@khanacademy/wonder-blocks-tooltip 2.4.1 → 2.4.3

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,306 +0,0 @@
1
- /**
2
- * The Tooltip component provides the means to anchor some additional
3
- * information to some content. The additional information is shown in a
4
- * callout that hovers above the page content. This additional information is
5
- * invoked by hovering over the anchored content, or focusing all or part of the
6
- * anchored content.
7
- *
8
- * This component is structured as follows:
9
- *
10
- * Tooltip (this component)
11
- * - TooltipAnchor (provides hover/focus behaviors on anchored content)
12
- * - TooltipPortalMounter (creates portal into which the callout is rendered)
13
- * --------------------------- [PORTAL BOUNDARY] ------------------------------
14
- * - TooltipPopper (provides positioning for the callout using react-popper)
15
- * - TooltipBubble (renders the callout borders, background and shadow)
16
- * - TooltipContent (renders the callout content; the actual information)
17
- * - TooltipTail (renders the callout tail and shadow that points from the
18
- * callout to the anchor content)
19
- */
20
- import * as React from "react";
21
- import * as ReactDOM from "react-dom";
22
-
23
- import {
24
- UniqueIDProvider,
25
- IIdentifierFactory,
26
- } from "@khanacademy/wonder-blocks-core";
27
- import {maybeGetPortalMountedModalHostElement} from "@khanacademy/wonder-blocks-modal";
28
- import type {Typography} from "@khanacademy/wonder-blocks-typography";
29
- import type {AriaProps} from "@khanacademy/wonder-blocks-core";
30
- import {color} from "@khanacademy/wonder-blocks-tokens";
31
-
32
- import TooltipAnchor from "./tooltip-anchor";
33
- import TooltipBubble from "./tooltip-bubble";
34
- import TooltipContent from "./tooltip-content";
35
- import TooltipPopper from "./tooltip-popper";
36
- import type {ContentStyle, Placement} from "../util/types";
37
-
38
- type Props = AriaProps &
39
- Readonly<{
40
- /**
41
- * The content for anchoring the tooltip.
42
- * This component will be used to position the tooltip.
43
- */
44
- children: React.ReactElement<any> | string;
45
- /**
46
- * Optional title for the tooltip content.
47
- */
48
- title?: string | React.ReactElement<React.ComponentProps<Typography>>;
49
-
50
- /**
51
- * Whether the tooltip should update its position when the anchor
52
- * element changes size or position. Defaults to false.
53
- */
54
- autoUpdate?: boolean;
55
- /**
56
- * The content to render in the tooltip.
57
- */
58
- content:
59
- | string
60
- | React.ReactElement<React.ComponentProps<typeof TooltipContent>>;
61
- /**
62
- * The unique identifier to give to the tooltip. Provide this in cases where
63
- * you want to override the default accessibility solution. This identifier
64
- * will be applied to the tooltip bubble content.
65
- *
66
- * By providing this identifier, the children that this tooltip anchors to
67
- * will not be automatically given the aria-desribedby attribute. Instead,
68
- * the accessibility solution is the responsibility of the caller.
69
- *
70
- * If this is not provided, the aria-describedby attribute will be added
71
- * to the children with a unique identifier pointing to the tooltip bubble
72
- * content.
73
- */
74
- id?: string;
75
- /**
76
- * When true, if a tabindex attribute is not already present on the element
77
- * wrapped by the anchor, the element will be given tabindex=0 to make it
78
- * keyboard focusable; otherwise, does not attempt to change the ability to
79
- * focus the anchor element.
80
- *
81
- * Defaults to true.
82
- *
83
- * One might set this to false in circumstances where the wrapped component
84
- * already can receive focus or contains an element that can.
85
- * Use good judgement when overriding this value, the tooltip content should
86
- * be accessible via keyboard in all circumstances where the tooltip would
87
- * appear using the mouse, so verify those use-cases.
88
- *
89
- * Also, note that the aria-describedby attribute is attached to the root
90
- * anchor element, so you may need to implement an additional accessibility
91
- * solution when overriding anchor focusivity.
92
- */
93
- forceAnchorFocusivity?: boolean;
94
- /**
95
- * Where the tooltip should appear in relation to the anchor element.
96
- * Defaults to "top".
97
- */
98
- placement: Placement;
99
- /**
100
- * Renders the tooltip when true, renders nothing when false.
101
- *
102
- * Using this prop makes the component behave as a controlled component. The
103
- * parent is responsible for managing the opening/closing of the tooltip
104
- * when using this prop.
105
- */
106
- opened?: boolean;
107
- /**
108
- * Test ID used for e2e testing.
109
- */
110
- testId?: string;
111
- /**
112
- * Optional custom styles for the tooltip content which are a subset of valid CSS styles.
113
- */
114
- contentStyle?: ContentStyle;
115
- /**
116
- * Optional background color.
117
- */
118
- backgroundColor?: keyof typeof color;
119
- }>;
120
-
121
- type State = Readonly<{
122
- /**
123
- * Whether the tooltip is open by hovering/focusing on the anchor element.
124
- */
125
- active: boolean;
126
- /**
127
- * Whether the tooltip is open by hovering on the tooltip bubble.
128
- */
129
- activeBubble: boolean;
130
- /**
131
- * The element that activates the tooltip.
132
- */
133
- anchorElement?: HTMLElement;
134
- }>;
135
-
136
- type DefaultProps = {
137
- forceAnchorFocusivity: Props["forceAnchorFocusivity"];
138
- placement: Props["placement"];
139
- };
140
-
141
- /**
142
- * Use a tooltip to help describe an on screen object.
143
- *
144
- * Tooltips:
145
- * - contain text
146
- * - (optional) contain small graphic elements to complement the text
147
- * - appear on hover or focus (for non-assistive tech keyboard users)
148
- * - must have a tail that points to a parent object
149
- * - DO NOT include actions
150
- *
151
- * For more rich content see Popovers, for taking action on an object, see
152
- * Snackbars (proposed).
153
- *
154
- * ### Usage
155
- *
156
- * ```jsx
157
- * import Tooltip from "@khanacademy/wonder-blocks-tooltip";
158
- *
159
- * <Tooltip content="This is a text tooltip">
160
- * Tooltip anchor
161
- * </Tooltip>
162
- * ```
163
- *
164
- */
165
- export default class Tooltip extends React.Component<Props, State> {
166
- static defaultProps: DefaultProps = {
167
- forceAnchorFocusivity: true,
168
- placement: "top",
169
- };
170
-
171
- /**
172
- * Used to sync the `opened` state when Tooltip acts as a controlled
173
- * component
174
- */
175
- static getDerivedStateFromProps(
176
- props: Props,
177
- state: State,
178
- ): Partial<State> | null {
179
- return {
180
- active:
181
- typeof props.opened === "boolean" ? props.opened : state.active,
182
- };
183
- }
184
-
185
- state: State = {
186
- active: false,
187
- activeBubble: false,
188
- };
189
- static ariaContentId = "aria-content";
190
-
191
- _updateAnchorElement(ref?: Element | null) {
192
- if (ref && ref !== this.state.anchorElement) {
193
- this.setState({anchorElement: ref as HTMLElement});
194
- }
195
- }
196
-
197
- _renderBubbleContent(): React.ReactElement<
198
- React.ComponentProps<typeof TooltipContent>
199
- > {
200
- const {title, content, contentStyle, testId} = this.props;
201
- if (typeof content === "string") {
202
- return (
203
- <TooltipContent
204
- title={title}
205
- contentStyle={contentStyle}
206
- testId={testId ? `${testId}-content` : undefined}
207
- >
208
- {content}
209
- </TooltipContent>
210
- );
211
- } else if (title) {
212
- return React.cloneElement(content, {title});
213
- } else {
214
- return content;
215
- }
216
- }
217
-
218
- _renderPopper(ids?: IIdentifierFactory): React.ReactNode {
219
- const {id, backgroundColor} = this.props;
220
- const bubbleId = ids ? ids.get(Tooltip.ariaContentId) : id;
221
- if (!bubbleId) {
222
- throw new Error("Did not get an identifier factory nor a id prop");
223
- }
224
-
225
- const {placement} = this.props;
226
- return (
227
- <TooltipPopper
228
- anchorElement={this.state.anchorElement}
229
- placement={placement}
230
- autoUpdate={this.props.autoUpdate}
231
- >
232
- {(props) => (
233
- <TooltipBubble
234
- id={bubbleId}
235
- style={props.style}
236
- backgroundColor={backgroundColor}
237
- tailOffset={props.tailOffset}
238
- isReferenceHidden={props.isReferenceHidden}
239
- placement={props.placement}
240
- updateTailRef={props.updateTailRef}
241
- updateBubbleRef={props.updateBubbleRef}
242
- onActiveChanged={(active) =>
243
- this.setState({activeBubble: active})
244
- }
245
- >
246
- {this._renderBubbleContent()}
247
- </TooltipBubble>
248
- )}
249
- </TooltipPopper>
250
- );
251
- }
252
-
253
- _getHost(): Element | null | undefined {
254
- const {anchorElement} = this.state;
255
-
256
- return (
257
- maybeGetPortalMountedModalHostElement(anchorElement) ||
258
- document.body
259
- );
260
- }
261
-
262
- _renderTooltipAnchor(ids?: IIdentifierFactory): React.ReactNode {
263
- const {autoUpdate, children, forceAnchorFocusivity} = this.props;
264
- const {active, activeBubble} = this.state;
265
-
266
- const popperHost = this._getHost();
267
-
268
- // Only render the popper if the anchor element is available so that we
269
- // can position the popper correctly. If autoUpdate is false, we don't
270
- // need to wait for the anchor element to render the popper.
271
- const shouldAnchorExist = autoUpdate ? this.state.anchorElement : true;
272
- const shouldBeVisible =
273
- popperHost && (active || activeBubble) && shouldAnchorExist;
274
-
275
- // TODO(kevinb): update to use ReactPopper's React 16-friendly syntax
276
- return (
277
- <React.Fragment>
278
- <TooltipAnchor
279
- forceAnchorFocusivity={forceAnchorFocusivity}
280
- anchorRef={(r) => this._updateAnchorElement(r)}
281
- onActiveChanged={(active) => this.setState({active})}
282
- ids={ids}
283
- >
284
- {children}
285
- </TooltipAnchor>
286
- {shouldBeVisible &&
287
- ReactDOM.createPortal(this._renderPopper(ids), popperHost)}
288
- </React.Fragment>
289
- );
290
- }
291
-
292
- render(): React.ReactNode {
293
- const {id} = this.props;
294
- if (id) {
295
- // Let's bypass the extra weight of an id provider since we don't
296
- // need it.
297
- return this._renderTooltipAnchor();
298
- } else {
299
- return (
300
- <UniqueIDProvider scope="tooltip" mockOnFirstRender={true}>
301
- {(ids) => this._renderTooltipAnchor(ids)}
302
- </UniqueIDProvider>
303
- );
304
- }
305
- }
306
- }
package/src/index.ts DELETED
@@ -1,10 +0,0 @@
1
- import type {Placement, PopperElementProps} from "./util/types";
2
-
3
- import Tooltip from "./components/tooltip";
4
- import TooltipContent from "./components/tooltip-content";
5
- import TooltipPopper from "./components/tooltip-popper";
6
- import TooltipTail from "./components/tooltip-tail";
7
-
8
- export {Tooltip as default, TooltipContent, TooltipPopper, TooltipTail};
9
-
10
- export type {Placement, PopperElementProps};
@@ -1,3 +0,0 @@
1
- // Jest Snapshot v1, https://goo.gl/fbAQLP
2
-
3
- exports[`ActiveTracker #subscribe if already subscribed, throws 1`] = `"Already subscribed."`;
@@ -1,3 +0,0 @@
1
- // Jest Snapshot v1, https://goo.gl/fbAQLP
2
-
3
- exports[`RefTracker #setCallback called with non-function, throws 1`] = `"targetFn must be a function"`;
@@ -1,142 +0,0 @@
1
- import ActiveTracker from "../active-tracker";
2
- import type {IActiveTrackerSubscriber} from "../active-tracker";
3
-
4
- class MockSubscriber implements IActiveTrackerSubscriber {
5
- activeStateStolen = jest.fn();
6
- }
7
-
8
- describe("ActiveTracker", () => {
9
- describe("#subscribe", () => {
10
- test("subscribes to notifications", () => {
11
- // Arrange
12
- const tracker = new ActiveTracker();
13
- const subscriber = new MockSubscriber();
14
- const thief = new MockSubscriber();
15
-
16
- // Act
17
- tracker.subscribe(subscriber);
18
- tracker.steal(thief);
19
-
20
- // Assert
21
- expect(subscriber.activeStateStolen).toHaveBeenCalledTimes(1);
22
- });
23
-
24
- test("if already subscribed, throws", () => {
25
- // Arrange
26
- const tracker = new ActiveTracker();
27
- const subscriber = new MockSubscriber();
28
- tracker.subscribe(subscriber);
29
-
30
- // Act
31
- const underTest = () => tracker.subscribe(subscriber);
32
-
33
- // Assert
34
- expect(underTest).toThrowErrorMatchingSnapshot();
35
- });
36
-
37
- test("returns a function", () => {
38
- // Arrange
39
- const tracker = new ActiveTracker();
40
- const subscriber = new MockSubscriber();
41
-
42
- // Act
43
- const result = tracker.subscribe(subscriber);
44
-
45
- // Assert
46
- expect(result).toBeInstanceOf(Function);
47
- });
48
-
49
- test("returned function unsubscribes from notifications", () => {
50
- // Arrange
51
- const tracker = new ActiveTracker();
52
- const subscriber1 = new MockSubscriber();
53
- const testCase = new MockSubscriber();
54
- const subscriber3 = new MockSubscriber();
55
- const subscriber4 = new MockSubscriber();
56
- tracker.subscribe(subscriber1);
57
- const unsubscribe = tracker.subscribe(testCase);
58
- tracker.subscribe(subscriber3);
59
-
60
- // Act
61
- unsubscribe();
62
- tracker.steal(subscriber4);
63
-
64
- // Assert
65
- expect(testCase.activeStateStolen).not.toHaveBeenCalled();
66
- expect(subscriber1.activeStateStolen).toHaveBeenCalledTimes(1);
67
- expect(subscriber3.activeStateStolen).toHaveBeenCalledTimes(1);
68
- });
69
- });
70
-
71
- describe("#steal", () => {
72
- test("notifies subscribers of theft attempt", () => {
73
- // Arrange
74
- const tracker = new ActiveTracker();
75
- const thief = new MockSubscriber();
76
- const subscriber = new MockSubscriber();
77
- tracker.subscribe(subscriber);
78
-
79
- // Act
80
- tracker.steal(thief);
81
-
82
- // Assert
83
- expect(subscriber.activeStateStolen).toHaveBeenCalledTimes(1);
84
- });
85
-
86
- test("does not notifier thief of their own theft attempt", () => {
87
- // Arrange
88
- const tracker = new ActiveTracker();
89
- const thief = new MockSubscriber();
90
- tracker.subscribe(thief);
91
-
92
- // Act
93
- tracker.steal(thief);
94
-
95
- // Assert
96
- expect(thief.activeStateStolen).not.toHaveBeenCalledTimes(1);
97
- });
98
-
99
- test("returns falsy if active state was not stolen", () => {
100
- // Arrange
101
- const tracker = new ActiveTracker();
102
- const thief = new MockSubscriber();
103
-
104
- // Act
105
- const result = tracker.steal(thief);
106
-
107
- // Assert
108
- expect(result).toBeFalsy();
109
- });
110
-
111
- test("returns truthy if active state was stolen", () => {
112
- // Arrange
113
- const tracker = new ActiveTracker();
114
- const thief = new MockSubscriber();
115
- const owner = new MockSubscriber();
116
- tracker.steal(owner);
117
-
118
- // Act
119
- const result = tracker.steal(thief);
120
-
121
- // Assert
122
- expect(result).toBeTruthy();
123
- });
124
- });
125
-
126
- describe("#giveup", () => {
127
- test("marks the active state as false", () => {
128
- // Arrange
129
- const tracker = new ActiveTracker();
130
- const owner = new MockSubscriber();
131
- tracker.steal(owner);
132
-
133
- // Act
134
- expect(tracker.steal(owner)).toBeTruthy();
135
- tracker.giveup();
136
- const result = tracker.steal(owner);
137
-
138
- // Assert
139
- expect(result).toBeFalsy();
140
- });
141
- });
142
- });
@@ -1,158 +0,0 @@
1
- import * as React from "react";
2
- import * as ReactDOM from "react-dom";
3
- import {render} from "@testing-library/react";
4
- import {View} from "@khanacademy/wonder-blocks-core";
5
-
6
- import RefTracker from "../ref-tracker";
7
-
8
- type CallbackFn = (arg1?: HTMLElement | null | undefined) => void;
9
-
10
- describe("RefTracker", () => {
11
- describe("#setCallback", () => {
12
- test("called with falsy value, no throw", () => {
13
- // Arrange
14
- const tracker = new RefTracker();
15
-
16
- // Act
17
- const underTest = () => tracker.setCallback(null);
18
-
19
- // Assert
20
- expect(underTest).not.toThrow();
21
- });
22
-
23
- test("called with non-function, throws", () => {
24
- // Arrange
25
- const tracker = new RefTracker();
26
- const targetFn = {} as CallbackFn;
27
-
28
- // Act
29
- const underTest = () => tracker.setCallback(targetFn);
30
-
31
- // Assert
32
- expect(underTest).toThrowErrorMatchingSnapshot();
33
- });
34
-
35
- describe("called with a function", () => {
36
- test("no prior call to updateRef, does not call targetFn", () => {
37
- // Arrange
38
- const tracker = new RefTracker();
39
- const targetFn = jest.fn();
40
- // Act
41
- tracker.setCallback(targetFn);
42
-
43
- // Assert
44
- expect(targetFn).not.toHaveBeenCalled();
45
- });
46
-
47
- test("prior updateRef call, target called with ref's node", async () => {
48
- // Arrange
49
- const tracker = new RefTracker();
50
- const targetFn = jest.fn();
51
- const ref = await new Promise((resolve: any) => {
52
- const nodes = (
53
- <View>
54
- <View ref={resolve} />
55
- </View>
56
- );
57
- render(nodes);
58
- });
59
- // @ts-expect-error [FEI-5019] - TS2345 - Argument of type 'unknown' is not assignable to parameter of type 'ReactInstance | null | undefined'.
60
- const domNode = ReactDOM.findDOMNode(ref);
61
- // @ts-expect-error [FEI-5019] - TS2345 - Argument of type 'unknown' is not assignable to parameter of type 'Element | Component<any, {}, any> | null | undefined'.
62
- tracker.updateRef(ref);
63
-
64
- // Act
65
- tracker.setCallback(targetFn);
66
-
67
- // Assert
68
- expect(targetFn).toHaveBeenCalledWith(domNode);
69
- });
70
- });
71
- });
72
-
73
- describe("#updateRef", () => {
74
- describe("calling without setting a callback", () => {
75
- test("falsy ref, no throw", () => {
76
- // Arrange
77
- const tracker = new RefTracker();
78
-
79
- // Act
80
- const underTest = () => tracker.updateRef(null);
81
-
82
- // Assert
83
- expect(underTest).not.toThrow();
84
- });
85
-
86
- test("real ref, no callback, no throw", async () => {
87
- // Arrange
88
- const tracker = new RefTracker();
89
- const ref = await new Promise((resolve: any) => {
90
- const nodes = (
91
- <View>
92
- <View ref={resolve} />
93
- </View>
94
- );
95
- render(nodes);
96
- });
97
-
98
- // Act
99
- // @ts-expect-error [FEI-5019] - TS2345 - Argument of type 'unknown' is not assignable to parameter of type 'Element | Component<any, {}, any> | null | undefined'.
100
- const underTest = () => tracker.updateRef(ref);
101
-
102
- // Assert
103
- expect(underTest).not.toThrow();
104
- });
105
-
106
- test("real ref, targetFn callback, calls the targetFn", async () => {
107
- // Arrange
108
- const tracker = new RefTracker();
109
- const targetFn = jest.fn();
110
- tracker.setCallback(targetFn);
111
-
112
- const ref = await new Promise((resolve: any) => {
113
- const nodes = (
114
- <View>
115
- <View ref={resolve} />
116
- </View>
117
- );
118
- render(nodes);
119
- });
120
- // @ts-expect-error [FEI-5019] - TS2345 - Argument of type 'unknown' is not assignable to parameter of type 'ReactInstance | null | undefined'.
121
- const domNode = ReactDOM.findDOMNode(ref);
122
-
123
- // Act
124
- // @ts-expect-error [FEI-5019] - TS2345 - Argument of type 'unknown' is not assignable to parameter of type 'Element | Component<any, {}, any> | null | undefined'.
125
- tracker.updateRef(ref);
126
-
127
- // Assert
128
- expect(targetFn).toHaveBeenCalledWith(domNode);
129
- });
130
-
131
- test("same ref, targetFn callback, does not call targetFn", async () => {
132
- // Arrange
133
- const tracker = new RefTracker();
134
- const targetFn = jest.fn();
135
- tracker.setCallback(targetFn);
136
-
137
- const ref = await new Promise((resolve: any) => {
138
- const nodes = (
139
- <View>
140
- <View ref={resolve} />
141
- </View>
142
- );
143
- render(nodes);
144
- });
145
- // @ts-expect-error [FEI-5019] - TS2345 - Argument of type 'unknown' is not assignable to parameter of type 'Element | Component<any, {}, any> | null | undefined'.
146
- tracker.updateRef(ref);
147
- targetFn.mockClear();
148
-
149
- // Act
150
- // @ts-expect-error [FEI-5019] - TS2345 - Argument of type 'unknown' is not assignable to parameter of type 'Element | Component<any, {}, any> | null | undefined'.
151
- tracker.updateRef(ref);
152
-
153
- // Assert
154
- expect(targetFn).not.toHaveBeenCalled();
155
- });
156
- });
157
- });
158
- });