diff --git a/src/components/ui/Accordion/Accordion.stories.tsx b/src/components/ui/Accordion/Accordion.stories.tsx new file mode 100644 index 0000000..a1f87cc --- /dev/null +++ b/src/components/ui/Accordion/Accordion.stories.tsx @@ -0,0 +1,224 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { FileText, Settings, Users, Shield, Bell, HelpCircle } from 'lucide-react' +import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from './Accordion' + +const meta: Meta = { + title: 'UI/Accordion', + component: Accordion, + tags: ['autodocs'], + argTypes: { + type: { + control: 'select', + options: ['single', 'multiple'], + }, + collapsible: { control: 'boolean' }, + }, + parameters: { + design: { + type: 'figma', + url: 'https://www.figma.com/design/mrabO6AtxN3ektGiTk0I9c/ResearchInsights?node-id=100-1366', + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} + +export default meta +type Story = StoryObj + +// --- Default --- + +export const Default: Story = { + render: () => ( + + + What is this design system? + + A React component library built for the Research Synthesiser, following NSW Design System + patterns with custom tokens and Tailwind CSS v4. + + + + How are tokens structured? + + Tokens are organised in layers: palette (raw values), semantic (purpose-based aliases), and + domain-specific tokens for components like buttons, badges, and chips. + + + + Can I customise the components? + + Yes. All components accept className overrides and forward refs. Style customisation is done + through Tailwind utilities and the design token layer. + + + + ), +} + +// --- Multiple --- + +export const Multiple: Story = { + render: () => ( + + + First section + + Multiple items can be open simultaneously when type is set to "multiple". + + + + Second section + + Click any header to toggle it independently of the others. + + + + Third section + + This item is also open by default, demonstrating multiple default values. + + + + ), +} + +// --- With Icons --- + +export const WithIcons: Story = { + render: () => ( + + + }>Documents + + Manage your uploaded documents, PDFs, and research papers. + + + + }>Team members + + View and manage team members who have access to this project. + + + + }>Settings + + Configure project settings, notifications, and integrations. + + + + }>Security + + Manage access controls, permissions, and audit logs. + + + + ), +} + +// --- Disabled --- + +export const DisabledItem: Story = { + render: () => ( + + + Available section + This section is interactive and can be toggled. + + + Disabled section + This content cannot be revealed. + + + Another available section + This section is also interactive. + + + ), +} + +// --- Single non-collapsible --- + +export const SingleNonCollapsible: Story = { + render: () => ( + + + Always one open + + In single non-collapsible mode, one item is always open. Clicking the active header + does nothing — you must click a different header to switch. + + + + Click me to switch + Now this item is open and the previous one closed. + + + Or click me + Each click opens one and closes the other. + + + ), +} + +// --- Rich content --- + +export const RichContent: Story = { + render: () => ( + + + }>Notification preferences + +
+

Choose how you would like to be notified about project updates:

+
    +
  • Email digests (daily or weekly)
  • +
  • In-app notifications
  • +
  • Browser push notifications
  • +
+

+ You can change these settings at any time from your profile. +

+
+
+
+ + }>Frequently asked questions + +
+
+

How do I export my data?

+

Navigate to Settings → Export and choose your preferred format (CSV, JSON, or PDF).

+
+
+

Is my data encrypted?

+

Yes. All data is encrypted at rest and in transit using AES-256.

+
+
+
+
+
+ ), +} + +// --- Many items --- + +export const ManyItems: Story = { + render: () => ( + + {Array.from({ length: 8 }, (_, i) => ( + + Section {i + 1} + + Content for section {i + 1}. Each item expands independently in single collapsible mode. + + + ))} + + ), +} diff --git a/src/components/ui/Accordion/Accordion.tsx b/src/components/ui/Accordion/Accordion.tsx new file mode 100644 index 0000000..0f5f47f --- /dev/null +++ b/src/components/ui/Accordion/Accordion.tsx @@ -0,0 +1,243 @@ +import { + createContext, + forwardRef, + useCallback, + useContext, + useId, + useMemo, + useState, + type HTMLAttributes, + type ReactNode, +} from 'react' +import { cn } from '@/lib/utils' + +const ChevronIcon = () => ( + + + +) + +// --- Context --- + +interface AccordionContextValue { + openItems: Set + toggle: (value: string) => void +} + +const AccordionContext = createContext(null) + +function useAccordionContext() { + const ctx = useContext(AccordionContext) + if (!ctx) throw new Error('Accordion components must be used within an Accordion') + return ctx +} + +interface AccordionItemContextValue { + value: string + isOpen: boolean + triggerId: string + contentId: string + disabled: boolean +} + +const AccordionItemContext = createContext(null) + +function useAccordionItemContext() { + const ctx = useContext(AccordionItemContext) + if (!ctx) throw new Error('AccordionItem sub-components must be used within an AccordionItem') + return ctx +} + +// --- Accordion --- + +export interface AccordionProps extends HTMLAttributes { + type?: 'single' | 'multiple' + collapsible?: boolean + defaultValue?: string | string[] + value?: string | string[] + onValueChange?: (value: string | string[]) => void +} + +export const Accordion = forwardRef( + ( + { type = 'single', collapsible = false, defaultValue, value, onValueChange, className, children, ...props }, + ref, + ) => { + const [internalOpen, setInternalOpen] = useState>(() => { + if (defaultValue) return new Set(Array.isArray(defaultValue) ? defaultValue : [defaultValue]) + return new Set() + }) + + const isControlled = value !== undefined + const openItems = isControlled + ? new Set(Array.isArray(value) ? value : value ? [value] : []) + : internalOpen + + const toggle = useCallback( + (itemValue: string) => { + const compute = (prev: Set): Set => { + const next = new Set(prev) + if (next.has(itemValue)) { + if (type === 'single' && !collapsible) return prev + next.delete(itemValue) + } else { + if (type === 'single') next.clear() + next.add(itemValue) + } + return next + } + + if (isControlled) { + const next = compute(openItems) + if (next !== openItems) { + onValueChange?.(type === 'single' ? ([...next][0] ?? '') : [...next]) + } + } else { + setInternalOpen((prev) => { + const next = compute(prev) + if (next !== prev) { + onValueChange?.(type === 'single' ? ([...next][0] ?? '') : [...next]) + } + return next + }) + } + }, + [type, collapsible, isControlled, openItems, onValueChange], + ) + + const contextValue = useMemo(() => ({ openItems, toggle }), [openItems, toggle]) + + return ( + +
+ {children} +
+
+ ) + }, +) +Accordion.displayName = 'Accordion' + +// --- AccordionItem --- + +export interface AccordionItemProps extends HTMLAttributes { + value: string + disabled?: boolean +} + +export const AccordionItem = forwardRef( + ({ value, disabled = false, className, children, ...props }, ref) => { + const { openItems } = useAccordionContext() + const isOpen = openItems.has(value) + const id = useId() + const triggerId = `${id}-trigger` + const contentId = `${id}-content` + + const itemContext = useMemo( + () => ({ value, isOpen, triggerId, contentId, disabled }), + [value, isOpen, triggerId, contentId, disabled], + ) + + return ( + +
+ {children} +
+
+ ) + }, +) +AccordionItem.displayName = 'AccordionItem' + +// --- AccordionTrigger --- + +export interface AccordionTriggerProps extends Omit, 'children'> { + icon?: ReactNode + children: ReactNode +} + +export const AccordionTrigger = forwardRef( + ({ icon, className, children, ...props }, ref) => { + const { toggle } = useAccordionContext() + const { value, isOpen, triggerId, contentId, disabled } = useAccordionItemContext() + + return ( + + ) + }, +) +AccordionTrigger.displayName = 'AccordionTrigger' + +// --- AccordionContent --- + +export interface AccordionContentProps extends HTMLAttributes {} + +export const AccordionContent = forwardRef( + ({ className, children, ...props }, ref) => { + const { isOpen, triggerId, contentId } = useAccordionItemContext() + + return ( +
+
+
{children}
+
+
+ ) + }, +) +AccordionContent.displayName = 'AccordionContent' diff --git a/src/components/ui/Accordion/index.ts b/src/components/ui/Accordion/index.ts new file mode 100644 index 0000000..805ce4d --- /dev/null +++ b/src/components/ui/Accordion/index.ts @@ -0,0 +1,7 @@ +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from './Accordion' +export type { + AccordionProps, + AccordionItemProps, + AccordionTriggerProps, + AccordionContentProps, +} from './Accordion' diff --git a/src/components/ui/Card/Card.stories.tsx b/src/components/ui/Card/Card.stories.tsx new file mode 100644 index 0000000..e014492 --- /dev/null +++ b/src/components/ui/Card/Card.stories.tsx @@ -0,0 +1,257 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { ClipboardList, BookOpen, Info, ExternalLink } from 'lucide-react' +import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card' + +const meta: Meta = { + title: 'UI/Card', + component: Card, + tags: ['autodocs'], + argTypes: { + variant: { + control: 'select', + options: ['surface', 'outlined', 'elevated', 'filled'], + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => ( + + + Card title + A short description of the card content. + + +

