8fb703c6db
- Contrast: --fg-muted reaches 4.5:1 on every surface in all nine themes (dark default was 2.55:1); status chips and accent text clear 4.5:1 on their own tints. New --accent-solid(/-hover) + tailwind `accent-solid` is the fill behind white text (.btn-primary, Switch on, Checkbox tick); --accent is now a text colour. Switch off / unchecked Checkbox get a >=3:1 edge. - <html lang> follows the chosen language; a global prefers-reduced-motion rule; index.html body uses a token instead of text-gray-200. - Layout: the sidebar starts collapsed below lg and becomes an off-canvas drawer below md (menu button, backdrop, Escape, useDialog). - ContextMenu: focus moves in, arrows/Home/End, Escape/Tab close, focus returns, a keyboard-opened menu anchors to its target. Select is a real listbox button (aria-expanded, activedescendant, type-ahead). PageTabs implements APG tabs with a tabpanel. New .btn-secondary/.btn-danger/ .btn-icon and a .select class for native selects, all with focus rings. - StatusBar announces connection and "N need you" politely; Sidebar's connection modal uses useDialog; EmptyState/Skeleton/AiToolIcon a11y. - api.ts request(): a non-JSON 200 throws a translated EBADRESPONSE instead of "Unexpected token '<'".
95 lines
3.7 KiB
TypeScript
95 lines
3.7 KiB
TypeScript
/**
|
|
* @file ContextMenu.test.tsx
|
|
* @description Keyboard model of the shared context menu: focus lands on the
|
|
* first item, arrows/Home/End move (wrapping, skipping disabled items), Escape
|
|
* and Tab close and hand focus back to the trigger, and a keyboard-opened menu
|
|
* (clientX/Y 0) anchors under its element.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { describe, it, expect, vi } from "vitest";
|
|
import { fireEvent, render, screen } from "@testing-library/react";
|
|
import { ContextMenu, MenuItem, useContextMenu } from "../ContextMenu";
|
|
|
|
function Harness({ onPick = () => {} }: { onPick?: (s: string) => void }) {
|
|
const { menu, open, close } = useContextMenu<string>();
|
|
return (
|
|
<>
|
|
<button onContextMenu={(e) => open(e, "row")}>trigger</button>
|
|
{menu && (
|
|
<ContextMenu x={menu.x} y={menu.y} onClose={close}>
|
|
<MenuItem onSelect={() => (onPick("a"), close())}>Alpha</MenuItem>
|
|
<MenuItem disabled onSelect={() => {}}>
|
|
Beta
|
|
</MenuItem>
|
|
<MenuItem onSelect={() => (onPick("c"), close())}>Gamma</MenuItem>
|
|
</ContextMenu>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
const item = (name: string) => screen.getByRole("menuitem", { name });
|
|
|
|
describe("ContextMenu keyboard", () => {
|
|
it("focuses the first item and moves with arrows, Home and End", () => {
|
|
render(<Harness />);
|
|
const trigger = screen.getByText("trigger");
|
|
trigger.focus();
|
|
fireEvent.contextMenu(trigger, { clientX: 40, clientY: 50 });
|
|
expect(item("Alpha")).toHaveFocus();
|
|
fireEvent.keyDown(item("Alpha"), { key: "ArrowDown" });
|
|
expect(item("Gamma")).toHaveFocus(); // Beta is disabled
|
|
fireEvent.keyDown(item("Gamma"), { key: "ArrowDown" });
|
|
expect(item("Alpha")).toHaveFocus(); // wraps
|
|
fireEvent.keyDown(item("Alpha"), { key: "ArrowUp" });
|
|
expect(item("Gamma")).toHaveFocus();
|
|
fireEvent.keyDown(item("Gamma"), { key: "Home" });
|
|
expect(item("Alpha")).toHaveFocus();
|
|
fireEvent.keyDown(item("Alpha"), { key: "End" });
|
|
expect(item("Gamma")).toHaveFocus();
|
|
});
|
|
|
|
it.each(["Escape", "Tab"])("%s closes and returns focus to the trigger", (key) => {
|
|
render(<Harness />);
|
|
const trigger = screen.getByText("trigger");
|
|
trigger.focus();
|
|
fireEvent.contextMenu(trigger, { clientX: 40, clientY: 50 });
|
|
fireEvent.keyDown(item("Alpha"), { key });
|
|
expect(screen.queryByRole("menu")).toBeNull();
|
|
expect(trigger).toHaveFocus();
|
|
});
|
|
|
|
it("Escape in the menu does not reach a document listener (a dialog behind)", () => {
|
|
const behind = vi.fn();
|
|
document.addEventListener("keydown", behind);
|
|
render(<Harness />);
|
|
fireEvent.contextMenu(screen.getByText("trigger"), { clientX: 40, clientY: 50 });
|
|
fireEvent.keyDown(item("Alpha"), { key: "Escape" });
|
|
document.removeEventListener("keydown", behind);
|
|
expect(behind).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns focus to the trigger after an item runs", () => {
|
|
const pick = vi.fn();
|
|
render(<Harness onPick={pick} />);
|
|
const trigger = screen.getByText("trigger");
|
|
trigger.focus();
|
|
fireEvent.contextMenu(trigger, { clientX: 40, clientY: 50 });
|
|
fireEvent.click(item("Gamma"));
|
|
expect(pick).toHaveBeenCalledWith("c");
|
|
expect(trigger).toHaveFocus();
|
|
});
|
|
|
|
it("anchors a keyboard-opened menu (clientX/Y 0) under the element", () => {
|
|
render(<Harness />);
|
|
const trigger = screen.getByText("trigger");
|
|
trigger.getBoundingClientRect = () =>
|
|
({ left: 120, bottom: 80, top: 60, right: 200, width: 80, height: 20 }) as DOMRect;
|
|
fireEvent.contextMenu(trigger, { clientX: 0, clientY: 0 });
|
|
const menu = screen.getByRole("menu");
|
|
expect(menu.style.left).toBe("120px");
|
|
expect(menu.style.top).toBe("80px");
|
|
});
|
|
});
|