- Layer
- Machine
- Package
@grassroot/ui-machines/sheet- Ships behaviour
- Open binding, dismissable guard, no DOM. Shared verbatim with Drawer.
Docs Platform
Sheet
A modal panel that slides in from an edge rather than appearing centred like
Dialog. Sheet and Drawer are two names for one machine,
@grassroot/ui-machines/sheet, with a different styled recipe
and set of sides on each. This page is Sheet's walkthrough;
Drawer's page covers the same contract
from the other side.
Explore the layers
Machine (@grassroot/ui-machines/sheet) is
pure state: no DOM, no framework, no styling. It is the same machine
Drawer uses. Headless
(@grassroot/ui-headless-<framework>/sheet) adds
Dialog-shaped accessible DOM/ARIA on top of it, per framework.
Styled (@grassroot/ui-<framework>) is
Grassroot's edge-panel visual recipe built on the headless layer.
Vanilla (mountSheet, from
@grassroot/ui-headless-core/sheet) is the framework-free
mount helper.
@grassroot/ui-machines/sheet@grassroot/ui-headless-{react,vue,solid,svelte,angular}/sheetdata-side, no visual recipe.@grassroot/ui-headless-core/sheet (mountSheet)@grassroot/ui-{react,vue,solid,svelte,angular}- Layer
- Headless
- Package
@grassroot/ui-headless-{react,vue,solid,svelte,angular}/sheet- Ships behaviour
- Dialog-shaped modal DOM/ARIA,
data-side, no visual recipe.
- Layer
- Vanilla
- Package
@grassroot/ui-headless-core/sheet(mountSheet)- Ships behaviour
- The same headless contract with no framework runtime at all.
- Layer
- Styled
- Package
@grassroot/ui-{react,vue,solid,svelte,angular}- Ships behaviour
- Grassroot's edge-panel visual recipe, tokens and classes only.
Behaviour contract
This is the one thing every layer agrees on. The machine decides it, the headless layer exposes it as DOM/ARIA, and the styled layer inherits it unchanged.
SheetInput: open? / defaultOpen? (ONE state-backed open binding)
dismissable?: boolean (default true)
side?: "left" | "right" (connector-only; the machine never reads it)
StateValue: "open" | "closed"
Context: { mode: "controlled" | "uncontrolled" }
Events: OPEN { reason: "trigger" | "programmatic" }
CLOSE { reason: "escape" | "outside-click" | "close-trigger" | "programmatic" }
Command: OPEN_CHANGE_REQUEST { open, reason }
-
Open binding: the same
open/defaultOpenshape Dialog uses, fixed to controlled or uncontrolled mode at first start. A post-start mode flip produces the standardbinding-mode-changediagnostic and leaves state unchanged. -
Dismissable defaults to
true, unlike AlertDialog, where it defaults tofalse. When it's set tofalse, Escape and outside-click become true no-ops; the close trigger and a programmatic close remain allowed either way. -
sideis connector-only styling metadata. Sheet accepts"left" | "right"and defaults to"right". It never enters machine state and never branches a transition, and the machine ignores it entirely. The connector emits it asdata-side. -
No-ops:
OPENwhile already open andCLOSEwhile already closed are true no-ops: no command, no version bump. - Forms: none.
Managers & dismissal
Sheet follows Dialog's proven manager contract exactly, so nothing about focus, scroll, or dismissal is reimplemented for Sheet specifically.
-
One activation call per open-commit. The
connector/adapter calls
activateSheetManagers(Drawer calls the siblingactivateDrawerManagers), which pushes a modal layer (pushLayer({ modal: true })), traps focus inside Content, and (through the layer manager) acquires the reference-counted scroll lock. All three release together on close-commit or unmount. -
The layer owns Escape and outside pointerdown. Its
onDismisssends the machine's ownCLOSEevent with the matching reason; it never mutates or bypasses machine state directly. Content and Trigger are both registered as layer containment elements, so clicking either one never counts as an outside click. - No styled wrapper owns a scrim click, an Escape handler, a focus-trap call, a scroll-lock call, or a manual portal. Backdrop itself has no dismissal handler of its own. Dismissal is entirely the layer's job.
Anatomy
Sheet exposes the same Dialog-shaped parts Drawer exposes, under
data-scope="sheet":
triggerbackdroppositionercontentrole="dialog" aria-modal="true"titledescriptionclose-trigger- Part
- Root
- data-part
- –
- Render rule
- context/service owner; renders no element
- Part
- Trigger
- data-part
trigger- Render rule
- always mounted
- Part
- Backdrop
- data-part
backdrop- Render rule
- mounted while open
- Part
- Positioner
- data-part
positioner- Render rule
- mounted while open, portalled
- Part
- Content
- data-part
content- Render rule
- mounted while open;
role="dialog"aria-modal="true"
- Part
- Title
- data-part
title- Render rule
- mounted while open; labels Content when present
- Part
- Description
- data-part
description- Render rule
- mounted while open; describes Content when present
- Part
- CloseTrigger
- data-part
close-trigger- Render rule
- mounted while open
Every rendered part carries data-scope="sheet",
data-part, and data-state="open" | "closed".
Content, Title, Description, and CloseTrigger are unmounted while
closed, matching the legacy wrappers exactly, the same
unmount-on-close model Dialog uses.
aria-labelledby/aria-describedby on Content
are emitted only when the matching Title/Description part is actually
registered, never pointed at an id that doesn't exist. IDs are
deterministic per scope seed but their exact format is not itself
versioned. data-side mirrors the side input, a pure styling hook that the machine never reads.
Keyboard, pointer, focus
Legacy-compatible throughout, following Dialog's manager contract:
OPEN { reason: "trigger" }.CLOSE { reason: "close-trigger" }.Escape / outside pointerdownTab / Shift+Tab- Trigger
- Click
- When
- Trigger
- Effect
- Sends
OPEN { reason: "trigger" }.
- Trigger
- Click
- When
- CloseTrigger
- Effect
- Sends
CLOSE { reason: "close-trigger" }.
- Trigger
Escape/ outside pointerdown- When
- open, dismissable
- Effect
- The layer sends the matching close reason.
- Trigger
Tab/Shift+Tab- When
- open
- Effect
- Wraps within Content; focus that escapes programmatically is reclaimed.
- Trigger
- Close (any reason)
- When
- –
- Effect
- Focus returns to the element that was active when the managers were activated.
Forms
None.
Accessibility
Content carries role="dialog" and
aria-modal="true" whenever it's mounted.
aria-labelledby and aria-describedby are
conditionally present, only when a Title or Description part actually
exists. Everything about focus movement, the Tab-trap, and the
ref-counted scroll lock is covered above under "Managers &
dismissal", since that is manager-owned DOM behaviour rather than an ARIA attribute.
connectSheet and every prop getter are DOM-free and
SSR-safe; overlay parts do not render a live portal during SSR, and
each adapter uses its existing Dialog portal/hydration idiom. Managers
are a mount-time-only concern and are never created during SSR.
Evidence & further reading
-
sheet.contract.ts, the shared browser-contract suite, implemented per adapter in each framework's ownbrowser/sheet.spec.ts(React, Vue, Solid, Svelte, Angular) plus the core vanilla mount. -
sheet.spec.md, the reviewable anatomy/ARIA/keyboard/focus/managers/forms/SSR contract shared with Drawer; code must match it, and where they disagree the spec wins. -
Build your own component on
@grassroot/ui-headless-core, the same machine → part scope → connect → commands recipe this page walks through for Sheet, applied to a component that isn't one of the shipped machines. - Drawer: the same machine, a different side domain and grab handle
- Dialog: the modal sibling whose manager contract Sheet reuses exactly
- AlertDialog: the confirmation-focused sibling, non-dismissable by default