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:
143
src/components/ui/Checkbox/Checkbox.tsx
Normal file
143
src/components/ui/Checkbox/Checkbox.tsx
Normal 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'
|
||||
Reference in New Issue
Block a user