From 7bc259112a2379feb4b43ff3637639389e5439a4 Mon Sep 17 00:00:00 2001 From: Andrei Fateev Date: Wed, 12 Aug 2026 13:31:00 +0200 Subject: [PATCH 1/4] test: add Playwright coverage and test ids for CpsTreeSelectComponent --- .../components/cps-tree-select.spec.ts | 222 ++++++++++++++++++ .../tree-select-page.component.html | 50 +++- .../tree-select-page.examples.ts | 33 +++ .../cps-tree-select.component.html | 65 ++++- .../cps-tree-select.component.ts | 4 +- 5 files changed, 361 insertions(+), 13 deletions(-) create mode 100644 playwright/cps-ui-kit/components/cps-tree-select.spec.ts diff --git a/playwright/cps-ui-kit/components/cps-tree-select.spec.ts b/playwright/cps-ui-kit/components/cps-tree-select.spec.ts new file mode 100644 index 000000000..066c98121 --- /dev/null +++ b/playwright/cps-ui-kit/components/cps-tree-select.spec.ts @@ -0,0 +1,222 @@ +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-treeselect-box').click(); +} + +test.describe('cps-tree-select', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/tree-select'); + }); + + test.describe('Real keyboard navigation', () => { + test('ArrowDown moves real focus between real treeitems', async ({ + page + }) => { + const wrapper = example(page, 'required-tree-select'); + 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-select'); + 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-select'); + 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-select'); + await open(wrapper); + + const leaf = page.getByRole('treeitem', { name: 'Attr2_2' }); + await leaf.focus(); + await page.keyboard.press('Enter'); + + await expect( + wrapper.getByTestId('cps-treeselect-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-select'); + await open(wrapper); + + const leaf = page.getByRole('treeitem', { name: 'Attr3_2' }); + await leaf.focus(); + await page.keyboard.press('Space'); + + await expect( + wrapper.getByTestId('cps-treeselect-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-select'); + 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-select'); + 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-select'); + const chips = wrapper + .getByTestId('cps-treeselect-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.describe('Real prefix icon', () => { + test('the configured prefix icon renders in the real box', async ({ + page + }) => { + const wrapper = example(page, 'prefix-icon-tree-select'); + + await expect( + wrapper.getByTestId('cps-treeselect-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-select'); + const container = wrapper.getByTestId('cps-treeselect-container'); + + await wrapper.getByTestId('cps-treeselect-clear-icon').click(); + await container.focus(); + await page.keyboard.press('Tab'); + + const error = wrapper.getByTestId('cps-treeselect-error'); + await expect(error).toHaveText('Field is required'); + const describedBy = await container.getAttribute('aria-describedby'); + expect(describedBy).toBeTruthy(); + await expect(error).toHaveAttribute('id', describedBy!); + }); + }); + + 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-select'); + const readout = page.getByTestId('two-way-binding-tree-select-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 select 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-select').getByTestId( + 'cps-treeselect-hint' + ); + + await expect(hint).toHaveCount(0); + }); + }); + + test.describe('openOnClear=false', () => { + test('clearing a pre-filled tree select does not reopen the dropdown', async ({ + page + }) => { + const wrapper = example(page, 'open-on-clear-false-tree-select'); + + await wrapper.getByTestId('cps-treeselect-clear-icon').click(); + + await expect(page.getByRole('treeitem')).toHaveCount(0); + await expect( + wrapper.getByTestId('cps-treeselect-placeholder') + ).toBeVisible(); + }); + }); +}); diff --git a/projects/composition/src/app/pages/tree-select-page/tree-select-page.component.html b/projects/composition/src/app/pages/tree-select-page/tree-select-page.component.html index 6e8dae9b9..35e49b6c5 100644 --- a/projects/composition/src/app/pages/tree-select-page/tree-select-page.component.html +++ b/projects/composition/src/app/pages/tree-select-page/tree-select-page.component.html @@ -6,6 +6,7 @@ [tsCode]="examples.requiredTreeSelect.ts">
@@ -46,6 +49,7 @@ [htmlCode]="examples.multipleTreeSelect.html" [tsCode]="examples.multipleTreeSelect.ts">
- {{ syncVal?.label }} + {{ + syncVal?.label + }}
@@ -134,6 +144,7 @@ [htmlCode]="examples.underlinedTreeSelect.html" [tsCode]="examples.underlinedTreeSelect.ts"> + + + + + + + + + + + + + + + diff --git a/projects/composition/src/app/pages/tree-select-page/tree-select-page.examples.ts b/projects/composition/src/app/pages/tree-select-page/tree-select-page.examples.ts index 53950476c..3e3153a33 100644 --- a/projects/composition/src/app/pages/tree-select-page/tree-select-page.examples.ts +++ b/projects/composition/src/app/pages/tree-select-page/tree-select-page.examples.ts @@ -256,6 +256,39 @@ syncVal: any = null;` [clearable]="true" [multiple]="true" [chips]="false"> +`, + ts: treeOptionsTs + }, + + ariaLabelTreeSelect: { + html: ` + +`, + ts: treeOptionsTs + }, + + hideDetailsTreeSelect: { + html: ` + +`, + ts: treeOptionsTs + }, + + openOnClearFalseTreeSelect: { + html: ` + `, ts: treeOptionsTs } diff --git a/projects/cps-ui-kit/src/lib/components/cps-tree-select/cps-tree-select.component.html b/projects/cps-ui-kit/src/lib/components/cps-tree-select/cps-tree-select.component.html index 27fa1d807..0afe3bf58 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-tree-select/cps-tree-select.component.html +++ b/projects/cps-ui-kit/src/lib/components/cps-tree-select/cps-tree-select.component.html @@ -1,14 +1,16 @@
@if (label) { -
- +
+ @if (infoTooltip) { -
+
@if (prefixIcon) { } @@ -55,7 +63,9 @@ (!treeSelection && !multiple) || (treeSelection?.length < 1 && multiple) ) { -
+
{{ placeholder }}
} @@ -65,11 +75,22 @@ ) {
@if (!multiple) { - {{ treeSelection.label }} + + {{ treeSelection.label }} } @if (multiple && !chips) { -
- +
+ {{ treeSelection | combineLabels: innerOptions : '' : 'label' : true @@ -80,6 +101,7 @@ @if (multiple && chips) {
@for (val of treeSelection; track val) { @@ -101,6 +123,7 @@ @if (clearable && !disabled) { @@ -167,22 +192,35 @@ [metaKeySelection]="multiple ? false : true" [selectionMode]="multiple ? 'multiple' : 'single'"> - {{ node.label }} + {{ node.label }} - + @if (multiple) { - + + } {{ node.label }} {{ node.info }} @@ -197,12 +235,16 @@ radius="0.25rem" opacity="0.3" class="cps-treeselect-progress-bar" + data-testid="cps-treeselect-loading-bar" bgColor="transparent"> }
@if (!error && !hideDetails) { -
+
{{ hint }}
} @@ -210,6 +252,7 @@
{{ error }} diff --git a/projects/cps-ui-kit/src/lib/components/cps-tree-select/cps-tree-select.component.ts b/projects/cps-ui-kit/src/lib/components/cps-tree-select/cps-tree-select.component.ts index 09fc11e2e..e3ddca74c 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-tree-select/cps-tree-select.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-tree-select/cps-tree-select.component.ts @@ -14,7 +14,9 @@ import { CpsBaseTreeDropdownComponent } from '../internal/cps-base-tree-dropdown * @group Types */ export type CpsTreeSelectAppearanceType = - 'outlined' | 'underlined' | 'borderless'; + | 'outlined' + | 'underlined' + | 'borderless'; /** * CpsTreeSelectComponent allows to select items from hierarchical data dropdown. From e510b4f6aad435d4fe08352c20a61fe5c596ea83 Mon Sep 17 00:00:00 2001 From: Andrei Fateev Date: Wed, 12 Aug 2026 13:50:01 +0200 Subject: [PATCH 2/4] add one more example --- .../components/cps-tree-select.spec.ts | 40 +++++++++++++++++++ .../tree-select-page.component.html | 11 +++++ .../tree-select-page.component.scss | 6 +++ .../tree-select-page.component.ts | 3 +- .../tree-select-page.examples.ts | 9 ++++- 5 files changed, 67 insertions(+), 2 deletions(-) diff --git a/playwright/cps-ui-kit/components/cps-tree-select.spec.ts b/playwright/cps-ui-kit/components/cps-tree-select.spec.ts index 066c98121..bd8f545c1 100644 --- a/playwright/cps-ui-kit/components/cps-tree-select.spec.ts +++ b/playwright/cps-ui-kit/components/cps-tree-select.spec.ts @@ -205,6 +205,46 @@ test.describe('cps-tree-select', () => { }); }); + test.describe('Real expandAll()/collapseAll()', () => { + test('Expand All reveals a two-level-deep node without any manual clicks', async ({ + page + }) => { + const wrapper = example(page, 'required-tree-select'); + + await page.getByTestId('expand-all-tree-select-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, 'required-tree-select'); + + 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-select-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('openOnClear=false', () => { test('clearing a pre-filled tree select does not reopen the dropdown', async ({ page diff --git a/projects/composition/src/app/pages/tree-select-page/tree-select-page.component.html b/projects/composition/src/app/pages/tree-select-page/tree-select-page.component.html index 35e49b6c5..41e357e95 100644 --- a/projects/composition/src/app/pages/tree-select-page/tree-select-page.component.html +++ b/projects/composition/src/app/pages/tree-select-page/tree-select-page.component.html @@ -6,6 +6,7 @@ [tsCode]="examples.requiredTreeSelect.ts"> +
+ + +
= html: `
= [clearable]="true" formControlName="requiredTreeSelect"> -
`, + + +`, ts: ` private readonly _formBuilder = inject(UntypedFormBuilder); From b47760a035c3b883576f33b0b19e2c1301713f29 Mon Sep 17 00:00:00 2001 From: Andrei Fateev Date: Wed, 12 Aug 2026 14:13:23 +0200 Subject: [PATCH 3/4] more to prev --- .../components/cps-tree-select.spec.ts | 4 +-- .../tree-select-page.component.html | 21 ++++++++++++-- .../tree-select-page.examples.ts | 29 ++++++++++++++----- .../cps-base-tree-dropdown.component.ts | 4 +++ 4 files changed, 45 insertions(+), 13 deletions(-) diff --git a/playwright/cps-ui-kit/components/cps-tree-select.spec.ts b/playwright/cps-ui-kit/components/cps-tree-select.spec.ts index bd8f545c1..c1f1d925c 100644 --- a/playwright/cps-ui-kit/components/cps-tree-select.spec.ts +++ b/playwright/cps-ui-kit/components/cps-tree-select.spec.ts @@ -209,7 +209,7 @@ test.describe('cps-tree-select', () => { test('Expand All reveals a two-level-deep node without any manual clicks', async ({ page }) => { - const wrapper = example(page, 'required-tree-select'); + const wrapper = example(page, 'expand-collapse-tree-select'); await page.getByTestId('expand-all-tree-select-btn').click(); await open(wrapper); @@ -224,7 +224,7 @@ test.describe('cps-tree-select', () => { }); test('Collapse All hides a previously-expanded node', async ({ page }) => { - const wrapper = example(page, 'required-tree-select'); + const wrapper = example(page, 'expand-collapse-tree-select'); await open(wrapper); await page.getByRole('treeitem', { name: 'Dataset 1' }).click(); diff --git a/projects/composition/src/app/pages/tree-select-page/tree-select-page.component.html b/projects/composition/src/app/pages/tree-select-page/tree-select-page.component.html index 41e357e95..7cda7b043 100644 --- a/projects/composition/src/app/pages/tree-select-page/tree-select-page.component.html +++ b/projects/composition/src/app/pages/tree-select-page/tree-select-page.component.html @@ -6,7 +6,6 @@ [tsCode]="examples.requiredTreeSelect.ts">
+
+ + + +
+ (clicked)="expandCollapseTreeSelectRef.expandAll()"> + (clicked)="expandCollapseTreeSelectRef.collapseAll()">
diff --git a/projects/composition/src/app/pages/tree-select-page/tree-select-page.examples.ts b/projects/composition/src/app/pages/tree-select-page/tree-select-page.examples.ts index 221c426ec..a9381c77e 100644 --- a/projects/composition/src/app/pages/tree-select-page/tree-select-page.examples.ts +++ b/projects/composition/src/app/pages/tree-select-page/tree-select-page.examples.ts @@ -92,7 +92,6 @@ export const treeSelectExamples: Record = html: `
= [clearable]="true" formControlName="requiredTreeSelect"> -
- -`, +`, ts: ` private readonly _formBuilder = inject(UntypedFormBuilder); @@ -123,6 +116,26 @@ ngOnInit(): void { }` }, + expandCollapseTreeSelect: { + html: ` + + + +`, + ts: treeOptionsTs + }, + loadingTreeSelect: { html: ` { 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 From a0810fd1ed7ee0f87f470ce8a56b9d817256f72e Mon Sep 17 00:00:00 2001 From: Andrei Fateev Date: Wed, 12 Aug 2026 16:43:30 +0200 Subject: [PATCH 4/4] fix prettier --- .vscode/settings.json | 1 + .../components/cps-tree-select/cps-tree-select.component.ts | 4 +--- 2 files changed, 2 insertions(+), 3 deletions(-) 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/projects/cps-ui-kit/src/lib/components/cps-tree-select/cps-tree-select.component.ts b/projects/cps-ui-kit/src/lib/components/cps-tree-select/cps-tree-select.component.ts index e3ddca74c..09fc11e2e 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-tree-select/cps-tree-select.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-tree-select/cps-tree-select.component.ts @@ -14,9 +14,7 @@ import { CpsBaseTreeDropdownComponent } from '../internal/cps-base-tree-dropdown * @group Types */ export type CpsTreeSelectAppearanceType = - | 'outlined' - | 'underlined' - | 'borderless'; + 'outlined' | 'underlined' | 'borderless'; /** * CpsTreeSelectComponent allows to select items from hierarchical data dropdown.