@seed-design/react-dismissible-layer 0.0.0-alpha-20260414104312
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/DismissibleLayer-12s-Cz1jvGI2.js +23 -0
- package/lib/DismissibleLayer-12s-ZXi0vrlt.cjs +23 -0
- package/lib/index.cjs +13 -0
- package/lib/index.d.ts +112 -0
- package/lib/index.js +3 -0
- package/lib/layer-stack-12s-Ctg3ipjW.cjs +125 -0
- package/lib/layer-stack-12s-DjRVp1_u.js +112 -0
- package/lib/use-escape-keydown-12s-Bl_her-_.js +31 -0
- package/lib/use-escape-keydown-12s-CNZqs1dI.cjs +31 -0
- package/lib/use-focus-outside-12s-Ch-SoiiA.cjs +48 -0
- package/lib/use-focus-outside-12s-LpFt7UjO.js +48 -0
- package/lib/use-pointer-down-outside-12s-BLctGw0m.js +202 -0
- package/lib/use-pointer-down-outside-12s-nlxC-DrD.cjs +202 -0
- package/lib/useDismissibleLayer-12s-C34wvjXv.js +146 -0
- package/lib/useDismissibleLayer-12s-D4qWttkW.cjs +146 -0
- package/package.json +45 -0
- package/src/DismissibleLayer.tsx +25 -0
- package/src/index.ts +17 -0
- package/src/layer-stack.test.ts +291 -0
- package/src/layer-stack.ts +169 -0
- package/src/use-escape-keydown.ts +31 -0
- package/src/use-focus-outside.ts +62 -0
- package/src/use-pointer-down-outside.ts +272 -0
- package/src/useDismissibleLayer.test.tsx +732 -0
- package/src/useDismissibleLayer.ts +234 -0
|
@@ -0,0 +1,732 @@
|
|
|
1
|
+
import { render, fireEvent, act } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { describe, expect, it, mock, beforeEach } from "bun:test";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
import { useDismissibleLayer, type UseDismissibleLayerOptions } from "./useDismissibleLayer";
|
|
6
|
+
|
|
7
|
+
const NOOP = () => {};
|
|
8
|
+
const optionDefaults = {
|
|
9
|
+
onEscapeKeyDown: NOOP,
|
|
10
|
+
onPressOutside: NOOP,
|
|
11
|
+
onFocusOutside: NOOP,
|
|
12
|
+
onCascadeDismiss: NOOP,
|
|
13
|
+
} satisfies Pick<
|
|
14
|
+
UseDismissibleLayerOptions,
|
|
15
|
+
"onEscapeKeyDown" | "onPressOutside" | "onFocusOutside" | "onCascadeDismiss"
|
|
16
|
+
>;
|
|
17
|
+
|
|
18
|
+
type TestOptions = Partial<UseDismissibleLayerOptions> & { enabled: boolean };
|
|
19
|
+
|
|
20
|
+
function DismissibleBox({
|
|
21
|
+
options,
|
|
22
|
+
children,
|
|
23
|
+
...divProps
|
|
24
|
+
}: {
|
|
25
|
+
options: TestOptions;
|
|
26
|
+
children?: React.ReactNode;
|
|
27
|
+
} & React.HTMLAttributes<HTMLDivElement>) {
|
|
28
|
+
const { dismissibleRef, dismissibleProps } = useDismissibleLayer({
|
|
29
|
+
...optionDefaults,
|
|
30
|
+
...options,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div ref={dismissibleRef} data-testid="dismissible" {...dismissibleProps} {...divProps}>
|
|
35
|
+
{children}
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function NestedLayers({
|
|
41
|
+
outerOptions,
|
|
42
|
+
innerOptions,
|
|
43
|
+
}: {
|
|
44
|
+
outerOptions: TestOptions;
|
|
45
|
+
innerOptions: TestOptions;
|
|
46
|
+
}) {
|
|
47
|
+
const { dismissibleRef: outerRef, dismissibleProps: outerProps } = useDismissibleLayer({
|
|
48
|
+
...optionDefaults,
|
|
49
|
+
...outerOptions,
|
|
50
|
+
});
|
|
51
|
+
const { dismissibleRef: innerRef, dismissibleProps: innerProps } = useDismissibleLayer({
|
|
52
|
+
...optionDefaults,
|
|
53
|
+
...innerOptions,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div ref={outerRef} data-testid="outer" {...outerProps}>
|
|
58
|
+
<div ref={innerRef} data-testid="inner" {...innerProps}>
|
|
59
|
+
<button type="button" data-testid="inner-button">
|
|
60
|
+
Inner
|
|
61
|
+
</button>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe("useDismissibleLayer", () => {
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
document.body.innerHTML = "";
|
|
70
|
+
});
|
|
71
|
+
describe("enabled", () => {
|
|
72
|
+
it("does not call onEscapeKeyDown when enabled is false", async () => {
|
|
73
|
+
const user = userEvent.setup();
|
|
74
|
+
const onEscapeKeyDown = mock(() => {});
|
|
75
|
+
render(<DismissibleBox options={{ enabled: false, onEscapeKeyDown }} />);
|
|
76
|
+
|
|
77
|
+
await user.keyboard("{Escape}");
|
|
78
|
+
expect(onEscapeKeyDown).not.toHaveBeenCalled();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("calls onEscapeKeyDown when enabled is true", async () => {
|
|
82
|
+
const user = userEvent.setup();
|
|
83
|
+
const onEscapeKeyDown = mock(() => {});
|
|
84
|
+
render(<DismissibleBox options={{ enabled: true, onEscapeKeyDown }} />);
|
|
85
|
+
|
|
86
|
+
await user.keyboard("{Escape}");
|
|
87
|
+
expect(onEscapeKeyDown).toHaveBeenCalledTimes(1);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
describe("escape", () => {
|
|
91
|
+
it("calls onEscapeKeyDown on Escape", async () => {
|
|
92
|
+
const user = userEvent.setup();
|
|
93
|
+
const onEscapeKeyDown = mock(() => {});
|
|
94
|
+
render(<DismissibleBox options={{ enabled: true, onEscapeKeyDown }} />);
|
|
95
|
+
|
|
96
|
+
await user.keyboard("{Escape}");
|
|
97
|
+
expect(onEscapeKeyDown).toHaveBeenCalledTimes(1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("only topmost layer receives onEscapeKeyDown in nested scenario", async () => {
|
|
101
|
+
const user = userEvent.setup();
|
|
102
|
+
const outerEscape = mock(() => {});
|
|
103
|
+
const innerEscape = mock(() => {});
|
|
104
|
+
|
|
105
|
+
render(
|
|
106
|
+
<NestedLayers
|
|
107
|
+
outerOptions={{ enabled: true, onEscapeKeyDown: outerEscape }}
|
|
108
|
+
innerOptions={{ enabled: true, onEscapeKeyDown: innerEscape }}
|
|
109
|
+
/>,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
await user.keyboard("{Escape}");
|
|
113
|
+
|
|
114
|
+
expect(innerEscape).toHaveBeenCalledTimes(1);
|
|
115
|
+
expect(outerEscape).not.toHaveBeenCalled();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("outer layer receives Escape after inner is disabled", async () => {
|
|
119
|
+
const user = userEvent.setup();
|
|
120
|
+
const outerEscape = mock(() => {});
|
|
121
|
+
const innerEscape = mock(() => {});
|
|
122
|
+
|
|
123
|
+
const { rerender } = render(
|
|
124
|
+
<NestedLayers
|
|
125
|
+
outerOptions={{ enabled: true, onEscapeKeyDown: outerEscape }}
|
|
126
|
+
innerOptions={{ enabled: true, onEscapeKeyDown: innerEscape }}
|
|
127
|
+
/>,
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
rerender(
|
|
131
|
+
<NestedLayers
|
|
132
|
+
outerOptions={{ enabled: true, onEscapeKeyDown: outerEscape }}
|
|
133
|
+
innerOptions={{ enabled: false, onEscapeKeyDown: innerEscape }}
|
|
134
|
+
/>,
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
await user.keyboard("{Escape}");
|
|
138
|
+
|
|
139
|
+
expect(outerEscape).toHaveBeenCalledTimes(1);
|
|
140
|
+
expect(innerEscape).not.toHaveBeenCalled();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("does not propagate Escape to outer layer when inner layer ignores it", async () => {
|
|
144
|
+
const user = userEvent.setup();
|
|
145
|
+
const outerEscape = mock(() => {});
|
|
146
|
+
// Inner layer receives Escape but does nothing (simulates closeOnEscape=false)
|
|
147
|
+
const innerEscape = mock(() => {});
|
|
148
|
+
|
|
149
|
+
render(
|
|
150
|
+
<NestedLayers
|
|
151
|
+
outerOptions={{ enabled: true, onEscapeKeyDown: outerEscape }}
|
|
152
|
+
innerOptions={{ enabled: true, onEscapeKeyDown: innerEscape }}
|
|
153
|
+
/>,
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
await user.keyboard("{Escape}");
|
|
157
|
+
|
|
158
|
+
// Inner layer received it but didn't close (still topmost)
|
|
159
|
+
expect(innerEscape).toHaveBeenCalledTimes(1);
|
|
160
|
+
// Outer layer must NOT receive it — isTopMost guard prevents this
|
|
161
|
+
expect(outerEscape).not.toHaveBeenCalled();
|
|
162
|
+
|
|
163
|
+
// Press Escape again — inner is still topmost, outer still blocked
|
|
164
|
+
await user.keyboard("{Escape}");
|
|
165
|
+
expect(innerEscape).toHaveBeenCalledTimes(2);
|
|
166
|
+
expect(outerEscape).not.toHaveBeenCalled();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("ignores Escape during IME composition", () => {
|
|
170
|
+
const onEscapeKeyDown = mock(() => {});
|
|
171
|
+
render(<DismissibleBox options={{ enabled: true, onEscapeKeyDown }} />);
|
|
172
|
+
|
|
173
|
+
fireEvent.keyDown(document, { key: "Escape", isComposing: true });
|
|
174
|
+
expect(onEscapeKeyDown).not.toHaveBeenCalled();
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
describe("pointer down outside", () => {
|
|
178
|
+
it("calls onPressOutside when clicking outside", async () => {
|
|
179
|
+
const user = userEvent.setup();
|
|
180
|
+
const onPressOutside = mock(() => {});
|
|
181
|
+
render(
|
|
182
|
+
<>
|
|
183
|
+
<DismissibleBox options={{ enabled: true, onPressOutside }} />
|
|
184
|
+
<button type="button" data-testid="outside">
|
|
185
|
+
Outside
|
|
186
|
+
</button>
|
|
187
|
+
</>,
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
await act(() => new Promise((r) => setTimeout(r, 10)));
|
|
191
|
+
|
|
192
|
+
const outside = document.querySelector('[data-testid="outside"]');
|
|
193
|
+
if (!outside) throw new Error("Outside button not found");
|
|
194
|
+
|
|
195
|
+
await user.click(outside);
|
|
196
|
+
expect(onPressOutside).toHaveBeenCalledTimes(1);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("does not call onPressOutside when clicking inside", async () => {
|
|
200
|
+
const user = userEvent.setup();
|
|
201
|
+
const onPressOutside = mock(() => {});
|
|
202
|
+
const { getByTestId } = render(
|
|
203
|
+
<DismissibleBox options={{ enabled: true, onPressOutside }}>
|
|
204
|
+
<button type="button" data-testid="inside">
|
|
205
|
+
Inside
|
|
206
|
+
</button>
|
|
207
|
+
</DismissibleBox>,
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
await act(() => new Promise((r) => setTimeout(r, 10)));
|
|
211
|
+
|
|
212
|
+
await user.click(getByTestId("inside"));
|
|
213
|
+
expect(onPressOutside).not.toHaveBeenCalled();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("does not call onPressOutside when clicking an excluded target", async () => {
|
|
217
|
+
const user = userEvent.setup();
|
|
218
|
+
const onPressOutside = mock(() => {});
|
|
219
|
+
const triggerRef = React.createRef<HTMLButtonElement>();
|
|
220
|
+
|
|
221
|
+
function TestComponent() {
|
|
222
|
+
const { dismissibleRef, dismissibleProps } = useDismissibleLayer({
|
|
223
|
+
...optionDefaults,
|
|
224
|
+
enabled: true,
|
|
225
|
+
onPressOutside,
|
|
226
|
+
exclude: (target) => triggerRef.current?.contains(target) ?? false,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
<>
|
|
231
|
+
<button type="button" ref={triggerRef} data-testid="trigger">
|
|
232
|
+
Trigger
|
|
233
|
+
</button>
|
|
234
|
+
<div ref={dismissibleRef} data-testid="content" {...dismissibleProps}>
|
|
235
|
+
Content
|
|
236
|
+
</div>
|
|
237
|
+
</>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
render(<TestComponent />);
|
|
242
|
+
await act(() => new Promise((r) => setTimeout(r, 10)));
|
|
243
|
+
|
|
244
|
+
const trigger = document.querySelector('[data-testid="trigger"]');
|
|
245
|
+
if (!trigger) throw new Error("Trigger not found");
|
|
246
|
+
|
|
247
|
+
await user.click(trigger);
|
|
248
|
+
expect(onPressOutside).not.toHaveBeenCalled();
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
describe("preventDefault bridge", () => {
|
|
252
|
+
it("consumer preventDefault on escape signals to parent layers", () => {
|
|
253
|
+
const event = new KeyboardEvent("keydown", {
|
|
254
|
+
key: "Escape",
|
|
255
|
+
bubbles: true,
|
|
256
|
+
cancelable: true,
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
render(
|
|
260
|
+
<DismissibleBox
|
|
261
|
+
options={{
|
|
262
|
+
enabled: true,
|
|
263
|
+
onEscapeKeyDown: (e) => e.preventDefault(),
|
|
264
|
+
}}
|
|
265
|
+
/>,
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
document.dispatchEvent(event);
|
|
269
|
+
expect(event.defaultPrevented).toBe(true);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// NOTE: We only test click-defer (default) flow here.
|
|
273
|
+
// The "drag" mode tests are in the "touch outside mode: drag" describe block.
|
|
274
|
+
it("consumer preventDefault on pointerdown signals to parent layers", async () => {
|
|
275
|
+
render(
|
|
276
|
+
<>
|
|
277
|
+
<DismissibleBox
|
|
278
|
+
options={{
|
|
279
|
+
enabled: true,
|
|
280
|
+
onPressOutside: (e) => e.preventDefault(),
|
|
281
|
+
}}
|
|
282
|
+
/>
|
|
283
|
+
<button type="button" data-testid="outside">
|
|
284
|
+
Outside
|
|
285
|
+
</button>
|
|
286
|
+
</>,
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
await act(() => new Promise((r) => setTimeout(r, 10)));
|
|
290
|
+
|
|
291
|
+
const event = new PointerEvent("pointerdown", {
|
|
292
|
+
bubbles: true,
|
|
293
|
+
cancelable: true,
|
|
294
|
+
});
|
|
295
|
+
const outside = document.querySelector('[data-testid="outside"]');
|
|
296
|
+
if (!outside) throw new Error("Outside button not found");
|
|
297
|
+
|
|
298
|
+
outside.dispatchEvent(event);
|
|
299
|
+
// "confirm" (default): pointerdown defers to click
|
|
300
|
+
fireEvent.click(outside);
|
|
301
|
+
expect(event.defaultPrevented).toBe(true);
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
describe("pressBehavior", () => {
|
|
306
|
+
const wait = (ms = 10) => act(() => new Promise((r) => setTimeout(r, ms)));
|
|
307
|
+
|
|
308
|
+
function touchStart(el: Element, x: number, y: number) {
|
|
309
|
+
fireEvent.touchStart(el, { touches: [{ clientX: x, clientY: y }] });
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function touchMove(el: Element, x: number, y: number) {
|
|
313
|
+
fireEvent.touchMove(el, { touches: [{ clientX: x, clientY: y }] });
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function touchEnd(el: Element) {
|
|
317
|
+
fireEvent.touchEnd(el, { touches: [] });
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
it("does not dismiss on pointerdown with pointerType touch", async () => {
|
|
321
|
+
const user = userEvent.setup();
|
|
322
|
+
const onPressOutside = mock(() => {});
|
|
323
|
+
render(
|
|
324
|
+
<>
|
|
325
|
+
<DismissibleBox options={{ enabled: true, onPressOutside, pressBehavior: "drag" }} />
|
|
326
|
+
<button type="button" data-testid="outside">
|
|
327
|
+
Outside
|
|
328
|
+
</button>
|
|
329
|
+
</>,
|
|
330
|
+
);
|
|
331
|
+
await wait();
|
|
332
|
+
|
|
333
|
+
const outside = document.querySelector('[data-testid="outside"]')!;
|
|
334
|
+
await user.pointer({ keys: "[TouchA>]", target: outside });
|
|
335
|
+
expect(onPressOutside).not.toHaveBeenCalled();
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("dismisses immediately when touch moves > 10px outside", async () => {
|
|
339
|
+
const onPressOutside = mock(() => {});
|
|
340
|
+
render(
|
|
341
|
+
<>
|
|
342
|
+
<DismissibleBox options={{ enabled: true, onPressOutside, pressBehavior: "drag" }} />
|
|
343
|
+
<button type="button" data-testid="outside">
|
|
344
|
+
Outside
|
|
345
|
+
</button>
|
|
346
|
+
</>,
|
|
347
|
+
);
|
|
348
|
+
await wait();
|
|
349
|
+
|
|
350
|
+
const outside = document.querySelector('[data-testid="outside"]')!;
|
|
351
|
+
touchStart(outside, 100, 100);
|
|
352
|
+
touchMove(outside, 100, 115); // delta 15px > 10px
|
|
353
|
+
expect(onPressOutside).toHaveBeenCalledTimes(1);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("dismisses on touchend when touch moves > 5px but < 10px", async () => {
|
|
357
|
+
const onPressOutside = mock(() => {});
|
|
358
|
+
render(
|
|
359
|
+
<>
|
|
360
|
+
<DismissibleBox options={{ enabled: true, onPressOutside, pressBehavior: "drag" }} />
|
|
361
|
+
<button type="button" data-testid="outside">
|
|
362
|
+
Outside
|
|
363
|
+
</button>
|
|
364
|
+
</>,
|
|
365
|
+
);
|
|
366
|
+
await wait();
|
|
367
|
+
|
|
368
|
+
const outside = document.querySelector('[data-testid="outside"]')!;
|
|
369
|
+
touchStart(outside, 100, 100);
|
|
370
|
+
touchMove(outside, 100, 107); // delta 7px: > 5px, < 10px
|
|
371
|
+
expect(onPressOutside).not.toHaveBeenCalled();
|
|
372
|
+
|
|
373
|
+
touchEnd(outside);
|
|
374
|
+
expect(onPressOutside).toHaveBeenCalledTimes(1);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it("defers tap to click when touch moves < 5px", async () => {
|
|
378
|
+
const onPressOutside = mock(() => {});
|
|
379
|
+
render(
|
|
380
|
+
<>
|
|
381
|
+
<DismissibleBox options={{ enabled: true, onPressOutside, pressBehavior: "drag" }} />
|
|
382
|
+
<button type="button" data-testid="outside">
|
|
383
|
+
Outside
|
|
384
|
+
</button>
|
|
385
|
+
</>,
|
|
386
|
+
);
|
|
387
|
+
await wait();
|
|
388
|
+
|
|
389
|
+
const outside = document.querySelector('[data-testid="outside"]')!;
|
|
390
|
+
touchStart(outside, 100, 100);
|
|
391
|
+
touchMove(outside, 100, 103); // delta 3px < 5px
|
|
392
|
+
touchEnd(outside);
|
|
393
|
+
// Not dismissed yet — deferred to click
|
|
394
|
+
expect(onPressOutside).not.toHaveBeenCalled();
|
|
395
|
+
|
|
396
|
+
fireEvent.click(outside);
|
|
397
|
+
expect(onPressOutside).toHaveBeenCalledTimes(1);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("suppresses synthetic pointerdown after touch drag dismiss", async () => {
|
|
401
|
+
const onPressOutside = mock(() => {});
|
|
402
|
+
render(
|
|
403
|
+
<>
|
|
404
|
+
<DismissibleBox options={{ enabled: true, onPressOutside, pressBehavior: "drag" }} />
|
|
405
|
+
<button type="button" data-testid="outside">
|
|
406
|
+
Outside
|
|
407
|
+
</button>
|
|
408
|
+
</>,
|
|
409
|
+
);
|
|
410
|
+
await wait();
|
|
411
|
+
|
|
412
|
+
const outside = document.querySelector('[data-testid="outside"]')!;
|
|
413
|
+
touchStart(outside, 100, 100);
|
|
414
|
+
touchMove(outside, 100, 115); // dismiss
|
|
415
|
+
expect(onPressOutside).toHaveBeenCalledTimes(1);
|
|
416
|
+
|
|
417
|
+
// synthetic pointerdown after touch — should be suppressed
|
|
418
|
+
fireEvent.pointerDown(outside);
|
|
419
|
+
expect(onPressOutside).toHaveBeenCalledTimes(1);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it("click-through guard expires after 1000ms", async () => {
|
|
423
|
+
const onPressOutside = mock(() => {});
|
|
424
|
+
render(
|
|
425
|
+
<>
|
|
426
|
+
<DismissibleBox options={{ enabled: true, onPressOutside, pressBehavior: "drag" }} />
|
|
427
|
+
<button type="button" data-testid="outside">
|
|
428
|
+
Outside
|
|
429
|
+
</button>
|
|
430
|
+
</>,
|
|
431
|
+
);
|
|
432
|
+
await wait();
|
|
433
|
+
|
|
434
|
+
const outside = document.querySelector('[data-testid="outside"]')!;
|
|
435
|
+
touchStart(outside, 100, 100);
|
|
436
|
+
touchMove(outside, 100, 115); // dismiss
|
|
437
|
+
expect(onPressOutside).toHaveBeenCalledTimes(1);
|
|
438
|
+
|
|
439
|
+
await wait(1100);
|
|
440
|
+
|
|
441
|
+
fireEvent.pointerDown(outside);
|
|
442
|
+
expect(onPressOutside).toHaveBeenCalledTimes(2);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it("does not dismiss when touchstart is inside the layer", async () => {
|
|
446
|
+
const onPressOutside = mock(() => {});
|
|
447
|
+
render(
|
|
448
|
+
<>
|
|
449
|
+
<DismissibleBox options={{ enabled: true, onPressOutside, pressBehavior: "drag" }}>
|
|
450
|
+
<button type="button" data-testid="inside">
|
|
451
|
+
Inside
|
|
452
|
+
</button>
|
|
453
|
+
</DismissibleBox>
|
|
454
|
+
<button type="button" data-testid="outside">
|
|
455
|
+
Outside
|
|
456
|
+
</button>
|
|
457
|
+
</>,
|
|
458
|
+
);
|
|
459
|
+
await wait();
|
|
460
|
+
|
|
461
|
+
const inside = document.querySelector('[data-testid="inside"]')!;
|
|
462
|
+
const outside = document.querySelector('[data-testid="outside"]')!;
|
|
463
|
+
touchStart(inside, 100, 100);
|
|
464
|
+
touchMove(outside, 100, 115);
|
|
465
|
+
touchEnd(outside);
|
|
466
|
+
expect(onPressOutside).not.toHaveBeenCalled();
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it("dismisses on drag even when touchstart target is excluded", async () => {
|
|
470
|
+
const onPressOutside = mock(() => {});
|
|
471
|
+
const triggerRef = React.createRef<HTMLButtonElement>();
|
|
472
|
+
|
|
473
|
+
function TestComponent() {
|
|
474
|
+
const { dismissibleRef, dismissibleProps } = useDismissibleLayer({
|
|
475
|
+
...optionDefaults,
|
|
476
|
+
enabled: true,
|
|
477
|
+
onPressOutside,
|
|
478
|
+
pressBehavior: "drag",
|
|
479
|
+
exclude: (target) => triggerRef.current?.contains(target) ?? false,
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
return (
|
|
483
|
+
<>
|
|
484
|
+
<button type="button" ref={triggerRef} data-testid="trigger">
|
|
485
|
+
Trigger
|
|
486
|
+
</button>
|
|
487
|
+
<div ref={dismissibleRef} data-testid="content" {...dismissibleProps}>
|
|
488
|
+
Content
|
|
489
|
+
</div>
|
|
490
|
+
</>
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
render(<TestComponent />);
|
|
495
|
+
await wait();
|
|
496
|
+
|
|
497
|
+
const trigger = document.querySelector('[data-testid="trigger"]')!;
|
|
498
|
+
touchStart(trigger, 100, 100);
|
|
499
|
+
touchMove(trigger, 100, 115); // >10px drag from excluded target → dismiss
|
|
500
|
+
expect(onPressOutside).toHaveBeenCalledTimes(1);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it("default mode still defers touch to click", async () => {
|
|
504
|
+
const onPressOutside = mock(() => {});
|
|
505
|
+
render(
|
|
506
|
+
<>
|
|
507
|
+
<DismissibleBox options={{ enabled: true, onPressOutside }} />
|
|
508
|
+
<button type="button" data-testid="outside">
|
|
509
|
+
Outside
|
|
510
|
+
</button>
|
|
511
|
+
</>,
|
|
512
|
+
);
|
|
513
|
+
await wait();
|
|
514
|
+
|
|
515
|
+
const outside = document.querySelector('[data-testid="outside"]')!;
|
|
516
|
+
fireEvent.pointerDown(outside, { pointerType: "touch" });
|
|
517
|
+
// click-defer: pointerdown alone should not dismiss
|
|
518
|
+
expect(onPressOutside).not.toHaveBeenCalled();
|
|
519
|
+
|
|
520
|
+
fireEvent.click(outside);
|
|
521
|
+
expect(onPressOutside).toHaveBeenCalledTimes(1);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it("mouse pointerdown still dismisses immediately in drag mode", async () => {
|
|
525
|
+
const user = userEvent.setup();
|
|
526
|
+
const onPressOutside = mock(() => {});
|
|
527
|
+
render(
|
|
528
|
+
<>
|
|
529
|
+
<DismissibleBox options={{ enabled: true, onPressOutside, pressBehavior: "drag" }} />
|
|
530
|
+
<button type="button" data-testid="outside">
|
|
531
|
+
Outside
|
|
532
|
+
</button>
|
|
533
|
+
</>,
|
|
534
|
+
);
|
|
535
|
+
await wait();
|
|
536
|
+
|
|
537
|
+
const outside = document.querySelector('[data-testid="outside"]')!;
|
|
538
|
+
await user.pointer({ keys: "[MouseLeft>]", target: outside });
|
|
539
|
+
expect(onPressOutside).toHaveBeenCalledTimes(1);
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it("does not dismiss on pointerdown in confirm mode", async () => {
|
|
543
|
+
const user = userEvent.setup();
|
|
544
|
+
const onPressOutside = mock(() => {});
|
|
545
|
+
render(
|
|
546
|
+
<>
|
|
547
|
+
<DismissibleBox options={{ enabled: true, onPressOutside, pressBehavior: "confirm" }} />
|
|
548
|
+
<button type="button" data-testid="outside">
|
|
549
|
+
Outside
|
|
550
|
+
</button>
|
|
551
|
+
</>,
|
|
552
|
+
);
|
|
553
|
+
await wait();
|
|
554
|
+
|
|
555
|
+
const outside = document.querySelector('[data-testid="outside"]')!;
|
|
556
|
+
await user.pointer({ keys: "[MouseLeft>]", target: outside });
|
|
557
|
+
expect(onPressOutside).not.toHaveBeenCalled();
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it("dismisses on click in confirm mode", async () => {
|
|
561
|
+
const user = userEvent.setup();
|
|
562
|
+
const onPressOutside = mock(() => {});
|
|
563
|
+
render(
|
|
564
|
+
<>
|
|
565
|
+
<DismissibleBox options={{ enabled: true, onPressOutside, pressBehavior: "confirm" }} />
|
|
566
|
+
<button type="button" data-testid="outside">
|
|
567
|
+
Outside
|
|
568
|
+
</button>
|
|
569
|
+
</>,
|
|
570
|
+
);
|
|
571
|
+
await wait();
|
|
572
|
+
|
|
573
|
+
const outside = document.querySelector('[data-testid="outside"]')!;
|
|
574
|
+
await user.pointer({ keys: "[MouseLeft>]", target: outside });
|
|
575
|
+
expect(onPressOutside).not.toHaveBeenCalled();
|
|
576
|
+
|
|
577
|
+
await user.pointer({ keys: "[/MouseLeft]" });
|
|
578
|
+
expect(onPressOutside).toHaveBeenCalledTimes(1);
|
|
579
|
+
});
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
describe("focus outside", () => {
|
|
583
|
+
const wait = (ms = 10) => act(() => new Promise((r) => setTimeout(r, ms)));
|
|
584
|
+
|
|
585
|
+
it("calls onFocusOutside after re-enable even if focus was inside before disable", async () => {
|
|
586
|
+
const onFocusOutside = mock(() => {});
|
|
587
|
+
let setEnabledExternal: (v: boolean) => void;
|
|
588
|
+
|
|
589
|
+
function Harness() {
|
|
590
|
+
const [enabled, setEnabled] = React.useState(true);
|
|
591
|
+
setEnabledExternal = setEnabled;
|
|
592
|
+
const { dismissibleRef, dismissibleProps } = useDismissibleLayer({
|
|
593
|
+
...optionDefaults,
|
|
594
|
+
enabled,
|
|
595
|
+
onFocusOutside,
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
return (
|
|
599
|
+
<>
|
|
600
|
+
<div ref={dismissibleRef} data-testid="layer" {...dismissibleProps}>
|
|
601
|
+
<button type="button" data-testid="inside">
|
|
602
|
+
Inside
|
|
603
|
+
</button>
|
|
604
|
+
</div>
|
|
605
|
+
<button type="button" data-testid="outside">
|
|
606
|
+
Outside
|
|
607
|
+
</button>
|
|
608
|
+
</>
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const { getByTestId } = render(<Harness />);
|
|
613
|
+
await wait();
|
|
614
|
+
|
|
615
|
+
// Focus inside the layer — sets isFocusInsideReactTreeRef to true via onFocusCapture
|
|
616
|
+
const insideBtn = getByTestId("inside");
|
|
617
|
+
act(() => insideBtn.focus());
|
|
618
|
+
|
|
619
|
+
// Disable the layer WITHOUT moving focus (no blur → ref stays true)
|
|
620
|
+
await act(async () => setEnabledExternal(false));
|
|
621
|
+
await wait();
|
|
622
|
+
|
|
623
|
+
// Re-enable the layer — ref is still stale (true) if cleanup didn't reset it
|
|
624
|
+
await act(async () => setEnabledExternal(true));
|
|
625
|
+
await wait();
|
|
626
|
+
|
|
627
|
+
// Fire focusin on an outside element — should trigger onFocusOutside
|
|
628
|
+
const outsideBtn = getByTestId("outside");
|
|
629
|
+
fireEvent.focusIn(outsideBtn);
|
|
630
|
+
|
|
631
|
+
expect(onFocusOutside).toHaveBeenCalledTimes(1);
|
|
632
|
+
});
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
describe("pointer event blocking", () => {
|
|
636
|
+
it("restores body.style.pointerEvents after nested blocking layers unmount", async () => {
|
|
637
|
+
const original = document.body.style.pointerEvents;
|
|
638
|
+
|
|
639
|
+
function Harness() {
|
|
640
|
+
const [showBoth, setShowBoth] = React.useState(true);
|
|
641
|
+
|
|
642
|
+
return (
|
|
643
|
+
<>
|
|
644
|
+
{showBoth && (
|
|
645
|
+
<DismissibleBox
|
|
646
|
+
options={{ enabled: true, blockPointerEvents: true }}
|
|
647
|
+
data-testid="outer"
|
|
648
|
+
>
|
|
649
|
+
<DismissibleBox
|
|
650
|
+
options={{ enabled: true, blockPointerEvents: true }}
|
|
651
|
+
data-testid="inner"
|
|
652
|
+
/>
|
|
653
|
+
</DismissibleBox>
|
|
654
|
+
)}
|
|
655
|
+
<button type="button" data-testid="unmount" onClick={() => setShowBoth(false)}>
|
|
656
|
+
Unmount
|
|
657
|
+
</button>
|
|
658
|
+
</>
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const { getByTestId } = render(<Harness />);
|
|
663
|
+
|
|
664
|
+
// Both blocking layers are mounted → body should be "none"
|
|
665
|
+
expect(document.body.style.pointerEvents).toBe("none");
|
|
666
|
+
|
|
667
|
+
// Unmount both layers — React cleans up children before parents
|
|
668
|
+
await act(async () => getByTestId("unmount").click());
|
|
669
|
+
|
|
670
|
+
// body.style.pointerEvents must be restored
|
|
671
|
+
expect(document.body.style.pointerEvents).toBe(original);
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
it("restores body.style.pointerEvents when blocking layers mount sequentially then unmount together", async () => {
|
|
675
|
+
const original = document.body.style.pointerEvents;
|
|
676
|
+
|
|
677
|
+
function Harness() {
|
|
678
|
+
const [showFirst, setShowFirst] = React.useState(false);
|
|
679
|
+
const [showSecond, setShowSecond] = React.useState(false);
|
|
680
|
+
|
|
681
|
+
return (
|
|
682
|
+
<>
|
|
683
|
+
{showFirst && (
|
|
684
|
+
<DismissibleBox
|
|
685
|
+
options={{ enabled: true, blockPointerEvents: true }}
|
|
686
|
+
data-testid="first"
|
|
687
|
+
/>
|
|
688
|
+
)}
|
|
689
|
+
{showSecond && (
|
|
690
|
+
<DismissibleBox
|
|
691
|
+
options={{ enabled: true, blockPointerEvents: true }}
|
|
692
|
+
data-testid="second"
|
|
693
|
+
/>
|
|
694
|
+
)}
|
|
695
|
+
<button type="button" data-testid="mount-first" onClick={() => setShowFirst(true)}>
|
|
696
|
+
Mount First
|
|
697
|
+
</button>
|
|
698
|
+
<button type="button" data-testid="mount-second" onClick={() => setShowSecond(true)}>
|
|
699
|
+
Mount Second
|
|
700
|
+
</button>
|
|
701
|
+
<button
|
|
702
|
+
type="button"
|
|
703
|
+
data-testid="unmount-all"
|
|
704
|
+
onClick={() => {
|
|
705
|
+
setShowFirst(false);
|
|
706
|
+
setShowSecond(false);
|
|
707
|
+
}}
|
|
708
|
+
>
|
|
709
|
+
Unmount All
|
|
710
|
+
</button>
|
|
711
|
+
</>
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const { getByTestId } = render(<Harness />);
|
|
716
|
+
|
|
717
|
+
// Mount first blocking layer
|
|
718
|
+
await act(async () => getByTestId("mount-first").click());
|
|
719
|
+
expect(document.body.style.pointerEvents).toBe("none");
|
|
720
|
+
|
|
721
|
+
// Mount second blocking layer
|
|
722
|
+
await act(async () => getByTestId("mount-second").click());
|
|
723
|
+
expect(document.body.style.pointerEvents).toBe("none");
|
|
724
|
+
|
|
725
|
+
// Unmount both at once
|
|
726
|
+
await act(async () => getByTestId("unmount-all").click());
|
|
727
|
+
|
|
728
|
+
// body.style.pointerEvents must be restored
|
|
729
|
+
expect(document.body.style.pointerEvents).toBe(original);
|
|
730
|
+
});
|
|
731
|
+
});
|
|
732
|
+
});
|