@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.
- package/CHANGELOG.md +19 -0
- package/dist/components/focus-manager.d.ts +18 -1
- package/dist/components/popover-event-listener.d.ts +1 -1
- package/dist/components/popover.d.ts +11 -1
- package/dist/es/index.js +109 -34
- package/dist/index.js +109 -34
- package/dist/util/util.d.ts +5 -0
- package/package.json +4 -4
- package/src/components/__tests__/focus-manager.test.tsx +115 -36
- package/src/components/__tests__/popover.test.tsx +421 -34
- package/src/components/focus-manager.tsx +155 -54
- package/src/components/popover-event-listener.ts +12 -3
- package/src/components/popover.tsx +38 -2
- package/src/util/__tests__/util.test.tsx +38 -0
- package/src/util/util.ts +8 -0
- package/tsconfig-build.tsbuildinfo +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
222
|
+
const anchorIndex = focusableElementsOutside.indexOf(anchorElement);
|
|
130
223
|
|
|
131
|
-
if (
|
|
224
|
+
if (
|
|
225
|
+
anchorIndex >= 0 &&
|
|
226
|
+
anchorIndex !== focusableElementsOutside.length - 1
|
|
227
|
+
) {
|
|
132
228
|
// guess next focusable element index
|
|
133
229
|
const nextElementIndex =
|
|
134
|
-
anchorIndex <
|
|
230
|
+
anchorIndex < focusableElementsOutside.length - 1
|
|
135
231
|
? anchorIndex + 1
|
|
136
232
|
: 0;
|
|
137
233
|
|
|
138
234
|
// get next element's DOM reference
|
|
139
|
-
return
|
|
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.
|
|
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
|
-
|
|
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
|
-
<
|
|
220
|
-
{
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
</
|
|
234
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|