ad6e80192d
Right-click menus: one portal shell (components/ContextMenu.tsx) for every item with actions - sidebar/Recents/archived session rows (SessionMenu), console tabs, pane headers, the Changes panel's files/tree/commits, the workspace home, sessions outside a workspace (SessionRowMenu: Sessions table, Dashboard, Activity, Workflow runs), prompt images, every Settings list and every Claude Code/Codex config list (FileItemMenu). Buttons stay; a delete keeps its button's confirm; the shell stops portal clicks from reaching the card that opened it. Status bar: Workspace $ and Session $ on the Workspace page, from ?lane= on GET /api/pricing/cost and the focused/replayed session's cost. Database, no data dropped: synchronous=NORMAL, journal_size_limit 64 MB, hourly PASSIVE checkpoint + PRAGMA optimize, idx_events_session_created (replacing the redundant idx_events_session), watchdog reads sessions.transcript_path, transcript parsed before the hook's write transaction, GET /api/sessions/:id?events=0 for the web page (reloads only on its own session, debounced), streamed export, transactional clear-data and chunked cleanup, subagent import reads tool_use_ids once, runtime logs rotated past 5 MB.
336 lines
11 KiB
TypeScript
336 lines
11 KiB
TypeScript
/**
|
|
* @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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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(
|
|
<>
|
|
<GlobalTooltip />
|
|
<button title="Refresh the working copy">refresh</button>
|
|
{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(
|
|
<button title="Raw value" data-tip-off>
|
|
opted out
|
|
</button>
|
|
);
|
|
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(
|
|
<button title="">
|
|
<span data-testid="blank-child">blank</span>
|
|
</button>
|
|
);
|
|
|
|
hover(screen.getByTestId("blank-child"));
|
|
await settle();
|
|
expect(screen.queryByTestId("global-tooltip")).toBeNull();
|
|
|
|
const button = screen.getByRole("button", { name: "refresh" });
|
|
button.innerHTML = "<span data-testid='deep'>refresh</span>";
|
|
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(
|
|
<button title="Stage this file">
|
|
<svg aria-hidden="true" />
|
|
</button>
|
|
);
|
|
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(
|
|
<button title="Two parts">
|
|
<span data-testid="part-a">a</span>
|
|
<span data-testid="part-b">b</span>
|
|
</button>
|
|
);
|
|
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(
|
|
<>
|
|
<GlobalTooltip />
|
|
<button title="Going away">x</button>
|
|
</>
|
|
);
|
|
hover(screen.getByRole("button", { name: "x" }));
|
|
await settle();
|
|
expect(screen.getByTestId("global-tooltip")).toBeInTheDocument();
|
|
|
|
rerender(
|
|
<>
|
|
<GlobalTooltip />
|
|
</>
|
|
);
|
|
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(
|
|
<>
|
|
<GlobalTooltip />
|
|
<button title="Pull 2 commits">pull</button>
|
|
</>
|
|
);
|
|
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(
|
|
<>
|
|
<GlobalTooltip />
|
|
<button title="Up to date">pull</button>
|
|
</>
|
|
);
|
|
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(
|
|
<>
|
|
<GlobalTooltip />
|
|
<button title="Pull 2 commits">pull</button>
|
|
</>
|
|
);
|
|
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(
|
|
<>
|
|
<GlobalTooltip />
|
|
<button>pull</button>
|
|
</>
|
|
);
|
|
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(<button title="Second control">second</button>);
|
|
// 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(
|
|
<>
|
|
<GlobalTooltip />
|
|
<div data-testid="scroller">
|
|
<button title="Inside the scroller">inside</button>
|
|
</div>
|
|
<div data-testid="elsewhere" />
|
|
</>
|
|
);
|
|
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();
|
|
});
|
|
|
|
it("silences an ancestor's native title while a child's bubble is up", async () => {
|
|
// Parking only the child's title let the browser fall back to the nearest
|
|
// ancestor's — a native black box over the designed bubble.
|
|
render(
|
|
<>
|
|
<GlobalTooltip />
|
|
<div data-testid="header" title="Drag to move this pane">
|
|
<button title="Close pane">x</button>
|
|
</div>
|
|
</>
|
|
);
|
|
const header = screen.getByTestId("header");
|
|
const button = screen.getByRole("button", { name: "x" });
|
|
hover(button);
|
|
await settle();
|
|
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Close pane");
|
|
expect(header).not.toHaveAttribute("title");
|
|
|
|
unhover(button);
|
|
expect(header).toHaveAttribute("title", "Drag to move this pane");
|
|
expect(button).toHaveAttribute("title", "Close pane");
|
|
});
|
|
|
|
it("does not bring back an ancestor title React removed while it was parked", async () => {
|
|
const { rerender } = render(
|
|
<>
|
|
<GlobalTooltip />
|
|
<div data-testid="header" title="Drag">
|
|
<button title="Close">x</button>
|
|
</div>
|
|
</>
|
|
);
|
|
const button = screen.getByRole("button", { name: "x" });
|
|
hover(button);
|
|
await settle();
|
|
rerender(
|
|
<>
|
|
<GlobalTooltip />
|
|
<div data-testid="header">
|
|
<button title="Close">x</button>
|
|
</div>
|
|
</>
|
|
);
|
|
unhover(button);
|
|
expect(screen.getByTestId("header")).not.toHaveAttribute("title");
|
|
});
|
|
});
|