Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tall-pandas-grow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/kumo": minor
---

Add auto-resize support to InputArea and Textarea.
15 changes: 15 additions & 0 deletions packages/kumo-docs-astro/src/components/demos/InputAreaDemo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,21 @@ export function InputAreaRowsDemo() {
);
}

export function InputAreaAutoResizeDemo() {
return (
<InputArea
label="Configuration value"
defaultValue={
"Review the configuration changes.\n\nAdd follow-up notes here.\n\n"
}
autoResize
minRows={2}
maxRows={8}
description="Resizes vertically with content size, up to 8 rows"
/>
);
}

export function InputAreaOptionalFieldDemo() {
return (
<InputArea
Expand Down
12 changes: 12 additions & 0 deletions packages/kumo-docs-astro/src/pages/components/input-area.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
InputAreaDisabledDemo,
InputAreaBareDemo,
InputAreaRowsDemo,
InputAreaAutoResizeDemo,
InputAreaOptionalFieldDemo,
InputAreaLabelTooltipDemo,
InputAreaReactNodeLabelDemo,
Expand Down Expand Up @@ -142,6 +143,17 @@ export default function Example() {
<InputAreaDisabledDemo client:visible />
</ComponentExample>

### Auto Resize

<p>
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.
</p>
<ComponentExample demo="InputAreaAutoResizeDemo">
<InputAreaAutoResizeDemo client:visible />
</ComponentExample>

### Bare InputArea

<p>
Expand Down
201 changes: 196 additions & 5 deletions packages/kumo/src/components/input/input-area.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLTextAreaElement | null>(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<HTMLTextAreaElement, InputAreaProps>(
(props, ref) => {
Expand All @@ -17,6 +159,10 @@ export const InputArea = React.forwardRef<HTMLTextAreaElement, InputAreaProps>(
labelTooltip,
description,
error,
autoResize = false,
minRows = 1,
maxRows,
rows,
...inputProps
} = props;

Expand All @@ -35,17 +181,54 @@ export const InputArea = React.forwardRef<HTMLTextAreaElement, InputAreaProps>(

// 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<HTMLTextAreaElement>) => {
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",
Comment thread
mattrothenberg marked this conversation as resolved.
className,
);

Expand All @@ -65,9 +248,10 @@ export const InputArea = React.forwardRef<HTMLTextAreaElement, InputAreaProps>(
render={(controlProps) => (
<textarea
{...controlProps}
ref={ref}
ref={setTextareaRef}
className={textareaClassName}
onChange={handleChange}
rows={autoResize ? minRows : rows}
{...inputProps}
/>
)}
Expand All @@ -79,9 +263,10 @@ export const InputArea = React.forwardRef<HTMLTextAreaElement, InputAreaProps>(
// Render bare textarea without Field wrapper
return (
<textarea
ref={ref}
ref={setTextareaRef}
className={textareaClassName}
onChange={handleChange}
rows={autoResize ? minRows : rows}
{...inputProps}
/>
);
Expand Down Expand Up @@ -114,6 +299,12 @@ export type InputAreaProps = {
description?: ReactNode;
/** Error message or validation error object */
error?: string | { message: ReactNode; match: FieldErrorMatch };
/** Automatically resize the textarea based on its content. */
autoResize?: boolean;
/** Minimum number of rows to display when `autoResize` is enabled. @default 1 */
minRows?: number;
/** Maximum number of rows to grow to when `autoResize` is enabled; content beyond this scrolls. */
maxRows?: number;

// Finally, spread the native input props (least important)
} & Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, "size">;
Loading
Loading