From d14c963f9e94454982afcdc5b0ab90f484e47834 Mon Sep 17 00:00:00 2001 From: Andrew Holloway Date: Wed, 27 Mar 2024 14:05:45 -0500 Subject: [PATCH] feat(Modal)!: introduce 2.0 component (#1907) - add in style changes for both Modal and dependent `ButtonGroup` - simplify interface and update with new semantics - update test snapshots for chromatic --- .../ButtonGroup/ButtonGroup-v2.module.css | 28 + .../ButtonGroup/ButtonGroup-v2.stories.tsx | 60 +++ src/components/ButtonGroup/ButtonGroup-v2.tsx | 43 ++ src/components/ButtonGroup/index.ts | 1 + src/components/Modal/Modal-v2.module.css | 212 ++++++++ src/components/Modal/Modal-v2.stories.tsx | 478 ++++++++++++++++++ src/components/Modal/Modal-v2.tsx | 434 ++++++++++++++++ src/components/Modal/index.ts | 1 + 8 files changed, 1257 insertions(+) create mode 100644 src/components/ButtonGroup/ButtonGroup-v2.module.css create mode 100644 src/components/ButtonGroup/ButtonGroup-v2.stories.tsx create mode 100644 src/components/ButtonGroup/ButtonGroup-v2.tsx create mode 100755 src/components/Modal/Modal-v2.module.css create mode 100644 src/components/Modal/Modal-v2.stories.tsx create mode 100644 src/components/Modal/Modal-v2.tsx diff --git a/src/components/ButtonGroup/ButtonGroup-v2.module.css b/src/components/ButtonGroup/ButtonGroup-v2.module.css new file mode 100644 index 000000000..630c4e430 --- /dev/null +++ b/src/components/ButtonGroup/ButtonGroup-v2.module.css @@ -0,0 +1,28 @@ +/*------------------------------------*\ + # BUTTON GROUP +\*------------------------------------*/ + +/** + * A group of buttons displayed in an organized fashion. + */ +.button-group { + display: flex; + flex-wrap: wrap; + justify-content: flex-start; + + gap: 0.5rem; +} + +.button-group--horizontal { + flex-direction: row-reverse; +} + +.button-group--vertical { + flex-direction: column; + align-content: center; +} + +.button-group--horizontal-progressive { + flex-direction: row-reverse; + justify-content: space-between; +} \ No newline at end of file diff --git a/src/components/ButtonGroup/ButtonGroup-v2.stories.tsx b/src/components/ButtonGroup/ButtonGroup-v2.stories.tsx new file mode 100644 index 000000000..6d4701170 --- /dev/null +++ b/src/components/ButtonGroup/ButtonGroup-v2.stories.tsx @@ -0,0 +1,60 @@ +import type { StoryObj, Meta } from '@storybook/react'; +import React from 'react'; + +import { ButtonGroup } from './ButtonGroup-v2'; +import { ButtonV2 as Button } from '../Button'; + +export default { + title: 'Components/V2/ButtonGroup', + component: ButtonGroup, + args: { + orientation: 'horizontal', + children: ( + <> + + + + ), + }, + argTypes: { + children: { + control: { + type: null, + }, + }, + }, + parameters: { + badges: ['intro-1.0', 'current-2.0'], + }, + decorators: [(Story) =>
{Story()}
], +} as Meta; + +type Args = React.ComponentProps; + +export const Default: StoryObj = {}; + +export const Vertical: StoryObj = { + args: { + buttonLayout: 'vertical', + }, +}; + +export const HorizontalProgressive: StoryObj = { + args: { + buttonLayout: 'horizontal-progressive', + }, +}; + +export const WithFiveButtons: StoryObj = { + args: { + children: ( + <> + + + + + + + ), + }, +}; diff --git a/src/components/ButtonGroup/ButtonGroup-v2.tsx b/src/components/ButtonGroup/ButtonGroup-v2.tsx new file mode 100644 index 000000000..679010ae0 --- /dev/null +++ b/src/components/ButtonGroup/ButtonGroup-v2.tsx @@ -0,0 +1,43 @@ +import clsx from 'clsx'; +import type { ReactNode } from 'react'; +import React from 'react'; + +import styles from './ButtonGroup-v2.module.css'; + +type ButtonGroupProps = { + // Component API + /** + * The buttons. Should not be wrapped in another element – we just want the buttons. + */ + children: ReactNode; + /** + * Additional classnames passed in for styling. + * + * This will be applied to the container we're placing around the buttons. + */ + className?: string; + // Design API + /** + * Whether the buttons should be laid out horizontally or stacked vertically (along with relative button position). + */ + buttonLayout?: 'horizontal' | 'vertical' | 'horizontal-progressive'; +}; + +/** + * `import {ButtonGroup} from "@chanzuckerberg/eds";` + * + * A container for buttons grouped together horizontally or vertically. + */ +export function ButtonGroup({ + children, + className, + buttonLayout = 'horizontal', +}: ButtonGroupProps) { + const componentClassName = clsx( + styles['button-group'], + buttonLayout && styles[`button-group--${buttonLayout}`], + className, + ); + + return
{children}
; +} diff --git a/src/components/ButtonGroup/index.ts b/src/components/ButtonGroup/index.ts index 6c6477b12..02bd9b97a 100644 --- a/src/components/ButtonGroup/index.ts +++ b/src/components/ButtonGroup/index.ts @@ -1 +1,2 @@ export { ButtonGroup as default } from './ButtonGroup'; +export { ButtonGroup as ButtonGroupV2 } from './ButtonGroup-v2'; diff --git a/src/components/Modal/Modal-v2.module.css b/src/components/Modal/Modal-v2.module.css new file mode 100755 index 000000000..95badd40a --- /dev/null +++ b/src/components/Modal/Modal-v2.module.css @@ -0,0 +1,212 @@ +@import '../../design-tokens/mixins.css'; + +/*------------------------------------*\ + # MODAL +\*------------------------------------*/ + +/** + * The modal wrapper and overlay which takes up the entire screen. + */ +.modal, +.modal__overlay { + position: fixed; + top: 0; + left: 0; + height: 100vh; /* TODO-AH: make sure this doesn't spill out of the container */ + width: 100%; +} + +/** + * The inverted background of the modal to provide contrast with the actual modal. + */ +.modal__overlay { + /* TODO-AH: opacity of color based on 50% */ + background-color: var(--eds-theme-color-background-utility-overlay-low-emphasis); + opacity: 0.5; +} + +/** + * The modal container which positions the modal in the center of the screen. + */ +.modal { + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + + /** + * Ensures modal is above other components. This is not a design token for now since we need to align on + * z-indeces across the system + */ + z-index: 1050; +} + +/** + * Modal transition animations. + */ +.modal__transition--enter { + transition: opacity var(--eds-anim-fade-long) var(--eds-anim-ease); + @media (prefers-reduced-motion) { + transition: none; + } +} + +.modal__transition--enterFrom { + opacity: 0; +} + +.modal__transition--enterTo { + opacity: 1; +} + +.modal__transition--leave { + transition: opacity var(--eds-anim-fade-long) var(--eds-anim-ease); + @media (prefers-reduced-motion) { + transition: none; + } +} + +.modal__transition--leaveFrom { + opacity: 1; +} + +.modal__transition--leaveTo { + opacity: 0; +} + +/** + * The content of the modal, which can wrap header, body, and footer. + */ +.modal__content { + position: relative; + height: 43.125rem; + max-height: 90vh; + overflow: hidden; + + /** + * This transparent border is for Window High Contrast Mode, which removes all + * background colors but makes borders 100% opacity black. Without this, the + * modal would have no clear boundary. + */ + border: var(--eds-theme-form-border-width) transparent var(--eds-theme-color-background-utility-container); + + display: flex; + flex-direction: column; + + width: 22.5rem; + + background-color: var(--eds-theme-color-background-utility-container); +} + +/** + * The medium modal size used for the md modal. + * Also used for the lg modal size for when the screen size is smaller than 75rem. + */ +.modal__content--md { + @media all and (min-width: $eds-bp-md) { + width: 42rem; + } +} + +/** + * The large modal size used for the lg/default modal. + */ +.modal__content--lg { + @media all and (min-width: $eds-bp-xl) { + width: 64rem; + --modal-horizontal-padding: 4rem; + } +} + +/** + * Allows scrolling of the modal content except for sticky footer. + * This functionality is our intended scroll behavior but consuming teams can + * style the body content as they wish to handle overflow in various ways. + */ +.modal__content--scrollable { + overflow: auto; +} + +/** + * The modal close button. + * TODO-AH: this should be a `Button` + */ +.modal__close-button { + border: 0; + background-color: transparent; + + position: absolute; + top: 0; + right: 0; + + width: 3rem; + height: 3rem; + + cursor: pointer; + + z-index: 1; + + color: var(--eds-theme-color-text-utility-default-secondary); +} + +/** + * The modal close icon that resides in the close button. + */ +.modal__close-icon { + position: absolute; + top: 0.5rem; + right: 0.5rem; +} + +/*------------------------------------*\ + # MODAL BODY +\*------------------------------------*/ + +/** + * The body of the modal + */ + .modal-body { + flex: 1; + padding: 0 2rem; +} + +/*------------------------------------*\ + # MODAL FOOTER +\*------------------------------------*/ + +/** + * Footer for the modal. + */ + .modal-footer { + width: 100%; + z-index: 1000; + + padding: 1.5rem 2rem; + + background-color: var(--eds-theme-color-background-utility-container); +} + +.modal-footer--sticky { + position: sticky; + bottom: 0; + + /* TODO-AH: bring in scrollwrapper to handle show/hide of visible elevation (and which box shadow to apply) */ + box-shadow: var(--eds-box-shadow-xl); +} + +/*------------------------------------*\ + # MODAL HEADER +\*------------------------------------*/ + +/** + * Header for the modal. + */ + .modal-header { + width: 100%; + + padding: 1.5rem 2rem; +} + +.modal-sub-title { + color: var(--eds-theme-color-text-utility-default-secondary); +} \ No newline at end of file diff --git a/src/components/Modal/Modal-v2.stories.tsx b/src/components/Modal/Modal-v2.stories.tsx new file mode 100644 index 000000000..6d15715d3 --- /dev/null +++ b/src/components/Modal/Modal-v2.stories.tsx @@ -0,0 +1,478 @@ +import type { StoryObj, Meta } from '@storybook/react'; +import React from 'react'; +import { useState } from 'react'; + +import { Modal, ModalContent } from './Modal-v2'; +import { Heading, Text } from '../../'; +import { chromaticViewports, storybookViewports } from '../../util/viewports'; +import { ButtonV2 as Button } from '../Button'; +import { ButtonGroupV2 as ButtonGroup } from '../ButtonGroup'; +import { TooltipV2 as Tooltip } from '../Tooltip'; + +export default { + title: 'Components/V2/Modal', + component: Modal, + parameters: { + // The modal is initially closed for most of these stories, + // which renders testing it for visual regressions unhelpful. + chromatic: { disableSnapshot: true }, + badges: ['intro-1.0', 'current-2.0'], + }, + tags: ['autodocs'], + argTypes: { + // For some reason, storybook is not able to pick up the doc.s automatically. Adding manually. + children: { + control: { + type: null, + }, + description: + 'Contains the sub-components for a Modal, including `.Header` , `.Title` , `.Body` , `.Footer` , `.Stepper`', + }, + open: { + type: 'boolean', + description: 'Whether or not the modal is visible.', + }, + hideCloseButton: { + description: + 'Hides the close button in the top right of the modal. **Default is `false`**.', + type: 'boolean', + }, + isScrollable: { + description: + 'Toggles scrollable variant of the modal. If modal is scrollable, footer is not, and vice versa.', + type: 'boolean', + }, + }, + decorators: [(Story) =>
{Story()}
], +} as Meta; + +type Args = React.ComponentProps; +type Story = StoryObj; +type InteractiveArgs = Omit; + +/** + * Wrapper for triggering `Modal` with a button + * @param args + * @returns + */ +function InteractiveExample(args: InteractiveArgs) { + const [open, setOpen] = useState(false); + + return ( + <> + + + setOpen(false)} open={open} /> + + ); +} + +/** + * Clicking on a trigger item (in this case `Button`) will cause a modal to open. + * + * **Note**: this only works from certain screens in Storybook. If it doesn't work as expected, view from the + * "docs" sub-page. + */ +export const Default: StoryObj = { + render: (args) => ( + + + Modal Title + Modal Sub-title + + +
Modal Content
+
+ + + {/* This has to be manually tested since Tooltip tests are flaky in Chromatic */} + + + + + + +
+ ), +}; + +/** + * Modals can contain long, scrollable text. This is not recommended, however. + */ +export const WithLongTextScrollable: StoryObj = { + args: { + isScrollable: true, + }, + render: (args) => ( + + + Modal Title + Modal Sub-title + + + Title text + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc aliquam + diam quis dolor maximus, non tincidunt lacus facilisis. Praesent ac + vestibulum diam. Sed ac orci fringilla, ullamcorper quam nec, + elementum turpis. Orci varius natoque penatibus et magnis dis + parturient montes, nascetur ridiculus mus. Curabitur et vulputate leo. + Phasellus convallis ante at augue iaculis, quis consectetur dolor + placerat. Nulla ornare malesuada elit eu faucibus. Mauris ultricies + tincidunt eleifend. Aliquam erat volutpat. Morbi nec ipsum sed sem + facilisis tristique nec et ligula. Vivamus id feugiat sapien. + + Title text + + Nam ac tincidunt arcu. Nam non metus sem. Morbi eleifend metus vel + venenatis accumsan. Vestibulum pharetra, ante quis sollicitudin + aliquam, orci ex pretium ipsum, congue rhoncus dui orci sed velit. + Cras ac leo vel massa rutrum auctor eget sed orci. Aenean id nisi + consectetur, dapibus tellus ut, finibus metus. Integer tristique est + vitae lectus suscipit, ut vulputate est fringilla. Donec pharetra + facilisis erat at venenatis. Etiam faucibus dignissim leo eget congue. + Sed vehicula imperdiet neque id gravida. Proin volutpat tortor quis + quam molestie, faucibus condimentum ante sagittis. Suspendisse sit + amet luctus tellus. Suspendisse a sapien hendrerit eros dictum + faucibus. Vivamus pretium vel sem faucibus tristique. Integer iaculis + pellentesque nunc ac pellentesque. + + Title text + + Integer mollis, urna eget sollicitudin laoreet, nunc elit facilisis + urna, ut finibus est mi eu quam. Nam ac venenatis massa. Vestibulum + suscipit ac ligula venenatis scelerisque. Fusce rutrum nulla lectus, + sed dignissim ipsum faucibus sodales. Suspendisse id aliquet quam. + Maecenas facilisis mauris dolor, id accumsan ex vehicula in. In nisl + ligula, fringilla in enim nec, lobortis sollicitudin purus. Aliquam + vehicula euismod enim quis finibus. Fusce ornare tortor malesuada, + consequat magna quis, porta dolor. Donec quis nisl ac sem dictum + semper quis vel turpis. Proin sed leo in ante rhoncus pellentesque a + eu urna. Phasellus consequat lectus et hendrerit luctus. Lorem ipsum + dolor sit amet, consectetur adipiscing elit. + + Title text + + Suspendisse vitae eros elit. Maecenas id urna tempus, tempus turpis + id, blandit turpis. Fusce augue quam, pellentesque et suscipit + consectetur, pharetra in mi. Suspendisse non ultricies purus. Integer + dignissim condimentum sem ac porta. Vivamus viverra congue massa, + vitae fermentum est scelerisque et. Duis a urna vitae odio semper + dictum a sed nisl. In fringilla hendrerit massa, at luctus arcu + tincidunt nec. Donec gravida, mauris sit amet porta lobortis, justo + sem vehicula ipsum, at pretium sem leo sed libero. Morbi tristique + rhoncus suscipit. Nullam at malesuada sapien. Nam in egestas tellus. + Nulla quis metus dui. Suspendisse sit amet nisi at lectus ultricies + egestas. + + Title text + + Integer pulvinar felis sit amet dignissim fermentum. Nulla sodales + enim mi, varius feugiat sapien congue eget. Morbi vitae ipsum non + ligula eleifend molestie. Aenean bibendum tortor sapien, quis volutpat + ante ultricies id. Morbi varius dolor ac ante posuere, sit amet + tincidunt lectus pulvinar. Proin id efficitur neque. Nullam vel + feugiat dui. Curabitur imperdiet lacinia eros, ac iaculis odio. + Praesent quis pretium sapien, quis posuere lectus. Proin eleifend + purus nec massa aliquam commodo. Quisque auctor suscipit ex sed + tristique. Sed eget ultrices est. Suspendisse nunc justo, dapibus at + eros ac, rutrum vestibulum est. + + + + + {/* This has to be manually tested since Tooltip tests are flaky in Chromatic */} + + + + + + + + ), +}; + +/** + * The default modal experience's Content area (the modal itself). The following stories show how the modal + * will render in various contexts and with various props set. + * + * When viewing code, This will use `
` directly, to demonstrate composition. When building `Modal`s, use + * `Modal.Title` instead. + * + * **NOTE**: The order of the buttons puts the primary to the far bottom and right of the modal, per + * macOS conventions and style guide. + */ +export const ContentDefault: Story = { + render: (args) => ( +
+
+ {}} + /> +
+ ), + args: { + children: ( + <> + + Modal Title + Modal Sub-title + + +
Modal Content
+
+ + + {/* This has to be manually tested since Tooltip tests are flaky in Chromatic */} + + + + + + + + ), + hideCloseButton: false, + open: true, + }, + parameters: { + // This story shows the modal content by default, for visual regression testing purposes. + chromatic: { disableSnapshot: false }, + }, +}; + +/** + * `Modal` provides `size`, which allows control over the natural width of the modal. This does not affect the contents + * of the modal. + */ +export const Medium: Story = { + ...ContentDefault, + args: { + ...ContentDefault.args, + size: 'md', + }, +}; + +/** + * `Modal` also allows for `small`. + */ +export const Small: Story = { + ...ContentDefault, + args: { + ...ContentDefault.args, + size: 'sm', + }, +}; + +/** + * Buttons can have a vertical alignment in the footer. For this, use `ButtonGroup` with the `buttonLayout` = `vertical`. + * Make sure to match the Button width style by using `isFullWidth`. + */ +export const LayoutVertical: Story = { + ...ContentDefault, + args: { + ...ContentDefault.args, + size: 'md', + children: ( + <> + + Modal Title + Modal Sub-title + + +
Modal Content
+
+ + + {/* This has to be manually tested since Tooltip tests are flaky in Chromatic */} + + + + + + + + ), + }, + render: (args) => ( +
+
+ {}} + /> +
+ ), +}; + +/** + * Buttons can have a vertical alignment in the footer. For this, use `ButtonGroup` with the `buttonLayout` = `vertical`. + * Make sure to match the Button width style by using `isFullWidth`. + */ +export const LayoutVerticalWithTertiary: Story = { + ...ContentDefault, + args: { + ...ContentDefault.args, + size: 'md', + children: ( + <> + + Modal Title + Modal Sub-title + + +
Modal Content
+
+ + + {/* This has to be manually tested since Tooltip tests are flaky in Chromatic */} + + + + + + + + ), + }, + render: (args) => ( +
+
+ {}} + /> +
+ ), +}; + +/** + * Modals can have destructive behavior. Use the 'critical' button variant. + */ +export const WithCriticalButton: Story = { + ...ContentDefault, + args: { + ...ContentDefault.args, + size: 'md', + children: ( + <> + + Modal Title + Modal Sub-title + + +
Modal Content
+
+ + + {/* This has to be manually tested since Tooltip tests are flaky in Chromatic */} + + + + + + + ), + }, + render: (args) => ( +
+
+ {}} + /> +
+ ), +}; + +/** + * This shows the responsive layout in a mobile viewport + */ +export const Mobile: Story = { + ...ContentDefault, + parameters: { + ...ContentDefault.parameters, + viewport: { + defaultViewport: 'googlePixel2', + }, + chromatic: { + disableSnapshot: false, + viewports: [chromaticViewports.googlePixel2], + }, + }, +}; + +/** + * This shows the responsive layout in a mobile (landscape) viewport + */ +export const MobileLandscape: Story = { + ...ContentDefault, + parameters: { + ...ContentDefault.parameters, + viewport: { + defaultViewport: 'mobilelandscape', + viewports: { + mobilelandscape: { + name: 'Mobile Landscape', + styles: { + width: '896px', + height: '414px', + }, + }, + }, + /** + * Chromatic sets viewport height to 900px, hence won't snap as necessary + */ + chromatic: { disableSnapshot: true }, + }, + }, +}; + +/** + * This shows the responsive layout in a tablet viewport + */ +export const Tablet: Story = { + ...ContentDefault, + parameters: { + ...ContentDefault.parameters, + viewport: { + defaultViewport: 'ipadMini', + viewports: { + mobilelandscape: storybookViewports.ipadMini, + }, + }, + chromatic: { + disableSnapshot: false, + viewports: [chromaticViewports.ipadMini], + }, + }, +}; diff --git a/src/components/Modal/Modal-v2.tsx b/src/components/Modal/Modal-v2.tsx new file mode 100644 index 000000000..72be4dbd8 --- /dev/null +++ b/src/components/Modal/Modal-v2.tsx @@ -0,0 +1,434 @@ +import { Dialog, Transition } from '@headlessui/react'; +import clsx from 'clsx'; +import type { MutableRefObject, ReactNode } from 'react'; +import React from 'react'; + +import type { ExtractProps } from '../../util/utility-types'; +import type { Size } from '../../util/variant-types'; +import Heading from '../Heading'; +import { Icon } from '../Icon/Icon'; +import Text from '../Text'; + +import styles from './Modal-v2.module.css'; + +type ModalContentProps = { + // Component API + /** + * Optional aria-label for the modal. + * + * If undefined, the headingText of the Modal.Header will be used. + * If there is no Modal.Header, an aria-label is required. + */ + 'aria-label'?: string; + /** + * Additional classnames passed in for styling. + */ + className?: string; + /** + * Contents of the modal. + */ + children: ReactNode; + /** + * Hides the close button in the top right of the modal. + * + * **Default is `false`**. + */ + hideCloseButton?: boolean; + /** + * A ref to an element that should receive focus when the modal first opens. + * + * If undefined, the first focusable element (usually the close button) will be used. + * + * ``` + * const inputFieldRef = useRef(); + * + * + * ... + * + * + * ``` + */ + initialFocus?: MutableRefObject; + /** + * Toggles scrollable variant of the modal. If modal is scrollable, footer is not, and vice versa. + * Also adds border and shadow to the footer indicate sticky status and tabindex to body for keyboard scrolling. + * Prop should be dependent on whether content overflows at the mobile level. + * Tabindex for keyboard scroll is on the body, however, due to focus outline + * not having high contrast on the brand header and being overlapped by the footer. + * + * **Default is `false`**. + */ + isScrollable?: boolean; + /** + * Method called when the close button is clicked. Use this to hide the modal. + * This should be used to also reset the `open` state. + * @see https://headlessui.com/react/dialog + * + * This is required even if you don't have a close button so the ESC key can close the modal. + * + * ``` + * const [isOpen, setIsOpen] = useState(true); + * // .... + * + * setIsOpen(false)}> + * ... + * + * ``` + */ + onClose: () => void; + // Design API + /** + * Max size of the modal. Defaults to 'lg'. + * Will still break responsively. + * + * **Default is `"lg"`**. + */ + size?: Extract; +}; + +type ModalProps = ModalContentProps & { + /** + * Whether or not the modal is visible. Recommend using `useState` to set this + * variable instead of a boolean literal, to avoid component control issues. + * @see https://headlessui.com/react/dialog + * + * ``` + * const [isOpen, setIsOpen] = useState(true); + * // .... + * + * + * ... + * + * ``` + */ + open: boolean; + /** + * Additional classnames passed in for the modal container. + */ + modalContainerClassName?: string; +}; + +type ModalTitleProps = ExtractProps & { + // Component API + /** + * Contents for the modal title. + */ + children: ReactNode; + /** + * CSS class names that can be appended to the component. + */ + className?: string; +}; + +type ModalSubTitleProps = ExtractProps & { + // Component API + /** + * Contents for the modal title. + */ + children: ReactNode; + /** + * CSS class names that can be appended to the component. + */ + className?: string; +}; + +type ModalBodyProps = { + // Component API + /** + * Child node(s) that can be nested inside component. `Modal.Header`, + * `Modal.Body`, and `Model.Footer` are the only permissible children of the Modal. + */ + children: ReactNode; + /** + * CSS class names that can be appended to the component. + */ + className?: string; + // Design API + /** + * Toggles focusable variant of the modal. Used to attach a tabIndex for keyboard scrolling + * and focus indicator outline. + * Scrolling functionality exists on Modal since the header also needs to scroll. + * Defaults to false since modal default is not scrollable. + */ + isFocusable?: boolean; +}; + +type ModalHeaderProps = { + // Component API + /** + * Child node(s) to place inside the Modal header. + * Should include the + */ + children: ReactNode; + /** + * CSS class names that can be appended to the component. + */ + className?: string; + // Design API +}; + +type ModalFooterProps = { + // Component API + /** + * Child node(s) to place inside the Modal footer. + */ + children: ReactNode; + /** + * CSS class names that can be appended to the component. + */ + className?: string; + // Design API + /** + * Toggles sticky variant of the footer. If modal is scrollable, footer is sticky. + * Also adds border and shadow to indicate sticky status. + * Defaults to false since modal default is not scrollable. + */ + isSticky?: boolean; +}; + +type Context = { + isScrollable?: boolean; +}; + +const ModalContext = React.createContext({}); + +function childrenHaveModalTitle(children?: ReactNode): boolean { + const childrenArray = React.Children.toArray(children); + return childrenArray.some((child) => { + if (typeof child === 'string' || typeof child === 'number') { + return false; + } else if ( + 'props' in child && + child.type && + typeof child.type !== 'string' && + (child.type?.name === 'ModalTitle' || child.type?.name === 'Modal.Title') + ) { + return true; + } else if ('props' in child && child.props.children) { + return childrenHaveModalTitle(child.props.children); + } + return false; + }); +} + +/** + * The actual modal, without the dark overlay behind it. + * + * This is only exported for testing purposes; please do not import and use this directly. + */ +export const ModalContent = (props: ModalContentProps) => { + const { + children, + className, + hideCloseButton = false, + isScrollable, + onClose, + size = 'lg', + ...other + } = props; + + const componentClassName = clsx( + styles['modal__content'], + isScrollable && styles['modal__content--scrollable'], + (size === 'md' || size === 'lg') && styles['modal__content--md'], + size === 'lg' && styles['modal__content--lg'], + className, + ); + + const closeIconClassName = clsx(styles['modal__close-icon']); + + return ( + +
+ {!hideCloseButton && ( + + )} + + {children} +
+
+ ); +}; + +/** + * `import {Modal} from "@chanzuckerberg/eds";` + * + * EDS Wrapper for the HeadlessUI Dialog component + * @see https://headlessui.dev/react/dialog + * + * NOTE: You must have at least one focusable element in the modal contents, for keyboard + * accessibility reasons. (The close button counts as a focusable element.) Use `initialFocus` + * to choose a different element. + * + */ +export const Modal = (props: ModalProps) => { + const { + 'aria-label': ariaLabel, + initialFocus, + modalContainerClassName, + onClose, + open, + ...rest + } = props; + const { children } = rest; + + if (process.env.NODE_ENV !== 'production') { + const hasModalTitle = childrenHaveModalTitle(children); + if (!hasModalTitle && !ariaLabel) { + throw new Error( + "You must use the Modal.Title helper component or pass in an aria-label when using the Modal. The Modal uses the Modal.Title to describe the modal to screen readers using aria-labelledby. If you're not using the Modal.Title component, you can pass in an aria-label instead.", + ); + } + } + + const componentClassName = clsx(styles['modal'], modalContainerClassName); + + return ( + + + + + + + + ); +}; + +/** + * Component defines the body of the modal. + */ +const ModalBody = ({ + children, + className, + isFocusable, + ...other +}: ModalBodyProps) => ( +
+ {children} +
+); + +/** + * Component defines the Footer section of the modal. + */ +const ModalFooter = ({ + children, + className, + isSticky = false, + ...other +}: ModalFooterProps) => { + return ( +
+ {children} +
+ ); +}; + +/** + * Component defines the Header section of the modal. + */ +const ModalHeader = ({ children, className, ...other }: ModalHeaderProps) => { + const componentClassName = clsx(styles['modal-header'], className); + return ( +
+ {children} +
+ ); +}; + +/** + * Component defines the Title section of the modal. + */ +const ModalTitle = ({ + children, + className, + preset = 'headline-md', + ...other +}: ModalTitleProps) => ( + + + {children} + + +); + +const ModalSubTitle = ({ + children, + className, + preset = 'headline-sm', + ...other +}: ModalSubTitleProps) => { + const componentClassName = clsx(styles['modal-sub-title'], className); + return ( + + {children} + + ); +}; + +/** + * Variations of the subcomponent to pass props from parent Modal component. + * Same prop passed directly to subcomponent has priority over prop passed from Modal component. + */ +const VariantModalHeader = (props: ModalHeaderProps) => { + return ; +}; + +const FocusableModalBody = (props: ModalBodyProps) => { + const { isScrollable } = React.useContext(ModalContext); + return ; +}; + +const StickyModalFooter = (props: ModalFooterProps) => { + const { isScrollable } = React.useContext(ModalContext); + return ; +}; + +Modal.displayName = 'Modal'; +VariantModalHeader.displayName = 'Modal.Header'; +ModalTitle.displayName = 'Modal.Title'; +ModalSubTitle.displayName = 'Modal.SubTitle'; +FocusableModalBody.displayName = 'Modal.Body'; +StickyModalFooter.displayName = 'Modal.Footer'; + +Modal.Header = VariantModalHeader; +Modal.Title = ModalTitle; +Modal.SubTitle = ModalSubTitle; +Modal.Body = FocusableModalBody; +Modal.Footer = StickyModalFooter; diff --git a/src/components/Modal/index.ts b/src/components/Modal/index.ts index 48f35c717..2736f369c 100644 --- a/src/components/Modal/index.ts +++ b/src/components/Modal/index.ts @@ -1 +1,2 @@ export { Modal as default } from './Modal'; +export { Modal as ModalV2 } from './Modal-v2';