Repository navigation
feat(core): BottomSheetSwitcher presents each sheet in its own native dialog - #7720
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
AI review status for this pull request.
|
1a42428 to
a84ca11
Compare
3729c1b to
7e6b697
Compare
There was a problem hiding this comment.
This change is on hold for a maintainer decision: one native dialog per step implements component:BottomSheetSwitcher DEC-1 and DEC-2 from draft spec PR #7744, which are not approved, while the record on main still describes one shared dialog (FR2, PR1). Approval needs a maintainer to approve or reject those decisions, including whether the moving ref/id and the switch from a script focus trap to native containment are a nonbreaking patch under spec:AST-017. Whatever that decision, these need fixing before approval:
- A flow that mounts closed leaves the switcher
refnullin consumer mount effects and rendersid/data-testidon no element in server markup or the first commit, contrary to the documented "never null while a sheet is mounted". restingHostSheetreadslastHostSheetRefduring render, whicharchitecture:react-component-runtimeINV4 forbids; keep the last host in state.- The PR has 2,010 authored changed lines, over the 2,000-line scope threshold; split it, or justify the exception against an approved spec row once one exists.
[Automated review]
| * Ref resolved to the native dialog hosting the flow: the active sheet's | ||
| * dialog, which changes on a handoff; the exiting sheet's through its exit; | ||
| * and, while the flow is closed, the dialog that hosted it last (or the | ||
| * first sheet's), so it is never null while a sheet is mounted. |
There was a problem hiding this comment.
This promise does not hold for a flow that mounts closed. The host is picked from sheetOrder, which the sheet dialogs' ref callbacks fill only after the first commit, so a caller's mount useLayoutEffect/useEffect reads ref.current === null, and renderToString output carries neither id nor data-testid (both are present on the base). Either attach the ref and the identifying attributes to an element rendered in the first commit, or narrow this doc, the .doc.mjs and the Changeset; a mount-effect test and a renderToString test would pin whichever you choose.
| // flow is closed the dialog that hosted it last (or the first mounted | ||
| // sheet's before any open), so the ref resolves to a closed dialog element | ||
| // rather than to null, as the shared dialog did. | ||
| const restingHostSheet = |
There was a problem hiding this comment.
lastHostSheetRef is written in the host layout effect and read here during render to decide which dialog carries the caller's id/data-testid and the ref. architecture:react-component-runtime INV4 says a ref mutation must not be the sole source for a value later read to produce rendered output; keeping the last host in state, updated during render when the presented host changes, would satisfy it.
7e6b697 to
7e0b6e9
Compare
a84ca11 to
5c35da2
Compare
There was a problem hiding this comment.
This change is on hold for a maintainer decision: one native dialog per step implements component:BottomSheetSwitcher DEC-1 and DEC-2 from draft spec PR #7744, which are not approved, while the record on main still describes one shared dialog (FR2, PR1). Approval needs a maintainer to approve or reject those decisions, including whether the moving ref and id, the per-dialog props and the switch from a script focus trap to native containment are a nonbreaking patch under spec:AST-017, given that v0.7.1 documents the ref as the one shared dialog. Whatever that decision, one fix is needed before approval:
- Under
prefers-reduced-motion, the backdrop's literal0.01stransition (BottomSheet.tsx:123-125) overrides the0sscrim-takeover pin, so every handoff paints one or two frames with no scrim; readingvar(--_sheet-scrim-duration, 0.01s)in that branch fixes it.
[Automated review]
| transitionDuration: durationVars['--duration-medium'], | ||
| // A switcher hands its one scrim from a fading dialog to the next by | ||
| // pinning this duration for the swap; a standalone sheet never sets it. | ||
| transitionDuration: `var(--_sheet-scrim-duration, ${durationVars['--duration-medium']})`, |
There was a problem hiding this comment.
Under prefers-reduced-motion: reduce the branch a few lines below sets a literal transitionDuration: '0.01s', which wins over this variable, so takeOverScrim's --_sheet-scrim-duration: 0s pin has no effect there and the entering backdrop eases in from 0 after the retained dialog has closed. Sampling every frame in Chromium, Continue paints 2 frames and Back 1 frame with no backdrop at all (the base paints none). Reading the pin in that branch too fixes it:
'@media (prefers-reduced-motion: reduce)': {
transitionDuration: 'var(--_sheet-scrim-duration, 0.01s)',
},5c35da2 to
6657ea4
Compare
7e0b6e9 to
a60dd43
Compare
There was a problem hiding this comment.
This change is on hold for a maintainer decision: one native dialog per step implements component:BottomSheetSwitcher DEC-1 and DEC-2 from draft spec PR #7744, which are not approved, while the record on main still describes one shared dialog (FR2, PR1). Approval needs a maintainer to approve or reject those decisions, including whether the moving ref and id, the per-dialog props and the switch from a script focus trap to native containment are a nonbreaking patch under spec:AST-017, given that v0.7.1 documents the ref as the one shared dialog. Whatever that decision, one fix is needed before approval:
- A
rolepassed toBottomSheetSwitcherno longer reaches any dialog, becauserole={purpose === 'required' ? 'alertdialog' : undefined}(BottomSheet.tsx:771) overrides it; forwarding the caller's value when the step is notrequiredrestores the base behavior.
[Automated review]
| </BottomSheetPanel> | ||
| </div> | ||
| inert={isInactive ? true : undefined} | ||
| role={purpose === 'required' ? 'alertdialog' : undefined} |
There was a problem hiding this comment.
This explicit undefined replaces a role the caller passed to BottomSheetSwitcher, which still sits in switcherDialogProps spread above, so no sheet dialog gets it: with role="alertdialog" on the switcher the base rendered it on the shared dialog, while here neither sheet dialog has a role and aria-describedby or the class still reach both. The docs in this PR say other dialog props apply to every sheet dialog. Keeping the caller's value when the step is not required fixes it:
role={purpose === 'required' ? 'alertdialog' : switcherDialogProps.role}a60dd43 to
adb3070
Compare
6657ea4 to
5d75a18
Compare
There was a problem hiding this comment.
This change is on hold for a maintainer decision: giving each BottomSheetSwitcher step its own native dialog implements component:BottomSheetSwitcher DEC-1 and DEC-2 from draft spec PR #7744, which are not approved, while the record on main still describes one shared dialog (FR2, PR1). Approval needs a maintainer to approve those decisions as the governing spec, or reject them, and to settle whether the moving ref and id, the props repeated on every step's dialog and the switch from a script focus trap to native containment are a nonbreaking patch under spec:AST-017, given that v0.7.1 documents the ref as the one shared dialog. The fixes from earlier rounds are in, and nothing else needs to change before that decision.
[Automated review]
adb3070 to
1221b3b
Compare
There was a problem hiding this comment.
This change is on hold for a maintainer decision: giving each BottomSheetSwitcher step its own native dialog implements component:BottomSheetSwitcher DEC-1 and DEC-2 from draft spec PR #7744, which are not approved, while the record on main still describes one shared dialog (FR2, PR1). Approval needs a maintainer to approve those decisions as the governing spec, or reject them, and to settle whether the moving ref and id, the props repeated on every step's dialog and the switch from a script focus trap to native containment are a nonbreaking patch under spec:AST-017, given that v0.7.1 documents the ref as the one shared dialog. Whatever that decision, one fix is needed: the switcher's builder doc still says "one CSS rule locks page scroll" (BottomSheetSwitcher.doc.mjs:145), while this head locks scroll with the shared useScrollLock body pin, as the same file's hasScrim entry says.
[Automated review]
| }, | ||
| description: | ||
| 'Coordinates multiple BottomSheets as a mutually exclusive flow. One activeSheet ID selects the only interactive sheet; during a handoff, the new sheet enters above the inert previous sheet. If the new sheet is shorter, the previous sheet simultaneously moves down until their top edges align, then fades after both transforms complete. The switcher owns one shared native <dialog>: modal flows call showModal() once for one top-layer boundary and one ::backdrop across the whole flow, while no-scrim flows use a non-modal show() shell. Its ref and shared DOM props target that dialog.', | ||
| 'Coordinates multiple BottomSheets as a mutually exclusive flow. One activeSheet ID selects the only interactive sheet; during a handoff, the new sheet enters above the inert previous sheet. If the new sheet is shorter, the previous sheet simultaneously moves down until their top edges align, then fades after both transforms complete. Each child sheet renders its own native <dialog>, exactly like a standalone sheet: modal flows present each sheet with showModal(), no-scrim flows with show(). The browser supplies modality: the page is inert, focus stays in the top-layer dialog, and one CSS rule locks page scroll while any sheet dialog is modal, so both hold across a handoff. The switcher coordinates the rest: one scrim paints at a time, focus returns to the element that opened the flow, and its ref, id, and dialog props resolve to the dialog hosting the active sheet.', |
There was a problem hiding this comment.
This sentence still describes the CSS scroll lock that was dropped: "one CSS rule locks page scroll while any sheet dialog is modal". At this head the switcher holds the shared useScrollLock(isModal) (BottomSheetSwitcher.tsx:387), which pins <body> with position: fixed, so builders should not expect the page offset to stay readable while a flow is open; the hasScrim entry below already says "one body scroll lock held for the whole flow". Suggested wording: "…focus stays in the top-layer dialog, and the switcher holds one body scroll lock for the whole modal flow, so both hold across a handoff."
1221b3b to
6a02977
Compare
There was a problem hiding this comment.
This change is on hold for a maintainer decision, not for a code change. The per-step dialogs implement component:BottomSheetSwitcher DEC-1 and DEC-2, which merged in #7744 as draft records with no approver, so they do not yet govern review, and a maintainer needs to make them current. The same decision needs to settle the release tier: spec:AST-017 FR6 says an engineering ruling cannot make an incompatible released contract nonbreaking, and a v0.7.1 caller that follows the flow through onToggle, or through a listener on the ref it read at mount, now hears the first step's dialog close while the flow is still open, so the [feat] patch may need to become [breaking] with a migration note. Nothing else needs to change before that decision.
[Automated review]
2be2ba2 to
56c27f5
Compare
There was a problem hiding this comment.
This change is on hold for a maintainer decision, not for a code change. The per-step dialogs implement component:BottomSheetSwitcher DEC-1 and DEC-2, which merged in #7744 as draft records with no approver, so they do not yet govern review, and a maintainer needs to make them current. The same decision needs to settle the release tier: spec:AST-017 FR6 says an engineering ruling cannot make an incompatible released contract nonbreaking, and a v0.7.1 caller that follows the flow through onToggle, or through a close listener on the ref it read at mount, now hears the first step's dialog close while the flow is still open, so the [feat] patch may need to become [breaking] with a migration note. Nothing else needs to change before that decision.
[Automated review]
56c27f5 to
271bec8
Compare
There was a problem hiding this comment.
This change is on hold for a maintainer decision, not for a code change. The per-step dialogs implement component:BottomSheetSwitcher DEC-1 and DEC-2, which are still draft records with no approver on main; their promotion in #7822 has not merged, so a maintainer needs to make those records current before they can govern this pull request. The same decision needs to settle the release tier: spec:AST-017 FR6 says an engineering ruling cannot make an incompatible released contract nonbreaking, and a v0.7.1 caller that follows the flow through onToggle, or through a close listener on the ref it read at mount, now hears the first step's dialog close while the flow is still open, so the [feat] patch may need to become [breaking] with a migration note. Nothing else needs to change before that decision.
[Automated review]
271bec8 to
478616c
Compare
a6280af to
4152c07
Compare
There was a problem hiding this comment.
This change is on hold for a maintainer decision, not for a code change. The per-step dialogs implement component:BottomSheetSwitcher DEC-1 and DEC-2, which are still draft on main: their promotion in #7822 has passed spec-owner approval but has not merged, so those records cannot govern this pull request yet. A maintainer also needs to settle the release tier: spec:AST-017 FR6 says an engineering ruling cannot make an incompatible released contract nonbreaking, and a v0.7.2 caller that follows the flow through onToggle, or through a close listener on the ref it read at mount, now hears the first step's dialog close while the flow is still open, so the [feat] patch may need to become [breaking] in a declared minor with a migration note. Nothing else needs to change before that decision.
[Automated review]
… dialog Every child BottomSheet owns a native <dialog>, as a standalone sheet does; the switcher coordinates which is presented, retained, or closed, hands the one scrim between dialogs without a fade, keeps focus in the active sheet when a retained dialog closes, and returns focus to the flow's opener. Focus containment is the native modal dialog's; the page scroll lock is the shared hook Dialog uses, held once for the whole modal flow, never per step. The switcher's ref resolves to the host dialog from the first commit on and is never null while a sheet is mounted; a callback ref's cleanup runs on detach; the last host is state adjusted during render. id and data-testid render on the host dialog only. A non-modal flow orders its sheet dialogs inside a switcher-owned isolated wrapper instead of a new page-wide z-index value. The anatomy part is renamed from Shared dialog to Sheet dialogs in the builder doc and, as check:knowledge requires, in the component record's anatomy rows. The implementation gap sentences the per-step dialogs close are deleted from both records.
e43f5a8 to
991e1df
Compare
There was a problem hiding this comment.
This change is on hold for a maintainer decision on its release tier, not for a code change. With #7822 merged, the per-step dialogs match the now-current component:BottomSheetSwitcher DEC-1 and DEC-2, but spec:AST-017 FR1 and FR6 say an engineering ruling cannot make an incompatible released contract nonbreaking, and a v0.7.2 caller that follows the flow through onToggle, or through a close listener on the ref it read at mount, now hears the first step's dialog close while the flow is still open. A maintainer needs to settle whether this ships as the declared [feat] patch or as [breaking] in a declared minor with a migration note; nothing else needs to change before that decision.
[Automated review]
|
/approve-spec 991e1df |
There was a problem hiding this comment.
Recommended for approval: each BottomSheetSwitcher step now runs in its own native dialog exactly as the current component:BottomSheetSwitcher record describes (DEC-1, DEC-2), the record edits carry spec-owner approval for this revision, and the declared [feat] patch follows the record's nonbreaking classification. In Chromium the flow looks identical to before, keeps one scrim on every frame of each step change, and leaves keyboard containment to the native modal dialog. One optional follow-up for the record owner: no requirement yet says where focus goes on a non-modal handoff, which now moves into the entering step.
[Automated review]
Record edits in this PR, exactly: the anatomy rename
check:knowledgecouples to the builder doc (theSheet dialogsdesign-relationships row and the two theming-anatomy reasons), and the deletion of the implementation-gap sentences the promotion PR recorded for the shipped shared dialog and this PR closes. Deleted fromBottomSheetSwitcher.spec.md: the Owns-bullet gap ("the switcher renders one dialog of its own and hosts every child as a panel inside it; no child owns a dialog yet"); the Dialog-surface gap ("every dialog prop,ref,id, anddata-testidreaches the switcher's one shared dialog"); the FR2 gap ("the switcher presents one shared dialog once per flow…"); the FR5 gap ("a script focus trap runs on top of the modal dialog's native containment; a non-modal flow is one shared dialog carrying the page-level value itself, with no flow wrapper"); the FR8 gap ("the one backdrop is the shared dialog's, so no takeover between dialogs happens"); the FR9 gap ("every forwarded input,ref,id, anddata-testidreaches the one shared dialog, which never changes on a handoff"); the representative-states lead-in ("Where a state names a sheet's own dialog… FR2 and FR5 record that implementation gap"); the Modal-state gap ("a script focus trap runs on top of the native containment"); the PR1 gap ("one shared dialog is presented throughout, so a handoff has one dialog open, not two"); and the "Shared dialog" row's gap ("one such host serves the whole flow"), which the renamedSheet dialogsrow replaces. Deleted fromBottomSheet.spec.md: the Owns-bullet gap ("a switcher-managed sheet renders a panel inside the switcher's one shared dialog and owns no dialog of its own") and the state-row gap ("the panel renders inside the switcher's one shared dialog"). Nothing else in either record changes; the verification-map note that the engine spec is not yet in the tree stays until #7757 adds it, and thespec:AST-027non-modal gap stays.Primary intent
BottomSheetSwitcherpresents each step in its own native<dialog>, exactly as a standaloneBottomSheetdoes, instead of hosting every step as a panel inside one switcher-owned dialog. The browser then supplies modality per step (inert page, native focus containment,cancel,::backdrop), and the switcher keeps only what no single dialog can decide: which dialog is presented or retained, one scrim handed between two backdrops without a fade, focus kept in the active sheet when a retained dialog closes, and focus returned to the flow's opener.The other templates do not fit: nothing is broken (
bug-fix), no new prop or export is added (new-feature), pixels are unchanged at rest (visual-update). The Changeset is[feat](patch): perspec:AST-017FR7 a compatible capability change ships as[feat], and consumers do gain capability here — native modal containment per step (asDialog), focus into the panel of a non-modal step, a consumerrolereaching the dialogs, a step re-presented on top on a rapid reversal — while[fix]is for a correction of a defect and[chore]for a change consumers cannot observe, and this one is observable (theref/idhost, SSR markup, the per-dialog props).User or maintainer impact
Builders using
BottomSheetSwitcherkeep the same props and defaults. What they can observe changes in three places, each documented in the prop docs and the Changeset: the switcher'srefresolves to the dialog hosting the active step and moves on a handoff (read it at use time or pass a callback ref);id/data-testidrender on that dialog only, and on no element in server markup of a flow that mounts closed; the other dialog props and handlers reach every step's dialog. Keyboard users get the browser's own modal containment instead of a script trap; a non-modal step's panel takes focus on open. Maintainers get one DOM shape for standalone sheets and switcher steps, and no second switcher-only implementation of modality.Problem and solution fit
BottomSheetrenders its own<dialog>in a switcher too; the switcher renders a boxless wrappercomponent:BottomSheetSwitcherDEC-1)ref/id/data-testid;lastHostSheetas state; first-commit attachuseScrollLock(isModal)once per flow, as onmain; standalone lock untouchedShared dialog→Sheet dialogsin the builder doc and the record's coupled cells; CLI template docs no longer say "shared dialog"Comparison summary and what came from where
Two prototypes were compared: a custom coordinator (#7706: per-sheet dialogs, JS focus trap and scroll lock kept, blur-before-present) and a native-first one (#7710: a strict superset of #7706 that drops the trap for native containment, replaces the body pin with a CSS lock, and swaps the blur for
closeKeepingFocus; the blur is what shipped in the end, see below). Both measured identical tomainin Chromium (dim 0.5 on every frame of every switch, no dip, settled frames pixel-identical, focus panel → panel and back to the opener); #7710 also in WebKit. Neither evidence set conflicted with the other. #7710's CSS scroll lock is not taken: this PR keeps the shared scroll-lock hook that Core's other modal overlays use, held once for the whole flow.--_sheet-scrim-duration: 0sfor one style flush)0.01sstill paints one dipped frame is why it is0suseFocusTrapDialog, which has no JS trap)showModal()so no step dialog records an element to return focus tocloseKeepingFocus(undo the engine's return) was measured to fire a focus round-trip ~0.6s after Back; see "Focus jump on Back" belowpreventScrollon the flow's focus return; backdrop click guarded to the host dialoguseScrollLockonce for the flow, asmaindoesHow each review finding was resolved
assignRefcalledref(null)and dropped React 19 callback-ref cleanupsmergeRefs(ref)(host)and its returned cleanup runs on detach — the same pathuseMergedRefsuses[details], then one cleanup and[details, confirm]on the handoff, one more cleanup on unmount, and never anullid/data-testidrendered once per sheetidanddata-testidrender only on the host dialog (the one therefresolves to) and move with it; every other dialog prop still reaches every sheet dialog[id="checkout"]closed, open, and mid-handoff (two open dialogs)zIndex: 1001on the non-modal path depended on the page-wide 1000 banddisplay: contentsin a modal flow (the dialogs are in the top layer); in a non-modal flow it is the isolated surface (isolation: isolate, fixed, pointer-events none) that orders the sheet dialogs locally — the interactive sheet at1, the retained sheet atauto— the way the shared shell ordered its panels before. The wrapper carries the one1000the shared shell carried, whichspec:AST-027already records as the non-modal migration gap; no sheet dialog has a page-level valueref.currentwasnullwhile the flow was closed[feat]patch, nonbreaking, ascomponent:BottomSheetSwitcherDEC-2 recordspnpm check:changesetsvalidates the tieractiveSheetinto a refFindings 5–7 were about the CSS scroll lock and are moot: the shared hook stays.
Second-round findings
nullin a consumer's mountuseLayoutEffect/useEffect, andid/data-testidappeared on no element in server markup or the first commit<dialog>, exactly as with the shared dialog. Attachment follows the resolved element, not the render, so the follow-up render that catchessheetOrderup does not detach and re-attach.id/data-testid: narrowed, with the reason. Which sheet mounts first is known only once its dialog registers during the first commit; the only render-time way to know it is to introspectchildren, which the repo's@astryx/no-react-introspectionlint rule forbids (Children.toArray, readingchild.props.sheetId), and an always-present placeholder<dialog>would hand consumers an element that is never shown. So the attributes render on the first sheet's dialog from the first commit's synchronous re-render on (before the first paint — a state update scheduled in a ref callback flushes with the commit), and server markup of a flow that mounts closed carries them on no element. The prop doc,.doc.mjs, and Changeset say so. The visible consequence is confined torenderToStringoutput and to code that readsgetElementByIdinside its own mount effectref.currentinside a parent's mountuseLayoutEffectanduseEffect— fails on the previous head (null), passes now. "renders every sheet dialog closed in server markup, with the switcher id on no element until the first commit" pins the narrowed contract:renderToStringyields two closed dialogs and noid; hydrating the same tree puts theidon the first sheet's dialog exactly oncelastHostSheetRefread during render (architecture:react-component-runtimeINV4)useState, adjusted during render when the presented host changes (if (presentedHostSheet != null && presentedHostSheet !== lastHostSheet) setLastHostSheet(...)), the sanctioned "state from previous renders" pattern; no ref is read during renderidon a closed flow)prefers-reduced-motion: reducethe backdrop's literaltransitionDuration: '0.01s'outranked the0stakeover pin, so every handoff painted 1–2 frames with no scrimtransitionDuration: 'var(--_sheet-scrim-duration, 0.01s)'. Measured in Chromium with reduced motion emulated: previous head 2 dip frames to dim 0 on every switch (Continue, Back, Continue); now 0 dip frames, dim 0.5 on all 73–74 frames per switch::backdroprules and asserts the duration isvar(--_sheet-scrim-duration, 0.01s), not a literal — fails with the literal restoredlastHostSheetRefremains: the last host has beenuseStatesince the previous round, and the ref no longer exists in the file; the line the review pointed at is the state-adjust-during-render block itselfroleforwarding (fourth round):role={purpose === 'required' ? 'alertdialog' : undefined}after the spread dropped a consumerrole, which the shared dialog let throughrole={purpose === 'required' ? 'alertdialog' : switcherDialogProps.role}. Audit of every explicit attribute on the sheet dialog againstmain's shared dialog:aria-label/aria-labelledbykeep consumer precedence (unchanged);aria-modalwas host-owned onmaintoo;aria-hidden/inertare new host state with no consumer value onmain;onCancel/onClick/onKeyDownare composed consumer-first as onmain;className/style/xstylemerge as onmain. Onlyrolehad regressedclassName,style,role,aria-describedby,data-*,onToggleon all three dialogs;requiredstillalertdialog) — fails on the previous head (roleabsent); "forces alertdialog on a required step and leaves the other sheet dialogs with no role when the switcher sets none"close()~0.6s after Continue/Back moved focus to the element that was focused when that dialog was presented (the previous step's Continue/Back, or the opener), andcloseKeepingFocusmoved it back —blur/focusout/focus/focusinon the active panel and on the old control with no user action. Measured with real pointer clicks: Chromium and WebKit both run the return even though focus is in another open dialog by then (the HTML spec's "only if focus is inside the closing dialog" condition is not what either engine does today)presentStepModally()blurs the focused element in the same task right beforeshowModal(), so the dialog records nothing and itsclose()moves nothing; the entering step then focuses its own panel, and the switcher returns focus to the opener itself at flow end (triggerRef, as before).closeKeepingFocusis removed; every close is a plainclose(). No frame paints without focus (blur and present in one task); the control is past its press (click has fired) and shows no ring (pointer focus), so nothing visible changes. Options weighed: recording a harmless element atshowModal()time (this), focusing the entering step before the retained element matters (impossible: a closed dialog's content is not focusable), closing while focus is in the active dialog (already the case; both engines return anyway)focus/blur/focusin/focusoutthrough Continue, Back and a mid-handoff reversal, each retained close asserted to fire zero events, with the dialog stub returning focus the way the engines do — fails on the previous head (blur:Back → focus:Start flow). Browser:focus-eventsharness with real clicks, both engines, main vs this build: Continue 8, Back 8, reversal 16, Continue 8 events, all at click time — identical tomain; the previous head read 8/16/24/16 with the extra eight ~620ms after each switchcloseKeepingFocus+showModal()and--_sheet-scrim-opacity: 0, but that dialog had closed with its backdrop at 1, so the backdrop eased 1 → 0 over--duration-mediumon top of the new owner's — two backdrops lit (dim 0.75) for 8 frames in Chromium, 1 in WebKitpresentWithClearScrim(dialog): pins--_sheet-scrim-duration: 0s, sets the opacity to 0, callsshowModal(), flushes::backdropstyle, lifts the pin — the same shape astakeOverScrim. Reduced motion honours it because the reduced-motion duration already reads the same variable. Why the earlier harness missed it: it sampled Continue, then Back ~1,400ms later (after the handoff had finished), where the returning dialog is closed and freshly presented, so nothing eased; the new harness clicks Back during the handoff at 60, 100, 400 and 900ms and reproduces the previous head's 8 over-frames at every in-handoff timing, 0 at 900ms--_sheet-scrim-duration/-opacityatshowModal()time:0s/0for the returning dialog, pin lifted after; exactly one dialog at opacity 1) — fails on the previous head; per-frame re-measure: 0 over-frames on Hug content, Capped and Narrow viewport × 4 reversal timings, Chromium and WebKit, normal and reduced motionid/data-testidBottomSheetSwitcher.doc.mjsdescription now saysref/id/data-testidresolve to the host and the other dialog props and handlers apply to every sheet dialog (it said "dialog props resolve to the dialog hosting the active sheet"). CLI template docsBottomSheetSwitcherShowcase.doc.mjs/BottomSheetSwitcherReviewFlow.doc.mjsdescribe each step in its own native dialog under one scrim (they said "one shared dialog"), with a[docs]@astryxdesign/cliChangeset as coverage requires.BottomSheetEdgeTint.test.tsx's comment no longer claims the tint "must not be minted per child sheet": each presented step dialog carries its own tint, one at rest and one per open dialog through a handoff (the retained dialog's sits beneath the entering dialog)cancel: the test "dismisses an info flow from a cancel on any sheet dialog, and from a backdrop click on the active sheet's dialog only" now dispatchescancelon a non-host dialog and asserts it dismisses — only the backdrop click is host-guardedBottomSheetSwitcher.doc.mjs's description said "one CSS rule locks page scroll while any sheet dialog is modal"; now "the switcher holds the shared body scroll lock once for the whole modal flow". The branch was grepped (source, JSDoc,.doc.mjs, changesets, tests) forCSS rule,:modal,sheetScrollLock,data-sheet-dialog,scrollbar-gutter,keyed off: the only remaining hit is a test-stub comment explaining what theshowModalstub stands in for. The inline re-posts atBottomSheetSwitcher.tsx:336(lastHostSheetRef) andBottomSheet.tsx:115(reduced motion) point at the state-adjust block and, at L122, thevar(--_sheet-scrim-duration, 0.01s)line (L115 is the default-duration line above it): staleuseScrollLock(isModal)at flow level exactly wheremaindoes;isModalstays true through a handoff (two dialogs open) and through the exit motion, so the shared counter is taken once and given back once. The standalone sheet keepsuseScrollLock(shouldPresent && hasScrim)as onmain. No per-step lock anywherewindow.scrollTo— the hook's one restore call — never fires between steps;<body>pinned at-300pxthroughout; one call with(0, 300)after the exit); "keeps the lock through a StrictMode replay of a flow mounted open, and releases it when the flow unmounts while presented"; "keeps the lock engaged under a CoreDialogopened from a step" (shared counter: theDialog's close gives back only its own hold);main's own BottomSheet and BottomSheetSwitcher body-pin tests pass uneditedrenderToString/hydrateRootimports; this PR is 1,970DialogInStepFlowfixture) moved to #7757, where its browser twin lives. The ninth-round CLI doc fixes, their Changeset and thecancelassertions then put it at 2,005, so "forces alertdialog on a required step and leaves the other sheet dialogs with no role when the switcher sets none" (21 lines; theroletest above it keeps the required-step assertion) and the new EdgeTint mid-handoff case moved to #7757 as well; this PR is 1,984HostFlowfixture, losing no assertionBefore / after structure
Before (
main): one switcher-owned<dialog>; steps are panels inside it. After: the switcher renders a boxless wrapper; each child renders what a standalone sheet renders.Behaviour deltas
mainshowModal(next)at click,close(previous)~650ms later; 2dipFrames=0, both engines, normal and reduced motionuseScrollLock(isModal)once per flowisModal; held through the two-dialog handoff with no release between steps, restored to the saved offset after the exit<body>position: fixed; top: -300pxthroughout,scrollY0 → 300 after exit, 0 window scroll events mid-handoff, both enginesinertDialog<body>preventScrollclose()+showModal()of the returning sheet with its backdrop cleared under a 0s pin; dim 0.5 on every frame, no dip, no double dim, no focus jumprefnullwhile a sheet is mounted, from the first commit onid,data-testidonCancel, other dialog props and handlerscancelon the dialog it presents, the active sheet'saria-labelstill wins<dialog>with the consumer'sid<dialog>per sheet,idon none until the first commithydrateRoot)Test edits
Existing suites ran unedited against the new code: 6 failures, all assertions about the shared-dialog structure or the removed trap; no behaviour assertion was weakened.
BottomSheetSwitcher.test.tsx)getSharedDialog()getActiveDialog(); addedgetSheetDialog,openDialogs,recordDialogCalls(whoseclosereturns focus like a browser)aria-hidden)dialog[open]count 1 →[details, confirm]during the handoff,[confirm]after; retained dialog assertedinertshowModal()0aria-modaland was presented withshowModal(), and no script prevents TabkeyDown Tab → falsetested the JS trap'spreventDefault; the behaviour moves toBottomSheetNativeModal.a11y.browser.spec.ts("Tab stays inside the active sheet"), Chromium + WebKitNew tests in "one native dialog per sheet" (17, plus one in the switcher suite; four more sit in #7757): per-sheet structure; native call order; one lock for the flow through a handoff and the exit; the lock through a StrictMode replay and released on unmount while presented; the returning sheet re-presented with its backdrop cleared under the pin; the ref across open/handoff/close/unmount including a parent's mount effects; callback-ref cleanups;
idonce; server markup and hydration (in #7757); non-modal isolation; dialog-prop forwarding; one scrim with instant takeover; focus into the entering sheet with the retained dialog inert; no focus event when a retained dialog closes after Continue, Back or a reversal;cancelby the active sheet'spurpose;cancelon any sheet dialog (the browser raises it on the one it presents) and the backdrop click on the host dialog only; non-modalshow()per sheet; re-present on reversal; forwarded props on every dialog withrolekept andrequiredforcingalertdialog. The Chromium + WebKit spec, and the gutter-fallback handoff/StrictMode unit case, are the stacked follow-up PR.Authority
component:BottomSheetSwitcherDEC-1 (per-step dialogs; the switcher's coordination duties), DEC-2 (host-dialog identity is implementation; nonbreaking), and the rewritten FR2, FR5, FR8, FR9, PR1 — spec PR spec(core): each BottomSheetSwitcher step owns its native dialog #7744.family:overlay-dismissal— one registration for the flow, pointed at the host dialog; nativecancelrouted throughshouldDismissOnCloseRequest(); Layer suites pass unedited.spec:AST-027— modal flows useshowModal()hosting; the non-modal flow's isolation owner is the switcher's wrapper (FR2) with local values inside (FR4); the wrapper's1000remains the recorded gap, unchanged frommain.architecture:public-component-apiINV8 — the ref target is documented on the prop.architecture:component-theming-surface— the anatomy key rename keeps thenone/delegatesTodispositions unchanged; no target is added.Writing rubric results
The record edits in
BottomSheetSwitcher.spec.mdare the three cellscheck:knowledgecouples to the builder doc's anatomy, plus the deletion of the implementation-gap sentences listed at the top (twelve sentences across the two records; each described the shipped shared dialog, which this PR replaces): theSheet dialogsdesign-relationships requirement cell, theSheet dialogstheming-anatomy reason, and the Scrim theming-anatomy reason (which names "the sheet dialog that owns the scrim" instead of "the switcher-coordinated native backdrop").Change and evidence
Files: under
packages/core/src/BottomSheet/, plus the two Core Changesets, the two CLI template docs underpackages/cli/assets/templates/blocks/components/BottomSheet/and their[docs]@astryxdesign/cliChangeset:BottomSheetSwitcher.tsx,BottomSheet.tsx,BottomSheetSwitcherContext.ts, the two.doc.mjs,BottomSheetSwitcher.test.tsx, a[feat]Changeset (patch), and the anatomy rename:Shared dialog→Sheet dialogsinBottomSheetSwitcher.doc.mjsand, becausecheck:knowledgerequires the record's anatomy to match, the same key inBottomSheetSwitcher.spec.md's design-relationships row and theming-anatomy block, with a[docs]Changeset. Builders read an anatomy that matches what the component renders from this PR on. 1,970 authored lines.API audit.
exportsmap identical; the.d.tsfile set identical (no private module added);BottomSheet/index.d.tsand the rootindex.d.tsbyte-identical;BottomSheet.d.tsdiffers only in doc comments;BottomSheetSwitcher.d.tsdiffers in doc comments and the destructuring pattern of the declared signature (no type change);BottomSheetSwitcherContext.d.tschanges but is not reachable from a public entry.Browser (Chromium 149 and WebKit under Playwright 1.61, 390×844 touch, static Storybook of
mainand of this branch, storieshug-content/capped/no-scrim/ standaloneshowcase). Native call log per switch as above; dim 0.5 on every frame of every switch in both directions,dipFrames=0 overFrames=0, page pixel luminance 127 mid-handoff and settled — and the same withprefers-reduced-motion: reduceemulated (0 dip frames on Continue, Back, Continue; the previous head showed 2 frames at dim 0 per switch); focus panel → panel on modal switches, opener after Escape / scrim tap / swipe; Tab pressed while both dialogs are open lands in the entering sheet's dialog (or the browser-UI stop), never the page or the retained dialog, in either engine; rapid reversalshowModal,close,showModal,closewith dim 0.5 throughout; a probeshowModal()dialog during a handoff hit-tests on top; no horizontal overflow; no console errors or warnings; settled screenshots pixel-identical tomain(36 of 36 pairs, both engines), mid-handoff frames differ by 0–7% from animation timing (the re-presented sheet on a rapid reversal is a frame further along). Re-measured on this exact build againstmain's, both engines: dim, focus after each switch and dismissal, Tab stops, reduced motion, and the scroll lock (<body>pinned at-300pxthrough the handoff,scrollY0 under wheel, 300 after the exit, 700 after a wheel then) are identical tomain's readings; the one difference is the known browser-UIbodystop native containment adds per Tab lap (main's JS trap wrapped directly).Scope
Testing