tera-system-ui 0.2.3 → 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.
- package/dist/components/bottom-sheet/BottomSheet.recipe.d.ts +9 -0
- package/dist/components/bottom-sheet/BottomSheet.svelte +26 -3
- package/dist/components/bottom-sheet/bottomSheetStore.svelte.d.ts +28 -1
- package/dist/components/bottom-sheet/bottomSheetStore.svelte.js +60 -39
- package/dist/components/combobox/Combobox.svelte +6 -0
- package/dist/components/date-range-picker/DateRangePicker.svelte +14 -1
- package/dist/components/dialog/Dialog.svelte +15 -2
- package/dist/components/drawer/Drawer.svelte +23 -1
- package/dist/components/dropdown-menu/components/DropdownMenu.svelte +7 -0
- package/dist/components/popover/Popover.svelte +8 -1
- package/dist/components/select/Select.svelte +14 -2
- package/dist/components/toast/ToastContainer.svelte +8 -1
- package/dist/components/tooltip/Tooltip.recipe.js +6 -1
- package/dist/hooks/index.d.ts +4 -0
- package/dist/hooks/index.js +3 -0
- package/dist/hooks/overlayLayer.svelte.d.ts +43 -0
- package/dist/hooks/overlayLayer.svelte.js +74 -0
- package/dist/hooks/overlayStack.svelte.d.ts +59 -0
- package/dist/hooks/overlayStack.svelte.js +107 -0
- package/dist/tokens/index.d.ts +35 -0
- package/dist/tokens/index.js +34 -0
- package/package.json +6 -1
- package/registry/bottom-sheet.json +4 -4
- package/registry/combobox.json +2 -2
- package/registry/date-range-picker.json +2 -2
- package/registry/dialog.json +1 -1
- package/registry/drawer.json +2 -2
- package/registry/dropdown-menu.json +2 -2
- package/registry/popover.json +2 -2
- package/registry/select.json +2 -2
- package/registry/toast.json +1 -1
- 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,8 +1102,19 @@ 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 &&
|
|
1117
|
+
if (e.key === 'Escape' && isVisible && overlayStack.isTopmost(id)) {
|
|
1095
1118
|
e.preventDefault();
|
|
1096
1119
|
doClose();
|
|
1097
1120
|
}
|
|
@@ -1111,7 +1134,7 @@ function touchEvents(node) {
|
|
|
1111
1134
|
}
|
|
1112
1135
|
</script>
|
|
1113
1136
|
|
|
1114
|
-
<svelte:window
|
|
1137
|
+
<svelte:window onkeydowncapture={handleKeydown}/>
|
|
1115
1138
|
|
|
1116
1139
|
{#if isVisible}
|
|
1117
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:
|
|
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
|
-
|
|
11
|
-
|
|
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
|
-
//
|
|
14
|
-
|
|
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
|
-
|
|
17
|
-
//
|
|
18
|
-
|
|
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
|
-
|
|
71
|
+
overlayStack.remove(id);
|
|
33
72
|
}
|
|
34
73
|
/**
|
|
35
74
|
* Open a sheet by ID
|
|
36
75
|
*/
|
|
37
76
|
function open(id) {
|
|
38
|
-
|
|
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
|
-
//
|
|
44
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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=
|
|
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=
|
|
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-[
|
|
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
|
-
|
|
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`,
|
package/dist/hooks/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/hooks/index.js
CHANGED
|
@@ -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;
|