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:
2026-06-03 15:11:01 +10:00
parent d915443b8c
commit 95f72407f8
15 changed files with 1469 additions and 113 deletions

View File

@@ -182,7 +182,7 @@ export const SideNavItem = forwardRef<HTMLAnchorElement | HTMLButtonElement, Sid
const isLink = 'href' in props && props.href !== undefined
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',
isNested
? 'h-14 pl-14 pr-6'
@@ -271,7 +271,7 @@ export const SideNavGroup = forwardRef<HTMLDivElement, SideNavGroupProps>(
const toggle = useCallback(() => setOpen((prev) => !prev), [])
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',
collapsed ? 'size-14 justify-center' : 'pl-4 pr-6',
active && collapsed

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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