Skip to content

feat(core): BottomSheetStack — stack bottom sheets in one dialog - #7503

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

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

Conversation

@imdreamrunner

Copy link
Copy Markdown
Contributor

Draft, staged behind the AST-044 API decision. This PR exists so the two Core
candidates for stacked bottom sheets can be compared side by side: this one
(BottomSheetStack, a host that stacks independently controlled sheets) and
#6728 (BottomSheetSwitcher activeSheets, the ordered-path form from the draft
spec AST-044 / #6042). Like #6728, it is not meant to merge before that decision.
It replaces the Lab prototype in #7409, which split one feature across two
packages through a Lab-only unstable_stack subpath.

User need

Touch flows drill in: a list opens an item, the item opens an editor, a filter
step opens its help. Builders want each step to stay visible beneath the next, so
the person keeps their place and can back out one step at a time, and they want
to express that without hoisting every step's state into one coordinator.

Today each BottomSheet owns its own native <dialog>. Opening a second sheet
over a first one opens a second modal: the first disappears behind a second
scrim, Escape and the scrim are resolved by whichever dialog happens to be
newest, focus return skips the sheet in between, and the two sheets cannot
recede as a stack. BottomSheetSwitcher keeps several steps in one dialog but
by design shows exactly one at a time (replace, not stack), so a "sheet over a
sheet" has no supported composition.

Why that harms the task: a person loses the context they came from (the list is
gone, not receded), and a wrong Escape or scrim tap closes the whole flow rather
than one step. Why that matters: these are the most common mobile flows the
system is meant to make easy, and every team currently rebuilds the stacking by
hand or avoids it.

Problem and solution fit

BottomSheetStack is one host that stacks every BottomSheet
(isOpen / onOpenChange) and BottomSheetSwitcher
(activeSheet / onActiveSheetChange) rendered anywhere under it, in one
shared native dialog with one scrim, one modal boundary, one scroll lock and
one dismissal owner. Each sheet keeps its existing controlled API and its own
state; the stack only decides order, which level is interactive, and how
covered levels recede.

Primary delta: the new component and its docs, tests, stories and showcase.
Supporting delta: BottomSheet, BottomSheetSwitcher and the shared panel gain
a third, internal rendering path used only inside a stack. No tagalongs: no
existing prop, type, default or behavior changes outside a stack.

API

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

function Orders() {
  const [isListOpen, setIsListOpen] = useState(false);
  const [orderId, setOrderId] = useState<string | null>(null);
  const [isEditing, setIsEditing] = useState(false);

  return (
    <BottomSheetStack>
      <BottomSheet isOpen={isListOpen} onOpenChange={setIsListOpen} label="Orders">
        {orders.map(order => (
          <Button key={order.id} label={order.title} onClick={() => setOrderId(order.id)} />
        ))}
      </BottomSheet>
      <BottomSheet
        isOpen={orderId != null}
        onOpenChange={open => !open && setOrderId(null)}
        label="Order">
        <Button label="Edit note" onClick={() => setIsEditing(true)} />
      </BottomSheet>
      <BottomSheet isOpen={isEditing} onOpenChange={setIsEditing} label="Edit note" purpose="form">
        …
      </BottomSheet>
    </BottomSheetStack>
  );
}

BottomSheetStackProps is BaseProps<HTMLDialogElement> plus:

Prop Type Default Meaning
children ReactNode — The subtree whose sheets and switchers are the levels, wherever they are rendered in it.
hasScrim boolean true Whether the one shared dialog is modal with a scrim. Set here only.
ref Ref<HTMLDialogElement> — The shared native dialog.
onCancel (event: SyntheticEvent<HTMLDialogElement>) => void — Runs before the stack answers a native cancel with the top level's purpose.

