169 lines
4.8 KiB
TypeScript
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; |