Add Input, Checkbox, Radio, and Switch form components with semantic token layer

Build four form primitives from Figma references with brand-aligned creative
decisions: restrained press states (scale-95 instead of highlight splashes),
clean iconless Switch, and consistent error states with inline warning icons.

Introduce form-control semantic tokens (--color-control-*) in tokens.css so
all form components share a single source for borders, checked states, focus
rings, labels, and errors. Retrofit Input to use these tokens instead of
direct palette references.

Update CLAUDE.md and ARCHITECTURE.md with token layer documentation, token
discipline rule (no palette references in components), and component tier
decision criteria.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 14:00:56 +10:00
parent 0e1b06b376
commit 07be9d7314
18 changed files with 1523 additions and 57 deletions

View File

@@ -0,0 +1,120 @@
import { useState } from 'react'
import type { Meta, StoryObj } from '@storybook/react-vite'
import { Checkbox } from './Checkbox'
const meta: Meta<typeof Checkbox> = {
title: 'UI/Checkbox',
component: Checkbox,
tags: ['autodocs'],
argTypes: {
label: { control: 'text' },
description: { control: 'text' },
error: { control: 'text' },
disabled: { control: 'boolean' },
indeterminate: { control: 'boolean' },
},
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/design/mrabO6AtxN3ektGiTk0I9c/ResearchInsights?node-id=33-5043',
},
},
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
label: 'Accept terms and conditions',
},
}
export const Checked: Story = {
args: {
label: 'Accept terms and conditions',
defaultChecked: true,
},
}
export const WithDescription: Story = {
args: {
label: 'Email notifications',
description: 'Receive updates about your research projects via email.',
},
}
export const WithError: Story = {
args: {
label: 'I agree to the privacy policy',
error: 'You must agree before continuing',
},
}
export const Disabled: Story = {
args: {
label: 'Unavailable option',
disabled: true,
},
}
export const DisabledChecked: Story = {
args: {
label: 'Locked setting',
disabled: true,
defaultChecked: true,
},
}
const IndeterminateExample = () => {
const [items, setItems] = useState([true, false, true])
const allChecked = items.every(Boolean)
const someChecked = items.some(Boolean) && !allChecked
return (
<div className="flex flex-col gap-3">
<Checkbox
label="Select all"
checked={allChecked}
indeterminate={someChecked}
onChange={() => setItems(allChecked ? [false, false, false] : [true, true, true])}
/>
<div className="ml-6 flex flex-col gap-2">
{['Survey responses', 'Interview transcripts', 'Field notes'].map((name, i) => (
<Checkbox
key={name}
label={name}
checked={items[i]}
onChange={() => {
const next = [...items]
next[i] = !next[i]
setItems(next)
}}
/>
))}
</div>
</div>
)
}
export const Indeterminate: Story = {
render: () => <IndeterminateExample />,
}
export const AllStates: Story = {
render: () => (
<div className="flex flex-col gap-4">
<Checkbox label="Unchecked" />
<Checkbox label="Checked" defaultChecked />
<Checkbox label="With description" description="Additional context for this option." />
<Checkbox
label="Checked with description"
description="This option is currently enabled."
defaultChecked
/>
<Checkbox label="Error" error="This field is required" />
<Checkbox label="Disabled" disabled />
<Checkbox label="Disabled checked" disabled defaultChecked />
</div>
),
}

View File

