Add Accordion, Textarea, Select, and Card components; fix Input focus shift

New components:
- Accordion: compound component with single/multiple mode, grid-rows animation
- Textarea: multi-line input with auto-resize, character count, outlined/stacked variants
- Select: custom dropdown with keyboard navigation, combobox/listbox ARIA pattern
- Card: 4 variants (surface/outlined/elevated/filled) with header action support

Changes:
- Fix Input/Textarea focus ring layout shift (ring-1 instead of border-2)
- Add small/xsmall sizes to IconButton for card action contexts
- Add --radius-xl token (16px) for larger container corners

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 17:10:11 +10:00
parent e025c0eb34
commit 4be996789e
16 changed files with 1853 additions and 5 deletions

View File

@@ -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<typeof Accordion> = {
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) => (
<div className="max-w-xl">
<Story />
</div>
),
],
}
export default meta
type Story = StoryObj<typeof meta>
// --- Default ---
export const Default: Story = {
render: () => (
<Accordion type="single" collapsible defaultValue="item-1">
<AccordionItem value="item-1">
<AccordionTrigger>What is this design system?</AccordionTrigger>
<AccordionContent>
A React component library built for the Research Synthesiser, following NSW Design System
patterns with custom tokens and Tailwind CSS v4.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger>How are tokens structured?</AccordionTrigger>
<AccordionContent>
Tokens are organised in layers: palette (raw values), semantic (purpose-based aliases), and
domain-specific tokens for components like buttons, badges, and chips.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-3">
<AccordionTrigger>Can I customise the components?</AccordionTrigger>
<AccordionContent>
Yes. All components accept className overrides and forward refs. Style customisation is done
through Tailwind utilities and the design token layer.
</AccordionContent>
</AccordionItem>
</Accordion>
),
}
// --- Multiple ---
export const Multiple: Story = {
render: () => (
<Accordion type="multiple" defaultValue={['item-1', 'item-3']}>
<AccordionItem value="item-1">
<AccordionTrigger>First section</AccordionTrigger>
<AccordionContent>
Multiple items can be open simultaneously when type is set to &quot;multiple&quot;.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger>Second section</AccordionTrigger>
<AccordionContent>
Click any header to toggle it independently of the others.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-3">
<AccordionTrigger>Third section</AccordionTrigger>
<AccordionContent>
This item is also open by default, demonstrating multiple default values.
</AccordionContent>
</AccordionItem>
</Accordion>
),
}
// --- With Icons ---
export const WithIcons: Story = {
render: () => (
<Accordion type="single" collapsible>
<AccordionItem value="documents">
<AccordionTrigger icon={<FileText />}>Documents</AccordionTrigger>
<AccordionContent>
Manage your uploaded documents, PDFs, and research papers.
</AccordionContent>
</AccordionItem>
<AccordionItem value="team">
<AccordionTrigger icon={<Users />}>Team members</AccordionTrigger>
<AccordionContent>
View and manage team members who have access to this project.
</AccordionContent>
</AccordionItem>
<AccordionItem value="settings">
<AccordionTrigger icon={<Settings />}>Settings</AccordionTrigger>
<AccordionContent>
Configure project settings, notifications, and integrations.
</AccordionContent>
</AccordionItem>
<AccordionItem value="security">
<AccordionTrigger icon={<Shield />}>Security</AccordionTrigger>
<AccordionContent>
Manage access controls, permissions, and audit logs.
</AccordionContent>
</AccordionItem>
</Accordion>
),
}
// --- Disabled ---
export const DisabledItem: Story = {
render: () => (
<Accordion type="single" collapsible>
<AccordionItem value="item-1">
<AccordionTrigger>Available section</AccordionTrigger>
<AccordionContent>This section is interactive and can be toggled.</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2" disabled>
<AccordionTrigger>Disabled section</AccordionTrigger>
<AccordionContent>This content cannot be revealed.</AccordionContent>
</AccordionItem>
<AccordionItem value="item-3">
<AccordionTrigger>Another available section</AccordionTrigger>
<AccordionContent>This section is also interactive.</AccordionContent>
</AccordionItem>
</Accordion>
),
}
// --- Single non-collapsible ---
export const SingleNonCollapsible: Story = {
render: () => (
<Accordion type="single" defaultValue="item-1">
<AccordionItem value="item-1">
<AccordionTrigger>Always one open</AccordionTrigger>
<AccordionContent>
In single non-collapsible mode, one item is always open. Clicking the active header
does nothing you must click a different header to switch.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger>Click me to switch</AccordionTrigger>
<AccordionContent>Now this item is open and the previous one closed.</AccordionContent>
</AccordionItem>
<AccordionItem value="item-3">
<AccordionTrigger>Or click me</AccordionTrigger>
<AccordionContent>Each click opens one and closes the other.</AccordionContent>
</AccordionItem>
</Accordion>
),
}
// --- Rich content ---
export const RichContent: Story = {
render: () => (
<Accordion type="single" collapsible>
<AccordionItem value="notifications">
<AccordionTrigger icon={<Bell />}>Notification preferences</AccordionTrigger>
<AccordionContent>
<div className="flex flex-col gap-3">
<p>Choose how you would like to be notified about project updates:</p>
<ul className="list-inside list-disc space-y-1">
<li>Email digests (daily or weekly)</li>
<li>In-app notifications</li>
<li>Browser push notifications</li>
</ul>
<p className="text-small text-text-secondary">
You can change these settings at any time from your profile.
</p>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="faq">
<AccordionTrigger icon={<HelpCircle />}>Frequently asked questions</AccordionTrigger>
<AccordionContent>
<div className="flex flex-col gap-4">
<div>
<p className="font-bold text-text">How do I export my data?</p>
<p>Navigate to Settings Export and choose your preferred format (CSV, JSON, or PDF).</p>
</div>
<div>
<p className="font-bold text-text">Is my data encrypted?</p>
<p>Yes. All data is encrypted at rest and in transit using AES-256.</p>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
),
}
// --- Many items ---
export const ManyItems: Story = {
render: () => (
<Accordion type="single" collapsible>
{Array.from({ length: 8 }, (_, i) => (
<AccordionItem key={i} value={`item-${i}`}>
<AccordionTrigger>Section {i + 1}</AccordionTrigger>
<AccordionContent>
Content for section {i + 1}. Each item expands independently in single collapsible mode.
</AccordionContent>
</AccordionItem>
))}
</Accordion>
),
}

