Add page templates, overhaul DESIGN.md, and fix SideNav text alignment
Introduce AppShell, DashboardPage, ListPage, and FormPage template components with Storybook recipe stories for AI agent consumption. Thoroughly update DESIGN.md with all missing components, corrected token values, and page layout conventions. Fix SideNav button items defaulting to centered text. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -77,9 +77,17 @@ Small compositions of atoms into reusable units. May combine icons, text, button
|
|||||||
### `src/components/organisms/` — Organisms
|
### `src/components/organisms/` — Organisms
|
||||||
Larger compositions that carry domain semantics or define page-level regions. Built from atoms and molecules.
|
Larger compositions that carry domain semantics or define page-level regions. Built from atoms and molecules.
|
||||||
- TopBar, SideNav, PageHeader
|
- TopBar, SideNav, PageHeader
|
||||||
- *(planned)* AppShell (header + sidebar + content area)
|
|
||||||
- *(planned)* DatePicker
|
- *(planned)* DatePicker
|
||||||
|
|
||||||
|
### `src/components/templates/` — Templates
|
||||||
|
Page-level layout components that define the shell and content structure. Templates accept typed slot props (ReactNode) for their sections, making them composable by AI agents and developers. They do not own content — they define where content goes.
|
||||||
|
- **AppShell** — TopBar + SideNav + scrollable content area. All pages render inside this.
|
||||||
|
- **DashboardPage** — PageHeader + stat cards row + responsive 2-column content grid
|
||||||
|
- **ListPage** — PageHeader + stat cards + list header with actions + scrollable item list
|
||||||
|
- **FormPage** — PageHeader + optional action bar + optional vertical stepper + constrained-width form content
|
||||||
|
|
||||||
|
Templates have Storybook stories tagged `['autodocs', 'template']` that show realistic "recipe" compositions — full pages built from real components with sample data. These serve as reference implementations for AI coding agents.
|
||||||
|
|
||||||
### Which Tier Does a Component Belong To?
|
### Which Tier Does a Component Belong To?
|
||||||
|
|
||||||
| Question | If yes → |
|
| Question | If yes → |
|
||||||
@@ -88,6 +96,7 @@ Larger compositions that carry domain semantics or define page-level regions. Bu
|
|||||||
| Does it compose 2+ atoms into a reusable unit (e.g., Alert = icon + text + close button)? | **molecules/** |
|
| 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 carry domain-specific naming or logic (e.g., ThemeCard, ParticipantRow)? | **organisms/** |
|
||||||
| Does it define a page-level region or shell (header, sidebar, content area)? | **organisms/** |
|
| Does it define a page-level region or shell (header, sidebar, content area)? | **organisms/** |
|
||||||
|
| Does it define the layout structure of a full page (slot-based, no owned content)? | **templates/** |
|
||||||
|
|
||||||
When in doubt: start in `atoms/`. Promote to `molecules/` when a component begins importing other atoms.
|
When in doubt: start in `atoms/`. Promote to `molecules/` when a component begins importing other atoms.
|
||||||
|
|
||||||
@@ -121,7 +130,8 @@ src/
|
|||||||
├── components/
|
├── components/
|
||||||
│ ├── atoms/ # Single-purpose elements
|
│ ├── atoms/ # Single-purpose elements
|
||||||
│ ├── molecules/ # Small compositions of atoms
|
│ ├── molecules/ # Small compositions of atoms
|
||||||
│ └── organisms/ # Domain-aware / page-level components
|
│ ├── organisms/ # Domain-aware / page-level components
|
||||||
|
│ └── templates/ # Page-level layout components (slot-based)
|
||||||
├── tokens/
|
├── tokens/
|
||||||
│ └── tokens.css # Design tokens (@theme block)
|
│ └── tokens.css # Design tokens (@theme block)
|
||||||
├── styles/
|
├── styles/
|
||||||
|
|||||||
724
DESIGN.md
724
DESIGN.md
@@ -6,39 +6,78 @@ colors:
|
|||||||
# Palette (do not reference directly in components)
|
# Palette (do not reference directly in components)
|
||||||
blue-01: "#002664"
|
blue-01: "#002664"
|
||||||
blue-02: "#146CFD"
|
blue-02: "#146CFD"
|
||||||
blue-03: "#69B3E7"
|
blue-03: "#8CE0FF"
|
||||||
blue-04: "#CBEDFD"
|
blue-04: "#CBEDFD"
|
||||||
blue-05: "#EBF5FF"
|
red-01: "#630019"
|
||||||
red-01: "#3E0014"
|
|
||||||
red-02: "#D7153A"
|
red-02: "#D7153A"
|
||||||
red-03: "#F5C5D0"
|
red-03: "#FFB8C1"
|
||||||
red-04: "#FDDDE5"
|
red-04: "#FFE6EA"
|
||||||
red-05: "#FFF5F8"
|
orange-01: "#941B00"
|
||||||
orange-01: "#7A3300"
|
orange-02: "#F3631B"
|
||||||
orange-02: "#EC6608"
|
orange-03: "#FFCE99"
|
||||||
orange-03: "#F5B98A"
|
orange-04: "#FDEDDF"
|
||||||
orange-04: "#FEF0E4"
|
green-01: "#004000"
|
||||||
green-01: "#005C35"
|
green-02: "#00AA45"
|
||||||
green-02: "#00A651"
|
green-03: "#A8EDB3"
|
||||||
green-03: "#89E5B3"
|
green-04: "#DBFADF"
|
||||||
green-04: "#E0F8EA"
|
teal-01: "#0B3F47"
|
||||||
|
teal-02: "#2E808E"
|
||||||
|
teal-03: "#8CDBE5"
|
||||||
|
teal-04: "#D1EEEA"
|
||||||
|
brown-01: "#523719"
|
||||||
|
brown-02: "#B68D5D"
|
||||||
|
brown-03: "#E8D0B5"
|
||||||
|
brown-04: "#EDE3D7"
|
||||||
|
purple-01: "#441170"
|
||||||
|
purple-02: "#8055F1"
|
||||||
|
purple-03: "#CEBFFF"
|
||||||
|
purple-04: "#E6E1FD"
|
||||||
|
fuchsia-01: "#65004D"
|
||||||
|
fuchsia-02: "#D912AE"
|
||||||
|
fuchsia-03: "#F4B5E6"
|
||||||
|
fuchsia-04: "#FDDEF2"
|
||||||
|
yellow-01: "#694800"
|
||||||
|
yellow-02: "#FAAF05"
|
||||||
|
yellow-03: "#FDE79A"
|
||||||
|
yellow-04: "#FFF4CF"
|
||||||
grey-01: "#22272B"
|
grey-01: "#22272B"
|
||||||
grey-02: "#6D7278"
|
grey-02: "#495054"
|
||||||
grey-03: "#C0C0C0"
|
grey-03: "#CDD3D6"
|
||||||
grey-04: "#E0E0E0"
|
grey-04: "#EBEBEB"
|
||||||
off-white: "#F4F4F4"
|
grey-05: "#F2F2F2"
|
||||||
white: "#FFFFFF"
|
white: "#FFFFFF"
|
||||||
# Semantic (use these in components)
|
# Semantic (use these in components)
|
||||||
primary: "{colors.blue-02}"
|
primary: "{colors.blue-01}"
|
||||||
primary-dark: "{colors.blue-01}"
|
info: "{colors.blue-02}"
|
||||||
|
secondary: "{colors.blue-04}"
|
||||||
error: "{colors.red-02}"
|
error: "{colors.red-02}"
|
||||||
success: "{colors.green-02}"
|
success: "{colors.green-02}"
|
||||||
warning: "{colors.orange-02}"
|
warning: "{colors.orange-02}"
|
||||||
text: "{colors.grey-01}"
|
text: "{colors.grey-01}"
|
||||||
text-secondary: "{colors.grey-02}"
|
text-secondary: "{colors.grey-02}"
|
||||||
border: "{colors.grey-04}"
|
border: "{colors.grey-04}"
|
||||||
bg: "{colors.off-white}"
|
bg: "{colors.grey-05}"
|
||||||
surface: "{colors.white}"
|
surface: "{colors.white}"
|
||||||
|
# Form controls
|
||||||
|
control-border: "{colors.grey-03}"
|
||||||
|
control-border-hover: "{colors.grey-01}"
|
||||||
|
control-checked: "{colors.blue-01}"
|
||||||
|
control-checked-hover: "{colors.blue-02}"
|
||||||
|
control-focus-ring: "{colors.blue-04}"
|
||||||
|
control-label: "{colors.blue-01}"
|
||||||
|
control-description: "{colors.grey-02}"
|
||||||
|
control-error: "{colors.red-02}"
|
||||||
|
control-bg: "{colors.white}"
|
||||||
|
control-bg-readonly: "{colors.grey-05}"
|
||||||
|
# Button
|
||||||
|
button-default: "{colors.blue-01}"
|
||||||
|
button-danger: "{colors.red-02}"
|
||||||
|
button-neutral: "{colors.grey-01}"
|
||||||
|
button-subtle-bg: "{colors.blue-04}"
|
||||||
|
button-subtle-text: "{colors.blue-01}"
|
||||||
|
# Switch
|
||||||
|
switch-on: "{colors.green-02}"
|
||||||
|
switch-on-hover: "{colors.green-01}"
|
||||||
typography:
|
typography:
|
||||||
h1:
|
h1:
|
||||||
fontFamily: Public Sans Variable
|
fontFamily: Public Sans Variable
|
||||||
@@ -92,59 +131,39 @@ typography:
|
|||||||
lineHeight: 1.5
|
lineHeight: 1.5
|
||||||
rounded:
|
rounded:
|
||||||
sm: 4px
|
sm: 4px
|
||||||
DEFAULT: 6px
|
DEFAULT: 8px
|
||||||
lg: 10px
|
lg: 16px
|
||||||
xl: 16px
|
xl: 24px
|
||||||
full: 9999px
|
full: 9999px
|
||||||
spacing:
|
spacing:
|
||||||
unit: 4px
|
unit: 4px
|
||||||
scale: "Tailwind default (4px base: 1=4px, 2=8px, 3=12px, 4=16px, 6=24px, 8=32px)"
|
scale: "Tailwind default (4px base: 1=4px, 2=8px, 3=12px, 4=16px, 6=24px, 8=32px)"
|
||||||
components:
|
|
||||||
button-primary-default:
|
|
||||||
backgroundColor: "{colors.primary-dark}"
|
|
||||||
textColor: "{colors.white}"
|
|
||||||
typography: "{typography.body}"
|
|
||||||
rounded: "{rounded.DEFAULT}"
|
|
||||||
height: 48px
|
|
||||||
button-primary-danger:
|
|
||||||
backgroundColor: "{colors.red-02}"
|
|
||||||
textColor: "{colors.white}"
|
|
||||||
button-secondary-default:
|
|
||||||
backgroundColor: transparent
|
|
||||||
textColor: "{colors.primary-dark}"
|
|
||||||
input:
|
|
||||||
backgroundColor: "{colors.white}"
|
|
||||||
textColor: "{colors.text}"
|
|
||||||
typography: "{typography.body}"
|
|
||||||
rounded: "{rounded.DEFAULT}"
|
|
||||||
height: 48px
|
|
||||||
card-surface:
|
|
||||||
backgroundColor: "{colors.surface}"
|
|
||||||
rounded: "{rounded.xl}"
|
|
||||||
badge-info:
|
|
||||||
backgroundColor: "{colors.blue-02}"
|
|
||||||
textColor: "{colors.white}"
|
|
||||||
rounded: "{rounded.full}"
|
|
||||||
alert-info:
|
|
||||||
backgroundColor: "{colors.blue-05}"
|
|
||||||
rounded: "{rounded.DEFAULT}"
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
ADS 3.0 (Adaptive Design System) is a React component library based on the NSW Department of Education's digital design language. It uses Public Sans as its typeface, a structured blue/grey palette with semantic colour aliases, and consistent border radii and spacing. Components are built with React 19, TypeScript (strict), and Tailwind CSS v4.
|
ADS 3.0 (Adaptive Design System) is a React component library based on the NSW Department of Education's digital design language. It uses Public Sans as its typeface, a structured colour palette with semantic aliases, and consistent border radii and spacing. Components are built with React 19, TypeScript (strict), and Tailwind CSS v4.
|
||||||
|
|
||||||
All visual values come from design tokens defined in `src/tokens/tokens.css`. Components never use raw colour values — they reference semantic or domain-specific token classes via Tailwind utilities.
|
All visual values come from design tokens defined in `src/tokens/tokens.css`. Components never use raw colour values — they reference semantic or domain-specific token classes via Tailwind utilities.
|
||||||
|
|
||||||
## Colors
|
## Colors
|
||||||
|
|
||||||
The palette is organised in three layers:
|
The palette is organised in layers, from raw to consumable:
|
||||||
|
|
||||||
1. **Palette** — Raw values (`blue-01` through `blue-05`, `grey-01` through `grey-04`, etc.). Never use these directly in component code.
|
1. **Palette** — Raw values across 10 colour families (blue, red, orange, green, teal, brown, purple, fuchsia, yellow, grey) with 4 shades each (`-01` darkest to `-04` lightest), plus grey-05 and white. Never use these directly in component code.
|
||||||
2. **Semantic** — Purpose-based aliases (`primary`, `error`, `success`, `warning`, `text`, `surface`, `bg`, `border`). Use these for general UI.
|
2. **Semantic** — Purpose-based aliases (`primary`, `info`, `secondary`, `error`, `success`, `warning`, `text`, `text-secondary`, `surface`, `bg`, `border`). Use these for general UI.
|
||||||
3. **Domain** — Component-specific tokens (`button-default`, `control-border`, `badge-info`, `alert-error-bg`, `tag-navy`, etc.). Use these within their respective components.
|
3. **Form control** — Shared interactive-state tokens for all form components: `control-border`, `control-border-hover`, `control-checked`, `control-checked-hover`, `control-focus-ring`, `control-label`, `control-description`, `control-error`, `control-bg`, `control-bg-readonly`.
|
||||||
|
4. **Button** — Intent tokens: `button-default` (navy), `button-danger` (red), `button-neutral` (dark grey), `button-subtle-bg`, `button-subtle-text`.
|
||||||
|
5. **Switch** — On-state tokens: `switch-on` (success green), `switch-on-hover`.
|
||||||
|
6. **Badge** — Status colour tokens: `badge-navy`, `badge-info`, `badge-info-light`, `badge-success`, `badge-success-light`, `badge-error`, `badge-error-light`, `badge-warning`, `badge-warning-light`, `badge-neutral`, plus contrast text tokens (`badge-on-success-light`, `badge-on-error-light`, `badge-on-warning-light`).
|
||||||
|
7. **Chip** — Border/fill state tokens: `chip-border`, `chip-text`, `chip-bg`, `chip-selected-bg`, `chip-selected-text`.
|
||||||
|
8. **Tag** — 11-colour system, each with a `-light` variant: navy, blue, green, red, orange, grey, teal, brown, purple, fuchsia, yellow.
|
||||||
|
9. **Alert** — Background, border, and icon tokens for 5 variants: `alert-{variant}-bg`, `alert-{variant}-border`, `alert-{variant}-icon` (info, warning, error, success, neutral).
|
||||||
|
10. **Avatar** — `avatar` (background), `avatar-text`.
|
||||||
|
11. **TopBar** — `topbar` (background, navy).
|
||||||
|
12. **SideNav** — `nav-bg`, `nav-text`, `nav-icon`, `nav-active`, `nav-divider`.
|
||||||
|
|
||||||
The primary brand colour is `blue-01` (#002664, dark navy) for interactive elements like buttons and links. `blue-02` (#146CFD) is the brighter accent used for info states, focus rings, and highlights.
|
The primary brand colour is `blue-01` (#002664, dark navy) used for buttons, links, and navigation. `blue-02` (#146CFD) is the brighter accent for info states, focus rings, and highlights.
|
||||||
|
|
||||||
## Typography
|
## Typography
|
||||||
|
|
||||||
@@ -159,7 +178,7 @@ Spacing follows Tailwind's default 4px base scale. Common values:
|
|||||||
- `gap-2` (8px), `gap-3` (12px), `gap-4` (16px) for flex/grid gaps
|
- `gap-2` (8px), `gap-3` (12px), `gap-4` (16px) for flex/grid gaps
|
||||||
- `space-y-4` (16px) for stacked content
|
- `space-y-4` (16px) for stacked content
|
||||||
|
|
||||||
No custom spacing tokens are defined — the Tailwind defaults are sufficient. This may change in future.
|
No custom spacing tokens are defined — the Tailwind defaults are sufficient.
|
||||||
|
|
||||||
## Elevation & Depth
|
## Elevation & Depth
|
||||||
|
|
||||||
@@ -171,14 +190,61 @@ Two shadow levels:
|
|||||||
|
|
||||||
Border radii use the custom scale:
|
Border radii use the custom scale:
|
||||||
- `rounded-sm` (4px) — small elements, tags
|
- `rounded-sm` (4px) — small elements, tags
|
||||||
- `rounded-default` (6px) — buttons, inputs, alerts
|
- `rounded-default` (8px) — buttons, inputs, alerts, chips
|
||||||
- `rounded-lg` (10px) — cards, dialogs
|
- `rounded-lg` (16px) — cards, dialogs
|
||||||
- `rounded-xl` (16px) — large containers
|
- `rounded-xl` (24px) — large containers, page headers
|
||||||
- `rounded-full` (9999px) — badges, circular buttons
|
- `rounded-full` (9999px) — badges, avatars, circular buttons
|
||||||
|
|
||||||
|
## Page Layout
|
||||||
|
|
||||||
|
Pages are composed using the **AppShell** template, which provides the fixed TopBar, collapsible SideNav, and scrollable content area. Content within the main area follows these conventions:
|
||||||
|
|
||||||
|
### Shell structure
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ TopBar (h-16, fixed top, full width) │
|
||||||
|
├────────┬────────────────────────────────┤
|
||||||
|
│SideNav │ Content area (scrollable) │
|
||||||
|
│(w-[360]│ │
|
||||||
|
│ or │ PageHeader (optional) │
|
||||||
|
│ w-20 │ │
|
||||||
|
│collapsed│ Main content │
|
||||||
|
│) │ │
|
||||||
|
└────────┴────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Content patterns
|
||||||
|
|
||||||
|
**Dashboard** — Use a responsive grid for content cards:
|
||||||
|
- 2-column grid at `lg` breakpoint: `grid grid-cols-1 lg:grid-cols-2 gap-6`
|
||||||
|
- Stat cards in a horizontal row: `flex gap-4` or `grid grid-cols-3 gap-4`
|
||||||
|
- Content padding: `p-6` or `p-8`
|
||||||
|
- Section spacing: `space-y-6`
|
||||||
|
|
||||||
|
**List page** — Stat summary row above a scrollable list:
|
||||||
|
- Stat cards row: `flex gap-4` with Card components
|
||||||
|
- List section: Card wrapping a vertical list with `divide-y divide-border`
|
||||||
|
- Each list item: `px-6 py-4` with flex layout for metadata
|
||||||
|
|
||||||
|
**Form page** — Stepped form with vertical stepper:
|
||||||
|
- Content max-width: `max-w-3xl` for readable line length
|
||||||
|
- Form sections: `space-y-6` between groups
|
||||||
|
- Stepper: vertical list on left, form content fills remaining space
|
||||||
|
- Submit actions: right-aligned with `flex justify-end gap-3`
|
||||||
|
|
||||||
|
### Spacing conventions
|
||||||
|
- Page content padding: `p-6` (24px) minimum
|
||||||
|
- Section gap: `gap-6` (24px) between major sections
|
||||||
|
- Card internal padding: `p-5` or `p-6`
|
||||||
|
- Form field spacing: `space-y-4` (16px) between fields, `space-y-6` (24px) between groups
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Components
|
## Components
|
||||||
|
|
||||||
### Button
|
### Atoms
|
||||||
|
|
||||||
|
#### Button
|
||||||
|
|
||||||
Interactive button with variant/intent/size matrix.
|
Interactive button with variant/intent/size matrix.
|
||||||
|
|
||||||
@@ -199,7 +265,7 @@ import { Button } from '@/components/atoms/Button'
|
|||||||
<Button variant="tertiary" size="compact">Cancel</Button>
|
<Button variant="tertiary" size="compact">Cancel</Button>
|
||||||
```
|
```
|
||||||
|
|
||||||
### IconButton
|
#### IconButton
|
||||||
|
|
||||||
Icon-only button with required `aria-label`.
|
Icon-only button with required `aria-label`.
|
||||||
|
|
||||||
@@ -220,7 +286,7 @@ import { X, Settings } from 'lucide-react'
|
|||||||
<IconButton icon={<Settings />} aria-label="Settings" shape="square" size="compact" />
|
<IconButton icon={<Settings />} aria-label="Settings" shape="square" size="compact" />
|
||||||
```
|
```
|
||||||
|
|
||||||
### Input
|
#### Input
|
||||||
|
|
||||||
Text input with label, description, hint, error, and icon slots.
|
Text input with label, description, hint, error, and icon slots.
|
||||||
|
|
||||||
@@ -244,7 +310,7 @@ import { Search, Mail } from 'lucide-react'
|
|||||||
<Input label="Name" error="Name is required" />
|
<Input label="Name" error="Name is required" />
|
||||||
```
|
```
|
||||||
|
|
||||||
### Textarea
|
#### Textarea
|
||||||
|
|
||||||
Multi-line text input with optional auto-resize.
|
Multi-line text input with optional auto-resize.
|
||||||
|
|
||||||
@@ -265,7 +331,7 @@ import { Textarea } from '@/components/atoms/Textarea'
|
|||||||
<Textarea label="Description" autoResize error="Too short" />
|
<Textarea label="Description" autoResize error="Too short" />
|
||||||
```
|
```
|
||||||
|
|
||||||
### Select
|
#### Select
|
||||||
|
|
||||||
Custom dropdown select with keyboard navigation.
|
Custom dropdown select with keyboard navigation.
|
||||||
|
|
||||||
@@ -297,7 +363,41 @@ import { Select } from '@/components/atoms/Select'
|
|||||||
/>
|
/>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Checkbox
|
#### Autocomplete
|
||||||
|
|
||||||
|
Combobox input with filtering, keyboard navigation, and optional free-text entry.
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `label` | `string` | — | **Required.** Visible label |
|
||||||
|
| `description` | `string` | — | Help text below label |
|
||||||
|
| `hint` | `string` | — | Inline hint |
|
||||||
|
| `error` | `string` | — | Error message |
|
||||||
|
| `placeholder` | `string` | — | Placeholder text |
|
||||||
|
| `options` | `AutocompleteOption[]` | — | **Required.** `{ value, label, disabled? }` |
|
||||||
|
| `value` | `string` | — | Controlled value |
|
||||||
|
| `onChange` | `(value: string) => void` | — | Change handler |
|
||||||
|
| `freeSolo` | `boolean` | `false` | Allow arbitrary text input |
|
||||||
|
| `disabled` | `boolean` | `false` | Disables the input |
|
||||||
|
| `loading` | `boolean` | `false` | Shows loading state |
|
||||||
|
| `noResultsText` | `string` | `'No results found'` | Empty state message |
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Autocomplete } from '@/components/atoms/Autocomplete'
|
||||||
|
|
||||||
|
<Autocomplete
|
||||||
|
label="Country"
|
||||||
|
options={[
|
||||||
|
{ value: 'au', label: 'Australia' },
|
||||||
|
{ value: 'nz', label: 'New Zealand' },
|
||||||
|
{ value: 'uk', label: 'United Kingdom' },
|
||||||
|
]}
|
||||||
|
onChange={(val) => setCountry(val)}
|
||||||
|
/>
|
||||||
|
<Autocomplete label="Tags" freeSolo options={tagOptions} />
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Checkbox
|
||||||
|
|
||||||
Checkbox with label, description, error, and indeterminate state.
|
Checkbox with label, description, error, and indeterminate state.
|
||||||
|
|
||||||
@@ -316,7 +416,7 @@ import { Checkbox } from '@/components/atoms/Checkbox'
|
|||||||
<Checkbox label="Required" error="You must agree" />
|
<Checkbox label="Required" error="You must agree" />
|
||||||
```
|
```
|
||||||
|
|
||||||
### Radio / RadioGroup
|
#### Radio / RadioGroup
|
||||||
|
|
||||||
Radio buttons grouped with shared state.
|
Radio buttons grouped with shared state.
|
||||||
|
|
||||||
@@ -345,7 +445,7 @@ import { RadioGroup, Radio } from '@/components/atoms/Radio'
|
|||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Switch
|
#### Switch
|
||||||
|
|
||||||
Toggle switch with label and description.
|
Toggle switch with label and description.
|
||||||
|
|
||||||
@@ -364,7 +464,69 @@ import { Switch } from '@/components/atoms/Switch'
|
|||||||
<Switch label="Notifications" description="Receive email alerts" />
|
<Switch label="Notifications" description="Receive email alerts" />
|
||||||
```
|
```
|
||||||
|
|
||||||
### Badge
|
#### Slider
|
||||||
|
|
||||||
|
Single-value slider with step support and keyboard navigation.
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `label` | `string` | — | Visible label |
|
||||||
|
| `value` | `number` | — | **Required.** Current value |
|
||||||
|
| `onChange` | `(value: number) => void` | — | **Required.** Change handler |
|
||||||
|
| `min` | `number` | `0` | Minimum value |
|
||||||
|
| `max` | `number` | `100` | Maximum value |
|
||||||
|
| `step` | `number` | `1` | Step increment |
|
||||||
|
| `disabled` | `boolean` | `false` | Disables the slider |
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Slider } from '@/components/atoms/Slider'
|
||||||
|
|
||||||
|
<Slider label="Volume" value={volume} onChange={setVolume} min={0} max={100} />
|
||||||
|
<Slider label="Opacity" value={opacity} onChange={setOpacity} min={0} max={1} step={0.1} />
|
||||||
|
```
|
||||||
|
|
||||||
|
#### RangeSlider
|
||||||
|
|
||||||
|
Dual-thumb range slider for selecting a value range.
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `label` | `string` | — | Visible label |
|
||||||
|
| `value` | `[number, number]` | — | **Required.** Current range |
|
||||||
|
| `onChange` | `(value: [number, number]) => void` | — | **Required.** Change handler |
|
||||||
|
| `min` | `number` | `0` | Minimum value |
|
||||||
|
| `max` | `number` | `100` | Maximum value |
|
||||||
|
| `step` | `number` | `1` | Step increment |
|
||||||
|
| `disabled` | `boolean` | `false` | Disables the slider |
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { RangeSlider } from '@/components/atoms/Slider'
|
||||||
|
|
||||||
|
<RangeSlider label="Price range" value={[20, 80]} onChange={setRange} min={0} max={200} />
|
||||||
|
```
|
||||||
|
|
||||||
|
#### FileInput
|
||||||
|
|
||||||
|
File picker with multi-file support and removable file tags.
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `label` | `string` | — | **Required.** Visible label |
|
||||||
|
| `description` | `string` | — | Help text |
|
||||||
|
| `error` | `string` | — | Error message |
|
||||||
|
| `accept` | `string` | — | Accepted file types (e.g., `.pdf,.docx`) |
|
||||||
|
| `multiple` | `boolean` | `false` | Allow multiple files |
|
||||||
|
| `disabled` | `boolean` | `false` | Disables the input |
|
||||||
|
| `onChange` | `(files: File[]) => void` | — | Change handler |
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { FileInput } from '@/components/atoms/FileInput'
|
||||||
|
|
||||||
|
<FileInput label="Upload documents" accept=".pdf,.docx" multiple onChange={setFiles} />
|
||||||
|
<FileInput label="Profile photo" accept="image/*" />
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Badge
|
||||||
|
|
||||||
Small status indicator with colour variants.
|
Small status indicator with colour variants.
|
||||||
|
|
||||||
@@ -382,32 +544,14 @@ import { Badge } from '@/components/atoms/Badge'
|
|||||||
<Badge variant="info" leftIcon={<Clock />}>Pending</Badge>
|
<Badge variant="info" leftIcon={<Clock />}>Pending</Badge>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Chip
|
#### Tag
|
||||||
|
|
||||||
Selectable/dismissible filter chip.
|
|
||||||
|
|
||||||
| Prop | Type | Default | Description |
|
|
||||||
|------|------|---------|-------------|
|
|
||||||
| `children` | `ReactNode` | — | **Required.** Chip label |
|
|
||||||
| `selected` | `boolean` | `false` | Selected state |
|
|
||||||
| `onDismiss` | `() => void` | — | Shows dismiss icon, makes removable |
|
|
||||||
| `rightIcon` | `ReactNode` | — | Custom right icon |
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { Chip } from '@/components/atoms/Chip'
|
|
||||||
|
|
||||||
<Chip selected={isActive} onClick={toggle}>Qualitative</Chip>
|
|
||||||
<Chip onDismiss={() => removeFilter('date')}>2024</Chip>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tag
|
|
||||||
|
|
||||||
Coloured label for categorisation.
|
Coloured label for categorisation.
|
||||||
|
|
||||||
| Prop | Type | Default | Description |
|
| Prop | Type | Default | Description |
|
||||||
|------|------|---------|-------------|
|
|------|------|---------|-------------|
|
||||||
| `variant` | `'outline' \| 'filled' \| 'light'` | `'outline'` | Visual style |
|
| `variant` | `'outline' \| 'filled' \| 'light'` | `'outline'` | Visual style |
|
||||||
| `color` | `'navy' \| 'blue' \| 'green' \| 'red' \| 'orange' \| 'grey'` | `'navy'` | Colour |
|
| `color` | `'navy' \| 'blue' \| 'green' \| 'red' \| 'orange' \| 'grey' \| 'teal' \| 'brown' \| 'purple' \| 'fuchsia' \| 'yellow'` | `'navy'` | Colour |
|
||||||
| `size` | `'default' \| 'sm'` | `'default'` | Tag size |
|
| `size` | `'default' \| 'sm'` | `'default'` | Tag size |
|
||||||
| `icon` | `ReactNode` | — | Leading icon |
|
| `icon` | `ReactNode` | — | Leading icon |
|
||||||
| `onRemove` | `() => void` | — | Shows remove button |
|
| `onRemove` | `() => void` | — | Shows remove button |
|
||||||
@@ -419,7 +563,103 @@ import { Tag } from '@/components/atoms/Tag'
|
|||||||
<Tag color="green" variant="filled" onRemove={handleRemove}>Complete</Tag>
|
<Tag color="green" variant="filled" onRemove={handleRemove}>Complete</Tag>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Tooltip
|
#### Chip
|
||||||
|
|
||||||
|
Selectable/dismissible filter chip with optional colour variants.
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `children` | `ReactNode` | — | **Required.** Chip label |
|
||||||
|
| `selected` | `boolean` | `false` | Selected state |
|
||||||
|
| `color` | `'default' \| 'info' \| 'error' \| 'warning' \| 'success'` | — | Colour variant (unselected only) |
|
||||||
|
| `onDismiss` | `() => void` | — | Shows dismiss icon, makes removable |
|
||||||
|
| `rightIcon` | `ReactNode` | — | Custom right icon |
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Chip } from '@/components/atoms/Chip'
|
||||||
|
|
||||||
|
<Chip selected={isActive} onClick={toggle}>Qualitative</Chip>
|
||||||
|
<Chip onDismiss={() => removeFilter('date')}>2024</Chip>
|
||||||
|
<Chip color="success">Approved</Chip>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Tabs
|
||||||
|
|
||||||
|
Tab navigation with controlled selection. Compound component: `Tabs`, `TabList`, `Tab`, `TabPanel`.
|
||||||
|
|
||||||
|
| Prop (Tabs) | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `value` | `string` | — | **Required.** Active tab value |
|
||||||
|
| `onChange` | `(value: string) => void` | — | **Required.** Tab change handler |
|
||||||
|
|
||||||
|
| Prop (Tab) | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `value` | `string` | — | **Required.** Tab identifier |
|
||||||
|
| `icon` | `ReactNode` | — | Icon before label |
|
||||||
|
| `disabled` | `boolean` | `false` | Disables the tab |
|
||||||
|
|
||||||
|
| Prop (TabPanel) | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `value` | `string` | — | **Required.** Must match a Tab value |
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Tabs, TabList, Tab, TabPanel } from '@/components/atoms/Tabs'
|
||||||
|
|
||||||
|
<Tabs value={activeTab} onChange={setActiveTab}>
|
||||||
|
<TabList>
|
||||||
|
<Tab value="overview">Overview</Tab>
|
||||||
|
<Tab value="details" icon={<Info />}>Details</Tab>
|
||||||
|
<Tab value="settings">Settings</Tab>
|
||||||
|
</TabList>
|
||||||
|
<TabPanel value="overview">Overview content</TabPanel>
|
||||||
|
<TabPanel value="details">Details content</TabPanel>
|
||||||
|
<TabPanel value="settings">Settings content</TabPanel>
|
||||||
|
</Tabs>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### List
|
||||||
|
|
||||||
|
Vertical list with icon support, active state, and optional links. Compound component: `List`, `ListItem`, `ListSubheader`, `ListDivider`.
|
||||||
|
|
||||||
|
| Prop (ListItem) | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `icon` | `ReactNode` | — | Leading icon |
|
||||||
|
| `active` | `boolean` | `false` | Highlighted state |
|
||||||
|
| `disabled` | `boolean` | `false` | Disables the item |
|
||||||
|
| `href` | `string` | — | Renders as `<a>` instead of `<li>` |
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { List, ListItem, ListSubheader, ListDivider } from '@/components/atoms/List'
|
||||||
|
import { Home, Settings, Users } from 'lucide-react'
|
||||||
|
|
||||||
|
<List>
|
||||||
|
<ListSubheader>Navigation</ListSubheader>
|
||||||
|
<ListItem icon={<Home />} active>Dashboard</ListItem>
|
||||||
|
<ListItem icon={<Users />}>Team</ListItem>
|
||||||
|
<ListDivider />
|
||||||
|
<ListItem icon={<Settings />}>Settings</ListItem>
|
||||||
|
</List>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Avatar
|
||||||
|
|
||||||
|
Circular avatar showing initials or an image.
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `initials` | `string` | — | **Required.** Fallback text (first 2 chars, uppercased) |
|
||||||
|
| `src` | `string` | — | Image URL |
|
||||||
|
| `alt` | `string` | — | Image alt text |
|
||||||
|
| `size` | `'sm' \| 'default' \| 'lg'` | `'default'` | Avatar dimensions (32/40/48px) |
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Avatar } from '@/components/atoms/Avatar'
|
||||||
|
|
||||||
|
<Avatar initials="JD" />
|
||||||
|
<Avatar initials="MM" src="/photos/mary.jpg" alt="Mary McKay" size="lg" />
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Tooltip
|
||||||
|
|
||||||
Floating tooltip on hover/focus with arrow.
|
Floating tooltip on hover/focus with arrow.
|
||||||
|
|
||||||
@@ -438,9 +678,11 @@ import { Tooltip } from '@/components/atoms/Tooltip'
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Card
|
### Molecules
|
||||||
|
|
||||||
Container with variant styles. Composed of `Card`, `CardHeader`, `CardContent`, `CardFooter`.
|
#### Card
|
||||||
|
|
||||||
|
Container with variant styles. Compound component: `Card`, `CardHeader`, `CardTitle`, `CardDescription`, `CardContent`, `CardFooter`.
|
||||||
|
|
||||||
| Prop (Card) | Type | Default | Description |
|
| Prop (Card) | Type | Default | Description |
|
||||||
|------|------|---------|-------------|
|
|------|------|---------|-------------|
|
||||||
@@ -451,11 +693,12 @@ Container with variant styles. Composed of `Card`, `CardHeader`, `CardContent`,
|
|||||||
| `action` | `ReactNode` | — | Action element (top-right) |
|
| `action` | `ReactNode` | — | Action element (top-right) |
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Card, CardHeader, CardContent, CardFooter } from '@/components/molecules/Card'
|
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/molecules/Card'
|
||||||
|
|
||||||
<Card variant="elevated">
|
<Card variant="elevated">
|
||||||
<CardHeader action={<IconButton icon={<MoreHorizontal />} aria-label="Options" variant="tertiary" />}>
|
<CardHeader action={<IconButton icon={<MoreHorizontal />} aria-label="Options" variant="tertiary" />}>
|
||||||
<h3>Card Title</h3>
|
<CardTitle>Card Title</CardTitle>
|
||||||
|
<CardDescription>Supporting description text</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p>Card body content goes here.</p>
|
<p>Card body content goes here.</p>
|
||||||
@@ -466,9 +709,9 @@ import { Card, CardHeader, CardContent, CardFooter } from '@/components/molecule
|
|||||||
</Card>
|
</Card>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Accordion
|
#### Accordion
|
||||||
|
|
||||||
Collapsible content sections. Composed of `Accordion`, `AccordionItem`, `AccordionTrigger`, `AccordionContent`.
|
Collapsible content sections. Compound component: `Accordion`, `AccordionItem`, `AccordionTrigger`, `AccordionContent`.
|
||||||
|
|
||||||
| Prop (Accordion) | Type | Default | Description |
|
| Prop (Accordion) | Type | Default | Description |
|
||||||
|------|------|---------|-------------|
|
|------|------|---------|-------------|
|
||||||
@@ -498,7 +741,7 @@ import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/
|
|||||||
</Accordion>
|
</Accordion>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Alert
|
#### Alert
|
||||||
|
|
||||||
Contextual message with icon, title, close button, and action slot.
|
Contextual message with icon, title, close button, and action slot.
|
||||||
|
|
||||||
@@ -519,9 +762,9 @@ import { Alert } from '@/components/molecules/Alert'
|
|||||||
<Alert variant="error" title="Error">Something went wrong.</Alert>
|
<Alert variant="error" title="Error">Something went wrong.</Alert>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Dialog
|
#### Dialog
|
||||||
|
|
||||||
Modal dialog with backdrop, size options, and composed sections.
|
Modal dialog with backdrop, size options, and composed sections. Uses the native `<dialog>` element. Compound component: `Dialog`, `DialogHeader`, `DialogTitle`, `DialogDescription`, `DialogContent`, `DialogFooter`, `DialogClose`.
|
||||||
|
|
||||||
| Prop (Dialog) | Type | Default | Description |
|
| Prop (Dialog) | Type | Default | Description |
|
||||||
|------|------|---------|-------------|
|
|------|------|---------|-------------|
|
||||||
@@ -530,14 +773,24 @@ Modal dialog with backdrop, size options, and composed sections.
|
|||||||
| `size` | `'sm' \| 'default' \| 'lg' \| 'full'` | `'default'` | Max width |
|
| `size` | `'sm' \| 'default' \| 'lg' \| 'full'` | `'default'` | Max width |
|
||||||
| `closeOnBackdrop` | `boolean` | `true` | Close when clicking backdrop |
|
| `closeOnBackdrop` | `boolean` | `true` | Close when clicking backdrop |
|
||||||
|
|
||||||
Composed with `DialogHeader`, `DialogContent`, `DialogFooter`.
|
| Prop (DialogHeader) | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `onClose` | `() => void` | — | Shows close button in header |
|
||||||
|
|
||||||
|
| Prop (DialogClose) | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `onClose` | `() => void` | — | **Required.** Close handler |
|
||||||
|
| `children` | `ReactNode` | — | **Required.** Button content |
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Dialog, DialogHeader, DialogContent, DialogFooter } from '@/components/molecules/Dialog'
|
import { Dialog, DialogHeader, DialogTitle, DialogDescription, DialogContent, DialogFooter } from '@/components/molecules/Dialog'
|
||||||
|
|
||||||
<Dialog open={isOpen} onClose={() => setIsOpen(false)} size="sm">
|
<Dialog open={isOpen} onClose={() => setIsOpen(false)} size="sm">
|
||||||
<DialogHeader onClose={() => setIsOpen(false)}>Confirm</DialogHeader>
|
<DialogHeader onClose={() => setIsOpen(false)}>
|
||||||
<DialogContent>Are you sure you want to delete this?</DialogContent>
|
<DialogTitle>Confirm deletion</DialogTitle>
|
||||||
|
<DialogDescription>This action cannot be undone.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogContent>Are you sure you want to delete this item?</DialogContent>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="tertiary" onClick={() => setIsOpen(false)}>Cancel</Button>
|
<Button variant="tertiary" onClick={() => setIsOpen(false)}>Cancel</Button>
|
||||||
<Button intent="danger">Delete</Button>
|
<Button intent="danger">Delete</Button>
|
||||||
@@ -545,9 +798,9 @@ import { Dialog, DialogHeader, DialogContent, DialogFooter } from '@/components/
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Popover
|
#### Popover
|
||||||
|
|
||||||
Floating content panel triggered by a child element. Composed of `Popover`, `PopoverTrigger`, `PopoverContent`.
|
Floating content panel triggered by a child element. Compound component: `Popover`, `PopoverTrigger`, `PopoverContent`, `PopoverClose`.
|
||||||
|
|
||||||
| Prop (Popover) | Type | Default | Description |
|
| Prop (Popover) | Type | Default | Description |
|
||||||
|------|------|---------|-------------|
|
|------|------|---------|-------------|
|
||||||
@@ -555,6 +808,11 @@ Floating content panel triggered by a child element. Composed of `Popover`, `Pop
|
|||||||
| `open` | `boolean` | — | Controlled open state |
|
| `open` | `boolean` | — | Controlled open state |
|
||||||
| `onOpenChange` | `(open: boolean) => void` | — | Open state handler |
|
| `onOpenChange` | `(open: boolean) => void` | — | Open state handler |
|
||||||
|
|
||||||
|
| Prop (PopoverClose) | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `onClose` | `() => void` | — | **Required.** Close handler |
|
||||||
|
| `children` | `ReactNode` | — | **Required.** Button content |
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Popover, PopoverTrigger, PopoverContent } from '@/components/molecules/Popover'
|
import { Popover, PopoverTrigger, PopoverContent } from '@/components/molecules/Popover'
|
||||||
|
|
||||||
@@ -568,6 +826,251 @@ import { Popover, PopoverTrigger, PopoverContent } from '@/components/molecules/
|
|||||||
</Popover>
|
</Popover>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### DataTable
|
||||||
|
|
||||||
|
Data table with sorting, row selection, and pagination. Generic component supporting typed row data.
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `columns` | `DataTableColumn<T>[]` | — | **Required.** Column definitions |
|
||||||
|
| `data` | `T[]` | — | **Required.** Row data array |
|
||||||
|
| `selectable` | `boolean` | `false` | Enable row selection with checkboxes |
|
||||||
|
| `pagination` | `boolean` | `true` | Show pagination controls |
|
||||||
|
| `pageSize` | `number` | `5` | Initial rows per page |
|
||||||
|
| `pageSizeOptions` | `number[]` | `[5, 10, 25]` | Page size dropdown options |
|
||||||
|
| `loading` | `boolean` | `false` | Shows loading state |
|
||||||
|
| `emptyMessage` | `string` | `'No data available'` | Empty state text |
|
||||||
|
| `onSelectionChange` | `(selected: T[]) => void` | — | Selection change handler |
|
||||||
|
|
||||||
|
**DataTableColumn:**
|
||||||
|
|
||||||
|
| Property | Type | Default | Description |
|
||||||
|
|----------|------|---------|-------------|
|
||||||
|
| `key` | `string` | — | **Required.** Row data key |
|
||||||
|
| `header` | `string` | — | **Required.** Column header text |
|
||||||
|
| `sortable` | `boolean` | `false` | Enable column sorting |
|
||||||
|
| `align` | `'left' \| 'center' \| 'right'` | `'left'` | Text alignment |
|
||||||
|
| `render` | `(value, row, index) => ReactNode` | — | Custom cell renderer |
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { DataTable } from '@/components/molecules/DataTable'
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={[
|
||||||
|
{ key: 'name', header: 'Name', sortable: true },
|
||||||
|
{ key: 'email', header: 'Email' },
|
||||||
|
{ key: 'role', header: 'Role', sortable: true },
|
||||||
|
{ key: 'status', header: 'Status', render: (val) => <Badge variant={val === 'active' ? 'success' : 'neutral'}>{val}</Badge> },
|
||||||
|
]}
|
||||||
|
data={users}
|
||||||
|
selectable
|
||||||
|
pageSize={10}
|
||||||
|
onSelectionChange={setSelected}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Organisms
|
||||||
|
|
||||||
|
#### TopBar
|
||||||
|
|
||||||
|
Fixed application header with logo, title, and action slots.
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `title` | `string` | — | **Required.** Application title |
|
||||||
|
| `leading` | `ReactNode` | — | Leading element (e.g., hamburger menu) |
|
||||||
|
| `logo` | `ReactNode` | — | Logo element next to title |
|
||||||
|
| `children` | `ReactNode` | — | Trailing actions (e.g., notifications, avatar) |
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { TopBar } from '@/components/organisms/TopBar'
|
||||||
|
|
||||||
|
<TopBar
|
||||||
|
title="Performance and development plan"
|
||||||
|
leading={<IconButton icon={<Menu />} aria-label="Menu" variant="tertiary" />}
|
||||||
|
logo={<img src="/nsw-logo.svg" alt="NSW" className="h-7" />}
|
||||||
|
>
|
||||||
|
<IconButton icon={<Bell />} aria-label="Notifications" variant="tertiary" />
|
||||||
|
<Avatar initials="MM" size="sm" />
|
||||||
|
</TopBar>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### SideNav
|
||||||
|
|
||||||
|
Collapsible sidebar navigation with grouped items, badges, and alert indicators. Compound component: `SideNav`, `SideNavItem`, `SideNavGroup`, `SideNavDivider`.
|
||||||
|
|
||||||
|
| Prop (SideNav) | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `collapsed` | `boolean` | `false` | Collapsed to icon-only mode (w-20) |
|
||||||
|
|
||||||
|
| Prop (SideNavItem) | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `icon` | `ReactNode` | — | Leading icon |
|
||||||
|
| `active` | `boolean` | `false` | Active/current state |
|
||||||
|
| `badge` | `number` | — | Count badge |
|
||||||
|
| `alert` | `boolean \| 'info' \| 'success' \| 'warning' \| 'error'` | — | Alert dot indicator |
|
||||||
|
| `href` | `string` | — | Renders as `<a>` instead of `<button>` |
|
||||||
|
|
||||||
|
| Prop (SideNavGroup) | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `icon` | `ReactNode` | — | **Required.** Group icon |
|
||||||
|
| `label` | `string` | — | **Required.** Group label |
|
||||||
|
| `defaultOpen` | `boolean` | `false` | Initially expanded |
|
||||||
|
| `badge` | `number` | — | Count badge |
|
||||||
|
| `alert` | `boolean \| 'info' \| 'success' \| 'warning' \| 'error'` | — | Alert dot indicator |
|
||||||
|
| `active` | `boolean` | `false` | Highlight the group trigger |
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { SideNav, SideNavItem, SideNavGroup, SideNavDivider } from '@/components/organisms/SideNav'
|
||||||
|
import { Home, FileText, Settings, Users } from 'lucide-react'
|
||||||
|
|
||||||
|
<SideNav collapsed={isCollapsed}>
|
||||||
|
<SideNavItem icon={<Home />} active>My status</SideNavItem>
|
||||||
|
<SideNavItem icon={<FileText />}>My details</SideNavItem>
|
||||||
|
<SideNavDivider />
|
||||||
|
<SideNavGroup icon={<Users />} label="PDP" defaultOpen>
|
||||||
|
<SideNavItem active>My PDP</SideNavItem>
|
||||||
|
<SideNavItem>PDP guide</SideNavItem>
|
||||||
|
<SideNavItem>Management</SideNavItem>
|
||||||
|
</SideNavGroup>
|
||||||
|
<SideNavItem icon={<Settings />}>Settings</SideNavItem>
|
||||||
|
</SideNav>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### PageHeader
|
||||||
|
|
||||||
|
Page title section with themed background, decorative arcs, and optional icon.
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `title` | `string` | — | **Required.** Page heading |
|
||||||
|
| `subtitle` | `string` | — | Supporting text below title |
|
||||||
|
| `icon` | `ReactNode` | — | Leading icon or illustration |
|
||||||
|
| `iconSize` | `string` | `'50px'` | Icon width/height |
|
||||||
|
| `theme` | `'light' \| 'dark'` | `'light'` | Background theme (light: blue-04 bg, dark: navy bg) |
|
||||||
|
| `centered` | `boolean` | `false` | Center-aligned layout |
|
||||||
|
| `noBackground` | `boolean` | `false` | No background or decorative arcs |
|
||||||
|
| `children` | `ReactNode` | — | Additional content below title |
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { PageHeader } from '@/components/organisms/PageHeader'
|
||||||
|
|
||||||
|
<PageHeader title="My Workspace" subtitle="Accreditation Level: Maintaining Proficient Teacher">
|
||||||
|
<div className="flex gap-4 mt-2">
|
||||||
|
<Badge variant="info">Maintaining</Badge>
|
||||||
|
<Badge variant="navy">5 Years</Badge>
|
||||||
|
</div>
|
||||||
|
</PageHeader>
|
||||||
|
<PageHeader title="Welcome" theme="dark" centered />
|
||||||
|
<PageHeader title="Settings" noBackground />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Templates
|
||||||
|
|
||||||
|
Templates are page-level layout components that define where content goes via typed slot props. They do not own content. Use them inside `AppShell` for full page layouts, or standalone for the content area only.
|
||||||
|
|
||||||
|
#### AppShell
|
||||||
|
|
||||||
|
Application shell that composes TopBar + SideNav + scrollable content area. All pages render inside this.
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `topBar` | `ReactNode` | — | **Required.** TopBar component |
|
||||||
|
| `sideNav` | `ReactNode` | — | **Required.** SideNav component |
|
||||||
|
| `sideNavCollapsed` | `boolean` | `false` | Whether SideNav is in icon-only mode |
|
||||||
|
| `children` | `ReactNode` | — | Scrollable content area |
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { AppShell } from '@/components/templates/AppShell'
|
||||||
|
|
||||||
|
<AppShell
|
||||||
|
topBar={<TopBar title="My App" />}
|
||||||
|
sideNav={<SideNav>{/* nav items */}</SideNav>}
|
||||||
|
>
|
||||||
|
{/* Page content */}
|
||||||
|
</AppShell>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### DashboardPage
|
||||||
|
|
||||||
|
Dashboard layout with stat summary row and responsive 2-column content grid.
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `header` | `ReactNode` | — | PageHeader or custom header |
|
||||||
|
| `stats` | `ReactNode` | — | Stat cards row (flex wrapped) |
|
||||||
|
| `children` | `ReactNode` | — | **Required.** Content grid items (2-col at lg) |
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { DashboardPage } from '@/components/templates/DashboardPage'
|
||||||
|
|
||||||
|
<DashboardPage
|
||||||
|
header={<PageHeader title="Dashboard" />}
|
||||||
|
stats={<><StatCard /><StatCard /><StatCard /></>}
|
||||||
|
>
|
||||||
|
<Card>Left column</Card>
|
||||||
|
<Card>Right column</Card>
|
||||||
|
</DashboardPage>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ListPage
|
||||||
|
|
||||||
|
List layout with stat summary, list header with actions, and scrollable item list.
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `header` | `ReactNode` | — | PageHeader or custom header |
|
||||||
|
| `stats` | `ReactNode` | — | Stat cards row |
|
||||||
|
| `listHeader` | `ReactNode` | — | Title + action button above the list |
|
||||||
|
| `children` | `ReactNode` | — | **Required.** List items (rendered inside divide-y container) |
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ListPage } from '@/components/templates/ListPage'
|
||||||
|
|
||||||
|
<ListPage
|
||||||
|
header={<PageHeader title="Activity Log" />}
|
||||||
|
stats={<><StatCard /><StatCard /></>}
|
||||||
|
listHeader={<div className="flex justify-between"><h2>Items</h2><Button>Add</Button></div>}
|
||||||
|
>
|
||||||
|
<div className="px-6 py-4">Item 1</div>
|
||||||
|
<div className="px-6 py-4">Item 2</div>
|
||||||
|
</ListPage>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### FormPage
|
||||||
|
|
||||||
|
Form layout with optional vertical stepper and constrained-width content.
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `header` | `ReactNode` | — | PageHeader or custom header |
|
||||||
|
| `actions` | `ReactNode` | — | Action bar above form (dropdowns, buttons) |
|
||||||
|
| `steps` | `FormPageStep[]` | — | Vertical stepper steps (`{ label, status }`) |
|
||||||
|
| `children` | `ReactNode` | — | **Required.** Form content (max-w-3xl centered) |
|
||||||
|
|
||||||
|
**FormPageStep:**
|
||||||
|
|
||||||
|
| Property | Type | Description |
|
||||||
|
|----------|------|-------------|
|
||||||
|
| `label` | `string` | Step label text |
|
||||||
|
| `status` | `'complete' \| 'current' \| 'upcoming'` | Step state |
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { FormPage } from '@/components/templates/FormPage'
|
||||||
|
|
||||||
|
<FormPage
|
||||||
|
header={<PageHeader title="Setup" />}
|
||||||
|
steps={[
|
||||||
|
{ label: 'Your details', status: 'current' },
|
||||||
|
{ label: 'Review', status: 'upcoming' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Card><CardContent><Input label="Name" /></CardContent></Card>
|
||||||
|
</FormPage>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Do's and Don'ts
|
## Do's and Don'ts
|
||||||
|
|
||||||
**Do:**
|
**Do:**
|
||||||
@@ -577,6 +1080,8 @@ import { Popover, PopoverTrigger, PopoverContent } from '@/components/molecules/
|
|||||||
- Use semantic HTML (`<button>`, `<input>`, `<dialog>`) — not `<div onClick>`
|
- Use semantic HTML (`<button>`, `<input>`, `<dialog>`) — not `<div onClick>`
|
||||||
- Include `aria-label` on icon-only buttons
|
- Include `aria-label` on icon-only buttons
|
||||||
- Use `lucide-react` for icons (already a dev dependency)
|
- Use `lucide-react` for icons (already a dev dependency)
|
||||||
|
- Compose pages using AppShell (TopBar + SideNav + content) as the outer layout
|
||||||
|
- Use the spacing conventions (p-6 content padding, gap-6 between sections)
|
||||||
|
|
||||||
**Don't:**
|
**Don't:**
|
||||||
- Hardcode colour hex values in component code
|
- Hardcode colour hex values in component code
|
||||||
@@ -584,3 +1089,4 @@ import { Popover, PopoverTrigger, PopoverContent } from '@/components/molecules/
|
|||||||
- Use CSS modules or styled-components
|
- Use CSS modules or styled-components
|
||||||
- Skip the `label` prop on form controls — all inputs must have visible labels
|
- Skip the `label` prop on form controls — all inputs must have visible labels
|
||||||
- Nest interactive elements (button inside button, link inside button)
|
- Nest interactive elements (button inside button, link inside button)
|
||||||
|
- Reference palette tokens (`text-blue-01`, `border-grey-03`) in component code — add a semantic token if one doesn't exist
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ export const SideNavItem = forwardRef<HTMLAnchorElement | HTMLButtonElement, Sid
|
|||||||
const isLink = 'href' in props && props.href !== undefined
|
const isLink = 'href' in props && props.href !== undefined
|
||||||
|
|
||||||
const styles = cn(
|
const styles = cn(
|
||||||
'relative flex items-center rounded-full transition-colors',
|
'relative flex items-center rounded-full text-left transition-colors',
|
||||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-info',
|
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-info',
|
||||||
isNested
|
isNested
|
||||||
? 'h-14 pl-14 pr-6'
|
? 'h-14 pl-14 pr-6'
|
||||||
@@ -271,7 +271,7 @@ export const SideNavGroup = forwardRef<HTMLDivElement, SideNavGroupProps>(
|
|||||||
const toggle = useCallback(() => setOpen((prev) => !prev), [])
|
const toggle = useCallback(() => setOpen((prev) => !prev), [])
|
||||||
|
|
||||||
const triggerStyles = cn(
|
const triggerStyles = cn(
|
||||||
'relative flex h-14 w-full items-center rounded-full transition-colors',
|
'relative flex h-14 w-full items-center rounded-full text-left transition-colors',
|
||||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-info',
|
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-info',
|
||||||
collapsed ? 'size-14 justify-center' : 'pl-4 pr-6',
|
collapsed ? 'size-14 justify-center' : 'pl-4 pr-6',
|
||||||
active && collapsed
|
active && collapsed
|
||||||
|
|||||||
95
src/components/templates/AppShell/AppShell.stories.tsx
Normal file
95
src/components/templates/AppShell/AppShell.stories.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { AppShell } from './AppShell'
|
||||||
|
import { TopBar } from '@/components/organisms/TopBar/TopBar'
|
||||||
|
import { SideNav, SideNavItem, SideNavGroup, SideNavDivider } from '@/components/organisms/SideNav/SideNav'
|
||||||
|
import { Avatar } from '@/components/atoms/Avatar/Avatar'
|
||||||
|
import { IconButton } from '@/components/atoms/IconButton/IconButton'
|
||||||
|
import { PageHeader } from '@/components/organisms/PageHeader/PageHeader'
|
||||||
|
import { Menu, Search, Bell, Home, FileText, LayoutGrid, Settings, Users, Link } from 'lucide-react'
|
||||||
|
|
||||||
|
const NswLogo = () => (
|
||||||
|
<div className="flex size-7 items-center justify-center rounded bg-white/20 text-caption font-bold text-white">NSW</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const meta: Meta<typeof AppShell> = {
|
||||||
|
title: 'Templates/AppShell',
|
||||||
|
component: AppShell,
|
||||||
|
tags: ['autodocs', 'template'],
|
||||||
|
parameters: {
|
||||||
|
layout: 'fullscreen',
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component: 'Application shell layout that composes TopBar + SideNav + scrollable content area. All page templates should be rendered inside an AppShell.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
export default meta
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof AppShell>
|
||||||
|
|
||||||
|
const SampleTopBar = ({ onMenuClick }: { onMenuClick?: () => void }) => (
|
||||||
|
<TopBar
|
||||||
|
title="My Application"
|
||||||
|
leading={<IconButton icon={<Menu />} aria-label="Toggle menu" variant="tertiary" onClick={onMenuClick} />}
|
||||||
|
logo={<NswLogo />}
|
||||||
|
>
|
||||||
|
<IconButton icon={<Search />} aria-label="Search" variant="tertiary" />
|
||||||
|
<IconButton icon={<Bell />} aria-label="Notifications" variant="tertiary" />
|
||||||
|
<Avatar initials="MM" size="sm" />
|
||||||
|
</TopBar>
|
||||||
|
)
|
||||||
|
|
||||||
|
const SampleSideNav = ({ collapsed }: { collapsed: boolean }) => (
|
||||||
|
<SideNav collapsed={collapsed}>
|
||||||
|
<SideNavItem icon={<Home />} active>My status</SideNavItem>
|
||||||
|
<SideNavItem icon={<FileText />}>My details</SideNavItem>
|
||||||
|
<SideNavItem icon={<LayoutGrid />}>Workspace</SideNavItem>
|
||||||
|
<SideNavDivider />
|
||||||
|
<SideNavGroup icon={<Users />} label="PDP" defaultOpen>
|
||||||
|
<SideNavItem>My PDP</SideNavItem>
|
||||||
|
<SideNavItem>PDP guide</SideNavItem>
|
||||||
|
<SideNavItem>Management</SideNavItem>
|
||||||
|
</SideNavGroup>
|
||||||
|
<SideNavItem icon={<Link />}>Resources</SideNavItem>
|
||||||
|
<SideNavItem icon={<Settings />}>Settings</SideNavItem>
|
||||||
|
</SideNav>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
return (
|
||||||
|
<AppShell
|
||||||
|
topBar={<SampleTopBar onMenuClick={() => setCollapsed(!collapsed)} />}
|
||||||
|
sideNav={<SampleSideNav collapsed={collapsed} />}
|
||||||
|
sideNavCollapsed={collapsed}
|
||||||
|
>
|
||||||
|
<PageHeader title="Dashboard" subtitle="Welcome back, Myra McKay" />
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="rounded-lg border border-border bg-surface p-8 text-center text-text-secondary">
|
||||||
|
Page content goes here
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Collapsed: Story = {
|
||||||
|
render: () => (
|
||||||
|
<AppShell
|
||||||
|
topBar={<SampleTopBar />}
|
||||||
|
sideNav={<SampleSideNav collapsed />}
|
||||||
|
sideNavCollapsed
|
||||||
|
>
|
||||||
|
<PageHeader title="Dashboard" subtitle="SideNav collapsed to icon-only mode" />
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="rounded-lg border border-border bg-surface p-8 text-center text-text-secondary">
|
||||||
|
Content area is wider with collapsed sidebar
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
),
|
||||||
|
}
|
||||||
30
src/components/templates/AppShell/AppShell.tsx
Normal file
30
src/components/templates/AppShell/AppShell.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface AppShellProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
/** TopBar component rendered fixed at the top */
|
||||||
|
topBar: ReactNode
|
||||||
|
/** SideNav component rendered in the left rail */
|
||||||
|
sideNav: ReactNode
|
||||||
|
/** Whether the SideNav is in collapsed (icon-only) mode */
|
||||||
|
sideNavCollapsed?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AppShell = forwardRef<HTMLDivElement, AppShellProps>(
|
||||||
|
({ topBar, sideNav, sideNavCollapsed = false, className, children, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn('flex h-screen flex-col bg-bg', className)} {...props}>
|
||||||
|
<div className="shrink-0">{topBar}</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
<aside className="flex shrink-0 overflow-y-auto">{sideNav}</aside>
|
||||||
|
|
||||||
|
<main className="flex-1 overflow-y-auto">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
AppShell.displayName = 'AppShell'
|
||||||
2
src/components/templates/AppShell/index.ts
Normal file
2
src/components/templates/AppShell/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { AppShell } from './AppShell'
|
||||||
|
export type { AppShellProps } from './AppShell'
|
||||||
193
src/components/templates/DashboardPage/DashboardPage.stories.tsx
Normal file
193
src/components/templates/DashboardPage/DashboardPage.stories.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { DashboardPage } from './DashboardPage'
|
||||||
|
import { AppShell } from '@/components/templates/AppShell/AppShell'
|
||||||
|
import { TopBar } from '@/components/organisms/TopBar/TopBar'
|
||||||
|
import { SideNav, SideNavItem, SideNavDivider } from '@/components/organisms/SideNav/SideNav'
|
||||||
|
import { PageHeader } from '@/components/organisms/PageHeader/PageHeader'
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/molecules/Card/Card'
|
||||||
|
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/molecules/Accordion/Accordion'
|
||||||
|
import { Badge } from '@/components/atoms/Badge/Badge'
|
||||||
|
import { Avatar } from '@/components/atoms/Avatar/Avatar'
|
||||||
|
import { IconButton } from '@/components/atoms/IconButton/IconButton'
|
||||||
|
import { Menu, Bell, Home, FileText, LayoutGrid, Users, CheckCircle, Clock, Info } from 'lucide-react'
|
||||||
|
|
||||||
|
const NswLogo = () => (
|
||||||
|
<div className="flex size-7 items-center justify-center rounded bg-white/20 text-caption font-bold text-white">NSW</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const meta: Meta<typeof DashboardPage> = {
|
||||||
|
title: 'Templates/DashboardPage',
|
||||||
|
component: DashboardPage,
|
||||||
|
tags: ['autodocs', 'template'],
|
||||||
|
parameters: {
|
||||||
|
layout: 'fullscreen',
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component: 'Dashboard page template with stat summary row and a responsive 2-column content grid. Use inside AppShell for the full page layout.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
export default meta
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof DashboardPage>
|
||||||
|
|
||||||
|
export const ProfessionalPathway: Story = {
|
||||||
|
name: 'Professional Pathway Dashboard',
|
||||||
|
render: () => {
|
||||||
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
return (
|
||||||
|
<AppShell
|
||||||
|
topBar={
|
||||||
|
<TopBar
|
||||||
|
title=""
|
||||||
|
leading={<IconButton icon={<Menu />} aria-label="Menu" variant="tertiary" onClick={() => setCollapsed(!collapsed)} />}
|
||||||
|
logo={<NswLogo />}
|
||||||
|
>
|
||||||
|
<IconButton icon={<Bell />} aria-label="Notifications" variant="tertiary" />
|
||||||
|
<Avatar initials="MM" size="sm" />
|
||||||
|
</TopBar>
|
||||||
|
}
|
||||||
|
sideNav={
|
||||||
|
<SideNav collapsed={collapsed}>
|
||||||
|
<SideNavItem icon={<Home />} active>My status</SideNavItem>
|
||||||
|
<SideNavItem icon={<Users />}>My details</SideNavItem>
|
||||||
|
<SideNavItem icon={<LayoutGrid />}>Workspace</SideNavItem>
|
||||||
|
<SideNavItem icon={<FileText />}>Resources</SideNavItem>
|
||||||
|
<SideNavDivider />
|
||||||
|
<SideNavItem icon={<Users />}>Accreditation</SideNavItem>
|
||||||
|
</SideNav>
|
||||||
|
}
|
||||||
|
sideNavCollapsed={collapsed}
|
||||||
|
>
|
||||||
|
<DashboardPage
|
||||||
|
header={
|
||||||
|
<PageHeader title="Myra McKay" subtitle="Accreditation Level: Proficient Teacher" theme="dark">
|
||||||
|
<div className="mt-2 text-small text-white/80">
|
||||||
|
Maroubra Junction Public School
|
||||||
|
</div>
|
||||||
|
</PageHeader>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Card variant="surface">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Steps to be taken</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<Accordion type="single" collapsible>
|
||||||
|
<AccordionItem value="s1">
|
||||||
|
<AccordionTrigger>Ensure you have completed the minimum requirements of your teaching degree as stated by NESA.</AccordionTrigger>
|
||||||
|
<AccordionContent>Details about teaching degree requirements.</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
<AccordionItem value="s2">
|
||||||
|
<AccordionTrigger>Apply for your Working With Children Check (WWCC).</AccordionTrigger>
|
||||||
|
<AccordionContent>Information about WWCC application.</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
<AccordionItem value="s3">
|
||||||
|
<AccordionTrigger>Create an eTAMS account and submit required documentation to NESA.</AccordionTrigger>
|
||||||
|
<AccordionContent>Steps for eTAMS registration.</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
<AccordionItem value="s4">
|
||||||
|
<AccordionTrigger>Pay your NESA fee.</AccordionTrigger>
|
||||||
|
<AccordionContent>Payment details.</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
<AccordionItem value="s5">
|
||||||
|
<AccordionTrigger>Complete Mandatory Training.</AccordionTrigger>
|
||||||
|
<AccordionContent>Training module details.</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<Card variant="surface">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Mandatory Training Reminders</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-between border-b border-border pb-3">
|
||||||
|
<span className="text-body font-medium">Aboriginal Cultural Education</span>
|
||||||
|
<Badge variant="success" leftIcon={<CheckCircle size={14} />}>Certified</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-small text-text-secondary">
|
||||||
|
Please consult the Mandatory Training Hub for role specific training, or contact the MyPL Helpdesk for queries regarding training.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card variant="elevated" className="bg-info/5">
|
||||||
|
<CardContent className="flex gap-4 p-5">
|
||||||
|
<Avatar initials="MK" size="lg" />
|
||||||
|
<div className="text-small">
|
||||||
|
<p className="font-medium text-text">
|
||||||
|
Hi I am Martha. I got my conditional accreditation recently through NESA. These links really helped me through the process.
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 flex flex-col gap-1">
|
||||||
|
<a href="#" className="text-info hover:underline">The resources that helped me</a>
|
||||||
|
<a href="#" className="text-info hover:underline">FAQ (questions I had)</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</DashboardPage>
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Standalone: Story = {
|
||||||
|
name: 'Without AppShell',
|
||||||
|
render: () => (
|
||||||
|
<DashboardPage
|
||||||
|
header={<PageHeader title="Dashboard" subtitle="Overview of your activity" />}
|
||||||
|
stats={
|
||||||
|
<>
|
||||||
|
<Card variant="surface" className="min-w-[180px] flex-1">
|
||||||
|
<CardContent className="flex items-center gap-4 p-5">
|
||||||
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
|
||||||
|
<Clock size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-h3 font-bold text-text">21h</p>
|
||||||
|
<p className="text-small text-text-secondary">Total hours logged</p>
|
||||||
|
<p className="text-caption text-text-secondary">Target 100h</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card variant="surface" className="min-w-[180px] flex-1">
|
||||||
|
<CardContent className="flex items-center gap-4 p-5">
|
||||||
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
|
||||||
|
<Clock size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-h3 font-bold text-text">18h</p>
|
||||||
|
<p className="text-small text-text-secondary">NESA Registered PD</p>
|
||||||
|
<p className="text-caption text-text-secondary">Target 60h</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card variant="surface" className="min-w-[180px] flex-1">
|
||||||
|
<CardContent className="flex items-center gap-4 p-5">
|
||||||
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
|
||||||
|
<Info size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-h3 font-bold text-text">5</p>
|
||||||
|
<p className="text-small text-text-secondary">Activities logged</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Card variant="surface">
|
||||||
|
<CardContent className="p-8 text-center text-text-secondary">Left column content</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card variant="surface">
|
||||||
|
<CardContent className="p-8 text-center text-text-secondary">Right column content</CardContent>
|
||||||
|
</Card>
|
||||||
|
</DashboardPage>
|
||||||
|
),
|
||||||
|
}
|
||||||
30
src/components/templates/DashboardPage/DashboardPage.tsx
Normal file
30
src/components/templates/DashboardPage/DashboardPage.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface DashboardPageProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
/** PageHeader or custom header section */
|
||||||
|
header?: ReactNode
|
||||||
|
/** Row of stat cards or summary widgets displayed above the content grid */
|
||||||
|
stats?: ReactNode
|
||||||
|
/** Two-column responsive content grid area */
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DashboardPage = forwardRef<HTMLDivElement, DashboardPageProps>(
|
||||||
|
({ header, stats, className, children, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn('flex flex-col', className)} {...props}>
|
||||||
|
{header}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-6 p-6">
|
||||||
|
{stats && <div className="flex flex-wrap gap-4">{stats}</div>}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DashboardPage.displayName = 'DashboardPage'
|
||||||
2
src/components/templates/DashboardPage/index.ts
Normal file
2
src/components/templates/DashboardPage/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { DashboardPage } from './DashboardPage'
|
||||||
|
export type { DashboardPageProps } from './DashboardPage'
|
||||||
198
src/components/templates/FormPage/FormPage.stories.tsx
Normal file
198
src/components/templates/FormPage/FormPage.stories.tsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { FormPage } from './FormPage'
|
||||||
|
import { AppShell } from '@/components/templates/AppShell/AppShell'
|
||||||
|
import { TopBar } from '@/components/organisms/TopBar/TopBar'
|
||||||
|
import { SideNav, SideNavItem, SideNavGroup, SideNavDivider } from '@/components/organisms/SideNav/SideNav'
|
||||||
|
import { PageHeader } from '@/components/organisms/PageHeader/PageHeader'
|
||||||
|
import { Card, CardContent } from '@/components/molecules/Card/Card'
|
||||||
|
import { Alert } from '@/components/molecules/Alert/Alert'
|
||||||
|
import { Input } from '@/components/atoms/Input/Input'
|
||||||
|
import { Select } from '@/components/atoms/Select/Select'
|
||||||
|
import { Button } from '@/components/atoms/Button/Button'
|
||||||
|
import { Badge } from '@/components/atoms/Badge/Badge'
|
||||||
|
import { Avatar } from '@/components/atoms/Avatar/Avatar'
|
||||||
|
import { IconButton } from '@/components/atoms/IconButton/IconButton'
|
||||||
|
import { Menu, Bell, Home, FileText, LayoutGrid, Users, Link, ArrowRight } from 'lucide-react'
|
||||||
|
|
||||||
|
const NswLogo = () => (
|
||||||
|
<div className="flex size-7 items-center justify-center rounded bg-white/20 text-caption font-bold text-white">NSW</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const meta: Meta<typeof FormPage> = {
|
||||||
|
title: 'Templates/FormPage',
|
||||||
|
component: FormPage,
|
||||||
|
tags: ['autodocs', 'template'],
|
||||||
|
parameters: {
|
||||||
|
layout: 'fullscreen',
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component: 'Form page template with optional vertical stepper and constrained-width form content. Use inside AppShell for the full page layout.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
export default meta
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof FormPage>
|
||||||
|
|
||||||
|
export const PDPDetails: Story = {
|
||||||
|
name: 'PDP Form',
|
||||||
|
render: () => {
|
||||||
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
return (
|
||||||
|
<AppShell
|
||||||
|
topBar={
|
||||||
|
<TopBar
|
||||||
|
title="Performance and development plan"
|
||||||
|
leading={<IconButton icon={<Menu />} aria-label="Menu" variant="tertiary" onClick={() => setCollapsed(!collapsed)} />}
|
||||||
|
logo={<NswLogo />}
|
||||||
|
>
|
||||||
|
<IconButton icon={<Bell />} aria-label="Notifications" variant="tertiary" />
|
||||||
|
<Avatar initials="DW" size="sm" />
|
||||||
|
</TopBar>
|
||||||
|
}
|
||||||
|
sideNav={
|
||||||
|
<SideNav collapsed={collapsed}>
|
||||||
|
<SideNavItem icon={<Home />}>My status</SideNavItem>
|
||||||
|
<SideNavItem icon={<Users />}>My details</SideNavItem>
|
||||||
|
<SideNavItem icon={<LayoutGrid />}>Workspace</SideNavItem>
|
||||||
|
<SideNavItem icon={<Link />}>Resources</SideNavItem>
|
||||||
|
<SideNavItem icon={<FileText />}>My documents & links</SideNavItem>
|
||||||
|
<SideNavDivider />
|
||||||
|
<SideNavGroup icon={<FileText />} label="PDP" defaultOpen active>
|
||||||
|
<SideNavItem active>My PDP</SideNavItem>
|
||||||
|
<SideNavItem>PDP guide</SideNavItem>
|
||||||
|
<SideNavItem>Management</SideNavItem>
|
||||||
|
<SideNavItem>Useful links</SideNavItem>
|
||||||
|
<SideNavItem>Support</SideNavItem>
|
||||||
|
</SideNavGroup>
|
||||||
|
</SideNav>
|
||||||
|
}
|
||||||
|
sideNavCollapsed={collapsed}
|
||||||
|
>
|
||||||
|
<FormPage
|
||||||
|
header={
|
||||||
|
<PageHeader title="Siya Ram" subtitle="Role title goes here" theme="dark">
|
||||||
|
<div className="mt-2 flex items-center gap-4">
|
||||||
|
<Badge variant="warning">Plan - In progress</Badge>
|
||||||
|
<span className="text-small text-white/80">Date commenced: dd-mm-yyyy</span>
|
||||||
|
</div>
|
||||||
|
</PageHeader>
|
||||||
|
}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Select
|
||||||
|
label=""
|
||||||
|
variant="stacked"
|
||||||
|
options={[{ value: '2026', label: '2026 - PDP Siya Ram' }]}
|
||||||
|
defaultValue="2026"
|
||||||
|
/>
|
||||||
|
<Button variant="secondary">More actions</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
steps={[
|
||||||
|
{ label: 'Your PDP details', status: 'current' },
|
||||||
|
{ label: 'Create your PDP', status: 'upcoming' },
|
||||||
|
{ label: 'Notify your PDP supervisor', status: 'upcoming' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Card variant="surface">
|
||||||
|
<CardContent className="space-y-6 p-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-h3 font-bold text-text">Welcome to your Performance and Development Plan (PDP)</h2>
|
||||||
|
<p className="mt-2 text-body text-text-secondary">
|
||||||
|
Once your goals are drafted and you're ready to share them, you can notify your PDP supervisor. Head to the Digital PDP page on the intranet to find key resources to help you complete your PDP.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Alert variant="info" title="Your PDP details">
|
||||||
|
Fill in the details below to get started with your PDP.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Select
|
||||||
|
label="PDP year"
|
||||||
|
options={[
|
||||||
|
{ value: '2026', label: '2026' },
|
||||||
|
{ value: '2025', label: '2025' },
|
||||||
|
]}
|
||||||
|
defaultValue="2026"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-body font-semibold text-text">Middle leader role(s)</p>
|
||||||
|
<p className="mb-2 text-small text-text-secondary">Some text about middle leader roles</p>
|
||||||
|
<Select
|
||||||
|
label="Middle leader role type (optional)"
|
||||||
|
options={[
|
||||||
|
{ value: 'deputy', label: 'Deputy Principal' },
|
||||||
|
{ value: 'head', label: 'Head Teacher' },
|
||||||
|
{ value: 'asst', label: 'Assistant Principal' },
|
||||||
|
]}
|
||||||
|
defaultValue="deputy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-body font-semibold text-text">Add your PDP supervisor's details here</p>
|
||||||
|
<p className="mb-2 text-small text-text-secondary">
|
||||||
|
Note: if your supervisor's name does not appear when you search for them, ask them to access the Digital PDP using their credentials, then try again.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Input label="PDP Supervisor's email" error="PDP Supervisor's email" defaultValue="dhoni.mahi@det.nsw.edu.au" />
|
||||||
|
<Input label="PDP Supervisor work location" error="PDP Supervisor work location" defaultValue="Work location goes here" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-body font-semibold text-text">Add your school or work location.</p>
|
||||||
|
<p className="mb-2 text-small text-text-secondary">
|
||||||
|
If you don't work in a school, add 'Education Office' as your work location.
|
||||||
|
</p>
|
||||||
|
<Input label="Your school or work location" error="Your school or work location" defaultValue="Work location goes here" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-small text-text-secondary">
|
||||||
|
<strong>Note:</strong> As the school leader, your principal can view all the POPs in the school.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex justify-start pt-2">
|
||||||
|
<Button rightIcon={<ArrowRight size={18} />}>Proceed</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</FormPage>
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SimpleForm: Story = {
|
||||||
|
name: 'Simple Form (no stepper)',
|
||||||
|
render: () => (
|
||||||
|
<FormPage
|
||||||
|
header={<PageHeader title="Create Account" subtitle="Set up your profile to get started" />}
|
||||||
|
>
|
||||||
|
<Card variant="surface">
|
||||||
|
<CardContent className="space-y-4 p-6">
|
||||||
|
<Input label="Full name" placeholder="Enter your full name" />
|
||||||
|
<Input label="Email address" type="email" placeholder="you@example.com" />
|
||||||
|
<Select
|
||||||
|
label="Role"
|
||||||
|
options={[
|
||||||
|
{ value: 'teacher', label: 'Teacher' },
|
||||||
|
{ value: 'principal', label: 'Principal' },
|
||||||
|
{ value: 'admin', label: 'Administrator' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-3 pt-4">
|
||||||
|
<Button variant="tertiary">Cancel</Button>
|
||||||
|
<Button>Create Account</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</FormPage>
|
||||||
|
),
|
||||||
|
}
|
||||||
81
src/components/templates/FormPage/FormPage.tsx
Normal file
81
src/components/templates/FormPage/FormPage.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface FormPageStep {
|
||||||
|
label: string
|
||||||
|
status: 'complete' | 'current' | 'upcoming'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormPageProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
/** PageHeader or custom header section */
|
||||||
|
header?: ReactNode
|
||||||
|
/** Action bar above the form content (e.g. dropdowns, buttons) */
|
||||||
|
actions?: ReactNode
|
||||||
|
/** Vertical stepper steps — renders a progress indicator alongside the form */
|
||||||
|
steps?: FormPageStep[]
|
||||||
|
/** Form content area */
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepIndicator = ({ step, index }: { step: FormPageStep; index: number }) => {
|
||||||
|
const base = 'flex size-8 shrink-0 items-center justify-center rounded-full text-small font-bold'
|
||||||
|
const styles = {
|
||||||
|
complete: 'bg-success text-white',
|
||||||
|
current: 'bg-info text-white',
|
||||||
|
upcoming: 'bg-grey-04 text-text-secondary',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className={cn(base, styles[step.status])}>
|
||||||
|
{step.status === 'complete' ? (
|
||||||
|
<svg className="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
index + 1
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className={cn(
|
||||||
|
'pt-1 text-small',
|
||||||
|
step.status === 'current' ? 'font-bold text-text' : 'text-text-secondary',
|
||||||
|
)}>
|
||||||
|
{step.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FormPage = forwardRef<HTMLDivElement, FormPageProps>(
|
||||||
|
({ header, actions, steps, className, children, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn('flex flex-col', className)} {...props}>
|
||||||
|
{header}
|
||||||
|
|
||||||
|
{actions && (
|
||||||
|
<div className="flex items-center justify-between gap-4 border-b border-border px-6 py-3">
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1 p-6">
|
||||||
|
<div className="mx-auto max-w-3xl">
|
||||||
|
{steps ? (
|
||||||
|
<div className="flex gap-8">
|
||||||
|
<nav className="flex w-48 shrink-0 flex-col gap-4" aria-label="Form steps">
|
||||||
|
{steps.map((step, i) => (
|
||||||
|
<StepIndicator key={step.label} step={step} index={i} />
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className="min-w-0 flex-1">{children}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
FormPage.displayName = 'FormPage'
|
||||||
2
src/components/templates/FormPage/index.ts
Normal file
2
src/components/templates/FormPage/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { FormPage } from './FormPage'
|
||||||
|
export type { FormPageProps, FormPageStep } from './FormPage'
|
||||||
169
src/components/templates/ListPage/ListPage.stories.tsx
Normal file
169
src/components/templates/ListPage/ListPage.stories.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { ListPage } from './ListPage'
|
||||||
|
import { AppShell } from '@/components/templates/AppShell/AppShell'
|
||||||
|
import { TopBar } from '@/components/organisms/TopBar/TopBar'
|
||||||
|
import { SideNav, SideNavItem, SideNavDivider } from '@/components/organisms/SideNav/SideNav'
|
||||||
|
import { PageHeader } from '@/components/organisms/PageHeader/PageHeader'
|
||||||
|
import { Card, CardContent } from '@/components/molecules/Card/Card'
|
||||||
|
import { Badge } from '@/components/atoms/Badge/Badge'
|
||||||
|
import { Tag } from '@/components/atoms/Tag/Tag'
|
||||||
|
import { Button } from '@/components/atoms/Button/Button'
|
||||||
|
import { Avatar } from '@/components/atoms/Avatar/Avatar'
|
||||||
|
import { IconButton } from '@/components/atoms/IconButton/IconButton'
|
||||||
|
import { Menu, Bell, Home, LayoutGrid, FileText, Users, Clock, BarChart3, Plus, Check } from 'lucide-react'
|
||||||
|
|
||||||
|
const NswLogo = () => (
|
||||||
|
<div className="flex size-7 items-center justify-center rounded bg-white/20 text-caption font-bold text-white">NSW</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const ActivityItem = ({ title, hours, date }: { title: string; hours: string; date: string }) => (
|
||||||
|
<div className="flex flex-col gap-2 px-6 py-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<a href="#" className="text-body font-semibold text-info hover:underline">{title}</a>
|
||||||
|
<div className="flex shrink-0 flex-col items-end gap-1 text-small text-text-secondary">
|
||||||
|
<span>{hours}</span>
|
||||||
|
<span>{date}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Tag color="navy" variant="filled" size="sm">NSW DoE</Tag>
|
||||||
|
<Badge variant="success" leftIcon={<Check size={14} />}>Registered</Badge>
|
||||||
|
<Tag color="blue" variant="filled" size="sm">S1</Tag>
|
||||||
|
<Tag color="orange" variant="filled" size="sm">s4</Tag>
|
||||||
|
<Tag color="green" variant="filled" size="sm">S6</Tag>
|
||||||
|
</div>
|
||||||
|
<p className="text-small text-text-secondary">
|
||||||
|
Lorem dolor sit amet, consectetur adipiscing elit. Donec condimentum nulla gravida pretium libero. Proin in felis consectetur, laoreet est eu, consectetur mi.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const meta: Meta<typeof ListPage> = {
|
||||||
|
title: 'Templates/ListPage',
|
||||||
|
component: ListPage,
|
||||||
|
tags: ['autodocs', 'template'],
|
||||||
|
parameters: {
|
||||||
|
layout: 'fullscreen',
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component: 'List page template with stat summary row, list header with actions, and a scrollable item list. Use inside AppShell for the full page layout.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
export default meta
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof ListPage>
|
||||||
|
|
||||||
|
export const PDLog: Story = {
|
||||||
|
name: 'PD Log',
|
||||||
|
render: () => {
|
||||||
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
return (
|
||||||
|
<AppShell
|
||||||
|
topBar={
|
||||||
|
<TopBar
|
||||||
|
title=""
|
||||||
|
leading={<IconButton icon={<Menu />} aria-label="Menu" variant="tertiary" onClick={() => setCollapsed(!collapsed)} />}
|
||||||
|
logo={<NswLogo />}
|
||||||
|
>
|
||||||
|
<IconButton icon={<Bell />} aria-label="Notifications" variant="tertiary" />
|
||||||
|
<Avatar initials="JW" size="sm" />
|
||||||
|
</TopBar>
|
||||||
|
}
|
||||||
|
sideNav={
|
||||||
|
<SideNav collapsed={collapsed}>
|
||||||
|
<SideNavItem icon={<Home />} active>My status</SideNavItem>
|
||||||
|
<SideNavItem icon={<LayoutGrid />}>Workspace</SideNavItem>
|
||||||
|
<SideNavItem icon={<FileText />}>My details</SideNavItem>
|
||||||
|
<SideNavDivider />
|
||||||
|
<SideNavItem icon={<Users />}>Accreditation</SideNavItem>
|
||||||
|
</SideNav>
|
||||||
|
}
|
||||||
|
sideNavCollapsed={collapsed}
|
||||||
|
>
|
||||||
|
<ListPage
|
||||||
|
header={
|
||||||
|
<PageHeader title="Jane Williamson's Workspace" subtitle="Accreditation Level: Maintaining Proficient Teacher" theme="dark">
|
||||||
|
<div className="mt-2 text-small text-white/80">
|
||||||
|
Maroubra Junction Public School
|
||||||
|
</div>
|
||||||
|
</PageHeader>
|
||||||
|
}
|
||||||
|
stats={
|
||||||
|
<>
|
||||||
|
<Card variant="surface" className="min-w-[180px] flex-1">
|
||||||
|
<CardContent className="flex items-center gap-4 p-5">
|
||||||
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
|
||||||
|
<Clock size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-h3 font-bold text-text">21h</p>
|
||||||
|
<p className="text-small text-text-secondary">Total hours logged</p>
|
||||||
|
<p className="text-caption text-text-secondary">Target 100h</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card variant="surface" className="min-w-[180px] flex-1">
|
||||||
|
<CardContent className="flex items-center gap-4 p-5">
|
||||||
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
|
||||||
|
<Clock size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-h3 font-bold text-text">18h</p>
|
||||||
|
<p className="text-small text-text-secondary">NESA Registered PD</p>
|
||||||
|
<p className="text-caption text-text-secondary">Target 60h</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card variant="surface" className="min-w-[180px] flex-1">
|
||||||
|
<CardContent className="flex items-center gap-4 p-5">
|
||||||
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
|
||||||
|
<BarChart3 size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-h3 font-bold text-text">5</p>
|
||||||
|
<p className="text-small text-text-secondary">Activities logged</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
listHeader={
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-h4 font-bold text-text">My PD Log</h2>
|
||||||
|
<p className="text-small text-text-secondary">Log every professional learning activity — NESA Registered and school-based.</p>
|
||||||
|
</div>
|
||||||
|
<Button leftIcon={<Plus size={18} />}>Add Activity</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ActivityItem title="Trauma-informed practice" hours="8h" date="2024-02-20" />
|
||||||
|
<ActivityItem title="Trauma-informed practice" hours="8h" date="2024-02-20" />
|
||||||
|
<ActivityItem title="Trauma-informed practice" hours="8h" date="2024-02-20" />
|
||||||
|
<ActivityItem title="Trauma-informed practice" hours="8h" date="2024-02-20" />
|
||||||
|
</ListPage>
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Standalone: Story = {
|
||||||
|
name: 'Without AppShell',
|
||||||
|
render: () => (
|
||||||
|
<ListPage
|
||||||
|
header={<PageHeader title="Activity Log" subtitle="Your professional development activities" />}
|
||||||
|
listHeader={
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-h4 font-bold text-text">Activities</h2>
|
||||||
|
<Button size="compact" leftIcon={<Plus size={18} />}>Add</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ActivityItem title="Sample activity" hours="4h" date="2024-03-15" />
|
||||||
|
<ActivityItem title="Another activity" hours="2h" date="2024-03-10" />
|
||||||
|
</ListPage>
|
||||||
|
),
|
||||||
|
}
|
||||||
36
src/components/templates/ListPage/ListPage.tsx
Normal file
36
src/components/templates/ListPage/ListPage.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface ListPageProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
/** PageHeader or custom header section */
|
||||||
|
header?: ReactNode
|
||||||
|
/** Row of stat cards or summary widgets */
|
||||||
|
stats?: ReactNode
|
||||||
|
/** Section header area with title and optional action (e.g. "Add Activity" button) */
|
||||||
|
listHeader?: ReactNode
|
||||||
|
/** Scrollable list content area */
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ListPage = forwardRef<HTMLDivElement, ListPageProps>(
|
||||||
|
({ header, stats, listHeader, className, children, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn('flex flex-col', className)} {...props}>
|
||||||
|
{header}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-6 p-6">
|
||||||
|
{stats && <div className="flex flex-wrap gap-4">{stats}</div>}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{listHeader}
|
||||||
|
|
||||||
|
<div className="divide-y divide-border rounded-lg bg-surface">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
ListPage.displayName = 'ListPage'
|
||||||
2
src/components/templates/ListPage/index.ts
Normal file
2
src/components/templates/ListPage/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { ListPage } from './ListPage'
|
||||||
|
export type { ListPageProps } from './ListPage'
|
||||||
Reference in New Issue
Block a user