@seed-design/react-drawer 1.0.11 → 2.0.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.
@@ -1,11 +1,15 @@
1
1
  'use client';
2
2
  import { jsx } from 'react/jsx-runtime';
3
- import { useComposedRefs } from '@radix-ui/react-compose-refs';
4
- import * as DialogPrimitive from '@radix-ui/react-dialog';
3
+ import { composeRefs } from '@radix-ui/react-compose-refs';
4
+ import { FocusScope } from '@radix-ui/react-focus-scope';
5
5
  import { useCallbackRef } from '@radix-ui/react-use-callback-ref';
6
- import { dataAttr } from '@seed-design/dom-utils';
6
+ import { hideOthers } from 'aria-hidden';
7
+ import { DismissibleLayer } from '@seed-design/react-dismissible-layer';
8
+ import { Presence } from '@seed-design/react-presence';
9
+ import { elementProps, dataAttr, buttonProps, mergeProps } from '@seed-design/dom-utils';
10
+ import { usePreventScroll } from '@seed-design/react-prevent-scroll';
7
11
  import { Primitive } from '@seed-design/react-primitive';
8
- import React, { useState, useCallback, useRef, useEffect, useMemo, createContext, useContext, forwardRef } from 'react';
12
+ import React, { useId, useState, useRef, useCallback, useEffect, useMemo, createContext, useContext, forwardRef } from 'react';
9
13
  import { useControllableState } from '@seed-design/react-use-controllable-state';
10
14
 
