tera-system-ui 0.2.3 → 0.2.5

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 (32) hide show
  1. package/dist/components/bottom-sheet/BottomSheet.recipe.d.ts +9 -0
  2. package/dist/components/bottom-sheet/BottomSheet.svelte +36 -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/tokens/index.d.ts +35 -0
  21. package/dist/tokens/index.js +34 -0
  22. package/package.json +6 -1
  23. package/registry/bottom-sheet.json +4 -4
  24. package/registry/combobox.json +2 -2
  25. package/registry/date-range-picker.json +2 -2
  26. package/registry/dialog.json +1 -1
  27. package/registry/drawer.json +2 -2
  28. package/registry/dropdown-menu.json +2 -2
  29. package/registry/popover.json +2 -2
  30. package/registry/select.json +2 -2
  31. package/registry/toast.json +1 -1
  32. package/registry/tooltip.json +1 -1
@@ -130,6 +130,15 @@ export interface BottomSheetProps {
130
130
  * iOS 16+. Set false if the host app manages page overscroll itself.
131
131
  */
132
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;
133
142
  /** Called after the sheet finishes closing. */
134
143
  onClose?: () => void;
135
144
  /** Called whenever the open state changes. */
@@ -43,6 +43,7 @@ function releaseOverscrollGuard() {
43
43
  import { bottomSheetStore } from './bottomSheetStore.svelte.js';
44
44
  import { bottomSheetRecipe } from './BottomSheet.recipe';
45
45
  import { IconX } from "../../internal/icons";
46
+ import { closeOnBackNavigation, overlayStack } from '../../hooks';
46
47
  // ============================================
47
48
  // CONFIGURABLE CONSTANTS
48
49
  // Adjust these values to customize the bottom sheet behavior
@@ -91,7 +92,7 @@ const CONFIG = {
91
92
  // ============================================
92
93
  // COMPONENT PROPS
93
94
  // ============================================
94
- 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, 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();
95
96
  // ============================================
96
97
  // INTERNAL STATE
97
98
  // ============================================
@@ -173,6 +174,17 @@ const allSnapPoints = $derived([0, ...snapPoints].sort((a, b) => a - b));
173
174
  // ============================================
174
175
  // LIFECYCLE
175
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
+ });
176
188
  onMount(() => {
177
189
  bottomSheetStore.register(id, { snapPoints, defaultSnapPoint, closeOnBackdrop });
178
190
  window.addEventListener('resize', handleWindowResize);
@@ -1090,11 +1102,31 @@ function handleBackdropClick(e) {
1090
1102
  return;
1091
1103
  doClose();
1092
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
+ */
1093
1116
  function handleKeydown(e) {
1094
- if (e.key === 'Escape' && isVisible && bottomSheetStore.isTopmost(id)) {
1117
+ if (e.key !== 'Escape' || !isVisible || !overlayStack.isTopmost(id))
1118
+ return;
1119
+ // dragToClose=false locks the sheet against user dismissal: if a drag can't close it,
1120
+ // Escape shouldn't either. Still swallow the key (preventDefault + stopPropagation) so a
1121
+ // locked topmost sheet ABSORBS Escape instead of letting it fall through and close an
1122
+ // overlay beneath it — the sheet on top owns the keypress.
1123
+ if (!dragToClose) {
1095
1124
  e.preventDefault();
1096
- doClose();
1125
+ e.stopPropagation();
1126
+ return;
1097
1127
  }
1128
+ e.preventDefault();
1129
+ doClose();
1098
1130
  }
1099
1131
  // Svelte action for touch events
1100
1132
  function touchEvents(node) {
@@ -1111,7 +1143,7 @@ function touchEvents(node) {
1111
1143
  }
1112
1144
  </script>
1113
1145
 
1114
- <svelte:window onkeydown={handleKeydown}/>
1146
+ <svelte:window onkeydowncapture={handleKeydown}/>
1115
1147
 
1116
1148
  {#if isVisible}
1117
1149
  <!-- 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>
@@ -3,6 +3,7 @@ import { selectRecipe } from './Select.recipe';
3
3
  import { IconCheck, IconChevronDown } from '../../internal/icons';
4
4
  import IconChevronsUp from '@tabler/icons-svelte/icons/chevrons-up';
5
5
  import IconChevronsDown from '@tabler/icons-svelte/icons/chevrons-down';
6
+ import { overlayLayer, withZIndex } from '../../hooks';
6
7
  let { type = 'single', options = [], groups, value = $bindable(), placeholder = 'Select…', variant, size, disabled, status, loop, allowDeselect, required, name, class: className, checkIcon, onchange, } = $props();
7
8
  // All options flat — used for label lookup in multiple mode
8
9
  const allOptions = $derived(groups ? groups.flatMap(g => g.options) : options);
@@ -18,7 +19,16 @@ const selectedChips = $derived(Array.isArray(value)
18
19
  ? value.map(v => ({ value: v, label: allOptions.find(o => o.value === v)?.label ?? v }))
19
20
  : []);
20
21
  const r = $derived(selectRecipe({ variant, size, disabled, status }));
22
+ // Internal only — Select exposes no `open` prop, but the shared stacking authority needs
23
+ // to know when the listbox is up so a Select inside a Dialog/BottomSheet lands above it
24
+ // instead of behind. `bind:open` on SelectPrimitive.Root is purely observational here.
25
+ let open = $state(false);
26
+ const layer = overlayLayer({ isOpen: () => open, kind: 'menu' });
27
+ const contentZIndex = $derived(layer.zIndex === undefined ? undefined : layer.zIndex + 1);
21
28
  const minHeight = "21rem";
29
+ // The content already carries a `style` (max-height); z-index must be CONCATENATED onto
30
+ // it, not added as a second `style` attribute.
31
+ const contentStyle = $derived(withZIndex(`max-height: min(${minHeight}, var(--bits-select-content-available-height))`, contentZIndex));
22
32
  </script>
23
33
 
24
34
  {#snippet itemList()}
@@ -48,6 +58,7 @@ const minHeight = "21rem";
48
58
  <SelectPrimitive.Root
49
59
  type="multiple"
50
60
  bind:value={value as string[] | undefined}
61
+ bind:open
51
62
  onValueChange={(v) => onchange?.(v)}
52
63
  {disabled}
53
64
  {loop}
@@ -79,7 +90,7 @@ const minHeight = "21rem";
79
90
  <SelectPrimitive.Content
80
91
  sideOffset={4}
81
92
  class={r.content()}
82
- style="max-height: min({minHeight}, var(--bits-select-content-available-height))"
93
+ style={contentStyle}
83
94
  >
84
95
  <SelectPrimitive.ScrollUpButton class="flex w-full items-center justify-center pt-1 text-text-tertiary cursor-default hover:text-text-primary transition-colors">
85
96
  <IconChevronsUp class="size-3.5" />
@@ -96,6 +107,7 @@ const minHeight = "21rem";
96
107
  <SelectPrimitive.Root
97
108
  type="single"
98
109
  bind:value={value as string | undefined}
110
+ bind:open
99
111
  onValueChange={(v) => onchange?.(v)}
100
112
  {disabled}
101
113
  {loop}
@@ -116,7 +128,7 @@ const minHeight = "21rem";
116
128
  <SelectPrimitive.Content
117
129
  sideOffset={6}
118
130
  class={r.content()}
119
- style="max-height: min({minHeight}, var(--bits-select-content-available-height))"
131
+ style={contentStyle}
120
132
  >
121
133
  <SelectPrimitive.ScrollUpButton class="flex w-full items-center justify-center pt-1 text-text-tertiary cursor-default hover:text-text-primary transition-colors">
122
134
  <IconChevronsUp class="size-3.5" />
@@ -12,10 +12,17 @@ const iconSymbol = {
12
12
  };
13
13
  </script>
14
14
 
15
+ <!--
16
+ z-[8000] = the PINNED toast layer (`layer.toast` in tera-system-ui/tokens — keep in sync;
17
+ Tailwind can only emit a class it reads as literal text). Toasts don't register with the
18
+ overlay stack: a notification must sit above modals regardless of open order, so ordering
19
+ it by open time would be wrong. It was z-[60], which put it BEHIND an open BottomSheet
20
+ (z 1000/1001) — a toast fired over a sheet was simply invisible. It now floats above.
21
+ -->
15
22
  <div
16
23
  aria-live="polite"
17
24
  aria-label="Notifications"
18
- class="fixed bottom-4 right-4 z-[60] flex flex-col gap-2 max-sm:right-1/2 max-sm:translate-x-1/2"
25
+ class="fixed bottom-4 right-4 z-[8000] flex flex-col gap-2 max-sm:right-1/2 max-sm:translate-x-1/2"
19
26
  >
20
27
  {#each toast.items as item (item.id)}
21
28
  <div
@@ -17,7 +17,12 @@ import { tv } from "tailwind-variants";
17
17
  export const tooltipRecipe = tv({
18
18
  slots: {
19
19
  trigger: 'inline-flex',
20
- content: `z-50 max-w-[220px] px-2.5 py-1.5 text-xs leading-snug font-mono font-medium tracking-tight rounded-[var(--tera-tooltip-radius,var(--tera-radius-sm))]
20
+ // z-[9000] = the PINNED tooltip layer (`layer.tooltip` in tera-system-ui/tokens —
21
+ // keep in sync; Tailwind can only emit a class it reads as literal text). Tooltips
22
+ // don't register with the overlay stack: they're transient, pointer-events:none
23
+ // chrome that belongs above any modal regardless of open order, so ordering them by
24
+ // open time would be meaningless. 9000 stays clear of third-party peaks (~10000).
25
+ content: `z-[9000] max-w-[220px] px-2.5 py-1.5 text-xs leading-snug font-mono font-medium tracking-tight rounded-[var(--tera-tooltip-radius,var(--tera-radius-sm))]
21
26
  bg-neutral-token-13 text-neutral-token-1 shadow-lg
22
27
  animate-in fade-in zoom-in-95 duration-[var(--tera-duration-fast)] ease-ui
23
28
  data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=closed]:zoom-out-95`,
@@ -1 +1,5 @@
1
1
  export { closeOnBackNavigation } from './closeOnBackNavigation.svelte';
2
+ export { overlayStack } from './overlayStack.svelte.js';
3
+ export type { OverlayKind, OverlayStackItem } from './overlayStack.svelte.js';
4
+ export { overlayLayer, withZIndex } from './overlayLayer.svelte.js';
5
+ export type { OverlayLayer } from './overlayLayer.svelte.js';
@@ -1 +1,4 @@
1
1
  export { closeOnBackNavigation } from './closeOnBackNavigation.svelte';
2
+ // The layering scale itself lives in the token contract: `tera-system-ui/tokens` → `layer`.
3
+ export { overlayStack } from './overlayStack.svelte.js';
4
+ export { overlayLayer, withZIndex } from './overlayLayer.svelte.js';
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Registers a layered surface with the shared {@link overlayStack} and reports the z-index
3
+ * it should paint at.
4
+ *
5
+ * Call once per surface that OWNS A PORTAL, from a component init scope:
6
+ *
7
+ * const layer = overlayLayer({ isOpen: () => open, kind: 'dialog' });
8
+ * // ...then apply layer.zIndex inline (style:z-index, or withZIndex for a bits-ui prop)
9
+ *
10
+ * Wrappers that merely delegate to another Tera overlay (PopoverResponsive, and
11
+ * DateRangePicker's mobile branch, which both render a real `<Dialog>`) must NOT call this —
12
+ * the child already registers, and a wrapper entry would consume a stack slot while owning
13
+ * no element to receive z, shifting every later overlay's depth for nothing.
14
+ *
15
+ * Must live in a `.svelte.ts` module so it can use `$effect`.
16
+ */
17
+ import { type OverlayKind } from './overlayStack.svelte.js';
18
+ export interface OverlayLayer {
19
+ /** The z to paint at, or `undefined` before this surface has ever opened (→ use the recipe's class). */
20
+ readonly zIndex: number | undefined;
21
+ /** Whether this surface is the topmost open overlay of ANY type. Drives Escape. */
22
+ readonly isTopmost: boolean;
23
+ }
24
+ export declare function overlayLayer(opts: {
25
+ /** Stable id. Defaults to an auto-generated one; pass it only if you already have one (BottomSheet). */
26
+ id?: string;
27
+ /** Current open state of the surface. */
28
+ isOpen: () => boolean;
29
+ /** Which family this surface belongs to. */
30
+ kind?: OverlayKind;
31
+ }): OverlayLayer;
32
+ /**
33
+ * Merge a runtime z-index into an existing `style` string.
34
+ *
35
+ * For bits-ui components, which take `style` as a PROP (so Svelte's `style:z-index`
36
+ * directive isn't available). Needed because several call sites spread `{...props}` last —
37
+ * a bare `style={...}` before the spread would be clobbered by an app-supplied one — and
38
+ * because Select's content already carries a `style` that must be concatenated, not
39
+ * duplicated. Apply AFTER the rest-spread.
40
+ *
41
+ * Returns `style` untouched when `z` is undefined (surface not open → recipe class stands).
42
+ */
43
+ export declare function withZIndex(style: unknown, z: number | undefined): string | undefined;