Modal
v0.2.0A `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, @behivetech/get-class-name
modal.tsx
1import { type HTMLAttributes, type ReactNode } from "react";2import * as RadixDialog from "@radix-ui/react-dialog";3import { getClassName } from "@behivetech/get-class-name";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";1415export interface ModalProps extends Omit<16 HTMLAttributes<HTMLDivElement>,17 "title"18> {19 /** Additional class names to merge with the component root element */20 className?: string;21 /** Called when the modal is dismissed (backdrop click, Escape, or the close button) */22 onClose: () => void;23 /** Optional header title; omit to render no header and no close button */24 title?: string;25 /** Optional icon rendered before the title in the header; ignored if `title` is omitted */26 icon?: ReactNode;27 /** Internal padding of the panel body — same values as `Card`'s `padding` prop */28 padding?: CardPadding;29 /** Which heading tag wraps `title` — same values as `Card`'s `headingLevel` prop */30 headingLevel?: CardHeadingLevel;31 /** Dialog content */32 children: ReactNode;33}3435/**36 * A `Card` centered in the viewport with a dimmed backdrop that blocks37 * interaction with the rest of the page, built on Radix's `Dialog` for focus38 * trapping, Escape-to-close, and outside-click-to-close. For a panel that docks39 * to an edge of the viewport instead, use `Drawer`.40 */41export const Modal = ({42 className,43 onClose,44 title,45 icon,46 padding,47 headingLevel,48 children,49 ...rest50}: ModalProps) => {51 const [rootClass, getChildClass] = getClassName({52 className,53 rootClass: "BHT__Modal",54 styles,55 });5657 const hasHeader = title !== undefined;5859 const cardTitle = hasHeader ? (60 <RadixDialog.Title asChild>61 <span>{title}</span>62 </RadixDialog.Title>63 ) : undefined;6465 const cardAction = hasHeader ? (66 <RadixDialog.Close asChild>67 <Button variant="ghost" size="sm" iconOnly aria-label="Close">68 <CloseIcon size="sm" />69 </Button>70 </RadixDialog.Close>71 ) : undefined;7273 return (74 <RadixDialog.Root open modal onOpenChange={(open) => !open && onClose()}>75 <RadixDialog.Portal>76 <RadixDialog.Overlay className={getChildClass("backdrop")} />77 <RadixDialog.Content {...rest} className={rootClass}>78 <Card79 className={getChildClass("panel")}80 icon={hasHeader ? icon : undefined}81 title={cardTitle}82 headingLevel={headingLevel}83 action={cardAction}84 padding={padding}85 scrollableBody86 >87 {!hasHeader && (88 <RadixDialog.Title89 className={getChildClass("visually-hidden-title")}90 >91 Dialog92 </RadixDialog.Title>93 )}94 {children}95 </Card>96 </RadixDialog.Content>97 </RadixDialog.Portal>98 </RadixDialog.Root>99 );100};
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 relative5// 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 "../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 &__visually-hidden-title {44 // Fixed px: this is the standard clip idiom for hiding content from sight45 // while leaving it to screen readers, not a size that should scale.46 position: absolute;47 width: 1px;48 height: 1px;49 padding: 0;50 margin: -1px;51 overflow: hidden;52 clip: rect(0, 0, 0, 0);53 white-space: nowrap;54 border: 0;55 }56}
index.ts
1export { Modal } from "./modal.js";2export type { ModalProps } from "./modal.js";
modal.composition.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);
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});