Behavior

  • Levels anywhere. Every sheet or switcher under the stack is a level, through
    the builder's own wrapper components (whatever their props are called), DOM
    wrappers, Suspense, Activity, context providers, portals and mapped lists.
    The stack never reads its children or infers anything from props; each level
    registers itself in a layout effect with a useId key, so a render that never
    commits registers nothing and StrictMode leaves one entry per level.
  • Tree order. Levels stack in document order of their in-place anchors, with a
    sheet rendered inside another level's content directly above that level. Order
    is re-derived on every commit; a reorder of memoized subtrees that re-renders
    nothing is caught by a MutationObserver and applied before the next paint.
  • Top-only interaction. The last open level is the top. Covered levels stay
    mounted with their scroll position and inputs, and are inert and
    aria-hidden; pointer input over a covered level's visible sliver reaches the
    scrim, never the covered panel.
  • Recede. The nearest two covered levels shrink and lift a step each
    (scale(0.96)/-8px, scale(0.92)/-16px); deeper levels share the second
    step. CSS motion only, reduced-motion aware.
  • One-level dismissal by purpose. Escape, a scrim tap and a downward swipe
    each close only the top level, through that level's own callback
    (onOpenChange(false) or onActiveSheetChange(null)), as the top level's
    purpose allows: info allows all three, form blocks swipe and scrim,
    required blocks all three and the shared dialog takes role="alertdialog".
    A stacked panel ignores gestures that started outside it, so a swipe on a
    nested child never also drags its parent.
  • Focus. Opening a level moves focus into it. Closing the top level returns
    focus to the control in the level beneath that opened it, when it is still
    there; otherwise focus enters the new top. When the last level closes, focus
    returns to the bottom level's finalFocusRef if it has one, else to the element
    that opened the first level, with the ring following the last input (no ring
    after a pointer close). One showModal() / close() pair per stack cycle.
  • Nested stacks merge. A stack rendered inside another stack at the same
    layer depth becomes a pass-through: its sheets join the outer stack at that
    place in the tree, and a development warning says its own props are ignored.
  • Core layers above the stack. A sheet joins a stack only at the stack's own
    layer depth. Dialog, AlertDialog, popover-based layers, Lightbox and
    MobileNav already push their content one layer deeper, so a sheet opened
    inside one of them is a standalone sheet above that layer, and a stack inside
    it is its own stack. The same holds for a LayerDepthProvider the builder
    places. No change to those layers.

Current specification

