/** * @file Tooltip.test.tsx * @description Pins the app-wide tooltip renderer: a control's native `title` * becomes the designed bubble on hover and on keyboard focus, the native * tooltip is suppressed only while the bubble is up and the attribute is put * back afterwards (it is the control's accessible name), and `data-tip-off` * opts out. * @author Nguyễn Ngọc Trí Vĩ */ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { render, screen, fireEvent, act } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { GlobalTooltip } from "../Tooltip"; function setup(extra?: React.ReactNode) { return render( <> {extra} ); } const hover = (el: Element) => fireEvent.pointerOver(el, { bubbles: true }); const unhover = (el: Element) => fireEvent.pointerOut(el, { bubbles: true }); // jsdom's :focus-visible is a heuristic over the previous events and leaks // between tests, so the tests say which kind of focus they mean: a spy answers // `:focus-visible` from this flag and passes every other selector through. let keyboardFocus = false; const realMatches = Element.prototype.matches; beforeEach(() => { keyboardFocus = false; vi.spyOn(Element.prototype, "matches").mockImplementation(function (this: Element, sel) { return sel === ":focus-visible" ? keyboardFocus && document.activeElement === this : realMatches.call(this, sel); }); }); afterEach(() => vi.restoreAllMocks()); const tabTo = (el: HTMLElement) => { keyboardFocus = true; act(() => el.focus()); }; beforeEach(() => vi.useFakeTimers({ shouldAdvanceTime: true })); afterEach(() => vi.useRealTimers()); const settle = async () => { await act(async () => { vi.advanceTimersByTime(400); }); }; describe("GlobalTooltip", () => { it("shows the bubble after a delay and takes the native title while it is up", async () => { setup(); const button = screen.getByRole("button", { name: "refresh" }); hover(button); // Nothing yet: a cursor crossing a toolbar must not strobe every control. expect(screen.queryByTestId("global-tooltip")).toBeNull(); await settle(); expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Refresh the working copy"); expect(button).not.toHaveAttribute("title"); }); it("hides and gives the title back on the way out", async () => { setup(); const button = screen.getByRole("button", { name: "refresh" }); hover(button); await settle(); unhover(button); expect(screen.queryByTestId("global-tooltip")).toBeNull(); expect(button).toHaveAttribute("title", "Refresh the working copy"); }); it("opens immediately on keyboard focus and closes on Escape", async () => { setup(); const button = screen.getByRole("button", { name: "refresh" }); tabTo(button); await act(async () => { vi.advanceTimersByTime(0); }); expect(screen.getByTestId("global-tooltip")).toBeInTheDocument(); fireEvent.keyDown(document, { key: "Escape" }); expect(screen.queryByTestId("global-tooltip")).toBeNull(); expect(button).toHaveAttribute("title", "Refresh the working copy"); }); it("leaves a data-tip-off control on the browser's own tooltip", async () => { setup( ); const opted = screen.getByRole("button", { name: "opted out" }); hover(opted); await settle(); expect(screen.queryByTestId("global-tooltip")).toBeNull(); expect(opted).toHaveAttribute("title", "Raw value"); }); it("reads the title through a child element, and ignores an empty one", async () => { setup( ); hover(screen.getByTestId("blank-child")); await settle(); expect(screen.queryByTestId("global-tooltip")).toBeNull(); const button = screen.getByRole("button", { name: "refresh" }); button.innerHTML = "refresh"; hover(screen.getByTestId("deep")); await settle(); expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Refresh the working copy"); }); it("keeps an icon-only control's accessible name while its title is parked", async () => { setup( ); const icon = screen.getByRole("button", { name: "Stage this file" }); tabTo(icon); await act(async () => { vi.advanceTimersByTime(0); }); expect(screen.getByTestId("global-tooltip")).toBeInTheDocument(); expect(icon).not.toHaveAttribute("title"); expect(screen.getByRole("button", { name: "Stage this file" })).toBe(icon); fireEvent.keyDown(document, { key: "Escape" }); // Put back exactly as found: no aria-label left behind. expect(icon).toHaveAttribute("title", "Stage this file"); expect(icon).not.toHaveAttribute("aria-label"); }); it("does not flicker while the pointer crosses the control's own children", async () => { setup( ); const a = screen.getByTestId("part-a"); const b = screen.getByTestId("part-b"); hover(a); await settle(); expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Two parts"); fireEvent.pointerOut(a, { bubbles: true, relatedTarget: b }); fireEvent.pointerOver(b, { bubbles: true, relatedTarget: a }); // Still up, with no fresh delay. expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Two parts"); }); it("hides when its control is removed from the page", async () => { const { rerender } = render( <> ); hover(screen.getByRole("button", { name: "x" })); await settle(); expect(screen.getByTestId("global-tooltip")).toBeInTheDocument(); rerender( <> ); await settle(); expect(screen.queryByTestId("global-tooltip")).toBeNull(); }); it("keeps a title React changed while the bubble was up, instead of writing the stale one back", async () => { const { rerender } = render( <> ); const button = screen.getByRole("button", { name: "pull" }); hover(button); await settle(); expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Pull 2 commits"); // A poll flips the hint while the pointer rests on the control. rerender( <> ); await settle(); // The bubble must not keep advertising the old text. expect(screen.queryByText("Pull 2 commits")).toBeNull(); unhover(button); expect(button).toHaveAttribute("title", "Up to date"); }); it("leaves no title behind when React removed it while the bubble was up", async () => { const { rerender } = render( <> ); const button = screen.getByRole("button", { name: "pull" }); hover(button); await settle(); expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Pull 2 commits"); // React "removes" an attribute that is already parked: no MutationRecord. rerender( <> ); await settle(); unhover(button); expect(button).not.toHaveAttribute("title"); expect(button).not.toHaveAttribute("data-tip"); expect(screen.queryByTestId("global-tooltip")).toBeNull(); }); it("does not open on the focus a mouse click gives, only on keyboard focus", async () => { const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); setup(); // A click is pointer focus: :focus-visible does not match. const button = screen.getByRole("button", { name: "refresh" }); await user.click(button); await act(async () => { vi.advanceTimersByTime(0); }); expect(screen.queryByTestId("global-tooltip")).toBeNull(); tabTo(screen.getByRole("button", { name: "second" })); await act(async () => { vi.advanceTimersByTime(0); }); expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Second control"); }); it("stays up when an unrelated panel scrolls, and hides when the anchor's own scroller moves", async () => { render( <>
); const button = screen.getByRole("button", { name: "inside" }); hover(button); await settle(); fireEvent.scroll(screen.getByTestId("elsewhere")); expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Inside the scroller"); fireEvent.scroll(screen.getByTestId("scroller")); expect(screen.queryByTestId("global-tooltip")).toBeNull(); expect(button).toHaveAttribute("title", "Inside the scroller"); }); it("hides when the document itself scrolls", async () => { setup(); hover(screen.getByRole("button", { name: "refresh" })); await settle(); fireEvent.scroll(document); expect(screen.queryByTestId("global-tooltip")).toBeNull(); }); });