tera-system-ui 0.2.1 → 0.2.2
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.
|
@@ -110,6 +110,15 @@ export interface BottomSheetProps {
|
|
|
110
110
|
closeOnBackdrop?: boolean;
|
|
111
111
|
/** Show a close (X) button in the header (requires the `header` snippet). */
|
|
112
112
|
showCloseButton?: boolean;
|
|
113
|
+
/**
|
|
114
|
+
* Keep the browser UI tint (iOS Safari status bar / Android address bar) matching the page
|
|
115
|
+
* while the sheet is open. The full-screen backdrop darkens the area behind the status bar;
|
|
116
|
+
* iOS Safari, when the page declares no `<meta name="theme-color">`, re-samples that area and
|
|
117
|
+
* flips the status bar dark — losing the immersive look. When true (the default) the sheet,
|
|
118
|
+
* only if the page has no `theme-color` of its own, adds one pinned to the page background for
|
|
119
|
+
* its open lifetime and removes it on close. A page that sets its own tint is left untouched.
|
|
120
|
+
*/
|
|
121
|
+
syncThemeColor?: boolean;
|
|
113
122
|
/** Called after the sheet finishes closing. */
|
|
114
123
|
onClose?: () => void;
|
|
115
124
|
/** Called whenever the open state changes. */
|
|
@@ -21,7 +21,7 @@ import { tv } from "tailwind-variants";
|
|
|
21
21
|
export const bottomSheetRecipe = tv({
|
|
22
22
|
slots: {
|
|
23
23
|
backdrop: 'fixed inset-0 bg-black opacity-0 touch-none [will-change:opacity] [-webkit-tap-highlight-color:transparent] transition-opacity duration-[var(--transition-duration,0.35s)] ease-out',
|
|
24
|
-
sheet: 'fixed bottom-0 left-0 right-0 flex flex-col overflow-visible bg-surface-raised rounded-t-[var(--tera-bottom-sheet-radius,12px)]
|
|
24
|
+
sheet: 'fixed bottom-0 left-0 right-0 flex flex-col overflow-visible bg-surface-raised rounded-t-[var(--tera-bottom-sheet-radius,12px)] [will-change:translate] [translate:0_100%] [transform:translateZ(0)] [backface-visibility:hidden] transition-[translate] duration-[var(--transition-duration,0.35s)] ease-[cubic-bezier(0.2,0.9,0.36,1)]',
|
|
25
25
|
handle: 'flex shrink-0 items-center justify-center px-4 pt-2 pb-1 cursor-grab touch-none active:cursor-grabbing',
|
|
26
26
|
handleBar: 'h-[5px] w-9 rounded-[2.5px] bg-text-tertiary',
|
|
27
27
|
header: 'relative shrink-0 cursor-grab touch-none select-none border-b border-border-default px-4 pt-1 pb-3 active:cursor-grabbing',
|
|
@@ -18,17 +18,24 @@ const CONFIG = {
|
|
|
18
18
|
backdrop: {
|
|
19
19
|
maxOpacity: 0.5 // Maximum opacity when fully open
|
|
20
20
|
},
|
|
21
|
-
//
|
|
21
|
+
// Spring physics for open / close / settle, PLAYED ON THE COMPOSITOR via the Web
|
|
22
|
+
// Animations API (see animateTo) so motion holds the display's native refresh rate
|
|
23
|
+
// (e.g. 120Hz) even under main-thread load. `response` is the approximate settle
|
|
24
|
+
// time (s); `damping` is the damping ratio (1 = critical/no bounce, <1 adds a little
|
|
25
|
+
// life). The spring is simulated ONCE per gesture — seeded with the finger's release
|
|
26
|
+
// velocity so motion continues instead of restarting — and emitted as compositor
|
|
27
|
+
// keyframes; there is no per-frame JavaScript. `close` finalizes the instant it
|
|
28
|
+
// reaches offscreen (simulateSpring hideOnArrival) so there is no lingering tail.
|
|
22
29
|
animation: {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
30
|
+
open: { response: 0.4, damping: 0.86 }, // present — quick, no bounce
|
|
31
|
+
close: { response: 0.26, damping: 0.85 }, // dismiss — fast, finalizes on arrival
|
|
32
|
+
snap: { response: 0.32, damping: 0.84 }, // moving between snap points
|
|
33
|
+
restDistance: 0.5, // px from target at which the simulated spring is "settled"
|
|
34
|
+
restVelocity: 40 // px/s below which the simulated spring is "at rest"
|
|
27
35
|
},
|
|
28
36
|
// Gesture handling
|
|
29
37
|
gesture: {
|
|
30
38
|
velocityInfluence: 0.01, // How much velocity affects snap selection
|
|
31
|
-
overdragExponent: 0.55, // Overdrag resistance (lower = more resistance)
|
|
32
39
|
closeVelocityThreshold: 0.35, // Velocity to trigger close (px/ms)
|
|
33
40
|
closeProgressThreshold: 0.35, // Progress to trigger close (0-1)
|
|
34
41
|
maxLockedDrag: 60, // Max drag when dragToClose disabled (px)
|
|
@@ -42,7 +49,7 @@ const CONFIG = {
|
|
|
42
49
|
// ============================================
|
|
43
50
|
// COMPONENT PROPS
|
|
44
51
|
// ============================================
|
|
45
|
-
let { id, open = $bindable(false), snapPoints = [0.9], defaultSnapPoint = 0, containerRef = null, scaleTargetRef = null, constrainToContainer = false, scaleBackground = true, dragToClose = true, closeOnBackdrop = true, showCloseButton = false, onClose, onOpenChange, children, header } = $props();
|
|
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();
|
|
46
53
|
// ============================================
|
|
47
54
|
// INTERNAL STATE
|
|
48
55
|
// ============================================
|
|
@@ -53,11 +60,28 @@ let backdropRef = $state(null);
|
|
|
53
60
|
// Sheet state
|
|
54
61
|
let isVisible = $state(false);
|
|
55
62
|
let isClosing = $state(false);
|
|
56
|
-
let isAnimating = $state(false);
|
|
57
63
|
let isDragging = $state(false);
|
|
58
|
-
|
|
64
|
+
// Plain (NOT $state): written on every drag move (can be ~120/s) but never read
|
|
65
|
+
// reactively (no template / $derived / $effect depends on it). Keeping it out of the
|
|
66
|
+
// reactivity graph avoids a signal write per pointer event on high-refresh displays.
|
|
67
|
+
// During a compositor animation it holds the resting target; the true on-screen value
|
|
68
|
+
// is read from the compositor when a gesture interrupts (readLiveTranslateY).
|
|
69
|
+
let currentTranslateY = 0;
|
|
59
70
|
let sheetHeight = $state(0);
|
|
60
|
-
|
|
71
|
+
// Compositor spring engine — programmatic motion (open/close/snap/settle) plays as Web
|
|
72
|
+
// Animations on the compositor thread (see animateTo). currentTranslateY holds the live
|
|
73
|
+
// RESTING position; while an animation runs we read the true on-screen value straight
|
|
74
|
+
// from the compositor (readLiveTranslateY), so grabbing the sheet mid-flight never jumps.
|
|
75
|
+
let sheetAnim = null; // sheet translate
|
|
76
|
+
let backdropAnim = null; // backdrop opacity (kept in sync)
|
|
77
|
+
let scaleAnim = null; // scale-target scale/translate/radius (in sync)
|
|
78
|
+
let scaleTargetPrimed = false; // static scale-target styles set once per open cycle
|
|
79
|
+
// iOS Safari status-bar tint. The full-screen backdrop darkens the area behind the status
|
|
80
|
+
// bar; with no page-level <meta name="theme-color"> Safari re-samples it and flips the bar
|
|
81
|
+
// dark (losing the immersive look). When the page declares none we add one (= page
|
|
82
|
+
// background) for the sheet's open lifetime and remove it on close; a page that sets its own
|
|
83
|
+
// theme-color is left alone — Safari honors it, so the backdrop can't flip it.
|
|
84
|
+
let themeColorMeta = null;
|
|
61
85
|
// Drag tracking
|
|
62
86
|
let dragState = {
|
|
63
87
|
startY: 0,
|
|
@@ -79,6 +103,11 @@ let dragState = {
|
|
|
79
103
|
// Scroll state
|
|
80
104
|
let scrollableElement = null;
|
|
81
105
|
let isScrolledToTop = true;
|
|
106
|
+
// Backdrop dismiss: the pointerId of a press that STARTED on the backdrop. A genuine
|
|
107
|
+
// dismiss is a full press+release on the backdrop; tracking the origin lets us ignore
|
|
108
|
+
// the stray pointerup that lands on a freshly-mounted backdrop when a press begun
|
|
109
|
+
// elsewhere (e.g. a long-press on a button that opened the sheet) is released over it.
|
|
110
|
+
let backdropPressPointerId = null;
|
|
82
111
|
// Dimensions
|
|
83
112
|
let windowHeight = $state(typeof window !== 'undefined' ? window.innerHeight : 800);
|
|
84
113
|
let containerHeight = $state(0);
|
|
@@ -108,6 +137,18 @@ onMount(() => {
|
|
|
108
137
|
});
|
|
109
138
|
onDestroy(() => {
|
|
110
139
|
containerResizeObserver?.disconnect();
|
|
140
|
+
cancelAnimations();
|
|
141
|
+
restoreThemeColor();
|
|
142
|
+
});
|
|
143
|
+
// Motion is driven by direct writes (drag) and Web Animations (open/close/snap), so keep
|
|
144
|
+
// the recipe's CSS `transition` OFF on these elements — otherwise the CSS transition would
|
|
145
|
+
// fight each per-move drag write and smear it. (WAAPI animations are independent of the
|
|
146
|
+
// `transition` property, so turning it off here does not affect them.)
|
|
147
|
+
$effect(() => {
|
|
148
|
+
if (sheetRef)
|
|
149
|
+
sheetRef.style.transition = 'none';
|
|
150
|
+
if (backdropRef)
|
|
151
|
+
backdropRef.style.transition = 'none';
|
|
111
152
|
});
|
|
112
153
|
// Watch containerRef for constrained mode
|
|
113
154
|
$effect(() => {
|
|
@@ -145,19 +186,11 @@ $effect(() => {
|
|
|
145
186
|
doClose();
|
|
146
187
|
}
|
|
147
188
|
});
|
|
148
|
-
//
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
if (!scrollableElement)
|
|
154
|
-
return;
|
|
155
|
-
const handleScroll = () => {
|
|
156
|
-
isScrolledToTop = scrollableElement.scrollTop <= 1;
|
|
157
|
-
};
|
|
158
|
-
scrollableElement.addEventListener('scroll', handleScroll, { passive: true });
|
|
159
|
-
isScrolledToTop = scrollableElement.scrollTop <= 1;
|
|
160
|
-
});
|
|
189
|
+
// NOTE: The scrollable region ([data-bottom-sheet-scroll]) is resolved lazily
|
|
190
|
+
// at gesture-start from the event target (see getScrollableFromTarget), NOT via
|
|
191
|
+
// a one-shot querySelector here. A single querySelector at open-time cannot see
|
|
192
|
+
// content that is mounted lazily AFTER the sheet opens (e.g. async-loaded lists),
|
|
193
|
+
// which left scrollableElement null and broke drag/scroll arbitration inside it.
|
|
161
194
|
// ============================================
|
|
162
195
|
// CORE FUNCTIONS
|
|
163
196
|
// ============================================
|
|
@@ -176,52 +209,254 @@ function recalculateDimensions() {
|
|
|
176
209
|
if (sheetRef)
|
|
177
210
|
sheetHeight = sheetRef.offsetHeight;
|
|
178
211
|
}
|
|
212
|
+
// ============================================
|
|
213
|
+
// COMPOSITOR SPRING ENGINE (Web Animations API)
|
|
214
|
+
// ============================================
|
|
215
|
+
// Open / close / snap / settle play as Web Animations on the COMPOSITOR thread, so they
|
|
216
|
+
// hold the display's native refresh rate (e.g. 120Hz) even when the main thread is busy —
|
|
217
|
+
// the failure mode of a per-frame rAF loop, which must be scheduled on main every frame
|
|
218
|
+
// and drops frames under contention. We simulate the damped spring ONCE per gesture
|
|
219
|
+
// (seeded with the finger's release velocity) to get a position trajectory, then hand it
|
|
220
|
+
// to the compositor as keyframes: NO per-frame JavaScript, store write, or layout during
|
|
221
|
+
// the animation. Drag stays on direct per-input writes (renderPosition) — it must track
|
|
222
|
+
// the finger 1:1, which no pre-baked animation can do.
|
|
223
|
+
const prefersReducedMotion = () => typeof window !== 'undefined' &&
|
|
224
|
+
!!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
|
225
|
+
/** Convert (response, dampingRatio) to spring stiffness/damping (unit mass). */
|
|
226
|
+
function springConstants(response, damping) {
|
|
227
|
+
const omega = (2 * Math.PI) / response; // natural angular frequency
|
|
228
|
+
return { k: omega * omega, c: 2 * damping * omega };
|
|
229
|
+
}
|
|
230
|
+
// ── Position → visual mappings. Shared by immediate drag writes (renderPosition) and by
|
|
231
|
+
// keyframe generation, so a compositor animation and a live drag paint identically. ──
|
|
232
|
+
function backdropOpacityFor(translateY) {
|
|
233
|
+
const progress = Math.max(0, Math.min(maxSnapPoint, (sheetHeight - translateY) / effectiveHeight));
|
|
234
|
+
return Math.max(0, Math.min(CONFIG.backdrop.maxOpacity, progress * CONFIG.backdrop.maxOpacity));
|
|
235
|
+
}
|
|
236
|
+
function scalePropsFor(translateY) {
|
|
237
|
+
const p = Math.max(0, Math.min(1, (sheetHeight - translateY) / effectiveHeight));
|
|
238
|
+
const { factor, borderRadius, translateY: ty } = CONFIG.scale;
|
|
239
|
+
return {
|
|
240
|
+
scale: String(1 - factor * p),
|
|
241
|
+
translate: `0 ${(ty * p).toFixed(2)}px`,
|
|
242
|
+
borderRadius: `${(borderRadius * p).toFixed(2)}px`
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
/** Set the scale target's static (non-animated) styles once per open cycle. */
|
|
246
|
+
function primeScaleTarget() {
|
|
247
|
+
if (scaleTargetPrimed || !scaleTarget || !scaleBackground)
|
|
248
|
+
return;
|
|
249
|
+
scaleTarget.style.transition = 'none';
|
|
250
|
+
scaleTarget.style.transformOrigin = CONFIG.scale.origin;
|
|
251
|
+
scaleTarget.style.overflow = 'hidden';
|
|
252
|
+
scaleTarget.style.willChange = 'scale, translate, border-radius';
|
|
253
|
+
scaleTargetPrimed = true;
|
|
254
|
+
}
|
|
255
|
+
/** Write the whole visual state for a given translateY. Used by DRAG and immediate sets. */
|
|
256
|
+
function renderPosition(translateY) {
|
|
257
|
+
if (sheetRef)
|
|
258
|
+
sheetRef.style.translate = `0 ${translateY}px`;
|
|
259
|
+
const rawProgress = (sheetHeight - translateY) / effectiveHeight;
|
|
260
|
+
const progress = Math.max(0, Math.min(maxSnapPoint, rawProgress));
|
|
261
|
+
updateBackdrop(progress);
|
|
262
|
+
updateScaleTarget(progress);
|
|
263
|
+
bottomSheetStore.updateProgress(id, Math.max(0, Math.min(1, rawProgress)));
|
|
264
|
+
}
|
|
265
|
+
/** Read the sheet's LIVE translateY — reflects a running compositor animation. */
|
|
266
|
+
function readLiveTranslateY() {
|
|
267
|
+
if (!sheetRef)
|
|
268
|
+
return null;
|
|
269
|
+
const t = getComputedStyle(sheetRef).getPropertyValue('translate');
|
|
270
|
+
if (!t || t === 'none')
|
|
271
|
+
return null;
|
|
272
|
+
const parts = t.trim().split(/\s+/);
|
|
273
|
+
const y = parts.length > 1 ? parseFloat(parts[1]) : 0;
|
|
274
|
+
return Number.isFinite(y) ? y : null;
|
|
275
|
+
}
|
|
276
|
+
/** Cancel any in-flight compositor animations (sheet + backdrop + scale). */
|
|
277
|
+
function cancelAnimations() {
|
|
278
|
+
sheetAnim?.cancel();
|
|
279
|
+
backdropAnim?.cancel();
|
|
280
|
+
scaleAnim?.cancel();
|
|
281
|
+
sheetAnim = backdropAnim = scaleAnim = null;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Freeze the sheet at its LIVE on-screen position and stop the animation, so a fresh
|
|
285
|
+
* gesture anchors to exactly what's rendered — no jump. Called when the user grabs the
|
|
286
|
+
* sheet mid-flight. (.cancel() reverts fill:none animations to their base, which is the
|
|
287
|
+
* settled target — so we pin the live value to inline styles FIRST, then cancel.)
|
|
288
|
+
*/
|
|
289
|
+
function interruptAnimation() {
|
|
290
|
+
if (sheetAnim || backdropAnim || scaleAnim) {
|
|
291
|
+
const liveY = readLiveTranslateY();
|
|
292
|
+
if (liveY !== null) {
|
|
293
|
+
currentTranslateY = liveY;
|
|
294
|
+
renderPosition(liveY); // pin translate + backdrop + scale to the live value
|
|
295
|
+
}
|
|
296
|
+
cancelAnimations();
|
|
297
|
+
}
|
|
298
|
+
isClosing = false;
|
|
299
|
+
}
|
|
300
|
+
/** Snap all visuals to a resting target — the base the fill:none animations settle onto. */
|
|
301
|
+
function applyFinal(target) {
|
|
302
|
+
currentTranslateY = target;
|
|
303
|
+
renderPosition(target);
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Simulate a damped spring from startY → targetY seeded with v0 (px/s), returning the
|
|
307
|
+
* position trajectory (evenly spaced in time) and its duration (ms). For close/hide we
|
|
308
|
+
* stop at the FIRST arrival at the target (on the way offscreen) so there is no tail.
|
|
309
|
+
*/
|
|
310
|
+
function simulateSpring(startY, targetY, v0, spring, hideOnArrival) {
|
|
311
|
+
const { restDistance, restVelocity } = CONFIG.animation;
|
|
312
|
+
const { k, c } = springConstants(spring.response, spring.damping);
|
|
313
|
+
const dt = 1 / 90; // keyframe sampling step; compositor interpolates between
|
|
314
|
+
const subSteps = 2; // physics sub-steps per sample (stability at high stiffness)
|
|
315
|
+
const h = dt / subSteps;
|
|
316
|
+
const maxSteps = Math.ceil(1.5 / dt); // safety cap (~1.5s)
|
|
317
|
+
let x = startY;
|
|
318
|
+
let v = v0;
|
|
319
|
+
const ys = [x];
|
|
320
|
+
for (let i = 0; i < maxSteps; i++) {
|
|
321
|
+
for (let s = 0; s < subSteps; s++) {
|
|
322
|
+
const a = -k * (x - targetY) - c * v;
|
|
323
|
+
v += a * h;
|
|
324
|
+
x += v * h;
|
|
325
|
+
}
|
|
326
|
+
if (hideOnArrival && x >= targetY) {
|
|
327
|
+
ys.push(targetY); // reached offscreen — stop before any rebound
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
ys.push(x);
|
|
331
|
+
if (!hideOnArrival && Math.abs(x - targetY) < restDistance && Math.abs(v) < restVelocity) {
|
|
332
|
+
ys[ys.length - 1] = targetY;
|
|
333
|
+
break;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return { ys, duration: (ys.length - 1) * dt * 1000 };
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Spring the sheet toward `target`, seeded with `initialVelocity` (px/s) so a flick
|
|
340
|
+
* continues seamlessly. Plays on the COMPOSITOR via the Web Animations API. If a spring
|
|
341
|
+
* is already in flight we read its LIVE position and re-simulate from there, so retargets
|
|
342
|
+
* (and mid-flight direction changes) never jump. The sheet's translate, the backdrop's
|
|
343
|
+
* opacity, and the scale target all animate from ONE simulation with identical timing, so
|
|
344
|
+
* they stay locked together on the compositor with zero per-frame JS.
|
|
345
|
+
*/
|
|
346
|
+
function animateTo(target, spring, initialVelocity = 0, onComplete, hideOnArrival = false) {
|
|
347
|
+
const running = !!(sheetAnim || backdropAnim || scaleAnim);
|
|
348
|
+
const start = running ? (readLiveTranslateY() ?? currentTranslateY) : currentTranslateY;
|
|
349
|
+
cancelAnimations();
|
|
350
|
+
// Reduced motion, or a move too small to be worth animating → jump straight there.
|
|
351
|
+
if (prefersReducedMotion()) {
|
|
352
|
+
applyFinal(target);
|
|
353
|
+
onComplete?.();
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
const { ys, duration } = simulateSpring(start, target, initialVelocity, spring, hideOnArrival);
|
|
357
|
+
if (duration < 8 || ys.length < 2) {
|
|
358
|
+
applyFinal(target);
|
|
359
|
+
onComplete?.();
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (scaleTarget && scaleBackground)
|
|
363
|
+
primeScaleTarget();
|
|
364
|
+
// Bases = final values, so when the fill:none animations end the elements hold `target`
|
|
365
|
+
// (no revert-to-start flash). The animations override this from t=0 with keyframe[0]=start.
|
|
366
|
+
applyFinal(target);
|
|
367
|
+
const N = ys.length;
|
|
368
|
+
const opts = { duration, easing: 'linear', fill: 'none' };
|
|
369
|
+
const sheetKf = ys.map((y, i) => ({ translate: `0 ${y.toFixed(2)}px`, offset: i / (N - 1) }));
|
|
370
|
+
sheetAnim = sheetRef ? sheetRef.animate(sheetKf, opts) : null;
|
|
371
|
+
if (backdropRef) {
|
|
372
|
+
const kf = ys.map((y, i) => ({ opacity: String(backdropOpacityFor(y)), offset: i / (N - 1) }));
|
|
373
|
+
backdropAnim = backdropRef.animate(kf, opts);
|
|
374
|
+
}
|
|
375
|
+
if (scaleTarget && scaleBackground) {
|
|
376
|
+
const kf = ys.map((y, i) => ({ ...scalePropsFor(y), offset: i / (N - 1) }));
|
|
377
|
+
scaleAnim = scaleTarget.animate(kf, opts);
|
|
378
|
+
}
|
|
379
|
+
// Finalize on natural completion only. .cancel() (retarget / grab) fires oncancel, not
|
|
380
|
+
// onfinish, so a superseded animation never runs the callback.
|
|
381
|
+
if (sheetAnim) {
|
|
382
|
+
sheetAnim.onfinish = () => {
|
|
383
|
+
sheetAnim = backdropAnim = scaleAnim = null;
|
|
384
|
+
onComplete?.();
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
else {
|
|
388
|
+
applyFinal(target);
|
|
389
|
+
onComplete?.();
|
|
390
|
+
}
|
|
391
|
+
}
|
|
179
392
|
function doOpen() {
|
|
393
|
+
// Refresh viewport/container dimensions BEFORE the sheet renders, so its
|
|
394
|
+
// height (maxSheetHeight) is derived from current values. While the sheet is
|
|
395
|
+
// closed, windowHeight is not tracked (handleWindowResize is gated on
|
|
396
|
+
// isVisible), so a resize between opens would otherwise leave the first
|
|
397
|
+
// render using a stale height — measured sheetHeight then mismatches the
|
|
398
|
+
// fresh effectiveHeight, shifting the sheet off the bottom edge until the
|
|
399
|
+
// next open. Doing this before isVisible keeps them in sync.
|
|
400
|
+
if (typeof window !== 'undefined')
|
|
401
|
+
windowHeight = window.innerHeight;
|
|
402
|
+
if (containerRef && constrainToContainer)
|
|
403
|
+
containerHeight = containerRef.clientHeight;
|
|
180
404
|
isVisible = true;
|
|
181
405
|
isClosing = false;
|
|
182
|
-
|
|
406
|
+
backdropPressPointerId = null; // fresh open — ignore any stale backdrop press id
|
|
407
|
+
applyThemeColor(); // hold the iOS status bar at the page color before the backdrop darkens it
|
|
183
408
|
bottomSheetStore.open(id);
|
|
184
409
|
requestAnimationFrame(() => {
|
|
185
410
|
// sheetRef is bound by this rAF tick.
|
|
186
411
|
recalculateDimensions();
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
412
|
+
// Start fully hidden (matches the sheet's initial CSS translate of 100%),
|
|
413
|
+
// then spring up to the target snap point.
|
|
414
|
+
currentTranslateY = sheetHeight;
|
|
415
|
+
renderPosition(sheetHeight);
|
|
416
|
+
const target = sheetHeight - currentSnapPoint * effectiveHeight;
|
|
417
|
+
animateTo(target, CONFIG.animation.open, 0);
|
|
190
418
|
});
|
|
191
419
|
}
|
|
192
420
|
function doClose() {
|
|
193
421
|
if (isClosing)
|
|
194
422
|
return;
|
|
423
|
+
animateSheetClose(0);
|
|
424
|
+
}
|
|
425
|
+
/** Spring the sheet fully down and finalize. `initialVelocity` (px/s) carries a flick-to-dismiss. */
|
|
426
|
+
function animateSheetClose(initialVelocity) {
|
|
195
427
|
isClosing = true;
|
|
196
|
-
|
|
197
|
-
snapTo(0, () => {
|
|
428
|
+
animateTo(sheetHeight, CONFIG.animation.close, initialVelocity, () => {
|
|
198
429
|
isVisible = false;
|
|
199
430
|
isClosing = false;
|
|
200
|
-
isAnimating = false;
|
|
201
431
|
bottomSheetStore.close(id);
|
|
202
432
|
prevOpen = false;
|
|
203
433
|
open = false;
|
|
204
434
|
resetScaleTarget();
|
|
435
|
+
restoreThemeColor();
|
|
205
436
|
onClose?.();
|
|
206
437
|
onOpenChange?.(false);
|
|
207
|
-
});
|
|
438
|
+
}, true); // hideOnArrival — finalize as soon as it's offscreen, no spring tail
|
|
208
439
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
if (
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
updateBackdrop(progress);
|
|
220
|
-
updateScaleTarget(progress);
|
|
221
|
-
bottomSheetStore.updateProgress(id, Math.max(0, Math.min(1, progress)));
|
|
222
|
-
if (onComplete) {
|
|
223
|
-
setTimeout(onComplete, parseFloat(transitionDuration) * 1000);
|
|
440
|
+
/**
|
|
441
|
+
* Settle after a drag release: resolve the target snap point, then spring to it
|
|
442
|
+
* carrying the finger's release velocity so the hand-off is seamless.
|
|
443
|
+
*/
|
|
444
|
+
function settleFromRelease() {
|
|
445
|
+
const nearestSnap = determineSnapPoint();
|
|
446
|
+
const releaseVelocity = calculateVelocity() * 1000; // px/ms → px/s (down = positive)
|
|
447
|
+
if (nearestSnap === 0 && dragToClose) {
|
|
448
|
+
animateSheetClose(releaseVelocity);
|
|
449
|
+
return;
|
|
224
450
|
}
|
|
451
|
+
const snap = nearestSnap > 0 ? nearestSnap : dragState.startSnapPoint;
|
|
452
|
+
const target = sheetHeight - snap * effectiveHeight;
|
|
453
|
+
animateTo(target, CONFIG.animation.snap, releaseVelocity, () => {
|
|
454
|
+
if (!open) {
|
|
455
|
+
prevOpen = true;
|
|
456
|
+
open = true;
|
|
457
|
+
onOpenChange?.(true);
|
|
458
|
+
}
|
|
459
|
+
});
|
|
225
460
|
}
|
|
226
461
|
// ============================================
|
|
227
462
|
// VISUAL UPDATES
|
|
@@ -232,23 +467,23 @@ function updateBackdrop(progress) {
|
|
|
232
467
|
const opacity = Math.max(0, Math.min(CONFIG.backdrop.maxOpacity, progress * CONFIG.backdrop.maxOpacity));
|
|
233
468
|
backdropRef.style.opacity = String(opacity);
|
|
234
469
|
}
|
|
235
|
-
function updateScaleTarget(progress
|
|
470
|
+
function updateScaleTarget(progress) {
|
|
236
471
|
if (!scaleTarget || !scaleBackground)
|
|
237
472
|
return;
|
|
473
|
+
// Static props are set ONCE per open cycle (see primeScaleTarget) — re-writing
|
|
474
|
+
// transition / transform-origin / overflow / will-change every frame churns the
|
|
475
|
+
// compositor layer and blows the frame budget on 120Hz displays.
|
|
476
|
+
primeScaleTarget();
|
|
238
477
|
const p = Math.max(0, Math.min(1, progress));
|
|
239
|
-
const { factor, borderRadius, translateY
|
|
478
|
+
const { factor, borderRadius, translateY } = CONFIG.scale;
|
|
479
|
+
// Per-frame: scale + translate are GPU-composited (cheap). Quantize border-radius to
|
|
480
|
+
// whole px so it only actually repaints ~a dozen times across the gesture, not 120×/s.
|
|
240
481
|
scaleTarget.style.scale = String(1 - factor * p);
|
|
241
|
-
scaleTarget.style.translate = `0 ${translateY * p}px`;
|
|
242
|
-
scaleTarget.style.borderRadius = `${borderRadius * p}px`;
|
|
243
|
-
scaleTarget.style.transformOrigin = origin;
|
|
244
|
-
scaleTarget.style.overflow = 'hidden';
|
|
245
|
-
scaleTarget.style.willChange = 'scale, translate, border-radius';
|
|
246
|
-
if (withTransition) {
|
|
247
|
-
const { easing } = CONFIG.animation;
|
|
248
|
-
scaleTarget.style.transition = `scale ${transitionDuration} ${easing}, translate ${transitionDuration} ${easing}, border-radius ${transitionDuration} ${easing}`;
|
|
249
|
-
}
|
|
482
|
+
scaleTarget.style.translate = `0 ${(translateY * p).toFixed(2)}px`;
|
|
483
|
+
scaleTarget.style.borderRadius = `${Math.round(borderRadius * p)}px`;
|
|
250
484
|
}
|
|
251
485
|
function resetScaleTarget() {
|
|
486
|
+
scaleTargetPrimed = false;
|
|
252
487
|
if (!scaleTarget || !scaleBackground)
|
|
253
488
|
return;
|
|
254
489
|
scaleTarget.style.scale = '';
|
|
@@ -260,6 +495,48 @@ function resetScaleTarget() {
|
|
|
260
495
|
scaleTarget.style.willChange = '';
|
|
261
496
|
}
|
|
262
497
|
// ============================================
|
|
498
|
+
// BROWSER CHROME (iOS status-bar tint)
|
|
499
|
+
// ============================================
|
|
500
|
+
function isTransparentColor(color) {
|
|
501
|
+
return !color || color === 'transparent' || color === 'rgba(0, 0, 0, 0)';
|
|
502
|
+
}
|
|
503
|
+
/** First opaque background up the page root — what iOS Safari samples for the status bar. */
|
|
504
|
+
function resolvePageBackgroundColor() {
|
|
505
|
+
if (typeof document === 'undefined')
|
|
506
|
+
return '#ffffff';
|
|
507
|
+
for (const el of [document.body, document.documentElement]) {
|
|
508
|
+
if (!el)
|
|
509
|
+
continue;
|
|
510
|
+
const bg = getComputedStyle(el).backgroundColor;
|
|
511
|
+
if (!isTransparentColor(bg))
|
|
512
|
+
return bg;
|
|
513
|
+
}
|
|
514
|
+
return '#ffffff';
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Keep the iOS Safari status bar immersive while the sheet is open. Acts ONLY when the page
|
|
518
|
+
* declares no <meta name="theme-color"> — the case Safari resolves by sampling the now-darkened
|
|
519
|
+
* backdrop and flipping the bar dark: we add one pinned to the page background (theme-aware,
|
|
520
|
+
* so it also tracks dark mode). A page that sets its own theme-color owns its status bar and
|
|
521
|
+
* is left untouched. restoreThemeColor removes ours on close.
|
|
522
|
+
*/
|
|
523
|
+
function applyThemeColor() {
|
|
524
|
+
if (!syncThemeColor || typeof document === 'undefined' || themeColorMeta)
|
|
525
|
+
return;
|
|
526
|
+
if (document.querySelector('meta[name="theme-color"]'))
|
|
527
|
+
return;
|
|
528
|
+
const meta = document.createElement('meta');
|
|
529
|
+
meta.name = 'theme-color';
|
|
530
|
+
meta.setAttribute('content', resolvePageBackgroundColor());
|
|
531
|
+
document.head.appendChild(meta);
|
|
532
|
+
themeColorMeta = meta;
|
|
533
|
+
}
|
|
534
|
+
/** Remove the theme-color tag we added on open (no-op if we never added one). */
|
|
535
|
+
function restoreThemeColor() {
|
|
536
|
+
themeColorMeta?.remove();
|
|
537
|
+
themeColorMeta = null;
|
|
538
|
+
}
|
|
539
|
+
// ============================================
|
|
263
540
|
// GESTURE UTILITIES
|
|
264
541
|
// ============================================
|
|
265
542
|
function pushHistory(y) {
|
|
@@ -282,23 +559,15 @@ function calculateVelocity() {
|
|
|
282
559
|
const dt = last.time - first.time;
|
|
283
560
|
return dt > 0 ? (last.y - first.y) / dt : 0;
|
|
284
561
|
}
|
|
285
|
-
function
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
const speed = Math.abs((last.y - first.y) / (last.time - first.time));
|
|
295
|
-
const { minDuration, maxDuration } = CONFIG.animation;
|
|
296
|
-
let duration = maxDuration - (speed / 2) * (maxDuration - minDuration);
|
|
297
|
-
duration = Math.max(minDuration, Math.min(maxDuration, duration));
|
|
298
|
-
return `${duration}s`;
|
|
299
|
-
}
|
|
300
|
-
function overdragResistance(value) {
|
|
301
|
-
return value < 0 ? -Math.pow(Math.abs(value), CONFIG.gesture.overdragExponent) : value;
|
|
562
|
+
function clampAboveMaxSnap(translateY) {
|
|
563
|
+
// The sheet is only as tall as its MAX snap point, with its bottom pinned to the
|
|
564
|
+
// screen bottom (translateY 0 == fully open to max). Letting translateY go below
|
|
565
|
+
// that (negative) would lift the sheet's bottom edge off the screen and reveal the
|
|
566
|
+
// background in the gap beneath it — which looks broken. So pin it: you cannot drag
|
|
567
|
+
// the sheet frame above its largest detent. This is also what native iOS does — the
|
|
568
|
+
// frame is firm at the top detent; only inner scroll content overscrolls.
|
|
569
|
+
const topLimit = sheetHeight - maxSnapPoint * effectiveHeight;
|
|
570
|
+
return translateY < topLimit ? topLimit : translateY;
|
|
302
571
|
}
|
|
303
572
|
function applyLockedDragResistance(rawTranslateY) {
|
|
304
573
|
// Base translateY when at start snap point: sheetHeight - (startSnapPoint * effectiveHeight)
|
|
@@ -365,6 +634,15 @@ function isInteractive(target) {
|
|
|
365
634
|
function shouldIgnoreDrag(target) {
|
|
366
635
|
return !!target.closest('[data-bottom-sheet-no-drag]');
|
|
367
636
|
}
|
|
637
|
+
/**
|
|
638
|
+
* Resolve the scrollable region ([data-bottom-sheet-scroll]) that contains the
|
|
639
|
+
* gesture target. Resolved per-gesture (not once at open-time) so that content
|
|
640
|
+
* mounted lazily after the sheet opened — and nested/multiple scroll regions —
|
|
641
|
+
* are detected correctly. Returns the nearest scrollable ancestor, or null.
|
|
642
|
+
*/
|
|
643
|
+
function getScrollableFromTarget(target) {
|
|
644
|
+
return target.closest('[data-bottom-sheet-scroll]');
|
|
645
|
+
}
|
|
368
646
|
/**
|
|
369
647
|
* Install a one-shot capture-phase click listener to suppress the ghost click
|
|
370
648
|
* that fires after a committed drag from an interactive element.
|
|
@@ -415,7 +693,7 @@ function commitToDrag(clientY) {
|
|
|
415
693
|
dragState.currentY = currentTranslateY;
|
|
416
694
|
clearHistory();
|
|
417
695
|
pushHistory(clientY);
|
|
418
|
-
|
|
696
|
+
interruptAnimation();
|
|
419
697
|
if (scaleTarget)
|
|
420
698
|
scaleTarget.style.transition = 'none';
|
|
421
699
|
isDragging = true;
|
|
@@ -458,7 +736,9 @@ function handleTouchStart(e) {
|
|
|
458
736
|
return;
|
|
459
737
|
const target = e.target;
|
|
460
738
|
const inDraggableArea = isInDraggableArea(target);
|
|
461
|
-
|
|
739
|
+
// Resolve scrollable region from the actual target so lazily-mounted content is detected.
|
|
740
|
+
scrollableElement = getScrollableFromTarget(target);
|
|
741
|
+
const inScrollable = !!scrollableElement;
|
|
462
742
|
const isInteractiveTarget = isInteractive(target);
|
|
463
743
|
// data-bottom-sheet-no-drag: treat as fully default element — no tracking at all
|
|
464
744
|
if (shouldIgnoreDrag(target) && !inDraggableArea) {
|
|
@@ -484,8 +764,8 @@ function handleTouchStart(e) {
|
|
|
484
764
|
dragState.committed = false;
|
|
485
765
|
dragState.inScrollable = inScrollable;
|
|
486
766
|
dragState.shouldPreventScroll = true; // Will be used if we commit
|
|
487
|
-
// Do NOT set isDragging
|
|
488
|
-
//
|
|
767
|
+
// Do NOT set isDragging or interrupt the animation yet — allow native
|
|
768
|
+
// touch behavior until the gesture commits, and don't call e.preventDefault().
|
|
489
769
|
return;
|
|
490
770
|
}
|
|
491
771
|
// Non-interactive element: immediately commit to dragging (existing behavior)
|
|
@@ -493,7 +773,7 @@ function handleTouchStart(e) {
|
|
|
493
773
|
dragState.isInteractiveStart = false;
|
|
494
774
|
dragState.committed = true;
|
|
495
775
|
dragState.shouldPreventScroll = !(inScrollable && isScrolledToTop && !inDraggableArea);
|
|
496
|
-
|
|
776
|
+
interruptAnimation();
|
|
497
777
|
if (scaleTarget)
|
|
498
778
|
scaleTarget.style.transition = 'none';
|
|
499
779
|
isDragging = true;
|
|
@@ -541,18 +821,12 @@ function handleTouchMove(e) {
|
|
|
541
821
|
return;
|
|
542
822
|
e.preventDefault();
|
|
543
823
|
let translateY = dragState.currentY + moveDelta;
|
|
544
|
-
|
|
545
|
-
translateY = overdragResistance(translateY);
|
|
824
|
+
translateY = clampAboveMaxSnap(translateY);
|
|
546
825
|
if (!dragToClose)
|
|
547
826
|
translateY = applyLockedDragResistance(translateY);
|
|
548
827
|
translateY = Math.min(translateY, sheetHeight);
|
|
549
828
|
currentTranslateY = translateY;
|
|
550
|
-
|
|
551
|
-
sheetRef.style.translate = `0 ${translateY}px`;
|
|
552
|
-
const progress = Math.max(0, Math.min(maxSnapPoint, (sheetHeight - translateY) / effectiveHeight));
|
|
553
|
-
updateBackdrop(progress);
|
|
554
|
-
updateScaleTarget(progress, false);
|
|
555
|
-
bottomSheetStore.updateProgress(id, progress);
|
|
829
|
+
renderPosition(translateY);
|
|
556
830
|
}
|
|
557
831
|
function handleTouchEnd(e) {
|
|
558
832
|
if (e.touches.length > 0 || !dragState.isTouchActive)
|
|
@@ -574,19 +848,7 @@ function handleTouchEnd(e) {
|
|
|
574
848
|
}
|
|
575
849
|
isDragging = false;
|
|
576
850
|
dragState.shouldPreventScroll = false;
|
|
577
|
-
|
|
578
|
-
if (nearestSnap === 0 && dragToClose) {
|
|
579
|
-
doClose();
|
|
580
|
-
}
|
|
581
|
-
else {
|
|
582
|
-
snapTo(nearestSnap > 0 ? nearestSnap : dragState.startSnapPoint, () => {
|
|
583
|
-
if (!open) {
|
|
584
|
-
prevOpen = true;
|
|
585
|
-
open = true;
|
|
586
|
-
onOpenChange?.(true);
|
|
587
|
-
}
|
|
588
|
-
});
|
|
589
|
-
}
|
|
851
|
+
settleFromRelease();
|
|
590
852
|
}
|
|
591
853
|
// ============================================
|
|
592
854
|
// POINTER EVENT HANDLERS (Desktop)
|
|
@@ -596,7 +858,9 @@ function handlePointerDown(e) {
|
|
|
596
858
|
return;
|
|
597
859
|
const target = e.target;
|
|
598
860
|
const inDraggableArea = isInDraggableArea(target);
|
|
599
|
-
|
|
861
|
+
// Resolve scrollable region from the actual target so lazily-mounted content is detected.
|
|
862
|
+
scrollableElement = getScrollableFromTarget(target);
|
|
863
|
+
const inScrollable = !!scrollableElement;
|
|
600
864
|
const isInteractiveTarget = isInteractive(target);
|
|
601
865
|
const shouldIgnoreDragTarget = shouldIgnoreDrag(target);
|
|
602
866
|
// data-bottom-sheet-no-drag: treat as fully default element — no tracking at all
|
|
@@ -624,7 +888,7 @@ function handlePointerDown(e) {
|
|
|
624
888
|
dragState.isPending = false;
|
|
625
889
|
dragState.isInteractiveStart = false;
|
|
626
890
|
dragState.committed = true;
|
|
627
|
-
|
|
891
|
+
interruptAnimation();
|
|
628
892
|
if (scaleTarget)
|
|
629
893
|
scaleTarget.style.transition = 'none';
|
|
630
894
|
sheetRef?.setPointerCapture(e.pointerId);
|
|
@@ -665,18 +929,12 @@ function handlePointerMove(e) {
|
|
|
665
929
|
dragState.lastY = clientY;
|
|
666
930
|
pushHistory(clientY);
|
|
667
931
|
let translateY = dragState.currentY + clientY - dragState.startY;
|
|
668
|
-
|
|
669
|
-
translateY = overdragResistance(translateY);
|
|
932
|
+
translateY = clampAboveMaxSnap(translateY);
|
|
670
933
|
if (!dragToClose)
|
|
671
934
|
translateY = applyLockedDragResistance(translateY);
|
|
672
935
|
translateY = Math.min(translateY, sheetHeight);
|
|
673
936
|
currentTranslateY = translateY;
|
|
674
|
-
|
|
675
|
-
sheetRef.style.translate = `0 ${translateY}px`;
|
|
676
|
-
const progress = Math.max(0, Math.min(maxSnapPoint, (sheetHeight - translateY) / effectiveHeight));
|
|
677
|
-
updateBackdrop(progress);
|
|
678
|
-
updateScaleTarget(progress, false);
|
|
679
|
-
bottomSheetStore.updateProgress(id, progress);
|
|
937
|
+
renderPosition(translateY);
|
|
680
938
|
e.preventDefault();
|
|
681
939
|
}
|
|
682
940
|
function handlePointerUp(e) {
|
|
@@ -701,19 +959,7 @@ function handlePointerUp(e) {
|
|
|
701
959
|
if (!isDragging)
|
|
702
960
|
return;
|
|
703
961
|
isDragging = false;
|
|
704
|
-
|
|
705
|
-
if (nearestSnap === 0 && dragToClose) {
|
|
706
|
-
doClose();
|
|
707
|
-
}
|
|
708
|
-
else {
|
|
709
|
-
snapTo(nearestSnap > 0 ? nearestSnap : dragState.startSnapPoint, () => {
|
|
710
|
-
if (!open) {
|
|
711
|
-
prevOpen = true;
|
|
712
|
-
open = true;
|
|
713
|
-
onOpenChange?.(true);
|
|
714
|
-
}
|
|
715
|
-
});
|
|
716
|
-
}
|
|
962
|
+
settleFromRelease();
|
|
717
963
|
}
|
|
718
964
|
function handlePointerCancel(e) {
|
|
719
965
|
if (e.pointerType === 'touch' || dragState.activePointerId !== e.pointerId)
|
|
@@ -733,14 +979,28 @@ function handlePointerCancel(e) {
|
|
|
733
979
|
if (!isDragging)
|
|
734
980
|
return;
|
|
735
981
|
isDragging = false;
|
|
736
|
-
|
|
982
|
+
// No velocity on cancel — settle to the nearest snap point at rest.
|
|
983
|
+
const target = sheetHeight - findNearestSnapPoint(currentTranslateY, 0) * effectiveHeight;
|
|
984
|
+
animateTo(target, CONFIG.animation.snap, 0);
|
|
737
985
|
}
|
|
738
986
|
function handleCloseButtonClick(e) {
|
|
739
987
|
e.stopPropagation();
|
|
740
988
|
doClose();
|
|
741
989
|
}
|
|
990
|
+
function handleBackdropPointerDown(e) {
|
|
991
|
+
// Record a press that begins ON the backdrop (and only the backdrop). Used to tell
|
|
992
|
+
// a deliberate backdrop tap apart from the ghost pointerup described below.
|
|
993
|
+
backdropPressPointerId = e.target === backdropRef ? e.pointerId : null;
|
|
994
|
+
}
|
|
742
995
|
function handleBackdropClick(e) {
|
|
743
|
-
|
|
996
|
+
// Dismiss only when THIS press both started and ended on the backdrop. Gating on the
|
|
997
|
+
// press origin (not an isAnimating timer) keeps a deliberate tap instant — even mid
|
|
998
|
+
// open-animation — while ignoring the stray release that fires when a press begun
|
|
999
|
+
// elsewhere lifts over a just-mounted backdrop (e.g. long-press a button to open the
|
|
1000
|
+
// sheet, then release: that pointerup lands on the backdrop but never pressed it).
|
|
1001
|
+
const startedOnBackdrop = backdropPressPointerId === e.pointerId;
|
|
1002
|
+
backdropPressPointerId = null;
|
|
1003
|
+
if (!closeOnBackdrop || e.target !== backdropRef || isDragging || !startedOnBackdrop)
|
|
744
1004
|
return;
|
|
745
1005
|
doClose();
|
|
746
1006
|
}
|
|
@@ -763,85 +1023,101 @@ function touchEvents(node) {
|
|
|
763
1023
|
}
|
|
764
1024
|
};
|
|
765
1025
|
}
|
|
766
|
-
</script>
|
|
767
|
-
|
|
768
|
-
<svelte:window onkeydown={handleKeydown}/>
|
|
769
|
-
|
|
770
|
-
{#if isVisible}
|
|
771
|
-
<!-- Backdrop -->
|
|
772
|
-
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
773
|
-
<div
|
|
774
|
-
bind:this={backdropRef}
|
|
775
|
-
class={r.backdrop()}
|
|
776
|
-
style:z-index={zIndex}
|
|
777
|
-
|
|
778
|
-
onpointerup={handleBackdropClick}
|
|
779
|
-
></div>
|
|
780
|
-
|
|
781
|
-
<!-- Sheet -->
|
|
782
|
-
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
783
|
-
<div
|
|
784
|
-
bind:this={sheetRef}
|
|
785
|
-
class={r.sheet()}
|
|
786
|
-
style:z-index={zIndex + 1}
|
|
787
|
-
style:height="{maxSheetHeight}px"
|
|
788
|
-
|
|
789
|
-
role="dialog"
|
|
790
|
-
aria-modal="true"
|
|
791
|
-
tabindex={-1}
|
|
792
|
-
use:touchEvents
|
|
793
|
-
onpointerdown={handlePointerDown}
|
|
794
|
-
onpointermove={handlePointerMove}
|
|
795
|
-
onpointerup={handlePointerUp}
|
|
796
|
-
onpointercancel={handlePointerCancel}
|
|
797
|
-
>
|
|
798
|
-
<!-- Drag Handle -->
|
|
799
|
-
<div class={r.handle()} data-bottom-sheet-handle>
|
|
800
|
-
<div class={r.handleBar()}></div>
|
|
801
|
-
</div>
|
|
802
|
-
|
|
803
|
-
<!-- Header -->
|
|
804
|
-
{#if header}
|
|
805
|
-
<div class={r.header()} data-bottom-sheet-header>
|
|
806
|
-
{@render header()}
|
|
807
|
-
{#if showCloseButton}
|
|
808
|
-
<button
|
|
809
|
-
class={r.closeButton()}
|
|
810
|
-
data-bottom-sheet-close
|
|
811
|
-
onclick={handleCloseButtonClick}
|
|
812
|
-
aria-label="Close"
|
|
813
|
-
type="button"
|
|
814
|
-
>
|
|
815
|
-
<IconX class="size-4"/>
|
|
816
|
-
</button>
|
|
817
|
-
{/if}
|
|
818
|
-
</div>
|
|
819
|
-
{/if}
|
|
820
|
-
|
|
821
|
-
<!-- Content -->
|
|
822
|
-
<div bind:this={contentRef} class={r.content()} data-bottom-sheet-content>
|
|
823
|
-
{#if children}
|
|
824
|
-
{@render children()}
|
|
825
|
-
{/if}
|
|
826
|
-
</div>
|
|
827
|
-
</div>
|
|
828
|
-
{/if}
|
|
829
|
-
|
|
830
|
-
<style>
|
|
831
|
-
/* Consumer-marked scroll region inside the sheet body. */
|
|
832
|
-
[data-bottom-sheet-content] :global([data-bottom-sheet-scroll]) {
|
|
833
|
-
flex: 1;
|
|
834
|
-
overflow-y: auto;
|
|
835
|
-
overflow-x: hidden;
|
|
836
|
-
overscroll-behavior: contain;
|
|
837
|
-
-webkit-overflow-scrolling: touch;
|
|
838
|
-
min-height: 0;
|
|
839
|
-
touch-action: pan-y;
|
|
840
|
-
}
|
|
841
|
-
|
|
842
|
-
/* Suppress native gestures on header children so the drag is captured. */
|
|
843
|
-
[data-bottom-sheet-header] :global(*) {
|
|
844
|
-
touch-action: none;
|
|
845
|
-
user-select: none;
|
|
846
|
-
}
|
|
847
|
-
|
|
1026
|
+
</script>
|
|
1027
|
+
|
|
1028
|
+
<svelte:window onkeydown={handleKeydown}/>
|
|
1029
|
+
|
|
1030
|
+
{#if isVisible}
|
|
1031
|
+
<!-- Backdrop -->
|
|
1032
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
1033
|
+
<div
|
|
1034
|
+
bind:this={backdropRef}
|
|
1035
|
+
class={r.backdrop()}
|
|
1036
|
+
style:z-index={zIndex}
|
|
1037
|
+
onpointerdown={handleBackdropPointerDown}
|
|
1038
|
+
onpointerup={handleBackdropClick}
|
|
1039
|
+
></div>
|
|
1040
|
+
|
|
1041
|
+
<!-- Sheet -->
|
|
1042
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
1043
|
+
<div
|
|
1044
|
+
bind:this={sheetRef}
|
|
1045
|
+
class={r.sheet()}
|
|
1046
|
+
style:z-index={zIndex + 1}
|
|
1047
|
+
style:height="{maxSheetHeight}px"
|
|
1048
|
+
data-bottom-sheet-surface
|
|
1049
|
+
role="dialog"
|
|
1050
|
+
aria-modal="true"
|
|
1051
|
+
tabindex={-1}
|
|
1052
|
+
use:touchEvents
|
|
1053
|
+
onpointerdown={handlePointerDown}
|
|
1054
|
+
onpointermove={handlePointerMove}
|
|
1055
|
+
onpointerup={handlePointerUp}
|
|
1056
|
+
onpointercancel={handlePointerCancel}
|
|
1057
|
+
>
|
|
1058
|
+
<!-- Drag Handle -->
|
|
1059
|
+
<div class={r.handle()} data-bottom-sheet-handle>
|
|
1060
|
+
<div class={r.handleBar()}></div>
|
|
1061
|
+
</div>
|
|
1062
|
+
|
|
1063
|
+
<!-- Header -->
|
|
1064
|
+
{#if header}
|
|
1065
|
+
<div class={r.header()} data-bottom-sheet-header>
|
|
1066
|
+
{@render header()}
|
|
1067
|
+
{#if showCloseButton}
|
|
1068
|
+
<button
|
|
1069
|
+
class={r.closeButton()}
|
|
1070
|
+
data-bottom-sheet-close
|
|
1071
|
+
onclick={handleCloseButtonClick}
|
|
1072
|
+
aria-label="Close"
|
|
1073
|
+
type="button"
|
|
1074
|
+
>
|
|
1075
|
+
<IconX class="size-4"/>
|
|
1076
|
+
</button>
|
|
1077
|
+
{/if}
|
|
1078
|
+
</div>
|
|
1079
|
+
{/if}
|
|
1080
|
+
|
|
1081
|
+
<!-- Content -->
|
|
1082
|
+
<div bind:this={contentRef} class={r.content()} data-bottom-sheet-content>
|
|
1083
|
+
{#if children}
|
|
1084
|
+
{@render children()}
|
|
1085
|
+
{/if}
|
|
1086
|
+
</div>
|
|
1087
|
+
</div>
|
|
1088
|
+
{/if}
|
|
1089
|
+
|
|
1090
|
+
<style>
|
|
1091
|
+
/* Consumer-marked scroll region inside the sheet body. */
|
|
1092
|
+
[data-bottom-sheet-content] :global([data-bottom-sheet-scroll]) {
|
|
1093
|
+
flex: 1;
|
|
1094
|
+
overflow-y: auto;
|
|
1095
|
+
overflow-x: hidden;
|
|
1096
|
+
overscroll-behavior: contain;
|
|
1097
|
+
-webkit-overflow-scrolling: touch;
|
|
1098
|
+
min-height: 0;
|
|
1099
|
+
touch-action: pan-y;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
/* Suppress native gestures on header children so the drag is captured. */
|
|
1103
|
+
[data-bottom-sheet-header] :global(*) {
|
|
1104
|
+
touch-action: none;
|
|
1105
|
+
user-select: none;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/* Extend the sheet's own surface below its bottom edge. The sheet is only as tall as
|
|
1109
|
+
its max snap point; if it ever sits a hair above the screen bottom — the open spring's
|
|
1110
|
+
slight overshoot, or sub-pixel rounding — this keeps the sliver beneath it sheet-colored
|
|
1111
|
+
instead of letting the background show through. The sheet is overflow-visible so this is
|
|
1112
|
+
not clipped, and it rides along with the sheet's transform. */
|
|
1113
|
+
:global([data-bottom-sheet-surface]::after) {
|
|
1114
|
+
content: '';
|
|
1115
|
+
position: absolute;
|
|
1116
|
+
top: 100%;
|
|
1117
|
+
left: 0;
|
|
1118
|
+
right: 0;
|
|
1119
|
+
height: 100%;
|
|
1120
|
+
background-color: inherit;
|
|
1121
|
+
pointer-events: none;
|
|
1122
|
+
}
|
|
1123
|
+
</style>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tera-system-ui",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"scripts": {
|
|
5
5
|
"dev": "vite dev",
|
|
6
6
|
"build": "npm run customPrepublish && npm run generate-index && npm run generate-registry && npm run generate-llms && vite build && npm run package && npm run copy-docs && npm run copy-llms && npm run postpublish",
|
|
@@ -1,50 +1,50 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "https://tera-system-ui/registry-item.schema.json",
|
|
3
|
-
"name": "bottom-sheet",
|
|
4
|
-
"type": "registry:component",
|
|
5
|
-
"description": "Draggable iOS-style bottom sheet with snap points, container-scale effect, and stacking.",
|
|
6
|
-
"recipe": "src/lib/components/bottom-sheet/BottomSheet.recipe.ts",
|
|
7
|
-
"files": [
|
|
8
|
-
{
|
|
9
|
-
"path": "src/lib/components/bottom-sheet/BottomSheet.recipe.ts",
|
|
10
|
-
"target": "BottomSheet.recipe.ts",
|
|
11
|
-
"type": "recipe",
|
|
12
|
-
"content": "import {tv, type VariantProps} from \"tailwind-variants\";\nimport type {Snippet} from \"svelte\";\n\n/**\n * BottomSheet recipe — the canonical styled layer for the draggable sheet.\n *\n * An iOS-style bottom sheet with snap points, gesture dragging, container-scale\n * effect, and stacking (custom behavior — not bits-ui). The recipe is slotted by\n * role — backdrop / sheet / handle / handleBar / header / closeButton / content —\n * so an app can restyle any part independently. Only the *themeable, static*\n * surface lives here; the per-frame transforms (translate / scale / opacity) are\n * driven imperatively from the component and the runtime transition duration is\n * passed through the `--transition-duration` CSS var.\n *\n * The `constrained` variant switches fixed → absolute positioning when the sheet\n * is bounded inside a container; `dragging` carries the grabbing cursor.\n *\n * Primary customization surface: extend with `tv({ extend: bottomSheetRecipe, ... })`\n * in typed code. Lighter rung: the per-component CSS var (--tera-bottom-sheet-radius)\n * retunes the top corner radius with no code change; it defaults to the prior\n * literal value inline.\n */\nexport const bottomSheetRecipe = tv({\n slots: {\n backdrop: 'fixed inset-0 bg-black opacity-0 touch-none [will-change:opacity] [-webkit-tap-highlight-color:transparent] transition-opacity duration-[var(--transition-duration,0.35s)] ease-out',\n sheet: 'fixed bottom-0 left-0 right-0 flex flex-col overflow-visible bg-surface-raised rounded-t-[var(--tera-bottom-sheet-radius,12px)]
|
|
13
|
-
},
|
|
14
|
-
{
|
|
15
|
-
"path": "src/lib/components/bottom-sheet/BottomSheet.svelte",
|
|
16
|
-
"target": "BottomSheet.svelte",
|
|
17
|
-
"type": "svelte",
|
|
18
|
-
"content": "<script lang=\"ts\">\n import {onDestroy, onMount} from 'svelte';\n import {bottomSheetStore} from './bottomSheetStore.svelte.js';\n import {bottomSheetRecipe, type BottomSheetProps} from './BottomSheet.recipe';\n import {IconX} from \"$lib/internal/icons\";\n\n // ============================================\n // CONFIGURABLE CONSTANTS\n // Adjust these values to customize the bottom sheet behavior\n // ============================================\n\n const CONFIG = {\n // Scale effect for background container\n scale: {\n factor: 0.06, // How much to scale down (6%)\n borderRadius: 16, // Border radius when scaled (px)\n translateY: 20, // Vertical offset when scaled (px)\n origin: 'top center' // Transform origin\n },\n\n // Backdrop\n backdrop: {\n maxOpacity: 0.5 // Maximum opacity when fully open\n },\n\n // Animation timing\n animation: {\n defaultDuration: 0.35, // Default duration (seconds)\n minDuration: 0.15, // Min duration for fast swipes\n maxDuration: 0.4, // Max duration for slow gestures\n easing: 'cubic-bezier(0.2, 0.9, 0.36, 1)' // iOS-style spring curve\n },\n\n // Gesture handling\n gesture: {\n velocityInfluence: 0.01, // How much velocity affects snap selection\n overdragExponent: 0.55, // Overdrag resistance (lower = more resistance)\n closeVelocityThreshold: 0.35, // Velocity to trigger close (px/ms)\n closeProgressThreshold: 0.35, // Progress to trigger close (0-1)\n maxLockedDrag: 60, // Max drag when dragToClose disabled (px)\n lockedDragResistance: 0.3, // Resistance when dragToClose disabled\n // Two-phase gesture recognition for interactive elements (iOS-style)\n dragActivationThreshold: 10, // Vertical px to commit to drag from interactive element\n horizontalDeadZone: 1.2, // If abs(dx)/abs(dy) > this, abort drag (let native horizontal win)\n tapDistanceThreshold: 6 // Max total movement (px) to still count as a tap\n }\n } as const;\n\n // ============================================\n // COMPONENT PROPS\n // ============================================\n\n let {\n id,\n open = $bindable(false),\n snapPoints = [0.9],\n defaultSnapPoint = 0,\n containerRef = null,\n scaleTargetRef = null,\n constrainToContainer = false,\n scaleBackground = true,\n dragToClose = true,\n closeOnBackdrop = true,\n showCloseButton = false,\n onClose,\n onOpenChange,\n children,\n header\n }: BottomSheetProps = $props();\n\n // ============================================\n // INTERNAL STATE\n // ============================================\n\n // DOM references\n let sheetRef = $state<HTMLElement | null>(null);\n let contentRef = $state<HTMLElement | null>(null);\n let backdropRef = $state<HTMLElement | null>(null);\n\n // Sheet state\n let isVisible = $state(false);\n let isClosing = $state(false);\n let isAnimating = $state(false);\n let isDragging = $state(false);\n let currentTranslateY = $state(0);\n let sheetHeight = $state(0);\n let transitionDuration = $state(`${CONFIG.animation.defaultDuration}s`);\n\n // Drag tracking\n let dragState = {\n startY: 0,\n startX: 0,\n lastY: 0,\n currentY: 0,\n deltaY: 0,\n startSnapPoint: 0,\n activePointerId: null as number | null,\n isTouchActive: false,\n shouldPreventScroll: false,\n history: [] as { y: number; time: number }[],\n // Two-phase gesture recognition for interactive elements\n isPending: false, // Tracking started but not yet committed to drag\n isInteractiveStart: false, // Gesture began on an interactive element\n committed: false, // Gesture has been resolved as a drag\n inScrollable: false // Gesture began inside a [data-bottom-sheet-scroll] area\n };\n\n // Scroll state\n let scrollableElement: HTMLElement | null = null;\n let isScrolledToTop = true;\n\n // Dimensions\n let windowHeight = $state(typeof window !== 'undefined' ? window.innerHeight : 800);\n let containerHeight = $state(0);\n let containerResizeObserver: ResizeObserver | null = null;\n let prevOpen: boolean | null = null;\n\n // ============================================\n // COMPUTED VALUES\n // ============================================\n\n const r = $derived(bottomSheetRecipe({constrained: constrainToContainer, dragging: isDragging}));\n\n const scaleTarget = $derived(scaleTargetRef ?? containerRef);\n const effectiveHeight = $derived(\n constrainToContainer && containerRef ? containerHeight : windowHeight\n );\n const maxSnapPoint = $derived(Math.max(...snapPoints));\n const maxSheetHeight = $derived(maxSnapPoint * effectiveHeight);\n const zIndex = $derived(bottomSheetStore.getZIndex(id));\n const currentSnapPoint = $derived(snapPoints[defaultSnapPoint] ?? 0.5);\n const allSnapPoints = $derived([0, ...snapPoints].sort((a, b) => a - b));\n\n // ============================================\n // LIFECYCLE\n // ============================================\n\n onMount(() => {\n bottomSheetStore.register(id, {snapPoints, defaultSnapPoint, closeOnBackdrop});\n window.addEventListener('resize', handleWindowResize);\n\n return () => {\n bottomSheetStore.unregister(id);\n window.removeEventListener('resize', handleWindowResize);\n };\n });\n\n onDestroy(() => {\n containerResizeObserver?.disconnect();\n });\n\n // Watch containerRef for constrained mode\n $effect(() => {\n if (!containerRef || !constrainToContainer) return;\n\n containerHeight = containerRef.clientHeight;\n containerResizeObserver?.disconnect();\n\n containerResizeObserver = new ResizeObserver(() => {\n containerHeight = containerRef!.clientHeight;\n if (isVisible && !isDragging && sheetRef) {\n sheetHeight = sheetRef.offsetHeight;\n }\n });\n containerResizeObserver.observe(containerRef);\n\n return () => {\n containerResizeObserver?.disconnect();\n containerResizeObserver = null;\n };\n });\n\n // Watch open prop changes\n $effect(() => {\n const shouldOpen = open;\n\n if (prevOpen === null) {\n prevOpen = shouldOpen;\n if (shouldOpen) doOpen();\n return;\n }\n\n if (shouldOpen && !prevOpen && !isVisible) {\n prevOpen = true;\n doOpen();\n } else if (!shouldOpen && prevOpen && isVisible && !isClosing) {\n prevOpen = false;\n doClose();\n }\n });\n\n // Setup scrollable element detection\n $effect(() => {\n if (!contentRef || !isVisible) return;\n\n scrollableElement = contentRef.querySelector<HTMLElement>('[data-bottom-sheet-scroll]');\n if (!scrollableElement) return;\n\n const handleScroll = () => {\n isScrolledToTop = scrollableElement!.scrollTop <= 1;\n };\n scrollableElement.addEventListener('scroll', handleScroll, {passive: true});\n isScrolledToTop = scrollableElement.scrollTop <= 1;\n });\n\n // ============================================\n // CORE FUNCTIONS\n // ============================================\n\n function handleWindowResize() {\n if (isVisible && !isDragging) {\n windowHeight = window.innerHeight;\n if (sheetRef) sheetHeight = sheetRef.offsetHeight;\n }\n }\n\n function recalculateDimensions() {\n if (typeof window !== 'undefined') windowHeight = window.innerHeight;\n if (containerRef && constrainToContainer) containerHeight = containerRef.clientHeight;\n if (sheetRef) sheetHeight = sheetRef.offsetHeight;\n }\n\n function doOpen() {\n isVisible = true;\n isClosing = false;\n isAnimating = true;\n bottomSheetStore.open(id);\n\n requestAnimationFrame(() => {\n // sheetRef is bound by this rAF tick.\n recalculateDimensions();\n requestAnimationFrame(() => snapTo(currentSnapPoint, () => {\n isAnimating = false;\n }));\n });\n }\n\n function doClose() {\n if (isClosing) return;\n isClosing = true;\n isAnimating = true;\n\n snapTo(0, () => {\n isVisible = false;\n isClosing = false;\n isAnimating = false;\n bottomSheetStore.close(id);\n prevOpen = false;\n open = false;\n resetScaleTarget();\n onClose?.();\n onOpenChange?.(false);\n });\n }\n\n function snapTo(progress: number, onComplete?: () => void) {\n transitionDuration = calcDynamicDuration();\n // Calculate translateY: sheet height is maxSnapPoint * effectiveHeight\n // When progress = maxSnapPoint, translateY = 0 (fully visible)\n // When progress = 0, translateY = sheetHeight (hidden)\n const targetTranslateY = sheetHeight - (progress * effectiveHeight);\n currentTranslateY = targetTranslateY;\n\n if (sheetRef) {\n sheetRef.style.translate = `0 ${targetTranslateY}px`;\n }\n\n updateBackdrop(progress);\n updateScaleTarget(progress);\n bottomSheetStore.updateProgress(id, Math.max(0, Math.min(1, progress)));\n\n if (onComplete) {\n setTimeout(onComplete, parseFloat(transitionDuration) * 1000);\n }\n }\n\n // ============================================\n // VISUAL UPDATES\n // ============================================\n\n function updateBackdrop(progress: number) {\n if (!backdropRef) return;\n const opacity = Math.max(0, Math.min(CONFIG.backdrop.maxOpacity, progress * CONFIG.backdrop.maxOpacity));\n backdropRef.style.opacity = String(opacity);\n }\n\n function updateScaleTarget(progress: number, withTransition = true) {\n if (!scaleTarget || !scaleBackground) return;\n\n const p = Math.max(0, Math.min(1, progress));\n const {factor, borderRadius, translateY, origin} = CONFIG.scale;\n\n scaleTarget.style.scale = String(1 - factor * p);\n scaleTarget.style.translate = `0 ${translateY * p}px`;\n scaleTarget.style.borderRadius = `${borderRadius * p}px`;\n scaleTarget.style.transformOrigin = origin;\n scaleTarget.style.overflow = 'hidden';\n scaleTarget.style.willChange = 'scale, translate, border-radius';\n\n if (withTransition) {\n const {easing} = CONFIG.animation;\n scaleTarget.style.transition = `scale ${transitionDuration} ${easing}, translate ${transitionDuration} ${easing}, border-radius ${transitionDuration} ${easing}`;\n }\n }\n\n function resetScaleTarget() {\n if (!scaleTarget || !scaleBackground) return;\n\n scaleTarget.style.scale = '';\n scaleTarget.style.translate = '';\n scaleTarget.style.borderRadius = '';\n scaleTarget.style.transformOrigin = '';\n scaleTarget.style.overflow = '';\n scaleTarget.style.transition = '';\n scaleTarget.style.willChange = '';\n }\n\n // ============================================\n // GESTURE UTILITIES\n // ============================================\n\n function pushHistory(y: number) {\n const now = performance.now();\n dragState.history = dragState.history.filter(p => now - p.time <= 100);\n dragState.history.push({y, time: now});\n }\n\n function clearHistory() {\n dragState.history = [];\n }\n\n function calculateVelocity(): number {\n const now = performance.now();\n const recent = dragState.history.filter(p => now - p.time <= 100);\n\n if (recent.length < 2) return 0;\n\n const first = recent[0];\n const last = recent[recent.length - 1];\n if (!first || !last) return 0;\n const dt = last.time - first.time;\n\n return dt > 0 ? (last.y - first.y) / dt : 0;\n }\n\n function calcDynamicDuration(): string {\n const now = performance.now();\n const recent = dragState.history.filter(p => now - p.time <= 100);\n\n if (recent.length < 2) return `${CONFIG.animation.defaultDuration}s`;\n\n const first = recent[0];\n const last = recent[recent.length - 1];\n if (!first || !last) return `${CONFIG.animation.defaultDuration}s`;\n const speed = Math.abs((last.y - first.y) / (last.time - first.time));\n const {minDuration, maxDuration} = CONFIG.animation;\n\n let duration = maxDuration - (speed / 2) * (maxDuration - minDuration);\n duration = Math.max(minDuration, Math.min(maxDuration, duration));\n\n return `${duration}s`;\n }\n\n function overdragResistance(value: number): number {\n return value < 0 ? -Math.pow(Math.abs(value), CONFIG.gesture.overdragExponent) : value;\n }\n\n function applyLockedDragResistance(rawTranslateY: number): number {\n // Base translateY when at start snap point: sheetHeight - (startSnapPoint * effectiveHeight)\n const baseTranslateY = sheetHeight - (dragState.startSnapPoint * effectiveHeight);\n const dragDistance = rawTranslateY - baseTranslateY;\n\n if (dragDistance > 0) {\n const {lockedDragResistance, maxLockedDrag} = CONFIG.gesture;\n const resistedDrag = Math.min(dragDistance * lockedDragResistance, maxLockedDrag);\n return baseTranslateY + resistedDrag;\n }\n return rawTranslateY;\n }\n\n function findNearestSnapPoint(translateY: number, velocityInfluence = 0): number {\n // Calculate progress: translateY = sheetHeight - (progress * effectiveHeight)\n // So: progress = (sheetHeight - translateY) / effectiveHeight\n const currentProgress = (sheetHeight - translateY) / effectiveHeight;\n const targetProgress = currentProgress + velocityInfluence;\n\n return allSnapPoints.reduce((nearest, sp) =>\n Math.abs(targetProgress - sp) < Math.abs(targetProgress - nearest) ? sp : nearest\n , allSnapPoints[0] ?? 0);\n }\n\n function determineSnapPoint(): number {\n // Calculate progress: translateY = sheetHeight - (progress * effectiveHeight)\n // So: progress = (sheetHeight - translateY) / effectiveHeight\n const currentProgress = (sheetHeight - currentTranslateY) / effectiveHeight;\n const velocity = calculateVelocity();\n\n if (!dragToClose) {\n return dragState.startSnapPoint;\n }\n\n const {closeVelocityThreshold, closeProgressThreshold, velocityInfluence} = CONFIG.gesture;\n\n // Fast swipe handling\n if (Math.abs(velocity) > closeVelocityThreshold) {\n if (velocity > 0) {\n // Swipe down - close or go to lower snap\n const lowerSnaps = allSnapPoints.filter(sp => sp < dragState.startSnapPoint);\n const lowest = lowerSnaps[lowerSnaps.length - 1];\n if (lowest !== undefined && currentProgress >= lowest * 0.5) {\n return lowest;\n }\n return 0;\n } else {\n // Swipe up - go to higher snap\n const higherSnaps = allSnapPoints.filter(sp => sp > currentProgress);\n return higherSnaps[0] ?? Math.max(...snapPoints);\n }\n }\n\n // Check close threshold\n const progressDrop = dragState.startSnapPoint - currentProgress;\n if (progressDrop > closeProgressThreshold) {\n return 0;\n }\n\n // Velocity is in px/ms, convert to progress units (divide by effectiveHeight, multiply by 1000 for scale)\n const velocityInProgressUnits = -velocity / effectiveHeight * 1000;\n return findNearestSnapPoint(currentTranslateY, velocityInProgressUnits * velocityInfluence);\n }\n\n // ============================================\n // TOUCH EVENT HANDLERS\n // ============================================\n\n function isInDraggableArea(target: HTMLElement): boolean {\n return !!(target.closest('[data-bottom-sheet-handle]'));\n }\n\n function isInteractive(target: HTMLElement): boolean {\n return !!target.closest('button, a, input, select, textarea, [tabindex]:not([tabindex=\"-1\"])');\n }\n\n function shouldIgnoreDrag(target: HTMLElement): boolean {\n return !!target.closest('[data-bottom-sheet-no-drag]');\n }\n\n /**\n * Install a one-shot capture-phase click listener to suppress the ghost click\n * that fires after a committed drag from an interactive element.\n */\n function suppressNextClick() {\n if (!sheetRef) return;\n const handler = (e: Event) => {\n e.preventDefault();\n e.stopPropagation();\n sheetRef?.removeEventListener('click', handler, true);\n };\n sheetRef.addEventListener('click', handler, true);\n // Safety: auto-remove after a short delay if click never fires\n setTimeout(() => sheetRef?.removeEventListener('click', handler, true), 300);\n }\n\n /**\n * Reset all pending/two-phase gesture state fields.\n */\n function resetPendingState() {\n dragState.isPending = false;\n dragState.isInteractiveStart = false;\n dragState.committed = false;\n dragState.inScrollable = false;\n }\n\n /**\n * Initialize common drag tracking fields shared by both touch and pointer paths.\n */\n function initDragTracking(clientX: number, clientY: number) {\n dragState.startX = clientX;\n dragState.startY = clientY;\n dragState.lastY = clientY;\n dragState.currentY = currentTranslateY;\n dragState.deltaY = 0;\n dragState.startSnapPoint = Math.round((sheetHeight - currentTranslateY) / effectiveHeight * 100) / 100;\n clearHistory();\n pushHistory(clientY);\n }\n\n /**\n * Commit to dragging: transition from pending state to active drag.\n * Re-anchors startY/currentY to the current position to prevent visual jump.\n */\n function commitToDrag(clientY: number) {\n dragState.isPending = false;\n dragState.committed = true;\n // Re-anchor to current position so sheet doesn't jump\n dragState.startY = clientY;\n dragState.currentY = currentTranslateY;\n clearHistory();\n pushHistory(clientY);\n\n transitionDuration = '0s';\n if (scaleTarget) scaleTarget.style.transition = 'none';\n isDragging = true;\n }\n\n /**\n * Check if the pending gesture should be resolved.\n * Returns: 'drag' if vertical threshold exceeded and should drag sheet,\n * 'abort' if horizontal wins or user wants to scroll content,\n * 'pending' if undecided.\n */\n function resolvePendingGesture(clientX: number, clientY: number): 'drag' | 'abort' | 'pending' {\n const {dragActivationThreshold, horizontalDeadZone} = CONFIG.gesture;\n const absX = Math.abs(clientX - dragState.startX);\n const absY = Math.abs(clientY - dragState.startY);\n const deltaY = clientY - dragState.startY; // positive = finger moving down\n\n // Horizontal threshold exceeded — let native behavior handle it (slider, scroll, etc.)\n if (absX >= dragActivationThreshold) {\n return 'abort';\n }\n\n // Vertical threshold exceeded and angle is more vertical than horizontal\n if (absY >= dragActivationThreshold && absY > absX * horizontalDeadZone) {\n // Inside a scrollable area: only commit to drag if pulling DOWN from top (dismiss gesture).\n // If swiping UP (to scroll content down), abort and let native scroll work.\n if (dragState.inScrollable) {\n if (scrollableElement) isScrolledToTop = scrollableElement.scrollTop <= 1;\n if (deltaY > 0 && isScrolledToTop) {\n // Pulling down from top → drag sheet (dismiss)\n return 'drag';\n }\n // Swiping up, or not at top → let native scroll handle it\n return 'abort';\n }\n return 'drag';\n }\n\n return 'pending';\n }\n\n function handleTouchStart(e: TouchEvent) {\n if (dragState.isTouchActive) return;\n\n const target = e.target as HTMLElement;\n const inDraggableArea = isInDraggableArea(target);\n const inScrollable = !!target.closest('[data-bottom-sheet-scroll]');\n const isInteractiveTarget = isInteractive(target);\n\n // data-bottom-sheet-no-drag: treat as fully default element — no tracking at all\n if (shouldIgnoreDrag(target) && !inDraggableArea) {\n return;\n }\n\n if (scrollableElement) isScrolledToTop = scrollableElement.scrollTop <= 1;\n\n if (inScrollable && !isScrolledToTop && !inDraggableArea) {\n dragState.shouldPreventScroll = false;\n return;\n }\n\n // Initialize tracking for all cases\n const touch = e.touches[0];\n if (!touch) return;\n dragState.isTouchActive = true;\n initDragTracking(touch.clientX, touch.clientY);\n\n // Interactive element (button, input, slider, etc.) NOT in draggable area:\n // Enter pending state — don't commit to drag yet, let threshold decide\n if (isInteractiveTarget && !inDraggableArea) {\n dragState.isPending = true;\n dragState.isInteractiveStart = true;\n dragState.committed = false;\n dragState.inScrollable = inScrollable;\n dragState.shouldPreventScroll = true; // Will be used if we commit\n\n // Do NOT set isDragging, transitionDuration, or disable transitions\n // Do NOT call e.preventDefault() — allow native touch behavior until committed\n return;\n }\n\n // Non-interactive element: immediately commit to dragging (existing behavior)\n dragState.isPending = false;\n dragState.isInteractiveStart = false;\n dragState.committed = true;\n dragState.shouldPreventScroll = !(inScrollable && isScrolledToTop && !inDraggableArea);\n\n transitionDuration = '0s';\n if (scaleTarget) scaleTarget.style.transition = 'none';\n isDragging = true;\n }\n\n function handleTouchMove(e: TouchEvent) {\n if (!dragState.isTouchActive) return;\n\n const touch = e.touches[0];\n if (!touch) return;\n const clientY = touch.clientY;\n const clientX = touch.clientX;\n\n // ── Two-phase: resolve pending gesture from interactive element ──\n if (dragState.isPending) {\n pushHistory(clientY);\n const result = resolvePendingGesture(clientX, clientY);\n\n if (result === 'drag') {\n // Vertical intent wins → commit to drag\n commitToDrag(clientY);\n dragState.shouldPreventScroll = true;\n e.preventDefault();\n return; // First real drag frame happens on next move\n }\n if (result === 'abort') {\n // Horizontal intent wins → abort, let native behavior (slider, etc.)\n dragState.isPending = false;\n dragState.committed = false;\n dragState.isTouchActive = false;\n dragState.isInteractiveStart = false;\n return;\n }\n // Still pending — do nothing, let native behavior continue\n return;\n }\n\n // ── Normal drag logic (committed) ──\n const moveDelta = clientY - dragState.startY;\n dragState.deltaY = clientY - dragState.lastY;\n dragState.lastY = clientY;\n pushHistory(clientY);\n\n if (scrollableElement) isScrolledToTop = scrollableElement.scrollTop <= 1;\n if (isScrolledToTop && moveDelta > 0) dragState.shouldPreventScroll = true;\n if (!dragState.shouldPreventScroll) return;\n\n e.preventDefault();\n\n let translateY = dragState.currentY + moveDelta;\n if (translateY < 0) translateY = overdragResistance(translateY);\n if (!dragToClose) translateY = applyLockedDragResistance(translateY);\n translateY = Math.min(translateY, sheetHeight);\n\n currentTranslateY = translateY;\n if (sheetRef) sheetRef.style.translate = `0 ${translateY}px`;\n\n const progress = Math.max(0, Math.min(maxSnapPoint, (sheetHeight - translateY) / effectiveHeight));\n updateBackdrop(progress);\n updateScaleTarget(progress, false);\n bottomSheetStore.updateProgress(id, progress);\n }\n\n function handleTouchEnd(e: TouchEvent) {\n if (e.touches.length > 0 || !dragState.isTouchActive) return;\n\n dragState.isTouchActive = false;\n\n // ── Two-phase: pending never resolved (tap) → let native click fire ──\n if (dragState.isPending) {\n resetPendingState();\n return;\n }\n\n // ── Committed drag from interactive element → suppress ghost click ──\n if (dragState.committed && dragState.isInteractiveStart) {\n suppressNextClick();\n }\n resetPendingState();\n\n if (!dragState.shouldPreventScroll) {\n isDragging = false;\n return;\n }\n\n isDragging = false;\n dragState.shouldPreventScroll = false;\n\n const nearestSnap = determineSnapPoint();\n\n if (nearestSnap === 0 && dragToClose) {\n doClose();\n } else {\n snapTo(nearestSnap > 0 ? nearestSnap : dragState.startSnapPoint, () => {\n if (!open) {\n prevOpen = true;\n open = true;\n onOpenChange?.(true);\n }\n });\n }\n }\n\n // ============================================\n // POINTER EVENT HANDLERS (Desktop)\n // ============================================\n\n function handlePointerDown(e: PointerEvent) {\n if (e.pointerType === 'touch' || e.button !== 0 || dragState.activePointerId !== null) return;\n\n const target = e.target as HTMLElement;\n const inDraggableArea = isInDraggableArea(target);\n const inScrollable = !!target.closest('[data-bottom-sheet-scroll]');\n const isInteractiveTarget = isInteractive(target);\n const shouldIgnoreDragTarget = shouldIgnoreDrag(target);\n\n // data-bottom-sheet-no-drag: treat as fully default element — no tracking at all\n if (shouldIgnoreDragTarget && !inDraggableArea) return;\n\n if (scrollableElement) isScrolledToTop = scrollableElement.scrollTop <= 1;\n if (inScrollable && !isScrolledToTop && !inDraggableArea) return;\n\n dragState.activePointerId = e.pointerId;\n initDragTracking(e.clientX, e.clientY);\n\n // Interactive element NOT in draggable area:\n // Enter pending state — don't commit yet, preserve native focus/click\n if (isInteractiveTarget && !inDraggableArea) {\n dragState.isPending = true;\n dragState.isInteractiveStart = true;\n dragState.committed = false;\n dragState.inScrollable = inScrollable;\n\n // Do NOT call e.preventDefault() — preserve focus for inputs\n // Do NOT call setPointerCapture — allow native hover/click behavior\n // Do NOT set isDragging or disable transitions\n return;\n }\n\n // Non-interactive element: immediately commit to dragging\n dragState.isPending = false;\n dragState.isInteractiveStart = false;\n dragState.committed = true;\n\n transitionDuration = '0s';\n if (scaleTarget) scaleTarget.style.transition = 'none';\n sheetRef?.setPointerCapture(e.pointerId);\n isDragging = true;\n e.preventDefault();\n }\n\n function handlePointerMove(e: PointerEvent) {\n if (e.pointerType === 'touch' || dragState.activePointerId !== e.pointerId) return;\n if (!isDragging && !dragState.isPending) return;\n\n const clientY = e.clientY;\n const clientX = e.clientX;\n\n // ── Two-phase: resolve pending gesture from interactive element ──\n if (dragState.isPending) {\n pushHistory(clientY);\n const result = resolvePendingGesture(clientX, clientY);\n\n if (result === 'drag') {\n // Vertical intent wins → commit to drag\n commitToDrag(clientY);\n sheetRef?.setPointerCapture(e.pointerId);\n e.preventDefault();\n return; // First real drag frame happens on next move\n }\n if (result === 'abort') {\n // Horizontal intent wins → abort, let native behavior\n dragState.isPending = false;\n dragState.committed = false;\n dragState.activePointerId = null;\n dragState.isInteractiveStart = false;\n return;\n }\n // Still pending — do nothing\n return;\n }\n\n // ── Normal drag logic (committed) ──\n dragState.deltaY = clientY - dragState.lastY;\n dragState.lastY = clientY;\n pushHistory(clientY);\n\n let translateY = dragState.currentY + clientY - dragState.startY;\n if (translateY < 0) translateY = overdragResistance(translateY);\n if (!dragToClose) translateY = applyLockedDragResistance(translateY);\n translateY = Math.min(translateY, sheetHeight);\n\n currentTranslateY = translateY;\n if (sheetRef) sheetRef.style.translate = `0 ${translateY}px`;\n\n const progress = Math.max(0, Math.min(maxSnapPoint, (sheetHeight - translateY) / effectiveHeight));\n updateBackdrop(progress);\n updateScaleTarget(progress, false);\n bottomSheetStore.updateProgress(id, progress);\n\n e.preventDefault();\n }\n\n function handlePointerUp(e: PointerEvent) {\n if (e.pointerType === 'touch' || dragState.activePointerId !== e.pointerId) return;\n\n try {\n sheetRef?.releasePointerCapture(e.pointerId);\n } catch {\n }\n dragState.activePointerId = null;\n\n // ── Two-phase: pending never resolved (tap) → let native click fire ──\n if (dragState.isPending) {\n resetPendingState();\n return;\n }\n\n // ── Committed drag from interactive element → suppress ghost click ──\n if (dragState.committed && dragState.isInteractiveStart) {\n suppressNextClick();\n }\n resetPendingState();\n\n if (!isDragging) return;\n isDragging = false;\n\n const nearestSnap = determineSnapPoint();\n\n if (nearestSnap === 0 && dragToClose) {\n doClose();\n } else {\n snapTo(nearestSnap > 0 ? nearestSnap : dragState.startSnapPoint, () => {\n if (!open) {\n prevOpen = true;\n open = true;\n onOpenChange?.(true);\n }\n });\n }\n }\n\n function handlePointerCancel(e: PointerEvent) {\n if (e.pointerType === 'touch' || dragState.activePointerId !== e.pointerId) return;\n\n try {\n sheetRef?.releasePointerCapture(e.pointerId);\n } catch {\n }\n dragState.activePointerId = null;\n\n // If pending, just reset — nothing visual happened\n if (dragState.isPending) {\n resetPendingState();\n return;\n }\n resetPendingState();\n\n if (!isDragging) return;\n isDragging = false;\n\n snapTo(findNearestSnapPoint(currentTranslateY, 0));\n }\n\n function handleCloseButtonClick(e: MouseEvent) {\n e.stopPropagation();\n doClose();\n }\n\n function handleBackdropClick(e: PointerEvent) {\n if (!closeOnBackdrop || e.target !== backdropRef || isDragging || isAnimating) return;\n doClose();\n }\n\n function handleKeydown(e: KeyboardEvent) {\n if (e.key === 'Escape' && isVisible && bottomSheetStore.isTopmost(id)) {\n e.preventDefault();\n doClose();\n }\n }\n\n // Svelte action for touch events\n function touchEvents(node: HTMLElement) {\n node.addEventListener('touchstart', handleTouchStart, {passive: false});\n node.addEventListener('touchmove', handleTouchMove, {passive: false});\n node.addEventListener('touchend', handleTouchEnd, {passive: true});\n\n return {\n destroy() {\n node.removeEventListener('touchstart', handleTouchStart);\n node.removeEventListener('touchmove', handleTouchMove);\n node.removeEventListener('touchend', handleTouchEnd);\n }\n };\n }\n</script>\n\n<svelte:window onkeydown={handleKeydown}/>\n\n{#if isVisible}\n <!-- Backdrop -->\n <!-- svelte-ignore a11y_no_static_element_interactions -->\n <div\n bind:this={backdropRef}\n class={r.backdrop()}\n style:z-index={zIndex}\n style:--transition-duration={transitionDuration}\n onpointerup={handleBackdropClick}\n ></div>\n\n <!-- Sheet -->\n <!-- svelte-ignore a11y_no_static_element_interactions -->\n <div\n bind:this={sheetRef}\n class={r.sheet()}\n style:z-index={zIndex + 1}\n style:height=\"{maxSheetHeight}px\"\n style:--transition-duration={transitionDuration}\n role=\"dialog\"\n aria-modal=\"true\"\n tabindex={-1}\n use:touchEvents\n onpointerdown={handlePointerDown}\n onpointermove={handlePointerMove}\n onpointerup={handlePointerUp}\n onpointercancel={handlePointerCancel}\n >\n <!-- Drag Handle -->\n <div class={r.handle()} data-bottom-sheet-handle>\n <div class={r.handleBar()}></div>\n </div>\n\n <!-- Header -->\n {#if header}\n <div class={r.header()} data-bottom-sheet-header>\n {@render header()}\n {#if showCloseButton}\n <button\n class={r.closeButton()}\n data-bottom-sheet-close\n onclick={handleCloseButtonClick}\n aria-label=\"Close\"\n type=\"button\"\n >\n <IconX class=\"size-4\"/>\n </button>\n {/if}\n </div>\n {/if}\n\n <!-- Content -->\n <div bind:this={contentRef} class={r.content()} data-bottom-sheet-content>\n {#if children}\n {@render children()}\n {/if}\n </div>\n </div>\n{/if}\n\n<style>\n /* Consumer-marked scroll region inside the sheet body. */\n [data-bottom-sheet-content] :global([data-bottom-sheet-scroll]) {\n flex: 1;\n overflow-y: auto;\n overflow-x: hidden;\n overscroll-behavior: contain;\n -webkit-overflow-scrolling: touch;\n min-height: 0;\n touch-action: pan-y;\n }\n\n /* Suppress native gestures on header children so the drag is captured. */\n [data-bottom-sheet-header] :global(*) {\n touch-action: none;\n user-select: none;\n }\n</style>\n"
|
|
19
|
-
},
|
|
20
|
-
{
|
|
21
|
-
"path": "src/lib/components/bottom-sheet/bottomSheetStore.svelte.ts",
|
|
22
|
-
"target": "bottomSheetStore.svelte.ts",
|
|
23
|
-
"type": "ts",
|
|
24
|
-
"content": "/**\n * Bottom Sheet store — Svelte 5 runes.\n *\n * A small singleton that tracks the stack of open bottom sheets so multiple\n * sheets can layer correctly (z-index), report drag progress, and resolve which\n * sheet is topmost (for Escape handling). Sheets register on mount and push/pop\n * themselves from the stack as they open/close. Exposed publicly so apps can\n * drive sheets programmatically (open/close/closeTop) in addition to `bind:open`.\n */\n\nexport interface BottomSheetConfig {\n id: string;\n snapPoints: number[];\n defaultSnapPoint: number;\n closeOnBackdrop: boolean;\n}\n\nexport interface SheetStackItem {\n id: string;\n zIndex: number;\n progress: number;\n config: BottomSheetConfig;\n}\n\nconst BASE_Z_INDEX = 1000;\nconst Z_INDEX_INCREMENT = 10;\n\nfunction createBottomSheetStore() {\n // Stack of active sheets (bottom to top)\n let sheetStack = $state<SheetStackItem[]>([]);\n\n // Registry of all sheet configs\n let registry = $state<Map<string, BottomSheetConfig>>(new Map());\n\n // Container refs for scale effect\n let containerRefs = $state<Map<string, HTMLElement>>(new Map());\n\n /**\n * Register a sheet with its config\n */\n function register(id: string, config: Omit<BottomSheetConfig, 'id'>): void {\n registry.set(id, {id, ...config});\n }\n\n /**\n * Unregister a sheet\n */\n function unregister(id: string): void {\n registry.delete(id);\n containerRefs.delete(id);\n // Remove from stack if present\n sheetStack = sheetStack.filter(s => s.id !== id);\n }\n\n /**\n * Open a sheet by ID\n */\n function open(id: string): void {\n const config = registry.get(id);\n if (!config) {\n // Sheet was not registered (component not mounted yet) — ignore.\n return;\n }\n\n // Check if already in stack\n if (sheetStack.some(s => s.id === id)) {\n return;\n }\n\n // Calculate z-index based on stack position\n const zIndex = BASE_Z_INDEX + (sheetStack.length * Z_INDEX_INCREMENT);\n\n sheetStack = [...sheetStack, {\n id,\n zIndex,\n progress: 0,\n config\n }];\n }\n\n /**\n * Close a sheet by ID\n */\n function close(id: string): void {\n sheetStack = sheetStack.filter(s => s.id !== id);\n // Recalculate z-indices for remaining sheets\n sheetStack = sheetStack.map((sheet, index) => ({\n ...sheet,\n zIndex: BASE_Z_INDEX + (index * Z_INDEX_INCREMENT)\n }));\n }\n\n /**\n * Close the topmost sheet\n */\n function closeTop(): void {\n const top = sheetStack[sheetStack.length - 1];\n if (top) {\n close(top.id);\n }\n }\n\n /**\n * Update progress for a sheet (0-1)\n */\n function updateProgress(id: string, progress: number): void {\n const index = sheetStack.findIndex(s => s.id === id);\n const sheet = sheetStack[index];\n if (sheet) {\n sheet.progress = Math.max(0, Math.min(1, progress));\n }\n }\n\n /**\n * Get z-index for a sheet\n */\n function getZIndex(id: string): number {\n const sheet = sheetStack.find(s => s.id === id);\n return sheet?.zIndex ?? BASE_Z_INDEX;\n }\n\n /**\n * Check if a sheet is open\n */\n function isOpen(id: string): boolean {\n return sheetStack.some(s => s.id === id);\n }\n\n /**\n * Check if a sheet is the topmost\n */\n function isTopmost(id: string): boolean {\n return sheetStack[sheetStack.length - 1]?.id === id;\n }\n\n /**\n * Get progress for a sheet\n */\n function getProgress(id: string): number {\n const sheet = sheetStack.find(s => s.id === id);\n return sheet?.progress ?? 0;\n }\n\n /**\n * Attach container ref for scale effect\n */\n function attachContainer(sheetId: string, ref: HTMLElement): void {\n containerRefs.set(sheetId, ref);\n }\n\n /**\n * Get container ref for a sheet\n */\n function getContainer(sheetId: string): HTMLElement | undefined {\n return containerRefs.get(sheetId);\n }\n\n /**\n * Get combined backdrop opacity from all sheets\n */\n function getCombinedBackdropOpacity(): number {\n // Use the topmost sheet's progress for backdrop\n const top = sheetStack[sheetStack.length - 1];\n return top ? top.progress * 0.5 : 0;\n }\n\n /**\n * Get the current stack\n */\n function getStack(): SheetStackItem[] {\n return sheetStack;\n }\n\n return {\n get stack() { return sheetStack; },\n get hasOpenSheets() { return sheetStack.length > 0; },\n\n register,\n unregister,\n open,\n close,\n closeTop,\n updateProgress,\n getZIndex,\n isOpen,\n isTopmost,\n getProgress,\n attachContainer,\n getContainer,\n getCombinedBackdropOpacity,\n getStack\n };\n}\n\n// Export singleton instance\nexport const bottomSheetStore = createBottomSheetStore();\n"
|
|
25
|
-
},
|
|
26
|
-
{
|
|
27
|
-
"path": "src/lib/components/bottom-sheet/index.ts",
|
|
28
|
-
"target": "index.ts",
|
|
29
|
-
"type": "ts",
|
|
30
|
-
"content": "export {default as BottomSheet} from './BottomSheet.svelte'\nexport {bottomSheetStore} from './bottomSheetStore.svelte.js'\nexport {bottomSheetRecipe} from './BottomSheet.recipe'\nexport type {BottomSheetRecipeProps, BottomSheetProps} from './BottomSheet.recipe'\nexport type {BottomSheetConfig, SheetStackItem} from './bottomSheetStore.svelte.js'\n"
|
|
31
|
-
}
|
|
32
|
-
],
|
|
33
|
-
"dependencies": [
|
|
34
|
-
"tailwind-variants"
|
|
35
|
-
],
|
|
36
|
-
"peerDependencies": [
|
|
37
|
-
"svelte"
|
|
38
|
-
],
|
|
39
|
-
"registryDependencies": [],
|
|
40
|
-
"sharedModules": {
|
|
41
|
-
"utils": false,
|
|
42
|
-
"icons": true,
|
|
43
|
-
"hooks": false
|
|
44
|
-
},
|
|
45
|
-
"exports": [
|
|
46
|
-
"BottomSheet",
|
|
47
|
-
"bottomSheetStore",
|
|
48
|
-
"bottomSheetRecipe"
|
|
49
|
-
]
|
|
50
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://tera-system-ui/registry-item.schema.json",
|
|
3
|
+
"name": "bottom-sheet",
|
|
4
|
+
"type": "registry:component",
|
|
5
|
+
"description": "Draggable iOS-style bottom sheet with snap points, container-scale effect, and stacking.",
|
|
6
|
+
"recipe": "src/lib/components/bottom-sheet/BottomSheet.recipe.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
{
|
|
9
|
+
"path": "src/lib/components/bottom-sheet/BottomSheet.recipe.ts",
|
|
10
|
+
"target": "BottomSheet.recipe.ts",
|
|
11
|
+
"type": "recipe",
|
|
12
|
+
"content": "import {tv, type VariantProps} from \"tailwind-variants\";\nimport type {Snippet} from \"svelte\";\n\n/**\n * BottomSheet recipe — the canonical styled layer for the draggable sheet.\n *\n * An iOS-style bottom sheet with snap points, gesture dragging, container-scale\n * effect, and stacking (custom behavior — not bits-ui). The recipe is slotted by\n * role — backdrop / sheet / handle / handleBar / header / closeButton / content —\n * so an app can restyle any part independently. Only the *themeable, static*\n * surface lives here; the per-frame transforms (translate / scale / opacity) are\n * driven imperatively from the component and the runtime transition duration is\n * passed through the `--transition-duration` CSS var.\n *\n * The `constrained` variant switches fixed → absolute positioning when the sheet\n * is bounded inside a container; `dragging` carries the grabbing cursor.\n *\n * Primary customization surface: extend with `tv({ extend: bottomSheetRecipe, ... })`\n * in typed code. Lighter rung: the per-component CSS var (--tera-bottom-sheet-radius)\n * retunes the top corner radius with no code change; it defaults to the prior\n * literal value inline.\n */\nexport const bottomSheetRecipe = tv({\n slots: {\n backdrop: 'fixed inset-0 bg-black opacity-0 touch-none [will-change:opacity] [-webkit-tap-highlight-color:transparent] transition-opacity duration-[var(--transition-duration,0.35s)] ease-out',\n sheet: 'fixed bottom-0 left-0 right-0 flex flex-col overflow-visible bg-surface-raised rounded-t-[var(--tera-bottom-sheet-radius,12px)] [will-change:translate] [translate:0_100%] [transform:translateZ(0)] [backface-visibility:hidden] transition-[translate] duration-[var(--transition-duration,0.35s)] ease-[cubic-bezier(0.2,0.9,0.36,1)]',\n handle: 'flex shrink-0 items-center justify-center px-4 pt-2 pb-1 cursor-grab touch-none active:cursor-grabbing',\n handleBar: 'h-[5px] w-9 rounded-[2.5px] bg-text-tertiary',\n header: 'relative shrink-0 cursor-grab touch-none select-none border-b border-border-default px-4 pt-1 pb-3 active:cursor-grabbing',\n closeButton: 'absolute right-2 top-[calc(50%-4px)] flex h-8 w-8 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full border-none bg-transparent p-0 text-text-tertiary [touch-action:manipulation] [-webkit-tap-highlight-color:transparent] transition-colors hover:bg-surface-hover hover:text-text-primary active:bg-surface-hover',\n content: 'flex min-h-0 flex-1 flex-col overflow-hidden [touch-action:pan-y]',\n },\n variants: {\n // Sheet bounded inside a container (vs. the viewport): switch fixed → absolute.\n constrained: {\n true: {backdrop: 'absolute', sheet: 'absolute'},\n false: {},\n },\n // Active drag: grabbing cursor + suppress selection.\n dragging: {\n true: {sheet: 'cursor-grabbing select-none'},\n false: {},\n },\n },\n defaultVariants: {\n constrained: false,\n dragging: false,\n },\n});\n\nexport type BottomSheetRecipeProps = VariantProps<typeof bottomSheetRecipe>;\n\nexport interface BottomSheetProps {\n /** Unique identifier for this sheet (drives stacking/z-index registry). */\n id: string;\n /** Whether the sheet is open. Bindable. */\n open?: boolean;\n /** Snap positions as viewport fractions (0–1), e.g. [0.25, 0.5, 0.9]. */\n snapPoints?: number[];\n /** Index into `snapPoints` to settle at when opening. */\n defaultSnapPoint?: number;\n /** Container element to constrain the sheet within (constrained mode). */\n containerRef?: HTMLElement | null;\n /** Element to apply the background scale effect to (defaults to containerRef). */\n scaleTargetRef?: HTMLElement | null;\n /** Constrain the sheet inside `containerRef` (absolute positioning). */\n constrainToContainer?: boolean;\n /** Scale/round the background target as the sheet opens (iOS effect). */\n scaleBackground?: boolean;\n /** Allow dragging down past the lowest snap point to close. */\n dragToClose?: boolean;\n /** Close when the backdrop is tapped. */\n closeOnBackdrop?: boolean;\n /** Show a close (X) button in the header (requires the `header` snippet). */\n showCloseButton?: boolean;\n /** Called after the sheet finishes closing. */\n onClose?: () => void;\n /** Called whenever the open state changes. */\n onOpenChange?: (open: boolean) => void;\n /** Main body content. */\n children?: Snippet;\n /** Header content; renders the header bar when set. */\n header?: Snippet;\n}\n"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"path": "src/lib/components/bottom-sheet/BottomSheet.svelte",
|
|
16
|
+
"target": "BottomSheet.svelte",
|
|
17
|
+
"type": "svelte",
|
|
18
|
+
"content": "<script lang=\"ts\">\n import {onDestroy, onMount} from 'svelte';\n import {bottomSheetStore} from './bottomSheetStore.svelte.js';\n import {bottomSheetRecipe, type BottomSheetProps} from './BottomSheet.recipe';\n import {IconX} from \"$lib/internal/icons\";\n\n // ============================================\n // CONFIGURABLE CONSTANTS\n // Adjust these values to customize the bottom sheet behavior\n // ============================================\n\n const CONFIG = {\n // Scale effect for background container\n scale: {\n factor: 0.06, // How much to scale down (6%)\n borderRadius: 16, // Border radius when scaled (px)\n translateY: 20, // Vertical offset when scaled (px)\n origin: 'top center' // Transform origin\n },\n\n // Backdrop\n backdrop: {\n maxOpacity: 0.5 // Maximum opacity when fully open\n },\n\n // Spring physics for open / close / settle. iOS-style motion is spring-based,\n // not fixed-duration easing: `response` is the approximate settle time (s) and\n // `damping` is the damping ratio (1 = critical/no bounce, <1 adds a little life).\n // Duration is emergent, and — crucially — the spring is seeded with the finger's\n // release velocity so the motion continues seamlessly instead of restarting.\n // Tuned snappy to match native iOS; `close` is underdamped and finalizes the\n // instant it reaches offscreen (see animationFrame) so there is no lingering tail.\n animation: {\n open: {response: 0.4, damping: 0.86}, // present — quick, no bounce\n close: {response: 0.26, damping: 0.85}, // dismiss — fast, finalizes on arrival\n snap: {response: 0.32, damping: 0.84}, // moving between snap points\n restDistance: 0.5, // px from target to consider a visible spring settled\n restVelocity: 40, // px/s below which a visible spring is considered at rest\n maxFrameDt: 0.064 // clamp per-frame dt after tab-switch stalls (s)\n },\n\n // Gesture handling\n gesture: {\n velocityInfluence: 0.01, // How much velocity affects snap selection\n closeVelocityThreshold: 0.35, // Velocity to trigger close (px/ms)\n closeProgressThreshold: 0.35, // Progress to trigger close (0-1)\n maxLockedDrag: 60, // Max drag when dragToClose disabled (px)\n lockedDragResistance: 0.3, // Resistance when dragToClose disabled\n // Two-phase gesture recognition for interactive elements (iOS-style)\n dragActivationThreshold: 10, // Vertical px to commit to drag from interactive element\n horizontalDeadZone: 1.2, // If abs(dx)/abs(dy) > this, abort drag (let native horizontal win)\n tapDistanceThreshold: 6 // Max total movement (px) to still count as a tap\n }\n } as const;\n\n // ============================================\n // COMPONENT PROPS\n // ============================================\n\n let {\n id,\n open = $bindable(false),\n snapPoints = [0.9],\n defaultSnapPoint = 0,\n containerRef = null,\n scaleTargetRef = null,\n constrainToContainer = false,\n scaleBackground = true,\n dragToClose = true,\n closeOnBackdrop = true,\n showCloseButton = false,\n onClose,\n onOpenChange,\n children,\n header\n }: BottomSheetProps = $props();\n\n // ============================================\n // INTERNAL STATE\n // ============================================\n\n // DOM references\n let sheetRef = $state<HTMLElement | null>(null);\n let contentRef = $state<HTMLElement | null>(null);\n let backdropRef = $state<HTMLElement | null>(null);\n\n // Sheet state\n let isVisible = $state(false);\n let isClosing = $state(false);\n let isDragging = $state(false);\n // Plain (NOT $state): written on every animation frame and drag move, but never read\n // reactively (no template / $derived / $effect depends on it). Keeping it out of the\n // reactivity graph avoids ~120 signal writes/sec on high-refresh displays.\n let currentTranslateY = 0;\n let sheetHeight = $state(0);\n\n // Spring animation engine — a single interruptible rAF loop owns the sheet's\n // position. currentTranslateY always reflects the LIVE on-screen value (written\n // every frame), so grabbing the sheet mid-animation never jumps.\n let rafId: number | null = null;\n let animTarget = 0; // px, translateY the spring settles toward\n let animVelocity = 0; // px/s, live spring velocity (carries flicks)\n let springK = 0; // stiffness\n let springC = 0; // damping coefficient\n let animOnComplete: (() => void) | null = null;\n let animHideOnArrival = false; // close: finalize the moment the sheet is offscreen\n let lastFrameTime = 0;\n let scaleTargetPrimed = false; // static scale-target styles set once per open cycle\n\n // Drag tracking\n let dragState = {\n startY: 0,\n startX: 0,\n lastY: 0,\n currentY: 0,\n deltaY: 0,\n startSnapPoint: 0,\n activePointerId: null as number | null,\n isTouchActive: false,\n shouldPreventScroll: false,\n history: [] as { y: number; time: number }[],\n // Two-phase gesture recognition for interactive elements\n isPending: false, // Tracking started but not yet committed to drag\n isInteractiveStart: false, // Gesture began on an interactive element\n committed: false, // Gesture has been resolved as a drag\n inScrollable: false // Gesture began inside a [data-bottom-sheet-scroll] area\n };\n\n // Scroll state\n let scrollableElement: HTMLElement | null = null;\n let isScrolledToTop = true;\n\n // Backdrop dismiss: the pointerId of a press that STARTED on the backdrop. A genuine\n // dismiss is a full press+release on the backdrop; tracking the origin lets us ignore\n // the stray pointerup that lands on a freshly-mounted backdrop when a press begun\n // elsewhere (e.g. a long-press on a button that opened the sheet) is released over it.\n let backdropPressPointerId: number | null = null;\n\n // Dimensions\n let windowHeight = $state(typeof window !== 'undefined' ? window.innerHeight : 800);\n let containerHeight = $state(0);\n let containerResizeObserver: ResizeObserver | null = null;\n let prevOpen: boolean | null = null;\n\n // ============================================\n // COMPUTED VALUES\n // ============================================\n\n const r = $derived(bottomSheetRecipe({constrained: constrainToContainer, dragging: isDragging}));\n\n const scaleTarget = $derived(scaleTargetRef ?? containerRef);\n const effectiveHeight = $derived(\n constrainToContainer && containerRef ? containerHeight : windowHeight\n );\n const maxSnapPoint = $derived(Math.max(...snapPoints));\n const maxSheetHeight = $derived(maxSnapPoint * effectiveHeight);\n const zIndex = $derived(bottomSheetStore.getZIndex(id));\n const currentSnapPoint = $derived(snapPoints[defaultSnapPoint] ?? 0.5);\n const allSnapPoints = $derived([0, ...snapPoints].sort((a, b) => a - b));\n\n // ============================================\n // LIFECYCLE\n // ============================================\n\n onMount(() => {\n bottomSheetStore.register(id, {snapPoints, defaultSnapPoint, closeOnBackdrop});\n window.addEventListener('resize', handleWindowResize);\n\n return () => {\n bottomSheetStore.unregister(id);\n window.removeEventListener('resize', handleWindowResize);\n };\n });\n\n onDestroy(() => {\n containerResizeObserver?.disconnect();\n cancelAnim();\n });\n\n // The spring loop drives every visual imperatively (translate / opacity / scale),\n // so keep the recipe's CSS transitions OFF on these elements — otherwise a CSS\n // transition would try to animate between each per-frame write and smear the motion.\n $effect(() => {\n if (sheetRef) sheetRef.style.transition = 'none';\n if (backdropRef) backdropRef.style.transition = 'none';\n });\n\n // Watch containerRef for constrained mode\n $effect(() => {\n if (!containerRef || !constrainToContainer) return;\n\n containerHeight = containerRef.clientHeight;\n containerResizeObserver?.disconnect();\n\n containerResizeObserver = new ResizeObserver(() => {\n containerHeight = containerRef!.clientHeight;\n if (isVisible && !isDragging && sheetRef) {\n sheetHeight = sheetRef.offsetHeight;\n }\n });\n containerResizeObserver.observe(containerRef);\n\n return () => {\n containerResizeObserver?.disconnect();\n containerResizeObserver = null;\n };\n });\n\n // Watch open prop changes\n $effect(() => {\n const shouldOpen = open;\n\n if (prevOpen === null) {\n prevOpen = shouldOpen;\n if (shouldOpen) doOpen();\n return;\n }\n\n if (shouldOpen && !prevOpen && !isVisible) {\n prevOpen = true;\n doOpen();\n } else if (!shouldOpen && prevOpen && isVisible && !isClosing) {\n prevOpen = false;\n doClose();\n }\n });\n\n // NOTE: The scrollable region ([data-bottom-sheet-scroll]) is resolved lazily\n // at gesture-start from the event target (see getScrollableFromTarget), NOT via\n // a one-shot querySelector here. A single querySelector at open-time cannot see\n // content that is mounted lazily AFTER the sheet opens (e.g. async-loaded lists),\n // which left scrollableElement null and broke drag/scroll arbitration inside it.\n\n // ============================================\n // CORE FUNCTIONS\n // ============================================\n\n function handleWindowResize() {\n if (isVisible && !isDragging) {\n windowHeight = window.innerHeight;\n if (sheetRef) sheetHeight = sheetRef.offsetHeight;\n }\n }\n\n function recalculateDimensions() {\n if (typeof window !== 'undefined') windowHeight = window.innerHeight;\n if (containerRef && constrainToContainer) containerHeight = containerRef.clientHeight;\n if (sheetRef) sheetHeight = sheetRef.offsetHeight;\n }\n\n // ============================================\n // SPRING ANIMATION ENGINE\n // ============================================\n // Settle, open and close all run through one interruptible rAF loop as damped\n // springs seeded with the finger's release velocity, so the motion CONTINUES from\n // the drag rather than restarting as a fresh CSS tween. The loop writes the live\n // position every frame, which is what makes catching the sheet mid-flight feel\n // native — there is no destination-vs-live mismatch to jump across.\n\n const prefersReducedMotion = () =>\n typeof window !== 'undefined' &&\n !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;\n\n /** Convert (response, dampingRatio) to spring stiffness/damping (unit mass). */\n function springConstants(response: number, damping: number) {\n const omega = (2 * Math.PI) / response; // natural angular frequency\n return {k: omega * omega, c: 2 * damping * omega};\n }\n\n /** Write the whole visual state for a given translateY (transform + backdrop + scale + progress). */\n function renderPosition(translateY: number) {\n if (sheetRef) sheetRef.style.translate = `0 ${translateY}px`;\n const rawProgress = (sheetHeight - translateY) / effectiveHeight;\n const progress = Math.max(0, Math.min(maxSnapPoint, rawProgress));\n updateBackdrop(progress);\n updateScaleTarget(progress);\n bottomSheetStore.updateProgress(id, Math.max(0, Math.min(1, rawProgress)));\n }\n\n function cancelAnim() {\n if (rafId !== null) {\n cancelAnimationFrame(rafId);\n rafId = null;\n }\n animOnComplete = null;\n }\n\n /** Stop any in-flight settle so a fresh gesture anchors to the true live position. */\n function interruptAnimation() {\n cancelAnim();\n isClosing = false;\n }\n\n /**\n * Spring toward a target translateY, optionally seeded with an initial velocity\n * (px/s — e.g. the finger's flick at release). Retargets a running loop in place,\n * so successive calls (or a mid-flight direction change) stay continuous.\n */\n function animateTo(\n target: number,\n spring: {response: number; damping: number},\n initialVelocity = 0,\n onComplete?: () => void,\n hideOnArrival = false\n ) {\n animTarget = target;\n animOnComplete = onComplete ?? null;\n animHideOnArrival = hideOnArrival;\n const {k, c} = springConstants(spring.response, spring.damping);\n springK = k;\n springC = c;\n\n // Respect reduced-motion: jump straight to the target, no spring.\n if (prefersReducedMotion()) {\n cancelAnim();\n animVelocity = 0;\n currentTranslateY = target;\n renderPosition(target);\n onComplete?.();\n return;\n }\n\n animVelocity = initialVelocity;\n if (rafId === null) {\n lastFrameTime = performance.now();\n rafId = requestAnimationFrame(animationFrame);\n }\n }\n\n function animationFrame(now: number) {\n const {restDistance, restVelocity, maxFrameDt} = CONFIG.animation;\n let dt = (now - lastFrameTime) / 1000;\n lastFrameTime = now;\n if (dt > maxFrameDt) dt = maxFrameDt; // clamp after tab-switch stalls\n\n // Sub-step with semi-implicit (symplectic) Euler for stability at high stiffness.\n let x = currentTranslateY;\n let v = animVelocity;\n const steps = Math.max(1, Math.ceil(dt / 0.004));\n const h = dt / steps;\n for (let i = 0; i < steps; i++) {\n const a = -springK * (x - animTarget) - springC * v;\n v += a * h;\n x += v * h;\n }\n\n let settled = Math.abs(x - animTarget) < restDistance && Math.abs(v) < restVelocity;\n // Close/hide: finalize the instant the sheet reaches offscreen — on the way down,\n // BEFORE any underdamped rebound. This removes the long asymptotic spring \"tail\"\n // that used to keep isVisible/open true (and the backdrop swallowing taps) for a\n // few hundred ms after the sheet already looked closed.\n if (animHideOnArrival && x >= animTarget - 0.5) settled = true;\n if (settled) {\n x = animTarget;\n v = 0;\n }\n\n animVelocity = v;\n currentTranslateY = x;\n renderPosition(x);\n\n if (settled) {\n rafId = null;\n const cb = animOnComplete;\n animOnComplete = null;\n cb?.();\n } else {\n rafId = requestAnimationFrame(animationFrame);\n }\n }\n\n function doOpen() {\n // Refresh viewport/container dimensions BEFORE the sheet renders, so its\n // height (maxSheetHeight) is derived from current values. While the sheet is\n // closed, windowHeight is not tracked (handleWindowResize is gated on\n // isVisible), so a resize between opens would otherwise leave the first\n // render using a stale height — measured sheetHeight then mismatches the\n // fresh effectiveHeight, shifting the sheet off the bottom edge until the\n // next open. Doing this before isVisible keeps them in sync.\n if (typeof window !== 'undefined') windowHeight = window.innerHeight;\n if (containerRef && constrainToContainer) containerHeight = containerRef.clientHeight;\n\n isVisible = true;\n isClosing = false;\n backdropPressPointerId = null; // fresh open — ignore any stale backdrop press id\n bottomSheetStore.open(id);\n\n requestAnimationFrame(() => {\n // sheetRef is bound by this rAF tick.\n recalculateDimensions();\n // Start fully hidden (matches the sheet's initial CSS translate of 100%),\n // then spring up to the target snap point.\n currentTranslateY = sheetHeight;\n renderPosition(sheetHeight);\n const target = sheetHeight - currentSnapPoint * effectiveHeight;\n animateTo(target, CONFIG.animation.open, 0);\n });\n }\n\n function doClose() {\n if (isClosing) return;\n animateSheetClose(0);\n }\n\n /** Spring the sheet fully down and finalize. `initialVelocity` (px/s) carries a flick-to-dismiss. */\n function animateSheetClose(initialVelocity: number) {\n isClosing = true;\n animateTo(sheetHeight, CONFIG.animation.close, initialVelocity, () => {\n isVisible = false;\n isClosing = false;\n bottomSheetStore.close(id);\n prevOpen = false;\n open = false;\n resetScaleTarget();\n onClose?.();\n onOpenChange?.(false);\n }, true); // hideOnArrival — finalize as soon as it's offscreen, no spring tail\n }\n\n /**\n * Settle after a drag release: resolve the target snap point, then spring to it\n * carrying the finger's release velocity so the hand-off is seamless.\n */\n function settleFromRelease() {\n const nearestSnap = determineSnapPoint();\n const releaseVelocity = calculateVelocity() * 1000; // px/ms → px/s (down = positive)\n\n if (nearestSnap === 0 && dragToClose) {\n animateSheetClose(releaseVelocity);\n return;\n }\n\n const snap = nearestSnap > 0 ? nearestSnap : dragState.startSnapPoint;\n const target = sheetHeight - snap * effectiveHeight;\n animateTo(target, CONFIG.animation.snap, releaseVelocity, () => {\n if (!open) {\n prevOpen = true;\n open = true;\n onOpenChange?.(true);\n }\n });\n }\n\n // ============================================\n // VISUAL UPDATES\n // ============================================\n\n function updateBackdrop(progress: number) {\n if (!backdropRef) return;\n const opacity = Math.max(0, Math.min(CONFIG.backdrop.maxOpacity, progress * CONFIG.backdrop.maxOpacity));\n backdropRef.style.opacity = String(opacity);\n }\n\n function updateScaleTarget(progress: number) {\n if (!scaleTarget || !scaleBackground) return;\n\n // Set the STATIC props ONCE per open cycle. Re-writing transition / transformOrigin /\n // overflow / will-change every frame churned the compositor layer (will-change\n // especially) and, together with a per-frame border-radius repaint of this\n // full-screen element, blew the frame budget on 120Hz displays. Priming once fixes it.\n if (!scaleTargetPrimed) {\n scaleTarget.style.transition = 'none';\n scaleTarget.style.transformOrigin = CONFIG.scale.origin;\n scaleTarget.style.overflow = 'hidden';\n scaleTarget.style.willChange = 'scale, translate, border-radius';\n scaleTargetPrimed = true;\n }\n\n const p = Math.max(0, Math.min(1, progress));\n const {factor, borderRadius, translateY} = CONFIG.scale;\n\n // Per-frame: scale + translate are GPU-composited (cheap). Quantize border-radius to\n // whole px so it only actually repaints ~a dozen times across the gesture, not 120×/s.\n scaleTarget.style.scale = String(1 - factor * p);\n scaleTarget.style.translate = `0 ${(translateY * p).toFixed(2)}px`;\n scaleTarget.style.borderRadius = `${Math.round(borderRadius * p)}px`;\n }\n\n function resetScaleTarget() {\n scaleTargetPrimed = false;\n if (!scaleTarget || !scaleBackground) return;\n\n scaleTarget.style.scale = '';\n scaleTarget.style.translate = '';\n scaleTarget.style.borderRadius = '';\n scaleTarget.style.transformOrigin = '';\n scaleTarget.style.overflow = '';\n scaleTarget.style.transition = '';\n scaleTarget.style.willChange = '';\n }\n\n // ============================================\n // GESTURE UTILITIES\n // ============================================\n\n function pushHistory(y: number) {\n const now = performance.now();\n dragState.history = dragState.history.filter(p => now - p.time <= 100);\n dragState.history.push({y, time: now});\n }\n\n function clearHistory() {\n dragState.history = [];\n }\n\n function calculateVelocity(): number {\n const now = performance.now();\n const recent = dragState.history.filter(p => now - p.time <= 100);\n\n if (recent.length < 2) return 0;\n\n const first = recent[0];\n const last = recent[recent.length - 1];\n if (!first || !last) return 0;\n const dt = last.time - first.time;\n\n return dt > 0 ? (last.y - first.y) / dt : 0;\n }\n\n function clampAboveMaxSnap(translateY: number): number {\n // The sheet is only as tall as its MAX snap point, with its bottom pinned to the\n // screen bottom (translateY 0 == fully open to max). Letting translateY go below\n // that (negative) would lift the sheet's bottom edge off the screen and reveal the\n // background in the gap beneath it — which looks broken. So pin it: you cannot drag\n // the sheet frame above its largest detent. This is also what native iOS does — the\n // frame is firm at the top detent; only inner scroll content overscrolls.\n const topLimit = sheetHeight - maxSnapPoint * effectiveHeight;\n return translateY < topLimit ? topLimit : translateY;\n }\n\n function applyLockedDragResistance(rawTranslateY: number): number {\n // Base translateY when at start snap point: sheetHeight - (startSnapPoint * effectiveHeight)\n const baseTranslateY = sheetHeight - (dragState.startSnapPoint * effectiveHeight);\n const dragDistance = rawTranslateY - baseTranslateY;\n\n if (dragDistance > 0) {\n const {lockedDragResistance, maxLockedDrag} = CONFIG.gesture;\n const resistedDrag = Math.min(dragDistance * lockedDragResistance, maxLockedDrag);\n return baseTranslateY + resistedDrag;\n }\n return rawTranslateY;\n }\n\n function findNearestSnapPoint(translateY: number, velocityInfluence = 0): number {\n // Calculate progress: translateY = sheetHeight - (progress * effectiveHeight)\n // So: progress = (sheetHeight - translateY) / effectiveHeight\n const currentProgress = (sheetHeight - translateY) / effectiveHeight;\n const targetProgress = currentProgress + velocityInfluence;\n\n return allSnapPoints.reduce((nearest, sp) =>\n Math.abs(targetProgress - sp) < Math.abs(targetProgress - nearest) ? sp : nearest\n , allSnapPoints[0] ?? 0);\n }\n\n function determineSnapPoint(): number {\n // Calculate progress: translateY = sheetHeight - (progress * effectiveHeight)\n // So: progress = (sheetHeight - translateY) / effectiveHeight\n const currentProgress = (sheetHeight - currentTranslateY) / effectiveHeight;\n const velocity = calculateVelocity();\n\n if (!dragToClose) {\n return dragState.startSnapPoint;\n }\n\n const {closeVelocityThreshold, closeProgressThreshold, velocityInfluence} = CONFIG.gesture;\n\n // Fast swipe handling\n if (Math.abs(velocity) > closeVelocityThreshold) {\n if (velocity > 0) {\n // Swipe down - close or go to lower snap\n const lowerSnaps = allSnapPoints.filter(sp => sp < dragState.startSnapPoint);\n const lowest = lowerSnaps[lowerSnaps.length - 1];\n if (lowest !== undefined && currentProgress >= lowest * 0.5) {\n return lowest;\n }\n return 0;\n } else {\n // Swipe up - go to higher snap\n const higherSnaps = allSnapPoints.filter(sp => sp > currentProgress);\n return higherSnaps[0] ?? Math.max(...snapPoints);\n }\n }\n\n // Check close threshold\n const progressDrop = dragState.startSnapPoint - currentProgress;\n if (progressDrop > closeProgressThreshold) {\n return 0;\n }\n\n // Velocity is in px/ms, convert to progress units (divide by effectiveHeight, multiply by 1000 for scale)\n const velocityInProgressUnits = -velocity / effectiveHeight * 1000;\n return findNearestSnapPoint(currentTranslateY, velocityInProgressUnits * velocityInfluence);\n }\n\n // ============================================\n // TOUCH EVENT HANDLERS\n // ============================================\n\n function isInDraggableArea(target: HTMLElement): boolean {\n return !!(target.closest('[data-bottom-sheet-handle]'));\n }\n\n function isInteractive(target: HTMLElement): boolean {\n return !!target.closest('button, a, input, select, textarea, [tabindex]:not([tabindex=\"-1\"])');\n }\n\n function shouldIgnoreDrag(target: HTMLElement): boolean {\n return !!target.closest('[data-bottom-sheet-no-drag]');\n }\n\n /**\n * Resolve the scrollable region ([data-bottom-sheet-scroll]) that contains the\n * gesture target. Resolved per-gesture (not once at open-time) so that content\n * mounted lazily after the sheet opened — and nested/multiple scroll regions —\n * are detected correctly. Returns the nearest scrollable ancestor, or null.\n */\n function getScrollableFromTarget(target: HTMLElement): HTMLElement | null {\n return target.closest<HTMLElement>('[data-bottom-sheet-scroll]');\n }\n\n /**\n * Install a one-shot capture-phase click listener to suppress the ghost click\n * that fires after a committed drag from an interactive element.\n */\n function suppressNextClick() {\n if (!sheetRef) return;\n const handler = (e: Event) => {\n e.preventDefault();\n e.stopPropagation();\n sheetRef?.removeEventListener('click', handler, true);\n };\n sheetRef.addEventListener('click', handler, true);\n // Safety: auto-remove after a short delay if click never fires\n setTimeout(() => sheetRef?.removeEventListener('click', handler, true), 300);\n }\n\n /**\n * Reset all pending/two-phase gesture state fields.\n */\n function resetPendingState() {\n dragState.isPending = false;\n dragState.isInteractiveStart = false;\n dragState.committed = false;\n dragState.inScrollable = false;\n }\n\n /**\n * Initialize common drag tracking fields shared by both touch and pointer paths.\n */\n function initDragTracking(clientX: number, clientY: number) {\n dragState.startX = clientX;\n dragState.startY = clientY;\n dragState.lastY = clientY;\n dragState.currentY = currentTranslateY;\n dragState.deltaY = 0;\n dragState.startSnapPoint = Math.round((sheetHeight - currentTranslateY) / effectiveHeight * 100) / 100;\n clearHistory();\n pushHistory(clientY);\n }\n\n /**\n * Commit to dragging: transition from pending state to active drag.\n * Re-anchors startY/currentY to the current position to prevent visual jump.\n */\n function commitToDrag(clientY: number) {\n dragState.isPending = false;\n dragState.committed = true;\n // Re-anchor to current position so sheet doesn't jump\n dragState.startY = clientY;\n dragState.currentY = currentTranslateY;\n clearHistory();\n pushHistory(clientY);\n\n interruptAnimation();\n if (scaleTarget) scaleTarget.style.transition = 'none';\n isDragging = true;\n }\n\n /**\n * Check if the pending gesture should be resolved.\n * Returns: 'drag' if vertical threshold exceeded and should drag sheet,\n * 'abort' if horizontal wins or user wants to scroll content,\n * 'pending' if undecided.\n */\n function resolvePendingGesture(clientX: number, clientY: number): 'drag' | 'abort' | 'pending' {\n const {dragActivationThreshold, horizontalDeadZone} = CONFIG.gesture;\n const absX = Math.abs(clientX - dragState.startX);\n const absY = Math.abs(clientY - dragState.startY);\n const deltaY = clientY - dragState.startY; // positive = finger moving down\n\n // Horizontal threshold exceeded — let native behavior handle it (slider, scroll, etc.)\n if (absX >= dragActivationThreshold) {\n return 'abort';\n }\n\n // Vertical threshold exceeded and angle is more vertical than horizontal\n if (absY >= dragActivationThreshold && absY > absX * horizontalDeadZone) {\n // Inside a scrollable area: only commit to drag if pulling DOWN from top (dismiss gesture).\n // If swiping UP (to scroll content down), abort and let native scroll work.\n if (dragState.inScrollable) {\n if (scrollableElement) isScrolledToTop = scrollableElement.scrollTop <= 1;\n if (deltaY > 0 && isScrolledToTop) {\n // Pulling down from top → drag sheet (dismiss)\n return 'drag';\n }\n // Swiping up, or not at top → let native scroll handle it\n return 'abort';\n }\n return 'drag';\n }\n\n return 'pending';\n }\n\n function handleTouchStart(e: TouchEvent) {\n if (dragState.isTouchActive) return;\n\n const target = e.target as HTMLElement;\n const inDraggableArea = isInDraggableArea(target);\n // Resolve scrollable region from the actual target so lazily-mounted content is detected.\n scrollableElement = getScrollableFromTarget(target);\n const inScrollable = !!scrollableElement;\n const isInteractiveTarget = isInteractive(target);\n\n // data-bottom-sheet-no-drag: treat as fully default element — no tracking at all\n if (shouldIgnoreDrag(target) && !inDraggableArea) {\n return;\n }\n\n if (scrollableElement) isScrolledToTop = scrollableElement.scrollTop <= 1;\n\n if (inScrollable && !isScrolledToTop && !inDraggableArea) {\n dragState.shouldPreventScroll = false;\n return;\n }\n\n // Initialize tracking for all cases\n const touch = e.touches[0];\n if (!touch) return;\n dragState.isTouchActive = true;\n initDragTracking(touch.clientX, touch.clientY);\n\n // Interactive element (button, input, slider, etc.) NOT in draggable area:\n // Enter pending state — don't commit to drag yet, let threshold decide\n if (isInteractiveTarget && !inDraggableArea) {\n dragState.isPending = true;\n dragState.isInteractiveStart = true;\n dragState.committed = false;\n dragState.inScrollable = inScrollable;\n dragState.shouldPreventScroll = true; // Will be used if we commit\n\n // Do NOT set isDragging or interrupt the animation yet — allow native\n // touch behavior until the gesture commits, and don't call e.preventDefault().\n return;\n }\n\n // Non-interactive element: immediately commit to dragging (existing behavior)\n dragState.isPending = false;\n dragState.isInteractiveStart = false;\n dragState.committed = true;\n dragState.shouldPreventScroll = !(inScrollable && isScrolledToTop && !inDraggableArea);\n\n interruptAnimation();\n if (scaleTarget) scaleTarget.style.transition = 'none';\n isDragging = true;\n }\n\n function handleTouchMove(e: TouchEvent) {\n if (!dragState.isTouchActive) return;\n\n const touch = e.touches[0];\n if (!touch) return;\n const clientY = touch.clientY;\n const clientX = touch.clientX;\n\n // ── Two-phase: resolve pending gesture from interactive element ──\n if (dragState.isPending) {\n pushHistory(clientY);\n const result = resolvePendingGesture(clientX, clientY);\n\n if (result === 'drag') {\n // Vertical intent wins → commit to drag\n commitToDrag(clientY);\n dragState.shouldPreventScroll = true;\n e.preventDefault();\n return; // First real drag frame happens on next move\n }\n if (result === 'abort') {\n // Horizontal intent wins → abort, let native behavior (slider, etc.)\n dragState.isPending = false;\n dragState.committed = false;\n dragState.isTouchActive = false;\n dragState.isInteractiveStart = false;\n return;\n }\n // Still pending — do nothing, let native behavior continue\n return;\n }\n\n // ── Normal drag logic (committed) ──\n const moveDelta = clientY - dragState.startY;\n dragState.deltaY = clientY - dragState.lastY;\n dragState.lastY = clientY;\n pushHistory(clientY);\n\n if (scrollableElement) isScrolledToTop = scrollableElement.scrollTop <= 1;\n if (isScrolledToTop && moveDelta > 0) dragState.shouldPreventScroll = true;\n if (!dragState.shouldPreventScroll) return;\n\n e.preventDefault();\n\n let translateY = dragState.currentY + moveDelta;\n translateY = clampAboveMaxSnap(translateY);\n if (!dragToClose) translateY = applyLockedDragResistance(translateY);\n translateY = Math.min(translateY, sheetHeight);\n\n currentTranslateY = translateY;\n renderPosition(translateY);\n }\n\n function handleTouchEnd(e: TouchEvent) {\n if (e.touches.length > 0 || !dragState.isTouchActive) return;\n\n dragState.isTouchActive = false;\n\n // ── Two-phase: pending never resolved (tap) → let native click fire ──\n if (dragState.isPending) {\n resetPendingState();\n return;\n }\n\n // ── Committed drag from interactive element → suppress ghost click ──\n if (dragState.committed && dragState.isInteractiveStart) {\n suppressNextClick();\n }\n resetPendingState();\n\n if (!dragState.shouldPreventScroll) {\n isDragging = false;\n return;\n }\n\n isDragging = false;\n dragState.shouldPreventScroll = false;\n\n settleFromRelease();\n }\n\n // ============================================\n // POINTER EVENT HANDLERS (Desktop)\n // ============================================\n\n function handlePointerDown(e: PointerEvent) {\n if (e.pointerType === 'touch' || e.button !== 0 || dragState.activePointerId !== null) return;\n\n const target = e.target as HTMLElement;\n const inDraggableArea = isInDraggableArea(target);\n // Resolve scrollable region from the actual target so lazily-mounted content is detected.\n scrollableElement = getScrollableFromTarget(target);\n const inScrollable = !!scrollableElement;\n const isInteractiveTarget = isInteractive(target);\n const shouldIgnoreDragTarget = shouldIgnoreDrag(target);\n\n // data-bottom-sheet-no-drag: treat as fully default element — no tracking at all\n if (shouldIgnoreDragTarget && !inDraggableArea) return;\n\n if (scrollableElement) isScrolledToTop = scrollableElement.scrollTop <= 1;\n if (inScrollable && !isScrolledToTop && !inDraggableArea) return;\n\n dragState.activePointerId = e.pointerId;\n initDragTracking(e.clientX, e.clientY);\n\n // Interactive element NOT in draggable area:\n // Enter pending state — don't commit yet, preserve native focus/click\n if (isInteractiveTarget && !inDraggableArea) {\n dragState.isPending = true;\n dragState.isInteractiveStart = true;\n dragState.committed = false;\n dragState.inScrollable = inScrollable;\n\n // Do NOT call e.preventDefault() — preserve focus for inputs\n // Do NOT call setPointerCapture — allow native hover/click behavior\n // Do NOT set isDragging or disable transitions\n return;\n }\n\n // Non-interactive element: immediately commit to dragging\n dragState.isPending = false;\n dragState.isInteractiveStart = false;\n dragState.committed = true;\n\n interruptAnimation();\n if (scaleTarget) scaleTarget.style.transition = 'none';\n sheetRef?.setPointerCapture(e.pointerId);\n isDragging = true;\n e.preventDefault();\n }\n\n function handlePointerMove(e: PointerEvent) {\n if (e.pointerType === 'touch' || dragState.activePointerId !== e.pointerId) return;\n if (!isDragging && !dragState.isPending) return;\n\n const clientY = e.clientY;\n const clientX = e.clientX;\n\n // ── Two-phase: resolve pending gesture from interactive element ──\n if (dragState.isPending) {\n pushHistory(clientY);\n const result = resolvePendingGesture(clientX, clientY);\n\n if (result === 'drag') {\n // Vertical intent wins → commit to drag\n commitToDrag(clientY);\n sheetRef?.setPointerCapture(e.pointerId);\n e.preventDefault();\n return; // First real drag frame happens on next move\n }\n if (result === 'abort') {\n // Horizontal intent wins → abort, let native behavior\n dragState.isPending = false;\n dragState.committed = false;\n dragState.activePointerId = null;\n dragState.isInteractiveStart = false;\n return;\n }\n // Still pending — do nothing\n return;\n }\n\n // ── Normal drag logic (committed) ──\n dragState.deltaY = clientY - dragState.lastY;\n dragState.lastY = clientY;\n pushHistory(clientY);\n\n let translateY = dragState.currentY + clientY - dragState.startY;\n translateY = clampAboveMaxSnap(translateY);\n if (!dragToClose) translateY = applyLockedDragResistance(translateY);\n translateY = Math.min(translateY, sheetHeight);\n\n currentTranslateY = translateY;\n renderPosition(translateY);\n\n e.preventDefault();\n }\n\n function handlePointerUp(e: PointerEvent) {\n if (e.pointerType === 'touch' || dragState.activePointerId !== e.pointerId) return;\n\n try {\n sheetRef?.releasePointerCapture(e.pointerId);\n } catch {\n }\n dragState.activePointerId = null;\n\n // ── Two-phase: pending never resolved (tap) → let native click fire ──\n if (dragState.isPending) {\n resetPendingState();\n return;\n }\n\n // ── Committed drag from interactive element → suppress ghost click ──\n if (dragState.committed && dragState.isInteractiveStart) {\n suppressNextClick();\n }\n resetPendingState();\n\n if (!isDragging) return;\n isDragging = false;\n\n settleFromRelease();\n }\n\n function handlePointerCancel(e: PointerEvent) {\n if (e.pointerType === 'touch' || dragState.activePointerId !== e.pointerId) return;\n\n try {\n sheetRef?.releasePointerCapture(e.pointerId);\n } catch {\n }\n dragState.activePointerId = null;\n\n // If pending, just reset — nothing visual happened\n if (dragState.isPending) {\n resetPendingState();\n return;\n }\n resetPendingState();\n\n if (!isDragging) return;\n isDragging = false;\n\n // No velocity on cancel — settle to the nearest snap point at rest.\n const target = sheetHeight - findNearestSnapPoint(currentTranslateY, 0) * effectiveHeight;\n animateTo(target, CONFIG.animation.snap, 0);\n }\n\n function handleCloseButtonClick(e: MouseEvent) {\n e.stopPropagation();\n doClose();\n }\n\n function handleBackdropPointerDown(e: PointerEvent) {\n // Record a press that begins ON the backdrop (and only the backdrop). Used to tell\n // a deliberate backdrop tap apart from the ghost pointerup described below.\n backdropPressPointerId = e.target === backdropRef ? e.pointerId : null;\n }\n\n function handleBackdropClick(e: PointerEvent) {\n // Dismiss only when THIS press both started and ended on the backdrop. Gating on the\n // press origin (not an isAnimating timer) keeps a deliberate tap instant — even mid\n // open-animation — while ignoring the stray release that fires when a press begun\n // elsewhere lifts over a just-mounted backdrop (e.g. long-press a button to open the\n // sheet, then release: that pointerup lands on the backdrop but never pressed it).\n const startedOnBackdrop = backdropPressPointerId === e.pointerId;\n backdropPressPointerId = null;\n if (!closeOnBackdrop || e.target !== backdropRef || isDragging || !startedOnBackdrop) return;\n doClose();\n }\n\n function handleKeydown(e: KeyboardEvent) {\n if (e.key === 'Escape' && isVisible && bottomSheetStore.isTopmost(id)) {\n e.preventDefault();\n doClose();\n }\n }\n\n // Svelte action for touch events\n function touchEvents(node: HTMLElement) {\n node.addEventListener('touchstart', handleTouchStart, {passive: false});\n node.addEventListener('touchmove', handleTouchMove, {passive: false});\n node.addEventListener('touchend', handleTouchEnd, {passive: true});\n\n return {\n destroy() {\n node.removeEventListener('touchstart', handleTouchStart);\n node.removeEventListener('touchmove', handleTouchMove);\n node.removeEventListener('touchend', handleTouchEnd);\n }\n };\n }\n</script>\n\n<svelte:window onkeydown={handleKeydown}/>\n\n{#if isVisible}\n <!-- Backdrop -->\n <!-- svelte-ignore a11y_no_static_element_interactions -->\n <div\n bind:this={backdropRef}\n class={r.backdrop()}\n style:z-index={zIndex}\n onpointerdown={handleBackdropPointerDown}\n onpointerup={handleBackdropClick}\n ></div>\n\n <!-- Sheet -->\n <!-- svelte-ignore a11y_no_static_element_interactions -->\n <div\n bind:this={sheetRef}\n class={r.sheet()}\n style:z-index={zIndex + 1}\n style:height=\"{maxSheetHeight}px\"\n data-bottom-sheet-surface\n role=\"dialog\"\n aria-modal=\"true\"\n tabindex={-1}\n use:touchEvents\n onpointerdown={handlePointerDown}\n onpointermove={handlePointerMove}\n onpointerup={handlePointerUp}\n onpointercancel={handlePointerCancel}\n >\n <!-- Drag Handle -->\n <div class={r.handle()} data-bottom-sheet-handle>\n <div class={r.handleBar()}></div>\n </div>\n\n <!-- Header -->\n {#if header}\n <div class={r.header()} data-bottom-sheet-header>\n {@render header()}\n {#if showCloseButton}\n <button\n class={r.closeButton()}\n data-bottom-sheet-close\n onclick={handleCloseButtonClick}\n aria-label=\"Close\"\n type=\"button\"\n >\n <IconX class=\"size-4\"/>\n </button>\n {/if}\n </div>\n {/if}\n\n <!-- Content -->\n <div bind:this={contentRef} class={r.content()} data-bottom-sheet-content>\n {#if children}\n {@render children()}\n {/if}\n </div>\n </div>\n{/if}\n\n<style>\n /* Consumer-marked scroll region inside the sheet body. */\n [data-bottom-sheet-content] :global([data-bottom-sheet-scroll]) {\n flex: 1;\n overflow-y: auto;\n overflow-x: hidden;\n overscroll-behavior: contain;\n -webkit-overflow-scrolling: touch;\n min-height: 0;\n touch-action: pan-y;\n }\n\n /* Suppress native gestures on header children so the drag is captured. */\n [data-bottom-sheet-header] :global(*) {\n touch-action: none;\n user-select: none;\n }\n\n /* Extend the sheet's own surface below its bottom edge. The sheet is only as tall as\n its max snap point; if it ever sits a hair above the screen bottom — the open spring's\n slight overshoot, or sub-pixel rounding — this keeps the sliver beneath it sheet-colored\n instead of letting the background show through. The sheet is overflow-visible so this is\n not clipped, and it rides along with the sheet's transform. */\n :global([data-bottom-sheet-surface]::after) {\n content: '';\n position: absolute;\n top: 100%;\n left: 0;\n right: 0;\n height: 100%;\n background-color: inherit;\n pointer-events: none;\n }\n</style>\n"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"path": "src/lib/components/bottom-sheet/bottomSheetStore.svelte.ts",
|
|
22
|
+
"target": "bottomSheetStore.svelte.ts",
|
|
23
|
+
"type": "ts",
|
|
24
|
+
"content": "/**\n * Bottom Sheet store — Svelte 5 runes.\n *\n * A small singleton that tracks the stack of open bottom sheets so multiple\n * sheets can layer correctly (z-index), report drag progress, and resolve which\n * sheet is topmost (for Escape handling). Sheets register on mount and push/pop\n * themselves from the stack as they open/close. Exposed publicly so apps can\n * drive sheets programmatically (open/close/closeTop) in addition to `bind:open`.\n */\n\nexport interface BottomSheetConfig {\n id: string;\n snapPoints: number[];\n defaultSnapPoint: number;\n closeOnBackdrop: boolean;\n}\n\nexport interface SheetStackItem {\n id: string;\n zIndex: number;\n progress: number;\n config: BottomSheetConfig;\n}\n\nconst BASE_Z_INDEX = 1000;\nconst Z_INDEX_INCREMENT = 10;\n\nfunction createBottomSheetStore() {\n // Stack of active sheets (bottom to top)\n let sheetStack = $state<SheetStackItem[]>([]);\n\n // Registry of all sheet configs\n let registry = $state<Map<string, BottomSheetConfig>>(new Map());\n\n // Container refs for scale effect\n let containerRefs = $state<Map<string, HTMLElement>>(new Map());\n\n /**\n * Register a sheet with its config\n */\n function register(id: string, config: Omit<BottomSheetConfig, 'id'>): void {\n registry.set(id, {id, ...config});\n }\n\n /**\n * Unregister a sheet\n */\n function unregister(id: string): void {\n registry.delete(id);\n containerRefs.delete(id);\n // Remove from stack if present\n sheetStack = sheetStack.filter(s => s.id !== id);\n }\n\n /**\n * Open a sheet by ID\n */\n function open(id: string): void {\n const config = registry.get(id);\n if (!config) {\n // Sheet was not registered (component not mounted yet) — ignore.\n return;\n }\n\n // Check if already in stack\n if (sheetStack.some(s => s.id === id)) {\n return;\n }\n\n // Calculate z-index based on stack position\n const zIndex = BASE_Z_INDEX + (sheetStack.length * Z_INDEX_INCREMENT);\n\n sheetStack = [...sheetStack, {\n id,\n zIndex,\n progress: 0,\n config\n }];\n }\n\n /**\n * Close a sheet by ID\n */\n function close(id: string): void {\n sheetStack = sheetStack.filter(s => s.id !== id);\n // Recalculate z-indices for remaining sheets\n sheetStack = sheetStack.map((sheet, index) => ({\n ...sheet,\n zIndex: BASE_Z_INDEX + (index * Z_INDEX_INCREMENT)\n }));\n }\n\n /**\n * Close the topmost sheet\n */\n function closeTop(): void {\n const top = sheetStack[sheetStack.length - 1];\n if (top) {\n close(top.id);\n }\n }\n\n /**\n * Update progress for a sheet (0-1)\n */\n function updateProgress(id: string, progress: number): void {\n const index = sheetStack.findIndex(s => s.id === id);\n const sheet = sheetStack[index];\n if (sheet) {\n sheet.progress = Math.max(0, Math.min(1, progress));\n }\n }\n\n /**\n * Get z-index for a sheet\n */\n function getZIndex(id: string): number {\n const sheet = sheetStack.find(s => s.id === id);\n return sheet?.zIndex ?? BASE_Z_INDEX;\n }\n\n /**\n * Check if a sheet is open\n */\n function isOpen(id: string): boolean {\n return sheetStack.some(s => s.id === id);\n }\n\n /**\n * Check if a sheet is the topmost\n */\n function isTopmost(id: string): boolean {\n return sheetStack[sheetStack.length - 1]?.id === id;\n }\n\n /**\n * Get progress for a sheet\n */\n function getProgress(id: string): number {\n const sheet = sheetStack.find(s => s.id === id);\n return sheet?.progress ?? 0;\n }\n\n /**\n * Attach container ref for scale effect\n */\n function attachContainer(sheetId: string, ref: HTMLElement): void {\n containerRefs.set(sheetId, ref);\n }\n\n /**\n * Get container ref for a sheet\n */\n function getContainer(sheetId: string): HTMLElement | undefined {\n return containerRefs.get(sheetId);\n }\n\n /**\n * Get combined backdrop opacity from all sheets\n */\n function getCombinedBackdropOpacity(): number {\n // Use the topmost sheet's progress for backdrop\n const top = sheetStack[sheetStack.length - 1];\n return top ? top.progress * 0.5 : 0;\n }\n\n /**\n * Get the current stack\n */\n function getStack(): SheetStackItem[] {\n return sheetStack;\n }\n\n return {\n get stack() { return sheetStack; },\n get hasOpenSheets() { return sheetStack.length > 0; },\n\n register,\n unregister,\n open,\n close,\n closeTop,\n updateProgress,\n getZIndex,\n isOpen,\n isTopmost,\n getProgress,\n attachContainer,\n getContainer,\n getCombinedBackdropOpacity,\n getStack\n };\n}\n\n// Export singleton instance\nexport const bottomSheetStore = createBottomSheetStore();\n"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"path": "src/lib/components/bottom-sheet/index.ts",
|
|
28
|
+
"target": "index.ts",
|
|
29
|
+
"type": "ts",
|
|
30
|
+
"content": "export {default as BottomSheet} from './BottomSheet.svelte'\nexport {bottomSheetStore} from './bottomSheetStore.svelte.js'\nexport {bottomSheetRecipe} from './BottomSheet.recipe'\nexport type {BottomSheetRecipeProps, BottomSheetProps} from './BottomSheet.recipe'\nexport type {BottomSheetConfig, SheetStackItem} from './bottomSheetStore.svelte.js'\n"
|
|
31
|
+
}
|
|
32
|
+
],
|
|
33
|
+
"dependencies": [
|
|
34
|
+
"tailwind-variants"
|
|
35
|
+
],
|
|
36
|
+
"peerDependencies": [
|
|
37
|
+
"svelte"
|
|
38
|
+
],
|
|
39
|
+
"registryDependencies": [],
|
|
40
|
+
"sharedModules": {
|
|
41
|
+
"utils": false,
|
|
42
|
+
"icons": true,
|
|
43
|
+
"hooks": false
|
|
44
|
+
},
|
|
45
|
+
"exports": [
|
|
46
|
+
"BottomSheet",
|
|
47
|
+
"bottomSheetStore",
|
|
48
|
+
"bottomSheetRecipe"
|
|
49
|
+
]
|
|
50
|
+
}
|