@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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # @khanacademy/wonder-blocks-popover
2
2
 
3
+ ## 3.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 60aba5b8: Update internal spacing references (from wb-spacing to wb-tokens)
8
+ - 7c51f377: Migrate wb-color imports to use tokens.color
9
+ - Updated dependencies [60aba5b8]
10
+ - Updated dependencies [7cd7f6cc]
11
+ - Updated dependencies [7c51f377]
12
+ - Updated dependencies [7cd7f6cc]
13
+ - Updated dependencies [7c51f377]
14
+ - @khanacademy/wonder-blocks-tooltip@2.1.26
15
+ - @khanacademy/wonder-blocks-modal@4.2.2
16
+ - @khanacademy/wonder-blocks-tokens@0.2.0
17
+ - @khanacademy/wonder-blocks-icon-button@5.1.9
18
+
19
+ ## 3.1.0
20
+
21
+ ### Minor Changes
22
+
23
+ - 7944c7d3: Add `closedFocusId` prop to manually return focus to a specific element
24
+
25
+ ### Patch Changes
26
+
27
+ - e5dd6215: Don't return focus to trigger element if the focus has to go to a different interactive element
28
+ - 163cfca3: Fix tab navigation order
29
+
3
30
  ## 3.0.23
4
31
 
5
32
  ### 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
