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 '<'".
94 lines
3.6 KiB
TypeScript
94 lines
3.6 KiB
TypeScript
/**
|
|
* @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");
|
|
});
|
|
});
|