Skip to content

feat(lab): BottomSheetStack — stack Core bottom sheets - #7409

Closed
imdreamrunner wants to merge 1 commit into
mainfrom
feat/lab-bottom-sheet-stack
Closed

imdreamrunner wants to merge 1 commit into
mainfrom
feat/lab-bottom-sheet-stack

Conversation

@imdreamrunner

@imdreamrunner imdreamrunner commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

User need

Product flows on phones need sheets that open over other sheets. A list opens a detail sheet over it, the detail sheet opens an editor, and Back walks down one level at a time with every level's state intact. Some flows need a sheet over one step of a BottomSheetSwitcher. Today BottomSheet presents one panel and BottomSheetSwitcher replaces one step with another, so builders coordinate independent sheets by hand. The result is two native dialogs, two scrims, and focus and dismissal split across them.

Problem and solution fit

BottomSheetStack in @astryxdesign/lab composes the existing Core sheets instead of adding a new controller API:

import {BottomSheet, BottomSheetSwitcher} from '@astryxdesign/core/BottomSheet';
import {BottomSheetStack} from '@astryxdesign/lab';

<BottomSheetStack>
  <BottomSheetSwitcher activeSheet={step} onActiveSheetChange={setStep}>
    <BottomSheet sheetId="categories" label="Categories">…</BottomSheet>
    <BottomSheet sheetId="options" label="Options">…</BottomSheet>
  </BottomSheetSwitcher>
  <HelpSheet isOpen={isHelpOpen} onClose={() => setHelpOpen(false)} />
</BottomSheetStack>
  • Levels. Every Core BottomSheet and BottomSheetSwitcher rendered anywhere under the stack is one level. That holds directly, behind wrapper components with any prop names, inside DOM wrappers, Suspense, Activity and providers, and in mapped lists. A component that renders two sheets gives two levels. The stack never reads its children.
  • Tree order. Levels stack in tree order, not in the order they opened. A sheet rendered inside another level's content stacks directly above that level, so a detail sheet can live next to the row that opens it. Opening several levels in one update (a deep link) shows them all, already in order, in the first frame.
  • Covered levels. The last open level is the top and the only interactive one. Covered levels stay mounted with their state, are inert and hidden from accessibility, and recede in two visible steps: scale 0.96 / −8px, then 0.92 / −16px.
  • Dismissal. Escape, a scrim tap and a swipe close only the top level, through that level's own onOpenChange(false) or onActiveSheetChange(null), and the top level's purpose applies.
  • One shared dialog. hasScrim lives on the stack, which owns one dialog, scrim, modal boundary, scroll lock and dismissal owner. A hasScrim set on a sheet inside the stack is ignored with a development warning.
  • Focus. finalFocusRef stays per standalone sheet and applies when that level closes. When every level closes together, the bottom level's ref wins. Without one, focus returns to the element that opened the first level.
  • Nesting and layers.
    • A BottomSheetStack inside another merges into the outer one: one dialog, one scrim, and a development warning.
    • A Core Dialog, AlertDialog, popover or other layer opened from a level opens above the stack, and a sheet inside that layer stays a standalone sheet above it.

Current specification

