@khanacademy/wonder-blocks-popover 3.0.22 → 3.1.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.
@@ -1,31 +1,19 @@
1
1
  import * as React from "react";
2
- import * as ReactDOM from "react-dom";
3
2
  import {render, screen} from "@testing-library/react";
4
3
  import userEvent from "@testing-library/user-event";
5
4
 
6
5
  import FocusManager from "../focus-manager";
7
- import {findFocusableNodes} from "../../util/util";
8
6
 
9
7
  describe("FocusManager", () => {
10
8
  it("should focus on the first focusable element inside the popover", async () => {
11
9
  // Arrange
12
- const ref = await new Promise((resolve: any) => {
13
- const nodes = (
14
- <div ref={resolve}>
15
- <button>Open popover</button>
16
- <button>Next focusable element outside</button>
17
- </div>
18
- );
19
- render(nodes);
20
- });
21
- // @ts-expect-error [FEI-5019] - TS2345 - Argument of type 'unknown' is not assignable to parameter of type 'ReactInstance | null | undefined'.
22
- const domNode = ReactDOM.findDOMNode(ref) as HTMLElement;
23
-
24
- // mock focusable elements in document
25
- // eslint-disable-next-line testing-library/no-node-access
26
- global.document.querySelectorAll = jest
27
- .fn()
28
- .mockImplementation(() => findFocusableNodes(domNode));
10
+ const externalNodes = (
11
+ <div>
12
+ <button>Open popover</button>
13
+ <button>Next focusable element outside</button>
14
+ </div>
15
+ );
16
+ render(externalNodes);
29
17
 
30
18
  // get the anchor reference to be able pass it to the FocusManager
31
19
  const anchorElementNode = screen.getByRole("button", {
@@ -58,23 +46,13 @@ describe("FocusManager", () => {
58
46
 
59
47
  it("should focus on the last focusable element inside the popover", async () => {
60
48
  // Arrange
61
- const ref = await new Promise((resolve: any) => {
62
- const nodes = (
63
- <div ref={resolve}>
64
- <button>Open popover</button>
65
- <button>Next focusable element outside</button>
66
- </div>
67
- );
68
- render(nodes);
69
- });
70
- // @ts-expect-error [FEI-5019] - TS2345 - Argument of type 'unknown' is not assignable to parameter of type 'ReactInstance | null | undefined'.
71
- const domNode = ReactDOM.findDOMNode(ref) as HTMLElement;
72
-
73
- // mock focusable elements in document
74
- // eslint-disable-next-line testing-library/no-node-access
75
- global.document.querySelectorAll = jest
76
- .fn()
77
- .mockImplementation(() => findFocusableNodes(domNode));
49
+ const externalNodes = (
50
+ <div>
51
+ <button>Open popover</button>
52
+ <button>Next focusable element outside</button>
53
+ </div>
54
+ );
55
+ render(externalNodes);
78
56
 
79
57
  // get the anchor reference to be able pass it to the FocusManager
80
58
  const anchorElementNode = screen.getByRole("button", {
@@ -109,4 +87,105 @@ describe("FocusManager", () => {
109
87
  // Assert
110
88
  expect(lastFocusableElementInside).toHaveFocus();
111
89
  });
90
+
91
+ it("should allow flowing the focus correctly", async () => {
92
+ // Arrange
93
+ const externalNodes = (
94
+ <div>
95
+ <button>Prev focusable element outside</button>
96
+ <button>Open popover</button>
97
+ <button>Next focusable element outside</button>
98
+ </div>
99
+ );
100
+ render(externalNodes);
101
+
102
+ // get the anchor reference to be able pass it to the FocusManager
103
+ const anchorElementNode = screen.getByRole("button", {
104
+ name: "Open popover",
105
+ });
106
+
107
+ render(
108
+ <FocusManager anchorElement={anchorElementNode}>
109
+ <div>
110
+ <button>first focusable element inside</button>
111
+ </div>
112
+ </FocusManager>,
113
+ );
114
+
115
+ // Act
116
+ // 1. focus on the previous element before the popover
117
+ userEvent.tab();
118
+
119
+ // 2. focus on the anchor element
120
+ userEvent.tab();
121
+
122
+ // 3. focus on focusable element inside the popover
123
+ userEvent.tab();
124
+
125
+ // 4. focus on the next focusable element outside the popover (this will
126
+ // be the first focusable element outside the popover)
127
+ userEvent.tab();
128
+
129
+ // NOTE: At this point, the focus moves to the document body, so we need
130
+ // to press tab again to move the focus to the next focusable element.
131
+ userEvent.tab();
132
+
133
+ // 5. Finally focus on the first element in the document
134
+ userEvent.tab();
135
+
136
+ // find previous focusable element outside the popover
137
+ const prevFocusableElementOutside = screen.getByRole("button", {
138
+ name: "Prev focusable element outside",
139
+ });
140
+
141
+ // Assert
142
+ expect(prevFocusableElementOutside).toHaveFocus();
143
+ });
144
+
145
+ it("should disallow focusability on internal elements if the user focus out of the focus manager", async () => {
146
+ // Arrange
147
+ const externalNodes = (
148
+ <div>
149
+ <button>Prev focusable element outside</button>
150
+ <button>Open popover</button>
151
+ <button>Next focusable element outside</button>
152
+ </div>
153
+ );
154
+ render(externalNodes);
155
+
156
+ // get the anchor reference to be able pass it to the FocusManager
157
+ const anchorElementNode = screen.getByRole("button", {
158
+ name: "Open popover",
159
+ });
160
+
161
+ render(
162
+ <FocusManager anchorElement={anchorElementNode}>
163
+ <div>
164
+ <button>first focusable element inside</button>
165
+ </div>
166
+ </FocusManager>,
167
+ );
168
+
169
+ // Act
170
+ // 1. focus on the previous element before the popover
171
+ userEvent.tab();
172
+
173
+ // 2. focus on the anchor element
174
+ userEvent.tab();
175
+
176
+ // 3. focus on focusable element inside the popover
177
+ userEvent.tab();
178
+
179
+ // 4. focus on the next focusable element outside the popover (this will
180
+ // be the first focusable element outside the popover)
181
+ userEvent.tab();
182
+
183
+ // The elements inside the focus manager should not be focusable anymore.
184
+ const focusableElementInside = screen.getByRole("button", {
185
+ name: "first focusable element inside",
186
+ });
187
+
188
+ // Assert
189
+ expect(focusableElementInside).toHaveAttribute("tabIndex", "-1");
190
+ });
112
191
  });
@@ -153,39 +153,6 @@ describe("Popover", () => {
153
153
  expect(onCloseMock).toBeCalled();
154
154
  });
155
155
 
156
- it("should close the Popover if dismissEnabled is set", async () => {
157
- // Arrange
158
- render(
159
- <Popover
160
- dismissEnabled={true}
161
- placement="top"
162
- content={<PopoverContent title="Title" content="content" />}
163
- >
164
- {({open}: any) => (
165
- <button data-anchor onClick={open}>
166
- Open default popover
167
- </button>
168
- )}
169
- </Popover>,
170
- );
171
-
172
- // open the popover
173
- userEvent.click(
174
- screen.getByRole("button", {name: "Open default popover"}),
175
- );
176
-
177
- // Act
178
- // we try to close it using the same trigger element
179
- userEvent.click(
180
- screen.getByRole("button", {name: "Open default popover"}),
181
- );
182
-
183
- // Assert
184
- await waitFor(() => {
185
- expect(screen.queryByText("Title")).not.toBeInTheDocument();
186
- });
187
- });
188
-
189
156
  it("should shift-tab back to the anchor after popover is closed", async () => {
190
157
  // Arrange
191
158
  const PopoverComponent = () => {
@@ -229,8 +196,16 @@ describe("Popover", () => {
229
196
  name: "Click to close the popover",
230
197
  });
231
198
  closeButton.click();
232
- // Shift-tab over to the anchor button
199
+
200
+ // At this point, the focus returns to the anchor element
201
+
202
+ // Shift-tab over to the document body
233
203
  userEvent.tab({shift: true});
204
+
205
+ // Shift-tab over to the outside button
206
+ userEvent.tab({shift: true});
207
+
208
+ // Shift-tab over to the anchor element
234
209
  userEvent.tab({shift: true});
235
210
 
236
211
  // Assert
@@ -288,6 +263,226 @@ describe("Popover", () => {
288
263
  expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
289
264
  });
290
265
 
266
+ describe("return focus", () => {
267
+ it("should return focus to the trigger element by default", async () => {
268
+ // Arrange
269
+ render(
270
+ <Popover
271
+ dismissEnabled={true}
272
+ content={
273
+ <PopoverContent
274
+ closeButtonVisible={true}
275
+ title="Returning focus to a specific element"
276
+ content='After dismissing the popover, the focus will be set on the button labeled "Focus here after close."'
277
+ />
278
+ }
279
+ >
280
+ <Button>Open popover</Button>
281
+ </Popover>,
282
+ );
283
+
284
+ const anchorButton = screen.getByRole("button", {
285
+ name: "Open popover",
286
+ });
287
+
288
+ // open the popover
289
+ userEvent.click(anchorButton);
290
+ await screen.findByRole("dialog");
291
+
292
+ // Act
293
+ const closeButton = screen.getByRole("button", {
294
+ name: "Close Popover",
295
+ });
296
+ closeButton.click();
297
+
298
+ // Assert
299
+ expect(anchorButton).toHaveFocus();
300
+ });
301
+
302
+ it("should return focus to a specific element if closedFocusId is set", async () => {
303
+ // Arrange
304
+ render(
305
+ <View>
306
+ <Button id="button-to-focus-on">
307
+ Focus here after close
308
+ </Button>
309
+ <Popover
310
+ closedFocusId="button-to-focus-on"
311
+ dismissEnabled={true}
312
+ content={
313
+ <PopoverContent
314
+ closeButtonVisible={true}
315
+ title="Returning focus to a specific element"
316
+ content='After dismissing the popover, the focus will be set on the button labeled "Focus here after close."'
317
+ />
318
+ }
319
+ >
320
+ <Button>Open popover</Button>
321
+ </Popover>
322
+ </View>,
323
+ );
324
+
325
+ const anchorButton = screen.getByRole("button", {
326
+ name: "Open popover",
327
+ });
328
+
329
+ // open the popover
330
+ userEvent.click(anchorButton);
331
+ await screen.findByRole("dialog");
332
+
333
+ // Act
334
+ const closeButton = screen.getByRole("button", {
335
+ name: "Close Popover",
336
+ });
337
+ closeButton.click();
338
+
339
+ // Assert
340
+ const buttonToFocusOn = screen.getByRole("button", {
341
+ name: "Focus here after close",
342
+ });
343
+ expect(buttonToFocusOn).toHaveFocus();
344
+ });
345
+ });
346
+
347
+ describe("dismissEnabled", () => {
348
+ it("should close the Popover if dismissEnabled is set", async () => {
349
+ // Arrange
350
+ render(
351
+ <Popover
352
+ dismissEnabled={true}
353
+ placement="top"
354
+ content={<PopoverContent title="Title" content="content" />}
355
+ >
356
+ {({open}: any) => (
357
+ <button data-anchor onClick={open}>
358
+ Open default popover
359
+ </button>
360
+ )}
361
+ </Popover>,
362
+ );
363
+
364
+ // open the popover
365
+ userEvent.click(
366
+ screen.getByRole("button", {name: "Open default popover"}),
367
+ );
368
+
369
+ // Act
370
+ // we try to close it using the same trigger element
371
+ userEvent.click(
372
+ screen.getByRole("button", {name: "Open default popover"}),
373
+ );
374
+
375
+ // Assert
376
+ await waitFor(() => {
377
+ expect(screen.queryByText("Title")).not.toBeInTheDocument();
378
+ });
379
+ });
380
+
381
+ it("should return focus to the anchor element when pressing Esc", async () => {
382
+ // Arrange
383
+ render(
384
+ <Popover
385
+ dismissEnabled={true}
386
+ placement="top"
387
+ content={<PopoverContent title="Title" content="content" />}
388
+ >
389
+ {({open}: any) => (
390
+ <button data-anchor onClick={open}>
391
+ Open default popover
392
+ </button>
393
+ )}
394
+ </Popover>,
395
+ );
396
+
397
+ // open the popover
398
+ userEvent.click(
399
+ screen.getByRole("button", {name: "Open default popover"}),
400
+ );
401
+
402
+ // Act
403
+ // we try to close it pressing the Escape key
404
+ userEvent.keyboard("{esc}");
405
+
406
+ // Assert
407
+ expect(
408
+ screen.getByRole("button", {name: "Open default popover"}),
409
+ ).toHaveFocus();
410
+ });
411
+
412
+ it("should return focus to the anchor element when clicking outside", async () => {
413
+ // Arrange
414
+ const {container} = render(
415
+ <Popover
416
+ dismissEnabled={true}
417
+ placement="top"
418
+ content={<PopoverContent title="Title" content="content" />}
419
+ >
420
+ {({open}: any) => (
421
+ <button data-anchor onClick={open}>
422
+ Open default popover
423
+ </button>
424
+ )}
425
+ </Popover>,
426
+ );
427
+
428
+ // open the popover
429
+ userEvent.click(
430
+ screen.getByRole("button", {name: "Open default popover"}),
431
+ );
432
+
433
+ // Act
434
+ // we try to close it clicking outside the popover
435
+ userEvent.click(container);
436
+ // NOTE: We need to click twice because the first click is handled
437
+ // by the trigger element.
438
+ userEvent.click(container);
439
+
440
+ // Assert
441
+ expect(
442
+ screen.getByRole("button", {name: "Open default popover"}),
443
+ ).toHaveFocus();
444
+ });
445
+
446
+ it("should NOT return focus to the anchor element when clicking on an interactive element", async () => {
447
+ // Arrange
448
+ render(
449
+ <View>
450
+ <Popover
451
+ dismissEnabled={true}
452
+ placement="top"
453
+ content={
454
+ <PopoverContent title="Title" content="content" />
455
+ }
456
+ >
457
+ {({open}: any) => (
458
+ <button data-anchor onClick={open}>
459
+ Open default popover
460
+ </button>
461
+ )}
462
+ </Popover>
463
+ <Button>Next button outside</Button>
464
+ </View>,
465
+ );
466
+
467
+ // open the popover
468
+ userEvent.click(
469
+ screen.getByRole("button", {name: "Open default popover"}),
470
+ );
471
+
472
+ // Act
473
+ // we try to close it clicking outside the popover
474
+ userEvent.click(
475
+ screen.getByRole("button", {name: "Next button outside"}),
476
+ );
477
+
478
+ // Assert
479
+ // The focus should remain on the button outside the popover
480
+ expect(
481
+ screen.getByRole("button", {name: "Next button outside"}),
482
+ ).toHaveFocus();
483
+ });
484
+ });
485
+
291
486
  describe("a11y", () => {
292
487
  it("should announce a popover correctly by reading the title contents", async () => {
293
488
  // Arrange
@@ -356,4 +551,196 @@ describe("Popover", () => {
356
551
  ).toBeInTheDocument();
357
552
  });
358
553
  });
554
+
555
+ describe("keyboard navigation", () => {
556
+ it("should move focus to the first focusable element after popover is open", async () => {
557
+ // Arrange
558
+ render(
559
+ <>
560
+ <Button>Prev focusable element outside</Button>
561
+ <Popover
562
+ onClose={jest.fn()}
563
+ content={
564
+ <PopoverContent
565
+ title="Popover title"
566
+ content="content"
567
+ actions={
568
+ <>
569
+ <Button>Button 1 inside popover</Button>
570
+ <Button>Button 2 inside popover</Button>
571
+ </>
572
+ }
573
+ />
574
+ }
575
+ >
576
+ <Button>Open default popover</Button>
577
+ </Popover>
578
+ <Button>Next focusable element outside</Button>
579
+ </>,
580
+ );
581
+
582
+ // Focus on the first element outside the popover
583
+ userEvent.tab();
584
+ // open the popover by focusing on the trigger element
585
+ userEvent.tab();
586
+ userEvent.keyboard("{enter}");
587
+
588
+ // Act
589
+ // Wait for the popover to be open.
590
+ await screen.findByRole("dialog");
591
+
592
+ // Assert
593
+ // Focus should move to the first button inside the popover
594
+ expect(
595
+ screen.getByRole("button", {
596
+ name: "Button 1 inside popover",
597
+ }),
598
+ ).toHaveFocus();
599
+ });
600
+
601
+ it("should allow flowing focus correctly even if the popover remains open", async () => {
602
+ // Arrange
603
+ render(
604
+ <>
605
+ <Button>Prev focusable element outside</Button>
606
+ <Popover
607
+ onClose={jest.fn()}
608
+ content={
609
+ <PopoverContent
610
+ title="Popover title"
611
+ content="content"
612
+ actions={<Button>Button inside popover</Button>}
613
+ />
614
+ }
615
+ >
616
+ <Button>Open default popover</Button>
617
+ </Popover>
618
+ <Button>Next focusable element outside</Button>
619
+ </>,
620
+ );
621
+
622
+ // Focus on the first element outside the popover
623
+ userEvent.tab();
624
+ // open the popover by focusing on the trigger element
625
+ userEvent.tab();
626
+ userEvent.keyboard("{enter}");
627
+
628
+ // Wait for the popover to be open.
629
+ await screen.findByRole("dialog");
630
+
631
+ // Act
632
+ // Focus on the next element after the popover
633
+ userEvent.tab();
634
+
635
+ // Assert
636
+ expect(
637
+ screen.getByRole("button", {
638
+ name: "Next focusable element outside",
639
+ }),
640
+ ).toHaveFocus();
641
+ });
642
+
643
+ it("should allow circular navigation when the popover is open", async () => {
644
+ // Arrange
645
+ render(
646
+ <>
647
+ <Button>Prev focusable element outside</Button>
648
+ <Popover
649
+ onClose={jest.fn()}
650
+ content={
651
+ <PopoverContent
652
+ title="Popover title"
653
+ content="content"
654
+ actions={<Button>Button inside popover</Button>}
655
+ />
656
+ }
657
+ >
658
+ <Button>Open default popover</Button>
659
+ </Popover>
660
+ <Button>Next focusable element outside</Button>
661
+ </>,
662
+ );
663
+
664
+ // Focus on the first element outside the popover
665
+ userEvent.tab();
666
+ // open the popover by focusing on the trigger element
667
+ userEvent.tab();
668
+ userEvent.keyboard("{enter}");
669
+
670
+ // Wait for the popover to be open.
671
+ await screen.findByRole("dialog");
672
+
673
+ // Focus on the next element after the popover
674
+ userEvent.tab();
675
+
676
+ // Focus on the document body
677
+ userEvent.tab();
678
+
679
+ // Act
680
+ // Focus again on the first element in the document.
681
+ userEvent.tab();
682
+
683
+ // Assert
684
+ expect(
685
+ screen.getByRole("button", {
686
+ name: "Prev focusable element outside",
687
+ }),
688
+ ).toHaveFocus();
689
+ });
690
+
691
+ it("should allow navigating backwards when the popover is open", async () => {
692
+ // Arrange
693
+ render(
694
+ <>
695
+ <Button>Prev focusable element outside</Button>
696
+ <Popover
697
+ onClose={jest.fn()}
698
+ content={
699
+ <PopoverContent
700
+ title="Popover title"
701
+ content="content"
702
+ actions={<Button>Button inside popover</Button>}
703
+ />
704
+ }
705
+ >
706
+ <Button>Open default popover</Button>
707
+ </Popover>
708
+ <Button>Next focusable element outside</Button>
709
+ </>,
710
+ );
711
+
712
+ // Open the popover
713
+ userEvent.click(
714
+ screen.getByRole("button", {name: "Open default popover"}),
715
+ );
716
+
717
+ // Wait for the popover to be open.
718
+ await screen.findByRole("dialog");
719
+
720
+ // At this point, the focus moves to the focusable element inside
721
+ // the popover, so we need to move the focus back to the trigger
722
+ // element.
723
+ userEvent.tab({shift: true});
724
+
725
+ // Focus on the first element in the document
726
+ userEvent.tab({shift: true});
727
+
728
+ // Focus on the document body
729
+ userEvent.tab({shift: true});
730
+
731
+ // Focus on the last element in the document
732
+ userEvent.tab({shift: true});
733
+
734
+ // Act
735
+ // Focus again on element inside the popover.
736
+ userEvent.tab({shift: true});
737
+
738
+ // Assert
739
+ expect(
740
+ screen.getByRole("button", {
741
+ name: "Button inside popover",
742
+ }),
743
+ ).toHaveFocus();
744
+ });
745
+ });
359
746
  });