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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user