1'use client';
2
3import { cva, type VariantProps } from 'class-variance-authority';
4import * as React from 'react';
5import { cn } from '../lib/cn';
6import { Label } from './label';
7
8const textareaVariants = cva(
9 'placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring aria-invalid:border-destructive aria-invalid:focus-visible:ring-destructive flex w-full min-w-0 rounded-md border bg-transparent transition-[color,box-shadow] outline-none focus-visible:ring-[1px] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-40',
10 {
11 variants: {
12 size: {
13 sm: 'min-h-14 px-2.5 py-1.5 text-base md:text-xs',
14 default: 'min-h-16 px-3 py-2 text-base md:text-sm',
15 lg: 'min-h-20 px-4 py-2.5 text-base',
16 },
17 resize: {
18 none: 'resize-none',
19 vertical: 'resize-y',
20 both: 'resize',
21 },
22 },
23 defaultVariants: {
24 size: 'default',
25 resize: 'vertical',
26 },
27 },
28);
29
30type TextareaSize = VariantProps<typeof textareaVariants>['size'];
31type TextareaResize = VariantProps<typeof textareaVariants>['resize'];
32
33interface TextareaProps extends Omit<React.ComponentProps<'textarea'>, 'size'> {
34 label?: string;
35 error?: string;
36 description?: string;
37 size?: TextareaSize;
38 resize?: TextareaResize;
39 autoResize?: boolean;
40 maxRows?: number;
41 showCount?: boolean;
42}
43
44const TextareaWrapper = ({
45 className,
46 ref,
47 ...props
48}: React.HTMLAttributes<HTMLDivElement> & { ref?: React.Ref<HTMLDivElement> }) => (
49 <div ref={ref} className={cn('flex flex-col gap-1.5', className)} {...props} />
50);
51TextareaWrapper.displayName = 'TextareaWrapper';
52
53const TextareaDescription = ({
54 className,
55 ref,
56 ...props
57}: React.HTMLAttributes<HTMLSpanElement> & { ref?: React.Ref<HTMLSpanElement> }) => (
58 <span ref={ref} className={cn('text-muted-foreground text-xs', className)} {...props} />
59);
60TextareaDescription.displayName = 'TextareaDescription';
61
62const TextareaError = ({
63 className,
64 ref,
65 ...props
66}: React.HTMLAttributes<HTMLSpanElement> & { ref?: React.Ref<HTMLSpanElement> }) => (
67 <span
68 ref={ref}
69 aria-live="polite"
70 className={cn('text-destructive-text text-xs', !props.children && 'sr-only', className)}
71 {...props}
72 />
73);
74TextareaError.displayName = 'TextareaError';
75
76const TextareaCount = ({
77 className,
78 ref,
79 ...props
80}: React.HTMLAttributes<HTMLSpanElement> & { ref?: React.Ref<HTMLSpanElement> }) => (
81 <span
82 ref={ref}
83 className={cn('text-muted-foreground ml-auto text-xs tabular-nums', className)}
84 {...props}
85 />
86);
87TextareaCount.displayName = 'TextareaCount';
88
89const useIsomorphicLayoutEffect =
90 typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect;
91
92function valueLength(value: unknown): number {
93 if (typeof value === 'string') return value.length;
94 if (typeof value === 'number') return String(value).length;
95 return 0;
96}
97
98const TextareaRoot = ({
99 className,
100 label,
101 error,
102 description,
103 size,
104 resize,
105 autoResize = false,
106 maxRows,
107 showCount = false,
108 maxLength,
109 id,
110 ref,
111 rows,
112 value,
113 defaultValue,
114 onChange,
115 'aria-describedby': ariaDescribedBy,
116 ...props
117}: TextareaProps & { ref?: React.Ref<HTMLTextAreaElement> }) => {
118 const generatedId = React.useId();
119 const textareaId = id ?? generatedId;
120 const errorId = `${textareaId}-error`;
121 const descriptionId = `${textareaId}-description`;
122 const countId = `${textareaId}-count`;
123
124 const innerRef = React.useRef<HTMLTextAreaElement>(null);
125 const [uncontrolledLength, setUncontrolledLength] = React.useState(() =>
126 valueLength(defaultValue),
127 );
128 const length = value !== undefined ? valueLength(value) : uncontrolledLength;
129
130 const mergedRef = React.useCallback(
131 (node: HTMLTextAreaElement | null) => {
132 innerRef.current = node;
133 if (typeof ref === 'function') ref(node);
134 else if (ref) (ref as React.MutableRefObject<HTMLTextAreaElement | null>).current = node;
135 },
136 [ref],
137 );
138
139 const fitToContent = React.useCallback(() => {
140 const node = innerRef.current;
141 if (!node || !autoResize) return;
142
143 const styles = window.getComputedStyle(node);
144 const lineHeight = Number.parseFloat(styles.lineHeight) || 20;
145 const padding =
146 (Number.parseFloat(styles.paddingTop) || 0) + (Number.parseFloat(styles.paddingBottom) || 0);
147 const border =
148 (Number.parseFloat(styles.borderTopWidth) || 0) +
149 (Number.parseFloat(styles.borderBottomWidth) || 0);
150 const limit = maxRows ? maxRows * lineHeight + padding + border : Number.POSITIVE_INFINITY;
151
152 node.style.height = 'auto';
153 const next = node.scrollHeight + border;
154 node.style.height = `${Math.min(next, limit)}px`;
155 node.style.overflowY = next > limit ? 'auto' : 'hidden';
156 }, [autoResize, maxRows]);
157
158 useIsomorphicLayoutEffect(() => {
159 fitToContent();
160 }, [fitToContent, value]);
161
162 React.useEffect(() => {
163 const node = innerRef.current;
164 if (!node || !autoResize || typeof ResizeObserver === 'undefined') return;
165 let lastWidth = node.getBoundingClientRect().width;
166 const observer = new ResizeObserver((entries) => {
167 const width = entries[0]?.contentRect.width ?? lastWidth;
168 if (width === lastWidth) return;
169 lastWidth = width;
170 fitToContent();
171 });
172 observer.observe(node);
173 return () => observer.disconnect();
174 }, [autoResize, fitToContent]);
175
176 const describedBy =
177 [
178 description ? descriptionId : undefined,
179 error ? errorId : undefined,
180 showCount ? countId : undefined,
181 ariaDescribedBy,
182 ]
183 .filter(Boolean)
184 .join(' ') || undefined;
185
186 return (
187 <TextareaWrapper>
188 {label && <Label htmlFor={textareaId}>{label}</Label>}
189 {description && <TextareaDescription id={descriptionId}>{description}</TextareaDescription>}
190 <textarea
191 ref={mergedRef}
192 id={textareaId}
193 data-slot="textarea"
194 rows={rows ?? (autoResize ? 1 : undefined)}
195 maxLength={maxLength}
196 value={value}
197 defaultValue={defaultValue}
198 className={cn(
199 textareaVariants({ size, resize: autoResize ? 'none' : resize }),
200 error && 'border-destructive focus-visible:border-destructive',
201 className,
202 )}
203 aria-invalid={!!error}
204 aria-describedby={describedBy}
205 onChange={(event) => {
206 onChange?.(event);
207 if (value === undefined) setUncontrolledLength(event.target.value.length);
208 fitToContent();
209 }}
210 {...props}
211 />
212 <div className={cn('flex items-start gap-3', !error && !showCount && 'sr-only')}>
213 <TextareaError id={errorId}>{error}</TextareaError>
214 {showCount && (
215 <TextareaCount id={countId}>
216 {maxLength ? `${length} / ${maxLength}` : length}
217 </TextareaCount>
218 )}
219 </div>
220 </TextareaWrapper>
221 );
222};
223
224TextareaRoot.displayName = 'Textarea';
225
226const Textarea = Object.assign(TextareaRoot, {
227 Wrapper: TextareaWrapper,
228 Label: Label,
229 Description: TextareaDescription,
230 Error: TextareaError,
231 Count: TextareaCount,
232});
233
234export { Textarea, textareaVariants };
235export type { TextareaProps };