@surdeddd/bottom-sheet 0.1.0 → 0.3.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.
Files changed (57) hide show
  1. package/README.md +57 -21
  2. package/dist/BottomSheetEngine-CAymrH-P.d.cts +285 -0
  3. package/dist/BottomSheetEngine-DcqcuudN.d.ts +285 -0
  4. package/dist/element.cjs +775 -537
  5. package/dist/element.d.cts +9 -57
  6. package/dist/element.d.ts +9 -57
  7. package/dist/element.iife.global.js +2 -2
  8. package/dist/element.js +775 -537
  9. package/dist/index.cjs +760 -600
  10. package/dist/index.d.cts +18 -85
  11. package/dist/index.d.ts +18 -85
  12. package/dist/index.js +758 -601
  13. package/dist/integrations/formik.d.cts +2 -8
  14. package/dist/integrations/formik.d.ts +2 -8
  15. package/dist/integrations/react-hook-form.d.cts +2 -11
  16. package/dist/integrations/react-hook-form.d.ts +2 -11
  17. package/dist/overlay.cjs +168 -122
  18. package/dist/overlay.d.cts +14 -137
  19. package/dist/overlay.d.ts +14 -137
  20. package/dist/overlay.js +168 -122
  21. package/dist/preact.d.cts +2 -2
  22. package/dist/preact.d.ts +2 -2
  23. package/dist/preact.js +0 -1
  24. package/dist/qwik.cjs +667 -519
  25. package/dist/qwik.d.cts +3 -24
  26. package/dist/qwik.d.ts +3 -24
  27. package/dist/qwik.js +668 -520
  28. package/dist/react.cjs +873 -648
  29. package/dist/react.d.cts +34 -159
  30. package/dist/react.d.ts +34 -159
  31. package/dist/react.js +873 -648
  32. package/dist/solid.cjs +703 -560
  33. package/dist/solid.d.cts +264 -32
  34. package/dist/solid.d.ts +264 -32
  35. package/dist/solid.js +704 -561
  36. package/dist/solid.source.jsx +3215 -0
  37. package/dist/styles.css +37 -112
  38. package/dist/svelte-core.cjs +3149 -0
  39. package/dist/svelte-core.js +3147 -0
  40. package/dist/svelte-src/BottomSheet.svelte +210 -0
  41. package/dist/svelte-src/index.js +2 -0
  42. package/dist/svelte.cjs +718 -547
  43. package/dist/svelte.d.cts +100 -0
  44. package/dist/svelte.d.ts +15 -4
  45. package/dist/svelte.js +719 -548
  46. package/dist/themes/ios.css +0 -3
  47. package/dist/themes/material.css +0 -3
  48. package/dist/themes/vercel.css +0 -3
  49. package/dist/{transform-Brkf-wzo.d.cts → transform-jVfAikE9.d.cts} +0 -1
  50. package/dist/{transform-Brkf-wzo.d.ts → transform-jVfAikE9.d.ts} +0 -1
  51. package/dist/vue.cjs +677 -526
  52. package/dist/vue.d.cts +78 -0
  53. package/dist/vue.d.ts +12 -2
  54. package/dist/vue.js +678 -527
  55. package/package.json +105 -45
  56. package/dist/BottomSheetEngine-CvSVbo6c.d.cts +0 -704
  57. package/dist/BottomSheetEngine-DbO8rW_3.d.ts +0 -704
package/dist/react.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { forwardRef, useRef, useState, useEffect, useImperativeHandle, useMemo, useCallback, useSyncExternalStore, useLayoutEffect } from 'react';
2
+ import { createPortal } from 'react-dom';
2
3
  import { jsxs, jsx } from 'react/jsx-runtime';
3
4
 
4
5
  var __defProp = Object.defineProperty;
@@ -23,6 +24,18 @@ var SheetStack = class {
23
24
  this.entries.splice(idx, 1);
24
25
  this.recompute();
25
26
  }
27
+ promote(id) {
28
+ const idx = this.entries.findIndex((e) => e.id === id);
29
+ if (idx === -1) return;
30
+ if (idx !== this.entries.length - 1) {
31
+ const [entry] = this.entries.splice(idx, 1);
32
+ this.entries.push(entry);
33
+ }
34
+ this.recompute();
35
+ }
36
+ update() {
37
+ this.recompute();
38
+ }
26
39
  size() {
27
40
  return this.entries.length;
28
41
  }
@@ -30,9 +43,24 @@ var SheetStack = class {
30
43
  this.entries = [];
31
44
  }
32
45
  recompute() {
46
+ let top = null;
47
+ const open = [];
48
+ for (const entry of this.entries) {
49
+ if (!entry.isOpen || entry.isOpen()) {
50
+ top = entry;
51
+ open.push(entry);
52
+ }
53
+ }
54
+ if (!top && this.entries.length > 0) {
55
+ top = this.entries[this.entries.length - 1];
56
+ }
33
57
  this.entries.forEach((entry, i) => {
34
58
  entry.setZIndex(BASE_Z + i * STEP);
35
- entry.setIsTop(i === this.entries.length - 1);
59
+ entry.setIsTop(entry === top);
60
+ if (entry.setDepth) {
61
+ const openIdx = open.indexOf(entry);
62
+ entry.setDepth(openIdx === -1 ? 0 : open.length - 1 - openIdx);
63
+ }
36
64
  });
37
65
  }
38
66
  };
