@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.
Files changed (36) hide show
  1. package/LICENSE +21 -0
  2. package/dist/es/index.js +1133 -0
  3. package/dist/index.js +1389 -0
  4. package/dist/index.js.flow +2 -0
  5. package/docs.md +11 -0
  6. package/package.json +37 -0
  7. package/src/__tests__/__snapshots__/generated-snapshot.test.js.snap +2674 -0
  8. package/src/__tests__/generated-snapshot.test.js +475 -0
  9. package/src/components/__tests__/__snapshots__/tooltip-tail.test.js.snap +9 -0
  10. package/src/components/__tests__/__snapshots__/tooltip.test.js.snap +47 -0
  11. package/src/components/__tests__/tooltip-anchor.test.js +987 -0
  12. package/src/components/__tests__/tooltip-bubble.test.js +80 -0
  13. package/src/components/__tests__/tooltip-popper.test.js +71 -0
  14. package/src/components/__tests__/tooltip-tail.test.js +117 -0
  15. package/src/components/__tests__/tooltip.integration.test.js +79 -0
  16. package/src/components/__tests__/tooltip.test.js +401 -0
  17. package/src/components/tooltip-anchor.js +330 -0
  18. package/src/components/tooltip-bubble.js +150 -0
  19. package/src/components/tooltip-bubble.md +92 -0
  20. package/src/components/tooltip-content.js +76 -0
  21. package/src/components/tooltip-content.md +34 -0
  22. package/src/components/tooltip-popper.js +101 -0
  23. package/src/components/tooltip-tail.js +462 -0
  24. package/src/components/tooltip-tail.md +143 -0
  25. package/src/components/tooltip.js +235 -0
  26. package/src/components/tooltip.md +194 -0
  27. package/src/components/tooltip.stories.js +76 -0
  28. package/src/index.js +12 -0
  29. package/src/util/__tests__/__snapshots__/active-tracker.test.js.snap +3 -0
  30. package/src/util/__tests__/__snapshots__/ref-tracker.test.js.snap +3 -0
  31. package/src/util/__tests__/active-tracker.test.js +142 -0
  32. package/src/util/__tests__/ref-tracker.test.js +153 -0
  33. package/src/util/active-tracker.js +94 -0
  34. package/src/util/constants.js +7 -0
  35. package/src/util/ref-tracker.js +46 -0
  36. 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,7 @@
1
+ // @flow
2
+ /**
3
+ * The attribute used to identify a tooltip portal.
4
+ */
5
+ export const TooltipPortalAttributeName = "data-tooltip-portal";
6
+ export const TooltipAppearanceDelay = 100;
7
+ export const TooltipDisappearanceDelay = 75;
@@ -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";