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:
2026-10-05 13:22:00 +07:00
parent 17a5c7470d
commit 8fb703c6db
26 changed files with 917 additions and 167 deletions
@@ -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");
});
});