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:
@@ -47,6 +47,7 @@ export function AiToolIcon({
|
||||
}
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={`${className} flex shrink-0 items-center justify-center rounded-md bg-surface-4 text-xs font-semibold text-fg-secondary`}
|
||||
>
|
||||
{tool.label.charAt(0).toUpperCase()}
|
||||
|
||||
@@ -82,10 +82,13 @@ export interface CheckboxProps {
|
||||
title?: string;
|
||||
}
|
||||
|
||||
// Fill + tick per tone. Accent fills with `accent-solid` under a white tick.
|
||||
// The status colours are TEXT shades — light in dark mode, dark in light mode —
|
||||
// so their tick takes the page colour (surface-0), which contrasts both ways.
|
||||
const TONE = {
|
||||
accent: "bg-accent border-accent",
|
||||
danger: "bg-status-danger border-status-danger",
|
||||
warning: "bg-status-warning border-status-warning",
|
||||
accent: "bg-accent-solid border-accent-solid text-white",
|
||||
danger: "bg-status-danger border-status-danger text-surface-0",
|
||||
warning: "bg-status-warning border-status-warning text-surface-0",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
@@ -114,11 +117,11 @@ export function Checkbox({
|
||||
className={`group inline-flex items-center gap-2 text-left disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none ${className ?? ""}`}
|
||||
>
|
||||
<span
|
||||
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-[5px] border transition-colors group-focus-visible:ring-2 group-focus-visible:ring-accent/40 ${
|
||||
checked ? TONE[tone] : "bg-surface-2 border-border group-hover:border-border-light"
|
||||
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-[5px] border transition-colors group-focus-visible:ring-2 group-focus-visible:ring-accent/60 ${
|
||||
checked ? TONE[tone] : "bg-surface-2 border-fg-muted group-hover:border-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
{checked && <Check className="h-3 w-3 text-white" strokeWidth={3} />}
|
||||
{checked && <Check className="h-3 w-3" strokeWidth={3} aria-hidden />}
|
||||
</span>
|
||||
{label != null && (
|
||||
<span
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -91,7 +91,7 @@ export function EmptyState({ icon: Icon, title, description, action, compact }:
|
||||
<div
|
||||
className={`rounded-2xl bg-surface-4 flex items-center justify-center ${compact ? "w-10 h-10 mb-3" : "w-14 h-14 mb-5"}`}
|
||||
>
|
||||
<Icon className="w-6 h-6 text-fg-muted" />
|
||||
<Icon className="w-6 h-6 text-fg-muted" aria-hidden />
|
||||
</div>
|
||||
<h3 className="text-base font-medium text-fg-secondary mb-2">{title}</h3>
|
||||
<p className={`text-sm text-fg-muted max-w-md ${compact ? "" : "mb-6"}`}>{description}</p>
|
||||
|
||||
@@ -12,6 +12,12 @@
|
||||
* written back on every toggle. Failures to access storage are swallowed so a
|
||||
* private-browsing quota error never breaks the UI.
|
||||
*
|
||||
* ## Narrow viewports
|
||||
* With no stored preference the sidebar starts collapsed below `lg` (see
|
||||
* {@link loadCollapsed}). Below `md` it leaves the flow entirely: a menu
|
||||
* button in a slim top bar opens it as an off-canvas drawer (a modal dialog —
|
||||
* backdrop, Escape, focus trapped and restored), which closes on navigation.
|
||||
*
|
||||
* ## Sticky descendants
|
||||
* The inner content wrapper uses `overflow-x-clip` (not `hidden`) so horizontal
|
||||
* overflow is clipped without creating a scroll container. That keeps `position:
|
||||
@@ -64,14 +70,29 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { useState, useCallback, useEffect, useSyncExternalStore } from "react";
|
||||
import { Outlet, useLocation } from "react-router-dom";
|
||||
import { Menu } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Sidebar, SIDEBAR_STORAGE_KEY, loadCollapsed } from "./Sidebar";
|
||||
import { UpdateNotifier } from "./UpdateNotifier";
|
||||
import { Sagi } from "./Sagi/Sagi";
|
||||
import { StatusBar } from "./StatusBar";
|
||||
|
||||
/** Below Tailwind's `md`: the sidebar becomes an off-canvas drawer. */
|
||||
export const DRAWER_QUERY = "(max-width: 767.98px)";
|
||||
|
||||
function useMedia(query: string): boolean {
|
||||
return useSyncExternalStore(
|
||||
(cb) => {
|
||||
const mq = window.matchMedia?.(query);
|
||||
mq?.addEventListener?.("change", cb);
|
||||
return () => mq?.removeEventListener?.("change", cb);
|
||||
},
|
||||
() => window.matchMedia?.(query).matches ?? false
|
||||
);
|
||||
}
|
||||
|
||||
/** Props for {@link Layout}. */
|
||||
interface LayoutProps {
|
||||
/** Live WebSocket status forwarded to the sidebar connection indicator. */
|
||||
@@ -87,7 +108,17 @@ export function Layout({ wsConnected }: LayoutProps) {
|
||||
const [collapsed, setCollapsed] = useState(loadCollapsed);
|
||||
const [connectionOpen, setConnectionOpen] = useState(false);
|
||||
const [updateAvailable, setUpdateAvailable] = useState(false);
|
||||
const sidebarWidth = collapsed ? "4.25rem" : "15rem";
|
||||
const drawer = useMedia(DRAWER_QUERY);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const closeDrawer = useCallback(() => setDrawerOpen(false), []);
|
||||
const { pathname, search } = useLocation();
|
||||
const sidebarWidth = drawer ? "0rem" : collapsed ? "4.25rem" : "15rem";
|
||||
|
||||
// A pick in the drawer navigates; the drawer has done its job.
|
||||
useEffect(closeDrawer, [pathname, search, closeDrawer]);
|
||||
useEffect(() => {
|
||||
if (!drawer) setDrawerOpen(false);
|
||||
}, [drawer]);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setCollapsed((prev) => {
|
||||
@@ -106,10 +137,19 @@ export function Layout({ wsConnected }: LayoutProps) {
|
||||
</a>
|
||||
<UpdateNotifier />
|
||||
<Sagi />
|
||||
{drawer && drawerOpen && (
|
||||
<div
|
||||
aria-hidden
|
||||
data-testid="sidebar-backdrop"
|
||||
onClick={closeDrawer}
|
||||
className="fixed inset-0 z-40 bg-black/50 animate-fade-in"
|
||||
/>
|
||||
)}
|
||||
<Sidebar
|
||||
wsConnected={wsConnected}
|
||||
collapsed={collapsed}
|
||||
onToggle={toggle}
|
||||
collapsed={drawer ? false : collapsed}
|
||||
onToggle={drawer ? closeDrawer : toggle}
|
||||
drawer={drawer ? { open: drawerOpen, onClose: closeDrawer } : undefined}
|
||||
connectionOpen={connectionOpen}
|
||||
onConnectionOpenChange={setConnectionOpen}
|
||||
onUpdateAvailableChange={setUpdateAvailable}
|
||||
@@ -123,6 +163,22 @@ export function Layout({ wsConnected }: LayoutProps) {
|
||||
width: `calc(100% - ${sidebarWidth})`,
|
||||
}}
|
||||
>
|
||||
{drawer && (
|
||||
<div className="sticky top-0 z-20 flex h-11 items-center gap-2 border-b border-border bg-surface-1 px-2">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="sidebar-open"
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
aria-label={t("openMenu")}
|
||||
aria-expanded={drawerOpen}
|
||||
title={t("openMenu")}
|
||||
className="btn-icon"
|
||||
>
|
||||
<Menu className="h-5 w-5" aria-hidden />
|
||||
</button>
|
||||
<span className="text-sm font-semibold text-fg-primary">{t("brand")}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* overflow-x-clip (not -hidden) clips horizontal overflow without
|
||||
creating a scroll container, so descendant `position: sticky`
|
||||
elements (e.g. the Settings page TOC) still pin to the window. */}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,17 @@
|
||||
* @file Select.tsx
|
||||
* @description Generic styled dropdown that replaces native `<select>` elements
|
||||
* for consistent cross-platform rendering. Native macOS/Chromium selects reserve
|
||||
* checkmark space inconsistently; this control aligns labels, supports arrow-key
|
||||
* navigation, flips above the trigger when viewport space is tight, and marks
|
||||
* the active option with accent color plus a Lucide check.
|
||||
* checkmark space inconsistently; this control aligns labels, flips above the
|
||||
* trigger when viewport space is tight, and marks the chosen option with accent
|
||||
* color plus a Lucide check.
|
||||
*
|
||||
* ## Accessibility — WAI-ARIA "listbox button"
|
||||
* The trigger is a `<button aria-haspopup="listbox" aria-expanded>`; opening it
|
||||
* moves focus to a `role="listbox"` popup that tracks the highlighted
|
||||
* `role="option"` through `aria-activedescendant`. Keys: ArrowUp/Down, Home/End,
|
||||
* Enter/Space pick, Escape cancels, Tab closes, and a typed character jumps to
|
||||
* the next option whose label starts with it. Focus returns to the trigger on
|
||||
* every close except Tab.
|
||||
*
|
||||
* ## Consumers
|
||||
* Used on the Run Claude page and webhook settings form wherever a compact
|
||||
@@ -63,7 +71,7 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
|
||||
import { ChevronDown, Check } from "lucide-react";
|
||||
|
||||
/** Single option in a {@link Select} list. */
|
||||
@@ -108,15 +116,14 @@ export function Select<T extends string>({
|
||||
}: SelectProps<T>) {
|
||||
const ghost = variant === "ghost";
|
||||
const [open, setOpen] = useState(false);
|
||||
const [active, setActive] = useState(() =>
|
||||
Math.max(
|
||||
0,
|
||||
options.findIndex((o) => o.value === value)
|
||||
)
|
||||
);
|
||||
const [active, setActive] = useState(0);
|
||||
const [openUp, setOpenUp] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
const typed = useRef({ text: "", at: 0 });
|
||||
const id = useId();
|
||||
const optionId = (idx: number) => `${id}-opt-${idx}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -128,52 +135,84 @@ export function Select<T extends string>({
|
||||
return () => document.removeEventListener("mousedown", onClick);
|
||||
}, [open]);
|
||||
|
||||
// When opening, decide whether to render the popover above the trigger if
|
||||
// the viewport doesn't have room below (common when this select sits at
|
||||
// the bottom of a form). 288 px = `max-h-72`.
|
||||
// On open: highlight the chosen option, decide whether the popover goes
|
||||
// above the trigger (288 px = `max-h-72`), and move focus into the list.
|
||||
useEffect(() => {
|
||||
if (!open || !buttonRef.current) return;
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
const below = window.innerHeight - rect.bottom;
|
||||
const above = rect.top;
|
||||
setOpenUp(below < 288 && above > below);
|
||||
if (!open) return;
|
||||
const idx = options.findIndex((o) => o.value === value);
|
||||
setActive(idx >= 0 ? idx : 0);
|
||||
if (buttonRef.current) {
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
const below = window.innerHeight - rect.bottom;
|
||||
setOpenUp(below < 288 && rect.top > below);
|
||||
}
|
||||
listRef.current?.focus({ preventScroll: true });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- only on open
|
||||
}, [open]);
|
||||
|
||||
// Sync active highlight with current value when reopening
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const idx = options.findIndex((o) => o.value === value);
|
||||
setActive(idx >= 0 ? idx : 0);
|
||||
}
|
||||
}, [open, value, options]);
|
||||
if (open) document.getElementById(optionId(active))?.scrollIntoView?.({ block: "nearest" });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- id is stable
|
||||
}, [open, active]);
|
||||
|
||||
const close = (refocus = true) => {
|
||||
setOpen(false);
|
||||
if (refocus) buttonRef.current?.focus();
|
||||
};
|
||||
|
||||
const choose = (opt: SelectOption<T>) => {
|
||||
onChange(opt.value);
|
||||
setOpen(false);
|
||||
buttonRef.current?.focus();
|
||||
close();
|
||||
};
|
||||
|
||||
const onKey = (e: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (
|
||||
!open &&
|
||||
(e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Enter" || e.key === " ")
|
||||
) {
|
||||
const onButtonKey = (e: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (["ArrowDown", "ArrowUp", "Enter", " "].includes(e.key)) {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
};
|
||||
|
||||
const onListKey = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const last = options.length - 1;
|
||||
const move: Record<string, (a: number) => number> = {
|
||||
ArrowDown: (a) => Math.min(last, a + 1),
|
||||
ArrowUp: (a) => Math.max(0, a - 1),
|
||||
Home: () => 0,
|
||||
End: () => last,
|
||||
PageDown: (a) => Math.min(last, a + 10),
|
||||
PageUp: (a) => Math.max(0, a - 10),
|
||||
};
|
||||
if (move[e.key]) {
|
||||
e.preventDefault();
|
||||
setActive((a) => Math.min(options.length - 1, a + 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActive((a) => Math.max(0, a - 1));
|
||||
} else if (e.key === "Enter") {
|
||||
setActive(move[e.key]!);
|
||||
} else if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
const opt = options[active];
|
||||
if (opt) choose(opt);
|
||||
} else if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
// Stop here: a Select inside a dialog must not close the dialog too.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
close();
|
||||
} else if (e.key === "Tab") {
|
||||
close(false);
|
||||
} else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
// Type-ahead: keys typed within 500 ms build one prefix; a repeated single
|
||||
// character cycles through the options that start with it.
|
||||
const now = Date.now();
|
||||
const t = typed.current;
|
||||
t.text = now - t.at > 500 ? e.key.toLowerCase() : t.text + e.key.toLowerCase();
|
||||
t.at = now;
|
||||
const cycling = t.text.length > 1 && [...t.text].every((c) => c === t.text[0]);
|
||||
const prefix = cycling ? t.text[0]! : t.text;
|
||||
const start = cycling || t.text.length === 1 ? active + 1 : active;
|
||||
for (let i = 0; i < options.length; i++) {
|
||||
const idx = (start + i) % options.length;
|
||||
if (options[idx]!.label.toLowerCase().startsWith(prefix)) {
|
||||
setActive(idx);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -187,24 +226,36 @@ export function Select<T extends string>({
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? `${id}-list` : undefined}
|
||||
title={label}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
onKeyDown={onKey}
|
||||
onClick={() => (open ? close() : setOpen(true))}
|
||||
onKeyDown={onButtonKey}
|
||||
className={
|
||||
ghost
|
||||
? "flex max-w-full items-center gap-1.5 rounded-md px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 hover:text-fg-primary focus:outline-none focus-visible:ring-1 focus-visible:ring-accent/50 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
: "w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
? "flex max-w-full items-center gap-1.5 rounded-md px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 hover:text-fg-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
: "w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent focus-visible:ring-2 focus-visible:ring-accent/40 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{current?.icon}
|
||||
<span className="truncate">{current?.label ?? "-"}</span>
|
||||
</span>
|
||||
<ChevronDown className="w-3.5 h-3.5 text-fg-muted flex-shrink-0" />
|
||||
<ChevronDown className="w-3.5 h-3.5 text-fg-muted flex-shrink-0" aria-hidden />
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
className={`absolute z-30 left-0 ${ghost ? "w-max min-w-[12rem] max-w-[20rem]" : "right-0"} rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1 ${
|
||||
ref={listRef}
|
||||
id={`${id}-list`}
|
||||
role="listbox"
|
||||
tabIndex={-1}
|
||||
aria-label={label}
|
||||
aria-activedescendant={options[active] ? optionId(active) : undefined}
|
||||
onKeyDown={onListKey}
|
||||
onBlur={(e) => {
|
||||
if (!containerRef.current?.contains(e.relatedTarget as Node | null)) setOpen(false);
|
||||
}}
|
||||
className={`absolute z-30 left-0 ${ghost ? "w-max min-w-[12rem] max-w-[20rem]" : "right-0"} rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1 outline-none ${
|
||||
openUp ? "bottom-full mb-1" : "top-full mt-1"
|
||||
}`}
|
||||
>
|
||||
@@ -212,13 +263,15 @@ export function Select<T extends string>({
|
||||
const isSelected = opt.value === value;
|
||||
const isActive = idx === active;
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
key={opt.value || "__default__"}
|
||||
type="button"
|
||||
id={optionId(idx)}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => choose(opt)}
|
||||
onMouseEnter={() => setActive(idx)}
|
||||
className={`w-full text-left px-3 py-1.5 transition-colors ${
|
||||
className={`w-full cursor-pointer text-left px-3 py-1.5 transition-colors ${
|
||||
isActive ? "bg-accent/15" : isSelected ? "bg-surface-3" : "hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
@@ -231,12 +284,14 @@ export function Select<T extends string>({
|
||||
>
|
||||
{opt.label}
|
||||
</span>
|
||||
{isSelected && <Check className="w-3 h-3 text-accent flex-shrink-0" />}
|
||||
{isSelected && (
|
||||
<Check className="w-3 h-3 text-accent flex-shrink-0" aria-hidden />
|
||||
)}
|
||||
</div>
|
||||
{opt.hint && (
|
||||
<div className="text-[10px] text-fg-muted truncate mt-0.5">{opt.hint}</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -65,13 +65,16 @@
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface SkeletonProps {
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
/** Rounded shape variant. Defaults to "md". */
|
||||
rounded?: "sm" | "md" | "lg" | "full";
|
||||
/** Aria label for screen readers. Defaults to "Loading". */
|
||||
/** Aria label for screen readers. Defaults to the translated "Loading…";
|
||||
* `""` makes the block decorative (`aria-hidden`), for every block but one
|
||||
* in a group, so a reader hears "Loading" once, not once per shape. */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
@@ -86,17 +89,15 @@ const ROUNDED_CLASS = {
|
||||
* Base shimmer block. Compose with width/height utilities for any shape:
|
||||
* <Skeleton className="h-6 w-24" />
|
||||
*/
|
||||
export function Skeleton({
|
||||
className = "",
|
||||
style,
|
||||
rounded = "md",
|
||||
label = "Loading",
|
||||
}: SkeletonProps) {
|
||||
export function Skeleton({ className = "", style, rounded = "md", label }: SkeletonProps) {
|
||||
const { t } = useTranslation();
|
||||
const hidden = label === "";
|
||||
return (
|
||||
<span
|
||||
role="status"
|
||||
aria-label={label}
|
||||
aria-busy="true"
|
||||
role={hidden ? undefined : "status"}
|
||||
aria-label={hidden ? undefined : (label ?? t("common:loading"))}
|
||||
aria-hidden={hidden || undefined}
|
||||
aria-busy={hidden ? undefined : "true"}
|
||||
className={`inline-block bg-surface-3/70 animate-pulse ${ROUNDED_CLASS[rounded]} ${className}`}
|
||||
style={style}
|
||||
/>
|
||||
@@ -133,7 +134,7 @@ export function TableRowSkeleton({ columns, widths }: { columns: number; widths?
|
||||
<tr className="border-b border-border/40">
|
||||
{Array.from({ length: columns }).map((_, i) => (
|
||||
<td key={i} className="px-5 py-4">
|
||||
<Skeleton className={`h-4 ${widths?.[i] ?? "w-24"}`} />
|
||||
<Skeleton className={`h-4 ${widths?.[i] ?? "w-24"}`} label={i === 0 ? undefined : ""} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
|
||||
@@ -102,8 +102,15 @@ export function StatusBar({
|
||||
<footer
|
||||
data-testid="status-bar"
|
||||
style={{ left }}
|
||||
className="fixed bottom-0 right-0 z-20 flex h-6 items-center gap-4 border-t border-border bg-surface-1 px-3 text-xs text-fg-muted transition-[left] duration-200"
|
||||
className="fixed bottom-0 right-0 z-20 flex h-6 items-center gap-4 overflow-hidden whitespace-nowrap border-t border-border bg-surface-1 px-3 text-xs text-fg-muted transition-[left] duration-200"
|
||||
>
|
||||
{/* What a screen reader is told without looking: the connection dropping
|
||||
or coming back, and agents waiting on the user. Polite, and nothing
|
||||
that ticks on its own (costs, running counts) — those stay silent. */}
|
||||
<span role="status" className="sr-only" data-testid="status-bar-live">
|
||||
{wsConnected ? t("live") : t("disconnected")}
|
||||
{needYou > 0 ? ` · ${t("statusBar.needYou", { count: needYou })}` : ""}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConnectionClick}
|
||||
@@ -112,7 +119,11 @@ export function StatusBar({
|
||||
wsConnected ? "text-status-success" : ""
|
||||
}`}
|
||||
>
|
||||
{wsConnected ? <Wifi className="h-3 w-3" /> : <WifiOff className="h-3 w-3" />}
|
||||
{wsConnected ? (
|
||||
<Wifi className="h-3 w-3" aria-hidden />
|
||||
) : (
|
||||
<WifiOff className="h-3 w-3" aria-hidden />
|
||||
)}
|
||||
{wsConnected ? t("live") : t("disconnected")}
|
||||
<span className="text-fg-muted">{`v${__APP_VERSION__}`}</span>
|
||||
{updateAvailable && (
|
||||
@@ -120,7 +131,9 @@ export function StatusBar({
|
||||
data-testid="status-bar-update"
|
||||
title={t("updateAvailable")}
|
||||
className="h-1.5 w-1.5 rounded-full bg-status-success"
|
||||
/>
|
||||
>
|
||||
<span className="sr-only">{t("updateAvailable")}</span>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<span data-testid="status-bar-counts">
|
||||
|
||||
@@ -30,8 +30,11 @@ export function Switch({
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||
checked ? "bg-accent" : "bg-surface-4"
|
||||
// On: the solid accent (white knob clears 3:1 in every theme). Off: a
|
||||
// border-light track with an inset fg-muted/80 ring (≥3:1 in every theme), so the control's edge
|
||||
// is visible on any surface (surface-4 alone was ~1:1 on a light card).
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||
checked ? "bg-accent-solid" : "bg-border-light ring-1 ring-inset ring-fg-muted/80"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* @file ContextMenu.test.tsx
|
||||
* @description Keyboard model of the shared context menu: focus lands on the
|
||||
* first item, arrows/Home/End move (wrapping, skipping disabled items), Escape
|
||||
* and Tab close and hand focus back to the trigger, and a keyboard-opened menu
|
||||
* (clientX/Y 0) anchors under its element.
|
||||
* @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 { ContextMenu, MenuItem, useContextMenu } from "../ContextMenu";
|
||||
|
||||
function Harness({ onPick = () => {} }: { onPick?: (s: string) => void }) {
|
||||
const { menu, open, close } = useContextMenu<string>();
|
||||
return (
|
||||
<>
|
||||
<button onContextMenu={(e) => open(e, "row")}>trigger</button>
|
||||
{menu && (
|
||||
<ContextMenu x={menu.x} y={menu.y} onClose={close}>
|
||||
<MenuItem onSelect={() => (onPick("a"), close())}>Alpha</MenuItem>
|
||||
<MenuItem disabled onSelect={() => {}}>
|
||||
Beta
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={() => (onPick("c"), close())}>Gamma</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const item = (name: string) => screen.getByRole("menuitem", { name });
|
||||
|
||||
describe("ContextMenu keyboard", () => {
|
||||
it("focuses the first item and moves with arrows, Home and End", () => {
|
||||
render(<Harness />);
|
||||
const trigger = screen.getByText("trigger");
|
||||
trigger.focus();
|
||||
fireEvent.contextMenu(trigger, { clientX: 40, clientY: 50 });
|
||||
expect(item("Alpha")).toHaveFocus();
|
||||
fireEvent.keyDown(item("Alpha"), { key: "ArrowDown" });
|
||||
expect(item("Gamma")).toHaveFocus(); // Beta is disabled
|
||||
fireEvent.keyDown(item("Gamma"), { key: "ArrowDown" });
|
||||
expect(item("Alpha")).toHaveFocus(); // wraps
|
||||
fireEvent.keyDown(item("Alpha"), { key: "ArrowUp" });
|
||||
expect(item("Gamma")).toHaveFocus();
|
||||
fireEvent.keyDown(item("Gamma"), { key: "Home" });
|
||||
expect(item("Alpha")).toHaveFocus();
|
||||
fireEvent.keyDown(item("Alpha"), { key: "End" });
|
||||
expect(item("Gamma")).toHaveFocus();
|
||||
});
|
||||
|
||||
it.each(["Escape", "Tab"])("%s closes and returns focus to the trigger", (key) => {
|
||||
render(<Harness />);
|
||||
const trigger = screen.getByText("trigger");
|
||||
trigger.focus();
|
||||
fireEvent.contextMenu(trigger, { clientX: 40, clientY: 50 });
|
||||
fireEvent.keyDown(item("Alpha"), { key });
|
||||
expect(screen.queryByRole("menu")).toBeNull();
|
||||
expect(trigger).toHaveFocus();
|
||||
});
|
||||
|
||||
it("Escape in the menu does not reach a document listener (a dialog behind)", () => {
|
||||
const behind = vi.fn();
|
||||
document.addEventListener("keydown", behind);
|
||||
render(<Harness />);
|
||||
fireEvent.contextMenu(screen.getByText("trigger"), { clientX: 40, clientY: 50 });
|
||||
fireEvent.keyDown(item("Alpha"), { key: "Escape" });
|
||||
document.removeEventListener("keydown", behind);
|
||||
expect(behind).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns focus to the trigger after an item runs", () => {
|
||||
const pick = vi.fn();
|
||||
render(<Harness onPick={pick} />);
|
||||
const trigger = screen.getByText("trigger");
|
||||
trigger.focus();
|
||||
fireEvent.contextMenu(trigger, { clientX: 40, clientY: 50 });
|
||||
fireEvent.click(item("Gamma"));
|
||||
expect(pick).toHaveBeenCalledWith("c");
|
||||
expect(trigger).toHaveFocus();
|
||||
});
|
||||
|
||||
it("anchors a keyboard-opened menu (clientX/Y 0) under the element", () => {
|
||||
render(<Harness />);
|
||||
const trigger = screen.getByText("trigger");
|
||||
trigger.getBoundingClientRect = () =>
|
||||
({ left: 120, bottom: 80, top: 60, right: 200, width: 80, height: 20 }) as DOMRect;
|
||||
fireEvent.contextMenu(trigger, { clientX: 0, clientY: 0 });
|
||||
const menu = screen.getByRole("menu");
|
||||
expect(menu.style.left).toBe("120px");
|
||||
expect(menu.style.top).toBe("80px");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* @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");
|
||||
});
|
||||
});
|
||||
@@ -61,17 +61,20 @@ describe("StatusBar", () => {
|
||||
const user = userEvent.setup();
|
||||
const onConnectionClick = vi.fn();
|
||||
at("/", <StatusBar wsConnected left="15rem" onConnectionClick={onConnectionClick} />);
|
||||
expect(screen.getByText("Live")).toBeInTheDocument();
|
||||
expect(screen.getByText(`v${__APP_VERSION__}`)).toBeInTheDocument();
|
||||
const conn = screen.getByRole("button", { name: /Live/ });
|
||||
expect(conn).toHaveTextContent(`v${__APP_VERSION__}`);
|
||||
expect(await screen.findByText("Today $1.23")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("status-bar-counts").textContent).toBe("2 running · 1 need you");
|
||||
await user.click(screen.getByText("Live"));
|
||||
await user.click(conn);
|
||||
expect(onConnectionClick).toHaveBeenCalled();
|
||||
// Polite live region: the connection and who is waiting, nothing that ticks.
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Live · 1 need you");
|
||||
});
|
||||
|
||||
it("says Disconnected when the socket is down", () => {
|
||||
at("/", <StatusBar wsConnected={false} left="0" onConnectionClick={() => {}} />);
|
||||
expect(screen.getByText("Disconnected")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Disconnected/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Disconnected");
|
||||
});
|
||||
|
||||
it("off the Workspace page shows no workspace or session cost", async () => {
|
||||
|
||||
Reference in New Issue
Block a user