import clsx from 'clsx';
import { TextareaHTMLAttributes, forwardRef, useEffect, useRef, useState, useCallback } from "react";
interface TextareaProps extends TextareaHTMLAttributes {
label?: string;
autoResize?: boolean;
maxHeight?: number;
useValidation?: boolean;
validate?: (value: string) => { valid: boolean; errorMessage?: string };
onValidatedChange?: (value: string) => void;
updateOnBlur?: boolean;
}
export const Textarea = forwardRef(({
className,
label,
autoResize = false,
maxHeight = 120, // default max height (roughly 5 lines)
value: propValue,
onChange,
// New validation props
useValidation = false,
validate,
onValidatedChange,
updateOnBlur = false,
onBlur,
onKeyDown,
...props
}, ref) => {
const internalRef = useRef(null);
const textareaRef = (ref as any) || internalRef;
const adjustHeightTimeoutRef = useRef(null);
// Local state for validation mode
const [localValue, setLocalValue] = useState(propValue as string);
const [validationError, setValidationError] = useState();
const [isEditing, setIsEditing] = useState(false);
// update local value when prop value changes
useEffect(() => {
setLocalValue(propValue as string);
}, [propValue]);
// Sync local state with prop value when not editing
useEffect(() => {
if (!isEditing) {
setLocalValue(propValue as string);
}
}, [propValue, isEditing]);
// Debounced adjustHeight function to prevent interference during rapid state changes
const debouncedAdjustHeight = useCallback(() => {
const textarea = textareaRef.current;
if (!textarea || !autoResize) return;
// Clear any pending timeout
if (adjustHeightTimeoutRef.current) {
clearTimeout(adjustHeightTimeoutRef.current);
}
// Debounce the height adjustment to prevent interference during rapid changes
adjustHeightTimeoutRef.current = setTimeout(() => {
// Store current focus state
const hadFocus = document.activeElement === textarea;
const selectionStart = textarea.selectionStart;
const selectionEnd = textarea.selectionEnd;
// Only adjust if the textarea is properly mounted and not currently being focused
if (textarea.offsetParent === null) return;
// Prevent adjustment during focus events to avoid disruption
requestAnimationFrame(() => {
textarea.style.height = 'auto';
const scrollHeight = textarea.scrollHeight;
textarea.style.height = `${Math.min(scrollHeight, maxHeight)}px`;
// Add scrolling if content exceeds maxHeight
textarea.style.overflowY = scrollHeight > maxHeight ? 'auto' : 'hidden';
// Restore focus and selection if it was focused before
if (hadFocus && document.activeElement !== textarea) {
textarea.focus();
textarea.setSelectionRange(selectionStart, selectionEnd);
}
});
}, 10); // Small debounce delay
}, [autoResize, maxHeight, textareaRef]);
useEffect(() => {
debouncedAdjustHeight();
// Add window resize listener
window.addEventListener('resize', debouncedAdjustHeight);
return () => {
window.removeEventListener('resize', debouncedAdjustHeight);
// Clear timeout on cleanup
if (adjustHeightTimeoutRef.current) {
clearTimeout(adjustHeightTimeoutRef.current);
}
};
}, [localValue, debouncedAdjustHeight]);
const validateAndUpdate = (value: string) => {
if (validate) {
const result = validate(value);
setValidationError(result.errorMessage);
if (result.valid && onValidatedChange) {
onValidatedChange(value);
return true;
}
return false;
} else if (onValidatedChange) {
onValidatedChange(value);
return true;
}
return false;
};
const handleChange = (e: React.ChangeEvent) => {
const newValue = e.target.value;
setLocalValue(newValue);
setIsEditing(true);
if (!updateOnBlur) {
if (useValidation) {
validateAndUpdate(newValue);
} else {
onChange?.(e);
}
}
};
const handleBlur = (e: React.FocusEvent) => {
setIsEditing(false);
if (updateOnBlur) {
if (useValidation) {
validateAndUpdate(localValue);
} else {
const syntheticEvent = {
...e,
target: { ...e.target, value: localValue },
currentTarget: { ...e.currentTarget, value: localValue }
};
onChange?.(syntheticEvent as any);
}
}
onBlur?.(e);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (updateOnBlur && e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
textareaRef.current?.blur();
}
onKeyDown?.(e);
};
return (
{label && (
)}
);
});
Textarea.displayName = "Textarea";