From 19d78ead75d27632a665f7f374503a5804f0e88b Mon Sep 17 00:00:00 2001 From: Pier Francesco Ferrari Date: Wed, 27 May 2026 11:11:13 +0200 Subject: [PATCH 1/2] feat: improve Modal --- .../src/ui/composite/Modal.test.tsx | 218 ++++++++++++++++++ .../app-elements/src/ui/composite/Modal.tsx | 160 +++++++++---- 2 files changed, 327 insertions(+), 51 deletions(-) create mode 100644 packages/app-elements/src/ui/composite/Modal.test.tsx diff --git a/packages/app-elements/src/ui/composite/Modal.test.tsx b/packages/app-elements/src/ui/composite/Modal.test.tsx new file mode 100644 index 000000000..9f331fdb4 --- /dev/null +++ b/packages/app-elements/src/ui/composite/Modal.test.tsx @@ -0,0 +1,218 @@ +import { fireEvent, render } from "@testing-library/react" +import { Modal } from "./Modal" + +interface SetupOptions { + dismissible?: boolean + show?: boolean +} + +const setup = ({ dismissible = false, show = true }: SetupOptions = {}) => { + const onClose = vi.fn() + + const utils = render( + + My Modal + Body + , + ) + + const dialog = document.body.querySelector("dialog") as HTMLDialogElement + const backdrop = utils.getByTestId("modal-backdrop") + const closeButton = utils.getByLabelText("Close") + + return { + ...utils, + onClose, + dialog, + backdrop, + closeButton, + } +} + +describe("Modal", () => { + test("Should not close on backdrop click when dismissible is false", () => { + const { backdrop, onClose } = setup({ dismissible: false }) + + fireEvent.click(backdrop) + + expect(onClose).not.toHaveBeenCalled() + }) + + test("Should not close on Escape cancel event when dismissible is false", () => { + const { dialog, onClose } = setup({ dismissible: false }) + + fireEvent(dialog, new Event("cancel", { cancelable: true })) + + expect(onClose).not.toHaveBeenCalled() + }) + + test("Should not close from keyboard activation on close button when dismissible is false", () => { + const { closeButton, onClose } = setup({ dismissible: false }) + + fireEvent.keyDown(closeButton, { key: "Enter" }) + fireEvent.keyDown(closeButton, { key: " " }) + + expect(onClose).not.toHaveBeenCalled() + }) + + test("Should close on close button click when dismissible is false", () => { + const { closeButton, onClose } = setup({ dismissible: false }) + + fireEvent.click(closeButton) + + expect(onClose).toHaveBeenCalledTimes(1) + }) + + test("Should close on backdrop click when dismissible is true", () => { + const { backdrop, onClose } = setup({ dismissible: true }) + + fireEvent.click(backdrop) + + expect(onClose).toHaveBeenCalledTimes(1) + }) + + test("Should close on Escape cancel event when dismissible is true", () => { + const { dialog, onClose } = setup({ dismissible: true }) + + fireEvent(dialog, new Event("cancel", { cancelable: true })) + + expect(onClose).toHaveBeenCalledTimes(1) + }) + + test("Should keep dialog visibility in sync across open-close-open cycle", () => { + const onClose = vi.fn() + const { rerender } = render( + + My Modal + Body + , + ) + + const dialog = document.body.querySelector("dialog") as HTMLDialogElement + expect(dialog).toHaveClass("hidden") + expect(dialog.open).toBe(false) + + rerender( + + My Modal + Body + , + ) + expect(dialog).toHaveClass("grid") + expect(dialog.open).toBe(true) + + rerender( + + My Modal + Body + , + ) + expect(dialog).toHaveClass("hidden") + expect(dialog.open).toBe(false) + + rerender( + + My Modal + Body + , + ) + expect(dialog).toHaveClass("grid") + expect(dialog.open).toBe(true) + }) + + test("Should use ariaLabel when Modal.Header is not rendered", () => { + const onClose = vi.fn() + render( + + Body + , + ) + + expect(document.body.querySelector("dialog")).toHaveAttribute( + "aria-label", + "Details panel", + ) + }) + + test("Should keep backdrop out of tab order", () => { + const { backdrop } = setup({ dismissible: true }) + + expect(backdrop).toHaveAttribute("tabindex", "-1") + }) + + test("Should keep aria-labelledby associations unique with multiple modals", () => { + const onClose = vi.fn() + render( + <> + + First Modal + Body + + + Second Modal + Body + + , + ) + + const dialogs = Array.from(document.body.querySelectorAll("dialog")) + expect(dialogs).toHaveLength(2) + + const firstLabelId = dialogs[0]?.getAttribute("aria-labelledby") + const secondLabelId = dialogs[1]?.getAttribute("aria-labelledby") + + expect(firstLabelId).toBeTruthy() + expect(secondLabelId).toBeTruthy() + expect(firstLabelId).not.toBe(secondLabelId) + expect(document.getElementById(firstLabelId ?? "")).toBeInTheDocument() + expect(document.getElementById(secondLabelId ?? "")).toBeInTheDocument() + }) + + test("Should fallback to open property when showModal/close are unavailable", () => { + const onClose = vi.fn() + const { rerender } = render( + + My Modal + Body + , + ) + + const dialog = document.body.querySelector("dialog") as HTMLDialogElement + const originalShowModal = dialog.showModal + const originalClose = dialog.close + + Object.defineProperty(dialog, "showModal", { + configurable: true, + value: undefined, + }) + Object.defineProperty(dialog, "close", { + configurable: true, + value: undefined, + }) + + rerender( + + My Modal + Body + , + ) + expect(dialog.open).toBe(true) + + rerender( + + My Modal + Body + , + ) + expect(dialog.open).toBe(false) + + Object.defineProperty(dialog, "showModal", { + configurable: true, + value: originalShowModal, + }) + Object.defineProperty(dialog, "close", { + configurable: true, + value: originalClose, + }) + }) +}) diff --git a/packages/app-elements/src/ui/composite/Modal.tsx b/packages/app-elements/src/ui/composite/Modal.tsx index 8247d45f7..9e500d4a3 100644 --- a/packages/app-elements/src/ui/composite/Modal.tsx +++ b/packages/app-elements/src/ui/composite/Modal.tsx @@ -1,6 +1,13 @@ import cn from "classnames" import type React from "react" -import { createContext, useContext, useEffect, useId } from "react" +import { + createContext, + forwardRef, + useContext, + useEffect, + useId, + useRef, +} from "react" import { createPortal } from "react-dom" import { Icon } from "#ui/atoms/Icon" @@ -8,6 +15,7 @@ import { Icon } from "#ui/atoms/Icon" const ModalContext = createContext<{ onClose: () => void modalId: string + dismissible: boolean } | null>(null) // Hook to use modal context @@ -22,97 +30,140 @@ const useModalContext = () => { export type ModalProps = { /** Whether the modal is open */ show?: boolean - /** Called when user clicks backdrop or the close button */ + /** Accessible name fallback used when Modal.Header is not rendered. */ + ariaLabel?: string + /** Called when the modal requests to close (e.g. header close button, and backdrop/Escape when `dismissible` is true). */ onClose: () => void /** Modal content */ children: React.ReactNode /** Max width preset */ size?: "large" | "small" | "x-small" + /** + * Enables modal dismissal via backdrop click and Escape key. + * + * - true: backdrop + Escape can trigger onClose. + * - false: backdrop + Escape are ignored; closing is allowed only via mouse click on the header close button. + * + * Note: when false, keyboard activation (Enter/Space) on the close button is intentionally blocked. + */ + dismissible?: boolean } -export const Modal: React.FC< - ModalProps & { ref?: React.RefObject } +type ModalComponent = React.ForwardRefExoticComponent< + ModalProps & React.RefAttributes > & { Header: React.FC Body: React.FC Footer: React.FC -} = ({ ref, show = false, children, onClose, size = "small" }) => { +} + +const ModalRoot = ( + { + show = false, + ariaLabel, + children, + onClose, + size = "small", + dismissible = false, + }: ModalProps, + ref: React.ForwardedRef, +) => { const modalId = useId() + const dialogRef = useRef(null) useEffect( - function preventBodyScrollbar() { - if (show) { - document.body.classList.add("overflow-hidden") + function syncDialogVisibility() { + const dialog = dialogRef.current + if (!dialog) return + + if (show && !dialog.open) { + if (typeof dialog.showModal === "function") { + dialog.showModal() + } else { + dialog.open = true + } + return } - return () => { - document.body.classList.remove("overflow-hidden") + if (!show && dialog.open) { + if (typeof dialog.close === "function") { + dialog.close() + } else { + dialog.open = false + } } }, [show], ) const content = ( - +
{ - e.stopPropagation() - }} - onKeyDown={(e) => { - // stop closing when pressing keys inside the dialog - if (e.key === "Escape" || e.key === "Enter" || e.key === " ") { - e.stopPropagation() - } - }} > {children}
) + /** + * The modal is rendered in a portal and uses the native dialog element for + * keyboard and focus behavior. Backdrop click/Escape can request close unless + * `dismissible` is false. + */ return createPortal(
- {show && ( - <> -
, document.body, ) } +export const Modal = forwardRef( + ModalRoot, +) as ModalComponent + Modal.Header = ({ children }) => { - const { onClose, modalId } = useModalContext() + const { onClose, modalId, dismissible } = useModalContext() return (
@@ -124,6 +175,13 @@ Modal.Header = ({ children }) => { aria-label="Close" className="p-2 -m-2" onClick={onClose} + onKeyDown={(event) => { + // Product requirement: when dismissible=false, modal must close only on mouse click. + // Block keyboard activation on the close button (Enter/Space). + if (!dismissible && (event.key === "Enter" || event.key === " ")) { + event.preventDefault() + } + }} > From c130637d1fb8a1917d1ac757f9e81b5496156933 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 13:51:25 +0000 Subject: [PATCH 2/2] docs: add dismissible Modal story --- .../src/stories/composite/Modal.stories.tsx | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/docs/src/stories/composite/Modal.stories.tsx b/packages/docs/src/stories/composite/Modal.stories.tsx index b6a467873..ddeb9f8ed 100644 --- a/packages/docs/src/stories/composite/Modal.stories.tsx +++ b/packages/docs/src/stories/composite/Modal.stories.tsx @@ -70,6 +70,28 @@ export const Default: StoryFn = () => { ) } +/** + * Modal that can be dismissed by clicking the backdrop or pressing Esc. + */ +export const Dismissible: StoryFn = () => { + const [show, setShow] = useState(false) + + const handleClose = () => setShow(false) + const handleShow = () => setShow(true) + + return ( +
+ + + Dismissible modal + + This modal can be closed by clicking outside or pressing Escape. + + +
+ ) +} + /** * Modal with a long, scrollable content. */