@@ -0,0 +1,143 @@
import { forwardRef, useId, useRef, useEffect, type InputHTMLAttributes } from 'react'
import { cn } from '@/lib/utils'
export interface CheckboxProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'size'> {
label?: string
description?: string
error?: string
indeterminate?: boolean
}
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
(
{
label,
description,
error,
indeterminate = false,
disabled,
className,
id: idProp,
...props
},
forwardedRef,
) => {
const autoId = useId()
const id = idProp ?? autoId
const descriptionId = `${id}-description`
const errorId = `${id}-error`
const internalRef = useRef<HTMLInputElement>(null)
const hasError = !!error
useEffect(() => {
const el = internalRef.current
if (el) el.indeterminate = indeterminate
}, [indeterminate])
const describedBy =
[description ? descriptionId : undefined, hasError ? errorId : undefined]
.filter(Boolean)
.join(' ') || undefined
return (
<div className={cn('flex gap-2', className)}>
<div className="flex h-6 items-center">
<input
ref={(node) => {
(internalRef as React.MutableRefObject<HTMLInputElement | null>).current = node
if (typeof forwardedRef === 'function') forwardedRef(node)
else if (forwardedRef) forwardedRef.current = node
}}
type="checkbox"
id={id}
disabled={disabled}
aria-invalid={hasError || undefined}
aria-describedby={describedBy}
className={cn(
'peer size-5 cursor-pointer appearance-none rounded-[3px] border-2 border-control-border bg-control-bg transition-colors',
'hover:border-control-border-hover',
'checked:border-control-checked checked:bg-control-checked',
'checked:hover:border-control-checked-hover checked:hover:bg-control-checked-hover',
'indeterminate:border-control-checked indeterminate:bg-control-checked',
'indeterminate:hover:border-control-checked-hover indeterminate:hover:bg-control-checked-hover',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-control-focus-ring focus-visible:ring-offset-1',
'active:scale-95',
'disabled:pointer-events-none disabled:opacity-50',
hasError && 'border-control-error checked:border-control-error checked:bg-control-error',
)}
{...props}
/>
<svg
className="pointer-events-none absolute size-5 text-white opacity-0 peer-checked:opacity-100 peer-indeterminate:hidden"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<polyline points="4.5 10.5 8 14 15.5 6.5" />
</svg>
<svg
className="pointer-events-none absolute size-5 text-white opacity-0 peer-indeterminate:opacity-100"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
aria-hidden="true"
>
<line x1="5" y1="10" x2="15" y2="10" />
</svg>
</div>
{(label || description || hasError) && (
<div className="flex flex-col gap-0.5 pt-px">
{label && (
<label
htmlFor={id}
className={cn(
'cursor-pointer text-body font-normal text-grey-01',
disabled && 'pointer-events-none opacity-50',
)}
>
{label}
</label>
)}
{description && (
<p
id={descriptionId}
className={cn('text-small text-text', disabled && 'opacity-50')}
>
{description}
</p>
)}
{hasError && (
<div id={errorId} className="flex items-center gap-1 text-small text-control-error">
<svg
className="size-4 shrink-0"
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>{error}</p>
</div>
)}
</div>
)}
</div>
)
},
)
Checkbox.displayName = 'Checkbox'

View File

@@ -0,0 +1,2 @@
export { Checkbox } from './Checkbox'
export type { CheckboxProps } from './Checkbox'

View File

