Skip to the code
cf.

Tooltip

The tea shop's checkout has a Delivery button with a short explanation in a tooltip. Build App: a button with a tooltip, taking label (the button's text), text (the tooltip's text), and delay (in milliseconds, default 500). When label and text aren't given, as in the Preview, use Delivery and a sentence of your own.

When the pointer moves onto the button, or the button gets the focus, wait delay ms, then show the tooltip: an element with role="tooltip", an id, and the text. While the tooltip shows, the button has aria-describedby set to that id; while it doesn't, the button has no aria-describedby.

The tooltip hides at once when the pointer leaves the button, when the button loses the focus, or when Escape is pressed, wherever the focus is. If the pointer leaves or the focus moves away before the delay is up, the tooltip never shows. Leave no timer running when the component is removed.

<App label="Delivery" text="Free delivery on orders over 30 dollars" delay={500} />
 
// 500 ms after hovering:
<button aria-describedby="tip">Delivery</button>
<span role="tooltip" id="tip">Free delivery on orders over 30 dollars</span>

What the tests check

  1. shows no tooltip at first
    render(<App label="Delivery" text="Free over 30 dollars" />);
    expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
    expect(screen.getByRole("button", { name: "Delivery" })).not.toHaveAttribute("aria-describedby");
    
  2. hovering shows the tooltip after the delay, not before
    const clock = useFakeTimers();
    render(<App label="Delivery" text="Free over 30 dollars" />);
    await user.hover(screen.getByRole("button", { name: "Delivery" }));
    await clock.advance(499);
    expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
    await clock.advance(1);
    expect(screen.getByRole("tooltip")).toHaveTextContent("Free over 30 dollars");
    
  3. focusing the button shows it after the delay too
    const clock = useFakeTimers();
    render(<App label="Delivery" text="Free over 30 dollars" />);
    await user.tab();
    expect(screen.getByRole("button", { name: "Delivery" })).toHaveFocus();
    expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
    await clock.advance(500);
    expect(screen.getByRole("tooltip")).toHaveTextContent("Free over 30 dollars");
    
  4. while it shows, the button is described by it
    const clock = useFakeTimers();
    render(<App label="Delivery" text="Free over 30 dollars" />);
    await user.hover(screen.getByRole("button", { name: "Delivery" }));
    await clock.advance(500);
    const tooltip = screen.getByRole("tooltip");
    expect(tooltip.id).toBeTruthy();
    expect(screen.getByRole("button", { name: "Delivery" })).toHaveAttribute("aria-describedby", tooltip.id);
    
  5. moving the pointer away hides it at once
    const clock = useFakeTimers();
    render(<App label="Delivery" text="Free over 30 dollars" />);
    const button = screen.getByRole("button", { name: "Delivery" });
    await user.hover(button);
    await clock.advance(500);
    await user.unhover(button);
    expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
    expect(button).not.toHaveAttribute("aria-describedby");
    
  6. moving the focus away hides it
    const clock = useFakeTimers();
    render(<App label="Delivery" text="Free over 30 dollars" />);
    await user.tab();
    await clock.advance(500);
    await user.tab();
    expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
    
  7. Escape hides it, even when the button doesn't have the focus
    const clock = useFakeTimers();
    render(<App label="Delivery" text="Free over 30 dollars" />);
    await user.hover(screen.getByRole("button", { name: "Delivery" }));
    await clock.advance(500);
    expect(screen.getByRole("button", { name: "Delivery" })).not.toHaveFocus();
    await user.keyboard("{Escape}");
    expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
    
  8. leaving before the delay is up means it never shows
    const clock = useFakeTimers();
    render(<App label="Delivery" text="Free over 30 dollars" />);
    const button = screen.getByRole("button", { name: "Delivery" });
    await user.hover(button);
    await clock.advance(300);
    await user.unhover(button);
    await clock.advance(1000);
    expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
    expect(clock.pending()).toBe(0);
    
  9. waits for the delay it's given
    const clock = useFakeTimers();
    render(<App label="Delivery" text="Free over 30 dollars" delay={1000} />);
    await user.hover(screen.getByRole("button", { name: "Delivery" }));
    await clock.advance(999);
    expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
    await clock.advance(1);
    expect(screen.getByRole("tooltip")).toBeInTheDocument();
    
  10. clears its timer when removed
    const clock = useFakeTimers();
    const { unmount } = render(<App label="Delivery" text="Free over 30 dollars" />);
    await user.hover(screen.getByRole("button", { name: "Delivery" }));
    unmount();
    expect(clock.pending()).toBe(0);
    

Learn more

App.jsxSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).