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
+60 -3
View File
@@ -5,19 +5,31 @@
* once measured, closed by Escape, a press outside it, or after an item runs.
* `useContextMenu` keeps "which item, where" for a list of rows; `menuPoint`
* turns a mouse event into that point (and stops the browser's own menu).
*
* Keyboard (WAI-ARIA menu): the first enabled item takes focus on open;
* Up/Down wrap, Home/End jump, Escape and Tab close, and focus goes back to
* what held it before the menu opened — unless an item moved it elsewhere
* (a rename field, a dialog). A menu opened from the keyboard (Shift+F10 or
* the Menu key reports clientX/Y 0) anchors under the element instead of the
* viewport's corner.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import type { MouseEvent as ReactMouseEvent } from "react";
import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent } from "react";
export type MenuPoint = { x: number; y: number };
/** The pointer of a right-click, the browser's own menu suppressed. */
/** The pointer of a right-click, the browser's own menu suppressed. A
* keyboard-opened menu has no pointer, so it anchors under the element. */
export function menuPoint(e: ReactMouseEvent): MenuPoint {
e.preventDefault();
e.stopPropagation();
if (e.clientX === 0 && e.clientY === 0 && e.currentTarget instanceof Element) {
const r = e.currentTarget.getBoundingClientRect();
return { x: r.left, y: r.bottom };
}
return { x: e.clientX, y: e.clientY };
}
@@ -33,6 +45,7 @@ export function useContextMenu<T>() {
const MARGIN = 8;
const stop = (e: { stopPropagation: () => void }) => e.stopPropagation();
const ITEMS = '[role="menuitem"]:not([disabled]):not([aria-disabled="true"])';
export function ContextMenu({
x,
@@ -50,6 +63,49 @@ export function ContextMenu({
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState({ left: x, top: y });
// Focus in on open, back out on close. Runs once per mount: a menu that
// moves to a new point keeps its focus.
useEffect(() => {
const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null;
ref.current?.querySelector<HTMLElement>(ITEMS)?.focus({ preventScroll: true });
return () => {
// Only when focus would otherwise be lost (it sat on a removed item).
const now = document.activeElement;
if (!now || now === document.body || ref.current?.contains(now)) previous?.focus?.();
};
}, []);
const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
const inField = (e.target as HTMLElement).closest(
"input, textarea, select, [contenteditable='true']"
);
if (e.key === "Escape" || (e.key === "Tab" && !inField)) {
// Handled here so a dialog behind the menu does not close as well.
e.preventDefault();
e.stopPropagation();
onClose();
return;
}
// A field inside a menu (a size slider, a search box) keeps its own keys.
if (inField) return;
const items = [...(ref.current?.querySelectorAll<HTMLElement>(ITEMS) ?? [])];
if (items.length === 0) return;
const i = items.indexOf(document.activeElement as HTMLElement);
const next =
e.key === "ArrowDown"
? (i + 1) % items.length
: e.key === "ArrowUp"
? (i - 1 + items.length) % items.length
: e.key === "Home"
? 0
: e.key === "End"
? items.length - 1
: -1;
if (next < 0) return;
e.preventDefault();
items[next]!.focus();
};
useLayoutEffect(() => {
const r = ref.current?.getBoundingClientRect();
if (!r) return;
@@ -88,6 +144,7 @@ export function ContextMenu({
onDoubleClick={stop}
onMouseDown={stop}
onPointerDown={stop}
onKeyDown={onKeyDown}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
@@ -101,7 +158,7 @@ export function ContextMenu({
}
export const MENU_ITEM =
"flex w-full items-center gap-2 rounded px-2 py-1 text-left text-xs text-fg-secondary hover:bg-surface-4 hover:text-fg-primary disabled:pointer-events-none disabled:opacity-50";
"flex w-full items-center gap-2 rounded px-2 py-1 text-left text-xs text-fg-secondary hover:bg-surface-4 hover:text-fg-primary focus:outline-none focus-visible:bg-surface-4 focus-visible:text-fg-primary focus-visible:ring-1 focus-visible:ring-accent/60 disabled:pointer-events-none disabled:opacity-50";
export function MenuItem({
onSelect,