fix(ui): stack record-header actions and scroll wide tables on mobile

RecordHeader's action-button cluster crowded/overlapped the client name on
narrow phones, and DataTable (Tickets, etc.) ran wider than the viewport,
clipping columns with no way to reach them.

Add a <=640px media query: .wf-rec wraps so .wf-rec-actions drops to its
own full-width row below the avatar/name/meta; desktop layout is untouched
outside the query. Wrap DataTable's <table> in a new .wf-table-wrap div
that scrolls horizontally on overflow at that breakpoint (nowrap cells so
the table can exceed 100% instead of being squeezed illegibly), keeping
the page body itself from scrolling sideways.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
main
Thomas Joise 2 days ago
parent 025b334b09
commit 0050f73a70

@ -134,52 +134,56 @@ export function DataTable(props: {
}
return (
<table className="wf">
<thead>
<tr>
{props.columns.map((c) => {
const sortable = canSort(c)
const arrow = sort?.key === c.key ? (sort.dir === 'asc' ? ' ▲' : ' ▼') : ''
// Horizontal-scroll boundary: on narrow screens a wide table scrolls inside this box
// instead of pushing the page body wider than the viewport (see .wf-table-wrap CSS).
<div className="wf-table-wrap">
<table className="wf">
<thead>
<tr>
{props.columns.map((c) => {
const sortable = canSort(c)
const arrow = sort?.key === c.key ? (sort.dir === 'asc' ? ' ▲' : ' ▼') : ''
return (
<th key={c.key} className={c.numeric === true ? 'num' : undefined}
aria-sort={sort?.key === c.key ? (sort.dir === 'asc' ? 'ascending' : 'descending') : undefined}
onClick={sortable ? () => toggle(c.key) : undefined}
style={sortable ? { cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap' } : undefined}
title={sortable ? 'Sort' : undefined}>
{c.label}<span style={{ opacity: 0.7 }}>{arrow}</span>
</th>
)
})}
</tr>
</thead>
<tbody>
{ordered.map(({ row, i }) => {
const tone = props.rowTone?.(i)
const clickable = props.onRowClick !== undefined
return (
<th key={c.key} className={c.numeric === true ? 'num' : undefined}
aria-sort={sort?.key === c.key ? (sort.dir === 'asc' ? 'ascending' : 'descending') : undefined}
onClick={sortable ? () => toggle(c.key) : undefined}
style={sortable ? { cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap' } : undefined}
title={sortable ? 'Sort' : undefined}>
{c.label}<span style={{ opacity: 0.7 }}>{arrow}</span>
</th>
<tr
key={i}
className={tone !== undefined ? `tone-${tone}` : undefined}
// Row click is keyboard-operable (WCAG 2.1.1): role=button + tabindex
// make it a focusable control, and Enter/Space fire the same handler.
onClick={clickable ? () => props.onRowClick?.(row, i) : undefined}
onKeyDown={clickable
? (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); props.onRowClick?.(row, i) }
}
: undefined}
role={clickable ? 'button' : undefined}
tabIndex={clickable ? 0 : undefined}
style={clickable ? { cursor: 'pointer' } : undefined}
>
{props.columns.map((c) => (
<td key={c.key} className={cellClass(c)}>{row[c.key]}</td>
))}
</tr>
)
})}
</tr>
</thead>
<tbody>
{ordered.map(({ row, i }) => {
const tone = props.rowTone?.(i)
const clickable = props.onRowClick !== undefined
return (
<tr
key={i}
className={tone !== undefined ? `tone-${tone}` : undefined}
// Row click is keyboard-operable (WCAG 2.1.1): role=button + tabindex
// make it a focusable control, and Enter/Space fire the same handler.
onClick={clickable ? () => props.onRowClick?.(row, i) : undefined}
onKeyDown={clickable
? (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); props.onRowClick?.(row, i) }
}
: undefined}
role={clickable ? 'button' : undefined}
tabIndex={clickable ? 0 : undefined}
style={clickable ? { cursor: 'pointer' } : undefined}
>
{props.columns.map((c) => (
<td key={c.key} className={cellClass(c)}>{row[c.key]}</td>
))}
</tr>
)
})}
</tbody>
</table>
</tbody>
</table>
</div>
)
}

@ -461,3 +461,18 @@ button.wf.pill { border-radius: 999px; padding: 8px 18px; }
.wf-formgrid { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 12px; }
.wf-formgrid .wide { grid-column: 1 / -1; }
@media (max-width: 560px) { .wf-formgrid { grid-template-columns: 1fr; } }
/* ================= mobile (phone) layout ================= */
@media (max-width: 640px) {
/* Record header: stack the action-button cluster below the avatar/name/meta instead of
crowding them on one row. Desktop layout (outside this query) is untouched. */
.wf-rec { flex-wrap: wrap; }
.wf-rec-actions { width: 100%; }
/* Wide tables (e.g. Tickets) scroll horizontally inside their own box rather than
clipping columns or pushing the page body wider than the viewport. `white-space:
nowrap` stops cells from being squeezed/wrapped illegibly, which is what actually
lets the table exceed 100% and become scrollable. */
.wf-table-wrap { overflow-x: auto; max-width: 100%; -webkit-overflow-scrolling: touch; }
.wf-table-wrap table.wf th, .wf-table-wrap table.wf td { white-space: nowrap; }
}

Loading…
Cancel
Save