@@ -0,0 +1,324 @@
import { useState } from 'react'
import type { Meta, StoryObj } from '@storybook/react-vite'
import { Input } from './Input'
const meta: Meta<typeof Input> = {
title: 'UI/Input',
component: Input,
tags: ['autodocs'],
argTypes: {
label: { control: 'text' },
description: { control: 'text' },
hint: { control: 'text' },
error: { control: 'text' },
variant: {
control: 'select',
options: ['outlined', 'stacked'],
},
size: {
control: 'select',
options: ['default', 'compact'],
},
disabled: { control: 'boolean' },
readOnly: { control: 'boolean' },
placeholder: { control: 'text' },
},
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/design/mrabO6AtxN3ektGiTk0I9c/ResearchInsights?node-id=22-3845',
},
},
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
label: 'Label',
placeholder: 'Placeholder',
},
}
export const WithHint: Story = {
args: {
label: 'Email',
placeholder: 'you@example.com',
hint: 'We will never share your email',
},
}
export const WithValue: Story = {
args: {
label: 'Full name',
defaultValue: 'Jane Smith',
},
}
export const WithError: Story = {
args: {
label: 'Email',
defaultValue: 'not-an-email',
error: 'Please enter a valid email address',
},
}
const SearchIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
)
const MailIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<rect width="20" height="16" x="2" y="4" rx="2" />
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
</svg>
)
const ChevronDownIcon = () => (
<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 WithLeftIcon: Story = {
args: {
label: 'Search',
placeholder: 'Search...',
leftIcon: <SearchIcon />,
},
}
export const WithRightIcon: Story = {
args: {
label: 'Category',
placeholder: 'Select...',
rightIcon: <ChevronDownIcon />,
},
}
export const WithBothIcons: Story = {
args: {
label: 'Email',
placeholder: 'you@example.com',
leftIcon: <MailIcon />,
rightIcon: <ChevronDownIcon />,
},
}
export const Compact: Story = {
args: {
label: 'Label',
placeholder: 'Placeholder',
size: 'compact',
},
}
export const Disabled: Story = {
args: {
label: 'Label',
placeholder: 'Placeholder',
disabled: true,
},
}
export const DisabledWithValue: Story = {
args: {
label: 'Full name',
defaultValue: 'Jane Smith',
disabled: true,
},
}
export const ReadOnly: Story = {
args: {
label: 'Account ID',
defaultValue: 'ACC-2024-001',
readOnly: true,
},
}
const CharacterCountExample = () => {
const [value, setValue] = useState('')
return (
<Input
label="Bio"
placeholder="Tell us about yourself"
hint="Keep it brief"
maxLength={250}
value={value}
onChange={(e) => setValue(e.target.value)}
/>
)
}
export const WithCharacterCount: Story = {
render: () => <CharacterCountExample />,
}
export const AllStates: Story = {
render: () => (
<div className="flex max-w-sm flex-col gap-6">
<Input label="Default" placeholder="Placeholder" />
<Input label="With hint" placeholder="Placeholder" hint="Helpful hint text" />
<Input label="With value" defaultValue="Some value" />
<Input label="Error" defaultValue="Bad value" error="This field is required" />
<Input label="Disabled" placeholder="Placeholder" disabled />
<Input label="Disabled with value" defaultValue="Jane Smith" disabled />
<Input label="Read only" defaultValue="ACC-2024-001" readOnly />
</div>
),
}
export const AllSizes: Story = {
render: () => (
<div className="flex max-w-sm flex-col gap-6">
<Input label="Default size" placeholder="48px height" size="default" />
<Input label="Compact size" placeholder="40px height" size="compact" />
</div>
),
}
export const WithIcons: Story = {
render: () => (
<div className="flex max-w-sm flex-col gap-6">
<Input label="Search" placeholder="Search..." leftIcon={<SearchIcon />} />
<Input
label="Email"
placeholder="you@example.com"
leftIcon={<MailIcon />}
rightIcon={<ChevronDownIcon />}
/>
<Input
label="Search (compact)"
placeholder="Search..."
leftIcon={<SearchIcon />}
size="compact"
/>
</div>
),
}
// --- Stacked variant ---
export const Stacked: Story = {
args: {
label: 'Full name',
placeholder: 'Enter your full name',
variant: 'stacked',
},
}
export const StackedWithDescription: Story = {
args: {
label: 'Project title',
description: 'Choose a clear, descriptive name for your research project.',
placeholder: 'e.g. Student Wellbeing Survey 2026',
variant: 'stacked',
},
}
export const StackedWithHint: Story = {
args: {
label: 'Email address',
description: 'Your department email is preferred.',
placeholder: 'you@education.nsw.gov.au',
hint: 'We will use this for project notifications',
variant: 'stacked',
},
}
export const StackedWithError: Story = {
args: {
label: 'Email address',
description: 'Your department email is preferred.',
defaultValue: 'not-valid',
error: 'Please enter a valid email address',
variant: 'stacked',
},
}
export const StackedWithIcons: Story = {
render: () => (
<div className="flex max-w-sm flex-col gap-6">
<Input
variant="stacked"
label="Search participants"
description="Find by name or ID."
placeholder="Search..."
leftIcon={<SearchIcon />}
/>
<Input
variant="stacked"
label="Contact email"
placeholder="you@example.com"
leftIcon={<MailIcon />}
/>
</div>
),
}
export const StackedAllStates: Story = {
render: () => (
<div className="flex max-w-sm flex-col gap-6">
<Input variant="stacked" label="Default" placeholder="Placeholder" />
<Input
variant="stacked"
label="With description"
description="A short description of the field."
placeholder="Placeholder"
/>
<Input
variant="stacked"
label="With hint"
description="Description text here."
placeholder="Placeholder"
hint="Helpful hint text"
/>
<Input variant="stacked" label="With value" defaultValue="Some value" />
<Input
variant="stacked"
label="Error"
description="Description text here."
defaultValue="Bad value"
error="This field is required"
/>
<Input variant="stacked" label="Disabled" placeholder="Placeholder" disabled />
<Input
variant="stacked"
label="Disabled with description"
description="This field cannot be edited."
defaultValue="Jane Smith"
disabled
/>
<Input variant="stacked" label="Read only" defaultValue="ACC-2024-001" readOnly />
</div>
),
}

View File

@@ -0,0 +1,193 @@
import { forwardRef, useId, type InputHTMLAttributes } from 'react'
import { cn } from '@/lib/utils'
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label: string
description?: string
hint?: string
error?: string
variant?: 'outlined' | 'stacked'
size?: 'default' | 'compact'
leftIcon?: React.ReactNode
rightIcon?: React.ReactNode
}
const sizeStyles: Record<string, { container: string; input: string; icon: string }> = {
default: {
container: 'h-12 gap-2',
input: 'text-body',
icon: 'size-6',
},
compact: {
container: 'h-10 gap-2',
input: 'text-small',
icon: 'size-5',
},
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
(
{
label,
description,
hint,
error,
variant = 'outlined',
size = 'default',
leftIcon,
rightIcon,
disabled,
readOnly,
maxLength,
value,
defaultValue,
className,
id: idProp,
...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 styles = sizeStyles[size]
const isStacked = variant === 'stacked'
const currentLength =
maxLength != null && typeof value === 'string' ? value.length : undefined
const describedBy = [
description && isStacked ? descriptionId : undefined,
supportiveText ? hintId : undefined,
]
.filter(Boolean)
.join(' ') || undefined
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-grey-01', disabled && 'opacity-50')}
>
{description}
</p>
)}
</div>
)}
<div
className={cn(
'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]',
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>
)}
{leftIcon && (
<span
className={cn('inline-flex shrink-0 items-center justify-center text-grey-01 [&>svg]:size-full', styles.icon)}
>
{leftIcon}
</span>
)}
<input
ref={ref}
id={id}
disabled={disabled}
readOnly={readOnly}
maxLength={maxLength}
value={value}
defaultValue={defaultValue}
aria-invalid={hasError || undefined}
aria-describedby={describedBy}
className={cn(
'min-w-0 flex-1 bg-transparent font-normal text-grey-01 outline-none',
'placeholder:text-grey-01/50',
styles.input,
disabled && 'text-grey-01/50',
)}
{...props}
/>
{rightIcon && (
<span
className={cn('inline-flex shrink-0 items-center justify-center text-grey-01 [&>svg]:size-full', styles.icon)}
>
{rightIcon}
</span>
)}
</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>
)
},
)
Input.displayName = 'Input'

