Behive Tech Registry

Drawer

v0.4.0

A `Card` docked to one edge of the viewport as a slide-in panel, 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.drawer

registry/behivetech/atoms/drawer · depends on @behivetech/atoms.button, @behivetech/atoms.card, @behivetech/atoms.icon, @behivetech/get-class-name

drawer.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 Drawer.
12import { CloseIcon } from "@behivetech/atoms.icon";
13import styles from "./drawer.module.scss";
14
15export type DrawerPlacement = "top" | "right" | "bottom" | "left";
16
17export interface DrawerProps extends Omit<
18 HTMLAttributes<HTMLDivElement>,
19 "title"
20> {
21 /** Additional class names to merge with the component root element */
22 className?: string;
23 /** Called when the drawer is dismissed (backdrop click, Escape, or the close button) */
24 onClose: () => void;
25 /** Optional header title; omit to render no header and no close button */
26 title?: string;
27 /** Optional icon rendered before the title in the header; ignored if `title` is omitted */
28 icon?: ReactNode;
29 /** Internal padding of the panel body — same values as `Card`'s `padding` prop */
30 padding?: CardPadding;
31 /** Which heading tag wraps `title` — same values as `Card`'s `headingLevel` prop */
32 headingLevel?: CardHeadingLevel;
33 /** Which edge the panel slides in from */
34 placement: DrawerPlacement;
35 /**
36 * Whether a dimmed backdrop covers the rest of the page, blocks interaction with
37 * it, and closes the drawer when clicked — a real modal. Defaults to true. Set
38 * false for a non-blocking panel that floats alongside content the user can keep
39 * interacting with, e.g. an always-visible properties panel that updates as a
40 * different item is selected behind it.
41 */
42 backdrop?: boolean;
43 /** Panel content */
44 children: ReactNode;
45}
46
47/**
48 * A `Card` docked to one edge of the viewport as a slide-in panel, built on
49 * Radix's `Dialog` for focus trapping, Escape-to-close, and outside-click-to-close.
50 * `placement` controls which edge it docks to; `backdrop` controls whether it
51 * blocks the rest of the page or floats alongside content the user can keep
52 * interacting with. For a centered dialog that always blocks the page, use `Modal`.
53 */
54export const Drawer = ({
55 className,
56 onClose,
57 title,
58 icon,
59 padding,
60 headingLevel,
61 placement,
62 backdrop = true,
63 children,
64 ...rest
65}: DrawerProps) => {
66 const [rootClass, getChildClass] = getClassName({
67 className,
68 rootClass: "BHT__Drawer",
69 modifiers: { [`placement-${placement}`]: true },
70 styles,
71 });
72
73 const hasHeader = title !== undefined;
74
75 const cardTitle = hasHeader ? (
76 <RadixDialog.Title asChild>
77 <span>{title}</span>
78 </RadixDialog.Title>
79 ) : undefined;
80
81 const cardAction = hasHeader ? (
82 <RadixDialog.Close asChild>
83 <Button variant="ghost" size="sm" iconOnly aria-label="Close">
84 <CloseIcon size="sm" />
85 </Button>
86 </RadixDialog.Close>
87 ) : undefined;
88
89 return (
90 <RadixDialog.Root
91 open
92 modal={backdrop}
93 onOpenChange={(open) => !open && onClose()}
94 >
95 <RadixDialog.Portal>
96 {backdrop && (
97 <RadixDialog.Overlay className={getChildClass("backdrop")} />
98 )}
99 <RadixDialog.Content
100 {...rest}
101 className={rootClass}
102 onPointerDownOutside={(e) => {
103 if (!backdrop) e.preventDefault();
104 }}
105 >
106 <Card
107 className={getChildClass("panel")}
108 icon={hasHeader ? icon : undefined}
109 title={cardTitle}
110 headingLevel={headingLevel}
111 action={cardAction}
112 padding={padding}
113 scrollableBody
114 >
115 {!hasHeader && (
116 <RadixDialog.Title
117 className={getChildClass("visually-hidden-title")}
118 >
119 Dialog
120 </RadixDialog.Title>
121 )}
122 {children}
123 </Card>
124 </RadixDialog.Content>
125 </RadixDialog.Portal>
126 </RadixDialog.Root>
127 );
128};
drawer.module.scss
1// See modal.module.scss for why this is here — Drawer renders its panel as a
2// Card too, and Card's CSS otherwise never ships with this package.
3@use "../card/card.module.scss";
4
5// Material Design 3 modal side sheet (bottom sheet for top/bottom placement).
6// The panel is a Card re-themed through its custom properties.
7.BHT__Drawer {
8 z-index: var(--z-modal, 400);
9
10 &__backdrop {
11 position: fixed;
12 inset: 0;
13 background: var(--color-overlay);
14 z-index: var(--z-modal, 400);
15 }
16
17 &__panel {
18 /* Card reads these custom properties — see Card's README */
19 --card-background: var(
20 --md-sys-color-surface-container-low,
21 var(--color-surface-raised)
22 );
23 --card-radius: var(--md-sys-shape-corner-large, 16px);
24
25 box-shadow: var(--md-sys-elevation-1, var(--shadow-sm));
26 }
27
28 &__visually-hidden-title {
29 // Fixed px: this is the standard clip idiom for hiding content from sight
30 // while leaving it to screen readers, not a size that should scale.
31 position: absolute;
32 width: 1px;
33 height: 1px;
34 padding: 0;
35 margin: -1px;
36 overflow: hidden;
37 clip: rect(0, 0, 0, 0);
38 white-space: nowrap;
39 border: 0;
40 }
41
42 &--placement-right,
43 &--placement-left {
44 &.BHT__Drawer {
45 position: fixed;
46 top: 0;
47 height: 100vh;
48 }
49
50 .BHT__Drawer {
51 &__panel {
52 width: min(20rem, 90vw);
53 height: 100%;
54 --card-radius: 0;
55 }
56 }
57 }
58
59 &--placement-right {
60 &.BHT__Drawer {
61 right: 0;
62 }
63 }
64
65 &--placement-left {
66 &.BHT__Drawer {
67 left: 0;
68 }
69 }
70
71 &--placement-top,
72 &--placement-bottom {
73 &.BHT__Drawer {
74 position: fixed;
75 left: 0;
76 width: 100vw;
77 }
78
79 .BHT__Drawer {
80 &__panel {
81 width: 100%;
82 max-height: min(20rem, 90vh);
83 --card-radius: 0;
84 }
85 }
86 }
87
88 &--placement-top {
89 &.BHT__Drawer {
90 top: 0;
91 }
92 }
93
94 &--placement-bottom {
95 &.BHT__Drawer {
96 bottom: 0;
97 }
98 }
99}
index.ts
1export { Drawer } from "./drawer.js";
2export type { DrawerProps, DrawerPlacement } from "./drawer.js";
drawer.composition.tsx
1import { Drawer } from "./drawer.js";
2
3export const BasicDrawer = () => (
4 <Drawer onClose={() => {}} title="Add Block" placement="right">
5 Panel content
6 </Drawer>
7);
8
9export const DrawerWithoutTitle = () => (
10 <Drawer onClose={() => {}} placement="right">
11 Panel content, no header
12 </Drawer>
13);
14
15export const NonBlockingRightPanelDrawer = () => (
16 <Drawer onClose={() => {}} title="Block" placement="right" backdrop={false}>
17 Non-blocking side panel content
18 </Drawer>
19);
20
21export const BottomSheetDrawer = () => (
22 <Drawer onClose={() => {}} title="Filters" placement="bottom">
23 Slides up from the bottom edge
24 </Drawer>
25);
drawer.spec.tsx
1import { render, screen, fireEvent } from "@testing-library/react";
2import userEvent from "@testing-library/user-event";
3import { Drawer } from "./drawer.js";
4
5describe("Drawer", () => {
6 it("renders children", () => {
7 render(
8 <Drawer onClose={() => {}} placement="right">
9 Hello
10 </Drawer>,
11 );
12 expect(screen.getByText("Hello")).toBeTruthy();
13 });
14
15 it("renders a title and close button when title is provided", () => {
16 render(
17 <Drawer onClose={() => {}} title="Add Block" placement="right">
18 Content
19 </Drawer>,
20 );
21 expect(screen.getByText("Add Block")).toBeTruthy();
22 expect(screen.getByRole("button", { name: "Close" })).toBeTruthy();
23 });
24
25 it("renders no header when title is omitted", () => {
26 render(
27 <Drawer onClose={() => {}} placement="right">
28 Content
29 </Drawer>,
30 );
31 expect(screen.queryByRole("button", { name: "Close" })).toBeNull();
32 });
33
34 it("renders the title in an h3 by default", () => {
35 render(
36 <Drawer onClose={() => {}} title="Add Block" placement="right">
37 Content
38 </Drawer>,
39 );
40 expect(
41 screen.getByRole("heading", { level: 3, name: "Add Block" }),
42 ).toBeTruthy();
43 });
44
45 it("renders the title in the heading level given by headingLevel", () => {
46 render(
47 <Drawer
48 onClose={() => {}}
49 title="Add Block"
50 headingLevel={2}
51 placement="right"
52 >
53 Content
54 </Drawer>,
55 );
56 expect(
57 screen.getByRole("heading", { level: 2, name: "Add Block" }),
58 ).toBeTruthy();
59 });
60
61 it("calls onClose when the backdrop is clicked", async () => {
62 // Drawer renders via a Radix Portal, appended to document.body rather than
63 // into the render() container, so query from the document instead.
64 const onClose = vi.fn();
65 render(
66 <Drawer onClose={onClose} placement="right">
67 Content
68 </Drawer>,
69 );
70 await userEvent.click(
71 document.body.querySelector('[class*="backdrop"]') as HTMLElement,
72 );
73 expect(onClose).toHaveBeenCalledTimes(1);
74 });
75
76 it("does not render a backdrop when backdrop is false", () => {
77 render(
78 <Drawer onClose={() => {}} backdrop={false} placement="right">
79 Content
80 </Drawer>,
81 );
82 expect(document.body.querySelector('[class*="backdrop"]')).toBeNull();
83 });
84
85 it("does not call onClose when the panel is clicked", async () => {
86 const onClose = vi.fn();
87 render(
88 <Drawer onClose={onClose} placement="right">
89 Content
90 </Drawer>,
91 );
92 await userEvent.click(screen.getByText("Content"));
93 expect(onClose).not.toHaveBeenCalled();
94 });
95
96 it("calls onClose when the close button is clicked", async () => {
97 const onClose = vi.fn();
98 render(
99 <Drawer onClose={onClose} title="Add Block" placement="right">
100 Content
101 </Drawer>,
102 );
103 await userEvent.click(screen.getByRole("button", { name: "Close" }));
104 expect(onClose).toHaveBeenCalledTimes(1);
105 });
106
107 it("calls onClose when Escape is pressed", () => {
108 const onClose = vi.fn();
109 render(
110 <Drawer onClose={onClose} placement="right">
111 Content
112 </Drawer>,
113 );
114 fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" });
115 expect(onClose).toHaveBeenCalledTimes(1);
116 });
117
118 it("applies additional className", () => {
119 render(
120 <Drawer onClose={() => {}} className="custom" placement="right">
121 Content
122 </Drawer>,
123 );
124 expect(screen.getByRole("dialog").className).toContain("custom");
125 });
126
127 it("applies the placement modifier class", () => {
128 render(
129 <Drawer onClose={() => {}} placement="right">
130 Content
131 </Drawer>,
132 );
133 expect(screen.getByRole("dialog").className).toContain(
134 "BHT__Drawer--placement-right",
135 );
136 });
137});