Modal
v0.4.1StableA `Card` centered in the viewport with a dimmed backdrop that blocks interaction with the rest of the page, built on [Radix `Dialog`](https://www.radix-ui.com/primitives/docs/components/dialog) for focus trapping, Escape-to-close, and outside-click-to-close. It has no opinion on what goes inside the panel — an optional `title` renders a header with a close button, otherwise just the `children` are shown.
registry/behivetech/atoms/modal · depends on @behivetech/atoms.button, @behivetech/atoms.card, @behivetech/atoms.icon
modal.tsx
1import { type HTMLAttributes, type ReactNode } from "react";2import * as RadixDialog from "@radix-ui/react-dialog";3import { getClassName } from "@bht-component-registry/class-names";4import {5 Card,6 type CardPadding,7 type CardHeadingLevel,8} from "@behivetech/atoms.card";9import { Button } from "@behivetech/atoms.button";10// The glyph, not the set: `<Icon name>` is a runtime lookup, so importing it11// here would pull all 22 icons into every consumer of Modal.12import { CloseIcon } from "@behivetech/atoms.icon";13import styles from "./modal.module.scss";1415/** `default` is a centered Card panel; `fullscreen` fills the viewport. */16export type ModalSize = "default" | "fullscreen";1718export interface ModalProps extends Omit<19 HTMLAttributes<HTMLDivElement>,20 "title"21> {22 /** Additional class names to merge with the component root element */23 className?: string;24 /** Called when the modal is dismissed (backdrop click, Escape, or the close button) */25 onClose: () => void;26 /** Optional header title; omit to render no header and no close button */27 title?: ReactNode;28 /**29 * `default` is a `Card` panel centered in the viewport. `fullscreen` fills the30 * viewport with no `Card` panel: a top bar holds the `title` and the close31 * button and `children` fill the rest, for content such as a lightbox.32 * Fullscreen ignores `icon`, `padding` and `headingLevel`, and its title is33 * not a heading. The close button is always there, with or without a title.34 */35 size?: ModalSize;36 /** Accessible name of the close button, for translation; defaults to "Close" */37 closeLabel?: string;38 /**39 * Called when focus is about to return to the element that was focused before40 * the modal opened; call `event.preventDefault()` to move focus elsewhere.41 */42 onCloseAutoFocus?: (event: Event) => void;43 /** Optional icon rendered before the title in the header; ignored if `title` is omitted */44 icon?: ReactNode;45 /** Internal padding of the panel body — same values as `Card`'s `padding` prop */46 padding?: CardPadding;47 /** Which heading tag wraps `title` — same values as `Card`'s `headingLevel` prop */48 headingLevel?: CardHeadingLevel;49 /**50 * Action buttons kept below the content. They stay visible while the content51 * scrolls under the panel's height cap, so a long form never pushes Save off52 * the screen. Ignored by `size="fullscreen"`, which has no panel.53 */54 footer?: ReactNode;55 /** Dialog content */56 children: ReactNode;57}5859/**60 * A `Card` centered in the viewport with a dimmed backdrop that blocks61 * interaction with the rest of the page, built on Radix's `Dialog` for focus62 * trapping, Escape-to-close, and outside-click-to-close. `size="fullscreen"`63 * drops the `Card` and fills the viewport instead. For a panel that docks64 * to an edge of the viewport instead, use `Drawer`.65 */66export const Modal = ({67 className,68 onClose,69 title,70 icon,71 padding,72 headingLevel,73 size = "default",74 closeLabel = "Close",75 onCloseAutoFocus,76 footer,77 children,78 ...rest79}: ModalProps) => {80 const isFullscreen = size === "fullscreen";81 const hasFooter = footer !== undefined && footer !== null && footer !== false;82 const [rootClass, getChildClass] = getClassName({83 className,84 rootClass: "BHT__Modal",85 modifiers: {86 fullscreen: isFullscreen,87 // The body padding moves from the Card to the content when a footer is shown.88 [`padding-${padding ?? "none"}`]: hasFooter,89 },90 styles,91 });92 const hasHeader = title !== undefined && title !== null && title !== false;9394 const cardTitle = hasHeader ? (95 <RadixDialog.Title asChild>96 <span>{title}</span>97 </RadixDialog.Title>98 ) : undefined;99100 const closeButton = (101 <RadixDialog.Close asChild>102 <Button103 variant="ghost"104 size={isFullscreen ? "lg" : "sm"}105 iconOnly106 aria-label={closeLabel}107 >108 <CloseIcon size={isFullscreen ? "md" : "sm"} />109 </Button>110 </RadixDialog.Close>111 );112113 const cardAction = hasHeader ? closeButton : undefined;114115 const hiddenTitle = !hasHeader && (116 <RadixDialog.Title className={getChildClass("visually-hidden-title")}>117 Dialog118 </RadixDialog.Title>119 );120121 // A fullscreen modal has no backdrop to tap, so Close is there with or without a title.122 const fullscreenContent = (123 <>124 <div className={getChildClass("top-bar")}>125 {hasHeader ? (126 <RadixDialog.Title asChild>127 <span className={getChildClass("top-bar-title")}>{title}</span>128 </RadixDialog.Title>129 ) : (130 hiddenTitle131 )}132 {closeButton}133 </div>134 <div className={getChildClass("stage")}>{children}</div>135 </>136 );137138 // With a footer the panel's body scrolls with no padding of its own: the content139 // carries the padding, and the footer sticks to the bottom edge of the scroll area.140 const content = hasFooter ? (141 <>142 <div className={getChildClass("content")}>{children}</div>143 <div className={getChildClass("footer")}>{footer}</div>144 </>145 ) : (146 children147 );148149 const panel = (150 <Card151 className={getChildClass("panel")}152 icon={hasHeader ? icon : undefined}153 title={cardTitle}154 headingLevel={headingLevel}155 action={cardAction}156 padding={hasFooter ? "none" : padding}157 scrollableBody158 >159 {hiddenTitle}160 {content}161 </Card>162 );163164 return (165 <RadixDialog.Root open modal onOpenChange={(open) => !open && onClose()}>166 <RadixDialog.Portal>167 <RadixDialog.Overlay className={getChildClass("backdrop")} />168 <RadixDialog.Content169 {...rest}170 className={rootClass}171 onCloseAutoFocus={onCloseAutoFocus}172 >173 {isFullscreen ? fullscreenContent : panel}174 </RadixDialog.Content>175 </RadixDialog.Portal>176 </RadixDialog.Root>177 );178};
modal.module.scss
1// Modal renders its panel as a Card, but Card is a separate published package2// with its own separate CSS — nothing about installing @behivetech/atoms.modal3// tells a consumer they also need @behivetech/atoms.card/styles.css. Pulling4// Card's actual stylesheet in here (not a copy — the real file, by package5// path) means Modal's own bundled CSS is self-sufficient: install/import just6// this package and the panel renders fully styled, no hidden second import7// required. Card uses generateScopedName: "[local]" (unhashed class names),8// so this emits the exact same selectors Card's own build emits — no risk of9// mismatching the classNames the real <Card> component actually renders with.10@use "@behivetech/atoms.card/card.module.scss";1112// Material Design 3 basic dialog: the panel is a Card re-themed through its13// custom properties, never by targeting Card's classes.14.BHT__Modal {15 position: fixed;16 top: 50%;17 left: 50%;18 transform: translate(-50%, -50%);19 z-index: var(--z-modal, 400);2021 &__backdrop {22 position: fixed;23 inset: 0;24 background: var(--color-overlay);25 z-index: var(--z-modal, 400);26 }2728 &__panel {29 /* Card reads these custom properties — see Card's README */30 --card-background: var(31 --md-sys-color-surface-container-high,32 var(--color-surface-light)33 );34 --card-radius: var(--md-sys-shape-corner-extra-large, 28px);35 // MD3 dialogs separate from the page by tone and elevation, not an outline.36 --card-border: none;3738 box-shadow: var(--md-sys-elevation-3, var(--shadow-lg));39 width: min(37.5rem, 90vw);40 max-height: 70vh;41 }4243 // Fullscreen drops the Card panel: the dialog itself fills the viewport on the44 // lowest surface tier, a top bar holds the title and Close, the stage the rest.45 &--fullscreen {46 inset: 0;47 transform: none;48 display: flex;49 flex-direction: column;50 background: var(51 --md-sys-color-surface-container-lowest,52 var(--color-surface-light)53 );54 color: var(--md-sys-color-on-surface, inherit);55 }5657 &__top-bar {58 display: flex;59 flex: none;60 align-items: center;61 justify-content: space-between;62 min-height: 4rem;63 padding-inline: var(--spacing-6) var(--spacing-4);64 }6566 &__top-bar-title {67 font-family: var(--md-sys-typescale-title-medium-font, var(--font-heading));68 font-size: var(--md-sys-typescale-title-medium-size, 1rem);69 font-weight: var(--md-sys-typescale-title-medium-weight, 500);70 line-height: var(--md-sys-typescale-title-medium-line-height, 1.5rem);71 letter-spacing: var(--md-sys-typescale-title-medium-tracking, 0);72 }7374 &__stage {75 display: flex;76 flex: 1;77 flex-direction: column;78 min-height: 0;79 }8081 // With a footer the Card's body scrolls with no padding of its own: `__content` carries82 // the padding, and `__footer` sticks to the bottom edge of the scroll area, so the83 // buttons stay in view while a long form scrolls beneath them.84 &--padding-sm {85 .BHT__Modal {86 &__content {87 padding: var(--spacing-3);88 }89 }90 }9192 &--padding-md {93 .BHT__Modal {94 &__content {95 padding: var(--spacing-5);96 }97 }98 }99100 &--padding-lg {101 .BHT__Modal {102 &__content {103 padding: var(--spacing-8);104 }105 }106 }107108 &__footer {109 position: sticky;110 bottom: 0;111 display: flex;112 flex-wrap: wrap;113 align-items: center;114 justify-content: flex-end;115 gap: var(--spacing-2);116 padding: var(--spacing-3) var(--spacing-5);117 background: var(118 --md-sys-color-surface-container-high,119 var(--color-surface-light)120 );121 border-top: 1px solid122 var(--md-sys-color-outline-variant, var(--color-border));123 }124125 &__visually-hidden-title {126 // Fixed px: this is the standard clip idiom for hiding content from sight127 // while leaving it to screen readers, not a size that should scale.128 position: absolute;129 width: 1px;130 height: 1px;131 padding: 0;132 margin: -1px;133 overflow: hidden;134 clip: rect(0, 0, 0, 0);135 white-space: nowrap;136 border: 0;137 }138}
index.ts
1export { Modal } from "./modal.js";2export type { ModalProps } from "./modal.js";
modal.example.tsx
1import { Modal } from "./modal.js";23export const BasicModal = () => (4 <Modal onClose={() => {}} title="Add Block">5 Modal content6 </Modal>7);89export const ModalWithoutTitle = () => (10 <Modal onClose={() => {}}>Modal content, no header</Modal>11);1213export const ModalWithFooter = () => (14 <Modal15 onClose={() => {}}16 title="Gallery image"17 padding="md"18 footer={19 <>20 <button type="button">Cancel</button>21 <button type="button">Save</button>22 </>23 }24 >25 Content that scrolls while the footer buttons stay in view.26 </Modal>27);
modal.spec.tsx
1import { render, screen, fireEvent } from "@testing-library/react";2import userEvent from "@testing-library/user-event";3import { Modal } from "./modal.js";45describe("Modal", () => {6 it("renders children", () => {7 render(<Modal onClose={() => {}}>Hello</Modal>);8 expect(screen.getByText("Hello")).toBeTruthy();9 });1011 it("renders a title and close button when title is provided", () => {12 render(13 <Modal onClose={() => {}} title="Confirm">14 Content15 </Modal>,16 );17 expect(screen.getByText("Confirm")).toBeTruthy();18 expect(screen.getByRole("button", { name: "Close" })).toBeTruthy();19 });2021 it("renders no header when title is omitted", () => {22 render(<Modal onClose={() => {}}>Content</Modal>);23 expect(screen.queryByRole("button", { name: "Close" })).toBeNull();24 });2526 it("renders the title in an h3 by default", () => {27 render(28 <Modal onClose={() => {}} title="Confirm">29 Content30 </Modal>,31 );32 expect(33 screen.getByRole("heading", { level: 3, name: "Confirm" }),34 ).toBeTruthy();35 });3637 it("renders the title in the heading level given by headingLevel", () => {38 render(39 <Modal onClose={() => {}} title="Confirm" headingLevel={2}>40 Content41 </Modal>,42 );43 expect(44 screen.getByRole("heading", { level: 2, name: "Confirm" }),45 ).toBeTruthy();46 });4748 it("calls onClose when the backdrop is clicked", async () => {49 // Modal renders via a Radix Portal, appended to document.body rather than50 // into the render() container, so query from the document instead.51 const onClose = vi.fn();52 render(<Modal onClose={onClose}>Content</Modal>);53 await userEvent.click(54 document.body.querySelector('[class*="backdrop"]') as HTMLElement,55 );56 expect(onClose).toHaveBeenCalledTimes(1);57 });5859 it("does not call onClose when the panel is clicked", async () => {60 const onClose = vi.fn();61 render(<Modal onClose={onClose}>Content</Modal>);62 await userEvent.click(screen.getByText("Content"));63 expect(onClose).not.toHaveBeenCalled();64 });6566 it("calls onClose when the close button is clicked", async () => {67 const onClose = vi.fn();68 render(69 <Modal onClose={onClose} title="Confirm">70 Content71 </Modal>,72 );73 await userEvent.click(screen.getByRole("button", { name: "Close" }));74 expect(onClose).toHaveBeenCalledTimes(1);75 });7677 it("calls onClose when Escape is pressed", () => {78 const onClose = vi.fn();79 render(<Modal onClose={onClose}>Content</Modal>);80 fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" });81 expect(onClose).toHaveBeenCalledTimes(1);82 });8384 it("applies additional className", () => {85 render(86 <Modal onClose={() => {}} className="custom">87 Content88 </Modal>,89 );90 expect(screen.getByRole("dialog").className).toContain("custom");91 });92 describe("fullscreen", () => {93 it("renders the title and a close button without a Card panel", () => {94 render(95 <Modal onClose={() => {}} title="2 of 12" size="fullscreen">96 Content97 </Modal>,98 );99 expect(screen.getByRole("dialog")).toHaveAccessibleName("2 of 12");100 expect(screen.getByRole("button", { name: "Close" })).toBeTruthy();101 // The title is a name, not a heading in the page outline.102 expect(screen.queryByRole("heading")).toBeNull();103 });104105 it("still renders a close button when title is omitted", () => {106 render(107 <Modal onClose={() => {}} size="fullscreen">108 Content109 </Modal>,110 );111 expect(screen.getByText("Content")).toBeTruthy();112 expect(screen.getByRole("button", { name: "Close" })).toBeTruthy();113 });114115 it("calls onClose on Escape and on the close button", async () => {116 const onClose = vi.fn();117 render(118 <Modal onClose={onClose} title="Viewer" size="fullscreen">119 Content120 </Modal>,121 );122 await userEvent.click(screen.getByRole("button", { name: "Close" }));123 await userEvent.keyboard("{Escape}");124 expect(onClose).toHaveBeenCalledTimes(2);125 });126 });127128 describe("footer", () => {129 it("renders the footer after the content, inside the dialog", () => {130 render(131 <Modal132 onClose={() => {}}133 title="Confirm"134 footer={<button>Save</button>}135 >136 Content137 </Modal>,138 );139140 const dialog = screen.getByRole("dialog");141 const content = screen.getByText("Content");142 const save = screen.getByRole("button", { name: "Save" });143144 expect(dialog).toContainElement(save);145 expect(146 content.compareDocumentPosition(save) &147 Node.DOCUMENT_POSITION_FOLLOWING,148 ).toBeTruthy();149 });150151 it("keeps the footer outside the content element that carries the padding", () => {152 render(153 <Modal154 onClose={() => {}}155 title="Confirm"156 padding="md"157 footer={<button>Save</button>}158 >159 Content160 </Modal>,161 );162163 expect(screen.getByText("Content").className).toContain("content");164 expect(165 screen166 .getByText("Content")167 .contains(screen.getByRole("button", { name: "Save" })),168 ).toBe(false);169 });170171 it("renders no footer element when the prop is omitted", () => {172 render(173 <Modal onClose={() => {}} title="Confirm">174 Content175 </Modal>,176 );177178 expect(document.body.querySelector('[class*="footer"]')).toBeNull();179 });180181 it("lets the footer's buttons act", async () => {182 const onSave = vi.fn();183184 render(185 <Modal186 onClose={() => {}}187 title="Confirm"188 footer={<button onClick={onSave}>Save</button>}189 >190 Content191 </Modal>,192 );193 await userEvent.click(screen.getByRole("button", { name: "Save" }));194195 expect(onSave).toHaveBeenCalledTimes(1);196 });197 });198199 it("names the close button from closeLabel", () => {200 render(201 <Modal onClose={() => {}} title="Confirm" closeLabel="Cerrar">202 Content203 </Modal>,204 );205 expect(screen.getByRole("button", { name: "Cerrar" })).toBeTruthy();206 });207208 it("lets onCloseAutoFocus take over where focus returns", async () => {209 const onCloseAutoFocus = vi.fn((event: Event) => event.preventDefault());210 const { unmount } = render(211 <Modal212 onClose={() => {}}213 title="Confirm"214 onCloseAutoFocus={onCloseAutoFocus}215 >216 Content217 </Modal>,218 );219 unmount();220 await vi.waitFor(() => expect(onCloseAutoFocus).toHaveBeenCalledTimes(1));221 });222223 it("treats a null or false title as no title", () => {224 render(225 <Modal onClose={() => {}} title={false}>226 Content227 </Modal>,228 );229 expect(screen.queryByRole("button", { name: "Close" })).toBeNull();230 expect(screen.getByRole("dialog")).toHaveAccessibleName("Dialog");231 });232});