192 lines
4.9 KiB
TypeScript
192 lines
4.9 KiB
TypeScript
import React from 'react';
|
|
import { cn } from '@/lib/utils';
|
|
import FormLabel from './FormLabel';
|
|
import FormError from './FormError';
|
|
|
|
/**
|
|
* FormRadio Component
|
|
* Radio button group with custom styling and keyboard navigation
|
|
*/
|
|
|
|
export interface RadioOption {
|
|
value: string;
|
|
label: string;
|
|
disabled?: boolean;
|
|
description?: string;
|
|
}
|
|
|
|
export interface FormRadioProps {
|
|
label?: string;
|
|
error?: string | string[];
|
|
helpText?: string;
|
|
required?: boolean;
|
|
options: RadioOption[];
|
|
value?: string;
|
|
onChange?: (value: string) => void;
|
|
containerClassName?: string;
|
|
radioClassName?: string;
|
|
disabled?: boolean;
|
|
id?: string;
|
|
name?: string;
|
|
layout?: 'vertical' | 'horizontal';
|
|
}
|
|
|
|
export const FormRadio: React.FC<FormRadioProps> = ({
|
|
label,
|
|
error,
|
|
helpText,
|
|
required = false,
|
|
options,
|
|
value,
|
|
onChange,
|
|
containerClassName,
|
|
radioClassName,
|
|
disabled = false,
|
|
id,
|
|
name,
|
|
layout = 'vertical',
|
|
}) => {
|
|
const hasError = !!error;
|
|
const showError = hasError;
|
|
|
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
if (onChange) {
|
|
onChange(e.target.value);
|
|
}
|
|
};
|
|
|
|
const inputId = id || (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined);
|
|
const groupName = name || inputId;
|
|
|
|
const baseRadioClasses = cn(
|
|
'w-4 h-4 border rounded-full transition-all duration-200',
|
|
'focus:outline-none focus:ring-2 focus:ring-primary',
|
|
{
|
|
'border-neutral-dark bg-neutral-light': !hasError,
|
|
'border-danger': hasError,
|
|
'opacity-60 cursor-not-allowed': disabled,
|
|
'cursor-pointer': !disabled,
|
|
},
|
|
radioClassName
|
|
);
|
|
|
|
const selectedIndicatorClasses = cn(
|
|
'w-2.5 h-2.5 rounded-full bg-primary transition-all duration-200',
|
|
{
|
|
'scale-0': false,
|
|
'scale-100': true,
|
|
}
|
|
);
|
|
|
|
const containerClasses = cn(
|
|
'flex flex-col gap-2',
|
|
{
|
|
'gap-3': layout === 'vertical',
|
|
'gap-4 flex-row flex-wrap': layout === 'horizontal',
|
|
},
|
|
containerClassName
|
|
);
|
|
|
|
const optionWrapperClasses = cn(
|
|
'flex items-start gap-2',
|
|
{
|
|
'opacity-60': disabled,
|
|
}
|
|
);
|
|
|
|
const labelClasses = cn(
|
|
'text-sm font-medium leading-none cursor-pointer',
|
|
{
|
|
'text-text-primary': !hasError,
|
|
'text-danger': hasError,
|
|
}
|
|
);
|
|
|
|
const descriptionClasses = 'text-xs text-text-secondary mt-0.5';
|
|
|
|
return (
|
|
<div className={cn('flex flex-col gap-1.5', containerClassName)}>
|
|
{label && (
|
|
<FormLabel htmlFor={inputId} required={required}>
|
|
{label}
|
|
</FormLabel>
|
|
)}
|
|
|
|
<div
|
|
className={containerClasses}
|
|
role="radiogroup"
|
|
aria-labelledby={inputId ? `${inputId}-label` : undefined}
|
|
aria-invalid={hasError}
|
|
>
|
|
{options.map((option) => {
|
|
const optionId = `${inputId}-${option.value}`;
|
|
const isChecked = value === option.value;
|
|
|
|
return (
|
|
<div key={option.value} className={optionWrapperClasses}>
|
|
<div className="relative flex items-center justify-center">
|
|
<input
|
|
type="radio"
|
|
id={optionId}
|
|
name={groupName}
|
|
value={option.value}
|
|
checked={isChecked}
|
|
onChange={handleChange}
|
|
disabled={disabled || option.disabled}
|
|
required={required}
|
|
aria-describedby={helpText || showError || option.description ? `${inputId}-error ${optionId}-desc` : undefined}
|
|
className={cn(
|
|
baseRadioClasses,
|
|
option.disabled && 'opacity-50',
|
|
isChecked && 'border-primary'
|
|
)}
|
|
/>
|
|
|
|
{isChecked && (
|
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
|
<div className={selectedIndicatorClasses} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex-1">
|
|
<label
|
|
htmlFor={optionId}
|
|
className={cn(
|
|
labelClasses,
|
|
option.disabled && 'opacity-50'
|
|
)}
|
|
>
|
|
{option.label}
|
|
</label>
|
|
|
|
{option.description && (
|
|
<p
|
|
className={descriptionClasses}
|
|
id={`${optionId}-desc`}
|
|
>
|
|
{option.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{helpText && (
|
|
<p className="text-xs text-text-secondary" id={`${inputId}-help`}>
|
|
{helpText}
|
|
</p>
|
|
)}
|
|
|
|
{showError && (
|
|
<FormError errors={error} id={`${inputId}-error`} />
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
FormRadio.displayName = 'FormRadio';
|
|
|
|
export default FormRadio; |