Repository navigation
feat(core): BottomSheet dialogs per sheet, native-first (prototype) - #7710
imdreamrunner wants to merge 1 commit into
Conversation
Every BottomSheet, standalone or a BottomSheetSwitcher step, owns its own native <dialog>, presented with showModal() when it has a scrim and show() otherwise, and the browser supplies modality: the page is inert, focus stays in the active sheet, Escape and the platform close arrive as `cancel`, the scrim is ::backdrop, and focus returns on close(). Page scroll is locked by one CSS rule keyed off `dialog:modal`, installed once per document, instead of pinning <body> from script; the JavaScript focus trap and scroll lock are removed from the switcher and the standalone sheet. The switcher keeps only what no single dialog can decide: which sheet is presented or retained, one scrim handed between dialogs without a fade, focus kept in the active sheet when a retained dialog closes, focus returned to the flow's opener, and the one layer-dismissal registration that gates `cancel` by purpose. Public props, defaults, and exports are unchanged.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
AI review status for this pull request.
|
PR Analysis Report
No new or modified components detected. Bundle Size Summary
Accessibility AuditStatus: No accessibility violations detected. Visual Regression40 of 482 shot(s) changed. A change here is a question, not a failure: check whether the after is the
and 20 more. Generated by PR Enrichment workflow | View full report |
There was a problem hiding this comment.
Approval waits on a maintainer decision. This prototype rewrites the draft BottomSheetSwitcher requirements (FR2, FR5, FR8, FR9, PR1) and BottomSheet's ownership for a dialog-per-sheet model with a CSS scroll lock, and it changes what the switcher ref points at, so a maintainer needs to approve that direction and decide its release tier. Whatever they decide, these need changes:
scrollbar-gutter: stableinsheetScrollLock.tsreserves a gutter the scrim does not cover, so on desktops with classic scrollbars a short page shows an undimmed 15px strip and the sheet shifts; reserve the gutter only while a scrollbar is showing.- The switcher's dialog props now land on every sheet dialog, so a forwarded
idis duplicated, andassignRefdrops React 19 callback-ref cleanups (utils/mergeRefs.tsalready handles them). - The non-modal path adds a z-index 1001 band, which
spec:AST-027FR1 and FR4 rule out. spec:AST-013FR10 needs a real iOS Safari check of the touch scroll lock, and at 2,239 authored lines the change should be split, for example with the scroll lock first.
[Automated review]
|
|
||
| /** The lock, as CSS text. */ | ||
| export function sheetScrollLockCss(): string { | ||
| return `${SHEET_SCROLL_LOCK_SELECTOR}{overflow:hidden;scrollbar-gutter:stable}`; |
There was a problem hiding this comment.
scrollbar-gutter: stable reserves a gutter even when the page has no scrollbar. With classic scrollbars, on a page that does not scroll, the ::backdrop does not cover that gutter: the right 15px stays undimmed and the sheet re-centres 7.5px left (1280px, headless Chromium). holdScrollbarGutter only reserved it when window.innerWidth > documentElement.clientWidth. Keep that condition, or drop the gutter from this rule.
| )} | ||
| hidden={!isPresented} | ||
| <dialog | ||
| {...switcherDialogProps} |
There was a problem hiding this comment.
Spreading the switcher's dialog props onto every sheet dialog duplicates identity attributes: <BottomSheetSwitcher id="checkout"> with three sheets renders three elements with id="checkout", where there used to be one. Forward id and data-testid only to the dialog the ref resolves to.
| return stableRef.current; | ||
| } | ||
|
|
||
| function assignRef<T>(ref: React.Ref<T> | undefined, value: T | null): void { |
There was a problem hiding this comment.
This drops React 19 callback-ref cleanups. A ref that returns a cleanup never has it called, and receives null instead; the previous useMergedRefs → mergeRefs path ran the cleanup. Keep the returned cleanup and call it on detach, as utils/mergeRefs.ts does.
| // would decide which sheet paints in front during a handoff. The interactive | ||
| // sheet's dialog takes the next value in the same overlay band instead. | ||
| dialogNonModalTop: { | ||
| zIndex: 1001, |
There was a problem hiding this comment.
spec:AST-027 FR1 and FR4 rule out depending on a page-wide band such as "overlays at 1000", and DEC-4 rules out adding another one. Here 1001 makes the step order depend on that band. The base ordered steps locally (positionerTop: 1) inside one 1000 shell. An isolated owner with local values, or the Layer route AST-027 names, avoids the new band.
Primary intent
Find out how far
<dialog>and CSS carry a bottom sheet — standalone and as a step of aBottomSheetSwitcher— when every sheet owns its own dialog, and measure exactly where custom JavaScript must stay. The public API is unchanged; the mechanisms behind page scroll lock, focus containment, Escape and the platform close, the scrim tap, focus return, and the scrim change, and three observable details move with the structure.The other templates do not fit: nothing is broken (
bug-fix), no capability is added (new-feature), and pixels are unchanged at rest (visual-update).User or maintainer impact
Consumers see the same flows: one steady scrim, the step handoff, focus in the new step and back on the opener, page scroll locked while a sheet is modal, Escape / scrim / swipe by
purpose. Maintainers lose two mechanisms (useFocusTrapanduseScrollLockin sheets) and gain one 68-line CSS module. The three deltas a consumer can observe are listed under "Behaviour deltas".Problem and solution fit
Today every sheet dialog is presented with
showModal()and then re-implemented in script: a focus trap on top of the browser's inert page, a body pin on top of the top layer, a captured trigger on top of the browser's own focus record. #7706 kept all of that when it moved the dialog to each sheet. This PR removes what the browser already does and keeps the rest, so the remaining script is the exact list of things a per-sheet dialog cannot know on its own: that it is one step of a flow.How far native goes
main)useScrollLock:body { position: fixed; top: -scrollY; overflow: hidden }, a lock counter, a measured scrollbar-gutter hold,scrollToon release:root:has(dialog[data-sheet-dialog]:modal) { overflow: hidden; scrollbar-gutter: stable }, installed once per document from a layout effect (the way Layer installs its@position-tryrules; StyleX cannot style the root)scrollY300, wheel 400 → 300, real finger drag 300px → 300, wheel/drag inside the sheet content → 300 (no chaining), through a handoff with two open dialogs → 300, under anothershowModal()dialog → locked, released only after the exit motion;scrollYnever moves so nothing to restore; viewport width 390→390overflow: hiddenfor a finger is unverified<body>);scrollbar-gutter: stablewould open a gutter on a classic-scrollbar desktop page that had no scrollbar (headless Chromium has overlay scrollbars, width 0, so unmeasured)useFocusTrapon the switcher (keydown Tab wrap + focusin redirect)showModal()makes everything outside the dialog inertDialog, which already relies on it. The retained dialog is also natively inert (a lower modal dialog is inert while a higher one is open)cancelhandler + the shared dismissal stack's document Escape listener + the per-dialog keydown re-routecancel(Escape in a modal dialog, Android back, the close watcher);preventDefault()always, then dismiss only ifpurpose !== 'required'and the shared stack says this layer is topmostcancelarrives natively; the stack gates it; required blocks itpurposegating and the overlay-dismissal family contract are not expressible natively; non-modalshow()dialogs get nocancelat all, so the stack's Escape listener stays for themonClickon the dialog wheretarget === currentTargetclosedby="any"was evaluatedclosedByexists in Chromium;cancelcarries no source, so it cannot distinguish a backdrop tap (infoonly) from Escape (infoandform)closedByabsent in WebKit ('closedBy' in HTMLDialogElement.prototype→ false)closedbyrejected: no WebKit support and no per-sourcepurposegatingdocument.activeElementat first present,.focus()it after the last dialog closes. Standalone: capture +focusForLastInputclose()on a modal dialog focuses the element focused atshowModal()preventScrollso an off-screen opener does not pull the page back (main's body pin masked that scroll). Standalone unchanged::backdropon the one dialog; gesture writes--_sheet-scrim-opacity::backdropper dialog. Tried letting both paint: the entering dialog's backdrop sits above the retained panel in the top layer (dims the previous step, double-dims the page); a cross-fade dips to 0.44 mid-handoffdipFrames=0 overFrames=0, page pixel luminance 127 mid-handoff and settled, settled screenshots pixel-identical to maintransition-duration: 0smust be an inline custom property because the stylesheet audit forbids it in a ruleuseLayerDismissalregistration for the flowshowModal()dialog opened during a handoff hit-tests on top; Escape with a nested Popover closes the Popover first (existing browser specs pass)showModal()call, and a modal dialog beneath another modal dialog is inertclose()+showModal()of the returning sheet (two extra native calls), dim 0.5 throughout, focus on the returning panelfocusPanelmoves focus)showModal()of the next step records the Back/Continue control that presented it;close()of the retained dialog later tries to focus itcloseKeepingFocus(8 lines) re-focuses whatever was focused before the closeReplaced outright:
useScrollLock(sheets) anduseFocusTrap(switcher), plus theblur()workaround of #7706. The deprecated read-onlyhasActiveFocusTrapEscape()signal keeps reporting a modal flow (useFocusTrapEscapeCompatibilitySignal, one line) so the released compatibility contract holds.Before / after structure
Before (
main): one switcher-owned<dialog>; steps are panels inside it.After: the switcher renders no element; each child renders what a standalone sheet renders, and a
<style id="astryx-bottom-sheet-scroll-lock">in<head>holds the one lock rule.Behaviour deltas
mainshowModal(next)at click,close(previous)~650ms lateraria-hidden); 1 at rest<body>,scrollToon releaseoverflow: hidden+scrollbar-gutter: stablewhile any sheet dialog is modalscrollYnever changes;<body>untouchedposition: fixed)overflow: hiddenDialog<body>show()focusing steps):focus-visibleon the focused panel after a pointer switchoutline: none, so nothing paints; next Tab behaves the samepreventScrollonActiveSheetChange(null)perpurposeclose()+showModal()of the returning sheetrefnullwhile closedonCancel, dialog propsaria-labelstill wins<dialog><dialog>per sheet; the lock stylesheet is installed client-side before the firstshowModal()openserver-side)Dialogopened from a stepshowModal()orderNon-modal flows (
hasScrim={false}) still useshow()per sheet withz-index1000/1001; nothing is locked or contained, as onmain. This stays inside the non-modal path that AST-027 already lists as a conformance gap.Comparison with #7706
blur()of the presenting control beforeshowModal()closeKeepingFocuson the retained dialog's close (8 lines, undoes a wrong native return instead of pre-empting it)Test edits
Existing suites ran unedited first against the new code: 6 failures, all assertions about removed internals or the shared-dialog structure. No behaviour assertion was weakened.
BottomSheet.test.tsx— dialog stubsshowModal/closestubs also record amodalDialogsset:modal; the set stands in for itBottomSheet.test.tsx"keeps a standalone modal sheet presented through its exit animation"body.style.position === 'fixed'→isScrollLockEngaged()(a modaldialog[data-sheet-dialog][open]exists)BottomSheet.test.tsx"keeps a standalone non-modal sheet visible until its exit ends"body.style.position→isScrollLockEngaged() === falseBottomSheetSwitcher.test.tsxhelpergetSharedDialog()getActiveDialog(); addedgetSheetDialog,openDialogs,isScrollLockEngaged,recordDialogCalls(whoseclosereturns focus like a browser)aria-hidden)dialog[open]count 1 →[details, confirm]during the handoff,[confirm]after; shared identity → active identity; retained dialog assertedinertshowModal()0body.style.position→isScrollLockEngaged(); titlesshowModal()and isaria-modal, and that no script prevents TabkeyDown Tabreturnsfalse) tested the JS trap'spreventDefault, which jsdom was the only place to observe; the behaviour moves toBottomSheetNativeModal.a11y.browser.spec.ts("Tab stays inside the active sheet"), which passes in Chromium and WebKitNew tests:
sheetScrollLock.test.ts(3: rule text, idempotent per-document install, iframe copy);BottomSheet.test.tsx"native modal host" (4: attribute + stylesheet installed once before presenting with<body>untouched; lock held under a CoreDialogand released only with the sheet; nativecancelbypurposewithpreventDefaultalways; backdrop click bypurpose);BottomSheetSwitcher.test.tsx"one native dialog per sheet" (13: per-sheet structure; native call order; lock across a handoff and through the exit; lock under a CoreDialogopened from a step with the Dialog presented on top;refmovement; dialog-prop forwarding; one scrim with instant takeover; focus into the entering sheet with the retained dialog inert; focus kept when a retained dialog's close returns focus natively;cancelby the active sheet'spurposeafter a handoff;cancel/backdrop click on the active dialog only; non-modalshow()per sheet; re-present on reversal);__tests__/BottomSheetNativeModal.a11y.browser.spec.ts(5, Chromium + WebKit: CSS lock through a handoff, under another modal dialog, released after exit, with<body>untouched and no chaining; non-modal locks nothing; Tab containment; Escape + backdrop tap + focus return; steady scrim). Against themainbuild the lock test fails as expected (rootoverflowstaysvisible).Records touched
BottomSheetSwitcher.spec.mdandBottomSheet.spec.mdaredraftrecords. They were edited only where they describe the host and the mechanisms behind it (Intent, Ownership, FR2, FR5, FR8, FR9, representative states, PR1, design relationships, theming-anatomy keyShared dialog→Sheet dialogs,verified_by). No decision was added; the decision logs are unchanged.Writing rubric results
draftstaysdraft; no claim was promoted.BottomSheetSwitcher.test.tsx,BottomSheetNativeModal.a11y.browser.spec.ts,sheetScrollLock.test.ts).Authority
component:BottomSheetSwitcher(draft) FR1–FR9, PR1, AR1–AR3 — preserved; FR5 and PR1 now name native containment and the CSS lock.family:overlay-dismissal— one registration for the flow; nativecancelrouted throughshouldDismissOnCloseRequest(); existing Layer suites pass unedited.architecture:layer-runtimeandspec:AST-027— nativeshowModal()hosting; the non-modalshow()+z-indexpath remains the recorded gap.spec:AST-002/architecture:public-component-api— props, defaults, and exports unchanged;refsemantics documented in the prop doc and the Changeset.Change and evidence
Files (all under
packages/core/src/BottomSheet/plus the Changeset):BottomSheet.tsx(624 → 804 lines: the switcher item now renders its own dialog, pluscloseKeepingFocus, the lock install, and the attribute),BottomSheetSwitcher.tsx(660 → 730 lines: no element, no trap, no scroll lock, but the per-sheet coordination — presentation order, scrim ownership, dialog-prop forwarding — moved in; 8 lines fewer than #7706's switcher),BottomSheetSwitcherContext.ts, newsheetScrollLock.ts(68 lines, the only "shared CSS plumbing"; it is sheet-scoped by attribute and lives in the BottomSheet directory, so no file outside it changed),sheetScrollLock.test.ts,__tests__/BottomSheetNativeModal.a11y.browser.spec.ts, the two.doc.mjs, the two draft specs, the two test files, and a[feat]Changeset.API audit. Core built on
origin/mainand on this branch;.d.tsfiles and theexportsmap diffed.exportsmap identical.BottomSheet/index.d.tsand the rootindex.d.tsbyte-identical.BottomSheet.d.tsandBottomSheetSwitcher.d.tsdiffer only in doc comments and the destructuring pattern in the declared signature (no type change).BottomSheetSwitcherContext.d.tschanges andsheetScrollLock.d.tsis new; neither is reachable from a public entry.Browser (Chromium 149 and WebKit "Safari 26.5" under Playwright 1.61, 390×844, touch, static Storybook of
mainand of this branch, storieshug-content,capped,no-scrim, and the standaloneshowcase). Native call log, per-frame backdrop sampling, focus traces, hit tests, Tab traces, and for the lock: page made 3000px tall and scrolled to 300, flow opened from an off-screen opener, then wheel (both engines) and real finger drag (Chromium, CDP) over the backdrop and inside the sheet content, through a handoff, under a probeshowModal()dialog, and after close.scrollY300 before, during (wheel, drag, chaining, mid-handoff, under a probe dialog), and after; rootoverflowhiddenexactly while a sheet dialog is modal, including the exit motion;<body>carries no inline style; viewport width 390→390.main: body pinned,scrollYreads 0, restored to 300 by script on release.dim 0.5 [A:1.00]→0.5 [A:1.00 B:0.00]→0.5 [B:1.00];dipFrames=0 overFrames=0; luminance 127 mid and settled.main: 0.5 constant.main).no-scrim.showModal()dialog during a handoff hit-tests on top in both engines.mainand on this branch alike (6/6 each), never on a warm engine or a reopen.WebKit under Playwright approximates Safari but not iOS hardware, and cannot be driven by native touch; the iOS touch-scroll answer is still open.
Recommendation
Native-first is viable: the browser carries focus containment, inertness, the per-dialog focus record,
cancel, the backdrop, and — with one CSS rule — page scroll lock, and every measurement matchesmainor improves on it. The smallest custom set that must stay in a per-sheet design is: (1) the flow-level opener capture and return, (2) the scrim handoff between two backdrops, (3)closeKeepingFocuson a retained dialog's close, (4) the re-present on reversal, and (5)purposegating ofcancelplus the shared dismissal stack — items 1–4 exist only because two dialogs stand in for one sheet; item 5 exists onmaintoo. The open risk is iOS Safari's handling of rootoverflow: hiddenfor touch, which this environment cannot measure; if a device check fails there,useScrollLockwould return for sheets and the rest of this PR still stands.Scope
Testing