@khanacademy/wonder-blocks-popover 3.2.8 → 3.2.10

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,19 @@
1
1
  # @khanacademy/wonder-blocks-popover
2
2
 
3
+ ## 3.2.10
4
+
5
+ ### Patch Changes
6
+
7
+ - 68dd6059: adds optional aria label for popover
8
+ - Updated dependencies [be540444]
9
+ - @khanacademy/wonder-blocks-tooltip@2.3.8
10
+
11
+ ## 3.2.9
12
+
13
+ ### Patch Changes
14
+
15
+ - 47d680f4: Removed usage of focus management in Popover component, in favor of a similar implementation that won't override custom keyboard navigation.
16
+
3
17
  ## 3.2.8
4
18
 
5
19
  ### Patch Changes
@@ -81,6 +81,20 @@ type Props = AriaProps & Readonly<{
81
81
  * Whether to show the popover tail or not. Defaults to true.
82
82
  */
83
83
  showTail: boolean;
84
+ /**
85
+ * Optional property to enable the portal functionality of popover.
86
+ * This is very handy in cases where the Popover can't be easily
87
+ * injected into the DOM structure and requires portaling to
88
+ * the trigger location.
89
+ *
90
+ * Set to "true" by default.
91
+ *
92
+ * CAUTION: Turning off portal could cause some clipping issues
93
+ * especially around legacy code with usage of z-indexing,
94
+ * Use caution when turning this functionality off and ensure
95
+ * your content does not get clipped or hidden.
96
+ */
97
+ portal?: boolean;
84
98
  }>;