+ This is the card body. It can contain any content — text, lists, forms, or other + components. +

+
+
+ ), +} + +// --- Variants --- + +export const Surface: Story = { + render: () => ( + + + Surface + + +

Default variant with border and subtle shadow.

+
+
+ ), +} + +export const Outlined: Story = { + render: () => ( + + + Outlined + + +

Border only, no shadow. Good for less prominent cards.

+
+
+ ), +} + +export const Elevated: Story = { + render: () => ( + + + Elevated + + +

Shadow only, no border. Creates a floating effect.

+
+
+ ), +} + +export const Filled: Story = { + render: () => ( + + + Professional pathway + + Track your progress through each stage. + + + +

+ Dark filled variant for featured or highlighted content sections. +

+
+
+ ), +} + +// --- With header action --- + +export const WithHeaderAction: Story = { + render: () => ( + + + + + } + > +
+ + Steps to be taken +
+
+ +
    +
  • + Apply and verify WWCC + +
  • +
  • + Fill a Registration Form + +
  • +
  • + Complete compliance modules + +
  • +
+
+
+ ), +} + +// --- With footer --- + +export const WithFooter: Story = { + render: () => ( + + + Mandatory Training Reminders + + Please consult the training hub for role-specific training requirements. + + + +
+ Aboriginal Cultural Education + Certified +
+
+ + + +
+ ), +} + +// --- Minimal --- + +export const ContentOnly: Story = { + render: () => ( + + +

