@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.cjs CHANGED
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  var react = require('react');
4
+ var reactDom = require('react-dom');
4
5
  var jsxRuntime = require('react/jsx-runtime');
5
6
 
6
7
  var __defProp = Object.defineProperty;
@@ -25,6 +26,18 @@ var SheetStack = class {
25
26
  this.entries.splice(idx, 1);
26
27
  this.recompute();
27
28
  }
29
+ promote(id) {
30
+ const idx = this.entries.findIndex((e) => e.id === id);
31
+ if (idx === -1) return;
32
+ if (idx !== this.entries.length - 1) {
33
+ const [entry] = this.entries.splice(idx, 1);
34
+ this.entries.push(entry);
35
+ }
36
+ this.recompute();
37
+ }
38
+ update() {
39
+ this.recompute();
40
+ }
28
41
  size() {
29
42
  return this.entries.length;
30
43
  }
@@ -32,9 +45,24 @@ var SheetStack = class {
32
45
  this.entries = [];
33
46
  }
34
47
  recompute() {
48
+ let top = null;
49
+ const open = [];
50
+ for (const entry of this.entries) {
51
+ if (!entry.isOpen || entry.isOpen()) {
52
+ top = entry;
53
+ open.push(entry);
54
+ }
55
+ }
56
+ if (!top && this.entries.length > 0) {
57
+ top = this.entries[this.entries.length - 1];
58
+ }
35
59
  this.entries.forEach((entry, i) => {
36
60
  entry.setZIndex(BASE_Z + i * STEP);
37
- entry.setIsTop(i === this.entries.length - 1);
61
+ entry.setIsTop(entry === top);
62
+ if (entry.setDepth) {
63
+ const openIdx = open.indexOf(entry);
64
+ entry.setDepth(openIdx === -1 ? 0 : open.length - 1 - openIdx);
65
+ }
38
66
  });
39
67
  }
40
68
  };
@@ -239,11 +267,6 @@ var SnapResolver = class {
239
267
  this.allowedIds = allowed ?? raw.map((p) => p.id);
240
268
  this.recompute();
241
269
  }
