Behive Tech Registry

Modal

v0.2.0

A `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.

pnpm add @behivetech/atoms.modal

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 it
11// here would pull all 22 icons into every consumer of Modal.
12import { CloseIcon } from "@behivetech/atoms.icon";
13import styles from "./modal.module.scss";
14
15export 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}
34
35/**
36 * A `Card` centered in the viewport with a dimmed backdrop that blocks
37 * interaction with the rest of the page, built on Radix's `Dialog` for focus
38 * trapping, Escape-to-close, and outside-click-to-close. For a panel that docks
39 * 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 ...rest
50}: ModalProps) => {
51 const [rootClass, getChildClass] = getClassName({
52 className,
53 rootClass: "BHT__Modal",
54 styles,
55 });
56
57 const hasHeader = title !== undefined;
58
59 const cardTitle = hasHeader ? (
60 <RadixDialog.Title asChild>
61 <span>{title}</span>
62 </RadixDialog.Title>
63 ) : undefined;
64
65 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;
72
73 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 <Card
79 className={getChildClass("panel")}
80 icon={hasHeader ? icon : undefined}
81 title={cardTitle}
82 headingLevel={headingLevel}
83 action={cardAction}
84 padding={padding}
85 scrollableBody
86 >
87 {!hasHeader && (
88 <RadixDialog.Title
89 className={getChildClass("visually-hidden-title")}
90 >
91 Dialog
92 </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 package
2// with its own separate CSS — nothing about installing @behivetech/atoms.modal
3// tells a consumer they also need @behivetech/atoms.card/styles.css. Pulling
4// Card's actual stylesheet in here (not a copy — the real file, by relative
5// path) means Modal's own bundled CSS is self-sufficient: install/import just
6// this package and the panel renders fully styled, no hidden second import
7// required. Card uses generateScopedName: "[local]" (unhashed class names),
8// so this emits the exact same selectors Card's own build emits — no risk of
9// mismatching the classNames the real <Card> component actually renders with.
10@use "../card/card.module.scss";
11
12// Material Design 3 basic dialog: the panel is a Card re-themed through its
13// 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);
20
21 &__backdrop {
22 position: fixed;
23 inset: 0;
24 background: var(--color-overlay);
25 z-index: var(--z-modal, 400);
26 }
27
28 &__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;
37
38 box-shadow: var(--md-sys-elevation-3, var(--shadow-lg));
39 width: min(37.5rem, 90vw);
40 max-height: 70vh;
41 }
42
43 &__visually-hidden-title {
44 // Fixed px: this is the standard clip idiom for hiding content from sight
45 // 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";
2
3export const BasicModal = () => (
4 <Modal onClose={() => {}} title="Add Block">
5 Modal content
6 </Modal>
7);
8
9export 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";
4
5describe("Modal", () => {
6 it("renders children", () => {
7 render(<Modal onClose={() => {}}>Hello</Modal>);
8 expect(screen.getByText("Hello")).toBeTruthy();
9 });
10
11 it("renders a title and close button when title is provided", () => {
12 render(
13 <Modal onClose={() => {}} title="Confirm">
14 Content
15 </Modal>,
16 );
17 expect(screen.getByText("Confirm")).toBeTruthy();
18 expect(screen.getByRole("button", { name: "Close" })).toBeTruthy();
19 });
20
21 it("renders no header when title is omitted", () => {
22 render(<Modal onClose={() => {}}>Content</Modal>);
23 expect(screen.queryByRole("button", { name: "Close" })).toBeNull();
24 });
25
26 it("renders the title in an h3 by default", () => {
27 render(
28 <Modal onClose={() => {}} title="Confirm">
29 Content
30 </Modal>,
31 );
32 expect(
33 screen.getByRole("heading", { level: 3, name: "Confirm" }),
34 ).toBeTruthy();
35 });
36
37 it("renders the title in the heading level given by headingLevel", () => {
38 render(
39 <Modal onClose={() => {}} title="Confirm" headingLevel={2}>
40 Content
41 </Modal>,
42 );
43 expect(
44 screen.getByRole("heading", { level: 2, name: "Confirm" }),
45 ).toBeTruthy();
46 });
47
48 it("calls onClose when the backdrop is clicked", async () => {
49 // Modal renders via a Radix Portal, appended to document.body rather than
50 // 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 });
58
59 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 });
65
66 it("calls onClose when the close button is clicked", async () => {
67 const onClose = vi.fn();
68 render(
69 <Modal onClose={onClose} title="Confirm">
70 Content
71 </Modal>,
72 );
73 await userEvent.click(screen.getByRole("button", { name: "Close" }));
74 expect(onClose).toHaveBeenCalledTimes(1);
75 });
76
77 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 });
83
84 it("applies additional className", () => {
85 render(
86 <Modal onClose={() => {}} className="custom">
87 Content
88 </Modal>,
89 );
90 expect(screen.getByRole("dialog").className).toContain("custom");
91 });
92});