ui-polish 2/6: PageHeader, DataTable dedup, Tabs/Toast/Dropdown a11y
- New ui/PageHeader.jsx emitting the existing .page-head structure; screens adopt it in the next two passes. - DataTable: sortable thead extracted as DataTableHead (Candidates can drop its verbatim copy) and an onRowClick prop with keyboard support. - Tabs: index-based ids, aria-controls, roving tabindex, arrow-key and Home/End navigation; TabPanel labels its panel. - Toast root announces politely to screen readers (was silent). - Dropdown stamps aria-expanded/aria-haspopup on every trigger. - primitives: shared PRIORITY_CLASS (Dashboard and Tasks had identical private copies). - Shell: skip link to #main-content, sidebar nav labeled Primary, clickable notification rows are real buttons now. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>pull/29/head
parent
bc5856005d
commit
bd16f8a775
|
|
@ -38,6 +38,7 @@ export default function AppLayout() {
|
|||
|
||||
return (
|
||||
<div id="app">
|
||||
<a className="skip-link" href="#main-content">Skip to content</a>
|
||||
<Sidebar
|
||||
collapsed={collapsed}
|
||||
mobileOpen={navOpen}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export default function Sidebar({ collapsed, mobileOpen, onToggleCollapse, badge
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="sidebar-nav">
|
||||
<nav className="sidebar-nav" aria-label="Primary">
|
||||
{NAV_GROUPS.map((group) => {
|
||||
const items = visible.filter((r) => r.group === group)
|
||||
if (!items.length) return null
|
||||
|
|
|
|||
|
|
@ -137,11 +137,11 @@ export default function Topbar({ onOpenNav, searchRef }) {
|
|||
<div className="notif-row"><div className="notif-text">No notifications yet.</div></div>
|
||||
)}
|
||||
{notifications.map((n) => (
|
||||
<div
|
||||
<button
|
||||
key={n.id}
|
||||
type="button"
|
||||
className={`notif-row${n.unread ? ' unread' : ''}`}
|
||||
onClick={() => openNotif(n)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<span className={`notif-icn ${n.color}`}><Icon name={n.icon} /></span>
|
||||
<div className="notif-body">
|
||||
|
|
@ -149,7 +149,7 @@ export default function Topbar({ onOpenNav, searchRef }) {
|
|||
{n.text && <div className="notif-text">{n.text}</div>}
|
||||
<div className="notif-time">{n.time}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="dropdown-foot">
|
||||
|
|
|
|||
|
|
@ -511,7 +511,9 @@ table.data thead th, .rbac-matrix th, .cal-dow, .info-item .il,
|
|||
.dropdown-link.danger { color: var(--danger); }
|
||||
.dropdown-link.danger svg { color: var(--danger); }
|
||||
.link-btn { color: var(--primary); font-size: 12px; font-weight: 600; }
|
||||
.notif-row { display: flex; gap: 12px; padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; transition: .12s; }
|
||||
/* Rendered as a <button> when clickable (keyboard-reachable); the global
|
||||
button reset keeps the visuals, these two keep the layout. */
|
||||
.notif-row { display: flex; gap: 12px; padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; transition: .12s; width: 100%; text-align: left; }
|
||||
.notif-row:hover { background: var(--bg-sunken); }
|
||||
.notif-row.unread { background: var(--primary-soft); }
|
||||
[data-theme="dark"] .notif-row.unread { background: var(--primary-soft); }
|
||||
|
|
|
|||
|
|
@ -103,39 +103,47 @@ export function Pagination({ from, to, total, page, pages, setPage, pageButtons
|
|||
)
|
||||
}
|
||||
|
||||
export default function DataTable({ columns, rows, pageSize = 10, empty }) {
|
||||
/** The sortable <thead>, exported so useDataTable consumers with custom
|
||||
tbody markup (Candidates' checkbox column) stop copying it verbatim. */
|
||||
export function DataTableHead({ columns, sort, toggleSort }) {
|
||||
return (
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((c) => {
|
||||
const isSorted = sort.key === c.key
|
||||
const cls = [
|
||||
c.sortable ? 'sortable' : '',
|
||||
isSorted ? (sort.dir === 1 ? 'sorted-asc' : 'sorted-desc') : '',
|
||||
].filter(Boolean).join(' ')
|
||||
return (
|
||||
<th
|
||||
key={c.key}
|
||||
className={cls}
|
||||
style={{ textAlign: c.align || 'left' }}
|
||||
onClick={c.sortable ? () => toggleSort(c.key) : undefined}
|
||||
>
|
||||
{c.label}
|
||||
{c.sortable && (
|
||||
<span className="sort-ind">
|
||||
{isSorted ? (sort.dir === 1 ? '▲' : '▼') : '⇅'}
|
||||
</span>
|
||||
)}
|
||||
</th>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DataTable({ columns, rows, pageSize = 10, empty, onRowClick }) {
|
||||
const t = useDataTable({ columns, rows, pageSize })
|
||||
|
||||
return (
|
||||
<div className="dt">
|
||||
<div className="table-wrap">
|
||||
<table className="data">
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((c) => {
|
||||
const isSorted = t.sort.key === c.key
|
||||
const cls = [
|
||||
c.sortable ? 'sortable' : '',
|
||||
isSorted ? (t.sort.dir === 1 ? 'sorted-asc' : 'sorted-desc') : '',
|
||||
].filter(Boolean).join(' ')
|
||||
return (
|
||||
<th
|
||||
key={c.key}
|
||||
className={cls}
|
||||
style={{ textAlign: c.align || 'left' }}
|
||||
onClick={c.sortable ? () => t.toggleSort(c.key) : undefined}
|
||||
>
|
||||
{c.label}
|
||||
{c.sortable && (
|
||||
<span className="sort-ind">
|
||||
{isSorted ? (t.sort.dir === 1 ? '▲' : '▼') : '⇅'}
|
||||
</span>
|
||||
)}
|
||||
</th>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<DataTableHead columns={columns} sort={t.sort} toggleSort={t.toggleSort} />
|
||||
<tbody>
|
||||
{t.pageRows.length === 0 ? (
|
||||
<tr>
|
||||
|
|
@ -145,7 +153,15 @@ export default function DataTable({ columns, rows, pageSize = 10, empty }) {
|
|||
</tr>
|
||||
) : (
|
||||
t.pageRows.map((row, i) => (
|
||||
<tr key={row.id ?? i}>
|
||||
<tr
|
||||
key={row.id ?? i}
|
||||
className={onRowClick ? 'row-click' : undefined}
|
||||
tabIndex={onRowClick ? 0 : undefined}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
onKeyDown={onRowClick ? (e) => {
|
||||
if (e.key === 'Enter' && e.target === e.currentTarget) onRowClick(row)
|
||||
} : undefined}
|
||||
>
|
||||
{columns.map((c) => (
|
||||
<td key={c.key} style={{ textAlign: c.align || 'left' }}>
|
||||
{c.render ? c.render(row) : (row[c.key] ?? '')}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className={`dropdown ${open ? 'open' : ''} ${className}`} ref={ref}>
|
||||
{trigger({ open, toggle: () => setOpen(!open) })}
|
||||
{cloneElement(triggerNode, { 'aria-expanded': open, 'aria-haspopup': 'true' })}
|
||||
<div className={`dropdown-menu ${panelClassName}`}>{children}</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 <h1> 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 && <div className="breadcrumb">{crumb}</div>}
|
||||
<div className="page-head">
|
||||
<div className="page-head-main">
|
||||
<h1 className="page-title">{title}</h1>
|
||||
{sub && <p className="page-sub">{sub}</p>}
|
||||
</div>
|
||||
{actions && <div className="page-head-actions">{actions}</div>}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 <Tabs> 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 (
|
||||
<div className={className} role="tablist">
|
||||
{tabs.map((t) => {
|
||||
<div className={className} role="tablist" onKeyDown={onKeyDown}>
|
||||
{tabs.map((t, i) => {
|
||||
const key = t.key ?? t
|
||||
const label = t.label ?? t
|
||||
const active = key === value
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
id={`${id}-${key}`}
|
||||
id={`${base}-tab-${i}`}
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
aria-controls={`${base}-panel-${i}`}
|
||||
tabIndex={active ? 0 : -1}
|
||||
className={`tab${active ? ' active' : ''}`}
|
||||
onClick={() => onChange(key)}
|
||||
>
|
||||
|
|
@ -37,12 +61,16 @@ export function Tabs({ tabs, value, onChange, className = 'tabs' }) {
|
|||
|
||||
/** Uncontrolled convenience wrapper: <TabPanel tabs={[{key,label,render}]} /> */
|
||||
export default function TabPanel({ tabs, initial, className }) {
|
||||
const base = useId()
|
||||
const [value, setValue] = useState(initial ?? tabs[0]?.key)
|
||||
const active = tabs.find((t) => t.key === value) ?? tabs[0]
|
||||
const activeIndex = Math.max(0, tabs.findIndex((t) => t.key === value))
|
||||
const active = tabs[activeIndex] ?? tabs[0]
|
||||
return (
|
||||
<>
|
||||
<Tabs tabs={tabs} value={value} onChange={setValue} className={className} />
|
||||
<div role="tabpanel">{active?.render?.()}</div>
|
||||
<Tabs tabs={tabs} value={value} onChange={setValue} className={className} idBase={base} />
|
||||
<div role="tabpanel" id={`${base}-panel-${activeIndex}`} aria-labelledby={`${base}-tab-${activeIndex}`}>
|
||||
{active?.render?.()}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ export default function ToastProvider({ children }) {
|
|||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
{createPortal(
|
||||
<div className="toast-root">
|
||||
<div className="toast-root" role="status" aria-live="polite">
|
||||
{items.map((t) => {
|
||||
const cfg = CONFIG[t.type] || CONFIG.info
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -43,6 +43,10 @@ export const STATUS_CLASS = {
|
|||
'Strong Hire': 'b-green', Hire: 'b-teal', 'Lean Hire': 'b-amber', 'No Hire': 'b-red',
|
||||
}
|
||||
|
||||
// Shared task/candidate priority map — Dashboard and Tasks each used to
|
||||
// define an identical private copy.
|
||||
export const PRIORITY_CLASS = { High: 'b-red', Medium: 'b-amber', Low: 'b-gray' }
|
||||
|
||||
export function Badge({ children, className }) {
|
||||
const cls = className || STATUS_CLASS[children] || 'b-gray'
|
||||
return <span className={`badge ${cls}`}>{children}</span>
|
||||
|
|
|
|||
Loading…
Reference in New Issue