Compare commits
8 Commits
592635e7ce
...
3e7de78721
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e7de78721 | |||
| 07be9d7314 | |||
| 0e1b06b376 | |||
| 40d53f86dd | |||
| ba796fb247 | |||
| 2205862c2f | |||
| b57aab01aa | |||
| afba95fbaf |
@@ -1,51 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(npm run dev)",
|
|
||||||
"Bash(npm run build)",
|
|
||||||
"Bash(npm run storybook)",
|
|
||||||
"Bash(npm run lint)",
|
|
||||||
"Bash(npx tsc --noEmit)",
|
|
||||||
"Bash(npx storybook *)",
|
|
||||||
"Bash(npx prettier *)",
|
|
||||||
"Bash(npx vitest *)",
|
|
||||||
"Bash(node --check *)",
|
|
||||||
"Bash(npm install *)",
|
|
||||||
"Bash(npm list *)",
|
|
||||||
"Bash(npm outdated *)",
|
|
||||||
"Bash(git status *)",
|
|
||||||
"Bash(git log *)",
|
|
||||||
"Bash(git diff *)",
|
|
||||||
"Bash(git add *)",
|
|
||||||
"Bash(git commit *)",
|
|
||||||
"Bash(git branch *)",
|
|
||||||
"Bash(git checkout *)",
|
|
||||||
"Bash(git push *)",
|
|
||||||
"Bash(git pull *)",
|
|
||||||
"Bash(git remote *)",
|
|
||||||
"Bash(kill %*)",
|
|
||||||
"Bash(lsof -i *)",
|
|
||||||
"WebSearch",
|
|
||||||
"WebFetch(domain:storybook.js.org)",
|
|
||||||
"WebFetch(domain:tailwindcss.com)",
|
|
||||||
"WebFetch(domain:react.dev)",
|
|
||||||
"WebFetch(domain:vitejs.dev)",
|
|
||||||
"WebFetch(domain:vite.dev)",
|
|
||||||
"WebFetch(domain:github.com)",
|
|
||||||
"WebFetch(domain:npmjs.com)",
|
|
||||||
"WebFetch(domain:help.penpot.app)",
|
|
||||||
"WebFetch(domain:penpot.app)",
|
|
||||||
"WebFetch(domain:developer.mozilla.org)"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"mcpServers": {
|
|
||||||
"storybook": {
|
|
||||||
"type": "http",
|
|
||||||
"url": "http://localhost:6006/mcp"
|
|
||||||
},
|
|
||||||
"penpot": {
|
|
||||||
"type": "http",
|
|
||||||
"url": "http://localhost:4401/mcp"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -25,3 +25,9 @@ dist-ssr
|
|||||||
|
|
||||||
*storybook.log
|
*storybook.log
|
||||||
storybook-static
|
storybook-static
|
||||||
|
|
||||||
|
# Claude Code (local-only project files)
|
||||||
|
.claude/
|
||||||
|
CLAUDE.md
|
||||||
|
ARCHITECTURE.md
|
||||||
|
plans/
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const config: StorybookConfig = {
|
|||||||
"@storybook/addon-vitest",
|
"@storybook/addon-vitest",
|
||||||
"@storybook/addon-a11y",
|
"@storybook/addon-a11y",
|
||||||
"@storybook/addon-docs",
|
"@storybook/addon-docs",
|
||||||
|
"@storybook/addon-designs",
|
||||||
"@storybook/addon-mcp"
|
"@storybook/addon-mcp"
|
||||||
],
|
],
|
||||||
"framework": "@storybook/react-vite"
|
"framework": "@storybook/react-vite"
|
||||||
|
|||||||
112
ARCHITECTURE.md
112
ARCHITECTURE.md
@@ -1,112 +0,0 @@
|
|||||||
# ARCHITECTURE.md — SDC-Frontend
|
|
||||||
|
|
||||||
This is the living architecture document for the SDC-Frontend design system. All structural decisions are recorded here. Update this document when the architecture evolves — never let the codebase and this document drift apart.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Overview
|
|
||||||
|
|
||||||
SDC-Frontend is a React component library and design system that will serve as the frontend for the Research Synthesiser. It is built in phases:
|
|
||||||
|
|
||||||
1. **Design system** — tokens, primitives, composite components
|
|
||||||
2. **Application screens** — the synthesiser UI rebuilt on top of the design system
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Token Pipeline
|
|
||||||
|
|
||||||
```
|
|
||||||
Penpot (design tool, self-hosted)
|
|
||||||
↓ Penpot MCP / manual export
|
|
||||||
src/tokens/tokens.css (@theme block)
|
|
||||||
↓ Tailwind CSS v4 reads @theme
|
|
||||||
↓ Generates utility classes + CSS custom properties
|
|
||||||
Components (use Tailwind utilities or var() references)
|
|
||||||
↓ Rendered in
|
|
||||||
Storybook (visual verification)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Token Categories
|
|
||||||
- **Colours**: `--color-*` (bg, surface, border, text, primary, success, warning, error)
|
|
||||||
- **Radii**: `--radius-*` (sm, default, lg, full)
|
|
||||||
- **Shadows**: `--shadow-*` (default, md)
|
|
||||||
|
|
||||||
### How Tailwind v4 @theme Works
|
|
||||||
Declaring `--color-primary: #2563eb` inside `@theme` in `tokens.css` automatically generates utilities like `bg-primary`, `text-primary`, `border-primary`. No JavaScript config file needed — the CSS file is the config.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Component Taxonomy
|
|
||||||
|
|
||||||
### `src/components/ui/` — Primitives
|
|
||||||
Atomic, reusable building blocks. Each is self-contained with no domain logic.
|
|
||||||
- Button, Input, Textarea, Select
|
|
||||||
- Card, Badge, Tag
|
|
||||||
- Dialog, Tooltip, Popover
|
|
||||||
|
|
||||||
### `src/components/composite/` — Composed Components
|
|
||||||
Built from primitives, may carry light domain semantics.
|
|
||||||
- StatusMessage (success/error/warning/info)
|
|
||||||
- TabBar, TabPanel
|
|
||||||
- ThemeCard (maps to synthesiser's theme display)
|
|
||||||
|
|
||||||
### `src/components/layout/` — Layout
|
|
||||||
Page-level structural components.
|
|
||||||
- AppShell (header + sidebar + content area)
|
|
||||||
- PageHeader
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Styling Approach
|
|
||||||
|
|
||||||
- **Primary**: Tailwind utility classes
|
|
||||||
- **Conditional classes**: `cn()` from `@/lib/utils` (clsx + tailwind-merge)
|
|
||||||
- **Token values**: Always from `src/tokens/tokens.css`, never hardcoded
|
|
||||||
- **No CSS modules, no styled-components, no inline styles** (except truly dynamic values)
|
|
||||||
- **Class ordering**: Enforced by `prettier-plugin-tailwindcss`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Storybook Conventions
|
|
||||||
|
|
||||||
- Every component has a co-located `.stories.tsx` file
|
|
||||||
- All stories use `tags: ['autodocs']` for auto-generated docs
|
|
||||||
- Stories cover: default state, all variants, edge cases, disabled/error states
|
|
||||||
- A11y addon runs on all stories — violations should be addressed
|
|
||||||
- MCP addon enabled at `localhost:6006/mcp` for AI-assisted development
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── components/
|
|
||||||
│ ├── ui/ # Primitives
|
|
||||||
│ ├── composite/ # Composed components
|
|
||||||
│ └── layout/ # Layout components
|
|
||||||
├── tokens/
|
|
||||||
│ └── tokens.css # Design tokens (@theme block)
|
|
||||||
├── styles/
|
|
||||||
│ └── global.css # Tailwind imports + base styles
|
|
||||||
├── lib/
|
|
||||||
│ └── utils.ts # cn() utility
|
|
||||||
├── hooks/ # Custom React hooks
|
|
||||||
├── pages/ # App screens (Phase 2)
|
|
||||||
├── App.tsx # Root component
|
|
||||||
└── main.tsx # Vite entry point
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Design Tool Integration
|
|
||||||
|
|
||||||
### Penpot (self-hosted at 192.168.50.211:9001)
|
|
||||||
- MCP server runs locally via `npx @penpot/mcp@stable` (port 4401)
|
|
||||||
- MCP plugin must be installed and active in the Penpot browser session
|
|
||||||
- Tools available: `high_level_overview`, `execute_code`, `penpot_api_info`, `export_shape`, `import_image`
|
|
||||||
- Design tokens in Penpot use W3C DTCG format — export as JSON, convert to `@theme` values
|
|
||||||
|
|
||||||
### Storybook MCP
|
|
||||||
- Available at `localhost:6006/mcp` when Storybook dev server is running
|
|
||||||
- Provides: component listing, documentation retrieval, story generation, a11y testing
|
|
||||||
150
CLAUDE.md
150
CLAUDE.md
@@ -1,150 +0,0 @@
|
|||||||
# CLAUDE.md — SDC-Frontend Design System
|
|
||||||
|
|
||||||
You are the lead engineer on **SDC-Frontend**, a React component library and design system for the Research Synthesiser, built for UX researchers at the NSW Department of Education.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## How I Want You to Work
|
|
||||||
|
|
||||||
These are non-negotiable working principles for this project.
|
|
||||||
|
|
||||||
### Plan Before You Build
|
|
||||||
Before beginning any significant piece of work, write a plan as a markdown file in `plans/`. Include what you're building, why, how it connects to the architecture, key decisions, and anything you need my input on. Share it with me before starting. Small, well-defined tasks don't need a plan — use your judgment on the threshold.
|
|
||||||
|
|
||||||
### Evaluate Before You Commit
|
|
||||||
Before choosing a library, pattern, or approach: identify your options, research the latest documentation (don't rely on training knowledge for version-specific details), verify it works within the project constraints, and state your reasoning — especially when you considered and rejected alternatives.
|
|
||||||
|
|
||||||
### Ask, Don't Assume
|
|
||||||
If you need information you don't have — about infrastructure, the team's workflow, the Penpot setup, design intent, or anything unclear — ask me. Do not fill gaps with assumptions. A question costs a moment; a wrong assumption costs hours.
|
|
||||||
|
|
||||||
### Architecture Governance
|
|
||||||
`ARCHITECTURE.md` is the living contract for this project. Before starting any task, re-read the relevant section. If you need to deviate, propose the change to me first, then update the document. If you discover something new, add it. Never let the codebase and architecture drift apart.
|
|
||||||
|
|
||||||
### Fail Gracefully, Build Honestly
|
|
||||||
Test components with realistic props and edge cases, not only happy-path defaults. When something breaks, surface clear errors. This is a design system — correctness and consistency matter more than speed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Technical Stack
|
|
||||||
|
|
||||||
| Tool | Version | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| React | 19.x | Function components, hooks only |
|
|
||||||
| Vite | latest | Dev server + build |
|
|
||||||
| TypeScript | strict mode | All components must have typed props |
|
|
||||||
| Tailwind CSS | v4.3+ | CSS-first config via `@theme` in `src/tokens/tokens.css` |
|
|
||||||
| Storybook | 10.x | Component dev, docs, visual testing, MCP addon |
|
|
||||||
| clsx + tailwind-merge | latest | Combined as `cn()` in `src/lib/utils.ts` |
|
|
||||||
| ESLint + Prettier | latest | With `prettier-plugin-tailwindcss` for class ordering |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Design Tokens
|
|
||||||
|
|
||||||
All design tokens live in `src/tokens/tokens.css` as a `@theme` block. This is the **single source of truth**. Do not hardcode colour values, radii, or shadows anywhere else.
|
|
||||||
|
|
||||||
Use Tailwind utilities (`bg-primary`, `text-error`, `rounded-default`, etc.) or CSS variables (`var(--color-primary)`) when utilities don't cover the case.
|
|
||||||
|
|
||||||
Token naming convention:
|
|
||||||
- Colours: `--color-{name}` (e.g., `--color-primary`, `--color-text-secondary`)
|
|
||||||
- Radii: `--radius-{size}` (e.g., `--radius-default`, `--radius-lg`)
|
|
||||||
- Shadows: `--shadow-{size}` (e.g., `--shadow-default`, `--shadow-md`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Component Conventions
|
|
||||||
|
|
||||||
### File Structure
|
|
||||||
Each component lives in its own directory:
|
|
||||||
```
|
|
||||||
src/components/ui/Button/
|
|
||||||
Button.tsx — Component implementation
|
|
||||||
Button.stories.tsx — Storybook stories
|
|
||||||
index.ts — Barrel export
|
|
||||||
```
|
|
||||||
|
|
||||||
### Component Taxonomy
|
|
||||||
- `src/components/ui/` — Primitive components (Button, Input, Card, Badge)
|
|
||||||
- `src/components/composite/` — Composed components (StatusMessage, TabBar)
|
|
||||||
- `src/components/layout/` — Layout components (AppShell, Sidebar)
|
|
||||||
|
|
||||||
### TypeScript
|
|
||||||
- Export a named props interface: `ButtonProps`, `CardProps`, etc.
|
|
||||||
- Use `React.forwardRef` for components that wrap native elements.
|
|
||||||
- Extend native element props where appropriate (e.g., `ButtonHTMLAttributes<HTMLButtonElement>`).
|
|
||||||
|
|
||||||
### Styling
|
|
||||||
- Use Tailwind utility classes as the primary styling method.
|
|
||||||
- Use the `cn()` utility from `@/lib/utils` for conditional classes.
|
|
||||||
- Never use inline styles except for truly dynamic values.
|
|
||||||
- Never use CSS modules or styled-components.
|
|
||||||
|
|
||||||
### Stories
|
|
||||||
- Every component MUST have a Storybook story file.
|
|
||||||
- Include at minimum: a `Default` story, and stories for each significant variant/state.
|
|
||||||
- Use Storybook Controls for interactive prop exploration.
|
|
||||||
- Use `tags: ['autodocs']` for automatic documentation generation.
|
|
||||||
|
|
||||||
### Accessibility
|
|
||||||
- All interactive components must be keyboard-navigable.
|
|
||||||
- Use semantic HTML elements (`<button>`, `<nav>`, `<main>`, not `<div onClick>`).
|
|
||||||
- Include `aria-` attributes where needed.
|
|
||||||
- Storybook a11y addon is configured — check for violations.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Code Patterns
|
|
||||||
|
|
||||||
### Conditional Classes
|
|
||||||
```tsx
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
<button className={cn(
|
|
||||||
'px-4 py-2 rounded-default font-medium',
|
|
||||||
variant === 'primary' && 'bg-primary text-white hover:bg-primary-hover',
|
|
||||||
variant === 'secondary' && 'bg-surface border border-border text-text',
|
|
||||||
disabled && 'opacity-50 cursor-not-allowed',
|
|
||||||
)}>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Barrel Exports
|
|
||||||
Every component directory has an `index.ts`:
|
|
||||||
```ts
|
|
||||||
export { Button } from './Button'
|
|
||||||
export type { ButtonProps } from './Button'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Path Alias
|
|
||||||
Use `@/` for imports from `src/`:
|
|
||||||
```ts
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## MCP Integrations
|
|
||||||
|
|
||||||
- **Penpot MCP** — reads design specs from the self-hosted Penpot instance. Requires Penpot open in browser with the MCP plugin active, and `npx @penpot/mcp@stable` running locally.
|
|
||||||
- **Storybook MCP** — runs at `localhost:6006/mcp` alongside the Storybook dev server. Provides component docs, story generation, and a11y testing.
|
|
||||||
|
|
||||||
### Design-to-Code Workflow
|
|
||||||
1. Use Penpot MCP `high_level_overview` to understand the design file
|
|
||||||
2. Use `execute_code` with `findShapes()` to inspect specific components
|
|
||||||
3. Extract CSS with `penpot.generateStyle()`
|
|
||||||
4. Build React component using design tokens from `src/tokens/tokens.css`
|
|
||||||
5. Verify in Storybook that the component matches the Penpot design
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Reference Project
|
|
||||||
|
|
||||||
The existing synthesiser at `../SDC-Synthesiser/` contains the UI patterns, design tokens, and component designs this system is based on. Consult it when building components that map to the existing interface.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Useful Subagents and Tools
|
|
||||||
|
|
||||||
- **Explore agent** — for codebase research spanning multiple files
|
|
||||||
- **webapp-testing skill** — for browser-based UI testing via Playwright
|
|
||||||
- **WebSearch/WebFetch** — for checking latest library documentation before making dependency decisions
|
|
||||||
- **Storybook MCP** — for AI-assisted story generation and component verification
|
|
||||||
79
package-lock.json
generated
79
package-lock.json
generated
@@ -8,6 +8,7 @@
|
|||||||
"name": "sdc-frontend",
|
"name": "sdc-frontend",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource-variable/public-sans": "^5.2.7",
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
"@chromatic-com/storybook": "^5.2.1",
|
"@chromatic-com/storybook": "^5.2.1",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@storybook/addon-a11y": "^10.4.0",
|
"@storybook/addon-a11y": "^10.4.0",
|
||||||
|
"@storybook/addon-designs": "^11.1.3",
|
||||||
"@storybook/addon-docs": "^10.4.0",
|
"@storybook/addon-docs": "^10.4.0",
|
||||||
"@storybook/addon-mcp": "^0.6.0",
|
"@storybook/addon-mcp": "^0.6.0",
|
||||||
"@storybook/addon-vitest": "^10.4.0",
|
"@storybook/addon-vitest": "^10.4.0",
|
||||||
@@ -915,6 +917,36 @@
|
|||||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@figspec/components": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@figspec/components/-/components-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-PFKBX2oFz+vhThKTNsu7Mh4ZT3X7YCiM694UkAMT36j/p0tdmXs9Je0Sf88stTEcMgwYvNv9TZtvniYmgaE+bw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@figspec/react": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@figspec/react/-/react-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-xflqJ3XQZVzm8+7dsm8OFxVAmBNNA3Mg65sqwNHiq7VRSMSD7qwH4BPsBy07ZaX+9nHeaacBpFZd3Q0aIsISqw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@figspec/components": "^2.0.1",
|
||||||
|
"@lit-labs/react": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fontsource-variable/public-sans": {
|
||||||
|
"version": "5.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource-variable/public-sans/-/public-sans-5.2.7.tgz",
|
||||||
|
"integrity": "sha512-4mvade2J3slKkvwRkS+p8T3szet/0vhWoSnuUJTVU81Uo2pRpSZY/Y8bSLRqpSwzIPxjVmRJ53oq6JKP/l/PSg==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@humanfs/core": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.2",
|
"version": "0.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||||
@@ -1046,6 +1078,26 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@lit-labs/react": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lit-labs/react/-/react-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-OD9h2JynerBQUMNzb563jiVpxfvPF0HjQkKY2mx0lpVYvD7F+rtJpOGz6ek+6ufMidV3i+MPT9SX62OKWHFrQg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@lit/react": "^1.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lit/react": {
|
||||||
|
"version": "1.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lit/react/-/react-1.0.8.tgz",
|
||||||
|
"integrity": "sha512-p2+YcF+JE67SRX3mMlJ1TKCSTsgyOVdAwd/nxp3NuV1+Cb6MWALbN6nT7Ld4tpmYofcE5kcaSY1YBB9erY+6fw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "17 || 18 || 19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@mdx-js/react": {
|
"node_modules/@mdx-js/react": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz",
|
||||||
@@ -2115,6 +2167,33 @@
|
|||||||
"storybook": "^10.4.0"
|
"storybook": "^10.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@storybook/addon-designs": {
|
||||||
|
"version": "11.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@storybook/addon-designs/-/addon-designs-11.1.3.tgz",
|
||||||
|
"integrity": "sha512-AK+ij478Y6S16TCNPwm7H90OipVe2wZApOlHjC6qDvMW61zyd4yP1icrRtjehSadw5SCoz8HcAmIYfQCOY6E4A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@figspec/react": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@storybook/addon-docs": "^10.0.0 || ^10.0.0-0 || ^10.1.0-0 || ^10.2.0-0",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"storybook": "^10.0.0 || ^10.0.0-0 || ^10.1.0-0 || ^10.2.0-0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@storybook/addon-docs": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@storybook/addon-docs": {
|
"node_modules/@storybook/addon-docs": {
|
||||||
"version": "10.4.0",
|
"version": "10.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.4.0.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"build-storybook": "storybook build"
|
"build-storybook": "storybook build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource-variable/public-sans": "^5.2.7",
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
"@chromatic-com/storybook": "^5.2.1",
|
"@chromatic-com/storybook": "^5.2.1",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@storybook/addon-a11y": "^10.4.0",
|
"@storybook/addon-a11y": "^10.4.0",
|
||||||
|
"@storybook/addon-designs": "^11.1.3",
|
||||||
"@storybook/addon-docs": "^10.4.0",
|
"@storybook/addon-docs": "^10.4.0",
|
||||||
"@storybook/addon-mcp": "^0.6.0",
|
"@storybook/addon-mcp": "^0.6.0",
|
||||||
"@storybook/addon-vitest": "^10.4.0",
|
"@storybook/addon-vitest": "^10.4.0",
|
||||||
|
|||||||
@@ -1,177 +0,0 @@
|
|||||||
# SDC-Frontend Project Setup Plan
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
The existing Research Synthesiser at `../SDC-Synthesiser/` is a vanilla HTML/CSS/JS app. This new project creates a React-based design system and component library that will eventually become the full frontend replacement. The design system is driven by Penpot (self-hosted at `192.168.50.211:9001`) via MCP, with Storybook for component development and documentation.
|
|
||||||
|
|
||||||
The project carries forward the same working principles (plan before building, evaluate before committing, ask don't assume, architecture governance) and the existing design tokens from the synthesiser's CSS.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
| Tool | Version | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| React | 19.x | Function components, hooks only |
|
|
||||||
| Vite | latest | Dev server + build |
|
|
||||||
| TypeScript | strict | All components have typed props |
|
|
||||||
| Tailwind CSS | v4.3+ | CSS-first `@theme` config — no JS config file |
|
|
||||||
| Storybook | 10.x | Component dev, docs, MCP addon |
|
|
||||||
| ESLint + Prettier | latest | With `prettier-plugin-tailwindcss` for class sorting |
|
|
||||||
| clsx + tailwind-merge | latest | Combined as `cn()` utility |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Steps
|
|
||||||
|
|
||||||
### 1. Scaffold Vite project
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/richie/Nextcloud/Projects/Coding/
|
|
||||||
npm create vite@latest SDC-Frontend -- --template react-ts
|
|
||||||
cd SDC-Frontend
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Install Tailwind CSS v4
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install tailwindcss @tailwindcss/vite
|
|
||||||
```
|
|
||||||
|
|
||||||
Update `vite.config.ts` to add the Tailwind plugin alongside React.
|
|
||||||
|
|
||||||
### 3. Create design tokens and global styles
|
|
||||||
|
|
||||||
- `src/tokens/tokens.css` — `@theme` block with all design tokens ported from the synthesiser's CSS variables
|
|
||||||
- `src/styles/global.css` — imports Tailwind and tokens, sets base typography
|
|
||||||
- Update `src/main.tsx` to import `global.css`
|
|
||||||
- Delete Vite boilerplate (`App.css`, default `App.tsx` content, `src/assets/react.svg`)
|
|
||||||
|
|
||||||
### 4. Install utility dependencies
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install clsx tailwind-merge
|
|
||||||
```
|
|
||||||
|
|
||||||
Create `src/lib/utils.ts` with `cn()` helper.
|
|
||||||
|
|
||||||
### 5. Configure path alias
|
|
||||||
|
|
||||||
Set up `@/` → `src/` in both `tsconfig.app.json` and `vite.config.ts` for clean imports.
|
|
||||||
|
|
||||||
### 6. Install and configure Storybook 10
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx storybook@latest init
|
|
||||||
npm install @storybook/addon-mcp
|
|
||||||
```
|
|
||||||
|
|
||||||
- Update `.storybook/main.ts` to add MCP addon
|
|
||||||
- Update `.storybook/preview.ts` to import `global.css` (so Tailwind works in stories)
|
|
||||||
|
|
||||||
### 7. Install and configure ESLint + Prettier
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install -D prettier prettier-plugin-tailwindcss eslint-config-prettier
|
|
||||||
```
|
|
||||||
|
|
||||||
- Create `.prettierrc` with Tailwind plugin
|
|
||||||
- Update ESLint config to extend `prettier`
|
|
||||||
|
|
||||||
### 8. Create initial Button component
|
|
||||||
|
|
||||||
Validates the full pipeline: tokens → Tailwind → TypeScript → Storybook.
|
|
||||||
|
|
||||||
- `src/components/ui/Button/Button.tsx` — primary/secondary/danger variants, sm/md/lg sizes
|
|
||||||
- `src/components/ui/Button/Button.stories.tsx` — stories for each variant + disabled state
|
|
||||||
- `src/components/ui/Button/index.ts` — barrel export
|
|
||||||
|
|
||||||
### 9. Write CLAUDE.md
|
|
||||||
|
|
||||||
Carry over the five working principles from SDC-Synthesiser, adapted for React/TS/Tailwind/Storybook. Add:
|
|
||||||
- Component conventions (folder structure, typed props, forwardRef, stories required)
|
|
||||||
- Design token rules (single source of truth in `tokens.css`, never hardcode)
|
|
||||||
- Styling rules (Tailwind utilities via `cn()`, no inline styles, no CSS modules)
|
|
||||||
- Accessibility requirements (semantic HTML, keyboard nav, ARIA)
|
|
||||||
- MCP integrations (Penpot, Storybook)
|
|
||||||
- Reference to existing synthesiser
|
|
||||||
|
|
||||||
### 10. Write initial ARCHITECTURE.md
|
|
||||||
|
|
||||||
Lightweight living document covering:
|
|
||||||
- Token pipeline (Penpot → tokens.css → Tailwind → components)
|
|
||||||
- Component taxonomy (ui/composite/layout)
|
|
||||||
- Styling approach
|
|
||||||
- Storybook conventions
|
|
||||||
|
|
||||||
### 11. Create .claude/settings.json
|
|
||||||
|
|
||||||
**MCP servers:**
|
|
||||||
- `storybook` — `http://localhost:6006/mcp` (available when Storybook is running)
|
|
||||||
- `penpot` — `http://localhost:4401/mcp` (Penpot MCP server, runs via `npx @penpot/mcp@stable`)
|
|
||||||
|
|
||||||
Note: The Penpot UI is at `http://192.168.50.211:9001/`. The MCP server runs locally on the dev machine and connects to Penpot via the browser plugin.
|
|
||||||
|
|
||||||
**Permissions:** Pre-allow npm scripts, TypeScript checks, git operations, WebSearch, and WebFetch for documentation domains.
|
|
||||||
|
|
||||||
### 12. Create plans/ directory
|
|
||||||
|
|
||||||
Add this plan as the first entry.
|
|
||||||
|
|
||||||
### 13. Git init + initial commit
|
|
||||||
|
|
||||||
- `git init`
|
|
||||||
- Verify `.gitignore` covers `node_modules/`, `dist/`, `storybook-static/`, `.claude/settings.local.json`
|
|
||||||
- Stage all files, commit with descriptive message
|
|
||||||
- User provides Gitea remote URL to push
|
|
||||||
|
|
||||||
### 14. Verify end-to-end
|
|
||||||
|
|
||||||
1. `npm run dev` — Vite serves without errors
|
|
||||||
2. `npm run storybook` — Storybook loads, Button stories render with correct token colours
|
|
||||||
3. `npx tsc --noEmit` — TypeScript compiles clean
|
|
||||||
4. `npm run build` — produces `dist/` output
|
|
||||||
5. Storybook Controls work interactively on Button
|
|
||||||
6. Autodocs generate for Button
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Files created/modified
|
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
|---|---|
|
|
||||||
| `vite.config.ts` | Add Tailwind plugin + path alias |
|
|
||||||
| `tsconfig.app.json` | Add `@/` path alias |
|
|
||||||
| `src/tokens/tokens.css` | Design tokens as `@theme` block |
|
|
||||||
| `src/styles/global.css` | Tailwind imports + base styles |
|
|
||||||
| `src/main.tsx` | Updated import path |
|
|
||||||
| `src/App.tsx` | Cleaned boilerplate |
|
|
||||||
| `src/lib/utils.ts` | `cn()` utility |
|
|
||||||
| `src/components/ui/Button/Button.tsx` | First component |
|
|
||||||
| `src/components/ui/Button/Button.stories.tsx` | First stories |
|
|
||||||
| `src/components/ui/Button/index.ts` | Barrel export |
|
|
||||||
| `.storybook/main.ts` | MCP addon added |
|
|
||||||
| `.storybook/preview.ts` | Global CSS import |
|
|
||||||
| `.prettierrc` | Tailwind class sorting |
|
|
||||||
| `CLAUDE.md` | Project principles + conventions |
|
|
||||||
| `ARCHITECTURE.md` | Living architecture doc |
|
|
||||||
| `.claude/settings.json` | MCP servers + permissions |
|
|
||||||
| `plans/project-setup.md` | This plan |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Penpot MCP Setup (user action required)
|
|
||||||
|
|
||||||
After the project is scaffolded, the user needs to:
|
|
||||||
1. Install the Penpot MCP plugin inside their Penpot instance at `192.168.50.211:9001`
|
|
||||||
2. Run `npx @penpot/mcp@stable` on their dev machine
|
|
||||||
3. Open Penpot in the browser with the MCP plugin active
|
|
||||||
4. Claude Code can then use the `penpot` MCP tools
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Open items
|
|
||||||
|
|
||||||
- **Gitea remote URL** — user will provide when ready to push
|
|
||||||
- **Penpot MCP plugin installation** — user needs to install in their Penpot instance
|
|
||||||
@@ -8,40 +8,179 @@ const meta: Meta<typeof Button> = {
|
|||||||
argTypes: {
|
argTypes: {
|
||||||
variant: {
|
variant: {
|
||||||
control: 'select',
|
control: 'select',
|
||||||
options: ['primary', 'secondary', 'danger'],
|
options: ['primary', 'secondary', 'tertiary'],
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
control: 'select',
|
||||||
|
options: ['navy', 'red', 'light', 'surface'],
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
control: 'select',
|
control: 'select',
|
||||||
options: ['sm', 'md', 'lg'],
|
options: ['default', 'comfortable', 'compact'],
|
||||||
},
|
},
|
||||||
disabled: { control: 'boolean' },
|
disabled: { control: 'boolean' },
|
||||||
children: { control: 'text' },
|
children: { control: 'text' },
|
||||||
},
|
},
|
||||||
|
parameters: {
|
||||||
|
design: {
|
||||||
|
type: 'figma',
|
||||||
|
url: 'https://www.figma.com/design/mrabO6AtxN3ektGiTk0I9c/ResearchInsights?node-id=10-20',
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export default meta
|
export default meta
|
||||||
type Story = StoryObj<typeof meta>
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
export const Primary: Story = {
|
export const Default: Story = {
|
||||||
args: { children: 'Primary Button', variant: 'primary' },
|
args: { children: 'Button' },
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Secondary: Story = {
|
export const PrimaryNavy: Story = {
|
||||||
args: { children: 'Secondary Button', variant: 'secondary' },
|
args: { children: 'Button', variant: 'primary', color: 'navy' },
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Danger: Story = {
|
export const PrimaryRed: Story = {
|
||||||
args: { children: 'Delete', variant: 'danger' },
|
args: { children: 'Delete', variant: 'primary', color: 'red' },
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Small: Story = {
|
export const PrimaryLight: Story = {
|
||||||
args: { children: 'Small', size: 'sm' },
|
args: { children: 'Button', variant: 'primary', color: 'light' },
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Large: Story = {
|
export const PrimarySurface: Story = {
|
||||||
args: { children: 'Large', size: 'lg' },
|
args: { children: 'Button', variant: 'primary', color: 'surface' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SecondaryNavy: Story = {
|
||||||
|
args: { children: 'Button', variant: 'secondary', color: 'navy' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SecondaryRed: Story = {
|
||||||
|
args: { children: 'Cancel', variant: 'secondary', color: 'red' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SecondarySurface: Story = {
|
||||||
|
args: { children: 'Button', variant: 'secondary', color: 'surface' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TertiaryNavy: Story = {
|
||||||
|
args: { children: 'Button', variant: 'tertiary', color: 'navy' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TertiaryRed: Story = {
|
||||||
|
args: { children: 'Remove', variant: 'tertiary', color: 'red' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TertiarySurface: Story = {
|
||||||
|
args: { children: 'Button', variant: 'tertiary', color: 'surface' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const ArrowIcon = () => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M5 12h14" />
|
||||||
|
<path d="m12 5 7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const LockIcon = () => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const WithLeftIcon: Story = {
|
||||||
|
args: { children: 'Button', leftIcon: <LockIcon /> },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithRightIcon: Story = {
|
||||||
|
args: { children: 'Button', rightIcon: <ArrowIcon /> },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithBothIcons: Story = {
|
||||||
|
args: {
|
||||||
|
children: 'Button',
|
||||||
|
leftIcon: <LockIcon />,
|
||||||
|
rightIcon: <ArrowIcon />,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Comfortable: Story = {
|
||||||
|
args: { children: 'Button', size: 'comfortable' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Compact: Story = {
|
||||||
|
args: { children: 'Button', size: 'compact' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AllSizes: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button size="default">Default</Button>
|
||||||
|
<Button size="comfortable">Comfortable</Button>
|
||||||
|
<Button size="compact">Compact</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AllVariants: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button variant="primary">Primary</Button>
|
||||||
|
<Button variant="secondary">Secondary</Button>
|
||||||
|
<Button variant="tertiary">Tertiary</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AllColours: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button color="navy">Navy</Button>
|
||||||
|
<Button color="red">Red</Button>
|
||||||
|
<Button color="light">Light</Button>
|
||||||
|
<Button color="surface">Surface</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button variant="secondary" color="navy">Navy</Button>
|
||||||
|
<Button variant="secondary" color="red">Red</Button>
|
||||||
|
<Button variant="secondary" color="light">Light</Button>
|
||||||
|
<Button variant="secondary" color="surface">Surface</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button variant="tertiary" color="navy">Navy</Button>
|
||||||
|
<Button variant="tertiary" color="red">Red</Button>
|
||||||
|
<Button variant="tertiary" color="light">Light</Button>
|
||||||
|
<Button variant="tertiary" color="surface">Surface</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Disabled: Story = {
|
export const Disabled: Story = {
|
||||||
args: { children: 'Disabled', disabled: true },
|
render: () => (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button disabled>Primary</Button>
|
||||||
|
<Button variant="secondary" disabled>Secondary</Button>
|
||||||
|
<Button variant="tertiary" disabled>Tertiary</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,30 +2,82 @@ import { forwardRef, type ButtonHTMLAttributes } from 'react'
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
variant?: 'primary' | 'secondary' | 'danger'
|
variant?: 'primary' | 'secondary' | 'tertiary'
|
||||||
size?: 'sm' | 'md' | 'lg'
|
color?: 'navy' | 'red' | 'light' | 'surface'
|
||||||
|
size?: 'default' | 'comfortable' | 'compact'
|
||||||
|
leftIcon?: React.ReactNode
|
||||||
|
rightIcon?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantColorStyles: Record<string, Record<string, string>> = {
|
||||||
|
primary: {
|
||||||
|
navy: 'bg-blue-01 text-white hover:bg-blue-01/90 active:bg-blue-01/80',
|
||||||
|
red: 'bg-red-02 text-white hover:bg-red-02/90 active:bg-red-02/80',
|
||||||
|
light: 'bg-blue-04 text-blue-01 hover:bg-blue-04/80 active:bg-blue-04/60',
|
||||||
|
surface: 'bg-grey-01 text-white hover:bg-grey-01/90 active:bg-grey-01/80',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
navy: 'border-2 border-blue-01 text-blue-01 hover:bg-blue-01/5 active:bg-blue-01/10',
|
||||||
|
red: 'border-2 border-red-02 text-red-02 hover:bg-red-02/5 active:bg-red-02/10',
|
||||||
|
light: 'border-2 border-blue-01 text-blue-01 hover:bg-blue-01/5 active:bg-blue-01/10',
|
||||||
|
surface: 'border-2 border-grey-01 text-grey-01 hover:bg-grey-01/5 active:bg-grey-01/10',
|
||||||
|
},
|
||||||
|
tertiary: {
|
||||||
|
navy: 'text-blue-01 hover:bg-blue-01/5 active:bg-blue-01/10',
|
||||||
|
red: 'text-red-02 hover:bg-red-02/5 active:bg-red-02/10',
|
||||||
|
light: 'text-blue-01 hover:bg-blue-01/5 active:bg-blue-01/10',
|
||||||
|
surface: 'text-grey-01 hover:bg-grey-01/5 active:bg-grey-01/10',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeStyles: Record<string, string> = {
|
||||||
|
default: 'h-12 px-6 text-body gap-2',
|
||||||
|
comfortable: 'h-10 px-5 text-body gap-2',
|
||||||
|
compact: 'h-9 px-4 text-small gap-1.5',
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconSizeStyles: Record<string, string> = {
|
||||||
|
default: 'size-6',
|
||||||
|
comfortable: 'size-5',
|
||||||
|
compact: 'size-5',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
({ className, variant = 'primary', size = 'md', children, ...props }, ref) => {
|
(
|
||||||
|
{
|
||||||
|
variant = 'primary',
|
||||||
|
color = 'navy',
|
||||||
|
size = 'default',
|
||||||
|
leftIcon,
|
||||||
|
rightIcon,
|
||||||
|
disabled,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
disabled={disabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex items-center justify-center font-medium rounded-default transition-colors',
|
'inline-flex items-center justify-center rounded-full font-bold transition-colors',
|
||||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary',
|
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-01',
|
||||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
sizeStyles[size],
|
||||||
variant === 'primary' && 'bg-primary text-white hover:bg-primary-hover',
|
variantColorStyles[variant][color],
|
||||||
variant === 'secondary' && 'bg-surface text-text border border-border hover:bg-bg',
|
disabled && 'pointer-events-none opacity-50',
|
||||||
variant === 'danger' && 'bg-error text-white hover:bg-red-700',
|
|
||||||
size === 'sm' && 'px-3 py-1.5 text-sm',
|
|
||||||
size === 'md' && 'px-4 py-2 text-base',
|
|
||||||
size === 'lg' && 'px-6 py-3 text-lg',
|
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
{leftIcon && (
|
||||||
|
<span className={cn('shrink-0', iconSizeStyles[size])}>{leftIcon}</span>
|
||||||
|
)}
|
||||||
{children}
|
{children}
|
||||||
|
{rightIcon && (
|
||||||
|
<span className={cn('shrink-0', iconSizeStyles[size])}>{rightIcon}</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
120
src/components/ui/Checkbox/Checkbox.stories.tsx
Normal file
120
src/components/ui/Checkbox/Checkbox.stories.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||||
|
import { Checkbox } from './Checkbox'
|
||||||
|
|
||||||
|
const meta: Meta<typeof Checkbox> = {
|
||||||
|
title: 'UI/Checkbox',
|
||||||
|
component: Checkbox,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
argTypes: {
|
||||||
|
label: { control: 'text' },
|
||||||
|
description: { control: 'text' },
|
||||||
|
error: { control: 'text' },
|
||||||
|
disabled: { control: 'boolean' },
|
||||||
|
indeterminate: { control: 'boolean' },
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
design: {
|
||||||
|
type: 'figma',
|
||||||
|
url: 'https://www.figma.com/design/mrabO6AtxN3ektGiTk0I9c/ResearchInsights?node-id=33-5043',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Accept terms and conditions',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Checked: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Accept terms and conditions',
|
||||||
|
defaultChecked: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithDescription: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Email notifications',
|
||||||
|
description: 'Receive updates about your research projects via email.',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithError: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'I agree to the privacy policy',
|
||||||
|
error: 'You must agree before continuing',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Disabled: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Unavailable option',
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DisabledChecked: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Locked setting',
|
||||||
|
disabled: true,
|
||||||
|
defaultChecked: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const IndeterminateExample = () => {
|
||||||
|
const [items, setItems] = useState([true, false, true])
|
||||||
|
const allChecked = items.every(Boolean)
|
||||||
|
const someChecked = items.some(Boolean) && !allChecked
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Checkbox
|
||||||
|
label="Select all"
|
||||||
|
checked={allChecked}
|
||||||
|
indeterminate={someChecked}
|
||||||
|
onChange={() => setItems(allChecked ? [false, false, false] : [true, true, true])}
|
||||||
|
/>
|
||||||
|
<div className="ml-6 flex flex-col gap-2">
|
||||||
|
{['Survey responses', 'Interview transcripts', 'Field notes'].map((name, i) => (
|
||||||
|
<Checkbox
|
||||||
|
key={name}
|
||||||
|
label={name}
|
||||||
|
checked={items[i]}
|
||||||
|
onChange={() => {
|
||||||
|
const next = [...items]
|
||||||
|
next[i] = !next[i]
|
||||||
|
setItems(next)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Indeterminate: Story = {
|
||||||
|
render: () => <IndeterminateExample />,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AllStates: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Checkbox label="Unchecked" />
|
||||||
|
<Checkbox label="Checked" defaultChecked />
|
||||||
|
<Checkbox label="With description" description="Additional context for this option." />
|
||||||
|
<Checkbox
|
||||||
|
label="Checked with description"
|
||||||
|
description="This option is currently enabled."
|
||||||
|
defaultChecked
|
||||||
|
/>
|
||||||
|
<Checkbox label="Error" error="This field is required" />
|
||||||
|
<Checkbox label="Disabled" disabled />
|
||||||
|
<Checkbox label="Disabled checked" disabled defaultChecked />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
143
src/components/ui/Checkbox/Checkbox.tsx
Normal file
143
src/components/ui/Checkbox/Checkbox.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { forwardRef, useId, useRef, useEffect, type InputHTMLAttributes } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface CheckboxProps
|
||||||
|
extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'size'> {
|
||||||
|
label?: string
|
||||||
|
description?: string
|
||||||
|
error?: string
|
||||||
|
indeterminate?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
error,
|
||||||
|
indeterminate = false,
|
||||||
|
disabled,
|
||||||
|
className,
|
||||||
|
id: idProp,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
forwardedRef,
|
||||||
|
) => {
|
||||||
|
const autoId = useId()
|
||||||
|
const id = idProp ?? autoId
|
||||||
|
const descriptionId = `${id}-description`
|
||||||
|
const errorId = `${id}-error`
|
||||||
|
const internalRef = useRef<HTMLInputElement>(null)
|
||||||
|
const hasError = !!error
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = internalRef.current
|
||||||
|
if (el) el.indeterminate = indeterminate
|
||||||
|
}, [indeterminate])
|
||||||
|
|
||||||
|
const describedBy =
|
||||||
|
[description ? descriptionId : undefined, hasError ? errorId : undefined]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ') || undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex gap-2', className)}>
|
||||||
|
<div className="flex h-6 items-center">
|
||||||
|
<input
|
||||||
|
ref={(node) => {
|
||||||
|
(internalRef as React.MutableRefObject<HTMLInputElement | null>).current = node
|
||||||
|
if (typeof forwardedRef === 'function') forwardedRef(node)
|
||||||
|
else if (forwardedRef) forwardedRef.current = node
|
||||||
|
}}
|
||||||
|
type="checkbox"
|
||||||
|
id={id}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-invalid={hasError || undefined}
|
||||||
|
aria-describedby={describedBy}
|
||||||
|
className={cn(
|
||||||
|
'peer size-5 cursor-pointer appearance-none rounded-[3px] border-2 border-control-border bg-control-bg transition-colors',
|
||||||
|
'hover:border-control-border-hover',
|
||||||
|
'checked:border-control-checked checked:bg-control-checked',
|
||||||
|
'checked:hover:border-control-checked-hover checked:hover:bg-control-checked-hover',
|
||||||
|
'indeterminate:border-control-checked indeterminate:bg-control-checked',
|
||||||
|
'indeterminate:hover:border-control-checked-hover indeterminate:hover:bg-control-checked-hover',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-control-focus-ring focus-visible:ring-offset-1',
|
||||||
|
'active:scale-95',
|
||||||
|
'disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
hasError && 'border-control-error checked:border-control-error checked:bg-control-error',
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<svg
|
||||||
|
className="pointer-events-none absolute size-5 text-white opacity-0 peer-checked:opacity-100 peer-indeterminate:hidden"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={3}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<polyline points="4.5 10.5 8 14 15.5 6.5" />
|
||||||
|
</svg>
|
||||||
|
<svg
|
||||||
|
className="pointer-events-none absolute size-5 text-white opacity-0 peer-indeterminate:opacity-100"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={3}
|
||||||
|
strokeLinecap="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<line x1="5" y1="10" x2="15" y2="10" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(label || description || hasError) && (
|
||||||
|
<div className="flex flex-col gap-0.5 pt-px">
|
||||||
|
{label && (
|
||||||
|
<label
|
||||||
|
htmlFor={id}
|
||||||
|
className={cn(
|
||||||
|
'cursor-pointer text-body font-normal text-grey-01',
|
||||||
|
disabled && 'pointer-events-none opacity-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{description && (
|
||||||
|
<p
|
||||||
|
id={descriptionId}
|
||||||
|
className={cn('text-small text-text', disabled && 'opacity-50')}
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{hasError && (
|
||||||
|
<div id={errorId} className="flex items-center gap-1 text-small text-control-error">
|
||||||
|
<svg
|
||||||
|
className="size-4 shrink-0"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<line x1="12" y1="8" x2="12" y2="12" />
|
||||||
|
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||||
|
</svg>
|
||||||
|
<p>{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
Checkbox.displayName = 'Checkbox'
|
||||||
2
src/components/ui/Checkbox/index.ts
Normal file
2
src/components/ui/Checkbox/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { Checkbox } from './Checkbox'
|
||||||
|
export type { CheckboxProps } from './Checkbox'
|
||||||
324
src/components/ui/Input/Input.stories.tsx
Normal file
324
src/components/ui/Input/Input.stories.tsx
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||||
|
import { Input } from './Input'
|
||||||
|
|
||||||
|
const meta: Meta<typeof Input> = {
|
||||||
|
title: 'UI/Input',
|
||||||
|
component: Input,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
argTypes: {
|
||||||
|
label: { control: 'text' },
|
||||||
|
description: { control: 'text' },
|
||||||
|
hint: { control: 'text' },
|
||||||
|
error: { control: 'text' },
|
||||||
|
variant: {
|
||||||
|
control: 'select',
|
||||||
|
options: ['outlined', 'stacked'],
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
control: 'select',
|
||||||
|
options: ['default', 'compact'],
|
||||||
|
},
|
||||||
|
disabled: { control: 'boolean' },
|
||||||
|
readOnly: { control: 'boolean' },
|
||||||
|
placeholder: { control: 'text' },
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
design: {
|
||||||
|
type: 'figma',
|
||||||
|
url: 'https://www.figma.com/design/mrabO6AtxN3ektGiTk0I9c/ResearchInsights?node-id=22-3845',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Label',
|
||||||
|
placeholder: 'Placeholder',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithHint: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Email',
|
||||||
|
placeholder: 'you@example.com',
|
||||||
|
hint: 'We will never share your email',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithValue: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Full name',
|
||||||
|
defaultValue: 'Jane Smith',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithError: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Email',
|
||||||
|
defaultValue: 'not-an-email',
|
||||||
|
error: 'Please enter a valid email address',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const SearchIcon = () => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<path d="m21 21-4.3-4.3" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const MailIcon = () => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<rect width="20" height="16" x="2" y="4" rx="2" />
|
||||||
|
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const ChevronDownIcon = () => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="m6 9 6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const WithLeftIcon: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Search',
|
||||||
|
placeholder: 'Search...',
|
||||||
|
leftIcon: <SearchIcon />,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithRightIcon: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Category',
|
||||||
|
placeholder: 'Select...',
|
||||||
|
rightIcon: <ChevronDownIcon />,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithBothIcons: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Email',
|
||||||
|
placeholder: 'you@example.com',
|
||||||
|
leftIcon: <MailIcon />,
|
||||||
|
rightIcon: <ChevronDownIcon />,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Compact: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Label',
|
||||||
|
placeholder: 'Placeholder',
|
||||||
|
size: 'compact',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Disabled: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Label',
|
||||||
|
placeholder: 'Placeholder',
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DisabledWithValue: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Full name',
|
||||||
|
defaultValue: 'Jane Smith',
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ReadOnly: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Account ID',
|
||||||
|
defaultValue: 'ACC-2024-001',
|
||||||
|
readOnly: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const CharacterCountExample = () => {
|
||||||
|
const [value, setValue] = useState('')
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
label="Bio"
|
||||||
|
placeholder="Tell us about yourself"
|
||||||
|
hint="Keep it brief"
|
||||||
|
maxLength={250}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithCharacterCount: Story = {
|
||||||
|
render: () => <CharacterCountExample />,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AllStates: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="flex max-w-sm flex-col gap-6">
|
||||||
|
<Input label="Default" placeholder="Placeholder" />
|
||||||
|
<Input label="With hint" placeholder="Placeholder" hint="Helpful hint text" />
|
||||||
|
<Input label="With value" defaultValue="Some value" />
|
||||||
|
<Input label="Error" defaultValue="Bad value" error="This field is required" />
|
||||||
|
<Input label="Disabled" placeholder="Placeholder" disabled />
|
||||||
|
<Input label="Disabled with value" defaultValue="Jane Smith" disabled />
|
||||||
|
<Input label="Read only" defaultValue="ACC-2024-001" readOnly />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AllSizes: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="flex max-w-sm flex-col gap-6">
|
||||||
|
<Input label="Default size" placeholder="48px height" size="default" />
|
||||||
|
<Input label="Compact size" placeholder="40px height" size="compact" />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithIcons: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="flex max-w-sm flex-col gap-6">
|
||||||
|
<Input label="Search" placeholder="Search..." leftIcon={<SearchIcon />} />
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
leftIcon={<MailIcon />}
|
||||||
|
rightIcon={<ChevronDownIcon />}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Search (compact)"
|
||||||
|
placeholder="Search..."
|
||||||
|
leftIcon={<SearchIcon />}
|
||||||
|
size="compact"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Stacked variant ---
|
||||||
|
|
||||||
|
export const Stacked: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Full name',
|
||||||
|
placeholder: 'Enter your full name',
|
||||||
|
variant: 'stacked',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StackedWithDescription: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Project title',
|
||||||
|
description: 'Choose a clear, descriptive name for your research project.',
|
||||||
|
placeholder: 'e.g. Student Wellbeing Survey 2026',
|
||||||
|
variant: 'stacked',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StackedWithHint: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Email address',
|
||||||
|
description: 'Your department email is preferred.',
|
||||||
|
placeholder: 'you@education.nsw.gov.au',
|
||||||
|
hint: 'We will use this for project notifications',
|
||||||
|
variant: 'stacked',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StackedWithError: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Email address',
|
||||||
|
description: 'Your department email is preferred.',
|
||||||
|
defaultValue: 'not-valid',
|
||||||
|
error: 'Please enter a valid email address',
|
||||||
|
variant: 'stacked',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StackedWithIcons: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="flex max-w-sm flex-col gap-6">
|
||||||
|
<Input
|
||||||
|
variant="stacked"
|
||||||
|
label="Search participants"
|
||||||
|
description="Find by name or ID."
|
||||||
|
placeholder="Search..."
|
||||||
|
leftIcon={<SearchIcon />}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
variant="stacked"
|
||||||
|
label="Contact email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
leftIcon={<MailIcon />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StackedAllStates: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="flex max-w-sm flex-col gap-6">
|
||||||
|
<Input variant="stacked" label="Default" placeholder="Placeholder" />
|
||||||
|
<Input
|
||||||
|
variant="stacked"
|
||||||
|
label="With description"
|
||||||
|
description="A short description of the field."
|
||||||
|
placeholder="Placeholder"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
variant="stacked"
|
||||||
|
label="With hint"
|
||||||
|
description="Description text here."
|
||||||
|
placeholder="Placeholder"
|
||||||
|
hint="Helpful hint text"
|
||||||
|
/>
|
||||||
|
<Input variant="stacked" label="With value" defaultValue="Some value" />
|
||||||
|
<Input
|
||||||
|
variant="stacked"
|
||||||
|
label="Error"
|
||||||
|
description="Description text here."
|
||||||
|
defaultValue="Bad value"
|
||||||
|
error="This field is required"
|
||||||
|
/>
|
||||||
|
<Input variant="stacked" label="Disabled" placeholder="Placeholder" disabled />
|
||||||
|
<Input
|
||||||
|
variant="stacked"
|
||||||
|
label="Disabled with description"
|
||||||
|
description="This field cannot be edited."
|
||||||
|
defaultValue="Jane Smith"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<Input variant="stacked" label="Read only" defaultValue="ACC-2024-001" readOnly />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
193
src/components/ui/Input/Input.tsx
Normal file
193
src/components/ui/Input/Input.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import { forwardRef, useId, type InputHTMLAttributes } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
label: string
|
||||||
|
description?: string
|
||||||
|
hint?: string
|
||||||
|
error?: string
|
||||||
|
variant?: 'outlined' | 'stacked'
|
||||||
|
size?: 'default' | 'compact'
|
||||||
|
leftIcon?: React.ReactNode
|
||||||
|
rightIcon?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeStyles: Record<string, { container: string; input: string; icon: string }> = {
|
||||||
|
default: {
|
||||||
|
container: 'h-12 gap-2',
|
||||||
|
input: 'text-body',
|
||||||
|
icon: 'size-6',
|
||||||
|
},
|
||||||
|
compact: {
|
||||||
|
container: 'h-10 gap-2',
|
||||||
|
input: 'text-small',
|
||||||
|
icon: 'size-5',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
hint,
|
||||||
|
error,
|
||||||
|
variant = 'outlined',
|
||||||
|
size = 'default',
|
||||||
|
leftIcon,
|
||||||
|
rightIcon,
|
||||||
|
disabled,
|
||||||
|
readOnly,
|
||||||
|
maxLength,
|
||||||
|
value,
|
||||||
|
defaultValue,
|
||||||
|
className,
|
||||||
|
id: idProp,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const autoId = useId()
|
||||||
|
const id = idProp ?? autoId
|
||||||
|
const descriptionId = `${id}-description`
|
||||||
|
const hintId = `${id}-hint`
|
||||||
|
const hasError = !!error
|
||||||
|
const supportiveText = error || hint
|
||||||
|
const styles = sizeStyles[size]
|
||||||
|
const isStacked = variant === 'stacked'
|
||||||
|
|
||||||
|
const currentLength =
|
||||||
|
maxLength != null && typeof value === 'string' ? value.length : undefined
|
||||||
|
|
||||||
|
const describedBy = [
|
||||||
|
description && isStacked ? descriptionId : undefined,
|
||||||
|
supportiveText ? hintId : undefined,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ') || undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex w-full flex-col', isStacked ? 'gap-1.5' : 'gap-1 pt-2', className)}>
|
||||||
|
{isStacked && (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<label
|
||||||
|
htmlFor={id}
|
||||||
|
className={cn(
|
||||||
|
'text-small font-bold',
|
||||||
|
hasError ? 'text-control-error' : 'text-control-label',
|
||||||
|
disabled && 'text-control-description',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
{description && (
|
||||||
|
<p
|
||||||
|
id={descriptionId}
|
||||||
|
className={cn('text-small text-grey-01', disabled && 'opacity-50')}
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative flex items-center rounded-[4px] border bg-control-bg px-3 transition-colors',
|
||||||
|
styles.container,
|
||||||
|
hasError
|
||||||
|
? 'border-control-error focus-within:border-2 focus-within:border-control-error focus-within:px-[11px]'
|
||||||
|
: 'border-control-border hover:border-control-border-hover focus-within:border-2 focus-within:border-control-checked focus-within:px-[11px]',
|
||||||
|
disabled && 'pointer-events-none border-control-border/50 bg-control-bg/50',
|
||||||
|
readOnly && 'border-transparent bg-control-bg-readonly',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!isStacked && (
|
||||||
|
<label
|
||||||
|
htmlFor={id}
|
||||||
|
className={cn(
|
||||||
|
'absolute left-2 top-0 z-10 -translate-y-1/2 bg-control-bg px-1 text-small font-bold leading-none',
|
||||||
|
hasError ? 'text-control-error' : 'text-control-label',
|
||||||
|
disabled && 'text-control-description',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{leftIcon && (
|
||||||
|
<span
|
||||||
|
className={cn('inline-flex shrink-0 items-center justify-center text-grey-01 [&>svg]:size-full', styles.icon)}
|
||||||
|
>
|
||||||
|
{leftIcon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
id={id}
|
||||||
|
disabled={disabled}
|
||||||
|
readOnly={readOnly}
|
||||||
|
maxLength={maxLength}
|
||||||
|
value={value}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
aria-invalid={hasError || undefined}
|
||||||
|
aria-describedby={describedBy}
|
||||||
|
className={cn(
|
||||||
|
'min-w-0 flex-1 bg-transparent font-normal text-grey-01 outline-none',
|
||||||
|
'placeholder:text-grey-01/50',
|
||||||
|
styles.input,
|
||||||
|
disabled && 'text-grey-01/50',
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{rightIcon && (
|
||||||
|
<span
|
||||||
|
className={cn('inline-flex shrink-0 items-center justify-center text-grey-01 [&>svg]:size-full', styles.icon)}
|
||||||
|
>
|
||||||
|
{rightIcon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(supportiveText || currentLength != null) && (
|
||||||
|
<div
|
||||||
|
id={hintId}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1 text-small',
|
||||||
|
hasError ? 'text-control-error' : 'text-control-description',
|
||||||
|
disabled && 'opacity-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{hasError && (
|
||||||
|
<svg
|
||||||
|
className="size-4 shrink-0"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<line x1="12" y1="8" x2="12" y2="12" />
|
||||||
|
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{supportiveText && <p className="flex-1">{supportiveText}</p>}
|
||||||
|
{currentLength != null && (
|
||||||
|
<p className="shrink-0 text-right">
|
||||||
|
{currentLength}/{maxLength}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
Input.displayName = 'Input'
|
||||||
2
src/components/ui/Input/index.ts
Normal file
2
src/components/ui/Input/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { Input } from './Input'
|
||||||
|
export type { InputProps } from './Input'
|
||||||
138
src/components/ui/Radio/Radio.stories.tsx
Normal file
138
src/components/ui/Radio/Radio.stories.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||||
|
import { Radio, RadioGroup } from './Radio'
|
||||||
|
|
||||||
|
const meta: Meta<typeof RadioGroup> = {
|
||||||
|
title: 'UI/Radio',
|
||||||
|
component: RadioGroup,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
design: {
|
||||||
|
type: 'figma',
|
||||||
|
url: 'https://www.figma.com/design/mrabO6AtxN3ektGiTk0I9c/ResearchInsights?node-id=33-5188',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
const ControlledExample = () => {
|
||||||
|
const [value, setValue] = useState('email')
|
||||||
|
return (
|
||||||
|
<RadioGroup label="Notification method" value={value} onChange={setValue}>
|
||||||
|
<Radio value="email" label="Email" />
|
||||||
|
<Radio value="sms" label="SMS" />
|
||||||
|
<Radio value="push" label="Push notification" />
|
||||||
|
</RadioGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
render: () => <ControlledExample />,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithDescriptions: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [value, setValue] = useState('standard')
|
||||||
|
return (
|
||||||
|
<RadioGroup
|
||||||
|
label="Export format"
|
||||||
|
description="Choose how your data will be exported."
|
||||||
|
value={value}
|
||||||
|
onChange={setValue}
|
||||||
|
>
|
||||||
|
<Radio
|
||||||
|
value="standard"
|
||||||
|
label="Standard CSV"
|
||||||
|
description="Comma-separated values, compatible with most tools."
|
||||||
|
/>
|
||||||
|
<Radio
|
||||||
|
value="excel"
|
||||||
|
label="Excel workbook"
|
||||||
|
description="Formatted spreadsheet with multiple sheets."
|
||||||
|
/>
|
||||||
|
<Radio
|
||||||
|
value="json"
|
||||||
|
label="JSON"
|
||||||
|
description="Machine-readable format for programmatic access."
|
||||||
|
/>
|
||||||
|
</RadioGroup>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Horizontal: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [value, setValue] = useState('all')
|
||||||
|
return (
|
||||||
|
<RadioGroup
|
||||||
|
label="Filter by status"
|
||||||
|
orientation="horizontal"
|
||||||
|
value={value}
|
||||||
|
onChange={setValue}
|
||||||
|
>
|
||||||
|
<Radio value="all" label="All" />
|
||||||
|
<Radio value="active" label="Active" />
|
||||||
|
<Radio value="archived" label="Archived" />
|
||||||
|
</RadioGroup>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithError: Story = {
|
||||||
|
render: () => (
|
||||||
|
<RadioGroup label="Participant type" error="Please select a participant type">
|
||||||
|
<Radio value="student" label="Student" />
|
||||||
|
<Radio value="teacher" label="Teacher" />
|
||||||
|
<Radio value="parent" label="Parent/carer" />
|
||||||
|
</RadioGroup>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Disabled: Story = {
|
||||||
|
render: () => (
|
||||||
|
<RadioGroup label="Plan" disabled defaultValue="free">
|
||||||
|
<Radio value="free" label="Free" />
|
||||||
|
<Radio value="pro" label="Professional" />
|
||||||
|
<Radio value="enterprise" label="Enterprise" />
|
||||||
|
</RadioGroup>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AllStates: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<RadioGroup label="Default group" defaultValue="a">
|
||||||
|
<Radio value="a" label="Option A" />
|
||||||
|
<Radio value="b" label="Option B" />
|
||||||
|
<Radio value="c" label="Option C" />
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
<RadioGroup
|
||||||
|
label="With descriptions"
|
||||||
|
description="Pick one of the following."
|
||||||
|
defaultValue="x"
|
||||||
|
>
|
||||||
|
<Radio value="x" label="Option X" description="Description for option X." />
|
||||||
|
<Radio value="y" label="Option Y" description="Description for option Y." />
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
<RadioGroup label="With error" error="Selection required">
|
||||||
|
<Radio value="1" label="Choice 1" />
|
||||||
|
<Radio value="2" label="Choice 2" />
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
<RadioGroup label="Disabled" disabled defaultValue="on">
|
||||||
|
<Radio value="on" label="Enabled" />
|
||||||
|
<Radio value="off" label="Disabled" />
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
<RadioGroup label="Horizontal" orientation="horizontal" defaultValue="left">
|
||||||
|
<Radio value="left" label="Left" />
|
||||||
|
<Radio value="center" label="Centre" />
|
||||||
|
<Radio value="right" label="Right" />
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
205
src/components/ui/Radio/Radio.tsx
Normal file
205
src/components/ui/Radio/Radio.tsx
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
forwardRef,
|
||||||
|
useContext,
|
||||||
|
useId,
|
||||||
|
type InputHTMLAttributes,
|
||||||
|
} from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface RadioGroupContextValue {
|
||||||
|
name: string
|
||||||
|
value?: string
|
||||||
|
disabled?: boolean
|
||||||
|
hasError?: boolean
|
||||||
|
onChange?: (value: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const RadioGroupContext = createContext<RadioGroupContextValue | null>(null)
|
||||||
|
|
||||||
|
export interface RadioGroupProps {
|
||||||
|
label?: string
|
||||||
|
description?: string
|
||||||
|
error?: string
|
||||||
|
value?: string
|
||||||
|
defaultValue?: string
|
||||||
|
disabled?: boolean
|
||||||
|
orientation?: 'vertical' | 'horizontal'
|
||||||
|
name?: string
|
||||||
|
onChange?: (value: string) => void
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RadioGroup = forwardRef<HTMLFieldSetElement, RadioGroupProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
error,
|
||||||
|
value,
|
||||||
|
defaultValue,
|
||||||
|
disabled,
|
||||||
|
orientation = 'vertical',
|
||||||
|
name: nameProp,
|
||||||
|
onChange,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const autoId = useId()
|
||||||
|
const name = nameProp ?? autoId
|
||||||
|
const descriptionId = `${name}-description`
|
||||||
|
const errorId = `${name}-error`
|
||||||
|
const hasError = !!error
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RadioGroupContext.Provider value={{ name, value: value ?? defaultValue, disabled, hasError, onChange }}>
|
||||||
|
<fieldset
|
||||||
|
ref={ref}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn('flex flex-col gap-1.5', className)}
|
||||||
|
aria-describedby={
|
||||||
|
[description ? descriptionId : undefined, hasError ? errorId : undefined]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ') || undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(label || description) && (
|
||||||
|
<div className="mb-1 flex flex-col gap-0.5">
|
||||||
|
{label && (
|
||||||
|
<legend
|
||||||
|
className={cn(
|
||||||
|
'text-small font-bold',
|
||||||
|
hasError ? 'text-control-error' : 'text-control-label',
|
||||||
|
disabled && 'opacity-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</legend>
|
||||||
|
)}
|
||||||
|
{description && (
|
||||||
|
<p
|
||||||
|
id={descriptionId}
|
||||||
|
className={cn('text-small text-text', disabled && 'opacity-50')}
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex gap-3',
|
||||||
|
orientation === 'vertical' ? 'flex-col' : 'flex-row flex-wrap',
|
||||||
|
)}
|
||||||
|
role="radiogroup"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{hasError && (
|
||||||
|
<div id={errorId} className="flex items-center gap-1 text-small text-control-error">
|
||||||
|
<svg
|
||||||
|
className="size-4 shrink-0"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<line x1="12" y1="8" x2="12" y2="12" />
|
||||||
|
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||||
|
</svg>
|
||||||
|
<p>{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</fieldset>
|
||||||
|
</RadioGroupContext.Provider>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
RadioGroup.displayName = 'RadioGroup'
|
||||||
|
|
||||||
|
export interface RadioProps
|
||||||
|
extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'size'> {
|
||||||
|
label?: string
|
||||||
|
description?: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Radio = forwardRef<HTMLInputElement, RadioProps>(
|
||||||
|
({ label, description, value, disabled: disabledProp, className, id: idProp, ...props }, ref) => {
|
||||||
|
const autoId = useId()
|
||||||
|
const id = idProp ?? autoId
|
||||||
|
const descriptionId = `${id}-description`
|
||||||
|
const group = useContext(RadioGroupContext)
|
||||||
|
const name = group?.name
|
||||||
|
const isChecked = group?.value != null ? group.value === value : undefined
|
||||||
|
const disabled = disabledProp ?? group?.disabled
|
||||||
|
const hasError = group?.hasError
|
||||||
|
|
||||||
|
const handleChange = () => {
|
||||||
|
group?.onChange?.(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex gap-2', className)}>
|
||||||
|
<div className="flex h-6 items-center">
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type="radio"
|
||||||
|
id={id}
|
||||||
|
name={name}
|
||||||
|
value={value}
|
||||||
|
checked={isChecked}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={handleChange}
|
||||||
|
aria-describedby={description ? descriptionId : undefined}
|
||||||
|
className={cn(
|
||||||
|
'peer size-5 cursor-pointer appearance-none rounded-full border-2 border-control-border bg-control-bg transition-colors',
|
||||||
|
'hover:border-control-border-hover',
|
||||||
|
'checked:border-[6px] checked:border-control-checked',
|
||||||
|
'checked:hover:border-control-checked-hover',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-control-focus-ring focus-visible:ring-offset-1',
|
||||||
|
'active:scale-95',
|
||||||
|
'disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
hasError && 'border-control-error checked:border-control-error',
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(label || description) && (
|
||||||
|
<div className="flex flex-col gap-0.5 pt-px">
|
||||||
|
{label && (
|
||||||
|
<label
|
||||||
|
htmlFor={id}
|
||||||
|
className={cn(
|
||||||
|
'cursor-pointer text-body font-normal text-grey-01',
|
||||||
|
disabled && 'pointer-events-none opacity-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{description && (
|
||||||
|
<p
|
||||||
|
id={descriptionId}
|
||||||
|
className={cn('text-small text-text', disabled && 'opacity-50')}
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
Radio.displayName = 'Radio'
|
||||||
2
src/components/ui/Radio/index.ts
Normal file
2
src/components/ui/Radio/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { Radio, RadioGroup } from './Radio'
|
||||||
|
export type { RadioProps, RadioGroupProps } from './Radio'
|
||||||
85
src/components/ui/Switch/Switch.stories.tsx
Normal file
85
src/components/ui/Switch/Switch.stories.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||||
|
import { Switch } from './Switch'
|
||||||
|
|
||||||
|
const meta: Meta<typeof Switch> = {
|
||||||
|
title: 'UI/Switch',
|
||||||
|
component: Switch,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
argTypes: {
|
||||||
|
label: { control: 'text' },
|
||||||
|
description: { control: 'text' },
|
||||||
|
checked: { control: 'boolean' },
|
||||||
|
disabled: { control: 'boolean' },
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
design: {
|
||||||
|
type: 'figma',
|
||||||
|
url: 'https://www.figma.com/design/mrabO6AtxN3ektGiTk0I9c/ResearchInsights?node-id=33-5337',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
const ControlledSwitch = (props: React.ComponentProps<typeof Switch>) => {
|
||||||
|
const [checked, setChecked] = useState(props.checked ?? false)
|
||||||
|
return <Switch {...props} checked={checked} onChange={setChecked} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
render: () => <ControlledSwitch label="Enable notifications" />,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const On: Story = {
|
||||||
|
render: () => <ControlledSwitch label="Enable notifications" checked />,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithDescription: Story = {
|
||||||
|
render: () => (
|
||||||
|
<ControlledSwitch
|
||||||
|
label="Auto-save responses"
|
||||||
|
description="Automatically save participant responses as they are entered."
|
||||||
|
checked
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Disabled: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Switch label="Disabled off" disabled />
|
||||||
|
<Switch label="Disabled on" disabled checked />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Standalone: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-body text-grey-01">Dark mode</span>
|
||||||
|
<ControlledSwitch aria-label="Toggle dark mode" />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AllStates: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<ControlledSwitch label="Off" />
|
||||||
|
<ControlledSwitch label="On" checked />
|
||||||
|
<ControlledSwitch
|
||||||
|
label="With description"
|
||||||
|
description="Additional context about this setting."
|
||||||
|
/>
|
||||||
|
<ControlledSwitch
|
||||||
|
label="On with description"
|
||||||
|
description="This feature is currently enabled."
|
||||||
|
checked
|
||||||
|
/>
|
||||||
|
<Switch label="Disabled off" disabled />
|
||||||
|
<Switch label="Disabled on" disabled checked />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
89
src/components/ui/Switch/Switch.tsx
Normal file
89
src/components/ui/Switch/Switch.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { forwardRef, useId, type ButtonHTMLAttributes } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface SwitchProps
|
||||||
|
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'onChange' | 'role'> {
|
||||||
|
label?: string
|
||||||
|
description?: string
|
||||||
|
checked?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
onChange?: (checked: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Switch = forwardRef<HTMLButtonElement, SwitchProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
checked = false,
|
||||||
|
disabled,
|
||||||
|
onChange,
|
||||||
|
className,
|
||||||
|
id: idProp,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const autoId = useId()
|
||||||
|
const id = idProp ?? autoId
|
||||||
|
const descriptionId = `${id}-description`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex items-start gap-3', className)}>
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
id={id}
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={checked}
|
||||||
|
aria-describedby={description ? descriptionId : undefined}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onChange?.(!checked)}
|
||||||
|
className={cn(
|
||||||
|
'relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full transition-colors duration-150',
|
||||||
|
checked ? 'bg-control-checked' : 'bg-control-border',
|
||||||
|
!disabled && checked && 'hover:bg-control-checked-hover',
|
||||||
|
!disabled && !checked && 'hover:bg-control-border-hover',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-control-focus-ring focus-visible:ring-offset-2',
|
||||||
|
'active:scale-[0.97]',
|
||||||
|
'disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none inline-block size-[18px] rounded-full bg-white shadow-default transition-transform duration-150',
|
||||||
|
checked ? 'translate-x-[22px]' : 'translate-x-[3px]',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{(label || description) && (
|
||||||
|
<div className="flex flex-col gap-0.5 pt-px">
|
||||||
|
{label && (
|
||||||
|
<label
|
||||||
|
htmlFor={id}
|
||||||
|
className={cn(
|
||||||
|
'cursor-pointer text-body font-normal text-grey-01',
|
||||||
|
disabled && 'pointer-events-none opacity-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{description && (
|
||||||
|
<p
|
||||||
|
id={descriptionId}
|
||||||
|
className={cn('text-small text-text', disabled && 'opacity-50')}
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
Switch.displayName = 'Switch'
|
||||||
2
src/components/ui/Switch/index.ts
Normal file
2
src/components/ui/Switch/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { Switch } from './Switch'
|
||||||
|
export type { SwitchProps } from './Switch'
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
|
@import "@fontsource-variable/public-sans";
|
||||||
|
@import "@fontsource-variable/public-sans/wght-italic.css";
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "../tokens/tokens.css";
|
@import "../tokens/tokens.css";
|
||||||
|
@import "./typography.css";
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
html {
|
|
||||||
font-size: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
font-family: var(--font-sans);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
background-color: var(--color-bg);
|
background-color: var(--color-bg);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
|
|||||||
41
src/styles/typography.css
Normal file
41
src/styles/typography.css
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
@utility text-body-strong {
|
||||||
|
font-size: var(--text-body);
|
||||||
|
line-height: var(--text-body--line-height);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility text-small-strong {
|
||||||
|
font-size: var(--text-small);
|
||||||
|
line-height: var(--text-small--line-height);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility text-label {
|
||||||
|
font-size: var(--text-h6);
|
||||||
|
line-height: var(--text-h6--line-height);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility text-overline {
|
||||||
|
font-size: var(--text-caption);
|
||||||
|
line-height: var(--text-caption--line-height);
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility text-body-link {
|
||||||
|
font-size: var(--text-body);
|
||||||
|
line-height: var(--text-body--line-height);
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration-line: underline;
|
||||||
|
color: var(--color-blue-02);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility text-small-link {
|
||||||
|
font-size: var(--text-small);
|
||||||
|
line-height: var(--text-small--line-height);
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration-line: underline;
|
||||||
|
color: var(--color-blue-02);
|
||||||
|
}
|
||||||
@@ -1,29 +1,86 @@
|
|||||||
@theme {
|
@theme {
|
||||||
/* Surface colors */
|
/* Typography - Font Family */
|
||||||
--color-bg: #f5f6f8;
|
--font-sans: 'Public Sans Variable', system-ui, sans-serif;
|
||||||
--color-surface: #ffffff;
|
|
||||||
--color-border: #e2e5ea;
|
|
||||||
|
|
||||||
/* Text colors */
|
/* Typography - Font Sizes & Line Heights */
|
||||||
--color-text: #1a1d23;
|
--text-h1: 48px;
|
||||||
--color-text-secondary: #5f6672;
|
--text-h1--line-height: 1.25;
|
||||||
|
--text-h2: 32px;
|
||||||
|
--text-h2--line-height: 1.25;
|
||||||
|
--text-h3: 24px;
|
||||||
|
--text-h3--line-height: 1.333;
|
||||||
|
--text-h4: 20px;
|
||||||
|
--text-h4--line-height: 1.4;
|
||||||
|
--text-h5: 16px;
|
||||||
|
--text-h5--line-height: 1.5;
|
||||||
|
--text-h6: 14px;
|
||||||
|
--text-h6--line-height: 1.43;
|
||||||
|
--text-intro: 20px;
|
||||||
|
--text-intro--line-height: 1.4;
|
||||||
|
--text-body: 16px;
|
||||||
|
--text-body--line-height: 1.5;
|
||||||
|
--text-small: 14px;
|
||||||
|
--text-small--line-height: 1.357;
|
||||||
|
--text-caption: 12px;
|
||||||
|
--text-caption--line-height: 1.5;
|
||||||
|
|
||||||
/* Primary */
|
/* Blues */
|
||||||
--color-primary: #2563eb;
|
--color-blue-01: #002664;
|
||||||
--color-primary-hover: #1d4ed8;
|
--color-blue-02: #146CFD;
|
||||||
--color-primary-light: #eff4ff;
|
--color-blue-03: #69B3E7;
|
||||||
|
--color-blue-04: #CBEDFD;
|
||||||
|
--color-blue-05: #EBF5FF; /* extrapolated: ultra-light background */
|
||||||
|
|
||||||
/* Semantic: Success */
|
/* Reds */
|
||||||
--color-success: #16a34a;
|
--color-red-01: #3E0014;
|
||||||
--color-success-bg: #dcfce7;
|
--color-red-02: #D7153A;
|
||||||
|
--color-red-03: #F5C5D0;
|
||||||
|
--color-red-04: #FDDDE5;
|
||||||
|
--color-red-05: #FFF5F8; /* extrapolated: ultra-light background */
|
||||||
|
|
||||||
/* Semantic: Warning */
|
/* Oranges */
|
||||||
--color-warning: #d97706;
|
--color-orange-01: #7A3300; /* extrapolated: dark */
|
||||||
--color-warning-bg: #fef9c3;
|
--color-orange-02: #EC6608;
|
||||||
|
--color-orange-03: #F5B98A;
|
||||||
|
--color-orange-04: #FEF0E4; /* extrapolated: light background */
|
||||||
|
|
||||||
/* Semantic: Error */
|
/* Greens */
|
||||||
--color-error: #dc2626;
|
--color-green-01: #005C35; /* extrapolated: dark */
|
||||||
--color-error-bg: #fee2e2;
|
--color-green-02: #00A651;
|
||||||
|
--color-green-03: #89E5B3;
|
||||||
|
--color-green-04: #E0F8EA; /* extrapolated: light background */
|
||||||
|
|
||||||
|
/* Greys */
|
||||||
|
--color-grey-01: #22272B;
|
||||||
|
--color-grey-02: #6D7278;
|
||||||
|
--color-grey-03: #C0C0C0;
|
||||||
|
--color-grey-04: #E0E0E0;
|
||||||
|
--color-off-white: #F4F4F4;
|
||||||
|
--color-white: #FFFFFF;
|
||||||
|
|
||||||
|
/* Semantic Aliases */
|
||||||
|
--color-primary: var(--color-blue-02);
|
||||||
|
--color-primary-dark: var(--color-blue-01);
|
||||||
|
--color-error: var(--color-red-02);
|
||||||
|
--color-success: var(--color-green-02);
|
||||||
|
--color-warning: var(--color-orange-02);
|
||||||
|
--color-text: var(--color-grey-01);
|
||||||
|
--color-text-secondary: var(--color-grey-02);
|
||||||
|
--color-border: var(--color-grey-04);
|
||||||
|
--color-bg: var(--color-off-white);
|
||||||
|
--color-surface: var(--color-white);
|
||||||
|
|
||||||
|
/* Form Controls */
|
||||||
|
--color-control-border: var(--color-grey-03);
|
||||||
|
--color-control-border-hover: var(--color-grey-01);
|
||||||
|
--color-control-checked: var(--color-blue-01);
|
||||||
|
--color-control-checked-hover: var(--color-blue-02);
|
||||||
|
--color-control-focus-ring: var(--color-blue-04);
|
||||||
|
--color-control-label: var(--color-blue-01);
|
||||||
|
--color-control-description: var(--color-grey-02);
|
||||||
|
--color-control-error: var(--color-red-02);
|
||||||
|
--color-control-bg: var(--color-white);
|
||||||
|
--color-control-bg-readonly: var(--color-off-white);
|
||||||
|
|
||||||
/* Radius */
|
/* Radius */
|
||||||
--radius-sm: 4px;
|
--radius-sm: 4px;
|
||||||
|
|||||||
Reference in New Issue
Block a user