@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/{Drawer-12s-B-JAOvwq.js → Drawer-12s-5syEwTtq.js} +274 -317
- package/lib/{Drawer-12s-B35ZJcKm.cjs → Drawer-12s-gd4zJm0S.cjs} +271 -333
- package/lib/index.cjs +1 -1
- package/lib/index.d.ts +4075 -56
- package/lib/index.js +2 -2
- package/package.json +9 -5
- package/src/Drawer.test.tsx +111 -0
- package/src/Drawer.tsx +222 -175
- package/src/browser.ts +0 -4
- package/src/useDrawer.test.tsx +54 -40
- package/src/useDrawer.ts +77 -62
- package/src/use-position-fixed.ts +0 -149
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-
|
|
2
|
-
export { u as useDrawer, j as useDrawerContext } from './Drawer-12s-
|
|
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": "
|
|
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-
|
|
31
|
+
"@radix-ui/react-focus-scope": "^1.1.4",
|
|
32
32
|
"@radix-ui/react-use-callback-ref": "^1.1.0",
|
|
33
|
-
"@seed-design/
|
|
34
|
-
"@seed-design/
|
|
35
|
-
"@seed-design/react-
|
|
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 {
|
|
4
|
-
import
|
|
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 {
|
|
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
|
|
27
|
+
export interface DrawerTriggerProps
|
|
28
|
+
extends PrimitiveProps,
|
|
29
|
+
React.ButtonHTMLAttributes<HTMLButtonElement> {}
|
|
43
30
|
|
|
44
|
-
export const DrawerTrigger =
|
|
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 =
|
|
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
|
-
<
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
152
|
+
const dFactor = ["bottom", "right"].includes(dir) ? 1 : -1;
|
|
146
153
|
|
|
147
|
-
if (
|
|
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
|
-
<
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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
|
|
325
|
+
export interface DrawerTitleProps
|
|
326
|
+
extends PrimitiveProps,
|
|
327
|
+
React.HTMLAttributes<HTMLHeadingElement> {}
|
|
282
328
|
|
|
283
|
-
export const DrawerTitle =
|
|
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
|
|
335
|
+
export interface DrawerDescriptionProps
|
|
336
|
+
extends PrimitiveProps,
|
|
337
|
+
React.HTMLAttributes<HTMLParagraphElement> {}
|
|
286
338
|
|
|
287
|
-
export const DrawerDescription =
|
|
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
|
|
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
|
|
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
|
|
304
|
-
|
|
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/) ||
|