+ A card with just content — no header or footer. Useful as a simple container. +

+
+
+ ), +} + +// --- Related information --- + +export const RelatedInformation: Story = { + render: () => ( + + + + + } + > +
+ + Related information +
+
+ +
    +
  • + Visit the Beginning Teacher Information Hub + +
  • +
  • + Apply for a role with DoE + +
  • +
  • + Start your accreditation journey + +
  • +
+
+
+ ), +} + +// --- All variants --- + +export const AllVariants: Story = { + render: () => ( +
+ + + Surface + + +

Border + subtle shadow

+
+
+ + + Outlined + + +

Border only

+
+
+ + + Elevated + + +

Shadow only

+
+
+ + + Filled + + +

Dark background, white text

+
+
+
+ ), +} diff --git a/src/components/ui/Card/Card.tsx b/src/components/ui/Card/Card.tsx new file mode 100644 index 0000000..31df479 --- /dev/null +++ b/src/components/ui/Card/Card.tsx @@ -0,0 +1,102 @@ +import { forwardRef, type HTMLAttributes } from 'react' +import { cn } from '@/lib/utils' + +// --- Card --- + +export interface CardProps extends HTMLAttributes { + variant?: 'surface' | 'outlined' | 'elevated' | 'filled' +} + +const variantStyles: Record = { + surface: 'bg-surface border border-border shadow-default', + outlined: 'bg-surface border border-border', + elevated: 'bg-surface shadow-md', + filled: 'bg-primary-dark text-white', +} + +export const Card = forwardRef( + ({ variant = 'surface', className, ...props }, ref) => ( +
+ ), +) +Card.displayName = 'Card' + +// --- CardHeader --- + +export interface CardHeaderProps extends HTMLAttributes { + action?: React.ReactNode +} + +export const CardHeader = forwardRef( + ({ action, className, children, ...props }, ref) => ( +
+
{children}
+ {action &&
{action}
} +
+ ), +) +CardHeader.displayName = 'CardHeader' + +// --- CardTitle --- + +export type CardTitleProps = HTMLAttributes + +export const CardTitle = forwardRef( + ({ className, ...props }, ref) => ( +

