Files
ADS3-Design-System/src/components/atoms/Radio/Radio.tsx
Richie 722475215d 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>
2026-05-22 09:10:12 +10:00

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'