@seed-design/react-drawer 1.0.10 → 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.
package/lib/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { D as DrawerBackdrop, a as DrawerCloseButton, b as DrawerContent, c as DrawerDescription, d as DrawerHandle, e as DrawerHeader, f as DrawerPositioner, g as DrawerRoot, h as DrawerTitle, i as DrawerTrigger } from './Drawer-12s-B-JAOvwq.js';
2
- export { u as useDrawer, j as useDrawerContext } from './Drawer-12s-B-JAOvwq.js';
1
+ import { D as DrawerBackdrop, a as DrawerCloseButton, b as DrawerContent, c as DrawerDescription, d as DrawerHandle, e as DrawerHeader, f as DrawerPositioner, g as DrawerRoot, h as DrawerTitle, i as DrawerTrigger } from './Drawer-12s-5syEwTtq.js';
2
+ export { u as useDrawer, j as useDrawerContext } from './Drawer-12s-5syEwTtq.js';
3
3
 
4
4
  var Drawer_namespace = {
5
5
  __proto__: null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seed-design/react-drawer",
3
- "version": "1.0.10",
3
+ "version": "2.0.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/daangn/seed-design.git",
@@ -28,11 +28,15 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@radix-ui/react-compose-refs": "^1.1.2",
31
- "@radix-ui/react-dialog": "^1.1.15",
31
+ "@radix-ui/react-focus-scope": "^1.1.4",
32
32
  "@radix-ui/react-use-callback-ref": "^1.1.0",
33
- "@seed-design/react-use-controllable-state": "1.0.0",
34
- "@seed-design/dom-utils": "1.0.0",
35
- "@seed-design/react-primitive": "1.0.0"
33
+ "@seed-design/dom-utils": "^2.0.0",
34
+ "@seed-design/react-dismissible-layer": "^1.0.0",
35
+ "@seed-design/react-presence": "^1.0.0",
36
+ "@seed-design/react-prevent-scroll": "^1.0.0",
37
+ "@seed-design/react-primitive": "^2.0.0",
38
+ "@seed-design/react-use-controllable-state": "^2.0.0",
39
+ "aria-hidden": "^1.2.6"
36
40
  },
