diff --git a/packages/css/src/dialog/README.md b/packages/css/src/dialog/README.md new file mode 100644 index 0000000000..7b0ee41d51 --- /dev/null +++ b/packages/css/src/dialog/README.md @@ -0,0 +1,23 @@ +# Dialog + +A Dialog allows the user to focus on one task or a piece of information, by popping-up and blocking the page content until the modal task is completed, or until the user dismisses the action. + +## Guidelines + +- Use Dialog sparingly, because it interrupts the user's workflow. +- Use Dialog for short and non-frequent tasks. For common tasks consider using the main flow. + +## Keyboard Support + +| key | function | +| :---------- | :----------------------------------------------------------- | +| Tab | Moves focus to next focusable element inside the dialog. | +| Shift + Tab | Moves focus to previous focusable element inside the dialog. | +| Escape | Closes the dialog. | + +## References + +- [HTMLDialogElement](https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement) +- [Return value](https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/returnValue) +- [Modal & Nonmodal Dialogs: When (& When Not) to Use Them](https://www.nngroup.com/articles/modal-nonmodal-dialog/) +- [Patterns](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/) diff --git a/packages/css/src/dialog/dialog.scss b/packages/css/src/dialog/dialog.scss new file mode 100644 index 0000000000..7259222b25 --- /dev/null +++ b/packages/css/src/dialog/dialog.scss @@ -0,0 +1,92 @@ +/** + * @license EUPL-1.2+ + * Copyright (c) 2023 Gemeente Amsterdam + */ + +@import "../../utils/breakpoint"; + +.amsterdam-dialog { + background-color: var(--amsterdam-dialog-background-color); + border: var(--amsterdam-dialog-border); + inset: 0; + max-inline-size: var(--amsterdam-dialog-max-inline-size); + padding-block: 0; + padding-inline: 0; + position: fixed; + + /* no token because dialog does not inherit from any element */ + &::backdrop { + background: #0006; + } +} + +.amsterdam-dialog__form { + display: grid; + gap: var(--amsterdam-dialog-form-gap); + grid-template-rows: auto 1fr auto; + max-block-size: var(--amsterdam-dialog-form-max-block-size); + padding-block: var(--amsterdam-dialog-form-padding-block); + padding-inline: var(--amsterdam-dialog-form-padding-inline); +} + +.amsterdam-dialog__article { + display: grid; + gap: 1.5rem; /* Until we have a consistent way of spacing text elements */ + max-block-size: 100%; /* safari */ + overflow-y: auto; + overscroll-behavior-y: contain; + padding-inline-end: var(--amsterdam-dialog-article-padding-inline-end); +} + +.amsterdam-dialog__header { + align-items: flex-start; + display: flex; + gap: var(--amsterdam-dialog-header-gap); +} + +@mixin reset { + -webkit-text-size-adjust: 100%; +} + +.amsterdam-dialog__title { + color: var(--amsterdam-dialog-title-color); + flex: auto; + font-family: var(--amsterdam-dialog-title-font-family); + font-size: var(--amsterdam-dialog-title-narrow-font-size); + font-weight: var(--amsterdam-dialog-title-font-weight); + line-height: var(--amsterdam-dialog-title-line-height); + + @media screen and (min-width: $amsterdam-breakpoint-typography) { + font-size: var(--amsterdam-dialog-title-wide-font-size); + } + + @include reset; +} + +.amsterdam-dialog__footer { + display: flex; + flex-direction: column; + grid-gap: var(--amsterdam-dialog-footer-gap); + padding-block: var(--amsterdam-dialog-footer-padding-block); + + @media screen and (min-width: $amsterdam-breakpoint-medium) { + flex-direction: row; + justify-content: end; + } +} + +.amsterdam-dialog__close { + background-color: var(--amsterdam-dialog-close-background-color); + border: 0; + cursor: pointer; + padding-block: 0; + padding-inline: 0; +} + +.amsterdam-dialog__close svg { + fill: var(--amsterdam-dialog-close-fill); +} + +.amsterdam-dialog__close:hover svg { + fill: var(--amsterdam-dialog-close-hover-fill); +} diff --git a/packages/css/src/index.scss b/packages/css/src/index.scss index f2a4aa7fd4..7d6321e474 100644 --- a/packages/css/src/index.scss +++ b/packages/css/src/index.scss @@ -4,6 +4,7 @@ */ /* Append here */ +@import "./dialog/dialog"; @import "./image/image"; @import "./pagination/pagination"; @import "./accordion/accordion"; diff --git a/packages/react/src/Dialog/Dialog.test.tsx b/packages/react/src/Dialog/Dialog.test.tsx new file mode 100644 index 0000000000..fdd20d4d87 --- /dev/null +++ b/packages/react/src/Dialog/Dialog.test.tsx @@ -0,0 +1,94 @@ +import { render, screen } from '@testing-library/react' +import { createRef } from 'react' +import { Dialog } from './Dialog' +import '@testing-library/jest-dom' + +describe('Dialog', () => { + it('renders', () => { + render() + + const component = screen.getByRole('dialog') + + expect(component).toBeInTheDocument() + expect(component).toBeVisible() + }) + + it('renders a design system BEM class name', () => { + render() + + const component = screen.getByRole('dialog', { hidden: true }) + + expect(component).toHaveClass('amsterdam-dialog') + }) + + it('renders an additional class name', () => { + render() + + const component = screen.getByRole('dialog', { hidden: true }) + + expect(component).toHaveClass('extra') + + expect(component).toHaveClass('amsterdam-dialog') + }) + + it('supports ForwardRef in React', () => { + const ref = createRef() + + render() + + const component = screen.getByRole('dialog', { hidden: true }) + + expect(ref.current).toBe(component) + }) + + it('is not visible when open attribute is not used', () => { + render() + + const component = screen.getByRole('dialog', { hidden: true }) + + expect(component).toBeInTheDocument() + expect(component).not.toBeVisible() + }) + + it('renders a title', () => { + const { getByText } = render() + + expect(getByText('Dialog Title')).toBeInTheDocument() + }) + + it('renders children', () => { + const { getByText } = render(Dialog Content) + + expect(getByText('Dialog Content')).toBeInTheDocument() + }) + + it('renders actions when provided', () => { + const { getByText } = render(Click Me} />) + + expect(getByText('Click Me')).toBeInTheDocument() + }) + + it('does not render actions when not provided', () => { + const { queryByText } = render() + + expect(queryByText('Click Me')).not.toBeInTheDocument() + }) + + it('renders DialogClose button', () => { + render() + + const closeButton = screen.getByText('Sluiten') + + expect(closeButton).toBeInTheDocument() + }) + + it.skip('can be closed with the Close button', () => { + // We currently can't test this because dialog isn't properly supported in jsdom + // https://github.com/jsdom/jsdom/issues/3294 + }) + + it.skip('has no accessible content when it is closed', () => { + // We currently can't test this because dialog isn't properly supported in jsdom + // https://github.com/jsdom/jsdom/issues/3294 + }) +}) diff --git a/packages/react/src/Dialog/Dialog.tsx b/packages/react/src/Dialog/Dialog.tsx new file mode 100644 index 0000000000..609e6070f8 --- /dev/null +++ b/packages/react/src/Dialog/Dialog.tsx @@ -0,0 +1,40 @@ +/** + * @license EUPL-1.2+ + * Copyright (c) 2023 Gemeente Amsterdam + */ + +import { CloseIcon } from '@amsterdam/design-system-react-icons' +import clsx from 'clsx' +import { DialogHTMLAttributes, ForwardedRef, forwardRef, PropsWithChildren, ReactNode } from 'react' +import { Icon } from '../Icon' +import { VisuallyHidden } from '../VisuallyHidden' + +export interface DialogProps extends PropsWithChildren> { + actions?: ReactNode +} + +// TODO: Replace with ActionButton +const DialogClose = forwardRef(({ ...restProps }, ref: ForwardedRef) => ( + +)) + +export const Dialog = forwardRef( + ({ children, className, title, actions, ...restProps }: DialogProps, ref: ForwardedRef) => ( + +
+
+ {title} + +
+
{children}
+ {actions &&
{actions}
} +
+
+ ), +) + +Dialog.displayName = 'Dialog' +DialogClose.displayName = 'DialogClose' diff --git a/packages/react/src/Dialog/README.md b/packages/react/src/Dialog/README.md new file mode 100644 index 0000000000..79439513a4 --- /dev/null +++ b/packages/react/src/Dialog/README.md @@ -0,0 +1,3 @@ +# React Modal component + +[Modal documentation](../../../css/src/modal/README.md) diff --git a/packages/react/src/Dialog/index.ts b/packages/react/src/Dialog/index.ts new file mode 100644 index 0000000000..a53afb069f --- /dev/null +++ b/packages/react/src/Dialog/index.ts @@ -0,0 +1,2 @@ +export { Dialog } from './Dialog' +export type { DialogProps } from './Dialog' diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 9147a66b5d..87bf1cb9f0 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -4,6 +4,7 @@ */ /* Append here */ +export * from './Dialog' export * from './Image' export * from './Pagination' export * from './Screen' diff --git a/proprietary/tokens/src/components/amsterdam/dialog.tokens.json b/proprietary/tokens/src/components/amsterdam/dialog.tokens.json new file mode 100644 index 0000000000..e8c911310a --- /dev/null +++ b/proprietary/tokens/src/components/amsterdam/dialog.tokens.json @@ -0,0 +1,47 @@ +{ + "amsterdam": { + "dialog": { + "background-color": { "value": "{amsterdam.color.primary-white}" }, + "border": { "value": "0" }, + "max-inline-size": { "value": "min(87.69vw, 45rem)" }, + "title": { + "color": { "value": "{amsterdam.color.primary-black}" }, + "font-family": { "value": "{amsterdam.typography.font-family}" }, + "font-weight": { "value": "{amsterdam.typography.font-weight.bold}" }, + "line-height": { "value": "{amsterdam.typography.text-level.5.line-height}" }, + "narrow": { + "font-size": { "value": "{amsterdam.typography.text-level.5.narrow.font-size}" } + }, + "wide": { + "font-size": { "value": "{amsterdam.typography.text-level.5.wide.font-size}" } + } + }, + "backdrop": { + "background": { "value": "#0006" } + }, + "close": { + "background-color": { "value": "transparent" }, + "fill": { "value": "{amsterdam.color.primary-black}" }, + "hover": { + "fill": { "value": "{amsterdam.color.primary-blue}" } + } + }, + "form": { + "gap": { "value": "1.5rem" }, + "padding-block": { "value": "clamp(1.5rem, calc(1.5rem + ((1vw - 0.5337rem) * 2.1448)), 2.5rem)" }, + "padding-inline": { "value": "clamp(1.5rem, calc(1.5rem + ((1vw - 0.5337rem) * 2.1448)), 2.5rem)" }, + "max-block-size": { "value": "75vh" } + }, + "article": { + "padding-inline-end": { "value": "1.5rem" } + }, + "header": { + "gap": { "value": "1rem" } + }, + "footer": { + "gap": { "value": "1rem" }, + "padding-block": { "value": "1.5rem 0" } + } + } + } +} diff --git a/storybook/storybook-react/config/preview-body.html b/storybook/storybook-react/config/preview-body.html index 857f4ea6c7..7ed8c4249f 100644 --- a/storybook/storybook-react/config/preview-body.html +++ b/storybook/storybook-react/config/preview-body.html @@ -36,4 +36,10 @@ margin: -16px; /* stylelint-disable-line */ padding: 16px; /* stylelint-disable-line */ } + + /* Workaround to mimic dark background for dialog Canvas blocks */ + .amsterdam-dialog-story { + background-color: #333 !important; + transition: background-color 0.3s; + } diff --git a/storybook/storybook-react/src/Dialog/Dialog.docs.mdx b/storybook/storybook-react/src/Dialog/Dialog.docs.mdx new file mode 100644 index 0000000000..e66babdd53 --- /dev/null +++ b/storybook/storybook-react/src/Dialog/Dialog.docs.mdx @@ -0,0 +1,19 @@ +import { Canvas, Controls, Markdown, Meta, Primary } from "@storybook/blocks"; +import * as DialogStories from "./Dialog.stories.tsx"; +import README from "../../../../packages/css/src/dialog/README.md?raw"; + + + +{README} + + + + + +## Scroll Content + + + +## Open Dialog Button + + diff --git a/storybook/storybook-react/src/Dialog/Dialog.stories.tsx b/storybook/storybook-react/src/Dialog/Dialog.stories.tsx new file mode 100644 index 0000000000..a60169c355 --- /dev/null +++ b/storybook/storybook-react/src/Dialog/Dialog.stories.tsx @@ -0,0 +1,121 @@ +/** + * @license EUPL-1.2+ + * Copyright (c) 2023 Gemeente Amsterdam + */ + +import { Button, Dialog, Heading, Paragraph } from '@amsterdam/design-system-react' +import { Meta, StoryObj } from '@storybook/react' + +const meta = { + title: 'Containers/Dialog', + component: Dialog, + args: { + title: 'Later verder gaan', + children: ( + + U kunt de ingevulde antwoorden opslaan in onze beveiligde database, deze kunt u later openen om verder te gaan + met invullen. Nadat u op opslaan heeft geklikt ontvangt u een mail. Met de link in deze mail kunt verder gaan + met het formulier. + + ), + actions: ( + <> + + + + ), + }, + parameters: { + backgrounds: { + default: 'dark', + }, + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Default: Story = { + args: { + open: true, + }, + render: (args) => ( +
+ +
+ ), +} + +export const ScrollContent: Story = { + args: { + open: true, + title: 'Privacyverklaring gemeente Amsterdam', + children: ( + <> + Algemeen + + De gemeente Amsterdam verwerkt bij de uitvoering van haar taken en verplichtingen persoonsgegevens. De manier + waarop de gemeente Amsterdam om gaat met persoonsgegevens is vastgelegd in het stedelijk kader verwerken + persoonsgegevens. + + + Deze verklaring geeft aanvullende informatie over de omgang met persoonsgegevens door de gemeente Amsterdam en + over uw mogelijkheden tot het uitoefenen van uw rechten met betrekking tot persoonsgegevens. + + + Meer specifieke informatie over privacy en de verwerking van persoonsgegevens door de gemeente Amsterdam kunt + u op de hoofdpagina vinden. + + + Vanwege nieuwe wetgeving of andere ontwikkelingen, past de gemeente regelmatig haar processen aan. Dit kunnen + ook wijzigingen zijn in de wijze van het verwerken van persoonsgegevens. Wij raden u daarom aan om regelmatig + deze pagina te bekijken. Deze pagina wordt doorlopend geactualiseerd. + + Geldende wet- en regelgeving en reikwijdte + + Vanaf 25 mei 2018 is de Algemene verordening gegevensbescherming (Avg) van toepassing op alle verwerkingen van + persoonsgegevens. Deze Europese wetgeving heeft directe werking in Nederland. Voor die zaken die nationaal + geregeld moeten worden, is de Uitvoeringswet Avg in Nederland aanvullend van toepassing. Deze wetteksten kunt + u vinden op de website van Autoriteit Persoonsgegevens. + + + ), + }, + render: (args) => ( +
+ +
+ ), +} + +export const ShowDialog: Story = { + args: { + id: 'showdialog', + actions: ( + <> + + + + ), + }, + decorators: [ + (Story) => ( +
+ + +
+ ), + ], + parameters: { + backgrounds: { + disable: true, + }, + }, +}