migration wip
This commit is contained in:
192
components/forms/FormRadio.tsx
Normal file
192
components/forms/FormRadio.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user