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