37
41
  "devDependencies": {
38
42
  "@types/react": "^19.1.6",
@@ -0,0 +1,111 @@
1
+ import { fireEvent, render } from "@testing-library/react";
2
+ import { afterAll, beforeAll, describe, expect, it, mock, spyOn } from "bun:test";
3
+ import { DrawerContent, DrawerDescription, DrawerRoot, DrawerTitle } from "./Drawer";
4
+
5
+ function mockRect(element: HTMLElement, size = 100) {
6
+ return spyOn(element, "getBoundingClientRect").mockReturnValue({
7
+ x: 0,
8
+ y: 0,
9
+ width: size,
10
+ height: size,
11
+ top: 0,
12
+ left: 0,
13
+ right: size,
14
+ bottom: size,
15
+ toJSON: () => {},
16
+ });
17
+ }
18
+
19
+ // happy-dom's fireEvent does not carry page coordinates onto the synthetic
20
+ // event, so build the native PointerEvent and pin pageX/pageY explicitly —
21
+ // the same data a real touch gesture delivers and what the swipe-threshold
22
+ // check reads.
23
+ function firePointer(type: string, element: HTMLElement, x: number, y: number) {
24
+ const event = new PointerEvent(type, {
25
+ bubbles: true,
26
+ cancelable: true,
27
+ pointerId: 1,
28
+ pointerType: "touch",
29
+ clientX: x,
30
+ clientY: y,
31
+ });
32
+ Object.defineProperty(event, "pageX", { value: x });
33
+ Object.defineProperty(event, "pageY", { value: y });
34
+ fireEvent(element, event);
35
+ }
36
+
37
+ function setup(onItemClick: () => void) {
38
+ const utils = render(
39
+ <DrawerRoot defaultOpen dismissible direction="bottom" modal={false}>
40
+ <DrawerContent>
41
+ <DrawerTitle>Sheet</DrawerTitle>
42
+ <DrawerDescription>Sheet body</DrawerDescription>
43
+ <button type="button" data-testid="item" onClick={onItemClick}>
44
+ item
45
+ </button>
46
+ </DrawerContent>
47
+ </DrawerRoot>,
48
+ );
49
+ const content = utils.container.ownerDocument.querySelector("[data-drawer]") as HTMLElement;
50
+
51
+ // getTranslate() reads getComputedStyle().transform, which happy-dom leaves
52
+ // empty; seed an identity matrix and a measurable rect so onDrag doesn't throw.
53
+ content.style.transform = "matrix(1, 0, 0, 1, 0, 0)";
54
+ mockRect(content);
55
+
56
+ return { ...utils, content, item: utils.getByTestId("item") };
57
+ }
58
+
59
+ describe("DrawerContent trailing-click suppression", () => {
60
+ const originalSetPointerCapture = window.HTMLElement.prototype.setPointerCapture;
61
+
62
+ beforeAll(() => {
63
+ window.HTMLElement.prototype.setPointerCapture = mock(() => {});
64
+ });
65
+
66
+ afterAll(() => {
67
+ window.HTMLElement.prototype.setPointerCapture = originalSetPointerCapture;
68
+ });
69
+
70
+ it("드래그가 swipe 임계값을 넘으면 뒤따르는 click을 억제한다", () => {
71
+ const onItemClick = mock(() => {});
72
+ const { content, item } = setup(onItemClick);
73
+
74
+ firePointer("pointerdown", content, 50, 50);
75
+ firePointer("pointermove", content, 50, 85); // +35px > touch 임계값 10px
76
+ firePointer("pointerup", content, 50, 85);
77
+ fireEvent.click(item, { clientX: 50, clientY: 85 });
78
+
79
+ expect(onItemClick).not.toHaveBeenCalled();
80
+ });
81
+
82
+ it("임계값을 넘지 않은 순수 tap은 click을 통과시킨다", () => {
83
+ const onItemClick = mock(() => {});
84
+ const { content, item } = setup(onItemClick);
85
+
86
+ firePointer("pointerdown", content, 50, 50);
87
+ firePointer("pointermove", content, 52, 53); // 임계값 이내
88
+ firePointer("pointerup", content, 52, 53);
89
+ fireEvent.click(item, { clientX: 52, clientY: 53 });
90
+
91
+ expect(onItemClick).toHaveBeenCalledTimes(1);
92
+ });
93
+
94
+ it("click 없이 끝난 드래그 뒤의 깨끗한 tap은 다음 pointerdown에서 리셋되어 통과한다", () => {
95
+ const onItemClick = mock(() => {});
96
+ const { content, item } = setup(onItemClick);
97
+
98
+ // 큰 드래그는 iOS가 click을 합성하지 않아 click 없이 끝난다.
99
+ firePointer("pointerdown", content, 50, 50);
100
+ firePointer("pointermove", content, 50, 85);
101
+ firePointer("pointerup", content, 50, 85);
102
+
103
+ // 다음 제스처의 pointerdown이 드래그 상태를 리셋하므로 tap은 정상 동작해야 한다.
104
+ firePointer("pointerdown", content, 50, 50);
105
+ firePointer("pointermove", content, 51, 51);
106
+ firePointer("pointerup", content, 51, 51);
107
+ fireEvent.click(item, { clientX: 51, clientY: 51 });
108
+
109
+ expect(onItemClick).toHaveBeenCalledTimes(1);
110
+ });
111
+ });
package/src/Drawer.tsx CHANGED
@@ -1,12 +1,16 @@
1
1
  "use client";
2
2
 
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 { dataAttr, mergeProps } from "@seed-design/dom-utils";
10
+ import { usePreventScroll } from "@seed-design/react-prevent-scroll";
7
11
  import { Primitive, type PrimitiveProps } from "@seed-design/react-primitive";
8
12
  import type * as React from "react";
9
- import { forwardRef, useEffect, useRef, useState } from "react";
13
+ import { forwardRef, useCallback, useEffect, useRef, useState } from "react";
10
14
  import type { DrawerDirection } from "./types";
11
15
  import { useDrawer, type UseDrawerProps } from "./useDrawer";
12
16
  import { DrawerProvider, useDrawerContext } from "./useDrawerContext";
@@ -16,32 +20,19 @@ export interface DrawerRootProps extends UseDrawerProps {
16
20
  }
17
21
 
18
22
  export const DrawerRoot = (props: DrawerRootProps) => {
19
- const { children, defaultOpen, dismissible, modal } = props;
20
23
  const api = useDrawer(props);
21
- return (
22
- <DialogPrimitive.Root
23
- defaultOpen={defaultOpen}
24
- open={api.isOpen}
25
- onOpenChange={(open) => {
26
- if (!dismissible && !open) return;
27
- if (open) {
28
- api.setHasBeenOpened(true);
29
- } else {
30
- api.closeDrawer(true);
31
- }
32
-
33
- api.setIsOpen(open);
34
- }}
35
- modal={modal}
36
- >
37
- <DrawerProvider value={api}>{children}</DrawerProvider>
38
- </DialogPrimitive.Root>
39
- );
24
+ return <DrawerProvider value={api}>{props.children}</DrawerProvider>;
40
25
  };
41
26
 
42
- export interface DrawerTriggerProps extends DialogPrimitive.DialogTriggerProps {}
27
+ export interface DrawerTriggerProps
28
+ extends PrimitiveProps,
29
+ React.ButtonHTMLAttributes<HTMLButtonElement> {}
43
30
 
44
- export const DrawerTrigger = DialogPrimitive.Trigger;
31
+ export const DrawerTrigger = forwardRef<HTMLButtonElement, DrawerTriggerProps>((props, ref) => {
32
+ const api = useDrawerContext();
33
+ return <Primitive.button ref={ref} {...mergeProps(api.triggerProps, props)} />;
34
+ });
35
+ DrawerTrigger.displayName = "DrawerTrigger";
45
36
 
46
37
  export interface DrawerPositionerProps
47
38
  extends PrimitiveProps,
@@ -49,19 +40,11 @@ export interface DrawerPositionerProps
49
40
 
50
41
  export const DrawerPositioner = forwardRef<HTMLDivElement, DrawerPositionerProps>((props, ref) => {
51
42
  const api = useDrawerContext();
52
- return (
53
- <Primitive.div
54
- ref={ref}
55
- {...props}
56
- style={{ pointerEvents: api.isOpen ? undefined : "none", ...props.style }}
57
- />
58
- );
43
+ return <Primitive.div ref={ref} {...mergeProps(api.positionerProps, props)} />;
59
44
  });
60
45
  DrawerPositioner.displayName = "DrawerPositioner";
61
46
 
62
- export interface DrawerBackdropProps
63
- extends DialogPrimitive.DialogOverlayProps,
64
- React.HTMLAttributes<HTMLDivElement> {}
47
+ export interface DrawerBackdropProps extends PrimitiveProps, React.HTMLAttributes<HTMLDivElement> {}
65
48
 
66
49
  export const DrawerBackdrop = forwardRef<HTMLDivElement, DrawerBackdropProps>((props, ref) => {
67
50
  const {
@@ -73,8 +56,10 @@ export const DrawerBackdrop = forwardRef<HTMLDivElement, DrawerBackdropProps>((p
73
56
  shouldFade,
74
57
  shouldOverlayAnimate,
75
58
  hasAnimationDone,
59
+ lazyMount,
60
+ unmountOnExit,
76
61
  } = useDrawerContext();
77
- const composedRef = useComposedRefs(ref, overlayRef);
62
+ const composedRef = composeRefs(ref, overlayRef);
78
63
  const hasSnapPoints = snapPoints && snapPoints.length > 0;
79
64
  const onMouseUp = useCallbackRef((event: React.PointerEvent<HTMLDivElement>) => onRelease(event));
80
65
 
@@ -83,26 +68,27 @@ export const DrawerBackdrop = forwardRef<HTMLDivElement, DrawerBackdropProps>((p
83
68
  }
84
69
 
85
70
  return (
86
- <DialogPrimitive.Overlay
87
- ref={composedRef}
88
- onMouseUp={onMouseUp}
89
- data-snap-points={isOpen && hasSnapPoints ? "true" : "false"}
90
- data-snap-points-overlay={isOpen && shouldFade ? "true" : "false"}
91
- data-should-overlay-animate={shouldOverlayAnimate ? "true" : "false"}
92
- data-open={dataAttr(isOpen)}
93
- data-animation-done={hasAnimationDone ? "true" : "false"}
94
- {...props}
95
- />
71
+ <Presence present={isOpen} unmountOnExit={unmountOnExit} lazyMount={lazyMount}>
72
+ <Primitive.div
73
+ ref={composedRef}
74
+ onMouseUp={onMouseUp}
75
+ data-snap-points={isOpen && hasSnapPoints ? "true" : "false"}
76
+ data-snap-points-overlay={isOpen && shouldFade ? "true" : "false"}
77
+ data-should-overlay-animate={shouldOverlayAnimate ? "true" : "false"}
78
+ data-open={dataAttr(isOpen)}
79
+ data-animation-done={hasAnimationDone ? "true" : "false"}
80
+ {...props}
81
+ />
82
+ </Presence>
96
83
  );
97
84
  });
98
85
  DrawerBackdrop.displayName = "DrawerBackdrop";
99
86
 
100
- export interface DrawerContentProps
101
- extends DialogPrimitive.DialogContentProps,
102
- React.HTMLAttributes<HTMLDivElement> {}
87
+ export interface DrawerContentProps extends PrimitiveProps, React.HTMLAttributes<HTMLDivElement> {}
103
88
 
104
89
  export const DrawerContent = forwardRef<HTMLDivElement, DrawerContentProps>((props, ref) => {
105
- const { onPointerDownOutside, style, onOpenAutoFocus, ...restProps } = props;
90
+ const { style, ...restProps } = props;
91
+
106
92
  const {
107
93
  drawerRef,
108
94
  onPress,
@@ -123,18 +109,39 @@ export const DrawerContent = forwardRef<HTMLDivElement, DrawerContentProps>((pro
123
109
  closeOnEscape,
124
110
  dismissible,
125
111
  hasAnimationDone,
112
+ titleId,
113
+ descriptionId,
114
+ lazyMount,
115
+ unmountOnExit,
126
116
  } = useDrawerContext();
117
+
118
+ // Lock body scroll while the modal drawer is open. Mirrors the `modal` contract: the lock
119
+ // releases the moment the drawer is non-modal or closed.
120
+ usePreventScroll({ isDisabled: !(modal && isOpen) });
121
+
122
+ const [contentNode, setContentNode] = useState<HTMLDivElement | null>(null);
123
+ const contentRef = useCallback((el: HTMLDivElement | null) => setContentNode(el), []);
124
+
125
+ // aria-hide everything except the content when modal
126
+ useEffect(() => {
127
+ if (!isOpen || !modal || !contentNode) return;
128
+ return hideOthers(contentNode);
129
+ }, [isOpen, modal, contentNode]);
130
+
127
131
  // Needed to use transition instead of animations
128
132
  const [delayedSnapPoints, setDelayedSnapPoints] = useState(false);
129
- const composedRef = useComposedRefs(ref, drawerRef);
130
133
  const pointerStartRef = useRef<{ x: number; y: number } | null>(null);
131
134
  const lastKnownPointerEventRef = useRef<React.PointerEvent<HTMLDivElement> | null>(null);
132
135
  const wasBeyondThePointRef = useRef(false);
136
+ // Whether the current gesture moved past the swipe-start threshold. Used to
137
+ // suppress the trailing click so dragging the sheet doesn't also activate the
138
+ // element the gesture started on (iOS still synthesizes a click on release).
139
+ const draggedRef = useRef(false);
133
140
  const hasSnapPoints = snapPoints && snapPoints.length > 0;
134
141
 
135
142
  const isDeltaInDirection = (
136
143
  delta: { x: number; y: number },
137
- direction: DrawerDirection,
144
+ dir: DrawerDirection,
138
145
  threshold = 0,
139
146
  ) => {
140
147
  if (wasBeyondThePointRef.current) return true;
@@ -142,9 +149,9 @@ export const DrawerContent = forwardRef<HTMLDivElement, DrawerContentProps>((pro
142
149
  const deltaY = Math.abs(delta.y);
143
150
  const deltaX = Math.abs(delta.x);
144
151
  const isDeltaX = deltaX > deltaY;
145
- const dFactor = ["bottom", "right"].includes(direction) ? 1 : -1;
152
+ const dFactor = ["bottom", "right"].includes(dir) ? 1 : -1;
146
153
 
147
- if (direction === "left" || direction === "right") {
154
+ if (dir === "left" || dir === "right") {
148
155
  const isReverseDirection = delta.x * dFactor < 0;
149
156
  if (!isReverseDirection && deltaX >= 0 && deltaX <= threshold) {
150
157
  return isDeltaX;
@@ -175,144 +182,184 @@ export const DrawerContent = forwardRef<HTMLDivElement, DrawerContentProps>((pro
175
182
  }
176
183
 
177
184
  return (
178
- <DialogPrimitive.Content
179
- data-delayed-snap-points={delayedSnapPoints ? "true" : "false"}
180
- data-drawer-direction={direction}
181
- data-open={dataAttr(isOpen)}
182
- data-animation-done={hasAnimationDone ? "true" : "false"}
183
- data-drawer=""
184
- data-snap-points={isOpen && hasSnapPoints ? "true" : "false"}
185
- data-custom-container={container ? "true" : "false"}
186
- {...restProps}
187
- ref={composedRef}
188
- style={
189
- snapPointsOffset && snapPointsOffset.length > 0
190
- ? ({
191
- "--snap-point-height": `${snapPointsOffset[activeSnapPointIndex ?? 0]!}px`,
192
- ...style,
193
- } as React.CSSProperties)
194
- : (style ?? {})
195
- }
196
- onPointerDown={(event) => {
197
- if (handleOnly) return;
198
- restProps.onPointerDown?.(event);
199
- pointerStartRef.current = { x: event.pageX, y: event.pageY };
200
- onPress(event);
201
- }}
202
- onOpenAutoFocus={(e) => {
203
- onOpenAutoFocus?.(e);
204
-
205
- if (!autoFocus) {
206
- e.preventDefault();
207
- }
208
- }}
209
- onPointerDownOutside={(e) => {
210
- onPointerDownOutside?.(e);
211
-
212
- if (!modal || e.defaultPrevented) {
213
- e.preventDefault();
214
- return;
215
- }
216
-
217
- if (keyboardIsOpen.current) {
218
- keyboardIsOpen.current = false;
219
- }
220
- }}
221
- onFocusOutside={(e) => {
222
- props.onFocusOutside?.(e);
223
- // Always prevent focusOutside to avoid conflicts when focus moves between modals
224
- // (e.g., when Dialog closes and restores focus while BottomSheet is opening)
225
- e.preventDefault();
226
- }}
227
- onPointerMove={(event) => {
228
- lastKnownPointerEventRef.current = event;
229
- if (handleOnly) return;
230
- restProps.onPointerMove?.(event);
231
- if (!pointerStartRef.current) return;
232
- const yPosition = event.pageY - pointerStartRef.current.y;
233
- const xPosition = event.pageX - pointerStartRef.current.x;
234
-
235
- const swipeStartThreshold = event.pointerType === "touch" ? 10 : 2;
236
- const delta = { x: xPosition, y: yPosition };
237
-
238
- const isAllowedToSwipe = isDeltaInDirection(delta, direction, swipeStartThreshold);
239
- if (isAllowedToSwipe) onDrag(event);
240
- else if (
241
- Math.abs(xPosition) > swipeStartThreshold ||
242
- Math.abs(yPosition) > swipeStartThreshold
243
- ) {
244
- pointerStartRef.current = null;
245
- }
246
- }}
247
- onPointerUp={(event) => {
248
- restProps.onPointerUp?.(event);
249
- pointerStartRef.current = null;
250
- wasBeyondThePointRef.current = false;
251
- onRelease(event);
252
- }}
253
- onPointerOut={(event) => {
254
- restProps.onPointerOut?.(event);
255
- handleOnPointerUp(lastKnownPointerEventRef.current);
256
- }}
257
- onContextMenu={(event) => {
258
- restProps.onContextMenu?.(event);
259
- if (lastKnownPointerEventRef.current) {
260
- handleOnPointerUp(lastKnownPointerEventRef.current);
261
- }
262
- }}
263
- onInteractOutside={(e) => {
264
- // Only close if event is not prevented (e.g., by onFocusOutside or onPointerDownOutside)
265
- if (dismissible && closeOnInteractOutside && !e.defaultPrevented) {
266
- closeDrawer(false, { reason: "interactOutside", event: e.detail.originalEvent });
267
- }
268
- props.onInteractOutside?.(e);
269
- }}
270
- onEscapeKeyDown={(e) => {
271
- if (dismissible && closeOnEscape) {
185
+ <Presence present={isOpen} unmountOnExit={unmountOnExit} lazyMount={lazyMount}>
186
+ {/* DismissibleLayer must wrap FocusScope, not the other way around.
187
+ FocusScope asChild uses Slot to forward tabIndex/onKeyDown/ref to the
188
+ DOM element; if DismissibleLayer sits between them, those props are
189
+ swallowed by DismissibleLayer's own destructuring and never reach the DOM. */}
190
+ <DismissibleLayer
191
+ enabled={isOpen}
192
+ onEscapeKeyDown={(e) => {
193
+ if (e.defaultPrevented) return;
194
+ if (!dismissible || !closeOnEscape) return;
272
195
  closeDrawer(false, { reason: "escapeKeyDown", event: e });
273
- }
274
- props.onEscapeKeyDown?.(e);
275
- }}
276
- />
196
+ }}
197
+ onPressOutside={(e) => {
198
+ if (e.defaultPrevented) return;
199
+ if (!modal) return;
200
+ if (keyboardIsOpen.current) keyboardIsOpen.current = false;
201
+
202
+ if (!dismissible || !closeOnInteractOutside) return;
203
+ closeDrawer(false, { reason: "interactOutside", event: e });
204
+ }}
205
+ onFocusOutside={() => {
206
+ // Focus trapping is handled by FocusScope — nothing to do here.
207
+ // The old Radix architecture needed e.preventDefault() here (PR #1187)
208
+ // to prevent cascade closes, but the new DismissibleLayer handles
209
+ // that via onCascadeDismiss instead.
210
+ }}
211
+ onCascadeDismiss={({ dismissedParent }) => {
212
+ closeDrawer(false, { reason: "cascadeDismiss", dismissedParent });
213
+ }}
214
+ >
215
+ <FocusScope
216
+ asChild
217
+ loop={modal}
218
+ trapped={modal && isOpen}
219
+ onMountAutoFocus={(e) => {
220
+ // prevent FocusScope's default autoFocus behavior
221
+ e.preventDefault();
222
+
223
+ // when autoFocus is true, FocusScope sets the focus to the first tabbable element; otherwise content;
224
+ // the desired behavior is to set the focus to the content regardless of whether there are tabbable elements or not when true]
225
+ if (autoFocus) {
226
+ drawerRef.current?.focus();
227
+ }
228
+
229
+ // later, we can do something like:
230
+ // when trigger has clicked using keyboard, focus the first tabbable element;
231
+ // when trigger has clicked using mouse, focus the content
232
+ // -> matches Menu behavior
233
+ }}
234
+ >
235
+ <Primitive.div
236
+ role="dialog"
237
+ aria-modal={modal}
238
+ aria-labelledby={titleId}
239
+ aria-describedby={descriptionId}
240
+ data-delayed-snap-points={delayedSnapPoints ? "true" : "false"}
241
+ data-drawer-direction={direction}
242
+ data-open={dataAttr(isOpen)}
243
+ data-animation-done={hasAnimationDone ? "true" : "false"}
244
+ data-drawer=""
245
+ data-snap-points={isOpen && hasSnapPoints ? "true" : "false"}
246
+ data-custom-container={container ? "true" : "false"}
247
+ {...restProps}
248
+ ref={composeRefs(ref, drawerRef, contentRef)}
249
+ style={{
250
+ ...(snapPointsOffset && snapPointsOffset.length > 0
251
+ ? ({
252
+ "--snap-point-height": `${snapPointsOffset[activeSnapPointIndex ?? 0]!}px`,
253
+ ...style,
254
+ } as React.CSSProperties)
255
+ : style),
256
+ ...(!modal && { pointerEvents: "auto" }),
257
+ }}
258
+ onPointerDown={(event) => {
259
+ if (handleOnly) return;
260
+ restProps.onPointerDown?.(event);
261
+ pointerStartRef.current = { x: event.pageX, y: event.pageY };
262
+ draggedRef.current = false;
263
+ onPress(event);
264
+ }}
265
+ onPointerMove={(event) => {
266
+ lastKnownPointerEventRef.current = event;
267
+ if (handleOnly) return;
268
+ restProps.onPointerMove?.(event);
269
+ if (!pointerStartRef.current) return;
270
+ const yPosition = event.pageY - pointerStartRef.current.y;
271
+ const xPosition = event.pageX - pointerStartRef.current.x;
272
+
273
+ const swipeStartThreshold = event.pointerType === "touch" ? 10 : 2;
274
+ const delta = { x: xPosition, y: yPosition };
275
+
276
+ // Once the pointer travels past the swipe-start threshold the gesture
277
+ // is a drag, not a tap — remember it so the trailing click can be
278
+ // suppressed on release (iOS still synthesizes one on the origin).
279
+ const isBeyondThreshold =
280
+ Math.abs(xPosition) > swipeStartThreshold ||
281
+ Math.abs(yPosition) > swipeStartThreshold;
282
+ if (isBeyondThreshold) draggedRef.current = true;
283
+
284
+ const isAllowedToSwipe = isDeltaInDirection(delta, direction, swipeStartThreshold);
285
+ if (isAllowedToSwipe) onDrag(event);
286
+ else if (isBeyondThreshold) {
287
+ pointerStartRef.current = null;
288
+ }
289
+ }}
290
+ onPointerUp={(event) => {
291
+ restProps.onPointerUp?.(event);
292
+ pointerStartRef.current = null;
293
+ wasBeyondThePointRef.current = false;
294
+ onRelease(event);
295
+ }}
296
+ onPointerOut={(event) => {
297
+ restProps.onPointerOut?.(event);
298
+ handleOnPointerUp(lastKnownPointerEventRef.current);
299
+ }}
300
+ onContextMenu={(event) => {
301
+ restProps.onContextMenu?.(event);
302
+ if (lastKnownPointerEventRef.current) {
303
+ handleOnPointerUp(lastKnownPointerEventRef.current);
304
+ }
305
+ }}
306
+ onClickCapture={(event) => {
307
+ restProps.onClickCapture?.(event);
308
+ // Swallow the click that follows a drag so dragging the sheet (e.g. a
309
+ // swipe-to-dismiss started on an item) doesn't also activate that item.
310
+ // A plain tap leaves draggedRef false and clicks normally.
311
+ if (draggedRef.current) {
312
+ event.preventDefault();
313
+ event.stopPropagation();
314
+ draggedRef.current = false;
315
+ }
316
+ }}
317
+ />
318
+ </FocusScope>
319
+ </DismissibleLayer>
320
+ </Presence>
277
321
  );
278
322
  });
279
323
  DrawerContent.displayName = "DrawerContent";
280
324
 
281
- export interface DrawerTitleProps extends DialogPrimitive.DialogTitleProps {}
325
+ export interface DrawerTitleProps
326
+ extends PrimitiveProps,
327
+ React.HTMLAttributes<HTMLHeadingElement> {}
282
328
 
283
- export const DrawerTitle = DialogPrimitive.Title;
329
+ export const DrawerTitle = forwardRef<HTMLHeadingElement, DrawerTitleProps>((props, ref) => {
330
+ const api = useDrawerContext();
331
+ return <Primitive.h2 ref={ref} {...mergeProps(api.titleProps, props)} />;
332
+ });
333
+ DrawerTitle.displayName = "DrawerTitle";
284
334
 
285
- export interface DrawerDescriptionProps extends DialogPrimitive.DialogDescriptionProps {}
335
+ export interface DrawerDescriptionProps
336
+ extends PrimitiveProps,
337
+ React.HTMLAttributes<HTMLParagraphElement> {}
286
338
 
287
- export const DrawerDescription = DialogPrimitive.Description;
339
+ export const DrawerDescription = forwardRef<HTMLParagraphElement, DrawerDescriptionProps>(
340
+ (props, ref) => {
341
+ const api = useDrawerContext();
342
+ return <Primitive.p ref={ref} {...mergeProps(api.descriptionProps, props)} />;
343
+ },
344
+ );
345
+ DrawerDescription.displayName = "DrawerDescription";
288
346
 
289
347
  export interface DrawerHeaderProps extends PrimitiveProps, React.HTMLAttributes<HTMLDivElement> {}
290
348
 
291
349
  export const DrawerHeader = forwardRef<HTMLDivElement, DrawerHeaderProps>((props, ref) => {
292
- const { isCloseButtonRendered } = useDrawerContext();
293
- return (
294
- <Primitive.div ref={ref} data-show-close-button={dataAttr(isCloseButtonRendered)} {...props} />
295
- );
350
+ const api = useDrawerContext();
351
+ return <Primitive.div ref={ref} {...mergeProps(api.headerProps, props)} />;
296
352
  });
297
353
  DrawerHeader.displayName = "DrawerHeader";
298
354
 
299
- export interface DrawerCloseButtonProps extends DialogPrimitive.DialogCloseProps {}
355
+ export interface DrawerCloseButtonProps
356
+ extends PrimitiveProps,
357
+ React.ButtonHTMLAttributes<HTMLButtonElement> {}
300
358
 
301
359
  export const DrawerCloseButton = forwardRef<HTMLButtonElement, DrawerCloseButtonProps>(
302
360
  (props, ref) => {
303
- const { closeButtonRef, setIsOpen } = useDrawerContext();
304
- const composedRef = useComposedRefs(ref, closeButtonRef);
305
- return (
306
- <Primitive.button
307
- ref={composedRef}
308
- {...props}
309
- onClick={(e) => {
310
- props.onClick?.(e);
311
- if (e.defaultPrevented) return;
312
- setIsOpen(false, { reason: "closeButton", event: e.nativeEvent });
313
- }}
314
- />
315
- );
361
+ const api = useDrawerContext();
362
+ return <Primitive.button ref={ref} {...mergeProps(api.closeButtonProps, props)} />;
316
363
  },
317
364
  );
318
365
 
package/src/browser.ts CHANGED
@@ -14,10 +14,6 @@ export function isIPhone(): boolean | undefined {
14
14
  return testPlatform(/^iPhone/);
15
15
  }
16
16
 
17
- export function isSafari(): boolean | undefined {
18
- return /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
19
- }
20
-
21
17
  export function isIPad(): boolean | undefined {
22
18
  return (
23
19
  testPlatform(/^iPad/) ||