@seed-design/react-drawer 1.0.9 → 1.0.11
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-B35ZJcKm.cjs → Drawer-12s-Bkn6enz-.cjs} +22 -1
- package/lib/{Drawer-12s-B-JAOvwq.js → Drawer-12s-DxbsRb10.js} +22 -1
- package/lib/index.cjs +1 -1
- package/lib/index.d.ts +4 -0
- package/lib/index.js +2 -2
- package/package.json +1 -1
- package/src/Drawer.test.tsx +111 -0
- package/src/Drawer.tsx +24 -4
- package/src/useDrawer.test.tsx +341 -0
- package/src/useDrawer.ts +4 -0
|
@@ -1029,6 +1029,10 @@ const DrawerContent = /*#__PURE__*/ React.forwardRef((props, ref)=>{
|
|
|
1029
1029
|
const pointerStartRef = React.useRef(null);
|
|
1030
1030
|
const lastKnownPointerEventRef = React.useRef(null);
|
|
1031
1031
|
const wasBeyondThePointRef = React.useRef(false);
|
|
1032
|
+
// Whether the current gesture moved past the swipe-start threshold. Used to
|
|
1033
|
+
// suppress the trailing click so dragging the sheet doesn't also activate the
|
|
1034
|
+
// element the gesture started on (iOS still synthesizes a click on release).
|
|
1035
|
+
const draggedRef = React.useRef(false);
|
|
1032
1036
|
const hasSnapPoints = snapPoints && snapPoints.length > 0;
|
|
1033
1037
|
const isDeltaInDirection = (delta, direction, threshold = 0)=>{
|
|
1034
1038
|
if (wasBeyondThePointRef.current) return true;
|
|
@@ -1086,6 +1090,7 @@ const DrawerContent = /*#__PURE__*/ React.forwardRef((props, ref)=>{
|
|
|
1086
1090
|
x: event.pageX,
|
|
1087
1091
|
y: event.pageY
|
|
1088
1092
|
};
|
|
1093
|
+
draggedRef.current = false;
|
|
1089
1094
|
onPress(event);
|
|
1090
1095
|
},
|
|
1091
1096
|
onOpenAutoFocus: (e)=>{
|
|
@@ -1122,9 +1127,14 @@ const DrawerContent = /*#__PURE__*/ React.forwardRef((props, ref)=>{
|
|
|
1122
1127
|
x: xPosition,
|
|
1123
1128
|
y: yPosition
|
|
1124
1129
|
};
|
|
1130
|
+
// Once the pointer travels past the swipe-start threshold the gesture
|
|
1131
|
+
// is a drag, not a tap — remember it so the trailing click can be
|
|
1132
|
+
// suppressed on release (iOS still synthesizes one on the origin).
|
|
1133
|
+
const isBeyondThreshold = Math.abs(xPosition) > swipeStartThreshold || Math.abs(yPosition) > swipeStartThreshold;
|
|
1134
|
+
if (isBeyondThreshold) draggedRef.current = true;
|
|
1125
1135
|
const isAllowedToSwipe = isDeltaInDirection(delta, direction, swipeStartThreshold);
|
|
1126
1136
|
if (isAllowedToSwipe) onDrag(event);
|
|
1127
|
-
else if (
|
|
1137
|
+
else if (isBeyondThreshold) {
|
|
1128
1138
|
pointerStartRef.current = null;
|
|
1129
1139
|
}
|
|
1130
1140
|
},
|
|
@@ -1144,6 +1154,17 @@ const DrawerContent = /*#__PURE__*/ React.forwardRef((props, ref)=>{
|
|
|
1144
1154
|
handleOnPointerUp(lastKnownPointerEventRef.current);
|
|
1145
1155
|
}
|
|
1146
1156
|
},
|
|
1157
|
+
onClickCapture: (event)=>{
|
|
1158
|
+
restProps.onClickCapture?.(event);
|
|
1159
|
+
// Swallow the click that follows a drag so dragging the sheet (e.g. a
|
|
1160
|
+
// swipe-to-dismiss started on an item) doesn't also activate that item.
|
|
1161
|
+
// A plain tap leaves draggedRef false and clicks normally.
|
|
1162
|
+
if (draggedRef.current) {
|
|
1163
|
+
event.preventDefault();
|
|
1164
|
+
event.stopPropagation();
|
|
1165
|
+
draggedRef.current = false;
|
|
1166
|
+
}
|
|
1167
|
+
},
|
|
1147
1168
|
onInteractOutside: (e)=>{
|
|
1148
1169
|
// Only close if event is not prevented (e.g., by onFocusOutside or onPointerDownOutside)
|
|
1149
1170
|
if (dismissible && closeOnInteractOutside && !e.defaultPrevented) {
|
|
@@ -1006,6 +1006,10 @@ const DrawerContent = /*#__PURE__*/ forwardRef((props, ref)=>{
|
|
|
1006
1006
|
const pointerStartRef = useRef(null);
|
|
1007
1007
|
const lastKnownPointerEventRef = useRef(null);
|
|
1008
1008
|
const wasBeyondThePointRef = useRef(false);
|
|
1009
|
+
// Whether the current gesture moved past the swipe-start threshold. Used to
|
|
1010
|
+
// suppress the trailing click so dragging the sheet doesn't also activate the
|
|
1011
|
+
// element the gesture started on (iOS still synthesizes a click on release).
|
|
1012
|
+
const draggedRef = useRef(false);
|
|
1009
1013
|
const hasSnapPoints = snapPoints && snapPoints.length > 0;
|
|
1010
1014
|
const isDeltaInDirection = (delta, direction, threshold = 0)=>{
|
|
1011
1015
|
if (wasBeyondThePointRef.current) return true;
|
|
@@ -1063,6 +1067,7 @@ const DrawerContent = /*#__PURE__*/ forwardRef((props, ref)=>{
|
|
|
1063
1067
|
x: event.pageX,
|
|
1064
1068
|
y: event.pageY
|
|
1065
1069
|
};
|
|
1070
|
+
draggedRef.current = false;
|
|
1066
1071
|
onPress(event);
|
|
1067
1072
|
},
|
|
1068
1073
|
onOpenAutoFocus: (e)=>{
|
|
@@ -1099,9 +1104,14 @@ const DrawerContent = /*#__PURE__*/ forwardRef((props, ref)=>{
|
|
|
1099
1104
|
x: xPosition,
|
|
1100
1105
|
y: yPosition
|
|
1101
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;
|
|
1102
1112
|
const isAllowedToSwipe = isDeltaInDirection(delta, direction, swipeStartThreshold);
|
|
1103
1113
|
if (isAllowedToSwipe) onDrag(event);
|
|
1104
|
-
else if (
|
|
1114
|
+
else if (isBeyondThreshold) {
|
|
1105
1115
|
pointerStartRef.current = null;
|
|
1106
1116
|
}
|
|
1107
1117
|
},
|
|
@@ -1121,6 +1131,17 @@ const DrawerContent = /*#__PURE__*/ forwardRef((props, ref)=>{
|
|
|
1121
1131
|
handleOnPointerUp(lastKnownPointerEventRef.current);
|
|
1122
1132
|
}
|
|
1123
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
|
+
},
|
|
1124
1145
|
onInteractOutside: (e)=>{
|
|
1125
1146
|
// Only close if event is not prevented (e.g., by onFocusOutside or onPointerDownOutside)
|
|
1126
1147
|
if (dismissible && closeOnInteractOutside && !e.defaultPrevented) {
|
package/lib/index.cjs
CHANGED
package/lib/index.d.ts
CHANGED
|
@@ -40,6 +40,7 @@ interface UseDrawerProps {
|
|
|
40
40
|
/**
|
|
41
41
|
* When `true` the `body` doesn't get any styles assigned from Drawer
|
|
42
42
|
* @default true
|
|
43
|
+
* @deprecated SEED React 2.0.0에서 제거됩니다. 2.0.0부터 항상 기본값 `true`처럼 동작합니다.
|
|
43
44
|
*/
|
|
44
45
|
noBodyStyles?: boolean;
|
|
45
46
|
onOpenChange?: (open: boolean, details?: DrawerChangeDetails) => void;
|
|
@@ -101,6 +102,9 @@ interface UseDrawerProps {
|
|
|
101
102
|
* Useful to revert any state changes for example.
|
|
102
103
|
*/
|
|
103
104
|
onAnimationEnd?: (open: boolean) => void;
|
|
105
|
+
/**
|
|
106
|
+
* @deprecated SEED React 2.0.0에서 제거됩니다. 2.0.0부터 항상 기본값 `false`처럼 동작합니다.
|
|
107
|
+
*/
|
|
104
108
|
preventScrollRestoration?: boolean;
|
|
105
109
|
autoFocus?: boolean;
|
|
106
110
|
/**
|
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-DxbsRb10.js';
|
|
2
|
+
export { u as useDrawer, j as useDrawerContext } from './Drawer-12s-DxbsRb10.js';
|
|
3
3
|
|
|
4
4
|
var Drawer_namespace = {
|
|
5
5
|
__proto__: null,
|
package/package.json
CHANGED
|
@@ -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
|
@@ -130,6 +130,10 @@ export const DrawerContent = forwardRef<HTMLDivElement, DrawerContentProps>((pro
|
|
|
130
130
|
const pointerStartRef = useRef<{ x: number; y: number } | null>(null);
|
|
131
131
|
const lastKnownPointerEventRef = useRef<React.PointerEvent<HTMLDivElement> | null>(null);
|
|
132
132
|
const wasBeyondThePointRef = useRef(false);
|
|
133
|
+
// Whether the current gesture moved past the swipe-start threshold. Used to
|
|
134
|
+
// suppress the trailing click so dragging the sheet doesn't also activate the
|
|
135
|
+
// element the gesture started on (iOS still synthesizes a click on release).
|
|
136
|
+
const draggedRef = useRef(false);
|
|
133
137
|
const hasSnapPoints = snapPoints && snapPoints.length > 0;
|
|
134
138
|
|
|
135
139
|
const isDeltaInDirection = (
|
|
@@ -197,6 +201,7 @@ export const DrawerContent = forwardRef<HTMLDivElement, DrawerContentProps>((pro
|
|
|
197
201
|
if (handleOnly) return;
|
|
198
202
|
restProps.onPointerDown?.(event);
|
|
199
203
|
pointerStartRef.current = { x: event.pageX, y: event.pageY };
|
|
204
|
+
draggedRef.current = false;
|
|
200
205
|
onPress(event);
|
|
201
206
|
}}
|
|
202
207
|
onOpenAutoFocus={(e) => {
|
|
@@ -235,12 +240,16 @@ export const DrawerContent = forwardRef<HTMLDivElement, DrawerContentProps>((pro
|
|
|
235
240
|
const swipeStartThreshold = event.pointerType === "touch" ? 10 : 2;
|
|
236
241
|
const delta = { x: xPosition, y: yPosition };
|
|
237
242
|
|
|
243
|
+
// Once the pointer travels past the swipe-start threshold the gesture
|
|
244
|
+
// is a drag, not a tap — remember it so the trailing click can be
|
|
245
|
+
// suppressed on release (iOS still synthesizes one on the origin).
|
|
246
|
+
const isBeyondThreshold =
|
|
247
|
+
Math.abs(xPosition) > swipeStartThreshold || Math.abs(yPosition) > swipeStartThreshold;
|
|
248
|
+
if (isBeyondThreshold) draggedRef.current = true;
|
|
249
|
+
|
|
238
250
|
const isAllowedToSwipe = isDeltaInDirection(delta, direction, swipeStartThreshold);
|
|
239
251
|
if (isAllowedToSwipe) onDrag(event);
|
|
240
|
-
else if (
|
|
241
|
-
Math.abs(xPosition) > swipeStartThreshold ||
|
|
242
|
-
Math.abs(yPosition) > swipeStartThreshold
|
|
243
|
-
) {
|
|
252
|
+
else if (isBeyondThreshold) {
|
|
244
253
|
pointerStartRef.current = null;
|
|
245
254
|
}
|
|
246
255
|
}}
|
|
@@ -260,6 +269,17 @@ export const DrawerContent = forwardRef<HTMLDivElement, DrawerContentProps>((pro
|
|
|
260
269
|
handleOnPointerUp(lastKnownPointerEventRef.current);
|
|
261
270
|
}
|
|
262
271
|
}}
|
|
272
|
+
onClickCapture={(event) => {
|
|
273
|
+
restProps.onClickCapture?.(event);
|
|
274
|
+
// Swallow the click that follows a drag so dragging the sheet (e.g. a
|
|
275
|
+
// swipe-to-dismiss started on an item) doesn't also activate that item.
|
|
276
|
+
// A plain tap leaves draggedRef false and clicks normally.
|
|
277
|
+
if (draggedRef.current) {
|
|
278
|
+
event.preventDefault();
|
|
279
|
+
event.stopPropagation();
|
|
280
|
+
draggedRef.current = false;
|
|
281
|
+
}
|
|
282
|
+
}}
|
|
263
283
|
onInteractOutside={(e) => {
|
|
264
284
|
// Only close if event is not prevented (e.g., by onFocusOutside or onPointerDownOutside)
|
|
265
285
|
if (dismissible && closeOnInteractOutside && !e.defaultPrevented) {
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { act, fireEvent, render } from "@testing-library/react";
|
|
2
|
+
import {
|
|
3
|
+
afterAll,
|
|
4
|
+
afterEach,
|
|
5
|
+
beforeAll,
|
|
6
|
+
describe,
|
|
7
|
+
expect,
|
|
8
|
+
it,
|
|
9
|
+
jest,
|
|
10
|
+
mock,
|
|
11
|
+
spyOn,
|
|
12
|
+
} from "bun:test";
|
|
13
|
+
import * as React from "react";
|
|
14
|
+
import { DRAG_CLASS, TRANSITIONS } from "./constants";
|
|
15
|
+
import { useDrawer, type UseDrawerProps } from "./useDrawer";
|
|
16
|
+
|
|
17
|
+
interface DrawerHarnessProps extends UseDrawerProps {
|
|
18
|
+
initialCloseButtonVisible?: boolean;
|
|
19
|
+
onApi?: (api: ReturnType<typeof useDrawer>) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function DrawerHarness({
|
|
23
|
+
initialCloseButtonVisible = false,
|
|
24
|
+
onApi,
|
|
25
|
+
...props
|
|
26
|
+
}: DrawerHarnessProps) {
|
|
27
|
+
const api = useDrawer(props);
|
|
28
|
+
const [showCloseButton, setShowCloseButton] = React.useState(initialCloseButtonVisible);
|
|
29
|
+
|
|
30
|
+
React.useEffect(() => {
|
|
31
|
+
onApi?.(api);
|
|
32
|
+
}, [api, onApi]);
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div>
|
|
36
|
+
<button data-testid="set-open" onClick={() => api.setIsOpen(true)}>
|
|
37
|
+
열기 설정
|
|
38
|
+
</button>
|
|
39
|
+
<button
|
|
40
|
+
data-testid="set-close"
|
|
41
|
+
onClick={() =>
|
|
42
|
+
api.setIsOpen(false, { reason: "closeButton", event: new MouseEvent("click") })
|
|
43
|
+
}
|
|
44
|
+
>
|
|
45
|
+
닫기 설정
|
|
46
|
+
</button>
|
|
47
|
+
<button
|
|
48
|
+
data-testid="close-drawer"
|
|
49
|
+
onClick={() =>
|
|
50
|
+
api.closeDrawer(false, { reason: "escapeKeyDown", event: new KeyboardEvent("keydown") })
|
|
51
|
+
}
|
|
52
|
+
>
|
|
53
|
+
드로어 닫기
|
|
54
|
+
</button>
|
|
55
|
+
<button
|
|
56
|
+
data-testid="set-second-snap"
|
|
57
|
+
onClick={() => api.setActiveSnapPoint(api.snapPoints?.[1] ?? null)}
|
|
58
|
+
>
|
|
59
|
+
두 번째 스냅으로 이동
|
|
60
|
+
</button>
|
|
61
|
+
<button
|
|
62
|
+
data-testid="toggle-close-button"
|
|
63
|
+
onClick={() => setShowCloseButton((visible) => !visible)}
|
|
64
|
+
>
|
|
65
|
+
닫기 버튼 토글
|
|
66
|
+
</button>
|
|
67
|
+
|
|
68
|
+
{showCloseButton ? <button data-testid="close-button" ref={api.closeButtonRef} /> : null}
|
|
69
|
+
|
|
70
|
+
<div data-testid="is-open">{String(api.isOpen)}</div>
|
|
71
|
+
<div data-testid="is-dragging">{String(api.isDragging)}</div>
|
|
72
|
+
<div data-testid="active-snap-point">{String(api.activeSnapPoint)}</div>
|
|
73
|
+
<div data-testid="has-animation-done">{String(api.hasAnimationDone)}</div>
|
|
74
|
+
<div data-testid="should-overlay-animate">{String(api.shouldOverlayAnimate)}</div>
|
|
75
|
+
<div data-testid="is-close-button-rendered">{String(api.isCloseButtonRendered)}</div>
|
|
76
|
+
|
|
77
|
+
<div
|
|
78
|
+
data-testid="drawer"
|
|
79
|
+
role="dialog"
|
|
80
|
+
ref={api.drawerRef}
|
|
81
|
+
onPointerDown={api.onPress}
|
|
82
|
+
onPointerMove={api.onDrag}
|
|
83
|
+
onPointerUp={api.onRelease}
|
|
84
|
+
/>
|
|
85
|
+
<div data-testid="overlay" ref={api.overlayRef} />
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function mockRect(element: HTMLElement, size = 100) {
|
|
91
|
+
return spyOn(element, "getBoundingClientRect").mockReturnValue({
|
|
92
|
+
x: 0,
|
|
93
|
+
y: 0,
|
|
94
|
+
width: size,
|
|
95
|
+
height: size,
|
|
96
|
+
top: 0,
|
|
97
|
+
left: 0,
|
|
98
|
+
right: size,
|
|
99
|
+
bottom: size,
|
|
100
|
+
toJSON: () => {},
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
describe("useDrawer", () => {
|
|
105
|
+
const originalSetPointerCapture = window.HTMLElement.prototype.setPointerCapture;
|
|
106
|
+
|
|
107
|
+
beforeAll(() => {
|
|
108
|
+
window.HTMLElement.prototype.setPointerCapture = mock(() => {});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
afterAll(() => {
|
|
112
|
+
window.HTMLElement.prototype.setPointerCapture = originalSetPointerCapture;
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
afterEach(() => {
|
|
116
|
+
jest.useRealTimers();
|
|
117
|
+
document.body.style.pointerEvents = "";
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("closeButtonRef를 통해 닫기 버튼 마운트 상태를 추적한다", () => {
|
|
121
|
+
const { getByTestId } = render(<DrawerHarness />);
|
|
122
|
+
|
|
123
|
+
expect(getByTestId("is-close-button-rendered")).toHaveTextContent("false");
|
|
124
|
+
|
|
125
|
+
fireEvent.click(getByTestId("toggle-close-button"));
|
|
126
|
+
expect(getByTestId("is-close-button-rendered")).toHaveTextContent("true");
|
|
127
|
+
|
|
128
|
+
fireEvent.click(getByTestId("toggle-close-button"));
|
|
129
|
+
expect(getByTestId("is-close-button-rendered")).toHaveTextContent("false");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("closeDrawer 호출 시 상세 정보와 함께 닫힘 라이프사이클 콜백을 호출한다", () => {
|
|
133
|
+
jest.useFakeTimers();
|
|
134
|
+
|
|
135
|
+
const onOpenChange = mock(() => {});
|
|
136
|
+
const onAnimationEnd = mock(() => {});
|
|
137
|
+
const onClose = mock(() => {});
|
|
138
|
+
const { getByTestId } = render(
|
|
139
|
+
<DrawerHarness
|
|
140
|
+
defaultOpen
|
|
141
|
+
onOpenChange={onOpenChange}
|
|
142
|
+
onAnimationEnd={onAnimationEnd}
|
|
143
|
+
onClose={onClose}
|
|
144
|
+
/>,
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
fireEvent.click(getByTestId("close-drawer"));
|
|
148
|
+
|
|
149
|
+
expect(getByTestId("is-open")).toHaveTextContent("false");
|
|
150
|
+
expect(onClose).toHaveBeenCalledTimes(1);
|
|
151
|
+
expect(onOpenChange).toHaveBeenCalledWith(
|
|
152
|
+
false,
|
|
153
|
+
expect.objectContaining({ reason: "escapeKeyDown" }),
|
|
154
|
+
);
|
|
155
|
+
expect(document.body.style.pointerEvents).toBe("auto");
|
|
156
|
+
|
|
157
|
+
act(() => {
|
|
158
|
+
jest.advanceTimersByTime(TRANSITIONS.EXIT_DURATION * 1000);
|
|
159
|
+
});
|
|
160
|
+
expect(onAnimationEnd).toHaveBeenCalledWith(false);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("dismissible이 false이고 snapPoints가 없으면 드래그를 시작하지 않는다", () => {
|
|
164
|
+
const { getByTestId } = render(<DrawerHarness defaultOpen dismissible={false} direction="left" />);
|
|
165
|
+
const drawer = getByTestId("drawer");
|
|
166
|
+
|
|
167
|
+
fireEvent.pointerDown(drawer, {
|
|
168
|
+
pointerId: 1,
|
|
169
|
+
pageX: 100,
|
|
170
|
+
pageY: 0,
|
|
171
|
+
clientX: 100,
|
|
172
|
+
clientY: 0,
|
|
173
|
+
pointerType: "mouse",
|
|
174
|
+
});
|
|
175
|
+
fireEvent.pointerMove(drawer, {
|
|
176
|
+
pointerId: 1,
|
|
177
|
+
pageX: 80,
|
|
178
|
+
pageY: 0,
|
|
179
|
+
clientX: 80,
|
|
180
|
+
clientY: 0,
|
|
181
|
+
pointerType: "mouse",
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
expect(getByTestId("is-dragging")).toHaveTextContent("false");
|
|
185
|
+
expect(drawer.classList.contains(DRAG_CLASS)).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("dismissible이 false여도 snapPoints가 있으면 드래그를 시작한다", () => {
|
|
189
|
+
const { getByTestId } = render(
|
|
190
|
+
<DrawerHarness
|
|
191
|
+
defaultOpen
|
|
192
|
+
dismissible={false}
|
|
193
|
+
direction="left"
|
|
194
|
+
snapPoints={["100px", "300px"]}
|
|
195
|
+
/>,
|
|
196
|
+
);
|
|
197
|
+
const drawer = getByTestId("drawer");
|
|
198
|
+
const rectSpy = mockRect(drawer, 100);
|
|
199
|
+
|
|
200
|
+
fireEvent.pointerDown(drawer, {
|
|
201
|
+
pointerId: 1,
|
|
202
|
+
pageX: 100,
|
|
203
|
+
pageY: 0,
|
|
204
|
+
clientX: 100,
|
|
205
|
+
clientY: 0,
|
|
206
|
+
pointerType: "mouse",
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
expect(getByTestId("is-dragging")).toHaveTextContent("true");
|
|
210
|
+
|
|
211
|
+
rectSpy.mockRestore();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("드래그 중 transform을 갱신하고 onDrag를 비율과 함께 호출한다", () => {
|
|
215
|
+
const onDrag = mock(() => {});
|
|
216
|
+
let api: ReturnType<typeof useDrawer> | null = null;
|
|
217
|
+
const { getByTestId } = render(
|
|
218
|
+
<DrawerHarness
|
|
219
|
+
defaultOpen
|
|
220
|
+
direction="left"
|
|
221
|
+
onDrag={onDrag}
|
|
222
|
+
onApi={(latestApi) => {
|
|
223
|
+
api = latestApi;
|
|
224
|
+
}}
|
|
225
|
+
/>,
|
|
226
|
+
);
|
|
227
|
+
const drawer = getByTestId("drawer");
|
|
228
|
+
const rectSpy = mockRect(drawer, 100);
|
|
229
|
+
|
|
230
|
+
if (!api) {
|
|
231
|
+
throw new Error("Drawer API를 사용할 수 없습니다.");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const createPointerEvent = (target: HTMLElement, pageX: number, pageY: number) =>
|
|
235
|
+
({
|
|
236
|
+
target,
|
|
237
|
+
currentTarget: target,
|
|
238
|
+
pointerId: 1,
|
|
239
|
+
pageX,
|
|
240
|
+
pageY,
|
|
241
|
+
nativeEvent: new PointerEvent("pointermove"),
|
|
242
|
+
}) as unknown as React.PointerEvent<HTMLDivElement>;
|
|
243
|
+
|
|
244
|
+
act(() => {
|
|
245
|
+
api?.onPress(createPointerEvent(drawer, 100, 0));
|
|
246
|
+
});
|
|
247
|
+
drawer.style.transform = "matrix(1, 0, 0, 1, 0, 0)";
|
|
248
|
+
act(() => {
|
|
249
|
+
api?.onDrag(createPointerEvent(drawer, 80, 0));
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
expect(getByTestId("is-dragging")).toHaveTextContent("true");
|
|
253
|
+
expect(drawer.classList.contains(DRAG_CLASS)).toBe(true);
|
|
254
|
+
expect(drawer.style.transform).toBe("translate3d(-20px, 0, 0)");
|
|
255
|
+
expect(onDrag).toHaveBeenCalledWith(expect.anything(), 0.2);
|
|
256
|
+
|
|
257
|
+
rectSpy.mockRestore();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("열리는 방향으로 스와이프 후 릴리즈하면 드로어를 리셋하고 open=true를 전달한다", () => {
|
|
261
|
+
const onRelease = mock(() => {});
|
|
262
|
+
const { getByTestId } = render(
|
|
263
|
+
<DrawerHarness defaultOpen direction="right" onRelease={onRelease} />,
|
|
264
|
+
);
|
|
265
|
+
const drawer = getByTestId("drawer");
|
|
266
|
+
const rectSpy = mockRect(drawer, 100);
|
|
267
|
+
|
|
268
|
+
fireEvent.pointerDown(drawer, {
|
|
269
|
+
pointerId: 1,
|
|
270
|
+
pageX: 100,
|
|
271
|
+
pageY: 0,
|
|
272
|
+
clientX: 100,
|
|
273
|
+
clientY: 0,
|
|
274
|
+
pointerType: "mouse",
|
|
275
|
+
});
|
|
276
|
+
drawer.style.transform = "matrix(1, 0, 0, 1, 10, 0)";
|
|
277
|
+
fireEvent.pointerUp(drawer, {
|
|
278
|
+
pointerId: 1,
|
|
279
|
+
pageX: 80,
|
|
280
|
+
pageY: 0,
|
|
281
|
+
clientX: 80,
|
|
282
|
+
clientY: 0,
|
|
283
|
+
pointerType: "mouse",
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
expect(getByTestId("is-dragging")).toHaveTextContent("false");
|
|
287
|
+
expect(drawer.style.transform).toBe("translate3d(0, 0, 0)");
|
|
288
|
+
expect(onRelease).toHaveBeenCalledWith(expect.anything(), true);
|
|
289
|
+
|
|
290
|
+
rectSpy.mockRestore();
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("닫힘 애니메이션 후 active snap point를 첫 번째 스냅 포인트로 되돌린다", () => {
|
|
294
|
+
jest.useFakeTimers();
|
|
295
|
+
|
|
296
|
+
const { getByTestId } = render(
|
|
297
|
+
<DrawerHarness defaultOpen snapPoints={["100px", "300px"]} />,
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
fireEvent.click(getByTestId("set-second-snap"));
|
|
301
|
+
expect(getByTestId("active-snap-point")).toHaveTextContent("300px");
|
|
302
|
+
|
|
303
|
+
fireEvent.click(getByTestId("close-drawer"));
|
|
304
|
+
act(() => {
|
|
305
|
+
jest.advanceTimersByTime(TRANSITIONS.EXIT_DURATION * 1000);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
expect(getByTestId("active-snap-point")).toHaveTextContent("100px");
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("open 상태와 transition 시간에 따라 hasAnimationDone을 갱신한다", () => {
|
|
312
|
+
jest.useFakeTimers();
|
|
313
|
+
|
|
314
|
+
const { getByTestId } = render(<DrawerHarness defaultOpen />);
|
|
315
|
+
|
|
316
|
+
expect(getByTestId("has-animation-done")).toHaveTextContent("false");
|
|
317
|
+
|
|
318
|
+
act(() => {
|
|
319
|
+
jest.advanceTimersByTime(TRANSITIONS.ENTER_DURATION * 1000);
|
|
320
|
+
});
|
|
321
|
+
expect(getByTestId("has-animation-done")).toHaveTextContent("true");
|
|
322
|
+
|
|
323
|
+
fireEvent.click(getByTestId("set-close"));
|
|
324
|
+
expect(getByTestId("has-animation-done")).toHaveTextContent("false");
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("fadeFromIndex가 0이면 초기 열림 구간에서만 오버레이 애니메이션을 활성화한다", () => {
|
|
328
|
+
jest.useFakeTimers();
|
|
329
|
+
|
|
330
|
+
const { getByTestId } = render(
|
|
331
|
+
<DrawerHarness defaultOpen snapPoints={["100px", "300px"]} fadeFromIndex={0} />,
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
expect(getByTestId("should-overlay-animate")).toHaveTextContent("true");
|
|
335
|
+
|
|
336
|
+
act(() => {
|
|
337
|
+
jest.advanceTimersByTime(TRANSITIONS.ENTER_DURATION * 1000);
|
|
338
|
+
});
|
|
339
|
+
expect(getByTestId("should-overlay-animate")).toHaveTextContent("false");
|
|
340
|
+
});
|
|
341
|
+
});
|
package/src/useDrawer.ts
CHANGED
|
@@ -43,6 +43,7 @@ export interface UseDrawerProps {
|
|
|
43
43
|
/**
|
|
44
44
|
* When `true` the `body` doesn't get any styles assigned from Drawer
|
|
45
45
|
* @default true
|
|
46
|
+
* @deprecated SEED React 2.0.0에서 제거됩니다. 2.0.0부터 항상 기본값 `true`처럼 동작합니다.
|
|
46
47
|
*/
|
|
47
48
|
noBodyStyles?: boolean;
|
|
48
49
|
onOpenChange?: (open: boolean, details?: DrawerChangeDetails) => void;
|
|
@@ -104,6 +105,9 @@ export interface UseDrawerProps {
|
|
|
104
105
|
* Useful to revert any state changes for example.
|
|
105
106
|
*/
|
|
106
107
|
onAnimationEnd?: (open: boolean) => void;
|
|
108
|
+
/**
|
|
109
|
+
* @deprecated SEED React 2.0.0에서 제거됩니다. 2.0.0부터 항상 기본값 `false`처럼 동작합니다.
|
|
110
|
+
*/
|
|
107
111
|
preventScrollRestoration?: boolean;
|
|
108
112
|
autoFocus?: boolean;
|
|
109
113
|
|