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
Talha Ahmed 2026-08-25 21:39:31 +05:00
parent bc5856005d
commit bd16f8a775
10 changed files with 123 additions and 44 deletions

View File

@ -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}

View File

@ -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

View File

@ -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">

View File

@ -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); }

View File

@ -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] ?? '')}

View File

@ -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>
)

View File

@ -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>
</>
)
}

View File

@ -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>
</>
)
}

View File

@ -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 (

View File

@ -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>