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:
+2
-10
@@ -28,6 +28,7 @@ import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth";
|
|||||||
import { fetchAppVersion } from "./api/client";
|
import { fetchAppVersion } from "./api/client";
|
||||||
import { FRONTEND_VERSION_LABEL } from "./version";
|
import { FRONTEND_VERSION_LABEL } from "./version";
|
||||||
import { usePersistentState } from "./hooks/usePersistentState";
|
import { usePersistentState } from "./hooks/usePersistentState";
|
||||||
|
import { useIsMobile } from "./hooks/useIsMobile";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -316,16 +317,7 @@ function ShellLayout({
|
|||||||
onToggleDarkMode: () => void;
|
onToggleDarkMode: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
const [isMobile, setIsMobile] = useState(
|
const isMobile = useIsMobile();
|
||||||
() => 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);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
|
|||||||
@@ -4,26 +4,48 @@ import { Button } from "@/components/ui/button";
|
|||||||
interface HoverEditButtonProps {
|
interface HoverEditButtonProps {
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
label?: string;
|
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
|
* existing hover-reveal rules in consuming pages (Actions, Settings) still
|
||||||
* target it (`&:hover .rail-edit { opacity: 1 }`) until those pages migrate.
|
* 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({
|
export function HoverEditButton({
|
||||||
onClick,
|
onClick,
|
||||||
label = "Edit",
|
label = "Edit",
|
||||||
|
mobile = "always",
|
||||||
}: HoverEditButtonProps) {
|
}: 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 (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
className="rail-edit text-muted-foreground opacity-0 transition-opacity duration-100 ease-out"
|
className={`rail-edit text-muted-foreground mobile-touch-target ${hoverClasses}`}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|||||||
@@ -18,4 +18,23 @@ describe("HoverEditButton", () => {
|
|||||||
screen.getByRole("button", { name: "Rename machine" }),
|
screen.getByRole("button", { name: "Rename machine" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('defaults to always-visible below md (mobile="always")', () => {
|
||||||
|
render(<HoverEditButton onClick={() => {}} />);
|
||||||
|
const button = screen.getByRole("button", { name: "Edit" });
|
||||||
|
const tokens = button.className.split(/\s+/);
|
||||||
|
// The default mobile mode layers hover-reveal only at md+ via
|
||||||
|
// md:opacity-0/md:group-hover:opacity-100, so the button is visible by
|
||||||
|
// default below md (no base opacity-0 token).
|
||||||
|
expect(tokens).toContain("md:opacity-0");
|
||||||
|
expect(tokens).toContain("md:group-hover:opacity-100");
|
||||||
|
expect(tokens).not.toContain("opacity-0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves the legacy opacity-0 behavior when mobile="hover"', () => {
|
||||||
|
render(<HoverEditButton onClick={() => {}} mobile="hover" />);
|
||||||
|
const button = screen.getByRole("button", { name: "Edit" });
|
||||||
|
expect(button.className).toContain("opacity-0");
|
||||||
|
expect(button.className).toContain("group-hover:opacity-100");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { MobileCardRow, type MobileCardField } from "../mobile-card";
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
size: string;
|
||||||
|
year: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: Row[] = [
|
||||||
|
{ id: "a", title: "Movie A", size: "4.2GB", year: 2026 },
|
||||||
|
{ id: "b", title: "Movie B", size: "2.1GB", year: 2025 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const fields: MobileCardField<Row>[] = [
|
||||||
|
{ key: "title", label: "Title", render: (r) => r.title, primary: true },
|
||||||
|
{ key: "size", label: "Size", render: (r) => r.size },
|
||||||
|
{ key: "year", label: "Year", render: (r) => r.year },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("MobileCardRow", () => {
|
||||||
|
it("renders the primary field as a title and the rest as key/value pairs", () => {
|
||||||
|
render(<MobileCardRow rows={rows} fields={fields} />);
|
||||||
|
|
||||||
|
// Primary title
|
||||||
|
expect(screen.getByText("Movie A")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Movie B")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Field labels and values (appear once per row)
|
||||||
|
expect(screen.getAllByText("Size")).toHaveLength(2);
|
||||||
|
expect(screen.getAllByText("4.2GB")).toHaveLength(1);
|
||||||
|
expect(screen.getAllByText("Year")).toHaveLength(2);
|
||||||
|
expect(screen.getAllByText("2026")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fires onRowClick when the card is tapped", async () => {
|
||||||
|
const onRowClick = vi.fn();
|
||||||
|
render(
|
||||||
|
<MobileCardRow rows={rows} fields={fields} onRowClick={onRowClick} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByText("Movie A"));
|
||||||
|
expect(onRowClick).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onRowClick).toHaveBeenCalledWith(rows[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the actions slot per row", () => {
|
||||||
|
render(
|
||||||
|
<MobileCardRow
|
||||||
|
rows={rows}
|
||||||
|
fields={fields}
|
||||||
|
actions={(r) => (
|
||||||
|
<button type="button" onClick={() => undefined}>
|
||||||
|
edit-{r.id}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("edit-a")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("edit-b")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a non-interactive card when onRowClick is absent", () => {
|
||||||
|
render(<MobileCardRow rows={rows} fields={fields} />);
|
||||||
|
// 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(<MobileCardRow rows={[]} fields={fields} />);
|
||||||
|
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<Row>[] = fields.filter(
|
||||||
|
(f) => f.key !== "title",
|
||||||
|
);
|
||||||
|
render(<MobileCardRow rows={rows} fields={noPrimary} />);
|
||||||
|
// 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(<MobileCardRow rows={rows} fields={fields} getRowId={(r) => 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(
|
||||||
|
<SheetForm
|
||||||
|
open
|
||||||
|
onOpenChange={() => {}}
|
||||||
|
title="Edit service"
|
||||||
|
onSave={() => {}}
|
||||||
|
onCancel={() => {}}
|
||||||
|
>
|
||||||
|
<input aria-label="Name" />
|
||||||
|
</SheetForm>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Edit service")).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("Name")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onSave when Save is clicked", async () => {
|
||||||
|
const onSave = vi.fn();
|
||||||
|
render(
|
||||||
|
<SheetForm
|
||||||
|
open
|
||||||
|
onOpenChange={() => {}}
|
||||||
|
title="Edit"
|
||||||
|
onSave={onSave}
|
||||||
|
onCancel={() => {}}
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
</SheetForm>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<SheetForm
|
||||||
|
open
|
||||||
|
onOpenChange={() => {}}
|
||||||
|
title="Edit"
|
||||||
|
onSave={() => {}}
|
||||||
|
onCancel={onCancel}
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
</SheetForm>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables Save and shows a pending label when isPending", () => {
|
||||||
|
render(
|
||||||
|
<SheetForm
|
||||||
|
open
|
||||||
|
onOpenChange={() => {}}
|
||||||
|
title="Edit"
|
||||||
|
onSave={() => {}}
|
||||||
|
onCancel={() => {}}
|
||||||
|
isPending
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
</SheetForm>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<SheetForm
|
||||||
|
open
|
||||||
|
onOpenChange={() => {}}
|
||||||
|
title="Edit"
|
||||||
|
onSave={() => {}}
|
||||||
|
onCancel={onCancel}
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
</SheetForm>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||||
|
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -100,3 +100,19 @@ body,
|
|||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user