+ ), +) +CardTitle.displayName = 'CardTitle' + +// --- CardDescription --- + +export type CardDescriptionProps = HTMLAttributes + +export const CardDescription = forwardRef( + ({ className, ...props }, ref) => ( +

+ ), +) +CardDescription.displayName = 'CardDescription' + +// --- CardContent --- + +export type CardContentProps = HTMLAttributes + +export const CardContent = forwardRef( + ({ className, ...props }, ref) => ( +

+ ), +) +CardContent.displayName = 'CardContent' + +// --- CardFooter --- + +export type CardFooterProps = HTMLAttributes + +export const CardFooter = forwardRef( + ({ className, ...props }, ref) => ( +
+ ), +) +CardFooter.displayName = 'CardFooter' diff --git a/src/components/ui/Card/index.ts b/src/components/ui/Card/index.ts new file mode 100644 index 0000000..f814160 --- /dev/null +++ b/src/components/ui/Card/index.ts @@ -0,0 +1,9 @@ +export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card' +export type { + CardProps, + CardHeaderProps, + CardTitleProps, + CardDescriptionProps, + CardContentProps, + CardFooterProps, +} from './Card' diff --git a/src/components/ui/IconButton/IconButton.stories.tsx b/src/components/ui/IconButton/IconButton.stories.tsx index 00e5b56..a98ee9b 100644 --- a/src/components/ui/IconButton/IconButton.stories.tsx +++ b/src/components/ui/IconButton/IconButton.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react-vite' -import { X, Plus, Trash2, Menu, ChevronDown, Search, ArrowRight } from 'lucide-react' +import { X, Plus, Trash2, Menu, ChevronDown, Search, ArrowRight, MoreVertical, Copy, Maximize2 } from 'lucide-react' import { IconButton } from './IconButton' const meta: Meta = { @@ -17,7 +17,7 @@ const meta: Meta = { }, size: { control: 'select', - options: ['large', 'default', 'compact'], + options: ['large', 'default', 'compact', 'small', 'xsmall'], }, shape: { control: 'select', @@ -76,6 +76,19 @@ export const AllSizes: Story = { } aria-label="Close" /> } aria-label="Close" /> } aria-label="Close" /> + } aria-label="Close" /> + } aria-label="Close" /> +
+ ), +} + +export const CardActions: Story = { + render: () => ( +
+ Card title + } aria-label="Copy" /> + } aria-label="Expand" /> + } aria-label="More options" />
), } diff --git a/src/components/ui/IconButton/IconButton.tsx b/src/components/ui/IconButton/IconButton.tsx index a839edf..7c90baa 100644 --- a/src/components/ui/IconButton/IconButton.tsx +++ b/src/components/ui/IconButton/IconButton.tsx @@ -4,7 +4,7 @@ import { cn } from '@/lib/utils' export interface IconButtonProps extends Omit, 'children'> { variant?: 'primary' | 'secondary' | 'tertiary' intent?: 'default' | 'danger' | 'neutral' - size?: 'default' | 'large' | 'compact' + size?: 'default' | 'large' | 'compact' | 'small' | 'xsmall' shape?: 'circle' | 'square' icon: ReactNode 'aria-label': string @@ -35,12 +35,16 @@ const sizeStyles: Record = { large: 'size-14', default: 'size-12', compact: 'size-10', + small: 'size-8', + xsmall: 'size-6', } const iconSizeStyles: Record = { large: 'size-6', default: 'size-6', compact: 'size-[18px]', + small: 'size-4', + xsmall: 'size-3.5', } export const IconButton = forwardRef( diff --git a/src/components/ui/Input/Input.tsx b/src/components/ui/Input/Input.tsx index 38da674..286d7a9 100644 --- a/src/components/ui/Input/Input.tsx +++ b/src/components/ui/Input/Input.tsx @@ -96,8 +96,8 @@ export const Input = forwardRef( 'relative flex items-center rounded-[4px] border bg-control-bg px-3 transition-colors', styles.container, hasError - ? 'border-control-error focus-within:border-2 focus-within:border-control-error focus-within:px-[11px]' - : 'border-control-border hover:border-control-border-hover focus-within:border-2 focus-within:border-control-checked focus-within:px-[11px]', + ? 'border-control-error focus-within:ring-1 focus-within:ring-control-error' + : 'border-control-border hover:border-control-border-hover focus-within:border-control-checked focus-within:ring-1 focus-within:ring-control-checked', disabled && 'pointer-events-none border-control-border/50 bg-control-bg/50', readOnly && 'border-transparent bg-control-bg-readonly', )} diff --git a/src/components/ui/Select/Select.stories.tsx b/src/components/ui/Select/Select.stories.tsx new file mode 100644 index 0000000..07c7df7 --- /dev/null +++ b/src/components/ui/Select/Select.stories.tsx @@ -0,0 +1,209 @@ +import { useState } from 'react' +import type { Meta, StoryObj } from '@storybook/react-vite' +import { Select } from './Select' + +const yearLevels = [ + { value: 'kindergarten', label: 'Kindergarten' }, + { value: 'year-1', label: 'Year 1' }, + { value: 'year-2', label: 'Year 2' }, + { value: 'year-3', label: 'Year 3' }, + { value: 'year-4', label: 'Year 4' }, + { value: 'year-5', label: 'Year 5' }, + { value: 'year-6', label: 'Year 6' }, +] + +const categories = [ + { value: 'wellbeing', label: 'Student wellbeing' }, + { value: 'engagement', label: 'Engagement' }, + { value: 'curriculum', label: 'Curriculum design' }, + { value: 'assessment', label: 'Assessment practices' }, + { value: 'inclusion', label: 'Inclusion & diversity' }, +] + +const meta: Meta = { + title: 'UI/Select', + component: Select, + tags: ['autodocs'], + argTypes: { + variant: { + control: 'select', + options: ['outlined', 'stacked'], + }, + disabled: { control: 'boolean' }, + placeholder: { control: 'text' }, + error: { control: 'text' }, + hint: { control: 'text' }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + label: 'Year level', + options: yearLevels, + }, +} + +export const WithPlaceholder: Story = { + args: { + label: 'Year level', + placeholder: 'Choose a year level...', + options: yearLevels, + }, +} + +export const WithHint: Story = { + args: { + label: 'Research category', + hint: 'Choose the primary focus area for this project', + options: categories, + }, +} + +export const WithError: Story = { + args: { + label: 'Year level', + error: 'Please select a year level', + options: yearLevels, + }, +} + +export const WithDisabledOptions: Story = { + args: { + label: 'Year level', + options: [ + { value: 'kindergarten', label: 'Kindergarten' }, + { value: 'year-1', label: 'Year 1' }, + { value: 'year-2', label: 'Year 2', disabled: true }, + { value: 'year-3', label: 'Year 3' }, + { value: 'year-4', label: 'Year 4', disabled: true }, + { value: 'year-5', label: 'Year 5' }, + ], + }, +} + +export const Disabled: Story = { + args: { + label: 'Year level', + options: yearLevels, + disabled: true, + }, +} + +const PreselectedExample = () => { + const [value, setValue] = useState('year-2') + return ( + +

+ Selected: {value || '(none)'} +

+
+ ) +} + +export const Controlled: Story = { + render: () => , +} + +// --- Stacked variant --- + +export const Stacked: Story = { + args: { + label: 'Research category', + placeholder: 'Choose a category...', + options: categories, + variant: 'stacked', + }, +} + +export const StackedWithDescription: Story = { + args: { + label: 'Year level', + description: 'Select the year level this research applies to.', + options: yearLevels, + variant: 'stacked', + }, +} + +export const StackedWithError: Story = { + args: { + label: 'Year level', + description: 'Select the year level this research applies to.', + error: 'A year level is required', + options: yearLevels, + variant: 'stacked', + }, +} + +// --- All states --- + +export const AllStates: Story = { + render: () => ( +
+ + +
+ ), +} + +export const StackedAllStates: Story = { + render: () => ( +
+ + +