Compare commits
24 Commits
592635e7ce
...
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| ce1efd1c13 | |||
| 58a67dfc75 | |||
| 6d3331f802 | |||
| 69751eb6f2 | |||
| 1c87e23e5d | |||
| d36330084a | |||
| b8fb8c63c6 | |||
| df7bbba915 | |||
| 95f72407f8 | |||
| d915443b8c | |||
| f4fd1fc04b | |||
| 722475215d | |||
| d696619e4e | |||
| 4be996789e | |||
| e025c0eb34 | |||
| c00335ef84 | |||
| 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
37
.github/workflows/publish-package.yml
vendored
Normal file
37
.github/workflows/publish-package.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
name: Publish package to GitHub Packages
|
||||
|
||||
# Publishes @richiesnitch/ads3-design-system to GitHub Packages when a version
|
||||
# tag (v*) is pushed, or on manual dispatch. Uses the workflow's built-in
|
||||
# GITHUB_TOKEN (no PAT needed) — packages:write permission is granted below.
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
registry-url: https://npm.pkg.github.com
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build library
|
||||
run: npm run build:lib
|
||||
|
||||
- name: Publish
|
||||
run: npm publish
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -25,3 +25,9 @@ dist-ssr
|
||||
|
||||
*storybook.log
|
||||
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-a11y",
|
||||
"@storybook/addon-docs",
|
||||
"@storybook/addon-designs",
|
||||
"@storybook/addon-mcp"
|
||||
],
|
||||
"framework": "@storybook/react-vite"
|
||||
|
||||
132
ARCHITECTURE.md
132
ARCHITECTURE.md
@@ -1,23 +1,28 @@
|
||||
# ARCHITECTURE.md — SDC-Frontend
|
||||
# ARCHITECTURE.md — ADS 3.0 Design System
|
||||
|
||||
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.
|
||||
This is the living architecture document for the ADS 3.0 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:
|
||||
ADS 3.0 Design System is a React component library implementing the ADS 3.0 (Adaptive Design System) design language. It provides tokens, primitives, and composite components as a shared foundation. Application-specific screens and domain logic belong in **consuming applications**, not in this repo.
|
||||
|
||||
1. **Design system** — tokens, primitives, composite components
|
||||
2. **Application screens** — the synthesiser UI rebuilt on top of the design system
|
||||
### Distribution
|
||||
|
||||
ADS is distributed as a **versioned npm package** consumed by downstream apps — not by forking this repo. (The earlier fork-based model has been superseded.)
|
||||
|
||||
- Published to **GitHub Packages** as `@richiesnitch/ads3-design-system` from `Richiesnitch/ads3-design-system`; a publish-on-tag GitHub Action runs `build:lib` and `npm publish`.
|
||||
- Built via `npm run build:lib` (`vite.lib.config.ts`): bundles `src/index.ts` to ESM with React/react-dom externalised and internal `@/` imports resolved at build time; emits type declarations and copies `tokens.css` / `typography.css` into `dist/`.
|
||||
- Consumers import components from the package entry, `@richiesnitch/ads3-design-system/tokens` + `/typography` for the design-token CSS, and point Tailwind's `@source` at the built bundle to generate utilities. No `@/` alias or sibling-folder clone required.
|
||||
|
||||
---
|
||||
|
||||
## 2. Token Pipeline
|
||||
|
||||
```
|
||||
Penpot (design tool, self-hosted)
|
||||
↓ Penpot MCP / manual export
|
||||
Figma (design tool)
|
||||
↓ Figma MCP / get_variable_defs
|
||||
src/tokens/tokens.css (@theme block)
|
||||
↓ Tailwind CSS v4 reads @theme
|
||||
↓ Generates utility classes + CSS custom properties
|
||||
@@ -26,8 +31,30 @@ Components (use Tailwind utilities or var() references)
|
||||
Storybook (visual verification)
|
||||
```
|
||||
|
||||
### Token Layers
|
||||
Tokens are structured in three layers:
|
||||
|
||||
1. **Palette** — raw colour values (`--color-blue-01`, `--color-grey-03`). Not used directly in components.
|
||||
2. **Semantic** — purpose-based aliases (`--color-primary`, `--color-error`, `--color-text`). General UI usage.
|
||||
3. **Form control** — shared interactive-state tokens for all form components (`--color-control-border`, `--color-control-checked`, `--color-control-label`, etc.). Ensures consistent styling across Input, Checkbox, Radio, Switch, Select, and future form primitives.
|
||||
4. **Button** — dedicated tokens for the Button component's intent system (`--color-button-default`, `--color-button-danger`, `--color-button-neutral`, `--color-button-subtle-bg`, `--color-button-subtle-text`).
|
||||
5. **Badge** — status colour tokens for the Badge component's variant system (`--color-badge-info`, `--color-badge-success`, `--color-badge-error`, `--color-badge-warning`, `--color-badge-neutral`, `--color-badge-text`).
|
||||
6. **Chip** — tokens for the Chip component's border/fill states (`--color-chip-border`, `--color-chip-text`, `--color-chip-bg`, `--color-chip-selected-bg`, `--color-chip-selected-text`).
|
||||
7. **Tag** — colour tokens for the Tag component's 11-colour system (`--color-tag-navy`, `--color-tag-blue`, `--color-tag-green`, `--color-tag-red`, `--color-tag-orange`, `--color-tag-grey`, `--color-tag-teal`, `--color-tag-brown`, `--color-tag-purple`, `--color-tag-fuchsia`, `--color-tag-yellow`, plus `-light` variants for each).
|
||||
8. **Alert** — background, border, and icon colour tokens for 5 alert variants (`--color-alert-{variant}-bg`, `--color-alert-{variant}-border`, `--color-alert-{variant}-icon` for info, warning, error, success, neutral).
|
||||
9. **Switch** — dedicated on-state tokens (`--color-switch-on`, `--color-switch-on-hover`) using success green per ADS 3.0.
|
||||
10. **Avatar** — background and text colour tokens (`--color-avatar`, `--color-avatar-text`).
|
||||
11. **TopBar** — background colour token (`--color-topbar`).
|
||||
12. **SideNav** — navigation-specific tokens (`--color-nav-bg`, `--color-nav-text`, `--color-nav-icon`, `--color-nav-active`, `--color-nav-divider`).
|
||||
|
||||
### Token Categories
|
||||
- **Colours**: `--color-*` (bg, surface, border, text, primary, success, warning, error)
|
||||
- **Palette colours**: `--color-{palette}-{shade}` — 10 families (blue, red, orange, green, teal, brown, purple, fuchsia, yellow, grey) × 4 shades each
|
||||
- **Semantic colours**: `--color-{purpose}` (e.g., `--color-primary` = navy, `--color-info` = bright blue, `--color-error`, `--color-text`)
|
||||
- **Form control colours**: `--color-control-{role}` (e.g., `--color-control-border`, `--color-control-checked`)
|
||||
- **Button colours**: `--color-button-{intent}` (e.g., `--color-button-default`, `--color-button-danger`)
|
||||
- **Badge colours**: `--color-badge-{variant}` (e.g., `--color-badge-info`, `--color-badge-error`)
|
||||
- **Chip colours**: `--color-chip-{role}` (e.g., `--color-chip-border`, `--color-chip-selected-bg`)
|
||||
- **Tag colours**: `--color-tag-{color}` and `--color-tag-{color}-light` (e.g., `--color-tag-blue`, `--color-tag-blue-light`)
|
||||
- **Radii**: `--radius-*` (sm, default, lg, full)
|
||||
- **Shadows**: `--shadow-*` (default, md)
|
||||
|
||||
@@ -38,22 +65,50 @@ Declaring `--color-primary: #2563eb` inside `@theme` in `tokens.css` automatical
|
||||
|
||||
## 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/atoms/` — Atoms
|
||||
Single-purpose, atomic building blocks. Each wraps a single native element or interaction pattern with no domain logic.
|
||||
- Button, IconButton
|
||||
- Input, Textarea, Select, Autocomplete
|
||||
- Checkbox, Radio/RadioGroup, Switch
|
||||
- Slider, RangeSlider
|
||||
- FileInput
|
||||
- Badge, Tag, Chip
|
||||
- Tabs (TabList, Tab, TabPanel)
|
||||
- List (ListItem, ListSubheader, ListDivider)
|
||||
- Avatar, Tooltip
|
||||
|
||||
### `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/molecules/` — Molecules
|
||||
Small compositions of atoms into reusable units. May combine icons, text, buttons, or other atoms.
|
||||
- Alert, Accordion, Card, Dialog, Popover
|
||||
- DataTable
|
||||
|
||||
### `src/components/layout/` — Layout
|
||||
Page-level structural components.
|
||||
- AppShell (header + sidebar + content area)
|
||||
- PageHeader
|
||||
### `src/components/organisms/` — Organisms
|
||||
Larger compositions that carry domain semantics or define page-level regions. Built from atoms and molecules.
|
||||
- TopBar, SideNav, PageHeader
|
||||
- *(planned)* DatePicker
|
||||
|
||||
### `src/components/templates/` — Templates
|
||||
Page-level layout components that define the shell and content structure. Templates accept typed slot props (ReactNode) for their sections, making them composable by AI agents and developers. They do not own content — they define where content goes.
|
||||
- **AppShell** — TopBar + SideNav + scrollable content area. All pages render inside this.
|
||||
- **DashboardPage** — PageHeader + stat cards row + responsive 2-column content grid
|
||||
- **ListPage** — PageHeader + stat cards + list header with actions + scrollable item list
|
||||
- **FormPage** — PageHeader + optional action bar + optional vertical stepper + constrained-width form content
|
||||
- **DetailPage** — PageHeader + optional action bar (e.g. tabs) + single-column constrained content for viewing records/profiles/documents
|
||||
- **CenteredPage** — TopBar (optional) + horizontally/vertically centered content, no sidebar. For login, error, onboarding flows
|
||||
|
||||
Templates have Storybook stories tagged `['autodocs', 'template']` that show realistic "recipe" compositions — full pages built from real components with sample data. These serve as reference implementations for AI coding agents.
|
||||
|
||||
### Which Tier Does a Component Belong To?
|
||||
|
||||
| Question | If yes → |
|
||||
|---|---|
|
||||
| Does it wrap a single native element or a single interaction pattern (button, input, toggle)? | **atoms/** |
|
||||
| Does it compose 2+ atoms into a reusable unit (e.g., Alert = icon + text + close button)? | **molecules/** |
|
||||
| Does it carry domain-specific naming or logic (e.g., ThemeCard, ParticipantRow)? | **organisms/** |
|
||||
| Does it define a page-level region or shell (header, sidebar, content area)? | **organisms/** |
|
||||
| Does it define the layout structure of a full page (slot-based, no owned content)? | **templates/** |
|
||||
|
||||
When in doubt: start in `atoms/`. Promote to `molecules/` when a component begins importing other atoms.
|
||||
|
||||
---
|
||||
|
||||
@@ -62,6 +117,7 @@ Page-level structural components.
|
||||
- **Primary**: Tailwind utility classes
|
||||
- **Conditional classes**: `cn()` from `@/lib/utils` (clsx + tailwind-merge)
|
||||
- **Token values**: Always from `src/tokens/tokens.css`, never hardcoded
|
||||
- **Token discipline**: Components reference semantic or domain-specific tokens (form-control, button), not palette tokens. If the needed token doesn't exist, add it to `tokens.css` before using a raw palette value.
|
||||
- **No CSS modules, no styled-components, no inline styles** (except truly dynamic values)
|
||||
- **Class ordering**: Enforced by `prettier-plugin-tailwindcss`
|
||||
|
||||
@@ -82,9 +138,10 @@ Page-level structural components.
|
||||
```
|
||||
src/
|
||||
├── components/
|
||||
│ ├── ui/ # Primitives
|
||||
│ ├── composite/ # Composed components
|
||||
│ └── layout/ # Layout components
|
||||
│ ├── atoms/ # Single-purpose elements
|
||||
│ ├── molecules/ # Small compositions of atoms
|
||||
│ ├── organisms/ # Domain-aware / page-level components
|
||||
│ └── templates/ # Page-level layout components (slot-based)
|
||||
├── tokens/
|
||||
│ └── tokens.css # Design tokens (@theme block)
|
||||
├── styles/
|
||||
@@ -92,7 +149,6 @@ src/
|
||||
├── lib/
|
||||
│ └── utils.ts # cn() utility
|
||||
├── hooks/ # Custom React hooks
|
||||
├── pages/ # App screens (Phase 2)
|
||||
├── App.tsx # Root component
|
||||
└── main.tsx # Vite entry point
|
||||
```
|
||||
@@ -101,12 +157,26 @@ src/
|
||||
|
||||
## 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
|
||||
### Figma
|
||||
- Project file: https://www.figma.com/design/mrabO6AtxN3ektGiTk0I9c/ResearchInsights (file key: `mrabO6AtxN3ektGiTk0I9c`)
|
||||
- MCP server: Official Figma Remote MCP at `https://mcp.figma.com/mcp` (HTTP transport, OAuth auth)
|
||||
- Key tools: `get_design_context`, `get_variable_defs`, `get_screenshot`, `search_design_system`, `use_figma`
|
||||
- Design tokens extracted via `get_variable_defs` → mapped to `@theme` values in `tokens.css`
|
||||
|
||||
### Code Connect
|
||||
- Links Figma components to their React implementations
|
||||
- Once linked, `get_design_context` returns actual component code instead of generic markup
|
||||
- Mapped as we build each component via `add_code_connect_map` (label: "React")
|
||||
|
||||
### Storybook MCP
|
||||
- Available at `localhost:6006/mcp` when Storybook dev server is running
|
||||
- Provides: component listing, documentation retrieval, story generation, a11y testing
|
||||
- `@storybook/addon-designs` embeds Figma frames in the story panel for side-by-side comparison
|
||||
- `@storybook/addon-mcp` serves the MCP endpoint
|
||||
|
||||
### claude2figma Skills
|
||||
- 4 skills in `.claude/skills/` that enforce design system compliance when writing to Figma
|
||||
- **figma-preflight**: Validates MCP connection, audits libraries, builds a Token Map of all styles/variables
|
||||
- **component-rules**: Library-first lookup, Auto Layout conventions, semantic node naming
|
||||
- **figma-style-binding**: All visual values must bind to Figma Styles or Variables, never hardcoded; includes post-write QA
|
||||
- **reference-interpreter**: Converts screenshots/references into structured Design Briefs mapped to design tokens
|
||||
|
||||
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
|
||||
91
README.md
91
README.md
@@ -1,73 +1,32 @@
|
||||
# React + TypeScript + Vite
|
||||
# ADS 3.0 Design System
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
A React component library implementing the ADS 3.0 (Adaptive Design System) design language. Built with React 19, TypeScript, Tailwind CSS v4, and Storybook 10.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
## Getting Started
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```bash
|
||||
npm install
|
||||
npm run storybook # Component development at localhost:6006
|
||||
npm run dev # Vite dev server
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
## Architecture
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
- **Tokens** — Design tokens in `src/tokens/tokens.css` as a Tailwind v4 `@theme` block
|
||||
- **Atoms** — Single-purpose elements (Button, Input, Badge, etc.)
|
||||
- **Molecules** — Small compositions (Alert, Dialog, Card, Accordion)
|
||||
- **Organisms** — Page-level regions (AppShell, TabBar)
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
## Usage as a Base
|
||||
|
||||
This repo is designed to be forked for specific applications. Fork it, then build your application screens and domain logic on top of the shared component set.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| React 19 | UI framework |
|
||||
| TypeScript (strict) | Type safety |
|
||||
| Tailwind CSS v4 | Utility-first styling via CSS-first config |
|
||||
| Storybook 10 | Component development and documentation |
|
||||
| Vite | Build tooling |
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>sdc-frontend</title>
|
||||
<title>ADS 3.0 Design System</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
840
package-lock.json
generated
840
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
42
package.json
42
package.json
@@ -1,28 +1,56 @@
|
||||
{
|
||||
"name": "sdc-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"name": "@richiesnitch/ads3-design-system",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/Richiesnitch/ads3-design-system.git"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npm.pkg.github.com"
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/src/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/src/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./tokens": "./dist/tokens.css",
|
||||
"./typography": "./dist/typography.css"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"build:lib": "vite build --config vite.lib.config.ts && cp src/tokens/tokens.css src/styles/typography.css dist/",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"lucide-react": "^1.16.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.27.19",
|
||||
"@fontsource-variable/public-sans": "^5.2.7",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"clsx": "^2.1.1",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "^5.2.1",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@microsoft/api-extractor": "^7.58.7",
|
||||
"@storybook/addon-a11y": "^10.4.0",
|
||||
"@storybook/addon-designs": "^11.1.3",
|
||||
"@storybook/addon-docs": "^10.4.0",
|
||||
"@storybook/addon-mcp": "^0.6.0",
|
||||
"@storybook/addon-vitest": "^10.4.0",
|
||||
@@ -39,13 +67,17 @@
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"eslint-plugin-storybook": "^10.4.0",
|
||||
"globals": "^17.6.0",
|
||||
"lucide-react": "^1.16.0",
|
||||
"playwright": "^1.60.0",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"storybook": "^10.4.0",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.59.2",
|
||||
"vite": "^8.0.12",
|
||||
"vite-plugin-dts": "^5.0.2",
|
||||
"vitest": "^4.1.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
23
public/nsw-logo.svg
Normal file
23
public/nsw-logo.svg
Normal file
@@ -0,0 +1,23 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_167_1431)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.4904 20.0612C13.3542 19.8393 13.1486 19.6376 12.8798 19.462C12.6114 19.2865 12.2456 19.1525 11.7938 19.0638L10.5361 18.803C10.1561 18.7205 9.89415 18.6137 9.75741 18.4862C9.62416 18.3617 9.55657 18.1961 9.55656 17.9941C9.55655 17.8654 9.58561 17.7483 9.64326 17.6456C9.70054 17.5427 9.78375 17.4516 9.89033 17.3748C9.99754 17.298 10.1309 17.2373 10.2863 17.1947C10.4436 17.1512 10.6203 17.1293 10.8117 17.1293C11.0636 17.1293 11.2909 17.1628 11.4872 17.2292C11.6811 17.2953 11.8419 17.4035 11.9648 17.5518C12.0879 17.7001 12.1635 17.9017 12.1894 18.1511L12.1929 18.1856L13.7534 18.1856L13.7527 18.146C13.7458 17.7219 13.6266 17.3328 13.3986 16.9898C13.1703 16.6464 12.8339 16.3706 12.3982 16.1694C11.9636 15.9688 11.4214 15.867 10.7864 15.8671C10.253 15.8671 9.76419 15.9608 9.33312 16.146C8.90088 16.3316 8.5538 16.5993 8.30217 16.942C8.0494 17.2865 7.92342 17.6987 7.92725 18.1678C7.93759 18.7401 8.1102 19.2046 8.44013 19.5491C8.76892 19.8917 9.21955 20.1198 9.77965 20.2262L11.0482 20.4866C11.2807 20.5331 11.4918 20.5941 11.6756 20.6683C11.8558 20.7409 11.9997 20.8354 12.1033 20.9491C12.2044 21.06 12.2556 21.206 12.2557 21.3819C12.2557 21.5779 12.1919 21.7434 12.0662 21.8733C11.9375 22.0062 11.7627 22.1064 11.5474 22.1722C11.3278 22.239 11.0823 22.2728 10.8176 22.2728C10.5595 22.2729 10.3216 22.2326 10.1109 22.1527C9.9012 22.0739 9.72519 21.9587 9.58771 21.8104C9.4506 21.6629 9.35662 21.4781 9.30815 21.2619L9.30125 21.2315L7.71088 21.2316L7.71508 21.2743C7.75252 21.6492 7.86824 21.9814 8.05879 22.2622C8.24896 22.5411 8.49258 22.7762 8.7828 22.9621C9.07224 23.1468 9.39679 23.2878 9.74778 23.3804C10.0979 23.4725 10.4599 23.5194 10.823 23.5193C11.4176 23.5193 11.9448 23.4248 12.3904 23.2381C12.838 23.0506 13.192 22.7925 13.4432 22.4709C13.6963 22.1471 13.8246 21.7779 13.8246 21.3727C13.8246 21.1814 13.8032 20.9686 13.7604 20.7404C13.7165 20.5103 13.6256 20.2818 13.4904 20.0612ZM2.3858 18.4422L5.78063 23.3835L7.16138 23.3834L7.16106 15.9938L5.61649 15.9939L5.6167 20.7472L2.3689 16.0106L2.35744 15.9941L0.845709 15.9941L0.846031 23.3837L2.38602 23.3836L2.3858 18.4422ZM20.4675 20.7194L21.7903 15.9934L23.3036 15.9933L21.135 23.383L19.9059 23.3831L18.5673 18.7052L17.2196 23.3832L16.0107 23.3832L13.8464 15.9937L15.3647 15.9937L16.6875 20.7188L18.0105 15.9936L19.1293 15.9935L20.4675 20.7194Z" fill="white"/>
|
||||
<path d="M11.2457 14.1603C9.42964 13.9357 7.66557 14.5223 4.56151 13.6728C4.24457 13.586 4.12774 13.9225 4.2992 14.2045C5.13321 15.5765 9.34571 14.5011 11.2541 14.3029C11.3374 14.2941 11.329 14.1707 11.2457 14.1603Z" fill="#D7153A"/>
|
||||
<path d="M19.1946 13.6722C16.0906 14.522 14.3265 13.9355 12.5104 14.1603C12.4272 14.1707 12.4184 14.2944 12.502 14.3028C14.4109 14.5009 18.6231 15.5759 19.457 14.2038C19.6283 13.9218 19.5116 13.5853 19.1946 13.6722Z" fill="#D7153A"/>
|
||||
<path d="M5.50686 11.7099C4.87563 10.7776 4.35971 9.73467 3.96025 8.5873C2.75554 8.9419 1.52259 9.45362 0.267489 10.1232C0.105208 10.2096 0.005553 10.3718 0.000597054 10.5561C-0.00397704 10.7406 0.0869094 10.9072 0.244616 11.0021C2.67513 12.4667 5.05139 13.3044 7.31879 13.4982C6.64368 13.0835 6.02468 12.4746 5.50686 11.7099Z" fill="#D7153A"/>
|
||||
<path d="M2.12068 8.59386C2.67663 8.36988 3.22763 8.17779 3.77366 8.01605C3.57813 7.37686 3.41773 6.70733 3.29092 6.0086C2.66316 5.92988 2.01364 5.87882 1.34159 5.85657C1.33586 5.85657 1.32975 5.85619 1.32402 5.85619C1.148 5.8562 0.98915 5.94532 0.89675 6.09744C0.801296 6.25417 0.797104 6.44431 0.885699 6.60564C1.27598 7.3155 1.68802 7.97811 2.12068 8.59386Z" fill="#D7153A"/>
|
||||
<path d="M8.09299 13.2336C8.46988 13.401 8.85555 13.4947 9.22934 13.5143C8.31407 12.8544 7.595 11.7354 7.18063 10.3092C6.64673 8.47307 6.46833 6.44717 6.64426 4.26145C5.92407 3.80399 5.14357 3.37571 4.30311 2.9778C4.14081 2.90097 3.95524 2.91596 3.80633 3.01777C3.65779 3.11918 3.5757 3.28705 3.58678 3.46644C3.78243 6.63782 4.59397 9.29789 5.99849 11.3725C6.58733 12.243 7.31172 12.8863 8.09299 13.2336Z" fill="#D7153A"/>
|
||||
<path d="M9.88405 2.85273C9.45322 2.32648 8.98586 1.80601 8.48142 1.29168C8.38366 1.19181 8.25766 1.1388 8.12744 1.13881C8.08086 1.13881 8.0339 1.14573 7.9873 1.15956C7.81052 1.21219 7.6826 1.35471 7.64405 1.54102C7.54555 2.01965 7.35811 2.99535 7.27529 3.96029C7.92024 4.3878 8.51594 4.8399 9.06162 5.31542C9.27309 4.50375 9.54799 3.68131 9.88405 2.85273Z" fill="#D7153A"/>
|
||||
<path d="M23.4873 10.1218C22.2321 9.45228 20.9992 8.94067 19.7944 8.58617C19.3951 9.73359 18.8792 10.7765 18.248 11.7088C17.7304 12.474 17.1113 13.0826 16.4359 13.4975C18.7033 13.3034 21.0795 12.4654 23.5099 11.0006C23.6676 10.9057 23.7588 10.7391 23.7538 10.5547C23.7497 10.3703 23.6496 10.2086 23.4873 10.1218Z" fill="#D7153A"/>
|
||||
<path d="M21.6346 8.59305C22.0673 7.97727 22.4793 7.31424 22.8695 6.60435C22.958 6.44301 22.9543 6.25325 22.8584 6.09614C22.7659 5.94441 22.6071 5.85492 22.4311 5.85493C22.4253 5.85493 22.4193 5.85493 22.4136 5.85531C21.7415 5.87762 21.0915 5.92912 20.4642 6.00751C20.3378 6.70625 20.1771 7.3758 19.9817 8.015C20.5277 8.17708 21.079 8.36951 21.6346 8.59305Z" fill="#D7153A"/>
|
||||
<path d="M16.5749 10.3091C16.1607 11.7351 15.4413 12.8544 14.5264 13.5144C14.9002 13.4948 15.2863 13.4006 15.6628 13.2331C16.444 12.8859 17.1683 12.2424 17.7572 11.3723C19.1615 9.29759 19.9728 6.63745 20.1681 3.46605C20.1792 3.28667 20.0971 3.1188 19.9485 3.0174C19.7996 2.91561 19.6141 2.90064 19.4518 2.97747C18.6113 3.37509 17.8308 3.80342 17.1107 4.26134C17.2869 6.44704 17.1082 8.47257 16.5749 10.3091Z" fill="#D7153A"/>
|
||||
<path d="M16.4796 3.95948C16.3967 2.99493 16.2092 2.01924 16.1107 1.54024C16.0724 1.35394 15.9441 1.21143 15.7674 1.15881C15.7208 1.14498 15.6738 1.13807 15.6272 1.13807C15.497 1.13808 15.371 1.1911 15.2732 1.29097C14.7689 1.80534 14.3015 2.32586 13.8708 2.85214C14.2068 3.6807 14.4818 4.50312 14.6938 5.31439C15.2394 4.83919 15.8346 4.38705 16.4796 3.95948Z" fill="#D7153A"/>
|
||||
<path d="M11.8757 8.75247C12.4862 7.53897 13.4614 6.45107 14.1937 5.76383C13.7916 4.11593 13.1725 2.56137 12.3179 0.934998C12.2297 0.767136 12.0639 0.667269 11.8757 0.667277C11.6874 0.667286 11.5221 0.767551 11.4336 0.935036C10.5703 2.56686 9.94066 4.19061 9.56004 5.76364C10.3245 6.47617 11.333 7.63813 11.8757 8.75247Z" fill="#D7153A"/>
|
||||
<path d="M14.1193 13.0585C14.7523 12.6105 15.3854 11.8299 15.7871 10.744C16.5648 8.6416 16.6452 6.29993 16.5337 4.64279C15.124 5.58974 12.8865 7.56735 12.1224 9.68163C11.7628 10.6766 11.5953 12.0318 11.8724 12.7877C11.9882 13.1039 12.1852 13.3436 12.4559 13.4576C12.866 13.6304 13.4961 13.4998 14.1193 13.0585Z" fill="#D7153A"/>
|
||||
<path d="M11.5602 9.497C11.3738 9.05911 11.1683 8.64617 10.8495 8.18215C9.94749 6.86962 8.73206 5.68578 7.21952 4.64293C7.20388 4.88877 6.98329 7.5439 7.75778 10.1398C8.34706 12.1142 9.35364 12.9185 9.90537 13.2293C10.4606 13.5416 10.9685 13.6153 11.5641 13.4662C10.9604 12.6381 11.0356 10.9386 11.5602 9.497Z" fill="#D7153A"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_167_1431">
|
||||
<rect width="24" height="23.1724" fill="white" transform="translate(0 0.667796)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.0 KiB |
@@ -2,11 +2,11 @@ function App() {
|
||||
return (
|
||||
<div className="min-h-screen bg-bg text-text">
|
||||
<header className="bg-surface border-b border-border px-6 py-3">
|
||||
<h1 className="text-lg font-semibold">SDC Design System</h1>
|
||||
<h1 className="text-lg font-semibold">ADS 3.0 Design System</h1>
|
||||
</header>
|
||||
<main className="p-6">
|
||||
<p className="text-text-secondary">
|
||||
Component library for the Research Synthesiser.
|
||||
React component library implementing the ADS 3.0 design language.
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
91
src/components/atoms/Autocomplete/Autocomplete.stories.tsx
Normal file
91
src/components/atoms/Autocomplete/Autocomplete.stories.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { useState } from 'react'
|
||||
import { Autocomplete } from './Autocomplete'
|
||||
|
||||
const states = [
|
||||
{ value: 'nsw', label: 'New South Wales' },
|
||||
{ value: 'vic', label: 'Victoria' },
|
||||
{ value: 'qld', label: 'Queensland' },
|
||||
{ value: 'wa', label: 'Western Australia' },
|
||||
{ value: 'sa', label: 'South Australia' },
|
||||
{ value: 'tas', label: 'Tasmania' },
|
||||
{ value: 'act', label: 'Australian Capital Territory' },
|
||||
{ value: 'nt', label: 'Northern Territory' },
|
||||
]
|
||||
|
||||
const meta: Meta<typeof Autocomplete> = {
|
||||
title: 'Atoms/Autocomplete',
|
||||
component: Autocomplete,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'padded' },
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof Autocomplete>
|
||||
|
||||
const BasicTemplate = () => {
|
||||
const [value, setValue] = useState('')
|
||||
return (
|
||||
<div className="w-96">
|
||||
<Autocomplete
|
||||
label="State"
|
||||
placeholder="Search states…"
|
||||
options={states}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => <BasicTemplate />,
|
||||
}
|
||||
|
||||
const FreeSoloTemplate = () => {
|
||||
const [value, setValue] = useState('')
|
||||
return (
|
||||
<div className="w-96">
|
||||
<Autocomplete
|
||||
label="School name"
|
||||
placeholder="Type to search or enter a new name…"
|
||||
options={states}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
freeSolo
|
||||
hint="Select from the list or type a custom value"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const FreeSolo: Story = {
|
||||
name: 'Free solo (Combobox)',
|
||||
render: () => <FreeSoloTemplate />,
|
||||
}
|
||||
|
||||
export const WithError: Story = {
|
||||
name: 'With error',
|
||||
render: () => (
|
||||
<div className="w-96">
|
||||
<Autocomplete
|
||||
label="State"
|
||||
options={states}
|
||||
error="Please select a valid state"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
render: () => (
|
||||
<div className="w-96">
|
||||
<Autocomplete
|
||||
label="State"
|
||||
options={states}
|
||||
value="nsw"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
216
src/components/atoms/Autocomplete/Autocomplete.tsx
Normal file
216
src/components/atoms/Autocomplete/Autocomplete.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type HTMLAttributes,
|
||||
} from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface AutocompleteOption {
|
||||
value: string
|
||||
label: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface AutocompleteProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
|
||||
label: string
|
||||
description?: string
|
||||
hint?: string
|
||||
error?: string
|
||||
placeholder?: string
|
||||
options: AutocompleteOption[]
|
||||
value?: string
|
||||
onChange?: (value: string) => void
|
||||
freeSolo?: boolean
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
noResultsText?: string
|
||||
}
|
||||
|
||||
const ChevronIcon = () => (
|
||||
<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 Autocomplete = forwardRef<HTMLDivElement, AutocompleteProps>(
|
||||
(
|
||||
{
|
||||
label,
|
||||
description,
|
||||
hint,
|
||||
error,
|
||||
placeholder,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
freeSolo = false,
|
||||
disabled = false,
|
||||
loading = false,
|
||||
noResultsText = 'No results found',
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const id = useId()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const listRef = useRef<HTMLUListElement>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [activeIndex, setActiveIndex] = useState(-1)
|
||||
|
||||
const selectedOption = options.find((o) => o.value === value)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!query) return options
|
||||
const q = query.toLowerCase()
|
||||
return options.filter((o) => o.label.toLowerCase().includes(q))
|
||||
}, [options, query])
|
||||
|
||||
const selectOption = useCallback(
|
||||
(opt: AutocompleteOption) => {
|
||||
onChange?.(opt.value)
|
||||
setQuery(opt.label)
|
||||
setOpen(false)
|
||||
setActiveIndex(-1)
|
||||
},
|
||||
[onChange],
|
||||
)
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setQuery(e.target.value)
|
||||
setOpen(true)
|
||||
setActiveIndex(-1)
|
||||
if (freeSolo) {
|
||||
onChange?.(e.target.value)
|
||||
}
|
||||
},
|
||||
[freeSolo, onChange],
|
||||
)
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (!open && (e.key === 'ArrowDown' || e.key === 'Enter')) {
|
||||
setOpen(true)
|
||||
return
|
||||
}
|
||||
if (!open) return
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setActiveIndex((i) => (i + 1) % filtered.length)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setActiveIndex((i) => (i <= 0 ? filtered.length - 1 : i - 1))
|
||||
} else if (e.key === 'Enter' && activeIndex >= 0) {
|
||||
e.preventDefault()
|
||||
const opt = filtered[activeIndex]
|
||||
if (opt && !opt.disabled) selectOption(opt)
|
||||
} else if (e.key === 'Escape') {
|
||||
setOpen(false)
|
||||
}
|
||||
},
|
||||
[open, filtered, activeIndex, selectOption],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedOption && !open) {
|
||||
setQuery(selectedOption.label)
|
||||
}
|
||||
}, [selectedOption, open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
const el = (ref as React.RefObject<HTMLDivElement>)?.current
|
||||
if (el && !el.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [open, ref])
|
||||
|
||||
const listboxId = `${id}-listbox`
|
||||
const hasError = !!error
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn('relative flex flex-col gap-1.5', className)} {...props}>
|
||||
<label htmlFor={id} className="text-small font-semibold text-control-label">
|
||||
{label}
|
||||
</label>
|
||||
{description && <p className="text-small text-control-description">{description}</p>}
|
||||
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={inputRef}
|
||||
id={id}
|
||||
type="text"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-controls={listboxId}
|
||||
aria-activedescendant={activeIndex >= 0 ? `${id}-opt-${activeIndex}` : undefined}
|
||||
aria-invalid={hasError || undefined}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
value={query}
|
||||
onChange={handleInputChange}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
'h-14 w-full rounded-default border bg-control-bg px-4 pr-10 text-body text-text outline-none transition-colors',
|
||||
'focus:border-primary focus:ring-2 focus:ring-control-focus-ring',
|
||||
hasError ? 'border-control-error' : 'border-control-border hover:border-primary',
|
||||
disabled && 'pointer-events-none opacity-55',
|
||||
)}
|
||||
/>
|
||||
<span className="pointer-events-none absolute right-3 top-1/2 size-5 -translate-y-1/2 text-primary [&>svg]:size-full">
|
||||
<ChevronIcon />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{open && !disabled && (
|
||||
<ul
|
||||
ref={listRef}
|
||||
id={listboxId}
|
||||
role="listbox"
|
||||
className="absolute left-0 right-0 top-full z-50 mt-1 max-h-60 overflow-auto rounded-default border border-border bg-surface py-1 shadow-md"
|
||||
>
|
||||
{loading ? (
|
||||
<li className="px-4 py-3 text-body text-text-secondary">Loading…</li>
|
||||
) : filtered.length === 0 ? (
|
||||
<li className="px-4 py-3 text-body text-text-secondary">{noResultsText}</li>
|
||||
) : (
|
||||
filtered.map((opt, i) => (
|
||||
<li
|
||||
key={opt.value}
|
||||
id={`${id}-opt-${i}`}
|
||||
role="option"
|
||||
aria-selected={opt.value === value}
|
||||
aria-disabled={opt.disabled || undefined}
|
||||
onClick={() => !opt.disabled && selectOption(opt)}
|
||||
className={cn(
|
||||
'cursor-pointer px-4 py-3 text-body transition-colors',
|
||||
opt.value === value && 'bg-info/12 font-bold',
|
||||
i === activeIndex && opt.value !== value && 'bg-info/5',
|
||||
opt.disabled && 'pointer-events-none opacity-55',
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{hint && !error && <p className="text-small text-control-description">{hint}</p>}
|
||||
{error && <p className="text-small text-control-error">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
Autocomplete.displayName = 'Autocomplete'
|
||||
2
src/components/atoms/Autocomplete/index.ts
Normal file
2
src/components/atoms/Autocomplete/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Autocomplete } from './Autocomplete'
|
||||
export type { AutocompleteProps, AutocompleteOption } from './Autocomplete'
|
||||
49
src/components/atoms/Avatar/Avatar.stories.tsx
Normal file
49
src/components/atoms/Avatar/Avatar.stories.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { Avatar } from './Avatar'
|
||||
|
||||
const meta: Meta<typeof Avatar> = {
|
||||
title: 'Atoms/Avatar',
|
||||
component: Avatar,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof Avatar>
|
||||
|
||||
export const Default: Story = {
|
||||
args: { initials: 'DW' },
|
||||
}
|
||||
|
||||
export const Small: Story = {
|
||||
args: { initials: 'SR', size: 'sm' },
|
||||
}
|
||||
|
||||
export const Large: Story = {
|
||||
args: { initials: 'JB', size: 'lg' },
|
||||
}
|
||||
|
||||
export const SingleInitial: Story = {
|
||||
name: 'Single initial',
|
||||
args: { initials: 'R', size: 'default' },
|
||||
}
|
||||
|
||||
export const AllSizes: Story = {
|
||||
name: 'All sizes',
|
||||
render: () => (
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar initials="SM" size="sm" />
|
||||
<Avatar initials="MD" size="default" />
|
||||
<Avatar initials="LG" size="lg" />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const OnDarkBackground: Story = {
|
||||
name: 'On dark background',
|
||||
render: () => (
|
||||
<div className="flex items-center gap-4 rounded-lg bg-primary-dark p-4">
|
||||
<Avatar initials="DW" size="lg" />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
46
src/components/atoms/Avatar/Avatar.tsx
Normal file
46
src/components/atoms/Avatar/Avatar.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { forwardRef, type HTMLAttributes } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface AvatarProps extends HTMLAttributes<HTMLDivElement> {
|
||||
initials: string
|
||||
src?: string
|
||||
alt?: string
|
||||
size?: 'sm' | 'default' | 'lg'
|
||||
}
|
||||
|
||||
const sizeStyles = {
|
||||
sm: 'size-8 text-caption',
|
||||
default: 'size-10 text-body',
|
||||
lg: 'size-12 text-[18px]',
|
||||
}
|
||||
|
||||
export const Avatar = forwardRef<HTMLDivElement, AvatarProps>(
|
||||
({ initials, src, alt, size = 'default', className, ...props }, ref) => {
|
||||
const label = alt || initials
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="img"
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center justify-center rounded-full bg-avatar text-avatar-text',
|
||||
sizeStyles[size],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{src ? (
|
||||
<img
|
||||
src={src}
|
||||
alt={label}
|
||||
className="size-full rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
initials.slice(0, 2).toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
Avatar.displayName = 'Avatar'
|
||||
2
src/components/atoms/Avatar/index.ts
Normal file
2
src/components/atoms/Avatar/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Avatar } from './Avatar'
|
||||
export type { AvatarProps } from './Avatar'
|
||||
173
src/components/atoms/Badge/Badge.stories.tsx
Normal file
173
src/components/atoms/Badge/Badge.stories.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { Badge } from './Badge'
|
||||
|
||||
const meta: Meta<typeof Badge> = {
|
||||
title: 'Atoms/Badge',
|
||||
component: Badge,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: [
|
||||
'navy',
|
||||
'info',
|
||||
'info-light',
|
||||
'success',
|
||||
'success-light',
|
||||
'error',
|
||||
'error-light',
|
||||
'warning',
|
||||
'warning-light',
|
||||
'neutral',
|
||||
'white',
|
||||
],
|
||||
},
|
||||
children: { control: 'text' },
|
||||
},
|
||||
parameters: {
|
||||
design: {
|
||||
type: 'figma',
|
||||
url: 'https://www.figma.com/design/mrabO6AtxN3ektGiTk0I9c/ResearchInsights?node-id=78-1035',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: { children: 'Status here' },
|
||||
}
|
||||
|
||||
// --- Variants ---
|
||||
|
||||
export const Navy: Story = {
|
||||
args: { children: 'Primary', variant: 'navy' },
|
||||
}
|
||||
|
||||
export const Info: Story = {
|
||||
args: { children: 'Information', variant: 'info' },
|
||||
}
|
||||
|
||||
export const InfoLight: Story = {
|
||||
args: { children: 'Information', variant: 'info-light' },
|
||||
}
|
||||
|
||||
export const Success: Story = {
|
||||
args: { children: 'Complete', variant: 'success' },
|
||||
}
|
||||
|
||||
export const SuccessLight: Story = {
|
||||
args: { children: 'Complete', variant: 'success-light' },
|
||||
}
|
||||
|
||||
export const Error: Story = {
|
||||
args: { children: 'Failed', variant: 'error' },
|
||||
}
|
||||
|
||||
export const ErrorLight: Story = {
|
||||
args: { children: 'Failed', variant: 'error-light' },
|
||||
}
|
||||
|
||||
export const Warning: Story = {
|
||||
args: { children: 'Pending', variant: 'warning' },
|
||||
}
|
||||
|
||||
export const WarningLight: Story = {
|
||||
args: { children: 'Pending', variant: 'warning-light' },
|
||||
}
|
||||
|
||||
export const Neutral: Story = {
|
||||
args: { children: 'Draft', variant: 'neutral' },
|
||||
}
|
||||
|
||||
export const White: Story = {
|
||||
args: { children: 'Default', variant: 'white' },
|
||||
}
|
||||
|
||||
// --- Icons ---
|
||||
|
||||
const CheckIcon = () => (
|
||||
<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="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const CalendarIcon = () => (
|
||||
<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="M8 2v4" />
|
||||
<path d="M16 2v4" />
|
||||
<rect width="18" height="18" x="3" y="4" rx="2" />
|
||||
<path d="M3 10h18" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const WithIcons: Story = {
|
||||
args: {
|
||||
children: 'Status here',
|
||||
variant: 'info',
|
||||
leftIcon: <CheckIcon />,
|
||||
rightIcon: <CalendarIcon />,
|
||||
},
|
||||
}
|
||||
|
||||
// --- All variants ---
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Badge variant="navy">Navy</Badge>
|
||||
<Badge variant="info">Info</Badge>
|
||||
<Badge variant="info-light">Info light</Badge>
|
||||
<Badge variant="success">Success</Badge>
|
||||
<Badge variant="success-light">Success light</Badge>
|
||||
<Badge variant="error">Error</Badge>
|
||||
<Badge variant="error-light">Error light</Badge>
|
||||
<Badge variant="warning">Warning</Badge>
|
||||
<Badge variant="warning-light">Warning light</Badge>
|
||||
<Badge variant="neutral">Neutral</Badge>
|
||||
<Badge variant="white">White</Badge>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const StrongVariants: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Badge variant="navy">Navy</Badge>
|
||||
<Badge variant="info">Info</Badge>
|
||||
<Badge variant="success">Success</Badge>
|
||||
<Badge variant="error">Error</Badge>
|
||||
<Badge variant="warning">Warning</Badge>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const LightVariants: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Badge variant="info-light">Info</Badge>
|
||||
<Badge variant="success-light">Success</Badge>
|
||||
<Badge variant="error-light">Error</Badge>
|
||||
<Badge variant="warning-light">Warning</Badge>
|
||||
<Badge variant="neutral">Neutral</Badge>
|
||||
<Badge variant="white">White</Badge>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
61
src/components/atoms/Badge/Badge.tsx
Normal file
61
src/components/atoms/Badge/Badge.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { HTMLAttributes, ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
variant?:
|
||||
| 'navy'
|
||||
| 'info'
|
||||
| 'info-light'
|
||||
| 'success'
|
||||
| 'success-light'
|
||||
| 'error'
|
||||
| 'error-light'
|
||||
| 'warning'
|
||||
| 'warning-light'
|
||||
| 'neutral'
|
||||
| 'white'
|
||||
leftIcon?: ReactNode
|
||||
rightIcon?: ReactNode
|
||||
}
|
||||
|
||||
const variantStyles: Record<string, string> = {
|
||||
navy: 'bg-badge-navy text-white',
|
||||
info: 'bg-badge-info text-white',
|
||||
'info-light': 'bg-badge-info-light text-primary',
|
||||
success: 'bg-badge-success text-white',
|
||||
'success-light': 'bg-badge-success-light text-badge-on-success-light',
|
||||
error: 'bg-badge-error text-white',
|
||||
'error-light': 'bg-badge-error-light text-badge-on-error-light',
|
||||
warning: 'bg-badge-warning text-white',
|
||||
'warning-light': 'bg-badge-warning-light text-badge-on-warning-light',
|
||||
neutral: 'bg-badge-neutral text-text-secondary',
|
||||
white: 'bg-surface text-primary border border-primary',
|
||||
}
|
||||
|
||||
export function Badge({
|
||||
variant = 'info',
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex h-[30px] items-center gap-1 rounded-lg px-3 text-small font-bold leading-[19px]',
|
||||
variantStyles[variant],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{leftIcon && (
|
||||
<span className="shrink-0 [&>svg]:size-full size-[18px]">{leftIcon}</span>
|
||||
)}
|
||||
{children}
|
||||
{rightIcon && (
|
||||
<span className="shrink-0 [&>svg]:size-full size-[18px]">{rightIcon}</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
2
src/components/atoms/Badge/index.ts
Normal file
2
src/components/atoms/Badge/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Badge } from './Badge'
|
||||
export type { BadgeProps } from './Badge'
|
||||
181
src/components/atoms/Button/Button.stories.tsx
Normal file
181
src/components/atoms/Button/Button.stories.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { Button } from './Button'
|
||||
|
||||
const meta: Meta<typeof Button> = {
|
||||
title: 'Atoms/Button',
|
||||
component: Button,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['primary', 'secondary', 'tertiary'],
|
||||
},
|
||||
intent: {
|
||||
control: 'select',
|
||||
options: ['default', 'danger', 'subtle', 'neutral'],
|
||||
},
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['default', 'comfortable', 'compact'],
|
||||
},
|
||||
loading: { control: 'boolean' },
|
||||
disabled: { control: 'boolean' },
|
||||
children: { control: 'text' },
|
||||
},
|
||||
parameters: {
|
||||
design: {
|
||||
type: 'figma',
|
||||
url: 'https://www.figma.com/design/mrabO6AtxN3ektGiTk0I9c/ResearchInsights?node-id=10-20',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: { children: 'Button' },
|
||||
}
|
||||
|
||||
// --- Intents ---
|
||||
|
||||
export const IntentDefault: Story = {
|
||||
args: { children: 'Submit', intent: 'default' },
|
||||
}
|
||||
|
||||
export const IntentDanger: Story = {
|
||||
args: { children: 'Delete', intent: 'danger' },
|
||||
}
|
||||
|
||||
export const IntentSubtle: Story = {
|
||||
args: { children: 'Learn more', intent: 'subtle' },
|
||||
}
|
||||
|
||||
export const IntentNeutral: Story = {
|
||||
args: { children: 'Cancel', intent: 'neutral' },
|
||||
}
|
||||
|
||||
// --- Icons ---
|
||||
|
||||
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: 'Secure', leftIcon: <LockIcon /> },
|
||||
}
|
||||
|
||||
export const WithRightIcon: Story = {
|
||||
args: { children: 'Continue', rightIcon: <ArrowIcon /> },
|
||||
}
|
||||
|
||||
export const WithBothIcons: Story = {
|
||||
args: {
|
||||
children: 'Secure',
|
||||
leftIcon: <LockIcon />,
|
||||
rightIcon: <ArrowIcon />,
|
||||
},
|
||||
}
|
||||
|
||||
// --- Loading ---
|
||||
|
||||
export const Loading: Story = {
|
||||
args: { children: 'Submitting...', loading: true },
|
||||
}
|
||||
|
||||
export const LoadingDanger: Story = {
|
||||
args: { children: 'Deleting...', loading: true, intent: 'danger' },
|
||||
}
|
||||
|
||||
export const LoadingSecondary: Story = {
|
||||
args: { children: 'Loading...', loading: true, variant: 'secondary' },
|
||||
}
|
||||
|
||||
// --- Sizes ---
|
||||
|
||||
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>
|
||||
),
|
||||
}
|
||||
|
||||
// --- Variants ---
|
||||
|
||||
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>
|
||||
),
|
||||
}
|
||||
|
||||
// --- Full matrix ---
|
||||
|
||||
export const AllIntents: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button intent="default">Default</Button>
|
||||
<Button intent="danger">Danger</Button>
|
||||
<Button intent="subtle">Subtle</Button>
|
||||
<Button intent="neutral">Neutral</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="secondary" intent="default">Default</Button>
|
||||
<Button variant="secondary" intent="danger">Danger</Button>
|
||||
<Button variant="secondary" intent="subtle">Subtle</Button>
|
||||
<Button variant="secondary" intent="neutral">Neutral</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="tertiary" intent="default">Default</Button>
|
||||
<Button variant="tertiary" intent="danger">Danger</Button>
|
||||
<Button variant="tertiary" intent="subtle">Subtle</Button>
|
||||
<Button variant="tertiary" intent="neutral">Neutral</Button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
// --- Disabled ---
|
||||
|
||||
export const Disabled: Story = {
|
||||
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>
|
||||
),
|
||||
}
|
||||
124
src/components/atoms/Button/Button.tsx
Normal file
124
src/components/atoms/Button/Button.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { forwardRef, type ButtonHTMLAttributes } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'tertiary'
|
||||
intent?: 'default' | 'danger' | 'subtle' | 'neutral'
|
||||
size?: 'default' | 'comfortable' | 'compact'
|
||||
loading?: boolean
|
||||
leftIcon?: React.ReactNode
|
||||
rightIcon?: React.ReactNode
|
||||
}
|
||||
|
||||
const variantIntentStyles: Record<string, Record<string, string>> = {
|
||||
primary: {
|
||||
default: 'bg-button-default text-white hover:bg-button-default/90 active:bg-button-default/80',
|
||||
danger: 'bg-button-danger text-white hover:bg-button-danger/90 active:bg-button-danger/80',
|
||||
subtle:
|
||||
'bg-button-subtle-bg text-button-subtle-text hover:bg-button-subtle-bg/80 active:bg-button-subtle-bg/60',
|
||||
neutral: 'bg-button-neutral text-white hover:bg-button-neutral/90 active:bg-button-neutral/80',
|
||||
},
|
||||
secondary: {
|
||||
default:
|
||||
'border-2 border-button-default text-button-default hover:bg-button-default/5 active:bg-button-default/10',
|
||||
danger:
|
||||
'border-2 border-button-danger text-button-danger hover:bg-button-danger/5 active:bg-button-danger/10',
|
||||
subtle:
|
||||
'border border-button-default/40 text-button-subtle-text hover:bg-button-default/5 active:bg-button-default/10',
|
||||
neutral:
|
||||
'border-2 border-button-neutral text-button-neutral hover:bg-button-neutral/5 active:bg-button-neutral/10',
|
||||
},
|
||||
tertiary: {
|
||||
default: 'text-button-default hover:bg-button-default/5 active:bg-button-default/10',
|
||||
danger: 'text-button-danger hover:bg-button-danger/5 active:bg-button-danger/10',
|
||||
subtle:
|
||||
'text-button-subtle-text/70 hover:text-button-subtle-text hover:bg-button-default/5 active:bg-button-default/10',
|
||||
neutral: 'text-button-neutral hover:bg-button-neutral/5 active:bg-button-neutral/10',
|
||||
},
|
||||
}
|
||||
|
||||
const sizeStyles: Record<string, string> = {
|
||||
default: 'h-11 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-5',
|
||||
comfortable: 'size-[18px]',
|
||||
compact: 'size-4',
|
||||
}
|
||||
|
||||
const Spinner = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
className={cn('animate-spin', className)}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{
|
||||
variant = 'primary',
|
||||
intent = 'default',
|
||||
size = 'default',
|
||||
loading = false,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
disabled,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const isDisabled = disabled || loading
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
disabled={isDisabled}
|
||||
aria-busy={loading || undefined}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-full font-semibold transition-colors',
|
||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-button-default',
|
||||
sizeStyles[size],
|
||||
variantIntentStyles[variant][intent],
|
||||
disabled && 'pointer-events-none opacity-50',
|
||||
loading && 'pointer-events-none',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{loading ? (
|
||||
<span className={cn('shrink-0 [&>svg]:size-full', iconSizeStyles[size])}>
|
||||
<Spinner />
|
||||
</span>
|
||||
) : (
|
||||
leftIcon && (
|
||||
<span className={cn('shrink-0 [&>svg]:size-full', iconSizeStyles[size])}>
|
||||
{leftIcon}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
{children}
|
||||
{!loading && rightIcon && (
|
||||
<span className={cn('shrink-0 [&>svg]:size-full', iconSizeStyles[size])}>
|
||||
{rightIcon}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
Button.displayName = 'Button'
|
||||
120
src/components/atoms/Checkbox/Checkbox.stories.tsx
Normal file
120
src/components/atoms/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: 'Atoms/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>
|
||||
),
|
||||
}
|
||||
144
src/components/atoms/Checkbox/Checkbox.tsx
Normal file
144
src/components/atoms/Checkbox/Checkbox.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
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 hover:border-control-error checked:border-control-error checked:bg-control-error checked:hover:border-control-error checked:hover:bg-control-error focus-visible:ring-red-03',
|
||||
)}
|
||||
{...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/atoms/Checkbox/index.ts
Normal file
2
src/components/atoms/Checkbox/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Checkbox } from './Checkbox'
|
||||
export type { CheckboxProps } from './Checkbox'
|
||||
118
src/components/atoms/Chip/Chip.stories.tsx
Normal file
118
src/components/atoms/Chip/Chip.stories.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useState } from 'react'
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { Chip } from './Chip'
|
||||
|
||||
const meta: Meta<typeof Chip> = {
|
||||
title: 'Atoms/Chip',
|
||||
component: Chip,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
selected: { control: 'boolean' },
|
||||
disabled: { control: 'boolean' },
|
||||
children: { control: 'text' },
|
||||
},
|
||||
parameters: {
|
||||
design: {
|
||||
type: 'figma',
|
||||
url: 'https://www.figma.com/design/mrabO6AtxN3ektGiTk0I9c/ResearchInsights?node-id=63-6084',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: { children: 'Label' },
|
||||
}
|
||||
|
||||
// --- Interactive ---
|
||||
|
||||
export const Clickable: Story = {
|
||||
args: { children: 'Filter', onClick: () => {} },
|
||||
}
|
||||
|
||||
export const Selected: Story = {
|
||||
args: { children: 'Active filter', selected: true, onClick: () => {} },
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: { children: 'Unavailable', disabled: true, onClick: () => {} },
|
||||
}
|
||||
|
||||
export const DisabledSelected: Story = {
|
||||
args: { children: 'Locked', disabled: true, selected: true, onClick: () => {} },
|
||||
}
|
||||
|
||||
// --- Dismissible ---
|
||||
|
||||
export const Dismissible: Story = {
|
||||
args: { children: 'Remove me', onDismiss: () => {} },
|
||||
}
|
||||
|
||||
// --- Custom icon ---
|
||||
|
||||
const DropdownIcon = () => (
|
||||
<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 WithDropdownIcon: Story = {
|
||||
args: { children: 'Category', rightIcon: <DropdownIcon />, onClick: () => {} },
|
||||
}
|
||||
|
||||
// --- Toggle example ---
|
||||
|
||||
export const ToggleGroup: Story = {
|
||||
render: () => {
|
||||
const labels = ['Qualitative', 'Quantitative', 'Mixed methods']
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
|
||||
const toggle = (label: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(label)) next.delete(label)
|
||||
else next.add(label)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{labels.map((label) => (
|
||||
<Chip key={label} selected={selected.has(label)} onClick={() => toggle(label)}>
|
||||
{label}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
// --- Dismissible list ---
|
||||
|
||||
export const DismissibleList: Story = {
|
||||
render: () => {
|
||||
const [items, setItems] = useState(['Education', 'Psychology', 'Sociology', 'Linguistics'])
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{items.map((item) => (
|
||||
<Chip key={item} onDismiss={() => setItems((prev) => prev.filter((i) => i !== item))}>
|
||||
{item}
|
||||
</Chip>
|
||||
))}
|
||||
{items.length === 0 && <span className="text-small text-text-secondary">All removed</span>}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
98
src/components/atoms/Chip/Chip.tsx
Normal file
98
src/components/atoms/Chip/Chip.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type ChipColor = 'default' | 'info' | 'error' | 'warning' | 'success'
|
||||
|
||||
export interface ChipProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children' | 'color'> {
|
||||
children: ReactNode
|
||||
selected?: boolean
|
||||
color?: ChipColor
|
||||
onDismiss?: () => void
|
||||
rightIcon?: ReactNode
|
||||
}
|
||||
|
||||
const DismissIcon = () => (
|
||||
<svg
|
||||
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"
|
||||
>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const colorStyles: Record<ChipColor, string> = {
|
||||
default: 'bg-grey-04/40 text-text-secondary',
|
||||
info: 'bg-blue-04/60 text-info',
|
||||
error: 'bg-red-04/60 text-error',
|
||||
warning: 'bg-orange-04/60 text-warning',
|
||||
success: 'bg-green-04/60 text-success',
|
||||
}
|
||||
|
||||
export const Chip = forwardRef<HTMLButtonElement, ChipProps>(
|
||||
({ children, selected = false, color, onDismiss, rightIcon, disabled, className, onClick, ...props }, ref) => {
|
||||
const isInteractive = !!(onClick || onDismiss)
|
||||
const Component = isInteractive ? 'button' : 'span'
|
||||
|
||||
const trailing = rightIcon ?? (onDismiss ? <DismissIcon /> : null)
|
||||
|
||||
return (
|
||||
<Component
|
||||
ref={isInteractive ? ref : undefined}
|
||||
type={isInteractive ? 'button' : undefined}
|
||||
disabled={isInteractive ? disabled : undefined}
|
||||
aria-pressed={isInteractive && selected ? true : undefined}
|
||||
onClick={
|
||||
isInteractive
|
||||
? (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (onDismiss && !rightIcon) {
|
||||
onDismiss()
|
||||
} else {
|
||||
onClick?.(e)
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
'inline-flex h-[30px] items-center gap-2 rounded-default px-3 py-1.5 text-small font-bold leading-[19px]',
|
||||
selected
|
||||
? 'border border-chip-selected-bg bg-chip-selected-bg text-chip-selected-text'
|
||||
: color && color !== 'default'
|
||||
? cn('border-transparent', colorStyles[color])
|
||||
: 'border border-chip-border bg-chip-bg text-chip-text',
|
||||
isInteractive && !disabled && !selected && 'hover:bg-chip-border/5 active:bg-chip-border/10',
|
||||
isInteractive && !disabled && selected && 'hover:bg-chip-selected-bg/90 active:bg-chip-selected-bg/80',
|
||||
isInteractive && 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-chip-border',
|
||||
disabled && 'pointer-events-none opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...(props as Record<string, unknown>)}
|
||||
>
|
||||
{children}
|
||||
{trailing && (
|
||||
<span
|
||||
className="shrink-0 [&>svg]:size-full size-[18px]"
|
||||
onClick={
|
||||
onDismiss && onClick
|
||||
? (e) => {
|
||||
e.stopPropagation()
|
||||
onDismiss()
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{trailing}
|
||||
</span>
|
||||
)}
|
||||
</Component>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
Chip.displayName = 'Chip'
|
||||
2
src/components/atoms/Chip/index.ts
Normal file
2
src/components/atoms/Chip/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Chip } from './Chip'
|
||||
export type { ChipProps, ChipColor } from './Chip'
|
||||
42
src/components/atoms/FileInput/FileInput.stories.tsx
Normal file
42
src/components/atoms/FileInput/FileInput.stories.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { FileInput } from './FileInput'
|
||||
|
||||
const meta: Meta<typeof FileInput> = {
|
||||
title: 'Atoms/FileInput',
|
||||
component: FileInput,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'padded' },
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof FileInput>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
label: 'Upload document',
|
||||
description: 'Supported formats: PDF, DOCX, PNG',
|
||||
},
|
||||
}
|
||||
|
||||
export const Multiple: Story = {
|
||||
args: {
|
||||
label: 'Upload files',
|
||||
multiple: true,
|
||||
accept: '.pdf,.docx,.png,.jpg',
|
||||
},
|
||||
}
|
||||
|
||||
export const WithError: Story = {
|
||||
name: 'With error',
|
||||
args: {
|
||||
label: 'Upload evidence',
|
||||
error: 'File size must be under 10MB',
|
||||
},
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
label: 'Upload document',
|
||||
disabled: true,
|
||||
},
|
||||
}
|
||||
120
src/components/atoms/FileInput/FileInput.tsx
Normal file
120
src/components/atoms/FileInput/FileInput.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { forwardRef, useCallback, useRef, useState, type HTMLAttributes } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface FileInputProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
|
||||
label: string
|
||||
description?: string
|
||||
error?: string
|
||||
accept?: string
|
||||
multiple?: boolean
|
||||
disabled?: boolean
|
||||
onChange?: (files: File[]) => void
|
||||
}
|
||||
|
||||
const PaperclipIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5a2.5 2.5 0 015 0v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6h-1.5v9.5a2.5 2.5 0 005 0V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6H16.5z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const FileInput = forwardRef<HTMLDivElement, FileInputProps>(
|
||||
({ label, description, error, accept, multiple = false, disabled = false, onChange, className, ...props }, ref) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
|
||||
const handleFiles = useCallback(
|
||||
(fileList: FileList | null) => {
|
||||
if (!fileList) return
|
||||
const next = Array.from(fileList)
|
||||
setFiles(next)
|
||||
onChange?.(next)
|
||||
},
|
||||
[onChange],
|
||||
)
|
||||
|
||||
const removeFile = useCallback(
|
||||
(index: number) => {
|
||||
setFiles((prev) => {
|
||||
const next = prev.filter((_, i) => i !== index)
|
||||
onChange?.(next)
|
||||
return next
|
||||
})
|
||||
if (inputRef.current) inputRef.current.value = ''
|
||||
},
|
||||
[onChange],
|
||||
)
|
||||
|
||||
const displayText = files.length > 0
|
||||
? files.map((f) => f.name).join(', ')
|
||||
: `Select file${multiple ? 's' : ''}…`
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn('flex flex-col gap-1.5', className)} {...props}>
|
||||
<label className="text-small font-semibold text-control-label">{label}</label>
|
||||
{description && <p className="text-small text-control-description">{description}</p>}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className={cn(
|
||||
'flex h-14 items-center gap-3 rounded-default border px-4 text-left transition-colors',
|
||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-info',
|
||||
error
|
||||
? 'border-control-error'
|
||||
: 'border-control-border hover:border-primary',
|
||||
disabled && 'pointer-events-none opacity-55',
|
||||
)}
|
||||
>
|
||||
<span className={cn('size-6 shrink-0 [&>svg]:size-full', error ? 'text-control-error' : 'text-primary')}>
|
||||
<PaperclipIcon />
|
||||
</span>
|
||||
<span className={cn(
|
||||
'flex-1 truncate text-body',
|
||||
files.length > 0 ? 'text-text' : 'text-text-secondary',
|
||||
)}>
|
||||
{displayText}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
disabled={disabled}
|
||||
onChange={(e) => handleFiles(e.target.files)}
|
||||
className="sr-only"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{files.map((file, i) => (
|
||||
<span
|
||||
key={`${file.name}-${i}`}
|
||||
className="inline-flex items-center gap-1 rounded-default border border-control-border px-2.5 py-1 text-small text-text"
|
||||
>
|
||||
<span className="max-w-48 truncate">{file.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeFile(i)}
|
||||
className="shrink-0 rounded-full p-0.5 hover:bg-text/[0.04]"
|
||||
aria-label={`Remove ${file.name}`}
|
||||
>
|
||||
<svg className="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-small text-control-error">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
FileInput.displayName = 'FileInput'
|
||||
2
src/components/atoms/FileInput/index.ts
Normal file
2
src/components/atoms/FileInput/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { FileInput } from './FileInput'
|
||||
export type { FileInputProps } from './FileInput'
|
||||
152
src/components/atoms/IconButton/IconButton.stories.tsx
Normal file
152
src/components/atoms/IconButton/IconButton.stories.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { X, Plus, Trash2, Menu, ChevronDown, Search, ArrowRight, MoreVertical, Copy, Maximize2 } from 'lucide-react'
|
||||
import { IconButton } from './IconButton'
|
||||
|
||||
const meta: Meta<typeof IconButton> = {
|
||||
title: 'Atoms/IconButton',
|
||||
component: IconButton,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['primary', 'secondary', 'tertiary'],
|
||||
},
|
||||
intent: {
|
||||
control: 'select',
|
||||
options: ['default', 'danger', 'neutral'],
|
||||
},
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['large', 'default', 'compact', 'small', 'xsmall'],
|
||||
},
|
||||
shape: {
|
||||
control: 'select',
|
||||
options: ['circle', 'square'],
|
||||
},
|
||||
disabled: { control: 'boolean' },
|
||||
},
|
||||
parameters: {
|
||||
design: {
|
||||
type: 'figma',
|
||||
url: 'https://www.figma.com/design/mrabO6AtxN3ektGiTk0I9c/ResearchInsights?node-id=81-1137',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: { icon: <X />, 'aria-label': 'Close' },
|
||||
}
|
||||
|
||||
// --- Intents ---
|
||||
|
||||
export const IntentDefault: Story = {
|
||||
args: { icon: <Plus />, 'aria-label': 'Add', intent: 'default' },
|
||||
}
|
||||
|
||||
export const IntentDanger: Story = {
|
||||
args: { icon: <Trash2 />, 'aria-label': 'Delete', intent: 'danger' },
|
||||
}
|
||||
|
||||
export const IntentNeutral: Story = {
|
||||
args: { icon: <Menu />, 'aria-label': 'Menu', intent: 'neutral' },
|
||||
}
|
||||
|
||||
// --- Shapes ---
|
||||
|
||||
export const Circle: Story = {
|
||||
args: { icon: <Plus />, 'aria-label': 'Add', shape: 'circle' },
|
||||
}
|
||||
|
||||
export const Square: Story = {
|
||||
args: { icon: <Plus />, 'aria-label': 'Add', shape: 'square' },
|
||||
}
|
||||
|
||||
export const SquareSecondary: Story = {
|
||||
args: { icon: <ChevronDown />, 'aria-label': 'Expand', shape: 'square', variant: 'secondary' },
|
||||
}
|
||||
|
||||
// --- Sizes ---
|
||||
|
||||
export const AllSizes: Story = {
|
||||
render: () => (
|
||||
<div className="flex items-center gap-4">
|
||||
<IconButton size="large" icon={<X />} aria-label="Close" />
|
||||
<IconButton size="default" icon={<X />} aria-label="Close" />
|
||||
<IconButton size="compact" icon={<X />} aria-label="Close" />
|
||||
<IconButton size="small" icon={<X />} aria-label="Close" />
|
||||
<IconButton size="xsmall" icon={<X />} aria-label="Close" />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const CardActions: Story = {
|
||||
render: () => (
|
||||
<div className="flex items-center gap-1 rounded-lg border border-border bg-surface p-4">
|
||||
<span className="mr-auto text-small font-bold text-text">Card title</span>
|
||||
<IconButton size="small" variant="tertiary" intent="neutral" icon={<Copy />} aria-label="Copy" />
|
||||
<IconButton size="small" variant="tertiary" intent="neutral" icon={<Maximize2 />} aria-label="Expand" />
|
||||
<IconButton size="small" variant="tertiary" intent="neutral" icon={<MoreVertical />} aria-label="More options" />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
// --- Disabled ---
|
||||
|
||||
export const Disabled: Story = {
|
||||
render: () => (
|
||||
<div className="flex items-center gap-4">
|
||||
<IconButton disabled icon={<X />} aria-label="Close" />
|
||||
<IconButton disabled variant="secondary" icon={<X />} aria-label="Close" />
|
||||
<IconButton disabled variant="tertiary" icon={<X />} aria-label="Close" />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
// --- Full matrix ---
|
||||
|
||||
export const AllVariantsAndIntents: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<IconButton size="large" variant="primary" intent="default" icon={<Plus />} aria-label="Add" />
|
||||
<IconButton size="large" variant="primary" intent="danger" icon={<Trash2 />} aria-label="Delete" />
|
||||
<IconButton size="large" variant="primary" intent="neutral" icon={<Menu />} aria-label="Menu" />
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<IconButton size="large" variant="secondary" intent="default" icon={<Search />} aria-label="Search" />
|
||||
<IconButton size="large" variant="secondary" intent="danger" icon={<Trash2 />} aria-label="Delete" />
|
||||
<IconButton size="large" variant="secondary" intent="neutral" icon={<Menu />} aria-label="Menu" />
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<IconButton size="large" variant="tertiary" intent="default" icon={<ArrowRight />} aria-label="Next" />
|
||||
<IconButton size="large" variant="tertiary" intent="danger" icon={<Trash2 />} aria-label="Delete" />
|
||||
<IconButton size="large" variant="tertiary" intent="neutral" icon={<Menu />} aria-label="Menu" />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const SquareMatrix: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<IconButton shape="square" size="large" variant="primary" intent="default" icon={<Plus />} aria-label="Add" />
|
||||
<IconButton shape="square" size="large" variant="primary" intent="danger" icon={<Trash2 />} aria-label="Delete" />
|
||||
<IconButton shape="square" size="large" variant="primary" intent="neutral" icon={<Menu />} aria-label="Menu" />
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<IconButton shape="square" size="large" variant="secondary" intent="default" icon={<Search />} aria-label="Search" />
|
||||
<IconButton shape="square" size="large" variant="secondary" intent="danger" icon={<Trash2 />} aria-label="Delete" />
|
||||
<IconButton shape="square" size="large" variant="secondary" intent="neutral" icon={<Menu />} aria-label="Menu" />
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<IconButton shape="square" size="large" variant="tertiary" intent="default" icon={<ArrowRight />} aria-label="Next" />
|
||||
<IconButton shape="square" size="large" variant="tertiary" intent="danger" icon={<Trash2 />} aria-label="Delete" />
|
||||
<IconButton shape="square" size="large" variant="tertiary" intent="neutral" icon={<Menu />} aria-label="Menu" />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
87
src/components/atoms/IconButton/IconButton.tsx
Normal file
87
src/components/atoms/IconButton/IconButton.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface IconButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
|
||||
variant?: 'primary' | 'secondary' | 'tertiary'
|
||||
intent?: 'default' | 'danger' | 'neutral'
|
||||
size?: 'default' | 'large' | 'compact' | 'small' | 'xsmall'
|
||||
shape?: 'circle' | 'square'
|
||||
icon: ReactNode
|
||||
'aria-label': string
|
||||
}
|
||||
|
||||
const variantIntentStyles: Record<string, Record<string, string>> = {
|
||||
primary: {
|
||||
default: 'bg-button-default text-white hover:bg-button-default/90 active:bg-button-default/80',
|
||||
danger: 'bg-button-danger text-white hover:bg-button-danger/90 active:bg-button-danger/80',
|
||||
neutral: 'bg-button-neutral text-white hover:bg-button-neutral/90 active:bg-button-neutral/80',
|
||||
},
|
||||
secondary: {
|
||||
default:
|
||||
'border-2 border-button-default text-button-default hover:bg-button-default/5 active:bg-button-default/10',
|
||||
danger:
|
||||
'border-2 border-button-danger text-button-danger hover:bg-button-danger/5 active:bg-button-danger/10',
|
||||
neutral:
|
||||
'border-2 border-button-neutral text-button-neutral hover:bg-button-neutral/5 active:bg-button-neutral/10',
|
||||
},
|
||||
tertiary: {
|
||||
default: 'text-button-default hover:bg-button-default/5 active:bg-button-default/10',
|
||||
danger: 'text-button-danger hover:bg-button-danger/5 active:bg-button-danger/10',
|
||||
neutral: 'text-button-neutral hover:bg-button-neutral/5 active:bg-button-neutral/10',
|
||||
},
|
||||
}
|
||||
|
||||
const sizeStyles: Record<string, string> = {
|
||||
large: 'size-14',
|
||||
default: 'size-12',
|
||||
compact: 'size-10',
|
||||
small: 'size-8',
|
||||
xsmall: 'size-6',
|
||||
}
|
||||
|
||||
const iconSizeStyles: Record<string, string> = {
|
||||
large: 'size-6',
|
||||
default: 'size-6',
|
||||
compact: 'size-[18px]',
|
||||
small: 'size-4',
|
||||
xsmall: 'size-3.5',
|
||||
}
|
||||
|
||||
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
||||
(
|
||||
{
|
||||
variant = 'primary',
|
||||
intent = 'default',
|
||||
size = 'default',
|
||||
shape = 'circle',
|
||||
icon,
|
||||
disabled,
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center transition-colors',
|
||||
shape === 'circle' ? 'rounded-full' : 'rounded-lg',
|
||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-button-default',
|
||||
sizeStyles[size],
|
||||
variantIntentStyles[variant][intent],
|
||||
disabled && 'pointer-events-none opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className={cn('shrink-0 [&>svg]:size-full', iconSizeStyles[size])}>
|
||||
{icon}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
IconButton.displayName = 'IconButton'
|
||||
2
src/components/atoms/IconButton/index.ts
Normal file
2
src/components/atoms/IconButton/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { IconButton } from './IconButton'
|
||||
export type { IconButtonProps } from './IconButton'
|
||||
324
src/components/atoms/Input/Input.stories.tsx
Normal file
324
src/components/atoms/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: 'Atoms/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/atoms/Input/Input.tsx
Normal file
193
src/components/atoms/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-text', 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:ring-1 focus-within:ring-control-error'
|
||||
: 'border-control-border hover:border-control-border-hover focus-within:border-control-checked focus-within:ring-1 focus-within:ring-control-checked',
|
||||
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-primary [&>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-text outline-none',
|
||||
'placeholder:text-text/50',
|
||||
styles.input,
|
||||
disabled && 'text-text/50',
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
{rightIcon && (
|
||||
<span
|
||||
className={cn('inline-flex shrink-0 items-center justify-center text-primary [&>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/atoms/Input/index.ts
Normal file
2
src/components/atoms/Input/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Input } from './Input'
|
||||
export type { InputProps } from './Input'
|
||||
57
src/components/atoms/List/List.stories.tsx
Normal file
57
src/components/atoms/List/List.stories.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { List, ListItem, ListSubheader, ListDivider } from './List'
|
||||
|
||||
const HomeIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" /></svg>
|
||||
)
|
||||
const StarIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z" /></svg>
|
||||
)
|
||||
const SettingsIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.488.488 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6A3.6 3.6 0 1115.6 12 3.6 3.6 0 0112 15.6z" /></svg>
|
||||
)
|
||||
|
||||
const meta: Meta<typeof List> = {
|
||||
title: 'Atoms/List',
|
||||
component: List,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'padded' },
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof List>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<List className="w-80 rounded-default shadow-default">
|
||||
<ListItem icon={<HomeIcon />}>Real-Time</ListItem>
|
||||
<ListItem icon={<StarIcon />}>Audience</ListItem>
|
||||
<ListItem icon={<SettingsIcon />}>Conversions</ListItem>
|
||||
</List>
|
||||
),
|
||||
}
|
||||
|
||||
export const WithActive: Story = {
|
||||
name: 'With active item',
|
||||
render: () => (
|
||||
<List className="w-80 rounded-default shadow-default">
|
||||
<ListItem icon={<HomeIcon />} active>Real-Time</ListItem>
|
||||
<ListItem icon={<StarIcon />}>Audience</ListItem>
|
||||
<ListItem icon={<SettingsIcon />}>Conversions</ListItem>
|
||||
</List>
|
||||
),
|
||||
}
|
||||
|
||||
export const WithSubheaders: Story = {
|
||||
name: 'With subheaders',
|
||||
render: () => (
|
||||
<List className="w-80 rounded-default shadow-default">
|
||||
<ListSubheader>Reports</ListSubheader>
|
||||
<ListItem icon={<HomeIcon />}>Real-Time</ListItem>
|
||||
<ListItem icon={<StarIcon />}>Audience</ListItem>
|
||||
<ListDivider />
|
||||
<ListSubheader>Settings</ListSubheader>
|
||||
<ListItem icon={<SettingsIcon />}>Preferences</ListItem>
|
||||
</List>
|
||||
),
|
||||
}
|
||||
96
src/components/atoms/List/List.tsx
Normal file
96
src/components/atoms/List/List.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// --- List ---
|
||||
|
||||
export interface ListProps extends HTMLAttributes<HTMLUListElement> {}
|
||||
|
||||
export const List = forwardRef<HTMLUListElement, ListProps>(
|
||||
({ className, children, ...props }, ref) => (
|
||||
<ul
|
||||
ref={ref}
|
||||
role="list"
|
||||
className={cn('flex flex-col bg-surface', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
)
|
||||
List.displayName = 'List'
|
||||
|
||||
// --- ListItem ---
|
||||
|
||||
export interface ListItemProps extends HTMLAttributes<HTMLLIElement> {
|
||||
icon?: ReactNode
|
||||
active?: boolean
|
||||
disabled?: boolean
|
||||
href?: string
|
||||
}
|
||||
|
||||
export const ListItem = forwardRef<HTMLLIElement, ListItemProps>(
|
||||
({ icon, active = false, disabled = false, href, className, children, ...props }, ref) => {
|
||||
const styles = cn(
|
||||
'flex min-h-12 items-center gap-4 px-4 py-2 transition-colors',
|
||||
active
|
||||
? 'bg-info/12 text-info'
|
||||
: 'text-text hover:bg-text/[0.04]',
|
||||
disabled && 'pointer-events-none opacity-55',
|
||||
className,
|
||||
)
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{icon && (
|
||||
<span className={cn('size-6 shrink-0 [&>svg]:size-full', active ? 'text-info' : 'text-text-secondary')}>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex-1 text-body">{children}</span>
|
||||
</>
|
||||
)
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<li ref={ref} {...props}>
|
||||
<a href={href} className={styles}>{content}</a>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<li ref={ref} role="listitem" className={styles} {...props}>
|
||||
{content}
|
||||
</li>
|
||||
)
|
||||
},
|
||||
)
|
||||
ListItem.displayName = 'ListItem'
|
||||
|
||||
// --- ListSubheader ---
|
||||
|
||||
export interface ListSubheaderProps extends HTMLAttributes<HTMLLIElement> {}
|
||||
|
||||
export const ListSubheader = forwardRef<HTMLLIElement, ListSubheaderProps>(
|
||||
({ className, children, ...props }, ref) => (
|
||||
<li
|
||||
ref={ref}
|
||||
className={cn('flex min-h-10 items-center px-4 text-small font-semibold text-text-secondary', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</li>
|
||||
),
|
||||
)
|
||||
ListSubheader.displayName = 'ListSubheader'
|
||||
|
||||
// --- ListDivider ---
|
||||
|
||||
export interface ListDividerProps extends HTMLAttributes<HTMLLIElement> {}
|
||||
|
||||
export const ListDivider = forwardRef<HTMLLIElement, ListDividerProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<li ref={ref} role="separator" className={cn('border-t border-border', className)} {...props} />
|
||||
),
|
||||
)
|
||||
ListDivider.displayName = 'ListDivider'
|
||||
2
src/components/atoms/List/index.ts
Normal file
2
src/components/atoms/List/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { List, ListItem, ListSubheader, ListDivider } from './List'
|
||||
export type { ListProps, ListItemProps, ListSubheaderProps, ListDividerProps } from './List'
|
||||
138
src/components/atoms/Radio/Radio.stories.tsx
Normal file
138
src/components/atoms/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: 'Atoms/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>
|
||||
),
|
||||
}
|
||||
206
src/components/atoms/Radio/Radio.tsx
Normal file
206
src/components/atoms/Radio/Radio.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
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 hover:border-control-error checked:border-control-error checked:hover:border-control-error focus-visible:ring-red-03',
|
||||
)}
|
||||
{...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/atoms/Radio/index.ts
Normal file
2
src/components/atoms/Radio/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Radio, RadioGroup } from './Radio'
|
||||
export type { RadioProps, RadioGroupProps } from './Radio'
|
||||
209
src/components/atoms/Select/Select.stories.tsx
Normal file
209
src/components/atoms/Select/Select.stories.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import { useState } from 'react'
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { Select } from './Select'
|
||||
|
||||
const yearLevels = [
|
||||
{ value: 'kindergarten', label: 'Kindergarten' },
|
||||
{ value: 'year-1', label: 'Year 1' },
|
||||
{ value: 'year-2', label: 'Year 2' },
|
||||
{ value: 'year-3', label: 'Year 3' },
|
||||
{ value: 'year-4', label: 'Year 4' },
|
||||
{ value: 'year-5', label: 'Year 5' },
|
||||
{ value: 'year-6', label: 'Year 6' },
|
||||
]
|
||||
|
||||
const categories = [
|
||||
{ value: 'wellbeing', label: 'Student wellbeing' },
|
||||
{ value: 'engagement', label: 'Engagement' },
|
||||
{ value: 'curriculum', label: 'Curriculum design' },
|
||||
{ value: 'assessment', label: 'Assessment practices' },
|
||||
{ value: 'inclusion', label: 'Inclusion & diversity' },
|
||||
]
|
||||
|
||||
const meta: Meta<typeof Select> = {
|
||||
title: 'Atoms/Select',
|
||||
component: Select,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['outlined', 'stacked'],
|
||||
},
|
||||
disabled: { control: 'boolean' },
|
||||
placeholder: { control: 'text' },
|
||||
error: { control: 'text' },
|
||||
hint: { control: 'text' },
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="max-w-sm">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
label: 'Year level',
|
||||
options: yearLevels,
|
||||
},
|
||||
}
|
||||
|
||||
export const WithPlaceholder: Story = {
|
||||
args: {
|
||||
label: 'Year level',
|
||||
placeholder: 'Choose a year level...',
|
||||
options: yearLevels,
|
||||
},
|
||||
}
|
||||
|
||||
export const WithHint: Story = {
|
||||
args: {
|
||||
label: 'Research category',
|
||||
hint: 'Choose the primary focus area for this project',
|
||||
options: categories,
|
||||
},
|
||||
}
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
label: 'Year level',
|
||||
error: 'Please select a year level',
|
||||
options: yearLevels,
|
||||
},
|
||||
}
|
||||
|
||||
export const WithDisabledOptions: Story = {
|
||||
args: {
|
||||
label: 'Year level',
|
||||
options: [
|
||||
{ value: 'kindergarten', label: 'Kindergarten' },
|
||||
{ value: 'year-1', label: 'Year 1' },
|
||||
{ value: 'year-2', label: 'Year 2', disabled: true },
|
||||
{ value: 'year-3', label: 'Year 3' },
|
||||
{ value: 'year-4', label: 'Year 4', disabled: true },
|
||||
{ value: 'year-5', label: 'Year 5' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
label: 'Year level',
|
||||
options: yearLevels,
|
||||
disabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
const PreselectedExample = () => {
|
||||
const [value, setValue] = useState('year-2')
|
||||
return (
|
||||
<Select
|
||||
label="Year level"
|
||||
options={yearLevels}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const Preselected: Story = {
|
||||
render: () => <PreselectedExample />,
|
||||
}
|
||||
|
||||
const ControlledExample = () => {
|
||||
const [value, setValue] = useState('')
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Select
|
||||
label="Year level"
|
||||
options={yearLevels}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
/>
|
||||
<p className="text-small text-text-secondary">
|
||||
Selected: {value || '(none)'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Controlled: Story = {
|
||||
render: () => <ControlledExample />,
|
||||
}
|
||||
|
||||
// --- Stacked variant ---
|
||||
|
||||
export const Stacked: Story = {
|
||||
args: {
|
||||
label: 'Research category',
|
||||
placeholder: 'Choose a category...',
|
||||
options: categories,
|
||||
variant: 'stacked',
|
||||
},
|
||||
}
|
||||
|
||||
export const StackedWithDescription: Story = {
|
||||
args: {
|
||||
label: 'Year level',
|
||||
description: 'Select the year level this research applies to.',
|
||||
options: yearLevels,
|
||||
variant: 'stacked',
|
||||
},
|
||||
}
|
||||
|
||||
export const StackedWithError: Story = {
|
||||
args: {
|
||||
label: 'Year level',
|
||||
description: 'Select the year level this research applies to.',
|
||||
error: 'A year level is required',
|
||||
options: yearLevels,
|
||||
variant: 'stacked',
|
||||
},
|
||||
}
|
||||
|
||||
// --- All states ---
|
||||
|
||||
export const AllStates: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Select label="Default" options={yearLevels} />
|
||||
<Select label="With hint" options={yearLevels} hint="Helpful hint text" />
|
||||
<Select label="With error" options={yearLevels} error="This field is required" />
|
||||
<Select label="Disabled" options={yearLevels} disabled />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const StackedAllStates: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Select variant="stacked" label="Default" options={yearLevels} />
|
||||
<Select
|
||||
variant="stacked"
|
||||
label="With description"
|
||||
description="A short description of the field."
|
||||
options={yearLevels}
|
||||
/>
|
||||
<Select
|
||||
variant="stacked"
|
||||
label="With hint"
|
||||
description="Description text here."
|
||||
options={yearLevels}
|
||||
hint="Helpful hint text"
|
||||
/>
|
||||
<Select
|
||||
variant="stacked"
|
||||
label="Error"
|
||||
description="Description text here."
|
||||
options={yearLevels}
|
||||
error="This field is required"
|
||||
/>
|
||||
<Select variant="stacked" label="Disabled" options={yearLevels} disabled />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
360
src/components/atoms/Select/Select.tsx
Normal file
360
src/components/atoms/Select/Select.tsx
Normal file
@@ -0,0 +1,360 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
type HTMLAttributes,
|
||||
} from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface SelectOption {
|
||||
value: string
|
||||
label: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface SelectProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
|
||||
label: string
|
||||
description?: string
|
||||
hint?: string
|
||||
error?: string
|
||||
variant?: 'outlined' | 'stacked'
|
||||
placeholder?: string
|
||||
options: SelectOption[]
|
||||
value?: string
|
||||
defaultValue?: string
|
||||
onChange?: (value: string) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const ChevronIcon = () => (
|
||||
<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 Select = forwardRef<HTMLDivElement, SelectProps>(
|
||||
(
|
||||
{
|
||||
label,
|
||||
description,
|
||||
hint,
|
||||
error,
|
||||
variant = 'outlined',
|
||||
placeholder = 'Select',
|
||||
options,
|
||||
value: controlledValue,
|
||||
defaultValue,
|
||||
onChange,
|
||||
disabled,
|
||||
className,
|
||||
id: idProp,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const autoId = useId()
|
||||
const id = idProp ?? autoId
|
||||
const triggerId = `${id}-trigger`
|
||||
const listboxId = `${id}-listbox`
|
||||
const descriptionId = `${id}-description`
|
||||
const hintId = `${id}-hint`
|
||||
const hasError = !!error
|
||||
const supportiveText = error || hint
|
||||
const isStacked = variant === 'stacked'
|
||||
|
||||
const isControlled = controlledValue !== undefined
|
||||
const [internalValue, setInternalValue] = useState(defaultValue ?? '')
|
||||
const selectedValue = isControlled ? controlledValue : internalValue
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [activeIndex, setActiveIndex] = useState(-1)
|
||||
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const listboxRef = useRef<HTMLUListElement>(null)
|
||||
|
||||
const selectedOption = options.find((o) => o.value === selectedValue)
|
||||
|
||||
const selectableOptions = options.filter((o) => !o.disabled)
|
||||
|
||||
const describedBy =
|
||||
[
|
||||
description && isStacked ? descriptionId : undefined,
|
||||
supportiveText ? hintId : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ') || undefined
|
||||
|
||||
const selectOption = useCallback(
|
||||
(optionValue: string) => {
|
||||
if (!isControlled) setInternalValue(optionValue)
|
||||
onChange?.(optionValue)
|
||||
setIsOpen(false)
|
||||
triggerRef.current?.focus()
|
||||
},
|
||||
[isControlled, onChange],
|
||||
)
|
||||
|
||||
const open = useCallback(() => {
|
||||
if (disabled) return
|
||||
setIsOpen(true)
|
||||
const idx = options.findIndex((o) => o.value === selectedValue && !o.disabled)
|
||||
setActiveIndex(idx >= 0 ? idx : selectableOptions.length > 0 ? options.indexOf(selectableOptions[0]) : -1)
|
||||
}, [disabled, options, selectedValue, selectableOptions])
|
||||
|
||||
const close = useCallback(() => {
|
||||
setIsOpen(false)
|
||||
setActiveIndex(-1)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as Node
|
||||
if (!triggerRef.current?.contains(target) && !listboxRef.current?.contains(target)) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [isOpen, close])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || activeIndex < 0) return
|
||||
const activeEl = listboxRef.current?.children[activeIndex] as HTMLElement | undefined
|
||||
activeEl?.scrollIntoView({ block: 'nearest' })
|
||||
}, [isOpen, activeIndex])
|
||||
|
||||
const handleTriggerKeyDown = (e: React.KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
open()
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
open()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const findNextIndex = (from: number, direction: 1 | -1): number => {
|
||||
let idx = from + direction
|
||||
while (idx >= 0 && idx < options.length) {
|
||||
if (!options[idx].disabled) return idx
|
||||
idx += direction
|
||||
}
|
||||
return from
|
||||
}
|
||||
|
||||
const handleListboxKeyDown = (e: React.KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
setActiveIndex((prev) => findNextIndex(prev, 1))
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
setActiveIndex((prev) => findNextIndex(prev, -1))
|
||||
break
|
||||
case 'Home':
|
||||
e.preventDefault()
|
||||
setActiveIndex(options.findIndex((o) => !o.disabled))
|
||||
break
|
||||
case 'End':
|
||||
e.preventDefault()
|
||||
for (let i = options.length - 1; i >= 0; i--) {
|
||||
if (!options[i].disabled) {
|
||||
setActiveIndex(i)
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
e.preventDefault()
|
||||
if (activeIndex >= 0 && !options[activeIndex].disabled) {
|
||||
selectOption(options[activeIndex].value)
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
case 'Tab':
|
||||
e.preventDefault()
|
||||
close()
|
||||
triggerRef.current?.focus()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('relative flex w-full flex-col', isStacked ? 'gap-1.5' : 'gap-1 pt-2', className)}
|
||||
{...props}
|
||||
>
|
||||
{isStacked && (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<label
|
||||
id={`${id}-label`}
|
||||
htmlFor={triggerId}
|
||||
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-text', disabled && 'opacity-50')}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={triggerRef}
|
||||
id={triggerId}
|
||||
type="button"
|
||||
role="combobox"
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
aria-controls={listboxId}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={hasError || undefined}
|
||||
aria-labelledby={isStacked ? `${id}-label` : undefined}
|
||||
disabled={disabled}
|
||||
onClick={() => (isOpen ? close() : open())}
|
||||
onKeyDown={handleTriggerKeyDown}
|
||||
className={cn(
|
||||
'flex h-12 w-full items-center rounded-[4px] border bg-control-bg px-3 text-left transition-colors',
|
||||
hasError
|
||||
? 'border-control-error focus:ring-1 focus:ring-control-error'
|
||||
: 'border-control-border hover:border-control-border-hover focus:border-control-checked focus:ring-1 focus:ring-control-checked',
|
||||
isOpen &&
|
||||
(hasError
|
||||
? 'ring-1 ring-control-error'
|
||||
: 'border-control-checked ring-1 ring-control-checked'),
|
||||
disabled && 'pointer-events-none border-control-border/50 bg-control-bg/50',
|
||||
)}
|
||||
>
|
||||
{!isStacked && (
|
||||
<span
|
||||
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}
|
||||
</span>
|
||||
)}
|
||||
<span className={cn('flex-1 truncate text-body', selectedOption ? 'text-text' : 'text-text/50')}>
|
||||
{selectedOption?.label ?? placeholder}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'size-5 shrink-0 transition-transform duration-200 [&>svg]:size-full',
|
||||
isOpen && 'rotate-180',
|
||||
)}
|
||||
>
|
||||
<ChevronIcon />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<ul
|
||||
ref={listboxRef}
|
||||
id={listboxId}
|
||||
role="listbox"
|
||||
aria-labelledby={isStacked ? `${id}-label` : triggerId}
|
||||
tabIndex={0}
|
||||
onKeyDown={handleListboxKeyDown}
|
||||
onFocus={() => {
|
||||
if (activeIndex < 0) {
|
||||
const idx = options.findIndex((o) => o.value === selectedValue && !o.disabled)
|
||||
setActiveIndex(idx >= 0 ? idx : options.findIndex((o) => !o.disabled))
|
||||
}
|
||||
}}
|
||||
className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border border-border bg-surface py-1 shadow-md"
|
||||
>
|
||||
{options.map((option, index) => (
|
||||
<li
|
||||
key={option.value}
|
||||
id={`${id}-option-${index}`}
|
||||
role="option"
|
||||
aria-selected={option.value === selectedValue}
|
||||
aria-disabled={option.disabled || undefined}
|
||||
onMouseEnter={() => !option.disabled && setActiveIndex(index)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
if (!option.disabled) selectOption(option.value)
|
||||
}}
|
||||
className={cn(
|
||||
'cursor-pointer px-4 py-2.5 text-body text-text transition-colors',
|
||||
option.value === selectedValue && 'bg-info/12 font-bold',
|
||||
index === activeIndex && option.value !== selectedValue && 'bg-info/5',
|
||||
option.disabled && 'pointer-events-none text-text/30',
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{supportiveText && (
|
||||
<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>
|
||||
)}
|
||||
<p className="flex-1">{supportiveText}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
Select.displayName = 'Select'
|
||||
2
src/components/atoms/Select/index.ts
Normal file
2
src/components/atoms/Select/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Select } from './Select'
|
||||
export type { SelectProps, SelectOption } from './Select'
|
||||
63
src/components/atoms/Slider/Slider.stories.tsx
Normal file
63
src/components/atoms/Slider/Slider.stories.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { useState } from 'react'
|
||||
import { Slider, RangeSlider } from './Slider'
|
||||
|
||||
const meta: Meta<typeof Slider> = {
|
||||
title: 'Atoms/Slider',
|
||||
component: Slider,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'padded' },
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof Slider>
|
||||
|
||||
const SliderTemplate = () => {
|
||||
const [value, setValue] = useState(40)
|
||||
return (
|
||||
<div className="w-80">
|
||||
<Slider label="Volume" value={value} onChange={setValue} />
|
||||
<p className="mt-2 text-small text-text-secondary">Value: {value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => <SliderTemplate />,
|
||||
}
|
||||
|
||||
const SteppedTemplate = () => {
|
||||
const [value, setValue] = useState(50)
|
||||
return (
|
||||
<div className="w-80">
|
||||
<Slider label="Brightness" value={value} onChange={setValue} min={0} max={100} step={10} />
|
||||
<p className="mt-2 text-small text-text-secondary">Value: {value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Stepped: Story = {
|
||||
render: () => <SteppedTemplate />,
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
render: () => (
|
||||
<div className="w-80">
|
||||
<Slider label="Disabled" value={30} onChange={() => {}} disabled />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
const RangeTemplate = () => {
|
||||
const [value, setValue] = useState<[number, number]>([20, 80])
|
||||
return (
|
||||
<div className="w-80">
|
||||
<RangeSlider label="Price range" value={value} onChange={setValue} />
|
||||
<p className="mt-2 text-small text-text-secondary">Range: {value[0]} – {value[1]}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Range: Story = {
|
||||
render: () => <RangeTemplate />,
|
||||
}
|
||||
201
src/components/atoms/Slider/Slider.tsx
Normal file
201
src/components/atoms/Slider/Slider.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { forwardRef, useCallback, useRef, type HTMLAttributes } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface SliderProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
|
||||
label?: string
|
||||
value: number
|
||||
onChange: (value: number) => void
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export const Slider = forwardRef<HTMLDivElement, SliderProps>(
|
||||
({ label, value, onChange, min = 0, max = 100, step = 1, disabled = false, className, ...props }, ref) => {
|
||||
const trackRef = useRef<HTMLDivElement>(null)
|
||||
const percent = ((value - min) / (max - min)) * 100
|
||||
|
||||
const handlePointer = useCallback(
|
||||
(clientX: number) => {
|
||||
const track = trackRef.current
|
||||
if (!track || disabled) return
|
||||
const rect = track.getBoundingClientRect()
|
||||
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
|
||||
const raw = min + ratio * (max - min)
|
||||
const stepped = Math.round(raw / step) * step
|
||||
onChange(Math.max(min, Math.min(max, stepped)))
|
||||
},
|
||||
[min, max, step, disabled, onChange],
|
||||
)
|
||||
|
||||
const onPointerDown = (e: React.PointerEvent) => {
|
||||
if (disabled) return
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
handlePointer(e.clientX)
|
||||
}
|
||||
|
||||
const onPointerMove = (e: React.PointerEvent) => {
|
||||
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
||||
handlePointer(e.clientX)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn('flex flex-col gap-2', disabled && 'opacity-55', className)} {...props}>
|
||||
{label && (
|
||||
<label className="text-small font-semibold text-control-label">{label}</label>
|
||||
)}
|
||||
<div
|
||||
ref={trackRef}
|
||||
role="slider"
|
||||
aria-label={label}
|
||||
aria-valuemin={min}
|
||||
aria-valuemax={max}
|
||||
aria-valuenow={value}
|
||||
aria-disabled={disabled || undefined}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
className="relative flex h-10 cursor-pointer touch-none items-center"
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onKeyDown={(e) => {
|
||||
if (disabled) return
|
||||
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
onChange(Math.min(max, value + step))
|
||||
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
onChange(Math.max(min, value - step))
|
||||
} else if (e.key === 'Home') {
|
||||
e.preventDefault()
|
||||
onChange(min)
|
||||
} else if (e.key === 'End') {
|
||||
e.preventDefault()
|
||||
onChange(max)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="h-1 w-full rounded-full bg-grey-03">
|
||||
<div className="h-full rounded-full bg-primary" style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
<div
|
||||
className="absolute -ml-[9px] size-[18px] rounded-full bg-primary shadow-md transition-shadow focus-visible:ring-2 focus-visible:ring-control-focus-ring"
|
||||
style={{ left: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
Slider.displayName = 'Slider'
|
||||
|
||||
// --- RangeSlider ---
|
||||
|
||||
export interface RangeSliderProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
|
||||
label?: string
|
||||
value: [number, number]
|
||||
onChange: (value: [number, number]) => void
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export const RangeSlider = forwardRef<HTMLDivElement, RangeSliderProps>(
|
||||
({ label, value, onChange, min = 0, max = 100, step = 1, disabled = false, className, ...props }, ref) => {
|
||||
const trackRef = useRef<HTMLDivElement>(null)
|
||||
const activeThumb = useRef<0 | 1>(0)
|
||||
|
||||
const toPercent = (v: number) => ((v - min) / (max - min)) * 100
|
||||
const lowPct = toPercent(value[0])
|
||||
const highPct = toPercent(value[1])
|
||||
|
||||
const snap = useCallback(
|
||||
(clientX: number) => {
|
||||
const track = trackRef.current
|
||||
if (!track || disabled) return
|
||||
const rect = track.getBoundingClientRect()
|
||||
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
|
||||
const raw = min + ratio * (max - min)
|
||||
const stepped = Math.round(raw / step) * step
|
||||
const clamped = Math.max(min, Math.min(max, stepped))
|
||||
|
||||
const next: [number, number] = [...value]
|
||||
if (activeThumb.current === 0) {
|
||||
next[0] = Math.min(clamped, value[1])
|
||||
} else {
|
||||
next[1] = Math.max(clamped, value[0])
|
||||
}
|
||||
onChange(next)
|
||||
},
|
||||
[min, max, step, value, disabled, onChange],
|
||||
)
|
||||
|
||||
const pickThumb = (clientX: number) => {
|
||||
const track = trackRef.current
|
||||
if (!track) return
|
||||
const rect = track.getBoundingClientRect()
|
||||
const ratio = (clientX - rect.left) / rect.width
|
||||
const pos = min + ratio * (max - min)
|
||||
activeThumb.current = Math.abs(pos - value[0]) <= Math.abs(pos - value[1]) ? 0 : 1
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn('flex flex-col gap-2', disabled && 'opacity-55', className)} {...props}>
|
||||
{label && (
|
||||
<label className="text-small font-semibold text-control-label">{label}</label>
|
||||
)}
|
||||
<div
|
||||
ref={trackRef}
|
||||
aria-label={label}
|
||||
className="relative flex h-10 cursor-pointer touch-none items-center"
|
||||
onPointerDown={(e) => {
|
||||
if (disabled) return
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
pickThumb(e.clientX)
|
||||
snap(e.clientX)
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
if (e.currentTarget.hasPointerCapture(e.pointerId)) snap(e.clientX)
|
||||
}}
|
||||
>
|
||||
<div className="h-1 w-full rounded-full bg-grey-03">
|
||||
<div
|
||||
className="absolute h-1 rounded-full bg-primary"
|
||||
style={{ left: `${lowPct}%`, width: `${highPct - lowPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
{[lowPct, highPct].map((pct, i) => (
|
||||
<div
|
||||
key={i}
|
||||
role="slider"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
aria-valuemin={min}
|
||||
aria-valuemax={max}
|
||||
aria-valuenow={value[i]}
|
||||
aria-label={`${label || 'Range'} ${i === 0 ? 'minimum' : 'maximum'}`}
|
||||
className="absolute -ml-[9px] size-[18px] rounded-full bg-primary shadow-md focus-visible:ring-2 focus-visible:ring-control-focus-ring"
|
||||
style={{ left: `${pct}%` }}
|
||||
onFocus={() => { activeThumb.current = i as 0 | 1 }}
|
||||
onKeyDown={(e) => {
|
||||
if (disabled) return
|
||||
const delta = e.key === 'ArrowRight' || e.key === 'ArrowUp' ? step
|
||||
: e.key === 'ArrowLeft' || e.key === 'ArrowDown' ? -step
|
||||
: e.key === 'Home' ? min - value[i]
|
||||
: e.key === 'End' ? max - value[i]
|
||||
: 0
|
||||
if (!delta) return
|
||||
e.preventDefault()
|
||||
const next: [number, number] = [...value]
|
||||
next[i] = Math.max(min, Math.min(max, value[i] + delta))
|
||||
if (next[0] > next[1]) return
|
||||
onChange(next)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
RangeSlider.displayName = 'RangeSlider'
|
||||
2
src/components/atoms/Slider/index.ts
Normal file
2
src/components/atoms/Slider/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Slider, RangeSlider } from './Slider'
|
||||
export type { SliderProps, RangeSliderProps } from './Slider'
|
||||
85
src/components/atoms/Switch/Switch.stories.tsx
Normal file
85
src/components/atoms/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: 'Atoms/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/atoms/Switch/Switch.tsx
Normal file
89
src/components/atoms/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-5 w-9 shrink-0 cursor-pointer items-center rounded-full transition-colors duration-150',
|
||||
checked ? 'bg-switch-on' : 'bg-control-border',
|
||||
!disabled && checked && 'hover:bg-switch-on-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-3.5 rounded-full bg-white shadow-default transition-transform duration-150',
|
||||
checked ? 'translate-x-[18px]' : 'translate-x-[2px]',
|
||||
)}
|
||||
/>
|
||||
</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/atoms/Switch/index.ts
Normal file
2
src/components/atoms/Switch/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Switch } from './Switch'
|
||||
export type { SwitchProps } from './Switch'
|
||||
82
src/components/atoms/Tabs/Tabs.stories.tsx
Normal file
82
src/components/atoms/Tabs/Tabs.stories.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { useState } from 'react'
|
||||
import { Tabs, TabList, Tab, TabPanel } from './Tabs'
|
||||
|
||||
const meta: Meta<typeof Tabs> = {
|
||||
title: 'Atoms/Tabs',
|
||||
component: Tabs,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'padded' },
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof Tabs>
|
||||
|
||||
const BasicTemplate = () => {
|
||||
const [value, setValue] = useState('tab1')
|
||||
return (
|
||||
<Tabs value={value} onChange={setValue}>
|
||||
<TabList>
|
||||
<Tab value="tab1">Overview</Tab>
|
||||
<Tab value="tab2">Details</Tab>
|
||||
<Tab value="tab3">History</Tab>
|
||||
</TabList>
|
||||
<TabPanel value="tab1">Overview content goes here.</TabPanel>
|
||||
<TabPanel value="tab2">Details content goes here.</TabPanel>
|
||||
<TabPanel value="tab3">History content goes here.</TabPanel>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => <BasicTemplate />,
|
||||
}
|
||||
|
||||
const WithIconsTemplate = () => {
|
||||
const [value, setValue] = useState('status')
|
||||
|
||||
const StatusIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" /></svg>
|
||||
)
|
||||
const DetailsIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z" /></svg>
|
||||
)
|
||||
|
||||
return (
|
||||
<Tabs value={value} onChange={setValue}>
|
||||
<TabList>
|
||||
<Tab value="status" icon={<StatusIcon />}>Status</Tab>
|
||||
<Tab value="details" icon={<DetailsIcon />}>Details</Tab>
|
||||
<Tab value="disabled" disabled>Disabled</Tab>
|
||||
</TabList>
|
||||
<TabPanel value="status">Status panel content.</TabPanel>
|
||||
<TabPanel value="details">Details panel content.</TabPanel>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
||||
export const WithIcons: Story = {
|
||||
name: 'With icons',
|
||||
render: () => <WithIconsTemplate />,
|
||||
}
|
||||
|
||||
const ManyTabsTemplate = () => {
|
||||
const [value, setValue] = useState('tab1')
|
||||
return (
|
||||
<Tabs value={value} onChange={setValue}>
|
||||
<TabList>
|
||||
{Array.from({ length: 8 }, (_, i) => (
|
||||
<Tab key={i} value={`tab${i + 1}`}>Tab {i + 1}</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
{Array.from({ length: 8 }, (_, i) => (
|
||||
<TabPanel key={i} value={`tab${i + 1}`}>Content for tab {i + 1}</TabPanel>
|
||||
))}
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
||||
export const ManyTabs: Story = {
|
||||
name: 'Many tabs',
|
||||
render: () => <ManyTabsTemplate />,
|
||||
}
|
||||
141
src/components/atoms/Tabs/Tabs.tsx
Normal file
141
src/components/atoms/Tabs/Tabs.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
createContext,
|
||||
forwardRef,
|
||||
useContext,
|
||||
useId,
|
||||
useMemo,
|
||||
type HTMLAttributes,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// --- Context ---
|
||||
|
||||
interface TabsContextValue {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
baseId: string
|
||||
}
|
||||
|
||||
const TabsContext = createContext<TabsContextValue | null>(null)
|
||||
|
||||
function useTabsContext() {
|
||||
const ctx = useContext(TabsContext)
|
||||
if (!ctx) throw new Error('Tab components must be used within Tabs')
|
||||
return ctx
|
||||
}
|
||||
|
||||
// --- Tabs ---
|
||||
|
||||
export interface TabsProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
export const Tabs = forwardRef<HTMLDivElement, TabsProps>(
|
||||
({ value, onChange, className, children, ...props }, ref) => {
|
||||
const baseId = useId()
|
||||
const ctx = useMemo(() => ({ value, onChange, baseId }), [value, onChange, baseId])
|
||||
|
||||
return (
|
||||
<TabsContext.Provider value={ctx}>
|
||||
<div ref={ref} className={className} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
</TabsContext.Provider>
|
||||
)
|
||||
},
|
||||
)
|
||||
Tabs.displayName = 'Tabs'
|
||||
|
||||
// --- TabList ---
|
||||
|
||||
export interface TabListProps extends HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export const TabList = forwardRef<HTMLDivElement, TabListProps>(
|
||||
({ className, children, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="tablist"
|
||||
className={cn('flex border-b border-border', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
)
|
||||
TabList.displayName = 'TabList'
|
||||
|
||||
// --- Tab ---
|
||||
|
||||
export interface TabProps extends HTMLAttributes<HTMLButtonElement> {
|
||||
value: string
|
||||
icon?: ReactNode
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export const Tab = forwardRef<HTMLButtonElement, TabProps>(
|
||||
({ value, icon, disabled = false, className, children, ...props }, ref) => {
|
||||
const { value: selected, onChange, baseId } = useTabsContext()
|
||||
const isSelected = value === selected
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
role="tab"
|
||||
type="button"
|
||||
id={`${baseId}-tab-${value}`}
|
||||
aria-selected={isSelected}
|
||||
aria-controls={`${baseId}-panel-${value}`}
|
||||
tabIndex={isSelected ? 0 : -1}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(value)}
|
||||
className={cn(
|
||||
'relative flex items-center gap-2 px-4 py-3 text-body font-semibold transition-colors',
|
||||
'focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-info',
|
||||
isSelected
|
||||
? 'text-primary'
|
||||
: 'text-text/80 hover:text-text',
|
||||
isSelected && 'after:absolute after:bottom-0 after:left-0 after:right-0 after:h-1 after:bg-error',
|
||||
disabled && 'pointer-events-none opacity-55',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{icon && <span className="size-5 shrink-0 [&>svg]:size-full">{icon}</span>}
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
)
|
||||
Tab.displayName = 'Tab'
|
||||
|
||||
// --- TabPanel ---
|
||||
|
||||
export interface TabPanelProps extends HTMLAttributes<HTMLDivElement> {
|
||||
value: string
|
||||
}
|
||||
|
||||
export const TabPanel = forwardRef<HTMLDivElement, TabPanelProps>(
|
||||
({ value, className, children, ...props }, ref) => {
|
||||
const { value: selected, baseId } = useTabsContext()
|
||||
const isSelected = value === selected
|
||||
|
||||
if (!isSelected) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="tabpanel"
|
||||
id={`${baseId}-panel-${value}`}
|
||||
aria-labelledby={`${baseId}-tab-${value}`}
|
||||
tabIndex={0}
|
||||
className={cn('pt-4', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
TabPanel.displayName = 'TabPanel'
|
||||
2
src/components/atoms/Tabs/index.ts
Normal file
2
src/components/atoms/Tabs/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Tabs, TabList, Tab, TabPanel } from './Tabs'
|
||||
export type { TabsProps, TabListProps, TabProps, TabPanelProps } from './Tabs'
|
||||
201
src/components/atoms/Tag/Tag.stories.tsx
Normal file
201
src/components/atoms/Tag/Tag.stories.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { useState } from 'react'
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { Hash, User, BookOpen } from 'lucide-react'
|
||||
import { Tag } from './Tag'
|
||||
import type { TagColor } from './Tag'
|
||||
|
||||
const meta: Meta<typeof Tag> = {
|
||||
title: 'Atoms/Tag',
|
||||
component: Tag,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['outline', 'filled', 'light'],
|
||||
},
|
||||
color: {
|
||||
control: 'select',
|
||||
options: ['navy', 'blue', 'green', 'red', 'orange', 'grey'],
|
||||
},
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['default', 'sm'],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: 'Student engagement',
|
||||
},
|
||||
}
|
||||
|
||||
// --- Variants ---
|
||||
|
||||
export const Outline: Story = {
|
||||
args: {
|
||||
variant: 'outline',
|
||||
children: 'Outline',
|
||||
},
|
||||
}
|
||||
|
||||
export const Filled: Story = {
|
||||
args: {
|
||||
variant: 'filled',
|
||||
children: 'Filled',
|
||||
},
|
||||
}
|
||||
|
||||
export const Light: Story = {
|
||||
args: {
|
||||
variant: 'light',
|
||||
children: 'Light',
|
||||
},
|
||||
}
|
||||
|
||||
// --- With icon ---
|
||||
|
||||
export const WithIcon: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Tag icon={<Hash />}>Theme</Tag>
|
||||
<Tag icon={<User />} variant="filled">Participant</Tag>
|
||||
<Tag icon={<BookOpen />} variant="light">Literature</Tag>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
// --- Removable ---
|
||||
|
||||
export const Removable: Story = {
|
||||
render: () => {
|
||||
const [tags, setTags] = useState(['Student engagement', 'Teacher wellbeing', 'Curriculum design', 'Assessment'])
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tags.map((tag) => (
|
||||
<Tag key={tag} onRemove={() => setTags((t) => t.filter((v) => v !== tag))}>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
{tags.length === 0 && <p className="text-small text-text-secondary">All tags removed</p>}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
// --- All colours ---
|
||||
|
||||
const colors: TagColor[] = ['navy', 'blue', 'green', 'red', 'orange', 'grey']
|
||||
|
||||
export const AllColours: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<p className="mb-2 text-small font-bold text-text-secondary">Outline</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{colors.map((c) => (
|
||||
<Tag key={c} color={c} variant="outline">{c}</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 text-small font-bold text-text-secondary">Filled</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{colors.map((c) => (
|
||||
<Tag key={c} color={c} variant="filled">{c}</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 text-small font-bold text-text-secondary">Light</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{colors.map((c) => (
|
||||
<Tag key={c} color={c} variant="light">{c}</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
// --- All variants (with features) ---
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<p className="mb-2 text-small font-bold text-text-secondary">Outline</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Tag variant="outline">Default</Tag>
|
||||
<Tag variant="outline" icon={<Hash />}>With icon</Tag>
|
||||
<Tag variant="outline" onRemove={() => {}}>Removable</Tag>
|
||||
<Tag variant="outline" size="sm">Small</Tag>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 text-small font-bold text-text-secondary">Filled</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Tag variant="filled">Default</Tag>
|
||||
<Tag variant="filled" icon={<Hash />}>With icon</Tag>
|
||||
<Tag variant="filled" onRemove={() => {}}>Removable</Tag>
|
||||
<Tag variant="filled" size="sm">Small</Tag>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 text-small font-bold text-text-secondary">Light</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Tag variant="light">Default</Tag>
|
||||
<Tag variant="light" icon={<Hash />}>With icon</Tag>
|
||||
<Tag variant="light" onRemove={() => {}}>Removable</Tag>
|
||||
<Tag variant="light" size="sm">Small</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
// --- Realistic usage ---
|
||||
|
||||
export const ThemeLabels: Story = {
|
||||
render: () => (
|
||||
<div className="max-w-md rounded-xl border border-border bg-surface p-4">
|
||||
<p className="mb-3 text-small font-bold text-text">Assigned themes</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Tag variant="light" color="blue" icon={<Hash />}>Student engagement</Tag>
|
||||
<Tag variant="light" color="green" icon={<Hash />}>Digital literacy</Tag>
|
||||
<Tag variant="light" color="orange" icon={<Hash />}>Rural access</Tag>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
// --- Removable coloured ---
|
||||
|
||||
export const RemovableColoured: Story = {
|
||||
render: () => {
|
||||
const initial: { label: string; color: TagColor }[] = [
|
||||
{ label: 'Qualitative', color: 'blue' },
|
||||
{ label: 'Approved', color: 'green' },
|
||||
{ label: 'Urgent', color: 'red' },
|
||||
{ label: 'Draft', color: 'orange' },
|
||||
{ label: 'Archived', color: 'grey' },
|
||||
]
|
||||
const [tags, setTags] = useState(initial)
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tags.map((tag) => (
|
||||
<Tag
|
||||
key={tag.label}
|
||||
color={tag.color}
|
||||
onRemove={() => setTags((t) => t.filter((v) => v.label !== tag.label))}
|
||||
>
|
||||
{tag.label}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
126
src/components/atoms/Tag/Tag.tsx
Normal file
126
src/components/atoms/Tag/Tag.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type TagColor =
|
||||
| 'navy' | 'blue' | 'green' | 'red' | 'orange' | 'grey'
|
||||
| 'teal' | 'brown' | 'purple' | 'fuchsia' | 'yellow'
|
||||
|
||||
export interface TagProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
variant?: 'outline' | 'filled' | 'light'
|
||||
color?: TagColor
|
||||
size?: 'default' | 'sm'
|
||||
icon?: ReactNode
|
||||
onRemove?: () => void
|
||||
}
|
||||
|
||||
const colorVariantStyles: Record<TagColor, Record<string, string>> = {
|
||||
navy: {
|
||||
outline: 'border border-tag-navy text-tag-navy',
|
||||
filled: 'bg-tag-navy text-white',
|
||||
light: 'bg-tag-navy-light text-tag-navy',
|
||||
},
|
||||
blue: {
|
||||
outline: 'border border-tag-blue text-tag-blue',
|
||||
filled: 'bg-tag-blue text-white',
|
||||
light: 'bg-tag-blue-light text-tag-blue',
|
||||
},
|
||||
green: {
|
||||
outline: 'border border-tag-green text-tag-green',
|
||||
filled: 'bg-tag-green text-white',
|
||||
light: 'bg-tag-green-light text-tag-green',
|
||||
},
|
||||
red: {
|
||||
outline: 'border border-tag-red text-tag-red',
|
||||
filled: 'bg-tag-red text-white',
|
||||
light: 'bg-tag-red-light text-tag-red',
|
||||
},
|
||||
orange: {
|
||||
outline: 'border border-tag-orange text-tag-orange',
|
||||
filled: 'bg-tag-orange text-white',
|
||||
light: 'bg-tag-orange-light text-tag-orange',
|
||||
},
|
||||
grey: {
|
||||
outline: 'border border-tag-grey text-tag-grey',
|
||||
filled: 'bg-tag-grey text-white',
|
||||
light: 'bg-tag-grey-light text-tag-grey',
|
||||
},
|
||||
teal: {
|
||||
outline: 'border border-tag-teal text-tag-teal',
|
||||
filled: 'bg-tag-teal text-white',
|
||||
light: 'bg-tag-teal-light text-tag-teal',
|
||||
},
|
||||
brown: {
|
||||
outline: 'border border-tag-brown text-tag-brown',
|
||||
filled: 'bg-tag-brown text-white',
|
||||
light: 'bg-tag-brown-light text-tag-brown',
|
||||
},
|
||||
purple: {
|
||||
outline: 'border border-tag-purple text-tag-purple',
|
||||
filled: 'bg-tag-purple text-white',
|
||||
light: 'bg-tag-purple-light text-tag-purple',
|
||||
},
|
||||
fuchsia: {
|
||||
outline: 'border border-tag-fuchsia text-tag-fuchsia',
|
||||
filled: 'bg-tag-fuchsia text-white',
|
||||
light: 'bg-tag-fuchsia-light text-tag-fuchsia',
|
||||
},
|
||||
yellow: {
|
||||
outline: 'border border-tag-yellow text-tag-yellow',
|
||||
filled: 'bg-tag-yellow text-white',
|
||||
light: 'bg-tag-yellow-light text-tag-yellow',
|
||||
},
|
||||
}
|
||||
|
||||
const sizeStyles: Record<string, string> = {
|
||||
default: 'h-7 px-2.5 text-small gap-1.5',
|
||||
sm: 'h-5 px-2 text-caption gap-1',
|
||||
}
|
||||
|
||||
const removeHoverStyles: Record<string, string> = {
|
||||
outline: 'hover:bg-current/10',
|
||||
filled: 'hover:bg-white/20',
|
||||
light: 'hover:bg-current/10',
|
||||
}
|
||||
|
||||
const removeSizeStyles: Record<string, string> = {
|
||||
default: 'size-4',
|
||||
sm: 'size-3',
|
||||
}
|
||||
|
||||
export const Tag = forwardRef<HTMLSpanElement, TagProps>(
|
||||
({ variant = 'outline', color = 'navy', size = 'default', icon, onRemove, className, children, ...props }, ref) => (
|
||||
<span
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full font-medium leading-none',
|
||||
colorVariantStyles[color][variant],
|
||||
sizeStyles[size],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{icon && <span className="shrink-0 [&>svg]:size-3.5">{icon}</span>}
|
||||
<span className="truncate">{children}</span>
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRemove()
|
||||
}}
|
||||
className={cn(
|
||||
'-mr-0.5 shrink-0 rounded-full p-px transition-colors',
|
||||
removeHoverStyles[variant],
|
||||
)}
|
||||
aria-label="Remove"
|
||||
>
|
||||
<svg className={removeSizeStyles[size]} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
)
|
||||
Tag.displayName = 'Tag'
|
||||
2
src/components/atoms/Tag/index.ts
Normal file
2
src/components/atoms/Tag/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Tag } from './Tag'
|
||||
export type { TagProps, TagColor } from './Tag'
|
||||
218
src/components/atoms/Textarea/Textarea.stories.tsx
Normal file
218
src/components/atoms/Textarea/Textarea.stories.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import { useState } from 'react'
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { Textarea } from './Textarea'
|
||||
|
||||
const meta: Meta<typeof Textarea> = {
|
||||
title: 'Atoms/Textarea',
|
||||
component: Textarea,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
label: { control: 'text' },
|
||||
description: { control: 'text' },
|
||||
hint: { control: 'text' },
|
||||
error: { control: 'text' },
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['outlined', 'stacked'],
|
||||
},
|
||||
resize: {
|
||||
control: 'select',
|
||||
options: ['vertical', 'horizontal', 'both', 'none'],
|
||||
},
|
||||
autoResize: { control: 'boolean' },
|
||||
rows: { control: 'number' },
|
||||
disabled: { control: 'boolean' },
|
||||
readOnly: { control: 'boolean' },
|
||||
placeholder: { control: 'text' },
|
||||
},
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
label: 'Description',
|
||||
placeholder: 'Enter a description...',
|
||||
},
|
||||
}
|
||||
|
||||
export const WithHint: Story = {
|
||||
args: {
|
||||
label: 'Research notes',
|
||||
placeholder: 'Record your observations...',
|
||||
hint: 'Include participant quotes where possible',
|
||||
},
|
||||
}
|
||||
|
||||
export const WithValue: Story = {
|
||||
args: {
|
||||
label: 'Summary',
|
||||
defaultValue:
|
||||
'Participants expressed a strong preference for visual feedback during task completion. Several noted that the absence of confirmation messages led to uncertainty about whether their input had been saved.',
|
||||
},
|
||||
}
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
label: 'Findings',
|
||||
defaultValue: 'Too short.',
|
||||
error: 'Please provide at least 50 characters',
|
||||
},
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
label: 'Description',
|
||||
placeholder: 'Enter a description...',
|
||||
disabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
export const DisabledWithValue: Story = {
|
||||
args: {
|
||||
label: 'Previous notes',
|
||||
defaultValue: 'This field has been locked after review submission.',
|
||||
disabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
export const ReadOnly: Story = {
|
||||
args: {
|
||||
label: 'Submitted feedback',
|
||||
defaultValue:
|
||||
'The onboarding flow was intuitive and participants completed all tasks without assistance. Average completion time was 4 minutes.',
|
||||
readOnly: true,
|
||||
},
|
||||
}
|
||||
|
||||
export const NoResize: Story = {
|
||||
args: {
|
||||
label: 'Comment',
|
||||
placeholder: 'Add a comment...',
|
||||
resize: 'none',
|
||||
rows: 4,
|
||||
},
|
||||
}
|
||||
|
||||
const CharacterCountExample = () => {
|
||||
const [value, setValue] = useState('')
|
||||
return (
|
||||
<Textarea
|
||||
label="Theme description"
|
||||
placeholder="Describe the theme in detail..."
|
||||
hint="Be specific about participant sentiment"
|
||||
maxLength={500}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const WithCharacterCount: Story = {
|
||||
render: () => <CharacterCountExample />,
|
||||
}
|
||||
|
||||
const AutoResizeExample = () => {
|
||||
const [value, setValue] = useState('')
|
||||
return (
|
||||
<Textarea
|
||||
label="Auto-resizing notes"
|
||||
placeholder="Start typing — the field grows as you type..."
|
||||
autoResize
|
||||
rows={2}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const AutoResize: Story = {
|
||||
render: () => <AutoResizeExample />,
|
||||
}
|
||||
|
||||
// --- Stacked variant ---
|
||||
|
||||
export const Stacked: Story = {
|
||||
args: {
|
||||
label: 'Research notes',
|
||||
placeholder: 'Record your observations...',
|
||||
variant: 'stacked',
|
||||
},
|
||||
}
|
||||
|
||||
export const StackedWithDescription: Story = {
|
||||
args: {
|
||||
label: 'Theme summary',
|
||||
description: 'Summarise the key findings for this theme.',
|
||||
placeholder: 'e.g. Participants consistently reported...',
|
||||
variant: 'stacked',
|
||||
},
|
||||
}
|
||||
|
||||
export const StackedWithError: Story = {
|
||||
args: {
|
||||
label: 'Analysis notes',
|
||||
description: 'Provide your interpretation of the data.',
|
||||
defaultValue: 'N/A',
|
||||
error: 'Analysis notes are required for theme completion',
|
||||
variant: 'stacked',
|
||||
},
|
||||
}
|
||||
|
||||
// --- All states ---
|
||||
|
||||
export const AllStates: Story = {
|
||||
render: () => (
|
||||
<div className="flex max-w-md flex-col gap-6">
|
||||
<Textarea label="Default" placeholder="Placeholder" />
|
||||
<Textarea label="With hint" placeholder="Placeholder" hint="Helpful hint text" />
|
||||
<Textarea
|
||||
label="With value"
|
||||
defaultValue="Participants noted that the notification system was effective but could benefit from customisable frequency settings."
|
||||
/>
|
||||
<Textarea label="Error" defaultValue="Bad value" error="This field is required" />
|
||||
<Textarea label="Disabled" placeholder="Placeholder" disabled />
|
||||
<Textarea label="Disabled with value" defaultValue="Locked content" disabled />
|
||||
<Textarea label="Read only" defaultValue="Submitted content" readOnly />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const StackedAllStates: Story = {
|
||||
render: () => (
|
||||
<div className="flex max-w-md flex-col gap-6">
|
||||
<Textarea variant="stacked" label="Default" placeholder="Placeholder" />
|
||||
<Textarea
|
||||
variant="stacked"
|
||||
label="With description"
|
||||
description="A short description of the field."
|
||||
placeholder="Placeholder"
|
||||
/>
|
||||
<Textarea
|
||||
variant="stacked"
|
||||
label="With hint"
|
||||
description="Description text here."
|
||||
placeholder="Placeholder"
|
||||
hint="Helpful hint text"
|
||||
/>
|
||||
<Textarea variant="stacked" label="With value" defaultValue="Some content entered by the user" />
|
||||
<Textarea
|
||||
variant="stacked"
|
||||
label="Error"
|
||||
description="Description text here."
|
||||
defaultValue="Bad value"
|
||||
error="This field is required"
|
||||
/>
|
||||
<Textarea variant="stacked" label="Disabled" placeholder="Placeholder" disabled />
|
||||
<Textarea
|
||||
variant="stacked"
|
||||
label="Disabled with description"
|
||||
description="This field cannot be edited."
|
||||
defaultValue="Locked content"
|
||||
disabled
|
||||
/>
|
||||
<Textarea variant="stacked" label="Read only" defaultValue="Submitted content" readOnly />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
197
src/components/atoms/Textarea/Textarea.tsx
Normal file
197
src/components/atoms/Textarea/Textarea.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { forwardRef, useId, useCallback, useRef, useEffect, type TextareaHTMLAttributes } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
label: string
|
||||
description?: string
|
||||
hint?: string
|
||||
error?: string
|
||||
variant?: 'outlined' | 'stacked'
|
||||
resize?: 'vertical' | 'horizontal' | 'both' | 'none'
|
||||
autoResize?: boolean
|
||||
}
|
||||
|
||||
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
(
|
||||
{
|
||||
label,
|
||||
description,
|
||||
hint,
|
||||
error,
|
||||
variant = 'outlined',
|
||||
resize = 'vertical',
|
||||
autoResize = false,
|
||||
disabled,
|
||||
readOnly,
|
||||
maxLength,
|
||||
value,
|
||||
defaultValue,
|
||||
rows = 3,
|
||||
className,
|
||||
id: idProp,
|
||||
onChange,
|
||||
...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 isStacked = variant === 'stacked'
|
||||
|
||||
const internalRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
|
||||
const currentLength =
|
||||
maxLength != null && typeof value === 'string' ? value.length : undefined
|
||||
|
||||
const describedBy =
|
||||
[
|
||||
description && isStacked ? descriptionId : undefined,
|
||||
supportiveText ? hintId : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ') || undefined
|
||||
|
||||
const resizeClass: Record<string, string> = {
|
||||
vertical: 'resize-y',
|
||||
horizontal: 'resize-x',
|
||||
both: 'resize',
|
||||
none: 'resize-none',
|
||||
}
|
||||
|
||||
const adjustHeight = useCallback(() => {
|
||||
const el = internalRef.current
|
||||
if (!el || !autoResize) return
|
||||
el.style.height = 'auto'
|
||||
el.style.height = `${el.scrollHeight}px`
|
||||
}, [autoResize])
|
||||
|
||||
useEffect(() => {
|
||||
adjustHeight()
|
||||
}, [adjustHeight, value])
|
||||
|
||||
const setRefs = useCallback(
|
||||
(node: HTMLTextAreaElement | null) => {
|
||||
internalRef.current = node
|
||||
if (typeof ref === 'function') ref(node)
|
||||
else if (ref) ref.current = node
|
||||
},
|
||||
[ref],
|
||||
)
|
||||
|
||||
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-text', disabled && 'opacity-50')}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex rounded-[4px] border bg-control-bg transition-colors',
|
||||
hasError
|
||||
? 'border-control-error focus-within:ring-1 focus-within:ring-control-error'
|
||||
: 'border-control-border hover:border-control-border-hover focus-within:border-control-checked focus-within:ring-1 focus-within:ring-control-checked',
|
||||
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>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
ref={setRefs}
|
||||
id={id}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
maxLength={maxLength}
|
||||
value={value}
|
||||
defaultValue={defaultValue}
|
||||
rows={rows}
|
||||
aria-invalid={hasError || undefined}
|
||||
aria-describedby={describedBy}
|
||||
onChange={(e) => {
|
||||
onChange?.(e)
|
||||
adjustHeight()
|
||||
}}
|
||||
className={cn(
|
||||
'w-full bg-transparent px-3 py-3 text-body font-normal text-text outline-none',
|
||||
'placeholder:text-text/50',
|
||||
autoResize ? 'resize-none overflow-hidden' : resizeClass[resize],
|
||||
disabled && 'text-text/50',
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
Textarea.displayName = 'Textarea'
|
||||
2
src/components/atoms/Textarea/index.ts
Normal file
2
src/components/atoms/Textarea/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Textarea } from './Textarea'
|
||||
export type { TextareaProps } from './Textarea'
|
||||
143
src/components/atoms/Tooltip/Tooltip.stories.tsx
Normal file
143
src/components/atoms/Tooltip/Tooltip.stories.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { Info, HelpCircle, Trash2, Copy } from 'lucide-react'
|
||||
import { Tooltip } from './Tooltip'
|
||||
import { Button } from '@/components/atoms/Button'
|
||||
import { IconButton } from '@/components/atoms/IconButton'
|
||||
|
||||
const meta: Meta<typeof Tooltip> = {
|
||||
title: 'Atoms/Tooltip',
|
||||
component: Tooltip,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
placement: {
|
||||
control: 'select',
|
||||
options: ['top', 'right', 'bottom', 'left'],
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="flex min-h-40 items-center justify-center p-16">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<Tooltip content="This is a tooltip">
|
||||
<Button>Hover me</Button>
|
||||
</Tooltip>
|
||||
),
|
||||
}
|
||||
|
||||
// --- Placements ---
|
||||
|
||||
export const Top: Story = {
|
||||
render: () => (
|
||||
<Tooltip content="Placed on top" placement="top">
|
||||
<Button variant="secondary">Top</Button>
|
||||
</Tooltip>
|
||||
),
|
||||
}
|
||||
|
||||
export const Right: Story = {
|
||||
render: () => (
|
||||
<Tooltip content="Placed on the right" placement="right">
|
||||
<Button variant="secondary">Right</Button>
|
||||
</Tooltip>
|
||||
),
|
||||
}
|
||||
|
||||
export const Bottom: Story = {
|
||||
render: () => (
|
||||
<Tooltip content="Placed on the bottom" placement="bottom">
|
||||
<Button variant="secondary">Bottom</Button>
|
||||
</Tooltip>
|
||||
),
|
||||
}
|
||||
|
||||
export const Left: Story = {
|
||||
render: () => (
|
||||
<Tooltip content="Placed on the left" placement="left">
|
||||
<Button variant="secondary">Left</Button>
|
||||
</Tooltip>
|
||||
),
|
||||
}
|
||||
|
||||
// --- With icon buttons ---
|
||||
|
||||
export const OnIconButtons: Story = {
|
||||
render: () => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip content="More information">
|
||||
<IconButton variant="tertiary" intent="neutral" icon={<Info />} aria-label="Info" />
|
||||
</Tooltip>
|
||||
<Tooltip content="Get help">
|
||||
<IconButton variant="tertiary" intent="neutral" icon={<HelpCircle />} aria-label="Help" />
|
||||
</Tooltip>
|
||||
<Tooltip content="Delete item" placement="bottom">
|
||||
<IconButton variant="tertiary" intent="danger" icon={<Trash2 />} aria-label="Delete" />
|
||||
</Tooltip>
|
||||
<Tooltip content="Copy to clipboard" placement="bottom">
|
||||
<IconButton variant="tertiary" intent="neutral" icon={<Copy />} aria-label="Copy" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
// --- Long content ---
|
||||
|
||||
export const LongContent: Story = {
|
||||
render: () => (
|
||||
<Tooltip content="This tooltip contains longer explanatory text that wraps across multiple lines within the max-width constraint.">
|
||||
<Button variant="secondary" intent="neutral">Hover for details</Button>
|
||||
</Tooltip>
|
||||
),
|
||||
}
|
||||
|
||||
// --- All placements ---
|
||||
|
||||
export const AllPlacements: Story = {
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="flex min-h-64 items-center justify-center p-24">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
render: () => (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div />
|
||||
<Tooltip content="Top" placement="top">
|
||||
<Button variant="secondary" intent="neutral" className="w-full">Top</Button>
|
||||
</Tooltip>
|
||||
<div />
|
||||
<Tooltip content="Left" placement="left">
|
||||
<Button variant="secondary" intent="neutral" className="w-full">Left</Button>
|
||||
</Tooltip>
|
||||
<div />
|
||||
<Tooltip content="Right" placement="right">
|
||||
<Button variant="secondary" intent="neutral" className="w-full">Right</Button>
|
||||
</Tooltip>
|
||||
<div />
|
||||
<Tooltip content="Bottom" placement="bottom">
|
||||
<Button variant="secondary" intent="neutral" className="w-full">Bottom</Button>
|
||||
</Tooltip>
|
||||
<div />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
// --- Instant (no delay) ---
|
||||
|
||||
export const NoDelay: Story = {
|
||||
render: () => (
|
||||
<Tooltip content="Appears instantly" delay={0}>
|
||||
<Button variant="secondary">No delay</Button>
|
||||
</Tooltip>
|
||||
),
|
||||
}
|
||||
102
src/components/atoms/Tooltip/Tooltip.tsx
Normal file
102
src/components/atoms/Tooltip/Tooltip.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
useState,
|
||||
useRef,
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import {
|
||||
useFloating,
|
||||
useHover,
|
||||
useFocus,
|
||||
useDismiss,
|
||||
useRole,
|
||||
useInteractions,
|
||||
offset,
|
||||
flip,
|
||||
shift,
|
||||
arrow,
|
||||
FloatingArrow,
|
||||
FloatingPortal,
|
||||
autoUpdate,
|
||||
type Placement,
|
||||
} from '@floating-ui/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface TooltipProps {
|
||||
content: ReactNode
|
||||
placement?: Placement
|
||||
delay?: number | { open?: number; close?: number }
|
||||
children: ReactElement
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Tooltip({
|
||||
content,
|
||||
placement = 'top',
|
||||
delay = { open: 400, close: 0 },
|
||||
children,
|
||||
className,
|
||||
}: TooltipProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const arrowRef = useRef(null)
|
||||
|
||||
const { refs, floatingStyles, context } = useFloating({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
placement,
|
||||
whileElementsMounted: autoUpdate,
|
||||
middleware: [
|
||||
offset(8),
|
||||
flip({ fallbackAxisSideDirection: 'start' }),
|
||||
shift({ padding: 8 }),
|
||||
arrow({ element: arrowRef }),
|
||||
],
|
||||
})
|
||||
|
||||
const hover = useHover(context, { delay })
|
||||
const focus = useFocus(context)
|
||||
const dismiss = useDismiss(context)
|
||||
const role = useRole(context, { role: 'tooltip' })
|
||||
|
||||
const { getReferenceProps, getFloatingProps } = useInteractions([
|
||||
hover,
|
||||
focus,
|
||||
dismiss,
|
||||
role,
|
||||
])
|
||||
|
||||
if (!isValidElement(children)) return children
|
||||
|
||||
return (
|
||||
<>
|
||||
{cloneElement(children, {
|
||||
ref: refs.setReference,
|
||||
...getReferenceProps(),
|
||||
} as Record<string, unknown>)}
|
||||
{open && (
|
||||
<FloatingPortal>
|
||||
<div
|
||||
ref={refs.setFloating}
|
||||
style={floatingStyles}
|
||||
className={cn(
|
||||
'z-50 max-w-xs rounded-default bg-surface px-3 py-1.5 font-sans text-small text-text shadow-md',
|
||||
className,
|
||||
)}
|
||||
{...getFloatingProps()}
|
||||
>
|
||||
{content}
|
||||
<FloatingArrow
|
||||
ref={arrowRef}
|
||||
context={context}
|
||||
className="fill-surface drop-shadow-sm"
|
||||
width={12}
|
||||
height={6}
|
||||
/>
|
||||
</div>
|
||||
</FloatingPortal>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
2
src/components/atoms/Tooltip/index.ts
Normal file
2
src/components/atoms/Tooltip/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Tooltip } from './Tooltip'
|
||||
export type { TooltipProps } from './Tooltip'
|
||||
224
src/components/molecules/Accordion/Accordion.stories.tsx
Normal file
224
src/components/molecules/Accordion/Accordion.stories.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { FileText, Settings, Users, Shield, Bell, HelpCircle } from 'lucide-react'
|
||||
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from './Accordion'
|
||||
|
||||
const meta: Meta<typeof Accordion> = {
|
||||
title: 'Molecules/Accordion',
|
||||
component: Accordion,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
type: {
|
||||
control: 'select',
|
||||
options: ['single', 'multiple'],
|
||||
},
|
||||
collapsible: { control: 'boolean' },
|
||||
},
|
||||
parameters: {
|
||||
design: {
|
||||
type: 'figma',
|
||||
url: 'https://www.figma.com/design/mrabO6AtxN3ektGiTk0I9c/ResearchInsights?node-id=100-1366',
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="max-w-xl">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// --- Default ---
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<Accordion type="single" collapsible defaultValue="item-1">
|
||||
<AccordionItem value="item-1">
|
||||
<AccordionTrigger>What is this design system?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
A React component library implementing the ADS 3.0 design language with custom tokens
|
||||
and Tailwind CSS v4.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-2">
|
||||
<AccordionTrigger>How are tokens structured?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Tokens are organised in layers: palette (raw values), semantic (purpose-based aliases), and
|
||||
domain-specific tokens for components like buttons, badges, and chips.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-3">
|
||||
<AccordionTrigger>Can I customise the components?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Yes. All components accept className overrides and forward refs. Style customisation is done
|
||||
through Tailwind utilities and the design token layer.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
),
|
||||
}
|
||||
|
||||
// --- Multiple ---
|
||||
|
||||
export const Multiple: Story = {
|
||||
render: () => (
|
||||
<Accordion type="multiple" defaultValue={['item-1', 'item-3']}>
|
||||
<AccordionItem value="item-1">
|
||||
<AccordionTrigger>First section</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Multiple items can be open simultaneously when type is set to "multiple".
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-2">
|
||||
<AccordionTrigger>Second section</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Click any header to toggle it independently of the others.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-3">
|
||||
<AccordionTrigger>Third section</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
This item is also open by default, demonstrating multiple default values.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
),
|
||||
}
|
||||
|
||||
// --- With Icons ---
|
||||
|
||||
export const WithIcons: Story = {
|
||||
render: () => (
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="documents">
|
||||
<AccordionTrigger icon={<FileText />}>Documents</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Manage your uploaded documents, PDFs, and research papers.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="team">
|
||||
<AccordionTrigger icon={<Users />}>Team members</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
View and manage team members who have access to this project.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="settings">
|
||||
<AccordionTrigger icon={<Settings />}>Settings</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Configure project settings, notifications, and integrations.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="security">
|
||||
<AccordionTrigger icon={<Shield />}>Security</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Manage access controls, permissions, and audit logs.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
),
|
||||
}
|
||||
|
||||
// --- Disabled ---
|
||||
|
||||
export const DisabledItem: Story = {
|
||||
render: () => (
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="item-1">
|
||||
<AccordionTrigger>Available section</AccordionTrigger>
|
||||
<AccordionContent>This section is interactive and can be toggled.</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-2" disabled>
|
||||
<AccordionTrigger>Disabled section</AccordionTrigger>
|
||||
<AccordionContent>This content cannot be revealed.</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-3">
|
||||
<AccordionTrigger>Another available section</AccordionTrigger>
|
||||
<AccordionContent>This section is also interactive.</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
),
|
||||
}
|
||||
|
||||
// --- Single non-collapsible ---
|
||||
|
||||
export const SingleNonCollapsible: Story = {
|
||||
render: () => (
|
||||
<Accordion type="single" defaultValue="item-1">
|
||||
<AccordionItem value="item-1">
|
||||
<AccordionTrigger>Always one open</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
In single non-collapsible mode, one item is always open. Clicking the active header
|
||||
does nothing — you must click a different header to switch.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-2">
|
||||
<AccordionTrigger>Click me to switch</AccordionTrigger>
|
||||
<AccordionContent>Now this item is open and the previous one closed.</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-3">
|
||||
<AccordionTrigger>Or click me</AccordionTrigger>
|
||||
<AccordionContent>Each click opens one and closes the other.</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
),
|
||||
}
|
||||
|
||||
// --- Rich content ---
|
||||
|
||||
export const RichContent: Story = {
|
||||
render: () => (
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="notifications">
|
||||
<AccordionTrigger icon={<Bell />}>Notification preferences</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="flex flex-col gap-3">
|
||||
<p>Choose how you would like to be notified about project updates:</p>
|
||||
<ul className="list-inside list-disc space-y-1">
|
||||
<li>Email digests (daily or weekly)</li>
|
||||
<li>In-app notifications</li>
|
||||
<li>Browser push notifications</li>
|
||||
</ul>
|
||||
<p className="text-small text-text-secondary">
|
||||
You can change these settings at any time from your profile.
|
||||
</p>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="faq">
|
||||
<AccordionTrigger icon={<HelpCircle />}>Frequently asked questions</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<p className="font-bold text-text">How do I export my data?</p>
|
||||
<p>Navigate to Settings → Export and choose your preferred format (CSV, JSON, or PDF).</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-text">Is my data encrypted?</p>
|
||||
<p>Yes. All data is encrypted at rest and in transit using AES-256.</p>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
),
|
||||
}
|
||||
|
||||
// --- Many items ---
|
||||
|
||||
export const ManyItems: Story = {
|
||||
render: () => (
|
||||
<Accordion type="single" collapsible>
|
||||
{Array.from({ length: 8 }, (_, i) => (
|
||||
<AccordionItem key={i} value={`item-${i}`}>
|
||||
<AccordionTrigger>Section {i + 1}</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Content for section {i + 1}. Each item expands independently in single collapsible mode.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
),
|
||||
}
|
||||
243
src/components/molecules/Accordion/Accordion.tsx
Normal file
243
src/components/molecules/Accordion/Accordion.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import {
|
||||
createContext,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useContext,
|
||||
useId,
|
||||
useMemo,
|
||||
useState,
|
||||
type HTMLAttributes,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const ChevronIcon = () => (
|
||||
<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>
|
||||
)
|
||||
|
||||
// --- Context ---
|
||||
|
||||
interface AccordionContextValue {
|
||||
openItems: Set<string>
|
||||
toggle: (value: string) => void
|
||||
}
|
||||
|
||||
const AccordionContext = createContext<AccordionContextValue | null>(null)
|
||||
|
||||
function useAccordionContext() {
|
||||
const ctx = useContext(AccordionContext)
|
||||
if (!ctx) throw new Error('Accordion components must be used within an Accordion')
|
||||
return ctx
|
||||
}
|
||||
|
||||
interface AccordionItemContextValue {
|
||||
value: string
|
||||
isOpen: boolean
|
||||
triggerId: string
|
||||
contentId: string
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
const AccordionItemContext = createContext<AccordionItemContextValue | null>(null)
|
||||
|
||||
function useAccordionItemContext() {
|
||||
const ctx = useContext(AccordionItemContext)
|
||||
if (!ctx) throw new Error('AccordionItem sub-components must be used within an AccordionItem')
|
||||
return ctx
|
||||
}
|
||||
|
||||
// --- Accordion ---
|
||||
|
||||
export interface AccordionProps extends HTMLAttributes<HTMLDivElement> {
|
||||
type?: 'single' | 'multiple'
|
||||
collapsible?: boolean
|
||||
defaultValue?: string | string[]
|
||||
value?: string | string[]
|
||||
onValueChange?: (value: string | string[]) => void
|
||||
}
|
||||
|
||||
export const Accordion = forwardRef<HTMLDivElement, AccordionProps>(
|
||||
(
|
||||
{ type = 'single', collapsible = false, defaultValue, value, onValueChange, className, children, ...props },
|
||||
ref,
|
||||
) => {
|
||||
const [internalOpen, setInternalOpen] = useState<Set<string>>(() => {
|
||||
if (defaultValue) return new Set(Array.isArray(defaultValue) ? defaultValue : [defaultValue])
|
||||
return new Set()
|
||||
})
|
||||
|
||||
const isControlled = value !== undefined
|
||||
const openItems = isControlled
|
||||
? new Set(Array.isArray(value) ? value : value ? [value] : [])
|
||||
: internalOpen
|
||||
|
||||
const toggle = useCallback(
|
||||
(itemValue: string) => {
|
||||
const compute = (prev: Set<string>): Set<string> => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(itemValue)) {
|
||||
if (type === 'single' && !collapsible) return prev
|
||||
next.delete(itemValue)
|
||||
} else {
|
||||
if (type === 'single') next.clear()
|
||||
next.add(itemValue)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
if (isControlled) {
|
||||
const next = compute(openItems)
|
||||
if (next !== openItems) {
|
||||
onValueChange?.(type === 'single' ? ([...next][0] ?? '') : [...next])
|
||||
}
|
||||
} else {
|
||||
setInternalOpen((prev) => {
|
||||
const next = compute(prev)
|
||||
if (next !== prev) {
|
||||
onValueChange?.(type === 'single' ? ([...next][0] ?? '') : [...next])
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
},
|
||||
[type, collapsible, isControlled, openItems, onValueChange],
|
||||
)
|
||||
|
||||
const contextValue = useMemo(() => ({ openItems, toggle }), [openItems, toggle])
|
||||
|
||||
return (
|
||||
<AccordionContext.Provider value={contextValue}>
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('overflow-hidden rounded-xl bg-surface', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</AccordionContext.Provider>
|
||||
)
|
||||
},
|
||||
)
|
||||
Accordion.displayName = 'Accordion'
|
||||
|
||||
// --- AccordionItem ---
|
||||
|
||||
export interface AccordionItemProps extends HTMLAttributes<HTMLDivElement> {
|
||||
value: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export const AccordionItem = forwardRef<HTMLDivElement, AccordionItemProps>(
|
||||
({ value, disabled = false, className, children, ...props }, ref) => {
|
||||
const { openItems } = useAccordionContext()
|
||||
const isOpen = openItems.has(value)
|
||||
const id = useId()
|
||||
const triggerId = `${id}-trigger`
|
||||
const contentId = `${id}-content`
|
||||
|
||||
const itemContext = useMemo(
|
||||
() => ({ value, isOpen, triggerId, contentId, disabled }),
|
||||
[value, isOpen, triggerId, contentId, disabled],
|
||||
)
|
||||
|
||||
return (
|
||||
<AccordionItemContext.Provider value={itemContext}>
|
||||
<div
|
||||
ref={ref}
|
||||
data-state={isOpen ? 'open' : 'closed'}
|
||||
className={cn('border-b border-border last:border-b-0', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</AccordionItemContext.Provider>
|
||||
)
|
||||
},
|
||||
)
|
||||
AccordionItem.displayName = 'AccordionItem'
|
||||
|
||||
// --- AccordionTrigger ---
|
||||
|
||||
export interface AccordionTriggerProps extends Omit<HTMLAttributes<HTMLButtonElement>, 'children'> {
|
||||
icon?: ReactNode
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export const AccordionTrigger = forwardRef<HTMLButtonElement, AccordionTriggerProps>(
|
||||
({ icon, className, children, ...props }, ref) => {
|
||||
const { toggle } = useAccordionContext()
|
||||
const { value, isOpen, triggerId, contentId, disabled } = useAccordionItemContext()
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
id={triggerId}
|
||||
type="button"
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={contentId}
|
||||
disabled={disabled}
|
||||
onClick={() => toggle(value)}
|
||||
className={cn(
|
||||
'flex h-16 w-full items-center gap-3 px-6 text-left font-bold text-text transition-colors',
|
||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-info',
|
||||
isOpen ? 'bg-info/12' : 'bg-surface hover:bg-info/5',
|
||||
disabled && 'pointer-events-none opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{icon && <span className="size-6 shrink-0 [&>svg]:size-full">{icon}</span>}
|
||||
<span className="flex-1">{children}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'size-6 shrink-0 transition-transform duration-200 [&>svg]:size-full',
|
||||
isOpen && 'rotate-180',
|
||||
)}
|
||||
>
|
||||
<ChevronIcon />
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
},
|
||||
)
|
||||
AccordionTrigger.displayName = 'AccordionTrigger'
|
||||
|
||||
// --- AccordionContent ---
|
||||
|
||||
export interface AccordionContentProps extends HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export const AccordionContent = forwardRef<HTMLDivElement, AccordionContentProps>(
|
||||
({ className, children, ...props }, ref) => {
|
||||
const { isOpen, triggerId, contentId } = useAccordionItemContext()
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
id={contentId}
|
||||
role="region"
|
||||
aria-labelledby={triggerId}
|
||||
aria-hidden={!isOpen}
|
||||
className={cn(
|
||||
'grid transition-[grid-template-rows] duration-200 ease-out',
|
||||
isOpen ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="min-h-0 overflow-hidden">
|
||||
<div className={cn('px-6 pb-4 pt-2 text-text-secondary', className)}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
AccordionContent.displayName = 'AccordionContent'
|
||||
7
src/components/molecules/Accordion/index.ts
Normal file
7
src/components/molecules/Accordion/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from './Accordion'
|
||||
export type {
|
||||
AccordionProps,
|
||||
AccordionItemProps,
|
||||
AccordionTriggerProps,
|
||||
AccordionContentProps,
|
||||
} from './Accordion'
|
||||
174
src/components/molecules/Alert/Alert.stories.tsx
Normal file
174
src/components/molecules/Alert/Alert.stories.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { useState } from 'react'
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { Alert } from './Alert'
|
||||
import { Button } from '@/components/atoms/Button'
|
||||
|
||||
const meta: Meta<typeof Alert> = {
|
||||
title: 'Molecules/Alert',
|
||||
component: Alert,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['info', 'warning', 'error', 'success', 'neutral'],
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="max-w-xl">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
title: 'Information',
|
||||
children: 'Your submission has been received and is being reviewed.',
|
||||
},
|
||||
}
|
||||
|
||||
// --- Variants ---
|
||||
|
||||
export const Info: Story = {
|
||||
args: {
|
||||
variant: 'info',
|
||||
title: 'Alert title',
|
||||
children: 'Lorem ipsum dolor sit amet consectetur adipisicing elit. Commodi, ratione debitis quis est labore voluptatibus!',
|
||||
},
|
||||
}
|
||||
|
||||
export const Warning: Story = {
|
||||
args: {
|
||||
variant: 'warning',
|
||||
title: 'Alert title',
|
||||
children: 'Lorem ipsum dolor sit amet consectetur adipisicing elit. Commodi, ratione debitis quis est labore voluptatibus!',
|
||||
},
|
||||
}
|
||||
|
||||
export const Error: Story = {
|
||||
args: {
|
||||
variant: 'error',
|
||||
title: 'Alert title',
|
||||
children: 'Lorem ipsum dolor sit amet consectetur adipisicing elit. Commodi, ratione debitis quis est labore voluptatibus!',
|
||||
},
|
||||
}
|
||||
|
||||
export const Success: Story = {
|
||||
args: {
|
||||
variant: 'success',
|
||||
title: 'Alert title',
|
||||
children: 'Lorem ipsum dolor sit amet consectetur adipisicing elit. Commodi, ratione debitis quis est labore voluptatibus!',
|
||||
},
|
||||
}
|
||||
|
||||
export const Neutral: Story = {
|
||||
args: {
|
||||
variant: 'neutral',
|
||||
title: 'Alert title',
|
||||
children: 'Lorem ipsum dolor sit amet consectetur adipisicing elit. Commodi, ratione debitis quis est labore voluptatibus!',
|
||||
},
|
||||
}
|
||||
|
||||
// --- With close ---
|
||||
|
||||
export const Dismissible: Story = {
|
||||
render: () => {
|
||||
const [visible, setVisible] = useState(true)
|
||||
if (!visible) return <Button size="compact" onClick={() => setVisible(true)}>Show alert</Button>
|
||||
return (
|
||||
<Alert variant="info" title="New feature available" onClose={() => setVisible(false)}>
|
||||
You can now export your synthesis results as a PDF report.
|
||||
</Alert>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
// --- With action ---
|
||||
|
||||
export const WithAction: Story = {
|
||||
render: () => (
|
||||
<Alert
|
||||
variant="warning"
|
||||
title="Incomplete submission"
|
||||
action={
|
||||
<Button size="compact">Complete now</Button>
|
||||
}
|
||||
>
|
||||
Your ethics application is missing required attachments. Please upload them before the deadline.
|
||||
</Alert>
|
||||
),
|
||||
}
|
||||
|
||||
// --- With close and action ---
|
||||
|
||||
export const WithCloseAndAction: Story = {
|
||||
render: () => (
|
||||
<Alert
|
||||
variant="error"
|
||||
title="Upload failed"
|
||||
onClose={() => {}}
|
||||
action={
|
||||
<Button size="compact" intent="danger">Retry upload</Button>
|
||||
}
|
||||
>
|
||||
The file could not be uploaded due to a network error. Please check your connection and try again.
|
||||
</Alert>
|
||||
),
|
||||
}
|
||||
|
||||
// --- Title only ---
|
||||
|
||||
export const TitleOnly: Story = {
|
||||
args: {
|
||||
variant: 'success',
|
||||
title: 'Changes saved successfully.',
|
||||
},
|
||||
}
|
||||
|
||||
// --- Content only ---
|
||||
|
||||
export const ContentOnly: Story = {
|
||||
args: {
|
||||
variant: 'neutral',
|
||||
children: 'This project is in read-only mode. Contact the project owner to request edit access.',
|
||||
},
|
||||
}
|
||||
|
||||
// --- All variants ---
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Alert variant="info" title="Alert title" onClose={() => {}}
|
||||
action={<Button size="compact">Optional action</Button>}
|
||||
>
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Commodi, ratione debitis quis est labore voluptatibus! Eaque cupiditate minima, at placeat totam, magni doloremque veniam neque porro libero rerum unde voluptatem!
|
||||
</Alert>
|
||||
<Alert variant="warning" title="Alert title" onClose={() => {}}
|
||||
action={<Button size="compact">Optional action</Button>}
|
||||
>
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Commodi, ratione debitis quis est labore voluptatibus! Eaque cupiditate minima, at placeat totam, magni doloremque veniam neque porro libero rerum unde voluptatem!
|
||||
</Alert>
|
||||
<Alert variant="error" title="Alert title" onClose={() => {}}
|
||||
action={<Button size="compact">Optional action</Button>}
|
||||
>
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Commodi, ratione debitis quis est labore voluptatibus! Eaque cupiditate minima, at placeat totam, magni doloremque veniam neque porro libero rerum unde voluptatem!
|
||||
</Alert>
|
||||
<Alert variant="success" title="Alert title" onClose={() => {}}
|
||||
action={<Button size="compact">Optional action</Button>}
|
||||
>
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Commodi, ratione debitis quis est labore voluptatibus! Eaque cupiditate minima, at placeat totam, magni doloremque veniam neque porro libero rerum unde voluptatem!
|
||||
</Alert>
|
||||
<Alert variant="neutral" title="Alert title" onClose={() => {}}
|
||||
action={<Button size="compact">Optional action</Button>}
|
||||
>
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Commodi, ratione debitis quis est labore voluptatibus! Eaque cupiditate minima, at placeat totam, magni doloremque veniam neque porro libero rerum unde voluptatem!
|
||||
</Alert>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
110
src/components/molecules/Alert/Alert.tsx
Normal file
110
src/components/molecules/Alert/Alert.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type AlertVariant = 'info' | 'warning' | 'error' | 'success' | 'neutral'
|
||||
|
||||
export interface AlertProps extends HTMLAttributes<HTMLDivElement> {
|
||||
variant?: AlertVariant
|
||||
title?: string
|
||||
onClose?: () => void
|
||||
action?: ReactNode
|
||||
icon?: ReactNode
|
||||
}
|
||||
|
||||
const variantStyles: Record<AlertVariant, string> = {
|
||||
info: 'bg-alert-info-bg',
|
||||
warning: 'bg-alert-warning-bg',
|
||||
error: 'bg-alert-error-bg',
|
||||
success: 'bg-alert-success-bg',
|
||||
neutral: 'bg-alert-neutral-bg',
|
||||
}
|
||||
|
||||
const iconColorStyles: Record<AlertVariant, string> = {
|
||||
info: 'text-alert-info-icon',
|
||||
warning: 'text-alert-warning-icon',
|
||||
error: 'text-alert-error-icon',
|
||||
success: 'text-alert-success-icon',
|
||||
neutral: 'text-alert-neutral-icon',
|
||||
}
|
||||
|
||||
const InfoIcon = () => (
|
||||
<svg className="size-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<text x="12" y="17" textAnchor="middle" fontSize="14" fontWeight="bold" fill="white">i</text>
|
||||
</svg>
|
||||
)
|
||||
|
||||
const WarningIcon = () => (
|
||||
<svg className="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" fill="currentColor" stroke="none" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" stroke="white" strokeWidth={2.5} />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" stroke="white" strokeWidth={2.5} />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const ErrorIcon = () => (
|
||||
<svg className="size-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="13" stroke="white" strokeWidth={2.5} strokeLinecap="round" />
|
||||
<line x1="12" y1="16.5" x2="12.01" y2="16.5" stroke="white" strokeWidth={2.5} strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const SuccessIcon = () => (
|
||||
<svg className="size-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M8 12.5l2.5 2.5 5-5" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const defaultIcons: Record<AlertVariant, () => ReactNode> = {
|
||||
info: InfoIcon,
|
||||
warning: WarningIcon,
|
||||
error: ErrorIcon,
|
||||
success: SuccessIcon,
|
||||
neutral: InfoIcon,
|
||||
}
|
||||
|
||||
export const Alert = forwardRef<HTMLDivElement, AlertProps>(
|
||||
({ variant = 'info', title, onClose, action, icon, className, children, ...props }, ref) => {
|
||||
const DefaultIcon = defaultIcons[variant]
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(
|
||||
'rounded-default px-4 py-3',
|
||||
variantStyles[variant],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={cn('mt-0.5 shrink-0', iconColorStyles[variant])}>
|
||||
{icon ?? <DefaultIcon />}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
{title && <p className="font-bold text-text">{title}</p>}
|
||||
{children && <div className={cn('text-small text-text', title && 'mt-1')}>{children}</div>}
|
||||
{action && <div className="mt-3">{action}</div>}
|
||||
</div>
|
||||
{onClose && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="mt-0.5 shrink-0 rounded-full p-0.5 text-text-secondary transition-colors hover:text-text"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<svg className="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
Alert.displayName = 'Alert'
|
||||
2
src/components/molecules/Alert/index.ts
Normal file
2
src/components/molecules/Alert/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Alert } from './Alert'
|
||||
export type { AlertProps, AlertVariant } from './Alert'
|
||||
257
src/components/molecules/Card/Card.stories.tsx
Normal file
257
src/components/molecules/Card/Card.stories.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { ClipboardList, BookOpen, Info, ExternalLink } from 'lucide-react'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card'
|
||||
|
||||
const meta: Meta<typeof Card> = {
|
||||
title: 'Molecules/Card',
|
||||
component: Card,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['surface', 'outlined', 'elevated', 'filled'],
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="max-w-lg">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Card title</CardTitle>
|
||||
<CardDescription>A short description of the card content.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-body text-text">
|
||||
This is the card body. It can contain any content — text, lists, forms, or other
|
||||
components.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
),
|
||||
}
|
||||
|
||||
// --- Variants ---
|
||||
|
||||
export const Surface: Story = {
|
||||
render: () => (
|
||||
<Card variant="surface">
|
||||
<CardHeader>
|
||||
<CardTitle>Surface</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-body text-text">Default variant with border and subtle shadow.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
),
|
||||
}
|
||||
|
||||
export const Outlined: Story = {
|
||||
render: () => (
|
||||
<Card variant="outlined">
|
||||
<CardHeader>
|
||||
<CardTitle>Outlined</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-body text-text">Border only, no shadow. Good for less prominent cards.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
),
|
||||
}
|
||||
|
||||
export const Elevated: Story = {
|
||||
render: () => (
|
||||
<Card variant="elevated">
|
||||
<CardHeader>
|
||||
<CardTitle>Elevated</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-body text-text">Shadow only, no border. Creates a floating effect.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
),
|
||||
}
|
||||
|
||||
export const Filled: Story = {
|
||||
render: () => (
|
||||
<Card variant="filled">
|
||||
<CardHeader>
|
||||
<CardTitle>Professional pathway</CardTitle>
|
||||
<CardDescription className="text-white/70">
|
||||
Track your progress through each stage.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-body text-white/90">
|
||||
Dark filled variant for featured or highlighted content sections.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
),
|
||||
}
|
||||
|
||||
// --- With header action ---
|
||||
|
||||
export const WithHeaderAction: Story = {
|
||||
render: () => (
|
||||
<Card>
|
||||
<CardHeader
|
||||
action={
|
||||
<button className="rounded-full p-1 text-text-secondary hover:bg-primary/5">
|
||||
<Info className="size-5" />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardList className="size-5 text-primary-dark" />
|
||||
<CardTitle>Steps to be taken</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="divide-y divide-border">
|
||||
<li className="flex items-center justify-between py-3 text-body text-text">
|
||||
Apply and verify WWCC
|
||||
<span className="text-text-secondary">›</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between py-3 text-body text-text">
|
||||
Fill a Registration Form
|
||||
<span className="text-text-secondary">›</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between py-3 text-body text-text">
|
||||
Complete compliance modules
|
||||
<span className="text-text-secondary">›</span>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
),
|
||||
}
|
||||
|
||||
// --- With footer ---
|
||||
|
||||
export const WithFooter: Story = {
|
||||
render: () => (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Mandatory Training Reminders</CardTitle>
|
||||
<CardDescription>
|
||||
Please consult the training hub for role-specific training requirements.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between rounded-lg bg-bg px-3 py-2 text-small">
|
||||
<span className="text-text">Aboriginal Cultural Education</span>
|
||||
<span className="font-bold text-success">Certified</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<button className="inline-flex items-center gap-1.5 rounded-lg border border-primary-dark px-3 py-1.5 text-small font-bold text-primary-dark hover:bg-primary/5">
|
||||
<ExternalLink className="size-3.5" />
|
||||
Go to myPL
|
||||
</button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
),
|
||||
}
|
||||
|
||||
// --- Minimal ---
|
||||
|
||||
export const ContentOnly: Story = {
|
||||
render: () => (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<p className="text-body text-text">
|
||||
A card with just content — no header or footer. Useful as a simple container.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
),
|
||||
}
|
||||
|
||||
// --- Related information ---
|
||||
|
||||
export const RelatedInformation: Story = {
|
||||
render: () => (
|
||||
<Card>
|
||||
<CardHeader
|
||||
action={
|
||||
<button className="rounded-full p-1 text-text-secondary hover:bg-primary/5">
|
||||
<Info className="size-5" />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className="size-5 text-primary-dark" />
|
||||
<CardTitle>Related information</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<ul className="divide-y divide-border">
|
||||
<li className="flex items-center justify-between py-3 text-body text-text">
|
||||
Visit the Beginning Teacher Information Hub
|
||||
<span className="text-text-secondary">›</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between py-3 text-body text-text">
|
||||
Apply for a role with DoE
|
||||
<span className="text-text-secondary">›</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between py-3 text-body text-text">
|
||||
Start your accreditation journey
|
||||
<span className="text-text-secondary">›</span>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
),
|
||||
}
|
||||
|
||||
// --- All variants ---
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Card variant="surface">
|
||||
<CardHeader>
|
||||
<CardTitle>Surface</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-small text-text-secondary">Border + subtle shadow</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card variant="outlined">
|
||||
<CardHeader>
|
||||
<CardTitle>Outlined</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-small text-text-secondary">Border only</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card variant="elevated">
|
||||
<CardHeader>
|
||||
<CardTitle>Elevated</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-small text-text-secondary">Shadow only</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card variant="filled">
|
||||
<CardHeader>
|
||||
<CardTitle>Filled</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-small text-white/70">Dark background, white text</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
102
src/components/molecules/Card/Card.tsx
Normal file
102
src/components/molecules/Card/Card.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { forwardRef, type HTMLAttributes } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// --- Card ---
|
||||
|
||||
export interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
variant?: 'surface' | 'outlined' | 'elevated' | 'filled'
|
||||
}
|
||||
|
||||
const variantStyles: Record<string, string> = {
|
||||
surface: 'bg-surface border border-border shadow-default',
|
||||
outlined: 'bg-surface border border-border',
|
||||
elevated: 'bg-surface shadow-md',
|
||||
filled: 'bg-primary text-white',
|
||||
}
|
||||
|
||||
export const Card = forwardRef<HTMLDivElement, CardProps>(
|
||||
({ variant = 'surface', className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('rounded-xl', variantStyles[variant], className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
)
|
||||
Card.displayName = 'Card'
|
||||
|
||||
// --- CardHeader ---
|
||||
|
||||
export interface CardHeaderProps extends HTMLAttributes<HTMLDivElement> {
|
||||
action?: React.ReactNode
|
||||
}
|
||||
|
||||
export const CardHeader = forwardRef<HTMLDivElement, CardHeaderProps>(
|
||||
({ action, className, children, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-start gap-3 px-6 pt-6', action && 'justify-between', className)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">{children}</div>
|
||||
{action && <div className="shrink-0">{action}</div>}
|
||||
</div>
|
||||
),
|
||||
)
|
||||
CardHeader.displayName = 'CardHeader'
|
||||
|
||||
// --- CardTitle ---
|
||||
|
||||
export type CardTitleProps = HTMLAttributes<HTMLHeadingElement>
|
||||
|
||||
export const CardTitle = forwardRef<HTMLHeadingElement, CardTitleProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn('text-h5 font-bold leading-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
)
|
||||
CardTitle.displayName = 'CardTitle'
|
||||
|
||||
// --- CardDescription ---
|
||||
|
||||
export type CardDescriptionProps = HTMLAttributes<HTMLParagraphElement>
|
||||
|
||||
export const CardDescription = forwardRef<HTMLParagraphElement, CardDescriptionProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn('text-small text-text-secondary', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
)
|
||||
CardDescription.displayName = 'CardDescription'
|
||||
|
||||
// --- CardContent ---
|
||||
|
||||
export type CardContentProps = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export const CardContent = forwardRef<HTMLDivElement, CardContentProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('px-6 py-4', className)} {...props} />
|
||||
),
|
||||
)
|
||||
CardContent.displayName = 'CardContent'
|
||||
|
||||
// --- CardFooter ---
|
||||
|
||||
export type CardFooterProps = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export const CardFooter = forwardRef<HTMLDivElement, CardFooterProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center gap-3 px-6 pb-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
)
|
||||
CardFooter.displayName = 'CardFooter'
|
||||
9
src/components/molecules/Card/index.ts
Normal file
9
src/components/molecules/Card/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card'
|
||||
export type {
|
||||
CardProps,
|
||||
CardHeaderProps,
|
||||
CardTitleProps,
|
||||
CardDescriptionProps,
|
||||
CardContentProps,
|
||||
CardFooterProps,
|
||||
} from './Card'
|
||||
79
src/components/molecules/DataTable/DataTable.stories.tsx
Normal file
79
src/components/molecules/DataTable/DataTable.stories.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { DataTable, type DataTableColumn } from './DataTable'
|
||||
|
||||
type Dessert = {
|
||||
name: string
|
||||
calories: number
|
||||
fat: number
|
||||
carbs: number
|
||||
protein: number
|
||||
iron: number
|
||||
}
|
||||
|
||||
const desserts: Dessert[] = [
|
||||
{ name: 'Frozen Yogurt', calories: 159, fat: 6, carbs: 24, protein: 4, iron: 1 },
|
||||
{ name: 'Ice cream sandwich', calories: 237, fat: 9, carbs: 37, protein: 4.3, iron: 1 },
|
||||
{ name: 'Eclair', calories: 262, fat: 16, carbs: 23, protein: 6, iron: 7 },
|
||||
{ name: 'Cupcake', calories: 305, fat: 3.7, carbs: 67, protein: 4.3, iron: 8 },
|
||||
{ name: 'Gingerbread', calories: 356, fat: 16, carbs: 49, protein: 3.9, iron: 16 },
|
||||
{ name: 'Jelly bean', calories: 375, fat: 0, carbs: 94, protein: 0, iron: 0 },
|
||||
{ name: 'Lollipop', calories: 392, fat: 0.2, carbs: 98, protein: 0, iron: 2 },
|
||||
{ name: 'Honeycomb', calories: 408, fat: 3.2, carbs: 87, protein: 6.5, iron: 45 },
|
||||
{ name: 'Donut', calories: 452, fat: 25, carbs: 51, protein: 4.9, iron: 22 },
|
||||
{ name: 'KitKat', calories: 518, fat: 26, carbs: 65, protein: 7, iron: 6 },
|
||||
]
|
||||
|
||||
const columns: DataTableColumn<Dessert>[] = [
|
||||
{ key: 'name', header: 'Dessert (100g serving)', sortable: true },
|
||||
{ key: 'calories', header: 'Calories', sortable: true, align: 'right' },
|
||||
{ key: 'fat', header: 'Fat (g)', sortable: true, align: 'right' },
|
||||
{ key: 'carbs', header: 'Carbs (g)', align: 'right' },
|
||||
{ key: 'protein', header: 'Protein (g)', align: 'right' },
|
||||
{ key: 'iron', header: 'Iron (%)', align: 'right' },
|
||||
]
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Molecules/DataTable',
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'padded' },
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<DataTable columns={columns} data={desserts} />
|
||||
),
|
||||
}
|
||||
|
||||
export const WithSelection: Story = {
|
||||
name: 'With selection',
|
||||
render: () => (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={desserts}
|
||||
selectable
|
||||
onSelectionChange={(sel) => console.log('Selected:', sel)}
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
export const CustomPageSize: Story = {
|
||||
name: 'Custom page size',
|
||||
render: () => (
|
||||
<DataTable columns={columns} data={desserts} pageSize={3} pageSizeOptions={[3, 5, 10]} />
|
||||
),
|
||||
}
|
||||
|
||||
export const Empty: Story = {
|
||||
render: () => (
|
||||
<DataTable columns={columns} data={[]} emptyMessage="No desserts found" />
|
||||
),
|
||||
}
|
||||
|
||||
export const Loading: Story = {
|
||||
render: () => (
|
||||
<DataTable columns={columns} data={[]} loading />
|
||||
),
|
||||
}
|
||||
282
src/components/molecules/DataTable/DataTable.tsx
Normal file
282
src/components/molecules/DataTable/DataTable.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
type HTMLAttributes,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface DataTableColumn<T = Record<string, unknown>> {
|
||||
key: string
|
||||
header: string
|
||||
sortable?: boolean
|
||||
align?: 'left' | 'center' | 'right'
|
||||
render?: (value: unknown, row: T, index: number) => ReactNode
|
||||
}
|
||||
|
||||
export interface DataTableProps<T = Record<string, unknown>> extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
|
||||
columns: DataTableColumn<T>[]
|
||||
data: T[]
|
||||
selectable?: boolean
|
||||
pagination?: boolean
|
||||
pageSize?: number
|
||||
pageSizeOptions?: number[]
|
||||
loading?: boolean
|
||||
emptyMessage?: string
|
||||
onSelectionChange?: (selected: T[]) => void
|
||||
}
|
||||
|
||||
type SortState = { key: string; dir: 'asc' | 'desc' } | null
|
||||
|
||||
const ChevronUpIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className="size-4">
|
||||
<path d="m18 15-6-6-6 6" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const ChevronDownIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className="size-4">
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
// --- DataTable ---
|
||||
|
||||
function DataTableInner<T extends Record<string, unknown>>(
|
||||
{
|
||||
columns,
|
||||
data,
|
||||
selectable = false,
|
||||
pagination = true,
|
||||
pageSize: initialPageSize = 5,
|
||||
pageSizeOptions = [5, 10, 25],
|
||||
loading = false,
|
||||
emptyMessage = 'No data available',
|
||||
onSelectionChange,
|
||||
className,
|
||||
...props
|
||||
}: DataTableProps<T>,
|
||||
ref: React.ForwardedRef<HTMLDivElement>,
|
||||
) {
|
||||
const [sort, setSort] = useState<SortState>(null)
|
||||
const [page, setPage] = useState(0)
|
||||
const [pageSize, setPageSize] = useState(initialPageSize)
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set())
|
||||
|
||||
const sortedData = useMemo(() => {
|
||||
if (!sort) return data
|
||||
const { key, dir } = sort
|
||||
return [...data].sort((a, b) => {
|
||||
const va = a[key]
|
||||
const vb = b[key]
|
||||
if (va == null && vb == null) return 0
|
||||
if (va == null) return 1
|
||||
if (vb == null) return -1
|
||||
const cmp = String(va).localeCompare(String(vb), undefined, { numeric: true })
|
||||
return dir === 'asc' ? cmp : -cmp
|
||||
})
|
||||
}, [data, sort])
|
||||
|
||||
const pageCount = pagination ? Math.max(1, Math.ceil(sortedData.length / pageSize)) : 1
|
||||
const pageData = pagination ? sortedData.slice(page * pageSize, (page + 1) * pageSize) : sortedData
|
||||
const rangeStart = page * pageSize + 1
|
||||
const rangeEnd = Math.min((page + 1) * pageSize, sortedData.length)
|
||||
|
||||
const toggleSort = useCallback((key: string) => {
|
||||
setSort((prev) => {
|
||||
if (prev?.key === key) {
|
||||
return prev.dir === 'asc' ? { key, dir: 'desc' } : null
|
||||
}
|
||||
return { key, dir: 'asc' }
|
||||
})
|
||||
}, [])
|
||||
|
||||
const toggleRow = useCallback(
|
||||
(index: number) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(index)) next.delete(index)
|
||||
else next.add(index)
|
||||
onSelectionChange?.(
|
||||
[...next].map((i) => sortedData[i]).filter(Boolean),
|
||||
)
|
||||
return next
|
||||
})
|
||||
},
|
||||
[sortedData, onSelectionChange],
|
||||
)
|
||||
|
||||
const toggleAll = useCallback(() => {
|
||||
setSelected((prev) => {
|
||||
if (prev.size === sortedData.length) {
|
||||
onSelectionChange?.([])
|
||||
return new Set()
|
||||
}
|
||||
const all = new Set(sortedData.map((_, i) => i))
|
||||
onSelectionChange?.([...sortedData])
|
||||
return all
|
||||
})
|
||||
}, [sortedData, onSelectionChange])
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn('overflow-hidden rounded-default bg-surface', className)} {...props}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-body">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
{selectable && (
|
||||
<th className="w-12 px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.size === sortedData.length && sortedData.length > 0}
|
||||
onChange={toggleAll}
|
||||
className="accent-primary"
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={cn(
|
||||
'px-4 py-3 font-normal text-primary',
|
||||
col.sortable && 'cursor-pointer select-none hover:bg-text/[0.04]',
|
||||
col.align === 'right' && 'text-right',
|
||||
col.align === 'center' && 'text-center',
|
||||
)}
|
||||
onClick={col.sortable ? () => toggleSort(col.key) : undefined}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{col.header}
|
||||
{col.sortable && sort?.key === col.key && (
|
||||
sort.dir === 'asc' ? <ChevronUpIcon /> : <ChevronDownIcon />
|
||||
)}
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length + (selectable ? 1 : 0)} className="px-4 py-8 text-center text-text-secondary">
|
||||
Loading…
|
||||
</td>
|
||||
</tr>
|
||||
) : pageData.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length + (selectable ? 1 : 0)} className="px-4 py-8 text-center text-text-secondary">
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
pageData.map((row, rowIdx) => {
|
||||
const globalIdx = page * pageSize + rowIdx
|
||||
return (
|
||||
<tr
|
||||
key={globalIdx}
|
||||
className={cn(
|
||||
'border-b border-border last:border-b-0 transition-colors',
|
||||
selected.has(globalIdx) ? 'bg-info/5' : 'hover:bg-text/[0.02]',
|
||||
)}
|
||||
>
|
||||
{selectable && (
|
||||
<td className="w-12 px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(globalIdx)}
|
||||
onChange={() => toggleRow(globalIdx)}
|
||||
className="accent-primary"
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
key={col.key}
|
||||
className={cn(
|
||||
'px-4 py-3',
|
||||
col.align === 'right' && 'text-right',
|
||||
col.align === 'center' && 'text-center',
|
||||
)}
|
||||
>
|
||||
{col.render
|
||||
? col.render(row[col.key], row, globalIdx)
|
||||
: String(row[col.key] ?? '')}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{pagination && sortedData.length > 0 && (
|
||||
<div className="flex items-center justify-end gap-4 border-t border-border px-4 py-2 text-small text-text-secondary">
|
||||
<label className="flex items-center gap-2">
|
||||
Rows per page:
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => {
|
||||
setPageSize(Number(e.target.value))
|
||||
setPage(0)
|
||||
}}
|
||||
className="rounded-sm border border-border bg-surface px-2 py-1 text-small text-text"
|
||||
>
|
||||
{pageSizeOptions.map((opt) => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<span>{rangeStart}-{rangeEnd} of {sortedData.length}</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage(0)}
|
||||
className="rounded-sm p-1 hover:bg-text/[0.04] disabled:opacity-40"
|
||||
aria-label="First page"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className="size-5"><path d="M18.41 16.59L13.82 12l4.59-4.59L17 6l-6 6 6 6zM6 6h2v12H6z" /></svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
className="rounded-sm p-1 hover:bg-text/[0.04] disabled:opacity-40"
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className="size-5"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z" /></svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={page >= pageCount - 1}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
className="rounded-sm p-1 hover:bg-text/[0.04] disabled:opacity-40"
|
||||
aria-label="Next page"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className="size-5"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" /></svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={page >= pageCount - 1}
|
||||
onClick={() => setPage(pageCount - 1)}
|
||||
className="rounded-sm p-1 hover:bg-text/[0.04] disabled:opacity-40"
|
||||
aria-label="Last page"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className="size-5"><path d="M5.59 7.41L10.18 12l-4.59 4.59L7 18l6-6-6-6zM16 6h2v12h-2z" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const DataTable = forwardRef(DataTableInner) as <T extends Record<string, unknown>>(
|
||||
props: DataTableProps<T> & { ref?: React.Ref<HTMLDivElement> },
|
||||
) => React.ReactElement | null
|
||||
2
src/components/molecules/DataTable/index.ts
Normal file
2
src/components/molecules/DataTable/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { DataTable } from './DataTable'
|
||||
export type { DataTableProps, DataTableColumn } from './DataTable'
|
||||
250
src/components/molecules/Dialog/Dialog.stories.tsx
Normal file
250
src/components/molecules/Dialog/Dialog.stories.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import { useState } from 'react'
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { Dialog, DialogHeader, DialogTitle, DialogDescription, DialogContent, DialogFooter } from './Dialog'
|
||||
import { Button } from '@/components/atoms/Button'
|
||||
import { Input } from '@/components/atoms/Input'
|
||||
|
||||
const meta: Meta<typeof Dialog> = {
|
||||
title: 'Molecules/Dialog',
|
||||
component: Dialog,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['sm', 'default', 'lg', 'full'],
|
||||
},
|
||||
closeOnBackdrop: {
|
||||
control: 'boolean',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// --- Default ---
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setOpen(true)}>Open dialog</Button>
|
||||
<Dialog open={open} onClose={() => setOpen(false)}>
|
||||
<DialogHeader onClose={() => setOpen(false)}>
|
||||
<DialogTitle>Dialog title</DialogTitle>
|
||||
<DialogDescription>A short description of the dialog purpose.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent>
|
||||
<p className="text-body text-text">
|
||||
This is the dialog body. It can contain any content — text, forms, lists, or other
|
||||
components.
|
||||
</p>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" intent="neutral" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => setOpen(false)}>Confirm</Button>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
// --- Small ---
|
||||
|
||||
export const Small: Story = {
|
||||
render: () => {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setOpen(true)}>Open small dialog</Button>
|
||||
<Dialog open={open} onClose={() => setOpen(false)} size="sm">
|
||||
<DialogHeader onClose={() => setOpen(false)}>
|
||||
<DialogTitle>Quick confirmation</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogContent>
|
||||
<p className="text-body text-text">Are you sure you want to proceed?</p>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button variant="tertiary" intent="neutral" onClick={() => setOpen(false)}>
|
||||
No
|
||||
</Button>
|
||||
<Button onClick={() => setOpen(false)}>Yes</Button>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
// --- Large ---
|
||||
|
||||
export const Large: Story = {
|
||||
render: () => {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setOpen(true)}>Open large dialog</Button>
|
||||
<Dialog open={open} onClose={() => setOpen(false)} size="lg">
|
||||
<DialogHeader onClose={() => setOpen(false)}>
|
||||
<DialogTitle>Review submission details</DialogTitle>
|
||||
<DialogDescription>
|
||||
Please review the information below before submitting.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent>
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg bg-bg px-4 py-3">
|
||||
<p className="text-small font-bold text-text">Participant count</p>
|
||||
<p className="text-body text-text-secondary">24 participants across 3 schools</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-bg px-4 py-3">
|
||||
<p className="text-small font-bold text-text">Data collection period</p>
|
||||
<p className="text-body text-text-secondary">March 2026 — June 2026</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-bg px-4 py-3">
|
||||
<p className="text-small font-bold text-text">Ethics approval</p>
|
||||
<p className="text-body text-text-secondary">SERAP 2026-0142 (approved)</p>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" intent="neutral" onClick={() => setOpen(false)}>
|
||||
Go back
|
||||
</Button>
|
||||
<Button onClick={() => setOpen(false)}>Submit</Button>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
// --- Danger confirmation ---
|
||||
|
||||
export const DangerConfirmation: Story = {
|
||||
render: () => {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<Button intent="danger" onClick={() => setOpen(true)}>
|
||||
Delete project
|
||||
</Button>
|
||||
<Dialog open={open} onClose={() => setOpen(false)} size="sm">
|
||||
<DialogHeader onClose={() => setOpen(false)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex size-10 items-center justify-center rounded-full bg-error/10">
|
||||
<AlertTriangle className="size-5 text-error" />
|
||||
</div>
|
||||
<DialogTitle>Delete project?</DialogTitle>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<DialogContent>
|
||||
<p className="text-body text-text">
|
||||
This action cannot be undone. All data, themes, and participant responses associated
|
||||
with this project will be permanently deleted.
|
||||
</p>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" intent="neutral" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button intent="danger" onClick={() => setOpen(false)}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
// --- With form ---
|
||||
|
||||
export const WithForm: Story = {
|
||||
render: () => {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setOpen(true)}>Create new theme</Button>
|
||||
<Dialog open={open} onClose={() => setOpen(false)}>
|
||||
<DialogHeader onClose={() => setOpen(false)}>
|
||||
<DialogTitle>New theme</DialogTitle>
|
||||
<DialogDescription>
|
||||
Give your theme a name and description to help organise your findings.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent>
|
||||
<div className="space-y-4">
|
||||
<Input label="Theme name" placeholder="e.g. Student engagement" />
|
||||
<Input label="Description" placeholder="Brief summary of this theme" hint="Optional — you can add this later" />
|
||||
</div>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" intent="neutral" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => setOpen(false)}>Create theme</Button>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
// --- No close button ---
|
||||
|
||||
export const NoCloseButton: Story = {
|
||||
render: () => {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setOpen(true)}>Open without close button</Button>
|
||||
<Dialog open={open} onClose={() => setOpen(false)} closeOnBackdrop={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Terms of use</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogContent>
|
||||
<p className="text-body text-text">
|
||||
You must accept the terms of use before continuing. This dialog cannot be dismissed
|
||||
by clicking the backdrop or pressing Escape — only through the action buttons.
|
||||
</p>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" intent="neutral" onClick={() => setOpen(false)}>
|
||||
Decline
|
||||
</Button>
|
||||
<Button onClick={() => setOpen(false)}>Accept</Button>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
// --- Content only ---
|
||||
|
||||
export const ContentOnly: Story = {
|
||||
render: () => {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setOpen(true)}>Open minimal dialog</Button>
|
||||
<Dialog open={open} onClose={() => setOpen(false)} size="sm">
|
||||
<DialogContent className="py-6">
|
||||
<p className="text-center text-body text-text">
|
||||
Your changes have been saved.
|
||||
</p>
|
||||
<div className="mt-4 flex justify-center">
|
||||
<Button onClick={() => setOpen(false)}>Done</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
},
|
||||
}
|
||||
195
src/components/molecules/Dialog/Dialog.tsx
Normal file
195
src/components/molecules/Dialog/Dialog.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useRef,
|
||||
useCallback,
|
||||
type HTMLAttributes,
|
||||
type DialogHTMLAttributes,
|
||||
type ReactNode,
|
||||
type MouseEvent,
|
||||
} from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// --- Dialog ---
|
||||
|
||||
export interface DialogProps extends Omit<DialogHTMLAttributes<HTMLDialogElement>, 'open'> {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
size?: 'sm' | 'default' | 'lg' | 'full'
|
||||
closeOnBackdrop?: boolean
|
||||
}
|
||||
|
||||
const sizeStyles: Record<string, string> = {
|
||||
sm: 'max-w-sm',
|
||||
default: 'max-w-lg',
|
||||
lg: 'max-w-2xl',
|
||||
full: 'max-w-[calc(100vw-3rem)] max-h-[calc(100vh-3rem)]',
|
||||
}
|
||||
|
||||
export const Dialog = forwardRef<HTMLDialogElement, DialogProps>(
|
||||
({ open, onClose, size = 'default', closeOnBackdrop = true, className, children, ...props }, ref) => {
|
||||
const internalRef = useRef<HTMLDialogElement>(null)
|
||||
const dialogRef = (ref as React.RefObject<HTMLDialogElement>) || internalRef
|
||||
|
||||
useEffect(() => {
|
||||
const el = dialogRef.current
|
||||
if (!el) return
|
||||
|
||||
if (open && !el.open) {
|
||||
el.showModal()
|
||||
} else if (!open && el.open) {
|
||||
el.close()
|
||||
}
|
||||
}, [open, dialogRef])
|
||||
|
||||
useEffect(() => {
|
||||
const el = dialogRef.current
|
||||
if (!el) return
|
||||
|
||||
const handleCancel = (e: Event) => {
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
}
|
||||
|
||||
el.addEventListener('cancel', handleCancel)
|
||||
return () => el.removeEventListener('cancel', handleCancel)
|
||||
}, [onClose, dialogRef])
|
||||
|
||||
const handleBackdropClick = useCallback(
|
||||
(e: MouseEvent<HTMLDialogElement>) => {
|
||||
if (closeOnBackdrop && e.target === dialogRef.current) {
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
[closeOnBackdrop, onClose, dialogRef],
|
||||
)
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
className={cn(
|
||||
'w-full rounded-xl bg-surface shadow-md backdrop:bg-black/50',
|
||||
'p-0 open:flex open:flex-col',
|
||||
sizeStyles[size],
|
||||
className,
|
||||
)}
|
||||
onClick={handleBackdropClick}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex flex-col">{children}</div>
|
||||
</dialog>
|
||||
)
|
||||
},
|
||||
)
|
||||
Dialog.displayName = 'Dialog'
|
||||
|
||||
// --- DialogHeader ---
|
||||
|
||||
export interface DialogHeaderProps extends HTMLAttributes<HTMLDivElement> {
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
export const DialogHeader = forwardRef<HTMLDivElement, DialogHeaderProps>(
|
||||
({ onClose, className, children, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-start gap-3 px-6 pt-6', onClose && 'justify-between', className)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">{children}</div>
|
||||
{onClose && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="shrink-0 rounded-full p-1.5 text-text-secondary transition-colors hover:bg-info/5 hover:text-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-info"
|
||||
aria-label="Close dialog"
|
||||
>
|
||||
<svg className="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
)
|
||||
DialogHeader.displayName = 'DialogHeader'
|
||||
|
||||
// --- DialogTitle ---
|
||||
|
||||
export type DialogTitleProps = HTMLAttributes<HTMLHeadingElement>
|
||||
|
||||
export const DialogTitle = forwardRef<HTMLHeadingElement, DialogTitleProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h2
|
||||
ref={ref}
|
||||
className={cn('text-h4 font-bold leading-tight text-text', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
)
|
||||
DialogTitle.displayName = 'DialogTitle'
|
||||
|
||||
// --- DialogDescription ---
|
||||
|
||||
export type DialogDescriptionProps = HTMLAttributes<HTMLParagraphElement>
|
||||
|
||||
export const DialogDescription = forwardRef<HTMLParagraphElement, DialogDescriptionProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn('text-body text-text-secondary', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
)
|
||||
DialogDescription.displayName = 'DialogDescription'
|
||||
|
||||
// --- DialogContent ---
|
||||
|
||||
export type DialogContentProps = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export const DialogContent = forwardRef<HTMLDivElement, DialogContentProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('px-6 py-4', className)} {...props} />
|
||||
),
|
||||
)
|
||||
DialogContent.displayName = 'DialogContent'
|
||||
|
||||
// --- DialogFooter ---
|
||||
|
||||
export type DialogFooterProps = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export const DialogFooter = forwardRef<HTMLDivElement, DialogFooterProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center justify-end gap-3 px-6 pb-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
)
|
||||
DialogFooter.displayName = 'DialogFooter'
|
||||
|
||||
// --- DialogClose ---
|
||||
|
||||
export interface DialogCloseProps extends HTMLAttributes<HTMLButtonElement> {
|
||||
onClose: () => void
|
||||
asChild?: boolean
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export const DialogClose = forwardRef<HTMLButtonElement, DialogCloseProps>(
|
||||
({ onClose, className, children, ...props }, ref) => (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
)
|
||||
DialogClose.displayName = 'DialogClose'
|
||||
18
src/components/molecules/Dialog/index.ts
Normal file
18
src/components/molecules/Dialog/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export {
|
||||
Dialog,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogClose,
|
||||
} from './Dialog'
|
||||
export type {
|
||||
DialogProps,
|
||||
DialogHeaderProps,
|
||||
DialogTitleProps,
|
||||
DialogDescriptionProps,
|
||||
DialogContentProps,
|
||||
DialogFooterProps,
|
||||
DialogCloseProps,
|
||||
} from './Dialog'
|
||||
155
src/components/molecules/Popover/Popover.stories.tsx
Normal file
155
src/components/molecules/Popover/Popover.stories.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { Settings, Filter, MoreVertical } from 'lucide-react'
|
||||
import { Popover, PopoverTrigger, PopoverContent, PopoverClose } from './Popover'
|
||||
import { Button } from '@/components/atoms/Button'
|
||||
import { IconButton } from '@/components/atoms/IconButton'
|
||||
import { Input } from '@/components/atoms/Input'
|
||||
import { Checkbox } from '@/components/atoms/Checkbox'
|
||||
|
||||
const meta: Meta<typeof Popover> = {
|
||||
title: 'Molecules/Popover',
|
||||
component: Popover,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
placement: {
|
||||
control: 'select',
|
||||
options: ['top', 'right', 'bottom', 'left', 'bottom-start', 'bottom-end'],
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="flex min-h-80 items-start justify-center p-16">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<Button variant="secondary">Open popover</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<p className="text-body font-bold text-text">Popover title</p>
|
||||
<p className="mt-1 text-small text-text-secondary">
|
||||
This is a popover with rich content. It can contain text, forms, or any components.
|
||||
</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
),
|
||||
}
|
||||
|
||||
// --- With form ---
|
||||
|
||||
export const WithForm: Story = {
|
||||
render: () => (
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<Button leftIcon={<Settings className="size-4" />}>Settings</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80">
|
||||
<p className="mb-3 text-body font-bold text-text">Display settings</p>
|
||||
<div className="space-y-3">
|
||||
<Input label="Items per page" type="number" defaultValue="25" />
|
||||
<Checkbox label="Show archived items" />
|
||||
<Checkbox label="Compact view" />
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<PopoverClose className="rounded-full px-3 py-1.5 text-small font-bold text-text-secondary hover:bg-bg">
|
||||
Cancel
|
||||
</PopoverClose>
|
||||
<PopoverClose className="rounded-full bg-primary-dark px-3 py-1.5 text-small font-bold text-white hover:bg-primary-dark/90">
|
||||
Apply
|
||||
</PopoverClose>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
),
|
||||
}
|
||||
|
||||
// --- Filter popover ---
|
||||
|
||||
export const FilterPopover: Story = {
|
||||
render: () => (
|
||||
<Popover placement="bottom-start">
|
||||
<PopoverTrigger>
|
||||
<Button variant="secondary" intent="neutral" leftIcon={<Filter className="size-4" />}>
|
||||
Filters
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64">
|
||||
<p className="mb-3 text-small font-bold text-text">Filter by status</p>
|
||||
<div className="space-y-2">
|
||||
<Checkbox label="Active" defaultChecked />
|
||||
<Checkbox label="Completed" defaultChecked />
|
||||
<Checkbox label="Archived" />
|
||||
<Checkbox label="Draft" />
|
||||
</div>
|
||||
<div className="mt-4 border-t border-border pt-3">
|
||||
<PopoverClose className="text-small font-bold text-primary-dark hover:underline">
|
||||
Clear all filters
|
||||
</PopoverClose>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
),
|
||||
}
|
||||
|
||||
// --- Context menu style ---
|
||||
|
||||
export const ActionMenu: Story = {
|
||||
render: () => (
|
||||
<Popover placement="bottom-end">
|
||||
<PopoverTrigger>
|
||||
<IconButton variant="tertiary" intent="neutral" icon={<MoreVertical />} aria-label="More actions" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-48 p-1">
|
||||
{['Edit', 'Duplicate', 'Move to folder'].map((item) => (
|
||||
<PopoverClose
|
||||
key={item}
|
||||
className="flex w-full rounded-md px-3 py-2 text-left text-small text-text hover:bg-bg"
|
||||
>
|
||||
{item}
|
||||
</PopoverClose>
|
||||
))}
|
||||
<div className="my-1 border-t border-border" />
|
||||
<PopoverClose className="flex w-full rounded-md px-3 py-2 text-left text-small text-error hover:bg-error/5">
|
||||
Delete
|
||||
</PopoverClose>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
),
|
||||
}
|
||||
|
||||
// --- Placements ---
|
||||
|
||||
export const BottomStart: Story = {
|
||||
render: () => (
|
||||
<Popover placement="bottom-start">
|
||||
<PopoverTrigger>
|
||||
<Button variant="secondary" intent="neutral">Bottom start</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<p className="text-small text-text">Aligned to the start of the trigger.</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
),
|
||||
}
|
||||
|
||||
export const TopEnd: Story = {
|
||||
render: () => (
|
||||
<Popover placement="top-end">
|
||||
<PopoverTrigger>
|
||||
<Button variant="secondary" intent="neutral">Top end</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<p className="text-small text-text">Aligned to the end of the trigger, above.</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
),
|
||||
}
|
||||
174
src/components/molecules/Popover/Popover.tsx
Normal file
174
src/components/molecules/Popover/Popover.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import {
|
||||
useState,
|
||||
createContext,
|
||||
useContext,
|
||||
useMemo,
|
||||
forwardRef,
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
type ReactNode,
|
||||
type ReactElement,
|
||||
type HTMLAttributes,
|
||||
} from 'react'
|
||||
import {
|
||||
useFloating,
|
||||
useClick,
|
||||
useDismiss,
|
||||
useRole,
|
||||
useInteractions,
|
||||
offset,
|
||||
flip,
|
||||
shift,
|
||||
autoUpdate,
|
||||
FloatingPortal,
|
||||
FloatingFocusManager,
|
||||
type Placement,
|
||||
} from '@floating-ui/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// --- Context ---
|
||||
|
||||
interface PopoverContextValue {
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
refs: ReturnType<typeof useFloating>['refs']
|
||||
floatingStyles: ReturnType<typeof useFloating>['floatingStyles']
|
||||
context: ReturnType<typeof useFloating>['context']
|
||||
getReferenceProps: ReturnType<typeof useInteractions>['getReferenceProps']
|
||||
getFloatingProps: ReturnType<typeof useInteractions>['getFloatingProps']
|
||||
}
|
||||
|
||||
const PopoverContext = createContext<PopoverContextValue | null>(null)
|
||||
|
||||
function usePopoverContext() {
|
||||
const ctx = useContext(PopoverContext)
|
||||
if (!ctx) throw new Error('Popover compound components must be used within <Popover>')
|
||||
return ctx
|
||||
}
|
||||
|
||||
// --- Popover root ---
|
||||
|
||||
export interface PopoverProps {
|
||||
placement?: Placement
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function Popover({
|
||||
placement = 'bottom',
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
children,
|
||||
}: PopoverProps) {
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = useState(false)
|
||||
const isControlled = controlledOpen !== undefined
|
||||
const open = isControlled ? controlledOpen : uncontrolledOpen
|
||||
const setOpen = isControlled ? (onOpenChange ?? (() => {})) : setUncontrolledOpen
|
||||
|
||||
const { refs, floatingStyles, context } = useFloating({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
placement,
|
||||
whileElementsMounted: autoUpdate,
|
||||
middleware: [
|
||||
offset(8),
|
||||
flip({ fallbackAxisSideDirection: 'start' }),
|
||||
shift({ padding: 8 }),
|
||||
],
|
||||
})
|
||||
|
||||
const click = useClick(context)
|
||||
const dismiss = useDismiss(context)
|
||||
const role = useRole(context)
|
||||
|
||||
const { getReferenceProps, getFloatingProps } = useInteractions([
|
||||
click,
|
||||
dismiss,
|
||||
role,
|
||||
])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ open, setOpen, refs, floatingStyles, context, getReferenceProps, getFloatingProps }),
|
||||
[open, setOpen, refs, floatingStyles, context, getReferenceProps, getFloatingProps],
|
||||
)
|
||||
|
||||
return <PopoverContext.Provider value={value}>{children}</PopoverContext.Provider>
|
||||
}
|
||||
|
||||
// --- PopoverTrigger ---
|
||||
|
||||
export interface PopoverTriggerProps {
|
||||
children: ReactElement
|
||||
}
|
||||
|
||||
export function PopoverTrigger({ children }: PopoverTriggerProps) {
|
||||
const { refs, getReferenceProps } = usePopoverContext()
|
||||
|
||||
if (!isValidElement(children)) return children
|
||||
|
||||
return cloneElement(children, {
|
||||
ref: refs.setReference,
|
||||
...getReferenceProps(),
|
||||
} as Record<string, unknown>)
|
||||
}
|
||||
|
||||
// --- PopoverContent ---
|
||||
|
||||
export type PopoverContentProps = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export const PopoverContent = forwardRef<HTMLDivElement, PopoverContentProps>(
|
||||
({ className, children, ...props }, _ref) => {
|
||||
const { open, refs, floatingStyles, context, getFloatingProps } = usePopoverContext()
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<FloatingPortal>
|
||||
<FloatingFocusManager context={context} modal={false}>
|
||||
<div
|
||||
ref={refs.setFloating}
|
||||
style={floatingStyles}
|
||||
className={cn(
|
||||
'z-50 w-72 rounded-lg border border-border bg-surface p-4 shadow-md',
|
||||
className,
|
||||
)}
|
||||
{...getFloatingProps()}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</FloatingFocusManager>
|
||||
</FloatingPortal>
|
||||
)
|
||||
},
|
||||
)
|
||||
PopoverContent.displayName = 'PopoverContent'
|
||||
|
||||
// --- PopoverClose ---
|
||||
|
||||
export interface PopoverCloseProps extends HTMLAttributes<HTMLButtonElement> {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export const PopoverClose = forwardRef<HTMLButtonElement, PopoverCloseProps>(
|
||||
({ className, onClick, children, ...props }, ref) => {
|
||||
const { setOpen } = usePopoverContext()
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
className={className}
|
||||
onClick={(e) => {
|
||||
setOpen(false)
|
||||
onClick?.(e)
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
)
|
||||
PopoverClose.displayName = 'PopoverClose'
|
||||
7
src/components/molecules/Popover/index.ts
Normal file
7
src/components/molecules/Popover/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverClose } from './Popover'
|
||||
export type {
|
||||
PopoverProps,
|
||||
PopoverTriggerProps,
|
||||
PopoverContentProps,
|
||||
PopoverCloseProps,
|
||||
} from './Popover'
|
||||
25
src/components/organisms/ApiSettings/ApiSettings.stories.tsx
Normal file
25
src/components/organisms/ApiSettings/ApiSettings.stories.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { useState } from 'react'
|
||||
import { ApiSettings } from './ApiSettings'
|
||||
import { Button } from '@/components/atoms/Button'
|
||||
|
||||
const meta: Meta<typeof ApiSettings> = {
|
||||
title: 'Organisms/ApiSettings',
|
||||
component: ApiSettings,
|
||||
tags: ['autodocs'],
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof ApiSettings>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => {
|
||||
const [open, setOpen] = useState(true)
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setOpen(true)}>Open API settings</Button>
|
||||
<ApiSettings open={open} onClose={() => setOpen(false)} />
|
||||
</>
|
||||
)
|
||||
},
|
||||
}
|
||||
146
src/components/organisms/ApiSettings/ApiSettings.tsx
Normal file
146
src/components/organisms/ApiSettings/ApiSettings.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
} from '@/components/molecules/Dialog'
|
||||
import { Button } from '@/components/atoms/Button'
|
||||
import { Input } from '@/components/atoms/Input'
|
||||
import { Alert } from '@/components/molecules/Alert'
|
||||
import { Badge } from '@/components/atoms/Badge'
|
||||
import {
|
||||
getEndpoint,
|
||||
getApiKey,
|
||||
saveCredentials,
|
||||
testConnection,
|
||||
} from '@/lib/credentials'
|
||||
|
||||
export interface ApiSettingsProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
/** Called after credentials are saved (e.g. to refresh a "no API key" badge). */
|
||||
onSaved?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared API-settings dialog for the SDC tool suite. Reads/writes the suite-wide
|
||||
* `sdc_api_endpoint` + `sdc_api_key` so the key is entered once across all tools.
|
||||
*/
|
||||
export function ApiSettings({ open, onClose, onSaved }: ApiSettingsProps) {
|
||||
const [endpoint, setEndpoint] = useState('')
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [status, setStatus] = useState<{ type: 'success' | 'error' | 'info'; message: string } | null>(null)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [models, setModels] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setEndpoint(getEndpoint())
|
||||
setApiKey(getApiKey())
|
||||
setStatus(null)
|
||||
setModels([])
|
||||
}
|
||||
}, [open])
|
||||
|
||||
function handleSave() {
|
||||
if (!endpoint.trim()) {
|
||||
setStatus({ type: 'error', message: 'Please enter a gateway endpoint.' })
|
||||
return
|
||||
}
|
||||
if (!apiKey.trim()) {
|
||||
setStatus({ type: 'error', message: 'Please enter an API key.' })
|
||||
return
|
||||
}
|
||||
saveCredentials(endpoint, apiKey)
|
||||
onSaved?.()
|
||||
setStatus({ type: 'success', message: 'Credentials saved. They apply across all SDC tools.' })
|
||||
}
|
||||
|
||||
async function handleTest() {
|
||||
if (!endpoint.trim() || !apiKey.trim()) {
|
||||
setStatus({ type: 'error', message: 'Enter an endpoint and key first.' })
|
||||
return
|
||||
}
|
||||
saveCredentials(endpoint, apiKey)
|
||||
onSaved?.()
|
||||
setTesting(true)
|
||||
setStatus({ type: 'info', message: 'Testing connection…' })
|
||||
setModels([])
|
||||
try {
|
||||
const result = await testConnection(endpoint, apiKey)
|
||||
setModels(result.models)
|
||||
setStatus({
|
||||
type: 'success',
|
||||
message: 'Connected. ' + result.models.length + ' model(s) available.',
|
||||
})
|
||||
} catch (e) {
|
||||
setStatus({ type: 'error', message: (e as Error).message })
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} size="lg">
|
||||
<DialogHeader onClose={onClose}>
|
||||
<DialogTitle>API Settings</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure your gateway connection. This is shared across all SDC tools.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
variant="stacked"
|
||||
label="Gateway Endpoint"
|
||||
description="The base URL of your LiteLLM gateway"
|
||||
type="url"
|
||||
placeholder="https://your-gateway.example.com"
|
||||
value={endpoint}
|
||||
onChange={(e) => setEndpoint(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
variant="stacked"
|
||||
label="API Key"
|
||||
type="password"
|
||||
placeholder="Paste your API key here"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
/>
|
||||
|
||||
{status && (
|
||||
<Alert
|
||||
variant={status.type === 'success' ? 'success' : status.type === 'error' ? 'error' : 'info'}
|
||||
title={status.type === 'success' ? 'Success' : status.type === 'error' ? 'Error' : ''}
|
||||
onClose={() => setStatus(null)}
|
||||
>
|
||||
{status.message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{models.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-small font-semibold text-text-secondary">Available Models</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{models.map((m) => (
|
||||
<Badge key={m} variant="info-light">{m}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" onClick={handleTest} loading={testing}>
|
||||
Test Connection
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
2
src/components/organisms/ApiSettings/index.ts
Normal file
2
src/components/organisms/ApiSettings/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ApiSettings } from './ApiSettings'
|
||||
export type { ApiSettingsProps } from './ApiSettings'
|
||||
97
src/components/organisms/PageHeader/PageHeader.stories.tsx
Normal file
97
src/components/organisms/PageHeader/PageHeader.stories.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { PageHeader } from './PageHeader'
|
||||
|
||||
const GridIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3 3h8v8H3zm0 10h8v8H3zm10-10h8v8h-8zm0 10h8v8h-8z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const BookIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM6 4h5v8l-2.5-1.5L6 12V4z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const meta: Meta<typeof PageHeader> = {
|
||||
title: 'Organisms/PageHeader',
|
||||
component: PageHeader,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'fullscreen' },
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof PageHeader>
|
||||
|
||||
export const Light: Story = {
|
||||
render: () => (
|
||||
<PageHeader
|
||||
title="Resources"
|
||||
subtitle="Essential resources for my work"
|
||||
icon={<GridIcon />}
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
export const Dark: Story = {
|
||||
render: () => (
|
||||
<PageHeader
|
||||
title="Resources"
|
||||
subtitle="Essential resources for my work"
|
||||
icon={<GridIcon />}
|
||||
theme="dark"
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
export const NoIcon: Story = {
|
||||
name: 'No icon',
|
||||
render: () => (
|
||||
<PageHeader
|
||||
title="My Documents"
|
||||
subtitle="View and manage your uploaded documents"
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
export const Centered: Story = {
|
||||
render: () => (
|
||||
<PageHeader
|
||||
title="Welcome to your PDP"
|
||||
subtitle="Performance and Development Plan portal"
|
||||
icon={<BookIcon />}
|
||||
centered
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
export const NoBackground: Story = {
|
||||
name: 'No background',
|
||||
render: () => (
|
||||
<PageHeader
|
||||
title="Settings"
|
||||
subtitle="Manage your account preferences"
|
||||
noBackground
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
export const WithContent: Story = {
|
||||
name: 'With content slot',
|
||||
render: () => (
|
||||
<PageHeader
|
||||
title="Resources"
|
||||
subtitle="Essential resources for my work"
|
||||
icon={<GridIcon />}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<button className="rounded-full bg-primary px-5 py-2 text-small font-semibold text-white">
|
||||
Browse all
|
||||
</button>
|
||||
<button className="rounded-full border-2 border-primary px-5 py-2 text-small font-semibold text-primary">
|
||||
My favourites
|
||||
</button>
|
||||
</div>
|
||||
</PageHeader>
|
||||
),
|
||||
}
|
||||
91
src/components/organisms/PageHeader/PageHeader.tsx
Normal file
91
src/components/organisms/PageHeader/PageHeader.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface PageHeaderProps extends HTMLAttributes<HTMLDivElement> {
|
||||
title: string
|
||||
subtitle?: string
|
||||
icon?: ReactNode
|
||||
iconSize?: string
|
||||
theme?: 'light' | 'dark'
|
||||
centered?: boolean
|
||||
noBackground?: boolean
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
function DecoArcs({ isDark }: { isDark: boolean }) {
|
||||
const stroke = isDark ? 'rgba(20, 108, 253, 0.25)' : 'rgba(0, 38, 100, 0.12)'
|
||||
return (
|
||||
<svg
|
||||
className="pointer-events-none absolute right-0 top-0 h-full w-1/2"
|
||||
viewBox="0 0 400 200"
|
||||
preserveAspectRatio="xMaxYMid slice"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="350" cy="100" r="160" stroke={stroke} strokeWidth="30" />
|
||||
<circle cx="350" cy="100" r="100" stroke={stroke} strokeWidth="20" />
|
||||
<circle cx="350" cy="100" r="50" stroke={stroke} strokeWidth="12" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export const PageHeader = forwardRef<HTMLDivElement, PageHeaderProps>(
|
||||
(
|
||||
{
|
||||
title,
|
||||
subtitle,
|
||||
icon,
|
||||
iconSize = '50px',
|
||||
theme = 'light',
|
||||
centered = false,
|
||||
noBackground = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const isDark = theme === 'dark'
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative overflow-hidden px-9 py-11',
|
||||
!noBackground && (isDark ? 'bg-primary text-white' : 'bg-secondary text-primary'),
|
||||
noBackground && 'text-text',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{!noBackground && <DecoArcs isDark={isDark} />}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-10 flex gap-5',
|
||||
centered ? 'flex-col items-center text-center' : 'items-start',
|
||||
)}
|
||||
>
|
||||
{icon && (
|
||||
<span
|
||||
className="shrink-0 [&>svg]:size-full"
|
||||
style={{ width: iconSize, height: iconSize }}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="text-h2-responsive">{title}</h1>
|
||||
{subtitle && (
|
||||
<p className={cn('mt-1 text-body', isDark ? 'text-white/80' : 'text-text-secondary')}>
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
{children && <div className="mt-4">{children}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
PageHeader.displayName = 'PageHeader'
|
||||
2
src/components/organisms/PageHeader/index.ts
Normal file
2
src/components/organisms/PageHeader/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { PageHeader } from './PageHeader'
|
||||
export type { PageHeaderProps } from './PageHeader'
|
||||
20
src/components/organisms/SdcTopBar/SdcTopBar.stories.tsx
Normal file
20
src/components/organisms/SdcTopBar/SdcTopBar.stories.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { SdcTopBar } from './SdcTopBar'
|
||||
|
||||
const meta: Meta<typeof SdcTopBar> = {
|
||||
title: 'Organisms/SdcTopBar',
|
||||
component: SdcTopBar,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'fullscreen' },
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof SdcTopBar>
|
||||
|
||||
export const Default: Story = {
|
||||
args: { appName: 'Status Report', activeTool: 'status-report' },
|
||||
}
|
||||
|
||||
export const Synthesiser: Story = {
|
||||
args: { appName: 'Research Synthesiser', activeTool: 'synthesiser' },
|
||||
}
|
||||
118
src/components/organisms/SdcTopBar/SdcTopBar.tsx
Normal file
118
src/components/organisms/SdcTopBar/SdcTopBar.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { Settings, Grid3x3 } from 'lucide-react'
|
||||
import { TopBar } from '@/components/organisms/TopBar'
|
||||
import { IconButton } from '@/components/atoms/IconButton'
|
||||
import { Badge } from '@/components/atoms/Badge'
|
||||
import { Popover, PopoverTrigger, PopoverContent } from '@/components/molecules/Popover'
|
||||
import { List, ListItem, ListSubheader, ListDivider } from '@/components/atoms/List'
|
||||
import { ApiSettings } from '@/components/organisms/ApiSettings'
|
||||
import { hasCredentials as checkCredentials } from '@/lib/credentials'
|
||||
|
||||
export interface SdcTool {
|
||||
/** Stable identifier, used to mark the current tool active. */
|
||||
slug: string
|
||||
label: string
|
||||
/** Relative link from one tool folder to another, e.g. '../SDC Status Report/index.html'. */
|
||||
href: string
|
||||
group: string
|
||||
}
|
||||
|
||||
/** The SDC tool suite directory. Adding a tool here updates the menu in every app. */
|
||||
export const SDC_TOOLS: SdcTool[] = [
|
||||
{ slug: 'synthesiser', label: 'Research Synthesiser', group: 'HCD Tools', href: '../SDC Project Synthesiser/index.html' },
|
||||
{ slug: 'data-synthesis', label: 'Data Synthesis', group: 'HCD Tools', href: '../SDC Data Synthesis/index.html' },
|
||||
{ slug: 'persona', label: 'Persona Builder', group: 'HCD Tools', href: '../SDC Persona Builder/index.html' },
|
||||
{ slug: 'case-study', label: 'Case Study Generator', group: 'HCD Tools', href: '../SDC Case Study Generator/index.html' },
|
||||
{ slug: 'charter', label: 'Project Charter', group: 'Project Management', href: '../SDC Project Charter/index.html' },
|
||||
{ slug: 'timeline', label: 'Timeline Builder', group: 'Project Management', href: '../SDC Timeline Builder/index.html' },
|
||||
{ slug: 'status-report', label: 'Status Report', group: 'Project Management', href: '../SDC Status Report/index.html' },
|
||||
]
|
||||
|
||||
export interface SdcTopBarProps {
|
||||
/** App name shown top-left. */
|
||||
appName: string
|
||||
/** Slug of the current tool, marked active and non-navigating in the menu. */
|
||||
activeTool?: string
|
||||
/** Optional logo node rendered before the app name. */
|
||||
logo?: ReactNode
|
||||
/** Override the tool list (defaults to the full SDC suite). */
|
||||
tools?: SdcTool[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared top bar for the SDC tool suite: app name (left), API settings (cog), and the
|
||||
* suite app directory (grid). Wraps the ADS TopBar; uses the shared `sdc_*` credentials.
|
||||
*/
|
||||
export function SdcTopBar({ appName, activeTool, logo, tools = SDC_TOOLS }: SdcTopBarProps) {
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [toolsOpen, setToolsOpen] = useState(false)
|
||||
const [hasCreds, setHasCreds] = useState(() => checkCredentials())
|
||||
|
||||
// Group tools preserving first-seen group order.
|
||||
const groups: { name: string; items: SdcTool[] }[] = []
|
||||
for (const t of tools) {
|
||||
let g = groups.find((x) => x.name === t.group)
|
||||
if (!g) { g = { name: t.group, items: [] }; groups.push(g) }
|
||||
g.items.push(t)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopBar title={appName} logo={logo}>
|
||||
<div className="flex items-center gap-1 text-white">
|
||||
{!hasCreds && <Badge variant="warning-light">No API key</Badge>}
|
||||
|
||||
<IconButton
|
||||
icon={<Settings />}
|
||||
aria-label="API settings"
|
||||
variant="tertiary"
|
||||
className="!text-white hover:!bg-white/10"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
/>
|
||||
|
||||
<Popover placement="bottom-end" open={toolsOpen} onOpenChange={setToolsOpen}>
|
||||
<PopoverTrigger>
|
||||
<IconButton
|
||||
icon={<Grid3x3 />}
|
||||
aria-label="SDC AI Tools"
|
||||
variant="tertiary"
|
||||
className="!text-white hover:!bg-white/10"
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<div className="w-64">
|
||||
<List>
|
||||
{groups.map((group) => (
|
||||
<div key={group.name}>
|
||||
<ListSubheader>{group.name}</ListSubheader>
|
||||
{group.items.map((item) => {
|
||||
const isActive = item.slug === activeTool
|
||||
return (
|
||||
<ListItem
|
||||
key={item.slug}
|
||||
active={isActive}
|
||||
href={isActive ? undefined : item.href}
|
||||
onClick={() => isActive && setToolsOpen(false)}
|
||||
>
|
||||
{item.label}
|
||||
</ListItem>
|
||||
)
|
||||
})}
|
||||
<ListDivider />
|
||||
</div>
|
||||
))}
|
||||
</List>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</TopBar>
|
||||
|
||||
<ApiSettings
|
||||
open={settingsOpen}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
onSaved={() => setHasCreds(checkCredentials())}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
2
src/components/organisms/SdcTopBar/index.ts
Normal file
2
src/components/organisms/SdcTopBar/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { SdcTopBar, SDC_TOOLS } from './SdcTopBar'
|
||||
export type { SdcTopBarProps, SdcTool } from './SdcTopBar'
|
||||
314
src/components/organisms/SideNav/SideNav.stories.tsx
Normal file
314
src/components/organisms/SideNav/SideNav.stories.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { useState } from 'react'
|
||||
import { SideNav, SideNavItem, SideNavGroup, SideNavDivider } from './SideNav'
|
||||
|
||||
const HomeIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const PersonIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const GridIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3 3h8v8H3zm0 10h8v8H3zm10-10h8v8h-8zm0 10h8v8h-8z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const BookIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM6 4h5v8l-2.5-1.5L6 12V4z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const FolderIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const SchoolIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M5 13.18v4L12 21l7-3.82v-4L12 17l-7-3.82zM12 3 1 9l11 6 9-4.91V17h2V9L12 3z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const SupportIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const meta: Meta<typeof SideNav> = {
|
||||
title: 'Organisms/SideNav',
|
||||
component: SideNav,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="h-[600px] border border-border rounded-lg overflow-hidden">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof SideNav>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<SideNav>
|
||||
<SideNavItem icon={<HomeIcon />} href="#" active>
|
||||
My status
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<PersonIcon />} href="#">
|
||||
My details
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<GridIcon />} href="#">
|
||||
Workspace
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<BookIcon />} href="#">
|
||||
Resources
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<FolderIcon />} href="#">
|
||||
My documents & links
|
||||
</SideNavItem>
|
||||
<SideNavDivider />
|
||||
<SideNavGroup icon={<SchoolIcon />} label="PDP" defaultOpen>
|
||||
<SideNavItem href="#">My PDP</SideNavItem>
|
||||
<SideNavItem href="#">PDP guide</SideNavItem>
|
||||
<SideNavItem href="#">Management</SideNavItem>
|
||||
<SideNavItem href="#">Useful links</SideNavItem>
|
||||
</SideNavGroup>
|
||||
<SideNavDivider />
|
||||
<SideNavItem icon={<SupportIcon />} href="#">
|
||||
Support
|
||||
</SideNavItem>
|
||||
</SideNav>
|
||||
),
|
||||
}
|
||||
|
||||
export const WithActiveNested: Story = {
|
||||
name: 'Active nested item',
|
||||
render: () => (
|
||||
<SideNav>
|
||||
<SideNavItem icon={<HomeIcon />} href="#">
|
||||
My status
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<PersonIcon />} href="#">
|
||||
My details
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<GridIcon />} href="#">
|
||||
Workspace
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<BookIcon />} href="#">
|
||||
Resources
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<FolderIcon />} href="#">
|
||||
My documents & links
|
||||
</SideNavItem>
|
||||
<SideNavDivider />
|
||||
<SideNavGroup icon={<SchoolIcon />} label="PDP" defaultOpen>
|
||||
<SideNavItem href="#" active>
|
||||
My PDP
|
||||
</SideNavItem>
|
||||
<SideNavItem href="#">PDP guide</SideNavItem>
|
||||
<SideNavItem href="#">Management</SideNavItem>
|
||||
<SideNavItem href="#">Useful links</SideNavItem>
|
||||
</SideNavGroup>
|
||||
<SideNavDivider />
|
||||
<SideNavItem icon={<SupportIcon />} href="#">
|
||||
Support
|
||||
</SideNavItem>
|
||||
</SideNav>
|
||||
),
|
||||
}
|
||||
|
||||
export const Collapsed: Story = {
|
||||
render: () => (
|
||||
<SideNav collapsed>
|
||||
<SideNavItem icon={<HomeIcon />} href="#" active>
|
||||
My status
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<PersonIcon />} href="#">
|
||||
My details
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<GridIcon />} href="#">
|
||||
Workspace
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<BookIcon />} href="#">
|
||||
Resources
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<FolderIcon />} href="#">
|
||||
My documents & links
|
||||
</SideNavItem>
|
||||
<SideNavDivider />
|
||||
<SideNavGroup icon={<SchoolIcon />} label="PDP" active>
|
||||
<SideNavItem href="#" active>
|
||||
My PDP
|
||||
</SideNavItem>
|
||||
<SideNavItem href="#">PDP guide</SideNavItem>
|
||||
</SideNavGroup>
|
||||
<SideNavDivider />
|
||||
<SideNavItem icon={<SupportIcon />} href="#">
|
||||
Support
|
||||
</SideNavItem>
|
||||
</SideNav>
|
||||
),
|
||||
}
|
||||
|
||||
export const WithBadges: Story = {
|
||||
name: 'With badges',
|
||||
render: () => (
|
||||
<SideNav>
|
||||
<SideNavItem icon={<HomeIcon />} href="#" active>
|
||||
My status
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<PersonIcon />} href="#">
|
||||
My details
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<BookIcon />} href="#" badge={12}>
|
||||
Resources
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<FolderIcon />} href="#" badge={3}>
|
||||
My documents & links
|
||||
</SideNavItem>
|
||||
<SideNavDivider />
|
||||
<SideNavGroup icon={<SchoolIcon />} label="PDP" badge={5} defaultOpen>
|
||||
<SideNavItem href="#" badge={2}>
|
||||
My PDP
|
||||
</SideNavItem>
|
||||
<SideNavItem href="#" badge={3}>
|
||||
PDP guide
|
||||
</SideNavItem>
|
||||
</SideNavGroup>
|
||||
<SideNavDivider />
|
||||
<SideNavItem icon={<SupportIcon />} href="#">
|
||||
Support
|
||||
</SideNavItem>
|
||||
</SideNav>
|
||||
),
|
||||
}
|
||||
|
||||
const ToggleTemplate = () => {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
<SideNav collapsed={collapsed}>
|
||||
<SideNavItem icon={<HomeIcon />} href="#" active>
|
||||
My status
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<PersonIcon />} href="#">
|
||||
My details
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<GridIcon />} href="#">
|
||||
Workspace
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<BookIcon />} href="#">
|
||||
Resources
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<FolderIcon />} href="#">
|
||||
My documents & links
|
||||
</SideNavItem>
|
||||
<SideNavDivider />
|
||||
<SideNavGroup icon={<SchoolIcon />} label="PDP" defaultOpen>
|
||||
<SideNavItem href="#">My PDP</SideNavItem>
|
||||
<SideNavItem href="#">PDP guide</SideNavItem>
|
||||
<SideNavItem href="#">Management</SideNavItem>
|
||||
<SideNavItem href="#">Useful links</SideNavItem>
|
||||
</SideNavGroup>
|
||||
<SideNavDivider />
|
||||
<SideNavItem icon={<SupportIcon />} href="#">
|
||||
Support
|
||||
</SideNavItem>
|
||||
</SideNav>
|
||||
<button
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
className="self-start rounded-lg border border-border px-4 py-2 text-small hover:bg-bg"
|
||||
>
|
||||
{collapsed ? 'Expand' : 'Collapse'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Interactive: Story = {
|
||||
name: 'Toggle collapsed',
|
||||
render: () => <ToggleTemplate />,
|
||||
}
|
||||
|
||||
export const WithAlerts: Story = {
|
||||
name: 'With alerts',
|
||||
render: () => (
|
||||
<SideNav>
|
||||
<SideNavItem icon={<HomeIcon />} href="#" active>
|
||||
My status
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<PersonIcon />} href="#" alert="error">
|
||||
My details
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<GridIcon />} href="#">
|
||||
Workspace
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<BookIcon />} href="#" alert="info">
|
||||
Resources
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<FolderIcon />} href="#" badge={3} alert="warning">
|
||||
My documents & links
|
||||
</SideNavItem>
|
||||
<SideNavDivider />
|
||||
<SideNavGroup icon={<SchoolIcon />} label="PDP" alert="success" defaultOpen>
|
||||
<SideNavItem href="#" active>
|
||||
My PDP
|
||||
</SideNavItem>
|
||||
<SideNavItem href="#">PDP guide</SideNavItem>
|
||||
</SideNavGroup>
|
||||
<SideNavDivider />
|
||||
<SideNavItem icon={<SupportIcon />} href="#">
|
||||
Support
|
||||
</SideNavItem>
|
||||
</SideNav>
|
||||
),
|
||||
}
|
||||
|
||||
export const CollapsedWithAlerts: Story = {
|
||||
name: 'Collapsed with alerts',
|
||||
render: () => (
|
||||
<SideNav collapsed>
|
||||
<SideNavItem icon={<HomeIcon />} href="#" active>
|
||||
My status
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<PersonIcon />} href="#" alert="error">
|
||||
My details
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<GridIcon />} href="#">
|
||||
Workspace
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<BookIcon />} href="#" alert="info">
|
||||
Resources
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<FolderIcon />} href="#" badge={3} alert="warning">
|
||||
My documents & links
|
||||
</SideNavItem>
|
||||
<SideNavDivider />
|
||||
<SideNavGroup icon={<SchoolIcon />} label="PDP" alert="success">
|
||||
<SideNavItem href="#" active>
|
||||
My PDP
|
||||
</SideNavItem>
|
||||
</SideNavGroup>
|
||||
<SideNavDivider />
|
||||
<SideNavItem icon={<SupportIcon />} href="#">
|
||||
Support
|
||||
</SideNavItem>
|
||||
</SideNav>
|
||||
),
|
||||
}
|
||||
329
src/components/organisms/SideNav/SideNav.tsx
Normal file
329
src/components/organisms/SideNav/SideNav.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
import {
|
||||
createContext,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
type AnchorHTMLAttributes,
|
||||
type ButtonHTMLAttributes,
|
||||
type HTMLAttributes,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Tooltip } from '@/components/atoms/Tooltip/Tooltip'
|
||||
|
||||
type AlertVariant = 'info' | 'success' | 'warning' | 'error'
|
||||
|
||||
const alertStyles: Record<AlertVariant, string> = {
|
||||
info: 'bg-info',
|
||||
success: 'bg-success',
|
||||
warning: 'bg-warning',
|
||||
error: 'bg-error',
|
||||
}
|
||||
|
||||
function resolveAlertColor(alert: boolean | AlertVariant | undefined): string | null {
|
||||
if (!alert) return null
|
||||
return alertStyles[alert === true ? 'error' : alert]
|
||||
}
|
||||
|
||||
const ChevronIcon = () => (
|
||||
<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>
|
||||
)
|
||||
|
||||
// --- Contexts ---
|
||||
|
||||
interface SideNavContextValue {
|
||||
collapsed: boolean
|
||||
}
|
||||
|
||||
const SideNavContext = createContext<SideNavContextValue>({ collapsed: false })
|
||||
|
||||
const NestedContext = createContext(false)
|
||||
|
||||
// --- SideNav ---
|
||||
|
||||
export interface SideNavProps extends HTMLAttributes<HTMLElement> {
|
||||
collapsed?: boolean
|
||||
}
|
||||
|
||||
export const SideNav = forwardRef<HTMLElement, SideNavProps>(
|
||||
({ collapsed = false, className, children, ...props }, ref) => {
|
||||
const contextValue = useMemo(() => ({ collapsed }), [collapsed])
|
||||
|
||||
return (
|
||||
<SideNavContext.Provider value={contextValue}>
|
||||
<nav
|
||||
ref={ref}
|
||||
aria-label="Side navigation"
|
||||
className={cn(
|
||||
'flex flex-col overflow-hidden bg-nav-bg px-2 py-2 transition-[width] duration-200',
|
||||
collapsed ? 'w-20' : 'w-[360px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</nav>
|
||||
</SideNavContext.Provider>
|
||||
)
|
||||
},
|
||||
)
|
||||
SideNav.displayName = 'SideNav'
|
||||
|
||||
// --- SideNavDivider ---
|
||||
|
||||
export interface SideNavDividerProps extends HTMLAttributes<HTMLHRElement> {}
|
||||
|
||||
export const SideNavDivider = forwardRef<HTMLHRElement, SideNavDividerProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
const { collapsed } = useContext(SideNavContext)
|
||||
|
||||
return (
|
||||
<hr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'my-1 border-t border-nav-divider',
|
||||
collapsed ? 'mx-1' : 'mx-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
SideNavDivider.displayName = 'SideNavDivider'
|
||||
|
||||
// --- Badge (internal) ---
|
||||
|
||||
function NavBadge({ count }: { count: number }) {
|
||||
return (
|
||||
<span className="inline-flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full bg-primary px-1.5 text-caption font-semibold leading-none text-white tabular-nums">
|
||||
{count}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Alert dot (internal) ---
|
||||
|
||||
function AlertDot({ alert }: { alert: boolean | AlertVariant | undefined }) {
|
||||
const color = resolveAlertColor(alert)
|
||||
if (!color) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className={cn('size-2 rounded-full', color)} aria-hidden="true" />
|
||||
<span className="sr-only">Has notifications</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Icon with optional alert overlay (collapsed mode) ---
|
||||
|
||||
function NavIcon({
|
||||
icon,
|
||||
alert,
|
||||
}: {
|
||||
icon: ReactNode
|
||||
alert?: boolean | AlertVariant
|
||||
}) {
|
||||
const color = resolveAlertColor(alert)
|
||||
|
||||
return (
|
||||
<span className="relative size-6 shrink-0 text-nav-icon [&>svg]:size-full">
|
||||
{icon}
|
||||
{color && (
|
||||
<>
|
||||
<span
|
||||
className={cn('absolute -right-1 -top-1 size-2.5 rounded-full ring-2 ring-nav-bg', color)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="sr-only">Has notifications</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// --- SideNavItem ---
|
||||
|
||||
type SideNavItemBase = {
|
||||
icon?: ReactNode
|
||||
active?: boolean
|
||||
badge?: number
|
||||
alert?: boolean | AlertVariant
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
type SideNavItemAsLink = SideNavItemBase & {
|
||||
href: string
|
||||
} & Omit<AnchorHTMLAttributes<HTMLAnchorElement>, keyof SideNavItemBase | 'href'>
|
||||
|
||||
type SideNavItemAsButton = SideNavItemBase & {
|
||||
href?: undefined
|
||||
} & Omit<ButtonHTMLAttributes<HTMLButtonElement>, keyof SideNavItemBase>
|
||||
|
||||
export type SideNavItemProps = SideNavItemAsLink | SideNavItemAsButton
|
||||
|
||||
export const SideNavItem = forwardRef<HTMLAnchorElement | HTMLButtonElement, SideNavItemProps>(
|
||||
({ icon, active = false, badge, alert, className, children, ...props }, ref) => {
|
||||
const { collapsed } = useContext(SideNavContext)
|
||||
const isNested = useContext(NestedContext)
|
||||
const isLink = 'href' in props && props.href !== undefined
|
||||
|
||||
const styles = cn(
|
||||
'relative flex items-center rounded-full text-left transition-colors',
|
||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-info',
|
||||
isNested
|
||||
? 'h-14 pl-14 pr-6'
|
||||
: cn('h-14', collapsed ? 'size-14 justify-center' : 'pl-4 pr-6'),
|
||||
active
|
||||
? 'bg-nav-active/12 text-nav-text'
|
||||
: 'text-nav-text hover:bg-nav-text/[0.04] active:bg-nav-text/[0.25]',
|
||||
className,
|
||||
)
|
||||
|
||||
const hasExtras = !collapsed && (alert || badge !== undefined)
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{icon && !isNested && (
|
||||
collapsed ? (
|
||||
<NavIcon icon={icon} alert={alert} />
|
||||
) : (
|
||||
<span className="mr-4 size-6 shrink-0 text-nav-icon [&>svg]:size-full">{icon}</span>
|
||||
)
|
||||
)}
|
||||
{!collapsed && (
|
||||
<>
|
||||
<span className="flex-1 truncate text-body leading-[1.5]">{children}</span>
|
||||
{hasExtras && (
|
||||
<span className="ml-2 flex shrink-0 items-center gap-2">
|
||||
<AlertDot alert={alert} />
|
||||
{badge !== undefined && <NavBadge count={badge} />}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
const element = isLink ? (
|
||||
<a
|
||||
ref={ref as React.Ref<HTMLAnchorElement>}
|
||||
className={styles}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
{...(props as Omit<AnchorHTMLAttributes<HTMLAnchorElement>, keyof SideNavItemBase>)}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
ref={ref as React.Ref<HTMLButtonElement>}
|
||||
type="button"
|
||||
className={styles}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
{...(props as Omit<ButtonHTMLAttributes<HTMLButtonElement>, keyof SideNavItemBase>)}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
)
|
||||
|
||||
if (collapsed && !isNested) {
|
||||
return (
|
||||
<Tooltip content={children} placement="right" delay={{ open: 200, close: 0 }}>
|
||||
{element}
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return element
|
||||
},
|
||||
)
|
||||
SideNavItem.displayName = 'SideNavItem'
|
||||
|
||||
// --- SideNavGroup ---
|
||||
|
||||
export interface SideNavGroupProps extends HTMLAttributes<HTMLDivElement> {
|
||||
icon: ReactNode
|
||||
label: string
|
||||
defaultOpen?: boolean
|
||||
badge?: number
|
||||
alert?: boolean | AlertVariant
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
export const SideNavGroup = forwardRef<HTMLDivElement, SideNavGroupProps>(
|
||||
({ icon, label, defaultOpen = false, badge, alert, active = false, className, children, ...props }, ref) => {
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
const { collapsed } = useContext(SideNavContext)
|
||||
|
||||
const toggle = useCallback(() => setOpen((prev) => !prev), [])
|
||||
|
||||
const triggerStyles = cn(
|
||||
'relative flex h-14 w-full items-center rounded-full text-left transition-colors',
|
||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-info',
|
||||
collapsed ? 'size-14 justify-center' : 'pl-4 pr-6',
|
||||
active && collapsed
|
||||
? 'bg-nav-active/12 text-nav-text'
|
||||
: 'text-nav-text hover:bg-nav-text/[0.04] active:bg-nav-text/[0.25]',
|
||||
)
|
||||
|
||||
const trigger = (
|
||||
<button type="button" onClick={toggle} aria-expanded={open} className={triggerStyles}>
|
||||
{collapsed ? (
|
||||
<NavIcon icon={icon} alert={alert} />
|
||||
) : (
|
||||
<span className="mr-4 size-6 shrink-0 text-nav-icon [&>svg]:size-full">{icon}</span>
|
||||
)}
|
||||
{!collapsed && (
|
||||
<>
|
||||
<span className="min-w-0 truncate text-body leading-[1.5]">{label}</span>
|
||||
<span className="ml-2 flex shrink-0 items-center gap-2">
|
||||
<AlertDot alert={alert} />
|
||||
{badge !== undefined && <NavBadge count={badge} />}
|
||||
<span
|
||||
className={cn(
|
||||
'size-5 shrink-0 transition-transform duration-200 [&>svg]:size-full',
|
||||
open && 'rotate-180',
|
||||
)}
|
||||
>
|
||||
<ChevronIcon />
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<div ref={ref} className={className} {...props}>
|
||||
{collapsed ? (
|
||||
<Tooltip content={label} placement="right" delay={{ open: 200, close: 0 }}>
|
||||
{trigger}
|
||||
</Tooltip>
|
||||
) : (
|
||||
trigger
|
||||
)}
|
||||
{open && !collapsed && (
|
||||
<NestedContext.Provider value={true}>
|
||||
<div role="group" aria-label={label} className="flex flex-col">
|
||||
{children}
|
||||
</div>
|
||||
</NestedContext.Provider>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
SideNavGroup.displayName = 'SideNavGroup'
|
||||
7
src/components/organisms/SideNav/index.ts
Normal file
7
src/components/organisms/SideNav/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { SideNav, SideNavItem, SideNavGroup, SideNavDivider } from './SideNav'
|
||||
export type {
|
||||
SideNavProps,
|
||||
SideNavItemProps,
|
||||
SideNavGroupProps,
|
||||
SideNavDividerProps,
|
||||
} from './SideNav'
|
||||
190
src/components/organisms/TopBar/TopBar.stories.tsx
Normal file
190
src/components/organisms/TopBar/TopBar.stories.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { useState } from 'react'
|
||||
import { TopBar } from './TopBar'
|
||||
import { Avatar } from '@/components/atoms/Avatar/Avatar'
|
||||
|
||||
// --- Story icons ---
|
||||
|
||||
const MenuIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const CloseIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const BackIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const HelpIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M9.09 9a3 3 0 015.83 1c0 2-3 3-3 3" />
|
||||
<circle cx="12" cy="17" r=".5" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const BellIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const DotsIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="5" r="2" />
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
<circle cx="12" cy="19" r="2" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const NswLogo = () => (
|
||||
<img src="/nsw-logo.svg" alt="NSW Government" className="h-6" />
|
||||
)
|
||||
|
||||
function IconBtn({ icon, label }: { icon: React.ReactNode; label: string }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
className="flex size-12 items-center justify-center rounded-full text-white/80 transition-colors hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<span className="size-6 [&>svg]:size-full">{icon}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Stories ---
|
||||
|
||||
const meta: Meta<typeof TopBar> = {
|
||||
title: 'Organisms/TopBar',
|
||||
component: TopBar,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'fullscreen' },
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof TopBar>
|
||||
|
||||
const trailingActions = (
|
||||
<>
|
||||
<IconBtn icon={<HelpIcon />} label="Help" />
|
||||
<IconBtn icon={<BellIcon />} label="Notifications" />
|
||||
<IconBtn icon={<DotsIcon />} label="More options" />
|
||||
<Avatar initials="DW" size="lg" />
|
||||
</>
|
||||
)
|
||||
|
||||
export const Default: Story = {
|
||||
name: 'Top level (no menu)',
|
||||
render: () => (
|
||||
<TopBar
|
||||
title="Performance and development plan"
|
||||
leading={
|
||||
<div className="flex size-14 items-center justify-center">
|
||||
<NswLogo />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{trailingActions}
|
||||
</TopBar>
|
||||
),
|
||||
}
|
||||
|
||||
export const MenuClosed: Story = {
|
||||
name: 'Top level (menu closed)',
|
||||
render: () => (
|
||||
<TopBar
|
||||
title="Performance and development plan"
|
||||
leading={<IconBtn icon={<MenuIcon />} label="Open menu" />}
|
||||
logo={<NswLogo />}
|
||||
>
|
||||
{trailingActions}
|
||||
</TopBar>
|
||||
),
|
||||
}
|
||||
|
||||
export const MenuOpen: Story = {
|
||||
name: 'Top level (menu open)',
|
||||
render: () => (
|
||||
<TopBar
|
||||
title="Performance and development plan"
|
||||
leading={<IconBtn icon={<CloseIcon />} label="Close menu" />}
|
||||
logo={<NswLogo />}
|
||||
>
|
||||
{trailingActions}
|
||||
</TopBar>
|
||||
),
|
||||
}
|
||||
|
||||
export const ChildLevel: Story = {
|
||||
name: 'Child level',
|
||||
render: () => (
|
||||
<TopBar
|
||||
title="PDP Guide"
|
||||
leading={<IconBtn icon={<BackIcon />} label="Go back" />}
|
||||
>
|
||||
{trailingActions}
|
||||
</TopBar>
|
||||
),
|
||||
}
|
||||
|
||||
export const FullscreenDialog: Story = {
|
||||
name: 'Fullscreen dialog',
|
||||
render: () => (
|
||||
<TopBar
|
||||
title="Edit PDP"
|
||||
leading={<IconBtn icon={<CloseIcon />} label="Close" />}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="mr-2 rounded-full bg-blue-04 px-6 py-2.5 text-body font-bold text-primary-dark transition-colors hover:bg-blue-04/80"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</TopBar>
|
||||
),
|
||||
}
|
||||
|
||||
const InteractiveTemplate = () => {
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<TopBar
|
||||
title="Performance and development plan"
|
||||
leading={
|
||||
<IconBtn
|
||||
icon={menuOpen ? <CloseIcon /> : <MenuIcon />}
|
||||
label={menuOpen ? 'Close menu' : 'Open menu'}
|
||||
/>
|
||||
}
|
||||
logo={<NswLogo />}
|
||||
>
|
||||
{trailingActions}
|
||||
</TopBar>
|
||||
<div className="p-4 text-small text-text-secondary">
|
||||
Menu is {menuOpen ? 'open' : 'closed'} — click the hamburger/close icon to toggle
|
||||
</div>
|
||||
{/* Invisible click handler since IconBtn doesn't take onClick */}
|
||||
<button
|
||||
className="fixed left-3.5 top-0 z-10 size-12 opacity-0"
|
||||
onClick={() => setMenuOpen((o) => !o)}
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Interactive: Story = {
|
||||
name: 'Interactive menu toggle',
|
||||
render: () => <InteractiveTemplate />,
|
||||
}
|
||||
44
src/components/organisms/TopBar/TopBar.tsx
Normal file
44
src/components/organisms/TopBar/TopBar.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface TopBarProps extends HTMLAttributes<HTMLElement> {
|
||||
title: string
|
||||
leading?: ReactNode
|
||||
logo?: ReactNode
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export const TopBar = forwardRef<HTMLElement, TopBarProps>(
|
||||
({ title, leading, logo, className, children, ...props }, ref) => {
|
||||
return (
|
||||
<header
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-16 w-full items-center bg-topbar',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{leading && (
|
||||
<div className="flex shrink-0 items-center pl-3.5">
|
||||
{leading}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3 pl-5">
|
||||
{logo && <div className="shrink-0">{logo}</div>}
|
||||
<h1 className="truncate text-h4 font-bold leading-7 text-white">
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{children && (
|
||||
<div className="flex shrink-0 items-center pr-2.5">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
)
|
||||
},
|
||||
)
|
||||
TopBar.displayName = 'TopBar'
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user