242
- /**
243
- * Re-resolve raw → resolved against the current viewport / fit-measure,
244
- * recompute maxAxisSize, invalidate range cache, notify engine. Called on
245
- * resize, orientationchange, visualViewport changes, and snap-list swaps.
246
- */
247
270
  recompute() {
248
271
  this.resolved = resolveSnapList(this.raw, this.mode, this.measureFit);
249
272
  this.maxAxisSize = this.resolved.reduce(
@@ -253,15 +276,10 @@ var SnapResolver = class {
253
276
  this.rangeCache = null;
254
277
  this.onMaxAxisSizeChange?.(this.maxAxisSize);
255
278
  }
256
- /** Replace the raw snap list and recompute. Engine calls on `setSnapPoints`. */
257
279
  setRaw(raw) {
258
280
  this.raw = raw;
259
281
  this.recompute();
260
282
  }
261
- /**
262
- * Replace the allow-list. Range cache depends on `(resolvedSnaps ×
263
- * allowedIds)` so flipping ids alone invalidates without a full recompute.
264
- */
265
283
  setAllowedIds(ids) {
266
284
  this.allowedIds = ids;
267
285
  this.rangeCache = null;
@@ -269,11 +287,6 @@ var SnapResolver = class {
269
287
  findById(id) {
270
288
  return findById(id, this.resolved);
271
289
  }
272
- /**
273
- * Pure drag-settle target resolution. Engine passes the gesture-specific
274
- * fields (delta, velocity, pointerKind, flickVelocity, dragThreshold) plus
275
- * `size`/`activeId` since those are still engine-owned.
276
- */
277
290
  findDragSettleTarget(args) {
278
291
  return findDragSettleTarget({
279
292
  ...args,
@@ -282,18 +295,9 @@ var SnapResolver = class {
282
295
  maxAxisSize: this.maxAxisSize
283
296
  });
284
297
  }
285
- /**
286
- * Memoised allowed-range query. Twice-per-drag-frame (gesture onMove +
287
- * applySize→computeProgress); cache collapses 2× iteration of resolvedSnaps
288
- * to one recompute per geometry/allow-list change.
289
- */
290
298
  getAllowedRange() {
291
299
  return this.rangeCache ?? (this.rangeCache = allowedRange(this.resolved, this.allowedIds));
292
300
  }
293
- /**
294
- * Read-only view of resolved snaps. Returned as `readonly` to discourage
295
- * accidental mutation by feature install sites that close over this list.
296
- */
297
301
  getResolvedSnaps() {
298
302
  return this.resolved;
299
303
  }
@@ -303,18 +307,6 @@ var SnapResolver = class {
303
307
  getMaxAxisSize() {
304
308
  return this.maxAxisSize;
305
309
  }
306
- /**
307
- * External clamp override. The resize-observer feature compares
308
- * `getMaxAxisSize()` against the live viewport and shrinks if the largest
309
- * resolved snap exceeds it (mobile address-bar collapse / soft-keyboard
310
- * window resize). The next `recompute()` overwrites this clamp from the
311
- * resolved-snap reduction.
312
- *
313
- * Does NOT invalidate the range cache — viewport clamping doesn't change
314
- * `(resolvedSnaps × allowedIds) → range`. DOES fire `onMaxAxisSizeChange`
315
- * so the resolver owns the layout-axis style write end-to-end (symmetric
316
- * with `recompute()` — feature call sites no longer duplicate the write).
317
- */
318
310
  setMaxAxisSize(n) {
319
311
  this.maxAxisSize = n;
320
312
  this.onMaxAxisSizeChange?.(n);
@@ -361,14 +353,17 @@ function installAutoCollapse(deps) {
361
353
  const fire = () => {
362
354
  if (deps.isDestroyed()) return;
363
355
  if (deps.isDragging()) return;
364
- const allowed = deps.getAllowedIds();
365
- const target = allowed.find((id) => {
356
+ const active = deps.resolveSnap(deps.getActiveId());
357
+ if (!active || active.size === 0) return;
358
+ let target = null;
359
+ for (const id of deps.getAllowedIds()) {
366
360
  const snap = deps.resolveSnap(id);
367
- return snap !== null && snap.size > 0;
368
- });
361
+ if (!snap || snap.size <= 0) continue;
362
+ if (!target || snap.size < target.size) target = snap;
363
+ }
369
364
  if (!target) return;
370
- if (target === deps.getActiveId()) return;
371
- deps.snapTo(target);
365
+ if (target.id === active.id || target.size >= active.size) return;
366
+ deps.snapTo(target.id);
372
367
  };
373
368
  const reset = () => {
374
369
  clearTimer();
@@ -379,6 +374,7 @@ function installAutoCollapse(deps) {
379
374
  }, ms);
380
375
  };
381
376
  const unsubscribe = deps.on("snap", () => reset());
377
+ if ((deps.resolveSnap(deps.getActiveId())?.size ?? 0) > 0) reset();
382
378
  return () => {
383
379
  clearTimer();
384
380
  unsubscribe();
@@ -391,6 +387,7 @@ function notifyLinkedSheets(sheets, self) {
391
387
  for (const linked of sheets) {
392
388
  if (linked === self) continue;
393
389
  try {
390
+ if (linked.state.size === 0) continue;
394
391
  const ids = linked.getAllowedIds();
395
392
  const target = ids.find((id) => id !== "closed");
396
393
  if (target && target !== linked.state.activeId) {
@@ -437,7 +434,7 @@ function installRoute(deps) {
437
434
  const onPop = (event) => {
438
435
  if (deps.isDestroyed()) return;
439
436
  const state = event.state;
440
- if (state && state.__bsRouted === target && deps.getSize() > 0) {
437
+ if ((!state || state.__bsRouted !== target) && deps.getSize() > 0) {
441
438
  routePushed = false;
442
439
  void deps.close();
443
440
  }
@@ -591,13 +588,17 @@ function installResizeObserver(deps) {
591
588
  if (viewportSize2 > 0 && viewportSize2 < max) {
592
589
  deps.setMaxAxisSize(viewportSize2);
593
590
  }
591
+ const wasAnimating = deps.isAnimating?.() ?? false;
594
592
  deps.cancelInFlight();
595
593
  deps.newCycle();
596
594
  const current = deps.resolveActiveSnap();
597
595
  const newMax = deps.getMaxAxisSize();
598
596
  const targetSize = current ? Math.min(current.size, newMax) : Math.min(deps.getSize(), newMax);
599
597
  deps.setSize(targetSize);
600
- if (current && !deps.isDragging()) deps.applySize(targetSize);
598
+ if (current && !deps.isDragging()) {
599
+ deps.applySize(targetSize);
600
+ if (wasAnimating) deps.resyncAfterCancel?.();
601
+ }
601
602
  };
602
603
  let resizeObserver = null;
603
604
  if (typeof ResizeObserver !== "undefined") {
@@ -689,73 +690,130 @@ var WriteSentinel = class {
689
690
  constructor() {
690
691
  __publicField(this, "last", -1);
691
692
  }
692
- /**
693
- * Returns `true` (and updates the cached value) if `next` differs from the
694
- * cached `last` by more than `epsilon`. Returns `false` (and leaves the
695
- * cache untouched) when the delta is within epsilon — caller skips the
696
- * CSSOM write.
697
- */
698
693
  shouldWrite(next, epsilon) {
699
694
  if (Math.abs(next - this.last) <= epsilon) return false;
700
695
  this.last = next;
701
696
  return true;
702
697
  }
703
- /**
704
- * Force the next `shouldWrite` to pass. Resets the cache to `-1` so any
705
- * non-negative `next` exceeds the epsilon comparison. Called from setters
706
- * that mutate state under the cache (range/mode/enabled flips that change
707
- * the progress→opacity mapping but don't move `progress` itself).
708
- */
709
698
  invalidate() {
710
699
  this.last = -1;
711
700
  }
712
- /**
713
- * Record `value` as the last-written without performing a write. Used
714
- * when a setter writes a literal value through a different path (e.g.
715
- * `setScrimMode("off")` writes `opacity: "0"` inline) and wants future
716
- * `shouldWrite` calls to short-circuit against that known cached state.
717
- */
718
701
  setLastWritten(value) {
719
702
  this.last = value;
720
703
  }
721
- /** Read-only accessor for diagnostic / dedup-aware peek. */
722
704
  get value() {
723
705
  return this.last;
724
706
  }
725
707
  };
726
708
 
709
+ // src/core/primitives/devWarn.ts
710
+ function devWarn(...args) {
711
+ if (typeof process !== "undefined" && process.env && process.env.NODE_ENV !== "production" && typeof console !== "undefined") {
712
+ console.warn(...args);
713
+ }
714
+ }
715
+
716
+ // src/core/primitives/overlay-position.ts
717
+ function resolveSheetAnchoredStyle(mode, position, inset) {
718
+ const sizeOffset = `calc(var(--bs-size, 0px) + ${inset})`;
719
+ switch (mode) {
720
+ case "bottom":
721
+ if (position === "sheet-top-left") return { bottom: sizeOffset, left: inset };
722
+ if (position === "sheet-top-right") return { bottom: sizeOffset, right: inset };
723
+ return { bottom: sizeOffset, left: "50%", transform: "translateX(-50%)" };
724
+ case "top":
725
+ if (position === "sheet-top-left") return { top: sizeOffset, left: inset };
726
+ if (position === "sheet-top-right") return { top: sizeOffset, right: inset };
727
+ return { top: sizeOffset, left: "50%", transform: "translateX(-50%)" };
728
+ case "left":
729
+ if (position === "sheet-top-left") return { left: sizeOffset, top: inset };
730
+ if (position === "sheet-top-right") return { left: sizeOffset, bottom: inset };
731
+ return { left: sizeOffset, top: "50%", transform: "translateY(-50%)" };
732
+ case "right":
733
+ if (position === "sheet-top-left") return { right: sizeOffset, top: inset };
734
+ if (position === "sheet-top-right") return { right: sizeOffset, bottom: inset };
735
+ return { right: sizeOffset, top: "50%", transform: "translateY(-50%)" };
736
+ }
737
+ }
738
+ function applyOverlayPosition(ws, mode, position, inset) {
739
+ switch (position) {
740
+ case "top-left":
741
+ ws.top = inset;
742
+ ws.left = inset;
743
+ break;
744
+ case "top-center":
745
+ ws.top = inset;
746
+ ws.left = "50%";
747
+ ws.transform = "translateX(-50%)";
748
+ break;
749
+ case "top-right":
750
+ ws.top = inset;
751
+ ws.right = inset;
752
+ break;
753
+ case "center-left":
754
+ ws.top = "50%";
755
+ ws.left = inset;
756
+ ws.transform = "translateY(-50%)";
757
+ break;
758
+ case "center":
759
+ ws.top = "50%";
760
+ ws.left = "50%";
761
+ ws.transform = "translate(-50%, -50%)";
762
+ break;
763
+ case "center-right":
764
+ ws.top = "50%";
765
+ ws.right = inset;
766
+ ws.transform = "translateY(-50%)";
767
+ break;
768
+ case "bottom-left":
769
+ ws.bottom = inset;
770
+ ws.left = inset;
771
+ break;
772
+ case "bottom-center":
773
+ ws.bottom = inset;
774
+ ws.left = "50%";
775
+ ws.transform = "translateX(-50%)";
776
+ break;
777
+ case "bottom-right":
778
+ ws.bottom = inset;
779
+ ws.right = inset;
780
+ break;
781
+ case "sheet-top-left":
782
+ case "sheet-top-center":
783
+ case "sheet-top-right": {
784
+ const style = resolveSheetAnchoredStyle(mode, position, inset);
785
+ if (style.top !== void 0) ws.top = style.top;
786
+ if (style.bottom !== void 0) ws.bottom = style.bottom;
787
+ if (style.left !== void 0) ws.left = style.left;
788
+ if (style.right !== void 0) ws.right = style.right;
789
+ if (style.transform !== void 0) ws.transform = style.transform;
790
+ break;
791
+ }
792
+ }
793
+ }
794
+
727
795
  // src/core/controllers/scrim-controller.ts
796
+ var VALID_CSS_LENGTH = /^-?\d+(?:\.\d+)?(?:px|em|rem|%)$/;
728
797
  var ScrimController = class {
729
798
  constructor(deps, opts) {
730
- // ── DOM refs (mirror engine's; cached for hot path) ─────────────────
731
799
  __publicField(this, "screenComponent");
732
800
  __publicField(this, "backdrop");
733
801
  __publicField(this, "mode");
734
- // ── Engine callbacks ────────────────────────────────────────────────
735
802
  __publicField(this, "isDestroyed");
736
803
  __publicField(this, "isTopSheet");
737
804
  __publicField(this, "getAllowedIds");
738
805
  __publicField(this, "getResolvedSnaps");
739
806
  __publicField(this, "snapToFn");
740
- // ── Mode + state ────────────────────────────────────────────────────
807
+ __publicField(this, "closeFn");
808
+ __publicField(this, "scrimInteractive", false);
741
809
  __publicField(this, "scrimMode");
742
810
  __publicField(this, "backdropRange");
743
811
  __publicField(this, "screenRange");
744
- // setScrimEnabled toggle — gated through `scrimEnabled` rather than nuking
745
- // ranges so re-enable can restore prior values without a remount. Public
746
- // so tests / introspection can read it without an `as any` cast.
747
812
  __publicField(this, "scrimEnabled", true);
748
813
  __publicField(this, "savedScrimRanges", null);
749
- // ── Tap-to-close ────────────────────────────────────────────────────
750
814
  __publicField(this, "scrimTapToCloseEnabled", false);
751
815
  __publicField(this, "detachScrimTap", null);
752
- // ── Scrim overlay slot (positioned floating element) ────────────────
753
816
  __publicField(this, "scrimOverlayTeardown", null);
754
- // ── Hot-path opacity dedup caches (per-frame writes) ────────────────
755
- // Numeric sentinels migrated to WriteSentinel primitive — single
756
- // invalidation surface, shared with the engine's size/progress sentinels.
757
- // The two string-typed sentinels (`Pointer`, `Display`) keep their inline
758
- // pattern: equality on string union, no epsilon, different reset semantics.
759
817
  __publicField(this, "backdropOpacitySentinel", new WriteSentinel());
760
818
  __publicField(this, "screenOpacitySentinel", new WriteSentinel());
761
819
  __publicField(this, "lastBackdropPointer", "");
@@ -768,6 +826,7 @@ var ScrimController = class {
768
826
  this.getAllowedIds = deps.getAllowedIds;
769
827
  this.getResolvedSnaps = deps.getResolvedSnaps;
770
828
  this.snapToFn = deps.snapTo;
829
+ this.closeFn = deps.close;
771
830
  this.scrimMode = opts.scrimMode ?? "full";
772
831
  const preset = opts.scrimPreset ? SCRIM_PRESETS[opts.scrimPreset] : null;
773
832
  this.backdropRange = opts.backdropRange ?? preset?.range ?? [0, 1];
@@ -790,41 +849,15 @@ var ScrimController = class {
790
849
  }
791
850
  }
792
851
  }
793
- /**
794
- * Invalidate the opacity dedup caches. Called from any setter that mutates
795
- * range/mode/enabled — opacity is a pure function of (progress, range,
796
- * enabled), so a config change makes the cached values stale even if
797
- * `progress` itself didn't move. Next `applyOpacity(progress, false)` call
798
- * sees the -1 sentinel and re-runs the math instead of short-circuiting.
799
- *
800
- * Public for the engine: `setSnapPoints` mutates resolved geometry which
801
- * shifts `progress→opacity` mapping under the cache; engine calls this to
802
- * keep the next render frame correct on a static sheet.
803
- * @internal
804
- */
805
852
  invalidateOpacityCache() {
806
853
  this.backdropOpacitySentinel.invalidate();
807
854
  this.screenOpacitySentinel.invalidate();
808
855
  }
809
- /** Engine calls this when its `attach()` runs — installs scrim-tap-click if requested. */
810
856
  attach() {
811
857
  if (this.scrimTapToCloseEnabled) {
812
858
  this.setScrimTapToClose(true);
813
859
  }
814
860
  }
815
- // ── Hot-path: opacity computation called per-frame from engine.applySize ──
816
- /**
817
- * Per-frame opacity writer. Computes backdrop + screen opacity from the
818
- * sheet's progress (0..1 within allowed range), gated by sub-pixel dedup
819
- * caches so steady-state frames don't trigger style recalc.
820
- *
821
- * `progressChanged` is the engine's already-computed dedup gate — when
822
- * `false`, opacity is by construction a pure function of `progress` so
823
- * cached values are still valid; we short-circuit the Math.min/max +
824
- * division work entirely. Setters that mutate ranges/mode/enabled
825
- * invalidate the cache by writing -1 sentinels so the next call re-runs
826
- * the math.
827
- */
828
861
  applyOpacity(progress, progressChanged) {
829
862
  if (!progressChanged && this.backdropOpacitySentinel.value !== -1 && this.screenOpacitySentinel.value !== -1) {
830
863
  return;
@@ -870,80 +903,30 @@ var ScrimController = class {
870
903
  }
871
904
  }
872
905
  }
873
- // ── Public setters (engine forwards these 1:1) ─────────────────────
874
- /**
875
- * Replace the [start, end] progress window in which the backdrop fades 0→1.
876
- * @param range - Tuple of two progress values in 0..1 (start <= end).
877
- * @param applySize - Engine-supplied recompute hook; runs one applyOpacity pass.
878
- * @example
879
- * // Backdrop only starts darkening after the sheet crosses 30% open.
880
- * engine.setBackdropRange([0.3, 1]);
881
- */
882
906
  setBackdropRange(range, applySize) {
883
907
  if (this.isDestroyed()) return;
884
908
  this.backdropRange = range;
885
909
  this.invalidateOpacityCache();
886
910
  applySize();
887
911
  }
888
- /**
889
- * Replace the [start, end] progress window in which the screen scrim fades 0→1.
890
- * @param range - Tuple of two progress values in 0..1 (start <= end).
891
- * @param applySize - Engine-supplied recompute hook; runs one applyOpacity pass.
892
- * @example
893
- * // Scrim is fully opaque only when the sheet is past 50% — softer feel.
894
- * engine.setScreenRange([0, 0.5]);
895
- */
896
912
  setScreenRange(range, applySize) {
897
913
  if (this.isDestroyed()) return;
898
914
  this.screenRange = range;
899
915
  this.invalidateOpacityCache();
900
916
  applySize();
901
917
  }
902
- /**
903
- * Set the scrim background color. Pass `null`/`undefined` to clear the
904
- * inline write and let consumer CSS take over.
905
- * @param color - Any valid CSS color string, or `null`/`undefined` to clear.
906
- * @example
907
- * engine.setScrimColor("rgba(0, 0, 0, 0.6)");
908
- * engine.setScrimColor(null); // hand back to consumer CSS
909
- */
910
918
  setScrimColor(color) {
911
919
  if (this.isDestroyed()) return;
912
920
  this.applyScrimStyles({ color: color === void 0 ? null : color });
913
921
  }
914
- /**
915
- * Set the scrim backdrop-filter blur radius. Pass `null`/`undefined` to clear.
916
- * @param blur - CSS length (e.g. `"8px"`) consumed as `blur(<value>)`, or
917
- * `null`/`undefined` to remove the filter.
918
- * @example
919
- * engine.setScrimBlur("12px"); // frosted-glass effect
920
- * engine.setScrimBlur(null); // disable blur
921
- */
922
922
  setScrimBlur(blur) {
923
923
  if (this.isDestroyed()) return;
924
924
  this.applyScrimStyles({ blur: blur === void 0 ? null : blur });
925
925
  }
926
- /**
927
- * Toggle whether the scrim layer captures pointer events.
928
- * @param interactive - `true` → `pointer-events: auto`; `false` → `none`.
929
- * @example
930
- * engine.setScrimInteractive(true); // scrim swallows clicks
931
- * engine.setScrimInteractive(false); // clicks pass through to content below
932
- */
933
926
  setScrimInteractive(interactive) {
934
927
  if (this.isDestroyed()) return;
935
928
  this.applyScrimStyles({ interactive });
936
929
  }
937
- /**
938
- * Switch scrim positioning at runtime.
939
- * @param mode - `"full"` (consumer CSS), `"above-sheet"` (engine-pinned inset
940
- * so only the area NOT covered by the sheet is dimmed), or
941
- * `"off"` (display:none + opacity:0 — no remount needed).
942
- * @param applySize - Engine-supplied recompute hook; runs one applyOpacity pass.
943
- * @example
944
- * engine.setScrimMode("above-sheet"); // dim only the area above the sheet
945
- * engine.setScrimMode("off"); // disable scrim layer
946
- */
947
930
  setScrimMode(mode, applySize) {
948
931
  if (this.isDestroyed()) return;
949
932
  if (mode === this.scrimMode) return;
@@ -973,15 +956,6 @@ var ScrimController = class {
973
956
  }
974
957
  applySize();
975
958
  }
976
- /**
977
- * Enable or disable click-on-scrim → snap to first non-zero allowed snap.
978
- * Auto-promotes `pointer-events: auto` when scrim is non-interactive so the
979
- * click listener actually fires.
980
- * @param enabled - `true` installs the click listener; `false` removes it.
981
- * @example
982
- * engine.setScrimTapToClose(true); // tap outside sheet collapses it
983
- * engine.setScrimTapToClose(false); // require explicit close button
984
- */
985
959
  setScrimTapToClose(enabled) {
986
960
  if (this.isDestroyed()) return;
987
961
  if (!this.screenComponent) return;
@@ -994,11 +968,15 @@ var ScrimController = class {
994
968
  }
995
969
  const onClick = () => {
996
970
  if (this.isDestroyed()) return;
971
+ if (this.closeFn) {
972
+ this.closeFn();
973
+ return;
974
+ }
997
975
  const allowed = this.getAllowedIds();
998
976
  const snaps = this.getResolvedSnaps();
999
977
  const fallback = allowed.find((id) => {
1000
978
  const snap = findById(id, snaps);
1001
- return snap !== null && snap.size > 0;
979
+ return snap !== null && snap.size === 0;
1002
980
  });
1003
981
  if (fallback) this.snapToFn(fallback);
1004
982
  };
@@ -1011,20 +989,11 @@ var ScrimController = class {
1011
989
  this.scrimTapToCloseEnabled = false;
1012
990
  }
1013
991
  }
1014
- /**
1015
- * Toggle the scrim layer without remounting. Disabling stashes current
1016
- * `screenRange`/`backdropRange` and degenerates them to `[1, 1]` (opacity
1017
- * formula → 0 across the whole range); re-enabling restores the saved values.
1018
- * @param enabled - `false` hides scrim, `true` restores prior ranges.
1019
- * @param applySize - Engine-supplied recompute hook; runs one applyOpacity pass.
1020
- * @example
1021
- * engine.setScrimEnabled(false); // hide scrim, ranges remembered
1022
- * engine.setScrimEnabled(true); // restore prior ranges (no remount)
1023
- */
1024
992
  setScrimEnabled(enabled, applySize) {
1025
993
  if (this.isDestroyed()) return;
1026
994
  if (enabled === this.scrimEnabled) return;
1027
995
  this.scrimEnabled = enabled;
996
+ this.invalidateOpacityCache();
1028
997
  if (!enabled) {
1029
998
  this.savedScrimRanges = {
1030
999
  screen: this.screenRange,
@@ -1039,20 +1008,6 @@ var ScrimController = class {
1039
1008
  }
1040
1009
  applySize();
1041
1010
  }
1042
- /**
1043
- * Batch-update multiple scrim fields with a single applySize() at the end.
1044
- * If `preset` is set, it seeds color/blur/interactive/range first; explicit
1045
- * fields override.
1046
- * @param opts - Partial scrim config: preset, mode, enabled, tapToClose,
1047
- * color, blur, interactive, range.
1048
- * @param applySize - Engine-supplied recompute hook; runs ONCE per call.
1049
- * @example
1050
- * engine.setScrim({
1051
- * preset: "frosted",
1052
- * mode: "above-sheet",
1053
- * tapToClose: true,
1054
- * });
1055
- */
1056
1011
  setScrim(opts, applySize) {
1057
1012
  if (this.isDestroyed()) return;
1058
1013
  if (opts.preset) {
@@ -1062,7 +1017,12 @@ var ScrimController = class {
1062
1017
  blur: cfg.blur ?? null,
1063
1018
  interactive: cfg.interactive
1064
1019
  });
1065
- this.screenRange = cfg.range;
1020
+ if (!this.scrimEnabled && this.savedScrimRanges) {
1021
+ this.savedScrimRanges = { screen: cfg.range, backdrop: cfg.range };
1022
+ } else {
1023
+ this.screenRange = cfg.range;
1024
+ this.backdropRange = cfg.range;
1025
+ }
1066
1026
  }
1067
1027
  const noop = () => {
1068
1028
  };
@@ -1075,27 +1035,18 @@ var ScrimController = class {
1075
1035
  interactive: opts.interactive
1076
1036
  });
1077
1037
  if (opts.range !== void 0) {
1078
- this.screenRange = opts.range;
1038
+ if (!this.scrimEnabled && this.savedScrimRanges) {
1039
+ this.savedScrimRanges = {
1040
+ screen: opts.range,
1041
+ backdrop: this.savedScrimRanges.backdrop
1042
+ };
1043
+ } else {
1044
+ this.screenRange = opts.range;
1045
+ }
1079
1046
  }
1080
1047
  this.invalidateOpacityCache();
1081
1048
  applySize();
1082
1049
  }
1083
- /**
1084
- * Inject a positioned floating element into the scrim layer (e.g. a close
1085
- * button anchored top-right). Replaces any prior overlay so repeated calls
1086
- * (reactive effects re-running) don't accumulate wrappers.
1087
- * @param opts - Children element, optional `inset` (default `"16px"`),
1088
- * `position` (9-way grid, default `"top-right"`), and
1089
- * `interactive` flag (default `true`).
1090
- * @returns Teardown function that removes the wrapper from the DOM.
1091
- * @example
1092
- * const teardown = engine.setScrimOverlay({
1093
- * children: closeBtn,
1094
- * position: "top-right",
1095
- * inset: "20px",
1096
- * });
1097
- * // later: teardown(); // remove the floating button
1098
- */
1099
1050
  setScrimOverlay(opts) {
1100
1051
  if (this.isDestroyed()) return () => {
1101
1052
  };
@@ -1103,61 +1054,38 @@ var ScrimController = class {
1103
1054
  };
1104
1055
  this.scrimOverlayTeardown?.();
1105
1056
  const screen = this.screenComponent;
1106
- const inset = opts.inset ?? "16px";
1057
+ const insetRaw = opts.inset ?? "16px";
1058
+ const inset = VALID_CSS_LENGTH.test(insetRaw) ? insetRaw : "16px";
1059
+ if (inset !== insetRaw) {
1060
+ devWarn(
1061
+ `[bottom-sheet] setScrimOverlay: invalid inset ${JSON.stringify(insetRaw)}; expected a CSS length like "16px" / "1rem" / "5%". Falling back to "16px".`
1062
+ );
1063
+ }
1064
+ if (opts.children.ownerDocument && opts.children.ownerDocument !== screen.ownerDocument) {
1065
+ devWarn(
1066
+ "[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."
1067
+ );
1068
+ }
1107
1069
  const position = opts.position ?? "top-right";
1108
1070
  const interactive = opts.interactive ?? true;
1109
1071
  const wrapper = screen.ownerDocument.createElement("div");
1110
1072
  wrapper.className = "bs-scrim-overlay";
1111
1073
  const ws = wrapper.style;
1112
1074
  ws.position = "absolute";
1113
- switch (position) {
1114
- case "top-left":
1115
- ws.top = inset;
1116
- ws.left = inset;
1117
- break;
1118
- case "top-center":
1119
- ws.top = inset;
1120
- ws.left = "50%";
1121
- ws.transform = "translateX(-50%)";
1122
- break;
1123
- case "top-right":
1124
- ws.top = inset;
1125
- ws.right = inset;
1126
- break;
1127
- case "center-left":
1128
- ws.top = "50%";
1129
- ws.left = inset;
1130
- ws.transform = "translateY(-50%)";
1131
- break;
1132
- case "center":
1133
- ws.top = "50%";
1134
- ws.left = "50%";
1135
- ws.transform = "translate(-50%, -50%)";
1136
- break;
1137
- case "center-right":
1138
- ws.top = "50%";
1139
- ws.right = inset;
1140
- ws.transform = "translateY(-50%)";
1141
- break;
1142
- case "bottom-left":
1143
- ws.bottom = inset;
1144
- ws.left = inset;
1145
- break;
1146
- case "bottom-center":
1147
- ws.bottom = inset;
1148
- ws.left = "50%";
1149
- ws.transform = "translateX(-50%)";
1150
- break;
1151
- case "bottom-right":
1152
- ws.bottom = inset;
1153
- ws.right = inset;
1154
- break;
1155
- }
1075
+ applyOverlayPosition(ws, this.mode, position, inset);
1156
1076
  if (interactive) {
1157
1077
  ws.pointerEvents = "auto";
1158
1078
  }
1159
1079
  wrapper.appendChild(opts.children);
1160
- screen.appendChild(wrapper);
1080
+ const host = screen.parentElement;
1081
+ if (!host) {
1082
+ devWarn(
1083
+ "[bottom-sheet] setScrimOverlay: scrim element has no parent, skipping overlay injection. Mount the sheet root before calling setScrimOverlay."
1084
+ );
1085
+ return () => {
1086
+ };
1087
+ }
1088
+ host.appendChild(wrapper);
1161
1089
  const teardown = () => {
1162
1090
  if (wrapper.parentNode) wrapper.parentNode.removeChild(wrapper);
1163
1091
  if (this.scrimOverlayTeardown === teardown) {
@@ -1167,7 +1095,6 @@ var ScrimController = class {
1167
1095
  this.scrimOverlayTeardown = teardown;
1168
1096
  return teardown;
1169
1097
  }
1170
- /** Tear down toggle-state listeners + remove any injected overlay wrapper. */
1171
1098
  destroy() {
1172
1099
  this.detachScrimTap?.();
1173
1100
  this.detachScrimTap = null;
@@ -1186,8 +1113,6 @@ var ScrimController = class {
1186
1113
  this.lastBackdropPointer = "";
1187
1114
  this.lastScreenDisplay = "";
1188
1115
  }
1189
- // ── Private helpers ────────────────────────────────────────────────
1190
- /** Centralized scrim style writer — atomic 3-property write across all setters. */
1191
1116
  applyScrimStyles(opts) {
1192
1117
  if (!this.screenComponent) return;
1193
1118
  const s = this.screenComponent.style;
@@ -1206,10 +1131,10 @@ var ScrimController = class {
1206
1131
  }
1207
1132
  }
1208
1133
  if (opts.interactive !== void 0) {
1134
+ this.scrimInteractive = opts.interactive;
1209
1135
  s.pointerEvents = opts.interactive ? "auto" : "none";
1210
1136
  }
1211
1137
  }
1212
- /** Pin the screen overlay to the area NOT occupied by the sheet. */
1213
1138
  applyAboveSheetInset() {
1214
1139
  if (!this.screenComponent) return;
1215
1140
  const insetByMode = {
@@ -1221,7 +1146,7 @@ var ScrimController = class {
1221
1146
  const s = this.screenComponent.style;
1222
1147
  s.position = "fixed";
1223
1148
  s.inset = insetByMode[this.mode];
1224
- s.pointerEvents = "none";
1149
+ s.pointerEvents = this.scrimInteractive || this.scrimTapToCloseEnabled ? "auto" : "none";
1225
1150
  }
1226
1151
  };
1227
1152
 
@@ -1275,6 +1200,10 @@ var tween = ({
1275
1200
  promise
1276
1201
  };
1277
1202
  };
1203
+ var prefersReducedMotion = () => {
1204
+ if (typeof window === "undefined" || !window.matchMedia) return false;
1205
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
1206
+ };
1278
1207
 
1279
1208
  // src/core/animation/spring.ts
1280
1209
  var DEFAULT_SPRING = {
@@ -1305,14 +1234,20 @@ var runSpring = ({
1305
1234
  if (cancelled) return;
1306
1235
  const dt = Math.min((now - lastTime) / 1e3, MAX_DT);
1307
1236
  lastTime = now;
1308
- const subDt = Math.min(dt, 1 / 240);
1309
- let stepsLeft = Math.max(1, Math.ceil(dt / subDt));
1237
+ const steps = Math.max(1, Math.ceil(dt * 240));
1238
+ const subDt = dt / steps;
1239
+ let stepsLeft = steps;
1310
1240
  while (stepsLeft-- > 0) {
1311
1241
  const force = -cfg.stiffness * (x - to) - cfg.damping * v;
1312
1242
  const accel = force / cfg.mass;
1313
1243
  v += accel * subDt;
1314
1244
  x += v * subDt;
1315
1245
  }
1246
+ if (!Number.isFinite(x) || !Number.isFinite(v)) {
1247
+ onUpdate(to, 0);
1248
+ resolve();
1249
+ return;
1250
+ }
1316
1251
  onUpdate(x, v);
1317
1252
  const isAtRest = Math.abs(to - x) < (cfg.restDelta ?? 0.5) && Math.abs(v) < (cfg.restSpeed ?? 0.5);
1318
1253
  if (isAtRest) {
@@ -1376,13 +1311,9 @@ var AnimationRunner = class {
1376
1311
  __publicField(this, "duration");
1377
1312
  __publicField(this, "easing");
1378
1313
  __publicField(this, "respectReducedMotion");
1379
- /** Live-tracked via mq listener so animateTo skips matchMedia per snap. */
1380
1314
  __publicField(this, "reducedMotion", false);
1381
- /** Disposes the matchMedia change listener — drained from destroy(). */
1382
1315
  __publicField(this, "detachReducedMotion", null);
1383
- /** Computed once — both inputs (option + API presence) stable for life. */
1384
1316
  __publicField(this, "viewTransitionsAvailable");
1385
- /** In-flight handles. Public for tests / debugging only — engine drives via cancel(). */
1386
1317
  __publicField(this, "currentTween", null);
1387
1318
  __publicField(this, "currentSpring", null);
1388
1319
  this.element = deps.element;
@@ -1413,26 +1344,13 @@ var AnimationRunner = class {
1413
1344
  }
1414
1345
  }
1415
1346
  }
1416
- /**
1417
- * Live derived flag — `true` whenever a spring/tween handle is in flight.
1418
- * Reading the canonical handles (instead of mirroring into a separate
1419
- * boolean) eliminates the reduced-motion early-return latent bug where
1420
- * the flag stayed true forever, and removes the cross-controller
1421
- * setIsAnimating callback.
1422
- */
1423
1347
  get isAnimating() {
1424
1348
  return this.currentTween !== null || this.currentSpring !== null;
1425
1349
  }
1426
- /** Cancel any in-flight tween/spring without touching engine state. */
1427
1350
  cancel() {
1428
1351
  this.currentTween?.cancel();
1429
1352
  this.currentSpring?.cancel();
1430
1353
  }
1431
- /**
1432
- * Animate from current size to `target`. Resolves when the animation
1433
- * completes; resolves early if cancelled mid-flight (caller checks an
1434
- * abort signal post-await for cycle-invariant correctness).
1435
- */
1436
1354
  async animateTo(target, velocityPxPerMs) {
1437
1355
  if (target === this.getSize()) return;
1438
1356
  this.currentTween?.cancel();
@@ -1473,7 +1391,6 @@ var AnimationRunner = class {
1473
1391
  if (!this.isDragging()) this.element.style.willChange = "auto";
1474
1392
  }
1475
1393
  }
1476
- /** Tear down the matchMedia listener. Engine calls from destroy(). */
1477
1394
  destroy() {
1478
1395
  this.detachReducedMotion?.();
1479
1396
  this.detachReducedMotion = null;
@@ -1505,9 +1422,13 @@ var isVisible = (el) => {
1505
1422
  var focusables = (root) => Array.from(root.querySelectorAll(FOCUSABLE)).filter(
1506
1423
  (el) => !el.hasAttribute("inert") && isVisible(el) && !el.matches("[aria-hidden='true']")
1507
1424
  );
1425
+ var trapStack = [];
1508
1426
  var installFocusTrap = (container, options = {}) => {
1509
1427
  if (typeof document === "undefined") return () => {
1510
1428
  };
1429
+ const token = {};
1430
+ trapStack.push(token);
1431
+ const isActive = () => trapStack[trapStack.length - 1] === token;
1511
1432
  const previouslyFocused = document.activeElement;
1512
1433
  const focusInitial = () => {
1513
1434
  const initial = typeof options.initialFocus === "string" ? container.querySelector(options.initialFocus) : options.initialFocus ?? focusables(container)[0] ?? container;
@@ -1515,6 +1436,7 @@ var installFocusTrap = (container, options = {}) => {
1515
1436
  };
1516
1437
  focusInitial();
1517
1438
  const handleKey = (e) => {
1439
+ if (!isActive()) return;
1518
1440
  if (e.key === "Escape" && options.onEscape) {
1519
1441
  options.onEscape();
1520
1442
  return;
@@ -1537,6 +1459,7 @@ var installFocusTrap = (container, options = {}) => {
1537
1459
  }
1538
1460
  };
1539
1461
  const handleFocusIn = (e) => {
1462
+ if (!isActive()) return;
1540
1463
  const target = e.target;
1541
1464
  if (target && !container.contains(target)) {
1542
1465
  const list = focusables(container);
@@ -1549,6 +1472,8 @@ var installFocusTrap = (container, options = {}) => {
1549
1472
  return () => {
1550
1473
  if (released) return;
1551
1474
  released = true;
1475
+ const idx = trapStack.indexOf(token);
1476
+ if (idx !== -1) trapStack.splice(idx, 1);
1552
1477
  document.removeEventListener("keydown", handleKey, true);
1553
1478
  document.removeEventListener("focusin", handleFocusIn, true);
1554
1479
  if (previouslyFocused && document.contains(previouslyFocused)) {
@@ -1604,11 +1529,17 @@ function createInertSiblings(rootProvider) {
1604
1529
  return {
1605
1530
  apply() {
1606
1531
  if (typeof document === "undefined") return;
1607
- const root = rootProvider();
1608
- const parent = root.parentElement ?? document.body;
1532
+ let top = rootProvider();
1533
+ while (top.parentElement && top.parentElement !== document.body) {
1534
+ top = top.parentElement;
1535
+ }
1536
+ const parent = top.parentElement ?? document.body;
1609
1537
  for (const child of Array.from(parent.children)) {
1610
- if (child === root) continue;
1538
+ if (child === top) continue;
1611
1539
  if (child.hasAttribute("inert")) continue;
1540
+ if (child.classList.contains("bs-backdrop") || child.classList.contains("bs-screen") || child.classList.contains("bs-root")) {
1541
+ continue;
1542
+ }
1612
1543
  child.setAttribute("inert", "");
1613
1544
  tracked.push(child);
1614
1545
  }
@@ -1624,7 +1555,6 @@ function createInertSiblings(rootProvider) {
1624
1555
  var LifecycleController = class {
1625
1556
  constructor(deps, opts) {
1626
1557
  __publicField(this, "element");
1627
- __publicField(this, "closeFn");
1628
1558
  __publicField(this, "focusTrapEnabled");
1629
1559
  __publicField(this, "initialFocus");
1630
1560
  __publicField(this, "closeOnEscape");
@@ -1633,16 +1563,11 @@ var LifecycleController = class {
1633
1563
  __publicField(this, "inertSiblings");
1634
1564
  __publicField(this, "shouldApplyInertSiblings");
1635
1565
  __publicField(this, "returnFocus");
1636
- /** Toggle-state teardowns — fire on close, NOT in destroy-only stack. */
1637
1566
  __publicField(this, "releaseFocusTrap", null);
1638
1567
  __publicField(this, "releaseScrollLock", null);
1639
- /** True between install() and release(). Guards release()/destroy() from
1640
- * re-running side effects (notably `returnFocus.focus()`) on already-
1641
- * released or never-installed instances. */
1642
1568
  __publicField(this, "installed", false);
1643
1569
  __publicField(this, "destroyed", false);
1644
1570
  this.element = deps.element;
1645
- this.closeFn = deps.close;
1646
1571
  this.focusTrapEnabled = opts.focusTrap ?? false;
1647
1572
  this.initialFocus = opts.initialFocus;
1648
1573
  this.closeOnEscape = opts.closeOnEscape ?? true;
@@ -1652,14 +1577,9 @@ var LifecycleController = class {
1652
1577
  this.returnFocus = opts.returnFocus;
1653
1578
  this.inertSiblings = createInertSiblings(() => this.element);
1654
1579
  }
1655
- /**
1656
- * Run the open-time install steps. Idempotent: each step checks its
1657
- * existing teardown ref so a re-open during a half-cancelled close
1658
- * doesn't double-install. Sets `installed=true` BEFORE the first side
1659
- * effect so a partial throw (detached / shadow-DOM target) leaves the
1660
- * caller's `release()` rollback path armed to undo whatever did succeed.
1661
- * No-op when destroyed.
1662
- */
1580
+ get isInstalled() {
1581
+ return this.installed;
1582
+ }
1663
1583
  install() {
1664
1584
  if (this.destroyed) return;
1665
1585
  this.installed = true;
@@ -1668,30 +1588,17 @@ var LifecycleController = class {
1668
1588
  }
1669
1589
  if (this.focusTrapEnabled && !this.releaseFocusTrap) {
1670
1590
  this.releaseFocusTrap = installFocusTrap(this.element, {
1671
- initialFocus: this.initialFocus,
1672
- onEscape: this.closeOnEscape ? () => void this.closeFn() : void 0
1591
+ initialFocus: this.initialFocus
1673
1592
  });
1674
1593
  }
1675
1594
  if (this.inertSiblingsEnabled && this.shouldApplyInertSiblings()) {
1676
1595
  this.inertSiblings.apply();
1677
1596
  }
1678
1597
  }
1679
- /**
1680
- * Update the post-release focus target. Overlay re-binds this when the
1681
- * consumer changes `returnFocus` after construction; the new value applies
1682
- * on the next `release()`. No-op when destroyed.
1683
- */
1684
1598
  setReturnFocus(target) {
1685
1599
  if (this.destroyed) return;
1686
1600
  this.returnFocus = target;
1687
1601
  }
1688
- /**
1689
- * Release all install-time teardowns. Short-circuits when not installed,
1690
- * so `close() → destroy()` (each routing through here) only restores
1691
- * focus once. The `installed` guard lets `inertSiblings.remove()` and
1692
- * the focus-trap/scroll-lock teardown nullings stay simple — they were
1693
- * already idempotent, but `returnFocus.focus()` was firing on every call.
1694
- */
1695
1602
  release() {
1696
1603
  if (!this.installed) return;
1697
1604
  this.installed = false;
@@ -1705,12 +1612,6 @@ var LifecycleController = class {
1705
1612
  target?.focus?.();
1706
1613
  }
1707
1614
  }
1708
- /**
1709
- * Final teardown — releases any still-installed listeners and seals the
1710
- * controller. After destroy(), install() is a no-op and setReturnFocus()
1711
- * is rejected, so post-destroy mutations can't queue up a stale focus
1712
- * restore.
1713
- */
1714
1615
  destroy() {
1715
1616
  this.release();
1716
1617
  this.destroyed = true;
@@ -1720,12 +1621,10 @@ var LifecycleController = class {
1720
1621
  // src/core/gestures.ts
1721
1622
  var isAxisVertical = (mode) => mode === "bottom" || mode === "top";
1722
1623
  var SampleRing = class {
1723
- // current count, ≤ capacity
1724
1624
  constructor(capacity) {
1725
1625
  __publicField(this, "slots");
1726
1626
  __publicField(this, "capacity");
1727
1627
  __publicField(this, "head", 0);
1728
- // next write index
1729
1628
  __publicField(this, "size", 0);
1730
1629
  this.capacity = capacity;
1731
1630
  this.slots = new Array(capacity);
@@ -1738,12 +1637,6 @@ var SampleRing = class {
1738
1637
  this.head = (this.head + 1) % this.capacity;
1739
1638
  if (this.size < this.capacity) this.size++;
1740
1639
  }
1741
- /**
1742
- * Drop oldest entries whose timestamp is below `cutoff`, but never shrink
1743
- * below `minKeep`. Mirrors the original
1744
- * `while (samples.length > minKeep && samples[0].t < cutoff) samples.shift()`
1745
- * but without copying — just advances the logical tail.
1746
- */
1747
1640
  pruneBefore(cutoff, minKeep) {
1748
1641
  while (this.size > minKeep) {
1749
1642
  const oldestIdx = (this.head - this.size + this.capacity) % this.capacity;
@@ -1849,10 +1742,11 @@ var installGestures = (handle, mode, callbacks) => {
1849
1742
  };
1850
1743
 
1851
1744
  // src/core/primitives/rubber-band.ts
1852
- var RUBBER_BAND_FACTOR = 0.55;
1853
1745
  function rubberBand(overshoot, maxAxisSize) {
1854
1746
  const cap = Math.min(maxAxisSize * 0.15, 80);
1855
- return Math.sign(overshoot) * Math.pow(Math.abs(overshoot), RUBBER_BAND_FACTOR) * Math.min(6, cap / Math.max(Math.abs(overshoot), 1));
1747
+ if (cap <= 0) return 0;
1748
+ const abs = Math.abs(overshoot);
1749
+ return Math.sign(overshoot) * (cap * abs / (abs + cap));
1856
1750
  }
1857
1751
 
1858
1752
  // src/core/features/soft-keyboard.ts
@@ -1879,28 +1773,14 @@ var GestureController = class {
1879
1773
  __publicField(this, "detach", null);
1880
1774
  __publicField(this, "isDragging_", false);
1881
1775
  __publicField(this, "dragStartSize", 0);
1882
- // Only "touch" gestures are eligible for soft-keyboard dismissal —
1883
- // mouse/trackpad users don't have a keyboard to dismiss.
1884
1776
  __publicField(this, "currentPointerKind", "touch");
1885
- // One blur per drag — flipping focus every move event races the keyboard's
1886
- // own animations on iOS and makes the page jitter.
1887
1777
  __publicField(this, "keyboardDismissed", false);
1888
- // Reused payload object for the "drag" event — mutated each onMove instead
1889
- // of allocating a fresh `{size, delta}` per frame. At 60-120Hz a long drag
1890
- // would otherwise churn tens of KB through the GC. Mirrors browser
1891
- // PointerEvent semantics: consumers must NOT retain the reference across
1892
- // frames (clone if needed). See SheetEventMap.drag JSDoc.
1893
1778
  __publicField(this, "dragPayload", { size: 0, delta: 0 });
1894
1779
  this.deps = deps;
1895
1780
  }
1896
- /** True between `dragstart` and `dragend`/`onCancel`. */
1897
1781
  get isDragging() {
1898
1782
  return this.isDragging_;
1899
1783
  }
1900
- /**
1901
- * Bind pointer listeners on the handle and return a teardown. Engine pushes
1902
- * the teardown into its TeardownStack so destroy() drains it once.
1903
- */
1904
1784
  install() {
1905
1785
  if (this.detach) return this.detach;
1906
1786
  this.detach = installGestures(this.deps.handle, this.deps.mode, {
@@ -1951,17 +1831,16 @@ var GestureController = class {
1951
1831
  this.isDragging_ = false;
1952
1832
  this.keyboardDismissed = false;
1953
1833
  this.deps.getRoot()?.removeAttribute("data-dragging");
1834
+ this.deps.emit("dragend", {
1835
+ size: this.deps.getDragContext().size,
1836
+ velocity: 0
1837
+ });
1954
1838
  const restoreTo = this.dragStartSize;
1955
1839
  void this.deps.animateTo(restoreTo, 0);
1956
1840
  }
1957
1841
  });
1958
1842
  return this.detach;
1959
1843
  }
1960
- /**
1961
- * Force-clear drag visual state. Engine calls from destroy() when destroyed
1962
- * mid-drag — gesture detach won't synthesize an onCancel, so we have to
1963
- * roll back the data-attribute + will-change ourselves.
1964
- */
1965
1844
  forceClearDragState() {
1966
1845
  if (!this.isDragging_) return;
1967
1846
  this.isDragging_ = false;
@@ -1980,11 +1859,6 @@ var AriaSliderWriter = class {
1980
1859
  mode === "bottom" || mode === "top" ? "vertical" : "horizontal"
1981
1860
  );
1982
1861
  }
1983
- /**
1984
- * Update slider position. `allowedIds` length determines `aria-valuemax`;
1985
- * `activeId` indexes into it for `aria-valuenow` + `aria-valuetext`.
1986
- * Empty list → strip every dynamic attr so AT skips the handle entirely.
1987
- */
1988
1862
  setValue(allowedIds, activeId) {
1989
1863
  if (allowedIds.length === 0) {
1990
1864
  this.handle.removeAttribute("aria-valuemin");
@@ -2034,6 +1908,287 @@ function createScrollCache(deps) {
2034
1908
  };
2035
1909
  }
2036
1910
 
1911
+ // src/core/primitives/anchor-animations.ts
1912
+ var DEFAULT_DURATION3 = 200;
1913
+ var DEFAULT_EASING = "cubic-bezier(0.22, 1, 0.36, 1)";
1914
+ var PRESET_FRAMES = {
1915
+ fade: {
1916
+ enter: [{ opacity: 0 }, { opacity: 1 }],
1917
+ exit: [{ opacity: 1 }, { opacity: 0 }]
1918
+ },
1919
+ scale: {
1920
+ enter: [
1921
+ { opacity: 0, transform: "scale(0.85)" },
1922
+ { opacity: 1, transform: "scale(1)" }
1923
+ ],
1924
+ exit: [
1925
+ { opacity: 1, transform: "scale(1)" },
1926
+ { opacity: 0, transform: "scale(0.85)" }
1927
+ ]
1928
+ },
1929
+ slide: {
1930
+ enter: [
1931
+ { opacity: 0, transform: "translateY(10px)" },
1932
+ { opacity: 1, transform: "translateY(0)" }
1933
+ ],
1934
+ exit: [
1935
+ { opacity: 1, transform: "translateY(0)" },
1936
+ { opacity: 0, transform: "translateY(10px)" }
1937
+ ]
1938
+ },
1939
+ pop: {
1940
+ enter: [
1941
+ { opacity: 0, transform: "scale(0.6)", offset: 0 },
1942
+ { opacity: 1, transform: "scale(1.06)", offset: 0.7 },
1943
+ { opacity: 1, transform: "scale(1)", offset: 1 }
1944
+ ],
1945
+ exit: [
1946
+ { opacity: 1, transform: "scale(1)" },
1947
+ { opacity: 0, transform: "scale(0.6)" }
1948
+ ]
1949
+ }
1950
+ };
1951
+ var INSTANT = {
1952
+ finished: Promise.resolve(),
1953
+ cancel: () => {
1954
+ }
1955
+ };
1956
+ function resolveTransitionFrames(spec, phase) {
1957
+ const fallback = { frames: null, duration: 0, easing: DEFAULT_EASING };
1958
+ if (spec === "none") return fallback;
1959
+ if (spec === void 0 || typeof spec === "string") {
1960
+ const preset2 = PRESET_FRAMES[spec ?? "fade"];
1961
+ return {
1962
+ frames: preset2[phase],
1963
+ duration: DEFAULT_DURATION3,
1964
+ easing: DEFAULT_EASING
1965
+ };
1966
+ }
1967
+ const custom = phase === "enter" ? spec.enter : spec.exit;
1968
+ if (custom) {
1969
+ return {
1970
+ frames: custom,
1971
+ duration: spec.duration ?? DEFAULT_DURATION3,
1972
+ easing: spec.easing ?? DEFAULT_EASING
1973
+ };
1974
+ }
1975
+ if (spec.preset === "none") return fallback;
1976
+ const preset = PRESET_FRAMES[spec.preset ?? "fade"];
1977
+ return {
1978
+ frames: preset[phase],
1979
+ duration: spec.duration ?? DEFAULT_DURATION3,
1980
+ easing: spec.easing ?? DEFAULT_EASING
1981
+ };
1982
+ }
1983
+ function runAnchorTransition(el, spec, phase) {
1984
+ const respectReduced = typeof spec === "object" ? spec.respectReducedMotion !== false : true;
1985
+ if (respectReduced && prefersReducedMotion()) return INSTANT;
1986
+ const { frames, duration, easing } = resolveTransitionFrames(spec, phase);
1987
+ if (!frames || duration <= 0) return INSTANT;
1988
+ if (typeof el.animate !== "function") return INSTANT;
1989
+ const animation = el.animate(frames, {
1990
+ duration,
1991
+ easing,
1992
+ fill: "both"
1993
+ });
1994
+ const finished = animation.finished.then(() => void 0).catch(() => void 0);
1995
+ return {
1996
+ finished,
1997
+ cancel: () => {
1998
+ try {
1999
+ animation.cancel();
2000
+ } catch {
2001
+ return;
2002
+ }
2003
+ }
2004
+ };
2005
+ }
2006
+
2007
+ // src/core/features/sheet-anchors.ts
2008
+ function attachAnchor(deps, opts) {
2009
+ const doc = deps.host.ownerDocument;
2010
+ const wrapper = doc.createElement("div");
2011
+ wrapper.className = "bs-anchor";
2012
+ const interactive = opts.interactive ?? true;
2013
+ const position = opts.position ?? "sheet-top-right";
2014
+ const isDock = position === "dock-bottom" || position === "dock-top";
2015
+ const ws = wrapper.style;
2016
+ ws.position = "fixed";
2017
+ if (isDock) {
2018
+ ws.left = "0";
2019
+ ws.right = "0";
2020
+ if (position === "dock-bottom") {
2021
+ ws.bottom = "0";
2022
+ } else {
2023
+ ws.top = "0";
2024
+ }
2025
+ } else {
2026
+ applyOverlayPosition(ws, deps.mode, position, opts.inset ?? "16px");
2027
+ }
2028
+ if (opts.fadeRange) {
2029
+ const [r0, r1] = opts.fadeRange;
2030
+ const span = Math.max(r1 - r0, 1e-4);
2031
+ ws.opacity = `clamp(0, calc((var(--bs-progress, 0) - ${r0}) / ${span}), 1)`;
2032
+ }
2033
+ wrapper.appendChild(opts.element);
2034
+ deps.host.appendChild(wrapper);
2035
+ let visible = null;
2036
+ let inFlight = null;
2037
+ const shouldShow = (state) => {
2038
+ if (typeof opts.showOn === "function") return opts.showOn(state);
2039
+ if (Array.isArray(opts.showOn)) return opts.showOn.includes(state.activeId);
2040
+ return isDock || state.size > 0;
2041
+ };
2042
+ const applyVisibility = (next, animate) => {
2043
+ if (next === visible) return;
2044
+ visible = next;
2045
+ inFlight?.cancel();
2046
+ inFlight = null;
2047
+ wrapper.style.pointerEvents = next && interactive ? "auto" : "none";
2048
+ if (next) {
2049
+ wrapper.style.visibility = "";
2050
+ if (animate) {
2051
+ inFlight = runAnchorTransition(opts.element, opts.animation, "enter");
2052
+ }
2053
+ return;
2054
+ }
2055
+ if (!animate) {
2056
+ wrapper.style.visibility = "hidden";
2057
+ return;
2058
+ }
2059
+ const handle = runAnchorTransition(opts.element, opts.animation, "exit");
2060
+ inFlight = handle;
2061
+ void handle.finished.then(() => {
2062
+ if (inFlight === handle && visible === false) {
2063
+ wrapper.style.visibility = "hidden";
2064
+ }
2065
+ });
2066
+ };
2067
+ const evaluate = (animate) => {
2068
+ if (deps.isDestroyed()) return;
2069
+ applyVisibility(shouldShow(deps.getState()), animate);
2070
+ };
2071
+ const offs = [
2072
+ deps.on("snap", () => evaluate(true)),
2073
+ deps.on("open", () => evaluate(true)),
2074
+ deps.on("close", () => evaluate(true))
2075
+ ];
2076
+ evaluate(false);
2077
+ return {
2078
+ wrapper,
2079
+ detach: () => {
2080
+ offs.forEach((off) => off());
2081
+ inFlight?.cancel();
2082
+ inFlight = null;
2083
+ if (wrapper.parentNode) wrapper.parentNode.removeChild(wrapper);
2084
+ },
2085
+ syncZ: (z) => {
2086
+ wrapper.style.zIndex = String(z);
2087
+ }
2088
+ };
2089
+ }
2090
+
2091
+ // src/core/features/scrim-stages.ts
2092
+ var matchesId = (def, activeId) => {
2093
+ if (def.for === void 0) return false;
2094
+ return Array.isArray(def.for) ? def.for.includes(activeId) : def.for === activeId;
2095
+ };
2096
+ var matchesRange = (def, progress) => {
2097
+ if (!def.forRange) return false;
2098
+ const [from, to] = def.forRange;
2099
+ return progress >= from && progress <= to;
2100
+ };
2101
+ function installScrimStages(deps, opts) {
2102
+ const doc = deps.host.ownerDocument;
2103
+ const entries = opts.stages.map((def) => {
2104
+ const wrapper = doc.createElement("div");
2105
+ wrapper.className = "bs-scrim-stage";
2106
+ const ws = wrapper.style;
2107
+ ws.position = "absolute";
2108
+ applyOverlayPosition(
2109
+ ws,
2110
+ deps.mode,
2111
+ def.position ?? opts.position ?? "center",
2112
+ def.inset ?? opts.inset ?? "16px"
2113
+ );
2114
+ ws.visibility = "hidden";
2115
+ ws.pointerEvents = "none";
2116
+ wrapper.appendChild(def.element);
2117
+ deps.host.appendChild(wrapper);
2118
+ return { def, wrapper, inFlight: null };
2119
+ });
2120
+ let active = null;
2121
+ const resolveActive = () => {
2122
+ const state = deps.getState();
2123
+ if (state.size === 0) {
2124
+ return entries.find((e) => matchesId(e.def, state.activeId)) ?? null;
2125
+ }
2126
+ return entries.find((e) => matchesId(e.def, state.activeId)) ?? entries.find((e) => matchesRange(e.def, state.progress)) ?? null;
2127
+ };
2128
+ const hide = (entry, animate) => {
2129
+ entry.inFlight?.cancel();
2130
+ entry.wrapper.style.pointerEvents = "none";
2131
+ if (!animate) {
2132
+ entry.inFlight = null;
2133
+ entry.wrapper.style.visibility = "hidden";
2134
+ return;
2135
+ }
2136
+ const handle = runAnchorTransition(
2137
+ entry.def.element,
2138
+ entry.def.animation ?? opts.animation,
2139
+ "exit"
2140
+ );
2141
+ entry.inFlight = handle;
2142
+ void handle.finished.then(() => {
2143
+ if (entry.inFlight === handle && active !== entry) {
2144
+ entry.wrapper.style.visibility = "hidden";
2145
+ }
2146
+ });
2147
+ };
2148
+ const show = (entry, animate) => {
2149
+ entry.inFlight?.cancel();
2150
+ entry.inFlight = null;
2151
+ entry.wrapper.style.visibility = "";
2152
+ entry.wrapper.style.pointerEvents = entry.def.interactive ?? opts.interactive ?? false ? "auto" : "none";
2153
+ if (animate) {
2154
+ entry.inFlight = runAnchorTransition(
2155
+ entry.def.element,
2156
+ entry.def.animation ?? opts.animation,
2157
+ "enter"
2158
+ );
2159
+ }
2160
+ };
2161
+ const update = (animate) => {
2162
+ if (deps.isDestroyed()) return;
2163
+ const next = resolveActive();
2164
+ if (next === active) return;
2165
+ const prev = active;
2166
+ active = next;
2167
+ if (prev) hide(prev, animate);
2168
+ if (next) show(next, animate);
2169
+ };
2170
+ const offs = [
2171
+ deps.on("snap", () => update(true)),
2172
+ deps.on("open", () => update(true)),
2173
+ deps.on("close", () => update(true))
2174
+ ];
2175
+ if (entries.some((e) => e.def.forRange)) {
2176
+ offs.push(deps.on("progress", () => update(true)));
2177
+ }
2178
+ update(false);
2179
+ return () => {
2180
+ offs.forEach((off) => off());
2181
+ for (const entry of entries) {
2182
+ entry.inFlight?.cancel();
2183
+ entry.inFlight = null;
2184
+ if (entry.wrapper.parentNode) {
2185
+ entry.wrapper.parentNode.removeChild(entry.wrapper);
2186
+ }
2187
+ }
2188
+ active = null;
2189
+ };
2190
+ }
2191
+
2037
2192
  // src/core/primitives/engine-options.ts
2038
2193
  var DEFAULT_FLICK_VELOCITY = 0.65;
2039
2194
  var DEFAULT_DRAG_THRESHOLD = 18;
@@ -2058,9 +2213,6 @@ function resolveEngineOptions(opts, extras) {
2058
2213
  scrimMode: opts.scrimMode,
2059
2214
  scrimColor: opts.scrimColor,
2060
2215
  scrimBlur: opts.scrimBlur,
2061
- // Auto-promote pointer-events when tap-to-close is on so the click
2062
- // listener actually receives events. Caller can still force `false`
2063
- // explicitly to opt out — `??` only kicks in for `undefined`.
2064
2216
  scrimInteractive: opts.scrimInteractive ?? (opts.scrimTapToClose ? true : void 0),
2065
2217
  scrimTapToClose: opts.scrimTapToClose,
2066
2218
  scrimPreset: opts.scrimPreset,
@@ -2081,10 +2233,6 @@ function resolveEngineOptions(opts, extras) {
2081
2233
  closeOnEscape: opts.closeOnEscape,
2082
2234
  lockBodyScroll: opts.lockBodyScroll,
2083
2235
  inertSiblings: opts.inertSiblings,
2084
- // Folded in from `extras` so the lifecycle bag is fully assembled at
2085
- // the call site — engine no longer needs a spread+override at
2086
- // controller construction. When `extras` is omitted, the field stays
2087
- // undefined and the controller uses its own default predicate.
2088
2236
  shouldApplyInertSiblings: extras?.shouldApplyInertSiblings
2089
2237
  }
2090
2238
  };
@@ -2119,6 +2267,7 @@ var BottomSheetEngine = class {
2119
2267
  __publicField(this, "scrollContainer");
2120
2268
  __publicField(this, "backdrop");
2121
2269
  __publicField(this, "screenComponent");
2270
+ __publicField(this, "scrimParent", null);
2122
2271
  __publicField(this, "mode");
2123
2272
  __publicField(this, "flickVelocity");
2124
2273
  __publicField(this, "dragThreshold");
@@ -2129,40 +2278,22 @@ var BottomSheetEngine = class {
2129
2278
  __publicField(this, "lifecycle");
2130
2279
  __publicField(this, "closeOnBack");
2131
2280
  __publicField(this, "routedTo");
2132
- // Engine retains `snapPointsRaw` for the "find a closed-id" query in
2133
- // close() (raw input, not resolved geometry). Resolver owns geometry.
2134
2281
  __publicField(this, "snapPointsRaw");
2135
2282
  __publicField(this, "snaps");
2136
2283
  __publicField(this, "activeId");
2137
2284
  __publicField(this, "size", 0);
2138
- // Constructed in attach(); a plugin's install() could read `state` before
2139
- // attach completes if the constructor throws partway through. State getter
2140
- // null-guards via `this.gesture?.isDragging ?? false`.
2141
2285
  __publicField(this, "gesture");
2142
2286
  __publicField(this, "rootEl", null);
2143
2287
  __publicField(this, "destroyed", false);
2144
- // In-flight ViewTransition handle. A second snapTo while a VT is in flight
2145
- // would otherwise let Chromium reject the second's promise and flicker —
2146
- // newCycle() calls skipTransition() to abort cleanly.
2147
2288
  __publicField(this, "currentViewTransition", null);
2148
- // Each cycle captures the signal at entry; when a newer cycle aborts the
2149
- // controller, the captured signal flips to `aborted=true` and post-await
2150
- // guards short-circuit (prevents stale snap/open/close emits).
2151
2289
  __publicField(this, "currentAbort", new AbortController());
2152
- // Sub-pixel `--bs-size` deltas and <1% `--bs-progress` deltas are not
2153
- // visually distinguishable; gating CSSOM writes drops 30-50% of writes
2154
- // during the spring's settle tail.
2155
2290
  __publicField(this, "sizeWriteSentinel", new WriteSentinel());
2156
2291
  __publicField(this, "progressWriteSentinel", new WriteSentinel());
2157
2292
  __publicField(this, "isTopSheet", true);
2158
- // Pooled payload — identity documented in SheetEventMap.progress JSDoc;
2159
- // consumers must not retain across frames.
2160
2293
  __publicField(this, "progressPayload", {
2161
2294
  value: 0,
2162
2295
  size: 0
2163
2296
  });
2164
- // Pooled buffer for `getDragContext()` — onMove calls this 60-120×/s.
2165
- // Inner `range` object identity is also stable (mutated in place).
2166
2297
  __publicField(this, "dragContextBuf", {
2167
2298
  size: 0,
2168
2299
  maxAxisSize: 0,
@@ -2173,13 +2304,13 @@ var BottomSheetEngine = class {
2173
2304
  __publicField(this, "persistKey");
2174
2305
  __publicField(this, "linkedSheets", []);
2175
2306
  __publicField(this, "scrollCache");
2176
- // Single LIFO stack for ALL destroy-only teardowns. Plugins (added last)
2177
- // tear down first before the infrastructure they depend on. Toggle-semantic
2178
- // listeners (focus-trap / scroll-lock / scrim tap-to-close) stay as named
2179
- // fields on their controllers.
2180
2307
  __publicField(this, "teardowns", new TeardownStack());
2181
- // Pre-computed transform template — built once per engine since `mode` is
2182
- // fixed for its lifetime. Avoids the 4-arm switch at 60-120 Hz.
2308
+ __publicField(this, "anchors", []);
2309
+ __publicField(this, "anchorHost", null);
2310
+ __publicField(this, "stackZ", 100);
2311
+ __publicField(this, "detachScrimStages", null);
2312
+ __publicField(this, "stackEffectEnabled", false);
2313
+ __publicField(this, "stackEffectPrimed", false);
2183
2314
  __publicField(this, "transformTemplate");
2184
2315
  const resolved = resolveEngineOptions(opts, {
2185
2316
  shouldApplyInertSiblings: () => {
@@ -2194,7 +2325,8 @@ var BottomSheetEngine = class {
2194
2325
  this.handle = opts.handle ?? opts.element;
2195
2326
  this.scrollContainer = opts.scrollContainer;
2196
2327
  this.backdrop = opts.backdrop;
2197
- this.screenComponent = opts.screenComponent;
2328
+ this.screenComponent = opts.scrim ?? opts.screenComponent;
2329
+ this.scrimParent = this.screenComponent?.parentElement ?? null;
2198
2330
  this.mode = resolved.mode;
2199
2331
  this.transformTemplate = buildTransformTemplate(this.mode);
2200
2332
  this.aria = new AriaSliderWriter(this.handle, this.mode);
@@ -2212,6 +2344,9 @@ var BottomSheetEngine = class {
2212
2344
  getResolvedSnaps: () => this.snaps.getResolvedSnaps().slice(),
2213
2345
  snapTo: (id) => {
2214
2346
  void this.snapTo(id);
2347
+ },
2348
+ close: () => {
2349
+ void this.close();
2215
2350
  }
2216
2351
  },
2217
2352
  resolved.scrim
@@ -2222,8 +2357,6 @@ var BottomSheetEngine = class {
2222
2357
  getRootEl: () => this.rootEl,
2223
2358
  applySize: (size) => this.applySize(size),
2224
2359
  getSize: () => this.size,
2225
- // Optional chain protects synthetic test paths that drive animateTo
2226
- // before attach() (gesture is constructed there).
2227
2360
  isDragging: () => this.gesture?.isDragging ?? false
2228
2361
  },
2229
2362
  resolved.animation
@@ -2237,6 +2370,7 @@ var BottomSheetEngine = class {
2237
2370
  );
2238
2371
  this.closeOnBack = resolved.closeOnBack;
2239
2372
  this.routedTo = opts.routedTo;
2373
+ this.stackEffectEnabled = opts.stackEffect ?? false;
2240
2374
  this.snapPointsRaw = opts.snapPoints;
2241
2375
  this.persistKey = opts.persistKey;
2242
2376
  if (opts.linkedSheets) {
@@ -2249,7 +2383,7 @@ var BottomSheetEngine = class {
2249
2383
  this.snapPointsRaw,
2250
2384
  resolved.initialAllowed,
2251
2385
  this.mode,
2252
- () => this.handle.offsetHeight,
2386
+ () => layoutAxis(this.mode) === "width" ? this.handle.offsetWidth : this.handle.offsetHeight,
2253
2387
  (maxAxisSize) => {
2254
2388
  this.element.style[layoutAxis(this.mode)] = `${maxAxisSize}px`;
2255
2389
  }
@@ -2328,21 +2462,6 @@ var BottomSheetEngine = class {
2328
2462
  on(event, fn) {
2329
2463
  return this.bus.on(event, fn);
2330
2464
  }
2331
- /**
2332
- * Register a plugin. `install` runs synchronously; if it returns a function,
2333
- * it's captured for teardown during `destroy()`. No-op if already destroyed
2334
- * — installing into a torn-down engine would leak listeners.
2335
- *
2336
- * Throws from `plugin.install` are caught and rethrown via `queueMicrotask`
2337
- * so a buggy plugin can't take the engine down with it: the engine stays
2338
- * usable, sibling plugins still install, and the error surfaces in dev tools
2339
- * on the next tick instead of being swallowed. Mirrors the per-fn isolation
2340
- * model used for teardowns.
2341
- *
2342
- * Plugins that accept the second `scope` argument get transactional cleanup:
2343
- * push partial-install cleanups via `scope.add(fn)` and the engine drains
2344
- * them on throw, OR merges them into destroy-time teardowns on success.
2345
- */
2346
2465
  use(plugin) {
2347
2466
  if (this.destroyed) {
2348
2467
  console.warn(
@@ -2352,9 +2471,6 @@ var BottomSheetEngine = class {
2352
2471
  }
2353
2472
  const scoped = [];
2354
2473
  const scope = {
2355
- // typeof check — not just truthiness — so a JS-only plugin pushing a
2356
- // bogus value is silently rejected instead of queueing a confusing
2357
- // TypeError on destroy() drain.
2358
2474
  add: (fn) => {
2359
2475
  if (typeof fn === "function") scoped.push(fn);
2360
2476
  }
@@ -2383,36 +2499,13 @@ var BottomSheetEngine = class {
2383
2499
  }
2384
2500
  return this;
2385
2501
  }
2386
- /**
2387
- * Replace the linked-sheets list at runtime. Use when peer engines aren't
2388
- * available at construction time (mounted later in the tree). The wiring
2389
- * to the `open` event is independent of this list — it reads `linkedSheets`
2390
- * lazily on emit, so this setter is a simple field swap.
2391
- */
2392
2502
  setLinkedSheets(sheets) {
2393
2503
  if (this.destroyed) return;
2394
2504
  this.linkedSheets = sheets;
2395
2505
  }
2396
- /** Read-only view of the current allow-list, for cross-sheet target negotiation. */
2397
2506
  getAllowedIds() {
2398
2507
  return this.snaps.getAllowedIds().slice();
2399
2508
  }
2400
- /**
2401
- * Animate to a snap point by id. Resolves when the animation completes.
2402
- *
2403
- * Two call shapes:
2404
- * ```ts
2405
- * engine.snapTo("full"); // default velocity
2406
- * engine.snapTo("full", 0.5); // px/ms initial velocity
2407
- * engine.snapTo("full", { velocity: 0.5, signal }); // abortable
2408
- * ```
2409
- *
2410
- * When `opts.signal` is provided, calling `controller.abort()` cancels the
2411
- * in-flight tween/spring and the returned promise resolves without firing
2412
- * `snap`. The captured `AbortSignal` is checked after each await so external
2413
- * aborts ride the same cycle invariant as internal ones (resize, destroy,
2414
- * concurrent snapTo).
2415
- */
2416
2509
  async snapTo(id, velocityOrOpts = 0, _skipBeforeSnap = false) {
2417
2510
  if (this.destroyed) return;
2418
2511
  const opts = typeof velocityOrOpts === "object" ? velocityOrOpts : { velocity: velocityOrOpts };
@@ -2471,16 +2564,6 @@ var BottomSheetEngine = class {
2471
2564
  this.handleClose();
2472
2565
  }
2473
2566
  }
2474
- /**
2475
- * Drive the sheet to an arbitrary px size, bypassing snap matching.
2476
- * Useful for preview scrubs, animation chaining, or programmatic gestures.
2477
- * Animates with the configured spring/tween. Resolves when the animation
2478
- * completes. Does NOT update `activeId` — call `snapTo(id)` after to commit.
2479
- *
2480
- * Unlike `snapTo`, this does not fire `before-snap` (it isn't a snap commit)
2481
- * nor `snap` (the sheet isn't settling to a known snap). `progress` events
2482
- * still fire via applySize. Target is clamped to `[0, maxAxisSize]`.
2483
- */
2484
2567
  async dragTo(targetSize, velocityOrOpts = 0) {
2485
2568
  if (this.destroyed) return;
2486
2569
  const opts = typeof velocityOrOpts === "object" ? velocityOrOpts : { velocity: velocityOrOpts };
@@ -2510,28 +2593,13 @@ var BottomSheetEngine = class {
2510
2593
  }
2511
2594
  setAllowed(ids, snap) {
2512
2595
  if (this.destroyed) return;
2513
- const willSnap = snap !== void 0 && ids.includes(snap) || !ids.includes(this.activeId) && ids.length > 0;
2514
- if (!willSnap) {
2515
- this.animation.cancel();
2516
- this.newCycle();
2517
- }
2518
2596
  this.snaps.setAllowedIds(ids);
2519
2597
  this.updateAriaSlider();
2520
2598
  if (snap && ids.includes(snap)) {
2521
- void this.snapTo(
2522
- snap,
2523
- 0,
2524
- /* internal */
2525
- true
2526
- );
2599
+ void this.snapTo(snap, 0, true);
2527
2600
  } else if (!ids.includes(this.activeId)) {
2528
2601
  const fallback = ids[0];
2529
- if (fallback) void this.snapTo(
2530
- fallback,
2531
- 0,
2532
- /* internal */
2533
- true
2534
- );
2602
+ if (fallback) void this.snapTo(fallback, 0, true);
2535
2603
  }
2536
2604
  }
2537
2605
  setBackdropRange(range) {
@@ -2549,21 +2617,6 @@ var BottomSheetEngine = class {
2549
2617
  setScrimInteractive(interactive) {
2550
2618
  this.scrim.setScrimInteractive(interactive);
2551
2619
  }
2552
- /**
2553
- * Batch-update multiple scrim fields in one call. Apply order is:
2554
- * 1. `preset` — fills baseline color/blur/range/interactive.
2555
- * 2. `mode` — re-positions the scrim (fixed inset / clear / hide).
2556
- * 3. `enabled` — flips the opacity contribution on/off.
2557
- * 4. `tapToClose` — installs/tears the click listener.
2558
- * 5. `color` / `blur` / `interactive` / `range` — explicit overrides.
2559
- * 6. ONE `applySize` so the per-frame opacity stays consistent.
2560
- *
2561
- * Performance: collecting changes here (vs calling individual setters)
2562
- * avoids repeated `applySize` calls during a multi-field swap. Setters
2563
- * called here suppress their own internal `applySize` either by passing
2564
- * through fast-paths (mode/enabled no-op when value unchanged) or by
2565
- * being visual-only (color/blur/interactive don't influence opacity).
2566
- */
2567
2620
  setScrim(opts) {
2568
2621
  this.scrim.setScrim(opts, () => this.applySize(this.size));
2569
2622
  }
@@ -2579,11 +2632,79 @@ var BottomSheetEngine = class {
2579
2632
  setScrimOverlay(opts) {
2580
2633
  return this.scrim.setScrimOverlay(opts);
2581
2634
  }
2582
- /**
2583
- * Read-only snapshot of scrim state. Use the `setScrim*` setters to mutate.
2584
- * Stable public API — internals (e.g. cached opacity sentinels) are NOT
2585
- * exposed so consumers can't accidentally couple to ScrimController shape.
2586
- */
2635
+ addAnchor(opts) {
2636
+ if (this.destroyed || typeof document === "undefined") return () => {
2637
+ };
2638
+ if (!this.anchorHost) {
2639
+ this.anchorHost = this.rootEl ?? this.element.parentElement ?? document.body;
2640
+ this.anchorHost.style.setProperty("--bs-size", `${this.size}px`);
2641
+ this.anchorHost.style.setProperty(
2642
+ "--bs-progress",
2643
+ String(this.computeProgress(this.size))
2644
+ );
2645
+ }
2646
+ const handle = attachAnchor(
2647
+ {
2648
+ mode: this.mode,
2649
+ host: this.anchorHost,
2650
+ getState: () => ({
2651
+ activeId: this.activeId,
2652
+ size: this.size,
2653
+ progress: this.computeProgress(this.size)
2654
+ }),
2655
+ on: (event, fn) => this.on(event, fn),
2656
+ isDestroyed: () => this.destroyed
2657
+ },
2658
+ opts
2659
+ );
2660
+ handle.syncZ(this.stackZ + 1);
2661
+ this.anchors.push(handle);
2662
+ const detach = () => {
2663
+ const idx = this.anchors.indexOf(handle);
2664
+ if (idx === -1) return;
2665
+ this.anchors.splice(idx, 1);
2666
+ handle.detach();
2667
+ };
2668
+ this.teardowns.add(detach);
2669
+ return detach;
2670
+ }
2671
+ setScrimStages(opts) {
2672
+ if (this.destroyed || typeof document === "undefined") return () => {
2673
+ };
2674
+ this.detachScrimStages?.();
2675
+ this.detachScrimStages = null;
2676
+ if (!opts) return () => {
2677
+ };
2678
+ const host = this.screenComponent?.parentElement;
2679
+ if (!host) {
2680
+ console.warn(
2681
+ "[BottomSheet] setScrimStages: no scrim element mounted \u2014 pass `scrim` to the engine first."
2682
+ );
2683
+ return () => {
2684
+ };
2685
+ }
2686
+ const detachInstalled = installScrimStages(
2687
+ {
2688
+ mode: this.mode,
2689
+ host,
2690
+ getState: () => ({
2691
+ activeId: this.activeId,
2692
+ size: this.size,
2693
+ progress: this.computeProgress(this.size)
2694
+ }),
2695
+ on: (event, fn) => this.on(event, fn),
2696
+ isDestroyed: () => this.destroyed
2697
+ },
2698
+ opts
2699
+ );
2700
+ const detach = () => {
2701
+ if (this.detachScrimStages === detach) this.detachScrimStages = null;
2702
+ detachInstalled();
2703
+ };
2704
+ this.detachScrimStages = detach;
2705
+ this.teardowns.add(() => this.detachScrimStages?.());
2706
+ return detach;
2707
+ }
2587
2708
  getScrimState() {
2588
2709
  return { mode: this.scrim.scrimMode, enabled: this.scrim.scrimEnabled };
2589
2710
  }
@@ -2629,24 +2750,11 @@ var BottomSheetEngine = class {
2629
2750
  emit(event, payload) {
2630
2751
  this.bus.emit(event, payload);
2631
2752
  }
2632
- /**
2633
- * Abort the current cycle's signal and replace the controller. Returns the
2634
- * fresh signal so the caller can capture it locally before any await — when
2635
- * a future newCycle() / destroy aborts our controller, the captured signal
2636
- * flips to aborted and post-await guards short-circuit.
2637
- */
2638
2753
  newCycle() {
2639
2754
  this.currentAbort.abort();
2640
2755
  this.currentAbort = new AbortController();
2641
2756
  return this.currentAbort.signal;
2642
2757
  }
2643
- /**
2644
- * Fire `before-snap` and report whether a listener cancelled it. The
2645
- * `cancel()` callback exposed to listeners is frozen after the synchronous
2646
- * emit phase so async callers can't silently miss — they get a console
2647
- * warning. Returns `true` when cancelled so callers can early-return /
2648
- * spring-back without re-implementing the cancel/frozen pattern.
2649
- */
2650
2758
  emitBeforeSnap(target, previousId) {
2651
2759
  let cancelled = false;
2652
2760
  let frozen = false;
@@ -2680,59 +2788,54 @@ var BottomSheetEngine = class {
2680
2788
  this.snaps.setRaw(this.snapPointsRaw);
2681
2789
  this.scrim.invalidateOpacityCache();
2682
2790
  }
2683
- /**
2684
- * Memoised `allowedRange()` — delegates to SnapResolver which owns the cache.
2685
- * Engine wrapper kept so existing call sites don't all need updating;
2686
- * resolver-level memoization survives across method invocations.
2687
- */
2688
2791
  getAllowedRange() {
2689
2792
  return this.snaps.getAllowedRange();
2690
2793
  }
2691
- /**
2692
- * Write ARIA slider value attributes onto the handle so the engine itself
2693
- * is the source of truth for screen-reader announcements. Called from the
2694
- * constructor and from every code path that mutates `activeId` /
2695
- * `allowedIds`. WAI-ARIA 1.2 requires `aria-valuenow` for `role="slider"` —
2696
- * without it screen readers can't announce drag/snap progress to keyboard
2697
- * + AT users (WCAG 4.1.2 Name, Role, Value).
2698
- *
2699
- * Engine does NOT set `role="slider"` itself — that's the adapter's or
2700
- * consumer markup's responsibility. We only decorate with dynamic values.
2701
- *
2702
- * Empty allowed-list is malformed input (settable via `setAllowed([])`);
2703
- * short-circuit to remove ARIA value attributes so screen readers see a
2704
- * no-value slider rather than a degenerate `min=max=now=0` (WAI-ARIA 1.2
2705
- * requires `valuemax > valuemin` for `role="slider"`).
2706
- */
2794
+ allowedIdsBySize() {
2795
+ return this.snaps.getAllowedIds().slice().sort(
2796
+ (a, b) => (this.snaps.findById(a)?.size ?? 0) - (this.snaps.findById(b)?.size ?? 0)
2797
+ );
2798
+ }
2707
2799
  updateAriaSlider() {
2708
- this.aria.setValue(this.snaps.getAllowedIds(), this.activeId);
2800
+ this.aria.setValue(this.allowedIdsBySize(), this.activeId);
2709
2801
  }
2710
2802
  registerInStack() {
2711
2803
  this.teardowns.add(sheetStack.push({
2712
2804
  id: this.id,
2713
2805
  setZIndex: (z) => {
2806
+ this.stackZ = z;
2714
2807
  this.element.style.zIndex = String(z);
2715
2808
  if (this.backdrop) this.backdrop.style.zIndex = String(z - 1);
2809
+ for (const anchor of this.anchors) anchor.syncZ(z + 1);
2716
2810
  },
2717
2811
  setIsTop: (isTop) => {
2718
2812
  this.isTopSheet = isTop;
2719
2813
  if (this.backdrop) {
2720
2814
  this.backdrop.style.display = isTop ? "" : "none";
2721
2815
  }
2722
- }
2816
+ },
2817
+ isOpen: () => this.size > 0,
2818
+ setDepth: (depth) => this.applyStackDepth(depth)
2723
2819
  }));
2724
2820
  }
2821
+ applyStackDepth(depth) {
2822
+ if (!this.stackEffectEnabled) return;
2823
+ this.element.setAttribute("data-stack-depth", String(depth));
2824
+ if (!this.stackEffectPrimed) {
2825
+ this.stackEffectPrimed = true;
2826
+ const isVertical = this.mode === "bottom" || this.mode === "top";
2827
+ this.element.style.transformOrigin = isVertical ? "50% 100%" : "0% 50%";
2828
+ this.element.style.transition = "scale 320ms cubic-bezier(0.29, 1.04, 0.84, 0.99)";
2829
+ }
2830
+ const scale = Math.max(1 - depth * 0.04, 0.86);
2831
+ this.element.style.scale = scale === 1 ? "" : String(scale);
2832
+ }
2725
2833
  attach() {
2726
2834
  this.gesture = new GestureController({
2727
2835
  handle: this.handle,
2728
2836
  element: this.element,
2729
2837
  mode: this.mode,
2730
2838
  getRoot: () => this.rootEl,
2731
- // Single coalesced reader — narrows the DI surface and removes 3 getter
2732
- // dispatches per onMove frame at 60-120Hz. Returns a SHARED buffer
2733
- // (this.dragContextBuf) — see field JSDoc for the retention contract.
2734
- // Inner `range` object is mutated in place too so callers caching its
2735
- // identity between frames stay valid.
2736
2839
  getDragContext: () => {
2737
2840
  const buf = this.dragContextBuf;
2738
2841
  const r = this.getAllowedRange();
@@ -2786,7 +2889,7 @@ var BottomSheetEngine = class {
2786
2889
  container: this.scrollContainer,
2787
2890
  isDragging: () => this.gesture?.isDragging ?? false,
2788
2891
  isAnimating: () => this.animation.isAnimating,
2789
- getAllowedIds: () => this.snaps.getAllowedIds(),
2892
+ getAllowedIds: () => this.allowedIdsBySize(),
2790
2893
  getActiveId: () => this.activeId,
2791
2894
  snapTo: (id) => {
2792
2895
  void this.snapTo(id);
@@ -2815,11 +2918,13 @@ var BottomSheetEngine = class {
2815
2918
  },
2816
2919
  newCycle: () => {
2817
2920
  this.newCycle();
2818
- }
2921
+ },
2922
+ isAnimating: () => this.animation.isAnimating,
2923
+ resyncAfterCancel: () => this.resyncAfterResize()
2819
2924
  }));
2820
2925
  if (this.lifecycle.closeOnEscape) {
2821
2926
  const onKey = (e) => {
2822
- if (this.destroyed) return;
2927
+ if (this.destroyed || e.defaultPrevented) return;
2823
2928
  if (e.key === "Escape" && this.isTopSheet && this.size > 0) {
2824
2929
  void this.close();
2825
2930
  }
@@ -2851,7 +2956,7 @@ var BottomSheetEngine = class {
2851
2956
  handle: this.handle,
2852
2957
  mode: this.mode,
2853
2958
  isDestroyed: () => this.destroyed,
2854
- getAllowedIds: () => this.snaps.getAllowedIds(),
2959
+ getAllowedIds: () => this.allowedIdsBySize(),
2855
2960
  getActiveId: () => this.activeId,
2856
2961
  snapTo: (id) => {
2857
2962
  void this.snapTo(id);
@@ -2872,6 +2977,7 @@ var BottomSheetEngine = class {
2872
2977
  });
2873
2978
  if (!target) return;
2874
2979
  const previousId = this.activeId;
2980
+ if (target.id === previousId && target.size === this.size) return;
2875
2981
  if (this.emitBeforeSnap(target, previousId)) {
2876
2982
  const restore = this.snaps.findById(previousId);
2877
2983
  if (restore) {
@@ -2880,6 +2986,7 @@ var BottomSheetEngine = class {
2880
2986
  return;
2881
2987
  }
2882
2988
  const previousSize = this.size;
2989
+ const previousSnapSize = this.snaps.findById(previousId)?.size;
2883
2990
  this.scrollCache.cache(previousId, previousSize, target.size);
2884
2991
  this.activeId = target.id;
2885
2992
  void this.animation.animateTo(target.size, velocity).then(() => {
@@ -2888,9 +2995,10 @@ var BottomSheetEngine = class {
2888
2995
  this.updateAriaSlider();
2889
2996
  this.emit("snap", { id: target.id, size: target.size });
2890
2997
  this.haptic();
2891
- if (previousId === "closed" && target.size > 0) {
2998
+ if (previousSnapSize === 0 && target.size > 0) {
2892
2999
  this.emit("open", { id: target.id });
2893
3000
  this.handleOpen();
3001
+ notifyLinkedSheets(this.linkedSheets, this);
2894
3002
  }
2895
3003
  if (target.size === 0) {
2896
3004
  this.emit("close", void 0);
@@ -2898,7 +3006,6 @@ var BottomSheetEngine = class {
2898
3006
  }
2899
3007
  });
2900
3008
  }
2901
- /** Trigger a brief haptic tick (iOS Safari + Android Chrome support). */
2902
3009
  haptic() {
2903
3010
  if (typeof navigator !== "undefined" && "vibrate" in navigator) {
2904
3011
  try {
@@ -2914,6 +3021,12 @@ var BottomSheetEngine = class {
2914
3021
  style.transform = this.transformTemplate(offset);
2915
3022
  if (this.sizeWriteSentinel.shouldWrite(size, SIZE_WRITE_EPSILON)) {
2916
3023
  style.setProperty("--bs-size", `${size}px`);
3024
+ if (this.scrimParent) {
3025
+ this.scrimParent.style.setProperty("--bs-size", `${size}px`);
3026
+ }
3027
+ if (this.anchorHost && this.anchorHost !== this.scrimParent) {
3028
+ this.anchorHost.style.setProperty("--bs-size", `${size}px`);
3029
+ }
2917
3030
  }
2918
3031
  const progress = this.computeProgress(size);
2919
3032
  const progressChanged = this.progressWriteSentinel.shouldWrite(
@@ -2922,6 +3035,9 @@ var BottomSheetEngine = class {
2922
3035
  );
2923
3036
  if (progressChanged) {
2924
3037
  style.setProperty("--bs-progress", String(progress));
3038
+ if (this.anchorHost) {
3039
+ this.anchorHost.style.setProperty("--bs-progress", String(progress));
3040
+ }
2925
3041
  }
2926
3042
  this.scrim.applyOpacity(progress, progressChanged);
2927
3043
  if (progressChanged && this.bus.listenerCount("progress") > 0) {
@@ -2936,6 +3052,7 @@ var BottomSheetEngine = class {
2936
3052
  return Math.min(Math.max((size - min) / (max - min), 0), 1);
2937
3053
  }
2938
3054
  handleOpen() {
3055
+ sheetStack.promote(this.id);
2939
3056
  try {
2940
3057
  this.lifecycle.install();
2941
3058
  } catch (err) {
@@ -2949,8 +3066,24 @@ var BottomSheetEngine = class {
2949
3066
  });
2950
3067
  }
2951
3068
  }
3069
+ resyncAfterResize() {
3070
+ if (this.destroyed) return;
3071
+ const target = this.snaps.findById(this.activeId);
3072
+ if (!target) return;
3073
+ this.updateAriaSlider();
3074
+ this.emit("snap", { id: target.id, size: target.size });
3075
+ if (target.size > 0 && !this.lifecycle.isInstalled) {
3076
+ this.emit("open", { id: target.id });
3077
+ this.handleOpen();
3078
+ notifyLinkedSheets(this.linkedSheets, this);
3079
+ } else if (target.size === 0 && this.lifecycle.isInstalled) {
3080
+ this.emit("close", void 0);
3081
+ this.handleClose();
3082
+ }
3083
+ }
2952
3084
  handleClose() {
2953
3085
  this.lifecycle.release();
3086
+ sheetStack.update();
2954
3087
  }
2955
3088
  };
2956
3089
 
@@ -2984,7 +3117,7 @@ function useBottomSheet(opts) {
2984
3117
  handle: handleRef.current ?? void 0,
2985
3118
  scrollContainer: contentRef.current ?? void 0,
2986
3119
  backdrop: backdropRef.current ?? void 0,
2987
- screenComponent: screenRef.current ?? void 0
3120
+ scrim: screenRef.current ?? void 0
2988
3121
  });
2989
3122
  engineRef.current = engine;
2990
3123
  cachedSnapshotRef.current = { ...engine.state };
@@ -3044,6 +3177,16 @@ function useBottomSheet(opts) {
3044
3177
  engineRef.current?.setAllowed(ids, snap);
3045
3178
  }, []);
3046
3179
  const getEngine = react.useCallback(() => engineRef.current, []);
3180
+ const addAnchor = react.useCallback(
3181
+ (anchorOpts) => engineRef.current?.addAnchor(anchorOpts) ?? (() => {
3182
+ }),
3183
+ []
3184
+ );
3185
+ const setScrimStages = react.useCallback(
3186
+ (stagesOpts) => engineRef.current?.setScrimStages(stagesOpts) ?? (() => {
3187
+ }),
3188
+ []
3189
+ );
3047
3190
  return react.useMemo(
3048
3191
  () => ({
3049
3192
  sheetRef,
@@ -3056,10 +3199,12 @@ function useBottomSheet(opts) {
3056
3199
  open,
3057
3200
  close,
3058
3201
  setAllowed,
3202
+ addAnchor,
3203
+ setScrimStages,
3059
3204
  engine: engineRef.current,
3060
3205
  getEngine
3061
3206
  }),
3062
- [state, snapTo, open, close, setAllowed, getEngine]
3207
+ [state, snapTo, open, close, setAllowed, addAnchor, setScrimStages, getEngine]
3063
3208
  );
3064
3209
  }
3065
3210
  var useHasMounted = () => {
@@ -3079,6 +3224,8 @@ var BottomSheet = react.forwardRef(
3079
3224
  leftButton,
3080
3225
  rightButton,
3081
3226
  screen,
3227
+ anchors,
3228
+ scrimStages,
3082
3229
  onChange,
3083
3230
  ariaLabel = "Bottom sheet",
3084
3231
  ariaLabelledBy,
@@ -3099,10 +3246,7 @@ var BottomSheet = react.forwardRef(
3099
3246
  setAllowed,
3100
3247
  getEngine
3101
3248
  } = useBottomSheet(engineOpts);
3102
- const isVerticalAxis = (engineOpts.mode ?? "bottom") === "bottom" || (engineOpts.mode ?? "bottom") === "top";
3103
3249
  const ariaModal = engineOpts.focusTrap === true;
3104
- const allowedIds = engineOpts.allowed ?? engineOpts.snapPoints.map((p) => p.id);
3105
- const activeIdx = allowedIds.indexOf(state.activeId);
3106
3250
  const lastAnnouncedRef = react.useRef("");
3107
3251
  const [announce, setAnnounce] = react.useState("");
3108
3252
  react.useEffect(() => {
@@ -3118,6 +3262,15 @@ var BottomSheet = react.forwardRef(
3118
3262
  open,
3119
3263
  close,
3120
3264
  setAllowed,
3265
+ setSnapPoints: (points, allowed) => getEngine()?.setSnapPoints(points, allowed),
3266
+ setScrim: (opts) => getEngine()?.setScrim(opts),
3267
+ setScrimOverlay: (opts) => getEngine()?.setScrimOverlay(opts) ?? (() => {
3268
+ }),
3269
+ addAnchor: (opts) => getEngine()?.addAnchor(opts) ?? (() => {
3270
+ }),
3271
+ setScrimStages: (opts) => getEngine()?.setScrimStages(opts) ?? (() => {
3272
+ }),
3273
+ getEngine,
3121
3274
  get state() {
3122
3275
  return getEngine()?.state ?? state;
3123
3276
  }
@@ -3127,6 +3280,61 @@ var BottomSheet = react.forwardRef(
3127
3280
  react.useEffect(() => {
3128
3281
  onChange?.(state);
3129
3282
  }, [state, onChange]);
3283
+ const [anchorHosts, setAnchorHosts] = react.useState([]);
3284
+ const anchorsKey = anchors ? JSON.stringify(
3285
+ anchors.map((a) => [
3286
+ a.position,
3287
+ a.inset,
3288
+ Array.isArray(a.showOn) ? a.showOn : a.showOn ? "fn" : null,
3289
+ a.fadeRange,
3290
+ a.interactive,
3291
+ typeof a.animation === "string" ? a.animation : a.animation ? "custom" : null
3292
+ ])
3293
+ ) : "";
3294
+ react.useEffect(() => {
3295
+ const engine = getEngine();
3296
+ if (!engine || !anchors || anchors.length === 0) return;
3297
+ const hosts = [];
3298
+ const detachers = anchors.map((a) => {
3299
+ const host = document.createElement("div");
3300
+ hosts.push(host);
3301
+ const { node: _node, ...rest } = a;
3302
+ return engine.addAnchor({ ...rest, element: host });
3303
+ });
3304
+ setAnchorHosts(hosts);
3305
+ return () => {
3306
+ detachers.forEach((d) => d());
3307
+ setAnchorHosts([]);
3308
+ };
3309
+ }, [getEngine, anchorsKey]);
3310
+ const [stageHosts, setStageHosts] = react.useState([]);
3311
+ const stagesKey = scrimStages ? JSON.stringify(
3312
+ scrimStages.stages.map((s) => [
3313
+ s.for,
3314
+ s.forRange,
3315
+ s.position,
3316
+ s.inset,
3317
+ s.interactive,
3318
+ typeof s.animation === "string" ? s.animation : s.animation ? "custom" : null
3319
+ ])
3320
+ ) : "";
3321
+ react.useEffect(() => {
3322
+ const engine = getEngine();
3323
+ if (!engine || !scrimStages || scrimStages.stages.length === 0) return;
3324
+ const hosts = [];
3325
+ const defs = scrimStages.stages.map((s) => {
3326
+ const host = document.createElement("div");
3327
+ hosts.push(host);
3328
+ const { node: _node, ...rest } = s;
3329
+ return { ...rest, element: host };
3330
+ });
3331
+ const detach = engine.setScrimStages({ ...scrimStages, stages: defs });
3332
+ setStageHosts(hosts);
3333
+ return () => {
3334
+ detach();
3335
+ setStageHosts([]);
3336
+ };
3337
+ }, [getEngine, stagesKey]);
3130
3338
  if (noSSR && !mounted) return null;
3131
3339
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "bs-root", children: [
3132
3340
  backdrop && /* @__PURE__ */ jsxRuntime.jsx(
@@ -3137,7 +3345,7 @@ var BottomSheet = react.forwardRef(
3137
3345
  onClick: closeOnBackdrop ? () => void close() : void 0
3138
3346
  }
3139
3347
  ),
3140
- screen && /* @__PURE__ */ jsxRuntime.jsx(
3348
+ /* @__PURE__ */ jsxRuntime.jsx(
3141
3349
  "div",
3142
3350
  {
3143
3351
  ref: screenRef,
@@ -3167,11 +3375,6 @@ var BottomSheet = react.forwardRef(
3167
3375
  className: "bs-handle",
3168
3376
  role: "slider",
3169
3377
  tabIndex: 0,
3170
- "aria-orientation": isVerticalAxis ? "vertical" : "horizontal",
3171
- "aria-valuemin": 0,
3172
- "aria-valuemax": Math.max(0, allowedIds.length - 1),
3173
- "aria-valuenow": Math.max(0, activeIdx),
3174
- "aria-valuetext": state.activeId,
3175
3378
  "aria-label": "Resize sheet",
3176
3379
  children: typeof header === "function" ? header(state) : header
3177
3380
  }
@@ -3190,6 +3393,12 @@ var BottomSheet = react.forwardRef(
3190
3393
  /* @__PURE__ */ jsxRuntime.jsx("span", { className: "bs-sr-only", role: "status", "aria-live": "polite", children: announce })
3191
3394
  ]
3192
3395
  }
3396
+ ),
3397
+ anchorHosts.map(
3398
+ (host, i) => anchors?.[i] ? reactDom.createPortal(anchors[i].node, host) : null
3399
+ ),
3400
+ stageHosts.map(
3401
+ (host, i) => scrimStages?.stages[i] ? reactDom.createPortal(scrimStages.stages[i].node, host) : null
3193
3402
  )
3194
3403
  ] });
3195
3404
  }
@@ -3260,8 +3469,6 @@ var OverlayEngine = class {
3260
3469
  __publicField(this, "exitEasing");
3261
3470
  __publicField(this, "backdropOpacity");
3262
3471
  __publicField(this, "backdropFilter");
3263
- // Definite-assignment: written via assignSwipeFromConfig in the
3264
- // constructor — TS can't follow the helper, mark the assertion explicit.
3265
3472
  __publicField(this, "swipeToCloseEnabled");
3266
3473
  __publicField(this, "swipeThreshold");
3267
3474
  __publicField(this, "swipeVelocityThreshold");
@@ -3276,18 +3483,16 @@ var OverlayEngine = class {
3276
3483
  __publicField(this, "closeOnOutsidePointerEnabled");
3277
3484
  __publicField(this, "detachOutsidePointer", null);
3278
3485
  __publicField(this, "detachSwipe", null);
3486
+ __publicField(this, "respectReducedMotionEnabled");
3487
+ __publicField(this, "isTop", true);
3488
+ __publicField(this, "backPushed", false);
3489
+ __publicField(this, "movedToTarget", false);
3279
3490
  __publicField(this, "onOpenCb");
3280
3491
  __publicField(this, "onCloseCb");
3281
3492
  __publicField(this, "isOpen_", false);
3282
3493
  __publicField(this, "isAnimating_", false);
3283
3494
  __publicField(this, "destroyed", false);
3284
- // Tracks whether setOverlayChildren ever populated the panel — lets
3285
- // clearOverlayChildren skip the DOM mutation when nothing was injected
3286
- // (so the field also acts as a zero-allocation feature flag).
3287
3495
  __publicField(this, "hasInjectedChildren", false);
3288
- // Bumped on every open/close. awaitTransition captures it and ignores
3289
- // transitionend/timeout fired by older cycles — prevents cross-cycle
3290
- // listener bleed during rapid toggling.
3291
3496
  __publicField(this, "cycleNonce", 0);
3292
3497
  __publicField(this, "bus", createEventBus());
3293
3498
  __publicField(this, "detachEscape", null);
@@ -3318,6 +3523,7 @@ var OverlayEngine = class {
3318
3523
  this.mountTarget = opts.mountTo;
3319
3524
  this.peekRaw = opts.peek;
3320
3525
  this.closeOnOutsidePointerEnabled = opts.closeOnOutsidePointer ?? false;
3526
+ this.respectReducedMotionEnabled = opts.respectReducedMotion ?? true;
3321
3527
  this.onOpenCb = opts.onOpen;
3322
3528
  this.onCloseCb = opts.onClose;
3323
3529
  if (this.peekRaw !== void 0 && this.edge !== "bottom") {
@@ -3340,9 +3546,6 @@ var OverlayEngine = class {
3340
3546
  closeOnEscape: this.closeOnEscape,
3341
3547
  lockBodyScroll: this.bodyScrollLockEnabled,
3342
3548
  inertSiblings: this.inertSiblingsEnabled,
3343
- // Overlay-specific guard: only paint body siblings inert when the
3344
- // overlay element is actually a body descendant. Detached / shadow-
3345
- // DOM / portal overlays would otherwise lock unrelated trees.
3346
3549
  shouldApplyInertSiblings: () => {
3347
3550
  const bodyChildren = Array.from(document.body.children);
3348
3551
  return bodyChildren.some(
@@ -3368,15 +3571,17 @@ var OverlayEngine = class {
3368
3571
  this.emit("before-open", void 0);
3369
3572
  this.isOpen_ = true;
3370
3573
  this.isAnimating_ = true;
3574
+ sheetStack.promote(this.id);
3371
3575
  this.element.removeAttribute("hidden");
3372
3576
  this.element.setAttribute("data-state", "entering");
3373
3577
  if (this.focusTrapEnabled) {
3374
3578
  this.element.setAttribute("aria-modal", "true");
3375
3579
  }
3376
- const transitionStr = `transform ${this.duration}ms ${this.enterEasing}, opacity ${this.duration}ms ease-out`;
3580
+ const dur = this.effectiveDuration();
3581
+ const transitionStr = `transform ${dur}ms ${this.enterEasing}, opacity ${dur}ms ease-out`;
3377
3582
  this.element.style.transition = transitionStr;
3378
3583
  if (this.backdrop) {
3379
- this.backdrop.style.transition = `opacity ${this.duration}ms ease-out`;
3584
+ this.backdrop.style.transition = `opacity ${dur}ms ease-out`;
3380
3585
  }
3381
3586
  const cycle = ++this.cycleNonce;
3382
3587
  return new Promise((resolve) => {
@@ -3388,7 +3593,7 @@ var OverlayEngine = class {
3388
3593
  this.element.style.opacity = "1";
3389
3594
  if (this.backdrop) {
3390
3595
  this.backdrop.style.opacity = String(this.backdropOpacity);
3391
- this.backdrop.style.pointerEvents = "auto";
3596
+ this.backdrop.style.pointerEvents = this.backdropOpacity > 0 ? "auto" : "none";
3392
3597
  if (this.backdropFilter) {
3393
3598
  this.backdrop.style.backdropFilter = this.backdropFilter;
3394
3599
  this.backdrop.style.webkitBackdropFilter = this.backdropFilter;
@@ -3424,7 +3629,10 @@ var OverlayEngine = class {
3424
3629
  });
3425
3630
  return resolve();
3426
3631
  }
3427
- this.awaitTransition(cycle).then(() => {
3632
+ this.awaitTransition(
3633
+ cycle,
3634
+ this.enterAnimation === "fade" ? "opacity" : "transform"
3635
+ ).then(() => {
3428
3636
  if (this.destroyed || cycle !== this.cycleNonce) return resolve();
3429
3637
  this.isAnimating_ = false;
3430
3638
  this.element.setAttribute("data-state", "open");
@@ -3441,11 +3649,14 @@ var OverlayEngine = class {
3441
3649
  this.emit("before-close", void 0);
3442
3650
  this.isOpen_ = false;
3443
3651
  this.isAnimating_ = true;
3652
+ sheetStack.update();
3653
+ this.popBackMarker(reason);
3444
3654
  this.element.setAttribute("data-state", "exiting");
3445
- const transitionStr = `transform ${this.duration}ms ${this.exitEasing}, opacity ${this.duration}ms ease-in`;
3655
+ const dur = this.effectiveDuration();
3656
+ const transitionStr = `transform ${dur}ms ${this.exitEasing}, opacity ${dur}ms ease-in`;
3446
3657
  this.element.style.transition = transitionStr;
3447
3658
  if (this.backdrop) {
3448
- this.backdrop.style.transition = `opacity ${this.duration}ms ease-in`;
3659
+ this.backdrop.style.transition = `opacity ${dur}ms ease-in`;
3449
3660
  }
3450
3661
  const cycle = ++this.cycleNonce;
3451
3662
  return new Promise((resolve) => {
@@ -3456,7 +3667,7 @@ var OverlayEngine = class {
3456
3667
  this.exitAnimation,
3457
3668
  this.peekPx
3458
3669
  );
3459
- this.element.style.opacity = "0";
3670
+ this.element.style.opacity = this.peekPx !== void 0 ? "1" : "0";
3460
3671
  if (this.backdrop) {
3461
3672
  this.backdrop.style.opacity = "0";
3462
3673
  this.backdrop.style.pointerEvents = "none";
@@ -3466,7 +3677,10 @@ var OverlayEngine = class {
3466
3677
  }
3467
3678
  }
3468
3679
  this.releaseInteractiveListeners();
3469
- this.awaitTransition(cycle).then(() => {
3680
+ this.awaitTransition(
3681
+ cycle,
3682
+ this.exitAnimation === "fade" ? "opacity" : "transform"
3683
+ ).then(() => {
3470
3684
  if (this.destroyed || cycle !== this.cycleNonce) return resolve();
3471
3685
  this.isAnimating_ = false;
3472
3686
  if (this.peekPx === void 0) {
@@ -3494,14 +3708,12 @@ var OverlayEngine = class {
3494
3708
  this.releaseInteractiveListeners();
3495
3709
  this.releaseStackEntry?.();
3496
3710
  this.releaseStackEntry = null;
3711
+ const hadOriginalParent = this.originalParent !== null;
3497
3712
  this.restoreMount();
3713
+ if (this.movedToTarget && !hadOriginalParent && this.element.parentNode) {
3714
+ this.element.parentNode.removeChild(this.element);
3715
+ }
3498
3716
  }
3499
- /**
3500
- * Runtime visual setter — opacity is applied immediately when open so a
3501
- * live-controls panel can preview the change without re-opening. When
3502
- * closed we only stash the field; next open() picks it up so we don't
3503
- * mutate hidden DOM (avoids paint thrash on detached panels).
3504
- */
3505
3717
  setBackdropOpacity(opacity) {
3506
3718
  if (this.destroyed) return;
3507
3719
  this.backdropOpacity = opacity;
@@ -3509,11 +3721,6 @@ var OverlayEngine = class {
3509
3721
  this.backdrop.style.opacity = String(opacity);
3510
3722
  }
3511
3723
  }
3512
- /**
3513
- * Runtime visual setter for `backdrop-filter`. `undefined` clears the
3514
- * filter (mirrors the close() behaviour). Defers to next open() when the
3515
- * overlay is currently closed.
3516
- */
3517
3724
  setBackdropFilter(filter) {
3518
3725
  if (this.destroyed) return;
3519
3726
  this.backdropFilter = filter;
@@ -3527,12 +3734,6 @@ var OverlayEngine = class {
3527
3734
  style.webkitBackdropFilter = "";
3528
3735
  }
3529
3736
  }
3530
- /**
3531
- * Toggle / reconfigure swipe-to-close at runtime. Re-resolves the flat
3532
- * threshold fields, then installs or tears down the pointer listener so
3533
- * the change applies to the CURRENT open cycle. Idempotent: safe to call
3534
- * with the same config repeatedly.
3535
- */
3536
3737
  setSwipeToClose(config) {
3537
3738
  if (this.destroyed) return;
3538
3739
  const wasEnabled = this.swipeToCloseEnabled;
@@ -3545,7 +3746,6 @@ var OverlayEngine = class {
3545
3746
  this.detachSwipe = null;
3546
3747
  }
3547
3748
  }
3548
- /** Animation setters only stage the field — applied on next open/close. */
3549
3749
  setEnterAnimation(animation) {
3550
3750
  if (this.destroyed) return;
3551
3751
  this.enterAnimation = animation;
@@ -3554,23 +3754,15 @@ var OverlayEngine = class {
3554
3754
  if (this.destroyed) return;
3555
3755
  this.exitAnimation = animation;
3556
3756
  }
3557
- /** Updates the field used at next close() — no immediate side effect. */
3558
3757
  setReturnFocus(target) {
3559
3758
  if (this.destroyed) return;
3560
3759
  this.lifecycle.setReturnFocus(target);
3561
3760
  }
3562
- /**
3563
- * Batch update — preset first, then individual overrides. Applied as a
3564
- * single DOM-write phase when open so live-controls don't trigger
3565
- * cascading reflows. Safe to call when closed (defers to next open).
3566
- */
3567
3761
  setOverlay(opts) {
3568
3762
  if (this.destroyed) return;
3569
3763
  const preset = opts.preset ? OVERLAY_PRESETS[opts.preset] : void 0;
3570
3764
  const merged = preset ? {
3571
3765
  backdropOpacity: preset.backdropOpacity,
3572
- // Preset's `undefined` filter means "no filter" — surface as null
3573
- // so the merged step clears the field instead of inheriting old.
3574
3766
  backdropFilter: preset.backdropFilter === void 0 ? null : preset.backdropFilter,
3575
3767
  swipeToClose: preset.swipeToClose,
3576
3768
  enterAnimation: preset.enterAnimation,
@@ -3621,6 +3813,36 @@ var OverlayEngine = class {
3621
3813
  }
3622
3814
  }
3623
3815
  }
3816
+ if (opts.duration !== void 0) this.duration = opts.duration;
3817
+ if (opts.enterEasing !== void 0) this.enterEasing = opts.enterEasing;
3818
+ if (opts.exitEasing !== void 0) this.exitEasing = opts.exitEasing;
3819
+ if (opts.closeOnEscape !== void 0) {
3820
+ this.closeOnEscape = opts.closeOnEscape;
3821
+ if (!opts.closeOnEscape) {
3822
+ this.detachEscape?.();
3823
+ this.detachEscape = null;
3824
+ } else if (this.isOpen_) {
3825
+ this.installInteractiveListeners();
3826
+ }
3827
+ }
3828
+ if (opts.closeOnBackdrop !== void 0) {
3829
+ this.closeOnBackdropEnabled = opts.closeOnBackdrop;
3830
+ if (!opts.closeOnBackdrop) {
3831
+ this.detachBackdrop?.();
3832
+ this.detachBackdrop = null;
3833
+ } else if (this.isOpen_) {
3834
+ this.installInteractiveListeners();
3835
+ }
3836
+ }
3837
+ if (opts.closeOnOutsidePointer !== void 0) {
3838
+ this.closeOnOutsidePointerEnabled = opts.closeOnOutsidePointer;
3839
+ if (!opts.closeOnOutsidePointer) {
3840
+ this.detachOutsidePointer?.();
3841
+ this.detachOutsidePointer = null;
3842
+ } else if (this.isOpen_) {
3843
+ this.installInteractiveListeners();
3844
+ }
3845
+ }
3624
3846
  if (opts.children !== void 0) {
3625
3847
  if (opts.children === null) {
3626
3848
  this.clearOverlayChildren();
@@ -3629,30 +3851,17 @@ var OverlayEngine = class {
3629
3851
  }
3630
3852
  }
3631
3853
  }
3632
- /**
3633
- * Runtime children injection — replace the panel's contents without
3634
- * unmounting the engine. `replaceChildren()` is a single atomic DOM op,
3635
- * so callers can swap content rapidly without intermediate flicker.
3636
- * Function form is resolved exactly ONCE per call (cheap lazy content).
3637
- */
3638
3854
  setOverlayChildren(children) {
3639
3855
  if (this.destroyed) return;
3640
3856
  const node = typeof children === "function" ? children() : children;
3641
3857
  this.element.replaceChildren(node);
3642
3858
  this.hasInjectedChildren = true;
3643
3859
  }
3644
- /**
3645
- * Clear injected children. No-op when nothing was injected so consumers
3646
- * who never used setOverlayChildren can call this defensively without
3647
- * paying for a DOM mutation.
3648
- */
3649
3860
  clearOverlayChildren() {
3650
3861
  if (this.destroyed || !this.hasInjectedChildren) return;
3651
3862
  this.element.replaceChildren();
3652
3863
  this.hasInjectedChildren = false;
3653
3864
  }
3654
- // Shared swipe-config normalizer — used by constructor, setSwipeToClose,
3655
- // and setOverlay so the union/object shape is decoded in exactly one place.
3656
3865
  assignSwipeFromConfig(swipe) {
3657
3866
  if (typeof swipe === "object" && swipe !== null) {
3658
3867
  this.swipeToCloseEnabled = swipe.enabled ?? true;
@@ -3667,6 +3876,22 @@ var OverlayEngine = class {
3667
3876
  emit(event, payload) {
3668
3877
  this.bus.emit(event, payload);
3669
3878
  }
3879
+ effectiveDuration() {
3880
+ return this.respectReducedMotionEnabled && prefersReducedMotion() ? 0 : this.duration;
3881
+ }
3882
+ popBackMarker(reason) {
3883
+ if (!this.backPushed) return;
3884
+ this.backPushed = false;
3885
+ if (reason === "back") return;
3886
+ const state = history.state;
3887
+ if (state && state.__bsOverlay === this.id) {
3888
+ try {
3889
+ history.back();
3890
+ } catch {
3891
+ return;
3892
+ }
3893
+ }
3894
+ }
3670
3895
  resolvePeekPx(raw) {
3671
3896
  if (typeof raw === "number") return Math.max(0, raw);
3672
3897
  try {
@@ -3688,6 +3913,7 @@ var OverlayEngine = class {
3688
3913
  this.originalParent = this.element.parentNode;
3689
3914
  this.originalNextSibling = this.element.nextSibling;
3690
3915
  target.appendChild(this.element);
3916
+ this.movedToTarget = true;
3691
3917
  this.mounted = true;
3692
3918
  }
3693
3919
  restoreMount() {
@@ -3721,7 +3947,7 @@ var OverlayEngine = class {
3721
3947
  this.backdrop.style.willChange = "opacity";
3722
3948
  }
3723
3949
  }
3724
- awaitTransition(cycle) {
3950
+ awaitTransition(cycle, property = "transform") {
3725
3951
  return new Promise((resolve) => {
3726
3952
  let done = false;
3727
3953
  const finish = () => {
@@ -3736,10 +3962,10 @@ var OverlayEngine = class {
3736
3962
  finish();
3737
3963
  return;
3738
3964
  }
3739
- if (e.propertyName === "transform") finish();
3965
+ if (e.target === this.element && e.propertyName === property) finish();
3740
3966
  };
3741
3967
  this.element.addEventListener("transitionend", onEnd);
3742
- const timer = window.setTimeout(finish, this.duration + 50);
3968
+ const timer = window.setTimeout(finish, this.effectiveDuration() + 50);
3743
3969
  });
3744
3970
  }
3745
3971
  installInteractiveListeners() {
@@ -3749,7 +3975,10 @@ var OverlayEngine = class {
3749
3975
  }
3750
3976
  if (this.closeOnEscape && !this.detachEscape) {
3751
3977
  const handler = (e) => {
3752
- if (e.key === "Escape" && this.isOpen_) void this.close("escape");
3978
+ if (e.defaultPrevented) return;
3979
+ if (e.key === "Escape" && this.isOpen_ && this.isTop) {
3980
+ void this.close("escape");
3981
+ }
3753
3982
  };
3754
3983
  document.addEventListener("keydown", handler);
3755
3984
  this.detachEscape = () => document.removeEventListener("keydown", handler);
@@ -3762,10 +3991,14 @@ var OverlayEngine = class {
3762
3991
  if (this.closeOnBackEnabled && typeof window !== "undefined" && !this.detachHardwareBack) {
3763
3992
  try {
3764
3993
  history.pushState({ __bsOverlay: this.id }, "");
3994
+ this.backPushed = true;
3765
3995
  } catch {
3766
3996
  }
3767
3997
  const onPop = () => {
3768
- if (this.isOpen_) void this.close("back");
3998
+ if (this.isOpen_ && this.isTop) {
3999
+ this.backPushed = false;
4000
+ void this.close("back");
4001
+ }
3769
4002
  };
3770
4003
  window.addEventListener("popstate", onPop);
3771
4004
  this.detachHardwareBack = () => window.removeEventListener("popstate", onPop);
@@ -3796,55 +4029,57 @@ var OverlayEngine = class {
3796
4029
  this.detachOutsidePointer = null;
3797
4030
  this.lifecycle.release();
3798
4031
  }
3799
- /**
3800
- * Light pointer-driven dismiss. Tracks finger along the edge axis, follows
3801
- * with translate, dismisses if past `swipeThreshold` (fraction of panel
3802
- * size) or release velocity exceeds `swipeVelocityThreshold` (px/ms).
3803
- * Pure CSS-transform during drag — no rAF or physics required since the
3804
- * panel only moves one direction.
3805
- */
3806
4032
  installSwipeToClose() {
3807
4033
  const el = this.element;
3808
4034
  const edge = this.edge;
3809
4035
  const isVertical = edge === "bottom" || edge === "top";
3810
4036
  const sign = edge === "bottom" || edge === "right" ? 1 : -1;
3811
- const threshold = this.swipeThreshold;
3812
- const velocityThreshold = this.swipeVelocityThreshold;
4037
+ const SLOP = 8;
3813
4038
  let startX = 0;
3814
4039
  let startY = 0;
3815
4040
  let startT = 0;
4041
+ let tracking = false;
3816
4042
  let dragging = false;
3817
4043
  let pointerId = null;
4044
+ const previousTouchAction = el.style.touchAction;
4045
+ el.style.touchAction = isVertical ? "pan-x" : "pan-y";
3818
4046
  const onDown = (e) => {
3819
4047
  if (e.button !== void 0 && e.button !== 0) return;
3820
4048
  pointerId = e.pointerId;
3821
4049
  startX = e.clientX;
3822
4050
  startY = e.clientY;
3823
4051
  startT = performance.now();
3824
- dragging = true;
3825
- el.style.transition = "none";
3826
- try {
3827
- el.setPointerCapture(e.pointerId);
3828
- } catch {
3829
- }
4052
+ tracking = true;
4053
+ dragging = false;
3830
4054
  };
3831
4055
  const onMove = (e) => {
3832
- if (!dragging || e.pointerId !== pointerId) return;
4056
+ if (!tracking || e.pointerId !== pointerId) return;
3833
4057
  const delta = isVertical ? e.clientY - startY : e.clientX - startX;
4058
+ if (!dragging) {
4059
+ if (Math.abs(delta) < SLOP) return;
4060
+ dragging = true;
4061
+ el.style.transition = "none";
4062
+ try {
4063
+ el.setPointerCapture(e.pointerId);
4064
+ } catch {
4065
+ }
4066
+ }
3834
4067
  const offset = Math.max(0, sign * delta);
3835
4068
  el.style.transform = isVertical ? `translate3d(0, ${sign * offset}px, 0)` : `translate3d(${sign * offset}px, 0, 0)`;
3836
4069
  };
3837
4070
  const finish = (e) => {
3838
- if (!dragging || e.pointerId !== pointerId) return;
3839
- dragging = false;
4071
+ if (!tracking || e.pointerId !== pointerId) return;
4072
+ tracking = false;
3840
4073
  pointerId = null;
4074
+ if (!dragging) return;
4075
+ dragging = false;
3841
4076
  const dt = performance.now() - startT || 1;
3842
4077
  const delta = isVertical ? e.clientY - startY : e.clientX - startX;
3843
4078
  const offset = Math.max(0, sign * delta);
3844
4079
  const velocity = sign * delta / dt;
3845
4080
  const size = isVertical ? el.offsetHeight : el.offsetWidth;
3846
- const past = offset > size * threshold || velocity > velocityThreshold;
3847
- el.style.transition = `transform ${this.duration}ms ${this.exitEasing}`;
4081
+ const past = offset > size * this.swipeThreshold || velocity > this.swipeVelocityThreshold;
4082
+ el.style.transition = `transform ${this.effectiveDuration()}ms ${this.exitEasing}`;
3848
4083
  if (past) {
3849
4084
  void this.close("swipe");
3850
4085
  } else {
@@ -3856,6 +4091,7 @@ var OverlayEngine = class {
3856
4091
  el.addEventListener("pointerup", finish);
3857
4092
  el.addEventListener("pointercancel", finish);
3858
4093
  return () => {
4094
+ el.style.touchAction = previousTouchAction;
3859
4095
  el.removeEventListener("pointerdown", onDown);
3860
4096
  el.removeEventListener("pointermove", onMove);
3861
4097
  el.removeEventListener("pointerup", finish);
@@ -3870,10 +4106,12 @@ var OverlayEngine = class {
3870
4106
  if (this.backdrop) this.backdrop.style.zIndex = String(z - 1);
3871
4107
  },
3872
4108
  setIsTop: (isTop) => {
4109
+ this.isTop = isTop;
3873
4110
  if (this.backdrop) {
3874
4111
  this.backdrop.style.display = isTop ? "" : "none";
3875
4112
  }
3876
- }
4113
+ },
4114
+ isOpen: () => this.isOpen_
3877
4115
  });
3878
4116
  }
3879
4117
  };
@@ -3959,7 +4197,7 @@ var Overlay = react.forwardRef(function Overlay2(props, ref) {
3959
4197
  if (openProp === void 0) return;
3960
4198
  if (openProp && !state.isOpen) void open();
3961
4199
  else if (!openProp && state.isOpen) void close();
3962
- }, [openProp]);
4200
+ }, [openProp, state.isOpen]);
3963
4201
  react.useEffect(() => {
3964
4202
  onChange?.(state);
3965
4203
  }, [state, onChange]);
@@ -4091,46 +4329,34 @@ var BottomSheetFromConfig = react.forwardRef(function BottomSheetFromConfig2(pro
4091
4329
  () => sheetRef.current,
4092
4330
  []
4093
4331
  );
4094
- const lastActiveIdRef = react.useRef(null);
4095
- const handleChange = (state) => {
4096
- const prev = lastActiveIdRef.current;
4097
- const next = state.activeId;
4098
- lastActiveIdRef.current = next;
4099
- const evs = eventsRef.current;
4100
- const hs = handlersRef.current;
4101
- if (evs.onSnap) {
4102
- const fn = hs[evs.onSnap];
4103
- if (typeof fn === "function") {
4104
- fn(next);
4105
- } else {
4106
- console.warn(
4107
- `[BottomSheetFromConfig] eventHandlers["${evs.onSnap}"] is not a function; onSnap is a no-op.`
4108
- );
4109
- }
4110
- }
4111
- const isClosedNext = next === "" || next === "closed";
4112
- const isClosedPrev = prev === null || prev === "" || prev === "closed";
4113
- if (isClosedPrev && !isClosedNext && evs.onOpen) {
4114
- const fn = hs[evs.onOpen];
4115
- if (typeof fn === "function") {
4116
- fn(next);
4117
- } else {
4118
- console.warn(
4119
- `[BottomSheetFromConfig] eventHandlers["${evs.onOpen}"] is not a function; onOpen is a no-op.`
4120
- );
4121
- }
4122
- }
4123
- if (!isClosedPrev && isClosedNext && evs.onClose) {
4124
- const fn = hs[evs.onClose];
4125
- if (typeof fn === "function") {
4332
+ const invoke = (key, arg) => {
4333
+ const evName = eventsRef.current[key];
4334
+ if (!evName) return;
4335
+ const fn = handlersRef.current[evName];
4336
+ if (typeof fn === "function") {
4337
+ if (arg === void 0) {
4126
4338
  fn();
4127
4339
  } else {
4128
- console.warn(
4129
- `[BottomSheetFromConfig] eventHandlers["${evs.onClose}"] is not a function; onClose is a no-op.`
4130
- );
4340
+ fn(arg);
4131
4341
  }
4342
+ } else {
4343
+ console.warn(
4344
+ `[BottomSheetFromConfig] eventHandlers["${evName}"] is not a function; ${key} is a no-op.`
4345
+ );
4132
4346
  }
4133
4347
  };
4348
+ const invokeRef = react.useRef(invoke);
4349
+ invokeRef.current = invoke;
4350
+ react.useEffect(() => {
4351
+ const engine = sheetRef.current?.getEngine();
4352
+ if (!engine) return;
4353
+ const offs = [
4354
+ engine.on("snap", (p) => invokeRef.current("onSnap", p.id)),
4355
+ engine.on("open", (p) => invokeRef.current("onOpen", p.id)),
4356
+ engine.on("close", () => invokeRef.current("onClose"))
4357
+ ];
4358
+ return () => offs.forEach((off) => off());
4359
+ }, []);
4134
4360
  return /* @__PURE__ */ jsxRuntime.jsx(
4135
4361
  BottomSheet,
4136
4362
  {
@@ -4146,7 +4372,6 @@ var BottomSheetFromConfig = react.forwardRef(function BottomSheetFromConfig2(pro
4146
4372
  closeOnBackdrop: derived.behavior.closeOnBackdrop,
4147
4373
  lockBodyScroll: derived.behavior.lockBodyScroll,
4148
4374
  rubberBand: derived.behavior.rubberBand,
4149
- onChange: handleChange,
4150
4375
  header: slotContent?.header,
4151
4376
  children: slotContent?.body
4152
4377
  }