Replace palette token references (blue-01, red-02, grey-01) with dedicated button domain tokens. Rename `color` prop to `intent` with semantic values (default, danger, subtle, neutral). Add loading state with spinner and aria-busy. Fix Checkbox and Radio error states leaking teal focus ring. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
145 lines
5.0 KiB
TypeScript
145 lines
5.0 KiB
TypeScript
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 hover:border-control-error checked:border-control-error checked:bg-control-error checked:hover:border-control-error checked:hover:bg-control-error focus-visible:ring-red-03',
|
|
)}
|
|
{...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'
|