View File

@@ -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 = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m6 9 6 6 6-6" />
</svg>
)
// --- Context ---
interface AccordionContextValue {
openItems: Set<string>
toggle: (value: string) => void
}
const AccordionContext = createContext<AccordionContextValue | null>(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<AccordionItemContextValue | null>(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<HTMLDivElement> {
type?: 'single' | 'multiple'
collapsible?: boolean
defaultValue?: string | string[]
value?: string | string[]
onValueChange?: (value: string | string[]) => void
}
export const Accordion = forwardRef<HTMLDivElement, AccordionProps>(
(
{ type = 'single', collapsible = false, defaultValue, value, onValueChange, className, children, ...props },
ref,
) => {
const [internalOpen, setInternalOpen] = useState<Set<string>>(() => {
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<string>): Set<string> => {
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 (
<AccordionContext.Provider value={contextValue}>
<div
ref={ref}
className={cn('overflow-hidden rounded-xl bg-surface', className)}
{...props}
>
{children}
</div>
</AccordionContext.Provider>
)
},
)
Accordion.displayName = 'Accordion'
// --- AccordionItem ---
export interface AccordionItemProps extends HTMLAttributes<HTMLDivElement> {
value: string
disabled?: boolean
}
export const AccordionItem = forwardRef<HTMLDivElement, AccordionItemProps>(
({ 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 (
<AccordionItemContext.Provider value={itemContext}>
<div
ref={ref}
data-state={isOpen ? 'open' : 'closed'}
className={cn('border-b border-border last:border-b-0', className)}
{...props}
>
{children}
</div>
</AccordionItemContext.Provider>
)
},
)
AccordionItem.displayName = 'AccordionItem'
// --- AccordionTrigger ---
export interface AccordionTriggerProps extends Omit<HTMLAttributes<HTMLButtonElement>, 'children'> {
icon?: ReactNode
children: ReactNode
}
export const AccordionTrigger = forwardRef<HTMLButtonElement, AccordionTriggerProps>(
({ icon, className, children, ...props }, ref) => {
const { toggle } = useAccordionContext()
const { value, isOpen, triggerId, contentId, disabled } = useAccordionItemContext()
return (
<button
ref={ref}
id={triggerId}
type="button"
aria-expanded={isOpen}
aria-controls={contentId}
disabled={disabled}
onClick={() => toggle(value)}
className={cn(
'flex h-16 w-full items-center gap-3 px-6 text-left font-bold text-text transition-colors',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary',
isOpen ? 'bg-primary/12' : 'bg-surface hover:bg-primary/5',
disabled && 'pointer-events-none opacity-50',
className,
)}
{...props}
>
{icon && <span className="size-6 shrink-0 [&>svg]:size-full">{icon}</span>}
<span className="flex-1">{children}</span>
<span
className={cn(
'size-6 shrink-0 transition-transform duration-200 [&>svg]:size-full',
isOpen && 'rotate-180',
)}
>
<ChevronIcon />
</span>
</button>
)
},
)
AccordionTrigger.displayName = 'AccordionTrigger'
// --- AccordionContent ---
export interface AccordionContentProps extends HTMLAttributes<HTMLDivElement> {}
export const AccordionContent = forwardRef<HTMLDivElement, AccordionContentProps>(
({ className, children, ...props }, ref) => {
const { isOpen, triggerId, contentId } = useAccordionItemContext()
return (
<div
ref={ref}
id={contentId}
role="region"
aria-labelledby={triggerId}
aria-hidden={!isOpen}
className={cn(
'grid transition-[grid-template-rows] duration-200 ease-out',
isOpen ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
)}
{...props}
>
<div className="min-h-0 overflow-hidden">
<div className={cn('px-6 pb-4 pt-2 text-text-secondary', className)}>{children}</div>
</div>
</div>
)
},
)
AccordionContent.displayName = 'AccordionContent'

View File

@@ -0,0 +1,7 @@
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from './Accordion'
export type {
AccordionProps,
AccordionItemProps,
AccordionTriggerProps,
AccordionContentProps,
} from './Accordion'

View File

@@ -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<typeof Card> = {
title: 'UI/Card',
component: Card,
tags: ['autodocs'],
argTypes: {
variant: {
control: 'select',
options: ['surface', 'outlined', 'elevated', 'filled'],
},
},
decorators: [
(Story) => (
<div className="max-w-lg">
<Story />
</div>
),
],
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => (
<Card>
<CardHeader>
<CardTitle>Card title</CardTitle>
<CardDescription>A short description of the card content.</CardDescription>
</CardHeader>
<CardContent>
<p className="text-body text-text">
This is the card body. It can contain any content text, lists, forms, or other
components.
</p>
</CardContent>
</Card>
),
}
// --- Variants ---
export const Surface: Story = {
render: () => (
<Card variant="surface">
<CardHeader>
<CardTitle>Surface</CardTitle>
</CardHeader>
<CardContent>
<p className="text-body text-text">Default variant with border and subtle shadow.</p>
</CardContent>
</Card>
),
}
export const Outlined: Story = {
render: () => (
<Card variant="outlined">
<CardHeader>
<CardTitle>Outlined</CardTitle>
</CardHeader>
<CardContent>
<p className="text-body text-text">Border only, no shadow. Good for less prominent cards.</p>
</CardContent>
</Card>
),
}
export const Elevated: Story = {
render: () => (
<Card variant="elevated">
<CardHeader>
<CardTitle>Elevated</CardTitle>
</CardHeader>
<CardContent>
<p className="text-body text-text">Shadow only, no border. Creates a floating effect.</p>
</CardContent>
</Card>
),
}
export const Filled: Story = {
render: () => (
<Card variant="filled">
<CardHeader>
<CardTitle>Professional pathway</CardTitle>
<CardDescription className="text-white/70">
Track your progress through each stage.
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-body text-white/90">
Dark filled variant for featured or highlighted content sections.
</p>
</CardContent>
</Card>
),
}
// --- With header action ---
export const WithHeaderAction: Story = {
render: () => (
<Card>
<CardHeader
action={
<button className="rounded-full p-1 text-text-secondary hover:bg-primary/5">
<Info className="size-5" />
</button>
}
>
<div className="flex items-center gap-2">
<ClipboardList className="size-5 text-primary-dark" />
<CardTitle>Steps to be taken</CardTitle>
</div>
</CardHeader>
<CardContent>
<ul className="divide-y divide-border">
<li className="flex items-center justify-between py-3 text-body text-text">
Apply and verify WWCC
<span className="text-text-secondary">&#8250;</span>
</li>
<li className="flex items-center justify-between py-3 text-body text-text">
Fill a Registration Form
<span className="text-text-secondary">&#8250;</span>
</li>
<li className="flex items-center justify-between py-3 text-body text-text">
Complete compliance modules
<span className="text-text-secondary">&#8250;</span>
</li>
</ul>
</CardContent>
</Card>
),
}
// --- With footer ---
export const WithFooter: Story = {
render: () => (
<Card>
<CardHeader>
<CardTitle>Mandatory Training Reminders</CardTitle>
<CardDescription>
Please consult the training hub for role-specific training requirements.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between rounded-lg bg-bg px-3 py-2 text-small">
<span className="text-text">Aboriginal Cultural Education</span>
<span className="font-bold text-success">Certified</span>
</div>
</CardContent>
<CardFooter>
<button className="inline-flex items-center gap-1.5 rounded-lg border border-primary-dark px-3 py-1.5 text-small font-bold text-primary-dark hover:bg-primary/5">
<ExternalLink className="size-3.5" />
Go to myPL
</button>
</CardFooter>
</Card>
),
}
// --- Minimal ---
export const ContentOnly: Story = {
render: () => (
<Card>
<CardContent>
<p className="text-body text-text">
A card with just content no header or footer. Useful as a simple container.
</p>
</CardContent>
</Card>
),
}
// --- Related information ---
export const RelatedInformation: Story = {
render: () => (
<Card>
<CardHeader
action={
<button className="rounded-full p-1 text-text-secondary hover:bg-primary/5">
<Info className="size-5" />
</button>
}
>
<div className="flex items-center gap-2">
<BookOpen className="size-5 text-primary-dark" />
<CardTitle>Related information</CardTitle>
</div>
</CardHeader>
<CardContent className="pt-0">
<ul className="divide-y divide-border">
<li className="flex items-center justify-between py-3 text-body text-text">
Visit the Beginning Teacher Information Hub
<span className="text-text-secondary">&#8250;</span>
</li>
<li className="flex items-center justify-between py-3 text-body text-text">
Apply for a role with DoE
<span className="text-text-secondary">&#8250;</span>
</li>
<li className="flex items-center justify-between py-3 text-body text-text">
Start your accreditation journey
<span className="text-text-secondary">&#8250;</span>
</li>
</ul>
</CardContent>
</Card>
),
}
// --- All variants ---
export const AllVariants: Story = {
render: () => (
<div className="flex flex-col gap-6">
<Card variant="surface">
<CardHeader>
<CardTitle>Surface</CardTitle>
</CardHeader>
<CardContent>
<p className="text-small text-text-secondary">Border + subtle shadow</p>
</CardContent>
</Card>
<Card variant="outlined">
<CardHeader>
<CardTitle>Outlined</CardTitle>
</CardHeader>
<CardContent>
<p className="text-small text-text-secondary">Border only</p>
</CardContent>
</Card>
<Card variant="elevated">
<CardHeader>
<CardTitle>Elevated</CardTitle>
</CardHeader>
<CardContent>
<p className="text-small text-text-secondary">Shadow only</p>
</CardContent>
</Card>
<Card variant="filled">
<CardHeader>
<CardTitle>Filled</CardTitle>
</CardHeader>
<CardContent>
<p className="text-small text-white/70">Dark background, white text</p>
</CardContent>
</Card>
</div>
),
}

View File

@@ -0,0 +1,102 @@
import { forwardRef, type HTMLAttributes } from 'react'
import { cn } from '@/lib/utils'
// --- Card ---
export interface CardProps extends HTMLAttributes<HTMLDivElement> {
variant?: 'surface' | 'outlined' | 'elevated' | 'filled'
}
const variantStyles: Record<string, string> = {
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<HTMLDivElement, CardProps>(
({ variant = 'surface', className, ...props }, ref) => (
<div
ref={ref}
className={cn('rounded-xl', variantStyles[variant], className)}
{...props}
/>
),
)
Card.displayName = 'Card'
// --- CardHeader ---
export interface CardHeaderProps extends HTMLAttributes<HTMLDivElement> {
action?: React.ReactNode
}
export const CardHeader = forwardRef<HTMLDivElement, CardHeaderProps>(
({ action, className, children, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-start gap-3 px-6 pt-6', action && 'justify-between', className)}
{...props}
>
<div className="flex min-w-0 flex-1 flex-col gap-1">{children}</div>
{action && <div className="shrink-0">{action}</div>}
</div>
),
)
CardHeader.displayName = 'CardHeader'
// --- CardTitle ---
export type CardTitleProps = HTMLAttributes<HTMLHeadingElement>
export const CardTitle = forwardRef<HTMLHeadingElement, CardTitleProps>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('text-h5 font-bold leading-tight', className)}
{...props}
/>
),
)
CardTitle.displayName = 'CardTitle'
// --- CardDescription ---
export type CardDescriptionProps = HTMLAttributes<HTMLParagraphElement>
export const CardDescription = forwardRef<HTMLParagraphElement, CardDescriptionProps>(
({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-small text-text-secondary', className)}
{...props}
/>
),
)
CardDescription.displayName = 'CardDescription'
// --- CardContent ---
export type CardContentProps = HTMLAttributes<HTMLDivElement>
export const CardContent = forwardRef<HTMLDivElement, CardContentProps>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('px-6 py-4', className)} {...props} />
),
)
CardContent.displayName = 'CardContent'
// --- CardFooter ---
export type CardFooterProps = HTMLAttributes<HTMLDivElement>
export const CardFooter = forwardRef<HTMLDivElement, CardFooterProps>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center gap-3 px-6 pb-6', className)}
{...props}
/>
),
)
CardFooter.displayName = 'CardFooter'

