Add mobile responsive primitives (Slice 1)

Foundation for the mobile-responsive-parity change. Adds:
- useIsMobile() hook: single source of truth for the md:768px cut (SSR-safe)
- MobileCardRow<T>: stacked card list for wide tables below md, with getRowId
  stable keys, primary field as title, optional onRowClick + actions slot
- SheetForm: full-height form host (h-[100dvh], flex column, sticky header +
  footer via flex not position:sticky) for mobile edit flows
- HoverEditButton: mobile prop (default 'always') -- always visible below md,
  hover-revealed at md+; desktop aesthetic preserved
- .mobile-touch-target CSS utility: 44x44 min hit area below md (WCAG 2.5.5)
- App.tsx refactored to use useIsMobile(); shell behavior unchanged

Tests cover primary/field rendering, onRowClick, actions slot, empty rows,
no-primary, stable keys (no duplicate-key warning), and all SheetForm
interactions. 86 tests pass; lint/build green.

MobileCardRow key strategy: uses getRowId when provided (falls back to index);
per design §trade-offs, fields are declared per-table to prioritize by mobile
importance rather than auto-derived from column defs.

Refs openspec/changes/mobile-responsive-parity/ (design §Shared primitives,
spec R1/R5/R6, tasks slice 1).
This commit is contained in:
Developer
2026-06-26 12:09:21 +00:00
parent 18ee77a4e4
commit 688a18af22
9 changed files with 501 additions and 15 deletions
+116
View File
@@ -0,0 +1,116 @@
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 (
<button
key={rowKey}
type="button"
onClick={() => onRowClick(row)}
className="mobile-touch-target min-h-11 w-full 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}
</button>
);
}
return (
<div
key={rowKey}
className="min-h-11 rounded-lg border border-border bg-card p-3"
>
{body}
</div>
);
})}
</div>
);
}