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