@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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# @khanacademy/wonder-blocks-popover
|
|
2
2
|
|
|
3
|
+
## 3.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 7944c7d3: Add `closedFocusId` prop to manually return focus to a specific element
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- e5dd6215: Don't return focus to trigger element if the focus has to go to a different interactive element
|
|
12
|
+
- 163cfca3: Fix tab navigation order
|
|
13
|
+
|
|
14
|
+
## 3.0.23
|
|
15
|
+
|
|
16
|
+
### Patch Changes
|
|
17
|
+
|
|
18
|
+
- @khanacademy/wonder-blocks-icon-button@5.1.8
|
|
19
|
+
- @khanacademy/wonder-blocks-modal@4.2.1
|
|
20
|
+
- @khanacademy/wonder-blocks-tooltip@2.1.25
|
|
21
|
+
|
|
3
22
|
## 3.0.22
|
|
4
23
|
|
|
5
24
|
### Patch Changes
|
|
@@ -49,11 +49,22 @@ export default class FocusManager extends React.Component<Props> {
|
|
|
49
49
|
/**
|
|
50
50
|
* List of focusable elements within the popover content
|
|
51
51
|
*/
|
|
52
|
-
|
|
52
|
+
elementsThatCanBeFocusableInsidePopover: Array<HTMLElement>;
|
|
53
|
+
/**
|
|
54
|
+
* The first focusable element inside the popover (if it exists)
|
|
55
|
+
*/
|
|
56
|
+
firstFocusableElementInPopover: HTMLElement | null | undefined;
|
|
57
|
+
/**
|
|
58
|
+
* The last focusable element inside the popover (if it exists)
|
|
59
|
+
*/
|
|
60
|
+
lastFocusableElementInPopover: HTMLElement | null | undefined;
|
|
53
61
|
/**
|
|
54
62
|
* Add keydown listeners
|
|
55
63
|
*/
|
|
56
64
|
addEventListeners: () => void;
|
|
65
|
+
removeEventListeners(): void;
|
|
66
|
+
handleKeydownFirstFocusableElement: (e: KeyboardEvent) => void;
|
|
67
|
+
handleKeydownLastFocusableElement: (e: KeyboardEvent) => void;
|
|
57
68
|
/**
|
|
58
69
|
* Gets the next focusable element after the anchor element
|
|
59
70
|
*/
|
|
@@ -67,6 +78,12 @@ export default class FocusManager extends React.Component<Props> {
|
|
|
67
78
|
* focus will be redirected to the anchor element.
|
|
68
79
|
*/
|
|
69
80
|
handleFocusPreviousFocusableElement: () => void;
|
|
81
|
+
/**
|
|
82
|
+
* Toggle focusability for all the focusable elements inside the popover.
|
|
83
|
+
* This is useful to prevent the user from tabbing into the popover when it
|
|
84
|
+
* reaches to the last focusable element within the document.
|
|
85
|
+
*/
|
|
86
|
+
changeFocusabilityInsidePopover: (enabled?: boolean) => void;
|
|
70
87
|
/**
|
|
71
88
|
* Triggered when the focus is set to the last sentinel. This way, the focus
|
|
72
89
|
* will be redirected to next element after the anchor element.
|
|
@@ -49,6 +49,12 @@ type Props = AriaProps & Readonly<{
|
|
|
49
49
|
*
|
|
50
50
|
*/
|
|
51
51
|
id?: string;
|
|
52
|
+
/**
|
|
53
|
+
* The selector for the element that will be focused after the popover
|
|
54
|
+
* dialog closes. When not set, the element that triggered the popover
|
|
55
|
+
* will be used.
|
|
56
|
+
*/
|
|
57
|
+
closedFocusId?: string;
|
|
52
58
|
/**
|
|
53
59
|
* The selector for the element that will be focused when the popover
|
|
54
60
|
* content shows. When not set, the first focusable element within the
|
|
@@ -129,10 +135,14 @@ export default class Popover extends React.Component<Props, State> {
|
|
|
129
135
|
* Popover content ref
|
|
130
136
|
*/
|
|
131
137
|
contentRef: React.RefObject<PopoverContent | PopoverContentCore>;
|
|
138
|
+
/**
|
|
139
|
+
* Returns focus to a given element.
|
|
140
|
+
*/
|
|
141
|
+
maybeReturnFocus: () => void;
|
|
132
142
|
/**
|
|
133
143
|
* Popover dialog closed
|
|
134
144
|
*/
|
|
135
|
-
handleClose: () => void;
|
|
145
|
+
handleClose: (shouldReturnFocus?: boolean) => void;
|
|
136
146
|
/**
|
|
137
147
|
* Popover dialog opened
|
|
138
148
|
*/
|
package/dist/es/index.js
CHANGED
|
@@ -133,6 +133,9 @@ const FOCUSABLE_ELEMENTS = 'button, [href], input, select, textarea, [tabindex]:
|
|
|
133
133
|
function findFocusableNodes(root) {
|
|
134
134
|
return Array.from(root.querySelectorAll(FOCUSABLE_ELEMENTS));
|
|
135
135
|
}
|
|
136
|
+
function isFocusable(element) {
|
|
137
|
+
return element.matches(FOCUSABLE_ELEMENTS);
|
|
138
|
+
}
|
|
136
139
|
|
|
137
140
|
class InitialFocus extends React.Component {
|
|
138
141
|
constructor(...args) {
|
|
@@ -180,17 +183,49 @@ class FocusManager extends React.Component {
|
|
|
180
183
|
super(...args);
|
|
181
184
|
this.nextElementAfterPopover = void 0;
|
|
182
185
|
this.rootNode = void 0;
|
|
183
|
-
this.
|
|
186
|
+
this.elementsThatCanBeFocusableInsidePopover = [];
|
|
187
|
+
this.firstFocusableElementInPopover = null;
|
|
188
|
+
this.lastFocusableElementInPopover = null;
|
|
184
189
|
this.addEventListeners = () => {
|
|
185
190
|
const {
|
|
186
191
|
anchorElement
|
|
187
192
|
} = this.props;
|
|
188
193
|
if (anchorElement) {
|
|
189
|
-
anchorElement.addEventListener("keydown", this.handleKeydownPreviousFocusableElement
|
|
194
|
+
anchorElement.addEventListener("keydown", this.handleKeydownPreviousFocusableElement);
|
|
195
|
+
}
|
|
196
|
+
if (this.rootNode) {
|
|
197
|
+
this.elementsThatCanBeFocusableInsidePopover = findFocusableNodes(this.rootNode);
|
|
198
|
+
this.firstFocusableElementInPopover = this.elementsThatCanBeFocusableInsidePopover[0];
|
|
199
|
+
this.lastFocusableElementInPopover = this.elementsThatCanBeFocusableInsidePopover[this.elementsThatCanBeFocusableInsidePopover.length - 1];
|
|
190
200
|
}
|
|
191
201
|
this.nextElementAfterPopover = this.getNextFocusableElement();
|
|
202
|
+
if (!this.nextElementAfterPopover) {
|
|
203
|
+
window.addEventListener("blur", () => {
|
|
204
|
+
this.changeFocusabilityInsidePopover(true);
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
if (this.firstFocusableElementInPopover) {
|
|
208
|
+
this.firstFocusableElementInPopover.addEventListener("keydown", this.handleKeydownFirstFocusableElement);
|
|
209
|
+
}
|
|
210
|
+
if (this.lastFocusableElementInPopover) {
|
|
211
|
+
this.lastFocusableElementInPopover.addEventListener("keydown", this.handleKeydownLastFocusableElement);
|
|
212
|
+
}
|
|
192
213
|
if (this.nextElementAfterPopover) {
|
|
193
|
-
this.nextElementAfterPopover.addEventListener("keydown", this.handleKeydownNextFocusableElement
|
|
214
|
+
this.nextElementAfterPopover.addEventListener("keydown", this.handleKeydownNextFocusableElement);
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
this.handleKeydownFirstFocusableElement = e => {
|
|
218
|
+
if (e.key === "Tab" && e.shiftKey) {
|
|
219
|
+
var _this$props$anchorEle;
|
|
220
|
+
e.preventDefault();
|
|
221
|
+
(_this$props$anchorEle = this.props.anchorElement) == null ? void 0 : _this$props$anchorEle.focus();
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
this.handleKeydownLastFocusableElement = e => {
|
|
225
|
+
if (this.nextElementAfterPopover && e.key === "Tab" && !e.shiftKey) {
|
|
226
|
+
var _this$nextElementAfte;
|
|
227
|
+
e.preventDefault();
|
|
228
|
+
(_this$nextElementAfte = this.nextElementAfterPopover) == null ? void 0 : _this$nextElementAfte.focus();
|
|
194
229
|
}
|
|
195
230
|
};
|
|
196
231
|
this.getNextFocusableElement = () => {
|
|
@@ -201,10 +236,14 @@ class FocusManager extends React.Component {
|
|
|
201
236
|
return;
|
|
202
237
|
}
|
|
203
238
|
const focusableElements = findFocusableNodes(document);
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
239
|
+
const focusableElementsOutside = focusableElements.filter(element => {
|
|
240
|
+
const index = this.elementsThatCanBeFocusableInsidePopover.indexOf(element);
|
|
241
|
+
return index < 0;
|
|
242
|
+
});
|
|
243
|
+
const anchorIndex = focusableElementsOutside.indexOf(anchorElement);
|
|
244
|
+
if (anchorIndex >= 0 && anchorIndex !== focusableElementsOutside.length - 1) {
|
|
245
|
+
const nextElementIndex = anchorIndex < focusableElementsOutside.length - 1 ? anchorIndex + 1 : 0;
|
|
246
|
+
return focusableElementsOutside[nextElementIndex];
|
|
208
247
|
}
|
|
209
248
|
return;
|
|
210
249
|
};
|
|
@@ -217,13 +256,18 @@ class FocusManager extends React.Component {
|
|
|
217
256
|
throw new Error("Assertion error: root node should exist after mount");
|
|
218
257
|
}
|
|
219
258
|
this.rootNode = rootNode;
|
|
220
|
-
this.focusableElementsInPopover = findFocusableNodes(this.rootNode);
|
|
221
259
|
};
|
|
222
260
|
this.handleFocusPreviousFocusableElement = () => {
|
|
223
261
|
if (this.props.anchorElement) {
|
|
224
262
|
this.props.anchorElement.focus();
|
|
225
263
|
}
|
|
226
264
|
};
|
|
265
|
+
this.changeFocusabilityInsidePopover = (enabled = true) => {
|
|
266
|
+
const tabIndex = enabled ? "0" : "-1";
|
|
267
|
+
this.elementsThatCanBeFocusableInsidePopover.forEach(element => {
|
|
268
|
+
element.setAttribute("tabIndex", tabIndex);
|
|
269
|
+
});
|
|
270
|
+
};
|
|
227
271
|
this.handleFocusNextFocusableElement = () => {
|
|
228
272
|
if (this.nextElementAfterPopover) {
|
|
229
273
|
this.nextElementAfterPopover.focus();
|
|
@@ -231,15 +275,16 @@ class FocusManager extends React.Component {
|
|
|
231
275
|
};
|
|
232
276
|
this.handleKeydownPreviousFocusableElement = e => {
|
|
233
277
|
if (e.key === "Tab" && !e.shiftKey) {
|
|
278
|
+
var _this$firstFocusableE;
|
|
234
279
|
e.preventDefault();
|
|
235
|
-
this.
|
|
280
|
+
(_this$firstFocusableE = this.firstFocusableElementInPopover) == null ? void 0 : _this$firstFocusableE.focus();
|
|
236
281
|
}
|
|
237
282
|
};
|
|
238
283
|
this.handleKeydownNextFocusableElement = e => {
|
|
239
284
|
if (e.key === "Tab" && e.shiftKey) {
|
|
285
|
+
var _this$lastFocusableEl;
|
|
240
286
|
e.preventDefault();
|
|
241
|
-
|
|
242
|
-
this.focusableElementsInPopover[lastElementIndex].focus();
|
|
287
|
+
(_this$lastFocusableEl = this.lastFocusableElementInPopover) == null ? void 0 : _this$lastFocusableEl.focus();
|
|
243
288
|
}
|
|
244
289
|
};
|
|
245
290
|
}
|
|
@@ -247,41 +292,53 @@ class FocusManager extends React.Component {
|
|
|
247
292
|
this.addEventListeners();
|
|
248
293
|
}
|
|
249
294
|
componentDidUpdate() {
|
|
295
|
+
this.removeEventListeners();
|
|
250
296
|
this.addEventListeners();
|
|
251
297
|
}
|
|
252
298
|
componentWillUnmount() {
|
|
299
|
+
this.changeFocusabilityInsidePopover(true);
|
|
300
|
+
this.removeEventListeners();
|
|
301
|
+
}
|
|
302
|
+
removeEventListeners() {
|
|
253
303
|
const {
|
|
254
304
|
anchorElement
|
|
255
305
|
} = this.props;
|
|
256
306
|
if (anchorElement) {
|
|
257
|
-
|
|
258
|
-
|
|
307
|
+
anchorElement.removeEventListener("keydown", this.handleKeydownPreviousFocusableElement);
|
|
308
|
+
}
|
|
309
|
+
if (!this.nextElementAfterPopover) {
|
|
310
|
+
window.removeEventListener("blur", () => {
|
|
311
|
+
this.changeFocusabilityInsidePopover(true);
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
if (this.firstFocusableElementInPopover) {
|
|
315
|
+
this.firstFocusableElementInPopover.removeEventListener("keydown", this.handleKeydownFirstFocusableElement);
|
|
316
|
+
}
|
|
317
|
+
if (this.lastFocusableElementInPopover) {
|
|
318
|
+
this.lastFocusableElementInPopover.removeEventListener("keydown", this.handleKeydownLastFocusableElement);
|
|
259
319
|
}
|
|
260
320
|
if (this.nextElementAfterPopover) {
|
|
261
|
-
this.nextElementAfterPopover.removeEventListener("keydown", this.handleKeydownNextFocusableElement
|
|
321
|
+
this.nextElementAfterPopover.removeEventListener("keydown", this.handleKeydownNextFocusableElement);
|
|
262
322
|
}
|
|
263
323
|
}
|
|
264
324
|
render() {
|
|
265
325
|
const {
|
|
266
326
|
children
|
|
267
327
|
} = this.props;
|
|
268
|
-
return React.createElement(
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
328
|
+
return React.createElement("div", {
|
|
329
|
+
ref: this.getComponentRootNode,
|
|
330
|
+
onClick: () => {
|
|
331
|
+
this.changeFocusabilityInsidePopover(true);
|
|
332
|
+
},
|
|
333
|
+
onFocus: () => {
|
|
334
|
+
this.changeFocusabilityInsidePopover(true);
|
|
335
|
+
},
|
|
336
|
+
onBlur: () => {
|
|
337
|
+
this.changeFocusabilityInsidePopover(false);
|
|
273
338
|
}
|
|
274
|
-
}), React.createElement("div", {
|
|
275
|
-
ref: this.getComponentRootNode
|
|
276
339
|
}, React.createElement(InitialFocus, {
|
|
277
340
|
initialFocusId: this.props.initialFocusId
|
|
278
|
-
}, children))
|
|
279
|
-
tabIndex: 0,
|
|
280
|
-
onFocus: this.handleFocusNextFocusableElement,
|
|
281
|
-
style: {
|
|
282
|
-
position: "fixed"
|
|
283
|
-
}
|
|
284
|
-
}));
|
|
341
|
+
}, children));
|
|
285
342
|
}
|
|
286
343
|
}
|
|
287
344
|
|
|
@@ -295,7 +352,7 @@ class PopoverEventListener extends React.Component {
|
|
|
295
352
|
if (e.key === "Escape") {
|
|
296
353
|
e.preventDefault();
|
|
297
354
|
e.stopPropagation();
|
|
298
|
-
this.props.onClose();
|
|
355
|
+
this.props.onClose(true);
|
|
299
356
|
}
|
|
300
357
|
};
|
|
301
358
|
this._handleClick = e => {
|
|
@@ -310,7 +367,8 @@ class PopoverEventListener extends React.Component {
|
|
|
310
367
|
if (node && !node.contains(e.target)) {
|
|
311
368
|
e.preventDefault();
|
|
312
369
|
e.stopPropagation();
|
|
313
|
-
|
|
370
|
+
const shouldReturnFocus = !isFocusable(e.target);
|
|
371
|
+
this.props.onClose(shouldReturnFocus);
|
|
314
372
|
}
|
|
315
373
|
};
|
|
316
374
|
}
|
|
@@ -335,19 +393,36 @@ class Popover extends React.Component {
|
|
|
335
393
|
placement: this.props.placement
|
|
336
394
|
};
|
|
337
395
|
this.contentRef = React.createRef();
|
|
338
|
-
this.
|
|
396
|
+
this.maybeReturnFocus = () => {
|
|
397
|
+
const {
|
|
398
|
+
anchorElement
|
|
399
|
+
} = this.state;
|
|
400
|
+
const {
|
|
401
|
+
closedFocusId
|
|
402
|
+
} = this.props;
|
|
403
|
+
if (closedFocusId) {
|
|
404
|
+
const focusElement = ReactDOM.findDOMNode(document.getElementById(closedFocusId));
|
|
405
|
+
focusElement == null ? void 0 : focusElement.focus();
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
if (anchorElement) {
|
|
409
|
+
anchorElement.focus();
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
this.handleClose = (shouldReturnFocus = true) => {
|
|
339
413
|
this.setState({
|
|
340
414
|
opened: false
|
|
341
415
|
}, () => {
|
|
342
416
|
var _this$props$onClose, _this$props;
|
|
343
417
|
(_this$props$onClose = (_this$props = this.props).onClose) == null ? void 0 : _this$props$onClose.call(_this$props);
|
|
418
|
+
if (shouldReturnFocus) {
|
|
419
|
+
this.maybeReturnFocus();
|
|
420
|
+
}
|
|
344
421
|
});
|
|
345
422
|
};
|
|
346
423
|
this.handleOpen = () => {
|
|
347
424
|
if (this.props.dismissEnabled && this.state.opened) {
|
|
348
|
-
this.
|
|
349
|
-
opened: false
|
|
350
|
-
});
|
|
425
|
+
this.handleClose(true);
|
|
351
426
|
} else {
|
|
352
427
|
this.setState({
|
|
353
428
|
opened: true
|
package/dist/index.js
CHANGED
|
@@ -164,6 +164,9 @@ const FOCUSABLE_ELEMENTS = 'button, [href], input, select, textarea, [tabindex]:
|
|
|
164
164
|
function findFocusableNodes(root) {
|
|
165
165
|
return Array.from(root.querySelectorAll(FOCUSABLE_ELEMENTS));
|
|
166
166
|
}
|
|
167
|
+
function isFocusable(element) {
|
|
168
|
+
return element.matches(FOCUSABLE_ELEMENTS);
|
|
169
|
+
}
|
|
167
170
|
|
|
168
171
|
class InitialFocus extends React__namespace.Component {
|
|
169
172
|
constructor(...args) {
|
|
@@ -211,17 +214,49 @@ class FocusManager extends React__namespace.Component {
|
|
|
211
214
|
super(...args);
|
|
212
215
|
this.nextElementAfterPopover = void 0;
|
|
213
216
|
this.rootNode = void 0;
|
|
214
|
-
this.
|
|
217
|
+
this.elementsThatCanBeFocusableInsidePopover = [];
|
|
218
|
+
this.firstFocusableElementInPopover = null;
|
|
219
|
+
this.lastFocusableElementInPopover = null;
|
|
215
220
|
this.addEventListeners = () => {
|
|
216
221
|
const {
|
|
217
222
|
anchorElement
|
|
218
223
|
} = this.props;
|
|
219
224
|
if (anchorElement) {
|
|
220
|
-
anchorElement.addEventListener("keydown", this.handleKeydownPreviousFocusableElement
|
|
225
|
+
anchorElement.addEventListener("keydown", this.handleKeydownPreviousFocusableElement);
|
|
226
|
+
}
|
|
227
|
+
if (this.rootNode) {
|
|
228
|
+
this.elementsThatCanBeFocusableInsidePopover = findFocusableNodes(this.rootNode);
|
|
229
|
+
this.firstFocusableElementInPopover = this.elementsThatCanBeFocusableInsidePopover[0];
|
|
230
|
+
this.lastFocusableElementInPopover = this.elementsThatCanBeFocusableInsidePopover[this.elementsThatCanBeFocusableInsidePopover.length - 1];
|
|
221
231
|
}
|
|
222
232
|
this.nextElementAfterPopover = this.getNextFocusableElement();
|
|
233
|
+
if (!this.nextElementAfterPopover) {
|
|
234
|
+
window.addEventListener("blur", () => {
|
|
235
|
+
this.changeFocusabilityInsidePopover(true);
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
if (this.firstFocusableElementInPopover) {
|
|
239
|
+
this.firstFocusableElementInPopover.addEventListener("keydown", this.handleKeydownFirstFocusableElement);
|
|
240
|
+
}
|
|
241
|
+
if (this.lastFocusableElementInPopover) {
|
|
242
|
+
this.lastFocusableElementInPopover.addEventListener("keydown", this.handleKeydownLastFocusableElement);
|
|
243
|
+
}
|
|
223
244
|
if (this.nextElementAfterPopover) {
|
|
224
|
-
this.nextElementAfterPopover.addEventListener("keydown", this.handleKeydownNextFocusableElement
|
|
245
|
+
this.nextElementAfterPopover.addEventListener("keydown", this.handleKeydownNextFocusableElement);
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
this.handleKeydownFirstFocusableElement = e => {
|
|
249
|
+
if (e.key === "Tab" && e.shiftKey) {
|
|
250
|
+
var _this$props$anchorEle;
|
|
251
|
+
e.preventDefault();
|
|
252
|
+
(_this$props$anchorEle = this.props.anchorElement) == null ? void 0 : _this$props$anchorEle.focus();
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
this.handleKeydownLastFocusableElement = e => {
|
|
256
|
+
if (this.nextElementAfterPopover && e.key === "Tab" && !e.shiftKey) {
|
|
257
|
+
var _this$nextElementAfte;
|
|
258
|
+
e.preventDefault();
|
|
259
|
+
(_this$nextElementAfte = this.nextElementAfterPopover) == null ? void 0 : _this$nextElementAfte.focus();
|
|
225
260
|
}
|
|
226
261
|
};
|
|
227
262
|
this.getNextFocusableElement = () => {
|
|
@@ -232,10 +267,14 @@ class FocusManager extends React__namespace.Component {
|
|
|
232
267
|
return;
|
|
233
268
|
}
|
|
234
269
|
const focusableElements = findFocusableNodes(document);
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
270
|
+
const focusableElementsOutside = focusableElements.filter(element => {
|
|
271
|
+
const index = this.elementsThatCanBeFocusableInsidePopover.indexOf(element);
|
|
272
|
+
return index < 0;
|
|
273
|
+
});
|
|
274
|
+
const anchorIndex = focusableElementsOutside.indexOf(anchorElement);
|
|
275
|
+
if (anchorIndex >= 0 && anchorIndex !== focusableElementsOutside.length - 1) {
|
|
276
|
+
const nextElementIndex = anchorIndex < focusableElementsOutside.length - 1 ? anchorIndex + 1 : 0;
|
|
277
|
+
return focusableElementsOutside[nextElementIndex];
|
|
239
278
|
}
|
|
240
279
|
return;
|
|
241
280
|
};
|
|
@@ -248,13 +287,18 @@ class FocusManager extends React__namespace.Component {
|
|
|
248
287
|
throw new Error("Assertion error: root node should exist after mount");
|
|
249
288
|
}
|
|
250
289
|
this.rootNode = rootNode;
|
|
251
|
-
this.focusableElementsInPopover = findFocusableNodes(this.rootNode);
|
|
252
290
|
};
|
|
253
291
|
this.handleFocusPreviousFocusableElement = () => {
|
|
254
292
|
if (this.props.anchorElement) {
|
|
255
293
|
this.props.anchorElement.focus();
|
|
256
294
|
}
|
|
257
295
|
};
|
|
296
|
+
this.changeFocusabilityInsidePopover = (enabled = true) => {
|
|
297
|
+
const tabIndex = enabled ? "0" : "-1";
|
|
298
|
+
this.elementsThatCanBeFocusableInsidePopover.forEach(element => {
|
|
299
|
+
element.setAttribute("tabIndex", tabIndex);
|
|
300
|
+
});
|
|
301
|
+
};
|
|
258
302
|
this.handleFocusNextFocusableElement = () => {
|
|
259
303
|
if (this.nextElementAfterPopover) {
|
|
260
304
|
this.nextElementAfterPopover.focus();
|
|
@@ -262,15 +306,16 @@ class FocusManager extends React__namespace.Component {
|
|
|
262
306
|
};
|
|
263
307
|
this.handleKeydownPreviousFocusableElement = e => {
|
|
264
308
|
if (e.key === "Tab" && !e.shiftKey) {
|
|
309
|
+
var _this$firstFocusableE;
|
|
265
310
|
e.preventDefault();
|
|
266
|
-
this.
|
|
311
|
+
(_this$firstFocusableE = this.firstFocusableElementInPopover) == null ? void 0 : _this$firstFocusableE.focus();
|
|
267
312
|
}
|
|
268
313
|
};
|
|
269
314
|
this.handleKeydownNextFocusableElement = e => {
|
|
270
315
|
if (e.key === "Tab" && e.shiftKey) {
|
|
316
|
+
var _this$lastFocusableEl;
|
|
271
317
|
e.preventDefault();
|
|
272
|
-
|
|
273
|
-
this.focusableElementsInPopover[lastElementIndex].focus();
|
|
318
|
+
(_this$lastFocusableEl = this.lastFocusableElementInPopover) == null ? void 0 : _this$lastFocusableEl.focus();
|
|
274
319
|
}
|
|
275
320
|
};
|
|
276
321
|
}
|
|
@@ -278,41 +323,53 @@ class FocusManager extends React__namespace.Component {
|
|
|
278
323
|
this.addEventListeners();
|
|
279
324
|
}
|
|
280
325
|
componentDidUpdate() {
|
|
326
|
+
this.removeEventListeners();
|
|
281
327
|
this.addEventListeners();
|
|
282
328
|
}
|
|
283
329
|
componentWillUnmount() {
|
|
330
|
+
this.changeFocusabilityInsidePopover(true);
|
|
331
|
+
this.removeEventListeners();
|
|
332
|
+
}
|
|
333
|
+
removeEventListeners() {
|
|
284
334
|
const {
|
|
285
335
|
anchorElement
|
|
286
336
|
} = this.props;
|
|
287
337
|
if (anchorElement) {
|
|
288
|
-
|
|
289
|
-
|
|
338
|
+
anchorElement.removeEventListener("keydown", this.handleKeydownPreviousFocusableElement);
|
|
339
|
+
}
|
|
340
|
+
if (!this.nextElementAfterPopover) {
|
|
341
|
+
window.removeEventListener("blur", () => {
|
|
342
|
+
this.changeFocusabilityInsidePopover(true);
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
if (this.firstFocusableElementInPopover) {
|
|
346
|
+
this.firstFocusableElementInPopover.removeEventListener("keydown", this.handleKeydownFirstFocusableElement);
|
|
347
|
+
}
|
|
348
|
+
if (this.lastFocusableElementInPopover) {
|
|
349
|
+
this.lastFocusableElementInPopover.removeEventListener("keydown", this.handleKeydownLastFocusableElement);
|
|
290
350
|
}
|
|
291
351
|
if (this.nextElementAfterPopover) {
|
|
292
|
-
this.nextElementAfterPopover.removeEventListener("keydown", this.handleKeydownNextFocusableElement
|
|
352
|
+
this.nextElementAfterPopover.removeEventListener("keydown", this.handleKeydownNextFocusableElement);
|
|
293
353
|
}
|
|
294
354
|
}
|
|
295
355
|
render() {
|
|
296
356
|
const {
|
|
297
357
|
children
|
|
298
358
|
} = this.props;
|
|
299
|
-
return React__namespace.createElement(
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
359
|
+
return React__namespace.createElement("div", {
|
|
360
|
+
ref: this.getComponentRootNode,
|
|
361
|
+
onClick: () => {
|
|
362
|
+
this.changeFocusabilityInsidePopover(true);
|
|
363
|
+
},
|
|
364
|
+
onFocus: () => {
|
|
365
|
+
this.changeFocusabilityInsidePopover(true);
|
|
366
|
+
},
|
|
367
|
+
onBlur: () => {
|
|
368
|
+
this.changeFocusabilityInsidePopover(false);
|
|
304
369
|
}
|
|
305
|
-
}), React__namespace.createElement("div", {
|
|
306
|
-
ref: this.getComponentRootNode
|
|
307
370
|
}, React__namespace.createElement(InitialFocus, {
|
|
308
371
|
initialFocusId: this.props.initialFocusId
|
|
309
|
-
}, children))
|
|
310
|
-
tabIndex: 0,
|
|
311
|
-
onFocus: this.handleFocusNextFocusableElement,
|
|
312
|
-
style: {
|
|
313
|
-
position: "fixed"
|
|
314
|
-
}
|
|
315
|
-
}));
|
|
372
|
+
}, children));
|
|
316
373
|
}
|
|
317
374
|
}
|
|
318
375
|
|
|
@@ -326,7 +383,7 @@ class PopoverEventListener extends React__namespace.Component {
|
|
|
326
383
|
if (e.key === "Escape") {
|
|
327
384
|
e.preventDefault();
|
|
328
385
|
e.stopPropagation();
|
|
329
|
-
this.props.onClose();
|
|
386
|
+
this.props.onClose(true);
|
|
330
387
|
}
|
|
331
388
|
};
|
|
332
389
|
this._handleClick = e => {
|
|
@@ -341,7 +398,8 @@ class PopoverEventListener extends React__namespace.Component {
|
|
|
341
398
|
if (node && !node.contains(e.target)) {
|
|
342
399
|
e.preventDefault();
|
|
343
400
|
e.stopPropagation();
|
|
344
|
-
|
|
401
|
+
const shouldReturnFocus = !isFocusable(e.target);
|
|
402
|
+
this.props.onClose(shouldReturnFocus);
|
|
345
403
|
}
|
|
346
404
|
};
|
|
347
405
|
}
|
|
@@ -366,19 +424,36 @@ class Popover extends React__namespace.Component {
|
|
|
366
424
|
placement: this.props.placement
|
|
367
425
|
};
|
|
368
426
|
this.contentRef = React__namespace.createRef();
|
|
369
|
-
this.
|
|
427
|
+
this.maybeReturnFocus = () => {
|
|
428
|
+
const {
|
|
429
|
+
anchorElement
|
|
430
|
+
} = this.state;
|
|
431
|
+
const {
|
|
432
|
+
closedFocusId
|
|
433
|
+
} = this.props;
|
|
434
|
+
if (closedFocusId) {
|
|
435
|
+
const focusElement = ReactDOM__namespace.findDOMNode(document.getElementById(closedFocusId));
|
|
436
|
+
focusElement == null ? void 0 : focusElement.focus();
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
if (anchorElement) {
|
|
440
|
+
anchorElement.focus();
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
this.handleClose = (shouldReturnFocus = true) => {
|
|
370
444
|
this.setState({
|
|
371
445
|
opened: false
|
|
372
446
|
}, () => {
|
|
373
447
|
var _this$props$onClose, _this$props;
|
|
374
448
|
(_this$props$onClose = (_this$props = this.props).onClose) == null ? void 0 : _this$props$onClose.call(_this$props);
|
|
449
|
+
if (shouldReturnFocus) {
|
|
450
|
+
this.maybeReturnFocus();
|
|
451
|
+
}
|
|
375
452
|
});
|
|
376
453
|
};
|
|
377
454
|
this.handleOpen = () => {
|
|
378
455
|
if (this.props.dismissEnabled && this.state.opened) {
|
|
379
|
-
this.
|
|
380
|
-
opened: false
|
|
381
|
-
});
|
|
456
|
+
this.handleClose(true);
|
|
382
457
|
} else {
|
|
383
458
|
this.setState({
|
|
384
459
|
opened: true
|
package/dist/util/util.d.ts
CHANGED
|
@@ -1 +1,6 @@
|
|
|
1
1
|
export declare function findFocusableNodes(root: HTMLElement | Document): Array<HTMLElement>;
|
|
2
|
+
/**
|
|
3
|
+
* Checks if an element is focusable
|
|
4
|
+
* @see https://html.spec.whatwg.org/multipage/interaction.html#focusable-area
|
|
5
|
+
*/
|
|
6
|
+
export declare function isFocusable(element: HTMLElement): boolean;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@khanacademy/wonder-blocks-popover",
|
|
3
|
-
"version": "3.0
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"design": "v1",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -18,10 +18,10 @@
|
|
|
18
18
|
"@babel/runtime": "^7.18.6",
|
|
19
19
|
"@khanacademy/wonder-blocks-color": "^3.0.0",
|
|
20
20
|
"@khanacademy/wonder-blocks-core": "^6.3.1",
|
|
21
|
-
"@khanacademy/wonder-blocks-icon-button": "^5.1.
|
|
22
|
-
"@khanacademy/wonder-blocks-modal": "^4.2.
|
|
21
|
+
"@khanacademy/wonder-blocks-icon-button": "^5.1.8",
|
|
22
|
+
"@khanacademy/wonder-blocks-modal": "^4.2.1",
|
|
23
23
|
"@khanacademy/wonder-blocks-spacing": "^4.0.1",
|
|
24
|
-
"@khanacademy/wonder-blocks-tooltip": "^2.1.
|
|
24
|
+
"@khanacademy/wonder-blocks-tooltip": "^2.1.25",
|
|
25
25
|
"@khanacademy/wonder-blocks-typography": "^2.1.10"
|
|
26
26
|
},
|
|
27
27
|
"peerDependencies": {
|