From 48cc697fa8ee40ad6e766bd5730c54f67531268b Mon Sep 17 00:00:00 2001 From: sirineJ <112706079+sirineJ@users.noreply.github.com> Date: Mon, 6 Jan 2025 14:54:54 +0100 Subject: [PATCH] Modal Component (#2795) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * update component sing native dialog and polyfill * create new ModalContext * add tests * add translations * add documentation * update NotificationModal component * export components * fix scroll-disabling styles * require min typescript version of 4.1 * fix ::backdrop inheritance issue for older browsers * add changeset * fix package-lock.json * fix animation duration * fix: classes order * Silence lint warnings in CI * Format Modal.mdx * fix CI * refactor styles * fix tests * code review Pt 1 * optimise scrolling * use useStack * export useScrollLock * refactor hasNativeDialogSupport * format file * cde review pt1 * add doc * refactor styles * improve closing modal on unmount * refactor tests * styles * fix useRef * fix scroll affordance * add layout padded * add doc to useScrollLock * fix params * restore scroll on clean up * fix CR workflow that creates previews based on main when using `issue_comment` * remove unused animation styles * add explanation for box-shadow workaround * use label workaround * change DateInput positioning to fixed * fix modal width * add post CR comment --------- Co-authored-by: Connor Bär --- .changeset/cyan-knives-drop.md | 5 + .changeset/eight-beers-think.md | 5 + .changeset/gold-lemons-report.md | 5 + .changeset/grumpy-wombats-talk.md | 5 + .changeset/rich-icons-pretend.md | 5 + .changeset/sharp-seals-leave.md | 5 + .changeset/wicked-pants-cough.md | 5 + .github/workflows/cr.yml | 83 +++- package-lock.json | 3 +- package.json | 2 +- .../components/DateInput/DateInput.tsx | 1 + .../circuit-ui/components/Modal/Modal.mdx | 92 ++++- .../components/Modal/Modal.module.css | 242 ++++++----- .../components/Modal/Modal.spec.tsx | 239 +++++++++-- .../components/Modal/Modal.stories.tsx | 226 +++++----- .../circuit-ui/components/Modal/Modal.tsx | 391 ++++++++++++------ .../components/Modal/ModalContext.spec.tsx | 115 ++++++ .../components/Modal/ModalContext.tsx | 114 +++++ .../components/Modal/ModalService.spec.tsx | 88 ++++ .../components/Modal/ModalService.ts | 39 ++ .../components/Modal/createUseModal.spec.tsx | 74 ++++ .../components/Modal/createUseModal.ts | 62 +++ packages/circuit-ui/components/Modal/index.ts | 5 +- .../components/Modal/translations/bg-BG.json | 3 + .../components/Modal/translations/cs-CZ.json | 3 + .../components/Modal/translations/da-DK.json | 3 + .../components/Modal/translations/de-AT.json | 3 + .../components/Modal/translations/de-CH.json | 3 + .../components/Modal/translations/de-DE.json | 3 + .../components/Modal/translations/de-LU.json | 3 + .../components/Modal/translations/el-CY.json | 3 + .../components/Modal/translations/el-GR.json | 3 + .../components/Modal/translations/en-AU.json | 3 + .../components/Modal/translations/en-GB.json | 3 + .../components/Modal/translations/en-IE.json | 3 + .../components/Modal/translations/en-MT.json | 3 + .../components/Modal/translations/en-US.json | 3 + .../components/Modal/translations/es-CL.json | 3 + .../components/Modal/translations/es-CO.json | 3 + .../components/Modal/translations/es-ES.json | 3 + .../components/Modal/translations/es-MX.json | 3 + .../components/Modal/translations/es-PE.json | 3 + .../components/Modal/translations/es-US.json | 3 + .../components/Modal/translations/et-EE.json | 3 + .../components/Modal/translations/fi-FI.json | 3 + .../components/Modal/translations/fr-BE.json | 3 + .../components/Modal/translations/fr-CH.json | 3 + .../components/Modal/translations/fr-FR.json | 3 + .../components/Modal/translations/fr-LU.json | 3 + .../components/Modal/translations/hr-HR.json | 3 + .../components/Modal/translations/hu-HU.json | 3 + .../components/Modal/translations/index.ts | 26 ++ .../components/Modal/translations/it-CH.json | 3 + .../components/Modal/translations/it-IT.json | 3 + .../components/Modal/translations/lt-LT.json | 3 + .../components/Modal/translations/lv-LV.json | 3 + .../components/Modal/translations/nb-NO.json | 3 + .../components/Modal/translations/nl-BE.json | 3 + .../components/Modal/translations/nl-NL.json | 3 + .../components/Modal/translations/pl-PL.json | 3 + .../components/Modal/translations/pt-BR.json | 3 + .../components/Modal/translations/pt-PT.json | 3 + .../components/Modal/translations/ro-RO.json | 3 + .../components/Modal/translations/sk-SK.json | 3 + .../components/Modal/translations/sl-SI.json | 3 + .../components/Modal/translations/sv-SE.json | 3 + .../NotificationModal/NotificationModal.mdx | 87 +++- .../NotificationModal.module.css | 54 +-- .../NotificationModal.spec.tsx | 43 +- .../NotificationModal.stories.tsx | 70 +++- .../NotificationModal/NotificationModal.tsx | 166 ++++---- .../components/NotificationModal/index.ts | 1 + .../NotificationModal/useNotificationModal.ts | 10 +- .../hooks/useScrollLock/useScrollLock.mdx | 28 ++ .../hooks/useScrollLock/useScrollLock.spec.ts | 76 ++++ .../useScrollLock/useScrollLock.stories.tsx | 61 +++ .../hooks/useScrollLock/useScrollLock.ts | 49 +++ packages/circuit-ui/index.ts | 7 +- packages/circuit-ui/package.json | 3 +- packages/design-tokens/scripts/build.ts | 8 +- 80 files changed, 2034 insertions(+), 592 deletions(-) create mode 100644 .changeset/cyan-knives-drop.md create mode 100644 .changeset/eight-beers-think.md create mode 100644 .changeset/gold-lemons-report.md create mode 100644 .changeset/grumpy-wombats-talk.md create mode 100644 .changeset/rich-icons-pretend.md create mode 100644 .changeset/sharp-seals-leave.md create mode 100644 .changeset/wicked-pants-cough.md create mode 100644 packages/circuit-ui/components/Modal/ModalContext.spec.tsx create mode 100644 packages/circuit-ui/components/Modal/ModalContext.tsx create mode 100644 packages/circuit-ui/components/Modal/ModalService.spec.tsx create mode 100644 packages/circuit-ui/components/Modal/ModalService.ts create mode 100644 packages/circuit-ui/components/Modal/createUseModal.spec.tsx create mode 100644 packages/circuit-ui/components/Modal/createUseModal.ts create mode 100644 packages/circuit-ui/components/Modal/translations/bg-BG.json create mode 100644 packages/circuit-ui/components/Modal/translations/cs-CZ.json create mode 100644 packages/circuit-ui/components/Modal/translations/da-DK.json create mode 100644 packages/circuit-ui/components/Modal/translations/de-AT.json create mode 100644 packages/circuit-ui/components/Modal/translations/de-CH.json create mode 100644 packages/circuit-ui/components/Modal/translations/de-DE.json create mode 100644 packages/circuit-ui/components/Modal/translations/de-LU.json create mode 100644 packages/circuit-ui/components/Modal/translations/el-CY.json create mode 100644 packages/circuit-ui/components/Modal/translations/el-GR.json create mode 100644 packages/circuit-ui/components/Modal/translations/en-AU.json create mode 100644 packages/circuit-ui/components/Modal/translations/en-GB.json create mode 100644 packages/circuit-ui/components/Modal/translations/en-IE.json create mode 100644 packages/circuit-ui/components/Modal/translations/en-MT.json create mode 100644 packages/circuit-ui/components/Modal/translations/en-US.json create mode 100644 packages/circuit-ui/components/Modal/translations/es-CL.json create mode 100644 packages/circuit-ui/components/Modal/translations/es-CO.json create mode 100644 packages/circuit-ui/components/Modal/translations/es-ES.json create mode 100644 packages/circuit-ui/components/Modal/translations/es-MX.json create mode 100644 packages/circuit-ui/components/Modal/translations/es-PE.json create mode 100644 packages/circuit-ui/components/Modal/translations/es-US.json create mode 100644 packages/circuit-ui/components/Modal/translations/et-EE.json create mode 100644 packages/circuit-ui/components/Modal/translations/fi-FI.json create mode 100644 packages/circuit-ui/components/Modal/translations/fr-BE.json create mode 100644 packages/circuit-ui/components/Modal/translations/fr-CH.json create mode 100644 packages/circuit-ui/components/Modal/translations/fr-FR.json create mode 100644 packages/circuit-ui/components/Modal/translations/fr-LU.json create mode 100644 packages/circuit-ui/components/Modal/translations/hr-HR.json create mode 100644 packages/circuit-ui/components/Modal/translations/hu-HU.json create mode 100644 packages/circuit-ui/components/Modal/translations/index.ts create mode 100644 packages/circuit-ui/components/Modal/translations/it-CH.json create mode 100644 packages/circuit-ui/components/Modal/translations/it-IT.json create mode 100644 packages/circuit-ui/components/Modal/translations/lt-LT.json create mode 100644 packages/circuit-ui/components/Modal/translations/lv-LV.json create mode 100644 packages/circuit-ui/components/Modal/translations/nb-NO.json create mode 100644 packages/circuit-ui/components/Modal/translations/nl-BE.json create mode 100644 packages/circuit-ui/components/Modal/translations/nl-NL.json create mode 100644 packages/circuit-ui/components/Modal/translations/pl-PL.json create mode 100644 packages/circuit-ui/components/Modal/translations/pt-BR.json create mode 100644 packages/circuit-ui/components/Modal/translations/pt-PT.json create mode 100644 packages/circuit-ui/components/Modal/translations/ro-RO.json create mode 100644 packages/circuit-ui/components/Modal/translations/sk-SK.json create mode 100644 packages/circuit-ui/components/Modal/translations/sl-SI.json create mode 100644 packages/circuit-ui/components/Modal/translations/sv-SE.json create mode 100644 packages/circuit-ui/hooks/useScrollLock/useScrollLock.mdx create mode 100644 packages/circuit-ui/hooks/useScrollLock/useScrollLock.spec.ts create mode 100644 packages/circuit-ui/hooks/useScrollLock/useScrollLock.stories.tsx create mode 100644 packages/circuit-ui/hooks/useScrollLock/useScrollLock.ts diff --git a/.changeset/cyan-knives-drop.md b/.changeset/cyan-knives-drop.md new file mode 100644 index 0000000000..09e48dba0a --- /dev/null +++ b/.changeset/cyan-knives-drop.md @@ -0,0 +1,5 @@ +--- +"@sumup-oss/circuit-ui": minor +--- + +Set an explicit minimum version for TypeScript of 4.1 or higher. While this is technically a breaking change, v4.1 was released over 4 years ago, so we don't expect this to break anyone's code. Please let us know if this causes you issues. diff --git a/.changeset/eight-beers-think.md b/.changeset/eight-beers-think.md new file mode 100644 index 0000000000..fae2e8fa56 --- /dev/null +++ b/.changeset/eight-beers-think.md @@ -0,0 +1,5 @@ +--- +"@sumup-oss/circuit-ui": minor +--- + +Refactored the NotificationModal component to use the new Modal component under the hood. diff --git a/.changeset/gold-lemons-report.md b/.changeset/gold-lemons-report.md new file mode 100644 index 0000000000..1f6368acaf --- /dev/null +++ b/.changeset/gold-lemons-report.md @@ -0,0 +1,5 @@ +--- +"@sumup-oss/design-tokens": minor +--- + +Added "::backdrop" to the list of selectors to apply theme custom properties to. See https://developer.chrome.com/blog/css-backdrop-inheritance. diff --git a/.changeset/grumpy-wombats-talk.md b/.changeset/grumpy-wombats-talk.md new file mode 100644 index 0000000000..0f4777d5d3 --- /dev/null +++ b/.changeset/grumpy-wombats-talk.md @@ -0,0 +1,5 @@ +--- +"@sumup-oss/circuit-ui": minor +--- + +Added a new hook `useScrollLock` to disable page scroll on demand. diff --git a/.changeset/rich-icons-pretend.md b/.changeset/rich-icons-pretend.md new file mode 100644 index 0000000000..6251bcfb74 --- /dev/null +++ b/.changeset/rich-icons-pretend.md @@ -0,0 +1,5 @@ +--- +"@sumup-oss/circuit-ui": minor +--- + +Deprecated the `hideCloseButton` prop in the Modal and NotificationModal components. It had no effect. diff --git a/.changeset/sharp-seals-leave.md b/.changeset/sharp-seals-leave.md new file mode 100644 index 0000000000..c685adc03d --- /dev/null +++ b/.changeset/sharp-seals-leave.md @@ -0,0 +1,5 @@ +--- +"@sumup-oss/circuit-ui": minor +--- + +Added default translations for the Modal and NotificationModal components. The `closeButtonLabel` prop is now optional. diff --git a/.changeset/wicked-pants-cough.md b/.changeset/wicked-pants-cough.md new file mode 100644 index 0000000000..d882020047 --- /dev/null +++ b/.changeset/wicked-pants-cough.md @@ -0,0 +1,5 @@ +--- +"@sumup-oss/circuit-ui": minor +--- + +Refactored the Modal component to use the native `dialog` element. The Modal component can now be rendered directly in your JSX (the older `useModal` hook continues to be supported). diff --git a/.github/workflows/cr.yml b/.github/workflows/cr.yml index d9fcedb65a..56e4de8a4e 100644 --- a/.github/workflows/cr.yml +++ b/.github/workflows/cr.yml @@ -1,11 +1,12 @@ name: Continuous Releases on: - issue_comment: - types: [created] + pull_request: + types: + - labeled jobs: build: - if: ${{ github.event.comment.body == '/preview' && github.event.issue.pull_request }} + if: ${{ github.event.label.name == 'preview' }} runs-on: ubuntu-latest steps: @@ -25,4 +26,78 @@ jobs: run: npm run build - name: Publish packages - run: npx pkg-pr-new publish './packages/circuit-ui' './packages/design-tokens' './packages/icons' + run: npx pkg-pr-new publish './packages/circuit-ui' './packages/design-tokens' './packages/icons' --json output.json --comment=off + + - name: Post or update comment + uses: actions/github-script@v6 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const fs = require('fs'); + const output = JSON.parse(fs.readFileSync('output.json', 'utf8')); + console.log(output); + + const packages = output.packages + .map((p) => `- ${p.name}: ${p.url}`) + .join('\n'); + + const sha = context.payload.pull_request.head.sha + + const commitUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${sha}`; + + const body = `## 🚀 Your packages were published + + ### Published Packages: + + ${packages} + + [View Commit](${commitUrl})`; + + const botCommentIdentifier = '## 🚀 Your packages were published '; + + async function findBotComment(issueNumber) { + if (!issueNumber) return null; + const comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + }); + return comments.data.find((comment) => + comment.body.includes(botCommentIdentifier) + ); + } + + async function createOrUpdateComment(issueNumber) { + if (!issueNumber) { + console.log('No issue number provided. Cannot post or update comment.'); + return; + } + + const existingComment = await findBotComment(issueNumber); + if (existingComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existingComment.id, + body: body, + }); + } else { + await github.rest.issues.createComment({ + issue_number: issueNumber, + owner: context.repo.owner, + repo: context.repo.repo, + body: body, + }); + } + } + if (context.issue.number) { + await createOrUpdateComment(context.issue.number); + } + + - name: Delete label + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: + ${{ github.event.pull_request.number }} + run: | + gh pr edit $PR_NUMBER --remove-label "preview" diff --git a/package-lock.json b/package-lock.json index 810e2b8ec8..310a9ec248 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41712,7 +41712,8 @@ "vite": "^5.4.11" }, "engines": { - "node": ">=20" + "node": ">=20", + "typescript": ">=4.1" }, "peerDependencies": { "@emotion/is-prop-valid": "^1.2.1", diff --git a/package.json b/package.json index 6579f619c1..f2b4cabf2d 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "test:ci": "vitest run --coverage", "lint": "biome check --diagnostic-level=error && foundry run eslint . --ext .js,.jsx,.ts,.tsx", "lint:fix": "biome check --write --diagnostic-level=error && foundry run eslint . --ext .js,.jsx,.ts,.tsx --fix", - "lint:ci": "biome ci && foundry run eslint . --ext .js,.jsx,.ts,.tsx --quiet ", + "lint:ci": "biome ci --diagnostic-level=error && foundry run eslint . --ext .js,.jsx,.ts,.tsx --quiet ", "lint:css": "foundry run stylelint '**/*.css'", "lint:css:fix": "foundry run stylelint '**/*.css' --fix", "dev": "npm run docs:start", diff --git a/packages/circuit-ui/components/DateInput/DateInput.tsx b/packages/circuit-ui/components/DateInput/DateInput.tsx index 185eff92b7..9de2f04e69 100644 --- a/packages/circuit-ui/components/DateInput/DateInput.tsx +++ b/packages/circuit-ui/components/DateInput/DateInput.tsx @@ -224,6 +224,7 @@ export const DateInput = forwardRef( const { floatingStyles, update } = useFloating({ open, placement, + strategy: 'fixed', middleware: [ offset(4), flip({ padding, fallbackAxisSideDirection: 'start' }), diff --git a/packages/circuit-ui/components/Modal/Modal.mdx b/packages/circuit-ui/components/Modal/Modal.mdx index 301a659210..27cb5160b5 100644 --- a/packages/circuit-ui/components/Modal/Modal.mdx +++ b/packages/circuit-ui/components/Modal/Modal.mdx @@ -5,58 +5,116 @@ import * as Stories from './Modal.stories'; # Modal - + -The modal component displays self-contained tasks in a focused window that overlays the page content. +The modal component displays self-contained tasks in an overlay view, requiring the user to interact with it before returning to the underlying content. ## When to use it -Generally, use the modal component sparingly. Consider displaying more complex tasks and large amounts of information on a separate page instead. +Generally, use the modal dialog component sparingly. Consider displaying more complex tasks and large amounts of information on a separate page instead. -## Variants +A modal dialog is a disruptive pattern that interrupts the user's current task. It should only be used when the task requires immediate attention or when the user needs to make a decision before continuing: - +- Confirming a critical action, such as deleting an item or abandoning a task (also see the [NotificationModal](Notification/NotificationModal/Docs) component). +- Forms or inputs to collect small amounts of data without navigating away from the current page (e.g., login, feedback forms). +- Focused tasks like editing an item, uploading files, or reviewing details. -### Contextual +## How to use it -Use this variant when the modal content requires the context of the page underneath to be understood. On small viewports, the modal component opens up from the bottom as a bottom sheet overlay on top of the page content, dimming the uncovered area while giving a visual context of the page underneath. The height of the bottom sheet can be manually adjusted depending on the use case and the amount of content needed to be displayed. +### Inline (recommended) -### Immersive +Place your dialog content directly in the `Modal` component: -Use this variant to focus the user's attention on the modal content. On small viewports, the modal component opens up from the bottom as a fullscreen overlay on top of the page content and covers it entirely in favor of an immersive experience. +```tsx +import { Modal, Body, Button, Heading } from '@sumup-oss/circuit-ui'; +import { useState } from 'react'; + +function Component() { + const [open, setOpen] = useState(false); + + return ( + <> + + setOpen(false)} + > + {() => ( + <> + + Modal title + + Modal content + + + )} + + + ); +} +``` -## Usage in code +### With the `useModal` hook -First, wrap your application in the `ModalProvider` which keeps track of the open modals, prevents scrolling of the page when a modal is open, and ensures the accessibility of the modal. +First, wrap your application in the `ModalProvider`: ```tsx -// _app.tsx for Next.js or App.js for CRA import { ModalProvider } from '@sumup-oss/circuit-ui'; -export default function App() { +export function App() { return {/* Your content here... */}; } ``` -Then, use the `useModal` hook to open a modal from a component: +Then, use the `useModal` hook to open a dialog from a component: ```tsx -import { useModal, Button, Body } from '@sumup-oss/circuit-ui'; +import { useModal, Heading, Button, Body } from '@sumup-oss/circuit-ui'; export function SayHello({ name }) { const { setModal } = useModal(); const handleClick = () => { setModal({ - children: Hello {name}, + children: ( + <> + + Modal title + + Modal content + + + ), + 'aria-labelledby': 'dialog-title', variant: 'immersive', - closeButtonLabel: 'Close modal', }); }; return ; } ``` + +## Immersive + +Use the `immersive` variant to focus the user's attention on the dialog content. On small viewports, the dialog component opens up from the bottom as a fullscreen overlay on top of the page content and covers it entirely in favor of an immersive experience. + + + +## Related components + +For cases displaying simple text content but requiring immediate or critical action(s) from the user, consider using the [NotificationModal](Notification/NotificationModal/Docs) component. + +## Accessibility + +This component is built using the native `dialog` HTML element and follows the [WAI-ARIA Modal Authoring Practices](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/). It is fully accessible and supports keyboard navigation and screen readers. In browsers where `dialog` is not supported, it uses [dialog-polyfill](https://github.com/GoogleChrome/dialog-polyfill). + +It is important to ensure that the dialog is appropriately labeled and that the user can easily navigate and interact with it using a keyboard or screen reader. +If your dialog content has a title, make sure to provide an `aria-labelledby` attribute with the ID of the title element to the `Modal` component. Otherwise, provide an `aria-label` attribute with a descriptive label. + +If your dialog displays a complex flow with multiple screens (for example: a complex form with multiple steps), make sure to programmatically set focus to the title upon landing on every step to convey to the user their evolution within your flow. + +If your content contains interactive elements, the component will focus the first interactive element when the dialog opens by default. However, if you wish to focus a different element, you can provide the `initialFocusRef` prop with the ref of the element you want to focus. It is generally recommended to focus the least destructive element, such as a close or a cancel button. If the element you want to focus is not interactive, don't forget to give it a tabindex with a negative value to enable its focus. diff --git a/packages/circuit-ui/components/Modal/Modal.module.css b/packages/circuit-ui/components/Modal/Modal.module.css index c034e4e2e4..aa32636896 100644 --- a/packages/circuit-ui/components/Modal/Modal.module.css +++ b/packages/circuit-ui/components/Modal/Modal.module.css @@ -1,15 +1,44 @@ .base { + --dialog-animation-duration: 0.3s; + position: fixed; + max-height: 90vh; + padding: 0; + margin: auto; + overflow-y: auto; + pointer-events: none; + visibility: hidden; background-color: var(--cui-bg-elevated); + border: none; outline: none; + + /* Firefox does not support animating the backdrop property. + As a workaround, we used the box-shadow on the dialog element as a fake backdrop, + which gets animated along with the dialog element itself. + https://stackoverflow.com/questions/75313685/animating-dialog-backdrop-in-firefox + */ + box-shadow: 0 0 0 100vmax var(--cui-bg-overlay); + animation: fade-out var(--dialog-animation-duration) forwards; +} + +.base.show { + pointer-events: auto; + animation: fade-in var(--dialog-animation-duration) forwards; +} + +.content { + position: relative; + max-height: 90vh; + overflow-y: scroll; } .base::after { - position: fixed; + position: absolute; right: 0; - bottom: 0; + bottom: env(safe-area-inset-bottom); left: 0; display: block; + pointer-events: none; content: ""; background: linear-gradient( color-mix(in sRGB, var(--cui-bg-elevated) 0%, transparent), @@ -18,157 +47,148 @@ ); } -@media (max-width: 479px) { - .base { - right: 0; - bottom: 0; - left: 0; - transition: transform var(--cui-transitions-default); - transform: translateY(100%); - } +/* Close button */ +.base .close { + position: absolute; + z-index: var(--cui-z-index-absolute); +} - .base::after { - height: var(--cui-spacings-mega); - } +/* Native Backdrop */ +.base::backdrop { + background: transparent; +} + +/* Polyfill Backdrop */ +.base + .backdrop { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + background: transparent; } @media (min-width: 480px) { .base { - top: 50%; - left: 50%; + top: 0; + right: 0; + bottom: 0; + left: 0; + min-width: 480px; + max-width: 50vw; + max-height: 90vh; border-radius: var(--cui-border-radius-mega); - opacity: 0; - transition: opacity var(--cui-transitions-slow); - transform: translate(-50%, -50%); } - .base::after { - height: var(--cui-spacings-giga); - border-bottom-right-radius: var(--cui-border-radius-mega); - border-bottom-left-radius: var(--cui-border-radius-mega); + .base .content { + padding: var(--cui-spacings-giga); + padding-bottom: calc( + env(safe-area-inset-bottom) + var(--cui-spacings-giga) + ); } -} -/* Variants */ - -@media (max-width: 479px) { - .contextual { - border-top-left-radius: var(--cui-border-radius-mega); - border-top-right-radius: var(--cui-border-radius-mega); + .base::after { + height: var(--cui-spacings-giga); } - .immersive { - top: 0; + .base .close { + top: var(--cui-spacings-byte); + right: var(--cui-spacings-byte); } } @media (max-width: 479px) { - .open { - transform: translateY(0); + .base { + top: unset; + right: 0; + bottom: 0; + left: 0; + width: 100%; + max-width: 100%; + border-radius: var(--cui-border-radius-mega) var(--cui-border-radius-mega) 0 + 0; + animation: slide-out var(--dialog-animation-duration) forwards; } -} -@media (min-width: 480px) { - .open { - opacity: 1; + .base.show { + animation: slide-in var(--dialog-animation-duration) forwards; } -} -@media (max-width: 479px) { - .closed { - transform: translateY(100%); + .immersive { + height: 100%; + max-height: unset; + border: none; + border-radius: unset; } -} -@media (min-width: 480px) { - .closed { - opacity: 0; + .base .content { + padding: var(--cui-spacings-mega); + padding-bottom: calc( + env(safe-area-inset-bottom) + var(--cui-spacings-mega) + ); + -webkit-overflow-scrolling: touch; } -} - -/* Overlay */ - -.overlay { - position: fixed; - top: 0; - right: 0; - bottom: 0; - left: 0; - z-index: var(--cui-z-index-modal); - background: var(--cui-bg-overlay); - opacity: 0; - transition: opacity var(--cui-transitions-default); -} -@media (min-width: 480px) { - .overlay { - transition: opacity var(--cui-transitions-slow); + .base::after { + height: var(--cui-spacings-mega); } -} -.overlay-open { - opacity: 1; + .base .close { + top: var(--cui-spacings-bit); + right: var(--cui-spacings-bit); + } } -.overlay-closed { - opacity: 0; -} +/* Animations */ -/* Content */ +@keyframes fade-in { + from { + visibility: hidden; + opacity: 0; + } -.content { - overflow-y: auto; + to { + visibility: visible; + opacity: 1; + } } -@media (max-width: 479px) { - .content { - width: 100vw; - padding: var(--cui-spacings-mega); - padding-bottom: calc( - env(safe-area-inset-bottom) + var(--cui-spacings-mega) - ); - -webkit-overflow-scrolling: touch; +@keyframes fade-out { + from { + visibility: visible; + opacity: 1; } -} -@media (min-width: 480px) { - .content { - min-width: 480px; - max-width: 90vw; - max-height: 90vh; - padding: var(--cui-spacings-giga); - padding-bottom: calc( - env(safe-area-inset-bottom) + var(--cui-spacings-giga) - ); + to { + visibility: hidden; + opacity: 0; } } -/* Variants */ - -@media (max-width: 479px) { - .contextual .content { - max-height: calc(100vh - var(--cui-spacings-giga)); +@keyframes slide-in { + from { + visibility: hidden; + opacity: 0; + transform: translateY(100%); } -} -/* iOS viewport bug fix: https://allthingssmitty.com/2020/05/11/css-fix-for-100vh-in-mobile-webkit/ */ -@supports (max-height: -webkit-fill-available) { - @media (max-width: 479px) { - .contextual .content { - max-height: 80vh; - } + to { + visibility: visible; + opacity: 1; + transform: translateY(0); } } -@media (max-width: 479px) { - .immersive .content { - height: 100%; +@keyframes slide-out { + from { + visibility: visible; + opacity: 1; + transform: translateY(0); } -} -.base .close { - position: absolute; - top: var(--cui-spacings-byte); - right: var(--cui-spacings-byte); - z-index: var(--cui-z-index-absolute); + to { + visibility: hidden; + opacity: 0; + transform: translateY(100%); + } } diff --git a/packages/circuit-ui/components/Modal/Modal.spec.tsx b/packages/circuit-ui/components/Modal/Modal.spec.tsx index 10942bf450..593ed3d452 100644 --- a/packages/circuit-ui/components/Modal/Modal.spec.tsx +++ b/packages/circuit-ui/components/Modal/Modal.spec.tsx @@ -1,5 +1,5 @@ /** - * Copyright 2019, SumUp Ltd. + * Copyright 2024, SumUp Ltd. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at @@ -13,57 +13,234 @@ * limitations under the License. */ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; +import { createRef } from 'react'; import { render, - userEvent, + screen, axe, + userEvent, waitFor, - screen, + act, } from '../../util/test-utils.js'; -import { Modal, type ModalProps } from './Modal.js'; +import { ANIMATION_DURATION, Modal } from './Modal.js'; describe('Modal', () => { - const defaultModal: ModalProps = { - variant: 'immersive', - isOpen: true, - closeButtonLabel: 'Close modal', + const props = { onClose: vi.fn(), - // eslint-disable-next-line react/prop-types, react/display-name - children:

Hello world!

, - // Silences the warning about the missing app element. - // In user land, the modal is always rendered by the ModalProvider, - // which takes care of setting the app element. - // http://reactcommunity.org/react-modal/accessibility/#app-element - ariaHideApp: false, + open: false, + closeButtonLabel: 'Close', + children: 'Modal dialog content', }; + let originalHTMLDialogElement: typeof window.HTMLDialogElement; + + beforeEach(() => { + originalHTMLDialogElement = window.HTMLDialogElement; + vi.clearAllMocks(); + vi.useFakeTimers({ shouldAdvanceTime: true }); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + vi.resetAllMocks(); + Object.defineProperty(window, 'HTMLDialogElement', { + writable: true, + value: originalHTMLDialogElement, + }); + }); + + it('should forward a ref', () => { + const ref = createRef(); + render(); + const dialog = screen.getByRole('dialog', { hidden: true }); + expect(ref.current).toBe(dialog); + }); - it('should render the modal', async () => { - render(); + it('should merge a custom class name with the default ones', () => { + const className = 'foo'; + render(); + // eslint-disable-next-line testing-library/no-container + const dialog = screen.getByRole('dialog', { hidden: true }); + expect(dialog?.className).toContain(className); + }); + + it('should render in closed state by default', () => { + render(); + // eslint-disable-next-line testing-library/no-container + const dialog = screen.getByRole('dialog', { hidden: true }); + expect(dialog).not.toBeVisible(); + }); - await waitFor(() => { - expect(screen.getByRole('dialog')).toBeVisible(); + it('should open the dialog when the open prop becomes truthy', () => { + const { rerender } = render(); + // eslint-disable-next-line testing-library/no-container + const dialog = screen.getByRole('dialog', { + hidden: true, }); + vi.spyOn(dialog, 'showModal'); + rerender(); + expect(dialog.showModal).toHaveBeenCalledOnce(); + expect(dialog).toBeVisible(); }); - it('should call the onClose callback', async () => { - render(); + it('should close the dialog when the open prop becomes falsy', () => { + const { rerender } = render(); + // eslint-disable-next-line testing-library/no-container + const dialog = screen.getByRole('dialog', { + hidden: true, + }); + vi.spyOn(dialog, 'close'); + rerender(); + act(() => { + vi.advanceTimersByTime(ANIMATION_DURATION); + }); + expect(dialog.close).toHaveBeenCalledOnce(); + expect(dialog).not.toBeVisible(); + }); - await userEvent.click(screen.getByRole('button')); + it('should close the dialog when the component is unmounted', async () => { + const { unmount } = render(); + // eslint-disable-next-line testing-library/no-container + const dialog = screen.getByRole('dialog', { + hidden: true, + }); + vi.spyOn(dialog, 'close'); + unmount(); + expect(dialog.close).toHaveBeenCalledOnce(); + expect(dialog).not.toBeVisible(); + }); + + describe('when the dialog is closed', () => { + it('should not render its children', () => { + render(); + const children = screen.queryByText('Modal dialog content'); + + expect(children).not.toBeInTheDocument(); + }); - expect(defaultModal.onClose).toHaveBeenCalled(); + it('should do nothing when pressing the Escape key', async () => { + render(); + await userEvent.keyboard('{Escape}'); + expect(props.onClose).not.toHaveBeenCalled(); + }); }); - it('should render the children render prop', () => { - render(); - expect(screen.getByTestId('children')).toHaveTextContent('Hello world!'); + describe('when the dialog is open', () => { + it('should render its children', () => { + render(); + act(() => { + vi.advanceTimersByTime(ANIMATION_DURATION); + }); + expect(screen.getByText('Modal dialog content')).toBeVisible(); + }); + + it('should not close modal on backdrop click if preventClose is true', async () => { + render(); + // eslint-disable-next-line testing-library/no-container + const dialog = screen.getByRole('dialog', { hidden: true }); + await userEvent.click(dialog); + act(() => { + vi.advanceTimersByTime(ANIMATION_DURATION); + }); + expect(props.onClose).not.toHaveBeenCalled(); + expect(dialog).toBeVisible(); + }); + + it('should close the dialog when pressing the backdrop', async () => { + render(); + const dialog = screen.getByRole('dialog', { hidden: true }); + await userEvent.click(screen.getByRole('dialog', { hidden: true })); + act(() => { + vi.advanceTimersByTime(ANIMATION_DURATION); + }); + expect(props.onClose).toHaveBeenCalledOnce(); + expect(dialog).not.toBeVisible(); + }); + + it('should close the dialog when the close button is clicked', async () => { + render(); + const dialog = screen.getByRole('dialog', { hidden: true }); + await userEvent.click(screen.getByRole('button', { name: 'Close' })); + act(() => { + vi.advanceTimersByTime(ANIMATION_DURATION); + }); + expect(props.onClose).toHaveBeenCalledOnce(); + expect(dialog).not.toBeVisible(); + }); + + it('should remove animation classes when closed when polyfill is used', async () => { + Object.defineProperty(window, 'HTMLDialogElement', { + writable: true, + value: undefined, + }); + + render(); + const dialog = screen.getByRole('dialog', { hidden: true }); + + const backdrop = document.getElementsByClassName('backdrop')[0]; + expect(backdrop.classList.toString()).toContain('backdrop-visible'); + await userEvent.click(screen.getByRole('button', { name: 'Close' })); + expect(backdrop.classList.toString()).not.toContain('backdrop-visible'); + act(() => { + vi.advanceTimersByTime(ANIMATION_DURATION); + }); + + expect(props.onClose).toHaveBeenCalledOnce(); + expect(dialog).not.toBeVisible(); + }); }); - it('should meet accessibility guidelines', async () => { - const { container } = render(); - const actual = await axe(container); - expect(actual).toHaveNoViolations(); + describe('accessibility', () => { + it('should have no accessibility violations', async () => { + const { container } = render(); + const actual = await axe(container); + expect(actual).toHaveNoViolations(); + }); + + it('should focus the close button when opened', () => { + render(); + expect(screen.getByRole('button', { name: /Close/i })).toHaveFocus(); + }); + + it('should focus the first interactive element when opened', async () => { + render( + + {() => ( + + )} + , + ); + const closeButton = screen.getByRole('button', { name: /Button/i }); + + await waitFor(() => expect(closeButton).toHaveFocus()); + }); + + it('should focus a given element when provided', async () => { + const ref = createRef(); + render( + + {() => ( +
+ + +
+ )} +
, + ); + const spacialButton = screen.getByRole('button', { + name: /Special button/i, + }); + + await waitFor(() => expect(spacialButton).toHaveFocus()); + }); }); }); diff --git a/packages/circuit-ui/components/Modal/Modal.stories.tsx b/packages/circuit-ui/components/Modal/Modal.stories.tsx index 89ea759ad0..dc1f3463a7 100644 --- a/packages/circuit-ui/components/Modal/Modal.stories.tsx +++ b/packages/circuit-ui/components/Modal/Modal.stories.tsx @@ -13,35 +13,33 @@ * limitations under the License. */ -import type { Decorator } from '@storybook/react'; -import { Fragment } from 'react'; +import { Fragment, useState } from 'react'; import { screen, userEvent, within } from '@storybook/test'; +import type { Decorator } from '@storybook/react'; -import { - FullViewport, - Stack, -} from '../../../../.storybook/components/index.js'; import { modes } from '../../../../.storybook/modes.js'; -import { Button } from '../Button/index.js'; import { Headline } from '../Headline/index.js'; import { Body } from '../Body/index.js'; -import { Image } from '../Image/index.js'; -import { ModalProvider } from '../ModalContext/index.js'; +import { Button } from '../Button/index.js'; +import { FullViewport } from '../../../../.storybook/components/index.js'; + +import { ModalProvider } from './ModalContext.js'; -import { useModal, Modal, type ModalProps } from './Modal.js'; +import { Modal, type ModalProps, useModal } from './index.js'; export default { title: 'Components/Modal', component: Modal, - subcomponents: { ModalProvider }, - tags: ['status:under-review'], - parameters: { - chromatic: { - modes: { - mobile: modes.smallMobile, - desktop: modes.desktop, - }, + tags: ['status:stable'], + chromatic: { + modes: { + mobile: modes.smallMobile, + desktop: modes.desktop, }, + pauseAnimationAtEnd: true, + }, + parameters: { + layout: 'padded', }, decorators: [ (Story) => ( @@ -54,10 +52,10 @@ export default { const defaultModalChildren = () => ( - + Hello World! - I am a modal. + I am a modal dialog. ); @@ -75,61 +73,39 @@ const openModal = async ({ await screen.findByRole('dialog'); }; -export const Base = (modal: ModalProps) => { - const ComponentWithModal = () => { - const { setModal } = useModal(); - - return ( - - ); - }; - return ( - - - - ); -}; - -Base.args = { - children: defaultModalChildren, +const baseArgs: ModalProps = { + open: false, + onClose: () => {}, + 'aria-labelledby': 'title', + 'aria-describedby': 'description', variant: 'contextual', - closeButtonLabel: 'Close modal', + closeButtonLabel: 'Close', + children: defaultModalChildren, }; -Base.play = openModal; - -export const Variants = (modal: ModalProps) => { - const ComponentWithModal = ({ variant }: Pick) => { - const { setModal } = useModal(); - return ( - - ); - }; +export const Base = (modal: ModalProps) => { + const [modalOpen, setModalOpen] = useState(false); return ( - - - - - - + <> + + setModalOpen(false)} /> + ); }; +Base.args = baseArgs; +Base.play = openModal; -Variants.args = { - children: defaultModalChildren, - closeButtonLabel: 'Close modal', -}; -Variants.parameters = { - chromatic: { disableSnapshot: true }, -}; - -export const PreventClose = (modal: ModalProps) => { +export const WithUseModal = (modal: ModalProps) => { const ComponentWithModal = () => { const { setModal } = useModal(); + return ( - - ), - variant: 'immersive', - preventClose: true, +WithUseModal.args = { + children: defaultModalChildren, + closeButtonLabel: 'Close modal', }; -PreventClose.play = openModal; +WithUseModal.play = openModal; export const InitiallyOpen = (modal: ModalProps) => { const initialModal = { id: 'initial', component: Modal, ...modal }; + return (
); }; - InitiallyOpen.args = { children: defaultModalChildren, variant: 'contextual', closeButtonLabel: 'Close modal', }; +InitiallyOpen.parameters = { + chromatic: { disableSnapshot: true }, +}; -export const CustomStyles = (modal: ModalProps) => { - const ComponentWithModal = () => { - const { setModal } = useModal(); - return ( - - ); - }; - - return ( - - - + setModalOpen(false)} + closeButtonLabel="Close" + aria-labelledby="title" + aria-describedby="description" + variant="immersive" + > + {defaultModalChildren} + + ); }; +Immersive.chromatic = { + modes: { + mobile: modes.smallMobile, + }, +}; +Immersive.play = openModal; -CustomStyles.args = { - children: () => ( - - { + const [modalOpen, setModalOpen] = useState(false); + return ( + <> + + setModalOpen(false)} + closeButtonLabel="Close" + aria-labelledby="title" + aria-describedby="description" + > + {defaultModalChildren} + + + ); }; -CustomStyles.play = openModal; +PreventClose.parameters = { + chromatic: { disableSnapshot: true }, +}; +PreventClose.play = openModal; diff --git a/packages/circuit-ui/components/Modal/Modal.tsx b/packages/circuit-ui/components/Modal/Modal.tsx index 5608fe0232..5b0856ccc6 100644 --- a/packages/circuit-ui/components/Modal/Modal.tsx +++ b/packages/circuit-ui/components/Modal/Modal.tsx @@ -15,139 +15,292 @@ 'use client'; -import type { HTMLAttributes, ReactNode } from 'react'; -import ReactModal from 'react-modal'; - -import { isFunction } from '../../util/type-check.js'; import { - createUseModal, - type ModalComponent, - type BaseModalProps, -} from '../ModalContext/index.js'; + forwardRef, + type HTMLAttributes, + type ReactNode, + type RefObject, + useCallback, + useEffect, + useLayoutEffect, + useRef, +} from 'react'; + import { CloseButton } from '../CloseButton/index.js'; -import { StackContext } from '../StackContext/index.js'; -import { - AccessibilityError, - isSufficientlyLabelled, -} from '../../util/errors.js'; +import dialogPolyfill from '../../vendor/dialog-polyfill/index.js'; +import { applyMultipleRefs } from '../../util/refs.js'; import { clsx } from '../../styles/clsx.js'; +import type { ClickEvent } from '../../types/events.js'; +import { isEscape } from '../../util/key-codes.js'; +import { useI18n } from '../../hooks/useI18n/useI18n.js'; +import { deprecate } from '../../util/logger.js'; +import type { Locale } from '../../util/i18n.js'; +import { useScrollLock } from '../../hooks/useScrollLock/useScrollLock.js'; import classes from './Modal.module.css'; +import { getFirstFocusableElement } from './ModalService.js'; +import { translations } from './translations/index.js'; -const TRANSITION_DURATION = 300; - -type PreventCloseProps = - | { - /** - * Text label for the close button for screen readers. - * Important for accessibility. - */ - closeButtonLabel?: never; - /** - * Prevent users from closing the modal by clicking/tapping the overlay or - * pressing the escape key. Default `false`. - */ - preventClose: boolean; - } - | { - closeButtonLabel: string; - preventClose?: never; - }; +type DataAttribute = `data-${string}`; +export interface ModalProps + extends Omit, 'children'> { + /** + * Whether the modal dialog is open or not. + */ + open: boolean; + /** + * Callback when the modal dialog is closed. + */ + onClose?: () => void; + /** + * a ReactNode or a function that returns the content of the modal dialog. + */ + children?: + | ReactNode + | (({ onClose }: { onClose?: ModalProps['onClose'] }) => ReactNode); + /** + * Text label for the close button for screen readers. + * Important for accessibility. + */ + closeButtonLabel?: string; + /** + * Use the `immersive` variant to focus the user's attention on the dialog content. + * default: 'contextual' + * */ + variant?: 'contextual' | 'immersive'; + /** + * Prevent users from closing the modal by clicking/tapping the overlay or + * pressing the escape key. Default `false`. + */ + preventClose?: boolean; + /** + * Enables focusing a particular element in the dialog content and override default behavior + */ + initialFocusRef?: RefObject; + /** + * One or more [IETF BCP 47](https://en.wikipedia.org/wiki/IETF_language_tag) + * locale identifiers such as `'de-DE'` or `['GB', 'en-US']`. + * When passing an array, the first supported locale is used. + * Defaults to `navigator.language` in supported environments. + */ + locale?: Locale; + /** + * @deprecated This prop was passed to `react-modal` and is no longer relevant. + * Use the `preventClose` prop instead. Also see https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/dialog_role#required_javascript_features + */ + hideCloseButton?: boolean; + [key: DataAttribute]: string | undefined; +} -export type ModalProps = BaseModalProps & - PreventCloseProps & { - /** - * The modal content. Use a render function when you need access to the - * `onClose` function. - */ - children: - | ReactNode - | (({ onClose }: Pick) => ReactNode); - /** - * Use the `contextual` variant when the modal content requires the context - * of the page underneath to be understood, otherwise, use the `immersive` - * variant to focus the user's attention. - */ - variant: 'contextual' | 'immersive'; - /** - * Custom styles for the modal wrapper element. - */ - className?: HTMLAttributes['className']; - /** - * Custom styles for the modal wrapper element. - */ - style?: HTMLAttributes['style']; - }; +export const ANIMATION_DURATION = 300; -/** - * The modal component displays self-contained tasks in a focused window that - * overlays the page content. - * Built on top of [`react-modal`](https://reactcommunity.org/react-modal/). - */ -export const Modal: ModalComponent = ({ - children, - onClose, - variant = 'contextual', - preventClose = false, - closeButtonLabel, - className, - style, - ...props -}) => { - if ( - process.env.NODE_ENV !== 'production' && - process.env.NODE_ENV !== 'test' && - !preventClose && - !isSufficientlyLabelled(closeButtonLabel) - ) { - throw new AccessibilityError( - 'Modal', - "The `closeButtonLabel` prop is missing or invalid. Pass it in `setModal`, or pass `preventClose` if you intend to hide the Modal's close button.", - ); +export const Modal = forwardRef((props, ref) => { + const { + open, + onClose, + locale, + closeButtonLabel, + variant = 'contextual', + children, + className, + preventClose, + initialFocusRef, + hideCloseButton, + ...rest + } = useI18n(props, translations); + const dialogRef = useRef(null); + const lastFocusedElementRef = useRef(null); + + if (process.env.NODE_ENV !== 'production') { + if (hideCloseButton) { + deprecate( + 'Modal', + 'The `hideCloseButton` prop has been deprecated. Use the `preventClose` prop instead.', + ); + } } - const reactModalProps = { - className: { - base: clsx(classes.base, classes[variant]), - afterOpen: classes.open, - beforeClose: classes.closed, + // eslint-disable-next-line compat/compat + const hasNativeDialog = window.HTMLDialogElement !== undefined; + + useScrollLock(open); + + useLayoutEffect( + () => () => { + if (dialogRef?.current?.open) { + dialogRef?.current?.close(); + } }, - overlayClassName: { - base: classes.overlay, - afterOpen: classes['overlay-open'], - beforeClose: classes['overlay-closed'], + [], + ); + + // set initial focus on the modal dialog content + useEffect(() => { + const dialogElement = dialogRef.current; + let timeoutId: NodeJS.Timeout; + if (open && dialogElement) { + timeoutId = setTimeout(() => { + if (initialFocusRef?.current) { + initialFocusRef?.current?.focus(); + } else { + getFirstFocusableElement(dialogElement).focus(); + } + }, ANIMATION_DURATION); + } + return () => { + clearTimeout(timeoutId); + }; + }, [open, initialFocusRef?.current]); + + const handleDialogClose = useCallback(() => { + const dialogElement = dialogRef.current; + if (!dialogElement) { + return; + } + // restore focus to the last focused element + if (lastFocusedElementRef.current) { + setTimeout( + () => lastFocusedElementRef.current?.focus(), + ANIMATION_DURATION, + ); + } + // trigger closing animation + dialogElement.classList.remove(classes.show); + if (!hasNativeDialog) { + (dialogElement.nextSibling as HTMLDivElement).classList.remove( + classes['backdrop-visible'], + ); + } + // trigger closing of the dialog after animation + setTimeout(() => { + if (dialogElement.open) { + dialogElement.close(); + } + }, ANIMATION_DURATION); + }, [hasNativeDialog]); + + // intercept and prevent polyfill modal closing if preventClose is true + const onPolyfillDialogKeydown = useCallback((event: KeyboardEvent) => { + if (isEscape(event)) { + event.preventDefault(); + event.stopPropagation(); + } + }, []); + + const onPolyfillBackdropClick = useCallback( + (event: MouseEvent) => { + if (preventClose) { + event.preventDefault(); + } }, - onRequestClose: onClose, - closeTimeoutMS: TRANSITION_DURATION, - shouldCloseOnOverlayClick: !preventClose, - shouldCloseOnEsc: !preventClose, - /** - * react-modal relies on document.activeElement to return focus after the modal is closed. - * Safari and Firefox don't set it properly on button click (see https://github.com/reactjs/react-modal/issues/858 and https://github.com/reactjs/react-modal/issues/389). - * Returning the focus to document.body or to the focus-root can cause unwanted page scroll. - * Preventing scroll on focus would provide better UX for mouse users and shouldn't cause any side effects for assistive technology users. - */ - preventScroll: true, - ...props, + [preventClose], + ); + + useEffect(() => { + const dialogElement = dialogRef.current; + if (!dialogElement) { + return undefined; + } + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore The package is bundled incorrectly + dialogPolyfill.registerDialog(dialogElement); + if (preventClose) { + dialogElement.addEventListener('keydown', onPolyfillDialogKeydown); + } + if (onClose) { + dialogElement.addEventListener('close', onClose); + } + + return () => { + if (onClose) { + dialogElement.removeEventListener('close', onClose); + } + if (!hasNativeDialog && dialogElement.nextSibling) { + (dialogElement.nextSibling as HTMLDivElement).removeEventListener( + 'click', + onPolyfillBackdropClick, + ); + dialogElement.removeEventListener('keydown', onPolyfillDialogKeydown); + } + }; + }, [ + onClose, + onPolyfillBackdropClick, + preventClose, + hasNativeDialog, + onPolyfillDialogKeydown, + ]); + + useEffect(() => { + const dialogElement = dialogRef.current; + + if (!dialogElement) { + return; + } + if (open) { + if (document.activeElement instanceof HTMLElement) { + lastFocusedElementRef.current = document.activeElement; + } + if (!dialogElement.open) { + dialogElement.showModal(); + if (!hasNativeDialog) { + // use the polyfill backdrop + (dialogElement.nextSibling as HTMLDivElement).classList.add( + classes['backdrop-visible'], + classes.backdrop, + ); + // intercept and prevent modal closing if preventClose is true + (dialogElement.nextSibling as HTMLDivElement).addEventListener( + 'click', + onPolyfillBackdropClick, + ); + } + + // trigger show animation + dialogElement.classList.add(classes.show); + } + } else if (dialogElement.open) { + handleDialogClose(); + } + }, [open, handleDialogClose, hasNativeDialog, onPolyfillBackdropClick]); + + const onDialogClick = ( + event: ClickEvent | ClickEvent, + ) => { + // the dialog content covers the whole dialog element + // leaving the backdrop element as the only clickable area + // that can trigger an onClick event + if (event.target === event.currentTarget && !preventClose) { + handleDialogClose(); + } }; return ( - - -
- {!preventClose && closeButtonLabel && ( - - {closeButtonLabel} - - )} - - {isFunction(children) ? children({ onClose }) : children} -
-
-
+ <> + {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */} + + + {closeButtonLabel} + + {open && ( +
+ {typeof children === 'function' + ? children?.({ onClose }) + : children} +
+ )} +
+ ); -}; - -Modal.TRANSITION_DURATION = TRANSITION_DURATION; +}); -export const useModal = createUseModal(Modal); +Modal.displayName = 'Modal'; diff --git a/packages/circuit-ui/components/Modal/ModalContext.spec.tsx b/packages/circuit-ui/components/Modal/ModalContext.spec.tsx new file mode 100644 index 0000000000..5e2eb4cc11 --- /dev/null +++ b/packages/circuit-ui/components/Modal/ModalContext.spec.tsx @@ -0,0 +1,115 @@ +/** + * Copyright 2021, SumUp Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useContext } from 'react'; + +import { render, act, screen, userEvent } from '../../util/test-utils.js'; + +import { ModalProvider, ModalContext } from './ModalContext.js'; +import { ANIMATION_DURATION, type ModalProps } from './Modal.js'; + +const Modal = ({ onClose, open }: ModalProps) => ( + + + +); + +describe('ModalContext', () => { + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + }); + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + describe('ModalProvider', () => { + const onClose = vi.fn(); + const modal = { + id: 'initial', + open: true, + component: Modal, + onClose, + children: () =>

Modal content

, + closeButtonLabel: 'Close', + }; + const initialState = [modal]; + + it('should render the initial modals', () => { + render( + +
+ , + ); + + act(() => { + vi.advanceTimersByTime(ANIMATION_DURATION); + }); + + expect(screen.getByTestId('dummy-dialog')).toBeVisible(); + }); + + it('should open and close a modal when the context functions are called', async () => { + const Trigger = () => { + const { setModal, removeModal } = useContext(ModalContext); + return ( + <> + + + + ); + }; + + render( + + + , + ); + + await userEvent.click(screen.getByRole('button', { name: 'Open modal' })); + + expect(screen.getByRole('dialog')).toBeVisible(); + + await userEvent.click(screen.getByRole('button', { name: 'Close' })); + + act(() => { + vi.runAllTimers(); + }); + + expect(screen.queryByTestId('dummy-dialog')).not.toBeInTheDocument(); + }); + + it('should close the modal when the onClose method is called', async () => { + render( + +
+ , + ); + + const closeButton = screen.queryByRole('button') as HTMLButtonElement; + await userEvent.click(closeButton); + act(() => { + vi.runAllTimers(); + }); + + expect(screen.queryByTestId('dummy-dialog')).not.toBeInTheDocument(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/circuit-ui/components/Modal/ModalContext.tsx b/packages/circuit-ui/components/Modal/ModalContext.tsx new file mode 100644 index 0000000000..d3d4521557 --- /dev/null +++ b/packages/circuit-ui/components/Modal/ModalContext.tsx @@ -0,0 +1,114 @@ +/** + * Copyright 2024, SumUp Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use client'; + +import { createContext, type ReactNode, useCallback, useMemo } from 'react'; + +import { useStack } from '../../hooks/useStack/index.js'; + +import { ANIMATION_DURATION, type ModalProps } from './Modal.js'; +import type { ModalDialogComponent } from './createUseModal.js'; + +export type SetModalArgs = Omit; + +// keep initial state compatible with the old version of this component +export type ModalState = SetModalArgs & { + component: ModalDialogComponent; + id: string | number; + open: boolean; +}; + +type ModalContextValue = { + setModal: (modal: ModalState) => void; + removeModal: (modal: ModalState) => void; +}; +export interface ModalProviderProps { + /** + * The ModalProvider should wrap your entire application. + */ + children: ReactNode; + /** + * An array of modals that should be displayed immediately, e.g. on page load. + */ + initialState?: ModalState[]; +} + +// TODO replace any +export const ModalContext = createContext>({ + setModal: () => {}, + removeModal: () => {}, +}); + +export function ModalProvider({ + children, + initialState, + ...defaultModalProps +}: ModalProviderProps) { + const [modals, dispatch] = useStack>( + initialState?.map((modal) => ({ ...modal, open: true })), + ); + + const setModal = useCallback( + (modal: ModalState) => { + dispatch({ type: 'push', item: modal }); + }, + [dispatch], + ); + + const removeModal = useCallback( + (modal: ModalState) => { + if (modal.onClose) { + modal.onClose(); + } + dispatch({ + type: 'update', + item: modal, + }); + dispatch({ + type: 'remove', + id: modal.id, + transition: { + duration: ANIMATION_DURATION, + }, + }); + }, + [dispatch], + ); + + const context = useMemo( + () => ({ setModal, removeModal }), + [setModal, removeModal], + ); + + return ( + + {children} + {modals.map((modal) => { + const { id, open, component: Component, ...modalProps } = modal; + return ( + // @ts-expect-error type will either be ModalProps or NotificationProps + removeModal(modal)} + /> + ); + })} + + ); +} diff --git a/packages/circuit-ui/components/Modal/ModalService.spec.tsx b/packages/circuit-ui/components/Modal/ModalService.spec.tsx new file mode 100644 index 0000000000..495798f97f --- /dev/null +++ b/packages/circuit-ui/components/Modal/ModalService.spec.tsx @@ -0,0 +1,88 @@ +/** + * Copyright 2024, SumUp Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, it } from 'vitest'; + +import { getKeyboardFocusableElements } from './ModalService.js'; + +describe('DialogService', () => { + describe('getKeyboardFocusableElements', () => { + it('should return empty array if element is empty', () => { + const container = document.createElement('div'); + document.body.appendChild(container); + + const result = getKeyboardFocusableElements(document.body); + expect(result).toEqual([]); + }); + + it('should not return an a tag without href', () => { + const a = document.createElement('a'); + document.body.appendChild(a); + const result = getKeyboardFocusableElements(document.body); + expect(result).toEqual([]); + }); + + it('should not return a disabled element', () => { + const input = document.createElement('input'); + input.setAttribute('disabled', 'true'); + document.body.appendChild(input); + const result = getKeyboardFocusableElements(document.body); + expect(result).toEqual([]); + }); + + it('should not return an element with aria-hidden', () => { + const input = document.createElement('input'); + input.setAttribute('aria-hidden', 'true'); + document.body.appendChild(input); + const result = getKeyboardFocusableElements(document.body); + expect(result).toEqual([]); + }); + + it('should return an array of focusable elements', () => { + const container = document.createElement('div'); + container.setAttribute('tabindex', '0'); + const button = document.createElement('button'); + const input = document.createElement('input'); + const a = document.createElement('a'); + a.setAttribute('href', 'showSignature(xyz)'); + const textarea = document.createElement('textarea'); + const select = document.createElement('select'); + const details = document.createElement('details'); + + document.body.append( + container, + button, + input, + a, + textarea, + select, + details, + ); + + const result = getKeyboardFocusableElements(document.body); + expect(result).toEqual( + expect.arrayContaining([ + button, + input, + a, + textarea, + select, + details, + container, + ]), + ); + }); + }); +}); diff --git a/packages/circuit-ui/components/Modal/ModalService.ts b/packages/circuit-ui/components/Modal/ModalService.ts new file mode 100644 index 0000000000..7c2a5ecc45 --- /dev/null +++ b/packages/circuit-ui/components/Modal/ModalService.ts @@ -0,0 +1,39 @@ +/** + * Copyright 2024, SumUp Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export function getKeyboardFocusableElements( + element: HTMLElement, +): HTMLElement[] { + return [ + ...element.querySelectorAll( + 'a[href], button, input, textarea, select, details,[tabindex]:not([tabindex="-1"])', + ), + ].filter( + (el) => + !el.hasAttribute('disabled') && + !el.hasAttribute('aria-disabled') && + !el.getAttribute('aria-hidden'), + ) as HTMLElement[]; +} + +export function getFirstFocusableElement( + dialog: HTMLDialogElement, +): HTMLElement { + const focusableElements = getKeyboardFocusableElements(dialog); + // if there is only one focusable element (the close button), focus it + return focusableElements.length === 1 + ? focusableElements[0] + : focusableElements[1]; +} diff --git a/packages/circuit-ui/components/Modal/createUseModal.spec.tsx b/packages/circuit-ui/components/Modal/createUseModal.spec.tsx new file mode 100644 index 0000000000..7ef53700e9 --- /dev/null +++ b/packages/circuit-ui/components/Modal/createUseModal.spec.tsx @@ -0,0 +1,74 @@ +/** + * Copyright 2021, SumUp Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, it, vi } from 'vitest'; +import type { PropsWithChildren } from 'react'; + +import { renderHook, act } from '../../util/test-utils.js'; + +import { createUseModal } from './createUseModal.js'; +import { ModalContext } from './ModalContext.js'; +import { Modal, type ModalProps } from './Modal.js'; + +const ModalComponent = (props: ModalProps) => ; + +const props = { + onClose: vi.fn(), + open: false, + closeButtonLabel: 'Close', + children: vi.fn(() =>
Modal dialog content
), +}; + +describe('createUseModal', () => { + const useModal = createUseModal(ModalComponent); + + const setModal = vi.fn(); + const removeModal = vi.fn(); + + const wrapper = ({ children }: PropsWithChildren) => ( + + {children} + + ); + + it('should add the modal when setModal is called', () => { + const { result } = renderHook(() => useModal(), { wrapper }); + + act(() => { + result.current.setModal(props); + }); + + const expected = expect.objectContaining({ + component: expect.any(Function), + id: expect.any(String), + }); + expect(setModal).toHaveBeenCalledWith(expected); + }); + + it('should remove the modal when removeModal is called', () => { + const { result } = renderHook(() => useModal(), { wrapper }); + + act(() => { + result.current.setModal(props); + }); + + act(() => { + result.current.removeModal(); + }); + + const expected = expect.any(Object); + expect(removeModal).toHaveBeenCalledWith(expected); + }); +}); diff --git a/packages/circuit-ui/components/Modal/createUseModal.ts b/packages/circuit-ui/components/Modal/createUseModal.ts new file mode 100644 index 0000000000..2a566451f2 --- /dev/null +++ b/packages/circuit-ui/components/Modal/createUseModal.ts @@ -0,0 +1,62 @@ +/** + * Copyright 2021, SumUp Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use client'; + +import { useContext, useCallback, useRef, useId, type ReactNode } from 'react'; + +import { ModalContext, type SetModalArgs } from './ModalContext.js'; +import type { ModalProps } from './Modal.js'; + +export type ModalDialogComponent = ( + props: TProps, +) => ReactNode; + +export function createUseModal( + component: ModalDialogComponent, +) { + return (): { + setModal: (props: SetModalArgs) => void; + removeModal: () => void; + } => { + const id = useId(); + const modalRef = useRef | null>(null); + const context = useContext(ModalContext); + + // biome-ignore lint/correctness/useExhaustiveDependencies: The `component` never changes + const setModal = useCallback( + (props: SetModalArgs): void => { + modalRef.current = props; + context.setModal({ ...props, id, component, open: true }); + }, + [context, id], + ); + + // biome-ignore lint/correctness/useExhaustiveDependencies: The `component` never changes + const removeModal = useCallback((): void => { + if (modalRef.current) { + context.removeModal({ + ...modalRef.current, + id, + component, + open: false, + }); + modalRef.current = null; + } + }, [context, id]); + + return { setModal, removeModal }; + }; +} diff --git a/packages/circuit-ui/components/Modal/index.ts b/packages/circuit-ui/components/Modal/index.ts index f9a6bb6201..3e721d7c1c 100644 --- a/packages/circuit-ui/components/Modal/index.ts +++ b/packages/circuit-ui/components/Modal/index.ts @@ -13,6 +13,9 @@ * limitations under the License. */ -export { useModal } from './Modal.js'; +import { createUseModal } from './createUseModal.js'; +import { Modal } from './Modal.js'; +export { Modal } from './Modal.js'; export type { ModalProps } from './Modal.js'; +export const useModal = createUseModal(Modal); diff --git a/packages/circuit-ui/components/Modal/translations/bg-BG.json b/packages/circuit-ui/components/Modal/translations/bg-BG.json new file mode 100644 index 0000000000..ddf333d07f --- /dev/null +++ b/packages/circuit-ui/components/Modal/translations/bg-BG.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Затвори" +} diff --git a/packages/circuit-ui/components/Modal/translations/cs-CZ.json b/packages/circuit-ui/components/Modal/translations/cs-CZ.json new file mode 100644 index 0000000000..78f6b38afd --- /dev/null +++ b/packages/circuit-ui/components/Modal/translations/cs-CZ.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Zavřít" +} diff --git a/packages/circuit-ui/components/Modal/translations/da-DK.json b/packages/circuit-ui/components/Modal/translations/da-DK.json new file mode 100644 index 0000000000..12c5d6f111 --- /dev/null +++ b/packages/circuit-ui/components/Modal/translations/da-DK.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Luk" +} diff --git a/packages/circuit-ui/components/Modal/translations/de-AT.json b/packages/circuit-ui/components/Modal/translations/de-AT.json new file mode 100644 index 0000000000..b6226360c4 --- /dev/null +++ b/packages/circuit-ui/components/Modal/translations/de-AT.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Schließen" +} diff --git a/packages/circuit-ui/components/Modal/translations/de-CH.json b/packages/circuit-ui/components/Modal/translations/de-CH.json new file mode 100644 index 0000000000..ddcc840f48 --- /dev/null +++ b/packages/circuit-ui/components/Modal/translations/de-CH.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Schliessen" +} diff --git a/packages/circuit-ui/components/Modal/translations/de-DE.json b/packages/circuit-ui/components/Modal/translations/de-DE.json new file mode 100644 index 0000000000..b6226360c4 --- /dev/null +++ b/packages/circuit-ui/components/Modal/translations/de-DE.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Schließen" +} diff --git a/packages/circuit-ui/components/Modal/translations/de-LU.json b/packages/circuit-ui/components/Modal/translations/de-LU.json new file mode 100644 index 0000000000..b6226360c4 --- /dev/null +++ b/packages/circuit-ui/components/Modal/translations/de-LU.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Schließen" +} diff --git a/packages/circuit-ui/components/Modal/translations/el-CY.json b/packages/circuit-ui/components/Modal/translations/el-CY.json new file mode 100644 index 0000000000..33d5e74c1d --- /dev/null +++ b/packages/circuit-ui/components/Modal/translations/el-CY.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Κλείσιμο" +} diff --git a/packages/circuit-ui/components/Modal/translations/el-GR.json b/packages/circuit-ui/components/Modal/translations/el-GR.json new file mode 100644 index 0000000000..33d5e74c1d --- /dev/null +++ b/packages/circuit-ui/components/Modal/translations/el-GR.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Κλείσιμο" +} diff --git a/packages/circuit-ui/components/Modal/translations/en-AU.json b/packages/circuit-ui/components/Modal/translations/en-AU.json new file mode 100644 index 0000000000..5fb33ad8f3 --- /dev/null +++ b/packages/circuit-ui/components/Modal/translations/en-AU.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Close" +} diff --git a/packages/circuit-ui/components/Modal/translations/en-GB.json b/packages/circuit-ui/components/Modal/translations/en-GB.json new file mode 100644 index 0000000000..5fb33ad8f3 --- /dev/null +++ b/packages/circuit-ui/components/Modal/translations/en-GB.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Close" +} diff --git a/packages/circuit-ui/components/Modal/translations/en-IE.json b/packages/circuit-ui/components/Modal/translations/en-IE.json new file mode 100644 index 0000000000..5fb33ad8f3 --- /dev/null +++ b/packages/circuit-ui/components/Modal/translations/en-IE.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Close" +} diff --git a/packages/circuit-ui/components/Modal/translations/en-MT.json b/packages/circuit-ui/components/Modal/translations/en-MT.json new file mode 100644 index 0000000000..5fb33ad8f3 --- /dev/null +++ b/packages/circuit-ui/components/Modal/translations/en-MT.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Close" +} diff --git a/packages/circuit-ui/components/Modal/translations/en-US.json b/packages/circuit-ui/components/Modal/translations/en-US.json new file mode 100644 index 0000000000..5fb33ad8f3 --- /dev/null +++ b/packages/circuit-ui/components/Modal/translations/en-US.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Close" +} diff --git a/packages/circuit-ui/components/Modal/translations/es-CL.json b/packages/circuit-ui/components/Modal/translations/es-CL.json new file mode 100644 index 0000000000..b46dc02916 --- /dev/null +++ b/packages/circuit-ui/components/Modal/translations/es-CL.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Cerrar" +} diff --git a/packages/circuit-ui/components/Modal/translations/es-CO.json b/packages/circuit-ui/components/Modal/translations/es-CO.json new file mode 100644 index 0000000000..b46dc02916 --- /dev/null +++ b/packages/circuit-ui/components/Modal/translations/es-CO.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Cerrar" +} diff --git a/packages/circuit-ui/components/Modal/translations/es-ES.json b/packages/circuit-ui/components/Modal/translations/es-ES.json new file mode 100644 index 0000000000..b46dc02916 --- /dev/null +++ b/packages/circuit-ui/components/Modal/translations/es-ES.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Cerrar" +} diff --git a/packages/circuit-ui/components/Modal/translations/es-MX.json b/packages/circuit-ui/components/Modal/translations/es-MX.json new file mode 100644 index 0000000000..b46dc02916 --- /dev/null +++ b/packages/circuit-ui/components/Modal/translations/es-MX.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Cerrar" +} diff --git a/packages/circuit-ui/components/Modal/translations/es-PE.json b/packages/circuit-ui/components/Modal/translations/es-PE.json new file mode 100644 index 0000000000..b46dc02916 --- /dev/null +++ b/packages/circuit-ui/components/Modal/translations/es-PE.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Cerrar" +} diff --git a/packages/circuit-ui/components/Modal/translations/es-US.json b/packages/circuit-ui/components/Modal/translations/es-US.json new file mode 100644 index 0000000000..b46dc02916 --- /dev/null +++ b/packages/circuit-ui/components/Modal/translations/es-US.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Cerrar" +} diff --git a/packages/circuit-ui/components/Modal/translations/et-EE.json b/packages/circuit-ui/components/Modal/translations/et-EE.json new file mode 100644 index 0000000000..a9d5010617 --- /dev/null +++ b/packages/circuit-ui/components/Modal/translations/et-EE.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Sulge" +} diff --git a/packages/circuit-ui/components/Modal/translations/fi-FI.json b/packages/circuit-ui/components/Modal/translations/fi-FI.json new file mode 100644 index 0000000000..10d8058a44 --- /dev/null +++ b/packages/circuit-ui/components/Modal/translations/fi-FI.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Sulje" +} diff --git a/packages/circuit-ui/components/Modal/translations/fr-BE.json b/packages/circuit-ui/components/Modal/translations/fr-BE.json new file mode 100644 index 0000000000..b071a84d05 --- /dev/null +++ b/packages/circuit-ui/components/Modal/translations/fr-BE.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Fermer" +} diff --git a/packages/circuit-ui/components/Modal/translations/fr-CH.json b/packages/circuit-ui/components/Modal/translations/fr-CH.json new file mode 100644 index 0000000000..b071a84d05 --- /dev/null +++ b/packages/circuit-ui/components/Modal/translations/fr-CH.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Fermer" +} diff --git a/packages/circuit-ui/components/Modal/translations/fr-FR.json b/packages/circuit-ui/components/Modal/translations/fr-FR.json new file mode 100644 index 0000000000..b071a84d05 --- /dev/null +++ b/packages/circuit-ui/components/Modal/translations/fr-FR.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Fermer" +} diff --git a/packages/circuit-ui/components/Modal/translations/fr-LU.json b/packages/circuit-ui/components/Modal/translations/fr-LU.json new file mode 100644 index 0000000000..b071a84d05 --- /dev/null +++ b/packages/circuit-ui/components/Modal/translations/fr-LU.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Fermer" +} diff --git a/packages/circuit-ui/components/Modal/translations/hr-HR.json b/packages/circuit-ui/components/Modal/translations/hr-HR.json new file mode 100644 index 0000000000..58c4e9bbb8 --- /dev/null +++ b/packages/circuit-ui/components/Modal/translations/hr-HR.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Zatvori" +} diff --git a/packages/circuit-ui/components/Modal/translations/hu-HU.json b/packages/circuit-ui/components/Modal/translations/hu-HU.json new file mode 100644 index 0000000000..f1f68c32e6 --- /dev/null +++ b/packages/circuit-ui/components/Modal/translations/hu-HU.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Bezárás" +} diff --git a/packages/circuit-ui/components/Modal/translations/index.ts b/packages/circuit-ui/components/Modal/translations/index.ts new file mode 100644 index 0000000000..d0df2ae94b --- /dev/null +++ b/packages/circuit-ui/components/Modal/translations/index.ts @@ -0,0 +1,26 @@ +/** + * Copyright 2024, SumUp Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { transformModulesToTranslations } from '../../../util/i18n.js'; + +export const translations = transformModulesToTranslations< + typeof import('./en-US.json') +>( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore import.meta.glob is supported by Vite + import.meta.glob('./*.json', { + eager: true, + }), +); diff --git a/packages/circuit-ui/components/Modal/translations/it-CH.json b/packages/circuit-ui/components/Modal/translations/it-CH.json new file mode 100644 index 0000000000..73c3efb158 --- /dev/null +++ b/packages/circuit-ui/components/Modal/translations/it-CH.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Chiudi" +} diff --git a/packages/circuit-ui/components/Modal/translations/it-IT.json b/packages/circuit-ui/components/Modal/translations/it-IT.json new file mode 100644 index 0000000000..73c3efb158 --- /dev/null +++ b/packages/circuit-ui/components/Modal/translations/it-IT.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Chiudi" +} diff --git a/packages/circuit-ui/components/Modal/translations/lt-LT.json b/packages/circuit-ui/components/Modal/translations/lt-LT.json new file mode 100644 index 0000000000..b55cf74468 --- /dev/null +++ b/packages/circuit-ui/components/Modal/translations/lt-LT.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Uždaryti" +} diff --git a/packages/circuit-ui/components/Modal/translations/lv-LV.json b/packages/circuit-ui/components/Modal/translations/lv-LV.json new file mode 100644 index 0000000000..486fc0ee7a --- /dev/null +++ b/packages/circuit-ui/components/Modal/translations/lv-LV.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Aizvērt" +} diff --git a/packages/circuit-ui/components/Modal/translations/nb-NO.json b/packages/circuit-ui/components/Modal/translations/nb-NO.json new file mode 100644 index 0000000000..72f78d9c6a --- /dev/null +++ b/packages/circuit-ui/components/Modal/translations/nb-NO.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Lukk" +} diff --git a/packages/circuit-ui/components/Modal/translations/nl-BE.json b/packages/circuit-ui/components/Modal/translations/nl-BE.json new file mode 100644 index 0000000000..538d49ed54 --- /dev/null +++ b/packages/circuit-ui/components/Modal/translations/nl-BE.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Sluit" +} diff --git a/packages/circuit-ui/components/Modal/translations/nl-NL.json b/packages/circuit-ui/components/Modal/translations/nl-NL.json new file mode 100644 index 0000000000..f2a9127e10 --- /dev/null +++ b/packages/circuit-ui/components/Modal/translations/nl-NL.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Sluiten" +} diff --git a/packages/circuit-ui/components/Modal/translations/pl-PL.json b/packages/circuit-ui/components/Modal/translations/pl-PL.json new file mode 100644 index 0000000000..a1cb3d0112 --- /dev/null +++ b/packages/circuit-ui/components/Modal/translations/pl-PL.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Zamknij" +} diff --git a/packages/circuit-ui/components/Modal/translations/pt-BR.json b/packages/circuit-ui/components/Modal/translations/pt-BR.json new file mode 100644 index 0000000000..12ce2fa58f --- /dev/null +++ b/packages/circuit-ui/components/Modal/translations/pt-BR.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Fechar" +} diff --git a/packages/circuit-ui/components/Modal/translations/pt-PT.json b/packages/circuit-ui/components/Modal/translations/pt-PT.json new file mode 100644 index 0000000000..12ce2fa58f --- /dev/null +++ b/packages/circuit-ui/components/Modal/translations/pt-PT.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Fechar" +} diff --git a/packages/circuit-ui/components/Modal/translations/ro-RO.json b/packages/circuit-ui/components/Modal/translations/ro-RO.json new file mode 100644 index 0000000000..58d8225420 --- /dev/null +++ b/packages/circuit-ui/components/Modal/translations/ro-RO.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Închide" +} diff --git a/packages/circuit-ui/components/Modal/translations/sk-SK.json b/packages/circuit-ui/components/Modal/translations/sk-SK.json new file mode 100644 index 0000000000..73e63be91a --- /dev/null +++ b/packages/circuit-ui/components/Modal/translations/sk-SK.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Zatvoriť" +} diff --git a/packages/circuit-ui/components/Modal/translations/sl-SI.json b/packages/circuit-ui/components/Modal/translations/sl-SI.json new file mode 100644 index 0000000000..364b505566 --- /dev/null +++ b/packages/circuit-ui/components/Modal/translations/sl-SI.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Zapri" +} diff --git a/packages/circuit-ui/components/Modal/translations/sv-SE.json b/packages/circuit-ui/components/Modal/translations/sv-SE.json new file mode 100644 index 0000000000..2d332d9d2a --- /dev/null +++ b/packages/circuit-ui/components/Modal/translations/sv-SE.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Stäng" +} diff --git a/packages/circuit-ui/components/NotificationModal/NotificationModal.mdx b/packages/circuit-ui/components/NotificationModal/NotificationModal.mdx index 3184b435cd..58ac3fe00a 100644 --- a/packages/circuit-ui/components/NotificationModal/NotificationModal.mdx +++ b/packages/circuit-ui/components/NotificationModal/NotificationModal.mdx @@ -1,5 +1,5 @@ -import { Meta, Status, Props, Story } from '../../../../.storybook/components'; -import * as Stories from './NotificationModal.stories'; +import { Meta, Status, Props, Story } from "../../../../.storybook/components"; +import * as Stories from "./NotificationModal.stories"; @@ -7,7 +7,7 @@ import * as Stories from './NotificationModal.stories'; -The notification modal component communicates critical information while blocking everything else on the page, and needs the user's attention or action to proceed. +The notification modal component communicates critical information while blocking everything else on the page, and needs the user's attention or action to proceed. It is built atop the [Modal](Components/Modal/Docs) component. @@ -17,6 +17,87 @@ The notification modal component communicates critical information while blockin - For information that needs a user's immediate attention. - To request confirmation before performing a destructive action. +## How to use it + +### Inline (recommended) + +Place your NotificationModal directly in your JSX: + +```jsx +import { NotificationModal, Button } from "@sumup-oss/circuit-ui"; +import { useState } from "react"; + +const [open, setOpen] = useState(false); + +return ( + <> + + setOpen(false)} + headline="It's time to update your browser" + actions={{ + primary: { + children: "Update now", + onClick: update(), + }, + secondary: { + children: "Not now", + onClick: () => setOpen(false), + }, + }} + > + +); +``` + +### with the `useModal` hook + +First, wrap your application in the `ModalProvider`: + +```tsx +import { ModalProvider } from "@sumup-oss/circuit-ui"; + +export default function App() { + return {/* Your content here... */}; +} +``` + +Then, use the `useNotificationModal` hook to open a NotificationModal from a component: + +```tsx +import { useNotificationModal, Heading, Button } from "@sumup-oss/circuit-ui"; + +export function SayHello({ name }) { + const { setModal, removeModal } = useNotificationModal(); + + const handleClick = () => { + setModal({ + image: { + src: "/images/illustration-update.svg", + alt: "", + }, + headline: "It's time to update your browser", + body: "You'll soon need a more up-to-date browser to continue using SumUp.", + actions: { + primary: { + children: "Update now", + onClick: update(), + }, + secondary: { + children: "Not now", + onClick: removeModal(), + }, + }, + closeButtonLabel: "Close", + }); + }; + + return ; +} +``` + ## Usage guidelines - Use a concise headline to communicate the message. diff --git a/packages/circuit-ui/components/NotificationModal/NotificationModal.module.css b/packages/circuit-ui/components/NotificationModal/NotificationModal.module.css index 7fb794efd8..928e04d691 100644 --- a/packages/circuit-ui/components/NotificationModal/NotificationModal.module.css +++ b/packages/circuit-ui/components/NotificationModal/NotificationModal.module.css @@ -1,19 +1,9 @@ .base { - position: fixed; - top: 50%; - left: 50%; width: calc(100vw - var(--cui-spacings-peta) * 2); + min-width: unset; max-width: 420px; max-height: calc(100vh - var(--cui-spacings-mega) * 2); - padding: var(--cui-spacings-giga); - overflow-y: auto; text-align: center; - background-color: var(--cui-bg-elevated); - border-radius: var(--cui-border-radius-mega); - outline: none; - opacity: 0; - transition: opacity var(--cui-transitions-slow); - transform: translate(-50%, -50%); } @media (max-width: 479px) { @@ -22,50 +12,8 @@ } } -/* Overlay */ - -.overlay { - position: fixed; - top: 0; - right: 0; - bottom: 0; - left: 0; - z-index: var(--cui-z-index-modal); - background: var(--cui-bg-overlay); - opacity: 0; - transition: opacity var(--cui-transitions-slow); -} - -@media (min-width: 480px) { - .overlay { - -webkit-overflow-scrolling: touch; - overflow-y: auto; - } -} - -.open { - opacity: 1; -} - -.closed { - opacity: 0; -} - /* Child elements */ -.base .close { - position: absolute; - top: var(--cui-spacings-byte); - right: var(--cui-spacings-byte); -} - -@media (min-width: 480px) { - .base .close { - top: var(--cui-spacings-mega); - right: var(--cui-spacings-mega); - } -} - .base .image { max-width: 232px; height: 120px; diff --git a/packages/circuit-ui/components/NotificationModal/NotificationModal.spec.tsx b/packages/circuit-ui/components/NotificationModal/NotificationModal.spec.tsx index 19c2c86300..ac684b2fbc 100644 --- a/packages/circuit-ui/components/NotificationModal/NotificationModal.spec.tsx +++ b/packages/circuit-ui/components/NotificationModal/NotificationModal.spec.tsx @@ -13,10 +13,11 @@ * limitations under the License. */ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Plus } from '@sumup-oss/icons'; -import { axe, render, userEvent, screen } from '../../util/test-utils.js'; +import { axe, render, userEvent, screen, act } from '../../util/test-utils.js'; +import { ANIMATION_DURATION } from '../Modal/Modal.js'; import { NotificationModal, @@ -24,11 +25,21 @@ import { } from './NotificationModal.js'; describe('NotificationModal', () => { + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + vi.clearAllMocks(); + }); + const renderNotificationModal = (props: NotificationModalProps) => render(); const baseNotificationModal = { - isOpen: true, + open: true, closeButtonLabel: 'Close modal', onClose: vi.fn(), image: { @@ -47,7 +58,6 @@ describe('NotificationModal', () => { onClick: vi.fn(), }, }, - ariaHideApp: false, } as const; it('should render with an SVG', () => { @@ -57,6 +67,9 @@ describe('NotificationModal', () => { image: { svg: Plus, alt }, }; renderNotificationModal(props); + act(() => { + vi.advanceTimersByTime(ANIMATION_DURATION); + }); const svg = screen.getByRole('img'); @@ -74,6 +87,10 @@ describe('NotificationModal', () => { it('should render the modal', async () => { renderNotificationModal(baseNotificationModal); + act(() => { + vi.advanceTimersByTime(ANIMATION_DURATION); + }); + const modalEl = await screen.findByRole('dialog'); expect(modalEl).toBeVisible(); @@ -82,27 +99,27 @@ describe('NotificationModal', () => { describe('business logic', () => { it('should close the modal when clicking the close button', async () => { renderNotificationModal(baseNotificationModal); + act(() => { + vi.advanceTimersByTime(ANIMATION_DURATION); + }); const closeButton = await screen.findByRole('button', { name: baseNotificationModal.closeButtonLabel, }); await userEvent.click(closeButton); - - expect(baseNotificationModal.onClose).toHaveBeenCalled(); - }); - - it('should close the modal when clicking outside', async () => { - renderNotificationModal(baseNotificationModal); - - await userEvent.click(document.body); + act(() => { + vi.advanceTimersByTime(ANIMATION_DURATION); + }); expect(baseNotificationModal.onClose).toHaveBeenCalled(); }); it('should perform an action and close the modal when clicking an action button', async () => { renderNotificationModal(baseNotificationModal); - + act(() => { + vi.advanceTimersByTime(ANIMATION_DURATION); + }); const actionButton = await screen.findByRole('button', { name: baseNotificationModal.actions.primary.children, }); diff --git a/packages/circuit-ui/components/NotificationModal/NotificationModal.stories.tsx b/packages/circuit-ui/components/NotificationModal/NotificationModal.stories.tsx index ca33e29a6b..66cbfcd7d8 100644 --- a/packages/circuit-ui/components/NotificationModal/NotificationModal.stories.tsx +++ b/packages/circuit-ui/components/NotificationModal/NotificationModal.stories.tsx @@ -16,10 +16,12 @@ import type { Decorator } from '@storybook/react'; import { action } from '@storybook/addon-actions'; import { screen, userEvent, within } from '@storybook/test'; +import { useState } from 'react'; import { FullViewport } from '../../../../.storybook/components/index.js'; -import { ModalProvider } from '../ModalContext/index.js'; +import { ModalProvider } from '../Modal/ModalContext.js'; import { Button } from '../Button/index.js'; +import { modes } from '../../../../.storybook/modes.js'; import { NotificationModal, @@ -31,6 +33,13 @@ export default { title: 'Notification/NotificationModal', component: NotificationModal, tags: ['status:stable'], + chromatic: { + modes: { + mobile: modes.smallMobile, + desktop: modes.desktop, + }, + pauseAnimationAtEnd: true, + }, parameters: { layout: 'padded', }, @@ -43,16 +52,14 @@ export default { ] as Decorator[], }; -export const Base = (modal: NotificationModalProps) => { - const ComponentWithModal = () => { - const { setModal } = useNotificationModal(); +export const Base = (args: Omit) => { + const [open, setOpen] = useState(false); - return ; - }; return ( - - - + <> + + setOpen(false)} /> + ); }; @@ -75,6 +82,51 @@ Base.args = { }, closeButtonLabel: 'Close', }; + +export const WithUseNotificationModal = () => { + const ComponentWithModal = () => { + const { setModal } = useNotificationModal(); + + return ( + + ); + }; + return ( + + + + ); +}; + +WithUseNotificationModal.parameters = { + chromatic: { disableSnapshot: true }, +}; + Base.play = async ({ canvasElement }: { canvasElement: HTMLCanvasElement }) => { const canvas = within(canvasElement); const button = canvas.getByRole('button', { diff --git a/packages/circuit-ui/components/NotificationModal/NotificationModal.tsx b/packages/circuit-ui/components/NotificationModal/NotificationModal.tsx index 9127440b71..837cf3fef7 100644 --- a/packages/circuit-ui/components/NotificationModal/NotificationModal.tsx +++ b/packages/circuit-ui/components/NotificationModal/NotificationModal.tsx @@ -15,64 +15,42 @@ 'use client'; -import type { FC, ReactNode, SVGProps } from 'react'; -import ReactModal from 'react-modal'; +import { type FC, type ReactNode, type SVGProps, useId, useRef } from 'react'; import type { ClickEvent } from '../../types/events.js'; -import type { ModalComponent, BaseModalProps } from '../ModalContext/index.js'; import { Image, type ImageProps } from '../Image/index.js'; import { Headline } from '../Headline/index.js'; import { Body } from '../Body/index.js'; import type { ButtonProps } from '../Button/index.js'; import { ButtonGroup, type ButtonGroupProps } from '../ButtonGroup/index.js'; -import { CloseButton } from '../CloseButton/index.js'; +import { Modal, type ModalProps } from '../Modal/index.js'; +import { clsx } from '../../styles/clsx.js'; import { CircuitError } from '../../util/errors.js'; import classes from './NotificationModal.module.css'; -const TRANSITION_DURATION = 200; - -type PreventCloseProps = - | { - /** - * Text label for the close button for screen readers. - * Important for accessibility. - */ - closeButtonLabel?: never; - /** - * Prevent users from closing the modal by clicking/tapping the overlay or - * pressing the escape key. Default `false`. - */ - preventClose: boolean; - } - | { - closeButtonLabel: string; - preventClose?: never; - }; - -export type NotificationModalProps = BaseModalProps & - PreventCloseProps & { - /** - * An optional image to illustrate the notification. Supports either - * passing an image source to `image.src` or an SVG component to - * `image.svg`. Pass an empty string as alt text if the image is - * [decorative](https://www.w3.org/WAI/tutorials/images/decorative/), - * or a localized description if the image is [informative](https://www.w3.org/WAI/tutorials/images/informative/). - */ - image?: ImageProps | { svg: FC>; alt: string }; - /** - * The notification's headline. - */ - headline: string; - /** - * Optional body copy for notification details. - */ - body?: string | ReactNode; - /** - * Action buttons to allow users to act on the notification. - */ - actions: ButtonGroupProps['actions']; - }; +export type NotificationModalProps = Omit & { + /** + * An optional image to illustrate the notification. Supports either + * passing an image source to `image.src` or an SVG component to + * `image.svg`. Pass an empty string as alt text if the image is + * [decorative](https://www.w3.org/WAI/tutorials/images/decorative/), + * or a localized description if the image is [informative](https://www.w3.org/WAI/tutorials/images/informative/). + */ + image?: ImageProps | { svg: FC>; alt: string }; + /** + * The notification's headline. + */ + headline: string; + /** + * Optional body copy for notification details. + */ + body?: string | ReactNode; + /** + * Action buttons to allow users to act on the notification. + */ + actions: ButtonGroupProps['actions']; +}; function NotificationImage({ image }: Pick) { if (!image) { @@ -96,11 +74,7 @@ function NotificationImage({ image }: Pick) { return ; } -/** - * Circuit UI's wrapper component for ReactModal. - * http://reactcommunity.org/react-modal/accessibility/#aria - */ -export const NotificationModal: ModalComponent = ({ +export const NotificationModal = ({ image, headline, body, @@ -110,7 +84,7 @@ export const NotificationModal: ModalComponent = ({ preventClose = false, className, ...props -}) => { +}: NotificationModalProps) => { if (process.env.NODE_ENV !== 'production' && className) { throw new CircuitError( 'NotificationModal', @@ -118,60 +92,58 @@ export const NotificationModal: ModalComponent = ({ ); } - const reactModalProps = { - className: { - base: classes.base, - afterOpen: classes.open, - beforeClose: classes.closed, - }, - overlayClassName: { - base: classes.overlay, - afterOpen: classes.open, - beforeClose: classes.closed, - }, - onRequestClose: onClose, - closeTimeoutMS: TRANSITION_DURATION, - shouldCloseOnOverlayClick: !preventClose, - shouldCloseOnEsc: !preventClose, + const headlineId = useId(); + const initialFocusRef = useRef(null); + const dialogProps = { + className: clsx(className, classes.base), + closeButtonLabel, + 'aria-labelledby': headlineId, + preventClose, + onClose, + initialFocusRef, ...props, }; function wrapOnClick(onClick?: ButtonProps['onClick']) { return (event: ClickEvent) => { - onClose?.(event); + onClose?.(); onClick?.(event); }; } return ( - - {!preventClose && closeButtonLabel && ( - - {closeButtonLabel} - + + {() => ( + <> + + + {headline} + + {body && {body}} + {actions && ( + + )} + )} - - - {headline} - - {body && {body}} - {actions && ( - - )} - +
); }; - -NotificationModal.TRANSITION_DURATION = TRANSITION_DURATION; diff --git a/packages/circuit-ui/components/NotificationModal/index.ts b/packages/circuit-ui/components/NotificationModal/index.ts index bff7a7de8a..1f830ea54d 100644 --- a/packages/circuit-ui/components/NotificationModal/index.ts +++ b/packages/circuit-ui/components/NotificationModal/index.ts @@ -16,3 +16,4 @@ export { useNotificationModal } from './useNotificationModal.js'; export type { NotificationModalProps } from './NotificationModal.js'; +export { NotificationModal } from './NotificationModal.js'; diff --git a/packages/circuit-ui/components/NotificationModal/useNotificationModal.ts b/packages/circuit-ui/components/NotificationModal/useNotificationModal.ts index cc7ab32477..f702e137ed 100644 --- a/packages/circuit-ui/components/NotificationModal/useNotificationModal.ts +++ b/packages/circuit-ui/components/NotificationModal/useNotificationModal.ts @@ -13,8 +13,12 @@ * limitations under the License. */ -import { createUseModal } from '../ModalContext/index.js'; +import { createUseModal } from '../Modal/createUseModal.js'; -import { NotificationModal } from './NotificationModal.js'; +import { + NotificationModal, + type NotificationModalProps, +} from './NotificationModal.js'; -export const useNotificationModal = createUseModal(NotificationModal); +export const useNotificationModal = + createUseModal(NotificationModal); diff --git a/packages/circuit-ui/hooks/useScrollLock/useScrollLock.mdx b/packages/circuit-ui/hooks/useScrollLock/useScrollLock.mdx new file mode 100644 index 0000000000..2f5e67bb31 --- /dev/null +++ b/packages/circuit-ui/hooks/useScrollLock/useScrollLock.mdx @@ -0,0 +1,28 @@ +import { Meta, Status, Story } from "../../../../.storybook/components"; +import * as Stories from "./useScrollLock.stories"; + + + +# useScrollLock() + + + +Disables scrolling on the body element when the given argument is true. + +
+ +
+ +```ts +function useScrollLock(isLocked: boolean): void; +``` + +## Usage + +Use this hook to prevent scrolling on the body element when a modal or other overlay is open. +This pattern prevents users from scrolling the background content while interacting with a modal or other overlay. diff --git a/packages/circuit-ui/hooks/useScrollLock/useScrollLock.spec.ts b/packages/circuit-ui/hooks/useScrollLock/useScrollLock.spec.ts new file mode 100644 index 0000000000..2a9733a97e --- /dev/null +++ b/packages/circuit-ui/hooks/useScrollLock/useScrollLock.spec.ts @@ -0,0 +1,76 @@ +/** + * Copyright 2024, SumUp Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { renderHook } from '../../util/test-utils.js'; + +import { useScrollLock } from './useScrollLock.js'; + +describe('useScrollLock', () => { + Object.defineProperty(window, 'scrollTo', { + value: vi.fn(), + writable: true, + }); + + Object.defineProperty(window, 'scrollY', { value: 1, writable: true }); + + beforeEach(() => { + document.body.style.position = ''; + document.body.style.top = ''; + window.scrollY = 1; + }); + + it('locks the scroll when `isLocked` is true', () => { + window.scrollY = 100; + const { rerender } = renderHook(({ isLocked }) => useScrollLock(isLocked), { + initialProps: { isLocked: false }, + }); + + rerender({ isLocked: true }); + + expect(document.body.style.position).toBe('fixed'); + expect(document.body.style.top).toBe('-100px'); + }); + + it('unlocks the scroll when `isLocked` is false', () => { + window.scrollY = 100; + + const { rerender } = renderHook(({ isLocked }) => useScrollLock(isLocked), { + initialProps: { isLocked: true }, + }); + + rerender({ isLocked: false }); + + expect(document.body.style.position).toBe(''); + expect(document.body.style.top).toBe(''); + expect(window.scrollTo).toHaveBeenCalledWith(0, 100); + }); + + it('unlocks the scroll when unmounted', () => { + window.scrollY = 100; + + const { unmount } = renderHook(() => useScrollLock(true), { + initialProps: { isLocked: true }, + }); + expect(document.body.style.position).toBe('fixed'); + + unmount(); + + expect(document.body.style.position).toBe(''); + expect(document.body.style.top).toBe(''); + expect(window.scrollTo).toHaveBeenCalledWith(0, 100); + }); +}); diff --git a/packages/circuit-ui/hooks/useScrollLock/useScrollLock.stories.tsx b/packages/circuit-ui/hooks/useScrollLock/useScrollLock.stories.tsx new file mode 100644 index 0000000000..edc7ec5015 --- /dev/null +++ b/packages/circuit-ui/hooks/useScrollLock/useScrollLock.stories.tsx @@ -0,0 +1,61 @@ +/** + * Copyright 2024, SumUp Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useState } from 'react'; + +import { Button } from '../../components/Button/index.js'; +import { Stack } from '../../../../.storybook/components/index.js'; + +import { useScrollLock } from './useScrollLock.js'; + +export default { + title: 'Hooks/useScrollLock', + parameters: { + chromatic: { + layout: 'padded', + disableSnapshot: true, + }, + }, + tags: ['status:stable'], +}; + +export const Base = ({ height = '150vh' }) => { + const [disableScroll, setDisableScroll] = useState(false); + + const toggleScroll = () => { + setDisableScroll((prev) => !prev); + }; + useScrollLock(disableScroll); + + return ( +
+ + + +
+ ); +}; + +export const ForDocs = () => Base({ height: 'unset' }); +ForDocs.tags = ['!dev']; // hide story, used for docs only. diff --git a/packages/circuit-ui/hooks/useScrollLock/useScrollLock.ts b/packages/circuit-ui/hooks/useScrollLock/useScrollLock.ts new file mode 100644 index 0000000000..003c087178 --- /dev/null +++ b/packages/circuit-ui/hooks/useScrollLock/useScrollLock.ts @@ -0,0 +1,49 @@ +/** + * Copyright 2024, SumUp Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useCallback, useEffect, useRef } from 'react'; + +export const useScrollLock = (isLocked: boolean): void => { + const scrollValue = useRef(); + + const restoreScroll = useCallback(() => { + // restore scroll to page + const { body } = document; + const scrollY = body.style.top; + body.style.position = ''; + body.style.top = ''; + body.style.width = ''; + window.scrollTo(0, Number.parseInt(scrollY || '0', 10) * -1); + }, []); + useEffect(() => { + if (isLocked) { + scrollValue.current = `${window.scrollY}px`; + const scrollY = scrollValue.current; + const { body } = document; + const bodyWidth = body.offsetWidth; + // when position is set to fixed, the body element is taken out of + // the normal document flow and this may cause it to change size. + // To prevent this, we set the width of the body to its current width. + body.style.position = 'fixed'; + body.style.width = `${bodyWidth}px`; + body.style.top = `-${scrollY}`; + } else { + restoreScroll(); + } + return () => { + restoreScroll(); + }; + }, [isLocked, restoreScroll]); +}; diff --git a/packages/circuit-ui/index.ts b/packages/circuit-ui/index.ts index f654e1ab4a..738a98fa4d 100644 --- a/packages/circuit-ui/index.ts +++ b/packages/circuit-ui/index.ts @@ -155,12 +155,14 @@ export type { PopoverProps, PopoverItemProps, } from './components/Popover/index.js'; -export { ModalProvider } from './components/ModalContext/index.js'; -export type { ModalProviderProps } from './components/ModalContext/index.js'; +export { ModalProvider } from './components/Modal/ModalContext.js'; +export type { ModalProviderProps } from './components/Modal/ModalContext.js'; export { useModal } from './components/Modal/index.js'; export type { ModalProps } from './components/Modal/index.js'; +export { Modal } from './components/Modal/index.js'; export { useNotificationModal } from './components/NotificationModal/index.js'; export type { NotificationModalProps } from './components/NotificationModal/index.js'; +export { NotificationModal } from './components/NotificationModal/index.js'; export { ListItem } from './components/ListItem/index.js'; export type { ListItemProps } from './components/ListItem/index.js'; export { ListItemGroup } from './components/ListItemGroup/index.js'; @@ -220,3 +222,4 @@ export { useFocusList } from './hooks/useFocusList/index.js'; export { useCollapsible } from './hooks/useCollapsible/index.js'; export { useSwipe } from './hooks/useSwipe/index.js'; export { useMedia } from './hooks/useMedia/index.js'; +export { useScrollLock } from './hooks/useScrollLock/useScrollLock.js'; diff --git a/packages/circuit-ui/package.json b/packages/circuit-ui/package.json index 5ec712887c..bb8d8ac0a3 100644 --- a/packages/circuit-ui/package.json +++ b/packages/circuit-ui/package.json @@ -97,6 +97,7 @@ "temporal-polyfill": "0.2.x" }, "engines": { - "node": ">=20" + "node": ">=20", + "typescript": ">=4.1" } } diff --git a/packages/design-tokens/scripts/build.ts b/packages/design-tokens/scripts/build.ts index a1dcdc8d8c..3ea2f6afea 100755 --- a/packages/design-tokens/scripts/build.ts +++ b/packages/design-tokens/scripts/build.ts @@ -48,7 +48,7 @@ function main(): void { { type: 'tokens', tokens: [...light, ...shared], - selectors: [':root'], + selectors: [':root, ::backdrop'], colorScheme: 'light', }, ], @@ -56,7 +56,7 @@ function main(): void { { type: 'tokens', tokens: [...dark, ...shared], - selectors: [':root'], + selectors: [':root, ::backdrop'], colorScheme: 'dark', }, ], @@ -80,13 +80,13 @@ function main(): void { { type: 'tokens', tokens: [...light, ...shared], - selectors: [':root'], + selectors: [':root, ::backdrop'], colorScheme: 'light', }, { type: 'tokens', tokens: dark, - selectors: ['@media (prefers-color-scheme: dark)', ':root'], + selectors: ['@media (prefers-color-scheme: dark)', ':root, ::backdrop'], colorScheme: 'dark', }, {