2eb649eceb
Below md, the Users directory and the three Backups tables render as MobileCardRow cards: - UsersPage: display name primary; username/activity/email fields. Each card carries a selection checkbox (44px via mobile-touch-target) in the actions slot with stopPropagation so toggling selection does not open the drawer; card-body tap still opens the drawer. - BackupAlertsTable: alert message primary; severity/type/created fields; Acknowledge action preserved in actions slot. - BackupJobsTable: job name primary; source/schedule/last-status fields (joins latestRuns into a JobCardRow). - BackupRunsTable: run job_id primary; status/duration/size/started fields; status-filter Select renders above both layouts (preserved on mobile). Desktop (md+) is byte-for-byte identical for all four components -- the UsersPage diff is dominated by re-indenting the existing Table into the isMobile ternary else branch. Fix from Slice 5 review: MobileCardRow now renders the clickable card as <div role=button tabIndex=0> with Enter/Space keyboard handling instead of <button>, so nesting a Radix Checkbox (which renders a <button>) in the actions slot produces valid HTML. The desktop-parity argument for <button>-in-<button> did not hold (desktop rows are <tr>, not buttons). Cross-cutting: useIsMobile hardened with typeof window.matchMedia guard (safe in real browsers; only changes jsdom crash -> false). The file-local 900px compose hook was renamed useComposeViewport to avoid collision with the shared 768px useIsMobile. Tests: BackupJobsTable test file added (was untested), UsersPage mobile selection round-trip + stopPropagation, mobile card render across all four components. 105 tests pass; lint/build green. Refs openspec/changes/mobile-responsive-parity/ (spec R3, tasks slice 5).
124 lines
3.6 KiB
TypeScript
124 lines
3.6 KiB
TypeScript
import * as React from "react";
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
/**
|
|
* Field descriptor for a {@link MobileCardRow}.
|
|
*
|
|
* The consuming page decides which fields to show and in what order; this
|
|
* primitive does not pick them. Exactly one field should set `primary: true` —
|
|
* it renders as the card title (bold, larger). The rest render as a key/value
|
|
* stack below the title.
|
|
*/
|
|
export interface MobileCardField<T> {
|
|
key: string;
|
|
label: string;
|
|
render: (row: T) => React.ReactNode;
|
|
/** When true, render as the card title (bold, larger). One per card. */
|
|
primary?: boolean;
|
|
}
|
|
|
|
export interface MobileCardRowProps<T> {
|
|
rows: T[];
|
|
fields: MobileCardField<T>[];
|
|
/** Stable per-row identity; falls back to the row index when omitted. */
|
|
getRowId?: (row: T) => string;
|
|
/** When set, the whole card becomes a button (44px min height). */
|
|
onRowClick?: (row: T) => void;
|
|
/** Optional right-aligned action slot (edit/delete icon buttons). */
|
|
actions?: (row: T) => React.ReactNode;
|
|
/** Optional className for the outer list container. */
|
|
className?: string;
|
|
}
|
|
|
|
/**
|
|
* Stacked card list for wide tables below the `md:` breakpoint.
|
|
*
|
|
* Each row renders as a card: the `primary` field as the title and the
|
|
* remaining fields as a key/value stack. When `onRowClick` is provided the
|
|
* whole card is a button with a 44px minimum touch target (spec R6.1). An
|
|
* optional `actions` slot renders right-aligned controls.
|
|
*
|
|
* This is the mobile counterpart to {@link DataTable}; pages branch on
|
|
* `useIsMobile()`. See OpenSpec change `mobile-responsive-parity`, design
|
|
* §`MobileCardRow`.
|
|
*/
|
|
export function MobileCardRow<T>({
|
|
rows,
|
|
fields,
|
|
getRowId,
|
|
onRowClick,
|
|
actions,
|
|
className,
|
|
}: MobileCardRowProps<T>) {
|
|
const primary = fields.find((f) => f.primary);
|
|
const rest = fields.filter((f) => !f.primary);
|
|
|
|
return (
|
|
<div className={cn("flex flex-col gap-2", className)}>
|
|
{rows.map((row, index) => {
|
|
const rowKey = getRowId?.(row) ?? String(index);
|
|
const body = (
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="flex min-w-0 flex-col gap-1">
|
|
{primary ? (
|
|
<div className="truncate text-sm font-medium text-foreground">
|
|
{primary.render(row)}
|
|
</div>
|
|
) : null}
|
|
{rest.length > 0 ? (
|
|
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-xs text-muted-foreground">
|
|
{rest.map((field) => (
|
|
<React.Fragment key={field.key}>
|
|
<dt className="font-medium text-muted-foreground">
|
|
{field.label}
|
|
</dt>
|
|
<dd className="truncate text-foreground">
|
|
{field.render(row)}
|
|
</dd>
|
|
</React.Fragment>
|
|
))}
|
|
</dl>
|
|
) : null}
|
|
</div>
|
|
{actions ? (
|
|
<div className="flex shrink-0 items-center gap-1">
|
|
{actions(row)}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
|
|
if (onRowClick) {
|
|
return (
|
|
<div
|
|
key={rowKey}
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={() => onRowClick(row)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault();
|
|
onRowClick(row);
|
|
}
|
|
}}
|
|
className="mobile-touch-target min-h-11 w-full cursor-pointer rounded-lg border border-border bg-card p-3 text-left transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
>
|
|
{body}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
key={rowKey}
|
|
className="min-h-11 rounded-lg border border-border bg-card p-3"
|
|
>
|
|
{body}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|