feat(a11y): contrast tokens, accent-solid, responsive drawer, keyboard menus
- 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 '<'".
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* @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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* @file Layout.test.tsx
|
||||
* @description Responsive shell: with no stored preference the sidebar starts
|
||||
* collapsed below `lg`, and below `md` it becomes an off-canvas drawer opened
|
||||
* from a menu button — a modal dialog that Escape and the backdrop close,
|
||||
* handing focus back to the button.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
|
||||
vi.mock("../Sagi/Sagi", () => ({ Sagi: () => null }));
|
||||
vi.mock("../UpdateNotifier", () => ({ UpdateNotifier: () => null }));
|
||||
vi.mock("../StatusBar", () => ({ StatusBar: () => null }));
|
||||
vi.mock("../WorkspaceTree", () => ({ WorkspaceTree: () => null }));
|
||||
|
||||
import { Layout, DRAWER_QUERY } from "../Layout";
|
||||
import { loadCollapsed, SIDEBAR_STORAGE_KEY } from "../Sidebar";
|
||||
|
||||
function viewport(width: number) {
|
||||
vi.stubGlobal("matchMedia", (q: string) => {
|
||||
const max = Number(/max-width:\s*([\d.]+)px/.exec(q)?.[1] ?? Infinity);
|
||||
return {
|
||||
matches: width <= max,
|
||||
media: q,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
localStorage.removeItem(SIDEBAR_STORAGE_KEY);
|
||||
});
|
||||
|
||||
describe("loadCollapsed", () => {
|
||||
it("starts collapsed below lg when nothing is stored", () => {
|
||||
viewport(800);
|
||||
expect(loadCollapsed()).toBe(true);
|
||||
viewport(1440);
|
||||
expect(loadCollapsed()).toBe(false);
|
||||
});
|
||||
|
||||
it("a stored choice wins over the viewport", () => {
|
||||
viewport(800);
|
||||
localStorage.setItem(SIDEBAR_STORAGE_KEY, "false");
|
||||
expect(loadCollapsed()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Layout drawer", () => {
|
||||
it("below md: the sidebar is off-canvas until the menu button opens it", async () => {
|
||||
viewport(600);
|
||||
expect(window.matchMedia(DRAWER_QUERY).matches).toBe(true);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Layout wsConnected />
|
||||
</MemoryRouter>
|
||||
);
|
||||
const aside = screen.getByTestId("sidebar");
|
||||
expect(aside.className).toContain("invisible");
|
||||
expect(aside).not.toHaveAttribute("role", "dialog");
|
||||
expect(screen.getByRole("main").style.marginLeft).toBe("0rem");
|
||||
|
||||
const open = screen.getByRole("button", { name: "Open menu" });
|
||||
open.focus();
|
||||
fireEvent.click(open);
|
||||
expect(aside).toHaveAttribute("role", "dialog");
|
||||
expect(aside).toHaveAttribute("aria-modal", "true");
|
||||
expect(aside.className).not.toContain("invisible");
|
||||
await waitFor(() => expect(aside.contains(document.activeElement)).toBe(true));
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(aside.className).toContain("invisible");
|
||||
expect(open).toHaveFocus();
|
||||
|
||||
fireEvent.click(open);
|
||||
fireEvent.click(screen.getByTestId("sidebar-backdrop"));
|
||||
expect(aside.className).toContain("invisible");
|
||||
});
|
||||
|
||||
it("at md and up: no menu button, the sidebar is a landmark beside the content", () => {
|
||||
viewport(800);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Layout wsConnected />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: "Open menu" })).toBeNull();
|
||||
expect(screen.getByRole("complementary", { name: "Sidebar" })).toBeInTheDocument();
|
||||
// Collapsed rail by default below lg, so the content is not covered.
|
||||
expect(screen.getByRole("main").style.marginLeft).toBe("4.25rem");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* @file Select.test.tsx
|
||||
* @description The shared Select as a WAI-ARIA listbox button: expanded state,
|
||||
* listbox/option roles with aria-selected, aria-activedescendant tracking,
|
||||
* Arrow/Home/End/Enter/Escape/type-ahead, and focus back on the trigger.
|
||||
* @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 { Select } from "../Select";
|
||||
|
||||
const OPTIONS = [
|
||||
{ value: "a", label: "Apple" },
|
||||
{ value: "b", label: "Banana" },
|
||||
{ value: "c", label: "Cherry" },
|
||||
{ value: "d", label: "Blueberry" },
|
||||
];
|
||||
|
||||
function setup(value = "b") {
|
||||
const onChange = vi.fn();
|
||||
render(<Select value={value} onChange={onChange} options={OPTIONS} label="Fruit" />);
|
||||
const button = screen.getByRole("button", { name: "Fruit" });
|
||||
return { onChange, button };
|
||||
}
|
||||
|
||||
const activeLabel = () => {
|
||||
const list = screen.getByRole("listbox");
|
||||
return document.getElementById(list.getAttribute("aria-activedescendant")!)?.textContent;
|
||||
};
|
||||
|
||||
describe("Select (listbox button)", () => {
|
||||
it("opens on ArrowDown into a focused listbox on the chosen option", () => {
|
||||
const { button } = setup();
|
||||
expect(button).toHaveAttribute("aria-haspopup", "listbox");
|
||||
expect(button).toHaveAttribute("aria-expanded", "false");
|
||||
fireEvent.keyDown(button, { key: "ArrowDown" });
|
||||
expect(button).toHaveAttribute("aria-expanded", "true");
|
||||
const list = screen.getByRole("listbox", { name: "Fruit" });
|
||||
expect(list).toHaveFocus();
|
||||
expect(button).toHaveAttribute("aria-controls", list.id);
|
||||
expect(screen.getByRole("option", { name: "Banana" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("option", { name: "Apple" })).toHaveAttribute("aria-selected", "false");
|
||||
expect(activeLabel()).toBe("Banana");
|
||||
});
|
||||
|
||||
it("moves with arrows/Home/End and picks with Enter, focus back on the button", () => {
|
||||
const { button, onChange } = setup();
|
||||
fireEvent.click(button);
|
||||
const list = screen.getByRole("listbox");
|
||||
fireEvent.keyDown(list, { key: "ArrowDown" });
|
||||
expect(activeLabel()).toBe("Cherry");
|
||||
fireEvent.keyDown(list, { key: "Home" });
|
||||
expect(activeLabel()).toBe("Apple");
|
||||
fireEvent.keyDown(list, { key: "End" });
|
||||
expect(activeLabel()).toBe("Blueberry");
|
||||
fireEvent.keyDown(list, { key: "ArrowUp" });
|
||||
fireEvent.keyDown(list, { key: "Enter" });
|
||||
expect(onChange).toHaveBeenCalledWith("c");
|
||||
expect(screen.queryByRole("listbox")).toBeNull();
|
||||
expect(button).toHaveFocus();
|
||||
});
|
||||
|
||||
it("Escape closes without picking and does not bubble", () => {
|
||||
const { button, onChange } = setup();
|
||||
const behind = vi.fn();
|
||||
document.addEventListener("keydown", behind);
|
||||
fireEvent.click(button);
|
||||
fireEvent.keyDown(screen.getByRole("listbox"), { key: "Escape" });
|
||||
document.removeEventListener("keydown", behind);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
expect(behind).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("listbox")).toBeNull();
|
||||
expect(button).toHaveFocus();
|
||||
});
|
||||
|
||||
it("type-ahead jumps to the next option starting with the typed letter", () => {
|
||||
const { button } = setup("a");
|
||||
fireEvent.click(button);
|
||||
const list = screen.getByRole("listbox");
|
||||
fireEvent.keyDown(list, { key: "b" });
|
||||
expect(activeLabel()).toBe("Banana");
|
||||
fireEvent.keyDown(list, { key: "b" });
|
||||
expect(activeLabel()).toBe("Blueberry"); // a repeated letter cycles
|
||||
});
|
||||
|
||||
it("picks with a click on an option", () => {
|
||||
const { button, onChange } = setup();
|
||||
fireEvent.click(button);
|
||||
fireEvent.click(screen.getByText("Apple"));
|
||||
expect(onChange).toHaveBeenCalledWith("a");
|
||||
});
|
||||
});
|
||||
@@ -61,17 +61,20 @@ describe("StatusBar", () => {
|
||||
const user = userEvent.setup();
|
||||
const onConnectionClick = vi.fn();
|
||||
at("/", <StatusBar wsConnected left="15rem" onConnectionClick={onConnectionClick} />);
|
||||
expect(screen.getByText("Live")).toBeInTheDocument();
|
||||
expect(screen.getByText(`v${__APP_VERSION__}`)).toBeInTheDocument();
|
||||
const conn = screen.getByRole("button", { name: /Live/ });
|
||||
expect(conn).toHaveTextContent(`v${__APP_VERSION__}`);
|
||||
expect(await screen.findByText("Today $1.23")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("status-bar-counts").textContent).toBe("2 running · 1 need you");
|
||||
await user.click(screen.getByText("Live"));
|
||||
await user.click(conn);
|
||||
expect(onConnectionClick).toHaveBeenCalled();
|
||||
// Polite live region: the connection and who is waiting, nothing that ticks.
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Live · 1 need you");
|
||||
});
|
||||
|
||||
it("says Disconnected when the socket is down", () => {
|
||||
at("/", <StatusBar wsConnected={false} left="0" onConnectionClick={() => {}} />);
|
||||
expect(screen.getByText("Disconnected")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Disconnected/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Disconnected");
|
||||
});
|
||||
|
||||
it("off the Workspace page shows no workspace or session cost", async () => {
|
||||
|
||||
Reference in New Issue
Block a user