View File

@@ -0,0 +1,9 @@
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card'
export type {
CardProps,
CardHeaderProps,
CardTitleProps,
CardDescriptionProps,
CardContentProps,
CardFooterProps,
} from './Card'

View File

@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react-vite' 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' import { IconButton } from './IconButton'
const meta: Meta<typeof IconButton> = { const meta: Meta<typeof IconButton> = {
@@ -17,7 +17,7 @@ const meta: Meta<typeof IconButton> = {
}, },
size: { size: {
control: 'select', control: 'select',
options: ['large', 'default', 'compact'], options: ['large', 'default', 'compact', 'small', 'xsmall'],
}, },
shape: { shape: {
control: 'select', control: 'select',
@@ -76,6 +76,19 @@ export const AllSizes: Story = {
<IconButton size="large" icon={<X />} aria-label="Close" /> <IconButton size="large" icon={<X />} aria-label="Close" />
<IconButton size="default" icon={<X />} aria-label="Close" /> <IconButton size="default" icon={<X />} aria-label="Close" />
<IconButton size="compact" icon={<X />} aria-label="Close" /> <IconButton size="compact" icon={<X />} aria-label="Close" />
<IconButton size="small" icon={<X />} aria-label="Close" />
<IconButton size="xsmall" icon={<X />} aria-label="Close" />
</div>
),
}
export const CardActions: Story = {
render: () => (
<div className="flex items-center gap-1 rounded-lg border border-border bg-surface p-4">
<span className="mr-auto text-small font-bold text-text">Card title</span>
<IconButton size="small" variant="tertiary" intent="neutral" icon={<Copy />} aria-label="Copy" />
<IconButton size="small" variant="tertiary" intent="neutral" icon={<Maximize2 />} aria-label="Expand" />
<IconButton size="small" variant="tertiary" intent="neutral" icon={<MoreVertical />} aria-label="More options" />
</div> </div>
), ),
} }

