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-select.spec.ts b/playwright/cps-ui-kit/components/cps-tree-select.spec.ts new file mode 100644 index 000000000..c1f1d925c --- /dev/null +++ b/playwright/cps-ui-kit/components/cps-tree-select.spec.ts @@ -0,0 +1,262 @@ +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('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-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, 'expand-collapse-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 + }) => { + 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..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,6 +6,7 @@ [tsCode]="examples.requiredTreeSelect.ts">
+ + + +
+ + +
+
+ @@ -46,6 +75,7 @@ [htmlCode]="examples.multipleTreeSelect.html" [tsCode]="examples.multipleTreeSelect.ts">
- {{ syncVal?.label }} + {{ + syncVal?.label + }}
@@ -134,6 +170,7 @@ [htmlCode]="examples.underlinedTreeSelect.html" [tsCode]="examples.underlinedTreeSelect.ts"> + + + + + + + + + + + + + + + diff --git a/projects/composition/src/app/pages/tree-select-page/tree-select-page.component.scss b/projects/composition/src/app/pages/tree-select-page/tree-select-page.component.scss index fe6f52680..cdd5b7d00 100644 --- a/projects/composition/src/app/pages/tree-select-page/tree-select-page.component.scss +++ b/projects/composition/src/app/pages/tree-select-page/tree-select-page.component.scss @@ -5,3 +5,9 @@ margin-left: 1.5rem; } } + +.tree-select-actions { + display: flex; + gap: 0.75rem; + margin-top: 0.75rem; +} diff --git a/projects/composition/src/app/pages/tree-select-page/tree-select-page.component.ts b/projects/composition/src/app/pages/tree-select-page/tree-select-page.component.ts index 89953b611..32bf097e2 100644 --- a/projects/composition/src/app/pages/tree-select-page/tree-select-page.component.ts +++ b/projects/composition/src/app/pages/tree-select-page/tree-select-page.component.ts @@ -6,7 +6,7 @@ import { UntypedFormGroup, Validators } from '@angular/forms'; -import { CpsTreeSelectComponent } from 'cps-ui-kit'; +import { CpsButtonComponent, CpsTreeSelectComponent } from 'cps-ui-kit'; import ComponentData from '../../api-data/cps-tree-select.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 { treeSelectExamples } from './tree-select-page.examples'; @Component({ imports: [ CpsTreeSelectComponent, + CpsButtonComponent, FormsModule, ReactiveFormsModule, ComponentDocsViewerComponent, 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..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 @@ -116,6 +116,26 @@ ngOnInit(): void { }` }, + expandCollapseTreeSelect: { + html: ` + + + +`, + ts: treeOptionsTs + }, + loadingTreeSelect: { html: ` +`, + 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/internal/cps-base-tree-dropdown/cps-base-tree-dropdown.component.ts b/projects/cps-ui-kit/src/lib/components/internal/cps-base-tree-dropdown/cps-base-tree-dropdown.component.ts index a562f1994..e26992b17 100644 --- a/projects/cps-ui-kit/src/lib/components/internal/cps-base-tree-dropdown/cps-base-tree-dropdown.component.ts +++ b/projects/cps-ui-kit/src/lib/components/internal/cps-base-tree-dropdown/cps-base-tree-dropdown.component.ts @@ -408,12 +408,16 @@ export class CpsBaseTreeDropdownComponent this.optionsMap?.forEach((value) => { 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