From 316e25a6cacbc2aec4f359fd08af5d231bada5cc Mon Sep 17 00:00:00 2001 From: xarmian Date: Wed, 22 Jul 2026 17:32:31 -0400 Subject: [PATCH] fix(web): trap focus in BottomSheet so ESC/Tab hit the sheet, not the layer under it (BUG-2130) (#1006) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(web): trap focus in BottomSheet so ESC/Tab hit the sheet, not the layer under it (BUG-2130) BottomSheet is a role="dialog" aria-modal mobile sheet but, unlike the native- Modal.svelte, it never moved focus into itself or trapped Tab. Two consequences, app-wide (most visible over the mobile split-pane): - ESC closed the wrong layer: focus stayed on the trigger outside the sheet, so a window-level ESC handler underneath (e.g. the collection page's pane-close) fired first and closed THAT instead of the sheet. - Tab escaped the sheet into the obscured content behind it. Fix in the shared component, mirroring Modal.svelte's behavior: - Move focus onto the panel (tabindex=-1) on open; restore focus to the trigger on close and on teardown-while-open. - Trap Tab/Shift+Tab within the sheet, reusing the pane's already-tested trap math (paneFocusables + nextTrapTarget from paneFocus.ts) so the two focus traps can't drift. The focus effect reads only `open`/`sheetEl` and writes the non-reactive `previouslyFocused`, so it can't self-invalidate (CONVE-1688). Surgical over a native- rebuild: 11 consumers make the blast radius large, and the bug is scoped to the shared component. Converging BottomSheet onto the Modal primitive is a separate, larger refactor. Adds BottomSheet.svelte.test.ts (focus-in, Tab/Shift+Tab wrap, Escape, backdrop, focus-restore). Verified: full web suite (471) green, svelte-check clean, Codex CLEAN, and a real mobile-browser drive (focus-in, Tab + Shift+Tab trapped, ESC closes only the sheet with the item pane surviving, focus restored to the trigger). Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): only the frontmost BottomSheet handles Escape/Tab (nested sheets) Codex PR review caught an adjacent facet of the same layer-isolation bug: every open BottomSheet registers a window-level Escape/Tab handler, so when one sheet opens another (Quick Actions sheet → the mobile emoji picker's sheet, both role="dialog" BottomSheets, the inner DOM-nested in the outer), a single Escape fired both handlers and closed BOTH layers. Gate each sheet's handler on being the frontmost (innermost) open sheet: a nested child sheet renders inside our content, so a sheet that CONTAINS another open `.bs-sheet` is not frontmost and stays out. Order-independent by design — a defaultPrevented/stopPropagation check can't work here because the outer sheet's window listener is registered first and fires before the inner's. Verified at runtime (mobile): open Quick Actions → New quick action → the emoji-picker button opens a nested sheet; one Escape now closes only the picker (Quick Actions survives), a second closes Quick Actions. Adds a nested-sheet unit test. Full web suite 472 green, svelte-check clean. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): generalize BottomSheet frontmost gate to sibling sheets too Follow-up to the nested-sheet fix: replace the descendant-only guard with a document-wide frontmost check so the "only the topmost sheet handles Escape/Tab" rule also holds for sibling sheets (two open overlays where neither DOM-contains the other). A sheet that contains a deeper open sheet is never frontmost; among the remaining leaf sheets the last in document order paints on top at the shared z-index, so it wins. Recomputed per keydown, so order-independent. Two full-screen overlays can't both be reached by the user today (opening one covers every other trigger), so this hardens a currently-unreachable topology rather than fixing a live repro — but it makes the invariant total and closes the Codex review's remaining finding. The single-sheet path short-circuits to frontmost=true, so the verified primary behavior is unchanged (re-verified at runtime: single-sheet focus-in/trap/Escape/restore + nested one-layer-per-Esc both still green). Adds a sibling-topology unit test. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra --- .../lib/components/common/BottomSheet.svelte | 88 ++++++++ .../common/BottomSheet.svelte.test.ts | 192 ++++++++++++++++++ 2 files changed, 280 insertions(+) create mode 100644 web/src/lib/components/common/BottomSheet.svelte.test.ts diff --git a/web/src/lib/components/common/BottomSheet.svelte b/web/src/lib/components/common/BottomSheet.svelte index b3bc449e..6c948a7c 100644 --- a/web/src/lib/components/common/BottomSheet.svelte +++ b/web/src/lib/components/common/BottomSheet.svelte @@ -18,6 +18,7 @@ --> @@ -51,6 +138,7 @@