View File

@@ -4,7 +4,7 @@ import { cn } from '@/lib/utils'
export interface IconButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'> { export interface IconButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
variant?: 'primary' | 'secondary' | 'tertiary' variant?: 'primary' | 'secondary' | 'tertiary'
intent?: 'default' | 'danger' | 'neutral' intent?: 'default' | 'danger' | 'neutral'
size?: 'default' | 'large' | 'compact' size?: 'default' | 'large' | 'compact' | 'small' | 'xsmall'
shape?: 'circle' | 'square' shape?: 'circle' | 'square'
icon: ReactNode icon: ReactNode
'aria-label': string 'aria-label': string
@@ -35,12 +35,16 @@ const sizeStyles: Record<string, string> = {
large: 'size-14', large: 'size-14',
default: 'size-12', default: 'size-12',
compact: 'size-10', compact: 'size-10',
small: 'size-8',
xsmall: 'size-6',
} }
const iconSizeStyles: Record<string, string> = { const iconSizeStyles: Record<string, string> = {
large: 'size-6', large: 'size-6',
default: 'size-6', default: 'size-6',
compact: 'size-[18px]', compact: 'size-[18px]',
small: 'size-4',
xsmall: 'size-3.5',
} }
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>( export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(

View File

@@ -96,8 +96,8 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
'relative flex items-center rounded-[4px] border bg-control-bg px-3 transition-colors', 'relative flex items-center rounded-[4px] border bg-control-bg px-3 transition-colors',
styles.container, styles.container,
hasError hasError
? 'border-control-error focus-within:border-2 focus-within:border-control-error 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-2 focus-within:border-control-checked focus-within:px-[11px]', : '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', disabled && 'pointer-events-none border-control-border/50 bg-control-bg/50',
readOnly && 'border-transparent bg-control-bg-readonly', readOnly && 'border-transparent bg-control-bg-readonly',
)} )}

