@yourrealm/bottom-sheet 0.0.0-stage → 0.2.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.
@@ -0,0 +1,1448 @@
1
+ 'use client';
2
+ import { t as __exportAll } from "./rolldown-runtime-DODCFbG6.js";
3
+ import { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
4
+ import { createPortal } from "react-dom";
5
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
6
+ // src/core/math.ts
7
+ /** UIScrollView.DecelerationRate.normal. Used to project where a release would come to rest. */
8
+ const DECELERATION = .998;
9
+ /**
10
+ * WWDC18 "Designing Fluid Interfaces": where would motion end if it just decelerated?
11
+ * @param position current scrollTop (px)
12
+ * @param velocity px/s, positive = towards larger scrollTop
13
+ */
14
+ function project(position, velocity, d = DECELERATION) {
15
+ return position + velocity / 1e3 * (d / (1 - d));
16
+ }
17
+ /**
18
+ * All resting positions for this layout, ascending: [closed], usable partials..., full.
19
+ * A partial is usable when it is above 0 and well below full (MIN_DETENT_GAP); duplicates by position are dropped.
20
+ * @param partials requested partial detents with their resolved positions
21
+ * @param full scrollTop of the full detent = min(H, sheet height)
22
+ * @param closable include `closed` (false for a sheet that can't be dismissed)
23
+ */
24
+ function buildStops(partials, full, closable = true) {
25
+ const usable = partials.filter((s) => s.detent !== "closed" && s.detent !== "full" && s.top > 0 && s.top < full * .85).sort((a, b) => a.top - b.top).filter((s, i, all) => i === 0 || Math.abs(s.top - all[i - 1].top) >= 1);
26
+ return [
27
+ ...closable ? [{
28
+ detent: "closed",
29
+ top: 0
30
+ }] : [],
31
+ ...usable,
32
+ {
33
+ detent: "full",
34
+ top: full
35
+ }
36
+ ];
37
+ }
38
+ /** The stop nearest to `top`. */
39
+ function nearestStop(top, stops) {
40
+ let best = stops[0];
41
+ for (const s of stops) if (Math.abs(s.top - top) < Math.abs(best.top - top)) best = s;
42
+ return best;
43
+ }
44
+ /** The stop resting exactly at `top` (±tolerance), or null when between stops / inside content. */
45
+ function stopAt(top, stops, tolerance = 2) {
46
+ return stops.find((s) => Math.abs(s.top - top) < tolerance) ?? null;
47
+ }
48
+ /**
49
+ * The stop for a requested detent: itself when usable, otherwise the stop nearest to where it would have been.
50
+ * @param rawTop where the requested detent would sit (px), used when it isn't a usable stop
51
+ */
52
+ function resolveStop(d, rawTop, stops) {
53
+ return stops.find((s) => s.detent === d) ?? nearestStop(rawTop, stops);
54
+ }
55
+ /**
56
+ * CSS-style cubic-bezier easing (x1, y1, x2, y2 within 0..1 on x): returns progress -> eased progress.
57
+ * Solves x(t) = k by Newton's method with a bisection fallback.
58
+ */
59
+ function cubicBezier(x1, y1, x2, y2) {
60
+ const cx = 3 * x1, bx = 3 * (x2 - x1) - cx, ax = 1 - cx - bx;
61
+ const cy = 3 * y1, by = 3 * (y2 - y1) - cy, ay = 1 - cy - by;
62
+ const x = (t) => ((ax * t + bx) * t + cx) * t;
63
+ const dx = (t) => (3 * ax * t + 2 * bx) * t + cx;
64
+ const y = (t) => ((ay * t + by) * t + cy) * t;
65
+ return (k) => {
66
+ if (k <= 0) return 0;
67
+ if (k >= 1) return 1;
68
+ let t = k;
69
+ for (let i = 0; i < 8; i++) {
70
+ const err = x(t) - k;
71
+ if (Math.abs(err) < 1e-6) return y(t);
72
+ const d = dx(t);
73
+ if (Math.abs(d) < 1e-6) break;
74
+ t -= err / d;
75
+ }
76
+ let lo = 0, hi = 1;
77
+ t = k;
78
+ for (let i = 0; i < 40; i++) {
79
+ const v = x(t);
80
+ if (Math.abs(v - k) < 1e-6) break;
81
+ if (v < k) lo = t;
82
+ else hi = t;
83
+ t = (lo + hi) / 2;
84
+ }
85
+ return y(t);
86
+ };
87
+ }
88
+ /** Material 3 "emphasized decelerate": for things arriving (a sheet opening or moving up). */
89
+ const easeDecelerate = cubicBezier(.05, .7, .1, 1);
90
+ /** Material 3 "emphasized accelerate": for things leaving (a sheet closing or moving down). */
91
+ const easeAccelerate = cubicBezier(.3, 0, .8, .15);
92
+ // src/core/controller.ts
93
+ /** scrollTop at or below this counts as closed (browsers settle on sub-pixel values, e.g. 1.02 in Firefox). */
94
+ const CLOSED_TOLERANCE = 2;
95
+ const LOCK_QUIET_MS = 150;
96
+ /** A fling can outlive a short quiet window and resume when overflow is restored (seen on Chrome and Firefox Android). */
97
+ const LOCK_MIN_MS = 800;
98
+ const WHEEL_GESTURE_GAP_MS = 250;
99
+ const KEYBOARD_THRESHOLD = 120;
100
+ const MAX_RETARGETS = 3;
101
+ /** A resize this soon after a user scroll event is mid-gesture or mid-momentum: don't re-target. */
102
+ const USER_SCROLL_QUIET = 100;
103
+ const VELOCITY_WINDOW_MS = 80;
104
+ /** A touch that starts this soon after a scroll event lands on moving content: it only stops the scroll. */
105
+ const MOVING_MS = 100;
106
+ /** A press is a tap while it moves less than this (px) and, on touch, ends within TAP_MS. */
107
+ const TAP_SLOP = 10;
108
+ const TAP_MS = 500;
109
+ /** The click a dropped press would produce follows within this. */
110
+ const CLICK_AFTER_MS = 1e3;
111
+ /**
112
+ * Arriving at closed closes the sheet only if we moved it there or the user touched, wheeled, dragged or pressed a
113
+ * key within this long. Any other scroll (the browser scrolling a focused element into view, an app's
114
+ * scrollIntoView, a test runner) that snaps to closed is undone. Long enough for a fling to finish.
115
+ */
116
+ const STRAY_MS = 3e3;
117
+ const INPUT_EVENTS = [
118
+ "touchstart",
119
+ "touchend",
120
+ "wheel",
121
+ "pointerdown",
122
+ "pointerup",
123
+ "keydown"
124
+ ];
125
+ /**
126
+ * Programmatic moves (open, close, snapTo, wheel steps, keyboard) are animated here, not by the browser's smooth
127
+ * scroll: that took 660-770ms on a phone (Chrome/Firefox Android, measured 2026-11-01) and varies by browser.
128
+ * Platform guidance for a sheet: ~400ms decelerating in, ~30% faster accelerating out.
129
+ */
130
+ const GLIDE_UP_MS = 400;
131
+ const GLIDE_DOWN_MS = 280;
132
+ let htmlLocks = 0;
133
+ let htmlPrev = {
134
+ overflow: "",
135
+ gutter: ""
136
+ };
137
+ function lockHtml() {
138
+ if (htmlLocks++ === 0) {
139
+ const html = document.documentElement;
140
+ htmlPrev = {
141
+ overflow: html.style.overflow,
142
+ gutter: html.style.scrollbarGutter
143
+ };
144
+ if (window.innerWidth - html.clientWidth > 0) html.style.scrollbarGutter = "stable";
145
+ html.style.overflow = "hidden";
146
+ }
147
+ }
148
+ function unlockHtml() {
149
+ if (htmlLocks > 0 && --htmlLocks === 0) {
150
+ const html = document.documentElement;
151
+ html.style.overflow = htmlPrev.overflow;
152
+ html.style.scrollbarGutter = htmlPrev.gutter;
153
+ }
154
+ }
155
+ function createSheetController(els, options) {
156
+ const { root, scroller, sheet } = els;
157
+ const markerHost = els.markers;
158
+ const dbg = (m) => options.debug?.(m);
159
+ const modal = options.modal ?? true;
160
+ let requested = [...options.detents];
161
+ let dismissible = options.dismissible ?? true;
162
+ let closing = false;
163
+ const closable = () => dismissible || closing;
164
+ let H = 0;
165
+ let full = 0;
166
+ let sheetH = 0;
167
+ let stops = [];
168
+ const markers = /* @__PURE__ */ new Map();
169
+ const closedMarker = makeMarker();
170
+ closedMarker.style.top = "0px";
171
+ markerHost.append(closedMarker);
172
+ const probe = document.createElement("div");
173
+ probe.setAttribute("aria-hidden", "true");
174
+ probe.style.cssText = "position:absolute;top:0;left:0;width:0;visibility:hidden;pointer-events:none";
175
+ markerHost.append(probe);
176
+ let isOpen = false;
177
+ let destroyed = false;
178
+ let touching = false;
179
+ let anchor = null;
180
+ let programmatic = false;
181
+ let target = null;
182
+ let current = null;
183
+ /** Last scroll event the user caused (finger, momentum, wheel): not ours, not the momentum lock's pin. */
184
+ let lastUserScroll = -Infinity;
185
+ let emitted = null;
186
+ let retries = 0;
187
+ let lock = null;
188
+ let unlockTimer;
189
+ let scrollEndTimer;
190
+ let lastWheel = 0;
191
+ let pageY = 0;
192
+ let keyboard = false;
193
+ let htmlLocked = false;
194
+ const releaseHtml = () => {
195
+ if (htmlLocked) unlockHtml();
196
+ htmlLocked = false;
197
+ };
198
+ const reducedMotion = () => matchMedia("(prefers-reduced-motion: reduce)").matches;
199
+ /** Where a detent would sit (px), whether or not it is usable in this layout. */
200
+ function rawTop(d) {
201
+ if (d === "closed") return 0;
202
+ if (d === "full") return full;
203
+ if (typeof d === "number") return H * d;
204
+ probe.style.height = d === "header" ? `calc(${headerBottom()}px + var(--ybs-header-detent-offset, 0px))` : d;
205
+ return probe.getBoundingClientRect().height;
206
+ }
207
+ /**
208
+ * The `'header'` detent: how far the header reaches into the sheet (its bottom edge), so the peek shows exactly
209
+ * the header. Measured only while the header isn't stuck (content scrolled moves a sticky header down the sheet);
210
+ * otherwise the last measurement is kept. Styles can add to it with --ybs-header-detent-offset (/styled's floating
211
+ * adds its bottom gap).
212
+ */
213
+ let headerPx = 0;
214
+ function headerBottom() {
215
+ const header = sheet.querySelector("[data-ybs-header]");
216
+ if (!header) return 0;
217
+ if (scroller.scrollTop <= full + 1) {
218
+ const box = sheet.getBoundingClientRect();
219
+ const scale = sheet.offsetHeight > 0 ? box.height / sheet.offsetHeight : 1;
220
+ headerPx = (header.getBoundingClientRect().bottom - box.top) / (scale || 1);
221
+ }
222
+ return headerPx;
223
+ }
224
+ const resolve = (d) => resolveStop(d, rawTop(d), stops);
225
+ const pos = (d) => resolve(d).top;
226
+ /** The lowest resting detent that isn't closed; also where open() goes by default. */
227
+ const floor = () => stops.find((s) => s.detent !== "closed").detent;
228
+ function makeMarker() {
229
+ const m = document.createElement("div");
230
+ m.setAttribute("data-ybs-marker", "");
231
+ m.setAttribute("aria-hidden", "true");
232
+ return m;
233
+ }
234
+ function layout() {
235
+ H = scroller.clientHeight;
236
+ sheetH = sheet.offsetHeight;
237
+ full = Math.min(H, sheetH);
238
+ const partials = requested.filter((d) => d !== "closed" && d !== "full").map((d) => ({
239
+ detent: d,
240
+ top: rawTop(d)
241
+ }));
242
+ stops = buildStops(partials, full, closable());
243
+ root.style.setProperty("--ybs-h", `${H}px`);
244
+ root.style.setProperty("--ybs-header-h", `${headerBottom()}px`);
245
+ const bleed = root.querySelector("[data-ybs-bleed]");
246
+ if (bleed) bleed.style.colorScheme = getComputedStyle(sheet).colorScheme;
247
+ root.toggleAttribute("data-ybs-fit", sheetH < H);
248
+ closedMarker.hidden = !closable();
249
+ for (const [d, m] of markers) if (!requested.includes(d)) {
250
+ m.remove();
251
+ markers.delete(d);
252
+ }
253
+ for (const p of partials) {
254
+ let m = markers.get(p.detent);
255
+ if (!m) {
256
+ m = makeMarker();
257
+ closedMarker.after(m);
258
+ markers.set(p.detent, m);
259
+ }
260
+ m.style.top = `${p.top}px`;
261
+ m.toggleAttribute("data-ybs-catch", p.detent === "header");
262
+ m.hidden = !stops.some((s) => s.detent === p.detent);
263
+ }
264
+ }
265
+ function scrollToDetent(d) {
266
+ target = resolve(d).detent;
267
+ dbg(`scrollTo ${String(target)} (${Math.round(pos(target))})`);
268
+ programmatic = true;
269
+ anchor = null;
270
+ retries = 0;
271
+ unlock();
272
+ if (Math.abs(scroller.scrollTop - pos(target)) < 1) {
273
+ stopGlide();
274
+ requestAnimationFrame(onScrollEnd);
275
+ return;
276
+ }
277
+ glide();
278
+ }
279
+ let gliding = false;
280
+ let glideRaf = 0;
281
+ function glide() {
282
+ stopGlide();
283
+ if (target == null) return;
284
+ const from = scroller.scrollTop;
285
+ const up = pos(target) > from;
286
+ const ms = reducedMotion() ? 0 : up ? GLIDE_UP_MS : GLIDE_DOWN_MS;
287
+ if (ms === 0) {
288
+ scroller.scrollTop = pos(target);
289
+ requestAnimationFrame(onScrollEnd);
290
+ return;
291
+ }
292
+ const ease = up ? easeDecelerate : easeAccelerate;
293
+ gliding = true;
294
+ root.setAttribute("data-ybs-glide", "");
295
+ let t0 = null;
296
+ const step = (now) => {
297
+ if (destroyed || !gliding || target == null) return;
298
+ t0 ??= now;
299
+ const k = Math.min((now - t0) / ms, 1);
300
+ scroller.scrollTop = from + (pos(target) - from) * ease(k);
301
+ if (k < 1) glideRaf = requestAnimationFrame(step);
302
+ else {
303
+ stopGlide();
304
+ onScrollEnd();
305
+ }
306
+ };
307
+ glideRaf = requestAnimationFrame(step);
308
+ }
309
+ function stopGlide() {
310
+ if (!gliding) return;
311
+ gliding = false;
312
+ cancelAnimationFrame(glideRaf);
313
+ root.removeAttribute("data-ybs-glide");
314
+ }
315
+ let lockedAt = 0;
316
+ function clamp(top) {
317
+ dbg(`clamp @${Math.round(scroller.scrollTop)} -> ${Math.round(top)} (anchor ${anchor == null ? "-" : Math.round(anchor)})`);
318
+ lock = top;
319
+ lockedAt = performance.now();
320
+ scroller.style.overflowY = "hidden";
321
+ scroller.scrollTop = top;
322
+ armUnlock();
323
+ anchor = top;
324
+ }
325
+ function armUnlock() {
326
+ clearTimeout(unlockTimer);
327
+ const minLeft = LOCK_MIN_MS - (performance.now() - lockedAt);
328
+ unlockTimer = setTimeout(unlock, Math.max(LOCK_QUIET_MS, minLeft));
329
+ }
330
+ function unlock() {
331
+ if (lock == null) return;
332
+ const pinned = lock;
333
+ lock = null;
334
+ scroller.style.overflowY = "";
335
+ scroller.scrollTop = pinned;
336
+ dbg(`unlock @${Math.round(scroller.scrollTop)}`);
337
+ requestAnimationFrame(() => {
338
+ if (lock != null || touching || programmatic) return;
339
+ const st = scroller.scrollTop;
340
+ if (Math.abs(st - pinned) > 2) dbg(`drift after unlock: ${Math.round(pinned)} -> ${Math.round(st)}`);
341
+ onScrollEnd();
342
+ });
343
+ }
344
+ /**
345
+ * Paint the position vars. `drop` is how far a header drag has pulled the whole sheet down while its content
346
+ * stays scrolled (see detachedDrag).
347
+ */
348
+ let scrolled = false;
349
+ let more = false;
350
+ function paint(st, drop = 0) {
351
+ const shown = Math.max(Math.min(st, full) - drop, 0);
352
+ const progress = full > 0 ? Math.min(Math.max(shown / full, 0), 1) : 0;
353
+ root.style.setProperty("--ybs-progress", String(progress));
354
+ root.style.setProperty("--ybs-visible", `${drop > 0 ? st : Math.min(st, full)}px`);
355
+ options.onProgress?.(progress, H > 0 ? shown / H : 0);
356
+ root.style.setProperty("--ybs-bleed", st > 4 && drop === 0 ? "1" : "0");
357
+ const s = st > full + 1;
358
+ if (s !== scrolled) {
359
+ scrolled = s;
360
+ root.toggleAttribute("data-ybs-scrolled", s);
361
+ }
362
+ const m = sheetH > H + 1 && st < scroller.scrollHeight - scroller.clientHeight - 2;
363
+ if (m !== more) {
364
+ more = m;
365
+ root.toggleAttribute("data-ybs-more", m);
366
+ }
367
+ }
368
+ function onScroll() {
369
+ const st = scroller.scrollTop;
370
+ lastScrollAt = performance.now();
371
+ if (!programmatic && lock == null) lastUserScroll = performance.now();
372
+ if (!detached) paint(st);
373
+ if (lock != null && !touching) {
374
+ if (Math.abs(st - lock) > .5) scroller.scrollTop = lock;
375
+ armUnlock();
376
+ } else if (!touching && !programmatic && anchor != null) {
377
+ if (anchor > full + 2 && st < full - 2 || anchor < full - 2 && st > full + 2) clamp(full);
378
+ }
379
+ if (!("onscrollend" in window)) {
380
+ clearTimeout(scrollEndTimer);
381
+ scrollEndTimer = setTimeout(onScrollEnd, 120);
382
+ }
383
+ }
384
+ function onScrollEnd() {
385
+ if (destroyed || gliding) return;
386
+ if (isOpen && !closing && full < 1 && programmatic) return;
387
+ const st = scroller.scrollTop;
388
+ dbg(`scrollend @${Math.round(st)} prog=${programmatic} target=${String(target)} lock=${String(lock)}`);
389
+ if (programmatic && target != null && !touching) {
390
+ const to = pos(target);
391
+ if (Math.abs(st - to) > 2 && retries++ < MAX_RETARGETS) {
392
+ glide();
393
+ return;
394
+ }
395
+ }
396
+ retries = 0;
397
+ if (root.getAttribute("data-ybs-drag") === "settle") root.removeAttribute("data-ybs-drag");
398
+ if (closing && isOpen && !belowFloor(st)) {
399
+ if (touching) dbg("closing: waiting for the finger to lift");
400
+ else scrollToDetent("closed");
401
+ return;
402
+ }
403
+ if (isOpen && !closing && !programmatic && !touching && belowFloor(st) && performance.now() - lastInputAt > STRAY_MS) {
404
+ const back = current && current !== "closed" ? current : floor();
405
+ dbg(`scrolled to closed without user input: back to ${String(back)}`);
406
+ scrollToDetent(back);
407
+ return;
408
+ }
409
+ const now = stopAt(st, stops)?.detent ?? null;
410
+ programmatic = false;
411
+ target = null;
412
+ current = now;
413
+ if (now != null && now !== "closed" && now !== emitted && isOpen) {
414
+ emitted = now;
415
+ options.onDetentChange?.(now);
416
+ }
417
+ if (isOpen && belowFloor(st)) {
418
+ if (touching) dbg(`at closed, waiting for the finger to lift (st=${st.toFixed(2)})`);
419
+ else settleAtClosed();
420
+ }
421
+ }
422
+ /**
423
+ * Resting where only closing makes sense: at 0, or, for a sheet that can't be dismissed, anywhere below its
424
+ * lowest detent. Firefox Android leaves a swipe there (46px under a 76px floor) instead of snapping up to it.
425
+ */
426
+ function belowFloor(st) {
427
+ return st <= CLOSED_TOLERANCE || !closable() && st < pos(floor()) - CLOSED_TOLERANCE;
428
+ }
429
+ /** Resting at 0: close, or for a sheet that can't be dismissed, go back up to its floor. */
430
+ function settleAtClosed() {
431
+ if (closable()) finishClose();
432
+ else {
433
+ dbg("not dismissible: back to the lowest detent");
434
+ scrollToDetent(floor());
435
+ }
436
+ }
437
+ let lastScrollAt = -Infinity;
438
+ /** Last touch, wheel, pointer or key input on the sheet: scrolls after it are the user's (see STRAY_MS). */
439
+ let lastInputAt = -Infinity;
440
+ const onInput = () => lastInputAt = performance.now();
441
+ let press = null;
442
+ let dropClickUntil = 0;
443
+ const isOutside = (el) => el instanceof Element && !el.closest("[data-ybs-sheet]");
444
+ function onPressStart(e) {
445
+ dropClickUntil = 0;
446
+ const touch = e.pointerType !== "mouse";
447
+ if (!touch) endDrag?.();
448
+ if (!touch && e.button !== 0) return void (press = null);
449
+ const now = performance.now();
450
+ press = {
451
+ id: e.pointerId,
452
+ x: e.clientX,
453
+ y: e.clientY,
454
+ t: now,
455
+ touch,
456
+ moving: touch && now - lastScrollAt < MOVING_MS,
457
+ outside: isOutside(e.target),
458
+ dragged: false
459
+ };
460
+ }
461
+ function onPressMove(e) {
462
+ if (press?.id === e.pointerId && Math.hypot(e.clientX - press.x, e.clientY - press.y) > TAP_SLOP) press.dragged = true;
463
+ }
464
+ /** For drags that start below TAP_SLOP (the header drag starts at 4px). */
465
+ const markDragged = () => {
466
+ if (press) press.dragged = true;
467
+ };
468
+ function onPressEnd(e) {
469
+ const p = press;
470
+ press = null;
471
+ if (!p || p.id !== e.pointerId) return;
472
+ const now = performance.now();
473
+ if (p.moving || p.dragged) {
474
+ if (p.moving) dbg("tap stopped the scroll: click dropped");
475
+ dropClickUntil = now + CLICK_AFTER_MS;
476
+ return;
477
+ }
478
+ if (p.outside && (!p.touch || now - p.t < TAP_MS)) {
479
+ dropClickUntil = now + CLICK_AFTER_MS;
480
+ options.onOutsideTap?.();
481
+ }
482
+ }
483
+ const onPressCancel = () => press = null;
484
+ function onClickCapture(e) {
485
+ if (performance.now() > dropClickUntil) return;
486
+ dropClickUntil = 0;
487
+ e.preventDefault();
488
+ e.stopPropagation();
489
+ }
490
+ function onTouchStart() {
491
+ touching = true;
492
+ anchor = null;
493
+ if (closing) return;
494
+ stopGlide();
495
+ programmatic = false;
496
+ unlock();
497
+ }
498
+ function onTouchEnd(e) {
499
+ if (e.touches.length) return;
500
+ touching = false;
501
+ anchor = scroller.scrollTop;
502
+ dbg(`release @${Math.round(anchor)}`);
503
+ requestAnimationFrame(() => {
504
+ if (touching || !isOpen || destroyed) return;
505
+ if (closing && !gliding && !belowFloor(scroller.scrollTop)) {
506
+ dbg("finger lifted while closing: carry on");
507
+ scrollToDetent("closed");
508
+ } else if (!programmatic && belowFloor(scroller.scrollTop)) {
509
+ dbg("finger lifted at closed");
510
+ settleAtClosed();
511
+ }
512
+ });
513
+ }
514
+ let wheelStepped = false;
515
+ function onWheel(e) {
516
+ if (e.ctrlKey) return;
517
+ if (closing) return void e.preventDefault();
518
+ const t = performance.now();
519
+ const newGesture = t - lastWheel > WHEEL_GESTURE_GAP_MS;
520
+ lastWheel = t;
521
+ const st = scroller.scrollTop;
522
+ const dy = e.deltaY;
523
+ const inSheetTravel = st < full - 1 || st <= full + 1 && dy < 0;
524
+ if (newGesture) {
525
+ stopGlide();
526
+ unlock();
527
+ anchor = st;
528
+ programmatic = false;
529
+ wheelStepped = false;
530
+ }
531
+ const startedInContent = anchor != null && anchor > full + 1;
532
+ if (!inSheetTravel || dy === 0 || Math.abs(e.deltaX) > Math.abs(dy)) return;
533
+ e.preventDefault();
534
+ if (startedInContent) return;
535
+ if (wheelStepped) return;
536
+ wheelStepped = true;
537
+ const here = nearestStop(target != null ? pos(target) : st, stops);
538
+ const i = stops.indexOf(here);
539
+ const next = stops[Math.min(Math.max(i + (dy > 0 ? 1 : -1), 0), stops.length - 1)].detent;
540
+ dbg(`wheel step ${String(here.detent)} -> ${String(next)}`);
541
+ if (next !== here.detent) scrollToDetent(next);
542
+ }
543
+ let detached = false;
544
+ let dropAnim = 0;
545
+ /** Ends a running header drag without a release (a new press arrived first, or destroy). */
546
+ let endDrag = null;
547
+ function detachedDrag(e, handle) {
548
+ stopGlide();
549
+ unlock();
550
+ cancelAnimationFrame(dropAnim);
551
+ const st0 = scroller.scrollTop;
552
+ const y0 = e.clientY;
553
+ const samples = [{
554
+ t: e.timeStamp,
555
+ y: e.clientY
556
+ }];
557
+ let drop = 0;
558
+ let started = false;
559
+ touching = true;
560
+ programmatic = false;
561
+ anchor = null;
562
+ dbg(`detached drag start @${Math.round(st0)}`);
563
+ const setDrop = (d) => {
564
+ drop = d;
565
+ scroller.style.transform = d > 0 ? `translate3d(0, ${d}px, 0)` : "";
566
+ paint(st0, d);
567
+ };
568
+ const id = e.pointerId;
569
+ const move = (ev) => {
570
+ if (ev.pointerId !== id) return;
571
+ const dy = ev.clientY - y0;
572
+ if (!started && Math.abs(dy) < 4) return;
573
+ started = true;
574
+ detached = true;
575
+ markDragged();
576
+ if (handle.isConnected) handle.setPointerCapture(ev.pointerId);
577
+ root.setAttribute("data-ybs-drag", "active");
578
+ if (scroller.scrollTop !== st0) scroller.scrollTop = st0;
579
+ setDrop(Math.max(dy, 0));
580
+ samples.push({
581
+ t: ev.timeStamp,
582
+ y: ev.clientY
583
+ });
584
+ while (samples.length > 2 && ev.timeStamp - samples[0].t > VELOCITY_WINDOW_MS) samples.shift();
585
+ };
586
+ const stop = () => {
587
+ window.removeEventListener("pointermove", move, true);
588
+ window.removeEventListener("pointerup", up, true);
589
+ window.removeEventListener("pointercancel", up, true);
590
+ endDrag = null;
591
+ touching = false;
592
+ root.removeAttribute("data-ybs-drag");
593
+ };
594
+ const up = (ev) => {
595
+ if (ev.pointerId !== id) return;
596
+ stop();
597
+ if (!started) return;
598
+ const a = samples[0];
599
+ const b = samples[samples.length - 1];
600
+ const dt = b.t - a.t;
601
+ const down = dt > 0 && ev.timeStamp - b.t < 50 ? (b.y - a.y) / dt * 1e3 : 0;
602
+ const to = nearestStop(project(full - drop, -down), stops).detent;
603
+ dbg(`detached drag release drop=${Math.round(drop)} v=${Math.round(down)} -> ${String(to)}`);
604
+ const end = to === "full" ? 0 : full - pos(to);
605
+ animateDrop(drop, end, setDrop, () => {
606
+ scroller.style.transform = "";
607
+ detached = false;
608
+ if (closing) return scrollToDetent("closed");
609
+ if (to === "full") {
610
+ paint(scroller.scrollTop);
611
+ return;
612
+ }
613
+ target = to;
614
+ programmatic = true;
615
+ scroller.scrollTop = pos(to);
616
+ paint(scroller.scrollTop);
617
+ if (to === "closed" || belowFloor(scroller.scrollTop)) settleAtClosed();
618
+ else requestAnimationFrame(onScrollEnd);
619
+ });
620
+ };
621
+ window.addEventListener("pointermove", move, true);
622
+ window.addEventListener("pointerup", up, true);
623
+ window.addEventListener("pointercancel", up, true);
624
+ endDrag = () => {
625
+ stop();
626
+ if (!started) return;
627
+ scroller.style.transform = "";
628
+ detached = false;
629
+ paint(scroller.scrollTop);
630
+ };
631
+ }
632
+ function animateDrop(from, to, set, done) {
633
+ const ms = reducedMotion() ? 0 : Math.min(380, 180 + Math.abs(to - from) * .4);
634
+ const t0 = performance.now();
635
+ const step = () => {
636
+ if (destroyed) return;
637
+ const k = ms === 0 ? 1 : Math.min((performance.now() - t0) / ms, 1);
638
+ const eased = 1 - (1 - k) ** 3;
639
+ set(from + (to - from) * eased);
640
+ if (k < 1) dropAnim = requestAnimationFrame(step);
641
+ else done();
642
+ };
643
+ dropAnim = requestAnimationFrame(step);
644
+ }
645
+ function onPointerDown(e) {
646
+ if (e.button !== 0 || !isOpen || detached || closing) return;
647
+ const el = e.target;
648
+ const handle = el?.closest("[data-ybs-drag]");
649
+ if (!handle) return;
650
+ if (scroller.scrollTop > full + 1) return detachedDrag(e, handle);
651
+ if (el?.closest("input, textarea, select, button, a, [contenteditable]")) return;
652
+ if (e.pointerType !== "mouse") return;
653
+ e.preventDefault();
654
+ handle.setPointerCapture(e.pointerId);
655
+ stopGlide();
656
+ unlock();
657
+ const startTop = scroller.scrollTop;
658
+ const y0 = e.clientY;
659
+ const maxTop = scroller.scrollHeight - scroller.clientHeight;
660
+ const samples = [{
661
+ t: e.timeStamp,
662
+ y: e.clientY
663
+ }];
664
+ root.setAttribute("data-ybs-drag", "active");
665
+ touching = true;
666
+ programmatic = false;
667
+ anchor = null;
668
+ const stop = () => {
669
+ handle.removeEventListener("pointermove", move);
670
+ handle.removeEventListener("pointerup", up);
671
+ handle.removeEventListener("pointercancel", up);
672
+ handle.removeEventListener("lostpointercapture", up);
673
+ endDrag = null;
674
+ touching = false;
675
+ };
676
+ const move = (ev) => {
677
+ scroller.scrollTop = Math.min(Math.max(startTop - (ev.clientY - y0), 0), maxTop);
678
+ samples.push({
679
+ t: ev.timeStamp,
680
+ y: ev.clientY
681
+ });
682
+ while (samples.length > 2 && ev.timeStamp - samples[0].t > VELOCITY_WINDOW_MS) samples.shift();
683
+ };
684
+ const up = (ev) => {
685
+ stop();
686
+ const top = scroller.scrollTop;
687
+ if (closing) return scrollToDetent("closed");
688
+ if (Math.abs(ev.clientY - y0) < 3 || top > full + 1) {
689
+ root.removeAttribute("data-ybs-drag");
690
+ return;
691
+ }
692
+ const a = samples[0];
693
+ const b = samples[samples.length - 1];
694
+ const dt = b.t - a.t;
695
+ const v = dt > 0 && ev.timeStamp - b.t < 50 ? -(b.y - a.y) / dt * 1e3 : 0;
696
+ root.setAttribute("data-ybs-drag", "settle");
697
+ scrollToDetent(nearestStop(project(top, v), stops).detent);
698
+ };
699
+ handle.addEventListener("pointermove", move);
700
+ handle.addEventListener("pointerup", up);
701
+ handle.addEventListener("pointercancel", up);
702
+ handle.addEventListener("lostpointercapture", up);
703
+ endDrag = () => {
704
+ stop();
705
+ root.removeAttribute("data-ybs-drag");
706
+ };
707
+ }
708
+ const KEYS_UP = /* @__PURE__ */ new Set([
709
+ "ArrowUp",
710
+ "PageUp",
711
+ "Home"
712
+ ]);
713
+ function onKeyDown(e) {
714
+ if (!(KEYS_UP.has(e.key) || e.key === " " && e.shiftKey) || e.defaultPrevented || scroller.scrollTop > full + 1) return;
715
+ const el = e.target;
716
+ if (el?.closest("input, textarea, select, [contenteditable=\"\"], [contenteditable=\"true\"]")) return;
717
+ if (e.key === " " && el?.closest("button, a, [role=\"button\"]")) return;
718
+ e.preventDefault();
719
+ }
720
+ function syncViewport() {
721
+ const vv = window.visualViewport;
722
+ if (!vv || !isOpen) return;
723
+ root.style.setProperty("--ybs-vv-top", `${vv.offsetTop}px`);
724
+ root.style.setProperty("--ybs-vv-h", `${vv.height}px`);
725
+ if (modal && Math.abs(window.scrollY - pageY) > .5) window.scrollTo(0, pageY);
726
+ const kb = window.innerHeight - vv.height * vv.scale > KEYBOARD_THRESHOLD;
727
+ if (kb !== keyboard) {
728
+ keyboard = kb;
729
+ root.toggleAttribute("data-ybs-keyboard", kb);
730
+ if (kb && !closing && (options.isActive?.() ?? true) && scroller.scrollTop < full - 1 && target !== "full") requestAnimationFrame(() => {
731
+ if (destroyed || !isOpen || closing) return;
732
+ layout();
733
+ if (scroller.scrollTop < full - 1) scrollToDetent("full");
734
+ });
735
+ }
736
+ }
737
+ function onWindowScroll() {
738
+ if (isOpen) syncViewport();
739
+ }
740
+ const ro = new ResizeObserver(() => {
741
+ if (!isOpen) return;
742
+ const wasEmpty = full < 1;
743
+ layout();
744
+ if (wasEmpty && full >= 1 && programmatic && target != null && !gliding) return glide();
745
+ dbg(`resize H=${H} full=${full}`);
746
+ if (touching) return;
747
+ if (!programmatic && performance.now() - lastUserScroll < USER_SCROLL_QUIET) return;
748
+ const d = programmatic ? target : current;
749
+ if (d == null) return;
750
+ if (gliding) return;
751
+ scroller.scrollTop = pos(d);
752
+ });
753
+ function finishClose() {
754
+ dbg("finishClose");
755
+ isOpen = false;
756
+ current = null;
757
+ emitted = null;
758
+ closing = false;
759
+ releaseHtml();
760
+ options.onClosed?.();
761
+ }
762
+ const passive = { passive: true };
763
+ scroller.addEventListener("scroll", onScroll, passive);
764
+ scroller.addEventListener("scrollend", onScrollEnd);
765
+ scroller.addEventListener("touchstart", onTouchStart, passive);
766
+ scroller.addEventListener("pointerdown", onPressStart, true);
767
+ for (const t of INPUT_EVENTS) scroller.addEventListener(t, onInput, {
768
+ capture: true,
769
+ passive: true
770
+ });
771
+ scroller.addEventListener("pointermove", onPressMove, true);
772
+ scroller.addEventListener("pointerup", onPressEnd, true);
773
+ scroller.addEventListener("pointercancel", onPressCancel, true);
774
+ scroller.addEventListener("click", onClickCapture, true);
775
+ scroller.addEventListener("touchend", onTouchEnd, passive);
776
+ scroller.addEventListener("touchcancel", onTouchEnd, passive);
777
+ scroller.addEventListener("wheel", onWheel, { passive: false });
778
+ scroller.addEventListener("keydown", onKeyDown);
779
+ sheet.addEventListener("pointerdown", onPointerDown);
780
+ window.visualViewport?.addEventListener("resize", syncViewport);
781
+ window.visualViewport?.addEventListener("scroll", syncViewport);
782
+ window.addEventListener("scroll", onWindowScroll, passive);
783
+ ro.observe(scroller);
784
+ ro.observe(sheet);
785
+ return {
786
+ get detent() {
787
+ return current;
788
+ },
789
+ open(detent) {
790
+ if (detent === "closed") detent = void 0;
791
+ if (isOpen) {
792
+ if (closing) {
793
+ closing = false;
794
+ layout();
795
+ }
796
+ scrollToDetent(detent ?? floor());
797
+ return;
798
+ }
799
+ isOpen = true;
800
+ closing = false;
801
+ pageY = window.scrollY;
802
+ keyboard = false;
803
+ if (modal && !htmlLocked) {
804
+ lockHtml();
805
+ htmlLocked = true;
806
+ }
807
+ syncViewport();
808
+ layout();
809
+ scroller.scrollTop = 0;
810
+ target = resolve(detent ?? floor()).detent;
811
+ programmatic = true;
812
+ scrollToDetent(target);
813
+ },
814
+ snapTo(detent) {
815
+ if (isOpen && !closing) scrollToDetent(detent);
816
+ },
817
+ close() {
818
+ if (!isOpen) return;
819
+ if (!closing) {
820
+ closing = true;
821
+ if (!dismissible) layout();
822
+ }
823
+ if (scroller.scrollTop <= CLOSED_TOLERANCE) finishClose();
824
+ else scrollToDetent("closed");
825
+ },
826
+ collapse() {
827
+ if (isOpen && !closing) scrollToDetent(floor());
828
+ },
829
+ setDetents(next) {
830
+ requested = [...next];
831
+ if (!isOpen) return;
832
+ layout();
833
+ if (!closing && current != null && !stops.some((s) => s.detent === current)) scrollToDetent(current);
834
+ },
835
+ setDismissible(next) {
836
+ dismissible = next;
837
+ if (isOpen) layout();
838
+ },
839
+ destroy() {
840
+ destroyed = true;
841
+ isOpen = false;
842
+ endDrag?.();
843
+ releaseHtml();
844
+ clearTimeout(unlockTimer);
845
+ clearTimeout(scrollEndTimer);
846
+ stopGlide();
847
+ cancelAnimationFrame(dropAnim);
848
+ scroller.style.transform = "";
849
+ ro.disconnect();
850
+ scroller.removeEventListener("scroll", onScroll);
851
+ scroller.removeEventListener("scrollend", onScrollEnd);
852
+ scroller.removeEventListener("touchstart", onTouchStart);
853
+ scroller.removeEventListener("pointerdown", onPressStart, true);
854
+ for (const t of INPUT_EVENTS) scroller.removeEventListener(t, onInput, true);
855
+ scroller.removeEventListener("pointermove", onPressMove, true);
856
+ scroller.removeEventListener("pointerup", onPressEnd, true);
857
+ scroller.removeEventListener("pointercancel", onPressCancel, true);
858
+ scroller.removeEventListener("click", onClickCapture, true);
859
+ scroller.removeEventListener("touchend", onTouchEnd);
860
+ scroller.removeEventListener("touchcancel", onTouchEnd);
861
+ scroller.removeEventListener("wheel", onWheel);
862
+ scroller.removeEventListener("keydown", onKeyDown);
863
+ sheet.removeEventListener("pointerdown", onPointerDown);
864
+ window.visualViewport?.removeEventListener("resize", syncViewport);
865
+ window.visualViewport?.removeEventListener("scroll", syncViewport);
866
+ window.removeEventListener("scroll", onWindowScroll);
867
+ closedMarker.remove();
868
+ probe.remove();
869
+ for (const m of markers.values()) m.remove();
870
+ markers.clear();
871
+ }
872
+ };
873
+ }
874
+ // src/react/stack.ts
875
+ /** Whether Escape (or a close request it caused) is for this sheet: always for a modal one, else only with focus in it. */
876
+ function takesEscape(entry) {
877
+ return entry.modal !== false || entry.root.contains(document.activeElement);
878
+ }
879
+ /** The CloseWatcher constructor, or null where the browser doesn't have it. */
880
+ function closeWatcher() {
881
+ return typeof window !== "undefined" ? window.CloseWatcher ?? null : null;
882
+ }
883
+ /** True during the task in which Escape was pressed, so a watcher's close can report 'escape' instead of 'back'. */
884
+ let escapePressed = false;
885
+ const closedByEscape = () => escapePressed;
886
+ const stack = [];
887
+ let escapeListening = false;
888
+ function onKeyDown(e) {
889
+ if (e.key !== "Escape" || e.defaultPrevented || e.isComposing || e.keyCode === 229) return;
890
+ escapePressed = true;
891
+ setTimeout(() => escapePressed = false);
892
+ const top = escapeTarget();
893
+ if (!top || top.watcher) return;
894
+ e.preventDefault();
895
+ top.close();
896
+ }
897
+ /** The sheet Escape is for: the topmost one that's open and takes it (a non-modal sheet only with focus inside). */
898
+ function escapeTarget() {
899
+ return stack.findLast((s) => !s.closing && takesEscape(s));
900
+ }
901
+ function pushSheet(entry) {
902
+ stack.push(entry);
903
+ if (!escapeListening) {
904
+ document.addEventListener("keydown", onKeyDown);
905
+ escapeListening = true;
906
+ }
907
+ }
908
+ function removeSheet(entry) {
909
+ const i = stack.indexOf(entry);
910
+ if (i === -1) return;
911
+ stack.splice(i, 1);
912
+ if (entry.recede && !stack.some((e) => e.recede)) setRecede(0);
913
+ const below = stack[i - 1];
914
+ if (below && !stack[i]) setCovered(below.root, 0);
915
+ if (!stack.length && escapeListening) {
916
+ document.removeEventListener("keydown", onKeyDown);
917
+ escapeListening = false;
918
+ }
919
+ }
920
+ /** Apply changed props to an open sheet's entry (it keeps its place in the stack and its watcher). */
921
+ function updateSheet(entry, next) {
922
+ entry.modal = next.modal;
923
+ if (entry.recede === next.recede) return;
924
+ entry.recede = next.recede;
925
+ if (!next.recede && !stack.some((e) => e.recede)) setRecede(0);
926
+ }
927
+ /**
928
+ * `inert` on everything outside the open modal sheets, counted per element: two stacked modal sheets both hold the
929
+ * page, so it stays inert until the last one lets go, whichever closes first. An element the app made inert itself
930
+ * is left alone. Covers the siblings of the sheet's root and of each of its ancestors up to <body>, so a sheet
931
+ * portalled into a container still blocks the whole page.
932
+ */
933
+ const inertHolds = /* @__PURE__ */ new Map();
934
+ function holdInert(root) {
935
+ const held = [];
936
+ for (let node = root; node.parentElement && node !== document.body; node = node.parentElement) for (const el of Array.from(node.parentElement.children)) {
937
+ if (el === node || el.tagName === "SCRIPT" || el.tagName === "STYLE") continue;
938
+ const n = inertHolds.get(el);
939
+ if (n === void 0 && el.hasAttribute("inert")) continue;
940
+ inertHolds.set(el, (n ?? 0) + 1);
941
+ el.setAttribute("inert", "");
942
+ held.push(el);
943
+ }
944
+ return held;
945
+ }
946
+ function releaseInert(held) {
947
+ for (const el of held) {
948
+ const n = (inertHolds.get(el) ?? 1) - 1;
949
+ if (n > 0) inertHolds.set(el, n);
950
+ else {
951
+ inertHolds.delete(el);
952
+ el.removeAttribute("inert");
953
+ }
954
+ }
955
+ }
956
+ function isTopSheet(root) {
957
+ return stack[stack.length - 1]?.root === root;
958
+ }
959
+ /** Called with the sheet's own progress (0 closed .. 1 full); updates the sheet underneath it. */
960
+ function reportProgress(root, progress, cover = progress) {
961
+ const i = stack.findIndex((e) => e.root === root);
962
+ const below = i > 0 ? stack[i - 1] : void 0;
963
+ if (below) setCovered(below.root, progress);
964
+ if (i !== -1 && stack[i] === stack.find((e) => e.recede)) setRecede(cover);
965
+ }
966
+ /**
967
+ * The page behind recedes (`data-ybs-recede` + `--ybs-recede` on <html>) while a sheet with `recede` is open.
968
+ * `--ybs-recede` is the share of the screen the sheet covers (0..1), so a short sheet barely recedes the page.
969
+ * `--ybs-scroll-y` lets the page scale from the top of the screen rather than the top of the document.
970
+ * The attribute is only present while receding: a transform on the page wrapper changes what its fixed
971
+ * elements are positioned against, so it must not linger.
972
+ */
973
+ function setRecede(progress) {
974
+ const html = document.documentElement;
975
+ if (progress > 0 && !html.hasAttribute("data-ybs-recede")) {
976
+ html.style.setProperty("--ybs-scroll-y", `${window.scrollY}px`);
977
+ html.setAttribute("data-ybs-recede", "");
978
+ }
979
+ if (progress <= 0) {
980
+ html.removeAttribute("data-ybs-recede");
981
+ html.style.removeProperty("--ybs-recede");
982
+ return;
983
+ }
984
+ html.style.setProperty("--ybs-recede", String(progress));
985
+ }
986
+ function setCovered(root, progress) {
987
+ root.style.setProperty("--ybs-covered", String(progress));
988
+ root.toggleAttribute("data-ybs-covered", progress > 0);
989
+ }
990
+ // src/react/css-check.ts
991
+ /**
992
+ * The CSS is a file the app imports (`@yourrealm/bottom-sheet/styles.css` or `styled.css`). A sheet without it can't
993
+ * work (the overlay, scroller and snapping are CSS), so say so loudly, once, when one opens. Each file sets a marker
994
+ * variable on :root that this reads.
995
+ */
996
+ const reported = /* @__PURE__ */ new Set();
997
+ function checkCss(id, file) {
998
+ if (reported.has(id) || typeof document === "undefined") return;
999
+ if (getComputedStyle(document.documentElement).getPropertyValue(`--ybs-css-${id}`).trim() === "1") return;
1000
+ reported.add(id);
1001
+ console.error(`@yourrealm/bottom-sheet: ${file} isn't loaded, so the sheet can't work. Import it once, before Tailwind:\n @import "@yourrealm/bottom-sheet/${file}";`);
1002
+ }
1003
+ // src/react/sheet.tsx
1004
+ var sheet_exports = /* @__PURE__ */ __exportAll({
1005
+ Backdrop: () => Backdrop,
1006
+ Close: () => Close,
1007
+ Content: () => Content,
1008
+ Description: () => Description,
1009
+ Footer: () => Footer,
1010
+ Handle: () => Handle,
1011
+ Header: () => Header,
1012
+ Portal: () => Portal,
1013
+ Root: () => Root,
1014
+ Title: () => Title,
1015
+ Trigger: () => Trigger,
1016
+ View: () => View
1017
+ });
1018
+ const SheetContext = createContext(null);
1019
+ function useSheet(part) {
1020
+ const ctx = useContext(SheetContext);
1021
+ if (!ctx) throw new Error(`<Sheet.${part}> must be used inside <Sheet.Root>`);
1022
+ return ctx;
1023
+ }
1024
+ /**
1025
+ * One stable callback ref for the user's ref and ours (a new callback each render would detach and re-attach
1026
+ * them every render). Forwards a callback ref's React 19 cleanup.
1027
+ */
1028
+ function useMergedRef(user, own) {
1029
+ return useCallback((node) => {
1030
+ own.current = node;
1031
+ const cleanup = typeof user === "function" ? user(node) : user ? (user.current = node, void 0) : void 0;
1032
+ return () => {
1033
+ own.current = null;
1034
+ if (typeof user === "function") typeof cleanup === "function" ? cleanup() : user(null);
1035
+ else if (user) user.current = null;
1036
+ };
1037
+ }, [user, own]);
1038
+ }
1039
+ let lastInteraction = "mouse";
1040
+ let tracking = false;
1041
+ function trackInteraction() {
1042
+ if (tracking || typeof document === "undefined") return;
1043
+ tracking = true;
1044
+ document.addEventListener("pointerdown", (e) => lastInteraction = e.pointerType || "mouse", true);
1045
+ document.addEventListener("keydown", () => lastInteraction = "keyboard", true);
1046
+ }
1047
+ /** Resolve `initialFocus` to the element to focus, or null to leave focus where it is. */
1048
+ function initialFocusTarget(pref, modal, sheet) {
1049
+ const fallback = modal ? sheet : null;
1050
+ const v = typeof pref === "function" ? pref(lastInteraction) : pref ?? modal;
1051
+ if (v === true) return typeof pref === "function" || pref === true ? sheet : fallback;
1052
+ if (v === null) return fallback;
1053
+ if (v === false || v === void 0) return null;
1054
+ if (v instanceof HTMLElement) return v;
1055
+ return v.current ?? fallback;
1056
+ }
1057
+ /** Full only, like iOS (`[.large()]`), Base UI and Vaul. Partial detents are opt-in. */
1058
+ const DEFAULT_DETENTS = [];
1059
+ function Root(props) {
1060
+ const { children, open: openProp, defaultOpen = false, onOpenChange, detents = DEFAULT_DETENTS, initialDetent, detent, onDetentChange, modal: modalProp = true, dismissible = true, fullscreen = false, recede = false, debug, onExitComplete } = props;
1061
+ const [openState, setOpenState] = useState(defaultOpen);
1062
+ const open = openProp ?? openState;
1063
+ const [mounted, setMounted] = useState(open);
1064
+ const [modalAtOpen, setModalAtOpen] = useState(modalProp);
1065
+ const modal = mounted ? modalAtOpen : modalProp;
1066
+ const closeOnBack = props.closeOnBack ?? modal;
1067
+ const controller = useRef(null);
1068
+ const rootEl = useRef(null);
1069
+ const sheetEl = useRef(null);
1070
+ const initialFocus = useRef(void 0);
1071
+ useEffect(trackInteraction, []);
1072
+ const id = useId();
1073
+ const [hasTitle, setHasTitle] = useState(false);
1074
+ const [hasDescription, setHasDescription] = useState(false);
1075
+ const setOpen = useCallback((next, reason) => {
1076
+ if (openProp === void 0) setOpenState(next);
1077
+ onOpenChange?.(next, { reason });
1078
+ }, [openProp, onOpenChange]);
1079
+ const requestDismiss = useCallback((reason) => {
1080
+ if (dismissible) setOpen(false, reason);
1081
+ else controller.current?.collapse();
1082
+ }, [dismissible, setOpen]);
1083
+ const closing = useRef(false);
1084
+ const debugRef = useRef(debug);
1085
+ debugRef.current = debug;
1086
+ const returnFocus = useRef(null);
1087
+ useEffect(() => {
1088
+ if (open && !mounted) {
1089
+ returnFocus.current = document.activeElement;
1090
+ setModalAtOpen(modalProp);
1091
+ setMounted(true);
1092
+ }
1093
+ if (open && mounted && closing.current) {
1094
+ closing.current = false;
1095
+ controller.current?.open(detent ?? initialDetent);
1096
+ const sheet = sheetEl.current;
1097
+ if (sheet && !sheet.contains(document.activeElement)) initialFocusTarget(initialFocus.current, modal, sheet)?.focus({ preventScroll: true });
1098
+ }
1099
+ if (!open && mounted) {
1100
+ closing.current = true;
1101
+ if (controller.current) controller.current.close();
1102
+ else setMounted(false);
1103
+ }
1104
+ }, [open, mounted]);
1105
+ const exitRef = useRef(onExitComplete);
1106
+ exitRef.current = onExitComplete;
1107
+ const wasMounted = useRef(mounted);
1108
+ useEffect(() => {
1109
+ if (wasMounted.current && !mounted) exitRef.current?.();
1110
+ wasMounted.current = mounted;
1111
+ }, [mounted]);
1112
+ const onClosed = useCallback(() => {
1113
+ debugRef.current?.(`onClosed (open=${open}, mounted=${mounted})`);
1114
+ closing.current = false;
1115
+ setMounted(false);
1116
+ if (open) setOpen(false, "swipe");
1117
+ }, [
1118
+ open,
1119
+ mounted,
1120
+ setOpen
1121
+ ]);
1122
+ const value = useMemo(() => ({
1123
+ open,
1124
+ mounted,
1125
+ setOpen,
1126
+ requestDismiss,
1127
+ modal,
1128
+ dismissible,
1129
+ closeOnBack,
1130
+ fullscreen,
1131
+ recede,
1132
+ detents,
1133
+ initialDetent,
1134
+ detent,
1135
+ onDetentChange,
1136
+ debug,
1137
+ onClosed,
1138
+ controller,
1139
+ returnFocus,
1140
+ rootEl,
1141
+ sheetEl,
1142
+ initialFocus,
1143
+ titleId: `${id}-title`,
1144
+ descriptionId: `${id}-description`,
1145
+ hasTitle,
1146
+ setHasTitle,
1147
+ hasDescription,
1148
+ setHasDescription
1149
+ }), [
1150
+ open,
1151
+ mounted,
1152
+ setOpen,
1153
+ requestDismiss,
1154
+ modal,
1155
+ dismissible,
1156
+ closeOnBack,
1157
+ fullscreen,
1158
+ recede,
1159
+ detents,
1160
+ initialDetent,
1161
+ detent,
1162
+ onDetentChange,
1163
+ debug,
1164
+ onClosed,
1165
+ id,
1166
+ hasTitle,
1167
+ hasDescription
1168
+ ]);
1169
+ return /* @__PURE__ */ jsx(SheetContext.Provider, {
1170
+ value,
1171
+ children
1172
+ });
1173
+ }
1174
+ function Trigger({ render, ...props }) {
1175
+ const ctx = useSheet("Trigger");
1176
+ const onClick = (e) => {
1177
+ props.onClick?.(e);
1178
+ if (!e.defaultPrevented) ctx.setOpen(true, "trigger");
1179
+ };
1180
+ if (render) return render({
1181
+ type: "button",
1182
+ "aria-haspopup": "dialog",
1183
+ "aria-expanded": ctx.open,
1184
+ onClick
1185
+ });
1186
+ return /* @__PURE__ */ jsx("button", {
1187
+ type: "button",
1188
+ "aria-haspopup": "dialog",
1189
+ "aria-expanded": ctx.open,
1190
+ ...props,
1191
+ onClick
1192
+ });
1193
+ }
1194
+ function Close(props) {
1195
+ const ctx = useSheet("Close");
1196
+ return /* @__PURE__ */ jsx("button", {
1197
+ type: "button",
1198
+ ...props,
1199
+ onClick: (e) => {
1200
+ props.onClick?.(e);
1201
+ if (!e.defaultPrevented) ctx.setOpen(false, "close-button");
1202
+ }
1203
+ });
1204
+ }
1205
+ /** Fixed layer. Mounted while open or animating closed. A modal sheet makes the rest of the page inert. */
1206
+ function Portal({ children, container }) {
1207
+ const ctx = useSheet("Portal");
1208
+ useEffect(() => {
1209
+ if (ctx.mounted) checkCss("core", "styles.css");
1210
+ }, [ctx.mounted]);
1211
+ const dismissRef = useRef(ctx.requestDismiss);
1212
+ dismissRef.current = ctx.requestDismiss;
1213
+ const ctxRef = useRef(ctx);
1214
+ ctxRef.current = ctx;
1215
+ const entryRef = useRef(null);
1216
+ useEffect(() => {
1217
+ const root = ctx.rootEl.current;
1218
+ if (!ctx.mounted || !root) return;
1219
+ const { recede, modal } = ctxRef.current;
1220
+ const entry = {
1221
+ root,
1222
+ close: () => dismissRef.current("escape"),
1223
+ recede: recede && modal,
1224
+ modal
1225
+ };
1226
+ entryRef.current = entry;
1227
+ pushSheet(entry);
1228
+ return () => {
1229
+ removeSheet(entry);
1230
+ entryRef.current = null;
1231
+ };
1232
+ }, [ctx.mounted, ctx.rootEl]);
1233
+ useEffect(() => {
1234
+ if (entryRef.current) updateSheet(entryRef.current, {
1235
+ recede: ctx.recede && ctx.modal,
1236
+ modal: ctx.modal
1237
+ });
1238
+ }, [ctx.recede, ctx.modal]);
1239
+ const blocking = ctx.mounted && ctx.open && ctx.modal;
1240
+ useEffect(() => {
1241
+ const root = ctx.rootEl.current;
1242
+ if (!blocking || !root) return;
1243
+ const held = holdInert(root);
1244
+ const dbg = (m) => ctxRef.current.debug?.(m);
1245
+ dbg(`portal mount: inert ${held.length} elements`);
1246
+ return () => {
1247
+ releaseInert(held);
1248
+ dbg(`portal unmount: released ${held.length}`);
1249
+ const back = ctx.returnFocus.current;
1250
+ if (back?.isConnected) back.focus({ preventScroll: true });
1251
+ };
1252
+ }, [
1253
+ blocking,
1254
+ ctx.rootEl,
1255
+ ctx.returnFocus
1256
+ ]);
1257
+ const [rearm, setRearm] = useState(0);
1258
+ const listenForBack = ctx.mounted && ctx.open && ctx.closeOnBack;
1259
+ useEffect(() => {
1260
+ if (entryRef.current) entryRef.current.closing = !ctx.open;
1261
+ }, [ctx.open, ctx.mounted]);
1262
+ useEffect(() => {
1263
+ const Watcher = closeWatcher();
1264
+ const entry = entryRef.current;
1265
+ if (!listenForBack || !Watcher || !entry) return;
1266
+ const watcher = new Watcher();
1267
+ watcher.onclose = () => {
1268
+ entry.watcher = null;
1269
+ const escape = closedByEscape();
1270
+ ctxRef.current.debug?.(`close watcher: ${escape ? "escape" : "back"}`);
1271
+ if (!escape) dismissRef.current("back");
1272
+ else if (takesEscape(entry)) dismissRef.current("escape");
1273
+ else escapeTarget()?.close();
1274
+ setRearm((n) => n + 1);
1275
+ };
1276
+ entry.watcher = watcher;
1277
+ return () => {
1278
+ if (entry.watcher === watcher) entry.watcher = null;
1279
+ watcher.destroy();
1280
+ };
1281
+ }, [listenForBack, rearm]);
1282
+ if (!ctx.mounted || typeof document === "undefined") return null;
1283
+ return createPortal(/* @__PURE__ */ jsx("div", {
1284
+ "data-ybs-root": "",
1285
+ "data-ybs-modeless": ctx.modal ? void 0 : "",
1286
+ "data-ybs-fullscreen": ctx.fullscreen ? "" : void 0,
1287
+ ref: ctx.rootEl,
1288
+ children
1289
+ }), container ?? document.body);
1290
+ }
1291
+ function Backdrop(props) {
1292
+ const ctx = useSheet("Backdrop");
1293
+ return /* @__PURE__ */ jsx("div", {
1294
+ "data-ybs-backdrop": "",
1295
+ "aria-hidden": "true",
1296
+ ...props,
1297
+ onClick: (e) => {
1298
+ props.onClick?.(e);
1299
+ if (!e.defaultPrevented) ctx.requestDismiss("outside-press");
1300
+ }
1301
+ });
1302
+ }
1303
+ /** The single native scroller: spacer (closed area), detent markers, and the sheet (Sheet.Content). */
1304
+ function View({ children, bleedClassName, ref, ...props }) {
1305
+ const ctx = useSheet("View");
1306
+ const scroller = useRef(null);
1307
+ const scrollerRef = useMergedRef(ref, scroller);
1308
+ const markers = useRef(null);
1309
+ const ctxRef = useRef(ctx);
1310
+ ctxRef.current = ctx;
1311
+ useLayoutEffect(() => {
1312
+ const root = scroller.current?.closest("[data-ybs-root]") ?? null;
1313
+ const sheet = ctx.sheetEl.current;
1314
+ if (!root || !scroller.current || !markers.current || !sheet) throw new Error("<Sheet.View> needs <Sheet.Content> inside it, inside <Sheet.Portal>");
1315
+ const c = createSheetController({
1316
+ root,
1317
+ scroller: scroller.current,
1318
+ markers: markers.current,
1319
+ sheet
1320
+ }, {
1321
+ detents: ctx.detents,
1322
+ modal: ctx.modal,
1323
+ dismissible: ctx.dismissible,
1324
+ onDetentChange: (d) => ctxRef.current.onDetentChange?.(d),
1325
+ onClosed: () => ctxRef.current.onClosed(),
1326
+ onProgress: (p, cover) => reportProgress(root, p, cover),
1327
+ onOutsideTap: () => ctxRef.current.requestDismiss("outside-press"),
1328
+ isActive: () => isTopSheet(root),
1329
+ debug: (m) => ctxRef.current.debug?.(m)
1330
+ });
1331
+ ctx.controller.current = c;
1332
+ c.open(ctx.detent ?? ctx.initialDetent);
1333
+ for (const [attr, id] of [["aria-labelledby", ctx.titleId], ["aria-describedby", ctx.descriptionId]]) if (!sheet.hasAttribute(attr) && sheet.querySelector(`[id="${id}"]`)) sheet.setAttribute(attr, id);
1334
+ initialFocusTarget(ctx.initialFocus.current, ctx.modal, sheet)?.focus({ preventScroll: true });
1335
+ return () => {
1336
+ if (!ctx.modal && sheet.contains(document.activeElement)) {
1337
+ const back = ctx.returnFocus.current;
1338
+ if (back?.isConnected) back.focus({ preventScroll: true });
1339
+ }
1340
+ c.destroy();
1341
+ ctx.controller.current = null;
1342
+ };
1343
+ }, []);
1344
+ const detentsKey = ctx.detents.join("|");
1345
+ useEffect(() => {
1346
+ ctx.controller.current?.setDetents(ctxRef.current.detents);
1347
+ }, [detentsKey, ctx.controller]);
1348
+ useEffect(() => {
1349
+ ctx.controller.current?.setDismissible(ctx.dismissible);
1350
+ }, [ctx.dismissible, ctx.controller]);
1351
+ useEffect(() => {
1352
+ const c = ctx.controller.current;
1353
+ if (c && ctx.detent !== void 0 && ctx.open && c.detent !== ctx.detent) c.snapTo(ctx.detent);
1354
+ }, [
1355
+ ctx.detent,
1356
+ ctx.open,
1357
+ ctx.controller
1358
+ ]);
1359
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("div", {
1360
+ "data-ybs-bleed": "",
1361
+ "aria-hidden": "true",
1362
+ className: bleedClassName
1363
+ }), /* @__PURE__ */ jsx("div", {
1364
+ ...props,
1365
+ "data-ybs-scroller": "",
1366
+ ref: scrollerRef,
1367
+ children: /* @__PURE__ */ jsxs("div", {
1368
+ "data-ybs-track": "",
1369
+ children: [
1370
+ /* @__PURE__ */ jsx("div", {
1371
+ "data-ybs-spacer": "",
1372
+ "aria-hidden": "true"
1373
+ }),
1374
+ /* @__PURE__ */ jsx("div", {
1375
+ "data-ybs-markers": "",
1376
+ ref: markers
1377
+ }),
1378
+ children
1379
+ ]
1380
+ })
1381
+ })] });
1382
+ }
1383
+ function Content({ ref, initialFocus, ...props }) {
1384
+ const ctx = useSheet("Content");
1385
+ const sheetRef = useMergedRef(ref, ctx.sheetEl);
1386
+ ctx.initialFocus.current = initialFocus;
1387
+ return /* @__PURE__ */ jsx("section", {
1388
+ "data-ybs-sheet": "",
1389
+ role: "dialog",
1390
+ "aria-modal": ctx.modal,
1391
+ "aria-labelledby": ctx.hasTitle ? ctx.titleId : void 0,
1392
+ "aria-describedby": ctx.hasDescription ? ctx.descriptionId : void 0,
1393
+ tabIndex: -1,
1394
+ ...props,
1395
+ ref: sheetRef
1396
+ });
1397
+ }
1398
+ /** Sticky top part of the sheet. Mouse-draggable on desktop. */
1399
+ function Header(props) {
1400
+ return /* @__PURE__ */ jsx("div", {
1401
+ "data-ybs-header": "",
1402
+ "data-ybs-drag": "",
1403
+ ...props
1404
+ });
1405
+ }
1406
+ /**
1407
+ * Sticky bottom part of the sheet, for actions. It stays at the bottom of the screen while the content scrolls, and at
1408
+ * partial detents too. The controller sets `data-ybs-more` on the root while content is hidden behind it.
1409
+ */
1410
+ function Footer(props) {
1411
+ return /* @__PURE__ */ jsx("div", {
1412
+ "data-ybs-footer": "",
1413
+ ...props
1414
+ });
1415
+ }
1416
+ /** Visual grabber. Decorative; dragging works on the whole Header. */
1417
+ function Handle(props) {
1418
+ return /* @__PURE__ */ jsx("div", {
1419
+ "data-ybs-handle": "",
1420
+ "aria-hidden": "true",
1421
+ ...props
1422
+ });
1423
+ }
1424
+ function Title(props) {
1425
+ const ctx = useSheet("Title");
1426
+ const { setHasTitle } = ctx;
1427
+ useLayoutEffect(() => {
1428
+ setHasTitle(true);
1429
+ return () => setHasTitle(false);
1430
+ }, [setHasTitle]);
1431
+ return /* @__PURE__ */ jsx("h2", {
1432
+ id: ctx.titleId,
1433
+ ...props
1434
+ });
1435
+ }
1436
+ function Description(props) {
1437
+ const ctx = useSheet("Description");
1438
+ const { setHasDescription } = ctx;
1439
+ useLayoutEffect(() => {
1440
+ setHasDescription(true);
1441
+ return () => setHasDescription(false);
1442
+ }, [setHasDescription]);
1443
+ return /* @__PURE__ */ jsx("p", {
1444
+ id: ctx.descriptionId,
1445
+ ...props
1446
+ });
1447
+ }
1448
+ export { Footer as a, Portal as c, Trigger as d, View as f, Description as i, Root as l, checkCss as m, Close as n, Handle as o, sheet_exports as p, Content as r, Header as s, Backdrop as t, Title as u };