Drawer
v0.4.0A `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.
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 it11// here would pull all 22 icons into every consumer of Drawer.12import { CloseIcon } from "@behivetech/atoms.icon";13import styles from "./drawer.module.scss";1415export type DrawerPlacement = "top" | "right" | "bottom" | "left";1617export 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 with37 * it, and closes the drawer when clicked — a real modal. Defaults to true. Set38 * false for a non-blocking panel that floats alongside content the user can keep39 * interacting with, e.g. an always-visible properties panel that updates as a40 * different item is selected behind it.41 */42 backdrop?: boolean;43 /** Panel content */44 children: ReactNode;45}4647/**48 * A `Card` docked to one edge of the viewport as a slide-in panel, built on49 * 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 it51 * blocks the rest of the page or floats alongside content the user can keep52 * 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 ...rest65}: DrawerProps) => {66 const [rootClass, getChildClass] = getClassName({67 className,68 rootClass: "BHT__Drawer",69 modifiers: { [`placement-${placement}`]: true },70 styles,71 });7273 const hasHeader = title !== undefined;7475 const cardTitle = hasHeader ? (76 <RadixDialog.Title asChild>77 <span>{title}</span>78 </RadixDialog.Title>79 ) : undefined;8081 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;8889 return (90 <RadixDialog.Root91 open92 modal={backdrop}93 onOpenChange={(open) => !open && onClose()}94 >95 <RadixDialog.Portal>96 {backdrop && (97 <RadixDialog.Overlay className={getChildClass("backdrop")} />98 )}99 <RadixDialog.Content100 {...rest}101 className={rootClass}102 onPointerDownOutside={(e) => {103 if (!backdrop) e.preventDefault();104 }}105 >106 <Card107 className={getChildClass("panel")}108 icon={hasHeader ? icon : undefined}109 title={cardTitle}110 headingLevel={headingLevel}111 action={cardAction}112 padding={padding}113 scrollableBody114 >115 {!hasHeader && (116 <RadixDialog.Title117 className={getChildClass("visually-hidden-title")}118 >119 Dialog120 </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 a2// Card too, and Card's CSS otherwise never ships with this package.3@use "../card/card.module.scss";45// 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);910 &__backdrop {11 position: fixed;12 inset: 0;13 background: var(--color-overlay);14 z-index: var(--z-modal, 400);15 }1617 &__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);2425 box-shadow: var(--md-sys-elevation-1, var(--shadow-sm));26 }2728 &__visually-hidden-title {29 // Fixed px: this is the standard clip idiom for hiding content from sight30 // 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 }4142 &--placement-right,43 &--placement-left {44 &.BHT__Drawer {45 position: fixed;46 top: 0;47 height: 100vh;48 }4950 .BHT__Drawer {51 &__panel {52 width: min(20rem, 90vw);53 height: 100%;54 --card-radius: 0;55 }56 }57 }5859 &--placement-right {60 &.BHT__Drawer {61 right: 0;62 }63 }6465 &--placement-left {66 &.BHT__Drawer {67 left: 0;68 }69 }7071 &--placement-top,72 &--placement-bottom {73 &.BHT__Drawer {74 position: fixed;75 left: 0;76 width: 100vw;77 }7879 .BHT__Drawer {80 &__panel {81 width: 100%;82 max-height: min(20rem, 90vh);83 --card-radius: 0;84 }85 }86 }8788 &--placement-top {89 &.BHT__Drawer {90 top: 0;91 }92 }9394 &--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";23export const BasicDrawer = () => (4 <Drawer onClose={() => {}} title="Add Block" placement="right">5 Panel content6 </Drawer>7);89export const DrawerWithoutTitle = () => (10 <Drawer onClose={() => {}} placement="right">11 Panel content, no header12 </Drawer>13);1415export const NonBlockingRightPanelDrawer = () => (16 <Drawer onClose={() => {}} title="Block" placement="right" backdrop={false}>17 Non-blocking side panel content18 </Drawer>19);2021export const BottomSheetDrawer = () => (22 <Drawer onClose={() => {}} title="Filters" placement="bottom">23 Slides up from the bottom edge24 </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";45describe("Drawer", () => {6 it("renders children", () => {7 render(8 <Drawer onClose={() => {}} placement="right">9 Hello10 </Drawer>,11 );12 expect(screen.getByText("Hello")).toBeTruthy();13 });1415 it("renders a title and close button when title is provided", () => {16 render(17 <Drawer onClose={() => {}} title="Add Block" placement="right">18 Content19 </Drawer>,20 );21 expect(screen.getByText("Add Block")).toBeTruthy();22 expect(screen.getByRole("button", { name: "Close" })).toBeTruthy();23 });2425 it("renders no header when title is omitted", () => {26 render(27 <Drawer onClose={() => {}} placement="right">28 Content29 </Drawer>,30 );31 expect(screen.queryByRole("button", { name: "Close" })).toBeNull();32 });3334 it("renders the title in an h3 by default", () => {35 render(36 <Drawer onClose={() => {}} title="Add Block" placement="right">37 Content38 </Drawer>,39 );40 expect(41 screen.getByRole("heading", { level: 3, name: "Add Block" }),42 ).toBeTruthy();43 });4445 it("renders the title in the heading level given by headingLevel", () => {46 render(47 <Drawer48 onClose={() => {}}49 title="Add Block"50 headingLevel={2}51 placement="right"52 >53 Content54 </Drawer>,55 );56 expect(57 screen.getByRole("heading", { level: 2, name: "Add Block" }),58 ).toBeTruthy();59 });6061 it("calls onClose when the backdrop is clicked", async () => {62 // Drawer renders via a Radix Portal, appended to document.body rather than63 // into the render() container, so query from the document instead.64 const onClose = vi.fn();65 render(66 <Drawer onClose={onClose} placement="right">67 Content68 </Drawer>,69 );70 await userEvent.click(71 document.body.querySelector('[class*="backdrop"]') as HTMLElement,72 );73 expect(onClose).toHaveBeenCalledTimes(1);74 });7576 it("does not render a backdrop when backdrop is false", () => {77 render(78 <Drawer onClose={() => {}} backdrop={false} placement="right">79 Content80 </Drawer>,81 );82 expect(document.body.querySelector('[class*="backdrop"]')).toBeNull();83 });8485 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 Content90 </Drawer>,91 );92 await userEvent.click(screen.getByText("Content"));93 expect(onClose).not.toHaveBeenCalled();94 });9596 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 Content101 </Drawer>,102 );103 await userEvent.click(screen.getByRole("button", { name: "Close" }));104 expect(onClose).toHaveBeenCalledTimes(1);105 });106107 it("calls onClose when Escape is pressed", () => {108 const onClose = vi.fn();109 render(110 <Drawer onClose={onClose} placement="right">111 Content112 </Drawer>,113 );114 fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" });115 expect(onClose).toHaveBeenCalledTimes(1);116 });117118 it("applies additional className", () => {119 render(120 <Drawer onClose={() => {}} className="custom" placement="right">121 Content122 </Drawer>,123 );124 expect(screen.getByRole("dialog").className).toContain("custom");125 });126127 it("applies the placement modifier class", () => {128 render(129 <Drawer onClose={() => {}} placement="right">130 Content131 </Drawer>,132 );133 expect(screen.getByRole("dialog").className).toContain(134 "BHT__Drawer--placement-right",135 );136 });137});