11
15
  function isMobileFirefox() {
@@ -18,9 +22,6 @@ function isMac() {
18
22
  function isIPhone() {
19
23
  return testPlatform(/^iPhone/);
20
24
  }
21
- function isSafari() {
22
- return /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
23
- }
24
25
  function isIPad() {
25
26
  return testPlatform(/^iPad/) || // iPadOS 13 lies and says it's a Mac, but we can distinguish by detecting touch support.
26
27
  isMac() && navigator.maxTouchPoints > 1;
@@ -125,124 +126,6 @@ function dampenValue(v) {
125
126
  return 8 * (Math.log(v + 1) - 2);
126
127
  }
127
128
 
128
- // This code comes from https://github.com/emilkowalski/vaul/blob/main/src/use-position-fixed.ts
129
- let previousBodyPosition = null;
130
- /**
131
- * This hook is necessary to prevent buggy behavior on iOS devices (need to test on Android).
132
- * I won't get into too much detail about what bugs it solves, but so far I've found that setting the body to `position: fixed` is the most reliable way to prevent those bugs.
133
- * Issues that this hook solves:
134
- * https://github.com/emilkowalski/vaul/issues/435
135
- * https://github.com/emilkowalski/vaul/issues/433
136
- * And more that I discovered, but were just not reported.
137
- */ function usePositionFixed({ isOpen, modal, nested, hasBeenOpened, preventScrollRestoration, noBodyStyles }) {
138
- const [activeUrl, setActiveUrl] = React.useState(()=>typeof window !== "undefined" ? window.location.href : "");
139
- const scrollPos = React.useRef(0);
140
- const setPositionFixed = React.useCallback(()=>{
141
- // All browsers on iOS will return true here.
142
- if (!isSafari()) return;
143
- // If previousBodyPosition is already set, don't set it again.
144
- if (previousBodyPosition === null && isOpen && !noBodyStyles) {
145
- previousBodyPosition = {
146
- position: document.body.style.position,
147
- top: document.body.style.top,
148
- left: document.body.style.left,
149
- height: document.body.style.height,
150
- right: "unset"
151
- };
152
- // Update the dom inside an animation frame
153
- const { scrollX, innerHeight } = window;
154
- document.body.style.setProperty("position", "fixed", "important");
155
- Object.assign(document.body.style, {
156
- top: `${-scrollPos.current}px`,
157
- left: `${-scrollX}px`,
158
- right: "0px",
159
- height: "auto"
160
- });
161
- window.setTimeout(()=>window.requestAnimationFrame(()=>{
162
- // Attempt to check if the bottom bar appeared due to the position change
163
- const bottomBarHeight = innerHeight - window.innerHeight;
164
- if (bottomBarHeight && scrollPos.current >= innerHeight) {
165
- // Move the content further up so that the bottom bar doesn't hide it
166
- document.body.style.top = `${-(scrollPos.current + bottomBarHeight)}px`;
167
- }
168
- }), 300);
169
- }
170
- }, [
171
- isOpen
172
- ]);
173
- const restorePositionSetting = React.useCallback(()=>{
174
- // All browsers on iOS will return true here.
175
- if (!isSafari()) return;
176
- if (previousBodyPosition !== null && !noBodyStyles) {
177
- // Convert the position from "px" to Int
178
- const y = -parseInt(document.body.style.top, 10);
179
- const x = -parseInt(document.body.style.left, 10);
180
- // Restore styles
181
- Object.assign(document.body.style, previousBodyPosition);
182
- window.requestAnimationFrame(()=>{
183
- if (preventScrollRestoration && activeUrl !== window.location.href) {
184
- setActiveUrl(window.location.href);
185
- return;
186
- }
187
- window.scrollTo(x, y);
188
- });
189
- previousBodyPosition = null;
190
- }
191
- }, [
192
- activeUrl
193
- ]);
194
- React.useEffect(()=>{
195
- function onScroll() {
196
- scrollPos.current = window.scrollY;
197
- }
198
- onScroll();
199
- window.addEventListener("scroll", onScroll);
200
- return ()=>{
201
- window.removeEventListener("scroll", onScroll);
202
- };
203
- }, []);
204
- React.useEffect(()=>{
205
- if (!modal) return;
206
- return ()=>{
207
- if (typeof document === "undefined") return;
208
- // Another drawer is opened, safe to ignore the execution
209
- const hasDrawerOpened = !!document.querySelector("[data-drawer]");
210
- if (hasDrawerOpened) return;
211
- restorePositionSetting();
212
- };
213
- }, [
214
- modal,
215
- restorePositionSetting
216
- ]);
217
- React.useEffect(()=>{
218
- if (nested || !hasBeenOpened) return;
219
- // This is needed to force Safari toolbar to show **before** the drawer starts animating to prevent a gnarly shift from happening
220
- if (isOpen) {
221
- // avoid for standalone mode (PWA)
222
- const isStandalone = window.matchMedia("(display-mode: standalone)").matches;
223
- !isStandalone && setPositionFixed();
224
- if (!modal) {
225
- window.setTimeout(()=>{
226
- restorePositionSetting();
227
- }, 500);
228
- }
229
- } else {
230
- restorePositionSetting();
231
- }
232
- }, [
233
- isOpen,
234
- hasBeenOpened,
235
- activeUrl,
236
- modal,
237
- nested,
238
- setPositionFixed,
239
- restorePositionSetting
240
- ]);
241
- return {
242
- restorePositionSetting
243
- };
244
- }
245
-
246
129
  function useSnapPoints({ activeSnapPointProp, setActiveSnapPointProp, snapPoints, drawerRef, overlayRef, fadeFromIndex, onSnapPointChange, direction = "bottom", container, snapToSequentialPoint }) {
247
130
  const [activeSnapPoint, setActiveSnapPoint] = useControllableState({
248
131
  prop: activeSnapPointProp,
@@ -468,38 +351,24 @@ function useSnapPoints({ activeSnapPointProp, setActiveSnapPointProp, snapPoints
468
351
  }
469
352
 
470
353
  function useDrawer(props) {
471
- const { open: openProp, onOpenChange, onDrag: onDragProp, onRelease: onReleaseProp, snapPoints, closeThreshold = CLOSE_THRESHOLD, scrollLockTimeout = SCROLL_LOCK_TIMEOUT, dismissible = true, handleOnly = false, fadeFromIndex = snapPoints && snapPoints.length - 1, activeSnapPoint: activeSnapPointProp, setActiveSnapPoint: setActiveSnapPointProp, fixed, modal = true, onClose, nested, noBodyStyles = true, direction = "bottom", defaultOpen = false, snapToSequentialPoint = false, preventScrollRestoration = false, repositionInputs = true, onAnimationEnd, container, autoFocus = false, closeOnInteractOutside = true, closeOnEscape = true } = props;
354
+ const { open: openProp, onOpenChange, onDrag: onDragProp, onRelease: onReleaseProp, snapPoints, closeThreshold = CLOSE_THRESHOLD, scrollLockTimeout = SCROLL_LOCK_TIMEOUT, dismissible = true, handleOnly = false, fadeFromIndex = snapPoints && snapPoints.length - 1, activeSnapPoint: activeSnapPointProp, setActiveSnapPoint: setActiveSnapPointProp, fixed, modal = true, onClose, direction = "bottom", defaultOpen = false, snapToSequentialPoint = false, repositionInputs = true, onAnimationEnd, container, autoFocus = true, closeOnInteractOutside = true, closeOnEscape = true, lazyMount: lazyMountProp = false, unmountOnExit: unmountOnExitProp = false } = props;
355
+ const drawerId = useId();
356
+ const titleId = `${drawerId}-title`;
357
+ const descriptionId = `${drawerId}-description`;
472
358
  const [isOpen = false, setIsOpen] = useControllableState({
473
359
  defaultProp: defaultOpen,
474
360
  prop: openProp,
475
361
  onChange: (o, details)=>{
476
362
  onOpenChange?.(o, details);
477
- if (!o && !nested) {
478
- restorePositionSetting();
479
- }
480
363
  setTimeout(()=>{
481
364
  onAnimationEnd?.(o);
482
365
  }, TRANSITIONS.EXIT_DURATION * 1000);
483
- if (o && !modal) {
484
- if (typeof window !== "undefined") {
485
- window.requestAnimationFrame(()=>{
486
- document.body.style.pointerEvents = "auto";
487
- });
488
- }
489
- }
490
- if (!o) {
491
- document.body.style.pointerEvents = "auto";
492
- }
493
366
  }
494
367
  });
495
368
  const [hasBeenOpened, setHasBeenOpened] = useState(false);
496
369
  const [hasAnimationDone, setHasAnimationDone] = useState(false);
497
370
  const [isDragging, setIsDragging] = useState(false);
498
371
  const [shouldOverlayAnimate, setShouldOverlayAnimate] = useState(false);
499
- const [isCloseButtonRendered, setIsCloseButtonRendered] = useState(false);
500
- const closeButtonRef = useCallback((node)=>{
501
- setIsCloseButtonRendered(!!node);
502
- }, []);
503
372
  const overlayRef = useRef(null);
504
373
  const openTime = useRef(null);
505
374
  const dragStartTime = useRef(null);
@@ -531,14 +400,6 @@ function useDrawer(props) {
531
400
  direction,
532
401
  snapToSequentialPoint
533
402
  });
534
- const { restorePositionSetting } = usePositionFixed({
535
- isOpen,
536
- modal,
537
- nested: nested ?? false,
538
- hasBeenOpened,
539
- preventScrollRestoration,
540
- noBodyStyles
541
- });
542
403
  function onPress(event) {
543
404
  if (!dismissible && !snapPoints) return;
544
405
  if (drawerRef.current && !drawerRef.current.contains(event.target)) return;
@@ -820,15 +681,6 @@ function useDrawer(props) {
820
681
  repositionInputs,
821
682
  fixed
822
683
  ]);
823
- useEffect(()=>{
824
- if (!modal) {
825
- window.requestAnimationFrame(()=>{
826
- document.body.style.pointerEvents = "auto";
827
- });
828
- }
829
- }, [
830
- modal
831
- ]);
832
684
  // Effect 1: Track drawer open state
833
685
  useEffect(()=>{
834
686
  if (isOpen) {
@@ -869,6 +721,11 @@ function useDrawer(props) {
869
721
  snapPoints,
870
722
  fadeFromIndex
871
723
  ]);
724
+ const stateProps = useMemo(()=>elementProps({
725
+ "data-open": dataAttr(isOpen)
726
+ }), [
727
+ isOpen
728
+ ]);
872
729
  return useMemo(()=>({
873
730
  activeSnapPoint,
874
731
  snapPoints,
@@ -891,16 +748,57 @@ function useDrawer(props) {
891
748
  snapPointsOffset,
892
749
  activeSnapPointIndex,
893
750
  direction,
894
- noBodyStyles,
895
751
  container,
896
752
  autoFocus,
897
753
  setHasBeenOpened,
898
754
  setIsOpen,
899
755
  closeOnInteractOutside,
900
756
  closeOnEscape,
757
+ titleId,
758
+ descriptionId,
759
+ lazyMount: lazyMountProp,
760
+ unmountOnExit: unmountOnExitProp,
901
761
  hasAnimationDone,
902
- closeButtonRef,
903
- isCloseButtonRendered
762
+ triggerProps: buttonProps({
763
+ ...stateProps,
764
+ onClick: (e)=>{
765
+ if (e.defaultPrevented) return;
766
+ setIsOpen(true, {
767
+ reason: "trigger",
768
+ event: e.nativeEvent
769
+ });
770
+ }
771
+ }),
772
+ positionerProps: elementProps({
773
+ ...stateProps,
774
+ style: {
775
+ pointerEvents: isOpen && modal ? undefined : "none"
776
+ }
777
+ }),
778
+ backdropProps: elementProps({
779
+ ...stateProps
780
+ }),
781
+ titleProps: elementProps({
782
+ id: titleId,
783
+ ...stateProps
784
+ }),
785
+ descriptionProps: elementProps({
786
+ id: descriptionId,
787
+ ...stateProps
788
+ }),
789
+ headerProps: elementProps({
790
+ ...stateProps
791
+ }),
792
+ closeButtonProps: buttonProps({
793
+ ...stateProps,
794
+ onClick: (e)=>{
795
+ if (e.defaultPrevented) return;
796
+ setIsOpen(false, {
797
+ reason: "closeButton",
798
+ event: e.nativeEvent
799
+ });
800
+ }
801
+ })
904
802
  }), [
905
803
  activeSnapPoint,
906
804
  snapPoints,
@@ -917,7 +815,6 @@ function useDrawer(props) {
917
815
  snapPointsOffset,
918
816
  activeSnapPointIndex,
919
817
  direction,
920
- noBodyStyles,
921
818
  container,
922
819
  autoFocus,
923
820
  setIsOpen,
@@ -926,9 +823,12 @@ function useDrawer(props) {
926
823
  onRelease,
927
824
  onDrag,
928
825
  onPress,
826
+ titleId,
827
+ descriptionId,
828
+ lazyMountProp,
829
+ unmountOnExitProp,
929
830
  hasAnimationDone,
930
- closeButtonRef,
931
- isCloseButtonRendered
831
+ stateProps
932
832
  ]);
933
833
  }
934
834
 
@@ -943,66 +843,74 @@ function useDrawerContext() {
943
843
  }
944
844
 
945
845
  const DrawerRoot = (props)=>{
946
- const { children, defaultOpen, dismissible, modal } = props;
947
846
  const api = useDrawer(props);
948
- return /*#__PURE__*/ jsx(DialogPrimitive.Root, {
949
- defaultOpen: defaultOpen,
950
- open: api.isOpen,
951
- onOpenChange: (open)=>{
952
- if (!dismissible && !open) return;
953
- if (open) {
954
- api.setHasBeenOpened(true);
955
- } else {
956
- api.closeDrawer(true);
957
- }
958
- api.setIsOpen(open);
959
- },
960
- modal: modal,
961
- children: /*#__PURE__*/ jsx(DrawerProvider, {
962
- value: api,
963
- children: children
964
- })
847
+ return /*#__PURE__*/ jsx(DrawerProvider, {
848
+ value: api,
849
+ children: props.children
965
850
  });
966
851
  };
967
- const DrawerTrigger = DialogPrimitive.Trigger;
852
+ const DrawerTrigger = /*#__PURE__*/ forwardRef((props, ref)=>{
853
+ const api = useDrawerContext();
854
+ return /*#__PURE__*/ jsx(Primitive.button, {
855
+ ref: ref,
856
+ ...mergeProps(api.triggerProps, props)
857
+ });
858
+ });
859
+ DrawerTrigger.displayName = "DrawerTrigger";
968
860
  const DrawerPositioner = /*#__PURE__*/ forwardRef((props, ref)=>{
969
861
  const api = useDrawerContext();
970
862
  return /*#__PURE__*/ jsx(Primitive.div, {
971
863
  ref: ref,
972
- ...props,
973
- style: {
974
- pointerEvents: api.isOpen ? undefined : "none",
975
- ...props.style
976
- }
864
+ ...mergeProps(api.positionerProps, props)
977
865
  });
978
866
  });
979
867
  DrawerPositioner.displayName = "DrawerPositioner";
980
868
  const DrawerBackdrop = /*#__PURE__*/ forwardRef((props, ref)=>{
981
- const { overlayRef, onRelease, modal, snapPoints, isOpen, shouldFade, shouldOverlayAnimate, hasAnimationDone } = useDrawerContext();
982
- const composedRef = useComposedRefs(ref, overlayRef);
869
+ const { overlayRef, onRelease, modal, snapPoints, isOpen, shouldFade, shouldOverlayAnimate, hasAnimationDone, lazyMount, unmountOnExit } = useDrawerContext();
870
+ const composedRef = composeRefs(ref, overlayRef);
983
871
  const hasSnapPoints = snapPoints && snapPoints.length > 0;
984
872
  const onMouseUp = useCallbackRef((event)=>onRelease(event));
985
873
  if (!modal) {
986
874
  return null;
987
875
  }
988
- return /*#__PURE__*/ jsx(DialogPrimitive.Overlay, {
989
- ref: composedRef,
990
- onMouseUp: onMouseUp,
991
- "data-snap-points": isOpen && hasSnapPoints ? "true" : "false",
992
- "data-snap-points-overlay": isOpen && shouldFade ? "true" : "false",
993
- "data-should-overlay-animate": shouldOverlayAnimate ? "true" : "false",
994
- "data-open": dataAttr(isOpen),
995
- "data-animation-done": hasAnimationDone ? "true" : "false",
996
- ...props
876
+ return /*#__PURE__*/ jsx(Presence, {
877
+ present: isOpen,
878
+ unmountOnExit: unmountOnExit,
879
+ lazyMount: lazyMount,
880
+ children: /*#__PURE__*/ jsx(Primitive.div, {
881
+ ref: composedRef,
882
+ onMouseUp: onMouseUp,
883
+ "data-snap-points": isOpen && hasSnapPoints ? "true" : "false",
884
+ "data-snap-points-overlay": isOpen && shouldFade ? "true" : "false",
885
+ "data-should-overlay-animate": shouldOverlayAnimate ? "true" : "false",
886
+ "data-open": dataAttr(isOpen),
887
+ "data-animation-done": hasAnimationDone ? "true" : "false",
888
+ ...props
889
+ })
997
890
  });
998
891
  });
999
892
  DrawerBackdrop.displayName = "DrawerBackdrop";
1000
893
  const DrawerContent = /*#__PURE__*/ forwardRef((props, ref)=>{
1001
- const { onPointerDownOutside, style, onOpenAutoFocus, ...restProps } = props;
1002
- const { drawerRef, onPress, onRelease, onDrag, keyboardIsOpen, snapPointsOffset, activeSnapPointIndex, modal, isOpen, direction, snapPoints, container, handleOnly, autoFocus, closeDrawer, closeOnInteractOutside, closeOnEscape, dismissible, hasAnimationDone } = useDrawerContext();
894
+ const { style, ...restProps } = props;
895
+ const { drawerRef, onPress, onRelease, onDrag, keyboardIsOpen, snapPointsOffset, activeSnapPointIndex, modal, isOpen, direction, snapPoints, container, handleOnly, autoFocus, closeDrawer, closeOnInteractOutside, closeOnEscape, dismissible, hasAnimationDone, titleId, descriptionId, lazyMount, unmountOnExit } = useDrawerContext();
896
+ // Lock body scroll while the modal drawer is open. Mirrors the `modal` contract: the lock
897
+ // releases the moment the drawer is non-modal or closed.
898
+ usePreventScroll({
899
+ isDisabled: !(modal && isOpen)
900
+ });
901
+ const [contentNode, setContentNode] = useState(null);
902
+ const contentRef = useCallback((el)=>setContentNode(el), []);
903
+ // aria-hide everything except the content when modal
904
+ useEffect(()=>{
905
+ if (!isOpen || !modal || !contentNode) return;
906
+ return hideOthers(contentNode);
907
+ }, [
908
+ isOpen,
909
+ modal,
910
+ contentNode
911
+ ]);
1003
912
  // Needed to use transition instead of animations
1004
913
  const [delayedSnapPoints, setDelayedSnapPoints] = useState(false);
1005
- const composedRef = useComposedRefs(ref, drawerRef);
1006
914
  const pointerStartRef = useRef(null);
1007
915
  const lastKnownPointerEventRef = useRef(null);
1008
916
  const wasBeyondThePointRef = useRef(false);
@@ -1011,7 +919,7 @@ const DrawerContent = /*#__PURE__*/ forwardRef((props, ref)=>{
1011
919
  // element the gesture started on (iOS still synthesizes a click on release).
1012
920
  const draggedRef = useRef(false);
1013
921
  const hasSnapPoints = snapPoints && snapPoints.length > 0;
1014
- const isDeltaInDirection = (delta, direction, threshold = 0)=>{
922
+ const isDeltaInDirection = (delta, dir, threshold = 0)=>{
1015
923
  if (wasBeyondThePointRef.current) return true;
1016
924
  const deltaY = Math.abs(delta.y);
1017
925
  const deltaX = Math.abs(delta.x);
@@ -1019,8 +927,8 @@ const DrawerContent = /*#__PURE__*/ forwardRef((props, ref)=>{
1019
927
  const dFactor = [
1020
928
  "bottom",
1021
929
  "right"
1022
- ].includes(direction) ? 1 : -1;
1023
- if (direction === "left" || direction === "right") {
930
+ ].includes(dir) ? 1 : -1;
931
+ if (dir === "left" || dir === "right") {
1024
932
  const isReverseDirection = delta.x * dFactor < 0;
1025
933
  if (!isReverseDirection && deltaX >= 0 && deltaX <= threshold) {
1026
934
  return isDeltaX;
@@ -1046,149 +954,177 @@ const DrawerContent = /*#__PURE__*/ forwardRef((props, ref)=>{
1046
954
  wasBeyondThePointRef.current = false;
1047
955
  onRelease(event);
1048
956
  }
1049
- return /*#__PURE__*/ jsx(DialogPrimitive.Content, {
1050
- "data-delayed-snap-points": delayedSnapPoints ? "true" : "false",
1051
- "data-drawer-direction": direction,
1052
- "data-open": dataAttr(isOpen),
1053
- "data-animation-done": hasAnimationDone ? "true" : "false",
1054
- "data-drawer": "",
1055
- "data-snap-points": isOpen && hasSnapPoints ? "true" : "false",
1056
- "data-custom-container": container ? "true" : "false",
1057
- ...restProps,
1058
- ref: composedRef,
1059
- style: snapPointsOffset && snapPointsOffset.length > 0 ? {
1060
- "--snap-point-height": `${snapPointsOffset[activeSnapPointIndex ?? 0]}px`,
1061
- ...style
1062
- } : style ?? {},
1063
- onPointerDown: (event)=>{
1064
- if (handleOnly) return;
1065
- restProps.onPointerDown?.(event);
1066
- pointerStartRef.current = {
1067
- x: event.pageX,
1068
- y: event.pageY
1069
- };
1070
- draggedRef.current = false;
1071
- onPress(event);
1072
- },
1073
- onOpenAutoFocus: (e)=>{
1074
- onOpenAutoFocus?.(e);
1075
- if (!autoFocus) {
1076
- e.preventDefault();
1077
- }
1078
- },
1079
- onPointerDownOutside: (e)=>{
1080
- onPointerDownOutside?.(e);
1081
- if (!modal || e.defaultPrevented) {
1082
- e.preventDefault();
1083
- return;
1084
- }
1085
- if (keyboardIsOpen.current) {
1086
- keyboardIsOpen.current = false;
1087
- }
1088
- },
1089
- onFocusOutside: (e)=>{
1090
- props.onFocusOutside?.(e);
1091
- // Always prevent focusOutside to avoid conflicts when focus moves between modals
1092
- // (e.g., when Dialog closes and restores focus while BottomSheet is opening)
1093
- e.preventDefault();
1094
- },
1095
- onPointerMove: (event)=>{
1096
- lastKnownPointerEventRef.current = event;
1097
- if (handleOnly) return;
1098
- restProps.onPointerMove?.(event);
1099
- if (!pointerStartRef.current) return;
1100
- const yPosition = event.pageY - pointerStartRef.current.y;
1101
- const xPosition = event.pageX - pointerStartRef.current.x;
1102
- const swipeStartThreshold = event.pointerType === "touch" ? 10 : 2;
1103
- const delta = {
1104
- x: xPosition,
1105
- y: yPosition
1106
- };
1107
- // Once the pointer travels past the swipe-start threshold the gesture
1108
- // is a drag, not a tap — remember it so the trailing click can be
1109
- // suppressed on release (iOS still synthesizes one on the origin).
1110
- const isBeyondThreshold = Math.abs(xPosition) > swipeStartThreshold || Math.abs(yPosition) > swipeStartThreshold;
1111
- if (isBeyondThreshold) draggedRef.current = true;
1112
- const isAllowedToSwipe = isDeltaInDirection(delta, direction, swipeStartThreshold);
1113
- if (isAllowedToSwipe) onDrag(event);
1114
- else if (isBeyondThreshold) {
1115
- pointerStartRef.current = null;
1116
- }
1117
- },
1118
- onPointerUp: (event)=>{
1119
- restProps.onPointerUp?.(event);
1120
- pointerStartRef.current = null;
1121
- wasBeyondThePointRef.current = false;
1122
- onRelease(event);
1123
- },
1124
- onPointerOut: (event)=>{
1125
- restProps.onPointerOut?.(event);
1126
- handleOnPointerUp(lastKnownPointerEventRef.current);
1127
- },
1128
- onContextMenu: (event)=>{
1129
- restProps.onContextMenu?.(event);
1130
- if (lastKnownPointerEventRef.current) {
1131
- handleOnPointerUp(lastKnownPointerEventRef.current);
1132
- }
1133
- },
1134
- onClickCapture: (event)=>{
1135
- restProps.onClickCapture?.(event);
1136
- // Swallow the click that follows a drag so dragging the sheet (e.g. a
1137
- // swipe-to-dismiss started on an item) doesn't also activate that item.
1138
- // A plain tap leaves draggedRef false and clicks normally.
1139
- if (draggedRef.current) {
1140
- event.preventDefault();
1141
- event.stopPropagation();
1142
- draggedRef.current = false;
1143
- }
1144
- },
1145
- onInteractOutside: (e)=>{
1146
- // Only close if event is not prevented (e.g., by onFocusOutside or onPointerDownOutside)
1147
- if (dismissible && closeOnInteractOutside && !e.defaultPrevented) {
957
+ return /*#__PURE__*/ jsx(Presence, {
958
+ present: isOpen,
959
+ unmountOnExit: unmountOnExit,
960
+ lazyMount: lazyMount,
961
+ children: /*#__PURE__*/ jsx(DismissibleLayer, {
962
+ enabled: isOpen,
963
+ onEscapeKeyDown: (e)=>{
964
+ if (e.defaultPrevented) return;
965
+ if (!dismissible || !closeOnEscape) return;
1148
966
  closeDrawer(false, {
1149
- reason: "interactOutside",
1150
- event: e.detail.originalEvent
967
+ reason: "escapeKeyDown",
968
+ event: e
1151
969
  });
1152
- }
1153
- props.onInteractOutside?.(e);
1154
- },
1155
- onEscapeKeyDown: (e)=>{
1156
- if (dismissible && closeOnEscape) {
970
+ },
971
+ onPressOutside: (e)=>{
972
+ if (e.defaultPrevented) return;
973
+ if (!modal) return;
974
+ if (keyboardIsOpen.current) keyboardIsOpen.current = false;
975
+ if (!dismissible || !closeOnInteractOutside) return;
1157
976
  closeDrawer(false, {
1158
- reason: "escapeKeyDown",
977
+ reason: "interactOutside",
1159
978
  event: e
1160
979
  });
1161
- }
1162
- props.onEscapeKeyDown?.(e);
1163
- }
980
+ },
981
+ onFocusOutside: ()=>{
982
+ // Focus trapping is handled by FocusScope — nothing to do here.
983
+ // The old Radix architecture needed e.preventDefault() here (PR #1187)
984
+ // to prevent cascade closes, but the new DismissibleLayer handles
985
+ // that via onCascadeDismiss instead.
986
+ },
987
+ onCascadeDismiss: ({ dismissedParent })=>{
988
+ closeDrawer(false, {
989
+ reason: "cascadeDismiss",
990
+ dismissedParent
991
+ });
992
+ },
993
+ children: /*#__PURE__*/ jsx(FocusScope, {
994
+ asChild: true,
995
+ loop: modal,
996
+ trapped: modal && isOpen,
997
+ onMountAutoFocus: (e)=>{
998
+ // prevent FocusScope's default autoFocus behavior
999
+ e.preventDefault();
1000
+ // when autoFocus is true, FocusScope sets the focus to the first tabbable element; otherwise content;
1001
+ // the desired behavior is to set the focus to the content regardless of whether there are tabbable elements or not when true]
1002
+ if (autoFocus) {
1003
+ drawerRef.current?.focus();
1004
+ }
1005
+ // later, we can do something like:
1006
+ // when trigger has clicked using keyboard, focus the first tabbable element;
1007
+ // when trigger has clicked using mouse, focus the content
1008
+ // -> matches Menu behavior
1009
+ },
1010
+ children: /*#__PURE__*/ jsx(Primitive.div, {
1011
+ role: "dialog",
1012
+ "aria-modal": modal,
1013
+ "aria-labelledby": titleId,
1014
+ "aria-describedby": descriptionId,
1015
+ "data-delayed-snap-points": delayedSnapPoints ? "true" : "false",
1016
+ "data-drawer-direction": direction,
1017
+ "data-open": dataAttr(isOpen),
1018
+ "data-animation-done": hasAnimationDone ? "true" : "false",
1019
+ "data-drawer": "",
1020
+ "data-snap-points": isOpen && hasSnapPoints ? "true" : "false",
1021
+ "data-custom-container": container ? "true" : "false",
1022
+ ...restProps,
1023
+ ref: composeRefs(ref, drawerRef, contentRef),
1024
+ style: {
1025
+ ...snapPointsOffset && snapPointsOffset.length > 0 ? {
1026
+ "--snap-point-height": `${snapPointsOffset[activeSnapPointIndex ?? 0]}px`,
1027
+ ...style
1028
+ } : style,
1029
+ ...!modal && {
1030
+ pointerEvents: "auto"
1031
+ }
1032
+ },
1033
+ onPointerDown: (event)=>{
1034
+ if (handleOnly) return;
1035
+ restProps.onPointerDown?.(event);
1036
+ pointerStartRef.current = {
1037
+ x: event.pageX,
1038
+ y: event.pageY
1039
+ };
1040
+ draggedRef.current = false;
1041
+ onPress(event);
1042
+ },
1043
+ onPointerMove: (event)=>{
1044
+ lastKnownPointerEventRef.current = event;
1045
+ if (handleOnly) return;
1046
+ restProps.onPointerMove?.(event);
1047
+ if (!pointerStartRef.current) return;
1048
+ const yPosition = event.pageY - pointerStartRef.current.y;
1049
+ const xPosition = event.pageX - pointerStartRef.current.x;
1050
+ const swipeStartThreshold = event.pointerType === "touch" ? 10 : 2;
1051
+ const delta = {
1052
+ x: xPosition,
1053
+ y: yPosition
1054
+ };
1055
+ // Once the pointer travels past the swipe-start threshold the gesture
1056
+ // is a drag, not a tap — remember it so the trailing click can be
1057
+ // suppressed on release (iOS still synthesizes one on the origin).
1058
+ const isBeyondThreshold = Math.abs(xPosition) > swipeStartThreshold || Math.abs(yPosition) > swipeStartThreshold;
1059
+ if (isBeyondThreshold) draggedRef.current = true;
1060
+ const isAllowedToSwipe = isDeltaInDirection(delta, direction, swipeStartThreshold);
1061
+ if (isAllowedToSwipe) onDrag(event);
1062
+ else if (isBeyondThreshold) {
1063
+ pointerStartRef.current = null;
1064
+ }
1065
+ },
1066
+ onPointerUp: (event)=>{
1067
+ restProps.onPointerUp?.(event);
1068
+ pointerStartRef.current = null;
1069
+ wasBeyondThePointRef.current = false;
1070
+ onRelease(event);
1071
+ },
1072
+ onPointerOut: (event)=>{
1073
+ restProps.onPointerOut?.(event);
1074
+ handleOnPointerUp(lastKnownPointerEventRef.current);
1075
+ },
1076
+ onContextMenu: (event)=>{
1077
+ restProps.onContextMenu?.(event);
1078
+ if (lastKnownPointerEventRef.current) {
1079
+ handleOnPointerUp(lastKnownPointerEventRef.current);
1080
+ }
1081
+ },
1082
+ onClickCapture: (event)=>{
1083
+ restProps.onClickCapture?.(event);
1084
+ // Swallow the click that follows a drag so dragging the sheet (e.g. a
1085
+ // swipe-to-dismiss started on an item) doesn't also activate that item.
1086
+ // A plain tap leaves draggedRef false and clicks normally.
1087
+ if (draggedRef.current) {
1088
+ event.preventDefault();
1089
+ event.stopPropagation();
1090
+ draggedRef.current = false;
1091
+ }
1092
+ }
1093
+ })
1094
+ })
1095
+ })
1164
1096
  });
1165
1097
  });
1166
1098
  DrawerContent.displayName = "DrawerContent";
1167
- const DrawerTitle = DialogPrimitive.Title;
1168
- const DrawerDescription = DialogPrimitive.Description;
1099
+ const DrawerTitle = /*#__PURE__*/ forwardRef((props, ref)=>{
1100
+ const api = useDrawerContext();
1101
+ return /*#__PURE__*/ jsx(Primitive.h2, {
1102
+ ref: ref,
1103
+ ...mergeProps(api.titleProps, props)
1104
+ });
1105
+ });
1106
+ DrawerTitle.displayName = "DrawerTitle";
1107
+ const DrawerDescription = /*#__PURE__*/ forwardRef((props, ref)=>{
1108
+ const api = useDrawerContext();
1109
+ return /*#__PURE__*/ jsx(Primitive.p, {
1110
+ ref: ref,
1111
+ ...mergeProps(api.descriptionProps, props)
1112
+ });
1113
+ });
1114
+ DrawerDescription.displayName = "DrawerDescription";
1169
1115
  const DrawerHeader = /*#__PURE__*/ forwardRef((props, ref)=>{
1170
- const { isCloseButtonRendered } = useDrawerContext();
1116
+ const api = useDrawerContext();
1171
1117
  return /*#__PURE__*/ jsx(Primitive.div, {
1172
1118
  ref: ref,
1173
- "data-show-close-button": dataAttr(isCloseButtonRendered),
1174
- ...props
1119
+ ...mergeProps(api.headerProps, props)
1175
1120
  });
1176
1121
  });
1177
1122
  DrawerHeader.displayName = "DrawerHeader";
1178
1123
  const DrawerCloseButton = /*#__PURE__*/ forwardRef((props, ref)=>{
1179
- const { closeButtonRef, setIsOpen } = useDrawerContext();
1180
- const composedRef = useComposedRefs(ref, closeButtonRef);
1124
+ const api = useDrawerContext();
1181
1125
  return /*#__PURE__*/ jsx(Primitive.button, {
1182
- ref: composedRef,
1183
- ...props,
1184
- onClick: (e)=>{
1185
- props.onClick?.(e);
1186
- if (e.defaultPrevented) return;
1187
- setIsOpen(false, {
1188
- reason: "closeButton",
1189
- event: e.nativeEvent
1190
- });
1191
- }
1126
+ ref: ref,
1127
+ ...mergeProps(api.closeButtonProps, props)
1192
1128
  });
1193
1129
  });
1194
1130
  const LONG_HANDLE_PRESS_TIMEOUT = 250;