diff --git a/.changeset/textarea-autosize-off-screen-measure.md b/.changeset/textarea-autosize-off-screen-measure.md new file mode 100644 index 000000000..248f35184 --- /dev/null +++ b/.changeset/textarea-autosize-off-screen-measure.md @@ -0,0 +1,8 @@ +--- +'@cube-dev/ui-kit': patch +--- + +`TextArea` / `CommandTextArea`: `autoSize` no longer disturbs the page while typing, and a single line is one row again. + +- **The height is now measured off-screen instead of on the live textarea.** Measuring used to set `height: auto` on the real element, force a layout, then restore it — twice per keystroke. Any ancestor sharing the column re-laid out mid-keystroke, so in a chat layout the transcript's scroll viewport grew by the collapsed rows and its scroll offset moved; the browser's scroll anchoring undid that imperfectly, which reads as the whole conversation bouncing a pixel in the rhythm of typing. A textarea already grown past its `rows` minimum — the everyday state of a chat prompt — lost 40px of scroll offset per keystroke with anchoring out of the way. +- **Row counting is fixed.** `height: auto` sizes a textarea from its `rows` attribute and the font's own metrics, and that height was being divided by CSS `line-height` to get a row count. Where the line height is tighter than the font's natural line box, one line of text counted as two rows, so an `autoSize` textarea with `rows={1}` rendered a row taller than its content. Row counting now rounds a measured content height that carries no such floor, which also stops a fractional line height (a zoomed page, a percentage preset) from adding a phantom row. diff --git a/src/components/fields/AGENTS.md b/src/components/fields/AGENTS.md new file mode 100644 index 000000000..81006e97a --- /dev/null +++ b/src/components/fields/AGENTS.md @@ -0,0 +1,16 @@ +# Fields + +Read [`docs/rules/input-components.md`](../../../docs/rules/input-components.md) before touching +anything here — hook order, the two `useFieldProps` modes, id/label wiring, `wrapWithField`, +validation props. + +## Textarea autosize + +`TextArea` and `CommandTextArea` share +[`TextInput/useAutoSizeTextArea.ts`](TextInput/useAutoSizeTextArea.ts). It measures an off-screen +mirror on purpose: **never size a live textarea by mutating its own height** (`height: auto` → read +`scrollHeight` → restore). That re-lays out every ancestor mid-keystroke, and a scroll container +sharing the column then has its scroll offset moved and imperfectly restored by the browser's scroll +anchoring — the chat-input jitter of CUB-4042. Note also that `height: auto` sizes a textarea from +its `rows` attribute and the font's line box, not from CSS `line-height`, so a `scrollHeight` read +that way carries a floor and cannot report a shrink. diff --git a/src/components/fields/CommandTextArea/CommandTextArea.browser.test.tsx b/src/components/fields/CommandTextArea/CommandTextArea.browser.test.tsx new file mode 100644 index 000000000..62cd28298 --- /dev/null +++ b/src/components/fields/CommandTextArea/CommandTextArea.browser.test.tsx @@ -0,0 +1,116 @@ +import { renderWithRoot, screen, userEvent, waitFor } from '../../../test'; + +import { CommandTextArea } from './CommandTextArea'; + +const input = () => screen.getByTestId('prompt') as HTMLTextAreaElement; +const scroller = () => screen.getByTestId('scroller'); + +const heightInRows = (el: HTMLTextAreaElement) => { + const style = getComputedStyle(el); + const box = + style.boxSizing === 'border-box' + ? (parseFloat(style.paddingTop) || 0) + + (parseFloat(style.paddingBottom) || 0) + + (parseFloat(style.borderTopWidth) || 0) + + (parseFloat(style.borderBottomWidth) || 0) + : 0; + + return ( + (el.getBoundingClientRect().height - box) / parseFloat(style.lineHeight) + ); +}; + +/** Three rows of content, so the prompt sits above its own `rows` minimum. */ +const GROWN_VALUE = 'one\ntwo\nthree'; + +/** + * A chat layout: a scrolled transcript above, the prompt below, both sharing + * one column, so anything that changes the prompt's height changes the + * transcript's viewport. + * + * Two details make the perturbation observable: + * + * - The prompt starts **grown** (three rows of content against `rows={1}`). + * `height: auto` sizes a textarea from its `rows` attribute, so a prompt + * sitting at its minimum has nothing to collapse — the state that jitters is + * the everyday one where the user has typed a few lines. + * - `overflow-anchor: none`. Chrome's scroll anchoring hides a transient scroll + * perturbation by undoing it, so with anchoring left on this assertion passes + * whether or not the perturbation happens. + */ +function ChatHarness() { + return ( +
+
+ {Array.from({ length: 40 }, (_, index) => ( +

+ Message {index} +

+ ))} +
+ +
+ ); +} + +describe('CommandTextArea autoSize', () => { + it('gives a single line exactly one row', async () => { + renderWithRoot( + , + ); + + await waitFor(() => expect(heightInRows(input())).toBeCloseTo(1, 1)); + }); + + // CUB-4042: measuring the live textarea (`height: auto` → read `scrollHeight` + // → restore) collapsed the prompt mid-keystroke, which grew the transcript's + // viewport and moved its scroll offset. The conversation visibly bounced on + // every keystroke. + it('does not move a sibling scroll container while typing', async () => { + renderWithRoot(); + + await waitFor(() => expect(heightInRows(input())).toBeCloseTo(3, 1)); + + const container = scroller(); + + container.scrollTop = container.scrollHeight; + + await waitFor(() => expect(container.scrollTop).toBeGreaterThan(0)); + + const before = container.scrollTop; + + // Appending to the last line keeps the row count — and so the prompt's own + // height — unchanged, leaving the transient as the only thing that could + // move the transcript. + await userEvent.click(input()); + await userEvent.keyboard('{End}x'); + + expect(input().value).toBe(`${GROWN_VALUE}x`); + expect(heightInRows(input())).toBeCloseTo(3, 1); + expect(container.scrollTop).toBe(before); + }); +}); diff --git a/src/components/fields/CommandTextArea/CommandTextArea.tsx b/src/components/fields/CommandTextArea/CommandTextArea.tsx index 1a0edafaf..a9731e8ca 100644 --- a/src/components/fields/CommandTextArea/CommandTextArea.tsx +++ b/src/components/fields/CommandTextArea/CommandTextArea.tsx @@ -40,6 +40,7 @@ import { CubeTextInputBaseProps, TextInputBase, } from '../TextInput/TextInputBase'; +import { useAutoSizeTextArea } from '../TextInput/useAutoSizeTextArea'; import { useCaretAnchor } from './useCaretAnchor'; @@ -403,28 +404,13 @@ function CommandTextArea( isActive: shouldShowPopover, }); - // ---- height autosize (mirrors TextArea) ------------------------------- - const adjustHeight = useEvent(() => { - const textarea = inputRef.current; - if (!textarea || !autoSize) return; - - textarea.style.height = 'auto'; - const computedStyle = getComputedStyle(textarea); - const paddingTop = parseFloat(computedStyle.paddingTop) || 0; - const paddingBottom = parseFloat(computedStyle.paddingBottom) || 0; - const borderTop = parseFloat(computedStyle.borderTopWidth) || 0; - const borderBottom = parseFloat(computedStyle.borderBottomWidth) || 0; - const lineHeight = parseInt(computedStyle.lineHeight) || 20; - const contentHeight = textarea.scrollHeight - paddingTop - paddingBottom; - const computedRows = Math.ceil(contentHeight / lineHeight); - const targetRows = Math.max(Math.min(computedRows, maxRows), rows); - const totalHeight = - targetRows * lineHeight + - paddingTop + - paddingBottom + - borderTop + - borderBottom; - textarea.style.height = `${totalHeight}px`; + // ---- height autosize (shared with TextArea) --------------------------- + const adjustHeight = useAutoSizeTextArea({ + inputRef, + autoSize, + rows, + maxRows, + value: effectiveValue, }); // ---- useTextField (ARIA wiring for the textarea) ---------------------- @@ -447,20 +433,6 @@ function CommandTextArea( const useEnvironmentalEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect; - useEnvironmentalEffect(() => { - if (!autoSize || !inputRef.current) return; - adjustHeight(); - const resizeObserver = new ResizeObserver(adjustHeight); - resizeObserver.observe(inputRef.current); - return () => resizeObserver.disconnect(); - }, [autoSize, inputRef.current]); - - useEnvironmentalEffect(() => { - if (autoSize && inputRef.current) { - adjustHeight(); - } - }, [effectiveValue]); - // ---- caret restore after commit -------------------------------------- const pendingCaretRef = useRef(null); useEnvironmentalEffect(() => { diff --git a/src/components/fields/TextArea/TextArea.browser.test.tsx b/src/components/fields/TextArea/TextArea.browser.test.tsx new file mode 100644 index 000000000..a90680de9 --- /dev/null +++ b/src/components/fields/TextArea/TextArea.browser.test.tsx @@ -0,0 +1,133 @@ +import { renderWithRoot, screen, userEvent, waitFor } from '../../../test'; + +import { TextArea } from './TextArea'; + +/** + * A line height tighter than the font's natural line box. This is the case the + * row arithmetic used to get wrong: a textarea's `height: auto` height comes + * from the font's own metrics, so dividing it by a tighter `line-height` + * rounded a single line up to two rows. + */ +const TIGHT_TYPOGRAPHY = { fontSize: '16px', lineHeight: '16px' } as const; + +const input = () => screen.getByTestId('area') as HTMLTextAreaElement; + +/** The height one row of this textarea should occupy, borders included. */ +function rowMetrics(el: HTMLTextAreaElement) { + const style = getComputedStyle(el); + const lineHeight = parseFloat(style.lineHeight); + const box = + style.boxSizing === 'border-box' + ? (parseFloat(style.paddingTop) || 0) + + (parseFloat(style.paddingBottom) || 0) + + (parseFloat(style.borderTopWidth) || 0) + + (parseFloat(style.borderBottomWidth) || 0) + : 0; + + return { lineHeight, box }; +} + +const heightInRows = (el: HTMLTextAreaElement) => { + const { lineHeight, box } = rowMetrics(el); + + return (el.getBoundingClientRect().height - box) / lineHeight; +}; + +/** + * `autoSize` geometry, in a real browser. + * + * jsdom reports 0 for every box and has no line boxes, so the row arithmetic + * here — the part that decided a single line was two rows tall — is invisible + * to the jsdom suite. + */ +describe('TextArea autoSize', () => { + it('gives a single line exactly one row', async () => { + renderWithRoot( +