@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,341 +0,0 @@
1
- /**
2
- * This component turns the given content into an accessible anchor for
3
- * positioning and displaying tooltips.
4
- */
5
- import * as React from "react";
6
- import * as ReactDOM from "react-dom";
7
-
8
- import {Text as WBText} from "@khanacademy/wonder-blocks-core";
9
- import type {IIdentifierFactory} from "@khanacademy/wonder-blocks-core";
10
-
11
- import ActiveTracker from "../util/active-tracker";
12
- import {
13
- TooltipAppearanceDelay,
14
- TooltipDisappearanceDelay,
15
- } from "../util/constants";
16
-
17
- import type {IActiveTrackerSubscriber} from "../util/active-tracker";
18
-
19
- type Props = {
20
- /**
21
- * The content for anchoring the tooltip.
22
- * This element will be used to position the tooltip.
23
- * If a string is passed as children we wrap it in a Text element.
24
- * We allow children to be a string so that we can add tooltips to
25
- * words within a large block of text easily.
26
- */
27
- children: React.ReactElement<any> | string;
28
- /**
29
- * Callback to be invoked when the anchored content is mounted.
30
- * This provides a reference to the anchored content, which can then be
31
- * used for calculating tooltip bubble positioning.
32
- */
33
- anchorRef: (arg1?: Element | null | undefined) => unknown;
34
- /**
35
- * When true, if a tabindex attribute is not already present on the element
36
- * wrapped by the anchor, the element will be given tabindex=0 to make it
37
- * keyboard focusable; otherwise, does not attempt to change the ability to
38
- * focus the anchor element.
39
- *
40
- * Defaults to true.
41
- *
42
- * One might set this to false in circumstances where the wrapped component
43
- * already can receive focus or contains an element that can.
44
- * Use good judgement when overriding this value, the tooltip content should
45
- * be accessible via keyboard in all circumstances where the tooltip would
46
- * appear using the mouse, so verify those use-cases.
47
- */
48
- forceAnchorFocusivity?: boolean;
49
- /**
50
- * Callback to pass active state back to Tooltip.
51
- *
52
- * `active` will be true whenever the anchor is hovered or focused and false
53
- * otherwise.
54
- */
55
- onActiveChanged: (active: boolean) => unknown;
56
- /**
57
- * Optional unique id factory.
58
- */
59
- ids?: IIdentifierFactory;
60
- };
61
-
62
- type DefaultProps = {
63
- forceAnchorFocusivity: Props["forceAnchorFocusivity"];
64
- };
65
-
66
- type State = {
67
- /** Is the anchor active or not? */
68
- active: boolean;
69
- };
70
-
71
- const TRACKER = new ActiveTracker();
72
-
73
- export default class TooltipAnchor
74
- extends React.Component<Props, State>
75
- implements IActiveTrackerSubscriber
76
- {
77
- _weSetFocusivity: boolean | null | undefined;
78
- _anchorNode: Element | null | undefined;
79
- _focused: boolean;
80
- _hovered: boolean;
81
- // @ts-expect-error [FEI-5019] - TS2564 - Property '_stolenFromUs' has no initializer and is not definitely assigned in the constructor.
82
- _stolenFromUs: boolean;
83
- // @ts-expect-error [FEI-5019] - TS2564 - Property '_unsubscribeFromTracker' has no initializer and is not definitely assigned in the constructor.
84
- _unsubscribeFromTracker: () => void | null | undefined;
85
- _timeoutID: number | null | undefined;
86
-
87
- static defaultProps: DefaultProps = {
88
- forceAnchorFocusivity: true,
89
- };
90
-
91
- constructor(props: Props) {
92
- super(props);
93
-
94
- this._focused = false;
95
- this._hovered = false;
96
- this.state = {
97
- active: false,
98
- };
99
- }
100
-
101
- componentDidMount() {
102
- const anchorNode = ReactDOM.findDOMNode(this);
103
-
104
- // This should never happen, but we have this check here to make TypeScript
105
- // happy and ensure that if this does happen, we'll know about it.
106
- if (anchorNode instanceof Text) {
107
- throw new Error(
108
- "TooltipAnchor must be applied to an Element. Text content is not supported.",
109
- );
110
- }
111
-
112
- this._unsubscribeFromTracker = TRACKER.subscribe(this);
113
- this._anchorNode = anchorNode;
114
- this._updateFocusivity();
115
- if (anchorNode) {
116
- /**
117
- * TODO(somewhatabstract): Work out how to allow pointer to go over
118
- * the tooltip content to keep it active. This likely requires
119
- * pointer events but that would break the obscurement checks we do.
120
- * So, careful consideration required. See WB-302.
121
- */
122
- anchorNode.addEventListener("focusin", this._handleFocusIn);
123
- anchorNode.addEventListener("focusout", this._handleFocusOut);
124
- anchorNode.addEventListener("mouseenter", this._handleMouseEnter);
125
- anchorNode.addEventListener("mouseleave", this._handleMouseLeave);
126
-
127
- this.props.anchorRef(this._anchorNode);
128
- }
129
- }
130
-
131
- componentDidUpdate(prevProps: Props) {
132
- if (
133
- prevProps.forceAnchorFocusivity !==
134
- this.props.forceAnchorFocusivity ||
135
- prevProps.children !== this.props.children
136
- ) {
137
- this._updateFocusivity();
138
- }
139
- }
140
-
141
- componentWillUnmount() {
142
- if (this._unsubscribeFromTracker) {
143
- this._unsubscribeFromTracker();
144
- }
145
- this._clearPendingAction();
146
-
147
- const anchorNode = this._anchorNode;
148
- if (anchorNode) {
149
- anchorNode.removeEventListener("focusin", this._handleFocusIn);
150
- anchorNode.removeEventListener("focusout", this._handleFocusOut);
151
- anchorNode.removeEventListener(
152
- "mouseenter",
153
- this._handleMouseEnter,
154
- );
155
- anchorNode.removeEventListener(
156
- "mouseleave",
157
- this._handleMouseLeave,
158
- );
159
- }
160
- if (this.state.active) {
161
- document.removeEventListener("keyup", this._handleKeyUp);
162
- }
163
- }
164
-
165
- static ariaContentId = "aria-content";
166
-
167
- activeStateStolen: () => void = () => {
168
- // Something wants the active state.
169
- // Do we have it? If so, let's remember that.
170
- // If we are already active, or we're inactive but have a timeoutID,
171
- // then it was stolen from us.
172
- this._stolenFromUs = this.state.active || !!this._timeoutID;
173
- // Let's first tell ourselves we're not focused (otherwise the tooltip
174
- // will be sticky on the next hover of this anchor and that just looks
175
- // weird).
176
- this._focused = false;
177
- // Now update our actual state.
178
- this._setActiveState(false, true);
179
- };
180
-
181
- _updateFocusivity() {
182
- const anchorNode = this._anchorNode;
183
- if (!anchorNode) {
184
- return;
185
- }
186
- const {forceAnchorFocusivity} = this.props;
187
- const currentTabIndex = anchorNode.getAttribute("tabindex");
188
-
189
- if (forceAnchorFocusivity && !currentTabIndex) {
190
- // Ensure that the anchor point is keyboard focusable so that
191
- // we can show the tooltip for visually impaired users that don't
192
- // use pointer devices nor assistive technology like screen readers.
193
- anchorNode.setAttribute("tabindex", "0");
194
- this._weSetFocusivity = true;
195
- } else if (!forceAnchorFocusivity && currentTabIndex) {
196
- // We may not be forcing it, but we also want to ensure that if we
197
- // did before, we remove it.
198
- if (this._weSetFocusivity) {
199
- anchorNode.removeAttribute("tabindex");
200
- this._weSetFocusivity = false;
201
- }
202
- }
203
- }
204
-
205
- _updateActiveState(hovered: boolean, focused: boolean) {
206
- // Update our stored values.
207
- this._hovered = hovered;
208
- this._focused = focused;
209
-
210
- this._setActiveState(hovered || focused);
211
- }
212
-
213
- _clearPendingAction() {
214
- if (this._timeoutID) {
215
- clearTimeout(this._timeoutID);
216
- this._timeoutID = null;
217
- }
218
- }
219
-
220
- _setActiveState(active: boolean, instant?: boolean) {
221
- if (
222
- this._stolenFromUs ||
223
- active !== this.state.active ||
224
- (!this.state.active && this._timeoutID)
225
- ) {
226
- // If we are about to lose active state or change it, we need to
227
- // cancel any pending action to show ourselves.
228
- // So, if active is stolen from us, we are changing active state,
229
- // or we are inactive and have a timer, clear the action.
230
- this._clearPendingAction();
231
- } else if (active === this.state.active) {
232
- if (this._timeoutID) {
233
- // Cancel pending action if the current `this.state.active` is
234
- // already the value we want to set it to (ie. the `active` arg).
235
- // This is okay to cancel because:
236
- // - if the pending action was to set `this.state.active` to the
237
- // same value, it is not needed because it already is up to date
238
- // - if the pending action was to set `this.state.active` to the
239
- // opposite value, it is not needed because there is a more recent
240
- // event that triggered this function with an `active` arg that is
241
- // the same value as the current state.
242
- this._clearPendingAction();
243
- }
244
- // Nothing else to do if active state is up to date.
245
- return;
246
- }
247
-
248
- // Determine if we are doing things immediately or not.
249
- instant = instant || (active && TRACKER.steal(this));
250
-
251
- if (instant) {
252
- if (active) {
253
- document.addEventListener("keyup", this._handleKeyUp);
254
- } else {
255
- document.removeEventListener("keyup", this._handleKeyUp);
256
- }
257
- this.setState({active});
258
- this.props.onActiveChanged(active);
259
- if (!this._stolenFromUs && !active) {
260
- // Only the very last thing going inactive will giveup
261
- // the stolen active state.
262
- TRACKER.giveup();
263
- }
264
- this._stolenFromUs = false;
265
- } else {
266
- const delay = active
267
- ? TooltipAppearanceDelay
268
- : TooltipDisappearanceDelay;
269
- // @ts-expect-error [FEI-5019] - TS2322 - Type 'Timeout' is not assignable to type 'number'.
270
- this._timeoutID = setTimeout(() => {
271
- this._timeoutID = null;
272
- this._setActiveState(active, true);
273
- }, delay);
274
- }
275
- }
276
-
277
- _handleFocusIn: () => void = () => {
278
- this._updateActiveState(this._hovered, true);
279
- };
280
-
281
- _handleFocusOut: () => void = () => {
282
- this._updateActiveState(this._hovered, false);
283
- };
284
-
285
- _handleMouseEnter: () => void = () => {
286
- this._updateActiveState(true, this._focused);
287
- };
288
-
289
- _handleMouseLeave: () => void = () => {
290
- this._updateActiveState(false, this._focused);
291
- };
292
-
293
- _handleKeyUp: (e: KeyboardEvent) => void = (e) => {
294
- // We check the key as that's keyboard layout agnostic and also avoids
295
- // the minefield of deprecated number type properties like keyCode and
296
- // which, with the replacement code, which uses a string instead.
297
- if (e.key === "Escape" && this.state.active) {
298
- // Stop the event going any further.
299
- // For cancellation events, like the Escape key, we generally should
300
- // air on the side of caution and only allow it to cancel one thing.
301
- // So, it's polite for us to stop propagation of the event.
302
- // Otherwise, we end up with UX where one Escape key press
303
- // unexpectedly cancels multiple things.
304
- //
305
- // For example, using Escape to close a tooltip or a dropdown while
306
- // displaying a modal and having the modal close as well. This would
307
- // be annoyingly bad UX.
308
- e.preventDefault();
309
- e.stopPropagation();
310
- this._updateActiveState(false, false);
311
- }
312
- };
313
-
314
- _renderAnchorableChildren(): React.ReactElement<any> {
315
- const {children} = this.props;
316
- return typeof children === "string" ? (
317
- <WBText>{children}</WBText>
318
- ) : (
319
- children
320
- );
321
- }
322
-
323
- _renderAccessibleChildren(ids: IIdentifierFactory): React.ReactNode {
324
- const anchorableChildren = this._renderAnchorableChildren();
325
-
326
- return React.cloneElement(anchorableChildren, {
327
- "aria-describedby": ids.get(TooltipAnchor.ariaContentId),
328
- });
329
- }
330
-
331
- render(): React.ReactNode {
332
- // We need to make sure we can anchor on our content.
333
- // If the content is just a string, we wrap it in a Text element
334
- // so as not to affect styling or layout but still have an element
335
- // to anchor to.
336
- if (this.props.ids) {
337
- return this._renderAccessibleChildren(this.props.ids);
338
- }
339
- return this._renderAnchorableChildren();
340
- }
341
- }
@@ -1,132 +0,0 @@
1
- import {StyleSheet} from "aphrodite";
2
- import * as React from "react";
3
- import {View} from "@khanacademy/wonder-blocks-core";
4
- import {color, spacing} from "@khanacademy/wonder-blocks-tokens";
5
-
6
- import TooltipContent from "./tooltip-content";
7
- import TooltipTail from "./tooltip-tail";
8
- import {PopperElementProps} from "../util/types";
9
-
10
- export type Props = {
11
- /** The unique identifier for this component. */
12
- id: string;
13
- /** The `TooltipContent` element that will be rendered in the bubble. */
14
- children: React.ReactElement<React.ComponentProps<typeof TooltipContent>>;
15
- onActiveChanged: (active: boolean) => unknown;
16
- /** Optional background color. */
17
- backgroundColor?: keyof typeof color;
18
- } & PopperElementProps; // (v3 beta introduces this) // TODO(somewhatabstract): Update react-docgen to support spread operators
19
-
20
- type State = {
21
- active: boolean;
22
- };
23
-
24
- export default class TooltipBubble extends React.Component<Props, State> {
25
- state: State = {
26
- active: false,
27
- };
28
-
29
- _setActiveState(active: boolean) {
30
- this.setState({active});
31
- this.props.onActiveChanged(active);
32
- }
33
-
34
- handleMouseEnter: () => void = () => {
35
- this._setActiveState(true);
36
- };
37
-
38
- handleMouseLeave: () => void = () => {
39
- this.props.onActiveChanged(false);
40
- };
41
-
42
- render(): React.ReactNode {
43
- const {
44
- id,
45
- children,
46
- updateBubbleRef,
47
- placement,
48
- isReferenceHidden,
49
- style,
50
- updateTailRef,
51
- tailOffset,
52
- backgroundColor,
53
- } = this.props;
54
- return (
55
- <View
56
- id={id}
57
- role="tooltip"
58
- data-placement={placement}
59
- onMouseEnter={this.handleMouseEnter}
60
- onMouseLeave={this.handleMouseLeave}
61
- ref={updateBubbleRef}
62
- style={[
63
- isReferenceHidden && styles.hide,
64
- styles.bubble,
65
- styles[`content-${placement}`],
66
- style,
67
- ]}
68
- >
69
- <View
70
- style={[
71
- styles.content,
72
- backgroundColor && {
73
- backgroundColor: color[backgroundColor],
74
- },
75
- ]}
76
- >
77
- {children}
78
- </View>
79
- <TooltipTail
80
- updateRef={updateTailRef}
81
- placement={placement}
82
- offset={tailOffset}
83
- color={backgroundColor}
84
- />
85
- </View>
86
- );
87
- }
88
- }
89
-
90
- const styles = StyleSheet.create({
91
- bubble: {
92
- position: "absolute",
93
- },
94
-
95
- /**
96
- * The hide style ensures that the bounds of the bubble stay unchanged.
97
- * This is because popper.js calculates the bubble position based off its
98
- * bounds and if we stopped rendering it entirely, it wouldn't know where to
99
- * place it when it reappeared.
100
- */
101
- hide: {
102
- pointerEvents: "none",
103
- opacity: 0,
104
- backgroundColor: "transparent",
105
- color: "transparent",
106
- },
107
-
108
- /**
109
- * Ensure the content and tail are properly arranged.
110
- */
111
- "content-top": {
112
- flexDirection: "column",
113
- },
114
- "content-right": {
115
- flexDirection: "row-reverse",
116
- },
117
- "content-bottom": {
118
- flexDirection: "column-reverse",
119
- },
120
- "content-left": {
121
- flexDirection: "row",
122
- },
123
-
124
- content: {
125
- maxWidth: 472,
126
- borderRadius: spacing.xxxSmall_4,
127
- border: `solid 1px ${color.offBlack16}`,
128
- backgroundColor: color.white,
129
- boxShadow: `0 ${spacing.xSmall_8}px ${spacing.xSmall_8}px 0 ${color.offBlack8}`,
130
- justifyContent: "center",
131
- },
132
- });
@@ -1,96 +0,0 @@
1
- import * as React from "react";
2
- import {StyleSheet} from "aphrodite";
3
-
4
- import {View} from "@khanacademy/wonder-blocks-core";
5
- import {Strut} from "@khanacademy/wonder-blocks-layout";
6
- import {spacing} from "@khanacademy/wonder-blocks-tokens";
7
- import {HeadingSmall, LabelMedium} from "@khanacademy/wonder-blocks-typography";
8
- import type {Typography} from "@khanacademy/wonder-blocks-typography";
9
-
10
- import {ContentStyle} from "../util/types";
11
-
12
- type Props = {
13
- /**
14
- * The title for the tooltip content.
15
- * Optional.
16
- */
17
- title?: string | React.ReactElement<React.ComponentProps<Typography>>;
18
- /**
19
- * The main content for a tooltip.
20
- */
21
- children:
22
- | string
23
- | React.ReactElement<React.ComponentProps<Typography>>
24
- | Array<React.ReactElement<React.ComponentProps<Typography>>>;
25
- /**
26
- * Optional custom styles for the tooltip which are a subset of valid CSS styles
27
- */
28
- contentStyle?: ContentStyle;
29
- /**
30
- * Test ID used for e2e testing.
31
- */
32
- testId?: string;
33
- };
34
-
35
- /**
36
- * This component is used to provide the content that is to be rendered in the
37
- * tooltip bubble.
38
- *
39
- * ### Usage
40
- *
41
- * ```jsx
42
- * import {TooltipContent} from "@khanacademy/wonder-blocks-tooltip";
43
- *
44
- * <TooltipContent title="Title text!">
45
- * Some content in my tooltip.
46
- * </TooltipContent>
47
- * ```
48
- */
49
- export default class TooltipContent extends React.Component<Props> {
50
- _renderTitle(): React.ReactNode {
51
- const {title} = this.props;
52
- if (title) {
53
- if (typeof title === "string") {
54
- return <HeadingSmall>{title}</HeadingSmall>;
55
- } else {
56
- return title;
57
- }
58
- }
59
- return null;
60
- }
61
-
62
- _renderChildren(): React.ReactNode {
63
- const {children} = this.props;
64
- if (typeof children === "string") {
65
- return <LabelMedium>{children}</LabelMedium>;
66
- } else {
67
- return children;
68
- }
69
- }
70
-
71
- render(): React.ReactNode {
72
- const title = this._renderTitle();
73
- const children = this._renderChildren();
74
- const containerStyle = title ? styles.withTitle : styles.withoutTitle;
75
- return (
76
- <View
77
- style={[containerStyle, this.props.contentStyle]}
78
- testId={this.props.testId}
79
- >
80
- {title}
81
- {title && children && <Strut size={spacing.xxxSmall_4} />}
82
- {children}
83
- </View>
84
- );
85
- }
86
- }
87
-
88
- const styles = StyleSheet.create({
89
- withoutTitle: {
90
- padding: `10px ${spacing.medium_16}px`,
91
- },
92
-
93
- withTitle: {
94
- padding: spacing.medium_16,
95
- },
96
- });