Skip to content

feat(core): BottomSheet dialogs per sheet, native-first (prototype) - #7710

Draft
imdreamrunner wants to merge 1 commit into
mainfrom
feat/switcher-native-sheet-dialog
Draft

imdreamrunner wants to merge 1 commit into
mainfrom
feat/switcher-native-sheet-dialog

Conversation

@imdreamrunner

Copy link
Copy Markdown
Contributor

Prototype for evaluation. A native-first alternative to #7706. Both move the native <dialog> from BottomSheetSwitcher to each child BottomSheet; this one then asks the browser to do everything a modal dialog can do and keeps custom code only where it measurably cannot. Not a proposal to merge as-is; see "Recommendation".

Primary intent

Find out how far <dialog> and CSS carry a bottom sheet — standalone and as a step of a BottomSheetSwitcher — 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 (useFocusTrap and useScrollLock in 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

Mechanism Today's custom code (main) Native / CSS replacement tried Chromium result WebKit result Kept custom? Why
Page scroll lock useScrollLock: body { position: fixed; top: -scrollY; overflow: hidden }, a lock counter, a measured scrollbar-gutter hold, scrollTo on release One rule, :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-try rules; StyleX cannot style the root) Holds: at scrollY 300, 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 another showModal() dialog → locked, released only after the exit motion; scrollY never moves so nothing to restore; viewport width 390→390 Same for wheel in every state. Touch cannot be injected into Playwright WebKit, and desktop WebKit is not iOS Safari: whether iOS honours root overflow: hidden for a finger is unverified No custom code. Caveats: iOS touch unverified; a page that scrolls a nested element rather than the viewport is no longer pinned (main pinned <body>); scrollbar-gutter: stable would open a gutter on a classic-scrollbar desktop page that had no scrollbar (headless Chromium has overlay scrollbars, width 0, so unmeasured)
Focus containment useFocusTrap on the switcher (keydown Tab wrap + focusin redirect) showModal() makes everything outside the dialog inert Tab ×10 inside a step never reaches the page or the retained dialog; the cycle has one extra stop (browser UI / nothing) per lap where the JS trap wrapped directly Same; two extra stops per lap (browser UI, then the dialog element) No custom code. Same behaviour as Core Dialog, which already relies on it. The retained dialog is also natively inert (a lower modal dialog is inert while a higher one is open)
Escape and platform close cancel handler + the shared dismissal stack's document Escape listener + the per-dialog keydown re-route Native cancel (Escape in a modal dialog, Android back, the close watcher); preventDefault() always, then dismiss only if purpose !== 'required' and the shared stack says this layer is topmost cancel arrives natively; the stack gates it; required blocks it Same Kept (unchanged from main, now on each sheet's dialog): purpose gating and the overlay-dismissal family contract are not expressible natively; non-modal show() dialogs get no cancel at all, so the stack's Escape listener stays for them
Scrim tap onClick on the dialog where target === currentTarget The native backdrop click already targets the dialog element, so the same handler is the native path; closedby="any" was evaluated closedBy exists in Chromium; cancel carries no source, so it cannot distinguish a backdrop tap (info only) from Escape (info and form) closedBy absent in WebKit ('closedBy' in HTMLDialogElement.prototype → false) Kept the click handler (3 lines, now scoped to the active sheet's dialog). closedby rejected: no WebKit support and no per-source purpose gating
Focus return on close Switcher: capture document.activeElement at first present, .focus() it after the last dialog closes. Standalone: capture + focusForLastInput close() on a modal dialog focuses the element focused at showModal() A one-sheet flow and a standalone sheet return natively (the script call is then a no-op). A multi-step flow does not: the last step's dialog recorded a control inside the previous step, which is hidden when the flow ends, so the browser's return lands nowhere. Measured: opener focused after Escape / scrim tap / swipe from step 2 on both builds Same Kept the flow-level capture + return (switcher), with preventScroll so an off-screen opener does not pull the page back (main's body pin masked that scroll). Standalone unchanged
Scrim One ::backdrop on the one dialog; gesture writes --_sheet-scrim-opacity One ::backdrop per 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-handoff With the retained dialog owning the scrim and the entering dialog's backdrop at 0 until the retained one closes, then taking the value with its transition pinned off for one style flush: dim 0.5 on all 70+ frames of every switch, dipFrames=0 overFrames=0, page pixel luminance 127 mid-handoff and settled, settled screenshots pixel-identical to main Same Kept (same shape as #7706, ~40 lines). The browser offers no handoff between two backdrops; CSS alone either double-dims or dips. transition-duration: 0s must be an inline custom property because the stylesheet audit forbids it in a rule
Layer dismissal ordering One useLayerDismissal registration for the flow Nothing native orders a sheet against a Popover or Dialog opened inside it Registration points at the active sheet's dialog; a showModal() dialog opened during a handoff hit-tests on top; Escape with a nested Popover closes the Popover first (existing browser specs pass) Same Kept (unchanged): the family contract requires it, and it is the only thing that knows about non-dialog layers
Top-layer order on reversal n/a (one dialog) None: the top layer orders by showModal() call, and a modal dialog beneath another modal dialog is inert Going Back to a sheet still fading beneath the current one: close() + showModal() of the returning sheet (two extra native calls), dim 0.5 throughout, focus on the returning panel Same Kept (same as #7706): a native constraint, reached only by reversing within ~400ms
Focus during a handoff n/a (one dialog; focusPanel moves focus) showModal() of the next step records the Back/Continue control that presented it; close() of the retained dialog later tries to focus it When the control is in a hidden step the return is a no-op; when it is in the now-active step (Back) the browser would move focus from the panel to that control a fade later. closeKeepingFocus (8 lines) re-focuses whatever was focused before the close Same Kept: smaller than #7706's blur-before-present and robust to how an engine implements the return

Replaced outright: useScrollLock (sheets) and useFocusTrap (switcher), plus the blur() workaround of #7706. The deprecated read-only hasActiveFocusTrapEscape() 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.

<dialog aria-label="Active step" aria-modal="true">   <!-- switcher: showModal() once; body pinned by script -->
  <div hidden …><div class="astryx-bottom-sheet">step A</div></div>
  <div><div class="astryx-bottom-sheet">step B</div></div>
</dialog>

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.

<dialog data-sheet-dialog aria-label="Step A" inert aria-hidden="true">  <!-- retained: open until its panel fades -->
  <div inert aria-hidden="true"><div class="astryx-bottom-sheet">step A</div></div>
</dialog>
<dialog data-sheet-dialog aria-label="Step B" aria-modal="true">          <!-- active: showModal() on entry -->
  <div><div class="astryx-bottom-sheet">step B</div></div>
</dialog>

Behaviour deltas

Area main This PR Note
Native calls per switch 0 showModal(next) at click, close(previous) ~650ms later measured, both engines
Open dialogs during a handoff 1 2 (retained one inert, aria-hidden); 1 at rest
Page dimming through a switch 0.5 constant 0.5 constant, no dip, no double dim 70+ samples per switch
Scroll lock mechanism inline styles on <body>, scrollTo on release root overflow: hidden + scrollbar-gutter: stable while any sheet dialog is modal scrollY never changes; <body> untouched
Page that scrolls a nested element, not the viewport pinned (body position: fixed) not pinned observable change; the sheet's own content still contains its scroll
Page scroll on iOS by touch pinned root overflow: hidden unverified on hardware
Tab inside a modal sheet wraps directly within the dialog native containment: one (Chromium) or two (WebKit) browser-UI stops per lap, never the page same as Core Dialog
Focus after a switch (modal) new step's panel new step's panel identical traces
Focus after a switch (non-modal) lost to <body> new step's panel improvement (native show() focusing steps)
:focus-visible on the focused panel after a pointer switch false false in Chromium; true in WebKit the panel has outline: none, so nothing paints; next Tab behaves the same
Focus return on close opener opener identical; now with preventScroll
Escape / scrim tap / swipe close via onActiveSheetChange(null) per purpose same identical
Rapid reversal (Back while fading) panel z-index swap close() + showModal() of the returning sheet 2 extra native calls; no dip, no focus jump
ref one persistent dialog the dialog hosting the active sheet; moves on handoff; null while closed observable change
onCancel, dialog props one dialog every sheet dialog (handlers reach the active one) observable change
Accessible name / role precedence rule across children each dialog named by its own sheet; consumer aria-label still wins equivalent output
SSR markup one closed <dialog> one closed <dialog> per sheet; the lock stylesheet is installed client-side before the first showModal() hydration-safe (no open server-side)
A Core Dialog opened from a step above the flow above the flow; lock held under it and after it closes top layer is showModal() order

Non-modal flows (hasScrim={false}) still use show() per sheet with z-index 1000/1001; nothing is locked or contained, as on main. This stays inside the non-modal path that AST-027 already lists as a conformance gap.

Comparison with #7706

#7706 workaround Here
JS focus trap kept in the switcher Gone — native containment
JS scroll lock kept in the switcher and standalone sheet Gone — one CSS rule
blur() of the presenting control before showModal() Gone — replaced by closeKeepingFocus on the retained dialog's close (8 lines, undoes a wrong native return instead of pre-empting it)
Scrim handed to the entering dialog without a fade Remains — no native handoff between two backdrops
Re-present a sheet that is still fading on reversal Remains — top-layer order and inertness are native constraints
Flow-level trigger capture and focus return Remains — a multi-step flow's last dialog recorded the wrong element

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.

Test Edit Reason
BottomSheet.test.tsx — dialog stubs showModal/close stubs also record a modalDialogs set jsdom cannot match :modal; the set stands in for it
BottomSheet.test.tsx "keeps a standalone modal sheet presented through its exit animation" body.style.position === 'fixed' → isScrollLockEngaged() (a modal dialog[data-sheet-dialog][open] exists) asserted the removed body pin; the behaviour (lock held through exit) is asserted through the rule's precondition
BottomSheet.test.tsx "keeps a standalone non-modal sheet visible until its exit ends" body.style.position → isScrollLockEngaged() === false same
BottomSheetSwitcher.test.tsx helper getSharedDialog() renamed getActiveDialog(); added getSheetDialog, openDialogs, isScrollLockEngaged, recordDialogCalls (whose close returns focus like a browser) the role query resolves to the active sheet's dialog (the retained one is aria-hidden)
"keeps the previous sheet stationary until the new entrance finishes, then fades it" dialog[open] count 1 → [details, confirm] during the handoff, [confirm] after; shared identity → active identity; retained dialog asserted inert two dialogs are open through a handoff by design
"replaces an unfinished outgoing sheet during rapid navigation" count 1 → 2; asserts a third showModal() the returning sheet re-presents
"ignores late scrim updates from an outgoing sheet gesture" scrim variable read from the retained (owner) dialog; entering dialog asserted 0 scrim ownership moved to the retained dialog
"dismisses the flow from the one shared scrim", "releases the shared modal layer…", "can coordinate a non-modal flow…" body.style.position → isScrollLockEngaged(); titles removed body pin
"keeps focus in a modal sheet that has no tabbable controls" now "focuses the panel … and leaves Tab to the native modal dialog": asserts the panel is focused, the dialog was presented with showModal() and is aria-modal, and that no script prevents Tab the old assertion (keyDown Tab returns false) tested the JS trap's preventDefault, which jsdom was the only place to observe; the behaviour moves to BottomSheetNativeModal.a11y.browser.spec.ts ("Tab stays inside the active sheet"), which passes in Chromium and WebKit
"forwards … to the shared dialog", "keeps the shared dialog inline…" titles and helper name only wording

New 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 Core Dialog and released only with the sheet; native cancel by purpose with preventDefault always; backdrop click by purpose); 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 Core Dialog opened from a step with the Dialog presented on top; ref movement; 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; cancel by the active sheet's purpose after a handoff; cancel/backdrop click on the active dialog only; non-modal show() 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 the main build the lock test fails as expected (root overflow stays visible).

Records touched

BottomSheetSwitcher.spec.md and BottomSheet.spec.md are draft records. 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 key Shared dialog → Sheet dialogs, verified_by). No decision was added; the decision logs are unchanged.

Writing rubric results

  • Present tense, describes the system: yes.
  • No proposals, history, pull requests, or research inside the records: yes (this PR body carries them).
  • Authority unchanged: draft stays draft; no claim was promoted.
  • Ownership boundary still names a single owner per fact: yes (dialog lifecycle, scrim ownership, flow focus return → switcher; the dialog element and the scroll-lock rule → BottomSheet; panel anatomy → BottomSheet).
  • Verification map names a real test per contract: yes (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; native cancel routed through shouldDismissOnCloseRequest(); existing Layer suites pass unedited.
  • architecture:layer-runtime and spec:AST-027 — native showModal() hosting; the non-modal show() + z-index path remains the recorded gap.
  • spec:AST-002 / architecture:public-component-api — props, defaults, and exports unchanged; ref semantics 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, plus closeKeepingFocus, 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, new sheetScrollLock.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/main and on this branch; .d.ts files and the exports map diffed. exports map identical. BottomSheet/index.d.ts and the root index.d.ts byte-identical. BottomSheet.d.ts and BottomSheetSwitcher.d.ts differ only in doc comments and the destructuring pattern in the declared signature (no type change). BottomSheetSwitcherContext.d.ts changes and sheetScrollLock.d.ts is 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 main and of this branch, stories hug-content, capped, no-scrim, and the standalone showcase). 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 probe showModal() dialog, and after close.

  • Lock: scrollY 300 before, during (wheel, drag, chaining, mid-handoff, under a probe dialog), and after; root overflow hidden exactly while a sheet dialog is modal, including the exit motion; <body> carries no inline style; viewport width 390→390. main: body pinned, scrollY reads 0, restored to 300 by script on release.
  • Scrim through every switch, both directions: 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.
  • Focus: panel → panel on modal switches; opener after Escape, scrim tap, swipe; non-modal switches land on the new panel (body on main).
  • Tab ×10 in a modal step never reaches the page or the retained dialog in either engine.
  • Escape, scrim tap, swipe on step 2 close the flow; the scrim tap is a no-op in no-scrim.
  • Rapid reversal: 4 native calls, dim 0.5 throughout, focus on the returning panel.
  • Probe showModal() dialog during a handoff hit-tests on top in both engines.
  • No horizontal overflow; no console errors or warnings on either build in either engine.
  • Settled screenshots pixel-identical between builds in both engines (24 of 24 settled pairs); mid-handoff frames differ by 0–5% of pixels from animation timing.
  • Pre-existing, not a delta: a process-cold WebKit leaves the first backdrop's entrance transition at 0.94–0.97 opacity when the story's play function opens the flow at page load — on main and 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 matches main or 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) closeKeepingFocus on a retained dialog's close, (4) the re-present on reversal, and (5) purpose gating of cancel plus the shared dismissal stack — items 1–4 exist only because two dialogs stand in for one sheet; item 5 exists on main too. The open risk is iOS Safari's handling of root overflow: hidden for touch, which this environment cannot measure; if a device check fails there, useScrollLock would return for sheets and the rest of this PR still stands.