- focusableElementsInPopover: Array<HTMLElement>;
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.
@@ -5,7 +5,7 @@ type Props = {
5
5
  /**
6
6
  * Called when `esc` is pressed
7
7
  */
8
- onClose: () => unknown;
8
+ onClose: (shouldReturnFocus: boolean) => unknown;
9
9
  /**
10
10
  * Popover Content ref.
11
11
  * Will close the popover when clicking outside this 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
@@ -4,9 +4,8 @@ import { View, IDProvider, addStyle } from '@khanacademy/wonder-blocks-core';
4
4
  import { TooltipTail, TooltipPopper } from '@khanacademy/wonder-blocks-tooltip';
5
5
  import { maybeGetPortalMountedModalHostElement } from '@khanacademy/wonder-blocks-modal';
6
6
  import { StyleSheet } from 'aphrodite';
7
- import Spacing from '@khanacademy/wonder-blocks-spacing';
7
+ import { spacing, color } from '@khanacademy/wonder-blocks-tokens';
8
8
  import { HeadingSmall, Body } from '@khanacademy/wonder-blocks-typography';
9
- import Colors from '@khanacademy/wonder-blocks-color';
10
9
  import xIcon from '@phosphor-icons/core/regular/x.svg';
11
10
  import IconButton from '@khanacademy/wonder-blocks-icon-button';
12
11
 
@@ -133,6 +132,9 @@ const FOCUSABLE_ELEMENTS = 'button, [href], input, select, textarea, [tabindex]:
133
132
  function findFocusableNodes(root) {
134
133
  return Array.from(root.querySelectorAll(FOCUSABLE_ELEMENTS));
135
134
  }
135
+ function isFocusable(element) {
136
+ return element.matches(FOCUSABLE_ELEMENTS);
137
+ }
136
138
 
137
139
  class InitialFocus extends React.Component {
138
140
  constructor(...args) {
@@ -180,17 +182,49 @@ class FocusManager extends React.Component {
180
182
  super(...args);
181
183
  this.nextElementAfterPopover = void 0;
182
184
  this.rootNode = void 0;
183
- this.focusableElementsInPopover = [];
185
+ this.elementsThatCanBeFocusableInsidePopover = [];
186
+ this.firstFocusableElementInPopover = null;
187
+ this.lastFocusableElementInPopover = null;
184
188
  this.addEventListeners = () => {
185
189
  const {
186
190
  anchorElement
187
191
  } = this.props;
188
192
  if (anchorElement) {
189
- anchorElement.addEventListener("keydown", this.handleKeydownPreviousFocusableElement, true);
193
+ anchorElement.addEventListener("keydown", this.handleKeydownPreviousFocusableElement);
194
+ }
195
+ if (this.rootNode) {
196
+ this.elementsThatCanBeFocusableInsidePopover = findFocusableNodes(this.rootNode);
197
+ this.firstFocusableElementInPopover = this.elementsThatCanBeFocusableInsidePopover[0];
198
+ this.lastFocusableElementInPopover = this.elementsThatCanBeFocusableInsidePopover[this.elementsThatCanBeFocusableInsidePopover.length - 1];
190
199
  }
191
200
  this.nextElementAfterPopover = this.getNextFocusableElement();
201
+ if (!this.nextElementAfterPopover) {
202
+ window.addEventListener("blur", () => {
203
+ this.changeFocusabilityInsidePopover(true);
204
+ });
205
+ }
206
+ if (this.firstFocusableElementInPopover) {
207
+ this.firstFocusableElementInPopover.addEventListener("keydown", this.handleKeydownFirstFocusableElement);
208
+ }
209
+ if (this.lastFocusableElementInPopover) {
210
+ this.lastFocusableElementInPopover.addEventListener("keydown", this.handleKeydownLastFocusableElement);
211
+ }
192
212
  if (this.nextElementAfterPopover) {
193
- this.nextElementAfterPopover.addEventListener("keydown", this.handleKeydownNextFocusableElement, true);
213
+ this.nextElementAfterPopover.addEventListener("keydown", this.handleKeydownNextFocusableElement);
214
+ }
215
+ };
216
+ this.handleKeydownFirstFocusableElement = e => {
217
+ if (e.key === "Tab" && e.shiftKey) {
218
+ var _this$props$anchorEle;
219
+ e.preventDefault();
220
+ (_this$props$anchorEle = this.props.anchorElement) == null ? void 0 : _this$props$anchorEle.focus();
221
+ }
222
+ };
223
+ this.handleKeydownLastFocusableElement = e => {
224
+ if (this.nextElementAfterPopover && e.key === "Tab" && !e.shiftKey) {
225
+ var _this$nextElementAfte;
226
+ e.preventDefault();
227
+ (_this$nextElementAfte = this.nextElementAfterPopover) == null ? void 0 : _this$nextElementAfte.focus();
194
228
  }
195
229
  };
196
230
  this.getNextFocusableElement = () => {
@@ -201,10 +235,14 @@ class FocusManager extends React.Component {
201
235
  return;
202
236
  }
203
237
  const focusableElements = findFocusableNodes(document);
204
- const anchorIndex = focusableElements.indexOf(anchorElement);
205
- if (anchorIndex >= 0) {
206
- const nextElementIndex = anchorIndex < focusableElements.length - 1 ? anchorIndex + 1 : 0;
207
- return focusableElements[nextElementIndex];
238
+ const focusableElementsOutside = focusableElements.filter(element => {
239
+ const index = this.elementsThatCanBeFocusableInsidePopover.indexOf(element);
240
+ return index < 0;
241
+ });
242
+ const anchorIndex = focusableElementsOutside.indexOf(anchorElement);
243
+ if (anchorIndex >= 0 && anchorIndex !== focusableElementsOutside.length - 1) {
244
+ const nextElementIndex = anchorIndex < focusableElementsOutside.length - 1 ? anchorIndex + 1 : 0;
245
+ return focusableElementsOutside[nextElementIndex];
208
246
  }
209
247
  return;
210
248
  };
@@ -217,13 +255,18 @@ class FocusManager extends React.Component {
217
255
  throw new Error("Assertion error: root node should exist after mount");
218
256
  }
219
257
  this.rootNode = rootNode;
220
- this.focusableElementsInPopover = findFocusableNodes(this.rootNode);
221
258
  };
222
259
  this.handleFocusPreviousFocusableElement = () => {
223
260
  if (this.props.anchorElement) {
224
261
  this.props.anchorElement.focus();
225
262
  }
226
263
  };
264
+ this.changeFocusabilityInsidePopover = (enabled = true) => {
265
+ const tabIndex = enabled ? "0" : "-1";
266
+ this.elementsThatCanBeFocusableInsidePopover.forEach(element => {
267
+ element.setAttribute("tabIndex", tabIndex);
268
+ });
269
+ };
227
270
  this.handleFocusNextFocusableElement = () => {
228
271
  if (this.nextElementAfterPopover) {
229
272
  this.nextElementAfterPopover.focus();
@@ -231,15 +274,16 @@ class FocusManager extends React.Component {
231
274
  };
232
275
  this.handleKeydownPreviousFocusableElement = e => {
233
276
  if (e.key === "Tab" && !e.shiftKey) {
277
+ var _this$firstFocusableE;
234
278
  e.preventDefault();
235
- this.focusableElementsInPopover[0].focus();
279
+ (_this$firstFocusableE = this.firstFocusableElementInPopover) == null ? void 0 : _this$firstFocusableE.focus();
236
280
  }
237
281
  };
238
282
  this.handleKeydownNextFocusableElement = e => {
239
283
  if (e.key === "Tab" && e.shiftKey) {
284
+ var _this$lastFocusableEl;
240
285
  e.preventDefault();
241
- const lastElementIndex = this.focusableElementsInPopover.length - 1;
242
- this.focusableElementsInPopover[lastElementIndex].focus();
286
+ (_this$lastFocusableEl = this.lastFocusableElementInPopover) == null ? void 0 : _this$lastFocusableEl.focus();
243
287
  }
244
288
  };
245
289
  }
@@ -247,41 +291,53 @@ class FocusManager extends React.Component {
247
291
  this.addEventListeners();
248
292
  }
249
293
  componentDidUpdate() {
294
+ this.removeEventListeners();
250
295
  this.addEventListeners();
251
296
  }
252
297
  componentWillUnmount() {
298
+ this.changeFocusabilityInsidePopover(true);
299
+ this.removeEventListeners();
300
+ }
301
+ removeEventListeners() {
253
302
  const {
254
303
  anchorElement
255
304
  } = this.props;
256
305
  if (anchorElement) {
257
- setTimeout(() => anchorElement.focus(), 0);
258
- anchorElement.removeEventListener("keydown", this.handleKeydownPreviousFocusableElement, true);
306
+ anchorElement.removeEventListener("keydown", this.handleKeydownPreviousFocusableElement);
307
+ }
308
+ if (!this.nextElementAfterPopover) {
309
+ window.removeEventListener("blur", () => {
310
+ this.changeFocusabilityInsidePopover(true);
311
+ });
312
+ }
313
+ if (this.firstFocusableElementInPopover) {
314
+ this.firstFocusableElementInPopover.removeEventListener("keydown", this.handleKeydownFirstFocusableElement);
315
+ }
316
+ if (this.lastFocusableElementInPopover) {
317
+ this.lastFocusableElementInPopover.removeEventListener("keydown", this.handleKeydownLastFocusableElement);
259
318
  }
260
319
  if (this.nextElementAfterPopover) {
261
- this.nextElementAfterPopover.removeEventListener("keydown", this.handleKeydownNextFocusableElement, true);
320
+ this.nextElementAfterPopover.removeEventListener("keydown", this.handleKeydownNextFocusableElement);
262
321
  }
263
322
  }
264
323
  render() {
265
324
  const {
266
325
  children
267
326
  } = this.props;
268
- return React.createElement(React.Fragment, null, React.createElement("div", {
269
- tabIndex: 0,
270
- onFocus: this.handleFocusPreviousFocusableElement,
271
- style: {
272
- position: "fixed"
327
+ return React.createElement("div", {
328
+ ref: this.getComponentRootNode,
329
+ onClick: () => {
330
+ this.changeFocusabilityInsidePopover(true);
331
+ },
332
+ onFocus: () => {
333
+ this.changeFocusabilityInsidePopover(true);
334
+ },
335
+ onBlur: () => {
336
+ this.changeFocusabilityInsidePopover(false);
273
337
  }
274
- }), React.createElement("div", {
275
- ref: this.getComponentRootNode
276
338
  }, React.createElement(InitialFocus, {
277
339
  initialFocusId: this.props.initialFocusId
278
- }, children)), React.createElement("div", {
279
- tabIndex: 0,
280
- onFocus: this.handleFocusNextFocusableElement,
281
- style: {
282
- position: "fixed"
283
- }
284
- }));
340
+ }, children));
285
341
  }
286
342
  }
287
343
 
@@ -295,7 +351,7 @@ class PopoverEventListener extends React.Component {
295
351
  if (e.key === "Escape") {
296
352
  e.preventDefault();
297
353
  e.stopPropagation();
298
- this.props.onClose();
354
+ this.props.onClose(true);
299
355
  }
300
356
  };
301
357
  this._handleClick = e => {
@@ -310,7 +366,8 @@ class PopoverEventListener extends React.Component {
310
366
  if (node && !node.contains(e.target)) {
311
367
  e.preventDefault();
312
368
  e.stopPropagation();
313
- this.props.onClose();
369
+ const shouldReturnFocus = !isFocusable(e.target);
370
+ this.props.onClose(shouldReturnFocus);
314
371
  }
315
372
  };
316
373
  }
@@ -335,19 +392,36 @@ class Popover extends React.Component {
335
392
  placement: this.props.placement
336
393
  };
337
394
  this.contentRef = React.createRef();
338
- this.handleClose = () => {
395
+ this.maybeReturnFocus = () => {
396
+ const {
397
+ anchorElement
398
+ } = this.state;
399
+ const {
400
+ closedFocusId
401
+ } = this.props;
402
+ if (closedFocusId) {
403
+ const focusElement = ReactDOM.findDOMNode(document.getElementById(closedFocusId));
404
+ focusElement == null ? void 0 : focusElement.focus();
405
+ return;
406
+ }
407
+ if (anchorElement) {
408
+ anchorElement.focus();
409
+ }
410
+ };
411
+ this.handleClose = (shouldReturnFocus = true) => {
339
412
  this.setState({
340
413
  opened: false
341
414
  }, () => {
342
415
  var _this$props$onClose, _this$props;
343
416
  (_this$props$onClose = (_this$props = this.props).onClose) == null ? void 0 : _this$props$onClose.call(_this$props);
417
+ if (shouldReturnFocus) {
418
+ this.maybeReturnFocus();
419
+ }
344
420
  });
345
421
  };
346
422
  this.handleOpen = () => {
347
423
  if (this.props.dismissEnabled && this.state.opened) {
348
- this.setState({
349
- opened: false
350
- });
424
+ this.handleClose(true);
351
425
  } else {
352
426
  this.setState({
353
427
  opened: true
@@ -500,29 +574,29 @@ PopoverContentCore.defaultProps = {
500
574
  };
501
575
  const styles$1 = StyleSheet.create({
502
576
  content: {
503
- borderRadius: Spacing.xxxSmall_4,
504
- border: `solid 1px ${Colors.offBlack16}`,
505
- backgroundColor: Colors.white,
506
- boxShadow: `0 ${Spacing.xSmall_8}px ${Spacing.xSmall_8}px 0 ${Colors.offBlack8}`,
577
+ borderRadius: spacing.xxxSmall_4,
578
+ border: `solid 1px ${color.offBlack16}`,
579
+ backgroundColor: color.white,
580
+ boxShadow: `0 ${spacing.xSmall_8}px ${spacing.xSmall_8}px 0 ${color.offBlack8}`,
507
581
  margin: 0,
508
- maxWidth: Spacing.medium_16 * 18,
509
- padding: Spacing.large_24,
582
+ maxWidth: spacing.medium_16 * 18,
583
+ padding: spacing.large_24,
510
584
  overflow: "hidden",
511
585
  justifyContent: "center"
512
586
  },
513
587
  blue: {
514
- backgroundColor: Colors.blue,
515
- color: Colors.white
588
+ backgroundColor: color.blue,
589
+ color: color.white
516
590
  },
517
591
  darkBlue: {
518
- backgroundColor: Colors.darkBlue,
519
- color: Colors.white
592
+ backgroundColor: color.darkBlue,
593
+ color: color.white
520
594
  },
521
595
  closeButton: {
522
596
  margin: 0,
523
597
  position: "absolute",
524
- right: Spacing.xxxSmall_4,
525
- top: Spacing.xxxSmall_4,
598
+ right: spacing.xxxSmall_4,
599
+ top: spacing.xxxSmall_4,
526
600
  zIndex: 1
527
601
  }
528
602
  });
@@ -636,7 +710,7 @@ PopoverContent.defaultProps = {
636
710
  };
637
711
  const styles = StyleSheet.create({
638
712
  actions: {
639
- marginTop: Spacing.large_24,
713
+ marginTop: spacing.large_24,
640
714
  flexDirection: "row",
641
715
  alignItems: "center",
642
716
  justifyContent: "flex-end"
@@ -645,15 +719,15 @@ const styles = StyleSheet.create({
645
719
  justifyContent: "center"
646
720
  },
647
721
  title: {
648
- marginBottom: Spacing.xSmall_8
722
+ marginBottom: spacing.xSmall_8
649
723
  },
650
724
  iconContainer: {
651
725
  alignItems: "center",
652
726
  justifyContent: "center",
653
- height: Spacing.xxxLarge_64,
654
- width: Spacing.xxxLarge_64,
655
- minWidth: Spacing.xxxLarge_64,
656
- marginRight: Spacing.medium_16,
727
+ height: spacing.xxxLarge_64,
728
+ width: spacing.xxxLarge_64,
729
+ minWidth: spacing.xxxLarge_64,
730
+ marginRight: spacing.medium_16,
657
731
  overflow: "hidden"
658
732
  },
659
733
  icon: {
@@ -663,15 +737,15 @@ const styles = StyleSheet.create({
663
737
  flexDirection: "row"
664
738
  },
665
739
  image: {
666
- marginBottom: Spacing.large_24,
667
- marginLeft: -Spacing.large_24,
668
- marginRight: -Spacing.large_24,
669
- marginTop: -Spacing.large_24,
670
- width: `calc(100% + ${Spacing.large_24 * 2}px)`
740
+ marginBottom: spacing.large_24,
741
+ marginLeft: -spacing.large_24,
742
+ marginRight: -spacing.large_24,
743
+ marginTop: -spacing.large_24,
744
+ width: `calc(100% + ${spacing.large_24 * 2}px)`
671
745
  },
672
746
  imageToBottom: {
673
- marginBottom: -Spacing.large_24,
674
- marginTop: Spacing.large_24,
747
+ marginBottom: -spacing.large_24,
748
+ marginTop: spacing.large_24,
675
749
  order: 1
676
750
  }
677
751
  });