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:
2026-11-09 03:52:46 +00:00
co-authored by Claude Opus 5.5
parent 3d9c5cef51
commit 7af2315316
33 changed files with 9526 additions and 1 deletions
+41
View File
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+4
View File
@@ -0,0 +1,4 @@
.next
node_modules
package-lock.json
next-env.d.ts
+5
View File
@@ -0,0 +1,5 @@
{
"printWidth": 100,
"plugins": ["prettier-plugin-tailwindcss"],
"tailwindStylesheet": "./src/app/globals.css"
}
+9
View File
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
## This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
+65 -1
View File
@@ -1,2 +1,66 @@
# typescript-project
# Design Engineering Playground
A TypeScript project for small interface components, built the way a design
engineer would build them. Each one uses shared design tokens, works with a
keyboard and a screen reader, and has motion that respects the user's settings.
## What's here
| Piece | Details worth noticing |
| ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Design tokens** (`src/app/globals.css`, `src/tokens/tokens.ts`) | Semantic colour names such as `surface`, `muted` and `accent`, never raw hex values in components. Light, dark and system themes come from one variable swap. A test fails if a token is missing from any theme. |
| **Button** | Primary, secondary, ghost and danger variants. Spring press feedback. A loading state that keeps the button's width and its accessible name, and sets `aria-busy`. |
| **Segmented control** | A WAI-ARIA radio group with a roving tabindex. Arrow, Home and End keys work. One shared-layout indicator glides between options. |
| **Switch** | `role="switch"`. Works controlled or uncontrolled through `useControllableState`. The label is clickable and the description is wired to `aria-describedby`. |
| **Theme toggle** | An inline pre-paint script means no flash on reload. Reads the saved preference through `useSyncExternalStore`, so hydration stays safe. |
| **Motion system** | Named springs and durations in `tokens.ts`. `MotionConfig reducedMotion="user"`, plus a CSS fallback for reduced motion. |
## Stack
Next.js 16 (App Router), React 19, TypeScript, Tailwind CSS v4, Motion,
Vitest and Testing Library, ESLint, and Prettier with Tailwind class sorting.
## Getting started
```bash
npm install
npm run dev # http://localhost:3000
```
| Script | What it does |
| ---------------- | -------------------------------------------------------------------- |
| `npm run check` | Runs typecheck, lint, format check and tests. Run it before pushing. |
| `npm test` | Unit and accessibility tests (Vitest + Testing Library) |
| `npm run build` | Production build. Every page is statically prerendered. |
| `npm run format` | Runs Prettier, which also sorts Tailwind classes |
## Structure
```
src/
app/ routes: / (component specimens), /tokens
components/ui/ the reusable components and their tests
components/site/ page chrome and demos
hooks/ useControllableState
lib/ theme store, cn()
tokens/ token metadata, motion presets, token tests
```
## Principles
1. **Tokens, not values.** Components use semantic names only, so a theme is just data.
2. **Accessible by construction.** Tests query by role and accessible name,
the way assistive technology does. A broken label fails the build.
3. **Motion with intent.** A few named springs keep every interaction feeling
like one system, and reduced-motion users get instant state changes.
4. **No layout shift.** Loading states, theme changes and hover effects never move
surrounding content.
## Roadmap
- [ ] Command menu (⌘K) with fuzzy search and list virtualisation
- [ ] Tooltip and popover with collision-aware positioning
- [ ] Toast stack with swipe-to-dismiss
- [ ] Playwright visual regression and axe checks in CI
- [ ] Case-study write-ups for each component: the problem, the options, and the decision
- [ ] Deploy (Vercel) and link from a portfolio
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+17
View File
@@ -0,0 +1,17 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
cacheComponents: true,
partialPrefetching: true,
turbopack: {
rules: {
"*.css": {
loaders: ["@tailwindcss/turbopack"],
as: "*.css",
},
},
},
};
export default nextConfig;
+8305
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
{
"name": "typescript-project",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"format": "prettier --write .",
"format:check": "prettier --check .",
"check": "npm run typecheck && npm run lint && npm run format:check && npm test"
},
"dependencies": {
"clsx": "^2.1.1",
"motion": "^12.43.0",
"next": "16.4.5",
"react": "19.3.0",
"react-dom": "19.3.0"
},
"devDependencies": {
"@tailwindcss/turbopack": "^4",
"@testing-library/dom": "^10.4.2",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.8",
"@types/node": "^22.20.5",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^6.2.1",
"eslint": "^9",
"eslint-config-next": "16.4.5",
"jsdom": "^31.0.0",
"prettier": "^3.9.10",
"prettier-plugin-tailwindcss": "^0.8.1",
"tailwindcss": "^4",
"typescript": "^5",
"vite-tsconfig-paths": "^6.1.1",
"vitest": "^5.1.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+90
View File
@@ -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;
}
}
+47
View File
@@ -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>
);
}
+65
View File
@@ -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>
);
}
+75
View File
@@ -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>
);
}
+60
View File
@@ -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>
);
}
+9
View File
@@ -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>;
}
+20
View File
@@ -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>
);
}
+35
View File
@@ -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>
);
}
+26
View File
@@ -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}
/>
);
}
+25
View File
@@ -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();
});
});
+87
View File
@@ -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");
});
});
+118
View File
@@ -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>
);
}
+37
View File
@@ -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",
);
});
});
+74
View File
@@ -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>
);
}
+29
View File
@@ -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;
}
+5
View File
@@ -0,0 +1,5 @@
import { clsx, type ClassValue } from "clsx";
export function cn(...inputs: ClassValue[]) {
return clsx(inputs);
}
+39
View File
@@ -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);
};
}
+46
View File
@@ -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)"]));
});
});
+40
View File
@@ -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>;
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}
+12
View File
@@ -0,0 +1,12 @@
import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [tsconfigPaths(), react()],
test: {
environment: "jsdom",
setupFiles: ["./vitest.setup.ts"],
include: ["src/**/*.test.{ts,tsx}"],
},
});
+5
View File
@@ -0,0 +1,5 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
afterEach(() => cleanup());