View File

@@ -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<typeof Select> = {
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) => (
<div className="max-w-sm">
<Story />
</div>
),
],
}
export default meta
type Story = StoryObj<typeof meta>
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 (
<Select
label="Year level"
options={yearLevels}
value={value}
onChange={setValue}
/>
)
}
export const Preselected: Story = {
render: () => <PreselectedExample />,
}
const ControlledExample = () => {
const [value, setValue] = useState('')
return (
<div className="flex flex-col gap-4">
<Select
label="Year level"
options={yearLevels}
value={value}
onChange={setValue}
/>
<p className="text-small text-text-secondary">
Selected: {value || '(none)'}
</p>
</div>
)
}
export const Controlled: Story = {
render: () => <ControlledExample />,
}
// --- 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: () => (
<div className="flex flex-col gap-6">
<Select label="Default" options={yearLevels} />
<Select label="With hint" options={yearLevels} hint="Helpful hint text" />
<Select label="With error" options={yearLevels} error="This field is required" />
<Select label="Disabled" options={yearLevels} disabled />
</div>
),
}
export const StackedAllStates: Story = {
render: () => (
<div className="flex flex-col gap-6">
<Select variant="stacked" label="Default" options={yearLevels} />
<Select
variant="stacked"
label="With description"
description="A short description of the field."
options={yearLevels}
/>
<Select
variant="stacked"
label="With hint"
description="Description text here."
options={yearLevels}
hint="Helpful hint text"
/>
<Select
variant="stacked"
label="Error"
description="Description text here."
options={yearLevels}
error="This field is required"
/>
<Select variant="stacked" label="Disabled" options={yearLevels} disabled />
</div>
),
}

View File