View File

@@ -0,0 +1,2 @@
export { Input } from './Input'
export type { InputProps } from './Input'

View File

@@ -0,0 +1,138 @@
import { useState } from 'react'
import type { Meta, StoryObj } from '@storybook/react-vite'
import { Radio, RadioGroup } from './Radio'
const meta: Meta<typeof RadioGroup> = {
title: 'UI/Radio',
component: RadioGroup,
tags: ['autodocs'],
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/design/mrabO6AtxN3ektGiTk0I9c/ResearchInsights?node-id=33-5188',
},
},
}
export default meta
type Story = StoryObj<typeof meta>
const ControlledExample = () => {
const [value, setValue] = useState('email')
return (
<RadioGroup label="Notification method" value={value} onChange={setValue}>
<Radio value="email" label="Email" />
<Radio value="sms" label="SMS" />
<Radio value="push" label="Push notification" />
</RadioGroup>
)
}
export const Default: Story = {
render: () => <ControlledExample />,
}
export const WithDescriptions: Story = {
render: () => {
const [value, setValue] = useState('standard')
return (
<RadioGroup
label="Export format"
description="Choose how your data will be exported."
value={value}
onChange={setValue}
>
<Radio
value="standard"
label="Standard CSV"
description="Comma-separated values, compatible with most tools."
/>
<Radio
value="excel"
label="Excel workbook"
description="Formatted spreadsheet with multiple sheets."
/>
<Radio
value="json"
label="JSON"
description="Machine-readable format for programmatic access."
/>
</RadioGroup>
)
},
}
export const Horizontal: Story = {
render: () => {
const [value, setValue] = useState('all')
return (
<RadioGroup
label="Filter by status"
orientation="horizontal"
value={value}
onChange={setValue}
>
<Radio value="all" label="All" />
<Radio value="active" label="Active" />
<Radio value="archived" label="Archived" />
</RadioGroup>
)
},
}
export const WithError: Story = {
render: () => (
<RadioGroup label="Participant type" error="Please select a participant type">
<Radio value="student" label="Student" />
<Radio value="teacher" label="Teacher" />
<Radio value="parent" label="Parent/carer" />
</RadioGroup>
),
}
export const Disabled: Story = {
render: () => (
<RadioGroup label="Plan" disabled defaultValue="free">
<Radio value="free" label="Free" />
<Radio value="pro" label="Professional" />
<Radio value="enterprise" label="Enterprise" />
</RadioGroup>
),
}
export const AllStates: Story = {
render: () => (
<div className="flex flex-col gap-8">
<RadioGroup label="Default group" defaultValue="a">
<Radio value="a" label="Option A" />
<Radio value="b" label="Option B" />
<Radio value="c" label="Option C" />
</RadioGroup>
<RadioGroup
label="With descriptions"
description="Pick one of the following."
defaultValue="x"
>
<Radio value="x" label="Option X" description="Description for option X." />
<Radio value="y" label="Option Y" description="Description for option Y." />
</RadioGroup>
<RadioGroup label="With error" error="Selection required">
<Radio value="1" label="Choice 1" />
<Radio value="2" label="Choice 2" />
</RadioGroup>
<RadioGroup label="Disabled" disabled defaultValue="on">
<Radio value="on" label="Enabled" />
<Radio value="off" label="Disabled" />
</RadioGroup>
<RadioGroup label="Horizontal" orientation="horizontal" defaultValue="left">
<Radio value="left" label="Left" />
<Radio value="center" label="Centre" />
<Radio value="right" label="Right" />
</RadioGroup>
</div>
),
}

