tera-system-ui 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/components/bottom-sheet/BottomSheet.recipe.d.ts +20 -0
  2. package/dist/components/bottom-sheet/BottomSheet.svelte +113 -4
  3. package/dist/components/bottom-sheet/bottomSheetStore.svelte.d.ts +28 -1
  4. package/dist/components/bottom-sheet/bottomSheetStore.svelte.js +60 -39
  5. package/dist/components/combobox/Combobox.svelte +6 -0
  6. package/dist/components/date-range-picker/DateRangePicker.svelte +14 -1
  7. package/dist/components/dialog/Dialog.svelte +15 -2
  8. package/dist/components/drawer/Drawer.svelte +23 -1
  9. package/dist/components/dropdown-menu/components/DropdownMenu.svelte +7 -0
  10. package/dist/components/popover/Popover.svelte +8 -1
  11. package/dist/components/select/Select.svelte +14 -2
  12. package/dist/components/toast/ToastContainer.svelte +8 -1
  13. package/dist/components/tooltip/Tooltip.recipe.js +6 -1
  14. package/dist/hooks/index.d.ts +4 -0
  15. package/dist/hooks/index.js +3 -0
  16. package/dist/hooks/overlayLayer.svelte.d.ts +43 -0
  17. package/dist/hooks/overlayLayer.svelte.js +74 -0
  18. package/dist/hooks/overlayStack.svelte.d.ts +59 -0
  19. package/dist/hooks/overlayStack.svelte.js +107 -0
  20. package/dist/llms/bottom-sheet.md +2 -0
  21. package/dist/tokens/index.d.ts +35 -0
  22. package/dist/tokens/index.js +34 -0
  23. package/package.json +6 -1
  24. package/registry/bottom-sheet.json +50 -50
  25. package/registry/combobox.json +2 -2
  26. package/registry/date-range-picker.json +2 -2
  27. package/registry/dialog.json +1 -1
  28. package/registry/drawer.json +2 -2
  29. package/registry/dropdown-menu.json +2 -2
  30. package/registry/popover.json +2 -2
  31. package/registry/select.json +2 -2
  32. package/registry/toast.json +1 -1
  33. package/registry/tooltip.json +1 -1
