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 { 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 (
|
||||
<div className="min-h-screen bg-background">
|
||||
|
||||
@@ -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 (
|
||||
<Button
|
||||
variant="ghost"
|
||||
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}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -18,4 +18,23 @@ describe("HoverEditButton", () => {
|
||||
screen.getByRole("button", { name: "Rename machine" }),
|
||||
).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;
|
||||
}
|
||||
|
||||
/*
|
||||
* 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