onRowClick(row) : undefined}
+ onKeyDown={onRowClick ? (e) => {
+ if (e.key === 'Enter' && e.target === e.currentTarget) onRowClick(row)
+ } : undefined}
+ >
{columns.map((c) => (
{c.render ? c.render(row) : (row[c.key] ?? '')}
diff --git a/frontend/src/ui/Dropdown.jsx b/frontend/src/ui/Dropdown.jsx
index 62407d2..9820e89 100644
--- a/frontend/src/ui/Dropdown.jsx
+++ b/frontend/src/ui/Dropdown.jsx
@@ -3,7 +3,7 @@
js/app.js:200-215, including its "only one open at a time" behaviour.
============================================================ */
-import { createContext, useContext, useEffect, useId, useMemo, useRef, useState } from 'react'
+import { cloneElement, createContext, useContext, useEffect, useId, useMemo, useRef, useState } from 'react'
const GroupContext = createContext(null)
@@ -43,9 +43,13 @@ export default function Dropdown({ trigger, children, className = '', panelClass
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
+ // The trigger is a render prop returning a single button; stamp the
+ // disclosure ARIA on it here so no call site has to remember to.
+ const triggerNode = trigger({ open, toggle: () => setOpen(!open) })
+
return (
- {trigger({ open, toggle: () => setOpen(!open) })}
+ {cloneElement(triggerNode, { 'aria-expanded': open, 'aria-haspopup': 'true' })}
{children}
)
diff --git a/frontend/src/ui/PageHeader.jsx b/frontend/src/ui/PageHeader.jsx
new file mode 100644
index 0000000..4df3a94
--- /dev/null
+++ b/frontend/src/ui/PageHeader.jsx
@@ -0,0 +1,24 @@
+/* ============================================================
+ PageHeader.jsx — the one page-top pattern.
+
+ Every screen used to hand-write the same .page-head block, and the copies
+ drifted (a bare on Matching rendered in the wrong face entirely).
+ Emits the exact class structure the stylesheet already targets, so this is
+ markup dedup, not a redesign. `title`/`sub`/`actions` accept any node —
+ selects, buttons and status chips ride through unchanged.
+ ============================================================ */
+
+export default function PageHeader({ title, sub, crumb, actions }) {
+ return (
+ <>
+ {crumb && {crumb} }
+
+
+ {title}
+ {sub && {sub} }
+
+ {actions && {actions} }
+
+ >
+ )
+}
diff --git a/frontend/src/ui/Tabs.jsx b/frontend/src/ui/Tabs.jsx
index e4a2283..2eb0764 100644
--- a/frontend/src/ui/Tabs.jsx
+++ b/frontend/src/ui/Tabs.jsx
@@ -6,24 +6,48 @@
`.active`. One component replaces four ad-hoc implementations, and only the
active pane is mounted — which also means a chart in a hidden pane no longer
draws into a zero-width canvas.
+
+ ARIA: tabs are indexed (`{base}-tab-{i}` / `{base}-panel-{i}`) so ids stay
+ valid whatever the key strings contain. A controlled without a
+ TabPanel emits aria-controls ids that nothing renders — inert, not an
+ error. Arrow keys move selection (selection follows focus); the roving
+ tabindex keeps the strip a single Tab stop.
============================================================ */
import { useId, useState } from 'react'
-export function Tabs({ tabs, value, onChange, className = 'tabs' }) {
- const id = useId()
+export function Tabs({ tabs, value, onChange, className = 'tabs', idBase }) {
+ const autoId = useId()
+ const base = idBase ?? autoId
+ const activeIndex = Math.max(0, tabs.findIndex((t) => (t.key ?? t) === value))
+
+ function onKeyDown(e) {
+ let next = null
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = (activeIndex + 1) % tabs.length
+ else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') next = (activeIndex - 1 + tabs.length) % tabs.length
+ else if (e.key === 'Home') next = 0
+ else if (e.key === 'End') next = tabs.length - 1
+ if (next === null) return
+ e.preventDefault()
+ const t = tabs[next]
+ onChange(t.key ?? t)
+ document.getElementById(`${base}-tab-${next}`)?.focus()
+ }
+
return (
-
- {tabs.map((t) => {
+
+ {tabs.map((t, i) => {
const key = t.key ?? t
const label = t.label ?? t
const active = key === value
return (
|