diff --git a/.changeset/tall-pandas-grow.md b/.changeset/tall-pandas-grow.md new file mode 100644 index 000000000..af256497f --- /dev/null +++ b/.changeset/tall-pandas-grow.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/kumo": minor +--- + +Add auto-resize support to InputArea and Textarea. diff --git a/packages/kumo-docs-astro/src/components/demos/InputAreaDemo.tsx b/packages/kumo-docs-astro/src/components/demos/InputAreaDemo.tsx index 8b4afdc6f..b4f969615 100644 --- a/packages/kumo-docs-astro/src/components/demos/InputAreaDemo.tsx +++ b/packages/kumo-docs-astro/src/components/demos/InputAreaDemo.tsx @@ -80,6 +80,21 @@ export function InputAreaRowsDemo() { ); } +export function InputAreaAutoResizeDemo() { + return ( + + ); +} + export function InputAreaOptionalFieldDemo() { return ( +### Auto Resize + +

+ Use `autoResize` to let the textarea grow vertically as users type or paste + multi-line content. `minRows` sets the minimum height, and the optional + `maxRows` caps growth — content beyond the cap scrolls. +

+ + + + ### Bare InputArea

diff --git a/packages/kumo/src/components/input/input-area.tsx b/packages/kumo/src/components/input/input-area.tsx index be0c14a32..1ff1e78b6 100644 --- a/packages/kumo/src/components/input/input-area.tsx +++ b/packages/kumo/src/components/input/input-area.tsx @@ -1,9 +1,151 @@ import { inputVariants } from "./input"; import { cn } from "../../utils/cn"; -import { useCallback, type ReactNode } from "react"; +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + type ReactNode, +} from "react"; import * as React from "react"; import { Field as FieldBase } from "@base-ui/react/field"; -import { Field as KumoField, normalizeFieldError, type FieldErrorMatch } from "../field/field"; +import { + Field as KumoField, + normalizeFieldError, + type FieldErrorMatch, +} from "../field/field"; + +// useLayoutEffect warns when rendered with react-dom/server (React 18); +// fall back to useEffect on the server where neither runs anyway. +const useIsomorphicLayoutEffect = + typeof window !== "undefined" ? useLayoutEffect : useEffect; + +function parsePx(value: string): number { + const parsed = Number.parseFloat(value); + return Number.isNaN(parsed) ? 0 : parsed; +} + +/** + * Measures the content height of a textarea and applies it as an explicit + * height, optionally clamped to `maxRows`. Handles box-sizing/borders and + * container width changes (rewrapping). + */ +function useTextareaAutoResize({ + enabled, + minRows, + maxRows, +}: { + enabled: boolean; + minRows: number; + maxRows?: number; +}) { + const textareaRef = useRef(null); + const enabledRef = useRef(enabled); + enabledRef.current = enabled; + const minRowsRef = useRef(minRows); + minRowsRef.current = minRows; + const maxRowsRef = useRef(maxRows); + maxRowsRef.current = maxRows; + + const resize = useCallback(() => { + const textarea = textareaRef.current; + if (!enabledRef.current || !textarea || typeof window === "undefined") + return; + + const style = window.getComputedStyle(textarea); + const borders = + parsePx(style.borderTopWidth) + parsePx(style.borderBottomWidth); + const padding = + parsePx(style.paddingTop) + parsePx(style.paddingBottom); + const isBorderBox = style.boxSizing === "border-box"; + + // Collapsing to `auto` lets scrollHeight report the true content height + // (needed to shrink); measure-then-write happens within a single layout + // pass, before paint. + textarea.style.height = "auto"; + // scrollHeight = content + padding. Convert to the height the current + // box-sizing expects: border-box needs borders added, content-box needs + // padding removed. + let height = isBorderBox + ? textarea.scrollHeight + borders + : textarea.scrollHeight - padding; + + const currentMinRows = minRowsRef.current; + const currentMaxRows = maxRowsRef.current; + if (currentMinRows > 0 || (currentMaxRows && currentMaxRows > 0)) { + // Computed line-height is normally a px value, but some environments + // (e.g. jsdom) can return "normal" or a unitless multiplier like "1.5". + const fontSize = parsePx(style.fontSize); + const rawLineHeight = style.lineHeight; + const lineHeight = + rawLineHeight === "normal" || rawLineHeight === "" + ? fontSize * 1.2 + : rawLineHeight.endsWith("px") + ? parsePx(rawLineHeight) + : parsePx(rawLineHeight) * fontSize; + const boxSpacing = isBorderBox ? padding + borders : 0; + const minHeight = lineHeight * currentMinRows + boxSpacing; + height = Math.max(height, minHeight); + + if (currentMaxRows && currentMaxRows > 0) { + const maxHeight = lineHeight * currentMaxRows + boxSpacing; + if (height > maxHeight) { + height = maxHeight; + // Content exceeds the clamp — it must stay scrollable. + textarea.style.overflowY = "auto"; + } else { + textarea.style.overflowY = "hidden"; + } + } else { + textarea.style.overflowY = "hidden"; + } + } else { + textarea.style.overflowY = "hidden"; + } + + textarea.style.height = `${height}px`; + }, []); + + // Re-measure after every commit while enabled: covers controlled value + // changes, size/variant/className changes, and anything else that reflows. + // Two extra synchronous layouts per render is cheap for a textarea and + // immune to stale-dependency bugs. + useIsomorphicLayoutEffect(() => { + if (enabled) resize(); + }); + + // Setup/teardown per node while enabled. Restores inline styles when + // autoResize turns off or on unmount so the manual resize handle behavior + // returns untouched. + useIsomorphicLayoutEffect(() => { + if (!enabled) return; + const textarea = textareaRef.current; + if (!textarea) return; + + // Width changes rewrap content and change the required height. Only + // react to inline-size changes — our own height writes also fire the + // observer and would otherwise cause redundant resize passes. + let lastWidth = textarea.clientWidth; + const observer = + typeof ResizeObserver !== "undefined" + ? new ResizeObserver(() => { + if (textarea.clientWidth !== lastWidth) { + lastWidth = textarea.clientWidth; + resize(); + } + }) + : null; + observer?.observe(textarea); + + return () => { + observer?.disconnect(); + textarea.style.height = ""; + textarea.style.overflowY = ""; + }; + }, [enabled, resize]); + + return { textareaRef, resize }; +} export const InputArea = React.forwardRef( (props, ref) => { @@ -17,6 +159,10 @@ export const InputArea = React.forwardRef( labelTooltip, description, error, + autoResize = false, + minRows = 1, + maxRows, + rows, ...inputProps } = props; @@ -35,17 +181,54 @@ export const InputArea = React.forwardRef( // Extract required from inputProps to pass to Field for label decoration const { required } = inputProps; + const { textareaRef, resize } = useTextareaAutoResize({ + enabled: autoResize, + minRows, + maxRows, + }); + + // Forwarded ref may be a React 19 callback ref returning a cleanup + // function — honor it instead of calling ref(null) on detach. + const refCleanup = useRef<(() => void) | void>(undefined); + const setTextareaRef = useCallback( + (node: HTMLTextAreaElement | null) => { + textareaRef.current = node; + + if (typeof ref === "function") { + if (node) { + refCleanup.current = ref(node) as (() => void) | void; + } else if (refCleanup.current) { + refCleanup.current(); + refCleanup.current = undefined; + } else { + ref(null); + } + } else if (ref) { + ref.current = node; + } + }, + [ref, textareaRef], + ); + + // Controlled inputs re-render on every keystroke, so the post-render + // layout effect already re-measures; resizing here too would force a + // second synchronous layout. Uncontrolled inputs don't re-render, so the + // change handler is their only resize trigger. + const isControlled = inputProps.value !== undefined; const handleChange = useCallback( (event: React.ChangeEvent) => { onChange?.(event); onValueChange?.(event.target.value); + if (!isControlled) resize(); }, - [onChange, onValueChange], + [onChange, onValueChange, resize, isControlled], ); const textareaClassName = cn( inputVariants({ size, variant, focusIndicator: true }), "h-auto py-2", // Input variant always comes with size, but it does not apply for textarea + autoResize && + "w-full field-sizing-content resize-none scroll-pb-2 [scrollbar-color:var(--color-kumo-line)_transparent] [scrollbar-width:thin] [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar]:bg-transparent [&::-webkit-scrollbar-track]:my-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-kumo-line [&::-webkit-scrollbar-corner]:bg-transparent", className, ); @@ -65,9 +248,10 @@ export const InputArea = React.forwardRef( render={(controlProps) => (