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>
This commit is contained in:
2026-06-03 14:24:23 +10:00
parent f4fd1fc04b
commit d915443b8c
45 changed files with 3029 additions and 54 deletions

162
ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,162 @@
# 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)* AppShell (header + sidebar + content area)
- *(planned)* DatePicker
### 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/** |
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
├── 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

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

@@ -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 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 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

@@ -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 */