From 92e25d056ebb3fd50b1467749d1e69e946aabce1 Mon Sep 17 00:00:00 2001 From: Arianna Kellogg Date: Wed, 12 May 2021 17:56:12 -0700 Subject: [PATCH 01/38] Modal base component, minimum Storybook examples identified, plus test suite --- src/components/Modal/Modal.stories.tsx | 31 +++++++++++++++++++++ src/components/Modal/Modal.test.tsx | 38 ++++++++++++++++++++++++++ src/components/Modal/Modal.tsx | 33 ++++++++++++++++++++++ 3 files changed, 102 insertions(+) create mode 100644 src/components/Modal/Modal.stories.tsx create mode 100644 src/components/Modal/Modal.test.tsx create mode 100644 src/components/Modal/Modal.tsx diff --git a/src/components/Modal/Modal.stories.tsx b/src/components/Modal/Modal.stories.tsx new file mode 100644 index 0000000000..02fe66a586 --- /dev/null +++ b/src/components/Modal/Modal.stories.tsx @@ -0,0 +1,31 @@ +import React from 'react' + +import { Modal } from './Modal' + +export default { + title: 'Components/Modal', + component: Modal, + parameters: { + docs: { + description: { + component: ` +### USWDS 2.0 SiteAlert component + +Source: http://designsystem.digital.gov/components/modal +`, + }, + }, + }, +} + +export const defaultModal = (): React.ReactElement => ( + some children +) + +export const largeModal = (): React.ReactElement => ( + large modal +) + +export const modalWithForcedAction = (): React.ReactElement => ( + forced action +) diff --git a/src/components/Modal/Modal.test.tsx b/src/components/Modal/Modal.test.tsx new file mode 100644 index 0000000000..1123cbc08b --- /dev/null +++ b/src/components/Modal/Modal.test.tsx @@ -0,0 +1,38 @@ +import React from 'react' + +import { Modal } from './Modal' + +import { render } from '@testing-library/react' + +describe('Modal component', () => { + it('renders without errors', () => { + const { getByTestId } = render(some children) + + expect(getByTestId('modal')).toBeInTheDocument() + }) + + it('renders large model when passed isLarge', () => { + const { getByTestId } = render(some children) + + expect(getByTestId('modal')).toHaveClass('usa-modal--lg') + }) + + it('accepts attributes passed in through props', () => { + const { getByTestId } = render( + some children + ) + + expect(getByTestId('modal')).toHaveAttribute( + 'aria-label', + 'aria-label-model' + ) + }) + + it('accepts a custom className', () => { + const { getByTestId } = render( + some children + ) + + expect(getByTestId('modal')).toHaveClass('usa-modal custom-class') + }) +}) diff --git a/src/components/Modal/Modal.tsx b/src/components/Modal/Modal.tsx new file mode 100644 index 0000000000..f484af6b6e --- /dev/null +++ b/src/components/Modal/Modal.tsx @@ -0,0 +1,33 @@ +import React from 'react' + +import classnames from 'classnames' + +interface ModalProps { + children: React.ReactNode + className?: string + isLarge?: boolean +} + +export const Modal = ({ + className, + children, + isLarge = false, + ...divProps +}: ModalProps & JSX.IntrinsicElements['div']): React.ReactElement => { + const classes = classnames( + 'usa-modal', + { + 'usa-modal--lg': isLarge, + }, + className + ) + return ( + <> +
+ {children} +
+ + ) +} + +export default Modal From 358510210f60a01388e6243fd00a3ecfb923473e Mon Sep 17 00:00:00 2001 From: Arianna Kellogg Date: Thu, 13 May 2021 12:24:20 -0700 Subject: [PATCH 02/38] Add example content to use in Storybook examples --- src/components/Modal/Modal.stories.tsx | 59 +++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/src/components/Modal/Modal.stories.tsx b/src/components/Modal/Modal.stories.tsx index 02fe66a586..d4de01701d 100644 --- a/src/components/Modal/Modal.stories.tsx +++ b/src/components/Modal/Modal.stories.tsx @@ -1,6 +1,7 @@ import React from 'react' import { Modal } from './Modal' +import close from 'uswds/src/img/close.svg' export default { title: 'Components/Modal', @@ -18,8 +19,64 @@ Source: http://designsystem.digital.gov/components/modal }, } +// const testContent = ( +//
+//
+// +//
+// +//
+//
+//
    +//
  • +// +//
  • +//
  • +// +//
  • +//
+//
+//
+// +//
+// ) + +// export const modalWithTestContent = (): React.ReactElement => ( +//
+// +// Open default modal +// +// +// {testContent} +// +//
+// ) + export const defaultModal = (): React.ReactElement => ( - some children + default modal ) export const largeModal = (): React.ReactElement => ( From c30913f61768ba2eabb069b3772824ba1ab3057a Mon Sep 17 00:00:00 2001 From: Arianna Kellogg Date: Mon, 17 May 2021 13:02:37 -0700 Subject: [PATCH 03/38] Add ModalDescription folder and files, passing one test --- src/components/Modal/{ => Modal}/Modal.stories.tsx | 0 src/components/Modal/{ => Modal}/Modal.test.tsx | 0 src/components/Modal/{ => Modal}/Modal.tsx | 0 .../Modal/ModalDescription/ModalDescription.test.tsx | 12 ++++++++++++ .../Modal/ModalDescription/ModalDescription.tsx | 7 +++++++ 5 files changed, 19 insertions(+) rename src/components/Modal/{ => Modal}/Modal.stories.tsx (100%) rename src/components/Modal/{ => Modal}/Modal.test.tsx (100%) rename src/components/Modal/{ => Modal}/Modal.tsx (100%) create mode 100644 src/components/Modal/ModalDescription/ModalDescription.test.tsx create mode 100644 src/components/Modal/ModalDescription/ModalDescription.tsx diff --git a/src/components/Modal/Modal.stories.tsx b/src/components/Modal/Modal/Modal.stories.tsx similarity index 100% rename from src/components/Modal/Modal.stories.tsx rename to src/components/Modal/Modal/Modal.stories.tsx diff --git a/src/components/Modal/Modal.test.tsx b/src/components/Modal/Modal/Modal.test.tsx similarity index 100% rename from src/components/Modal/Modal.test.tsx rename to src/components/Modal/Modal/Modal.test.tsx diff --git a/src/components/Modal/Modal.tsx b/src/components/Modal/Modal/Modal.tsx similarity index 100% rename from src/components/Modal/Modal.tsx rename to src/components/Modal/Modal/Modal.tsx diff --git a/src/components/Modal/ModalDescription/ModalDescription.test.tsx b/src/components/Modal/ModalDescription/ModalDescription.test.tsx new file mode 100644 index 0000000000..ec5f92356b --- /dev/null +++ b/src/components/Modal/ModalDescription/ModalDescription.test.tsx @@ -0,0 +1,12 @@ +import React from 'react' +import { render } from '@testing-library/react' + +import { ModalDescription } from './ModalDescription' + +describe('ModalDescription component', () => { + it('renders without errors', () => { + const { queryByTestId } = render() + + expect(queryByTestId('modalDescription')).toBeInTheDocument() + }) +}) diff --git a/src/components/Modal/ModalDescription/ModalDescription.tsx b/src/components/Modal/ModalDescription/ModalDescription.tsx new file mode 100644 index 0000000000..c69011c60b --- /dev/null +++ b/src/components/Modal/ModalDescription/ModalDescription.tsx @@ -0,0 +1,7 @@ +import React from 'react' + +export const ModalDescription = (): React.ReactElement => { + return
modal description
+} + +export default ModalDescription From a474a51147688346dad31d013381a1b571a6fc59 Mon Sep 17 00:00:00 2001 From: Arianna Kellogg Date: Mon, 17 May 2021 13:08:26 -0700 Subject: [PATCH 04/38] Update ModalDeescription to include USWDS class, update tests --- .../ModalDescription.test.tsx | 16 +++++++++++++- .../ModalDescription/ModalDescription.tsx | 21 +++++++++++++++++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/components/Modal/ModalDescription/ModalDescription.test.tsx b/src/components/Modal/ModalDescription/ModalDescription.test.tsx index ec5f92356b..451b0439cb 100644 --- a/src/components/Modal/ModalDescription/ModalDescription.test.tsx +++ b/src/components/Modal/ModalDescription/ModalDescription.test.tsx @@ -5,8 +5,22 @@ import { ModalDescription } from './ModalDescription' describe('ModalDescription component', () => { it('renders without errors', () => { - const { queryByTestId } = render() + const { queryByTestId } = render( + + You have unsaved changes that will be lost. + + ) expect(queryByTestId('modalDescription')).toBeInTheDocument() }) + + it('accepts a custom className', () => { + const { queryByTestId } = render( + + You have unsaved changes that will be lost. + + ) + + expect(queryByTestId('modalDescription')).toHaveClass('custom-class') + }) }) diff --git a/src/components/Modal/ModalDescription/ModalDescription.tsx b/src/components/Modal/ModalDescription/ModalDescription.tsx index c69011c60b..a621287154 100644 --- a/src/components/Modal/ModalDescription/ModalDescription.tsx +++ b/src/components/Modal/ModalDescription/ModalDescription.tsx @@ -1,7 +1,24 @@ import React from 'react' +import classnames from 'classnames' -export const ModalDescription = (): React.ReactElement => { - return
modal description
+interface ModalDescriptionProps { + children: React.ReactNode + className?: string +} + +export const ModalDescription = ({ + children, + className, + ...divProps +}: ModalDescriptionProps & + JSX.IntrinsicElements['div']): React.ReactElement => { + const classes = classnames('usa-modal__main', className) + + return ( +
+ {children} +
+ ) } export default ModalDescription From fdd3078309cf9fcba74d84c7d65999b835edd7e5 Mon Sep 17 00:00:00 2001 From: Arianna Kellogg Date: Mon, 17 May 2021 13:24:02 -0700 Subject: [PATCH 05/38] Add folder and files for ModalHeading --- src/components/Modal/Modal/Modal.stories.tsx | 3 ++- src/components/Modal/Modal/Modal.tsx | 2 +- src/components/Modal/ModalHeading/ModalHeading.test.tsx | 1 + src/components/Modal/ModalHeading/ModalHeading.tsx | 1 + 4 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 src/components/Modal/ModalHeading/ModalHeading.test.tsx create mode 100644 src/components/Modal/ModalHeading/ModalHeading.tsx diff --git a/src/components/Modal/Modal/Modal.stories.tsx b/src/components/Modal/Modal/Modal.stories.tsx index d4de01701d..c51f012ef0 100644 --- a/src/components/Modal/Modal/Modal.stories.tsx +++ b/src/components/Modal/Modal/Modal.stories.tsx @@ -1,7 +1,8 @@ import React from 'react' import { Modal } from './Modal' -import close from 'uswds/src/img/close.svg' +import { ModalDescription } from '../ModalDescription/ModalDescription' +// import close from 'uswds/src/img/close.svg' export default { title: 'Components/Modal', diff --git a/src/components/Modal/Modal/Modal.tsx b/src/components/Modal/Modal/Modal.tsx index f484af6b6e..f0809d2766 100644 --- a/src/components/Modal/Modal/Modal.tsx +++ b/src/components/Modal/Modal/Modal.tsx @@ -24,7 +24,7 @@ export const Modal = ({ return ( <>
- {children} +
{children}
) diff --git a/src/components/Modal/ModalHeading/ModalHeading.test.tsx b/src/components/Modal/ModalHeading/ModalHeading.test.tsx new file mode 100644 index 0000000000..b45dfaa8f0 --- /dev/null +++ b/src/components/Modal/ModalHeading/ModalHeading.test.tsx @@ -0,0 +1 @@ +import React from 'react' diff --git a/src/components/Modal/ModalHeading/ModalHeading.tsx b/src/components/Modal/ModalHeading/ModalHeading.tsx new file mode 100644 index 0000000000..b45dfaa8f0 --- /dev/null +++ b/src/components/Modal/ModalHeading/ModalHeading.tsx @@ -0,0 +1 @@ +import React from 'react' From b289aa4b049afa5fcd9355304e191ece603f67ce Mon Sep 17 00:00:00 2001 From: Arianna Kellogg Date: Mon, 17 May 2021 13:28:36 -0700 Subject: [PATCH 06/38] Skeleton for ModalHeading, passing one test --- .../Modal/ModalHeading/ModalHeading.test.tsx | 11 +++++++++++ src/components/Modal/ModalHeading/ModalHeading.tsx | 6 ++++++ 2 files changed, 17 insertions(+) diff --git a/src/components/Modal/ModalHeading/ModalHeading.test.tsx b/src/components/Modal/ModalHeading/ModalHeading.test.tsx index b45dfaa8f0..9ca41e1bf8 100644 --- a/src/components/Modal/ModalHeading/ModalHeading.test.tsx +++ b/src/components/Modal/ModalHeading/ModalHeading.test.tsx @@ -1 +1,12 @@ import React from 'react' +import { render } from '@testing-library/react' + +import { ModalHeading } from './ModalHeading' + +describe('ModalHeading component', () => { + it('renders without errors', () => { + const { queryByTestId } = render() + + expect(queryByTestId('modalHeading')).toBeInTheDocument() + }) +}) diff --git a/src/components/Modal/ModalHeading/ModalHeading.tsx b/src/components/Modal/ModalHeading/ModalHeading.tsx index b45dfaa8f0..ec81c5b4ef 100644 --- a/src/components/Modal/ModalHeading/ModalHeading.tsx +++ b/src/components/Modal/ModalHeading/ModalHeading.tsx @@ -1 +1,7 @@ import React from 'react' + +export const ModalHeading = (): React.ReactElement => { + return
modal heading
+} + +export default ModalHeading From 948ba7431fb7ecfe892a49e0d3d1e4e9e3e91585 Mon Sep 17 00:00:00 2001 From: Arianna Kellogg Date: Mon, 17 May 2021 14:35:35 -0700 Subject: [PATCH 07/38] Flesh out default Modal Storybook example --- src/components/Modal/Modal/Modal.stories.tsx | 86 ++++++------------- src/components/Modal/Modal/Modal.test.tsx | 6 +- src/components/Modal/Modal/Modal.tsx | 8 +- .../Modal/ModalContent/ModalContent.test.tsx | 24 ++++++ .../Modal/ModalContent/ModalContent.tsx | 23 +++++ .../ModalDescription.test.tsx | 6 +- .../ModalDescription/ModalDescription.tsx | 2 +- .../Modal/ModalFooter/ModalFooter.test.tsx | 40 +++++++++ .../Modal/ModalFooter/ModalFooter.tsx | 23 +++++ .../Modal/ModalHeading/ModalHeading.test.tsx | 7 +- .../Modal/ModalHeading/ModalHeading.tsx | 30 ++++++- .../Modal/ModalMain/ModalMain.test.tsx | 32 +++++++ src/components/Modal/ModalMain/ModalMain.tsx | 25 ++++++ src/index.ts | 6 ++ 14 files changed, 244 insertions(+), 74 deletions(-) create mode 100644 src/components/Modal/ModalContent/ModalContent.test.tsx create mode 100644 src/components/Modal/ModalContent/ModalContent.tsx create mode 100644 src/components/Modal/ModalFooter/ModalFooter.test.tsx create mode 100644 src/components/Modal/ModalFooter/ModalFooter.tsx create mode 100644 src/components/Modal/ModalMain/ModalMain.test.tsx create mode 100644 src/components/Modal/ModalMain/ModalMain.tsx diff --git a/src/components/Modal/Modal/Modal.stories.tsx b/src/components/Modal/Modal/Modal.stories.tsx index c51f012ef0..4890f06d5b 100644 --- a/src/components/Modal/Modal/Modal.stories.tsx +++ b/src/components/Modal/Modal/Modal.stories.tsx @@ -1,8 +1,16 @@ import React from 'react' import { Modal } from './Modal' +import { ModalContent } from '../ModalContent/ModalContent' import { ModalDescription } from '../ModalDescription/ModalDescription' -// import close from 'uswds/src/img/close.svg' +import { ModalFooter } from '../ModalFooter/ModalFooter' +import { ModalHeading } from '../ModalHeading/ModalHeading' +import { ModalMain } from '../ModalMain/ModalMain' + +import { Button } from '../../Button/Button' +import { ButtonGroup } from '../../ButtonGroup/ButtonGroup' + +import close from 'uswds/src/img/close.svg' export default { title: 'Components/Modal', @@ -20,66 +28,28 @@ Source: http://designsystem.digital.gov/components/modal }, } -// const testContent = ( -//
-//
-// -//
-// -//
-//
-//
    -//
  • -// -//
  • -//
  • -// -//
  • -//
-//
-//
-// -//
-// ) - -// export const modalWithTestContent = (): React.ReactElement => ( -//
-// -// Open default modal -// -// -// {testContent} -// -//
-// ) - export const defaultModal = (): React.ReactElement => ( - default modal + + + + + Are you sure you want to continue? + + +

You have unsaved changes that will be lost.

