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
+92
View File
@@ -0,0 +1,92 @@
import * as React from "react";
import { Loader2, XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
export interface SheetFormProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
onSave: () => void;
onCancel: () => void;
/** Disable Save and show a pending spinner. */
isPending?: boolean;
/** Override the Save button label (default "Save"). */
saveLabel?: string;
children: React.ReactNode;
/** Optional className applied to the scrolling body. */
bodyClassName?: string;
}
/**
* Full-height form host for the mobile (`< md`) breakpoint.
*
* Wraps the shadcn `Sheet` primitive with a fixed header (title + close) and a
* fixed footer (Cancel + Save). The body scrolls between them. Laid out as a
* flex column (NOT `position: sticky`) because Radix `Sheet` uses transforms,
* which break sticky positioning — see OpenSpec change
* `mobile-responsive-parity`, design §`SheetForm` / risks.
*
* Uses `h-[100dvh]` (not `h-screen`) to avoid the iOS Safari URL-bar resize
* jump. Consumers choose this host vs the desktop `Dialog` via `useIsMobile()`.
*/
export function SheetForm({
open,
onOpenChange,
title,
onSave,
onCancel,
isPending = false,
saveLabel = "Save",
children,
bodyClassName,
}: SheetFormProps) {
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="bottom"
showCloseButton={false}
className="flex h-[100dvh] w-full flex-col gap-0 p-0 sm:max-w-full"
>
{/* Header — fixed at top */}
<div className="flex h-14 shrink-0 items-center justify-between border-b border-border px-4">
<SheetTitle className="font-heading text-base font-medium">
{title}
</SheetTitle>
<Button
variant="ghost"
size="icon-sm"
aria-label="Close"
onClick={onCancel}
>
<XIcon />
</Button>
</div>
{/* Body — scrolls */}
<div className={cn("flex-1 overflow-y-auto p-4", bodyClassName)}>
{children}
</div>
{/* Footer — fixed at bottom */}
<div className="flex shrink-0 items-center justify-end gap-2 border-t border-border bg-muted/50 p-4">
<Button variant="outline" onClick={onCancel} disabled={isPending}>
Cancel
</Button>
<Button onClick={onSave} disabled={isPending}>
{isPending ? (
<>
<Loader2 className="animate-spin" />
Saving
</>
) : (
saveLabel
)}
</Button>
</div>
</SheetContent>
</Sheet>
);
}