@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.
@@ -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
+ });