Files
klz-cables.com/components/forms/FormRadio.tsx
2025-12-29 18:18:48 +01:00

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;