feat(Drawer): recede a buried drawer so a stack reads as layered pages - #5652
Open
freddymeta wants to merge 7 commits into
Open
feat(Drawer): recede a buried drawer so a stack reads as layered pages#5652freddymeta wants to merge 7 commits into
freddymeta wants to merge 7 commits into
Conversation
…iewport Rebuilt on today's main as one commit. The branch carried eight, including #5549's two (landed, and dropped here) and four that walked the modality API out and back again — noise in a review, so they are squashed into the shape they arrived at. Rebased onto `useDrawerDialogPresence`, which #5549 extracted. The hook gains one optional field, `mountHost`: a ref is stable, so nothing in it can observe the dialog ELEMENT arriving, and a bounded Drawer renders no dialog until its container resolves one commit later. Without naming the host as a dependency an already-open bounded Drawer never opens at all — which is exactly how the rebase first failed its own test. Bounded mode, unchanged from the reviewed head: - the panel is portalled into the container and pinned to its SCROLLPORT, so scrolling the pane 180px no longer carries the drawer 180px with it; - `isModal` blocks the container with `inert` rather than the top layer, so the keyboard is blocked exactly as far as the pointer is; - target resolution is deferred until the panel paints and survives the host being swapped or detached. New in this round, from review: the inert pass is held for the whole open lifetime instead of stamped once. A pane is live content — a row streams in, a menu opens, a lazy panel resolves — and a one-shot pass left every later arrival focusable behind a scrim that already blocked the pointer. A `childList` MutationObserver re-runs it; the drawer still hands back only what it inerted, so a container someone else marked is returned untouched. Two tests, both verified failing against a one-shot pass.
CI caught what the lab typecheck could not: the sandbox app renders
Drawer with `hasScrim={false}` in two places, and `packages/lab`'s
tsconfig only covers its own `src`, so nothing local failed. Both meant
non-modal, which is what they now say.
The third `hasScrim` in the repo is BottomSheet's, which keeps the name
and is untouched.
Follow the reviewed API ruling instead of combining enforcement and paint in `isModal`: - `containerRef` chooses where the Drawer is scoped; - `modality="modal" | "nonModal"` chooses whether that scope is blocked; - `hasScrim` chooses whether it is dimmed, defaulting to modality. The axes are real in both viewport and bounded modes. A non-modal scrim is paint-only and pointer-transparent; a modal without a scrim still blocks. A bounded modal keeps current and late-inserted siblings inert through the exit transition. A live modality change reopens the native dialog with show() or showModal(), so native enforcement cannot drift from the prop. Remove the superseded public `isModal` prop and migrate all branch-owned call sites, docs, examples, and the existing Storybook story. The story now exposes all four combinations. Unit coverage proves all viewport and bounded combinations; coupling paint back to modality makes three focused tests fail. Chromium verified the full 2x2x2 matrix (viewport/bounded x modal/nonModal x scrim/clear): painted pixels were 153,160,169 vs 255,255,255; modal controls received 0 clicks, non-modal controls received 1; bounded modal content was inert; both non-modal scrims had pointer-events:none.
Harden the independent modality axis in two transition states: - an open native dialog cannot switch between show() and showModal() in place, so track its opening mode and synchronously close/reopen when modality changes; - bounded background content stays inert for the whole rendered lifetime, including the slide-out, instead of becoming keyboard-active before the pointer dismissal plane has left. Extend the unit guard through modal -> nonModal in both directions and assert bounded inert cleanup only after the transform transition. Correct the Chinese docs that still claimed a bounded Drawer could never be modal.
Opening a second drawer over a first replaced it outright: the panel behind
vanished under the new one, so nothing said where the user came from or how
deep they were. The EPS Sheet solves this by receding the panels behind the
front one, and this brings that to Drawer.
A drawer with drawers stacked on top withdraws toward its own closing edge and
shrinks per level, keeping each leading edge visible beside the one in front,
with rounded corners so it reads as a card behind rather than a clipped panel.
The origin is pinned to the closing edge, so the shrink is a withdrawal into
the page rather than a panel floating free. Both flip under RTL.
Two deliberate departures from the EPS implementation:
- Depth comes from the existing LIFO stack registry, not from React nesting.
Drawer's documented contract is that siblings stack and must never be
nested, so a nesting context would find nothing to walk; the registry
already holds the order. It gains a subscription and `useSyncExternalStore`
reads it, with a server snapshot of 0.
- The shrink is a unitless ratio per level rather than a px inset. An inset
has to be divided by the measured panel to become a scale, which costs a
ResizeObserver and recedes less on a tall screen than a short one.
The geometry is themeable rather than per-call, following the Spinner var
precedent: `--drawer-stack-peek`, `--drawer-stack-scale-step`,
`--drawer-stack-min-scale` and `--drawer-stack-radius` are declared on the
element carrying the `drawer` theme target. `hasStackRecede={false}` opts a
drawer out. Depth is mirrored to `data-stack-depth` so the state is
inspectable and an app can hang its own rules off it.
Chromium, three deep: depths 2/1/0, buried panels at scale(0.92)/scale(0.96)
translated -80px/-40px with 8px corners and the origin on the closing edge,
the front panel untouched at scale(1). Overriding the two vars to 80px/0.1
moved them to scale(0.8) (clamped by the floor) and scale(0.9) at
-160px/-80px. Unwinding the stack returned each panel to rest.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
PR Analysis Report📚 Storybook PreviewView Storybook for this PR 🧪 Sandbox PreviewView Sandbox for this PR Modified ComponentsDrawer (@astryxdesign/lab) · View in Storybook
Bundle Size Summary
Accessibility AuditStatus: No accessibility violations detected. Generated by PR Enrichment workflow | Storybook | Sandbox | View full report |
Only conflict was the generated motionAudit.ts. Taken from main verbatim rather than regenerated: main's own committed copy does not reproduce locally either (the generator emits 284 lines here against 1368 committed), so a local regen would have written a file that disagrees with every other branch. 63 Drawer tests pass.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The gap
Open a second drawer over a first and the first is simply gone. Nothing says where the user came from, or how deep they are — the panel behind is covered by an identically-shaped panel, so a two-level flow reads exactly like a one-level flow that changed its contents.
EPS solves this in its own Sheet, and the effect is worth having here: the panels behind the front one recede, so the stack reads as layered pages.
What it does
A drawer with drawers stacked on top withdraws toward its own closing edge and shrinks a little per level, keeping each leading edge visible beside the one in front, with rounded corners so it reads as a card behind rather than a clipped panel. The transform origin is pinned to the closing edge, so the shrink is a withdrawal into the page rather than a panel floating free of it. Both flip under RTL.
Nothing to opt into: it follows the stacking contract Drawer already documents.
hasStackRecede={false}opts a drawer out.(Assets live on the
assets/pr-drawer-stackbranch — asset-only, deletable with the PR. I have no fork.)Themeable, not per-call
The geometry is a design decision, so it belongs to the theme rather than to each call site. Four vars are declared on the element carrying the
drawertheme target, the same door--spinner-diameteropens:--drawer-stack-peek40px--drawer-stack-scale-step0.04--drawer-stack-min-scale0.8--drawer-stack-radiusvar(--radius-element)Depth is mirrored to
data-stack-depthon the panel, so the state is inspectable and an app can hang its own rules off it.Two deliberate departures from the EPS implementation
Depth comes from the stack registry, not from React nesting. EPS threads a context up through physically nested sheets. Drawer's documented contract is the opposite — siblings stack, never nest them — so a nesting context would find nothing to walk, and EPS needs a
nestedDepthescape hatch for exactly the flat-sibling case Drawer mandates. The LIFO registry already holds the order; it gains a subscription anduseSyncExternalStorereads it, with a server snapshot of0. No escape hatch needed.The shrink is a ratio per level, not a px inset. EPS insets the cross-axis edge by 16px per level, which has to be divided by the measured panel to become a
scale()— that costs aResizeObserver, and it makes the recede shallower on a tall screen than a short one. A unitless ratio is resolution-independent, needs no measurement, and is directly themeable.Measured
Chromium, against the built
packages/lab/dist, three drawers deep:data-stack-depth2matrix(0.92, 0, 0, 0.92, -80, 0)8px1matrix(0.96, 0, 0, 0.96, -40, 0)8pxmatrix(1, 0, 0, 1, 0, 0)0pxOverriding the two vars to
80px/0.1moved them toscale(0.8)— clamped by the floor — andscale(0.9), at-160px/-80px. WithhasStackRecede={false}all three sat atmatrix(1, 0, 0, 1, 0, 0). Unwinding 3 → 2 → 1 returned each panel to rest.Tests
63 pass. The four new ones cover depth by level, the unwind, the opt-out, and the vars being declared on the themeable element; reverting the recede wiring fails exactly the two behavioural ones. Full build, lab/docs/Storybook typechecks,
check:repoandlint:strictpass.Storybook:
Lab/Drawer→NestedStack.No changeset:
@astryxdesign/labis private and not released.