From c98c9e542502913885194d41c7ce1811ec43e643 Mon Sep 17 00:00:00 2001 From: Andrew Holloway Date: Tue, 7 Nov 2023 21:17:21 -0600 Subject: [PATCH] docs(Modal): update and improve documentation --- src/components/Modal/Modal.stories.module.css | 45 -- src/components/Modal/Modal.stories.tsx | 416 +++++++++--------- src/components/Modal/Modal.test.tsx | 10 +- src/components/Modal/Modal.tsx | 4 +- .../Modal/__snapshots__/Modal.test.tsx.snap | 212 +++------ 5 files changed, 270 insertions(+), 417 deletions(-) delete mode 100644 src/components/Modal/Modal.stories.module.css diff --git a/src/components/Modal/Modal.stories.module.css b/src/components/Modal/Modal.stories.module.css deleted file mode 100644 index 85b63ab26..000000000 --- a/src/components/Modal/Modal.stories.module.css +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Footer button group that is justified toward the end of the footer. - */ -.footer__button-group { - display: flex; - width: 100%; - justify-content: flex-end; -} - -/** - * Wraps the non interactive stories. - */ -.default__wrapper { - display: flex; - align-items: center; - justify-content: center; -} - -/** - * Background for the non interactive stories. - */ -.default__background { - position: absolute; - height: 100%; - width: 100%; - background-color: var(--eds-theme-color-body-background-inverted); - opacity: 0.5; -} - -/** - * Last paragraph of the long text stories. - * Removes browser placed margin-bottom for

. - */ -.long-text__last-paragraph { - margin-bottom: 0; -} - -/** - * Footer story example with stepper. Separates the stepper and button group. - */ -.footer--with-stepper { - display: flex; - justify-content: space-between; - align-items: center; -} diff --git a/src/components/Modal/Modal.stories.tsx b/src/components/Modal/Modal.stories.tsx index 9fee1c9aa..8db7c3ba0 100644 --- a/src/components/Modal/Modal.stories.tsx +++ b/src/components/Modal/Modal.stories.tsx @@ -4,10 +4,9 @@ import type { ReactNode } from 'react'; import React from 'react'; import { useState } from 'react'; import { Modal, ModalContent } from './Modal'; -import { Button, ButtonGroup, Heading, Text, Tooltip } from '../../'; +import { Button, ButtonGroup, Heading, Tooltip } from '../../'; import { chromaticViewports, storybookViewports } from '../../util/viewports'; import { VARIANTS } from '../Heading/Heading'; -import styles from './Modal.stories.module.css'; export default { title: 'Components/Modal', @@ -18,16 +17,35 @@ export default { chromatic: { disableSnapshot: true }, badges: ['1.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', }, }, -} as Meta; +} as Meta; type Args = React.ComponentProps; +type Story = StoryObj; type InteractiveArgs = Omit; const getChildren = ( @@ -37,11 +55,7 @@ const getChildren = ( ) => ( <> - Brand Asset - - } + brandAsset={

Brand Asset
} > {inDialogComponent ? ( Modal Title @@ -52,168 +66,29 @@ const getChildren = ( {bodyContent} {showStepper && } - + {/* This has to be manually tested since Tooltip tests are flaky in Chromatic */} + - - ); -export const Default: StoryObj = { - render: (args) => ( -
-
- {}} - /> -
- ), - args: { - children: getChildren(false), - hideCloseButton: false, - open: true, - }, - parameters: { - // This story shows the modal content by default, for visual regression testing purposes. - chromatic: { disableSnapshot: false }, - }, -}; - -export const Medium: StoryObj = { - ...Default, - args: { - ...Default.args, - size: 'md', - }, -}; - -export const Small: StoryObj = { - ...Default, - args: { - ...Default.args, - size: 'sm', - }, -}; - -export const Brand: StoryObj = { - ...Default, - args: { - ...Default.args, - variant: 'brand', - }, -}; - -export const Mobile: StoryObj = { - ...Default, - parameters: { - ...Default.parameters, - viewport: { - defaultViewport: 'googlePixel2', - }, - chromatic: { - disableSnapshot: false, - viewports: [chromaticViewports.googlePixel2], - }, - }, -}; - -export const MobileLandscape: StoryObj = { - ...Default, - parameters: { - ...Default.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 }, - }, - }, -}; - -export const MobileBrand: StoryObj = { - ...Mobile, - args: { - ...Mobile.args, - variant: 'brand', - }, -}; - -export const MobileLandscapeBrand: StoryObj = { - ...MobileLandscape, - args: { - ...MobileLandscape.args, - variant: 'brand', - }, -}; - -export const Tablet: StoryObj = { - ...Default, - parameters: { - ...Default.parameters, - viewport: { - defaultViewport: 'ipadMini', - viewports: { - mobilelandscape: storybookViewports.ipadMini, - }, - }, - chromatic: { - disableSnapshot: false, - viewports: [chromaticViewports.ipadMini], - }, - }, -}; - -export const TabletBrand: StoryObj = { - ...Tablet, - args: { - ...Tablet.args, - variant: 'brand', - }, -}; - -export const WithStepper: StoryObj = { - ...Default, - args: { - ...Default.args, - children: getChildren( - false, - 'Modal body content. This is an example use case with the stepper in the footer.', - true, - ), - }, -}; - function InteractiveExample(args: InteractiveArgs) { const [open, setOpen] = useState(false); return ( <> - - Please note: opening the modal only works in the Canvas tab. - @@ -223,13 +98,25 @@ function InteractiveExample(args: InteractiveArgs) { ); } -export const DefaultInteractive: StoryObj = { +/** + * 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) => ( {getChildren()} ), }; type HeadingArgs = React.ComponentProps; +/** + * 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 ControlHeadingInteractive: StoryObj = { argTypes: { as: { @@ -240,6 +127,7 @@ export const ControlHeadingInteractive: StoryObj = { size: { control: 'select', name: 'title "size" prop', + // TODO: convert to using `presets` from variant-types and updating modal similar to `Text` options: [ 'h1', 'h2', @@ -283,6 +171,11 @@ export const ControlHeadingInteractive: StoryObj = { }, }; +/** + * You can disable the close button on the modal. This will require users to either click out of the modal, or hit escape to close. + * + * **NOTE**: this is less discoverable and should be avoided when possible. + */ export const WithoutCloseButton: StoryObj = { render: (args) => ( @@ -341,7 +234,7 @@ const reallyLongText = ( sapien. Nam in egestas tellus. Nulla quis metus dui. Suspendisse sit amet nisi at lectus ultricies egestas.

-

+

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 @@ -355,14 +248,9 @@ const reallyLongText = (

); -export const WithLongText: StoryObj = { - render: (args) => ( - - {getChildren(true, reallyLongText)} - - ), -}; - +/** + * Modals can contain long, scrollable text. This is not recommended, however. + */ export const WithLongTextScrollable: StoryObj = { args: { isScrollable: true, @@ -374,6 +262,9 @@ export const WithLongTextScrollable: StoryObj = { ), }; +/** + * You can avoid rendering the header and footer in a modal + */ export const WithoutHeaderAndFooter: StoryObj = { args: { isScrollable: true, @@ -388,64 +279,171 @@ export const WithoutHeaderAndFooter: StoryObj = { ), }; -type ModalStepperArgs = React.ComponentProps; - -export const ModalStepper: StoryObj = { +export const ModalStepper: StoryObj< + React.ComponentProps +> = { args: { activeStep: 1, totalSteps: 3, }, render: (args) => , - decorators: [ - (Story) => ( -
- {Story()} -
- ), - ], + decorators: [(Story) =>
{Story()}
], parameters: { chromatic: { disableSnapshot: false }, }, }; - -const InteractiveModalStepperComponent = () => { - const [activeStep, setActiveStep] = useState(1); - return ( -
- - - {activeStep > 1 && ( - - )} - {activeStep < 5 && ( - - )} - +/** + * 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. + * + * **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: getChildren(false), + 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', + }, }; -export const InteractiveModalStepper: StoryObj = { - ...ModalStepper, - render: () => , - /** - * For interactive purposes only, low value in snapping or checking for visual regression since they should be covered in the other stories. - */ +/** + * `Modal` also allows for `small`. + */ +export const Small: Story = { + ...ContentDefault, + args: { + ...ContentDefault.args, + size: 'sm', + }, +}; + +/** + * The brand variant offers a catered experience with a slot for a hero image to be attached to the modal. + */ +export const Brand: Story = { + ...ContentDefault, + args: { + ...ContentDefault.args, + variant: 'brand', + }, +}; + +export const Mobile: Story = { + ...ContentDefault, parameters: { - snapshot: { skip: true }, + ...ContentDefault.parameters, + viewport: { + defaultViewport: 'googlePixel2', + }, + chromatic: { + disableSnapshot: false, + viewports: [chromaticViewports.googlePixel2], + }, + }, +}; + +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 }, + }, + }, +}; + +export const MobileBrand: Story = { + ...Mobile, + args: { + ...Mobile.args, + variant: 'brand', + }, +}; + +export const MobileLandscapeBrand: Story = { + ...MobileLandscape, + args: { + ...MobileLandscape.args, + variant: 'brand', + }, +}; + +export const Tablet: Story = { + ...ContentDefault, + parameters: { + ...ContentDefault.parameters, + viewport: { + defaultViewport: 'ipadMini', + viewports: { + mobilelandscape: storybookViewports.ipadMini, + }, + }, + chromatic: { + disableSnapshot: false, + viewports: [chromaticViewports.ipadMini], + }, + }, +}; + +export const TabletBrand: Story = { + ...Tablet, + args: { + ...Tablet.args, + variant: 'brand', + }, +}; + +/** + * `Modal` can make use of the embedded `.Stepper` sub-component, to implement wizard behavior. + */ +export const WithStepper: Story = { + ...ContentDefault, + args: { + ...ContentDefault.args, + children: getChildren( + false, + 'Modal body content. This is an example use case with the stepper in the footer.', + true, + ), }, }; diff --git a/src/components/Modal/Modal.test.tsx b/src/components/Modal/Modal.test.tsx index c4317f204..c4e762b54 100644 --- a/src/components/Modal/Modal.test.tsx +++ b/src/components/Modal/Modal.test.tsx @@ -8,7 +8,7 @@ import { Modal } from './Modal'; import * as stories from './Modal.stories'; import '../../../jest/helpers/removeModalTransitionStylesJestSerializer'; -const { DefaultInteractive } = composeStories(stories); +const { Default } = composeStories(stories); window.ResizeObserver = class FakeResizeObserver { observe() {} @@ -37,13 +37,13 @@ describe('Modal', () => { }); it('is initially closed', () => { - render(); + render(); expect(screen.queryByRole('dialog')).toBeFalsy(); }); it('shows the modal when the open modal button is clicked', async () => { const user = userEvent.setup(); - render(); + render(); const openModalButton = await screen.findByRole('button', { name: 'Open the modal', }); @@ -54,7 +54,7 @@ describe('Modal', () => { it('closes the modal on close button click', async () => { const user = userEvent.setup(); - render(); + render(); const openModalButton = await screen.findByRole('button', { name: 'Open the modal', }); @@ -70,7 +70,7 @@ describe('Modal', () => { it('closes the modal on ESC key press', async () => { const user = userEvent.setup(); - render(); + render(); const openModalButton = await screen.findByRole('button', { name: 'Open the modal', }); diff --git a/src/components/Modal/Modal.tsx b/src/components/Modal/Modal.tsx index 0a3c74a15..179e39969 100644 --- a/src/components/Modal/Modal.tsx +++ b/src/components/Modal/Modal.tsx @@ -3,6 +3,7 @@ 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 type { HeadingSize } from '../Heading'; import Heading from '../Heading'; import { Icon, type IconName } from '../Icon/Icon'; @@ -75,7 +76,7 @@ type ModalContentProps = { * Max size of the modal. Defaults to 'lg'. * Will still break responsively. */ - size?: 'sm' | 'md' | 'lg'; + size?: Extract; /** * Color variants of the modal. */ @@ -406,6 +407,7 @@ const ModalHeader = ({ /** * Stepper for the modal to indicate page status. + * TODO: Separate the stepper from the modal, and make into a standalone component. */ const ModalStepper = ({ activeStep, diff --git a/src/components/Modal/__snapshots__/Modal.test.tsx.snap b/src/components/Modal/__snapshots__/Modal.test.tsx.snap index 4f4b83cef..2a155a81c 100644 --- a/src/components/Modal/__snapshots__/Modal.test.tsx.snap +++ b/src/components/Modal/__snapshots__/Modal.test.tsx.snap @@ -39,8 +39,7 @@ exports[`Modal Brand story renders snapshot 1`] = ` class="modal-header__brand-asset" >
Brand Asset
@@ -55,26 +54,26 @@ exports[`Modal Brand story renders snapshot 1`] = ` class="modal-footer" >
`; -exports[`Modal Default story renders snapshot 1`] = ` +exports[`Modal ContentDefault story renders snapshot 1`] = ` `; -exports[`Modal DefaultInteractive story renders snapshot 1`] = ` +exports[`Modal Default story renders snapshot 1`] = ` @@ -262,19 +261,19 @@ exports[`Modal Medium story renders snapshot 1`] = ` class="modal-footer" > @@ -326,19 +325,19 @@ exports[`Modal Mobile story renders snapshot 1`] = ` class="modal-footer" > @@ -384,8 +383,7 @@ exports[`Modal MobileBrand story renders snapshot 1`] = ` class="modal-header__brand-asset" >
Brand Asset
@@ -400,19 +398,19 @@ exports[`Modal MobileBrand story renders snapshot 1`] = ` class="modal-footer" > @@ -464,19 +462,19 @@ exports[`Modal MobileLandscape story renders snapshot 1`] = ` class="modal-footer" > @@ -522,8 +520,7 @@ exports[`Modal MobileLandscapeBrand story renders snapshot 1`] = ` class="modal-header__brand-asset" >
Brand Asset
@@ -538,19 +535,19 @@ exports[`Modal MobileLandscapeBrand story renders snapshot 1`] = ` class="modal-footer" > @@ -677,19 +674,19 @@ exports[`Modal Small story renders snapshot 1`] = ` class="modal-footer" > @@ -741,19 +738,19 @@ exports[`Modal Tablet story renders snapshot 1`] = ` class="modal-footer" > @@ -799,8 +796,7 @@ exports[`Modal TabletBrand story renders snapshot 1`] = ` class="modal-header__brand-asset" >
Brand Asset
@@ -815,26 +811,26 @@ exports[`Modal TabletBrand story renders snapshot 1`] = ` class="modal-footer" > `; -exports[`Modal WithLongText story renders snapshot 1`] = ` +exports[`Modal WithLongTextScrollable story renders snapshot 1`] = `
- -
-`; - -exports[`Modal WithLongTextScrollable story renders snapshot 1`] = ` - @@ -1279,14 +1177,14 @@ exports[`Modal WithoutHeaderAndFooter story renders snapshot 1`] = ` aria-modal="true" class="modal modal__transition--enterTo" data-headlessui-state="open" - id="headlessui-dialog-:rc:" + id="headlessui-dialog-:r9:" role="dialog" >