From e187573792c3f17a3d46cc0ae0044ddc0fb75f22 Mon Sep 17 00:00:00 2001 From: xarmian Date: Mon, 20 Apr 2026 07:35:23 -0400 Subject: [PATCH] feat(web): add reusable BottomSheet primitive (TASK-627) (#158) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): add reusable BottomSheet primitive (TASK-627) Introduce $lib/components/common/BottomSheet.svelte — a controlled bottom-sheet / modal component built on Svelte 5 runes with no new runtime dependencies. Features: - Mobile (< 640px): docked to bottom, full-width, rounded top corners, swipe-down-to-dismiss via pointer events (80px threshold). - Desktop (>= 640px): configurable via `desktopMode` prop — 'sheet' (default, bottom-anchored with max-width) or 'centered' (traditional centered dialog mirroring the existing .overlay/.modal pattern). - Escape key, backdrop tap, and swipe-down all trigger onclose(). - Focus trap while open; restores focus on close to the previously focused element. - Body scroll lock while open, safely restored on close/unmount. - Svelte `fly` + `fade` transitions; honors prefers-reduced-motion. - role="dialog", aria-modal="true", optional `title` wired to aria-labelledby. No consumers yet — foundation for [[IDEA-493]] (quick-actions mobile fix and inline New/Manage affordances will consume this in TASK-628 and TASK-629). Parent: IDEA-493. * fix(web): harden BottomSheet focus trap + scroll lock per Codex review - Focus trap: forward Tab now also pulls focus back when active is outside the sheet (assistive tech / programmatic focus change), mirroring the Shift+Tab branch. Without this, focus escaping the sheet broke modal isolation on subsequent Tab presses. - Scroll lock: moved to module-level counter + shared prev-overflow via acquireScrollLock(). Stacked sheets no longer clobber each other — the original body overflow is captured on the first open and restored only when the last sheet closes. Addresses both P2 comments on PR #158. * fix(web): topmost-only Escape + hydration-safe IDs in BottomSheet - Escape / Tab trap now gated to the topmost open sheet only. Added a module-level open-sheet stack (symbol tokens) so stacked instances can identify which one should handle global keyboard events. One Escape keypress no longer closes every open sheet at once. - Replaced Math.random() heading ID with $props.id() (Svelte 5.20+) so the aria-labelledby target is stable across SSR and hydration. Addresses P1 (stacked Escape) and P2 (SSR hydration mismatch) from Codex review round 2 on PR #158. * fix(web): tie BottomSheet z-index to open-stack position Convert the module-level openStack to a Svelte 5 $state array so each instance can reactively read its position in the stack. Compute backdrop + sheet z-index from that position (BASE_Z 61, two slots per stack level) and apply via inline style, replacing the fixed CSS z-index values. This keeps visual stacking aligned with the keyboard-topmost logic (pushOpenSheet / isTopmostSheet) — if a sheet renders earlier in the DOM but opens later, its visual layer now matches its logical topmost role instead of being obscured by an older DOM sibling. Addresses Codex round 3 P2 on PR #158. * fix(web): skip BottomSheet focus restore when another sheet is open In stacked-sheet scenarios, closing a non-topmost sheet would still run previouslyFocused.focus() in the focus-management effect cleanup, yanking focus out of the active dialog and onto background UI. Gate the restoration on: 1. openStack contains no tokens other than this instance's token (no other sheet is still open), AND 2. document.activeElement is not already inside a different role="dialog" ancestor. If either check fails, skip the restore — another sheet is still in control of focus. Addresses Codex round 4 P2 on PR #158. * fix(web): refine BottomSheet focus restore for stacked topmost close Round 4's blanket skip-when-others-open was too aggressive. When the topmost sheet closes while another sheet remains behind it, the previouslyFocused target typically lives inside that remaining sheet (it was the active element when this sheet opened) — restoring it is correct and keeps focus inside the remaining modal. New rule: - If no other sheets open → always restore (normal case). - If others are still open → restore only when previouslyFocused lives inside a DIFFERENT still-open dialog (not this closing one). Otherwise skip, so we don't yank focus onto background UI. Addresses Codex round 5 P2 on PR #158. --- .../lib/components/common/BottomSheet.svelte | 499 ++++++++++++++++++ 1 file changed, 499 insertions(+) create mode 100644 web/src/lib/components/common/BottomSheet.svelte diff --git a/web/src/lib/components/common/BottomSheet.svelte b/web/src/lib/components/common/BottomSheet.svelte new file mode 100644 index 000000000..83e132626 --- /dev/null +++ b/web/src/lib/components/common/BottomSheet.svelte @@ -0,0 +1,499 @@ + + + + + + + +{#if open} +
{}} + role="presentation" + style:z-index={backdropZ} + >
+ + +{/if} + +