From 5f0055f5e338fcf7c752cd9840e1edd76b8ba591 Mon Sep 17 00:00:00 2001 From: Artem Belik Date: Wed, 24 Jun 2026 14:00:09 +0300 Subject: [PATCH 1/3] fix(textarea): canGrow limit when maxRows (#DS-5158) --- .../textarea/e2e.playwright-spec.ts | 74 +++++++++++++++---- packages/components/textarea/e2e.ts | 40 ++++++++-- .../textarea/textarea.component.spec.ts | 37 ++++++++++ .../components/textarea/textarea.component.ts | 16 ++-- packages/e2e/routes.ts | 8 +- playwright.config.ts | 3 +- 6 files changed, 142 insertions(+), 36 deletions(-) diff --git a/packages/components/textarea/e2e.playwright-spec.ts b/packages/components/textarea/e2e.playwright-spec.ts index 5b9cce9d18..4d2c62dc46 100644 --- a/packages/components/textarea/e2e.playwright-spec.ts +++ b/packages/components/textarea/e2e.playwright-spec.ts @@ -1,6 +1,22 @@ import { expect, Locator, Page, test } from '@playwright/test'; import { e2eEnableDarkTheme } from '../../e2e/utils'; +const getHeight = async (locator: Locator): Promise => { + await expect(locator).toBeVisible(); + const box = await locator.boundingBox(); + + expect(box).not.toBeNull(); + + return box!.height; +}; + +const pasteFromClipboard = async (page: Page, textarea: Locator, text: string) => { + await page.evaluate((t) => navigator.clipboard.writeText(t), text); + await textarea.click(); + await page.keyboard.press('ControlOrMeta+a'); + await page.keyboard.press('ControlOrMeta+v'); +}; + test.describe('KbqTextareaModule', () => { test.describe('E2eTextareaStates', () => { const getComponent = (page: Page): Locator => page.getByTestId('e2eTextareaStates'); @@ -16,18 +32,6 @@ test.describe('KbqTextareaModule', () => { test.describe('E2eTextareaGrowBehavior', () => { const getTextarea = (page: Page): Locator => page.getByTestId('grow_textarea'); - // Wait for the locator to be visible, then resolve its rendered height. - // Auto-retries via `toBeVisible` and asserts the bounding box is non-null - // so a transient detach surfaces as a readable expect failure, not a TypeError. - const getHeight = async (locator: Locator): Promise => { - await expect(locator).toBeVisible(); - const box = await locator.boundingBox(); - - expect(box).not.toBeNull(); - - return box!.height; - }; - test('should grow initially when ngModel has multiple lines', async ({ page }) => { await page.goto('/E2eTextareaGrowBehavior'); @@ -38,10 +42,10 @@ test.describe('KbqTextareaModule', () => { await page.goto('/E2eTextareaGrowBehavior'); const textarea = getTextarea(page); - await page.getByTestId('grow_set_short').click(); + await pasteFromClipboard(page, textarea, 'test\ntest'); const shortHeight = await getHeight(textarea); - await page.getByTestId('grow_set_long').click(); + await pasteFromClipboard(page, textarea, 'test\ntest\ntest\ntest\ntest\ntest'); const longHeight = await getHeight(textarea); expect(longHeight).toBeGreaterThan(shortHeight); @@ -51,16 +55,54 @@ test.describe('KbqTextareaModule', () => { await page.goto('/E2eTextareaGrowBehavior'); const textarea = getTextarea(page); - await page.getByTestId('grow_set_long').click(); + await pasteFromClipboard(page, textarea, 'test\ntest\ntest\ntest\ntest\ntest'); const longHeight = await getHeight(textarea); - await page.getByTestId('grow_set_short').click(); + await pasteFromClipboard(page, textarea, 'test\ntest'); const shortHeight = await getHeight(textarea); expect(shortHeight).toBeLessThan(longHeight); }); }); + test.describe('E2eTextareaGrowMaxRows', () => { + const getTextarea = (page: Page): Locator => page.getByTestId('grow-max-rows_textarea'); + + test('should grow initially when ngModel has multiple lines', async ({ page }) => { + await page.goto('/E2eTextareaGrowMaxRows'); + + expect(await getHeight(getTextarea(page))).toBeGreaterThan(50); + }); + + test('should grow to maxRows height when pasting text exceeding maxRows', async ({ page }) => { + await page.goto('/E2eTextareaGrowMaxRows'); + const textarea = getTextarea(page); + const shortHeight = await getHeight(textarea); + + await pasteFromClipboard( + page, + textarea, + 'line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10' + ); + expect(await getHeight(textarea)).toBeGreaterThan(shortHeight); + }); + + test('should not grow beyond maxRows height', async ({ page }) => { + await page.goto('/E2eTextareaGrowMaxRows'); + const textarea = getTextarea(page); + + await pasteFromClipboard(page, textarea, 'line1\nline2\nline3\nline4\nline5'); + const atMaxRowsHeight = await getHeight(textarea); + + await pasteFromClipboard( + page, + textarea, + 'line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10' + ); + expect(await getHeight(textarea)).toBe(atMaxRowsHeight); + }); + }); + test.describe('E2eTextareaScrollOnFocus', () => { test('should not scroll the page when focusing a kbqTextarea', async ({ page }) => { await page.goto('/E2eTextareaScrollOnFocus'); diff --git a/packages/components/textarea/e2e.ts b/packages/components/textarea/e2e.ts index 11808cf524..ab37803f2b 100644 --- a/packages/components/textarea/e2e.ts +++ b/packages/components/textarea/e2e.ts @@ -101,10 +101,6 @@ export class E2eTextareaStates { - - - - `, styles: ` :host { @@ -121,11 +117,39 @@ export class E2eTextareaStates { } }) export class E2eTextareaGrowBehavior { - protected readonly short = 'test\ntest'; - protected readonly medium = 'test\ntest\ntest'; - protected readonly long = 'test\ntest\ntest\ntest\ntest\ntest'; + protected readonly value = model('test\ntest\ntest'); +} - protected readonly value = model(this.medium); +@Component({ + selector: 'e2e-textarea-grow-max-rows', + imports: [KbqTextareaModule, FormsModule], + template: ` + + + + `, + styles: ` + :host { + display: flex; + flex-direction: column; + gap: var(--kbq-size-s); + padding: var(--kbq-size-s); + width: 320px; + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + 'data-testid': 'e2eTextareaGrowMaxRows' + } +}) +export class E2eTextareaGrowMaxRows { + protected readonly value = model('line1\nline2\nline3'); } const longTextareaContent = diff --git a/packages/components/textarea/textarea.component.spec.ts b/packages/components/textarea/textarea.component.spec.ts index c74376aa9e..18cff62bd5 100644 --- a/packages/components/textarea/textarea.component.spec.ts +++ b/packages/components/textarea/textarea.component.spec.ts @@ -141,6 +141,19 @@ class TextareaControlWithAsyncValidators { }); } +@Component({ + imports: [KbqTextareaModule, FormsModule], + template: ` + + + + ` +}) +class KbqTextareaGrowWithMaxRows { + readonly textarea = viewChild.required(KbqTextarea); + value: string = 'line1\nline2'; +} + @Component({ imports: [KbqTextareaModule, ReactiveFormsModule], template: ` @@ -279,6 +292,30 @@ describe('KbqTextarea', () => { expect(getTextareaElement(fixture).classList.contains('kbq-textarea-resizable')).toBe(false); }); + + it('should not have kbq-textarea_max-row-limit-reached class when maxRows is not exceeded', () => { + const fixture = createComponent(KbqTextareaGrowWithMaxRows); + + fixture.detectChanges(); + + expect(getTextareaElement(fixture).classList.contains('kbq-textarea_max-row-limit-reached')).toBe(false); + }); + }); + + describe('grow behavior', () => { + it('should call stateChanges.next when input event fires', fakeAsync(() => { + const fixture = createComponent(KbqTextareaForBehaviors); + + fixture.detectChanges(); + tick(); + + const textarea = fixture.debugElement.query(By.directive(KbqTextarea)).injector.get(KbqTextarea); + const spy = jest.spyOn(textarea.stateChanges, 'next'); + + dispatchFakeEvent(getTextareaElement(fixture), 'input'); + + expect(spy).toHaveBeenCalled(); + })); }); describe('ErrorStateMatcher', () => { diff --git a/packages/components/textarea/textarea.component.ts b/packages/components/textarea/textarea.component.ts index eb3fb9edbf..0b0660308b 100644 --- a/packages/components/textarea/textarea.component.ts +++ b/packages/components/textarea/textarea.component.ts @@ -1,4 +1,4 @@ -import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { coerceBooleanProperty, coerceCssPixelValue } from '@angular/cdk/coercion'; import { Platform } from '@angular/cdk/platform'; import { booleanAttribute, @@ -25,7 +25,6 @@ import { } from '@koobiq/components/core'; import { KbqFormFieldControl } from '@koobiq/components/form-field'; import { Subject, Subscription } from 'rxjs'; -import { delay } from 'rxjs/operators'; export const KBQ_TEXTAREA_VALUE_ACCESSOR = new InjectionToken<{ value: any }>('KBQ_TEXTAREA_VALUE_ACCESSOR'); @@ -45,7 +44,7 @@ let nextUniqueId = 0; '[required]': 'required', '(blur)': 'onBlur()', '(focus)': 'focusChanged(true)', - '(paste)': 'stateChanges.next()' + '(input)': 'stateChanges.next()' }, exportAs: 'kbqTextarea' }) @@ -229,7 +228,7 @@ export class KbqTextarea // eslint-disable-next-line @angular-eslint/no-lifecycle-call this.parent?.animationDone.subscribe(() => this.ngOnInit()); - this.growSubscription = this.stateChanges.pipe(delay(0)).subscribe(this.grow); + this.growSubscription = this.stateChanges.subscribe(this.grow); } ngOnInit() { @@ -313,12 +312,9 @@ export class KbqTextarea this.rowsCount = Math.floor(height / this.lineHeight); - if (!this.maxRowLimitReached) { - textarea.style.minHeight = `${height}px`; - } else if (!textarea.style.minHeight && this.lineHeight) { - // need for first initialization when value above maxRows - textarea.style.minHeight = `${this.maxRows() * this.lineHeight}px`; - } + textarea.style.minHeight = coerceCssPixelValue( + this.maxRowLimitReached ? this.maxRows() * this.lineHeight : height + ); }); }; diff --git a/packages/e2e/routes.ts b/packages/e2e/routes.ts index e7ac63428a..4f217cbff6 100644 --- a/packages/e2e/routes.ts +++ b/packages/e2e/routes.ts @@ -82,7 +82,12 @@ import { E2eTagListStates, E2eTagStateAndStyle } from '../components/tags/e2e'; -import { E2eTextareaGrowBehavior, E2eTextareaScrollOnFocus, E2eTextareaStates } from '../components/textarea/e2e'; +import { + E2eTextareaGrowBehavior, + E2eTextareaGrowMaxRows, + E2eTextareaScrollOnFocus, + E2eTextareaStates +} from '../components/textarea/e2e'; import { E2eTimepickerStates } from '../components/timepicker/e2e'; import { E2eTimezoneStates } from '../components/timezone/e2e'; import { E2eToastStates } from '../components/toast/e2e'; @@ -135,6 +140,7 @@ const components = [ E2eAccordionStates, E2eTextareaStates, E2eTextareaGrowBehavior, + E2eTextareaGrowMaxRows, E2eTextareaScrollOnFocus, E2eDatepickerStates, E2eTableStates, diff --git a/playwright.config.ts b/playwright.config.ts index d49b88feb0..ef9bfd7a07 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -52,6 +52,7 @@ export default defineConfig({ deviceScaleFactor: 2, reducedMotion: 'reduce', viewport - } + }, + permissions: ['clipboard-read', 'clipboard-write'] } }); From ff6a53f3d87209a91c788859b801d28573a1d32e Mon Sep 17 00:00:00 2001 From: Artem Belik Date: Wed, 24 Jun 2026 14:20:43 +0300 Subject: [PATCH 2/3] refactor: permissions --- packages/components/textarea/e2e.playwright-spec.ts | 2 ++ playwright.config.ts | 3 +-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/components/textarea/e2e.playwright-spec.ts b/packages/components/textarea/e2e.playwright-spec.ts index 4d2c62dc46..fb899147d5 100644 --- a/packages/components/textarea/e2e.playwright-spec.ts +++ b/packages/components/textarea/e2e.playwright-spec.ts @@ -18,6 +18,8 @@ const pasteFromClipboard = async (page: Page, textarea: Locator, text: string) = }; test.describe('KbqTextareaModule', () => { + test.use({ permissions: ['clipboard-read', 'clipboard-write'] }); + test.describe('E2eTextareaStates', () => { const getComponent = (page: Page): Locator => page.getByTestId('e2eTextareaStates'); diff --git a/playwright.config.ts b/playwright.config.ts index ef9bfd7a07..d49b88feb0 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -52,7 +52,6 @@ export default defineConfig({ deviceScaleFactor: 2, reducedMotion: 'reduce', viewport - }, - permissions: ['clipboard-read', 'clipboard-write'] + } } }); From f244aac36be2d7745dedac339363d1c3502daa33 Mon Sep 17 00:00:00 2001 From: Artem Belik Date: Wed, 24 Jun 2026 15:45:46 +0300 Subject: [PATCH 3/3] refactor: review --- .../textarea/textarea.component.spec.ts | 57 ++++++++++++++++++- .../components/textarea/textarea.component.ts | 9 ++- 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/packages/components/textarea/textarea.component.spec.ts b/packages/components/textarea/textarea.component.spec.ts index 18cff62bd5..b7365c8574 100644 --- a/packages/components/textarea/textarea.component.spec.ts +++ b/packages/components/textarea/textarea.component.spec.ts @@ -1,5 +1,13 @@ import { Component, Provider, Type, viewChild } from '@angular/core'; -import { ComponentFixture, ComponentFixtureAutoDetect, TestBed, fakeAsync, flush, tick } from '@angular/core/testing'; +import { + ComponentFixture, + ComponentFixtureAutoDetect, + TestBed, + fakeAsync, + flush, + flushMicrotasks, + tick +} from '@angular/core/testing'; import { AsyncValidatorFn, FormControl, @@ -303,19 +311,62 @@ describe('KbqTextarea', () => { }); describe('grow behavior', () => { - it('should call stateChanges.next when input event fires', fakeAsync(() => { + it('should call stateChanges.next when input event fires with a changed value', fakeAsync(() => { const fixture = createComponent(KbqTextareaForBehaviors); fixture.detectChanges(); tick(); + const textareaEl = getTextareaElement(fixture); const textarea = fixture.debugElement.query(By.directive(KbqTextarea)).injector.get(KbqTextarea); const spy = jest.spyOn(textarea.stateChanges, 'next'); - dispatchFakeEvent(getTextareaElement(fixture), 'input'); + textareaEl.value = 'changed value'; + dispatchFakeEvent(textareaEl, 'input'); expect(spy).toHaveBeenCalled(); })); + + it('should emit stateChanges once per input change: dirtyCheckNativeValue in (input) prevents ngDoCheck from re-emitting', fakeAsync(() => { + const fixture = createComponent(KbqTextareaForBehaviors); + + fixture.detectChanges(); + tick(); // flush initial setTimeout(grow, 0) from ngOnInit + + const textareaEl = getTextareaElement(fixture); + const textareaDir = fixture.debugElement.query(By.directive(KbqTextarea)).injector.get(KbqTextarea); + const nextSpy = jest.spyOn(textareaDir.stateChanges, 'next'); + + textareaEl.value = 'test\ntest\ntest\ntest\ntest'; + // (input) → dirtyCheckNativeValue() → previousNativeValue updated → stateChanges.next() [#1] + // Zone.js auto-CD → ngDoCheck → dirtyCheckNativeValue: previousNativeValue === value → no emit + dispatchFakeEvent(textareaEl, 'input'); + fixture.detectChanges(); // explicit CD: no further changes + + expect(nextSpy).toHaveBeenCalledTimes(1); + })); + + it('should defer grow to microtask so lineHeight is initialized before first grow call', fakeAsync(() => { + const fixture = createComponent(KbqTextareaGrowWithMaxRows); + + const textareaDir = fixture.debugElement.query(By.directive(KbqTextarea)).injector.get(KbqTextarea); + + fixture.detectChanges(); // ngOnInit queues M1 (lineHeight init); stateChanges may emit → grow microtasks pending + flushMicrotasks(); // drain M1 + any grow microtasks queued during detectChanges + tick(); // flush setTimeout(grow, 0) from ngOnInit + + // Spy set up AFTER initial flushes — only captures subsequent grow() calls + const growSpy = jest.spyOn(textareaDir, 'grow'); + + // observeOn(asapScheduler) defers grow to microtask (M2), NOT synchronous + textareaDir.stateChanges.next(); + + expect(growSpy).not.toHaveBeenCalled(); // M2 still pending + + flushMicrotasks(); // M2 runs → grow() + + expect(growSpy).toHaveBeenCalledTimes(1); + })); }); describe('ErrorStateMatcher', () => { diff --git a/packages/components/textarea/textarea.component.ts b/packages/components/textarea/textarea.component.ts index 0b0660308b..a471d820a3 100644 --- a/packages/components/textarea/textarea.component.ts +++ b/packages/components/textarea/textarea.component.ts @@ -16,6 +16,7 @@ import { OnInit, Renderer2 } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormGroupDirective, NgControl, NgForm, UntypedFormControl } from '@angular/forms'; import { CanUpdateErrorState, @@ -24,7 +25,7 @@ import { KBQ_WINDOW } from '@koobiq/components/core'; import { KbqFormFieldControl } from '@koobiq/components/form-field'; -import { Subject, Subscription } from 'rxjs'; +import { asapScheduler, observeOn, Subject } from 'rxjs'; export const KBQ_TEXTAREA_VALUE_ACCESSOR = new InjectionToken<{ value: any }>('KBQ_TEXTAREA_VALUE_ACCESSOR'); @@ -44,7 +45,7 @@ let nextUniqueId = 0; '[required]': 'required', '(blur)': 'onBlur()', '(focus)': 'focusChanged(true)', - '(input)': 'stateChanges.next()' + '(input)': 'dirtyCheckNativeValue()' }, exportAs: 'kbqTextarea' }) @@ -207,7 +208,6 @@ export class KbqTextarea private _required = false; private valueAccessor: { value: any }; - private growSubscription: Subscription; private lineHeight: number = 0; private minHeight: number = 0; @@ -228,7 +228,7 @@ export class KbqTextarea // eslint-disable-next-line @angular-eslint/no-lifecycle-call this.parent?.animationDone.subscribe(() => this.ngOnInit()); - this.growSubscription = this.stateChanges.subscribe(this.grow); + this.stateChanges.pipe(observeOn(asapScheduler), takeUntilDestroyed()).subscribe(() => this.grow()); } ngOnInit() { @@ -256,7 +256,6 @@ export class KbqTextarea ngOnDestroy() { this.stateChanges.complete(); - this.growSubscription.unsubscribe(); } ngDoCheck() {