@@ -119,6 +119,26 @@ export interface BottomSheetProps {
119
119
  * its open lifetime and removes it on close. A page that sets its own tint is left untouched.
120
120
  */
121
121
  syncThemeColor?: boolean;
122
+ /**
123
+ * Prevent the browser's native pull-to-refresh / overscroll-reload from hijacking a downward
124
+ * drag that starts on an interactive element inside the sheet. The two-phase gesture only
125
+ * claims the touch (`preventDefault`) once the drag commits past its activation threshold, so a
126
+ * *slow* pull can let the browser start pull-to-refresh first (a quick flick clears the
127
+ * threshold immediately and does not). When true (the default) the sheet pins
128
+ * `overscroll-behavior-y: contain` on the document root for its open lifetime — reference-counted
129
+ * across stacked sheets, the page's original value restored on close. Reliable on Android and
130
+ * iOS 16+. Set false if the host app manages page overscroll itself.
131
+ */
132
+ preventPullToRefresh?: boolean;
133
+ /**
134
+ * Whether pressing the browser Back button closes the sheet. Default true (matching Dialog).
135
+ *
136
+ * While open, the sheet pushes a dummy history entry (same URL); Back pops it and closes the
137
+ * sheet instead of navigating. This is also what makes "Back closes the TOPMOST overlay" work
138
+ * across types: entries are LIFO, so a sheet opened over a Dialog takes the Back press rather
139
+ * than letting it close the dialog underneath — but only because the sheet pushes one too.
140
+ */
141
+ closeOnNavigateBack?: boolean;
122
142
  /** Called after the sheet finishes closing. */
123
143
  onClose?: () => void;
124
144
  /** Called whenever the open state changes. */
@@ -1,7 +1,49 @@
1
+ <script module lang="ts">"use strict";
2
+ // ============================================
3
+ // PAGE-ROOT OVERSCROLL GUARD (native pull-to-refresh)
4
+ // ============================================
5
+ // The two-phase drag doesn't preventDefault() until a gesture commits past the ~10px
6
+ // activation threshold, so a SLOW downward pull that STARTS on an interactive element
7
+ // (button / input / link / [tabindex]) leaves a window where the browser can claim the
8
+ // gesture as native pull-to-refresh / overscroll-reload before the sheet does — which is
9
+ // why only a *slow* pull triggers it (a quick flick clears the threshold on the first
10
+ // touchmove and preventDefault()s first). `overscroll-behavior-y: contain` on the document
11
+ // root closes that window for the sheet's whole open lifetime WITHOUT touching the gesture
12
+ // logic. Shared + reference-counted across every mounted sheet so stacked sheets apply it
13
+ // once and the page's original inline value is restored only when the last sheet releases.
14
+ let overscrollGuardCount = 0;
15
+ let savedRootOverscrollY = '';
16
+ let savedBodyOverscrollY = '';
17
+ function acquireOverscrollGuard() {
18
+ if (typeof document === 'undefined')
19
+ return;
20
+ if (overscrollGuardCount === 0) {
21
+ const root = document.documentElement;
22
+ const { body } = document;
23
+ savedRootOverscrollY = root.style.overscrollBehaviorY;
24
+ savedBodyOverscrollY = body.style.overscrollBehaviorY;
25
+ // Set both: browsers disagree on whether html or body is the root scroller.
26
+ root.style.overscrollBehaviorY = 'contain';
27
+ body.style.overscrollBehaviorY = 'contain';
28
+ }
29
+ overscrollGuardCount++;
30
+ }
31
+ function releaseOverscrollGuard() {
32
+ if (typeof document === 'undefined' || overscrollGuardCount === 0)
33
+ return;
34
+ overscrollGuardCount--;
35
+ if (overscrollGuardCount === 0) {
36
+ document.documentElement.style.overscrollBehaviorY = savedRootOverscrollY;
37
+ document.body.style.overscrollBehaviorY = savedBodyOverscrollY;
38
+ }
39
+ }
40
+ </script>
41
+
1
42
  <script lang="ts">import { onDestroy, onMount } from 'svelte';
2
43
  import { bottomSheetStore } from './bottomSheetStore.svelte.js';
3
44
  import { bottomSheetRecipe } from './BottomSheet.recipe';
4
45
  import { IconX } from "../../internal/icons";
46
+ import { closeOnBackNavigation, overlayStack } from '../../hooks';
5
47
  // ============================================
6
48
  // CONFIGURABLE CONSTANTS
7
49
  // Adjust these values to customize the bottom sheet behavior
@@ -42,6 +84,7 @@ const CONFIG = {
42
84
  lockedDragResistance: 0.3, // Resistance when dragToClose disabled
43
85
  // Two-phase gesture recognition for interactive elements (iOS-style)
44
86
  dragActivationThreshold: 10, // Vertical px to commit to drag from interactive element
87
+ pullClaimThreshold: 3, // Vertical px to CLAIM the gesture (preventDefault) BEFORE commit, so iOS Safari can't start pull-to-refresh during a slow pull. Below dragActivationThreshold; tune on-device.
45
88
  horizontalDeadZone: 1.2, // If abs(dx)/abs(dy) > this, abort drag (let native horizontal win)
46
89
  tapDistanceThreshold: 6 // Max total movement (px) to still count as a tap
47
90
  }
@@ -49,7 +92,7 @@ const CONFIG = {
49
92
  // ============================================
50
93
  // COMPONENT PROPS
51
94
  // ============================================
52
- let { id, open = $bindable(false), snapPoints = [0.9], defaultSnapPoint = 0, containerRef = null, scaleTargetRef = null, constrainToContainer = false, scaleBackground = true, dragToClose = true, closeOnBackdrop = true, showCloseButton = false, syncThemeColor = true, onClose, onOpenChange, children, header } = $props();
95
+ let { id, open = $bindable(false), snapPoints = [0.9], defaultSnapPoint = 0, containerRef = null, scaleTargetRef = null, constrainToContainer = false, scaleBackground = true, dragToClose = true, closeOnBackdrop = true, showCloseButton = false, syncThemeColor = true, preventPullToRefresh = true, closeOnNavigateBack = true, onClose, onOpenChange, children, header } = $props();
53
96
  // ============================================
54
97
  // INTERNAL STATE
55
98
  // ============================================
@@ -82,6 +125,10 @@ let scaleTargetPrimed = false; // static scale-target styles set once per open c
82
125
  // background) for the sheet's open lifetime and remove it on close; a page that sets its own
83
126
  // theme-color is left alone — Safari honors it, so the backdrop can't flip it.
84
127
  let themeColorMeta = null;
128
+ // Whether THIS sheet currently holds the shared page-root overscroll guard (see the module
129
+ // script). Kept per-instance so acquire/release stay balanced — restore is called from both
130
+ // the close completion and onDestroy, and must be a no-op the second time.
131
+ let heldOverscrollGuard = false;
85
132
  // Drag tracking
86
133
  let dragState = {
87
134
  startY: 0,
@@ -127,6 +174,17 @@ const allSnapPoints = $derived([0, ...snapPoints].sort((a, b) => a - b));
127
174
  // ============================================
128
175
  // LIFECYCLE
129
176
  // ============================================
177
+ // Browser Back closes the sheet, so a sheet opened over a Dialog takes the Back press
178
+ // instead of letting it close the dialog underneath (history entries are LIFO, so this
179
+ // resolves "topmost wins" on its own — but only once every modal pushes one).
180
+ // Setting `open = false` (rather than doClose()) keeps the single close path in the
181
+ // `open` effect; the hook's teardown then fires at the END of the close spring, where
182
+ // `poppedByBack` correctly suppresses the compensating history.back().
183
+ closeOnBackNavigation({
184
+ enabled: () => closeOnNavigateBack,
185
+ isOpen: () => open,
186
+ close: () => { open = false; },
187
+ });
130
188
  onMount(() => {
131
189
  bottomSheetStore.register(id, { snapPoints, defaultSnapPoint, closeOnBackdrop });
132
190
  window.addEventListener('resize', handleWindowResize);
@@ -139,6 +197,7 @@ onDestroy(() => {
139
197
  containerResizeObserver?.disconnect();
140
198
  cancelAnimations();
141
199
  restoreThemeColor();
200
+ restoreOverscrollGuard();
142
201
  });
143
202
  // Motion is driven by direct writes (drag) and Web Animations (open/close/snap), so keep
144
203
  // the recipe's CSS `transition` OFF on these elements — otherwise the CSS transition would
@@ -405,6 +464,7 @@ function doOpen() {
405
464
  isClosing = false;
406
465
  backdropPressPointerId = null; // fresh open — ignore any stale backdrop press id
407
466
  applyThemeColor(); // hold the iOS status bar at the page color before the backdrop darkens it
467
+ applyOverscrollGuard(); // stop the page pull-to-refresh from hijacking a slow drag off an interactive element
408
468
  bottomSheetStore.open(id);
409
469
  requestAnimationFrame(() => {
410
470
  // sheetRef is bound by this rAF tick.
@@ -433,6 +493,7 @@ function animateSheetClose(initialVelocity) {
433
493
  open = false;
434
494
  resetScaleTarget();
435
495
  restoreThemeColor();
496
+ restoreOverscrollGuard();
436
497
  onClose?.();
437
498
  onOpenChange?.(false);
438
499
  }, true); // hideOnArrival — finalize as soon as it's offscreen, no spring tail
@@ -536,6 +597,24 @@ function restoreThemeColor() {
536
597
  themeColorMeta?.remove();
537
598
  themeColorMeta = null;
538
599
  }
600
+ /**
601
+ * Guard the page against native pull-to-refresh while this sheet is open (see the module
602
+ * script). Balanced + idempotent per instance so the shared reference count stays correct
603
+ * even though restore fires from both the close completion and onDestroy.
604
+ */
605
+ function applyOverscrollGuard() {
606
+ if (!preventPullToRefresh || heldOverscrollGuard)
607
+ return;
608
+ heldOverscrollGuard = true;
609
+ acquireOverscrollGuard();
610
+ }
611
+ /** Release this sheet's hold on the page overscroll guard (no-op if not held). */
612
+ function restoreOverscrollGuard() {
613
+ if (!heldOverscrollGuard)
614
+ return;
615
+ heldOverscrollGuard = false;
616
+ releaseOverscrollGuard();
617
+ }
539
618
  // ============================================
540
619
  // GESTURE UTILITIES
541
620
  // ============================================
@@ -805,7 +884,26 @@ function handleTouchMove(e) {
805
884
  dragState.isInteractiveStart = false;
806
885
  return;
807
886
  }
808
- // Still pending do nothing, let native behavior continue
887
+ // Still pending (under the commit threshold). Claim a downward, vertical-leaning pull
888
+ // NOW so iOS Safari can't start its native pull-to-refresh during this slow pre-commit
889
+ // window — overscroll-behavior does NOT stop Safari's UA refresh; only preventDefault
890
+ // does. Stay PENDING (the sheet doesn't start moving until the >=10px commit), but deny
891
+ // the browser the gesture. Skip when a scroll region under the finger should scroll
892
+ // instead (pulling down while not at its top), and skip horizontal-leaning moves so
893
+ // native sliders keep working. Touch-only: desktop has no pull-to-refresh.
894
+ const claimDx = Math.abs(clientX - dragState.startX);
895
+ const claimDy = Math.abs(clientY - dragState.startY);
896
+ if (clientY - dragState.startY > 0 && claimDy >= CONFIG.gesture.pullClaimThreshold && claimDy >= claimDx) {
897
+ let claimGesture = true;
898
+ if (dragState.inScrollable) {
899
+ if (scrollableElement)
900
+ isScrolledToTop = scrollableElement.scrollTop <= 1;
901
+ if (!isScrolledToTop)
902
+ claimGesture = false; // let the content scroll natively
903
+ }
904
+ if (claimGesture && e.cancelable)
905
+ e.preventDefault();
906
+ }
809
907
  return;
810
908
  }
811
909
  // ── Normal drag logic (committed) ──
@@ -1004,8 +1102,19 @@ function handleBackdropClick(e) {
1004
1102
  return;
1005
1103
  doClose();
1006
1104
  }
1105
+ /**
1106
+ * Escape — CAPTURE phase (see the `onkeydowncapture` binding below), which is load-bearing.
1107
+ *
1108
+ * Topmost is resolved across EVERY overlay type, not just among sheets: a Dialog opened
1109
+ * from inside this sheet should take the keypress alone. But bits-ui listens on `document`
1110
+ * while this listens on `window`, so on the bubble path bits-ui always runs FIRST — and
1111
+ * Svelte flushes effects synchronously at the end of its handler, dropping the dialog from
1112
+ * the stack. This handler would then find ITSELF topmost and close too: one Escape, both
1113
+ * gone. Capturing at window means we run before anything else can mutate the stack, so the
1114
+ * check reads the state as it was when the key was pressed.
1115
+ */
1007
1116
  function handleKeydown(e) {
1008
- if (e.key === 'Escape' && isVisible && bottomSheetStore.isTopmost(id)) {
1117
+ if (e.key === 'Escape' && isVisible && overlayStack.isTopmost(id)) {
1009
1118
  e.preventDefault();
1010
1119
  doClose();
1011
1120
  }
@@ -1025,7 +1134,7 @@ function touchEvents(node) {
1025
1134
  }
1026
1135
  </script>
1027
1136
 
1028
- <svelte:window onkeydown={handleKeydown}/>
1137
+ <svelte:window onkeydowncapture={handleKeydown}/>
1029
1138
 
1030
1139
  {#if isVisible}
1031
1140
  <!-- Backdrop -->
@@ -6,6 +6,28 @@
6
6
  * sheet is topmost (for Escape handling). Sheets register on mount and push/pop
7
7
  * themselves from the stack as they open/close. Exposed publicly so apps can
8
8
  * drive sheets programmatically (open/close/closeTop) in addition to `bind:open`.
9
+ *
10
+ * ## Now a SHEET-SCOPED VIEW over the shared overlay stack
11
+ *
12
+ * This store used to be the library's only runtime z authority, which is exactly why an
13
+ * overlay opened from inside a sheet (a `z-50` dialog vs. the sheet's 1001) painted behind
14
+ * it. Layering now lives in the shared `overlayStack` (`$lib/hooks`), which every
15
+ * portal-owning overlay registers with; this store keeps sheet-specific concerns (config,
16
+ * drag progress, container refs) and PROJECTS the shared stack down to just the sheets.
17
+ *
18
+ * The public API is unchanged. `stack` / `getStack` / `isTopmost` / `closeTop` /
19
+ * `hasOpenSheets` / `getCombinedBackdropOpacity` still mean "among bottom sheets".
20
+ *
21
+ * Two behavior notes:
22
+ * - `getZIndex` now returns a z resolved against ALL overlays, not just sheets. Ordering
23
+ * between sheets is unchanged (still base + 10 per stacked sheet).
24
+ * - `close()` no longer reindexes the survivors, so a sheet's z can stay higher than its
25
+ * position implies after a sheet below it closes. Relative ORDER is still exact; only the
26
+ * absolute number differs. Reindexing live entries collides with close animations, which
27
+ * paint for ~260ms after the entry is gone.
28
+ *
29
+ * For "topmost across ALL overlay types" (what Escape should use), read
30
+ * `overlayStack.isTopmost(id)` directly — `BottomSheet.svelte` does.
9
31
  */
10
32
  export interface BottomSheetConfig {
11
33
  id: string;
@@ -20,7 +42,12 @@ export interface SheetStackItem {
20
42
  config: BottomSheetConfig;
21
43
  }
22
44
  export declare const bottomSheetStore: {
23
- readonly stack: SheetStackItem[];
45
+ readonly stack: {
46
+ id: string;
47
+ zIndex: number;
48
+ progress: number;
49
+ config: BottomSheetConfig;
50
+ }[];
24
51
  readonly hasOpenSheets: boolean;
25
52
  register: (id: string, config: Omit<BottomSheetConfig, "id">) => void;
26
53
  unregister: (id: string) => void;
@@ -6,16 +6,54 @@
6
6
  * sheet is topmost (for Escape handling). Sheets register on mount and push/pop
7
7
  * themselves from the stack as they open/close. Exposed publicly so apps can
8
8
  * drive sheets programmatically (open/close/closeTop) in addition to `bind:open`.
9
+ *
10
+ * ## Now a SHEET-SCOPED VIEW over the shared overlay stack
11
+ *
12
+ * This store used to be the library's only runtime z authority, which is exactly why an
13
+ * overlay opened from inside a sheet (a `z-50` dialog vs. the sheet's 1001) painted behind
14
+ * it. Layering now lives in the shared `overlayStack` (`$lib/hooks`), which every
15
+ * portal-owning overlay registers with; this store keeps sheet-specific concerns (config,
16
+ * drag progress, container refs) and PROJECTS the shared stack down to just the sheets.
17
+ *
18
+ * The public API is unchanged. `stack` / `getStack` / `isTopmost` / `closeTop` /
19
+ * `hasOpenSheets` / `getCombinedBackdropOpacity` still mean "among bottom sheets".
20
+ *
21
+ * Two behavior notes:
22
+ * - `getZIndex` now returns a z resolved against ALL overlays, not just sheets. Ordering
23
+ * between sheets is unchanged (still base + 10 per stacked sheet).
24
+ * - `close()` no longer reindexes the survivors, so a sheet's z can stay higher than its
25
+ * position implies after a sheet below it closes. Relative ORDER is still exact; only the
26
+ * absolute number differs. Reindexing live entries collides with close animations, which
27
+ * paint for ~260ms after the entry is gone.
28
+ *
29
+ * For "topmost across ALL overlay types" (what Escape should use), read
30
+ * `overlayStack.isTopmost(id)` directly — `BottomSheet.svelte` does.
9
31
  */
10
- const BASE_Z_INDEX = 1000;
11
- const Z_INDEX_INCREMENT = 10;
32
+ import { SvelteMap } from 'svelte/reactivity';
33
+ import { overlayStack } from '../../hooks/overlayStack.svelte.js';
34
+ import { layer } from '../../tokens/index.js';
35
+ const BASE_Z_INDEX = layer.base;
12
36
  function createBottomSheetStore() {
13
- // Stack of active sheets (bottom to top)
14
- let sheetStack = $state([]);
37
+ // SvelteMap, not `$state(new Map())`: `$state` proxies the object but NOT a Map's
38
+ // internals, so `.set()` on a plain $state Map notifies nobody — the `sheetStack`
39
+ // derived below would never see a config or progress change.
15
40
  // Registry of all sheet configs
16
- let registry = $state(new Map());
17
- // Container refs for scale effect
18
- let containerRefs = $state(new Map());
41
+ const registry = new SvelteMap();
42
+ // Drag progress per sheet (0-1). Written ~120×/s during a drag; `sheetStack` is lazy, so
43
+ // this costs one signal write per frame unless something actually reads the stack.
44
+ const progressById = new SvelteMap();
45
+ // Container refs for scale effect. Plain Map — only ever read imperatively.
46
+ const containerRefs = new Map();
47
+ // The shared stack, projected down to just the sheets — bottom to top, in the shared
48
+ // stack's own order, so a sheet opened above another still reads as being above it.
49
+ const sheetStack = $derived(overlayStack.stack
50
+ .filter(item => item.kind === 'bottom-sheet' && registry.has(item.id))
51
+ .map(item => ({
52
+ id: item.id,
53
+ zIndex: item.zIndex,
54
+ progress: progressById.get(item.id) ?? 0,
55
+ config: registry.get(item.id)
56
+ })));
19
57
  /**
20
58
  * Register a sheet with its config
21
59
  */
@@ -28,41 +66,27 @@ function createBottomSheetStore() {
28
66
  function unregister(id) {
29
67
  registry.delete(id);
30
68
  containerRefs.delete(id);
69
+ progressById.delete(id);
31
70
  // Remove from stack if present
32
- sheetStack = sheetStack.filter(s => s.id !== id);
71
+ overlayStack.remove(id);
33
72
  }
34
73
  /**
35
74
  * Open a sheet by ID
36
75
  */
37
76
  function open(id) {
38
- const config = registry.get(id);
39
- if (!config) {
77
+ if (!registry.has(id)) {
40
78
  // Sheet was not registered (component not mounted yet) — ignore.
41
79
  return;
42
80
  }
43
- // Check if already in stack
44
- if (sheetStack.some(s => s.id === id)) {
45
- return;
46
- }
47
- // Calculate z-index based on stack position
48
- const zIndex = BASE_Z_INDEX + (sheetStack.length * Z_INDEX_INCREMENT);
49
- sheetStack = [...sheetStack, {
50
- id,
51
- zIndex,
52
- progress: 0,
53
- config
54
- }];
81
+ // push() is idempotent — an already-open sheet keeps its z rather than jumping.
82
+ overlayStack.push(id, 'bottom-sheet');
55
83
  }
56
84
  /**
57
85
  * Close a sheet by ID
58
86
  */
59
87
  function close(id) {
60
- sheetStack = sheetStack.filter(s => s.id !== id);
61
- // Recalculate z-indices for remaining sheets
62
- sheetStack = sheetStack.map((sheet, index) => ({
63
- ...sheet,
64
- zIndex: BASE_Z_INDEX + (index * Z_INDEX_INCREMENT)
65
- }));
88
+ overlayStack.remove(id);
89
+ progressById.delete(id);
66
90
  }
67
91
  /**
68
92
  * Close the topmost sheet
@@ -77,27 +101,25 @@ function createBottomSheetStore() {
77
101
  * Update progress for a sheet (0-1)
78
102
  */
79
103
  function updateProgress(id, progress) {
80
- const index = sheetStack.findIndex(s => s.id === id);
81
- const sheet = sheetStack[index];
82
- if (sheet) {
83
- sheet.progress = Math.max(0, Math.min(1, progress));
84
- }
104
+ if (!overlayStack.has(id))
105
+ return;
106
+ progressById.set(id, Math.max(0, Math.min(1, progress)));
85
107
  }
86
108
  /**
87
109
  * Get z-index for a sheet
88
110
  */
89
111
  function getZIndex(id) {
90
- const sheet = sheetStack.find(s => s.id === id);
91
- return sheet?.zIndex ?? BASE_Z_INDEX;
112
+ return overlayStack.getZIndex(id) ?? BASE_Z_INDEX;
92
113
  }
93
114
  /**
94
115
  * Check if a sheet is open
95
116
  */
96
117
  function isOpen(id) {
97
- return sheetStack.some(s => s.id === id);
118
+ return overlayStack.has(id);
98
119
  }
99
120
  /**
100
- * Check if a sheet is the topmost
121
+ * Check if a sheet is the topmost SHEET. For "topmost across every overlay type"
122
+ * (Escape, close-top), use `overlayStack.isTopmost(id)` instead.
101
123
  */
102
124
  function isTopmost(id) {
103
125
  return sheetStack[sheetStack.length - 1]?.id === id;
@@ -106,8 +128,7 @@ function createBottomSheetStore() {
106
128
  * Get progress for a sheet
107
129
  */
108
130
  function getProgress(id) {
109
- const sheet = sheetStack.find(s => s.id === id);
110
- return sheet?.progress ?? 0;
131
+ return progressById.get(id) ?? 0;
111
132
  }
112
133
  /**
113
134
  * Attach container ref for scale effect
@@ -3,9 +3,14 @@ import { comboboxRecipe } from './Combobox.recipe';
3
3
  import { cn } from '../../utils/utils.js';
4
4
  import { IconCheck, IconChevronDown } from '../../internal/icons';
5
5
  import IconX from '@tabler/icons-svelte/icons/x';
6
+ import { overlayLayer, withZIndex } from '../../hooks';
6
7
  let { type, items = [], value = $bindable(undefined), placeholder = 'Search…', disabled, loop, allowDeselect, onchange, class: className, ref = $bindable(null), name, required, scrollAlignment, freeInput = false, checkIcon, chevronIcon, clearIcon, } = $props();
7
8
  let searchText = $state('');
8
9
  let open = $state(false);
10
+ // Shared stacking authority — a combobox opened inside a Dialog/BottomSheet must land
11
+ // above it, which its hardcoded z-50 could not do.
12
+ const layer = overlayLayer({ isOpen: () => open, kind: 'menu' });
13
+ const contentZIndex = $derived(layer.zIndex === undefined ? undefined : layer.zIndex + 1);
9
14
  const filteredItems = $derived(searchText.trim()
10
15
  ? items.filter(i => i.label.toLowerCase().includes(searchText.trim().toLowerCase()))
11
16
  : items);
@@ -118,6 +123,7 @@ const r = $derived(comboboxRecipe({ open, disabled }));
118
123
  sideOffset={4}
119
124
  customAnchor={ref}
120
125
  class={r.content({class: 'min-w-[var(--bits-combobox-anchor-width)]'})}
126
+ style={withZIndex(undefined, contentZIndex)}
121
127
  >
122
128
  <ComboboxPrimitive.Viewport class={r.viewport()}>
123
129
  {#each filteredItems as item (item.value)}
@@ -8,6 +8,7 @@ import { cn } from '../../utils/utils.js';
8
8
  import { untrack } from 'svelte';
9
9
  import { innerWidth } from 'svelte/reactivity/window';
10
10
  import { Dialog } from '../dialog/index.js';
11
+ import { overlayLayer, withZIndex } from '../../hooks';
11
12
  import { dateRangePickerRecipe } from './DateRangePicker.recipe.js';
12
13
  let { dateRange = $bindable([]), onchange, enableTime = false, placeholder = 'Select date range', disabled = false, class: className, minValue, maxValue, isDateDisabled, numberOfMonths: numberOfMonthsProp, weekStartsOn, calendarIcon, clearIcon, prevIcon, nextIcon, closeIcon, } = $props();
13
14
  const styles = dateRangePickerRecipe();
@@ -181,6 +182,12 @@ const iconBtnStyles = styles.iconBtn();
181
182
  const segmentStyles = styles.segment();
182
183
  const dayStyles = styles.day();
183
184
  const popoverContentStyles = styles.panel();
185
+ // Shared stacking authority — gated on the DESKTOP branch only. On mobile this component
186
+ // renders a real <Dialog>, which registers itself; a second entry here would own no
187
+ // element to receive z (the `panel` slot only exists on the desktop popover) while still
188
+ // consuming a stack slot and shifting every later overlay's depth for nothing.
189
+ const layer = overlayLayer({ isOpen: () => open && isDesktop, kind: 'popover' });
190
+ const panelZIndex = $derived(layer.zIndex === undefined ? undefined : layer.zIndex + 1);
184
191
  </script>
185
192
 
186
193
  <!-- ─── Shared snippets ──────────────────────────────────────────────────── -->
@@ -378,7 +385,13 @@ const popoverContentStyles = styles.panel();
378
385
  </PopoverPrimitive.Trigger>
379
386
 
380
387
  <PopoverPrimitive.Portal>
381
- <PopoverPrimitive.Content sideOffset={4} align="start" class={popoverContentStyles}>
388
+ <PopoverPrimitive.Content
389
+ sideOffset={4}
390
+ align="start"
391
+ class={popoverContentStyles}
392
+ style={withZIndex(undefined, panelZIndex)}
393
+ escapeKeydownBehavior={layer.isTopmost ? 'close' : 'ignore'}
394
+ >
382
395
  <div class="flex">
383
396
  <!-- Quick-select sidebar -->
384
397
  <div class={styles.presetSidebar()}>
@@ -2,8 +2,14 @@
2
2
  import { dialogRecipe } from './Dialog.recipe';
3
3
  import { IconX } from '../../internal/icons';
4
4
  import { cn } from '../../utils/index.js';
5
- import { closeOnBackNavigation } from '../../hooks';
5
+ import { closeOnBackNavigation, overlayLayer, withZIndex } from '../../hooks';
6
6
  let { children, open = $bindable(false), ref = $bindable(null), closeOnClickOutside = true, closeOnNavigateBack = true, closeButton = true, size = 'sm', header, footer, class: className, position = 'center', padding, staticRender = false, preventScroll, onOpenChangeComplete, triggerRef, focusTriggerAfterClose = true, containerClass, headerClass, bodyClass, footerClass, closeIcon, ...props } = $props();
7
+ // Registers with the shared stacking authority so a dialog opened from INSIDE another
8
+ // layered surface (the canonical case: a BottomSheet at z 1000/1001) lands above it
9
+ // instead of losing the race with its own hardcoded z-50. Undefined until first open, so
10
+ // SSR / never-opened dialogs keep the recipe's class exactly as before.
11
+ const layer = overlayLayer({ isOpen: () => open, kind: 'dialog' });
12
+ const contentZIndex = $derived(layer.zIndex === undefined ? undefined : layer.zIndex + 1);
7
13
  const isFull = $derived(size === 'full');
8
14
  const r = $derived(dialogRecipe({
9
15
  size,
@@ -27,7 +33,7 @@ closeOnBackNavigation({
27
33
  <DialogPrimitive.Root bind:open onOpenChangeComplete={onOpenChangeComplete}>
28
34
  <DialogPrimitive.Portal>
29
35
  <!-- Backdrop -->
30
- <DialogPrimitive.Overlay class={r.overlay()} />
36
+ <DialogPrimitive.Overlay class={r.overlay()} style={withZIndex(undefined, layer.zIndex)} />
31
37
 
32
38
  <!--
33
39
  We use the `child` snippet (bits-ui "asChild" pattern) so that bits-ui gates its
@@ -40,12 +46,19 @@ closeOnBackNavigation({
40
46
  forceMount={staticRender || undefined}
41
47
  interactOutsideBehavior={closeOnClickOutside ? 'close' : 'ignore'}
42
48
  preventScroll={preventScroll}
49
+ escapeKeydownBehavior={layer.isTopmost ? 'close' : 'ignore'}
43
50
  onCloseAutoFocus={handleCloseAutoFocus}
44
51
  {...props}
45
52
  >
46
53
  {#snippet child({ props: contentProps })}
54
+ <!--
55
+ `style:z-index` (a directive, not an attribute) so it merges with — rather
56
+ than clobbers — whatever bits-ui and the app put in `style`, and wins over
57
+ the recipe's z-50 class. To override it from an app, use `z-[…]!`.
58
+ -->
47
59
  <div
48
60
  {...contentProps}
61
+ style:z-index={contentZIndex}
49
62
  inert={staticRender && !open ? true : undefined}
50
63
  class={cn(
51
64
  contentProps.class,
@@ -1,24 +1,46 @@
1
1
  <script lang="ts">import { drawerRecipe } from './Drawer.recipe';
2
+ import { overlayLayer } from '../../hooks';
2
3
  let { open = $bindable(false), side = 'right', size = 'md', closeOnClickOutside = true, header, footer, children, closeIcon, onclose, } = $props();
4
+ // Shared stacking authority. The drawer renders IN PLACE (no portal), but it still takes
5
+ // a slot so overlays opened from inside it stack above, and so Escape can resolve a
6
+ // single topmost across every overlay type.
7
+ const layer = overlayLayer({ isOpen: () => open, kind: 'drawer' });
8
+ const contentZIndex = $derived(layer.zIndex === undefined ? undefined : layer.zIndex + 1);
3
9
  const r = $derived(drawerRecipe({ side, size }));
4
10
  function close() {
5
11
  open = false;
6
12
  onclose?.();
7
13
  }
14
+ // Escape was documented on `closeOnClickOutside` but never actually worked: the handler
15
+ // lived on a <div role="presentation"> with no tabindex, which can't receive keydown.
16
+ // It has to work now — a registered surface that swallows its slot without honouring
17
+ // Escape would turn Escape off for everything beneath it (bits-ui overlays below defer
18
+ // to whoever is topmost). Window CAPTURE + topmost-gated, mirroring BottomSheet: capture
19
+ // runs before bits-ui's document listener, so `isTopmost` is read as of the keypress
20
+ // rather than after another overlay has already closed and left this one on top.
21
+ function handleKeydown(e) {
22
+ if (e.key === 'Escape' && open && closeOnClickOutside && layer.isTopmost) {
23
+ e.preventDefault();
24
+ close();
25
+ }
26
+ }
8
27
  </script>
9
28
 
29
+ <svelte:window onkeydowncapture={handleKeydown}/>
30
+
10
31
  {#if open}
11
32
  <!-- Backdrop -->
12
33
  <div
13
34
  role="presentation"
14
35
  class={r.overlay()}
36
+ style:z-index={layer.zIndex}
15
37
  onclick={() => closeOnClickOutside && close()}
16
- onkeydown={(e) => e.key === 'Escape' && closeOnClickOutside && close()}
17
38
  ></div>
18
39
 
19
40
  <!-- Drawer panel -->
20
41
  <div
21
42
  class={r.content()}
43
+ style:z-index={contentZIndex}
22
44
  role="dialog"
23
45
  aria-modal="true"
24
46
  >
@@ -1,6 +1,11 @@
1
1
  <script lang="ts">import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
2
2
  import { dropdownMenuRecipe } from '../DropdownMenu.recipe';
3
+ import { overlayLayer, withZIndex } from '../../../hooks';
3
4
  let { children, trigger, class: className, open = $bindable(false), side = 'bottom', sideOffset = 4, align = 'start', loop, preventScroll, ...props } = $props();
5
+ // Shared stacking authority — a menu opened from inside a Dialog or BottomSheet must
6
+ // land above it, which its hardcoded z-50 could not do.
7
+ const layer = overlayLayer({ isOpen: () => open, kind: 'menu' });
8
+ const contentZIndex = $derived(layer.zIndex === undefined ? undefined : layer.zIndex + 1);
4
9
  const r = dropdownMenuRecipe();
5
10
  </script>
6
11
 
@@ -22,8 +27,10 @@ const r = dropdownMenuRecipe();
22
27
  {align}
23
28
  {loop}
24
29
  {preventScroll}
30
+ escapeKeydownBehavior={layer.isTopmost ? 'close' : 'ignore'}
25
31
  {...props}
26
32
  class={r.content({ class: className })}
33
+ style={withZIndex(props.style, contentZIndex)}
27
34
  >
28
35
  {@render children?.()}
29
36
  </DropdownMenuPrimitive.Content>
@@ -1,6 +1,12 @@
1
1
  <script lang="ts">import { Popover as PopoverPrimitive } from 'bits-ui';
2
2
  import { popoverRecipe } from './Popover.recipe';
3
+ import { overlayLayer, withZIndex } from '../../hooks';
3
4
  let { children, trigger, class: className, open = $bindable(false), side = 'bottom', sideOffset = 4, align = 'start', alignOffset, closeOnClickOutside = true, closeOnEscape = true, trapFocus, preventScroll, onInteractOutside, ref = $bindable(null), ...props } = $props();
5
+ // Shared stacking authority — a popover opened from inside a BottomSheet must land above
6
+ // it rather than behind, which its hardcoded z-50 could never do. Content sits at z+1,
7
+ // the slot's content rung (backdrop-owning overlays put their backdrop on z).
8
+ const layer = overlayLayer({ isOpen: () => open, kind: 'popover' });
9
+ const contentZIndex = $derived(layer.zIndex === undefined ? undefined : layer.zIndex + 1);
4
10
  const r = $derived(popoverRecipe());
5
11
  </script>
6
12
 
@@ -25,10 +31,11 @@ const r = $derived(popoverRecipe());
25
31
  {trapFocus}
26
32
  {preventScroll}
27
33
  interactOutsideBehavior={closeOnClickOutside ? 'close' : 'ignore'}
28
- escapeKeydownBehavior={closeOnEscape ? 'close' : 'ignore'}
34
+ escapeKeydownBehavior={closeOnEscape && layer.isTopmost ? 'close' : 'ignore'}
29
35
  onInteractOutside={() => onInteractOutside?.()}
30
36
  class={r.content({ class: className })}
31
37
  {...props}
38
+ style={withZIndex(props.style, contentZIndex)}
32
39
  >
33
40
  {@render children?.()}
34
41
  </PopoverPrimitive.Content>