diff --git a/.changeset/drawer.md b/.changeset/drawer.md new file mode 100644 index 0000000..a8ace02 --- /dev/null +++ b/.changeset/drawer.md @@ -0,0 +1,43 @@ +--- +'@dunky.dev/drawer': minor +'@dunky.dev/react-drawer': minor +--- + +Add the Drawer primitive — a modal panel that slides in from a screen edge, +semantically a dialog with a placement, shipped as an agnostic core +(`@dunky.dev/drawer`) plus a React binding (`@dunky.dev/react-drawer`). + +The dialog contract carries over — focus trap, scroll lock, Escape and +outside-press dismissal (each gateable and vetoable), topmost-layer-first +stacking through the shared layer stack, and the controlled `open` contract: +a controlled drawer reports every open/close intent through `onOpenChange` +but only moves when the prop does, so ignoring a report is a working veto. +`placement` picks the edge the panel is anchored to +(`left | right | top | bottom`, defaulting to `right`). +Placement is pure configuration: it never gates behavior, it rides along as a +`data-placement` styling hook on the visual layers (Backdrop, Viewport, +Content) next to `data-state`, so slide animations key off attributes. A +drawer is always modal in v0 — there is no `modal` or `role` switch; snap +points and swipe gestures are out of scope by decision. + +```tsx +import { Drawer } from '@dunky.dev/react-drawer' + +function App() { + return ( + + Settings + + + + + Board settings + Configure this board. + Done + + + + + ) +} +``` diff --git a/packages/core/drawer/README.md b/packages/core/drawer/README.md new file mode 100644 index 0000000..ff3d1e3 --- /dev/null +++ b/packages/core/drawer/README.md @@ -0,0 +1,27 @@ +# @dunky.dev/drawer + +The framework-agnostic drawer interaction, modeled as a state machine on +`@dunky.dev/state-machine`. Pure logic — no substrate, no framework. Consumers +pair it with a substrate driver rather than driving the machine directly. + +The behavior contract — scenarios, guarantees, driver obligations — lives in +[SPEC.md](./SPEC.md). + +## Install + +```sh +npm install @dunky.dev/drawer +``` + +## Usage + +```ts +import { machine, connector } from '@dunky.dev/state-machine' +import { drawerMachine, drawerConnect } from '@dunky.dev/drawer' + +// `id` is substrate-minted (SSR-safe); the connect derives the per-part ids. +const service = machine(drawerMachine({ ...options, id: 'my-drawer' })) +connector(service, drawerConnect, options) // wires the consumer callbacks +service.start() +service.send({ type: 'toggle' }) +``` diff --git a/packages/core/drawer/SPEC.md b/packages/core/drawer/SPEC.md new file mode 100644 index 0000000..4dd0c05 --- /dev/null +++ b/packages/core/drawer/SPEC.md @@ -0,0 +1,170 @@ +# SPEC / Drawer + +## Reference + +- **W3C pattern**: [APG Dialog (Modal)](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/), + over the normative + [WAI-ARIA 1.2 `dialog` / `aria-modal`](https://www.w3.org/TR/wai-aria-1.2/#dialog) + definitions — a drawer is semantically a modal dialog with a placement. +- **State machine**: built on `@dunky.dev/state-machine`. +- **Prior art**: API shape modeled on the Ark and Chakra drawers; the shared + dialog conventions follow the Radix, Base UI, and Ark dialogs. + +## Overview + +A drawer is a modal panel that slides in from an edge of the screen and takes +over interaction until the user completes or dismisses it. Semantically it is +a dialog; what makes it a drawer is the placement — the panel is anchored to +one edge (left, right, top, or bottom) instead of floating over the center. +It is the home for edge-anchored workflows: settings panels, filters, carts, +and navigation that need the page kept visually in context behind them. + +## Anatomy + +``` + — root; owns open/close state, renders nothing of its own + |_ — the control that opens the drawer; focus returns here on close + |_ — the layer behind the drawer panel, covering the page + |_ — the positioning layer that anchors the panel to its edge + |_ — the drawer panel itself + |_ — names the drawer + |_ <Description> — describes the drawer + |_ <Close> — the in-drawer dismissal affordance +``` + +## Behavior + +Using the drawer is a walkthrough of intent, not a prop list: + +- The **root** owns open/close state, exposed controlled and uncontrolled, + mirroring native patterns: an uncontrolled drawer can be seeded open, while a + controlled consumer drives it from outside. Every open/close intent — + trigger press, Escape, outside press, imperative close — is reported back; + a controlled drawer stops there and only moves when the `open` prop does, + so ignoring a reported intent is how the consumer vetoes it. Whether the + drawer is controlled is fixed at mount. The root also fixes the + **placement**: which edge the panel is anchored to, defaulting to the right + (the trailing-edge convention drawers share). Placement is configuration, + not state — it never changes while the drawer is open, and every visual part + carries it as a `data-placement` styling hook next to `data-state`. +- The **trigger** toggles the drawer and carries the popup relationship to + assistive tech. It is also the element focus returns to on close. +- Pressing the **backdrop** — or the **viewport** area around the panel — + counts as an "outside interaction"; whether that dismisses follows the + drawer's dismissal settings, whichever layer was pressed. Presses inside the + content never count as outside. +- The **content** is the drawer panel, labelled and described by the Title and + Description parts when they are rendered. +- **Title / Description** name and describe the drawer. The drawer's ARIA name + and description always follow what is actually rendered — an omitted part + never leaves a dangling reference. A drawer should always have a Title (APG + requires an accessible name); when it genuinely can't, an accessible label + goes on the content instead. +- **Close** dismisses from inside — the visible close affordance the APG + strongly recommends alongside Escape. + +Dismissal is configurable at the root: Escape closing and outside-press closing +can each be toggled off, and the consumer can veto a single occurrence of +either from its handler. + +Drawers stack with other overlay layers — a dialog opened from within a drawer +(or a drawer from a dialog) sits on top of it, and the stack unwinds one layer +at a time. The full contract is [Nesting](#nesting) under Accessibility. + +## States + +| State | Behavior | +| -------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `closed` | Nothing is shown beyond the trigger. Open intents (trigger press, imperative open) move to `open`. | +| `open` | Backdrop and content are shown. Close intents close unconditionally; Escape and outside-press close only if their respective settings allow it. | + +### Title/Description presence + +A Title or Description can appear or disappear at any time, open or closed — +the ARIA relationships on Content always follow what is actually rendered. + +## Accessibility + +Per APG Dialog (Modal) — a drawer is always modal: + +- **Roles**: Content is `dialog` with `aria-modal`. +- **Name**: the drawer is labelled by the rendered Title, or by an accessible + label on Content in the no-title case. One of the two must be present. +- **Focus**: trapped inside the drawer while open — the full contract is + [Focus trap](#focus-trap) below. +- **Keyboard**: Escape closes (unless gated/vetoed). A visible Close button is + part of the composition guidance, not just Escape. +- **Scroll**: the page behind an open drawer doesn't scroll, and hiding its + scrollbar doesn't shift the page layout. +- **Nesting**: only the topmost layer of a stack exists for the user — the + full contract is [Nesting](#nesting) below. + +### Focus trap + +Per the [APG modal-dialog keyboard interaction](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/#keyboardinteraction): + +- **When it applies**: while the drawer is open. In a stack only the topmost + layer traps — the ones beneath it are unreachable until it closes. +- **On open**: focus moves into the drawer — that is the strict rule. The + consumer-designated initial-focus element wins when one is set and can take + focus; otherwise a drawer that collects input starts at its first form field + (input, select, textarea), and any other drawer focuses the panel itself + (Content), which is focusable in script but not in the tab order. +- **While open**: Tab moves forward through the drawer's focusables and wraps + from the last back to the first; Shift+Tab moves backward and wraps from the + first — or from the panel itself — to the last. Focus never tabs out of the + drawer. +- **No focusables**: Tab is a no-op; focus stays on the panel. +- **On close**: focus returns to the element focused before opening (normally + the Trigger). + +### Nesting + +Drawers join the shared overlay layer stack (`@dunky.dev/dom-layer-stack`), so +they stack with any overlay primitive — another drawer, a dialog — under one +contract. Each layer stays fully independent: its own open/close state, +dismissal settings, and open/close reporting. + +- **Topmost only**: only the topmost layer is interactive and exposed to + assistive technology; everything beneath the topmost modal layer is hidden + and unreachable, by pointer, keyboard, or screen reader. +- **Escape**: dismisses only the topmost layer, subject to that layer's own + dismissal settings — a stack unwinds one layer per press. +- **Outside press**: pressing around the topmost layer is an outside + interaction for that layer alone; the layers beneath are unaffected. +- **Unwinding**: when the topmost layer closes, the one beneath becomes + topmost again — re-exposed, interactive, with focus restored to the element + focused before the closed layer opened. +- **Scroll**: the page stays scroll-locked until the last modal layer in the + stack closes. + +## Constraints + +- Content must always resolve an accessible name — from a rendered Title or an + accessible label — never neither. +- ARIA labelled-by / described-by must only reference elements that are + actually rendered. +- While open, focus stays trapped within the drawer; on close it returns to + the element focused before opening. +- Every open ⇄ close intent, whatever its source, is reported to the + consumer. A controlled drawer never transitions on its own — it follows the + `open` prop alone, and a prop-driven transition is not echoed back. +- Placement is fixed configuration: it selects an edge and rides along as a + styling hook; it never gates behavior. +- Out of scope for v0, by decision: snap points, swipe/drag gestures, and a + non-modal mode. A drawer is always modal — `aria-modal`, focus trap, scroll + lock, backdrop — until a future version says otherwise. + +## Internals + +- **A drawer is a dialog with placement, not a dialog option.** The overlap + with the dialog core is intentional and the packages stay independent — + primitives never cross-import; the shared mechanics (layer stack, focus + trap, scroll lock) live in the shared DOM utils instead. +- **Placement lives in context, not in the state graph.** It changes nothing + about transitions or gating; the connect stamps it on the visual parts + (backdrop, viewport, content) as `data-placement` so styling and slide + animations key off it. +- **Always modal.** Dropping the `modal` and `role` switches keeps the v0 + surface honest: there is no non-modal drawer to configure, and the panel is + always a plain `dialog` (urgent interruptions are the alert dialog's job). diff --git a/packages/core/drawer/package.json b/packages/core/drawer/package.json new file mode 100644 index 0000000..c0bcb63 --- /dev/null +++ b/packages/core/drawer/package.json @@ -0,0 +1,40 @@ +{ + "name": "@dunky.dev/drawer", + "version": "0.0.0", + "description": "Framework-agnostic drawer interaction, modeled as a state machine.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/dunky-dev/ui.git", + "directory": "packages/core/drawer" + }, + "files": [ + "dist" + ], + "type": "module", + "sideEffects": false, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "publishConfig": { + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "access": "public" + }, + "scripts": { + "build": "tsdown" + }, + "dependencies": { + "@dunky.dev/state-machine": "^0.1.0", + "@dunky.dev/state-machine-bindings": "^0.1.0" + } +} diff --git a/packages/core/drawer/src/connect.ts b/packages/core/drawer/src/connect.ts new file mode 100644 index 0000000..ebf1ea6 --- /dev/null +++ b/packages/core/drawer/src/connect.ts @@ -0,0 +1,126 @@ +import { makeReaction, type Connect } from '@dunky.dev/state-machine' +import type { AttrBindings, EventBindings, PointerPayload } from '@dunky.dev/state-machine-bindings' +import type { + DrawerContext, + DrawerIds, + DrawerMachineEvent, + DrawerOptions, + DrawerPlacement, + DrawerStateName, +} from './types' + +// The bindings a part carries, drawn from the shared agnostic vocabulary; the +// index signature keeps parts assignable to the loose shape each substrate's +// normalize() accepts. `data-state` is the styling/animation hook; +// `data-placement` keys edge-specific styling on the visual layers. +export type DrawerPartBindings = EventBindings & + AttrBindings & { + 'data-state'?: DrawerStateName + 'data-placement'?: DrawerPlacement + } & Record<string, unknown> + +// The cross-part ids all derive from the one base id, so the trigger's +// aria-controls, Content's id, and the labelledby/describedby always agree. +function drawerIds(id: string): DrawerIds { + return { content: `${id}-content`, title: `${id}-title`, description: `${id}-description` } +} + +/** The view-facing surface a driver reads from the running drawer machine. */ +export interface DrawerApi { + open: boolean + placement: DrawerPlacement + ids: DrawerIds + setOpen: (open: boolean) => void + parts: { + trigger: DrawerPartBindings + backdrop: DrawerPartBindings + viewport: DrawerPartBindings + content: DrawerPartBindings + title: DrawerPartBindings + description: DrawerPartBindings + close: DrawerPartBindings + } +} + +export const drawerConnect: Connect< + DrawerStateName, + DrawerContext, + DrawerMachineEvent, + DrawerOptions, + DrawerApi +> = ({ state, context, props, send }) => { + const open = state === 'open' + const dataState: DrawerStateName = open ? 'open' : 'closed' + const placement = context.placement + const ids = drawerIds(context.id) + + // An outside press is an intent, not a close command: the consumer may veto + // it; whether it dismisses is gated in the machine. + const onOutsidePress = (event?: PointerPayload): void => { + props.onInteractOutside?.(event) + if (event?.defaultPrevented !== true) send({ type: 'interact.outside' }) + } + + return { + open, + placement, + ids, + setOpen(next) { + if (open === next) return + send({ type: next ? 'open' : 'close' }) + }, + parts: { + trigger: { + hasPopup: 'dialog', + expanded: open, + // A dangling aria-controls id is an a11y defect — only while open. + controls: open ? ids.content : undefined, + 'data-state': dataState, + onPress: () => send({ type: 'toggle' }), + }, + backdrop: { + 'data-state': dataState, + 'data-placement': placement, + onPress: onOutsidePress, + }, + viewport: { + 'data-state': dataState, + 'data-placement': placement, + onPress: onOutsidePress, + }, + content: { + role: 'dialog', + id: ids.content, + // A drawer is always modal (see SPEC.md, Design). + modal: true, + labelledBy: context.parts.title ? ids.title : undefined, + describedBy: context.parts.description ? ids.description : undefined, + // The initial focus target: focusable in script, not in the tab order. + focusable: false, + 'data-state': dataState, + 'data-placement': placement, + }, + title: { id: ids.title }, + description: { id: ids.description }, + close: { + onPress: () => send({ type: 'close' }), + }, + }, + } +} + +const reaction = makeReaction<DrawerStateName, DrawerContext, DrawerMachineEvent, DrawerOptions>() + +// One reaction per consumer callback. Reactions fire in registration order within +// a single setContext — that order is the callback-order contract. See SPEC.md. +// onOpenChange reads the intent mailbox, not the state: a controlled machine +// reports intents without moving, and the prop-driven `controlled.sync` +// transition never writes the mailbox — the consumer's own change isn't echoed. +drawerConnect.reactions = [ + reaction( + m => m.context.openIntent, + (intent, props) => { + if (intent !== null) props.onOpenChange?.(intent.open) + }, + ), +] diff --git a/packages/core/drawer/src/index.ts b/packages/core/drawer/src/index.ts new file mode 100644 index 0000000..891cde7 --- /dev/null +++ b/packages/core/drawer/src/index.ts @@ -0,0 +1,13 @@ +export { drawerMachine, type DrawerMachine } from './machine' +export { drawerConnect, type DrawerApi, type DrawerPartBindings } from './connect' +export type { + DrawerCallbacks, + DrawerContext, + DrawerIds, + DrawerMachineEvent, + DrawerOptions, + DrawerPart, + DrawerPlacement, + DrawerStateName, +} from './types' +export type { KeyboardPayload, PointerPayload } from '@dunky.dev/state-machine-bindings' diff --git a/packages/core/drawer/src/machine.ts b/packages/core/drawer/src/machine.ts new file mode 100644 index 0000000..38a09aa --- /dev/null +++ b/packages/core/drawer/src/machine.ts @@ -0,0 +1,97 @@ +import { + and, + setup, + type Action, + type Guard, + type Machine, + type TransitionConfig, +} from '@dunky.dev/state-machine' +import type { DrawerContext, DrawerMachineEvent, DrawerOptions, DrawerStateName } from './types' + +/** The running drawer machine — what a substrate holds and sends events to. */ +export type DrawerMachine = Machine<DrawerStateName, DrawerContext, DrawerMachineEvent> + +type DrawerAction = Action<DrawerContext, DrawerMachineEvent> +type DrawerGuard = Guard<DrawerContext, DrawerMachineEvent> + +const isControlled: DrawerGuard = ({ context }) => context.controlled +const canEscape: DrawerGuard = ({ context }) => context.closeOnEscape +const canDismissOutside: DrawerGuard = ({ context }) => context.closeOnInteractOutside +const syncOpens: DrawerGuard = ({ event }) => event.type === 'controlled.sync' && event.open +const syncCloses: DrawerGuard = ({ event }) => event.type === 'controlled.sync' && !event.open + +// Every open/close intent lands in the mailbox; the connect's reaction turns +// it into onOpenChange. Uncontrolled, the intent rides along with the +// transition; controlled, the intent IS the outcome — the machine stays put +// until `controlled.sync` echoes the prop back. +const requestOpen: DrawerAction = ({ setContext }) => setContext({ openIntent: { open: true } }) +const requestClose: DrawerAction = ({ setContext }) => setContext({ openIntent: { open: false } }) + +const setPartPresence: DrawerAction = ({ event, context, setContext }) => { + if (event.type !== 'part.presence') return + setContext({ parts: { ...context.parts, [event.part]: event.present } }) +} + +export function drawerMachine( + options: DrawerOptions, +): TransitionConfig<DrawerStateName, DrawerContext, DrawerMachineEvent> { + // Annotated so createMachine infers Context as DrawerContext, not the narrowed literal. + const context: DrawerContext = { + // The trailing-edge convention drawers share. + placement: options.placement ?? 'right', + closeOnEscape: options.closeOnEscape ?? true, + closeOnInteractOutside: options.closeOnInteractOutside ?? true, + controlled: options.open !== undefined, + openIntent: null, + // The substrate supplies a unique id; `drawer` is only a bare fallback. + id: options.id ?? 'drawer', + parts: { title: false, description: false }, + } + + // Each intent event lists two candidates — first guard wins: controlled + // only writes the mailbox; uncontrolled also takes the transition. + return setup.as<DrawerContext, DrawerMachineEvent>().createMachine({ + initial: (options.open ?? options.defaultOpen) === true ? 'open' : 'closed', + context, + // Top-level: parts report their presence from any state. + on: { + 'part.presence': { actions: setPartPresence }, + }, + states: { + closed: { + on: { + open: [ + { guard: isControlled, actions: requestOpen }, + { target: 'open', actions: requestOpen }, + ], + toggle: [ + { guard: isControlled, actions: requestOpen }, + { target: 'open', actions: requestOpen }, + ], + 'controlled.sync': { target: 'open', guard: syncOpens }, + }, + }, + open: { + on: { + close: [ + { guard: isControlled, actions: requestClose }, + { target: 'closed', actions: requestClose }, + ], + toggle: [ + { guard: isControlled, actions: requestClose }, + { target: 'closed', actions: requestClose }, + ], + escape: [ + { guard: and(canEscape, isControlled), actions: requestClose }, + { guard: canEscape, target: 'closed', actions: requestClose }, + ], + 'interact.outside': [ + { guard: and(canDismissOutside, isControlled), actions: requestClose }, + { guard: canDismissOutside, target: 'closed', actions: requestClose }, + ], + 'controlled.sync': { target: 'closed', guard: syncCloses }, + }, + }, + }, + }) +} diff --git a/packages/core/drawer/src/types.ts b/packages/core/drawer/src/types.ts new file mode 100644 index 0000000..869f6fb --- /dev/null +++ b/packages/core/drawer/src/types.ts @@ -0,0 +1,86 @@ +// Public + machine-facing types for the framework-agnostic drawer primitive. +// The state machine is substrate-free: all event reading lives in a +// per-substrate driver. +import type { KeyboardPayload, PointerPayload } from '@dunky.dev/state-machine-bindings' + +export type DrawerStateName = 'closed' | 'open' + +/** The screen edge the drawer panel is anchored to. */ +export type DrawerPlacement = 'left' | 'right' | 'top' | 'bottom' + +/** The parts whose presence drives the ARIA relationships on Content. */ +export type DrawerPart = 'title' | 'description' + +/** + * The cross-part ids, derived from the one `id` on context: each renders as + * `id` on one element and as an ARIA reference (aria-controls / labelledby / + * describedby) on another, and the connect wires both sides. + */ +export interface DrawerIds { + content: string + title: string + description: string +} + +export interface DrawerContext { + placement: DrawerPlacement + closeOnEscape: boolean + closeOnInteractOutside: boolean + // Whether the consumer controls `open`. Fixed at build time: a controlled + // machine never moves on its own — intents go out through `openIntent` and + // only `controlled.sync` (the prop echo) transitions it. + controlled: boolean + // Emission mailbox for onOpenChange: a fresh token per intent so the + // reaction fires even when the intended value repeats. + openIntent: { open: boolean } | null + // The base id (substrate-minted, SSR-safe); the connect derives the per-part + // ids from it. + id: string + // Which optional parts are currently present, so Content only references the + // ones actually rendered. + parts: Record<DrawerPart, boolean> +} + +// Dismissal intents (`escape` / `interact.outside`) are distinct from `close` +// so the machine can gate them. `controlled.sync` is the controlled driver: +// the substrate sends it when the `open` prop changes, and it is the only +// event that moves a controlled machine. +export type DrawerMachineEvent = + | { type: 'open' } + | { type: 'close' } + | { type: 'toggle' } + | { type: 'escape' } + | { type: 'interact.outside' } + | { type: 'controlled.sync'; open: boolean } + | { type: 'part.presence'; part: DrawerPart; present: boolean } + +export interface DrawerCallbacks { + /** Fired on every open/close transition with the new value. */ + onOpenChange?: (open: boolean) => void + /** Fired before an Escape dismissal; `preventDefault()` vetoes it. */ + onEscapeKeyDown?: (event: KeyboardPayload) => void + /** Fired before an outside-press dismissal; `preventDefault()` vetoes it. */ + onInteractOutside?: (event?: PointerPayload) => void +} + +/** + * The agnostic drawer options — the behavior a consumer configures. A + * substrate's props extend this with its own concerns (e.g. `children`). + * A drawer is always modal — there is no `modal` or `role` switch (see + * SPEC.md, Design). + */ +export interface DrawerOptions extends DrawerCallbacks { + /** Base id for the drawer's parts; the substrate supplies a unique, SSR-safe + * one. The per-part ids (content/title/description) are derived from it. */ + id?: string + /** Controlled open state; every open/close intent is reported through `onOpenChange`. */ + open?: boolean + /** Initial open state for the uncontrolled drawer. @default false */ + defaultOpen?: boolean + /** The screen edge the panel is anchored to. @default 'right' */ + placement?: DrawerPlacement + /** Whether Escape closes the drawer. @default true */ + closeOnEscape?: boolean + /** Whether pressing the backdrop closes the drawer. @default true */ + closeOnInteractOutside?: boolean +} diff --git a/packages/core/drawer/tests/machine.test.ts b/packages/core/drawer/tests/machine.test.ts new file mode 100644 index 0000000..b58d69d --- /dev/null +++ b/packages/core/drawer/tests/machine.test.ts @@ -0,0 +1,275 @@ +// The agnostic core of the Drawer — machine + connect, no DOM, no framework. +import { describe, expect, it, vi } from 'vitest' +import { connector, machine, type Connector, type Machine } from '@dunky.dev/state-machine' +import { drawerMachine, drawerConnect } from '@dunky.dev/drawer' +import type { + DrawerApi, + DrawerContext, + DrawerIds, + DrawerMachineEvent, + DrawerOptions, + DrawerStateName, + PointerPayload, +} from '@dunky.dev/drawer' + +// The per-part ids the connect derives from a base id of `drw`. +const ids: DrawerIds = { + content: 'drw-content', + title: 'drw-title', + description: 'drw-description', +} + +interface Harness { + service: Machine<DrawerStateName, DrawerContext, DrawerMachineEvent> + connection: Connector<DrawerStateName, DrawerContext, DrawerApi, DrawerOptions> +} + +const build = (options: DrawerOptions = {}): Harness => { + const service = machine(drawerMachine({ id: 'drw', ...options })) + const connection = connector(service, drawerConnect, options) + service.start() + return { service, connection } +} + +describe('drawer machine — open/close', () => { + it('starts closed by default', () => { + const { service } = build() + expect(service.state).toBe('closed') + }) + + it('starts open when defaultOpen', () => { + const { service } = build({ defaultOpen: true }) + expect(service.state).toBe('open') + }) + + it('starts open when controlled open=true', () => { + const { service } = build({ open: true }) + expect(service.state).toBe('open') + }) + + it('toggle flips closed -> open -> closed', () => { + const { service } = build() + service.send({ type: 'toggle' }) + expect(service.state).toBe('open') + service.send({ type: 'toggle' }) + expect(service.state).toBe('closed') + }) + + it('open/close events are one-directional (open while open is a no-op)', () => { + const { service } = build({ defaultOpen: true }) + service.send({ type: 'open' }) + expect(service.state).toBe('open') + service.send({ type: 'close' }) + expect(service.state).toBe('closed') + service.send({ type: 'close' }) + expect(service.state).toBe('closed') + }) +}) + +describe('drawer machine — placement', () => { + it('defaults placement to right', () => { + const { service } = build() + expect(service.context.placement).toBe('right') + }) + + it('seeds the configured placement into context', () => { + const { service } = build({ placement: 'bottom' }) + expect(service.context.placement).toBe('bottom') + }) +}) + +describe('drawer machine — dismissal gating', () => { + it('escape closes when closeOnEscape (the default)', () => { + const { service } = build({ defaultOpen: true }) + service.send({ type: 'escape' }) + expect(service.state).toBe('closed') + }) + + it('escape is ignored when closeOnEscape=false', () => { + const { service } = build({ defaultOpen: true, closeOnEscape: false }) + service.send({ type: 'escape' }) + expect(service.state).toBe('open') + }) + + it('interact.outside closes when closeOnInteractOutside (the default)', () => { + const { service } = build({ defaultOpen: true }) + service.send({ type: 'interact.outside' }) + expect(service.state).toBe('closed') + }) + + it('interact.outside is ignored when closeOnInteractOutside=false', () => { + const { service } = build({ defaultOpen: true, closeOnInteractOutside: false }) + service.send({ type: 'interact.outside' }) + expect(service.state).toBe('open') + }) +}) + +describe('drawer machine — part registration', () => { + it('tracks title/description presence in context', () => { + const { service } = build({ defaultOpen: true }) + expect(service.context.parts.title).toBe(false) + expect(service.context.parts.description).toBe(false) + + service.send({ type: 'part.presence', part: 'title', present: true }) + service.send({ type: 'part.presence', part: 'description', present: true }) + expect(service.context.parts.title).toBe(true) + expect(service.context.parts.description).toBe(true) + + service.send({ type: 'part.presence', part: 'title', present: false }) + expect(service.context.parts.title).toBe(false) + expect(service.context.parts.description).toBe(true) + }) + + it('registration works in any state (top-level events)', () => { + const { service } = build() + service.send({ type: 'part.presence', part: 'title', present: true }) + expect(service.state).toBe('closed') + expect(service.context.parts.title).toBe(true) + }) +}) + +describe('drawer connect — logical bindings', () => { + it('trigger carries the popup relationship and toggles', () => { + const { service, connection } = build() + const trigger = connection.snapshot.parts.trigger + expect(trigger.hasPopup).toBe('dialog') + expect(trigger.expanded).toBe(false) + expect(trigger.controls).toBeUndefined() // nothing to control while closed + + trigger.onPress?.() + expect(service.state).toBe('open') + + const openTrigger = connection.snapshot.parts.trigger + expect(openTrigger.expanded).toBe(true) + expect(openTrigger.controls).toBe(ids.content) + + openTrigger.onPress?.() + expect(service.state).toBe('closed') + }) + + it('content carries the dialog role, modality, and identity', () => { + const { connection } = build({ defaultOpen: true }) + const content = connection.snapshot.parts.content + expect(content.role).toBe('dialog') + expect(content.modal).toBe(true) // a drawer is always modal + expect(content.id).toBe(ids.content) + expect(content.focusable).toBe(false) // the focus target: tabIndex -1 + }) + + it('labelledBy/describedBy only reference rendered parts', () => { + const { service, connection } = build({ defaultOpen: true }) + expect(connection.snapshot.parts.content.labelledBy).toBeUndefined() + expect(connection.snapshot.parts.content.describedBy).toBeUndefined() + + service.send({ type: 'part.presence', part: 'title', present: true }) + service.send({ type: 'part.presence', part: 'description', present: true }) + expect(connection.snapshot.parts.content.labelledBy).toBe(ids.title) + expect(connection.snapshot.parts.content.describedBy).toBe(ids.description) + + expect(connection.snapshot.parts.title.id).toBe(ids.title) + expect(connection.snapshot.parts.description.id).toBe(ids.description) + }) + + it('backdrop press dismisses through the machine', () => { + const { service, connection } = build({ defaultOpen: true }) + connection.snapshot.parts.backdrop.onPress?.() + expect(service.state).toBe('closed') + }) + + it('viewport press is the same outside interaction as the backdrop', () => { + const { service, connection } = build({ defaultOpen: true }) + expect(connection.snapshot.parts.viewport['data-state']).toBe('open') + connection.snapshot.parts.viewport.onPress?.() + expect(service.state).toBe('closed') + }) + + it('backdrop press honors the onInteractOutside veto', () => { + const onInteractOutside = vi.fn((event?: PointerPayload) => event?.preventDefault?.()) + const { service, connection } = build({ defaultOpen: true, onInteractOutside }) + + const payload: PointerPayload = { + defaultPrevented: false, + preventDefault() { + this.defaultPrevented = true + }, + } + connection.snapshot.parts.backdrop.onPress?.(payload) + + expect(onInteractOutside).toHaveBeenCalledTimes(1) + expect(service.state).toBe('open') // vetoed + }) + + it('close part closes; setOpen drives both directions', () => { + const { service, connection } = build({ defaultOpen: true }) + connection.snapshot.parts.close.onPress?.() + expect(service.state).toBe('closed') + + connection.snapshot.setOpen(true) + expect(service.state).toBe('open') + connection.snapshot.setOpen(false) + expect(service.state).toBe('closed') + }) + + it('parts expose data-state for styling/animation', () => { + const { connection } = build({ defaultOpen: true }) + expect(connection.snapshot.parts.content['data-state']).toBe('open') + expect(connection.snapshot.parts.trigger['data-state']).toBe('open') + connection.snapshot.setOpen(false) + expect(connection.snapshot.parts.content['data-state']).toBe('closed') + }) + + it('visual layers carry data-placement; non-visual parts do not', () => { + const { connection } = build({ defaultOpen: true, placement: 'left' }) + const { parts } = connection.snapshot + expect(parts.backdrop['data-placement']).toBe('left') + expect(parts.viewport['data-placement']).toBe('left') + expect(parts.content['data-placement']).toBe('left') + expect(parts.trigger['data-placement']).toBeUndefined() + expect(parts.close['data-placement']).toBeUndefined() + }) + + it('exposes the resolved placement on the api', () => { + const { connection } = build() + expect(connection.snapshot.placement).toBe('right') + }) +}) + +describe('drawer connect — reactions', () => { + it('fires onOpenChange on every flip, not on subscribe', () => { + const onOpenChange = vi.fn() + const { service } = build({ onOpenChange }) + expect(onOpenChange).not.toHaveBeenCalled() + + service.send({ type: 'toggle' }) + expect(onOpenChange).toHaveBeenLastCalledWith(true) + service.send({ type: 'escape' }) + expect(onOpenChange).toHaveBeenLastCalledWith(false) + expect(onOpenChange).toHaveBeenCalledTimes(2) + }) +}) + +describe('drawer machine — controlled', () => { + it('reports dismissal intents without moving the machine', () => { + const onOpenChange = vi.fn() + const { service } = build({ open: true, onOpenChange }) + service.send({ type: 'escape' }) + expect(service.state).toBe('open') + expect(onOpenChange).toHaveBeenLastCalledWith(false) + }) + + it('moves only on controlled.sync, and the prop echo is not reported back', () => { + const onOpenChange = vi.fn() + const { service } = build({ open: true, onOpenChange }) + service.send({ type: 'controlled.sync', open: false }) + expect(service.state).toBe('closed') + expect(onOpenChange).not.toHaveBeenCalled() + }) + + it('still gates dismissal intents before reporting them', () => { + const onOpenChange = vi.fn() + const { service } = build({ open: true, closeOnEscape: false, onOpenChange }) + service.send({ type: 'escape' }) + expect(service.state).toBe('open') + expect(onOpenChange).not.toHaveBeenCalled() + }) +}) diff --git a/packages/react/drawer/README.md b/packages/react/drawer/README.md new file mode 100644 index 0000000..b668290 --- /dev/null +++ b/packages/react/drawer/README.md @@ -0,0 +1,40 @@ +# @dunky.dev/react-drawer + +React binding for [`@dunky.dev/drawer`](../../core/drawer): a compound +component — `Drawer` plus its parts — that drives the framework-free drawer +machine. The root owns the machine; parts translate the core's logical +bindings into DOM attributes and handlers, and wire the DOM-only concerns +(portal, focus trap, scroll lock, layer stack). + +Behavior contract: [`../../core/drawer/SPEC.md`](../../core/drawer/SPEC.md). +React-specific surface: [SPEC.md](./SPEC.md). + +## Install + +```sh +npm install @dunky.dev/react-drawer +``` + +## Usage + +```tsx +import { Drawer } from '@dunky.dev/react-drawer' + +function BoardSettings() { + return ( + <Drawer placement='right'> + <Drawer.Trigger>Settings</Drawer.Trigger> + <Drawer.Portal> + <Drawer.Backdrop /> + <Drawer.Viewport> + <Drawer.Content> + <Drawer.Title>Board settings</Drawer.Title> + <Drawer.Description>Configure this board.</Drawer.Description> + <Drawer.Close>Done</Drawer.Close> + </Drawer.Content> + </Drawer.Viewport> + </Drawer.Portal> + </Drawer> + ) +} +``` diff --git a/packages/react/drawer/SPEC.md b/packages/react/drawer/SPEC.md new file mode 100644 index 0000000..6f4468f --- /dev/null +++ b/packages/react/drawer/SPEC.md @@ -0,0 +1,137 @@ +# SPEC / React / Drawer + +The React implementation of the [core spec](../../core/drawer/SPEC.md). + +## Docs + +🔗 [`dunky.dev/ui/components/drawer`](https://dunky.dev/ui/components/drawer). + +## Install + +```sh +npm install @dunky.dev/react-drawer +``` + +## Usage + +```tsx +import { Drawer } from '@dunky.dev/react-drawer' +;<Drawer placement='right'> + <Drawer.Trigger>Open</Drawer.Trigger> + <Drawer.Portal> + <Drawer.Backdrop /> + <Drawer.Viewport> + <Drawer.Content> + <Drawer.Title>Title</Drawer.Title> + <Drawer.Description>Description</Drawer.Description> + <Drawer.Close>Close</Drawer.Close> + </Drawer.Content> + </Drawer.Viewport> + </Drawer.Portal> +</Drawer> +``` + +React-specific notes on top of the core contract: + +- **`Portal`** teleports the layers to `document.body`, or to a `container` + you supply. Nothing is kept mounted while closed. When scoped to a + `container`, the scroll lock applies to that container instead of the page, + and the backdrop/viewport must be positioned `absolute` (not `fixed`) so the + overlay pins to the container. +- **`Content`** renders the native `<dialog>` element, always with the `open` + attribute since it only mounts while the drawer is open. It is shown without + `showModal()` on purpose: modality, dismissal, and focus stay driven by the + core contract, consistent across browsers, instead of splitting authority + with the browser's built-in dialog behavior. +- Everything ships headless — parts carry behavior, ARIA wiring, and the + styling hooks: `data-state` (`open` / `closed`) on the Trigger and the + visual layers (Backdrop, Viewport, Content), plus `data-placement` + (`left` / `right` / `top` / `bottom`) on the visual layers for + edge-specific styling and slide animations. The panel doesn't slide by + itself — the consumer keys the transition off those attributes. + +## API + +### `Drawer` + +The root: owns open/close state, renders no DOM. Accepts the core +`DrawerOptions`. + +| Prop | Type | Default | Description | +| ------------------------ | ---------------------------------------- | -------------- | ------------------------------------------------------------------- | +| `open` | `boolean` | — | Controlled open state. | +| `defaultOpen` | `boolean` | `false` | Initial open state for the uncontrolled drawer. | +| `onOpenChange` | `(open: boolean) => void` | — | Fired on every open/close transition with the new value. | +| `placement` | `'left' \| 'right' \| 'top' \| 'bottom'` | `'right'` | The screen edge the panel is anchored to. | +| `closeOnEscape` | `boolean` | `true` | Whether Escape closes the drawer. | +| `closeOnInteractOutside` | `boolean` | `true` | Whether pressing the backdrop/viewport closes the drawer. | +| `onEscapeKeyDown` | `(event) => void` | — | Fired before an Escape dismissal; `preventDefault()` vetoes. | +| `onInteractOutside` | `(event?) => void` | — | Fired before an outside-press dismissal; `preventDefault()` vetoes. | +| `id` | `string` | auto (`useId`) | Base id for the parts; per-part ids are derived from it. | +| `children` | `ReactNode` | — | The drawer's parts. | + +### `Drawer.Trigger` + +Opens the drawer; focus returns here on close. + +| Prop | Type | Default | Description | +| ---------- | -------------------------- | ------- | ------------------------------------- | +| `...props` | `ComponentProps<'button'>` | — | Forwarded to the rendered `<button>`. | + +### `Drawer.Portal` + +Teleports the layers out of the tree while open; unmounts them while closed. + +| Prop | Type | Default | Description | +| ----------- | --------------------- | --------------- | --------------------------- | +| `container` | `HTMLElement \| null` | `document.body` | The element to portal into. | +| `children` | `ReactNode` | — | The layers to teleport. | + +### `Drawer.Backdrop` + +The layer behind the drawer panel, covering the page. + +| Prop | Type | Default | Description | +| ---------- | ----------------------- | ------- | ---------------------------------- | +| `...props` | `ComponentProps<'div'>` | — | Forwarded to the rendered `<div>`. | + +### `Drawer.Viewport` + +The positioning layer that anchors the panel to its edge. + +| Prop | Type | Default | Description | +| ---------- | ----------------------- | ------- | ---------------------------------- | +| `...props` | `ComponentProps<'div'>` | — | Forwarded to the rendered `<div>`. | + +### `Drawer.Content` + +The drawer panel; renders the native `<dialog>`. + +| Prop | Type | Default | Description | +| -------------- | -------------------------------- | ---------------- | ------------------------------------------- | +| `initialFocus` | `RefObject<HTMLElement \| null>` | the drawer panel | The element to focus when the drawer opens. | +| `...props` | `ComponentProps<'dialog'>` | — | Forwarded to the rendered `<dialog>`. | + +### `Drawer.Title` + +Names the drawer (wires `aria-labelledby` on Content). + +| Prop | Type | Default | Description | +| ---------- | ---------------------- | ------- | --------------------------------- | +| `...props` | `ComponentProps<'h2'>` | — | Forwarded to the rendered `<h2>`. | + +### `Drawer.Description` + +Describes the drawer (wires `aria-describedby` on Content). + +| Prop | Type | Default | Description | +| ---------- | --------------------- | ------- | -------------------------------- | +| `...props` | `ComponentProps<'p'>` | — | Forwarded to the rendered `<p>`. | + +### `Drawer.Close` + +Dismisses the drawer from inside. + +| Prop | Type | Default | Description | +| ---------- | -------------------------- | ------- | ------------------------------------- | +| `...props` | `ComponentProps<'button'>` | — | Forwarded to the rendered `<button>`. | diff --git a/packages/react/drawer/package.json b/packages/react/drawer/package.json new file mode 100644 index 0000000..316a8fe --- /dev/null +++ b/packages/react/drawer/package.json @@ -0,0 +1,55 @@ +{ + "name": "@dunky.dev/react-drawer", + "version": "0.0.0", + "description": "React binding for @dunky.dev/drawer.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/dunky-dev/ui.git", + "directory": "packages/react/drawer" + }, + "files": [ + "dist" + ], + "type": "module", + "sideEffects": false, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "publishConfig": { + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "access": "public" + }, + "scripts": { + "build": "tsdown" + }, + "dependencies": { + "@dunky.dev/dom-layer-stack": "workspace:^", + "@dunky.dev/drawer": "workspace:^", + "@dunky.dev/react-state-machine": "^0.1.0", + "@dunky.dev/react-use-focus-trap": "workspace:^", + "@dunky.dev/react-use-layer-stack": "workspace:^", + "@dunky.dev/react-use-scroll-lock": "workspace:^" + }, + "devDependencies": { + "@testing-library/react": "^16.1.0", + "@types/react": "^19.2.15", + "@types/react-dom": "^19.2.3", + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } +} diff --git a/packages/react/drawer/src/context.ts b/packages/react/drawer/src/context.ts new file mode 100644 index 0000000..95792e2 --- /dev/null +++ b/packages/react/drawer/src/context.ts @@ -0,0 +1,25 @@ +import { createContext, useContext, type Context } from 'react' +import type { DrawerApi, DrawerMachine } from '@dunky.dev/drawer' + +export interface DrawerContextValue { + api: DrawerApi + machine: DrawerMachine +} + +export const DrawerContext: Context<DrawerContextValue | undefined> = createContext< + DrawerContextValue | undefined +>(undefined) + +export const useDrawerContext = (): DrawerContextValue => { + const context = useContext(DrawerContext) + if (context === undefined) { + throw new Error('Drawer parts must be rendered within a <Drawer> root') + } + return context +} + +// The element the Portal teleported into, or null for the page body. Content +// reads it to scope the scroll lock to that container instead of the page. +export const DrawerPortalContext: Context<HTMLElement | null> = createContext<HTMLElement | null>( + null, +) diff --git a/packages/react/drawer/src/drawer.tsx b/packages/react/drawer/src/drawer.tsx new file mode 100644 index 0000000..f0e0c93 --- /dev/null +++ b/packages/react/drawer/src/drawer.tsx @@ -0,0 +1,292 @@ +import { + forwardRef, + useContext, + useEffect, + useImperativeHandle, + useRef, + type ComponentPropsWithoutRef, + type ForwardRefExoticComponent, + type MouseEvent, + type ReactNode, + type RefAttributes, + type RefObject, +} from 'react' +import { createPortal } from 'react-dom' +import { isTopmostLayer, registerLayer } from '@dunky.dev/dom-layer-stack' +import { useFocusTrap } from '@dunky.dev/react-use-focus-trap' +import { LayerDepthContext, useLayerDepth } from '@dunky.dev/react-use-layer-stack' +import { useScrollLock } from '@dunky.dev/react-use-scroll-lock' +import type { DrawerOptions } from '@dunky.dev/drawer' + +import { mergeProps, normalize } from '@dunky.dev/react-state-machine' +import { DrawerContext, DrawerPortalContext, useDrawerContext } from './context' +import { getInitialFocus } from './utils/get-initial-focus' +import { useDrawer } from './use-drawer' + +// Explicit so the exports satisfy --isolatedDeclarations (a bare forwardRef +// call gives the variable no annotatable type). +type PartComponent<Props, Element> = ForwardRefExoticComponent<Props & RefAttributes<Element>> + +// ============================================================================= +// <Drawer> — root, owns the machine and renders no DOM +// ============================================================================= + +export interface DrawerProps extends DrawerOptions { + children?: ReactNode +} + +export const Drawer: ((props: DrawerProps) => ReactNode) & Parts = ({ children, ...options }) => { + const depth = useLayerDepth() + 1 + const value = useDrawer(options) + return ( + <LayerDepthContext.Provider value={depth}> + <DrawerContext.Provider value={value}>{children}</DrawerContext.Provider> + </LayerDepthContext.Provider> + ) +} + +// ============================================================================= +// <Drawer.Trigger> — toggles the drawer; focus returns here on close +// ============================================================================= + +export interface DrawerTriggerProps extends ComponentPropsWithoutRef<'button'> {} + +export const Trigger: PartComponent<DrawerTriggerProps, HTMLButtonElement> = forwardRef< + HTMLButtonElement, + DrawerTriggerProps +>((props, forwardedRef) => { + const { api } = useDrawerContext() + const merged = mergeProps({ type: 'button' as const, ...props }, normalize(api.parts.trigger)) + return <button {...merged} ref={forwardedRef} /> +}) + +// ============================================================================= +// <Drawer.Portal> — teleports the layers out of the tree while open +// ============================================================================= + +export interface DrawerPortalProps { + children?: ReactNode + /** The element to portal into. @default document.body */ + container?: HTMLElement | null +} + +export const Portal = ({ children, container }: DrawerPortalProps): ReactNode => { + const { api } = useDrawerContext() + if (!api.open || typeof document === 'undefined') return null + // Publish the scoped container (null = page body) so Content locks the right + // scroll surface. + return createPortal( + <DrawerPortalContext.Provider value={container ?? null}> + {children} + </DrawerPortalContext.Provider>, + container ?? document.body, + ) +} + +// ============================================================================= +// <Drawer.Backdrop> — the layer behind the drawer panel +// ============================================================================= + +export interface DrawerBackdropProps extends ComponentPropsWithoutRef<'div'> {} + +export const Backdrop: PartComponent<DrawerBackdropProps, HTMLDivElement> = forwardRef< + HTMLDivElement, + DrawerBackdropProps +>((props, forwardedRef) => { + const { api, machine } = useDrawerContext() + const { onClick, ...bindings } = normalize(api.parts.backdrop) as { + onClick?: (event: MouseEvent<HTMLDivElement>) => void + } & Record<string, unknown> + + const merged = mergeProps(props as Record<string, unknown>, { + ...bindings, + // Only the topmost layer of a stack answers an outside press. + onClick: (event: MouseEvent<HTMLDivElement>) => { + if (isTopmostLayer(machine.context.id)) onClick?.(event) + }, + }) + + return <div {...merged} ref={forwardedRef} /> +}) + +// ============================================================================= +// <Drawer.Viewport> — the positioning layer that anchors the panel to its edge +// ============================================================================= + +export interface DrawerViewportProps extends ComponentPropsWithoutRef<'div'> {} + +export const Viewport: PartComponent<DrawerViewportProps, HTMLDivElement> = forwardRef< + HTMLDivElement, + DrawerViewportProps +>((props, forwardedRef) => { + const { api, machine } = useDrawerContext() + const { onClick, ...bindings } = normalize(api.parts.viewport) as { + onClick?: (event: MouseEvent<HTMLDivElement>) => void + } & Record<string, unknown> + + const merged = mergeProps(props as Record<string, unknown>, { + ...bindings, + // Content presses bubble up here — only a press that started on the + // viewport itself is an outside interaction, and only the topmost layer + // of a stack answers it. + onClick: (event: MouseEvent<HTMLDivElement>) => { + if (event.target !== event.currentTarget) return + if (!isTopmostLayer(machine.context.id)) return + onClick?.(event) + }, + }) + + return <div {...merged} ref={forwardedRef} /> +}) + +// ============================================================================= +// <Drawer.Content> — the drawer panel: focus moves in on open, restores on +// close, traps while open +// ============================================================================= + +export interface DrawerContentProps extends ComponentPropsWithoutRef<'dialog'> { + /** The element to focus when the drawer opens. @default the drawer panel */ + initialFocus?: RefObject<HTMLElement | null> +} + +export const Content: PartComponent<DrawerContentProps, HTMLDialogElement> = forwardRef< + HTMLDialogElement, + DrawerContentProps +>(({ initialFocus, ...props }, forwardedRef) => { + const { api, machine } = useDrawerContext() + const depth = useLayerDepth() + const portalContainer = useContext(DrawerPortalContext) + const contentRef = useRef<HTMLDialogElement>(null) + useImperativeHandle(forwardedRef, () => contentRef.current as HTMLDialogElement) + const initialFocusRef = useRef(initialFocus) + initialFocusRef.current = initialFocus + + // Content only mounts while open, so mount/unmount ARE the open/close edges. + // One effect keeps the ordering right both ways: the stack joins before focus + // moves in, and on close it must release the layers beneath (un-inert them) + // before focus can move back out to one of them. + useEffect(() => { + const content = contentRef.current + if (content === null) return + + const previous = document.activeElement + const unregister = registerLayer({ + id: machine.context.id, + depth, + element: content, + // A drawer is always modal — it always anchors containment. + modal: true, + }) + + // preventScroll everywhere: the scroll lock already froze the surface, so + // moving focus must not scroll it — otherwise opening jumps the (top-of- + // container) panel into view and closing jumps back to the trigger. + const target = initialFocusRef.current?.current ?? getInitialFocus(content) + target.focus({ preventScroll: true }) + // A target that can't take focus (disabled, hidden) falls back to the panel. + if (document.activeElement !== target) content.focus({ preventScroll: true }) + + return () => { + unregister() + if (previous instanceof HTMLElement) previous.focus({ preventScroll: true }) + } + }, [machine, depth]) + + // Content only mounts while open, so the lock spans exactly the open state. + // A scoped drawer locks its portal container; a page drawer locks the body. + useScrollLock(true, portalContainer) + + useFocusTrap(contentRef, { + // Only the topmost layer traps — a nested layer owns focus while open. + enabled: () => isTopmostLayer(machine.context.id), + }) + + const merged = mergeProps(props as Record<string, unknown>, { + ...normalize(api.parts.content), + // The native <dialog> is display:none without `open`; Content only mounts + // while the drawer is open, so the attribute is unconditionally true. + open: true, + }) + + return <dialog {...merged} ref={contentRef} /> +}) + +// ============================================================================= +// <Drawer.Title> — the drawer's accessible name +// ============================================================================= + +export interface DrawerTitleProps extends ComponentPropsWithoutRef<'h2'> {} + +export const Title: PartComponent<DrawerTitleProps, HTMLHeadingElement> = forwardRef< + HTMLHeadingElement, + DrawerTitleProps +>((props, forwardedRef) => { + const { api, machine } = useDrawerContext() + + useEffect(() => { + machine.send({ type: 'part.presence', part: 'title', present: true }) + return () => machine.send({ type: 'part.presence', part: 'title', present: false }) + }, [machine]) + + const merged = mergeProps(props as Record<string, unknown>, normalize(api.parts.title)) + return <h2 {...merged} ref={forwardedRef} /> +}) + +// ============================================================================= +// <Drawer.Description> — the drawer's accessible description +// ============================================================================= + +export interface DrawerDescriptionProps extends ComponentPropsWithoutRef<'p'> {} + +export const Description: PartComponent<DrawerDescriptionProps, HTMLParagraphElement> = forwardRef< + HTMLParagraphElement, + DrawerDescriptionProps +>((props, forwardedRef) => { + const { api, machine } = useDrawerContext() + + useEffect(() => { + machine.send({ type: 'part.presence', part: 'description', present: true }) + return () => machine.send({ type: 'part.presence', part: 'description', present: false }) + }, [machine]) + + const merged = mergeProps(props as Record<string, unknown>, normalize(api.parts.description)) + return <p {...merged} ref={forwardedRef} /> +}) + +// ============================================================================= +// <Drawer.Close> — the visible in-drawer close affordance +// ============================================================================= + +export interface DrawerCloseProps extends ComponentPropsWithoutRef<'button'> {} + +export const Close: PartComponent<DrawerCloseProps, HTMLButtonElement> = forwardRef< + HTMLButtonElement, + DrawerCloseProps +>((props, forwardedRef) => { + const { api } = useDrawerContext() + const merged = mergeProps({ type: 'button' as const, ...props }, normalize(api.parts.close)) + return <button {...merged} ref={forwardedRef} /> +}) + +// Parts +// ----------------------------------------------------------------------------- + +export interface Parts { + Trigger: typeof Trigger + Portal: typeof Portal + Backdrop: typeof Backdrop + Viewport: typeof Viewport + Content: typeof Content + Title: typeof Title + Description: typeof Description + Close: typeof Close +} + +Drawer.Trigger = Trigger +Drawer.Portal = Portal +Drawer.Backdrop = Backdrop +Drawer.Viewport = Viewport +Drawer.Content = Content +Drawer.Title = Title +Drawer.Description = Description +Drawer.Close = Close diff --git a/packages/react/drawer/src/effects.ts b/packages/react/drawer/src/effects.ts new file mode 100644 index 0000000..435507c --- /dev/null +++ b/packages/react/drawer/src/effects.ts @@ -0,0 +1,39 @@ +import { isTopmostLayer } from '@dunky.dev/dom-layer-stack' +import type { ComponentEffect } from '@dunky.dev/react-state-machine' +import type { DrawerMachine, DrawerOptions } from '@dunky.dev/drawer' + +// Substrate effects: prop-driven or document-level work the machine can't own. +// useMachine runs one useEffect per entry, keyed on the listed prop deps. +type DrawerEffect = ComponentEffect<DrawerMachine, DrawerOptions> + +// Controlled open: the machine never moves on its own when controlled — this +// echo of the `open` prop is the only thing that transitions it. +const syncControlledOpen: DrawerEffect = [ + (machine, props) => { + if (props.open === undefined) return + if (props.open !== machine.matches('open')) { + machine.send({ type: 'controlled.sync', open: props.open }) + } + }, + ['open'], +] + +// Escape is a document-level concern, not a part's — it must work wherever +// focus is. +const trackEscape: DrawerEffect = [ + (machine, props) => { + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key !== 'Escape' || !machine.matches('open')) return + // Only the topmost layer answers Escape — a nested stack closes one + // layer at a time. + if (!isTopmostLayer(machine.context.id)) return + props.onEscapeKeyDown?.(event) + if (!event.defaultPrevented) machine.send({ type: 'escape' }) + } + document.addEventListener('keydown', onKeyDown, true) + return () => document.removeEventListener('keydown', onKeyDown, true) + }, + ['onEscapeKeyDown'], +] + +export const drawerEffects: DrawerEffect[] = [syncControlledOpen, trackEscape] diff --git a/packages/react/drawer/src/index.ts b/packages/react/drawer/src/index.ts new file mode 100644 index 0000000..5ab904b --- /dev/null +++ b/packages/react/drawer/src/index.ts @@ -0,0 +1,19 @@ +export { + Drawer, + type DrawerProps, + type DrawerTriggerProps, + type DrawerPortalProps, + type DrawerBackdropProps, + type DrawerViewportProps, + type DrawerContentProps, + type DrawerTitleProps, + type DrawerDescriptionProps, + type DrawerCloseProps, +} from './drawer' +export type { + DrawerCallbacks, + DrawerOptions, + DrawerPlacement, + KeyboardPayload, + PointerPayload, +} from '@dunky.dev/drawer' diff --git a/packages/react/drawer/src/use-drawer.ts b/packages/react/drawer/src/use-drawer.ts new file mode 100644 index 0000000..b402094 --- /dev/null +++ b/packages/react/drawer/src/use-drawer.ts @@ -0,0 +1,17 @@ +import { useId } from 'react' +import { useMachine } from '@dunky.dev/react-state-machine' +import { drawerMachine, drawerConnect } from '@dunky.dev/drawer' +import type { DrawerOptions } from '@dunky.dev/drawer' + +import type { DrawerContextValue } from './context' +import { drawerEffects } from './effects' + +export function useDrawer(options: DrawerOptions): DrawerContextValue { + const id = useId() + // `?? id` (not spread order): an explicit `id={undefined}` must not knock out + // the generated fallback — ids also key the layer stack, so they must exist. + return useMachine(drawerMachine, drawerConnect, drawerEffects, { + ...options, + id: options.id ?? id, + }) +} diff --git a/packages/react/drawer/src/utils/get-initial-focus.ts b/packages/react/drawer/src/utils/get-initial-focus.ts new file mode 100644 index 0000000..4630d69 --- /dev/null +++ b/packages/react/drawer/src/utils/get-initial-focus.ts @@ -0,0 +1,9 @@ +// The strict rule is only that focus moves into the drawer: a drawer that +// collects input starts at its first form field; any other content keeps +// focus on the drawer panel itself. +const FORM_FIELD_SELECTOR = + 'input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled])' + +export function getInitialFocus(content: HTMLElement): HTMLElement { + return content.querySelector<HTMLElement>(FORM_FIELD_SELECTOR) ?? content +} diff --git a/packages/react/drawer/stories/drawer.stories.tsx b/packages/react/drawer/stories/drawer.stories.tsx new file mode 100644 index 0000000..2f7c81b --- /dev/null +++ b/packages/react/drawer/stories/drawer.stories.tsx @@ -0,0 +1,188 @@ +import type { CSSProperties } from 'react' +import type { Meta, StoryObj } from '@storybook/react-vite' +import { Drawer, type DrawerPlacement } from '@dunky.dev/react-drawer' + +const meta: Meta<typeof Drawer> = { + title: 'Primitives/Drawer', + component: Drawer, +} + +export default meta +type StoryType = StoryObj<typeof Drawer> + +// The primitive ships headless — the story is the consumer, so it brings the +// styles. `data-state` and `data-placement` on the parts are the real styling +// hooks: the panel slides from the edge `data-placement` names. +const backdrop: CSSProperties = { + position: 'fixed', + inset: 0, + background: 'rgba(0, 0, 0, 0.4)', +} +const viewport: CSSProperties = { + position: 'fixed', + inset: 0, +} +// Keyed by data-placement: each edge pins the panel and sizes the slide axis. +const panelByPlacement: Record<DrawerPlacement, CSSProperties> = { + left: { top: 0, bottom: 0, left: 0, width: 320 }, + right: { top: 0, bottom: 0, right: 0, width: 320 }, + top: { top: 0, left: 0, right: 0, height: 240 }, + bottom: { bottom: 0, left: 0, right: 0, height: 240 }, +} +const content = (placement: DrawerPlacement): CSSProperties => ({ + // Reset the UA <dialog> styles so the viewport's edge pinning owns position. + position: 'absolute', + margin: 0, + border: 'none', + maxWidth: 'none', + maxHeight: 'none', + padding: 24, + overflow: 'auto', + boxSizing: 'border-box', + background: 'white', + boxShadow: '0 8px 32px rgba(0, 0, 0, 0.24)', + ...panelByPlacement[placement], +}) +const actions: CSSProperties = { + display: 'flex', + justifyContent: 'flex-end', + gap: 8, + marginTop: 16, +} +const field: CSSProperties = { + display: 'flex', + flexDirection: 'column', + gap: 4, + marginTop: 12, +} +const input: CSSProperties = { + padding: '8px 10px', + border: '1px solid #ccc', + borderRadius: 6, + font: 'inherit', +} + +const DrawerActions = () => ( + <div style={actions}> + <Drawer.Close>Cancel</Drawer.Close> + <Drawer.Close>Apply</Drawer.Close> + </div> +) + +const PlacementDrawer = ({ placement }: { placement: DrawerPlacement }) => ( + <Drawer defaultOpen placement={placement}> + <Drawer.Trigger>Open drawer</Drawer.Trigger> + <Drawer.Portal> + <Drawer.Backdrop style={backdrop} /> + <Drawer.Viewport style={viewport}> + <Drawer.Content style={content(placement)}> + <Drawer.Title>Board settings</Drawer.Title> + <Drawer.Description> + The panel is anchored to the {placement} edge — data-placement is the styling hook. + </Drawer.Description> + <DrawerActions /> + </Drawer.Content> + </Drawer.Viewport> + </Drawer.Portal> + </Drawer> +) + +export const standard: StoryType = { + render: () => <PlacementDrawer placement='right' />, +} + +export const left: StoryType = { + render: () => <PlacementDrawer placement='left' />, +} + +export const top: StoryType = { + render: () => <PlacementDrawer placement='top' />, +} + +export const bottom: StoryType = { + render: () => <PlacementDrawer placement='bottom' />, +} + +export const trigger: StoryType = { + render: () => ( + <Drawer> + <Drawer.Trigger>Open drawer</Drawer.Trigger> + <Drawer.Portal> + <Drawer.Backdrop style={backdrop} /> + <Drawer.Viewport style={viewport}> + <Drawer.Content style={content('right')}> + <Drawer.Title>Closed by default</Drawer.Title> + <Drawer.Description>Only the trigger renders until it is pressed.</Drawer.Description> + <DrawerActions /> + </Drawer.Content> + </Drawer.Viewport> + </Drawer.Portal> + </Drawer> + ), +} + +export const filtersForm: StoryType = { + render: () => ( + <Drawer defaultOpen> + <Drawer.Trigger>Filters</Drawer.Trigger> + <Drawer.Portal> + <Drawer.Backdrop style={backdrop} /> + <Drawer.Viewport style={viewport}> + <Drawer.Content style={content('right')}> + <Drawer.Title>Filters</Drawer.Title> + <Drawer.Description> + Focus moves to the first field on open, and stays trapped inside while the drawer is + open. + </Drawer.Description> + <label style={field}> + Owner + <input style={input} name='owner' type='text' /> + </label> + <label style={field}> + Updated after + <input style={input} name='updated' type='date' /> + </label> + <DrawerActions /> + </Drawer.Content> + </Drawer.Viewport> + </Drawer.Portal> + </Drawer> + ), +} + +export const nested: StoryType = { + render: () => ( + <Drawer defaultOpen> + <Drawer.Trigger>Open outer</Drawer.Trigger> + <Drawer.Portal> + <Drawer.Backdrop style={backdrop} /> + <Drawer.Viewport style={viewport}> + <Drawer.Content style={content('right')}> + <Drawer.Title>Outer drawer</Drawer.Title> + <Drawer.Description> + Escape and outside presses dismiss the topmost layer only — the stack unwinds one + layer at a time. + </Drawer.Description> + <Drawer placement='bottom'> + <Drawer.Trigger>Open inner</Drawer.Trigger> + <Drawer.Portal> + <Drawer.Backdrop style={backdrop} /> + <Drawer.Viewport style={viewport}> + <Drawer.Content style={content('bottom')}> + <Drawer.Title>Inner drawer</Drawer.Title> + <Drawer.Description> + While open, everything beneath — including the outer drawer — is inert and + hidden from assistive tech. + </Drawer.Description> + <DrawerActions /> + </Drawer.Content> + </Drawer.Viewport> + </Drawer.Portal> + </Drawer> + <DrawerActions /> + </Drawer.Content> + </Drawer.Viewport> + </Drawer.Portal> + </Drawer> + ), +} diff --git a/packages/react/drawer/tests/drawer.test.tsx b/packages/react/drawer/tests/drawer.test.tsx new file mode 100644 index 0000000..7bcba4d --- /dev/null +++ b/packages/react/drawer/tests/drawer.test.tsx @@ -0,0 +1,402 @@ +// @vitest-environment jsdom +// The React edge of the Drawer — behavior only; the machine's own contract is +// covered in @dunky.dev/drawer's tests. +import { useRef } from 'react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Drawer, type DrawerProps } from '@dunky.dev/react-drawer' + +const DefaultDrawer = (props: DrawerProps) => ( + <Drawer {...props}> + <Drawer.Trigger>Trigger</Drawer.Trigger> + <Drawer.Portal> + <Drawer.Backdrop data-testid='backdrop' /> + <Drawer.Viewport data-testid='viewport'> + <Drawer.Content> + <Drawer.Title>Title</Drawer.Title> + <Drawer.Description>Description</Drawer.Description> + <button type='button'>Action</button> + <Drawer.Close>Close</Drawer.Close> + </Drawer.Content> + </Drawer.Viewport> + </Drawer.Portal> + </Drawer> +) + +const openDrawer = (): void => { + act(() => screen.getByText('Trigger').click()) +} + +const pressEscape = (): void => { + fireEvent.keyDown(document.body, { key: 'Escape' }) +} + +// RTL auto-cleanup needs vitest globals; this repo runs with globals: false. +afterEach(cleanup) + +describe('Drawer', () => { + describe('open / close', () => { + it('opens on trigger press and closes on close press', () => { + render(<DefaultDrawer />) + expect(screen.queryByRole('dialog')).toBeNull() + + openDrawer() + expect(screen.queryByRole('dialog')).not.toBeNull() + + act(() => screen.getByText('Close').click()) + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('renders open when defaultOpen', () => { + render(<DefaultDrawer defaultOpen />) + expect(screen.queryByRole('dialog')).not.toBeNull() + }) + + it('fires onOpenChange with the new value on open and close', () => { + const onOpenChange = vi.fn() + render(<DefaultDrawer onOpenChange={onOpenChange} />) + + openDrawer() + expect(onOpenChange).toHaveBeenLastCalledWith(true) + + act(() => screen.getByText('Close').click()) + expect(onOpenChange).toHaveBeenLastCalledWith(false) + }) + }) + + describe('escape key', () => { + it('closes on Escape', () => { + render(<DefaultDrawer defaultOpen />) + act(pressEscape) + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('stays open when closeOnEscape=false', () => { + render(<DefaultDrawer defaultOpen closeOnEscape={false} />) + act(pressEscape) + expect(screen.queryByRole('dialog')).not.toBeNull() + }) + + it('stays open when onEscapeKeyDown prevents default', () => { + const onEscapeKeyDown = vi.fn(event => event.preventDefault()) + render(<DefaultDrawer defaultOpen onEscapeKeyDown={onEscapeKeyDown} />) + act(pressEscape) + expect(onEscapeKeyDown).toHaveBeenCalledTimes(1) + expect(screen.queryByRole('dialog')).not.toBeNull() + }) + }) + + describe('outside interaction', () => { + it('closes on backdrop press', () => { + render(<DefaultDrawer defaultOpen />) + act(() => screen.getByTestId('backdrop').click()) + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('stays open when closeOnInteractOutside=false', () => { + render(<DefaultDrawer defaultOpen closeOnInteractOutside={false} />) + act(() => screen.getByTestId('backdrop').click()) + expect(screen.queryByRole('dialog')).not.toBeNull() + }) + + it('stays open when onInteractOutside prevents default', () => { + const onInteractOutside = vi.fn(event => event?.preventDefault()) + render(<DefaultDrawer defaultOpen onInteractOutside={onInteractOutside} />) + act(() => screen.getByTestId('backdrop').click()) + expect(onInteractOutside).toHaveBeenCalledTimes(1) + expect(screen.queryByRole('dialog')).not.toBeNull() + }) + + it('closes on a press on the viewport around the panel', () => { + render(<DefaultDrawer defaultOpen />) + act(() => screen.getByTestId('viewport').click()) + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('does not close when a press inside the panel bubbles to the viewport', () => { + render(<DefaultDrawer defaultOpen />) + act(() => screen.getByText('Action').click()) + expect(screen.queryByRole('dialog')).not.toBeNull() + }) + }) + + describe('controlled open', () => { + it('follows the open prop in both directions', () => { + const { rerender } = render(<DefaultDrawer open={false} />) + expect(screen.queryByRole('dialog')).toBeNull() + + rerender(<DefaultDrawer open />) + expect(screen.queryByRole('dialog')).not.toBeNull() + + rerender(<DefaultDrawer open={false} />) + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('reports a dismissal intent but stays open until the prop closes it', () => { + const onOpenChange = vi.fn() + render(<DefaultDrawer open onOpenChange={onOpenChange} />) + act(pressEscape) + expect(onOpenChange).toHaveBeenLastCalledWith(false) + // The consumer didn't update `open` — that's the veto. + expect(screen.queryByRole('dialog')).not.toBeNull() + }) + + it('reports a trigger press without opening until the prop does', () => { + const onOpenChange = vi.fn() + render(<DefaultDrawer open={false} onOpenChange={onOpenChange} />) + openDrawer() + expect(onOpenChange).toHaveBeenLastCalledWith(true) + expect(screen.queryByRole('dialog')).toBeNull() + }) + }) + + describe('placement', () => { + it('stamps data-placement on the visual layers, defaulting to right', () => { + render(<DefaultDrawer defaultOpen />) + expect(screen.getByTestId('backdrop').getAttribute('data-placement')).toBe('right') + expect(screen.getByTestId('viewport').getAttribute('data-placement')).toBe('right') + expect(screen.getByRole('dialog').getAttribute('data-placement')).toBe('right') + }) + + it('carries the configured placement', () => { + render(<DefaultDrawer defaultOpen placement='left' />) + expect(screen.getByRole('dialog').getAttribute('data-placement')).toBe('left') + }) + + it('leaves the trigger without a placement hook', () => { + render(<DefaultDrawer defaultOpen />) + expect(screen.getByText('Trigger').hasAttribute('data-placement')).toBe(false) + }) + }) + + describe('aria wiring', () => { + it('trigger exposes the popup relationship', () => { + render(<DefaultDrawer />) + const trigger = screen.getByText('Trigger') + expect(trigger.getAttribute('aria-haspopup')).toBe('dialog') + expect(trigger.getAttribute('aria-expanded')).toBe('false') + + openDrawer() + expect(trigger.getAttribute('aria-expanded')).toBe('true') + expect(trigger.getAttribute('aria-controls')).toBe(screen.getByRole('dialog').id) + }) + + it('renders the native dialog element, marked open and modal', () => { + render(<DefaultDrawer defaultOpen />) + const panel = screen.getByRole('dialog') + expect(panel.tagName).toBe('DIALOG') + expect(panel.hasAttribute('open')).toBe(true) + expect(panel.getAttribute('aria-modal')).toBe('true') + }) + + it('panel is labelled by the Title and described by the Description', () => { + render(<DefaultDrawer defaultOpen />) + const panel = screen.getByRole('dialog', { name: 'Title' }) + + const describedBy = panel.getAttribute('aria-describedby') + expect(describedBy).not.toBeNull() + expect(document.getElementById(describedBy as string)?.textContent).toBe('Description') + }) + + it('supports aria-label on Content when no Title is rendered', () => { + render( + <Drawer defaultOpen> + <Drawer.Portal> + <Drawer.Content aria-label='Settings'>content</Drawer.Content> + </Drawer.Portal> + </Drawer>, + ) + const panel = screen.getByRole('dialog', { name: 'Settings' }) + expect(panel.hasAttribute('aria-labelledby')).toBe(false) + expect(panel.hasAttribute('aria-describedby')).toBe(false) + }) + }) + + describe('focus management', () => { + it('moves focus into the panel on open and restores it on close', () => { + render(<DefaultDrawer />) + const trigger = screen.getByText('Trigger') + act(() => trigger.focus()) + + openDrawer() + expect(document.activeElement).toBe(screen.getByRole('dialog')) + + act(pressEscape) + expect(document.activeElement).toBe(trigger) + }) + + // jsdom does no layout, so the scroll jump can't be reproduced — assert the + // mechanism that prevents it: focus never scrolls the locked surface. + it('moves focus without scrolling the locked surface', () => { + const focusSpy = vi.spyOn(HTMLElement.prototype, 'focus') + render(<DefaultDrawer defaultOpen />) + + expect(focusSpy).toHaveBeenCalledWith({ preventScroll: true }) + focusSpy.mockRestore() + }) + + it('moves focus to the first form field when the drawer contains one', () => { + render( + <Drawer defaultOpen> + <Drawer.Portal> + <Drawer.Content aria-label='Form'> + <button type='button'>Action</button> + <input aria-label='Name' /> + </Drawer.Content> + </Drawer.Portal> + </Drawer>, + ) + expect(document.activeElement).toBe(screen.getByLabelText('Name')) + }) + + it('wraps Tab from the last focusable to the first', () => { + render(<DefaultDrawer defaultOpen />) + const panel = screen.getByRole('dialog') + + act(() => screen.getByText('Close').focus()) + fireEvent.keyDown(panel, { key: 'Tab' }) + expect(document.activeElement).toBe(screen.getByText('Action')) + }) + + it('wraps Shift+Tab from the first focusable to the last', () => { + render(<DefaultDrawer defaultOpen />) + const panel = screen.getByRole('dialog') + + act(() => screen.getByText('Action').focus()) + fireEvent.keyDown(panel, { key: 'Tab', shiftKey: true }) + expect(document.activeElement).toBe(screen.getByText('Close')) + }) + + const InitialFocusDrawer = ({ disabled = false }: { disabled?: boolean }) => { + const initialFocus = useRef<HTMLInputElement>(null) + return ( + <Drawer defaultOpen> + <Drawer.Portal> + <Drawer.Viewport> + <Drawer.Content aria-label='Form' initialFocus={initialFocus}> + <input ref={initialFocus} disabled={disabled} aria-label='Name' /> + </Drawer.Content> + </Drawer.Viewport> + </Drawer.Portal> + </Drawer> + ) + } + + it('moves focus to the initialFocus element on open', () => { + render(<InitialFocusDrawer />) + expect(document.activeElement).toBe(screen.getByLabelText('Name')) + }) + + it('falls back to the panel when the initialFocus target cannot take focus', () => { + render(<InitialFocusDrawer disabled />) + expect(document.activeElement).toBe(screen.getByRole('dialog')) + }) + }) + + describe('scroll lock', () => { + it('locks body scroll while the drawer is open', () => { + render(<DefaultDrawer />) + openDrawer() + expect(document.body.style.overflow).toBe('hidden') + + act(pressEscape) + expect(document.body.style.overflow).not.toBe('hidden') + }) + + it('locks the portal container, not the body, when scoped', () => { + const panel = document.createElement('div') + document.body.append(panel) + + render( + <Drawer defaultOpen> + <Drawer.Portal container={panel}> + <Drawer.Content aria-label='Scoped'>content</Drawer.Content> + </Drawer.Portal> + </Drawer>, + ) + + expect(panel.style.overflow).toBe('hidden') + expect(document.body.style.overflow).not.toBe('hidden') + + act(pressEscape) + expect(panel.style.overflow).not.toBe('hidden') + panel.remove() + }) + }) + + describe('layer stack', () => { + const NestedOverlays = (props: DrawerProps) => ( + <Drawer defaultOpen {...props}> + <Drawer.Portal> + <Drawer.Backdrop /> + <Drawer.Viewport data-testid='outer-viewport'> + <Drawer.Content> + <Drawer.Title>Outer</Drawer.Title> + <Drawer defaultOpen placement='left'> + <Drawer.Portal> + <Drawer.Backdrop /> + <Drawer.Viewport data-testid='inner-viewport'> + <Drawer.Content> + <Drawer.Title>Inner</Drawer.Title> + </Drawer.Content> + </Drawer.Viewport> + </Drawer.Portal> + </Drawer> + </Drawer.Content> + </Drawer.Viewport> + </Drawer.Portal> + </Drawer> + ) + + it('Escape dismisses the topmost layer only, one layer per press', () => { + render(<NestedOverlays />) + expect(screen.queryByText('Outer')).not.toBeNull() + expect(screen.queryByText('Inner')).not.toBeNull() + + act(pressEscape) + expect(screen.queryByText('Inner')).toBeNull() + expect(screen.queryByText('Outer')).not.toBeNull() + + act(pressEscape) + expect(screen.queryByText('Outer')).toBeNull() + }) + + it('hides the layer beneath the topmost from assistive tech and restores it on unwind', () => { + render(<NestedOverlays />) + const outer = screen.getByTestId('outer-viewport') + expect(outer.getAttribute('aria-hidden')).toBe('true') + expect(outer.hasAttribute('inert')).toBe(true) + + const inner = screen.getByTestId('inner-viewport') + expect(inner.hasAttribute('aria-hidden')).toBe(false) + + act(pressEscape) // close the inner drawer + expect(outer.hasAttribute('aria-hidden')).toBe(false) + expect(outer.hasAttribute('inert')).toBe(false) + }) + + it('ignores an outside press on a lower layer — only the topmost dismisses', () => { + render(<NestedOverlays />) + act(() => screen.getByTestId('outer-viewport').click()) + expect(screen.queryByText('Outer')).not.toBeNull() + expect(screen.queryByText('Inner')).not.toBeNull() + + act(() => screen.getByTestId('inner-viewport').click()) + expect(screen.queryByText('Inner')).toBeNull() + expect(screen.queryByText('Outer')).not.toBeNull() + }) + + it('cleans up containment and scroll lock when the parent closes over an open child', () => { + const { container, rerender } = render(<NestedOverlays open />) + expect(screen.queryByText('Inner')).not.toBeNull() + expect(container.hasAttribute('inert')).toBe(true) + + rerender(<NestedOverlays open={false} />) + expect(screen.queryByText('Outer')).toBeNull() + expect(screen.queryByText('Inner')).toBeNull() + expect(document.body.style.overflow).not.toBe('hidden') + expect(container.hasAttribute('aria-hidden')).toBe(false) + expect(container.hasAttribute('inert')).toBe(false) + }) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b487891..bccb88e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,15 @@ importers: specifier: ^0.1.0 version: 0.1.0 + packages/core/drawer: + dependencies: + '@dunky.dev/state-machine': + specifier: ^0.1.0 + version: 0.1.0 + '@dunky.dev/state-machine-bindings': + specifier: ^0.1.0 + version: 0.1.0 + packages/dom/utils/focus-trap: {} packages/dom/utils/interact-outside: {} @@ -128,6 +137,43 @@ importers: specifier: ^19.2.6 version: 19.2.7(react@19.2.7) + packages/react/drawer: + dependencies: + '@dunky.dev/dom-layer-stack': + specifier: workspace:^ + version: link:../../dom/utils/layer-stack + '@dunky.dev/drawer': + specifier: workspace:^ + version: link:../../core/drawer + '@dunky.dev/react-state-machine': + specifier: ^0.1.0 + version: 0.1.0(react@19.2.7) + '@dunky.dev/react-use-focus-trap': + specifier: workspace:^ + version: link:../hooks/use-focus-trap + '@dunky.dev/react-use-layer-stack': + specifier: workspace:^ + version: link:../hooks/use-layer-stack + '@dunky.dev/react-use-scroll-lock': + specifier: workspace:^ + version: link:../hooks/use-scroll-lock + devDependencies: + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@types/react': + specifier: ^19.2.15 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.17) + react: + specifier: ^19.2.6 + version: 19.2.7 + react-dom: + specifier: ^19.2.6 + version: 19.2.7(react@19.2.7) + packages/react/hooks/use-focus-trap: dependencies: '@dunky.dev/dom-focus-trap': diff --git a/tsconfig.json b/tsconfig.json index c3956bf..b2278c6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,8 @@ "skipLibCheck": true, "strict": true, "paths": { + "@dunky.dev/drawer": ["./packages/core/drawer/src"], + "@dunky.dev/react-drawer": ["./packages/react/drawer/src"], "@dunky.dev/dialog": ["./packages/core/dialog/src"], "@dunky.dev/react-dialog": ["./packages/react/dialog/src"], "@dunky.dev/dom-focus-trap": ["./packages/dom/utils/focus-trap/src"], diff --git a/tsdown.config.ts b/tsdown.config.ts index 1e327f2..0992602 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -13,11 +13,13 @@ export default defineConfig({ workspace: [ 'packages/core', 'packages/core/dialog', + 'packages/core/drawer', 'packages/dom/utils/focus-trap', 'packages/dom/utils/interact-outside', 'packages/dom/utils/layer-stack', 'packages/dom/utils/scroll-lock', 'packages/react/dialog', + 'packages/react/drawer', 'packages/react/hooks/use-focus-trap', 'packages/react/hooks/use-interact-outside', 'packages/react/hooks/use-layer-stack',