Skip to content

fix(BottomSheet): a sheet removed while open returns focus where a close would - #10061

Merged
cixzhang merged 8 commits into
mainfrom
fix/bottomsheet-unmount-returns-focus
Nov 5, 2026
Merged

cixzhang merged 8 commits into
mainfrom
fix/bottomsheet-unmount-returns-focus

Conversation

@cixzhang

@cixzhang cixzhang commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Refs #8963

User impact

A BottomSheet that is removed while it is open drops focus to <body>. That happens when the app renders the sheet only while open ({isEditing && <BottomSheet isOpen …>}), or removes it with the rest of a view. Keyboard and screen-reader users then lose their place: the next Tab starts from the top of the page. After this change, focus goes where a close would send it: to finalFocusRef, else the control that opened the sheet, or into the sheet beneath.

Problem and solution fit

  1. Why does it occur? Removing a <dialog> runs no close and no browser focus return. BottomSheet's own focus return runs only when a close's exit ends. On unmount, the registry only takes the dialog out of the top layer (closeDialogKeepingFocus).
  2. Why does that harm the task? Focus on <body> means the person must find their place again.
  3. Why does that matter? WCAG 2.2 SC 2.4.3, under spec:AST-020 FR1. Dialog already restores focus in this case, as does Lightbox.

Fix: a removal while open is a close for focus. The stack registration prepares the same repair Dialog uses. Its cleanup picks the target before the registry lets the sheet go. After the commit, the repair acts only if the sheet really left the document and the removal lost focus. Then:

  • a sheet open beneath gets focus by FR9: the opener when it is inside that sheet, else that sheet's panel;
  • otherwise focus goes to finalFocusRef, else the opener, while it is still connected.

A sheet that took focus in the same commit (an opening sheet, a switcher step) keeps it, because focus is not lost. A covered sheet removed under a top sheet that stays moves nothing, because focus is in the top sheet. A whole stack removed at once returns focus to the first sheet's finalFocusRef or opener, as FR9 does for a stack closed together. When no target is left on the page (the opener removed with the sheet), focus stays where the browser leaves it. Focus the person had already moved out of the sheet to the page body (they clicked the page behind a non-modal sheet) stays where it is, even with a finalFocusRef: the removal did not lose that focus (FR20's condition). The shared repair decides this through a new keepRestingFocus option on prepareFocusRepairOnRemoval. It reads whether focus rested on the body at the commit's first removal, because one sheet's removal can drop focus before a later sibling's cleanup runs. Dialog and Lightbox do not set it.

utils/focusReturn.ts splits prepareFocusReturnOnRemoval into a general prepareFocusRepairOnRemoval(surface)(repair) plus the opener return Dialog and Lightbox already use. Their behavior is unchanged.

