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
+1
View File
@@ -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()}
+9 -6
View File
@@ -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
+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,
+1 -1
View File
@@ -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>
+61 -5
View File
@@ -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. */}
+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>
</>
);
}
+105 -50
View File
@@ -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>
+50 -27
View File
@@ -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>
);
+12 -11
View File
@@ -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>
+16 -3
View File
@@ -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">
+5 -2
View File
@@ -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 () => {