All checks were successful
React + Vite + TypeScript SPA: scaffold, operations-terminal design system, Bearer-token auth, and the Machines view. - Design system: OKLCH-tinted dark theme (ink-slate + signal-cyan), Hanken Grotesk + JetBrains Mono, status-color language (online/offline/granted/pending/denied/not_required), motion with prefers-reduced-motion honored. - Auth: token in sessionStorage via ref (never React state), protected routes, 401 session teardown, admin-gated per-agent-key UI. - Machines view: data table (sticky header, keyboard-activated rows, skeleton loading, actionable empty/error states), non-blocking detail drawer, delete confirm, admin key management with copy-once reveal. - UI primitives: Modal (focus trap + inert + portal + dialogStack), Drawer, Table, Badge/StatusDot, toast, states. - Typed API client normalizing the two error-envelope shapes. Passed Code Review (no blockers), impeccable critique-and-polish, and local gates (tsc/lint/build green). Dev-only Vite proxy to :3002. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
151 lines
4.2 KiB
TypeScript
151 lines
4.2 KiB
TypeScript
import { useEffect, useId, useRef } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import type { ReactNode } from "react";
|
|
import {
|
|
hasOpenDialog,
|
|
isTopDialog,
|
|
popDialog,
|
|
pushDialog,
|
|
} from "./dialogStack";
|
|
|
|
interface DrawerProps {
|
|
open: boolean;
|
|
title: ReactNode;
|
|
/** Accessible name when `title` is not plain text. */
|
|
ariaLabel?: string;
|
|
/** Optional secondary line under the title (status, id). */
|
|
subtitle?: ReactNode;
|
|
onClose: () => void;
|
|
/** Sticky footer slot for actions. */
|
|
footer?: ReactNode;
|
|
children: ReactNode;
|
|
}
|
|
|
|
const FOCUSABLE =
|
|
'a[href],button:not([disabled]),textarea,input,select,[tabindex]:not([tabindex="-1"])';
|
|
|
|
/**
|
|
* Right-anchored side panel for read and inspect flows (machine detail and
|
|
* history) where a modal would over-interrupt. Shares the dialog a11y contract:
|
|
* Tab focus is trapped, the rest of the page is inert, Escape closes, and focus
|
|
* returns to the trigger on close.
|
|
*/
|
|
export function Drawer({
|
|
open,
|
|
title,
|
|
ariaLabel,
|
|
subtitle,
|
|
onClose,
|
|
footer,
|
|
children,
|
|
}: DrawerProps) {
|
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
const lastFocused = useRef<HTMLElement | null>(null);
|
|
const titleId = useId();
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const panel = panelRef.current;
|
|
lastFocused.current = document.activeElement as HTMLElement | null;
|
|
const token = pushDialog();
|
|
|
|
const root = document.getElementById("root");
|
|
root?.setAttribute("inert", "");
|
|
|
|
const first = panel?.querySelector<HTMLElement>(FOCUSABLE);
|
|
(first ?? panel)?.focus();
|
|
|
|
function onKey(e: KeyboardEvent) {
|
|
if (!isTopDialog(token)) return;
|
|
if (e.key === "Escape") {
|
|
e.stopPropagation();
|
|
onClose();
|
|
return;
|
|
}
|
|
if (e.key !== "Tab" || !panel) return;
|
|
const items = Array.from(
|
|
panel.querySelectorAll<HTMLElement>(FOCUSABLE),
|
|
).filter((el) => el.offsetParent !== null || el === document.activeElement);
|
|
if (items.length === 0) {
|
|
e.preventDefault();
|
|
panel.focus();
|
|
return;
|
|
}
|
|
const firstEl = items[0];
|
|
const lastEl = items[items.length - 1];
|
|
const active = document.activeElement as HTMLElement;
|
|
if (e.shiftKey && (active === firstEl || active === panel)) {
|
|
e.preventDefault();
|
|
lastEl.focus();
|
|
} else if (!e.shiftKey && active === lastEl) {
|
|
e.preventDefault();
|
|
firstEl.focus();
|
|
}
|
|
}
|
|
document.addEventListener("keydown", onKey, true);
|
|
|
|
const prevOverflow = document.body.style.overflow;
|
|
document.body.style.overflow = "hidden";
|
|
|
|
return () => {
|
|
document.removeEventListener("keydown", onKey, true);
|
|
document.body.style.overflow = prevOverflow;
|
|
popDialog(token);
|
|
if (!hasOpenDialog()) root?.removeAttribute("inert");
|
|
lastFocused.current?.focus?.();
|
|
};
|
|
}, [open, onClose]);
|
|
|
|
if (!open) return null;
|
|
|
|
return createPortal(
|
|
<div
|
|
className="drawer__scrim"
|
|
onMouseDown={(e) => {
|
|
if (e.target === e.currentTarget) onClose();
|
|
}}
|
|
>
|
|
<aside
|
|
ref={panelRef}
|
|
className="drawer"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={ariaLabel}
|
|
aria-labelledby={ariaLabel ? undefined : titleId}
|
|
tabIndex={-1}
|
|
>
|
|
<header className="drawer__head">
|
|
<div className="drawer__titles">
|
|
<h2 className="drawer__title" id={titleId}>
|
|
{title}
|
|
</h2>
|
|
{subtitle && <div className="drawer__subtitle">{subtitle}</div>}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="iconbtn"
|
|
onClick={onClose}
|
|
aria-label="Close panel"
|
|
>
|
|
<svg
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
aria-hidden="true"
|
|
>
|
|
<path d="M6 6l12 12M18 6 6 18" />
|
|
</svg>
|
|
</button>
|
|
</header>
|
|
<div className="drawer__body">{children}</div>
|
|
{footer && <footer className="drawer__footer">{footer}</footer>}
|
|
</aside>
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|