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:
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file Sidebar.tsx
|
||||
* @description The app's left sidebar: the brand, the navigation links and the workspace list, with Settings and the collapse/expand button pinned to its foot. It also owns the connection details modal (opened from the status bar), which carries the version and the update check — the sidebar itself has no footer, language or theme controls (those live in Settings → General / Appearance). The collapsed state is kept in localStorage.
|
||||
* @description The app's left sidebar: the brand, the navigation links and the workspace list, with Settings and the collapse/expand button pinned to its foot. It also owns the connection details modal (opened from the status bar), which carries the version and the update check — the sidebar itself has no footer, language or theme controls (those live in Settings → General / Appearance). The collapsed state is kept in localStorage; with none stored it starts collapsed below Tailwind's `lg`. Below `md` the Layout turns it into an off-canvas drawer (`drawer` prop): a modal dialog with the shared `useDialog` focus contract.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -51,6 +51,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDialog } from "../hooks/useDialog";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
FolderOpen,
|
||||
@@ -103,12 +104,18 @@ interface PersistedStats {
|
||||
recentEvents: { type: string; at: number }[];
|
||||
}
|
||||
|
||||
/** Below Tailwind's `lg` a full 15rem sidebar eats too much of the page. */
|
||||
const NARROW_QUERY = "(max-width: 1023.98px)";
|
||||
|
||||
/** The stored choice, else collapsed on a narrow viewport. */
|
||||
function loadCollapsed(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY) === "true";
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored !== null) return stored === "true";
|
||||
} catch {
|
||||
return false;
|
||||
/* storage off: fall through to the viewport */
|
||||
}
|
||||
return window.matchMedia?.(NARROW_QUERY).matches ?? false;
|
||||
}
|
||||
|
||||
function loadStats(): PersistedStats {
|
||||
@@ -161,6 +168,8 @@ interface SidebarProps {
|
||||
onConnectionOpenChange: (open: boolean) => void;
|
||||
/** Told whether a newer release is out, for the status bar's dot. */
|
||||
onUpdateAvailableChange?: (available: boolean) => void;
|
||||
/** Set on a narrow viewport: the sidebar is an off-canvas drawer. */
|
||||
drawer?: { open: boolean; onClose: () => void };
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
@@ -170,8 +179,12 @@ export function Sidebar({
|
||||
connectionOpen,
|
||||
onConnectionOpenChange,
|
||||
onUpdateAvailableChange,
|
||||
drawer,
|
||||
}: SidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
const asideRef = useRef<HTMLElement>(null);
|
||||
const drawerOpen = Boolean(drawer?.open);
|
||||
useDialog(drawerOpen, asideRef, drawer?.onClose);
|
||||
// Track whether nav items are clipped by overflow so we can render
|
||||
// chevron affordances pointing toward the hidden items. Recomputed on
|
||||
// scroll, resize, and any structural change (e.g. collapse toggle).
|
||||
@@ -394,8 +407,21 @@ export function Sidebar({
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`fixed left-0 top-0 bottom-0 bg-surface-1 border-r border-border flex flex-col z-30 overflow-hidden transition-[width] duration-200 ${
|
||||
collapsed ? "w-[4.25rem]" : "w-60"
|
||||
ref={asideRef}
|
||||
aria-label={t("nav:sidebar")}
|
||||
role={drawerOpen ? "dialog" : undefined}
|
||||
aria-modal={drawerOpen || undefined}
|
||||
data-testid="sidebar"
|
||||
className={`fixed left-0 top-0 bottom-0 bg-surface-1 border-r border-border flex flex-col overflow-hidden ${
|
||||
drawer
|
||||
? // Visibility flips at once on open (a still-hidden panel cannot take
|
||||
// focus) and only after the slide-out on close.
|
||||
`z-50 w-60 max-w-[85vw] duration-200 ${
|
||||
drawer.open
|
||||
? "visible translate-x-0 shadow-2xl transition-transform"
|
||||
: "invisible -translate-x-full transition-[transform,visibility]"
|
||||
}`
|
||||
: `z-30 transition-[width] duration-200 ${collapsed ? "w-[4.25rem]" : "w-60"}`
|
||||
}`}
|
||||
>
|
||||
<div className="px-3 py-4 border-b border-border flex-shrink-0">
|
||||
@@ -500,8 +526,8 @@ export function Sidebar({
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
data-testid="sidebar-toggle"
|
||||
title={collapsed ? t("nav:expand") : t("nav:collapse")}
|
||||
aria-label={collapsed ? t("nav:expand") : t("nav:collapse")}
|
||||
title={drawer ? t("nav:closeMenu") : collapsed ? t("nav:expand") : t("nav:collapse")}
|
||||
aria-label={drawer ? t("nav:closeMenu") : collapsed ? t("nav:expand") : t("nav:collapse")}
|
||||
className={`inline-flex items-center justify-center rounded-lg text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary ${
|
||||
collapsed ? "py-2.5" : "h-9 w-9 flex-shrink-0"
|
||||
}`}
|
||||
@@ -604,15 +630,8 @@ function ConnectionStatusModal({
|
||||
}, [open]);
|
||||
|
||||
const close = useCallback(() => onClose(), [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") close();
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [open, close]);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
useDialog(open, panelRef, close);
|
||||
|
||||
const wsUrl = useMemo(() => {
|
||||
if (typeof window === "undefined") return "";
|
||||
@@ -641,14 +660,17 @@ function ConnectionStatusModal({
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="connection-status-title"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) close();
|
||||
}}
|
||||
>
|
||||
<div className="w-full max-w-md card shadow-2xl animate-slide-up overflow-hidden flex flex-col max-h-[85vh]">
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="connection-status-title"
|
||||
className="w-full max-w-md card shadow-2xl animate-slide-up overflow-hidden flex flex-col max-h-[85vh]"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 px-5 py-4 border-b border-border">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div
|
||||
@@ -690,9 +712,9 @@ function ConnectionStatusModal({
|
||||
type="button"
|
||||
onClick={close}
|
||||
aria-label={t("nav:close")}
|
||||
className="p-1.5 -m-1 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors flex-shrink-0"
|
||||
className="btn-icon -m-1 flex-shrink-0"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
<X className="w-4 h-4" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -919,6 +941,7 @@ function Sparkline({
|
||||
connected: boolean;
|
||||
avgLabel: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const W = 320;
|
||||
const H = 56;
|
||||
const max = Math.max(1, ...buckets);
|
||||
@@ -930,15 +953,15 @@ function Sparkline({
|
||||
});
|
||||
const linePath = points.length > 0 ? `M ${points.join(" L ")}` : "";
|
||||
const areaPath = points.length > 0 ? `M 0,${H} L ${points.join(" L ")} L ${W},${H} Z` : "";
|
||||
const stroke = connected ? "#34d399" : "#6b7280";
|
||||
const stroke = connected ? "rgb(var(--status-success))" : "rgb(var(--fg-muted))";
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface-2 p-2.5">
|
||||
<svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" className="w-full h-14" aria-hidden>
|
||||
<defs>
|
||||
<linearGradient id="spark-fill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={stroke} stopOpacity="0.35" />
|
||||
<stop offset="100%" stopColor={stroke} stopOpacity="0" />
|
||||
<stop offset="0%" style={{ stopColor: stroke, stopOpacity: 0.35 }} />
|
||||
<stop offset="100%" style={{ stopColor: stroke, stopOpacity: 0 }} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{areaPath && <path d={areaPath} fill="url(#spark-fill)" />}
|
||||
@@ -946,7 +969,7 @@ function Sparkline({
|
||||
<path
|
||||
d={linePath}
|
||||
fill="none"
|
||||
stroke={stroke}
|
||||
style={{ stroke }}
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
@@ -957,7 +980,7 @@ function Sparkline({
|
||||
<div className="flex items-center justify-between mt-1.5 text-[10px] text-fg-muted font-mono">
|
||||
<span>−60s</span>
|
||||
<span>{avgLabel}</span>
|
||||
<span>{"now"}</span>
|
||||
<span>{t("nav:now")}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user