No current record admits stacked bottom sheets yet. The behavior is the subject
of draft spec AST-044 (#6042), which #6728 implements as activeSheets on
BottomSheetSwitcher. This PR is the other candidate for that decision and does
not create or amend a record; the owning record and the overlay-dismissal
family membership follow the decision.

Public delta and caller burden

Additive only.

  • New: BottomSheetStack and BottomSheetStackProps, exported from
    @astryxdesign/core/BottomSheet and the package root through the existing
    barrel. No new package subpath.
  • Unchanged: every BottomSheet and BottomSheetSwitcher prop, type, default
    and behavior outside a stack. Their only new behavior is as a level inside a
    stack. The emitted .d.ts for both differs from main only in file-header
    comment lines; the exports map is identical; the existing Core suites pass
    unedited.
  • Removed relative to feat(lab): BottomSheetStack — stack Core bottom sheets #7409: the Lab-only @astryxdesign/core/BottomSheet/unstable_stack
    subpath and every unstable_ export. The host context, parent-level context,
    registration hook and layer-depth check are internal modules.

Caller burden: wrap the flow in one BottomSheetStack; keep each level's open
state where it already lives; set hasScrim on the stack rather than on a level
(a level's hasScrim is ignored with a development warning); close every level
yourself when the flow is done (there is deliberately no close-all prop, see
below). A single open level behaves like a normal sheet.

Caveats

  • Non-sheet content inside the stack is hidden. The children subtree is
    rendered into a hidden region of the dialog so a wrapper's own output (a
    Suspense fallback, a padded div) never shows on the scrim. Put triggers
    and page content outside the stack; put sheets inside.
  • CSS inheritance does not reach a portaled panel. Each level's panel is
    rendered into the shared dialog, not where the sheet sits in the markup, so a
    font or color set on a wrapper div does not apply to that panel. React
    context does, so a ThemeProvider around a sheet still applies.
  • App-owned portals order by where they land. A sheet rendered through the
    builder's own createPortal is ordered by where that portal's content lands
    in the document, not by where the portal is called.
  • Memoized-reorder timing. A reorder that re-renders no level is applied by
    the mutation observer before the next paint, one step after React's own
    commit; tests that assert order synchronously after such a reorder need an
    act() flush.
  • No close-all prop. Closing everything is an app decision (which level's
    callback, what state to reset), so callers close every level themselves; a
    one-update close animates every level out together and returns focus once.
  • A switcher at the bottom has no finalFocusRef. BottomSheetSwitcher has
    no such prop today, so when a switcher is the bottom level and the stack
    closes, focus returns to the element that opened the first level.

Design and alternatives

Chosen model: a host that stacks independently controlled sheets. The stack
owns presentation (one dialog, scrim, modal boundary, scroll lock, dismissal
owner) and derives everything else — the open set, the top, each level's depth,
inert — from what the sheets report. State stays where the data lives, in the
components that already own it, and composition stays local: a detail sheet can
live next to the row that opens it, inside the list sheet, with no hoisting.
Tree order rather than open order makes the result deterministic under
deep links and reorders.

Alternative: an ordered path on BottomSheetSwitcher (#6728, AST-044).
activeSheets: ReadonlyArray<string> names the bottom-to-top path of sheet ids;
onActiveSheetsChange(nextIds, {reason, dismissedSheetId}) reports one-level
dismissal; finalFocusRef names the destination after []. Both candidates
agree on the user-facing rules (top-only interaction, covered levels mounted,
inert and receded, one-level dismissal by purpose, recorded covered focus,
hasScrim kept on the shared dialog). They differ in where the model lives:

Concern BottomSheetStack (this PR) BottomSheetSwitcher activeSheets (#6728)
Source of truth for order The tree: document order of levels, derived each commit The caller: an explicit ordered id list
Level identity None required; each sheet keeps isOpen sheetId on every child; invalid or unknown ids are validated and warned
Where sheets may render Anywhere under the stack, including inside another level's content Direct children of the switcher (the released sheetId contract)
Mixing with a switcher A whole switcher is one level; standalone sheets and switchers mix Everything in the flow is a switcher child
Close-all Caller closes each level; no prop activeSheets = []
Dismissal report The top level's own callback One callback with reason and dismissedSheetId
New API surface One new component with four props Three new props on an existing component; no new component
Deep link Set several isOpen in one update; order is the tree Pass the full path
Hidden cost Children subtree is hidden; portaled panels lose CSS inheritance Flow state must be hoisted into one list even when steps belong to different owners

Both are additive and both keep the standalone paths untouched, so the decision
is about the model builders should learn, not about compatibility.

Rejected inside this design: a global stack keyed by mount order (not
deterministic under deep links and reorders); inferring levels from children
props (breaks through wrappers and renames); a per-level hasScrim (two
modalities in one dialog); a close-all prop (hides an app decision about which
state resets).

Evidence

  • Unit (jsdom): the Core suite covers sheets anywhere with no prop inference;
    concurrent React (transitions that suspend register nothing until commit;
    useDeferredValue); the first frame under flushSync (right order, depth and
    inert with no microtask); nested stacks merging; tree order including
    memoized reorders; Core layers above the stack staying standalone through the
    layer-depth rule (Dialog, AlertDialog, Popover, Selector, Lightbox,
    MobileNav); nested-sheet gesture gating, close sequencing and focus rules;
    the known-bug regressions; and SSR/hydration. The internal context suite shows
    the contexts are inert without a stack: a standalone BottomSheet and
    BottomSheetSwitcher own their own modal dialog, render no anchor and no
    recede, and do not warn.
  • Existing Core suites pass unedited, and the listed overlay suites match main.
  • API audit: emitted .d.ts and the exports map diffed against main (see
    Testing).
  • Browser (Chromium 390×844, touch, static Storybook): recede matrices, top-only
    hit-testing over covered slivers, Escape / scrim / touch swipe each popping one
    level, a nested touch swipe closing only the child (pointer capture stays on
    the child), one showModal/close per cycle, focus return with the ring
    following the last input, a Dialog over the stack with a sheet over the
    Dialog, no horizontal overflow and no console errors. Screenshots and
    measurements are attached.

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/MobileTokenizer
  branch: 108 files / 3951 tests passed (the two new suites add 2 files / 80 tests)
  main:   106 files / 3871 tests passed
pnpm -F @astryxdesign/core typecheck            # clean
pnpm -F @astryxdesign/lab typecheck             # only the pre-existing MobileTokenizer.test.tsx error on main
ASTRYX_STRICT_LINT=1 pnpm exec eslint packages/core/src/BottomSheet apps/storybook/stories/BottomSheetStack.stories.tsx packages/cli/assets/templates/blocks/components/BottomSheet
                                                # 0 errors (1 pre-existing warning on main)
pnpm check:sync · check:knowledge · check:package-boundaries · check:changesets · check:use-client · node scripts/sync-exports.js --check · git diff --check
pnpm -F @astryxdesign/core build · pnpm storybook:build

API audit (.d.ts on main vs this branch, plus the exports map): the only
differences are the two new files (BottomSheetStack.d.ts,
BottomSheetStackContext.d.ts), the two new names in BottomSheet/index.d.ts,
and file-header comment lines in BottomSheet.d.ts, BottomSheetSwitcher.d.ts
and BottomSheetPanel.d.ts. index.d.ts and every other emitted .d.ts are
byte-identical; the exports map is identical.

Real-browser matrix (Chromium, 390×844, touch emulation, static Storybook):
Orders drill-in (open → depth 3 → Escape → swipe → scrim → Close all), deep link
at depth 3, switcher base with help overlay, list with switcher top, mapped file
browser, sheets anywhere (row-owned detail, nested touch swipe and mouse drag,
Escape with a nested child on top, Dialog over the stack, sheet over the
Dialog, Suspense level), nested stack merging into one dialog with one
backdrop.

@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 14, 2026 12:28am 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 20, 2026, 1:50 AM UTC

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.
@github-actions

github-actions Bot commented Oct 14, 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

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

@imdreamrunner
imdreamrunner force-pushed the feat/core-bottom-sheet-stack branch from 735c3d6 to 4c15804 Compare October 14, 2026 00:20
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 is on hold for a maintainer decision: no current spec on main admits stacked bottom sheets, so a maintainer needs to provide or identify the governing spec that chooses between this BottomSheetStack host and BottomSheetSwitcher activeSheets (#6728) and adds the new surface to the overlay-dismissal family membership. Whichever model is chosen, these changes are needed before this can merge:

  • Split the 6,017 authored changed lines into pull requests of under 2,000 lines each.
  • Host a hasScrim={false} stack in the browser top layer instead of at z-index: 1000 (AST-027 FR7), and focus the bottom level's finalFocusRef when it closes, as a standalone non-modal sheet does.
  • Connect or warn about a switcher level's ref and onCancel, forward its onClick and onKeyDown, and keep level boxes and anchors out of React state (react-component-runtime INV1).
  • Derive the two latch-only layout-effect states during render, observe memoised reorders of sheets nested inside a level, and add Button's required label to the examples in BottomSheetStack.doc.mjs.

[Automated review]

},
dialogNonModal: {
pointerEvents: 'none',
zIndex: 1000,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A hasScrim={false} stack is shown with show() at z-index: 1000 instead of entering the browser top layer. AST-027 FR7 says a local component must not recreate layering with positioned DOM and page-level z-index, and its gap table already lists this pattern in BottomSheet and BottomSheetSwitcher as a confirmed leak. Route non-modal hosting through Layer or the native top layer, or ship the stack modal-only for now.

setOrder(next);
});
});
observer.observe(region, {childList: true, subtree: true});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nested levels render their anchors inside their parent's panel, which lives in the slot rather than in this region. So a memoised reorder of sheets inside a level is never observed, and the old top stays interactive until some later commit. Observe the slot subtree as well.