View File

@@ -0,0 +1,205 @@
import {
createContext,
forwardRef,
useContext,
useId,
type InputHTMLAttributes,
} from 'react'
import { cn } from '@/lib/utils'
interface RadioGroupContextValue {
name: string
value?: string
disabled?: boolean
hasError?: boolean
onChange?: (value: string) => void
}
const RadioGroupContext = createContext<RadioGroupContextValue | null>(null)
export interface RadioGroupProps {
label?: string
description?: string
error?: string
value?: string
defaultValue?: string
disabled?: boolean
orientation?: 'vertical' | 'horizontal'
name?: string
onChange?: (value: string) => void
children: React.ReactNode
className?: string
}
export const RadioGroup = forwardRef<HTMLFieldSetElement, RadioGroupProps>(
(
{
label,
description,
error,
value,
defaultValue,
disabled,
orientation = 'vertical',
name: nameProp,
onChange,
children,
className,
},
ref,
) => {
const autoId = useId()
const name = nameProp ?? autoId
const descriptionId = `${name}-description`
const errorId = `${name}-error`
const hasError = !!error
return (
<RadioGroupContext.Provider value={{ name, value: value ?? defaultValue, disabled, hasError, onChange }}>
<fieldset
ref={ref}
disabled={disabled}
className={cn('flex flex-col gap-1.5', className)}
aria-describedby={
[description ? descriptionId : undefined, hasError ? errorId : undefined]
.filter(Boolean)
.join(' ') || undefined
}
>
{(label || description) && (
<div className="mb-1 flex flex-col gap-0.5">
{label && (
<legend
className={cn(
'text-small font-bold',
hasError ? 'text-control-error' : 'text-control-label',
disabled && 'opacity-50',
)}
>
{label}
</legend>
)}
{description && (
<p
id={descriptionId}
className={cn('text-small text-text', disabled && 'opacity-50')}
>
{description}
</p>
)}
</div>
)}
<div
className={cn(
'flex gap-3',
orientation === 'vertical' ? 'flex-col' : 'flex-row flex-wrap',
)}
role="radiogroup"
>
{children}
</div>
{hasError && (
<div id={errorId} className="flex items-center gap-1 text-small text-control-error">
<svg
className="size-4 shrink-0"
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>{error}</p>
</div>
)}
</fieldset>
</RadioGroupContext.Provider>
)
},
)
RadioGroup.displayName = 'RadioGroup'
export interface RadioProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'size'> {
label?: string
description?: string
value: string
}
export const Radio = forwardRef<HTMLInputElement, RadioProps>(
({ label, description, value, disabled: disabledProp, className, id: idProp, ...props }, ref) => {
const autoId = useId()
const id = idProp ?? autoId
const descriptionId = `${id}-description`
const group = useContext(RadioGroupContext)
const name = group?.name
const isChecked = group?.value != null ? group.value === value : undefined
const disabled = disabledProp ?? group?.disabled
const hasError = group?.hasError
const handleChange = () => {
group?.onChange?.(value)
}
return (
<div className={cn('flex gap-2', className)}>
<div className="flex h-6 items-center">
<input
ref={ref}
type="radio"
id={id}
name={name}
value={value}
checked={isChecked}
disabled={disabled}
onChange={handleChange}
aria-describedby={description ? descriptionId : undefined}
className={cn(
'peer size-5 cursor-pointer appearance-none rounded-full border-2 border-control-border bg-control-bg transition-colors',
'hover:border-control-border-hover',
'checked:border-[6px] checked:border-control-checked',
'checked:hover:border-control-checked-hover',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-control-focus-ring focus-visible:ring-offset-1',
'active:scale-95',
'disabled:pointer-events-none disabled:opacity-50',
hasError && 'border-control-error checked:border-control-error',
)}
{...props}
/>
</div>
{(label || description) && (
<div className="flex flex-col gap-0.5 pt-px">
{label && (
<label
htmlFor={id}
className={cn(
'cursor-pointer text-body font-normal text-grey-01',
disabled && 'pointer-events-none opacity-50',
)}
>
{label}
</label>
)}
{description && (
<p
id={descriptionId}
className={cn('text-small text-text', disabled && 'opacity-50')}
>
{description}
</p>
)}
</div>
)}
</div>
)
},
)
Radio.displayName = 'Radio'

