Scaffold design engineering playground
Next.js 16 + TypeScript + Tailwind v4 + Motion project showcasing interface components built on semantic design tokens: - Light/dark/system theming from one CSS variable set, with a pre-paint script so a saved theme never flashes - Button, Switch and SegmentedControl with keyboard support, ARIA semantics and spring motion that respects reduced-motion - Vitest + Testing Library tests, including a token-coverage test - /tokens page documenting colour and motion tokens Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,90 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/*
|
||||
* Design tokens. These are the only place colours are written down.
|
||||
* Components use the semantic names (bg-surface, text-muted, ...).
|
||||
* They never use raw palette values.
|
||||
* src/tokens/tokens.test.ts checks that every token in tokens.ts is defined
|
||||
* here for both themes.
|
||||
*/
|
||||
|
||||
:root,
|
||||
[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--background: #fafafa;
|
||||
--surface: #ffffff;
|
||||
--surface-hover: #f2f2f3;
|
||||
--border: #e4e4e7;
|
||||
--foreground: #18181b;
|
||||
--muted: #71717a;
|
||||
--accent: #4f46e5;
|
||||
--accent-hover: #4338ca;
|
||||
--accent-foreground: #ffffff;
|
||||
--danger: #dc2626;
|
||||
--ring: #6366f1;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
color-scheme: dark;
|
||||
--background: #09090b;
|
||||
--surface: #131316;
|
||||
--surface-hover: #1c1c21;
|
||||
--border: #2a2a30;
|
||||
--foreground: #f4f4f5;
|
||||
--muted: #a1a1aa;
|
||||
--accent: #818cf8;
|
||||
--accent-hover: #a5b4fc;
|
||||
--accent-foreground: #0b0b1a;
|
||||
--danger: #f87171;
|
||||
--ring: #a5b4fc;
|
||||
}
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--background: #09090b;
|
||||
--surface: #131316;
|
||||
--surface-hover: #1c1c21;
|
||||
--border: #2a2a30;
|
||||
--foreground: #f4f4f5;
|
||||
--muted: #a1a1aa;
|
||||
--accent: #818cf8;
|
||||
--accent-hover: #a5b4fc;
|
||||
--accent-foreground: #0b0b1a;
|
||||
--danger: #f87171;
|
||||
--ring: #a5b4fc;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-surface: var(--surface);
|
||||
--color-surface-hover: var(--surface-hover);
|
||||
--color-border: var(--border);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-hover: var(--accent-hover);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-danger: var(--danger);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
|
||||
--ease-snappy: cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Providers } from "@/components/site/providers";
|
||||
import { SiteHeader } from "@/components/site/site-header";
|
||||
import { themeScript } from "@/lib/theme";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "Design Engineering Playground",
|
||||
template: "%s · Design Engineering Playground",
|
||||
},
|
||||
description: "Interface components built with care for motion, accessibility and design tokens.",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||
return (
|
||||
// suppressHydrationWarning: the theme script may set data-theme before React hydrates.
|
||||
<html
|
||||
lang="en"
|
||||
suppressHydrationWarning
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
||||
</head>
|
||||
<body className="flex min-h-full flex-col font-sans">
|
||||
<Providers>
|
||||
<SiteHeader />
|
||||
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-12 sm:px-6 sm:py-16">
|
||||
{children}
|
||||
</main>
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { ButtonDemo, SegmentedControlDemo, SwitchDemo } from "@/components/site/demos";
|
||||
import { Specimen } from "@/components/site/specimen";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex flex-col gap-12">
|
||||
<header className="flex max-w-2xl flex-col gap-4">
|
||||
<p className="font-mono text-xs tracking-wider text-accent uppercase">Design engineering</p>
|
||||
<h1 className="text-4xl font-semibold tracking-tight text-balance sm:text-5xl">
|
||||
Small components with the details sweated.
|
||||
</h1>
|
||||
<p className="text-lg text-pretty text-muted">
|
||||
Each piece here is built on shared design tokens. It works with a keyboard and a screen
|
||||
reader, and its motion follows your system settings. Try switching the theme or tabbing
|
||||
through the page.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Specimen
|
||||
title="Button"
|
||||
description="Four variants. While loading it keeps its width, so nothing around it shifts."
|
||||
notes={["Spring press feedback", "aria-busy while loading", "Visible focus ring"]}
|
||||
>
|
||||
<ButtonDemo />
|
||||
</Specimen>
|
||||
|
||||
<Specimen
|
||||
title="Segmented control"
|
||||
description="A radio group whose selection indicator glides between options."
|
||||
notes={["← → Home End", "Roving tabindex", "Shared-layout animation"]}
|
||||
>
|
||||
<SegmentedControlDemo />
|
||||
</Specimen>
|
||||
|
||||
<Specimen
|
||||
title="Switch"
|
||||
description="Works controlled or uncontrolled. Clicking the label toggles it too."
|
||||
notes={['role="switch"', "Description wired to aria-describedby", "Spring thumb"]}
|
||||
>
|
||||
<SwitchDemo />
|
||||
</Specimen>
|
||||
|
||||
<Specimen
|
||||
title="Theme"
|
||||
description="Auto, light and dark, chosen in the header. The choice persists without a flash on reload."
|
||||
notes={["Inline pre-paint script", "useSyncExternalStore", "Semantic colour tokens"]}
|
||||
>
|
||||
<div className="grid w-full max-w-xs grid-cols-3 gap-2" aria-hidden>
|
||||
{[
|
||||
"bg-background",
|
||||
"bg-surface",
|
||||
"bg-surface-hover",
|
||||
"bg-accent",
|
||||
"bg-muted",
|
||||
"bg-danger",
|
||||
].map((c) => (
|
||||
<div key={c} className={`h-12 rounded-lg border border-border ${c}`} />
|
||||
))}
|
||||
</div>
|
||||
</Specimen>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { Metadata } from "next";
|
||||
import { colorTokens, durations, springs } from "@/tokens/tokens";
|
||||
|
||||
export const metadata: Metadata = { title: "Tokens" };
|
||||
|
||||
export default function TokensPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-14">
|
||||
<header className="flex max-w-2xl flex-col gap-3">
|
||||
<h1 className="text-3xl font-semibold tracking-tight">Tokens</h1>
|
||||
<p className="text-muted">
|
||||
Components use these semantic names and never raw values. That is why the whole site
|
||||
changes theme by swapping one set of CSS variables. Toggle the theme to see each swatch
|
||||
change.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="flex flex-col gap-5">
|
||||
<h2 className="text-lg font-semibold">Colour</h2>
|
||||
<ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{colorTokens.map((token) => (
|
||||
<li
|
||||
key={token.name}
|
||||
className="flex items-center gap-4 rounded-xl border border-border bg-surface p-3"
|
||||
>
|
||||
<span
|
||||
className="size-12 shrink-0 rounded-lg border border-border"
|
||||
style={{ background: `var(--${token.name})` }}
|
||||
/>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<code className="font-mono text-sm">{token.name}</code>
|
||||
<span className="truncate text-sm text-muted">{token.role}</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-5">
|
||||
<h2 className="text-lg font-semibold">Motion</h2>
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
<TokenTable
|
||||
caption="Durations"
|
||||
rows={Object.entries(durations).map(([name, s]) => [name, `${s * 1000}ms`])}
|
||||
/>
|
||||
<TokenTable
|
||||
caption="Springs"
|
||||
rows={Object.entries(springs).map(([name, s]) => [
|
||||
name,
|
||||
`stiffness ${s.stiffness} · damping ${s.damping}`,
|
||||
])}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TokenTable({ caption, rows }: { caption: string; rows: [string, string][] }) {
|
||||
return (
|
||||
<table className="w-full overflow-hidden rounded-xl border border-border bg-surface text-sm">
|
||||
<caption className="mb-2 text-left text-sm text-muted">{caption}</caption>
|
||||
<tbody>
|
||||
{rows.map(([name, value]) => (
|
||||
<tr key={name} className="border-t border-border first:border-t-0">
|
||||
<th scope="row" className="px-4 py-3 text-left font-mono font-normal">
|
||||
{name}
|
||||
</th>
|
||||
<td className="px-4 py-3 text-right font-mono text-muted">{value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
export function ButtonDemo() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
function save() {
|
||||
setSaving(true);
|
||||
setTimeout(() => setSaving(false), 1600);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||
<Button loading={saving} onClick={save}>
|
||||
Save changes
|
||||
</Button>
|
||||
<Button variant="secondary">Cancel</Button>
|
||||
<Button variant="ghost">Skip</Button>
|
||||
<Button variant="danger">Delete</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SwitchDemo() {
|
||||
return (
|
||||
<div className="flex w-full max-w-sm flex-col gap-5">
|
||||
<Switch label="Email notifications" description="A summary every Monday." defaultChecked />
|
||||
<Switch label="Product updates" />
|
||||
<Switch label="Beta features" description="Managed by your organisation." disabled />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const views = [
|
||||
{ value: "day", label: "Day" },
|
||||
{ value: "week", label: "Week" },
|
||||
{ value: "month", label: "Month" },
|
||||
{ value: "year", label: "Year" },
|
||||
] as const;
|
||||
|
||||
export function SegmentedControlDemo() {
|
||||
const [view, setView] = useState<(typeof views)[number]["value"]>("week");
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<SegmentedControl
|
||||
aria-label="Calendar view"
|
||||
options={views}
|
||||
value={view}
|
||||
onValueChange={setView}
|
||||
/>
|
||||
<p className="text-sm text-muted" aria-live="polite">
|
||||
Showing the <span className="font-medium text-foreground">{view}</span> view
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { MotionConfig } from "motion/react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/** Motion follows the OS "reduce motion" setting everywhere. */
|
||||
export function Providers({ children }: { children: ReactNode }) {
|
||||
return <MotionConfig reducedMotion="user">{children}</MotionConfig>;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import Link from "next/link";
|
||||
import { ThemeToggle } from "./theme-toggle";
|
||||
|
||||
export function SiteHeader() {
|
||||
return (
|
||||
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
|
||||
<div className="mx-auto flex h-14 max-w-5xl items-center justify-between gap-4 px-4 sm:px-6">
|
||||
<nav className="flex items-center gap-5 text-sm">
|
||||
<Link href="/" className="font-semibold">
|
||||
Playground
|
||||
</Link>
|
||||
<Link href="/tokens" className="text-muted transition-colors hover:text-foreground">
|
||||
Tokens
|
||||
</Link>
|
||||
</nav>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/** A framed showcase for one component: the live demo plus the details behind it. */
|
||||
export function Specimen({
|
||||
title,
|
||||
description,
|
||||
notes,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
notes: string[];
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="overflow-hidden rounded-2xl border border-border bg-surface">
|
||||
<div className="flex min-h-48 items-center justify-center border-b border-border bg-background/60 p-8">
|
||||
{children}
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 p-6">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">{title}</h2>
|
||||
<p className="mt-1 text-sm text-muted">{description}</p>
|
||||
</div>
|
||||
<ul className="flex flex-wrap gap-2 text-xs text-muted">
|
||||
{notes.map((note) => (
|
||||
<li key={note} className="rounded-full border border-border px-2.5 py-1">
|
||||
{note}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import { getTheme, setTheme, subscribeTheme, type ThemePreference } from "@/lib/theme";
|
||||
|
||||
const options = [
|
||||
{ value: "system", label: "Auto" },
|
||||
{ value: "light", label: "Light" },
|
||||
{ value: "dark", label: "Dark" },
|
||||
] as const satisfies readonly { value: ThemePreference; label: string }[];
|
||||
|
||||
export function ThemeToggle() {
|
||||
// The server renders "system". The client switches to the saved preference after hydration.
|
||||
const theme = useSyncExternalStore(subscribeTheme, getTheme, () => "system" as const);
|
||||
|
||||
return (
|
||||
<SegmentedControl
|
||||
aria-label="Colour theme"
|
||||
size="sm"
|
||||
options={options}
|
||||
value={theme}
|
||||
onValueChange={setTheme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Button } from "./button";
|
||||
|
||||
describe("Button", () => {
|
||||
it("defaults to type=button so it never submits a form by accident", () => {
|
||||
render(<Button>Save</Button>);
|
||||
expect(screen.getByRole("button", { name: "Save" })).toHaveAttribute("type", "button");
|
||||
});
|
||||
|
||||
it("keeps its accessible name and blocks clicks while loading", async () => {
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<Button loading onClick={onClick}>
|
||||
Save
|
||||
</Button>,
|
||||
);
|
||||
const button = screen.getByRole("button", { name: "Save" });
|
||||
expect(button).toBeDisabled();
|
||||
expect(button).toHaveAttribute("aria-busy", "true");
|
||||
await userEvent.click(button);
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { motion, type HTMLMotionProps } from "motion/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { springs } from "@/tokens/tokens";
|
||||
|
||||
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
|
||||
export type ButtonSize = "sm" | "md" | "lg";
|
||||
|
||||
export interface ButtonProps extends Omit<HTMLMotionProps<"button">, "children"> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
/** Shows a spinner and blocks clicks. The button keeps its width and its accessible name. */
|
||||
loading?: boolean;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
const variantClasses: Record<ButtonVariant, string> = {
|
||||
primary: "bg-accent text-accent-foreground hover:bg-accent-hover",
|
||||
secondary: "border border-border bg-surface text-foreground hover:bg-surface-hover",
|
||||
ghost: "text-foreground hover:bg-surface-hover",
|
||||
danger: "bg-danger text-white hover:brightness-110",
|
||||
};
|
||||
|
||||
const sizeClasses: Record<ButtonSize, string> = {
|
||||
sm: "h-8 px-3 text-sm",
|
||||
md: "h-10 px-4 text-sm",
|
||||
lg: "h-12 px-5 text-base",
|
||||
};
|
||||
|
||||
export function Button({
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
loading = false,
|
||||
disabled,
|
||||
type = "button",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const isDisabled = disabled || loading;
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
type={type}
|
||||
disabled={isDisabled}
|
||||
aria-busy={loading || undefined}
|
||||
whileTap={isDisabled ? undefined : { scale: 0.97 }}
|
||||
transition={springs.press}
|
||||
className={cn(
|
||||
"relative inline-flex items-center justify-center rounded-lg font-medium select-none",
|
||||
"transition-[background-color,border-color,filter] duration-150 ease-snappy",
|
||||
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:outline-none",
|
||||
"disabled:cursor-not-allowed disabled:opacity-60",
|
||||
variantClasses[variant],
|
||||
sizeClasses[size],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{/* opacity-0 rather than invisible, so screen readers still hear the label while loading */}
|
||||
<span className={cn("inline-flex items-center gap-2", loading && "opacity-0")}>
|
||||
{children}
|
||||
</span>
|
||||
{loading && (
|
||||
<span className="absolute inset-0 grid place-items-center" aria-hidden>
|
||||
<Spinner />
|
||||
</span>
|
||||
)}
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
|
||||
function Spinner() {
|
||||
return (
|
||||
<svg className="size-4 animate-spin" viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="6.5" stroke="currentColor" strokeOpacity="0.25" strokeWidth="2" />
|
||||
<path
|
||||
d="M14.5 8A6.5 6.5 0 0 0 8 1.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SegmentedControl } from "./segmented-control";
|
||||
|
||||
const options = [
|
||||
{ value: "day", label: "Day" },
|
||||
{ value: "week", label: "Week" },
|
||||
{ value: "month", label: "Month" },
|
||||
] as const;
|
||||
|
||||
function setup() {
|
||||
render(<SegmentedControl aria-label="View" options={options} defaultValue="week" />);
|
||||
return (name: string) => screen.getByRole("radio", { name });
|
||||
}
|
||||
|
||||
describe("SegmentedControl", () => {
|
||||
it("is a labelled radio group with one Tab stop", () => {
|
||||
const radio = setup();
|
||||
expect(screen.getByRole("radiogroup", { name: "View" })).toBeInTheDocument();
|
||||
expect(radio("Week")).toHaveAttribute("tabindex", "0");
|
||||
expect(radio("Day")).toHaveAttribute("tabindex", "-1");
|
||||
});
|
||||
|
||||
it("moves selection and focus with arrow keys, wrapping at the ends", async () => {
|
||||
const radio = setup();
|
||||
await userEvent.tab();
|
||||
expect(radio("Week")).toHaveFocus();
|
||||
|
||||
await userEvent.keyboard("{ArrowRight}");
|
||||
expect(radio("Month")).toHaveFocus();
|
||||
expect(radio("Month")).toHaveAttribute("aria-checked", "true");
|
||||
|
||||
await userEvent.keyboard("{ArrowRight}");
|
||||
expect(radio("Day")).toHaveFocus();
|
||||
|
||||
await userEvent.keyboard("{End}");
|
||||
expect(radio("Month")).toHaveAttribute("aria-checked", "true");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { useId, useRef, type KeyboardEvent } from "react";
|
||||
import { useControllableState } from "@/hooks/use-controllable-state";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { springs } from "@/tokens/tokens";
|
||||
|
||||
export interface SegmentedOption<T extends string> {
|
||||
value: T;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SegmentedControlProps<T extends string> {
|
||||
options: readonly SegmentedOption<T>[];
|
||||
/** Required: a segmented control has no visible group label. */
|
||||
"aria-label": string;
|
||||
value?: T;
|
||||
defaultValue?: T;
|
||||
onValueChange?: (value: T) => void;
|
||||
size?: "sm" | "md";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single-choice control (WAI-ARIA radio group).
|
||||
* Arrow keys move and select, Home/End jump, and only the selected option is a
|
||||
* Tab stop. The selection indicator is one shared element that glides between
|
||||
* options.
|
||||
*/
|
||||
export function SegmentedControl<T extends string>({
|
||||
options,
|
||||
value,
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
size = "md",
|
||||
className,
|
||||
"aria-label": ariaLabel,
|
||||
}: SegmentedControlProps<T>) {
|
||||
const [selected, setSelected] = useControllableState<T>({
|
||||
value,
|
||||
defaultValue: defaultValue ?? options[0].value,
|
||||
onChange: onValueChange,
|
||||
});
|
||||
const indicatorId = useId();
|
||||
const refs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
|
||||
function onKeyDown(event: KeyboardEvent, index: number) {
|
||||
const last = options.length - 1;
|
||||
let next: number;
|
||||
switch (event.key) {
|
||||
case "ArrowRight":
|
||||
case "ArrowDown":
|
||||
next = index === last ? 0 : index + 1;
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
case "ArrowUp":
|
||||
next = index === 0 ? last : index - 1;
|
||||
break;
|
||||
case "Home":
|
||||
next = 0;
|
||||
break;
|
||||
case "End":
|
||||
next = last;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
setSelected(options[next].value);
|
||||
refs.current[next]?.focus();
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
"inline-flex rounded-lg border border-border bg-surface-hover p-0.5",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{options.map((option, index) => {
|
||||
const isSelected = option.value === selected;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
ref={(el) => {
|
||||
refs.current[index] = el;
|
||||
}}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isSelected}
|
||||
tabIndex={isSelected ? 0 : -1}
|
||||
onClick={() => setSelected(option.value)}
|
||||
onKeyDown={(e) => onKeyDown(e, index)}
|
||||
className={cn(
|
||||
"relative rounded-md font-medium transition-colors duration-150",
|
||||
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
|
||||
size === "sm" ? "h-7 px-2.5 text-xs" : "h-8 px-3.5 text-sm",
|
||||
isSelected ? "text-foreground" : "text-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{isSelected && (
|
||||
<motion.span
|
||||
layoutId={indicatorId}
|
||||
aria-hidden
|
||||
className="absolute inset-0 rounded-md bg-surface shadow-sm ring-1 ring-border"
|
||||
transition={springs.snappy}
|
||||
/>
|
||||
)}
|
||||
<span className="relative">{option.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Switch } from "./switch";
|
||||
|
||||
describe("Switch", () => {
|
||||
it("toggles when the label is clicked and reports the change", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<Switch label="Notifications" onCheckedChange={onChange} />);
|
||||
const toggle = screen.getByRole("switch", { name: "Notifications" });
|
||||
|
||||
expect(toggle).toHaveAttribute("aria-checked", "false");
|
||||
await userEvent.click(screen.getByText("Notifications"));
|
||||
expect(toggle).toHaveAttribute("aria-checked", "true");
|
||||
expect(onChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("toggles with Space from the keyboard", async () => {
|
||||
render(<Switch label="Beta" />);
|
||||
await userEvent.tab();
|
||||
await userEvent.keyboard(" ");
|
||||
expect(screen.getByRole("switch", { name: "Beta" })).toHaveAttribute("aria-checked", "true");
|
||||
});
|
||||
|
||||
it("stays put when controlled and the parent does not update", async () => {
|
||||
render(<Switch label="Locked" checked={false} onCheckedChange={() => {}} />);
|
||||
await userEvent.click(screen.getByRole("switch"));
|
||||
expect(screen.getByRole("switch")).toHaveAttribute("aria-checked", "false");
|
||||
});
|
||||
|
||||
it("links the description for screen readers", () => {
|
||||
render(<Switch label="Email" description="Weekly summary" />);
|
||||
expect(screen.getByRole("switch", { name: "Email" })).toHaveAccessibleDescription(
|
||||
"Weekly summary",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { useId } from "react";
|
||||
import { useControllableState } from "@/hooks/use-controllable-state";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { springs } from "@/tokens/tokens";
|
||||
|
||||
export interface SwitchProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
checked?: boolean;
|
||||
defaultChecked?: boolean;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Switch({
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
defaultChecked = false,
|
||||
onCheckedChange,
|
||||
disabled,
|
||||
className,
|
||||
}: SwitchProps) {
|
||||
const [isOn, setOn] = useControllableState({
|
||||
value: checked,
|
||||
defaultValue: defaultChecked,
|
||||
onChange: onCheckedChange,
|
||||
});
|
||||
const id = useId();
|
||||
const descriptionId = `${id}-description`;
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center justify-between gap-6", className)}>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<label htmlFor={id} className="text-sm font-medium">
|
||||
{label}
|
||||
</label>
|
||||
{description && (
|
||||
<span id={descriptionId} className="text-sm text-muted">
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
id={id}
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={isOn}
|
||||
aria-describedby={description ? descriptionId : undefined}
|
||||
disabled={disabled}
|
||||
onClick={() => setOn(!isOn)}
|
||||
className={cn(
|
||||
"relative inline-flex h-6 w-11 shrink-0 items-center rounded-full p-0.5",
|
||||
"transition-colors duration-200 ease-snappy",
|
||||
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:outline-none",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
isOn ? "bg-accent" : "bg-border",
|
||||
)}
|
||||
>
|
||||
<motion.span
|
||||
aria-hidden
|
||||
className="size-5 rounded-full bg-white shadow-sm"
|
||||
initial={false}
|
||||
animate={{ x: isOn ? 20 : 0 }}
|
||||
transition={springs.snappy}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
/**
|
||||
* Lets a component work controlled (`value` + `onChange`) or uncontrolled
|
||||
* (`defaultValue`) with one code path, as native inputs do.
|
||||
*/
|
||||
export function useControllableState<T>({
|
||||
value,
|
||||
defaultValue,
|
||||
onChange,
|
||||
}: {
|
||||
value?: T;
|
||||
defaultValue: T;
|
||||
onChange?: (value: T) => void;
|
||||
}) {
|
||||
const [internal, setInternal] = useState(defaultValue);
|
||||
const isControlled = value !== undefined;
|
||||
const current = isControlled ? value : internal;
|
||||
|
||||
const setValue = useCallback(
|
||||
(next: T) => {
|
||||
if (!isControlled) setInternal(next);
|
||||
onChange?.(next);
|
||||
},
|
||||
[isControlled, onChange],
|
||||
);
|
||||
|
||||
return [current, setValue] as const;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return clsx(inputs);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
export type ThemePreference = "system" | "light" | "dark";
|
||||
|
||||
const STORAGE_KEY = "theme";
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
/**
|
||||
* Runs in <head> before first paint so a saved theme never flashes.
|
||||
* "system" is the absence of data-theme, which leaves it to prefers-color-scheme.
|
||||
*/
|
||||
export const themeScript = `(function(){try{var t=localStorage.getItem("${STORAGE_KEY}");if(t==="light"||t==="dark")document.documentElement.setAttribute("data-theme",t)}catch(e){}})()`;
|
||||
|
||||
export function getTheme(): ThemePreference {
|
||||
try {
|
||||
const value = localStorage.getItem(STORAGE_KEY);
|
||||
return value === "light" || value === "dark" ? value : "system";
|
||||
} catch {
|
||||
return "system";
|
||||
}
|
||||
}
|
||||
|
||||
export function setTheme(theme: ThemePreference) {
|
||||
try {
|
||||
if (theme === "system") localStorage.removeItem(STORAGE_KEY);
|
||||
else localStorage.setItem(STORAGE_KEY, theme);
|
||||
} catch {
|
||||
// Storage can be unavailable (private mode). The theme still applies for this visit.
|
||||
}
|
||||
const root = document.documentElement;
|
||||
if (theme === "system") root.removeAttribute("data-theme");
|
||||
else root.setAttribute("data-theme", theme);
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
export function subscribeTheme(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { colorTokens } from "./tokens";
|
||||
|
||||
const css = readFileSync(join(__dirname, "../app/globals.css"), "utf8");
|
||||
|
||||
/** Returns the body of the first CSS block whose selector contains `selector`. */
|
||||
function block(selector: string): string {
|
||||
const start = css.indexOf(selector);
|
||||
if (start === -1) throw new Error(`Selector not found: ${selector}`);
|
||||
const open = css.indexOf("{", start);
|
||||
const close = css.indexOf("}", open);
|
||||
return css.slice(open + 1, close);
|
||||
}
|
||||
|
||||
describe("design tokens", () => {
|
||||
const themes = {
|
||||
light: block('[data-theme="light"]'),
|
||||
"dark (system)": block(':root:not([data-theme="light"])'),
|
||||
"dark (explicit)": block('[data-theme="dark"] {'),
|
||||
};
|
||||
|
||||
for (const [theme, body] of Object.entries(themes)) {
|
||||
it.each(colorTokens.map((t) => t.name))(`defines --%s in ${theme}`, (name) => {
|
||||
expect(body).toMatch(new RegExp(`--${name}:\\s*[^;]+;`));
|
||||
});
|
||||
}
|
||||
|
||||
it("exposes every token to Tailwind", () => {
|
||||
const theme = block("@theme inline");
|
||||
for (const { name } of colorTokens) {
|
||||
expect(theme).toContain(`--color-${name}: var(--${name});`);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps system dark and explicit dark in sync", () => {
|
||||
const norm = (s: string) =>
|
||||
s
|
||||
.split(";")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
.sort();
|
||||
expect(norm(themes["dark (system)"])).toEqual(norm(themes["dark (explicit)"]));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Transition } from "motion/react";
|
||||
|
||||
/**
|
||||
* Metadata for the colour tokens defined in src/app/globals.css.
|
||||
* The CSS is the source of the values. This file is the source of names
|
||||
* and intent, and it powers the /tokens page.
|
||||
*/
|
||||
export const colorTokens = [
|
||||
{ name: "background", role: "Page canvas" },
|
||||
{ name: "surface", role: "Cards, inputs, raised elements" },
|
||||
{ name: "surface-hover", role: "Hover state for surfaces and ghost buttons" },
|
||||
{ name: "border", role: "Hairlines and dividers" },
|
||||
{ name: "foreground", role: "Primary text and icons" },
|
||||
{ name: "muted", role: "Secondary text, captions" },
|
||||
{ name: "accent", role: "Primary actions, selected states" },
|
||||
{ name: "accent-hover", role: "Hover state for accent" },
|
||||
{ name: "accent-foreground", role: "Text on accent" },
|
||||
{ name: "danger", role: "Destructive actions, errors" },
|
||||
{ name: "ring", role: "Focus indicator" },
|
||||
] as const;
|
||||
|
||||
export type ColorToken = (typeof colorTokens)[number]["name"];
|
||||
|
||||
/** Durations in seconds (Motion's unit). */
|
||||
export const durations = {
|
||||
instant: 0.08,
|
||||
fast: 0.15,
|
||||
base: 0.24,
|
||||
slow: 0.4,
|
||||
} as const;
|
||||
|
||||
/** Named motion presets, so interactions feel like one system. */
|
||||
export const springs = {
|
||||
/** Toggles, indicators: quick and settled, no visible overshoot. */
|
||||
snappy: { type: "spring", stiffness: 520, damping: 34, mass: 0.8 },
|
||||
/** Press feedback: very short and firm. */
|
||||
press: { type: "spring", stiffness: 800, damping: 40 },
|
||||
/** Larger surfaces entering: softer. */
|
||||
gentle: { type: "spring", stiffness: 260, damping: 30 },
|
||||
} as const satisfies Record<string, Transition>;
|
||||
Reference in New Issue
Block a user