From 7ddd4ca5b6316333faaa5984335b0475bedf2ecb Mon Sep 17 00:00:00 2001 From: Sahil Wakode Date: Fri, 23 Oct 2026 13:12:03 +0530 Subject: [PATCH 01/10] fix(apollo-wind): start a QuickFormField Text area one line tall At design time every field type is the same one-line control; a Text area now starts at one line too and grows with its content up to eight lines. The multi-line box is for the runtime form, not the authoring row. --- .../quick-form-field/quick-form-field.divergence.test.tsx | 8 ++++++++ .../components/ui/quick-form-field/quick-form-field.tsx | 5 +++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.divergence.test.tsx b/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.divergence.test.tsx index 49cc0e577..f59aea47d 100644 --- a/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.divergence.test.tsx +++ b/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.divergence.test.tsx @@ -246,6 +246,14 @@ describe('QuickFormField — divergences from apollo-wind', () => { expect(container.querySelector('[role="group"]')).toHaveAttribute('data-layout', 'grow'); }); + // Same height as every other field type until the content needs more. + // jsdom has no layout, so a row measures as Textarea's 20px fallback. + it('starts one line tall, like the other field types', () => { + render(); + const textarea = screen.getByPlaceholderText('Text area value') as HTMLTextAreaElement; + expect(textarea.style.minHeight).toBe('20px'); + }); + it('keeps an expression on the one-line input', () => { const { container } = render( ) : fieldType === 'textarea' ? ( - // Grows with its content up to the ceiling, then scrolls. - + // Starts one line tall, like every other field type, then grows with its + // content up to the ceiling and scrolls past it. + ) : ( Date: Fri, 23 Oct 2026 13:16:15 +0530 Subject: [PATCH 02/10] fix(apollo-wind): keep a QuickFormField Text area at one line at design time Design time uses the same one-line control for every field type, so a Text area no longer grows: floor and ceiling are both one row, and it leaves the grow layout. Content scrolls inside the box; it stays a textarea so line breaks in a saved value survive. --- .../quick-form-field.divergence.test.tsx | 27 ++++++++++++++----- .../ui/quick-form-field/quick-form-field.tsx | 13 +++++---- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.divergence.test.tsx b/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.divergence.test.tsx index f59aea47d..e6b3e8174 100644 --- a/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.divergence.test.tsx +++ b/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.divergence.test.tsx @@ -238,20 +238,35 @@ describe('QuickFormField — divergences from apollo-wind', () => { // The text area is the component's own control, so consumers don't supply one. describe('text area', () => { - it('renders a fixed value as a growing text area', () => { + it('renders a fixed value as a one-line text area that does not grow', () => { const { container } = render( ); expect(screen.getByPlaceholderText('Text area value').tagName).toBe('TEXTAREA'); - expect(container.querySelector('[role="group"]')).toHaveAttribute('data-layout', 'grow'); + expect(container.querySelector('[role="group"]')).toHaveAttribute('data-layout', 'row'); }); - // Same height as every other field type until the content needs more. - // jsdom has no layout, so a row measures as Textarea's 20px fallback. - it('starts one line tall, like the other field types', () => { - render(); + // Fixed at one line at design time: the floor and the ceiling are both one + // row, so content scrolls instead of growing the box. jsdom has no layout, + // so a row measures as Textarea's 20px fallback. + it('stays one line tall, even with several lines of content', () => { + // jsdom reports no content height; make three lines (60px) of content measurable. + const scrollHeight = vi + .spyOn(HTMLElement.prototype, 'scrollHeight', 'get') + .mockReturnValue(60); + render( + + ); const textarea = screen.getByPlaceholderText('Text area value') as HTMLTextAreaElement; expect(textarea.style.minHeight).toBe('20px'); + expect(textarea.style.height).toBe('20px'); + expect(textarea).toHaveValue('first\nsecond\nthird'); + scrollHeight.mockRestore(); }); it('keeps an expression on the one-line input', () => { diff --git a/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.tsx b/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.tsx index 200c49297..e3894dc6b 100644 --- a/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.tsx +++ b/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.tsx @@ -362,9 +362,9 @@ export function QuickFormField({ onBlur={onValueBlur} /> ) : fieldType === 'textarea' ? ( - // Starts one line tall, like every other field type, then grows with its - // content up to the ceiling and scrolls past it. - + // One line tall at design time, like every other field type; longer content + // scrolls inside it. A textarea rather than an input, so line breaks survive. + ) : ( Date: Fri, 23 Oct 2026 13:27:38 +0530 Subject: [PATCH 03/10] feat(apollo-wind): let a form textarea grow with its content A MetadataForm textarea started at four rows and scrolled past them. It now starts at the same height (minRows, else rows, else 4) and grows with its content up to maxRows (new, default 8) before scrolling. The ceiling never sits below the floor, so a field asking for 10 rows still starts at 10. --- .../src/components/forms/field-control.tsx | 25 ++++++++++- .../components/forms/field-renderer.test.tsx | 44 +++++++++++++++++++ .../src/components/forms/form-schema.ts | 2 + 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/packages/apollo-wind/src/components/forms/field-control.tsx b/packages/apollo-wind/src/components/forms/field-control.tsx index 606a69c4d..ddef2b160 100644 --- a/packages/apollo-wind/src/components/forms/field-control.tsx +++ b/packages/apollo-wind/src/components/forms/field-control.tsx @@ -26,10 +26,31 @@ import { Switch } from '@/components/ui/switch'; import { Textarea } from '@/components/ui/textarea'; import { toDateValue } from '@/lib/date-value'; import { joinIds } from './aria-ids'; -import type { FieldMetadata, FieldOption, FieldType, SliderFieldMetadata } from './form-schema'; +import type { + FieldMetadata, + FieldOption, + FieldType, + SliderFieldMetadata, + TextareaFieldMetadata, +} from './form-schema'; import { LiteralValueContext } from './literal-value-context'; import { StringListControl } from './string-list-field'; +/** A form textarea's starting height, in rows, when the field sets none. */ +const DEFAULT_TEXTAREA_ROWS = 4; +/** How far a form textarea grows with its content before it scrolls. */ +const DEFAULT_TEXTAREA_MAX_ROWS = 8; + +/** + * A form textarea starts at its `minRows` (else `rows`, else 4) and grows with + * its content up to `maxRows` (default 8), then scrolls. The ceiling never sits + * below the floor, so a field asking for 10 rows still starts at 10. + */ +function textareaSizing(field: TextareaFieldMetadata): { minRows: number; maxRows: number } { + const minRows = field.minRows ?? field.rows ?? DEFAULT_TEXTAREA_ROWS; + return { minRows, maxRows: Math.max(field.maxRows ?? DEFAULT_TEXTAREA_MAX_ROWS, minRows) }; +} + /** The react-hook-form `Controller` binding a control reads and writes. */ export interface FieldControlFormField { value: unknown; @@ -237,7 +258,7 @@ export function FieldControl({ aria-invalid={invalid || undefined} aria-describedby={describedById} aria-errormessage={errorId} - {...(field.minRows != null ? { minRows: field.minRows } : { rows: field.rows || 4 })} + {...textareaSizing(field)} /> ); diff --git a/packages/apollo-wind/src/components/forms/field-renderer.test.tsx b/packages/apollo-wind/src/components/forms/field-renderer.test.tsx index 8c395358a..7cdc49e5e 100644 --- a/packages/apollo-wind/src/components/forms/field-renderer.test.tsx +++ b/packages/apollo-wind/src/components/forms/field-renderer.test.tsx @@ -168,6 +168,50 @@ describe('FormFieldRenderer', () => { const textarea = screen.getByPlaceholderText('Tell us about yourself'); expect(textarea.tagName).toBe('TEXTAREA'); }); + + afterEach(() => vi.restoreAllMocks()); + + // jsdom has no layout: a row measures as Textarea's 20px fallback, and the + // content height is mocked so growth is measurable. + const renderTextarea = (extra: Partial, contentHeight: number) => { + vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(contentHeight); + const field = { + name: 'notes', + type: 'textarea', + placeholder: 'Notes', + ...extra, + } as FieldMetadata; + render( + + + + ); + return screen.getByPlaceholderText('Notes') as HTMLTextAreaElement; + }; + + it('starts four rows tall and grows with its content', () => { + const textarea = renderTextarea({}, 120); // six rows of content + expect(textarea.style.minHeight).toBe('80px'); + expect(textarea.style.height).toBe('120px'); + }); + + it('stops growing at eight rows, then scrolls', () => { + const textarea = renderTextarea({}, 400); // twenty rows of content + expect(textarea.style.height).toBe('160px'); + expect(textarea.style.overflowY).toBe('auto'); + }); + + it('honours a custom maxRows', () => { + const textarea = renderTextarea({ minRows: 2, maxRows: 3 } as Partial, 400); + expect(textarea.style.minHeight).toBe('40px'); + expect(textarea.style.height).toBe('60px'); + }); + + it('never caps below a taller starting height', () => { + const textarea = renderTextarea({ rows: 10 } as Partial, 0); + expect(textarea.style.minHeight).toBe('200px'); + expect(textarea.style.height).toBe('200px'); + }); }); describe('select field', () => { diff --git a/packages/apollo-wind/src/components/forms/form-schema.ts b/packages/apollo-wind/src/components/forms/form-schema.ts index 16ed2615b..9632f6b02 100644 --- a/packages/apollo-wind/src/components/forms/form-schema.ts +++ b/packages/apollo-wind/src/components/forms/form-schema.ts @@ -278,6 +278,8 @@ export interface TextareaFieldMetadata extends BaseFieldMetadata { rows?: number; /** Autosize floor in rows; takes precedence over `rows` when set. */ minRows?: number; + /** How many rows the textarea grows to with its content before it scrolls (default 8). */ + maxRows?: number; /** DOM maxLength character cap. */ maxLength?: number; } From a88fd3df8baa5222b10270655a03b21670284f28 Mon Sep 17 00:00:00 2001 From: Sahil Wakode Date: Fri, 23 Oct 2026 16:11:29 +0530 Subject: [PATCH 04/10] fix(apollo-wind): keep the one-line Text area at the exact row height Review fixes: - rows={1}: a native textarea's intrinsic height is two rows. - Wrap the Text area so the group keeps its row height and centring: a direct-child textarea switches it to h-auto, top-aligned. Measured in a real browser, the Text area box is now exactly a Text box (36px core, 40px future) and stays that height with three lines of content. - schema-serializer keeps maxRows beside minRows. --- .../forms/schema-serializer.test.ts | 28 +++++++++++++++++++ .../src/components/forms/schema-serializer.ts | 1 + .../quick-form-field.divergence.test.tsx | 11 ++++++-- .../ui/quick-form-field/quick-form-field.tsx | 17 +++++++++-- 4 files changed, 52 insertions(+), 5 deletions(-) diff --git a/packages/apollo-wind/src/components/forms/schema-serializer.test.ts b/packages/apollo-wind/src/components/forms/schema-serializer.test.ts index f5340094d..75304991d 100644 --- a/packages/apollo-wind/src/components/forms/schema-serializer.test.ts +++ b/packages/apollo-wind/src/components/forms/schema-serializer.test.ts @@ -327,6 +327,34 @@ describe('serializeSchema', () => { expect(fields[2].accept).toBe('image/*'); expect(fields[2].maxSize).toBe(5000000); }); + + it("keeps a textarea's minRows and maxRows", () => { + const schema: FormSchema = { + id: 'form', + title: 'Form', + sections: [ + { + id: 's1', + fields: [ + { + name: 'notes', + type: 'textarea', + label: 'Notes', + minRows: 2, + maxRows: 3, + } as FieldMetadata, + ], + }, + ], + }; + + const [field] = ( + serializeSchema(schema).sections as Array<{ fields: Array> }> + )[0].fields; + + expect(field.minRows).toBe(2); + expect(field.maxRows).toBe(3); + }); }); describe('section serialization', () => { diff --git a/packages/apollo-wind/src/components/forms/schema-serializer.ts b/packages/apollo-wind/src/components/forms/schema-serializer.ts index aee5bfa46..cee809853 100644 --- a/packages/apollo-wind/src/components/forms/schema-serializer.ts +++ b/packages/apollo-wind/src/components/forms/schema-serializer.ts @@ -245,6 +245,7 @@ function serializeField(field: FieldMetadata): JsonObject { if ('showPreview' in field && field.showPreview) result.showPreview = field.showPreview; if ('use12Hour' in field && field.use12Hour) result.use12Hour = field.use12Hour; if ('minRows' in field && field.minRows !== undefined) result.minRows = field.minRows; + if ('maxRows' in field && field.maxRows !== undefined) result.maxRows = field.maxRows; if ('maxLength' in field && field.maxLength !== undefined) result.maxLength = field.maxLength; if ('maxItems' in field && field.maxItems !== undefined) result.maxItems = field.maxItems; if ('emptyMessage' in field && field.emptyMessage) result.emptyMessage = field.emptyMessage; diff --git a/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.divergence.test.tsx b/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.divergence.test.tsx index e6b3e8174..b23018342 100644 --- a/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.divergence.test.tsx +++ b/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.divergence.test.tsx @@ -242,8 +242,15 @@ describe('QuickFormField — divergences from apollo-wind', () => { const { container } = render( ); - expect(screen.getByPlaceholderText('Text area value').tagName).toBe('TEXTAREA'); - expect(container.querySelector('[role="group"]')).toHaveAttribute('data-layout', 'row'); + const textarea = screen.getByPlaceholderText('Text area value'); + expect(textarea.tagName).toBe('TEXTAREA'); + // A native textarea is two rows tall by default. + expect(textarea).toHaveAttribute('rows', '1'); + const group = container.querySelector('[role="group"]'); + expect(group).toHaveAttribute('data-layout', 'row'); + // Not the group's direct child, so the group keeps its row height and + // centring instead of switching to auto height (`has-[>textarea]`). + expect(group?.querySelector(':scope > textarea')).toBeNull(); }); // Fixed at one line at design time: the floor and the ceiling are both one diff --git a/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.tsx b/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.tsx index e3894dc6b..e1b0bb409 100644 --- a/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.tsx +++ b/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.tsx @@ -362,9 +362,20 @@ export function QuickFormField({ onBlur={onValueBlur} /> ) : fieldType === 'textarea' ? ( - // One line tall at design time, like every other field type; longer content - // scrolls inside it. A textarea rather than an input, so line breaks survive. - + // One line at design time, like every other field type; longer content scrolls + // inside it. A textarea rather than an input, so a saved value keeps its line + // breaks. Wrapped so the group keeps its row height and centring: a textarea + // that is the group's direct child switches it to auto height, top-aligned. + // `rows={1}` because a native textarea's intrinsic height is two rows. +
+ +
) : ( Date: Fri, 23 Oct 2026 17:16:23 +0530 Subject: [PATCH 05/10] fix(apollo-wind): start a form textarea at one row; one-line comments A form textarea now starts at one row (rows/minRows still honoured) and grows with its content up to eight. Sets the native rows too, since a textarea's intrinsic height is two rows. Measured in a real browser: 38px empty, 178px at the eight-row cap. --- .../src/components/forms/field-control.tsx | 19 +++++++++---------- .../components/forms/field-renderer.test.tsx | 8 ++++---- .../src/components/forms/form-schema.ts | 2 +- .../quick-form-field.divergence.test.tsx | 10 +++------- .../ui/quick-form-field/quick-form-field.tsx | 9 ++------- 5 files changed, 19 insertions(+), 29 deletions(-) diff --git a/packages/apollo-wind/src/components/forms/field-control.tsx b/packages/apollo-wind/src/components/forms/field-control.tsx index ddef2b160..5fbffc54b 100644 --- a/packages/apollo-wind/src/components/forms/field-control.tsx +++ b/packages/apollo-wind/src/components/forms/field-control.tsx @@ -36,19 +36,18 @@ import type { import { LiteralValueContext } from './literal-value-context'; import { StringListControl } from './string-list-field'; -/** A form textarea's starting height, in rows, when the field sets none. */ -const DEFAULT_TEXTAREA_ROWS = 4; -/** How far a form textarea grows with its content before it scrolls. */ +const DEFAULT_TEXTAREA_ROWS = 1; const DEFAULT_TEXTAREA_MAX_ROWS = 8; -/** - * A form textarea starts at its `minRows` (else `rows`, else 4) and grows with - * its content up to `maxRows` (default 8), then scrolls. The ceiling never sits - * below the floor, so a field asking for 10 rows still starts at 10. - */ -function textareaSizing(field: TextareaFieldMetadata): { minRows: number; maxRows: number } { +/** Starts at minRows/rows (default 1) and grows with content up to maxRows (default 8). */ +function textareaSizing(field: TextareaFieldMetadata) { const minRows = field.minRows ?? field.rows ?? DEFAULT_TEXTAREA_ROWS; - return { minRows, maxRows: Math.max(field.maxRows ?? DEFAULT_TEXTAREA_MAX_ROWS, minRows) }; + // `rows` too: a native textarea's intrinsic height is two rows. + return { + rows: minRows, + minRows, + maxRows: Math.max(field.maxRows ?? DEFAULT_TEXTAREA_MAX_ROWS, minRows), + }; } /** The react-hook-form `Controller` binding a control reads and writes. */ diff --git a/packages/apollo-wind/src/components/forms/field-renderer.test.tsx b/packages/apollo-wind/src/components/forms/field-renderer.test.tsx index 7cdc49e5e..8f046e703 100644 --- a/packages/apollo-wind/src/components/forms/field-renderer.test.tsx +++ b/packages/apollo-wind/src/components/forms/field-renderer.test.tsx @@ -171,8 +171,7 @@ describe('FormFieldRenderer', () => { afterEach(() => vi.restoreAllMocks()); - // jsdom has no layout: a row measures as Textarea's 20px fallback, and the - // content height is mocked so growth is measurable. + // jsdom has no layout: a row is 20px and the content height is mocked. const renderTextarea = (extra: Partial, contentHeight: number) => { vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(contentHeight); const field = { @@ -189,9 +188,10 @@ describe('FormFieldRenderer', () => { return screen.getByPlaceholderText('Notes') as HTMLTextAreaElement; }; - it('starts four rows tall and grows with its content', () => { + it('starts one row tall and grows with its content', () => { const textarea = renderTextarea({}, 120); // six rows of content - expect(textarea.style.minHeight).toBe('80px'); + expect(textarea).toHaveAttribute('rows', '1'); + expect(textarea.style.minHeight).toBe('20px'); expect(textarea.style.height).toBe('120px'); }); diff --git a/packages/apollo-wind/src/components/forms/form-schema.ts b/packages/apollo-wind/src/components/forms/form-schema.ts index 9632f6b02..fafb68be2 100644 --- a/packages/apollo-wind/src/components/forms/form-schema.ts +++ b/packages/apollo-wind/src/components/forms/form-schema.ts @@ -278,7 +278,7 @@ export interface TextareaFieldMetadata extends BaseFieldMetadata { rows?: number; /** Autosize floor in rows; takes precedence over `rows` when set. */ minRows?: number; - /** How many rows the textarea grows to with its content before it scrolls (default 8). */ + /** Rows the textarea grows to before it scrolls (default 8). */ maxRows?: number; /** DOM maxLength character cap. */ maxLength?: number; diff --git a/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.divergence.test.tsx b/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.divergence.test.tsx index b23018342..fb8273b74 100644 --- a/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.divergence.test.tsx +++ b/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.divergence.test.tsx @@ -244,20 +244,16 @@ describe('QuickFormField — divergences from apollo-wind', () => { ); const textarea = screen.getByPlaceholderText('Text area value'); expect(textarea.tagName).toBe('TEXTAREA'); - // A native textarea is two rows tall by default. expect(textarea).toHaveAttribute('rows', '1'); const group = container.querySelector('[role="group"]'); expect(group).toHaveAttribute('data-layout', 'row'); - // Not the group's direct child, so the group keeps its row height and - // centring instead of switching to auto height (`has-[>textarea]`). + // Not a direct child, so `has-[>textarea]` can't make the group auto height. expect(group?.querySelector(':scope > textarea')).toBeNull(); }); - // Fixed at one line at design time: the floor and the ceiling are both one - // row, so content scrolls instead of growing the box. jsdom has no layout, - // so a row measures as Textarea's 20px fallback. + // jsdom has no layout, so a row measures as Textarea's 20px fallback. it('stays one line tall, even with several lines of content', () => { - // jsdom reports no content height; make three lines (60px) of content measurable. + // Three lines (60px) of content. const scrollHeight = vi .spyOn(HTMLElement.prototype, 'scrollHeight', 'get') .mockReturnValue(60); diff --git a/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.tsx b/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.tsx index e1b0bb409..d8a331d8a 100644 --- a/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.tsx +++ b/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.tsx @@ -362,11 +362,7 @@ export function QuickFormField({ onBlur={onValueBlur} /> ) : fieldType === 'textarea' ? ( - // One line at design time, like every other field type; longer content scrolls - // inside it. A textarea rather than an input, so a saved value keeps its line - // breaks. Wrapped so the group keeps its row height and centring: a textarea - // that is the group's direct child switches it to auto height, top-aligned. - // `rows={1}` because a native textarea's intrinsic height is two rows. + // One fixed line at design time; wrapped so the group keeps its row height.
Date: Fri, 23 Oct 2026 17:21:28 +0530 Subject: [PATCH 06/10] test(apollo-wind): pin the form textarea's drag-to-resize handle --- .../apollo-wind/src/components/forms/field-renderer.test.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/apollo-wind/src/components/forms/field-renderer.test.tsx b/packages/apollo-wind/src/components/forms/field-renderer.test.tsx index 8f046e703..0a720be9a 100644 --- a/packages/apollo-wind/src/components/forms/field-renderer.test.tsx +++ b/packages/apollo-wind/src/components/forms/field-renderer.test.tsx @@ -195,6 +195,10 @@ describe('FormFieldRenderer', () => { expect(textarea.style.height).toBe('120px'); }); + it('can be dragged taller with the resize handle', () => { + expect(renderTextarea({}, 0)).toHaveClass('resize-y'); + }); + it('stops growing at eight rows, then scrolls', () => { const textarea = renderTextarea({}, 400); // twenty rows of content expect(textarea.style.height).toBe('160px'); From 730054078e4b1462649e39384f0ed1f0a76fdb99 Mon Sep 17 00:00:00 2001 From: Sahil Wakode Date: Fri, 23 Oct 2026 17:29:02 +0530 Subject: [PATCH 07/10] refactor(apollo-wind): drop textarea auto-grow; runtime resizes by drag The drag handle already lets a reviewer make the box as tall as they need, so the eight-row auto-grow ceiling, the new maxRows option and its serializer support are removed. A form textarea starts at one row and keeps its resize handle. --- .../src/components/forms/field-control.tsx | 9 ++---- .../components/forms/field-renderer.test.tsx | 32 +++++-------------- .../src/components/forms/form-schema.ts | 2 -- .../forms/schema-serializer.test.ts | 28 ---------------- .../src/components/forms/schema-serializer.ts | 1 - 5 files changed, 10 insertions(+), 62 deletions(-) diff --git a/packages/apollo-wind/src/components/forms/field-control.tsx b/packages/apollo-wind/src/components/forms/field-control.tsx index 5fbffc54b..3c212f79c 100644 --- a/packages/apollo-wind/src/components/forms/field-control.tsx +++ b/packages/apollo-wind/src/components/forms/field-control.tsx @@ -37,17 +37,12 @@ import { LiteralValueContext } from './literal-value-context'; import { StringListControl } from './string-list-field'; const DEFAULT_TEXTAREA_ROWS = 1; -const DEFAULT_TEXTAREA_MAX_ROWS = 8; -/** Starts at minRows/rows (default 1) and grows with content up to maxRows (default 8). */ +/** Starts at minRows/rows (default 1); `minRows` gives it the drag-to-resize handle. */ function textareaSizing(field: TextareaFieldMetadata) { const minRows = field.minRows ?? field.rows ?? DEFAULT_TEXTAREA_ROWS; // `rows` too: a native textarea's intrinsic height is two rows. - return { - rows: minRows, - minRows, - maxRows: Math.max(field.maxRows ?? DEFAULT_TEXTAREA_MAX_ROWS, minRows), - }; + return { rows: minRows, minRows }; } /** The react-hook-form `Controller` binding a control reads and writes. */ diff --git a/packages/apollo-wind/src/components/forms/field-renderer.test.tsx b/packages/apollo-wind/src/components/forms/field-renderer.test.tsx index 0a720be9a..a28f4b05b 100644 --- a/packages/apollo-wind/src/components/forms/field-renderer.test.tsx +++ b/packages/apollo-wind/src/components/forms/field-renderer.test.tsx @@ -169,11 +169,8 @@ describe('FormFieldRenderer', () => { expect(textarea.tagName).toBe('TEXTAREA'); }); - afterEach(() => vi.restoreAllMocks()); - - // jsdom has no layout: a row is 20px and the content height is mocked. - const renderTextarea = (extra: Partial, contentHeight: number) => { - vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(contentHeight); + // jsdom has no layout, so a row measures as Textarea's 20px fallback. + const renderTextarea = (extra: Partial = {}) => { const field = { name: 'notes', type: 'textarea', @@ -188,33 +185,20 @@ describe('FormFieldRenderer', () => { return screen.getByPlaceholderText('Notes') as HTMLTextAreaElement; }; - it('starts one row tall and grows with its content', () => { - const textarea = renderTextarea({}, 120); // six rows of content + it('starts one row tall', () => { + const textarea = renderTextarea(); expect(textarea).toHaveAttribute('rows', '1'); expect(textarea.style.minHeight).toBe('20px'); - expect(textarea.style.height).toBe('120px'); }); it('can be dragged taller with the resize handle', () => { - expect(renderTextarea({}, 0)).toHaveClass('resize-y'); - }); - - it('stops growing at eight rows, then scrolls', () => { - const textarea = renderTextarea({}, 400); // twenty rows of content - expect(textarea.style.height).toBe('160px'); - expect(textarea.style.overflowY).toBe('auto'); - }); - - it('honours a custom maxRows', () => { - const textarea = renderTextarea({ minRows: 2, maxRows: 3 } as Partial, 400); - expect(textarea.style.minHeight).toBe('40px'); - expect(textarea.style.height).toBe('60px'); + expect(renderTextarea()).toHaveClass('resize-y'); }); - it('never caps below a taller starting height', () => { - const textarea = renderTextarea({ rows: 10 } as Partial, 0); + it('still honours a taller starting height', () => { + const textarea = renderTextarea({ rows: 10 } as Partial); + expect(textarea).toHaveAttribute('rows', '10'); expect(textarea.style.minHeight).toBe('200px'); - expect(textarea.style.height).toBe('200px'); }); }); diff --git a/packages/apollo-wind/src/components/forms/form-schema.ts b/packages/apollo-wind/src/components/forms/form-schema.ts index fafb68be2..16ed2615b 100644 --- a/packages/apollo-wind/src/components/forms/form-schema.ts +++ b/packages/apollo-wind/src/components/forms/form-schema.ts @@ -278,8 +278,6 @@ export interface TextareaFieldMetadata extends BaseFieldMetadata { rows?: number; /** Autosize floor in rows; takes precedence over `rows` when set. */ minRows?: number; - /** Rows the textarea grows to before it scrolls (default 8). */ - maxRows?: number; /** DOM maxLength character cap. */ maxLength?: number; } diff --git a/packages/apollo-wind/src/components/forms/schema-serializer.test.ts b/packages/apollo-wind/src/components/forms/schema-serializer.test.ts index 75304991d..f5340094d 100644 --- a/packages/apollo-wind/src/components/forms/schema-serializer.test.ts +++ b/packages/apollo-wind/src/components/forms/schema-serializer.test.ts @@ -327,34 +327,6 @@ describe('serializeSchema', () => { expect(fields[2].accept).toBe('image/*'); expect(fields[2].maxSize).toBe(5000000); }); - - it("keeps a textarea's minRows and maxRows", () => { - const schema: FormSchema = { - id: 'form', - title: 'Form', - sections: [ - { - id: 's1', - fields: [ - { - name: 'notes', - type: 'textarea', - label: 'Notes', - minRows: 2, - maxRows: 3, - } as FieldMetadata, - ], - }, - ], - }; - - const [field] = ( - serializeSchema(schema).sections as Array<{ fields: Array> }> - )[0].fields; - - expect(field.minRows).toBe(2); - expect(field.maxRows).toBe(3); - }); }); describe('section serialization', () => { diff --git a/packages/apollo-wind/src/components/forms/schema-serializer.ts b/packages/apollo-wind/src/components/forms/schema-serializer.ts index cee809853..aee5bfa46 100644 --- a/packages/apollo-wind/src/components/forms/schema-serializer.ts +++ b/packages/apollo-wind/src/components/forms/schema-serializer.ts @@ -245,7 +245,6 @@ function serializeField(field: FieldMetadata): JsonObject { if ('showPreview' in field && field.showPreview) result.showPreview = field.showPreview; if ('use12Hour' in field && field.use12Hour) result.use12Hour = field.use12Hour; if ('minRows' in field && field.minRows !== undefined) result.minRows = field.minRows; - if ('maxRows' in field && field.maxRows !== undefined) result.maxRows = field.maxRows; if ('maxLength' in field && field.maxLength !== undefined) result.maxLength = field.maxLength; if ('maxItems' in field && field.maxItems !== undefined) result.maxItems = field.maxItems; if ('emptyMessage' in field && field.emptyMessage) result.emptyMessage = field.emptyMessage; From c9ebd119c3f25720182164b5e466627dcec3ccce Mon Sep 17 00:00:00 2001 From: Sahil Wakode Date: Fri, 23 Oct 2026 17:36:08 +0530 Subject: [PATCH 08/10] refactor(apollo-wind): simplify the Text area sizing Design time needs only rows={1}: min/maxRows switch on auto-sizing a fixed box never uses. Runtime inlines the helper as one rows value used for both rows and minRows. --- .../src/components/forms/field-control.tsx | 25 ++++++------------- .../quick-form-field.divergence.test.tsx | 12 +++------ .../ui/quick-form-field/quick-form-field.tsx | 8 +----- 3 files changed, 11 insertions(+), 34 deletions(-) diff --git a/packages/apollo-wind/src/components/forms/field-control.tsx b/packages/apollo-wind/src/components/forms/field-control.tsx index 3c212f79c..5c1839bf7 100644 --- a/packages/apollo-wind/src/components/forms/field-control.tsx +++ b/packages/apollo-wind/src/components/forms/field-control.tsx @@ -26,25 +26,10 @@ import { Switch } from '@/components/ui/switch'; import { Textarea } from '@/components/ui/textarea'; import { toDateValue } from '@/lib/date-value'; import { joinIds } from './aria-ids'; -import type { - FieldMetadata, - FieldOption, - FieldType, - SliderFieldMetadata, - TextareaFieldMetadata, -} from './form-schema'; +import type { FieldMetadata, FieldOption, FieldType, SliderFieldMetadata } from './form-schema'; import { LiteralValueContext } from './literal-value-context'; import { StringListControl } from './string-list-field'; -const DEFAULT_TEXTAREA_ROWS = 1; - -/** Starts at minRows/rows (default 1); `minRows` gives it the drag-to-resize handle. */ -function textareaSizing(field: TextareaFieldMetadata) { - const minRows = field.minRows ?? field.rows ?? DEFAULT_TEXTAREA_ROWS; - // `rows` too: a native textarea's intrinsic height is two rows. - return { rows: minRows, minRows }; -} - /** The react-hook-form `Controller` binding a control reads and writes. */ export interface FieldControlFormField { value: unknown; @@ -237,7 +222,9 @@ export function FieldControl({ /> ); - case 'textarea': + case 'textarea': { + // `minRows` adds the drag-to-resize handle; `rows` because a textarea is two rows by default. + const rows = field.minRows ?? field.rows ?? 1; return (