View File

@@ -0,0 +1,2 @@
export { Radio, RadioGroup } from './Radio'
export type { RadioProps, RadioGroupProps } from './Radio'

View File

@@ -0,0 +1,85 @@
import { useState } from 'react'
import type { Meta, StoryObj } from '@storybook/react-vite'
import { Switch } from './Switch'
const meta: Meta<typeof Switch> = {
title: 'UI/Switch',
component: Switch,
tags: ['autodocs'],
argTypes: {
label: { control: 'text' },
description: { control: 'text' },
checked: { control: 'boolean' },
disabled: { control: 'boolean' },
},
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/design/mrabO6AtxN3ektGiTk0I9c/ResearchInsights?node-id=33-5337',
},
},
}
export default meta
type Story = StoryObj<typeof meta>
const ControlledSwitch = (props: React.ComponentProps<typeof Switch>) => {
const [checked, setChecked] = useState(props.checked ?? false)
return <Switch {...props} checked={checked} onChange={setChecked} />
}
export const Default: Story = {
render: () => <ControlledSwitch label="Enable notifications" />,
}
export const On: Story = {
render: () => <ControlledSwitch label="Enable notifications" checked />,
}
export const WithDescription: Story = {
render: () => (
<ControlledSwitch
label="Auto-save responses"
description="Automatically save participant responses as they are entered."
checked
/>
),
}
export const Disabled: Story = {
render: () => (
<div className="flex flex-col gap-4">
<Switch label="Disabled off" disabled />
<Switch label="Disabled on" disabled checked />
</div>
),
}
export const Standalone: Story = {
render: () => (
<div className="flex items-center gap-4">
<span className="text-body text-grey-01">Dark mode</span>
<ControlledSwitch aria-label="Toggle dark mode" />
</div>
),
}
export const AllStates: Story = {
render: () => (
<div className="flex flex-col gap-4">
<ControlledSwitch label="Off" />
<ControlledSwitch label="On" checked />
<ControlledSwitch
label="With description"
description="Additional context about this setting."
/>
<ControlledSwitch
label="On with description"
description="This feature is currently enabled."
checked
/>
<Switch label="Disabled off" disabled />
<Switch label="Disabled on" disabled checked />
</div>
),
}

