@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/index.js CHANGED
@@ -20,6 +20,18 @@ var SheetStack = class {
20
20
  this.entries.splice(idx, 1);
21
21
  this.recompute();
22
22
  }
23
+ promote(id) {
24
+ const idx = this.entries.findIndex((e) => e.id === id);
25
+ if (idx === -1) return;
26
+ if (idx !== this.entries.length - 1) {
27
+ const [entry] = this.entries.splice(idx, 1);
28
+ this.entries.push(entry);
29
+ }
30
+ this.recompute();
31
+ }
32
+ update() {
33
+ this.recompute();
34
+ }
23
35
  size() {
24
36
  return this.entries.length;
25
37
  }
@@ -27,9 +39,24 @@ var SheetStack = class {
27
39
  this.entries = [];
28
40
  }
29
41
  recompute() {
42
+ let top = null;
43
+ const open = [];
44
+ for (const entry of this.entries) {
45
+ if (!entry.isOpen || entry.isOpen()) {
46
+ top = entry;
47
+ open.push(entry);
48
+ }
49
+ }
50
+ if (!top && this.entries.length > 0) {
51
+ top = this.entries[this.entries.length - 1];
52
+ }
30
53
  this.entries.forEach((entry, i) => {
31
54
  entry.setZIndex(BASE_Z + i * STEP);
32
- entry.setIsTop(i === this.entries.length - 1);
55
+ entry.setIsTop(entry === top);
56
+ if (entry.setDepth) {
57
+ const openIdx = open.indexOf(entry);
58
+ entry.setDepth(openIdx === -1 ? 0 : open.length - 1 - openIdx);
59
+ }
33
60
  });
34
61
  }
35
62
  };
@@ -234,11 +261,6 @@ var SnapResolver = class {
234
261
  this.allowedIds = allowed ?? raw.map((p) => p.id);
235
262
  this.recompute();
236
263
  }
