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,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'