@surdeddd/bottom-sheet 0.1.0
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/LICENSE +21 -0
- package/README.md +377 -0
- package/dist/BottomSheetEngine-CvSVbo6c.d.cts +704 -0
- package/dist/BottomSheetEngine-DbO8rW_3.d.ts +704 -0
- package/dist/element.cjs +3442 -0
- package/dist/element.d.cts +88 -0
- package/dist/element.d.ts +88 -0
- package/dist/element.iife.global.js +3 -0
- package/dist/element.js +3442 -0
- package/dist/index.cjs +3693 -0
- package/dist/index.d.cts +184 -0
- package/dist/index.d.ts +184 -0
- package/dist/index.js +3669 -0
- package/dist/integrations/formik.cjs +27 -0
- package/dist/integrations/formik.d.cts +27 -0
- package/dist/integrations/formik.d.ts +27 -0
- package/dist/integrations/formik.js +25 -0
- package/dist/integrations/react-hook-form.cjs +28 -0
- package/dist/integrations/react-hook-form.d.cts +30 -0
- package/dist/integrations/react-hook-form.d.ts +30 -0
- package/dist/integrations/react-hook-form.js +26 -0
- package/dist/overlay.cjs +1058 -0
- package/dist/overlay.d.cts +282 -0
- package/dist/overlay.d.ts +282 -0
- package/dist/overlay.js +1053 -0
- package/dist/preact.cjs +2 -0
- package/dist/preact.d.cts +5 -0
- package/dist/preact.d.ts +5 -0
- package/dist/preact.js +2 -0
- package/dist/qwik.cjs +3046 -0
- package/dist/qwik.d.cts +55 -0
- package/dist/qwik.d.ts +55 -0
- package/dist/qwik.js +3044 -0
- package/dist/react.cjs +4162 -0
- package/dist/react.d.cts +301 -0
- package/dist/react.d.ts +301 -0
- package/dist/react.js +4156 -0
- package/dist/solid.cjs +3063 -0
- package/dist/solid.d.cts +66 -0
- package/dist/solid.d.ts +66 -0
- package/dist/solid.js +3061 -0
- package/dist/styles.css +508 -0
- package/dist/svelte.cjs +3200 -0
- package/dist/svelte.d.ts +89 -0
- package/dist/svelte.js +3177 -0
- package/dist/themes/ios.css +24 -0
- package/dist/themes/material.css +24 -0
- package/dist/themes/vercel.css +24 -0
- package/dist/transform-Brkf-wzo.d.cts +4 -0
- package/dist/transform-Brkf-wzo.d.ts +4 -0
- package/dist/vue.cjs +3361 -0
- package/dist/vue.d.ts +68 -0
- package/dist/vue.js +3358 -0
- package/package.json +288 -0
package/dist/react.js
ADDED
|
@@ -0,0 +1,4156 @@
|
|
|
1
|
+
import { forwardRef, useRef, useState, useEffect, useImperativeHandle, useMemo, useCallback, useSyncExternalStore, useLayoutEffect } from 'react';
|
|
2
|
+
import { jsxs, jsx } from 'react/jsx-runtime';
|
|
3
|
+
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
|
|
6
|
+
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
|
|
7
|
+
|
|
8
|
+
// src/core/lifecycle/sheetStack.ts
|
|
9
|
+
var BASE_Z = 100;
|
|
10
|
+
var STEP = 10;
|
|
11
|
+
var SheetStack = class {
|
|
12
|
+
constructor() {
|
|
13
|
+
__publicField(this, "entries", []);
|
|
14
|
+
}
|
|
15
|
+
push(entry) {
|
|
16
|
+
this.entries.push(entry);
|
|
17
|
+
this.recompute();
|
|
18
|
+
return () => this.remove(entry.id);
|
|
19
|
+
}
|
|
20
|
+
remove(id) {
|
|
21
|
+
const idx = this.entries.findIndex((e) => e.id === id);
|
|
22
|
+
if (idx === -1) return;
|
|
23
|
+
this.entries.splice(idx, 1);
|
|
24
|
+
this.recompute();
|
|
25
|
+
}
|
|
26
|
+
size() {
|
|
27
|
+
return this.entries.length;
|
|
28
|
+
}
|
|
29
|
+
clear() {
|
|
30
|
+
this.entries = [];
|
|
31
|
+
}
|
|
32
|
+
recompute() {
|
|
33
|
+
this.entries.forEach((entry, i) => {
|
|
34
|
+
entry.setZIndex(BASE_Z + i * STEP);
|
|
35
|
+
entry.setIsTop(i === this.entries.length - 1);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
var sheetStack = new SheetStack();
|
|
40
|
+
|
|
41
|
+
// src/core/primitives/transform.ts
|
|
42
|
+
function buildTransformTemplate(axis) {
|
|
43
|
+
switch (axis) {
|
|
44
|
+
case "bottom":
|
|
45
|
+
return (offset) => `translate3d(0, ${offset}px, 0)`;
|
|
46
|
+
case "top":
|
|
47
|
+
return (offset) => `translate3d(0, ${-offset}px, 0)`;
|
|
48
|
+
case "left":
|
|
49
|
+
return (offset) => `translate3d(${-offset}px, 0, 0)`;
|
|
50
|
+
case "right":
|
|
51
|
+
return (offset) => `translate3d(${offset}px, 0, 0)`;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
var CLOSED_TRANSFORM = {
|
|
55
|
+
bottom: "translate3d(0, 100%, 0)",
|
|
56
|
+
top: "translate3d(0, -100%, 0)",
|
|
57
|
+
left: "translate3d(-100%, 0, 0)",
|
|
58
|
+
right: "translate3d(100%, 0, 0)"
|
|
59
|
+
};
|
|
60
|
+
var layoutAxis = (axis) => axis === "left" || axis === "right" ? "width" : "height";
|
|
61
|
+
|
|
62
|
+
// src/core/primitives/instance-id.ts
|
|
63
|
+
var counters = {};
|
|
64
|
+
function nextInstanceId(prefix) {
|
|
65
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
66
|
+
return `${prefix}-${crypto.randomUUID()}`;
|
|
67
|
+
}
|
|
68
|
+
counters[prefix] = (counters[prefix] ?? 0) + 1;
|
|
69
|
+
return `${prefix}-${counters[prefix]}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// src/core/primitives/event-bus.ts
|
|
73
|
+
function createEventBus() {
|
|
74
|
+
const listeners = /* @__PURE__ */ new Map();
|
|
75
|
+
return {
|
|
76
|
+
on(event, fn) {
|
|
77
|
+
let set = listeners.get(event);
|
|
78
|
+
if (!set) {
|
|
79
|
+
set = /* @__PURE__ */ new Set();
|
|
80
|
+
listeners.set(event, set);
|
|
81
|
+
}
|
|
82
|
+
set.add(fn);
|
|
83
|
+
return () => {
|
|
84
|
+
set.delete(fn);
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
emit(event, payload) {
|
|
88
|
+
const set = listeners.get(event);
|
|
89
|
+
if (!set) return;
|
|
90
|
+
for (const fn of [...set]) {
|
|
91
|
+
fn(payload);
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
listenerCount(event) {
|
|
95
|
+
return listeners.get(event)?.size ?? 0;
|
|
96
|
+
},
|
|
97
|
+
clear() {
|
|
98
|
+
listeners.clear();
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// src/core/primitives/cssLength.ts
|
|
104
|
+
var probe = null;
|
|
105
|
+
var PROBE_ATTR = "data-bs-probe";
|
|
106
|
+
var ensureProbe = (axis) => {
|
|
107
|
+
if (typeof document === "undefined") return null;
|
|
108
|
+
if (!probe || !probe.isConnected) {
|
|
109
|
+
const existing = document.querySelector(`[${PROBE_ATTR}]`);
|
|
110
|
+
if (existing) {
|
|
111
|
+
probe = existing;
|
|
112
|
+
} else {
|
|
113
|
+
probe = document.createElement("div");
|
|
114
|
+
probe.setAttribute("aria-hidden", "true");
|
|
115
|
+
probe.setAttribute(PROBE_ATTR, "");
|
|
116
|
+
probe.style.cssText = "position:absolute;visibility:hidden;pointer-events:none;contain:strict;left:0;top:0;width:0;height:0;";
|
|
117
|
+
document.body.appendChild(probe);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (axis === "vertical") {
|
|
121
|
+
probe.style.width = "0";
|
|
122
|
+
probe.style.height = "";
|
|
123
|
+
} else {
|
|
124
|
+
probe.style.height = "0";
|
|
125
|
+
probe.style.width = "";
|
|
126
|
+
}
|
|
127
|
+
return probe;
|
|
128
|
+
};
|
|
129
|
+
var isViewportAxisVertical = (mode) => mode === "bottom" || mode === "top";
|
|
130
|
+
var viewportSize = (mode) => {
|
|
131
|
+
if (typeof window === "undefined") return 0;
|
|
132
|
+
return isViewportAxisVertical(mode) ? window.innerHeight : window.innerWidth;
|
|
133
|
+
};
|
|
134
|
+
var PERCENT = /^(-?\d+(?:\.\d+)?)%$/;
|
|
135
|
+
var resolveSnap = (point, mode, measureFit) => {
|
|
136
|
+
if (typeof point === "number") return Math.max(0, point);
|
|
137
|
+
if (point === "full") return viewportSize(mode);
|
|
138
|
+
if (point === "fit") return Math.max(0, measureFit?.() ?? 0);
|
|
139
|
+
const pctMatch = point.match(PERCENT);
|
|
140
|
+
if (pctMatch) {
|
|
141
|
+
const pct = parseFloat(pctMatch[1]) / 100;
|
|
142
|
+
return Math.max(0, viewportSize(mode) * pct);
|
|
143
|
+
}
|
|
144
|
+
const axis = isViewportAxisVertical(mode) ? "vertical" : "horizontal";
|
|
145
|
+
const el = ensureProbe(axis);
|
|
146
|
+
if (!el) return 0;
|
|
147
|
+
if (axis === "vertical") {
|
|
148
|
+
el.style.height = point;
|
|
149
|
+
return Math.max(0, el.getBoundingClientRect().height);
|
|
150
|
+
}
|
|
151
|
+
el.style.width = point;
|
|
152
|
+
return Math.max(0, el.getBoundingClientRect().width);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// src/core/primitives/snap-points.ts
|
|
156
|
+
var resolveSnapList = (points, mode, measureFit) => points.map((p) => {
|
|
157
|
+
const raw = resolveSnap(p.size, mode, measureFit);
|
|
158
|
+
if (!Number.isFinite(raw) || raw < 0) {
|
|
159
|
+
console.warn(
|
|
160
|
+
`[BottomSheet] snap "${p.id}" resolved to invalid size (${String(p.size)} \u2192 ${raw}); clamped to 0.`
|
|
161
|
+
);
|
|
162
|
+
return { id: p.id, size: 0 };
|
|
163
|
+
}
|
|
164
|
+
return { id: p.id, size: raw };
|
|
165
|
+
}).sort((a, b) => a.size - b.size);
|
|
166
|
+
var VH_NOT_DVH = /(^|[^a-z])vh\b/i;
|
|
167
|
+
var auditVhUsage = (points) => {
|
|
168
|
+
for (const p of points) {
|
|
169
|
+
if (typeof p.size !== "string") continue;
|
|
170
|
+
if (!VH_NOT_DVH.test(p.size)) continue;
|
|
171
|
+
console.warn(
|
|
172
|
+
`[BottomSheet] snap "${p.id}" uses "vh" \u2014 prefer "dvh" on mobile (iOS Safari URL bar makes vh unstable). See https://web.dev/blog/viewport-units`
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
var findNearest = (size, resolved, allowed, direction = 0, bias = 0) => {
|
|
177
|
+
const pool = resolved.filter((p) => allowed.includes(p.id));
|
|
178
|
+
if (pool.length === 0) return null;
|
|
179
|
+
const target = size + direction * bias;
|
|
180
|
+
let best = pool[0];
|
|
181
|
+
let bestDist = Math.abs(target - best.size);
|
|
182
|
+
for (let i = 1; i < pool.length; i++) {
|
|
183
|
+
const cand = pool[i];
|
|
184
|
+
const dist = Math.abs(target - cand.size);
|
|
185
|
+
if (dist < bestDist) {
|
|
186
|
+
best = cand;
|
|
187
|
+
bestDist = dist;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return best;
|
|
191
|
+
};
|
|
192
|
+
var allowedRange = (resolved, allowed) => {
|
|
193
|
+
const pool = resolved.filter((p) => allowed.includes(p.id));
|
|
194
|
+
if (pool.length === 0) return { min: 0, max: 0 };
|
|
195
|
+
return {
|
|
196
|
+
min: pool[0].size,
|
|
197
|
+
max: pool[pool.length - 1].size
|
|
198
|
+
};
|
|
199
|
+
};
|
|
200
|
+
var findById = (id, resolved) => resolved.find((p) => p.id === id) ?? null;
|
|
201
|
+
var MOUSE_FLICK_VELOCITY = 0.4;
|
|
202
|
+
var MOUSE_DRAG_THRESHOLD = 12;
|
|
203
|
+
var DIRECTIONAL_INTENT_VELOCITY = 0.15;
|
|
204
|
+
var findDragSettleTarget = (input) => {
|
|
205
|
+
const speed = Math.abs(input.velocity);
|
|
206
|
+
const flickThreshold = input.pointerKind === "mouse" ? MOUSE_FLICK_VELOCITY : input.flickVelocity;
|
|
207
|
+
const dragThresh = input.pointerKind === "mouse" ? MOUSE_DRAG_THRESHOLD : input.dragThreshold;
|
|
208
|
+
let direction = 0;
|
|
209
|
+
if (speed > flickThreshold || speed > DIRECTIONAL_INTENT_VELOCITY) {
|
|
210
|
+
direction = input.velocity > 0 ? 1 : -1;
|
|
211
|
+
}
|
|
212
|
+
if (Math.abs(input.delta) < dragThresh && direction === 0) {
|
|
213
|
+
return findById(input.activeId, input.resolved);
|
|
214
|
+
}
|
|
215
|
+
const speedBias = speed > flickThreshold ? Math.min(speed * 180, input.maxAxisSize) : 0;
|
|
216
|
+
return findNearest(
|
|
217
|
+
input.size,
|
|
218
|
+
input.resolved,
|
|
219
|
+
input.allowed,
|
|
220
|
+
direction,
|
|
221
|
+
speedBias
|
|
222
|
+
);
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// src/core/primitives/snap-resolver.ts
|
|
226
|
+
var SnapResolver = class {
|
|
227
|
+
constructor(raw, allowed, mode, measureFit, onMaxAxisSizeChange) {
|
|
228
|
+
__publicField(this, "mode", mode);
|
|
229
|
+
__publicField(this, "measureFit", measureFit);
|
|
230
|
+
__publicField(this, "onMaxAxisSizeChange", onMaxAxisSizeChange);
|
|
231
|
+
__publicField(this, "raw");
|
|
232
|
+
__publicField(this, "allowedIds");
|
|
233
|
+
__publicField(this, "resolved", []);
|
|
234
|
+
__publicField(this, "maxAxisSize", 0);
|
|
235
|
+
__publicField(this, "rangeCache", null);
|
|
236
|
+
this.raw = raw;
|
|
237
|
+
this.allowedIds = allowed ?? raw.map((p) => p.id);
|
|
238
|
+
this.recompute();
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Re-resolve raw → resolved against the current viewport / fit-measure,
|
|
242
|
+
* recompute maxAxisSize, invalidate range cache, notify engine. Called on
|
|
243
|
+
* resize, orientationchange, visualViewport changes, and snap-list swaps.
|
|
244
|
+
*/
|
|
245
|
+
recompute() {
|
|
246
|
+
this.resolved = resolveSnapList(this.raw, this.mode, this.measureFit);
|
|
247
|
+
this.maxAxisSize = this.resolved.reduce(
|
|
248
|
+
(m, s) => s.size > m ? s.size : m,
|
|
249
|
+
0
|
|
250
|
+
);
|
|
251
|
+
this.rangeCache = null;
|
|
252
|
+
this.onMaxAxisSizeChange?.(this.maxAxisSize);
|
|
253
|
+
}
|
|
254
|
+
/** Replace the raw snap list and recompute. Engine calls on `setSnapPoints`. */
|
|
255
|
+
setRaw(raw) {
|
|
256
|
+
this.raw = raw;
|
|
257
|
+
this.recompute();
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Replace the allow-list. Range cache depends on `(resolvedSnaps ×
|
|
261
|
+
* allowedIds)` so flipping ids alone invalidates without a full recompute.
|
|
262
|
+
*/
|
|
263
|
+
setAllowedIds(ids) {
|
|
264
|
+
this.allowedIds = ids;
|
|
265
|
+
this.rangeCache = null;
|
|
266
|
+
}
|
|
267
|
+
findById(id) {
|
|
268
|
+
return findById(id, this.resolved);
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Pure drag-settle target resolution. Engine passes the gesture-specific
|
|
272
|
+
* fields (delta, velocity, pointerKind, flickVelocity, dragThreshold) plus
|
|
273
|
+
* `size`/`activeId` since those are still engine-owned.
|
|
274
|
+
*/
|
|
275
|
+
findDragSettleTarget(args) {
|
|
276
|
+
return findDragSettleTarget({
|
|
277
|
+
...args,
|
|
278
|
+
resolved: this.resolved,
|
|
279
|
+
allowed: this.allowedIds,
|
|
280
|
+
maxAxisSize: this.maxAxisSize
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Memoised allowed-range query. Twice-per-drag-frame (gesture onMove +
|
|
285
|
+
* applySize→computeProgress); cache collapses 2× iteration of resolvedSnaps
|
|
286
|
+
* to one recompute per geometry/allow-list change.
|
|
287
|
+
*/
|
|
288
|
+
getAllowedRange() {
|
|
289
|
+
return this.rangeCache ?? (this.rangeCache = allowedRange(this.resolved, this.allowedIds));
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Read-only view of resolved snaps. Returned as `readonly` to discourage
|
|
293
|
+
* accidental mutation by feature install sites that close over this list.
|
|
294
|
+
*/
|
|
295
|
+
getResolvedSnaps() {
|
|
296
|
+
return this.resolved;
|
|
297
|
+
}
|
|
298
|
+
getAllowedIds() {
|
|
299
|
+
return this.allowedIds;
|
|
300
|
+
}
|
|
301
|
+
getMaxAxisSize() {
|
|
302
|
+
return this.maxAxisSize;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* External clamp override. The resize-observer feature compares
|
|
306
|
+
* `getMaxAxisSize()` against the live viewport and shrinks if the largest
|
|
307
|
+
* resolved snap exceeds it (mobile address-bar collapse / soft-keyboard
|
|
308
|
+
* window resize). The next `recompute()` overwrites this clamp from the
|
|
309
|
+
* resolved-snap reduction.
|
|
310
|
+
*
|
|
311
|
+
* Does NOT invalidate the range cache — viewport clamping doesn't change
|
|
312
|
+
* `(resolvedSnaps × allowedIds) → range`. DOES fire `onMaxAxisSizeChange`
|
|
313
|
+
* so the resolver owns the layout-axis style write end-to-end (symmetric
|
|
314
|
+
* with `recompute()` — feature call sites no longer duplicate the write).
|
|
315
|
+
*/
|
|
316
|
+
setMaxAxisSize(n) {
|
|
317
|
+
this.maxAxisSize = n;
|
|
318
|
+
this.onMaxAxisSizeChange?.(n);
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
// src/core/features/persist.ts
|
|
323
|
+
function readPersistedId(key) {
|
|
324
|
+
if (!key) return null;
|
|
325
|
+
if (typeof window === "undefined") return null;
|
|
326
|
+
if (typeof localStorage === "undefined") return null;
|
|
327
|
+
try {
|
|
328
|
+
return localStorage.getItem(key);
|
|
329
|
+
} catch {
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
function installPersist(engine, key) {
|
|
334
|
+
const unsubscribe = engine.on("snap", ({ id }) => {
|
|
335
|
+
if (typeof window === "undefined") return;
|
|
336
|
+
if (typeof localStorage === "undefined") return;
|
|
337
|
+
try {
|
|
338
|
+
localStorage.setItem(key, id);
|
|
339
|
+
} catch {
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
return unsubscribe;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// src/core/features/auto-collapse.ts
|
|
346
|
+
function installAutoCollapse(deps) {
|
|
347
|
+
const { ms } = deps;
|
|
348
|
+
if (ms === void 0 || ms <= 0) {
|
|
349
|
+
return () => {
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
let timer = null;
|
|
353
|
+
const clearTimer = () => {
|
|
354
|
+
if (timer !== null) {
|
|
355
|
+
clearTimeout(timer);
|
|
356
|
+
timer = null;
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
const fire = () => {
|
|
360
|
+
if (deps.isDestroyed()) return;
|
|
361
|
+
if (deps.isDragging()) return;
|
|
362
|
+
const allowed = deps.getAllowedIds();
|
|
363
|
+
const target = allowed.find((id) => {
|
|
364
|
+
const snap = deps.resolveSnap(id);
|
|
365
|
+
return snap !== null && snap.size > 0;
|
|
366
|
+
});
|
|
367
|
+
if (!target) return;
|
|
368
|
+
if (target === deps.getActiveId()) return;
|
|
369
|
+
deps.snapTo(target);
|
|
370
|
+
};
|
|
371
|
+
const reset = () => {
|
|
372
|
+
clearTimer();
|
|
373
|
+
if (deps.isDestroyed()) return;
|
|
374
|
+
timer = setTimeout(() => {
|
|
375
|
+
timer = null;
|
|
376
|
+
fire();
|
|
377
|
+
}, ms);
|
|
378
|
+
};
|
|
379
|
+
const unsubscribe = deps.on("snap", () => reset());
|
|
380
|
+
return () => {
|
|
381
|
+
clearTimer();
|
|
382
|
+
unsubscribe();
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// src/core/features/linked-sheets.ts
|
|
387
|
+
function notifyLinkedSheets(sheets, self) {
|
|
388
|
+
if (sheets.length === 0) return;
|
|
389
|
+
for (const linked of sheets) {
|
|
390
|
+
if (linked === self) continue;
|
|
391
|
+
try {
|
|
392
|
+
const ids = linked.getAllowedIds();
|
|
393
|
+
const target = ids.find((id) => id !== "closed");
|
|
394
|
+
if (target && target !== linked.state.activeId) {
|
|
395
|
+
void linked.snapTo(target);
|
|
396
|
+
}
|
|
397
|
+
} catch (err) {
|
|
398
|
+
queueMicrotask(() => {
|
|
399
|
+
throw err;
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// src/core/features/route.ts
|
|
406
|
+
function installRoute(deps) {
|
|
407
|
+
if (typeof window === "undefined") {
|
|
408
|
+
return () => {
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
if (!deps.routedTo && !deps.closeOnBack) {
|
|
412
|
+
return () => {
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
let routePushed = false;
|
|
416
|
+
let closeOnBackPushed = false;
|
|
417
|
+
let detachPopstate = null;
|
|
418
|
+
const popOurMarker = (key, expected) => {
|
|
419
|
+
const state = history.state;
|
|
420
|
+
if (state && state[key] === expected) {
|
|
421
|
+
try {
|
|
422
|
+
history.back();
|
|
423
|
+
} catch {
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
const onOpen = () => {
|
|
428
|
+
if (deps.routedTo && !detachPopstate) {
|
|
429
|
+
const target = deps.routedTo;
|
|
430
|
+
try {
|
|
431
|
+
history.pushState({ __bsRouted: target }, "", target);
|
|
432
|
+
routePushed = true;
|
|
433
|
+
} catch {
|
|
434
|
+
}
|
|
435
|
+
const onPop = (event) => {
|
|
436
|
+
if (deps.isDestroyed()) return;
|
|
437
|
+
const state = event.state;
|
|
438
|
+
if (state && state.__bsRouted === target && deps.getSize() > 0) {
|
|
439
|
+
routePushed = false;
|
|
440
|
+
void deps.close();
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
window.addEventListener("popstate", onPop);
|
|
444
|
+
detachPopstate = () => window.removeEventListener("popstate", onPop);
|
|
445
|
+
} else if (deps.closeOnBack && !detachPopstate) {
|
|
446
|
+
try {
|
|
447
|
+
history.pushState({ __bsSheet: deps.sheetId }, "");
|
|
448
|
+
closeOnBackPushed = true;
|
|
449
|
+
} catch {
|
|
450
|
+
}
|
|
451
|
+
const onPop = () => {
|
|
452
|
+
if (deps.isDestroyed()) return;
|
|
453
|
+
if (deps.getSize() > 0 && deps.isTopSheet()) {
|
|
454
|
+
const state = history.state;
|
|
455
|
+
if (state?.__bsSheet !== deps.sheetId) closeOnBackPushed = false;
|
|
456
|
+
void deps.close();
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
window.addEventListener("popstate", onPop);
|
|
460
|
+
detachPopstate = () => window.removeEventListener("popstate", onPop);
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
const onClose = () => {
|
|
464
|
+
detachPopstate?.();
|
|
465
|
+
detachPopstate = null;
|
|
466
|
+
if (routePushed) {
|
|
467
|
+
routePushed = false;
|
|
468
|
+
if (deps.routedTo !== void 0) popOurMarker("__bsRouted", deps.routedTo);
|
|
469
|
+
}
|
|
470
|
+
if (closeOnBackPushed) {
|
|
471
|
+
closeOnBackPushed = false;
|
|
472
|
+
popOurMarker("__bsSheet", deps.sheetId);
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
const unsubscribeOpen = deps.on("open", onOpen);
|
|
476
|
+
const unsubscribeClose = deps.on("close", onClose);
|
|
477
|
+
if (deps.getSize() > 0) onOpen();
|
|
478
|
+
return () => {
|
|
479
|
+
unsubscribeOpen();
|
|
480
|
+
unsubscribeClose();
|
|
481
|
+
detachPopstate?.();
|
|
482
|
+
detachPopstate = null;
|
|
483
|
+
if (routePushed) {
|
|
484
|
+
routePushed = false;
|
|
485
|
+
if (deps.routedTo !== void 0) popOurMarker("__bsRouted", deps.routedTo);
|
|
486
|
+
}
|
|
487
|
+
if (closeOnBackPushed) {
|
|
488
|
+
closeOnBackPushed = false;
|
|
489
|
+
popOurMarker("__bsSheet", deps.sheetId);
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// src/core/features/content-swipe.ts
|
|
495
|
+
var CONTENT_PULL_THRESHOLD = 60;
|
|
496
|
+
function installContentSwipe(deps) {
|
|
497
|
+
const { container } = deps;
|
|
498
|
+
let startY = 0;
|
|
499
|
+
let moved = false;
|
|
500
|
+
const onTouchStart = (e) => {
|
|
501
|
+
const t = e.touches[0];
|
|
502
|
+
if (!t) return;
|
|
503
|
+
startY = t.clientY;
|
|
504
|
+
moved = false;
|
|
505
|
+
};
|
|
506
|
+
const onTouchMove = (e) => {
|
|
507
|
+
const t = e.touches[0];
|
|
508
|
+
if (!t) return;
|
|
509
|
+
if (Math.abs(t.clientY - startY) > CONTENT_PULL_THRESHOLD) moved = true;
|
|
510
|
+
};
|
|
511
|
+
const onTouchEnd = (e) => {
|
|
512
|
+
if (!moved) return;
|
|
513
|
+
if (deps.isDragging() || deps.isAnimating()) return;
|
|
514
|
+
const t = e.changedTouches[0];
|
|
515
|
+
if (!t) return;
|
|
516
|
+
const deltaY = t.clientY - startY;
|
|
517
|
+
const atTop = container.scrollTop <= 0;
|
|
518
|
+
const allowed = deps.getAllowedIds();
|
|
519
|
+
const idx = allowed.indexOf(deps.getActiveId());
|
|
520
|
+
if (idx === -1) return;
|
|
521
|
+
if (atTop && deltaY > CONTENT_PULL_THRESHOLD && idx > 0) {
|
|
522
|
+
deps.snapTo(allowed[idx - 1]);
|
|
523
|
+
} else if (deltaY < -CONTENT_PULL_THRESHOLD && idx < allowed.length - 1) {
|
|
524
|
+
deps.snapTo(allowed[idx + 1]);
|
|
525
|
+
}
|
|
526
|
+
};
|
|
527
|
+
container.addEventListener("touchstart", onTouchStart, { passive: true });
|
|
528
|
+
container.addEventListener("touchmove", onTouchMove, { passive: true });
|
|
529
|
+
container.addEventListener("touchend", onTouchEnd, { passive: true });
|
|
530
|
+
return () => {
|
|
531
|
+
container.removeEventListener("touchstart", onTouchStart);
|
|
532
|
+
container.removeEventListener("touchmove", onTouchMove);
|
|
533
|
+
container.removeEventListener("touchend", onTouchEnd);
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// src/core/features/visual-viewport.ts
|
|
538
|
+
var SAFETY_PAD = 8;
|
|
539
|
+
function installVisualViewport(deps) {
|
|
540
|
+
if (typeof window === "undefined" || !window.visualViewport) {
|
|
541
|
+
return () => {
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
const vv = window.visualViewport;
|
|
545
|
+
let rafId = null;
|
|
546
|
+
const onResize = () => {
|
|
547
|
+
if (rafId !== null) return;
|
|
548
|
+
rafId = requestAnimationFrame(() => {
|
|
549
|
+
rafId = null;
|
|
550
|
+
if (deps.isDestroyed()) return;
|
|
551
|
+
deps.recomputeSnaps();
|
|
552
|
+
const isVertical = deps.isVerticalAxis();
|
|
553
|
+
const visible = isVertical ? vv.height : vv.width;
|
|
554
|
+
const windowSize = isVertical ? window.innerHeight : window.innerWidth;
|
|
555
|
+
const clamp = Math.max(0, Math.min(windowSize, visible) - SAFETY_PAD);
|
|
556
|
+
if (clamp < deps.getMaxAxisSize()) {
|
|
557
|
+
deps.setMaxAxisSize(clamp);
|
|
558
|
+
}
|
|
559
|
+
const max = deps.getMaxAxisSize();
|
|
560
|
+
deps.setSize(Math.min(deps.getSize(), max));
|
|
561
|
+
const current = deps.resolveActiveSnap();
|
|
562
|
+
if (current && !deps.isDragging()) {
|
|
563
|
+
deps.applySize(Math.min(current.size, max));
|
|
564
|
+
} else {
|
|
565
|
+
deps.applySize(deps.getSize());
|
|
566
|
+
}
|
|
567
|
+
});
|
|
568
|
+
};
|
|
569
|
+
vv.addEventListener("resize", onResize);
|
|
570
|
+
return () => {
|
|
571
|
+
vv.removeEventListener("resize", onResize);
|
|
572
|
+
if (rafId !== null) cancelAnimationFrame(rafId);
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// src/core/features/resize-observer.ts
|
|
577
|
+
function installResizeObserver(deps) {
|
|
578
|
+
if (typeof window === "undefined") {
|
|
579
|
+
return () => {
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
const onResize = () => {
|
|
583
|
+
if (deps.isDestroyed()) return;
|
|
584
|
+
deps.recomputeSnaps();
|
|
585
|
+
const mode = deps.getMode();
|
|
586
|
+
const isVerticalAxis = mode === "bottom" || mode === "top";
|
|
587
|
+
const viewportSize2 = isVerticalAxis ? window.innerHeight : window.innerWidth;
|
|
588
|
+
const max = deps.getMaxAxisSize();
|
|
589
|
+
if (viewportSize2 > 0 && viewportSize2 < max) {
|
|
590
|
+
deps.setMaxAxisSize(viewportSize2);
|
|
591
|
+
}
|
|
592
|
+
deps.cancelInFlight();
|
|
593
|
+
deps.newCycle();
|
|
594
|
+
const current = deps.resolveActiveSnap();
|
|
595
|
+
const newMax = deps.getMaxAxisSize();
|
|
596
|
+
const targetSize = current ? Math.min(current.size, newMax) : Math.min(deps.getSize(), newMax);
|
|
597
|
+
deps.setSize(targetSize);
|
|
598
|
+
if (current && !deps.isDragging()) deps.applySize(targetSize);
|
|
599
|
+
};
|
|
600
|
+
let resizeObserver = null;
|
|
601
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
602
|
+
resizeObserver = new ResizeObserver(onResize);
|
|
603
|
+
resizeObserver.observe(document.documentElement);
|
|
604
|
+
} else {
|
|
605
|
+
window.addEventListener("resize", onResize);
|
|
606
|
+
}
|
|
607
|
+
window.addEventListener("orientationchange", onResize);
|
|
608
|
+
const onVisibility = () => {
|
|
609
|
+
if (deps.isDestroyed()) return;
|
|
610
|
+
if (document.hidden) deps.cancelInFlight();
|
|
611
|
+
};
|
|
612
|
+
document.addEventListener("visibilitychange", onVisibility);
|
|
613
|
+
return () => {
|
|
614
|
+
resizeObserver?.disconnect();
|
|
615
|
+
window.removeEventListener("resize", onResize);
|
|
616
|
+
window.removeEventListener("orientationchange", onResize);
|
|
617
|
+
document.removeEventListener("visibilitychange", onVisibility);
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// src/core/features/slider-keyboard.ts
|
|
622
|
+
function installSliderKeyboard(deps) {
|
|
623
|
+
const isVerticalAxis = deps.mode === "bottom" || deps.mode === "top";
|
|
624
|
+
const stepUp = isVerticalAxis ? "ArrowUp" : "ArrowRight";
|
|
625
|
+
const stepDown = isVerticalAxis ? "ArrowDown" : "ArrowLeft";
|
|
626
|
+
const sheetExpandsKey = deps.mode === "top" ? stepDown : stepUp;
|
|
627
|
+
const sheetCollapsesKey = deps.mode === "top" ? stepUp : stepDown;
|
|
628
|
+
const onHandleKey = (e) => {
|
|
629
|
+
if (deps.isDestroyed()) return;
|
|
630
|
+
const allowed = deps.getAllowedIds();
|
|
631
|
+
const idx = allowed.indexOf(deps.getActiveId());
|
|
632
|
+
if (idx === -1) return;
|
|
633
|
+
if (e.key === sheetExpandsKey && idx < allowed.length - 1) {
|
|
634
|
+
e.preventDefault();
|
|
635
|
+
deps.snapTo(allowed[idx + 1]);
|
|
636
|
+
} else if (e.key === sheetCollapsesKey && idx > 0) {
|
|
637
|
+
e.preventDefault();
|
|
638
|
+
deps.snapTo(allowed[idx - 1]);
|
|
639
|
+
} else if (e.key === "Home") {
|
|
640
|
+
e.preventDefault();
|
|
641
|
+
deps.snapTo(allowed[0]);
|
|
642
|
+
} else if (e.key === "End") {
|
|
643
|
+
e.preventDefault();
|
|
644
|
+
deps.snapTo(allowed[allowed.length - 1]);
|
|
645
|
+
}
|
|
646
|
+
};
|
|
647
|
+
deps.handle.addEventListener("keydown", onHandleKey);
|
|
648
|
+
return () => deps.handle.removeEventListener("keydown", onHandleKey);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// src/core/types.ts
|
|
652
|
+
var SCRIM_PRESETS = Object.freeze({
|
|
653
|
+
subtle: {
|
|
654
|
+
color: "rgba(0,0,0,0.2)",
|
|
655
|
+
blur: void 0,
|
|
656
|
+
range: [0.3, 1],
|
|
657
|
+
interactive: false
|
|
658
|
+
},
|
|
659
|
+
standard: {
|
|
660
|
+
color: "rgba(0,0,0,0.4)",
|
|
661
|
+
blur: void 0,
|
|
662
|
+
range: [0, 1],
|
|
663
|
+
interactive: false
|
|
664
|
+
},
|
|
665
|
+
monitoring: {
|
|
666
|
+
color: "rgba(15,15,20,0.55)",
|
|
667
|
+
blur: "4px",
|
|
668
|
+
range: [0, 1],
|
|
669
|
+
interactive: false
|
|
670
|
+
},
|
|
671
|
+
cinematic: {
|
|
672
|
+
color: "rgba(0,0,0,0.7)",
|
|
673
|
+
blur: "12px",
|
|
674
|
+
range: [0, 1],
|
|
675
|
+
interactive: false
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
// src/core/primitives/hot-path-thresholds.ts
|
|
680
|
+
var SIZE_WRITE_EPSILON = 0.5;
|
|
681
|
+
var OPACITY_WRITE_EPSILON = 5e-3;
|
|
682
|
+
var POINTER_EVENTS_OPACITY_THRESHOLD = 0.05;
|
|
683
|
+
var RANGE_DIVISION_EPSILON = 1e-4;
|
|
684
|
+
|
|
685
|
+
// src/core/primitives/opacity-dedup.ts
|
|
686
|
+
var WriteSentinel = class {
|
|
687
|
+
constructor() {
|
|
688
|
+
__publicField(this, "last", -1);
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Returns `true` (and updates the cached value) if `next` differs from the
|
|
692
|
+
* cached `last` by more than `epsilon`. Returns `false` (and leaves the
|
|
693
|
+
* cache untouched) when the delta is within epsilon — caller skips the
|
|
694
|
+
* CSSOM write.
|
|
695
|
+
*/
|
|
696
|
+
shouldWrite(next, epsilon) {
|
|
697
|
+
if (Math.abs(next - this.last) <= epsilon) return false;
|
|
698
|
+
this.last = next;
|
|
699
|
+
return true;
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* Force the next `shouldWrite` to pass. Resets the cache to `-1` so any
|
|
703
|
+
* non-negative `next` exceeds the epsilon comparison. Called from setters
|
|
704
|
+
* that mutate state under the cache (range/mode/enabled flips that change
|
|
705
|
+
* the progress→opacity mapping but don't move `progress` itself).
|
|
706
|
+
*/
|
|
707
|
+
invalidate() {
|
|
708
|
+
this.last = -1;
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Record `value` as the last-written without performing a write. Used
|
|
712
|
+
* when a setter writes a literal value through a different path (e.g.
|
|
713
|
+
* `setScrimMode("off")` writes `opacity: "0"` inline) and wants future
|
|
714
|
+
* `shouldWrite` calls to short-circuit against that known cached state.
|
|
715
|
+
*/
|
|
716
|
+
setLastWritten(value) {
|
|
717
|
+
this.last = value;
|
|
718
|
+
}
|
|
719
|
+
/** Read-only accessor for diagnostic / dedup-aware peek. */
|
|
720
|
+
get value() {
|
|
721
|
+
return this.last;
|
|
722
|
+
}
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
// src/core/controllers/scrim-controller.ts
|
|
726
|
+
var ScrimController = class {
|
|
727
|
+
constructor(deps, opts) {
|
|
728
|
+
// ── DOM refs (mirror engine's; cached for hot path) ─────────────────
|
|
729
|
+
__publicField(this, "screenComponent");
|
|
730
|
+
__publicField(this, "backdrop");
|
|
731
|
+
__publicField(this, "mode");
|
|
732
|
+
// ── Engine callbacks ────────────────────────────────────────────────
|
|
733
|
+
__publicField(this, "isDestroyed");
|
|
734
|
+
__publicField(this, "isTopSheet");
|
|
735
|
+
__publicField(this, "getAllowedIds");
|
|
736
|
+
__publicField(this, "getResolvedSnaps");
|
|
737
|
+
__publicField(this, "snapToFn");
|
|
738
|
+
// ── Mode + state ────────────────────────────────────────────────────
|
|
739
|
+
__publicField(this, "scrimMode");
|
|
740
|
+
__publicField(this, "backdropRange");
|
|
741
|
+
__publicField(this, "screenRange");
|
|
742
|
+
// setScrimEnabled toggle — gated through `scrimEnabled` rather than nuking
|
|
743
|
+
// ranges so re-enable can restore prior values without a remount. Public
|
|
744
|
+
// so tests / introspection can read it without an `as any` cast.
|
|
745
|
+
__publicField(this, "scrimEnabled", true);
|
|
746
|
+
__publicField(this, "savedScrimRanges", null);
|
|
747
|
+
// ── Tap-to-close ────────────────────────────────────────────────────
|
|
748
|
+
__publicField(this, "scrimTapToCloseEnabled", false);
|
|
749
|
+
__publicField(this, "detachScrimTap", null);
|
|
750
|
+
// ── Scrim overlay slot (positioned floating element) ────────────────
|
|
751
|
+
__publicField(this, "scrimOverlayTeardown", null);
|
|
752
|
+
// ── Hot-path opacity dedup caches (per-frame writes) ────────────────
|
|
753
|
+
// Numeric sentinels migrated to WriteSentinel primitive — single
|
|
754
|
+
// invalidation surface, shared with the engine's size/progress sentinels.
|
|
755
|
+
// The two string-typed sentinels (`Pointer`, `Display`) keep their inline
|
|
756
|
+
// pattern: equality on string union, no epsilon, different reset semantics.
|
|
757
|
+
__publicField(this, "backdropOpacitySentinel", new WriteSentinel());
|
|
758
|
+
__publicField(this, "screenOpacitySentinel", new WriteSentinel());
|
|
759
|
+
__publicField(this, "lastBackdropPointer", "");
|
|
760
|
+
__publicField(this, "lastScreenDisplay", "");
|
|
761
|
+
this.screenComponent = deps.screenComponent;
|
|
762
|
+
this.backdrop = deps.backdrop;
|
|
763
|
+
this.mode = deps.mode;
|
|
764
|
+
this.isDestroyed = deps.isDestroyed;
|
|
765
|
+
this.isTopSheet = deps.isTopSheet;
|
|
766
|
+
this.getAllowedIds = deps.getAllowedIds;
|
|
767
|
+
this.getResolvedSnaps = deps.getResolvedSnaps;
|
|
768
|
+
this.snapToFn = deps.snapTo;
|
|
769
|
+
this.scrimMode = opts.scrimMode ?? "full";
|
|
770
|
+
const preset = opts.scrimPreset ? SCRIM_PRESETS[opts.scrimPreset] : null;
|
|
771
|
+
this.backdropRange = opts.backdropRange ?? preset?.range ?? [0, 1];
|
|
772
|
+
this.screenRange = opts.screenRange ?? preset?.range ?? [0, 1];
|
|
773
|
+
this.scrimTapToCloseEnabled = opts.scrimTapToClose ?? false;
|
|
774
|
+
if (this.screenComponent) {
|
|
775
|
+
this.applyScrimStyles({
|
|
776
|
+
color: opts.scrimColor ?? preset?.color,
|
|
777
|
+
blur: opts.scrimBlur ?? preset?.blur,
|
|
778
|
+
interactive: opts.scrimInteractive ?? preset?.interactive ?? false
|
|
779
|
+
});
|
|
780
|
+
if (this.scrimMode === "above-sheet") {
|
|
781
|
+
this.applyAboveSheetInset();
|
|
782
|
+
} else if (this.scrimMode === "off") {
|
|
783
|
+
const s = this.screenComponent.style;
|
|
784
|
+
s.opacity = "0";
|
|
785
|
+
s.display = "none";
|
|
786
|
+
this.screenOpacitySentinel.setLastWritten(0);
|
|
787
|
+
this.lastScreenDisplay = "none";
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
/**
|
|
792
|
+
* Invalidate the opacity dedup caches. Called from any setter that mutates
|
|
793
|
+
* range/mode/enabled — opacity is a pure function of (progress, range,
|
|
794
|
+
* enabled), so a config change makes the cached values stale even if
|
|
795
|
+
* `progress` itself didn't move. Next `applyOpacity(progress, false)` call
|
|
796
|
+
* sees the -1 sentinel and re-runs the math instead of short-circuiting.
|
|
797
|
+
*
|
|
798
|
+
* Public for the engine: `setSnapPoints` mutates resolved geometry which
|
|
799
|
+
* shifts `progress→opacity` mapping under the cache; engine calls this to
|
|
800
|
+
* keep the next render frame correct on a static sheet.
|
|
801
|
+
* @internal
|
|
802
|
+
*/
|
|
803
|
+
invalidateOpacityCache() {
|
|
804
|
+
this.backdropOpacitySentinel.invalidate();
|
|
805
|
+
this.screenOpacitySentinel.invalidate();
|
|
806
|
+
}
|
|
807
|
+
/** Engine calls this when its `attach()` runs — installs scrim-tap-click if requested. */
|
|
808
|
+
attach() {
|
|
809
|
+
if (this.scrimTapToCloseEnabled) {
|
|
810
|
+
this.setScrimTapToClose(true);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
// ── Hot-path: opacity computation called per-frame from engine.applySize ──
|
|
814
|
+
/**
|
|
815
|
+
* Per-frame opacity writer. Computes backdrop + screen opacity from the
|
|
816
|
+
* sheet's progress (0..1 within allowed range), gated by sub-pixel dedup
|
|
817
|
+
* caches so steady-state frames don't trigger style recalc.
|
|
818
|
+
*
|
|
819
|
+
* `progressChanged` is the engine's already-computed dedup gate — when
|
|
820
|
+
* `false`, opacity is by construction a pure function of `progress` so
|
|
821
|
+
* cached values are still valid; we short-circuit the Math.min/max +
|
|
822
|
+
* division work entirely. Setters that mutate ranges/mode/enabled
|
|
823
|
+
* invalidate the cache by writing -1 sentinels so the next call re-runs
|
|
824
|
+
* the math.
|
|
825
|
+
*/
|
|
826
|
+
applyOpacity(progress, progressChanged) {
|
|
827
|
+
if (!progressChanged && this.backdropOpacitySentinel.value !== -1 && this.screenOpacitySentinel.value !== -1) {
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
if (this.backdrop && this.isTopSheet()) {
|
|
831
|
+
const [s, e] = this.backdropRange;
|
|
832
|
+
const range = Math.max(e - s, RANGE_DIVISION_EPSILON);
|
|
833
|
+
const backdropOpacity = Math.min(
|
|
834
|
+
Math.max((progress - s) / range, 0),
|
|
835
|
+
1
|
|
836
|
+
);
|
|
837
|
+
if (this.backdropOpacitySentinel.shouldWrite(
|
|
838
|
+
backdropOpacity,
|
|
839
|
+
OPACITY_WRITE_EPSILON
|
|
840
|
+
)) {
|
|
841
|
+
this.backdrop.style.opacity = String(backdropOpacity);
|
|
842
|
+
}
|
|
843
|
+
const nextPointer = backdropOpacity > POINTER_EVENTS_OPACITY_THRESHOLD ? "auto" : "none";
|
|
844
|
+
if (nextPointer !== this.lastBackdropPointer) {
|
|
845
|
+
this.backdrop.style.pointerEvents = nextPointer;
|
|
846
|
+
this.lastBackdropPointer = nextPointer;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
if (this.screenComponent && this.scrimMode !== "off") {
|
|
850
|
+
const [ss, se] = this.screenRange;
|
|
851
|
+
const screenOpacity = Math.min(
|
|
852
|
+
1,
|
|
853
|
+
Math.max(
|
|
854
|
+
0,
|
|
855
|
+
(progress - ss) / Math.max(RANGE_DIVISION_EPSILON, se - ss)
|
|
856
|
+
)
|
|
857
|
+
);
|
|
858
|
+
if (this.screenOpacitySentinel.shouldWrite(
|
|
859
|
+
screenOpacity,
|
|
860
|
+
OPACITY_WRITE_EPSILON
|
|
861
|
+
)) {
|
|
862
|
+
this.screenComponent.style.opacity = String(screenOpacity);
|
|
863
|
+
}
|
|
864
|
+
const nextDisplay = screenOpacity > 0 ? "" : "none";
|
|
865
|
+
if (nextDisplay !== this.lastScreenDisplay) {
|
|
866
|
+
this.screenComponent.style.display = nextDisplay;
|
|
867
|
+
this.lastScreenDisplay = nextDisplay;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
// ── Public setters (engine forwards these 1:1) ─────────────────────
|
|
872
|
+
/**
|
|
873
|
+
* Replace the [start, end] progress window in which the backdrop fades 0→1.
|
|
874
|
+
* @param range - Tuple of two progress values in 0..1 (start <= end).
|
|
875
|
+
* @param applySize - Engine-supplied recompute hook; runs one applyOpacity pass.
|
|
876
|
+
* @example
|
|
877
|
+
* // Backdrop only starts darkening after the sheet crosses 30% open.
|
|
878
|
+
* engine.setBackdropRange([0.3, 1]);
|
|
879
|
+
*/
|
|
880
|
+
setBackdropRange(range, applySize) {
|
|
881
|
+
if (this.isDestroyed()) return;
|
|
882
|
+
this.backdropRange = range;
|
|
883
|
+
this.invalidateOpacityCache();
|
|
884
|
+
applySize();
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* Replace the [start, end] progress window in which the screen scrim fades 0→1.
|
|
888
|
+
* @param range - Tuple of two progress values in 0..1 (start <= end).
|
|
889
|
+
* @param applySize - Engine-supplied recompute hook; runs one applyOpacity pass.
|
|
890
|
+
* @example
|
|
891
|
+
* // Scrim is fully opaque only when the sheet is past 50% — softer feel.
|
|
892
|
+
* engine.setScreenRange([0, 0.5]);
|
|
893
|
+
*/
|
|
894
|
+
setScreenRange(range, applySize) {
|
|
895
|
+
if (this.isDestroyed()) return;
|
|
896
|
+
this.screenRange = range;
|
|
897
|
+
this.invalidateOpacityCache();
|
|
898
|
+
applySize();
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* Set the scrim background color. Pass `null`/`undefined` to clear the
|
|
902
|
+
* inline write and let consumer CSS take over.
|
|
903
|
+
* @param color - Any valid CSS color string, or `null`/`undefined` to clear.
|
|
904
|
+
* @example
|
|
905
|
+
* engine.setScrimColor("rgba(0, 0, 0, 0.6)");
|
|
906
|
+
* engine.setScrimColor(null); // hand back to consumer CSS
|
|
907
|
+
*/
|
|
908
|
+
setScrimColor(color) {
|
|
909
|
+
if (this.isDestroyed()) return;
|
|
910
|
+
this.applyScrimStyles({ color: color === void 0 ? null : color });
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* Set the scrim backdrop-filter blur radius. Pass `null`/`undefined` to clear.
|
|
914
|
+
* @param blur - CSS length (e.g. `"8px"`) consumed as `blur(<value>)`, or
|
|
915
|
+
* `null`/`undefined` to remove the filter.
|
|
916
|
+
* @example
|
|
917
|
+
* engine.setScrimBlur("12px"); // frosted-glass effect
|
|
918
|
+
* engine.setScrimBlur(null); // disable blur
|
|
919
|
+
*/
|
|
920
|
+
setScrimBlur(blur) {
|
|
921
|
+
if (this.isDestroyed()) return;
|
|
922
|
+
this.applyScrimStyles({ blur: blur === void 0 ? null : blur });
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* Toggle whether the scrim layer captures pointer events.
|
|
926
|
+
* @param interactive - `true` → `pointer-events: auto`; `false` → `none`.
|
|
927
|
+
* @example
|
|
928
|
+
* engine.setScrimInteractive(true); // scrim swallows clicks
|
|
929
|
+
* engine.setScrimInteractive(false); // clicks pass through to content below
|
|
930
|
+
*/
|
|
931
|
+
setScrimInteractive(interactive) {
|
|
932
|
+
if (this.isDestroyed()) return;
|
|
933
|
+
this.applyScrimStyles({ interactive });
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* Switch scrim positioning at runtime.
|
|
937
|
+
* @param mode - `"full"` (consumer CSS), `"above-sheet"` (engine-pinned inset
|
|
938
|
+
* so only the area NOT covered by the sheet is dimmed), or
|
|
939
|
+
* `"off"` (display:none + opacity:0 — no remount needed).
|
|
940
|
+
* @param applySize - Engine-supplied recompute hook; runs one applyOpacity pass.
|
|
941
|
+
* @example
|
|
942
|
+
* engine.setScrimMode("above-sheet"); // dim only the area above the sheet
|
|
943
|
+
* engine.setScrimMode("off"); // disable scrim layer
|
|
944
|
+
*/
|
|
945
|
+
setScrimMode(mode, applySize) {
|
|
946
|
+
if (this.isDestroyed()) return;
|
|
947
|
+
if (mode === this.scrimMode) return;
|
|
948
|
+
this.invalidateOpacityCache();
|
|
949
|
+
const previous = this.scrimMode;
|
|
950
|
+
this.scrimMode = mode;
|
|
951
|
+
if (this.screenComponent) {
|
|
952
|
+
const s = this.screenComponent.style;
|
|
953
|
+
if (previous === "above-sheet" && mode !== "above-sheet") {
|
|
954
|
+
s.position = "";
|
|
955
|
+
s.inset = "";
|
|
956
|
+
s.pointerEvents = "";
|
|
957
|
+
}
|
|
958
|
+
if (mode === "above-sheet") {
|
|
959
|
+
this.applyAboveSheetInset();
|
|
960
|
+
} else if (mode === "off") {
|
|
961
|
+
s.opacity = "0";
|
|
962
|
+
s.display = "none";
|
|
963
|
+
this.screenOpacitySentinel.setLastWritten(0);
|
|
964
|
+
this.lastScreenDisplay = "none";
|
|
965
|
+
} else {
|
|
966
|
+
if (this.lastScreenDisplay === "none") {
|
|
967
|
+
s.display = "";
|
|
968
|
+
this.lastScreenDisplay = "";
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
applySize();
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* Enable or disable click-on-scrim → snap to first non-zero allowed snap.
|
|
976
|
+
* Auto-promotes `pointer-events: auto` when scrim is non-interactive so the
|
|
977
|
+
* click listener actually fires.
|
|
978
|
+
* @param enabled - `true` installs the click listener; `false` removes it.
|
|
979
|
+
* @example
|
|
980
|
+
* engine.setScrimTapToClose(true); // tap outside sheet collapses it
|
|
981
|
+
* engine.setScrimTapToClose(false); // require explicit close button
|
|
982
|
+
*/
|
|
983
|
+
setScrimTapToClose(enabled) {
|
|
984
|
+
if (this.isDestroyed()) return;
|
|
985
|
+
if (!this.screenComponent) return;
|
|
986
|
+
const installed = this.detachScrimTap !== null;
|
|
987
|
+
if (enabled === installed) return;
|
|
988
|
+
if (enabled) {
|
|
989
|
+
const target = this.screenComponent;
|
|
990
|
+
if (target.style.pointerEvents === "none") {
|
|
991
|
+
target.style.pointerEvents = "auto";
|
|
992
|
+
}
|
|
993
|
+
const onClick = () => {
|
|
994
|
+
if (this.isDestroyed()) return;
|
|
995
|
+
const allowed = this.getAllowedIds();
|
|
996
|
+
const snaps = this.getResolvedSnaps();
|
|
997
|
+
const fallback = allowed.find((id) => {
|
|
998
|
+
const snap = findById(id, snaps);
|
|
999
|
+
return snap !== null && snap.size > 0;
|
|
1000
|
+
});
|
|
1001
|
+
if (fallback) this.snapToFn(fallback);
|
|
1002
|
+
};
|
|
1003
|
+
target.addEventListener("click", onClick);
|
|
1004
|
+
this.detachScrimTap = () => target.removeEventListener("click", onClick);
|
|
1005
|
+
this.scrimTapToCloseEnabled = true;
|
|
1006
|
+
} else {
|
|
1007
|
+
this.detachScrimTap?.();
|
|
1008
|
+
this.detachScrimTap = null;
|
|
1009
|
+
this.scrimTapToCloseEnabled = false;
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
/**
|
|
1013
|
+
* Toggle the scrim layer without remounting. Disabling stashes current
|
|
1014
|
+
* `screenRange`/`backdropRange` and degenerates them to `[1, 1]` (opacity
|
|
1015
|
+
* formula → 0 across the whole range); re-enabling restores the saved values.
|
|
1016
|
+
* @param enabled - `false` hides scrim, `true` restores prior ranges.
|
|
1017
|
+
* @param applySize - Engine-supplied recompute hook; runs one applyOpacity pass.
|
|
1018
|
+
* @example
|
|
1019
|
+
* engine.setScrimEnabled(false); // hide scrim, ranges remembered
|
|
1020
|
+
* engine.setScrimEnabled(true); // restore prior ranges (no remount)
|
|
1021
|
+
*/
|
|
1022
|
+
setScrimEnabled(enabled, applySize) {
|
|
1023
|
+
if (this.isDestroyed()) return;
|
|
1024
|
+
if (enabled === this.scrimEnabled) return;
|
|
1025
|
+
this.scrimEnabled = enabled;
|
|
1026
|
+
if (!enabled) {
|
|
1027
|
+
this.savedScrimRanges = {
|
|
1028
|
+
screen: this.screenRange,
|
|
1029
|
+
backdrop: this.backdropRange
|
|
1030
|
+
};
|
|
1031
|
+
this.screenRange = [1, 1];
|
|
1032
|
+
this.backdropRange = [1, 1];
|
|
1033
|
+
} else {
|
|
1034
|
+
this.screenRange = this.savedScrimRanges?.screen ?? [0, 1];
|
|
1035
|
+
this.backdropRange = this.savedScrimRanges?.backdrop ?? [0, 1];
|
|
1036
|
+
this.savedScrimRanges = null;
|
|
1037
|
+
}
|
|
1038
|
+
applySize();
|
|
1039
|
+
}
|
|
1040
|
+
/**
|
|
1041
|
+
* Batch-update multiple scrim fields with a single applySize() at the end.
|
|
1042
|
+
* If `preset` is set, it seeds color/blur/interactive/range first; explicit
|
|
1043
|
+
* fields override.
|
|
1044
|
+
* @param opts - Partial scrim config: preset, mode, enabled, tapToClose,
|
|
1045
|
+
* color, blur, interactive, range.
|
|
1046
|
+
* @param applySize - Engine-supplied recompute hook; runs ONCE per call.
|
|
1047
|
+
* @example
|
|
1048
|
+
* engine.setScrim({
|
|
1049
|
+
* preset: "frosted",
|
|
1050
|
+
* mode: "above-sheet",
|
|
1051
|
+
* tapToClose: true,
|
|
1052
|
+
* });
|
|
1053
|
+
*/
|
|
1054
|
+
setScrim(opts, applySize) {
|
|
1055
|
+
if (this.isDestroyed()) return;
|
|
1056
|
+
if (opts.preset) {
|
|
1057
|
+
const cfg = SCRIM_PRESETS[opts.preset];
|
|
1058
|
+
this.applyScrimStyles({
|
|
1059
|
+
color: cfg.color,
|
|
1060
|
+
blur: cfg.blur ?? null,
|
|
1061
|
+
interactive: cfg.interactive
|
|
1062
|
+
});
|
|
1063
|
+
this.screenRange = cfg.range;
|
|
1064
|
+
}
|
|
1065
|
+
const noop = () => {
|
|
1066
|
+
};
|
|
1067
|
+
if (opts.mode !== void 0) this.setScrimMode(opts.mode, noop);
|
|
1068
|
+
if (opts.enabled !== void 0) this.setScrimEnabled(opts.enabled, noop);
|
|
1069
|
+
if (opts.tapToClose !== void 0) this.setScrimTapToClose(opts.tapToClose);
|
|
1070
|
+
this.applyScrimStyles({
|
|
1071
|
+
color: opts.color,
|
|
1072
|
+
blur: opts.blur,
|
|
1073
|
+
interactive: opts.interactive
|
|
1074
|
+
});
|
|
1075
|
+
if (opts.range !== void 0) {
|
|
1076
|
+
this.screenRange = opts.range;
|
|
1077
|
+
}
|
|
1078
|
+
this.invalidateOpacityCache();
|
|
1079
|
+
applySize();
|
|
1080
|
+
}
|
|
1081
|
+
/**
|
|
1082
|
+
* Inject a positioned floating element into the scrim layer (e.g. a close
|
|
1083
|
+
* button anchored top-right). Replaces any prior overlay so repeated calls
|
|
1084
|
+
* (reactive effects re-running) don't accumulate wrappers.
|
|
1085
|
+
* @param opts - Children element, optional `inset` (default `"16px"`),
|
|
1086
|
+
* `position` (9-way grid, default `"top-right"`), and
|
|
1087
|
+
* `interactive` flag (default `true`).
|
|
1088
|
+
* @returns Teardown function that removes the wrapper from the DOM.
|
|
1089
|
+
* @example
|
|
1090
|
+
* const teardown = engine.setScrimOverlay({
|
|
1091
|
+
* children: closeBtn,
|
|
1092
|
+
* position: "top-right",
|
|
1093
|
+
* inset: "20px",
|
|
1094
|
+
* });
|
|
1095
|
+
* // later: teardown(); // remove the floating button
|
|
1096
|
+
*/
|
|
1097
|
+
setScrimOverlay(opts) {
|
|
1098
|
+
if (this.isDestroyed()) return () => {
|
|
1099
|
+
};
|
|
1100
|
+
if (!this.screenComponent) return () => {
|
|
1101
|
+
};
|
|
1102
|
+
this.scrimOverlayTeardown?.();
|
|
1103
|
+
const screen = this.screenComponent;
|
|
1104
|
+
const inset = opts.inset ?? "16px";
|
|
1105
|
+
const position = opts.position ?? "top-right";
|
|
1106
|
+
const interactive = opts.interactive ?? true;
|
|
1107
|
+
const wrapper = screen.ownerDocument.createElement("div");
|
|
1108
|
+
wrapper.className = "bs-scrim-overlay";
|
|
1109
|
+
const ws = wrapper.style;
|
|
1110
|
+
ws.position = "absolute";
|
|
1111
|
+
switch (position) {
|
|
1112
|
+
case "top-left":
|
|
1113
|
+
ws.top = inset;
|
|
1114
|
+
ws.left = inset;
|
|
1115
|
+
break;
|
|
1116
|
+
case "top-center":
|
|
1117
|
+
ws.top = inset;
|
|
1118
|
+
ws.left = "50%";
|
|
1119
|
+
ws.transform = "translateX(-50%)";
|
|
1120
|
+
break;
|
|
1121
|
+
case "top-right":
|
|
1122
|
+
ws.top = inset;
|
|
1123
|
+
ws.right = inset;
|
|
1124
|
+
break;
|
|
1125
|
+
case "center-left":
|
|
1126
|
+
ws.top = "50%";
|
|
1127
|
+
ws.left = inset;
|
|
1128
|
+
ws.transform = "translateY(-50%)";
|
|
1129
|
+
break;
|
|
1130
|
+
case "center":
|
|
1131
|
+
ws.top = "50%";
|
|
1132
|
+
ws.left = "50%";
|
|
1133
|
+
ws.transform = "translate(-50%, -50%)";
|
|
1134
|
+
break;
|
|
1135
|
+
case "center-right":
|
|
1136
|
+
ws.top = "50%";
|
|
1137
|
+
ws.right = inset;
|
|
1138
|
+
ws.transform = "translateY(-50%)";
|
|
1139
|
+
break;
|
|
1140
|
+
case "bottom-left":
|
|
1141
|
+
ws.bottom = inset;
|
|
1142
|
+
ws.left = inset;
|
|
1143
|
+
break;
|
|
1144
|
+
case "bottom-center":
|
|
1145
|
+
ws.bottom = inset;
|
|
1146
|
+
ws.left = "50%";
|
|
1147
|
+
ws.transform = "translateX(-50%)";
|
|
1148
|
+
break;
|
|
1149
|
+
case "bottom-right":
|
|
1150
|
+
ws.bottom = inset;
|
|
1151
|
+
ws.right = inset;
|
|
1152
|
+
break;
|
|
1153
|
+
}
|
|
1154
|
+
if (interactive) {
|
|
1155
|
+
ws.pointerEvents = "auto";
|
|
1156
|
+
}
|
|
1157
|
+
wrapper.appendChild(opts.children);
|
|
1158
|
+
screen.appendChild(wrapper);
|
|
1159
|
+
const teardown = () => {
|
|
1160
|
+
if (wrapper.parentNode) wrapper.parentNode.removeChild(wrapper);
|
|
1161
|
+
if (this.scrimOverlayTeardown === teardown) {
|
|
1162
|
+
this.scrimOverlayTeardown = null;
|
|
1163
|
+
}
|
|
1164
|
+
};
|
|
1165
|
+
this.scrimOverlayTeardown = teardown;
|
|
1166
|
+
return teardown;
|
|
1167
|
+
}
|
|
1168
|
+
/** Tear down toggle-state listeners + remove any injected overlay wrapper. */
|
|
1169
|
+
destroy() {
|
|
1170
|
+
this.detachScrimTap?.();
|
|
1171
|
+
this.detachScrimTap = null;
|
|
1172
|
+
this.scrimOverlayTeardown?.();
|
|
1173
|
+
this.scrimOverlayTeardown = null;
|
|
1174
|
+
if (this.screenComponent) {
|
|
1175
|
+
const s = this.screenComponent.style;
|
|
1176
|
+
s.opacity = "";
|
|
1177
|
+
s.display = "";
|
|
1178
|
+
}
|
|
1179
|
+
if (this.backdrop) {
|
|
1180
|
+
this.backdrop.style.opacity = "";
|
|
1181
|
+
this.backdrop.style.pointerEvents = "";
|
|
1182
|
+
}
|
|
1183
|
+
this.invalidateOpacityCache();
|
|
1184
|
+
this.lastBackdropPointer = "";
|
|
1185
|
+
this.lastScreenDisplay = "";
|
|
1186
|
+
}
|
|
1187
|
+
// ── Private helpers ────────────────────────────────────────────────
|
|
1188
|
+
/** Centralized scrim style writer — atomic 3-property write across all setters. */
|
|
1189
|
+
applyScrimStyles(opts) {
|
|
1190
|
+
if (!this.screenComponent) return;
|
|
1191
|
+
const s = this.screenComponent.style;
|
|
1192
|
+
if (opts.color !== void 0) {
|
|
1193
|
+
s.background = opts.color === null ? "" : opts.color;
|
|
1194
|
+
}
|
|
1195
|
+
if (opts.blur !== void 0) {
|
|
1196
|
+
const w = s;
|
|
1197
|
+
if (opts.blur === null) {
|
|
1198
|
+
s.backdropFilter = "";
|
|
1199
|
+
w.webkitBackdropFilter = "";
|
|
1200
|
+
} else {
|
|
1201
|
+
const filter = `blur(${opts.blur})`;
|
|
1202
|
+
s.backdropFilter = filter;
|
|
1203
|
+
w.webkitBackdropFilter = filter;
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
if (opts.interactive !== void 0) {
|
|
1207
|
+
s.pointerEvents = opts.interactive ? "auto" : "none";
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
/** Pin the screen overlay to the area NOT occupied by the sheet. */
|
|
1211
|
+
applyAboveSheetInset() {
|
|
1212
|
+
if (!this.screenComponent) return;
|
|
1213
|
+
const insetByMode = {
|
|
1214
|
+
bottom: "0 0 var(--bs-size) 0",
|
|
1215
|
+
top: "var(--bs-size) 0 0 0",
|
|
1216
|
+
left: "0 0 0 var(--bs-size)",
|
|
1217
|
+
right: "0 var(--bs-size) 0 0"
|
|
1218
|
+
};
|
|
1219
|
+
const s = this.screenComponent.style;
|
|
1220
|
+
s.position = "fixed";
|
|
1221
|
+
s.inset = insetByMode[this.mode];
|
|
1222
|
+
s.pointerEvents = "none";
|
|
1223
|
+
}
|
|
1224
|
+
};
|
|
1225
|
+
|
|
1226
|
+
// src/core/animation/animation.ts
|
|
1227
|
+
var easeOutBack = (t) => {
|
|
1228
|
+
const c1 = 1.40158;
|
|
1229
|
+
const c3 = c1 + 1;
|
|
1230
|
+
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
|
|
1231
|
+
};
|
|
1232
|
+
var easeOutCubic = (t) => 1 - Math.pow(1 - t, 3);
|
|
1233
|
+
var easeLinear = (t) => t;
|
|
1234
|
+
var easeOutQuint = (t) => 1 - Math.pow(1 - t, 5);
|
|
1235
|
+
var tween = ({
|
|
1236
|
+
from,
|
|
1237
|
+
to,
|
|
1238
|
+
duration,
|
|
1239
|
+
easing = easeOutCubic,
|
|
1240
|
+
onUpdate
|
|
1241
|
+
}) => {
|
|
1242
|
+
let cancelled = false;
|
|
1243
|
+
let rafId = 0;
|
|
1244
|
+
let resolveFn = null;
|
|
1245
|
+
const start = performance.now();
|
|
1246
|
+
const promise = new Promise((resolve) => {
|
|
1247
|
+
resolveFn = resolve;
|
|
1248
|
+
if (duration <= 0) {
|
|
1249
|
+
onUpdate(to);
|
|
1250
|
+
resolve();
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1253
|
+
const step = (now) => {
|
|
1254
|
+
if (cancelled) return;
|
|
1255
|
+
const elapsed = now - start;
|
|
1256
|
+
const t = Math.min(elapsed / duration, 1);
|
|
1257
|
+
const value = from + (to - from) * easing(t);
|
|
1258
|
+
onUpdate(value);
|
|
1259
|
+
if (t < 1) {
|
|
1260
|
+
rafId = requestAnimationFrame(step);
|
|
1261
|
+
} else {
|
|
1262
|
+
resolve();
|
|
1263
|
+
}
|
|
1264
|
+
};
|
|
1265
|
+
rafId = requestAnimationFrame(step);
|
|
1266
|
+
});
|
|
1267
|
+
return {
|
|
1268
|
+
cancel: () => {
|
|
1269
|
+
cancelled = true;
|
|
1270
|
+
cancelAnimationFrame(rafId);
|
|
1271
|
+
resolveFn?.();
|
|
1272
|
+
},
|
|
1273
|
+
promise
|
|
1274
|
+
};
|
|
1275
|
+
};
|
|
1276
|
+
|
|
1277
|
+
// src/core/animation/spring.ts
|
|
1278
|
+
var DEFAULT_SPRING = {
|
|
1279
|
+
stiffness: 220,
|
|
1280
|
+
damping: 26,
|
|
1281
|
+
mass: 1,
|
|
1282
|
+
restDelta: 0.5,
|
|
1283
|
+
restSpeed: 0.5
|
|
1284
|
+
};
|
|
1285
|
+
var MAX_DT = 1 / 30;
|
|
1286
|
+
var runSpring = ({
|
|
1287
|
+
from,
|
|
1288
|
+
to,
|
|
1289
|
+
velocity = 0,
|
|
1290
|
+
config,
|
|
1291
|
+
onUpdate
|
|
1292
|
+
}) => {
|
|
1293
|
+
const cfg = { ...DEFAULT_SPRING, ...config };
|
|
1294
|
+
let cancelled = false;
|
|
1295
|
+
let rafId = 0;
|
|
1296
|
+
let resolveFn = null;
|
|
1297
|
+
let x = from;
|
|
1298
|
+
let v = velocity;
|
|
1299
|
+
let lastTime = performance.now();
|
|
1300
|
+
const promise = new Promise((resolve) => {
|
|
1301
|
+
resolveFn = resolve;
|
|
1302
|
+
const step = (now) => {
|
|
1303
|
+
if (cancelled) return;
|
|
1304
|
+
const dt = Math.min((now - lastTime) / 1e3, MAX_DT);
|
|
1305
|
+
lastTime = now;
|
|
1306
|
+
const subDt = Math.min(dt, 1 / 240);
|
|
1307
|
+
let stepsLeft = Math.max(1, Math.ceil(dt / subDt));
|
|
1308
|
+
while (stepsLeft-- > 0) {
|
|
1309
|
+
const force = -cfg.stiffness * (x - to) - cfg.damping * v;
|
|
1310
|
+
const accel = force / cfg.mass;
|
|
1311
|
+
v += accel * subDt;
|
|
1312
|
+
x += v * subDt;
|
|
1313
|
+
}
|
|
1314
|
+
onUpdate(x, v);
|
|
1315
|
+
const isAtRest = Math.abs(to - x) < (cfg.restDelta ?? 0.5) && Math.abs(v) < (cfg.restSpeed ?? 0.5);
|
|
1316
|
+
if (isAtRest) {
|
|
1317
|
+
onUpdate(to, 0);
|
|
1318
|
+
resolve();
|
|
1319
|
+
} else {
|
|
1320
|
+
rafId = requestAnimationFrame(step);
|
|
1321
|
+
}
|
|
1322
|
+
};
|
|
1323
|
+
rafId = requestAnimationFrame(step);
|
|
1324
|
+
});
|
|
1325
|
+
return {
|
|
1326
|
+
cancel: () => {
|
|
1327
|
+
cancelled = true;
|
|
1328
|
+
cancelAnimationFrame(rafId);
|
|
1329
|
+
resolveFn?.();
|
|
1330
|
+
},
|
|
1331
|
+
promise
|
|
1332
|
+
};
|
|
1333
|
+
};
|
|
1334
|
+
|
|
1335
|
+
// src/core/animation/animation-presets.ts
|
|
1336
|
+
var DEFAULT_DURATION = 220;
|
|
1337
|
+
function resolveAnimationPreset(animation) {
|
|
1338
|
+
switch (animation) {
|
|
1339
|
+
case "ios-spring":
|
|
1340
|
+
return {
|
|
1341
|
+
kind: "spring",
|
|
1342
|
+
spring: { stiffness: 300, damping: 30, mass: 1 }
|
|
1343
|
+
};
|
|
1344
|
+
case "material-bounce":
|
|
1345
|
+
return {
|
|
1346
|
+
kind: "spring",
|
|
1347
|
+
spring: { stiffness: 200, damping: 22, mass: 1 }
|
|
1348
|
+
};
|
|
1349
|
+
case "linear":
|
|
1350
|
+
return { kind: "tween", duration: DEFAULT_DURATION, easing: easeLinear };
|
|
1351
|
+
case "snappy":
|
|
1352
|
+
return { kind: "tween", duration: 180, easing: easeOutQuint };
|
|
1353
|
+
case "tween":
|
|
1354
|
+
return { kind: "tween" };
|
|
1355
|
+
case "spring":
|
|
1356
|
+
case void 0:
|
|
1357
|
+
default:
|
|
1358
|
+
return { kind: "spring" };
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
// src/core/controllers/animation-runner.ts
|
|
1363
|
+
var DEFAULT_DURATION2 = 220;
|
|
1364
|
+
var VELOCITY_PX_PER_S = 1e3;
|
|
1365
|
+
var AnimationRunner = class {
|
|
1366
|
+
constructor(deps, opts) {
|
|
1367
|
+
__publicField(this, "element");
|
|
1368
|
+
__publicField(this, "getRootEl");
|
|
1369
|
+
__publicField(this, "applySizeFn");
|
|
1370
|
+
__publicField(this, "getSize");
|
|
1371
|
+
__publicField(this, "isDragging");
|
|
1372
|
+
__publicField(this, "animationKind");
|
|
1373
|
+
__publicField(this, "springConfig");
|
|
1374
|
+
__publicField(this, "duration");
|
|
1375
|
+
__publicField(this, "easing");
|
|
1376
|
+
__publicField(this, "respectReducedMotion");
|
|
1377
|
+
/** Live-tracked via mq listener so animateTo skips matchMedia per snap. */
|
|
1378
|
+
__publicField(this, "reducedMotion", false);
|
|
1379
|
+
/** Disposes the matchMedia change listener — drained from destroy(). */
|
|
1380
|
+
__publicField(this, "detachReducedMotion", null);
|
|
1381
|
+
/** Computed once — both inputs (option + API presence) stable for life. */
|
|
1382
|
+
__publicField(this, "viewTransitionsAvailable");
|
|
1383
|
+
/** In-flight handles. Public for tests / debugging only — engine drives via cancel(). */
|
|
1384
|
+
__publicField(this, "currentTween", null);
|
|
1385
|
+
__publicField(this, "currentSpring", null);
|
|
1386
|
+
this.element = deps.element;
|
|
1387
|
+
this.getRootEl = deps.getRootEl;
|
|
1388
|
+
this.applySizeFn = deps.applySize;
|
|
1389
|
+
this.getSize = deps.getSize;
|
|
1390
|
+
this.isDragging = deps.isDragging;
|
|
1391
|
+
const preset = resolveAnimationPreset(opts.animation);
|
|
1392
|
+
this.animationKind = preset.kind;
|
|
1393
|
+
this.duration = opts.duration ?? preset.duration ?? DEFAULT_DURATION2;
|
|
1394
|
+
this.easing = opts.easing ?? preset.easing ?? easeOutBack;
|
|
1395
|
+
this.springConfig = { ...preset.spring ?? {}, ...opts.spring ?? {} };
|
|
1396
|
+
this.respectReducedMotion = opts.respectReducedMotion ?? true;
|
|
1397
|
+
const viewTransitionsEnabled = opts.viewTransitions ?? false;
|
|
1398
|
+
this.viewTransitionsAvailable = viewTransitionsEnabled && typeof document !== "undefined" && typeof document.startViewTransition === "function";
|
|
1399
|
+
if (this.respectReducedMotion && typeof window !== "undefined" && typeof window.matchMedia === "function") {
|
|
1400
|
+
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
1401
|
+
this.reducedMotion = mq.matches;
|
|
1402
|
+
const onMqChange = (e) => {
|
|
1403
|
+
this.reducedMotion = e.matches;
|
|
1404
|
+
};
|
|
1405
|
+
if (typeof mq.addEventListener === "function") {
|
|
1406
|
+
mq.addEventListener("change", onMqChange);
|
|
1407
|
+
this.detachReducedMotion = () => mq.removeEventListener("change", onMqChange);
|
|
1408
|
+
} else if (typeof mq.addListener === "function") {
|
|
1409
|
+
mq.addListener(onMqChange);
|
|
1410
|
+
this.detachReducedMotion = () => mq.removeListener(onMqChange);
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
/**
|
|
1415
|
+
* Live derived flag — `true` whenever a spring/tween handle is in flight.
|
|
1416
|
+
* Reading the canonical handles (instead of mirroring into a separate
|
|
1417
|
+
* boolean) eliminates the reduced-motion early-return latent bug where
|
|
1418
|
+
* the flag stayed true forever, and removes the cross-controller
|
|
1419
|
+
* setIsAnimating callback.
|
|
1420
|
+
*/
|
|
1421
|
+
get isAnimating() {
|
|
1422
|
+
return this.currentTween !== null || this.currentSpring !== null;
|
|
1423
|
+
}
|
|
1424
|
+
/** Cancel any in-flight tween/spring without touching engine state. */
|
|
1425
|
+
cancel() {
|
|
1426
|
+
this.currentTween?.cancel();
|
|
1427
|
+
this.currentSpring?.cancel();
|
|
1428
|
+
}
|
|
1429
|
+
/**
|
|
1430
|
+
* Animate from current size to `target`. Resolves when the animation
|
|
1431
|
+
* completes; resolves early if cancelled mid-flight (caller checks an
|
|
1432
|
+
* abort signal post-await for cycle-invariant correctness).
|
|
1433
|
+
*/
|
|
1434
|
+
async animateTo(target, velocityPxPerMs) {
|
|
1435
|
+
if (target === this.getSize()) return;
|
|
1436
|
+
this.currentTween?.cancel();
|
|
1437
|
+
this.currentSpring?.cancel();
|
|
1438
|
+
this.getRootEl()?.setAttribute("data-animating", "true");
|
|
1439
|
+
this.element.style.willChange = "transform";
|
|
1440
|
+
if (this.reducedMotion) {
|
|
1441
|
+
this.applySizeFn(target);
|
|
1442
|
+
this.getRootEl()?.removeAttribute("data-animating");
|
|
1443
|
+
if (!this.isDragging()) this.element.style.willChange = "auto";
|
|
1444
|
+
return;
|
|
1445
|
+
}
|
|
1446
|
+
if (this.animationKind === "spring") {
|
|
1447
|
+
const spring = runSpring({
|
|
1448
|
+
from: this.getSize(),
|
|
1449
|
+
to: target,
|
|
1450
|
+
velocity: velocityPxPerMs * VELOCITY_PX_PER_S,
|
|
1451
|
+
config: this.springConfig,
|
|
1452
|
+
onUpdate: (v) => this.applySizeFn(v)
|
|
1453
|
+
});
|
|
1454
|
+
this.currentSpring = spring;
|
|
1455
|
+
await spring.promise;
|
|
1456
|
+
if (this.currentSpring === spring) this.currentSpring = null;
|
|
1457
|
+
} else {
|
|
1458
|
+
const tw = tween({
|
|
1459
|
+
from: this.getSize(),
|
|
1460
|
+
to: target,
|
|
1461
|
+
duration: this.duration,
|
|
1462
|
+
easing: this.easing,
|
|
1463
|
+
onUpdate: (v) => this.applySizeFn(v)
|
|
1464
|
+
});
|
|
1465
|
+
this.currentTween = tw;
|
|
1466
|
+
await tw.promise;
|
|
1467
|
+
if (this.currentTween === tw) this.currentTween = null;
|
|
1468
|
+
}
|
|
1469
|
+
if (!this.isAnimating) {
|
|
1470
|
+
this.getRootEl()?.removeAttribute("data-animating");
|
|
1471
|
+
if (!this.isDragging()) this.element.style.willChange = "auto";
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
/** Tear down the matchMedia listener. Engine calls from destroy(). */
|
|
1475
|
+
destroy() {
|
|
1476
|
+
this.detachReducedMotion?.();
|
|
1477
|
+
this.detachReducedMotion = null;
|
|
1478
|
+
this.currentTween = null;
|
|
1479
|
+
this.currentSpring = null;
|
|
1480
|
+
}
|
|
1481
|
+
};
|
|
1482
|
+
|
|
1483
|
+
// src/core/lifecycle/focusTrap.ts
|
|
1484
|
+
var FOCUSABLE = [
|
|
1485
|
+
"a[href]",
|
|
1486
|
+
"area[href]",
|
|
1487
|
+
"button:not([disabled])",
|
|
1488
|
+
"input:not([disabled]):not([type='hidden'])",
|
|
1489
|
+
"select:not([disabled])",
|
|
1490
|
+
"textarea:not([disabled])",
|
|
1491
|
+
"iframe",
|
|
1492
|
+
"object",
|
|
1493
|
+
"embed",
|
|
1494
|
+
"[contenteditable='true']",
|
|
1495
|
+
"[tabindex]:not([tabindex='-1'])"
|
|
1496
|
+
].join(",");
|
|
1497
|
+
var isVisible = (el) => {
|
|
1498
|
+
if (typeof el.checkVisibility === "function") {
|
|
1499
|
+
return el.checkVisibility({ checkOpacity: false, checkVisibilityCSS: true });
|
|
1500
|
+
}
|
|
1501
|
+
return el.offsetParent !== null;
|
|
1502
|
+
};
|
|
1503
|
+
var focusables = (root) => Array.from(root.querySelectorAll(FOCUSABLE)).filter(
|
|
1504
|
+
(el) => !el.hasAttribute("inert") && isVisible(el) && !el.matches("[aria-hidden='true']")
|
|
1505
|
+
);
|
|
1506
|
+
var installFocusTrap = (container, options = {}) => {
|
|
1507
|
+
if (typeof document === "undefined") return () => {
|
|
1508
|
+
};
|
|
1509
|
+
const previouslyFocused = document.activeElement;
|
|
1510
|
+
const focusInitial = () => {
|
|
1511
|
+
const initial = typeof options.initialFocus === "string" ? container.querySelector(options.initialFocus) : options.initialFocus ?? focusables(container)[0] ?? container;
|
|
1512
|
+
initial?.focus({ preventScroll: true });
|
|
1513
|
+
};
|
|
1514
|
+
focusInitial();
|
|
1515
|
+
const handleKey = (e) => {
|
|
1516
|
+
if (e.key === "Escape" && options.onEscape) {
|
|
1517
|
+
options.onEscape();
|
|
1518
|
+
return;
|
|
1519
|
+
}
|
|
1520
|
+
if (e.key !== "Tab") return;
|
|
1521
|
+
const list = focusables(container);
|
|
1522
|
+
if (list.length === 0) {
|
|
1523
|
+
e.preventDefault();
|
|
1524
|
+
return;
|
|
1525
|
+
}
|
|
1526
|
+
const first = list[0];
|
|
1527
|
+
const last = list[list.length - 1];
|
|
1528
|
+
const active = document.activeElement;
|
|
1529
|
+
if (e.shiftKey && active === first) {
|
|
1530
|
+
e.preventDefault();
|
|
1531
|
+
last.focus();
|
|
1532
|
+
} else if (!e.shiftKey && active === last) {
|
|
1533
|
+
e.preventDefault();
|
|
1534
|
+
first.focus();
|
|
1535
|
+
}
|
|
1536
|
+
};
|
|
1537
|
+
const handleFocusIn = (e) => {
|
|
1538
|
+
const target = e.target;
|
|
1539
|
+
if (target && !container.contains(target)) {
|
|
1540
|
+
const list = focusables(container);
|
|
1541
|
+
list[0]?.focus({ preventScroll: true });
|
|
1542
|
+
}
|
|
1543
|
+
};
|
|
1544
|
+
document.addEventListener("keydown", handleKey, true);
|
|
1545
|
+
document.addEventListener("focusin", handleFocusIn, true);
|
|
1546
|
+
let released = false;
|
|
1547
|
+
return () => {
|
|
1548
|
+
if (released) return;
|
|
1549
|
+
released = true;
|
|
1550
|
+
document.removeEventListener("keydown", handleKey, true);
|
|
1551
|
+
document.removeEventListener("focusin", handleFocusIn, true);
|
|
1552
|
+
if (previouslyFocused && document.contains(previouslyFocused)) {
|
|
1553
|
+
previouslyFocused.focus({ preventScroll: true });
|
|
1554
|
+
} else {
|
|
1555
|
+
const fallback = document.querySelector("[data-bs-restore-fallback]");
|
|
1556
|
+
fallback?.focus({ preventScroll: true });
|
|
1557
|
+
}
|
|
1558
|
+
};
|
|
1559
|
+
};
|
|
1560
|
+
|
|
1561
|
+
// src/core/lifecycle/scrollLock.ts
|
|
1562
|
+
var lockCount = 0;
|
|
1563
|
+
var savedStyles = null;
|
|
1564
|
+
var lockBodyScroll = () => {
|
|
1565
|
+
if (typeof document === "undefined") return () => {
|
|
1566
|
+
};
|
|
1567
|
+
if (lockCount === 0) {
|
|
1568
|
+
const body = document.body;
|
|
1569
|
+
savedStyles = {
|
|
1570
|
+
overflow: body.style.overflow,
|
|
1571
|
+
position: body.style.position,
|
|
1572
|
+
top: body.style.top,
|
|
1573
|
+
width: body.style.width,
|
|
1574
|
+
scrollY: window.scrollY
|
|
1575
|
+
};
|
|
1576
|
+
body.style.overflow = "hidden";
|
|
1577
|
+
body.style.position = "fixed";
|
|
1578
|
+
body.style.top = `-${savedStyles.scrollY}px`;
|
|
1579
|
+
body.style.width = "100%";
|
|
1580
|
+
}
|
|
1581
|
+
lockCount++;
|
|
1582
|
+
let released = false;
|
|
1583
|
+
return () => {
|
|
1584
|
+
if (released) return;
|
|
1585
|
+
released = true;
|
|
1586
|
+
lockCount = Math.max(0, lockCount - 1);
|
|
1587
|
+
if (lockCount === 0 && savedStyles) {
|
|
1588
|
+
const body = document.body;
|
|
1589
|
+
body.style.overflow = savedStyles.overflow;
|
|
1590
|
+
body.style.position = savedStyles.position;
|
|
1591
|
+
body.style.top = savedStyles.top;
|
|
1592
|
+
body.style.width = savedStyles.width;
|
|
1593
|
+
window.scrollTo(0, savedStyles.scrollY);
|
|
1594
|
+
savedStyles = null;
|
|
1595
|
+
}
|
|
1596
|
+
};
|
|
1597
|
+
};
|
|
1598
|
+
|
|
1599
|
+
// src/core/features/inert-siblings.ts
|
|
1600
|
+
function createInertSiblings(rootProvider) {
|
|
1601
|
+
const tracked = [];
|
|
1602
|
+
return {
|
|
1603
|
+
apply() {
|
|
1604
|
+
if (typeof document === "undefined") return;
|
|
1605
|
+
const root = rootProvider();
|
|
1606
|
+
const parent = root.parentElement ?? document.body;
|
|
1607
|
+
for (const child of Array.from(parent.children)) {
|
|
1608
|
+
if (child === root) continue;
|
|
1609
|
+
if (child.hasAttribute("inert")) continue;
|
|
1610
|
+
child.setAttribute("inert", "");
|
|
1611
|
+
tracked.push(child);
|
|
1612
|
+
}
|
|
1613
|
+
},
|
|
1614
|
+
remove() {
|
|
1615
|
+
for (const el of tracked) el.removeAttribute("inert");
|
|
1616
|
+
tracked.length = 0;
|
|
1617
|
+
}
|
|
1618
|
+
};
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
// src/core/controllers/lifecycle-controller.ts
|
|
1622
|
+
var LifecycleController = class {
|
|
1623
|
+
constructor(deps, opts) {
|
|
1624
|
+
__publicField(this, "element");
|
|
1625
|
+
__publicField(this, "closeFn");
|
|
1626
|
+
__publicField(this, "focusTrapEnabled");
|
|
1627
|
+
__publicField(this, "initialFocus");
|
|
1628
|
+
__publicField(this, "closeOnEscape");
|
|
1629
|
+
__publicField(this, "bodyScrollLockEnabled");
|
|
1630
|
+
__publicField(this, "inertSiblingsEnabled");
|
|
1631
|
+
__publicField(this, "inertSiblings");
|
|
1632
|
+
__publicField(this, "shouldApplyInertSiblings");
|
|
1633
|
+
__publicField(this, "returnFocus");
|
|
1634
|
+
/** Toggle-state teardowns — fire on close, NOT in destroy-only stack. */
|
|
1635
|
+
__publicField(this, "releaseFocusTrap", null);
|
|
1636
|
+
__publicField(this, "releaseScrollLock", null);
|
|
1637
|
+
/** True between install() and release(). Guards release()/destroy() from
|
|
1638
|
+
* re-running side effects (notably `returnFocus.focus()`) on already-
|
|
1639
|
+
* released or never-installed instances. */
|
|
1640
|
+
__publicField(this, "installed", false);
|
|
1641
|
+
__publicField(this, "destroyed", false);
|
|
1642
|
+
this.element = deps.element;
|
|
1643
|
+
this.closeFn = deps.close;
|
|
1644
|
+
this.focusTrapEnabled = opts.focusTrap ?? false;
|
|
1645
|
+
this.initialFocus = opts.initialFocus;
|
|
1646
|
+
this.closeOnEscape = opts.closeOnEscape ?? true;
|
|
1647
|
+
this.bodyScrollLockEnabled = opts.lockBodyScroll ?? true;
|
|
1648
|
+
this.inertSiblingsEnabled = opts.inertSiblings ?? false;
|
|
1649
|
+
this.shouldApplyInertSiblings = opts.shouldApplyInertSiblings ?? (() => true);
|
|
1650
|
+
this.returnFocus = opts.returnFocus;
|
|
1651
|
+
this.inertSiblings = createInertSiblings(() => this.element);
|
|
1652
|
+
}
|
|
1653
|
+
/**
|
|
1654
|
+
* Run the open-time install steps. Idempotent: each step checks its
|
|
1655
|
+
* existing teardown ref so a re-open during a half-cancelled close
|
|
1656
|
+
* doesn't double-install. Sets `installed=true` BEFORE the first side
|
|
1657
|
+
* effect so a partial throw (detached / shadow-DOM target) leaves the
|
|
1658
|
+
* caller's `release()` rollback path armed to undo whatever did succeed.
|
|
1659
|
+
* No-op when destroyed.
|
|
1660
|
+
*/
|
|
1661
|
+
install() {
|
|
1662
|
+
if (this.destroyed) return;
|
|
1663
|
+
this.installed = true;
|
|
1664
|
+
if (this.bodyScrollLockEnabled && !this.releaseScrollLock) {
|
|
1665
|
+
this.releaseScrollLock = lockBodyScroll();
|
|
1666
|
+
}
|
|
1667
|
+
if (this.focusTrapEnabled && !this.releaseFocusTrap) {
|
|
1668
|
+
this.releaseFocusTrap = installFocusTrap(this.element, {
|
|
1669
|
+
initialFocus: this.initialFocus,
|
|
1670
|
+
onEscape: this.closeOnEscape ? () => void this.closeFn() : void 0
|
|
1671
|
+
});
|
|
1672
|
+
}
|
|
1673
|
+
if (this.inertSiblingsEnabled && this.shouldApplyInertSiblings()) {
|
|
1674
|
+
this.inertSiblings.apply();
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
/**
|
|
1678
|
+
* Update the post-release focus target. Overlay re-binds this when the
|
|
1679
|
+
* consumer changes `returnFocus` after construction; the new value applies
|
|
1680
|
+
* on the next `release()`. No-op when destroyed.
|
|
1681
|
+
*/
|
|
1682
|
+
setReturnFocus(target) {
|
|
1683
|
+
if (this.destroyed) return;
|
|
1684
|
+
this.returnFocus = target;
|
|
1685
|
+
}
|
|
1686
|
+
/**
|
|
1687
|
+
* Release all install-time teardowns. Short-circuits when not installed,
|
|
1688
|
+
* so `close() → destroy()` (each routing through here) only restores
|
|
1689
|
+
* focus once. The `installed` guard lets `inertSiblings.remove()` and
|
|
1690
|
+
* the focus-trap/scroll-lock teardown nullings stay simple — they were
|
|
1691
|
+
* already idempotent, but `returnFocus.focus()` was firing on every call.
|
|
1692
|
+
*/
|
|
1693
|
+
release() {
|
|
1694
|
+
if (!this.installed) return;
|
|
1695
|
+
this.installed = false;
|
|
1696
|
+
this.releaseFocusTrap?.();
|
|
1697
|
+
this.releaseFocusTrap = null;
|
|
1698
|
+
this.releaseScrollLock?.();
|
|
1699
|
+
this.releaseScrollLock = null;
|
|
1700
|
+
this.inertSiblings.remove();
|
|
1701
|
+
if (this.returnFocus) {
|
|
1702
|
+
const target = typeof this.returnFocus === "function" ? this.returnFocus() : this.returnFocus;
|
|
1703
|
+
target?.focus?.();
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
/**
|
|
1707
|
+
* Final teardown — releases any still-installed listeners and seals the
|
|
1708
|
+
* controller. After destroy(), install() is a no-op and setReturnFocus()
|
|
1709
|
+
* is rejected, so post-destroy mutations can't queue up a stale focus
|
|
1710
|
+
* restore.
|
|
1711
|
+
*/
|
|
1712
|
+
destroy() {
|
|
1713
|
+
this.release();
|
|
1714
|
+
this.destroyed = true;
|
|
1715
|
+
}
|
|
1716
|
+
};
|
|
1717
|
+
|
|
1718
|
+
// src/core/gestures.ts
|
|
1719
|
+
var isAxisVertical = (mode) => mode === "bottom" || mode === "top";
|
|
1720
|
+
var SampleRing = class {
|
|
1721
|
+
// current count, ≤ capacity
|
|
1722
|
+
constructor(capacity) {
|
|
1723
|
+
__publicField(this, "slots");
|
|
1724
|
+
__publicField(this, "capacity");
|
|
1725
|
+
__publicField(this, "head", 0);
|
|
1726
|
+
// next write index
|
|
1727
|
+
__publicField(this, "size", 0);
|
|
1728
|
+
this.capacity = capacity;
|
|
1729
|
+
this.slots = new Array(capacity);
|
|
1730
|
+
for (let i = 0; i < capacity; i++) this.slots[i] = { t: 0, v: 0 };
|
|
1731
|
+
}
|
|
1732
|
+
push(t, v) {
|
|
1733
|
+
const slot = this.slots[this.head];
|
|
1734
|
+
slot.t = t;
|
|
1735
|
+
slot.v = v;
|
|
1736
|
+
this.head = (this.head + 1) % this.capacity;
|
|
1737
|
+
if (this.size < this.capacity) this.size++;
|
|
1738
|
+
}
|
|
1739
|
+
/**
|
|
1740
|
+
* Drop oldest entries whose timestamp is below `cutoff`, but never shrink
|
|
1741
|
+
* below `minKeep`. Mirrors the original
|
|
1742
|
+
* `while (samples.length > minKeep && samples[0].t < cutoff) samples.shift()`
|
|
1743
|
+
* but without copying — just advances the logical tail.
|
|
1744
|
+
*/
|
|
1745
|
+
pruneBefore(cutoff, minKeep) {
|
|
1746
|
+
while (this.size > minKeep) {
|
|
1747
|
+
const oldestIdx = (this.head - this.size + this.capacity) % this.capacity;
|
|
1748
|
+
if (this.slots[oldestIdx].t < cutoff) this.size--;
|
|
1749
|
+
else break;
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
get length() {
|
|
1753
|
+
return this.size;
|
|
1754
|
+
}
|
|
1755
|
+
oldest() {
|
|
1756
|
+
if (this.size === 0) return void 0;
|
|
1757
|
+
return this.slots[(this.head - this.size + this.capacity) % this.capacity];
|
|
1758
|
+
}
|
|
1759
|
+
newest() {
|
|
1760
|
+
if (this.size === 0) return void 0;
|
|
1761
|
+
return this.slots[(this.head - 1 + this.capacity) % this.capacity];
|
|
1762
|
+
}
|
|
1763
|
+
reset() {
|
|
1764
|
+
this.head = 0;
|
|
1765
|
+
this.size = 0;
|
|
1766
|
+
}
|
|
1767
|
+
};
|
|
1768
|
+
var installGestures = (handle, mode, callbacks) => {
|
|
1769
|
+
let activePointerId = null;
|
|
1770
|
+
let activePointerType = "touch";
|
|
1771
|
+
let startCoord = 0;
|
|
1772
|
+
let lastCoord = 0;
|
|
1773
|
+
const VELOCITY_WINDOW_MS = (kind) => kind === "mouse" ? 160 : 120;
|
|
1774
|
+
const MAX_SAMPLES = 32;
|
|
1775
|
+
const samples = new SampleRing(MAX_SAMPLES);
|
|
1776
|
+
const axis = isAxisVertical(mode) ? "Y" : "X";
|
|
1777
|
+
const sign = mode === "bottom" || mode === "right" ? -1 : 1;
|
|
1778
|
+
const coordOf = (e) => axis === "Y" ? e.clientY : e.clientX;
|
|
1779
|
+
const onPointerDown = (e) => {
|
|
1780
|
+
if (activePointerId !== null) return;
|
|
1781
|
+
if (e.button !== void 0 && e.button !== 0) return;
|
|
1782
|
+
activePointerId = e.pointerId;
|
|
1783
|
+
activePointerType = e.pointerType || "touch";
|
|
1784
|
+
startCoord = coordOf(e);
|
|
1785
|
+
lastCoord = startCoord;
|
|
1786
|
+
samples.reset();
|
|
1787
|
+
samples.push(e.timeStamp, startCoord);
|
|
1788
|
+
try {
|
|
1789
|
+
handle.setPointerCapture(e.pointerId);
|
|
1790
|
+
} catch {
|
|
1791
|
+
}
|
|
1792
|
+
callbacks.onStart(startCoord, activePointerType);
|
|
1793
|
+
};
|
|
1794
|
+
const onPointerMove = (e) => {
|
|
1795
|
+
if (e.pointerId !== activePointerId) return;
|
|
1796
|
+
const coord = coordOf(e);
|
|
1797
|
+
const rawDelta = coord - startCoord;
|
|
1798
|
+
const delta = rawDelta * sign;
|
|
1799
|
+
lastCoord = coord;
|
|
1800
|
+
samples.push(e.timeStamp, coord);
|
|
1801
|
+
const cutoff = e.timeStamp - VELOCITY_WINDOW_MS(activePointerType);
|
|
1802
|
+
samples.pruneBefore(cutoff, 2);
|
|
1803
|
+
callbacks.onMove(delta);
|
|
1804
|
+
};
|
|
1805
|
+
const finishGesture = (e) => {
|
|
1806
|
+
if (e.pointerId !== activePointerId) return;
|
|
1807
|
+
const rawDelta = lastCoord - startCoord;
|
|
1808
|
+
const delta = rawDelta * sign;
|
|
1809
|
+
let velocity = 0;
|
|
1810
|
+
if (samples.length >= 3) {
|
|
1811
|
+
const first = samples.oldest();
|
|
1812
|
+
const last = samples.newest();
|
|
1813
|
+
const dt = last.t - first.t;
|
|
1814
|
+
if (dt > 0) velocity = (last.v - first.v) / dt * sign;
|
|
1815
|
+
}
|
|
1816
|
+
const finishedKind = activePointerType;
|
|
1817
|
+
activePointerId = null;
|
|
1818
|
+
samples.reset();
|
|
1819
|
+
try {
|
|
1820
|
+
if (handle.hasPointerCapture(e.pointerId)) {
|
|
1821
|
+
handle.releasePointerCapture(e.pointerId);
|
|
1822
|
+
}
|
|
1823
|
+
} catch {
|
|
1824
|
+
}
|
|
1825
|
+
callbacks.onEnd(delta, velocity, finishedKind);
|
|
1826
|
+
};
|
|
1827
|
+
const onPointerCancel = (e) => {
|
|
1828
|
+
if (e.pointerId !== activePointerId) return;
|
|
1829
|
+
activePointerId = null;
|
|
1830
|
+
samples.reset();
|
|
1831
|
+
callbacks.onCancel?.();
|
|
1832
|
+
};
|
|
1833
|
+
handle.addEventListener("pointerdown", onPointerDown);
|
|
1834
|
+
handle.addEventListener("pointermove", onPointerMove);
|
|
1835
|
+
handle.addEventListener("pointerup", finishGesture);
|
|
1836
|
+
handle.addEventListener("pointercancel", onPointerCancel);
|
|
1837
|
+
handle.style.touchAction = isAxisVertical(mode) ? "pan-x" : "pan-y";
|
|
1838
|
+
return () => {
|
|
1839
|
+
handle.removeEventListener("pointerdown", onPointerDown);
|
|
1840
|
+
handle.removeEventListener("pointermove", onPointerMove);
|
|
1841
|
+
handle.removeEventListener("pointerup", finishGesture);
|
|
1842
|
+
handle.removeEventListener("pointercancel", onPointerCancel);
|
|
1843
|
+
if (activePointerId !== null && handle.hasPointerCapture(activePointerId)) {
|
|
1844
|
+
handle.releasePointerCapture(activePointerId);
|
|
1845
|
+
}
|
|
1846
|
+
};
|
|
1847
|
+
};
|
|
1848
|
+
|
|
1849
|
+
// src/core/primitives/rubber-band.ts
|
|
1850
|
+
var RUBBER_BAND_FACTOR = 0.55;
|
|
1851
|
+
function rubberBand(overshoot, maxAxisSize) {
|
|
1852
|
+
const cap = Math.min(maxAxisSize * 0.15, 80);
|
|
1853
|
+
return Math.sign(overshoot) * Math.pow(Math.abs(overshoot), RUBBER_BAND_FACTOR) * Math.min(6, cap / Math.max(Math.abs(overshoot), 1));
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
// src/core/features/soft-keyboard.ts
|
|
1857
|
+
function dismissSoftKeyboardIfFocused(root) {
|
|
1858
|
+
if (typeof document === "undefined") return false;
|
|
1859
|
+
const active = document.activeElement;
|
|
1860
|
+
if (!active) return false;
|
|
1861
|
+
if (!root.contains(active)) return false;
|
|
1862
|
+
const tag = active.tagName;
|
|
1863
|
+
const isEditable = tag === "INPUT" || tag === "TEXTAREA" || active.isContentEditable === true;
|
|
1864
|
+
if (!isEditable) return false;
|
|
1865
|
+
try {
|
|
1866
|
+
active.blur();
|
|
1867
|
+
return true;
|
|
1868
|
+
} catch {
|
|
1869
|
+
return false;
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
// src/core/controllers/gesture-controller.ts
|
|
1874
|
+
var GestureController = class {
|
|
1875
|
+
constructor(deps) {
|
|
1876
|
+
__publicField(this, "deps");
|
|
1877
|
+
__publicField(this, "detach", null);
|
|
1878
|
+
__publicField(this, "isDragging_", false);
|
|
1879
|
+
__publicField(this, "dragStartSize", 0);
|
|
1880
|
+
// Only "touch" gestures are eligible for soft-keyboard dismissal —
|
|
1881
|
+
// mouse/trackpad users don't have a keyboard to dismiss.
|
|
1882
|
+
__publicField(this, "currentPointerKind", "touch");
|
|
1883
|
+
// One blur per drag — flipping focus every move event races the keyboard's
|
|
1884
|
+
// own animations on iOS and makes the page jitter.
|
|
1885
|
+
__publicField(this, "keyboardDismissed", false);
|
|
1886
|
+
// Reused payload object for the "drag" event — mutated each onMove instead
|
|
1887
|
+
// of allocating a fresh `{size, delta}` per frame. At 60-120Hz a long drag
|
|
1888
|
+
// would otherwise churn tens of KB through the GC. Mirrors browser
|
|
1889
|
+
// PointerEvent semantics: consumers must NOT retain the reference across
|
|
1890
|
+
// frames (clone if needed). See SheetEventMap.drag JSDoc.
|
|
1891
|
+
__publicField(this, "dragPayload", { size: 0, delta: 0 });
|
|
1892
|
+
this.deps = deps;
|
|
1893
|
+
}
|
|
1894
|
+
/** True between `dragstart` and `dragend`/`onCancel`. */
|
|
1895
|
+
get isDragging() {
|
|
1896
|
+
return this.isDragging_;
|
|
1897
|
+
}
|
|
1898
|
+
/**
|
|
1899
|
+
* Bind pointer listeners on the handle and return a teardown. Engine pushes
|
|
1900
|
+
* the teardown into its TeardownStack so destroy() drains it once.
|
|
1901
|
+
*/
|
|
1902
|
+
install() {
|
|
1903
|
+
if (this.detach) return this.detach;
|
|
1904
|
+
this.detach = installGestures(this.deps.handle, this.deps.mode, {
|
|
1905
|
+
onStart: (_coord, kind) => {
|
|
1906
|
+
this.deps.cancelAnimation();
|
|
1907
|
+
this.isDragging_ = true;
|
|
1908
|
+
this.dragStartSize = this.deps.getDragContext().size;
|
|
1909
|
+
this.currentPointerKind = kind;
|
|
1910
|
+
this.keyboardDismissed = false;
|
|
1911
|
+
this.deps.getRoot()?.setAttribute("data-dragging", "true");
|
|
1912
|
+
this.deps.element.style.willChange = "transform";
|
|
1913
|
+
this.deps.emit("dragstart", { size: this.dragStartSize });
|
|
1914
|
+
},
|
|
1915
|
+
onMove: (delta) => {
|
|
1916
|
+
const ctx = this.deps.getDragContext();
|
|
1917
|
+
const { min, max } = ctx.range;
|
|
1918
|
+
const maxAxis = ctx.maxAxisSize;
|
|
1919
|
+
const rubberOn = ctx.rubberBandEnabled;
|
|
1920
|
+
let next = this.dragStartSize + delta;
|
|
1921
|
+
if (next > max) {
|
|
1922
|
+
next = rubberOn ? max + rubberBand(next - max, maxAxis) : max;
|
|
1923
|
+
} else if (next < min) {
|
|
1924
|
+
next = rubberOn ? min - rubberBand(min - next, maxAxis) : min;
|
|
1925
|
+
}
|
|
1926
|
+
this.deps.applySize(next);
|
|
1927
|
+
if (!this.keyboardDismissed && this.currentPointerKind === "touch" && this.deps.mode === "bottom" && next < this.dragStartSize) {
|
|
1928
|
+
if (dismissSoftKeyboardIfFocused(this.deps.element)) {
|
|
1929
|
+
this.keyboardDismissed = true;
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
if (this.deps.listenerCount("drag") > 0) {
|
|
1933
|
+
this.dragPayload.size = next;
|
|
1934
|
+
this.dragPayload.delta = delta;
|
|
1935
|
+
this.deps.emit("drag", this.dragPayload);
|
|
1936
|
+
}
|
|
1937
|
+
},
|
|
1938
|
+
onEnd: (delta, velocity, kind) => {
|
|
1939
|
+
this.isDragging_ = false;
|
|
1940
|
+
this.keyboardDismissed = false;
|
|
1941
|
+
this.deps.getRoot()?.removeAttribute("data-dragging");
|
|
1942
|
+
this.deps.emit("dragend", {
|
|
1943
|
+
size: this.deps.getDragContext().size,
|
|
1944
|
+
velocity
|
|
1945
|
+
});
|
|
1946
|
+
this.deps.settleAfterDrag(delta, velocity, kind);
|
|
1947
|
+
},
|
|
1948
|
+
onCancel: () => {
|
|
1949
|
+
this.isDragging_ = false;
|
|
1950
|
+
this.keyboardDismissed = false;
|
|
1951
|
+
this.deps.getRoot()?.removeAttribute("data-dragging");
|
|
1952
|
+
const restoreTo = this.dragStartSize;
|
|
1953
|
+
void this.deps.animateTo(restoreTo, 0);
|
|
1954
|
+
}
|
|
1955
|
+
});
|
|
1956
|
+
return this.detach;
|
|
1957
|
+
}
|
|
1958
|
+
/**
|
|
1959
|
+
* Force-clear drag visual state. Engine calls from destroy() when destroyed
|
|
1960
|
+
* mid-drag — gesture detach won't synthesize an onCancel, so we have to
|
|
1961
|
+
* roll back the data-attribute + will-change ourselves.
|
|
1962
|
+
*/
|
|
1963
|
+
forceClearDragState() {
|
|
1964
|
+
if (!this.isDragging_) return;
|
|
1965
|
+
this.isDragging_ = false;
|
|
1966
|
+
this.keyboardDismissed = false;
|
|
1967
|
+
this.deps.getRoot()?.removeAttribute("data-dragging");
|
|
1968
|
+
this.deps.element.style.willChange = "auto";
|
|
1969
|
+
}
|
|
1970
|
+
};
|
|
1971
|
+
|
|
1972
|
+
// src/core/primitives/aria-slider-writer.ts
|
|
1973
|
+
var AriaSliderWriter = class {
|
|
1974
|
+
constructor(handle, mode) {
|
|
1975
|
+
__publicField(this, "handle", handle);
|
|
1976
|
+
handle.setAttribute(
|
|
1977
|
+
"aria-orientation",
|
|
1978
|
+
mode === "bottom" || mode === "top" ? "vertical" : "horizontal"
|
|
1979
|
+
);
|
|
1980
|
+
}
|
|
1981
|
+
/**
|
|
1982
|
+
* Update slider position. `allowedIds` length determines `aria-valuemax`;
|
|
1983
|
+
* `activeId` indexes into it for `aria-valuenow` + `aria-valuetext`.
|
|
1984
|
+
* Empty list → strip every dynamic attr so AT skips the handle entirely.
|
|
1985
|
+
*/
|
|
1986
|
+
setValue(allowedIds, activeId) {
|
|
1987
|
+
if (allowedIds.length === 0) {
|
|
1988
|
+
this.handle.removeAttribute("aria-valuemin");
|
|
1989
|
+
this.handle.removeAttribute("aria-valuemax");
|
|
1990
|
+
this.handle.removeAttribute("aria-valuenow");
|
|
1991
|
+
this.handle.removeAttribute("aria-valuetext");
|
|
1992
|
+
return;
|
|
1993
|
+
}
|
|
1994
|
+
const max = Math.max(allowedIds.length - 1, 0);
|
|
1995
|
+
const idx = Math.max(allowedIds.indexOf(activeId), 0);
|
|
1996
|
+
this.handle.setAttribute("aria-valuemin", "0");
|
|
1997
|
+
this.handle.setAttribute("aria-valuemax", String(max));
|
|
1998
|
+
this.handle.setAttribute("aria-valuenow", String(idx));
|
|
1999
|
+
this.handle.setAttribute("aria-valuetext", activeId);
|
|
2000
|
+
}
|
|
2001
|
+
};
|
|
2002
|
+
|
|
2003
|
+
// src/core/features/scroll-cache.ts
|
|
2004
|
+
function createScrollCache(deps) {
|
|
2005
|
+
const positions = /* @__PURE__ */ new Map();
|
|
2006
|
+
const isSmallSize = (size) => {
|
|
2007
|
+
const max = deps.getMaxAxisSize();
|
|
2008
|
+
if (max <= 0) return false;
|
|
2009
|
+
return size < max / 2;
|
|
2010
|
+
};
|
|
2011
|
+
return {
|
|
2012
|
+
cache(fromId, prevSize, nextSize) {
|
|
2013
|
+
const container = deps.scrollContainer;
|
|
2014
|
+
if (!container) return;
|
|
2015
|
+
if (isSmallSize(prevSize)) return;
|
|
2016
|
+
if (!isSmallSize(nextSize)) return;
|
|
2017
|
+
positions.set(fromId, container.scrollTop);
|
|
2018
|
+
},
|
|
2019
|
+
restore(toId, prevSize, nextSize) {
|
|
2020
|
+
const container = deps.scrollContainer;
|
|
2021
|
+
if (!container) return;
|
|
2022
|
+
if (isSmallSize(nextSize)) return;
|
|
2023
|
+
if (!isSmallSize(prevSize)) return;
|
|
2024
|
+
const cached = positions.get(toId);
|
|
2025
|
+
if (cached === void 0) return;
|
|
2026
|
+
container.scrollTop = cached;
|
|
2027
|
+
positions.delete(toId);
|
|
2028
|
+
},
|
|
2029
|
+
clear() {
|
|
2030
|
+
positions.clear();
|
|
2031
|
+
}
|
|
2032
|
+
};
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
// src/core/primitives/engine-options.ts
|
|
2036
|
+
var DEFAULT_FLICK_VELOCITY = 0.65;
|
|
2037
|
+
var DEFAULT_DRAG_THRESHOLD = 18;
|
|
2038
|
+
function resolveEngineOptions(opts, extras) {
|
|
2039
|
+
const initialAllowed = opts.allowed ?? opts.snapPoints.map((p) => p.id);
|
|
2040
|
+
let initialId = opts.initial ?? initialAllowed[0] ?? opts.snapPoints[0]?.id ?? "default";
|
|
2041
|
+
if (opts.persistKey) {
|
|
2042
|
+
const restored = readPersistedId(opts.persistKey);
|
|
2043
|
+
if (restored && initialAllowed.includes(restored)) {
|
|
2044
|
+
initialId = restored;
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
2047
|
+
return {
|
|
2048
|
+
mode: opts.mode ?? "bottom",
|
|
2049
|
+
flickVelocity: opts.flickVelocity ?? DEFAULT_FLICK_VELOCITY,
|
|
2050
|
+
dragThreshold: opts.dragThreshold ?? DEFAULT_DRAG_THRESHOLD,
|
|
2051
|
+
rubberBandEnabled: opts.rubberBand ?? true,
|
|
2052
|
+
closeOnBack: opts.closeOnBack ?? false,
|
|
2053
|
+
initialAllowed,
|
|
2054
|
+
initialId,
|
|
2055
|
+
scrim: {
|
|
2056
|
+
scrimMode: opts.scrimMode,
|
|
2057
|
+
scrimColor: opts.scrimColor,
|
|
2058
|
+
scrimBlur: opts.scrimBlur,
|
|
2059
|
+
// Auto-promote pointer-events when tap-to-close is on so the click
|
|
2060
|
+
// listener actually receives events. Caller can still force `false`
|
|
2061
|
+
// explicitly to opt out — `??` only kicks in for `undefined`.
|
|
2062
|
+
scrimInteractive: opts.scrimInteractive ?? (opts.scrimTapToClose ? true : void 0),
|
|
2063
|
+
scrimTapToClose: opts.scrimTapToClose,
|
|
2064
|
+
scrimPreset: opts.scrimPreset,
|
|
2065
|
+
screenRange: opts.screenRange,
|
|
2066
|
+
backdropRange: opts.backdropRange
|
|
2067
|
+
},
|
|
2068
|
+
animation: {
|
|
2069
|
+
animation: opts.animation,
|
|
2070
|
+
duration: opts.duration,
|
|
2071
|
+
easing: opts.easing,
|
|
2072
|
+
spring: opts.spring,
|
|
2073
|
+
respectReducedMotion: opts.respectReducedMotion,
|
|
2074
|
+
viewTransitions: opts.viewTransitions
|
|
2075
|
+
},
|
|
2076
|
+
lifecycle: {
|
|
2077
|
+
focusTrap: opts.focusTrap,
|
|
2078
|
+
initialFocus: opts.initialFocus,
|
|
2079
|
+
closeOnEscape: opts.closeOnEscape,
|
|
2080
|
+
lockBodyScroll: opts.lockBodyScroll,
|
|
2081
|
+
inertSiblings: opts.inertSiblings,
|
|
2082
|
+
// Folded in from `extras` so the lifecycle bag is fully assembled at
|
|
2083
|
+
// the call site — engine no longer needs a spread+override at
|
|
2084
|
+
// controller construction. When `extras` is omitted, the field stays
|
|
2085
|
+
// undefined and the controller uses its own default predicate.
|
|
2086
|
+
shouldApplyInertSiblings: extras?.shouldApplyInertSiblings
|
|
2087
|
+
}
|
|
2088
|
+
};
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
// src/core/BottomSheetEngine.ts
|
|
2092
|
+
var HAPTIC_DURATION_MS = 8;
|
|
2093
|
+
var TeardownStack = class {
|
|
2094
|
+
constructor() {
|
|
2095
|
+
__publicField(this, "fns", []);
|
|
2096
|
+
}
|
|
2097
|
+
add(fn) {
|
|
2098
|
+
if (fn) this.fns.push(fn);
|
|
2099
|
+
}
|
|
2100
|
+
drain() {
|
|
2101
|
+
while (this.fns.length) {
|
|
2102
|
+
try {
|
|
2103
|
+
this.fns.pop()();
|
|
2104
|
+
} catch (err) {
|
|
2105
|
+
queueMicrotask(() => {
|
|
2106
|
+
throw err;
|
|
2107
|
+
});
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
};
|
|
2112
|
+
var BottomSheetEngine = class {
|
|
2113
|
+
constructor(opts) {
|
|
2114
|
+
__publicField(this, "id", nextInstanceId("bs"));
|
|
2115
|
+
__publicField(this, "element");
|
|
2116
|
+
__publicField(this, "handle");
|
|
2117
|
+
__publicField(this, "scrollContainer");
|
|
2118
|
+
__publicField(this, "backdrop");
|
|
2119
|
+
__publicField(this, "screenComponent");
|
|
2120
|
+
__publicField(this, "mode");
|
|
2121
|
+
__publicField(this, "flickVelocity");
|
|
2122
|
+
__publicField(this, "dragThreshold");
|
|
2123
|
+
__publicField(this, "rubberBandEnabled");
|
|
2124
|
+
__publicField(this, "scrim");
|
|
2125
|
+
__publicField(this, "aria");
|
|
2126
|
+
__publicField(this, "animation");
|
|
2127
|
+
__publicField(this, "lifecycle");
|
|
2128
|
+
__publicField(this, "closeOnBack");
|
|
2129
|
+
__publicField(this, "routedTo");
|
|
2130
|
+
// Engine retains `snapPointsRaw` for the "find a closed-id" query in
|
|
2131
|
+
// close() (raw input, not resolved geometry). Resolver owns geometry.
|
|
2132
|
+
__publicField(this, "snapPointsRaw");
|
|
2133
|
+
__publicField(this, "snaps");
|
|
2134
|
+
__publicField(this, "activeId");
|
|
2135
|
+
__publicField(this, "size", 0);
|
|
2136
|
+
// Constructed in attach(); a plugin's install() could read `state` before
|
|
2137
|
+
// attach completes if the constructor throws partway through. State getter
|
|
2138
|
+
// null-guards via `this.gesture?.isDragging ?? false`.
|
|
2139
|
+
__publicField(this, "gesture");
|
|
2140
|
+
__publicField(this, "rootEl", null);
|
|
2141
|
+
__publicField(this, "destroyed", false);
|
|
2142
|
+
// In-flight ViewTransition handle. A second snapTo while a VT is in flight
|
|
2143
|
+
// would otherwise let Chromium reject the second's promise and flicker —
|
|
2144
|
+
// newCycle() calls skipTransition() to abort cleanly.
|
|
2145
|
+
__publicField(this, "currentViewTransition", null);
|
|
2146
|
+
// Each cycle captures the signal at entry; when a newer cycle aborts the
|
|
2147
|
+
// controller, the captured signal flips to `aborted=true` and post-await
|
|
2148
|
+
// guards short-circuit (prevents stale snap/open/close emits).
|
|
2149
|
+
__publicField(this, "currentAbort", new AbortController());
|
|
2150
|
+
// Sub-pixel `--bs-size` deltas and <1% `--bs-progress` deltas are not
|
|
2151
|
+
// visually distinguishable; gating CSSOM writes drops 30-50% of writes
|
|
2152
|
+
// during the spring's settle tail.
|
|
2153
|
+
__publicField(this, "sizeWriteSentinel", new WriteSentinel());
|
|
2154
|
+
__publicField(this, "progressWriteSentinel", new WriteSentinel());
|
|
2155
|
+
__publicField(this, "isTopSheet", true);
|
|
2156
|
+
// Pooled payload — identity documented in SheetEventMap.progress JSDoc;
|
|
2157
|
+
// consumers must not retain across frames.
|
|
2158
|
+
__publicField(this, "progressPayload", {
|
|
2159
|
+
value: 0,
|
|
2160
|
+
size: 0
|
|
2161
|
+
});
|
|
2162
|
+
// Pooled buffer for `getDragContext()` — onMove calls this 60-120×/s.
|
|
2163
|
+
// Inner `range` object identity is also stable (mutated in place).
|
|
2164
|
+
__publicField(this, "dragContextBuf", {
|
|
2165
|
+
size: 0,
|
|
2166
|
+
maxAxisSize: 0,
|
|
2167
|
+
range: { min: 0, max: 0 },
|
|
2168
|
+
rubberBandEnabled: false
|
|
2169
|
+
});
|
|
2170
|
+
__publicField(this, "bus", createEventBus());
|
|
2171
|
+
__publicField(this, "persistKey");
|
|
2172
|
+
__publicField(this, "linkedSheets", []);
|
|
2173
|
+
__publicField(this, "scrollCache");
|
|
2174
|
+
// Single LIFO stack for ALL destroy-only teardowns. Plugins (added last)
|
|
2175
|
+
// tear down first before the infrastructure they depend on. Toggle-semantic
|
|
2176
|
+
// listeners (focus-trap / scroll-lock / scrim tap-to-close) stay as named
|
|
2177
|
+
// fields on their controllers.
|
|
2178
|
+
__publicField(this, "teardowns", new TeardownStack());
|
|
2179
|
+
// Pre-computed transform template — built once per engine since `mode` is
|
|
2180
|
+
// fixed for its lifetime. Avoids the 4-arm switch at 60-120 Hz.
|
|
2181
|
+
__publicField(this, "transformTemplate");
|
|
2182
|
+
const resolved = resolveEngineOptions(opts, {
|
|
2183
|
+
shouldApplyInertSiblings: () => {
|
|
2184
|
+
if (typeof document === "undefined") return false;
|
|
2185
|
+
const bodyChildren = Array.from(document.body.children);
|
|
2186
|
+
return bodyChildren.some(
|
|
2187
|
+
(c) => c === opts.element || c.contains(opts.element)
|
|
2188
|
+
);
|
|
2189
|
+
}
|
|
2190
|
+
});
|
|
2191
|
+
this.element = opts.element;
|
|
2192
|
+
this.handle = opts.handle ?? opts.element;
|
|
2193
|
+
this.scrollContainer = opts.scrollContainer;
|
|
2194
|
+
this.backdrop = opts.backdrop;
|
|
2195
|
+
this.screenComponent = opts.screenComponent;
|
|
2196
|
+
this.mode = resolved.mode;
|
|
2197
|
+
this.transformTemplate = buildTransformTemplate(this.mode);
|
|
2198
|
+
this.aria = new AriaSliderWriter(this.handle, this.mode);
|
|
2199
|
+
this.flickVelocity = resolved.flickVelocity;
|
|
2200
|
+
this.dragThreshold = resolved.dragThreshold;
|
|
2201
|
+
this.rubberBandEnabled = resolved.rubberBandEnabled;
|
|
2202
|
+
this.scrim = new ScrimController(
|
|
2203
|
+
{
|
|
2204
|
+
mode: this.mode,
|
|
2205
|
+
screenComponent: this.screenComponent,
|
|
2206
|
+
backdrop: this.backdrop,
|
|
2207
|
+
isDestroyed: () => this.destroyed,
|
|
2208
|
+
isTopSheet: () => this.isTopSheet,
|
|
2209
|
+
getAllowedIds: () => this.snaps.getAllowedIds().slice(),
|
|
2210
|
+
getResolvedSnaps: () => this.snaps.getResolvedSnaps().slice(),
|
|
2211
|
+
snapTo: (id) => {
|
|
2212
|
+
void this.snapTo(id);
|
|
2213
|
+
}
|
|
2214
|
+
},
|
|
2215
|
+
resolved.scrim
|
|
2216
|
+
);
|
|
2217
|
+
this.animation = new AnimationRunner(
|
|
2218
|
+
{
|
|
2219
|
+
element: this.element,
|
|
2220
|
+
getRootEl: () => this.rootEl,
|
|
2221
|
+
applySize: (size) => this.applySize(size),
|
|
2222
|
+
getSize: () => this.size,
|
|
2223
|
+
// Optional chain protects synthetic test paths that drive animateTo
|
|
2224
|
+
// before attach() (gesture is constructed there).
|
|
2225
|
+
isDragging: () => this.gesture?.isDragging ?? false
|
|
2226
|
+
},
|
|
2227
|
+
resolved.animation
|
|
2228
|
+
);
|
|
2229
|
+
this.lifecycle = new LifecycleController(
|
|
2230
|
+
{
|
|
2231
|
+
element: this.element,
|
|
2232
|
+
close: () => this.close()
|
|
2233
|
+
},
|
|
2234
|
+
resolved.lifecycle
|
|
2235
|
+
);
|
|
2236
|
+
this.closeOnBack = resolved.closeOnBack;
|
|
2237
|
+
this.routedTo = opts.routedTo;
|
|
2238
|
+
this.snapPointsRaw = opts.snapPoints;
|
|
2239
|
+
this.persistKey = opts.persistKey;
|
|
2240
|
+
if (opts.linkedSheets) {
|
|
2241
|
+
this.linkedSheets = opts.linkedSheets;
|
|
2242
|
+
}
|
|
2243
|
+
this.activeId = resolved.initialId;
|
|
2244
|
+
auditVhUsage(this.snapPointsRaw);
|
|
2245
|
+
this.rootEl = this.element.closest(".bs-root");
|
|
2246
|
+
this.snaps = new SnapResolver(
|
|
2247
|
+
this.snapPointsRaw,
|
|
2248
|
+
resolved.initialAllowed,
|
|
2249
|
+
this.mode,
|
|
2250
|
+
() => this.handle.offsetHeight,
|
|
2251
|
+
(maxAxisSize) => {
|
|
2252
|
+
this.element.style[layoutAxis(this.mode)] = `${maxAxisSize}px`;
|
|
2253
|
+
}
|
|
2254
|
+
);
|
|
2255
|
+
const initial = this.snaps.findById(this.activeId);
|
|
2256
|
+
if (initial) this.size = initial.size;
|
|
2257
|
+
this.applySize(this.size);
|
|
2258
|
+
this.updateAriaSlider();
|
|
2259
|
+
this.scrollCache = createScrollCache({
|
|
2260
|
+
scrollContainer: this.scrollContainer,
|
|
2261
|
+
getMaxAxisSize: () => this.snaps.getMaxAxisSize()
|
|
2262
|
+
});
|
|
2263
|
+
this.attach();
|
|
2264
|
+
this.registerInStack();
|
|
2265
|
+
if (this.snaps.getMaxAxisSize() === 0 && typeof IntersectionObserver !== "undefined" && typeof document !== "undefined") {
|
|
2266
|
+
const io = new IntersectionObserver((entries) => {
|
|
2267
|
+
for (const entry of entries) {
|
|
2268
|
+
if (!entry.isIntersecting) continue;
|
|
2269
|
+
if (this.destroyed) return;
|
|
2270
|
+
this.snaps.recompute();
|
|
2271
|
+
const current = this.snaps.findById(this.activeId);
|
|
2272
|
+
if (current) {
|
|
2273
|
+
this.size = current.size;
|
|
2274
|
+
this.applySize(this.size);
|
|
2275
|
+
this.updateAriaSlider();
|
|
2276
|
+
}
|
|
2277
|
+
io.disconnect();
|
|
2278
|
+
return;
|
|
2279
|
+
}
|
|
2280
|
+
});
|
|
2281
|
+
io.observe(this.element);
|
|
2282
|
+
this.teardowns.add(() => io.disconnect());
|
|
2283
|
+
}
|
|
2284
|
+
if (this.persistKey) {
|
|
2285
|
+
this.teardowns.add(installPersist(this, this.persistKey));
|
|
2286
|
+
}
|
|
2287
|
+
this.teardowns.add(
|
|
2288
|
+
installAutoCollapse({
|
|
2289
|
+
ms: opts.autoCollapseAfter,
|
|
2290
|
+
isDestroyed: () => this.destroyed,
|
|
2291
|
+
isDragging: () => this.gesture?.isDragging ?? false,
|
|
2292
|
+
getAllowedIds: () => this.snaps.getAllowedIds().slice(),
|
|
2293
|
+
getActiveId: () => this.activeId,
|
|
2294
|
+
resolveSnap: (id) => this.snaps.findById(id),
|
|
2295
|
+
snapTo: (id) => {
|
|
2296
|
+
void this.snapTo(id);
|
|
2297
|
+
},
|
|
2298
|
+
on: (event, fn) => this.on(event, fn)
|
|
2299
|
+
})
|
|
2300
|
+
);
|
|
2301
|
+
this.teardowns.add(
|
|
2302
|
+
installRoute({
|
|
2303
|
+
routedTo: this.routedTo,
|
|
2304
|
+
closeOnBack: this.closeOnBack,
|
|
2305
|
+
isTopSheet: () => this.isTopSheet,
|
|
2306
|
+
getSize: () => this.size,
|
|
2307
|
+
isDestroyed: () => this.destroyed,
|
|
2308
|
+
close: () => this.close(),
|
|
2309
|
+
on: (event, fn) => this.on(event, fn),
|
|
2310
|
+
sheetId: this.id
|
|
2311
|
+
})
|
|
2312
|
+
);
|
|
2313
|
+
if (this.size > 0) {
|
|
2314
|
+
this.handleOpen();
|
|
2315
|
+
}
|
|
2316
|
+
}
|
|
2317
|
+
get state() {
|
|
2318
|
+
return {
|
|
2319
|
+
size: this.size,
|
|
2320
|
+
activeId: this.activeId,
|
|
2321
|
+
isDragging: this.gesture?.isDragging ?? false,
|
|
2322
|
+
isAnimating: this.animation.isAnimating,
|
|
2323
|
+
progress: this.computeProgress(this.size)
|
|
2324
|
+
};
|
|
2325
|
+
}
|
|
2326
|
+
on(event, fn) {
|
|
2327
|
+
return this.bus.on(event, fn);
|
|
2328
|
+
}
|
|
2329
|
+
/**
|
|
2330
|
+
* Register a plugin. `install` runs synchronously; if it returns a function,
|
|
2331
|
+
* it's captured for teardown during `destroy()`. No-op if already destroyed
|
|
2332
|
+
* — installing into a torn-down engine would leak listeners.
|
|
2333
|
+
*
|
|
2334
|
+
* Throws from `plugin.install` are caught and rethrown via `queueMicrotask`
|
|
2335
|
+
* so a buggy plugin can't take the engine down with it: the engine stays
|
|
2336
|
+
* usable, sibling plugins still install, and the error surfaces in dev tools
|
|
2337
|
+
* on the next tick instead of being swallowed. Mirrors the per-fn isolation
|
|
2338
|
+
* model used for teardowns.
|
|
2339
|
+
*
|
|
2340
|
+
* Plugins that accept the second `scope` argument get transactional cleanup:
|
|
2341
|
+
* push partial-install cleanups via `scope.add(fn)` and the engine drains
|
|
2342
|
+
* them on throw, OR merges them into destroy-time teardowns on success.
|
|
2343
|
+
*/
|
|
2344
|
+
use(plugin) {
|
|
2345
|
+
if (this.destroyed) {
|
|
2346
|
+
console.warn(
|
|
2347
|
+
`[BottomSheet] use("${plugin.name}") called on destroyed engine \u2014 ignored.`
|
|
2348
|
+
);
|
|
2349
|
+
return this;
|
|
2350
|
+
}
|
|
2351
|
+
const scoped = [];
|
|
2352
|
+
const scope = {
|
|
2353
|
+
// typeof check — not just truthiness — so a JS-only plugin pushing a
|
|
2354
|
+
// bogus value is silently rejected instead of queueing a confusing
|
|
2355
|
+
// TypeError on destroy() drain.
|
|
2356
|
+
add: (fn) => {
|
|
2357
|
+
if (typeof fn === "function") scoped.push(fn);
|
|
2358
|
+
}
|
|
2359
|
+
};
|
|
2360
|
+
let teardown;
|
|
2361
|
+
try {
|
|
2362
|
+
teardown = plugin.install(this, scope);
|
|
2363
|
+
} catch (err) {
|
|
2364
|
+
for (let i = scoped.length - 1; i >= 0; i--) {
|
|
2365
|
+
try {
|
|
2366
|
+
scoped[i]();
|
|
2367
|
+
} catch (cleanupErr) {
|
|
2368
|
+
queueMicrotask(() => {
|
|
2369
|
+
throw cleanupErr;
|
|
2370
|
+
});
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2373
|
+
queueMicrotask(() => {
|
|
2374
|
+
throw err;
|
|
2375
|
+
});
|
|
2376
|
+
return this;
|
|
2377
|
+
}
|
|
2378
|
+
for (const fn of scoped) this.teardowns.add(fn);
|
|
2379
|
+
if (typeof teardown === "function") {
|
|
2380
|
+
this.teardowns.add(teardown);
|
|
2381
|
+
}
|
|
2382
|
+
return this;
|
|
2383
|
+
}
|
|
2384
|
+
/**
|
|
2385
|
+
* Replace the linked-sheets list at runtime. Use when peer engines aren't
|
|
2386
|
+
* available at construction time (mounted later in the tree). The wiring
|
|
2387
|
+
* to the `open` event is independent of this list — it reads `linkedSheets`
|
|
2388
|
+
* lazily on emit, so this setter is a simple field swap.
|
|
2389
|
+
*/
|
|
2390
|
+
setLinkedSheets(sheets) {
|
|
2391
|
+
if (this.destroyed) return;
|
|
2392
|
+
this.linkedSheets = sheets;
|
|
2393
|
+
}
|
|
2394
|
+
/** Read-only view of the current allow-list, for cross-sheet target negotiation. */
|
|
2395
|
+
getAllowedIds() {
|
|
2396
|
+
return this.snaps.getAllowedIds().slice();
|
|
2397
|
+
}
|
|
2398
|
+
/**
|
|
2399
|
+
* Animate to a snap point by id. Resolves when the animation completes.
|
|
2400
|
+
*
|
|
2401
|
+
* Two call shapes:
|
|
2402
|
+
* ```ts
|
|
2403
|
+
* engine.snapTo("full"); // default velocity
|
|
2404
|
+
* engine.snapTo("full", 0.5); // px/ms initial velocity
|
|
2405
|
+
* engine.snapTo("full", { velocity: 0.5, signal }); // abortable
|
|
2406
|
+
* ```
|
|
2407
|
+
*
|
|
2408
|
+
* When `opts.signal` is provided, calling `controller.abort()` cancels the
|
|
2409
|
+
* in-flight tween/spring and the returned promise resolves without firing
|
|
2410
|
+
* `snap`. The captured `AbortSignal` is checked after each await so external
|
|
2411
|
+
* aborts ride the same cycle invariant as internal ones (resize, destroy,
|
|
2412
|
+
* concurrent snapTo).
|
|
2413
|
+
*/
|
|
2414
|
+
async snapTo(id, velocityOrOpts = 0, _skipBeforeSnap = false) {
|
|
2415
|
+
if (this.destroyed) return;
|
|
2416
|
+
const opts = typeof velocityOrOpts === "object" ? velocityOrOpts : { velocity: velocityOrOpts };
|
|
2417
|
+
const velocityPxPerMs = opts.velocity ?? 0;
|
|
2418
|
+
const externalSignal = opts.signal;
|
|
2419
|
+
if (externalSignal?.aborted) return;
|
|
2420
|
+
const target = this.snaps.findById(id);
|
|
2421
|
+
if (!target) {
|
|
2422
|
+
console.warn(`[BottomSheet] unknown snap id: ${id}`);
|
|
2423
|
+
return;
|
|
2424
|
+
}
|
|
2425
|
+
if (!this.snaps.getAllowedIds().includes(id)) {
|
|
2426
|
+
console.warn(`[BottomSheet] snap "${id}" is not in allowed list`);
|
|
2427
|
+
return;
|
|
2428
|
+
}
|
|
2429
|
+
if (!_skipBeforeSnap && this.emitBeforeSnap(target, this.activeId)) {
|
|
2430
|
+
return;
|
|
2431
|
+
}
|
|
2432
|
+
const signal = this.newCycle();
|
|
2433
|
+
const onExternalAbort = () => {
|
|
2434
|
+
this.animation.cancel();
|
|
2435
|
+
this.newCycle();
|
|
2436
|
+
};
|
|
2437
|
+
externalSignal?.addEventListener("abort", onExternalAbort, { once: true });
|
|
2438
|
+
const wasClosed = this.size === 0;
|
|
2439
|
+
const previousId = this.activeId;
|
|
2440
|
+
const previousSize = this.size;
|
|
2441
|
+
this.scrollCache.cache(previousId, previousSize, target.size);
|
|
2442
|
+
this.activeId = id;
|
|
2443
|
+
if (this.animation.viewTransitionsAvailable) {
|
|
2444
|
+
this.currentViewTransition?.skipTransition?.();
|
|
2445
|
+
const vt = document.startViewTransition(() => {
|
|
2446
|
+
this.applySize(target.size);
|
|
2447
|
+
});
|
|
2448
|
+
this.currentViewTransition = vt;
|
|
2449
|
+
try {
|
|
2450
|
+
await vt.finished;
|
|
2451
|
+
} catch {
|
|
2452
|
+
}
|
|
2453
|
+
if (this.currentViewTransition === vt) this.currentViewTransition = null;
|
|
2454
|
+
} else {
|
|
2455
|
+
await this.animation.animateTo(target.size, velocityPxPerMs);
|
|
2456
|
+
}
|
|
2457
|
+
externalSignal?.removeEventListener("abort", onExternalAbort);
|
|
2458
|
+
if (signal.aborted) return;
|
|
2459
|
+
this.scrollCache.restore(id, previousSize, target.size);
|
|
2460
|
+
this.updateAriaSlider();
|
|
2461
|
+
this.emit("snap", { id, size: target.size });
|
|
2462
|
+
if (wasClosed && target.size > 0) {
|
|
2463
|
+
this.emit("open", { id });
|
|
2464
|
+
this.handleOpen();
|
|
2465
|
+
notifyLinkedSheets(this.linkedSheets, this);
|
|
2466
|
+
}
|
|
2467
|
+
if (target.size === 0) {
|
|
2468
|
+
this.emit("close", void 0);
|
|
2469
|
+
this.handleClose();
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
/**
|
|
2473
|
+
* Drive the sheet to an arbitrary px size, bypassing snap matching.
|
|
2474
|
+
* Useful for preview scrubs, animation chaining, or programmatic gestures.
|
|
2475
|
+
* Animates with the configured spring/tween. Resolves when the animation
|
|
2476
|
+
* completes. Does NOT update `activeId` — call `snapTo(id)` after to commit.
|
|
2477
|
+
*
|
|
2478
|
+
* Unlike `snapTo`, this does not fire `before-snap` (it isn't a snap commit)
|
|
2479
|
+
* nor `snap` (the sheet isn't settling to a known snap). `progress` events
|
|
2480
|
+
* still fire via applySize. Target is clamped to `[0, maxAxisSize]`.
|
|
2481
|
+
*/
|
|
2482
|
+
async dragTo(targetSize, velocityOrOpts = 0) {
|
|
2483
|
+
if (this.destroyed) return;
|
|
2484
|
+
const opts = typeof velocityOrOpts === "object" ? velocityOrOpts : { velocity: velocityOrOpts };
|
|
2485
|
+
const velocityPxPerMs = opts.velocity ?? 0;
|
|
2486
|
+
const externalSignal = opts.signal;
|
|
2487
|
+
if (externalSignal?.aborted) return;
|
|
2488
|
+
this.newCycle();
|
|
2489
|
+
const onExternalAbort = () => {
|
|
2490
|
+
this.animation.cancel();
|
|
2491
|
+
this.newCycle();
|
|
2492
|
+
};
|
|
2493
|
+
externalSignal?.addEventListener("abort", onExternalAbort, { once: true });
|
|
2494
|
+
const clamped = Math.max(0, Math.min(targetSize, this.snaps.getMaxAxisSize()));
|
|
2495
|
+
await this.animation.animateTo(clamped, velocityPxPerMs);
|
|
2496
|
+
externalSignal?.removeEventListener("abort", onExternalAbort);
|
|
2497
|
+
if (this.destroyed || externalSignal?.aborted) return;
|
|
2498
|
+
}
|
|
2499
|
+
open(id) {
|
|
2500
|
+
if (this.destroyed) return Promise.resolve();
|
|
2501
|
+
const target = id ?? this.snaps.getAllowedIds().find((a) => a !== "closed") ?? this.activeId;
|
|
2502
|
+
return this.snapTo(target);
|
|
2503
|
+
}
|
|
2504
|
+
close() {
|
|
2505
|
+
if (this.destroyed) return Promise.resolve();
|
|
2506
|
+
const closedId = this.snapPointsRaw.find((p) => p.id === "closed")?.id ?? this.snaps.getAllowedIds()[0];
|
|
2507
|
+
return this.snapTo(closedId ?? this.activeId);
|
|
2508
|
+
}
|
|
2509
|
+
setAllowed(ids, snap) {
|
|
2510
|
+
if (this.destroyed) return;
|
|
2511
|
+
const willSnap = snap !== void 0 && ids.includes(snap) || !ids.includes(this.activeId) && ids.length > 0;
|
|
2512
|
+
if (!willSnap) {
|
|
2513
|
+
this.animation.cancel();
|
|
2514
|
+
this.newCycle();
|
|
2515
|
+
}
|
|
2516
|
+
this.snaps.setAllowedIds(ids);
|
|
2517
|
+
this.updateAriaSlider();
|
|
2518
|
+
if (snap && ids.includes(snap)) {
|
|
2519
|
+
void this.snapTo(
|
|
2520
|
+
snap,
|
|
2521
|
+
0,
|
|
2522
|
+
/* internal */
|
|
2523
|
+
true
|
|
2524
|
+
);
|
|
2525
|
+
} else if (!ids.includes(this.activeId)) {
|
|
2526
|
+
const fallback = ids[0];
|
|
2527
|
+
if (fallback) void this.snapTo(
|
|
2528
|
+
fallback,
|
|
2529
|
+
0,
|
|
2530
|
+
/* internal */
|
|
2531
|
+
true
|
|
2532
|
+
);
|
|
2533
|
+
}
|
|
2534
|
+
}
|
|
2535
|
+
setBackdropRange(range) {
|
|
2536
|
+
this.scrim.setBackdropRange(range, () => this.applySize(this.size));
|
|
2537
|
+
}
|
|
2538
|
+
setScreenRange(range) {
|
|
2539
|
+
this.scrim.setScreenRange(range, () => this.applySize(this.size));
|
|
2540
|
+
}
|
|
2541
|
+
setScrimColor(color) {
|
|
2542
|
+
this.scrim.setScrimColor(color);
|
|
2543
|
+
}
|
|
2544
|
+
setScrimBlur(blur) {
|
|
2545
|
+
this.scrim.setScrimBlur(blur);
|
|
2546
|
+
}
|
|
2547
|
+
setScrimInteractive(interactive) {
|
|
2548
|
+
this.scrim.setScrimInteractive(interactive);
|
|
2549
|
+
}
|
|
2550
|
+
/**
|
|
2551
|
+
* Batch-update multiple scrim fields in one call. Apply order is:
|
|
2552
|
+
* 1. `preset` — fills baseline color/blur/range/interactive.
|
|
2553
|
+
* 2. `mode` — re-positions the scrim (fixed inset / clear / hide).
|
|
2554
|
+
* 3. `enabled` — flips the opacity contribution on/off.
|
|
2555
|
+
* 4. `tapToClose` — installs/tears the click listener.
|
|
2556
|
+
* 5. `color` / `blur` / `interactive` / `range` — explicit overrides.
|
|
2557
|
+
* 6. ONE `applySize` so the per-frame opacity stays consistent.
|
|
2558
|
+
*
|
|
2559
|
+
* Performance: collecting changes here (vs calling individual setters)
|
|
2560
|
+
* avoids repeated `applySize` calls during a multi-field swap. Setters
|
|
2561
|
+
* called here suppress their own internal `applySize` either by passing
|
|
2562
|
+
* through fast-paths (mode/enabled no-op when value unchanged) or by
|
|
2563
|
+
* being visual-only (color/blur/interactive don't influence opacity).
|
|
2564
|
+
*/
|
|
2565
|
+
setScrim(opts) {
|
|
2566
|
+
this.scrim.setScrim(opts, () => this.applySize(this.size));
|
|
2567
|
+
}
|
|
2568
|
+
setScrimMode(mode) {
|
|
2569
|
+
this.scrim.setScrimMode(mode, () => this.applySize(this.size));
|
|
2570
|
+
}
|
|
2571
|
+
setScrimTapToClose(enabled) {
|
|
2572
|
+
this.scrim.setScrimTapToClose(enabled);
|
|
2573
|
+
}
|
|
2574
|
+
setScrimEnabled(enabled) {
|
|
2575
|
+
this.scrim.setScrimEnabled(enabled, () => this.applySize(this.size));
|
|
2576
|
+
}
|
|
2577
|
+
setScrimOverlay(opts) {
|
|
2578
|
+
return this.scrim.setScrimOverlay(opts);
|
|
2579
|
+
}
|
|
2580
|
+
/**
|
|
2581
|
+
* Read-only snapshot of scrim state. Use the `setScrim*` setters to mutate.
|
|
2582
|
+
* Stable public API — internals (e.g. cached opacity sentinels) are NOT
|
|
2583
|
+
* exposed so consumers can't accidentally couple to ScrimController shape.
|
|
2584
|
+
*/
|
|
2585
|
+
getScrimState() {
|
|
2586
|
+
return { mode: this.scrim.scrimMode, enabled: this.scrim.scrimEnabled };
|
|
2587
|
+
}
|
|
2588
|
+
setSnapPoints(points, allowed) {
|
|
2589
|
+
if (this.destroyed) return;
|
|
2590
|
+
this.animation.cancel();
|
|
2591
|
+
this.newCycle();
|
|
2592
|
+
this.snapPointsRaw = points;
|
|
2593
|
+
auditVhUsage(points);
|
|
2594
|
+
this.snaps.setRaw(points);
|
|
2595
|
+
if (allowed) this.snaps.setAllowedIds(allowed);
|
|
2596
|
+
this.scrollCache.clear();
|
|
2597
|
+
this.scrim.invalidateOpacityCache();
|
|
2598
|
+
const current = this.snaps.findById(this.activeId);
|
|
2599
|
+
if (current) {
|
|
2600
|
+
this.applySize(current.size);
|
|
2601
|
+
}
|
|
2602
|
+
this.updateAriaSlider();
|
|
2603
|
+
}
|
|
2604
|
+
destroy() {
|
|
2605
|
+
this.destroyed = true;
|
|
2606
|
+
this.currentAbort.abort();
|
|
2607
|
+
this.currentViewTransition?.skipTransition?.();
|
|
2608
|
+
this.currentViewTransition = null;
|
|
2609
|
+
this.bus.clear();
|
|
2610
|
+
this.animation.cancel();
|
|
2611
|
+
this.gesture?.forceClearDragState();
|
|
2612
|
+
this.teardowns.drain();
|
|
2613
|
+
this.scrim.destroy();
|
|
2614
|
+
this.animation.destroy();
|
|
2615
|
+
this.lifecycle.destroy();
|
|
2616
|
+
if (this.backdrop) {
|
|
2617
|
+
this.backdrop.style.opacity = "";
|
|
2618
|
+
this.backdrop.style.pointerEvents = "";
|
|
2619
|
+
}
|
|
2620
|
+
if (this.screenComponent) {
|
|
2621
|
+
this.screenComponent.style.opacity = "";
|
|
2622
|
+
this.screenComponent.style.display = "";
|
|
2623
|
+
}
|
|
2624
|
+
this.sizeWriteSentinel.invalidate();
|
|
2625
|
+
this.progressWriteSentinel.invalidate();
|
|
2626
|
+
}
|
|
2627
|
+
emit(event, payload) {
|
|
2628
|
+
this.bus.emit(event, payload);
|
|
2629
|
+
}
|
|
2630
|
+
/**
|
|
2631
|
+
* Abort the current cycle's signal and replace the controller. Returns the
|
|
2632
|
+
* fresh signal so the caller can capture it locally before any await — when
|
|
2633
|
+
* a future newCycle() / destroy aborts our controller, the captured signal
|
|
2634
|
+
* flips to aborted and post-await guards short-circuit.
|
|
2635
|
+
*/
|
|
2636
|
+
newCycle() {
|
|
2637
|
+
this.currentAbort.abort();
|
|
2638
|
+
this.currentAbort = new AbortController();
|
|
2639
|
+
return this.currentAbort.signal;
|
|
2640
|
+
}
|
|
2641
|
+
/**
|
|
2642
|
+
* Fire `before-snap` and report whether a listener cancelled it. The
|
|
2643
|
+
* `cancel()` callback exposed to listeners is frozen after the synchronous
|
|
2644
|
+
* emit phase so async callers can't silently miss — they get a console
|
|
2645
|
+
* warning. Returns `true` when cancelled so callers can early-return /
|
|
2646
|
+
* spring-back without re-implementing the cancel/frozen pattern.
|
|
2647
|
+
*/
|
|
2648
|
+
emitBeforeSnap(target, previousId) {
|
|
2649
|
+
let cancelled = false;
|
|
2650
|
+
let frozen = false;
|
|
2651
|
+
this.emit("before-snap", {
|
|
2652
|
+
id: target.id,
|
|
2653
|
+
size: target.size,
|
|
2654
|
+
previousId,
|
|
2655
|
+
cancel: () => {
|
|
2656
|
+
if (frozen) {
|
|
2657
|
+
console.warn(
|
|
2658
|
+
"[BottomSheet] before-snap.cancel() called asynchronously \u2014 ignored. cancel() must be invoked synchronously inside the listener."
|
|
2659
|
+
);
|
|
2660
|
+
return;
|
|
2661
|
+
}
|
|
2662
|
+
cancelled = true;
|
|
2663
|
+
}
|
|
2664
|
+
});
|
|
2665
|
+
frozen = true;
|
|
2666
|
+
return cancelled;
|
|
2667
|
+
}
|
|
2668
|
+
recomputeSnaps() {
|
|
2669
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2670
|
+
for (const p of this.snapPointsRaw) {
|
|
2671
|
+
if (seen.has(p.id)) {
|
|
2672
|
+
console.warn(
|
|
2673
|
+
`[BottomSheet] duplicate snap id "${p.id}" \u2014 only the first occurrence is reachable.`
|
|
2674
|
+
);
|
|
2675
|
+
}
|
|
2676
|
+
seen.add(p.id);
|
|
2677
|
+
}
|
|
2678
|
+
this.snaps.setRaw(this.snapPointsRaw);
|
|
2679
|
+
this.scrim.invalidateOpacityCache();
|
|
2680
|
+
}
|
|
2681
|
+
/**
|
|
2682
|
+
* Memoised `allowedRange()` — delegates to SnapResolver which owns the cache.
|
|
2683
|
+
* Engine wrapper kept so existing call sites don't all need updating;
|
|
2684
|
+
* resolver-level memoization survives across method invocations.
|
|
2685
|
+
*/
|
|
2686
|
+
getAllowedRange() {
|
|
2687
|
+
return this.snaps.getAllowedRange();
|
|
2688
|
+
}
|
|
2689
|
+
/**
|
|
2690
|
+
* Write ARIA slider value attributes onto the handle so the engine itself
|
|
2691
|
+
* is the source of truth for screen-reader announcements. Called from the
|
|
2692
|
+
* constructor and from every code path that mutates `activeId` /
|
|
2693
|
+
* `allowedIds`. WAI-ARIA 1.2 requires `aria-valuenow` for `role="slider"` —
|
|
2694
|
+
* without it screen readers can't announce drag/snap progress to keyboard
|
|
2695
|
+
* + AT users (WCAG 4.1.2 Name, Role, Value).
|
|
2696
|
+
*
|
|
2697
|
+
* Engine does NOT set `role="slider"` itself — that's the adapter's or
|
|
2698
|
+
* consumer markup's responsibility. We only decorate with dynamic values.
|
|
2699
|
+
*
|
|
2700
|
+
* Empty allowed-list is malformed input (settable via `setAllowed([])`);
|
|
2701
|
+
* short-circuit to remove ARIA value attributes so screen readers see a
|
|
2702
|
+
* no-value slider rather than a degenerate `min=max=now=0` (WAI-ARIA 1.2
|
|
2703
|
+
* requires `valuemax > valuemin` for `role="slider"`).
|
|
2704
|
+
*/
|
|
2705
|
+
updateAriaSlider() {
|
|
2706
|
+
this.aria.setValue(this.snaps.getAllowedIds(), this.activeId);
|
|
2707
|
+
}
|
|
2708
|
+
registerInStack() {
|
|
2709
|
+
this.teardowns.add(sheetStack.push({
|
|
2710
|
+
id: this.id,
|
|
2711
|
+
setZIndex: (z) => {
|
|
2712
|
+
this.element.style.zIndex = String(z);
|
|
2713
|
+
if (this.backdrop) this.backdrop.style.zIndex = String(z - 1);
|
|
2714
|
+
},
|
|
2715
|
+
setIsTop: (isTop) => {
|
|
2716
|
+
this.isTopSheet = isTop;
|
|
2717
|
+
if (this.backdrop) {
|
|
2718
|
+
this.backdrop.style.display = isTop ? "" : "none";
|
|
2719
|
+
}
|
|
2720
|
+
}
|
|
2721
|
+
}));
|
|
2722
|
+
}
|
|
2723
|
+
attach() {
|
|
2724
|
+
this.gesture = new GestureController({
|
|
2725
|
+
handle: this.handle,
|
|
2726
|
+
element: this.element,
|
|
2727
|
+
mode: this.mode,
|
|
2728
|
+
getRoot: () => this.rootEl,
|
|
2729
|
+
// Single coalesced reader — narrows the DI surface and removes 3 getter
|
|
2730
|
+
// dispatches per onMove frame at 60-120Hz. Returns a SHARED buffer
|
|
2731
|
+
// (this.dragContextBuf) — see field JSDoc for the retention contract.
|
|
2732
|
+
// Inner `range` object is mutated in place too so callers caching its
|
|
2733
|
+
// identity between frames stay valid.
|
|
2734
|
+
getDragContext: () => {
|
|
2735
|
+
const buf = this.dragContextBuf;
|
|
2736
|
+
const r = this.getAllowedRange();
|
|
2737
|
+
buf.size = this.size;
|
|
2738
|
+
buf.maxAxisSize = this.snaps.getMaxAxisSize();
|
|
2739
|
+
buf.range.min = r.min;
|
|
2740
|
+
buf.range.max = r.max;
|
|
2741
|
+
buf.rubberBandEnabled = this.rubberBandEnabled;
|
|
2742
|
+
return buf;
|
|
2743
|
+
},
|
|
2744
|
+
cancelAnimation: () => this.animation.cancel(),
|
|
2745
|
+
applySize: (size) => this.applySize(size),
|
|
2746
|
+
animateTo: (size, velocity) => this.animation.animateTo(size, velocity),
|
|
2747
|
+
settleAfterDrag: (delta, velocity, kind) => this.settleAfterDrag(delta, velocity, kind),
|
|
2748
|
+
emit: (event, payload) => this.emit(event, payload),
|
|
2749
|
+
listenerCount: (event) => this.bus.listenerCount(event)
|
|
2750
|
+
});
|
|
2751
|
+
this.teardowns.add(this.gesture.install());
|
|
2752
|
+
try {
|
|
2753
|
+
this.attachFeatures();
|
|
2754
|
+
} catch (err) {
|
|
2755
|
+
this.teardowns.drain();
|
|
2756
|
+
try {
|
|
2757
|
+
this.scrim.destroy();
|
|
2758
|
+
} catch (destroyErr) {
|
|
2759
|
+
queueMicrotask(() => {
|
|
2760
|
+
throw destroyErr;
|
|
2761
|
+
});
|
|
2762
|
+
}
|
|
2763
|
+
try {
|
|
2764
|
+
this.animation.destroy();
|
|
2765
|
+
} catch (destroyErr) {
|
|
2766
|
+
queueMicrotask(() => {
|
|
2767
|
+
throw destroyErr;
|
|
2768
|
+
});
|
|
2769
|
+
}
|
|
2770
|
+
try {
|
|
2771
|
+
this.lifecycle.destroy();
|
|
2772
|
+
} catch (destroyErr) {
|
|
2773
|
+
queueMicrotask(() => {
|
|
2774
|
+
throw destroyErr;
|
|
2775
|
+
});
|
|
2776
|
+
}
|
|
2777
|
+
this.destroyed = true;
|
|
2778
|
+
throw err;
|
|
2779
|
+
}
|
|
2780
|
+
}
|
|
2781
|
+
attachFeatures() {
|
|
2782
|
+
if (this.scrollContainer) {
|
|
2783
|
+
this.teardowns.add(installContentSwipe({
|
|
2784
|
+
container: this.scrollContainer,
|
|
2785
|
+
isDragging: () => this.gesture?.isDragging ?? false,
|
|
2786
|
+
isAnimating: () => this.animation.isAnimating,
|
|
2787
|
+
getAllowedIds: () => this.snaps.getAllowedIds(),
|
|
2788
|
+
getActiveId: () => this.activeId,
|
|
2789
|
+
snapTo: (id) => {
|
|
2790
|
+
void this.snapTo(id);
|
|
2791
|
+
}
|
|
2792
|
+
}));
|
|
2793
|
+
}
|
|
2794
|
+
if (typeof window !== "undefined") {
|
|
2795
|
+
this.teardowns.add(installResizeObserver({
|
|
2796
|
+
element: this.element,
|
|
2797
|
+
getMode: () => this.mode,
|
|
2798
|
+
isDestroyed: () => this.destroyed,
|
|
2799
|
+
isDragging: () => this.gesture?.isDragging ?? false,
|
|
2800
|
+
resolveActiveSnap: () => this.snaps.findById(this.activeId),
|
|
2801
|
+
getMaxAxisSize: () => this.snaps.getMaxAxisSize(),
|
|
2802
|
+
getSize: () => this.size,
|
|
2803
|
+
setMaxAxisSize: (size) => {
|
|
2804
|
+
this.snaps.setMaxAxisSize(size);
|
|
2805
|
+
},
|
|
2806
|
+
setSize: (size) => {
|
|
2807
|
+
this.size = size;
|
|
2808
|
+
},
|
|
2809
|
+
recomputeSnaps: () => this.recomputeSnaps(),
|
|
2810
|
+
applySize: (size) => this.applySize(size),
|
|
2811
|
+
cancelInFlight: () => {
|
|
2812
|
+
this.animation.cancel();
|
|
2813
|
+
},
|
|
2814
|
+
newCycle: () => {
|
|
2815
|
+
this.newCycle();
|
|
2816
|
+
}
|
|
2817
|
+
}));
|
|
2818
|
+
if (this.lifecycle.closeOnEscape) {
|
|
2819
|
+
const onKey = (e) => {
|
|
2820
|
+
if (this.destroyed) return;
|
|
2821
|
+
if (e.key === "Escape" && this.isTopSheet && this.size > 0) {
|
|
2822
|
+
void this.close();
|
|
2823
|
+
}
|
|
2824
|
+
};
|
|
2825
|
+
document.addEventListener("keydown", onKey);
|
|
2826
|
+
this.teardowns.add(
|
|
2827
|
+
() => document.removeEventListener("keydown", onKey)
|
|
2828
|
+
);
|
|
2829
|
+
}
|
|
2830
|
+
this.teardowns.add(installVisualViewport({
|
|
2831
|
+
element: this.element,
|
|
2832
|
+
isVerticalAxis: () => this.mode === "bottom" || this.mode === "top",
|
|
2833
|
+
isDestroyed: () => this.destroyed,
|
|
2834
|
+
isDragging: () => this.gesture?.isDragging ?? false,
|
|
2835
|
+
recomputeSnaps: () => this.recomputeSnaps(),
|
|
2836
|
+
resolveActiveSnap: () => this.snaps.findById(this.activeId),
|
|
2837
|
+
getMaxAxisSize: () => this.snaps.getMaxAxisSize(),
|
|
2838
|
+
getSize: () => this.size,
|
|
2839
|
+
setMaxAxisSize: (size) => {
|
|
2840
|
+
this.snaps.setMaxAxisSize(size);
|
|
2841
|
+
},
|
|
2842
|
+
setSize: (size) => {
|
|
2843
|
+
this.size = size;
|
|
2844
|
+
},
|
|
2845
|
+
applySize: (size) => this.applySize(size)
|
|
2846
|
+
}));
|
|
2847
|
+
}
|
|
2848
|
+
this.teardowns.add(installSliderKeyboard({
|
|
2849
|
+
handle: this.handle,
|
|
2850
|
+
mode: this.mode,
|
|
2851
|
+
isDestroyed: () => this.destroyed,
|
|
2852
|
+
getAllowedIds: () => this.snaps.getAllowedIds(),
|
|
2853
|
+
getActiveId: () => this.activeId,
|
|
2854
|
+
snapTo: (id) => {
|
|
2855
|
+
void this.snapTo(id);
|
|
2856
|
+
}
|
|
2857
|
+
}));
|
|
2858
|
+
this.scrim.attach();
|
|
2859
|
+
}
|
|
2860
|
+
settleAfterDrag(delta, velocity, kind = "touch") {
|
|
2861
|
+
const signal = this.newCycle();
|
|
2862
|
+
const target = this.snaps.findDragSettleTarget({
|
|
2863
|
+
delta,
|
|
2864
|
+
velocity,
|
|
2865
|
+
pointerKind: kind,
|
|
2866
|
+
size: this.size,
|
|
2867
|
+
activeId: this.activeId,
|
|
2868
|
+
flickVelocity: this.flickVelocity,
|
|
2869
|
+
dragThreshold: this.dragThreshold
|
|
2870
|
+
});
|
|
2871
|
+
if (!target) return;
|
|
2872
|
+
const previousId = this.activeId;
|
|
2873
|
+
if (this.emitBeforeSnap(target, previousId)) {
|
|
2874
|
+
const restore = this.snaps.findById(previousId);
|
|
2875
|
+
if (restore) {
|
|
2876
|
+
void this.animation.animateTo(restore.size, 0);
|
|
2877
|
+
}
|
|
2878
|
+
return;
|
|
2879
|
+
}
|
|
2880
|
+
const previousSize = this.size;
|
|
2881
|
+
this.scrollCache.cache(previousId, previousSize, target.size);
|
|
2882
|
+
this.activeId = target.id;
|
|
2883
|
+
void this.animation.animateTo(target.size, velocity).then(() => {
|
|
2884
|
+
if (signal.aborted) return;
|
|
2885
|
+
this.scrollCache.restore(target.id, previousSize, target.size);
|
|
2886
|
+
this.updateAriaSlider();
|
|
2887
|
+
this.emit("snap", { id: target.id, size: target.size });
|
|
2888
|
+
this.haptic();
|
|
2889
|
+
if (previousId === "closed" && target.size > 0) {
|
|
2890
|
+
this.emit("open", { id: target.id });
|
|
2891
|
+
this.handleOpen();
|
|
2892
|
+
}
|
|
2893
|
+
if (target.size === 0) {
|
|
2894
|
+
this.emit("close", void 0);
|
|
2895
|
+
this.handleClose();
|
|
2896
|
+
}
|
|
2897
|
+
});
|
|
2898
|
+
}
|
|
2899
|
+
/** Trigger a brief haptic tick (iOS Safari + Android Chrome support). */
|
|
2900
|
+
haptic() {
|
|
2901
|
+
if (typeof navigator !== "undefined" && "vibrate" in navigator) {
|
|
2902
|
+
try {
|
|
2903
|
+
navigator.vibrate(HAPTIC_DURATION_MS);
|
|
2904
|
+
} catch {
|
|
2905
|
+
}
|
|
2906
|
+
}
|
|
2907
|
+
}
|
|
2908
|
+
applySize(size) {
|
|
2909
|
+
this.size = size;
|
|
2910
|
+
const offset = this.snaps.getMaxAxisSize() - size;
|
|
2911
|
+
const style = this.element.style;
|
|
2912
|
+
style.transform = this.transformTemplate(offset);
|
|
2913
|
+
if (this.sizeWriteSentinel.shouldWrite(size, SIZE_WRITE_EPSILON)) {
|
|
2914
|
+
style.setProperty("--bs-size", `${size}px`);
|
|
2915
|
+
}
|
|
2916
|
+
const progress = this.computeProgress(size);
|
|
2917
|
+
const progressChanged = this.progressWriteSentinel.shouldWrite(
|
|
2918
|
+
progress,
|
|
2919
|
+
OPACITY_WRITE_EPSILON
|
|
2920
|
+
);
|
|
2921
|
+
if (progressChanged) {
|
|
2922
|
+
style.setProperty("--bs-progress", String(progress));
|
|
2923
|
+
}
|
|
2924
|
+
this.scrim.applyOpacity(progress, progressChanged);
|
|
2925
|
+
if (progressChanged && this.bus.listenerCount("progress") > 0) {
|
|
2926
|
+
this.progressPayload.value = progress;
|
|
2927
|
+
this.progressPayload.size = size;
|
|
2928
|
+
this.emit("progress", this.progressPayload);
|
|
2929
|
+
}
|
|
2930
|
+
}
|
|
2931
|
+
computeProgress(size) {
|
|
2932
|
+
const { min, max } = this.getAllowedRange();
|
|
2933
|
+
if (max <= min) return 0;
|
|
2934
|
+
return Math.min(Math.max((size - min) / (max - min), 0), 1);
|
|
2935
|
+
}
|
|
2936
|
+
handleOpen() {
|
|
2937
|
+
try {
|
|
2938
|
+
this.lifecycle.install();
|
|
2939
|
+
} catch (err) {
|
|
2940
|
+
if (!this.destroyed) this.newCycle();
|
|
2941
|
+
this.handleClose();
|
|
2942
|
+
this.applySize(0);
|
|
2943
|
+
this.activeId = this.snapPointsRaw.find((p) => p.id === "closed")?.id ?? this.activeId;
|
|
2944
|
+
this.size = 0;
|
|
2945
|
+
queueMicrotask(() => {
|
|
2946
|
+
throw err;
|
|
2947
|
+
});
|
|
2948
|
+
}
|
|
2949
|
+
}
|
|
2950
|
+
handleClose() {
|
|
2951
|
+
this.lifecycle.release();
|
|
2952
|
+
}
|
|
2953
|
+
};
|
|
2954
|
+
|
|
2955
|
+
// src/react/useBottomSheet.ts
|
|
2956
|
+
var useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
|
|
2957
|
+
var SSR_STATE = Object.freeze({
|
|
2958
|
+
size: 0,
|
|
2959
|
+
activeId: "",
|
|
2960
|
+
isDragging: false,
|
|
2961
|
+
isAnimating: false,
|
|
2962
|
+
progress: 0
|
|
2963
|
+
});
|
|
2964
|
+
function useBottomSheet(opts) {
|
|
2965
|
+
const sheetRef = useRef(null);
|
|
2966
|
+
const handleRef = useRef(null);
|
|
2967
|
+
const contentRef = useRef(null);
|
|
2968
|
+
const backdropRef = useRef(null);
|
|
2969
|
+
const screenRef = useRef(null);
|
|
2970
|
+
const engineRef = useRef(null);
|
|
2971
|
+
const subscribersRef = useRef(/* @__PURE__ */ new Set());
|
|
2972
|
+
const cachedSnapshotRef = useRef(SSR_STATE);
|
|
2973
|
+
const optsRef = useRef(opts);
|
|
2974
|
+
optsRef.current = opts;
|
|
2975
|
+
const [, setMounted] = useState(0);
|
|
2976
|
+
useIsomorphicLayoutEffect(() => {
|
|
2977
|
+
if (!sheetRef.current) return;
|
|
2978
|
+
const current = optsRef.current;
|
|
2979
|
+
const engine = new BottomSheetEngine({
|
|
2980
|
+
...current,
|
|
2981
|
+
element: sheetRef.current,
|
|
2982
|
+
handle: handleRef.current ?? void 0,
|
|
2983
|
+
scrollContainer: contentRef.current ?? void 0,
|
|
2984
|
+
backdrop: backdropRef.current ?? void 0,
|
|
2985
|
+
screenComponent: screenRef.current ?? void 0
|
|
2986
|
+
});
|
|
2987
|
+
engineRef.current = engine;
|
|
2988
|
+
cachedSnapshotRef.current = { ...engine.state };
|
|
2989
|
+
setMounted((n) => n + 1);
|
|
2990
|
+
const refresh = () => {
|
|
2991
|
+
cachedSnapshotRef.current = { ...engine.state };
|
|
2992
|
+
subscribersRef.current.forEach((fn) => fn());
|
|
2993
|
+
};
|
|
2994
|
+
const offs = [
|
|
2995
|
+
engine.on("snap", (payload) => {
|
|
2996
|
+
refresh();
|
|
2997
|
+
optsRef.current.onSnap?.(payload.id);
|
|
2998
|
+
}),
|
|
2999
|
+
engine.on("dragstart", refresh),
|
|
3000
|
+
engine.on("dragend", refresh)
|
|
3001
|
+
];
|
|
3002
|
+
return () => {
|
|
3003
|
+
offs.forEach((off) => off());
|
|
3004
|
+
engine.destroy();
|
|
3005
|
+
engineRef.current = null;
|
|
3006
|
+
cachedSnapshotRef.current = SSR_STATE;
|
|
3007
|
+
setMounted((n) => n + 1);
|
|
3008
|
+
};
|
|
3009
|
+
}, []);
|
|
3010
|
+
const allowedKey = (opts.allowed ?? opts.snapPoints.map((p) => p.id)).join(
|
|
3011
|
+
"\0"
|
|
3012
|
+
);
|
|
3013
|
+
const snapKey = opts.snapPoints.map((p) => p.id).join("\0");
|
|
3014
|
+
useEffect(() => {
|
|
3015
|
+
if (!engineRef.current) return;
|
|
3016
|
+
const ids = opts.allowed ? Array.from(opts.allowed) : opts.snapPoints.map((p) => p.id);
|
|
3017
|
+
engineRef.current.setAllowed(ids);
|
|
3018
|
+
}, [allowedKey, snapKey]);
|
|
3019
|
+
const subscribe = useCallback((fn) => {
|
|
3020
|
+
subscribersRef.current.add(fn);
|
|
3021
|
+
return () => subscribersRef.current.delete(fn);
|
|
3022
|
+
}, []);
|
|
3023
|
+
const getSnapshot = useCallback(
|
|
3024
|
+
() => cachedSnapshotRef.current,
|
|
3025
|
+
[]
|
|
3026
|
+
);
|
|
3027
|
+
const getServerSnapshot = useCallback(() => SSR_STATE, []);
|
|
3028
|
+
const state = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
3029
|
+
const snapTo = useCallback(
|
|
3030
|
+
(id) => engineRef.current?.snapTo(id) ?? Promise.resolve(),
|
|
3031
|
+
[]
|
|
3032
|
+
);
|
|
3033
|
+
const open = useCallback(
|
|
3034
|
+
(id) => engineRef.current?.open(id) ?? Promise.resolve(),
|
|
3035
|
+
[]
|
|
3036
|
+
);
|
|
3037
|
+
const close = useCallback(
|
|
3038
|
+
() => engineRef.current?.close() ?? Promise.resolve(),
|
|
3039
|
+
[]
|
|
3040
|
+
);
|
|
3041
|
+
const setAllowed = useCallback((ids, snap) => {
|
|
3042
|
+
engineRef.current?.setAllowed(ids, snap);
|
|
3043
|
+
}, []);
|
|
3044
|
+
const getEngine = useCallback(() => engineRef.current, []);
|
|
3045
|
+
return useMemo(
|
|
3046
|
+
() => ({
|
|
3047
|
+
sheetRef,
|
|
3048
|
+
handleRef,
|
|
3049
|
+
contentRef,
|
|
3050
|
+
backdropRef,
|
|
3051
|
+
screenRef,
|
|
3052
|
+
state,
|
|
3053
|
+
snapTo,
|
|
3054
|
+
open,
|
|
3055
|
+
close,
|
|
3056
|
+
setAllowed,
|
|
3057
|
+
engine: engineRef.current,
|
|
3058
|
+
getEngine
|
|
3059
|
+
}),
|
|
3060
|
+
[state, snapTo, open, close, setAllowed, getEngine]
|
|
3061
|
+
);
|
|
3062
|
+
}
|
|
3063
|
+
var useHasMounted = () => {
|
|
3064
|
+
const [mounted, setMounted] = useState(false);
|
|
3065
|
+
useEffect(() => setMounted(true), []);
|
|
3066
|
+
return mounted;
|
|
3067
|
+
};
|
|
3068
|
+
var BottomSheet = forwardRef(
|
|
3069
|
+
function BottomSheet2(props, ref) {
|
|
3070
|
+
const {
|
|
3071
|
+
backdrop = true,
|
|
3072
|
+
closeOnBackdrop = true,
|
|
3073
|
+
className = "",
|
|
3074
|
+
style,
|
|
3075
|
+
header,
|
|
3076
|
+
children,
|
|
3077
|
+
leftButton,
|
|
3078
|
+
rightButton,
|
|
3079
|
+
screen,
|
|
3080
|
+
onChange,
|
|
3081
|
+
ariaLabel = "Bottom sheet",
|
|
3082
|
+
ariaLabelledBy,
|
|
3083
|
+
noSSR = false,
|
|
3084
|
+
...engineOpts
|
|
3085
|
+
} = props;
|
|
3086
|
+
const mounted = useHasMounted();
|
|
3087
|
+
const {
|
|
3088
|
+
sheetRef,
|
|
3089
|
+
handleRef,
|
|
3090
|
+
contentRef,
|
|
3091
|
+
backdropRef,
|
|
3092
|
+
screenRef,
|
|
3093
|
+
state,
|
|
3094
|
+
snapTo,
|
|
3095
|
+
open,
|
|
3096
|
+
close,
|
|
3097
|
+
setAllowed,
|
|
3098
|
+
getEngine
|
|
3099
|
+
} = useBottomSheet(engineOpts);
|
|
3100
|
+
const isVerticalAxis = (engineOpts.mode ?? "bottom") === "bottom" || (engineOpts.mode ?? "bottom") === "top";
|
|
3101
|
+
const ariaModal = engineOpts.focusTrap === true;
|
|
3102
|
+
const allowedIds = engineOpts.allowed ?? engineOpts.snapPoints.map((p) => p.id);
|
|
3103
|
+
const activeIdx = allowedIds.indexOf(state.activeId);
|
|
3104
|
+
const lastAnnouncedRef = useRef("");
|
|
3105
|
+
const [announce, setAnnounce] = useState("");
|
|
3106
|
+
useEffect(() => {
|
|
3107
|
+
if (state.activeId !== lastAnnouncedRef.current) {
|
|
3108
|
+
lastAnnouncedRef.current = state.activeId;
|
|
3109
|
+
setAnnounce(state.activeId);
|
|
3110
|
+
}
|
|
3111
|
+
}, [state.activeId]);
|
|
3112
|
+
useImperativeHandle(
|
|
3113
|
+
ref,
|
|
3114
|
+
() => ({
|
|
3115
|
+
snapTo,
|
|
3116
|
+
open,
|
|
3117
|
+
close,
|
|
3118
|
+
setAllowed,
|
|
3119
|
+
get state() {
|
|
3120
|
+
return getEngine()?.state ?? state;
|
|
3121
|
+
}
|
|
3122
|
+
}),
|
|
3123
|
+
[snapTo, open, close, setAllowed, getEngine, state]
|
|
3124
|
+
);
|
|
3125
|
+
useEffect(() => {
|
|
3126
|
+
onChange?.(state);
|
|
3127
|
+
}, [state, onChange]);
|
|
3128
|
+
if (noSSR && !mounted) return null;
|
|
3129
|
+
return /* @__PURE__ */ jsxs("div", { className: "bs-root", children: [
|
|
3130
|
+
backdrop && /* @__PURE__ */ jsx(
|
|
3131
|
+
"div",
|
|
3132
|
+
{
|
|
3133
|
+
ref: backdropRef,
|
|
3134
|
+
className: "bs-backdrop",
|
|
3135
|
+
onClick: closeOnBackdrop ? () => void close() : void 0
|
|
3136
|
+
}
|
|
3137
|
+
),
|
|
3138
|
+
screen && /* @__PURE__ */ jsx(
|
|
3139
|
+
"div",
|
|
3140
|
+
{
|
|
3141
|
+
ref: screenRef,
|
|
3142
|
+
className: "bs-screen",
|
|
3143
|
+
children: screen
|
|
3144
|
+
}
|
|
3145
|
+
),
|
|
3146
|
+
/* @__PURE__ */ jsxs(
|
|
3147
|
+
"section",
|
|
3148
|
+
{
|
|
3149
|
+
ref: sheetRef,
|
|
3150
|
+
className: `bs-sheet ${className}`.trim(),
|
|
3151
|
+
style,
|
|
3152
|
+
"data-mode": engineOpts.mode ?? "bottom",
|
|
3153
|
+
"data-active": state.activeId,
|
|
3154
|
+
role: ariaModal ? "dialog" : "region",
|
|
3155
|
+
"aria-modal": ariaModal ? "true" : void 0,
|
|
3156
|
+
"aria-label": ariaLabelledBy ? void 0 : ariaLabel,
|
|
3157
|
+
"aria-labelledby": ariaLabelledBy,
|
|
3158
|
+
children: [
|
|
3159
|
+
leftButton && /* @__PURE__ */ jsx("div", { className: "bs-button-slot", "data-side": "left", children: leftButton }),
|
|
3160
|
+
rightButton && /* @__PURE__ */ jsx("div", { className: "bs-button-slot", "data-side": "right", children: rightButton }),
|
|
3161
|
+
/* @__PURE__ */ jsx(
|
|
3162
|
+
"div",
|
|
3163
|
+
{
|
|
3164
|
+
ref: handleRef,
|
|
3165
|
+
className: "bs-handle",
|
|
3166
|
+
role: "slider",
|
|
3167
|
+
tabIndex: 0,
|
|
3168
|
+
"aria-orientation": isVerticalAxis ? "vertical" : "horizontal",
|
|
3169
|
+
"aria-valuemin": 0,
|
|
3170
|
+
"aria-valuemax": Math.max(0, allowedIds.length - 1),
|
|
3171
|
+
"aria-valuenow": Math.max(0, activeIdx),
|
|
3172
|
+
"aria-valuetext": state.activeId,
|
|
3173
|
+
"aria-label": "Resize sheet",
|
|
3174
|
+
children: typeof header === "function" ? header(state) : header
|
|
3175
|
+
}
|
|
3176
|
+
),
|
|
3177
|
+
/* @__PURE__ */ jsx(
|
|
3178
|
+
"div",
|
|
3179
|
+
{
|
|
3180
|
+
ref: contentRef,
|
|
3181
|
+
className: "bs-content",
|
|
3182
|
+
tabIndex: 0,
|
|
3183
|
+
role: "region",
|
|
3184
|
+
"aria-label": "Sheet content",
|
|
3185
|
+
children
|
|
3186
|
+
}
|
|
3187
|
+
),
|
|
3188
|
+
/* @__PURE__ */ jsx("span", { className: "bs-sr-only", role: "status", "aria-live": "polite", children: announce })
|
|
3189
|
+
]
|
|
3190
|
+
}
|
|
3191
|
+
)
|
|
3192
|
+
] });
|
|
3193
|
+
}
|
|
3194
|
+
);
|
|
3195
|
+
|
|
3196
|
+
// src/core/overlay.ts
|
|
3197
|
+
var OVERLAY_PRESETS = Object.freeze({
|
|
3198
|
+
sheet: Object.freeze({
|
|
3199
|
+
enterAnimation: "slide",
|
|
3200
|
+
exitAnimation: "slide",
|
|
3201
|
+
backdropOpacity: 0.5,
|
|
3202
|
+
backdropFilter: void 0,
|
|
3203
|
+
swipeToClose: true
|
|
3204
|
+
}),
|
|
3205
|
+
dialog: Object.freeze({
|
|
3206
|
+
enterAnimation: "scale",
|
|
3207
|
+
exitAnimation: "scale",
|
|
3208
|
+
backdropOpacity: 0.6,
|
|
3209
|
+
backdropFilter: "blur(8px)",
|
|
3210
|
+
swipeToClose: false
|
|
3211
|
+
}),
|
|
3212
|
+
sidebar: Object.freeze({
|
|
3213
|
+
enterAnimation: "slide",
|
|
3214
|
+
exitAnimation: "slide",
|
|
3215
|
+
backdropOpacity: 0.4,
|
|
3216
|
+
backdropFilter: void 0,
|
|
3217
|
+
swipeToClose: true
|
|
3218
|
+
}),
|
|
3219
|
+
toast: Object.freeze({
|
|
3220
|
+
enterAnimation: "fade",
|
|
3221
|
+
exitAnimation: "fade",
|
|
3222
|
+
backdropOpacity: 0,
|
|
3223
|
+
backdropFilter: void 0,
|
|
3224
|
+
swipeToClose: false
|
|
3225
|
+
})
|
|
3226
|
+
});
|
|
3227
|
+
var closedTransform = (edge) => CLOSED_TRANSFORM[edge];
|
|
3228
|
+
var closedTransformFor = (edge, animation, peekPx) => {
|
|
3229
|
+
switch (animation) {
|
|
3230
|
+
case "fade":
|
|
3231
|
+
return "translate3d(0, 0, 0)";
|
|
3232
|
+
case "scale":
|
|
3233
|
+
return "translate3d(0, 0, 0) scale(0.94)";
|
|
3234
|
+
case "slide":
|
|
3235
|
+
default:
|
|
3236
|
+
if (peekPx !== void 0 && edge === "bottom") {
|
|
3237
|
+
return `translate3d(0, calc(100% - ${peekPx}px), 0)`;
|
|
3238
|
+
}
|
|
3239
|
+
return closedTransform(edge);
|
|
3240
|
+
}
|
|
3241
|
+
};
|
|
3242
|
+
var openTransformFor = (animation) => animation === "scale" ? "translate3d(0, 0, 0) scale(1)" : "translate3d(0, 0, 0)";
|
|
3243
|
+
var OverlayEngine = class {
|
|
3244
|
+
constructor(opts) {
|
|
3245
|
+
__publicField(this, "id", nextInstanceId("ovl"));
|
|
3246
|
+
__publicField(this, "element");
|
|
3247
|
+
__publicField(this, "backdrop");
|
|
3248
|
+
__publicField(this, "edge");
|
|
3249
|
+
__publicField(this, "closeOnEscape");
|
|
3250
|
+
__publicField(this, "closeOnBackdropEnabled");
|
|
3251
|
+
__publicField(this, "focusTrapEnabled");
|
|
3252
|
+
__publicField(this, "initialFocus");
|
|
3253
|
+
__publicField(this, "bodyScrollLockEnabled");
|
|
3254
|
+
__publicField(this, "inertSiblingsEnabled");
|
|
3255
|
+
__publicField(this, "closeOnBackEnabled");
|
|
3256
|
+
__publicField(this, "duration");
|
|
3257
|
+
__publicField(this, "enterEasing");
|
|
3258
|
+
__publicField(this, "exitEasing");
|
|
3259
|
+
__publicField(this, "backdropOpacity");
|
|
3260
|
+
__publicField(this, "backdropFilter");
|
|
3261
|
+
// Definite-assignment: written via assignSwipeFromConfig in the
|
|
3262
|
+
// constructor — TS can't follow the helper, mark the assertion explicit.
|
|
3263
|
+
__publicField(this, "swipeToCloseEnabled");
|
|
3264
|
+
__publicField(this, "swipeThreshold");
|
|
3265
|
+
__publicField(this, "swipeVelocityThreshold");
|
|
3266
|
+
__publicField(this, "enterAnimation");
|
|
3267
|
+
__publicField(this, "exitAnimation");
|
|
3268
|
+
__publicField(this, "mountTarget");
|
|
3269
|
+
__publicField(this, "originalParent", null);
|
|
3270
|
+
__publicField(this, "originalNextSibling", null);
|
|
3271
|
+
__publicField(this, "mounted", false);
|
|
3272
|
+
__publicField(this, "peekRaw");
|
|
3273
|
+
__publicField(this, "peekPx");
|
|
3274
|
+
__publicField(this, "closeOnOutsidePointerEnabled");
|
|
3275
|
+
__publicField(this, "detachOutsidePointer", null);
|
|
3276
|
+
__publicField(this, "detachSwipe", null);
|
|
3277
|
+
__publicField(this, "onOpenCb");
|
|
3278
|
+
__publicField(this, "onCloseCb");
|
|
3279
|
+
__publicField(this, "isOpen_", false);
|
|
3280
|
+
__publicField(this, "isAnimating_", false);
|
|
3281
|
+
__publicField(this, "destroyed", false);
|
|
3282
|
+
// Tracks whether setOverlayChildren ever populated the panel — lets
|
|
3283
|
+
// clearOverlayChildren skip the DOM mutation when nothing was injected
|
|
3284
|
+
// (so the field also acts as a zero-allocation feature flag).
|
|
3285
|
+
__publicField(this, "hasInjectedChildren", false);
|
|
3286
|
+
// Bumped on every open/close. awaitTransition captures it and ignores
|
|
3287
|
+
// transitionend/timeout fired by older cycles — prevents cross-cycle
|
|
3288
|
+
// listener bleed during rapid toggling.
|
|
3289
|
+
__publicField(this, "cycleNonce", 0);
|
|
3290
|
+
__publicField(this, "bus", createEventBus());
|
|
3291
|
+
__publicField(this, "detachEscape", null);
|
|
3292
|
+
__publicField(this, "detachBackdrop", null);
|
|
3293
|
+
__publicField(this, "detachHardwareBack", null);
|
|
3294
|
+
__publicField(this, "releaseStackEntry", null);
|
|
3295
|
+
__publicField(this, "lifecycle");
|
|
3296
|
+
this.element = opts.element;
|
|
3297
|
+
this.backdrop = opts.backdrop;
|
|
3298
|
+
this.edge = opts.edge ?? "bottom";
|
|
3299
|
+
this.closeOnEscape = opts.closeOnEscape ?? true;
|
|
3300
|
+
this.closeOnBackdropEnabled = opts.closeOnBackdrop ?? true;
|
|
3301
|
+
this.focusTrapEnabled = opts.focusTrap ?? true;
|
|
3302
|
+
this.initialFocus = opts.initialFocus;
|
|
3303
|
+
this.bodyScrollLockEnabled = opts.lockBodyScroll ?? true;
|
|
3304
|
+
this.inertSiblingsEnabled = opts.inertSiblings ?? false;
|
|
3305
|
+
this.closeOnBackEnabled = opts.closeOnBack ?? false;
|
|
3306
|
+
this.duration = opts.duration ?? 320;
|
|
3307
|
+
this.enterEasing = opts.enterEasing ?? "cubic-bezier(0.29, 1.24, 0.84, 0.99)";
|
|
3308
|
+
this.exitEasing = opts.exitEasing ?? "cubic-bezier(0.4, 0, 1, 1)";
|
|
3309
|
+
const preset = opts.preset ? OVERLAY_PRESETS[opts.preset] : void 0;
|
|
3310
|
+
this.backdropOpacity = opts.backdropOpacity ?? preset?.backdropOpacity ?? 1;
|
|
3311
|
+
this.backdropFilter = opts.backdropFilter ?? preset?.backdropFilter ?? void 0;
|
|
3312
|
+
const swipeRaw = opts.swipeToClose !== void 0 ? opts.swipeToClose : preset?.swipeToClose;
|
|
3313
|
+
this.assignSwipeFromConfig(swipeRaw);
|
|
3314
|
+
this.enterAnimation = opts.enterAnimation ?? preset?.enterAnimation ?? "slide";
|
|
3315
|
+
this.exitAnimation = opts.exitAnimation ?? preset?.exitAnimation ?? this.enterAnimation;
|
|
3316
|
+
this.mountTarget = opts.mountTo;
|
|
3317
|
+
this.peekRaw = opts.peek;
|
|
3318
|
+
this.closeOnOutsidePointerEnabled = opts.closeOnOutsidePointer ?? false;
|
|
3319
|
+
this.onOpenCb = opts.onOpen;
|
|
3320
|
+
this.onCloseCb = opts.onClose;
|
|
3321
|
+
if (this.peekRaw !== void 0 && this.edge !== "bottom") {
|
|
3322
|
+
console.warn(
|
|
3323
|
+
"[OverlayEngine] `peek` is only supported on edge: 'bottom' \u2014 ignoring."
|
|
3324
|
+
);
|
|
3325
|
+
this.peekRaw = void 0;
|
|
3326
|
+
}
|
|
3327
|
+
if (this.peekRaw !== void 0) {
|
|
3328
|
+
this.peekPx = this.resolvePeekPx(this.peekRaw);
|
|
3329
|
+
}
|
|
3330
|
+
this.lifecycle = new LifecycleController(
|
|
3331
|
+
{
|
|
3332
|
+
element: this.element,
|
|
3333
|
+
close: () => this.close("escape")
|
|
3334
|
+
},
|
|
3335
|
+
{
|
|
3336
|
+
focusTrap: this.focusTrapEnabled,
|
|
3337
|
+
initialFocus: this.initialFocus,
|
|
3338
|
+
closeOnEscape: this.closeOnEscape,
|
|
3339
|
+
lockBodyScroll: this.bodyScrollLockEnabled,
|
|
3340
|
+
inertSiblings: this.inertSiblingsEnabled,
|
|
3341
|
+
// Overlay-specific guard: only paint body siblings inert when the
|
|
3342
|
+
// overlay element is actually a body descendant. Detached / shadow-
|
|
3343
|
+
// DOM / portal overlays would otherwise lock unrelated trees.
|
|
3344
|
+
shouldApplyInertSiblings: () => {
|
|
3345
|
+
const bodyChildren = Array.from(document.body.children);
|
|
3346
|
+
return bodyChildren.some(
|
|
3347
|
+
(c) => c === this.element || c.contains(this.element)
|
|
3348
|
+
);
|
|
3349
|
+
},
|
|
3350
|
+
returnFocus: opts.returnFocus
|
|
3351
|
+
}
|
|
3352
|
+
);
|
|
3353
|
+
this.applyClosedStyles();
|
|
3354
|
+
this.registerInStack();
|
|
3355
|
+
if (opts.initialOpen) void this.open();
|
|
3356
|
+
}
|
|
3357
|
+
get state() {
|
|
3358
|
+
return { isOpen: this.isOpen_, isAnimating: this.isAnimating_ };
|
|
3359
|
+
}
|
|
3360
|
+
on(event, fn) {
|
|
3361
|
+
return this.bus.on(event, fn);
|
|
3362
|
+
}
|
|
3363
|
+
open() {
|
|
3364
|
+
if (this.destroyed || this.isOpen_) return Promise.resolve();
|
|
3365
|
+
this.maybeMount();
|
|
3366
|
+
this.emit("before-open", void 0);
|
|
3367
|
+
this.isOpen_ = true;
|
|
3368
|
+
this.isAnimating_ = true;
|
|
3369
|
+
this.element.removeAttribute("hidden");
|
|
3370
|
+
this.element.setAttribute("data-state", "entering");
|
|
3371
|
+
if (this.focusTrapEnabled) {
|
|
3372
|
+
this.element.setAttribute("aria-modal", "true");
|
|
3373
|
+
}
|
|
3374
|
+
const transitionStr = `transform ${this.duration}ms ${this.enterEasing}, opacity ${this.duration}ms ease-out`;
|
|
3375
|
+
this.element.style.transition = transitionStr;
|
|
3376
|
+
if (this.backdrop) {
|
|
3377
|
+
this.backdrop.style.transition = `opacity ${this.duration}ms ease-out`;
|
|
3378
|
+
}
|
|
3379
|
+
const cycle = ++this.cycleNonce;
|
|
3380
|
+
return new Promise((resolve) => {
|
|
3381
|
+
requestAnimationFrame(() => {
|
|
3382
|
+
if (this.destroyed || cycle !== this.cycleNonce) return resolve();
|
|
3383
|
+
requestAnimationFrame(() => {
|
|
3384
|
+
if (this.destroyed || cycle !== this.cycleNonce) return resolve();
|
|
3385
|
+
this.element.style.transform = openTransformFor(this.enterAnimation);
|
|
3386
|
+
this.element.style.opacity = "1";
|
|
3387
|
+
if (this.backdrop) {
|
|
3388
|
+
this.backdrop.style.opacity = String(this.backdropOpacity);
|
|
3389
|
+
this.backdrop.style.pointerEvents = "auto";
|
|
3390
|
+
if (this.backdropFilter) {
|
|
3391
|
+
this.backdrop.style.backdropFilter = this.backdropFilter;
|
|
3392
|
+
this.backdrop.style.webkitBackdropFilter = this.backdropFilter;
|
|
3393
|
+
}
|
|
3394
|
+
}
|
|
3395
|
+
try {
|
|
3396
|
+
this.installInteractiveListeners();
|
|
3397
|
+
} catch (err) {
|
|
3398
|
+
this.releaseInteractiveListeners();
|
|
3399
|
+
this.isOpen_ = false;
|
|
3400
|
+
this.isAnimating_ = false;
|
|
3401
|
+
this.element.style.transform = closedTransformFor(
|
|
3402
|
+
this.edge,
|
|
3403
|
+
this.enterAnimation,
|
|
3404
|
+
this.peekPx
|
|
3405
|
+
);
|
|
3406
|
+
this.element.style.opacity = "0";
|
|
3407
|
+
if (this.backdrop) {
|
|
3408
|
+
this.backdrop.style.opacity = "0";
|
|
3409
|
+
this.backdrop.style.pointerEvents = "none";
|
|
3410
|
+
if (this.backdropFilter) {
|
|
3411
|
+
this.backdrop.style.backdropFilter = "";
|
|
3412
|
+
this.backdrop.style.webkitBackdropFilter = "";
|
|
3413
|
+
}
|
|
3414
|
+
}
|
|
3415
|
+
this.element.setAttribute("data-state", "closed");
|
|
3416
|
+
this.element.setAttribute("hidden", "");
|
|
3417
|
+
if (this.focusTrapEnabled) {
|
|
3418
|
+
this.element.removeAttribute("aria-modal");
|
|
3419
|
+
}
|
|
3420
|
+
queueMicrotask(() => {
|
|
3421
|
+
throw err;
|
|
3422
|
+
});
|
|
3423
|
+
return resolve();
|
|
3424
|
+
}
|
|
3425
|
+
this.awaitTransition(cycle).then(() => {
|
|
3426
|
+
if (this.destroyed || cycle !== this.cycleNonce) return resolve();
|
|
3427
|
+
this.isAnimating_ = false;
|
|
3428
|
+
this.element.setAttribute("data-state", "open");
|
|
3429
|
+
this.emit("open", void 0);
|
|
3430
|
+
this.onOpenCb?.();
|
|
3431
|
+
resolve();
|
|
3432
|
+
});
|
|
3433
|
+
});
|
|
3434
|
+
});
|
|
3435
|
+
});
|
|
3436
|
+
}
|
|
3437
|
+
close(reason = "programmatic") {
|
|
3438
|
+
if (this.destroyed || !this.isOpen_) return Promise.resolve();
|
|
3439
|
+
this.emit("before-close", void 0);
|
|
3440
|
+
this.isOpen_ = false;
|
|
3441
|
+
this.isAnimating_ = true;
|
|
3442
|
+
this.element.setAttribute("data-state", "exiting");
|
|
3443
|
+
const transitionStr = `transform ${this.duration}ms ${this.exitEasing}, opacity ${this.duration}ms ease-in`;
|
|
3444
|
+
this.element.style.transition = transitionStr;
|
|
3445
|
+
if (this.backdrop) {
|
|
3446
|
+
this.backdrop.style.transition = `opacity ${this.duration}ms ease-in`;
|
|
3447
|
+
}
|
|
3448
|
+
const cycle = ++this.cycleNonce;
|
|
3449
|
+
return new Promise((resolve) => {
|
|
3450
|
+
requestAnimationFrame(() => {
|
|
3451
|
+
if (this.destroyed || cycle !== this.cycleNonce) return resolve();
|
|
3452
|
+
this.element.style.transform = closedTransformFor(
|
|
3453
|
+
this.edge,
|
|
3454
|
+
this.exitAnimation,
|
|
3455
|
+
this.peekPx
|
|
3456
|
+
);
|
|
3457
|
+
this.element.style.opacity = "0";
|
|
3458
|
+
if (this.backdrop) {
|
|
3459
|
+
this.backdrop.style.opacity = "0";
|
|
3460
|
+
this.backdrop.style.pointerEvents = "none";
|
|
3461
|
+
if (this.backdropFilter) {
|
|
3462
|
+
this.backdrop.style.backdropFilter = "";
|
|
3463
|
+
this.backdrop.style.webkitBackdropFilter = "";
|
|
3464
|
+
}
|
|
3465
|
+
}
|
|
3466
|
+
this.releaseInteractiveListeners();
|
|
3467
|
+
this.awaitTransition(cycle).then(() => {
|
|
3468
|
+
if (this.destroyed || cycle !== this.cycleNonce) return resolve();
|
|
3469
|
+
this.isAnimating_ = false;
|
|
3470
|
+
if (this.peekPx === void 0) {
|
|
3471
|
+
this.element.setAttribute("hidden", "");
|
|
3472
|
+
}
|
|
3473
|
+
this.element.setAttribute("data-state", "closed");
|
|
3474
|
+
if (this.focusTrapEnabled) {
|
|
3475
|
+
this.element.removeAttribute("aria-modal");
|
|
3476
|
+
}
|
|
3477
|
+
this.emit("close", { reason });
|
|
3478
|
+
this.onCloseCb?.();
|
|
3479
|
+
resolve();
|
|
3480
|
+
});
|
|
3481
|
+
});
|
|
3482
|
+
});
|
|
3483
|
+
}
|
|
3484
|
+
toggle() {
|
|
3485
|
+
if (this.destroyed) return Promise.resolve();
|
|
3486
|
+
return this.isOpen_ ? this.close() : this.open();
|
|
3487
|
+
}
|
|
3488
|
+
destroy() {
|
|
3489
|
+
this.destroyed = true;
|
|
3490
|
+
this.cycleNonce++;
|
|
3491
|
+
this.bus.clear();
|
|
3492
|
+
this.releaseInteractiveListeners();
|
|
3493
|
+
this.releaseStackEntry?.();
|
|
3494
|
+
this.releaseStackEntry = null;
|
|
3495
|
+
this.restoreMount();
|
|
3496
|
+
}
|
|
3497
|
+
/**
|
|
3498
|
+
* Runtime visual setter — opacity is applied immediately when open so a
|
|
3499
|
+
* live-controls panel can preview the change without re-opening. When
|
|
3500
|
+
* closed we only stash the field; next open() picks it up so we don't
|
|
3501
|
+
* mutate hidden DOM (avoids paint thrash on detached panels).
|
|
3502
|
+
*/
|
|
3503
|
+
setBackdropOpacity(opacity) {
|
|
3504
|
+
if (this.destroyed) return;
|
|
3505
|
+
this.backdropOpacity = opacity;
|
|
3506
|
+
if (this.isOpen_ && this.backdrop) {
|
|
3507
|
+
this.backdrop.style.opacity = String(opacity);
|
|
3508
|
+
}
|
|
3509
|
+
}
|
|
3510
|
+
/**
|
|
3511
|
+
* Runtime visual setter for `backdrop-filter`. `undefined` clears the
|
|
3512
|
+
* filter (mirrors the close() behaviour). Defers to next open() when the
|
|
3513
|
+
* overlay is currently closed.
|
|
3514
|
+
*/
|
|
3515
|
+
setBackdropFilter(filter) {
|
|
3516
|
+
if (this.destroyed) return;
|
|
3517
|
+
this.backdropFilter = filter;
|
|
3518
|
+
if (!this.isOpen_ || !this.backdrop) return;
|
|
3519
|
+
const style = this.backdrop.style;
|
|
3520
|
+
if (filter) {
|
|
3521
|
+
this.backdrop.style.backdropFilter = filter;
|
|
3522
|
+
style.webkitBackdropFilter = filter;
|
|
3523
|
+
} else {
|
|
3524
|
+
this.backdrop.style.backdropFilter = "";
|
|
3525
|
+
style.webkitBackdropFilter = "";
|
|
3526
|
+
}
|
|
3527
|
+
}
|
|
3528
|
+
/**
|
|
3529
|
+
* Toggle / reconfigure swipe-to-close at runtime. Re-resolves the flat
|
|
3530
|
+
* threshold fields, then installs or tears down the pointer listener so
|
|
3531
|
+
* the change applies to the CURRENT open cycle. Idempotent: safe to call
|
|
3532
|
+
* with the same config repeatedly.
|
|
3533
|
+
*/
|
|
3534
|
+
setSwipeToClose(config) {
|
|
3535
|
+
if (this.destroyed) return;
|
|
3536
|
+
const wasEnabled = this.swipeToCloseEnabled;
|
|
3537
|
+
this.assignSwipeFromConfig(config);
|
|
3538
|
+
if (!this.isOpen_) return;
|
|
3539
|
+
if (this.swipeToCloseEnabled && !this.detachSwipe) {
|
|
3540
|
+
this.detachSwipe = this.installSwipeToClose();
|
|
3541
|
+
} else if (!this.swipeToCloseEnabled && wasEnabled && this.detachSwipe) {
|
|
3542
|
+
this.detachSwipe();
|
|
3543
|
+
this.detachSwipe = null;
|
|
3544
|
+
}
|
|
3545
|
+
}
|
|
3546
|
+
/** Animation setters only stage the field — applied on next open/close. */
|
|
3547
|
+
setEnterAnimation(animation) {
|
|
3548
|
+
if (this.destroyed) return;
|
|
3549
|
+
this.enterAnimation = animation;
|
|
3550
|
+
}
|
|
3551
|
+
setExitAnimation(animation) {
|
|
3552
|
+
if (this.destroyed) return;
|
|
3553
|
+
this.exitAnimation = animation;
|
|
3554
|
+
}
|
|
3555
|
+
/** Updates the field used at next close() — no immediate side effect. */
|
|
3556
|
+
setReturnFocus(target) {
|
|
3557
|
+
if (this.destroyed) return;
|
|
3558
|
+
this.lifecycle.setReturnFocus(target);
|
|
3559
|
+
}
|
|
3560
|
+
/**
|
|
3561
|
+
* Batch update — preset first, then individual overrides. Applied as a
|
|
3562
|
+
* single DOM-write phase when open so live-controls don't trigger
|
|
3563
|
+
* cascading reflows. Safe to call when closed (defers to next open).
|
|
3564
|
+
*/
|
|
3565
|
+
setOverlay(opts) {
|
|
3566
|
+
if (this.destroyed) return;
|
|
3567
|
+
const preset = opts.preset ? OVERLAY_PRESETS[opts.preset] : void 0;
|
|
3568
|
+
const merged = preset ? {
|
|
3569
|
+
backdropOpacity: preset.backdropOpacity,
|
|
3570
|
+
// Preset's `undefined` filter means "no filter" — surface as null
|
|
3571
|
+
// so the merged step clears the field instead of inheriting old.
|
|
3572
|
+
backdropFilter: preset.backdropFilter === void 0 ? null : preset.backdropFilter,
|
|
3573
|
+
swipeToClose: preset.swipeToClose,
|
|
3574
|
+
enterAnimation: preset.enterAnimation,
|
|
3575
|
+
exitAnimation: preset.exitAnimation
|
|
3576
|
+
} : {};
|
|
3577
|
+
if (opts.backdropOpacity !== void 0) {
|
|
3578
|
+
merged.backdropOpacity = opts.backdropOpacity;
|
|
3579
|
+
}
|
|
3580
|
+
if (opts.backdropFilter !== void 0) {
|
|
3581
|
+
merged.backdropFilter = opts.backdropFilter;
|
|
3582
|
+
}
|
|
3583
|
+
if (opts.swipeToClose !== void 0) {
|
|
3584
|
+
merged.swipeToClose = opts.swipeToClose;
|
|
3585
|
+
}
|
|
3586
|
+
if (opts.enterAnimation !== void 0) {
|
|
3587
|
+
merged.enterAnimation = opts.enterAnimation;
|
|
3588
|
+
}
|
|
3589
|
+
if (opts.exitAnimation !== void 0) {
|
|
3590
|
+
merged.exitAnimation = opts.exitAnimation;
|
|
3591
|
+
}
|
|
3592
|
+
if (merged.enterAnimation !== void 0) {
|
|
3593
|
+
this.enterAnimation = merged.enterAnimation;
|
|
3594
|
+
}
|
|
3595
|
+
if (merged.exitAnimation !== void 0) {
|
|
3596
|
+
this.exitAnimation = merged.exitAnimation;
|
|
3597
|
+
}
|
|
3598
|
+
if (merged.swipeToClose !== void 0) {
|
|
3599
|
+
this.setSwipeToClose(merged.swipeToClose);
|
|
3600
|
+
}
|
|
3601
|
+
if (merged.backdropOpacity !== void 0) {
|
|
3602
|
+
this.backdropOpacity = merged.backdropOpacity;
|
|
3603
|
+
}
|
|
3604
|
+
if (merged.backdropFilter !== void 0) {
|
|
3605
|
+
this.backdropFilter = merged.backdropFilter === null ? void 0 : merged.backdropFilter;
|
|
3606
|
+
}
|
|
3607
|
+
if (this.isOpen_ && this.backdrop) {
|
|
3608
|
+
if (merged.backdropOpacity !== void 0) {
|
|
3609
|
+
this.backdrop.style.opacity = String(this.backdropOpacity);
|
|
3610
|
+
}
|
|
3611
|
+
if (merged.backdropFilter !== void 0) {
|
|
3612
|
+
const style = this.backdrop.style;
|
|
3613
|
+
if (this.backdropFilter) {
|
|
3614
|
+
this.backdrop.style.backdropFilter = this.backdropFilter;
|
|
3615
|
+
style.webkitBackdropFilter = this.backdropFilter;
|
|
3616
|
+
} else {
|
|
3617
|
+
this.backdrop.style.backdropFilter = "";
|
|
3618
|
+
style.webkitBackdropFilter = "";
|
|
3619
|
+
}
|
|
3620
|
+
}
|
|
3621
|
+
}
|
|
3622
|
+
if (opts.children !== void 0) {
|
|
3623
|
+
if (opts.children === null) {
|
|
3624
|
+
this.clearOverlayChildren();
|
|
3625
|
+
} else {
|
|
3626
|
+
this.setOverlayChildren(opts.children);
|
|
3627
|
+
}
|
|
3628
|
+
}
|
|
3629
|
+
}
|
|
3630
|
+
/**
|
|
3631
|
+
* Runtime children injection — replace the panel's contents without
|
|
3632
|
+
* unmounting the engine. `replaceChildren()` is a single atomic DOM op,
|
|
3633
|
+
* so callers can swap content rapidly without intermediate flicker.
|
|
3634
|
+
* Function form is resolved exactly ONCE per call (cheap lazy content).
|
|
3635
|
+
*/
|
|
3636
|
+
setOverlayChildren(children) {
|
|
3637
|
+
if (this.destroyed) return;
|
|
3638
|
+
const node = typeof children === "function" ? children() : children;
|
|
3639
|
+
this.element.replaceChildren(node);
|
|
3640
|
+
this.hasInjectedChildren = true;
|
|
3641
|
+
}
|
|
3642
|
+
/**
|
|
3643
|
+
* Clear injected children. No-op when nothing was injected so consumers
|
|
3644
|
+
* who never used setOverlayChildren can call this defensively without
|
|
3645
|
+
* paying for a DOM mutation.
|
|
3646
|
+
*/
|
|
3647
|
+
clearOverlayChildren() {
|
|
3648
|
+
if (this.destroyed || !this.hasInjectedChildren) return;
|
|
3649
|
+
this.element.replaceChildren();
|
|
3650
|
+
this.hasInjectedChildren = false;
|
|
3651
|
+
}
|
|
3652
|
+
// Shared swipe-config normalizer — used by constructor, setSwipeToClose,
|
|
3653
|
+
// and setOverlay so the union/object shape is decoded in exactly one place.
|
|
3654
|
+
assignSwipeFromConfig(swipe) {
|
|
3655
|
+
if (typeof swipe === "object" && swipe !== null) {
|
|
3656
|
+
this.swipeToCloseEnabled = swipe.enabled ?? true;
|
|
3657
|
+
this.swipeThreshold = swipe.threshold ?? 0.3;
|
|
3658
|
+
this.swipeVelocityThreshold = swipe.velocityThreshold ?? 0.5;
|
|
3659
|
+
} else {
|
|
3660
|
+
this.swipeToCloseEnabled = swipe ?? false;
|
|
3661
|
+
this.swipeThreshold = 0.3;
|
|
3662
|
+
this.swipeVelocityThreshold = 0.5;
|
|
3663
|
+
}
|
|
3664
|
+
}
|
|
3665
|
+
emit(event, payload) {
|
|
3666
|
+
this.bus.emit(event, payload);
|
|
3667
|
+
}
|
|
3668
|
+
resolvePeekPx(raw) {
|
|
3669
|
+
if (typeof raw === "number") return Math.max(0, raw);
|
|
3670
|
+
try {
|
|
3671
|
+
return Math.max(0, resolveSnap(raw, "bottom"));
|
|
3672
|
+
} catch {
|
|
3673
|
+
return 0;
|
|
3674
|
+
}
|
|
3675
|
+
}
|
|
3676
|
+
maybeMount() {
|
|
3677
|
+
if (this.mounted || !this.mountTarget || this.mountTarget === "parent") {
|
|
3678
|
+
this.mounted = true;
|
|
3679
|
+
return;
|
|
3680
|
+
}
|
|
3681
|
+
const target = this.mountTarget === "body" ? document.body : this.mountTarget;
|
|
3682
|
+
if (!target) {
|
|
3683
|
+
this.mounted = true;
|
|
3684
|
+
return;
|
|
3685
|
+
}
|
|
3686
|
+
this.originalParent = this.element.parentNode;
|
|
3687
|
+
this.originalNextSibling = this.element.nextSibling;
|
|
3688
|
+
target.appendChild(this.element);
|
|
3689
|
+
this.mounted = true;
|
|
3690
|
+
}
|
|
3691
|
+
restoreMount() {
|
|
3692
|
+
if (!this.originalParent) return;
|
|
3693
|
+
try {
|
|
3694
|
+
if (this.originalNextSibling && this.originalNextSibling.parentNode === this.originalParent) {
|
|
3695
|
+
this.originalParent.insertBefore(this.element, this.originalNextSibling);
|
|
3696
|
+
} else {
|
|
3697
|
+
this.originalParent.appendChild(this.element);
|
|
3698
|
+
}
|
|
3699
|
+
} catch {
|
|
3700
|
+
}
|
|
3701
|
+
this.originalParent = null;
|
|
3702
|
+
this.originalNextSibling = null;
|
|
3703
|
+
}
|
|
3704
|
+
applyClosedStyles() {
|
|
3705
|
+
this.element.style.willChange = "transform, opacity";
|
|
3706
|
+
this.element.style.transform = closedTransformFor(
|
|
3707
|
+
this.edge,
|
|
3708
|
+
this.enterAnimation,
|
|
3709
|
+
this.peekPx
|
|
3710
|
+
);
|
|
3711
|
+
this.element.style.opacity = this.peekPx !== void 0 ? "1" : "0";
|
|
3712
|
+
if (this.peekPx === void 0) {
|
|
3713
|
+
this.element.setAttribute("hidden", "");
|
|
3714
|
+
}
|
|
3715
|
+
this.element.setAttribute("data-state", "closed");
|
|
3716
|
+
if (this.backdrop) {
|
|
3717
|
+
this.backdrop.style.opacity = "0";
|
|
3718
|
+
this.backdrop.style.pointerEvents = "none";
|
|
3719
|
+
this.backdrop.style.willChange = "opacity";
|
|
3720
|
+
}
|
|
3721
|
+
}
|
|
3722
|
+
awaitTransition(cycle) {
|
|
3723
|
+
return new Promise((resolve) => {
|
|
3724
|
+
let done = false;
|
|
3725
|
+
const finish = () => {
|
|
3726
|
+
if (done) return;
|
|
3727
|
+
done = true;
|
|
3728
|
+
this.element.removeEventListener("transitionend", onEnd);
|
|
3729
|
+
clearTimeout(timer);
|
|
3730
|
+
resolve();
|
|
3731
|
+
};
|
|
3732
|
+
const onEnd = (e) => {
|
|
3733
|
+
if (cycle !== this.cycleNonce) {
|
|
3734
|
+
finish();
|
|
3735
|
+
return;
|
|
3736
|
+
}
|
|
3737
|
+
if (e.propertyName === "transform") finish();
|
|
3738
|
+
};
|
|
3739
|
+
this.element.addEventListener("transitionend", onEnd);
|
|
3740
|
+
const timer = window.setTimeout(finish, this.duration + 50);
|
|
3741
|
+
});
|
|
3742
|
+
}
|
|
3743
|
+
installInteractiveListeners() {
|
|
3744
|
+
this.lifecycle.install();
|
|
3745
|
+
if (this.swipeToCloseEnabled && !this.detachSwipe) {
|
|
3746
|
+
this.detachSwipe = this.installSwipeToClose();
|
|
3747
|
+
}
|
|
3748
|
+
if (this.closeOnEscape && !this.detachEscape) {
|
|
3749
|
+
const handler = (e) => {
|
|
3750
|
+
if (e.key === "Escape" && this.isOpen_) void this.close("escape");
|
|
3751
|
+
};
|
|
3752
|
+
document.addEventListener("keydown", handler);
|
|
3753
|
+
this.detachEscape = () => document.removeEventListener("keydown", handler);
|
|
3754
|
+
}
|
|
3755
|
+
if (this.closeOnBackdropEnabled && this.backdrop && !this.detachBackdrop) {
|
|
3756
|
+
const handler = () => void this.close("backdrop");
|
|
3757
|
+
this.backdrop.addEventListener("click", handler);
|
|
3758
|
+
this.detachBackdrop = () => this.backdrop?.removeEventListener("click", handler);
|
|
3759
|
+
}
|
|
3760
|
+
if (this.closeOnBackEnabled && typeof window !== "undefined" && !this.detachHardwareBack) {
|
|
3761
|
+
try {
|
|
3762
|
+
history.pushState({ __bsOverlay: this.id }, "");
|
|
3763
|
+
} catch {
|
|
3764
|
+
}
|
|
3765
|
+
const onPop = () => {
|
|
3766
|
+
if (this.isOpen_) void this.close("back");
|
|
3767
|
+
};
|
|
3768
|
+
window.addEventListener("popstate", onPop);
|
|
3769
|
+
this.detachHardwareBack = () => window.removeEventListener("popstate", onPop);
|
|
3770
|
+
}
|
|
3771
|
+
if (this.closeOnOutsidePointerEnabled && !this.detachOutsidePointer) {
|
|
3772
|
+
const handler = (e) => {
|
|
3773
|
+
if (!this.isOpen_) return;
|
|
3774
|
+
const target = e.target;
|
|
3775
|
+
if (!target) return;
|
|
3776
|
+
if (this.element.contains(target)) return;
|
|
3777
|
+
if (this.backdrop && this.backdrop.contains(target)) return;
|
|
3778
|
+
void this.close("outside-pointer");
|
|
3779
|
+
};
|
|
3780
|
+
document.addEventListener("pointerdown", handler, true);
|
|
3781
|
+
this.detachOutsidePointer = () => document.removeEventListener("pointerdown", handler, true);
|
|
3782
|
+
}
|
|
3783
|
+
}
|
|
3784
|
+
releaseInteractiveListeners() {
|
|
3785
|
+
this.detachEscape?.();
|
|
3786
|
+
this.detachEscape = null;
|
|
3787
|
+
this.detachBackdrop?.();
|
|
3788
|
+
this.detachBackdrop = null;
|
|
3789
|
+
this.detachHardwareBack?.();
|
|
3790
|
+
this.detachHardwareBack = null;
|
|
3791
|
+
this.detachSwipe?.();
|
|
3792
|
+
this.detachSwipe = null;
|
|
3793
|
+
this.detachOutsidePointer?.();
|
|
3794
|
+
this.detachOutsidePointer = null;
|
|
3795
|
+
this.lifecycle.release();
|
|
3796
|
+
}
|
|
3797
|
+
/**
|
|
3798
|
+
* Light pointer-driven dismiss. Tracks finger along the edge axis, follows
|
|
3799
|
+
* with translate, dismisses if past `swipeThreshold` (fraction of panel
|
|
3800
|
+
* size) or release velocity exceeds `swipeVelocityThreshold` (px/ms).
|
|
3801
|
+
* Pure CSS-transform during drag — no rAF or physics required since the
|
|
3802
|
+
* panel only moves one direction.
|
|
3803
|
+
*/
|
|
3804
|
+
installSwipeToClose() {
|
|
3805
|
+
const el = this.element;
|
|
3806
|
+
const edge = this.edge;
|
|
3807
|
+
const isVertical = edge === "bottom" || edge === "top";
|
|
3808
|
+
const sign = edge === "bottom" || edge === "right" ? 1 : -1;
|
|
3809
|
+
const threshold = this.swipeThreshold;
|
|
3810
|
+
const velocityThreshold = this.swipeVelocityThreshold;
|
|
3811
|
+
let startX = 0;
|
|
3812
|
+
let startY = 0;
|
|
3813
|
+
let startT = 0;
|
|
3814
|
+
let dragging = false;
|
|
3815
|
+
let pointerId = null;
|
|
3816
|
+
const onDown = (e) => {
|
|
3817
|
+
if (e.button !== void 0 && e.button !== 0) return;
|
|
3818
|
+
pointerId = e.pointerId;
|
|
3819
|
+
startX = e.clientX;
|
|
3820
|
+
startY = e.clientY;
|
|
3821
|
+
startT = performance.now();
|
|
3822
|
+
dragging = true;
|
|
3823
|
+
el.style.transition = "none";
|
|
3824
|
+
try {
|
|
3825
|
+
el.setPointerCapture(e.pointerId);
|
|
3826
|
+
} catch {
|
|
3827
|
+
}
|
|
3828
|
+
};
|
|
3829
|
+
const onMove = (e) => {
|
|
3830
|
+
if (!dragging || e.pointerId !== pointerId) return;
|
|
3831
|
+
const delta = isVertical ? e.clientY - startY : e.clientX - startX;
|
|
3832
|
+
const offset = Math.max(0, sign * delta);
|
|
3833
|
+
el.style.transform = isVertical ? `translate3d(0, ${sign * offset}px, 0)` : `translate3d(${sign * offset}px, 0, 0)`;
|
|
3834
|
+
};
|
|
3835
|
+
const finish = (e) => {
|
|
3836
|
+
if (!dragging || e.pointerId !== pointerId) return;
|
|
3837
|
+
dragging = false;
|
|
3838
|
+
pointerId = null;
|
|
3839
|
+
const dt = performance.now() - startT || 1;
|
|
3840
|
+
const delta = isVertical ? e.clientY - startY : e.clientX - startX;
|
|
3841
|
+
const offset = Math.max(0, sign * delta);
|
|
3842
|
+
const velocity = sign * delta / dt;
|
|
3843
|
+
const size = isVertical ? el.offsetHeight : el.offsetWidth;
|
|
3844
|
+
const past = offset > size * threshold || velocity > velocityThreshold;
|
|
3845
|
+
el.style.transition = `transform ${this.duration}ms ${this.exitEasing}`;
|
|
3846
|
+
if (past) {
|
|
3847
|
+
void this.close("swipe");
|
|
3848
|
+
} else {
|
|
3849
|
+
el.style.transform = openTransformFor(this.enterAnimation);
|
|
3850
|
+
}
|
|
3851
|
+
};
|
|
3852
|
+
el.addEventListener("pointerdown", onDown);
|
|
3853
|
+
el.addEventListener("pointermove", onMove);
|
|
3854
|
+
el.addEventListener("pointerup", finish);
|
|
3855
|
+
el.addEventListener("pointercancel", finish);
|
|
3856
|
+
return () => {
|
|
3857
|
+
el.removeEventListener("pointerdown", onDown);
|
|
3858
|
+
el.removeEventListener("pointermove", onMove);
|
|
3859
|
+
el.removeEventListener("pointerup", finish);
|
|
3860
|
+
el.removeEventListener("pointercancel", finish);
|
|
3861
|
+
};
|
|
3862
|
+
}
|
|
3863
|
+
registerInStack() {
|
|
3864
|
+
this.releaseStackEntry = sheetStack.push({
|
|
3865
|
+
id: this.id,
|
|
3866
|
+
setZIndex: (z) => {
|
|
3867
|
+
this.element.style.zIndex = String(z);
|
|
3868
|
+
if (this.backdrop) this.backdrop.style.zIndex = String(z - 1);
|
|
3869
|
+
},
|
|
3870
|
+
setIsTop: (isTop) => {
|
|
3871
|
+
if (this.backdrop) {
|
|
3872
|
+
this.backdrop.style.display = isTop ? "" : "none";
|
|
3873
|
+
}
|
|
3874
|
+
}
|
|
3875
|
+
});
|
|
3876
|
+
}
|
|
3877
|
+
};
|
|
3878
|
+
|
|
3879
|
+
// src/react/useOverlay.ts
|
|
3880
|
+
var useIsomorphicLayoutEffect2 = typeof window !== "undefined" ? useLayoutEffect : useEffect;
|
|
3881
|
+
var useOverlay = (opts) => {
|
|
3882
|
+
const panelRef = useRef(null);
|
|
3883
|
+
const backdropRef = useRef(null);
|
|
3884
|
+
const engineRef = useRef(null);
|
|
3885
|
+
const optsRef = useRef(opts);
|
|
3886
|
+
optsRef.current = opts;
|
|
3887
|
+
const [state, setState] = useState({
|
|
3888
|
+
isOpen: opts.initialOpen ?? false,
|
|
3889
|
+
isAnimating: false
|
|
3890
|
+
});
|
|
3891
|
+
useIsomorphicLayoutEffect2(() => {
|
|
3892
|
+
if (!panelRef.current) return;
|
|
3893
|
+
const engine = new OverlayEngine({
|
|
3894
|
+
...optsRef.current,
|
|
3895
|
+
element: panelRef.current,
|
|
3896
|
+
backdrop: backdropRef.current ?? void 0
|
|
3897
|
+
});
|
|
3898
|
+
engineRef.current = engine;
|
|
3899
|
+
setState(engine.state);
|
|
3900
|
+
const sync = () => setState(engine.state);
|
|
3901
|
+
const offs = [
|
|
3902
|
+
engine.on("open", sync),
|
|
3903
|
+
engine.on("close", sync),
|
|
3904
|
+
engine.on("before-open", sync),
|
|
3905
|
+
engine.on("before-close", sync)
|
|
3906
|
+
];
|
|
3907
|
+
return () => {
|
|
3908
|
+
offs.forEach((off) => off());
|
|
3909
|
+
engine.destroy();
|
|
3910
|
+
engineRef.current = null;
|
|
3911
|
+
};
|
|
3912
|
+
}, []);
|
|
3913
|
+
const open = useCallback(
|
|
3914
|
+
() => engineRef.current?.open() ?? Promise.resolve(),
|
|
3915
|
+
[]
|
|
3916
|
+
);
|
|
3917
|
+
const close = useCallback(
|
|
3918
|
+
() => engineRef.current?.close() ?? Promise.resolve(),
|
|
3919
|
+
[]
|
|
3920
|
+
);
|
|
3921
|
+
const toggle = useCallback(
|
|
3922
|
+
() => engineRef.current?.toggle() ?? Promise.resolve(),
|
|
3923
|
+
[]
|
|
3924
|
+
);
|
|
3925
|
+
const getEngine = useCallback(() => engineRef.current, []);
|
|
3926
|
+
return useMemo(
|
|
3927
|
+
() => ({
|
|
3928
|
+
panelRef,
|
|
3929
|
+
backdropRef,
|
|
3930
|
+
state,
|
|
3931
|
+
open,
|
|
3932
|
+
close,
|
|
3933
|
+
toggle,
|
|
3934
|
+
getEngine
|
|
3935
|
+
}),
|
|
3936
|
+
[state, open, close, toggle, getEngine]
|
|
3937
|
+
);
|
|
3938
|
+
};
|
|
3939
|
+
var Overlay = forwardRef(function Overlay2(props, ref) {
|
|
3940
|
+
const {
|
|
3941
|
+
open: openProp,
|
|
3942
|
+
backdrop = true,
|
|
3943
|
+
className = "",
|
|
3944
|
+
style,
|
|
3945
|
+
children,
|
|
3946
|
+
ariaLabel = "Overlay",
|
|
3947
|
+
ariaLabelledBy,
|
|
3948
|
+
onChange,
|
|
3949
|
+
edge = "bottom",
|
|
3950
|
+
...engineOpts
|
|
3951
|
+
} = props;
|
|
3952
|
+
const { panelRef, backdropRef, state, open, close, toggle } = useOverlay({
|
|
3953
|
+
...engineOpts,
|
|
3954
|
+
edge
|
|
3955
|
+
});
|
|
3956
|
+
useEffect(() => {
|
|
3957
|
+
if (openProp === void 0) return;
|
|
3958
|
+
if (openProp && !state.isOpen) void open();
|
|
3959
|
+
else if (!openProp && state.isOpen) void close();
|
|
3960
|
+
}, [openProp]);
|
|
3961
|
+
useEffect(() => {
|
|
3962
|
+
onChange?.(state);
|
|
3963
|
+
}, [state, onChange]);
|
|
3964
|
+
useImperativeHandle(
|
|
3965
|
+
ref,
|
|
3966
|
+
() => ({ open, close, toggle, state }),
|
|
3967
|
+
[open, close, toggle, state]
|
|
3968
|
+
);
|
|
3969
|
+
return /* @__PURE__ */ jsxs("div", { className: "ovl-root", children: [
|
|
3970
|
+
backdrop && /* @__PURE__ */ jsx(
|
|
3971
|
+
"div",
|
|
3972
|
+
{
|
|
3973
|
+
ref: backdropRef,
|
|
3974
|
+
className: "ovl-backdrop",
|
|
3975
|
+
"aria-hidden": "true"
|
|
3976
|
+
}
|
|
3977
|
+
),
|
|
3978
|
+
/* @__PURE__ */ jsx(
|
|
3979
|
+
"section",
|
|
3980
|
+
{
|
|
3981
|
+
ref: panelRef,
|
|
3982
|
+
className: `ovl-panel ${className}`.trim(),
|
|
3983
|
+
"data-edge": edge,
|
|
3984
|
+
style,
|
|
3985
|
+
role: "dialog",
|
|
3986
|
+
"aria-modal": engineOpts.focusTrap !== false ? "true" : void 0,
|
|
3987
|
+
"aria-label": ariaLabelledBy ? void 0 : ariaLabel,
|
|
3988
|
+
"aria-labelledby": ariaLabelledBy,
|
|
3989
|
+
children
|
|
3990
|
+
}
|
|
3991
|
+
)
|
|
3992
|
+
] });
|
|
3993
|
+
});
|
|
3994
|
+
var SUPPORTED_VERSION = 1;
|
|
3995
|
+
var TOP_LEVEL_KEYS = /* @__PURE__ */ new Set([
|
|
3996
|
+
"version",
|
|
3997
|
+
"snapPoints",
|
|
3998
|
+
"initial",
|
|
3999
|
+
"allowed",
|
|
4000
|
+
"mode",
|
|
4001
|
+
"animation",
|
|
4002
|
+
"spring",
|
|
4003
|
+
"behavior",
|
|
4004
|
+
"events"
|
|
4005
|
+
]);
|
|
4006
|
+
var BEHAVIOR_KEYS = /* @__PURE__ */ new Set([
|
|
4007
|
+
"focusTrap",
|
|
4008
|
+
"closeOnEscape",
|
|
4009
|
+
"closeOnBackdrop",
|
|
4010
|
+
"lockBodyScroll",
|
|
4011
|
+
"rubberBand"
|
|
4012
|
+
]);
|
|
4013
|
+
var EVENTS_KEYS = /* @__PURE__ */ new Set(["onSnap", "onOpen", "onClose"]);
|
|
4014
|
+
var warnUnknownKeys = (obj, allowed, context) => {
|
|
4015
|
+
if (!obj || typeof obj !== "object") return;
|
|
4016
|
+
const unknown = Object.keys(obj).filter((k) => !allowed.has(k));
|
|
4017
|
+
if (unknown.length === 0) return;
|
|
4018
|
+
console.warn(
|
|
4019
|
+
`[BottomSheetFromConfig] Unknown ${unknown.length === 1 ? "key" : "keys"} in ${context}: ${unknown.map((k) => `"${k}"`).join(", ")}. Allowed: ${[...allowed].join(", ")}. Likely a typo \u2014 values ignored.`
|
|
4020
|
+
);
|
|
4021
|
+
};
|
|
4022
|
+
var validatedConfigs = /* @__PURE__ */ new WeakSet();
|
|
4023
|
+
function validateConfig(config) {
|
|
4024
|
+
if (validatedConfigs.has(config)) return true;
|
|
4025
|
+
validatedConfigs.add(config);
|
|
4026
|
+
let ok = true;
|
|
4027
|
+
if (config.version !== SUPPORTED_VERSION) {
|
|
4028
|
+
console.warn(
|
|
4029
|
+
`[BottomSheetFromConfig] Unsupported config version ${String(
|
|
4030
|
+
config.version
|
|
4031
|
+
)}; expected ${SUPPORTED_VERSION}. Falling back to defaults.`
|
|
4032
|
+
);
|
|
4033
|
+
return false;
|
|
4034
|
+
}
|
|
4035
|
+
if (!Array.isArray(config.snapPoints) || config.snapPoints.length === 0) {
|
|
4036
|
+
console.warn(
|
|
4037
|
+
"[BottomSheetFromConfig] config.snapPoints is missing or empty; using a single full-height fallback snap."
|
|
4038
|
+
);
|
|
4039
|
+
ok = false;
|
|
4040
|
+
}
|
|
4041
|
+
warnUnknownKeys(
|
|
4042
|
+
config,
|
|
4043
|
+
TOP_LEVEL_KEYS,
|
|
4044
|
+
"config"
|
|
4045
|
+
);
|
|
4046
|
+
warnUnknownKeys(
|
|
4047
|
+
config.behavior,
|
|
4048
|
+
BEHAVIOR_KEYS,
|
|
4049
|
+
"config.behavior"
|
|
4050
|
+
);
|
|
4051
|
+
warnUnknownKeys(
|
|
4052
|
+
config.events,
|
|
4053
|
+
EVENTS_KEYS,
|
|
4054
|
+
"config.events"
|
|
4055
|
+
);
|
|
4056
|
+
return ok;
|
|
4057
|
+
}
|
|
4058
|
+
var FALLBACK_SNAP_POINTS = [
|
|
4059
|
+
{ id: "default", size: "full" }
|
|
4060
|
+
];
|
|
4061
|
+
var BottomSheetFromConfig = forwardRef(function BottomSheetFromConfig2(props, ref) {
|
|
4062
|
+
const { config, eventHandlers, slotContent, stableContent = false } = props;
|
|
4063
|
+
useEffect(() => {
|
|
4064
|
+
validateConfig(config);
|
|
4065
|
+
}, [config]);
|
|
4066
|
+
const memoKey = stableContent ? JSON.stringify(config) : config;
|
|
4067
|
+
const derived = useMemo(() => {
|
|
4068
|
+
const valid = config.version === SUPPORTED_VERSION;
|
|
4069
|
+
const hasSnapPoints = Array.isArray(config.snapPoints) && config.snapPoints.length > 0;
|
|
4070
|
+
return {
|
|
4071
|
+
snapPoints: valid && hasSnapPoints ? config.snapPoints : FALLBACK_SNAP_POINTS,
|
|
4072
|
+
initial: config.initial,
|
|
4073
|
+
allowed: config.allowed,
|
|
4074
|
+
mode: config.mode,
|
|
4075
|
+
animation: config.animation,
|
|
4076
|
+
spring: config.spring,
|
|
4077
|
+
behavior: config.behavior ?? {},
|
|
4078
|
+
events: config.events ?? {}
|
|
4079
|
+
};
|
|
4080
|
+
}, [memoKey]);
|
|
4081
|
+
const handlers = eventHandlers ?? {};
|
|
4082
|
+
const eventsRef = useRef(derived.events);
|
|
4083
|
+
eventsRef.current = derived.events;
|
|
4084
|
+
const handlersRef = useRef(handlers);
|
|
4085
|
+
handlersRef.current = handlers;
|
|
4086
|
+
const sheetRef = useRef(null);
|
|
4087
|
+
useImperativeHandle(
|
|
4088
|
+
ref,
|
|
4089
|
+
() => sheetRef.current,
|
|
4090
|
+
[]
|
|
4091
|
+
);
|
|
4092
|
+
const lastActiveIdRef = useRef(null);
|
|
4093
|
+
const handleChange = (state) => {
|
|
4094
|
+
const prev = lastActiveIdRef.current;
|
|
4095
|
+
const next = state.activeId;
|
|
4096
|
+
lastActiveIdRef.current = next;
|
|
4097
|
+
const evs = eventsRef.current;
|
|
4098
|
+
const hs = handlersRef.current;
|
|
4099
|
+
if (evs.onSnap) {
|
|
4100
|
+
const fn = hs[evs.onSnap];
|
|
4101
|
+
if (typeof fn === "function") {
|
|
4102
|
+
fn(next);
|
|
4103
|
+
} else {
|
|
4104
|
+
console.warn(
|
|
4105
|
+
`[BottomSheetFromConfig] eventHandlers["${evs.onSnap}"] is not a function; onSnap is a no-op.`
|
|
4106
|
+
);
|
|
4107
|
+
}
|
|
4108
|
+
}
|
|
4109
|
+
const isClosedNext = next === "" || next === "closed";
|
|
4110
|
+
const isClosedPrev = prev === null || prev === "" || prev === "closed";
|
|
4111
|
+
if (isClosedPrev && !isClosedNext && evs.onOpen) {
|
|
4112
|
+
const fn = hs[evs.onOpen];
|
|
4113
|
+
if (typeof fn === "function") {
|
|
4114
|
+
fn(next);
|
|
4115
|
+
} else {
|
|
4116
|
+
console.warn(
|
|
4117
|
+
`[BottomSheetFromConfig] eventHandlers["${evs.onOpen}"] is not a function; onOpen is a no-op.`
|
|
4118
|
+
);
|
|
4119
|
+
}
|
|
4120
|
+
}
|
|
4121
|
+
if (!isClosedPrev && isClosedNext && evs.onClose) {
|
|
4122
|
+
const fn = hs[evs.onClose];
|
|
4123
|
+
if (typeof fn === "function") {
|
|
4124
|
+
fn();
|
|
4125
|
+
} else {
|
|
4126
|
+
console.warn(
|
|
4127
|
+
`[BottomSheetFromConfig] eventHandlers["${evs.onClose}"] is not a function; onClose is a no-op.`
|
|
4128
|
+
);
|
|
4129
|
+
}
|
|
4130
|
+
}
|
|
4131
|
+
};
|
|
4132
|
+
return /* @__PURE__ */ jsx(
|
|
4133
|
+
BottomSheet,
|
|
4134
|
+
{
|
|
4135
|
+
ref: sheetRef,
|
|
4136
|
+
snapPoints: derived.snapPoints,
|
|
4137
|
+
initial: derived.initial,
|
|
4138
|
+
allowed: derived.allowed,
|
|
4139
|
+
mode: derived.mode,
|
|
4140
|
+
animation: derived.animation,
|
|
4141
|
+
spring: derived.spring,
|
|
4142
|
+
focusTrap: derived.behavior.focusTrap,
|
|
4143
|
+
closeOnEscape: derived.behavior.closeOnEscape,
|
|
4144
|
+
closeOnBackdrop: derived.behavior.closeOnBackdrop,
|
|
4145
|
+
lockBodyScroll: derived.behavior.lockBodyScroll,
|
|
4146
|
+
rubberBand: derived.behavior.rubberBand,
|
|
4147
|
+
onChange: handleChange,
|
|
4148
|
+
header: slotContent?.header,
|
|
4149
|
+
children: slotContent?.body
|
|
4150
|
+
}
|
|
4151
|
+
);
|
|
4152
|
+
});
|
|
4153
|
+
|
|
4154
|
+
export { BottomSheet, BottomSheetFromConfig, Overlay, useBottomSheet, useOverlay };
|
|
4155
|
+
//# sourceMappingURL=react.js.map
|
|
4156
|
+
//# sourceMappingURL=react.js.map
|