237
- /**
238
- * Re-resolve raw → resolved against the current viewport / fit-measure,
239
- * recompute maxAxisSize, invalidate range cache, notify engine. Called on
240
- * resize, orientationchange, visualViewport changes, and snap-list swaps.
241
- */
242
264
  recompute() {
243
265
  this.resolved = resolveSnapList(this.raw, this.mode, this.measureFit);
244
266
  this.maxAxisSize = this.resolved.reduce(
@@ -248,15 +270,10 @@ var SnapResolver = class {
248
270
  this.rangeCache = null;
249
271
  this.onMaxAxisSizeChange?.(this.maxAxisSize);
250
272
  }
251
- /** Replace the raw snap list and recompute. Engine calls on `setSnapPoints`. */
252
273
  setRaw(raw) {
253
274
  this.raw = raw;
254
275
  this.recompute();
255
276
  }
256
- /**
257
- * Replace the allow-list. Range cache depends on `(resolvedSnaps ×
258
- * allowedIds)` so flipping ids alone invalidates without a full recompute.
259
- */
260
277
  setAllowedIds(ids) {
261
278
  this.allowedIds = ids;
262
279
  this.rangeCache = null;
@@ -264,11 +281,6 @@ var SnapResolver = class {
264
281
  findById(id) {
265
282
  return findById(id, this.resolved);
266
283
  }
267
- /**
268
- * Pure drag-settle target resolution. Engine passes the gesture-specific
269
- * fields (delta, velocity, pointerKind, flickVelocity, dragThreshold) plus
270
- * `size`/`activeId` since those are still engine-owned.
271
- */
272
284
  findDragSettleTarget(args) {
273
285
  return findDragSettleTarget({
274
286
  ...args,
@@ -277,18 +289,9 @@ var SnapResolver = class {
277
289
  maxAxisSize: this.maxAxisSize
278
290
  });
279
291
  }
280
- /**
281
- * Memoised allowed-range query. Twice-per-drag-frame (gesture onMove +
282
- * applySize→computeProgress); cache collapses 2× iteration of resolvedSnaps
283
- * to one recompute per geometry/allow-list change.
284
- */
285
292
  getAllowedRange() {
286
293
  return this.rangeCache ?? (this.rangeCache = allowedRange(this.resolved, this.allowedIds));
287
294
  }
288
- /**
289
- * Read-only view of resolved snaps. Returned as `readonly` to discourage
290
- * accidental mutation by feature install sites that close over this list.
291
- */
292
295
  getResolvedSnaps() {
293
296
  return this.resolved;
294
297
  }
@@ -298,18 +301,6 @@ var SnapResolver = class {
298
301
  getMaxAxisSize() {
299
302
  return this.maxAxisSize;
300
303
  }
301
- /**
302
- * External clamp override. The resize-observer feature compares
303
- * `getMaxAxisSize()` against the live viewport and shrinks if the largest
304
- * resolved snap exceeds it (mobile address-bar collapse / soft-keyboard
305
- * window resize). The next `recompute()` overwrites this clamp from the
306
- * resolved-snap reduction.
307
- *
308
- * Does NOT invalidate the range cache — viewport clamping doesn't change
309
- * `(resolvedSnaps × allowedIds) → range`. DOES fire `onMaxAxisSizeChange`
310
- * so the resolver owns the layout-axis style write end-to-end (symmetric
311
- * with `recompute()` — feature call sites no longer duplicate the write).
312
- */
313
304
  setMaxAxisSize(n) {
314
305
  this.maxAxisSize = n;
315
306
  this.onMaxAxisSizeChange?.(n);
@@ -356,14 +347,17 @@ function installAutoCollapse(deps) {
356
347
  const fire = () => {
357
348
  if (deps.isDestroyed()) return;
358
349
  if (deps.isDragging()) return;
359
- const allowed = deps.getAllowedIds();
360
- const target = allowed.find((id) => {
350
+ const active = deps.resolveSnap(deps.getActiveId());
351
+ if (!active || active.size === 0) return;
352
+ let target = null;
353
+ for (const id of deps.getAllowedIds()) {
361
354
  const snap = deps.resolveSnap(id);
362
- return snap !== null && snap.size > 0;
363
- });
355
+ if (!snap || snap.size <= 0) continue;
356
+ if (!target || snap.size < target.size) target = snap;
357
+ }
364
358
  if (!target) return;
365
- if (target === deps.getActiveId()) return;
366
- deps.snapTo(target);
359
+ if (target.id === active.id || target.size >= active.size) return;
360
+ deps.snapTo(target.id);
367
361
  };
368
362
  const reset = () => {
369
363
  clearTimer();
@@ -374,6 +368,7 @@ function installAutoCollapse(deps) {
374
368
  }, ms);
375
369
  };
376
370
  const unsubscribe = deps.on("snap", () => reset());
371
+ if ((deps.resolveSnap(deps.getActiveId())?.size ?? 0) > 0) reset();
377
372
  return () => {
378
373
  clearTimer();
379
374
  unsubscribe();
@@ -386,6 +381,7 @@ function notifyLinkedSheets(sheets, self) {
386
381
  for (const linked of sheets) {
387
382
  if (linked === self) continue;
388
383
  try {
384
+ if (linked.state.size === 0) continue;
389
385
  const ids = linked.getAllowedIds();
390
386
  const target = ids.find((id) => id !== "closed");
391
387
  if (target && target !== linked.state.activeId) {
@@ -432,7 +428,7 @@ function installRoute(deps) {
432
428
  const onPop = (event) => {
433
429
  if (deps.isDestroyed()) return;
434
430
  const state = event.state;
435
- if (state && state.__bsRouted === target && deps.getSize() > 0) {
431
+ if ((!state || state.__bsRouted !== target) && deps.getSize() > 0) {
436
432
  routePushed = false;
437
433
  void deps.close();
438
434
  }
@@ -586,13 +582,17 @@ function installResizeObserver(deps) {
586
582
  if (viewportSize2 > 0 && viewportSize2 < max) {
587
583
  deps.setMaxAxisSize(viewportSize2);
588
584
  }
585
+ const wasAnimating = deps.isAnimating?.() ?? false;
589
586
  deps.cancelInFlight();
590
587
  deps.newCycle();
591
588
  const current = deps.resolveActiveSnap();
592
589
  const newMax = deps.getMaxAxisSize();
593
590
  const targetSize = current ? Math.min(current.size, newMax) : Math.min(deps.getSize(), newMax);
594
591
  deps.setSize(targetSize);
595
- if (current && !deps.isDragging()) deps.applySize(targetSize);
592
+ if (current && !deps.isDragging()) {
593
+ deps.applySize(targetSize);
594
+ if (wasAnimating) deps.resyncAfterCancel?.();
595
+ }
596
596
  };
597
597
  let resizeObserver = null;
598
598
  if (typeof ResizeObserver !== "undefined") {
@@ -684,73 +684,130 @@ var WriteSentinel = class {
684
684
  constructor() {
685
685
  __publicField(this, "last", -1);
686
686
  }
687
- /**
688
- * Returns `true` (and updates the cached value) if `next` differs from the
689
- * cached `last` by more than `epsilon`. Returns `false` (and leaves the
690
- * cache untouched) when the delta is within epsilon — caller skips the
691
- * CSSOM write.
692
- */
693
687
  shouldWrite(next, epsilon) {
694
688
  if (Math.abs(next - this.last) <= epsilon) return false;
695
689
  this.last = next;
696
690
  return true;
697
691
  }
698
- /**
699
- * Force the next `shouldWrite` to pass. Resets the cache to `-1` so any
700
- * non-negative `next` exceeds the epsilon comparison. Called from setters
701
- * that mutate state under the cache (range/mode/enabled flips that change
702
- * the progress→opacity mapping but don't move `progress` itself).
703
- */
704
692
  invalidate() {
705
693
  this.last = -1;
706
694
  }
707
- /**
708
- * Record `value` as the last-written without performing a write. Used
709
- * when a setter writes a literal value through a different path (e.g.
710
- * `setScrimMode("off")` writes `opacity: "0"` inline) and wants future
711
- * `shouldWrite` calls to short-circuit against that known cached state.
712
- */
713
695
  setLastWritten(value) {
714
696
  this.last = value;
715
697
  }
716
- /** Read-only accessor for diagnostic / dedup-aware peek. */
717
698
  get value() {
718
699
  return this.last;
719
700
  }
720
701
  };
721
702
 
703
+ // src/core/primitives/devWarn.ts
704
+ function devWarn(...args) {
705
+ if (typeof process !== "undefined" && process.env && process.env.NODE_ENV !== "production" && typeof console !== "undefined") {
706
+ console.warn(...args);
707
+ }
708
+ }
709
+
710
+ // src/core/primitives/overlay-position.ts
711
+ function resolveSheetAnchoredStyle(mode, position, inset) {
712
+ const sizeOffset = `calc(var(--bs-size, 0px) + ${inset})`;
713
+ switch (mode) {
714
+ case "bottom":
715
+ if (position === "sheet-top-left") return { bottom: sizeOffset, left: inset };
716
+ if (position === "sheet-top-right") return { bottom: sizeOffset, right: inset };
717
+ return { bottom: sizeOffset, left: "50%", transform: "translateX(-50%)" };
718
+ case "top":
719
+ if (position === "sheet-top-left") return { top: sizeOffset, left: inset };
720
+ if (position === "sheet-top-right") return { top: sizeOffset, right: inset };
721
+ return { top: sizeOffset, left: "50%", transform: "translateX(-50%)" };
722
+ case "left":
723
+ if (position === "sheet-top-left") return { left: sizeOffset, top: inset };
724
+ if (position === "sheet-top-right") return { left: sizeOffset, bottom: inset };
725
+ return { left: sizeOffset, top: "50%", transform: "translateY(-50%)" };
726
+ case "right":
727
+ if (position === "sheet-top-left") return { right: sizeOffset, top: inset };
728
+ if (position === "sheet-top-right") return { right: sizeOffset, bottom: inset };
729
+ return { right: sizeOffset, top: "50%", transform: "translateY(-50%)" };
730
+ }
731
+ }
732
+ function applyOverlayPosition(ws, mode, position, inset) {
733
+ switch (position) {
734
+ case "top-left":
735
+ ws.top = inset;
736
+ ws.left = inset;
737
+ break;
738
+ case "top-center":
739
+ ws.top = inset;
740
+ ws.left = "50%";
741
+ ws.transform = "translateX(-50%)";
742
+ break;
743
+ case "top-right":
744
+ ws.top = inset;
745
+ ws.right = inset;
746
+ break;
747
+ case "center-left":
748
+ ws.top = "50%";
749
+ ws.left = inset;
750
+ ws.transform = "translateY(-50%)";
751
+ break;
752
+ case "center":
753
+ ws.top = "50%";
754
+ ws.left = "50%";
755
+ ws.transform = "translate(-50%, -50%)";
756
+ break;
757
+ case "center-right":
758
+ ws.top = "50%";
759
+ ws.right = inset;
760
+ ws.transform = "translateY(-50%)";
761
+ break;
762
+ case "bottom-left":
763
+ ws.bottom = inset;
764
+ ws.left = inset;
765
+ break;
766
+ case "bottom-center":
767
+ ws.bottom = inset;
768
+ ws.left = "50%";
769
+ ws.transform = "translateX(-50%)";
770
+ break;
771
+ case "bottom-right":
772
+ ws.bottom = inset;
773
+ ws.right = inset;
774
+ break;
775
+ case "sheet-top-left":
776
+ case "sheet-top-center":
777
+ case "sheet-top-right": {
778
+ const style = resolveSheetAnchoredStyle(mode, position, inset);
779
+ if (style.top !== void 0) ws.top = style.top;
780
+ if (style.bottom !== void 0) ws.bottom = style.bottom;
781
+ if (style.left !== void 0) ws.left = style.left;
782
+ if (style.right !== void 0) ws.right = style.right;
783
+ if (style.transform !== void 0) ws.transform = style.transform;
784
+ break;
785
+ }
786
+ }
787
+ }
788
+
722
789
  // src/core/controllers/scrim-controller.ts
790
+ var VALID_CSS_LENGTH = /^-?\d+(?:\.\d+)?(?:px|em|rem|%)$/;
723
791
  var ScrimController = class {
724
792
  constructor(deps, opts) {
725
- // ── DOM refs (mirror engine's; cached for hot path) ─────────────────
726
793
  __publicField(this, "screenComponent");
727
794
  __publicField(this, "backdrop");
728
795
  __publicField(this, "mode");
729
- // ── Engine callbacks ────────────────────────────────────────────────
730
796
  __publicField(this, "isDestroyed");
731
797
  __publicField(this, "isTopSheet");
732
798
  __publicField(this, "getAllowedIds");
733
799
  __publicField(this, "getResolvedSnaps");
734
800
  __publicField(this, "snapToFn");
735
- // ── Mode + state ────────────────────────────────────────────────────
801
+ __publicField(this, "closeFn");
802
+ __publicField(this, "scrimInteractive", false);
736
803
  __publicField(this, "scrimMode");
737
804
  __publicField(this, "backdropRange");
738
805
  __publicField(this, "screenRange");
739
- // setScrimEnabled toggle — gated through `scrimEnabled` rather than nuking
740
- // ranges so re-enable can restore prior values without a remount. Public
741
- // so tests / introspection can read it without an `as any` cast.
742
806
  __publicField(this, "scrimEnabled", true);
743
807
  __publicField(this, "savedScrimRanges", null);
744
- // ── Tap-to-close ────────────────────────────────────────────────────
745
808
  __publicField(this, "scrimTapToCloseEnabled", false);
746
809
  __publicField(this, "detachScrimTap", null);
747
- // ── Scrim overlay slot (positioned floating element) ────────────────
748
810
  __publicField(this, "scrimOverlayTeardown", null);
749
- // ── Hot-path opacity dedup caches (per-frame writes) ────────────────
750
- // Numeric sentinels migrated to WriteSentinel primitive — single
751
- // invalidation surface, shared with the engine's size/progress sentinels.
752
- // The two string-typed sentinels (`Pointer`, `Display`) keep their inline
753
- // pattern: equality on string union, no epsilon, different reset semantics.
754
811
  __publicField(this, "backdropOpacitySentinel", new WriteSentinel());
755
812
  __publicField(this, "screenOpacitySentinel", new WriteSentinel());
756
813
  __publicField(this, "lastBackdropPointer", "");
@@ -763,6 +820,7 @@ var ScrimController = class {
763
820
  this.getAllowedIds = deps.getAllowedIds;
764
821
  this.getResolvedSnaps = deps.getResolvedSnaps;
765
822
  this.snapToFn = deps.snapTo;
823
+ this.closeFn = deps.close;
766
824
  this.scrimMode = opts.scrimMode ?? "full";
767
825
  const preset = opts.scrimPreset ? SCRIM_PRESETS[opts.scrimPreset] : null;
768
826
  this.backdropRange = opts.backdropRange ?? preset?.range ?? [0, 1];
@@ -785,41 +843,15 @@ var ScrimController = class {
785
843
  }
786
844
  }
787
845
  }
788
- /**
789
- * Invalidate the opacity dedup caches. Called from any setter that mutates
790
- * range/mode/enabled — opacity is a pure function of (progress, range,
791
- * enabled), so a config change makes the cached values stale even if
792
- * `progress` itself didn't move. Next `applyOpacity(progress, false)` call
793
- * sees the -1 sentinel and re-runs the math instead of short-circuiting.
794
- *
795
- * Public for the engine: `setSnapPoints` mutates resolved geometry which
796
- * shifts `progress→opacity` mapping under the cache; engine calls this to
797
- * keep the next render frame correct on a static sheet.
798
- * @internal
799
- */
800
846
  invalidateOpacityCache() {
801
847
  this.backdropOpacitySentinel.invalidate();
802
848
  this.screenOpacitySentinel.invalidate();
803
849
  }
804
- /** Engine calls this when its `attach()` runs — installs scrim-tap-click if requested. */
805
850
  attach() {
806
851
  if (this.scrimTapToCloseEnabled) {
807
852
  this.setScrimTapToClose(true);
808
853
  }
809
854
  }
810
- // ── Hot-path: opacity computation called per-frame from engine.applySize ──
811
- /**
812
- * Per-frame opacity writer. Computes backdrop + screen opacity from the
813
- * sheet's progress (0..1 within allowed range), gated by sub-pixel dedup
814
- * caches so steady-state frames don't trigger style recalc.
815
- *
816
- * `progressChanged` is the engine's already-computed dedup gate — when
817
- * `false`, opacity is by construction a pure function of `progress` so
818
- * cached values are still valid; we short-circuit the Math.min/max +
819
- * division work entirely. Setters that mutate ranges/mode/enabled
820
- * invalidate the cache by writing -1 sentinels so the next call re-runs
821
- * the math.
822
- */
823
855
  applyOpacity(progress, progressChanged) {
824
856
  if (!progressChanged && this.backdropOpacitySentinel.value !== -1 && this.screenOpacitySentinel.value !== -1) {
825
857
  return;
@@ -865,80 +897,30 @@ var ScrimController = class {
865
897
  }
866
898
  }
867
899
  }
868
- // ── Public setters (engine forwards these 1:1) ─────────────────────
869
- /**
870
- * Replace the [start, end] progress window in which the backdrop fades 0→1.
871
- * @param range - Tuple of two progress values in 0..1 (start <= end).
872
- * @param applySize - Engine-supplied recompute hook; runs one applyOpacity pass.
873
- * @example
874
- * // Backdrop only starts darkening after the sheet crosses 30% open.
875
- * engine.setBackdropRange([0.3, 1]);
876
- */
877
900
  setBackdropRange(range, applySize) {
878
901
  if (this.isDestroyed()) return;
879
902
  this.backdropRange = range;
880
903
  this.invalidateOpacityCache();
881
904
  applySize();
882
905
  }
883
- /**
884
- * Replace the [start, end] progress window in which the screen scrim fades 0→1.
885
- * @param range - Tuple of two progress values in 0..1 (start <= end).
886
- * @param applySize - Engine-supplied recompute hook; runs one applyOpacity pass.
887
- * @example
888
- * // Scrim is fully opaque only when the sheet is past 50% — softer feel.
889
- * engine.setScreenRange([0, 0.5]);
890
- */
891
906
  setScreenRange(range, applySize) {
892
907
  if (this.isDestroyed()) return;
893
908
  this.screenRange = range;
894
909
  this.invalidateOpacityCache();
895
910
  applySize();
896
911
  }
897
- /**
898
- * Set the scrim background color. Pass `null`/`undefined` to clear the
899
- * inline write and let consumer CSS take over.
900
- * @param color - Any valid CSS color string, or `null`/`undefined` to clear.
901
- * @example
902
- * engine.setScrimColor("rgba(0, 0, 0, 0.6)");
903
- * engine.setScrimColor(null); // hand back to consumer CSS
904
- */
905
912
  setScrimColor(color) {
906
913
  if (this.isDestroyed()) return;
907
914
  this.applyScrimStyles({ color: color === void 0 ? null : color });
908
915
  }
909
- /**
910
- * Set the scrim backdrop-filter blur radius. Pass `null`/`undefined` to clear.
911
- * @param blur - CSS length (e.g. `"8px"`) consumed as `blur(<value>)`, or
912
- * `null`/`undefined` to remove the filter.
913
- * @example
914
- * engine.setScrimBlur("12px"); // frosted-glass effect
915
- * engine.setScrimBlur(null); // disable blur
916
- */
917
916
  setScrimBlur(blur) {
918
917
  if (this.isDestroyed()) return;
919
918
  this.applyScrimStyles({ blur: blur === void 0 ? null : blur });
920
919
  }
921
- /**
922
- * Toggle whether the scrim layer captures pointer events.
923
- * @param interactive - `true` → `pointer-events: auto`; `false` → `none`.
924
- * @example
925
- * engine.setScrimInteractive(true); // scrim swallows clicks
926
- * engine.setScrimInteractive(false); // clicks pass through to content below
927
- */
928
920
  setScrimInteractive(interactive) {
929
921
  if (this.isDestroyed()) return;
930
922
  this.applyScrimStyles({ interactive });
931
923
  }
932
- /**
933
- * Switch scrim positioning at runtime.
934
- * @param mode - `"full"` (consumer CSS), `"above-sheet"` (engine-pinned inset
935
- * so only the area NOT covered by the sheet is dimmed), or
936
- * `"off"` (display:none + opacity:0 — no remount needed).
937
- * @param applySize - Engine-supplied recompute hook; runs one applyOpacity pass.
938
- * @example
939
- * engine.setScrimMode("above-sheet"); // dim only the area above the sheet
940
- * engine.setScrimMode("off"); // disable scrim layer
941
- */
942
924
  setScrimMode(mode, applySize) {
943
925
  if (this.isDestroyed()) return;
944
926
  if (mode === this.scrimMode) return;
@@ -968,15 +950,6 @@ var ScrimController = class {
968
950
  }
969
951
  applySize();
970
952
  }
971
- /**
972
- * Enable or disable click-on-scrim → snap to first non-zero allowed snap.
973
- * Auto-promotes `pointer-events: auto` when scrim is non-interactive so the
974
- * click listener actually fires.
975
- * @param enabled - `true` installs the click listener; `false` removes it.
976
- * @example
977
- * engine.setScrimTapToClose(true); // tap outside sheet collapses it
978
- * engine.setScrimTapToClose(false); // require explicit close button
979
- */
980
953
  setScrimTapToClose(enabled) {
981
954
  if (this.isDestroyed()) return;
982
955
  if (!this.screenComponent) return;
@@ -989,11 +962,15 @@ var ScrimController = class {
989
962
  }
990
963
  const onClick = () => {
991
964
  if (this.isDestroyed()) return;
965
+ if (this.closeFn) {
966
+ this.closeFn();
967
+ return;
968
+ }
992
969
  const allowed = this.getAllowedIds();
993
970
  const snaps = this.getResolvedSnaps();
994
971
  const fallback = allowed.find((id) => {
995
972
  const snap = findById(id, snaps);
996
- return snap !== null && snap.size > 0;
973
+ return snap !== null && snap.size === 0;
997
974
  });
998
975
  if (fallback) this.snapToFn(fallback);
999
976
  };
@@ -1006,20 +983,11 @@ var ScrimController = class {
1006
983
  this.scrimTapToCloseEnabled = false;
1007
984
  }
1008
985
  }
1009
- /**
1010
- * Toggle the scrim layer without remounting. Disabling stashes current
1011
- * `screenRange`/`backdropRange` and degenerates them to `[1, 1]` (opacity
1012
- * formula → 0 across the whole range); re-enabling restores the saved values.
1013
- * @param enabled - `false` hides scrim, `true` restores prior ranges.
1014
- * @param applySize - Engine-supplied recompute hook; runs one applyOpacity pass.
1015
- * @example
1016
- * engine.setScrimEnabled(false); // hide scrim, ranges remembered
1017
- * engine.setScrimEnabled(true); // restore prior ranges (no remount)
1018
- */
1019
986
  setScrimEnabled(enabled, applySize) {
1020
987
  if (this.isDestroyed()) return;
1021
988
  if (enabled === this.scrimEnabled) return;
1022
989
  this.scrimEnabled = enabled;
990
+ this.invalidateOpacityCache();
1023
991
  if (!enabled) {
1024
992
  this.savedScrimRanges = {
1025
993
  screen: this.screenRange,
@@ -1034,20 +1002,6 @@ var ScrimController = class {
1034
1002
  }
1035
1003
  applySize();
1036
1004
  }
1037
- /**
1038
- * Batch-update multiple scrim fields with a single applySize() at the end.
1039
- * If `preset` is set, it seeds color/blur/interactive/range first; explicit
1040
- * fields override.
1041
- * @param opts - Partial scrim config: preset, mode, enabled, tapToClose,
1042
- * color, blur, interactive, range.
1043
- * @param applySize - Engine-supplied recompute hook; runs ONCE per call.
1044
- * @example
1045
- * engine.setScrim({
1046
- * preset: "frosted",
1047
- * mode: "above-sheet",
1048
- * tapToClose: true,
1049
- * });
1050
- */
1051
1005
  setScrim(opts, applySize) {
1052
1006
  if (this.isDestroyed()) return;
1053
1007
  if (opts.preset) {
@@ -1057,7 +1011,12 @@ var ScrimController = class {
1057
1011
  blur: cfg.blur ?? null,
1058
1012
  interactive: cfg.interactive
1059
1013
  });
1060
- this.screenRange = cfg.range;
1014
+ if (!this.scrimEnabled && this.savedScrimRanges) {
1015
+ this.savedScrimRanges = { screen: cfg.range, backdrop: cfg.range };
1016
+ } else {
1017
+ this.screenRange = cfg.range;
1018
+ this.backdropRange = cfg.range;
1019
+ }
1061
1020
  }
1062
1021
  const noop = () => {
1063
1022
  };
@@ -1070,27 +1029,18 @@ var ScrimController = class {
1070
1029
  interactive: opts.interactive
1071
1030
  });
1072
1031
  if (opts.range !== void 0) {
1073
- this.screenRange = opts.range;
1032
+ if (!this.scrimEnabled && this.savedScrimRanges) {
1033
+ this.savedScrimRanges = {
1034
+ screen: opts.range,
1035
+ backdrop: this.savedScrimRanges.backdrop
1036
+ };
1037
+ } else {
1038
+ this.screenRange = opts.range;
1039
+ }
1074
1040
  }
1075
1041
  this.invalidateOpacityCache();
1076
1042
  applySize();
1077
1043
  }
1078
- /**
1079
- * Inject a positioned floating element into the scrim layer (e.g. a close
1080
- * button anchored top-right). Replaces any prior overlay so repeated calls
1081
- * (reactive effects re-running) don't accumulate wrappers.
1082
- * @param opts - Children element, optional `inset` (default `"16px"`),
1083
- * `position` (9-way grid, default `"top-right"`), and
1084
- * `interactive` flag (default `true`).
1085
- * @returns Teardown function that removes the wrapper from the DOM.
1086
- * @example
1087
- * const teardown = engine.setScrimOverlay({
1088
- * children: closeBtn,
1089
- * position: "top-right",
1090
- * inset: "20px",
1091
- * });
1092
- * // later: teardown(); // remove the floating button
1093
- */
1094
1044
  setScrimOverlay(opts) {
1095
1045
  if (this.isDestroyed()) return () => {
1096
1046
  };
@@ -1098,61 +1048,38 @@ var ScrimController = class {
1098
1048
  };
1099
1049
  this.scrimOverlayTeardown?.();
1100
1050
  const screen = this.screenComponent;
1101
- const inset = opts.inset ?? "16px";
1051
+ const insetRaw = opts.inset ?? "16px";
1052
+ const inset = VALID_CSS_LENGTH.test(insetRaw) ? insetRaw : "16px";
1053
+ if (inset !== insetRaw) {
1054
+ devWarn(
1055
+ `[bottom-sheet] setScrimOverlay: invalid inset ${JSON.stringify(insetRaw)}; expected a CSS length like "16px" / "1rem" / "5%". Falling back to "16px".`
1056
+ );
1057
+ }
1058
+ if (opts.children.ownerDocument && opts.children.ownerDocument !== screen.ownerDocument) {
1059
+ devWarn(
1060
+ "[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."
1061
+ );
1062
+ }
1102
1063
  const position = opts.position ?? "top-right";
1103
1064
  const interactive = opts.interactive ?? true;
1104
1065
  const wrapper = screen.ownerDocument.createElement("div");
1105
1066
  wrapper.className = "bs-scrim-overlay";
1106
1067
  const ws = wrapper.style;
1107
1068
  ws.position = "absolute";
1108
- switch (position) {
1109
- case "top-left":
1110
- ws.top = inset;
1111
- ws.left = inset;
1112
- break;
1113
- case "top-center":
1114
- ws.top = inset;
1115
- ws.left = "50%";
1116
- ws.transform = "translateX(-50%)";
1117
- break;
1118
- case "top-right":
1119
- ws.top = inset;
1120
- ws.right = inset;
1121
- break;
1122
- case "center-left":
1123
- ws.top = "50%";
1124
- ws.left = inset;
1125
- ws.transform = "translateY(-50%)";
1126
- break;
1127
- case "center":
1128
- ws.top = "50%";
1129
- ws.left = "50%";
1130
- ws.transform = "translate(-50%, -50%)";
1131
- break;
1132
- case "center-right":
1133
- ws.top = "50%";
1134
- ws.right = inset;
1135
- ws.transform = "translateY(-50%)";
1136
- break;
1137
- case "bottom-left":
1138
- ws.bottom = inset;
1139
- ws.left = inset;
1140
- break;
1141
- case "bottom-center":
1142
- ws.bottom = inset;
1143
- ws.left = "50%";
1144
- ws.transform = "translateX(-50%)";
1145
- break;
1146
- case "bottom-right":
1147
- ws.bottom = inset;
1148
- ws.right = inset;
1149
- break;
1150
- }
1069
+ applyOverlayPosition(ws, this.mode, position, inset);
1151
1070
  if (interactive) {
1152
1071
  ws.pointerEvents = "auto";
1153
1072
  }
1154
1073
  wrapper.appendChild(opts.children);
1155
- screen.appendChild(wrapper);
1074
+ const host = screen.parentElement;
1075
+ if (!host) {
1076
+ devWarn(
1077
+ "[bottom-sheet] setScrimOverlay: scrim element has no parent, skipping overlay injection. Mount the sheet root before calling setScrimOverlay."
1078
+ );
1079
+ return () => {
1080
+ };
1081
+ }
1082
+ host.appendChild(wrapper);
1156
1083
  const teardown = () => {
1157
1084
  if (wrapper.parentNode) wrapper.parentNode.removeChild(wrapper);
1158
1085
  if (this.scrimOverlayTeardown === teardown) {
@@ -1162,7 +1089,6 @@ var ScrimController = class {
1162
1089
  this.scrimOverlayTeardown = teardown;
1163
1090
  return teardown;
1164
1091
  }
1165
- /** Tear down toggle-state listeners + remove any injected overlay wrapper. */
1166
1092
  destroy() {
1167
1093
  this.detachScrimTap?.();
1168
1094
  this.detachScrimTap = null;
@@ -1181,8 +1107,6 @@ var ScrimController = class {
1181
1107
  this.lastBackdropPointer = "";
1182
1108
  this.lastScreenDisplay = "";
1183
1109
  }
1184
- // ── Private helpers ────────────────────────────────────────────────
1185
- /** Centralized scrim style writer — atomic 3-property write across all setters. */
1186
1110
  applyScrimStyles(opts) {
1187
1111
  if (!this.screenComponent) return;
1188
1112
  const s = this.screenComponent.style;
@@ -1201,10 +1125,10 @@ var ScrimController = class {
1201
1125
  }
1202
1126
  }
1203
1127
  if (opts.interactive !== void 0) {
1128
+ this.scrimInteractive = opts.interactive;
1204
1129
  s.pointerEvents = opts.interactive ? "auto" : "none";
1205
1130
  }
1206
1131
  }
1207
- /** Pin the screen overlay to the area NOT occupied by the sheet. */
1208
1132
  applyAboveSheetInset() {
1209
1133
  if (!this.screenComponent) return;
1210
1134
  const insetByMode = {
@@ -1216,7 +1140,7 @@ var ScrimController = class {
1216
1140
  const s = this.screenComponent.style;
1217
1141
  s.position = "fixed";
1218
1142
  s.inset = insetByMode[this.mode];
1219
- s.pointerEvents = "none";
1143
+ s.pointerEvents = this.scrimInteractive || this.scrimTapToCloseEnabled ? "auto" : "none";
1220
1144
  }
1221
1145
  };
1222
1146
 
@@ -1304,14 +1228,20 @@ var runSpring = ({
1304
1228
  if (cancelled) return;
1305
1229
  const dt = Math.min((now - lastTime) / 1e3, MAX_DT);
1306
1230
  lastTime = now;
1307
- const subDt = Math.min(dt, 1 / 240);
1308
- let stepsLeft = Math.max(1, Math.ceil(dt / subDt));
1231
+ const steps = Math.max(1, Math.ceil(dt * 240));
1232
+ const subDt = dt / steps;
1233
+ let stepsLeft = steps;
1309
1234
  while (stepsLeft-- > 0) {
1310
1235
  const force = -cfg.stiffness * (x - to) - cfg.damping * v;
1311
1236
  const accel = force / cfg.mass;
1312
1237
  v += accel * subDt;
1313
1238
  x += v * subDt;
1314
1239
  }
1240
+ if (!Number.isFinite(x) || !Number.isFinite(v)) {
1241
+ onUpdate(to, 0);
1242
+ resolve();
1243
+ return;
1244
+ }
1315
1245
  onUpdate(x, v);
1316
1246
  const isAtRest = Math.abs(to - x) < (cfg.restDelta ?? 0.5) && Math.abs(v) < (cfg.restSpeed ?? 0.5);
1317
1247
  if (isAtRest) {
@@ -1375,13 +1305,9 @@ var AnimationRunner = class {
1375
1305
  __publicField(this, "duration");
1376
1306
  __publicField(this, "easing");
1377
1307
  __publicField(this, "respectReducedMotion");
1378
- /** Live-tracked via mq listener so animateTo skips matchMedia per snap. */
1379
1308
  __publicField(this, "reducedMotion", false);
1380
- /** Disposes the matchMedia change listener — drained from destroy(). */
1381
1309
  __publicField(this, "detachReducedMotion", null);
1382
- /** Computed once — both inputs (option + API presence) stable for life. */
1383
1310
  __publicField(this, "viewTransitionsAvailable");
1384
- /** In-flight handles. Public for tests / debugging only — engine drives via cancel(). */
1385
1311
  __publicField(this, "currentTween", null);
1386
1312
  __publicField(this, "currentSpring", null);
1387
1313
  this.element = deps.element;
@@ -1412,26 +1338,13 @@ var AnimationRunner = class {
1412
1338
  }
1413
1339
  }
1414
1340
  }
1415
- /**
1416
- * Live derived flag — `true` whenever a spring/tween handle is in flight.
1417
- * Reading the canonical handles (instead of mirroring into a separate
1418
- * boolean) eliminates the reduced-motion early-return latent bug where
1419
- * the flag stayed true forever, and removes the cross-controller
1420
- * setIsAnimating callback.
1421
- */
1422
1341
  get isAnimating() {
1423
1342
  return this.currentTween !== null || this.currentSpring !== null;
1424
1343
  }
1425
- /** Cancel any in-flight tween/spring without touching engine state. */
1426
1344
  cancel() {
1427
1345
  this.currentTween?.cancel();
1428
1346
  this.currentSpring?.cancel();
1429
1347
  }
1430
- /**
1431
- * Animate from current size to `target`. Resolves when the animation
1432
- * completes; resolves early if cancelled mid-flight (caller checks an
1433
- * abort signal post-await for cycle-invariant correctness).
1434
- */
1435
1348
  async animateTo(target, velocityPxPerMs) {
1436
1349
  if (target === this.getSize()) return;
1437
1350
  this.currentTween?.cancel();
@@ -1472,7 +1385,6 @@ var AnimationRunner = class {
1472
1385
  if (!this.isDragging()) this.element.style.willChange = "auto";
1473
1386
  }
1474
1387
  }
1475
- /** Tear down the matchMedia listener. Engine calls from destroy(). */
1476
1388
  destroy() {
1477
1389
  this.detachReducedMotion?.();
1478
1390
  this.detachReducedMotion = null;
@@ -1504,9 +1416,13 @@ var isVisible = (el) => {
1504
1416
  var focusables = (root) => Array.from(root.querySelectorAll(FOCUSABLE)).filter(
1505
1417
  (el) => !el.hasAttribute("inert") && isVisible(el) && !el.matches("[aria-hidden='true']")
1506
1418
  );
1419
+ var trapStack = [];
1507
1420
  var installFocusTrap = (container, options = {}) => {
1508
1421
  if (typeof document === "undefined") return () => {
1509
1422
  };
1423
+ const token = {};
1424
+ trapStack.push(token);
1425
+ const isActive = () => trapStack[trapStack.length - 1] === token;
1510
1426
  const previouslyFocused = document.activeElement;
1511
1427
  const focusInitial = () => {
1512
1428
  const initial = typeof options.initialFocus === "string" ? container.querySelector(options.initialFocus) : options.initialFocus ?? focusables(container)[0] ?? container;
@@ -1514,6 +1430,7 @@ var installFocusTrap = (container, options = {}) => {
1514
1430
  };
1515
1431
  focusInitial();
1516
1432
  const handleKey = (e) => {
1433
+ if (!isActive()) return;
1517
1434
  if (e.key === "Escape" && options.onEscape) {
1518
1435
  options.onEscape();
1519
1436
  return;
@@ -1536,6 +1453,7 @@ var installFocusTrap = (container, options = {}) => {
1536
1453
  }
1537
1454
  };
1538
1455
  const handleFocusIn = (e) => {
1456
+ if (!isActive()) return;
1539
1457
  const target = e.target;
1540
1458
  if (target && !container.contains(target)) {
1541
1459
  const list = focusables(container);
@@ -1548,6 +1466,8 @@ var installFocusTrap = (container, options = {}) => {
1548
1466
  return () => {
1549
1467
  if (released) return;
1550
1468
  released = true;
1469
+ const idx = trapStack.indexOf(token);
1470
+ if (idx !== -1) trapStack.splice(idx, 1);
1551
1471
  document.removeEventListener("keydown", handleKey, true);
1552
1472
  document.removeEventListener("focusin", handleFocusIn, true);
1553
1473
  if (previouslyFocused && document.contains(previouslyFocused)) {
@@ -1603,11 +1523,17 @@ function createInertSiblings(rootProvider) {
1603
1523
  return {
1604
1524
  apply() {
1605
1525
  if (typeof document === "undefined") return;
1606
- const root = rootProvider();
1607
- const parent = root.parentElement ?? document.body;
1526
+ let top = rootProvider();
1527
+ while (top.parentElement && top.parentElement !== document.body) {
1528
+ top = top.parentElement;
1529
+ }
1530
+ const parent = top.parentElement ?? document.body;
1608
1531
  for (const child of Array.from(parent.children)) {
1609
- if (child === root) continue;
1532
+ if (child === top) continue;
1610
1533
  if (child.hasAttribute("inert")) continue;
1534
+ if (child.classList.contains("bs-backdrop") || child.classList.contains("bs-screen") || child.classList.contains("bs-root")) {
1535
+ continue;
1536
+ }
1611
1537
  child.setAttribute("inert", "");
1612
1538
  tracked.push(child);
1613
1539
  }
@@ -1623,7 +1549,6 @@ function createInertSiblings(rootProvider) {
1623
1549
  var LifecycleController = class {
1624
1550
  constructor(deps, opts) {
1625
1551
  __publicField(this, "element");
1626
- __publicField(this, "closeFn");
1627
1552
  __publicField(this, "focusTrapEnabled");
1628
1553
  __publicField(this, "initialFocus");
1629
1554
  __publicField(this, "closeOnEscape");
@@ -1632,16 +1557,11 @@ var LifecycleController = class {
1632
1557
  __publicField(this, "inertSiblings");
1633
1558
  __publicField(this, "shouldApplyInertSiblings");
1634
1559
  __publicField(this, "returnFocus");
1635
- /** Toggle-state teardowns — fire on close, NOT in destroy-only stack. */
1636
1560
  __publicField(this, "releaseFocusTrap", null);
1637
1561
  __publicField(this, "releaseScrollLock", null);
1638
- /** True between install() and release(). Guards release()/destroy() from
1639
- * re-running side effects (notably `returnFocus.focus()`) on already-
1640
- * released or never-installed instances. */
1641
1562
  __publicField(this, "installed", false);
1642
1563
  __publicField(this, "destroyed", false);
1643
1564
  this.element = deps.element;
1644
- this.closeFn = deps.close;
1645
1565
  this.focusTrapEnabled = opts.focusTrap ?? false;
1646
1566
  this.initialFocus = opts.initialFocus;
1647
1567
  this.closeOnEscape = opts.closeOnEscape ?? true;
@@ -1651,14 +1571,9 @@ var LifecycleController = class {
1651
1571
  this.returnFocus = opts.returnFocus;
1652
1572
  this.inertSiblings = createInertSiblings(() => this.element);
1653
1573
  }
1654
- /**
1655
- * Run the open-time install steps. Idempotent: each step checks its
1656
- * existing teardown ref so a re-open during a half-cancelled close
1657
- * doesn't double-install. Sets `installed=true` BEFORE the first side
1658
- * effect so a partial throw (detached / shadow-DOM target) leaves the
1659
- * caller's `release()` rollback path armed to undo whatever did succeed.
1660
- * No-op when destroyed.
1661
- */
1574
+ get isInstalled() {
1575
+ return this.installed;
1576
+ }
1662
1577
  install() {
1663
1578
  if (this.destroyed) return;
1664
1579
  this.installed = true;
@@ -1667,30 +1582,17 @@ var LifecycleController = class {
1667
1582
  }
1668
1583
  if (this.focusTrapEnabled && !this.releaseFocusTrap) {
1669
1584
  this.releaseFocusTrap = installFocusTrap(this.element, {
1670
- initialFocus: this.initialFocus,
1671
- onEscape: this.closeOnEscape ? () => void this.closeFn() : void 0
1585
+ initialFocus: this.initialFocus
1672
1586
  });
1673
1587
  }
1674
1588
  if (this.inertSiblingsEnabled && this.shouldApplyInertSiblings()) {
1675
1589
  this.inertSiblings.apply();
1676
1590
  }
1677
1591
  }
1678
- /**
1679
- * Update the post-release focus target. Overlay re-binds this when the
1680
- * consumer changes `returnFocus` after construction; the new value applies
1681
- * on the next `release()`. No-op when destroyed.
1682
- */
1683
1592
  setReturnFocus(target) {
1684
1593
  if (this.destroyed) return;
1685
1594
  this.returnFocus = target;
1686
1595
  }
1687
- /**
1688
- * Release all install-time teardowns. Short-circuits when not installed,
1689
- * so `close() → destroy()` (each routing through here) only restores
1690
- * focus once. The `installed` guard lets `inertSiblings.remove()` and
1691
- * the focus-trap/scroll-lock teardown nullings stay simple — they were
1692
- * already idempotent, but `returnFocus.focus()` was firing on every call.
1693
- */
1694
1596
  release() {
1695
1597
  if (!this.installed) return;
1696
1598
  this.installed = false;
@@ -1704,12 +1606,6 @@ var LifecycleController = class {
1704
1606
  target?.focus?.();
1705
1607
  }
1706
1608
  }
1707
- /**
1708
- * Final teardown — releases any still-installed listeners and seals the
1709
- * controller. After destroy(), install() is a no-op and setReturnFocus()
1710
- * is rejected, so post-destroy mutations can't queue up a stale focus
1711
- * restore.
1712
- */
1713
1609
  destroy() {
1714
1610
  this.release();
1715
1611
  this.destroyed = true;
@@ -1719,12 +1615,10 @@ var LifecycleController = class {
1719
1615
  // src/core/gestures.ts
1720
1616
  var isAxisVertical = (mode) => mode === "bottom" || mode === "top";
1721
1617
  var SampleRing = class {
1722
- // current count, ≤ capacity
1723
1618
  constructor(capacity) {
1724
1619
  __publicField(this, "slots");
1725
1620
  __publicField(this, "capacity");
1726
1621
  __publicField(this, "head", 0);
1727
- // next write index
1728
1622
  __publicField(this, "size", 0);
1729
1623
  this.capacity = capacity;
1730
1624
  this.slots = new Array(capacity);
@@ -1737,12 +1631,6 @@ var SampleRing = class {
1737
1631
  this.head = (this.head + 1) % this.capacity;
1738
1632
  if (this.size < this.capacity) this.size++;
1739
1633
  }
1740
- /**
1741
- * Drop oldest entries whose timestamp is below `cutoff`, but never shrink
1742
- * below `minKeep`. Mirrors the original
1743
- * `while (samples.length > minKeep && samples[0].t < cutoff) samples.shift()`
1744
- * but without copying — just advances the logical tail.
1745
- */
1746
1634
  pruneBefore(cutoff, minKeep) {
1747
1635
  while (this.size > minKeep) {
1748
1636
  const oldestIdx = (this.head - this.size + this.capacity) % this.capacity;
@@ -1849,10 +1737,11 @@ var installGestures = (handle, mode, callbacks) => {
1849
1737
  var attachGestures = installGestures;
1850
1738
 
1851
1739
  // src/core/primitives/rubber-band.ts
1852
- var RUBBER_BAND_FACTOR = 0.55;
1853
1740
  function rubberBand(overshoot, maxAxisSize) {
1854
1741
  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));
1742
+ if (cap <= 0) return 0;
1743
+ const abs = Math.abs(overshoot);
1744
+ return Math.sign(overshoot) * (cap * abs / (abs + cap));
1856
1745
  }
1857
1746
 
1858
1747
  // src/core/features/soft-keyboard.ts
@@ -1879,28 +1768,14 @@ var GestureController = class {
1879
1768
  __publicField(this, "detach", null);
1880
1769
  __publicField(this, "isDragging_", false);
1881
1770
  __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
1771
  __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
1772
  __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
1773
  __publicField(this, "dragPayload", { size: 0, delta: 0 });
1894
1774
  this.deps = deps;
1895
1775
  }
1896
- /** True between `dragstart` and `dragend`/`onCancel`. */
1897
1776
  get isDragging() {
1898
1777
  return this.isDragging_;
1899
1778
  }
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
1779
  install() {
1905
1780
  if (this.detach) return this.detach;
1906
1781
  this.detach = installGestures(this.deps.handle, this.deps.mode, {
@@ -1951,17 +1826,16 @@ var GestureController = class {
1951
1826
  this.isDragging_ = false;
1952
1827
  this.keyboardDismissed = false;
1953
1828
  this.deps.getRoot()?.removeAttribute("data-dragging");
1829
+ this.deps.emit("dragend", {
1830
+ size: this.deps.getDragContext().size,
1831
+ velocity: 0
1832
+ });
1954
1833
  const restoreTo = this.dragStartSize;
1955
1834
  void this.deps.animateTo(restoreTo, 0);
1956
1835
  }
1957
1836
  });
1958
1837
  return this.detach;
1959
1838
  }
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
1839
  forceClearDragState() {
1966
1840
  if (!this.isDragging_) return;
1967
1841
  this.isDragging_ = false;
@@ -1980,11 +1854,6 @@ var AriaSliderWriter = class {
1980
1854
  mode === "bottom" || mode === "top" ? "vertical" : "horizontal"
1981
1855
  );
1982
1856
  }
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
1857
  setValue(allowedIds, activeId) {
1989
1858
  if (allowedIds.length === 0) {
1990
1859
  this.handle.removeAttribute("aria-valuemin");
@@ -2034,6 +1903,287 @@ function createScrollCache(deps) {
2034
1903
  };
2035
1904
  }
2036
1905
 
1906
+ // src/core/primitives/anchor-animations.ts
1907
+ var DEFAULT_DURATION3 = 200;
1908
+ var DEFAULT_EASING = "cubic-bezier(0.22, 1, 0.36, 1)";
1909
+ var PRESET_FRAMES = {
1910
+ fade: {
1911
+ enter: [{ opacity: 0 }, { opacity: 1 }],
1912
+ exit: [{ opacity: 1 }, { opacity: 0 }]
1913
+ },
1914
+ scale: {
1915
+ enter: [
1916
+ { opacity: 0, transform: "scale(0.85)" },
1917
+ { opacity: 1, transform: "scale(1)" }
1918
+ ],
1919
+ exit: [
1920
+ { opacity: 1, transform: "scale(1)" },
1921
+ { opacity: 0, transform: "scale(0.85)" }
1922
+ ]
1923
+ },
1924
+ slide: {
1925
+ enter: [
1926
+ { opacity: 0, transform: "translateY(10px)" },
1927
+ { opacity: 1, transform: "translateY(0)" }
1928
+ ],
1929
+ exit: [
1930
+ { opacity: 1, transform: "translateY(0)" },
1931
+ { opacity: 0, transform: "translateY(10px)" }
1932
+ ]
1933
+ },
1934
+ pop: {
1935
+ enter: [
1936
+ { opacity: 0, transform: "scale(0.6)", offset: 0 },
1937
+ { opacity: 1, transform: "scale(1.06)", offset: 0.7 },
1938
+ { opacity: 1, transform: "scale(1)", offset: 1 }
1939
+ ],
1940
+ exit: [
1941
+ { opacity: 1, transform: "scale(1)" },
1942
+ { opacity: 0, transform: "scale(0.6)" }
1943
+ ]
1944
+ }
1945
+ };
1946
+ var INSTANT = {
1947
+ finished: Promise.resolve(),
1948
+ cancel: () => {
1949
+ }
1950
+ };
1951
+ function resolveTransitionFrames(spec, phase) {
1952
+ const fallback = { frames: null, duration: 0, easing: DEFAULT_EASING };
1953
+ if (spec === "none") return fallback;
1954
+ if (spec === void 0 || typeof spec === "string") {
1955
+ const preset2 = PRESET_FRAMES[spec ?? "fade"];
1956
+ return {
1957
+ frames: preset2[phase],
1958
+ duration: DEFAULT_DURATION3,
1959
+ easing: DEFAULT_EASING
1960
+ };
1961
+ }
1962
+ const custom = phase === "enter" ? spec.enter : spec.exit;
1963
+ if (custom) {
1964
+ return {
1965
+ frames: custom,
1966
+ duration: spec.duration ?? DEFAULT_DURATION3,
1967
+ easing: spec.easing ?? DEFAULT_EASING
1968
+ };
1969
+ }
1970
+ if (spec.preset === "none") return fallback;
1971
+ const preset = PRESET_FRAMES[spec.preset ?? "fade"];
1972
+ return {
1973
+ frames: preset[phase],
1974
+ duration: spec.duration ?? DEFAULT_DURATION3,
1975
+ easing: spec.easing ?? DEFAULT_EASING
1976
+ };
1977
+ }
1978
+ function runAnchorTransition(el, spec, phase) {
1979
+ const respectReduced = typeof spec === "object" ? spec.respectReducedMotion !== false : true;
1980
+ if (respectReduced && prefersReducedMotion()) return INSTANT;
1981
+ const { frames, duration, easing } = resolveTransitionFrames(spec, phase);
1982
+ if (!frames || duration <= 0) return INSTANT;
1983
+ if (typeof el.animate !== "function") return INSTANT;
1984
+ const animation = el.animate(frames, {
1985
+ duration,
1986
+ easing,
1987
+ fill: "both"
1988
+ });
1989
+ const finished = animation.finished.then(() => void 0).catch(() => void 0);
1990
+ return {
1991
+ finished,
1992
+ cancel: () => {
1993
+ try {
1994
+ animation.cancel();
1995
+ } catch {
1996
+ return;
1997
+ }
1998
+ }
1999
+ };
2000
+ }
2001
+
2002
+ // src/core/features/sheet-anchors.ts
2003
+ function attachAnchor(deps, opts) {
2004
+ const doc = deps.host.ownerDocument;
2005
+ const wrapper = doc.createElement("div");
2006
+ wrapper.className = "bs-anchor";
2007
+ const interactive = opts.interactive ?? true;
2008
+ const position = opts.position ?? "sheet-top-right";
2009
+ const isDock = position === "dock-bottom" || position === "dock-top";
2010
+ const ws = wrapper.style;
2011
+ ws.position = "fixed";
2012
+ if (isDock) {
2013
+ ws.left = "0";
2014
+ ws.right = "0";
2015
+ if (position === "dock-bottom") {
2016
+ ws.bottom = "0";
2017
+ } else {
2018
+ ws.top = "0";
2019
+ }
2020
+ } else {
2021
+ applyOverlayPosition(ws, deps.mode, position, opts.inset ?? "16px");
2022
+ }
2023
+ if (opts.fadeRange) {
2024
+ const [r0, r1] = opts.fadeRange;
2025
+ const span = Math.max(r1 - r0, 1e-4);
2026
+ ws.opacity = `clamp(0, calc((var(--bs-progress, 0) - ${r0}) / ${span}), 1)`;
2027
+ }
2028
+ wrapper.appendChild(opts.element);
2029
+ deps.host.appendChild(wrapper);
2030
+ let visible = null;
2031
+ let inFlight = null;
2032
+ const shouldShow = (state) => {
2033
+ if (typeof opts.showOn === "function") return opts.showOn(state);
2034
+ if (Array.isArray(opts.showOn)) return opts.showOn.includes(state.activeId);
2035
+ return isDock || state.size > 0;
2036
+ };
2037
+ const applyVisibility = (next, animate) => {
2038
+ if (next === visible) return;
2039
+ visible = next;
2040
+ inFlight?.cancel();
2041
+ inFlight = null;
2042
+ wrapper.style.pointerEvents = next && interactive ? "auto" : "none";
2043
+ if (next) {
2044
+ wrapper.style.visibility = "";
2045
+ if (animate) {
2046
+ inFlight = runAnchorTransition(opts.element, opts.animation, "enter");
2047
+ }
2048
+ return;
2049
+ }
2050
+ if (!animate) {
2051
+ wrapper.style.visibility = "hidden";
2052
+ return;
2053
+ }
2054
+ const handle = runAnchorTransition(opts.element, opts.animation, "exit");
2055
+ inFlight = handle;
2056
+ void handle.finished.then(() => {
2057
+ if (inFlight === handle && visible === false) {
2058
+ wrapper.style.visibility = "hidden";
2059
+ }
2060
+ });
2061
+ };
2062
+ const evaluate = (animate) => {
2063
+ if (deps.isDestroyed()) return;
2064
+ applyVisibility(shouldShow(deps.getState()), animate);
2065
+ };
2066
+ const offs = [
2067
+ deps.on("snap", () => evaluate(true)),
2068
+ deps.on("open", () => evaluate(true)),
2069
+ deps.on("close", () => evaluate(true))
2070
+ ];
2071
+ evaluate(false);
2072
+ return {
2073
+ wrapper,
2074
+ detach: () => {
2075
+ offs.forEach((off) => off());
2076
+ inFlight?.cancel();
2077
+ inFlight = null;
2078
+ if (wrapper.parentNode) wrapper.parentNode.removeChild(wrapper);
2079
+ },
2080
+ syncZ: (z) => {
2081
+ wrapper.style.zIndex = String(z);
2082
+ }
2083
+ };
2084
+ }
2085
+
2086
+ // src/core/features/scrim-stages.ts
2087
+ var matchesId = (def, activeId) => {
2088
+ if (def.for === void 0) return false;
2089
+ return Array.isArray(def.for) ? def.for.includes(activeId) : def.for === activeId;
2090
+ };
2091
+ var matchesRange = (def, progress) => {
2092
+ if (!def.forRange) return false;
2093
+ const [from, to] = def.forRange;
2094
+ return progress >= from && progress <= to;
2095
+ };
2096
+ function installScrimStages(deps, opts) {
2097
+ const doc = deps.host.ownerDocument;
2098
+ const entries = opts.stages.map((def) => {
2099
+ const wrapper = doc.createElement("div");
2100
+ wrapper.className = "bs-scrim-stage";
2101
+ const ws = wrapper.style;
2102
+ ws.position = "absolute";
2103
+ applyOverlayPosition(
2104
+ ws,
2105
+ deps.mode,
2106
+ def.position ?? opts.position ?? "center",
2107
+ def.inset ?? opts.inset ?? "16px"
2108
+ );
2109
+ ws.visibility = "hidden";
2110
+ ws.pointerEvents = "none";
2111
+ wrapper.appendChild(def.element);
2112
+ deps.host.appendChild(wrapper);
2113
+ return { def, wrapper, inFlight: null };
2114
+ });
2115
+ let active = null;
2116
+ const resolveActive = () => {
2117
+ const state = deps.getState();
2118
+ if (state.size === 0) {
2119
+ return entries.find((e) => matchesId(e.def, state.activeId)) ?? null;
2120
+ }
2121
+ return entries.find((e) => matchesId(e.def, state.activeId)) ?? entries.find((e) => matchesRange(e.def, state.progress)) ?? null;
2122
+ };
2123
+ const hide = (entry, animate) => {
2124
+ entry.inFlight?.cancel();
2125
+ entry.wrapper.style.pointerEvents = "none";
2126
+ if (!animate) {
2127
+ entry.inFlight = null;
2128
+ entry.wrapper.style.visibility = "hidden";
2129
+ return;
2130
+ }
2131
+ const handle = runAnchorTransition(
2132
+ entry.def.element,
2133
+ entry.def.animation ?? opts.animation,
2134
+ "exit"
2135
+ );
2136
+ entry.inFlight = handle;
2137
+ void handle.finished.then(() => {
2138
+ if (entry.inFlight === handle && active !== entry) {
2139
+ entry.wrapper.style.visibility = "hidden";
2140
+ }
2141
+ });
2142
+ };
2143
+ const show = (entry, animate) => {
2144
+ entry.inFlight?.cancel();
2145
+ entry.inFlight = null;
2146
+ entry.wrapper.style.visibility = "";
2147
+ entry.wrapper.style.pointerEvents = entry.def.interactive ?? opts.interactive ?? false ? "auto" : "none";
2148
+ if (animate) {
2149
+ entry.inFlight = runAnchorTransition(
2150
+ entry.def.element,
2151
+ entry.def.animation ?? opts.animation,
2152
+ "enter"
2153
+ );
2154
+ }
2155
+ };
2156
+ const update = (animate) => {
2157
+ if (deps.isDestroyed()) return;
2158
+ const next = resolveActive();
2159
+ if (next === active) return;
2160
+ const prev = active;
2161
+ active = next;
2162
+ if (prev) hide(prev, animate);
2163
+ if (next) show(next, animate);
2164
+ };
2165
+ const offs = [
2166
+ deps.on("snap", () => update(true)),
2167
+ deps.on("open", () => update(true)),
2168
+ deps.on("close", () => update(true))
2169
+ ];
2170
+ if (entries.some((e) => e.def.forRange)) {
2171
+ offs.push(deps.on("progress", () => update(true)));
2172
+ }
2173
+ update(false);
2174
+ return () => {
2175
+ offs.forEach((off) => off());
2176
+ for (const entry of entries) {
2177
+ entry.inFlight?.cancel();
2178
+ entry.inFlight = null;
2179
+ if (entry.wrapper.parentNode) {
2180
+ entry.wrapper.parentNode.removeChild(entry.wrapper);
2181
+ }
2182
+ }
2183
+ active = null;
2184
+ };
2185
+ }
2186
+
2037
2187
  // src/core/primitives/engine-options.ts
2038
2188
  var DEFAULT_FLICK_VELOCITY = 0.65;
2039
2189
  var DEFAULT_DRAG_THRESHOLD = 18;
@@ -2058,9 +2208,6 @@ function resolveEngineOptions(opts, extras) {
2058
2208
  scrimMode: opts.scrimMode,
2059
2209
  scrimColor: opts.scrimColor,
2060
2210
  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
2211
  scrimInteractive: opts.scrimInteractive ?? (opts.scrimTapToClose ? true : void 0),
2065
2212
  scrimTapToClose: opts.scrimTapToClose,
2066
2213
  scrimPreset: opts.scrimPreset,
@@ -2081,10 +2228,6 @@ function resolveEngineOptions(opts, extras) {
2081
2228
  closeOnEscape: opts.closeOnEscape,
2082
2229
  lockBodyScroll: opts.lockBodyScroll,
2083
2230
  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
2231
  shouldApplyInertSiblings: extras?.shouldApplyInertSiblings
2089
2232
  }
2090
2233
  };
@@ -2119,6 +2262,7 @@ var BottomSheetEngine = class {
2119
2262
  __publicField(this, "scrollContainer");
2120
2263
  __publicField(this, "backdrop");
2121
2264
  __publicField(this, "screenComponent");
2265
+ __publicField(this, "scrimParent", null);
2122
2266
  __publicField(this, "mode");
2123
2267
  __publicField(this, "flickVelocity");
2124
2268
  __publicField(this, "dragThreshold");
@@ -2129,40 +2273,22 @@ var BottomSheetEngine = class {
2129
2273
  __publicField(this, "lifecycle");
2130
2274
  __publicField(this, "closeOnBack");
2131
2275
  __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
2276
  __publicField(this, "snapPointsRaw");
2135
2277
  __publicField(this, "snaps");
2136
2278
  __publicField(this, "activeId");
2137
2279
  __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
2280
  __publicField(this, "gesture");
2142
2281
  __publicField(this, "rootEl", null);
2143
2282
  __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
2283
  __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
2284
  __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
2285
  __publicField(this, "sizeWriteSentinel", new WriteSentinel());
2156
2286
  __publicField(this, "progressWriteSentinel", new WriteSentinel());
2157
2287
  __publicField(this, "isTopSheet", true);
2158
- // Pooled payload — identity documented in SheetEventMap.progress JSDoc;
2159
- // consumers must not retain across frames.
2160
2288
  __publicField(this, "progressPayload", {
2161
2289
  value: 0,
2162
2290
  size: 0
2163
2291
  });
2164
- // Pooled buffer for `getDragContext()` — onMove calls this 60-120×/s.
2165
- // Inner `range` object identity is also stable (mutated in place).
2166
2292
  __publicField(this, "dragContextBuf", {
2167
2293
  size: 0,
2168
2294
  maxAxisSize: 0,
@@ -2173,13 +2299,13 @@ var BottomSheetEngine = class {
2173
2299
  __publicField(this, "persistKey");
2174
2300
  __publicField(this, "linkedSheets", []);
2175
2301
  __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
2302
  __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.
2303
+ __publicField(this, "anchors", []);
2304
+ __publicField(this, "anchorHost", null);
2305
+ __publicField(this, "stackZ", 100);
2306
+ __publicField(this, "detachScrimStages", null);
2307
+ __publicField(this, "stackEffectEnabled", false);
2308
+ __publicField(this, "stackEffectPrimed", false);
2183
2309
  __publicField(this, "transformTemplate");
2184
2310
  const resolved = resolveEngineOptions(opts, {
2185
2311
  shouldApplyInertSiblings: () => {
@@ -2194,7 +2320,8 @@ var BottomSheetEngine = class {
2194
2320
  this.handle = opts.handle ?? opts.element;
2195
2321
  this.scrollContainer = opts.scrollContainer;
2196
2322
  this.backdrop = opts.backdrop;
2197
- this.screenComponent = opts.screenComponent;
2323
+ this.screenComponent = opts.scrim ?? opts.screenComponent;
2324
+ this.scrimParent = this.screenComponent?.parentElement ?? null;
2198
2325
  this.mode = resolved.mode;
2199
2326
  this.transformTemplate = buildTransformTemplate(this.mode);
2200
2327
  this.aria = new AriaSliderWriter(this.handle, this.mode);
@@ -2212,6 +2339,9 @@ var BottomSheetEngine = class {
2212
2339
  getResolvedSnaps: () => this.snaps.getResolvedSnaps().slice(),
2213
2340
  snapTo: (id) => {
2214
2341
  void this.snapTo(id);
2342
+ },
2343
+ close: () => {
2344
+ void this.close();
2215
2345
  }
2216
2346
  },
2217
2347
  resolved.scrim
@@ -2222,8 +2352,6 @@ var BottomSheetEngine = class {
2222
2352
  getRootEl: () => this.rootEl,
2223
2353
  applySize: (size) => this.applySize(size),
2224
2354
  getSize: () => this.size,
2225
- // Optional chain protects synthetic test paths that drive animateTo
2226
- // before attach() (gesture is constructed there).
2227
2355
  isDragging: () => this.gesture?.isDragging ?? false
2228
2356
  },
2229
2357
  resolved.animation
@@ -2237,6 +2365,7 @@ var BottomSheetEngine = class {
2237
2365
  );
2238
2366
  this.closeOnBack = resolved.closeOnBack;
2239
2367
  this.routedTo = opts.routedTo;
2368
+ this.stackEffectEnabled = opts.stackEffect ?? false;
2240
2369
  this.snapPointsRaw = opts.snapPoints;
2241
2370
  this.persistKey = opts.persistKey;
2242
2371
  if (opts.linkedSheets) {
@@ -2249,7 +2378,7 @@ var BottomSheetEngine = class {
2249
2378
  this.snapPointsRaw,
2250
2379
  resolved.initialAllowed,
2251
2380
  this.mode,
2252
- () => this.handle.offsetHeight,
2381
+ () => layoutAxis(this.mode) === "width" ? this.handle.offsetWidth : this.handle.offsetHeight,
2253
2382
  (maxAxisSize) => {
2254
2383
  this.element.style[layoutAxis(this.mode)] = `${maxAxisSize}px`;
2255
2384
  }
@@ -2328,21 +2457,6 @@ var BottomSheetEngine = class {
2328
2457
  on(event, fn) {
2329
2458
  return this.bus.on(event, fn);
2330
2459
  }
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
2460
  use(plugin) {
2347
2461
  if (this.destroyed) {
2348
2462
  console.warn(
@@ -2352,9 +2466,6 @@ var BottomSheetEngine = class {
2352
2466
  }
2353
2467
  const scoped = [];
2354
2468
  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
2469
  add: (fn) => {
2359
2470
  if (typeof fn === "function") scoped.push(fn);
2360
2471
  }
@@ -2383,36 +2494,13 @@ var BottomSheetEngine = class {
2383
2494
  }
2384
2495
  return this;
2385
2496
  }
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
2497
  setLinkedSheets(sheets) {
2393
2498
  if (this.destroyed) return;
2394
2499
  this.linkedSheets = sheets;
2395
2500
  }
2396
- /** Read-only view of the current allow-list, for cross-sheet target negotiation. */
2397
2501
  getAllowedIds() {
2398
2502
  return this.snaps.getAllowedIds().slice();
2399
2503
  }
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
2504
  async snapTo(id, velocityOrOpts = 0, _skipBeforeSnap = false) {
2417
2505
  if (this.destroyed) return;
2418
2506
  const opts = typeof velocityOrOpts === "object" ? velocityOrOpts : { velocity: velocityOrOpts };
@@ -2471,16 +2559,6 @@ var BottomSheetEngine = class {
2471
2559
  this.handleClose();
2472
2560
  }
2473
2561
  }
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
2562
  async dragTo(targetSize, velocityOrOpts = 0) {
2485
2563
  if (this.destroyed) return;
2486
2564
  const opts = typeof velocityOrOpts === "object" ? velocityOrOpts : { velocity: velocityOrOpts };
@@ -2510,28 +2588,13 @@ var BottomSheetEngine = class {
2510
2588
  }
2511
2589
  setAllowed(ids, snap) {
2512
2590
  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
2591
  this.snaps.setAllowedIds(ids);
2519
2592
  this.updateAriaSlider();
2520
2593
  if (snap && ids.includes(snap)) {
2521
- void this.snapTo(
2522
- snap,
2523
- 0,
2524
- /* internal */
2525
- true
2526
- );
2594
+ void this.snapTo(snap, 0, true);
2527
2595
  } else if (!ids.includes(this.activeId)) {
2528
2596
  const fallback = ids[0];
2529
- if (fallback) void this.snapTo(
2530
- fallback,
2531
- 0,
2532
- /* internal */
2533
- true
2534
- );
2597
+ if (fallback) void this.snapTo(fallback, 0, true);
2535
2598
  }
2536
2599
  }
2537
2600
  setBackdropRange(range) {
@@ -2549,21 +2612,6 @@ var BottomSheetEngine = class {
2549
2612
  setScrimInteractive(interactive) {
2550
2613
  this.scrim.setScrimInteractive(interactive);
2551
2614
  }
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
2615
  setScrim(opts) {
2568
2616
  this.scrim.setScrim(opts, () => this.applySize(this.size));
2569
2617
  }
@@ -2579,11 +2627,79 @@ var BottomSheetEngine = class {
2579
2627
  setScrimOverlay(opts) {
2580
2628
  return this.scrim.setScrimOverlay(opts);
2581
2629
  }
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
- */
2630
+ addAnchor(opts) {
2631
+ if (this.destroyed || typeof document === "undefined") return () => {
2632
+ };
2633
+ if (!this.anchorHost) {
2634
+ this.anchorHost = this.rootEl ?? this.element.parentElement ?? document.body;
2635
+ this.anchorHost.style.setProperty("--bs-size", `${this.size}px`);
2636
+ this.anchorHost.style.setProperty(
2637
+ "--bs-progress",
2638
+ String(this.computeProgress(this.size))
2639
+ );
2640
+ }
2641
+ const handle = attachAnchor(
2642
+ {
2643
+ mode: this.mode,
2644
+ host: this.anchorHost,
2645
+ getState: () => ({
2646
+ activeId: this.activeId,
2647
+ size: this.size,
2648
+ progress: this.computeProgress(this.size)
2649
+ }),
2650
+ on: (event, fn) => this.on(event, fn),
2651
+ isDestroyed: () => this.destroyed
2652
+ },
2653
+ opts
2654
+ );
2655
+ handle.syncZ(this.stackZ + 1);
2656
+ this.anchors.push(handle);
2657
+ const detach = () => {
2658
+ const idx = this.anchors.indexOf(handle);
2659
+ if (idx === -1) return;
2660
+ this.anchors.splice(idx, 1);
2661
+ handle.detach();
2662
+ };
2663
+ this.teardowns.add(detach);
2664
+ return detach;
2665
+ }
2666
+ setScrimStages(opts) {
2667
+ if (this.destroyed || typeof document === "undefined") return () => {
2668
+ };
2669
+ this.detachScrimStages?.();
2670
+ this.detachScrimStages = null;
2671
+ if (!opts) return () => {
2672
+ };
2673
+ const host = this.screenComponent?.parentElement;
2674
+ if (!host) {
2675
+ console.warn(
2676
+ "[BottomSheet] setScrimStages: no scrim element mounted \u2014 pass `scrim` to the engine first."
2677
+ );
2678
+ return () => {
2679
+ };
2680
+ }
2681
+ const detachInstalled = installScrimStages(
2682
+ {
2683
+ mode: this.mode,
2684
+ host,
2685
+ getState: () => ({
2686
+ activeId: this.activeId,
2687
+ size: this.size,
2688
+ progress: this.computeProgress(this.size)
2689
+ }),
2690
+ on: (event, fn) => this.on(event, fn),
2691
+ isDestroyed: () => this.destroyed
2692
+ },
2693
+ opts
2694
+ );
2695
+ const detach = () => {
2696
+ if (this.detachScrimStages === detach) this.detachScrimStages = null;
2697
+ detachInstalled();
2698
+ };
2699
+ this.detachScrimStages = detach;
2700
+ this.teardowns.add(() => this.detachScrimStages?.());
2701
+ return detach;
2702
+ }
2587
2703
  getScrimState() {
2588
2704
  return { mode: this.scrim.scrimMode, enabled: this.scrim.scrimEnabled };
2589
2705
  }
@@ -2629,24 +2745,11 @@ var BottomSheetEngine = class {
2629
2745
  emit(event, payload) {
2630
2746
  this.bus.emit(event, payload);
2631
2747
  }
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
2748
  newCycle() {
2639
2749
  this.currentAbort.abort();
2640
2750
  this.currentAbort = new AbortController();
2641
2751
  return this.currentAbort.signal;
2642
2752
  }
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
2753
  emitBeforeSnap(target, previousId) {
2651
2754
  let cancelled = false;
2652
2755
  let frozen = false;
@@ -2680,59 +2783,54 @@ var BottomSheetEngine = class {
2680
2783
  this.snaps.setRaw(this.snapPointsRaw);
2681
2784
  this.scrim.invalidateOpacityCache();
2682
2785
  }
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
2786
  getAllowedRange() {
2689
2787
  return this.snaps.getAllowedRange();
2690
2788
  }
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
- */
2789
+ allowedIdsBySize() {
2790
+ return this.snaps.getAllowedIds().slice().sort(
2791
+ (a, b) => (this.snaps.findById(a)?.size ?? 0) - (this.snaps.findById(b)?.size ?? 0)
2792
+ );
2793
+ }
2707
2794
  updateAriaSlider() {
2708
- this.aria.setValue(this.snaps.getAllowedIds(), this.activeId);
2795
+ this.aria.setValue(this.allowedIdsBySize(), this.activeId);
2709
2796
  }
2710
2797
  registerInStack() {
2711
2798
  this.teardowns.add(sheetStack.push({
2712
2799
  id: this.id,
2713
2800
  setZIndex: (z) => {
2801
+ this.stackZ = z;
2714
2802
  this.element.style.zIndex = String(z);
2715
2803
  if (this.backdrop) this.backdrop.style.zIndex = String(z - 1);
2804
+ for (const anchor of this.anchors) anchor.syncZ(z + 1);
2716
2805
  },
2717
2806
  setIsTop: (isTop) => {
2718
2807
  this.isTopSheet = isTop;
2719
2808
  if (this.backdrop) {
2720
2809
  this.backdrop.style.display = isTop ? "" : "none";
2721
2810
  }
2722
- }
2811
+ },
2812
+ isOpen: () => this.size > 0,
2813
+ setDepth: (depth) => this.applyStackDepth(depth)
2723
2814
  }));
2724
2815
  }
2816
+ applyStackDepth(depth) {
2817
+ if (!this.stackEffectEnabled) return;
2818
+ this.element.setAttribute("data-stack-depth", String(depth));
2819
+ if (!this.stackEffectPrimed) {
2820
+ this.stackEffectPrimed = true;
2821
+ const isVertical = this.mode === "bottom" || this.mode === "top";
2822
+ this.element.style.transformOrigin = isVertical ? "50% 100%" : "0% 50%";
2823
+ this.element.style.transition = "scale 320ms cubic-bezier(0.29, 1.04, 0.84, 0.99)";
2824
+ }
2825
+ const scale = Math.max(1 - depth * 0.04, 0.86);
2826
+ this.element.style.scale = scale === 1 ? "" : String(scale);
2827
+ }
2725
2828
  attach() {
2726
2829
  this.gesture = new GestureController({
2727
2830
  handle: this.handle,
2728
2831
  element: this.element,
2729
2832
  mode: this.mode,
2730
2833
  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
2834
  getDragContext: () => {
2737
2835
  const buf = this.dragContextBuf;
2738
2836
  const r = this.getAllowedRange();
@@ -2786,7 +2884,7 @@ var BottomSheetEngine = class {
2786
2884
  container: this.scrollContainer,
2787
2885
  isDragging: () => this.gesture?.isDragging ?? false,
2788
2886
  isAnimating: () => this.animation.isAnimating,
2789
- getAllowedIds: () => this.snaps.getAllowedIds(),
2887
+ getAllowedIds: () => this.allowedIdsBySize(),
2790
2888
  getActiveId: () => this.activeId,
2791
2889
  snapTo: (id) => {
2792
2890
  void this.snapTo(id);
@@ -2815,11 +2913,13 @@ var BottomSheetEngine = class {
2815
2913
  },
2816
2914
  newCycle: () => {
2817
2915
  this.newCycle();
2818
- }
2916
+ },
2917
+ isAnimating: () => this.animation.isAnimating,
2918
+ resyncAfterCancel: () => this.resyncAfterResize()
2819
2919
  }));
2820
2920
  if (this.lifecycle.closeOnEscape) {
2821
2921
  const onKey = (e) => {
2822
- if (this.destroyed) return;
2922
+ if (this.destroyed || e.defaultPrevented) return;
2823
2923
  if (e.key === "Escape" && this.isTopSheet && this.size > 0) {
2824
2924
  void this.close();
2825
2925
  }
@@ -2851,7 +2951,7 @@ var BottomSheetEngine = class {
2851
2951
  handle: this.handle,
2852
2952
  mode: this.mode,
2853
2953
  isDestroyed: () => this.destroyed,
2854
- getAllowedIds: () => this.snaps.getAllowedIds(),
2954
+ getAllowedIds: () => this.allowedIdsBySize(),
2855
2955
  getActiveId: () => this.activeId,
2856
2956
  snapTo: (id) => {
2857
2957
  void this.snapTo(id);
@@ -2872,6 +2972,7 @@ var BottomSheetEngine = class {
2872
2972
  });
2873
2973
  if (!target) return;
2874
2974
  const previousId = this.activeId;
2975
+ if (target.id === previousId && target.size === this.size) return;
2875
2976
  if (this.emitBeforeSnap(target, previousId)) {
2876
2977
  const restore = this.snaps.findById(previousId);
2877
2978
  if (restore) {
@@ -2880,6 +2981,7 @@ var BottomSheetEngine = class {
2880
2981
  return;
2881
2982
  }
2882
2983
  const previousSize = this.size;
2984
+ const previousSnapSize = this.snaps.findById(previousId)?.size;
2883
2985
  this.scrollCache.cache(previousId, previousSize, target.size);
2884
2986
  this.activeId = target.id;
2885
2987
  void this.animation.animateTo(target.size, velocity).then(() => {
@@ -2888,9 +2990,10 @@ var BottomSheetEngine = class {
2888
2990
  this.updateAriaSlider();
2889
2991
  this.emit("snap", { id: target.id, size: target.size });
2890
2992
  this.haptic();
2891
- if (previousId === "closed" && target.size > 0) {
2993
+ if (previousSnapSize === 0 && target.size > 0) {
2892
2994
  this.emit("open", { id: target.id });
2893
2995
  this.handleOpen();
2996
+ notifyLinkedSheets(this.linkedSheets, this);
2894
2997
  }
2895
2998
  if (target.size === 0) {
2896
2999
  this.emit("close", void 0);
@@ -2898,7 +3001,6 @@ var BottomSheetEngine = class {
2898
3001
  }
2899
3002
  });
2900
3003
  }
2901
- /** Trigger a brief haptic tick (iOS Safari + Android Chrome support). */
2902
3004
  haptic() {
2903
3005
  if (typeof navigator !== "undefined" && "vibrate" in navigator) {
2904
3006
  try {
@@ -2914,6 +3016,12 @@ var BottomSheetEngine = class {
2914
3016
  style.transform = this.transformTemplate(offset);
2915
3017
  if (this.sizeWriteSentinel.shouldWrite(size, SIZE_WRITE_EPSILON)) {
2916
3018
  style.setProperty("--bs-size", `${size}px`);
3019
+ if (this.scrimParent) {
3020
+ this.scrimParent.style.setProperty("--bs-size", `${size}px`);
3021
+ }
3022
+ if (this.anchorHost && this.anchorHost !== this.scrimParent) {
3023
+ this.anchorHost.style.setProperty("--bs-size", `${size}px`);
3024
+ }
2917
3025
  }
2918
3026
  const progress = this.computeProgress(size);
2919
3027
  const progressChanged = this.progressWriteSentinel.shouldWrite(
@@ -2922,6 +3030,9 @@ var BottomSheetEngine = class {
2922
3030
  );
2923
3031
  if (progressChanged) {
2924
3032
  style.setProperty("--bs-progress", String(progress));
3033
+ if (this.anchorHost) {
3034
+ this.anchorHost.style.setProperty("--bs-progress", String(progress));
3035
+ }
2925
3036
  }
2926
3037
  this.scrim.applyOpacity(progress, progressChanged);
2927
3038
  if (progressChanged && this.bus.listenerCount("progress") > 0) {
@@ -2936,6 +3047,7 @@ var BottomSheetEngine = class {
2936
3047
  return Math.min(Math.max((size - min) / (max - min), 0), 1);
2937
3048
  }
2938
3049
  handleOpen() {
3050
+ sheetStack.promote(this.id);
2939
3051
  try {
2940
3052
  this.lifecycle.install();
2941
3053
  } catch (err) {
@@ -2949,8 +3061,24 @@ var BottomSheetEngine = class {
2949
3061
  });
2950
3062
  }
2951
3063
  }
3064
+ resyncAfterResize() {
3065
+ if (this.destroyed) return;
3066
+ const target = this.snaps.findById(this.activeId);
3067
+ if (!target) return;
3068
+ this.updateAriaSlider();
3069
+ this.emit("snap", { id: target.id, size: target.size });
3070
+ if (target.size > 0 && !this.lifecycle.isInstalled) {
3071
+ this.emit("open", { id: target.id });
3072
+ this.handleOpen();
3073
+ notifyLinkedSheets(this.linkedSheets, this);
3074
+ } else if (target.size === 0 && this.lifecycle.isInstalled) {
3075
+ this.emit("close", void 0);
3076
+ this.handleClose();
3077
+ }
3078
+ }
2952
3079
  handleClose() {
2953
3080
  this.lifecycle.release();
3081
+ sheetStack.update();
2954
3082
  }
2955
3083
  };
2956
3084
 
@@ -3045,8 +3173,6 @@ var OverlayEngine = class {
3045
3173
  __publicField(this, "exitEasing");
3046
3174
  __publicField(this, "backdropOpacity");
3047
3175
  __publicField(this, "backdropFilter");
3048
- // Definite-assignment: written via assignSwipeFromConfig in the
3049
- // constructor — TS can't follow the helper, mark the assertion explicit.
3050
3176
  __publicField(this, "swipeToCloseEnabled");
3051
3177
  __publicField(this, "swipeThreshold");
3052
3178
  __publicField(this, "swipeVelocityThreshold");
@@ -3061,18 +3187,16 @@ var OverlayEngine = class {
3061
3187
  __publicField(this, "closeOnOutsidePointerEnabled");
3062
3188
  __publicField(this, "detachOutsidePointer", null);
3063
3189
  __publicField(this, "detachSwipe", null);
3190
+ __publicField(this, "respectReducedMotionEnabled");
3191
+ __publicField(this, "isTop", true);
3192
+ __publicField(this, "backPushed", false);
3193
+ __publicField(this, "movedToTarget", false);
3064
3194
  __publicField(this, "onOpenCb");
3065
3195
  __publicField(this, "onCloseCb");
3066
3196
  __publicField(this, "isOpen_", false);
3067
3197
  __publicField(this, "isAnimating_", false);
3068
3198
  __publicField(this, "destroyed", false);
3069
- // Tracks whether setOverlayChildren ever populated the panel — lets
3070
- // clearOverlayChildren skip the DOM mutation when nothing was injected
3071
- // (so the field also acts as a zero-allocation feature flag).
3072
3199
  __publicField(this, "hasInjectedChildren", false);
3073
- // Bumped on every open/close. awaitTransition captures it and ignores
3074
- // transitionend/timeout fired by older cycles — prevents cross-cycle
3075
- // listener bleed during rapid toggling.
3076
3200
  __publicField(this, "cycleNonce", 0);
3077
3201
  __publicField(this, "bus", createEventBus());
3078
3202
  __publicField(this, "detachEscape", null);
@@ -3103,6 +3227,7 @@ var OverlayEngine = class {
3103
3227
  this.mountTarget = opts.mountTo;
3104
3228
  this.peekRaw = opts.peek;
3105
3229
  this.closeOnOutsidePointerEnabled = opts.closeOnOutsidePointer ?? false;
3230
+ this.respectReducedMotionEnabled = opts.respectReducedMotion ?? true;
3106
3231
  this.onOpenCb = opts.onOpen;
3107
3232
  this.onCloseCb = opts.onClose;
3108
3233
  if (this.peekRaw !== void 0 && this.edge !== "bottom") {
@@ -3125,9 +3250,6 @@ var OverlayEngine = class {
3125
3250
  closeOnEscape: this.closeOnEscape,
3126
3251
  lockBodyScroll: this.bodyScrollLockEnabled,
3127
3252
  inertSiblings: this.inertSiblingsEnabled,
3128
- // Overlay-specific guard: only paint body siblings inert when the
3129
- // overlay element is actually a body descendant. Detached / shadow-
3130
- // DOM / portal overlays would otherwise lock unrelated trees.
3131
3253
  shouldApplyInertSiblings: () => {
3132
3254
  const bodyChildren = Array.from(document.body.children);
3133
3255
  return bodyChildren.some(
@@ -3153,15 +3275,17 @@ var OverlayEngine = class {
3153
3275
  this.emit("before-open", void 0);
3154
3276
  this.isOpen_ = true;
3155
3277
  this.isAnimating_ = true;
3278
+ sheetStack.promote(this.id);
3156
3279
  this.element.removeAttribute("hidden");
3157
3280
  this.element.setAttribute("data-state", "entering");
3158
3281
  if (this.focusTrapEnabled) {
3159
3282
  this.element.setAttribute("aria-modal", "true");
3160
3283
  }
3161
- const transitionStr = `transform ${this.duration}ms ${this.enterEasing}, opacity ${this.duration}ms ease-out`;
3284
+ const dur = this.effectiveDuration();
3285
+ const transitionStr = `transform ${dur}ms ${this.enterEasing}, opacity ${dur}ms ease-out`;
3162
3286
  this.element.style.transition = transitionStr;
3163
3287
  if (this.backdrop) {
3164
- this.backdrop.style.transition = `opacity ${this.duration}ms ease-out`;
3288
+ this.backdrop.style.transition = `opacity ${dur}ms ease-out`;
3165
3289
  }
3166
3290
  const cycle = ++this.cycleNonce;
3167
3291
  return new Promise((resolve) => {
@@ -3173,7 +3297,7 @@ var OverlayEngine = class {
3173
3297
  this.element.style.opacity = "1";
3174
3298
  if (this.backdrop) {
3175
3299
  this.backdrop.style.opacity = String(this.backdropOpacity);
3176
- this.backdrop.style.pointerEvents = "auto";
3300
+ this.backdrop.style.pointerEvents = this.backdropOpacity > 0 ? "auto" : "none";
3177
3301
  if (this.backdropFilter) {
3178
3302
  this.backdrop.style.backdropFilter = this.backdropFilter;
3179
3303
  this.backdrop.style.webkitBackdropFilter = this.backdropFilter;
@@ -3209,7 +3333,10 @@ var OverlayEngine = class {
3209
3333
  });
3210
3334
  return resolve();
3211
3335
  }
3212
- this.awaitTransition(cycle).then(() => {
3336
+ this.awaitTransition(
3337
+ cycle,
3338
+ this.enterAnimation === "fade" ? "opacity" : "transform"
3339
+ ).then(() => {
3213
3340
  if (this.destroyed || cycle !== this.cycleNonce) return resolve();
3214
3341
  this.isAnimating_ = false;
3215
3342
  this.element.setAttribute("data-state", "open");
@@ -3226,11 +3353,14 @@ var OverlayEngine = class {
3226
3353
  this.emit("before-close", void 0);
3227
3354
  this.isOpen_ = false;
3228
3355
  this.isAnimating_ = true;
3356
+ sheetStack.update();
3357
+ this.popBackMarker(reason);
3229
3358
  this.element.setAttribute("data-state", "exiting");
3230
- const transitionStr = `transform ${this.duration}ms ${this.exitEasing}, opacity ${this.duration}ms ease-in`;
3359
+ const dur = this.effectiveDuration();
3360
+ const transitionStr = `transform ${dur}ms ${this.exitEasing}, opacity ${dur}ms ease-in`;
3231
3361
  this.element.style.transition = transitionStr;
3232
3362
  if (this.backdrop) {
3233
- this.backdrop.style.transition = `opacity ${this.duration}ms ease-in`;
3363
+ this.backdrop.style.transition = `opacity ${dur}ms ease-in`;
3234
3364
  }
3235
3365
  const cycle = ++this.cycleNonce;
3236
3366
  return new Promise((resolve) => {
@@ -3241,7 +3371,7 @@ var OverlayEngine = class {
3241
3371
  this.exitAnimation,
3242
3372
  this.peekPx
3243
3373
  );
3244
- this.element.style.opacity = "0";
3374
+ this.element.style.opacity = this.peekPx !== void 0 ? "1" : "0";
3245
3375
  if (this.backdrop) {
3246
3376
  this.backdrop.style.opacity = "0";
3247
3377
  this.backdrop.style.pointerEvents = "none";
@@ -3251,7 +3381,10 @@ var OverlayEngine = class {
3251
3381
  }
3252
3382
  }
3253
3383
  this.releaseInteractiveListeners();
3254
- this.awaitTransition(cycle).then(() => {
3384
+ this.awaitTransition(
3385
+ cycle,
3386
+ this.exitAnimation === "fade" ? "opacity" : "transform"
3387
+ ).then(() => {
3255
3388
  if (this.destroyed || cycle !== this.cycleNonce) return resolve();
3256
3389
  this.isAnimating_ = false;
3257
3390
  if (this.peekPx === void 0) {
@@ -3279,14 +3412,12 @@ var OverlayEngine = class {
3279
3412
  this.releaseInteractiveListeners();
3280
3413
  this.releaseStackEntry?.();
3281
3414
  this.releaseStackEntry = null;
3415
+ const hadOriginalParent = this.originalParent !== null;
3282
3416
  this.restoreMount();
3417
+ if (this.movedToTarget && !hadOriginalParent && this.element.parentNode) {
3418
+ this.element.parentNode.removeChild(this.element);
3419
+ }
3283
3420
  }
3284
- /**
3285
- * Runtime visual setter — opacity is applied immediately when open so a
3286
- * live-controls panel can preview the change without re-opening. When
3287
- * closed we only stash the field; next open() picks it up so we don't
3288
- * mutate hidden DOM (avoids paint thrash on detached panels).
3289
- */
3290
3421
  setBackdropOpacity(opacity) {
3291
3422
  if (this.destroyed) return;
3292
3423
  this.backdropOpacity = opacity;
@@ -3294,11 +3425,6 @@ var OverlayEngine = class {
3294
3425
  this.backdrop.style.opacity = String(opacity);
3295
3426
  }
3296
3427
  }
3297
- /**
3298
- * Runtime visual setter for `backdrop-filter`. `undefined` clears the
3299
- * filter (mirrors the close() behaviour). Defers to next open() when the
3300
- * overlay is currently closed.
3301
- */
3302
3428
  setBackdropFilter(filter) {
3303
3429
  if (this.destroyed) return;
3304
3430
  this.backdropFilter = filter;
@@ -3312,12 +3438,6 @@ var OverlayEngine = class {
3312
3438
  style.webkitBackdropFilter = "";
3313
3439
  }
3314
3440
  }
3315
- /**
3316
- * Toggle / reconfigure swipe-to-close at runtime. Re-resolves the flat
3317
- * threshold fields, then installs or tears down the pointer listener so
3318
- * the change applies to the CURRENT open cycle. Idempotent: safe to call
3319
- * with the same config repeatedly.
3320
- */
3321
3441
  setSwipeToClose(config) {
3322
3442
  if (this.destroyed) return;
3323
3443
  const wasEnabled = this.swipeToCloseEnabled;
@@ -3330,7 +3450,6 @@ var OverlayEngine = class {
3330
3450
  this.detachSwipe = null;
3331
3451
  }
3332
3452
  }
3333
- /** Animation setters only stage the field — applied on next open/close. */
3334
3453
  setEnterAnimation(animation) {
3335
3454
  if (this.destroyed) return;
3336
3455
  this.enterAnimation = animation;
@@ -3339,23 +3458,15 @@ var OverlayEngine = class {
3339
3458
  if (this.destroyed) return;
3340
3459
  this.exitAnimation = animation;
3341
3460
  }
3342
- /** Updates the field used at next close() — no immediate side effect. */
3343
3461
  setReturnFocus(target) {
3344
3462
  if (this.destroyed) return;
3345
3463
  this.lifecycle.setReturnFocus(target);
3346
3464
  }
3347
- /**
3348
- * Batch update — preset first, then individual overrides. Applied as a
3349
- * single DOM-write phase when open so live-controls don't trigger
3350
- * cascading reflows. Safe to call when closed (defers to next open).
3351
- */
3352
3465
  setOverlay(opts) {
3353
3466
  if (this.destroyed) return;
3354
3467
  const preset = opts.preset ? OVERLAY_PRESETS[opts.preset] : void 0;
3355
3468
  const merged = preset ? {
3356
3469
  backdropOpacity: preset.backdropOpacity,
3357
- // Preset's `undefined` filter means "no filter" — surface as null
3358
- // so the merged step clears the field instead of inheriting old.
3359
3470
  backdropFilter: preset.backdropFilter === void 0 ? null : preset.backdropFilter,
3360
3471
  swipeToClose: preset.swipeToClose,
3361
3472
  enterAnimation: preset.enterAnimation,
@@ -3406,6 +3517,36 @@ var OverlayEngine = class {
3406
3517
  }
3407
3518
  }
3408
3519
  }
3520
+ if (opts.duration !== void 0) this.duration = opts.duration;
3521
+ if (opts.enterEasing !== void 0) this.enterEasing = opts.enterEasing;
3522
+ if (opts.exitEasing !== void 0) this.exitEasing = opts.exitEasing;
3523
+ if (opts.closeOnEscape !== void 0) {
3524
+ this.closeOnEscape = opts.closeOnEscape;
3525
+ if (!opts.closeOnEscape) {
3526
+ this.detachEscape?.();
3527
+ this.detachEscape = null;
3528
+ } else if (this.isOpen_) {
3529
+ this.installInteractiveListeners();
3530
+ }
3531
+ }
3532
+ if (opts.closeOnBackdrop !== void 0) {
3533
+ this.closeOnBackdropEnabled = opts.closeOnBackdrop;
3534
+ if (!opts.closeOnBackdrop) {
3535
+ this.detachBackdrop?.();
3536
+ this.detachBackdrop = null;
3537
+ } else if (this.isOpen_) {
3538
+ this.installInteractiveListeners();
3539
+ }
3540
+ }
3541
+ if (opts.closeOnOutsidePointer !== void 0) {
3542
+ this.closeOnOutsidePointerEnabled = opts.closeOnOutsidePointer;
3543
+ if (!opts.closeOnOutsidePointer) {
3544
+ this.detachOutsidePointer?.();
3545
+ this.detachOutsidePointer = null;
3546
+ } else if (this.isOpen_) {
3547
+ this.installInteractiveListeners();
3548
+ }
3549
+ }
3409
3550
  if (opts.children !== void 0) {
3410
3551
  if (opts.children === null) {
3411
3552
  this.clearOverlayChildren();
@@ -3414,30 +3555,17 @@ var OverlayEngine = class {
3414
3555
  }
3415
3556
  }
3416
3557
  }
3417
- /**
3418
- * Runtime children injection — replace the panel's contents without
3419
- * unmounting the engine. `replaceChildren()` is a single atomic DOM op,
3420
- * so callers can swap content rapidly without intermediate flicker.
3421
- * Function form is resolved exactly ONCE per call (cheap lazy content).
3422
- */
3423
3558
  setOverlayChildren(children) {
3424
3559
  if (this.destroyed) return;
3425
3560
  const node = typeof children === "function" ? children() : children;
3426
3561
  this.element.replaceChildren(node);
3427
3562
  this.hasInjectedChildren = true;
3428
3563
  }
3429
- /**
3430
- * Clear injected children. No-op when nothing was injected so consumers
3431
- * who never used setOverlayChildren can call this defensively without
3432
- * paying for a DOM mutation.
3433
- */
3434
3564
  clearOverlayChildren() {
3435
3565
  if (this.destroyed || !this.hasInjectedChildren) return;
3436
3566
  this.element.replaceChildren();
3437
3567
  this.hasInjectedChildren = false;
3438
3568
  }
3439
- // Shared swipe-config normalizer — used by constructor, setSwipeToClose,
3440
- // and setOverlay so the union/object shape is decoded in exactly one place.
3441
3569
  assignSwipeFromConfig(swipe) {
3442
3570
  if (typeof swipe === "object" && swipe !== null) {
3443
3571
  this.swipeToCloseEnabled = swipe.enabled ?? true;
@@ -3452,6 +3580,22 @@ var OverlayEngine = class {
3452
3580
  emit(event, payload) {
3453
3581
  this.bus.emit(event, payload);
3454
3582
  }
3583
+ effectiveDuration() {
3584
+ return this.respectReducedMotionEnabled && prefersReducedMotion() ? 0 : this.duration;
3585
+ }
3586
+ popBackMarker(reason) {
3587
+ if (!this.backPushed) return;
3588
+ this.backPushed = false;
3589
+ if (reason === "back") return;
3590
+ const state = history.state;
3591
+ if (state && state.__bsOverlay === this.id) {
3592
+ try {
3593
+ history.back();
3594
+ } catch {
3595
+ return;
3596
+ }
3597
+ }
3598
+ }
3455
3599
  resolvePeekPx(raw) {
3456
3600
  if (typeof raw === "number") return Math.max(0, raw);
3457
3601
  try {
@@ -3473,6 +3617,7 @@ var OverlayEngine = class {
3473
3617
  this.originalParent = this.element.parentNode;
3474
3618
  this.originalNextSibling = this.element.nextSibling;
3475
3619
  target.appendChild(this.element);
3620
+ this.movedToTarget = true;
3476
3621
  this.mounted = true;
3477
3622
  }
3478
3623
  restoreMount() {
@@ -3506,7 +3651,7 @@ var OverlayEngine = class {
3506
3651
  this.backdrop.style.willChange = "opacity";
3507
3652
  }
3508
3653
  }
3509
- awaitTransition(cycle) {
3654
+ awaitTransition(cycle, property = "transform") {
3510
3655
  return new Promise((resolve) => {
3511
3656
  let done = false;
3512
3657
  const finish = () => {
@@ -3521,10 +3666,10 @@ var OverlayEngine = class {
3521
3666
  finish();
3522
3667
  return;
3523
3668
  }
3524
- if (e.propertyName === "transform") finish();
3669
+ if (e.target === this.element && e.propertyName === property) finish();
3525
3670
  };
3526
3671
  this.element.addEventListener("transitionend", onEnd);
3527
- const timer = window.setTimeout(finish, this.duration + 50);
3672
+ const timer = window.setTimeout(finish, this.effectiveDuration() + 50);
3528
3673
  });
3529
3674
  }
3530
3675
  installInteractiveListeners() {
@@ -3534,7 +3679,10 @@ var OverlayEngine = class {
3534
3679
  }
3535
3680
  if (this.closeOnEscape && !this.detachEscape) {
3536
3681
  const handler = (e) => {
3537
- if (e.key === "Escape" && this.isOpen_) void this.close("escape");
3682
+ if (e.defaultPrevented) return;
3683
+ if (e.key === "Escape" && this.isOpen_ && this.isTop) {
3684
+ void this.close("escape");
3685
+ }
3538
3686
  };
3539
3687
  document.addEventListener("keydown", handler);
3540
3688
  this.detachEscape = () => document.removeEventListener("keydown", handler);
@@ -3547,10 +3695,14 @@ var OverlayEngine = class {
3547
3695
  if (this.closeOnBackEnabled && typeof window !== "undefined" && !this.detachHardwareBack) {
3548
3696
  try {
3549
3697
  history.pushState({ __bsOverlay: this.id }, "");
3698
+ this.backPushed = true;
3550
3699
  } catch {
3551
3700
  }
3552
3701
  const onPop = () => {
3553
- if (this.isOpen_) void this.close("back");
3702
+ if (this.isOpen_ && this.isTop) {
3703
+ this.backPushed = false;
3704
+ void this.close("back");
3705
+ }
3554
3706
  };
3555
3707
  window.addEventListener("popstate", onPop);
3556
3708
  this.detachHardwareBack = () => window.removeEventListener("popstate", onPop);
@@ -3581,55 +3733,57 @@ var OverlayEngine = class {
3581
3733
  this.detachOutsidePointer = null;
3582
3734
  this.lifecycle.release();
3583
3735
  }
3584
- /**
3585
- * Light pointer-driven dismiss. Tracks finger along the edge axis, follows
3586
- * with translate, dismisses if past `swipeThreshold` (fraction of panel
3587
- * size) or release velocity exceeds `swipeVelocityThreshold` (px/ms).
3588
- * Pure CSS-transform during drag — no rAF or physics required since the
3589
- * panel only moves one direction.
3590
- */
3591
3736
  installSwipeToClose() {
3592
3737
  const el = this.element;
3593
3738
  const edge = this.edge;
3594
3739
  const isVertical = edge === "bottom" || edge === "top";
3595
3740
  const sign = edge === "bottom" || edge === "right" ? 1 : -1;
3596
- const threshold = this.swipeThreshold;
3597
- const velocityThreshold = this.swipeVelocityThreshold;
3741
+ const SLOP = 8;
3598
3742
  let startX = 0;
3599
3743
  let startY = 0;
3600
3744
  let startT = 0;
3745
+ let tracking = false;
3601
3746
  let dragging = false;
3602
3747
  let pointerId = null;
3748
+ const previousTouchAction = el.style.touchAction;
3749
+ el.style.touchAction = isVertical ? "pan-x" : "pan-y";
3603
3750
  const onDown = (e) => {
3604
3751
  if (e.button !== void 0 && e.button !== 0) return;
3605
3752
  pointerId = e.pointerId;
3606
3753
  startX = e.clientX;
3607
3754
  startY = e.clientY;
3608
3755
  startT = performance.now();
3609
- dragging = true;
3610
- el.style.transition = "none";
3611
- try {
3612
- el.setPointerCapture(e.pointerId);
3613
- } catch {
3614
- }
3756
+ tracking = true;
3757
+ dragging = false;
3615
3758
  };
3616
3759
  const onMove = (e) => {
3617
- if (!dragging || e.pointerId !== pointerId) return;
3760
+ if (!tracking || e.pointerId !== pointerId) return;
3618
3761
  const delta = isVertical ? e.clientY - startY : e.clientX - startX;
3762
+ if (!dragging) {
3763
+ if (Math.abs(delta) < SLOP) return;
3764
+ dragging = true;
3765
+ el.style.transition = "none";
3766
+ try {
3767
+ el.setPointerCapture(e.pointerId);
3768
+ } catch {
3769
+ }
3770
+ }
3619
3771
  const offset = Math.max(0, sign * delta);
3620
3772
  el.style.transform = isVertical ? `translate3d(0, ${sign * offset}px, 0)` : `translate3d(${sign * offset}px, 0, 0)`;
3621
3773
  };
3622
3774
  const finish = (e) => {
3623
- if (!dragging || e.pointerId !== pointerId) return;
3624
- dragging = false;
3775
+ if (!tracking || e.pointerId !== pointerId) return;
3776
+ tracking = false;
3625
3777
  pointerId = null;
3778
+ if (!dragging) return;
3779
+ dragging = false;
3626
3780
  const dt = performance.now() - startT || 1;
3627
3781
  const delta = isVertical ? e.clientY - startY : e.clientX - startX;
3628
3782
  const offset = Math.max(0, sign * delta);
3629
3783
  const velocity = sign * delta / dt;
3630
3784
  const size = isVertical ? el.offsetHeight : el.offsetWidth;
3631
- const past = offset > size * threshold || velocity > velocityThreshold;
3632
- el.style.transition = `transform ${this.duration}ms ${this.exitEasing}`;
3785
+ const past = offset > size * this.swipeThreshold || velocity > this.swipeVelocityThreshold;
3786
+ el.style.transition = `transform ${this.effectiveDuration()}ms ${this.exitEasing}`;
3633
3787
  if (past) {
3634
3788
  void this.close("swipe");
3635
3789
  } else {
@@ -3641,6 +3795,7 @@ var OverlayEngine = class {
3641
3795
  el.addEventListener("pointerup", finish);
3642
3796
  el.addEventListener("pointercancel", finish);
3643
3797
  return () => {
3798
+ el.style.touchAction = previousTouchAction;
3644
3799
  el.removeEventListener("pointerdown", onDown);
3645
3800
  el.removeEventListener("pointermove", onMove);
3646
3801
  el.removeEventListener("pointerup", finish);
@@ -3655,15 +3810,17 @@ var OverlayEngine = class {
3655
3810
  if (this.backdrop) this.backdrop.style.zIndex = String(z - 1);
3656
3811
  },
3657
3812
  setIsTop: (isTop) => {
3813
+ this.isTop = isTop;
3658
3814
  if (this.backdrop) {
3659
3815
  this.backdrop.style.display = isTop ? "" : "none";
3660
3816
  }
3661
- }
3817
+ },
3818
+ isOpen: () => this.isOpen_
3662
3819
  });
3663
3820
  }
3664
3821
  };
3665
3822
  var createOverlay = (opts) => new OverlayEngine(opts);
3666
3823
 
3667
- export { BottomSheetEngine, DEFAULT_SPRING, OVERLAY_PRESETS, OverlayEngine as Overlay, OverlayEngine, SCRIM_PRESETS, allowedRange, attachGestures, createOverlay, createSheetManager, easeOutBack, easeOutCubic, findById, findNearest, installFocusTrap, installGestures, lockBodyScroll, prefersReducedMotion, resolveSnap, resolveSnapList, runSpring, sheetStack, tween };
3824
+ export { BottomSheetEngine, DEFAULT_SPRING, OVERLAY_PRESETS, OverlayEngine as Overlay, OverlayEngine, SCRIM_PRESETS, allowedRange, applyOverlayPosition, attachGestures, createOverlay, createSheetManager, easeOutBack, easeOutCubic, findById, findNearest, installFocusTrap, installGestures, lockBodyScroll, prefersReducedMotion, resolveSheetAnchoredStyle, resolveSnap, resolveSnapList, runAnchorTransition, runSpring, sheetStack, tween };
3668
3825
  //# sourceMappingURL=index.js.map
3669
3826
  //# sourceMappingURL=index.js.map