@khanacademy/wonder-blocks-popover 3.2.14 → 3.2.16

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,96 +0,0 @@
1
- import * as React from "react";
2
- import * as ReactDOM from "react-dom";
3
- import {isFocusable} from "../util/util";
4
-
5
- import PopoverContent from "./popover-content";
6
- import PopoverContentCore from "./popover-content-core";
7
-
8
- type Props = {
9
- /**
10
- * Called when `esc` is pressed
11
- */
12
- onClose: (shouldReturnFocus: boolean) => unknown;
13
- /**
14
- * Popover Content ref.
15
- * Will close the popover when clicking outside this element.
16
- */
17
- contentRef?: React.RefObject<PopoverContentCore | PopoverContent>;
18
- };
19
-
20
- type State = {
21
- /**
22
- * Tracks the first click triggered by the click event listener.
23
- */
24
- isFirstClick: boolean;
25
- };
26
-
27
- /**
28
- * A component that, when mounted, calls `onClose` when certain events occur.
29
- * This includes when pressing Escape or clicking outside the Popover.
30
- * @see @khanacademy/wonder-blocks-modal/components/modal-launcher.js
31
- */
32
- export default class PopoverEventListener extends React.Component<
33
- Props,
34
- State
35
- > {
36
- state: State = {
37
- isFirstClick: true,
38
- };
39
-
40
- componentDidMount() {
41
- window.addEventListener("keyup", this._handleKeyup);
42
- window.addEventListener("click", this._handleClick);
43
- }
44
-
45
- componentWillUnmount() {
46
- window.removeEventListener("keyup", this._handleKeyup);
47
- window.removeEventListener("click", this._handleClick);
48
- }
49
-
50
- _handleKeyup: (e: KeyboardEvent) => void = (e) => {
51
- // We check the key as that's keyboard layout agnostic and also avoids
52
- // the minefield of deprecated number type properties like keyCode and
53
- // which, with the replacement code, which uses a string instead.
54
- if (e.key === "Escape") {
55
- // Stop the event going any further.
56
- // For cancellation events, like the Escape key, we generally should
57
- // air on the side of caution and only allow it to cancel one thing.
58
- // So, it's polite for us to stop propagation of the event.
59
- // Otherwise, we end up with UX where one Escape key press
60
- // unexpectedly cancels multiple things.
61
- e.preventDefault();
62
- e.stopPropagation();
63
- // In the case of the Escape key, we should return focus to the
64
- // trigger button.
65
- this.props.onClose(true);
66
- }
67
- };
68
-
69
- _handleClick: (e: MouseEvent) => void = (e) => {
70
- // Prevents the problem where clicking the trigger button
71
- // triggers a click event and immediately closes the popover.
72
- if (this.state.isFirstClick) {
73
- this.setState({isFirstClick: false});
74
- return;
75
- }
76
-
77
- const node = ReactDOM.findDOMNode(this.props.contentRef?.current);
78
- if (node && !node.contains(e.target as any)) {
79
- // Stop the event going any further.
80
- // Only allow click to cancel one thing at a time.
81
- e.preventDefault();
82
- e.stopPropagation();
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);
90
- }
91
- };
92
-
93
- render(): React.ReactElement | null {
94
- return null;
95
- }
96
- }
@@ -1,410 +0,0 @@
1
- import * as React from "react";
2
- import * as ReactDOM from "react-dom";
3
-
4
- import {IDProvider} from "@khanacademy/wonder-blocks-core";
5
- import {TooltipPopper} from "@khanacademy/wonder-blocks-tooltip";
6
- import {maybeGetPortalMountedModalHostElement} from "@khanacademy/wonder-blocks-modal";
7
-
8
- import type {AriaProps} from "@khanacademy/wonder-blocks-core";
9
- import type {
10
- Placement,
11
- PopperElementProps,
12
- } from "@khanacademy/wonder-blocks-tooltip";
13
- import type {RootBoundary} from "@popperjs/core";
14
-
15
- import PopoverContent from "./popover-content";
16
- import PopoverContentCore from "./popover-content-core";
17
- import PopoverContext from "./popover-context";
18
- import PopoverAnchor from "./popover-anchor";
19
- import PopoverDialog from "./popover-dialog";
20
- import PopoverEventListener from "./popover-event-listener";
21
- import InitialFocus from "./initial-focus";
22
- import FocusManager from "./focus-manager";
23
-
24
- type PopoverContents =
25
- | React.ReactElement<React.ComponentProps<typeof PopoverContent>>
26
- | React.ReactElement<React.ComponentProps<typeof PopoverContentCore>>;
27
-
28
- type Props = AriaProps &
29
- Readonly<{
30
- /**
31
- * The element that triggers the popover. This element will be used to
32
- * position the popover. It can be either a Node or a function using the
33
- * children-as-function pattern to pass an open function for use anywhere
34
- * within children. The latter provides a lot of flexibility in terms of
35
- * what actions may trigger the `Popover` to launch the popover dialog.
36
- */
37
- children:
38
- | React.ReactElement<any>
39
- | ((arg1: {open: () => void}) => React.ReactElement<any>);
40
- /**
41
- * The content of the popover. You can either use
42
- * [PopoverContent](#PopoverContent) with one of the pre-defined variants,
43
- * or include your own custom content using
44
- * [PopoverContentCore](#PopoverContentCore directly.
45
- *
46
- * If the popover needs to close itself, the close function provided to this
47
- * callback can be called to close the popover.
48
- */
49
- content:
50
- | PopoverContents
51
- | ((arg1: {close: () => void}) => PopoverContents);
52
- /**
53
- * Where the popover should try to appear in relation to the trigger element.
54
- */
55
- placement: Placement;
56
- /**
57
- * When enabled, user can hide the popover content by pressing the `esc` key
58
- * or clicking/tapping outside of it.
59
- */
60
- dismissEnabled?: boolean;
61
- /**
62
- * The unique identifier to give to the popover. Provide this in cases
63
- * where you want to override the default accessibility solution. This
64
- * identifier will be applied to the popover title and content.
65
- *
66
- * This is also used as a prefix to the IDs of the popover's elements.
67
- *
68
- * For example, if you pass `"my-popover"` as the ID, the popover title
69
- * will have the ID `"my-popover-title"` and the popover content will
70
- * have the ID `"my-popover-content"`.
71
- *
72
- */
73
- id?: string;
74
- /**
75
- * The selector for the element that will be focused after the popover
76
- * dialog closes. When not set, the element that triggered the popover
77
- * will be used.
78
- */
79
- closedFocusId?: string;
80
- /**
81
- * The selector for the element that will be focused when the popover
82
- * content shows. When not set, the first focusable element within the
83
- * popover content will be used.
84
- */
85
- initialFocusId?: string;
86
- /**
87
- * Renders the popover when true, renders nothing when false.
88
- *
89
- * Using this prop makes the component behave as a controlled component. The
90
- * parent is responsible for managing the opening/closing of the popover
91
- * when using this prop.
92
- */
93
- opened?: boolean;
94
- /**
95
- * Called when the popover closes
96
- */
97
- onClose?: () => unknown;
98
- /**
99
- * Test ID used for e2e testing.
100
- */
101
- testId?: string;
102
- /**
103
- * Whether to show the popover tail or not. Defaults to true.
104
- */
105
- showTail: boolean;
106
- /**
107
- * Optional property to enable the portal functionality of popover.
108
- * This is very handy in cases where the Popover can't be easily
109
- * injected into the DOM structure and requires portaling to
110
- * the trigger location.
111
- *
112
- * Set to "true" by default.
113
- *
114
- * CAUTION: Turning off portal could cause some clipping issues
115
- * especially around legacy code with usage of z-indexing,
116
- * Use caution when turning this functionality off and ensure
117
- * your content does not get clipped or hidden.
118
- */
119
- portal?: boolean;
120
- /**
121
- * Optional property to set what the root boundary is for the popper behavior.
122
- * This is set to "viewport" by default, causing the popper to be positioned based
123
- * on the user's viewport. If set to "document", it will position itself based
124
- * on where there is available room within the document body.
125
- */
126
- rootBoundary?: RootBoundary;
127
- }>;
128
-
129
- type State = Readonly<{
130
- /**
131
- * Keeps a reference of the dialog state
132
- */
133
- opened: boolean;
134
- /**
135
- * Anchor element DOM reference
136
- */
137
- anchorElement?: HTMLElement;
138
- /**
139
- * Current popper placement
140
- */
141
- placement: Placement;
142
- }>;
143
-
144
- type DefaultProps = Readonly<{
145
- placement: Props["placement"];
146
- showTail: Props["showTail"];
147
- portal: Props["portal"];
148
- rootBoundary: Props["rootBoundary"];
149
- }>;
150
-
151
- /**
152
- * Popovers provide additional information that is related to a particular
153
- * element and/or content. They can include text, links, icons and
154
- * illustrations. The main difference with `Tooltip` is that they must be
155
- * dismissed by clicking an element.
156
- *
157
- * This component uses the `PopoverPopper` component to position the
158
- * `PopoverContentCore` component according to the children it is wrapping.
159
- *
160
- * ### Usage
161
- *
162
- * ```jsx
163
- * import {Popover, PopoverContent} from "@khanacademy/wonder-blocks-popover";
164
- *
165
- * <Popover
166
- * onClose={() => {}}
167
- * content={
168
- * <PopoverContent title="Title" content="Some content" closeButtonVisible />
169
- * }>
170
- * {({ open }) => <Button onClick={open}>Open popover</Button>}
171
- * </Popover>
172
- * ```
173
- */
174
- export default class Popover extends React.Component<Props, State> {
175
- static defaultProps: DefaultProps = {
176
- placement: "top",
177
- showTail: true,
178
- portal: true,
179
- rootBoundary: "viewport",
180
- };
181
-
182
- /**
183
- * Used to sync the `opened` state when Popover acts as a controlled
184
- * component
185
- */
186
- static getDerivedStateFromProps(
187
- props: Props,
188
- state: State,
189
- ): Partial<State> | null | undefined {
190
- return {
191
- opened:
192
- typeof props.opened === "boolean" ? props.opened : state.opened,
193
- };
194
- }
195
-
196
- state: State = {
197
- opened: !!this.props.opened,
198
- placement: this.props.placement,
199
- };
200
-
201
- /**
202
- * Popover content ref
203
- */
204
- contentRef: React.RefObject<PopoverContent | PopoverContentCore> =
205
- React.createRef();
206
-
207
- /**
208
- * Returns focus to a given element.
209
- */
210
- maybeReturnFocus = () => {
211
- const {anchorElement} = this.state;
212
- const {closedFocusId} = this.props;
213
-
214
- // Focus on the specified element after dismissing the popover.
215
- if (closedFocusId) {
216
- const focusElement = ReactDOM.findDOMNode(
217
- document.getElementById(closedFocusId),
218
- ) as any;
219
-
220
- focusElement?.focus();
221
- return;
222
- }
223
-
224
- // If no element is specified, focus on the element that triggered the
225
- // popover.
226
- if (anchorElement) {
227
- anchorElement.focus();
228
- }
229
- };
230
-
231
- /**
232
- * Popover dialog closed
233
- */
234
- handleClose: (shouldReturnFocus?: boolean) => void = (
235
- shouldReturnFocus = true,
236
- ) => {
237
- this.setState({opened: false}, () => {
238
- this.props.onClose?.();
239
-
240
- if (shouldReturnFocus) {
241
- this.maybeReturnFocus();
242
- }
243
- });
244
- };
245
-
246
- /**
247
- * Popover dialog opened
248
- */
249
- handleOpen: () => void = () => {
250
- if (this.props.dismissEnabled && this.state.opened) {
251
- this.handleClose(true);
252
- } else {
253
- this.setState({opened: true});
254
- }
255
- };
256
-
257
- updateRef = (actualRef?: HTMLElement) => {
258
- if (actualRef && this.state.anchorElement !== actualRef) {
259
- this.setState({
260
- anchorElement: actualRef,
261
- });
262
- }
263
- };
264
-
265
- renderContent(uniqueId: string): PopoverContents {
266
- const {content} = this.props;
267
-
268
- const popoverContents: PopoverContents =
269
- typeof content === "function"
270
- ? content({
271
- close: this.handleClose,
272
- })
273
- : content;
274
-
275
- // @ts-expect-error: TS2769 - No overload matches this call.
276
- return React.cloneElement(popoverContents, {
277
- ref: this.contentRef,
278
- // internal prop: only injected by Popover
279
- // This allows us to announce the popover content when it is opened.
280
- uniqueId,
281
- });
282
- }
283
-
284
- renderPopper(uniqueId: string): React.ReactNode {
285
- const {
286
- initialFocusId,
287
- placement,
288
- showTail,
289
- portal,
290
- "aria-label": ariaLabel,
291
- "aria-describedby": ariaDescribedBy,
292
- rootBoundary,
293
- } = this.props;
294
- const {anchorElement} = this.state;
295
-
296
- const describedBy = ariaDescribedBy || `${uniqueId}-content`;
297
-
298
- const ariaLabelledBy = ariaLabel ? undefined : `${uniqueId}-title`;
299
-
300
- const popperContent = (
301
- <TooltipPopper
302
- anchorElement={anchorElement}
303
- placement={placement}
304
- rootBoundary={rootBoundary}
305
- >
306
- {(props: PopperElementProps) => (
307
- <PopoverDialog
308
- {...props}
309
- aria-label={ariaLabel}
310
- aria-describedby={describedBy}
311
- aria-labelledby={ariaLabelledBy}
312
- id={uniqueId}
313
- onUpdate={(placement) => this.setState({placement})}
314
- showTail={showTail}
315
- >
316
- {this.renderContent(uniqueId)}
317
- </PopoverDialog>
318
- )}
319
- </TooltipPopper>
320
- );
321
-
322
- if (portal) {
323
- return (
324
- <FocusManager
325
- anchorElement={anchorElement}
326
- initialFocusId={initialFocusId}
327
- >
328
- {popperContent}
329
- </FocusManager>
330
- );
331
- } else {
332
- return (
333
- // Ensures the user is focused on the first available element
334
- // when popover is rendered without the focus manager.
335
- <InitialFocus initialFocusId={initialFocusId}>
336
- {popperContent}
337
- </InitialFocus>
338
- );
339
- }
340
- }
341
-
342
- getHost(): Element | null | undefined {
343
- // If we are in a modal, we find where we should be portalling the
344
- // popover by using the helper function from the modal package on the
345
- // trigger element. If we are not in a modal, we use body as the
346
- // location to portal to.
347
- return (
348
- maybeGetPortalMountedModalHostElement(this.state.anchorElement) ||
349
- document.body
350
- );
351
- }
352
-
353
- renderPortal(uniqueId: string, opened: boolean) {
354
- if (!opened) {
355
- return null;
356
- }
357
-
358
- const {portal} = this.props;
359
- const popperHost = this.getHost();
360
-
361
- // Attach the popover to a Portal
362
- if (portal && popperHost) {
363
- return ReactDOM.createPortal(
364
- this.renderPopper(uniqueId),
365
- popperHost,
366
- );
367
- }
368
-
369
- // Otherwise, append the dialog next to the trigger element
370
- return this.renderPopper(uniqueId);
371
- }
372
-
373
- render(): React.ReactNode {
374
- const {children, dismissEnabled, id} = this.props;
375
- const {opened, placement} = this.state;
376
-
377
- return (
378
- <PopoverContext.Provider
379
- value={{
380
- close: this.handleClose,
381
- placement: placement,
382
- }}
383
- >
384
- <IDProvider id={id} scope="popover">
385
- {(uniqueId) => (
386
- <React.Fragment>
387
- <PopoverAnchor
388
- anchorRef={this.updateRef}
389
- id={`${uniqueId}-anchor`}
390
- aria-controls={uniqueId}
391
- aria-expanded={opened ? "true" : "false"}
392
- onClick={this.handleOpen}
393
- >
394
- {children}
395
- </PopoverAnchor>
396
- {this.renderPortal(uniqueId, opened)}
397
- </React.Fragment>
398
- )}
399
- </IDProvider>
400
-
401
- {dismissEnabled && opened && (
402
- <PopoverEventListener
403
- onClose={this.handleClose}
404
- contentRef={this.contentRef}
405
- />
406
- )}
407
- </PopoverContext.Provider>
408
- );
409
- }
410
- }
package/src/index.ts DELETED
@@ -1,5 +0,0 @@
1
- import Popover from "./components/popover";
2
- import PopoverContent from "./components/popover-content";
3
- import PopoverContentCore from "./components/popover-content-core";
4
-
5
- export {Popover, PopoverContent, PopoverContentCore};
@@ -1,38 +0,0 @@
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 DELETED
@@ -1,20 +0,0 @@
1
- /**
2
- * List of elements that can be focused
3
- * @see https://www.w3.org/TR/html5/editing.html#can-be-focused
4
- */
5
- const FOCUSABLE_ELEMENTS =
6
- 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
7
-
8
- export function findFocusableNodes(
9
- root: HTMLElement | Document,
10
- ): Array<HTMLElement> {
11
- return Array.from(root.querySelectorAll(FOCUSABLE_ELEMENTS));
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
- }
@@ -1,17 +0,0 @@
1
- {
2
- "exclude": ["dist"],
3
- "extends": "../tsconfig-shared.json",
4
- "compilerOptions": {
5
- "outDir": "./dist",
6
- "rootDir": "src",
7
- },
8
- "references": [
9
- {"path": "../wonder-blocks-core/tsconfig-build.json"},
10
- {"path": "../wonder-blocks-icon/tsconfig-build.json"},
11
- {"path": "../wonder-blocks-icon-button/tsconfig-build.json"},
12
- {"path": "../wonder-blocks-modal/tsconfig-build.json"},
13
- {"path": "../wonder-blocks-tokens/tsconfig-build.json"},
14
- {"path": "../wonder-blocks-tooltip/tsconfig-build.json"},
15
- {"path": "../wonder-blocks-typography/tsconfig-build.json"},
16
- ]
17
- }