Skip to content

Commit

Permalink
feat: Add experimental unstable_timeout option to useTooltipState (
Browse files Browse the repository at this point in the history
  • Loading branch information
diegohaz committed Apr 16, 2020
1 parent 9feb9c1 commit 5fe208f
Show file tree
Hide file tree
Showing 2 changed files with 222 additions and 14 deletions.
123 changes: 118 additions & 5 deletions packages/reakit/src/Tooltip/TooltipState.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as React from "react";
import {
useSealedState,
SealedInitialState,
Expand All @@ -10,11 +11,24 @@ import {
PopoverStateReturn,
} from "../Popover/PopoverState";

export type TooltipState = Omit<PopoverState, "modal">;
export type TooltipState = Omit<PopoverState, "modal"> & {
/**
* @private
*/
unstable_timeout: number;
};

export type TooltipActions = Omit<PopoverActions, "setModal">;
export type TooltipActions = Omit<PopoverActions, "setModal"> & {
/**
* @private
*/
unstable_setTimeout: React.Dispatch<
React.SetStateAction<TooltipState["unstable_timeout"]>
>;
};

export type TooltipInitialState = Omit<PopoverInitialState, "modal">;
export type TooltipInitialState = Omit<PopoverInitialState, "modal"> &
Pick<Partial<TooltipState>, "unstable_timeout">;

export type TooltipStateReturn = Omit<
PopoverStateReturn,
Expand All @@ -23,15 +37,114 @@ export type TooltipStateReturn = Omit<
TooltipState &
TooltipActions;

type Listener = (id: string | null) => void;

const state = {
currentTooltipId: null as string | null,
listeners: new Set<Listener>(),
subscribe(listener: Listener) {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
},
show(id: string | null) {
this.currentTooltipId = id;
this.listeners.forEach((listener) => listener(id));
},
hide(id: string) {
if (this.currentTooltipId === id) {
this.currentTooltipId = null;
this.listeners.forEach((listener) => listener(null));
}
},
};

export function useTooltipState(
initialState: SealedInitialState<TooltipInitialState> = {}
): TooltipStateReturn {
const { placement = "top", ...sealed } = useSealedState(initialState);
return usePopoverState({ ...sealed, placement });
const {
placement = "top",
unstable_timeout: initialTimeout = 0,
...sealed
} = useSealedState(initialState);
const [timeout, setTimeout] = React.useState(initialTimeout);
const showTimeout = React.useRef<number | null>(null);
const hideTimeout = React.useRef<number | null>(null);

const popover = usePopoverState({ ...sealed, placement });

const clearTimeouts = React.useCallback(() => {
if (showTimeout.current !== null) {
window.clearTimeout(showTimeout.current);
}
if (hideTimeout.current !== null) {
window.clearTimeout(hideTimeout.current);
}
}, []);

const hide = React.useCallback(() => {
clearTimeouts();
popover.hide();
// Let's give some time so people can move from a reference to another
// and still show tooltips immediately
hideTimeout.current = window.setTimeout(() => {
state.hide(popover.baseId);
}, timeout);
}, [clearTimeouts, popover.hide, timeout, popover.baseId]);

const show = React.useCallback(() => {
clearTimeouts();
if (!timeout || state.currentTooltipId) {
// If there's no timeout or a tooltip visible already, we can show this
// immediately
state.show(popover.baseId);
popover.show();
} else {
// There may be a reference with focus whose tooltip is still not visible
// In this case, we want to update it before it gets shown.
state.show(null);
// Otherwise, wait a little bit to show the tooltip
showTimeout.current = window.setTimeout(() => {
state.show(popover.baseId);
popover.show();
}, timeout);
}
}, [clearTimeouts, timeout, popover.show, popover.baseId]);

React.useEffect(() => {
return state.subscribe((id) => {
if (id !== popover.baseId) {
clearTimeouts();
if (popover.visible) {
// Make sure there will be only one tooltip visible
popover.hide();
}
}
});
}, [popover.baseId, clearTimeouts, popover.visible, popover.hide]);

React.useEffect(
() => () => {
clearTimeouts();
state.hide(popover.baseId);
},
[clearTimeouts, popover.baseId]
);

return {
...popover,
hide,
show,
unstable_timeout: timeout,
unstable_setTimeout: setTimeout,
};
}

const keys: Array<keyof PopoverStateReturn | keyof TooltipStateReturn> = [
...usePopoverState.__keys,
"unstable_timeout",
"unstable_setTimeout",
];

useTooltipState.__keys = keys;
113 changes: 104 additions & 9 deletions packages/reakit/src/Tooltip/__tests__/index-test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
import * as React from "react";
import { render, hover, wait } from "reakit-test-utils";
import { act, render, hover, focus } from "reakit-test-utils";
import { Tooltip, TooltipReference, useTooltipState } from "..";

test("show tooltip on hover", async () => {
function advanceTimersByTime(ms: number) {
act(() => {
jest.advanceTimersByTime(ms);
});
}

afterEach(async () => {
hover(document.body);
});

test("show tooltip on hover", () => {
const Test = () => {
const tooltip = useTooltipState();
return (
Expand All @@ -12,12 +22,97 @@ test("show tooltip on hover", async () => {
</>
);
};
const { baseElement, getByText } = render(<Test />);
const reference = getByText("reference");
const tooltip = getByText("tooltip");
expect(tooltip).not.toBeVisible();
hover(reference);
await wait(expect(tooltip).toBeVisible);
const { baseElement, getByText: text } = render(<Test />);
expect(text("tooltip")).not.toBeVisible();
hover(text("reference"));
expect(text("tooltip")).toBeVisible();
hover(baseElement);
expect(text("tooltip")).not.toBeVisible();
});

test("show only one tooltip", () => {
const Test = () => {
const tooltip1 = useTooltipState();
const tooltip2 = useTooltipState();
return (
<>
<TooltipReference {...tooltip1}>reference1</TooltipReference>
<Tooltip {...tooltip1}>tooltip1</Tooltip>
<TooltipReference {...tooltip2}>reference2</TooltipReference>
<Tooltip {...tooltip2}>tooltip2</Tooltip>
</>
);
};
const { getByText: text } = render(<Test />);
expect(text("tooltip1")).not.toBeVisible();
expect(text("tooltip2")).not.toBeVisible();
focus(text("reference1"));
expect(text("tooltip1")).toBeVisible();
expect(text("tooltip2")).not.toBeVisible();
hover(text("reference2"));
expect(text("tooltip1")).not.toBeVisible();
expect(text("tooltip2")).toBeVisible();
});

test("show tooltip with a timeout", () => {
const Test = () => {
const tooltip = useTooltipState({ unstable_timeout: 250 });
return (
<>
<TooltipReference {...tooltip}>reference</TooltipReference>
<Tooltip {...tooltip}>tooltip</Tooltip>
</>
);
};
const { baseElement, getByText: text } = render(<Test />);
jest.useFakeTimers();
expect(text("tooltip")).not.toBeVisible();
hover(text("reference"));
expect(text("tooltip")).not.toBeVisible();
advanceTimersByTime(249);
hover(baseElement);
await wait(expect(tooltip).not.toBeVisible);
expect(text("tooltip")).not.toBeVisible();
advanceTimersByTime(1);
expect(text("tooltip")).not.toBeVisible();
hover(text("reference"));
advanceTimersByTime(249);
expect(text("tooltip")).not.toBeVisible();
advanceTimersByTime(1);
expect(text("tooltip")).toBeVisible();
jest.useRealTimers();
});

test("show tooltip immediately if there is another one visible", () => {
const Test = () => {
const tooltip1 = useTooltipState({ unstable_timeout: 500 });
const tooltip2 = useTooltipState({ unstable_timeout: 300 });
return (
<>
<TooltipReference {...tooltip1}>reference1</TooltipReference>
<Tooltip {...tooltip1}>tooltip1</Tooltip>
<TooltipReference {...tooltip2}>reference2</TooltipReference>
<Tooltip {...tooltip2}>tooltip2</Tooltip>
</>
);
};
const { getByText: text } = render(<Test />);
jest.useFakeTimers();
expect(text("tooltip1")).not.toBeVisible();
expect(text("tooltip2")).not.toBeVisible();
focus(text("reference1"));
advanceTimersByTime(499);
expect(text("tooltip1")).not.toBeVisible();
expect(text("tooltip2")).not.toBeVisible();
hover(text("reference2"));
expect(text("tooltip1")).not.toBeVisible();
expect(text("tooltip2")).not.toBeVisible();
advanceTimersByTime(1);
expect(text("tooltip1")).not.toBeVisible();
expect(text("tooltip2")).not.toBeVisible();
advanceTimersByTime(499);
expect(text("tooltip1")).not.toBeVisible();
expect(text("tooltip2")).toBeVisible();
hover(text("reference1"));
expect(text("tooltip1")).toBeVisible();
expect(text("tooltip2")).not.toBeVisible();
});

0 comments on commit 5fe208f

Please sign in to comment.