Repository navigation
feat(core): BottomSheet stacks by open order, replaces on a same-update switch, and BottomSheetSwitcher is deprecated - #7648
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
AI review status for this pull request.
|
PR Analysis Report
|
911679f to
ebb29cd
Compare
There was a problem hiding this comment.
Approval waits on a maintainer decision: no current spec admits stacked bottom sheets yet, so a maintainer needs to settle AST-044 (this model, #6728 or #7503) and record the chosen model in the BottomSheet and BottomSheetSwitcher specs. If this model is chosen, three things still need changes:
- With a sheet rendered inside another sheet's content and a later sheet opened outside it, Escape closes the covered nested sheet and the top sheet declines a platform close request, which contradicts the new Stacking docs.
- The new Effect in
BottomSheet.tsxthat closes nested sheets calls theironOpenChange(false)on mount and again on every rerender, so a host that opens after its child wipes the child's open state. - At about 3,380 changed lines, please split the change into reviewable pieces once the model is chosen.
[Automated review]
| if (isOpenProp && !isHostOpen) { | ||
| onOpenChange(false); | ||
| } | ||
| }, [isHostOpen, isOpenProp, onOpenChange]); |
There was a problem hiding this comment.
This Effect calls the consumer's onOpenChange(false) in reaction to state, with onOpenChange as a dependency. An inline handler that does not close at once gets it again on every rerender, StrictMode fires it twice, and a host that has not opened yet resets a nested sheet that should appear with it. isOpen = isOpenProp && isHostOpen already keeps the dialog out of the top layer; either drop the callback or fire it once per host open → closed transition (react-component-runtime INV2/INV3).
| anatomy, | ||
| description: | ||
| 'A mobile touch surface for filters, actions, forms, and detail views that should rise from the bottom of the viewport; use BottomSheetSwitcher for multi-step flows.', | ||
| "A mobile touch surface for filters, actions, forms, and detail views that should rise from the bottom of the viewport; use BottomSheetSwitcher for multi-step flows that replace one step with the next, and open a second sheet over the first when a step should stay visible beneath it.\n\nStacking. Every sheet keeps its own isOpen. Open a sheet while another scrim sheet is open and it stacks on top: the sheet beneath stays mounted with its scroll position and inputs, recedes behind the new one (the nearest covered sheet shrinks to 0.96 and lifts 8px, deeper ones to 0.92 and 16px), and is blocked from pointer, keyboard, and assistive technology by the top sheet's modal dialog. The sheets can be siblings, live in separate components, sit inside another sheet's content, or sit inside a BottomSheetSwitcher step; a whole switcher is one level, and switching its step in place does not change its position. Nothing moves in the DOM: each sheet's dialog stays where it is written, and no wrapper component is needed.\n\nOrder is open order. The top sheet is the one presented most recently that is still open; a sheet that closes and reopens comes back on top. When several sheets open in one update (a deep link into a flow), there is no open order between them, so tree order decides: a sheet below the sheets rendered in its content, siblings in the order they are written. That order is right in the first painted frame.\n\nOne scrim. Only the bottom sheet's backdrop dims the page; the backdrops of the sheets above it are transparent, so a stack never darkens per level. The dim holds steady while sheets push and pop, and if the bottom sheet closes while sheets remain above it, the next sheet's backdrop takes over in the same frame.\n\nDismissal. Escape, a tap on the scrim, a downward swipe, and a platform close request (Android back, a close watcher) reach only the top sheet, through that sheet's own onOpenChange, and that sheet's purpose applies: required blocks all of them, form blocks the scrim and the swipe. Nothing closes a whole stack at once; set every sheet closed in one update when a flow is done and they leave together.\n\nClosing a covered sheet from code. Sheets rendered inside the closing sheet's content are part of it: they are presented only while it is open, and when it closes their onOpenChange(false) is called and they leave with it. Sheets above it that are not in its content stay open and keep their order; the closing sheet slides out from beneath them, and if it owned the scrim, the sheet above takes it over.\n\nFocus. Opening a sheet moves focus into it (a data-autofocus descendant first). Closing the top sheet returns focus into the sheet it reveals: to the control that opened it when that control is still there, otherwise to the revealed sheet's panel. When the last sheet closes, the standalone rules apply: finalFocusRef, else the element that opened the sheet, with a focus ring only after keyboard input.\n\nCaveats. A Dialog, AlertDialog, or Popover opened from a sheet paints above the sheet and does not make it recede; a sheet opened from inside that Dialog paints above the Dialog and stacks over the sheet beneath. Sheets with hasScrim={false} stay out of the stack entirely. A sheet React hides (a suspended Suspense boundary, Activity mode=\"hidden\") leaves the stack and closes its dialog; when it is shown again it is presented again, on top. Only sheets rendered in the same document stack; a sheet inside an iframe is its own stack.", |
There was a problem hiding this comment.
"Escape … and a platform close request … reach only the top sheet" does not hold when a sheet rendered inside another sheet's content is followed by a sheet opened outside it. The shared layer stack ranks React depth first, so Escape closes the covered nested sheet and the top sheet declines cancel. Either align the dismissal stack with the sheet stack or narrow this sentence to sibling and fully nested stacks.
| expect(openDialogs()).toEqual([]); | ||
| }); | ||
|
|
||
| it('a sheet in a closed sheet’s content is not presented until that sheet opens', () => { |
There was a problem hiding this comment.
The title says the inner sheet is "not presented until that sheet opens", but the assertion pins onInnerChange(false), which resets the consumer's state so the inner sheet never appears when the host does open. Worth a test that opens the host one update later and expects the inner sheet to present.
ebb29cd to
0c62409
Compare
0c62409 to
308556e
Compare
There was a problem hiding this comment.
Approval waits on a maintainer decision: no current spec admits stacked bottom sheets, the same-update replace, or deprecating BottomSheetSwitcher, so a maintainer needs to settle AST-044 (this model, #6728 or #7503) and record the chosen model in the BottomSheet and BottomSheetSwitcher specs. If this model is chosen, these still need changes:
- Pressing Continue during a Back handoff keeps the old step on screen for about 400 ms before it fades (about 45 ms the other way round), because
setBottomSheetOpendrops the returning sheet before its reopen arrives (bottomSheetStack.ts:885). - The deprecation leaves
hasScrim={false}flows without a replacement, and the BottomSheet docs, the CLI templates, the switcher stories and LabMobileTokenizerstill use the switcher (AST-017 FR28, FR29b and FR29c). - The Effect at
BottomSheet.tsx:439callsonOpenChange(false)without naming the external system it synchronizes (react-component-runtime INV3 and INV2). - At 5,694 changed lines, please land the registry, the replace and the deprecation as separate pull requests once the model is chosen.
[Automated review]
| // A replacing sheet that closes drops the sheet it was replacing at once, | ||
| // the way a switcher hides a retained step when the flow is left. | ||
| if (entry.replacing != null) { | ||
| finishReplace(entry.replacing); |
There was a problem hiding this comment.
This drops the replaced sheet before the returning sheet's open flip arrives in the same commit. With steps written in flow order, pressing Continue during a Back handoff closes the returning step's dialog and clears its replace, so the isEnteringInPlace branch above (line 873) never runs. In Chromium the old step then starts fading at 398 ms instead of 45 ms; the switcher fades at 32 to 35 ms in both directions. Deciding the batch's pairing before dropping the sheet (for example in detectReplace) would fix it, together with a mirrored-reversal test.
| * Closing the top sheet and opening the next in the same update runs the | ||
| * same replace handoff, with no `sheetId` and no switcher. The switcher keeps | ||
| * working as released until a future minor release removes it; a | ||
| * `hasScrim={false}` flow is the one case it still covers alone, because |
There was a problem hiding this comment.
AST-017 FR28 requires a deprecation patch to ship "the working replacement while old usage remains equivalent". This line concedes that hasScrim={false} flows have no replacement, so those callers are deprecated with nowhere to migrate. Either limit the deprecation to modal flows, or hold it until non-modal sheets can replace.
| @@ -194,6 +199,11 @@ export const docs = { | |||
| description: | |||
| "Don't make the sheet content overly long. Consider breaking it into steps and using Bottom Sheet Switcher.", | |||
There was a problem hiding this comment.
This line still recommends the switcher that this PR deprecates, and so does line 424 in docsDense. AST-017 FR29b asks maintained docs, templates and stories to teach only the replacement. The CLI block templates, BottomSheetSwitcher.stories.tsx and Lab MobileTokenizer (FR29c) need the same migration.
| const wasHostOpen = wasHostOpenRef.current; | ||
| wasHostOpenRef.current = isHostOpen; | ||
| if (wasHostOpen && !isHostOpen && isOpenPropRef.current) { | ||
| onOpenChangeRef.current(false); |
There was a problem hiding this comment.
This Effect fires the consumer's onOpenChange(false) in reaction to host state, and its comment names no external system (react-component-runtime INV3). INV2 also keeps onOpenChange out of Effects that react to the resulting state. The once-per-transition guard fixes the duplicate calls reported earlier, but the ownership question remains: the isOpenProp && isHostOpen gate already keeps the dialog out of the top layer.
308556e to
3c092b1
Compare
There was a problem hiding this comment.
Approval waits on a maintainer decision: no current spec admits stacked bottom sheets, the same-update replace, top-layer popover hosting for every non-modal sheet, or deprecating BottomSheetSwitcher in a patch, so a maintainer needs to settle AST-044 (this model, #6728 or #7503) and record the chosen model in the BottomSheet and BottomSheetSwitcher specs. If this model is chosen, these still need changes:
- Renaming the two Bottom Sheet flow templates moves their published ShadCN routes without aliases, which is why
registry-contractand the docsite build fail; keep the old identities withregistry.slugorregistry.aliasesand refresh the route lock. - Opening a lone
hasScrim={false}sheet from the keyboard now focuses its content group with a visible outline instead of the panel, although the Changesets say a sheet on its own behaves as before. - The presentation latch
useLayoutEffectinBottomSheet.tsxonly sets state and names no external system (react-component-runtime INV3). - At 6,744 changed lines, please land the registry, the replace and the deprecation as separate pull requests once the model is chosen.
[Automated review]
| exampleFor: 'BottomSheetSwitcher', | ||
| name: 'Bottom Sheet Switcher — Review flow', | ||
| displayName: 'Bottom Sheet Switcher — Review flow', | ||
| exampleFor: 'BottomSheet', |
There was a problem hiding this comment.
Moving exampleFor (and the rename on line 8) changes this template's ShadCN identity from example-bottom-sheet-switcher-review-flow at examples/bottom-sheet-switcher/review-flow to example-bottom-sheet-review-flow at examples/bottom-sheet/review-flow. Neither is aliased or in internal/shadcn-registry/routes.lock.json, which is what fails registry-contract and the docsite build. registry: {aliases: ['bottom-sheet-switcher/review-flow']} keeps the old route (as CollapsibleDividedAccordion.doc.mjs does); the reviewed lock then needs a refresh with UPDATE_SHADCN_ROUTE_LOCK=1.
| name: 'Bottom Sheet Switcher', | ||
| displayName: 'Bottom Sheet Switcher', | ||
| alsoExampleFor: ['BottomSheet'], | ||
| name: 'Bottom Sheet — Multi-step flow', |
There was a problem hiding this comment.
This rename turns the published showcase-bottom-sheet-switcher-default (showcases/bottom-sheet-switcher/default) into showcase-bottom-sheet-switcher-bottom-sheet-multi-step-flow. Adding registry: {slug: 'default'} restores the locked identity exactly while keeping the new display name.
| useLayoutEffect(() => { | ||
| if (isOpen) { | ||
| // eslint-disable-next-line @eslint-react/set-state-in-effect -- latches controlled open state for exit animation | ||
| setIsPresented(true); | ||
| } | ||
| }, [isOpen]); |
There was a problem hiding this comment.
Split out of the Effect that called showModal(), this useLayoutEffect now only sets state, and its lint reason names no external system (react-component-runtime INV3). Deriving the latch during render, with the previous-value pattern used for settledIsOpen above, would remove the Effect.
| export function showSheetHostNonModal(dialog: HTMLDialogElement): void { | ||
| if (typeof dialog.showPopover === 'function') { | ||
| dialog.showPopover(); | ||
| } else { | ||
| dialog.show(); | ||
| } | ||
| } |
There was a problem hiding this comment.
In Chromium, showPopover() on the NoScrim story's dialog focuses the sheet's content group (role="group", tabIndex 0), where show() focused the panel, so a keyboard-opened lone non-modal sheet now draws a 2px focus outline. The Changesets say a sheet on its own behaves as before; applying the sheet's own open-focus rule after showPopover() would keep that true.
3c092b1 to
83a854c
Compare
There was a problem hiding this comment.
Approval waits on an engineering owner: stacking, the same-update replace, the switcher as a stack level and the BottomSheetSwitcher deprecation implement component:BottomSheet DEC-2, FR5–FR15, AR3–AR5 and PR1–PR2 and component:BottomSheetSwitcher DEC-3, DEP-0015 and FR10, which exist only in the open record PR #8010, plus three architecture:layer-runtime passages this pull request adds. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner; the decision should also settle whether AST-017 FR29c lets Lab's MobilePowerSearch keep the switcher after the deprecation patch and whether this behaviour may ship as a patch under AST-017 FR25. Whatever is decided, two changes remain:
- A step advanced by code within a second of a press in the previous step (an interstitial "Processing" step, say) is not treated as a replace, so the flow closes with focus on the page instead of its opener; please let a press inside a sheet that is no longer open fall back to focus in
actorFor, with a test. - At 10,486 authored lines, please land the no-scrim top-layer hosting, the switcher toast-outlet fix, the stacking registry, the replace handoff and the deprecation with its migrations as separate pull requests.
[Automated review]
| function actorFor(doc: Document): Element | null { | ||
| const press = state.lastPress.get(doc); | ||
| if ( | ||
| press != null && | ||
| press.target.isConnected && | ||
| Date.now() - press.at <= PRESS_ACTOR_WINDOW_MS | ||
| ) { | ||
| return press.target; | ||
| } | ||
| return doc.activeElement; |
There was a problem hiding this comment.
A press stays the actor for 1,000 ms even when its target sits in a sheet that has already been replaced and is inert. In a flow whose middle step is advanced by code shortly after Submit (an interstitial "Processing" step), the press in the first step makes detectReplace reject Processing → Done although focus is inside Processing: in a jsdom probe with the replace suite's stubs Done opens as a push (["Processing:exiting:1","Done:top:0"] at 300 ms, a replace at 1,500 ms), and in Chromium closing the flow leaves focus on body instead of the opener. DEP-0015 step 5 and the deprecation Changeset promise the handoff for "code acting while focus is in the step". Treating a press inside an inert or no-longer-open sheet as stale (falling back to the focused element), plus a test for a step advanced inside the window, would keep migrated flows' focus return.
There was a problem hiding this comment.
Approval waits on an engineering-owner decision: the stacking, same-update replace and BottomSheetSwitcher deprecation implement component:BottomSheet and component:BottomSheetSwitcher requirements that exist only in the open record PR #8010, and this PR adds three new architecture:layer-runtime passages, so none of that behaviour has approved spec authority yet. An engineering owner records the decision by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner), and the decision should also settle the patch release tier and the deprecation's missing equivalents for an Escape-only onCancel veto and dialog-level props. Whatever that decision, two changes remain needed: split the 10,870-line change (the no-scrim top-layer host, the switcher toast fix and the reduced-motion fix can each land on their own), and qualify the two Changesets that say a lone modal sheet "behaves exactly as before", since the reduced-motion Changeset changes it.
[Automated review]
| '@astryxdesign/core': patch | ||
| --- | ||
|
|
||
| [feat] `BottomSheet` stacks by open order. A modal sheet opened while another modal sheet is open now stacks on top of it instead of presenting as a second, unrelated dialog: the covered sheet stays mounted with its state, recedes behind the top sheet (0.96 / −8px at depth 1, 0.92 / −16px from depth 2), and is blocked from pointer, keyboard, and assistive technology; only the bottom sheet's backdrop dims the page, so a stack no longer darkens per level; and Escape, a scrim tap, a swipe, and a platform close request pop only the top sheet, through that sheet's own `onOpenChange` and honouring its `purpose`. The top sheet is the most recently opened one still open, wherever the sheets are written — siblings, separate components, inside another sheet's content, or inside a `BottomSheetSwitcher` step; a whole switcher is one level, and switching its step keeps its place. When several sheets open in one update (a deep link), tree order decides and the first painted frame is already right: a sheet nested in another sheet's content no longer opens beneath its parent. Closing the top sheet returns focus into the sheet it reveals; closing every sheet of a stack in one update returns focus once, to the bottom sheet's `finalFocusRef` or opener, after the last dialog has closed (each sheet's own return used to be refused while the others were still modal). A sheet rendered inside a closing sheet's content now leaves with it instead of staying open invisibly, and comes back with the host if its own `isOpen` is still true (its state is never written for it); sheets above it that are not in its content stay open. Sheets with `hasScrim={false}` never stack and are never stacked on. No prop, type, or export changes; a modal sheet on its own behaves exactly as before. |
There was a problem hiding this comment.
This ends "a modal sheet on its own behaves exactly as before", but .changeset/bottom-sheet-reduced-motion-cross-fade.md changes that sheet's entrance and exit under prefers-reduced-motion: reduce (it now fades in from opacity 0 on a coarse pointer). Qualify it, for example "apart from the reduced-motion fix", so the release notes agree.
| '@astryxdesign/core': patch | ||
| --- | ||
|
|
||
| [feat] `BottomSheet` replaces a sheet that closes in the same update another opens. When the top sheet of a stack closes and exactly one other sheet opens in the same update (one state value naming the open step does this), the new sheet replaces it instead of stacking on it: it rises above the replaced sheet, a taller replaced sheet moves down to meet its top edge at the same time, the replaced sheet fades only after both motions finish and is inert and hidden from assistive technology from the start, the scrim holds steady with no dip, focus moves into the new sheet, and closing the flow later returns focus to the element that opened its first sheet. This is the handoff `BottomSheetSwitcher` runs between its steps, now available to standalone sheets; a multi-step flow can be written as one `BottomSheet` per step with no switcher. A covered sheet closing while a sheet opens, several sheets changing at once, or a close and an open in different updates keep the ordinary push and pop motion, and existing stacking behaviour is unchanged. A replace is recognised only when the move was made from inside the closing sheet: the control last pressed (or, with no recent press, the focused element) was in it, as a step's own Continue or Back button is; a close and an open driven from a control outside it, by two unrelated owners, stay a pop and a push with their own focus return. Non-modal sheets (`hasScrim={false}`) replace each other the same way: they are now hosted in the browser top layer as manual popovers, with no scrim, focus trap or scroll lock as before, so a non-modal step orders above the one it replaces, and the toast viewport re-enters the top layer above a non-modal sheet as it does above a modal. The non-modal sheet's page-level `z-index` is gone with the move (AST-027); below the Popover API support floor the sheet falls back to `show()` in page stacking order, usable but with no promise over later page content (the reduced fallback AST-003 describes). They still never stack. A non-modal sheet now also returns focus to its opener when it closes while the person is still in it (a modal sheet always did); focus the person took elsewhere on the live page stays there. The private stack registry now keeps one state per page (shared by every copy of the package on it, guarded by shape) and one stack per document. No prop, type, or export changes; a modal sheet on its own behaves exactly as before. A non-modal sheet on its own changes in three ways with its new host: it is in the top layer (a manual popover) instead of page stacking order, its page-level `z-index` is gone, and it returns focus to its opener on close while focus is still inside it; its scrim-less, scroll-free, page-interactive contract is unchanged. |
There was a problem hiding this comment.
Same as the stacking Changeset: "a modal sheet on its own behaves exactly as before" contradicts the reduced-motion Changeset released alongside it. Qualify the sentence.
There was a problem hiding this comment.
Approval waits on an engineering-owner decision: the stacking, same-update replace, switcher-as-a-stack-level and BottomSheetSwitcher deprecation behaviour implements component:BottomSheet and component:BottomSheetSwitcher requirements that exist only in the open record PR #8010, plus three new architecture:layer-runtime passages added here, so none of it has approved spec authority on main yet. Any engineering owner listed in .github/ENGOWNERS, the author included, can record that decision by commenting /approve-spec <full head SHA> on this pull request, and the decision should also settle the patch release tier under AST-017 FR25 and whether Lab's MobilePowerSearch may keep the switcher under FR29c. Whatever is decided, three changes remain:
- Under reduced motion a mouse or pen drag dismissal cross-fades on a fine pointer, where the reduced-motion rule allows that only for a touch drag's release; set
isDismissedByDragonly for atouchpointer, and clear it where a drag starts rather than in the reopen effect (useSheetGestures.ts,BottomSheetPanel.tsx). - Split the 10,965-line change: the no-scrim top-layer host, the switcher toast outlet and the reduced-motion fix can each land on their own, ahead of the stack, the replace and the deprecation.
- The deprecation Changeset, the switcher's
@deprecatedJSDoc, its docs and its story say a lone switcher flow behaves as released apart from the toast fix, but the reduced-motion fix changes its steps too.
[Automated review]
| '@astryxdesign/core': patch | ||
| --- | ||
|
|
||
| [feat] `BottomSheetSwitcher` and the `sheetId` prop of `BottomSheet` are deprecated. The replacement is already in this release: write each step of a multi-step flow as its own standalone `BottomSheet` with `isOpen`, driven by one state value that names the open step; closing the top sheet and opening the next in the same update runs the same replace handoff the switcher ran (entering step above, a taller retained step aligns down and fades after both motions, one steady scrim, focus into the new step and back to the flow's opener on close). Migration, per flow: (1) replace the `activeSheet` / `onActiveSheetChange` pair with one `step` state value; (2) give each step `isOpen={step === 'id'}` and `onOpenChange={isOpen => !isOpen && setStep(current => (current === 'id' ? null : current))}` in place of `sheetId="id"` (the guard keeps a step that is being replaced from clearing the step the person just chose); (3) remove the `BottomSheetSwitcher` wrapper, moving its `hasScrim` onto every step; (4) keep the step buttons as they were (`setStep('next')`). The handoff runs when the move is made from inside the open step (a step's own Continue or Back, or code while focus is in it); a step changed from a control outside the flow's sheets — a page control beside a `hasScrim={false}` flow, or code after focus has left the step — closes and opens with the sheets' own push and pop motion, where the switcher handed off on any `activeSheet` change. The switcher's other props map as follows: `onCancel` becomes a guarded `onOpenChange` on each step (one that ignores `false` while the flow must stay open), which keeps the step's dialog role — a standalone sheet reports Escape, the platform close, the scrim tap and the swipe all as `onOpenChange(false)` with no reason, so the guard holds the step open against all of them while it applies, where `onCancel` vetoed only the cancel request; a veto of Escape alone has no equivalent, and the only per-path lever is `purpose` (`form` keeps Escape and blocks the scrim tap and the swipe; `required` blocks all three and exposes the step as an `alertdialog`). A consumer `aria-label` / `aria-labelledby` becomes each step's `label`, which names its dialog. `ref`, `id`, `data-testid`, `className`, `style`, `xstyle` and the other dialog props have no dialog-level equivalent — a standalone sheet's `ref` and base props reach its panel `<div>` and its dialog is implementation — so a test or an anchor that needs the dialog queries the panel's `closest('dialog')`, and dialog-level styling is dropped. Flows that keep using the switcher keep working: a switcher flow used on its own behaves as released — with one fix, released alongside: a toast raised from a step now renders above the flow and can be pressed — and when it is stacked with other sheets the stacking behaviour of this release applies to it as one level (it recedes and is blocked when covered, shares the one scrim, stands down from dismissal while covered, and returns focus by the stacking rules). Its declarations and docs are marked `@deprecated` and its docs, templates and stories show only the replacement, until a future minor release removes it. Non-modal flows (`hasScrim={false}`) migrate the same way: non-modal sheets replace each other too. |
There was a problem hiding this comment.
This says a lone switcher flow "behaves as released — with one fix", the toast outlet, but .changeset/bottom-sheet-reduced-motion-cross-fade.md also changes "a BottomSheetSwitcher step's" entrance, exit and handoff under reduced motion (switcher steps render the shared BottomSheetPanel). Please name the reduced-motion fix here too, as the stacking and replace Changesets already do, and in the matching @deprecated JSDoc, BottomSheetSwitcher.doc.mjs and the switcher story description.
| // fades out in place on every pointer. DR3 covers the rest. | ||
| const isCoarseCrossFade = useMediaQuery(REDUCED_MOTION_COARSE_QUERY); | ||
| const isCrossFade = | ||
| isCoarseCrossFade || (isReducedMotion && isDismissedByDrag); |
There was a problem hiding this comment.
isDismissedByDrag is set for any primary button-0 drag (useSheetGestures.ts), so a mouse or pen drag dismissal cross-fades here for 200 ms on a fine pointer. design:reduced-motion gives the cross-fade only to "a transition a component starts when a touch drag is released", and DR3 asks every other fine-pointer transition to complete immediately. Setting the flag only when the releasing pointer's pointerType is touch (with a mouse case in the test) would match the record and this PR's own Changeset wording.
There was a problem hiding this comment.
Approval waits on an engineering-owner decision: the stacking, same-update replace, switcher-as-a-stack-level and BottomSheetSwitcher deprecation behaviour implements component:BottomSheet and component:BottomSheetSwitcher requirements that exist only in the open record PR #8010, plus three architecture:layer-runtime passages added here, so none of it has approved spec authority yet. Any engineering owner listed in .github/ENGOWNERS, the author included, can record that decision by commenting /approve-spec <full head SHA> on this pull request; the decision should also settle the patch release tier under AST-017 FR25 and whether Lab's MobilePowerSearch may keep the switcher under FR29c. Two changes remain whatever is decided:
- Split the 10,993 authored lines into separately landable pull requests: the reduced-motion fix, the switcher toast outlet and non-modal top-layer hosting can each land ahead of the stack, the replace and the deprecation.
- Give the new
setIsDismissedByTouchDrag(false)reset inuseSheetGestures.tsa reason that names what it synchronizes (react-component-runtime INV3), or derive it during render.
[Automated review]
| // eslint-disable-next-line @eslint-react/set-state-in-effect -- resets gesture state on controlled reopen | ||
| setIsTraveling(false); | ||
| // eslint-disable-next-line @eslint-react/set-state-in-effect -- resets gesture state on controlled reopen | ||
| setIsDismissedByTouchDrag(false); |
There was a problem hiding this comment.
This new reset sits in the controlled-reopen effect with the reason "resets gesture state on controlled reopen", which names no external system or measurement (react-component-runtime INV3). The flag is new in this PR and is read only while closing; resetting it during render when isOpen turns true (as StandaloneBottomSheet does for isPresented) would avoid the effect-owned state.
There was a problem hiding this comment.
Approval waits on an engineering-owner decision: the stacking, same-update replace, switcher-as-a-stack-level and BottomSheetSwitcher deprecation behaviour implements component:BottomSheet and component:BottomSheetSwitcher requirements that exist only in the open record PR #8010, plus three architecture:layer-runtime passages added here, so none of it has approved spec authority yet. Any engineering owner listed in .github/ENGOWNERS, the author included, can record that decision by commenting /approve-spec <full head SHA> on this pull request; the same decision should settle the patch release tier under AST-017 FR25 and whether Lab's MobilePowerSearch may keep the switcher under FR29c. Two changes remain whatever is decided:
- Split the 11,036 authored lines into separately landable pull requests: the reduced-motion fix, the switcher toast outlet and the no-scrim top-layer host can each land ahead of the stack, the replace and the deprecation.
- After a touch swipe the owner refuses with a guarded
onOpenChange, a later close cross-fades for 200 ms under reduced motion on a fine pointer, wheredesign:reduced-motionDR3 asks for an immediate change; clear the touch-drag flag inuseSheetGestures.tswhenever the sheet is still open after the release, and add a test for that path.
[Automated review]
| const [touchDragDismissal, setTouchDragDismissal] = useState(false); | ||
| const [wasOpen, setWasOpen] = useState(isOpen); | ||
| if (isOpen !== wasOpen) { | ||
| setWasOpen(isOpen); | ||
| if (isOpen && touchDragDismissal) { | ||
| setTouchDragDismissal(false); | ||
| } | ||
| } | ||
| const isDismissedByTouchDrag = touchDragDismissal && !isOpen; |
There was a problem hiding this comment.
The flag is cleared only when isOpen flips from false to true. A touch swipe the app refuses (a guarded onOpenChange, the documented replacement for the switcher's onCancel) leaves isOpen true, so touchDragDismissal stays set. The next close from a control, Escape or code then makes isDismissedByTouchDrag true and cross-fades for 200 ms on a fine pointer under reduced motion, where design:reduced-motion DR3 asks for an immediate change. Clearing it whenever the sheet is still open after the release (if (isOpen && touchDragDismissal) setTouchDragDismissal(false), without the wasOpen guard) keeps only a close that lands with the release. A test for a refused swipe followed by a control close would pin it.
There was a problem hiding this comment.
Approval waits on an engineering-owner decision: the stacking, same-update replace, switcher-as-a-stack-level and BottomSheetSwitcher deprecation behaviour implements component:BottomSheet and component:BottomSheetSwitcher rows that exist only in the open record PR #8010, plus three new architecture:layer-runtime passages, and no current spec on main admits them yet. An engineering owner listed in .github/ENGOWNERS, including the author, records that decision by commenting /approve-spec <full head SHA> on this pull request, which also settles shipping the change as a patch under spec:AST-017 FR25 and leaving Lab's MobilePowerSearch on the switcher under FR29c. Two changes are needed whatever is decided:
- Land the
hasScrim={false}top-layer host, the switcher toast outlet and the reduced-motion fix as their own pull requests, since the change is about 11,100 authored lines. - A
hasScrim={false}sheet opened while aDialogoutside it is open stays inshow()beneath page content once thatDialogunmounts, because an unmounted dialog fires noclose; promote deferred sheets without relying onclose, with a test for that path.
[Automated review]
There was a problem hiding this comment.
Approval waits on an engineering-owner decision: the stacking, same-update replace, switcher-as-a-stack-level and BottomSheetSwitcher deprecation behaviour implements component:BottomSheet and component:BottomSheetSwitcher rows that exist only in the open record PR #8010, plus three new architecture:layer-runtime passages, so no current spec on main admits them yet. Any engineering owner listed in .github/ENGOWNERS, the author included, records that decision by commenting /approve-spec <full head SHA> on this pull request, which also settles shipping the change as a patch under AST-017 FR25 and leaving Lab's MobilePowerSearch on the switcher under FR29c. Whatever is decided, three changes are needed:
- Split the change (about 11,100 authored lines): the
hasScrim={false}top-layer host, the switcher toast outlet and the reduced-motion fix can each land on their own pull request. - A
hasScrim={false}sheet opened while an outsideDialogis open stays inshow()beneath page content once thatDialogunmounts, because an unmounted dialog fires noclose; promote deferred sheets without relying onclose, and add a test that unmounts theDialog. - When one sheet of a “Close all” unmounts before its exit ends, focus never returns to the opener, because the unregister path never completes the group; complete it there directly, with a test.
[Automated review]
| doc.addEventListener( | ||
| 'close', | ||
| () => { | ||
| promoteDeferredNonModal(doc); | ||
| }, | ||
| true, | ||
| ); |
There was a problem hiding this comment.
A deferred hasScrim={false} sheet is promoted only on a document close event or the next sheet-stack update. An open <dialog> removed from the document fires no close (modalOutlet.ts says the same), so when the blocking Dialog unmounts while open — {isEditing && <Dialog isOpen …>} closed by its own button — the sheet stays in show() with no z-index, and a fixed page bar paints over it and takes its presses. In Chromium the press at the centre of "Got it" reaches the page’s z-index: 10 tab bar on this head and the button on the base. Re-check deferred sheets when a blocking modal leaves the document without a close, and add a test that unmounts an open Dialog over a deferred sheet.
There was a problem hiding this comment.
Approval waits on an engineering-owner decision: the stacking, same-update replace, switcher-as-a-stack-level and BottomSheetSwitcher deprecation behaviour implements component:BottomSheet and component:BottomSheetSwitcher rows that exist only in the open record PR #8010, plus three new architecture:layer-runtime passages, so no current spec on main admits them yet. Any engineering owner listed in .github/ENGOWNERS, the author included, records that decision by commenting /approve-spec <full head SHA> on this pull request, which also settles shipping the change as a patch under AST-017 FR25 and leaving Lab's MobilePowerSearch on the switcher under FR29c. Whatever is decided, these changes are needed:
- Split the change (about 11,300 authored lines): the
hasScrim={false}top-layer host, the switcher toast outlet and the reduced-motion fix can each land on their own pull request. - A
hasScrim={false}sheet opened while aMobileNavorLightboxis open stays inshow()beneath page content once that modal unmounts, because the removal check only recognises dialogs witharia-modal="true"; re-check waiting sheets on any dialog removal and add a test that unmounts an openMobileNav. - When one sheet of a "Close all" unmounts before its exit ends, focus never returns to the opener because the unregister path never completes the group; complete it there, with a test.
- The "unmounts while open" and "stacked over a standalone sheet" stories put their controls in the page behind a modal, where nobody can press them; move those controls into the modal's content.
[Automated review]
| // `aria-modal` is the mark to read here. | ||
| if ( | ||
| dialogs.some( | ||
| dialog => dialog.open && dialog.getAttribute('aria-modal') === 'true', |
There was a problem hiding this comment.
This only promotes a deferred sheet when the removed dialog carries aria-modal="true", but hasOpenModalOutside defers under any :modal dialog. MobileNav and Lightbox present with showModal() and set no aria-modal, so a hasScrim={false} sheet opened while one of them is open stays in show() under page content after it unmounts (Chromium: the press at "Got it" lands on a z-index: 10 tab bar). Since promoteDeferredNonModal re-checks hasOpenModalOutside anyway, re-checking on the removal of any dialog would cover them; a test that unmounts an open MobileNav over a deferred sheet would pin it.
| // Keep the membership alive for the host's own report: the entry | ||
| // is gone from `entries`, so key the report by the group directly. | ||
| state.closingTogether.delete(entry); | ||
| pendingHostReports.set(key, closingGroup); |
There was a problem hiding this comment.
For a standalone sheet unmounted mid-exit the dialog is still open here, so the group is parked for a host report that only handleMotionComplete sends, and an unmounted host never reaches it: the group's pending set never empties and focus never returns (jsdom probe: "Close all" with the upper sheet unmounted mid-exit leaves focus on body). The comment above says this case is reported here; removing the entry from pending and calling completeClosingGroup after entry.hide() would match it.
| <HStack gap={2}> | ||
| <Button label="Edit" onClick={() => setIsEditing(true)} /> | ||
| <Button | ||
| label="Show hint" |
There was a problem hiding this comment.
"Show hint" and "Discard" sit in the page behind a modal Dialog, where the browser makes them inert, so a person cannot walk through this story's scenario; moving both into the Dialog's content (as the Settings story does) keeps it reproducible by hand.
There was a problem hiding this comment.
Approval waits on an engineering-owner decision: stacking, the same-update replace, the switcher as a stack level and the BottomSheetSwitcher deprecation implement component:BottomSheet and component:BottomSheetSwitcher rows that exist only in the open record PR #8010, and this pull request adds three architecture:layer-runtime passages, so no current spec on main admits them yet. Any engineering owner listed in .github/ENGOWNERS, which includes the author, records that decision by commenting /approve-spec <full head SHA> on this pull request, which also settles leaving Lab's MobilePowerSearch on the switcher under AST-017 FR29c and shipping the change as a patch under FR25. Whatever is decided, these changes are needed:
- Split the change (about 11,400 authored lines): the
hasScrim={false}top-layer host, the toast-hosting changes and the reduced-motion fix can each land on their own pull request. - A
hasScrim={false}sheet waiting under an outside modal is never moved into the top layer when the modal still blocking it opened after it started waiting and then unmounts while open; record those blockers when the re-check keeps the sheet waiting (bottomSheetStack.ts, around line 948) and add a test that swaps one openDialogfor another and unmounts the second. - The "Stacked over a standalone sheet" story keeps "Close settings" in the page behind the modal Settings sheet, where nobody can press it; move it into the sheet's content.
[Automated review]
| deferred.delete(dialog); | ||
| continue; | ||
| } | ||
| if (hasOpenModalOutside(dialog)) { |
There was a problem hiding this comment.
When this re-check keeps the sheet waiting because another modal is now open, that modal is never added to entry.blockers (blockers are recorded only in deferTopLayerEntry, lines 896–899). If one conditionally rendered Dialog is replaced by another in one update and the second later unmounts while open, its removal is not a recognised blocker and the sheet stays in show() with z-index: auto after every modal is gone (a jsdom probe at this head: showPopover() is never called). Recording openModalsOutside(dialog) here before continue (or re-checking on any dialog removal), with a test for that sequence, would keep spec:AST-027 FR8.
| {/* Closes the host from the page while the flow is open: the flow | ||
| leaves with its host and returns with it, its step unchanged. */} | ||
| <Button | ||
| label="Close settings" |
There was a problem hiding this comment.
"Close settings" sits in the page behind the modal Settings sheet, so the browser makes it inert exactly while the scenario it exists for (closing the host while the flow is open) is on screen. Moving it into the Settings sheet's content, as the stacking story now does with its Dialog controls, keeps the story reproducible by hand.
There was a problem hiding this comment.
Approval waits on an engineering-owner decision: the stacking, same-update replace, switcher-as-a-stack-level and BottomSheetSwitcher deprecation behaviour implements component:BottomSheet and component:BottomSheetSwitcher rows that exist only in the open record PR #8010, plus three new architecture:layer-runtime passages, and no current spec on main admits them yet. An engineering owner listed in .github/ENGOWNERS, the author included, records that decision by commenting /approve-spec <full head SHA> on this pull request, which also settles shipping the whole change as a patch under spec:AST-017 FR25 and leaving Lab's MobilePowerSearch on the switcher under FR29c. Whatever is decided, these changes are needed:
- Land the
hasScrim={false}top-layer host, the toast-outlet changes and the reduced-motion rewrite as their own pull requests: at about 11,400 authored lines this one is too large to land as a unit, and those three parts already have current authority on their own. - Keep focus in a sheet opened over a covered switcher flow when code changes the flow's step: today the field the person is typing in is blurred and focus lands on the sheet's panel (
bottomSheetStack.ts:726).
[Automated review]
There was a problem hiding this comment.
Approval waits on one engineering-owner decision. The three architecture:layer-runtime passages this PR adds are not approved for this head, and they must be read beside main's new INV14, which gives top-layer mounting to the shared layer runtime. Any engineering owner listed in .github/ENGOWNERS, the author included, records that decision by commenting /approve-spec <full head SHA> on this pull request; the stacking, replace, no-scrim hosting and deprecation themselves match the component:BottomSheet and component:BottomSheetSwitcher records that landed with #8010. Whatever is decided, three changes are needed:
- Split the roughly 11,400 authored lines along the seams the Changesets already draw: the reduced-motion rule, the switcher toast outlet and the no-scrim host first, then stacking with the replace, then the deprecation.
- Keep focus in the field the person is typing in when code switches the step of a switcher flow that a sheet covers (today it lands on the covering sheet's panel), with a test whose
showModalstub moves focus. - Retarget the pull request to
mainand rebase, because its base is the record branch that already landed, so CI has not run and both component records conflict withmain.
[Automated review]
| } | ||
| } | ||
| for (let index = kept; index < presentable.length; index += 1) { | ||
| presentable[index].show(); |
There was a problem hiding this comment.
This re-show runs the sheet's showModal(), whose focusing steps move focus to its panel, and focus('open') below then leaves it there. When code switches the step of a switcher flow that this sheet covers (notifyBottomSheetHostChanged), a person typing in the top sheet loses focus and the next keystrokes. In Chromium, typing "abc" in the field and then letting the flow move from step one to step two leaves focus on the panel, and "de" never reaches the field. Keeping the active element across the re-show, as closeDialogKeepingFocus does for the hide, and focusing only a sheet that newly opened would fix it. A stacking test whose showModal stub moves focus would pin it.
There was a problem hiding this comment.
Approval waits on an engineering-owner decision about the three architecture:layer-runtime passages this pull request adds, including how they read beside main's INV14, which gives top-layer mounting and the backdrop tint to the shared layer runtime. Any engineering owner listed in .github/ENGOWNERS, the author included, records that decision by commenting /approve-spec <full head SHA> on this pull request. The stacking, replace, no-scrim hosting and deprecation themselves match the component:BottomSheet and component:BottomSheetSwitcher records on main. Whatever is decided, these need changes:
- When code changes the step of a switcher flow that a standalone sheet covers,
Layer/modalOutlet.tspushes the re-supplied outlet above the top sheet, so toasts render in the covered, inert step (component:BottomSheetFR13). Keep the group's top as the latest outlet, and add a modal-host test for that sequence. - At about 11,700 authored lines, please split along the Changeset seams (reduced motion, the switcher toast outlet, the no-scrim host, the deprecation, the layer-runtime edit), or document the exception.
- Retarget to
mainand rebase. The base branch already landed as #8010, so after the rebase the record edits apply cleanly and CI runs.
[Automated review]
| useModalOutlet( | ||
| hostDialogRef, | ||
| isModal && isFlowOpen, | ||
| stackView.isTop || !stackView.isStacked, | ||
| 'bottom-sheet', | ||
| hostSheet, | ||
| ); |
There was a problem hiding this comment.
When code changes the step of a flow that a standalone sheet covers, this re-supplies the outlet for the new step's dialog, and addOutlet in Layer/modalOutlet.ts pushes it to the end even though isLatest is false here; the covering sheet's raise effect does not run again. The covered step's dialog (inert, aria-hidden) then hosts the toast viewport, so a toast raised from the top sheet renders inside it and, in Chromium, a press on it reaches the top sheet's control beneath, against component:BottomSheet FR13. Inserting a re-supplied, not-latest outlet beneath its group's top (or re-raising the group's latest), with a modal-host test for this sequence, would keep toasts in the top sheet.
There was a problem hiding this comment.
This pull request can't be approved yet: it adds text to established records (three architecture:layer-runtime passages on dismissal stand-down, toast-outlet order and toasts above a no-scrim sheet; a Current-gaps sentence placing BottomSheet's own top-layer mounting inside the INV14 gap; and an element-jobs sentence in component:BottomSheet), so an engineering owner has to accept that text for this exact head. Any engineering owner listed in .github/ENGOWNERS, including the author, records that decision by commenting /approve-spec <full head SHA> on this pull request. The stacking, replace, no-scrim hosting and deprecation themselves match the current component:BottomSheet and component:BottomSheetSwitcher records, and three changes are needed whatever the decision:
- Let a package copy with a different stack-state shape join the one stack per document, or have an engineering owner scope PR1 to copies that share the shape (
bottomSheetStack.ts:359). - Remove the document press and
closelisteners when a document's last sheet leaves, or name that lifetime incomponent:BottomSheetwith a single-installation test (architecture:react-component-runtimeINV5). - Split the roughly 11,900 authored lines along the seams the Changesets already draw.
[Automated review]
| function listenForPresses(doc: Document): void { | ||
| if (state.listenedDocuments.has(doc)) { | ||
| return; | ||
| } | ||
| state.listenedDocuments.add(doc); | ||
| const record = (event: Event) => { | ||
| if (event.target instanceof Element) { | ||
| state.lastPress.set(doc, {target: event.target, at: Date.now()}); | ||
| } | ||
| }; | ||
| doc.addEventListener('pointerdown', record, true); | ||
| doc.addEventListener('click', record, true); | ||
| doc.addEventListener('keydown', record, true); | ||
| } |
There was a problem hiding this comment.
listenForPresses installs these three capture-phase listeners with a document's first sheet, and nothing removes them; deferTopLayerEntry (:910-916) does the same for close. The recorded target is held strongly by the WeakMap value (the comment at :500-501 says weakly), so an element removed by its own click stays reachable until the next press. architecture:react-component-runtime INV5 lets a document-owned singleton persist across consumer gaps only when current authority names that lifetime and a test proves one shared installation. Removing the listeners when the document's last sheet leaves the registry, and keeping the target behind a WeakRef, would meet it.
There was a problem hiding this comment.
This pull request can't be approved yet: it changes established records, so an engineering owner has to accept that text for this exact head. Any engineering owner listed in .github/ENGOWNERS, including the author, records the decision by commenting /approve-spec <full head SHA>; it needs to settle the new component:BottomSheet PR1 wording that lets package copies on different stack protocol versions keep separate stacks (and whether DEC-2, which still says one stack across package copies, should match), the three architecture:layer-runtime passages with their INV14-gap sentence, and the element-jobs sentence. The stacking, replace, no-scrim hosting, toast outlets, reduced motion and deprecation otherwise match the current component records, and the listener lifetime is fixed; two changes are needed whatever the decision:
- Split the roughly 12,000 authored lines along the seams the Changesets already draw (no-scrim host, toast outlets, reduced motion, deprecation), or document the exception.
- Make
.changeset/bottom-sheet-replace-handoff.mdand thebottomSheetStack.tsheader say the shared state is shared only by copies on the same stack protocol version.
[Automated review]
| document-global key the copies meet under; a copy on another protocol | ||
| version keeps a stack of its own, ordering only the sheets it renders, so | ||
| two copies of different versions on one page are two independent stacks | ||
| and neither reads the other's state. No public API reaches the ordering. |
There was a problem hiding this comment.
This narrows the current PR1 ("Sheets rendered by every copy of the package loaded on a page … form one stack per document"): copies on different stack protocol versions would keep two stacks. DEC-2 in this record still says "The ordering is one per document across package copies and React roots (PR1)". An engineering owner needs to accept the new scope for this head (/approve-spec <full head SHA>), and DEC-2 should say the same thing as PR1 either way.
| '@astryxdesign/core': patch | ||
| --- | ||
|
|
||
| [feat] `BottomSheet` replaces a sheet that closes in the same update another opens. When the top sheet of a stack closes and exactly one other sheet opens in the same update (one state value naming the open step does this), the new sheet replaces it instead of stacking on it: it rises above the replaced sheet, a taller replaced sheet moves down to meet its top edge at the same time, the replaced sheet fades only after both motions finish and is inert and hidden from assistive technology from the start, the scrim holds steady with no dip, focus moves into the new sheet, and closing the flow later returns focus to the element that opened its first sheet. This is the handoff `BottomSheetSwitcher` runs between its steps, now available to standalone sheets; a multi-step flow can be written as one `BottomSheet` per step with no switcher. A covered sheet closing while a sheet opens, several sheets changing at once, or a close and an open in different updates keep the ordinary push and pop motion, and existing stacking behaviour is unchanged. A replace is recognised only when the move was made from inside the closing sheet: the control last pressed (or, with no recent press, the focused element) was in it, as a step's own Continue or Back button is; a close and an open driven from a control outside it, by two unrelated owners, stay a pop and a push with their own focus return. Non-modal sheets (`hasScrim={false}`) replace each other the same way: they are now hosted in the browser top layer as manual popovers, with no scrim, focus trap or scroll lock as before, so a non-modal step orders above the one it replaces, and the toast viewport re-enters the top layer above a non-modal sheet as it does above a modal. The non-modal sheet's page-level `z-index` is gone with the move (AST-027); below the Popover API support floor the sheet falls back to `show()` in page stacking order, usable but with no promise over later page content (the reduced fallback AST-003 describes). They still never stack. A non-modal sheet now also returns focus to its opener when it closes while the person is still in it (a modal sheet always did); focus the person took elsewhere on the live page stays there. The private stack registry now keeps one state per page (shared by every copy of the package on it, guarded by shape) and one stack per document. No prop, type, or export changes; a modal sheet on its own behaves exactly as before (apart from the reduced-motion fix released alongside). A non-modal sheet on its own changes in three ways with its new host: it is in the top layer (a manual popover) instead of page stacking order, its page-level `z-index` is gone, and it returns focus to its opener on close while focus is still inside it; its scrim-less, scroll-free, page-interactive contract is unchanged. |
There was a problem hiding this comment.
This note says the registry keeps "one state per page (shared by every copy of the package on it, guarded by shape)", but the state key now carries the protocol version (bottomSheetStack.ts:342-346), so a copy on another version keeps its own stack. Suggest matching whatever PR1 ends up saying, for example "shared by every copy on the same stack protocol version".
There was a problem hiding this comment.
This pull request can't be approved yet: it changes established records — component:BottomSheet PR1 and DEC-2 now let package copies on different stack protocol versions keep separate stacks, and it adds an element-jobs sentence and three architecture:layer-runtime passages — so an engineering owner has to accept that text for this exact head. Any engineering owner listed in .github/ENGOWNERS, including the author if they are one, records that decision by commenting /approve-spec <full head SHA> on this pull request. Independently of that decision, please split the 12,066 authored lines into separately reviewable pull requests (for example reduced motion and the no-scrim host, then the stack with the replace, then the deprecation), update the bottomSheetStack.ts header comment that still says every package copy shares one state, and correct the description's statement that the component records change by deletion only.
[Automated review]
| * The state lives on `globalThis` so that two copies of this module on one | ||
| * page share one stack (see StackState); everything the reconcile reads, | ||
| * including each document's shown order and which documents already have | ||
| * the press listener, is in that shared state and none of it is per copy. |
There was a problem hiding this comment.
This header still says every copy of the module shares one stack and that nothing the reconcile reads is per copy. On this head the state key carries the protocol version (L344–L347), and focusToKeep (L800) and deferredTopLayer (L937) are module-level, so they are per copy. Please state the version scope here and either move both maps into StackState or name them as per copy.
There was a problem hiding this comment.
This pull request can't be approved yet: it changes established records — component:BottomSheet PR1 and DEC-2 now let package copies on different stack protocol versions keep separate stacks, and it adds an element-jobs clause and architecture:layer-runtime passages — so an engineering owner has to accept that text for this exact head. Any engineering owner listed in .github/ENGOWNERS, including the author if they are one, records that decision by commenting /approve-spec <full head SHA> on this pull request. Independently of that decision, please split the 12,143 authored lines into separately reviewable pull requests (for example reduced motion and the no-scrim host, then the stack with the replace, then the deprecation), or document the size exception with decomposition or necessity evidence.
[Automated review]
|
/approve-spec 1797865 |
…te switch, and BottomSheetSwitcher is deprecated
Modal bottom sheets form one stack per document, ordered by open order
(ties within one commit by tree order), with one visible scrim, covered
sheets receded and blocked, the native top layer reconciled before paint,
and dismissal reaching only the top sheet. A sheet rendered in a closing
sheet's content leaves with it and comes back with it while its own
isOpen is still true; its state is never written for it. A whole
BottomSheetSwitcher flow is one level of the stack.
When the top sheet closes and exactly one other sheet opens in the same
update, from a control inside the closing sheet, the opening sheet
replaces it with the switcher's handoff: it enters above the retained
sheet, a taller retained sheet aligns down to meet it, the retained sheet
fades after both motions and keeps the scrim until the replacing sheet
takes it over with no transition, and focus moves into the entering
sheet. Unrelated owners acting in one update keep their own push and pop
motion and their own focus return.
Non-modal sheets (hasScrim={false}) are hosted in the top layer as manual
popovers (show() below the Popover API floor), with no scrim, scroll lock
or focus trap as before; they never stack, but they replace each other
the same way, return focus to their opener on close while focus is still
inside, and the toast viewport re-enters the top layer above them.
The private registry keeps one state per page (shared by every copy of
the package, guarded by shape) and one stack per document; closes driven
by the registry never move focus; a change of scrim owner lands with a 0s
inline pin for two frames; closing every sheet of a stack in one update
returns focus once, to the bottom sheet's target, after the last dialog
has closed.
A BottomSheetSwitcher flow, whose steps each own a native dialog, is one
level of the stack: the active step's dialog is the level, a tree anchor
keeps same-commit ties stable across handoffs, the registry is told when
a handoff changes the dialog so the sheets above the flow stay above it,
the flow's scrim is multiplied by the stack's, and covered step dialogs
carry the stack state.
BottomSheetSwitcher and BottomSheet's sheetId are deprecated in favour of
one standalone sheet per step driven by one state value; a flow used on
its own behaves as released, and a flow stacked with other sheets is one
level of the stack, until a future minor removes them. The CLI block
templates, the Lab MobileTokenizer and the stories teach the replacement.
A modal standalone sheet is a toast outlet only while it is the open sheet
on top; a covered sheet hands the outlet to the sheet above it and takes
it back when that sheet closes.
Implements component:BottomSheet DEC-2 / FR5–FR12 / AR3–AR4,
component:BottomSheetSwitcher FR10 and DEP-0015, and the
architecture:layer-runtime toast re-entry; deletes their implementation
gap notes and adds the stacking and replace suites to verified_by.
|
/approve-spec 20ed66a |
There was a problem hiding this comment.
This pull request needs changes before it can be approved. The stacked-sheet behaviour implements the current component:BottomSheet and component:BottomSheetSwitcher requirements, and this head's record edits carry an engineering owner's exact-head /approve-spec, so authority is settled; the remaining problem is size: 12,413 authored lines bundle six separately releasable Changesets. Please split it into separately reviewable pull requests (for example the registry with stacking and the replace, the no-scrim popover host, toast-outlet ordering, the reduced-motion fix, and the deprecation with its docs, templates and Lab migration), or document the size exception with evidence that these parts cannot ship apart. No other defect turned up in the review.
[Automated review]
feat(core): BottomSheet stacks by open order, replaces on a same-update switch, and BottomSheetSwitcher is deprecated
User need
Mobile product flows drill in: a list opens a detail, the detail opens an editor, a form opens a picker. On touch, each of those steps is a
BottomSheet, and the step beneath should stay visible, receded, while the next one is on top — the pattern every mobile platform ships (iOS card presentation, Material bottom sheets, Ionic card modals).Why can't builders do this today? On
main, two open modal sheets are two unrelated native dialogs: both scrims paint, so the page darkens per level; the covered sheet does not recede, so the person cannot tell there is a sheet beneath; a sheet nested in another sheet's content that opens in the same commit as its parent (a deep link, a reload) ends up under its parent in the top layer, because React runs child effects before parent effects; and a parent sheet closed from code leaves a nested child dialog open in the top layer with no box to paint — an invisible modal that blocks the page. Why does that matter? Every team that needs a drill-in either hoists all steps into oneBottomSheetSwitcher(which replaces steps, so the covered step is gone), or rebuilds stacking with its own wrappers and z-indexes, each slightly differently. Why does that matter? The design system exists so that a flow's layering, dismissal and focus behave the same everywhere, and this is the one overlay family where it does not.A second need sits inside the first. Every multi-step flow today — set-up wizards, filters, pickers — hoists its steps into one
BottomSheetSwitcher, because that is the only way to get the replace handoff: the next step rises above the current one, a taller current step moves down to meet it, the current step fades after both motions, the scrim never dips, focus lands in the new step and returns to the flow's opener when it closes. Once sheets stack by open order, a flow written as plain sheets (setStep('next')closes one and opens the next in one update) reads as a push plus a pop: the old step recedes while sliding out, two backdrops cross, focus bookkeeping follows the stacking rules. Builders would have to choose between two components with two mental models for one UI. This PR makes the standalone sheet do both, measured identical to the switcher, and deprecates the switcher.Problem and solution fit
Candidate C is the model the established libraries use (Radix, Headless UI, React Aria, Ariakit, MUI, Base UI, Ark/Zag, Ionic, Mantine): each sheet keeps its own open state; a sheet opened while another is open stacks on top of it; the top sheet is the most recently opened one; nesting in JSX is optional. There is no new component and no new prop. Stacking, and the replace between steps, are behaviour of the component that already exists.
bottomSheetStack.ts) ranks sheets by open order, breaks same-commit ties by tree order, and reconciles the native top layer::backdropis visible; a change of scrim owner lands with no transitionpurposeapplyBottomSheetSwitcherflow — rendered in a closing sheet's content leaves with it, and comes back with it if still open; its state is never written for itBottomSheetSwitcherflow is one level: the active step's dialog is the level, the registry is told when a handoff changes that dialog, and the flow's steps keep clear backdrops when a lower sheet owns the scrimcomponent:BottomSheetFR12,component:BottomSheetSwitcherFR10)hasScrim={false}) are hosted in the top layer as manual popovers and replace each other the same way; the toast viewport re-enters above them (throughmodalOutlet, as it does for Dialogs)show()+z-indexpath AST-027 lists as a leak is closed for the standalone sheet, and app-global toasts stay above it@deprecatedonBottomSheetSwitcher,BottomSheetSwitcherPropsandsheetId; docs teach the replacement firstspec:AST-017FR26/FR28No tagalongs: no visual change to a modal sheet on its own, no new exports. Two files outside
BottomSheet/change:Layer/modalOutlet.tsgains a top-layer-entry notice, anisLatest/grouppair for callers whose modals are ordered by something other than supply order (sheets; Dialogs keep the default), and a stale-closeguard;Toast/ToastViewport.tsxre-enters the top layer on the notice (no behaviour change for toasts over Dialogs). Onecurrentarchitecture record,layer-runtime.md, gains the sentences for the shipped sheet behaviour. (A lone non-modal sheet changes host — top layer, no page-levelz-index, focus return on close while focus is inside; named in the behaviour table and the replace Changeset.)Current specification
component:BottomSheet(packages/core/src/BottomSheet/BottomSheet.spec.md, current; amended on this PR's base): DEC-2 and FR5–FR12 own the stack (open order, tree-order ties, one visible scrim, covered sheets receded and blocked, dismissal to the top sheet only, nested sheets that leave and return with their host, focus on a pop and on a close of the whole stack), the replace handoff and its conditions, non-modal manual-popover hosting, and a switcher flow as one level; AR3–AR4 the inertness and focus contract; PR1–PR2 the one registry per page and one visible backdrop. This PR deletes the gap notes on FR5–FR12, AR3–AR4 and the two verification rows, and addsBottomSheetStacking.test.tsxandBottomSheetReplace.test.tsxtoverified_by.component:BottomSheetSwitcher(current; amended on the base): stays current with DEC-1/DEC-2 (each step owns its native dialog), FR1–FR9, AR1–AR3 and the theming anatomy all in force. DEC-3 and DEP-0015 record the lifecycle as deprecated — supported and maintained, docs and templates point to standalone step sheets, no runtime warning, removal only in a future declared minor through a[breaking]change — and FR10 records how a flow takes part in the stack (the active step's dialog is the level and keeps the flow's place across handoffs; covered → receded, blocked, transparent backdrop, dismissal stands down; closing over a lower sheet returns focus into it; a step is never paired in a replace). This PR deletes FR10's gap note and its verification row's, and adds the stacking suite toverified_by.architecture:layer-runtime(current): this PR edits it, as a records change in the implementation pull request, the way the per-step switcher change did. The record describes the runtime shipped onmain, so the record pull request leaves it alone and keeps the not-yet-shipped rules ascomponent:BottomSheetFR7/FR13/FR14 with gap notes; once this PR ships them, their shipped form belongs here. It adds three passages — a covered or replaced sheet reports itself absent to the shared dismissal stack; every open scrim sheet and a modal switcher flow's active step supply toast outlets, the latest among sheets following the stack and never raised above a later Dialog; the toast re-entry above an announcing non-modal sheet, with the non-modal Drawer excluded — and removes the scrim switcher from the list of modals toasts stay behind. Itsapproved_by/approved_atlines keepmain's values until an engineering owner's/approve-specis recorded on this pull request; the maintainer rewrites them after that approval.family:overlay-dismissal(docs/families/overlay-dismissal.md, current) governs dismissal: FR2 (one request affects one surface) and FR3 (platform close requests use the same topmost rule) are what stacked sheets rely on. Both members already register in the shared stack; this PR adds no second registry — a covered or replaced sheet answersisPresent: falseso the shared stack picks the right owner.spec:AST-027(current): z-index stays local; this PR adds no page-level band. Non-modal standalone sheets move fromshow()to a manual popover in the top layer (FR8), and the page-level1000is removed with the move (IR4); theshow()fallback below the Popover API floor is page stacking order, the reduced fallback AST-003 DEC-5 describes. AST-027's conformance table still lists "BottomSheet and BottomSheetSwitcher non-modal paths" as a confirmed leak: after this PR that row holds only for the deprecated switcher's own non-modal shell, whichcomponent:BottomSheetSwitchernow records as left to removal rather than re-hosting.spec:AST-017FR26, FR28, FR29, FR29a, FR29b: the deprecation is explicit on the declarations, ships with its replacement and an ordered migration, adds no runtime behaviour, and is removed by a separate[breaking]change in a declared minor.Public delta and caller burden
Public API: no additions.
BottomSheet(isOpen/onOpenChange/hasScrim/finalFocusRef/purpose…) andBottomSheetSwitcher(activeSheet/onActiveSheetChange/hasScrim…) keep every prop, type, default and export. The Core API audit (dist.d.ts+ exports map,origin/mainvs this branch) shows an identical exports map; the public declaration files differ only in doc comments and the three new@deprecatedtags (BottomSheetSwitcher,BottomSheetSwitcherProps,BottomSheet'ssheetId); the two new internal declaration files (bottomSheetStack.d.ts,BottomSheetHostContext.d.ts) are unreachable through the exports map.Release tier — a patch, for the owner to confirm under
spec:AST-017FR25. The Changesets arepatch([feat]×3,[fix], and the CLI's). The argument, stated plainly:finalFocusRefafter close, dismissal bypurpose); a non-modal sheet on its own keeps its documented contract (no scrim, no scroll lock, page interactive, dismissal bypurpose, focus return) while its host moves fromshow()+ a page-levelz-indexto the top layer with none — and stays beneath an open outside modal exactly as released.@deprecateddeclaration, replacement docs and migrated templates; removal is a future declared minor (FR28/FR31). The one behaviour change to a lone flow — toasts raised from a step become pressable — is a[fix]toward AST-003 FR16/FR18, named in its own Changeset.[breaking]and the records a compatibility note. The author's reading is that none crosses that line; the owner decides the tier before this lands.Behaviour delta (this is the proposal):
maintodayfinalFocusRef/ opener)isOpenis still true (their state is never written); focus stays in the top sheetBottomSheetSwitcherhasScrim={false}sheetshow(),z-index: 1000, independent; focus never returned on closeshow()below the Popover API floor): no backdrop, no scroll lock, no focus steal, page interactive, dismissal bypurposeas before; never stacks; replaces another non-modal sheet with the same handoff; returns focus to its opener on close while the person is still in itBottomSheetSwitchersheetId, removal in a future minor. A flow used on its own behaves as released; a flow stacked with other sheets is one level of the stack: it registers, recedes and isinert/aria-hiddenwhen covered, carriesdata-sheet-stack*, stands its focus trap and dismissal down while covered, keeps a transparent backdrop above the scrim owner, and returns focus by the stacking rules (into the revealed sheet, or once for a "Close all")showModalcount, same scrim, same focusshow()in page stacking order withz-index: 1000; focus never returned on closez-index; focus on open unchanged (panel when the browser moves focus in, otherwise none); returns focus to its opener on close while focus is still insidebodyfinalFocusRefor opener, after the last dialog has closedBefore / after callsites. Stacking needs nothing new:
A multi-step flow drops the switcher:
Caller burden: nothing new to learn to get stacking. For a flow: one state value naming the open step, and knowing that a same-update close and open of the top sheet, made from inside it, is a replace (the "Replacing a sheet" paragraph of the Stacking docs). Compatibility: three
[feat]patch Changesets for Core (stacking, replace, deprecation) and one for the CLI (the two templates). No consumer source change is required; a consumer who relied on two open sheets each painting its own scrim and not receding sees the new behaviour. A switcher flow on its own changes nothing at runtime; one stacked with other sheets takes part in the stack as one level (above). Editors show the@deprecatedtags. Theming: no new theme target; the recede uses the spacing token for its offset and the standard duration/easing tokens. Accessibility: covered sheets are blocked by the browser's own modal-dialog rules (the top sheet'sshowModal()makes everything outside it inert, lower dialogs included); a covered or replaced sheet that contains no presented sheet is additionally markedinert+aria-hidden; one that contains the top sheet is left alone, because inert on it would reach the top sheet.Deprecation and migration
BottomSheetSwitcher,BottomSheetSwitcherPropsandBottomSheet'ssheetIdcarry@deprecatedJSDoc naming the replacement;BottomSheetSwitcher.doc.mjsleads with "Deprecated: …", ships the replacement example as its only example (the switcher example is removed);BottomSheet.doc.mjsteaches one state value and standalone sheets everywhere it used to send multi-step flows to the switcher. There is no runtime warning: the repository's whole-component deprecations warn through declarations only (the 0.6 deprecations, removed in #6118, followed the same pattern), perspec:AST-017FR29/FR29d. Migration, per flow:activeSheet/onActiveSheetChangepair with onestepstate value.isOpen={step === 'id'}andonOpenChange={isOpen => !isOpen && setStep(current => (current === 'id' ? null : current))}in place ofsheetId="id"— the guard keeps a step that closes because the person moved on from clearing the step they chose.BottomSheetSwitcherwrapper, moving itshasScrimonto every step.setStep('next')).hasScrim={false}flow, or code after focus has left the step — closes and opens with the sheets' own push and pop motion, where the switcher handed off on anyactiveSheetchange; move such a control into the step, or accept the motion.The migration is
component:BottomSheetSwitcherDEP-0015's, step for step. Every switcher use has the replacement (spec:AST-017FR28): modal andhasScrim={false}flows migrate the same way, because non-modal sheets replace too (below). Maintained surfaces teach only the replacement (FR29b/FR29c):BottomSheet.doc.mjs(both best-practice lists, thehasScrimdoc, the Stacking section),BottomSheetSwitcher.doc.mjs(replacement example only; best practices say how to migrate), the two CLI block templates (BottomSheetSwitcherShowcase,BottomSheetSwitcherReviewFlow: rewritten as standalone steps; ids and files keep their names because a released template id is part of the CLI's JSON contract, and their published ShadCN registry identities are pinned too —exampleFor: 'BottomSheetSwitcher'withalsoExampleFor: ['BottomSheet']andregistry: {slug}keepshowcases/bottom-sheet-switcher/defaultandexamples/bottom-sheet-switcher/review-flowexactly, so the reviewed route lock is untouched and both blocks are also listed on theBottomSheetpage), LabMobileTokenizer(its one-step switcher is a standaloneBottomSheetwithisOpen; behaviour unchanged, Lab is private so no Changeset), theBottomSheetStackingandLayerDismissalstories (standalone steps), andBottomSheetSwitcher.stories.tsx, which stays as the deprecated component's own documentation, titled "(deprecated)" with a description pointing at theBottomSheetReplacestories that mirror it. The v0.5.0 blog post and thecurrentrecords that name the switcher as a dismissal-stack member are left as they are (history, and still true).Design and alternatives
Chosen model. Every modal sheet (and every modal switcher flow) keeps its own
<dialog>and callsshowModal()on it, so the browser's top layer already paints later opens above earlier ones and already blocks the dialogs beneath the topmost modal one. What the browser cannot know is that these dialogs belong to one stack. A small private registry fills that in:useLayoutEffect), for as long as a sheet is presented (including its exit animation). A render that never commits — a transition that suspends, a discarded concurrent render — registers nothing.(batch, seq). A batch is one React commit: hosts seal the batch in a passive effect, and React runs every pending passive effect before the next commit's layout effects (synchronously after a sync commit). Later batch is higher. Within one batch there is no open order, so document order of the dialog elements decides — a sheet before the sheets rendered in its content, siblings as written. A sheet that closes and reopens takes a new rank and comes back on top.showModal()in; where the ranked order diverges (a deep link whose inner sheet registered first), it closes the dialogs from the first divergence up and shows them again in rank order — inside the same commit, before paint. A depth-3 deep link is therefore correct in the first painted frame, underflushSync, before any microtask (tested).inert, data attributes and backdrop opacity declaratively and the first frame after the layout phase already has them. SSR renders no stack state; hydration matches.BottomSheetHostContext): it is presented only while its host is open: when the host closes, it leaves with the host without itsisOpenbeing written, and if it is still open when the host reopens it is presented again, on top. This is what prevents an invisible stuck modal, and it is also what makes a row-owned detail sheet work with no state hoisting.The replace. The registry pairs a move the person makes from one sheet to the next:
pointerdown,clickorkeydownin the document, recorded by one capture-phase listener per document, and counts only while fresh (one second: the handler a press runs flips the sheets in the same task). With no fresh press (a step change after a save resolves) the actor is the element focused when the update's first flip arrived, read before any dialog of the update is shown so the opening sheet's ownshowModal()cannot have moved focus yet. A close and an open driven from a control outside the closing sheet are two unrelated owners acting in one update: they keep their own openers and their own push and pop motion.Condition 5 is the answer to the one case an independent verification of the registry found wrong (two owners in one tree; a page control closes X's sheet and opens Y's; B adopted A's opener and closing B returned focus to X's control). Every real step flow passes it by construction: Continue and Back live in the step being left, and a step change made by code after a delay (a save that resolves) passes too, because under a modal sheet focus cannot be anywhere but inside it. The press, rather than focus alone, is read because focus says nothing in two real cases: a non-modal sheet never takes focus, and some browsers do not focus a clicked button, so under either the click's target is the only evidence of where the person acted. The one-second window keeps a stale press (an Escape key pressed on a sheet that has since closed) from vetoing a later, unrelated step change. The handoff itself is the switcher's state machine, ported into the registry and driven by the panels' existing motion reports:
covered→ (entering starts; a taller retained sheet getsaligningwith the measured offset) → both complete →fading→ fade complete → the replaced dialog closes and the replacing sheet takes the scrim in the same frame. The replaced sheet isinert/aria-hiddenat once, stands down from the dismissal stack, and lends its opener to the replacer. A reversal mid-handoff swaps the roles; a dismissal mid-handoff drops the retained sheet at once, as the switcher does. The host holds its open pose for one synchronous re-render when it closes, until the registry has paired or not: a first-rendered exit pose, read back by the panel's own style query, started the exit transition, and the reversal into the retained pose then snapped the panel to rest.Alternatives considered and why not:
showModal()to the end of the commit instead of reconciling: there is no synchronous "end of all layout effects" hook, andrequestAnimationFrame/ microtasks run after the first synchronous frame check the requirement asks for. The reconcile only re-shows dialogs for same-commit nested opens (invisible, pre-paint); normal opens append.inert: wrong for a covered sheet that contains the top sheet in its DOM (inert would reach the top sheet). Hence: native modal blocking always, explicitinertonly when safe.stackedprop or aBottomSheetStackwrapper to opt in: rejected by requirement 1; a lone sheet is unaffected, so there is nothing to opt into.<BottomSheetStack>provider, public or internal: the coordinated thing (the document's top layer) is wider than any React tree. A context cannot reach a second React root, a second package copy or a widget mounted outside it, and in every one of those cases the module registry does the right thing or no worse than today; the repository already uses module singletons for document-wide facts (layerStack,modalOutlet,useScrollLock). The only scoping a provider adds is a "same level" boundary, and it adds it only if products wrap unrelated regions in separate providers — candidate B's problem back again. Condition 5 covers that case without a wrapper.onOpenChange(false)when its host closes: the earlier revisions did this from an Effect reacting to the host's state, which is a component writing a consumer's state with no external system to synchronise (architecture:react-component-runtimeINV2/INV3), and there is no event path for it either — a host learns it is closing only through its own props. Dropped: the gateisOpen && isHostOpenalready keeps the nested dialog out of the top layer while the host is closed; the nested sheet leaves with its host, itsisOpenstays the consumer's, and if it is still open when the host reopens it is presented again, on top — the same contract as a Dialog rendered inside a closed Dialog. The docs' Stacking section says so and tells the consumer to close it when the flow is done.BottomSheet(replaces/ a flow key): unnecessary once the rule above is unambiguous; it would make the author say twice what one state value already says. Kept in reserve.document.activeElementat pairing time: wrong on a reversal, because the returning sheet's layout effect runs first in tree order and itsshowModal()moves focus before the pairing is seen. Hence the actor is captured at the update's first flip.0.01stransition for scrim handoffs: still paints one frame at the old value; the pin is0s, inline, held for two animation frames.z-index: 1001on the enteringshow()dialog: the first version did this;spec:AST-027rules it out. Hence the top layer: a non-modal sheet's<dialog>carriespopover="manual"and is shown withshowPopover()(as CoreDraweralready does forhasScrim={false}), so the browser orders the entering step above the retained one with no page-level band. A manual popover has no light dismiss, no backdrop and no scroll side effects; its one difference fromshow()is the browser's focusing step, which lands on the scroll region's focusable group (and draws a ring) whereshow()landed on the panel (tabindex="-1", no ring), so when the browser moves focus into the sheet the host puts it on the panel and a lone non-modal sheet focuses exactly as before (verified in Chromium: same target,outline: none, no outlined element; a jsdom test stubs the browser's focus move). The non-modal contract is otherwise as released: page interactive and scrollable, no scroll lock, no focus steal on a lone open, dismissal bypurpose(Escape and swipe) through the shared stack. In a replace the entering step takes focus explicitly (Chromium'sshowPopover()happens to move focus into it, Safari's does not; the registry does not depend on either), so the keyboard stays in the flow and the final close finds focus inside. One addition: the sheet records its opener on show (as the modal path does) and returns focus to it when it closes while the person is still in the sheet — read at the start of the close, before the browser's focus fixup for the exiting sheet'sinertruns — and leaves focus alone when the person had moved on to the live page. An explicitfinalFocusRefkeeps its released contract for a sheet closing on its own or last: it is focused wherever focus was, modal or not. When a sheet closes with a modal sheet still open beneath it (new in this PR), focus returns into that sheet — to thefinalFocusRefelement if it is inside it, else the revealed panel — because the page is still inert and a target on it cannot take focus; the prop's JSDoc and the Stacking docs say so;hidePopover()itself never moves focus, andmain'sshow()/close()pair never restored it either (theinertblur came first). Because the sheet now enters the top layer, the toast viewport (apopover="manual"host shown earlier) would paint beneath it; the sheet's show path therefore callsmodalOutlet'snotifyTopLayerEntry(), the same subscriptionToastViewportalready uses to follow Dialogs, and the viewport re-enters the top layer in place (hide + show, focus inside it kept) — it does not move into the sheet, because a non-modal sheet blocks nothing and is not an outlet (architecture:layer-runtime's ToastViewport paragraph records this). Tooltips, popovers and menus opened from the sheet are shown after it and already order above it. The non-modal sheet's page-levelz-index: 1000is removed with the move (spec:AST-027IR4). Below the Popover API floor (Tier 2 in the browser support guide: Chrome 114+, Safari 17+, Firefox 125+; the Popover API is the guide's one Tier 2 requirement) the attribute is inert and the sheet falls back toshow()in page stacking order — the documented reduced fallback ofspec:AST-003DEC-5: the sheet opens, replaces and dismisses, but makes no promise over later page content and no z-index ladder compensates; nothing in the fallback path reads the removed value. In the registry non-modal sheets are a group of their own per document: they never stack, recede or own a scrim, and only replace each other; a non-modal and a modal sheet never pair. Leaving non-modal flows on the switcher was the alternative: it leaves the deprecation with a gap (spec:AST-017FR28) and the AST-027 leak open.Decisions on the open items
inert/aria-hiddenwhen the covered dialog contains no presented sheet; recede 0.96/−8px at depth 1 and 0.92/−16px from depth 2, withtransform-origin: 50% 0on the positioner; only sheets count (a CoreDialogover a sheet does not recede it).--_sheet-scrim-opacity: 1), every upper sheet's is0; a drag on an upper sheet never thins the visible scrim; when the owner closes under others, the next owner's backdrop appears withtransition-duration: 0swhile the old one fades out on its usual linear curve.isOpenis never written, so one still open returns when its host does); siblings above that are not in its content stay open and keep their order, the closing sheet slides out from beneath them. No stuck or orphaned sheets. Prior art: native<dialog>s are independent (kept for siblings); Radix unmounts nested children (kept for nested content, through presentation rather than an unmount); Zag/UIKit close everything above (not adopted — a sibling's state belongs to its owner).<Activity mode="hidden">sheet leaves the stack and closes its dialog (a modal dialog with no box would block the page), and on reveal is presented again on top; SSR and hydration markup match (stack state is never rendered on the server).hasScrim={false}): never stack — they neither make a modal sheet recede nor are receded by one — but they are hosted in the top layer as manual popovers (show()below the Popover API floor), replace each other with the same handoff, and return focus to their opener on close while focus is inside. No scrim, no scroll lock; the page stays live.Caveats (documented in the Stacking section):
Dialog/AlertDialog/Popoveropened from a sheet is not a level: it paints above the sheet without receding it; a sheet opened from inside it paints above the Dialog and stacks over the sheet beneath.closeevent fires for each re-show.Registry hardening
globalThisunderSymbol.for('astryx.bottomSheetStack.v<N>'), the key naming the protocol version, with the shape as a collision guard; two copies of the package on one page (two bundles, a dev server serving one file under two URLs, a micro-frontend) that speak the same protocol share one stack; a copy on another version finds no state under its key and keeps a stack of its own (PR1). Everything the reconcile reads is in that state — the entries, each document's shown order (keyed byDocument, per modal/non-modal group) and which documents already carry the press listener — so nothing is per copyownerDocument; a sheet portaled into another document has its own stack, top layer and focuscloseDialogKeepingFocus:close()returns focus to the element recorded atshowModal(); every registry-driven close (a re-show that repairs order, a replaced sheet leaving) and every close beneath an open sheet restores the active element if the browser moved itwriteScrimOpacitypins--_sheet-scrim-duration: 0sinline for two animation frames (two nestedrequestAnimationFrames) on both the new owner (appears) and the old owner (leaves); the stylesheet reads the pin in the default and the reduced-motion declaration; the pending restore is cancelled on unmountlayerStack) has the same two-copies problemEvidence
Review findings resolved
Every finding of the automated reviews on this work (the stacking review and the replace-prototype review), with the fix and the test that pins it.
isPresent: falseto the dismissal stack while it is covered, so the press and thecancelrequest go to the top sheet, as the Stacking docs say. The dismissal stack itself is untouched; the sheet stack tells it who is on screen.onOpenChange(false)on mount and on every rerender (twice under StrictMode), so a host that opened after its child wiped the child's open state.close(). Closing a modal dialog returns focus to the element recorded atshowModal(); the reconcile's close + re-show and a covered sheet closing from code could pull focus out of the sheet on top.closeDialogKeepingFocus: every registry-driven close and every close beneath an open sheet restores the active element if the browser moved it; a sheet closing beneath others never runs its own focus return.transition-duration: 0.01s, which still paints one frame at the old value, and the old owner's backdrop faded over 410 ms underneath it: measured page dim 0.5 → 0.74 → 0.5. Underprefers-reduced-motionthe stylesheet's literal0.01soverrode the pin.--_sheet-scrim-duration: 0spin that stays for two animation frames (two nestedrequestAnimationFrames; cancelled on unmount), on both sides: the new owner's backdrop appears at once and the old owner's leaves at once (isScrimTakenOverin the view). The stylesheet reads the pin in both the default and the reduced-motion declaration. Measured: dim constant at 0.5 through the handoff, with and without reduced motion.globalThisunderSymbol.for('astryx.bottomSheetStack')with a shape number; a copy that finds a state of another shape keeps its own. Entries are keyed byownerDocument, so a sheet portaled into another document is its own stack with that document's top layer and focus. The test reset also clears the seal flag. The shared dismissal stack (layerStack) has the same duplication problem and is a follow-up outside this PR.show()dialogs with a page-levelz-index: 1001(AST-027).BottomSheet.doc.mjsstill sent multi-step flows to the switcher (thehasScrimdoc, both descriptions, a best practice, the example).@deprecatedwording.check:knowledgesetIsCloseSettled(false)reset in a layout effect with no external system.isOpenresets it during render (the previous-value pattern), and only the layout effect that follows the registry's answer sets it.writeScrimInstantlyscheduled were not cancelled on unmount.writeScrimOpacityin the registry with a per-host pending handle; hosts cancel it in an effect cleanup.setBottomSheetOpendropped the replaced sheet when its replacer closed, before the returning sheet's open flip of the same update arrived, so theisEnteringInPlacepath never ran.detectReplaceswaps the roles instead of dropping when the sheet a closing replacer was replacing is the one coming back.hasScrim={false}flows without a replacement (AST-017 FR28).check:knowledge; CLI template tests; LabMobileTokenizertests unchanged and passing;storybook:buildonOpenChange(false)in an Effect reacting to host state, with no external system named (react-component-runtime INV3, INV2).registry-contract, thentest, docsite lanes and the Vercel build): the template rewrite movedexampleForand renamed the docs, which renamedshowcase-bottom-sheet-switcher-defaultand movedexamples/bottom-sheet-switcher/review-flow.exampleFor: 'BottomSheetSwitcher'(+alsoExampleFor: ['BottomSheet']) and pin the leaf withregistry: {slug: 'default'}/{slug: 'review-flow'}; zero route changes, lock not refreshed.generate-data(canary and latest) passes the lock check;verify-production-gate,verify-full-catalog(1030 preview items, 909 hidden production items), the hidden-production discovery test and the docsite build pass locallyshowPopover()focuses the scroll region's group (2 px ring) whereshow()focused the panel.div.astryx-bottom-sheet,outline: none, no outlined element; no-scrim replace step 2: switcher →body, standalone → the new step's panel, no ringuseLayoutEffectthat only set state (react-component-runtime INV3).hidePopover().inert's focus fixup); focus the person took elsewhere stays. Measured onmainfirst: itsshow()/close()never returned focus either (the exiting shell'sinertblurs the panel beforeclose()), so this is a fix overmain, consistent with CoreDrawer's non-modal path.main: lone NoScrim Escape and swipe, flow Cancel / Escape / swipe / final Done —main→bodyin all six, branch → the opener in all sixgroupKeys,nextGroupIdand the press-listener set were per module copy whileshownOrderwas shared, so two copies meeting different documents both derived0:modaland one reconcile hid the other's sheet.Document(per modal/non-modal group) inside the shared state, and the listened-documents set lives there too; no per-copy state remains.show:framed, hide:framed, show:main; passes now with both shown)check:knowledge; no testfinalFocusRefskipped on a non-modal close when focus had left the sheet (the "only while focus is inside" rule covered it too).finalFocusRefis unconditional, exactly as onmain, on both paths (`finalFocusRef ?? (modalBottomSheetSwitcher.doc.mjsand the@exampleon theBottomSheetSwitcherfunction.@deprecatedtag keeps the replacement example, the doc's only example is the replacement, and its best practices now say how to migrate instead of how to use the switcher. No other copyable switcher example remains in.doc.mjsfiles, CLI docs or templates (the two templates are standalone steps; the switcher's own tests and the deprecated stories stay as its documentation).check:knowledge; docsite buildshowPopover()put it in the top layer after the toast viewport, so a toast raised while it was open (or before) hit-tested to the sheet.modalOutletnotifies a top-layer entry after the sheet'sshowPopover();ToastViewport.place()re-enters the viewport in place on that notice (the existing subscription; a non-modal sheet is not an outlet, so the viewport does not move).ToastViewport.modalHost.test"re-enters the top layer in place when a non-modal sheet is shown after it" (hide + show after the sheet's own show, parent unchanged, a toast raised inside the sheet lands in the same viewport, closing the sheet re-enters nothing) and "keeps focus inside the viewport across a re-entry"; Chromium hit-test on the newNon-modal over a live pagestory — toast raised before the sheet opens, toast raised from the sheet while open: both hit the toast after the fix (both hit the sheet on the previous head); tooltip and popover opened from the sheet hit themselves; WebKit agreesz-index: 1000on the non-modal shell (AST-027 IR4).show()fallback is page stacking order, as DEC-5 describes. The switcher's own non-modal shell keeps its value: that path is deprecated and not re-hosted here (AST-027's table already lists it).show()fallback test still replaces<body>when it closed after its opener had gone: React runs the registration effect's cleanup (sameisFlowVisibledependency) before the close effect, so the flow had already left the registry —getBottomSheetBeneathwasnulland the depth read the reset view.body), passes now; Replace "after a reversal, closing the flow still returns focus to the flow's opener" added while validating the Safari script<body>, so the flow's final close could not return it to the opener (real Safari 27.0.1 run of the WebDriver check; Chromium'sshowPopover()had been moving focus into the entering step, Safari's does not).focus('replace'): the panel, or adata-autofocusdescendant), whatever the native host did, when the pairing is a replace — i.e. the person was in the replaced step. A lone non-modal sheet still takes no focus on open; two unrelated owners swapped from the page still leave focus on the page. Every other focus move (modal open, reveal, switcher step, flow close) was already explicit; none relied on the browser.<body>: with several sheets closing in one update each returned focus at depth 0 — the bottom sheet's page opener refused while the upper dialogs were still modal, the upper openers inside dialogs already closed (the exits complete bottom-first, in tree order of thetransitionendevents).mainbehaves the same, but the Stacking docs promise a return to the opener.finalFocusRefor opener. A single close, a batch that also opens (the replace), a batch leaving sheets open (pops return into the revealed sheet), and non-modal sheets are not groups; a modal sheet closing with a non-modal one is not a group either. The switcher reads its membership in its registration cleanup, like its depth.finalFocusRefpreferred; switcher flow on top closing with the sheet beneath → bottom opener; non-modal pair keeps its own rule; modal + non-modal is not a group — the first and third fail on the previous head under an emulation of the browser's focus refusals (closed dialog, outside every modal). Chromium and WebKit: Orders drill-in "Close all" from depth 3 → "Open orders" (previouslybody)finalFocusRefwas described as unconditional on every close although a pop with a sheet beneath returns into that sheet; evidence was cited by hashes no branch reaches and a harness not in the tree..doc.mjs, JSDoc, this description); the commit message is rewritten; thefinalFocusRefJSDoc and prop doc state the stacked rule; the migration recipe uses the guardedsetStepeverywhere (changesets, docs, templates, stories, this description); the pin is "two animation frames" everywhere; runs are described by what they ran against; the Safari harness is published in its own pull request (#8297,chore/bottom-sheet-safari-harness) rather thaninternal/scripts/bottom-sheet-safari-check.mjs(the directory for one-off automation scripts, beside the Crowdin screenshot uploader). Also, a glued two-in-one evidence row inherited frommaininBottomSheet.spec.md's evidence table is split back into two rows (no content change).check:knowledge,check:synccheck:knowledgeon both headsmain(DEC-1; this PR was written against the shared dialog).hasScrim={false}sheet leaving focus onbody; a toast painting beneath a non-modal sheet; "Close all" ending onbody).registry: {slug: 'default'}(route lock unchanged, 783 preview routes verified); the standalone host captures the opener before the non-modal branch and returns to it while focus is inside (Chromium: Escape on the lone NoScrim sheet → "Set up notifications");showSheetHostNonModalnotifies the modal-outlet registry and the toast viewport re-enters the top layer (Chromium: the toast overlaps the sheet and is what the pointer hits; focus stays in the sheet); closes of a whole stack go through the registry's closing group and return focus once after the last dialog has closed (Chromium: Close all from depth 3 → "Open orders").BottomSheetSwitcher.doc.mjs(both descriptions and the summary), both@deprecatedJSDoc blocks, and the records (DEP-0015replacement+ migration step 5, DEC-3). The BottomSheet docs' "Replacing a sheet" already stated it.BottomSheetNativeModal.a11y.browser.spec.tsno longer foundcore-bottomsheetswitcher--*.id: 'core-bottomsheetswitcher'on the stories' meta keeps every id as it was (browser spec, docs, links); the Safari harness's dead-deprecatedcandidates removed.core-bottomsheetswitcher--hug-content; the Chromium checks drive the switcher stories by those idsastryx search close44 → 45 with BottomSheet;icon set366 → 367).experimentalleak.experimental-isolation.test.mjs34/34MobilePowerSearchstill uses the switcher while the DEP's downstream row said Lab uses the replacement.downstreamrow and Intent keep a gap note namingMobilePowerSearch;MobileTokenizer, the templates and the stories are migrated. Whether FR29c requiresMobilePowerSearchto migrate in this patch is an open owner decision (see the FR29c row below).mainnow makes a modal standalone sheet a toast outlet (merged while this was in review).spec:AST-003FR18 allows: every open scrim sheet supplies an outlet (useModalOutlet(dialogRef, isOpen && hasScrim, stackView.isTop)— a covered sheet stays registered), and the latest outlet follows the sheet stack:useModalOutletgained anisLatestargument that, when it turns true (a sheet shown on top, or revealed by a pop), moves that outlet to the end of the registry so the toast viewport moves into it. Switcher flows remain non-outlets, as onmain.getActiveModalOutlet) while the viewport is in the top sheet; the non-modal test asserts:popover-openmainmoves focus to the panel (show()'s focusing steps).showSheetHostNonModalre-homes whatever the browser focused to the panel, so the behaviour ismain's; the record is corrected (spec PR). Verified: keyboard open of the NoScrim story lands on the panel onmain's build and on this head, identically.BottomSheetHostContext).activeSheet = isHostOpen ? activeSheetProp : null: with the host closed the flow reads as closed everywhere (presentation, registry, dismissal, context) and leaves with its host through its own exit; itsactiveSheetis never written, so it comes back with the host on its step. Same rule as a standalone sheet. The "Stacked over a standalone sheet" story gains a page-level "Close settings" control to show it.data-autofocusfield: the record had said the field is focused; onmainthe nativeshow()focuses the panel first and the autofocus is not applied.main); the record corrected (spec PR). Verified identical on a static build ofmainand on this head with a newNoScrimAutofocusstory (ahasAutoFocusTextInput in a no-scrim sheet): panel focused, field not.detectReplacereverts both — replaced sheet back to an ordinary exit, replacer back to an ordinary push with its own opener, no owed focus — before the commit paints. Several sheets changing in one update keep push and pop motion in both tree orders. State shape bumped to 6.replaced, Saved is a fresh top, dim 0.5 throughout, closing Saved does not land on the List openerdata-autofocuson a lone open: the record had said a modal sheet focuses the field; Chromium onmainfocuses the panel (nativeshowModal()), the field is not applied.main, verified with a newModalAutofocusstory on a static build ofmainand on this head: panel focused, field not, identically). Records corrected in the spec PR; the consumer doc's "Focus" sentence andfocusSheetPanel's comment now describe the panel focus, with the replacement entry as the stated exception. The two Stacking stories that putdata-autofocuson a TextInput wrapper (where it did nothing) usehasAutoFocus.BottomSheetSwitcher.doc.mjsand the file header still said each step dialog works "exactly like a standalone sheet: … no-scrim flows withshow()", while a standalonehasScrim={false}sheet is now apopover="manual"host.showModal()as a standalone modal sheet does; a no-scrim flow presents each step withshow()inside the switcher's isolated flow wrapper (local ordering), which differs from a standalone no-scrim sheet's top-layer popover host and is named as one reason to migrate. Searched the other docs, JSDoc, Changesets, templates and stories for the same claim: none. The record's DEC-1/FR5 already nameshow()for the switcher and leave that path to removal.check:knowledge,check:sync; template suitesMobilePowerSearch(raised on the spec PR).MobilePowerSearchmigrates in its own follow-up — or migratingMobilePowerSearchbefore the deprecation lands). This PR currently follows the first because the owner asked that the Lab flow be left alone while two Lab changes are in flight; nothing here is decided.BottomSheetSwitcher.doc.mjsmigration strings (the best-practice entry and its dense twin), worded as the record's DEP-0015:onCancel→ a guardedonOpenChange(holds the step against Escape, platform close, scrim tap and swipe alike —onOpenChange(false)carries no reason, so an Escape-only veto has no equivalent;purposeis the only per-path lever);aria-label/aria-labelledby→ each step'slabel;ref/id/data-testid/className/style/xstyleand other dialog props have no dialog-level equivalent (query the panel'sclosest('dialog'); dialog-level styling dropped).astryx component BottomSheetSwitcher --denseshows it; CLI template suites,experimental-isolation34/34closetook the outlet away from a re-shown sheet — a registry re-show (close()thenshowModal(), on a reversal or a reopen during exit) queues acloseevent thatuseModalOutletread as the sheet closing, so a toast raised from the reopened sheet could no longer be pressed (spec:AST-003FR16).useModalOutlet'sclosehandler ignores the event whiledialog.openis true again (the dialog was closed and re-shown in one task).useModalOutlet(ref, isActive, isLatest, group)raises a sheet's outlet only among sheet outlets (raiseWithinGroup), never above an outlet of another group supplied later. Dialogs are untouched (no group, default ordering).layer-runtimecarried rulesmaindoes not meet, against its own scope sentence.component:BottomSheetFR7/FR13/FR14 with gap notes; this PR adds the shipped sentences tolayer-runtime(dismissal standing-down, outlets per AST-003 FR18 with the sheet-stack order and the Dialog rule, the non-modal re-entry and its Drawer exclusion) as a records change, the way the per-step switcher change did, its approval metadata keepsmain's values until an engineering owner's/approve-specis recorded on this pull request.check:knowledge[fix]: new Changesetbottom-sheet-switcher-toast-outlet.md(AST-003 FR18 conformance — a toast raised from a flow is now pressable; the host follows each handoff; ahasScrim={false}flow leaves the viewport in place). The deprecation Changeset, bothdoc.mjsdescriptions, the story description, the two JSDoc summaries and the Switcher record's Compatibility statement now say "behaves as released, with one fix: a toast raised from a step renders above the flow and can be pressed".check:changesets(25), changeset coveragelayer-runtimepassages lived on the base./approve-specon this pull request; the record's approval metadata keepsmain's values until that is recorded.layer-runtime'sapproved_by/approved_atwere rewritten ahead of the approval.main's values; the maintainer rewrites them once an engineering owner's/approve-specis recorded on this pull request.git diff origin/main -- docs/architecture/layer-runtime.mdshows no change to those linesdata-autofocusfield, against AR4 and the Focus guidance: the dialog is shown (and the native focusing steps have put focus on the entering panel) before the registry's placement runs, and both the placement's caller andfocusSheetPanelstopped at "focus is already inside".data-autofocusdescendant when there is one, and otherwise leaves the panel focus alone (no secondfocus()); only focus the person already moved to a control inside the step stays put. Lone opens are unchanged (panel).showModal()/showPopover()as a browser does, so the condition is not stubbed away; both fail without the fix; a control case without a field keeps the panel. Two new stories (modal, no scrim); Chromium: step one alone has the panel focused, and after Continue the entering step ends with its Name field focused, both hostingshasScrim={false}sheet opened while a modal it is not inside is open painted above the modal viashowPopover()while staying inert, so a press landed on the modal beneath (on the base it stayed hidden under the modal).:modal, with the modal hosts' ownaria-modalwhere an engine lacks it): with one open, the sheet is presented byshow()beneath the modal, with no page-levelz-index, and announces no top-layer entry; it enters the top layer when no outside modal is open, or at once when it is rendered inside the modal's content (not inert there). The decision sits in the one presentation path, so a stack re-show takes the same branch.show()and noshowPopover()(fails without the fix), plus a Dialog case (fails without the fix), an inside-the-modal case and a no-modal case (top layer); new story "A no-scrim sheet opened from a modal sheet, declared outside it"; Chromium: the hint is shown byshow()(not a popover) while the modal is open, a press at its button does not reach it, and once the modal closes it is pressableshow()(because an outside modal was open, or a closing modal still matched:modalthrough its exit) stayed there after the modal had gone, with noz-index, so a fixed tab bar painted over it and took its presses (AST-027 FR8).show()is remembered per document and, on any dialogclose(one capturing listener per document) and on every sheet-stack update, is moved into the top layer —close()thenshowPopover(), announcing the entry so toasts re-enter, focus kept — once no outside modal remains. A modal on its way out (inert oraria-hidden) counts as gone, so a sheet opened in the update that closes a modal sheet enters the top layer at once.:modalanswer) — both fail without the fix. The story gained a fixedz-index: 10tab bar and a Swap control; Chromium: beneath the modal while open; once the modal has gone the hint is a popover, pressable, above the tab bar it overlaps; after Swap the hint is in the top layer while the modal sheet is still mid-exit, and ends pressable above the tab barBottomSheetStacking.test.tsx: a plain function assigned tomodal.matchesis not a type predicate under TypeScript's overloads) and the storybook typecheck failed (the new autofocus story'sTextInputlackedvalue). The earlier validation reported "typecheck 0" from a rootpnpm typecheckthat does not exist (it exited 1 with "command not found", and agrep -con its output read 0).vi.spyOn(modal, 'matches').mockImplementation(...); the story'sTextInputis controlled. Validation now runs the typecheck steps CI runs, per package, and reports their exit codes: coretypecheckandtypecheck:docs, labtypecheck:docs, storybooktypecheck, a11y-spectypecheck, clitypecheck:strictandtypecheck:template-docs— all exit 0 on this head. (@astryxdesign/lab typecheck, not a CI step, fails onorigin/mainitself inMobileTokenizer.test.tsx, unrelated to this PR.)dialogexposed, with its name — a lone sheet, stacks of two and three, and a replace handoff before and after it settles. The record's stacking verification row names the check.show()andshowModal()land on the first focusable area in tree order — the panel (tabindex="-1") — while itsshowPopover()skips such containers and lands on the first control; Firefox 151 lands on the first control on all three paths (thedata-autofocusfield only when it comes first). WebKit could not be launched on the Linux node (missing system libraries), so it is unmeasured here.show()does, measured once on a throwaway non-modal dialog inside one task (focus restored) — Chromium re-targets to the panel asshow()did, Firefox is left alone asshow()did.show()to focus the container, and a Firefox-shaped twin ("an engine whose show() skips the panel container") asserts the first control keeps focus; per-engine probe scripts in the evidence (engines-focus.mjs,engines-native-focusing-steps.mjs) with their output; Chromium harness unchanged for lone opens (panel)prefers-reduced-motion: reduceno motion plays and nothing cross-fades in its place: the recede and return land in their end state the frame they apply, the entering step is at rest from its first painted frame, the retained step's alignment and fade each land instantly, the handoff's steps still run in the released order (enter, align, fade, close) so it settles within a few frames with no intermediate values painted, and the dim stays constant; new AR5 — a push or pop adds no announcement beyond the focus move (no live region), a replace is perceived as focus entering the new step (itsdata-autofocusfield or panel), whose dialog name is announced as focus enters; the retained step is hidden from the first frame. Code already matched (every motion'stransitionDurationcollapses to0.01sunder the query; no live region exists), so no change.reducedMotion: 'reduce'emulated: the covered sheet is in its receded end state within ~one frame of the push and the dim is constant; the entering step is at its resting transform within ~one frame with the retained step already faded, the handoff still completes to one presented step, dim constant (5 checks); the accessibility-tree checks cover AR3/AR5's exposuredocument.bodyand cached for the page: if the first non-modal sheet to reach it sat inside an open modal, the probe ran under that modal's inertness (whereshow()moves no focus), cached the wrong answer, and every later non-modal sheet focused wrongly.show()stub moves no focus for a dialog under an open modal outside it; fails with the body-level probe. Firefox and Chromium probes re-run on this head: unchanged (panel / first control)hasScrim={false}open in Chromium focused and blurred the sheet's first control before the panel (showPopover()ran its focusing steps onto the first control, and focus moved to the panel only afterwards), so a field validating on blur showed its error on open; the page's first open added the probe's round. The releasedshow()focused the panel directly.autofocusis set on the panel for the duration ofshowPopover()(it becomes the steps' autofocus delegate in every engine; the attribute is removed at once), so focus lands on the panel with no control touched. Then, only in an engine whoseshow()skips the container (Firefox), focus moves once from the panel to the first sequentially focusable control — onefocuson it, noblur. The engine probe runs after that, inside the shown sheet, and only blurs and refocuses the panel: no page content and no control sees an event.focus/bluron the first control and asserts none (fails with the previous approach); the Firefox-shaped test asserts the first control ends focused; both assertautofocusis not left on the panel. Real browsers (evidenceengines-focus.txt, per-openfocusin/focusoutcapture, page's first non-modal open so the probe round is included): Chromium — trigger blur, panel focus, then the probe's own container focus/blur inside the sheet, panel focus; no sheet control and no page content sees an event. Firefox — trigger blur, panel focus/blur, the probe's own button focus/blur inside the sheet, panel focus/blur, then onefocuson the first control (Search) and nobluron it — its released result. Modal opens: trigger blur + one focus (panel in Chromium; the field or first button in Firefox)architecture:knowledge-contractsINV10 keeps out of records.show()lands").internal/scripts/bottom-sheet-safari-check.mjs, 658 lines, and its README section) ships nothing and is separable.chore/bottom-sheet-safari-harness(branch offmain: the script plus its README row and section, noting that the stacking and replace stories ship with this PR and are recorded asskippedon a deployment without them). The three real-Safari runs quoted below were made with that script; the Evidence section points at the harness PR.check:repoon both branchesdetectReplacereject Processing → Done as a page-control push/pop, although focus was inside Processing; in Chromium the final close then left focus onbody.show()-fallback tests guard the ordinary press-from-inside caseowneris the bottom open or replaced entry): the existing test "runs the replace one level up a stack and leaves the sheet beneath covered" assertsBasekeeps--_sheet-scrim-opacity: 1while the retainedAand enteringBare0.mainlandeddesign:reduced-motion(#8272) while this round was in review: under reduced motion a transition must not move or scale (DR1), cross-fades on a coarse pointer (DR2) and changes at once on any other (DR3); the switcher record onmaincarried a gap note that the shipped switcher still runs shortened transforms and does not fade in on touch.prefers-reduced-motion: reduceno transform runs (@starting-stylekeepstranslateY(0), the exit keepstranslateY(0), the recede lands in its destination at once); withpointer: coarsethe entrance starts fromopacity: 0and the exit ends atopacity: 0withtransition-duration: 0.01s, 200ms(transform, opacity), so the entering sheet fades in at rest while the retained or leaving sheet fades out where it is — a cross-fade of 200 ms; on a fine pointer the0.01scollapse stays. Completion: an exit under the cross-fade waits on its opacity fade before the dialog closes (useMediaQueryon the same query); the entrance keeps waiting on its instant transform so the retained fade starts at once and the two fades cross. Two residues the browser exposed and this PR also fixes: the retained sheet's alignment offset was still written inline (156 px of travel under DR1) — a retained sheet no longer aligns under reduced motion, it fades where it is; and the gesture hook pinned an inlinetransition: noneon every open sheet under reduced motion (made redundant by the stylesheet's own collapse), which silenced the opacity fade — the inline pin now applies only to a drag or a scroll-area reconcile. Both gap notes close (FR15's and the switcher's FR3); only theMobilePowerSearchnote outlives this PR. New Changesetbottom-sheet-reduced-motion-cross-fade.md([fix]).transition: noneinstead of the removed inline pin; Chromium with reduced motion emulated under an iPhone profile (coarse) and a desktop one (fine), 15 checks: no transform travel in the handoff (entering and retained each hold one transform) or a lone exit, on either pointer; coarse — both steps paint intermediate opacity frames (7 each), the entering fade 133 ms and the retained fade 183 ms, the retained fade starting (97 ms) before the entering fade completes (146 ms) so they cross; fine — no intermediate opacity frame, the retained dialog closes within two frames, a lone exit leaves at once; the recede lands at scale 0.96 at once on both; dim constantdesign:reduced-motioncross-fades a transition the release of a touch drag started even on a fine-pointer device, so a swipe dismissal under reduced motion must fade out in place rather than vanish.isDismissedByDrag(set when a release coasts past the dismiss line, cleared when the sheet reopens); the panel treats an exit that follows such a release as a cross-fade on every pointer —translateY(0),opacity: 0,transition-duration: 0.01s, 200ms— and waits on the opacity fade before closing. Dismissals from a control, Escape, the scrim or code keep DR3 on a fine pointer. FR15 states the clause; the Switcher's Handoff state says the retained step paints the scrim only when the flow owns it; main's reduced-motion failure signal is back in the Switcher's FR1–FR4/FR8 row.check:changesetspointerType(a synthetic touch counts astouch) and reportsisDismissedByTouchDragonly for a finger; a mouse or pen drag keeps DR3 on a fine pointer. The deprecation Changeset, the switcher docs, story and JSDoc now say "two fixes" (the toast outlet and reduced motion).pointer: coarsefalse): a CDP-driven finger swipe paints intermediate opacity frames and closes within ~200 ms with no travel after the release, a mouse drag on the same handle closes with no intermediate framearchitecture:react-component-runtimeINV3).isOpenturns true again it is reset during render (the previous-prop pattern) and the exposed value istouchDragDismissal && !isOpen. No effect, no lint exemption.isOpenwent false → true, so a guardedonOpenChange(the documented replacement for the switcher'sonCancel) that ignored the swipe left it set, and the next close from a control, Escape or code cross-faded as if it were a drag.isOpenRequested(the host's ownisOpen, distinct from the panel state that staysopenuntil the close settles): false → the exit under way is the drag's; still true → the request is spent. Derived during render, no effect.spec:AST-017FR29c); onmainit says "use BottomSheetSwitcher for multi-step flows" and configures the flow's scrim on the switcher.hasScrimon a standalone sheet); this round the three prop tails that still said "Omit inside BottomSheetSwitcher" name the switcher as deprecated too, so every mention of it in the doc does.check:repo; theDEP-0015row in the Switcher record names this dochasScrim={false}sheet was promoted only on a documentcloseor the next stack update; an open<dialog>removed from the document fires noclose, so a blocking Dialog that unmounts while open ({isEditing && <Dialog isOpen>}) left the sheet onshow()beneath nothing.MutationObserverper document watcheschildListremovals. The dialogs that caused each deferral — every:modaldialog outside the sheet — are recorded at defer time in a per-documentWeakSet, because:modalleaves with the dialog's top-layer membership and cannot be read off a removed node, andaria-modalis not set byshowModal()hosts such as MobileNav or Lightbox; a removed subtree holding a recorded blocker triggers the same promotion pass (a blocker that closes still arrives through the documentcloselistener). The observer disconnects once nothing waits (promotion orhideSheetHost).show()beneath the modal Dialog, Discard removes the open Dialog, the hint is:popover-open(no longer ashow()dialog) and hit-tests above the fixed tab bar, and still closes from its controlcheck:repoarchitecture:component-style-authoringINV7 /spec:AST-017FR60 (element-count parity, now onmain)<dialog>and panel — no pointer-specific element, no second mounted presentation, and the only transient copy is none at all: the replace handoff retains the existing step's dialog while the new one enters, so no content is mounted twice.pointer: coarse) and a desktop one (fine) at the same viewport: every open layer's element count is identical on both pointers (stacked: 47 + 31; no-scrim: 36; settled handoff: 58), one presentation eachshowModal()plus the stack'sfocus('open')landed on the sheet's panel. The next keystrokes went to the panel.presentStepModallyremembers the active element when it is inside another presented dialog that neither contains nor is contained by the step (a sheet over the flow) before it blurs, per document for the one commit. (2)reconcileTopLayerreads that element (or the live active element inside a re-shown open sheet), makes it the dialog'sautofocusdelegate for exactly the re-show'sshowModal()— the earlier popover technique, now for the modal path — so the native focusing steps land on it rather than the panel, focuses it again if an engine left it elsewhere, and skipsfocus('open'). The top layer is append-only, so the re-show must close the dialog first and the field sees one blur and one focus; nothing shorter exists. A covered sheet's panel also no longer takes focus (focusSheetPanelreturns when its dialog isinert/aria-hidden), so the entering step beneath never pulls focus either.autofocusdelegate removed again, ≤1 blur and ≤1 focus, the panel not focused (fails on the previous head: focus ended on the step's panel, then on C's); new story "Typing in a sheet over a flow that advances from code"; Chromium: 25 characters typed at 120 ms intervals across the flow's 2 s code-driven step change all land in the field, focus stays on it, the flow is on step 2 beneath (inert) with the Note sheet on top, and the field saw ≤1 blur/≤1 focuscomponent:BottomSheetFR13). The flow's outlet is re-supplied from its new step dialog on a handoff (hostKey), and a supply went to the top of the outlet order, above the sheet still on top of the flow — the stack'sisLatestfor that sheet had not changed, so nothing raised it back.Layer/modalOutlet.ts: a re-supply made while the caller is not its group's latest goes directly beneath its group's current top instead of to the end, so the sheet on top stays the latest outlet. A first supply still goes on top (the stack's answer arrives a render later and the existing raise settles it), and a flip ofisLatestalone never re-supplies (isLatestis read through a ref at supply time). (2) Found by the browser check: the stack's re-show of the top sheet (close +showModal(), to keep it above the flow's new dialog) carries the toast viewport's popover — hosted inside that sheet — back beneath the sheet in the top layer while it still reads:popover-open, so a toast landed in the right dialog but painted under its panel.reconcileTopLayernow announces a top-layer entry when it re-shows a dialog that was already shown (the samenotifyTopLayerEntrya non-modal sheet's entry uses), and the viewport re-enters in place as it already did for that case. A first show announces nothing: its outlet moves the hosts.architecture:layer-runtimeINV14 (onmain) gives top-layer mounting and the backdrop tint to the shared runtime; this PR's three added passages had to read consistently with it.show()/popover mechanics and says how the surface is put in the top layer is the component's own today, inside the INV14 gap. Honest note: the code does not use a shared runtime mounting —bottomSheetStack.tscallsshowModal()/showPopover()/close()itself (the re-show that keeps open order, the step present, the non-modal host), which is exactly the BottomSheet entrymain's Current gaps already lists; the Current gaps paragraph now names the stack's mounting as part of that entry rather than papering over it. Frontmatter trigger list and the INV14/INV15 sentence aremain's.check:knowledgemain's #8229 (Popover presented as a BottomSheet on a phone), which touched the same files: it removed the standalone sheet's positioner wrapper (INV7 — the dialog is now the panel's positioner, a grid), moved the handle pill to::before, renders the edge tint only while presented, addedBottomSheetEscapeContext(Popover can turn Escape off for its sheet) and gated the gesture listeners onisOpen.stackPositionerStyles) now applies to the standalone dialog itself, the panel's positioner — panel and grabber shrink and lift as one, and::backdropis not a descendant so the one visible scrim is unaffected; switcher steps keep their own positioner as before.BottomSheetEscapeContextand this PR'sBottomSheetHostContextnest around the content in both the standalone sheet and a switcher step. The gesture hook'sisOpengate and this PR'sisOpenRequestedcompose (one gates listeners, the other settles a release's dismissal). A Popover-presented sheet is a normal standalone modal sheet, so it registers in the stack like any other. Records:main's element-jobs text kept, with one clause that a covered sheet's recede is the dialog's transform; the layer-runtime Current gaps keepsmain's Popover presentation-hold sentence beside this PR's.covered/depth 1 and left to the browser's modal blocking since it contains the Popover's dialog; Filters on top, modal; Escape closes only Filters; focus back on the Filter control); #8229's own 952-linePopoverPresentationsuite passes unchanged; Chromium: the Popover sheet story opens one modal sheet with no stack mark, its panel a direct child of the dialog flush with the bottom edge, the dialog untransformed, Escape closes it; the stacking story's covered dialog carriesscale(0.96)+ lift with its panel at 96% of the viewport width while the top dialog is untransformed, and the covered sheet keeps the one full-opacity backdrop (the top one clear)Symbol.for('astryx.bottomSheetStack.v<N>')); a copy on another protocol finds no state under its own key and keeps a stack of its own, ordering only the sheets it renders — two independent stacks, neither reading the other's state. The shape check under the key remains as a guard against a key collision. PR1 incomponent:BottomSheetnow scopes the promise to copies that share the stack protocol and states the fallback.architecture:react-component-runtimeINV5): the capture-phase press listeners and the deferralcloselistener were installed with a document's first sheet and never removed, and the recorded press target was held strongly despite the comment.closelistener is installed with a document's first deferral and removed with the last promotion, alongside the mutation observer. The press target is aWeakRef, dereferenced at read time.WeakRefwhosederef()is the pressed control)focusToKeepand the deferred-non-modal table were module-level.StackState(focusToKeep,deferredTopLayerwith its watchers), the protocol version bumped (shape change), and the header states the scope: everything the stack reads and writes across commits is in the shared state for copies on the same protocol version; the one module-level value is the engine probe's answer, a browser fact every copy measures alike. The test reset releases the test document's deferral watchers before replacing the table.shownOrder,listenedDocuments,lastPress,focusToKeep,deferredTopLayer), and a non-modal sheet this copy deferred beneath a Dialog is promoted by a fresh same-protocol copy's promotion pass reading the shared deferralsmain's #8009 (modal-awareuseAnnounce,ModalAnnounceProvider,hooks/modalAnnounce.ts), which wraps every standalone sheet's and switcher step's content inModalAnnounceProvider.main'sModalAnnounceProvider(isOpen && hasScrim) and each switcher step its own (isPresented && hasScrim), outsideLayerDepthProvider, with #8229'sBottomSheetEscapeContextand this PR'sBottomSheetHostContextnested inside, unchanged in meaning. Routing under the stack needed no fix:main's route reads exposure offopen/:modal/inert/aria-hidden, which are exactly the marks the stack writes — a covered sheet is inert and aria-hidden (not exposed, so its words are delivered beyond it to the exposed top sheet), a same-update replace leaves the leaving sheet open-but-unexposed in the same update it writes to it, so its provider hands the words to the entering sheet, and a covered sheet that contains the top sheet (exposed, not on top) has its words cleared, as FR14 rules for a covered modal.BottomSheetStacking.announce.test.tsx(5): top sheet's words land in the top sheet only; a covered sheet's words reach the exposed top sheet, never the covered sheet or the page; the covered sheet hears its own again once exposed; a same-update replace hands words announced from the leaving sheet to the entering one; a containing covered sheet's words are cleared while the top sheet hears its own.main'suseAnnounce.modal.test.tsxpasses unchanged. Chromium (Orders drill-in story, with "Announce from Orders"/"Announce from order" controls): words from the top sheet are written in its own region only; words from the covered (inert) sheet are delivered beyond it to the top sheet's region, not the covered sheet's, not the page'sTests
BottomSheet,Dialog,AlertDialog,Selector,DropdownMenu,ContextMenu,DateInput,DateTimeInput,TimeInput,Tokenizer,Layer,Layout,CommandPalette,MoreMenu,Popover,Lightbox,MobileNav,Toastand LabMobileTokenizerpasses: on this base, the BottomSheet, Layer, Toast, Dialog, Lab and CLI template folders pass (121 files / 1,750 tests; BottomSheet alone 7 files / 342 tests incl.main's 29 per-step switcher tests unchanged); the two new suites are 70 stacking + 47 replace = 117 tests, and 8 tests were added to existing suites (BottomSheet.test's non-modal focus tests, the Layer invariants popover stub, the Toast viewport re-entry tests); every existing test passes. Three existing tests changed with the non-modal hosting the owner asked for:BottomSheet.test"opens non-modally" now expects the manual popover (and a new test covers theshow()fallback), R11 countsshowPopoverinstead ofshow, and the Layer invariants suite stubsshowPopoverthe way the Drawer suite does. EveryBottomSheetSwitchertest passes unedited. R1 and R12 tests assert a lone sheet carries no stack attribute, no transform, scrim 1, oneshowModal, focus on its panel; everyBottomSheetSwitchertest passes unedited.BottomSheetStacking.test.tsx, 70 tests grouped R1–R14 (R14, new: a switcher flow stacked over a standalone sheet and under one — one scrim, recede, blocking, Escape to the top level, focus return into the lower level, and a step switch while covered keeping the covering sheet on top): siblings, separate components, nested in content, inside a switcher step, switcher as a level, out-of-tree-order opens, same-commit deep link underflushSyncbefore any microtask (states, depth, transforms, scrim, top-layer order, focus), recede matrix to depth 3, Dialog over a sheet, Escape/scrim/swipe/canceleach popping one withpurposehonoured and reaching the top sheet past a covered nested one, one scrim through push/pop and the handoff (pin recorded at write time), closing a covered sheet (nested children leave with it and come back with it, their state never written; siblings stay; switcher step left; focus stays on top), focus (into, back to opener, panel fallback, deep link, last sheet), top-layer order with a Dialog between sheets, StrictMode, a never-committing transition, Suspense hide/reveal, Activity, SSR+hydration, non-modal, one registry per page (two copies share; other shape keeps its own; two copies across an iframe and the main document keep both sheets shown) and per document.BottomSheetReplace.test.tsx, 47 tests: what is and is not a replace (push, pop, different updates, covered close, nested, switcher flow, a lone sheet), the handoff (retained in place, inert, hidden; fade after entering; alignment both completion orders; shorter retained; scrim kept then handed over with the0spin; one level up a stack), focus and dismissal (into the new sheet and kept when the replaced dialog closes; back to the flow's opener; Escape to the replacer during the handoff and to the returning sheet during a reversal; dismissal and a third step mid-handoff drop the retained sheet;purposeon the replacer), unrelated owners (the verification's case; the same swap from inside; the press read over focus both ways; steps wrapped in components; a code-driven step change; a stale press not vetoing a later replace), reversals (Back during Continue, Continue during Back, a double reversal — each with the steps written in flow order and in reverse order), non-modal (manual popover, replace with no stacking, reversal re-show, no pairing with a modal sheet,show()fallback still replacing, the flow's last step returning focus to its opener).Real browser
Chromium (Playwright 1.61, Chrome Headless Shell 149) at 390×844 with touch against the static Storybooks of
main(BottomSheetSwitcherstories) and this branch (BottomSheetReplacestories, which mirror them one to one: same content, labels, heights and steps). Per switch, every animation frame: each panel's computedtransformandopacity, every dialog's::backdropopacity (combined into the page dim),elementFromPointon each panel, inert /aria-hidden,document.activeElement, the nativeshowModal/show/closelog, mid-handoff and settled screenshots with a pixel diff. Timings are relative to the click; the two builds can sit one frame apart purely from where the click fell in the frame, so deltas are read with a ±1 frame (≈17 ms) tolerance. "Back-during-handoff" is Back pressed 150 ms into 1→2.Chromium, default motion
Chromium,
prefers-reduced-motion: reduce(every motion completes in one frame on both builds; the dim must still hold at 0.5)WebKit (Playwright's WebKit 26.5 in its Linux container, same harness — WebKit, not Safari on iOS hardware;
spec:AST-013FR11 does not count it as an iOS Safari claim). Headless WebKit's first frames after a click arrive 50–150 ms apart and vary between runs, so per-frame motion cannot be compared there (the earlier main-vs-main noise run shows deltas of the same size as main-vs-branch). What WebKit does establish for the top-layer changes (spec:AST-027): the non-modal sheet is hosted as a manual popover (:popover-open,:modalfalse) and replaces with the same handoff (settled screenshots 0 px on every no-scrim switch); the page dim constant at 0.5 on every modal switch, settled screenshots identical (0 px) on every pair; the toast viewport re-enters above a non-modal sheet (toast raised before the sheet opens and from the sheet while open both hit-test to the toast; tooltip and popover opened from the sheet hit themselves); focus return on the lone no-scrim sheet's Escape → the opener on both builds (WebKit'sclose()aftershow()did restore focus onmain, unlike Chromium), and the no-scrim flow's final close → the flow's opener on both; the eight stacking stories identical to the stacking-only head except the three stories whose description text changed.Safari (real Safari 27.0.1 on macOS 27.0.1 through
safaridriver's W3C WebDriver endpoint, run 2 on the revision of this PR with the non-modal focus fix, run 3 on the revision that added the "Close all" return (the last code change before this description; the harness lives in its own pull request,chore/bottom-sheet-safari-harness, so the runs can be repeated — it ships nothing and was split out of this PR) — 11 of 11 drivable checks pass, 1 not driven; macOS Safari, not iOS hardware, so it informs but does not satisfy an iOS Safari claim underspec:AST-013FR11; viewport 390×792 in a 390×844 window). The script (bottom-sheet-safari-check.mjsin the harness PR, no dependencies, run by a person on a Mac — Linux CI cannot) drives the same stories at 390×844, samples the page dim and open dialogs per animation frame inside oneexecuteAsynccall, and hit-tests toasts and the top sheet; checks safaridriver cannot drive reliably (hover for tooltips/popovers) are marked not-driven, never faked. Three runs: run 1 on an earlier revision of this PR found the gap (focus onbodyafter a no-scrim switch, so the flow's final close did not return to the opener); run 2 confirmed the fix — 10 of 10 drivable checks; run 3 adds "Close all" — 11 of 11 drivable checks pass, 1 not driven. Themaincolumn is run 1's reference (that deployment only has the switcher and lone NoScrim stories; the rest are recorded as skipped)::popover-open, not:modalshow()host (open, not:modal); focus inside:popover-open, not:modal; focus insidebodybodyafter the switchbody, the gap)body)inert+aria-hidden; top sheet hit-testedinert+aria-hidden="true"; Order 1042 hit-tests itselfDismissal on the active step (step 2), after reopening with focus on the opener (Chromium; reduced motion and WebKit agree):
The stacking stories, no regression. The eight
Core/BottomSheetStackingstories driven identically on an earlier stacking-only revision of this PR and on this branch, and again on this branch with and without the replace (open, drill in up to three levels, Escape pop with the dim sampled per frame, "Close all" where offered, and the owner-closes-under-others scrim handoff on the out-of-tree-order story): every state summary, screenshot (0 px), call log and dim range identical, except the handoff the fix below changed: the owner-closes-under-others page dim was 0.5 → 0.736 → 0.5 on that earlier revision (the new owner's backdrop appeared over the old one's 410 ms fade) and is 0.5 constant now, with and without reduced motion. Re-run on the final head against the stacking-only head: every story identical (0 px, same dims, same calls) except the two stories rewritten from a switcher to standalone steps, whose only pixel difference is their new description paragraph above the sheets (y 80–191); their sheets, pops, dims and focus are identical. Also measured on the stacking stories: recede matrices (matrix(0.96,0,0,0.96,0,-8)/matrix(0.92,0,0,0.92,0,-16)),::backdropopacity1,0,0at depth 3, top-only hit testing, Escape / scrim tap / CDP touch swipe each popping exactly one, nativeshowModalorder for the deep link, out-of-tree-order with the later-opened sheet on top, focus return into the revealed sheet, Dialog interop both ways, no overflow, no console errors.Accessibility classification (
spec:AST-009FR8)What changes for assistive technology, and the evidence layer that proves each claim:
inert+aria-hidden="true"; a covered sheet that contains the top sheet is left alone (the top sheet'sshowModal()already makes everything outside it inert); a replaced sheet isinert+aria-hiddenfrom its first retained frameinertis nativeariaSnapshoton the stacking, replace mid-handoff and settled, and lone non-modal states: only the top/entering dialog is in the tree); real browser (hit-testing and focusability of covered sheets, Chromium and WebKit)finalFocusRefunconditionalprefers-reduced-motionruns)popover="manual"<dialog>with the samearia-label, noaria-modal, same role; only the native host changesdialog "Nearby places"node)role="region"and its live announcements are unchanged (a re-entry toggles the popover's display, it does not re-render toasts)useAnnouncepath and toast DOM are untouched, and the existing Dialog move already re-shows the viewport the same way)BottomSheetSwitcherinert/aria-hiddenand focus behaviour is the stacking row aboveinerton a covered<dialog>, manual-popover<dialog>hosting, or sheet focus returnNo real-AT receipt is required under FR1–FR6, so no stable-release block is recorded (FR17); the change uses proportionate evidence (FR7). What this does not prove, per the evidence table: what a screen reader announces when focus moves into the entering sheet or back to the opener, and virtual-cursor entry; neither is claimed by this PR. If the owning records later make one of those a claim (e.g. "the new step's label is announced on a replace"), that becomes an FR2/FR3 trigger with a VoiceOver-on-iOS row, and the stacking and replace stories are the fixtures for it.
Remaining deltas, with root causes
bodyon every step and on close (in Chromium the exiting shell goesinert, which blurs the panel beforedialog.close(), so the non-modalclose()finds nothing to restore —mainnever returned focus for a non-modal sheet, lone or in a flow). The standalone sheet lands focus on the new step's panel (tabindex="-1", no ring) and, when the flow closes while the person is still in it, returns it to the flow's opener (Escape, Cancel, swipe and the last step's Done all measured:main→body, branch → the opener). Settled screenshots are identical (0 px) on every no-scrim switch; the focus columns are the only difference, and they are the switcher's gaps.close()+showModal(), two forced layouts) and runs one extra synchronous render in the same task. Not the drop-order bug the review found (that one cost ~400 ms and is fixed; the capped flow, which has no alignment travel, now measures 0.0 px); the residual is the re-show itself.Reconciliation with the per-step switcher dialogs on
mainmainnow presents eachBottomSheetSwitcherstep in its own native dialog (component:BottomSheetSwitcherDEC-1): the switcher coordinates which step dialog is presented, retained or closed, hands its one scrim between them (takeOverScrim,presentWithClearScrim,presentStepModally), and resolves its ref to the host dialog. The stack's "a whole switcher is one level" therefore became "the active step's dialog is the flow's level":getElementis the host dialog (hostDialogRef), so a handoff re-points the level without re-registering and the flow keeps its rank. AgetTreeAnchoron the registration names the flow wrapper — the one element that contains every step dialog — so a same-commit tie against another sheet is broken the same way whichever step is active (the step dialogs are siblings in the DOM; the wrapper precedes and contains them all).main. When the host dialog changes, the switcher callsnotifyBottomSheetHostChanged(key): the registry records the new dialog as the most recently shown and, when a sheet belongs above the flow, hides and re-presents it in the same commit, before paint — so switching the step of a covered flow (only code can) keeps the covering sheet on top. The registry's ownshowfor the level presents the host dialog only to restore order; it uses the switcher's blur-first present and the step's scrim rule, and bumps the switcher's presentation stamp soisCoveredInTopLayerstays right.flowOwnsStackScrim = !isStacked || ownsScrim. A flow stacked above another sheet keeps every step backdrop clear (through its own handoffs too); a flow beneath owns the stack's scrim and hands it between its steps as onmain. A change of the stack's owner lands on the flow's owner dialog with the two-frame0spin (writeScrimOpacity); the switcher's intra-flowtakeOverScrimis unchanged. Gesture thinning from a step is ignored while a lower sheet owns the scrim.inert,aria-hidden,data-sheet-stack/-depth, and the recede transform, in addition to the switcher's own retained-step inertness.isPresent: falsewhile covered. When a flow closes over a lower sheet, focus returns into that sheet (to the opener when it is there, else the panel); at depth > 0 the flow's close moves nothing; in a "Close all" the flow reports its close to the registry and the registry returns focus once. The step dialogs blur before presenting (as onmain), so their native close moves nothing.replaceableon its registration); a step is never paired with a standalone sheet.BottomSheetHostContext, now set inside the step'sLayerDepthProvider.layer-runtime.mdis edited by this PR: the shipped passages (see Current specification). Sheets keep the shareduseScrollLock, asDialogdoes.Writing rubric results
Three current records are edited; what changes, exactly:
component:BottomSheet— deleted: every present-tense "Implementation gap" note the record pull request placed on the rows this PR implements (FR5–FR15, AR3–AR5 and their verification rows). Changed: PR1's wording (the one-stack promise is scoped to package copies that share the stack protocol, with the fallback stated: a copy on another protocol version keeps its own stack); DEC-2's one sentence that cites PR1, aligned to that scope; one clause inmain's element-jobs passage — "a covered sheet's recede (FR6) is this element's transform" — after feat(Popover): presentation — a BottomSheet on a phone when asked, as iOS presents a popover #8229 made the dialog the panel's positioner. Added: this PR's suites toverified_by.component:BottomSheetSwitcher— deleted: the "Implementation gap" notes on FR10, the lifecycle statement, DEP-0015'sstaterow and the FR10 / DEP-0015 verification rows, and the@deprecated/consumer-docs parts of the Intent gap note; theMobilePowerSearchsentence stays (its own change, feat(lab): MobilePowerSearch's steps are standalone sheets that replace one another #8476, migrates it). Added: suites toverified_by. No requirement text changes.architecture:layer-runtime— added: the shipped passages (covered and replaced sheets standing down from dismissal while the shared stack stays the one dismissal registry; toast outlets perspec:AST-003FR18 following the sheet stack's order, the switcher flow's active step, the step-change case and the later-Dialog rule; the non-modal top-layer entry and the Drawer exclusion), each worded so that the registry orders and mounts nothing (INV14), and one sentence in Current gaps naming the sheet stack's mounting as part of BottomSheet's existing entry. Deleted: the scrim switcher from the list of modals toasts stay behind.main's INV14/INV15 sentences, trigger list and Popover presentation-hold sentence are kept;approved_by/approved_atkeepmain's values until anR1 narration: the added
layer-runtimesentences are present-tense statements about the shipped runtime; the deleted notes describedmain, and nothing replaces them.R2 research: none in the records; measurements stay in this description.
R3 evidence naming a change: none;
verified_bynames test files.R4 rejected alternatives: none added.
R5 change references: none added (searched
#+ digits,/pull/,/issues/in the three records).R6 open questions: none added or ruled.
Scope
Why one pull request
The six Changesets name six release notes, not six separable changes; the line count is mostly tests. Of the 12,455 changed lines against
main(+11,790 / −665), 5,971 (48%) are test files —BottomSheetStacking.test.tsx3,184,BottomSheetReplace.test.tsx1,526,ToastViewport.modalHost.test.tsx502,BottomSheet.test.tsx473,BottomSheetStacking.announce.test.tsx246 — and a further 2,064 are Storybook stories that the browser evidence drives; the records, docs and Changesets are 441. What remains is the stack and the parts that only exist because of it:bottomSheetStack.tssealBatch/batchPair) and performed by its top-layer reconcile (the leaving sheet stays presented, inert, through its fade while the entering one is shown). Without the registry there is no batch to read and no reconcile to hand off in;bottom-sheet-replace-handoffcannot ship beforebottom-sheet-open-order-stacking.show()dialog in page stacking order and the toast outlet was whatever dialog registered last. Once sheets stack in one top layer, a non-modal sheet has to be hosted there too (thepopover="manual"host and its deferred entry beneath an open Dialog) or it would be painted under the stack, and the toast outlet has to follow the stack's order (Layer/modalOutlet.ts: outlets grouped and raised with the sheet stack, a switcher step's re-supply placed beneath the top sheet, the viewport's re-entry after a re-show) or toasts raised from the top sheet land in a covered, inert dialog — the FR13 bug this review found and this PR fixes.bottom-sheet-switcher-toast-outletis that ordering applied to a switcher flow as one level; it has no meaning without the levels.design:reduced-motionDR1–DR3 are applied inBottomSheetPanelto the entrance and exit, which exist onmain, and to the covered sheet's recede and the replace cross-fade, which do not;bottom-sheet-reduced-motion-cross-fadeshipping alone would describe motions a release without the stack never performs, and the stack shipping alone would introduce two motions that ignore the rule.bottom-sheet-switcher-deprecatedandcli-bottom-sheet-templates-standalone-stepstell callers to write each step as its ownBottomSheetdriven by one state value, "closing the top sheet and opening the next in the same update" — that sentence is the replace handoff. Deprecating the switcher in a release where that pattern opens two unrelated dialogs with two scrims would be a deprecation whose named replacement does not work —spec:AST-017FR7 makes a replacement-first deprecation a[feat]that names the replacement and the migration — andcomponent:BottomSheetSwitcherDEP-0015 readsdeprecatedwith this change, so the JSDoc@deprecated, the doc.mjs guidance and the templates must land with the behaviour they point at.The owner's waiver, verbatim as recorded under Scope: "ok do we need the C -> C+ -> deprecate stack? it feels not the best way to stack the change. i don't mind large PR. i can bypass [the review size] limit". The Safari harness, the one piece with no shared dependency, was split out into
chore/bottom-sheet-safari-harnessbefore that waiver.chore/bottom-sheet-safari-harness; for the rest the owner waived the split, verbatim: "ok do we need the C -> C+ -> deprecate stack? it feels not the best way to stack the change. i don't mind large PR. i can bypass [the review size] limit". The parts share one registry, one set of records and one migration story; the four Core Changesets and the CLI Changeset stay separate so the release notes do.Testing
Real-browser checks on this head (Chromium, Playwright 1.61, Chrome Headless Shell 149, 390×844 touch, static Storybook, per-frame
::backdropsampling composited into the page dim): 110 of 110 pass (rerun on this head). Drag dismissals under reduced motion on a fine pointer (desktop profile,pointer: coarsefalse): a finger swipe driven through CDP touch events paints 10 intermediate opacity frames, holds its transform after the release frame and closes 169 ms after the fade starts; a mouse drag on the same handle closes with no intermediate opacity frame. Reduced motion (reducedMotion: 'reduce', iPhone profile forpointer: coarseand a desktop profile for a fine pointer): see thedesign:reduced-motionrow above. Interstitial (new story): Submit by press makes Form → Processing a replace; the code-advanced Processing → Done 300 ms later is a replace too; closing Done returns focus to the control that opened the flow. Reduced motion (reducedMotion: 'reduce'emulated): the covered sheet is in its receded end state within one frame of the push and the dim is constant; on a replace the entering step is at its resting transform from its first painted frame, the retained step paints no intermediate opacity or transform values (alignment and fade each land in their end state the frame they apply), the retained dialog is closed at ~116 ms and only the new step remains, dim constant. Per-engine lone-open focus (engines-focus.txt): Chromium 149 — panel for modal, non-modal and leading-button cases; Firefox 151 — thedata-autofocusfield (modal), the first controlSearch(non-modal), the first buttonView(leading button); bare-dialog probe (engines-native-focusing-steps.txt): Chromiumshow()→container,showPopover()→first control,showModal()→container; Firefox → first control on all three. WebKit could not be launched on the node. Accessibility tree (AR3; AST-020, AST-009 FR3): from the browser's exposed tree (Playwright aria snapshot), a lone sheet exposes exactly onedialogwith its name; with two and with three sheets open only the top sheet is exposed, the covered sheets are hidden; during a replace handoff the replaced sheet is hidden and the entering step is the one exposeddialog, with its name, and after the handoff likewise. A no-scrim sheet opened from a modal sheet and declared outside it is shown byshow()(not a popover) while the modal is open and a press at its button does not reach it; once the modal has gone it is a popover in the top layer, pressable above the page's fixedz-index: 10tab bar that it overlaps; after a Swap (modal closes, hint opens in one update) the hint is already in the top layer while the modal sheet is still:modaland inert mid-exit, and ends pressable above the tab bar. A step entering by a replace with an autofocused TextInput ends with that field focused, modal and non-modal alike; the same step opened alone has the panel focused. A toast raised from a deprecated switcher's step renders in the active step and is pressable — in a lone flow, in a flow stacked over a standalone sheet, and after Continue in the entering step rather than the retained one. Sheet → Dialog → sheet → close the inner sheet: a toast raised from the Dialog renders inside the Dialog, not inert, is what the pointer hits, and dismissing it leaves the Dialog open. A modal sheet with adata-autofocusfield opens with the panel focused, on this head and on a static build ofmainalike. After Back pressed during a Continue handoff, and after a sheet reopened during its exit, a toast raised from the reopened sheet renders inside it, not inert, and is what the pointer hits. Two closes and one open from inside the top sheet (written confirmation-first) are a pop, a pop and a push: neither closing sheet is marked replaced, the new sheet is a fresh top, the dim stays at 0.5, and closing it does not return to the first sheet's opener. Lone no-scrim open from the keyboard: focus lands on the panel, on this head and on a static build ofmainalike; with adata-autofocusfield in the sheet, the panel is still what is focused and the field is not, on both builds (the releasedshow()behaviour, kept). A switcher flow stacked over a standalone sheet leaves when that sheet is closed from code (no dialog left open) and returns on its step when the sheet reopens. Orders drill-in to depth 3: one visible backdrop, covered sheetsinert+aria-hiddenwithmatrix(0.96…−8)/matrix(0.92…−16), dim 0.5 constant over 107 frames of pushes, Escape pops one (dim 0.5 constant), focus returns to the control inside the revealed sheet, Close all from depth 3 → focus on "Open orders". Deep link depth three: innermost on top in the first settled frame and hit-testable. Replace (hug, capped): retained stepinert+aria-hiddenthrough the handoff, dim 0.5 constant through Continue and Back, focus in the entering panel, Escape closes the flow and returns focus to the trigger. No-scrim:popover="manual",z-index: auto, dim 0, replace keeps one dialog with focus in the entering step, Escape returns focus to the trigger. Non-modal over a live page: the toast overlaps the sheet and is what the pointer hits (paints above it); focus stays in the sheet. Deprecated switcher stacked over a standalone sheet (new story): Settingscovered, receded, owns the one scrim (backdrop 1), blocked by the step's modality (its "Set up notifications" control is not hit-testable; it is not markedinert, because it contains the flow's dialog — AR3); both step backdrops clear through the flow's own Continue handoff; dim 0.5 constant over 92 frames; Escape closes the flow only and focus returns to the opener inside Settings; dim 0.5 constant through the close. Deprecated switcher stacked under a standalone sheet (new story): the stepcovered,inert, receded, owns the scrim; Help on top with a clear backdrop; dim 0.5 constant through the push and the pop; Escape closes Help only and focus returns to the Help button in the step. Deprecated switcher alone: no stack state, one backdrop, dim constant through its own handoff, Escape returns focus to the trigger. Harness and results:chromium-cplus/check.mjs,results.json, screenshots.Earlier revisions' evidence (Chromium parity against the switcher's handoff with and without reduced motion, Playwright WebKit, three real-Safari runs through the harness now in
chore/bottom-sheet-safari-harness) is below and stands for the standalone paths, which this revision does not change; the switcher-level paths above are the ones the per-step reconciliation touched.Prior art
Modal.Stack: https://mantine.dev/core/modal/#modalstack