feat(FN-4712): complete Step 2 — extract autosize textarea hook

Fusion-Task-Id: FN-4712
Fusion-Task-Lineage: 92ef2161-7ce2-4277-9921-7fe1dd204e98
This commit is contained in:
Fusion (runfusion.ai)
2026-05-15 22:12:11 -07:00
committed by gsxdsm
parent 018bbe40b7
commit accc89c7f5
2 changed files with 112 additions and 0 deletions

View File

@@ -0,0 +1,55 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { useState } from "react";
import { clampTextareaHeight, useAutosizeTextarea } from "../useAutosizeTextarea";
describe("clampTextareaHeight", () => {
it("returns min when scrollHeight is smaller", () => {
expect(clampTextareaHeight(20, { min: 40, max: 320 })).toBe(40);
});
it("returns scrollHeight when within range", () => {
expect(clampTextareaHeight(160, { min: 40, max: 320 })).toBe(160);
});
it("returns max when scrollHeight exceeds cap", () => {
expect(clampTextareaHeight(640, { min: 40, max: 320 })).toBe(320);
});
});
function AutosizeHarness() {
const [value, setValue] = useState("");
const { ref } = useAutosizeTextarea({ value, minHeight: 40, maxHeight: 120 });
return (
<>
<textarea data-testid="autosize-textarea" ref={ref} value={value} onChange={(event) => setValue(event.target.value)} />
<button type="button" onClick={() => setValue("line 1\nline 2\nline 3")}>grow</button>
</>
);
}
describe("useAutosizeTextarea", () => {
it("sets style.height when value changes", async () => {
render(<AutosizeHarness />);
const textarea = screen.getByTestId("autosize-textarea") as HTMLTextAreaElement;
Object.defineProperty(textarea, "scrollHeight", {
configurable: true,
get: () => (textarea.value.includes("\n") ? 160 : 24),
});
await userEvent.click(screen.getByRole("button", { name: "grow" }));
await waitFor(() => {
expect(textarea.style.height).toBe("120px");
});
});
it("tolerates ref unmount", () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => undefined);
const { unmount } = render(<AutosizeHarness />);
expect(() => unmount()).not.toThrow();
spy.mockRestore();
});
});

View File

@@ -0,0 +1,57 @@
import { useCallback, useLayoutEffect, useRef } from "react";
const DEFAULT_MIN_HEIGHT = 40;
const DEFAULT_MAX_HEIGHT = 320;
export function clampTextareaHeight(scrollHeight: number, opts: { min: number; max: number }): number {
return Math.max(opts.min, Math.min(scrollHeight, opts.max));
}
interface UseAutosizeTextareaOptions {
value: string;
minHeight?: number;
maxHeight?: number;
deps?: unknown[];
}
interface UseAutosizeTextareaResult {
ref: (node: HTMLTextAreaElement | null) => void;
resize: () => void;
}
export function useAutosizeTextarea({
value,
minHeight = DEFAULT_MIN_HEIGHT,
maxHeight = DEFAULT_MAX_HEIGHT,
deps = [],
}: UseAutosizeTextareaOptions): UseAutosizeTextareaResult {
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const resize = useCallback(() => {
const node = textareaRef.current;
if (!node) {
return;
}
node.style.height = "auto";
node.style.height = `${clampTextareaHeight(node.scrollHeight, { min: minHeight, max: maxHeight })}px`;
}, [maxHeight, minHeight]);
const ref = useCallback(
(node: HTMLTextAreaElement | null) => {
textareaRef.current = node;
if (!node) {
return;
}
node.style.height = "auto";
node.style.height = `${clampTextareaHeight(node.scrollHeight, { min: minHeight, max: maxHeight })}px`;
},
[maxHeight, minHeight],
);
useLayoutEffect(() => {
resize();
}, [resize, value, ...deps]);
return { ref, resize };
}