Lab has no spec contract for this component, and none is claimed. The direction is related to the draft spec:AST-044 (#6042), which proposes an ordered path on BottomSheetSwitcher. This PR explores the composition alternative in Lab so the two can be compared in use. It changes no Core spec.

Public delta and caller burden

  • Lab: new export BottomSheetStack / BottomSheetStackProps (children, hasScrim, plus ref/onCancel/base props forwarded to the shared dialog). Canary only.
  • Core: no public API change.
    • The props and types of BottomSheet and BottomSheetSwitcher, and the export lists of @astryxdesign/core and @astryxdesign/core/BottomSheet, are unchanged.
    • The only addition is one subpath, @astryxdesign/core/BottomSheet/unstable_stack. Its exports are unstable_-prefixed and documented as Lab-only coordination with no stability promise. It isn't in the root barrel, the component index or the docs.
    • Without a stack, the contexts are null and Core takes the paths it takes today. No provider is added to any Core component's tree.
  • Caller burden:
    • There's no close-all prop: close every level yourself, in one update to animate them out together.
    • A switcher level has no finalFocusRef.
  • Documented caveats:
    • Panels render inside the shared dialog, so CSS inherited from a wrapper element around one sheet doesn't reach its panel. React context does.
    • A sheet placed through the app's own createPortal is ordered by where that portal's content lands in the document.
    • A reorder of memoized children that re-renders neither the stack nor the sheets is applied before the next paint, not in the same commit.

Design and alternatives

  • Registration, not child inspection.
    • Each Core sheet or switcher under a stack registers in a layout effect, with a useId key, a hidden in-place anchor, and the id of the level whose content it's in.
    • It reports {isOpen, isPresented, purpose, label, finalFocusRef, dismiss}.
    • The open set, top, depth and inert all derive from registrations, so the stack never infers anything from element props or shape. Never registering during render keeps uncommitted and StrictMode renders from affecting the stack.
  • Order from the document.
    • Siblings are ordered by anchor document position, with a level's child levels immediately after it.
    • Each level owns a box that its panel portals into, and the stack places those boxes inside its dialog. A covered parent's inert and recede transform therefore never reach a child stacked above it.
    • A MutationObserver covers reorders that run no React callback.
  • First frame. Layout-effect updates flush before paint. The panel mounts one commit after its level is first placed. So the first painted frame has the right order, depth and inert, and showModal runs once.
  • Layer boundaries. A sheet joins a stack only at the stack's own layer depth. Any Core layer increases that depth, so sheets inside a Dialog or popover stay standalone, with no edits to those components.
  • Nested gestures. React events bubble through the panel portals, so a stacked panel ignores gestures that started outside its own element. Nothing stops propagation, so app handlers still see every event.
  • Alternatives considered:
    • Reading isOpen/activeSheet from direct children (the first iteration of this PR) drops wrapped sheets and mis-handles empty or multi-sheet children.
    • An explicit level or slot API reintroduces a direct-child contract.
    • An ordered stack value with onStackChange is the main API alternative; it's tracked by docs(spec): evolve BottomSheetSwitcher with an ordered sheet path #6042.
    • A global stack would change Core defaults and interleave badly with other top-layer modals.
  • Mechanics reused from prior work:

Evidence

  • API audit. Core was built on main and on this branch, and the emitted .d.ts were compared.
    • Root index, BottomSheet/index, Dialog, AlertDialog, Layer and Popover are identical.
    • BottomSheet, BottomSheetSwitcher and BottomSheetPanel differ only in file-header comment lines.
    • The exports map differs only by ./BottomSheet/unstable_stack.
  • Unchanged legacy paths. Existing Core suites pass unedited. 18 Core tests show the seam is inert without a stack, and that a layer between the stack and a sheet makes the sheet standalone.
  • Lab tests. 62 tests, grouped by requirement:
    • sheets anywhere (wrapper with renamed props, div, Suspense, ErrorBoundary, provider, Activity, two-sheet component, mapped list);
    • no prop inference;
    • concurrent React (StrictMode, a suspended transition, Suspense hide/reveal and Activity with state kept, useDeferredValue, SSR + hydration);
    • first frame under flushSync before any microtask;
    • nested stacks;
    • tree order, including memoized reorders and app portals;
    • a sheet inside a Dialog, AlertDialog, popover, Lightbox or MobileNav opened from a level;
    • regression cases: an empty child, two sheets in one component, a sheet inside a switcher step;
    • the earlier level, dismissal, switcher, focus and host rules.
  • Browser (Chromium, 390×844, touch):
    • Recede: matrix(0.96,…,-8) at depth 1 and matrix(0.92,…,-16) at depth 2.
    • Hit-testing: reaches only the top level; covered slivers hit the scrim.
    • Dismissal: Escape, scrim tap and a touch swipe each pop one level.
    • Nested sheets: a touch swipe on a sheet inside another level's content closes only that sheet, and the parent never takes pointer capture. App onClick handlers around the stack still fire.
    • Native calls: one showModal/close per cycle, with close() while the shell is still displayed.
    • Focus: returns to the opener, with a ring only after keyboard input.
    • Dialog from a level: paints above the stack, and a sheet inside it paints above the Dialog.
    • No horizontal overflow, page errors or console errors.

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 exec vitest run packages/core/src/{BottomSheet,Dialog,AlertDialog,Selector,DropdownMenu,ContextMenu,DateInput,DateTimeInput,TimeInput,Tokenizer,Layer,Layout,CommandPalette,MoreMenu,Popover,theme,Toast,Lightbox,MobileNav}/ \
  packages/lab/src/{BottomSheetStack,MobileTokenizer}/
  branch: 108 files / 3945 tests passed; main: 106 / 3865 (delta = the 2 new test files)
pnpm -F @astryxdesign/core typecheck            # clean
pnpm -F @astryxdesign/lab typecheck             # only the existing MobileTokenizer.test.tsx error also on main
ASTRYX_STRICT_LINT=1 pnpm exec eslint <touched dirs>   # 0 errors
pnpm check:sync && pnpm check:knowledge && pnpm check:package-boundaries && pnpm check:changesets
node scripts/sync-exports.js --check            # up to date
pnpm storybook:build                            # Storybook build completed successfully

Stories under Lab/BottomSheetStack:

  • Orders drill-in with Close all
  • Deep link at depth 3
  • Filters (switcher base + help overlay)
  • Contacts (switcher top: edit → saved)
  • File browser (mapped, unknown depth)
  • Sheets anywhere (row-owned detail sheet, Suspense, a Dialog over the stack)
  • Nested stack merges

@vercel

vercel Bot commented Oct 13, 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 13, 2026 10:37pm UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 13, 2026
@astracat-bot

astracat-bot Bot commented Oct 13, 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:47 PM UTC

@github-actions

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

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

New Components

BottomSheetStack (@astryxdesign/lab)
Metric Value
Bundle Size (ESM) N/A
Bundle Size (CJS) 332B
Lines of Code 552
Source Files 2
Complexity Very High (118)
Exports BottomSheetStack
Props Count N/A
Has Tests Yes
Has Stories Yes

Bundle Size Summary

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

Accessibility Audit

Status: No accessibility violations detected.

Visual Regression

44 of 448 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 238,081
Chat Full AI Chat probe dark 237,048
Markdown Default probe light 159,625
Markdown Default probe dark 158,986
Collapsible Dividers probe dark 66,445
Collapsible Dividers probe light 66,445
Chat Default neutral dark 33,205
Chat Default neutral light 32,876
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
TableRowExpansion Detail Panel neutral dark 5,150
TableRowExpansion Detail Panel neutral light 4,068

and 24 more.


Generated by PR Enrichment workflow | View full report

Lab host that stacks Core BottomSheet and BottomSheetSwitcher levels in one
shared 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 host 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.

Core gains one unstable_-prefixed, Lab-only subpath
(@astryxdesign/core/BottomSheet/unstable_stack): a host context, a
parent-level context, and the level protocol hook. A sheet joins a stack only
at the stack's own layer depth, so a sheet opened inside a Core Dialog,
AlertDialog, or popover layer stays a standalone sheet above it. Without a
host the contexts are null and Core behaves as before. No public prop, type,
or barrel export changes.
@imdreamrunner
imdreamrunner force-pushed the feat/lab-bottom-sheet-stack branch from e7c65b4 to 7c64844 Compare October 13, 2026 22:28
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.

@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 is on hold until a maintainer provides or identifies the spec that governs the Lab BottomSheetStack: a new component record, amendments to the BottomSheet records, or the ordered-path direction in draft spec:AST-044, covering tree-order levels in one shared dialog, inert receded covered levels, top-only dismissal and focus return. Independently of that decision, these changes are needed:

  • Move @astryxdesign/core/BottomSheet/unstable_stack behind the /experimental boundary that AST-017 FR21 defines (with @experimental declarations, stability: 'experimental' docs and an [experimental] Changeset), or stop exporting it, because a reachable Core subpath is public API whatever its prefix.
  • Keep a stacked BottomSheetSwitcher's ref, onKeyDown, onClick, onCancel and aria-label working, or warn in development and document that they are dropped.
  • Keep DOM nodes out of React state in the level protocol (architecture:react-component-runtime INV1), and document or warn that content rendered under the stack outside a sheet is not shown.
  • Split the 5,897 authored lines into separately reviewable pieces, such as the Core seam and the Lab host.

[Automated review]

"types": "./dist/Markdown/remark.d.ts",
"default": "./dist/Markdown/remark.js"
},
"./BottomSheet/unstable_stack": {

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 entry point makes the unstable_ exports public: architecture:public-component-api INV12 treats anything reachable from a published path as public whatever its name, and AST-017 FR21 keeps experimental hooks, functions and types out of the stable promise only on the owning component's /experimental subpath, with @experimental declarations and stability: 'experimental' docs. Suggest ./BottomSheet/experimental with those markers and an [experimental] Changeset, or no export.

'@astryxdesign/core': patch
---

[chore] `BottomSheet` and `BottomSheetSwitcher` read internal, `unstable_`-prefixed stack contexts (`@astryxdesign/core/BottomSheet/unstable_stack`) that let an external host present several sheets inside one shared dialog; a sheet under such a host registers itself with it from wherever it is rendered. Without such a host the contexts are null and both components behave exactly as before. The subpath is Lab-only coordination with no stability promise and is not part of the documented API.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[chore] files a new Core package entry point as an internal change; AST-017 FR22 expects [experimental] for an explicitly experimental surface (or [feat] if it is meant to be stable).

isStacked && hasScrim !== undefined,
);