@@ -0,0 +1,360 @@
import {
forwardRef,
useCallback,
useEffect,
useId,
useRef,
useState,
type HTMLAttributes,
} from 'react'
import { cn } from '@/lib/utils'
export interface SelectOption {
value: string
label: string
disabled?: boolean
}
export interface SelectProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
label: string
description?: string
hint?: string
error?: string
variant?: 'outlined' | 'stacked'
placeholder?: string
options: SelectOption[]
value?: string
defaultValue?: string
onChange?: (value: string) => void
disabled?: boolean
}
const ChevronIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m6 9 6 6 6-6" />
</svg>
)
export const Select = forwardRef<HTMLDivElement, SelectProps>(
(
{
label,
description,
hint,
error,
variant = 'outlined',
placeholder = 'Select',
options,
value: controlledValue,
defaultValue,
onChange,
disabled,
className,
id: idProp,
...props
},
ref,
) => {
const autoId = useId()
const id = idProp ?? autoId
const triggerId = `${id}-trigger`
const listboxId = `${id}-listbox`
const descriptionId = `${id}-description`
const hintId = `${id}-hint`
const hasError = !!error
const supportiveText = error || hint
const isStacked = variant === 'stacked'
const isControlled = controlledValue !== undefined
const [internalValue, setInternalValue] = useState(defaultValue ?? '')
const selectedValue = isControlled ? controlledValue : internalValue
const [isOpen, setIsOpen] = useState(false)
const [activeIndex, setActiveIndex] = useState(-1)
const triggerRef = useRef<HTMLButtonElement>(null)
const listboxRef = useRef<HTMLUListElement>(null)
const selectedOption = options.find((o) => o.value === selectedValue)
const selectableOptions = options.filter((o) => !o.disabled)
const describedBy =
[
description && isStacked ? descriptionId : undefined,
supportiveText ? hintId : undefined,
]
.filter(Boolean)
.join(' ') || undefined
const selectOption = useCallback(
(optionValue: string) => {
if (!isControlled) setInternalValue(optionValue)
onChange?.(optionValue)
setIsOpen(false)
triggerRef.current?.focus()
},
[isControlled, onChange],
)
const open = useCallback(() => {
if (disabled) return
setIsOpen(true)
const idx = options.findIndex((o) => o.value === selectedValue && !o.disabled)
setActiveIndex(idx >= 0 ? idx : selectableOptions.length > 0 ? options.indexOf(selectableOptions[0]) : -1)
}, [disabled, options, selectedValue, selectableOptions])
const close = useCallback(() => {
setIsOpen(false)
setActiveIndex(-1)
}, [])
useEffect(() => {
if (!isOpen) return
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as Node
if (!triggerRef.current?.contains(target) && !listboxRef.current?.contains(target)) {
close()
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [isOpen, close])
useEffect(() => {
if (!isOpen || activeIndex < 0) return
const activeEl = listboxRef.current?.children[activeIndex] as HTMLElement | undefined
activeEl?.scrollIntoView({ block: 'nearest' })
}, [isOpen, activeIndex])
const handleTriggerKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case 'Enter':
case ' ':
case 'ArrowDown':
e.preventDefault()
open()
break
case 'ArrowUp':
e.preventDefault()
open()
break
}
}
const findNextIndex = (from: number, direction: 1 | -1): number => {
let idx = from + direction
while (idx >= 0 && idx < options.length) {
if (!options[idx].disabled) return idx
idx += direction
}
return from
}
const handleListboxKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setActiveIndex((prev) => findNextIndex(prev, 1))
break
case 'ArrowUp':
e.preventDefault()
setActiveIndex((prev) => findNextIndex(prev, -1))
break
case 'Home':
e.preventDefault()
setActiveIndex(options.findIndex((o) => !o.disabled))
break
case 'End':
e.preventDefault()
for (let i = options.length - 1; i >= 0; i--) {
if (!options[i].disabled) {
setActiveIndex(i)
break
}
}
break
case 'Enter':
case ' ':
e.preventDefault()
if (activeIndex >= 0 && !options[activeIndex].disabled) {
selectOption(options[activeIndex].value)
}
break
case 'Escape':
case 'Tab':
e.preventDefault()
close()
triggerRef.current?.focus()
break
}
}
return (
<div
ref={ref}
className={cn('relative flex w-full flex-col', isStacked ? 'gap-1.5' : 'gap-1 pt-2', className)}
{...props}
>
{isStacked && (
<div className="flex flex-col gap-0.5">
<label
id={`${id}-label`}
htmlFor={triggerId}
className={cn(
'text-small font-bold',
hasError ? 'text-control-error' : 'text-control-label',
disabled && 'text-control-description',
)}
>
{label}
</label>
{description && (
<p
id={descriptionId}
className={cn('text-small text-text', disabled && 'opacity-50')}
>
{description}
</p>
)}
</div>
)}
<div className="relative">
<button
ref={triggerRef}
id={triggerId}
type="button"
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-controls={listboxId}
aria-describedby={describedBy}
aria-invalid={hasError || undefined}
aria-labelledby={isStacked ? `${id}-label` : undefined}
disabled={disabled}
onClick={() => (isOpen ? close() : open())}
onKeyDown={handleTriggerKeyDown}
className={cn(
'flex h-12 w-full items-center rounded-[4px] border bg-control-bg px-3 text-left transition-colors',
hasError
? 'border-control-error focus:ring-1 focus:ring-control-error'
: 'border-control-border hover:border-control-border-hover focus:border-control-checked focus:ring-1 focus:ring-control-checked',
isOpen &&
(hasError
? 'ring-1 ring-control-error'
: 'border-control-checked ring-1 ring-control-checked'),
disabled && 'pointer-events-none border-control-border/50 bg-control-bg/50',
)}
>
{!isStacked && (
<span
className={cn(
'absolute left-2 top-0 z-10 -translate-y-1/2 bg-control-bg px-1 text-small font-bold leading-none',
hasError ? 'text-control-error' : 'text-control-label',
disabled && 'text-control-description',
)}
>
{label}
</span>
)}
<span className={cn('flex-1 truncate text-body', selectedOption ? 'text-text' : 'text-text/50')}>
{selectedOption?.label ?? placeholder}
</span>
<span
className={cn(
'size-5 shrink-0 transition-transform duration-200 [&>svg]:size-full',
isOpen && 'rotate-180',
)}
>
<ChevronIcon />
</span>
</button>
{isOpen && (
<ul
ref={listboxRef}
id={listboxId}
role="listbox"
aria-labelledby={isStacked ? `${id}-label` : triggerId}
tabIndex={0}
onKeyDown={handleListboxKeyDown}
onFocus={() => {
if (activeIndex < 0) {
const idx = options.findIndex((o) => o.value === selectedValue && !o.disabled)
setActiveIndex(idx >= 0 ? idx : options.findIndex((o) => !o.disabled))
}
}}
className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border border-border bg-surface py-1 shadow-md"
>
{options.map((option, index) => (
<li
key={option.value}
id={`${id}-option-${index}`}
role="option"
aria-selected={option.value === selectedValue}
aria-disabled={option.disabled || undefined}
onMouseEnter={() => !option.disabled && setActiveIndex(index)}
onMouseDown={(e) => {
e.preventDefault()
if (!option.disabled) selectOption(option.value)
}}
className={cn(
'cursor-pointer px-4 py-2.5 text-body text-text transition-colors',
option.value === selectedValue && 'bg-primary/12 font-bold',
index === activeIndex && option.value !== selectedValue && 'bg-primary/5',
option.disabled && 'pointer-events-none text-text/30',
)}
>
{option.label}
</li>
))}
</ul>
)}
</div>
{supportiveText && (
<div
id={hintId}
className={cn(
'flex items-center gap-1 text-small',
hasError ? 'text-control-error' : 'text-control-description',
disabled && 'opacity-50',
)}
>
{hasError && (
<svg
className="size-4 shrink-0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
)}
<p className="flex-1">{supportiveText}</p>
</div>
)}
</div>
)
},
)
Select.displayName = 'Select'

