Moved all 17 components from ui/ into atomic design tiers: atoms (Button, IconButton, Input, Textarea, Select, Checkbox, Radio, Switch, Badge, Tag, Chip, Tooltip) and molecules (Alert, Accordion, Card, Dialog, Popover). Updated all Storybook titles and cross-component imports. Changed Input icons to primary-dark and replaced palette token references with semantic tokens. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
207 lines
6.0 KiB
TypeScript
207 lines
6.0 KiB
TypeScript
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 hover:border-control-error checked:border-control-error checked:hover:border-control-error focus-visible:ring-red-03',
|
|
)}
|
|
{...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'
|