Scope

  • This PR has one primary intent.
  • Independently removable deltas were removed or split.
  • Public text and artifacts contain no internal Meta context.

Testing

pnpm exec vitest run packages/core/src/{BottomSheet,Dialog,AlertDialog,Selector,DropdownMenu,ContextMenu,DateInput,DateTimeInput,TimeInput,Tokenizer,Layer,Layout,CommandPalette,MoreMenu,Popover,Lightbox,MobileNav}/ packages/lab/src/MobileTokenizer/
  main:   Test Files 85 passed, Tests 2451 passed
  branch: Test Files 86 passed, Tests 2471 passed   (+20 new, 1 new file)
  existing suites unedited against the new code: 6 failed (all structural, listed above), 574 passed
pnpm -F @astryxdesign/core typecheck                      ✓
ASTRYX_STRICT_LINT=1 pnpm exec eslint packages/core/src/BottomSheet/   0 errors (1 pre-existing warning in an untouched spec)
pnpm check:sync · check:knowledge · check:package-boundaries · check:changesets   ✓
node scripts/sync-exports.js --check                      ✓
git diff --check                                          ✓
pnpm exec vitest run --project node scripts/build-css.test.mjs   11 passed (Core rebuilt on the branch first)
node scripts/release/changeset-coverage.mjs pr --base origin/main --head HEAD --released v0.7.1   ✓ ships to @astryxdesign/core, each named
pnpm storybook:build                                      ✓ (main and branch)
pnpm exec playwright test packages/core/src/BottomSheet/__tests__/ --project chromium   43 passed (38 existing + 5 new)
pnpm exec playwright test … BottomSheetNativeModal, BottomSheetFocusReturn, BottomSheetKeyboard --project webkit   35 passed (30 existing + 5 new)

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.
@vercel

