Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -470,14 +470,29 @@
0 2px 4px -2px rgb(0 0 0 / 0.2);
}

/* DaisyUI's .stat-title and .stat-desc ship with base-content at 60% alpha,
* which falls short of WCAG AA (3.86:1 on scripthammer-light, needs 4.5:1).
* Raising alpha to 80% gives ~7.1:1 on light and ~9.3:1 on dark — AAA on both. */
/* DaisyUI's .stat-title and .stat-desc ship base-content at 60% alpha, which
* falls short of WCAG AA. This block already corrected that to 80% — and 80%
* is STILL SHORT, which nobody could see until #454's admin fixture let the AAA
* sweep reach an admin route.
*
* The old comment claimed "~7.1:1 on light". Measured on /admin/email:
*
* .stat-title #484f58 on #ebe5dd = 6.62:1 against a 7:1 gate
*
* 7.1 was computed against **base-100**. A `.stats` block sits on **base-200**,
* and that is exactly the surface #462 records as the trap: `/80` measures 7.08
* on base-100 and 6.4–6.5 on base-200. The alpha was right for the surface it
* was checked on and wrong for the one it renders on.
*
* Solid, therefore — the same conclusion #411, #425 and #495 reached for
* `.label`, the 404 copy and the conversation timestamp. A stat's caption is
* the label for its number; there is no reading in which it should be dimmer
* than the text around it. */
[data-theme='scripthammer-dark'] .stat-title,
[data-theme='scripthammer-dark'] .stat-desc,
[data-theme='scripthammer-light'] .stat-title,
[data-theme='scripthammer-light'] .stat-desc {
color: color-mix(in oklab, var(--color-base-content) 80%, transparent);
color: var(--color-base-content);
}

/* Avatar glow/shadow — uses base-content so it auto-adapts:
Expand Down
219 changes: 118 additions & 101 deletions src/components/molecular/AdminDataTable/AdminDataTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,115 +133,132 @@ export function AdminDataTable<T extends Record<string, unknown>>({
};

return (
<div
className={`overflow-x-auto${className ? ` ${className}` : ''}`}
data-testid={testId}
>
<table className="table">
<thead>
<tr>
{renderExpandedRow && (
<th scope="col" className="w-10">
<span className="sr-only">Toggle details</span>
</th>
)}
{columns.map((col) => (
<th
key={col.key}
scope="col"
aria-sort={col.sortable ? getAriaSortValue(col.key) : undefined}
>
{col.sortable ? (
// Native <button> = tab stop + Enter/Space activation.
// aria-sort stays on the <th> where ARIA puts it; the
// arrow glyph is visual-only (aria-sort already says it).
<button
type="button"
onClick={() => handleSort(col.key)}
className="hover:bg-base-300 -m-1 flex w-full items-center gap-1 rounded p-1 text-left font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current"
>
{col.label}
<span aria-hidden="true" className="text-xs">
{sortKey === col.key
? sortDir === 'asc'
? '\u2191'
: '\u2193'
: '\u21c5'}
</span>
</button>
) : (
col.label
)}
</th>
))}
</tr>
</thead>
<tbody>
{sortedData.map((row, rowIndex) => {
const rowKey = String(row[keyField] ?? rowIndex);
const isExpanded = expandedKey === rowKey;

if (!renderExpandedRow) {
return (
<tr key={rowKey}>
{columns.map((col) => (
<td key={col.key}>
{col.render
? col.render(row)
: String(row[col.key] ?? '')}
</td>
))}
</tr>
);
}

// Row-click is the mouse affordance. The button is the
// a11y-correct trigger — aria-expanded is only valid on
// button/link roles outside treegrid (axe aria-conditional-attr).
return (
<React.Fragment key={rowKey}>
<tr
className="hover:bg-base-200 cursor-pointer"
onClick={() => toggleExpanded(rowKey)}
// WRAPPED in `sh-well`, not welled in place (#430). `sh-well` paints an
// inset shadow BELOW its children, so putting it on the `overflow-x-auto`
// div clips the shadow inside the scroll area and hides it under the table.
//
// #430 wrapped the two hand-rolled scrollers in AdminPaymentPanel and
// AdminMessagingOverview and missed THIS one — the shared component several
// admin surfaces render. It went unseen because it only appears when there
// is data: locally /admin/messaging had no conversations, so the table
// never rendered and the check passed on nothing. CI's shared project has
// data, so the first run that could reach an admin page as an admin found
// it immediately (#454's fixture).
<div className="sh-well rounded-lg p-2">
<div
className={`overflow-x-auto${className ? ` ${className}` : ''}`}
data-testid={testId}
>
<table className="table">
<thead>
<tr>
{renderExpandedRow && (
<th scope="col" className="w-10">
<span className="sr-only">Toggle details</span>
</th>
)}
{columns.map((col) => (
<th
key={col.key}
scope="col"
aria-sort={
col.sortable ? getAriaSortValue(col.key) : undefined
}
>
<td className="w-10">
{col.sortable ? (
// Native <button> = tab stop + Enter/Space activation.
// aria-sort stays on the <th> where ARIA puts it; the
// arrow glyph is visual-only (aria-sort already says it).
<button
type="button"
aria-expanded={isExpanded}
aria-label={isExpanded ? 'Hide details' : 'Show details'}
className="btn btn-ghost btn-xs btn-circle min-h-11 min-w-11"
onClick={(e) => {
// Row also listens. Without this the click bubbles
// and toggles twice — open then immediately close.
e.stopPropagation();
toggleExpanded(rowKey);
}}
onClick={() => handleSort(col.key)}
className="hover:bg-base-300 -m-1 flex w-full items-center gap-1 rounded p-1 text-left font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current"
>
<span aria-hidden="true">
{isExpanded ? '\u25be' : '\u25b8'}
{col.label}
<span aria-hidden="true" className="text-xs">
{sortKey === col.key
? sortDir === 'asc'
? '\u2191'
: '\u2193'
: '\u21c5'}
</span>
</button>
</td>
{columns.map((col) => (
<td key={col.key}>
{col.render
? col.render(row)
: String(row[col.key] ?? '')}
</td>
))}
</tr>
{isExpanded && (
<tr>
<td colSpan={columns.length + 1} className="bg-base-200">
{renderExpandedRow(row)}
) : (
col.label
)}
</th>
))}
</tr>
</thead>
<tbody>
{sortedData.map((row, rowIndex) => {
const rowKey = String(row[keyField] ?? rowIndex);
const isExpanded = expandedKey === rowKey;

if (!renderExpandedRow) {
return (
<tr key={rowKey}>
{columns.map((col) => (
<td key={col.key}>
{col.render
? col.render(row)
: String(row[col.key] ?? '')}
</td>
))}
</tr>
);
}

// Row-click is the mouse affordance. The button is the
// a11y-correct trigger — aria-expanded is only valid on
// button/link roles outside treegrid (axe aria-conditional-attr).
return (
<React.Fragment key={rowKey}>
<tr
className="hover:bg-base-200 cursor-pointer"
onClick={() => toggleExpanded(rowKey)}
>
<td className="w-10">
<button
type="button"
aria-expanded={isExpanded}
aria-label={
isExpanded ? 'Hide details' : 'Show details'
}
className="btn btn-ghost btn-xs btn-circle min-h-11 min-w-11"
onClick={(e) => {
// Row also listens. Without this the click bubbles
// and toggles twice — open then immediately close.
e.stopPropagation();
toggleExpanded(rowKey);
}}
>
<span aria-hidden="true">
{isExpanded ? '\u25be' : '\u25b8'}
</span>
</button>
</td>
{columns.map((col) => (
<td key={col.key}>
{col.render
? col.render(row)
: String(row[col.key] ?? '')}
</td>
))}
</tr>
)}
</React.Fragment>
);
})}
</tbody>
</table>
{isExpanded && (
<tr>
<td colSpan={columns.length + 1} className="bg-base-200">
{renderExpandedRow(row)}
</td>
</tr>
)}
</React.Fragment>
);
})}
</tbody>
</table>
</div>
</div>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,33 +169,42 @@ export function AdminMessagingOverview({
/>

<h3 className="mb-2 text-lg font-semibold">Top Senders</h3>
{/* See AdminPaymentPanel: the well needs a padded parent around the
scroller, not on it (#430). The comment lives ABOVE the ternary —
a `{comment}` between `? (` and its element is two root nodes and a
syntax error, which is exactly what it was a moment ago. */}
{(trends.top_senders ?? []).length > 0 ? (
<div className="overflow-x-auto">
<table className="table-sm table" data-testid="top-senders-table">
<thead>
<tr>
<th>User</th>
<th className="text-right">Messages</th>
</tr>
</thead>
<tbody>
{(trends.top_senders ?? []).map((s) => (
<tr key={s.user_id}>
<td>
<div className="font-medium">
{s.display_name ?? s.username ?? 'N/A'}
</div>
{s.username && s.display_name && (
<div className="text-base-content text-xs">
@{s.username}
</div>
)}
</td>
<td className="text-right font-mono">{s.messages}</td>
<div className="sh-well rounded-lg p-2">
<div className="overflow-x-auto">
<table
className="table-sm table"
data-testid="top-senders-table"
>
<thead>
<tr>
<th>User</th>
<th className="text-right">Messages</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{(trends.top_senders ?? []).map((s) => (
<tr key={s.user_id}>
<td>
<div className="font-medium">
{s.display_name ?? s.username ?? 'N/A'}
</div>
{s.username && s.display_name && (
<div className="text-base-content text-xs">
@{s.username}
</div>
)}
</td>
<td className="text-right font-mono">{s.messages}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : (
<p
Expand Down
Loading
Loading