@@ -237,11 +265,6 @@ var SnapResolver = class {
237
265
  this.allowedIds = allowed ?? raw.map((p) => p.id);
238
266
  this.recompute();
239
267
  }
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
268
  recompute() {
246
269
  this.resolved = resolveSnapList(this.raw, this.mode, this.measureFit);
247
270
  this.maxAxisSize = this.resolved.reduce(
@@ -251,15 +274,10 @@ var SnapResolver = class {
251
274
  this.rangeCache = null;
252
275
  this.onMaxAxisSizeChange?.(this.maxAxisSize);
253
276
  }
254
- /** Replace the raw snap list and recompute. Engine calls on `setSnapPoints`. */
255
277
  setRaw(raw) {
256
278
  this.raw = raw;
257
279
  this.recompute();
258
280
  }
259
- /**
260
- * Replace the allow-list. Range cache depends on `(resolvedSnaps ×
261
- * allowedIds)` so flipping ids alone invalidates without a full recompute.
262
- */
263
281
  setAllowedIds(ids) {
264
282
  this.allowedIds = ids;
265
283
  this.rangeCache = null;
@@ -267,11 +285,6 @@ var SnapResolver = class {
267
285
  findById(id) {
268
286
  return findById(id, this.resolved);
269
287
  }
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
288
  findDragSettleTarget(args) {
276
289
  return findDragSettleTarget({
277
290
  ...args,
@@ -280,18 +293,9 @@ var SnapResolver = class {
280
293
  maxAxisSize: this.maxAxisSize
281
294
  });
282
295
  }
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
296
  getAllowedRange() {
289
297
  return this.rangeCache ?? (this.rangeCache = allowedRange(this.resolved, this.allowedIds));
290
298
  }
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
299
  getResolvedSnaps() {
296
300
  return this.resolved;
297
301
  }
@@ -301,18 +305,6 @@ var SnapResolver = class {
301
305
  getMaxAxisSize() {
302
306
  return this.maxAxisSize;
303
307
  }
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
308
  setMaxAxisSize(n) {
317
309
  this.maxAxisSize = n;
318
310
  this.onMaxAxisSizeChange?.(n);
@@ -359,14 +351,17 @@ function installAutoCollapse(deps) {
359
351
  const fire = () => {
360
352
  if (deps.isDestroyed()) return;
361
353
  if (deps.isDragging()) return;
362
- const allowed = deps.getAllowedIds();
363
- const target = allowed.find((id) => {
354
+ const active = deps.resolveSnap(deps.getActiveId());
355
+ if (!active || active.size === 0) return;
356
+ let target = null;
357
+ for (const id of deps.getAllowedIds()) {
364
358
  const snap = deps.resolveSnap(id);
365
- return snap !== null && snap.size > 0;
366
- });
359
+ if (!snap || snap.size <= 0) continue;
360
+ if (!target || snap.size < target.size) target = snap;
361
+ }
367
362
  if (!target) return;
368
- if (target === deps.getActiveId()) return;
369
- deps.snapTo(target);
363
+ if (target.id === active.id || target.size >= active.size) return;
364
+ deps.snapTo(target.id);
370
365
  };
371
366
  const reset = () => {
372
367
  clearTimer();
@@ -377,6 +372,7 @@ function installAutoCollapse(deps) {
377
372
  }, ms);
378
373
  };
379
374
  const unsubscribe = deps.on("snap", () => reset());
375
+ if ((deps.resolveSnap(deps.getActiveId())?.size ?? 0) > 0) reset();
380
376
  return () => {
381
377
  clearTimer();
382
378
  unsubscribe();
@@ -389,6 +385,7 @@ function notifyLinkedSheets(sheets, self) {
389
385
  for (const linked of sheets) {
390
386
  if (linked === self) continue;
391
387
  try {
388
+ if (linked.state.size === 0) continue;
392
389
  const ids = linked.getAllowedIds();
393
390
  const target = ids.find((id) => id !== "closed");
394
391
  if (target && target !== linked.state.activeId) {
@@ -435,7 +432,7 @@ function installRoute(deps) {
435
432
  const onPop = (event) => {
436
433
  if (deps.isDestroyed()) return;
437
434
  const state = event.state;
438
- if (state && state.__bsRouted === target && deps.getSize() > 0) {
435
+ if ((!state || state.__bsRouted !== target) && deps.getSize() > 0) {
439
436
  routePushed = false;
440
437
  void deps.close();
441
438
  }
@@ -589,13 +586,17 @@ function installResizeObserver(deps) {
589
586
  if (viewportSize2 > 0 && viewportSize2 < max) {
590
587
  deps.setMaxAxisSize(viewportSize2);
591
588
  }
589
+ const wasAnimating = deps.isAnimating?.() ?? false;
592
590
  deps.cancelInFlight();
593
591
  deps.newCycle();
594
592
  const current = deps.resolveActiveSnap();
595
593
  const newMax = deps.getMaxAxisSize();
596
594
  const targetSize = current ? Math.min(current.size, newMax) : Math.min(deps.getSize(), newMax);
597
595
  deps.setSize(targetSize);
598
- if (current && !deps.isDragging()) deps.applySize(targetSize);
596
+ if (current && !deps.isDragging()) {
597
+ deps.applySize(targetSize);
598
+ if (wasAnimating) deps.resyncAfterCancel?.();
599
+ }
599
600
  };
600
601
  let resizeObserver = null;
601
602
  if (typeof ResizeObserver !== "undefined") {
@@ -687,73 +688,130 @@ var WriteSentinel = class {
687
688
  constructor() {
688
689
  __publicField(this, "last", -1);
689
690
  }
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
691
  shouldWrite(next, epsilon) {
697
692
  if (Math.abs(next - this.last) <= epsilon) return false;
698
693
  this.last = next;
699
694
  return true;
700
695
  }
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
696
  invalidate() {
708
697
  this.last = -1;
709
698
  }
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
699
  setLastWritten(value) {
717
700
  this.last = value;
718
701
  }
719
- /** Read-only accessor for diagnostic / dedup-aware peek. */
720
702
  get value() {
721
703
  return this.last;
722
704
  }
723
705
  };
724
706
 
707
+ // src/core/primitives/devWarn.ts
708
+ function devWarn(...args) {
709
+ if (typeof process !== "undefined" && process.env && process.env.NODE_ENV !== "production" && typeof console !== "undefined") {
710
+ console.warn(...args);
711
+ }
712
+ }
713
+
714
+ // src/core/primitives/overlay-position.ts
715
+ function resolveSheetAnchoredStyle(mode, position, inset) {
716
+ const sizeOffset = `calc(var(--bs-size, 0px) + ${inset})`;
717
+ switch (mode) {
718
+ case "bottom":
719
+ if (position === "sheet-top-left") return { bottom: sizeOffset, left: inset };
720
+ if (position === "sheet-top-right") return { bottom: sizeOffset, right: inset };
721
+ return { bottom: sizeOffset, left: "50%", transform: "translateX(-50%)" };
722
+ case "top":
723
+ if (position === "sheet-top-left") return { top: sizeOffset, left: inset };
724
+ if (position === "sheet-top-right") return { top: sizeOffset, right: inset };
725
+ return { top: sizeOffset, left: "50%", transform: "translateX(-50%)" };
726
+ case "left":
727
+ if (position === "sheet-top-left") return { left: sizeOffset, top: inset };
728
+ if (position === "sheet-top-right") return { left: sizeOffset, bottom: inset };
729
+ return { left: sizeOffset, top: "50%", transform: "translateY(-50%)" };
730
+ case "right":
731
+ if (position === "sheet-top-left") return { right: sizeOffset, top: inset };
732
+ if (position === "sheet-top-right") return { right: sizeOffset, bottom: inset };
733
+ return { right: sizeOffset, top: "50%", transform: "translateY(-50%)" };
734
+ }
735
+ }
736
+ function applyOverlayPosition(ws, mode, position, inset) {
737
+ switch (position) {
738
+ case "top-left":
739
+ ws.top = inset;
740
+ ws.left = inset;
741
+ break;
742
+ case "top-center":
743
+ ws.top = inset;
744
+ ws.left = "50%";
745
+ ws.transform = "translateX(-50%)";
746
+ break;
747
+ case "top-right":
748
+ ws.top = inset;
749
+ ws.right = inset;
750
+ break;
751
+ case "center-left":
752
+ ws.top = "50%";
753
+ ws.left = inset;
754
+ ws.transform = "translateY(-50%)";
755
+ break;
756
+ case "center":
757
+ ws.top = "50%";
758
+ ws.left = "50%";
759
+ ws.transform = "translate(-50%, -50%)";
760
+ break;
761
+ case "center-right":
762
+ ws.top = "50%";
763
+ ws.right = inset;
764
+ ws.transform = "translateY(-50%)";
765
+ break;
766
+ case "bottom-left":
767
+ ws.bottom = inset;
768
+ ws.left = inset;
769
+ break;
770
+ case "bottom-center":
771
+ ws.bottom = inset;
772
+ ws.left = "50%";
773
+ ws.transform = "translateX(-50%)";
774
+ break;
775
+ case "bottom-right":
776
+ ws.bottom = inset;
777
+ ws.right = inset;
778
+ break;
779
+ case "sheet-top-left":
780
+ case "sheet-top-center":
781
+ case "sheet-top-right": {
782
+ const style = resolveSheetAnchoredStyle(mode, position, inset);
783
+ if (style.top !== void 0) ws.top = style.top;
784
+ if (style.bottom !== void 0) ws.bottom = style.bottom;
785
+ if (style.left !== void 0) ws.left = style.left;
786
+ if (style.right !== void 0) ws.right = style.right;
787
+ if (style.transform !== void 0) ws.transform = style.transform;
788
+ break;
789
+ }
790
+ }
791
+ }
792
+
725
793
  // src/core/controllers/scrim-controller.ts
794
+ var VALID_CSS_LENGTH = /^-?\d+(?:\.\d+)?(?:px|em|rem|%)$/;
726
795
  var ScrimController = class {
727
796
  constructor(deps, opts) {
728
- // ── DOM refs (mirror engine's; cached for hot path) ─────────────────
729
797
  __publicField(this, "screenComponent");
730
798
  __publicField(this, "backdrop");
731
799
  __publicField(this, "mode");
732
- // ── Engine callbacks ────────────────────────────────────────────────
733
800
  __publicField(this, "isDestroyed");
734
801
  __publicField(this, "isTopSheet");
735
802
  __publicField(this, "getAllowedIds");
736
803
  __publicField(this, "getResolvedSnaps");
737
804
  __publicField(this, "snapToFn");
738
- // ── Mode + state ────────────────────────────────────────────────────
805
+ __publicField(this, "closeFn");
806
+ __publicField(this, "scrimInteractive", false);
739
807
  __publicField(this, "scrimMode");
740
808
  __publicField(this, "backdropRange");
741
809
  __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
810
  __publicField(this, "scrimEnabled", true);
746
811
  __publicField(this, "savedScrimRanges", null);
747
- // ── Tap-to-close ────────────────────────────────────────────────────
748
812
  __publicField(this, "scrimTapToCloseEnabled", false);
749
813
  __publicField(this, "detachScrimTap", null);
750
- // ── Scrim overlay slot (positioned floating element) ────────────────
751
814
  __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
815
  __publicField(this, "backdropOpacitySentinel", new WriteSentinel());
758
816
  __publicField(this, "screenOpacitySentinel", new WriteSentinel());
759
817
  __publicField(this, "lastBackdropPointer", "");
@@ -766,6 +824,7 @@ var ScrimController = class {
766
824
  this.getAllowedIds = deps.getAllowedIds;
767
825
  this.getResolvedSnaps = deps.getResolvedSnaps;
768
826
  this.snapToFn = deps.snapTo;
827
+ this.closeFn = deps.close;
769
828
  this.scrimMode = opts.scrimMode ?? "full";
770
829
  const preset = opts.scrimPreset ? SCRIM_PRESETS[opts.scrimPreset] : null;
771
830
  this.backdropRange = opts.backdropRange ?? preset?.range ?? [0, 1];
@@ -788,41 +847,15 @@ var ScrimController = class {
788
847
  }
789
848
  }
790
849
  }
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
850
  invalidateOpacityCache() {
804
851
  this.backdropOpacitySentinel.invalidate();
805
852
  this.screenOpacitySentinel.invalidate();
806
853
  }
807
- /** Engine calls this when its `attach()` runs — installs scrim-tap-click if requested. */
808
854
  attach() {
809
855
  if (this.scrimTapToCloseEnabled) {
810
856
  this.setScrimTapToClose(true);
811
857
  }
812
858
  }
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
859
  applyOpacity(progress, progressChanged) {
827
860
  if (!progressChanged && this.backdropOpacitySentinel.value !== -1 && this.screenOpacitySentinel.value !== -1) {
828
861
  return;
@@ -868,80 +901,30 @@ var ScrimController = class {
868
901
  }
869
902
  }
870
903
  }
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
904
  setBackdropRange(range, applySize) {
881
905
  if (this.isDestroyed()) return;
882
906
  this.backdropRange = range;
883
907
  this.invalidateOpacityCache();
884
908
  applySize();
885
909
  }
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
910
  setScreenRange(range, applySize) {
895
911
  if (this.isDestroyed()) return;
896
912
  this.screenRange = range;
897
913
  this.invalidateOpacityCache();
898
914
  applySize();
899
915
  }
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
916
  setScrimColor(color) {
909
917
  if (this.isDestroyed()) return;
910
918
  this.applyScrimStyles({ color: color === void 0 ? null : color });
911
919
  }
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
920
  setScrimBlur(blur) {
921
921
  if (this.isDestroyed()) return;
922
922
  this.applyScrimStyles({ blur: blur === void 0 ? null : blur });
923
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
924
  setScrimInteractive(interactive) {
932
925
  if (this.isDestroyed()) return;
933
926
  this.applyScrimStyles({ interactive });
934
927
  }
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
928
  setScrimMode(mode, applySize) {
946
929
  if (this.isDestroyed()) return;
947
930
  if (mode === this.scrimMode) return;
@@ -971,15 +954,6 @@ var ScrimController = class {
971
954
  }
972
955
  applySize();
973
956
  }
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
957
  setScrimTapToClose(enabled) {
984
958
  if (this.isDestroyed()) return;
985
959
  if (!this.screenComponent) return;
@@ -992,11 +966,15 @@ var ScrimController = class {
992
966
  }
993
967
  const onClick = () => {
994
968
  if (this.isDestroyed()) return;
969
+ if (this.closeFn) {
970
+ this.closeFn();
971
+ return;
972
+ }
995
973
  const allowed = this.getAllowedIds();
996
974
  const snaps = this.getResolvedSnaps();
997
975
  const fallback = allowed.find((id) => {
998
976
  const snap = findById(id, snaps);
999
- return snap !== null && snap.size > 0;
977
+ return snap !== null && snap.size === 0;
1000
978
  });
1001
979
  if (fallback) this.snapToFn(fallback);
1002
980
  };
@@ -1009,20 +987,11 @@ var ScrimController = class {
1009
987
  this.scrimTapToCloseEnabled = false;
1010
988
  }
1011
989
  }
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
990
  setScrimEnabled(enabled, applySize) {
1023
991
  if (this.isDestroyed()) return;
1024
992
  if (enabled === this.scrimEnabled) return;
1025
993
  this.scrimEnabled = enabled;
994
+ this.invalidateOpacityCache();
1026
995
  if (!enabled) {
1027
996
  this.savedScrimRanges = {
1028
997
  screen: this.screenRange,
@@ -1037,20 +1006,6 @@ var ScrimController = class {
1037
1006
  }
1038
1007
  applySize();
1039
1008
  }
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
1009
  setScrim(opts, applySize) {
1055
1010
  if (this.isDestroyed()) return;
1056
1011
  if (opts.preset) {
@@ -1060,7 +1015,12 @@ var ScrimController = class {
1060
1015
  blur: cfg.blur ?? null,
1061
1016
  interactive: cfg.interactive
1062
1017
  });
1063
- this.screenRange = cfg.range;
1018
+ if (!this.scrimEnabled && this.savedScrimRanges) {
1019
+ this.savedScrimRanges = { screen: cfg.range, backdrop: cfg.range };
1020
+ } else {
1021
+ this.screenRange = cfg.range;
1022
+ this.backdropRange = cfg.range;
1023
+ }
1064
1024
  }
1065
1025
  const noop = () => {
1066
1026
  };
@@ -1073,27 +1033,18 @@ var ScrimController = class {
1073
1033
  interactive: opts.interactive
1074
1034
  });
1075
1035
  if (opts.range !== void 0) {
1076
- this.screenRange = opts.range;
1036
+ if (!this.scrimEnabled && this.savedScrimRanges) {
1037
+ this.savedScrimRanges = {
1038
+ screen: opts.range,
1039
+ backdrop: this.savedScrimRanges.backdrop
1040
+ };
1041
+ } else {
1042
+ this.screenRange = opts.range;
1043
+ }
1077
1044
  }
1078
1045
  this.invalidateOpacityCache();
1079
1046
  applySize();
1080
1047
  }
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
1048
  setScrimOverlay(opts) {
1098
1049
  if (this.isDestroyed()) return () => {
1099
1050
  };
@@ -1101,61 +1052,38 @@ var ScrimController = class {
1101
1052
  };
1102
1053
  this.scrimOverlayTeardown?.();
1103
1054
  const screen = this.screenComponent;
1104
- const inset = opts.inset ?? "16px";
1055
+ const insetRaw = opts.inset ?? "16px";
1056
+ const inset = VALID_CSS_LENGTH.test(insetRaw) ? insetRaw : "16px";
1057
+ if (inset !== insetRaw) {
1058
+ devWarn(
1059
+ `[bottom-sheet] setScrimOverlay: invalid inset ${JSON.stringify(insetRaw)}; expected a CSS length like "16px" / "1rem" / "5%". Falling back to "16px".`
1060
+ );
1061
+ }
1062
+ if (opts.children.ownerDocument && opts.children.ownerDocument !== screen.ownerDocument) {
1063
+ devWarn(
1064
+ "[bottom-sheet] setScrimOverlay: children belong to a different document than the scrim. Cross-document adoption can drop event listeners; construct children via the same document as the sheet root."
1065
+ );
1066
+ }
1105
1067
  const position = opts.position ?? "top-right";
1106
1068
  const interactive = opts.interactive ?? true;
1107
1069
  const wrapper = screen.ownerDocument.createElement("div");
1108
1070
  wrapper.className = "bs-scrim-overlay";
1109
1071
  const ws = wrapper.style;
1110
1072
  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
- }
1073
+ applyOverlayPosition(ws, this.mode, position, inset);
1154
1074
  if (interactive) {
1155
1075
  ws.pointerEvents = "auto";
1156
1076
  }
1157
1077
  wrapper.appendChild(opts.children);
1158
- screen.appendChild(wrapper);
1078
+ const host = screen.parentElement;
1079
+ if (!host) {
1080
+ devWarn(
1081
+ "[bottom-sheet] setScrimOverlay: scrim element has no parent, skipping overlay injection. Mount the sheet root before calling setScrimOverlay."
1082
+ );
1083
+ return () => {
1084
+ };
1085
+ }
1086
+ host.appendChild(wrapper);
1159
1087
  const teardown = () => {
1160
1088
  if (wrapper.parentNode) wrapper.parentNode.removeChild(wrapper);
1161
1089
  if (this.scrimOverlayTeardown === teardown) {
@@ -1165,7 +1093,6 @@ var ScrimController = class {
1165
1093
  this.scrimOverlayTeardown = teardown;
1166
1094
  return teardown;
1167
1095
  }
1168
- /** Tear down toggle-state listeners + remove any injected overlay wrapper. */
1169
1096
  destroy() {
1170
1097
  this.detachScrimTap?.();
1171
1098
  this.detachScrimTap = null;
@@ -1184,8 +1111,6 @@ var ScrimController = class {
1184
1111
  this.lastBackdropPointer = "";
1185
1112
  this.lastScreenDisplay = "";
1186
1113
  }
1187
- // ── Private helpers ────────────────────────────────────────────────
1188
- /** Centralized scrim style writer — atomic 3-property write across all setters. */
1189
1114
  applyScrimStyles(opts) {
1190
1115
  if (!this.screenComponent) return;
1191
1116
  const s = this.screenComponent.style;
@@ -1204,10 +1129,10 @@ var ScrimController = class {
1204
1129
  }
1205
1130
  }
1206
1131
  if (opts.interactive !== void 0) {
1132
+ this.scrimInteractive = opts.interactive;
1207
1133
  s.pointerEvents = opts.interactive ? "auto" : "none";
1208
1134
  }
1209
1135
  }
1210
- /** Pin the screen overlay to the area NOT occupied by the sheet. */
1211
1136
  applyAboveSheetInset() {
1212
1137
  if (!this.screenComponent) return;
1213
1138
  const insetByMode = {
@@ -1219,7 +1144,7 @@ var ScrimController = class {
1219
1144
  const s = this.screenComponent.style;
1220
1145
  s.position = "fixed";
1221
1146
  s.inset = insetByMode[this.mode];
1222
- s.pointerEvents = "none";
1147
+ s.pointerEvents = this.scrimInteractive || this.scrimTapToCloseEnabled ? "auto" : "none";
1223
1148
  }
1224
1149
  };
1225
1150
 
@@ -1273,6 +1198,10 @@ var tween = ({
1273
1198
  promise
1274
1199
  };
1275
1200
  };
1201
+ var prefersReducedMotion = () => {
1202
+ if (typeof window === "undefined" || !window.matchMedia) return false;
1203
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
1204
+ };
1276
1205
 
1277
1206
  // src/core/animation/spring.ts
1278
1207
  var DEFAULT_SPRING = {
@@ -1303,14 +1232,20 @@ var runSpring = ({
1303
1232
  if (cancelled) return;
1304
1233
  const dt = Math.min((now - lastTime) / 1e3, MAX_DT);
1305
1234
  lastTime = now;
1306
- const subDt = Math.min(dt, 1 / 240);
1307
- let stepsLeft = Math.max(1, Math.ceil(dt / subDt));
1235
+ const steps = Math.max(1, Math.ceil(dt * 240));
1236
+ const subDt = dt / steps;
1237
+ let stepsLeft = steps;
1308
1238
  while (stepsLeft-- > 0) {
1309
1239
  const force = -cfg.stiffness * (x - to) - cfg.damping * v;
1310
1240
  const accel = force / cfg.mass;
1311
1241
  v += accel * subDt;
1312
1242
  x += v * subDt;
1313
1243
  }
1244
+ if (!Number.isFinite(x) || !Number.isFinite(v)) {
1245
+ onUpdate(to, 0);
1246
+ resolve();
1247
+ return;
1248
+ }
1314
1249
  onUpdate(x, v);
1315
1250
  const isAtRest = Math.abs(to - x) < (cfg.restDelta ?? 0.5) && Math.abs(v) < (cfg.restSpeed ?? 0.5);
1316
1251
  if (isAtRest) {
@@ -1374,13 +1309,9 @@ var AnimationRunner = class {
1374
1309
  __publicField(this, "duration");
1375
1310
  __publicField(this, "easing");
1376
1311
  __publicField(this, "respectReducedMotion");
1377
- /** Live-tracked via mq listener so animateTo skips matchMedia per snap. */
1378
1312
  __publicField(this, "reducedMotion", false);
1379
- /** Disposes the matchMedia change listener — drained from destroy(). */
1380
1313
  __publicField(this, "detachReducedMotion", null);
1381
- /** Computed once — both inputs (option + API presence) stable for life. */
1382
1314
  __publicField(this, "viewTransitionsAvailable");
1383
- /** In-flight handles. Public for tests / debugging only — engine drives via cancel(). */
1384
1315
  __publicField(this, "currentTween", null);
1385
1316
  __publicField(this, "currentSpring", null);
1386
1317
  this.element = deps.element;
@@ -1411,26 +1342,13 @@ var AnimationRunner = class {
1411
1342
  }
1412
1343
  }
1413
1344
  }
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
1345
  get isAnimating() {
1422
1346
  return this.currentTween !== null || this.currentSpring !== null;
1423
1347
  }
1424
- /** Cancel any in-flight tween/spring without touching engine state. */
1425
1348
  cancel() {
1426
1349
  this.currentTween?.cancel();
1427
1350
  this.currentSpring?.cancel();
1428
1351
  }
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
1352
  async animateTo(target, velocityPxPerMs) {
1435
1353
  if (target === this.getSize()) return;
1436
1354
  this.currentTween?.cancel();
@@ -1471,7 +1389,6 @@ var AnimationRunner = class {
1471
1389
  if (!this.isDragging()) this.element.style.willChange = "auto";
1472
1390
  }
1473
1391
  }
1474
- /** Tear down the matchMedia listener. Engine calls from destroy(). */
1475
1392
  destroy() {
1476
1393
  this.detachReducedMotion?.();
1477
1394
  this.detachReducedMotion = null;
@@ -1503,9 +1420,13 @@ var isVisible = (el) => {
1503
1420
  var focusables = (root) => Array.from(root.querySelectorAll(FOCUSABLE)).filter(
1504
1421
  (el) => !el.hasAttribute("inert") && isVisible(el) && !el.matches("[aria-hidden='true']")
1505
1422
  );
1423
+ var trapStack = [];
1506
1424
  var installFocusTrap = (container, options = {}) => {
1507
1425
  if (typeof document === "undefined") return () => {
1508
1426
  };
1427
+ const token = {};
1428
+ trapStack.push(token);
1429
+ const isActive = () => trapStack[trapStack.length - 1] === token;
1509
1430
  const previouslyFocused = document.activeElement;
1510
1431
  const focusInitial = () => {
1511
1432
  const initial = typeof options.initialFocus === "string" ? container.querySelector(options.initialFocus) : options.initialFocus ?? focusables(container)[0] ?? container;
@@ -1513,6 +1434,7 @@ var installFocusTrap = (container, options = {}) => {
1513
1434
  };
1514
1435
  focusInitial();
1515
1436
  const handleKey = (e) => {
1437
+ if (!isActive()) return;
1516
1438
  if (e.key === "Escape" && options.onEscape) {
1517
1439
  options.onEscape();
1518
1440
  return;
@@ -1535,6 +1457,7 @@ var installFocusTrap = (container, options = {}) => {
1535
1457
  }
1536
1458
  };
1537
1459
  const handleFocusIn = (e) => {
1460
+ if (!isActive()) return;
1538
1461
  const target = e.target;
1539
1462
  if (target && !container.contains(target)) {
1540
1463
  const list = focusables(container);
@@ -1547,6 +1470,8 @@ var installFocusTrap = (container, options = {}) => {
1547
1470
  return () => {
1548
1471
  if (released) return;
1549
1472
  released = true;
1473
+ const idx = trapStack.indexOf(token);
1474
+ if (idx !== -1) trapStack.splice(idx, 1);
1550
1475
  document.removeEventListener("keydown", handleKey, true);
1551
1476
  document.removeEventListener("focusin", handleFocusIn, true);
1552
1477
  if (previouslyFocused && document.contains(previouslyFocused)) {
@@ -1602,11 +1527,17 @@ function createInertSiblings(rootProvider) {
1602
1527
  return {
1603
1528
  apply() {
1604
1529
  if (typeof document === "undefined") return;
1605
- const root = rootProvider();
1606
- const parent = root.parentElement ?? document.body;
1530
+ let top = rootProvider();
1531
+ while (top.parentElement && top.parentElement !== document.body) {
1532
+ top = top.parentElement;
1533
+ }
1534
+ const parent = top.parentElement ?? document.body;
1607
1535
  for (const child of Array.from(parent.children)) {
1608
- if (child === root) continue;
1536
+ if (child === top) continue;
1609
1537
  if (child.hasAttribute("inert")) continue;
1538
+ if (child.classList.contains("bs-backdrop") || child.classList.contains("bs-screen") || child.classList.contains("bs-root")) {
1539
+ continue;
1540
+ }
1610
1541
  child.setAttribute("inert", "");
1611
1542
  tracked.push(child);
1612
1543
  }
@@ -1622,7 +1553,6 @@ function createInertSiblings(rootProvider) {
1622
1553
  var LifecycleController = class {
1623
1554
  constructor(deps, opts) {
1624
1555
  __publicField(this, "element");
1625
- __publicField(this, "closeFn");
1626
1556
  __publicField(this, "focusTrapEnabled");
1627
1557
  __publicField(this, "initialFocus");
1628
1558
  __publicField(this, "closeOnEscape");
@@ -1631,16 +1561,11 @@ var LifecycleController = class {
1631
1561
  __publicField(this, "inertSiblings");
1632
1562
  __publicField(this, "shouldApplyInertSiblings");
1633
1563
  __publicField(this, "returnFocus");
1634
- /** Toggle-state teardowns — fire on close, NOT in destroy-only stack. */
1635
1564
  __publicField(this, "releaseFocusTrap", null);
1636
1565
  __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
1566
  __publicField(this, "installed", false);
1641
1567
  __publicField(this, "destroyed", false);
1642
1568
  this.element = deps.element;
1643
- this.closeFn = deps.close;
1644
1569
  this.focusTrapEnabled = opts.focusTrap ?? false;
1645
1570
  this.initialFocus = opts.initialFocus;
1646
1571
  this.closeOnEscape = opts.closeOnEscape ?? true;
@@ -1650,14 +1575,9 @@ var LifecycleController = class {
1650
1575
  this.returnFocus = opts.returnFocus;
1651
1576
  this.inertSiblings = createInertSiblings(() => this.element);
1652
1577
  }
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
- */
1578
+ get isInstalled() {
1579
+ return this.installed;
1580
+ }
1661
1581
  install() {
1662
1582
  if (this.destroyed) return;
1663
1583
  this.installed = true;
@@ -1666,30 +1586,17 @@ var LifecycleController = class {
1666
1586
  }
1667
1587
  if (this.focusTrapEnabled && !this.releaseFocusTrap) {
1668
1588
  this.releaseFocusTrap = installFocusTrap(this.element, {
1669
- initialFocus: this.initialFocus,
1670
- onEscape: this.closeOnEscape ? () => void this.closeFn() : void 0
1589
+ initialFocus: this.initialFocus
1671
1590
  });
1672
1591
  }
1673
1592
  if (this.inertSiblingsEnabled && this.shouldApplyInertSiblings()) {
1674
1593
  this.inertSiblings.apply();
1675
1594
  }
1676
1595
  }
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
1596
  setReturnFocus(target) {
1683
1597
  if (this.destroyed) return;
1684
1598
  this.returnFocus = target;
1685
1599
  }
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
1600
  release() {
1694
1601
  if (!this.installed) return;
1695
1602
  this.installed = false;
@@ -1703,12 +1610,6 @@ var LifecycleController = class {
1703
1610
  target?.focus?.();
1704
1611
  }
1705
1612
  }
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
1613
  destroy() {
1713
1614
  this.release();
1714
1615
  this.destroyed = true;
@@ -1718,12 +1619,10 @@ var LifecycleController = class {
1718
1619
  // src/core/gestures.ts
1719
1620
  var isAxisVertical = (mode) => mode === "bottom" || mode === "top";
1720
1621
  var SampleRing = class {
1721
- // current count, ≤ capacity
1722
1622
  constructor(capacity) {
1723
1623
  __publicField(this, "slots");
1724
1624
  __publicField(this, "capacity");
1725
1625
  __publicField(this, "head", 0);
1726
- // next write index
1727
1626
  __publicField(this, "size", 0);
1728
1627
  this.capacity = capacity;
1729
1628
  this.slots = new Array(capacity);
@@ -1736,12 +1635,6 @@ var SampleRing = class {
1736
1635
  this.head = (this.head + 1) % this.capacity;
1737
1636
  if (this.size < this.capacity) this.size++;
1738
1637
  }
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
1638
  pruneBefore(cutoff, minKeep) {
1746
1639
  while (this.size > minKeep) {
1747
1640
  const oldestIdx = (this.head - this.size + this.capacity) % this.capacity;
@@ -1847,10 +1740,11 @@ var installGestures = (handle, mode, callbacks) => {
1847
1740
  };
1848
1741
 
1849
1742
  // src/core/primitives/rubber-band.ts
1850
- var RUBBER_BAND_FACTOR = 0.55;
1851
1743
  function rubberBand(overshoot, maxAxisSize) {
1852
1744
  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));
1745
+ if (cap <= 0) return 0;
1746
+ const abs = Math.abs(overshoot);
1747
+ return Math.sign(overshoot) * (cap * abs / (abs + cap));
1854
1748
  }
1855
1749
 
1856
1750
  // src/core/features/soft-keyboard.ts
@@ -1877,28 +1771,14 @@ var GestureController = class {
1877
1771
  __publicField(this, "detach", null);
1878
1772
  __publicField(this, "isDragging_", false);
1879
1773
  __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
1774
  __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
1775
  __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
1776
  __publicField(this, "dragPayload", { size: 0, delta: 0 });
1892
1777
  this.deps = deps;
1893
1778
  }
1894
- /** True between `dragstart` and `dragend`/`onCancel`. */
1895
1779
  get isDragging() {
1896
1780
  return this.isDragging_;
1897
1781
  }
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
1782
  install() {
1903
1783
  if (this.detach) return this.detach;
1904
1784
  this.detach = installGestures(this.deps.handle, this.deps.mode, {
@@ -1949,17 +1829,16 @@ var GestureController = class {
1949
1829
  this.isDragging_ = false;
1950
1830
  this.keyboardDismissed = false;
1951
1831
  this.deps.getRoot()?.removeAttribute("data-dragging");
1832
+ this.deps.emit("dragend", {
1833
+ size: this.deps.getDragContext().size,
1834
+ velocity: 0
1835
+ });
1952
1836
  const restoreTo = this.dragStartSize;
1953
1837
  void this.deps.animateTo(restoreTo, 0);
1954
1838
  }
1955
1839
  });
1956
1840
  return this.detach;
1957
1841
  }
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
1842
  forceClearDragState() {
1964
1843
  if (!this.isDragging_) return;
1965
1844
  this.isDragging_ = false;
@@ -1978,11 +1857,6 @@ var AriaSliderWriter = class {
1978
1857
  mode === "bottom" || mode === "top" ? "vertical" : "horizontal"
1979
1858
  );
1980
1859
  }
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
1860
  setValue(allowedIds, activeId) {
1987
1861
  if (allowedIds.length === 0) {
1988
1862
  this.handle.removeAttribute("aria-valuemin");
@@ -2032,6 +1906,287 @@ function createScrollCache(deps) {
2032
1906
  };
2033
1907
  }
2034
1908
 
1909
+ // src/core/primitives/anchor-animations.ts
1910
+ var DEFAULT_DURATION3 = 200;
1911
+ var DEFAULT_EASING = "cubic-bezier(0.22, 1, 0.36, 1)";
1912
+ var PRESET_FRAMES = {
1913
+ fade: {
1914
+ enter: [{ opacity: 0 }, { opacity: 1 }],
1915
+ exit: [{ opacity: 1 }, { opacity: 0 }]
1916
+ },
1917
+ scale: {
1918
+ enter: [
1919
+ { opacity: 0, transform: "scale(0.85)" },
1920
+ { opacity: 1, transform: "scale(1)" }
1921
+ ],
1922
+ exit: [
1923
+ { opacity: 1, transform: "scale(1)" },
1924
+ { opacity: 0, transform: "scale(0.85)" }
1925
+ ]
1926
+ },
1927
+ slide: {
1928
+ enter: [
1929
+ { opacity: 0, transform: "translateY(10px)" },
1930
+ { opacity: 1, transform: "translateY(0)" }
1931
+ ],
1932
+ exit: [
1933
+ { opacity: 1, transform: "translateY(0)" },
1934
+ { opacity: 0, transform: "translateY(10px)" }
1935
+ ]
1936
+ },
1937
+ pop: {
1938
+ enter: [
1939
+ { opacity: 0, transform: "scale(0.6)", offset: 0 },
1940
+ { opacity: 1, transform: "scale(1.06)", offset: 0.7 },
1941
+ { opacity: 1, transform: "scale(1)", offset: 1 }
1942
+ ],
1943
+ exit: [
1944
+ { opacity: 1, transform: "scale(1)" },
1945
+ { opacity: 0, transform: "scale(0.6)" }
1946
+ ]
1947
+ }
1948
+ };
1949
+ var INSTANT = {
1950
+ finished: Promise.resolve(),
1951
+ cancel: () => {
1952
+ }
1953
+ };
1954
+ function resolveTransitionFrames(spec, phase) {
1955
+ const fallback = { frames: null, duration: 0, easing: DEFAULT_EASING };
1956
+ if (spec === "none") return fallback;
1957
+ if (spec === void 0 || typeof spec === "string") {
1958
+ const preset2 = PRESET_FRAMES[spec ?? "fade"];
1959
+ return {
1960
+ frames: preset2[phase],
1961
+ duration: DEFAULT_DURATION3,
1962
+ easing: DEFAULT_EASING
1963
+ };
1964
+ }
1965
+ const custom = phase === "enter" ? spec.enter : spec.exit;
1966
+ if (custom) {
1967
+ return {
1968
+ frames: custom,
1969
+ duration: spec.duration ?? DEFAULT_DURATION3,
1970
+ easing: spec.easing ?? DEFAULT_EASING
1971
+ };
1972
+ }
1973
+ if (spec.preset === "none") return fallback;
1974
+ const preset = PRESET_FRAMES[spec.preset ?? "fade"];
1975
+ return {
1976
+ frames: preset[phase],
1977
+ duration: spec.duration ?? DEFAULT_DURATION3,
1978
+ easing: spec.easing ?? DEFAULT_EASING
1979
+ };
1980
+ }
1981
+ function runAnchorTransition(el, spec, phase) {
1982
+ const respectReduced = typeof spec === "object" ? spec.respectReducedMotion !== false : true;
1983
+ if (respectReduced && prefersReducedMotion()) return INSTANT;
1984
+ const { frames, duration, easing } = resolveTransitionFrames(spec, phase);
1985
+ if (!frames || duration <= 0) return INSTANT;
1986
+ if (typeof el.animate !== "function") return INSTANT;
1987
+ const animation = el.animate(frames, {
1988
+ duration,
1989
+ easing,
1990
+ fill: "both"
1991
+ });
1992
+ const finished = animation.finished.then(() => void 0).catch(() => void 0);
1993
+ return {
1994
+ finished,
1995
+ cancel: () => {
1996
+ try {
1997
+ animation.cancel();
1998
+ } catch {
1999
+ return;
2000
+ }
2001
+ }
2002
+ };
2003
+ }
2004
+
2005
+ // src/core/features/sheet-anchors.ts
2006
+ function attachAnchor(deps, opts) {
2007
+ const doc = deps.host.ownerDocument;
2008
+ const wrapper = doc.createElement("div");
2009
+ wrapper.className = "bs-anchor";
2010
+ const interactive = opts.interactive ?? true;
2011
+ const position = opts.position ?? "sheet-top-right";
2012
+ const isDock = position === "dock-bottom" || position === "dock-top";
2013
+ const ws = wrapper.style;
2014
+ ws.position = "fixed";
2015
+ if (isDock) {
2016
+ ws.left = "0";
2017
+ ws.right = "0";
2018
+ if (position === "dock-bottom") {
2019
+ ws.bottom = "0";
2020
+ } else {
2021
+ ws.top = "0";
2022
+ }
2023
+ } else {
2024
+ applyOverlayPosition(ws, deps.mode, position, opts.inset ?? "16px");
2025
+ }
2026
+ if (opts.fadeRange) {
2027
+ const [r0, r1] = opts.fadeRange;
2028
+ const span = Math.max(r1 - r0, 1e-4);
2029
+ ws.opacity = `clamp(0, calc((var(--bs-progress, 0) - ${r0}) / ${span}), 1)`;
2030
+ }
2031
+ wrapper.appendChild(opts.element);
2032
+ deps.host.appendChild(wrapper);
2033
+ let visible = null;
2034
+ let inFlight = null;
2035
+ const shouldShow = (state) => {
2036
+ if (typeof opts.showOn === "function") return opts.showOn(state);
2037
+ if (Array.isArray(opts.showOn)) return opts.showOn.includes(state.activeId);
2038
+ return isDock || state.size > 0;
2039
+ };
2040
+ const applyVisibility = (next, animate) => {
2041
+ if (next === visible) return;
2042
+ visible = next;
2043
+ inFlight?.cancel();
2044
+ inFlight = null;
2045
+ wrapper.style.pointerEvents = next && interactive ? "auto" : "none";
2046
+ if (next) {
2047
+ wrapper.style.visibility = "";
2048
+ if (animate) {
2049
+ inFlight = runAnchorTransition(opts.element, opts.animation, "enter");
2050
+ }
2051
+ return;
2052
+ }
2053
+ if (!animate) {
2054
+ wrapper.style.visibility = "hidden";
2055
+ return;
2056
+ }
2057
+ const handle = runAnchorTransition(opts.element, opts.animation, "exit");
2058
+ inFlight = handle;
2059
+ void handle.finished.then(() => {
2060
+ if (inFlight === handle && visible === false) {
2061
+ wrapper.style.visibility = "hidden";
2062
+ }
2063
+ });
2064
+ };
2065
+ const evaluate = (animate) => {
2066
+ if (deps.isDestroyed()) return;
2067
+ applyVisibility(shouldShow(deps.getState()), animate);
2068
+ };
2069
+ const offs = [
2070
+ deps.on("snap", () => evaluate(true)),
2071
+ deps.on("open", () => evaluate(true)),
2072
+ deps.on("close", () => evaluate(true))
2073
+ ];
2074
+ evaluate(false);
2075
+ return {
2076
+ wrapper,
2077
+ detach: () => {
2078
+ offs.forEach((off) => off());
2079
+ inFlight?.cancel();
2080
+ inFlight = null;
2081
+ if (wrapper.parentNode) wrapper.parentNode.removeChild(wrapper);
2082
+ },
2083
+ syncZ: (z) => {
2084
+ wrapper.style.zIndex = String(z);
2085
+ }
2086
+ };
2087
+ }
2088
+
2089
+ // src/core/features/scrim-stages.ts
2090
+ var matchesId = (def, activeId) => {
2091
+ if (def.for === void 0) return false;
2092
+ return Array.isArray(def.for) ? def.for.includes(activeId) : def.for === activeId;
2093
+ };
2094
+ var matchesRange = (def, progress) => {
2095
+ if (!def.forRange) return false;
2096
+ const [from, to] = def.forRange;
2097
+ return progress >= from && progress <= to;
2098
+ };
2099
+ function installScrimStages(deps, opts) {
2100
+ const doc = deps.host.ownerDocument;
2101
+ const entries = opts.stages.map((def) => {
2102
+ const wrapper = doc.createElement("div");
2103
+ wrapper.className = "bs-scrim-stage";
2104
+ const ws = wrapper.style;
2105
+ ws.position = "absolute";
2106
+ applyOverlayPosition(
2107
+ ws,
2108
+ deps.mode,
2109
+ def.position ?? opts.position ?? "center",
2110
+ def.inset ?? opts.inset ?? "16px"
2111
+ );
2112
+ ws.visibility = "hidden";
2113
+ ws.pointerEvents = "none";
2114
+ wrapper.appendChild(def.element);
2115
+ deps.host.appendChild(wrapper);
2116
+ return { def, wrapper, inFlight: null };
2117
+ });
2118
+ let active = null;
2119
+ const resolveActive = () => {
2120
+ const state = deps.getState();
2121
+ if (state.size === 0) {
2122
+ return entries.find((e) => matchesId(e.def, state.activeId)) ?? null;
2123
+ }
2124
+ return entries.find((e) => matchesId(e.def, state.activeId)) ?? entries.find((e) => matchesRange(e.def, state.progress)) ?? null;
2125
+ };
2126
+ const hide = (entry, animate) => {
2127
+ entry.inFlight?.cancel();
2128
+ entry.wrapper.style.pointerEvents = "none";
2129
+ if (!animate) {
2130
+ entry.inFlight = null;
2131
+ entry.wrapper.style.visibility = "hidden";
2132
+ return;
2133
+ }
2134
+ const handle = runAnchorTransition(
2135
+ entry.def.element,
2136
+ entry.def.animation ?? opts.animation,
2137
+ "exit"
2138
+ );
2139
+ entry.inFlight = handle;
2140
+ void handle.finished.then(() => {
2141
+ if (entry.inFlight === handle && active !== entry) {
2142
+ entry.wrapper.style.visibility = "hidden";
2143
+ }
2144
+ });
2145
+ };
2146
+ const show = (entry, animate) => {
2147
+ entry.inFlight?.cancel();
2148
+ entry.inFlight = null;
2149
+ entry.wrapper.style.visibility = "";
2150
+ entry.wrapper.style.pointerEvents = entry.def.interactive ?? opts.interactive ?? false ? "auto" : "none";
2151
+ if (animate) {
2152
+ entry.inFlight = runAnchorTransition(
2153
+ entry.def.element,
2154
+ entry.def.animation ?? opts.animation,
2155
+ "enter"
2156
+ );
2157
+ }
2158
+ };
2159
+ const update = (animate) => {
2160
+ if (deps.isDestroyed()) return;
2161
+ const next = resolveActive();
2162
+ if (next === active) return;
2163
+ const prev = active;
2164
+ active = next;
2165
+ if (prev) hide(prev, animate);
2166
+ if (next) show(next, animate);
2167
+ };
2168
+ const offs = [
2169
+ deps.on("snap", () => update(true)),
2170
+ deps.on("open", () => update(true)),
2171
+ deps.on("close", () => update(true))
2172
+ ];
2173
+ if (entries.some((e) => e.def.forRange)) {
2174
+ offs.push(deps.on("progress", () => update(true)));
2175
+ }
2176
+ update(false);
2177
+ return () => {
2178
+ offs.forEach((off) => off());
2179
+ for (const entry of entries) {
2180
+ entry.inFlight?.cancel();
2181
+ entry.inFlight = null;
2182
+ if (entry.wrapper.parentNode) {
2183
+ entry.wrapper.parentNode.removeChild(entry.wrapper);
2184
+ }
2185
+ }
2186
+ active = null;
2187
+ };
2188
+ }
2189
+
2035
2190
  // src/core/primitives/engine-options.ts
2036
2191
  var DEFAULT_FLICK_VELOCITY = 0.65;
2037
2192
  var DEFAULT_DRAG_THRESHOLD = 18;
@@ -2056,9 +2211,6 @@ function resolveEngineOptions(opts, extras) {
2056
2211
  scrimMode: opts.scrimMode,
2057
2212
  scrimColor: opts.scrimColor,
2058
2213
  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
2214
  scrimInteractive: opts.scrimInteractive ?? (opts.scrimTapToClose ? true : void 0),
2063
2215
  scrimTapToClose: opts.scrimTapToClose,
2064
2216
  scrimPreset: opts.scrimPreset,
@@ -2079,10 +2231,6 @@ function resolveEngineOptions(opts, extras) {
2079
2231
  closeOnEscape: opts.closeOnEscape,
2080
2232
  lockBodyScroll: opts.lockBodyScroll,
2081
2233
  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
2234
  shouldApplyInertSiblings: extras?.shouldApplyInertSiblings
2087
2235
  }
2088
2236
  };
@@ -2117,6 +2265,7 @@ var BottomSheetEngine = class {
2117
2265
  __publicField(this, "scrollContainer");
2118
2266
  __publicField(this, "backdrop");
2119
2267
  __publicField(this, "screenComponent");
2268
+ __publicField(this, "scrimParent", null);
2120
2269
  __publicField(this, "mode");
2121
2270
  __publicField(this, "flickVelocity");
2122
2271
  __publicField(this, "dragThreshold");
@@ -2127,40 +2276,22 @@ var BottomSheetEngine = class {
2127
2276
  __publicField(this, "lifecycle");
2128
2277
  __publicField(this, "closeOnBack");
2129
2278
  __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
2279
  __publicField(this, "snapPointsRaw");
2133
2280
  __publicField(this, "snaps");
2134
2281
  __publicField(this, "activeId");
2135
2282
  __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
2283
  __publicField(this, "gesture");
2140
2284
  __publicField(this, "rootEl", null);
2141
2285
  __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
2286
  __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
2287
  __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
2288
  __publicField(this, "sizeWriteSentinel", new WriteSentinel());
2154
2289
  __publicField(this, "progressWriteSentinel", new WriteSentinel());
2155
2290
  __publicField(this, "isTopSheet", true);
2156
- // Pooled payload — identity documented in SheetEventMap.progress JSDoc;
2157
- // consumers must not retain across frames.
2158
2291
  __publicField(this, "progressPayload", {
2159
2292
  value: 0,
2160
2293
  size: 0
2161
2294
  });
2162
- // Pooled buffer for `getDragContext()` — onMove calls this 60-120×/s.
2163
- // Inner `range` object identity is also stable (mutated in place).
2164
2295
  __publicField(this, "dragContextBuf", {
2165
2296
  size: 0,
2166
2297
  maxAxisSize: 0,
@@ -2171,13 +2302,13 @@ var BottomSheetEngine = class {
2171
2302
  __publicField(this, "persistKey");
2172
2303
  __publicField(this, "linkedSheets", []);
2173
2304
  __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
2305
  __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.
2306
+ __publicField(this, "anchors", []);
2307
+ __publicField(this, "anchorHost", null);
2308
+ __publicField(this, "stackZ", 100);
2309
+ __publicField(this, "detachScrimStages", null);
2310
+ __publicField(this, "stackEffectEnabled", false);
2311
+ __publicField(this, "stackEffectPrimed", false);
2181
2312
  __publicField(this, "transformTemplate");
2182
2313
  const resolved = resolveEngineOptions(opts, {
2183
2314
  shouldApplyInertSiblings: () => {
@@ -2192,7 +2323,8 @@ var BottomSheetEngine = class {
2192
2323
  this.handle = opts.handle ?? opts.element;
2193
2324
  this.scrollContainer = opts.scrollContainer;
2194
2325
  this.backdrop = opts.backdrop;
2195
- this.screenComponent = opts.screenComponent;
2326
+ this.screenComponent = opts.scrim ?? opts.screenComponent;
2327
+ this.scrimParent = this.screenComponent?.parentElement ?? null;
2196
2328
  this.mode = resolved.mode;
2197
2329
  this.transformTemplate = buildTransformTemplate(this.mode);
2198
2330
  this.aria = new AriaSliderWriter(this.handle, this.mode);
@@ -2210,6 +2342,9 @@ var BottomSheetEngine = class {
2210
2342
  getResolvedSnaps: () => this.snaps.getResolvedSnaps().slice(),
2211
2343
  snapTo: (id) => {
2212
2344
  void this.snapTo(id);
2345
+ },
2346
+ close: () => {
2347
+ void this.close();
2213
2348
  }
2214
2349
  },
2215
2350
  resolved.scrim
@@ -2220,8 +2355,6 @@ var BottomSheetEngine = class {
2220
2355
  getRootEl: () => this.rootEl,
2221
2356
  applySize: (size) => this.applySize(size),
2222
2357
  getSize: () => this.size,
2223
- // Optional chain protects synthetic test paths that drive animateTo
2224
- // before attach() (gesture is constructed there).
2225
2358
  isDragging: () => this.gesture?.isDragging ?? false
2226
2359
  },
2227
2360
  resolved.animation
@@ -2235,6 +2368,7 @@ var BottomSheetEngine = class {
2235
2368
  );
2236
2369
  this.closeOnBack = resolved.closeOnBack;
2237
2370
  this.routedTo = opts.routedTo;
2371
+ this.stackEffectEnabled = opts.stackEffect ?? false;
2238
2372
  this.snapPointsRaw = opts.snapPoints;
2239
2373
  this.persistKey = opts.persistKey;
2240
2374
  if (opts.linkedSheets) {
@@ -2247,7 +2381,7 @@ var BottomSheetEngine = class {
2247
2381
  this.snapPointsRaw,
2248
2382
  resolved.initialAllowed,
2249
2383
  this.mode,
2250
- () => this.handle.offsetHeight,
2384
+ () => layoutAxis(this.mode) === "width" ? this.handle.offsetWidth : this.handle.offsetHeight,
2251
2385
  (maxAxisSize) => {
2252
2386
  this.element.style[layoutAxis(this.mode)] = `${maxAxisSize}px`;
2253
2387
  }
@@ -2326,21 +2460,6 @@ var BottomSheetEngine = class {
2326
2460
  on(event, fn) {
2327
2461
  return this.bus.on(event, fn);
2328
2462
  }
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
2463
  use(plugin) {
2345
2464
  if (this.destroyed) {
2346
2465
  console.warn(
@@ -2350,9 +2469,6 @@ var BottomSheetEngine = class {
2350
2469
  }
2351
2470
  const scoped = [];
2352
2471
  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
2472
  add: (fn) => {
2357
2473
  if (typeof fn === "function") scoped.push(fn);
2358
2474
  }
@@ -2381,36 +2497,13 @@ var BottomSheetEngine = class {
2381
2497
  }
2382
2498
  return this;
2383
2499
  }
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
2500
  setLinkedSheets(sheets) {
2391
2501
  if (this.destroyed) return;
2392
2502
  this.linkedSheets = sheets;
2393
2503
  }
2394
- /** Read-only view of the current allow-list, for cross-sheet target negotiation. */
2395
2504
  getAllowedIds() {
2396
2505
  return this.snaps.getAllowedIds().slice();
2397
2506
  }
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
2507
  async snapTo(id, velocityOrOpts = 0, _skipBeforeSnap = false) {
2415
2508
  if (this.destroyed) return;
2416
2509
  const opts = typeof velocityOrOpts === "object" ? velocityOrOpts : { velocity: velocityOrOpts };
@@ -2469,16 +2562,6 @@ var BottomSheetEngine = class {
2469
2562
  this.handleClose();
2470
2563
  }
2471
2564
  }
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
2565
  async dragTo(targetSize, velocityOrOpts = 0) {
2483
2566
  if (this.destroyed) return;
2484
2567
  const opts = typeof velocityOrOpts === "object" ? velocityOrOpts : { velocity: velocityOrOpts };
@@ -2508,28 +2591,13 @@ var BottomSheetEngine = class {
2508
2591
  }
2509
2592
  setAllowed(ids, snap) {
2510
2593
  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
2594
  this.snaps.setAllowedIds(ids);
2517
2595
  this.updateAriaSlider();
2518
2596
  if (snap && ids.includes(snap)) {
2519
- void this.snapTo(
2520
- snap,
2521
- 0,
2522
- /* internal */
2523
- true
2524
- );
2597
+ void this.snapTo(snap, 0, true);
2525
2598
  } else if (!ids.includes(this.activeId)) {
2526
2599
  const fallback = ids[0];
2527
- if (fallback) void this.snapTo(
2528
- fallback,
2529
- 0,
2530
- /* internal */
2531
- true
2532
- );
2600
+ if (fallback) void this.snapTo(fallback, 0, true);
2533
2601
  }
2534
2602
  }
2535
2603
  setBackdropRange(range) {
@@ -2547,21 +2615,6 @@ var BottomSheetEngine = class {
2547
2615
  setScrimInteractive(interactive) {
2548
2616
  this.scrim.setScrimInteractive(interactive);
2549
2617
  }
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
2618
  setScrim(opts) {
2566
2619
  this.scrim.setScrim(opts, () => this.applySize(this.size));
2567
2620
  }
@@ -2577,11 +2630,79 @@ var BottomSheetEngine = class {
2577
2630
  setScrimOverlay(opts) {
2578
2631
  return this.scrim.setScrimOverlay(opts);
2579
2632
  }
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
- */
2633
+ addAnchor(opts) {
2634
+ if (this.destroyed || typeof document === "undefined") return () => {
2635
+ };
2636
+ if (!this.anchorHost) {
2637
+ this.anchorHost = this.rootEl ?? this.element.parentElement ?? document.body;
2638
+ this.anchorHost.style.setProperty("--bs-size", `${this.size}px`);
2639
+ this.anchorHost.style.setProperty(
2640
+ "--bs-progress",
2641
+ String(this.computeProgress(this.size))
2642
+ );
2643
+ }
2644
+ const handle = attachAnchor(
2645
+ {
2646
+ mode: this.mode,
2647
+ host: this.anchorHost,
2648
+ getState: () => ({
2649
+ activeId: this.activeId,
2650
+ size: this.size,
2651
+ progress: this.computeProgress(this.size)
2652
+ }),
2653
+ on: (event, fn) => this.on(event, fn),
2654
+ isDestroyed: () => this.destroyed
2655
+ },
2656
+ opts
2657
+ );
2658
+ handle.syncZ(this.stackZ + 1);
2659
+ this.anchors.push(handle);
2660
+ const detach = () => {
2661
+ const idx = this.anchors.indexOf(handle);
2662
+ if (idx === -1) return;
2663
+ this.anchors.splice(idx, 1);
2664
+ handle.detach();
2665
+ };
2666
+ this.teardowns.add(detach);
2667
+ return detach;
2668
+ }
2669
+ setScrimStages(opts) {
2670
+ if (this.destroyed || typeof document === "undefined") return () => {
2671
+ };
2672
+ this.detachScrimStages?.();
2673
+ this.detachScrimStages = null;
2674
+ if (!opts) return () => {
2675
+ };
2676
+ const host = this.screenComponent?.parentElement;
2677
+ if (!host) {
2678
+ console.warn(
2679
+ "[BottomSheet] setScrimStages: no scrim element mounted \u2014 pass `scrim` to the engine first."
2680
+ );
2681
+ return () => {
2682
+ };
2683
+ }
2684
+ const detachInstalled = installScrimStages(
2685
+ {
2686
+ mode: this.mode,
2687
+ host,
2688
+ getState: () => ({
2689
+ activeId: this.activeId,
2690
+ size: this.size,
2691
+ progress: this.computeProgress(this.size)
2692
+ }),
2693
+ on: (event, fn) => this.on(event, fn),
2694
+ isDestroyed: () => this.destroyed
2695
+ },
2696
+ opts
2697
+ );
2698
+ const detach = () => {
2699
+ if (this.detachScrimStages === detach) this.detachScrimStages = null;
2700
+ detachInstalled();
2701
+ };
2702
+ this.detachScrimStages = detach;
2703
+ this.teardowns.add(() => this.detachScrimStages?.());
2704
+ return detach;
2705
+ }
2585
2706
  getScrimState() {
2586
2707
  return { mode: this.scrim.scrimMode, enabled: this.scrim.scrimEnabled };
2587
2708
  }
@@ -2627,24 +2748,11 @@ var BottomSheetEngine = class {
2627
2748
  emit(event, payload) {
2628
2749
  this.bus.emit(event, payload);
2629
2750
  }
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
2751
  newCycle() {
2637
2752
  this.currentAbort.abort();
2638
2753
  this.currentAbort = new AbortController();
2639
2754
  return this.currentAbort.signal;
2640
2755
  }
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
2756
  emitBeforeSnap(target, previousId) {
2649
2757
  let cancelled = false;
2650
2758
  let frozen = false;
@@ -2678,59 +2786,54 @@ var BottomSheetEngine = class {
2678
2786
  this.snaps.setRaw(this.snapPointsRaw);
2679
2787
  this.scrim.invalidateOpacityCache();
2680
2788
  }
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
2789
  getAllowedRange() {
2687
2790
  return this.snaps.getAllowedRange();
2688
2791
  }
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
- */
2792
+ allowedIdsBySize() {
2793
+ return this.snaps.getAllowedIds().slice().sort(
2794
+ (a, b) => (this.snaps.findById(a)?.size ?? 0) - (this.snaps.findById(b)?.size ?? 0)
2795
+ );
2796
+ }
2705
2797
  updateAriaSlider() {
2706
- this.aria.setValue(this.snaps.getAllowedIds(), this.activeId);
2798
+ this.aria.setValue(this.allowedIdsBySize(), this.activeId);
2707
2799
  }
2708
2800
  registerInStack() {
2709
2801
  this.teardowns.add(sheetStack.push({
2710
2802
  id: this.id,
2711
2803
  setZIndex: (z) => {
2804
+ this.stackZ = z;
2712
2805
  this.element.style.zIndex = String(z);
2713
2806
  if (this.backdrop) this.backdrop.style.zIndex = String(z - 1);
2807
+ for (const anchor of this.anchors) anchor.syncZ(z + 1);
2714
2808
  },
2715
2809
  setIsTop: (isTop) => {
2716
2810
  this.isTopSheet = isTop;
2717
2811
  if (this.backdrop) {
2718
2812
  this.backdrop.style.display = isTop ? "" : "none";
2719
2813
  }
2720
- }
2814
+ },
2815
+ isOpen: () => this.size > 0,
2816
+ setDepth: (depth) => this.applyStackDepth(depth)
2721
2817
  }));
2722
2818
  }
2819
+ applyStackDepth(depth) {
2820
+ if (!this.stackEffectEnabled) return;
2821
+ this.element.setAttribute("data-stack-depth", String(depth));
2822
+ if (!this.stackEffectPrimed) {
2823
+ this.stackEffectPrimed = true;
2824
+ const isVertical = this.mode === "bottom" || this.mode === "top";
2825
+ this.element.style.transformOrigin = isVertical ? "50% 100%" : "0% 50%";
2826
+ this.element.style.transition = "scale 320ms cubic-bezier(0.29, 1.04, 0.84, 0.99)";
2827
+ }
2828
+ const scale = Math.max(1 - depth * 0.04, 0.86);
2829
+ this.element.style.scale = scale === 1 ? "" : String(scale);
2830
+ }
2723
2831
  attach() {
2724
2832
  this.gesture = new GestureController({
2725
2833
  handle: this.handle,
2726
2834
  element: this.element,
2727
2835
  mode: this.mode,
2728
2836
  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
2837
  getDragContext: () => {
2735
2838
  const buf = this.dragContextBuf;
2736
2839
  const r = this.getAllowedRange();
@@ -2784,7 +2887,7 @@ var BottomSheetEngine = class {
2784
2887
  container: this.scrollContainer,
2785
2888
  isDragging: () => this.gesture?.isDragging ?? false,
2786
2889
  isAnimating: () => this.animation.isAnimating,
2787
- getAllowedIds: () => this.snaps.getAllowedIds(),
2890
+ getAllowedIds: () => this.allowedIdsBySize(),
2788
2891
  getActiveId: () => this.activeId,
2789
2892
  snapTo: (id) => {
2790
2893
  void this.snapTo(id);
@@ -2813,11 +2916,13 @@ var BottomSheetEngine = class {
2813
2916
  },
2814
2917
  newCycle: () => {
2815
2918
  this.newCycle();
2816
- }
2919
+ },
2920
+ isAnimating: () => this.animation.isAnimating,
2921
+ resyncAfterCancel: () => this.resyncAfterResize()
2817
2922
  }));
2818
2923
  if (this.lifecycle.closeOnEscape) {
2819
2924
  const onKey = (e) => {
2820
- if (this.destroyed) return;
2925
+ if (this.destroyed || e.defaultPrevented) return;
2821
2926
  if (e.key === "Escape" && this.isTopSheet && this.size > 0) {
2822
2927
  void this.close();
2823
2928
  }
@@ -2849,7 +2954,7 @@ var BottomSheetEngine = class {
2849
2954
  handle: this.handle,
2850
2955
  mode: this.mode,
2851
2956
  isDestroyed: () => this.destroyed,
2852
- getAllowedIds: () => this.snaps.getAllowedIds(),
2957
+ getAllowedIds: () => this.allowedIdsBySize(),
2853
2958
  getActiveId: () => this.activeId,
2854
2959
  snapTo: (id) => {
2855
2960
  void this.snapTo(id);
@@ -2870,6 +2975,7 @@ var BottomSheetEngine = class {
2870
2975
  });
2871
2976
  if (!target) return;
2872
2977
  const previousId = this.activeId;
2978
+ if (target.id === previousId && target.size === this.size) return;
2873
2979
  if (this.emitBeforeSnap(target, previousId)) {
2874
2980
  const restore = this.snaps.findById(previousId);
2875
2981
  if (restore) {
@@ -2878,6 +2984,7 @@ var BottomSheetEngine = class {
2878
2984
  return;
2879
2985
  }
2880
2986
  const previousSize = this.size;
2987
+ const previousSnapSize = this.snaps.findById(previousId)?.size;
2881
2988
  this.scrollCache.cache(previousId, previousSize, target.size);
2882
2989
  this.activeId = target.id;
2883
2990
  void this.animation.animateTo(target.size, velocity).then(() => {
@@ -2886,9 +2993,10 @@ var BottomSheetEngine = class {
2886
2993
  this.updateAriaSlider();
2887
2994
  this.emit("snap", { id: target.id, size: target.size });
2888
2995
  this.haptic();
2889
- if (previousId === "closed" && target.size > 0) {
2996
+ if (previousSnapSize === 0 && target.size > 0) {
2890
2997
  this.emit("open", { id: target.id });
2891
2998
  this.handleOpen();
2999
+ notifyLinkedSheets(this.linkedSheets, this);
2892
3000
  }
2893
3001
  if (target.size === 0) {
2894
3002
  this.emit("close", void 0);
@@ -2896,7 +3004,6 @@ var BottomSheetEngine = class {
2896
3004
  }
2897
3005
  });
2898
3006
  }
2899
- /** Trigger a brief haptic tick (iOS Safari + Android Chrome support). */
2900
3007
  haptic() {
2901
3008
  if (typeof navigator !== "undefined" && "vibrate" in navigator) {
2902
3009
  try {
@@ -2912,6 +3019,12 @@ var BottomSheetEngine = class {
2912
3019
  style.transform = this.transformTemplate(offset);
2913
3020
  if (this.sizeWriteSentinel.shouldWrite(size, SIZE_WRITE_EPSILON)) {
2914
3021
  style.setProperty("--bs-size", `${size}px`);
3022
+ if (this.scrimParent) {
3023
+ this.scrimParent.style.setProperty("--bs-size", `${size}px`);
3024
+ }
3025
+ if (this.anchorHost && this.anchorHost !== this.scrimParent) {
3026
+ this.anchorHost.style.setProperty("--bs-size", `${size}px`);
3027
+ }
2915
3028
  }
2916
3029
  const progress = this.computeProgress(size);
2917
3030
  const progressChanged = this.progressWriteSentinel.shouldWrite(
@@ -2920,6 +3033,9 @@ var BottomSheetEngine = class {
2920
3033
  );
2921
3034
  if (progressChanged) {
2922
3035
  style.setProperty("--bs-progress", String(progress));
3036
+ if (this.anchorHost) {
3037
+ this.anchorHost.style.setProperty("--bs-progress", String(progress));
3038
+ }
2923
3039
  }
2924
3040
  this.scrim.applyOpacity(progress, progressChanged);
2925
3041
  if (progressChanged && this.bus.listenerCount("progress") > 0) {
@@ -2934,6 +3050,7 @@ var BottomSheetEngine = class {
2934
3050
  return Math.min(Math.max((size - min) / (max - min), 0), 1);
2935
3051
  }
2936
3052
  handleOpen() {
3053
+ sheetStack.promote(this.id);
2937
3054
  try {
2938
3055
  this.lifecycle.install();
2939
3056
  } catch (err) {
@@ -2947,8 +3064,24 @@ var BottomSheetEngine = class {
2947
3064
  });
2948
3065
  }
2949
3066
  }
3067
+ resyncAfterResize() {
3068
+ if (this.destroyed) return;
3069
+ const target = this.snaps.findById(this.activeId);
3070
+ if (!target) return;
3071
+ this.updateAriaSlider();
3072
+ this.emit("snap", { id: target.id, size: target.size });
3073
+ if (target.size > 0 && !this.lifecycle.isInstalled) {
3074
+ this.emit("open", { id: target.id });
3075
+ this.handleOpen();
3076
+ notifyLinkedSheets(this.linkedSheets, this);
3077
+ } else if (target.size === 0 && this.lifecycle.isInstalled) {
3078
+ this.emit("close", void 0);
3079
+ this.handleClose();
3080
+ }
3081
+ }
2950
3082
  handleClose() {
2951
3083
  this.lifecycle.release();
3084
+ sheetStack.update();
2952
3085
  }
2953
3086
  };
2954
3087
 
@@ -2982,7 +3115,7 @@ function useBottomSheet(opts) {
2982
3115
  handle: handleRef.current ?? void 0,
2983
3116
  scrollContainer: contentRef.current ?? void 0,
2984
3117
  backdrop: backdropRef.current ?? void 0,
2985
- screenComponent: screenRef.current ?? void 0
3118
+ scrim: screenRef.current ?? void 0
2986
3119
  });
2987
3120
  engineRef.current = engine;
2988
3121
  cachedSnapshotRef.current = { ...engine.state };
@@ -3042,6 +3175,16 @@ function useBottomSheet(opts) {
3042
3175
  engineRef.current?.setAllowed(ids, snap);
3043
3176
  }, []);
3044
3177
  const getEngine = useCallback(() => engineRef.current, []);
3178
+ const addAnchor = useCallback(
3179
+ (anchorOpts) => engineRef.current?.addAnchor(anchorOpts) ?? (() => {
3180
+ }),
3181
+ []
3182
+ );
3183
+ const setScrimStages = useCallback(
3184
+ (stagesOpts) => engineRef.current?.setScrimStages(stagesOpts) ?? (() => {
3185
+ }),
3186
+ []
3187
+ );
3045
3188
  return useMemo(
3046
3189
  () => ({
3047
3190
  sheetRef,
@@ -3054,10 +3197,12 @@ function useBottomSheet(opts) {
3054
3197
  open,
3055
3198
  close,
3056
3199
  setAllowed,
3200
+ addAnchor,
3201
+ setScrimStages,
3057
3202
  engine: engineRef.current,
3058
3203
  getEngine
3059
3204
  }),
3060
- [state, snapTo, open, close, setAllowed, getEngine]
3205
+ [state, snapTo, open, close, setAllowed, addAnchor, setScrimStages, getEngine]
3061
3206
  );
3062
3207
  }
3063
3208
  var useHasMounted = () => {
@@ -3077,6 +3222,8 @@ var BottomSheet = forwardRef(
3077
3222
  leftButton,
3078
3223
  rightButton,
3079
3224
  screen,
3225
+ anchors,
3226
+ scrimStages,
3080
3227
  onChange,
3081
3228
  ariaLabel = "Bottom sheet",
3082
3229
  ariaLabelledBy,
@@ -3097,10 +3244,7 @@ var BottomSheet = forwardRef(
3097
3244
  setAllowed,
3098
3245
  getEngine
3099
3246
  } = useBottomSheet(engineOpts);
3100
- const isVerticalAxis = (engineOpts.mode ?? "bottom") === "bottom" || (engineOpts.mode ?? "bottom") === "top";
3101
3247
  const ariaModal = engineOpts.focusTrap === true;
3102
- const allowedIds = engineOpts.allowed ?? engineOpts.snapPoints.map((p) => p.id);
3103
- const activeIdx = allowedIds.indexOf(state.activeId);
3104
3248
  const lastAnnouncedRef = useRef("");
3105
3249
  const [announce, setAnnounce] = useState("");
3106
3250
  useEffect(() => {
@@ -3116,6 +3260,15 @@ var BottomSheet = forwardRef(
3116
3260
  open,
3117
3261
  close,
3118
3262
  setAllowed,
3263
+ setSnapPoints: (points, allowed) => getEngine()?.setSnapPoints(points, allowed),
3264
+ setScrim: (opts) => getEngine()?.setScrim(opts),
3265
+ setScrimOverlay: (opts) => getEngine()?.setScrimOverlay(opts) ?? (() => {
3266
+ }),
3267
+ addAnchor: (opts) => getEngine()?.addAnchor(opts) ?? (() => {
3268
+ }),
3269
+ setScrimStages: (opts) => getEngine()?.setScrimStages(opts) ?? (() => {
3270
+ }),
3271
+ getEngine,
3119
3272
  get state() {
3120
3273
  return getEngine()?.state ?? state;
3121
3274
  }
@@ -3125,6 +3278,61 @@ var BottomSheet = forwardRef(
3125
3278
  useEffect(() => {
3126
3279
  onChange?.(state);
3127
3280
  }, [state, onChange]);
3281
+ const [anchorHosts, setAnchorHosts] = useState([]);
3282
+ const anchorsKey = anchors ? JSON.stringify(
3283
+ anchors.map((a) => [
3284
+ a.position,
3285
+ a.inset,
3286
+ Array.isArray(a.showOn) ? a.showOn : a.showOn ? "fn" : null,
3287
+ a.fadeRange,
3288
+ a.interactive,
3289
+ typeof a.animation === "string" ? a.animation : a.animation ? "custom" : null
3290
+ ])
3291
+ ) : "";
3292
+ useEffect(() => {
3293
+ const engine = getEngine();
3294
+ if (!engine || !anchors || anchors.length === 0) return;
3295
+ const hosts = [];
3296
+ const detachers = anchors.map((a) => {
3297
+ const host = document.createElement("div");
3298
+ hosts.push(host);
3299
+ const { node: _node, ...rest } = a;
3300
+ return engine.addAnchor({ ...rest, element: host });
3301
+ });
3302
+ setAnchorHosts(hosts);
3303
+ return () => {
3304
+ detachers.forEach((d) => d());
3305
+ setAnchorHosts([]);
3306
+ };
3307
+ }, [getEngine, anchorsKey]);
3308
+ const [stageHosts, setStageHosts] = useState([]);
3309
+ const stagesKey = scrimStages ? JSON.stringify(
3310
+ scrimStages.stages.map((s) => [
3311
+ s.for,
3312
+ s.forRange,
3313
+ s.position,
3314
+ s.inset,
3315
+ s.interactive,
3316
+ typeof s.animation === "string" ? s.animation : s.animation ? "custom" : null
3317
+ ])
3318
+ ) : "";
3319
+ useEffect(() => {
3320
+ const engine = getEngine();
3321
+ if (!engine || !scrimStages || scrimStages.stages.length === 0) return;
3322
+ const hosts = [];
3323
+ const defs = scrimStages.stages.map((s) => {
3324
+ const host = document.createElement("div");
3325
+ hosts.push(host);
3326
+ const { node: _node, ...rest } = s;
3327
+ return { ...rest, element: host };
3328
+ });
3329
+ const detach = engine.setScrimStages({ ...scrimStages, stages: defs });
3330
+ setStageHosts(hosts);
3331
+ return () => {
3332
+ detach();
3333
+ setStageHosts([]);
3334
+ };
3335
+ }, [getEngine, stagesKey]);
3128
3336
  if (noSSR && !mounted) return null;
3129
3337
  return /* @__PURE__ */ jsxs("div", { className: "bs-root", children: [
3130
3338
  backdrop && /* @__PURE__ */ jsx(
@@ -3135,7 +3343,7 @@ var BottomSheet = forwardRef(
3135
3343
  onClick: closeOnBackdrop ? () => void close() : void 0
3136
3344
  }
3137
3345
  ),
3138
- screen && /* @__PURE__ */ jsx(
3346
+ /* @__PURE__ */ jsx(
3139
3347
  "div",
3140
3348
  {
3141
3349
  ref: screenRef,
@@ -3165,11 +3373,6 @@ var BottomSheet = forwardRef(
3165
3373
  className: "bs-handle",
3166
3374
  role: "slider",
3167
3375
  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
3376
  "aria-label": "Resize sheet",
3174
3377
  children: typeof header === "function" ? header(state) : header
3175
3378
  }
@@ -3188,6 +3391,12 @@ var BottomSheet = forwardRef(
3188
3391
  /* @__PURE__ */ jsx("span", { className: "bs-sr-only", role: "status", "aria-live": "polite", children: announce })
3189
3392
  ]
3190
3393
  }
3394
+ ),
3395
+ anchorHosts.map(
3396
+ (host, i) => anchors?.[i] ? createPortal(anchors[i].node, host) : null
3397
+ ),
3398
+ stageHosts.map(
3399
+ (host, i) => scrimStages?.stages[i] ? createPortal(scrimStages.stages[i].node, host) : null
3191
3400
  )
3192
3401
  ] });
3193
3402
  }
@@ -3258,8 +3467,6 @@ var OverlayEngine = class {
3258
3467
  __publicField(this, "exitEasing");
3259
3468
  __publicField(this, "backdropOpacity");
3260
3469
  __publicField(this, "backdropFilter");
3261
- // Definite-assignment: written via assignSwipeFromConfig in the
3262
- // constructor — TS can't follow the helper, mark the assertion explicit.
3263
3470
  __publicField(this, "swipeToCloseEnabled");
3264
3471
  __publicField(this, "swipeThreshold");
3265
3472
  __publicField(this, "swipeVelocityThreshold");
@@ -3274,18 +3481,16 @@ var OverlayEngine = class {
3274
3481
  __publicField(this, "closeOnOutsidePointerEnabled");
3275
3482
  __publicField(this, "detachOutsidePointer", null);
3276
3483
  __publicField(this, "detachSwipe", null);
3484
+ __publicField(this, "respectReducedMotionEnabled");
3485
+ __publicField(this, "isTop", true);
3486
+ __publicField(this, "backPushed", false);
3487
+ __publicField(this, "movedToTarget", false);
3277
3488
  __publicField(this, "onOpenCb");
3278
3489
  __publicField(this, "onCloseCb");
3279
3490
  __publicField(this, "isOpen_", false);
3280
3491
  __publicField(this, "isAnimating_", false);
3281
3492
  __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
3493
  __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
3494
  __publicField(this, "cycleNonce", 0);
3290
3495
  __publicField(this, "bus", createEventBus());
3291
3496
  __publicField(this, "detachEscape", null);
@@ -3316,6 +3521,7 @@ var OverlayEngine = class {
3316
3521
  this.mountTarget = opts.mountTo;
3317
3522
  this.peekRaw = opts.peek;
3318
3523
  this.closeOnOutsidePointerEnabled = opts.closeOnOutsidePointer ?? false;
3524
+ this.respectReducedMotionEnabled = opts.respectReducedMotion ?? true;
3319
3525
  this.onOpenCb = opts.onOpen;
3320
3526
  this.onCloseCb = opts.onClose;
3321
3527
  if (this.peekRaw !== void 0 && this.edge !== "bottom") {
@@ -3338,9 +3544,6 @@ var OverlayEngine = class {
3338
3544
  closeOnEscape: this.closeOnEscape,
3339
3545
  lockBodyScroll: this.bodyScrollLockEnabled,
3340
3546
  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
3547
  shouldApplyInertSiblings: () => {
3345
3548
  const bodyChildren = Array.from(document.body.children);
3346
3549
  return bodyChildren.some(
@@ -3366,15 +3569,17 @@ var OverlayEngine = class {
3366
3569
  this.emit("before-open", void 0);
3367
3570
  this.isOpen_ = true;
3368
3571
  this.isAnimating_ = true;
3572
+ sheetStack.promote(this.id);
3369
3573
  this.element.removeAttribute("hidden");
3370
3574
  this.element.setAttribute("data-state", "entering");
3371
3575
  if (this.focusTrapEnabled) {
3372
3576
  this.element.setAttribute("aria-modal", "true");
3373
3577
  }
3374
- const transitionStr = `transform ${this.duration}ms ${this.enterEasing}, opacity ${this.duration}ms ease-out`;
3578
+ const dur = this.effectiveDuration();
3579
+ const transitionStr = `transform ${dur}ms ${this.enterEasing}, opacity ${dur}ms ease-out`;
3375
3580
  this.element.style.transition = transitionStr;
3376
3581
  if (this.backdrop) {
3377
- this.backdrop.style.transition = `opacity ${this.duration}ms ease-out`;
3582
+ this.backdrop.style.transition = `opacity ${dur}ms ease-out`;
3378
3583
  }
3379
3584
  const cycle = ++this.cycleNonce;
3380
3585
  return new Promise((resolve) => {
@@ -3386,7 +3591,7 @@ var OverlayEngine = class {
3386
3591
  this.element.style.opacity = "1";
3387
3592
  if (this.backdrop) {
3388
3593
  this.backdrop.style.opacity = String(this.backdropOpacity);
3389
- this.backdrop.style.pointerEvents = "auto";
3594
+ this.backdrop.style.pointerEvents = this.backdropOpacity > 0 ? "auto" : "none";
3390
3595
  if (this.backdropFilter) {
3391
3596
  this.backdrop.style.backdropFilter = this.backdropFilter;
3392
3597
  this.backdrop.style.webkitBackdropFilter = this.backdropFilter;
@@ -3422,7 +3627,10 @@ var OverlayEngine = class {
3422
3627
  });
3423
3628
  return resolve();
3424
3629
  }
3425
- this.awaitTransition(cycle).then(() => {
3630
+ this.awaitTransition(
3631
+ cycle,
3632
+ this.enterAnimation === "fade" ? "opacity" : "transform"
3633
+ ).then(() => {
3426
3634
  if (this.destroyed || cycle !== this.cycleNonce) return resolve();
3427
3635
  this.isAnimating_ = false;
3428
3636
  this.element.setAttribute("data-state", "open");
@@ -3439,11 +3647,14 @@ var OverlayEngine = class {
3439
3647
  this.emit("before-close", void 0);
3440
3648
  this.isOpen_ = false;
3441
3649
  this.isAnimating_ = true;
3650
+ sheetStack.update();
3651
+ this.popBackMarker(reason);
3442
3652
  this.element.setAttribute("data-state", "exiting");
3443
- const transitionStr = `transform ${this.duration}ms ${this.exitEasing}, opacity ${this.duration}ms ease-in`;
3653
+ const dur = this.effectiveDuration();
3654
+ const transitionStr = `transform ${dur}ms ${this.exitEasing}, opacity ${dur}ms ease-in`;
3444
3655
  this.element.style.transition = transitionStr;
3445
3656
  if (this.backdrop) {
3446
- this.backdrop.style.transition = `opacity ${this.duration}ms ease-in`;
3657
+ this.backdrop.style.transition = `opacity ${dur}ms ease-in`;
3447
3658
  }
3448
3659
  const cycle = ++this.cycleNonce;
3449
3660
  return new Promise((resolve) => {
@@ -3454,7 +3665,7 @@ var OverlayEngine = class {
3454
3665
  this.exitAnimation,
3455
3666
  this.peekPx
3456
3667
  );
3457
- this.element.style.opacity = "0";
3668
+ this.element.style.opacity = this.peekPx !== void 0 ? "1" : "0";
3458
3669
  if (this.backdrop) {
3459
3670
  this.backdrop.style.opacity = "0";
3460
3671
  this.backdrop.style.pointerEvents = "none";
@@ -3464,7 +3675,10 @@ var OverlayEngine = class {
3464
3675
  }
3465
3676
  }
3466
3677
  this.releaseInteractiveListeners();
3467
- this.awaitTransition(cycle).then(() => {
3678
+ this.awaitTransition(
3679
+ cycle,
3680
+ this.exitAnimation === "fade" ? "opacity" : "transform"
3681
+ ).then(() => {
3468
3682
  if (this.destroyed || cycle !== this.cycleNonce) return resolve();
3469
3683
  this.isAnimating_ = false;
3470
3684
  if (this.peekPx === void 0) {
@@ -3492,14 +3706,12 @@ var OverlayEngine = class {
3492
3706
  this.releaseInteractiveListeners();
3493
3707
  this.releaseStackEntry?.();
3494
3708
  this.releaseStackEntry = null;
3709
+ const hadOriginalParent = this.originalParent !== null;
3495
3710
  this.restoreMount();
3711
+ if (this.movedToTarget && !hadOriginalParent && this.element.parentNode) {
3712
+ this.element.parentNode.removeChild(this.element);
3713
+ }
3496
3714
  }
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
3715
  setBackdropOpacity(opacity) {
3504
3716
  if (this.destroyed) return;
3505
3717
  this.backdropOpacity = opacity;
@@ -3507,11 +3719,6 @@ var OverlayEngine = class {
3507
3719
  this.backdrop.style.opacity = String(opacity);
3508
3720
  }
3509
3721
  }
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
3722
  setBackdropFilter(filter) {
3516
3723
  if (this.destroyed) return;
3517
3724
  this.backdropFilter = filter;
@@ -3525,12 +3732,6 @@ var OverlayEngine = class {
3525
3732
  style.webkitBackdropFilter = "";
3526
3733
  }
3527
3734
  }
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
3735
  setSwipeToClose(config) {
3535
3736
  if (this.destroyed) return;
3536
3737
  const wasEnabled = this.swipeToCloseEnabled;
@@ -3543,7 +3744,6 @@ var OverlayEngine = class {
3543
3744
  this.detachSwipe = null;
3544
3745
  }
3545
3746
  }
3546
- /** Animation setters only stage the field — applied on next open/close. */
3547
3747
  setEnterAnimation(animation) {
3548
3748
  if (this.destroyed) return;
3549
3749
  this.enterAnimation = animation;
@@ -3552,23 +3752,15 @@ var OverlayEngine = class {
3552
3752
  if (this.destroyed) return;
3553
3753
  this.exitAnimation = animation;
3554
3754
  }
3555
- /** Updates the field used at next close() — no immediate side effect. */
3556
3755
  setReturnFocus(target) {
3557
3756
  if (this.destroyed) return;
3558
3757
  this.lifecycle.setReturnFocus(target);
3559
3758
  }
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
3759
  setOverlay(opts) {
3566
3760
  if (this.destroyed) return;
3567
3761
  const preset = opts.preset ? OVERLAY_PRESETS[opts.preset] : void 0;
3568
3762
  const merged = preset ? {
3569
3763
  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
3764
  backdropFilter: preset.backdropFilter === void 0 ? null : preset.backdropFilter,
3573
3765
  swipeToClose: preset.swipeToClose,
3574
3766
  enterAnimation: preset.enterAnimation,
@@ -3619,6 +3811,36 @@ var OverlayEngine = class {
3619
3811
  }
3620
3812
  }
3621
3813
  }
3814
+ if (opts.duration !== void 0) this.duration = opts.duration;
3815
+ if (opts.enterEasing !== void 0) this.enterEasing = opts.enterEasing;
3816
+ if (opts.exitEasing !== void 0) this.exitEasing = opts.exitEasing;
3817
+ if (opts.closeOnEscape !== void 0) {
3818
+ this.closeOnEscape = opts.closeOnEscape;
3819
+ if (!opts.closeOnEscape) {
3820
+ this.detachEscape?.();
3821
+ this.detachEscape = null;
3822
+ } else if (this.isOpen_) {
3823
+ this.installInteractiveListeners();
3824
+ }
3825
+ }
3826
+ if (opts.closeOnBackdrop !== void 0) {
3827
+ this.closeOnBackdropEnabled = opts.closeOnBackdrop;
3828
+ if (!opts.closeOnBackdrop) {
3829
+ this.detachBackdrop?.();
3830
+ this.detachBackdrop = null;
3831
+ } else if (this.isOpen_) {
3832
+ this.installInteractiveListeners();
3833
+ }
3834
+ }
3835
+ if (opts.closeOnOutsidePointer !== void 0) {
3836
+ this.closeOnOutsidePointerEnabled = opts.closeOnOutsidePointer;
3837
+ if (!opts.closeOnOutsidePointer) {
3838
+ this.detachOutsidePointer?.();
3839
+ this.detachOutsidePointer = null;
3840
+ } else if (this.isOpen_) {
3841
+ this.installInteractiveListeners();
3842
+ }
3843
+ }
3622
3844
  if (opts.children !== void 0) {
3623
3845
  if (opts.children === null) {
3624
3846
  this.clearOverlayChildren();
@@ -3627,30 +3849,17 @@ var OverlayEngine = class {
3627
3849
  }
3628
3850
  }
3629
3851
  }
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
3852
  setOverlayChildren(children) {
3637
3853
  if (this.destroyed) return;
3638
3854
  const node = typeof children === "function" ? children() : children;
3639
3855
  this.element.replaceChildren(node);
3640
3856
  this.hasInjectedChildren = true;
3641
3857
  }
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
3858
  clearOverlayChildren() {
3648
3859
  if (this.destroyed || !this.hasInjectedChildren) return;
3649
3860
  this.element.replaceChildren();
3650
3861
  this.hasInjectedChildren = false;
3651
3862
  }
3652
- // Shared swipe-config normalizer — used by constructor, setSwipeToClose,
3653
- // and setOverlay so the union/object shape is decoded in exactly one place.
3654
3863
  assignSwipeFromConfig(swipe) {
3655
3864
  if (typeof swipe === "object" && swipe !== null) {
3656
3865
  this.swipeToCloseEnabled = swipe.enabled ?? true;
@@ -3665,6 +3874,22 @@ var OverlayEngine = class {
3665
3874
  emit(event, payload) {
3666
3875
  this.bus.emit(event, payload);
3667
3876
  }
3877
+ effectiveDuration() {
3878
+ return this.respectReducedMotionEnabled && prefersReducedMotion() ? 0 : this.duration;
3879
+ }
3880
+ popBackMarker(reason) {
3881
+ if (!this.backPushed) return;
3882
+ this.backPushed = false;
3883
+ if (reason === "back") return;
3884
+ const state = history.state;
3885
+ if (state && state.__bsOverlay === this.id) {
3886
+ try {
3887
+ history.back();
3888
+ } catch {
3889
+ return;
3890
+ }
3891
+ }
3892
+ }
3668
3893
  resolvePeekPx(raw) {
3669
3894
  if (typeof raw === "number") return Math.max(0, raw);
3670
3895
  try {
@@ -3686,6 +3911,7 @@ var OverlayEngine = class {
3686
3911
  this.originalParent = this.element.parentNode;
3687
3912
  this.originalNextSibling = this.element.nextSibling;
3688
3913
  target.appendChild(this.element);
3914
+ this.movedToTarget = true;
3689
3915
  this.mounted = true;
3690
3916
  }
3691
3917
  restoreMount() {
@@ -3719,7 +3945,7 @@ var OverlayEngine = class {
3719
3945
  this.backdrop.style.willChange = "opacity";
3720
3946
  }
3721
3947
  }
3722
- awaitTransition(cycle) {
3948
+ awaitTransition(cycle, property = "transform") {
3723
3949
  return new Promise((resolve) => {
3724
3950
  let done = false;
3725
3951
  const finish = () => {
@@ -3734,10 +3960,10 @@ var OverlayEngine = class {
3734
3960
  finish();
3735
3961
  return;
3736
3962
  }
3737
- if (e.propertyName === "transform") finish();
3963
+ if (e.target === this.element && e.propertyName === property) finish();
3738
3964
  };
3739
3965
  this.element.addEventListener("transitionend", onEnd);
3740
- const timer = window.setTimeout(finish, this.duration + 50);
3966
+ const timer = window.setTimeout(finish, this.effectiveDuration() + 50);
3741
3967
  });
3742
3968
  }
3743
3969
  installInteractiveListeners() {
@@ -3747,7 +3973,10 @@ var OverlayEngine = class {
3747
3973
  }
3748
3974
  if (this.closeOnEscape && !this.detachEscape) {
3749
3975
  const handler = (e) => {
3750
- if (e.key === "Escape" && this.isOpen_) void this.close("escape");
3976
+ if (e.defaultPrevented) return;
3977
+ if (e.key === "Escape" && this.isOpen_ && this.isTop) {
3978
+ void this.close("escape");
3979
+ }
3751
3980
  };
3752
3981
  document.addEventListener("keydown", handler);
3753
3982
  this.detachEscape = () => document.removeEventListener("keydown", handler);
@@ -3760,10 +3989,14 @@ var OverlayEngine = class {
3760
3989
  if (this.closeOnBackEnabled && typeof window !== "undefined" && !this.detachHardwareBack) {
3761
3990
  try {
3762
3991
  history.pushState({ __bsOverlay: this.id }, "");
3992
+ this.backPushed = true;
3763
3993
  } catch {
3764
3994
  }
3765
3995
  const onPop = () => {
3766
- if (this.isOpen_) void this.close("back");
3996
+ if (this.isOpen_ && this.isTop) {
3997
+ this.backPushed = false;
3998
+ void this.close("back");
3999
+ }
3767
4000
  };
3768
4001
  window.addEventListener("popstate", onPop);
3769
4002
  this.detachHardwareBack = () => window.removeEventListener("popstate", onPop);
@@ -3794,55 +4027,57 @@ var OverlayEngine = class {
3794
4027
  this.detachOutsidePointer = null;
3795
4028
  this.lifecycle.release();
3796
4029
  }
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
4030
  installSwipeToClose() {
3805
4031
  const el = this.element;
3806
4032
  const edge = this.edge;
3807
4033
  const isVertical = edge === "bottom" || edge === "top";
3808
4034
  const sign = edge === "bottom" || edge === "right" ? 1 : -1;
3809
- const threshold = this.swipeThreshold;
3810
- const velocityThreshold = this.swipeVelocityThreshold;
4035
+ const SLOP = 8;
3811
4036
  let startX = 0;
3812
4037
  let startY = 0;
3813
4038
  let startT = 0;
4039
+ let tracking = false;
3814
4040
  let dragging = false;
3815
4041
  let pointerId = null;
4042
+ const previousTouchAction = el.style.touchAction;
4043
+ el.style.touchAction = isVertical ? "pan-x" : "pan-y";
3816
4044
  const onDown = (e) => {
3817
4045
  if (e.button !== void 0 && e.button !== 0) return;
3818
4046
  pointerId = e.pointerId;
3819
4047
  startX = e.clientX;
3820
4048
  startY = e.clientY;
3821
4049
  startT = performance.now();
3822
- dragging = true;
3823
- el.style.transition = "none";
3824
- try {
3825
- el.setPointerCapture(e.pointerId);
3826
- } catch {
3827
- }
4050
+ tracking = true;
4051
+ dragging = false;
3828
4052
  };
3829
4053
  const onMove = (e) => {
3830
- if (!dragging || e.pointerId !== pointerId) return;
4054
+ if (!tracking || e.pointerId !== pointerId) return;
3831
4055
  const delta = isVertical ? e.clientY - startY : e.clientX - startX;
4056
+ if (!dragging) {
4057
+ if (Math.abs(delta) < SLOP) return;
4058
+ dragging = true;
4059
+ el.style.transition = "none";
4060
+ try {
4061
+ el.setPointerCapture(e.pointerId);
4062
+ } catch {
4063
+ }
4064
+ }
3832
4065
  const offset = Math.max(0, sign * delta);
3833
4066
  el.style.transform = isVertical ? `translate3d(0, ${sign * offset}px, 0)` : `translate3d(${sign * offset}px, 0, 0)`;
3834
4067
  };
3835
4068
  const finish = (e) => {
3836
- if (!dragging || e.pointerId !== pointerId) return;
3837
- dragging = false;
4069
+ if (!tracking || e.pointerId !== pointerId) return;
4070
+ tracking = false;
3838
4071
  pointerId = null;
4072
+ if (!dragging) return;
4073
+ dragging = false;
3839
4074
  const dt = performance.now() - startT || 1;
3840
4075
  const delta = isVertical ? e.clientY - startY : e.clientX - startX;
3841
4076
  const offset = Math.max(0, sign * delta);
3842
4077
  const velocity = sign * delta / dt;
3843
4078
  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}`;
4079
+ const past = offset > size * this.swipeThreshold || velocity > this.swipeVelocityThreshold;
4080
+ el.style.transition = `transform ${this.effectiveDuration()}ms ${this.exitEasing}`;
3846
4081
  if (past) {
3847
4082
  void this.close("swipe");
3848
4083
  } else {
@@ -3854,6 +4089,7 @@ var OverlayEngine = class {
3854
4089
  el.addEventListener("pointerup", finish);
3855
4090
  el.addEventListener("pointercancel", finish);
3856
4091
  return () => {
4092
+ el.style.touchAction = previousTouchAction;
3857
4093
  el.removeEventListener("pointerdown", onDown);
3858
4094
  el.removeEventListener("pointermove", onMove);
3859
4095
  el.removeEventListener("pointerup", finish);
@@ -3868,10 +4104,12 @@ var OverlayEngine = class {
3868
4104
  if (this.backdrop) this.backdrop.style.zIndex = String(z - 1);
3869
4105
  },
3870
4106
  setIsTop: (isTop) => {
4107
+ this.isTop = isTop;
3871
4108
  if (this.backdrop) {
3872
4109
  this.backdrop.style.display = isTop ? "" : "none";
3873
4110
  }
3874
- }
4111
+ },
4112
+ isOpen: () => this.isOpen_
3875
4113
  });
3876
4114
  }
3877
4115
  };
@@ -3957,7 +4195,7 @@ var Overlay = forwardRef(function Overlay2(props, ref) {
3957
4195
  if (openProp === void 0) return;
3958
4196
  if (openProp && !state.isOpen) void open();
3959
4197
  else if (!openProp && state.isOpen) void close();
3960
- }, [openProp]);
4198
+ }, [openProp, state.isOpen]);
3961
4199
  useEffect(() => {
3962
4200
  onChange?.(state);
3963
4201
  }, [state, onChange]);
@@ -4089,46 +4327,34 @@ var BottomSheetFromConfig = forwardRef(function BottomSheetFromConfig2(props, re
4089
4327
  () => sheetRef.current,
4090
4328
  []
4091
4329
  );
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") {
4330
+ const invoke = (key, arg) => {
4331
+ const evName = eventsRef.current[key];
4332
+ if (!evName) return;
4333
+ const fn = handlersRef.current[evName];
4334
+ if (typeof fn === "function") {
4335
+ if (arg === void 0) {
4124
4336
  fn();
4125
4337
  } else {
4126
- console.warn(
4127
- `[BottomSheetFromConfig] eventHandlers["${evs.onClose}"] is not a function; onClose is a no-op.`
4128
- );
4338
+ fn(arg);
4129
4339
  }
4340
+ } else {
4341
+ console.warn(
4342
+ `[BottomSheetFromConfig] eventHandlers["${evName}"] is not a function; ${key} is a no-op.`
4343
+ );
4130
4344
  }
4131
4345
  };
4346
+ const invokeRef = useRef(invoke);
4347
+ invokeRef.current = invoke;
4348
+ useEffect(() => {
4349
+ const engine = sheetRef.current?.getEngine();
4350
+ if (!engine) return;
4351
+ const offs = [
4352
+ engine.on("snap", (p) => invokeRef.current("onSnap", p.id)),
4353
+ engine.on("open", (p) => invokeRef.current("onOpen", p.id)),
4354
+ engine.on("close", () => invokeRef.current("onClose"))
4355
+ ];
4356
+ return () => offs.forEach((off) => off());
4357
+ }, []);
4132
4358
  return /* @__PURE__ */ jsx(
4133
4359
  BottomSheet,
4134
4360
  {
@@ -4144,7 +4370,6 @@ var BottomSheetFromConfig = forwardRef(function BottomSheetFromConfig2(props, re
4144
4370
  closeOnBackdrop: derived.behavior.closeOnBackdrop,
4145
4371
  lockBodyScroll: derived.behavior.lockBodyScroll,
4146
4372
  rubberBand: derived.behavior.rubberBand,
4147
- onChange: handleChange,
4148
4373
  header: slotContent?.header,
4149
4374
  children: slotContent?.body
4150
4375
  }