vercel Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Oct 20, 2026 12:47am UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 20, 2026
@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Oct 20, 2026
@astracat-bot

astracat-bot Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
❓ Needs maintainer input (for maintainers only) Oct 20, 2026, 4:41 AM UTC

@github-actions

Copy link
Copy Markdown
Contributor

PR Analysis Report

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

No new or modified components detected.

Bundle Size Summary

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

Accessibility Audit

Status: No accessibility violations detected.

Visual Regression

40 of 482 shot(s) changed.

A change here is a question, not a failure: check whether the after is the
picture you intended. Record that review on the PR. Baseline maintenance is an
explicit dispatch of CI; this report never rewrites the baseline or adds a release gate.

component story theme mode pixels
Selector Indicator Space Bottom Sheet Narrow End RTL neutral light 754,077
Selector Indicator Space Bottom Sheet Wide Start neutral light 740,032
Chat Full AI Chat probe light 230,823
Chat Full AI Chat probe dark 229,805
RichTextEditor Markdown parity: side by side probe dark 94,866
RichTextEditor Markdown parity: side by side probe light 89,762
Collapsible Dividers probe dark 66,445
Collapsible Dividers probe light 66,445
Chat Default neutral dark 36,304
Chat Default neutral light 35,767
Chat Empty State probe light 23,531
Chat Empty State probe dark 22,622
ChatAutoScroll Scroll Behavior Comparison neutral dark 15,378
ChatAutoScroll Scroll Behavior Comparison neutral light 14,107
Chat With Attachments probe light 13,406
Chat With Attachments probe dark 11,232
ProgressBar With Target Mark probe dark 7,895
ProgressBar With Target Mark probe light 7,895
Markdown Default neutral dark 7,573
Markdown Default neutral light 7,566

and 20 more.


Generated by PR Enrichment workflow | View full report

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval 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: stable in sheetScrollLock.ts reserves 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 id is duplicated, and assignRef drops React 19 callback-ref cleanups (utils/mergeRefs.ts already handles them).
  • The non-modal path adds a z-index 1001 band, which spec:AST-027 FR1 and FR4 rule out.
  • spec:AST-013 FR10 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}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This 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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

This branch was successfully deployed

1 active deployment
Preview — 13fab344 Deployed Oct 20, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot. needs:design-review Affects visuals — Design should review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant