Skip to content

feat(core): BottomSheetSwitcher renders a dialog per sheet (prototype) - #7706

Draft
imdreamrunner wants to merge 1 commit into
mainfrom
feat/switcher-per-sheet-dialog
Draft

imdreamrunner wants to merge 1 commit into
mainfrom
feat/switcher-per-sheet-dialog

Conversation

@imdreamrunner

Copy link
Copy Markdown
Contributor

Prototype for evaluation. This PR is a working implementation that moves the native <dialog> from BottomSheetSwitcher to each child BottomSheet, with measurements of what that costs and what it fixes. It is not a proposal to merge as-is; see "Recommendation" at the end.

Primary intent

Evaluate whether BottomSheetSwitcher should coordinate one native <dialog> per child sheet (the way a standalone <BottomSheet isOpen> and the open-order stacking candidate in #7648 work) instead of hosting every child as a panel inside one switcher-owned dialog. The public API is unchanged; the structure, the native call pattern, and two observable details change, and they are measured below.

The other templates do not fit: nothing is broken (bug-fix), no capability is added (new-feature), and pixels are unchanged at rest (visual-update).

Why the dialog was on the switcher

The switcher has had one shared dialog since it was merged in #5023 (fb0b7a99f1). The commits inside that PR show it was a deliberate mid-PR move, and the surrounding text states the reason.

Before the move — a dialog per sheet, non-modal, under a portaled scrim. The first commit, 9dac3f6792 ("feat(lab): add multi-step BottomSheet orchestrator"), kept a native <dialog> on every child, but opened each with show() and had the orchestrator render its own <div> scrim with a JS focus trap and scroll lock. Its BottomSheet.tsx header (line 23) reads: "Inside a BottomSheetOrchestrator, the parent owns one shared scrim/focus trap/scroll lock and every child uses show() beneath it." Because show() does not enter the top layer, the orchestrator had to portal the whole visual layer to document.body. At 5890d633aa, BottomSheetSwitcher.tsx line 214 explains: "Switcher-managed sheets use show(), so they do not enter the native top layer. Portal the complete visual layer to body to keep transformed, contained, or overflow-clipped application ancestors from displacing the viewport scrim and sheets. Waiting until layout keeps server and initial client markup aligned while still mounting the portal before paint." The doc of the same revision summarised it as: "The switcher portals one shared visual layer to document.body and renders one scrim for the whole flow, so containing blocks cannot clip it and handoffs never stack backdrops."

The move — fbdc197ef7 "refactor(lab): share dialog across BottomSheet flow". That commit replaced the per-sheet show() dialogs, the portal, and the <div> scrim with one switcher-owned dialog opened once with showModal(). The reason is in the header it added (BottomSheetSwitcher.tsx lines 18–21, unchanged on main today): "All child sheets render as panels inside one switcher-owned <dialog>. A scrim flow calls showModal() once and keeps that native top-layer dialog open across every handoff. A no-scrim flow calls show() on the same inline shell. This keeps one modal boundary and one native ::backdrop without a portal." The PR description says the same from the outside: "With hasScrim={true}, the switcher uses showModal() to provide one top-layer boundary and one native ::backdrop across every handoff", and lists as validation "Verified exactly one open dialog across handoffs" and "Verified modal mode remains viewport-sized under a transformed and clipped ancestor".

So the stated reasons were, in order:

  1. One top-layer boundary and one native ::backdrop across every handoff — no stacked or flickering backdrops while one sheet replaces another, and native modality (inert page, native focus containment, cancel) from one showModal() instead of a JS trap plus a painted <div>.
  2. No portal. The per-sheet design needed show() for the children (two showModal() calls would have stacked two backdrops), and show() dialogs stay in their containing block, so the layer had to be portaled out of transformed or clipping ancestors, with an SSR/hydration timing cost. One showModal() dialog sits in the top layer wherever it is rendered.
  3. The model the structure encodes: the switcher is the sheet. The same PR renamed the orchestrator to "switcher", extracted the child's visual surface into BottomSheetPanel (61aa914d5b), and describes children as "panel layers inside it". The current BottomSheetSwitcher.doc.mjs anatomy on main names them "Sheet panels", and the draft BottomSheetSwitcher.spec.md records it as FR2 ("All child sheets share one native dialog") and PR1 ("One shared host"). A sheetId child is a step of one sheet, not a sheet of its own.

No reviewer discussion on #5023 argues the point (the PR has no review comments), and the draft spec's decision log is empty, so items 1–2 are the stated engineering reasons and item 3 is the model they serve.

Before / after structure

Before (main):

<dialog aria-label="Active step" aria-modal="true">   <!-- switcher: showModal() once -->
  <div hidden …><div class="astryx-bottom-sheet">step A</div></div>
  <div><div class="astryx-bottom-sheet">step B</div></div>
  <div data-sheet-edge-tint />
</dialog>

After (this PR): the switcher renders no element; each child renders what a standalone sheet renders.

<dialog aria-label="Step A" inert aria-hidden="true">  <!-- retained: open until its panel fades -->
  <div inert aria-hidden="true"><div class="astryx-bottom-sheet">step A</div></div>
  <div data-sheet-edge-tint />
</dialog>
<dialog aria-label="Step B" aria-modal="true">          <!-- active: showModal() on entry -->
  <div><div class="astryx-bottom-sheet">step B</div></div>
  <div data-sheet-edge-tint />
</dialog>

The switcher keeps the transition state machine, the focus trap, the scroll lock, the layer-dismissal registration, the opener capture and focus return, and the purpose-driven dismissal rules. It gains three coordination duties the shared dialog never needed:

  • Scrim ownership. In the top layer, the entering dialog's ::backdrop paints above the retained dialog, so it would dim the previous panel and double-dim the page. The retained dialog therefore keeps the flow's scrim while it is visible; the entering dialog is presented with a transparent backdrop (--_sheet-scrim-opacity: 0); when the retained dialog closes after its fade, the new owner takes the value with its backdrop transition disabled for that one style flush (--_sheet-scrim-duration: 0s, inline, then removed). A 0.01s pin was tried first and still painted one frame at the start value — a visible one-frame dip — which is why the swap disables the transition outright.
  • Top-layer order on reversal. Going back to a sheet whose dialog is still open beneath the current one (Back pressed while the previous sheet is still fading) closes and re-presents that dialog so the interactive sheet is topmost.
  • Native focus bookkeeping. showModal() records the focused element and close() returns focus to it. For a sheet presented from a Back/Continue button inside another sheet, that return would pull focus out of the active sheet when the retained dialog closes after its fade (measured as a focus jump before the fix). The switcher blurs such a control before presenting, so the browser records nothing; the panel focus the switcher sets stands.

Behaviour deltas

Area main This PR Note
Native calls per switch 0 (one showModal() for the flow) showModal(next) on entry, close(previous) after its fade measured, see Evidence
Open dialogs during a handoff 1 2 (retained one inert, aria-hidden) at rest: 1 in both
Page dimming through a switch 0.5 constant 0.5 constant, no dip, no double dim 70+ samples per switch, dipFrames=0
Retained panel dimmed? no no (retained dialog owns the scrim) settled screenshots pixel-identical
Focus after a switch (modal) new step's panel new step's panel identical traces
Focus after a switch (non-modal) lost to <body> new step's panel (show() focusing steps) improvement, called out
Focus return on close opener opener identical
Escape / scrim tap / swipe close via onActiveSheetChange(null) per purpose same identical
Scroll lock across a switch held held identical
Rapid reversal (Back while fading) panel z-index swap close() + showModal() of the returning sheet 2 extra native calls; no dip, no focus jump
ref one persistent dialog the dialog hosting the active sheet; moves on handoff; null while closed observable change
onCancel, dialog props one dialog every sheet dialog (handlers reach the active one) observable change
Accessible name / role precedence rule (consumer label → active child → retained child) each dialog is named by its own sheet; consumer aria-label still wins simpler, equivalent output
SSR markup one closed <dialog> one closed <dialog> per sheet hydration-safe (no open attribute server-side)
A Core Dialog opened from a step above the flow above the flow top layer is showModal() order
Edge tint one per flow one per presented sheet (two during a handoff) same colour

Non-modal flows (hasScrim={false}) still use show() per sheet; the interactive sheet's dialog takes z-index: 1001 so DOM order cannot put a retained sheet in front. This stays inside the non-modal path that AST-027 already lists as a conformance gap.

Test edits

Existing suites were run unedited first. Every failure was an assertion about the structure this PR deliberately changes; no behaviour assertion was weakened.

Test (BottomSheetSwitcher.test.tsx) Edit Reason
helper getSharedDialog() renamed getActiveDialog(); added getSheetDialog(testId) and openDialogs() the role query now resolves to the active sheet's dialog (the retained one is aria-hidden)
"keeps the previous sheet stationary until the new entrance finishes, then fades it" dialog[open] count 1 → [details, confirm] during the handoff, [confirm] after; shared-dialog identity → active-dialog identity; retained dialog asserted inert two dialogs are open through a handoff by design
"replaces an unfinished outgoing sheet during rapid navigation" dialog[open] count 1 → 2; asserts a third showModal() (re-present) the returning sheet re-presents to be topmost
"ignores late scrim updates from an outgoing sheet gesture" scrim variable read from the retained (owner) dialog; entering dialog asserted 0 scrim ownership moved to the retained dialog
"releases the shared modal layer when the closing sheet unmounts immediately" title only behaviour unchanged (a presented sheet closes its dialog on unmount)
"dismisses the flow from the one shared scrim", "forwards … to the shared dialog", "keeps the shared dialog inline" titles and helper name only wording

Nine tests were added under "one native dialog per sheet": per-sheet dialog structure, the native call order through a handoff and close, ref movement, dialog-prop forwarding to every sheet dialog, one scrim with an instant takeover, focus into the entering sheet with the retained dialog inert, blur-before-present (no focused control for the browser to return to), purpose after a handoff, non-modal show() per sheet, and re-presenting a still-fading sheet on reversal.

Other suites in the validation set (Dialog, Layer, Selector, DateInput, Tokenizer, MobileTokenizer, …) pass unedited: 2458 tests on this branch vs 2448 on main (+10 new).

Records touched

BottomSheetSwitcher.spec.md and BottomSheet.spec.md are both draft records. They were edited only where they describe the shared host (Intent, Ownership, FR2, FR8, FR9, representative states, PR1, the design-relationships rows, and the theming-anatomy key Shared dialog → Sheet dialogs, which check:knowledge requires to match the .doc.mjs anatomy). No decision was added; the decision log stays "None".

Writing rubric results

  • Present tense, describes the system: yes (the records describe the structure on this branch).
  • No proposals, history, pull requests, or research inside the records: yes (this PR body carries them).
  • Authority unchanged: draft stays draft; no claim was promoted.
  • Ownership boundary still names a single owner per fact: yes (dialog lifecycle and scrim ownership → switcher; panel anatomy → BottomSheet).
  • Verification map still names a real test per contract: yes (BottomSheetSwitcher.test.tsx covers FR1–FR9).

Authority

  • component:BottomSheetSwitcher (draft) FR1–FR9, PR1, AR1–AR3 — preserved except the host structure noted above.
  • family:overlay-dismissal — the switcher keeps one registration for the flow and routes native cancel through shouldDismissOnCloseRequest().
  • architecture:layer-runtime and spec:AST-027 — native showModal() hosting; the non-modal show() + z-index path remains the recorded gap.
  • spec:AST-002 / architecture:public-component-api — props, defaults, and exports unchanged; ref target semantics documented in the prop doc and the Changeset.

Change and evidence

Files: BottomSheetSwitcher.tsx (coordinator, no element), BottomSheet.tsx (the switcher item renders its own dialog; standalone host untouched; scrim transition duration reads an unset custom property with the token as fallback; one new dialogNonModalTop style), BottomSheetSwitcherContext.ts (host props and scrim/presentation hooks), BottomSheetSwitcher.doc.mjs, BottomSheet.doc.mjs, the two draft specs, tests, and a [feat] Changeset.

API audit. Core built on origin/main and on this branch; .d.ts files and the exports map diffed. exports map identical. BottomSheet/index.d.ts and the root index.d.ts are byte-identical. BottomSheetSwitcher.d.ts differs only in doc comments and the destructuring pattern in the declared signature (no type change). BottomSheetSwitcherContext.d.ts changes but is not reachable from any public entry.

Browser (Chromium, 390×844, touch, static Storybook, main vs branch, stories hug-content, capped, no-scrim). Harness instruments show/showModal/close, samples every open dialog's computed ::backdrop opacity per animation frame through each switch and derives the combined page dimming, records document.activeElement per frame, hit-tests the interactive panel, and reads a page pixel's luminance.

  • Native call log per switch (branch): showModal(next) at click, close(previous) ~640 ms later after its fade. main: none. Open/close/open flashes: none; a dialog is never closed and re-shown within a switch except the rapid-reversal case (one close + showModal).
  • Backdrop through a switch (branch, every switch, both directions): dim 0.5 [A:1.00] → 0.5 [A:1.00 B:0.00] → 0.5 [B:1.00]; dipFrames=0 overFrames=0; pixel luminance 127 mid-handoff and settled on both builds. main: 0.5 constant.
  • Focus: modal switches move focus panel → panel on both builds; after Escape, scrim tap, or swipe the opener regains focus on both builds; non-modal switches land on the new panel on the branch and on <body> on main.
  • Escape, scrim tap, swipe on step 2: close the flow on both builds; the scrim tap is a no-op in no-scrim on both.
  • A showModal() dialog opened during a handoff hit-tests on top on both builds.
  • No horizontal overflow (scrollWidth 390 = clientWidth 390), no console errors or warnings on either build.
  • Settled screenshots after every switch are pixel-identical between builds; mid-handoff frames differ by a few pixels of animation timing only.

What it buys and breaks under "the switcher is the sheet"

If the switcher is one sheet and its sheetId children are steps, this PR turns every step into a sheet: each gets its own modal boundary, top-layer entry, backdrop, and native focus record. The measurements show the user-visible result can be made identical, but only by actively undoing what the browser does for a second sheet — hiding the second backdrop and handing the scrim over without a fade, re-presenting on reversal to fix top-layer order, and blurring before showModal() so close() cannot return focus into the active step. The shared dialog needs none of that because the replace animation happens inside one host.

What per-step dialogs buy: one DOM shape for standalone sheets, switcher steps, and the open-order stacking candidate (#7648); each step carries its own accessible name and alertdialog role without a precedence rule; non-modal steps receive focus natively.

What they cost: two native calls per switch, two open modal dialogs through every handoff, a moving ref, and three coordination mechanisms that exist only to make two sheets look like one.

Recommendation

Keep the switcher as the single sheet. The shared dialog is the direct expression of the model (one sheet, N steps), it is the only structure that gets the replace handoff, one scrim, and one focus boundary for free, and the stacking candidate in #7648 does not need this change: a switcher with one dialog is already one level in an open-order stack. This prototype is useful as evidence of the exact cost of the alternative, and the blur-before-present finding is worth keeping in mind for any design that presents a second modal dialog from inside a first.

Scope

  • This PR has one primary intent.
  • Independently removable deltas were removed or split.
  • Public text and artifacts contain no internal Meta context.

Testing

pnpm exec vitest run packages/core/src/{BottomSheet,Dialog,AlertDialog,Selector,DropdownMenu,ContextMenu,DateInput,DateTimeInput,TimeInput,Tokenizer,Layer,Layout,CommandPalette,MoreMenu,Popover,Lightbox,MobileNav}/ packages/lab/src/MobileTokenizer/
  main:   Test Files 85 passed, Tests 2448 passed
  branch: Test Files 85 passed, Tests 2458 passed
pnpm -F @astryxdesign/core typecheck                      ✓
ASTRYX_STRICT_LINT=1 pnpm exec eslint packages/core/src/BottomSheet/   0 errors (1 pre-existing warning in an untouched spec)
pnpm check:sync · check:knowledge · check:package-boundaries · check:changesets   ✓
node scripts/sync-exports.js --check                      ✓
git diff --check                                          ✓
pnpm exec vitest run --project node scripts/build-css.test.mjs   11 passed
node scripts/release/changeset-coverage.mjs pr --base origin/main --head HEAD --released v0.7.1   ✓ ships to @astryxdesign/core, each named
pnpm storybook:build                                      ✓ (main and branch)

Browser evidence (call logs, per-frame backdrop samples, focus traces, screenshots) was captured against the static Storybook of both builds at 390×844 with touch, as summarised above.

@vercel

vercel Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Oct 19, 2026 11:20pm UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 19, 2026
@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Oct 19, 2026
@astracat-bot

astracat-bot Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
❓ Needs maintainer input (for maintainers only) Oct 20, 2026, 1:41 PM UTC

@github-actions

Copy link
Copy Markdown
Contributor

PR Analysis Report

Preview availability: Storybook and Sandbox are not both ready on the exact-head Vercel preview.

No new or modified components detected.

Bundle Size Summary

Package Size (ESM) Size (CJS) Gzipped
@astryxdesign/core N/A 4.9KB 1.2KB

Accessibility Audit

Status: No accessibility violations detected.

Visual Regression

40 of 482 shot(s) changed.

A change here is a question, not a failure: check whether the after is the
picture you intended. Record that review on the PR. Baseline maintenance is an
explicit dispatch of CI; this report never rewrites the baseline or adds a release gate.

component story theme mode pixels
Selector Indicator Space Bottom Sheet Narrow End RTL neutral light 754,077
Selector Indicator Space Bottom Sheet Wide Start neutral light 740,188
Chat Full AI Chat probe light 230,823
Chat Full AI Chat probe dark 229,805
RichTextEditor Markdown parity: side by side probe dark 94,866
RichTextEditor Markdown parity: side by side probe light 89,762
Collapsible Dividers probe dark 66,445
Collapsible Dividers probe light 66,445
Chat Default neutral dark 36,304
Chat Default neutral light 35,767
Chat Empty State probe light 23,531
Chat Empty State probe dark 22,622
ChatAutoScroll Scroll Behavior Comparison neutral dark 15,378
ChatAutoScroll Scroll Behavior Comparison neutral light 14,107
Chat With Attachments probe light 13,406
Chat With Attachments probe dark 11,232
ProgressBar With Target Mark probe dark 7,895
ProgressBar With Target Mark probe light 7,895
Markdown Default neutral dark 7,573
Markdown Default neutral light 7,566

and 20 more.


Generated by PR Enrichment workflow | View full report

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approval waits on a maintainer decision: this prototype rewrites the draft BottomSheetSwitcher requirements FR2, FR8, FR9 and PR1 from one shared dialog to one native dialog per sheet, and a maintainer needs to approve or reject that direction (the PR itself recommends keeping the shared dialog). People using a flow would see no difference: frames match the base in Chromium, WebKit differs only by a hover tint, and the scrim never dips. If the direction goes ahead, these need fixing first:

  • The switcher calls consumer refs itself, so a React 19 callback ref receives null and its returned cleanup never runs.
  • Dialog props are copied onto every sheet dialog, so an id passed to the switcher appears once per sheet.
  • zIndex: 1001 on the non-modal path depends on a page-wide band, which AST-027 FR4 rules out.
  • ref.current is now null while the flow is closed, which breaks released usage, so the Changeset needs [breaking] and a minor bump rather than patch.

[Automated review]

return stableRef.current;
}

function assignRef<T>(ref: React.Ref<T> | undefined, value: T | null): void {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assignRef calls a callback ref, discards what it returns, and later calls ref(null). Under React 19, a callback ref may return a cleanup that runs instead of ref(null) (public-component-api INV8), and utils/mergeRefs.ts already handles this. A cleanup-returning ref receives ref(null) six times in one open, handoff, close and unmount cycle, and its cleanup never runs.

)}
hidden={!isPresented}
<dialog
{...switcherDialogProps}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every sheet dialog spreads the switcher's rest props, so an id or data-testid given to BottomSheetSwitcher renders once per sheet. With two sheets, getElementById returns the first sheet's dialog even after it has closed. Could single-instance attributes go only on the host sheet's dialog?

// would decide which sheet paints in front during a handoff. The interactive
// sheet's dialog takes the next value in the same overlay band instead.
dialogNonModalTop: {
zIndex: 1001,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This orders the interactive non-modal sheet by a page-level value in the 1000 band, which AST-027 FR4 rules out ("MUST NOT … depend on page-wide bands such as … 'overlays at 1000.'"). With no switcher element, nothing provides an isolation owner for this relationship.

@@ -0,0 +1,9 @@
---
'@astryxdesign/core': patch

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ref.current is now null while the flow is closed and moves between sheet dialogs. Code that reads or listens on the released "one shared native dialog" ref must change, which AST-017 FR1 and FR7 classify as [breaking] in the minor tier, not patch.

(visibleTransition.retainedSheet == null
? undefined
: sheetLabels.get(visibleTransition.retainedSheet));
scrimOpacityRef.current = activeSheet == null ? 0 : 1;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This effect now only copies activeSheet into a ref and synchronizes no external system (react-component-runtime INV3). Folding the assignment into applyScrimOpacity keeps the backdrop write in one effect.

This branch was successfully deployed

1 active deployment
Preview — 7943df2e Deployed Oct 19, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot. needs:design-review Affects visuals — Design should review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant