Compare commits

...

3 Commits

Author SHA1 Message Date
95f72407f8 Add page templates, overhaul DESIGN.md, and fix SideNav text alignment
Introduce AppShell, DashboardPage, ListPage, and FormPage template
components with Storybook recipe stories for AI agent consumption.
Thoroughly update DESIGN.md with all missing components, corrected
token values, and page layout conventions. Fix SideNav button items
defaulting to centered text.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-03 15:11:01 +10:00
d915443b8c Align design system with ADS 3.0 and add new components
Token foundation: fix 16 palette colours to match official ADS_COLORS,
add 5 new palettes (teal, brown, purple, fuchsia, yellow), realign
semantic tokens (primary=navy, info=bright blue), fix border radii to
8px base, add responsive heading typography.

Component migration: swap primary/info references across all existing
components, update Button (44px/semibold), Switch (green/compact),
Chip (30px/8px radius + colour variants), SideNav (80px rail), Tag
(11 colours).

New components: SideNav, TopBar, Avatar, Tabs, PageHeader, Slider,
RangeSlider, FileInput, DataTable, List, Autocomplete.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-03 14:24:23 +10:00
f4fd1fc04b Rebrand to ADS 3.0 Design System and add DESIGN.md component reference
Convert from Research Synthesiser-specific project to general-purpose ADS 3.0
design system intended to be forked for downstream applications. Add DESIGN.md
following Google Labs spec as machine-readable reference for AI coding agents.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-22 09:59:02 +10:00
63 changed files with 5002 additions and 126 deletions

172
ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,172 @@
# ARCHITECTURE.md — ADS 3.0 Design System
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
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 downstream forks of this repo.
---
## 2. Token Pipeline
```
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
Components (use Tailwind utilities or var() references)
↓ Rendered in
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
- **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)
### How Tailwind v4 @theme Works
Declaring `--color-primary: #2563eb` inside `@theme` in `tokens.css` automatically generates utilities like `bg-primary`, `text-primary`, `border-primary`. No JavaScript config file needed — the CSS file is the config.
---
## 3. Component Taxonomy
### `src/components/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/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/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
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.
---
## 4. Styling Approach
- **Primary**: Tailwind utility classes
- **Conditional classes**: `cn()` from `@/lib/utils` (clsx + tailwind-merge)
- **Token values**: Always from `src/tokens/tokens.css`, never hardcoded
- **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`
---
## 5. Storybook Conventions
- Every component has a co-located `.stories.tsx` file
- All stories use `tags: ['autodocs']` for auto-generated docs
- Stories cover: default state, all variants, edge cases, disabled/error states
- A11y addon runs on all stories — violations should be addressed
- MCP addon enabled at `localhost:6006/mcp` for AI-assisted development
---
## 6. Project Structure
```
src/
├── components/
│ ├── 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/
│ └── global.css # Tailwind imports + base styles
├── lib/
│ └── utils.ts # cn() utility
├── hooks/ # Custom React hooks
├── App.tsx # Root component
└── main.tsx # Vite entry point
```
---
## 7. Design Tool Integration
### 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

1092
DESIGN.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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) ```bash
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) npm install
npm run storybook # Component development at localhost:6006
## React Compiler npm run dev # Vite dev server
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...
},
},
])
``` ```
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 - **Tokens** — Design tokens in `src/tokens/tokens.css` as a Tailwind v4 `@theme` block
// eslint.config.js - **Atoms** — Single-purpose elements (Button, Input, Badge, etc.)
import reactX from 'eslint-plugin-react-x' - **Molecules** — Small compositions (Alert, Dialog, Card, Accordion)
import reactDom from 'eslint-plugin-react-dom' - **Organisms** — Page-level regions (AppShell, TabBar)
export default defineConfig([ ## Usage as a Base
globalIgnores(['dist']),
{ 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.
files: ['**/*.{ts,tsx}'],
extends: [ ## Tech Stack
// Other configs...
// Enable lint rules for React | Tool | Purpose |
reactX.configs['recommended-typescript'], |------|---------|
// Enable lint rules for React DOM | React 19 | UI framework |
reactDom.configs.recommended, | TypeScript (strict) | Type safety |
], | Tailwind CSS v4 | Utility-first styling via CSS-first config |
languageOptions: { | Storybook 10 | Component development and documentation |
parserOptions: { | Vite | Build tooling |
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>sdc-frontend</title> <title>ADS 3.0 Design System</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -1,5 +1,5 @@
{ {
"name": "sdc-frontend", "name": "ads3-design-system",
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",

View File

@@ -2,11 +2,11 @@ function App() {
return ( return (
<div className="min-h-screen bg-bg text-text"> <div className="min-h-screen bg-bg text-text">
<header className="bg-surface border-b border-border px-6 py-3"> <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> </header>
<main className="p-6"> <main className="p-6">
<p className="text-text-secondary"> <p className="text-text-secondary">
Component library for the Research Synthesiser. React component library implementing the ADS 3.0 design language.
</p> </p>
</main> </main>
</div> </div>

View 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>
),
}

View 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'

View File

@@ -0,0 +1,2 @@
export { Autocomplete } from './Autocomplete'
export type { AutocompleteProps, AutocompleteOption } from './Autocomplete'

View 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>
),
}

View 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'

View File

@@ -0,0 +1,2 @@
export { Avatar } from './Avatar'
export type { AvatarProps } from './Avatar'

View File

@@ -21,7 +21,7 @@ export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
const variantStyles: Record<string, string> = { const variantStyles: Record<string, string> = {
navy: 'bg-badge-navy text-white', navy: 'bg-badge-navy text-white',
info: 'bg-badge-info text-white', info: 'bg-badge-info text-white',
'info-light': 'bg-badge-info-light text-primary-dark', 'info-light': 'bg-badge-info-light text-primary',
success: 'bg-badge-success text-white', success: 'bg-badge-success text-white',
'success-light': 'bg-badge-success-light text-badge-on-success-light', 'success-light': 'bg-badge-success-light text-badge-on-success-light',
error: 'bg-badge-error text-white', error: 'bg-badge-error text-white',
@@ -29,7 +29,7 @@ const variantStyles: Record<string, string> = {
warning: 'bg-badge-warning text-white', warning: 'bg-badge-warning text-white',
'warning-light': 'bg-badge-warning-light text-badge-on-warning-light', 'warning-light': 'bg-badge-warning-light text-badge-on-warning-light',
neutral: 'bg-badge-neutral text-text-secondary', neutral: 'bg-badge-neutral text-text-secondary',
white: 'bg-surface text-primary-dark border border-primary-dark', white: 'bg-surface text-primary border border-primary',
} }
export function Badge({ export function Badge({

View File

@@ -38,7 +38,7 @@ const variantIntentStyles: Record<string, Record<string, string>> = {
} }
const sizeStyles: Record<string, string> = { const sizeStyles: Record<string, string> = {
default: 'h-12 px-6 text-body gap-2', default: 'h-11 px-6 text-body gap-2',
comfortable: 'h-10 px-5 text-body gap-2', comfortable: 'h-10 px-5 text-body gap-2',
compact: 'h-9 px-4 text-small gap-1.5', compact: 'h-9 px-4 text-small gap-1.5',
} }
@@ -89,7 +89,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
disabled={isDisabled} disabled={isDisabled}
aria-busy={loading || undefined} aria-busy={loading || undefined}
className={cn( className={cn(
'inline-flex items-center justify-center rounded-full font-bold transition-colors', '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', 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-button-default',
sizeStyles[size], sizeStyles[size],
variantIntentStyles[variant][intent], variantIntentStyles[variant][intent],

View File

@@ -1,9 +1,12 @@
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react' import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
export interface ChipProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'> { export type ChipColor = 'default' | 'info' | 'error' | 'warning' | 'success'
export interface ChipProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children' | 'color'> {
children: ReactNode children: ReactNode
selected?: boolean selected?: boolean
color?: ChipColor
onDismiss?: () => void onDismiss?: () => void
rightIcon?: ReactNode rightIcon?: ReactNode
} }
@@ -24,8 +27,16 @@ const DismissIcon = () => (
</svg> </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>( export const Chip = forwardRef<HTMLButtonElement, ChipProps>(
({ children, selected = false, onDismiss, rightIcon, disabled, className, onClick, ...props }, ref) => { ({ children, selected = false, color, onDismiss, rightIcon, disabled, className, onClick, ...props }, ref) => {
const isInteractive = !!(onClick || onDismiss) const isInteractive = !!(onClick || onDismiss)
const Component = isInteractive ? 'button' : 'span' const Component = isInteractive ? 'button' : 'span'
@@ -49,10 +60,12 @@ export const Chip = forwardRef<HTMLButtonElement, ChipProps>(
: undefined : undefined
} }
className={cn( className={cn(
'inline-flex h-8 items-center gap-2 rounded-lg border px-3 py-1.5 text-small leading-[19px]', 'inline-flex h-[30px] items-center gap-2 rounded-default px-3 py-1.5 text-small font-bold leading-[19px]',
selected selected
? 'border-chip-selected-bg bg-chip-selected-bg text-chip-selected-text' ? 'border border-chip-selected-bg bg-chip-selected-bg text-chip-selected-text'
: 'border-chip-border bg-chip-bg text-chip-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-border/5 active:bg-chip-border/10',
isInteractive && !disabled && selected && 'hover:bg-chip-selected-bg/90 active:bg-chip-selected-bg/80', 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', isInteractive && 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-chip-border',

View File

@@ -1,2 +1,2 @@
export { Chip } from './Chip' export { Chip } from './Chip'
export type { ChipProps } from './Chip' export type { ChipProps, ChipColor } from './Chip'

View 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,
},
}

View 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'

View File

@@ -0,0 +1,2 @@
export { FileInput } from './FileInput'
export type { FileInputProps } from './FileInput'

View File

@@ -117,7 +117,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
{leftIcon && ( {leftIcon && (
<span <span
className={cn('inline-flex shrink-0 items-center justify-center text-primary-dark [&>svg]:size-full', styles.icon)} className={cn('inline-flex shrink-0 items-center justify-center text-primary [&>svg]:size-full', styles.icon)}
> >
{leftIcon} {leftIcon}
</span> </span>
@@ -144,7 +144,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
{rightIcon && ( {rightIcon && (
<span <span
className={cn('inline-flex shrink-0 items-center justify-center text-primary-dark [&>svg]:size-full', styles.icon)} className={cn('inline-flex shrink-0 items-center justify-center text-primary [&>svg]:size-full', styles.icon)}
> >
{rightIcon} {rightIcon}
</span> </span>

View 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>
),
}

View 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'

View File

@@ -0,0 +1,2 @@
export { List, ListItem, ListSubheader, ListDivider } from './List'
export type { ListProps, ListItemProps, ListSubheaderProps, ListDividerProps } from './List'

View File

@@ -311,8 +311,8 @@ export const Select = forwardRef<HTMLDivElement, SelectProps>(
}} }}
className={cn( className={cn(
'cursor-pointer px-4 py-2.5 text-body text-text transition-colors', 'cursor-pointer px-4 py-2.5 text-body text-text transition-colors',
option.value === selectedValue && 'bg-primary/12 font-bold', option.value === selectedValue && 'bg-info/12 font-bold',
index === activeIndex && option.value !== selectedValue && 'bg-primary/5', index === activeIndex && option.value !== selectedValue && 'bg-info/5',
option.disabled && 'pointer-events-none text-text/30', option.disabled && 'pointer-events-none text-text/30',
)} )}
> >

View 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 />,
}

View 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'

View File

@@ -0,0 +1,2 @@
export { Slider, RangeSlider } from './Slider'
export type { SliderProps, RangeSliderProps } from './Slider'

View File

@@ -40,9 +40,9 @@ export const Switch = forwardRef<HTMLButtonElement, SwitchProps>(
disabled={disabled} disabled={disabled}
onClick={() => onChange?.(!checked)} onClick={() => onChange?.(!checked)}
className={cn( className={cn(
'relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full transition-colors duration-150', 'relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full transition-colors duration-150',
checked ? 'bg-control-checked' : 'bg-control-border', checked ? 'bg-switch-on' : 'bg-control-border',
!disabled && checked && 'hover:bg-control-checked-hover', !disabled && checked && 'hover:bg-switch-on-hover',
!disabled && !checked && 'hover:bg-control-border-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', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-control-focus-ring focus-visible:ring-offset-2',
'active:scale-[0.97]', 'active:scale-[0.97]',
@@ -52,8 +52,8 @@ export const Switch = forwardRef<HTMLButtonElement, SwitchProps>(
> >
<span <span
className={cn( className={cn(
'pointer-events-none inline-block size-[18px] rounded-full bg-white shadow-default transition-transform duration-150', 'pointer-events-none inline-block size-3.5 rounded-full bg-white shadow-default transition-transform duration-150',
checked ? 'translate-x-[22px]' : 'translate-x-[3px]', checked ? 'translate-x-[18px]' : 'translate-x-[2px]',
)} )}
/> />
</button> </button>

View 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 />,
}

View 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'

View File

@@ -0,0 +1,2 @@
export { Tabs, TabList, Tab, TabPanel } from './Tabs'
export type { TabsProps, TabListProps, TabProps, TabPanelProps } from './Tabs'

View File

@@ -1,7 +1,9 @@
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react' import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
export type TagColor = 'navy' | 'blue' | 'green' | 'red' | 'orange' | 'grey' export type TagColor =
| 'navy' | 'blue' | 'green' | 'red' | 'orange' | 'grey'
| 'teal' | 'brown' | 'purple' | 'fuchsia' | 'yellow'
export interface TagProps extends HTMLAttributes<HTMLSpanElement> { export interface TagProps extends HTMLAttributes<HTMLSpanElement> {
variant?: 'outline' | 'filled' | 'light' variant?: 'outline' | 'filled' | 'light'
@@ -42,6 +44,31 @@ const colorVariantStyles: Record<TagColor, Record<string, string>> = {
filled: 'bg-tag-grey text-white', filled: 'bg-tag-grey text-white',
light: 'bg-tag-grey-light text-tag-grey', 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> = { const sizeStyles: Record<string, string> = {

View File

@@ -39,8 +39,8 @@ export const Default: Story = {
<AccordionItem value="item-1"> <AccordionItem value="item-1">
<AccordionTrigger>What is this design system?</AccordionTrigger> <AccordionTrigger>What is this design system?</AccordionTrigger>
<AccordionContent> <AccordionContent>
A React component library built for the Research Synthesiser, following NSW Design System A React component library implementing the ADS 3.0 design language with custom tokens
patterns with custom tokens and Tailwind CSS v4. and Tailwind CSS v4.
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
<AccordionItem value="item-2"> <AccordionItem value="item-2">

View File

@@ -189,8 +189,8 @@ export const AccordionTrigger = forwardRef<HTMLButtonElement, AccordionTriggerPr
onClick={() => toggle(value)} onClick={() => toggle(value)}
className={cn( className={cn(
'flex h-16 w-full items-center gap-3 px-6 text-left font-bold text-text transition-colors', '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-primary', 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-info',
isOpen ? 'bg-primary/12' : 'bg-surface hover:bg-primary/5', isOpen ? 'bg-info/12' : 'bg-surface hover:bg-info/5',
disabled && 'pointer-events-none opacity-50', disabled && 'pointer-events-none opacity-50',
className, className,
)} )}

View File

@@ -11,7 +11,7 @@ const variantStyles: Record<string, string> = {
surface: 'bg-surface border border-border shadow-default', surface: 'bg-surface border border-border shadow-default',
outlined: 'bg-surface border border-border', outlined: 'bg-surface border border-border',
elevated: 'bg-surface shadow-md', elevated: 'bg-surface shadow-md',
filled: 'bg-primary-dark text-white', filled: 'bg-primary text-white',
} }
export const Card = forwardRef<HTMLDivElement, CardProps>( export const Card = forwardRef<HTMLDivElement, CardProps>(

View 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 />
),
}

View 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

View File

@@ -0,0 +1,2 @@
export { DataTable } from './DataTable'
export type { DataTableProps, DataTableColumn } from './DataTable'

View File

@@ -101,7 +101,7 @@ export const DialogHeader = forwardRef<HTMLDivElement, DialogHeaderProps>(
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="shrink-0 rounded-full p-1.5 text-text-secondary transition-colors hover:bg-primary/5 hover:text-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" 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" aria-label="Close dialog"
> >
<svg className="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"> <svg className="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">

View 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>
),
}

View 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'

View File

@@ -0,0 +1,2 @@
export { PageHeader } from './PageHeader'
export type { PageHeaderProps } from './PageHeader'

View 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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>
),
}

View 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 bg-nav-bg px-2 py-2 transition-[width] duration-200',
collapsed ? 'w-20 items-center' : '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'

View File

@@ -0,0 +1,7 @@
export { SideNav, SideNavItem, SideNavGroup, SideNavDivider } from './SideNav'
export type {
SideNavProps,
SideNavItemProps,
SideNavGroupProps,
SideNavDividerProps,
} from './SideNav'

View File

@@ -0,0 +1,205 @@
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 = () => (
<svg viewBox="0 0 36 35" className="size-9" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" clipRule="evenodd" d="M20.234 29.091c-.204-.333-.512-.636-.916-.899a4.932 4.932 0 0 0-1.629-.597l-1.886-.391c-.57-.124-.963-.285-1.168-.476-.2-.187-.302-.435-.302-.738 0-.193.044-.369.13-.523.086-.154.211-.291.371-.406.161-.115.361-.206.594-.27a2.92 2.92 0 0 1 .788-.098c.378 0 .719.05 1.013.15.291.1.532.262.717.484.184.223.297.525.336.899l.005.052h2.341l-.001-.06a3.474 3.474 0 0 0-.531-1.734 3.453 3.453 0 0 0-1.5-1.23c-.653-.302-1.466-.454-2.419-.454-.8 0-1.533.14-2.18.418-.648.278-1.169.68-1.546 1.194-.38.516-.568 1.135-.563 1.838.016.859.275 1.555.77 2.072.493.514 1.169.856 2.01 1.016l1.902.39c.35.07.666.161.942.273.27.109.486.25.642.421.151.167.228.385.228.65 0 .293-.096.542-.284.736-.193.2-.456.35-.778.449a3.527 3.527 0 0 1-1.095.15c-.387 0-.744-.06-1.06-.18a1.956 1.956 0 0 1-.784-.514 1.714 1.714 0 0 1-.42-.822l-.01-.046h-2.386l.006.064c.057.562.23 1.06.516 1.482.286.418.651.771 1.086 1.05.434.277.921.488 1.448.627a6.44 6.44 0 0 0 1.613.209c.891 0 1.682-.142 2.351-.422.671-.281 1.202-.668 1.579-1.151.38-.485.572-1.04.572-1.647 0-.287-.032-.606-.096-.949a3.178 3.178 0 0 0-.405-.948ZM3.577 26.662l5.092 7.412h2.071V22.99H8.424v7.13L3.552 23.014l-.017-.025H1.268v11.085h2.31v-7.412Zm27.123 3.417 1.984-7.09h2.27l-3.253 11.085h-1.844l-2.007-7.017-2.022 7.017H24.014L20.77 22.99h2.276l1.984 7.088 1.985-7.088h1.678l2.007 7.089Z" fill="white" />
<path d="M16.868 20.24c-2.724-.338-5.37.542-10.027-.733-.475-.13-.65.375-.393.798 1.25 2.058 7.57.445 10.432.148.125-.013.113-.198-.012-.213Z" fill="#D7153A" />
<path d="M28.791 19.508c-4.656 1.274-7.302.395-10.026.731-.125.016-.138.201-.013.214 2.863.297 9.182 1.91 10.432-.148.258-.423.083-.928-.393-1.06v.263Z" fill="#D7153A" />
<path d="M8.26 16.564c-.947-1.399-1.72-2.963-2.32-4.684-1.807.531-3.657 1.3-5.54 2.303a.629.629 0 0 0-.4.65c-.007.276.13.527.366.669 3.646 2.197 7.21 3.454 10.611 3.744a7.133 7.133 0 0 1-2.718-2.682Z" fill="#D7153A" />
<path d="M3.18 11.89c.835-.337 1.661-.625 2.48-.867a31.64 31.64 0 0 1-.724-3.011 24.71 24.71 0 0 0-2.924-.228h-.026c-.264 0-.502.133-.641.362a.637.637 0 0 0-.017.762c.586 1.065 1.204 2.059 1.852 2.982Z" fill="#D7153A" />
<path d="M12.139 18.849c.565.251 1.143.392 1.704.421-1.373-.99-2.451-2.668-3.073-4.808-.8-2.754-1.068-5.793-.804-9.071a28.32 28.32 0 0 0-3.511-1.926.622.622 0 0 0-.746.06.636.636 0 0 0-.33.673c.294 4.757 1.511 8.747 3.617 11.86.884 1.305 1.97 2.27 3.143 2.791Z" fill="#D7153A" />
<path d="M14.826 3.278a28.02 28.02 0 0 0-2.104-2.342.605.605 0 0 0-.531-.229.588.588 0 0 0-.515.573c-.148.718-.43 2.181-.554 3.63a27.77 27.77 0 0 1 2.68 2.033c.317-1.217.73-2.451 1.024-3.665Z" fill="#D7153A" />
<path d="M35.23 14.183c-1.882-1.005-3.732-1.772-5.539-2.304a24.63 24.63 0 0 1-2.32 4.684 7.127 7.127 0 0 1-2.718 2.682c3.401-.29 6.966-1.548 10.611-3.744a.635.635 0 0 0 .366-.67.629.629 0 0 0-.4-.648Z" fill="#D7153A" />
<path d="M32.451 11.889a28.137 28.137 0 0 0 1.853-2.983.637.637 0 0 0-.017-.762.618.618 0 0 0-.641-.362h-.026c-1.008.034-1.983.11-2.924.228-.19 1.048-.431 2.053-.724 3.011.819.243 1.646.531 2.48.867Z" fill="#D7153A" />
<path d="M24.862 14.463c-.622-2.139-1.7-3.818-3.073-4.808.56.029 1.14.17 1.705.421 1.171.521 2.258 1.486 3.141 2.791 2.107 3.112 3.324 7.103 3.617 11.859a.636.636 0 0 0-.33-.673.622.622 0 0 0-.745-.06 28.382 28.382 0 0 0-3.511 1.926c.264-3.279-.003-6.318-.804-9.071v-.385Z" fill="#D7153A" />
<path d="M24.719 4.939c-.125-1.447-.406-2.911-.554-3.63a.588.588 0 0 0-.515-.572.605.605 0 0 0-.53.229 28.145 28.145 0 0 0-2.105 2.342c.504 1.243.917 2.476 1.234 3.694a27.58 27.58 0 0 1 2.47-2.063Z" fill="#D7153A" />
<path d="M17.813 12.128c.916-1.82 2.379-3.452 3.477-4.483a26.56 26.56 0 0 0-2.814-7.243.631.631 0 0 0-1.326 0 26.634 26.634 0 0 0-3.474 7.243c1.147 1.069 2.66 2.812 3.474 4.483h.663Z" fill="#D7153A" />
<path d="M21.178 18.587c.95-.672 1.9-1.843 2.502-3.472 1.167-3.153 1.287-6.666 1.12-9.152-2.114 1.42-5.471 4.387-6.617 7.558-.54 1.492-.791 3.525-.375 4.66.174.474.469.833.875 1.004.616.26 1.56.063 2.495-.598Z" fill="#D7153A" />
<path d="M17.34 13.245a11.07 11.07 0 0 0-1.066-1.973 22.052 22.052 0 0 0-5.445-5.309c-.023.369-.354 4.351.808 8.245.883 2.962 2.393 4.168 3.221 4.634.833.469 1.595.579 2.488.356-.906-1.242-.793-3.792-.006-5.954Z" fill="#D7153A" />
</svg>
)
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 />,
}

View 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'

View File

@@ -0,0 +1,2 @@
export { TopBar } from './TopBar'
export type { TopBarProps } from './TopBar'

View File

@@ -0,0 +1,95 @@
import type { Meta, StoryObj } from '@storybook/react'
import { useState } from 'react'
import { AppShell } from './AppShell'
import { TopBar } from '@/components/organisms/TopBar/TopBar'
import { SideNav, SideNavItem, SideNavGroup, SideNavDivider } from '@/components/organisms/SideNav/SideNav'
import { Avatar } from '@/components/atoms/Avatar/Avatar'
import { IconButton } from '@/components/atoms/IconButton/IconButton'
import { PageHeader } from '@/components/organisms/PageHeader/PageHeader'
import { Menu, Search, Bell, Home, FileText, LayoutGrid, Settings, Users, Link } from 'lucide-react'
const NswLogo = () => (
<div className="flex size-7 items-center justify-center rounded bg-white/20 text-caption font-bold text-white">NSW</div>
)
const meta: Meta<typeof AppShell> = {
title: 'Templates/AppShell',
component: AppShell,
tags: ['autodocs', 'template'],
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: 'Application shell layout that composes TopBar + SideNav + scrollable content area. All page templates should be rendered inside an AppShell.',
},
},
},
}
export default meta
type Story = StoryObj<typeof AppShell>
const SampleTopBar = ({ onMenuClick }: { onMenuClick?: () => void }) => (
<TopBar
title="My Application"
leading={<IconButton icon={<Menu />} aria-label="Toggle menu" variant="tertiary" onClick={onMenuClick} />}
logo={<NswLogo />}
>
<IconButton icon={<Search />} aria-label="Search" variant="tertiary" />
<IconButton icon={<Bell />} aria-label="Notifications" variant="tertiary" />
<Avatar initials="MM" size="sm" />
</TopBar>
)
const SampleSideNav = ({ collapsed }: { collapsed: boolean }) => (
<SideNav collapsed={collapsed}>
<SideNavItem icon={<Home />} active>My status</SideNavItem>
<SideNavItem icon={<FileText />}>My details</SideNavItem>
<SideNavItem icon={<LayoutGrid />}>Workspace</SideNavItem>
<SideNavDivider />
<SideNavGroup icon={<Users />} label="PDP" defaultOpen>
<SideNavItem>My PDP</SideNavItem>
<SideNavItem>PDP guide</SideNavItem>
<SideNavItem>Management</SideNavItem>
</SideNavGroup>
<SideNavItem icon={<Link />}>Resources</SideNavItem>
<SideNavItem icon={<Settings />}>Settings</SideNavItem>
</SideNav>
)
export const Default: Story = {
render: () => {
const [collapsed, setCollapsed] = useState(false)
return (
<AppShell
topBar={<SampleTopBar onMenuClick={() => setCollapsed(!collapsed)} />}
sideNav={<SampleSideNav collapsed={collapsed} />}
sideNavCollapsed={collapsed}
>
<PageHeader title="Dashboard" subtitle="Welcome back, Myra McKay" />
<div className="p-6">
<div className="rounded-lg border border-border bg-surface p-8 text-center text-text-secondary">
Page content goes here
</div>
</div>
</AppShell>
)
},
}
export const Collapsed: Story = {
render: () => (
<AppShell
topBar={<SampleTopBar />}
sideNav={<SampleSideNav collapsed />}
sideNavCollapsed
>
<PageHeader title="Dashboard" subtitle="SideNav collapsed to icon-only mode" />
<div className="p-6">
<div className="rounded-lg border border-border bg-surface p-8 text-center text-text-secondary">
Content area is wider with collapsed sidebar
</div>
</div>
</AppShell>
),
}

View File

@@ -0,0 +1,30 @@
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
import { cn } from '@/lib/utils'
export interface AppShellProps extends HTMLAttributes<HTMLDivElement> {
/** TopBar component rendered fixed at the top */
topBar: ReactNode
/** SideNav component rendered in the left rail */
sideNav: ReactNode
/** Whether the SideNav is in collapsed (icon-only) mode */
sideNavCollapsed?: boolean
}
export const AppShell = forwardRef<HTMLDivElement, AppShellProps>(
({ topBar, sideNav, sideNavCollapsed = false, className, children, ...props }, ref) => {
return (
<div ref={ref} className={cn('flex h-screen flex-col bg-bg', className)} {...props}>
<div className="shrink-0">{topBar}</div>
<div className="flex flex-1 overflow-hidden">
<aside className="flex shrink-0 overflow-y-auto">{sideNav}</aside>
<main className="flex-1 overflow-y-auto">
{children}
</main>
</div>
</div>
)
},
)
AppShell.displayName = 'AppShell'

View File

@@ -0,0 +1,2 @@
export { AppShell } from './AppShell'
export type { AppShellProps } from './AppShell'

View File

@@ -0,0 +1,193 @@
import type { Meta, StoryObj } from '@storybook/react'
import { useState } from 'react'
import { DashboardPage } from './DashboardPage'
import { AppShell } from '@/components/templates/AppShell/AppShell'
import { TopBar } from '@/components/organisms/TopBar/TopBar'
import { SideNav, SideNavItem, SideNavDivider } from '@/components/organisms/SideNav/SideNav'
import { PageHeader } from '@/components/organisms/PageHeader/PageHeader'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/molecules/Card/Card'
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/molecules/Accordion/Accordion'
import { Badge } from '@/components/atoms/Badge/Badge'
import { Avatar } from '@/components/atoms/Avatar/Avatar'
import { IconButton } from '@/components/atoms/IconButton/IconButton'
import { Menu, Bell, Home, FileText, LayoutGrid, Users, CheckCircle, Clock, Info } from 'lucide-react'
const NswLogo = () => (
<div className="flex size-7 items-center justify-center rounded bg-white/20 text-caption font-bold text-white">NSW</div>
)
const meta: Meta<typeof DashboardPage> = {
title: 'Templates/DashboardPage',
component: DashboardPage,
tags: ['autodocs', 'template'],
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: 'Dashboard page template with stat summary row and a responsive 2-column content grid. Use inside AppShell for the full page layout.',
},
},
},
}
export default meta
type Story = StoryObj<typeof DashboardPage>
export const ProfessionalPathway: Story = {
name: 'Professional Pathway Dashboard',
render: () => {
const [collapsed, setCollapsed] = useState(false)
return (
<AppShell
topBar={
<TopBar
title=""
leading={<IconButton icon={<Menu />} aria-label="Menu" variant="tertiary" onClick={() => setCollapsed(!collapsed)} />}
logo={<NswLogo />}
>
<IconButton icon={<Bell />} aria-label="Notifications" variant="tertiary" />
<Avatar initials="MM" size="sm" />
</TopBar>
}
sideNav={
<SideNav collapsed={collapsed}>
<SideNavItem icon={<Home />} active>My status</SideNavItem>
<SideNavItem icon={<Users />}>My details</SideNavItem>
<SideNavItem icon={<LayoutGrid />}>Workspace</SideNavItem>
<SideNavItem icon={<FileText />}>Resources</SideNavItem>
<SideNavDivider />
<SideNavItem icon={<Users />}>Accreditation</SideNavItem>
</SideNav>
}
sideNavCollapsed={collapsed}
>
<DashboardPage
header={
<PageHeader title="Myra McKay" subtitle="Accreditation Level: Proficient Teacher" theme="dark">
<div className="mt-2 text-small text-white/80">
Maroubra Junction Public School
</div>
</PageHeader>
}
>
<Card variant="surface">
<CardHeader>
<CardTitle>Steps to be taken</CardTitle>
</CardHeader>
<CardContent className="p-0">
<Accordion type="single" collapsible>
<AccordionItem value="s1">
<AccordionTrigger>Ensure you have completed the minimum requirements of your teaching degree as stated by NESA.</AccordionTrigger>
<AccordionContent>Details about teaching degree requirements.</AccordionContent>
</AccordionItem>
<AccordionItem value="s2">
<AccordionTrigger>Apply for your Working With Children Check (WWCC).</AccordionTrigger>
<AccordionContent>Information about WWCC application.</AccordionContent>
</AccordionItem>
<AccordionItem value="s3">
<AccordionTrigger>Create an eTAMS account and submit required documentation to NESA.</AccordionTrigger>
<AccordionContent>Steps for eTAMS registration.</AccordionContent>
</AccordionItem>
<AccordionItem value="s4">
<AccordionTrigger>Pay your NESA fee.</AccordionTrigger>
<AccordionContent>Payment details.</AccordionContent>
</AccordionItem>
<AccordionItem value="s5">
<AccordionTrigger>Complete Mandatory Training.</AccordionTrigger>
<AccordionContent>Training module details.</AccordionContent>
</AccordionItem>
</Accordion>
</CardContent>
</Card>
<div className="flex flex-col gap-6">
<Card variant="surface">
<CardHeader>
<CardTitle>Mandatory Training Reminders</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between border-b border-border pb-3">
<span className="text-body font-medium">Aboriginal Cultural Education</span>
<Badge variant="success" leftIcon={<CheckCircle size={14} />}>Certified</Badge>
</div>
<p className="mt-4 text-small text-text-secondary">
Please consult the Mandatory Training Hub for role specific training, or contact the MyPL Helpdesk for queries regarding training.
</p>
</CardContent>
</Card>
<Card variant="elevated" className="bg-info/5">
<CardContent className="flex gap-4 p-5">
<Avatar initials="MK" size="lg" />
<div className="text-small">
<p className="font-medium text-text">
Hi I am Martha. I got my conditional accreditation recently through NESA. These links really helped me through the process.
</p>
<div className="mt-3 flex flex-col gap-1">
<a href="#" className="text-info hover:underline">The resources that helped me</a>
<a href="#" className="text-info hover:underline">FAQ (questions I had)</a>
</div>
</div>
</CardContent>
</Card>
</div>
</DashboardPage>
</AppShell>
)
},
}
export const Standalone: Story = {
name: 'Without AppShell',
render: () => (
<DashboardPage
header={<PageHeader title="Dashboard" subtitle="Overview of your activity" />}
stats={
<>
<Card variant="surface" className="min-w-[180px] flex-1">
<CardContent className="flex items-center gap-4 p-5">
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
<Clock size={20} />
</div>
<div>
<p className="text-h3 font-bold text-text">21h</p>
<p className="text-small text-text-secondary">Total hours logged</p>
<p className="text-caption text-text-secondary">Target 100h</p>
</div>
</CardContent>
</Card>
<Card variant="surface" className="min-w-[180px] flex-1">
<CardContent className="flex items-center gap-4 p-5">
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
<Clock size={20} />
</div>
<div>
<p className="text-h3 font-bold text-text">18h</p>
<p className="text-small text-text-secondary">NESA Registered PD</p>
<p className="text-caption text-text-secondary">Target 60h</p>
</div>
</CardContent>
</Card>
<Card variant="surface" className="min-w-[180px] flex-1">
<CardContent className="flex items-center gap-4 p-5">
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
<Info size={20} />
</div>
<div>
<p className="text-h3 font-bold text-text">5</p>
<p className="text-small text-text-secondary">Activities logged</p>
</div>
</CardContent>
</Card>
</>
}
>
<Card variant="surface">
<CardContent className="p-8 text-center text-text-secondary">Left column content</CardContent>
</Card>
<Card variant="surface">
<CardContent className="p-8 text-center text-text-secondary">Right column content</CardContent>
</Card>
</DashboardPage>
),
}

View File

@@ -0,0 +1,30 @@
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
import { cn } from '@/lib/utils'
export interface DashboardPageProps extends HTMLAttributes<HTMLDivElement> {
/** PageHeader or custom header section */
header?: ReactNode
/** Row of stat cards or summary widgets displayed above the content grid */
stats?: ReactNode
/** Two-column responsive content grid area */
children: ReactNode
}
export const DashboardPage = forwardRef<HTMLDivElement, DashboardPageProps>(
({ header, stats, className, children, ...props }, ref) => {
return (
<div ref={ref} className={cn('flex flex-col', className)} {...props}>
{header}
<div className="flex flex-col gap-6 p-6">
{stats && <div className="flex flex-wrap gap-4">{stats}</div>}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{children}
</div>
</div>
</div>
)
},
)
DashboardPage.displayName = 'DashboardPage'

View File

@@ -0,0 +1,2 @@
export { DashboardPage } from './DashboardPage'
export type { DashboardPageProps } from './DashboardPage'

View File

@@ -0,0 +1,198 @@
import type { Meta, StoryObj } from '@storybook/react'
import { useState } from 'react'
import { FormPage } from './FormPage'
import { AppShell } from '@/components/templates/AppShell/AppShell'
import { TopBar } from '@/components/organisms/TopBar/TopBar'
import { SideNav, SideNavItem, SideNavGroup, SideNavDivider } from '@/components/organisms/SideNav/SideNav'
import { PageHeader } from '@/components/organisms/PageHeader/PageHeader'
import { Card, CardContent } from '@/components/molecules/Card/Card'
import { Alert } from '@/components/molecules/Alert/Alert'
import { Input } from '@/components/atoms/Input/Input'
import { Select } from '@/components/atoms/Select/Select'
import { Button } from '@/components/atoms/Button/Button'
import { Badge } from '@/components/atoms/Badge/Badge'
import { Avatar } from '@/components/atoms/Avatar/Avatar'
import { IconButton } from '@/components/atoms/IconButton/IconButton'
import { Menu, Bell, Home, FileText, LayoutGrid, Users, Link, ArrowRight } from 'lucide-react'
const NswLogo = () => (
<div className="flex size-7 items-center justify-center rounded bg-white/20 text-caption font-bold text-white">NSW</div>
)
const meta: Meta<typeof FormPage> = {
title: 'Templates/FormPage',
component: FormPage,
tags: ['autodocs', 'template'],
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: 'Form page template with optional vertical stepper and constrained-width form content. Use inside AppShell for the full page layout.',
},
},
},
}
export default meta
type Story = StoryObj<typeof FormPage>
export const PDPDetails: Story = {
name: 'PDP Form',
render: () => {
const [collapsed, setCollapsed] = useState(false)
return (
<AppShell
topBar={
<TopBar
title="Performance and development plan"
leading={<IconButton icon={<Menu />} aria-label="Menu" variant="tertiary" onClick={() => setCollapsed(!collapsed)} />}
logo={<NswLogo />}
>
<IconButton icon={<Bell />} aria-label="Notifications" variant="tertiary" />
<Avatar initials="DW" size="sm" />
</TopBar>
}
sideNav={
<SideNav collapsed={collapsed}>
<SideNavItem icon={<Home />}>My status</SideNavItem>
<SideNavItem icon={<Users />}>My details</SideNavItem>
<SideNavItem icon={<LayoutGrid />}>Workspace</SideNavItem>
<SideNavItem icon={<Link />}>Resources</SideNavItem>
<SideNavItem icon={<FileText />}>My documents & links</SideNavItem>
<SideNavDivider />
<SideNavGroup icon={<FileText />} label="PDP" defaultOpen active>
<SideNavItem active>My PDP</SideNavItem>
<SideNavItem>PDP guide</SideNavItem>
<SideNavItem>Management</SideNavItem>
<SideNavItem>Useful links</SideNavItem>
<SideNavItem>Support</SideNavItem>
</SideNavGroup>
</SideNav>
}
sideNavCollapsed={collapsed}
>
<FormPage
header={
<PageHeader title="Siya Ram" subtitle="Role title goes here" theme="dark">
<div className="mt-2 flex items-center gap-4">
<Badge variant="warning">Plan - In progress</Badge>
<span className="text-small text-white/80">Date commenced: dd-mm-yyyy</span>
</div>
</PageHeader>
}
actions={
<>
<Select
label=""
variant="stacked"
options={[{ value: '2026', label: '2026 - PDP Siya Ram' }]}
defaultValue="2026"
/>
<Button variant="secondary">More actions</Button>
</>
}
steps={[
{ label: 'Your PDP details', status: 'current' },
{ label: 'Create your PDP', status: 'upcoming' },
{ label: 'Notify your PDP supervisor', status: 'upcoming' },
]}
>
<Card variant="surface">
<CardContent className="space-y-6 p-6">
<div>
<h2 className="text-h3 font-bold text-text">Welcome to your Performance and Development Plan (PDP)</h2>
<p className="mt-2 text-body text-text-secondary">
Once your goals are drafted and you're ready to share them, you can notify your PDP supervisor. Head to the Digital PDP page on the intranet to find key resources to help you complete your PDP.
</p>
</div>
<Alert variant="info" title="Your PDP details">
Fill in the details below to get started with your PDP.
</Alert>
<div className="space-y-4">
<Select
label="PDP year"
options={[
{ value: '2026', label: '2026' },
{ value: '2025', label: '2025' },
]}
defaultValue="2026"
/>
<div>
<p className="text-body font-semibold text-text">Middle leader role(s)</p>
<p className="mb-2 text-small text-text-secondary">Some text about middle leader roles</p>
<Select
label="Middle leader role type (optional)"
options={[
{ value: 'deputy', label: 'Deputy Principal' },
{ value: 'head', label: 'Head Teacher' },
{ value: 'asst', label: 'Assistant Principal' },
]}
defaultValue="deputy"
/>
</div>
<div>
<p className="text-body font-semibold text-text">Add your PDP supervisor's details here</p>
<p className="mb-2 text-small text-text-secondary">
Note: if your supervisor's name does not appear when you search for them, ask them to access the Digital PDP using their credentials, then try again.
</p>
<div className="space-y-4">
<Input label="PDP Supervisor's email" error="PDP Supervisor's email" defaultValue="dhoni.mahi@det.nsw.edu.au" />
<Input label="PDP Supervisor work location" error="PDP Supervisor work location" defaultValue="Work location goes here" />
</div>
</div>
<div>
<p className="text-body font-semibold text-text">Add your school or work location.</p>
<p className="mb-2 text-small text-text-secondary">
If you don't work in a school, add 'Education Office' as your work location.
</p>
<Input label="Your school or work location" error="Your school or work location" defaultValue="Work location goes here" />
</div>
<p className="text-small text-text-secondary">
<strong>Note:</strong> As the school leader, your principal can view all the POPs in the school.
</p>
<div className="flex justify-start pt-2">
<Button rightIcon={<ArrowRight size={18} />}>Proceed</Button>
</div>
</div>
</CardContent>
</Card>
</FormPage>
</AppShell>
)
},
}
export const SimpleForm: Story = {
name: 'Simple Form (no stepper)',
render: () => (
<FormPage
header={<PageHeader title="Create Account" subtitle="Set up your profile to get started" />}
>
<Card variant="surface">
<CardContent className="space-y-4 p-6">
<Input label="Full name" placeholder="Enter your full name" />
<Input label="Email address" type="email" placeholder="you@example.com" />
<Select
label="Role"
options={[
{ value: 'teacher', label: 'Teacher' },
{ value: 'principal', label: 'Principal' },
{ value: 'admin', label: 'Administrator' },
]}
/>
<div className="flex justify-end gap-3 pt-4">
<Button variant="tertiary">Cancel</Button>
<Button>Create Account</Button>
</div>
</CardContent>
</Card>
</FormPage>
),
}

View File

@@ -0,0 +1,81 @@
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
import { cn } from '@/lib/utils'
export interface FormPageStep {
label: string
status: 'complete' | 'current' | 'upcoming'
}
export interface FormPageProps extends HTMLAttributes<HTMLDivElement> {
/** PageHeader or custom header section */
header?: ReactNode
/** Action bar above the form content (e.g. dropdowns, buttons) */
actions?: ReactNode
/** Vertical stepper steps — renders a progress indicator alongside the form */
steps?: FormPageStep[]
/** Form content area */
children: ReactNode
}
const StepIndicator = ({ step, index }: { step: FormPageStep; index: number }) => {
const base = 'flex size-8 shrink-0 items-center justify-center rounded-full text-small font-bold'
const styles = {
complete: 'bg-success text-white',
current: 'bg-info text-white',
upcoming: 'bg-grey-04 text-text-secondary',
}
return (
<div className="flex items-start gap-3">
<div className={cn(base, styles[step.status])}>
{step.status === 'complete' ? (
<svg className="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
index + 1
)}
</div>
<span className={cn(
'pt-1 text-small',
step.status === 'current' ? 'font-bold text-text' : 'text-text-secondary',
)}>
{step.label}
</span>
</div>
)
}
export const FormPage = forwardRef<HTMLDivElement, FormPageProps>(
({ header, actions, steps, className, children, ...props }, ref) => {
return (
<div ref={ref} className={cn('flex flex-col', className)} {...props}>
{header}
{actions && (
<div className="flex items-center justify-between gap-4 border-b border-border px-6 py-3">
{actions}
</div>
)}
<div className="flex-1 p-6">
<div className="mx-auto max-w-3xl">
{steps ? (
<div className="flex gap-8">
<nav className="flex w-48 shrink-0 flex-col gap-4" aria-label="Form steps">
{steps.map((step, i) => (
<StepIndicator key={step.label} step={step} index={i} />
))}
</nav>
<div className="min-w-0 flex-1">{children}</div>
</div>
) : (
children
)}
</div>
</div>
</div>
)
},
)
FormPage.displayName = 'FormPage'

View File

@@ -0,0 +1,2 @@
export { FormPage } from './FormPage'
export type { FormPageProps, FormPageStep } from './FormPage'

View File

@@ -0,0 +1,169 @@
import type { Meta, StoryObj } from '@storybook/react'
import { useState } from 'react'
import { ListPage } from './ListPage'
import { AppShell } from '@/components/templates/AppShell/AppShell'
import { TopBar } from '@/components/organisms/TopBar/TopBar'
import { SideNav, SideNavItem, SideNavDivider } from '@/components/organisms/SideNav/SideNav'
import { PageHeader } from '@/components/organisms/PageHeader/PageHeader'
import { Card, CardContent } from '@/components/molecules/Card/Card'
import { Badge } from '@/components/atoms/Badge/Badge'
import { Tag } from '@/components/atoms/Tag/Tag'
import { Button } from '@/components/atoms/Button/Button'
import { Avatar } from '@/components/atoms/Avatar/Avatar'
import { IconButton } from '@/components/atoms/IconButton/IconButton'
import { Menu, Bell, Home, LayoutGrid, FileText, Users, Clock, BarChart3, Plus, Check } from 'lucide-react'
const NswLogo = () => (
<div className="flex size-7 items-center justify-center rounded bg-white/20 text-caption font-bold text-white">NSW</div>
)
const ActivityItem = ({ title, hours, date }: { title: string; hours: string; date: string }) => (
<div className="flex flex-col gap-2 px-6 py-4">
<div className="flex items-start justify-between">
<a href="#" className="text-body font-semibold text-info hover:underline">{title}</a>
<div className="flex shrink-0 flex-col items-end gap-1 text-small text-text-secondary">
<span>{hours}</span>
<span>{date}</span>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Tag color="navy" variant="filled" size="sm">NSW DoE</Tag>
<Badge variant="success" leftIcon={<Check size={14} />}>Registered</Badge>
<Tag color="blue" variant="filled" size="sm">S1</Tag>
<Tag color="orange" variant="filled" size="sm">s4</Tag>
<Tag color="green" variant="filled" size="sm">S6</Tag>
</div>
<p className="text-small text-text-secondary">
Lorem dolor sit amet, consectetur adipiscing elit. Donec condimentum nulla gravida pretium libero. Proin in felis consectetur, laoreet est eu, consectetur mi.
</p>
</div>
)
const meta: Meta<typeof ListPage> = {
title: 'Templates/ListPage',
component: ListPage,
tags: ['autodocs', 'template'],
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: 'List page template with stat summary row, list header with actions, and a scrollable item list. Use inside AppShell for the full page layout.',
},
},
},
}
export default meta
type Story = StoryObj<typeof ListPage>
export const PDLog: Story = {
name: 'PD Log',
render: () => {
const [collapsed, setCollapsed] = useState(false)
return (
<AppShell
topBar={
<TopBar
title=""
leading={<IconButton icon={<Menu />} aria-label="Menu" variant="tertiary" onClick={() => setCollapsed(!collapsed)} />}
logo={<NswLogo />}
>
<IconButton icon={<Bell />} aria-label="Notifications" variant="tertiary" />
<Avatar initials="JW" size="sm" />
</TopBar>
}
sideNav={
<SideNav collapsed={collapsed}>
<SideNavItem icon={<Home />} active>My status</SideNavItem>
<SideNavItem icon={<LayoutGrid />}>Workspace</SideNavItem>
<SideNavItem icon={<FileText />}>My details</SideNavItem>
<SideNavDivider />
<SideNavItem icon={<Users />}>Accreditation</SideNavItem>
</SideNav>
}
sideNavCollapsed={collapsed}
>
<ListPage
header={
<PageHeader title="Jane Williamson's Workspace" subtitle="Accreditation Level: Maintaining Proficient Teacher" theme="dark">
<div className="mt-2 text-small text-white/80">
Maroubra Junction Public School
</div>
</PageHeader>
}
stats={
<>
<Card variant="surface" className="min-w-[180px] flex-1">
<CardContent className="flex items-center gap-4 p-5">
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
<Clock size={20} />
</div>
<div>
<p className="text-h3 font-bold text-text">21h</p>
<p className="text-small text-text-secondary">Total hours logged</p>
<p className="text-caption text-text-secondary">Target 100h</p>
</div>
</CardContent>
</Card>
<Card variant="surface" className="min-w-[180px] flex-1">
<CardContent className="flex items-center gap-4 p-5">
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
<Clock size={20} />
</div>
<div>
<p className="text-h3 font-bold text-text">18h</p>
<p className="text-small text-text-secondary">NESA Registered PD</p>
<p className="text-caption text-text-secondary">Target 60h</p>
</div>
</CardContent>
</Card>
<Card variant="surface" className="min-w-[180px] flex-1">
<CardContent className="flex items-center gap-4 p-5">
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
<BarChart3 size={20} />
</div>
<div>
<p className="text-h3 font-bold text-text">5</p>
<p className="text-small text-text-secondary">Activities logged</p>
</div>
</CardContent>
</Card>
</>
}
listHeader={
<div className="flex items-center justify-between">
<div>
<h2 className="text-h4 font-bold text-text">My PD Log</h2>
<p className="text-small text-text-secondary">Log every professional learning activity NESA Registered and school-based.</p>
</div>
<Button leftIcon={<Plus size={18} />}>Add Activity</Button>
</div>
}
>
<ActivityItem title="Trauma-informed practice" hours="8h" date="2024-02-20" />
<ActivityItem title="Trauma-informed practice" hours="8h" date="2024-02-20" />
<ActivityItem title="Trauma-informed practice" hours="8h" date="2024-02-20" />
<ActivityItem title="Trauma-informed practice" hours="8h" date="2024-02-20" />
</ListPage>
</AppShell>
)
},
}
export const Standalone: Story = {
name: 'Without AppShell',
render: () => (
<ListPage
header={<PageHeader title="Activity Log" subtitle="Your professional development activities" />}
listHeader={
<div className="flex items-center justify-between">
<h2 className="text-h4 font-bold text-text">Activities</h2>
<Button size="compact" leftIcon={<Plus size={18} />}>Add</Button>
</div>
}
>
<ActivityItem title="Sample activity" hours="4h" date="2024-03-15" />
<ActivityItem title="Another activity" hours="2h" date="2024-03-10" />
</ListPage>
),
}

View File

@@ -0,0 +1,36 @@
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
import { cn } from '@/lib/utils'
export interface ListPageProps extends HTMLAttributes<HTMLDivElement> {
/** PageHeader or custom header section */
header?: ReactNode
/** Row of stat cards or summary widgets */
stats?: ReactNode
/** Section header area with title and optional action (e.g. "Add Activity" button) */
listHeader?: ReactNode
/** Scrollable list content area */
children: ReactNode
}
export const ListPage = forwardRef<HTMLDivElement, ListPageProps>(
({ header, stats, listHeader, className, children, ...props }, ref) => {
return (
<div ref={ref} className={cn('flex flex-col', className)} {...props}>
{header}
<div className="flex flex-col gap-6 p-6">
{stats && <div className="flex flex-wrap gap-4">{stats}</div>}
<div className="flex flex-col gap-4">
{listHeader}
<div className="divide-y divide-border rounded-lg bg-surface">
{children}
</div>
</div>
</div>
</div>
)
},
)
ListPage.displayName = 'ListPage'

View File

@@ -0,0 +1,2 @@
export { ListPage } from './ListPage'
export type { ListPageProps } from './ListPage'

View File

@@ -12,3 +12,19 @@
color: var(--color-text); color: var(--color-text);
} }
} }
:root {
--text-h1-responsive: 2.25rem;
--text-h2-responsive: 1.75rem;
--text-h3-responsive: 1.375rem;
--text-h4-responsive: 1.125rem;
}
@media (min-width: 960px) {
:root {
--text-h1-responsive: 3rem;
--text-h2-responsive: 2rem;
--text-h3-responsive: 1.5rem;
--text-h4-responsive: 1.25rem;
}
}

View File

@@ -29,7 +29,7 @@
line-height: var(--text-body--line-height); line-height: var(--text-body--line-height);
font-weight: 700; font-weight: 700;
text-decoration-line: underline; text-decoration-line: underline;
color: var(--color-blue-02); color: var(--color-info);
} }
@utility text-small-link { @utility text-small-link {
@@ -37,5 +37,35 @@
line-height: var(--text-small--line-height); line-height: var(--text-small--line-height);
font-weight: 700; font-weight: 700;
text-decoration-line: underline; text-decoration-line: underline;
color: var(--color-blue-02); color: var(--color-info);
}
@utility text-button {
font-size: var(--text-body);
line-height: calc(19 / 16);
font-weight: 600;
}
@utility text-h1-responsive {
font-size: var(--text-h1-responsive);
line-height: 1.25;
font-weight: 700;
}
@utility text-h2-responsive {
font-size: var(--text-h2-responsive);
line-height: 1.25;
font-weight: 700;
}
@utility text-h3-responsive {
font-size: var(--text-h3-responsive);
line-height: 1.333;
font-weight: 700;
}
@utility text-h4-responsive {
font-size: var(--text-h4-responsive);
line-height: 1.4;
font-weight: 700;
} }

View File

@@ -27,47 +27,76 @@
/* Blues */ /* Blues */
--color-blue-01: #002664; --color-blue-01: #002664;
--color-blue-02: #146CFD; --color-blue-02: #146CFD;
--color-blue-03: #69B3E7; --color-blue-03: #8CE0FF;
--color-blue-04: #CBEDFD; --color-blue-04: #CBEDFD;
--color-blue-05: #EBF5FF; /* extrapolated: ultra-light background */
/* Reds */ /* Reds */
--color-red-01: #3E0014; --color-red-01: #630019;
--color-red-02: #D7153A; --color-red-02: #D7153A;
--color-red-03: #F5C5D0; --color-red-03: #FFB8C1;
--color-red-04: #FDDDE5; --color-red-04: #FFE6EA;
--color-red-05: #FFF5F8; /* extrapolated: ultra-light background */
/* Oranges */ /* Oranges */
--color-orange-01: #7A3300; /* extrapolated: dark */ --color-orange-01: #941B00;
--color-orange-02: #EC6608; --color-orange-02: #F3631B;
--color-orange-03: #F5B98A; --color-orange-03: #FFCE99;
--color-orange-04: #FEF0E4; /* extrapolated: light background */ --color-orange-04: #FDEDDF;
/* Greens */ /* Greens */
--color-green-01: #005C35; /* extrapolated: dark */ --color-green-01: #004000;
--color-green-02: #00A651; --color-green-02: #00AA45;
--color-green-03: #89E5B3; --color-green-03: #A8EDB3;
--color-green-04: #E0F8EA; /* extrapolated: light background */ --color-green-04: #DBFADF;
/* Teals */
--color-teal-01: #0B3F47;
--color-teal-02: #2E808E;
--color-teal-03: #8CDBE5;
--color-teal-04: #D1EEEA;
/* Browns */
--color-brown-01: #523719;
--color-brown-02: #B68D5D;
--color-brown-03: #E8D0B5;
--color-brown-04: #EDE3D7;
/* Purples */
--color-purple-01: #441170;
--color-purple-02: #8055F1;
--color-purple-03: #CEBFFF;
--color-purple-04: #E6E1FD;
/* Fuchsias */
--color-fuchsia-01: #65004D;
--color-fuchsia-02: #D912AE;
--color-fuchsia-03: #F4B5E6;
--color-fuchsia-04: #FDDEF2;
/* Yellows */
--color-yellow-01: #694800;
--color-yellow-02: #FAAF05;
--color-yellow-03: #FDE79A;
--color-yellow-04: #FFF4CF;
/* Greys */ /* Greys */
--color-grey-01: #22272B; --color-grey-01: #22272B;
--color-grey-02: #6D7278; --color-grey-02: #495054;
--color-grey-03: #C0C0C0; --color-grey-03: #CDD3D6;
--color-grey-04: #E0E0E0; --color-grey-04: #EBEBEB;
--color-off-white: #F4F4F4; --color-grey-05: #F2F2F2;
--color-white: #FFFFFF; --color-white: #FFFFFF;
/* Semantic Aliases */ /* Semantic Aliases */
--color-primary: var(--color-blue-02); --color-primary: var(--color-blue-01);
--color-primary-dark: var(--color-blue-01); --color-info: var(--color-blue-02);
--color-secondary: var(--color-blue-04);
--color-error: var(--color-red-02); --color-error: var(--color-red-02);
--color-success: var(--color-green-02); --color-success: var(--color-green-02);
--color-warning: var(--color-orange-02); --color-warning: var(--color-orange-02);
--color-text: var(--color-grey-01); --color-text: var(--color-grey-01);
--color-text-secondary: var(--color-grey-02); --color-text-secondary: var(--color-grey-02);
--color-border: var(--color-grey-04); --color-border: var(--color-grey-04);
--color-bg: var(--color-off-white); --color-bg: var(--color-grey-05);
--color-surface: var(--color-white); --color-surface: var(--color-white);
/* Form Controls */ /* Form Controls */
@@ -80,7 +109,7 @@
--color-control-description: var(--color-grey-02); --color-control-description: var(--color-grey-02);
--color-control-error: var(--color-red-02); --color-control-error: var(--color-red-02);
--color-control-bg: var(--color-white); --color-control-bg: var(--color-white);
--color-control-bg-readonly: var(--color-off-white); --color-control-bg-readonly: var(--color-grey-05);
/* Button */ /* Button */
--color-button-default: var(--color-blue-01); --color-button-default: var(--color-blue-01);
@@ -89,6 +118,10 @@
--color-button-subtle-bg: var(--color-blue-04); --color-button-subtle-bg: var(--color-blue-04);
--color-button-subtle-text: var(--color-blue-01); --color-button-subtle-text: var(--color-blue-01);
/* Switch */
--color-switch-on: var(--color-success);
--color-switch-on-hover: var(--color-green-01);
/* Badge */ /* Badge */
--color-badge-navy: var(--color-blue-01); --color-badge-navy: var(--color-blue-01);
--color-badge-info: var(--color-blue-02); --color-badge-info: var(--color-blue-02);
@@ -124,29 +157,53 @@
--color-tag-orange-light: var(--color-orange-04); --color-tag-orange-light: var(--color-orange-04);
--color-tag-grey: var(--color-grey-02); --color-tag-grey: var(--color-grey-02);
--color-tag-grey-light: var(--color-grey-04); --color-tag-grey-light: var(--color-grey-04);
--color-tag-teal: var(--color-teal-01);
--color-tag-teal-light: var(--color-teal-04);
--color-tag-brown: var(--color-brown-01);
--color-tag-brown-light: var(--color-brown-04);
--color-tag-purple: var(--color-purple-01);
--color-tag-purple-light: var(--color-purple-04);
--color-tag-fuchsia: var(--color-fuchsia-01);
--color-tag-fuchsia-light: var(--color-fuchsia-04);
--color-tag-yellow: var(--color-yellow-01);
--color-tag-yellow-light: var(--color-yellow-04);
/* Avatar */
--color-avatar: var(--color-blue-04);
--color-avatar-text: var(--color-grey-01);
/* TopBar */
--color-topbar: var(--color-blue-01);
/* SideNav */
--color-nav-bg: var(--color-white);
--color-nav-text: var(--color-grey-01);
--color-nav-icon: var(--color-blue-01);
--color-nav-active: var(--color-info);
--color-nav-divider: var(--color-grey-03);
/* Alert */ /* Alert */
--color-alert-info-bg: var(--color-blue-05); --color-alert-info-bg: var(--color-blue-04);
--color-alert-info-border: var(--color-blue-02); --color-alert-info-border: var(--color-blue-02);
--color-alert-info-icon: var(--color-blue-02); --color-alert-info-icon: var(--color-blue-02);
--color-alert-warning-bg: var(--color-orange-04); --color-alert-warning-bg: var(--color-orange-04);
--color-alert-warning-border: var(--color-orange-02); --color-alert-warning-border: var(--color-orange-02);
--color-alert-warning-icon: var(--color-orange-02); --color-alert-warning-icon: var(--color-orange-02);
--color-alert-error-bg: var(--color-red-05); --color-alert-error-bg: var(--color-red-04);
--color-alert-error-border: var(--color-red-02); --color-alert-error-border: var(--color-red-02);
--color-alert-error-icon: var(--color-red-02); --color-alert-error-icon: var(--color-red-02);
--color-alert-success-bg: var(--color-green-04); --color-alert-success-bg: var(--color-green-04);
--color-alert-success-border: var(--color-green-02); --color-alert-success-border: var(--color-green-02);
--color-alert-success-icon: var(--color-green-02); --color-alert-success-icon: var(--color-green-02);
--color-alert-neutral-bg: var(--color-off-white); --color-alert-neutral-bg: var(--color-grey-05);
--color-alert-neutral-border: var(--color-grey-03); --color-alert-neutral-border: var(--color-grey-03);
--color-alert-neutral-icon: var(--color-blue-01); --color-alert-neutral-icon: var(--color-blue-01);
/* Radius */ /* Radius */
--radius-sm: 4px; --radius-sm: 4px;
--radius-default: 6px; --radius-default: 8px;
--radius-lg: 10px; --radius-lg: 16px;
--radius-xl: 16px; --radius-xl: 24px;
--radius-full: 9999px; --radius-full: 9999px;
/* Shadows */ /* Shadows */