View File

@@ -0,0 +1,2 @@
export { Select } from './Select'
export type { SelectProps, SelectOption } from './Select'

View File

@@ -0,0 +1,218 @@
import { useState } from 'react'
import type { Meta, StoryObj } from '@storybook/react-vite'
import { Textarea } from './Textarea'
const meta: Meta<typeof Textarea> = {
title: 'UI/Textarea',
component: Textarea,
tags: ['autodocs'],
argTypes: {
label: { control: 'text' },
description: { control: 'text' },
hint: { control: 'text' },
error: { control: 'text' },
variant: {
control: 'select',
options: ['outlined', 'stacked'],
},
resize: {
control: 'select',
options: ['vertical', 'horizontal', 'both', 'none'],
},
autoResize: { control: 'boolean' },
rows: { control: 'number' },
disabled: { control: 'boolean' },
readOnly: { control: 'boolean' },
placeholder: { control: 'text' },
},
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
label: 'Description',
placeholder: 'Enter a description...',
},
}
export const WithHint: Story = {
args: {
label: 'Research notes',
placeholder: 'Record your observations...',
hint: 'Include participant quotes where possible',
},
}
export const WithValue: Story = {
args: {
label: 'Summary',
defaultValue:
'Participants expressed a strong preference for visual feedback during task completion. Several noted that the absence of confirmation messages led to uncertainty about whether their input had been saved.',
},
}
export const WithError: Story = {
args: {
label: 'Findings',
defaultValue: 'Too short.',
error: 'Please provide at least 50 characters',
},
}
export const Disabled: Story = {
args: {
label: 'Description',
placeholder: 'Enter a description...',
disabled: true,
},
}
export const DisabledWithValue: Story = {
args: {
label: 'Previous notes',
defaultValue: 'This field has been locked after review submission.',
disabled: true,
},
}
export const ReadOnly: Story = {
args: {
label: 'Submitted feedback',
defaultValue:
'The onboarding flow was intuitive and participants completed all tasks without assistance. Average completion time was 4 minutes.',
readOnly: true,
},
}
export const NoResize: Story = {
args: {
label: 'Comment',
placeholder: 'Add a comment...',
resize: 'none',
rows: 4,
},
}
const CharacterCountExample = () => {
const [value, setValue] = useState('')
return (
<Textarea
label="Theme description"
placeholder="Describe the theme in detail..."
hint="Be specific about participant sentiment"
maxLength={500}
value={value}
onChange={(e) => setValue(e.target.value)}
/>
)
}
export const WithCharacterCount: Story = {
render: () => <CharacterCountExample />,
}
const AutoResizeExample = () => {
const [value, setValue] = useState('')
return (
<Textarea
label="Auto-resizing notes"
placeholder="Start typing — the field grows as you type..."
autoResize
rows={2}
value={value}
onChange={(e) => setValue(e.target.value)}
/>
)
}
export const AutoResize: Story = {
render: () => <AutoResizeExample />,
}
// --- Stacked variant ---
export const Stacked: Story = {
args: {
label: 'Research notes',
placeholder: 'Record your observations...',
variant: 'stacked',
},
}
export const StackedWithDescription: Story = {
args: {
label: 'Theme summary',
description: 'Summarise the key findings for this theme.',
placeholder: 'e.g. Participants consistently reported...',
variant: 'stacked',
},
}
export const StackedWithError: Story = {
args: {
label: 'Analysis notes',
description: 'Provide your interpretation of the data.',
defaultValue: 'N/A',
error: 'Analysis notes are required for theme completion',
variant: 'stacked',
},
}
// --- All states ---
export const AllStates: Story = {
render: () => (
<div className="flex max-w-md flex-col gap-6">
<Textarea label="Default" placeholder="Placeholder" />
<Textarea label="With hint" placeholder="Placeholder" hint="Helpful hint text" />
<Textarea
label="With value"
defaultValue="Participants noted that the notification system was effective but could benefit from customisable frequency settings."
/>
<Textarea label="Error" defaultValue="Bad value" error="This field is required" />
<Textarea label="Disabled" placeholder="Placeholder" disabled />
<Textarea label="Disabled with value" defaultValue="Locked content" disabled />
<Textarea label="Read only" defaultValue="Submitted content" readOnly />
</div>
),
}
export const StackedAllStates: Story = {
render: () => (
<div className="flex max-w-md flex-col gap-6">
<Textarea variant="stacked" label="Default" placeholder="Placeholder" />
<Textarea
variant="stacked"
label="With description"
description="A short description of the field."
placeholder="Placeholder"
/>
<Textarea
variant="stacked"
label="With hint"
description="Description text here."
placeholder="Placeholder"
hint="Helpful hint text"
/>
<Textarea variant="stacked" label="With value" defaultValue="Some content entered by the user" />
<Textarea
variant="stacked"
label="Error"
description="Description text here."
defaultValue="Bad value"
error="This field is required"
/>
<Textarea variant="stacked" label="Disabled" placeholder="Placeholder" disabled />
<Textarea
variant="stacked"
label="Disabled with description"
description="This field cannot be edited."
defaultValue="Locked content"
disabled
/>
<Textarea variant="stacked" label="Read only" defaultValue="Submitted content" readOnly />
</div>
),
}

