@khanacademy/wonder-blocks-popover 3.0.23 → 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.
@@ -52,6 +52,8 @@ export default class FocusManager extends React.Component<Props> {
52
52
  }
53
53
 
54
54
  componentDidUpdate() {
55
+ // Ensure that the event listeners are not duplicated.
56
+ this.removeEventListeners();
55
57
  this.addEventListeners();
56
58
  }
57
59
 
@@ -59,32 +61,26 @@ export default class FocusManager extends React.Component<Props> {
59
61
  * Remove keydown listeners
60
62
  */
61
63
  componentWillUnmount() {
62
- const {anchorElement} = this.props;
63
-
64
- if (anchorElement) {
65
- // wait for styles to applied, then return the focus to the anchor
66
- setTimeout(() => anchorElement.focus(), 0);
67
-
68
- anchorElement.removeEventListener(
69
- "keydown",
70
- this.handleKeydownPreviousFocusableElement,
71
- true,
72
- );
73
- }
64
+ // Reset focusability
65
+ this.changeFocusabilityInsidePopover(true);
74
66
 
75
- if (this.nextElementAfterPopover) {
76
- this.nextElementAfterPopover.removeEventListener(
77
- "keydown",
78
- this.handleKeydownNextFocusableElement,
79
- true,
80
- );
81
- }
67
+ this.removeEventListeners();
82
68
  }
83
69
 
84
70
  /**
85
71
  * List of focusable elements within the popover content
86
72
  */
87
- focusableElementsInPopover: Array<HTMLElement> = [];
73
+ elementsThatCanBeFocusableInsidePopover: Array<HTMLElement> = [];
74
+
75
+ /**
76
+ * The first focusable element inside the popover (if it exists)
77
+ */
78
+ firstFocusableElementInPopover: HTMLElement | null | undefined = null;
79
+
80
+ /**
81
+ * The last focusable element inside the popover (if it exists)
82
+ */
83
+ lastFocusableElementInPopover: HTMLElement | null | undefined = null;
88
84
 
89
85
  /**
90
86
  * Add keydown listeners
@@ -96,22 +92,113 @@ export default class FocusManager extends React.Component<Props> {
96
92
  anchorElement.addEventListener(
97
93
  "keydown",
98
94
  this.handleKeydownPreviousFocusableElement,
99
- true,
100
95
  );
101
96
  }
102
97
 
98
+ if (this.rootNode) {
99
+ // store the list of possible focusable elements inside the popover
100
+ this.elementsThatCanBeFocusableInsidePopover = findFocusableNodes(
101
+ this.rootNode,
102
+ );
103
+
104
+ // find the first and last focusable elements inside the popover
105
+ this.firstFocusableElementInPopover =
106
+ this.elementsThatCanBeFocusableInsidePopover[0];
107
+ this.lastFocusableElementInPopover =
108
+ this.elementsThatCanBeFocusableInsidePopover[
109
+ this.elementsThatCanBeFocusableInsidePopover.length - 1
110
+ ];
111
+ }
112
+
103
113
  // tries to get the next focusable element outside of the popover
104
114
  this.nextElementAfterPopover = this.getNextFocusableElement();
105
115
 
116
+ // NOTE: This is only needed when the trigger element is the last
117
+ // focusable element in the document. It's specially useful for when the
118
+ // focus is set in the address bar and the user presses `shift+tab` to
119
+ // focus back on the document.
120
+ if (!this.nextElementAfterPopover) {
121
+ window.addEventListener("blur", () => {
122
+ this.changeFocusabilityInsidePopover(true);
123
+ });
124
+ }
125
+
126
+ if (this.firstFocusableElementInPopover) {
127
+ this.firstFocusableElementInPopover.addEventListener(
128
+ "keydown",
129
+ this.handleKeydownFirstFocusableElement,
130
+ );
131
+ }
132
+
133
+ if (this.lastFocusableElementInPopover) {
134
+ this.lastFocusableElementInPopover.addEventListener(
135
+ "keydown",
136
+ this.handleKeydownLastFocusableElement,
137
+ );
138
+ }
139
+
106
140
  if (this.nextElementAfterPopover) {
107
141
  this.nextElementAfterPopover.addEventListener(
108
142
  "keydown",
109
143
  this.handleKeydownNextFocusableElement,
110
- true,
111
144
  );
112
145
  }
113
146
  };
114
147
 
148
+ removeEventListeners() {
149
+ const {anchorElement} = this.props;
150
+
151
+ if (anchorElement) {
152
+ anchorElement.removeEventListener(
153
+ "keydown",
154
+ this.handleKeydownPreviousFocusableElement,
155
+ );
156
+ }
157
+
158
+ if (!this.nextElementAfterPopover) {
159
+ window.removeEventListener("blur", () => {
160
+ this.changeFocusabilityInsidePopover(true);
161
+ });
162
+ }
163
+
164
+ if (this.firstFocusableElementInPopover) {
165
+ this.firstFocusableElementInPopover.removeEventListener(
166
+ "keydown",
167
+ this.handleKeydownFirstFocusableElement,
168
+ );
169
+ }
170
+
171
+ if (this.lastFocusableElementInPopover) {
172
+ this.lastFocusableElementInPopover.removeEventListener(
173
+ "keydown",
174
+ this.handleKeydownLastFocusableElement,
175
+ );
176
+ }
177
+
178
+ if (this.nextElementAfterPopover) {
179
+ this.nextElementAfterPopover.removeEventListener(
180
+ "keydown",
181
+ this.handleKeydownNextFocusableElement,
182
+ );
183
+ }
184
+ }
185
+
186
+ handleKeydownFirstFocusableElement: (e: KeyboardEvent) => void = (e) => {
187
+ // It will try focus only if the user is pressing `Shift+tab`
188
+ if (e.key === "Tab" && e.shiftKey) {
189
+ e.preventDefault();
190
+ this.props.anchorElement?.focus();
191
+ }
192
+ };
193
+
194
+ handleKeydownLastFocusableElement: (e: KeyboardEvent) => void = (e) => {
195
+ // It will try focus only if the user is pressing `Shift+tab`
196
+ if (this.nextElementAfterPopover && e.key === "Tab" && !e.shiftKey) {
197
+ e.preventDefault();
198
+ this.nextElementAfterPopover?.focus();
199
+ }
200
+ };
201
+
115
202
  /**
116
203
  * Gets the next focusable element after the anchor element
117
204
  */
@@ -125,18 +212,27 @@ export default class FocusManager extends React.Component<Props> {
125
212
  // get the total list of focusable elements within the document
126
213
  const focusableElements = findFocusableNodes(document);
127
214
 
215
+ const focusableElementsOutside = focusableElements.filter((element) => {
216
+ const index =
217
+ this.elementsThatCanBeFocusableInsidePopover.indexOf(element);
218
+ return index < 0;
219
+ });
220
+
128
221
  // get anchor element index
129
- const anchorIndex = focusableElements.indexOf(anchorElement);
222
+ const anchorIndex = focusableElementsOutside.indexOf(anchorElement);
130
223
 
131
- if (anchorIndex >= 0) {
224
+ if (
225
+ anchorIndex >= 0 &&
226
+ anchorIndex !== focusableElementsOutside.length - 1
227
+ ) {
132
228
  // guess next focusable element index
133
229
  const nextElementIndex =
134
- anchorIndex < focusableElements.length - 1
230
+ anchorIndex < focusableElementsOutside.length - 1
135
231
  ? anchorIndex + 1
136
232
  : 0;
137
233
 
138
234
  // get next element's DOM reference
139
- return focusableElements[nextElementIndex];
235
+ return focusableElementsOutside[nextElementIndex];
140
236
  }
141
237
 
142
238
  return;
@@ -161,9 +257,6 @@ export default class FocusManager extends React.Component<Props> {
161
257
  }
162
258
 
163
259
  this.rootNode = rootNode as HTMLElement;
164
-
165
- // store the list of possible focusable elements inside the popover
166
- this.focusableElementsInPopover = findFocusableNodes(this.rootNode);
167
260
  };
168
261
 
169
262
  /**
@@ -176,6 +269,21 @@ export default class FocusManager extends React.Component<Props> {
176
269
  }
177
270
  };
178
271
 
272
+ /**
273
+ * Toggle focusability for all the focusable elements inside the popover.
274
+ * This is useful to prevent the user from tabbing into the popover when it
275
+ * reaches to the last focusable element within the document.
276
+ */
277
+ changeFocusabilityInsidePopover = (enabled = true) => {
278
+ const tabIndex = enabled ? "0" : "-1";
279
+
280
+ // Enable/disable focusability for all the focusable elements inside the
281
+ // popover.
282
+ this.elementsThatCanBeFocusableInsidePopover.forEach((element) => {
283
+ element.setAttribute("tabIndex", tabIndex);
284
+ });
285
+ };
286
+
179
287
  /**
180
288
  * Triggered when the focus is set to the last sentinel. This way, the focus
181
289
  * will be redirected to next element after the anchor element.
@@ -195,7 +303,7 @@ export default class FocusManager extends React.Component<Props> {
195
303
  // It will try focus only if the user is pressing `tab`
196
304
  if (e.key === "Tab" && !e.shiftKey) {
197
305
  e.preventDefault();
198
- this.focusableElementsInPopover[0].focus();
306
+ this.firstFocusableElementInPopover?.focus();
199
307
  }
200
308
  };
201
309
 
@@ -207,8 +315,7 @@ export default class FocusManager extends React.Component<Props> {
207
315
  // It will try focus only if the user is pressing `Shift+tab`
208
316
  if (e.key === "Tab" && e.shiftKey) {
209
317
  e.preventDefault();
210
- const lastElementIndex = this.focusableElementsInPopover.length - 1;
211
- this.focusableElementsInPopover[lastElementIndex].focus();
318
+ this.lastFocusableElementInPopover?.focus();
212
319
  }
213
320
  };
214
321
 
@@ -216,28 +323,22 @@ export default class FocusManager extends React.Component<Props> {
216
323
  const {children} = this.props;
217
324
 
218
325
  return (
219
- <React.Fragment>
220
- {/* First sentinel
221
- * We set the sentinels to be position: fixed to make sure
222
- * they're always in view, this prevents page scrolling when
223
- * tabbing. */}
224
- <div
225
- tabIndex={0}
226
- onFocus={this.handleFocusPreviousFocusableElement}
227
- style={{position: "fixed"}}
228
- />
229
- <div ref={this.getComponentRootNode}>
230
- <InitialFocus initialFocusId={this.props.initialFocusId}>
231
- {children}
232
- </InitialFocus>
233
- </div>
234
- {/* last sentinel */}
235
- <div
236
- tabIndex={0}
237
- onFocus={this.handleFocusNextFocusableElement}
238
- style={{position: "fixed"}}
239
- />
240
- </React.Fragment>
326
+ <div
327
+ ref={this.getComponentRootNode}
328
+ onClick={() => {
329
+ this.changeFocusabilityInsidePopover(true);
330
+ }}
331
+ onFocus={() => {
332
+ this.changeFocusabilityInsidePopover(true);
333
+ }}
334
+ onBlur={() => {
335
+ this.changeFocusabilityInsidePopover(false);
336
+ }}
337
+ >
338
+ <InitialFocus initialFocusId={this.props.initialFocusId}>
339
+ {children}
340
+ </InitialFocus>
341
+ </div>
241
342
  );