85
99
  type State = Readonly<{
86
100
  /**
@@ -99,6 +113,7 @@ type State = Readonly<{
99
113
  type DefaultProps = Readonly<{
100
114
  placement: Props["placement"];
101
115
  showTail: Props["showTail"];
116
+ portal: Props["portal"];
102
117
  }>;
103
118
  /**
104
119
  * Popovers provide additional information that is related to a particular
@@ -151,6 +166,7 @@ export default class Popover extends React.Component<Props, State> {
151
166
  renderContent(uniqueId: string): PopoverContents;
152
167
  renderPopper(uniqueId: string): React.ReactNode;
153
168
  getHost(): Element | null | undefined;
169
+ renderPortal(uniqueId: string, opened: boolean): React.ReactNode;
154
170
  render(): React.ReactNode;
155
171
  }
156
172
  export {};
package/dist/es/index.js CHANGED
@@ -72,11 +72,13 @@ class PopoverDialog extends React.Component {
72
72
  style,
73
73
  showTail,
74
74
  "aria-describedby": ariaDescribedby,
75
- "aria-labelledby": ariaLabelledBy
75
+ "aria-labelledby": ariaLabelledBy,
76
+ "aria-label": ariaLabel
76
77
  } = this.props;
77
78
  const contentProps = children.props;
78
79
  const color = contentProps.emphasized ? "blue" : contentProps.color;
79
80
  return React.createElement(React.Fragment, null, React.createElement(View, {
81
+ "aria-label": ariaLabel,
80
82
  "aria-describedby": ariaDescribedby,
81
83
  "aria-labelledby": ariaLabelledBy,
82
84
  id: id,
@@ -122,6 +124,49 @@ function isFocusable(element) {
122
124
  return element.matches(FOCUSABLE_ELEMENTS);
123
125
  }
124
126
 
127
+ class PopoverEventListener extends React.Component {
128
+ constructor(...args) {
129
+ super(...args);
130
+ this.state = {
131
+ isFirstClick: true
132
+ };
133
+ this._handleKeyup = e => {
134
+ if (e.key === "Escape") {
135
+ e.preventDefault();
136
+ e.stopPropagation();
137
+ this.props.onClose(true);
138
+ }
139
+ };
140
+ this._handleClick = e => {
141
+ var _this$props$contentRe;
142
+ if (this.state.isFirstClick) {
143
+ this.setState({
144
+ isFirstClick: false
145
+ });
146
+ return;
147
+ }
148
+ const node = ReactDOM.findDOMNode((_this$props$contentRe = this.props.contentRef) == null ? void 0 : _this$props$contentRe.current);
149
+ if (node && !node.contains(e.target)) {
150
+ e.preventDefault();
151
+ e.stopPropagation();
152
+ const shouldReturnFocus = !isFocusable(e.target);
153
+ this.props.onClose(shouldReturnFocus);
154
+ }
155
+ };
156
+ }
157
+ componentDidMount() {
158
+ window.addEventListener("keyup", this._handleKeyup);
159
+ window.addEventListener("click", this._handleClick);
160
+ }
161
+ componentWillUnmount() {
162
+ window.removeEventListener("keyup", this._handleKeyup);
163
+ window.removeEventListener("click", this._handleClick);
164
+ }
165
+ render() {
166
+ return null;
167
+ }
168
+ }
169
+
125
170
  class InitialFocus extends React.Component {
126
171
  constructor(...args) {
127
172
  super(...args);
@@ -327,49 +372,6 @@ class FocusManager extends React.Component {
327
372
  }
328
373
  }
329
374
 
330
- class PopoverEventListener extends React.Component {
331
- constructor(...args) {
332
- super(...args);
333
- this.state = {
334
- isFirstClick: true
335
- };
336
- this._handleKeyup = e => {
337
- if (e.key === "Escape") {
338
- e.preventDefault();
339
- e.stopPropagation();
340
- this.props.onClose(true);
341
- }
342
- };
343
- this._handleClick = e => {
344
- var _this$props$contentRe;
345
- if (this.state.isFirstClick) {
346
- this.setState({
347
- isFirstClick: false
348
- });
349
- return;
350
- }
351
- const node = ReactDOM.findDOMNode((_this$props$contentRe = this.props.contentRef) == null ? void 0 : _this$props$contentRe.current);
352
- if (node && !node.contains(e.target)) {
353
- e.preventDefault();
354
- e.stopPropagation();
355
- const shouldReturnFocus = !isFocusable(e.target);
356
- this.props.onClose(shouldReturnFocus);
357
- }
358
- };
359
- }
360
- componentDidMount() {
361
- window.addEventListener("keyup", this._handleKeyup);
362
- window.addEventListener("click", this._handleClick);
363
- }
364
- componentWillUnmount() {
365
- window.removeEventListener("keyup", this._handleKeyup);
366
- window.removeEventListener("click", this._handleClick);
367
- }
368
- render() {
369
- return null;
370
- }
371
- }
372
-
373
375
  class Popover extends React.Component {
374
376
  constructor(...args) {
375
377
  super(...args);
@@ -443,30 +445,55 @@ class Popover extends React.Component {
443
445
  const {
444
446
  initialFocusId,
445
447
  placement,
446
- showTail
448
+ showTail,
449
+ portal,
450
+ "aria-label": ariaLabel
447
451
  } = this.props;
448
452
  const {
449
453
  anchorElement
450
454
  } = this.state;
451
- return React.createElement(FocusManager, {
452
- anchorElement: anchorElement,
453
- initialFocusId: initialFocusId
454
- }, React.createElement(TooltipPopper, {
455
+ const ariaDescribedBy = ariaLabel ? undefined : `${uniqueId}-content`;
456
+ const ariaLabelledBy = ariaLabel ? undefined : `${uniqueId}-title`;
457
+ const popperContent = React.createElement(TooltipPopper, {
455
458
  anchorElement: anchorElement,
456
459
  placement: placement
457
460
  }, props => React.createElement(PopoverDialog, _extends({}, props, {
458
- "aria-describedby": `${uniqueId}-content`,
459
- "aria-labelledby": `${uniqueId}-title`,
461
+ "aria-label": ariaLabel,
462
+ "aria-describedby": ariaDescribedBy,
463
+ "aria-labelledby": ariaLabelledBy,
460
464
  id: uniqueId,
461
465
  onUpdate: placement => this.setState({
462
466
  placement
463
467
  }),
464
468
  showTail: showTail
465
- }), this.renderContent(uniqueId))));
469
+ }), this.renderContent(uniqueId)));
470
+ if (portal) {
471
+ return React.createElement(FocusManager, {
472
+ anchorElement: anchorElement,
473
+ initialFocusId: initialFocusId
474
+ }, popperContent);
475
+ } else {
476
+ return React.createElement(InitialFocus, {
477
+ initialFocusId: initialFocusId
478
+ }, popperContent);
479
+ }
466
480
  }
467
481
  getHost() {
468
482
  return maybeGetPortalMountedModalHostElement(this.state.anchorElement) || document.body;
469
483
  }
484
+ renderPortal(uniqueId, opened) {
485
+ if (!opened) {
486
+ return null;
487
+ }
488
+ const {
489
+ portal
490
+ } = this.props;
491
+ const popperHost = this.getHost();
492
+ if (portal && popperHost) {
493
+ return ReactDOM.createPortal(this.renderPopper(uniqueId), popperHost);
494
+ }
495
+ return this.renderPopper(uniqueId);
496
+ }
470
497
  render() {
471
498
  const {
472
499
  children,
@@ -477,7 +504,6 @@ class Popover extends React.Component {
477
504
  opened,
478
505
  placement
479
506
  } = this.state;
480
- const popperHost = this.getHost();
481
507
  return React.createElement(PopoverContext.Provider, {
482
508
  value: {
483
509
  close: this.handleClose,
@@ -492,7 +518,7 @@ class Popover extends React.Component {
492
518
  "aria-controls": uniqueId,
493
519
  "aria-expanded": opened ? "true" : "false",
494
520
  onClick: this.handleOpen
495
- }, children), popperHost && opened && ReactDOM.createPortal(this.renderPopper(uniqueId), popperHost))), dismissEnabled && opened && React.createElement(PopoverEventListener, {
521
+ }, children), this.renderPortal(uniqueId, opened))), dismissEnabled && opened && React.createElement(PopoverEventListener, {
496
522
  onClose: this.handleClose,
497
523
  contentRef: this.contentRef
498
524
  }));
@@ -500,7 +526,8 @@ class Popover extends React.Component {
500
526
  }
501
527
  Popover.defaultProps = {
502
528
  placement: "top",
503
- showTail: true
529
+ showTail: true,
530
+ portal: true
504
531
  };
505
532
 
506
533
  class CloseButton extends React.Component {
package/dist/index.js CHANGED
@@ -102,11 +102,13 @@ class PopoverDialog extends React__namespace.Component {
102
102
  style,
103
103
  showTail,
104
104
  "aria-describedby": ariaDescribedby,
105
- "aria-labelledby": ariaLabelledBy
105
+ "aria-labelledby": ariaLabelledBy,
106
+ "aria-label": ariaLabel
106
107
  } = this.props;
107
108
  const contentProps = children.props;
108
109
  const color = contentProps.emphasized ? "blue" : contentProps.color;
109
110
  return React__namespace.createElement(React__namespace.Fragment, null, React__namespace.createElement(wonderBlocksCore.View, {
111
+ "aria-label": ariaLabel,
110
112
  "aria-describedby": ariaDescribedby,
111
113
  "aria-labelledby": ariaLabelledBy,
112
114
  id: id,
@@ -152,6 +154,49 @@ function isFocusable(element) {
152
154
  return element.matches(FOCUSABLE_ELEMENTS);
153
155
  }
154
156
 
157
+ class PopoverEventListener extends React__namespace.Component {
158
+ constructor(...args) {
159
+ super(...args);
160
+ this.state = {
161
+ isFirstClick: true
162
+ };
163
+ this._handleKeyup = e => {
164
+ if (e.key === "Escape") {
165
+ e.preventDefault();
166
+ e.stopPropagation();
167
+ this.props.onClose(true);
168
+ }
169
+ };
170
+ this._handleClick = e => {
171
+ var _this$props$contentRe;
172
+ if (this.state.isFirstClick) {
173
+ this.setState({
174
+ isFirstClick: false
175
+ });
176
+ return;
177
+ }
178
+ const node = ReactDOM__namespace.findDOMNode((_this$props$contentRe = this.props.contentRef) == null ? void 0 : _this$props$contentRe.current);
179
+ if (node && !node.contains(e.target)) {
180
+ e.preventDefault();
181
+ e.stopPropagation();
182
+ const shouldReturnFocus = !isFocusable(e.target);
183
+ this.props.onClose(shouldReturnFocus);
184
+ }
185
+ };
186
+ }
187
+ componentDidMount() {
188
+ window.addEventListener("keyup", this._handleKeyup);
189
+ window.addEventListener("click", this._handleClick);
190
+ }
191
+ componentWillUnmount() {
192
+ window.removeEventListener("keyup", this._handleKeyup);
193
+ window.removeEventListener("click", this._handleClick);
194
+ }
195
+ render() {
196
+ return null;
197
+ }
198
+ }
199
+
155
200
  class InitialFocus extends React__namespace.Component {
156
201
  constructor(...args) {
157
202
  super(...args);
@@ -357,49 +402,6 @@ class FocusManager extends React__namespace.Component {
357
402
  }
358
403
  }
359
404
 
360
- class PopoverEventListener extends React__namespace.Component {
361
- constructor(...args) {
362
- super(...args);
363
- this.state = {
364
- isFirstClick: true
365
- };
366
- this._handleKeyup = e => {
367
- if (e.key === "Escape") {
368
- e.preventDefault();
369
- e.stopPropagation();
370
- this.props.onClose(true);
371
- }
372
- };
373
- this._handleClick = e => {
374
- var _this$props$contentRe;
375
- if (this.state.isFirstClick) {
376
- this.setState({
377
- isFirstClick: false
378
- });
379
- return;
380
- }
381
- const node = ReactDOM__namespace.findDOMNode((_this$props$contentRe = this.props.contentRef) == null ? void 0 : _this$props$contentRe.current);
382
- if (node && !node.contains(e.target)) {
383
- e.preventDefault();
384
- e.stopPropagation();
385
- const shouldReturnFocus = !isFocusable(e.target);
386
- this.props.onClose(shouldReturnFocus);
387
- }
388
- };
389
- }
390
- componentDidMount() {
391
- window.addEventListener("keyup", this._handleKeyup);
392
- window.addEventListener("click", this._handleClick);
393
- }
394
- componentWillUnmount() {
395
- window.removeEventListener("keyup", this._handleKeyup);
396
- window.removeEventListener("click", this._handleClick);
397
- }
398
- render() {
399
- return null;
400
- }
401
- }
402
-
403
405
  class Popover extends React__namespace.Component {
404
406
  constructor(...args) {
405
407
  super(...args);
@@ -473,30 +475,55 @@ class Popover extends React__namespace.Component {
473
475
  const {
474
476
  initialFocusId,
475
477
  placement,
476
- showTail
478
+ showTail,
479
+ portal,
480
+ "aria-label": ariaLabel
477
481
  } = this.props;
478
482
  const {
479
483
  anchorElement
480
484
  } = this.state;
481
- return React__namespace.createElement(FocusManager, {
482
- anchorElement: anchorElement,
483
- initialFocusId: initialFocusId
484
- }, React__namespace.createElement(wonderBlocksTooltip.TooltipPopper, {
485
+ const ariaDescribedBy = ariaLabel ? undefined : `${uniqueId}-content`;
486
+ const ariaLabelledBy = ariaLabel ? undefined : `${uniqueId}-title`;
487
+ const popperContent = React__namespace.createElement(wonderBlocksTooltip.TooltipPopper, {
485
488
  anchorElement: anchorElement,
486
489
  placement: placement
487
490
  }, props => React__namespace.createElement(PopoverDialog, _extends__default["default"]({}, props, {
488
- "aria-describedby": `${uniqueId}-content`,
489
- "aria-labelledby": `${uniqueId}-title`,
491
+ "aria-label": ariaLabel,
492
+ "aria-describedby": ariaDescribedBy,
493
+ "aria-labelledby": ariaLabelledBy,
490
494
  id: uniqueId,
491
495
  onUpdate: placement => this.setState({
492
496
  placement
493
497
  }),
494
498
  showTail: showTail
495
- }), this.renderContent(uniqueId))));
499
+ }), this.renderContent(uniqueId)));
500
+ if (portal) {
501
+ return React__namespace.createElement(FocusManager, {
502
+ anchorElement: anchorElement,
503
+ initialFocusId: initialFocusId
504
+ }, popperContent);
505
+ } else {
506
+ return React__namespace.createElement(InitialFocus, {
507
+ initialFocusId: initialFocusId
508
+ }, popperContent);
509
+ }
496
510
  }
497
511
  getHost() {
498
512
  return wonderBlocksModal.maybeGetPortalMountedModalHostElement(this.state.anchorElement) || document.body;
499
513
  }
514
+ renderPortal(uniqueId, opened) {
515
+ if (!opened) {
516
+ return null;
517
+ }
518
+ const {
519
+ portal
520
+ } = this.props;
521
+ const popperHost = this.getHost();
522
+ if (portal && popperHost) {
523
+ return ReactDOM__namespace.createPortal(this.renderPopper(uniqueId), popperHost);
524
+ }
525
+ return this.renderPopper(uniqueId);
526
+ }
500
527
  render() {
501
528
  const {
502
529
  children,
@@ -507,7 +534,6 @@ class Popover extends React__namespace.Component {
507
534
  opened,
508
535
  placement
509
536
  } = this.state;
510
- const popperHost = this.getHost();
511
537
  return React__namespace.createElement(PopoverContext.Provider, {
512
538
  value: {
513
539
  close: this.handleClose,
@@ -522,7 +548,7 @@ class Popover extends React__namespace.Component {
522
548
  "aria-controls": uniqueId,
523
549
  "aria-expanded": opened ? "true" : "false",
524
550
  onClick: this.handleOpen
525
- }, children), popperHost && opened && ReactDOM__namespace.createPortal(this.renderPopper(uniqueId), popperHost))), dismissEnabled && opened && React__namespace.createElement(PopoverEventListener, {
551
+ }, children), this.renderPortal(uniqueId, opened))), dismissEnabled && opened && React__namespace.createElement(PopoverEventListener, {
526
552
  onClose: this.handleClose,
527
553
  contentRef: this.contentRef
528
554
  }));
@@ -530,7 +556,8 @@ class Popover extends React__namespace.Component {
530
556
  }
531
557
  Popover.defaultProps = {
532
558
  placement: "top",
533
- showTail: true
559
+ showTail: true,
560
+ portal: true
534
561
  };
535
562
 
536
563
  class CloseButton extends React__namespace.Component {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@khanacademy/wonder-blocks-popover",
3
- "version": "3.2.8",
3
+ "version": "3.2.10",
4
4
  "design": "v1",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -20,7 +20,7 @@
20
20
  "@khanacademy/wonder-blocks-icon-button": "^5.3.3",
21
21
  "@khanacademy/wonder-blocks-modal": "^5.1.8",
22
22
  "@khanacademy/wonder-blocks-tokens": "^1.3.1",
23
- "@khanacademy/wonder-blocks-tooltip": "^2.3.7",
23
+ "@khanacademy/wonder-blocks-tooltip": "^2.3.8",
24
24
  "@khanacademy/wonder-blocks-typography": "^2.1.14"
25
25
  },
26
26
  "peerDependencies": {
@@ -583,6 +583,40 @@ describe("Popover", () => {
583
583
  ).toBeInTheDocument();
584
584
  });
585
585
 
586
+ it("should announce a popover correctly by reading the aria label", async () => {
587
+ // Arrange
588
+ render(
589
+ <Popover
590
+ onClose={jest.fn()}
591
+ aria-label="Popover Aria Label"
592
+ content={
593
+ <PopoverContentCore>
594
+ <button data-close-button onClick={close}>
595
+ Close Popover
596
+ </button>
597
+ </PopoverContentCore>
598
+ }
599
+ >
600
+ <Button>Open default popover</Button>
601
+ </Popover>,
602
+ );
603
+
604
+ // Act
605
+ // Open the popover
606
+ const openButton = await screen.findByRole("button", {
607
+ name: "Open default popover",
608
+ });
609
+
610
+ await userEvent.click(openButton);
611
+ const popover = await screen.findByRole("dialog");
612
+
613
+ // Assert
614
+
615
+ expect(popover).toHaveAttribute("aria-label", "Popover Aria Label");
616
+ expect(popover).not.toHaveAttribute("aria-labelledby");
617
+ expect(popover).not.toHaveAttribute("aria-describedby");
618
+ });
619
+
586
620
  it("should correctly describe the popover content core's aria label", async () => {
587
621
  // Arrange
588
622
  render(
@@ -621,14 +655,15 @@ describe("Popover", () => {
621
655
  });
622
656
  });
623
657
 
624
- describe("keyboard navigation", () => {
625
- it("should move focus to the first focusable element after popover is open", async () => {
658
+ describe.each([true, false])("keyboard navigation", (portal) => {
659
+ it(`when portal=${portal}, should move focus to the first focusable element after popover is open`, async () => {
626
660
  // Arrange
627
661
  render(
628
662
  <>
629
663
  <Button>Prev focusable element outside</Button>
630
664
  <Popover
631
665
  onClose={jest.fn()}
666
+ portal={portal}
632
667
  content={
633
668
  <PopoverContent
634
669
  title="Popover title"
@@ -667,13 +702,14 @@ describe("Popover", () => {
667
702
  ).toHaveFocus();
668
703
  });
669
704
 
670
- it("should allow flowing focus correctly even if the popover remains open", async () => {
705
+ it(`when portal=${portal}, should allow flowing focus correctly even if the popover remains open`, async () => {
671
706
  // Arrange
672
707
  render(
673
708
  <>
674
709
  <Button>Prev focusable element outside</Button>
675
710
  <Popover
676
711
  onClose={jest.fn()}
712
+ portal={portal}
677
713
  content={
678
714
  <PopoverContent
679
715
  title="Popover title"
@@ -709,13 +745,14 @@ describe("Popover", () => {
709
745
  ).toHaveFocus();
710
746
  });
711
747
 
712
- it("should allow circular navigation when the popover is open", async () => {
748
+ it(`when portal=${portal}, should allow circular navigation when the popover is open`, async () => {
713
749
  // Arrange
714
750
  render(
715
751
  <>
716
752
  <Button>Prev focusable element outside</Button>
717
753
  <Popover
718
754
  onClose={jest.fn()}
755
+ portal={portal}
719
756
  content={
720
757
  <PopoverContent
721
758
  title="Popover title"
@@ -757,13 +794,14 @@ describe("Popover", () => {
757
794
  ).toHaveFocus();
758
795
  });
759
796
 
760
- it("should allow navigating backwards when the popover is open", async () => {
797
+ it(`when portal=${portal}, should allow navigating backwards when the popover is open`, async () => {
761
798
  // Arrange
762
799
  render(
763
800
  <>
764
801
  <Button>Prev focusable element outside</Button>
765
802
  <Popover
766
803
  onClose={jest.fn()}
804
+ portal={portal}
767
805
  content={
768
806
  <PopoverContent
769
807
  title="Popover title"
@@ -78,6 +78,7 @@ export default class PopoverDialog extends React.Component<Props> {
78
78
  showTail,
79
79
  "aria-describedby": ariaDescribedby,
80
80
  "aria-labelledby": ariaLabelledBy,
81
+ "aria-label": ariaLabel,
81
82
  } = this.props;
82
83
 
83
84
  const contentProps = children.props as any;
@@ -90,6 +91,7 @@ export default class PopoverDialog extends React.Component<Props> {
90
91
  return (
91
92
  <React.Fragment>
92
93
  <View
94
+ aria-label={ariaLabel}
93
95
  aria-describedby={ariaDescribedby}
94
96
  aria-labelledby={ariaLabelledBy}
95
97
  id={id}