View File

@@ -0,0 +1,89 @@
import { forwardRef, useId, type ButtonHTMLAttributes } from 'react'
import { cn } from '@/lib/utils'
export interface SwitchProps
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'onChange' | 'role'> {
label?: string
description?: string
checked?: boolean
disabled?: boolean
onChange?: (checked: boolean) => void
}
export const Switch = forwardRef<HTMLButtonElement, SwitchProps>(
(
{
label,
description,
checked = false,
disabled,
onChange,
className,
id: idProp,
...props
},
ref,
) => {
const autoId = useId()
const id = idProp ?? autoId
const descriptionId = `${id}-description`
return (
<div className={cn('flex items-start gap-3', className)}>
<button
ref={ref}
id={id}
type="button"
role="switch"
aria-checked={checked}
aria-describedby={description ? descriptionId : undefined}
disabled={disabled}
onClick={() => onChange?.(!checked)}
className={cn(
'relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full transition-colors duration-150',
checked ? 'bg-control-checked' : 'bg-control-border',
!disabled && checked && 'hover:bg-control-checked-hover',
!disabled && !checked && 'hover:bg-control-border-hover',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-control-focus-ring focus-visible:ring-offset-2',
'active:scale-[0.97]',
'disabled:pointer-events-none disabled:opacity-50',
)}
{...props}
>
<span
className={cn(
'pointer-events-none inline-block size-[18px] rounded-full bg-white shadow-default transition-transform duration-150',
checked ? 'translate-x-[22px]' : 'translate-x-[3px]',
)}
/>
</button>
{(label || description) && (
<div className="flex flex-col gap-0.5 pt-px">
{label && (
<label
htmlFor={id}
className={cn(
'cursor-pointer text-body font-normal text-grey-01',
disabled && 'pointer-events-none opacity-50',
)}
>
{label}
</label>
)}
{description && (
<p
id={descriptionId}
className={cn('text-small text-text', disabled && 'opacity-50')}
>
{description}
</p>
)}
</div>
)}
</div>
)
},
)
Switch.displayName = 'Switch'

View File

@@ -0,0 +1,2 @@
export { Switch } from './Switch'
export type { SwitchProps } from './Switch'

View File

@@ -70,6 +70,18 @@
--color-bg: var(--color-off-white);
--color-surface: var(--color-white);
/* Form Controls */
--color-control-border: var(--color-grey-03);
--color-control-border-hover: var(--color-grey-01);
--color-control-checked: var(--color-blue-01);
--color-control-checked-hover: var(--color-blue-02);
--color-control-focus-ring: var(--color-blue-04);
--color-control-label: var(--color-blue-01);
--color-control-description: var(--color-grey-02);
--color-control-error: var(--color-red-02);
--color-control-bg: var(--color-white);
--color-control-bg-readonly: var(--color-off-white);
/* Radius */
--radius-sm: 4px;
--radius-default: 6px;