@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,235 @@
1
+ // @flow
2
+ /**
3
+ * The Tooltip component provides the means to anchor some additional
4
+ * information to some content. The additional information is shown in a
5
+ * callout that hovers above the page content. This additional information is
6
+ * invoked by hovering over the anchored content, or focusing all or part of the
7
+ * anchored content.
8
+ *
9
+ * This component is structured as follows:
10
+ *
11
+ * Tooltip (this component)
12
+ * - TooltipAnchor (provides hover/focus behaviors on anchored content)
13
+ * - TooltipPortalMounter (creates portal into which the callout is rendered)
14
+ * --------------------------- [PORTAL BOUNDARY] ------------------------------
15
+ * - TooltipPopper (provides positioning for the callout using react-popper)
16
+ * - TooltipBubble (renders the callout borders, background and shadow)
17
+ * - TooltipContent (renders the callout content; the actual information)
18
+ * - TooltipTail (renders the callout tail and shadow that points from the
19
+ * callout to the anchor content)
20
+ */
21
+ import * as React from "react";
22
+ import * as ReactDOM from "react-dom";
23
+
24
+ import {
25
+ UniqueIDProvider,
26
+ type IIdentifierFactory,
27
+ } from "@khanacademy/wonder-blocks-core";
28
+ import {maybeGetPortalMountedModalHostElement} from "@khanacademy/wonder-blocks-modal";
29
+ import type {Typography} from "@khanacademy/wonder-blocks-typography";
30
+ import type {AriaProps} from "@khanacademy/wonder-blocks-core";
31
+
32
+ import TooltipAnchor from "./tooltip-anchor.js";
33
+ import TooltipBubble from "./tooltip-bubble.js";
34
+ import TooltipContent from "./tooltip-content.js";
35
+ import TooltipPopper from "./tooltip-popper.js";
36
+ import type {Placement} from "../util/types.js";
37
+
38
+ type Props = {|
39
+ ...AriaProps,
40
+
41
+ /**
42
+ * The content for anchoring the tooltip.
43
+ * This component will be used to position the tooltip.
44
+ */
45
+ children: React.Element<any> | string,
46
+
47
+ /**
48
+ * The title of the tooltip.
49
+ * Optional.
50
+ */
51
+ title?: string | React.Element<Typography>,
52
+
53
+ /**
54
+ * The content to render in the tooltip.
55
+ */
56
+ content: string | React.Element<typeof TooltipContent>,
57
+
58
+ /**
59
+ * The unique identifier to give to the tooltip. Provide this in cases where
60
+ * you want to override the default accessibility solution. This identifier
61
+ * will be applied to the tooltip bubble content.
62
+ *
63
+ * By providing this identifier, the children that this tooltip anchors to
64
+ * will not be automatically given the aria-desribedby attribute. Instead,
65
+ * the accessibility solution is the responsibility of the caller.
66
+ *
67
+ * If this is not provided, the aria-describedby attribute will be added
68
+ * to the children with a unique identifier pointing to the tooltip bubble
69
+ * content.
70
+ */
71
+ id?: string,
72
+
73
+ /**
74
+ * When true, if a tabindex attribute is not already present on the element
75
+ * wrapped by the anchor, the element will be given tabindex=0 to make it
76
+ * keyboard focusable; otherwise, does not attempt to change the ability to
77
+ * focus the anchor element.
78
+ *
79
+ * Defaults to true.
80
+ *
81
+ * One might set this to false in circumstances where the wrapped component
82
+ * already can receive focus or contains an element that can.
83
+ * Use good judgement when overriding this value, the tooltip content should
84
+ * be accessible via keyboard in all circumstances where the tooltip would
85
+ * appear using the mouse, so verify those use-cases.
86
+ *
87
+ * Also, note that the aria-describedby attribute is attached to the root
88
+ * anchor element, so you may need to implement an additional accessibility
89
+ * solution when overriding anchor focusivity.
90
+ */
91
+ forceAnchorFocusivity?: boolean,
92
+
93
+ /**
94
+ * Where the tooltip should appear in relation to the anchor element.
95
+ * Defaults to "top".
96
+ */
97
+ placement: Placement,
98
+
99
+ /**
100
+ * Test ID used for e2e testing.
101
+ */
102
+ testId?: string,
103
+ |};
104
+
105
+ type State = {|
106
+ /**
107
+ * Whether the tooltip is open by hovering/focusing on the anchor element.
108
+ */
109
+ active: boolean,
110
+ /**
111
+ * Whether the tooltip is open by hovering on the tooltip bubble.
112
+ */
113
+ activeBubble: boolean,
114
+ /**
115
+ * The element that activates the tooltip.
116
+ */
117
+ anchorElement: ?HTMLElement,
118
+ |};
119
+
120
+ type DefaultProps = {|
121
+ forceAnchorFocusivity: $PropertyType<Props, "forceAnchorFocusivity">,
122
+ placement: $PropertyType<Props, "placement">,
123
+ |};
124
+
125
+ export default class Tooltip extends React.Component<Props, State> {
126
+ static defaultProps: DefaultProps = {
127
+ forceAnchorFocusivity: true,
128
+ placement: "top",
129
+ };
130
+
131
+ state: State = {
132
+ active: false,
133
+ activeBubble: false,
134
+ anchorElement: null,
135
+ };
136
+ static ariaContentId: string = "aria-content";
137
+
138
+ _updateAnchorElement(ref: ?Element) {
139
+ if (ref && ref !== this.state.anchorElement) {
140
+ this.setState({anchorElement: ((ref: any): HTMLElement)});
141
+ }
142
+ }
143
+
144
+ _renderBubbleContent(): React.Element<typeof TooltipContent> {
145
+ const {title, content} = this.props;
146
+ if (typeof content === "string") {
147
+ return <TooltipContent title={title}>{content}</TooltipContent>;
148
+ } else if (title) {
149
+ return React.cloneElement(content, {title});
150
+ } else {
151
+ return content;
152
+ }
153
+ }
154
+
155
+ _renderPopper(ids?: IIdentifierFactory): React.Node {
156
+ const {id} = this.props;
157
+ const bubbleId = ids ? ids.get(Tooltip.ariaContentId) : id;
158
+ if (!bubbleId) {
159
+ throw new Error("Did not get an identifier factory nor a id prop");
160
+ }
161
+
162
+ const {placement} = this.props;
163
+ return (
164
+ <TooltipPopper
165
+ anchorElement={this.state.anchorElement}
166
+ placement={placement}
167
+ >
168
+ {(props) => (
169
+ <TooltipBubble
170
+ id={bubbleId}
171
+ style={props.style}
172
+ tailOffset={props.tailOffset}
173
+ isReferenceHidden={props.isReferenceHidden}
174
+ placement={props.placement}
175
+ updateTailRef={props.updateTailRef}
176
+ updateBubbleRef={props.updateBubbleRef}
177
+ onActiveChanged={(active) =>
178
+ this.setState({activeBubble: active})
179
+ }
180
+ >
181
+ {this._renderBubbleContent()}
182
+ </TooltipBubble>
183
+ )}
184
+ </TooltipPopper>
185
+ );
186
+ }
187
+
188
+ _getHost(): ?Element {
189
+ const {anchorElement} = this.state;
190
+
191
+ return (
192
+ maybeGetPortalMountedModalHostElement(anchorElement) ||
193
+ document.body
194
+ );
195
+ }
196
+
197
+ _renderTooltipAnchor(ids?: IIdentifierFactory): React.Node {
198
+ const {children, forceAnchorFocusivity} = this.props;
199
+ const {active, activeBubble} = this.state;
200
+
201
+ const popperHost = this._getHost();
202
+
203
+ // TODO(kevinb): update to use ReactPopper's React 16-friendly syntax
204
+ return (
205
+ <React.Fragment>
206
+ <TooltipAnchor
207
+ forceAnchorFocusivity={forceAnchorFocusivity}
208
+ anchorRef={(r) => this._updateAnchorElement(r)}
209
+ onActiveChanged={(active) => this.setState({active})}
210
+ ids={ids}
211
+ >
212
+ {children}
213
+ </TooltipAnchor>
214
+ {popperHost &&
215
+ (active || activeBubble) &&
216
+ ReactDOM.createPortal(this._renderPopper(ids), popperHost)}
217
+ </React.Fragment>
218
+ );
219
+ }
220
+
221
+ render(): React.Node {
222
+ const {id} = this.props;
223
+ if (id) {
224
+ // Let's bypass the extra weight of an id provider since we don't
225
+ // need it.
226
+ return this._renderTooltipAnchor();
227
+ } else {
228
+ return (
229
+ <UniqueIDProvider scope="tooltip" mockOnFirstRender={true}>
230
+ {(ids) => this._renderTooltipAnchor(ids)}
231
+ </UniqueIDProvider>
232
+ );
233
+ }
234
+ }
235
+ }
@@ -0,0 +1,194 @@
1
+ ### Text anchor & text tooltip & placement right
2
+
3
+ ```js
4
+ import Tooltip from "@khanacademy/wonder-blocks-tooltip";
5
+
6
+ <Tooltip content="This is a text tooltip on the right" placement="right">
7
+ Some text
8
+ </Tooltip>
9
+ ```
10
+
11
+ ### Complex anchor & title tooltip & placement default (top)
12
+
13
+ In this example, we're no longer forcing the anchor root to be focusable, since the text input can take focus. However, that needs a custom accessibility implementation too (for that, we should use `UniqueIDProvider`, but we'll cheat here and give our own identifier).
14
+
15
+ ```js
16
+ import {View} from "@khanacademy/wonder-blocks-core";
17
+ import Tooltip from "@khanacademy/wonder-blocks-tooltip";
18
+
19
+ <Tooltip
20
+ id="my-a11y-tooltip"
21
+ forceAnchorFocusivity={false}
22
+ title="This tooltip has a title"
23
+ content="I'm at the top!"
24
+ >
25
+ <View>
26
+ Some text
27
+ <input aria-describedby="my-a11y-tooltip" />
28
+ </View>
29
+ </Tooltip>
30
+ ```
31
+
32
+ ### Substring anchor in scrollable parent & placement bottom
33
+ In this example, we have the anchor in a scrollable parent. Notice how, when the anchor is focused but scrolled out of bounds, the tooltip disappears.
34
+
35
+ ```js
36
+ import {StyleSheet} from "aphrodite";
37
+
38
+ import {View} from "@khanacademy/wonder-blocks-core";
39
+ import {Body} from "@khanacademy/wonder-blocks-typography";
40
+ import Tooltip from "@khanacademy/wonder-blocks-tooltip";
41
+
42
+ const styles = StyleSheet.create({
43
+ scrollbox: {
44
+ height: 100,
45
+ overflow: "auto",
46
+ border: "1px solid black",
47
+ margin: 10,
48
+ },
49
+ hostbox: {
50
+ minHeight: "200vh",
51
+ },
52
+ });
53
+
54
+ <View>
55
+ <View style={styles.scrollbox}>
56
+ <View style={styles.hostbox}>
57
+ <Body>
58
+ This is a big long piece of text with a
59
+ <Tooltip content="This tooltip will disappear when scrolled out of bounds" placement="bottom">
60
+ [tooltip]
61
+ </Tooltip>
62
+ <span> </span>in the middle.
63
+ </Body>
64
+ </View>
65
+ </View>
66
+ </View>
67
+ ```
68
+
69
+ ### Tooltip in a modal & placement left
70
+ This checks that the tooltip works how we want inside a modal. Click the button to take a look.
71
+
72
+ ```js
73
+ import {StyleSheet} from "aphrodite";
74
+
75
+ import {View, Text} from "@khanacademy/wonder-blocks-core";
76
+ import {OnePaneDialog, ModalLauncher} from "@khanacademy/wonder-blocks-modal";
77
+ import Button from "@khanacademy/wonder-blocks-button";
78
+ import Tooltip from "@khanacademy/wonder-blocks-tooltip";
79
+
80
+ const styles = StyleSheet.create({
81
+ scrollbox: {
82
+ height: 100,
83
+ overflow: "auto",
84
+ border: "1px solid black",
85
+ margin: 10,
86
+ },
87
+ hostbox: {
88
+ minHeight: "200vh",
89
+ },
90
+ modalbox: {
91
+ height: "200vh",
92
+ },
93
+ });
94
+
95
+ const scrollyContent = (
96
+ <View style={styles.scrollbox}>
97
+ <View style={styles.hostbox}>
98
+ <Tooltip content="I'm on the left!" placement="left">
99
+ tooltip
100
+ </Tooltip>
101
+ </View>
102
+ </View>
103
+ );
104
+
105
+ const modalContent = (
106
+ <View style={styles.modalbox}>
107
+ {scrollyContent}
108
+ </View>
109
+ );
110
+
111
+ const modal = (
112
+ <OnePaneDialog
113
+ title="My modal"
114
+ footer="Still my modal"
115
+ content={modalContent} />
116
+ );
117
+
118
+ <ModalLauncher modal={modal}>
119
+ {({openModal}) => <Button onClick={openModal}>Click here!</Button>}
120
+ </ModalLauncher>
121
+ ```
122
+
123
+ ### Tooltips side-by-side
124
+
125
+ ```js
126
+ import {StyleSheet} from "aphrodite";
127
+
128
+ import {View} from "@khanacademy/wonder-blocks-core";
129
+ import {LabelSmall} from "@khanacademy/wonder-blocks-typography";
130
+ import Tooltip from "@khanacademy/wonder-blocks-tooltip";
131
+
132
+ const styles = StyleSheet.create({
133
+ "block": {
134
+ border: "solid 1px steelblue",
135
+ width: 32,
136
+ height: 32,
137
+ alignItems: "center",
138
+ justifyContent: "center",
139
+ }
140
+ });
141
+
142
+ <View>
143
+ <LabelSmall>Here, we can see that the first tooltip shown has an initial delay before it appears, as does the last tooltip shown, yet when moving between tooltipped items, the transition from one to another is instantaneous.</LabelSmall>
144
+
145
+ <View style={{flexDirection: "row"}}>
146
+ <Tooltip content="Tooltip A" placement="bottom">
147
+ <View style={styles.block}>A</View>
148
+ </Tooltip>
149
+ <Tooltip content="Tooltip B" placement="bottom">
150
+ <View style={styles.block}>B</View>
151
+ </Tooltip>
152
+ <Tooltip content="Tooltip C" placement="bottom">
153
+ <View style={styles.block}>C</View>
154
+ </Tooltip>
155
+ <Tooltip content="Tooltip D" placement="bottom">
156
+ <View style={styles.block}>D</View>
157
+ </Tooltip>
158
+ </View>
159
+ </View>
160
+ ```
161
+
162
+ ### Tooltips on buttons
163
+
164
+ ```js
165
+ import {StyleSheet} from "aphrodite";
166
+
167
+ import Button from "@khanacademy/wonder-blocks-button";
168
+ import IconButton from "@khanacademy/wonder-blocks-icon-button";
169
+ import {icons} from "@khanacademy/wonder-blocks-icon";
170
+ import {View} from "@khanacademy/wonder-blocks-core";
171
+ import {Strut} from "@khanacademy/wonder-blocks-layout";
172
+ import Tooltip from "@khanacademy/wonder-blocks-tooltip";
173
+
174
+ const styles = {
175
+ container: {
176
+ flexDirection: "row",
177
+ alignItems: "center",
178
+ },
179
+ };
180
+
181
+ <View style={styles.container}>
182
+ <Tooltip content="I'm a little tooltip">
183
+ <Button>Click me!</Button>
184
+ </Tooltip>
185
+ <Strut size={16} />
186
+ <Tooltip content="Short and stout">
187
+ <IconButton
188
+ icon={icons.search}
189
+ aria-label="search"
190
+ onClick={(e) => console.log("hello")}
191
+ />
192
+ </Tooltip>
193
+ </View>
194
+ ```
@@ -0,0 +1,76 @@
1
+ // @flow
2
+ import * as React from "react";
3
+ import {StyleSheet} from "aphrodite";
4
+ import {View} from "@khanacademy/wonder-blocks-core";
5
+ import {TextField} from "@khanacademy/wonder-blocks-form";
6
+ import Tooltip from "@khanacademy/wonder-blocks-tooltip";
7
+
8
+ import type {Placement} from "@khanacademy/wonder-blocks-tooltip";
9
+ import type {StoryComponentType} from "@storybook/react";
10
+
11
+ export default {
12
+ title: "Tooltip",
13
+ };
14
+
15
+ const BaseTooltipExample = ({placement}: {|placement: Placement|}) => {
16
+ const inputRef = React.useRef<null | HTMLElement>(null);
17
+ React.useEffect(() => {
18
+ if (inputRef.current) {
19
+ inputRef.current.focus();
20
+ }
21
+ }, []);
22
+
23
+ return (
24
+ <View style={styles.centered}>
25
+ <View>
26
+ <Tooltip
27
+ content={`This is a text tooltip on the ${placement}`}
28
+ placement={placement}
29
+ >
30
+ <TextField
31
+ id="tf-1"
32
+ type="text"
33
+ value=""
34
+ placeholder="Text"
35
+ onChange={() => {}}
36
+ ref={inputRef}
37
+ />
38
+ </Tooltip>
39
+ </View>
40
+ </View>
41
+ );
42
+ };
43
+
44
+ export const tooltipRight: StoryComponentType = () => (
45
+ <BaseTooltipExample placement="right" />
46
+ );
47
+
48
+ export const tooltipLeft: StoryComponentType = () => (
49
+ <BaseTooltipExample placement="left" />
50
+ );
51
+
52
+ export const tooltipTop: StoryComponentType = () => (
53
+ <BaseTooltipExample placement="top" />
54
+ );
55
+
56
+ export const tooltipBottom: StoryComponentType = () => (
57
+ <BaseTooltipExample placement="bottom" />
58
+ );
59
+
60
+ const styles = StyleSheet.create({
61
+ row: {
62
+ flexDirection: "row",
63
+ },
64
+ fullBleed: {
65
+ width: "100%",
66
+ },
67
+ wrapper: {
68
+ height: "800px",
69
+ width: "1200px",
70
+ },
71
+ centered: {
72
+ alignItems: "center",
73
+ justifyContent: "center",
74
+ height: `calc(100vh - 16px)`,
75
+ },
76
+ });
package/src/index.js ADDED
@@ -0,0 +1,12 @@
1
+ // @flow
2
+ import type {Placement} from "./util/types.js";
3
+ import type {PopperElementProps} from "./components/tooltip-bubble.js";
4
+
5
+ import Tooltip from "./components/tooltip.js";
6
+ import TooltipContent from "./components/tooltip-content.js";
7
+ import TooltipPopper from "./components/tooltip-popper.js";
8
+ import TooltipTail from "./components/tooltip-tail.js";
9
+
10
+ export {Tooltip as default, TooltipContent, TooltipPopper, TooltipTail};
11
+
12
+ export type {Placement, PopperElementProps};
@@ -0,0 +1,3 @@
1
+ // Jest Snapshot v1, https://goo.gl/fbAQLP
2
+
3
+ exports[`ActiveTracker #subscribe if already subscribed, throws 1`] = `"Already subscribed."`;
@@ -0,0 +1,3 @@
1
+ // Jest Snapshot v1, https://goo.gl/fbAQLP
2
+
3
+ exports[`RefTracker #setCallback called with non-function, throws 1`] = `"targetFn must be a function"`;
@@ -0,0 +1,142 @@
1
+ // @flow
2
+ import ActiveTracker, {IActiveTrackerSubscriber} from "../active-tracker.js";
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
+ });