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

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;