From 688a18af22a5fee4a8b4a306dc18bfa6d13ba420 Mon Sep 17 00:00:00 2001 From: Developer Date: Fri, 26 Jun 2026 12:09:21 +0000 Subject: [PATCH] Add mobile responsive primitives (Slice 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for the mobile-responsive-parity change. Adds: - useIsMobile() hook: single source of truth for the md:768px cut (SSR-safe) - MobileCardRow: 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). --- frontend/src/App.tsx | 12 +- frontend/src/components/HoverEditButton.tsx | 32 ++++- .../__tests__/HoverEditButton.test.tsx | 19 +++ .../ui/__tests__/mobile-card.test.tsx | 101 +++++++++++++++ .../ui/__tests__/sheet-form.test.tsx | 96 +++++++++++++++ frontend/src/components/ui/mobile-card.tsx | 116 ++++++++++++++++++ frontend/src/components/ui/sheet-form.tsx | 92 ++++++++++++++ frontend/src/hooks/useIsMobile.ts | 32 +++++ frontend/src/index.css | 16 +++ 9 files changed, 501 insertions(+), 15 deletions(-) create mode 100644 frontend/src/components/ui/__tests__/mobile-card.test.tsx create mode 100644 frontend/src/components/ui/__tests__/sheet-form.test.tsx create mode 100644 frontend/src/components/ui/mobile-card.tsx create mode 100644 frontend/src/components/ui/sheet-form.tsx create mode 100644 frontend/src/hooks/useIsMobile.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0547fb7..5745104 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -28,6 +28,7 @@ import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth"; import { fetchAppVersion } from "./api/client"; import { FRONTEND_VERSION_LABEL } from "./version"; import { usePersistentState } from "./hooks/usePersistentState"; +import { useIsMobile } from "./hooks/useIsMobile"; import { Button } from "@/components/ui/button"; import { Tooltip, @@ -316,16 +317,7 @@ function ShellLayout({ onToggleDarkMode: () => void; }) { const [sidebarCollapsed, setSidebarCollapsed] = useState(false); - const [isMobile, setIsMobile] = useState( - () => window.matchMedia("(max-width: 768px)").matches, - ); - - useEffect(() => { - const mql = window.matchMedia("(max-width: 768px)"); - const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches); - mql.addEventListener("change", handler); - return () => mql.removeEventListener("change", handler); - }, []); + const isMobile = useIsMobile(); return (
diff --git a/frontend/src/components/HoverEditButton.tsx b/frontend/src/components/HoverEditButton.tsx index bf693fd..324260a 100644 --- a/frontend/src/components/HoverEditButton.tsx +++ b/frontend/src/components/HoverEditButton.tsx @@ -4,26 +4,48 @@ import { Button } from "@/components/ui/button"; interface HoverEditButtonProps { onClick: () => void; label?: string; + /** Controls visibility below the `md:` (768px) breakpoint. + * + * - `always` (default): the button is always visible on mobile/touch. + * - `hover`: keep the legacy opacity-0-everywhere behavior. + * + * At `md:` and above the hover-reveal aesthetic is always preserved + * (`md:opacity-0 md:group-hover:opacity-100`), so desktop is not regressed. + * See OpenSpec change `mobile-responsive-parity`, spec R5. */ + mobile?: "always" | "hover"; } /** - * Hover-to-reveal edit affordance. + * Hover-to-reveal edit affordance (desktop) / always-visible (mobile). * - * Keeps the `rail-edit` class plus the opacity-0 base + transition so the + * Keeps the `rail-edit` class plus the opacity base + transition so the * existing hover-reveal rules in consuming pages (Actions, Settings) still * target it (`&:hover .rail-edit { opacity: 1 }`) until those pages migrate. - * MUI IconButton + EditOutlined → shadcn `Button variant="ghost" size="icon-sm"` - * + lucide `Pencil`. Same exported props/display name. + * + * Mobile behavior (`mobile="always"`, the default): the button is visible by + * default below `md` because hover does not fire on touch. The hover-reveal + * aesthetic is layered back on at `md:` and above via `md:opacity-0 + * md:group-hover:opacity-100`. MUI IconButton + EditOutlined → shadcn `Button + * variant="ghost" size="icon-sm"` + lucide `Pencil`. Same exported props/display + * name. See OpenSpec change `mobile-responsive-parity`, spec R5. */ export function HoverEditButton({ onClick, label = "Edit", + mobile = "always", }: HoverEditButtonProps) { + // Legacy mode: opacity-0 everywhere, revealed by group hover (the consuming + // row supplies `group`). + const hoverClasses = + mobile === "hover" + ? "opacity-0 transition-opacity duration-100 ease-out group-hover:opacity-100" + : "md:opacity-0 md:transition-opacity md:duration-100 md:ease-out md:group-hover:opacity-100"; + return ( + )} + />, + ); + + expect(screen.getByText("edit-a")).toBeInTheDocument(); + expect(screen.getByText("edit-b")).toBeInTheDocument(); + }); + + it("renders a non-interactive card when onRowClick is absent", () => { + render(); + // No buttons wrapping the cards. + expect(screen.queryAllByRole("button")).toHaveLength(0); + expect(screen.getByText("Movie A")).toBeInTheDocument(); + }); + + it("renders nothing when rows is empty", () => { + const { container } = render(); + const cards = container.querySelector(".flex.flex-col.gap-2"); + expect(cards?.children).toHaveLength(0); + expect(screen.queryByText("Size")).not.toBeInTheDocument(); + }); + + it("renders a card without a title when no primary field is set", () => { + const noPrimary: MobileCardField[] = fields.filter( + (f) => f.key !== "title", + ); + render(); + // No title text rendered, but the key/value stack still is. + expect(screen.queryByText("Movie A")).not.toBeInTheDocument(); + expect(screen.getAllByText("Size")).toHaveLength(2); + }); + + it("uses getRowId for stable keys and emits no duplicate-key warning", () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + render( r.id} />); + // No React duplicate-key warning should fire. + const duplicateKeyCalls = errorSpy.mock.calls.filter((args) => + String(args[0] ?? "").includes("same key"), + ); + expect(duplicateKeyCalls).toHaveLength(0); + errorSpy.mockRestore(); + }); +}); diff --git a/frontend/src/components/ui/__tests__/sheet-form.test.tsx b/frontend/src/components/ui/__tests__/sheet-form.test.tsx new file mode 100644 index 0000000..e9d4871 --- /dev/null +++ b/frontend/src/components/ui/__tests__/sheet-form.test.tsx @@ -0,0 +1,96 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { SheetForm } from "../sheet-form"; + +describe("SheetForm", () => { + it("renders the title and children", () => { + render( + {}} + title="Edit service" + onSave={() => {}} + onCancel={() => {}} + > + + , + ); + + expect(screen.getByText("Edit service")).toBeInTheDocument(); + expect(screen.getByLabelText("Name")).toBeInTheDocument(); + }); + + it("calls onSave when Save is clicked", async () => { + const onSave = vi.fn(); + render( + {}} + title="Edit" + onSave={onSave} + onCancel={() => {}} + > +
+ , + ); + + await userEvent.click(screen.getByRole("button", { name: "Save" })); + expect(onSave).toHaveBeenCalledTimes(1); + }); + + it("calls onCancel when Cancel is clicked", async () => { + const onCancel = vi.fn(); + render( + {}} + title="Edit" + onSave={() => {}} + onCancel={onCancel} + > +
+ , + ); + + await userEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it("disables Save and shows a pending label when isPending", () => { + render( + {}} + title="Edit" + onSave={() => {}} + onCancel={() => {}} + isPending + > +
+ , + ); + + const saveButton = screen.getByRole("button", { name: /Saving/i }); + expect(saveButton).toBeDisabled(); + expect(screen.getByText("Saving…")).toBeInTheDocument(); + }); + + it("calls onCancel when the close (X) button is clicked", async () => { + const onCancel = vi.fn(); + render( + {}} + title="Edit" + onSave={() => {}} + onCancel={onCancel} + > +
+ , + ); + + await userEvent.click(screen.getByRole("button", { name: "Close" })); + expect(onCancel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/components/ui/mobile-card.tsx b/frontend/src/components/ui/mobile-card.tsx new file mode 100644 index 0000000..758ec0e --- /dev/null +++ b/frontend/src/components/ui/mobile-card.tsx @@ -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 { + 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 { + rows: T[]; + fields: MobileCardField[]; + /** 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({ + rows, + fields, + getRowId, + onRowClick, + actions, + className, +}: MobileCardRowProps) { + const primary = fields.find((f) => f.primary); + const rest = fields.filter((f) => !f.primary); + + return ( +
+ {rows.map((row, index) => { + const rowKey = getRowId?.(row) ?? String(index); + const body = ( +
+
+ {primary ? ( +
+ {primary.render(row)} +
+ ) : null} + {rest.length > 0 ? ( +
+ {rest.map((field) => ( + +
+ {field.label} +
+
+ {field.render(row)} +
+
+ ))} +
+ ) : null} +
+ {actions ? ( +
+ {actions(row)} +
+ ) : null} +
+ ); + + if (onRowClick) { + return ( + + ); + } + + return ( +
+ {body} +
+ ); + })} +
+ ); +} diff --git a/frontend/src/components/ui/sheet-form.tsx b/frontend/src/components/ui/sheet-form.tsx new file mode 100644 index 0000000..471b802 --- /dev/null +++ b/frontend/src/components/ui/sheet-form.tsx @@ -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 ( + + + {/* Header — fixed at top */} +
+ + {title} + + +
+ + {/* Body — scrolls */} +
+ {children} +
+ + {/* Footer — fixed at bottom */} +
+ + +
+
+
+ ); +} diff --git a/frontend/src/hooks/useIsMobile.ts b/frontend/src/hooks/useIsMobile.ts new file mode 100644 index 0000000..854a5a5 --- /dev/null +++ b/frontend/src/hooks/useIsMobile.ts @@ -0,0 +1,32 @@ +import { useEffect, useState } from "react"; + +/** Mobile breakpoint (must match Tailwind `md:` and the OpenSpec spec R1.2). */ +const MOBILE_QUERY = "(max-width: 768px)"; + +/** + * Single source of truth for the mobile/desktop responsive cut. + * + * Returns `true` when the viewport matches `max-width: 768px` (phone portrait), + * `false` at `md:` and above. SSR-safe: returns `false` when `window` is + * undefined so server-rendered markup stays on the desktop path. + * + * Replaces the ad-hoc `window.matchMedia("(max-width: 768px)")` reads scattered + * across pages (App.tsx, Media.tsx) — see OpenSpec change + * `mobile-responsive-parity`, design §`useIsMobile`. + */ +export function useIsMobile(): boolean { + const [isMobile, setIsMobile] = useState( + () => + typeof window !== "undefined" && window.matchMedia(MOBILE_QUERY).matches, + ); + + useEffect(() => { + if (typeof window === "undefined") return; + const mql = window.matchMedia(MOBILE_QUERY); + const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches); + mql.addEventListener("change", handler); + return () => mql.removeEventListener("change", handler); + }, []); + + return isMobile; +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 92fe49e..80b727f 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -100,3 +100,19 @@ body, * { box-sizing: border-box; } + +/* + * Mobile touch-target utility (spec R6.1). + * + * Applies a 44x44px minimum hit area to interactive elements ONLY below the + * `md:` (768px) breakpoint, satisfying WCAG 2.5.5 / Apple HIG on touch devices. + * At md+ the class is inert so desktop sizing is not regressed. Pages sprinkle + * this on icon buttons, checkboxes, switches, and row taps. See OpenSpec + * change `mobile-responsive-parity`, design §`mobile-touch-target`. + */ +@media (max-width: 767px) { + .mobile-touch-target { + min-height: 44px; + min-width: 44px; + } +}