@howells/stacksheet 1.1.1 → 1.1.3

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/dist/index.js CHANGED
@@ -1,3 +1,9 @@
1
+ // src/create.tsx
2
+ import { Portal } from "@radix-ui/react-portal";
3
+ import { createContext as createContext2, useContext as useContext2, useMemo as useMemo2 } from "react";
4
+ import { useStore as useStore2 } from "zustand";
5
+ import { useShallow } from "zustand/react/shallow";
6
+
1
7
  // src/springs.ts
2
8
  var springs = {
3
9
  subtle: { stiffness: 300, damping: 30, mass: 1 },
@@ -60,16 +66,17 @@ function resolveConfig(config = {}) {
60
66
  };
61
67
  }
62
68
 
63
- // src/create.tsx
64
- import { Portal } from "@radix-ui/react-portal";
65
- import { createContext as createContext2, useContext as useContext2, useMemo as useMemo2 } from "react";
66
- import { useStore as useStore2 } from "zustand";
67
- import { useShallow } from "zustand/react/shallow";
68
-
69
69
  // src/renderer.tsx
70
70
  import FocusTrap from "focus-trap-react";
71
- import { AnimatePresence, motion as m } from "motion/react";
72
71
  import {
72
+ AnimatePresence,
73
+ motion as m,
74
+ useMotionValue,
75
+ useReducedMotion,
76
+ useTransform
77
+ } from "motion/react";
78
+ import {
79
+ memo,
73
80
  useCallback as useCallback2,
74
81
  useEffect as useEffect3,
75
82
  useMemo,
@@ -166,8 +173,12 @@ function resolveSnapPointPx(point, viewportHeight) {
166
173
  case "px":
167
174
  return value;
168
175
  case "rem":
169
- case "em":
170
- return value * Number.parseFloat(getComputedStyle(document.documentElement).fontSize);
176
+ case "em": {
177
+ const fontSize = typeof document !== "undefined" ? Number.parseFloat(
178
+ getComputedStyle(document.documentElement).fontSize
179
+ ) : 16;
180
+ return value * fontSize;
181
+ }
171
182
  case "vh":
172
183
  case "%":
173
184
  return value / 100 * viewportHeight;
@@ -307,7 +318,7 @@ function getTransformOrigin(side) {
307
318
  }
308
319
  return "center top";
309
320
  }
310
- function getPanelStyles(side, config, _depth, index) {
321
+ function getPanelStyles(side, config, index) {
311
322
  const { width, maxWidth, zIndex } = config;
312
323
  const base = {
313
324
  position: "fixed",
@@ -323,7 +334,9 @@ function getPanelStyles(side, config, _depth, index) {
323
334
  left: 0,
324
335
  right: 0,
325
336
  bottom: 0,
326
- maxHeight: "85vh"
337
+ // dvh tracks the dynamic viewport on iOS Safari (accounts for browser chrome).
338
+ // The vh fallback covers browsers that don't support dvh yet.
339
+ maxHeight: "85dvh"
327
340
  // borderRadius is animated via Motion's animate prop for scale correction
328
341
  };
329
342
  }
@@ -416,16 +429,27 @@ function classifyGesture(dx, dy, axis, sign) {
416
429
  }
417
430
  return "drag";
418
431
  }
432
+ function commitGesture(dx, dy, axis, sign, scrollEl) {
433
+ const gesture = classifyGesture(dx, dy, axis, sign);
434
+ if (gesture === "none") {
435
+ return "none";
436
+ }
437
+ if (scrollEl && !isAtScrollEdge(scrollEl, axis, sign)) {
438
+ return "none";
439
+ }
440
+ return "drag";
441
+ }
419
442
  function getPanelDimension(panel, axis) {
420
443
  if (!panel) {
421
444
  return 300;
422
445
  }
423
446
  return axis === "x" ? panel.offsetWidth : panel.offsetHeight;
424
447
  }
425
- function useDrag(panelRef, config, onDragUpdate) {
448
+ function useDrag(panelRef, config, callbacks) {
426
449
  const startRef = useRef(null);
427
450
  const committedRef = useRef(null);
428
451
  const offsetRef = useRef(0);
452
+ const isDraggingRef = useRef(false);
429
453
  const scrollTargetRef = useRef(null);
430
454
  const { axis, sign } = getDismissAxis(config.side);
431
455
  const handlePointerDown = useCallback(
@@ -464,19 +488,17 @@ function useDrag(panelRef, config, onDragUpdate) {
464
488
  return;
465
489
  }
466
490
  if (committedRef.current === null) {
467
- const gesture = classifyGesture(dx, dy, axis, sign);
468
- if (gesture === "none") {
469
- committedRef.current = "none";
470
- startRef.current = null;
471
- return;
472
- }
473
- const scrollEl = scrollTargetRef.current;
474
- if (scrollEl && !isAtScrollEdge(scrollEl, axis, sign)) {
475
- committedRef.current = "none";
491
+ committedRef.current = commitGesture(
492
+ dx,
493
+ dy,
494
+ axis,
495
+ sign,
496
+ scrollTargetRef.current
497
+ );
498
+ if (committedRef.current !== "drag") {
476
499
  startRef.current = null;
477
500
  return;
478
501
  }
479
- committedRef.current = "drag";
480
502
  }
481
503
  if (committedRef.current !== "drag") {
482
504
  return;
@@ -485,10 +507,14 @@ function useDrag(panelRef, config, onDragUpdate) {
485
507
  const directional = rawOffset * sign;
486
508
  const clampedOffset = directional >= 0 ? directional : -Math.sqrt(Math.abs(directional)) * RUBBER_BAND_FACTOR;
487
509
  offsetRef.current = clampedOffset;
488
- onDragUpdate({ offset: clampedOffset, isDragging: true });
510
+ callbacks.dragOffset.set(clampedOffset);
511
+ if (!isDraggingRef.current) {
512
+ isDraggingRef.current = true;
513
+ callbacks.onDragStart();
514
+ }
489
515
  e.preventDefault();
490
516
  },
491
- [axis, sign, onDragUpdate]
517
+ [axis, sign, callbacks]
492
518
  );
493
519
  const dismiss = useCallback(() => {
494
520
  if (config.isNested) {
@@ -496,8 +522,10 @@ function useDrag(panelRef, config, onDragUpdate) {
496
522
  } else {
497
523
  config.onClose();
498
524
  }
499
- onDragUpdate({ offset: 0, isDragging: false });
500
- }, [config, onDragUpdate]);
525
+ callbacks.dragOffset.set(0);
526
+ isDraggingRef.current = false;
527
+ callbacks.onDragEnd();
528
+ }, [config, callbacks]);
501
529
  const handlePointerUp = useCallback(
502
530
  (_e) => {
503
531
  if (!startRef.current || committedRef.current !== "drag") {
@@ -527,7 +555,9 @@ function useDrag(panelRef, config, onDragUpdate) {
527
555
  dismiss();
528
556
  } else {
529
557
  config.onSnap(targetIndex);
530
- onDragUpdate({ offset: 0, isDragging: false });
558
+ callbacks.dragOffset.set(0);
559
+ isDraggingRef.current = false;
560
+ callbacks.onDragEnd();
531
561
  }
532
562
  return;
533
563
  }
@@ -536,18 +566,24 @@ function useDrag(panelRef, config, onDragUpdate) {
536
566
  if (pastThreshold || fastEnough) {
537
567
  dismiss();
538
568
  } else {
539
- onDragUpdate({ offset: 0, isDragging: false });
569
+ callbacks.dragOffset.set(0);
570
+ isDraggingRef.current = false;
571
+ callbacks.onDragEnd();
540
572
  }
541
573
  },
542
- [panelRef, axis, config, onDragUpdate, dismiss]
574
+ [panelRef, axis, config, callbacks, dismiss]
543
575
  );
544
576
  const handlePointerCancel = useCallback(() => {
545
577
  startRef.current = null;
546
578
  committedRef.current = null;
547
579
  offsetRef.current = 0;
548
580
  scrollTargetRef.current = null;
549
- onDragUpdate({ offset: 0, isDragging: false });
550
- }, [onDragUpdate]);
581
+ callbacks.dragOffset.set(0);
582
+ if (isDraggingRef.current) {
583
+ isDraggingRef.current = false;
584
+ callbacks.onDragEnd();
585
+ }
586
+ }, [callbacks]);
551
587
  useEffect2(() => {
552
588
  const el = panelRef.current;
553
589
  if (!(el && config.enabled)) {
@@ -625,10 +661,7 @@ function resolveClassNames(cn) {
625
661
  header: cn.header ?? ""
626
662
  };
627
663
  }
628
- function selectSpring(isTop, spring, stackSpring) {
629
- return isTop ? spring : stackSpring;
630
- }
631
- function buildAriaProps(isTop, isModal, isComposable, ariaLabel, panelId) {
664
+ function buildAriaProps(isTop, isModal, isComposable, ariaLabel, panelId, hasDescription) {
632
665
  if (!isTop) {
633
666
  return {};
634
667
  }
@@ -638,27 +671,19 @@ function buildAriaProps(isTop, isModal, isComposable, ariaLabel, panelId) {
638
671
  }
639
672
  if (isComposable) {
640
673
  props["aria-labelledby"] = `${panelId}-title`;
641
- props["aria-describedby"] = `${panelId}-desc`;
674
+ if (hasDescription) {
675
+ props["aria-describedby"] = `${panelId}-desc`;
676
+ }
642
677
  } else {
643
678
  props["aria-label"] = ariaLabel;
644
679
  }
645
680
  return props;
646
681
  }
647
- function getDragTransform(side, offset) {
648
- if (offset === 0) {
649
- return {};
650
- }
651
- switch (side) {
652
- case "right":
653
- return { x: offset };
654
- case "left":
655
- return { x: -offset };
656
- case "bottom":
657
- return { y: offset };
658
- default:
659
- return {};
660
- }
661
- }
682
+ var VISUAL_TWEEN = {
683
+ type: "tween",
684
+ duration: 0.25,
685
+ ease: "easeOut"
686
+ };
662
687
  function usePanelHeight(panelRef, hasSnapPoints) {
663
688
  const [height, setHeight] = useState2(0);
664
689
  useEffect3(() => {
@@ -690,16 +715,11 @@ function buildPanelStyle(panelStyles, isTop, hasPanelClass, isDragging) {
690
715
  };
691
716
  }
692
717
  function buildPanelTransition(isDragging, isTop, spring, stackSpring) {
693
- const visualTween = {
694
- type: "tween",
695
- duration: 0.25,
696
- ease: "easeOut"
697
- };
698
718
  if (isDragging) {
699
719
  return { type: "tween", duration: 0 };
700
720
  }
701
- const base = selectSpring(isTop, spring, stackSpring);
702
- return { ...base, borderRadius: visualTween, boxShadow: visualTween };
721
+ const base = isTop ? spring : stackSpring;
722
+ return { ...base, borderRadius: VISUAL_TWEEN, boxShadow: VISUAL_TWEEN };
703
723
  }
704
724
  function computeSnapYOffset(side, snapHeights, activeSnapIndex, measuredHeight) {
705
725
  if (side !== "bottom" || snapHeights.length === 0 || measuredHeight <= 0) {
@@ -707,6 +727,38 @@ function computeSnapYOffset(side, snapHeights, activeSnapIndex, measuredHeight)
707
727
  }
708
728
  return getSnapOffset(activeSnapIndex, snapHeights, measuredHeight);
709
729
  }
730
+ function ModalFocusTrap({
731
+ enabled,
732
+ active,
733
+ fallbackRef,
734
+ children
735
+ }) {
736
+ if (!enabled) {
737
+ return children;
738
+ }
739
+ return /* @__PURE__ */ jsx2(
740
+ FocusTrap,
741
+ {
742
+ active,
743
+ focusTrapOptions: {
744
+ initialFocus: false,
745
+ returnFocusOnDeactivate: true,
746
+ escapeDeactivates: false,
747
+ allowOutsideClick: true,
748
+ checkCanFocusTrap: () => new Promise(
749
+ (resolve) => requestAnimationFrame(() => resolve())
750
+ ),
751
+ fallbackFocus: () => {
752
+ if (fallbackRef.current) {
753
+ return fallbackRef.current;
754
+ }
755
+ return document.body;
756
+ }
757
+ },
758
+ children
759
+ }
760
+ );
761
+ }
710
762
  function PanelInnerContent({
711
763
  isComposable,
712
764
  shouldRender,
@@ -731,31 +783,54 @@ function PanelInnerContent({
731
783
  )
732
784
  ] });
733
785
  }
734
- function BottomHandle() {
786
+ function BottomHandle({ onDismiss }) {
735
787
  return /* @__PURE__ */ jsx2(
736
- "div",
788
+ "button",
737
789
  {
738
- className: "flex shrink-0 cursor-grab touch-none items-center justify-center pt-4 pb-1",
790
+ "aria-label": "Dismiss",
791
+ className: "flex w-full shrink-0 cursor-grab touch-none items-center justify-center border-none bg-transparent pt-4 pb-1",
739
792
  "data-stacksheet-handle": "",
740
- children: /* @__PURE__ */ jsx2("div", { className: "h-1 w-9 rounded-sm bg-current/25" })
793
+ onClick: onDismiss,
794
+ type: "button",
795
+ children: /* @__PURE__ */ jsx2("div", { "aria-hidden": "true", className: "h-1 w-9 rounded-sm bg-current/25" })
741
796
  }
742
797
  );
743
798
  }
744
- function SideHandle({ side, isHovered }) {
799
+ function SideHandle({
800
+ side,
801
+ isHovered,
802
+ onDismiss
803
+ }) {
745
804
  const position = side === "right" ? { right: "100%" } : { left: "100%" };
746
805
  return /* @__PURE__ */ jsx2(
747
806
  m.div,
748
807
  {
749
808
  animate: { opacity: isHovered ? 1 : 0 },
809
+ "aria-label": "Dismiss",
750
810
  className: "absolute top-0 bottom-0 flex w-6 cursor-grab touch-none items-center justify-center",
751
811
  "data-stacksheet-handle": "",
812
+ onClick: onDismiss,
813
+ onKeyDown: (e) => {
814
+ if (e.key === "Enter" || e.key === " ") {
815
+ e.preventDefault();
816
+ onDismiss?.();
817
+ }
818
+ },
819
+ role: "button",
752
820
  style: position,
821
+ tabIndex: 0,
753
822
  transition: { duration: isHovered ? 0.15 : 0.4, ease: "easeOut" },
754
- children: /* @__PURE__ */ jsx2("div", { className: "h-10 w-[5px] rounded-sm bg-current/35 shadow-sm" })
823
+ children: /* @__PURE__ */ jsx2(
824
+ "div",
825
+ {
826
+ "aria-hidden": "true",
827
+ className: "h-10 w-[5px] rounded-sm bg-current/35 shadow-sm"
828
+ }
829
+ )
755
830
  }
756
831
  );
757
832
  }
758
- function SheetPanel({
833
+ var SheetPanel = memo(function SheetPanel2({
759
834
  item,
760
835
  index,
761
836
  depth,
@@ -778,18 +853,25 @@ function SheetPanel({
778
853
  slideTarget,
779
854
  spring,
780
855
  stackSpring,
781
- exitSpring
856
+ exitSpring,
857
+ prefersReducedMotion
782
858
  }) {
783
859
  const panelRef = useRef2(null);
784
860
  const hasEnteredRef = useRef2(false);
785
- const [dragState, setDragState] = useState2({
786
- offset: 0,
787
- isDragging: false
788
- });
861
+ const dragOffset = useMotionValue(0);
862
+ const [isDragging, setIsDragging] = useState2(false);
789
863
  const [isHovered, setIsHovered] = useState2(false);
864
+ const dragCallbacks = useMemo(
865
+ () => ({
866
+ dragOffset,
867
+ onDragStart: () => setIsDragging(true),
868
+ onDragEnd: () => setIsDragging(false)
869
+ }),
870
+ [dragOffset]
871
+ );
790
872
  const measuredHeight = usePanelHeight(panelRef, snapHeights.length > 0);
791
873
  const transform = getStackTransform(depth, config.stacking);
792
- const panelStyles = getPanelStyles(side, config, depth, index);
874
+ const panelStyles = getPanelStyles(side, config, index);
793
875
  useEffect3(() => {
794
876
  if (!isTop) {
795
877
  hasEnteredRef.current = false;
@@ -804,7 +886,7 @@ function SheetPanel({
804
886
  useDrag(
805
887
  panelRef,
806
888
  {
807
- enabled: isTop && config.drag && config.dismissible,
889
+ enabled: isTop && config.drag && config.dismissible && !prefersReducedMotion,
808
890
  closeThreshold: config.closeThreshold,
809
891
  velocityThreshold: config.velocityThreshold,
810
892
  side,
@@ -816,22 +898,44 @@ function SheetPanel({
816
898
  onSnap,
817
899
  sequential: config.snapToSequentialPoints
818
900
  },
819
- setDragState
901
+ dragCallbacks
820
902
  );
821
903
  const ariaLabel = (typeof item.data?.__ariaLabel === "string" ? item.data.__ariaLabel : void 0) ?? config.ariaLabel;
822
904
  const panelId = `stacksheet-${item.id}`;
905
+ const [hasDescription, setHasDescription] = useState2(false);
906
+ const registerDescription = useCallback2(() => {
907
+ setHasDescription(true);
908
+ return () => setHasDescription(false);
909
+ }, []);
823
910
  const panelContext = useMemo(
824
- () => ({ close, back: pop, isNested, isTop, panelId, side }),
825
- [close, pop, isNested, isTop, panelId, side]
911
+ () => ({
912
+ close,
913
+ back: pop,
914
+ isNested,
915
+ isTop,
916
+ panelId,
917
+ side,
918
+ hasDescription,
919
+ registerDescription
920
+ }),
921
+ [
922
+ close,
923
+ pop,
924
+ isNested,
925
+ isTop,
926
+ panelId,
927
+ side,
928
+ hasDescription,
929
+ registerDescription
930
+ ]
826
931
  );
827
932
  const isComposable = renderHeader === false;
828
933
  const hasPanelClass = classNames.panel !== "";
829
- const dragOffset = getDragTransform(side, dragState.offset);
830
934
  const panelStyle = buildPanelStyle(
831
935
  panelStyles,
832
936
  isTop,
833
937
  hasPanelClass,
834
- dragState.isDragging
938
+ isDragging
835
939
  );
836
940
  const headerProps = {
837
941
  isNested,
@@ -844,10 +948,11 @@ function SheetPanel({
844
948
  config.modal,
845
949
  isComposable,
846
950
  ariaLabel,
847
- panelId
951
+ panelId,
952
+ hasDescription
848
953
  );
849
954
  const transition = buildPanelTransition(
850
- dragState.isDragging,
955
+ isDragging,
851
956
  isTop,
852
957
  spring,
853
958
  stackSpring
@@ -863,22 +968,19 @@ function SheetPanel({
863
968
  const animateTarget = {
864
969
  ...slideTarget,
865
970
  ...stackOffset,
866
- ...dragOffset,
867
971
  scale: transform.scale,
868
972
  opacity: transform.opacity,
869
973
  ...animatedRadius,
870
- boxShadow: getShadow(side, !isTop),
974
+ boxShadow: getShadow(!isTop),
871
975
  transition,
872
- ...snapYOffset > 0 ? { y: (dragOffset.y ?? 0) + snapYOffset } : {}
976
+ ...snapYOffset > 0 ? { y: snapYOffset } : {}
873
977
  };
978
+ const dragSign = side === "left" ? -1 : 1;
979
+ const dragTranslate = useTransform(dragOffset, (v) => v * dragSign);
980
+ const dragStyle = side === "bottom" ? { y: dragTranslate } : { x: dragTranslate };
874
981
  const initialRadius = getInitialRadius(side);
875
982
  const showSideHandle = isTop && side !== "bottom";
876
983
  const showBottomHandle = isTop && side === "bottom";
877
- const exitTween = {
878
- type: "tween",
879
- duration: 0.25,
880
- ease: "easeOut"
881
- };
882
984
  const panelContent = /* @__PURE__ */ jsxs(
883
985
  m.div,
884
986
  {
@@ -887,26 +989,36 @@ function SheetPanel({
887
989
  exit: {
888
990
  ...slideFrom,
889
991
  opacity: 0.6,
890
- boxShadow: getShadow(side, false),
891
- transition: { ...exitSpring, boxShadow: exitTween }
992
+ boxShadow: getShadow(false),
993
+ transition: { ...exitSpring, boxShadow: VISUAL_TWEEN }
892
994
  },
893
995
  initial: {
894
996
  ...slideFrom,
895
997
  opacity: 0.8,
896
998
  ...initialRadius,
897
- boxShadow: getShadow(side, false)
999
+ boxShadow: getShadow(false)
898
1000
  },
899
1001
  onAnimationComplete: handleAnimationComplete,
1002
+ onBlur: showSideHandle ? () => setIsHovered(false) : void 0,
1003
+ onFocus: showSideHandle ? () => setIsHovered(true) : void 0,
900
1004
  onMouseEnter: showSideHandle ? () => setIsHovered(true) : void 0,
901
1005
  onMouseLeave: showSideHandle ? () => setIsHovered(false) : void 0,
902
1006
  ref: panelRef,
903
- style: panelStyle,
1007
+ style: { ...panelStyle, ...dragStyle },
904
1008
  tabIndex: isTop ? -1 : void 0,
1009
+ ...isTop ? {} : { "aria-hidden": "true", inert: true },
905
1010
  ...ariaProps,
906
1011
  children: [
907
- showSideHandle && /* @__PURE__ */ jsx2(SideHandle, { isHovered, side }),
1012
+ showSideHandle && /* @__PURE__ */ jsx2(
1013
+ SideHandle,
1014
+ {
1015
+ isHovered,
1016
+ onDismiss: isNested ? pop : close,
1017
+ side
1018
+ }
1019
+ ),
908
1020
  /* @__PURE__ */ jsxs("div", { className: "flex min-h-0 flex-1 flex-col overflow-hidden rounded-[inherit]", children: [
909
- showBottomHandle && /* @__PURE__ */ jsx2(BottomHandle, {}),
1021
+ showBottomHandle && /* @__PURE__ */ jsx2(BottomHandle, { onDismiss: isNested ? pop : close }),
910
1022
  /* @__PURE__ */ jsx2(
911
1023
  PanelInnerContent,
912
1024
  {
@@ -924,35 +1036,19 @@ function SheetPanel({
924
1036
  },
925
1037
  item.id
926
1038
  );
927
- if (!config.modal) {
928
- return /* @__PURE__ */ jsx2(SheetPanelContext.Provider, { value: panelContext, children: panelContent });
929
- }
930
1039
  return /* @__PURE__ */ jsx2(SheetPanelContext.Provider, { value: panelContext, children: /* @__PURE__ */ jsx2(
931
- FocusTrap,
1040
+ ModalFocusTrap,
932
1041
  {
933
1042
  active: isTop,
934
- focusTrapOptions: {
935
- initialFocus: false,
936
- returnFocusOnDeactivate: true,
937
- escapeDeactivates: false,
938
- allowOutsideClick: true,
939
- checkCanFocusTrap: () => new Promise(
940
- (resolve) => requestAnimationFrame(() => resolve())
941
- ),
942
- fallbackFocus: () => {
943
- if (panelRef.current) {
944
- return panelRef.current;
945
- }
946
- return document.body;
947
- }
948
- },
1043
+ enabled: config.modal,
1044
+ fallbackRef: panelRef,
949
1045
  children: panelContent
950
1046
  }
951
1047
  ) });
952
- }
953
- function useBodyScale(config, isOpen) {
1048
+ });
1049
+ function useBodyScale(config, isOpen, prefersReducedMotion) {
954
1050
  useEffect3(() => {
955
- if (!config.shouldScaleBackground) {
1051
+ if (!config.shouldScaleBackground || prefersReducedMotion) {
956
1052
  return;
957
1053
  }
958
1054
  const wrapper = document.querySelector("[data-stacksheet-wrapper]");
@@ -977,7 +1073,12 @@ function useBodyScale(config, isOpen) {
977
1073
  };
978
1074
  wrapper.addEventListener("transitionend", handleEnd, { once: true });
979
1075
  return () => wrapper.removeEventListener("transitionend", handleEnd);
980
- }, [isOpen, config.shouldScaleBackground, config.scaleBackgroundAmount]);
1076
+ }, [
1077
+ isOpen,
1078
+ config.shouldScaleBackground,
1079
+ config.scaleBackgroundAmount,
1080
+ prefersReducedMotion
1081
+ ]);
981
1082
  }
982
1083
  function SheetRenderer({
983
1084
  store,
@@ -992,7 +1093,11 @@ function SheetRenderer({
992
1093
  const rawClose = useStore(store, (s) => s.close);
993
1094
  const rawPop = useStore(store, (s) => s.pop);
994
1095
  const side = useResolvedSide(config);
995
- const classNames = resolveClassNames(classNamesProp);
1096
+ const prefersReducedMotion = useReducedMotion() ?? false;
1097
+ const classNames = useMemo(
1098
+ () => resolveClassNames(classNamesProp),
1099
+ [classNamesProp]
1100
+ );
996
1101
  const snapHeights = useMemo(
997
1102
  () => side === "bottom" && config.snapPoints.length > 0 ? resolveSnapPoints(
998
1103
  config.snapPoints,
@@ -1034,7 +1139,7 @@ function SheetRenderer({
1034
1139
  );
1035
1140
  const close = useCallback2(() => closeWith("programmatic"), [closeWith]);
1036
1141
  const pop = useCallback2(() => popWith("programmatic"), [popWith]);
1037
- useBodyScale(config, isOpen);
1142
+ useBodyScale(config, isOpen, prefersReducedMotion);
1038
1143
  const triggerRef = useRef2(null);
1039
1144
  const wasOpenRef = useRef2(false);
1040
1145
  useEffect3(() => {
@@ -1042,13 +1147,17 @@ function SheetRenderer({
1042
1147
  triggerRef.current = document.activeElement;
1043
1148
  } else if (!isOpen && wasOpenRef.current) {
1044
1149
  const el = triggerRef.current;
1045
- if (el && el instanceof HTMLElement) {
1150
+ if (el && el instanceof HTMLElement && el !== document.body && el.tagName !== "BODY") {
1046
1151
  el.focus();
1047
1152
  }
1048
1153
  triggerRef.current = null;
1049
1154
  }
1050
1155
  wasOpenRef.current = isOpen;
1051
1156
  }, [isOpen]);
1157
+ const stackLengthRef = useRef2(stack.length);
1158
+ useEffect3(() => {
1159
+ stackLengthRef.current = stack.length;
1160
+ }, [stack.length]);
1052
1161
  useEffect3(() => {
1053
1162
  if (!(isOpen && config.closeOnEscape && config.dismissible)) {
1054
1163
  return;
@@ -1056,7 +1165,7 @@ function SheetRenderer({
1056
1165
  function handleKeyDown(e) {
1057
1166
  if (e.key === "Escape") {
1058
1167
  e.preventDefault();
1059
- if (stack.length > 1) {
1168
+ if (stackLengthRef.current > 1) {
1060
1169
  popWith("escape");
1061
1170
  } else {
1062
1171
  closeWith("escape");
@@ -1065,36 +1174,37 @@ function SheetRenderer({
1065
1174
  }
1066
1175
  document.addEventListener("keydown", handleKeyDown);
1067
1176
  return () => document.removeEventListener("keydown", handleKeyDown);
1068
- }, [
1069
- isOpen,
1070
- config.closeOnEscape,
1071
- config.dismissible,
1072
- stack.length,
1073
- popWith,
1074
- closeWith
1075
- ]);
1177
+ }, [isOpen, config.closeOnEscape, config.dismissible, popWith, closeWith]);
1076
1178
  useEffect3(() => {
1077
1179
  if (!(isOpen && config.dismissible) || typeof globalThis.CloseWatcher === "undefined") {
1078
1180
  return;
1079
1181
  }
1080
1182
  const watcher = new globalThis.CloseWatcher();
1081
1183
  watcher.onclose = () => {
1082
- if (stack.length > 1) {
1184
+ if (stackLengthRef.current > 1) {
1083
1185
  popWith("escape");
1084
1186
  } else {
1085
1187
  closeWith("escape");
1086
1188
  }
1087
1189
  };
1088
1190
  return () => watcher.destroy();
1089
- }, [isOpen, config.dismissible, stack.length, popWith, closeWith]);
1090
- const slideFrom = getSlideFrom(side);
1091
- const slideTarget = getSlideTarget();
1092
- const spring = {
1093
- type: "spring",
1094
- damping: config.spring.damping,
1095
- stiffness: config.spring.stiffness,
1096
- mass: config.spring.mass
1097
- };
1191
+ }, [isOpen, config.dismissible, popWith, closeWith]);
1192
+ const slideFrom = useMemo(() => getSlideFrom(side), [side]);
1193
+ const slideTarget = useMemo(() => getSlideTarget(), []);
1194
+ const spring = useMemo(
1195
+ () => prefersReducedMotion ? { type: "tween", duration: 0 } : {
1196
+ type: "spring",
1197
+ damping: config.spring.damping,
1198
+ stiffness: config.spring.stiffness,
1199
+ mass: config.spring.mass
1200
+ },
1201
+ [
1202
+ prefersReducedMotion,
1203
+ config.spring.damping,
1204
+ config.spring.stiffness,
1205
+ config.spring.mass
1206
+ ]
1207
+ );
1098
1208
  const stackSpring = spring;
1099
1209
  const exitSpring = spring;
1100
1210
  const isModal = config.modal;
@@ -1102,6 +1212,7 @@ function SheetRenderer({
1102
1212
  const hasBackdropClass = classNames.backdrop !== "";
1103
1213
  const backdropStyle = {
1104
1214
  zIndex: config.zIndex,
1215
+ willChange: "opacity",
1105
1216
  cursor: config.closeOnBackdrop && config.dismissible ? "pointer" : void 0,
1106
1217
  ...hasBackdropClass ? {} : { background: "var(--overlay, rgba(0, 0, 0, 0.2))" }
1107
1218
  };
@@ -1110,11 +1221,16 @@ function SheetRenderer({
1110
1221
  config.onCloseComplete?.(closeReasonRef.current);
1111
1222
  }
1112
1223
  }, [stack.length, config]);
1224
+ const handleBackdropExitComplete = useCallback2(() => {
1225
+ requestAnimationFrame(() => {
1226
+ void document.body.offsetHeight;
1227
+ });
1228
+ }, []);
1113
1229
  const swipeClose = useCallback2(() => closeWith("swipe"), [closeWith]);
1114
1230
  const swipePop = useCallback2(() => popWith("swipe"), [popWith]);
1115
1231
  const shouldLockScroll = isOpen && isModal && config.lockScroll;
1116
1232
  return /* @__PURE__ */ jsxs(Fragment, { children: [
1117
- showOverlay && /* @__PURE__ */ jsx2(AnimatePresence, { children: isOpen && /* @__PURE__ */ jsx2(
1233
+ showOverlay && /* @__PURE__ */ jsx2(AnimatePresence, { onExitComplete: handleBackdropExitComplete, children: isOpen && /* @__PURE__ */ jsx2(
1118
1234
  m.div,
1119
1235
  {
1120
1236
  animate: { opacity: 1 },
@@ -1154,6 +1270,7 @@ function SheetRenderer({
1154
1270
  item,
1155
1271
  onSnap: handleSnap,
1156
1272
  pop,
1273
+ prefersReducedMotion,
1157
1274
  renderHeader,
1158
1275
  shouldRender,
1159
1276
  side,
@@ -1185,7 +1302,7 @@ function getInitialRadius(side) {
1185
1302
  }
1186
1303
  var SHADOW_SM = "0px 2px 5px 0px rgba(0,0,0,0.11), 0px 9px 9px 0px rgba(0,0,0,0.1), 0px 21px 13px 0px rgba(0,0,0,0.06)";
1187
1304
  var SHADOW_LG = "0px 23px 52px 0px rgba(0,0,0,0.08), 0px 94px 94px 0px rgba(0,0,0,0.07), 0px 211px 127px 0px rgba(0,0,0,0.04)";
1188
- function getShadow(_side, isNested) {
1305
+ function getShadow(isNested) {
1189
1306
  return isNested ? SHADOW_SM : SHADOW_LG;
1190
1307
  }
1191
1308
 
@@ -1261,6 +1378,15 @@ function createSheetStore(config) {
1261
1378
  third
1262
1379
  );
1263
1380
  }
1381
+ function pruneRegistry(remainingStack) {
1382
+ const usedTypes = new Set(remainingStack.map((item) => item.type));
1383
+ for (const [component, typeKey] of componentRegistry) {
1384
+ if (!usedTypes.has(typeKey)) {
1385
+ componentRegistry.delete(component);
1386
+ componentMap.delete(typeKey);
1387
+ }
1388
+ }
1389
+ }
1264
1390
  const store = createStore()((set, get) => {
1265
1391
  function _openResolved({ type, id, data }) {
1266
1392
  set({
@@ -1372,6 +1498,7 @@ function createSheetStore(config) {
1372
1498
  if (next.length === state.stack.length) {
1373
1499
  return state;
1374
1500
  }
1501
+ pruneRegistry(next);
1375
1502
  if (next.length === 0) {
1376
1503
  return { stack: [], isOpen: false };
1377
1504
  }
@@ -1381,12 +1508,16 @@ function createSheetStore(config) {
1381
1508
  pop() {
1382
1509
  set((state) => {
1383
1510
  if (state.stack.length <= 1) {
1511
+ pruneRegistry([]);
1384
1512
  return { stack: [], isOpen: false };
1385
1513
  }
1386
- return { stack: state.stack.slice(0, -1), isOpen: true };
1514
+ const next = state.stack.slice(0, -1);
1515
+ pruneRegistry(next);
1516
+ return { stack: next, isOpen: true };
1387
1517
  });
1388
1518
  },
1389
1519
  close() {
1520
+ pruneRegistry([]);
1390
1521
  set({ stack: [], isOpen: false });
1391
1522
  }
1392
1523
  };
@@ -1470,6 +1601,9 @@ import {
1470
1601
  Viewport as ScrollAreaViewport
1471
1602
  } from "@radix-ui/react-scroll-area";
1472
1603
  import { Slot } from "@radix-ui/react-slot";
1604
+ import {
1605
+ useEffect as useEffect4
1606
+ } from "react";
1473
1607
  import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
1474
1608
  function SheetHandle({
1475
1609
  asChild,
@@ -1477,14 +1611,26 @@ function SheetHandle({
1477
1611
  style,
1478
1612
  children
1479
1613
  }) {
1614
+ const { close, back, isNested } = useSheetPanel();
1615
+ const dismiss = isNested ? back : close;
1480
1616
  const Comp = asChild ? Slot : "div";
1481
1617
  return /* @__PURE__ */ jsx4(
1482
1618
  Comp,
1483
1619
  {
1620
+ "aria-label": "Dismiss",
1484
1621
  className: `flex shrink-0 cursor-grab touch-none items-center justify-center pt-4 pb-1 ${className ?? ""}`,
1485
1622
  "data-stacksheet-handle": "",
1623
+ onClick: dismiss,
1624
+ onKeyDown: (e) => {
1625
+ if (e.key === "Enter" || e.key === " ") {
1626
+ e.preventDefault();
1627
+ dismiss();
1628
+ }
1629
+ },
1630
+ role: "button",
1486
1631
  style,
1487
- children: children ?? /* @__PURE__ */ jsx4("div", { className: "h-1 w-9 rounded-sm bg-current/25" })
1632
+ tabIndex: 0,
1633
+ children: children ?? /* @__PURE__ */ jsx4("div", { "aria-hidden": "true", className: "h-1 w-9 rounded-sm bg-current/25" })
1488
1634
  }
1489
1635
  );
1490
1636
  }
@@ -1523,7 +1669,8 @@ function SheetDescription({
1523
1669
  style,
1524
1670
  children
1525
1671
  }) {
1526
- const { panelId } = useSheetPanel();
1672
+ const { panelId, registerDescription } = useSheetPanel();
1673
+ useEffect4(() => registerDescription(), [registerDescription]);
1527
1674
  const Comp = asChild ? Slot : "p";
1528
1675
  return /* @__PURE__ */ jsx4(Comp, { className, id: `${panelId}-desc`, style, children });
1529
1676
  }
@@ -1621,10 +1768,6 @@ var Sheet = {
1621
1768
  export {
1622
1769
  Sheet,
1623
1770
  createStacksheet,
1624
- getPanelStyles,
1625
- getSlideFrom,
1626
- getStackTransform,
1627
- resolveConfig,
1628
1771
  springs,
1629
1772
  useIsMobile,
1630
1773
  useResolvedSide,