Expected behavior and authority

  • component:BottomSheet FR20 (new, from the specification owner's ruling): "A sheet removed while open is closed for focus…", with FR9 and FR11 for where focus goes.
  • AR4 now names FR20.
  • spec:AST-020 FR1 (WCAG 2.2 SC 2.4.3).

Smallest restoration

  • BottomSheet.tsx: the removal repair, prepared in the existing stack registration. No new Effect.
  • utils/focusReturn.ts: the shared repair split out, plus the opt-in keepRestingFocus read. No behavior change for Dialog or Lightbox.
  • BottomSheetCloseFocus.test.tsx: a "removed while open" suite.
  • BottomSheet.spec.md: FR20, its verification row, and the AR4 cross-reference.
  • A Storybook story, RenderedWhileOpen, for the reproduction.
  • A patch changeset.

Evidence

Real Chromium (headless), story core-bottomsheet--rendered-while-open, keyboard only: Tab to "Edit filters", Enter, Tab to "Done", Enter (which removes the open sheet).

Sheet main (same story) This pull request
Modal (scrim) focus on BODY focus on "Edit filters"
Non-modal focus on BODY focus on "Edit filters"
Non-modal, opened by click, page behind clicked (focus on BODY), then Escape removes it focus on BODY focus on BODY (unchanged)

Scope

  • One defect is restored.
  • Separable contradictory or unsettled tagalongs were removed or split.
  • No new public API, visual representation, default, or interaction model is hidden under the bug-fix label.
  • Public text and artifacts contain no internal Meta context.

This fixes standalone BottomSheets only. A BottomSheetSwitcher flow removed while open still returns no focus; FR20 records that as an implementation gap, and it is a separate follow-up. This fixes only the BottomSheet part of #8963. Dialog (with AlertDialog and CommandPalette) and Lightbox are already fixed, and Drawer has its own pull request. No gesture or motion changes.

Testing

  • New suite "BottomSheet removed while open": 10 cases. On main, 6 fail: a modal and a non-modal removed from inside, finalFocusRef, the top sheet of a stack, and a whole stack nested or as siblings declared top first. The other 4 guard cases pass on main: a covered sheet removed under a top sheet that stays, a sheet opening in the same update, an opener removed with the sheet, and focus that rested on the body before a non-modal sheet was removed (that one fails on this PR's previous head). The nested whole-stack case also fails on this PR's first head, and the sibling case on its second.
  • vitest run packages/core/src/BottomSheet: BottomSheet, Dialog, Lightbox and utils: 77 files, 1792 tests passed.
  • pnpm -F @astryxdesign/core typecheck, eslint, Prettier, pnpm check:changesets, pnpm check:knowledge.

Writing rubric results

Run over the BottomSheet.spec.md changes (FR20, including its whole-stack and covered-sheet sentences, its verification row, the AR4 sentence):

  • R1 narration: none. FR20 states what the sheet does.
  • R2 research: none in the record.
  • R3 evidence: the verification row names the test suite and the states, not a change.
  • R4 rejected alternatives: none.
  • R5 change references: none.
  • R6 open questions: none added.

@vercel

vercel Bot commented Nov 5, 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 Nov 5, 2026 11:43am UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Nov 5, 2026
@astracat-bot

astracat-bot Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Nov 5, 2026, 11:55 AM UTC

@cixzhang

cixzhang commented Nov 5, 2026

Copy link
Copy Markdown
Contributor Author

/approve-spec 156b994

@github-actions

github-actions Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

🔎 Preview · 2de4225

Storybook · Sandbox · built from this PR's head

Modified components

Component Size LoC Visual · A11y · RTL
BottomSheet 56.6 kB (+246 B) · 7.5 kB CSS (0) — ✓ · ✓ · —
TimeInput 140.8 kB (+230 B) · 14.5 kB CSS (0) — — · — · —
DateTimeInput 185.6 kB (+194 B) · 17.2 kB CSS (0) — — · — · —
62 more components
Component Size LoC Visual · A11y · RTL
DateInput 163.7 kB (+188 B) · 15.5 kB CSS (0) — — · — · —
ContextMenu 83.0 kB (+174 B) · 16.3 kB CSS (0) — — · — · —
PowerSearch 260.2 kB (+168 B) · 22.6 kB CSS (0) — — · — · —
lab/Schedule 63.9 kB (+166 B) · 3.9 kB CSS (0) — — · — · —
TableCell 84.2 kB (+155 B) · 16.7 kB CSS (0) — — · — · —
TableHeaderCell 83.8 kB (+155 B) · 16.5 kB CSS (0) — — · — · —
Markdown 153.7 kB (+149 B) · 19.2 kB CSS (0) — — · — · —
Table 92.4 kB (+147 B) · 16.9 kB CSS (0) — — · — · —
DropdownMenu 77.9 kB (+132 B) · 16.4 kB CSS (0) — — · — · —
MoreMenu 78.2 kB (+129 B) · 16.4 kB CSS (0) — — · — · —
lab/CommentComposer 109.0 kB (+120 B) · 1.9 kB CSS (0) — — · — · —
lab/TourStep 61.3 kB (+116 B) · 590 B CSS (0) — — · — · —
MultiSelector 80.3 kB (+111 B) · 15.6 kB CSS (0) — — · — · —
Selector 83.2 kB (+111 B) · 16.2 kB CSS (0) — — · — · —
Pagination 93.2 kB (+110 B) · 16.6 kB CSS (0) — — · — · —
lab/ChatEmojiPicker 69.1 kB (+101 B) · 773 B CSS (0) — — · — · —
lab/ChatReactionBar 69.6 kB (+101 B) · 1.1 kB CSS (0) — — · — · —
Popover 55.4 kB (+99 B) · 12.8 kB CSS (0) — — · — · —
Tokenizer 74.8 kB (+98 B) · 17.1 kB CSS (0) — — · — · —
CommandPalette 58.7 kB (+96 B) · 12.3 kB CSS (0) — — · — · —
DateRangeInput 75.9 kB (+93 B) · 11.2 kB CSS (0) — — · — · —
lab/TransferListSelector 87.1 kB (+91 B) · 1.9 kB CSS (0) — — · — · —
Dialog 22.3 kB (+83 B) · 6.7 kB CSS (0) — — · — · —
Drawer 41.4 kB (+82 B) · 8.2 kB CSS (0) — — · — · —
AlertDialog 90.2 kB (+73 B) · 13.1 kB CSS (0) — — · — · —
Lightbox 55.0 kB (+73 B) · 9.1 kB CSS (0) — — · — · —
lab/Comments 273.6 kB (+71 B) · 2.5 kB CSS (0) — — · — · —
lab/CommentThread 268.5 kB (+69 B) · 2.4 kB CSS (0) — — · — · —
ContextMenuItem 25.9 kB (+60 B) · 6.1 kB CSS (0) — — · — · —
richtext/RichTextEditorToolbar 234.0 kB (+55 B) · 454 B CSS (0) — — · — · —
DropdownMenuRadioItem 21.1 kB (+5 B) · 6.4 kB CSS (0) — — · — · —
TopNavMenu 61.0 kB (+5 B) · 9.9 kB CSS (0) — — · — · —
SideNavHeading 61.7 kB (+4 B) · 10.0 kB CSS (0) — — · — · —
Collapsible 24.6 kB (+1 B) · 4.5 kB CSS (0) — — · — · —
lab/CommentPin 48.5 kB (+1 B) · 1.2 kB CSS (0) — — · — · —
AppShell 76.3 kB (0) · 12.2 kB CSS (0) — — · — · —
BottomSheetSwitcher 17.4 kB (0) · 3.4 kB CSS (0) — ✓ · ✓ · —
BreadcrumbItem 71.5 kB (0) · 11.3 kB CSS (0) — — · — · —
ChatComposer 63.3 kB (0) · 10.5 kB CSS (0) — — · — · —
ChatComposerInput 61.3 kB (0) · 10.0 kB CSS (0) — — · — · —
ChatComposerTokenElement 61.3 kB (0) · 10.0 kB CSS (0) — — · — · —
ComplexSelector 57.6 kB (0) · 10.4 kB CSS (0) — — · — · —
DropdownMenuCheckboxItem 21.0 kB (0) · 6.4 kB CSS (0) — — · — · —
DropdownMenuItem 20.3 kB (0) · 6.0 kB CSS (0) — — · — · —
DropdownMenuSubMenu 59.7 kB (0) · 9.3 kB CSS (0) — — · — · —
LayerProvider 64.5 kB (0) · 9.4 kB CSS (0) — — · — · —
LayoutPanel 20.5 kB (0) · 4.9 kB CSS (0) — — · — · —
SideNav 76.3 kB (0) · 10.1 kB CSS (0) — — · — · —
SideNavItem 56.7 kB (0) · 9.8 kB CSS (0) — — · — · —
TableSelectionToolbar 56.4 kB (0) · 11.8 kB CSS (0) — — · — · —
TabList 35.8 kB (0) · 6.3 kB CSS (0) — — · — · —
Toolbar 35.2 kB (0) · 7.8 kB CSS (0) — — · — · —
TopNavHeading 61.1 kB (0) · 9.8 kB CSS (0) — — · — · —
TopNavMegaMenu 56.2 kB (0) · 10.6 kB CSS (0) — — · — · —
TreeList 40.8 kB (0) · 5.4 kB CSS (0) — — · — · —
Typeahead 65.1 kB (0) · 11.2 kB CSS (0) — — · — · —
AvatarGroup 37.8 kB (−1 B) · 5.5 kB CSS (0) — — · — · —
BaseTypeahead 57.2 kB (−1 B) · 9.6 kB CSS (0) — — · — · —
ButtonGroup 9.1 kB (−1 B) · 3.4 kB CSS (0) — — · — · —
NavHeadingMenu 15.4 kB (−1 B) · 3.5 kB CSS (0) — — · — · —
SegmentedControl 83.9 kB (−2 B) · 6.7 kB CSS (0) — — · — · —
TabMenu 58.3 kB (−7 B) · 9.8 kB CSS (0) — — · — · —

Package size: @astryxdesign/core 6.57 MB packed (+1.3 kB); no new dependencies.

API changes: none in this pull request

Committed API report behind this build: cli 9 rows, core 4 rows; run pnpm api:report.

Touched components checked; add ci:run-visual, ci:run-a11y or ci:run-rtl to run the full suites.


Generated by PR Enrichment workflow | View full report

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changes requested. Removing a single open sheet now returns focus to finalFocusRef, the opener or the sheet beneath, and the new tests fail on main and pass here. Three things need to change before approval:

  • A stack removed in one update (Filters with Sort opened from it, removed by a button in Sort) still leaves focus on the page body, because the cleanup returns early for the covered sheet at BottomSheet.tsx:563; dropping || stackViewRef.current.depth > 0 lets the focus-lost check return focus to the first opener, as FR9 does for a stack closed together, and the BottomSheet suite still passes with that change.
  • The changeset's "Focus no longer drops to the page" claims more than the change does, since the new "moves no focus when the opener was removed too" test expects focus on the body, so please say that focus returns while its target is still on the page.
  • The removal repair is a second layout Effect that works only while declared above the registration Effect, so please prepare it at registration, choose the target in that cleanup before the registry's leave runs, and remove the separate Effect.

Please also add a whole-stack case to the suite and to FR19's verification row, and limit FR19's covered-sheet sentence to a top sheet that stays. A new head needs a fresh /approve-spec from an engineering owner.

[Automated review]

const key = stackKeyRef.current;
const repairOnRemoval = prepareFocusRepairOnRemoval(dialog);
return () => {
if (!isSheetHostPresented(dialog) || stackViewRef.current.depth > 0) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

When a whole stack is removed in one update, this early return skips the covered (bottom) sheet. The top sheet's opener was inside that sheet, so focus stays on the body. Dropping || stackViewRef.current.depth > 0 lets prepareFocusRepairOnRemoval's focus-lost check decide. A covered sheet removed under a top sheet that stays still moves nothing, because focus is in the top sheet. A removed stack returns focus to the bottom sheet's finalFocusRef or opener, as FR9 does for a stack closed together.

// that took focus in the same commit (a replace, a switcher step) keeps it.
// Declared before the registration below, so its cleanup still sees this
// sheet in the stack.
useLayoutEffect(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This Effect is correct only while it sits above the registration Effect. Below it, the registry's leave would already have closed the dialog and dropped the entry, so the repair would never run. Prepare the repair when the sheet registers, and choose the target in that cleanup before calling the leave function. That removes the extra Effect and its order dependency.

'@astryxdesign/core': patch
---

[fix] BottomSheet: a sheet removed while it is open (rendered only while open, or removed with the rest of a view) returns focus where a close would send it: to `finalFocusRef`, else the control that opened it, or into the sheet beneath. Focus no longer drops to the page, so keyboard and screen-reader users keep their place. A sheet that opens in the same update keeps focus, and removing a covered sheet moves no focus.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

"Focus no longer drops to the page" claims more than the change does: the new "moves no focus when the opener was removed too" test expects focus on the body. Suggested wording: focus returns where a close would send it while that target is still on the page. When none remains, as when the opener is removed with the sheet, focus stays on the page.

Comment on lines +550 to +552
// close for focus: focus goes where an ordinary close sends it. A covered
// sheet moves no focus, because focus is in the sheet on top, and a sheet
// that took focus in the same commit (a replace, a switcher step) keeps it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (comment style): this sentence has 31 words and two topics. Suggested: "A covered sheet moves no focus. Focus is in the sheet on top. A sheet that took focus in the same commit keeps it."

…a whole stack removed at once returns focus to its first opener
@cixzhang

cixzhang commented Nov 5, 2026

Copy link
Copy Markdown
Contributor Author

/approve-spec 5f21650

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changes requested. Removing an open sheet now returns focus to finalFocusRef, the opener or the sheet beneath, the changes requested earlier are in, and the new cases fail on main and pass here. One thing needs to change before approval:

  • When sibling sheets are declared top sheet first and the top sheet has a finalFocusRef outside the bottom sheet, removing both in one update sends focus to that finalFocusRef instead of the bottom sheet's opener as FR19 states, because the top sheet's repair runs first and falls through when the sheet beneath has also left (BottomSheet.tsx:629-639); return early when revealed is no longer connected, and add a sibling-order whole-stack case to the suite.

Optionally, split the 26-word comment at BottomSheet.tsx:561-562. A new head needs a fresh /approve-spec from an engineering owner.

[Automated review]

Comment on lines +629 to +639
repairOnRemoval(() => {
if (
revealed != null &&
revealed.element.isConnected &&
(returnTarget == null || !revealed.element.contains(returnTarget))
) {
revealed.focus();
} else if (returnTarget != null && returnTarget.isConnected) {
returnFocusForLastInput(returnTarget);
}
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

When a whole stack is removed and the top sheet is declared before the bottom sheet (siblings), this repair runs first: revealed is the bottom sheet, which has also left, so it falls through to this sheet's own finalFocusRef, and the bottom sheet's repair then finds focus no longer lost. FR19 says the whole stack returns to the bottom sheet's finalFocusRef or opener. Returning early when revealed != null && !revealed.element.isConnected leaves the return to the bottom sheet's repair; the BottomSheet suite still passes with it. Please add a sibling, top-declared-first case with a finalFocusRef on the top sheet.

Comment on lines +561 to +562
// A sheet removed while open is closed for focus: once the commit has
// finished, focus goes where a close sends it, if the removal lost focus.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (comment style): this sentence has 26 words and two topics. Suggested: "After the commit, the repair sends focus where a close would, if the removal lost focus."

@cixzhang

cixzhang commented Nov 5, 2026

Copy link
Copy Markdown
Contributor Author

/approve-spec 3486879

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changes requested. Removing an open standalone sheet now returns focus to finalFocusRef, the opener or the sheet beneath, the sibling-stack case from the last round is fixed, and the new cases fail on main and pass here. One thing needs to change before approval:

  • Miss: FR19 (BottomSheet.spec.md:170) and the changeset describe every removed sheet and stack, but a BottomSheetSwitcher flow removed while open, alone or as the bottom level of a stack (FR12), still leaves focus on body because only StandaloneBottomSheet arms the repair, so either scope FR19 and the changeset to standalone sheets with a gap note for the switcher flow, or give the switcher's registration cleanup (BottomSheetSwitcher.tsx:690-699) the same repair and a test.

A new head needs a fresh /approve-spec from an engineering owner.

[Automated review]

| FR16 | An open modal sheet is a focus region through Core's shared focus-region module (`component:Dialog/DEC-2`): in the top sheet, Tab from the last tabbable control moves focus to the first and Shift+Tab from the first moves focus to the last, so focus never leaves the sheet for the browser's own interface. A covered or closing sheet does not answer Tab, and a non-modal sheet contains nothing (FR11). A switcher step (the deprecated BottomSheetSwitcher) is the exception to DEC-2, as `component:Dialog` DEC-2 states. | `component:Dialog/DEC-2` |
| FR17 | A BottomSheet open on the server, by its own `isOpen` or as a `BottomSheetSwitcher`'s active sheet, renders closed until hydration (no open styles, no `aria-modal`, the markup of a closed sheet) and opens in the render after hydration, the first that can enter its native host; it never claims a modality it does not have (`spec:AST-020` FR1, WCAG 2.2 SC 4.1.2). The server and hydrating renders produce the same markup (`spec:AST-013` FR9), and the open after hydration is settled, with no entry motion; later opens and closes animate as usual (`spec:AST-013` FR13). | `spec:AST-020` FR1 |
| FR18 | A finger's pull up past the tallest detent stretches the sheet on `spec:AST-094` FR3's rubber band, the same on every pull, with no hold. The sheet's surface reaches below the screen far enough for the whole stretch and the release, so the page under it never shows. A pull on the sheet's body stretches it as a pull on the grabber does, once the content has no room left to scroll that way: content that fits stretches at once, and a long list scrolls to its end first, then holds still while the sheet stretches. At every detent, a list that reaches its end mid-pull holds still while the sheet takes the rest of the pull. A sheet the caller sizes or styles keeps 48 px below the screen, and its stretch stops there. An inline block size, any `xstyle` and any class name count, because the sheet cannot read their rules. A mouse or a pen keeps its own resisted stretch, at most 48 px (`spec:AST-017` FR59). | DEC-3; `spec:AST-094` FR3 |
| FR19 | A sheet removed while open is closed for focus: when it was the standalone or top sheet and focus was inside it, or fell to the body with its removal, focus returns as FR9 (modal) or FR11 (non-modal) state, to `finalFocusRef`, else the opener while it is still connected; with a sheet open beneath, focus goes into that revealed sheet by FR9. An FR10 replace keeps its handoff to the entering sheet, and a switcher step's change keeps FR12's handoff. Removing a whole stack in one update returns focus once, as FR9 closes it: to the bottom sheet's `finalFocusRef` or opener. Removing a covered sheet under a top sheet that stays moves no focus, because focus is in the top sheet. A removed opener moves no focus and raises no error. | `spec:AST-020/FR1` (WCAG 2.2 SC 2.4.3); FR9; FR11 |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Miss: this row covers every sheet removed while open and every stack, and it names switcher steps, but only StandaloneBottomSheet arms the repair. A BottomSheetSwitcher flow removed while open, alone or as the bottom level of a stack (FR12), still leaves focus on body. Scope the row to standalone sheets, for example "A standalone sheet (not a switcher step) removed while open …", with an implementation gap note for the switcher flow, or give BottomSheetSwitcher's registration cleanup the same repair and a test.

'@astryxdesign/core': patch
---

[fix] BottomSheet: a sheet removed while it is open (rendered only while open, or removed with the rest of a view) returns focus where a close would send it, while that place is still on the page: to `finalFocusRef`, else the control that opened it, or into the sheet beneath. A whole stack removed at once returns focus to the first sheet's `finalFocusRef` or opener. When none of these is left on the page, for example when the opener is removed with the sheet, focus stays where the browser leaves it. A sheet that opens in the same update keeps focus, and removing a covered sheet under a top sheet that stays moves no focus.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Miss: a <BottomSheet sheetId> step in a BottomSheetSwitcher that is removed while open still leaves focus on body. Suggested wording, unless the switcher gets the same repair: "BottomSheet: a standalone sheet removed while it is open (rendered only while open, or removed with the rest of a view) returns focus …".

@cixzhang

cixzhang commented Nov 5, 2026

Copy link
Copy Markdown
Contributor Author

/approve-spec f951384

@astracat-bot astracat-bot Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changes requested. Removing an open standalone sheet now returns focus to finalFocusRef, the opener or the sheet beneath, FR19 and the changeset now name standalone sheets, and six of the nine new cases fail on main and pass here. One thing needs to change before approval:

  • Miss: a non-modal sheet (hasScrim={false}) rendered only while open, removed after the person clicked the page behind it and pressed Escape, now moves focus from body to its opener, which a close of the same sheet does not do and which FR19 ("focus was inside it, or fell to the body with its removal") and FR11 ("only while focus is still inside the sheet") rule out, so skip the opener return when focus already rests on the body before the removal (BottomSheet.tsx:626-627) and add that case to the suite, or state that behavior in FR19 and the changeset.

A new head needs a fresh /approve-spec from an engineering owner.

[Automated review]

Comment on lines +625 to +627
if (repairOnRemoval != null && isSheetHostPresented(dialog)) {
const returnTarget =
finalFocusRefRef.current?.current ?? triggerRef.current;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Miss: for a non-modal sheet this returns focus to the opener even when focus already rested on the page body before the removal (the person clicked the live page, then pressed Escape). isFocusLost is also true for that body focus, so the repair runs, while a close of the same sheet leaves focus alone (FR11, and the close path's hasScrim || hadFocusAtCloseRef.current at line 754). Suggested: read here, before leave(), whether focus already rests on the body, and skip the opener return for a non-modal sheet when it does, with a test; or state this behavior in FR19 and the changeset.

@cixzhang

cixzhang commented Nov 5, 2026

Copy link
Copy Markdown
Contributor Author

/approve-spec ca21aa3

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changes requested. Removing an open standalone sheet now returns focus to finalFocusRef, the opener or the sheet beneath, and the non-modal case from the last round is fixed: focus that already rested on the page body stays there. Two things in the new commit need to change before approval:

  • BottomSheet.tsx:308-323 adds isDetachedFocus, a copy of isFocusDetached, and module-level state to decide whether a removal lost focus, which prepareFocusRepairOnRemoval in utils/focusReturn.ts already decides, so move that before-removal read into focusReturn.ts (for example an option that records isFocusDetached(doc) at the commit's first removal) and let the cleanup make one call.
  • A non-modal sheet with finalFocusRef that is removed after the person clicked the page and pressed Escape now leaves focus on body, while closing the same sheet focuses finalFocusRef (FR11), so the changeset's "returns focus where a close would send it" and the comment at BottomSheet.tsx:646-648 overstate the change; add FR19's condition to both (for example "Focus the person had already moved out of a non-modal sheet stays where it is") or keep finalFocusRef in that case and add a test.

CI has not run on this draft yet, and a new head needs a fresh /approve-spec from an engineering owner.

[Automated review]

Comment on lines +308 to +323
const focusAtRemovalStart = new WeakMap<Document, Element | null>();
function focusBeforeRemoval(doc: Document): Element | null {
if (!focusAtRemovalStart.has(doc)) {
focusAtRemovalStart.set(doc, doc.activeElement);
queueMicrotask(() => focusAtRemovalStart.delete(doc));
}
return focusAtRemovalStart.get(doc) ?? null;
}

function isDetachedFocus(element: Element | null): boolean {
return (
element == null ||
element === element.ownerDocument.body ||
element === element.ownerDocument.documentElement
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

isDetachedFocus repeats the three checks of isFocusDetached (utils/isFocusDetached.ts) on a stored element, and focusAtRemovalStart keeps module-level state for whether the removal lost focus, which prepareFocusRepairOnRemoval decides. Suggested: record isFocusDetached(doc) once per document at the commit's first removal inside focusReturn.ts (an option Dialog and Lightbox leave off), so this cleanup makes one call and these helpers go.

Comment on lines +646 to +648
// Read before the registry lets go of the sheet beneath. Focus that
// already rested on the page body did not fall with the removal, so it
// stays, as a close leaves it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

"As a close leaves it" holds only for a non-modal sheet without finalFocusRef: a non-modal close focuses finalFocusRef wherever focus is (FR11), and for a modal sheet the native close in leave() returns focus to the opener. Suggested: "Focus already on the page body did not fall with the removal. The repair leaves it."

'@astryxdesign/core': patch
---

[fix] BottomSheet: a standalone sheet (not a `BottomSheetSwitcher` step) removed while it is open (rendered only while open, or removed with the rest of a view) returns focus where a close would send it, while that place is still on the page: to `finalFocusRef`, else the control that opened it, or into the sheet beneath. A whole stack of standalone sheets removed at once returns focus to the first sheet's `finalFocusRef` or opener. When none of these is left on the page, for example when the opener is removed with the sheet, focus stays where the browser leaves it. A sheet that opens in the same update keeps focus, and removing a covered sheet under a top sheet that stays moves no focus.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

With a non-modal sheet and finalFocusRef, focus that rested on the page body before the removal now stays there, while closing the same sheet focuses finalFocusRef (FR11). Suggested addition: "Focus the person had already moved out of a non-modal sheet stays where it is."

@cixzhang

cixzhang commented Nov 5, 2026

Copy link
Copy Markdown
Contributor Author

/approve-spec d3f7d67

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approved pending CI: CI has not run on this head yet. Every CI job is skipped while the pull request is a draft, so CI runs once it is marked ready for review. Removing an open standalone sheet now returns focus to finalFocusRef, the opener or the sheet beneath, and both points from the last round are fixed: the shared prepareFocusRepairOnRemoval now owns the check for focus already resting on the page body, and the changeset says such focus stays where it is. The new tests fail on main and pass here.

[Automated review]

@cixzhang
cixzhang marked this pull request as ready for review November 5, 2026 11:09
@cixzhang

cixzhang commented Nov 5, 2026

Copy link
Copy Markdown
Contributor Author

/approve-spec 6876fbe

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approved pending CI: the required build, test and docsite-test checks report the heavy-CI deferral on this head, so those suites have not run yet; they run on the pre-merge re-run.

A standalone BottomSheet removed while open now returns focus to finalFocusRef, the opener or the sheet beneath, as FR20 states, and the new removed-while-open cases fail on main and pass here. Numbering the rule FR20 keeps it clear of the FR19 proposed in #10055.

[Automated review]

@astracat-bot astracat-bot Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approved pending CI: the heavy test, build, interaction, visual-regression and accessibility jobs are still running on this head, so this approval does not cover their results.

A standalone BottomSheet removed while open now returns focus to finalFocusRef, the opener or the sheet beneath, as FR20 states, and the new removed-while-open cases fail on main and pass with this change. The merge from main leaves this pull request's own changes untouched.

[Automated review]

@cixzhang

cixzhang commented Nov 5, 2026

Copy link
Copy Markdown
Contributor Author

/approve-spec 2de4225

@cixzhang
cixzhang merged commit 8fdf6be into main Nov 5, 2026
102 of 105 checks passed
@github-actions
github-actions Bot deleted the fix/bottomsheet-unmount-returns-focus branch November 6, 2026 06:04

This branch was successfully deployed

1 active deployment
Preview — 2de42250 Deployed Nov 5, 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.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant