@sproutsocial/seeds-react-modal 2.2.5 → 2.4.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.
@@ -0,0 +1,972 @@
1
+ /* eslint-disable */
2
+ import React from "react";
3
+ import {
4
+ render,
5
+ fireEvent,
6
+ cleanup,
7
+ screen,
8
+ waitFor,
9
+ renderHook,
10
+ act,
11
+ } from "@sproutsocial/seeds-react-testing-library";
12
+ import userEvent from "@testing-library/user-event";
13
+ import {
14
+ Modal,
15
+ ModalBody,
16
+ useModalExternalTrigger,
17
+ ModalExternalTrigger,
18
+ } from "../../v2";
19
+ import { Button } from "@sproutsocial/seeds-react-button";
20
+
21
+ // Mock matchMedia for mobile detection
22
+ beforeAll(() => {
23
+ Object.defineProperty(window, "matchMedia", {
24
+ writable: true,
25
+ value: jest.fn().mockImplementation((query) => {
26
+ const listeners = new Map();
27
+ return {
28
+ matches: false,
29
+ media: query,
30
+ onchange: null,
31
+ addListener: jest.fn(),
32
+ removeListener: jest.fn(),
33
+ addEventListener: jest.fn((_event, listener) => {
34
+ listeners.set(listener, listener);
35
+ }),
36
+ removeEventListener: jest.fn((_event, listener) => {
37
+ listeners.delete(listener);
38
+ }),
39
+ dispatchEvent: jest.fn(),
40
+ };
41
+ }),
42
+ });
43
+ });
44
+
45
+ afterEach(() => cleanup());
46
+
47
+ describe("Modal V2 - Dialog.Content Event Handlers and Convenience Props", () => {
48
+ describe("onInteractOutside", () => {
49
+ it("should prevent modal from closing when clicking outside if onInteractOutside calls preventDefault", async () => {
50
+ const handleInteractOutside = jest.fn((e) => e.preventDefault());
51
+ const handleOpenChange = jest.fn();
52
+
53
+ render(
54
+ <Modal
55
+ open={true}
56
+ onOpenChange={handleOpenChange}
57
+ onInteractOutside={handleInteractOutside}
58
+ title="Test Modal"
59
+ description="Test description"
60
+ closeButtonAriaLabel="Close"
61
+ >
62
+ <ModalBody>Content</ModalBody>
63
+ </Modal>
64
+ );
65
+
66
+ const overlay = screen.getByTestId("modal-overlay");
67
+ await userEvent.click(overlay);
68
+
69
+ expect(handleInteractOutside).toHaveBeenCalled();
70
+ expect(handleOpenChange).not.toHaveBeenCalled();
71
+ });
72
+
73
+ it("should allow modal to close when clicking outside if onInteractOutside does not prevent default", async () => {
74
+ const handleInteractOutside = jest.fn();
75
+ const handleOpenChange = jest.fn();
76
+
77
+ render(
78
+ <Modal
79
+ open={true}
80
+ onOpenChange={handleOpenChange}
81
+ onInteractOutside={handleInteractOutside}
82
+ title="Test Modal"
83
+ description="Test description"
84
+ closeButtonAriaLabel="Close"
85
+ >
86
+ <ModalBody>Content</ModalBody>
87
+ </Modal>
88
+ );
89
+
90
+ const overlay = screen.getByTestId("modal-overlay");
91
+ await userEvent.click(overlay);
92
+
93
+ expect(handleInteractOutside).toHaveBeenCalled();
94
+ expect(handleOpenChange).toHaveBeenCalledWith(false);
95
+ });
96
+
97
+ it("should close modal by default when clicking outside if no onInteractOutside handler provided", async () => {
98
+ const handleOpenChange = jest.fn();
99
+
100
+ render(
101
+ <Modal
102
+ open={true}
103
+ onOpenChange={handleOpenChange}
104
+ title="Test Modal"
105
+ description="Test description"
106
+ closeButtonAriaLabel="Close"
107
+ >
108
+ <ModalBody>Content</ModalBody>
109
+ </Modal>
110
+ );
111
+
112
+ const overlay = screen.getByTestId("modal-overlay");
113
+ await userEvent.click(overlay);
114
+
115
+ expect(handleOpenChange).toHaveBeenCalledWith(false);
116
+ });
117
+
118
+ it("should not call onInteractOutside when modal is closed", async () => {
119
+ const handleInteractOutside = jest.fn();
120
+
121
+ render(
122
+ <Modal
123
+ open={false}
124
+ onInteractOutside={handleInteractOutside}
125
+ title="Test Modal"
126
+ description="Test description"
127
+ closeButtonAriaLabel="Close"
128
+ >
129
+ <ModalBody>Content</ModalBody>
130
+ </Modal>
131
+ );
132
+
133
+ // Click on document body since modal isn't rendered when closed
134
+ await userEvent.click(document.body);
135
+
136
+ expect(handleInteractOutside).not.toHaveBeenCalled();
137
+ });
138
+ });
139
+
140
+ describe("onEscapeKeyDown", () => {
141
+ it("should prevent modal from closing when pressing Escape if onEscapeKeyDown calls preventDefault", async () => {
142
+ const handleEscapeKeyDown = jest.fn((e) => e.preventDefault());
143
+ const handleOpenChange = jest.fn();
144
+
145
+ render(
146
+ <Modal
147
+ open={true}
148
+ onOpenChange={handleOpenChange}
149
+ onEscapeKeyDown={handleEscapeKeyDown}
150
+ title="Test Modal"
151
+ description="Test description"
152
+ closeButtonAriaLabel="Close"
153
+ >
154
+ <ModalBody>Content</ModalBody>
155
+ </Modal>
156
+ );
157
+
158
+ const dialog = screen.getByRole("dialog");
159
+ fireEvent.keyDown(dialog, { key: "Escape", code: "Escape" });
160
+
161
+ expect(handleEscapeKeyDown).toHaveBeenCalled();
162
+ expect(handleOpenChange).not.toHaveBeenCalled();
163
+ });
164
+
165
+ it("should allow modal to close when pressing Escape if onEscapeKeyDown does not prevent default", async () => {
166
+ const handleEscapeKeyDown = jest.fn();
167
+ const handleOpenChange = jest.fn();
168
+
169
+ render(
170
+ <Modal
171
+ open={true}
172
+ onOpenChange={handleOpenChange}
173
+ onEscapeKeyDown={handleEscapeKeyDown}
174
+ title="Test Modal"
175
+ description="Test description"
176
+ closeButtonAriaLabel="Close"
177
+ >
178
+ <ModalBody>Content</ModalBody>
179
+ </Modal>
180
+ );
181
+
182
+ const dialog = screen.getByRole("dialog");
183
+ fireEvent.keyDown(dialog, { key: "Escape", code: "Escape" });
184
+
185
+ expect(handleEscapeKeyDown).toHaveBeenCalled();
186
+ expect(handleOpenChange).toHaveBeenCalledWith(false);
187
+ });
188
+
189
+ it("should close modal by default when pressing Escape if no onEscapeKeyDown handler provided", async () => {
190
+ const handleOpenChange = jest.fn();
191
+
192
+ render(
193
+ <Modal
194
+ open={true}
195
+ onOpenChange={handleOpenChange}
196
+ title="Test Modal"
197
+ description="Test description"
198
+ closeButtonAriaLabel="Close"
199
+ >
200
+ <ModalBody>Content</ModalBody>
201
+ </Modal>
202
+ );
203
+
204
+ const dialog = screen.getByRole("dialog");
205
+ fireEvent.keyDown(dialog, { key: "Escape", code: "Escape" });
206
+
207
+ expect(handleOpenChange).toHaveBeenCalledWith(false);
208
+ });
209
+
210
+ it("should not call onEscapeKeyDown when modal is closed", async () => {
211
+ const handleEscapeKeyDown = jest.fn();
212
+
213
+ render(
214
+ <Modal
215
+ open={false}
216
+ onEscapeKeyDown={handleEscapeKeyDown}
217
+ title="Test Modal"
218
+ description="Test description"
219
+ closeButtonAriaLabel="Close"
220
+ >
221
+ <ModalBody>Content</ModalBody>
222
+ </Modal>
223
+ );
224
+
225
+ // Press Escape on document body since modal isn't rendered when closed
226
+ fireEvent.keyDown(document.body, { key: "Escape", code: "Escape" });
227
+
228
+ expect(handleEscapeKeyDown).not.toHaveBeenCalled();
229
+ });
230
+ });
231
+
232
+ describe("onPointerDownOutside", () => {
233
+ it("should call onPointerDownOutside when clicking outside modal", async () => {
234
+ const handlePointerDownOutside = jest.fn();
235
+
236
+ render(
237
+ <Modal
238
+ open={true}
239
+ onPointerDownOutside={handlePointerDownOutside}
240
+ title="Test Modal"
241
+ description="Test description"
242
+ closeButtonAriaLabel="Close"
243
+ >
244
+ <ModalBody>Content</ModalBody>
245
+ </Modal>
246
+ );
247
+
248
+ const overlay = screen.getByTestId("modal-overlay");
249
+ await userEvent.click(overlay);
250
+
251
+ expect(handlePointerDownOutside).toHaveBeenCalled();
252
+ });
253
+
254
+ it("should prevent modal from closing if onPointerDownOutside calls preventDefault", async () => {
255
+ const handlePointerDownOutside = jest.fn((e) => e.preventDefault());
256
+ const handleOpenChange = jest.fn();
257
+
258
+ render(
259
+ <Modal
260
+ open={true}
261
+ onOpenChange={handleOpenChange}
262
+ onPointerDownOutside={handlePointerDownOutside}
263
+ title="Test Modal"
264
+ description="Test description"
265
+ closeButtonAriaLabel="Close"
266
+ >
267
+ <ModalBody>Content</ModalBody>
268
+ </Modal>
269
+ );
270
+
271
+ const overlay = screen.getByTestId("modal-overlay");
272
+ await userEvent.click(overlay);
273
+
274
+ expect(handlePointerDownOutside).toHaveBeenCalled();
275
+ expect(handleOpenChange).not.toHaveBeenCalled();
276
+ });
277
+ });
278
+
279
+ describe("onOpenAutoFocus", () => {
280
+ it("should call onOpenAutoFocus when modal opens", async () => {
281
+ const handleOpenAutoFocus = jest.fn((e) => e.preventDefault());
282
+
283
+ const { rerender } = render(
284
+ <Modal
285
+ open={false}
286
+ onOpenAutoFocus={handleOpenAutoFocus}
287
+ title="Test Modal"
288
+ description="Test description"
289
+ closeButtonAriaLabel="Close"
290
+ >
291
+ <ModalBody>Content</ModalBody>
292
+ </Modal>
293
+ );
294
+
295
+ act(() => {
296
+ rerender(
297
+ <Modal
298
+ open={true}
299
+ onOpenAutoFocus={handleOpenAutoFocus}
300
+ title="Test Modal"
301
+ description="Test description"
302
+ closeButtonAriaLabel="Close"
303
+ >
304
+ <ModalBody>Content</ModalBody>
305
+ </Modal>
306
+ );
307
+ });
308
+
309
+ await waitFor(() => {
310
+ expect(handleOpenAutoFocus).toHaveBeenCalled();
311
+ });
312
+ });
313
+ });
314
+
315
+ describe("onCloseAutoFocus", () => {
316
+ it("should call onCloseAutoFocus when modal closes", async () => {
317
+ const handleCloseAutoFocus = jest.fn((e) => e.preventDefault());
318
+
319
+ const { rerender } = render(
320
+ <Modal
321
+ open={true}
322
+ onCloseAutoFocus={handleCloseAutoFocus}
323
+ title="Test Modal"
324
+ description="Test description"
325
+ closeButtonAriaLabel="Close"
326
+ >
327
+ <ModalBody>Content</ModalBody>
328
+ </Modal>
329
+ );
330
+
331
+ act(() => {
332
+ rerender(
333
+ <Modal
334
+ open={false}
335
+ onCloseAutoFocus={handleCloseAutoFocus}
336
+ title="Test Modal"
337
+ description="Test description"
338
+ closeButtonAriaLabel="Close"
339
+ >
340
+ <ModalBody>Content</ModalBody>
341
+ </Modal>
342
+ );
343
+ });
344
+
345
+ await waitFor(() => {
346
+ expect(handleCloseAutoFocus).toHaveBeenCalled();
347
+ });
348
+ });
349
+ });
350
+
351
+ describe("Combined event handlers", () => {
352
+ it("should support multiple event handlers at once", async () => {
353
+ const handleInteractOutside = jest.fn((e) => e.preventDefault());
354
+ const handleEscapeKeyDown = jest.fn((e) => e.preventDefault());
355
+ const handlePointerDownOutside = jest.fn();
356
+ const handleOpenChange = jest.fn();
357
+
358
+ render(
359
+ <Modal
360
+ open={true}
361
+ onOpenChange={handleOpenChange}
362
+ onInteractOutside={handleInteractOutside}
363
+ onEscapeKeyDown={handleEscapeKeyDown}
364
+ onPointerDownOutside={handlePointerDownOutside}
365
+ title="Test Modal"
366
+ description="Test description"
367
+ closeButtonAriaLabel="Close"
368
+ >
369
+ <ModalBody>Content</ModalBody>
370
+ </Modal>
371
+ );
372
+
373
+ // Test Escape key
374
+ const dialog = screen.getByRole("dialog");
375
+ fireEvent.keyDown(dialog, { key: "Escape", code: "Escape" });
376
+
377
+ expect(handleEscapeKeyDown).toHaveBeenCalled();
378
+ expect(handleOpenChange).not.toHaveBeenCalled();
379
+
380
+ // Test outside click
381
+ const overlay = screen.getByTestId("modal-overlay");
382
+ await userEvent.click(overlay);
383
+
384
+ expect(handlePointerDownOutside).toHaveBeenCalled();
385
+ expect(handleInteractOutside).toHaveBeenCalled();
386
+ expect(handleOpenChange).not.toHaveBeenCalled();
387
+ });
388
+ });
389
+
390
+ describe("Draggable modal", () => {
391
+ it("should not support onInteractOutside for draggable modals (TypeScript)", () => {
392
+ expect(true).toBe(true);
393
+ });
394
+
395
+ it("should prevent closing on outside click for draggable modals by default", async () => {
396
+ const handleOpenChange = jest.fn();
397
+
398
+ render(
399
+ <Modal
400
+ open={true}
401
+ draggable={true}
402
+ onOpenChange={handleOpenChange}
403
+ title="Test Modal"
404
+ description="Test description"
405
+ closeButtonAriaLabel="Close"
406
+ >
407
+ <ModalBody>Content</ModalBody>
408
+ </Modal>
409
+ );
410
+
411
+ // For draggable modals, showOverlay is false by default
412
+ // so we need to click outside the modal content itself
413
+ // Click on body outside the dialog
414
+ fireEvent.pointerDown(document.body);
415
+
416
+ // Modal should not close for draggable modals
417
+ expect(handleOpenChange).not.toHaveBeenCalled();
418
+ });
419
+
420
+ it("should support other event handlers for draggable modals", async () => {
421
+ const handleEscapeKeyDown = jest.fn();
422
+ const handleOpenAutoFocus = jest.fn();
423
+
424
+ render(
425
+ <Modal
426
+ open={true}
427
+ draggable={true}
428
+ onEscapeKeyDown={handleEscapeKeyDown}
429
+ onOpenAutoFocus={handleOpenAutoFocus}
430
+ title="Test Modal"
431
+ description="Test description"
432
+ closeButtonAriaLabel="Close"
433
+ >
434
+ <ModalBody>Content</ModalBody>
435
+ </Modal>
436
+ );
437
+
438
+ await waitFor(() => {
439
+ expect(handleOpenAutoFocus).toHaveBeenCalled();
440
+ });
441
+
442
+ const dialog = screen.getByRole("dialog");
443
+ fireEvent.keyDown(dialog, { key: "Escape", code: "Escape" });
444
+
445
+ expect(handleEscapeKeyDown).toHaveBeenCalled();
446
+ });
447
+ });
448
+
449
+ describe("Integration with close button", () => {
450
+ it("should still allow closing via close button even when onInteractOutside prevents outside clicks", async () => {
451
+ const handleInteractOutside = jest.fn((e) => e.preventDefault());
452
+ const handleOpenChange = jest.fn();
453
+
454
+ render(
455
+ <Modal
456
+ open={true}
457
+ onOpenChange={handleOpenChange}
458
+ onInteractOutside={handleInteractOutside}
459
+ title="Test Modal"
460
+ description="Test description"
461
+ closeButtonAriaLabel="Close modal"
462
+ >
463
+ <ModalBody>Content</ModalBody>
464
+ </Modal>
465
+ );
466
+
467
+ const closeButton = screen.getByRole("button", { name: "Close modal" });
468
+ await userEvent.click(closeButton);
469
+
470
+ expect(handleOpenChange).toHaveBeenCalledWith(false);
471
+ });
472
+
473
+ it("should still allow closing via close button even when onEscapeKeyDown prevents Escape key", async () => {
474
+ const handleEscapeKeyDown = jest.fn((e) => e.preventDefault());
475
+ const handleOpenChange = jest.fn();
476
+
477
+ render(
478
+ <Modal
479
+ open={true}
480
+ onOpenChange={handleOpenChange}
481
+ onEscapeKeyDown={handleEscapeKeyDown}
482
+ title="Test Modal"
483
+ description="Test description"
484
+ closeButtonAriaLabel="Close modal"
485
+ >
486
+ <ModalBody>Content</ModalBody>
487
+ </Modal>
488
+ );
489
+
490
+ const closeButton = screen.getByRole("button", { name: "Close modal" });
491
+ await userEvent.click(closeButton);
492
+
493
+ expect(handleOpenChange).toHaveBeenCalledWith(false);
494
+ });
495
+ });
496
+
497
+ describe("Uncontrolled modal", () => {
498
+ it("should work with uncontrolled modal using defaultOpen and event handlers", async () => {
499
+ const handleInteractOutside = jest.fn((e) => e.preventDefault());
500
+
501
+ function UncontrolledModalTest() {
502
+ return (
503
+ <Modal
504
+ defaultOpen={true}
505
+ onInteractOutside={handleInteractOutside}
506
+ modalTrigger={<Button>Open</Button>}
507
+ title="Test Modal"
508
+ description="Test description"
509
+ closeButtonAriaLabel="Close"
510
+ >
511
+ <ModalBody>Content</ModalBody>
512
+ </Modal>
513
+ );
514
+ }
515
+
516
+ render(<UncontrolledModalTest />);
517
+
518
+ const overlay = screen.getByTestId("modal-overlay");
519
+ await userEvent.click(overlay);
520
+
521
+ expect(handleInteractOutside).toHaveBeenCalled();
522
+
523
+ // Modal should still be open since we prevented default
524
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
525
+ });
526
+ });
527
+
528
+ describe("Convenience boolean props", () => {
529
+ describe("disableOutsideClickClose", () => {
530
+ it("should prevent modal from closing when clicking outside", async () => {
531
+ const handleOpenChange = jest.fn();
532
+
533
+ render(
534
+ <Modal
535
+ open={true}
536
+ onOpenChange={handleOpenChange}
537
+ disableOutsideClickClose
538
+ title="Test Modal"
539
+ description="Test description"
540
+ closeButtonAriaLabel="Close"
541
+ >
542
+ <ModalBody>Content</ModalBody>
543
+ </Modal>
544
+ );
545
+
546
+ const overlay = screen.getByTestId("modal-overlay");
547
+ await userEvent.click(overlay);
548
+
549
+ expect(handleOpenChange).not.toHaveBeenCalled();
550
+ });
551
+
552
+ it("should still call custom onInteractOutside handler when disableOutsideClickClose is true", async () => {
553
+ const handleInteractOutside = jest.fn();
554
+ const handleOpenChange = jest.fn();
555
+
556
+ render(
557
+ <Modal
558
+ open={true}
559
+ onOpenChange={handleOpenChange}
560
+ disableOutsideClickClose
561
+ onInteractOutside={handleInteractOutside}
562
+ title="Test Modal"
563
+ description="Test description"
564
+ closeButtonAriaLabel="Close"
565
+ >
566
+ <ModalBody>Content</ModalBody>
567
+ </Modal>
568
+ );
569
+
570
+ const overlay = screen.getByTestId("modal-overlay");
571
+ await userEvent.click(overlay);
572
+
573
+ expect(handleInteractOutside).toHaveBeenCalled();
574
+ expect(handleOpenChange).not.toHaveBeenCalled();
575
+ });
576
+
577
+ it("should allow closing via close button even when disableOutsideClickClose is true", async () => {
578
+ const handleOpenChange = jest.fn();
579
+
580
+ render(
581
+ <Modal
582
+ open={true}
583
+ onOpenChange={handleOpenChange}
584
+ disableOutsideClickClose
585
+ title="Test Modal"
586
+ description="Test description"
587
+ closeButtonAriaLabel="Close modal"
588
+ >
589
+ <ModalBody>Content</ModalBody>
590
+ </Modal>
591
+ );
592
+
593
+ const closeButton = screen.getByRole("button", { name: "Close modal" });
594
+ await userEvent.click(closeButton);
595
+
596
+ expect(handleOpenChange).toHaveBeenCalledWith(false);
597
+ });
598
+ });
599
+
600
+ describe("disableEscapeKeyClose", () => {
601
+ it("should prevent modal from closing when pressing Escape", async () => {
602
+ const handleOpenChange = jest.fn();
603
+
604
+ render(
605
+ <Modal
606
+ open={true}
607
+ onOpenChange={handleOpenChange}
608
+ disableEscapeKeyClose
609
+ title="Test Modal"
610
+ description="Test description"
611
+ closeButtonAriaLabel="Close"
612
+ >
613
+ <ModalBody>Content</ModalBody>
614
+ </Modal>
615
+ );
616
+
617
+ const dialog = screen.getByRole("dialog");
618
+ fireEvent.keyDown(dialog, { key: "Escape", code: "Escape" });
619
+
620
+ expect(handleOpenChange).not.toHaveBeenCalled();
621
+ });
622
+
623
+ it("should still call custom onEscapeKeyDown handler when disableEscapeKeyClose is true", async () => {
624
+ const handleEscapeKeyDown = jest.fn();
625
+ const handleOpenChange = jest.fn();
626
+
627
+ render(
628
+ <Modal
629
+ open={true}
630
+ onOpenChange={handleOpenChange}
631
+ disableEscapeKeyClose
632
+ onEscapeKeyDown={handleEscapeKeyDown}
633
+ title="Test Modal"
634
+ description="Test description"
635
+ closeButtonAriaLabel="Close"
636
+ >
637
+ <ModalBody>Content</ModalBody>
638
+ </Modal>
639
+ );
640
+
641
+ const dialog = screen.getByRole("dialog");
642
+ fireEvent.keyDown(dialog, { key: "Escape", code: "Escape" });
643
+
644
+ expect(handleEscapeKeyDown).toHaveBeenCalled();
645
+ expect(handleOpenChange).not.toHaveBeenCalled();
646
+ });
647
+
648
+ it("should allow closing via close button even when disableEscapeKeyClose is true", async () => {
649
+ const handleOpenChange = jest.fn();
650
+
651
+ render(
652
+ <Modal
653
+ open={true}
654
+ onOpenChange={handleOpenChange}
655
+ disableEscapeKeyClose
656
+ title="Test Modal"
657
+ closeButtonAriaLabel="Close modal"
658
+ >
659
+ <ModalBody>Content</ModalBody>
660
+ </Modal>
661
+ );
662
+
663
+ const closeButton = screen.getByRole("button", { name: "Close modal" });
664
+ await userEvent.click(closeButton);
665
+
666
+ expect(handleOpenChange).toHaveBeenCalledWith(false);
667
+ });
668
+ });
669
+
670
+ describe("Combined convenience props", () => {
671
+ it("should prevent closing via both outside click and Escape when both props are true", async () => {
672
+ const handleOpenChange = jest.fn();
673
+
674
+ render(
675
+ <Modal
676
+ open={true}
677
+ onOpenChange={handleOpenChange}
678
+ disableOutsideClickClose
679
+ disableEscapeKeyClose
680
+ title="Test Modal"
681
+ description="Test description"
682
+ closeButtonAriaLabel="Close"
683
+ >
684
+ <ModalBody>Content</ModalBody>
685
+ </Modal>
686
+ );
687
+
688
+ // Try to close via outside click
689
+ const overlay = screen.getByTestId("modal-overlay");
690
+ await userEvent.click(overlay);
691
+ expect(handleOpenChange).not.toHaveBeenCalled();
692
+
693
+ // Try to close via Escape key
694
+ const dialog = screen.getByRole("dialog");
695
+ fireEvent.keyDown(dialog, { key: "Escape", code: "Escape" });
696
+ expect(handleOpenChange).not.toHaveBeenCalled();
697
+ });
698
+
699
+ it("should call both custom handlers when both convenience props and handlers are provided", async () => {
700
+ const handleInteractOutside = jest.fn();
701
+ const handleEscapeKeyDown = jest.fn();
702
+ const handleOpenChange = jest.fn();
703
+
704
+ render(
705
+ <Modal
706
+ open={true}
707
+ onOpenChange={handleOpenChange}
708
+ disableOutsideClickClose
709
+ disableEscapeKeyClose
710
+ onInteractOutside={handleInteractOutside}
711
+ onEscapeKeyDown={handleEscapeKeyDown}
712
+ title="Test Modal"
713
+ description="Test description"
714
+ closeButtonAriaLabel="Close"
715
+ >
716
+ <ModalBody>Content</ModalBody>
717
+ </Modal>
718
+ );
719
+
720
+ // Click outside
721
+ const overlay = screen.getByTestId("modal-overlay");
722
+ await userEvent.click(overlay);
723
+ expect(handleInteractOutside).toHaveBeenCalled();
724
+ expect(handleOpenChange).not.toHaveBeenCalled();
725
+
726
+ // Press Escape
727
+ const dialog = screen.getByRole("dialog");
728
+ fireEvent.keyDown(dialog, { key: "Escape", code: "Escape" });
729
+ expect(handleEscapeKeyDown).toHaveBeenCalled();
730
+ expect(handleOpenChange).not.toHaveBeenCalled();
731
+ });
732
+ });
733
+
734
+ describe("Convenience props with draggable modals", () => {
735
+ it("should not support disableOutsideClickClose for draggable modals (TypeScript)", () => {
736
+ expect(true).toBe(true);
737
+ });
738
+
739
+ it("should support disableEscapeKeyClose for draggable modals", async () => {
740
+ const handleOpenChange = jest.fn();
741
+
742
+ render(
743
+ <Modal
744
+ open={true}
745
+ draggable={true}
746
+ onOpenChange={handleOpenChange}
747
+ disableEscapeKeyClose
748
+ title="Test Modal"
749
+ description="Test description"
750
+ closeButtonAriaLabel="Close"
751
+ >
752
+ <ModalBody>Content</ModalBody>
753
+ </Modal>
754
+ );
755
+
756
+ const dialog = screen.getByRole("dialog");
757
+ fireEvent.keyDown(dialog, { key: "Escape", code: "Escape" });
758
+
759
+ expect(handleOpenChange).not.toHaveBeenCalled();
760
+ });
761
+ });
762
+ });
763
+ });
764
+
765
+ describe("useModalExternalTrigger hook", () => {
766
+ it("should return triggerRef, triggerProps function, and onCloseAutoFocus callback", () => {
767
+ const { result } = renderHook(() => useModalExternalTrigger());
768
+
769
+ expect(result.current.triggerRef).toBeDefined();
770
+ expect(result.current.triggerRef.current).toBeNull();
771
+ expect(typeof result.current.triggerProps).toBe("function");
772
+ expect(typeof result.current.onCloseAutoFocus).toBe("function");
773
+ });
774
+
775
+ it("should return correct ARIA props based on isOpen state", () => {
776
+ const { result } = renderHook(() => useModalExternalTrigger());
777
+
778
+ const propsWhenClosed = result.current.triggerProps(false);
779
+ expect(propsWhenClosed).toEqual({
780
+ "aria-haspopup": "dialog",
781
+ "aria-expanded": false,
782
+ });
783
+
784
+ const propsWhenOpen = result.current.triggerProps(true);
785
+ expect(propsWhenOpen).toEqual({
786
+ "aria-haspopup": "dialog",
787
+ "aria-expanded": true,
788
+ });
789
+ });
790
+
791
+ it("should include aria-controls when modalId is provided", () => {
792
+ const { result } = renderHook(() => useModalExternalTrigger("test-modal"));
793
+
794
+ const props = result.current.triggerProps(false);
795
+ expect(props).toEqual({
796
+ "aria-haspopup": "dialog",
797
+ "aria-expanded": false,
798
+ "aria-controls": "test-modal",
799
+ });
800
+ });
801
+
802
+ it("should prevent default and focus trigger on onCloseAutoFocus", () => {
803
+ const { result } = renderHook(() => useModalExternalTrigger());
804
+
805
+ const mockElement = { focus: jest.fn() };
806
+ // @ts-ignore - setting current for test
807
+ result.current.triggerRef.current = mockElement as any;
808
+
809
+ const mockEvent = { preventDefault: jest.fn() } as any;
810
+ result.current.onCloseAutoFocus(mockEvent);
811
+
812
+ expect(mockEvent.preventDefault).toHaveBeenCalled();
813
+ expect(mockElement.focus).toHaveBeenCalled();
814
+ });
815
+
816
+ it("should not throw if triggerRef.current is null", () => {
817
+ const { result } = renderHook(() => useModalExternalTrigger());
818
+
819
+ const mockEvent = { preventDefault: jest.fn() } as any;
820
+
821
+ expect(() => {
822
+ result.current.onCloseAutoFocus(mockEvent);
823
+ }).not.toThrow();
824
+
825
+ expect(mockEvent.preventDefault).toHaveBeenCalled();
826
+ });
827
+ });
828
+
829
+ describe("ModalExternalTrigger component", () => {
830
+ it("should render with Button and display children", () => {
831
+ render(
832
+ <ModalExternalTrigger isOpen={false} onTrigger={jest.fn()}>
833
+ Open Modal
834
+ </ModalExternalTrigger>
835
+ );
836
+
837
+ const button = screen.getByRole("button");
838
+ expect(button).toBeInTheDocument();
839
+ expect(button).toHaveTextContent("Open Modal");
840
+ });
841
+
842
+ it("should have correct ARIA attributes when closed", () => {
843
+ render(
844
+ <ModalExternalTrigger isOpen={false} onTrigger={jest.fn()}>
845
+ Open Modal
846
+ </ModalExternalTrigger>
847
+ );
848
+
849
+ const button = screen.getByRole("button");
850
+ expect(button).toHaveAttribute("aria-haspopup", "dialog");
851
+ expect(button).toHaveAttribute("aria-expanded", "false");
852
+ });
853
+
854
+ it("should have correct ARIA attributes when open", () => {
855
+ render(
856
+ <ModalExternalTrigger isOpen={true} onTrigger={jest.fn()}>
857
+ Open Modal
858
+ </ModalExternalTrigger>
859
+ );
860
+
861
+ const button = screen.getByRole("button");
862
+ expect(button).toHaveAttribute("aria-haspopup", "dialog");
863
+ expect(button).toHaveAttribute("aria-expanded", "true");
864
+ });
865
+
866
+ it("should include aria-controls when modalId is provided", () => {
867
+ render(
868
+ <ModalExternalTrigger
869
+ isOpen={false}
870
+ onTrigger={jest.fn()}
871
+ modalId="test-modal"
872
+ >
873
+ Open Modal
874
+ </ModalExternalTrigger>
875
+ );
876
+
877
+ const button = screen.getByRole("button");
878
+ expect(button).toHaveAttribute("aria-controls", "test-modal");
879
+ });
880
+
881
+ it("should call onTrigger when clicked", async () => {
882
+ const onTrigger = jest.fn();
883
+
884
+ render(
885
+ <ModalExternalTrigger isOpen={false} onTrigger={onTrigger}>
886
+ Open Modal
887
+ </ModalExternalTrigger>
888
+ );
889
+
890
+ const button = screen.getByRole("button");
891
+ await userEvent.click(button);
892
+
893
+ expect(onTrigger).toHaveBeenCalledTimes(1);
894
+ });
895
+
896
+ it("should call custom onClick before onTrigger", async () => {
897
+ const onClick = jest.fn();
898
+ const onTrigger = jest.fn();
899
+
900
+ render(
901
+ <ModalExternalTrigger
902
+ isOpen={false}
903
+ onTrigger={onTrigger}
904
+ onClick={onClick}
905
+ >
906
+ Open Modal
907
+ </ModalExternalTrigger>
908
+ );
909
+
910
+ const button = screen.getByRole("button");
911
+ await userEvent.click(button);
912
+
913
+ expect(onClick).toHaveBeenCalled();
914
+ expect(onTrigger).toHaveBeenCalled();
915
+ // onClick should be called before onTrigger
916
+ expect(onClick.mock.invocationCallOrder[0]!).toBeLessThan(
917
+ onTrigger.mock.invocationCallOrder[0]!
918
+ );
919
+ });
920
+
921
+ it("should not call onTrigger if onClick prevents default", async () => {
922
+ const onClick = jest.fn((e) => e.preventDefault());
923
+ const onTrigger = jest.fn();
924
+
925
+ render(
926
+ <ModalExternalTrigger
927
+ isOpen={false}
928
+ onTrigger={onTrigger}
929
+ onClick={onClick}
930
+ >
931
+ Open Modal
932
+ </ModalExternalTrigger>
933
+ );
934
+
935
+ const button = screen.getByRole("button");
936
+ await userEvent.click(button);
937
+
938
+ expect(onClick).toHaveBeenCalled();
939
+ expect(onTrigger).not.toHaveBeenCalled();
940
+ });
941
+
942
+ it("should forward ref correctly", () => {
943
+ const ref = React.createRef<HTMLButtonElement>();
944
+
945
+ render(
946
+ <ModalExternalTrigger ref={ref} isOpen={false} onTrigger={jest.fn()}>
947
+ Open Modal
948
+ </ModalExternalTrigger>
949
+ );
950
+
951
+ expect(ref.current).toBeInstanceOf(HTMLButtonElement);
952
+ });
953
+
954
+ it("should support all Button props", () => {
955
+ render(
956
+ <ModalExternalTrigger
957
+ isOpen={false}
958
+ onTrigger={jest.fn()}
959
+ appearance="primary"
960
+ size="large"
961
+ disabled={true}
962
+ data-testid="test-button"
963
+ >
964
+ Open Modal
965
+ </ModalExternalTrigger>
966
+ );
967
+
968
+ const button = screen.getByTestId("test-button");
969
+ expect(button).toBeInTheDocument();
970
+ expect(button).toHaveAttribute("disabled");
971
+ });
972
+ });