diff --git a/.vscode/settings.json b/.vscode/settings.json index 2c1485213..c3749ad98 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -27,6 +27,7 @@ "**/src/**/*.spec.ts": "typescriptreact" }, "git.enableCommitSigning": true, + "prettier.prettierPath": "./node_modules/prettier", "editor.codeLens": true, "editor.formatOnSave": true, "editor.codeActionsOnSave": { diff --git a/playwright/cps-ui-kit/components/cps-tree-autocomplete.spec.ts b/playwright/cps-ui-kit/components/cps-tree-autocomplete.spec.ts new file mode 100644 index 000000000..54161162b --- /dev/null +++ b/playwright/cps-ui-kit/components/cps-tree-autocomplete.spec.ts @@ -0,0 +1,385 @@ +import { test, expect, type Page, type Locator } from '@playwright/test'; + +function example(page: Page, testId: string): Locator { + return page.getByTestId(testId); +} + +async function open(wrapper: Locator): Promise { + await wrapper.getByTestId('cps-treeautocomplete-box').click(); +} + +test.describe('cps-tree-autocomplete', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/tree-autocomplete'); + }); + + test.describe('Real keyboard navigation', () => { + test('ArrowDown moves real focus between real treeitems', async ({ + page + }) => { + const wrapper = example(page, 'required-tree-autocomplete'); + await open(wrapper); + + const attr2 = page.getByRole('treeitem', { name: 'Attr2_2' }); + const attr3 = page.getByRole('treeitem', { name: 'Attr3_2' }); + await attr2.focus(); + + await page.keyboard.press('ArrowDown'); + await expect(attr3).toBeFocused(); + }); + + test('Escape closes the real dropdown', async ({ page }) => { + const wrapper = example(page, 'required-tree-autocomplete'); + await open(wrapper); + + await expect(page.getByRole('treeitem').first()).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(page.getByRole('treeitem')).toHaveCount(0); + }); + }); + + test.describe('Real directory expand/collapse via click', () => { + test('clicking a directory row toggles its real aria-expanded state and reveals its children', async ({ + page + }) => { + const wrapper = example(page, 'required-tree-autocomplete'); + await open(wrapper); + + const directory = page.getByRole('treeitem', { name: 'Dataset 1' }); + await expect(directory).toHaveAttribute('aria-expanded', 'false'); + + await directory.click(); + + await expect(directory).toHaveAttribute('aria-expanded', 'true'); + await expect( + page.getByRole('treeitem', { name: 'Attr1_1' }) + ).toBeVisible(); + }); + }); + + test.describe('Real Enter/Space on a leaf node', () => { + test('Enter selects a leaf node and closes the dropdown despite the keydown handler calling preventDefault', async ({ + page + }) => { + const wrapper = example(page, 'required-tree-autocomplete'); + await open(wrapper); + + const leaf = page.getByRole('treeitem', { name: 'Attr2_2' }); + await leaf.focus(); + await page.keyboard.press('Enter'); + + await expect( + wrapper.getByTestId('cps-treeautocomplete-selected-value') + ).toHaveText('Attr2_2'); + await expect(page.getByRole('treeitem')).toHaveCount(0); + }); + + test('Space selects a leaf node and closes the dropdown', async ({ + page + }) => { + const wrapper = example(page, 'required-tree-autocomplete'); + await open(wrapper); + + const leaf = page.getByRole('treeitem', { name: 'Attr3_2' }); + await leaf.focus(); + await page.keyboard.press('Space'); + + await expect( + wrapper.getByTestId('cps-treeautocomplete-selected-value') + ).toHaveText('Attr3_2'); + await expect(page.getByRole('treeitem')).toHaveCount(0); + }); + }); + + test.describe('Real Enter on a directory node', () => { + test('expands the directory instead of selecting/closing', async ({ + page + }) => { + const wrapper = example(page, 'required-tree-autocomplete'); + await open(wrapper); + + const directory = page.getByRole('treeitem', { name: 'Dataset 3' }); + await directory.focus(); + await expect(directory).toHaveAttribute('aria-expanded', 'false'); + + await page.keyboard.press('Enter'); + + await expect(directory).toHaveAttribute('aria-expanded', 'true'); + await expect(page.getByRole('treeitem')).not.toHaveCount(0); + }); + }); + + test.describe('Real virtual scroll rendering', () => { + test('a virtualized tree renders real treeitem nodes on open', async ({ + page + }) => { + const wrapper = example(page, 'virtual-scroll-tree-autocomplete'); + await open(wrapper); + + await expect(page.getByRole('treeitem').first()).toBeVisible(); + }); + }); + + test.describe('Real chip removal', () => { + test('clicking a chip close button removes it from the real selection', async ({ + page + }) => { + const wrapper = example(page, 'virtual-scroll-tree-autocomplete'); + const chips = wrapper + .getByTestId('cps-treeautocomplete-chips-group') + .locator('cps-chip'); + await expect(chips.first()).toBeVisible(); + const initialCount = await chips.count(); + expect(initialCount).toBeGreaterThan(0); + + await chips.first().getByRole('button').click(); + + await expect(chips).toHaveCount(initialCount - 1); + }); + + test('double-Backspace removes the last chip', async ({ page }) => { + const wrapper = example(page, 'virtual-scroll-tree-autocomplete'); + const chips = wrapper + .getByTestId('cps-treeautocomplete-chips-group') + .locator('cps-chip'); + await expect(chips.first()).toBeVisible(); + const initialCount = await chips.count(); + expect(initialCount).toBeGreaterThan(0); + + const input = wrapper.getByTestId('cps-treeautocomplete-input'); + await input.click(); + await expect(input).toBeFocused(); + await page.keyboard.press('Backspace'); + await page.keyboard.press('Backspace'); + + await expect(chips).toHaveCount(initialCount - 1); + }); + }); + + test.describe('Real prefix icon', () => { + test('the configured prefix icon renders in the real box', async ({ + page + }) => { + const wrapper = example(page, 'prefix-icon-tree-autocomplete'); + + await expect( + wrapper.getByTestId('cps-treeautocomplete-prefix-icon') + ).toBeVisible(); + }); + }); + + test.describe('Real required-field validation', () => { + test('clearing the value and blurring shows a real required-field error', async ({ + page + }) => { + const wrapper = example(page, 'required-tree-autocomplete'); + const input = wrapper.getByTestId('cps-treeautocomplete-input'); + + await wrapper.getByTestId('cps-treeautocomplete-clear-icon').click(); + await input.focus(); + await page.keyboard.press('Tab'); + + const error = wrapper.getByTestId('cps-treeautocomplete-error'); + await expect(error).toHaveText('Field is required'); + const describedBy = await input.getAttribute('aria-describedby'); + expect(describedBy).toBeTruthy(); + await expect(error).toHaveAttribute('id', describedBy!); + await expect(input).toHaveAttribute('aria-invalid', 'true'); + }); + }); + + test.describe('Real two-way binding - real readout', () => { + test('selecting a node updates the visible bound value', async ({ + page + }) => { + const wrapper = example(page, 'two-way-binding-tree-autocomplete'); + const readout = page.getByTestId( + 'two-way-binding-tree-autocomplete-value' + ); + + await open(wrapper); + await page.getByRole('treeitem', { name: 'Attr3_2' }).click(); + + await expect(readout).toHaveText('Attr3_2'); + }); + }); + + test.describe('Real accessible-name computation', () => { + test('an unlabeled tree autocomplete exposes its ariaLabel as the real accessible name', async ({ + page + }) => { + await expect( + page.getByRole('combobox', { name: 'Select an item', exact: true }) + ).toBeVisible(); + }); + }); + + test.describe('hideDetails suppresses the hint/error row entirely', () => { + test('no hint element is rendered when hideDetails is set', async ({ + page + }) => { + const hint = example(page, 'hide-details-tree-autocomplete').getByTestId( + 'cps-treeautocomplete-hint' + ); + + await expect(hint).toHaveCount(0); + }); + }); + + test.describe('openOnClear=false', () => { + test('clearing a pre-filled tree autocomplete does not reopen the dropdown', async ({ + page + }) => { + const wrapper = example(page, 'open-on-clear-false-tree-autocomplete'); + + await wrapper.getByTestId('cps-treeautocomplete-clear-icon').click(); + + await expect(page.getByRole('treeitem')).toHaveCount(0); + await expect( + wrapper.getByTestId('cps-treeautocomplete-selected-value') + ).toHaveCount(0); + }); + }); + + test.describe('Real typing filters the tree', () => { + test('typing a leaf label narrows the real tree to matching nodes', async ({ + page + }) => { + const wrapper = example(page, 'required-tree-autocomplete'); + const input = wrapper.getByTestId('cps-treeautocomplete-input'); + await input.click(); + await expect(page.getByRole('treeitem').first()).toBeVisible(); + + await input.fill('AttrB'); + + await expect(page.getByRole('treeitem', { name: 'AttrB' })).toBeVisible(); + await expect( + page.getByRole('treeitem', { name: 'Dataset 2' }) + ).toHaveCount(0); + }); + }); + + test.describe('Real Enter confirms typed text', () => { + test('Enter with text matching a node label exactly selects that node and closes the dropdown', async ({ + page + }) => { + const wrapper = example(page, 'required-tree-autocomplete'); + const input = wrapper.getByTestId('cps-treeautocomplete-input'); + await input.click(); + await input.fill('AttrB'); + + await page.keyboard.press('Enter'); + + await expect( + wrapper.getByTestId('cps-treeautocomplete-selected-value') + ).toHaveText('AttrB'); + await expect(page.getByRole('treeitem')).toHaveCount(0); + }); + + test('Enter with text matching no node reverts the input to the current selection label', async ({ + page + }) => { + const wrapper = example(page, 'required-tree-autocomplete'); + const input = wrapper.getByTestId('cps-treeautocomplete-input'); + const previousLabel = await wrapper + .getByTestId('cps-treeautocomplete-selected-value') + .textContent(); + await input.click(); + await input.fill('zzz-no-such-node-zzz'); + + await page.keyboard.press('Enter'); + + await expect(input).toHaveValue(previousLabel!); + await expect( + wrapper.getByTestId('cps-treeautocomplete-selected-value') + ).toHaveText(previousLabel!); + }); + }); + + test.describe('Real expandAll()/collapseAll()', () => { + test('Expand All reveals a two-level-deep node without any manual clicks', async ({ + page + }) => { + const wrapper = example(page, 'expand-collapse-tree-autocomplete'); + + await page.getByTestId('expand-all-tree-autocomplete-btn').click(); + await open(wrapper); + + await expect( + page.getByRole('treeitem', { name: 'Dataset 1' }) + ).toHaveAttribute('aria-expanded', 'true'); + await expect( + page.getByRole('treeitem', { name: 'Attr1_1' }) + ).toHaveAttribute('aria-expanded', 'true'); + await expect(page.getByRole('treeitem', { name: 'AttrA' })).toBeVisible(); + }); + + test('Collapse All hides a previously-expanded node', async ({ page }) => { + const wrapper = example(page, 'expand-collapse-tree-autocomplete'); + + await open(wrapper); + await page.getByRole('treeitem', { name: 'Dataset 1' }).click(); + await expect( + page.getByRole('treeitem', { name: 'Attr1_1' }) + ).toBeVisible(); + await page.keyboard.press('Escape'); + + await page.getByTestId('collapse-all-tree-autocomplete-btn').click(); + await open(wrapper); + + await expect( + page.getByRole('treeitem', { name: 'Dataset 1' }) + ).toHaveAttribute('aria-expanded', 'false'); + await expect(page.getByRole('treeitem', { name: 'Attr1_1' })).toHaveCount( + 0 + ); + }); + }); + + test.describe('Real custom empty message', () => { + test('typing text with no matches shows the configured emptyMessage', async ({ + page + }) => { + const wrapper = example(page, 'empty-message-tree-autocomplete'); + const input = wrapper.getByTestId('cps-treeautocomplete-input'); + await input.click(); + await expect(page.getByRole('treeitem').first()).toBeVisible(); + + await input.fill('zzz-no-such-node-zzz'); + + await expect( + page + .getByTestId('cps-treeautocomplete-options') + .getByText('Nothing matches your search') + ).toBeVisible(); + await expect(page.getByRole('treeitem')).toHaveCount(0); + }); + }); + + test.describe('Real focus behavior', () => { + test('clicking the box moves real DOM focus to the input', async ({ + page + }) => { + const wrapper = example(page, 'required-tree-autocomplete'); + await open(wrapper); + + await expect( + wrapper.getByTestId('cps-treeautocomplete-input') + ).toBeFocused(); + }); + + test('clicking the box visually hides the selected-label span behind the now-active input', async ({ + page + }) => { + const wrapper = example(page, 'required-tree-autocomplete'); + const selectedValue = wrapper.getByTestId( + 'cps-treeautocomplete-selected-value' + ); + await expect(selectedValue).toHaveCSS('opacity', '1'); + + await open(wrapper); + + await expect(selectedValue).toHaveCSS('opacity', '0'); + }); + }); +}); diff --git a/projects/composition/src/app/pages/tree-autocomplete-page/tree-autocomplete-page.component.html b/projects/composition/src/app/pages/tree-autocomplete-page/tree-autocomplete-page.component.html index cbb019e06..804994078 100644 --- a/projects/composition/src/app/pages/tree-autocomplete-page/tree-autocomplete-page.component.html +++ b/projects/composition/src/app/pages/tree-autocomplete-page/tree-autocomplete-page.component.html @@ -6,6 +6,7 @@ [tsCode]="examples.requiredTreeAutocomplete.ts">
+ + + +
+ + +
+
+ @@ -46,6 +77,7 @@ [htmlCode]="examples.multipleTreeAutocomplete.html" [tsCode]="examples.multipleTreeAutocomplete.ts">
- {{ syncVal?.label }} + {{ syncVal?.label }}
@@ -134,6 +174,7 @@ [htmlCode]="examples.underlinedTreeAutocomplete.html" [tsCode]="examples.underlinedTreeAutocomplete.ts"> + + + + + + + + + + + + + + + + + + + + diff --git a/projects/composition/src/app/pages/tree-autocomplete-page/tree-autocomplete-page.component.scss b/projects/composition/src/app/pages/tree-autocomplete-page/tree-autocomplete-page.component.scss index fe6f52680..6fe19008c 100644 --- a/projects/composition/src/app/pages/tree-autocomplete-page/tree-autocomplete-page.component.scss +++ b/projects/composition/src/app/pages/tree-autocomplete-page/tree-autocomplete-page.component.scss @@ -5,3 +5,9 @@ margin-left: 1.5rem; } } + +.tree-autocomplete-actions { + display: flex; + gap: 0.75rem; + margin-top: 0.75rem; +} diff --git a/projects/composition/src/app/pages/tree-autocomplete-page/tree-autocomplete-page.component.ts b/projects/composition/src/app/pages/tree-autocomplete-page/tree-autocomplete-page.component.ts index db3aa9fed..bed256997 100644 --- a/projects/composition/src/app/pages/tree-autocomplete-page/tree-autocomplete-page.component.ts +++ b/projects/composition/src/app/pages/tree-autocomplete-page/tree-autocomplete-page.component.ts @@ -6,7 +6,7 @@ import { UntypedFormGroup, Validators } from '@angular/forms'; -import { CpsTreeAutocompleteComponent } from 'cps-ui-kit'; +import { CpsButtonComponent, CpsTreeAutocompleteComponent } from 'cps-ui-kit'; import ComponentData from '../../api-data/cps-tree-autocomplete.json'; import { ComponentDocsViewerComponent } from '../../components/component-docs-viewer/component-docs-viewer.component'; import { CodeExampleComponent } from '../../components/code-example/code-example.component'; @@ -15,6 +15,7 @@ import { treeAutocompleteExamples } from './tree-autocomplete-page.examples'; @Component({ imports: [ CpsTreeAutocompleteComponent, + CpsButtonComponent, FormsModule, ReactiveFormsModule, ComponentDocsViewerComponent, diff --git a/projects/composition/src/app/pages/tree-autocomplete-page/tree-autocomplete-page.examples.ts b/projects/composition/src/app/pages/tree-autocomplete-page/tree-autocomplete-page.examples.ts index f3d14b988..96f7506c3 100644 --- a/projects/composition/src/app/pages/tree-autocomplete-page/tree-autocomplete-page.examples.ts +++ b/projects/composition/src/app/pages/tree-autocomplete-page/tree-autocomplete-page.examples.ts @@ -121,6 +121,26 @@ ngOnInit(): void { }` }, + expandCollapseTreeAutocomplete: { + html: ` + + + +`, + ts: treeOptionsTs + }, + loadingTreeAutocomplete: { html: ` +`, + ts: treeOptionsTs + }, + + ariaLabelTreeAutocomplete: { + html: ` + +`, + ts: treeOptionsTs + }, + + hideDetailsTreeAutocomplete: { + html: ` + +`, + ts: treeOptionsTs + }, + + openOnClearFalseTreeAutocomplete: { + html: ` + +`, + ts: treeOptionsTs + }, + + emptyMessageTreeAutocomplete: { + html: ` + `, ts: treeOptionsTs } diff --git a/projects/cps-ui-kit/src/lib/components/cps-tree-autocomplete/cps-tree-autocomplete.component.html b/projects/cps-ui-kit/src/lib/components/cps-tree-autocomplete/cps-tree-autocomplete.component.html index 45dd18811..1e1679083 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-tree-autocomplete/cps-tree-autocomplete.component.html +++ b/projects/cps-ui-kit/src/lib/components/cps-tree-autocomplete/cps-tree-autocomplete.component.html @@ -1,6 +1,7 @@
@if (label) { -
- +
+ @if (infoTooltip) { -
-
+
+
@if (prefixIcon) { } @@ -47,13 +59,21 @@ (treeSelection && !multiple) || (treeSelection?.length > 0 && multiple) ) { -
+
@if (!multiple) { - -
- {{ - treeSelection.label - }} + +
+ {{ treeSelection.label }}
- @for (val of treeSelection; track val; let last = $last) { +
+ @for ( + val of treeSelection; + track val; + let last = $last; + let i = $index + ) {
{{ val.label }}{{ !last ? ',' : '' }}
} + class="cps-treeautocomplete-input-listitem" + data-testid="cps-treeautocomplete-input-listitem"> - @for (val of treeSelection; track val; let last = $last) { + @for ( + val of treeSelection; + track val; + let last = $last; + let i = $index + ) { + class="cps-treeautocomplete-input-listitem" + data-testid="cps-treeautocomplete-input-listitem"> } - + @if (clearable && !disabled) { @@ -198,24 +244,45 @@ [metaKeySelection]="multiple ? false : true" [selectionMode]="multiple ? 'multiple' : 'single'"> - {{ - node.label - }} + {{ node.label }} - - + + @if (multiple) { - + + } {{ node.label }} {{ node.info }} @@ -230,12 +297,16 @@ radius="0.25rem" opacity="0.3" class="cps-treeautocomplete-progress-bar" + data-testid="cps-treeautocomplete-loading-bar" bgColor="transparent"> }
@if (!error && !hideDetails) { -
+
{{ hint }}
} @@ -243,6 +314,7 @@
{{ error }} @@ -257,6 +329,7 @@ { if (value.children) value.expanded = true; }); + this._treeRefreshKey++; + this.treeList?.cd?.markForCheck(); } collapseAll() { this.optionsMap?.forEach((value) => { if (value.children) value.expanded = false; }); + this._treeRefreshKey++; + this.treeList?.cd?.markForCheck(); } // eslint-disable-next-line @typescript-eslint/no-empty-function