From 1002d8717c7b26772ab76b63a229d4b84fe7ca1b Mon Sep 17 00:00:00 2001 From: Eirik Backer Date: Wed, 25 Sep 2024 13:06:13 +0200 Subject: [PATCH] fix(Modal): api alignment (#2440) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **Question for review:** Should `Modal.Close` be a compound component? 🤔 - Fixes #2063 - Fixes #1177 --------- Co-authored-by: barsnes --- .changeset/hip-schools-greet.md | 14 + .../_components/src/ColorModal/ColorModal.tsx | 13 +- .../TokenModal/TokenModal.module.css | 11 +- .../components/TokenModal/TokenModal.tsx | 167 ++++++------ packages/css/modal.css | 129 ++++----- packages/react/src/components/Modal/Modal.mdx | 43 ++- .../src/components/Modal/Modal.stories.tsx | 257 +++++++++--------- .../react/src/components/Modal/Modal.test.tsx | 63 +---- packages/react/src/components/Modal/Modal.tsx | 97 +++++++ .../src/components/Modal/ModalContext.tsx | 16 ++ .../src/components/Modal/ModalDialog.tsx | 123 --------- .../src/components/Modal/ModalFooter.tsx | 12 +- .../src/components/Modal/ModalHeader.tsx | 64 +---- .../react/src/components/Modal/ModalRoot.tsx | 47 ---- .../src/components/Modal/ModalTrigger.tsx | 14 +- packages/react/src/components/Modal/index.ts | 54 +--- .../src/components/Modal/useModalState.ts | 24 -- .../src/components/Modal/useScrollLock.ts | 24 -- .../form/Combobox/Combobox.stories.tsx | 62 ++--- test/vitest.setup.ts | 9 + 20 files changed, 495 insertions(+), 748 deletions(-) create mode 100644 .changeset/hip-schools-greet.md create mode 100644 packages/react/src/components/Modal/Modal.tsx create mode 100644 packages/react/src/components/Modal/ModalContext.tsx delete mode 100644 packages/react/src/components/Modal/ModalDialog.tsx delete mode 100644 packages/react/src/components/Modal/ModalRoot.tsx delete mode 100644 packages/react/src/components/Modal/useModalState.ts delete mode 100644 packages/react/src/components/Modal/useScrollLock.ts diff --git a/.changeset/hip-schools-greet.md b/.changeset/hip-schools-greet.md new file mode 100644 index 0000000000..80b1ead410 --- /dev/null +++ b/.changeset/hip-schools-greet.md @@ -0,0 +1,14 @@ +--- +"@digdir/designsystemet-css": patch +"@digdir/designsystemet-react": patch +--- + +Modal: +- Rename `Modal.Dialog` to `Modal` +- Rename `Modal.Root` to `Modal.Context` +- Replace `onInteractOutside` event with `backdropClose` boolean +- Replace `closeButton` and `closeButtonTitle` on `Modal.Header` with `closeButton` on `Modal` +- Add border to `Modal.Header` and `Modal.Footer` +- Remove `Modal.Content` +- Remove `onBeforeClose` +- Remove `subtitle` from `Modal.Header` diff --git a/apps/_components/src/ColorModal/ColorModal.tsx b/apps/_components/src/ColorModal/ColorModal.tsx index 07d6fa70f1..da7bcce160 100644 --- a/apps/_components/src/ColorModal/ColorModal.tsx +++ b/apps/_components/src/ColorModal/ColorModal.tsx @@ -52,18 +52,17 @@ export const ColorModal = ({ weight, }: ColorModalProps) => { return ( - - + colorModalRef.current?.close()} > {`${capitalizeFirstLetter(namespace)} ${capitalizeFirstLetter(getColorNameFromNumber(weight))}`} - +
{getColorDescription({ weight, @@ -121,8 +120,8 @@ export const ColorModal = ({ */} - - - +
+ + ); }; diff --git a/apps/theme/components/TokenModal/TokenModal.module.css b/apps/theme/components/TokenModal/TokenModal.module.css index 4b35cede37..4e48084efe 100644 --- a/apps/theme/components/TokenModal/TokenModal.module.css +++ b/apps/theme/components/TokenModal/TokenModal.module.css @@ -22,10 +22,6 @@ padding-right: 0; } -.modalContent { - padding-bottom: 24px; -} - .tabs { margin-bottom: 16px; } @@ -34,7 +30,7 @@ margin-bottom: 16px; } -.modalHeader h2 { +.modalHeader { display: flex; align-items: center; justify-content: space-between; @@ -55,11 +51,6 @@ font-weight: 500; } -.modalHeader > button { - right: 22px; - top: 22px; -} - .hiddenGlobalBtn { position: absolute; left: 0; diff --git a/apps/theme/components/TokenModal/TokenModal.tsx b/apps/theme/components/TokenModal/TokenModal.tsx index f3dc6edcbf..0f07e785cc 100644 --- a/apps/theme/components/TokenModal/TokenModal.tsx +++ b/apps/theme/components/TokenModal/TokenModal.tsx @@ -69,7 +69,7 @@ export const TokenModal = ({ }, []); return ( - + { return modalRef.current?.showModal(); @@ -77,99 +77,94 @@ export const TokenModal = ({ > Ta i bruk tema - modalRef.current?.close()} - style={{ - maxWidth: '1400px', - }} + - + Kopier fargetema - - - - Velg et av alternativene under for å ta i bruk design-tokens med - ditt tema. - - - Alt 1. Design tokens - - { - const value = e.currentTarget.value - .replace(/\s+/g, '-') - .replace(/[^A-Z0-9-]+/gi, '') - .toLowerCase(); + + + Velg et av alternativene under for å ta i bruk design-tokens med ditt + tema. + + + Alt 1. Design tokens + + { + const value = e.currentTarget.value + .replace(/\s+/g, '-') + .replace(/[^A-Z0-9-]+/gi, '') + .toLowerCase(); - setThemeName(value); - }} - style={{ marginBlock: 'var(--ds-spacing-4)' }} - > - - Kopier kommandosnutten under og kjør på maskinen din for å generere - alle design tokens (json-filer). Sørg for at du har{' '} - - Node.js (åpnes i ny fane) - {' '} - installert på maskinen din. - -
+ + Kopier kommandosnutten under og kjør på maskinen din for å generere + alle design tokens (json-filer). Sørg for at du har{' '} + + Node.js (åpnes i ny fane) + {' '} + installert på maskinen din. + +
+ {cliSnippet} +
+ + Alt 2. Figma plugin + + + JSON for bruk med Designsystemet{' '} + - {cliSnippet} -
- - Alt 2. Figma plugin - - - JSON for bruk med Designsystemet{' '} - - Figma Plugin (åpnes i ny fane) - {' '} - og{' '} - - Figma UI kit (åpnes i ny fane) - - . - - - Dette alternativet er kun ment for rask prototyping av valgt tema i - Figma. For å bruke design tokens i produksjon, anbefales det å bruke - alternativ 1. - -
-
- - Light Mode - -
- {lightThemeSnippet} -
+ Figma Plugin (åpnes i ny fane) + {' '} + og{' '} + + Figma UI kit (åpnes i ny fane) + + . + + + Dette alternativet er kun ment for rask prototyping av valgt tema i + Figma. For å bruke design tokens i produksjon, anbefales det å bruke + alternativ 1. + +
+
+ + Light Mode + +
+ {lightThemeSnippet}
-
- - Dark Mode - -
- {darkThemeSnippet} -
+
+
+ + Dark Mode + +
+ {darkThemeSnippet}
- - - +
+ + ); }; diff --git a/packages/css/modal.css b/packages/css/modal.css index 10090ce7fe..34d1cd54ce 100644 --- a/packages/css/modal.css +++ b/packages/css/modal.css @@ -1,25 +1,27 @@ .ds-modal { - --dsc-modal-max-width: 40rem; + --dsc-modal-backdrop-background: rgb(0 0 0 / 0.5); --dsc-modal-background: var(--ds-color-neutral-background-default); + --dsc-modal-close-margin: var(--ds-spacing-3); --dsc-modal-color: var(--ds-color-neutral-text-default); - --dsc-modal-backdrop-background: rgb(0 0 0 / 0.5); - --dsc-modal-header-padding: var(--ds-spacing-6) var(--ds-spacing-18) var(--ds-spacing-2) var(--ds-spacing-6); - --dsc-modal-footer-padding: var(--ds-spacing-3) var(--ds-spacing-6) var(--ds-spacing-6) var(--ds-spacing-6); - --dsc-modal-content-padding: var(--ds-spacing-2) var(--ds-spacing-6); - --dsc-modal-content-max-height: 80vh; + --dsc-modal-divider: 1px solid var(--ds-color-neutral-border-subtle); + --dsc-modal-max-height: 80vh; + --dsc-modal-max-width: 40rem; + --dsc-modal-padding-invert: calc(var(--dsc-modal-padding) * -1); + --dsc-modal-padding: var(--ds-spacing-6); - padding: 0; - width: 100%; - max-width: var(--dsc-modal-max-width); - border: none; + background: var(--dsc-modal-background); border-radius: min(1rem, var(--ds-border-radius-lg)); + border: 0; box-shadow: var(--ds-shadow-xl); - background-color: var(--dsc-modal-background); color: var(--dsc-modal-color); + max-height: var(--dsc-modal-max-height); + max-width: var(--dsc-modal-max-width); + padding: var(--dsc-modal-padding); + width: 100%; &::backdrop { - background-color: var(--dsc-modal-backdrop-background); animation: fade-in 300ms ease-in-out; + background: var(--dsc-modal-backdrop-background); } &[open] { @@ -28,90 +30,65 @@ fade-in 300ms ease-in-out; } - & > hr { - margin: var(--ds-spacing-3) 0 !important; - } - @media (max-width: 40rem) { - & { - min-width: 100%; - max-width: 100%; - border-radius: 0; - } + min-width: 100%; + max-width: 100%; + border-radius: 0; } @media (prefers-reduced-motion: reduce) { - &[open] { - animation: none; - } - + &[open], &::backdrop { animation: none; } } - &.ds-modal--lock-scroll { - overflow: hidden; - } - - .ds-modal__header { - display: flex; - justify-content: space-between; - flex-direction: column; - padding: var(--dsc-modal-header-padding); - gap: var(--ds-spacing-1); - - &:not(:has(> .ds-modal__header__button)) { - --dsc-modal-header-padding: var(--ds-spacing-6) var(--ds-spacing-6) var(--ds-spacing-2) var(--ds-spacing-6); - } - - .ds-modal__header__button { - position: absolute; - top: var(--ds-spacing-3); - right: var(--ds-spacing-3); - color: var(--dsc-modal-color); - - &::before { - content: ''; - background: currentcolor; - height: 1.5em; - width: 1.5em; - mask: center/contain no-repeat - url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' fill='none' viewBox='0 0 24 24' focusable='false' role='img'%3E%3Cpath fill='currentColor' d='M6.53 5.47a.75.75 0 0 0-1.06 1.06L10.94 12l-5.47 5.47a.75.75 0 1 0 1.06 1.06L12 13.06l5.47 5.47a.75.75 0 1 0 1.06-1.06L13.06 12l5.47-5.47a.75.75 0 0 0-1.06-1.06L12 10.94z'%3E%3C/path%3E%3C/svg%3E"); - } + /* Close button */ + & > form[method='dialog']:first-child > button:only-child { + --margin-top-right: calc(var(--dsc-modal-padding-invert) + var(--dsc-modal-close-margin)); + + float: right; + margin: var(--margin-top-right) var(--margin-top-right) var(--dsc-modal-close-margin) var(--dsc-modal-close-margin); + color: inherit; + + &::before { + content: ''; + background: currentcolor; + height: var(--ds-spacing-6); + width: var(--ds-spacing-6); + mask: center/contain no-repeat + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' fill='none' viewBox='0 0 24 24' focusable='false' role='img'%3E%3Cpath fill='currentColor' d='M6.53 5.47a.75.75 0 0 0-1.06 1.06L10.94 12l-5.47 5.47a.75.75 0 1 0 1.06 1.06L12 13.06l5.47 5.47a.75.75 0 1 0 1.06-1.06L13.06 12l5.47-5.47a.75.75 0 0 0-1.06-1.06L12 10.94z'%3E%3C/path%3E%3C/svg%3E"); } } +} - .ds-modal__footer { - display: flex; - align-items: center; - gap: var(--ds-spacing-4); - padding: var(--dsc-modal-footer-padding); - } +.ds-modal__header { + border-bottom: var(--dsc-modal-divider); + margin-block: var(--dsc-modal-padding-invert) var(--dsc-modal-padding); + margin-inline: var(--dsc-modal-padding-invert); + padding: var(--dsc-modal-padding); +} - .ds-modal__content { - padding: var(--dsc-modal-content-padding); - max-height: var(--dsc-modal-content-max-height); - overflow-y: auto; - } +.ds-modal__footer { + border-top: var(--dsc-modal-divider); + margin-block: var(--dsc-modal-padding) var(--dsc-modal-padding-invert); + margin-inline: var(--dsc-modal-padding-invert); + padding: var(--dsc-modal-padding); } -@keyframes slide-in { - 0% { - transform: translateY(50px); - } +/* Prevent scroll when open */ +body:has(.ds-modal[open]) { + overflow: hidden; +} - 100% { - transform: translateY(0); +@keyframes slide-in { + from { + translate: 0 50px; } } @keyframes fade-in { - 0% { + from { opacity: 0; } - - 100% { - opacity: 1; - } } diff --git a/packages/react/src/components/Modal/Modal.mdx b/packages/react/src/components/Modal/Modal.mdx index 1b77364977..ad4c089768 100644 --- a/packages/react/src/components/Modal/Modal.mdx +++ b/packages/react/src/components/Modal/Modal.mdx @@ -20,20 +20,21 @@ Les [MDN Docs](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog) ## Slik bruker du `Modal` ```tsx - + Open Modal - - Hader - Content + + + Header + + Content Footer - - + + ``` ## Bruk med eksten trigger Dersom du vil bruke en ekstern trigger, som for eksempel ligger en annen plass i treet, kan du bruke `ref` for å åpne modalen. -Du må fortsatt ha `Modal.Root` rundt `Modal.Dialog` for at modalen skal fungere, men du kan fjerne `Modal.Trigger`. ```tsx const modalRef = useRef(null); @@ -41,32 +42,26 @@ const modalRef = useRef(null); ... - - - Hader - Content - Footer - - + + Content + ``` ### Med bruk av `ref` - + ### Close on backdrop click -Vi bruker `onInteractOutside` proppen for å lukke modalen når brukeren klikker utenfor modalen. +Vi bruker `backdropClose={true}` proppen for å lukke modalen når brukeren klikker utenfor. - + -### Med `Divider` +### Med `Header` og `Footer` -Du kan legge divider mellom, for å skille innhold fra hverandre. `Divider` får ekstra margin på topp og bunn. +Du kan legge til `Modal.Header` og `Modal.Footer` for å få på topp- og bunn-område med sikkelinje. - + ### Form @@ -76,7 +71,7 @@ Vi bruker native `autoFocus` på `Textfield` for å fokusere inputen i skjemaet. ### Med egendefinert bredde -Bruk max-width for å sette egendefinert maks bredde på modalen. Default er 650px. +Bruk `max-width` for å sette egendefinert maks bredde på modalen. Default er 40rem. @@ -84,7 +79,7 @@ Bruk max-width for å sette egendefinert maks bredde på modalen. Default er 650 Bruk `overflow: visible` for å la innhold gå utenfor modalen. - + ### `Modal.Header` diff --git a/packages/react/src/components/Modal/Modal.stories.tsx b/packages/react/src/components/Modal/Modal.stories.tsx index bab2630764..771e18302f 100644 --- a/packages/react/src/components/Modal/Modal.stories.tsx +++ b/packages/react/src/components/Modal/Modal.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryFn } from '@storybook/react'; import { useRef, useState } from 'react'; -import { Button, Combobox, Divider, Paragraph, Textfield } from '..'; +import { Button, Combobox, Heading, Paragraph, Textfield } from '..'; import { Modal } from '.'; @@ -15,115 +15,126 @@ const decorators = [ export default { title: 'Komponenter/Modal', - component: Modal.Dialog, + component: Modal, decorators, } as Meta; -export const Preview: StoryFn = (args) => { - return ( - <> - - Open Modal - - Modal header - - - Lorem ipsum dolor sit, amet consectetur adipisicing elit. - Blanditiis doloremque obcaecati assumenda odio ducimus sunt et. - - - Modal footer - - - - ); -}; +export const Preview: StoryFn = (args) => ( + + Open Modal + + + Modal header + + + Lorem ipsum dolor sit, amet consectetur adipisicing elit. Blanditiis + doloremque obcaecati assumenda odio ducimus sunt et. + + Modal footer + + +); -export const WithoutTriggerComponent: StoryFn = (args) => { +export const WithoutModalContext: StoryFn = (args) => { const modalRef = useRef(null); return ( <> - - - Modal header - - - Lorem ipsum dolor sit, amet consectetur adipisicing elit. - Blanditiis doloremque obcaecati assumenda odio ducimus sunt et. - - - Modal footer - - + + Modal subtittel + + Modal header + + + Lorem ipsum dolor sit, amet consectetur adipisicing elit. Blanditiis + doloremque obcaecati assumenda odio ducimus sunt et. + + Modal footer + ); }; -export const CloseOnBackdropClick: StoryFn = () => { +export const BackdropClose: StoryFn = () => { const modalRef = useRef(null); return ( - + Open Modal - modalRef.current?.close()} - > - - Modal med closeOnBackdropClick og en veldig lang tittel - - - - Lorem ipsum dolor sit, amet consectetur adipisicing elit. Blanditiis - doloremque obcaecati assumenda odio ducimus sunt et. - - - Footer - - + + + Modal med backdropClose og en veldig lang tittel + + + Lorem ipsum dolor sit, amet consectetur adipisicing elit. Blanditiis + doloremque obcaecati assumenda odio ducimus sunt et. + + Modal footer + + ); }; -export const WithDivider: StoryFn = () => { - return ( - - Open Modal - - - Vi kan legge divider under header - - - - Rundt content - - - Og over footer - - - ); -}; +export const WithHeaderAndFooter: StoryFn = () => ( + + Open Modal + + + Her er det også divider + Vi kan legge divider under header + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur + sodales eros justo. Aenean non mi ipsum. Cras viverra elit nec vulputate + mattis. Nunc placerat euismod pulvinar. Sed nec fringilla nulla, sit + amet ultricies ante. Morbi egestas venenatis massa, eu interdum leo + rutrum eu. Nulla varius, mi ac feugiat lacinia, magna eros ullamcorper + arcu, vel tincidunt erat leo nec tortor. Sed ut dui arcu. Morbi commodo + ipsum hendrerit est imperdiet imperdiet. Etiam sed maximus nisi. Quisque + posuere posuere orci, non egestas risus facilisis a. Vivamus non tempus + felis, in maximus lorem. Class aptent taciti sociosqu ad litora torquent + per conubia nostra, per inceptos himenaeos. + + + Etiam nec tincidunt est. Integer semper sodales efficitur. Pellentesque + pellentesque varius leo id congue. Integer lacinia porttitor massa id + euismod. Maecenas porta, magna nec interdum eleifend, risus magna + condimentum neque, a gravida nisl risus a elit. Donec accumsan metus et + lectus placerat varius. Donec tristique odio arcu. Donec cursus leo a + dui auctor pulvinar. Sed in elit urna. Nunc vitae magna sed nibh + elementum dignissim et ut massa. + + Og over footer + + +); -export const ModalWithForm: StoryFn = () => { +export const ModalWithForm: StoryFn = () => { const modalRef = useRef(null); const [input, setInput] = useState(''); return ( - + Open Modal - setInput('')}> - Modal med skjema - - setInput(e.target.value)} - /> - - + setInput('')}> + + Modal med skjema + + setInput(e.target.value)} + /> +
- - - +
+
+
); }; -export const ModalWithMaxWidth: StoryFn = () => { - return ( - <> - - Open Modal - {/* @ts-expect-error #2353 */} - - Modal med en veldig lang bredde - - - Lorem ipsum dolor sit, amet consectetur adipisicing elit. - Blanditiis doloremque obcaecati assumenda odio ducimus sunt et. - - - Footer - - - - ); -}; +export const ModalWithMaxWidth: StoryFn = () => ( + + Open Modal + + + Modal med en veldig lang bredde + + + Lorem ipsum dolor sit, amet consectetur adipisicing elit. Blanditiis + doloremque obcaecati assumenda odio ducimus sunt et. + + + +); -export const ModalWithSelect: StoryFn = () => { +export const ModalWithCombobox: StoryFn = () => { const modalRef = useRef(null); return ( <> - + Open Modal - - Modal med select - - - Fant ingen treff - Leikanger - Oslo - Brønnøysund - Stavanger - Trondheim - Tromsø - Bergen - Mo i Rana - - + + + Modal med combobox + + + Fant ingen treff + Leikanger + Oslo + Brønnøysund + Stavanger + Trondheim + Tromsø + Bergen + Mo i Rana + - - + + ); }; diff --git a/packages/react/src/components/Modal/Modal.test.tsx b/packages/react/src/components/Modal/Modal.test.tsx index e910693f31..1c3262b8e9 100644 --- a/packages/react/src/components/Modal/Modal.test.tsx +++ b/packages/react/src/components/Modal/Modal.test.tsx @@ -2,24 +2,25 @@ import { render as renderRtl, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { act } from 'react'; -import type { ModalDialogProps } from './'; +import type { ModalProps } from './'; import { Modal } from './'; +const CLOSE_LABEL = 'Lukk dialogvindu'; const HEADER_TITLE = 'Modal header title'; const OPEN_MODAL = 'Open Modal'; -const Comp = (args: Partial) => { +const Comp = (args: Partial) => { return ( <> - + {OPEN_MODAL} - - + + ); }; -const render = async (props: Partial = {}) => { +const render = async (props: Partial = {}) => { /* Flush microtasks */ await act(async () => {}); const user = userEvent.setup(); @@ -56,30 +57,18 @@ describe('Modal', () => { }); it('should open and close the modal', async () => { - const { user } = await render({ - children: ( - <> - {HEADER_TITLE} - - ), - }); - const showSpy = vi.spyOn(HTMLDialogElement.prototype, 'showModal'); - const closeSpy = vi.spyOn(HTMLDialogElement.prototype, 'close'); + const { user } = await render(); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); const button = screen.getByRole('button', { name: OPEN_MODAL }); await act(async () => await user.click(button)); - expect(showSpy).toHaveBeenCalledTimes(1); - expect(screen.queryByRole('dialog')).toBeInTheDocument(); - const closeButton = screen.getByRole('button', { name: /close/i }); + const closeButton = screen.getByRole('button', { name: CLOSE_LABEL }); await act(async () => await user.click(closeButton)); - expect(closeSpy).toHaveBeenCalledTimes(1); - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); @@ -89,28 +78,19 @@ describe('Modal', () => { }); it('should render the close button', async () => { - await render({ - children: ( - <> - {HEADER_TITLE} - - ), - open: true, - }); - expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument(); + await render({ open: true }); + expect( + screen.getByRole('button', { name: CLOSE_LABEL }), + ).toBeInTheDocument(); }); it('should not render the close button when closeButton is false', async () => { await render({ open: true, - children: ( - <> - {HEADER_TITLE} - - ), + closeButton: false, }); expect( - screen.queryByRole('button', { name: /close/i }), + screen.queryByRole('button', { name: CLOSE_LABEL }), ).not.toBeInTheDocument(); }); @@ -126,19 +106,6 @@ describe('Modal', () => { expect(screen.getByText(HEADER_TITLE)).toBeInTheDocument(); }); - it('should render the header subtitle', async () => { - const headerSubtitle = 'Modal header subtitle'; - await render({ - open: true, - children: ( - <> - {HEADER_TITLE} - - ), - }); - expect(screen.getByText(headerSubtitle)).toBeInTheDocument(); - }); - it('should render the children', async () => { const children = 'Modal children'; await render({ children, open: true }); diff --git a/packages/react/src/components/Modal/Modal.tsx b/packages/react/src/components/Modal/Modal.tsx new file mode 100644 index 0000000000..a58719802e --- /dev/null +++ b/packages/react/src/components/Modal/Modal.tsx @@ -0,0 +1,97 @@ +import { useMergeRefs } from '@floating-ui/react'; +import { Slot } from '@radix-ui/react-slot'; +import cl from 'clsx/lite'; +import type { DialogHTMLAttributes } from 'react'; +import { forwardRef, useContext, useEffect, useRef } from 'react'; + +import { Button } from '../Button'; +import { Context } from './ModalContext'; + +export type ModalProps = { + /** + * Screen reader label of close button. Set false to hide the close button. + * @default 'Lukk dialogvindu' + */ + closeButton?: string | false; + /** + * Close on backdrop click. + * @default false + */ + backdropClose?: boolean; + /** + * Callback that is called when the modal is closed. + * @default undefined + */ + onClose?: () => void; + asChild?: boolean; +} & DialogHTMLAttributes; + +export const Modal = forwardRef(function Modal( + { + asChild, + children, + className, + closeButton = 'Lukk dialogvindu', + onClose, + open, + backdropClose = false, + ...rest + }, + ref, +) { + const contextRef = useContext(Context); + const modalRef = useRef(null); // This local ref is used to make sure the modal works without a ModalContext + const Component = asChild ? Slot : 'dialog'; + const mergedRefs = useMergeRefs([contextRef, ref, modalRef]); + + useEffect(() => modalRef.current?.[open ? 'showModal' : 'close'](), [open]); // Toggle open based on prop + + useEffect(() => { + const modal = modalRef.current; + const handleBackdropClick = ({ + clientY: y, + clientX: x, + target, + }: MouseEvent) => { + if (window.getSelection()?.toString()) return; // Fix bug where if you select text spanning two divs it thinks you clicked outside + if (modal && target === modal && backdropClose) { + const { top, left, right, bottom } = modal.getBoundingClientRect(); + const isInDialog = top <= y && y <= bottom && left <= x && x <= right; + + if (!isInDialog) modal?.close(); // Both and ::backdrop is considered same event.target + } + }; + + const handleAutoFocus = () => { + const autofocus = modal?.querySelector('[autofocus]'); + if (document.activeElement !== autofocus) autofocus?.focus(); + }; + + modal?.addEventListener('animationend', handleAutoFocus); + modal?.addEventListener('click', handleBackdropClick); + return () => { + modal?.removeEventListener('animationend', handleAutoFocus); + modal?.removeEventListener('click', handleBackdropClick); + }; + }, [backdropClose]); + + return ( + + {closeButton !== false && ( +
+
- - + Open Modal + + + Combobox i Modal + + { + setValue(value); }} + label='Hvor går reisen?' + portal={false} > - Combobox i Modal - - { - setValue(value); - }} - label='Hvor går reisen?' - portal={false} - > - Fant ingen treff - {PLACES.map((item, index) => ( - - {item.name} - - ))} - - - - - + Fant ingen treff + {PLACES.map((item, index) => ( + + {item.name} + + ))} + +
+ ); }; diff --git a/test/vitest.setup.ts b/test/vitest.setup.ts index b2b5602e94..fc2a789ef3 100644 --- a/test/vitest.setup.ts +++ b/test/vitest.setup.ts @@ -33,6 +33,15 @@ HTMLDialogElement.prototype.close = vi.fn(function mock( this.open = false; }); +// Add support for dialog form method +document.addEventListener('click', ({ target }) => { + if (target instanceof HTMLElement) + target + .closest('form[method="dialog"] button[type="submit"]') + ?.closest('dialog') + ?.close(); +}); + const { setScreenWidth } = mockMediaQuery(800); setScreenWidth(800);