@surdeddd/bottom-sheet 0.10.0 โ†’ 0.11.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.
package/README.md CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  > Universal, headless bottom-sheet engine. **One core, eight adapters, GPU-accelerated, fully tested.**
4
4
 
5
- ![bundle](https://img.shields.io/badge/core-16.8%20KB%20gzip-1a1614?style=flat-square)
6
- ![tests](https://img.shields.io/badge/tests-501%20unit-2ea44f?style=flat-square)
5
+ ![bundle](https://img.shields.io/badge/core-%E2%89%A422.5%20KB%20gzip%20budget-1a1614?style=flat-square)
6
+ ![tests](https://img.shields.io/badge/tests-500%2B%20unit-2ea44f?style=flat-square)
7
7
  ![ts](https://img.shields.io/badge/TypeScript-strict-3178c6?style=flat-square&logo=typescript&logoColor=white)
8
8
  ![license](https://img.shields.io/badge/license-MIT-dc3522?style=flat-square)
9
9
 
@@ -14,40 +14,41 @@ stacking โ€” and the exact same engine behind every adapter.
14
14
  ## ๐Ÿš€ [Live demo โ†’ bottom-sheet-demo.vercel.app](https://bottom-sheet-demo.vercel.app)
15
15
 
16
16
  <p align="center">
17
- <img src="https://raw.githubusercontent.com/Surdeddd/BottomSheet/main/docs/gifs/01-drag-to-close.gif" alt="drag to close โ€” spring physics" width="380" />
17
+ <picture>
18
+ <source media="(prefers-color-scheme: dark)" srcset="docs/screenshots/sheet-dark.png" />
19
+ <img src="docs/screenshots/sheet-light.png" alt="bottom sheet at a mid snap point over a dimmed page" width="420" />
20
+ </picture>
18
21
  </p>
19
22
 
20
- ```
23
+ <p align="center">
24
+ <img src="https://raw.githubusercontent.com/Surdeddd/BottomSheet/main/docs/gifs/01-drag-to-close.gif" alt="drag to close โ€” spring physics" width="380" />
25
+ </p>
21
26
 
22
- โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
23
- โ”‚ ENGINE (~9.15 KB gzip) โ”‚
24
- โ”‚ spring ยท gestures ยท snap math โ”‚
25
- โ”‚ focus trap ยท scroll lock ยท stack โ”‚
26
- โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
27
- โ”‚
28
- โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
29
- โ–ผ โ–ผ โ–ผ โ–ผ โ–ผ โ–ผ โ–ผ โ–ผ
30
- React Vue 3 Svelte Solid Lit Web Comp. Vanilla Angular
31
- (recipe)
32
- ```
27
+ <p align="center">
28
+ <img src="docs/assets/architecture.svg" alt="One engine core feeding eight adapters โ€” React, Preact, Vue 3, Svelte, Solid, Qwik, Web Component and Vanilla โ€” plus a standalone /overlay subpath" width="720" />
29
+ </p>
33
30
 
34
- ## Why another bottom-sheet?
35
-
36
- | Feature | vaul | react-modal-sheet | **this lib** |
37
- | ------------------------------------ | ------- | ----------------- | ------------- |
38
- | React adapter | โœ“ | โœ“ | โœ“ |
39
- | Vue / Svelte / Web Component | โ€” | โ€” | **โœ“ โœ“ โœ“** |
40
- | Vanilla / no framework | โ€” | โ€” | **โœ“** |
41
- | `bottom` mode | โœ“ | โœ“ | โœ“ |
42
- | `top` / `left` / `right` / overlay | โ€” | โ€” | **โœ“** |
43
- | Spring physics with velocity carry | โœ“ | partial | **โœ“** |
44
- | Pointer-type-aware tuning | โ€” | โ€” | **โœ“** |
45
- | Hardware-back interception (Android) | โ€” | โ€” | **โœ“** |
46
- | `env(safe-area-inset-*)` | partial | โ€” | **โœ“** |
47
- | WCAG 2.1 AA keyboard slider | partial | โ€” | **โœ“** |
48
- | Multi-sheet stacking | โ€” | โ€” | **โœ“** |
49
- | Sheet-manager registry (typed) | โ€” | โ€” | **โœ“** |
50
- | Tests (unit + e2e) | partial | partial | **501 + 195** |
31
+ ## What it does
32
+
33
+ Every item below is verifiable in this repo's `src/`:
34
+
35
+ - **8 build targets** โ€” React, Preact, Vue 3, Svelte 5, Solid, Qwik, Web
36
+ Component (Lit-compatible `<bottom-sheet>`) and the vanilla core, plus an
37
+ `/overlay` subpath (standalone slide-up panel).
38
+ - **4 axes** โ€” `bottom`, `top`, `left`, `right` (via `mode`).
39
+ - **Spring physics with velocity carry**, plus pointer-type-aware tuning
40
+ (separate touch / mouse / pen drag thresholds).
41
+ - **Hardware-back interception** on Android โ€” opt-in via `closeOnBack`.
42
+ - **`env(safe-area-inset-*)`-aware padding** so action rows clear the home
43
+ indicator.
44
+ - **WCAG 2.1 AA keyboard slider** โ€” Arrow/Home/End/Esc stepping, focus trap
45
+ with restore.
46
+ - **Multi-sheet stacking** plus a typed sheet-manager registry.
47
+ - **Cancelable `before-snap` / `before-close`** โ€” veto a transition
48
+ synchronously.
49
+ - Backed by 500+ unit tests across the suite and ~200 Playwright e2e runs
50
+ across three browser engines (`mobile-chrome` / `mobile-safari` /
51
+ `firefox`).
51
52
 
52
53
  ## Install
53
54
 
@@ -456,19 +457,19 @@ npm run demo
456
457
  # http://localhost:5173
457
458
  ```
458
459
 
459
- The editorial demo showcases all five adapters, every gesture, every mode
460
+ The editorial demo showcases the adapters, every gesture, every mode
460
461
  (`bottom ยท top ยท left ยท right ยท overlay`), and live engine readouts (active
461
462
  snap, progress, velocity, FPS). EN/RU and light/dark toggles in the corner.
462
463
 
463
464
  ## Testing
464
465
 
465
466
  ```bash
466
- npm test # 501 unit tests via vitest + happy-dom (~6s)
467
+ npm test # 500+ unit tests via vitest + happy-dom
467
468
  npx playwright test # e2e via Playwright on mobile-Chrome / mobile-Safari / Firefox
468
469
  npm run typecheck # TypeScript --noEmit
469
470
  ```
470
471
 
471
- CI runs all three across Node 20/22 on every PR and tag.
472
+ CI runs all three across Node 20.19 / 22 on every PR and tag.
472
473
 
473
474
  ## Contributing
474
475
 
package/dist/element.cjs CHANGED
@@ -504,6 +504,202 @@ var init_linked_sheets = __esm({
504
504
  }
505
505
  });
506
506
 
507
+ // src/core/features/history-coordinator.ts
508
+ function onPopState() {
509
+ if (suppress > 0) {
510
+ suppress -= 1;
511
+ const rebrand = pendingRebrand;
512
+ pendingRebrand = null;
513
+ if (rebrand) rebrand();
514
+ maybeTeardownListener();
515
+ return;
516
+ }
517
+ if (entries.length === 0) {
518
+ notifyRouteChange();
519
+ maybeTeardownListener();
520
+ return;
521
+ }
522
+ let target = null;
523
+ let targetIdx = -1;
524
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
525
+ const entry = entries[i];
526
+ if (!entry.surface.isOpen()) continue;
527
+ if (!target) {
528
+ target = entry;
529
+ targetIdx = i;
530
+ }
531
+ if (entry.surface.isTop()) {
532
+ target = entry;
533
+ targetIdx = i;
534
+ break;
535
+ }
536
+ }
537
+ if (!target) {
538
+ const removed = entries.pop();
539
+ if (removed) removed.live = false;
540
+ maybeTeardownListener();
541
+ return;
542
+ }
543
+ target.live = false;
544
+ entries.splice(targetIdx, 1);
545
+ const settled = target;
546
+ void Promise.resolve(settled.surface.close()).then(() => {
547
+ if (settled.surface.isOpen()) restore(settled);
548
+ });
549
+ maybeTeardownListener();
550
+ }
551
+ function restore(entry) {
552
+ if (!hasWindow() || !hasHistory()) return;
553
+ entry.live = true;
554
+ runInternal(() => {
555
+ try {
556
+ history.pushState(entry.surface.markerState(), "", entry.surface.url);
557
+ } catch {
558
+ entry.live = false;
559
+ }
560
+ });
561
+ if (entry.live) {
562
+ entries.push(entry);
563
+ ensureListener();
564
+ }
565
+ }
566
+ function pushBackMarker(surface) {
567
+ const entry = { surface, priorUrl: void 0, live: false };
568
+ if (!hasWindow() || !hasHistory()) {
569
+ return entry;
570
+ }
571
+ entry.priorUrl = surface.url !== void 0 ? location.href : void 0;
572
+ runInternal(() => {
573
+ try {
574
+ history.pushState(surface.markerState(), "", surface.url);
575
+ entry.live = true;
576
+ } catch {
577
+ entry.live = false;
578
+ }
579
+ });
580
+ if (entry.live) {
581
+ entries.push(entry);
582
+ ensureListener();
583
+ }
584
+ return entry;
585
+ }
586
+ function popBackMarker(handle) {
587
+ if (!hasWindow() || !hasHistory()) return;
588
+ const entry = handle;
589
+ const idx = entries.indexOf(entry);
590
+ if (idx === -1 || !entry.live) return;
591
+ const last = entries.length - 1;
592
+ entry.live = false;
593
+ entries.splice(idx, 1);
594
+ if (idx !== last) {
595
+ const rebrandSurface = entries[entries.length - 1].surface;
596
+ const rebrandUrl = entry.priorUrl;
597
+ pendingRebrand = () => {
598
+ runInternal(() => {
599
+ history.replaceState(
600
+ rebrandSurface.markerState(),
601
+ "",
602
+ rebrandUrl !== void 0 ? rebrandUrl : location.href
603
+ );
604
+ });
605
+ };
606
+ }
607
+ suppress += 1;
608
+ runInternal(() => {
609
+ try {
610
+ history.back();
611
+ } catch {
612
+ suppress = Math.max(0, suppress - 1);
613
+ pendingRebrand = null;
614
+ }
615
+ });
616
+ maybeTeardownListener();
617
+ }
618
+ function subscribeRouteChange(fn) {
619
+ if (!hasWindow() || !hasHistory()) return () => {
620
+ };
621
+ subscribers.add(fn);
622
+ if (subscribers.size === 1) installPatch();
623
+ ensureListener();
624
+ return () => {
625
+ if (!subscribers.has(fn)) return;
626
+ subscribers.delete(fn);
627
+ if (subscribers.size === 0) removePatch();
628
+ maybeTeardownListener();
629
+ };
630
+ }
631
+ var entries, subscribers, suppress, internalDepth, pendingRebrand, listenerInstalled, patchInstalled, origPushState, origReplaceState, patchedPushState, patchedReplaceState, hasWindow, hasHistory, runInternal, notifyRouteChange, ensureListener, maybeTeardownListener, installPatch, removePatch;
632
+ var init_history_coordinator = __esm({
633
+ "src/core/features/history-coordinator.ts"() {
634
+ entries = [];
635
+ subscribers = /* @__PURE__ */ new Set();
636
+ suppress = 0;
637
+ internalDepth = 0;
638
+ pendingRebrand = null;
639
+ listenerInstalled = false;
640
+ patchInstalled = false;
641
+ origPushState = null;
642
+ origReplaceState = null;
643
+ patchedPushState = null;
644
+ patchedReplaceState = null;
645
+ hasWindow = () => typeof window !== "undefined";
646
+ hasHistory = () => typeof history !== "undefined";
647
+ runInternal = (fn) => {
648
+ internalDepth += 1;
649
+ try {
650
+ fn();
651
+ } finally {
652
+ internalDepth -= 1;
653
+ }
654
+ };
655
+ notifyRouteChange = () => {
656
+ const snapshot = Array.from(subscribers);
657
+ for (const fn of snapshot) fn();
658
+ };
659
+ ensureListener = () => {
660
+ if (listenerInstalled || !hasWindow()) return;
661
+ window.addEventListener("popstate", onPopState);
662
+ listenerInstalled = true;
663
+ };
664
+ maybeTeardownListener = () => {
665
+ if (!listenerInstalled) return;
666
+ if (entries.length > 0 || subscribers.size > 0 || suppress > 0) return;
667
+ if (hasWindow()) window.removeEventListener("popstate", onPopState);
668
+ listenerInstalled = false;
669
+ };
670
+ installPatch = () => {
671
+ if (patchInstalled || !hasHistory()) return;
672
+ origPushState = history.pushState;
673
+ origReplaceState = history.replaceState;
674
+ patchedPushState = function(...args) {
675
+ origPushState.apply(this, args);
676
+ if (internalDepth === 0) notifyRouteChange();
677
+ };
678
+ patchedReplaceState = function(...args) {
679
+ origReplaceState.apply(this, args);
680
+ if (internalDepth === 0) notifyRouteChange();
681
+ };
682
+ history.pushState = patchedPushState;
683
+ history.replaceState = patchedReplaceState;
684
+ patchInstalled = true;
685
+ };
686
+ removePatch = () => {
687
+ if (!patchInstalled) return;
688
+ if (history.pushState === patchedPushState && origPushState) {
689
+ history.pushState = origPushState;
690
+ }
691
+ if (history.replaceState === patchedReplaceState && origReplaceState) {
692
+ history.replaceState = origReplaceState;
693
+ }
694
+ origPushState = null;
695
+ origReplaceState = null;
696
+ patchedPushState = null;
697
+ patchedReplaceState = null;
698
+ patchInstalled = false;
699
+ };
700
+ }
701
+ });
702
+
507
703
  // src/core/features/route.ts
508
704
  function installRoute(deps) {
509
705
  if (typeof window === "undefined") {
@@ -514,135 +710,43 @@ function installRoute(deps) {
514
710
  return () => {
515
711
  };
516
712
  }
517
- let pushed = false;
518
- let closer = null;
519
- const teardownEntry = () => {
520
- if (closer) {
521
- const idx = closers.indexOf(closer);
522
- if (idx !== -1) closers.splice(idx, 1);
523
- }
524
- const wasConsumed = closer?.consumed ?? false;
525
- closer = null;
526
- if (pushed && !wasConsumed) {
527
- pushed = false;
528
- programmaticBack();
529
- } else {
530
- pushed = false;
531
- }
532
- };
533
- const pushEntry = () => {
534
- try {
535
- if (deps.routedTo !== void 0) {
536
- history.pushState({ __bsRouted: deps.sheetId }, "", deps.routedTo);
537
- } else {
538
- history.pushState({ __bsSheet: deps.sheetId }, "");
539
- }
540
- pushed = true;
541
- } catch {
542
- pushed = false;
543
- }
544
- };
713
+ let handle = null;
545
714
  const onOpen = () => {
546
- if (closer) return;
547
- const self = {
715
+ if (handle) return;
716
+ handle = pushBackMarker({
548
717
  isOpen: () => !deps.isDestroyed() && deps.getSize() > 0,
549
- isTop: () => deps.isTopSheet(),
550
- close: () => deps.close(),
551
- restore: () => {
552
- if (deps.isDestroyed() || closer !== self) return;
553
- self.consumed = false;
554
- if (!closers.includes(self)) closers.push(self);
555
- pushEntry();
556
- },
557
- consumed: false
558
- };
559
- closer = self;
560
- ensureHandler();
561
- closers.push(self);
562
- pushEntry();
718
+ isTop: deps.isTopSheet,
719
+ close: deps.close,
720
+ markerState: () => deps.routedTo !== void 0 ? { __bsRouted: deps.sheetId } : { __bsSheet: deps.sheetId },
721
+ url: deps.routedTo
722
+ });
723
+ };
724
+ const onClose = () => {
725
+ if (!handle) return;
726
+ popBackMarker(handle);
727
+ handle = null;
563
728
  };
564
729
  const unsubscribeOpen = deps.on("open", onOpen);
565
- const unsubscribeClose = deps.on("close", teardownEntry);
730
+ const unsubscribeClose = deps.on("close", onClose);
566
731
  if (deps.getSize() > 0) onOpen();
567
732
  return () => {
568
733
  unsubscribeOpen();
569
734
  unsubscribeClose();
570
- teardownEntry();
735
+ if (handle) {
736
+ popBackMarker(handle);
737
+ handle = null;
738
+ }
571
739
  };
572
740
  }
573
741
  function installRouteChange(deps) {
574
- if (typeof window === "undefined" || typeof history === "undefined") {
575
- return () => {
576
- };
577
- }
578
- const onChange = () => {
742
+ return subscribeRouteChange(() => {
579
743
  if (deps.isDestroyed() || deps.getSize() <= 0) return;
580
744
  void deps.close();
581
- };
582
- const wrap = (key) => {
583
- const original = history[key];
584
- const patched = function(...args) {
585
- original.apply(this, args);
586
- onChange();
587
- };
588
- history[key] = patched;
589
- return () => {
590
- if (history[key] === patched) {
591
- history[key] = original;
592
- }
593
- };
594
- };
595
- window.addEventListener("popstate", onChange);
596
- const restorePush = wrap("pushState");
597
- const restoreReplace = wrap("replaceState");
598
- return () => {
599
- window.removeEventListener("popstate", onChange);
600
- restorePush();
601
- restoreReplace();
602
- };
745
+ });
603
746
  }
604
- var closers, handlerInstalled, suppress, ensureHandler, programmaticBack;
605
747
  var init_route = __esm({
606
748
  "src/core/features/route.ts"() {
607
- closers = [];
608
- handlerInstalled = false;
609
- suppress = 0;
610
- ensureHandler = () => {
611
- if (handlerInstalled || typeof window === "undefined") return;
612
- handlerInstalled = true;
613
- window.addEventListener("popstate", () => {
614
- if (suppress > 0) {
615
- suppress -= 1;
616
- return;
617
- }
618
- let target = null;
619
- for (let i = closers.length - 1; i >= 0; i -= 1) {
620
- const c = closers[i];
621
- if (!c.isOpen()) continue;
622
- if (!target) target = c;
623
- if (c.isTop()) {
624
- target = c;
625
- break;
626
- }
627
- }
628
- if (!target) return;
629
- target.consumed = true;
630
- const idx = closers.indexOf(target);
631
- if (idx !== -1) closers.splice(idx, 1);
632
- const settled = target;
633
- void Promise.resolve(settled.close()).then(() => {
634
- if (settled.isOpen()) settled.restore();
635
- });
636
- });
637
- };
638
- programmaticBack = () => {
639
- suppress += 1;
640
- try {
641
- history.back();
642
- } catch {
643
- suppress = Math.max(0, suppress - 1);
644
- }
645
- };
749
+ init_history_coordinator();
646
750
  }
647
751
  });
648
752
 
@@ -2640,7 +2744,7 @@ var init_sheet_anchors = __esm({
2640
2744
  // src/core/features/scrim-stages.ts
2641
2745
  function installScrimStages(deps, opts) {
2642
2746
  const doc = deps.host.ownerDocument;
2643
- const entries = opts.stages.map((def) => {
2747
+ const entries2 = opts.stages.map((def) => {
2644
2748
  const wrapper = doc.createElement("div");
2645
2749
  wrapper.className = "bs-scrim-stage";
2646
2750
  const ws = wrapper.style;
@@ -2661,9 +2765,9 @@ function installScrimStages(deps, opts) {
2661
2765
  const resolveActive = () => {
2662
2766
  const state = deps.getState();
2663
2767
  if (state.size === 0) {
2664
- return entries.find((e) => matchesId(e.def, state.activeId)) ?? null;
2768
+ return entries2.find((e) => matchesId(e.def, state.activeId)) ?? null;
2665
2769
  }
2666
- return entries.find((e) => matchesId(e.def, state.activeId)) ?? entries.find((e) => matchesRange(e.def, state.progress)) ?? null;
2770
+ return entries2.find((e) => matchesId(e.def, state.activeId)) ?? entries2.find((e) => matchesRange(e.def, state.progress)) ?? null;
2667
2771
  };
2668
2772
  const hide = (entry, animate) => {
2669
2773
  entry.inFlight?.cancel();
@@ -2712,13 +2816,13 @@ function installScrimStages(deps, opts) {
2712
2816
  deps.on("open", () => update(true)),
2713
2817
  deps.on("close", () => update(true))
2714
2818
  ];
2715
- if (entries.some((e) => e.def.forRange)) {
2819
+ if (entries2.some((e) => e.def.forRange)) {
2716
2820
  offs.push(deps.on("progress", () => update(true)));
2717
2821
  }
2718
2822
  update(false);
2719
2823
  return () => {
2720
2824
  offs.forEach((off) => off());
2721
- for (const entry of entries) {
2825
+ for (const entry of entries2) {
2722
2826
  entry.inFlight?.cancel();
2723
2827
  entry.inFlight = null;
2724
2828
  if (entry.wrapper.parentNode) {
@@ -3038,8 +3142,8 @@ var init_BottomSheetEngine = __esm({
3038
3142
  this.attach();
3039
3143
  this.registerInStack();
3040
3144
  if (this.snaps.getMaxAxisSize() === 0 && typeof IntersectionObserver !== "undefined" && typeof document !== "undefined") {
3041
- const io = new IntersectionObserver((entries) => {
3042
- for (const entry of entries) {
3145
+ const io = new IntersectionObserver((entries2) => {
3146
+ for (const entry of entries2) {
3043
3147
  if (!entry.isIntersecting) continue;
3044
3148
  if (this.destroyed) return;
3045
3149
  this.snaps.recompute();
@@ -3756,9 +3860,9 @@ var init_BottomSheetEngine = __esm({
3756
3860
  return;
3757
3861
  }
3758
3862
  if (this.emitBeforeSnap(target, previousId)) {
3759
- const restore = this.snaps.findById(previousId);
3760
- if (restore) {
3761
- void this.animation.animateTo(restore.size, 0);
3863
+ const restore2 = this.snaps.findById(previousId);
3864
+ if (restore2) {
3865
+ void this.animation.animateTo(restore2.size, 0);
3762
3866
  }
3763
3867
  return;
3764
3868
  }