diff --git a/playwright/cps-ui-kit/components/cps-autocomplete.spec.ts b/playwright/cps-ui-kit/components/cps-autocomplete.spec.ts index 16cefb02c..ce2498f19 100644 --- a/playwright/cps-ui-kit/components/cps-autocomplete.spec.ts +++ b/playwright/cps-ui-kit/components/cps-autocomplete.spec.ts @@ -1,48 +1,459 @@ -import { test, expect } from '@playwright/test'; +import { test, expect, type Page, type Locator } from '@playwright/test'; -test.describe('cps-autocomplete page', () => { - test.describe('required single autocomplete with a tooltip', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/autocomplete'); +/** + * The async demo examples wait on a 500ms input debounce plus a 1000ms + * fake network delay (`_getOptionsFromServer` in + * autocomplete-page.component.ts) - 1500ms total. This is a 4x margin + * over that for CI/slower-browser headroom. + */ +const ASYNC_FETCH_TIMEOUT_MS = 6000; + +/** + * The async-validation demo example simulates a 3000ms validation delay + * (see onOptionSelected's delay(3000) in autocomplete-page.component.ts). + * This is a 2x margin over that for CI/slower-browser headroom. + */ +const ASYNC_VALIDATION_TIMEOUT_MS = 6000; + +function example(page: Page, testId: string): Locator { + return page.getByTestId(testId); +} + +/** Opens the dropdown and clicks an option by its visible name. */ +async function selectOption( + wrapper: Locator, + page: Page, + optionName: string +): Promise { + await wrapper.click(); + await page.getByRole('option', { name: optionName }).click(); +} + +/** + * Fills an autocomplete's input and waits for a specific fetched option + * to appear. Playwright's bundled WebKit build can occasionally close + * the dropdown itself right as async-loaded options render - a quirk + * confirmed specific to that test-runner engine (manually verified not + * to reproduce in real Safari, and never observed in Chromium). On + * other browsers this is a plain wait, so a real regression there still + * fails fast and clearly instead of being masked by the WebKit-only + * reopen-and-retry fallback below. + */ +async function fillAndWaitForOption( + wrapper: Locator, + page: Page, + browserName: string, + query: string, + optionName: string +): Promise { + await wrapper.getByTestId('cps-autocomplete-input').fill(query); + + const option = page.getByRole('option', { name: optionName }); + + if (browserName !== 'webkit') { + await option.waitFor({ + state: 'visible', + timeout: ASYNC_FETCH_TIMEOUT_MS }); + return; + } + + const listbox = page.getByTestId('cps-autocomplete-listbox'); + + await Promise.race([ + option.waitFor({ state: 'visible', timeout: ASYNC_FETCH_TIMEOUT_MS }), + listbox.waitFor({ state: 'hidden', timeout: ASYNC_FETCH_TIMEOUT_MS }) + ]).catch(() => {}); + + if (!(await option.isVisible())) { + await wrapper.click(); + await option.waitFor({ state: 'visible', timeout: 2000 }); + } +} + +test.describe('cps-autocomplete', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/autocomplete'); + }); + + test.describe('Required single-select with tooltip', () => { + test('selects and re-selects an option', async ({ page }) => { + const wrapper = example(page, 'required-autocomplete'); + const input = wrapper.getByTestId('cps-autocomplete-input'); + + await expect(input).toHaveAttribute('role', 'combobox'); + await expect(input).toHaveAttribute('aria-expanded', 'false'); + + await wrapper.click(); + await expect(input).toHaveAttribute('aria-expanded', 'true'); + await expect( + page.getByTestId('cps-autocomplete-listbox') + ).toHaveAttribute('role', 'listbox'); + await expect( + page.getByTestId('cps-autocomplete-option').first() + ).toHaveAttribute('role', 'option'); - test('should select items properly', async ({ page }) => { - await page.getByTestId('required-autocomplete').click(); await page.getByRole('option', { name: 'Rome' }).click(); await expect( - page - .getByTestId('required-autocomplete') - .locator('.single-item-selection') + wrapper.getByTestId('cps-autocomplete-selected-value') ).toHaveText('Rome'); - await page.getByTestId('required-autocomplete').click(); - await page.getByRole('option', { name: 'Prague' }).click(); + await selectOption(wrapper, page, 'Prague'); await expect( - page - .getByTestId('required-autocomplete') - .locator('.single-item-selection') + wrapper.getByTestId('cps-autocomplete-selected-value') ).toHaveText('Prague'); }); - test('should clear items', async ({ page }) => { + test('clearing shows the required error and marks the input invalid', async ({ + page + }) => { + const wrapper = example(page, 'required-autocomplete'); + + await selectOption(wrapper, page, 'Rome'); + + await wrapper.getByTestId('cps-autocomplete-clear-icon').click(); + await page.locator('body').click({ position: { x: 0, y: 0 } }); + + await expect(wrapper.getByTestId('cps-autocomplete-error')).toHaveText( + 'Field is required' + ); await expect( - page - .getByTestId('required-autocomplete') - .locator('.single-item-selection') - ).toHaveText('Prague'); + wrapper.getByTestId('cps-autocomplete-selected-value') + ).toHaveCount(0); + await expect( + wrapper.getByTestId('cps-autocomplete-input') + ).toHaveAttribute('aria-invalid', 'true'); + await expect( + wrapper.getByTestId('cps-autocomplete-input') + ).toHaveAttribute('aria-required', 'true'); + }); + }); + + test.describe('Async single-select', () => { + test('shows a loading state, then populates fetched options', async ({ + page + }) => { + const wrapper = example(page, 'single-async-autocomplete'); + + await wrapper.getByTestId('cps-autocomplete-input').fill('lon'); + await expect(page.getByTestId('cps-autocomplete-loading')).toHaveText( + 'Loading cities...' + ); + await expect(page.getByRole('option', { name: 'London' })).toBeVisible({ + timeout: ASYNC_FETCH_TIMEOUT_MS + }); + }); + + test('shows the empty message when no results match', async ({ page }) => { + const wrapper = example(page, 'single-async-autocomplete'); + + await wrapper.getByTestId('cps-autocomplete-input').fill('zzz-none'); + await expect(page.getByTestId('cps-autocomplete-empty')).toHaveText( + 'No cities found', + { timeout: ASYNC_FETCH_TIMEOUT_MS } + ); + }); + }); - await page - .getByTestId('required-autocomplete') - .locator('.cps-autocomplete-box-clear-icon') + test.describe('Async multi-select', () => { + test('selects multiple options as chips and removes one', async ({ + page, + browserName + }) => { + const wrapper = example(page, 'multiple-async-autocomplete'); + + await fillAndWaitForOption(wrapper, page, browserName, 'o', 'London'); + await page.getByRole('option', { name: 'London' }).click(); + await page.getByRole('option', { name: 'Oslo' }).click(); + await expect(wrapper.locator('cps-chip')).toHaveCount(2); + + await wrapper + .locator('cps-chip') + .filter({ hasText: 'London' }) + .getByRole('button') .click(); + await expect(wrapper.locator('cps-chip')).toHaveCount(1); + await expect(wrapper.locator('cps-chip')).toContainText('Oslo'); + }); - await page.locator('body').click({ position: { x: 0, y: 0 } }); - await expect(page.getByText('Field is required')).toBeVisible(); + test('select all toggles every currently fetched option', async ({ + page, + browserName + }) => { + const wrapper = example(page, 'multiple-async-autocomplete'); + + await fillAndWaitForOption(wrapper, page, browserName, 'o', 'London'); + const selectAll = page.getByTestId('cps-autocomplete-select-all'); + await expect(selectAll).toBeVisible(); + + const optionCount = await page + .getByTestId('cps-autocomplete-option') + .count(); + await selectAll.click(); + await expect(wrapper.locator('cps-chip')).toHaveCount(optionCount); + await expect(selectAll).toHaveAttribute('aria-selected', 'true'); + + await selectAll.click(); + await expect(wrapper.locator('cps-chip')).toHaveCount(0); + }); + }); + + test.describe('Disabled', () => { + test('does not open the dropdown', async ({ page }) => { + const wrapper = example(page, 'disabled-autocomplete'); + + await wrapper.getByTestId('cps-autocomplete-input').click({ + force: true + }); + await expect(page.getByTestId('cps-autocomplete-listbox')).toBeHidden(); + await expect( + wrapper.getByTestId('cps-autocomplete-input') + ).toBeDisabled(); + }); + }); + + test.describe('Multiple, not clearable, plain text', () => { + test('renders comma-separated text instead of chips and hides the clear icon', async ({ + page + }) => { + const wrapper = example(page, 'multiple-not-clearable-autocomplete'); + + await expect(wrapper.locator('cps-chip')).toHaveCount(0); + await expect( + wrapper.getByTestId('cps-autocomplete-selected-text-item') + ).toHaveText('Capetown'); + await expect( + wrapper.getByTestId('cps-autocomplete-clear-icon') + ).toHaveCount(0); + }); + }); + + test.describe('Virtual scroll', () => { + test('opens with a virtualized list and always shows the clear icon', async ({ + page + }) => { + const wrapper = example(page, 'virtual-scroll-autocomplete'); + + await expect( + wrapper.getByTestId('cps-autocomplete-clear-icon') + ).toBeVisible(); + + await wrapper.click(); + await expect(page.locator('.p-virtualscroller')).toBeVisible(); + await expect(page.getByTestId('cps-autocomplete-select-all')).toHaveCount( + 0 + ); + }); + + test('arrow keys highlight options', async ({ page }) => { + const wrapper = example(page, 'virtual-scroll-autocomplete'); + + await wrapper.click(); + await expect(page.locator('.p-virtualscroller')).toBeVisible(); + await page.keyboard.press('ArrowDown'); + await expect( + page.locator('[data-testid="cps-autocomplete-option"].highlighten') + ).toHaveCount(1); + }); + }); + + test.describe('Multiple with non-closable chips', () => { + test('chips have no remove button and the chevron is hidden', async ({ + page + }) => { + const wrapper = example(page, 'non-closable-chips-autocomplete'); + + await expect(wrapper.locator('cps-chip')).toHaveCount(2); await expect( - page - .getByTestId('required-autocomplete') - .locator('.single-item-selection') + wrapper.locator('cps-chip').first().getByRole('button') ).toHaveCount(0); + await expect(wrapper.getByTestId('cps-autocomplete-chevron')).toHaveCount( + 0 + ); + }); + }); + + test.describe('Multiple with prefix icon', () => { + test('shows the prefix icon', async ({ page }) => { + const wrapper = example(page, 'prefix-icon-autocomplete'); + + await expect( + wrapper.getByTestId('cps-autocomplete-prefix-icon') + ).toBeVisible(); + }); + }); + + test.describe('Two-way ngModel binding', () => { + test('selection reflects into the bound model and keeps initial order', async ({ + page + }) => { + const wrapper = example(page, 'two-way-binding-autocomplete'); + + await selectOption(wrapper, page, 'Tesla'); + await selectOption(wrapper, page, 'Amazon'); + + await expect(page.locator('.sync-val')).toHaveText('AMZN,TSLA'); + const chipTexts = await wrapper.locator('cps-chip').allTextContents(); + expect(chipTexts).toEqual(['Amazon', 'Tesla']); + }); + }); + + test.describe('Async validation', () => { + test('shows a busy/validating state after selection that clears once resolved', async ({ + page + }) => { + const wrapper = example(page, 'async-validation-autocomplete'); + + await selectOption(wrapper, page, 'Rome'); + + await expect( + wrapper.getByTestId('cps-autocomplete-input') + ).toHaveAttribute('aria-busy', 'true'); + await expect(wrapper.locator('cps-progress-linear')).toBeVisible(); + + await expect( + wrapper.getByTestId('cps-autocomplete-input') + ).not.toHaveAttribute('aria-busy', 'true', { + timeout: ASYNC_VALIDATION_TIMEOUT_MS + }); + await expect(wrapper.locator('cps-progress-linear')).toHaveCount(0); + }); + }); + + test.describe('Alias filtering', () => { + test('matches an option by alias when the label does not match', async ({ + page + }) => { + const wrapper = example(page, 'alias-filtering-autocomplete'); + + await wrapper.getByTestId('cps-autocomplete-input').fill('prg'); + await expect(page.getByRole('option', { name: 'Prague' })).toBeVisible(); + await expect(page.getByTestId('cps-autocomplete-option')).toHaveCount(1); + }); + }); + + test.describe('Custom empty option index', () => { + test('clearing resolves to the configured empty option instead of nothing', async ({ + page + }) => { + const wrapper = example(page, 'empty-option-index-autocomplete'); + + await expect( + wrapper.getByTestId('cps-autocomplete-selected-value') + ).toHaveText('New York'); + + await wrapper.getByTestId('cps-autocomplete-clear-icon').click(); + + await expect( + wrapper.getByTestId('cps-autocomplete-selected-value') + ).toHaveText('Capetown'); + }); + }); + + test.describe('Clear without reopening', () => { + test('does not reopen the dropdown after clearing', async ({ page }) => { + const wrapper = example(page, 'open-on-clear-false-autocomplete'); + + await wrapper.getByTestId('cps-autocomplete-clear-icon').click(); + await expect(wrapper.getByTestId('cps-autocomplete-input')).toBeFocused(); + await expect(page.getByTestId('cps-autocomplete-listbox')).toBeHidden(); + }); + }); + + test.describe('Hidden hint and error details', () => { + test('never renders hint or error text', async ({ page }) => { + const wrapper = example(page, 'hide-details-autocomplete'); + + await expect(wrapper.getByTestId('cps-autocomplete-hint')).toHaveCount(0); + + await wrapper.click(); + await page.locator('body').click({ position: { x: 0, y: 0 } }); + + await expect(wrapper.getByTestId('cps-autocomplete-error')).toHaveCount( + 0 + ); + }); + }); + + test.describe('Multiple without select all', () => { + test('does not show a select-all row', async ({ page }) => { + const wrapper = example(page, 'select-all-false-autocomplete'); + + await selectOption(wrapper, page, 'Rome'); + + await expect(page.getByTestId('cps-autocomplete-option')).not.toHaveCount( + 0 + ); + await expect(page.getByTestId('cps-autocomplete-select-all')).toHaveCount( + 0 + ); + }); + }); + + test.describe('Keyboard navigation', () => { + test('ArrowDown/ArrowUp highlight options and wrap around', async ({ + page + }) => { + const wrapper = example(page, 'alias-filtering-autocomplete'); + const input = wrapper.getByTestId('cps-autocomplete-input'); + const firstOption = page.getByTestId('cps-autocomplete-option').first(); + const lastOption = page.getByTestId('cps-autocomplete-option').last(); + + await wrapper.click(); + await expect(page.getByTestId('cps-autocomplete-listbox')).toBeVisible(); + await page.keyboard.press('ArrowDown'); + await expect(firstOption).toHaveClass(/highlighten/); + + const activeDescendant = await input.getAttribute( + 'aria-activedescendant' + ); + expect(activeDescendant).toBeTruthy(); + await expect(page.locator(`#${activeDescendant}`)).toHaveAttribute( + 'aria-selected', + 'false' + ); + + await page.keyboard.press('ArrowUp'); + await expect(lastOption).toHaveClass(/highlighten/); + await expect(firstOption).not.toHaveClass(/highlighten/); + }); + + test('Enter selects the highlighted option', async ({ page }) => { + const wrapper = example(page, 'alias-filtering-autocomplete'); + + await wrapper.click(); + await expect(page.getByTestId('cps-autocomplete-listbox')).toBeVisible(); + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('Enter'); + + await expect( + wrapper.getByTestId('cps-autocomplete-selected-value') + ).toHaveText('New York'); + await expect(page.getByTestId('cps-autocomplete-listbox')).toBeHidden(); + }); + + test('Escape closes the dropdown', async ({ page }) => { + const wrapper = example(page, 'required-autocomplete'); + + await wrapper.click(); + await expect(page.getByTestId('cps-autocomplete-listbox')).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(page.getByTestId('cps-autocomplete-listbox')).toBeHidden(); + }); + + test('double Backspace removes the last chip in multi-select', async ({ + page + }) => { + const wrapper = example(page, 'non-closable-chips-autocomplete'); + const input = wrapper.getByTestId('cps-autocomplete-input'); + + await expect(wrapper.locator('cps-chip')).toHaveCount(2); + await input.click(); + await expect(input).toBeFocused(); + await page.keyboard.press('Backspace'); + await page.keyboard.press('Backspace'); + + await expect(wrapper.locator('cps-chip')).toHaveCount(1); }); }); }); diff --git a/projects/composition/src/app/pages/autocomplete-page/autocomplete-page.component.html b/projects/composition/src/app/pages/autocomplete-page/autocomplete-page.component.html index d367bc28c..13e8951a5 100644 --- a/projects/composition/src/app/pages/autocomplete-page/autocomplete-page.component.html +++ b/projects/composition/src/app/pages/autocomplete-page/autocomplete-page.component.html @@ -22,6 +22,7 @@ [htmlCode]="examples.singleAsync.html" [tsCode]="examples.singleAsync.ts"> @@ -71,6 +74,7 @@ [htmlCode]="examples.multipleNotClearable.html" [tsCode]="examples.multipleNotClearable.ts">
+ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/projects/composition/src/app/pages/autocomplete-page/autocomplete-page.component.ts b/projects/composition/src/app/pages/autocomplete-page/autocomplete-page.component.ts index 40ae97d92..10620aa1c 100644 --- a/projects/composition/src/app/pages/autocomplete-page/autocomplete-page.component.ts +++ b/projects/composition/src/app/pages/autocomplete-page/autocomplete-page.component.ts @@ -31,16 +31,31 @@ import { CommonModule } from '@angular/common'; }) export class AutocompletePageComponent implements OnInit { options = [ - { name: 'New York', data: { code: 'NY' } }, - { name: 'Prague', data: { code: 'PRG' }, info: 'Prague info' }, - { name: 'Capetown', data: { code: 'CPT' }, info: 'Capetown info' }, - { name: 'Rome', data: { code: 'RM' } }, - { name: 'London', data: { code: 'LDN' }, info: 'London info' }, - { name: 'Istanbul', data: { code: 'IST' } }, - { name: 'Paris', data: { code: 'PRS' } }, - { name: 'Tokyo', data: { code: 'TOK' } }, - { name: 'Oslo', data: { code: 'OSL' }, info: 'Oslo info' }, - { name: 'Berlin', data: { code: 'BER' } } + { name: 'New York', data: { code: 'NY' }, alias: 'NYC' }, + { + name: 'Prague', + data: { code: 'PRG' }, + info: 'Prague info', + alias: 'PRG' + }, + { + name: 'Capetown', + data: { code: 'CPT' }, + info: 'Capetown info', + alias: 'CPT' + }, + { name: 'Rome', data: { code: 'RM' }, alias: 'ROM' }, + { + name: 'London', + data: { code: 'LDN' }, + info: 'London info', + alias: 'LDN' + }, + { name: 'Istanbul', data: { code: 'IST' }, alias: 'IST' }, + { name: 'Paris', data: { code: 'PRS' }, alias: 'PAR' }, + { name: 'Tokyo', data: { code: 'TOK' }, alias: 'TOK' }, + { name: 'Oslo', data: { code: 'OSL' }, info: 'Oslo info', alias: 'OSL' }, + { name: 'Berlin', data: { code: 'BER' }, alias: 'BER' } ]; syncOptions = [ @@ -57,6 +72,9 @@ export class AutocompletePageComponent implements OnInit { syncVal: any = []; componentData = ComponentData; + emptyOptionIndexValue = this.options[0]; + openOnClearValue = this.options[3]; + isSingleLoading = false; isMultiLoading = false; externalError = ''; @@ -79,7 +97,8 @@ export class AutocompletePageComponent implements OnInit { ngOnInit(): void { this.form = this._formBuilder.group({ - requiredAutocomplete: [this.options[1], [Validators.required]] + requiredAutocomplete: [this.options[1], [Validators.required]], + hideDetailsAutocomplete: [null, [Validators.required]] }); this.singleOptionsObservable$ = this._defineOptionsObservable( diff --git a/projects/composition/src/app/pages/autocomplete-page/autocomplete-page.examples.ts b/projects/composition/src/app/pages/autocomplete-page/autocomplete-page.examples.ts index 96e6f66b5..31d2417d4 100644 --- a/projects/composition/src/app/pages/autocomplete-page/autocomplete-page.examples.ts +++ b/projects/composition/src/app/pages/autocomplete-page/autocomplete-page.examples.ts @@ -1,15 +1,15 @@ const citiesOptionsTs = ` options = [ - { name: 'New York', data: { code: 'NY' } }, - { name: 'Prague', data: { code: 'PRG' }, info: 'Prague info' }, - { name: 'Capetown', data: { code: 'CPT' }, info: 'Capetown info' }, - { name: 'Rome', data: { code: 'RM' } }, - { name: 'London', data: { code: 'LDN' }, info: 'London info' }, - { name: 'Istanbul', data: { code: 'IST' } }, - { name: 'Paris', data: { code: 'PRS' } }, - { name: 'Tokyo', data: { code: 'TOK' } }, - { name: 'Oslo', data: { code: 'OSL' }, info: 'Oslo info' }, - { name: 'Berlin', data: { code: 'BER' } } + { name: 'New York', data: { code: 'NY' }, alias: 'NYC' }, + { name: 'Prague', data: { code: 'PRG' }, info: 'Prague info', alias: 'PRG' }, + { name: 'Capetown', data: { code: 'CPT' }, info: 'Capetown info', alias: 'CPT' }, + { name: 'Rome', data: { code: 'RM' }, alias: 'ROM' }, + { name: 'London', data: { code: 'LDN' }, info: 'London info', alias: 'LDN' }, + { name: 'Istanbul', data: { code: 'IST' }, alias: 'IST' }, + { name: 'Paris', data: { code: 'PRS' }, alias: 'PAR' }, + { name: 'Tokyo', data: { code: 'TOK' }, alias: 'TOK' }, + { name: 'Oslo', data: { code: 'OSL' }, info: 'Oslo info', alias: 'OSL' }, + { name: 'Berlin', data: { code: 'BER' }, alias: 'BER' } ];`; export const autocompleteExamples: Record< @@ -265,5 +265,90 @@ onOptionSelected(option: Option): void { error: () => (this.externalError = 'Validation failed') }); }` + }, + + aliasFiltering: { + html: ` + +`, + ts: citiesOptionsTs + }, + + emptyOptionIndex: { + html: ` + +`, + ts: ` +${citiesOptionsTs.trim()} + +emptyOptionIndexValue = this.options[0];` + }, + + openOnClear: { + html: ` + +`, + ts: ` +${citiesOptionsTs.trim()} + +openOnClearValue = this.options[3];` + }, + + hideDetails: { + html: ` + +`, + ts: ` +${citiesOptionsTs.trim()} + +form = this.fb.group({ + hideDetailsAutocomplete: [null, [Validators.required]] +});` + }, + + selectAllFalse: { + html: ` + +`, + ts: citiesOptionsTs } }; diff --git a/projects/cps-ui-kit/src/lib/components/cps-autocomplete/cps-autocomplete.component.html b/projects/cps-ui-kit/src/lib/components/cps-autocomplete/cps-autocomplete.component.html index 3cc2fdd2e..56ab52767 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-autocomplete/cps-autocomplete.component.html +++ b/projects/cps-ui-kit/src/lib/components/cps-autocomplete/cps-autocomplete.component.html @@ -1,6 +1,7 @@
@if (label) { -
+
@if (infoTooltip) { + class="prefix-icon" + data-testid="cps-autocomplete-prefix-icon"> } @if (hasSelectedValue()) {
@if (!multiple) { -
+
{{ returnObject ? value[optionLabel] @@ -84,6 +88,7 @@ ) {
@if (!error && !externalError && !hideDetails) { -
+
{{ hint }}
} @@ -319,6 +333,7 @@
{{ error || externalError }} @@ -334,6 +349,7 @@ #autocompleteInput type="text" class="cps-autocomplete-box-input" + data-testid="cps-autocomplete-input" spellcheck="false" [disabled]="disabled" role="combobox" @@ -360,6 +376,7 @@