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
8 changes: 8 additions & 0 deletions .changeset/textarea-autosize-off-screen-measure.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 16 additions & 0 deletions src/components/fields/AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.
116 changes: 116 additions & 0 deletions src/components/fields/CommandTextArea/CommandTextArea.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
style={{
display: 'flex',
flexDirection: 'column',
height: '320px',
width: '360px',
}}
>
<div
data-qa="scroller"
style={{ flex: 1, overflow: 'auto', overflowAnchor: 'none' }}
>
{Array.from({ length: 40 }, (_, index) => (
<p key={index} style={{ margin: 0, overflowAnchor: 'none' }}>
Message {index}
</p>
))}
</div>
<CommandTextArea
autoSize
aria-label="Prompt"
qa="prompt"
rows={1}
maxRows={10}
defaultValue={GROWN_VALUE}
/>
</div>
);
}

describe('CommandTextArea autoSize', () => {
it('gives a single line exactly one row', async () => {
renderWithRoot(
<CommandTextArea
autoSize
aria-label="Prompt"
qa="prompt"
rows={1}
defaultValue="one"
inputStyles={{ fontSize: '16px', lineHeight: '16px' }}
/>,
);

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(<ChatHarness />);

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);
});
});
44 changes: 8 additions & 36 deletions src/components/fields/CommandTextArea/CommandTextArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
CubeTextInputBaseProps,
TextInputBase,
} from '../TextInput/TextInputBase';
import { useAutoSizeTextArea } from '../TextInput/useAutoSizeTextArea';

import { useCaretAnchor } from './useCaretAnchor';

Expand Down Expand Up @@ -403,28 +404,13 @@ function CommandTextArea<T extends object>(
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) ----------------------
Expand All @@ -447,20 +433,6 @@ function CommandTextArea<T extends object>(
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<number | null>(null);
useEnvironmentalEffect(() => {
Expand Down
133 changes: 133 additions & 0 deletions src/components/fields/TextArea/TextArea.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<TextArea
autoSize
qa="area"
rows={1}
label="Note"
defaultValue="one"
inputStyles={TIGHT_TYPOGRAPHY}
/>,
);

await waitFor(() => expect(heightInRows(input())).toBeCloseTo(1, 1));
});

it('grows with the content and shrinks back', async () => {
renderWithRoot(
<TextArea
autoSize
qa="area"
rows={1}
label="Note"
inputStyles={TIGHT_TYPOGRAPHY}
/>,
);

await waitFor(() => expect(heightInRows(input())).toBeCloseTo(1, 1));

await userEvent.click(input());
await userEvent.keyboard('one{Enter}two{Enter}three');

await waitFor(() => expect(heightInRows(input())).toBeCloseTo(3, 1));

await userEvent.clear(input());

await waitFor(() => expect(heightInRows(input())).toBeCloseTo(1, 1));
});

it('stops growing at maxRows', async () => {
renderWithRoot(
<TextArea
autoSize
qa="area"
rows={1}
maxRows={2}
label="Note"
defaultValue={'one\ntwo\nthree\nfour'}
inputStyles={TIGHT_TYPOGRAPHY}
/>,
);

await waitFor(() => expect(heightInRows(input())).toBeCloseTo(2, 1));
});

it('counts a trailing newline as a row', async () => {
renderWithRoot(
<TextArea
autoSize
qa="area"
rows={1}
label="Note"
inputStyles={TIGHT_TYPOGRAPHY}
/>,
);

await userEvent.click(input());
await userEvent.keyboard('one{Enter}');

expect(input().value).toBe('one\n');
// The caret sits on an empty second row, so the box has to make room for
// it. The mirror is a textarea rather than a div precisely so that it lays
// a trailing newline out the same way the live field does.
await waitFor(() => expect(heightInRows(input())).toBeCloseTo(2, 1));
});

it('honours rows as a minimum', async () => {
renderWithRoot(
<TextArea
autoSize
qa="area"
rows={3}
label="Note"
defaultValue="one"
inputStyles={TIGHT_TYPOGRAPHY}
/>,
);

await waitFor(() => expect(heightInRows(input())).toBeCloseTo(3, 1));
});
});
Loading
Loading