@khanacademy/wonder-blocks-tooltip 1.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.
- package/LICENSE +21 -0
- package/dist/es/index.js +1133 -0
- package/dist/index.js +1389 -0
- package/dist/index.js.flow +2 -0
- package/docs.md +11 -0
- package/package.json +37 -0
- package/src/__tests__/__snapshots__/generated-snapshot.test.js.snap +2674 -0
- package/src/__tests__/generated-snapshot.test.js +475 -0
- package/src/components/__tests__/__snapshots__/tooltip-tail.test.js.snap +9 -0
- package/src/components/__tests__/__snapshots__/tooltip.test.js.snap +47 -0
- package/src/components/__tests__/tooltip-anchor.test.js +987 -0
- package/src/components/__tests__/tooltip-bubble.test.js +80 -0
- package/src/components/__tests__/tooltip-popper.test.js +71 -0
- package/src/components/__tests__/tooltip-tail.test.js +117 -0
- package/src/components/__tests__/tooltip.integration.test.js +79 -0
- package/src/components/__tests__/tooltip.test.js +401 -0
- package/src/components/tooltip-anchor.js +330 -0
- package/src/components/tooltip-bubble.js +150 -0
- package/src/components/tooltip-bubble.md +92 -0
- package/src/components/tooltip-content.js +76 -0
- package/src/components/tooltip-content.md +34 -0
- package/src/components/tooltip-popper.js +101 -0
- package/src/components/tooltip-tail.js +462 -0
- package/src/components/tooltip-tail.md +143 -0
- package/src/components/tooltip.js +235 -0
- package/src/components/tooltip.md +194 -0
- package/src/components/tooltip.stories.js +76 -0
- package/src/index.js +12 -0
- package/src/util/__tests__/__snapshots__/active-tracker.test.js.snap +3 -0
- package/src/util/__tests__/__snapshots__/ref-tracker.test.js.snap +3 -0
- package/src/util/__tests__/active-tracker.test.js +142 -0
- package/src/util/__tests__/ref-tracker.test.js +153 -0
- package/src/util/active-tracker.js +94 -0
- package/src/util/constants.js +7 -0
- package/src/util/ref-tracker.js +46 -0
- package/src/util/types.js +29 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import * as ReactDOM from "react-dom";
|
|
4
|
+
import {mount} from "enzyme";
|
|
5
|
+
|
|
6
|
+
import {View} from "@khanacademy/wonder-blocks-core";
|
|
7
|
+
|
|
8
|
+
import RefTracker from "../ref-tracker.js";
|
|
9
|
+
|
|
10
|
+
type CallbackFn = (?HTMLElement) => void;
|
|
11
|
+
|
|
12
|
+
describe("RefTracker", () => {
|
|
13
|
+
describe("#setCallback", () => {
|
|
14
|
+
test("called with falsy value, no throw", () => {
|
|
15
|
+
// Arrange
|
|
16
|
+
const tracker = new RefTracker();
|
|
17
|
+
|
|
18
|
+
// Act
|
|
19
|
+
const underTest = () => tracker.setCallback(null);
|
|
20
|
+
|
|
21
|
+
// Assert
|
|
22
|
+
expect(underTest).not.toThrow();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("called with non-function, throws", () => {
|
|
26
|
+
// Arrange
|
|
27
|
+
const tracker = new RefTracker();
|
|
28
|
+
const targetFn = (({}: any): CallbackFn);
|
|
29
|
+
|
|
30
|
+
// Act
|
|
31
|
+
const underTest = () => tracker.setCallback(targetFn);
|
|
32
|
+
|
|
33
|
+
// Assert
|
|
34
|
+
expect(underTest).toThrowErrorMatchingSnapshot();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("called with a function", () => {
|
|
38
|
+
test("no prior call to updateRef, does not call targetFn", () => {
|
|
39
|
+
// Arrange
|
|
40
|
+
const tracker = new RefTracker();
|
|
41
|
+
const targetFn = jest.fn();
|
|
42
|
+
// Act
|
|
43
|
+
tracker.setCallback(targetFn);
|
|
44
|
+
|
|
45
|
+
// Assert
|
|
46
|
+
expect(targetFn).not.toHaveBeenCalled();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("prior updateRef call, target called with ref's node", async () => {
|
|
50
|
+
// Arrange
|
|
51
|
+
const tracker = new RefTracker();
|
|
52
|
+
const targetFn = jest.fn();
|
|
53
|
+
const ref = await new Promise((resolve) => {
|
|
54
|
+
const nodes = (
|
|
55
|
+
<View>
|
|
56
|
+
<View ref={resolve} />
|
|
57
|
+
</View>
|
|
58
|
+
);
|
|
59
|
+
mount(nodes);
|
|
60
|
+
});
|
|
61
|
+
const domNode = ReactDOM.findDOMNode(ref);
|
|
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) => {
|
|
90
|
+
const nodes = (
|
|
91
|
+
<View>
|
|
92
|
+
<View ref={resolve} />
|
|
93
|
+
</View>
|
|
94
|
+
);
|
|
95
|
+
mount(nodes);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Act
|
|
99
|
+
const underTest = () => tracker.updateRef(ref);
|
|
100
|
+
|
|
101
|
+
// Assert
|
|
102
|
+
expect(underTest).not.toThrow();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("real ref, targetFn callback, calls the targetFn", async () => {
|
|
106
|
+
// Arrange
|
|
107
|
+
const tracker = new RefTracker();
|
|
108
|
+
const targetFn = jest.fn();
|
|
109
|
+
tracker.setCallback(targetFn);
|
|
110
|
+
|
|
111
|
+
const ref = await new Promise((resolve) => {
|
|
112
|
+
const nodes = (
|
|
113
|
+
<View>
|
|
114
|
+
<View ref={resolve} />
|
|
115
|
+
</View>
|
|
116
|
+
);
|
|
117
|
+
mount(nodes);
|
|
118
|
+
});
|
|
119
|
+
const domNode = ReactDOM.findDOMNode(ref);
|
|
120
|
+
|
|
121
|
+
// Act
|
|
122
|
+
tracker.updateRef(ref);
|
|
123
|
+
|
|
124
|
+
// Assert
|
|
125
|
+
expect(targetFn).toHaveBeenCalledWith(domNode);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("same ref, targetFn callback, does not call targetFn", async () => {
|
|
129
|
+
// Arrange
|
|
130
|
+
const tracker = new RefTracker();
|
|
131
|
+
const targetFn = jest.fn();
|
|
132
|
+
tracker.setCallback(targetFn);
|
|
133
|
+
|
|
134
|
+
const ref = await new Promise((resolve) => {
|
|
135
|
+
const nodes = (
|
|
136
|
+
<View>
|
|
137
|
+
<View ref={resolve} />
|
|
138
|
+
</View>
|
|
139
|
+
);
|
|
140
|
+
mount(nodes);
|
|
141
|
+
});
|
|
142
|
+
tracker.updateRef(ref);
|
|
143
|
+
targetFn.mockClear();
|
|
144
|
+
|
|
145
|
+
// Act
|
|
146
|
+
tracker.updateRef(ref);
|
|
147
|
+
|
|
148
|
+
// Assert
|
|
149
|
+
expect(targetFn).not.toHaveBeenCalled();
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* This interface should be implemented by types that are interested in the
|
|
5
|
+
* notifications of active state being stolen. Generally, this would also be
|
|
6
|
+
* subscribers that may also steal active state, but not necessarily.
|
|
7
|
+
*
|
|
8
|
+
* Once implemented, the type must call subscribe on a tracker to begin
|
|
9
|
+
* receiving notifications.
|
|
10
|
+
*/
|
|
11
|
+
export interface IActiveTrackerSubscriber {
|
|
12
|
+
/**
|
|
13
|
+
* Notification raised when something steals the active state from a
|
|
14
|
+
* subscribed tracker.
|
|
15
|
+
*/
|
|
16
|
+
activeStateStolen: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* This class is used to track the concept of active state (though technically
|
|
21
|
+
* that could be any boolean state). The tracker has a variety of subscribers
|
|
22
|
+
* that receive notifications of state theft and can steal the state.
|
|
23
|
+
*
|
|
24
|
+
* For the tooltip, this enables us to have a single tooltip active at any one
|
|
25
|
+
* time. The tracker allows tooltip anchors to coordinate which of them is
|
|
26
|
+
* active, and to ensure that if a different one becomes active, all the others
|
|
27
|
+
* know that they aren't.
|
|
28
|
+
*
|
|
29
|
+
* - When notified that the state has been stolen, subscribers can immediately
|
|
30
|
+
* reflect that theft (in the case of a tooltip, they would hide themselves).
|
|
31
|
+
* - The thief does not get notified if they were the one who stole the state
|
|
32
|
+
* since they should already know that they did that (this avoids having to have
|
|
33
|
+
* checks for reentrancy, for example).
|
|
34
|
+
* - When the subscriber that owns the state no longer needs it, it can
|
|
35
|
+
* voluntarily give it up.
|
|
36
|
+
* - If the state is stolen while a subscriber owns the
|
|
37
|
+
* state, that subscriber does not give up the state, as it doesn't have it
|
|
38
|
+
* anymore (it was stolen).
|
|
39
|
+
*/
|
|
40
|
+
export default class ActiveTracker {
|
|
41
|
+
_subscribers: Array<IActiveTrackerSubscriber> = [];
|
|
42
|
+
_active: boolean;
|
|
43
|
+
|
|
44
|
+
_getIndex(who: IActiveTrackerSubscriber): number {
|
|
45
|
+
return this._subscribers.findIndex((v) => v === who);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Called when a tooltip anchor becomes active so that it can tell all other
|
|
50
|
+
* anchors that they are no longer the active tooltip. Returns true if
|
|
51
|
+
* the there was a steal of active state from another anchor; otherwise, if
|
|
52
|
+
* no other anchor had been active, returns false.
|
|
53
|
+
*/
|
|
54
|
+
steal(who: IActiveTrackerSubscriber): boolean {
|
|
55
|
+
const wasActive = !!this._active;
|
|
56
|
+
this._active = true;
|
|
57
|
+
for (const anchor of this._subscribers) {
|
|
58
|
+
if (anchor === who) {
|
|
59
|
+
// We don't need to notify the thief.
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
anchor.activeStateStolen();
|
|
63
|
+
}
|
|
64
|
+
return wasActive;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Called if a tooltip doesn't want to be active anymore.
|
|
69
|
+
* Should not be called when being told the active spot was stolen by
|
|
70
|
+
* another anchor, only when the anchor is unhovered and unfocused and they
|
|
71
|
+
* were active.
|
|
72
|
+
*/
|
|
73
|
+
giveup() {
|
|
74
|
+
this._active = false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Subscribes a tooltip anchor to the tracker so that it can be notified of
|
|
79
|
+
* steals. Returns a method that can be used to unsubscribe the anchor from
|
|
80
|
+
* notifications.
|
|
81
|
+
*/
|
|
82
|
+
subscribe(who: IActiveTrackerSubscriber): () => void {
|
|
83
|
+
if (this._getIndex(who) >= 0) {
|
|
84
|
+
throw new Error("Already subscribed.");
|
|
85
|
+
}
|
|
86
|
+
this._subscribers.push(who);
|
|
87
|
+
|
|
88
|
+
const unsubscribe = () => {
|
|
89
|
+
const index = this._getIndex(who);
|
|
90
|
+
this._subscribers.splice(index, 1);
|
|
91
|
+
};
|
|
92
|
+
return unsubscribe;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
/**
|
|
3
|
+
* This is a little helper that we can use to wrap the react-popper reference
|
|
4
|
+
* update methods so that we can convert a regular React ref into a DOM node
|
|
5
|
+
* as react-popper expects, and also ensure we only update react-popper
|
|
6
|
+
* on actual changes, and not just renders of the same thing.
|
|
7
|
+
*/
|
|
8
|
+
import * as React from "react";
|
|
9
|
+
import * as ReactDOM from "react-dom";
|
|
10
|
+
|
|
11
|
+
import type {PopperChildrenProps} from "react-popper";
|
|
12
|
+
import type {getRefFn} from "./types.js";
|
|
13
|
+
|
|
14
|
+
type PopperRef = $PropertyType<PopperChildrenProps, "ref">;
|
|
15
|
+
|
|
16
|
+
export default class RefTracker {
|
|
17
|
+
updateRef: getRefFn;
|
|
18
|
+
_lastRef: ?HTMLElement;
|
|
19
|
+
_targetFn: ?(?HTMLElement) => void;
|
|
20
|
+
|
|
21
|
+
updateRef: (ref: ?(React.Component<any> | Element)) => void = (ref) => {
|
|
22
|
+
if (ref) {
|
|
23
|
+
// We only want to update the reference if it is
|
|
24
|
+
// actually changed. Otherwise, we can trigger another render that
|
|
25
|
+
// would then update the reference again and just keep looping.
|
|
26
|
+
const domNode = ReactDOM.findDOMNode(ref);
|
|
27
|
+
if (domNode instanceof HTMLElement && domNode !== this._lastRef) {
|
|
28
|
+
this._lastRef = domNode;
|
|
29
|
+
this._targetFn && this._targetFn(domNode);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
setCallback: (targetFn: ?PopperRef) => void = (targetFn) => {
|
|
35
|
+
if (this._targetFn !== targetFn) {
|
|
36
|
+
if (targetFn && typeof targetFn !== "function") {
|
|
37
|
+
throw new Error("targetFn must be a function");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
this._targetFn = targetFn || null;
|
|
41
|
+
if (this._lastRef && this._targetFn) {
|
|
42
|
+
this._targetFn(this._lastRef);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
|
|
4
|
+
export type getRefFn = (?(React.Component<any> | Element)) => void;
|
|
5
|
+
|
|
6
|
+
export type Offset = {|
|
|
7
|
+
bottom: ?string,
|
|
8
|
+
top: ?string,
|
|
9
|
+
left: ?string,
|
|
10
|
+
right: ?string,
|
|
11
|
+
transform: ?string,
|
|
12
|
+
|};
|
|
13
|
+
|
|
14
|
+
export type Placement =
|
|
15
|
+
| "auto"
|
|
16
|
+
| "auto-start"
|
|
17
|
+
| "auto-end"
|
|
18
|
+
| "top"
|
|
19
|
+
| "top-start"
|
|
20
|
+
| "top-end"
|
|
21
|
+
| "bottom"
|
|
22
|
+
| "bottom-start"
|
|
23
|
+
| "bottom-end"
|
|
24
|
+
| "right"
|
|
25
|
+
| "right-start"
|
|
26
|
+
| "right-end"
|
|
27
|
+
| "left"
|
|
28
|
+
| "left-start"
|
|
29
|
+
| "left-end";
|