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