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.
- package/dist/components/bottom-sheet/BottomSheet.recipe.d.ts +20 -0
- package/dist/components/bottom-sheet/BottomSheet.svelte +113 -4
- 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/llms/bottom-sheet.md +2 -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 +50 -50
- 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
|
@@ -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
|
|
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 &&
|
|
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
|
|
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:
|
|
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>
|