"use client"; import { AnimatePresence, animate, motion, useReducedMotion, } from "motion/react"; import { forwardRef, useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type TextareaHTMLAttributes, } from "react"; import { EASE_OUT_CSS, SPRING_PRESS } from "@/lib/ease"; import { cn } from "@/lib/utils"; export type TextareaClassNames = { root?: string; label?: string; description?: string; field?: string; textarea?: string; counter?: string; errorMessage?: string; }; export interface TextareaProps extends Omit< TextareaHTMLAttributes, "value" | "defaultValue" | "onChange" | "rows" > { label?: string; /** Helper text under the label, linked through aria-describedby. */ description?: string; value?: string; defaultValue?: string; onChange?: (value: string) => void; /** Truthy error triggers a shake, red border and (if a string) a message. */ error?: string | boolean; /** Rows shown when empty; the field never shrinks below this. */ minRows?: number; /** Rows the field grows to before it starts scrolling. */ maxRows?: number; /** Shows the character counter. Defaults to true when `maxLength` is set. */ showCount?: boolean; /** Fraction of `maxLength` at which the counter turns to the warning color. */ warnAt?: number; className?: string; classNames?: TextareaClassNames; } const SHAKE = { x: [0, -6, 6, -4, 4, -2, 0] }; export const Textarea = forwardRef( function Textarea( { label, description, value: valueProp, defaultValue, onChange, onFocus, onBlur, error, minRows = 3, maxRows = 8, maxLength, showCount, warnAt = 0.9, placeholder, className, classNames, disabled, id: idProp, ...rest }, forwardedRef, ) { const reactId = useId(); const id = idProp ?? reactId; const reduce = useReducedMotion(); const controlled = valueProp !== undefined; const [internal, setInternal] = useState(defaultValue ?? ""); const value = controlled ? (valueProp ?? "") : internal; const [focused, setFocused] = useState(false); const [height, setHeight] = useState(null); const [scrolls, setScrolls] = useState(false); const fieldRef = useRef(null); const counterRef = useRef(null); const mirrorRef = useRef(null); const innerRef = useRef(null); const setRefs = useCallback( (node: HTMLTextAreaElement | null) => { innerRef.current = node; if (typeof forwardedRef === "function") forwardedRef(node); else if (forwardedRef) forwardedRef.current = node; }, [forwardedRef], ); const hasError = Boolean(error); const errorMessage = typeof error === "string" ? error : null; const countVisible = showCount ?? maxLength !== undefined; const count = value.length; const ratio = maxLength ? count / maxLength : 0; const atLimit = maxLength !== undefined && count >= maxLength; const nearLimit = maxLength !== undefined && ratio >= warnAt && !atLimit; // Measure the content with an invisible twin so the real field can // transition between pixel heights (resetting it to `auto` would kill the // transition). Height is this component's purpose, so it animates layout. const measure = useCallback(() => { const mirror = mirrorRef.current; if (!mirror) return; const style = getComputedStyle(mirror); const lineHeight = Number.parseFloat(style.lineHeight) || 24; const padY = Number.parseFloat(style.paddingTop) + Number.parseFloat(style.paddingBottom); const min = lineHeight * minRows + padY; const max = lineHeight * Math.max(minRows, maxRows) + padY; const content = mirror.scrollHeight; setHeight(Math.min(max, Math.max(min, content))); setScrolls(content > max + 1); }, [minRows, maxRows]); useLayoutEffect(() => { if (mirrorRef.current) mirrorRef.current.value = value || " "; measure(); }, [value, measure]); // Width changes rewrap the text, so re-measure on resize. useEffect(() => { const field = fieldRef.current; if (!field || typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver(() => measure()); observer.observe(field); return () => observer.disconnect(); }, [measure]); // Shake the field when an error appears. useEffect(() => { if (!fieldRef.current || reduce || !hasError) return; animate(fieldRef.current, SHAKE, { duration: 0.45 }); }, [hasError, reduce]); const nudgeCounter = useCallback(() => { if (!counterRef.current || reduce) return; animate( counterRef.current, { x: [0, -4, 4, -3, 3, 0] }, { duration: 0.35 }, ); }, [reduce]); // Typing or pasting past `maxLength` is blocked natively; catch the // attempt first so the counter can shake. Native listener because React's // onBeforeInput misses paste and line breaks. useEffect(() => { const el = innerRef.current; if (!el || maxLength === undefined) return; const onBeforeInput = (event: InputEvent) => { if (!event.inputType.startsWith("insert")) return; const data = event.data ?? event.dataTransfer?.getData("text/plain") ?? (event.inputType === "insertLineBreak" ? "\n" : ""); const selected = el.selectionEnd - el.selectionStart; if (el.value.length - selected + data.length > maxLength) { nudgeCounter(); } }; el.addEventListener("beforeinput", onBeforeInput); return () => el.removeEventListener("beforeinput", onBeforeInput); }, [maxLength, nudgeCounter]); const handleChange = (next: string) => { if (!controlled) setInternal(next); onChange?.(next); }; const describedBy = [ description ? `${id}-description` : null, countVisible ? `${id}-count` : null, errorMessage ? `${id}-error` : null, ] .filter(Boolean) .join(" ") || undefined; const sharedText = "block w-full resize-none bg-transparent px-3.5 py-2.5 text-base leading-6"; return (
{label || description ? (
{label ? ( ) : null} {description ? (

{description}

) : null}
) : null}