+
+ + + + + + +
+
+
) +// export const largeModal = (): React.ReactElement => ( large modal ) diff --git a/src/components/Modal/Modal/Modal.test.tsx b/src/components/Modal/Modal/Modal.test.tsx index 1123cbc08b..96501fda98 100644 --- a/src/components/Modal/Modal/Modal.test.tsx +++ b/src/components/Modal/Modal/Modal.test.tsx @@ -11,7 +11,7 @@ describe('Modal component', () => { expect(getByTestId('modal')).toBeInTheDocument() }) - it('renders large model when passed isLarge', () => { + it('renders large modal when passed isLarge', () => { const { getByTestId } = render(some children) expect(getByTestId('modal')).toHaveClass('usa-modal--lg') @@ -19,12 +19,12 @@ describe('Modal component', () => { it('accepts attributes passed in through props', () => { const { getByTestId } = render( - some children + some children ) expect(getByTestId('modal')).toHaveAttribute( 'aria-label', - 'aria-label-model' + 'aria-label-modal' ) }) diff --git a/src/components/Modal/Modal/Modal.tsx b/src/components/Modal/Modal/Modal.tsx index f0809d2766..fbcda3a22b 100644 --- a/src/components/Modal/Modal/Modal.tsx +++ b/src/components/Modal/Modal/Modal.tsx @@ -22,11 +22,9 @@ export const Modal = ({ className ) return ( - <> -
-
{children}
-
- +
+ {children} +
) } diff --git a/src/components/Modal/ModalContent/ModalContent.test.tsx b/src/components/Modal/ModalContent/ModalContent.test.tsx new file mode 100644 index 0000000000..695f6fd2ef --- /dev/null +++ b/src/components/Modal/ModalContent/ModalContent.test.tsx @@ -0,0 +1,24 @@ +import React from 'react' +import { render } from '@testing-library/react' + +import { ModalContent } from './ModalContent' + +describe('ModalContent component', () => { + it('renders without errors', () => { + const { queryByTestId } = render( + You have unsaved changes that will be lost. + ) + + expect(queryByTestId('modalContent')).toBeInTheDocument() + }) + + it('accepts a custom className', () => { + const { queryByTestId } = render( + + You have unsaved changes that will be lost. + + ) + + expect(queryByTestId('modalContent')).toHaveClass('custom-class') + }) +}) diff --git a/src/components/Modal/ModalContent/ModalContent.tsx b/src/components/Modal/ModalContent/ModalContent.tsx new file mode 100644 index 0000000000..59713ce09b --- /dev/null +++ b/src/components/Modal/ModalContent/ModalContent.tsx @@ -0,0 +1,23 @@ +import React from 'react' +import classnames from 'classnames' + +interface ModalContentProps { + children: React.ReactNode + className?: string +} + +export const ModalContent = ({ + children, + className, + ...divProps +}: ModalContentProps & JSX.IntrinsicElements['div']): React.ReactElement => { + const classes = classnames('usa-modal__content', className) + + return ( +
+ {children} +
+ ) +} + +export default ModalContent diff --git a/src/components/Modal/ModalDescription/ModalDescription.test.tsx b/src/components/Modal/ModalDescription/ModalDescription.test.tsx index 451b0439cb..44bf4097fb 100644 --- a/src/components/Modal/ModalDescription/ModalDescription.test.tsx +++ b/src/components/Modal/ModalDescription/ModalDescription.test.tsx @@ -7,7 +7,7 @@ describe('ModalDescription component', () => { it('renders without errors', () => { const { queryByTestId } = render( - You have unsaved changes that will be lost. +

You have unsaved changes that will be lost.

) @@ -17,10 +17,10 @@ describe('ModalDescription component', () => { it('accepts a custom className', () => { const { queryByTestId } = render( - You have unsaved changes that will be lost. +

You have unsaved changes that will be lost.

) - expect(queryByTestId('modalDescription')).toHaveClass('custom-class') + expect(queryByTestId('modalDescription')).toBeInTheDocument() }) }) diff --git a/src/components/Modal/ModalDescription/ModalDescription.tsx b/src/components/Modal/ModalDescription/ModalDescription.tsx index a621287154..7806a08d89 100644 --- a/src/components/Modal/ModalDescription/ModalDescription.tsx +++ b/src/components/Modal/ModalDescription/ModalDescription.tsx @@ -12,7 +12,7 @@ export const ModalDescription = ({ ...divProps }: ModalDescriptionProps & JSX.IntrinsicElements['div']): React.ReactElement => { - const classes = classnames('usa-modal__main', className) + const classes = classnames('usa-prose', className) return (
diff --git a/src/components/Modal/ModalFooter/ModalFooter.test.tsx b/src/components/Modal/ModalFooter/ModalFooter.test.tsx new file mode 100644 index 0000000000..8715773052 --- /dev/null +++ b/src/components/Modal/ModalFooter/ModalFooter.test.tsx @@ -0,0 +1,40 @@ +import React from 'react' +import { render } from '@testing-library/react' + +import { ModalFooter } from './ModalFooter' + +describe('ModalFooter component', () => { + it('renders without errors', () => { + const { queryByTestId } = render( + +
    +
  • + +
  • +
  • + +
  • +
+
+ ) + + expect(queryByTestId('modalFooter')).toBeInTheDocument() + }) + + it('accepts a custom className', () => { + const { queryByTestId } = render( + +
    +
  • + +
  • +
  • + +
  • +
+
+ ) + + expect(queryByTestId('modalFooter')).toBeInTheDocument() + }) +}) diff --git a/src/components/Modal/ModalFooter/ModalFooter.tsx b/src/components/Modal/ModalFooter/ModalFooter.tsx new file mode 100644 index 0000000000..6b382ab786 --- /dev/null +++ b/src/components/Modal/ModalFooter/ModalFooter.tsx @@ -0,0 +1,23 @@ +import React from 'react' +import classnames from 'classnames' + +interface ModalFooterProps { + children: React.ReactNode + className?: string +} + +export const ModalFooter = ({ + children, + className, + ...divProps +}: ModalFooterProps & JSX.IntrinsicElements['div']): React.ReactElement => { + const classes = classnames('usa-modal__footer', className) + + return ( +
+ {children} +
+ ) +} + +export default ModalFooter diff --git a/src/components/Modal/ModalHeading/ModalHeading.test.tsx b/src/components/Modal/ModalHeading/ModalHeading.test.tsx index 9ca41e1bf8..b62e1822ab 100644 --- a/src/components/Modal/ModalHeading/ModalHeading.test.tsx +++ b/src/components/Modal/ModalHeading/ModalHeading.test.tsx @@ -5,8 +5,13 @@ import { ModalHeading } from './ModalHeading' describe('ModalHeading component', () => { it('renders without errors', () => { - const { queryByTestId } = render() + const { queryByTestId, queryByRole } = render( + Are you sure you want to continue? + ) expect(queryByTestId('modalHeading')).toBeInTheDocument() + expect(queryByRole('heading')).toBeInTheDocument() }) + + // it('accepts a custom className') }) diff --git a/src/components/Modal/ModalHeading/ModalHeading.tsx b/src/components/Modal/ModalHeading/ModalHeading.tsx index ec81c5b4ef..6169a3e8ae 100644 --- a/src/components/Modal/ModalHeading/ModalHeading.tsx +++ b/src/components/Modal/ModalHeading/ModalHeading.tsx @@ -1,7 +1,31 @@ -import React from 'react' +import React, { DetailedHTMLProps } from 'react' +import classnames from 'classnames' -export const ModalHeading = (): React.ReactElement => { - return
modal heading
+interface ModalHeadingProps { + type: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' + className?: string + children?: React.ReactNode +} + +type HeadingModalHeadingProps = ModalHeadingProps & + React.DetailedHTMLProps< + React.HTMLAttributes, + HTMLHeadingElement + > + +export const ModalHeading = ({ + type, + className, + children, + ...headingProps +}: HeadingModalHeadingProps): React.ReactElement => { + const classes = classnames('usa-modal__heading', className) + const Tag = type + return ( + + modal heading + + ) } export default ModalHeading diff --git a/src/components/Modal/ModalMain/ModalMain.test.tsx b/src/components/Modal/ModalMain/ModalMain.test.tsx new file mode 100644 index 0000000000..67b0fdc3cb --- /dev/null +++ b/src/components/Modal/ModalMain/ModalMain.test.tsx @@ -0,0 +1,32 @@ +import React from 'react' + +import { ModalMain } from './ModalMain' + +import { render } from '@testing-library/react' + +describe('ModalMain component', () => { + it('renders without errors', () => { + const { getByTestId } = render(some children) + + expect(getByTestId('modalMain')).toBeInTheDocument() + }) + + it('accepts attributes passed in through props', () => { + const { getByTestId } = render( + some children + ) + + expect(getByTestId('modalMain')).toHaveAttribute( + 'aria-label', + 'aria-label-model' + ) + }) + + it('accepts a custom className', () => { + const { getByTestId } = render( + some children + ) + + expect(getByTestId('modalMain')).toHaveClass('usa-modal__main custom-class') + }) +}) diff --git a/src/components/Modal/ModalMain/ModalMain.tsx b/src/components/Modal/ModalMain/ModalMain.tsx new file mode 100644 index 0000000000..5ee2aee68f --- /dev/null +++ b/src/components/Modal/ModalMain/ModalMain.tsx @@ -0,0 +1,25 @@ +import React from 'react' + +import classnames from 'classnames' + +interface ModalMainProps { + children: React.ReactNode + className?: string + isLarge?: boolean +} + +export const ModalMain = ({ + className, + children, + isLarge = false, + ...divProps +}: ModalMainProps & JSX.IntrinsicElements['div']): React.ReactElement => { + const classes = classnames('usa-modal__main', className) + return ( +
+ {children} +
+ ) +} + +export default ModalMain diff --git a/src/index.ts b/src/index.ts index 7a79e04d6d..cdb7d79559 100644 --- a/src/index.ts +++ b/src/index.ts @@ -82,6 +82,12 @@ export { FooterNav } from './components/Footer/FooterNav/FooterNav' export { Logo } from './components/Footer/Logo/Logo' export { SocialLinks } from './components/Footer/SocialLinks/SocialLinks' +/** Modal components */ +export { Modal } from './components/Modal/Modal/Modal' +export { ModalContent } from './components/Modal/ModalContent/ModalContent' +export { ModalHeading } from './components/Modal/ModalHeading/ModalHeading' +export { ModalMain } from './components/Modal/ModalMain/ModalMain' + /** Card components */ export { CardGroup } from './components/card/CardGroup/CardGroup' export { Card } from './components/card/Card/Card' From e77c3351dcd06202d4a176e3bd723e92f2b60d0f Mon Sep 17 00:00:00 2001 From: Arianna Kellogg Date: Mon, 17 May 2021 14:37:37 -0700 Subject: [PATCH 08/38] Export ModalFooter, ModalDescription from index.ts --- src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/index.ts b/src/index.ts index cdb7d79559..d8924701c5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -85,6 +85,8 @@ export { SocialLinks } from './components/Footer/SocialLinks/SocialLinks' /** Modal components */ export { Modal } from './components/Modal/Modal/Modal' export { ModalContent } from './components/Modal/ModalContent/ModalContent' +export { ModalDescription } from './components/Modal/ModalDescription/ModalDescription' +export { ModalFooter } from './components/Modal/ModalFooter/ModalFooter' export { ModalHeading } from './components/Modal/ModalHeading/ModalHeading' export { ModalMain } from './components/Modal/ModalMain/ModalMain' From 49a916b57966202b178a40ed80c29e04310c188c Mon Sep 17 00:00:00 2001 From: Arianna Kellogg Date: Mon, 17 May 2021 17:39:20 -0700 Subject: [PATCH 09/38] Add ModalFooter component --- src/components/Modal/Modal/Modal.stories.tsx | 51 +++++++++++++++++-- .../ModalCloseButton.test.tsx | 24 +++++++++ .../ModalCloseButton/ModalCloseButton.tsx | 25 +++++++++ .../Modal/ModalHeading/ModalHeading.tsx | 2 +- src/index.ts | 1 + 5 files changed, 99 insertions(+), 4 deletions(-) create mode 100644 src/components/Modal/ModalCloseButton/ModalCloseButton.test.tsx create mode 100644 src/components/Modal/ModalCloseButton/ModalCloseButton.tsx diff --git a/src/components/Modal/Modal/Modal.stories.tsx b/src/components/Modal/Modal/Modal.stories.tsx index 4890f06d5b..a94bd4b011 100644 --- a/src/components/Modal/Modal/Modal.stories.tsx +++ b/src/components/Modal/Modal/Modal.stories.tsx @@ -1,6 +1,7 @@ import React from 'react' import { Modal } from './Modal' +import { ModalCloseButton } from '../ModalCloseButton/ModalCloseButton' import { ModalContent } from '../ModalContent/ModalContent' import { ModalDescription } from '../ModalDescription/ModalDescription' import { ModalFooter } from '../ModalFooter/ModalFooter' @@ -32,7 +33,7 @@ export const defaultModal = (): React.ReactElement => ( - + Are you sure you want to continue? @@ -45,15 +46,59 @@ export const defaultModal = (): React.ReactElement => ( + + Close this window + ) // export const largeModal = (): React.ReactElement => ( - large modal + + large modal + + + + Are you sure you want to continue? + + +

You have unsaved changes that will be lost.

+
+ + + + + + +
+ + Close this window + +
+
) export const modalWithForcedAction = (): React.ReactElement => ( - forced action + + + + + Are you sure you want to continue? + + +

You have unsaved changes that will be lost.

+
+ + + + + + +
+ + Close this window + +
+
) diff --git a/src/components/Modal/ModalCloseButton/ModalCloseButton.test.tsx b/src/components/Modal/ModalCloseButton/ModalCloseButton.test.tsx new file mode 100644 index 0000000000..9f54baaf41 --- /dev/null +++ b/src/components/Modal/ModalCloseButton/ModalCloseButton.test.tsx @@ -0,0 +1,24 @@ +import React from 'react' +import { render } from '@testing-library/react' + +import { ModalCloseButton } from './ModalCloseButton' + +describe('ModalCloseButton component', () => { + it('renders without errors', () => { + const { queryByRole } = render( + close button + ) + + expect(queryByRole('button')).toBeInTheDocument() + }) + + it('accepts a custom className', () => { + const { queryByRole } = render( + close button + ) + + expect(queryByRole('button')).toHaveClass( + 'usa-button usa-modal__close custom-class' + ) + }) +}) diff --git a/src/components/Modal/ModalCloseButton/ModalCloseButton.tsx b/src/components/Modal/ModalCloseButton/ModalCloseButton.tsx new file mode 100644 index 0000000000..6ded4de577 --- /dev/null +++ b/src/components/Modal/ModalCloseButton/ModalCloseButton.tsx @@ -0,0 +1,25 @@ +import React from 'react' + +import classnames from 'classnames' +import { Button } from '../../Button/Button' + +interface ModalCloseButtonProps { + children: React.ReactNode + className?: string +} +export const ModalCloseButton = ({ + children, + className, + ...buttonProps +}: ModalCloseButtonProps & + JSX.IntrinsicElements['button']): React.ReactElement => { + const classes = classnames('usa-modal__close', className) + + return ( + + ) +} + +export default ModalCloseButton diff --git a/src/components/Modal/ModalHeading/ModalHeading.tsx b/src/components/Modal/ModalHeading/ModalHeading.tsx index 6169a3e8ae..288bc944ed 100644 --- a/src/components/Modal/ModalHeading/ModalHeading.tsx +++ b/src/components/Modal/ModalHeading/ModalHeading.tsx @@ -23,7 +23,7 @@ export const ModalHeading = ({ const Tag = type return ( - modal heading + {children} ) } diff --git a/src/index.ts b/src/index.ts index d8924701c5..a14b0d4fa8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -84,6 +84,7 @@ export { SocialLinks } from './components/Footer/SocialLinks/SocialLinks' /** Modal components */ export { Modal } from './components/Modal/Modal/Modal' +export { ModalCloseButton } from './components/Modal/ModalCloseButton/ModalCloseButton' export { ModalContent } from './components/Modal/ModalContent/ModalContent' export { ModalDescription } from './components/Modal/ModalDescription/ModalDescription' export { ModalFooter } from './components/Modal/ModalFooter/ModalFooter' From 7ac0c35ded62ef9c864b19abc013144f49563576 Mon Sep 17 00:00:00 2001 From: Arianna Kellogg Date: Mon, 17 May 2021 17:40:01 -0700 Subject: [PATCH 10/38] Remove ModalCloseButton from Storybook example with a forced action --- src/components/Modal/Modal/Modal.stories.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/components/Modal/Modal/Modal.stories.tsx b/src/components/Modal/Modal/Modal.stories.tsx index a94bd4b011..da24e6d648 100644 --- a/src/components/Modal/Modal/Modal.stories.tsx +++ b/src/components/Modal/Modal/Modal.stories.tsx @@ -96,9 +96,6 @@ export const modalWithForcedAction = (): React.ReactElement => ( - - Close this window - ) From 7f4c414f24d99a75ec3b2c24fbe0947e4772f62a Mon Sep 17 00:00:00 2001 From: Arianna Kellogg Date: Tue, 15 Jun 2021 15:30:34 -0700 Subject: [PATCH 11/38] Add data-close-modal attribute to close button, comment for unique ID in parent Modal component --- src/components/Modal/Modal/Modal.tsx | 2 ++ src/components/Modal/ModalCloseButton/ModalCloseButton.tsx | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/Modal/Modal/Modal.tsx b/src/components/Modal/Modal/Modal.tsx index fbcda3a22b..4f42e24e84 100644 --- a/src/components/Modal/Modal/Modal.tsx +++ b/src/components/Modal/Modal/Modal.tsx @@ -21,6 +21,8 @@ export const Modal = ({ }, className ) + + // needs a unique ID return (
{children} diff --git a/src/components/Modal/ModalCloseButton/ModalCloseButton.tsx b/src/components/Modal/ModalCloseButton/ModalCloseButton.tsx index 6ded4de577..1e4897f958 100644 --- a/src/components/Modal/ModalCloseButton/ModalCloseButton.tsx +++ b/src/components/Modal/ModalCloseButton/ModalCloseButton.tsx @@ -16,7 +16,7 @@ export const ModalCloseButton = ({ const classes = classnames('usa-modal__close', className) return ( - ) From 344ecfd31ee76e99e26974892cd82bd76704ef7b Mon Sep 17 00:00:00 2001 From: Arianna Kellogg Date: Tue, 15 Jun 2021 15:38:29 -0700 Subject: [PATCH 12/38] Add skeleton for ModalWrapper component, passing one test --- .../Modal/ModalWrapper/ModalWrapper.test.tsx | 12 ++++++++++ .../Modal/ModalWrapper/ModalWrapper.tsx | 23 +++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 src/components/Modal/ModalWrapper/ModalWrapper.test.tsx create mode 100644 src/components/Modal/ModalWrapper/ModalWrapper.tsx diff --git a/src/components/Modal/ModalWrapper/ModalWrapper.test.tsx b/src/components/Modal/ModalWrapper/ModalWrapper.test.tsx new file mode 100644 index 0000000000..78a6f118c1 --- /dev/null +++ b/src/components/Modal/ModalWrapper/ModalWrapper.test.tsx @@ -0,0 +1,12 @@ +import React from 'react' +import { render } from '@testing-library/react' + +import { ModalWrapper } from './ModalWrapper' + +describe('ModalWrapper component', () => { + it('renders without errors', () => { + const { getByTestId } = render(children) + + expect(getByTestId('modalWrapper')).toBeInTheDocument() + }) +}) diff --git a/src/components/Modal/ModalWrapper/ModalWrapper.tsx b/src/components/Modal/ModalWrapper/ModalWrapper.tsx new file mode 100644 index 0000000000..b0b50dad53 --- /dev/null +++ b/src/components/Modal/ModalWrapper/ModalWrapper.tsx @@ -0,0 +1,23 @@ +import React from 'react' +import classnames from 'classnames' + +interface ModalWrapperProps { + children: React.ReactNode + className?: string +} + +export const ModalWrapper = ({ + children, + className, + ...divProps +}: ModalWrapperProps & JSX.IntrinsicElements['div']): React.ReactElement => { + const classes = classnames('usa-modal-wrapper', className) + + return ( +
+ {children} +
+ ) +} + +export default ModalWrapper From 08ebb2fdf65722ed765f14677b9874fabffe4e29 Mon Sep 17 00:00:00 2001 From: Arianna Kellogg Date: Wed, 16 Jun 2021 05:58:09 -0700 Subject: [PATCH 13/38] Rendering placeholder Storybook example for ModalWrapper --- src/components/Modal/Modal/Modal.stories.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/components/Modal/Modal/Modal.stories.tsx b/src/components/Modal/Modal/Modal.stories.tsx index da24e6d648..1f169346ea 100644 --- a/src/components/Modal/Modal/Modal.stories.tsx +++ b/src/components/Modal/Modal/Modal.stories.tsx @@ -7,6 +7,7 @@ import { ModalDescription } from '../ModalDescription/ModalDescription' import { ModalFooter } from '../ModalFooter/ModalFooter' import { ModalHeading } from '../ModalHeading/ModalHeading' import { ModalMain } from '../ModalMain/ModalMain' +import { ModalWrapper } from '../ModalWrapper/ModalWrapper' import { Button } from '../../Button/Button' import { ButtonGroup } from '../../ButtonGroup/ButtonGroup' @@ -29,6 +30,10 @@ Source: http://designsystem.digital.gov/components/modal }, } +export const modalWrapper = (): React.ReactElement => ( + some children +) + export const defaultModal = (): React.ReactElement => ( From 11aa30e2631be0555ef38c9f251d2863c41f945a Mon Sep 17 00:00:00 2001 From: Arianna Kellogg Date: Tue, 22 Jun 2021 08:40:17 -0700 Subject: [PATCH 14/38] Add isVisible prop to ModalWrapper --- src/components/Modal/Modal/Modal.stories.tsx | 2 +- .../Modal/ModalWrapper/ModalWrapper.test.tsx | 4 +++- src/components/Modal/ModalWrapper/ModalWrapper.tsx | 11 ++++++++++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/components/Modal/Modal/Modal.stories.tsx b/src/components/Modal/Modal/Modal.stories.tsx index 1f169346ea..06a47ba0d5 100644 --- a/src/components/Modal/Modal/Modal.stories.tsx +++ b/src/components/Modal/Modal/Modal.stories.tsx @@ -31,7 +31,7 @@ Source: http://designsystem.digital.gov/components/modal } export const modalWrapper = (): React.ReactElement => ( - some children + some children ) export const defaultModal = (): React.ReactElement => ( diff --git a/src/components/Modal/ModalWrapper/ModalWrapper.test.tsx b/src/components/Modal/ModalWrapper/ModalWrapper.test.tsx index 78a6f118c1..5afe523757 100644 --- a/src/components/Modal/ModalWrapper/ModalWrapper.test.tsx +++ b/src/components/Modal/ModalWrapper/ModalWrapper.test.tsx @@ -5,7 +5,9 @@ import { ModalWrapper } from './ModalWrapper' describe('ModalWrapper component', () => { it('renders without errors', () => { - const { getByTestId } = render(children) + const { getByTestId } = render( + children + ) expect(getByTestId('modalWrapper')).toBeInTheDocument() }) diff --git a/src/components/Modal/ModalWrapper/ModalWrapper.tsx b/src/components/Modal/ModalWrapper/ModalWrapper.tsx index b0b50dad53..4df43c321a 100644 --- a/src/components/Modal/ModalWrapper/ModalWrapper.tsx +++ b/src/components/Modal/ModalWrapper/ModalWrapper.tsx @@ -3,15 +3,24 @@ import classnames from 'classnames' interface ModalWrapperProps { children: React.ReactNode + isVisible: boolean className?: string } export const ModalWrapper = ({ children, + isVisible, className, ...divProps }: ModalWrapperProps & JSX.IntrinsicElements['div']): React.ReactElement => { - const classes = classnames('usa-modal-wrapper', className) + const classes = classnames( + 'usa-modal-wrapper', + { + 'is-visible': isVisible, + 'is-hidden': !isVisible, + }, + className + ) return (
From e633f9bcf59c0e09dd8de17f7d63416161b4a500 Mon Sep 17 00:00:00 2001 From: Arianna Kellogg Date: Mon, 28 Jun 2021 14:24:33 -0700 Subject: [PATCH 15/38] Modal storybook update --- src/components/Modal/Modal/Modal.stories.tsx | 132 ++++++++++--------- 1 file changed, 67 insertions(+), 65 deletions(-) diff --git a/src/components/Modal/Modal/Modal.stories.tsx b/src/components/Modal/Modal/Modal.stories.tsx index 06a47ba0d5..bce339b2ff 100644 --- a/src/components/Modal/Modal/Modal.stories.tsx +++ b/src/components/Modal/Modal/Modal.stories.tsx @@ -30,77 +30,79 @@ Source: http://designsystem.digital.gov/components/modal }, } -export const modalWrapper = (): React.ReactElement => ( - some children -) - export const defaultModal = (): React.ReactElement => ( - - - - - Are you sure you want to continue? - - -

You have unsaved changes that will be lost.

-
- - - - - - -
- - Close this window - -
-
+ + + + + + Are you sure you want to continue? + + +

You have unsaved changes that will be lost.

+
+ + + + + + +
+ + Close this window + +
+
+
) // export const largeModal = (): React.ReactElement => ( - - large modal - - - - Are you sure you want to continue? - - -

You have unsaved changes that will be lost.

-
- - - - - - -
- - Close this window - -
-
+ + + large modal + + + + Are you sure you want to continue? + + +

You have unsaved changes that will be lost.

+
+ + + + + + +
+ + Close this window + +
+
+
) export const modalWithForcedAction = (): React.ReactElement => ( - - - - - Are you sure you want to continue? - - -

You have unsaved changes that will be lost.

-
- - - - - - -
-
-
+ + + + + + Are you sure you want to continue? + + +

You have unsaved changes that will be lost.

+
+ + + + + + +
+
+
+
) From cefff0dd326ba4b4a7d6e9f509a14e8d34e4c194 Mon Sep 17 00:00:00 2001 From: Suzanne Rozier Date: Wed, 29 Sep 2021 11:45:11 -0500 Subject: [PATCH 16/38] 2.11 update --- package.json | 4 ++-- src/components/Accordion/Accordion.tsx | 4 ++-- src/components/Alert/Alert.tsx | 2 +- .../forms/Validation/Validation.stories.tsx | 2 +- .../stepindicator/StepIndicator/StepIndicator.tsx | 4 ++-- yarn.lock | 11 +++++------ 6 files changed, 13 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index 23c465d90e..23aa197605 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ "peerDependencies": { "react": "^16.x || ^17.x", "react-dom": "^16.x || ^17.x", - "uswds": "2.10.3" + "uswds": "2.11.2" }, "devDependencies": { "@babel/core": "^7.10.5", @@ -129,7 +129,7 @@ "stylelint-scss": "^3.17.1", "ts-jest": "^26.1.2", "typescript": "^4.2.4", - "uswds": "2.10.3", + "uswds": "2.11.2", "webpack": "^5.52.1", "webpack-cli": "^4.0.0" }, diff --git a/src/components/Accordion/Accordion.tsx b/src/components/Accordion/Accordion.tsx index b4ccce04e2..7d535623f6 100644 --- a/src/components/Accordion/Accordion.tsx +++ b/src/components/Accordion/Accordion.tsx @@ -34,7 +34,7 @@ export const AccordionItem = ({ return ( <> -

+

-

+
- {heading &&

{heading}

} + {heading &&

{heading}

} {children && (validation ? ( children diff --git a/src/components/forms/Validation/Validation.stories.tsx b/src/components/forms/Validation/Validation.stories.tsx index b3cc7200c1..718aaf528a 100644 --- a/src/components/forms/Validation/Validation.stories.tsx +++ b/src/components/forms/Validation/Validation.stories.tsx @@ -62,7 +62,7 @@ export const Default = (): React.ReactElement => { onSubmit={(): void => { console.log('submit') }}> -
+
diff --git a/src/components/stepindicator/StepIndicator/StepIndicator.tsx b/src/components/stepindicator/StepIndicator/StepIndicator.tsx index d783cfcf38..db27870684 100644 --- a/src/components/stepindicator/StepIndicator/StepIndicator.tsx +++ b/src/components/stepindicator/StepIndicator/StepIndicator.tsx @@ -54,7 +54,7 @@ export const StepIndicator = ( {children}
-

+

Step @@ -67,7 +67,7 @@ export const StepIndicator = ( {currentStepLabel} -

+
) diff --git a/yarn.lock b/yarn.lock index b211b3bc4e..d2e9451f5e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10989,7 +10989,7 @@ lodash.clonedeep@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8= -lodash.debounce@^4.0.7, lodash.debounce@^4.0.8: +lodash.debounce@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168= @@ -15861,16 +15861,15 @@ use@^3.1.0: resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ== -uswds@2.10.3: - version "2.10.3" - resolved "https://registry.yarnpkg.com/uswds/-/uswds-2.10.3.tgz#16d34cee81897762d6d69d3ac83aa55129826fa6" - integrity sha512-krNRzx1jRzOJpuH/qtmQhd5zxnXTaDVqrPNYT99sJbxzWUqjb1zZHh3jFNo+xKDpNuiO0XMPwZwlaSp2YdZ3Ag== +uswds@2.11.2: + version "2.11.2" + resolved "https://registry.yarnpkg.com/uswds/-/uswds-2.11.2.tgz#bf5ff132f6324c0939d01b7eefebde340c22dcec" + integrity sha512-JISTXCjPIlrufbObIifjrMDn5jF9bbLu7UYhGWmEs9iqB6Z2KDCXHVoBUyzMmIrIjW/UWWYHZzPqOOHO6/IMCQ== dependencies: classlist-polyfill "^1.0.3" del "^6.0.0" domready "^1.0.8" elem-dataset "^2.0.0" - lodash.debounce "^4.0.7" object-assign "^4.1.1" receptor "^1.0.0" resolve-id-refs "^0.1.0" From 99449118c7b1707dd793ab98aa7afb81cd866fb6 Mon Sep 17 00:00:00 2001 From: Suzanne Rozier Date: Wed, 29 Sep 2021 13:49:55 -0500 Subject: [PATCH 17/38] Update thumbs down icon --- src/components/Icon/Icons.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Icon/Icons.ts b/src/components/Icon/Icons.ts index ab23653fa7..94272e418e 100644 --- a/src/components/Icon/Icons.ts +++ b/src/components/Icon/Icons.ts @@ -208,7 +208,7 @@ import StoreSvg from 'uswds/dist/img/usa-icons/store.svg?svgr' import SupportSvg from 'uswds/dist/img/usa-icons/support.svg?svgr' import SupportAgentSvg from 'uswds/dist/img/usa-icons/support_agent.svg?svgr' import TextFieldsSvg from 'uswds/dist/img/usa-icons/text_fields.svg?svgr' -import ThumbDownAltSvg from 'uswds/dist/img/usa-icons/thumb_down_off_alt.svg?svgr' +import ThumbDownAltSvg from 'uswds/dist/img/usa-icons/thumb_down_alt.svg?svgr' import ThumbUpAltSvg from 'uswds/dist/img/usa-icons/thumb_up_alt.svg?svgr' import TimerSvg from 'uswds/dist/img/usa-icons/timer.svg?svgr' import ToggleOffSvg from 'uswds/dist/img/usa-icons/toggle_off.svg?svgr' From 519bf2c0bf3fed452f1065ccd9ffc9809659ce4d Mon Sep 17 00:00:00 2001 From: Suzanne Rozier Date: Thu, 30 Sep 2021 09:40:06 -0500 Subject: [PATCH 18/38] Simplify modal components --- src/components/Modal/Modal/Modal.stories.tsx | 148 ++++++++++-------- src/components/Modal/Modal/Modal.tsx | 20 ++- .../ModalCloseButton.test.tsx | 22 ++- .../ModalCloseButton/ModalCloseButton.tsx | 22 +-- .../Modal/ModalHeading/ModalHeading.test.tsx | 14 +- .../Modal/ModalHeading/ModalHeading.tsx | 25 +-- src/index.ts | 4 - 7 files changed, 132 insertions(+), 123 deletions(-) diff --git a/src/components/Modal/Modal/Modal.stories.tsx b/src/components/Modal/Modal/Modal.stories.tsx index bce339b2ff..5e8a8f794c 100644 --- a/src/components/Modal/Modal/Modal.stories.tsx +++ b/src/components/Modal/Modal/Modal.stories.tsx @@ -1,19 +1,13 @@ import React from 'react' import { Modal } from './Modal' -import { ModalCloseButton } from '../ModalCloseButton/ModalCloseButton' -import { ModalContent } from '../ModalContent/ModalContent' -import { ModalDescription } from '../ModalDescription/ModalDescription' import { ModalFooter } from '../ModalFooter/ModalFooter' import { ModalHeading } from '../ModalHeading/ModalHeading' -import { ModalMain } from '../ModalMain/ModalMain' import { ModalWrapper } from '../ModalWrapper/ModalWrapper' import { Button } from '../../Button/Button' import { ButtonGroup } from '../../ButtonGroup/ButtonGroup' -import close from 'uswds/src/img/close.svg' - export default { title: 'Components/Modal', component: Modal, @@ -21,7 +15,7 @@ export default { docs: { description: { component: ` -### USWDS 2.0 SiteAlert component +### USWDS 2.0 Modal component Source: http://designsystem.digital.gov/components/modal `, @@ -32,77 +26,99 @@ Source: http://designsystem.digital.gov/components/modal export const defaultModal = (): React.ReactElement => ( - - - - - Are you sure you want to continue? - - -

You have unsaved changes that will be lost.

-
- - - - - - -
- - Close this window - -
+ + + Are you sure you want to continue? + +
+ +
+ + + + + +
) -// export const largeModal = (): React.ReactElement => ( - - large modal - - - - Are you sure you want to continue? - - -

You have unsaved changes that will be lost.

-
- - - - - - -
- - Close this window - -
+ + + Are you sure you want to continue? + +
+ +
+ + + + + +
) export const modalWithForcedAction = (): React.ReactElement => ( - - - - - Are you sure you want to continue? - - -

You have unsaved changes that will be lost.

-
- - - - - - -
-
+ + + Your session will end soon. + +
+ +
+ + + + + +
) diff --git a/src/components/Modal/Modal/Modal.tsx b/src/components/Modal/Modal/Modal.tsx index 4f42e24e84..12b5b759fd 100644 --- a/src/components/Modal/Modal/Modal.tsx +++ b/src/components/Modal/Modal/Modal.tsx @@ -1,17 +1,20 @@ import React from 'react' - import classnames from 'classnames' +import { ModalCloseButton } from '../ModalCloseButton/ModalCloseButton' + interface ModalProps { children: React.ReactNode className?: string isLarge?: boolean + forceAction?: boolean } export const Modal = ({ className, children, isLarge = false, + forceAction = false, ...divProps }: ModalProps & JSX.IntrinsicElements['div']): React.ReactElement => { const classes = classnames( @@ -22,10 +25,21 @@ export const Modal = ({ className ) + const handleClose = () => { + /* */ + } + // needs a unique ID return ( -
- {children} +
+
+
{children}
+ {!forceAction && } +
) } diff --git a/src/components/Modal/ModalCloseButton/ModalCloseButton.test.tsx b/src/components/Modal/ModalCloseButton/ModalCloseButton.test.tsx index 9f54baaf41..1b94b4c474 100644 --- a/src/components/Modal/ModalCloseButton/ModalCloseButton.test.tsx +++ b/src/components/Modal/ModalCloseButton/ModalCloseButton.test.tsx @@ -1,24 +1,22 @@ import React from 'react' -import { render } from '@testing-library/react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { ModalCloseButton } from './ModalCloseButton' describe('ModalCloseButton component', () => { it('renders without errors', () => { - const { queryByRole } = render( - close button - ) + render() - expect(queryByRole('button')).toBeInTheDocument() + expect(screen.queryByRole('button')).toBeInTheDocument() }) - it('accepts a custom className', () => { - const { queryByRole } = render( - close button - ) + it('implements the close handler', () => { + const mockHandleClose = jest.fn() - expect(queryByRole('button')).toHaveClass( - 'usa-button usa-modal__close custom-class' - ) + render() + + userEvent.click(screen.getByRole('button')) + expect(mockHandleClose).toHaveBeenCalled() }) }) diff --git a/src/components/Modal/ModalCloseButton/ModalCloseButton.tsx b/src/components/Modal/ModalCloseButton/ModalCloseButton.tsx index 1e4897f958..45d2bf1194 100644 --- a/src/components/Modal/ModalCloseButton/ModalCloseButton.tsx +++ b/src/components/Modal/ModalCloseButton/ModalCloseButton.tsx @@ -1,23 +1,25 @@ import React from 'react' - -import classnames from 'classnames' +import { IconClose } from '../../Icon/Icons' import { Button } from '../../Button/Button' interface ModalCloseButtonProps { - children: React.ReactNode - className?: string + handleClose: () => void } + export const ModalCloseButton = ({ - children, - className, + handleClose, ...buttonProps }: ModalCloseButtonProps & JSX.IntrinsicElements['button']): React.ReactElement => { - const classes = classnames('usa-modal__close', className) - return ( - ) } diff --git a/src/components/Modal/ModalHeading/ModalHeading.test.tsx b/src/components/Modal/ModalHeading/ModalHeading.test.tsx index b62e1822ab..7b8feff183 100644 --- a/src/components/Modal/ModalHeading/ModalHeading.test.tsx +++ b/src/components/Modal/ModalHeading/ModalHeading.test.tsx @@ -1,17 +1,15 @@ import React from 'react' -import { render } from '@testing-library/react' +import { render, screen } from '@testing-library/react' import { ModalHeading } from './ModalHeading' describe('ModalHeading component', () => { it('renders without errors', () => { - const { queryByTestId, queryByRole } = render( - Are you sure you want to continue? - ) + render(Are you sure you want to continue?) - expect(queryByTestId('modalHeading')).toBeInTheDocument() - expect(queryByRole('heading')).toBeInTheDocument() + expect(screen.queryByRole('heading', { level: 2 })).toBeInTheDocument() + expect(screen.queryByRole('heading', { level: 2 })).toHaveTextContent( + 'Are you sure you want to continue?' + ) }) - - // it('accepts a custom className') }) diff --git a/src/components/Modal/ModalHeading/ModalHeading.tsx b/src/components/Modal/ModalHeading/ModalHeading.tsx index 288bc944ed..349cf5398e 100644 --- a/src/components/Modal/ModalHeading/ModalHeading.tsx +++ b/src/components/Modal/ModalHeading/ModalHeading.tsx @@ -1,31 +1,16 @@ -import React, { DetailedHTMLProps } from 'react' +import React from 'react' import classnames from 'classnames' -interface ModalHeadingProps { - type: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' - className?: string - children?: React.ReactNode -} - -type HeadingModalHeadingProps = ModalHeadingProps & - React.DetailedHTMLProps< - React.HTMLAttributes, - HTMLHeadingElement - > - export const ModalHeading = ({ - type, className, children, ...headingProps -}: HeadingModalHeadingProps): React.ReactElement => { +}: React.HTMLProps): React.ReactElement => { const classes = classnames('usa-modal__heading', className) - const Tag = type + return ( - +

{children} - +

) } - -export default ModalHeading diff --git a/src/index.ts b/src/index.ts index ba324def20..f51ca89474 100644 --- a/src/index.ts +++ b/src/index.ts @@ -87,12 +87,8 @@ export { SocialLinks } from './components/Footer/SocialLinks/SocialLinks' /** Modal components */ export { Modal } from './components/Modal/Modal/Modal' -export { ModalCloseButton } from './components/Modal/ModalCloseButton/ModalCloseButton' -export { ModalContent } from './components/Modal/ModalContent/ModalContent' -export { ModalDescription } from './components/Modal/ModalDescription/ModalDescription' export { ModalFooter } from './components/Modal/ModalFooter/ModalFooter' export { ModalHeading } from './components/Modal/ModalHeading/ModalHeading' -export { ModalMain } from './components/Modal/ModalMain/ModalMain' /** Card components */ export { CardGroup } from './components/card/CardGroup/CardGroup' From 89528eabee692b3921ea2d7eb3918c36fddc8100 Mon Sep 17 00:00:00 2001 From: Suzanne Rozier Date: Thu, 30 Sep 2021 09:49:40 -0500 Subject: [PATCH 19/38] Update tests, remove unused files --- .../Modal/{Modal => }/Modal.stories.tsx | 10 ++--- src/components/Modal/Modal.test.tsx | 43 +++++++++++++++++++ src/components/Modal/{Modal => }/Modal.tsx | 2 +- src/components/Modal/Modal/Modal.test.tsx | 38 ---------------- .../ModalCloseButton/ModalCloseButton.tsx | 2 + .../Modal/ModalContent/ModalContent.test.tsx | 24 ----------- .../Modal/ModalContent/ModalContent.tsx | 23 ---------- .../ModalDescription.test.tsx | 26 ----------- .../ModalDescription/ModalDescription.tsx | 24 ----------- .../Modal/ModalMain/ModalMain.test.tsx | 32 -------------- src/components/Modal/ModalMain/ModalMain.tsx | 25 ----------- src/index.ts | 4 +- 12 files changed, 53 insertions(+), 200 deletions(-) rename src/components/Modal/{Modal => }/Modal.stories.tsx (91%) create mode 100644 src/components/Modal/Modal.test.tsx rename src/components/Modal/{Modal => }/Modal.tsx (92%) delete mode 100644 src/components/Modal/Modal/Modal.test.tsx delete mode 100644 src/components/Modal/ModalContent/ModalContent.test.tsx delete mode 100644 src/components/Modal/ModalContent/ModalContent.tsx delete mode 100644 src/components/Modal/ModalDescription/ModalDescription.test.tsx delete mode 100644 src/components/Modal/ModalDescription/ModalDescription.tsx delete mode 100644 src/components/Modal/ModalMain/ModalMain.test.tsx delete mode 100644 src/components/Modal/ModalMain/ModalMain.tsx diff --git a/src/components/Modal/Modal/Modal.stories.tsx b/src/components/Modal/Modal.stories.tsx similarity index 91% rename from src/components/Modal/Modal/Modal.stories.tsx rename to src/components/Modal/Modal.stories.tsx index 5e8a8f794c..c1f1107971 100644 --- a/src/components/Modal/Modal/Modal.stories.tsx +++ b/src/components/Modal/Modal.stories.tsx @@ -1,12 +1,12 @@ import React from 'react' import { Modal } from './Modal' -import { ModalFooter } from '../ModalFooter/ModalFooter' -import { ModalHeading } from '../ModalHeading/ModalHeading' -import { ModalWrapper } from '../ModalWrapper/ModalWrapper' +import { ModalFooter } from './ModalFooter/ModalFooter' +import { ModalHeading } from './ModalHeading/ModalHeading' +import { ModalWrapper } from './ModalWrapper/ModalWrapper' -import { Button } from '../../Button/Button' -import { ButtonGroup } from '../../ButtonGroup/ButtonGroup' +import { Button } from '../Button/Button' +import { ButtonGroup } from '../ButtonGroup/ButtonGroup' export default { title: 'Components/Modal', diff --git a/src/components/Modal/Modal.test.tsx b/src/components/Modal/Modal.test.tsx new file mode 100644 index 0000000000..6363bbeb82 --- /dev/null +++ b/src/components/Modal/Modal.test.tsx @@ -0,0 +1,43 @@ +import React from 'react' + +import { Modal } from './Modal' + +import { render, screen } from '@testing-library/react' + +describe('Modal component', () => { + it('renders without errors', () => { + render(some children) + expect(screen.queryByTestId('modal')).toBeInTheDocument() + expect(screen.queryByText('some children')).toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'Close this window' }) + ).toBeInTheDocument() + }) + + it('renders large modal when passed isLarge', () => { + render(some children) + expect(screen.getByTestId('modal')).toHaveClass('usa-modal--lg') + }) + + it('accepts attributes passed in through props', () => { + render(some children) + + expect(screen.getByTestId('modal')).toHaveAttribute( + 'aria-label', + 'aria-label-modal' + ) + }) + + it('accepts a custom className', () => { + render(some children) + + expect(screen.getByTestId('modal')).toHaveClass('usa-modal custom-class') + }) + + it('does not render a close button if forceAction is true', () => { + render(some children) + expect( + screen.queryByRole('button', { name: 'Close this window' }) + ).not.toBeInTheDocument() + }) +}) diff --git a/src/components/Modal/Modal/Modal.tsx b/src/components/Modal/Modal.tsx similarity index 92% rename from src/components/Modal/Modal/Modal.tsx rename to src/components/Modal/Modal.tsx index 12b5b759fd..49aa3784b4 100644 --- a/src/components/Modal/Modal/Modal.tsx +++ b/src/components/Modal/Modal.tsx @@ -1,7 +1,7 @@ import React from 'react' import classnames from 'classnames' -import { ModalCloseButton } from '../ModalCloseButton/ModalCloseButton' +import { ModalCloseButton } from './ModalCloseButton/ModalCloseButton' interface ModalProps { children: React.ReactNode diff --git a/src/components/Modal/Modal/Modal.test.tsx b/src/components/Modal/Modal/Modal.test.tsx deleted file mode 100644 index 96501fda98..0000000000 --- a/src/components/Modal/Modal/Modal.test.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import React from 'react' - -import { Modal } from './Modal' - -import { render } from '@testing-library/react' - -describe('Modal component', () => { - it('renders without errors', () => { - const { getByTestId } = render(some children) - - expect(getByTestId('modal')).toBeInTheDocument() - }) - - it('renders large modal when passed isLarge', () => { - const { getByTestId } = render(some children) - - expect(getByTestId('modal')).toHaveClass('usa-modal--lg') - }) - - it('accepts attributes passed in through props', () => { - const { getByTestId } = render( - some children - ) - - expect(getByTestId('modal')).toHaveAttribute( - 'aria-label', - 'aria-label-modal' - ) - }) - - it('accepts a custom className', () => { - const { getByTestId } = render( - some children - ) - - expect(getByTestId('modal')).toHaveClass('usa-modal custom-class') - }) -}) diff --git a/src/components/Modal/ModalCloseButton/ModalCloseButton.tsx b/src/components/Modal/ModalCloseButton/ModalCloseButton.tsx index 45d2bf1194..c71bea520c 100644 --- a/src/components/Modal/ModalCloseButton/ModalCloseButton.tsx +++ b/src/components/Modal/ModalCloseButton/ModalCloseButton.tsx @@ -6,6 +6,8 @@ interface ModalCloseButtonProps { handleClose: () => void } +// TODO - accept custom aria-label + export const ModalCloseButton = ({ handleClose, ...buttonProps diff --git a/src/components/Modal/ModalContent/ModalContent.test.tsx b/src/components/Modal/ModalContent/ModalContent.test.tsx deleted file mode 100644 index 695f6fd2ef..0000000000 --- a/src/components/Modal/ModalContent/ModalContent.test.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react' -import { render } from '@testing-library/react' - -import { ModalContent } from './ModalContent' - -describe('ModalContent component', () => { - it('renders without errors', () => { - const { queryByTestId } = render( - You have unsaved changes that will be lost. - ) - - expect(queryByTestId('modalContent')).toBeInTheDocument() - }) - - it('accepts a custom className', () => { - const { queryByTestId } = render( - - You have unsaved changes that will be lost. - - ) - - expect(queryByTestId('modalContent')).toHaveClass('custom-class') - }) -}) diff --git a/src/components/Modal/ModalContent/ModalContent.tsx b/src/components/Modal/ModalContent/ModalContent.tsx deleted file mode 100644 index 59713ce09b..0000000000 --- a/src/components/Modal/ModalContent/ModalContent.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React from 'react' -import classnames from 'classnames' - -interface ModalContentProps { - children: React.ReactNode - className?: string -} - -export const ModalContent = ({ - children, - className, - ...divProps -}: ModalContentProps & JSX.IntrinsicElements['div']): React.ReactElement => { - const classes = classnames('usa-modal__content', className) - - return ( -
- {children} -
- ) -} - -export default ModalContent diff --git a/src/components/Modal/ModalDescription/ModalDescription.test.tsx b/src/components/Modal/ModalDescription/ModalDescription.test.tsx deleted file mode 100644 index 44bf4097fb..0000000000 --- a/src/components/Modal/ModalDescription/ModalDescription.test.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import React from 'react' -import { render } from '@testing-library/react' - -import { ModalDescription } from './ModalDescription' - -describe('ModalDescription component', () => { - it('renders without errors', () => { - const { queryByTestId } = render( - -

You have unsaved changes that will be lost.

-
- ) - - expect(queryByTestId('modalDescription')).toBeInTheDocument() - }) - - it('accepts a custom className', () => { - const { queryByTestId } = render( - -

You have unsaved changes that will be lost.

-
- ) - - expect(queryByTestId('modalDescription')).toBeInTheDocument() - }) -}) diff --git a/src/components/Modal/ModalDescription/ModalDescription.tsx b/src/components/Modal/ModalDescription/ModalDescription.tsx deleted file mode 100644 index 7806a08d89..0000000000 --- a/src/components/Modal/ModalDescription/ModalDescription.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react' -import classnames from 'classnames' - -interface ModalDescriptionProps { - children: React.ReactNode - className?: string -} - -export const ModalDescription = ({ - children, - className, - ...divProps -}: ModalDescriptionProps & - JSX.IntrinsicElements['div']): React.ReactElement => { - const classes = classnames('usa-prose', className) - - return ( -
- {children} -
- ) -} - -export default ModalDescription diff --git a/src/components/Modal/ModalMain/ModalMain.test.tsx b/src/components/Modal/ModalMain/ModalMain.test.tsx deleted file mode 100644 index 67b0fdc3cb..0000000000 --- a/src/components/Modal/ModalMain/ModalMain.test.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react' - -import { ModalMain } from './ModalMain' - -import { render } from '@testing-library/react' - -describe('ModalMain component', () => { - it('renders without errors', () => { - const { getByTestId } = render(some children) - - expect(getByTestId('modalMain')).toBeInTheDocument() - }) - - it('accepts attributes passed in through props', () => { - const { getByTestId } = render( - some children - ) - - expect(getByTestId('modalMain')).toHaveAttribute( - 'aria-label', - 'aria-label-model' - ) - }) - - it('accepts a custom className', () => { - const { getByTestId } = render( - some children - ) - - expect(getByTestId('modalMain')).toHaveClass('usa-modal__main custom-class') - }) -}) diff --git a/src/components/Modal/ModalMain/ModalMain.tsx b/src/components/Modal/ModalMain/ModalMain.tsx deleted file mode 100644 index 5ee2aee68f..0000000000 --- a/src/components/Modal/ModalMain/ModalMain.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import React from 'react' - -import classnames from 'classnames' - -interface ModalMainProps { - children: React.ReactNode - className?: string - isLarge?: boolean -} - -export const ModalMain = ({ - className, - children, - isLarge = false, - ...divProps -}: ModalMainProps & JSX.IntrinsicElements['div']): React.ReactElement => { - const classes = classnames('usa-modal__main', className) - return ( -
- {children} -
- ) -} - -export default ModalMain diff --git a/src/index.ts b/src/index.ts index f51ca89474..b38e7537dc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -86,9 +86,9 @@ export { Logo } from './components/Footer/Logo/Logo' export { SocialLinks } from './components/Footer/SocialLinks/SocialLinks' /** Modal components */ -export { Modal } from './components/Modal/Modal/Modal' -export { ModalFooter } from './components/Modal/ModalFooter/ModalFooter' +export { Modal } from './components/Modal/Modal' export { ModalHeading } from './components/Modal/ModalHeading/ModalHeading' +export { ModalFooter } from './components/Modal/ModalFooter/ModalFooter' /** Card components */ export { CardGroup } from './components/card/CardGroup/CardGroup' From ac72177fe9233b7f53ec5ffebd9ea549057f9683 Mon Sep 17 00:00:00 2001 From: Suzanne Rozier Date: Thu, 30 Sep 2021 10:24:02 -0500 Subject: [PATCH 20/38] Move Modal to ModalWindow component --- src/components/Modal/Modal.stories.tsx | 124 ------------------ .../ModalCloseButton/ModalCloseButton.tsx | 2 - .../Modal/ModalFooter/ModalFooter.tsx | 2 - .../Modal/ModalWindow/ModalWindow.stories.tsx | 117 +++++++++++++++++ .../ModalWindow.test.tsx} | 14 +- .../ModalWindow.tsx} | 10 +- src/index.ts | 2 +- 7 files changed, 130 insertions(+), 141 deletions(-) delete mode 100644 src/components/Modal/Modal.stories.tsx create mode 100644 src/components/Modal/ModalWindow/ModalWindow.stories.tsx rename src/components/Modal/{Modal.test.tsx => ModalWindow/ModalWindow.test.tsx} (72%) rename src/components/Modal/{Modal.tsx => ModalWindow/ModalWindow.tsx} (78%) diff --git a/src/components/Modal/Modal.stories.tsx b/src/components/Modal/Modal.stories.tsx deleted file mode 100644 index c1f1107971..0000000000 --- a/src/components/Modal/Modal.stories.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import React from 'react' - -import { Modal } from './Modal' -import { ModalFooter } from './ModalFooter/ModalFooter' -import { ModalHeading } from './ModalHeading/ModalHeading' -import { ModalWrapper } from './ModalWrapper/ModalWrapper' - -import { Button } from '../Button/Button' -import { ButtonGroup } from '../ButtonGroup/ButtonGroup' - -export default { - title: 'Components/Modal', - component: Modal, - parameters: { - docs: { - description: { - component: ` -### USWDS 2.0 Modal component - -Source: http://designsystem.digital.gov/components/modal -`, - }, - }, - }, -} - -export const defaultModal = (): React.ReactElement => ( - - - - Are you sure you want to continue? - -
- -
- - - - - - -
-
-) - -export const largeModal = (): React.ReactElement => ( - - - - Are you sure you want to continue? - -
- -
- - - - - - -
-
-) - -export const modalWithForcedAction = (): React.ReactElement => ( - - - - Your session will end soon. - -
- -
- - - - - - -
-
-) diff --git a/src/components/Modal/ModalCloseButton/ModalCloseButton.tsx b/src/components/Modal/ModalCloseButton/ModalCloseButton.tsx index c71bea520c..3997671e38 100644 --- a/src/components/Modal/ModalCloseButton/ModalCloseButton.tsx +++ b/src/components/Modal/ModalCloseButton/ModalCloseButton.tsx @@ -25,5 +25,3 @@ export const ModalCloseButton = ({ ) } - -export default ModalCloseButton diff --git a/src/components/Modal/ModalFooter/ModalFooter.tsx b/src/components/Modal/ModalFooter/ModalFooter.tsx index 6b382ab786..ed1f676949 100644 --- a/src/components/Modal/ModalFooter/ModalFooter.tsx +++ b/src/components/Modal/ModalFooter/ModalFooter.tsx @@ -19,5 +19,3 @@ export const ModalFooter = ({
) } - -export default ModalFooter diff --git a/src/components/Modal/ModalWindow/ModalWindow.stories.tsx b/src/components/Modal/ModalWindow/ModalWindow.stories.tsx new file mode 100644 index 0000000000..4df8de6832 --- /dev/null +++ b/src/components/Modal/ModalWindow/ModalWindow.stories.tsx @@ -0,0 +1,117 @@ +import React from 'react' + +import { ModalWindow } from './ModalWindow' +import { ModalFooter } from '../ModalFooter/ModalFooter' +import { ModalHeading } from '../ModalHeading/ModalHeading' + +import { Button } from '../../Button/Button' +import { ButtonGroup } from '../../ButtonGroup/ButtonGroup' + +export default { + title: 'Components/Modal/ModalWindow', + component: ModalWindow, + parameters: { + docs: { + description: { + component: ` +### USWDS 2.0 Modal component + +Source: http://designsystem.digital.gov/components/modal +`, + }, + }, + }, +} + +export const defaultModal = (): React.ReactElement => ( + + + Are you sure you want to continue? + +
+ +
+ + + + + + +
+) + +export const largeModal = (): React.ReactElement => ( + + + Are you sure you want to continue? + +
+ +
+ + + + + + +
+) + +export const modalWithForcedAction = (): React.ReactElement => ( + + + Your session will end soon. + +
+ +
+ + + + + + +
+) diff --git a/src/components/Modal/Modal.test.tsx b/src/components/Modal/ModalWindow/ModalWindow.test.tsx similarity index 72% rename from src/components/Modal/Modal.test.tsx rename to src/components/Modal/ModalWindow/ModalWindow.test.tsx index 6363bbeb82..d310de8ad8 100644 --- a/src/components/Modal/Modal.test.tsx +++ b/src/components/Modal/ModalWindow/ModalWindow.test.tsx @@ -1,12 +1,12 @@ import React from 'react' -import { Modal } from './Modal' +import { ModalWindow } from './ModalWindow' import { render, screen } from '@testing-library/react' describe('Modal component', () => { it('renders without errors', () => { - render(some children) + render(some children) expect(screen.queryByTestId('modal')).toBeInTheDocument() expect(screen.queryByText('some children')).toBeInTheDocument() expect( @@ -15,12 +15,14 @@ describe('Modal component', () => { }) it('renders large modal when passed isLarge', () => { - render(some children) + render(some children) expect(screen.getByTestId('modal')).toHaveClass('usa-modal--lg') }) it('accepts attributes passed in through props', () => { - render(some children) + render( + some children + ) expect(screen.getByTestId('modal')).toHaveAttribute( 'aria-label', @@ -29,13 +31,13 @@ describe('Modal component', () => { }) it('accepts a custom className', () => { - render(some children) + render(some children) expect(screen.getByTestId('modal')).toHaveClass('usa-modal custom-class') }) it('does not render a close button if forceAction is true', () => { - render(some children) + render(some children) expect( screen.queryByRole('button', { name: 'Close this window' }) ).not.toBeInTheDocument() diff --git a/src/components/Modal/Modal.tsx b/src/components/Modal/ModalWindow/ModalWindow.tsx similarity index 78% rename from src/components/Modal/Modal.tsx rename to src/components/Modal/ModalWindow/ModalWindow.tsx index 49aa3784b4..2deb9a69a1 100644 --- a/src/components/Modal/Modal.tsx +++ b/src/components/Modal/ModalWindow/ModalWindow.tsx @@ -1,22 +1,22 @@ import React from 'react' import classnames from 'classnames' -import { ModalCloseButton } from './ModalCloseButton/ModalCloseButton' +import { ModalCloseButton } from '../ModalCloseButton/ModalCloseButton' -interface ModalProps { +interface ModalWindowProps { children: React.ReactNode className?: string isLarge?: boolean forceAction?: boolean } -export const Modal = ({ +export const ModalWindow = ({ className, children, isLarge = false, forceAction = false, ...divProps -}: ModalProps & JSX.IntrinsicElements['div']): React.ReactElement => { +}: ModalWindowProps & JSX.IntrinsicElements['div']): React.ReactElement => { const classes = classnames( 'usa-modal', { @@ -43,5 +43,3 @@ export const Modal = ({
) } - -export default Modal diff --git a/src/index.ts b/src/index.ts index b38e7537dc..3d6f1b23cb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -86,7 +86,7 @@ export { Logo } from './components/Footer/Logo/Logo' export { SocialLinks } from './components/Footer/SocialLinks/SocialLinks' /** Modal components */ -export { Modal } from './components/Modal/Modal' +export { ModalWindow } from './components/Modal/ModalWindow/ModalWindow' export { ModalHeading } from './components/Modal/ModalHeading/ModalHeading' export { ModalFooter } from './components/Modal/ModalFooter/ModalFooter' From 3ae7aeb668e9b41ca18a0c1704004826bdfa911a Mon Sep 17 00:00:00 2001 From: Suzanne Rozier Date: Thu, 30 Sep 2021 15:10:08 -0500 Subject: [PATCH 21/38] Setting up modal functionality --- src/components/Modal/Modal.tsx | 57 +++++++++++++++++++ src/components/Modal/ModalOpenButton.tsx | 33 +++++++++++ .../Modal/ModalWindow/ModalWindow.tsx | 12 ++-- .../Modal/ModalWrapper/ModalWrapper.tsx | 8 ++- src/components/Modal/utils.ts | 18 ++++++ src/index.ts | 4 +- 6 files changed, 124 insertions(+), 8 deletions(-) create mode 100644 src/components/Modal/Modal.tsx create mode 100644 src/components/Modal/ModalOpenButton.tsx create mode 100644 src/components/Modal/utils.ts diff --git a/src/components/Modal/Modal.tsx b/src/components/Modal/Modal.tsx new file mode 100644 index 0000000000..c990ec6cf1 --- /dev/null +++ b/src/components/Modal/Modal.tsx @@ -0,0 +1,57 @@ +import React from 'react' + +import { ModalHook } from './utils' +import { ModalWindow } from './ModalWindow/ModalWindow' +import { ModalWrapper } from './ModalWrapper/ModalWrapper' + +interface ModalProps { + id: string + children: React.ReactNode + className?: string + isLarge?: boolean + forceAction?: boolean +} + +// isopen effect +// return open/close functions +// close button controls + +export const Modal = ({ + id, + children, + isOpen, + closeModal, + isLarge = false, + forceAction = false, + ...divProps +}: ModalProps & + ModalHook & + JSX.IntrinsicElements['div']): React.ReactElement => { + const ariaLabelledBy = divProps['aria-labelledby'] + const ariaDescribedBy = divProps['aria-describedby'] + + delete divProps['aria-labelledby'] + delete divProps['aria-describedby'] + + return ( + + + {children} + + + ) +} + +export default Modal diff --git a/src/components/Modal/ModalOpenButton.tsx b/src/components/Modal/ModalOpenButton.tsx new file mode 100644 index 0000000000..c1221f7aef --- /dev/null +++ b/src/components/Modal/ModalOpenButton.tsx @@ -0,0 +1,33 @@ +/* eslint-disable jsx-a11y/interactive-supports-focus */ +/* eslint-disable jsx-a11y/click-events-have-key-events */ +import React from 'react' +import classnames from 'classnames' + +type ModalOpenButtonProps = { + handleOpen: () => void +} + +// TODO - support or + + + + + + ) +} + +export const largeModal = (): React.ReactElement => { + const { isOpen, openModal, closeModal } = useModal() + + return ( + <> + e.preventDefault()}> + Open large modal + + + + Are you sure you want to continue? + +
+ +
+ + + + + + +
+ + ) +} + +export const forceActionModal = (): React.ReactElement => { + const { isOpen, openModal, closeModal } = useModal() + + return ( + <> + e.preventDefault()}> + Open modal with forced action + + + + Your session will end soon. + +
+ +
+ + + + + + +
+ + ) +} diff --git a/src/components/Modal/Modal.tsx b/src/components/Modal/Modal.tsx index 88ec90673c..dd0fc3b1cc 100644 --- a/src/components/Modal/Modal.tsx +++ b/src/components/Modal/Modal.tsx @@ -17,10 +17,10 @@ export type ModalProps = ModalComponentProps & Pick & JSX.IntrinsicElements['div'] -// isOpen effect // modal toggle button (A, button) // - aria-controls=modal ID // focus trap +// click on overlay to close export const Modal = ({ id, diff --git a/src/components/Modal/ModalOpenButton.tsx b/src/components/Modal/ModalOpenButton.tsx index c1221f7aef..c32b045e8d 100644 --- a/src/components/Modal/ModalOpenButton.tsx +++ b/src/components/Modal/ModalOpenButton.tsx @@ -20,13 +20,18 @@ export const ModalOpenButton = ({ JSX.IntrinsicElements['a']): React.ReactElement => { const classes = classnames('usa-button', className) + const handleClick: React.MouseEventHandler = (e) => { + e.preventDefault() + handleOpen() + } + return ( + onClick={handleClick}> {children} ) diff --git a/src/components/Modal/ModalWindow/ModalWindow.stories.tsx b/src/components/Modal/ModalWindow/ModalWindow.stories.tsx deleted file mode 100644 index 4df8de6832..0000000000 --- a/src/components/Modal/ModalWindow/ModalWindow.stories.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import React from 'react' - -import { ModalWindow } from './ModalWindow' -import { ModalFooter } from '../ModalFooter/ModalFooter' -import { ModalHeading } from '../ModalHeading/ModalHeading' - -import { Button } from '../../Button/Button' -import { ButtonGroup } from '../../ButtonGroup/ButtonGroup' - -export default { - title: 'Components/Modal/ModalWindow', - component: ModalWindow, - parameters: { - docs: { - description: { - component: ` -### USWDS 2.0 Modal component - -Source: http://designsystem.digital.gov/components/modal -`, - }, - }, - }, -} - -export const defaultModal = (): React.ReactElement => ( - - - Are you sure you want to continue? - -
- -
- - - - - - -
-) - -export const largeModal = (): React.ReactElement => ( - - - Are you sure you want to continue? - -
- -
- - - - - - -
-) - -export const modalWithForcedAction = (): React.ReactElement => ( - - - Your session will end soon. - -
- -
- - - - - - -
-) From 9c7cf347f311fe8839fbaa6687565a9d283746e0 Mon Sep 17 00:00:00 2001 From: Suzanne Rozier Date: Fri, 1 Oct 2021 14:54:30 -0500 Subject: [PATCH 27/38] Hook up click on overlay --- src/components/Modal/Modal.test.tsx | 96 +++++++++++++++++-- src/components/Modal/Modal.tsx | 4 +- .../Modal/ModalWrapper/ModalWrapper.test.tsx | 14 ++- .../Modal/ModalWrapper/ModalWrapper.tsx | 20 ++-- 4 files changed, 117 insertions(+), 17 deletions(-) diff --git a/src/components/Modal/Modal.test.tsx b/src/components/Modal/Modal.test.tsx index 362de69166..40d359b443 100644 --- a/src/components/Modal/Modal.test.tsx +++ b/src/components/Modal/Modal.test.tsx @@ -42,12 +42,11 @@ describe('Modal component', () => { expect(modalWindow).toHaveAttribute('tabindex', '-1') expect(modalWindow).toHaveTextContent('Test modal') - const closeButton = screen.getByRole('button', { - name: 'Close this window', - }) - expect(closeButton).toBeInTheDocument() - userEvent.click(closeButton) - expect(modalState.closeModal).toHaveBeenCalled() + expect( + screen.getByRole('button', { + name: 'Close this window', + }) + ).toBeInTheDocument() }) it('passes aria props to the modal wrapper', () => { @@ -99,6 +98,47 @@ describe('Modal component', () => { expect(modalWrapper).toHaveClass('is-visible') }) + it('can click on the close button to close', () => { + const modalState = { + isOpen: true, + closeModal: jest.fn(), + } + + const testModalId = 'testModal' + + render( + + Test modal + + ) + + const closeButton = screen.getByRole('button', { + name: 'Close this window', + }) + expect(closeButton).toBeInTheDocument() + userEvent.click(closeButton) + expect(modalState.closeModal).toHaveBeenCalled() + }) + + it('can click on the overlay to close', () => { + const modalState = { + isOpen: true, + closeModal: jest.fn(), + } + + const testModalId = 'testModal' + + render( + + Test modal + + ) + + const overlay = screen.getByTestId('modalOverlay') + userEvent.click(overlay) + expect(modalState.closeModal).toHaveBeenCalled() + }) + it('renders a large modalWindow isLarge is true', () => { const modalState = { isOpen: false, @@ -344,6 +384,31 @@ describe('Modal component', () => { }) describe('if forceAction is true', () => { + it('renders with no close button', () => { + const modalState = { + isOpen: false, + closeModal: jest.fn(), + } + + const testModalId = 'testModal' + + render( + + Test modal + + ) + + // Modal wrapper + const modalWrapper = screen.getByRole('dialog') + expect(modalWrapper).toHaveAttribute('data-force-action', 'true') + + expect( + screen.queryByRole('button', { + name: 'Close this window', + }) + ).not.toBeInTheDocument() + }) + it('styles the body element', () => { const closeModal = jest.fn() const { rerender, baseElement } = render( @@ -382,6 +447,25 @@ describe('Modal component', () => { expect(baseElement).not.toHaveClass('usa-js-no-click') }) + + it('cannot click on the overlay to close', () => { + const modalState = { + isOpen: true, + closeModal: jest.fn(), + } + + const testModalId = 'testModal' + + render( + + Test modal + + ) + + const overlay = screen.getByTestId('modalOverlay') + userEvent.click(overlay) + expect(modalState.closeModal).not.toHaveBeenCalled() + }) }) }) }) diff --git a/src/components/Modal/Modal.tsx b/src/components/Modal/Modal.tsx index dd0fc3b1cc..5f0e009819 100644 --- a/src/components/Modal/Modal.tsx +++ b/src/components/Modal/Modal.tsx @@ -119,7 +119,9 @@ export const Modal = ({ aria-labelledby={ariaLabelledBy} aria-describedby={ariaDescribedBy} data-force-action={forceAction} - isVisible={isOpen}> + isVisible={isOpen} + handleClose={closeModal} + forceAction={forceAction}> { it('renders without errors', () => { - const { getByTestId } = render( - children + render( + + children + ) - expect(getByTestId('modalWrapper')).toBeInTheDocument() + expect(screen.getByRole('dialog')).toBeInTheDocument() }) }) diff --git a/src/components/Modal/ModalWrapper/ModalWrapper.tsx b/src/components/Modal/ModalWrapper/ModalWrapper.tsx index 66533f185f..36280425fc 100644 --- a/src/components/Modal/ModalWrapper/ModalWrapper.tsx +++ b/src/components/Modal/ModalWrapper/ModalWrapper.tsx @@ -2,19 +2,21 @@ import React from 'react' import classnames from 'classnames' interface ModalWrapperProps { + id: string children: React.ReactNode isVisible: boolean + forceAction: boolean + handleClose: () => void className?: string } -// Copy attributes from Modal -// Render into a portal -// Toggle body classes - export const ModalWrapper = ({ + id, children, isVisible, + forceAction, className, + handleClose, ...divProps }: ModalWrapperProps & JSX.IntrinsicElements['div']): React.ReactElement => { const classes = classnames( @@ -26,9 +28,15 @@ export const ModalWrapper = ({ className ) + /* eslint-disable jsx-a11y/click-events-have-key-events */ + /* eslint-disable jsx-a11y/no-static-element-interactions */ return ( -
-
+ From 6cff7784b8362703d47678e7519cf12caf521470 Mon Sep 17 00:00:00 2001 From: Suzanne Rozier Date: Mon, 4 Oct 2021 12:12:32 -0500 Subject: [PATCH 28/38] Adding focus, event handlers, tests --- package.json | 1 + src/components/Button/Button.tsx | 132 ++++--- src/components/Modal/Modal.stories.tsx | 50 +++ src/components/Modal/Modal.test.tsx | 374 ++++++++++++++++-- src/components/Modal/Modal.tsx | 68 ++-- .../ModalCloseButton/ModalCloseButton.tsx | 43 +- src/components/Modal/ModalOpenButton.tsx | 4 +- .../Modal/ModalWindow/ModalWindow.tsx | 73 ++-- .../Modal/ModalWrapper/ModalWrapper.tsx | 65 +-- src/components/Modal/utils.ts | 29 +- yarn.lock | 33 +- 11 files changed, 658 insertions(+), 214 deletions(-) diff --git a/package.json b/package.json index 23aa197605..b6b3c5ee2e 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,7 @@ "eslint-plugin-react": "^7.16.0", "eslint-plugin-react-hooks": "^4.0.0", "eslint-plugin-security": "^1.4.0", + "focus-trap-react": "^8.8.1", "happo-plugin-storybook": "^2.7.0", "happo.io": "^6.0.0", "husky": "^4.3.8", diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index ae0d66e687..a6f55f4456 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { forwardRef } from 'react' import classnames from 'classnames' import { deprecationWarning } from '../../deprecation' @@ -30,72 +30,80 @@ interface ButtonProps { unstyled?: boolean } -export const Button = ({ - type, - children, - secondary, - base, - accent, - accentStyle, - outline, - inverse, - size, - big, - small, - icon, - unstyled, - onClick, - className, - ...defaultProps -}: ButtonProps & JSX.IntrinsicElements['button']): React.ReactElement => { - if (big) { - deprecationWarning('Button property big is deprecated. Use size.') - } +export const Button = forwardRef( + ( + { + type, + children, + secondary, + base, + accent, + accentStyle, + outline, + inverse, + size, + big, + small, + icon, + unstyled, + onClick, + className, + ...defaultProps + }: ButtonProps & JSX.IntrinsicElements['button'], + ref: React.ForwardedRef + ): React.ReactElement => { + if (big) { + deprecationWarning('Button property big is deprecated. Use size.') + } - if (icon) { - deprecationWarning('Button property icon is deprecated.') - } + if (icon) { + deprecationWarning('Button property icon is deprecated.') + } - if (accent) { - deprecationWarning('Button property accent is deprecated. Use accentStyle.') - } + if (accent) { + deprecationWarning( + 'Button property accent is deprecated. Use accentStyle.' + ) + } - const isBig = size ? size === 'big' : big - const isSmall = size ? size === 'small' : small + const isBig = size ? size === 'big' : big + const isSmall = size ? size === 'small' : small - if (isSmall) { - deprecationWarning( - 'Small button is deprecated. Use the default, pass in a custom className, or use size big.' - ) - } + if (isSmall) { + deprecationWarning( + 'Small button is deprecated. Use the default, pass in a custom className, or use size big.' + ) + } - const classes = classnames( - 'usa-button', - { - 'usa-button--secondary': secondary, - 'usa-button--base': base, - 'usa-button--accent-cool': accent || accentStyle === 'cool', - 'usa-button--accent-warm': accentStyle === 'warm', - 'usa-button--outline': outline, - 'usa-button--inverse': inverse, - 'usa-button--big': isBig, - 'usa-button--small': isSmall, - 'usa-button--icon': icon, - 'usa-button--unstyled': unstyled, - }, - className - ) + const classes = classnames( + 'usa-button', + { + 'usa-button--secondary': secondary, + 'usa-button--base': base, + 'usa-button--accent-cool': accent || accentStyle === 'cool', + 'usa-button--accent-warm': accentStyle === 'warm', + 'usa-button--outline': outline, + 'usa-button--inverse': inverse, + 'usa-button--big': isBig, + 'usa-button--small': isSmall, + 'usa-button--icon': icon, + 'usa-button--unstyled': unstyled, + }, + className + ) - return ( - - ) -} + return ( + + ) + } +) export default Button diff --git a/src/components/Modal/Modal.stories.tsx b/src/components/Modal/Modal.stories.tsx index f19f76f88e..5c7b3cccd2 100644 --- a/src/components/Modal/Modal.stories.tsx +++ b/src/components/Modal/Modal.stories.tsx @@ -166,3 +166,53 @@ export const forceActionModal = (): React.ReactElement => { ) } + +export const customFocusElementModal = (): React.ReactElement => { + const { isOpen, openModal, closeModal } = useModal() + + return ( + <> + e.preventDefault()}> + Open modal with custom initial focus element + + + + Are you sure you want to continue? + +
+ + + +
+ + + + + + +
+ + ) +} diff --git a/src/components/Modal/Modal.test.tsx b/src/components/Modal/Modal.test.tsx index 40d359b443..006d5cd84e 100644 --- a/src/components/Modal/Modal.test.tsx +++ b/src/components/Modal/Modal.test.tsx @@ -1,16 +1,183 @@ import React from 'react' -import { render, screen } from '@testing-library/react' +import { render, screen, waitFor, fireEvent } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { Modal } from './Modal' -import { getScrollbarWidth } from './utils' - -jest.mock('./utils') +import { useModal } from './utils' +import { ModalHeading } from './ModalHeading/ModalHeading' +import { ModalFooter } from './ModalFooter/ModalFooter' +import { ModalOpenButton } from './ModalOpenButton' +import { Button } from '../Button/Button' +import { ButtonGroup } from '../ButtonGroup/ButtonGroup' + +jest.mock('./utils', () => { + const utils = jest.requireActual('./utils') + + return { + __esModule: true, + ...utils, + getScrollbarWidth: jest.fn().mockReturnValue('15px'), + } +}) -const mockedGetScrollbarWidth = getScrollbarWidth as jest.MockedFunction< - typeof getScrollbarWidth -> -mockedGetScrollbarWidth.mockReturnValue('15px') +const ExampleModal = ({ + forceAction = false, +}: { + forceAction?: boolean +}): React.ReactElement => { + const { isOpen, openModal, closeModal } = useModal() + + return ( + <> + e.preventDefault()}> + Open default modal + + + + Are you sure you want to continue? + +
+ +
+ + + + + + +
+ + ) +} + +const ExampleModalWithDoubleOpen = ({ + openCallback, +}: { + openCallback: () => void +}): React.ReactElement => { + const { isOpen, openModal, closeModal } = useModal() + + const handleDoubleOpen = (e: React.MouseEvent) => { + if (openModal(e)) { + openCallback() + } + } + + return ( + <> + e.preventDefault()}> + Open default modal + + + + Are you sure you want to continue? + +
+ + +
+ + + + + + +
+ + ) +} + +const ExampleModalWithFocusElement = (): React.ReactElement => { + const { isOpen, openModal, closeModal } = useModal() + + return ( + <> + e.preventDefault()}> + Open default modal + + + + Are you sure you want to continue? + +
+ + +
+ + + + + + +
+ + ) +} describe('Modal component', () => { it('renders its children inside a modal wrapper', () => { @@ -383,32 +550,155 @@ describe('Modal component', () => { expect(screen.getByTestId('hidden')).toHaveAttribute('aria-hidden') }) - describe('if forceAction is true', () => { - it('renders with no close button', () => { - const modalState = { - isOpen: false, - closeModal: jest.fn(), - } + it('stops event propagation if toggle modal is called from within a modal', () => { + const mockOpenCallback = jest.fn() - const testModalId = 'testModal' + render(, { + container: document.body, + }) - render( - - Test modal - - ) + const openButton = screen.getByRole('button', { + name: 'Open default modal', + }) + + userEvent.click(openButton) + + userEvent.click( + screen.getByRole('button', { + name: 'This button should not do anything', + }) + ) + + expect(mockOpenCallback).not.toHaveBeenCalled() + }) + + describe('focusing', () => { + it('activates a focus trap', async () => { + render(, { + container: document.body, + }) + + const openButton = screen.getByRole('button', { + name: 'Open default modal', + }) + + userEvent.click(openButton) - // Modal wrapper - const modalWrapper = screen.getByRole('dialog') - expect(modalWrapper).toHaveAttribute('data-force-action', 'true') + await waitFor(() => { + expect(screen.getByRole('dialog')).toHaveClass('is-visible') + expect(screen.getByTestId('modalWindow')).toHaveFocus() + }) + userEvent.tab() expect( - screen.queryByRole('button', { - name: 'Close this window', + screen.getByRole('button', { name: 'Continue without saving' }) + ).toHaveFocus() + + userEvent.tab() + expect(screen.getByRole('button', { name: 'Go back' })).toHaveFocus() + + userEvent.tab() + expect( + screen.getByRole('button', { name: 'Close this window' }) + ).toHaveFocus() + + userEvent.tab() + expect( + screen.getByRole('button', { name: 'Continue without saving' }) + ).toHaveFocus() + }) + + it('returns focus to the opener element on close', async () => { + render(, { + container: document.body, + }) + + const openButton = screen.getByRole('button', { + name: 'Open default modal', + }) + + userEvent.click(openButton) + + await waitFor(() => { + expect(screen.getByRole('dialog')).toHaveClass('is-visible') + expect(screen.getByTestId('modalWindow')).toHaveFocus() + }) + + userEvent.tab() + expect( + screen.getByRole('button', { + name: 'Continue without saving', + }) + ).toHaveFocus() + + userEvent.click( + screen.getByRole('button', { name: 'Close this window' }) + ) + + expect( + screen.getByRole('button', { + name: 'Open default modal', }) - ).not.toBeInTheDocument() + ).toHaveFocus() + }) + + it('the escape key closes the modal', async () => { + render(, { + container: document.body, + }) + + const openButton = screen.getByRole('button', { + name: 'Open default modal', + }) + + userEvent.click(openButton) + + await waitFor(() => { + expect(screen.getByRole('dialog')).toHaveClass('is-visible') + expect(screen.getByTestId('modalWindow')).toHaveFocus() + }) + + fireEvent.keyDown(screen.getByTestId('modalWindow'), { key: 'Escape' }) + + await waitFor(() => { + expect(screen.getByRole('dialog')).not.toHaveClass('is-visible') + + expect( + screen.getByRole('button', { + name: 'Open default modal', + }) + ).toHaveFocus() + }) }) + it('can pass in a custom onFocus element', async () => { + render(, { + container: document.body, + }) + + const openButton = screen.getByRole('button', { + name: 'Open default modal', + }) + + userEvent.click(openButton) + + await waitFor(() => { + expect(screen.getByRole('dialog')).toHaveClass('is-visible') + expect( + screen.getByRole('button', { name: 'Focus me first' }) + ).toHaveFocus() + }) + }) + }) + + describe('if forceAction is true', () => { + const testModalChildren = ( +
+

Test modal

+ Focus target +
+ ) + it('styles the body element', () => { const closeModal = jest.fn() const { rerender, baseElement } = render( @@ -417,7 +707,7 @@ describe('Modal component', () => { isOpen={false} closeModal={closeModal} forceAction> - Test modal + {testModalChildren} ) @@ -429,7 +719,7 @@ describe('Modal component', () => { isOpen={true} closeModal={closeModal} forceAction> - Test modal + {testModalChildren} ) @@ -441,7 +731,7 @@ describe('Modal component', () => { isOpen={false} closeModal={closeModal} forceAction> - Test modal + {testModalChildren} ) @@ -458,7 +748,7 @@ describe('Modal component', () => { render( - Test modal + {testModalChildren} ) @@ -466,6 +756,30 @@ describe('Modal component', () => { userEvent.click(overlay) expect(modalState.closeModal).not.toHaveBeenCalled() }) + + it('the escape key does not close the modal', async () => { + render(, { + container: document.body, + }) + + const openButton = screen.getByRole('button', { + name: 'Open default modal', + }) + + userEvent.click(openButton) + + await waitFor(() => { + expect(screen.getByRole('dialog')).toHaveClass('is-visible') + expect(screen.getByTestId('modalWindow')).toHaveFocus() + }) + + fireEvent.keyDown(screen.getByTestId('modalWindow'), { key: 'Escape' }) + + await waitFor(() => { + expect(screen.getByRole('dialog')).toHaveClass('is-visible') + expect(screen.getByTestId('modalWindow')).toHaveFocus() + }) + }) }) }) }) diff --git a/src/components/Modal/Modal.tsx b/src/components/Modal/Modal.tsx index 5f0e009819..fb1d801145 100644 --- a/src/components/Modal/Modal.tsx +++ b/src/components/Modal/Modal.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState, useRef } from 'react' +import FocusTrap from 'focus-trap-react' import { ModalHook, getScrollbarWidth } from './utils' import { ModalWindow } from './ModalWindow/ModalWindow' @@ -17,10 +18,9 @@ export type ModalProps = ModalComponentProps & Pick & JSX.IntrinsicElements['div'] -// modal toggle button (A, button) +// TODO: +// decide how to handle modal toggle button (anchor, button) // - aria-controls=modal ID -// focus trap -// click on overlay to close export const Modal = ({ id, @@ -35,6 +35,8 @@ export const Modal = ({ const [mounted, setMounted] = useState(false) const initialPaddingRef = useRef() const tempPaddingRef = useRef() + const modalEl = useRef(null) + const closeButtonEl = useRef(null) const modalRootSelector = modalRoot || '.usa-modal-wrapper' @@ -112,26 +114,48 @@ export const Modal = ({ delete divProps['aria-labelledby'] delete divProps['aria-describedby'] + const initialFocus = () => { + const focusEl = modalEl.current?.querySelector('[data-focus]') as + | HTMLElement + | SVGElement + + return focusEl ? focusEl : modalEl.current || false + } + + const focusTrapOptions = { + initialFocus, + escapeDeactivates: (): boolean => { + if (forceAction) return false + + closeModal() + return true + }, + } + return ( - - - {children} - - + + + + {children} + + + ) } diff --git a/src/components/Modal/ModalCloseButton/ModalCloseButton.tsx b/src/components/Modal/ModalCloseButton/ModalCloseButton.tsx index 3997671e38..e282bab27f 100644 --- a/src/components/Modal/ModalCloseButton/ModalCloseButton.tsx +++ b/src/components/Modal/ModalCloseButton/ModalCloseButton.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { forwardRef } from 'react' import { IconClose } from '../../Icon/Icons' import { Button } from '../../Button/Button' @@ -8,20 +8,27 @@ interface ModalCloseButtonProps { // TODO - accept custom aria-label -export const ModalCloseButton = ({ - handleClose, - ...buttonProps -}: ModalCloseButtonProps & - JSX.IntrinsicElements['button']): React.ReactElement => { - return ( - - ) -} +export const ModalCloseButton = forwardRef( + ( + { + handleClose, + ...buttonProps + }: ModalCloseButtonProps & JSX.IntrinsicElements['button'], + ref: React.ForwardedRef + ): React.ReactElement => { + return ( + + ) + } +) + +ModalCloseButton.displayName = 'ModalCloseButton' diff --git a/src/components/Modal/ModalOpenButton.tsx b/src/components/Modal/ModalOpenButton.tsx index c32b045e8d..4ed63dcaa1 100644 --- a/src/components/Modal/ModalOpenButton.tsx +++ b/src/components/Modal/ModalOpenButton.tsx @@ -4,7 +4,7 @@ import React from 'react' import classnames from 'classnames' type ModalOpenButtonProps = { - handleOpen: () => void + handleOpen: (e: React.MouseEvent) => void } // TODO - support or + if (isSmall) { + deprecationWarning( + 'Small button is deprecated. Use the default, pass in a custom className, or use size big.' ) } -) + + const classes = classnames( + 'usa-button', + { + 'usa-button--secondary': secondary, + 'usa-button--base': base, + 'usa-button--accent-cool': accent || accentStyle === 'cool', + 'usa-button--accent-warm': accentStyle === 'warm', + 'usa-button--outline': outline, + 'usa-button--inverse': inverse, + 'usa-button--big': isBig, + 'usa-button--small': isSmall, + 'usa-button--icon': icon, + 'usa-button--unstyled': unstyled, + }, + className + ) + + return ( + + ) +} export default Button diff --git a/src/components/Modal/Modal.tsx b/src/components/Modal/Modal.tsx index fb1d801145..a0decf66f7 100644 --- a/src/components/Modal/Modal.tsx +++ b/src/components/Modal/Modal.tsx @@ -36,7 +36,6 @@ export const Modal = ({ const initialPaddingRef = useRef() const tempPaddingRef = useRef() const modalEl = useRef(null) - const closeButtonEl = useRef(null) const modalRootSelector = modalRoot || '.usa-modal-wrapper' @@ -147,7 +146,6 @@ export const Modal = ({ modalId={id} {...divProps} ref={modalEl} - closeButtonRef={closeButtonEl} isLarge={isLarge} forceAction={forceAction} tabIndex={-1} diff --git a/src/components/Modal/ModalCloseButton/ModalCloseButton.tsx b/src/components/Modal/ModalCloseButton/ModalCloseButton.tsx index e282bab27f..655d78c5e3 100644 --- a/src/components/Modal/ModalCloseButton/ModalCloseButton.tsx +++ b/src/components/Modal/ModalCloseButton/ModalCloseButton.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef } from 'react' +import React from 'react' import { IconClose } from '../../Icon/Icons' import { Button } from '../../Button/Button' @@ -8,27 +8,22 @@ interface ModalCloseButtonProps { // TODO - accept custom aria-label -export const ModalCloseButton = forwardRef( - ( - { - handleClose, - ...buttonProps - }: ModalCloseButtonProps & JSX.IntrinsicElements['button'], - ref: React.ForwardedRef - ): React.ReactElement => { - return ( - - ) - } -) +export const ModalCloseButton = ({ + handleClose, + ...buttonProps +}: ModalCloseButtonProps & + JSX.IntrinsicElements['button']): React.ReactElement => { + return ( + + ) +} ModalCloseButton.displayName = 'ModalCloseButton' diff --git a/src/components/Modal/ModalWindow/ModalWindow.tsx b/src/components/Modal/ModalWindow/ModalWindow.tsx index 16bc14972e..42bb626c2d 100644 --- a/src/components/Modal/ModalWindow/ModalWindow.tsx +++ b/src/components/Modal/ModalWindow/ModalWindow.tsx @@ -10,7 +10,6 @@ interface ModalWindowProps { className?: string isLarge?: boolean forceAction?: boolean - closeButtonRef: React.ForwardedRef } export const ModalWindow = forwardRef( @@ -22,7 +21,6 @@ export const ModalWindow = forwardRef( handleClose, isLarge = false, forceAction = false, - closeButtonRef, ...divProps }: ModalWindowProps & JSX.IntrinsicElements['div'], ref: React.ForwardedRef @@ -48,7 +46,6 @@ export const ModalWindow = forwardRef( )}
From 1075eb1cb925bb07818961b0fc5aec2a57d0d56c Mon Sep 17 00:00:00 2001 From: Suzanne Rozier Date: Mon, 4 Oct 2021 15:36:29 -0500 Subject: [PATCH 30/38] Fix toggle event propagation, cleanup --- src/components/Modal/Modal.stories.tsx | 12 ++-- src/components/Modal/Modal.test.tsx | 85 ++++---------------------- src/components/Modal/utils.ts | 25 ++++++-- 3 files changed, 36 insertions(+), 86 deletions(-) diff --git a/src/components/Modal/Modal.stories.tsx b/src/components/Modal/Modal.stories.tsx index 5c7b3cccd2..83729c65c0 100644 --- a/src/components/Modal/Modal.stories.tsx +++ b/src/components/Modal/Modal.stories.tsx @@ -33,8 +33,7 @@ export const defaultModal = (): React.ReactElement => { e.preventDefault()}> + aria-controls="example-modal-1"> Open default modal { e.preventDefault()}> + aria-controls="example-modal-2"> Open large modal { e.preventDefault()}> + aria-controls="example-modal-3"> Open modal with forced action { e.preventDefault()}> + aria-controls="example-modal-1"> Open modal with custom initial focus element void -}): React.ReactElement => { - const { isOpen, openModal, closeModal } = useModal() - - const handleDoubleOpen = (e: React.MouseEvent) => { - if (openModal(e)) { - openCallback() - } - } - - return ( - <> - e.preventDefault()}> - Open default modal - - - - Are you sure you want to continue? - -
- - -
- - - - - - -
- - ) -} - const ExampleModalWithFocusElement = (): React.ReactElement => { const { isOpen, openModal, closeModal } = useModal() @@ -306,7 +248,7 @@ describe('Modal component', () => { expect(modalState.closeModal).toHaveBeenCalled() }) - it('renders a large modalWindow isLarge is true', () => { + it('renders a large modal window when isLarge is true', () => { const modalState = { isOpen: false, closeModal: jest.fn(), @@ -551,25 +493,20 @@ describe('Modal component', () => { }) it('stops event propagation if toggle modal is called from within a modal', () => { - const mockOpenCallback = jest.fn() - - render(, { - container: document.body, - }) + const { result } = renderHook(() => useModal()) - const openButton = screen.getByRole('button', { - name: 'Open default modal', - }) + const closeSpy = jest.spyOn(result.current, 'closeModal') - userEvent.click(openButton) + const testModalId = 'testModal' - userEvent.click( - screen.getByRole('button', { - name: 'This button should not do anything', - }) + render( + + Test modal + ) - expect(mockOpenCallback).not.toHaveBeenCalled() + userEvent.click(screen.getByText('Test modal')) + expect(closeSpy).toHaveLastReturnedWith(false) }) describe('focusing', () => { diff --git a/src/components/Modal/utils.ts b/src/components/Modal/utils.ts index 2b9b0aba16..965c9de019 100644 --- a/src/components/Modal/utils.ts +++ b/src/components/Modal/utils.ts @@ -9,8 +9,8 @@ export type ModalHook = { export const useModal = (): ModalHook => { const [isOpen, setIsOpen] = useState(false) - const openModal = (e?: React.MouseEvent): boolean => { - const clickedElement = e?.target as Element + const allowToggle = (e: React.MouseEvent): boolean => { + const clickedElement = e.target as Element if (e && clickedElement) { if (clickedElement.closest('.usa-modal')) { @@ -20,19 +20,36 @@ export const useModal = (): ModalHook => { clickedElement.closest('[data-close-modal]') ) { // Element is a close button - proceed + return true } else { // Don't allow opening a modal from within a modal - e.stopPropagation() return false } } } + return true + } + + const openModal = (e?: React.MouseEvent): boolean => { + if (e && !allowToggle(e)) { + e.stopPropagation() + return false + } + setIsOpen(true) return true } - const closeModal = (): void => setIsOpen(false) + const closeModal = (e?: React.MouseEvent): boolean => { + if (e && !allowToggle(e)) { + e.stopPropagation() + return false + } + + setIsOpen(false) + return true + } return { isOpen, openModal, closeModal } } From a334ebfe88a348f3f0c7651ea184a5a454b7de8b Mon Sep 17 00:00:00 2001 From: Suzanne Rozier Date: Tue, 5 Oct 2021 15:12:38 -0500 Subject: [PATCH 31/38] Change Modal API to use forwardRef --- src/components/Modal/Modal.stories.tsx | 61 +++-- src/components/Modal/Modal.test.tsx | 366 ++++++++++--------------- src/components/Modal/Modal.tsx | 282 ++++++++++--------- src/components/Modal/utils.test.ts | 48 ++++ src/components/Modal/utils.ts | 21 +- 5 files changed, 403 insertions(+), 375 deletions(-) diff --git a/src/components/Modal/Modal.stories.tsx b/src/components/Modal/Modal.stories.tsx index 83729c65c0..445e312f4f 100644 --- a/src/components/Modal/Modal.stories.tsx +++ b/src/components/Modal/Modal.stories.tsx @@ -1,13 +1,12 @@ -import React from 'react' +import React, { useRef } from 'react' -import { Modal } from './Modal' +import { Modal, ModalRef } from './Modal' import { ModalHeading } from './ModalHeading/ModalHeading' import { ModalFooter } from './ModalFooter/ModalFooter' import { ModalOpenButton } from './ModalOpenButton' import { Button } from '../Button/Button' import { ButtonGroup } from '../ButtonGroup/ButtonGroup' -import { useModal } from './utils' export default { title: 'Components/Modal', @@ -26,19 +25,21 @@ Source: http://designsystem.digital.gov/components/modal } export const defaultModal = (): React.ReactElement => { - const { isOpen, openModal, closeModal } = useModal() + const modalRef = useRef() + + const handleOpen = (e) => modalRef.current?.toggleModal(e, true) + const handleClose = (e) => modalRef.current?.toggleModal(e, false) return ( <> Open default modal @@ -52,7 +53,7 @@ export const defaultModal = (): React.ReactElement => {
- @@ -71,19 +72,21 @@ export const defaultModal = (): React.ReactElement => { } export const largeModal = (): React.ReactElement => { - const { isOpen, openModal, closeModal } = useModal() + const modalRef = useRef() + + const handleOpen = (e) => modalRef.current?.toggleModal(e, true) + const handleClose = (e) => modalRef.current?.toggleModal(e, false) return ( <> Open large modal {
- @@ -117,19 +120,21 @@ export const largeModal = (): React.ReactElement => { } export const forceActionModal = (): React.ReactElement => { - const { isOpen, openModal, closeModal } = useModal() + const modalRef = useRef() + + const handleOpen = (e) => modalRef.current?.toggleModal(e, true) + const handleClose = (e) => modalRef.current?.toggleModal(e, false) return ( <> Open modal with forced action {
- @@ -165,19 +170,21 @@ export const forceActionModal = (): React.ReactElement => { } export const customFocusElementModal = (): React.ReactElement => { - const { isOpen, openModal, closeModal } = useModal() + const modalRef = useRef() + + const handleOpen = (e) => modalRef.current?.toggleModal(e, true) + const handleClose = (e) => modalRef.current?.toggleModal(e, false) return ( <> Open modal with custom initial focus element @@ -195,7 +202,7 @@ export const customFocusElementModal = (): React.ReactElement => {
- diff --git a/src/components/Modal/Modal.test.tsx b/src/components/Modal/Modal.test.tsx index 81085d2d28..19510731f2 100644 --- a/src/components/Modal/Modal.test.tsx +++ b/src/components/Modal/Modal.test.tsx @@ -1,10 +1,14 @@ -import React from 'react' -import { render, screen, waitFor, fireEvent } from '@testing-library/react' +import React, { createRef, useRef } from 'react' +import { + render, + screen, + waitFor, + fireEvent, + RenderOptions, +} from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { renderHook } from '@testing-library/react-hooks' -import { Modal } from './Modal' -import { useModal } from './utils' +import { Modal, ModalRef } from './Modal' import { ModalHeading } from './ModalHeading/ModalHeading' import { ModalFooter } from './ModalFooter/ModalFooter' import { ModalOpenButton } from './ModalOpenButton' @@ -21,29 +25,41 @@ jest.mock('./utils', () => { } }) +const renderWithModalRoot = ( + ui: React.ReactElement, + options: RenderOptions = {} +) => { + const modalContainer = document.createElement('div') + modalContainer.setAttribute('id', 'modal-root') + + return render(ui, { + ...options, + container: document.body.appendChild(modalContainer), + }) +} + const ExampleModal = ({ forceAction = false, }: { forceAction?: boolean }): React.ReactElement => { - const { isOpen, openModal, closeModal } = useModal() + const modalRef = useRef(null) return ( <> modalRef.current?.toggleModal(e, true)} href="#example-modal-1" - aria-controls="example-modal-1" - onClick={(e) => e.preventDefault()}> + aria-controls="example-modal-1"> Open default modal + forceAction={forceAction} + modalRoot="#modal-root"> Are you sure you want to continue? @@ -54,7 +70,10 @@ const ExampleModal = ({ - @@ -73,23 +92,22 @@ const ExampleModal = ({ } const ExampleModalWithFocusElement = (): React.ReactElement => { - const { isOpen, openModal, closeModal } = useModal() + const modalRef = useRef(null) return ( <> modalRef.current?.toggleModal(e, true)} href="#example-modal-1" - aria-controls="example-modal-1" - onClick={(e) => e.preventDefault()}> + aria-controls="example-modal-1"> Open default modal + aria-describedby="modal-1-description" + modalRoot="#modal-root"> Are you sure you want to continue? @@ -103,7 +121,10 @@ const ExampleModalWithFocusElement = (): React.ReactElement => { - @@ -122,19 +143,14 @@ const ExampleModalWithFocusElement = (): React.ReactElement => { } describe('Modal component', () => { - it('renders its children inside a modal wrapper', () => { - const modalState = { - isOpen: false, - closeModal: jest.fn(), - } + beforeEach(() => { + document.body.style.paddingRight = '0px' + }) + it('renders its children inside a modal wrapper', () => { const testModalId = 'testModal' - render( - - Test modal - - ) + render(Test modal) // Modal wrapper const modalWrapper = screen.getByRole('dialog') @@ -159,17 +175,11 @@ describe('Modal component', () => { }) it('passes aria props to the modal wrapper', () => { - const modalState = { - isOpen: false, - closeModal: jest.fn(), - } - const testModalId = 'testModal' render( Test modal @@ -188,76 +198,72 @@ describe('Modal component', () => { expect(modalWindow).not.toHaveAttribute('aria-describedby') }) - it('renders the visible state when open', () => { - const modalState = { - isOpen: true, - closeModal: jest.fn(), - } + it('renders the visible state when open', async () => { + const modalRef = createRef() + const handleOpen = () => modalRef.current?.toggleModal(undefined, true) const testModalId = 'testModal' - render( - + renderWithModalRoot( + Test modal ) + await waitFor(() => handleOpen()) + + expect(modalRef.current?.modalIsOpen).toBe(true) const modalWrapper = screen.getByRole('dialog') expect(modalWrapper).not.toHaveClass('is-hidden') expect(modalWrapper).toHaveClass('is-visible') }) - it('can click on the close button to close', () => { - const modalState = { - isOpen: true, - closeModal: jest.fn(), - } - + it('can click on the close button to close', async () => { const testModalId = 'testModal' + const modalRef = createRef() + const handleOpen = () => modalRef.current?.toggleModal(undefined, true) - render( - + renderWithModalRoot( + Test modal ) + await waitFor(() => handleOpen()) + + expect(modalRef.current?.modalIsOpen).toBe(true) const closeButton = screen.getByRole('button', { name: 'Close this window', }) expect(closeButton).toBeInTheDocument() userEvent.click(closeButton) - expect(modalState.closeModal).toHaveBeenCalled() + expect(modalRef.current?.modalIsOpen).toBe(false) }) - it('can click on the overlay to close', () => { - const modalState = { - isOpen: true, - closeModal: jest.fn(), - } - + it('can click on the overlay to close', async () => { const testModalId = 'testModal' + const modalRef = createRef() + const handleOpen = () => modalRef.current?.toggleModal(undefined, true) render( - + Test modal ) + await waitFor(() => handleOpen()) + + expect(modalRef.current?.modalIsOpen).toBe(true) const overlay = screen.getByTestId('modalOverlay') userEvent.click(overlay) - expect(modalState.closeModal).toHaveBeenCalled() + expect(modalRef.current?.modalIsOpen).toBe(false) }) it('renders a large modal window when isLarge is true', () => { - const modalState = { - isOpen: false, - closeModal: jest.fn(), - } - const testModalId = 'testModal' render( - + Test modal ) @@ -266,15 +272,10 @@ describe('Modal component', () => { }) it('does not render a close button when forceAction is true', () => { - const modalState = { - isOpen: false, - closeModal: jest.fn(), - } - const testModalId = 'testModal' render( - + Test modal ) @@ -292,42 +293,39 @@ describe('Modal component', () => { }) describe('toggling', () => { - it('styles the body element', () => { - const closeModal = jest.fn() - const { rerender, baseElement } = render( - + it('styles the body element', async () => { + const modalRef = createRef() + const handleOpen = () => modalRef.current?.toggleModal(undefined, true) + const handleClose = () => modalRef.current?.toggleModal(undefined, false) + + const { baseElement } = render( + Test modal ) expect(baseElement).not.toHaveClass('usa-js-modal--active') - expect(baseElement).not.toHaveStyle('padding-right: 0px') + expect(baseElement).toHaveStyle('padding-right: 0px') - rerender( - - Test modal - - ) + await waitFor(() => handleOpen()) expect(baseElement).toHaveClass('usa-js-modal--active') expect(baseElement).toHaveStyle('padding-right: 15px') - rerender( - - Test modal - - ) + await waitFor(() => handleClose()) expect(baseElement).not.toHaveClass('usa-js-modal--active') expect(baseElement).toHaveStyle('padding-right: 0px') }) - it('styles the body element when it already has padding right', () => { - const closeModal = jest.fn() + it('styles the body element when it already has padding right', async () => { + const modalRef = createRef() + const handleOpen = () => modalRef.current?.toggleModal(undefined, true) + const handleClose = () => modalRef.current?.toggleModal(undefined, false) document.body.style.paddingRight = '20px' - const { rerender, baseElement } = render( - + const { baseElement } = render( + Test modal ) @@ -335,39 +333,34 @@ describe('Modal component', () => { expect(baseElement).not.toHaveClass('usa-js-modal--active') expect(baseElement).toHaveStyle('padding-right: 20px') - rerender( - - Test modal - - ) + await waitFor(() => handleOpen()) expect(baseElement).toHaveClass('usa-js-modal--active') expect(baseElement).toHaveStyle('padding-right: 35px') - rerender( - - Test modal - - ) + await waitFor(() => handleClose()) expect(baseElement).not.toHaveClass('usa-js-modal--active') expect(baseElement).toHaveStyle('padding-right: 20px') }) - it('hides other elements from screen readers', () => { + it('hides other elements from screen readers', async () => { + const modalRef = createRef() + const handleOpen = () => modalRef.current?.toggleModal(undefined, true) + const handleClose = () => modalRef.current?.toggleModal(undefined, false) + const modalProps = { + ref: modalRef, id: 'testModal', - closeModal: jest.fn(), } - const { rerender } = render( + + render( <>

Some other element

- - Test modal - + Test modal , { container: document.body, @@ -381,17 +374,7 @@ describe('Modal component', () => { expect(screen.getByTestId('hidden')).toHaveAttribute('aria-hidden') - rerender( - <> -

Some other element

- - - Test modal - - - ) + await waitFor(() => handleOpen()) expect(screen.getByTestId('nonhidden')).toHaveAttribute('aria-hidden') expect(screen.getByTestId('nonhidden')).toHaveAttribute( @@ -399,17 +382,7 @@ describe('Modal component', () => { ) expect(screen.getByTestId('hidden')).toHaveAttribute('aria-hidden') - rerender( - <> -

Some other element

- - - Test modal - - - ) + await waitFor(() => handleClose()) expect(screen.getByTestId('nonhidden')).not.toHaveAttribute('aria-hidden') expect(screen.getByTestId('nonhidden')).not.toHaveAttribute( @@ -419,23 +392,25 @@ describe('Modal component', () => { expect(screen.getByTestId('hidden')).toHaveAttribute('aria-hidden') }) - it('hides other elements from screen readers with a custom modal root', () => { + it('hides other elements from screen readers with a custom modal root', async () => { + const modalRef = createRef() + const handleOpen = () => modalRef.current?.toggleModal(undefined, true) + const handleClose = () => modalRef.current?.toggleModal(undefined, false) + const modalProps = { + ref: modalRef, id: 'testModal', - closeModal: jest.fn(), modalRoot: '#modal-root', } - const { rerender } = render( + render( <>

Some other element

- - Test modal - + Test modal
, { @@ -450,19 +425,7 @@ describe('Modal component', () => { expect(screen.getByTestId('hidden')).toHaveAttribute('aria-hidden') - rerender( - <> -

Some other element

- -
- - Test modal - -
- - ) + await waitFor(() => handleOpen()) expect(screen.getByTestId('nonhidden')).toHaveAttribute('aria-hidden') expect(screen.getByTestId('nonhidden')).toHaveAttribute( @@ -470,19 +433,7 @@ describe('Modal component', () => { ) expect(screen.getByTestId('hidden')).toHaveAttribute('aria-hidden') - rerender( - <> -

Some other element

- -
- - Test modal - -
- - ) + await waitFor(() => handleClose()) expect(screen.getByTestId('nonhidden')).not.toHaveAttribute('aria-hidden') expect(screen.getByTestId('nonhidden')).not.toHaveAttribute( @@ -492,28 +443,26 @@ describe('Modal component', () => { expect(screen.getByTestId('hidden')).toHaveAttribute('aria-hidden') }) - it('stops event propagation if toggle modal is called from within a modal', () => { - const { result } = renderHook(() => useModal()) - - const closeSpy = jest.spyOn(result.current, 'closeModal') - - const testModalId = 'testModal' + it('stops event propagation if toggle modal is called from within a modal', async () => { + const modalRef = createRef() + const handleOpen = () => modalRef.current?.toggleModal(undefined, true) render( - + Test modal ) + expect(modalRef.current?.modalIsOpen).toBe(false) + await waitFor(() => handleOpen()) + expect(modalRef.current?.modalIsOpen).toBe(true) userEvent.click(screen.getByText('Test modal')) - expect(closeSpy).toHaveLastReturnedWith(false) + expect(modalRef.current?.modalIsOpen).toBe(true) }) describe('focusing', () => { it('activates a focus trap', async () => { - render(, { - container: document.body, - }) + renderWithModalRoot() const openButton = screen.getByRole('button', { name: 'Open default modal', @@ -546,9 +495,7 @@ describe('Modal component', () => { }) it('returns focus to the opener element on close', async () => { - render(, { - container: document.body, - }) + renderWithModalRoot() const openButton = screen.getByRole('button', { name: 'Open default modal', @@ -580,9 +527,7 @@ describe('Modal component', () => { }) it('the escape key closes the modal', async () => { - render(, { - container: document.body, - }) + renderWithModalRoot() const openButton = screen.getByRole('button', { name: 'Open default modal', @@ -609,9 +554,7 @@ describe('Modal component', () => { }) it('can pass in a custom onFocus element', async () => { - render(, { - container: document.body, - }) + renderWithModalRoot() const openButton = screen.getByRole('button', { name: 'Open default modal', @@ -636,68 +579,51 @@ describe('Modal component', () => { ) - it('styles the body element', () => { - const closeModal = jest.fn() - const { rerender, baseElement } = render( - + it('styles the body element', async () => { + const modalRef = createRef() + const handleOpen = () => modalRef.current?.toggleModal(undefined, true) + const handleClose = () => + modalRef.current?.toggleModal(undefined, false) + + const { baseElement } = render( + {testModalChildren} ) expect(baseElement).not.toHaveClass('usa-js-no-click') - rerender( - - {testModalChildren} - - ) + await waitFor(() => handleOpen()) expect(baseElement).toHaveClass('usa-js-no-click') - rerender( - - {testModalChildren} - - ) + await waitFor(() => handleClose()) expect(baseElement).not.toHaveClass('usa-js-no-click') }) - it('cannot click on the overlay to close', () => { - const modalState = { - isOpen: true, - closeModal: jest.fn(), - } + it('cannot click on the overlay to close', async () => { + const modalRef = createRef() + const handleOpen = () => modalRef.current?.toggleModal(undefined, true) const testModalId = 'testModal' render( - + {testModalChildren} ) + await waitFor(() => handleOpen()) + expect(modalRef.current?.modalIsOpen).toBe(true) + const overlay = screen.getByTestId('modalOverlay') userEvent.click(overlay) - expect(modalState.closeModal).not.toHaveBeenCalled() + expect(modalRef.current?.modalIsOpen).toBe(true) }) it('the escape key does not close the modal', async () => { - render(, { - container: document.body, - }) + renderWithModalRoot() const openButton = screen.getByRole('button', { name: 'Open default modal', diff --git a/src/components/Modal/Modal.tsx b/src/components/Modal/Modal.tsx index a0decf66f7..16e40eaff7 100644 --- a/src/components/Modal/Modal.tsx +++ b/src/components/Modal/Modal.tsx @@ -1,7 +1,13 @@ -import React, { useEffect, useState, useRef } from 'react' +import React, { + useEffect, + useState, + useRef, + forwardRef, + useImperativeHandle, +} from 'react' import FocusTrap from 'focus-trap-react' -import { ModalHook, getScrollbarWidth } from './utils' +import { useModal, getScrollbarWidth } from './utils' import { ModalWindow } from './ModalWindow/ModalWindow' import { ModalWrapper } from './ModalWrapper/ModalWrapper' @@ -14,147 +20,171 @@ interface ModalComponentProps { modalRoot?: string } -export type ModalProps = ModalComponentProps & - Pick & - JSX.IntrinsicElements['div'] - -// TODO: -// decide how to handle modal toggle button (anchor, button) -// - aria-controls=modal ID - -export const Modal = ({ - id, - children, - isOpen, - closeModal, - isLarge = false, - forceAction = false, - modalRoot = '.usa-modal-wrapper', - ...divProps -}: ModalProps): React.ReactElement => { - const [mounted, setMounted] = useState(false) - const initialPaddingRef = useRef() - const tempPaddingRef = useRef() - const modalEl = useRef(null) - - const modalRootSelector = modalRoot || '.usa-modal-wrapper' - - const NON_MODALS = `body > *:not(${modalRootSelector}):not([aria-hidden])` - const NON_MODALS_HIDDEN = `[data-modal-hidden]` - - useEffect(() => { - const SCROLLBAR_WIDTH = getScrollbarWidth() - const INITIAL_PADDING = - window - .getComputedStyle(document.body) - .getPropertyValue('padding-right') || '0px' - - const TEMPORARY_PADDING = `${ - parseInt(INITIAL_PADDING.replace(/px/, ''), 10) + - parseInt(SCROLLBAR_WIDTH.replace(/px/, ''), 10) - }px` - - initialPaddingRef.current = INITIAL_PADDING - tempPaddingRef.current = TEMPORARY_PADDING - - setMounted(true) - }, []) - - useEffect(() => { - if (mounted) { - const { body } = document - - const INITIAL_PADDING = initialPaddingRef.current - const TEMPORARY_PADDING = tempPaddingRef.current - - body.style.paddingRight = - (body.style.paddingRight === TEMPORARY_PADDING - ? INITIAL_PADDING - : TEMPORARY_PADDING) || '' - - if (isOpen === true) { - body.classList.add('usa-js-modal--active') - - document.querySelectorAll(NON_MODALS).forEach((el) => { - el.setAttribute('aria-hidden', 'true') - el.setAttribute('data-modal-hidden', '') - }) +export type ModalProps = ModalComponentProps & JSX.IntrinsicElements['div'] + +export type ModalRef = { + modalId: string + modalIsOpen: boolean + toggleModal: (event?: React.MouseEvent, open?: boolean) => boolean +} + +export const Modal = forwardRef( + ( + { + id, + children, + isLarge = false, + forceAction = false, + modalRoot = '.usa-modal-wrapper', + ...divProps + }: ModalProps, + ref: React.Ref + ): React.ReactElement => { + const { isOpen, closeModal, toggleModal } = useModal() + const [mounted, setMounted] = useState(false) + const initialPaddingRef = useRef() + const tempPaddingRef = useRef() + const modalEl = useRef(null) + + const modalRootSelector = modalRoot || '.usa-modal-wrapper' + + const NON_MODALS = `body > *:not(${modalRootSelector}):not([aria-hidden])` + const NON_MODALS_HIDDEN = `[data-modal-hidden]` + + useImperativeHandle( + ref, + () => ({ + modalId: id, + modalIsOpen: isOpen, + toggleModal, + }), + [id, isOpen] + ) + + useEffect(() => { + const SCROLLBAR_WIDTH = getScrollbarWidth() + const INITIAL_PADDING = + window + .getComputedStyle(document.body) + .getPropertyValue('padding-right') || '0px' + + const TEMPORARY_PADDING = `${ + parseInt(INITIAL_PADDING.replace(/px/, ''), 10) + + parseInt(SCROLLBAR_WIDTH.replace(/px/, ''), 10) + }px` + + initialPaddingRef.current = INITIAL_PADDING + tempPaddingRef.current = TEMPORARY_PADDING + + setMounted(true) + + return () => { + // On unmount + const { body } = document + const INITIAL_PADDING = initialPaddingRef.current + const TEMPORARY_PADDING = tempPaddingRef.current - if (forceAction) { - body.classList.add('usa-js-no-click') - } - } else if (isOpen === false) { body.classList.remove('usa-js-modal--active') body.classList.remove('usa-js-no-click') + body.style.paddingRight = + (body.style.paddingRight === TEMPORARY_PADDING + ? INITIAL_PADDING + : TEMPORARY_PADDING) || '' + document.querySelectorAll(NON_MODALS_HIDDEN).forEach((el) => { el.removeAttribute('aria-hidden') el.removeAttribute('data-modal-hidden') }) } - } - - return () => { - const { body } = document - - body.classList.remove('usa-js-modal--active') - body.classList.remove('usa-js-no-click') + }, []) + + useEffect(() => { + if (mounted) { + const { body } = document + + const INITIAL_PADDING = initialPaddingRef.current + const TEMPORARY_PADDING = tempPaddingRef.current + + body.style.paddingRight = + (body.style.paddingRight === TEMPORARY_PADDING + ? INITIAL_PADDING + : TEMPORARY_PADDING) || '' + + if (isOpen === true) { + body.classList.add('usa-js-modal--active') + + document.querySelectorAll(NON_MODALS).forEach((el) => { + el.setAttribute('aria-hidden', 'true') + el.setAttribute('data-modal-hidden', '') + }) + + if (forceAction) { + body.classList.add('usa-js-no-click') + } + } else if (isOpen === false) { + body.classList.remove('usa-js-modal--active') + body.classList.remove('usa-js-no-click') + + document.querySelectorAll(NON_MODALS_HIDDEN).forEach((el) => { + el.removeAttribute('aria-hidden') + el.removeAttribute('data-modal-hidden') + }) + } + } + }, [isOpen]) - document.querySelectorAll(NON_MODALS_HIDDEN).forEach((el) => { - el.removeAttribute('aria-hidden') - el.removeAttribute('data-modal-hidden') - }) - } - }, [isOpen]) + const ariaLabelledBy = divProps['aria-labelledby'] + const ariaDescribedBy = divProps['aria-describedby'] - const ariaLabelledBy = divProps['aria-labelledby'] - const ariaDescribedBy = divProps['aria-describedby'] + delete divProps['aria-labelledby'] + delete divProps['aria-describedby'] - delete divProps['aria-labelledby'] - delete divProps['aria-describedby'] + const initialFocus = () => { + const focusEl = modalEl.current?.querySelector('[data-focus]') as + | HTMLElement + | SVGElement - const initialFocus = () => { - const focusEl = modalEl.current?.querySelector('[data-focus]') as - | HTMLElement - | SVGElement + return focusEl ? focusEl : modalEl.current || false + } - return focusEl ? focusEl : modalEl.current || false - } + const focusTrapOptions = { + initialFocus, + escapeDeactivates: (): boolean => { + if (forceAction) return false - const focusTrapOptions = { - initialFocus, - escapeDeactivates: (): boolean => { - if (forceAction) return false + closeModal() + return true + }, + } - closeModal() - return true - }, + return ( + + + + {children} + + + + ) } +) - return ( - - - - {children} - - - - ) -} +Modal.displayName = 'Modal' export default Modal diff --git a/src/components/Modal/utils.test.ts b/src/components/Modal/utils.test.ts index 91ff943ace..47917407c4 100644 --- a/src/components/Modal/utils.test.ts +++ b/src/components/Modal/utils.test.ts @@ -31,4 +31,52 @@ describe('the useModal hook', () => { }) expect(result.current.isOpen).toEqual(false) }) + + describe('toggleModal', () => { + it('with no parameters sets isOpen to its opposite', () => { + const { result } = renderHook(() => useModal()) + expect(result.current.isOpen).toEqual(false) + act(() => { + result.current.toggleModal() + }) + expect(result.current.isOpen).toEqual(true) + act(() => { + result.current.toggleModal() + }) + expect(result.current.isOpen).toEqual(false) + }) + + it('called with true sets isOpen to true', () => { + const { result } = renderHook(() => useModal()) + + expect(result.current.isOpen).toEqual(false) + act(() => { + result.current.toggleModal(undefined, true) + }) + expect(result.current.isOpen).toEqual(true) + act(() => { + result.current.toggleModal(undefined, true) + }) + expect(result.current.isOpen).toEqual(true) + }) + + it('called with false sets isOpen to false', () => { + const { result } = renderHook(() => useModal()) + + expect(result.current.isOpen).toEqual(false) + act(() => { + result.current.toggleModal(undefined, false) + }) + expect(result.current.isOpen).toEqual(false) + + act(() => { + result.current.toggleModal() + }) + expect(result.current.isOpen).toEqual(true) + act(() => { + result.current.toggleModal(undefined, false) + }) + expect(result.current.isOpen).toEqual(false) + }) + }) }) diff --git a/src/components/Modal/utils.ts b/src/components/Modal/utils.ts index 965c9de019..9bcab48f14 100644 --- a/src/components/Modal/utils.ts +++ b/src/components/Modal/utils.ts @@ -3,7 +3,8 @@ import React, { useState } from 'react' export type ModalHook = { isOpen: boolean openModal: (e?: React.MouseEvent) => boolean - closeModal: () => void + closeModal: (e?: React.MouseEvent) => boolean + toggleModal: (e?: React.MouseEvent, open?: boolean) => boolean } export const useModal = (): ModalHook => { @@ -51,7 +52,23 @@ export const useModal = (): ModalHook => { return true } - return { isOpen, openModal, closeModal } + const toggleModal = (e?: React.MouseEvent, open?: boolean): boolean => { + // console.log('TOGGLE MODAL', e, open) + if (e && !allowToggle(e)) { + e.stopPropagation() + return false + } + + if (open === true) setIsOpen(true) + else if (open === false) setIsOpen(false) + else { + setIsOpen((state) => !state) + } + + return true + } + + return { isOpen, toggleModal, openModal, closeModal } } export const getScrollbarWidth = (): string => { From b2c562ee6115dde5186288e0194f8b2a18f63e9a Mon Sep 17 00:00:00 2001 From: Suzanne Rozier Date: Tue, 5 Oct 2021 15:16:52 -0500 Subject: [PATCH 32/38] Replace closeModal, openModal with toggleModal --- src/components/Modal/Modal.tsx | 6 ++++- .../ModalCloseButton/ModalCloseButton.tsx | 2 -- src/components/Modal/utils.test.ts | 23 ------------------ src/components/Modal/utils.ts | 24 +------------------ 4 files changed, 6 insertions(+), 49 deletions(-) diff --git a/src/components/Modal/Modal.tsx b/src/components/Modal/Modal.tsx index 16e40eaff7..471aa884bc 100644 --- a/src/components/Modal/Modal.tsx +++ b/src/components/Modal/Modal.tsx @@ -40,7 +40,7 @@ export const Modal = forwardRef( }: ModalProps, ref: React.Ref ): React.ReactElement => { - const { isOpen, closeModal, toggleModal } = useModal() + const { isOpen, toggleModal } = useModal() const [mounted, setMounted] = useState(false) const initialPaddingRef = useRef() const tempPaddingRef = useRef() @@ -51,6 +51,10 @@ export const Modal = forwardRef( const NON_MODALS = `body > *:not(${modalRootSelector}):not([aria-hidden])` const NON_MODALS_HIDDEN = `[data-modal-hidden]` + const closeModal = (e?: React.MouseEvent) => { + toggleModal(e, false) + } + useImperativeHandle( ref, () => ({ diff --git a/src/components/Modal/ModalCloseButton/ModalCloseButton.tsx b/src/components/Modal/ModalCloseButton/ModalCloseButton.tsx index 655d78c5e3..e695bbbad7 100644 --- a/src/components/Modal/ModalCloseButton/ModalCloseButton.tsx +++ b/src/components/Modal/ModalCloseButton/ModalCloseButton.tsx @@ -6,8 +6,6 @@ interface ModalCloseButtonProps { handleClose: () => void } -// TODO - accept custom aria-label - export const ModalCloseButton = ({ handleClose, ...buttonProps diff --git a/src/components/Modal/utils.test.ts b/src/components/Modal/utils.test.ts index 47917407c4..bb99f6318d 100644 --- a/src/components/Modal/utils.test.ts +++ b/src/components/Modal/utils.test.ts @@ -9,29 +9,6 @@ describe('the useModal hook', () => { expect(result.current.isOpen).toEqual(false) }) - it('openModal sets isOpen to true', () => { - const { result } = renderHook(() => useModal()) - - act(() => { - result.current.openModal() - }) - expect(result.current.isOpen).toEqual(true) - }) - - it('closeModal sets isOpen to false', () => { - const { result } = renderHook(() => useModal()) - - act(() => { - result.current.openModal() - }) - expect(result.current.isOpen).toEqual(true) - - act(() => { - result.current.closeModal() - }) - expect(result.current.isOpen).toEqual(false) - }) - describe('toggleModal', () => { it('with no parameters sets isOpen to its opposite', () => { const { result } = renderHook(() => useModal()) diff --git a/src/components/Modal/utils.ts b/src/components/Modal/utils.ts index 9bcab48f14..304e73db28 100644 --- a/src/components/Modal/utils.ts +++ b/src/components/Modal/utils.ts @@ -2,8 +2,6 @@ import React, { useState } from 'react' export type ModalHook = { isOpen: boolean - openModal: (e?: React.MouseEvent) => boolean - closeModal: (e?: React.MouseEvent) => boolean toggleModal: (e?: React.MouseEvent, open?: boolean) => boolean } @@ -32,26 +30,6 @@ export const useModal = (): ModalHook => { return true } - const openModal = (e?: React.MouseEvent): boolean => { - if (e && !allowToggle(e)) { - e.stopPropagation() - return false - } - - setIsOpen(true) - return true - } - - const closeModal = (e?: React.MouseEvent): boolean => { - if (e && !allowToggle(e)) { - e.stopPropagation() - return false - } - - setIsOpen(false) - return true - } - const toggleModal = (e?: React.MouseEvent, open?: boolean): boolean => { // console.log('TOGGLE MODAL', e, open) if (e && !allowToggle(e)) { @@ -68,7 +46,7 @@ export const useModal = (): ModalHook => { return true } - return { isOpen, toggleModal, openModal, closeModal } + return { isOpen, toggleModal } } export const getScrollbarWidth = (): string => { From 059b5891e7faa7e4da5bee36e4222b3e9b4e846c Mon Sep 17 00:00:00 2001 From: Suzanne Rozier Date: Tue, 5 Oct 2021 15:36:32 -0500 Subject: [PATCH 33/38] Update modal exports --- src/index.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index 5b8a8e0802..6ba894111b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -86,9 +86,8 @@ export { Logo } from './components/Footer/Logo/Logo' export { SocialLinks } from './components/Footer/SocialLinks/SocialLinks' /** Modal components */ -export { Modal, ModalProps } from './components/Modal/Modal' +export { Modal, ModalProps, ModalRef } from './components/Modal/Modal' export { ModalOpenButton } from './components/Modal/ModalOpenButton' -export { useModal } from './components/Modal/utils' export { ModalHeading } from './components/Modal/ModalHeading/ModalHeading' export { ModalFooter } from './components/Modal/ModalFooter/ModalFooter' From f10dc558e756beaa01d43fd9a9dbc69a6d482cc6 Mon Sep 17 00:00:00 2001 From: Suzanne Rozier Date: Wed, 6 Oct 2021 12:34:07 -0500 Subject: [PATCH 34/38] Add ModalToggleButton, ModalOpenLink components --- src/components/Button/Button.tsx | 2 +- src/components/Link/Link.tsx | 2 +- src/components/Modal/Modal.stories.tsx | 124 +++++++------- src/components/Modal/Modal.test.tsx | 16 +- src/components/Modal/ModalOpenButton.tsx | 38 ----- src/components/Modal/ModalOpenLink.test.tsx | 71 ++++++++ src/components/Modal/ModalOpenLink.tsx | 54 ++++++ .../Modal/ModalToggleButton.test.tsx | 159 ++++++++++++++++++ src/components/Modal/ModalToggleButton.tsx | 60 +++++++ src/index.ts | 3 +- 10 files changed, 411 insertions(+), 118 deletions(-) delete mode 100644 src/components/Modal/ModalOpenButton.tsx create mode 100644 src/components/Modal/ModalOpenLink.test.tsx create mode 100644 src/components/Modal/ModalOpenLink.tsx create mode 100644 src/components/Modal/ModalToggleButton.test.tsx create mode 100644 src/components/Modal/ModalToggleButton.tsx diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index ae0d66e687..faf3d867e5 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -2,7 +2,7 @@ import React from 'react' import classnames from 'classnames' import { deprecationWarning } from '../../deprecation' -interface ButtonProps { +export interface ButtonProps { type: 'button' | 'submit' | 'reset' children: React.ReactNode secondary?: boolean diff --git a/src/components/Link/Link.tsx b/src/components/Link/Link.tsx index 12a7574716..bb8b88b9e2 100644 --- a/src/components/Link/Link.tsx +++ b/src/components/Link/Link.tsx @@ -64,7 +64,7 @@ export function Link( // 3. Therefore we know that removing those props leaves us // with FCProps // - const linkProps: FCProps = (remainingProps as unknown) as FCProps + const linkProps: FCProps = remainingProps as unknown as FCProps const classes = linkClasses(variant, className) return React.createElement( asCustom, diff --git a/src/components/Modal/Modal.stories.tsx b/src/components/Modal/Modal.stories.tsx index 445e312f4f..64bd48cc76 100644 --- a/src/components/Modal/Modal.stories.tsx +++ b/src/components/Modal/Modal.stories.tsx @@ -3,9 +3,8 @@ import React, { useRef } from 'react' import { Modal, ModalRef } from './Modal' import { ModalHeading } from './ModalHeading/ModalHeading' import { ModalFooter } from './ModalFooter/ModalFooter' -import { ModalOpenButton } from './ModalOpenButton' +import { ModalToggleButton } from './ModalToggleButton' -import { Button } from '../Button/Button' import { ButtonGroup } from '../ButtonGroup/ButtonGroup' export default { @@ -18,6 +17,27 @@ export default { ### USWDS 2.0 Modal component Source: http://designsystem.digital.gov/components/modal + +To use this component, you will need to create a ref and pass it into the rendered Modal component. This ref will expose several properties (also described by the exported ModalRef type): + +- modalId: string + - the value of the id attribute given to the modal +- modalIsOpen: boolean + - true if the modal is currently open, otherwise false +- toggleModal: (e?: React.MouseEvent, open?: boolean) => boolean + - use this function to open or close the modal. + - if attached to an event handler, pass the event in + - if the second argument is provided, it will explicitly open the modal if true, or explicitly close it if false + - returns true if the toggle operation was successful, false if the event was prevented. + +Follow the [USWDS](https://designsystem.digital.gov/components/modal/) guidance for using modals: + +- Pass a unique ID into each modal component. +- Any component that opens the modal should be a button or anchor element, have the [data-open-modal] attribute, and [aria-controls] attribute with the value of the modal ID. +- Any component that closes the modal should be a button element, and have the [data-close-modal] attribute, and [aria-controls] attribute with the value of the modal ID. +- Use the forceAction prop on the modal component if the user should be forced to take an action before closing the modal. + +You can also use the provided ModalToggleButton and/or ModalOpenLink components, which will adhere to the above guidelines for convenience. `, }, }, @@ -27,17 +47,11 @@ Source: http://designsystem.digital.gov/components/modal export const defaultModal = (): React.ReactElement => { const modalRef = useRef() - const handleOpen = (e) => modalRef.current?.toggleModal(e, true) - const handleClose = (e) => modalRef.current?.toggleModal(e, false) - return ( <> - + Open default modal - + { - - + @@ -74,17 +87,11 @@ export const defaultModal = (): React.ReactElement => { export const largeModal = (): React.ReactElement => { const modalRef = useRef() - const handleOpen = (e) => modalRef.current?.toggleModal(e, true) - const handleClose = (e) => modalRef.current?.toggleModal(e, false) - return ( <> - + Open large modal - + { - - + @@ -122,17 +128,11 @@ export const largeModal = (): React.ReactElement => { export const forceActionModal = (): React.ReactElement => { const modalRef = useRef() - const handleOpen = (e) => modalRef.current?.toggleModal(e, true) - const handleClose = (e) => modalRef.current?.toggleModal(e, false) - return ( <> - + Open modal with forced action - + { - - + @@ -172,17 +171,11 @@ export const forceActionModal = (): React.ReactElement => { export const customFocusElementModal = (): React.ReactElement => { const modalRef = useRef() - const handleOpen = (e) => modalRef.current?.toggleModal(e, true) - const handleClose = (e) => modalRef.current?.toggleModal(e, false) - return ( <> - + Open modal with custom initial focus element - + { - - + diff --git a/src/components/Modal/Modal.test.tsx b/src/components/Modal/Modal.test.tsx index 19510731f2..f206e1d318 100644 --- a/src/components/Modal/Modal.test.tsx +++ b/src/components/Modal/Modal.test.tsx @@ -11,7 +11,7 @@ import userEvent from '@testing-library/user-event' import { Modal, ModalRef } from './Modal' import { ModalHeading } from './ModalHeading/ModalHeading' import { ModalFooter } from './ModalFooter/ModalFooter' -import { ModalOpenButton } from './ModalOpenButton' +import { ModalToggleButton } from './ModalToggleButton' import { Button } from '../Button/Button' import { ButtonGroup } from '../ButtonGroup/ButtonGroup' @@ -47,12 +47,9 @@ const ExampleModal = ({ return ( <> - modalRef.current?.toggleModal(e, true)} - href="#example-modal-1" - aria-controls="example-modal-1"> + Open default modal - + { return ( <> - modalRef.current?.toggleModal(e, true)} - href="#example-modal-1" - aria-controls="example-modal-1"> + Open default modal - + void -} - -// TODO - support
or + ) +} diff --git a/src/index.ts b/src/index.ts index 6ba894111b..a1f9fc05fa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -87,7 +87,8 @@ export { SocialLinks } from './components/Footer/SocialLinks/SocialLinks' /** Modal components */ export { Modal, ModalProps, ModalRef } from './components/Modal/Modal' -export { ModalOpenButton } from './components/Modal/ModalOpenButton' +export { ModalToggleButton } from './components/Modal/ModalToggleButton' +export { ModalOpenLink } from './components/Modal/ModalOpenLink' export { ModalHeading } from './components/Modal/ModalHeading/ModalHeading' export { ModalFooter } from './components/Modal/ModalFooter/ModalFooter' From b49836c0cedda219a2ac2996f14a52cf493c05b3 Mon Sep 17 00:00:00 2001 From: Suzanne Rozier Date: Wed, 6 Oct 2021 12:39:59 -0500 Subject: [PATCH 35/38] Update ModalOpenLink test --- src/components/Modal/ModalOpenLink.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Modal/ModalOpenLink.test.tsx b/src/components/Modal/ModalOpenLink.test.tsx index 0a14ce06ef..0d5e176e72 100644 --- a/src/components/Modal/ModalOpenLink.test.tsx +++ b/src/components/Modal/ModalOpenLink.test.tsx @@ -47,7 +47,7 @@ describe('ModalOpenLink', () => { expect(consoleSpy).toHaveBeenCalledWith('ModalRef is required') }) - it('toggles the modal when clicked', () => { + it('opens the modal when clicked', () => { const mockRef: ModalRef = { modalIsOpen: false, modalId: 'testModal', @@ -66,6 +66,6 @@ describe('ModalOpenLink', () => { const button = screen.getByRole('button', { name: 'Open modal' }) userEvent.click(button) - expect(mockRef.toggleModal).toHaveBeenCalled() + expect(mockRef.toggleModal).toHaveBeenCalledWith(expect.anything(), true) }) }) From 129ffa12f33a9d6f30d2bf9253423dd99655586e Mon Sep 17 00:00:00 2001 From: Suzanne Rozier Date: Wed, 6 Oct 2021 12:44:59 -0500 Subject: [PATCH 36/38] Fix custom link implementation --- src/components/Modal/ModalOpenLink.test.tsx | 46 +++++++++++++++++++++ src/components/Modal/ModalOpenLink.tsx | 20 ++++----- 2 files changed, 55 insertions(+), 11 deletions(-) diff --git a/src/components/Modal/ModalOpenLink.test.tsx b/src/components/Modal/ModalOpenLink.test.tsx index 0d5e176e72..2a56b72d42 100644 --- a/src/components/Modal/ModalOpenLink.test.tsx +++ b/src/components/Modal/ModalOpenLink.test.tsx @@ -68,4 +68,50 @@ describe('ModalOpenLink', () => { userEvent.click(button) expect(mockRef.toggleModal).toHaveBeenCalledWith(expect.anything(), true) }) + + it('renders with a custom component', () => { + type CustomLinkProps = React.PropsWithChildren<{ + to: string + className?: string + }> & + JSX.IntrinsicElements['a'] + + const CustomLink: React.FunctionComponent = ({ + to, + children, + className, + ...linkProps + }: CustomLinkProps): React.ReactElement => ( + + {children} + + ) + + const mockRef: ModalRef = { + modalIsOpen: false, + modalId: 'testModal', + toggleModal: jest.fn().mockReturnValue(true), + } + + const modalRef: React.RefObject = { + current: mockRef, + } + + render( + + to="#testModal" + asCustom={CustomLink} + modalRef={modalRef}> + Open modal + + ) + + const button = screen.getByRole('button', { name: 'Open modal' }) + expect(button).toBeInTheDocument() + expect(button).toHaveAttribute('aria-controls', mockRef.modalId) + expect(button).toHaveAttribute('data-open-modal') + expect(button).toHaveAttribute('href', '#testModal') + userEvent.click(button) + expect(mockRef.toggleModal).toHaveBeenCalledWith(expect.anything(), true) + }) }) diff --git a/src/components/Modal/ModalOpenLink.tsx b/src/components/Modal/ModalOpenLink.tsx index 827806b38d..797bb3802d 100644 --- a/src/components/Modal/ModalOpenLink.tsx +++ b/src/components/Modal/ModalOpenLink.tsx @@ -24,8 +24,6 @@ export function ModalOpenLink({ }: | (DefaultLinkProps & ModalOpenLinkProps) | (CustomLinkProps & ModalOpenLinkProps)): React.ReactElement { - const linkProps = props as DefaultLinkProps | CustomLinkProps - const handleClick: React.MouseEventHandler = (e) => { if (!modalRef || !modalRef.current) { console.error('ModalRef is required') @@ -36,19 +34,19 @@ export function ModalOpenLink({ modalRef.current.toggleModal(e, true) } + const linkProps = { + ...props, + role: 'button', + 'aria-controls': modalRef?.current?.modalId, + 'data-open-modal': true, + onClick: handleClick, + } as DefaultLinkProps | CustomLinkProps + if (isCustomProps(linkProps)) { return {...linkProps} /> } const definitelyLinkProps = linkProps as DefaultLinkProps - return ( - - ) + return } From ded43ce51c80526b0a4619b5173a422c9616b810 Mon Sep 17 00:00:00 2001 From: Suzanne Rozier Date: Thu, 7 Oct 2021 09:19:29 -0500 Subject: [PATCH 37/38] Add open modal stories --- src/components/Modal/OpenModal.stories.tsx | 163 +++++++++++++++++++++ src/components/Modal/utils.ts | 1 - 2 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 src/components/Modal/OpenModal.stories.tsx diff --git a/src/components/Modal/OpenModal.stories.tsx b/src/components/Modal/OpenModal.stories.tsx new file mode 100644 index 0000000000..a721417970 --- /dev/null +++ b/src/components/Modal/OpenModal.stories.tsx @@ -0,0 +1,163 @@ +import React from 'react' + +import { ModalWindow } from './ModalWindow/ModalWindow' +import { ModalHeading } from './ModalHeading/ModalHeading' +import { ModalFooter } from './ModalFooter/ModalFooter' +import { ButtonGroup } from '../ButtonGroup/ButtonGroup' +import { Button } from '../Button/Button' +import { ModalWrapper } from './ModalWrapper/ModalWrapper' + +type StorybookArguments = { + handleClose: () => void +} + +export default { + title: 'Components/Modal/Open states', + argTypes: { + handleClose: { action: 'Close modal' }, + }, + parameters: { + docs: { + description: { + component: ` +### USWDS 2.0 Modal component + +Source: http://designsystem.digital.gov/components/modal +`, + }, + }, + }, +} + +export const defaultModal = ( + argTypes: StorybookArguments +): React.ReactElement => { + return ( + + + + Are you sure you want to continue? + +
+ +
+ + + + + + +
+
+ ) +} + +export const largeModal = ( + argTypes: StorybookArguments +): React.ReactElement => { + return ( + + + + Are you sure you want to continue? + +
+ +
+ + + + + + +
+
+ ) +} + +export const forceActionModal = ( + argTypes: StorybookArguments +): React.ReactElement => { + return ( + + + + Your session will end soon. + +
+ +
+ + + + + + +
+
+ ) +} diff --git a/src/components/Modal/utils.ts b/src/components/Modal/utils.ts index 304e73db28..4e0e9d9380 100644 --- a/src/components/Modal/utils.ts +++ b/src/components/Modal/utils.ts @@ -31,7 +31,6 @@ export const useModal = (): ModalHook => { } const toggleModal = (e?: React.MouseEvent, open?: boolean): boolean => { - // console.log('TOGGLE MODAL', e, open) if (e && !allowToggle(e)) { e.stopPropagation() return false From d76d583cbfc18e0decc91d7d6f70b17a0dc540e5 Mon Sep 17 00:00:00 2001 From: Suzanne Rozier Date: Thu, 7 Oct 2021 10:04:40 -0500 Subject: [PATCH 38/38] Fix Storybook action lag by replacing close handler with noop Co-authored-by: Brandon Lenz --- src/components/Modal/OpenModal.stories.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/Modal/OpenModal.stories.tsx b/src/components/Modal/OpenModal.stories.tsx index a721417970..ed0408289d 100644 --- a/src/components/Modal/OpenModal.stories.tsx +++ b/src/components/Modal/OpenModal.stories.tsx @@ -11,10 +11,14 @@ type StorybookArguments = { handleClose: () => void } +const noop = (): void => { + return +} + export default { title: 'Components/Modal/Open states', argTypes: { - handleClose: { action: 'Close modal' }, + handleClose: noop, }, parameters: { docs: {