Reorganise components into atoms/molecules/organisms and fix Input icon colours
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>
This commit is contained in:
206
src/components/atoms/Radio/Radio.tsx
Normal file
206
src/components/atoms/Radio/Radio.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
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'
|
||||
Reference in New Issue
Block a user