- ActionMenu: skip disabled items in roving keyboard nav (plumb disabledIndices through the AnchoredPopover platform); a pending action no longer strands focus on an unfocusable button. - ActionMenu + ChannelStatsPopover: add type="button" to the popover triggers so they can't submit an enclosing form. - ChannelStatsPopover: restore the per-stat explanations as a native title on each row — the guidance the old inline stats showed is back. - i18n: drop the now-dead channels.tags.yourTags / .manage keys (en+hu); the stats *Hint keys are live again via the restored row titles. - test: cover ActionMenu (open/select/close/disabled) and ChannelStatsPopover (rows, conditional ETA row, per-row hint title).
91 lines
3.6 KiB
TypeScript
91 lines
3.6 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// Contract of the reusable ⋯ overflow menu. Labels/handlers are plain props (no i18n), so we assert
|
|
// the open/select/close + disabled-item behaviour. The menu content only mounts while open, and the
|
|
// AnchoredPopover platform is floating-ui based, so we stub the two observers autoUpdate wires and
|
|
// wrap in a FloatingTree (its real-app parent) so nested-layer dispatch has a node to register.
|
|
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
|
|
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
|
import { FloatingTree } from "@floating-ui/react";
|
|
import ActionMenu, { type ActionMenuItem } from "./ActionMenu";
|
|
|
|
beforeAll(() => {
|
|
// floating-ui's autoUpdate observes with ResizeObserver + IntersectionObserver (layoutShift); jsdom
|
|
// ships neither, and the floating element mounts (so they run) the moment the menu opens.
|
|
class Stub {
|
|
observe() {}
|
|
unobserve() {}
|
|
disconnect() {}
|
|
takeRecords() {
|
|
return [];
|
|
}
|
|
}
|
|
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
|
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
|
});
|
|
|
|
afterEach(cleanup);
|
|
|
|
const renderMenu = (items: ActionMenuItem[]) =>
|
|
render(
|
|
<FloatingTree>
|
|
<ActionMenu ariaLabel="Catalog actions" items={items} />
|
|
</FloatingTree>,
|
|
);
|
|
|
|
describe("ActionMenu", () => {
|
|
it("renders a labelled trigger and keeps items unmounted until opened", () => {
|
|
renderMenu([{ id: "sync", label: "Sync", onSelect: vi.fn() }]);
|
|
expect(screen.getByRole("button", { name: "Catalog actions" })).toBeTruthy();
|
|
expect(screen.queryByRole("menuitem")).toBeNull();
|
|
});
|
|
|
|
// floating-ui never computes a position in jsdom, so the surface stays visibility:hidden; the items
|
|
// are in the DOM but the a11y tree treats them as hidden — hence `{ hidden: true }` on role queries.
|
|
it("opens on click and shows one menuitem per item", () => {
|
|
renderMenu([
|
|
{ id: "sync", label: "Sync", onSelect: vi.fn() },
|
|
{ id: "backfill", label: "Backfill", onSelect: vi.fn() },
|
|
]);
|
|
fireEvent.click(screen.getByRole("button", { name: "Catalog actions" }));
|
|
expect(screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent)).toEqual([
|
|
"Sync",
|
|
"Backfill",
|
|
]);
|
|
});
|
|
|
|
it("selecting an item fires its onSelect and closes the menu", () => {
|
|
const onSelect = vi.fn();
|
|
renderMenu([{ id: "sync", label: "Sync", onSelect }]);
|
|
fireEvent.click(screen.getByRole("button", { name: "Catalog actions" }));
|
|
fireEvent.click(screen.getByText("Sync"));
|
|
expect(onSelect).toHaveBeenCalledTimes(1);
|
|
expect(screen.queryByRole("menuitem", { hidden: true })).toBeNull();
|
|
});
|
|
|
|
it("a disabled item is inert — rendered disabled and never fires onSelect", () => {
|
|
const onSelect = vi.fn();
|
|
renderMenu([{ id: "sync", label: "Sync", onSelect, disabled: true }]);
|
|
fireEvent.click(screen.getByRole("button", { name: "Catalog actions" }));
|
|
const item = screen.getByText("Sync").closest("button")!;
|
|
expect((item as HTMLButtonElement).disabled).toBe(true);
|
|
fireEvent.click(item);
|
|
expect(onSelect).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("honours a custom trigger node", () => {
|
|
renderMenu([{ id: "sync", label: "Sync", onSelect: vi.fn() }]);
|
|
cleanup();
|
|
render(
|
|
<FloatingTree>
|
|
<ActionMenu
|
|
ariaLabel="More"
|
|
items={[{ id: "x", label: "X", onSelect: vi.fn() }]}
|
|
trigger={<span data-testid="custom-trigger">•••</span>}
|
|
/>
|
|
</FloatingTree>,
|
|
);
|
|
expect(screen.getByTestId("custom-trigger")).toBeTruthy();
|
|
});
|
|
});
|