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
+65 -14
View File
@@ -1,12 +1,20 @@
/**
* @file PageTabs.tsx
* @description Shared top-of-page tab bar used by the pages that merged several
* former routes into one (Settings hub, Sessions/Events history). Rendering only -
* the owning page keeps the active tab in its own query param. `actions` sits at
* the bar's right edge, outside the tablist.
* former routes into one (Settings hub, Sessions/Events history). The owning
* page keeps the active tab in its own query param. `actions` sits at the bar's
* right edge, outside the tablist.
*
* These are real in-page tabs (one view swapped in place), so they follow the
* WAI-ARIA tabs pattern: one tab stop (roving tabindex), Left/Right/Home/End
* move AND select. Pass the active view as `children` and it is wrapped in the
* `role="tabpanel"` the tabs point at with `aria-controls`; without children no
* `aria-controls` is emitted (it would name an element that does not exist).
* The selected tab is marked by weight and an underline, not colour alone.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { ComponentType, ReactNode } from "react";
import { useId, type ComponentType, type KeyboardEvent, type ReactNode } from "react";
export interface PageTab {
id: string;
@@ -21,18 +29,48 @@ interface PageTabsProps {
ariaLabel: string;
/** Buttons at the right edge of the bar (not tabs). */
actions?: ReactNode;
/** The active tab's view, rendered inside its `role="tabpanel"`. */
children?: ReactNode;
}
/**
* Horizontal tab strip for a page that hosts several panels.
* @param props - Tabs to render, the active tab id, and the selection callback.
* @returns The tab bar element.
*/
export function PageTabs({ tabs, activeId, onSelect, ariaLabel, actions }: PageTabsProps) {
export function PageTabs({
tabs,
activeId,
onSelect,
ariaLabel,
actions,
children,
}: PageTabsProps) {
const base = useId();
const tabId = (id: string) => `${base}-tab-${id}`;
const panelId = `${base}-panel`;
const hasPanel = children !== undefined;
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const i = tabs.findIndex((t) => t.id === activeId);
const last = tabs.length - 1;
const next =
e.key === "ArrowRight"
? (i + 1) % tabs.length
: e.key === "ArrowLeft"
? (i - 1 + tabs.length) % tabs.length
: e.key === "Home"
? 0
: e.key === "End"
? last
: -1;
const tab = tabs[next];
if (!tab) return;
e.preventDefault();
onSelect(tab.id);
document.getElementById(tabId(tab.id))?.focus();
};
const list = (
<div
role="tablist"
aria-label={ariaLabel}
onKeyDown={onKeyDown}
className={`flex items-center gap-1.5 overflow-x-auto no-scrollbar ${
actions ? "min-w-0 flex-1" : "border-b border-border/60 pb-2"
}`}
@@ -42,13 +80,16 @@ export function PageTabs({ tabs, activeId, onSelect, ariaLabel, actions }: PageT
return (
<button
key={id}
id={tabId(id)}
type="button"
role="tab"
aria-selected={isActive}
aria-controls={hasPanel && isActive ? panelId : undefined}
tabIndex={isActive ? 0 : -1}
onClick={() => onSelect(id)}
className={`inline-flex items-center gap-1.5 text-xs whitespace-nowrap px-3 py-1.5 rounded-lg border transition-colors flex-shrink-0 ${
className={`inline-flex items-center gap-1.5 text-xs whitespace-nowrap px-3 py-1.5 rounded-lg border transition-colors flex-shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 ${
isActive
? "bg-accent/15 border-accent/30 text-accent"
? "bg-accent/15 border-accent/30 text-accent font-semibold shadow-[inset_0_-2px_0_rgb(var(--accent))]"
: "border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
}`}
>
@@ -59,11 +100,21 @@ export function PageTabs({ tabs, activeId, onSelect, ariaLabel, actions }: PageT
})}
</div>
);
if (!actions) return list;
return (
const bar = actions ? (
<div className="flex items-center gap-3 border-b border-border/60 pb-2">
{list}
<div className="flex flex-shrink-0 items-center gap-1.5">{actions}</div>
</div>
) : (
list
);
if (!hasPanel) return bar;
return (
<>
{bar}
<div role="tabpanel" id={panelId} aria-labelledby={tabId(activeId)}>
{children}
</div>
</>
);
}