Behive Tech Component Registry

Modal

v0.4.1Stable

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

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 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
15/** `default` is a centered Card panel; `fullscreen` fills the viewport. */
16export type ModalSize = "default" | "fullscreen";
17
18export 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 the
30 * viewport with no `Card` panel: a top bar holds the `title` and the close
31 * button and `children` fill the rest, for content such as a lightbox.
32 * Fullscreen ignores `icon`, `padding` and `headingLevel`, and its title is
33 * 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 before
40 * 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 content
51 * scrolls under the panel's height cap, so a long form never pushes Save off
52 * the screen. Ignored by `size="fullscreen"`, which has no panel.
53 */
54 footer?: ReactNode;
55 /** Dialog content */
56 children: ReactNode;
57}
58
59/**
60 * A `Card` centered in the viewport with a dimmed backdrop that blocks
61 * interaction with the rest of the page, built on Radix's `Dialog` for focus
62 * trapping, Escape-to-close, and outside-click-to-close. `size="fullscreen"`
63 * drops the `Card` and fills the viewport instead. For a panel that docks
64 * 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 ...rest
79}: 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;
93
94 const cardTitle = hasHeader ? (
95 <RadixDialog.Title asChild>
96 <span>{title}</span>
97 </RadixDialog.Title>
98 ) : undefined;
99
100 const closeButton = (
101 <RadixDialog.Close asChild>
102 <Button
103 variant="ghost"
104 size={isFullscreen ? "lg" : "sm"}
105 iconOnly
106 aria-label={closeLabel}
107 >
108 <CloseIcon size={isFullscreen ? "md" : "sm"} />
109 </Button>
110 </RadixDialog.Close>
111 );
112
113 const cardAction = hasHeader ? closeButton : undefined;
114
115 const hiddenTitle = !hasHeader && (
116 <RadixDialog.Title className={getChildClass("visually-hidden-title")}>
117 Dialog
118 </RadixDialog.Title>
119 );
120
121 // 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 hiddenTitle
131 )}
132 {closeButton}
133 </div>
134 <div className={getChildClass("stage")}>{children}</div>
135 </>
136 );
137
138 // With a footer the panel's body scrolls with no padding of its own: the content
139 // 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 children
147 );
148
149 const panel = (
150 <Card
151 className={getChildClass("panel")}
152 icon={hasHeader ? icon : undefined}
153 title={cardTitle}
154 headingLevel={headingLevel}
155 action={cardAction}
156 padding={hasFooter ? "none" : padding}
157 scrollableBody
158 >
159 {hiddenTitle}
160 {content}
161 </Card>
162 );
163
164 return (
165 <RadixDialog.Root open modal onOpenChange={(open) => !open && onClose()}>
166 <RadixDialog.Portal>
167 <RadixDialog.Overlay className={getChildClass("backdrop")} />
168 <RadixDialog.Content
169 {...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 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 package
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 "@behivetech/atoms.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 // Fullscreen drops the Card panel: the dialog itself fills the viewport on the
44 // 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 }
56
57 &__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 }
65
66 &__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 }
73
74 &__stage {
75 display: flex;
76 flex: 1;
77 flex-direction: column;
78 min-height: 0;
79 }
80
81 // With a footer the Card's body scrolls with no padding of its own: `__content` carries
82 // the padding, and `__footer` sticks to the bottom edge of the scroll area, so the
83 // 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 }
91
92 &--padding-md {
93 .BHT__Modal {
94 &__content {
95 padding: var(--spacing-5);
96 }
97 }
98 }
99
100 &--padding-lg {
101 .BHT__Modal {
102 &__content {
103 padding: var(--spacing-8);
104 }
105 }
106 }
107
108 &__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 solid
122 var(--md-sys-color-outline-variant, var(--color-border));
123 }
124
125 &__visually-hidden-title {
126 // Fixed px: this is the standard clip idiom for hiding content from sight
127 // 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";
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);
12
13export const ModalWithFooter = () => (
14 <Modal
15 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";
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 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 Content
97 </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 });
104
105 it("still renders a close button when title is omitted", () => {
106 render(
107 <Modal onClose={() => {}} size="fullscreen">
108 Content
109 </Modal>,
110 );
111 expect(screen.getByText("Content")).toBeTruthy();
112 expect(screen.getByRole("button", { name: "Close" })).toBeTruthy();
113 });
114
115 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 Content
120 </Modal>,
121 );
122 await userEvent.click(screen.getByRole("button", { name: "Close" }));
123 await userEvent.keyboard("{Escape}");
124 expect(onClose).toHaveBeenCalledTimes(2);
125 });
126 });
127
128 describe("footer", () => {
129 it("renders the footer after the content, inside the dialog", () => {
130 render(
131 <Modal
132 onClose={() => {}}
133 title="Confirm"
134 footer={<button>Save</button>}
135 >
136 Content
137 </Modal>,
138 );
139
140 const dialog = screen.getByRole("dialog");
141 const content = screen.getByText("Content");
142 const save = screen.getByRole("button", { name: "Save" });
143
144 expect(dialog).toContainElement(save);
145 expect(
146 content.compareDocumentPosition(save) &
147 Node.DOCUMENT_POSITION_FOLLOWING,
148 ).toBeTruthy();
149 });
150
151 it("keeps the footer outside the content element that carries the padding", () => {
152 render(
153 <Modal
154 onClose={() => {}}
155 title="Confirm"
156 padding="md"
157 footer={<button>Save</button>}
158 >
159 Content
160 </Modal>,
161 );
162
163 expect(screen.getByText("Content").className).toContain("content");
164 expect(
165 screen
166 .getByText("Content")
167 .contains(screen.getByRole("button", { name: "Save" })),
168 ).toBe(false);
169 });
170
171 it("renders no footer element when the prop is omitted", () => {
172 render(
173 <Modal onClose={() => {}} title="Confirm">
174 Content
175 </Modal>,
176 );
177
178 expect(document.body.querySelector('[class*="footer"]')).toBeNull();
179 });
180
181 it("lets the footer's buttons act", async () => {
182 const onSave = vi.fn();
183
184 render(
185 <Modal
186 onClose={() => {}}
187 title="Confirm"
188 footer={<button onClick={onSave}>Save</button>}
189 >
190 Content
191 </Modal>,
192 );
193 await userEvent.click(screen.getByRole("button", { name: "Save" }));
194
195 expect(onSave).toHaveBeenCalledTimes(1);
196 });
197 });
198
199 it("names the close button from closeLabel", () => {
200 render(
201 <Modal onClose={() => {}} title="Confirm" closeLabel="Cerrar">
202 Content
203 </Modal>,
204 );
205 expect(screen.getByRole("button", { name: "Cerrar" })).toBeTruthy();
206 });
207
208 it("lets onCloseAutoFocus take over where focus returns", async () => {
209 const onCloseAutoFocus = vi.fn((event: Event) => event.preventDefault());
210 const { unmount } = render(
211 <Modal
212 onClose={() => {}}
213 title="Confirm"
214 onCloseAutoFocus={onCloseAutoFocus}
215 >
216 Content
217 </Modal>,
218 );
219 unmount();
220 await vi.waitFor(() => expect(onCloseAutoFocus).toHaveBeenCalledTimes(1));
221 });
222
223 it("treats a null or false title as no title", () => {
224 render(
225 <Modal onClose={() => {}} title={false}>
226 Content
227 </Modal>,
228 );
229 expect(screen.queryByRole("button", { name: "Close" })).toBeNull();
230 expect(screen.getByRole("dialog")).toHaveAccessibleName("Dialog");
231 });
232});