Skip to content

Commit

Permalink
Modal Component (#2795)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
sirineJ and connor-baer authored Jan 6, 2025
1 parent 673650a commit 48cc697
Show file tree
Hide file tree
Showing 80 changed files with 2,034 additions and 592 deletions.
5 changes: 5 additions & 0 deletions .changeset/cyan-knives-drop.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/eight-beers-think.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sumup-oss/circuit-ui": minor
---

Refactored the NotificationModal component to use the new Modal component under the hood.
5 changes: 5 additions & 0 deletions .changeset/gold-lemons-report.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/grumpy-wombats-talk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sumup-oss/circuit-ui": minor
---

Added a new hook `useScrollLock` to disable page scroll on demand.
5 changes: 5 additions & 0 deletions .changeset/rich-icons-pretend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sumup-oss/circuit-ui": minor
---

Deprecated the `hideCloseButton` prop in the Modal and NotificationModal components. It had no effect.
5 changes: 5 additions & 0 deletions .changeset/sharp-seals-leave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sumup-oss/circuit-ui": minor
---

Added default translations for the Modal and NotificationModal components. The `closeButtonLabel` prop is now optional.
5 changes: 5 additions & 0 deletions .changeset/wicked-pants-cough.md
Original file line number Diff line number Diff line change
@@ -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).
83 changes: 79 additions & 4 deletions .github/workflows/cr.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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"
3 changes: 2 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/circuit-ui/components/DateInput/DateInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ export const DateInput = forwardRef<HTMLInputElement, DateInputProps>(
const { floatingStyles, update } = useFloating({
open,
placement,
strategy: 'fixed',
middleware: [
offset(4),
flip({ padding, fallbackAxisSideDirection: 'start' }),
Expand Down
92 changes: 75 additions & 17 deletions packages/circuit-ui/components/Modal/Modal.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,58 +5,116 @@ import * as Stories from './Modal.stories';

# Modal

<Status variant="under-review" />
<Status variant="stable" />

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.

<Story of={Stories.Base} />
<Props />

## 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:

<Story of={Stories.Variants} />
- 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 (
<>
<Button onClick={() => setOpen(true)}>Open dialog</Button>
<Modal
open={open}
aria-labelledby="dialog-title"
onClose={() => setOpen(false)}
>
{() => (
<>
<Heading as="h2" id="dialog-title">
Modal title
</Heading>
<Body>Modal content</Body>
<Button>Close dialog</Button>
</>
)}
</Modal>
</>
);
}
```

## 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 <ModalProvider>{/* Your content here... */}</ModalProvider>;
}
```

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: <Body>Hello {name}</Body>,
children: (
<>
<Heading as="h2" id="dialog-title">
Modal title
</Heading>
<Body>Modal content</Body>
<Button>Close dialog</Button>
</>
),
'aria-labelledby': 'dialog-title',
variant: 'immersive',
closeButtonLabel: 'Close modal',
});
};

return <Button onClick={handleClick}>Say hello</Button>;
}
```

## 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.

<Story of={Stories.Immersive} />

## 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.
Loading

0 comments on commit 48cc697

Please sign in to comment.