@khanacademy/wonder-blocks-popover 3.2.15 → 3.3.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.
@@ -1,61 +0,0 @@
1
- import * as React from "react";
2
-
3
- import type {AriaProps, StyleType} from "@khanacademy/wonder-blocks-core";
4
- import xIcon from "@phosphor-icons/core/regular/x.svg";
5
- import IconButton from "@khanacademy/wonder-blocks-icon-button";
6
-
7
- import PopoverContext from "./popover-context";
8
-
9
- type Props = AriaProps & {
10
- /**
11
- * Whether to display the light version of this component instead, for use
12
- * when the item is used on a dark background.
13
- */
14
- light?: boolean;
15
- /**
16
- * Custom styles applied to the IconButton
17
- */
18
- style?: StyleType;
19
- /**
20
- * Test ID used for e2e testing.
21
- */
22
- testId?: string;
23
- };
24
-
25
- type DefaultProps = {
26
- light: Props["light"];
27
- ["aria-label"]: Props["aria-label"];
28
- };
29
-
30
- /**
31
- * This is the visual component rendering the close button that is rendered
32
- * inside the PopoverContentCore. It’s rendered if closeButtonVisible is set
33
- * true.
34
- */
35
- export default class CloseButton extends React.Component<Props> {
36
- static defaultProps: DefaultProps = {
37
- light: true,
38
- "aria-label": "Close Popover",
39
- };
40
-
41
- render(): React.ReactNode {
42
- const {light, "aria-label": ariaLabel, style, testId} = this.props;
43
- return (
44
- <PopoverContext.Consumer>
45
- {({close}) => {
46
- return (
47
- <IconButton
48
- icon={xIcon}
49
- aria-label={ariaLabel}
50
- onClick={close}
51
- kind={light ? "primary" : "tertiary"}
52
- light={light}
53
- style={style}
54
- testId={testId}
55
- />
56
- );
57
- }}
58
- </PopoverContext.Consumer>
59
- );
60
- }
61
- }
@@ -1,344 +0,0 @@
1
- import * as React from "react";
2
- import * as ReactDOM from "react-dom";
3
- import {findFocusableNodes} from "../util/util";
4
- import InitialFocus from "./initial-focus";
5
-
6
- type Props = {
7
- /**
8
- * The popover content container
9
- */
10
- children: React.ReactElement<any>;
11
- /**
12
- * A reference to the trigger element
13
- */
14
- anchorElement: HTMLElement | null | undefined;
15
- /**
16
- * The selector for the element that will be focused when the dialog shows.
17
- * When not set, the first tabbable element within the dialog will be used.
18
- */
19
- initialFocusId?: string;
20
- };
21
-
22
- /**
23
- * This component ensures that focus flows correctly when the popover is open.
24
- *
25
- * Inside the popover:
26
- * - `tab`: Moves focus to the next focusable element.
27
- * - `shift + tab`: Moves focus to the previous focusable element.
28
- *
29
- * After the focus reaches the start/end of the popover, then we handle two
30
- * different scenarios:
31
- *
32
- * 1. If the focus has reached the last focusable element inside the popover,
33
- * the next tab will set focus on the next focusable element that exists
34
- * after the PopoverAnchor.
35
- * 2. If the focus is set to the first focusable element inside the popover, the
36
- * next shift + tab will set focus on the PopoverAnchor element.
37
- *
38
- */
39
- export default class FocusManager extends React.Component<Props> {
40
- /**
41
- * The focusable element that is positioned after the trigger element
42
- */
43
- nextElementAfterPopover: HTMLElement | null | undefined;
44
-
45
- /**
46
- * Tabbing is restricted to descendents of this element.
47
- */
48
- rootNode: HTMLElement | null | undefined;
49
-
50
- componentDidMount() {
51
- this.addEventListeners();
52
- }
53
-
54
- componentDidUpdate() {
55
- // Ensure that the event listeners are not duplicated.
56
- this.removeEventListeners();
57
- this.addEventListeners();
58
- }
59
-
60
- /**
61
- * Remove keydown listeners
62
- */
63
- componentWillUnmount() {
64
- // Reset focusability
65
- this.changeFocusabilityInsidePopover(true);
66
-
67
- this.removeEventListeners();
68
- }
69
-
70
- /**
71
- * List of focusable elements within the popover content
72
- */
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;
84
-
85
- /**
86
- * Add keydown listeners
87
- */
88
- addEventListeners: () => void = () => {
89
- const {anchorElement} = this.props;
90
-
91
- if (anchorElement) {
92
- anchorElement.addEventListener(
93
- "keydown",
94
- this.handleKeydownPreviousFocusableElement,
95
- );
96
- }
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
-
113
- // tries to get the next focusable element outside of the popover
114
- this.nextElementAfterPopover = this.getNextFocusableElement();
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
-
140
- if (this.nextElementAfterPopover) {
141
- this.nextElementAfterPopover.addEventListener(
142
- "keydown",
143
- this.handleKeydownNextFocusableElement,
144
- );
145
- }
146
- };
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
-
202
- /**
203
- * Gets the next focusable element after the anchor element
204
- */
205
- getNextFocusableElement: () => HTMLElement | null | undefined = () => {
206
- const {anchorElement} = this.props;
207
-
208
- if (!anchorElement) {
209
- return;
210
- }
211
-
212
- // get the total list of focusable elements within the document
213
- const focusableElements = findFocusableNodes(document);
214
-
215
- const focusableElementsOutside = focusableElements.filter((element) => {
216
- const index =
217
- this.elementsThatCanBeFocusableInsidePopover.indexOf(element);
218
- return index < 0;
219
- });
220
-
221
- // get anchor element index
222
- const anchorIndex = focusableElementsOutside.indexOf(anchorElement);
223
-
224
- if (
225
- anchorIndex >= 0 &&
226
- anchorIndex !== focusableElementsOutside.length - 1
227
- ) {
228
- // guess next focusable element index
229
- const nextElementIndex =
230
- anchorIndex < focusableElementsOutside.length - 1
231
- ? anchorIndex + 1
232
- : 0;
233
-
234
- // get next element's DOM reference
235
- return focusableElementsOutside[nextElementIndex];
236
- }
237
-
238
- return;
239
- };
240
-
241
- /**
242
- * Gets the list of focusable elements inside the popover
243
- */
244
- // @ts-expect-error [FEI-5019] - TS2322 - Type '(node: any) => void' is not assignable to type '() => void'.
245
- getComponentRootNode: () => void = (node: any) => {
246
- if (!node) {
247
- // The component is being umounted
248
- return;
249
- }
250
-
251
- const rootNode: HTMLElement = ReactDOM.findDOMNode(node) as any;
252
-
253
- if (!rootNode) {
254
- throw new Error(
255
- "Assertion error: root node should exist after mount",
256
- );
257
- }
258
-
259
- this.rootNode = rootNode as HTMLElement;
260
- };
261
-
262
- /**
263
- * Triggered when the focus is set to the first sentinel. This way, the
264
- * focus will be redirected to the anchor element.
265
- */
266
- handleFocusPreviousFocusableElement: () => void = () => {
267
- if (this.props.anchorElement) {
268
- this.props.anchorElement.focus();
269
- }
270
- };
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
-
287
- /**
288
- * Triggered when the focus is set to the last sentinel. This way, the focus
289
- * will be redirected to next element after the anchor element.
290
- */
291
- handleFocusNextFocusableElement: () => void = () => {
292
- if (this.nextElementAfterPopover) {
293
- this.nextElementAfterPopover.focus();
294
- }
295
- };
296
-
297
- /**
298
- * Triggered when the focus is leaving the previous focusable element. This
299
- * way, the focus is redirected to the first focusable element inside the
300
- * popover.
301
- */
302
- handleKeydownPreviousFocusableElement: (e: KeyboardEvent) => void = (e) => {
303
- // It will try focus only if the user is pressing `tab`
304
- if (e.key === "Tab" && !e.shiftKey) {
305
- e.preventDefault();
306
- this.firstFocusableElementInPopover?.focus();
307
- }
308
- };
309
-
310
- /**
311
- * Triggered when the focus is leaving the next focusable element. This way,
312
- * the focus is redirected to the last focusable element inside the popover.
313
- */
314
- handleKeydownNextFocusableElement: (e: KeyboardEvent) => void = (e) => {
315
- // It will try focus only if the user is pressing `Shift+tab`
316
- if (e.key === "Tab" && e.shiftKey) {
317
- e.preventDefault();
318
- this.lastFocusableElementInPopover?.focus();
319
- }
320
- };
321
-
322
- render(): React.ReactNode {
323
- const {children} = this.props;
324
-
325
- return (
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>
342
- );
343
- }
344
- }
@@ -1,87 +0,0 @@
1
- import * as React from "react";
2
- import * as ReactDOM from "react-dom";
3
-
4
- import {findFocusableNodes} from "../util/util";
5
-
6
- type Props = {
7
- /**
8
- * The container to apply the initial focus
9
- */
10
- children: React.ReactElement<any>;
11
- /**
12
- * The selector for the element that will be focused when the component shows.
13
- * When not set, the first tabbable element within the component will be used.
14
- */
15
- initialFocusId?: string;
16
- };
17
-
18
- /**
19
- * This component finds which element (from within the children) needs to
20
- * receive focus. After that, the children is rendered with the focus assigned.
21
- */
22
- export default class InitialFocus extends React.Component<Props> {
23
- componentDidMount() {
24
- const node: HTMLElement = ReactDOM.findDOMNode(this) as any;
25
-
26
- if (!node) {
27
- return;
28
- }
29
- // try to focus on the first focussable element
30
- this.setInitialFocusableElement(node);
31
- }
32
-
33
- /**
34
- * Gets the focusable element and applies focus to it
35
- */
36
- setInitialFocusableElement: (node: HTMLElement) => void = (node) => {
37
- // 1. try to get element specified by the user
38
- // 2. get first occurence from list of focusable elements
39
- // 3. If no focusable elements are found, get the container itself
40
- const firstFocusableElement =
41
- this.maybeGetInitialFocusElement(node) ||
42
- this.maybeGetFirstFocusableElement(node) ||
43
- node;
44
-
45
- if (firstFocusableElement === node) {
46
- // add tabIndex to make the container focusable
47
- node.tabIndex = -1;
48
- }
49
-
50
- // using timeout to prevent page jumps when focusing on this element
51
- setTimeout(() => {
52
- firstFocusableElement.focus();
53
- }, 0);
54
- };
55
-
56
- /**
57
- * Returns an element specified by the user
58
- */
59
- maybeGetInitialFocusElement(node: HTMLElement): HTMLElement | null {
60
- const {initialFocusId} = this.props;
61
-
62
- if (!initialFocusId) {
63
- return null;
64
- }
65
-
66
- return node.querySelector(`#${initialFocusId}`);
67
- }
68
-
69
- /**
70
- * Returns the first focusable element found inside the children
71
- */
72
- maybeGetFirstFocusableElement(node: HTMLElement): HTMLElement | null {
73
- // get a collection of elements that can be focused
74
- const focusableElements = findFocusableNodes(node);
75
-
76
- if (!focusableElements.length) {
77
- return null;
78
- }
79
-
80
- // if found, return the first focusable element
81
- return focusableElements[0];
82
- }
83
-
84
- render(): React.ReactNode {
85
- return this.props.children;
86
- }
87
- }
@@ -1,93 +0,0 @@
1
- import * as React from "react";
2
- import * as ReactDOM from "react-dom";
3
-
4
- import type {AriaProps} from "@khanacademy/wonder-blocks-core";
5
-
6
- type Props = AriaProps & {
7
- /**
8
- * Callback to be invoked when the anchored content is mounted.
9
- * This provides a reference to the anchored content, which can then be
10
- * used for calculating popover content positioning.
11
- */
12
- anchorRef: (arg1?: HTMLElement) => unknown;
13
- /**
14
- * The element that triggers the popover. This element will be used to
15
- * position the popover. It can be either a Node or a function using the
16
- * children-as-function pattern to pass an open function for use anywhere
17
- * within children. The latter provides a lot of flexibility in terms of
18
- * what actions may trigger the `Popover` to launch the
19
- * [PopoverDialog](#PopoverDialog).
20
- */
21
- children:
22
- | React.ReactElement<any>
23
- | ((arg1: {open: () => void}) => React.ReactElement<any>);
24
- /**
25
- * The unique identifier to give to the anchor.
26
- */
27
- id?: string;
28
- /**
29
- * Called when the anchor is clicked
30
- */
31
- onClick: () => void;
32
- };
33
-
34
- /**
35
- * The element that triggers the popover dialog. This is also used as reference
36
- * to position the dialog itself.
37
- */
38
- export default class PopoverAnchor extends React.Component<Props> {
39
- componentDidMount() {
40
- const anchorNode = ReactDOM.findDOMNode(this) as HTMLElement;
41
-
42
- if (anchorNode) {
43
- this.props.anchorRef(anchorNode);
44
- }
45
- }
46
-
47
- render(): React.ReactNode {
48
- const {
49
- children,
50
- id,
51
- onClick,
52
- "aria-controls": ariaControls,
53
- "aria-expanded": ariaExpanded,
54
- } = this.props;
55
-
56
- // props that will be injected to both children versions
57
- const sharedProps = {
58
- id: id,
59
- "aria-controls": ariaControls,
60
- "aria-expanded": ariaExpanded,
61
- } as const;
62
-
63
- if (typeof children === "function") {
64
- const renderedChildren = children({
65
- open: onClick,
66
- });
67
-
68
- // we clone it to allow injecting the sharedProps defined before
69
- return React.cloneElement(renderedChildren, sharedProps);
70
- } else {
71
- // add onClick handler to automatically open the dialog after
72
- // clicking on this anchor element
73
- // @ts-expect-error [FEI-5019] - TS2769 - No overload matches this call.
74
- return React.cloneElement(children, {
75
- // @ts-expect-error [FEI-5019] - TS2339 - Property 'props' does not exist on type 'ReactElement<any, string | JSXElementConstructor<any>> | (ReactElement<any, string | JSXElementConstructor<any>> & string) | ... 9 more ... | (((arg1: { ...; }) => ReactElement<...>) & true)'.
76
- ...children.props,
77
- ...sharedProps,
78
- // @ts-expect-error [FEI-5019] - TS2339 - Property 'props' does not exist on type 'ReactElement<any, string | JSXElementConstructor<any>> | (ReactElement<any, string | JSXElementConstructor<any>> & string) | ... 9 more ... | (((arg1: { ...; }) => ReactElement<...>) & true)'.
79
- onClick: children.props.onClick
80
- ? // @ts-expect-error [FEI-5019] - TS7006 - Parameter 'e' implicitly has an 'any' type.
81
- (e) => {
82
- e.stopPropagation();
83
- // This is done to avoid overriding a custom onClick
84
- // handler inside the children node
85
- // @ts-expect-error [FEI-5019] - TS2339 - Property 'props' does not exist on type 'ReactElement<any, string | JSXElementConstructor<any>> | (ReactElement<any, string | JSXElementConstructor<any>> & string) | ... 9 more ... | (((arg1: { ...; }) => ReactElement<...>) & true)'.
86
- children.props.onClick();
87
- onClick();
88
- }
89
- : onClick,
90
- });
91
- }
92
- }
93
- }