259 lines
6.8 KiB
TypeScript
259 lines
6.8 KiB
TypeScript
import React, { useState, useEffect, useRef } from 'react';
|
|
import { cn } from '@/lib/utils';
|
|
import FormLabel from './FormLabel';
|
|
import FormError from './FormError';
|
|
|
|
/**
|
|
* FormCheckbox Component
|
|
* Single and group checkboxes with indeterminate state and custom styling
|
|
*/
|
|
|
|
export interface CheckboxOption {
|
|
value: string;
|
|
label: string;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
export interface FormCheckboxProps {
|
|
label?: string;
|
|
error?: string | string[];
|
|
helpText?: string;
|
|
required?: boolean;
|
|
checked?: boolean;
|
|
indeterminate?: boolean;
|
|
options?: CheckboxOption[];
|
|
value?: string[];
|
|
onChange?: (value: string[]) => void;
|
|
containerClassName?: string;
|
|
checkboxClassName?: string;
|
|
disabled?: boolean;
|
|
id?: string;
|
|
name?: string;
|
|
}
|
|
|
|
export const FormCheckbox: React.FC<FormCheckboxProps> = ({
|
|
label,
|
|
error,
|
|
helpText,
|
|
required = false,
|
|
checked = false,
|
|
indeterminate = false,
|
|
options,
|
|
value = [],
|
|
onChange,
|
|
containerClassName,
|
|
checkboxClassName,
|
|
disabled = false,
|
|
id,
|
|
name,
|
|
}) => {
|
|
const [internalChecked, setInternalChecked] = useState(checked);
|
|
const checkboxRef = useRef<HTMLInputElement>(null);
|
|
|
|
const hasError = !!error;
|
|
const showError = hasError;
|
|
|
|
// Handle indeterminate state
|
|
useEffect(() => {
|
|
if (checkboxRef.current) {
|
|
checkboxRef.current.indeterminate = indeterminate;
|
|
}
|
|
}, [indeterminate, internalChecked]);
|
|
|
|
// Sync internal state with prop
|
|
useEffect(() => {
|
|
setInternalChecked(checked);
|
|
}, [checked]);
|
|
|
|
const isGroup = Array.isArray(options) && options.length > 0;
|
|
|
|
const handleSingleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const newChecked = e.target.checked;
|
|
setInternalChecked(newChecked);
|
|
if (onChange && !isGroup) {
|
|
// For single checkbox, call onChange with boolean
|
|
// But to maintain consistency, we'll treat it as a group with one option
|
|
if (newChecked) {
|
|
onChange([name || 'checkbox']);
|
|
} else {
|
|
onChange([]);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleGroupChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const optionValue = e.target.value;
|
|
const isChecked = e.target.checked;
|
|
|
|
let newValue: string[];
|
|
|
|
if (isChecked) {
|
|
newValue = [...value, optionValue];
|
|
} else {
|
|
newValue = value.filter((v) => v !== optionValue);
|
|
}
|
|
|
|
if (onChange) {
|
|
onChange(newValue);
|
|
}
|
|
};
|
|
|
|
const inputId = id || (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined);
|
|
|
|
const baseCheckboxClasses = cn(
|
|
'w-4 h-4 rounded border transition-all duration-200',
|
|
'focus:outline-none focus:ring-2 focus:ring-primary',
|
|
{
|
|
'border-neutral-dark bg-neutral-light': !internalChecked && !hasError && !indeterminate,
|
|
'border-primary bg-primary text-white': internalChecked && !hasError,
|
|
'border-danger bg-danger text-white': hasError,
|
|
'border-primary bg-primary/50 text-white': indeterminate,
|
|
'opacity-60 cursor-not-allowed': disabled,
|
|
'cursor-pointer': !disabled,
|
|
},
|
|
checkboxClassName
|
|
);
|
|
|
|
const containerClasses = cn(
|
|
'flex flex-col gap-2',
|
|
containerClassName
|
|
);
|
|
|
|
const groupContainerClasses = cn(
|
|
'flex flex-col gap-2',
|
|
containerClassName
|
|
);
|
|
|
|
const singleWrapperClasses = cn(
|
|
'flex items-start gap-2',
|
|
{
|
|
'opacity-60': disabled,
|
|
}
|
|
);
|
|
|
|
const labelClasses = cn(
|
|
'text-sm font-medium leading-none',
|
|
{
|
|
'text-text-primary': !hasError,
|
|
'text-danger': hasError,
|
|
}
|
|
);
|
|
|
|
// Single checkbox
|
|
if (!isGroup) {
|
|
return (
|
|
<div className={containerClasses}>
|
|
<div className={singleWrapperClasses}>
|
|
<input
|
|
ref={checkboxRef}
|
|
type="checkbox"
|
|
id={inputId}
|
|
name={name}
|
|
checked={internalChecked}
|
|
onChange={handleSingleChange}
|
|
disabled={disabled}
|
|
required={required}
|
|
aria-invalid={hasError}
|
|
aria-describedby={helpText || showError ? `${inputId}-error` : undefined}
|
|
className={baseCheckboxClasses}
|
|
/>
|
|
|
|
<div className="flex-1">
|
|
{label && (
|
|
<label htmlFor={inputId} className={labelClasses}>
|
|
{label}
|
|
{required && <span className="text-danger ml-1">*</span>}
|
|
</label>
|
|
)}
|
|
|
|
{helpText && (
|
|
<p className="text-xs text-text-secondary mt-1" id={`${inputId}-help`}>
|
|
{helpText}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{showError && (
|
|
<FormError errors={error} id={`${inputId}-error`} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Checkbox group
|
|
const groupLabelId = inputId ? `${inputId}-group-label` : undefined;
|
|
const allSelected = options.every(opt => value.includes(opt.value));
|
|
const someSelected = options.some(opt => value.includes(opt.value)) && !allSelected;
|
|
|
|
// Update indeterminate state for group select all
|
|
useEffect(() => {
|
|
if (checkboxRef.current && someSelected) {
|
|
checkboxRef.current.indeterminate = true;
|
|
}
|
|
}, [someSelected, value, options]);
|
|
|
|
return (
|
|
<div className={groupContainerClasses}>
|
|
{label && (
|
|
<div className="mb-2">
|
|
<FormLabel id={groupLabelId} required={required}>
|
|
{label}
|
|
</FormLabel>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex flex-col gap-2" role="group" aria-labelledby={groupLabelId}>
|
|
{options.map((option) => {
|
|
const optionId = `${inputId}-${option.value}`;
|
|
const isChecked = value.includes(option.value);
|
|
|
|
return (
|
|
<div key={option.value} className="flex items-start gap-2">
|
|
<input
|
|
type="checkbox"
|
|
id={optionId}
|
|
value={option.value}
|
|
checked={isChecked}
|
|
onChange={handleGroupChange}
|
|
disabled={disabled || option.disabled}
|
|
required={required && value.length === 0}
|
|
aria-invalid={hasError}
|
|
className={cn(
|
|
baseCheckboxClasses,
|
|
option.disabled && 'opacity-50'
|
|
)}
|
|
/>
|
|
|
|
<div className="flex-1">
|
|
<label
|
|
htmlFor={optionId}
|
|
className={cn(
|
|
labelClasses,
|
|
option.disabled && 'opacity-50'
|
|
)}
|
|
>
|
|
{option.label}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{helpText && (
|
|
<p className="text-xs text-text-secondary mt-1" id={`${inputId}-help`}>
|
|
{helpText}
|
|
</p>
|
|
)}
|
|
|
|
{showError && (
|
|
<FormError errors={error} id={`${inputId}-error`} />
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
FormCheckbox.displayName = 'FormCheckbox';
|
|
|
|
export default FormCheckbox; |