tera-system-ui 0.2.1 → 0.2.3

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.
@@ -1,3 +1,44 @@
1
+ <script module lang="ts">"use strict";
2
+ // ============================================
3
+ // PAGE-ROOT OVERSCROLL GUARD (native pull-to-refresh)
4
+ // ============================================
5
+ // The two-phase drag doesn't preventDefault() until a gesture commits past the ~10px
6
+ // activation threshold, so a SLOW downward pull that STARTS on an interactive element
7
+ // (button / input / link / [tabindex]) leaves a window where the browser can claim the
8
+ // gesture as native pull-to-refresh / overscroll-reload before the sheet does — which is
9
+ // why only a *slow* pull triggers it (a quick flick clears the threshold on the first
10
+ // touchmove and preventDefault()s first). `overscroll-behavior-y: contain` on the document
11
+ // root closes that window for the sheet's whole open lifetime WITHOUT touching the gesture
12
+ // logic. Shared + reference-counted across every mounted sheet so stacked sheets apply it
13
+ // once and the page's original inline value is restored only when the last sheet releases.
14
+ let overscrollGuardCount = 0;
15
+ let savedRootOverscrollY = '';
16
+ let savedBodyOverscrollY = '';
17
+ function acquireOverscrollGuard() {
18
+ if (typeof document === 'undefined')
19
+ return;
20
+ if (overscrollGuardCount === 0) {
21
+ const root = document.documentElement;
22
+ const { body } = document;
23
+ savedRootOverscrollY = root.style.overscrollBehaviorY;
24
+ savedBodyOverscrollY = body.style.overscrollBehaviorY;
25
+ // Set both: browsers disagree on whether html or body is the root scroller.
26
+ root.style.overscrollBehaviorY = 'contain';
27
+ body.style.overscrollBehaviorY = 'contain';
28
+ }
29
+ overscrollGuardCount++;
30
+ }
31
+ function releaseOverscrollGuard() {
32
+ if (typeof document === 'undefined' || overscrollGuardCount === 0)
33
+ return;
34
+ overscrollGuardCount--;
35
+ if (overscrollGuardCount === 0) {
36
+ document.documentElement.style.overscrollBehaviorY = savedRootOverscrollY;
37
+ document.body.style.overscrollBehaviorY = savedBodyOverscrollY;
38
+ }
39
+ }
40
+ </script>
41
+
1
42
  <script lang="ts">import { onDestroy, onMount } from 'svelte';
2
43
  import { bottomSheetStore } from './bottomSheetStore.svelte.js';
3
44
  import { bottomSheetRecipe } from './BottomSheet.recipe';
@@ -18,23 +59,31 @@ const CONFIG = {
18
59
  backdrop: {
19
60
  maxOpacity: 0.5 // Maximum opacity when fully open
20
61
  },
21
- // Animation timing
62
+ // Spring physics for open / close / settle, PLAYED ON THE COMPOSITOR via the Web
63
+ // Animations API (see animateTo) so motion holds the display's native refresh rate
64
+ // (e.g. 120Hz) even under main-thread load. `response` is the approximate settle
65
+ // time (s); `damping` is the damping ratio (1 = critical/no bounce, <1 adds a little
66
+ // life). The spring is simulated ONCE per gesture — seeded with the finger's release
67
+ // velocity so motion continues instead of restarting — and emitted as compositor
68
+ // keyframes; there is no per-frame JavaScript. `close` finalizes the instant it
69
+ // reaches offscreen (simulateSpring hideOnArrival) so there is no lingering tail.
22
70
  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
71
+ open: { response: 0.4, damping: 0.86 }, // present quick, no bounce
72
+ close: { response: 0.26, damping: 0.85 }, // dismiss fast, finalizes on arrival
73
+ snap: { response: 0.32, damping: 0.84 }, // moving between snap points
74
+ restDistance: 0.5, // px from target at which the simulated spring is "settled"
75
+ restVelocity: 40 // px/s below which the simulated spring is "at rest"
27
76
  },
28
77
  // Gesture handling
29
78
  gesture: {
30
79
  velocityInfluence: 0.01, // How much velocity affects snap selection
31
- overdragExponent: 0.55, // Overdrag resistance (lower = more resistance)
32
80
  closeVelocityThreshold: 0.35, // Velocity to trigger close (px/ms)
33
81
  closeProgressThreshold: 0.35, // Progress to trigger close (0-1)
34
82
  maxLockedDrag: 60, // Max drag when dragToClose disabled (px)
35
83
  lockedDragResistance: 0.3, // Resistance when dragToClose disabled
36
84
  // Two-phase gesture recognition for interactive elements (iOS-style)
37
85
  dragActivationThreshold: 10, // Vertical px to commit to drag from interactive element
86
+ pullClaimThreshold: 3, // Vertical px to CLAIM the gesture (preventDefault) BEFORE commit, so iOS Safari can't start pull-to-refresh during a slow pull. Below dragActivationThreshold; tune on-device.
38
87
  horizontalDeadZone: 1.2, // If abs(dx)/abs(dy) > this, abort drag (let native horizontal win)
39
88
  tapDistanceThreshold: 6 // Max total movement (px) to still count as a tap
40
89
  }
