@khanacademy/wonder-blocks-popover 3.0.23 → 3.1.1

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
  }
@@ -2,9 +2,8 @@ import * as React from "react";
2
2
  import {StyleSheet} from "aphrodite";
3
3
 
4
4
  import type {AriaProps, StyleType} from "@khanacademy/wonder-blocks-core";
5
- import Colors from "@khanacademy/wonder-blocks-color";
6
5
  import {View} from "@khanacademy/wonder-blocks-core";
7
- import Spacing from "@khanacademy/wonder-blocks-spacing";
6
+ import {color, spacing} from "@khanacademy/wonder-blocks-tokens";
8
7
 
9
8
  import CloseButton from "./close-button";
10
9
 
@@ -105,13 +104,13 @@ export default class PopoverContentCore extends React.Component<Props> {
105
104
 
106
105
  const styles = StyleSheet.create({
107
106
  content: {
108
- borderRadius: Spacing.xxxSmall_4,
109
- border: `solid 1px ${Colors.offBlack16}`,
110
- backgroundColor: Colors.white,
111
- boxShadow: `0 ${Spacing.xSmall_8}px ${Spacing.xSmall_8}px 0 ${Colors.offBlack8}`,
107
+ borderRadius: spacing.xxxSmall_4,
108
+ border: `solid 1px ${color.offBlack16}`,
109
+ backgroundColor: color.white,
110
+ boxShadow: `0 ${spacing.xSmall_8}px ${spacing.xSmall_8}px 0 ${color.offBlack8}`,
112
111
  margin: 0,
113
- maxWidth: Spacing.medium_16 * 18, // 288px
114
- padding: Spacing.large_24,
112
+ maxWidth: spacing.medium_16 * 18, // 288px
113
+ padding: spacing.large_24,
115
114
  overflow: "hidden",
116
115
  justifyContent: "center",
117
116
  },
@@ -119,13 +118,13 @@ const styles = StyleSheet.create({
119
118
  * Theming
120
119
  */
121
120
  blue: {
122
- backgroundColor: Colors.blue,
123
- color: Colors.white,
121
+ backgroundColor: color.blue,
122
+ color: color.white,
124
123
  },
125
124
 
126
125
  darkBlue: {
127
- backgroundColor: Colors.darkBlue,
128
- color: Colors.white,
126
+ backgroundColor: color.darkBlue,
127
+ color: color.white,
129
128
  },
130
129
 
131
130
  /**
@@ -134,8 +133,8 @@ const styles = StyleSheet.create({
134
133
  closeButton: {
135
134
  margin: 0,
136
135
  position: "absolute",
137
- right: Spacing.xxxSmall_4,
138
- top: Spacing.xxxSmall_4,
136
+ right: spacing.xxxSmall_4,
137
+ top: spacing.xxxSmall_4,
139
138
  // Allows the button to be above the title and/or custom content
140
139
  zIndex: 1,
141
140
  },
@@ -3,7 +3,7 @@ import {StyleSheet} from "aphrodite";
3
3
 
4
4
  import type {AriaProps, StyleType} from "@khanacademy/wonder-blocks-core";
5
5
  import {addStyle, View} from "@khanacademy/wonder-blocks-core";
6
- import Spacing from "@khanacademy/wonder-blocks-spacing";
6
+ import {spacing} from "@khanacademy/wonder-blocks-tokens";
7
7
  import {Body, HeadingSmall} from "@khanacademy/wonder-blocks-typography";
8
8
 
9
9
  import type {PopoverContextType} from "./popover-context";
@@ -265,7 +265,7 @@ const styles = StyleSheet.create({
265
265
  * Shared styles
266
266
  */
267
267
  actions: {
268
- marginTop: Spacing.large_24,
268
+ marginTop: spacing.large_24,
269
269
  flexDirection: "row",
270
270
  alignItems: "center",
271
271
  justifyContent: "flex-end",
@@ -276,7 +276,7 @@ const styles = StyleSheet.create({
276
276
  },
277
277
 
278
278
  title: {
279
- marginBottom: Spacing.xSmall_8,
279
+ marginBottom: spacing.xSmall_8,
280
280
  },
281
281
 
282
282
  /**
@@ -285,10 +285,10 @@ const styles = StyleSheet.create({
285
285
  iconContainer: {
286
286
  alignItems: "center",
287
287
  justifyContent: "center",
288
- height: Spacing.xxxLarge_64,
289
- width: Spacing.xxxLarge_64,
290
- minWidth: Spacing.xxxLarge_64,
291
- marginRight: Spacing.medium_16,
288
+ height: spacing.xxxLarge_64,
289
+ width: spacing.xxxLarge_64,
290
+ minWidth: spacing.xxxLarge_64,
291
+ marginRight: spacing.medium_16,
292
292
  overflow: "hidden",
293
293
  },
294
294
 
@@ -304,16 +304,16 @@ const styles = StyleSheet.create({
304
304
  * Illustration styles
305
305
  */
306
306
  image: {
307
- marginBottom: Spacing.large_24,
308
- marginLeft: -Spacing.large_24,
309
- marginRight: -Spacing.large_24,
310
- marginTop: -Spacing.large_24,
311
- width: `calc(100% + ${Spacing.large_24 * 2}px)`,
307
+ marginBottom: spacing.large_24,
308
+ marginLeft: -spacing.large_24,
309
+ marginRight: -spacing.large_24,
310
+ marginTop: -spacing.large_24,
311
+ width: `calc(100% + ${spacing.large_24 * 2}px)`,
312
312
  },
313
313
 
314
314
  imageToBottom: {
315
- marginBottom: -Spacing.large_24,
316
- marginTop: Spacing.large_24,
315
+ marginBottom: -spacing.large_24,
316
+ marginTop: spacing.large_24,
317
317
  order: 1,
318
318
  },
319
319
  });
@@ -3,7 +3,7 @@ import {StyleSheet} from "aphrodite";
3
3
 
4
4
  import {View} from "@khanacademy/wonder-blocks-core";
5
5
  import {TooltipTail} from "@khanacademy/wonder-blocks-tooltip";
6
- import Color from "@khanacademy/wonder-blocks-color";
6
+ import * as tokens from "@khanacademy/wonder-blocks-tokens";
7
7
 
8
8
  import type {AriaProps} from "@khanacademy/wonder-blocks-core";
9
9
  import type {
@@ -83,7 +83,7 @@ export default class PopoverDialog extends React.Component<Props> {
83
83
  const contentProps = children.props as any;
84
84
 
85
85
  // extract the background color from the popover content
86
- const color: keyof typeof Color = contentProps.emphasized
86
+ const color: keyof typeof tokens.color = contentProps.emphasized
87
87
  ? "blue"
88
88
  : contentProps.color;
89
89
 
@@ -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
+ }
@@ -6,12 +6,11 @@
6
6
  "rootDir": "src",
7
7
  },
8
8
  "references": [
9
- {"path": "../wonder-blocks-color/tsconfig-build.json"},
10
9
  {"path": "../wonder-blocks-core/tsconfig-build.json"},
11
10
  {"path": "../wonder-blocks-icon/tsconfig-build.json"},
12
11
  {"path": "../wonder-blocks-icon-button/tsconfig-build.json"},
13
12
  {"path": "../wonder-blocks-modal/tsconfig-build.json"},
14
- {"path": "../wonder-blocks-spacing/tsconfig-build.json"},
13
+ {"path": "../wonder-blocks-tokens/tsconfig-build.json"},
15
14
  {"path": "../wonder-blocks-tooltip/tsconfig-build.json"},
16
15
  {"path": "../wonder-blocks-typography/tsconfig-build.json"},
17
16
  ]