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)] shadow-[0_-1px_0_rgb(255_255_255/0.05),0_-4px_20px_rgb(0_0_0/0.3)] [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)]',
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
- // Animation timing
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
- defaultDuration: 0.35, // Default duration (seconds)
24
- minDuration: 0.15, // Min duration for fast swipes
25
- maxDuration: 0.4, // Max duration for slow gestures
26
- easing: 'cubic-bezier(0.2, 0.9, 0.36, 1)' // iOS-style spring curve
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
- let currentTranslateY = $state(0);
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
- let transitionDuration = $state(`${CONFIG.animation.defaultDuration}s`);
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
- // Setup scrollable element detection
149
- $effect(() => {
150
- if (!contentRef || !isVisible)
151
- return;
152
- scrollableElement = contentRef.querySelector('[data-bottom-sheet-scroll]');
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
- isAnimating = true;
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
- requestAnimationFrame(() => snapTo(currentSnapPoint, () => {
188
- isAnimating = false;
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
- isAnimating = true;
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
- function snapTo(progress, onComplete) {
210
- transitionDuration = calcDynamicDuration();
211
- // Calculate translateY: sheet height is maxSnapPoint * effectiveHeight
212
- // When progress = maxSnapPoint, translateY = 0 (fully visible)
213
- // When progress = 0, translateY = sheetHeight (hidden)
214
- const targetTranslateY = sheetHeight - (progress * effectiveHeight);
215
- currentTranslateY = targetTranslateY;
216
- if (sheetRef) {
217
- sheetRef.style.translate = `0 ${targetTranslateY}px`;
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, withTransition = true) {
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, origin } = CONFIG.scale;
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 calcDynamicDuration() {
286
- const now = performance.now();
287
- const recent = dragState.history.filter(p => now - p.time <= 100);
288
- if (recent.length < 2)
289
- return `${CONFIG.animation.defaultDuration}s`;
290
- const first = recent[0];
291
- const last = recent[recent.length - 1];
292
- if (!first || !last)
293
- return `${CONFIG.animation.defaultDuration}s`;
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
- transitionDuration = '0s';
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
- const inScrollable = !!target.closest('[data-bottom-sheet-scroll]');
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, transitionDuration, or disable transitions
488
- // Do NOT call e.preventDefault() — allow native touch behavior until committed
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
- transitionDuration = '0s';
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
- if (translateY < 0)
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
- if (sheetRef)
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
- const nearestSnap = determineSnapPoint();
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
- const inScrollable = !!target.closest('[data-bottom-sheet-scroll]');
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
- transitionDuration = '0s';
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
- if (translateY < 0)
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
- if (sheetRef)
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
- const nearestSnap = determineSnapPoint();
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
- snapTo(findNearestSnapPoint(currentTranslateY, 0));
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
- if (!closeOnBackdrop || e.target !== backdropRef || isDragging || isAnimating)
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
- style:--transition-duration={transitionDuration}
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
- style:--transition-duration={transitionDuration}
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
- </style>
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.1",
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)] shadow-[0_-1px_0_rgb(255_255_255/0.05),0_-4px_20px_rgb(0_0_0/0.3)] [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 // 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
+ }