From 2b948cf2343013b11632e37b8bf5b83638148acb Mon Sep 17 00:00:00 2001 From: Marcus Kernohan <135075821+mkernohanbc@users.noreply.github.com> Date: Thu, 19 Sep 2024 13:40:43 -0700 Subject: [PATCH] Add Modal, Dialog and AlertDialog components (#481) * scaffolding Dialog component * scaffolding Modal component * boilerplate stories * modal dialog example boilerplate * roughing out Dialog structure * fleshing out Modal/Dialog structure and styling * building out example Modal Dialog on vite * close button behaviour * overlay animation * forking AlertDialog component * alert dialog variants and theming * expose and set ARIA role * set default role to dialog instead of alertdialog * import modal props and update styling * stripping Dialog down to the studs * make left icon hideable * make close button toggleable on Dialog and AlertDialog * expose size controls for Modal container * simplifying generic Dialog structure * close button positioning in generic Dialog * sketching out stories * working on generic Dialog example * clean up Modal example * finishing up Modal and Dialog styling and behaviour * simplifying modal and dialog props and styling * cleaning up examples on vite * fleshing out stories for Modal, Dialog and AlertDialog * WIP dialogs docs * add AlertDialog docs and expand Dialogs docs * Dialog/Alert Dialog docs * remove redundant prop declaration from Dialog * Use ReactAriaComponents ModalOverlayProps * simplify modal structure and styling of modaloverlay * add width and z-index to Modal CSS * Add passing test for Modal * Add DialogRenderProps type information to Dialog to make Modal test pass * Add passing test for Dialog * Remove unused React import from AlertDialog, reorder SVG imports * Export DialogRenderProps interface from Dialog, use in AlertDialog * Add missing key props to kitchen sink InlineAlert buttons * Add missing label to kitchen sink TextField instance * Add passing test for AlertDialog * Dialog isCloseable defaults to true to match AlertDialog * Update Dialog tests for updated isCloseable prop default behavior * Remove non-functional onPress props from AlertDialog instances in kitchen sink app * Add key props to Button instances in AlertDialogs in kitchen sink app * Add key props to Button instances in AlertDialog stories * AlertDialogWithoutCloseButton story uses Modal isDismissable to close with click outside * Update AlertDialog interface comment for prop * Refactor AlertDialog to use children slot instead of description string * Flatten nested CSS rules for AlertDialog * Flatten nested CSS rules for Dialog * Use JS tokens for styles in Dialog stories * ModalOverlay animation added for fade-in and fade-out --------- Co-authored-by: Tyler Krys --- packages/react-components/src/App.tsx | 2 + .../components/AlertDialog/AlertDialog.css | 60 ++++++ .../AlertDialog/AlertDialog.test.tsx | 133 +++++++++++++ .../components/AlertDialog/AlertDialog.tsx | 112 +++++++++++ .../src/components/AlertDialog/index.ts | 2 + .../src/components/Dialog/Dialog.css | 22 ++ .../src/components/Dialog/Dialog.test.tsx | 85 ++++++++ .../src/components/Dialog/Dialog.tsx | 56 ++++++ .../src/components/Dialog/index.ts | 2 + .../src/components/Modal/Modal.css | 55 +++++ .../src/components/Modal/Modal.test.tsx | 29 +++ .../src/components/Modal/Modal.tsx | 14 ++ .../src/components/Modal/index.ts | 2 + .../react-components/src/components/index.ts | 7 +- .../src/pages/InlineAlert/InlineAlert.tsx | 12 +- .../src/pages/ModalDialog/ModalDialog.tsx | 188 ++++++++++++++++++ .../src/pages/ModalDialog/index.ts | 3 + .../src/pages/TextField/TextField.tsx | 2 +- packages/react-components/src/pages/index.ts | 2 + .../src/stories/AlertDialog.mdx | 96 +++++++++ .../src/stories/AlertDialog.stories.tsx | 170 ++++++++++++++++ .../src/stories/Dialog.stories.tsx | 119 +++++++++++ .../react-components/src/stories/Dialogs.mdx | 114 +++++++++++ .../src/stories/InlineAlert.mdx | 2 +- .../src/stories/Modal.stories.tsx | 89 +++++++++ 25 files changed, 1372 insertions(+), 6 deletions(-) create mode 100644 packages/react-components/src/components/AlertDialog/AlertDialog.css create mode 100644 packages/react-components/src/components/AlertDialog/AlertDialog.test.tsx create mode 100644 packages/react-components/src/components/AlertDialog/AlertDialog.tsx create mode 100644 packages/react-components/src/components/AlertDialog/index.ts create mode 100644 packages/react-components/src/components/Dialog/Dialog.css create mode 100644 packages/react-components/src/components/Dialog/Dialog.test.tsx create mode 100644 packages/react-components/src/components/Dialog/Dialog.tsx create mode 100644 packages/react-components/src/components/Dialog/index.ts create mode 100644 packages/react-components/src/components/Modal/Modal.css create mode 100644 packages/react-components/src/components/Modal/Modal.test.tsx create mode 100644 packages/react-components/src/components/Modal/Modal.tsx create mode 100644 packages/react-components/src/components/Modal/index.ts create mode 100644 packages/react-components/src/pages/ModalDialog/ModalDialog.tsx create mode 100644 packages/react-components/src/pages/ModalDialog/index.ts create mode 100644 packages/react-components/src/stories/AlertDialog.mdx create mode 100644 packages/react-components/src/stories/AlertDialog.stories.tsx create mode 100644 packages/react-components/src/stories/Dialog.stories.tsx create mode 100644 packages/react-components/src/stories/Dialogs.mdx create mode 100644 packages/react-components/src/stories/Modal.stories.tsx diff --git a/packages/react-components/src/App.tsx b/packages/react-components/src/App.tsx index e85ea989..498ead45 100644 --- a/packages/react-components/src/App.tsx +++ b/packages/react-components/src/App.tsx @@ -11,6 +11,7 @@ import { ButtonGroupPage, CheckboxGroupPage, InlineAlertPage, + ModalDialogPage, RadioGroupPage, SelectPage, TagGroupPage, @@ -155,6 +156,7 @@ function App() { + diff --git a/packages/react-components/src/components/AlertDialog/AlertDialog.css b/packages/react-components/src/components/AlertDialog/AlertDialog.css new file mode 100644 index 00000000..9fbdc1c4 --- /dev/null +++ b/packages/react-components/src/components/AlertDialog/AlertDialog.css @@ -0,0 +1,60 @@ +.bcds-react-aria-AlertDialog { + display: flex; + flex-direction: column; +} + +/* Variant icons */ +.bcds-react-aria-AlertDialog.info .bcds-react-aria-AlertDialog--Icon { + color: var(--icons-color-primary); +} +.bcds-react-aria-AlertDialog.confirmation .bcds-react-aria-AlertDialog--Icon { + color: var(--icons-color-success); +} +.bcds-react-aria-AlertDialog.warning .bcds-react-aria-AlertDialog--Icon { + color: var(--icons-color-warning); +} +.bcds-react-aria-AlertDialog.error .bcds-react-aria-AlertDialog--Icon { + color: var(--icons-color-danger); +} +.bcds-react-aria-AlertDialog.destructive .bcds-react-aria-AlertDialog--Icon { + color: var(--icons-color-danger); +} + +.bcds-react-aria-AlertDialog--Header { + display: inline-flex; + flex-direction: row; + gap: var(--layout-padding-small); + justify-content: space-between; + padding: var(--layout-padding-medium) var(--layout-padding-large); + border-bottom: var(--layout-border-width-small) solid + var(--surface-color-border-default); +} + +.bcds-react-aria-AlertDialog--Title { + flex-grow: 1; + font: var(--typography-bold-h5); + color: var(--typography-color-primary); +} + +.bcds-react-aria-AlertDialog--Icon { + justify-self: flex-start; + align-self: center; + padding-top: var(--layout-padding-xsmall); +} + +.bcds-react-aria-AlertDialog--closeIcon { + justify-self: flex-end; + color: var(--icons-color-primary); +} + +.bcds-react-aria-AlertDialog--children { + font: var(--typography-regular-body); + color: var(--typography-color-primary); + padding: var(--layout-padding-medium) var(--layout-padding-large); + border-bottom: var(--layout-border-width-small) solid + var(--surface-color-border-default); +} + +.bcds-react-aria-AlertDialog > .bcds-ButtonGroup { + padding: var(--layout-padding-medium) var(--layout-padding-large); +} diff --git a/packages/react-components/src/components/AlertDialog/AlertDialog.test.tsx b/packages/react-components/src/components/AlertDialog/AlertDialog.test.tsx new file mode 100644 index 00000000..42a59b21 --- /dev/null +++ b/packages/react-components/src/components/AlertDialog/AlertDialog.test.tsx @@ -0,0 +1,133 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; + +import AlertDialog from "./AlertDialog"; +import Button from "../Button"; +import { DialogTrigger } from "../Dialog"; +import Modal from "../Modal"; + +describe("AlertDialog", () => { + it("text within AlertDialog is not initially visible", () => { + render( + + + + Lorem ipsum + + + ); + + const text = screen.queryByText(/lorem ipsum/i); + expect(text).not.toBeInTheDocument(); + }); + + it("text within AlertDialog is visible after pressing open button", () => { + render( + + + + Lorem ipsum + + + ); + + const button = screen.getByText(/open/i); + fireEvent.click(button); + const text = screen.queryByText(/lorem ipsum/i); + expect(text).toBeInTheDocument(); + }); + + it("with `isCloseable` set to `false`, no close button is present", () => { + render( + + + + Lorem ipsum + + + ); + + const missingModalText = screen.queryByText(/lorem ipsum/i); + expect(missingModalText).not.toBeInTheDocument(); + + const openButton = screen.getByText(/open/i); + fireEvent.click(openButton); + const presentModalText = screen.queryByText(/lorem ipsum/i); + expect(presentModalText).toBeInTheDocument(); + + const closeButton = screen.queryByLabelText(/close/i); + expect(closeButton).not.toBeInTheDocument(); + }); + + it("by default, a close button is present and pushing it closes the AlertDialog", () => { + render( + + + + Lorem ipsum + + + ); + + const missingModalText = screen.queryByText(/lorem ipsum/i); + expect(missingModalText).not.toBeInTheDocument(); + + const openButton = screen.getByText(/open/i); + fireEvent.click(openButton); + const presentModalText = screen.queryByText(/lorem ipsum/i); + expect(presentModalText).toBeInTheDocument(); + + const closeButton = screen.getByLabelText(/close/i); + expect(closeButton).toBeInTheDocument(); + fireEvent.click(closeButton); + const dismissedModalText = screen.queryByText(/lorem ipsum/i); + expect(dismissedModalText).not.toBeInTheDocument(); + }); + + it("can be passed Button components that can be pressed when open", () => { + const handleCancel = jest.fn(); + const handleSubmit = jest.fn(); + render( + + + + + Cancel + , + , + ]} + > + Lorem ipsum + + + + ); + + const missingCancelButton = screen.queryByText(/cancel/i); + expect(missingCancelButton).not.toBeInTheDocument(); + const missingSubmitButton = screen.queryByText(/submit/i); + expect(missingSubmitButton).not.toBeInTheDocument(); + + const openButton = screen.getByText(/open/i); + fireEvent.click(openButton); + + const presentCancelButton = screen.getByText(/cancel/i); + const presentSubmitButton = screen.getByText(/submit/i); + fireEvent.click(presentCancelButton); + expect(handleCancel).toHaveBeenCalledTimes(1); + fireEvent.click(presentSubmitButton); + expect(handleSubmit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/react-components/src/components/AlertDialog/AlertDialog.tsx b/packages/react-components/src/components/AlertDialog/AlertDialog.tsx new file mode 100644 index 00000000..d371f93f --- /dev/null +++ b/packages/react-components/src/components/AlertDialog/AlertDialog.tsx @@ -0,0 +1,112 @@ +import { + Dialog as ReactAriaDialog, + DialogTrigger, + DialogProps as ReactAriaDialogProps, +} from "react-aria-components"; + +import Button from "../Button"; +import ButtonGroup from "../ButtonGroup"; +import { DialogRenderProps } from "../Dialog/Dialog"; +import SvgCheckCircleIcon from "../Icons/SvgCheckCircleIcon"; +import SvgCloseIcon from "../Icons/SvgCloseIcon"; +import SvgExclamationCircleIcon from "../Icons/SvgExclamationCircleIcon"; +import SvgExclamationIcon from "../Icons/SvgExclamationIcon"; +import SvgInfoIcon from "../Icons/SvgInfoIcon"; + +import "./AlertDialog.css"; + +export interface AlertDialogProps extends ReactAriaDialogProps { + /* Dialog theme */ + variant?: "info" | "confirmation" | "warning" | "error" | "destructive"; + /* Dialog title */ + title?: string; + /* Show or hide left icon */ + isIconHidden?: boolean; + /* Show or hide close button */ + isCloseable?: boolean; + /* Array of Button components */ + buttons?: React.ReactNode; +} + +/* Sets correct left icon for selected variant */ +function getIcon(variant: string) { + switch (variant) { + case "info": + return ; + case "confirmation": + return ; + case "warning": + return ; + case "error": + return ; + case "destructive": + return ; + default: + return; + } +} + +export default function AlertDialog({ + children, + variant = "info", + role = "dialog", + title, + isCloseable = true, + isIconHidden = false, + buttons, + ...props +}: AlertDialogProps) { + return ( + + {({ close }: DialogRenderProps) => ( + <> +
+ {!isIconHidden && ( +
+ {getIcon(variant)} +
+ )} + {title && ( +
+ {title} +
+ )} + {isCloseable && ( +
+ +
+ )} +
+ {children && ( +
+ <>{children} +
+ )} + {buttons && ( + + {buttons} + + )} + + )} +
+ ); +} + +export { DialogTrigger }; diff --git a/packages/react-components/src/components/AlertDialog/index.ts b/packages/react-components/src/components/AlertDialog/index.ts new file mode 100644 index 00000000..035da203 --- /dev/null +++ b/packages/react-components/src/components/AlertDialog/index.ts @@ -0,0 +1,2 @@ +export { default, DialogTrigger } from "./AlertDialog"; +export type { AlertDialogProps } from "./AlertDialog"; diff --git a/packages/react-components/src/components/Dialog/Dialog.css b/packages/react-components/src/components/Dialog/Dialog.css new file mode 100644 index 00000000..3e093a94 --- /dev/null +++ b/packages/react-components/src/components/Dialog/Dialog.css @@ -0,0 +1,22 @@ +.bcds-react-aria-Dialog { + display: flex; + flex-direction: column; + min-height: var(--layout-margin-xxxlarge); +} + +.bcds-react-aria-Dialog--Container { + position: relative; +} + +/* Close icon */ +.bcds-react-aria-Dialog--closeIcon { + color: var(--icons-color-primary); + position: absolute; + top: 0; + right: 0; + padding: var(--layout-padding-small); +} +.bcds-react-aria-Dialog--closeIcon > svg { + min-width: var(--icons-size-medium); + height: var(--icons-size-medium); +} diff --git a/packages/react-components/src/components/Dialog/Dialog.test.tsx b/packages/react-components/src/components/Dialog/Dialog.test.tsx new file mode 100644 index 00000000..d31a0d90 --- /dev/null +++ b/packages/react-components/src/components/Dialog/Dialog.test.tsx @@ -0,0 +1,85 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; + +import Button from "../Button"; +import Dialog, { DialogTrigger } from "./Dialog"; +import Modal from "../Modal"; + +describe("Dialog", () => { + it("text within Dialog is not initially visible", () => { + render( + + + + Lorem ipsum + + + ); + + const text = screen.queryByText(/lorem ipsum/i); + expect(text).not.toBeInTheDocument(); + }); + + it("text within Dialog is visible after pressing open button", () => { + render( + + + + Lorem ipsum + + + ); + + const button = screen.getByText(/open/i); + fireEvent.click(button); + const text = screen.queryByText(/lorem ipsum/i); + expect(text).toBeInTheDocument(); + }); + + it("with `isCloseable` set to `false`, no close button is present", () => { + render( + + + + Lorem ipsum + + + ); + + const missingModalText = screen.queryByText(/lorem ipsum/i); + expect(missingModalText).not.toBeInTheDocument(); + + const openButton = screen.getByText(/open/i); + fireEvent.click(openButton); + const presentModalText = screen.queryByText(/lorem ipsum/i); + expect(presentModalText).toBeInTheDocument(); + + const closeButton = screen.queryByLabelText(/close/i); + expect(closeButton).not.toBeInTheDocument(); + }); + + it("without `isCloseable` passed, close button appears by default, press closes the Dialog", () => { + render( + + + + Lorem ipsum + + + ); + + const missingModalText = screen.queryByText(/lorem ipsum/i); + expect(missingModalText).not.toBeInTheDocument(); + + const openButton = screen.getByText(/open/i); + fireEvent.click(openButton); + const presentModalText = screen.queryByText(/lorem ipsum/i); + expect(presentModalText).toBeInTheDocument(); + + const closeButton = screen.getByLabelText(/close/i); + expect(closeButton).toBeInTheDocument(); + fireEvent.click(closeButton); + const dismissedModalText = screen.queryByText(/lorem ipsum/i); + expect(dismissedModalText).not.toBeInTheDocument(); + }); +}); diff --git a/packages/react-components/src/components/Dialog/Dialog.tsx b/packages/react-components/src/components/Dialog/Dialog.tsx new file mode 100644 index 00000000..2949addf --- /dev/null +++ b/packages/react-components/src/components/Dialog/Dialog.tsx @@ -0,0 +1,56 @@ +import { + Dialog as ReactAriaDialog, + DialogTrigger, + DialogProps as ReactAriaDialogProps, +} from "react-aria-components"; + +import Button from "../Button"; +import SvgCloseIcon from "../Icons/SvgCloseIcon"; + +import "./Dialog.css"; + +export interface DialogProps extends ReactAriaDialogProps { + /* Show or hide close button */ + isCloseable?: boolean; +} + +// This is not currently exported by RAC but we need it to type `close`. +export interface DialogRenderProps { + /** Handler function to close the Dialog */ + close: () => void; +} + +export default function Dialog({ + isCloseable = true, + role = "dialog", + children, + ...props +}: DialogProps) { + return ( + + {({ close }: DialogRenderProps) => ( + <> +
+ {isCloseable && ( + + + + )} + <>{children} +
+ + )} +
+ ); +} + +export { DialogTrigger }; diff --git a/packages/react-components/src/components/Dialog/index.ts b/packages/react-components/src/components/Dialog/index.ts new file mode 100644 index 00000000..f8cf6089 --- /dev/null +++ b/packages/react-components/src/components/Dialog/index.ts @@ -0,0 +1,2 @@ +export { default, DialogTrigger } from "./Dialog"; +export type { DialogProps } from "./Dialog"; diff --git a/packages/react-components/src/components/Modal/Modal.css b/packages/react-components/src/components/Modal/Modal.css new file mode 100644 index 00000000..a6f01b37 --- /dev/null +++ b/packages/react-components/src/components/Modal/Modal.css @@ -0,0 +1,55 @@ +/* Styles for overlay element */ +.react-aria-ModalOverlay:has(.bcds-react-aria-Modal) { + position: fixed; + inset: 0; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: var(--layout-margin-small); + background: var(--surface-color-overlay-default); + height: 100vh; + width: 100vw; + z-index: 1000; +} +.react-aria-ModalOverlay:has(.bcds-react-aria-Modal)[data-entering] { + animation: modal-fade 200ms; +} +.react-aria-ModalOverlay:has(.bcds-react-aria-Modal)[data-exiting] { + animation: modal-fade 150ms reverse ease-in; +} +@media (prefers-reduced-motion) { + /* Don't animate modal fade in/out if prefers-reduced-motion is on */ + .react-aria-ModalOverlay:has(.bcds-react-aria-Modal)[data-entering] { + animation: unset; + } + .react-aria-ModalOverlay:has(.bcds-react-aria-Modal)[data-exiting] { + animation: unset; + } +} + +@keyframes modal-fade { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +/* Styles for content container */ +.bcds-react-aria-Modal { + box-sizing: border-box; + max-width: 100vw; + width: 600px; + max-height: 100vh; + min-height: var(--layout-margin-xxxlarge); + height: auto; + margin: var(--layout-margin-large); + border-radius: var(--layout-border-radius-medium); + border: var(--layout-border-width-small) solid + var(--surface-color-border-default); + background: var(--surface-color-forms-default); + box-shadow: var(--surface-shadow-large); +} diff --git a/packages/react-components/src/components/Modal/Modal.test.tsx b/packages/react-components/src/components/Modal/Modal.test.tsx new file mode 100644 index 00000000..ecc9dc3f --- /dev/null +++ b/packages/react-components/src/components/Modal/Modal.test.tsx @@ -0,0 +1,29 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; + +import Button from "../Button"; +import { DialogTrigger } from "../Dialog"; +import Modal from "./Modal"; + +describe("Modal", () => { + beforeEach(() => { + render( + + + Lorem ipsum + + ); + }); + + it("text within Modal is not initially visible", () => { + const text = screen.queryByText(/lorem ipsum/i); + expect(text).not.toBeInTheDocument(); + }); + + it("text within Modal is visible after pressing open button", () => { + const button = screen.getByText(/open/i); + fireEvent.click(button); + const text = screen.queryByText(/lorem ipsum/i); + expect(text).toBeInTheDocument(); + }); +}); diff --git a/packages/react-components/src/components/Modal/Modal.tsx b/packages/react-components/src/components/Modal/Modal.tsx new file mode 100644 index 00000000..627d5530 --- /dev/null +++ b/packages/react-components/src/components/Modal/Modal.tsx @@ -0,0 +1,14 @@ +import { + Modal as ReactAriaModal, + ModalOverlayProps, +} from "react-aria-components"; + +import "./Modal.css"; + +export default function Modal({ children, ...props }: ModalOverlayProps) { + return ( + + {children} + + ); +} diff --git a/packages/react-components/src/components/Modal/index.ts b/packages/react-components/src/components/Modal/index.ts new file mode 100644 index 00000000..815460fc --- /dev/null +++ b/packages/react-components/src/components/Modal/index.ts @@ -0,0 +1,2 @@ +export { default } from "./Modal"; +export type { ModalOverlayProps } from "react-aria-components"; diff --git a/packages/react-components/src/components/index.ts b/packages/react-components/src/components/index.ts index 743fcfd4..608849e2 100644 --- a/packages/react-components/src/components/index.ts +++ b/packages/react-components/src/components/index.ts @@ -1,13 +1,16 @@ import "@bcgov/design-tokens/css/variables.css"; -export { default as InlineAlert } from "./InlineAlert"; +export { default as AlertDialog } from "./AlertDialog"; export { default as Button } from "./Button"; export { default as ButtonGroup } from "./ButtonGroup"; export { default as Checkbox } from "./Checkbox"; export { default as CheckboxGroup } from "./CheckboxGroup"; -export { default as Header } from "./Header"; +export { default as Dialog, DialogTrigger } from "./Dialog"; export { default as Footer, FooterLinks } from "./Footer"; export { default as Form } from "./Form"; +export { default as Header } from "./Header"; +export { default as InlineAlert } from "./InlineAlert"; +export { default as Modal } from "./Modal"; export { default as Radio } from "./Radio"; export { default as RadioGroup } from "./RadioGroup"; export { default as Select } from "./Select"; diff --git a/packages/react-components/src/pages/InlineAlert/InlineAlert.tsx b/packages/react-components/src/pages/InlineAlert/InlineAlert.tsx index a2668306..17366d47 100644 --- a/packages/react-components/src/pages/InlineAlert/InlineAlert.tsx +++ b/packages/react-components/src/pages/InlineAlert/InlineAlert.tsx @@ -36,10 +36,18 @@ export default function InlineAlertPage() { title="This is an alert that also has additional actions" description="It uses button components to display additional important actions that the user can take." buttons={[ - , - , ]} diff --git a/packages/react-components/src/pages/ModalDialog/ModalDialog.tsx b/packages/react-components/src/pages/ModalDialog/ModalDialog.tsx new file mode 100644 index 00000000..e2d7ec12 --- /dev/null +++ b/packages/react-components/src/pages/ModalDialog/ModalDialog.tsx @@ -0,0 +1,188 @@ +import { + AlertDialog, + Button, + ButtonGroup, + Dialog, + DialogTrigger, + Form, + TextField, + Select, + Modal, +} from "@/components"; + +export default function ModalDialogPage() { + return ( + <> +

Alert Dialog

+

Info Alert Dialog

+ + + + + Cancel + , + , + ]} + > + It has some additional description text + + + +

Warning Alert Dialog

+ + + + + Cancel + , + , + ]} + > + It has some additional description text + + + +

