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:
162
ARCHITECTURE.md
Normal file
162
ARCHITECTURE.md
Normal 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
|
||||||
91
src/components/atoms/Autocomplete/Autocomplete.stories.tsx
Normal file
91
src/components/atoms/Autocomplete/Autocomplete.stories.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Autocomplete } from './Autocomplete'
|
||||||
|
|
||||||
|
const states = [
|
||||||
|
{ value: 'nsw', label: 'New South Wales' },
|
||||||
|
{ value: 'vic', label: 'Victoria' },
|
||||||
|
{ value: 'qld', label: 'Queensland' },
|
||||||
|
{ value: 'wa', label: 'Western Australia' },
|
||||||
|
{ value: 'sa', label: 'South Australia' },
|
||||||
|
{ value: 'tas', label: 'Tasmania' },
|
||||||
|
{ value: 'act', label: 'Australian Capital Territory' },
|
||||||
|
{ value: 'nt', label: 'Northern Territory' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const meta: Meta<typeof Autocomplete> = {
|
||||||
|
title: 'Atoms/Autocomplete',
|
||||||
|
component: Autocomplete,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: { layout: 'padded' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof Autocomplete>
|
||||||
|
|
||||||
|
const BasicTemplate = () => {
|
||||||
|
const [value, setValue] = useState('')
|
||||||
|
return (
|
||||||
|
<div className="w-96">
|
||||||
|
<Autocomplete
|
||||||
|
label="State"
|
||||||
|
placeholder="Search states…"
|
||||||
|
options={states}
|
||||||
|
value={value}
|
||||||
|
onChange={setValue}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
render: () => <BasicTemplate />,
|
||||||
|
}
|
||||||
|
|
||||||
|
const FreeSoloTemplate = () => {
|
||||||
|
const [value, setValue] = useState('')
|
||||||
|
return (
|
||||||
|
<div className="w-96">
|
||||||
|
<Autocomplete
|
||||||
|
label="School name"
|
||||||
|
placeholder="Type to search or enter a new name…"
|
||||||
|
options={states}
|
||||||
|
value={value}
|
||||||
|
onChange={setValue}
|
||||||
|
freeSolo
|
||||||
|
hint="Select from the list or type a custom value"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FreeSolo: Story = {
|
||||||
|
name: 'Free solo (Combobox)',
|
||||||
|
render: () => <FreeSoloTemplate />,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithError: Story = {
|
||||||
|
name: 'With error',
|
||||||
|
render: () => (
|
||||||
|
<div className="w-96">
|
||||||
|
<Autocomplete
|
||||||
|
label="State"
|
||||||
|
options={states}
|
||||||
|
error="Please select a valid state"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Disabled: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="w-96">
|
||||||
|
<Autocomplete
|
||||||
|
label="State"
|
||||||
|
options={states}
|
||||||
|
value="nsw"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
216
src/components/atoms/Autocomplete/Autocomplete.tsx
Normal file
216
src/components/atoms/Autocomplete/Autocomplete.tsx
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import {
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useId,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type HTMLAttributes,
|
||||||
|
} from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface AutocompleteOption {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutocompleteProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
|
||||||
|
label: string
|
||||||
|
description?: string
|
||||||
|
hint?: string
|
||||||
|
error?: string
|
||||||
|
placeholder?: string
|
||||||
|
options: AutocompleteOption[]
|
||||||
|
value?: string
|
||||||
|
onChange?: (value: string) => void
|
||||||
|
freeSolo?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
loading?: boolean
|
||||||
|
noResultsText?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChevronIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="m6 9 6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const Autocomplete = forwardRef<HTMLDivElement, AutocompleteProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
hint,
|
||||||
|
error,
|
||||||
|
placeholder,
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
freeSolo = false,
|
||||||
|
disabled = false,
|
||||||
|
loading = false,
|
||||||
|
noResultsText = 'No results found',
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const id = useId()
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const listRef = useRef<HTMLUListElement>(null)
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [activeIndex, setActiveIndex] = useState(-1)
|
||||||
|
|
||||||
|
const selectedOption = options.find((o) => o.value === value)
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!query) return options
|
||||||
|
const q = query.toLowerCase()
|
||||||
|
return options.filter((o) => o.label.toLowerCase().includes(q))
|
||||||
|
}, [options, query])
|
||||||
|
|
||||||
|
const selectOption = useCallback(
|
||||||
|
(opt: AutocompleteOption) => {
|
||||||
|
onChange?.(opt.value)
|
||||||
|
setQuery(opt.label)
|
||||||
|
setOpen(false)
|
||||||
|
setActiveIndex(-1)
|
||||||
|
},
|
||||||
|
[onChange],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleInputChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setQuery(e.target.value)
|
||||||
|
setOpen(true)
|
||||||
|
setActiveIndex(-1)
|
||||||
|
if (freeSolo) {
|
||||||
|
onChange?.(e.target.value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[freeSolo, onChange],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (!open && (e.key === 'ArrowDown' || e.key === 'Enter')) {
|
||||||
|
setOpen(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!open) return
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault()
|
||||||
|
setActiveIndex((i) => (i + 1) % filtered.length)
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault()
|
||||||
|
setActiveIndex((i) => (i <= 0 ? filtered.length - 1 : i - 1))
|
||||||
|
} else if (e.key === 'Enter' && activeIndex >= 0) {
|
||||||
|
e.preventDefault()
|
||||||
|
const opt = filtered[activeIndex]
|
||||||
|
if (opt && !opt.disabled) selectOption(opt)
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[open, filtered, activeIndex, selectOption],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedOption && !open) {
|
||||||
|
setQuery(selectedOption.label)
|
||||||
|
}
|
||||||
|
}, [selectedOption, open])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const handleClick = (e: MouseEvent) => {
|
||||||
|
const el = (ref as React.RefObject<HTMLDivElement>)?.current
|
||||||
|
if (el && !el.contains(e.target as Node)) setOpen(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClick)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClick)
|
||||||
|
}, [open, ref])
|
||||||
|
|
||||||
|
const listboxId = `${id}-listbox`
|
||||||
|
const hasError = !!error
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn('relative flex flex-col gap-1.5', className)} {...props}>
|
||||||
|
<label htmlFor={id} className="text-small font-semibold text-control-label">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
{description && <p className="text-small text-control-description">{description}</p>}
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
id={id}
|
||||||
|
type="text"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={listboxId}
|
||||||
|
aria-activedescendant={activeIndex >= 0 ? `${id}-opt-${activeIndex}` : undefined}
|
||||||
|
aria-invalid={hasError || undefined}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={query}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className={cn(
|
||||||
|
'h-14 w-full rounded-default border bg-control-bg px-4 pr-10 text-body text-text outline-none transition-colors',
|
||||||
|
'focus:border-primary focus:ring-2 focus:ring-control-focus-ring',
|
||||||
|
hasError ? 'border-control-error' : 'border-control-border hover:border-primary',
|
||||||
|
disabled && 'pointer-events-none opacity-55',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="pointer-events-none absolute right-3 top-1/2 size-5 -translate-y-1/2 text-primary [&>svg]:size-full">
|
||||||
|
<ChevronIcon />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open && !disabled && (
|
||||||
|
<ul
|
||||||
|
ref={listRef}
|
||||||
|
id={listboxId}
|
||||||
|
role="listbox"
|
||||||
|
className="absolute left-0 right-0 top-full z-50 mt-1 max-h-60 overflow-auto rounded-default border border-border bg-surface py-1 shadow-md"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<li className="px-4 py-3 text-body text-text-secondary">Loading…</li>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<li className="px-4 py-3 text-body text-text-secondary">{noResultsText}</li>
|
||||||
|
) : (
|
||||||
|
filtered.map((opt, i) => (
|
||||||
|
<li
|
||||||
|
key={opt.value}
|
||||||
|
id={`${id}-opt-${i}`}
|
||||||
|
role="option"
|
||||||
|
aria-selected={opt.value === value}
|
||||||
|
aria-disabled={opt.disabled || undefined}
|
||||||
|
onClick={() => !opt.disabled && selectOption(opt)}
|
||||||
|
className={cn(
|
||||||
|
'cursor-pointer px-4 py-3 text-body transition-colors',
|
||||||
|
opt.value === value && 'bg-info/12 font-bold',
|
||||||
|
i === activeIndex && opt.value !== value && 'bg-info/5',
|
||||||
|
opt.disabled && 'pointer-events-none opacity-55',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hint && !error && <p className="text-small text-control-description">{hint}</p>}
|
||||||
|
{error && <p className="text-small text-control-error">{error}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Autocomplete.displayName = 'Autocomplete'
|
||||||
2
src/components/atoms/Autocomplete/index.ts
Normal file
2
src/components/atoms/Autocomplete/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { Autocomplete } from './Autocomplete'
|
||||||
|
export type { AutocompleteProps, AutocompleteOption } from './Autocomplete'
|
||||||
49
src/components/atoms/Avatar/Avatar.stories.tsx
Normal file
49
src/components/atoms/Avatar/Avatar.stories.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { Avatar } from './Avatar'
|
||||||
|
|
||||||
|
const meta: Meta<typeof Avatar> = {
|
||||||
|
title: 'Atoms/Avatar',
|
||||||
|
component: Avatar,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: { layout: 'centered' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof Avatar>
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: { initials: 'DW' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Small: Story = {
|
||||||
|
args: { initials: 'SR', size: 'sm' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Large: Story = {
|
||||||
|
args: { initials: 'JB', size: 'lg' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SingleInitial: Story = {
|
||||||
|
name: 'Single initial',
|
||||||
|
args: { initials: 'R', size: 'default' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AllSizes: Story = {
|
||||||
|
name: 'All sizes',
|
||||||
|
render: () => (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Avatar initials="SM" size="sm" />
|
||||||
|
<Avatar initials="MD" size="default" />
|
||||||
|
<Avatar initials="LG" size="lg" />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OnDarkBackground: Story = {
|
||||||
|
name: 'On dark background',
|
||||||
|
render: () => (
|
||||||
|
<div className="flex items-center gap-4 rounded-lg bg-primary-dark p-4">
|
||||||
|
<Avatar initials="DW" size="lg" />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
46
src/components/atoms/Avatar/Avatar.tsx
Normal file
46
src/components/atoms/Avatar/Avatar.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { forwardRef, type HTMLAttributes } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface AvatarProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
initials: string
|
||||||
|
src?: string
|
||||||
|
alt?: string
|
||||||
|
size?: 'sm' | 'default' | 'lg'
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeStyles = {
|
||||||
|
sm: 'size-8 text-caption',
|
||||||
|
default: 'size-10 text-body',
|
||||||
|
lg: 'size-12 text-[18px]',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Avatar = forwardRef<HTMLDivElement, AvatarProps>(
|
||||||
|
({ initials, src, alt, size = 'default', className, ...props }, ref) => {
|
||||||
|
const label = alt || initials
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="img"
|
||||||
|
aria-label={label}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex shrink-0 items-center justify-center rounded-full bg-avatar text-avatar-text',
|
||||||
|
sizeStyles[size],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{src ? (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={label}
|
||||||
|
className="size-full rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
initials.slice(0, 2).toUpperCase()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Avatar.displayName = 'Avatar'
|
||||||
2
src/components/atoms/Avatar/index.ts
Normal file
2
src/components/atoms/Avatar/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { Avatar } from './Avatar'
|
||||||
|
export type { AvatarProps } from './Avatar'
|
||||||
@@ -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({
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export { Chip } from './Chip'
|
export { Chip } from './Chip'
|
||||||
export type { ChipProps } from './Chip'
|
export type { ChipProps, ChipColor } from './Chip'
|
||||||
|
|||||||
42
src/components/atoms/FileInput/FileInput.stories.tsx
Normal file
42
src/components/atoms/FileInput/FileInput.stories.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { FileInput } from './FileInput'
|
||||||
|
|
||||||
|
const meta: Meta<typeof FileInput> = {
|
||||||
|
title: 'Atoms/FileInput',
|
||||||
|
component: FileInput,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: { layout: 'padded' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof FileInput>
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Upload document',
|
||||||
|
description: 'Supported formats: PDF, DOCX, PNG',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Multiple: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Upload files',
|
||||||
|
multiple: true,
|
||||||
|
accept: '.pdf,.docx,.png,.jpg',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithError: Story = {
|
||||||
|
name: 'With error',
|
||||||
|
args: {
|
||||||
|
label: 'Upload evidence',
|
||||||
|
error: 'File size must be under 10MB',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Disabled: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Upload document',
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
120
src/components/atoms/FileInput/FileInput.tsx
Normal file
120
src/components/atoms/FileInput/FileInput.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { forwardRef, useCallback, useRef, useState, type HTMLAttributes } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface FileInputProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
|
||||||
|
label: string
|
||||||
|
description?: string
|
||||||
|
error?: string
|
||||||
|
accept?: string
|
||||||
|
multiple?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
onChange?: (files: File[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const PaperclipIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5a2.5 2.5 0 015 0v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6h-1.5v9.5a2.5 2.5 0 005 0V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6H16.5z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const FileInput = forwardRef<HTMLDivElement, FileInputProps>(
|
||||||
|
({ label, description, error, accept, multiple = false, disabled = false, onChange, className, ...props }, ref) => {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [files, setFiles] = useState<File[]>([])
|
||||||
|
|
||||||
|
const handleFiles = useCallback(
|
||||||
|
(fileList: FileList | null) => {
|
||||||
|
if (!fileList) return
|
||||||
|
const next = Array.from(fileList)
|
||||||
|
setFiles(next)
|
||||||
|
onChange?.(next)
|
||||||
|
},
|
||||||
|
[onChange],
|
||||||
|
)
|
||||||
|
|
||||||
|
const removeFile = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
setFiles((prev) => {
|
||||||
|
const next = prev.filter((_, i) => i !== index)
|
||||||
|
onChange?.(next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
if (inputRef.current) inputRef.current.value = ''
|
||||||
|
},
|
||||||
|
[onChange],
|
||||||
|
)
|
||||||
|
|
||||||
|
const displayText = files.length > 0
|
||||||
|
? files.map((f) => f.name).join(', ')
|
||||||
|
: `Select file${multiple ? 's' : ''}…`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn('flex flex-col gap-1.5', className)} {...props}>
|
||||||
|
<label className="text-small font-semibold text-control-label">{label}</label>
|
||||||
|
{description && <p className="text-small text-control-description">{description}</p>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
className={cn(
|
||||||
|
'flex h-14 items-center gap-3 rounded-default border px-4 text-left transition-colors',
|
||||||
|
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-info',
|
||||||
|
error
|
||||||
|
? 'border-control-error'
|
||||||
|
: 'border-control-border hover:border-primary',
|
||||||
|
disabled && 'pointer-events-none opacity-55',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn('size-6 shrink-0 [&>svg]:size-full', error ? 'text-control-error' : 'text-primary')}>
|
||||||
|
<PaperclipIcon />
|
||||||
|
</span>
|
||||||
|
<span className={cn(
|
||||||
|
'flex-1 truncate text-body',
|
||||||
|
files.length > 0 ? 'text-text' : 'text-text-secondary',
|
||||||
|
)}>
|
||||||
|
{displayText}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
multiple={multiple}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => handleFiles(e.target.files)}
|
||||||
|
className="sr-only"
|
||||||
|
tabIndex={-1}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{files.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{files.map((file, i) => (
|
||||||
|
<span
|
||||||
|
key={`${file.name}-${i}`}
|
||||||
|
className="inline-flex items-center gap-1 rounded-default border border-control-border px-2.5 py-1 text-small text-text"
|
||||||
|
>
|
||||||
|
<span className="max-w-48 truncate">{file.name}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeFile(i)}
|
||||||
|
className="shrink-0 rounded-full p-0.5 hover:bg-text/[0.04]"
|
||||||
|
aria-label={`Remove ${file.name}`}
|
||||||
|
>
|
||||||
|
<svg className="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p className="text-small text-control-error">{error}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
FileInput.displayName = 'FileInput'
|
||||||
2
src/components/atoms/FileInput/index.ts
Normal file
2
src/components/atoms/FileInput/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { FileInput } from './FileInput'
|
||||||
|
export type { FileInputProps } from './FileInput'
|
||||||
@@ -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>
|
||||||
|
|||||||
57
src/components/atoms/List/List.stories.tsx
Normal file
57
src/components/atoms/List/List.stories.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { List, ListItem, ListSubheader, ListDivider } from './List'
|
||||||
|
|
||||||
|
const HomeIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" /></svg>
|
||||||
|
)
|
||||||
|
const StarIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z" /></svg>
|
||||||
|
)
|
||||||
|
const SettingsIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.488.488 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6A3.6 3.6 0 1115.6 12 3.6 3.6 0 0112 15.6z" /></svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const meta: Meta<typeof List> = {
|
||||||
|
title: 'Atoms/List',
|
||||||
|
component: List,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: { layout: 'padded' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof List>
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
render: () => (
|
||||||
|
<List className="w-80 rounded-default shadow-default">
|
||||||
|
<ListItem icon={<HomeIcon />}>Real-Time</ListItem>
|
||||||
|
<ListItem icon={<StarIcon />}>Audience</ListItem>
|
||||||
|
<ListItem icon={<SettingsIcon />}>Conversions</ListItem>
|
||||||
|
</List>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithActive: Story = {
|
||||||
|
name: 'With active item',
|
||||||
|
render: () => (
|
||||||
|
<List className="w-80 rounded-default shadow-default">
|
||||||
|
<ListItem icon={<HomeIcon />} active>Real-Time</ListItem>
|
||||||
|
<ListItem icon={<StarIcon />}>Audience</ListItem>
|
||||||
|
<ListItem icon={<SettingsIcon />}>Conversions</ListItem>
|
||||||
|
</List>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithSubheaders: Story = {
|
||||||
|
name: 'With subheaders',
|
||||||
|
render: () => (
|
||||||
|
<List className="w-80 rounded-default shadow-default">
|
||||||
|
<ListSubheader>Reports</ListSubheader>
|
||||||
|
<ListItem icon={<HomeIcon />}>Real-Time</ListItem>
|
||||||
|
<ListItem icon={<StarIcon />}>Audience</ListItem>
|
||||||
|
<ListDivider />
|
||||||
|
<ListSubheader>Settings</ListSubheader>
|
||||||
|
<ListItem icon={<SettingsIcon />}>Preferences</ListItem>
|
||||||
|
</List>
|
||||||
|
),
|
||||||
|
}
|
||||||
96
src/components/atoms/List/List.tsx
Normal file
96
src/components/atoms/List/List.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
// --- List ---
|
||||||
|
|
||||||
|
export interface ListProps extends HTMLAttributes<HTMLUListElement> {}
|
||||||
|
|
||||||
|
export const List = forwardRef<HTMLUListElement, ListProps>(
|
||||||
|
({ className, children, ...props }, ref) => (
|
||||||
|
<ul
|
||||||
|
ref={ref}
|
||||||
|
role="list"
|
||||||
|
className={cn('flex flex-col bg-surface', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ul>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
List.displayName = 'List'
|
||||||
|
|
||||||
|
// --- ListItem ---
|
||||||
|
|
||||||
|
export interface ListItemProps extends HTMLAttributes<HTMLLIElement> {
|
||||||
|
icon?: ReactNode
|
||||||
|
active?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
href?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ListItem = forwardRef<HTMLLIElement, ListItemProps>(
|
||||||
|
({ icon, active = false, disabled = false, href, className, children, ...props }, ref) => {
|
||||||
|
const styles = cn(
|
||||||
|
'flex min-h-12 items-center gap-4 px-4 py-2 transition-colors',
|
||||||
|
active
|
||||||
|
? 'bg-info/12 text-info'
|
||||||
|
: 'text-text hover:bg-text/[0.04]',
|
||||||
|
disabled && 'pointer-events-none opacity-55',
|
||||||
|
className,
|
||||||
|
)
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
{icon && (
|
||||||
|
<span className={cn('size-6 shrink-0 [&>svg]:size-full', active ? 'text-info' : 'text-text-secondary')}>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="flex-1 text-body">{children}</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (href) {
|
||||||
|
return (
|
||||||
|
<li ref={ref} {...props}>
|
||||||
|
<a href={href} className={styles}>{content}</a>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li ref={ref} role="listitem" className={styles} {...props}>
|
||||||
|
{content}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
ListItem.displayName = 'ListItem'
|
||||||
|
|
||||||
|
// --- ListSubheader ---
|
||||||
|
|
||||||
|
export interface ListSubheaderProps extends HTMLAttributes<HTMLLIElement> {}
|
||||||
|
|
||||||
|
export const ListSubheader = forwardRef<HTMLLIElement, ListSubheaderProps>(
|
||||||
|
({ className, children, ...props }, ref) => (
|
||||||
|
<li
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex min-h-10 items-center px-4 text-small font-semibold text-text-secondary', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</li>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
ListSubheader.displayName = 'ListSubheader'
|
||||||
|
|
||||||
|
// --- ListDivider ---
|
||||||
|
|
||||||
|
export interface ListDividerProps extends HTMLAttributes<HTMLLIElement> {}
|
||||||
|
|
||||||
|
export const ListDivider = forwardRef<HTMLLIElement, ListDividerProps>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<li ref={ref} role="separator" className={cn('border-t border-border', className)} {...props} />
|
||||||
|
),
|
||||||
|
)
|
||||||
|
ListDivider.displayName = 'ListDivider'
|
||||||
2
src/components/atoms/List/index.ts
Normal file
2
src/components/atoms/List/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { List, ListItem, ListSubheader, ListDivider } from './List'
|
||||||
|
export type { ListProps, ListItemProps, ListSubheaderProps, ListDividerProps } from './List'
|
||||||
@@ -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',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
63
src/components/atoms/Slider/Slider.stories.tsx
Normal file
63
src/components/atoms/Slider/Slider.stories.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Slider, RangeSlider } from './Slider'
|
||||||
|
|
||||||
|
const meta: Meta<typeof Slider> = {
|
||||||
|
title: 'Atoms/Slider',
|
||||||
|
component: Slider,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: { layout: 'padded' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof Slider>
|
||||||
|
|
||||||
|
const SliderTemplate = () => {
|
||||||
|
const [value, setValue] = useState(40)
|
||||||
|
return (
|
||||||
|
<div className="w-80">
|
||||||
|
<Slider label="Volume" value={value} onChange={setValue} />
|
||||||
|
<p className="mt-2 text-small text-text-secondary">Value: {value}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
render: () => <SliderTemplate />,
|
||||||
|
}
|
||||||
|
|
||||||
|
const SteppedTemplate = () => {
|
||||||
|
const [value, setValue] = useState(50)
|
||||||
|
return (
|
||||||
|
<div className="w-80">
|
||||||
|
<Slider label="Brightness" value={value} onChange={setValue} min={0} max={100} step={10} />
|
||||||
|
<p className="mt-2 text-small text-text-secondary">Value: {value}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Stepped: Story = {
|
||||||
|
render: () => <SteppedTemplate />,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Disabled: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="w-80">
|
||||||
|
<Slider label="Disabled" value={30} onChange={() => {}} disabled />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
const RangeTemplate = () => {
|
||||||
|
const [value, setValue] = useState<[number, number]>([20, 80])
|
||||||
|
return (
|
||||||
|
<div className="w-80">
|
||||||
|
<RangeSlider label="Price range" value={value} onChange={setValue} />
|
||||||
|
<p className="mt-2 text-small text-text-secondary">Range: {value[0]} – {value[1]}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Range: Story = {
|
||||||
|
render: () => <RangeTemplate />,
|
||||||
|
}
|
||||||
201
src/components/atoms/Slider/Slider.tsx
Normal file
201
src/components/atoms/Slider/Slider.tsx
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import { forwardRef, useCallback, useRef, type HTMLAttributes } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface SliderProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
|
||||||
|
label?: string
|
||||||
|
value: number
|
||||||
|
onChange: (value: number) => void
|
||||||
|
min?: number
|
||||||
|
max?: number
|
||||||
|
step?: number
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Slider = forwardRef<HTMLDivElement, SliderProps>(
|
||||||
|
({ label, value, onChange, min = 0, max = 100, step = 1, disabled = false, className, ...props }, ref) => {
|
||||||
|
const trackRef = useRef<HTMLDivElement>(null)
|
||||||
|
const percent = ((value - min) / (max - min)) * 100
|
||||||
|
|
||||||
|
const handlePointer = useCallback(
|
||||||
|
(clientX: number) => {
|
||||||
|
const track = trackRef.current
|
||||||
|
if (!track || disabled) return
|
||||||
|
const rect = track.getBoundingClientRect()
|
||||||
|
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
|
||||||
|
const raw = min + ratio * (max - min)
|
||||||
|
const stepped = Math.round(raw / step) * step
|
||||||
|
onChange(Math.max(min, Math.min(max, stepped)))
|
||||||
|
},
|
||||||
|
[min, max, step, disabled, onChange],
|
||||||
|
)
|
||||||
|
|
||||||
|
const onPointerDown = (e: React.PointerEvent) => {
|
||||||
|
if (disabled) return
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId)
|
||||||
|
handlePointer(e.clientX)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPointerMove = (e: React.PointerEvent) => {
|
||||||
|
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
||||||
|
handlePointer(e.clientX)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn('flex flex-col gap-2', disabled && 'opacity-55', className)} {...props}>
|
||||||
|
{label && (
|
||||||
|
<label className="text-small font-semibold text-control-label">{label}</label>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
ref={trackRef}
|
||||||
|
role="slider"
|
||||||
|
aria-label={label}
|
||||||
|
aria-valuemin={min}
|
||||||
|
aria-valuemax={max}
|
||||||
|
aria-valuenow={value}
|
||||||
|
aria-disabled={disabled || undefined}
|
||||||
|
tabIndex={disabled ? -1 : 0}
|
||||||
|
className="relative flex h-10 cursor-pointer touch-none items-center"
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (disabled) return
|
||||||
|
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault()
|
||||||
|
onChange(Math.min(max, value + step))
|
||||||
|
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault()
|
||||||
|
onChange(Math.max(min, value - step))
|
||||||
|
} else if (e.key === 'Home') {
|
||||||
|
e.preventDefault()
|
||||||
|
onChange(min)
|
||||||
|
} else if (e.key === 'End') {
|
||||||
|
e.preventDefault()
|
||||||
|
onChange(max)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="h-1 w-full rounded-full bg-grey-03">
|
||||||
|
<div className="h-full rounded-full bg-primary" style={{ width: `${percent}%` }} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="absolute -ml-[9px] size-[18px] rounded-full bg-primary shadow-md transition-shadow focus-visible:ring-2 focus-visible:ring-control-focus-ring"
|
||||||
|
style={{ left: `${percent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Slider.displayName = 'Slider'
|
||||||
|
|
||||||
|
// --- RangeSlider ---
|
||||||
|
|
||||||
|
export interface RangeSliderProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
|
||||||
|
label?: string
|
||||||
|
value: [number, number]
|
||||||
|
onChange: (value: [number, number]) => void
|
||||||
|
min?: number
|
||||||
|
max?: number
|
||||||
|
step?: number
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RangeSlider = forwardRef<HTMLDivElement, RangeSliderProps>(
|
||||||
|
({ label, value, onChange, min = 0, max = 100, step = 1, disabled = false, className, ...props }, ref) => {
|
||||||
|
const trackRef = useRef<HTMLDivElement>(null)
|
||||||
|
const activeThumb = useRef<0 | 1>(0)
|
||||||
|
|
||||||
|
const toPercent = (v: number) => ((v - min) / (max - min)) * 100
|
||||||
|
const lowPct = toPercent(value[0])
|
||||||
|
const highPct = toPercent(value[1])
|
||||||
|
|
||||||
|
const snap = useCallback(
|
||||||
|
(clientX: number) => {
|
||||||
|
const track = trackRef.current
|
||||||
|
if (!track || disabled) return
|
||||||
|
const rect = track.getBoundingClientRect()
|
||||||
|
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
|
||||||
|
const raw = min + ratio * (max - min)
|
||||||
|
const stepped = Math.round(raw / step) * step
|
||||||
|
const clamped = Math.max(min, Math.min(max, stepped))
|
||||||
|
|
||||||
|
const next: [number, number] = [...value]
|
||||||
|
if (activeThumb.current === 0) {
|
||||||
|
next[0] = Math.min(clamped, value[1])
|
||||||
|
} else {
|
||||||
|
next[1] = Math.max(clamped, value[0])
|
||||||
|
}
|
||||||
|
onChange(next)
|
||||||
|
},
|
||||||
|
[min, max, step, value, disabled, onChange],
|
||||||
|
)
|
||||||
|
|
||||||
|
const pickThumb = (clientX: number) => {
|
||||||
|
const track = trackRef.current
|
||||||
|
if (!track) return
|
||||||
|
const rect = track.getBoundingClientRect()
|
||||||
|
const ratio = (clientX - rect.left) / rect.width
|
||||||
|
const pos = min + ratio * (max - min)
|
||||||
|
activeThumb.current = Math.abs(pos - value[0]) <= Math.abs(pos - value[1]) ? 0 : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn('flex flex-col gap-2', disabled && 'opacity-55', className)} {...props}>
|
||||||
|
{label && (
|
||||||
|
<label className="text-small font-semibold text-control-label">{label}</label>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
ref={trackRef}
|
||||||
|
aria-label={label}
|
||||||
|
className="relative flex h-10 cursor-pointer touch-none items-center"
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
if (disabled) return
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId)
|
||||||
|
pickThumb(e.clientX)
|
||||||
|
snap(e.clientX)
|
||||||
|
}}
|
||||||
|
onPointerMove={(e) => {
|
||||||
|
if (e.currentTarget.hasPointerCapture(e.pointerId)) snap(e.clientX)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="h-1 w-full rounded-full bg-grey-03">
|
||||||
|
<div
|
||||||
|
className="absolute h-1 rounded-full bg-primary"
|
||||||
|
style={{ left: `${lowPct}%`, width: `${highPct - lowPct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{[lowPct, highPct].map((pct, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
role="slider"
|
||||||
|
tabIndex={disabled ? -1 : 0}
|
||||||
|
aria-valuemin={min}
|
||||||
|
aria-valuemax={max}
|
||||||
|
aria-valuenow={value[i]}
|
||||||
|
aria-label={`${label || 'Range'} ${i === 0 ? 'minimum' : 'maximum'}`}
|
||||||
|
className="absolute -ml-[9px] size-[18px] rounded-full bg-primary shadow-md focus-visible:ring-2 focus-visible:ring-control-focus-ring"
|
||||||
|
style={{ left: `${pct}%` }}
|
||||||
|
onFocus={() => { activeThumb.current = i as 0 | 1 }}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (disabled) return
|
||||||
|
const delta = e.key === 'ArrowRight' || e.key === 'ArrowUp' ? step
|
||||||
|
: e.key === 'ArrowLeft' || e.key === 'ArrowDown' ? -step
|
||||||
|
: e.key === 'Home' ? min - value[i]
|
||||||
|
: e.key === 'End' ? max - value[i]
|
||||||
|
: 0
|
||||||
|
if (!delta) return
|
||||||
|
e.preventDefault()
|
||||||
|
const next: [number, number] = [...value]
|
||||||
|
next[i] = Math.max(min, Math.min(max, value[i] + delta))
|
||||||
|
if (next[0] > next[1]) return
|
||||||
|
onChange(next)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
RangeSlider.displayName = 'RangeSlider'
|
||||||
2
src/components/atoms/Slider/index.ts
Normal file
2
src/components/atoms/Slider/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { Slider, RangeSlider } from './Slider'
|
||||||
|
export type { SliderProps, RangeSliderProps } from './Slider'
|
||||||
@@ -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>
|
||||||
|
|||||||
82
src/components/atoms/Tabs/Tabs.stories.tsx
Normal file
82
src/components/atoms/Tabs/Tabs.stories.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Tabs, TabList, Tab, TabPanel } from './Tabs'
|
||||||
|
|
||||||
|
const meta: Meta<typeof Tabs> = {
|
||||||
|
title: 'Atoms/Tabs',
|
||||||
|
component: Tabs,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: { layout: 'padded' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof Tabs>
|
||||||
|
|
||||||
|
const BasicTemplate = () => {
|
||||||
|
const [value, setValue] = useState('tab1')
|
||||||
|
return (
|
||||||
|
<Tabs value={value} onChange={setValue}>
|
||||||
|
<TabList>
|
||||||
|
<Tab value="tab1">Overview</Tab>
|
||||||
|
<Tab value="tab2">Details</Tab>
|
||||||
|
<Tab value="tab3">History</Tab>
|
||||||
|
</TabList>
|
||||||
|
<TabPanel value="tab1">Overview content goes here.</TabPanel>
|
||||||
|
<TabPanel value="tab2">Details content goes here.</TabPanel>
|
||||||
|
<TabPanel value="tab3">History content goes here.</TabPanel>
|
||||||
|
</Tabs>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
render: () => <BasicTemplate />,
|
||||||
|
}
|
||||||
|
|
||||||
|
const WithIconsTemplate = () => {
|
||||||
|
const [value, setValue] = useState('status')
|
||||||
|
|
||||||
|
const StatusIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" /></svg>
|
||||||
|
)
|
||||||
|
const DetailsIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z" /></svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs value={value} onChange={setValue}>
|
||||||
|
<TabList>
|
||||||
|
<Tab value="status" icon={<StatusIcon />}>Status</Tab>
|
||||||
|
<Tab value="details" icon={<DetailsIcon />}>Details</Tab>
|
||||||
|
<Tab value="disabled" disabled>Disabled</Tab>
|
||||||
|
</TabList>
|
||||||
|
<TabPanel value="status">Status panel content.</TabPanel>
|
||||||
|
<TabPanel value="details">Details panel content.</TabPanel>
|
||||||
|
</Tabs>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithIcons: Story = {
|
||||||
|
name: 'With icons',
|
||||||
|
render: () => <WithIconsTemplate />,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ManyTabsTemplate = () => {
|
||||||
|
const [value, setValue] = useState('tab1')
|
||||||
|
return (
|
||||||
|
<Tabs value={value} onChange={setValue}>
|
||||||
|
<TabList>
|
||||||
|
{Array.from({ length: 8 }, (_, i) => (
|
||||||
|
<Tab key={i} value={`tab${i + 1}`}>Tab {i + 1}</Tab>
|
||||||
|
))}
|
||||||
|
</TabList>
|
||||||
|
{Array.from({ length: 8 }, (_, i) => (
|
||||||
|
<TabPanel key={i} value={`tab${i + 1}`}>Content for tab {i + 1}</TabPanel>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ManyTabs: Story = {
|
||||||
|
name: 'Many tabs',
|
||||||
|
render: () => <ManyTabsTemplate />,
|
||||||
|
}
|
||||||
141
src/components/atoms/Tabs/Tabs.tsx
Normal file
141
src/components/atoms/Tabs/Tabs.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
forwardRef,
|
||||||
|
useContext,
|
||||||
|
useId,
|
||||||
|
useMemo,
|
||||||
|
type HTMLAttributes,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
// --- Context ---
|
||||||
|
|
||||||
|
interface TabsContextValue {
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
baseId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const TabsContext = createContext<TabsContextValue | null>(null)
|
||||||
|
|
||||||
|
function useTabsContext() {
|
||||||
|
const ctx = useContext(TabsContext)
|
||||||
|
if (!ctx) throw new Error('Tab components must be used within Tabs')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tabs ---
|
||||||
|
|
||||||
|
export interface TabsProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Tabs = forwardRef<HTMLDivElement, TabsProps>(
|
||||||
|
({ value, onChange, className, children, ...props }, ref) => {
|
||||||
|
const baseId = useId()
|
||||||
|
const ctx = useMemo(() => ({ value, onChange, baseId }), [value, onChange, baseId])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabsContext.Provider value={ctx}>
|
||||||
|
<div ref={ref} className={className} {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</TabsContext.Provider>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Tabs.displayName = 'Tabs'
|
||||||
|
|
||||||
|
// --- TabList ---
|
||||||
|
|
||||||
|
export interface TabListProps extends HTMLAttributes<HTMLDivElement> {}
|
||||||
|
|
||||||
|
export const TabList = forwardRef<HTMLDivElement, TabListProps>(
|
||||||
|
({ className, children, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="tablist"
|
||||||
|
className={cn('flex border-b border-border', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
TabList.displayName = 'TabList'
|
||||||
|
|
||||||
|
// --- Tab ---
|
||||||
|
|
||||||
|
export interface TabProps extends HTMLAttributes<HTMLButtonElement> {
|
||||||
|
value: string
|
||||||
|
icon?: ReactNode
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Tab = forwardRef<HTMLButtonElement, TabProps>(
|
||||||
|
({ value, icon, disabled = false, className, children, ...props }, ref) => {
|
||||||
|
const { value: selected, onChange, baseId } = useTabsContext()
|
||||||
|
const isSelected = value === selected
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
role="tab"
|
||||||
|
type="button"
|
||||||
|
id={`${baseId}-tab-${value}`}
|
||||||
|
aria-selected={isSelected}
|
||||||
|
aria-controls={`${baseId}-panel-${value}`}
|
||||||
|
tabIndex={isSelected ? 0 : -1}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onChange(value)}
|
||||||
|
className={cn(
|
||||||
|
'relative flex items-center gap-2 px-4 py-3 text-body font-semibold transition-colors',
|
||||||
|
'focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-info',
|
||||||
|
isSelected
|
||||||
|
? 'text-primary'
|
||||||
|
: 'text-text/80 hover:text-text',
|
||||||
|
isSelected && 'after:absolute after:bottom-0 after:left-0 after:right-0 after:h-1 after:bg-error',
|
||||||
|
disabled && 'pointer-events-none opacity-55',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{icon && <span className="size-5 shrink-0 [&>svg]:size-full">{icon}</span>}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Tab.displayName = 'Tab'
|
||||||
|
|
||||||
|
// --- TabPanel ---
|
||||||
|
|
||||||
|
export interface TabPanelProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TabPanel = forwardRef<HTMLDivElement, TabPanelProps>(
|
||||||
|
({ value, className, children, ...props }, ref) => {
|
||||||
|
const { value: selected, baseId } = useTabsContext()
|
||||||
|
const isSelected = value === selected
|
||||||
|
|
||||||
|
if (!isSelected) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="tabpanel"
|
||||||
|
id={`${baseId}-panel-${value}`}
|
||||||
|
aria-labelledby={`${baseId}-tab-${value}`}
|
||||||
|
tabIndex={0}
|
||||||
|
className={cn('pt-4', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
TabPanel.displayName = 'TabPanel'
|
||||||
2
src/components/atoms/Tabs/index.ts
Normal file
2
src/components/atoms/Tabs/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { Tabs, TabList, Tab, TabPanel } from './Tabs'
|
||||||
|
export type { TabsProps, TabListProps, TabProps, TabPanelProps } from './Tabs'
|
||||||
@@ -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> = {
|
||||||
|
|||||||
@@ -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,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>(
|
||||||
|
|||||||
79
src/components/molecules/DataTable/DataTable.stories.tsx
Normal file
79
src/components/molecules/DataTable/DataTable.stories.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { DataTable, type DataTableColumn } from './DataTable'
|
||||||
|
|
||||||
|
type Dessert = {
|
||||||
|
name: string
|
||||||
|
calories: number
|
||||||
|
fat: number
|
||||||
|
carbs: number
|
||||||
|
protein: number
|
||||||
|
iron: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const desserts: Dessert[] = [
|
||||||
|
{ name: 'Frozen Yogurt', calories: 159, fat: 6, carbs: 24, protein: 4, iron: 1 },
|
||||||
|
{ name: 'Ice cream sandwich', calories: 237, fat: 9, carbs: 37, protein: 4.3, iron: 1 },
|
||||||
|
{ name: 'Eclair', calories: 262, fat: 16, carbs: 23, protein: 6, iron: 7 },
|
||||||
|
{ name: 'Cupcake', calories: 305, fat: 3.7, carbs: 67, protein: 4.3, iron: 8 },
|
||||||
|
{ name: 'Gingerbread', calories: 356, fat: 16, carbs: 49, protein: 3.9, iron: 16 },
|
||||||
|
{ name: 'Jelly bean', calories: 375, fat: 0, carbs: 94, protein: 0, iron: 0 },
|
||||||
|
{ name: 'Lollipop', calories: 392, fat: 0.2, carbs: 98, protein: 0, iron: 2 },
|
||||||
|
{ name: 'Honeycomb', calories: 408, fat: 3.2, carbs: 87, protein: 6.5, iron: 45 },
|
||||||
|
{ name: 'Donut', calories: 452, fat: 25, carbs: 51, protein: 4.9, iron: 22 },
|
||||||
|
{ name: 'KitKat', calories: 518, fat: 26, carbs: 65, protein: 7, iron: 6 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const columns: DataTableColumn<Dessert>[] = [
|
||||||
|
{ key: 'name', header: 'Dessert (100g serving)', sortable: true },
|
||||||
|
{ key: 'calories', header: 'Calories', sortable: true, align: 'right' },
|
||||||
|
{ key: 'fat', header: 'Fat (g)', sortable: true, align: 'right' },
|
||||||
|
{ key: 'carbs', header: 'Carbs (g)', align: 'right' },
|
||||||
|
{ key: 'protein', header: 'Protein (g)', align: 'right' },
|
||||||
|
{ key: 'iron', header: 'Iron (%)', align: 'right' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const meta: Meta = {
|
||||||
|
title: 'Molecules/DataTable',
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: { layout: 'padded' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
render: () => (
|
||||||
|
<DataTable columns={columns} data={desserts} />
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithSelection: Story = {
|
||||||
|
name: 'With selection',
|
||||||
|
render: () => (
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={desserts}
|
||||||
|
selectable
|
||||||
|
onSelectionChange={(sel) => console.log('Selected:', sel)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CustomPageSize: Story = {
|
||||||
|
name: 'Custom page size',
|
||||||
|
render: () => (
|
||||||
|
<DataTable columns={columns} data={desserts} pageSize={3} pageSizeOptions={[3, 5, 10]} />
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Empty: Story = {
|
||||||
|
render: () => (
|
||||||
|
<DataTable columns={columns} data={[]} emptyMessage="No desserts found" />
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Loading: Story = {
|
||||||
|
render: () => (
|
||||||
|
<DataTable columns={columns} data={[]} loading />
|
||||||
|
),
|
||||||
|
}
|
||||||
282
src/components/molecules/DataTable/DataTable.tsx
Normal file
282
src/components/molecules/DataTable/DataTable.tsx
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
import {
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type HTMLAttributes,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
// --- Types ---
|
||||||
|
|
||||||
|
export interface DataTableColumn<T = Record<string, unknown>> {
|
||||||
|
key: string
|
||||||
|
header: string
|
||||||
|
sortable?: boolean
|
||||||
|
align?: 'left' | 'center' | 'right'
|
||||||
|
render?: (value: unknown, row: T, index: number) => ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataTableProps<T = Record<string, unknown>> extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
|
||||||
|
columns: DataTableColumn<T>[]
|
||||||
|
data: T[]
|
||||||
|
selectable?: boolean
|
||||||
|
pagination?: boolean
|
||||||
|
pageSize?: number
|
||||||
|
pageSizeOptions?: number[]
|
||||||
|
loading?: boolean
|
||||||
|
emptyMessage?: string
|
||||||
|
onSelectionChange?: (selected: T[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type SortState = { key: string; dir: 'asc' | 'desc' } | null
|
||||||
|
|
||||||
|
const ChevronUpIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className="size-4">
|
||||||
|
<path d="m18 15-6-6-6 6" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const ChevronDownIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className="size-4">
|
||||||
|
<path d="m6 9 6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- DataTable ---
|
||||||
|
|
||||||
|
function DataTableInner<T extends Record<string, unknown>>(
|
||||||
|
{
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
selectable = false,
|
||||||
|
pagination = true,
|
||||||
|
pageSize: initialPageSize = 5,
|
||||||
|
pageSizeOptions = [5, 10, 25],
|
||||||
|
loading = false,
|
||||||
|
emptyMessage = 'No data available',
|
||||||
|
onSelectionChange,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: DataTableProps<T>,
|
||||||
|
ref: React.ForwardedRef<HTMLDivElement>,
|
||||||
|
) {
|
||||||
|
const [sort, setSort] = useState<SortState>(null)
|
||||||
|
const [page, setPage] = useState(0)
|
||||||
|
const [pageSize, setPageSize] = useState(initialPageSize)
|
||||||
|
const [selected, setSelected] = useState<Set<number>>(new Set())
|
||||||
|
|
||||||
|
const sortedData = useMemo(() => {
|
||||||
|
if (!sort) return data
|
||||||
|
const { key, dir } = sort
|
||||||
|
return [...data].sort((a, b) => {
|
||||||
|
const va = a[key]
|
||||||
|
const vb = b[key]
|
||||||
|
if (va == null && vb == null) return 0
|
||||||
|
if (va == null) return 1
|
||||||
|
if (vb == null) return -1
|
||||||
|
const cmp = String(va).localeCompare(String(vb), undefined, { numeric: true })
|
||||||
|
return dir === 'asc' ? cmp : -cmp
|
||||||
|
})
|
||||||
|
}, [data, sort])
|
||||||
|
|
||||||
|
const pageCount = pagination ? Math.max(1, Math.ceil(sortedData.length / pageSize)) : 1
|
||||||
|
const pageData = pagination ? sortedData.slice(page * pageSize, (page + 1) * pageSize) : sortedData
|
||||||
|
const rangeStart = page * pageSize + 1
|
||||||
|
const rangeEnd = Math.min((page + 1) * pageSize, sortedData.length)
|
||||||
|
|
||||||
|
const toggleSort = useCallback((key: string) => {
|
||||||
|
setSort((prev) => {
|
||||||
|
if (prev?.key === key) {
|
||||||
|
return prev.dir === 'asc' ? { key, dir: 'desc' } : null
|
||||||
|
}
|
||||||
|
return { key, dir: 'asc' }
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggleRow = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(index)) next.delete(index)
|
||||||
|
else next.add(index)
|
||||||
|
onSelectionChange?.(
|
||||||
|
[...next].map((i) => sortedData[i]).filter(Boolean),
|
||||||
|
)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[sortedData, onSelectionChange],
|
||||||
|
)
|
||||||
|
|
||||||
|
const toggleAll = useCallback(() => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
if (prev.size === sortedData.length) {
|
||||||
|
onSelectionChange?.([])
|
||||||
|
return new Set()
|
||||||
|
}
|
||||||
|
const all = new Set(sortedData.map((_, i) => i))
|
||||||
|
onSelectionChange?.([...sortedData])
|
||||||
|
return all
|
||||||
|
})
|
||||||
|
}, [sortedData, onSelectionChange])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn('overflow-hidden rounded-default bg-surface', className)} {...props}>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left text-body">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border">
|
||||||
|
{selectable && (
|
||||||
|
<th className="w-12 px-4 py-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.size === sortedData.length && sortedData.length > 0}
|
||||||
|
onChange={toggleAll}
|
||||||
|
className="accent-primary"
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
{columns.map((col) => (
|
||||||
|
<th
|
||||||
|
key={col.key}
|
||||||
|
className={cn(
|
||||||
|
'px-4 py-3 font-normal text-primary',
|
||||||
|
col.sortable && 'cursor-pointer select-none hover:bg-text/[0.04]',
|
||||||
|
col.align === 'right' && 'text-right',
|
||||||
|
col.align === 'center' && 'text-center',
|
||||||
|
)}
|
||||||
|
onClick={col.sortable ? () => toggleSort(col.key) : undefined}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
{col.header}
|
||||||
|
{col.sortable && sort?.key === col.key && (
|
||||||
|
sort.dir === 'asc' ? <ChevronUpIcon /> : <ChevronDownIcon />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={columns.length + (selectable ? 1 : 0)} className="px-4 py-8 text-center text-text-secondary">
|
||||||
|
Loading…
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : pageData.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={columns.length + (selectable ? 1 : 0)} className="px-4 py-8 text-center text-text-secondary">
|
||||||
|
{emptyMessage}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
pageData.map((row, rowIdx) => {
|
||||||
|
const globalIdx = page * pageSize + rowIdx
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={globalIdx}
|
||||||
|
className={cn(
|
||||||
|
'border-b border-border last:border-b-0 transition-colors',
|
||||||
|
selected.has(globalIdx) ? 'bg-info/5' : 'hover:bg-text/[0.02]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selectable && (
|
||||||
|
<td className="w-12 px-4 py-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.has(globalIdx)}
|
||||||
|
onChange={() => toggleRow(globalIdx)}
|
||||||
|
className="accent-primary"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
{columns.map((col) => (
|
||||||
|
<td
|
||||||
|
key={col.key}
|
||||||
|
className={cn(
|
||||||
|
'px-4 py-3',
|
||||||
|
col.align === 'right' && 'text-right',
|
||||||
|
col.align === 'center' && 'text-center',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{col.render
|
||||||
|
? col.render(row[col.key], row, globalIdx)
|
||||||
|
: String(row[col.key] ?? '')}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pagination && sortedData.length > 0 && (
|
||||||
|
<div className="flex items-center justify-end gap-4 border-t border-border px-4 py-2 text-small text-text-secondary">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
Rows per page:
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPageSize(Number(e.target.value))
|
||||||
|
setPage(0)
|
||||||
|
}}
|
||||||
|
className="rounded-sm border border-border bg-surface px-2 py-1 text-small text-text"
|
||||||
|
>
|
||||||
|
{pageSizeOptions.map((opt) => (
|
||||||
|
<option key={opt} value={opt}>{opt}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<span>{rangeStart}-{rangeEnd} of {sortedData.length}</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={page === 0}
|
||||||
|
onClick={() => setPage(0)}
|
||||||
|
className="rounded-sm p-1 hover:bg-text/[0.04] disabled:opacity-40"
|
||||||
|
aria-label="First page"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" className="size-5"><path d="M18.41 16.59L13.82 12l4.59-4.59L17 6l-6 6 6 6zM6 6h2v12H6z" /></svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={page === 0}
|
||||||
|
onClick={() => setPage((p) => p - 1)}
|
||||||
|
className="rounded-sm p-1 hover:bg-text/[0.04] disabled:opacity-40"
|
||||||
|
aria-label="Previous page"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" className="size-5"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z" /></svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={page >= pageCount - 1}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
className="rounded-sm p-1 hover:bg-text/[0.04] disabled:opacity-40"
|
||||||
|
aria-label="Next page"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" className="size-5"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" /></svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={page >= pageCount - 1}
|
||||||
|
onClick={() => setPage(pageCount - 1)}
|
||||||
|
className="rounded-sm p-1 hover:bg-text/[0.04] disabled:opacity-40"
|
||||||
|
aria-label="Last page"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" className="size-5"><path d="M5.59 7.41L10.18 12l-4.59 4.59L7 18l6-6-6-6zM16 6h2v12h-2z" /></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DataTable = forwardRef(DataTableInner) as <T extends Record<string, unknown>>(
|
||||||
|
props: DataTableProps<T> & { ref?: React.Ref<HTMLDivElement> },
|
||||||
|
) => React.ReactElement | null
|
||||||
2
src/components/molecules/DataTable/index.ts
Normal file
2
src/components/molecules/DataTable/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { DataTable } from './DataTable'
|
||||||
|
export type { DataTableProps, DataTableColumn } from './DataTable'
|
||||||
@@ -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">
|
||||||
|
|||||||
97
src/components/organisms/PageHeader/PageHeader.stories.tsx
Normal file
97
src/components/organisms/PageHeader/PageHeader.stories.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { PageHeader } from './PageHeader'
|
||||||
|
|
||||||
|
const GridIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M3 3h8v8H3zm0 10h8v8H3zm10-10h8v8h-8zm0 10h8v8h-8z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const BookIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M18 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM6 4h5v8l-2.5-1.5L6 12V4z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const meta: Meta<typeof PageHeader> = {
|
||||||
|
title: 'Organisms/PageHeader',
|
||||||
|
component: PageHeader,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: { layout: 'fullscreen' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof PageHeader>
|
||||||
|
|
||||||
|
export const Light: Story = {
|
||||||
|
render: () => (
|
||||||
|
<PageHeader
|
||||||
|
title="Resources"
|
||||||
|
subtitle="Essential resources for my work"
|
||||||
|
icon={<GridIcon />}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Dark: Story = {
|
||||||
|
render: () => (
|
||||||
|
<PageHeader
|
||||||
|
title="Resources"
|
||||||
|
subtitle="Essential resources for my work"
|
||||||
|
icon={<GridIcon />}
|
||||||
|
theme="dark"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NoIcon: Story = {
|
||||||
|
name: 'No icon',
|
||||||
|
render: () => (
|
||||||
|
<PageHeader
|
||||||
|
title="My Documents"
|
||||||
|
subtitle="View and manage your uploaded documents"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Centered: Story = {
|
||||||
|
render: () => (
|
||||||
|
<PageHeader
|
||||||
|
title="Welcome to your PDP"
|
||||||
|
subtitle="Performance and Development Plan portal"
|
||||||
|
icon={<BookIcon />}
|
||||||
|
centered
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NoBackground: Story = {
|
||||||
|
name: 'No background',
|
||||||
|
render: () => (
|
||||||
|
<PageHeader
|
||||||
|
title="Settings"
|
||||||
|
subtitle="Manage your account preferences"
|
||||||
|
noBackground
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithContent: Story = {
|
||||||
|
name: 'With content slot',
|
||||||
|
render: () => (
|
||||||
|
<PageHeader
|
||||||
|
title="Resources"
|
||||||
|
subtitle="Essential resources for my work"
|
||||||
|
icon={<GridIcon />}
|
||||||
|
>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button className="rounded-full bg-primary px-5 py-2 text-small font-semibold text-white">
|
||||||
|
Browse all
|
||||||
|
</button>
|
||||||
|
<button className="rounded-full border-2 border-primary px-5 py-2 text-small font-semibold text-primary">
|
||||||
|
My favourites
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</PageHeader>
|
||||||
|
),
|
||||||
|
}
|
||||||
91
src/components/organisms/PageHeader/PageHeader.tsx
Normal file
91
src/components/organisms/PageHeader/PageHeader.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface PageHeaderProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
title: string
|
||||||
|
subtitle?: string
|
||||||
|
icon?: ReactNode
|
||||||
|
iconSize?: string
|
||||||
|
theme?: 'light' | 'dark'
|
||||||
|
centered?: boolean
|
||||||
|
noBackground?: boolean
|
||||||
|
children?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
function DecoArcs({ isDark }: { isDark: boolean }) {
|
||||||
|
const stroke = isDark ? 'rgba(20, 108, 253, 0.25)' : 'rgba(0, 38, 100, 0.12)'
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className="pointer-events-none absolute right-0 top-0 h-full w-1/2"
|
||||||
|
viewBox="0 0 400 200"
|
||||||
|
preserveAspectRatio="xMaxYMid slice"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="350" cy="100" r="160" stroke={stroke} strokeWidth="30" />
|
||||||
|
<circle cx="350" cy="100" r="100" stroke={stroke} strokeWidth="20" />
|
||||||
|
<circle cx="350" cy="100" r="50" stroke={stroke} strokeWidth="12" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PageHeader = forwardRef<HTMLDivElement, PageHeaderProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
icon,
|
||||||
|
iconSize = '50px',
|
||||||
|
theme = 'light',
|
||||||
|
centered = false,
|
||||||
|
noBackground = false,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const isDark = theme === 'dark'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative overflow-hidden px-9 py-11',
|
||||||
|
!noBackground && (isDark ? 'bg-primary text-white' : 'bg-secondary text-primary'),
|
||||||
|
noBackground && 'text-text',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{!noBackground && <DecoArcs isDark={isDark} />}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative z-10 flex gap-5',
|
||||||
|
centered ? 'flex-col items-center text-center' : 'items-start',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon && (
|
||||||
|
<span
|
||||||
|
className="shrink-0 [&>svg]:size-full"
|
||||||
|
style={{ width: iconSize, height: iconSize }}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h1 className="text-h2-responsive">{title}</h1>
|
||||||
|
{subtitle && (
|
||||||
|
<p className={cn('mt-1 text-body', isDark ? 'text-white/80' : 'text-text-secondary')}>
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{children && <div className="mt-4">{children}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
PageHeader.displayName = 'PageHeader'
|
||||||
2
src/components/organisms/PageHeader/index.ts
Normal file
2
src/components/organisms/PageHeader/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { PageHeader } from './PageHeader'
|
||||||
|
export type { PageHeaderProps } from './PageHeader'
|
||||||
314
src/components/organisms/SideNav/SideNav.stories.tsx
Normal file
314
src/components/organisms/SideNav/SideNav.stories.tsx
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { SideNav, SideNavItem, SideNavGroup, SideNavDivider } from './SideNav'
|
||||||
|
|
||||||
|
const HomeIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const PersonIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const GridIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M3 3h8v8H3zm0 10h8v8H3zm10-10h8v8h-8zm0 10h8v8h-8z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const BookIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M18 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM6 4h5v8l-2.5-1.5L6 12V4z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const FolderIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const SchoolIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M5 13.18v4L12 21l7-3.82v-4L12 17l-7-3.82zM12 3 1 9l11 6 9-4.91V17h2V9L12 3z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const SupportIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const meta: Meta<typeof SideNav> = {
|
||||||
|
title: 'Organisms/SideNav',
|
||||||
|
component: SideNav,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
},
|
||||||
|
decorators: [
|
||||||
|
(Story) => (
|
||||||
|
<div className="h-[600px] border border-border rounded-lg overflow-hidden">
|
||||||
|
<Story />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof SideNav>
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
render: () => (
|
||||||
|
<SideNav>
|
||||||
|
<SideNavItem icon={<HomeIcon />} href="#" active>
|
||||||
|
My status
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<PersonIcon />} href="#">
|
||||||
|
My details
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<GridIcon />} href="#">
|
||||||
|
Workspace
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<BookIcon />} href="#">
|
||||||
|
Resources
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<FolderIcon />} href="#">
|
||||||
|
My documents & links
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavDivider />
|
||||||
|
<SideNavGroup icon={<SchoolIcon />} label="PDP" defaultOpen>
|
||||||
|
<SideNavItem href="#">My PDP</SideNavItem>
|
||||||
|
<SideNavItem href="#">PDP guide</SideNavItem>
|
||||||
|
<SideNavItem href="#">Management</SideNavItem>
|
||||||
|
<SideNavItem href="#">Useful links</SideNavItem>
|
||||||
|
</SideNavGroup>
|
||||||
|
<SideNavDivider />
|
||||||
|
<SideNavItem icon={<SupportIcon />} href="#">
|
||||||
|
Support
|
||||||
|
</SideNavItem>
|
||||||
|
</SideNav>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithActiveNested: Story = {
|
||||||
|
name: 'Active nested item',
|
||||||
|
render: () => (
|
||||||
|
<SideNav>
|
||||||
|
<SideNavItem icon={<HomeIcon />} href="#">
|
||||||
|
My status
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<PersonIcon />} href="#">
|
||||||
|
My details
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<GridIcon />} href="#">
|
||||||
|
Workspace
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<BookIcon />} href="#">
|
||||||
|
Resources
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<FolderIcon />} href="#">
|
||||||
|
My documents & links
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavDivider />
|
||||||
|
<SideNavGroup icon={<SchoolIcon />} label="PDP" defaultOpen>
|
||||||
|
<SideNavItem href="#" active>
|
||||||
|
My PDP
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem href="#">PDP guide</SideNavItem>
|
||||||
|
<SideNavItem href="#">Management</SideNavItem>
|
||||||
|
<SideNavItem href="#">Useful links</SideNavItem>
|
||||||
|
</SideNavGroup>
|
||||||
|
<SideNavDivider />
|
||||||
|
<SideNavItem icon={<SupportIcon />} href="#">
|
||||||
|
Support
|
||||||
|
</SideNavItem>
|
||||||
|
</SideNav>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Collapsed: Story = {
|
||||||
|
render: () => (
|
||||||
|
<SideNav collapsed>
|
||||||
|
<SideNavItem icon={<HomeIcon />} href="#" active>
|
||||||
|
My status
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<PersonIcon />} href="#">
|
||||||
|
My details
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<GridIcon />} href="#">
|
||||||
|
Workspace
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<BookIcon />} href="#">
|
||||||
|
Resources
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<FolderIcon />} href="#">
|
||||||
|
My documents & links
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavDivider />
|
||||||
|
<SideNavGroup icon={<SchoolIcon />} label="PDP" active>
|
||||||
|
<SideNavItem href="#" active>
|
||||||
|
My PDP
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem href="#">PDP guide</SideNavItem>
|
||||||
|
</SideNavGroup>
|
||||||
|
<SideNavDivider />
|
||||||
|
<SideNavItem icon={<SupportIcon />} href="#">
|
||||||
|
Support
|
||||||
|
</SideNavItem>
|
||||||
|
</SideNav>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithBadges: Story = {
|
||||||
|
name: 'With badges',
|
||||||
|
render: () => (
|
||||||
|
<SideNav>
|
||||||
|
<SideNavItem icon={<HomeIcon />} href="#" active>
|
||||||
|
My status
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<PersonIcon />} href="#">
|
||||||
|
My details
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<BookIcon />} href="#" badge={12}>
|
||||||
|
Resources
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<FolderIcon />} href="#" badge={3}>
|
||||||
|
My documents & links
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavDivider />
|
||||||
|
<SideNavGroup icon={<SchoolIcon />} label="PDP" badge={5} defaultOpen>
|
||||||
|
<SideNavItem href="#" badge={2}>
|
||||||
|
My PDP
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem href="#" badge={3}>
|
||||||
|
PDP guide
|
||||||
|
</SideNavItem>
|
||||||
|
</SideNavGroup>
|
||||||
|
<SideNavDivider />
|
||||||
|
<SideNavItem icon={<SupportIcon />} href="#">
|
||||||
|
Support
|
||||||
|
</SideNavItem>
|
||||||
|
</SideNav>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToggleTemplate = () => {
|
||||||
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<SideNav collapsed={collapsed}>
|
||||||
|
<SideNavItem icon={<HomeIcon />} href="#" active>
|
||||||
|
My status
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<PersonIcon />} href="#">
|
||||||
|
My details
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<GridIcon />} href="#">
|
||||||
|
Workspace
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<BookIcon />} href="#">
|
||||||
|
Resources
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<FolderIcon />} href="#">
|
||||||
|
My documents & links
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavDivider />
|
||||||
|
<SideNavGroup icon={<SchoolIcon />} label="PDP" defaultOpen>
|
||||||
|
<SideNavItem href="#">My PDP</SideNavItem>
|
||||||
|
<SideNavItem href="#">PDP guide</SideNavItem>
|
||||||
|
<SideNavItem href="#">Management</SideNavItem>
|
||||||
|
<SideNavItem href="#">Useful links</SideNavItem>
|
||||||
|
</SideNavGroup>
|
||||||
|
<SideNavDivider />
|
||||||
|
<SideNavItem icon={<SupportIcon />} href="#">
|
||||||
|
Support
|
||||||
|
</SideNavItem>
|
||||||
|
</SideNav>
|
||||||
|
<button
|
||||||
|
onClick={() => setCollapsed((c) => !c)}
|
||||||
|
className="self-start rounded-lg border border-border px-4 py-2 text-small hover:bg-bg"
|
||||||
|
>
|
||||||
|
{collapsed ? 'Expand' : 'Collapse'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Interactive: Story = {
|
||||||
|
name: 'Toggle collapsed',
|
||||||
|
render: () => <ToggleTemplate />,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithAlerts: Story = {
|
||||||
|
name: 'With alerts',
|
||||||
|
render: () => (
|
||||||
|
<SideNav>
|
||||||
|
<SideNavItem icon={<HomeIcon />} href="#" active>
|
||||||
|
My status
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<PersonIcon />} href="#" alert="error">
|
||||||
|
My details
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<GridIcon />} href="#">
|
||||||
|
Workspace
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<BookIcon />} href="#" alert="info">
|
||||||
|
Resources
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<FolderIcon />} href="#" badge={3} alert="warning">
|
||||||
|
My documents & links
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavDivider />
|
||||||
|
<SideNavGroup icon={<SchoolIcon />} label="PDP" alert="success" defaultOpen>
|
||||||
|
<SideNavItem href="#" active>
|
||||||
|
My PDP
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem href="#">PDP guide</SideNavItem>
|
||||||
|
</SideNavGroup>
|
||||||
|
<SideNavDivider />
|
||||||
|
<SideNavItem icon={<SupportIcon />} href="#">
|
||||||
|
Support
|
||||||
|
</SideNavItem>
|
||||||
|
</SideNav>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CollapsedWithAlerts: Story = {
|
||||||
|
name: 'Collapsed with alerts',
|
||||||
|
render: () => (
|
||||||
|
<SideNav collapsed>
|
||||||
|
<SideNavItem icon={<HomeIcon />} href="#" active>
|
||||||
|
My status
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<PersonIcon />} href="#" alert="error">
|
||||||
|
My details
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<GridIcon />} href="#">
|
||||||
|
Workspace
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<BookIcon />} href="#" alert="info">
|
||||||
|
Resources
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<FolderIcon />} href="#" badge={3} alert="warning">
|
||||||
|
My documents & links
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavDivider />
|
||||||
|
<SideNavGroup icon={<SchoolIcon />} label="PDP" alert="success">
|
||||||
|
<SideNavItem href="#" active>
|
||||||
|
My PDP
|
||||||
|
</SideNavItem>
|
||||||
|
</SideNavGroup>
|
||||||
|
<SideNavDivider />
|
||||||
|
<SideNavItem icon={<SupportIcon />} href="#">
|
||||||
|
Support
|
||||||
|
</SideNavItem>
|
||||||
|
</SideNav>
|
||||||
|
),
|
||||||
|
}
|
||||||
329
src/components/organisms/SideNav/SideNav.tsx
Normal file
329
src/components/organisms/SideNav/SideNav.tsx
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type AnchorHTMLAttributes,
|
||||||
|
type ButtonHTMLAttributes,
|
||||||
|
type HTMLAttributes,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Tooltip } from '@/components/atoms/Tooltip/Tooltip'
|
||||||
|
|
||||||
|
type AlertVariant = 'info' | 'success' | 'warning' | 'error'
|
||||||
|
|
||||||
|
const alertStyles: Record<AlertVariant, string> = {
|
||||||
|
info: 'bg-info',
|
||||||
|
success: 'bg-success',
|
||||||
|
warning: 'bg-warning',
|
||||||
|
error: 'bg-error',
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAlertColor(alert: boolean | AlertVariant | undefined): string | null {
|
||||||
|
if (!alert) return null
|
||||||
|
return alertStyles[alert === true ? 'error' : alert]
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChevronIcon = () => (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="m6 9 6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Contexts ---
|
||||||
|
|
||||||
|
interface SideNavContextValue {
|
||||||
|
collapsed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const SideNavContext = createContext<SideNavContextValue>({ collapsed: false })
|
||||||
|
|
||||||
|
const NestedContext = createContext(false)
|
||||||
|
|
||||||
|
// --- SideNav ---
|
||||||
|
|
||||||
|
export interface SideNavProps extends HTMLAttributes<HTMLElement> {
|
||||||
|
collapsed?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SideNav = forwardRef<HTMLElement, SideNavProps>(
|
||||||
|
({ collapsed = false, className, children, ...props }, ref) => {
|
||||||
|
const contextValue = useMemo(() => ({ collapsed }), [collapsed])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SideNavContext.Provider value={contextValue}>
|
||||||
|
<nav
|
||||||
|
ref={ref}
|
||||||
|
aria-label="Side navigation"
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col 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'
|
||||||
7
src/components/organisms/SideNav/index.ts
Normal file
7
src/components/organisms/SideNav/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export { SideNav, SideNavItem, SideNavGroup, SideNavDivider } from './SideNav'
|
||||||
|
export type {
|
||||||
|
SideNavProps,
|
||||||
|
SideNavItemProps,
|
||||||
|
SideNavGroupProps,
|
||||||
|
SideNavDividerProps,
|
||||||
|
} from './SideNav'
|
||||||
205
src/components/organisms/TopBar/TopBar.stories.tsx
Normal file
205
src/components/organisms/TopBar/TopBar.stories.tsx
Normal 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 />,
|
||||||
|
}
|
||||||
44
src/components/organisms/TopBar/TopBar.tsx
Normal file
44
src/components/organisms/TopBar/TopBar.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface TopBarProps extends HTMLAttributes<HTMLElement> {
|
||||||
|
title: string
|
||||||
|
leading?: ReactNode
|
||||||
|
logo?: ReactNode
|
||||||
|
children?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TopBar = forwardRef<HTMLElement, TopBarProps>(
|
||||||
|
({ title, leading, logo, className, children, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex h-16 w-full items-center bg-topbar',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{leading && (
|
||||||
|
<div className="flex shrink-0 items-center pl-3.5">
|
||||||
|
{leading}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-3 pl-5">
|
||||||
|
{logo && <div className="shrink-0">{logo}</div>}
|
||||||
|
<h1 className="truncate text-h4 font-bold leading-7 text-white">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{children && (
|
||||||
|
<div className="flex shrink-0 items-center pr-2.5">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
TopBar.displayName = 'TopBar'
|
||||||
2
src/components/organisms/TopBar/index.ts
Normal file
2
src/components/organisms/TopBar/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { TopBar } from './TopBar'
|
||||||
|
export type { TopBarProps } from './TopBar'
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
Reference in New Issue
Block a user