View File

@@ -0,0 +1,197 @@
import { forwardRef, useId, useCallback, useRef, useEffect, type TextareaHTMLAttributes } from 'react'
import { cn } from '@/lib/utils'
export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
label: string
description?: string
hint?: string
error?: string
variant?: 'outlined' | 'stacked'
resize?: 'vertical' | 'horizontal' | 'both' | 'none'
autoResize?: boolean
}
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
(
{
label,
description,
hint,
error,
variant = 'outlined',
resize = 'vertical',
autoResize = false,
disabled,
readOnly,
maxLength,
value,
defaultValue,
rows = 3,
className,
id: idProp,
onChange,
...props
},
ref,
) => {
const autoId = useId()
const id = idProp ?? autoId
const descriptionId = `${id}-description`
const hintId = `${id}-hint`
const hasError = !!error
const supportiveText = error || hint
const isStacked = variant === 'stacked'
const internalRef = useRef<HTMLTextAreaElement | null>(null)
const currentLength =
maxLength != null && typeof value === 'string' ? value.length : undefined
const describedBy =
[
description && isStacked ? descriptionId : undefined,
supportiveText ? hintId : undefined,
]
.filter(Boolean)
.join(' ') || undefined
const resizeClass: Record<string, string> = {
vertical: 'resize-y',
horizontal: 'resize-x',
both: 'resize',
none: 'resize-none',
}
const adjustHeight = useCallback(() => {
const el = internalRef.current
if (!el || !autoResize) return
el.style.height = 'auto'
el.style.height = `${el.scrollHeight}px`
}, [autoResize])
useEffect(() => {
adjustHeight()
}, [adjustHeight, value])
const setRefs = useCallback(
(node: HTMLTextAreaElement | null) => {
internalRef.current = node
if (typeof ref === 'function') ref(node)
else if (ref) ref.current = node
},
[ref],
)
return (
<div className={cn('flex w-full flex-col', isStacked ? 'gap-1.5' : 'gap-1 pt-2', className)}>
{isStacked && (
<div className="flex flex-col gap-0.5">
<label
htmlFor={id}
className={cn(
'text-small font-bold',
hasError ? 'text-control-error' : 'text-control-label',
disabled && 'text-control-description',
)}
>
{label}
</label>
{description && (
<p
id={descriptionId}
className={cn('text-small text-text', disabled && 'opacity-50')}
>
{description}
</p>
)}
</div>
)}
<div
className={cn(
'relative flex rounded-[4px] border bg-control-bg transition-colors',
hasError
? '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',
)}
>
{!isStacked && (
<label
htmlFor={id}
className={cn(
'absolute left-2 top-0 z-10 -translate-y-1/2 bg-control-bg px-1 text-small font-bold leading-none',
hasError ? 'text-control-error' : 'text-control-label',
disabled && 'text-control-description',
)}
>
{label}
</label>
)}
<textarea
ref={setRefs}
id={id}
disabled={disabled}
readOnly={readOnly}
maxLength={maxLength}
value={value}
defaultValue={defaultValue}
rows={rows}
aria-invalid={hasError || undefined}
aria-describedby={describedBy}
onChange={(e) => {
onChange?.(e)
adjustHeight()
}}
className={cn(
'w-full bg-transparent px-3 py-3 text-body font-normal text-text outline-none',
'placeholder:text-text/50',
autoResize ? 'resize-none overflow-hidden' : resizeClass[resize],
disabled && 'text-text/50',
)}
{...props}
/>
</div>
{(supportiveText || currentLength != null) && (
<div
id={hintId}
className={cn(
'flex items-center gap-1 text-small',
hasError ? 'text-control-error' : 'text-control-description',
disabled && 'opacity-50',
)}
>
{hasError && (
<svg
className="size-4 shrink-0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
)}
{supportiveText && <p className="flex-1">{supportiveText}</p>}
{currentLength != null && (
<p className="shrink-0 text-right">
{currentLength}/{maxLength}
</p>
)}
</div>
)}
</div>
)
},
)
Textarea.displayName = 'Textarea'

View File

@@ -0,0 +1,2 @@
export { Textarea } from './Textarea'
export type { TextareaProps } from './Textarea'

View File

@@ -115,6 +115,7 @@
--radius-sm: 4px; --radius-sm: 4px;
--radius-default: 6px; --radius-default: 6px;
--radius-lg: 10px; --radius-lg: 10px;
--radius-xl: 16px;
--radius-full: 9999px; --radius-full: 9999px;
/* Shadows */ /* Shadows */