Error Alert Dialog

+ + + + + Cancel + , + , + ]} + > + It has some additional description text + + + +

Generic Dialog

+

Default dialog

+ + + + + + +

Empty dialog (dismissable)

+ + + + + + +

Empty modal (closeable, not dismissable)

+ + + + + + +

Empty dialog (closeable and dismissable)

+ + + + + + +

Generic dialog with composed form

+ + + + +
+ + This dialog contains a form + +
+ + + + + + + + +
, + ], + }, + render: ({ ...args }: DialogProps) => ( + + + + + + + ), +}; diff --git a/packages/react-components/src/stories/Dialogs.mdx b/packages/react-components/src/stories/Dialogs.mdx new file mode 100644 index 00000000..964bd472 --- /dev/null +++ b/packages/react-components/src/stories/Dialogs.mdx @@ -0,0 +1,114 @@ +{/* Dialogs.mdx */} + +import { + Canvas, + Controls, + Meta, + Primary, + Source, + Story, + Subtitle, +} from "@storybook/blocks"; + +import * as AlertDialogStories from "./AlertDialog.stories"; +import * as ModalStories from "./Modal.stories"; +import * as DialogStories from "./Dialog.stories"; + + + +# Dialogs + + + Dialogs present important information to the user, and block interaction with + the rest of the interface until they take an action or dismiss the dialog. + + + + +## Usage and resources + +The B.C. Design System provides two methods to build an interruptive dialog: + +- [Dialog](#dialog): an empty container, in which you can assemble your own UI +- [Alert Dialog](#alert-dialog): a pre-composed dialog template, suitable for common error messages or notifications + +Learn more about working with the dialog components: + +- [Usage and best practice guidance](https://www2.gov.bc.ca/gov/content?id=6A0A247719CA42DDB9B8BAD47D46F69C) +- [View the button component in Figma](https://www2.gov.bc.ca/gov/content?id=8E36BE1D10E04A17B0CD4D913FA7AC43#designers) + +These components are based on these React Aria components: + +- [Modal](https://react-spectrum.adobe.com/react-aria/Modal.html) +- [Dialog](https://react-spectrum.adobe.com/react-aria/Dialog.html) + +Consult the React Aria documentation for additional technical information. + +### Anatomy + +To create a modal dialog in your UI, you need: + +- A `` element +- A trigger event or element (commonly a [button](?path=/docs/components-button-button--docs), positioned as the first child inside a ``) +- A [``](#modal), which creates a blocking overlay over the entire UI when activated +- A [``](#dialog), placed inside the `` and containing some content and/or components + +Structurally, these components are assembled like this: + +```javascript + + + + This dialog contains some content and/or components + + +``` + +## Modal + + + + +When triggered by a ``, a modal renders two components: + +- ``: a translucent overlay that covers the entire UI and blocks interaction until it is dismissed +- ``: a container element, positioned in the centre of the screen. Expects a [Dialog](#dialog) or [Alert Dialog](#alert-dialog) as children + +Use the `isDismissable` prop (defaults to `true`) to control whether the user can dismiss a modal by clicking outside the `` container. + +Use `isKeyboardDismissDisabled` (defaults to `false`) to control whether the user can dismiss a modal by pressing 'Escape'. + +### Controlled state + +Instead of using a trigger element inside a ``, you can also [use React Aria's APIs to make a modal's state controlled](https://react-spectrum.adobe.com/react-aria/Modal.html#controlled-open-state), and integrate it with your app's business logic and state management approach. + +### Styling + +You can pass a `style` object to a `Modal` to apply your own styling to the container element: + + + +## Dialog + + + + +`Dialog` is a generic content container, that can be used inside a `Modal` (or other patterns like [popovers](https://react-spectrum.adobe.com/react-aria/Popover.html).) + +You can pass a set of components to the `children` slot to compose your own dialog UI: + + + +### Styling + +You can pass a `style` object to a `Dialog` to apply custom styling and layout rules. You can also write additional CSS rules for the `.bcds-react-aria-Dialog--Container` class. + +## Alert Dialog + +An alert dialog is a pre-composed dialog template, suitable for common error messages or notifications that require the user to provide confirmation or take an action before proceeding. [View full docs for the Alert Dialog component.](?path=/docs/components-dialogs-alertdialog--docs) + + + diff --git a/packages/react-components/src/stories/InlineAlert.mdx b/packages/react-components/src/stories/InlineAlert.mdx index bc9d4045..4e772b4b 100644 --- a/packages/react-components/src/stories/InlineAlert.mdx +++ b/packages/react-components/src/stories/InlineAlert.mdx @@ -72,7 +72,7 @@ Use the `title` and optional `description` props to populate an alert's content. -Use the `buttons` prop to pass an array of button components, which are rendered inside a [ButtonGroup](/docs/components-button-button-group--docs): +Use the `buttons` prop to pass an array of button components, which are rendered inside a [ButtonGroup](?path=/docs/components-button-buttongroup--docs): diff --git a/packages/react-components/src/stories/Modal.stories.tsx b/packages/react-components/src/stories/Modal.stories.tsx new file mode 100644 index 00000000..6a890fa5 --- /dev/null +++ b/packages/react-components/src/stories/Modal.stories.tsx @@ -0,0 +1,89 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { Button, Modal, DialogTrigger } from "../components"; +import type { ModalOverlayProps } from "react-aria-components"; + +const meta = { + title: "Components/Dialogs/Modal", + component: Modal, + parameters: { + layout: "centered", + }, + argTypes: { + isDismissable: { + control: { type: "boolean" }, + description: "Whether to close the modal when user interacts outside it", + }, + isKeyboardDismissDisabled: { + control: { type: "boolean" }, + description: + "Whether pressing the escape key to close the modal should be disabled", + }, + isOpen: { + control: { type: "boolean" }, + description: "Whether the overlay is open by default (controlled)", + }, + defaultOpen: { + control: { type: "boolean" }, + description: "Whether the overlay is open by default (uncontrolled)", + }, + }, + args: {}, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const ModalTemplate: Story = { + args: { isDismissable: true }, + render: ({ ...args }: ModalOverlayProps) => ( + + + + + ), +}; + +export const DefaultOpenModal: Story = { + ...ModalTemplate, + args: { + defaultOpen: true, + }, + render: ({ ...args }: ModalOverlayProps) => ( + + + +
+ This modal is open by default +
+
+
+ ), +}; + +export const CustomSizeModal: Story = { + args: { isDismissable: true }, + render: ({ ...args }: ModalOverlayProps) => ( + + + + This modal has custom width and height rules applied + + + ), +};