Skip to content

feat(core): BottomSheetSwitcher ordered activeSheets path (AST-044) - #6728

Draft
imdreamrunner wants to merge 6 commits into
mainfrom
feat/switcher-active-sheets
Draft

imdreamrunner wants to merge 6 commits into
mainfrom
feat/switcher-active-sheets

Conversation

@imdreamrunner

Copy link
Copy Markdown
Contributor

Draft — staged behind spec review. This implements the candidate contract of spec:AST-044 (proposed in #6042, authority: draft). It should not merge before that record is accepted; the spec PR itself is not modified here.

User need

Product flows need drill-in bottom sheets: from a sheet presenting a list, open a details sheet above it while the list sheet stays visible as receded context, then go back with the list sheet's state and focus intact. Today BottomSheetSwitcher presents exactly one sheet at a time, so builders coordinate independent standalone sheets by hand — ordering, Back, close-all, deep links, and cross-sheet focus recovery all live outside the component contract, and a second native dialog breaks the one modal boundary the switcher promises.

Problem and solution fit

One additive capability: the switcher's controlled value generalizes from one id to an ordered bottom-to-top path of ids (activeSheets). The existing shared host, child registration, dismissal routing, and focus machinery extend in place — no new component and no second coordinator, so a sheet the switcher already opened gains stacking by appending to the same list. Every delta in this PR (props, covered-sheet presentation, validation, focus memory, docs, tests) serves that one capability.

Current specification

spec:AST-044 — BottomSheetSwitcher ordered path and overlay policy (docs/specs/AST-044-bottom-sheet-switcher-path/spec.md, proposed in #6042):

  • DEC-1 — one explicit ordered path (activeSheets), singular activeSheet as its length ≤ 1 projection; no imperative navigation API, no new component.
  • DEC-2 — retain released hasScrim presentation; add only finalFocusRef; no modality prop.
  • DEC-3 — implicit dismissal (Escape / platform close / visible modal scrim / top-sheet swipe) pops one visible level; close-all stays an explicit caller update.
  • DEC-4 — covered panels stay mounted, inert, and accessibility-hidden behind one host, with internal recede geometry.
  • DEC-5 — invalid ids present the longest valid prefix, warn in development, and never rewrite the controlled value.

The record is still draft, so this PR is review material for that contract, not an implementation of accepted policy. The component contract (BottomSheetSwitcher.spec.md, also draft) is updated in this PR to describe the projected behavior and drops its former "no ordered stack" non-goal.

Public delta and caller burden

New optional props on BottomSheetSwitcher (index exports unchanged; child BottomSheet API unchanged):

  • activeSheets?: ReadonlyArray<string> — canonical controlled path, ordered bottom-to-top. [] closes; append one id to push; drop the final id to pop; replace a suffix to change branch. Only the last id is interactive; covered ids stay mounted, inert, aria-hidden, and visually receded (bounded internal treatment).
  • onActiveSheetsChange?: (nextIds, details: {reason: 'escape' | 'scrim' | 'swipe'; dismissedSheetId: string}) => void — implicit dismissal requests the presented path without its final id.
  • finalFocusRef?: React.RefObject<HTMLElement | null> — preferred focus destination after [] + exit; falls back to the captured opener in modal flows; a non-modal close never steals focus that left the flow.

Compatibility:

  • The released singular pair activeSheet / onActiveSheetChange keeps its exact shipped behavior (open, single-level enter/align/fade handoff, dismiss-to-null) as a length ≤ 1 projection; it receives nextIds.at(-1) ?? null and no details. The two props are now optional in types, but exactly one controlled pair is expected; supplying both warns in development and the list form wins.
  • hasScrim keeps its released showModal()/show() coupling and meaning at every path length. No modality prop is added.
  • Invalid ids (empty, repeated in the value, unknown, or ambiguously registered) cut presentation to the longest valid leading prefix with a development-only warning; the controlled value is never rewritten and no callback fires to normalize it.

Representative callsites:

// Released singular form — unchanged
<BottomSheetSwitcher activeSheet={sheet} onActiveSheetChange={setSheet}>

// New list form — drill-in stack
const [activeSheets, setActiveSheets] = useState<ReadonlyArray<string>>([]);
<BottomSheetSwitcher activeSheets={activeSheets} onActiveSheetsChange={setActiveSheets}>
  <BottomSheet sheetId="issues" label="Issues">
    <IssueList onSelect={() => setActiveSheets(['issues', 'issue-details'])} />
  </BottomSheet>
  <BottomSheet sheetId="issue-details" label="Issue details">
    <Button label="Back" onClick={() => setActiveSheets(c => c.slice(0, -1))} />
    <Button label="Close all" onClick={() => setActiveSheets([])} />
  </BottomSheet>
</BottomSheetSwitcher>

Caller burden: pick exactly one controlled form; keep path ids non-empty, unique, and matching mounted sheetIds; express Back/close-all as ordinary state updates.

Design and alternatives

Chosen: a caller-owned, serializable ordered path interpreted by the existing switcher host — push/pop (and the released single-level replacement) animate; every other edit (multi-pop, branch replacement, reorder, deep link) converges deterministically without bespoke choreography. Alternatives the spec rejects: a separate BottomSheetStack component (cannot compose with a sheet the switcher already opened; splits Back/close-all across two state machines); inferring order from independent isOpen booleans or opening chronology (cannot express restored routes or intentional branches); an imperative push()/pop()/history API (duplicates caller-owned route state); an independent modality prop (deferred family-wide decision, AST-044 DEC-2).

Evidence

  • packages/core/src/BottomSheet/ suite: 235 passed (7 files), including 40 BottomSheetSwitcher tests — all 29 pre-existing switcher tests pass unchanged (legacy singular regression), plus 11 new ordered-path tests: list open/push/pop, close-all from depth, deep initial path, dismissal details per channel (escape/scrim/swipe), controlled refusal, both-forms warning, unknown-id longest-prefix + warning, duplicate-registration unavailability, covered-focus record/restore, finalFocusRef return, and singular-projection behavior.
  • tsc (package + docs projects), eslint on the touched files, and Prettier are clean; the full repo check:repo suite (knowledge records, SYNC comments, changesets, package boundaries, and friends) passed in the pre-commit hook.
  • Not included here: real Chromium/Safari evidence for the browser-owned claims (top-layer behavior, focus entry/return, inertness, scroll locking, reduced motion). AST-044's verification matrix requires that before the API is released; jsdom is not a substitute.

Scope

  • One capability is added.
  • Unrelated fixes, cleanup, and policy changes were removed or split.
  • Consumer docs, focused tests, and migration evidence ship with the feature.
  • Public text and artifacts contain no internal Meta context.

Testing

pnpm -F @astryxdesign/core test src/BottomSheet/        # 235 passed (40 switcher)
pnpm -F @astryxdesign/core typecheck
pnpm -F @astryxdesign/core typecheck:docs
pnpm -F @astryxdesign/core exec eslint src/BottomSheet/
pnpm check:knowledge

State matrix exercised in unit tests: empty / one / two levels; push, pop, close-all, deep link; modal and non-modal hosts; info/form/required purposes; invalid, duplicate, and unmounting ids; rapid replacement; focus capture, intra-stack restore, and final return. Real-browser matrix tracked by spec:AST-044 before release.

Add the controlled activeSheets list API to BottomSheetSwitcher per
spec:AST-044 (vibe-test winner: list form):

- activeSheets: ReadonlyArray<string> is the canonical bottom-to-top path;
  [] closes, append pushes a drill-in sheet, dropping the final id pops,
  replacing a suffix changes branch. Only the top id is interactive.
- onActiveSheetsChange(nextIds, {reason: 'escape'|'scrim'|'swipe',
  dismissedSheetId}) reports implicit one-level dismissal; close-all stays
  an explicit caller update.
- Released singular activeSheet/onActiveSheetChange stays a length <= 1
  projection with identical shipped behavior (including the single-level
  enter/align/fade handoff); supplying both forms warns in dev and the
  list form wins.
- Covered sheets stay mounted, inert, aria-hidden, and recede with a
  bounded stack transform; push records covered focus, pop restores it.
- Invalid ids (empty, repeated, unknown, ambiguous registration) present
  the longest valid prefix, warn once per condition in dev, and never
  rewrite the controlled value.
- finalFocusRef names the preferred focus destination after [] + exit;
  modal flows fall back to the captured opener, non-modal closes never
  steal focus that left the flow.
- hasScrim keeps its released showModal()/show() coupling at every depth;
  no modality prop, no new component, index exports unchanged.
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Sep 28, 2026
@vercel

vercel Bot commented Sep 28, 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 Sep 29, 2026 6:26pm UTC

Request Review

@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Sep 28, 2026
@astracat-bot

astracat-bot Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
❓ Needs maintainer input (for maintainers only) Oct 19, 2026, 11:50 PM UTC

@github-actions

github-actions Bot commented Sep 29, 2026 •

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

1 of 420 shot(s) changed. View the report

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
SideNav Resizable in AppShell probe dark 3,208
SideNav — Resizable in AppShell — probe dark
BeforeAfterDiff
Before visual regression frame After visual regression frame Pixel difference frame

Generated by PR Enrichment workflow | View full report

…eetSwitcher example

The docsite renders a component's preview from its isShowcase block (via the
generated showcase registry), not from the doc.mjs examples array, so the new
list API never surfaced in the preview. Make the drill-in stack primary on
every surface:

- BottomSheetSwitcherShowcase (the docsite Overview preview) is now an
  issues drill-in on activeSheets: list -> details push -> activity push,
  Back pops, Close all clears, covered sheets recede.
- The Properties-tab playground drives activeSheets too: it opens to the
  stacked path ['details', 'preferences'], and implicit dismissal pops one
  level (onActiveSheetsChange documents its first parameter as
  activeSheets so the preview auto-wires it).
- doc.mjs examples[] puts the drill-in first; the singular three-step flow
  stays second, labeled as the singular form.
- BottomSheetSwitcherReviewFlow (low-value two-step review) becomes the
  legacy singular multi-step example; its old ShadCN route is preserved as
  an alias and the reviewed route lock is refreshed accordingly.
- Storybook: 'Drill-in Stack (activeSheets)' and its three-level variant
  are the first stories; the singular stories stay, relabeled
  'Legacy Singular (activeSheet) — ...'.
…ts flow

Replace the separate issues drill-in and singular three-step examples with
one flow: the notification-setup wizard on the ordered activeSheets path.
Steps replace each other exactly like the released singular flow, and the
first step pushes a stacked help sheet above itself, so a single example
shows both the familiar step handoff and the new stacking (covered step
recedes, Back pops one level).

Applied consistently:
- BottomSheetSwitcherShowcase (docsite Overview preview) is the unified flow.
- The Properties playground gains a details-help sheet and opens to the
  stacked path ['details', 'details-help'].
- doc.mjs examples[]: the unified flow is primary; the released singular
  three-step flow stays second as the compat reference.
- Storybook: 'Multi-step + Stacked Help (activeSheets)' is the first story,
  starting on the stacked path so the recede is immediately visible; the
  'Legacy Singular (activeSheet)' stories stay to cover the released API.
- BottomSheetSwitcherReviewFlow (Examples section) remains the legacy
  singular multi-step example; no route changes.
…sheets

PROTOTYPE pending spec:AST-044 OQ5 design review — not accepted recede
geometry:

- Covered stacked sheets now blur and dim with visual depth (capped at 2):
  depth 1 gets blur(2px) brightness(0.92), depth 2 blur(4px)
  brightness(0.84); the top sheet carries no filter. The filter rides the
  existing recede transition and its reduced-motion collapse (state + CSS
  only, no per-frame JS); willChange stays on transform. It applies only at
  depth > 0, where the positioner already has a transform, so the
  containing-block effect on fixed descendants is unchanged and the level is
  inert either way.
- Unified example: the first step and its stacked help sheet now share
  height="capped" so the stacked sheet presents at the same height as the
  covered step receding behind it — applied to the showcase block, the
  playground children, the primary doc example, and the primary Storybook
  story. No new height API.
- Test asserts the covered layer's depth filter alongside its transform.
@imdreamrunner

Copy link
Copy Markdown
Contributor Author

Prototype note (commit 35784c2): two experimental additions for evaluation, both pending spec:AST-044 OQ5 design review — neither is accepted recede geometry:

  1. Depth blur/dim on covered sheets — covered stacked levels now carry a depth-scaled filter (d1: blur(2px) brightness(0.92), d2: blur(4px) brightness(0.84), visual depth capped at 2; top sheet unfiltered). It rides the existing recede transform's transition and reduced-motion collapse — CSS only, no per-frame JS.
  2. Example height parity — in the unified example, the first step and its stacked help sheet both use height="capped" so the stacked sheet presents at the same height as the covered step receding behind it (showcase, playground, primary doc example, primary Storybook story). Existing API only.

Both are easy to strip or retune once OQ5 settles the real geometry.

…ontent

Owner corrections on the OQ5 prototype:

- Halve the covered-sheet filter: depth 1 is now blur(1px)
  brightness(0.96), depth 2 blur(2px) brightness(0.92). Same depth
  scaling, transition, and reduced-motion collapse.
- Drop the fixed height="capped" parity: the first step and its stacked
  help sheet return to natural hug heights, and the help sheet's content is
  expanded (labeled explanatory rows matching the step's structure) so its
  hug height closely matches the covered step's — applied to the showcase
  block, playground children, primary doc example, and the primary
  Storybook story. No height API involvement.
The stacked help sheet sat slightly taller than the covered first step at
hug height; dropping its third labeled row ('Easy to change later') brings
the two natural heights back level in the showcase block and the primary
Storybook story (the regenerated docsite showcase follows). No height
props, no filter change.
@rubyycheung

Copy link
Copy Markdown
Contributor

I recommend not using blur as the default covered-sheet treatment.

  • Scale and translation already communicate depth; subtle dimming can de-emphasize the covered sheet without adding another effect.
  • Blur makes the retained sheet harder to recognize as preserved context, especially at small type and dense mobile layouts.
  • A CSS filter adds compositing cost during an already animated transition and is more likely to vary across browsers/devices.
  • Blur can produce unpredictable contrast around text and controls based on the content underneath, which increases visual and accessibility QA burden.

Because covered sheets are already inert and hidden from accessibility APIs, blur does not add semantic value. I would keep the bounded scale/translation treatment and, if more separation is needed, use a light content-independent dimming layer instead. The current code correctly labels blur as prototype geometry pending design review; my vote is to remove it before this becomes the canonical treatment.

imdreamrunner added a commit that referenced this pull request Oct 14, 2026
Core host that stacks BottomSheet and BottomSheetSwitcher levels in one
shared native dialog. Every sheet rendered anywhere under the stack is one
level: through wrapper components with any prop names, DOM wrappers,
Suspense, Activity, providers, and mapped lists. The stack never reads its
children; each level registers itself in a layout effect (useId key, hidden
in-place anchor, parent-level id, level-owned portal box) and reports open,
presented, purpose, label, finalFocusRef, and dismiss. Order is tree order
(anchor document position, a sheet inside a level's content right above it),
derived every commit, with a MutationObserver applying memoised reorders
before the next paint. Only the top level is interactive; covered levels
stay mounted, inert, and receded. Escape, scrim tap, and swipe close the top
level through its own callback; a stacked panel ignores gestures that
started outside it. A nested stack merges into the outer one with a dev
warning. A sheet joins a stack only at the stack's own layer depth, so a
sheet opened inside a Dialog, AlertDialog, or popover layer stays a
standalone sheet above it.

Replaces the Lab prototype (#7409): the Lab-only
@astryxdesign/core/BottomSheet/unstable_stack subpath and its unstable_
exports are gone; the host context, parent-level context, registration hook
and layer-depth check are internal modules (BottomSheetStackContext.ts), and
the stack reuses BottomSheetEdgeTint and focusForLastInput instead of
copies. BottomSheetStack and BottomSheetStackProps are exported from
@astryxdesign/core/BottomSheet and the package root; no new subpath.

Additive only: BottomSheet and BottomSheetSwitcher keep their props, types,
and behavior outside a stack, and the existing Core suites pass unedited.
Staged behind the AST-044 API decision alongside #6728.
imdreamrunner added a commit that referenced this pull request Oct 16, 2026
A modal BottomSheet opened while another modal sheet is open stacks on top
of it: the covered sheet stays mounted with its state, recedes (0.96 / -8px,
then 0.92 / -16px) and is blocked; only the bottom sheet's backdrop dims the
page; Escape, a scrim tap, a swipe and a platform close request pop only the
top sheet through its own callback. Order is open order wherever the sheets
are written (siblings, separate components, inside another sheet's content,
inside a BottomSheetSwitcher step; a whole switcher is one level). Sheets
opened in one commit are ordered by tree, right in the first painted frame,
through a private commit-phase registry that reconciles the native top layer.
A sheet rendered in a closing sheet's content closes with it. No public API
change; a sheet on its own behaves exactly as before.

Candidate C for the AST-044 stacked-sheet decision, alongside #6728 and #7503.

@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: nothing on main yet authorizes the activeSheets path, onActiveSheetsChange or finalFocusRef, so a maintainer needs to accept spec:AST-044 (#6042) as current, including its covered-sheet treatment (OQ5), and approve or reject the FR10–FR15 requirements this PR adds to BottomSheetSwitcher.spec.md. Separately from that decision, a few changes are needed before this can land:

  • The change is 2,040 authored lines; moving the template, story and example rewrites (about 720 lines) to a follow-up brings it under the scope limit.
  • Sheet presence is registered in an effect that sets switcher state, so the first render presents an initially open flow, including the released activeSheet form, as closed and repairs it afterwards.
  • With both controlled pairs optional, a switcher with no pair, or activeSheets without onActiveSheetsChange, compiles and fails silently; a discriminated union or a development warning would catch it.
  • An activeSheet that no mounted sheet registers no longer opens the dialog and warns about activeSheets[0], which the changeset's "exact shipped behavior" note does not reflect.
  • The review-flow example rewrite and rename and the "Legacy Singular" story titles sit outside the capability and are better dropped or split, and the branch needs a rebase because BottomSheet.tsx conflicts with main.

[Automated review]

if (!hasValidSheetId) {
return;
}
return registerSheetPresence(sheetId);

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 registration sets switcher state, so the first render validates every requested id against an empty registry and presents [], including for the released activeSheet form; the selected sheet's layer is hidden on the first commit and repaired by the re-render (react-component-runtime INV1, INV3). Presenting requested ids until children have registered once would keep the first render valid.

* forms warns in development and the list form wins.
*/
activeSheet: string | null;
activeSheet?: string | null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

With this and the list pair all optional, <BottomSheetSwitcher> with no pair, or activeSheets without onActiveSheetsChange, compiles and fails silently (AST-002 FR15). A discriminated union of the two controlled pairs, each with its callback required, would keep the released requiredness and make the both-forms case unrepresentable.

valid prefix and warn in development.

The change is additive: the released singular `activeSheet` /
`onActiveSheetChange` pair keeps its exact shipped behavior as a length ≤ 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.

Singular ids now go through the prefix rule: an activeSheet that no mounted sheet registers used to open the dialog and now stays closed with a warning naming activeSheets[0]. Either keep the shipped singular handling or describe the change here.

This branch was successfully deployed

1 active deployment
Preview — b678f81a Deployed Sep 29, 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.

2 participants