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.
- package/dist/components/bottom-sheet/BottomSheet.recipe.d.ts +20 -0
- package/dist/components/bottom-sheet/BottomSheet.recipe.js +1 -1
- package/dist/components/bottom-sheet/BottomSheet.svelte +570 -208
- package/dist/llms/bottom-sheet.md +2 -0
- package/package.json +1 -1
- package/registry/bottom-sheet.json +2 -2
|
@@ -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
|
-
//
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
if (!scrollableElement)
|
|
154
|
-
return;
|
|
155
|
-
const handleScroll = () => {
|
|
156
|
-
isScrolledToTop = scrollableElement.scrollTop <= 1;
|
|
157
|
-
};
|
|
158
|
-
scrollableElement.addEventListener('scroll', handleScroll, { passive: true });
|
|
159
|
-
isScrolledToTop = scrollableElement.scrollTop <= 1;
|
|
160
|
-
});
|
|
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
|
-
|
|
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
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
if (
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
updateBackdrop(progress);
|
|
220
|
-
updateScaleTarget(progress);
|
|
221
|
-
bottomSheetStore.updateProgress(id, Math.max(0, Math.min(1, progress)));
|
|
222
|
-
if (onComplete) {
|
|
223
|
-
setTimeout(onComplete, parseFloat(transitionDuration) * 1000);
|
|
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
|
|
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
|
|
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
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
const speed = Math.abs((last.y - first.y) / (last.time - first.time));
|
|
295
|
-
const { minDuration, maxDuration } = CONFIG.animation;
|
|
296
|
-
let duration = maxDuration - (speed / 2) * (maxDuration - minDuration);
|
|
297
|
-
duration = Math.max(minDuration, Math.min(maxDuration, duration));
|
|
298
|
-
return `${duration}s`;
|
|
299
|
-
}
|
|
300
|
-
function overdragResistance(value) {
|
|
301
|
-
return value < 0 ? -Math.pow(Math.abs(value), CONFIG.gesture.overdragExponent) : value;
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
488
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
778
|
-
onpointerup={handleBackdropClick}
|
|
779
|
-
></div>
|
|
780
|
-
|
|
781
|
-
<!-- Sheet -->
|
|
782
|
-
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
783
|
-
<div
|
|
784
|
-
bind:this={sheetRef}
|
|
785
|
-
class={r.sheet()}
|
|
786
|
-
style:z-index={zIndex + 1}
|
|
787
|
-
style:height="{maxSheetHeight}px"
|
|
788
|
-
|
|
789
|
-
role="dialog"
|
|
790
|
-
aria-modal="true"
|
|
791
|
-
tabindex={-1}
|
|
792
|
-
use:touchEvents
|
|
793
|
-
onpointerdown={handlePointerDown}
|
|
794
|
-
onpointermove={handlePointerMove}
|
|
795
|
-
onpointerup={handlePointerUp}
|
|
796
|
-
onpointercancel={handlePointerCancel}
|
|
797
|
-
>
|
|
798
|
-
<!-- Drag Handle -->
|
|
799
|
-
<div class={r.handle()} data-bottom-sheet-handle>
|
|
800
|
-
<div class={r.handleBar()}></div>
|
|
801
|
-
</div>
|
|
802
|
-
|
|
803
|
-
<!-- Header -->
|
|
804
|
-
{#if header}
|
|
805
|
-
<div class={r.header()} data-bottom-sheet-header>
|
|
806
|
-
{@render header()}
|
|
807
|
-
{#if showCloseButton}
|
|
808
|
-
<button
|
|
809
|
-
class={r.closeButton()}
|
|
810
|
-
data-bottom-sheet-close
|
|
811
|
-
onclick={handleCloseButtonClick}
|
|
812
|
-
aria-label="Close"
|
|
813
|
-
type="button"
|
|
814
|
-
>
|
|
815
|
-
<IconX class="size-4"/>
|
|
816
|
-
</button>
|
|
817
|
-
{/if}
|
|
818
|
-
</div>
|
|
819
|
-
{/if}
|
|
820
|
-
|
|
821
|
-
<!-- Content -->
|
|
822
|
-
<div bind:this={contentRef} class={r.content()} data-bottom-sheet-content>
|
|
823
|
-
{#if children}
|
|
824
|
-
{@render children()}
|
|
825
|
-
{/if}
|
|
826
|
-
</div>
|
|
827
|
-
</div>
|
|
828
|
-
{/if}
|
|
829
|
-
|
|
830
|
-
<style>
|
|
831
|
-
/* Consumer-marked scroll region inside the sheet body. */
|
|
832
|
-
[data-bottom-sheet-content] :global([data-bottom-sheet-scroll]) {
|
|
833
|
-
flex: 1;
|
|
834
|
-
overflow-y: auto;
|
|
835
|
-
overflow-x: hidden;
|
|
836
|
-
overscroll-behavior: contain;
|
|
837
|
-
-webkit-overflow-scrolling: touch;
|
|
838
|
-
min-height: 0;
|
|
839
|
-
touch-action: pan-y;
|
|
840
|
-
}
|
|
841
|
-
|
|
842
|
-
/* Suppress native gestures on header children so the drag is captured. */
|
|
843
|
-
[data-bottom-sheet-header] :global(*) {
|
|
844
|
-
touch-action: none;
|
|
845
|
-
user-select: none;
|
|
846
|
-
}
|
|
847
|
-
|
|
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>
|