@@ -42,7 +91,7 @@ const CONFIG = {
42
91
  // ============================================
43
92
  // COMPONENT PROPS
44
93
  // ============================================
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();
94
+ let { id, open = $bindable(false), snapPoints = [0.9], defaultSnapPoint = 0, containerRef = null, scaleTargetRef = null, constrainToContainer = false, scaleBackground = true, dragToClose = true, closeOnBackdrop = true, showCloseButton = false, syncThemeColor = true, preventPullToRefresh = true, onClose, onOpenChange, children, header } = $props();
46
95
  // ============================================
47
96
  // INTERNAL STATE
48
97
  // ============================================
@@ -53,11 +102,32 @@ let backdropRef = $state(null);
53
102
  // Sheet state
54
103
  let isVisible = $state(false);
55
104
  let isClosing = $state(false);
56
- let isAnimating = $state(false);
57
105
  let isDragging = $state(false);
58
- let currentTranslateY = $state(0);
106
+ // Plain (NOT $state): written on every drag move (can be ~120/s) but never read
107
+ // reactively (no template / $derived / $effect depends on it). Keeping it out of the
108
+ // reactivity graph avoids a signal write per pointer event on high-refresh displays.
109
+ // During a compositor animation it holds the resting target; the true on-screen value
110
+ // is read from the compositor when a gesture interrupts (readLiveTranslateY).
111
+ let currentTranslateY = 0;
59
112
  let sheetHeight = $state(0);
60
- let transitionDuration = $state(`${CONFIG.animation.defaultDuration}s`);
113
+ // Compositor spring engine — programmatic motion (open/close/snap/settle) plays as Web
114
+ // Animations on the compositor thread (see animateTo). currentTranslateY holds the live
115
+ // RESTING position; while an animation runs we read the true on-screen value straight
116
+ // from the compositor (readLiveTranslateY), so grabbing the sheet mid-flight never jumps.
117
+ let sheetAnim = null; // sheet translate
118
+ let backdropAnim = null; // backdrop opacity (kept in sync)
119
+ let scaleAnim = null; // scale-target scale/translate/radius (in sync)
120
+ let scaleTargetPrimed = false; // static scale-target styles set once per open cycle
121
+ // iOS Safari status-bar tint. The full-screen backdrop darkens the area behind the status
122
+ // bar; with no page-level <meta name="theme-color"> Safari re-samples it and flips the bar
123
+ // dark (losing the immersive look). When the page declares none we add one (= page
124
+ // background) for the sheet's open lifetime and remove it on close; a page that sets its own
125
+ // theme-color is left alone — Safari honors it, so the backdrop can't flip it.
126
+ let themeColorMeta = null;
127
+ // Whether THIS sheet currently holds the shared page-root overscroll guard (see the module
128
+ // script). Kept per-instance so acquire/release stay balanced — restore is called from both
129
+ // the close completion and onDestroy, and must be a no-op the second time.
130
+ let heldOverscrollGuard = false;
61
131
  // Drag tracking
62
132
  let dragState = {
63
133
  startY: 0,
@@ -79,6 +149,11 @@ let dragState = {
79
149
  // Scroll state
80
150
  let scrollableElement = null;
81
151
  let isScrolledToTop = true;
152
+ // Backdrop dismiss: the pointerId of a press that STARTED on the backdrop. A genuine
153
+ // dismiss is a full press+release on the backdrop; tracking the origin lets us ignore
154
+ // the stray pointerup that lands on a freshly-mounted backdrop when a press begun
155
+ // elsewhere (e.g. a long-press on a button that opened the sheet) is released over it.
156
+ let backdropPressPointerId = null;
82
157
  // Dimensions
83
158
  let windowHeight = $state(typeof window !== 'undefined' ? window.innerHeight : 800);
84
159
  let containerHeight = $state(0);
@@ -108,6 +183,19 @@ onMount(() => {
108
183
  });
109
184
  onDestroy(() => {
110
185
  containerResizeObserver?.disconnect();
186
+ cancelAnimations();
187
+ restoreThemeColor();
188
+ restoreOverscrollGuard();
189
+ });
190
+ // Motion is driven by direct writes (drag) and Web Animations (open/close/snap), so keep
191
+ // the recipe's CSS `transition` OFF on these elements — otherwise the CSS transition would
192
+ // fight each per-move drag write and smear it. (WAAPI animations are independent of the
193
+ // `transition` property, so turning it off here does not affect them.)
194
+ $effect(() => {
195
+ if (sheetRef)
196
+ sheetRef.style.transition = 'none';
197
+ if (backdropRef)
198
+ backdropRef.style.transition = 'none';
111
199
  });
112
200
  // Watch containerRef for constrained mode
113
201
  $effect(() => {
@@ -145,19 +233,11 @@ $effect(() => {
145
233
  doClose();
146
234
  }
147
235
  });
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
- });
236
+ // NOTE: The scrollable region ([data-bottom-sheet-scroll]) is resolved lazily
237
+ // at gesture-start from the event target (see getScrollableFromTarget), NOT via
238
+ // a one-shot querySelector here. A single querySelector at open-time cannot see
239
+ // content that is mounted lazily AFTER the sheet opens (e.g. async-loaded lists),
240
+ // which left scrollableElement null and broke drag/scroll arbitration inside it.
161
241
  // ============================================
162
242
  // CORE FUNCTIONS
163
243
  // ============================================
@@ -176,52 +256,256 @@ function recalculateDimensions() {
176
256
  if (sheetRef)
177
257
  sheetHeight = sheetRef.offsetHeight;
178
258
  }
259
+ // ============================================
260
+ // COMPOSITOR SPRING ENGINE (Web Animations API)
261
+ // ============================================
262
+ // Open / close / snap / settle play as Web Animations on the COMPOSITOR thread, so they
263
+ // hold the display's native refresh rate (e.g. 120Hz) even when the main thread is busy —
264
+ // the failure mode of a per-frame rAF loop, which must be scheduled on main every frame
265
+ // and drops frames under contention. We simulate the damped spring ONCE per gesture
266
+ // (seeded with the finger's release velocity) to get a position trajectory, then hand it
267
+ // to the compositor as keyframes: NO per-frame JavaScript, store write, or layout during
268
+ // the animation. Drag stays on direct per-input writes (renderPosition) — it must track
269
+ // the finger 1:1, which no pre-baked animation can do.
270
+ const prefersReducedMotion = () => typeof window !== 'undefined' &&
271
+ !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
272
+ /** Convert (response, dampingRatio) to spring stiffness/damping (unit mass). */
273
+ function springConstants(response, damping) {
274
+ const omega = (2 * Math.PI) / response; // natural angular frequency
275
+ return { k: omega * omega, c: 2 * damping * omega };
276
+ }
277
+ // ── Position → visual mappings. Shared by immediate drag writes (renderPosition) and by
278
+ // keyframe generation, so a compositor animation and a live drag paint identically. ──
279
+ function backdropOpacityFor(translateY) {
280
+ const progress = Math.max(0, Math.min(maxSnapPoint, (sheetHeight - translateY) / effectiveHeight));
281
+ return Math.max(0, Math.min(CONFIG.backdrop.maxOpacity, progress * CONFIG.backdrop.maxOpacity));
282
+ }
283
+ function scalePropsFor(translateY) {
284
+ const p = Math.max(0, Math.min(1, (sheetHeight - translateY) / effectiveHeight));
285
+ const { factor, borderRadius, translateY: ty } = CONFIG.scale;
286
+ return {
287
+ scale: String(1 - factor * p),
288
+ translate: `0 ${(ty * p).toFixed(2)}px`,
289
+ borderRadius: `${(borderRadius * p).toFixed(2)}px`
290
+ };
291
+ }
292
+ /** Set the scale target's static (non-animated) styles once per open cycle. */
293
+ function primeScaleTarget() {
294
+ if (scaleTargetPrimed || !scaleTarget || !scaleBackground)
295
+ return;
296
+ scaleTarget.style.transition = 'none';
297
+ scaleTarget.style.transformOrigin = CONFIG.scale.origin;
298
+ scaleTarget.style.overflow = 'hidden';
299
+ scaleTarget.style.willChange = 'scale, translate, border-radius';
300
+ scaleTargetPrimed = true;
301
+ }
302
+ /** Write the whole visual state for a given translateY. Used by DRAG and immediate sets. */
303
+ function renderPosition(translateY) {
304
+ if (sheetRef)
305
+ sheetRef.style.translate = `0 ${translateY}px`;
306
+ const rawProgress = (sheetHeight - translateY) / effectiveHeight;
307
+ const progress = Math.max(0, Math.min(maxSnapPoint, rawProgress));
308
+ updateBackdrop(progress);
309
+ updateScaleTarget(progress);
310
+ bottomSheetStore.updateProgress(id, Math.max(0, Math.min(1, rawProgress)));
311
+ }
312
+ /** Read the sheet's LIVE translateY — reflects a running compositor animation. */
313
+ function readLiveTranslateY() {
314
+ if (!sheetRef)
315
+ return null;
316
+ const t = getComputedStyle(sheetRef).getPropertyValue('translate');
317
+ if (!t || t === 'none')
318
+ return null;
319
+ const parts = t.trim().split(/\s+/);
320
+ const y = parts.length > 1 ? parseFloat(parts[1]) : 0;
321
+ return Number.isFinite(y) ? y : null;
322
+ }
323
+ /** Cancel any in-flight compositor animations (sheet + backdrop + scale). */
324
+ function cancelAnimations() {
325
+ sheetAnim?.cancel();
326
+ backdropAnim?.cancel();
327
+ scaleAnim?.cancel();
328
+ sheetAnim = backdropAnim = scaleAnim = null;
329
+ }
330
+ /**
331
+ * Freeze the sheet at its LIVE on-screen position and stop the animation, so a fresh
332
+ * gesture anchors to exactly what's rendered — no jump. Called when the user grabs the
333
+ * sheet mid-flight. (.cancel() reverts fill:none animations to their base, which is the
334
+ * settled target — so we pin the live value to inline styles FIRST, then cancel.)
335
+ */
336
+ function interruptAnimation() {
337
+ if (sheetAnim || backdropAnim || scaleAnim) {
338
+ const liveY = readLiveTranslateY();
339
+ if (liveY !== null) {
340
+ currentTranslateY = liveY;
341
+ renderPosition(liveY); // pin translate + backdrop + scale to the live value
342
+ }
343
+ cancelAnimations();
344
+ }
345
+ isClosing = false;
346
+ }
347
+ /** Snap all visuals to a resting target — the base the fill:none animations settle onto. */
348
+ function applyFinal(target) {
349
+ currentTranslateY = target;
350
+ renderPosition(target);
351
+ }
352
+ /**
353
+ * Simulate a damped spring from startY → targetY seeded with v0 (px/s), returning the
354
+ * position trajectory (evenly spaced in time) and its duration (ms). For close/hide we
355
+ * stop at the FIRST arrival at the target (on the way offscreen) so there is no tail.
356
+ */
357
+ function simulateSpring(startY, targetY, v0, spring, hideOnArrival) {
358
+ const { restDistance, restVelocity } = CONFIG.animation;
359
+ const { k, c } = springConstants(spring.response, spring.damping);
360
+ const dt = 1 / 90; // keyframe sampling step; compositor interpolates between
361
+ const subSteps = 2; // physics sub-steps per sample (stability at high stiffness)
362
+ const h = dt / subSteps;
363
+ const maxSteps = Math.ceil(1.5 / dt); // safety cap (~1.5s)
364
+ let x = startY;
365
+ let v = v0;
366
+ const ys = [x];
367
+ for (let i = 0; i < maxSteps; i++) {
368
+ for (let s = 0; s < subSteps; s++) {
369
+ const a = -k * (x - targetY) - c * v;
370
+ v += a * h;
371
+ x += v * h;
372
+ }
373
+ if (hideOnArrival && x >= targetY) {
374
+ ys.push(targetY); // reached offscreen — stop before any rebound
375
+ break;
376
+ }
377
+ ys.push(x);
378
+ if (!hideOnArrival && Math.abs(x - targetY) < restDistance && Math.abs(v) < restVelocity) {
379
+ ys[ys.length - 1] = targetY;
380
+ break;
381
+ }
382
+ }
383
+ return { ys, duration: (ys.length - 1) * dt * 1000 };
384
+ }
385
+ /**
386
+ * Spring the sheet toward `target`, seeded with `initialVelocity` (px/s) so a flick
387
+ * continues seamlessly. Plays on the COMPOSITOR via the Web Animations API. If a spring
388
+ * is already in flight we read its LIVE position and re-simulate from there, so retargets
389
+ * (and mid-flight direction changes) never jump. The sheet's translate, the backdrop's
390
+ * opacity, and the scale target all animate from ONE simulation with identical timing, so
391
+ * they stay locked together on the compositor with zero per-frame JS.
392
+ */
393
+ function animateTo(target, spring, initialVelocity = 0, onComplete, hideOnArrival = false) {
394
+ const running = !!(sheetAnim || backdropAnim || scaleAnim);
395
+ const start = running ? (readLiveTranslateY() ?? currentTranslateY) : currentTranslateY;
396
+ cancelAnimations();
397
+ // Reduced motion, or a move too small to be worth animating → jump straight there.
398
+ if (prefersReducedMotion()) {
399
+ applyFinal(target);
400
+ onComplete?.();
401
+ return;
402
+ }
403
+ const { ys, duration } = simulateSpring(start, target, initialVelocity, spring, hideOnArrival);
404
+ if (duration < 8 || ys.length < 2) {
405
+ applyFinal(target);
406
+ onComplete?.();
407
+ return;
408
+ }
409
+ if (scaleTarget && scaleBackground)
410
+ primeScaleTarget();
411
+ // Bases = final values, so when the fill:none animations end the elements hold `target`
412
+ // (no revert-to-start flash). The animations override this from t=0 with keyframe[0]=start.
413
+ applyFinal(target);
414
+ const N = ys.length;
415
+ const opts = { duration, easing: 'linear', fill: 'none' };
416
+ const sheetKf = ys.map((y, i) => ({ translate: `0 ${y.toFixed(2)}px`, offset: i / (N - 1) }));
417
+ sheetAnim = sheetRef ? sheetRef.animate(sheetKf, opts) : null;
418
+ if (backdropRef) {
419
+ const kf = ys.map((y, i) => ({ opacity: String(backdropOpacityFor(y)), offset: i / (N - 1) }));
420
+ backdropAnim = backdropRef.animate(kf, opts);
421
+ }
422
+ if (scaleTarget && scaleBackground) {
423
+ const kf = ys.map((y, i) => ({ ...scalePropsFor(y), offset: i / (N - 1) }));
424
+ scaleAnim = scaleTarget.animate(kf, opts);
425
+ }
426
+ // Finalize on natural completion only. .cancel() (retarget / grab) fires oncancel, not
427
+ // onfinish, so a superseded animation never runs the callback.
428
+ if (sheetAnim) {
429
+ sheetAnim.onfinish = () => {
430
+ sheetAnim = backdropAnim = scaleAnim = null;
431
+ onComplete?.();
432
+ };
433
+ }
434
+ else {
435
+ applyFinal(target);
436
+ onComplete?.();
437
+ }
438
+ }
179
439
  function doOpen() {
440
+ // Refresh viewport/container dimensions BEFORE the sheet renders, so its
441
+ // height (maxSheetHeight) is derived from current values. While the sheet is
442
+ // closed, windowHeight is not tracked (handleWindowResize is gated on
443
+ // isVisible), so a resize between opens would otherwise leave the first
444
+ // render using a stale height — measured sheetHeight then mismatches the
445
+ // fresh effectiveHeight, shifting the sheet off the bottom edge until the
446
+ // next open. Doing this before isVisible keeps them in sync.
447
+ if (typeof window !== 'undefined')
448
+ windowHeight = window.innerHeight;
449
+ if (containerRef && constrainToContainer)
450
+ containerHeight = containerRef.clientHeight;
180
451
  isVisible = true;
181
452
  isClosing = false;
182
- isAnimating = true;
453
+ backdropPressPointerId = null; // fresh open — ignore any stale backdrop press id
454
+ applyThemeColor(); // hold the iOS status bar at the page color before the backdrop darkens it
455
+ applyOverscrollGuard(); // stop the page pull-to-refresh from hijacking a slow drag off an interactive element
183
456
  bottomSheetStore.open(id);
184
457
  requestAnimationFrame(() => {
185
458
  // sheetRef is bound by this rAF tick.
186
459
  recalculateDimensions();
187
- requestAnimationFrame(() => snapTo(currentSnapPoint, () => {
188
- isAnimating = false;
189
- }));
460
+ // Start fully hidden (matches the sheet's initial CSS translate of 100%),
461
+ // then spring up to the target snap point.
462
+ currentTranslateY = sheetHeight;
463
+ renderPosition(sheetHeight);
464
+ const target = sheetHeight - currentSnapPoint * effectiveHeight;
465
+ animateTo(target, CONFIG.animation.open, 0);
190
466
  });
191
467
  }
192
468
  function doClose() {
193
469
  if (isClosing)
194
470
  return;
471
+ animateSheetClose(0);
472
+ }
473
+ /** Spring the sheet fully down and finalize. `initialVelocity` (px/s) carries a flick-to-dismiss. */
474
+ function animateSheetClose(initialVelocity) {
195
475
  isClosing = true;
196
- isAnimating = true;
197
- snapTo(0, () => {
476
+ animateTo(sheetHeight, CONFIG.animation.close, initialVelocity, () => {
198
477
  isVisible = false;
199
478
  isClosing = false;
200
- isAnimating = false;
201
479
  bottomSheetStore.close(id);
202
480
  prevOpen = false;
203
481
  open = false;
204
482
  resetScaleTarget();
483
+ restoreThemeColor();
484
+ restoreOverscrollGuard();
205
485
  onClose?.();
206
486
  onOpenChange?.(false);
207
- });
487
+ }, true); // hideOnArrival — finalize as soon as it's offscreen, no spring tail
208
488
  }
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);
489
+ /**
490
+ * Settle after a drag release: resolve the target snap point, then spring to it
491
+ * carrying the finger's release velocity so the hand-off is seamless.
492
+ */
493
+ function settleFromRelease() {
494
+ const nearestSnap = determineSnapPoint();
495
+ const releaseVelocity = calculateVelocity() * 1000; // px/ms → px/s (down = positive)
496
+ if (nearestSnap === 0 && dragToClose) {
497
+ animateSheetClose(releaseVelocity);
498
+ return;
224
499
  }
500
+ const snap = nearestSnap > 0 ? nearestSnap : dragState.startSnapPoint;
501
+ const target = sheetHeight - snap * effectiveHeight;
502
+ animateTo(target, CONFIG.animation.snap, releaseVelocity, () => {
503
+ if (!open) {
504
+ prevOpen = true;
505
+ open = true;
506
+ onOpenChange?.(true);
507
+ }
508
+ });
225
509
  }
226
510
  // ============================================
227
511
  // VISUAL UPDATES
@@ -232,23 +516,23 @@ function updateBackdrop(progress) {
232
516
  const opacity = Math.max(0, Math.min(CONFIG.backdrop.maxOpacity, progress * CONFIG.backdrop.maxOpacity));
233
517
  backdropRef.style.opacity = String(opacity);
234
518
  }
235
- function updateScaleTarget(progress, withTransition = true) {
519
+ function updateScaleTarget(progress) {
236
520
  if (!scaleTarget || !scaleBackground)
237
521
  return;
522
+ // Static props are set ONCE per open cycle (see primeScaleTarget) — re-writing
523
+ // transition / transform-origin / overflow / will-change every frame churns the
524
+ // compositor layer and blows the frame budget on 120Hz displays.
525
+ primeScaleTarget();
238
526
  const p = Math.max(0, Math.min(1, progress));
239
- const { factor, borderRadius, translateY, origin } = CONFIG.scale;
527
+ const { factor, borderRadius, translateY } = CONFIG.scale;
528
+ // Per-frame: scale + translate are GPU-composited (cheap). Quantize border-radius to
529
+ // whole px so it only actually repaints ~a dozen times across the gesture, not 120×/s.
240
530
  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
- }
531
+ scaleTarget.style.translate = `0 ${(translateY * p).toFixed(2)}px`;
532
+ scaleTarget.style.borderRadius = `${Math.round(borderRadius * p)}px`;
250
533
  }
251
534
  function resetScaleTarget() {
535
+ scaleTargetPrimed = false;
252
536
  if (!scaleTarget || !scaleBackground)
253
537
  return;
254
538
  scaleTarget.style.scale = '';
@@ -260,6 +544,66 @@ function resetScaleTarget() {
260
544
  scaleTarget.style.willChange = '';
261
545
  }
262
546
  // ============================================
547
+ // BROWSER CHROME (iOS status-bar tint)
548
+ // ============================================
549
+ function isTransparentColor(color) {
550
+ return !color || color === 'transparent' || color === 'rgba(0, 0, 0, 0)';
551
+ }
552
+ /** First opaque background up the page root — what iOS Safari samples for the status bar. */
553
+ function resolvePageBackgroundColor() {
554
+ if (typeof document === 'undefined')
555
+ return '#ffffff';
556
+ for (const el of [document.body, document.documentElement]) {
557
+ if (!el)
558
+ continue;
559
+ const bg = getComputedStyle(el).backgroundColor;
560
+ if (!isTransparentColor(bg))
561
+ return bg;
562
+ }
563
+ return '#ffffff';
564
+ }
565
+ /**
566
+ * Keep the iOS Safari status bar immersive while the sheet is open. Acts ONLY when the page
567
+ * declares no <meta name="theme-color"> — the case Safari resolves by sampling the now-darkened
568
+ * backdrop and flipping the bar dark: we add one pinned to the page background (theme-aware,
569
+ * so it also tracks dark mode). A page that sets its own theme-color owns its status bar and
570
+ * is left untouched. restoreThemeColor removes ours on close.
571
+ */
572
+ function applyThemeColor() {
573
+ if (!syncThemeColor || typeof document === 'undefined' || themeColorMeta)
574
+ return;
575
+ if (document.querySelector('meta[name="theme-color"]'))
576
+ return;
577
+ const meta = document.createElement('meta');
578
+ meta.name = 'theme-color';
579
+ meta.setAttribute('content', resolvePageBackgroundColor());
580
+ document.head.appendChild(meta);
581
+ themeColorMeta = meta;
582
+ }
583
+ /** Remove the theme-color tag we added on open (no-op if we never added one). */
584
+ function restoreThemeColor() {
585
+ themeColorMeta?.remove();
586
+ themeColorMeta = null;
587
+ }
588
+ /**
589
+ * Guard the page against native pull-to-refresh while this sheet is open (see the module
590
+ * script). Balanced + idempotent per instance so the shared reference count stays correct
591
+ * even though restore fires from both the close completion and onDestroy.
592
+ */
593
+ function applyOverscrollGuard() {
594
+ if (!preventPullToRefresh || heldOverscrollGuard)
595
+ return;
596
+ heldOverscrollGuard = true;
597
+ acquireOverscrollGuard();
598
+ }
599
+ /** Release this sheet's hold on the page overscroll guard (no-op if not held). */
600
+ function restoreOverscrollGuard() {
601
+ if (!heldOverscrollGuard)
602
+ return;
603
+ heldOverscrollGuard = false;
604
+ releaseOverscrollGuard();
605
+ }
606
+ // ============================================
263
607
  // GESTURE UTILITIES
264
608
  // ============================================
265
609
  function pushHistory(y) {
@@ -282,23 +626,15 @@ function calculateVelocity() {
282
626
  const dt = last.time - first.time;
283
627
  return dt > 0 ? (last.y - first.y) / dt : 0;
284
628
  }
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;
629
+ function clampAboveMaxSnap(translateY) {
630
+ // The sheet is only as tall as its MAX snap point, with its bottom pinned to the
631
+ // screen bottom (translateY 0 == fully open to max). Letting translateY go below
632
+ // that (negative) would lift the sheet's bottom edge off the screen and reveal the
633
+ // background in the gap beneath it — which looks broken. So pin it: you cannot drag
634
+ // the sheet frame above its largest detent. This is also what native iOS does — the
635
+ // frame is firm at the top detent; only inner scroll content overscrolls.
636
+ const topLimit = sheetHeight - maxSnapPoint * effectiveHeight;
637
+ return translateY < topLimit ? topLimit : translateY;
302
638
  }
303
639
  function applyLockedDragResistance(rawTranslateY) {
304
640
  // Base translateY when at start snap point: sheetHeight - (startSnapPoint * effectiveHeight)
@@ -365,6 +701,15 @@ function isInteractive(target) {
365
701
  function shouldIgnoreDrag(target) {
366
702
  return !!target.closest('[data-bottom-sheet-no-drag]');
367
703
  }
704
+ /**
705
+ * Resolve the scrollable region ([data-bottom-sheet-scroll]) that contains the
706
+ * gesture target. Resolved per-gesture (not once at open-time) so that content
707
+ * mounted lazily after the sheet opened — and nested/multiple scroll regions —
708
+ * are detected correctly. Returns the nearest scrollable ancestor, or null.
709
+ */
710
+ function getScrollableFromTarget(target) {
711
+ return target.closest('[data-bottom-sheet-scroll]');
712
+ }
368
713
  /**
369
714
  * Install a one-shot capture-phase click listener to suppress the ghost click
370
715
  * that fires after a committed drag from an interactive element.
@@ -415,7 +760,7 @@ function commitToDrag(clientY) {
415
760
  dragState.currentY = currentTranslateY;
416
761
  clearHistory();
417
762
  pushHistory(clientY);
418
- transitionDuration = '0s';
763
+ interruptAnimation();
419
764
  if (scaleTarget)
420
765
  scaleTarget.style.transition = 'none';
421
766
  isDragging = true;
@@ -458,7 +803,9 @@ function handleTouchStart(e) {
458
803
  return;
459
804
  const target = e.target;
460
805
  const inDraggableArea = isInDraggableArea(target);
461
- const inScrollable = !!target.closest('[data-bottom-sheet-scroll]');
806
+ // Resolve scrollable region from the actual target so lazily-mounted content is detected.
807
+ scrollableElement = getScrollableFromTarget(target);
808
+ const inScrollable = !!scrollableElement;
462
809
  const isInteractiveTarget = isInteractive(target);
463
810
  // data-bottom-sheet-no-drag: treat as fully default element — no tracking at all
464
811
  if (shouldIgnoreDrag(target) && !inDraggableArea) {
@@ -484,8 +831,8 @@ function handleTouchStart(e) {
484
831
  dragState.committed = false;
485
832
  dragState.inScrollable = inScrollable;
486
833
  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
834
+ // Do NOT set isDragging or interrupt the animation yet — allow native
835
+ // touch behavior until the gesture commits, and don't call e.preventDefault().
489
836
  return;
490
837
  }
491
838
  // Non-interactive element: immediately commit to dragging (existing behavior)
@@ -493,7 +840,7 @@ function handleTouchStart(e) {
493
840
  dragState.isInteractiveStart = false;
494
841
  dragState.committed = true;
495
842
  dragState.shouldPreventScroll = !(inScrollable && isScrolledToTop && !inDraggableArea);
496
- transitionDuration = '0s';
843
+ interruptAnimation();
497
844
  if (scaleTarget)
498
845
  scaleTarget.style.transition = 'none';
499
846
  isDragging = true;
@@ -525,7 +872,26 @@ function handleTouchMove(e) {
525
872
  dragState.isInteractiveStart = false;
526
873
  return;
527
874
  }
528
- // Still pending do nothing, let native behavior continue
875
+ // Still pending (under the commit threshold). Claim a downward, vertical-leaning pull
876
+ // NOW so iOS Safari can't start its native pull-to-refresh during this slow pre-commit
877
+ // window — overscroll-behavior does NOT stop Safari's UA refresh; only preventDefault
878
+ // does. Stay PENDING (the sheet doesn't start moving until the >=10px commit), but deny
879
+ // the browser the gesture. Skip when a scroll region under the finger should scroll
880
+ // instead (pulling down while not at its top), and skip horizontal-leaning moves so
881
+ // native sliders keep working. Touch-only: desktop has no pull-to-refresh.
882
+ const claimDx = Math.abs(clientX - dragState.startX);
883
+ const claimDy = Math.abs(clientY - dragState.startY);
884
+ if (clientY - dragState.startY > 0 && claimDy >= CONFIG.gesture.pullClaimThreshold && claimDy >= claimDx) {
885
+ let claimGesture = true;
886
+ if (dragState.inScrollable) {
887
+ if (scrollableElement)
888
+ isScrolledToTop = scrollableElement.scrollTop <= 1;
889
+ if (!isScrolledToTop)
890
+ claimGesture = false; // let the content scroll natively
891
+ }
892
+ if (claimGesture && e.cancelable)
893
+ e.preventDefault();
894
+ }
529
895
  return;
530
896
  }
531
897
  // ── Normal drag logic (committed) ──
@@ -541,18 +907,12 @@ function handleTouchMove(e) {
541
907
  return;
542
908
  e.preventDefault();
543
909
  let translateY = dragState.currentY + moveDelta;
544
- if (translateY < 0)
545
- translateY = overdragResistance(translateY);
910
+ translateY = clampAboveMaxSnap(translateY);
546
911
  if (!dragToClose)
547
912
  translateY = applyLockedDragResistance(translateY);
548
913
  translateY = Math.min(translateY, sheetHeight);
549
914
  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);
915
+ renderPosition(translateY);
556
916
  }
557
917
  function handleTouchEnd(e) {
558
918
  if (e.touches.length > 0 || !dragState.isTouchActive)
@@ -574,19 +934,7 @@ function handleTouchEnd(e) {
574
934
  }
575
935
  isDragging = false;
576
936
  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
- }
937
+ settleFromRelease();
590
938
  }
591
939
  // ============================================
592
940
  // POINTER EVENT HANDLERS (Desktop)
@@ -596,7 +944,9 @@ function handlePointerDown(e) {
596
944
  return;
597
945
  const target = e.target;
598
946
  const inDraggableArea = isInDraggableArea(target);
599
- const inScrollable = !!target.closest('[data-bottom-sheet-scroll]');
947
+ // Resolve scrollable region from the actual target so lazily-mounted content is detected.
948
+ scrollableElement = getScrollableFromTarget(target);
949
+ const inScrollable = !!scrollableElement;
600
950
  const isInteractiveTarget = isInteractive(target);
601
951
  const shouldIgnoreDragTarget = shouldIgnoreDrag(target);
602
952
  // data-bottom-sheet-no-drag: treat as fully default element — no tracking at all
@@ -624,7 +974,7 @@ function handlePointerDown(e) {
624
974
  dragState.isPending = false;
625
975
  dragState.isInteractiveStart = false;
626
976
  dragState.committed = true;
627
- transitionDuration = '0s';
977
+ interruptAnimation();
628
978
  if (scaleTarget)
629
979
  scaleTarget.style.transition = 'none';
630
980
  sheetRef?.setPointerCapture(e.pointerId);
@@ -665,18 +1015,12 @@ function handlePointerMove(e) {
665
1015
  dragState.lastY = clientY;
666
1016
  pushHistory(clientY);
667
1017
  let translateY = dragState.currentY + clientY - dragState.startY;
668
- if (translateY < 0)
669
- translateY = overdragResistance(translateY);
1018
+ translateY = clampAboveMaxSnap(translateY);
670
1019
  if (!dragToClose)
671
1020
  translateY = applyLockedDragResistance(translateY);
672
1021
  translateY = Math.min(translateY, sheetHeight);
673
1022
  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);
1023
+ renderPosition(translateY);
680
1024
  e.preventDefault();
681
1025
  }
682
1026
  function handlePointerUp(e) {
@@ -701,19 +1045,7 @@ function handlePointerUp(e) {
701
1045
  if (!isDragging)
702
1046
  return;
703
1047
  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
- }
1048
+ settleFromRelease();
717
1049
  }
718
1050
  function handlePointerCancel(e) {
719
1051
  if (e.pointerType === 'touch' || dragState.activePointerId !== e.pointerId)
@@ -733,14 +1065,28 @@ function handlePointerCancel(e) {
733
1065
  if (!isDragging)
734
1066
  return;
735
1067
  isDragging = false;
736
- snapTo(findNearestSnapPoint(currentTranslateY, 0));
1068
+ // No velocity on cancel — settle to the nearest snap point at rest.
1069
+ const target = sheetHeight - findNearestSnapPoint(currentTranslateY, 0) * effectiveHeight;
1070
+ animateTo(target, CONFIG.animation.snap, 0);
737
1071
  }
738
1072
  function handleCloseButtonClick(e) {
739
1073
  e.stopPropagation();
740
1074
  doClose();
741
1075
  }
1076
+ function handleBackdropPointerDown(e) {
1077
+ // Record a press that begins ON the backdrop (and only the backdrop). Used to tell
1078
+ // a deliberate backdrop tap apart from the ghost pointerup described below.
1079
+ backdropPressPointerId = e.target === backdropRef ? e.pointerId : null;
1080
+ }
742
1081
  function handleBackdropClick(e) {
743
- if (!closeOnBackdrop || e.target !== backdropRef || isDragging || isAnimating)
1082
+ // Dismiss only when THIS press both started and ended on the backdrop. Gating on the
1083
+ // press origin (not an isAnimating timer) keeps a deliberate tap instant — even mid
1084
+ // open-animation — while ignoring the stray release that fires when a press begun
1085
+ // elsewhere lifts over a just-mounted backdrop (e.g. long-press a button to open the
1086
+ // sheet, then release: that pointerup lands on the backdrop but never pressed it).
1087
+ const startedOnBackdrop = backdropPressPointerId === e.pointerId;
1088
+ backdropPressPointerId = null;
1089
+ if (!closeOnBackdrop || e.target !== backdropRef || isDragging || !startedOnBackdrop)
744
1090
  return;
745
1091
  doClose();
746
1092
  }
@@ -763,85 +1109,101 @@ function touchEvents(node) {
763
1109
  }
764
1110
  };
765
1111
  }
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>
1112
+ </script>
1113
+
1114
+ <svelte:window onkeydown={handleKeydown}/>
1115
+
1116
+ {#if isVisible}
1117
+ <!-- Backdrop -->
1118
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
1119
+ <div
1120
+ bind:this={backdropRef}
1121
+ class={r.backdrop()}
1122
+ style:z-index={zIndex}
1123
+ onpointerdown={handleBackdropPointerDown}
1124
+ onpointerup={handleBackdropClick}
1125
+ ></div>
1126
+
1127
+ <!-- Sheet -->
1128
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
1129
+ <div
1130
+ bind:this={sheetRef}
1131
+ class={r.sheet()}
1132
+ style:z-index={zIndex + 1}
1133
+ style:height="{maxSheetHeight}px"
1134
+ data-bottom-sheet-surface
1135
+ role="dialog"
1136
+ aria-modal="true"
1137
+ tabindex={-1}
1138
+ use:touchEvents
1139
+ onpointerdown={handlePointerDown}
1140
+ onpointermove={handlePointerMove}
1141
+ onpointerup={handlePointerUp}
1142
+ onpointercancel={handlePointerCancel}
1143
+ >
1144
+ <!-- Drag Handle -->
1145
+ <div class={r.handle()} data-bottom-sheet-handle>
1146
+ <div class={r.handleBar()}></div>
1147
+ </div>
1148
+
1149
+ <!-- Header -->
1150
+ {#if header}
1151
+ <div class={r.header()} data-bottom-sheet-header>
1152
+ {@render header()}
1153
+ {#if showCloseButton}
1154
+ <button
1155
+ class={r.closeButton()}
1156
+ data-bottom-sheet-close
1157
+ onclick={handleCloseButtonClick}
1158
+ aria-label="Close"
1159
+ type="button"
1160
+ >
1161
+ <IconX class="size-4"/>
1162
+ </button>
1163
+ {/if}
1164
+ </div>
1165
+ {/if}
1166
+
1167
+ <!-- Content -->
1168
+ <div bind:this={contentRef} class={r.content()} data-bottom-sheet-content>
1169
+ {#if children}
1170
+ {@render children()}
1171
+ {/if}
1172
+ </div>
1173
+ </div>
1174
+ {/if}
1175
+
1176
+ <style>
1177
+ /* Consumer-marked scroll region inside the sheet body. */
1178
+ [data-bottom-sheet-content] :global([data-bottom-sheet-scroll]) {
1179
+ flex: 1;
1180
+ overflow-y: auto;
1181
+ overflow-x: hidden;
1182
+ overscroll-behavior: contain;
1183
+ -webkit-overflow-scrolling: touch;
1184
+ min-height: 0;
1185
+ touch-action: pan-y;
1186
+ }
1187
+
1188
+ /* Suppress native gestures on header children so the drag is captured. */
1189
+ [data-bottom-sheet-header] :global(*) {
1190
+ touch-action: none;
1191
+ user-select: none;
1192
+ }
1193
+
1194
+ /* Extend the sheet's own surface below its bottom edge. The sheet is only as tall as
1195
+ its max snap point; if it ever sits a hair above the screen bottom — the open spring's
1196
+ slight overshoot, or sub-pixel rounding — this keeps the sliver beneath it sheet-colored
1197
+ instead of letting the background show through. The sheet is overflow-visible so this is
1198
+ not clipped, and it rides along with the sheet's transform. */
1199
+ :global([data-bottom-sheet-surface]::after) {
1200
+ content: '';
1201
+ position: absolute;
1202
+ top: 100%;
1203
+ left: 0;
1204
+ right: 0;
1205
+ height: 100%;
1206
+ background-color: inherit;
1207
+ pointer-events: none;
1208
+ }
1209
+ </style>