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

169 lines
4.8 KiB
TypeScript

import React, { useState, useEffect, useRef, useCallback } from 'react';
import { cn } from '@/lib/utils';
import FormLabel from './FormLabel';
import FormError from './FormError';
/**
* FormTextarea Component
* Textarea with resize options, character counter, auto-resize, and validation states
*/
export interface FormTextareaProps extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'maxLength'> {
label?: string;
error?: string | string[];
helpText?: string;
required?: boolean;
showCharCount?: boolean;
autoResize?: boolean;
maxHeight?: number;
minHeight?: number;
containerClassName?: string;
textareaClassName?: string;
maxLength?: number;
}
export const FormTextarea: React.FC<FormTextareaProps> = ({
label,
error,
helpText,
required = false,
showCharCount = false,
autoResize = false,
maxHeight = 300,
minHeight = 120,
containerClassName,
textareaClassName,
maxLength,
disabled = false,
value = '',
onChange,
rows = 4,
...props
}) => {
const [isFocused, setIsFocused] = useState(false);
const [charCount, setCharCount] = useState(0);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const hasError = !!error;
const showError = hasError;
// Update character count
useEffect(() => {
const currentValue = typeof value === 'string' ? value : String(value || '');
setCharCount(currentValue.length);
}, [value]);
// Auto-resize textarea
useEffect(() => {
if (!autoResize || !textareaRef.current) return;
const textarea = textareaRef.current;
// Reset height to calculate new height
textarea.style.height = 'auto';
// Calculate new height
const newHeight = Math.min(
Math.max(textarea.scrollHeight, minHeight),
maxHeight
);
textarea.style.height = `${newHeight}px`;
}, [value, autoResize, minHeight, maxHeight]);
const handleFocus = () => setIsFocused(true);
const handleBlur = () => setIsFocused(false);
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
if (maxLength && e.target.value.length > maxLength) {
e.target.value = e.target.value.slice(0, maxLength);
}
onChange?.(e);
}, [onChange, maxLength]);
const inputId = props.id || (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined);
const baseTextareaClasses = cn(
'w-full px-3 py-2 border rounded-md transition-all duration-200 resize-y',
'bg-neutral-light text-text-primary',
'placeholder:text-text-light',
'focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary',
'disabled:opacity-60 disabled:cursor-not-allowed',
{
'border-neutral-dark hover:border-neutral-dark': !hasError && !isFocused,
'border-primary ring-2 ring-primary': isFocused && !hasError,
'border-danger ring-2 ring-danger/20': hasError,
},
autoResize && 'overflow-hidden',
textareaClassName
);
const containerClasses = cn(
'flex flex-col gap-1.5',
containerClassName
);
const charCountClasses = cn(
'text-xs text-right mt-1',
{
'text-text-secondary': charCount <= (maxLength || 0) * 0.8,
'text-warning': charCount > (maxLength || 0) * 0.8 && charCount <= (maxLength || 0),
'text-danger': charCount > (maxLength || 0),
}
);
const showCharCounter = showCharCount || (maxLength && charCount > 0);
return (
<div className={containerClasses}>
{label && (
<FormLabel htmlFor={inputId} required={required}>
{label}
</FormLabel>
)}
<div className="relative">
<textarea
ref={textareaRef}
id={inputId}
className={baseTextareaClasses}
value={value}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={disabled}
rows={rows}
aria-invalid={hasError}
aria-describedby={helpText || showError || showCharCounter ? `${inputId}-error ${inputId}-help ${inputId}-count` : undefined}
required={required}
maxLength={maxLength}
style={autoResize ? { minHeight: `${minHeight}px`, overflow: 'hidden' } : {}}
{...props}
/>
</div>
<div className="flex justify-between items-center gap-2">
{helpText && (
<p className="text-xs text-text-secondary flex-1" id={`${inputId}-help`}>
{helpText}
</p>
)}
{showCharCounter && (
<p className={charCountClasses} id={`${inputId}-count`}>
{charCount}
{maxLength ? ` / ${maxLength}` : ''}
</p>
)}
</div>
{showError && (
<FormError errors={error} id={`${inputId}-error`} />
)}
</div>
);
};
FormTextarea.displayName = 'FormTextarea';
export default FormTextarea;