// Releases the display latch after close(), in the same synchronous pass.
// eslint-disable-next-line @eslint-react/set-state-in-effect -- releases the display latch from the same layout effect that calls close()
setIsShellOpen(false);
if (dialogModeRef.current === 'modal') {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

When a hasScrim={false} stack closes, this gate skips the bottom level's finalFocusRef along with the trigger, so focus is left on <body>. A standalone non-modal BottomSheet honours finalFocusRef, and the new docs promise it. Apply finalFocusRef whatever the modality, and keep only the trigger capture modal-only.

const [box] = useState(() =>
host == null || typeof document === 'undefined'
? null
: document.createElement('div'),

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. architecture:react-component-runtime INV1 says DOM nodes must not be stored in state: own the node through a ref and keep only a rendering key. The host's entries state copies each registration's anchor and box the same way.

// step's content becomes a level right above this one. The level element
// is a <div>; the inherited dialog attributes it receives are the same
// HTML attributes under a different element type.
const levelProps = props as unknown as HTMLAttributes<HTMLDivElement>;

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 a stack the switcher's ref, onCancel, onClick and onKeyDown are dropped: the ref stays null and the handlers never run, with no development warning (unlike hasScrim). Compose onClick and onKeyDown onto this level element, and connect or warn about ref and onCancel, per public-component-api INV5 and INV8.

if (isOpen) {
// Latch presentation so a later close stays mounted through its exit.
// eslint-disable-next-line @eslint-react/set-state-in-effect -- latches controlled open state for exit animation
setIsPresented(true);

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 layout effect only latches state, and its lint reason names no external system, which react-component-runtime INV3 requires. The standalone sheet's identical latch is fine because it sits in the effect that calls showModal(). Derive the latch during render instead. The same applies to setHasBeenPlaced(true) in BottomSheetStackContext.ts.

<BottomSheetStack>
<BottomSheet isOpen={isListOpen} onOpenChange={setIsListOpen} label="Orders">
{orders.map(id => (
<Button key={id} onClick={() => setOrder(id)}>Order {id}</Button>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Button requires label (ButtonProps.label: string), so this example and the Edit button below fail type-checking when copied. Pass label.

This branch was successfully deployed

1 active deployment
Preview — 4c158040 Deployed Oct 14, 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