242
343
  }
243
344
  }
@@ -1,5 +1,6 @@
1
1
  import * as React from "react";
2
2
  import * as ReactDOM from "react-dom";
3
+ import {isFocusable} from "../util/util";
3
4
 
4
5
  import PopoverContent from "./popover-content";
5
6
  import PopoverContentCore from "./popover-content-core";
@@ -8,7 +9,7 @@ type Props = {
8
9
  /**
9
10
  * Called when `esc` is pressed
10
11
  */
11
- onClose: () => unknown;
12
+ onClose: (shouldReturnFocus: boolean) => unknown;
12
13
  /**
13
14
  * Popover Content ref.
14
15
  * Will close the popover when clicking outside this element.
@@ -59,7 +60,9 @@ export default class PopoverEventListener extends React.Component<
59
60
  // unexpectedly cancels multiple things.
60
61
  e.preventDefault();
61
62
  e.stopPropagation();
62
- this.props.onClose();
63
+ // In the case of the Escape key, we should return focus to the
64
+ // trigger button.
65
+ this.props.onClose(true);
63
66
  }
64
67
  };
65
68
 
@@ -77,7 +80,13 @@ export default class PopoverEventListener extends React.Component<
77
80
  // Only allow click to cancel one thing at a time.
78
81
  e.preventDefault();
79
82
  e.stopPropagation();
80
- this.props.onClose();
83
+
84
+ // Determine if the focus must go to a focusable/interactive
85
+ // element.
86
+ const shouldReturnFocus = !isFocusable(e.target as any);
87
+ // If that's the case, we need to prevent the default behavior of
88
+ // returning the focus to the trigger button.
89
+ this.props.onClose(shouldReturnFocus);
81
90
  }
82
91
  };
83
92
 
@@ -69,6 +69,12 @@ type Props = AriaProps &
69
69
  *
70
70
  */
71
71
  id?: string;
72
+ /**
73
+ * The selector for the element that will be focused after the popover
74
+ * dialog closes. When not set, the element that triggered the popover
75
+ * will be used.
76
+ */
77
+ closedFocusId?: string;
72
78
  /**
73
79
  * The selector for the element that will be focused when the popover
74
80
  * content shows. When not set, the first focusable element within the
@@ -171,12 +177,42 @@ export default class Popover extends React.Component<Props, State> {
171
177
  contentRef: React.RefObject<PopoverContent | PopoverContentCore> =
172
178
  React.createRef();
173
179
 
180
+ /**
181
+ * Returns focus to a given element.
182
+ */
183
+ maybeReturnFocus = () => {
184
+ const {anchorElement} = this.state;
185
+ const {closedFocusId} = this.props;
186
+
187
+ // Focus on the specified element after dismissing the popover.
188
+ if (closedFocusId) {
189
+ const focusElement = ReactDOM.findDOMNode(
190
+ document.getElementById(closedFocusId),
191
+ ) as any;
192
+
193
+ focusElement?.focus();
194
+ return;
195
+ }
196
+
197
+ // If no element is specified, focus on the element that triggered the
198
+ // popover.
199
+ if (anchorElement) {
200
+ anchorElement.focus();
201
+ }
202
+ };
203
+
174
204
  /**
175
205
  * Popover dialog closed
176
206
  */
177
- handleClose: () => void = () => {
207
+ handleClose: (shouldReturnFocus?: boolean) => void = (
208
+ shouldReturnFocus = true,
209
+ ) => {
178
210
  this.setState({opened: false}, () => {
179
211
  this.props.onClose?.();
212
+
213
+ if (shouldReturnFocus) {
214
+ this.maybeReturnFocus();
215
+ }
180
216
  });
181
217
  };
182
218
 
@@ -185,7 +221,7 @@ export default class Popover extends React.Component<Props, State> {
185
221
  */
186
222
  handleOpen: () => void = () => {
187
223
  if (this.props.dismissEnabled && this.state.opened) {
188
- this.setState({opened: false});
224
+ this.handleClose(true);
189
225
  } else {
190
226
  this.setState({opened: true});
191
227
  }
@@ -0,0 +1,38 @@
1
+ import * as React from "react";
2
+ import {render, screen} from "@testing-library/react";
3
+ import {isFocusable} from "../util";
4
+
5
+ describe("isFocusable", () => {
6
+ it("should mark a button as focusable", () => {
7
+ // Arrange
8
+ render(<button>Open popover</button>);
9
+
10
+ // Act
11
+ const result = isFocusable(screen.getByRole("button"));
12
+
13
+ // Assert
14
+ expect(result).toBe(true);
15
+ });
16
+
17
+ it("should mark a div as non-focusable", () => {
18
+ // Arrange
19
+ render(<div>placeholder</div>);
20
+
21
+ // Act
22
+ const result = isFocusable(screen.getByText("placeholder"));
23
+
24
+ // Assert
25
+ expect(result).toBe(false);
26
+ });
27
+
28
+ it("should mark a div with tabIndex greater than -1 as focusable", () => {
29
+ // Arrange
30
+ render(<div tabIndex={0}>placeholder</div>);
31
+
32
+ // Act
33
+ const result = isFocusable(screen.getByText("placeholder"));
34
+
35
+ // Assert
36
+ expect(result).toBe(true);
37
+ });
38
+ });
package/src/util/util.ts CHANGED
@@ -10,3 +10,11 @@ export function findFocusableNodes(
10
10
  ): Array<HTMLElement> {
11
11
  return Array.from(root.querySelectorAll(FOCUSABLE_ELEMENTS));
12
12
  }
13
+
14
+ /**
15
+ * Checks if an element is focusable
16
+ * @see https://html.spec.whatwg.org/multipage/interaction.html#focusable-area
17
+ */
18
+ export function isFocusable(element: HTMLElement): boolean {
19
+ return element.matches(FOCUSABLE_ELEMENTS);
20
+ }