@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,80 @@
|
|
|
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 TooltipBubble from "../tooltip-bubble.js";
|
|
9
|
+
import typeof TooltipContent from "../tooltip-content.js";
|
|
10
|
+
|
|
11
|
+
const sleep = (duration = 0) =>
|
|
12
|
+
new Promise((resolve, reject) => setTimeout(resolve, duration));
|
|
13
|
+
|
|
14
|
+
describe("TooltipBubble", () => {
|
|
15
|
+
// A little helper method to make the actual test more readable.
|
|
16
|
+
const makePopperProps = () => ({
|
|
17
|
+
placement: "top",
|
|
18
|
+
tailOffset: {
|
|
19
|
+
top: "0",
|
|
20
|
+
left: "50",
|
|
21
|
+
bottom: undefined,
|
|
22
|
+
right: undefined,
|
|
23
|
+
transform: "translate3d(50, 0, 0)",
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("updates reference to bubble container", async () => {
|
|
28
|
+
// Arrange
|
|
29
|
+
const bubbleNode = await new Promise((resolve) => {
|
|
30
|
+
// Get some props and set the ref to our assert, that way we assert
|
|
31
|
+
// when the bubble component is mounted.
|
|
32
|
+
const props = makePopperProps();
|
|
33
|
+
|
|
34
|
+
// Do some casting to pretend this is `TooltipContent`. That way
|
|
35
|
+
// we are isolating behaviors a bit more.
|
|
36
|
+
const fakeContent = (((
|
|
37
|
+
<View id="content">Some content</View>
|
|
38
|
+
): any): React.Element<TooltipContent>);
|
|
39
|
+
const nodes = (
|
|
40
|
+
<View>
|
|
41
|
+
<TooltipBubble
|
|
42
|
+
id="bubble"
|
|
43
|
+
placement={props.placement}
|
|
44
|
+
tailOffset={props.tailOffset}
|
|
45
|
+
updateBubbleRef={resolve}
|
|
46
|
+
onActiveChanged={() => {}}
|
|
47
|
+
>
|
|
48
|
+
{fakeContent}
|
|
49
|
+
</TooltipBubble>
|
|
50
|
+
</View>
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
// Act
|
|
54
|
+
mount(nodes);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* All we're doing is making sure we got called and verifying that
|
|
59
|
+
* we got called with an element we expect.
|
|
60
|
+
*/
|
|
61
|
+
// Assert
|
|
62
|
+
// Did we get a node?
|
|
63
|
+
expect(bubbleNode).toBeDefined();
|
|
64
|
+
|
|
65
|
+
// Is the node a mounted element?
|
|
66
|
+
const realElement = ReactDOM.findDOMNode(bubbleNode);
|
|
67
|
+
expect(realElement instanceof Element).toBeTruthy();
|
|
68
|
+
|
|
69
|
+
// Keep flow happy...
|
|
70
|
+
if (realElement instanceof Element) {
|
|
71
|
+
// Did we apply our data attribute?
|
|
72
|
+
expect(realElement.getAttribute("data-placement")).toBe("top");
|
|
73
|
+
|
|
74
|
+
// Did we render our content?
|
|
75
|
+
await sleep();
|
|
76
|
+
const contentElement = document.getElementById("content");
|
|
77
|
+
expect(contentElement).toBeDefined();
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
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 typeof TooltipBubble from "../tooltip-bubble.js";
|
|
9
|
+
import TooltipPopper from "../tooltip-popper.js";
|
|
10
|
+
|
|
11
|
+
type State = {|ref: ?HTMLElement|};
|
|
12
|
+
/**
|
|
13
|
+
* A little wrapper for the TooltipPopper so that we can provide an anchor
|
|
14
|
+
* element reference and test that the children get rendered.
|
|
15
|
+
*/
|
|
16
|
+
class TestHarness extends React.Component<any, State> {
|
|
17
|
+
state: State = {
|
|
18
|
+
ref: null,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
updateRef(ref) {
|
|
22
|
+
const actualRef = ref && ReactDOM.findDOMNode(ref);
|
|
23
|
+
if (actualRef && this.state.ref !== actualRef) {
|
|
24
|
+
this.setState({ref: ((actualRef: any): ?HTMLElement)});
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
render(): React.Node {
|
|
29
|
+
const fakeBubble = (((
|
|
30
|
+
<View ref={(ref) => this.props.resultRef(ref)}>Fake bubble</View>
|
|
31
|
+
): any): React.Element<TooltipBubble>);
|
|
32
|
+
return (
|
|
33
|
+
<View>
|
|
34
|
+
<View ref={(ref) => this.updateRef(ref)}>Anchor</View>
|
|
35
|
+
<TooltipPopper
|
|
36
|
+
placement={this.props.placement}
|
|
37
|
+
anchorElement={this.state.ref}
|
|
38
|
+
>
|
|
39
|
+
{(props) => fakeBubble}
|
|
40
|
+
</TooltipPopper>
|
|
41
|
+
</View>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("TooltipPopper", () => {
|
|
47
|
+
// The TooltipPopper component is just a wrapper around react-popper.
|
|
48
|
+
// PopperJS requires full visual rendering and we don't do that here as
|
|
49
|
+
// we're not in a browser.
|
|
50
|
+
// So, let's do a test that we at least render the content how we expect
|
|
51
|
+
// and use other things to test the overall placement things.
|
|
52
|
+
test("ensure component renders", async () => {
|
|
53
|
+
// Arrange
|
|
54
|
+
const ref = await new Promise((resolve, reject) => {
|
|
55
|
+
const nodes = (
|
|
56
|
+
<View>
|
|
57
|
+
<TestHarness placement="bottom" resultRef={resolve} />
|
|
58
|
+
</View>
|
|
59
|
+
);
|
|
60
|
+
mount(nodes);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
if (!ref) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Act
|
|
68
|
+
// Assert
|
|
69
|
+
expect(ref).toBeDefined();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import {shallow} from "enzyme";
|
|
4
|
+
|
|
5
|
+
import TooltipTail from "../tooltip-tail.js";
|
|
6
|
+
|
|
7
|
+
import type {Placement} from "../../util/types.js";
|
|
8
|
+
|
|
9
|
+
describe("TooltipTail", () => {
|
|
10
|
+
describe("#render", () => {
|
|
11
|
+
test("unknown placement, throws", () => {
|
|
12
|
+
// Arrange
|
|
13
|
+
const fakePlacement = (("notaplacement": any): Placement);
|
|
14
|
+
const nodes = <TooltipTail placement={fakePlacement} />;
|
|
15
|
+
|
|
16
|
+
// Act
|
|
17
|
+
const underTest = () => shallow(nodes);
|
|
18
|
+
|
|
19
|
+
// Assert
|
|
20
|
+
expect(underTest).toThrowErrorMatchingSnapshot();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("known placement, does not throw", () => {
|
|
24
|
+
// Arrange
|
|
25
|
+
const testPoints = ["top", "right", "bottom", "left"];
|
|
26
|
+
const makeNode = (p) => <TooltipTail placement={p} />;
|
|
27
|
+
|
|
28
|
+
// Act
|
|
29
|
+
const testees = testPoints.map((tp) => () => shallow(makeNode(tp)));
|
|
30
|
+
|
|
31
|
+
// Assert
|
|
32
|
+
for (const testee of testees) {
|
|
33
|
+
expect(testee).not.toThrowError();
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("INTERNALS", () => {
|
|
39
|
+
// We have some code internally that is there for code maintenance to
|
|
40
|
+
// catch if we add a new placement string and forget to update one of
|
|
41
|
+
// our methods. These tests are to verify those maintenance checks
|
|
42
|
+
// so they poke at the insides of the TooltipTail. We only test the
|
|
43
|
+
// throw case in these examples and rely on testing the external
|
|
44
|
+
// behavior of the component to verify the expected execution paths.
|
|
45
|
+
test("_getFilterPositioning throws on bad placement", () => {
|
|
46
|
+
// Arrange
|
|
47
|
+
const fakePlacement = (("notaplacement": any): Placement);
|
|
48
|
+
// We need an instance so let's make one with a valid placement so
|
|
49
|
+
// that we can render it.
|
|
50
|
+
const nodes = <TooltipTail placement="top" />;
|
|
51
|
+
const wrapper = shallow(nodes);
|
|
52
|
+
const tailInstance = wrapper.instance();
|
|
53
|
+
// Sneakily change props so as not to re-render.
|
|
54
|
+
// This means getting rid of the read-only props and
|
|
55
|
+
// recreating so that we can write a fake placement value
|
|
56
|
+
// for testing.
|
|
57
|
+
const oldProps = tailInstance.props;
|
|
58
|
+
// $FlowIgnore
|
|
59
|
+
delete tailInstance.props;
|
|
60
|
+
tailInstance.props = {
|
|
61
|
+
...oldProps,
|
|
62
|
+
placement: fakePlacement,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// Act
|
|
66
|
+
const underTest = () => tailInstance._getFilterPositioning();
|
|
67
|
+
|
|
68
|
+
// Assert
|
|
69
|
+
expect(underTest).toThrowErrorMatchingSnapshot();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("_calculateDimensionsFromPlacement throws on bad placement", () => {
|
|
73
|
+
// Arrange
|
|
74
|
+
const fakePlacement = (("notaplacement": any): Placement);
|
|
75
|
+
// We need an instance so let's make one with a valid placement so
|
|
76
|
+
// that we can render it.
|
|
77
|
+
const nodes = <TooltipTail placement="top" />;
|
|
78
|
+
const wrapper = shallow(nodes);
|
|
79
|
+
const tailInstance = wrapper.instance();
|
|
80
|
+
// Sneakily change props so as not to re-render.
|
|
81
|
+
// This means getting rid of the read-only props and
|
|
82
|
+
// recreating so that we can write a fake placement value
|
|
83
|
+
// for testing.
|
|
84
|
+
const oldProps = tailInstance.props;
|
|
85
|
+
// $FlowIgnore
|
|
86
|
+
delete tailInstance.props;
|
|
87
|
+
tailInstance.props = {
|
|
88
|
+
...oldProps,
|
|
89
|
+
placement: fakePlacement,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// Act
|
|
93
|
+
const underTest = () =>
|
|
94
|
+
tailInstance._calculateDimensionsFromPlacement();
|
|
95
|
+
|
|
96
|
+
// Assert
|
|
97
|
+
expect(underTest).toThrowErrorMatchingSnapshot();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("_minDistanceFromCorners throws on bad placement", () => {
|
|
101
|
+
// Arrange
|
|
102
|
+
const fakePlacement = (("notaplacement": any): Placement);
|
|
103
|
+
// We need an instance so let's make one with a valid placement so
|
|
104
|
+
// that we can render it.
|
|
105
|
+
const nodes = <TooltipTail placement="top" />;
|
|
106
|
+
const wrapper = shallow(nodes);
|
|
107
|
+
const tailInstance = wrapper.instance();
|
|
108
|
+
|
|
109
|
+
// Act
|
|
110
|
+
const underTest = () =>
|
|
111
|
+
tailInstance._minDistanceFromCorners(fakePlacement);
|
|
112
|
+
|
|
113
|
+
// Assert
|
|
114
|
+
expect(underTest).toThrowErrorMatchingSnapshot();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
|
|
4
|
+
import {render, screen, fireEvent} from "@testing-library/react";
|
|
5
|
+
// eslint-disable-next-line import/no-unassigned-import
|
|
6
|
+
import "@testing-library/jest-dom/extend-expect";
|
|
7
|
+
import userEvent from "@testing-library/user-event";
|
|
8
|
+
|
|
9
|
+
import Tooltip from "../tooltip.js";
|
|
10
|
+
|
|
11
|
+
describe("tooltip integration tests", () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
jest.useFakeTimers();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("should set timeoutId be null when TooltipBubble is active", () => {
|
|
17
|
+
// Arrange
|
|
18
|
+
render(<Tooltip content="hello, world">an anchor</Tooltip>);
|
|
19
|
+
const anchor = screen.getByText("an anchor");
|
|
20
|
+
|
|
21
|
+
// Act
|
|
22
|
+
userEvent.hover(anchor);
|
|
23
|
+
// There's a 100ms delay before TooltipAnchor calls _setActiveState with
|
|
24
|
+
// instant set to true. This second call is what actually triggers the
|
|
25
|
+
// call to this.props.onActiveChanged() which updates Tooltip's active
|
|
26
|
+
// state.
|
|
27
|
+
jest.runAllTimers();
|
|
28
|
+
|
|
29
|
+
// Assert
|
|
30
|
+
expect(screen.getByRole("tooltip")).toBeInTheDocument();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("should hide the bubble on mouseleave on TooltipAnchor", () => {
|
|
34
|
+
// Arrange
|
|
35
|
+
render(<Tooltip content="hello, world">an anchor</Tooltip>);
|
|
36
|
+
|
|
37
|
+
const anchor = screen.getByText("an anchor");
|
|
38
|
+
userEvent.hover(anchor);
|
|
39
|
+
|
|
40
|
+
// Act
|
|
41
|
+
userEvent.unhover(anchor);
|
|
42
|
+
// There's a 100ms delay before TooltipAnchor calls _setActiveState with
|
|
43
|
+
// instant set to true. This second call is what actually triggers the
|
|
44
|
+
// call to this.props.onActiveChanged() which updates Tooltip's active
|
|
45
|
+
// state.
|
|
46
|
+
jest.runAllTimers();
|
|
47
|
+
|
|
48
|
+
// Assert
|
|
49
|
+
expect(screen.queryByRole("tooltip")).toBeNull();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("should close TooltipBubble on mouseleave on TooltipBubble", async () => {
|
|
53
|
+
// Arrange
|
|
54
|
+
render(<Tooltip content="hello, world">an anchor</Tooltip>);
|
|
55
|
+
|
|
56
|
+
const anchor = screen.getByText("an anchor");
|
|
57
|
+
userEvent.hover(anchor);
|
|
58
|
+
// hover on bubble to keep it active
|
|
59
|
+
const bubbleWrapper = await screen.findByRole("tooltip");
|
|
60
|
+
userEvent.unhover(anchor);
|
|
61
|
+
|
|
62
|
+
// Used because RTL complains about the bubble containing a child
|
|
63
|
+
// element with pointerEvents: none
|
|
64
|
+
// eslint-disable-next-line testing-library/prefer-user-event
|
|
65
|
+
fireEvent.mouseEnter(bubbleWrapper);
|
|
66
|
+
|
|
67
|
+
// Act
|
|
68
|
+
// eslint-disable-next-line testing-library/prefer-user-event
|
|
69
|
+
fireEvent.mouseLeave(bubbleWrapper);
|
|
70
|
+
// There's a 100ms delay before TooltipAnchor calls _setActiveState with
|
|
71
|
+
// instant set to true. This second call is what actually triggers the
|
|
72
|
+
// call to this.props.onActiveChanged() which updates Tooltip's active
|
|
73
|
+
// state.
|
|
74
|
+
jest.runAllTimers();
|
|
75
|
+
|
|
76
|
+
// Assert
|
|
77
|
+
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
|
|
78
|
+
});
|
|
79
|
+
});
|