if (stackHost != 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.

In this branch the switcher's ref, onKeyDown, onClick and onCancel are never attached, and aria-label is spread onto a role-less div. With identical props the ref stays null (standalone: the DIALOG), both handlers fire 0 times (standalone: once), and the shared dialog is named after the active sheet instead of the caller's label. Please compose the handlers onto the level element and connect or warn about the rest (public-component-api INV5, INV8).

// The level owns its box so a re-registration (StrictMode, Suspense,
// Activity) keeps the same portal target and the panel keeps its state.
// Created on the client only; the server renders no portal.
const [box] = useState(() =>

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 keeps a DOM node in React state, and the host keeps anchor and box elements in its entries state. architecture:react-component-runtime INV1: 'DOM nodes and whole DOM collections MUST NOT be stored in React state; store only the minimal rendering key or own the node through a ref.'

ref={slotRef}
data-bottom-sheet-stack-levels=""
/>
<div hidden ref={childrenRegionRef} data-bottom-sheet-stack-children="">

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Everything under the stack that is not inside a sheet renders here, hidden inside the dialog, so a trigger button placed directly under the stack is invisible and out of the accessibility tree, with no warning. The doc says wrapper components and DOM wrappers 'all work'; please say that only sheet content is shown (and warn in development), or render this region in place outside the dialog.

@imdreamrunner

Copy link
Copy Markdown
Contributor Author

Closing: superseded by BottomSheet's open-order stacking and same-update replace, merged in #7648.

@github-actions
github-actions Bot deleted the feat/lab-bottom-sheet-stack branch October 28, 2026 06:17

This branch was successfully deployed

1 active deployment
Preview — 7c64844b Deployed Oct 13, 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