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
76 changes: 60 additions & 16 deletions packages/components/textarea/e2e.playwright-spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,25 @@
import { expect, Locator, Page, test } from '@playwright/test';
import { e2eEnableDarkTheme } from '../../e2e/utils';

const getHeight = async (locator: Locator): Promise<number> => {
await expect(locator).toBeVisible();
const box = await locator.boundingBox();

expect(box).not.toBeNull();

return box!.height;
Comment thread
artembelik marked this conversation as resolved.
};

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.use({ permissions: ['clipboard-read', 'clipboard-write'] });

test.describe('E2eTextareaStates', () => {
const getComponent = (page: Page): Locator => page.getByTestId('e2eTextareaStates');

Expand All @@ -16,18 +34,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<number> => {
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');

Expand All @@ -38,10 +44,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);
Expand All @@ -51,16 +57,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);
Comment thread
artembelik marked this conversation as resolved.
});
});

test.describe('E2eTextareaScrollOnFocus', () => {
test('should not scroll the page when focusing a kbqTextarea', async ({ page }) => {
await page.goto('/E2eTextareaScrollOnFocus');
Expand Down
40 changes: 32 additions & 8 deletions packages/components/textarea/e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,6 @@ export class E2eTextareaStates {
<kbq-form-field>
<textarea kbqTextarea data-testid="grow_textarea" [(ngModel)]="value"></textarea>
</kbq-form-field>

<button data-testid="grow_set_short" type="button" (click)="value.set(short)">Short</button>
<button data-testid="grow_set_medium" type="button" (click)="value.set(medium)">Medium</button>
<button data-testid="grow_set_long" type="button" (click)="value.set(long)">Long</button>
`,
styles: `
:host {
Expand All @@ -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<string>(this.medium);
@Component({
selector: 'e2e-textarea-grow-max-rows',
imports: [KbqTextareaModule, FormsModule],
template: `
<kbq-form-field>
<textarea
kbqTextarea
data-testid="grow-max-rows_textarea"
[canGrow]="true"
[maxRows]="5"
[(ngModel)]="value"
></textarea>
</kbq-form-field>
`,
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 =
Expand Down
90 changes: 89 additions & 1 deletion packages/components/textarea/textarea.component.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -141,6 +149,19 @@ class TextareaControlWithAsyncValidators {
});
}

@Component({
imports: [KbqTextareaModule, FormsModule],
template: `
<kbq-form-field>
<textarea kbqTextarea [canGrow]="true" [maxRows]="3" [(ngModel)]="value"></textarea>
</kbq-form-field>
`
})
class KbqTextareaGrowWithMaxRows {
readonly textarea = viewChild.required(KbqTextarea);
value: string = 'line1\nline2';
}

@Component({
imports: [KbqTextareaModule, ReactiveFormsModule],
template: `
Expand Down Expand Up @@ -279,6 +300,73 @@ 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 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');

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(() => {
Comment thread
artembelik marked this conversation as resolved.
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', () => {
Expand Down
21 changes: 8 additions & 13 deletions packages/components/textarea/textarea.component.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand All @@ -24,8 +25,7 @@ import {
KBQ_WINDOW
} from '@koobiq/components/core';
import { KbqFormFieldControl } from '@koobiq/components/form-field';
import { Subject, Subscription } from 'rxjs';
import { delay } from 'rxjs/operators';
import { asapScheduler, observeOn, Subject } from 'rxjs';

export const KBQ_TEXTAREA_VALUE_ACCESSOR = new InjectionToken<{ value: any }>('KBQ_TEXTAREA_VALUE_ACCESSOR');

Expand All @@ -45,7 +45,7 @@ let nextUniqueId = 0;
'[required]': 'required',
'(blur)': 'onBlur()',
'(focus)': 'focusChanged(true)',
'(paste)': 'stateChanges.next()'
'(input)': 'dirtyCheckNativeValue()'
},
Comment thread
artembelik marked this conversation as resolved.
exportAs: 'kbqTextarea'
})
Expand Down Expand Up @@ -208,7 +208,6 @@ export class KbqTextarea
private _required = false;

private valueAccessor: { value: any };
private growSubscription: Subscription;

private lineHeight: number = 0;
private minHeight: number = 0;
Expand All @@ -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.stateChanges.pipe(observeOn(asapScheduler), takeUntilDestroyed()).subscribe(() => this.grow());
}
Comment thread
artembelik marked this conversation as resolved.

ngOnInit() {
Expand Down Expand Up @@ -257,7 +256,6 @@ export class KbqTextarea

ngOnDestroy() {
this.stateChanges.complete();
this.growSubscription.unsubscribe();
}

ngDoCheck() {
Expand Down Expand Up @@ -313,12 +311,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
Comment thread
artembelik marked this conversation as resolved.
);
});
};

Expand Down
8 changes: 7 additions & 1 deletion packages/e2e/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -135,6 +140,7 @@ const components = [
E2eAccordionStates,
E2eTextareaStates,
E2eTextareaGrowBehavior,
E2eTextareaGrowMaxRows,
E2eTextareaScrollOnFocus,
E2eDatepickerStates,
E2eTableStates,
Expand Down
Loading