From 8c3eff24ad3f6c5751548e31e047c48c0c2e5a00 Mon Sep 17 00:00:00 2001 From: Andrei Fateev Date: Wed, 17 Jun 2026 14:03:06 +0200 Subject: [PATCH 01/13] fixing ally issues in CpsTreeSelectComponent --- playwright/cps-accessibility.spec.ts | 36 +-- .../app/api-data/cps-tree-autocomplete.json | 10 +- .../src/app/api-data/cps-tree-select.json | 10 +- .../src/app/api-data/internal.json | 10 +- .../tree-select-page.component.html | 4 +- .../tree-select-page.component.scss | 4 +- .../cps-tree-autocomplete.component.html | 32 +-- .../cps-tree-autocomplete.component.scss | 5 + .../cps-tree-autocomplete.component.ts | 28 +-- .../cps-tree-select.component.html | 90 ++++--- .../cps-tree-select.component.scss | 156 ++++++++---- .../cps-tree-select.component.ts | 120 ++++++--- .../cps-base-tree-dropdown.component.ts | 227 +++++++++++++++--- 13 files changed, 525 insertions(+), 207 deletions(-) diff --git a/playwright/cps-accessibility.spec.ts b/playwright/cps-accessibility.spec.ts index 3c7a5aca8..1bf18f3b2 100644 --- a/playwright/cps-accessibility.spec.ts +++ b/playwright/cps-accessibility.spec.ts @@ -227,25 +227,25 @@ const components: ComponentEntry[] = [ await page.waitForSelector('.example-content'); await page.locator('.example-content cps-button').first().hover(); } + }, + { + route: '/tree-autocomplete', + name: 'Tree autocomplete', + selector: ['cps-tree-autocomplete', '.cps-treeautocomplete-options-menu'], + setup: async (page) => { + await page.waitForSelector('cps-tree-autocomplete'); + await page.locator('cps-tree-autocomplete').first().click(); + } + }, + { + route: '/tree-select', + name: 'Tree select', + selector: ['cps-tree-select', '.cps-treeselect-options-menu'], + setup: async (page) => { + await page.waitForSelector('cps-tree-select'); + await page.locator('cps-tree-select').first().click(); + } } - // { - // route: '/tree-autocomplete', - // name: 'Tree autocomplete', - // selector: ['cps-tree-autocomplete', '.cps-treeautocomplete-options-menu'], - // setup: async (page) => { - // await page.waitForSelector('cps-tree-autocomplete'); - // await page.locator('cps-tree-autocomplete').first().click(); - // } - // }, - // { - // route: '/tree-select', - // name: 'Tree select', - // selector: ['cps-tree-select', '.cps-treeselect-options-menu'], - // setup: async (page) => { - // await page.waitForSelector('cps-tree-select'); - // await page.locator('cps-tree-select').first().click(); - // } - // }, // { route: '/tree-table', name: 'Tree table', selector: 'cps-tree-table' } ]; diff --git a/projects/composition/src/app/api-data/cps-tree-autocomplete.json b/projects/composition/src/app/api-data/cps-tree-autocomplete.json index c07d9106e..f804f5bc5 100644 --- a/projects/composition/src/app/api-data/cps-tree-autocomplete.json +++ b/projects/composition/src/app/api-data/cps-tree-autocomplete.json @@ -37,6 +37,14 @@ "default": "", "description": "Label of the component." }, + { + "name": "ariaLabel", + "optional": false, + "readonly": false, + "type": "string", + "default": "", + "description": "Aria label for accessibility, takes precedence over label." + }, { "name": "hint", "optional": false, @@ -146,7 +154,7 @@ "optional": false, "readonly": false, "type": "iconSizeType", - "default": "18px", + "default": "1.125rem", "description": "Size of icon before input value, of type number, string, 'fill', 'xsmall', 'small', 'normal' or 'large'." }, { diff --git a/projects/composition/src/app/api-data/cps-tree-select.json b/projects/composition/src/app/api-data/cps-tree-select.json index 5199cabea..edd768a03 100644 --- a/projects/composition/src/app/api-data/cps-tree-select.json +++ b/projects/composition/src/app/api-data/cps-tree-select.json @@ -29,6 +29,14 @@ "default": "", "description": "Label of the component." }, + { + "name": "ariaLabel", + "optional": false, + "readonly": false, + "type": "string", + "default": "", + "description": "Aria label for accessibility, takes precedence over label." + }, { "name": "hint", "optional": false, @@ -138,7 +146,7 @@ "optional": false, "readonly": false, "type": "iconSizeType", - "default": "18px", + "default": "1.125rem", "description": "Size of icon before input value, of type number, string, 'fill', 'xsmall', 'small', 'normal' or 'large'." }, { diff --git a/projects/composition/src/app/api-data/internal.json b/projects/composition/src/app/api-data/internal.json index 582dc60a8..601e71c56 100644 --- a/projects/composition/src/app/api-data/internal.json +++ b/projects/composition/src/app/api-data/internal.json @@ -13,6 +13,14 @@ "default": "", "description": "Label of the component." }, + { + "name": "ariaLabel", + "optional": false, + "readonly": false, + "type": "string", + "default": "", + "description": "Aria label for accessibility, takes precedence over label." + }, { "name": "hint", "optional": false, @@ -122,7 +130,7 @@ "optional": false, "readonly": false, "type": "iconSizeType", - "default": "18px", + "default": "1.125rem", "description": "Size of icon before input value, of type number, string, 'fill', 'xsmall', 'small', 'normal' or 'large'." }, { 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 ea7291fdc..1f96f4b6a 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 @@ -76,9 +76,9 @@
@if (label) {
@@ -24,11 +26,9 @@ (keydown)="onContainerKeyDown($event)" class="cps-treeautocomplete-container" [class.focused]="isActive()" - [ngClass]="{ - 'persistent-clear': persistentClear, - borderless: appearance === 'borderless', - underlined: appearance === 'underlined' - }"> + [class.persistent-clear]="persistentClear" + [class.borderless]="appearance === 'borderless'" + [class.underlined]="appearance === 'underlined'">
@if (prefixIcon) { @@ -67,9 +67,7 @@ @for (val of treeSelection; track val; let last = $last) {
+ [class.about-to-remove]="last && backspaceClickedOnce"> {{ val.label }}{{ !last ? ',' : '' }}
} @@ -89,9 +87,7 @@ [disabled]="disabled" [closable]="closableChips" (closed)="remove(val)" - [ngClass]="{ - 'about-to-remove': last && backspaceClickedOnce - }" + [class.about-to-remove]="last && backspaceClickedOnce" [label]="val.label"> } @@ -160,18 +156,14 @@ (beforeMenuHidden)="onBeforeOptionsHidden($event)" hideTransitionOptions="0s linear" containerClass="cps-treeautocomplete-options-menu"> -
+
+ [class.disabled]="disabled" + [class.error]="error"> @if (label) {
@@ -25,12 +21,26 @@ }
+ [class.persistent-clear]="persistentClear" + [class.borderless]="appearance === 'borderless'" + [class.underlined]="appearance === 'underlined'" + [class.active]="isOpened" + #componentContainer + role="combobox" + aria-haspopup="tree" + [attr.aria-expanded]="isOpened" + [attr.aria-busy]="loading ? true : null" + [attr.aria-controls]="optionsTreeId" + [attr.aria-required]="isRequired || null" + [attr.aria-invalid]="error ? 'true' : null" + [attr.aria-label]="ariaLabel || label || null" + [attr.aria-describedby]="describedBy" + [attr.aria-disabled]="disabled || null" + (keydown)="onKeyDown($event)" + (focus)="onFocus()" + (blur)="onBlur()">
@if (prefixIcon) { @@ -58,8 +68,8 @@ {{ treeSelection.label }} } @if (multiple && !chips) { -
- +
+ {{ treeSelection | combineLabels: innerOptions : '' : 'label' : true @@ -68,9 +78,14 @@
} @if (multiple && chips) { -
+
@for (val of treeSelection; track val) { @if (clearable && !disabled) { - + } @if (showChevron) { - +
+ [class.arrow-navigating]="isArrowNavigating" + [style.width.px]="boxWidthPx" + (keydown)="onOptionsKeyDown($event)"> + [selectionMode]="multiple ? 'multiple' : 'single'" + [pt]="{ nodeToggleButton: { 'aria-label': 'Toggle' } }"> {{ node.label }} @@ -172,12 +202,16 @@ }
@if (!error && !hideDetails) { -
+
{{ hint }}
} @if (error && !hideDetails) { -
+
{{ error }}
} diff --git a/projects/cps-ui-kit/src/lib/components/cps-tree-select/cps-tree-select.component.scss b/projects/cps-ui-kit/src/lib/components/cps-tree-select/cps-tree-select.component.scss index 80ce8e357..4cba14718 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-tree-select/cps-tree-select.component.scss +++ b/projects/cps-ui-kit/src/lib/components/cps-tree-select/cps-tree-select.component.scss @@ -1,3 +1,5 @@ +@use '../../../../styles/mixins' as *; + $color-calm: var(--cps-color-calm); $color-error: var(--cps-state-error); $error-background: #fef3f2; @@ -32,10 +34,20 @@ $hover-transition-duration: 0.2s; .cps-treeselect-container { position: relative; + outline: none; + + &:focus-visible { + @include focus-ring(0, -0.0625rem, 0.25rem); + &::before, + &::after { + pointer-events: none; + } + } + .cps-treeselect-progress-bar { position: absolute; - bottom: 1px; - padding: 0 1px; + bottom: 0.0625rem; + padding: 0 0.0625rem; } &.borderless, @@ -48,22 +60,23 @@ $hover-transition-duration: 0.2s; } &.underlined { .cps-treeselect-box { - border-bottom: 1px solid $treeselect-border-color !important; + border-bottom: 0.0625rem solid $treeselect-border-color !important; } } } - &.active { + .cps-treeselect-container.active { .cps-treeselect-box { - border: 1px solid $color-calm; + border: 0.0625rem solid $color-calm; .cps-treeselect-box-left { .prefix-icon { color: $color-calm; } } .cps-treeselect-box-chevron { - top: 22px; - transform: rotate(180deg); + cps-icon { + transform: rotate(180deg); + } } } } @@ -76,7 +89,7 @@ $hover-transition-duration: 0.2s; font-size: 0.875rem; font-weight: 600; .cps-treeselect-label-info-circle { - margin-left: 8px; + margin-left: 0.5rem; pointer-events: all; } } @@ -86,7 +99,7 @@ $hover-transition-duration: 0.2s; .cps-treeselect-container:hover { .cps-treeselect-box { .cps-treeselect-box-icons { - .cps-treeselect-box-clear-icon { + .cps-treeselect-box-clear-icon:not(:focus):not(:hover) { cps-icon { opacity: 0.5; } @@ -98,17 +111,17 @@ $hover-transition-duration: 0.2s; .cps-treeselect-box { overflow: hidden; justify-content: space-between; - min-height: 38px; + min-height: 2.375rem; width: 100%; cursor: pointer; background: white; font-size: 1rem; outline: none; - padding: 0 12px; - border-radius: 4px; + padding: 0 0.75rem; + border-radius: 0.25rem; align-items: center; display: flex; - border: 1px solid $treeselect-border-color; + border: 0.0625rem solid $treeselect-border-color; transition-duration: $hover-transition-duration; &-placeholder { @@ -117,19 +130,19 @@ $hover-transition-duration: 0.2s; } &-items { - margin-top: 3px; - margin-bottom: 3px; + margin-top: 0.1875rem; + margin-bottom: 0.1875rem; .text-group, .single-item { color: $treeselect-option-value-color; - padding-top: 3px; - padding-bottom: 3px; + padding-top: 0.1875rem; + padding-bottom: 0.1875rem; } .chips-group { cps-chip { - padding-bottom: 3px; - padding-top: 3px; - padding-right: 4px; + padding-bottom: 0.1875rem; + padding-top: 0.1875rem; + padding-right: 0.25rem; } } .text-group-item { @@ -147,7 +160,7 @@ $hover-transition-duration: 0.2s; } &:hover { - border: 1px solid $color-calm; + border: 0.0625rem solid $color-calm; .cps-treeselect-box-left { .prefix-icon { color: $color-calm; @@ -160,20 +173,41 @@ $hover-transition-duration: 0.2s; .cps-treeselect-box-clear-icon { display: flex; + padding: 0.25rem; color: $color-error; - margin-left: 8px; + margin-left: 0.25rem; + cursor: pointer; + &:focus { + outline: none; + } + &:hover, + &:focus { + cps-icon { + opacity: 1; + } + } + &:focus-visible { + @include focus-ring(-0.125rem, -0.25rem, 50%); + } cps-icon { opacity: 0; transition-duration: $hover-transition-duration; - &:hover { - opacity: 1 !important; - } } } .cps-treeselect-box-chevron { display: flex; - margin-left: 8px; + padding: 0.25rem; transition-duration: $hover-transition-duration; + cursor: pointer; + cps-icon { + transition: transform $hover-transition-duration; + } + &:focus { + outline: none; + } + &:focus-visible { + @include focus-ring(-0.125rem, -0.25rem, 0.375rem); + } } } } @@ -224,9 +258,18 @@ $hover-transition-duration: 0.2s; .cps-treeselect-options { background: white; overflow-x: hidden; - max-height: 242px; + max-height: 15.125rem; overflow-y: auto; + // Default: a focused+selected node looks the same as a plain selected node + // (mouse click focuses the node too) - overridden below only while the + // focus came from real keyboard arrow navigation. + --focused-selected-option-background: #{$selected-option-background}; + + &.arrow-navigating { + --focused-selected-option-background: #{$option-highlight-selected-background}; + } + ::ng-deep { .p-tree { background: #ffffff; @@ -237,7 +280,7 @@ $hover-transition-duration: 0.2s; } .cps-treeselect-option { - margin-right: 8px; + margin-right: 0.5rem; display: flex; align-items: center; justify-content: space-between; @@ -245,14 +288,14 @@ $hover-transition-duration: 0.2s; &-left { display: flex; align-items: center; - margin-right: 8px; + margin-right: 0.5rem; } &-check { background-color: transparent; border: 0; - width: 16px; - height: 16px; + width: 1rem; + height: 1rem; cursor: pointer; display: inline-block; vertical-align: middle; @@ -262,27 +305,27 @@ $hover-transition-duration: 0.2s; transition: border-color 90ms cubic-bezier(0, 0, 0.2, 0.1), background-color 90ms cubic-bezier(0, 0, 0.2, 0.1); - margin-right: 8px; + margin-right: 0.5rem; opacity: 0; &::after { color: $color-calm; - top: 4px; - left: 1px; - width: 8px; - height: 3px; - border-left: 2px solid currentColor; + top: 0.25rem; + left: 0.0625rem; + width: 0.5rem; + height: 0.1875rem; + border-left: 0.125rem solid currentColor; transform: rotate(-45deg); opacity: 1; box-sizing: content-box; position: absolute; content: ''; - border-bottom: 2px solid currentColor; + border-bottom: 0.125rem solid currentColor; transition: opacity 90ms cubic-bezier(0, 0, 0.2, 0.1); } } &-info { - margin-left: 6px; + margin-left: 0.375rem; color: $treeselect-option-info-color; } @@ -293,7 +336,7 @@ $hover-transition-duration: 0.2s; .p-component { font-family: 'Source Sans Pro', sans-serif; - font-size: 14px; + font-size: 0.875rem; font-weight: normal; } @@ -314,6 +357,13 @@ $hover-transition-duration: 0.2s; min-width: fit-content; } + .p-tree-node { + &:focus { + outline: none; + box-shadow: none; + } + } + .p-tree .p-tree-root-children .p-tree-node .p-tree-node-content { border-radius: 0; transition: box-shadow 0.2s; @@ -344,6 +394,11 @@ $hover-transition-duration: 0.2s; background-color 0.2s, color 0.2s, box-shadow 0.2s; + + .p-tree-node-toggle-icon { + width: 0.875rem; + height: 0.875rem; + } } .p-tree @@ -383,7 +438,7 @@ $hover-transition-duration: 0.2s; .p-tree-root-children .p-tree-node:focus > .p-tree-node-content.p-tree-node-selected { - background: $option-highlight-selected-background; + background: var(--focused-selected-option-background); } .p-tree-node-toggle-button { @@ -401,6 +456,11 @@ $hover-transition-duration: 0.2s; :hover { color: $color-calm; } + &:focus, + &:focus-visible { + outline: none; + box-shadow: none; + } } .p-tree @@ -431,13 +491,19 @@ $hover-transition-duration: 0.2s; .cps-tree-node-fully-expandable { cursor: pointer; - -webkit-user-select: none; /* Safari */ - -ms-user-select: none; /* IE 10 and IE 11 */ - user-select: none; /* Standard syntax */ + user-select: none; } .cps-treeselect-directory-elem { font-weight: bold; - font-size: 16px; + font-size: 1rem; + } + + [role='treeitem']:focus-visible, + [data-pc-section='nodetogglebutton']:focus-visible { + &::before, + &::after { + display: none; + } } } } 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 540d6b978..3521c7876 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 @@ -1,13 +1,4 @@ -import { CommonModule } from '@angular/common'; -import { - AfterViewInit, - ChangeDetectorRef, - Component, - Input, - OnDestroy, - OnInit, - Optional -} from '@angular/core'; +import { ChangeDetectorRef, Component, Input, Optional } from '@angular/core'; import { FormsModule, NgControl } from '@angular/forms'; import { CpsIconComponent } from '../cps-icon/cps-icon.component'; import { CpsChipComponent } from '../cps-chip/cps-chip.component'; @@ -33,7 +24,6 @@ export type CpsTreeSelectAppearanceType = */ @Component({ imports: [ - CommonModule, FormsModule, TreeModule, CpsIconComponent, @@ -48,10 +38,7 @@ export type CpsTreeSelectAppearanceType = templateUrl: './cps-tree-select.component.html', styleUrls: ['./cps-tree-select.component.scss'] }) -export class CpsTreeSelectComponent - extends CpsBaseTreeDropdownComponent - implements OnInit, AfterViewInit, OnDestroy -{ +export class CpsTreeSelectComponent extends CpsBaseTreeDropdownComponent { /** * Styling appearance of tree select, it can be "outlined", "underlined" or "borderless". * @group Props @@ -71,36 +58,99 @@ export class CpsTreeSelectComponent super(control, cdRef); } - override ngOnInit() { - super.ngOnInit(); - } - - override ngAfterViewInit() { - super.ngAfterViewInit(); - } - - override ngOnDestroy() { - super.ngOnDestroy(); - } - onBeforeOptionsHidden() { this.toggleOptions(false); } - onBoxClick() { + onBoxClick(event?: Event) { + event?.stopPropagation(); this.toggleOptions(); } - onKeyDown(event: any) { + onKeyDown(event: KeyboardEvent) { + const code = event.code; + // Tab — close dropdown if open, let browser move focus naturally + if (code === 'Tab') { + if (this.isOpened) this.toggleOptions(false); + return; + } + event.preventDefault(); - const code = event.keyCode; - // escape - if (code === 27) { + + // Escape — close and return focus to trigger + if (code === 'Escape') { this.toggleOptions(false); + this.componentContainer?.nativeElement?.focus(); } - // click down arrow - else if (code === 40) { - this.initArrowsNavigaton(); + // Enter or Space — toggle dropdown + else if (code === 'Enter' || code === 'NumpadEnter' || code === 'Space') { + this.toggleOptions(!this.isOpened); + } + // Arrow Up or Down — open if closed and focus the selected node; + // if already open, move one step away from the selected node + else if (code === 'ArrowUp' || code === 'ArrowDown') { + const up = code === 'ArrowUp'; + if (!this.isOpened) { + this.toggleOptions(true); + setTimeout(() => this.initArrowsNavigaton(up)); + } else { + this.navigateIntoOptions(up); + } + } + } + + onOptionsKeyDown(event: KeyboardEvent) { + switch (event.code) { + case 'Escape': + event.preventDefault(); + this.toggleOptions(false); + this.componentContainer?.nativeElement?.focus(); + break; + + case 'Tab': + event.preventDefault(); + this.toggleOptions(false); + this.componentContainer?.nativeElement?.focus(); + break; + + case 'ArrowUp': + case 'ArrowDown': { + this.isArrowNavigating = true; + + // PrimeNG's handler already ran (bubble phase) and moved focus synchronously. + // If focus is still on the original element, PrimeNG hit a boundary — wrap around. + const target = event.target as HTMLElement; + if (this._document.activeElement === target) { + event.preventDefault(); + if (event.code === 'ArrowUp') { + this._focusTreeNode(this._getLastVisibleTreeNodeLi()); + } else { + this._focusTreeNode( + this.treeContainerElement?.querySelector( + '[role="treeitem"]' + ) ?? null + ); + } + } + break; + } + + case 'Enter': + case 'Space': + case 'NumpadEnter': { + // Mouse click on a fully-expandable directory row both selects and + // toggles expand (PrimeNG's onNodeClick + our container click listener). + // PrimeNG's own Enter/Space handling only replicates the selection half, + // so trigger the expand toggle here to match. + const target = event.target as HTMLElement; + if (target?.classList?.contains('cps-tree-node-fully-expandable')) { + const contentElem = target.querySelector( + '.p-tree-node-content' + ); + if (contentElem) this.onClickFullyExpandable(contentElem); + } + break; + } } } } 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 8400fbb4a..79a00f1eb 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 @@ -2,8 +2,10 @@ import { AfterViewInit, ChangeDetectorRef, Component, + computed, ElementRef, EventEmitter, + inject, Input, OnChanges, OnDestroy, @@ -14,8 +16,13 @@ import { SimpleChanges, ViewChild } from '@angular/core'; -import { ControlValueAccessor, NgControl } from '@angular/forms'; +import { DOCUMENT } from '@angular/common'; +import { ControlValueAccessor, NgControl, Validators } from '@angular/forms'; import { TreeNode } from 'primeng/api'; +import { + generateUniqueId, + logMissingAriaLabelError +} from '../../../utils/internal/accessibility-utils'; import { Subscription } from 'rxjs'; import { Tree } from 'primeng/tree'; import { isEqual } from 'lodash-es'; @@ -23,6 +30,10 @@ import { IconType, iconSizeType } from '../../cps-icon/cps-icon.component'; import { convertSize } from '../../../utils/internal/size-utils'; import { CpsTooltipPosition } from '../../../directives/cps-tooltip/cps-tooltip.directive'; import { CpsMenuComponent } from '../../cps-menu/cps-menu.component'; +import { CPS_ROOT_FONT_SIZE_SERVICE } from '../../../services/cps-root-font-size/cps-root-font-size.service'; + +const VIRTUAL_SCROLL_ITEM_SIZE_REM = 2.5; +const VIRTUAL_SCROLL_MAX_VISIBLE_ITEMS = 6; /** * BaseTreeDropdownComponent is an internal base component to support hierarchical data dropdown. @@ -41,6 +52,12 @@ export class CpsBaseTreeDropdownComponent */ @Input() label = ''; + /** + * Aria label for accessibility, takes precedence over label. + * @group Props + */ + @Input() ariaLabel = ''; + /** * Bottom hint text. * @group Props @@ -123,7 +140,7 @@ export class CpsBaseTreeDropdownComponent * Size of icon before input value, of type number, string, 'fill', 'xsmall', 'small', 'normal' or 'large'. * @group Props */ - @Input() prefixIconSize: iconSizeType = '18px'; + @Input() prefixIconSize: iconSizeType = '1.125rem'; /** * When enabled, a loading bar is displayed. @@ -248,21 +265,57 @@ export class CpsBaseTreeDropdownComponent innerOptions: TreeNode[] = []; optionsMap = new Map(); + + private _treeRefreshKey = 0; + + readonly treeTrackBy = ( + _index: number, + item: TreeNode & { node?: TreeNode } + ) => { + if (this.virtualScroll) return item?.node?.key ?? item?.key ?? _index; + return `${item?.key}-${this._treeRefreshKey}`; + }; + originalOptionsMap = new Map(); - virtualListHeight = 240; - virtualScrollItemSize = 40; + private readonly _cpsRootFontSizeService = inject(CPS_ROOT_FONT_SIZE_SERVICE); + protected readonly _document = inject(DOCUMENT); + + readonly virtualScrollItemSizePx = computed( + () => + (this._cpsRootFontSizeService?.fontSize() || 16) * + VIRTUAL_SCROLL_ITEM_SIZE_REM + ); + + virtualListHeightRem = + VIRTUAL_SCROLL_ITEM_SIZE_REM * VIRTUAL_SCROLL_MAX_VISIBLE_ITEMS; + + hintId = ''; + errorId = ''; + optionsTreeId = ''; error = ''; cvtWidth = ''; isOpened = false; optionFocused = false; isAutocomplete = false; + isArrowNavigating = false; + + get isRequired(): boolean { + return this.control?.control?.hasValidator(Validators.required) ?? false; + } + + get describedBy(): string | null { + if (this.hideDetails) return null; + if (this.error) return this.errorId; + if (this.hint) return this.hintId; + return null; + } treeContainerElement!: HTMLElement; treeSelection: any; - boxWidth = 0; + boxWidthPx = 0; resizeObserver: ResizeObserver; constructor( @@ -274,12 +327,18 @@ export class CpsBaseTreeDropdownComponent } this.resizeObserver = new ResizeObserver((entries) => { entries.forEach((entry) => { - if (entry?.target) this.boxWidth = (entry.target as any).offsetWidth; + if (entry?.target) this.boxWidthPx = (entry.target as any).offsetWidth; }); }); } ngOnInit() { + const prefix = this.isAutocomplete + ? 'cps-treeautocomplete' + : 'cps-treeselect'; + this.hintId = generateUniqueId(`${prefix}-hint`); + this.errorId = generateUniqueId(`${prefix}-error`); + this.optionsTreeId = generateUniqueId(`${prefix}-options`); this.cvtWidth = convertSize(this.width); if (!this._value) { if (this.multiple) { @@ -295,12 +354,32 @@ export class CpsBaseTreeDropdownComponent this._checkErrors(); } ); + + logMissingAriaLabelError( + this.isAutocomplete + ? 'CpsTreeAutocompleteComponent' + : 'CpsTreeSelectComponent', + this.label, + this.ariaLabel + ); } ngOnChanges(changes: SimpleChanges) { + if (changes.width) { + this.cvtWidth = convertSize(this.width); + } if (changes.options) { this.innerOptions = this._toInnerOptions(this.options); } + if (changes.label || changes.ariaLabel) { + logMissingAriaLabelError( + this.isAutocomplete + ? 'CpsTreeAutocompleteComponent' + : 'CpsTreeSelectComponent', + this.label, + this.ariaLabel + ); + } } ngAfterViewInit() { @@ -398,6 +477,7 @@ export class CpsBaseTreeDropdownComponent onSelectNode() { if (!this.multiple) { this.toggleOptions(false); + this.componentContainer?.nativeElement?.focus(); } } @@ -411,8 +491,10 @@ export class CpsBaseTreeDropdownComponent treeNode.expanded = !treeNode.expanded; this.updateOptions(); + this._treeRefreshKey++; + this.treeList?.cd?.markForCheck(); setTimeout(() => { - this._nodeToggled(elem); + this._nodeToggled(elem, key); }); } @@ -423,11 +505,13 @@ export class CpsBaseTreeDropdownComponent recalcVirtualListHeight() { if (!this.virtualScroll) return; const currentLen = this.treeList?.serializedValue?.length || 0; - this.virtualListHeight = Math.min( - this.virtualScrollItemSize * currentLen, - 240 - ); - this._setTreeListHeight(this.virtualListHeight + 'px'); + this.virtualListHeightRem = + VIRTUAL_SCROLL_ITEM_SIZE_REM * + Math.min(currentLen, VIRTUAL_SCROLL_MAX_VISIBLE_ITEMS); + this._setTreeListHeight(this.virtualListHeightRem + 'rem'); + + this.treeList?.scroller?.calculateOptions(); + this.treeList?.scroller?.cd?.detectChanges(); } toggleOptions(show?: boolean): void { @@ -450,15 +534,19 @@ export class CpsBaseTreeDropdownComponent this.isOpened = this.optionsMenu.isVisible(); this.optionFocused = false; - if (this.isOpened && this.treeSelection) { - this._expandToNodes( - this.multiple ? this.treeSelection : [this.treeSelection] - ); - this._setTreeListHeight(''); - this.updateOptions(); + this.isArrowNavigating = false; + if (this.isOpened) { + if (this.treeSelection) { + this._expandToNodes( + this.multiple ? this.treeSelection : [this.treeSelection] + ); + this._treeRefreshKey++; + this.treeList?.cd?.markForCheck(); + } setTimeout(() => { + this.updateOptions(); this.recalcVirtualListHeight(); - const selected = this.treeContainerElement.querySelector( + const selected = this.treeContainerElement?.querySelector( '.p-highlight' ) as any; if (selected) { @@ -494,28 +582,89 @@ export class CpsBaseTreeDropdownComponent this.updateValue(this.treeSelectionToValue(this.treeSelection)); } - initArrowsNavigaton() { + initArrowsNavigaton(up = false) { if (!this.isOpened) return; if (!this.optionFocused) { - const firstElem = - this.treeContainerElement?.querySelector('.p-tree-node'); - - if (firstElem) (firstElem as HTMLElement).focus(); + const elemToFocus = + this.treeContainerElement?.querySelector( + '[role="treeitem"][aria-selected="true"]' + ) ?? + (up + ? this._getLastVisibleTreeNodeLi() + : this.treeContainerElement?.querySelector('.p-tree-node')); + + this._focusTreeNode(elemToFocus as HTMLElement | null); this.optionFocused = true; + this.isArrowNavigating = true; + } + } + + protected _focusTreeNode(elem: HTMLElement | null) { + if (!elem) return; + this.treeContainerElement + ?.querySelectorAll('[role="treeitem"]') + .forEach((li) => { + li.tabIndex = -1; + }); + elem.tabIndex = 0; + elem.focus(); + } + + navigateIntoOptions(up: boolean) { + if (!this.isOpened || this.optionFocused) return; + const hasSelected = !!this.treeContainerElement?.querySelector( + '[role="treeitem"][aria-selected="true"]' + ); + + this.initArrowsNavigaton(up); + + if (!hasSelected) return; + + const active = this._document.activeElement as HTMLElement | null; + if (!active || !this.treeContainerElement?.contains(active)) return; + + const items = Array.from( + this.treeContainerElement.querySelectorAll( + '[role="treeitem"]' + ) + ); + const next = items[items.indexOf(active) + (up ? -1 : 1)]; + if (next) this._focusTreeNode(next); + } + + private _findLastVisibleDescendantLi(pTreeNode: Element): HTMLElement | null { + const li = Array.from(pTreeNode.children).find( + (el) => el.getAttribute('data-pc-section') === 'node' + ) as HTMLElement | undefined; + const childrenUl = li?.children[1]; + if (childrenUl && childrenUl.children.length > 0) { + return this._findLastVisibleDescendantLi( + childrenUl.children[childrenUl.children.length - 1] + ); } + return li ?? null; + } + + protected _getLastVisibleTreeNodeLi(): HTMLElement | null { + const lastPTreeNode = this.treeContainerElement?.lastElementChild; + return lastPTreeNode + ? this._findLastVisibleDescendantLi(lastPTreeNode) + : null; } onNodeExpand(event: any) { this._nodeToggledWithChevron( event?.originalEvent?.currentTarget?.parentElement ); + if (this.virtualScroll) this._refocusVirtualNode(event?.node?.key); } onNodeCollapse(event: any) { this._nodeToggledWithChevron( event?.originalEvent?.currentTarget?.parentElement ); + if (this.virtualScroll) this._refocusVirtualNode(event?.node?.key); } treeSelectionToValue(selection: any) { @@ -561,10 +710,11 @@ export class CpsBaseTreeDropdownComponent } this.optionFocused = true; + this.isArrowNavigating = false; - const elem = event.target.classList.contains('p-treenode-content') + const elem = event.target.classList.contains('p-tree-node-content') ? event.target - : getParentWithClass(event.target, 'p-treenode-content'); + : getParentWithClass(event.target, 'p-tree-node-content'); if ( elem?.parentElement?.classList?.contains('cps-tree-node-fully-expandable') @@ -588,13 +738,30 @@ export class CpsBaseTreeDropdownComponent this.treeList.scroller.style.height = height; } - private _nodeToggled(elem: HTMLElement) { + private _nodeToggled(elem: HTMLElement, key?: string) { this.recalcVirtualListHeight(); setTimeout(() => { this.optionsMenu.align(); }); - if (elem?.parentElement) (elem?.parentElement as HTMLElement).focus(); + if (key) { + this._focusTreeNode( + this.treeContainerElement?.querySelector(`.key-${key}`) ?? + null + ); + } else if (elem?.parentElement) { + this._focusTreeNode(elem.parentElement as HTMLElement); + } + } + + private _refocusVirtualNode(key: string | undefined) { + if (!key) return; + setTimeout(() => { + this._focusTreeNode( + this.treeContainerElement?.querySelector(`.key-${key}`) ?? + null + ); + }); } private _nodeToggledWithChevron(elem: HTMLElement) { @@ -666,10 +833,10 @@ export class CpsBaseTreeDropdownComponent inner.type = 'directory'; inner.selectable = false; inner.styleClass += ' cps-tree-node-fully-expandable'; - if (this.initialExpandDirectories) inner.expanded = true; + inner.expanded = this.initialExpandDirectories; } if (o.children) { - if (this.initialExpandAll) inner.expanded = true; + inner.expanded = this.initialExpandAll || !!inner.expanded; inner.children = o.children.map((c: any, index: number) => { return mapOption( From 5d2c389e957a1d795f55c6b731abd6e3122a4f61 Mon Sep 17 00:00:00 2001 From: Andrei Fateev Date: Wed, 17 Jun 2026 14:05:18 +0200 Subject: [PATCH 02/13] update tree autocomplete page --- .../tree-autocomplete-page.component.html | 4 ++-- .../tree-autocomplete-page.component.scss | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) 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 3f9316679..fe9977e6a 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 @@ -77,9 +77,9 @@
Date: Wed, 17 Jun 2026 14:08:11 +0200 Subject: [PATCH 03/13] more to prev --- .../cps-base-tree-dropdown.component.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 79a00f1eb..d2c21df96 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 @@ -13,8 +13,8 @@ import { Optional, Output, Self, - SimpleChanges, - ViewChild + ViewChild, + type SimpleChanges } from '@angular/core'; import { DOCUMENT } from '@angular/common'; import { ControlValueAccessor, NgControl, Validators } from '@angular/forms'; From 8a304b470e9ca2f4937be243c5fa85bdaa1d36a2 Mon Sep 17 00:00:00 2001 From: Andrei Fateev Date: Thu, 18 Jun 2026 02:03:09 +0200 Subject: [PATCH 04/13] fix tree autocomplete --- .vscode/settings.json | 2 +- .../cps-tree-autocomplete.component.html | 106 ++++++++--- .../cps-tree-autocomplete.component.scss | 165 +++++++++++------- .../cps-tree-autocomplete.component.ts | 80 +++++++-- .../cps-tree-select.component.scss | 8 +- .../cps-tree-select.component.ts | 66 +------ .../cps-base-tree-dropdown.component.ts | 64 +++++++ 7 files changed, 319 insertions(+), 172 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 2c1485213..bcb54171e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -26,7 +26,7 @@ "files.associations": { "**/src/**/*.spec.ts": "typescriptreact" }, - "git.enableCommitSigning": true, + "git.enableCommitSigning": false, "editor.codeLens": true, "editor.formatOnSave": true, "editor.codeActionsOnSave": { 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 14ac9556d..954edba6a 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,10 +1,11 @@
@if (label) {
@@ -26,6 +27,7 @@ (keydown)="onContainerKeyDown($event)" class="cps-treeautocomplete-container" [class.focused]="isActive()" + [class.keyboard-focused]="isKeyboardFocused" [class.persistent-clear]="persistentClear" [class.borderless]="appearance === 'borderless'" [class.underlined]="appearance === 'underlined'"> @@ -63,27 +65,35 @@ } @if (multiple && !chips) { -
+
@for (val of treeSelection; track val; let last = $last) {
{{ val.label }}{{ !last ? ',' : '' }}
} - + + +
} @if (multiple && chips) { -
+
@for (val of treeSelection; track val; let last = $last) { } - + + +
}
@@ -106,6 +118,17 @@ #treeAutocompleteInput class="cps-treeautocomplete-box-input" spellcheck="false" + role="combobox" + aria-haspopup="tree" + aria-autocomplete="list" + [attr.aria-expanded]="isOpened" + [attr.aria-controls]="optionsTreeId" + [attr.aria-required]="isRequired || null" + [attr.aria-invalid]="error ? 'true' : null" + [attr.aria-label]="ariaLabel || label || null" + [attr.aria-describedby]="describedBy" + [attr.aria-disabled]="disabled || null" + [attr.aria-busy]="loading ? true : null" [placeholder]=" (!treeSelection && !multiple) || (treeSelection?.length < 1 && multiple) @@ -121,6 +144,13 @@ @if (clearable && !disabled) { - + } @if (showChevron) { + role="button" + [attr.aria-label]=" + isOpened ? 'Collapse options' : 'Expand options' + " + [tabindex]="disabled ? -1 : 0" + (mousedown)="onChevronClick($event)" + (keydown.enter)="onChevronClick($event)" + (keydown.space)="onChevronClick($event)"> -
+
@if (!error && !hideDetails) { -
+
{{ hint }}
} @if (error && !hideDetails) { -
+
{{ error }}
} @@ -230,6 +275,17 @@ #treeAutocompleteInput class="cps-treeautocomplete-box-input" spellcheck="false" + role="combobox" + aria-haspopup="tree" + aria-autocomplete="list" + [attr.aria-expanded]="isOpened" + [attr.aria-controls]="optionsTreeId" + [attr.aria-required]="isRequired || null" + [attr.aria-invalid]="error ? 'true' : null" + [attr.aria-label]="ariaLabel || label || null" + [attr.aria-describedby]="describedBy" + [attr.aria-disabled]="disabled || null" + [attr.aria-busy]="loading ? true : null" [class]="inputClass" [style]="inputStyle" [placeholder]=" diff --git a/projects/cps-ui-kit/src/lib/components/cps-tree-autocomplete/cps-tree-autocomplete.component.scss b/projects/cps-ui-kit/src/lib/components/cps-tree-autocomplete/cps-tree-autocomplete.component.scss index ed5c38264..403d200d5 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-tree-autocomplete/cps-tree-autocomplete.component.scss +++ b/projects/cps-ui-kit/src/lib/components/cps-tree-autocomplete/cps-tree-autocomplete.component.scss @@ -1,3 +1,5 @@ +@use '../../../../styles/mixins' as *; + $color-calm: var(--cps-color-calm); $color-error: var(--cps-state-error); $error-background: #fef3f2; @@ -36,8 +38,8 @@ $hover-transition-duration: 0.2s; position: relative; .cps-treeautocomplete-progress-bar { position: absolute; - bottom: 1px; - padding: 0 1px; + bottom: 0.0625rem; + padding: 0 0.0625rem; } &.focused { @@ -46,6 +48,14 @@ $hover-transition-duration: 0.2s; } } + &.keyboard-focused { + @include focus-ring(0, -0.0625rem, 0.25rem); + &::before, + &::after { + pointer-events: none; + } + } + &.borderless, &.underlined { .cps-treeautocomplete-box { @@ -56,22 +66,28 @@ $hover-transition-duration: 0.2s; } &.underlined { .cps-treeautocomplete-box { - border-bottom: 1px solid $treeautocomplete-border-color !important; + border-bottom: 0.0625rem solid $treeautocomplete-border-color !important; } } } &.active { .cps-treeautocomplete-box { - border: 1px solid $color-calm; + border: 0.0625rem solid $color-calm; .cps-treeautocomplete-box-area { .prefix-icon { color: $color-calm; } } + } + } + + &.opened { + .cps-treeautocomplete-box { .cps-treeautocomplete-box-chevron { - top: 22px; - transform: rotate(180deg); + cps-icon { + transform: rotate(180deg); + } } } } @@ -84,7 +100,7 @@ $hover-transition-duration: 0.2s; font-size: 0.875rem; font-weight: 600; .cps-treeautocomplete-label-info-circle { - margin-left: 8px; + margin-left: 0.5rem; pointer-events: all; } } @@ -94,7 +110,7 @@ $hover-transition-duration: 0.2s; .cps-treeautocomplete-container:hover { .cps-treeautocomplete-box { .cps-treeautocomplete-box-icons { - .cps-treeautocomplete-box-clear-icon { + .cps-treeautocomplete-box-clear-icon:not(:focus):not(:hover) { cps-icon { opacity: 0.5; } @@ -105,20 +121,20 @@ $hover-transition-duration: 0.2s; .cps-treeautocomplete-box { overflow: hidden; - min-height: 38px; + min-height: 2.375rem; width: 100%; cursor: text; background: white; font-size: 1rem; outline: none; - padding: 0 12px 0 12px; - border-radius: 4px; - border: 1px solid $treeautocomplete-border-color; + padding: 0 0.75rem 0 0.75rem; + border-radius: 0.25rem; + border: 0.0625rem solid $treeautocomplete-border-color; transition-duration: $hover-transition-duration; &-area { display: flex; - min-height: 36px; + min-height: 2.25rem; align-items: center; .prefix-icon { margin-right: 0.5rem; @@ -127,11 +143,11 @@ $hover-transition-duration: 0.2s; } &-input { - min-height: 36px; + min-height: 2.25rem; padding: 0; background-color: transparent; width: 0; - min-width: 30px; + min-width: 1.875rem; flex-grow: 1; font-size: 1rem; color: $treeautocomplete-option-value-color; @@ -149,9 +165,9 @@ $hover-transition-duration: 0.2s; display: inline-flex; flex-direction: column; width: 100%; - padding-top: 3px; - padding-bottom: 3px; - min-height: 36px; + padding-top: 0.1875rem; + padding-bottom: 0.1875rem; + min-height: 2.25rem; justify-content: center; position: relative; @@ -180,10 +196,10 @@ $hover-transition-duration: 0.2s; } .multi-chip-input { - min-height: 30px; + min-height: 1.875rem; } .multi-item-input { - min-height: 28px; + min-height: 1.75rem; } .chips-group { @@ -191,9 +207,9 @@ $hover-transition-duration: 0.2s; flex-wrap: wrap; align-items: center; cps-chip { - padding-bottom: 3px; - padding-top: 3px; - padding-right: 4px; + padding-bottom: 0.1875rem; + padding-top: 0.1875rem; + padding-right: 0.25rem; } } .text-group { @@ -202,15 +218,15 @@ $hover-transition-duration: 0.2s; display: inline-flex; flex-wrap: wrap; .text-group-item { - padding-bottom: 3px; - padding-top: 3px; - padding-right: 4px; + padding-bottom: 0.1875rem; + padding-top: 0.1875rem; + padding-right: 0.25rem; } } } &:hover { - border: 1px solid $color-calm; + border: 0.0625rem solid $color-calm; .cps-treeautocomplete-box-area { .prefix-icon { color: $color-calm; @@ -222,29 +238,42 @@ $hover-transition-duration: 0.2s; display: flex; .cps-treeautocomplete-box-clear-icon { - cursor: pointer; display: flex; + padding: 0.25rem; color: $color-error; - margin-left: 8px; + margin-left: 0.25rem; + cursor: pointer; + &:focus { + outline: none; + } + &:hover, + &:focus { + cps-icon { + opacity: 1; + } + } + &:focus-visible { + @include focus-ring(-0.125rem, -0.25rem, 50%); + } cps-icon { opacity: 0; transition-duration: $hover-transition-duration; - &:hover { - opacity: 1 !important; - } } } .cps-treeautocomplete-box-chevron { display: flex; - margin-left: 8px; + padding: 0.25rem; + margin-left: 0.25rem; transition-duration: $hover-transition-duration; cursor: pointer; - &:hover { - ::ng-deep cps-icon { - .cps-icon { - color: $color-calm !important; - } - } + cps-icon { + transition: transform $hover-transition-duration; + } + &:focus { + outline: none; + } + &:focus-visible { + @include focus-ring(-0.125rem, -0.25rem, 0.375rem); } } } @@ -303,9 +332,15 @@ $hover-transition-duration: 0.2s; .cps-treeautocomplete-options { background: white; overflow-x: hidden; - max-height: 242px; + max-height: 15.125rem; overflow-y: auto; + --focused-selected-option-background: #{$selected-option-background}; + + &.arrow-navigating { + --focused-selected-option-background: #{$option-highlight-selected-background}; + } + ::ng-deep { .p-tree { background: #ffffff; @@ -316,7 +351,7 @@ $hover-transition-duration: 0.2s; } .cps-treeautocomplete-option { - margin-right: 8px; + margin-right: 0.5rem; display: flex; align-items: center; justify-content: space-between; @@ -324,14 +359,14 @@ $hover-transition-duration: 0.2s; &-left { display: flex; align-items: center; - margin-right: 8px; + margin-right: 0.5rem; } &-check { background-color: transparent; border: 0; - width: 16px; - height: 16px; + width: 1rem; + height: 1rem; cursor: pointer; display: inline-block; vertical-align: middle; @@ -341,27 +376,27 @@ $hover-transition-duration: 0.2s; transition: border-color 90ms cubic-bezier(0, 0, 0.2, 0.1), background-color 90ms cubic-bezier(0, 0, 0.2, 0.1); - margin-right: 8px; + margin-right: 0.5rem; opacity: 0; &::after { color: $color-calm; - top: 4px; - left: 1px; - width: 8px; - height: 3px; - border-left: 2px solid currentColor; + top: 0.25rem; + left: 0.0625rem; + width: 0.5rem; + height: 0.1875rem; + border-left: 0.125rem solid currentColor; transform: rotate(-45deg); opacity: 1; box-sizing: content-box; position: absolute; content: ''; - border-bottom: 2px solid currentColor; + border-bottom: 0.125rem solid currentColor; transition: opacity 90ms cubic-bezier(0, 0, 0.2, 0.1); } } &-info { - margin-left: 6px; + margin-left: 0.375rem; color: $treeautocomplete-option-info-color; } @@ -372,7 +407,7 @@ $hover-transition-duration: 0.2s; .p-component { font-family: 'Source Sans Pro', sans-serif; - font-size: 14px; + font-size: 0.875rem; font-weight: normal; } @@ -467,7 +502,7 @@ $hover-transition-duration: 0.2s; .p-tree-root-children .p-tree-node:focus > .p-tree-node-content.p-tree-node-selected { - background: $option-highlight-selected-background; + background: var(--focused-selected-option-background); } .p-tree-node-toggle-button { @@ -485,6 +520,11 @@ $hover-transition-duration: 0.2s; :hover { color: $color-calm; } + &:focus, + &:focus-visible { + outline: none; + box-shadow: none; + } } .p-tree @@ -514,20 +554,25 @@ $hover-transition-duration: 0.2s; } .p-tree-empty-message { - padding: 11px; - font-size: 16px; + padding: 0.6875rem; + font-size: 1rem; cursor: default; } .cps-tree-node-fully-expandable { cursor: pointer; - -webkit-user-select: none; /* Safari */ - -ms-user-select: none; /* IE 10 and IE 11 */ - user-select: none; /* Standard syntax */ + user-select: none; } .cps-treeautocomplete-directory-elem { font-weight: bold; - font-size: 16px; + font-size: 1rem; + } + + *:focus-visible { + &::before, + &::after { + display: none !important; + } } } } diff --git a/projects/cps-ui-kit/src/lib/components/cps-tree-autocomplete/cps-tree-autocomplete.component.ts b/projects/cps-ui-kit/src/lib/components/cps-tree-autocomplete/cps-tree-autocomplete.component.ts index 6bbd4d03c..4657ffe4e 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-tree-autocomplete/cps-tree-autocomplete.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-tree-autocomplete/cps-tree-autocomplete.component.ts @@ -74,6 +74,9 @@ export class CpsTreeAutocompleteComponent extends CpsBaseTreeDropdownComponent { inputText = ''; backspaceClickedOnce = false; activeSingle = false; + isKeyboardFocused = false; + + private _mouseClicked = false; constructor( @Optional() public override control: NgControl, @@ -87,9 +90,25 @@ export class CpsTreeAutocompleteComponent extends CpsBaseTreeDropdownComponent { this.backspaceClickedOnce = false; this._clearInput(); super.onSelectNode(); + if (!this.multiple) { + setTimeout(() => { + this.focusInput(); + }, 0); + } + } + + override onFocus() { + if (!this.multiple) { + this.activeSingle = true; + if (!this.inputText) this.inputText = this._getValueLabel(); + } + this.isKeyboardFocused = !this._mouseClicked; + this._mouseClicked = false; + super.onFocus(); } override onBlur() { + this.isKeyboardFocused = false; if (!this.isOpened) { this._closeAndClear(); } @@ -105,6 +124,7 @@ export class CpsTreeAutocompleteComponent extends CpsBaseTreeDropdownComponent { } onBoxClick() { + this._mouseClicked = true; if (!this.multiple) { this.activeSingle = true; if (!this.inputText) this.inputText = this._getValueLabel(); @@ -114,29 +134,55 @@ export class CpsTreeAutocompleteComponent extends CpsBaseTreeDropdownComponent { this.optionFocused = false; } - onContainerKeyDown(event: any) { - const code = event.keyCode; - // escape - if (code === 27) { + onContainerKeyDown(event: KeyboardEvent) { + const code = event.code; + if (code === 'Tab') { + if (this.isOpened) this._closeAndClear(); + } else if (code === 'Escape') { this._closeAndClear(); + } else if (code === 'ArrowDown' || code === 'ArrowUp') { + event.preventDefault(); + this.isKeyboardFocused = true; + const up = code === 'ArrowUp'; + if (!this.isOpened) { + this.toggleOptions(true); + setTimeout(() => { + const current = this.treeList?.el?.nativeElement?.querySelector( + '.p-tree-root-children' + ) as HTMLElement | null; + if (current) this.treeContainerElement = current; + this.initArrowsNavigaton(up); + }); + } else { + const current = this.treeList?.el?.nativeElement?.querySelector( + '.p-tree-root-children' + ) as HTMLElement | null; + if (current) this.treeContainerElement = current; + this.optionFocused = false; + this.navigateIntoOptions(up); + } } - // click down arrow - else if (code === 40) { - this.initArrowsNavigaton(); - } } - onInputKeyDown(event: any) { - const code = event.keyCode; - // backspace - if (code === 8) { + protected override _onOptionsClose(): void { + this._closeAndClear(); + this.focusInput(); + } + + onInputKeyDown(event: KeyboardEvent) { + const code = event.code; + if (code === 'Backspace') { this._removeLastValue(); event.stopPropagation(); - } - // enter - else if (code === 13) { + } else if (code === 'Enter' || code === 'NumpadEnter') { + if (!this.isOpened) { + event.stopPropagation(); + event.preventDefault(); + this.toggleOptions(true); + return; + } if (!this.optionFocused) { - this._confirmInput(event?.target?.value || ''); + this._confirmInput((event.target as HTMLInputElement)?.value || ''); event.stopPropagation(); } } @@ -195,6 +241,7 @@ export class CpsTreeAutocompleteComponent extends CpsBaseTreeDropdownComponent { this.toggleOptions(true); } this.backspaceClickedOnce = false; + this.optionFocused = false; const searchVal = (event?.target?.value || '').toLowerCase(); if (!searchVal) this.treeList.resetFilter(); @@ -233,6 +280,7 @@ export class CpsTreeAutocompleteComponent extends CpsBaseTreeDropdownComponent { private _clearInput() { this.treeList.resetFilter(); + this.treeList?.cd?.markForCheck(); this.inputText = ''; this.activeSingle = false; this.updateOptions(); diff --git a/projects/cps-ui-kit/src/lib/components/cps-tree-select/cps-tree-select.component.scss b/projects/cps-ui-kit/src/lib/components/cps-tree-select/cps-tree-select.component.scss index 4cba14718..ef2ee6b6b 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-tree-select/cps-tree-select.component.scss +++ b/projects/cps-ui-kit/src/lib/components/cps-tree-select/cps-tree-select.component.scss @@ -261,9 +261,6 @@ $hover-transition-duration: 0.2s; max-height: 15.125rem; overflow-y: auto; - // Default: a focused+selected node looks the same as a plain selected node - // (mouse click focuses the node too) - overridden below only while the - // focus came from real keyboard arrow navigation. --focused-selected-option-background: #{$selected-option-background}; &.arrow-navigating { @@ -498,11 +495,10 @@ $hover-transition-duration: 0.2s; font-size: 1rem; } - [role='treeitem']:focus-visible, - [data-pc-section='nodetogglebutton']:focus-visible { + *:focus-visible { &::before, &::after { - display: none; + display: none !important; } } } 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 3521c7876..e1c8a400e 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 @@ -69,7 +69,6 @@ export class CpsTreeSelectComponent extends CpsBaseTreeDropdownComponent { onKeyDown(event: KeyboardEvent) { const code = event.code; - // Tab — close dropdown if open, let browser move focus naturally if (code === 'Tab') { if (this.isOpened) this.toggleOptions(false); return; @@ -77,18 +76,12 @@ export class CpsTreeSelectComponent extends CpsBaseTreeDropdownComponent { event.preventDefault(); - // Escape — close and return focus to trigger if (code === 'Escape') { this.toggleOptions(false); this.componentContainer?.nativeElement?.focus(); - } - // Enter or Space — toggle dropdown - else if (code === 'Enter' || code === 'NumpadEnter' || code === 'Space') { + } else if (code === 'Enter' || code === 'NumpadEnter' || code === 'Space') { this.toggleOptions(!this.isOpened); - } - // Arrow Up or Down — open if closed and focus the selected node; - // if already open, move one step away from the selected node - else if (code === 'ArrowUp' || code === 'ArrowDown') { + } else if (code === 'ArrowUp' || code === 'ArrowDown') { const up = code === 'ArrowUp'; if (!this.isOpened) { this.toggleOptions(true); @@ -98,59 +91,4 @@ export class CpsTreeSelectComponent extends CpsBaseTreeDropdownComponent { } } } - - onOptionsKeyDown(event: KeyboardEvent) { - switch (event.code) { - case 'Escape': - event.preventDefault(); - this.toggleOptions(false); - this.componentContainer?.nativeElement?.focus(); - break; - - case 'Tab': - event.preventDefault(); - this.toggleOptions(false); - this.componentContainer?.nativeElement?.focus(); - break; - - case 'ArrowUp': - case 'ArrowDown': { - this.isArrowNavigating = true; - - // PrimeNG's handler already ran (bubble phase) and moved focus synchronously. - // If focus is still on the original element, PrimeNG hit a boundary — wrap around. - const target = event.target as HTMLElement; - if (this._document.activeElement === target) { - event.preventDefault(); - if (event.code === 'ArrowUp') { - this._focusTreeNode(this._getLastVisibleTreeNodeLi()); - } else { - this._focusTreeNode( - this.treeContainerElement?.querySelector( - '[role="treeitem"]' - ) ?? null - ); - } - } - break; - } - - case 'Enter': - case 'Space': - case 'NumpadEnter': { - // Mouse click on a fully-expandable directory row both selects and - // toggles expand (PrimeNG's onNodeClick + our container click listener). - // PrimeNG's own Enter/Space handling only replicates the selection half, - // so trigger the expand toggle here to match. - const target = event.target as HTMLElement; - if (target?.classList?.contains('cps-tree-node-fully-expandable')) { - const contentElem = target.querySelector( - '.p-tree-node-content' - ); - if (contentElem) this.onClickFullyExpandable(contentElem); - } - break; - } - } - } } 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 d2c21df96..4a7ed90d9 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 @@ -633,6 +633,70 @@ export class CpsBaseTreeDropdownComponent if (next) this._focusTreeNode(next); } + protected _onOptionsClose(): void { + this.toggleOptions(false); + this.componentContainer?.nativeElement?.focus(); + } + + onOptionsKeyDown(event: KeyboardEvent): void { + switch (event.code) { + case 'Escape': + case 'Tab': + event.preventDefault(); + this._onOptionsClose(); + break; + + case 'ArrowUp': + case 'ArrowDown': { + this.isArrowNavigating = true; + const target = event.target as HTMLElement; + if (this._document.activeElement === target) { + event.preventDefault(); + if (event.code === 'ArrowUp') { + this._focusTreeNode(this._getLastVisibleTreeNodeLi()); + } else { + this._focusTreeNode( + this.treeContainerElement?.querySelector( + '[role="treeitem"]' + ) ?? null + ); + } + } + break; + } + + case 'Enter': + case 'Space': + case 'NumpadEnter': { + const target = event.target as HTMLElement; + const ariaExpanded = target.getAttribute('aria-expanded'); + if (ariaExpanded === null) break; + + const isCollapsed = ariaExpanded !== 'true'; + const isFullyExpandable = target.classList.contains( + 'cps-tree-node-fully-expandable' + ); + + if (isCollapsed || isFullyExpandable) { + const toggleBtn = target.querySelector( + '.p-tree-node-toggle-button' + ); + if (toggleBtn) { + toggleBtn.click(); + if (isCollapsed) { + setTimeout(() => { + const firstChild = + target.querySelector('[role="treeitem"]'); + if (firstChild) this._focusTreeNode(firstChild); + }); + } + } + } + break; + } + } + } + private _findLastVisibleDescendantLi(pTreeNode: Element): HTMLElement | null { const li = Array.from(pTreeNode.children).find( (el) => el.getAttribute('data-pc-section') === 'node' From a0111877789d985335ff54cf5cbe064e265823a6 Mon Sep 17 00:00:00 2001 From: Andrei Fateev Date: Thu, 18 Jun 2026 02:24:33 +0200 Subject: [PATCH 05/13] fix outer div click --- .vscode/settings.json | 2 +- .../cps-tree-autocomplete.component.html | 1 + .../cps-tree-autocomplete.component.ts | 6 ++++++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index bcb54171e..2c1485213 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -26,7 +26,7 @@ "files.associations": { "**/src/**/*.spec.ts": "typescriptreact" }, - "git.enableCommitSigning": false, + "git.enableCommitSigning": true, "editor.codeLens": true, "editor.formatOnSave": true, "editor.codeActionsOnSave": { 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 954edba6a..c717fa04a 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 @@ -2,6 +2,7 @@ [style.width]="cvtWidth" class="cps-treeautocomplete" tabindex="-1" + (keydown)="onOuterDivKeyDown($event)" [class.disabled]="disabled" [class.error]="error" [class.active]="isActive()" diff --git a/projects/cps-ui-kit/src/lib/components/cps-tree-autocomplete/cps-tree-autocomplete.component.ts b/projects/cps-ui-kit/src/lib/components/cps-tree-autocomplete/cps-tree-autocomplete.component.ts index 4657ffe4e..0eba52cee 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-tree-autocomplete/cps-tree-autocomplete.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-tree-autocomplete/cps-tree-autocomplete.component.ts @@ -134,6 +134,12 @@ export class CpsTreeAutocompleteComponent extends CpsBaseTreeDropdownComponent { this.optionFocused = false; } + onOuterDivKeyDown(event: KeyboardEvent) { + if (event.target !== this.componentContainer?.nativeElement) return; + this.focusInput(); + this.onContainerKeyDown(event); + } + onContainerKeyDown(event: KeyboardEvent) { const code = event.code; if (code === 'Tab') { From 39466749805a28c83f633c80a1df47ddab5a9efe Mon Sep 17 00:00:00 2001 From: Andrei Fateev Date: Thu, 18 Jun 2026 10:06:08 +0200 Subject: [PATCH 06/13] fix icons positioning --- .../cps-tree-autocomplete.component.scss | 6 ++---- .../cps-tree-select/cps-tree-select.component.scss | 3 ++- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/projects/cps-ui-kit/src/lib/components/cps-tree-autocomplete/cps-tree-autocomplete.component.scss b/projects/cps-ui-kit/src/lib/components/cps-tree-autocomplete/cps-tree-autocomplete.component.scss index 403d200d5..68c0b5b0d 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-tree-autocomplete/cps-tree-autocomplete.component.scss +++ b/projects/cps-ui-kit/src/lib/components/cps-tree-autocomplete/cps-tree-autocomplete.component.scss @@ -239,9 +239,8 @@ $hover-transition-duration: 0.2s; .cps-treeautocomplete-box-clear-icon { display: flex; - padding: 0.25rem; color: $color-error; - margin-left: 0.25rem; + margin-left: 0.5rem; cursor: pointer; &:focus { outline: none; @@ -262,8 +261,7 @@ $hover-transition-duration: 0.2s; } .cps-treeautocomplete-box-chevron { display: flex; - padding: 0.25rem; - margin-left: 0.25rem; + margin-left: 0.5rem; transition-duration: $hover-transition-duration; cursor: pointer; cps-icon { diff --git a/projects/cps-ui-kit/src/lib/components/cps-tree-select/cps-tree-select.component.scss b/projects/cps-ui-kit/src/lib/components/cps-tree-select/cps-tree-select.component.scss index ef2ee6b6b..3e6357fd4 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-tree-select/cps-tree-select.component.scss +++ b/projects/cps-ui-kit/src/lib/components/cps-tree-select/cps-tree-select.component.scss @@ -117,7 +117,8 @@ $hover-transition-duration: 0.2s; background: white; font-size: 1rem; outline: none; - padding: 0 0.75rem; + padding-left: 0.75rem; + padding-right: 0.5rem; border-radius: 0.25rem; align-items: center; display: flex; From 103f0bbe1dcae54be7cd7c510b49ab8a0d0fe942 Mon Sep 17 00:00:00 2001 From: Andrei Fateev Date: Thu, 18 Jun 2026 10:22:06 +0200 Subject: [PATCH 07/13] fix focus rings size --- .../cps-tree-autocomplete.component.scss | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/projects/cps-ui-kit/src/lib/components/cps-tree-autocomplete/cps-tree-autocomplete.component.scss b/projects/cps-ui-kit/src/lib/components/cps-tree-autocomplete/cps-tree-autocomplete.component.scss index 68c0b5b0d..38b9a9697 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-tree-autocomplete/cps-tree-autocomplete.component.scss +++ b/projects/cps-ui-kit/src/lib/components/cps-tree-autocomplete/cps-tree-autocomplete.component.scss @@ -252,7 +252,7 @@ $hover-transition-duration: 0.2s; } } &:focus-visible { - @include focus-ring(-0.125rem, -0.25rem, 50%); + @include focus-ring(0.125rem, 0.25rem, 50%); } cps-icon { opacity: 0; @@ -271,7 +271,7 @@ $hover-transition-duration: 0.2s; outline: none; } &:focus-visible { - @include focus-ring(-0.125rem, -0.25rem, 0.375rem); + @include focus-ring(); } } } From 163d2184f6a78dee13e451e99dc04fc250c57c0d Mon Sep 17 00:00:00 2001 From: Andrei Fateev Date: Thu, 18 Jun 2026 10:54:26 +0200 Subject: [PATCH 08/13] get rid of duplicated input --- .../cps-tree-autocomplete.component.html | 33 +++---------------- .../cps-tree-autocomplete.component.scss | 7 ++++ 2 files changed, 12 insertions(+), 28 deletions(-) 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 c717fa04a..efc875766 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 @@ -75,7 +75,7 @@ {{ val.label }}{{ !last ? ',' : '' }}
} - + } - + + + } @if (clearable && !disabled) { diff --git a/projects/cps-ui-kit/src/lib/components/cps-tree-autocomplete/cps-tree-autocomplete.component.scss b/projects/cps-ui-kit/src/lib/components/cps-tree-autocomplete/cps-tree-autocomplete.component.scss index 38b9a9697..faa01d074 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-tree-autocomplete/cps-tree-autocomplete.component.scss +++ b/projects/cps-ui-kit/src/lib/components/cps-tree-autocomplete/cps-tree-autocomplete.component.scss @@ -202,6 +202,13 @@ $hover-transition-duration: 0.2s; min-height: 1.75rem; } + .cps-treeautocomplete-input-listitem { + flex-grow: 1; + min-width: 1.875rem; + display: flex; + align-items: center; + } + .chips-group { display: inline-flex; flex-wrap: wrap; From 3790863e26a1fbd6ee0dad4610272af04bbd95f5 Mon Sep 17 00:00:00 2001 From: Andrei Fateev Date: Thu, 18 Jun 2026 11:57:52 +0200 Subject: [PATCH 09/13] add onContainerMouseDown --- .../cps-tree-autocomplete.component.html | 1 + .../cps-tree-autocomplete.component.ts | 13 +++++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) 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 efc875766..5310d6cde 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 @@ -25,6 +25,7 @@
}
Date: Thu, 18 Jun 2026 12:21:27 +0200 Subject: [PATCH 10/13] add toggle node arialabel --- .../cps-tree-autocomplete.component.html | 14 +++++++++----- .../cps-tree-select/cps-tree-select.component.html | 4 ++-- .../cps-base-tree-dropdown.component.ts | 4 ++++ 3 files changed, 15 insertions(+), 7 deletions(-) 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 5310d6cde..ba3f1a99f 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 @@ -76,7 +76,9 @@ {{ val.label }}{{ !last ? ',' : '' }}
} - + } - + + } @@ -179,6 +182,7 @@ + [selectionMode]="multiple ? 'multiple' : 'single'"> {{ node.label }} 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 4a7ed90d9..4f6848777 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 @@ -268,6 +268,10 @@ export class CpsBaseTreeDropdownComponent private _treeRefreshKey = 0; + readonly treeNodeToggleButtonPt = { + nodeToggleButton: { 'aria-label': 'Toggle node' } + }; + readonly treeTrackBy = ( _index: number, item: TreeNode & { node?: TreeNode } From 628361fddea7e1b1363470c6a2f1ba8d8a012ba8 Mon Sep 17 00:00:00 2001 From: Andrei Fateev Date: Thu, 18 Jun 2026 13:36:56 +0200 Subject: [PATCH 11/13] add UTs --- .../cps-tree-autocomplete.component.spec.ts | 369 ++++++++++++++ .../cps-tree-select.component.spec.ts | 185 +++++++ .../cps-base-tree-dropdown.component.spec.ts | 459 ++++++++++++++++++ 3 files changed, 1013 insertions(+) create mode 100644 projects/cps-ui-kit/src/lib/components/cps-tree-autocomplete/cps-tree-autocomplete.component.spec.ts create mode 100644 projects/cps-ui-kit/src/lib/components/cps-tree-select/cps-tree-select.component.spec.ts create mode 100644 projects/cps-ui-kit/src/lib/components/internal/cps-base-tree-dropdown/cps-base-tree-dropdown.component.spec.ts diff --git a/projects/cps-ui-kit/src/lib/components/cps-tree-autocomplete/cps-tree-autocomplete.component.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-tree-autocomplete/cps-tree-autocomplete.component.spec.ts new file mode 100644 index 000000000..764b67312 --- /dev/null +++ b/projects/cps-ui-kit/src/lib/components/cps-tree-autocomplete/cps-tree-autocomplete.component.spec.ts @@ -0,0 +1,369 @@ +import { NO_ERRORS_SCHEMA, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { CPS_ROOT_FONT_SIZE_SERVICE } from '../../services/cps-root-font-size/cps-root-font-size.service'; +import { CpsMenuHideReason } from '../cps-menu/cps-menu.component'; +import { CpsTreeAutocompleteComponent } from './cps-tree-autocomplete.component'; + +const mockFontSize = signal(16); +const mockRootFontSizeService = { + fontSize: mockFontSize.asReadonly() +}; + +const OPTIONS = [ + { label: 'Option 1', value: 'opt1' }, + { label: 'Option 2', value: 'opt2' }, + { + label: 'Parent', + value: 'parent', + children: [ + { label: 'Child 1', value: 'child1' }, + { label: 'Child 2', value: 'child2' } + ] + } +]; + +describe('CpsTreeAutocompleteComponent', () => { + let component: CpsTreeAutocompleteComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + FormsModule, + ReactiveFormsModule, + CpsTreeAutocompleteComponent, + NoopAnimationsModule + ], + providers: [ + { + provide: CPS_ROOT_FONT_SIZE_SERVICE, + useValue: mockRootFontSizeService + } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(CpsTreeAutocompleteComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('ariaLabel', 'Test tree autocomplete'); + fixture.componentRef.setInput('options', OPTIONS); + fixture.detectChanges(); + + let menuVisible = false; + const menu = component.optionsMenu; + jest.spyOn(menu, 'show').mockImplementation(() => { + menuVisible = true; + }); + jest.spyOn(menu, 'hide').mockImplementation(() => { + menuVisible = false; + }); + jest.spyOn(menu, 'toggle').mockImplementation(() => { + menuVisible = !menuVisible; + }); + jest.spyOn(menu, 'isVisible').mockImplementation(() => menuVisible); + + jest.spyOn(component.treeList, 'resetFilter').mockImplementation(() => {}); + jest + .spyOn(component.treeList as any, '_filter') + .mockImplementation(() => {}); + }); + + it('should create the component', () => { + expect(component).toBeTruthy(); + }); + + describe('Display', () => { + it('should apply underlined appearance class', () => { + fixture.componentRef.setInput('appearance', 'underlined'); + fixture.detectChanges(); + const container = fixture.debugElement.query( + By.css('.cps-treeautocomplete-container.underlined') + ); + expect(container).toBeTruthy(); + }); + + it('should apply borderless appearance class', () => { + fixture.componentRef.setInput('appearance', 'borderless'); + fixture.detectChanges(); + const container = fixture.debugElement.query( + By.css('.cps-treeautocomplete-container.borderless') + ); + expect(container).toBeTruthy(); + }); + + it('should show placeholder when no value is selected', () => { + fixture.componentRef.setInput('placeholder', 'Search here'); + component.treeSelection = undefined; + fixture.detectChanges(); + const input = fixture.debugElement.query( + By.css('input.cps-treeautocomplete-box-input') + ); + expect(input.nativeElement.placeholder).toBe('Search here'); + }); + }); + + describe('isActive()', () => { + it('should return true when dropdown is open', () => { + component.toggleOptions(true); + expect(component.isActive()).toBe(true); + }); + + it('should return false when dropdown is closed and input is not focused', () => { + expect(component.isActive()).toBe(false); + }); + }); + + describe('onChevronClick', () => { + it('should call event.preventDefault()', () => { + const event = new MouseEvent('mousedown'); + jest.spyOn(event, 'preventDefault'); + component.onChevronClick(event); + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it('should open the dropdown when closed', () => { + const event = new MouseEvent('mousedown'); + jest.spyOn(event, 'preventDefault'); + component.onChevronClick(event); + expect(component.isOpened).toBe(true); + }); + + it('should close the dropdown and clear input when open', () => { + component.toggleOptions(true); + component.inputText = 'typed value'; + const event = new MouseEvent('mousedown'); + jest.spyOn(event, 'preventDefault'); + component.onChevronClick(event); + expect(component.isOpened).toBe(false); + expect(component.inputText).toBe(''); + }); + }); + + describe('onContainerMouseDown', () => { + it('should call event.preventDefault() when target is not the input', () => { + const event = new MouseEvent('mousedown'); + Object.defineProperty(event, 'target', { + value: document.createElement('div') + }); + jest.spyOn(event, 'preventDefault'); + component.onContainerMouseDown(event); + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it('should NOT call event.preventDefault() when target is the input element', () => { + const inputEl = component.treeAutocompleteInput.nativeElement; + const event = new MouseEvent('mousedown'); + Object.defineProperty(event, 'target', { value: inputEl }); + jest.spyOn(event, 'preventDefault'); + component.onContainerMouseDown(event); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + }); + + describe('onFocus', () => { + it('should set activeSingle to true in single mode', () => { + component.onFocus(); + expect(component.activeSingle).toBe(true); + }); + + it('should NOT set activeSingle in multiple mode', () => { + fixture.componentRef.setInput('multiple', true); + component.onFocus(); + expect(component.activeSingle).toBe(false); + }); + + it('should set isKeyboardFocused to true when not preceded by a mouse click', () => { + component.onFocus(); + expect(component.isKeyboardFocused).toBe(true); + }); + + it('should populate inputText from selection label in single mode when input is empty', () => { + component.writeValue(OPTIONS[0]); + component.inputText = ''; + component.onFocus(); + expect(component.inputText).toBe('Option 1'); + }); + }); + + describe('onBlur', () => { + it('should reset isKeyboardFocused to false', () => { + component.isKeyboardFocused = true; + component.onBlur(); + expect(component.isKeyboardFocused).toBe(false); + }); + + it('should clear inputText when dropdown is closed', () => { + component.inputText = 'some text'; + component.onBlur(); + expect(component.inputText).toBe(''); + }); + + it('should not clear inputText when dropdown is open', () => { + component.toggleOptions(true); + component.inputText = 'some text'; + component.onBlur(); + expect(component.inputText).toBe('some text'); + }); + }); + + describe('onBeforeOptionsHidden', () => { + it('should close the dropdown but preserve inputText on SCROLL or RESIZE', () => { + for (const reason of [ + CpsMenuHideReason.SCROLL, + CpsMenuHideReason.RESIZE + ]) { + component.toggleOptions(true); + component.inputText = 'typed'; + component.onBeforeOptionsHidden(reason); + expect(component.isOpened).toBe(false); + expect(component.inputText).toBe('typed'); + } + }); + + it('should clear inputText on reasons other than SCROLL/RESIZE', () => { + component.toggleOptions(true); + component.inputText = 'typed'; + component.onBeforeOptionsHidden(CpsMenuHideReason.CLICK_OUTSIDE); + expect(component.inputText).toBe(''); + }); + }); + + describe('onBoxClick', () => { + it('should set activeSingle to true in single mode', () => { + component.onBoxClick(); + expect(component.activeSingle).toBe(true); + }); + + it('should open the dropdown', () => { + component.onBoxClick(); + expect(component.isOpened).toBe(true); + }); + + it('should populate inputText from selection in single mode when empty', () => { + component.writeValue(OPTIONS[0]); + component.inputText = ''; + component.onBoxClick(); + expect(component.inputText).toBe('Option 1'); + }); + }); + + describe('onContainerKeyDown', () => { + function keyEvent(code: string): KeyboardEvent { + return new KeyboardEvent('keydown', { code, bubbles: true }); + } + + it('should close and clear on Tab when dropdown is open', () => { + component.toggleOptions(true); + component.inputText = 'typed'; + component.onContainerKeyDown(keyEvent('Tab')); + expect(component.isOpened).toBe(false); + expect(component.inputText).toBe(''); + }); + + it('should do nothing on Tab when dropdown is already closed', () => { + jest.spyOn(component, 'toggleOptions'); + component.onContainerKeyDown(keyEvent('Tab')); + expect(component.toggleOptions).not.toHaveBeenCalled(); + }); + + it('should close and clear on Escape', () => { + component.toggleOptions(true); + component.inputText = 'typed'; + component.onContainerKeyDown(keyEvent('Escape')); + expect(component.isOpened).toBe(false); + expect(component.inputText).toBe(''); + }); + + it('should open dropdown, set isKeyboardFocused, and call preventDefault on arrow keys', () => { + const event = keyEvent('ArrowDown'); + jest.spyOn(event, 'preventDefault'); + component.onContainerKeyDown(event); + expect(component.isOpened).toBe(true); + expect(component.isKeyboardFocused).toBe(true); + expect(event.preventDefault).toHaveBeenCalled(); + + component.toggleOptions(false); + component.isKeyboardFocused = false; + component.onContainerKeyDown(keyEvent('ArrowUp')); + expect(component.isOpened).toBe(true); + expect(component.isKeyboardFocused).toBe(true); + }); + }); + + describe('onInputKeyDown', () => { + function keyEvent(code: string): KeyboardEvent { + return new KeyboardEvent('keydown', { code, bubbles: true }); + } + + it('should call event.stopPropagation() on Backspace', () => { + const event = keyEvent('Backspace'); + jest.spyOn(event, 'stopPropagation'); + component.onInputKeyDown(event); + expect(event.stopPropagation).toHaveBeenCalled(); + }); + + it('should open dropdown on Enter when closed', () => { + const event = keyEvent('Enter'); + jest.spyOn(event, 'preventDefault'); + component.onInputKeyDown(event); + expect(component.isOpened).toBe(true); + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it('should open dropdown on NumpadEnter when closed', () => { + component.onInputKeyDown(keyEvent('NumpadEnter')); + expect(component.isOpened).toBe(true); + }); + }); + + describe('filterOptions', () => { + it('should open the dropdown when not already open', () => { + expect(component.isOpened).toBe(false); + component.filterOptions({ target: { value: 'opt' } }); + expect(component.isOpened).toBe(true); + }); + + it('should call treeList.resetFilter when search value is empty', () => { + component.filterOptions({ target: { value: '' } }); + expect(component.treeList.resetFilter).toHaveBeenCalled(); + }); + + it('should call treeList._filter with lowercased search value', () => { + component.filterOptions({ target: { value: 'Option' } }); + expect((component.treeList as any)._filter).toHaveBeenCalledWith( + 'option' + ); + }); + + it('should reset backspaceClickedOnce to false', () => { + component.backspaceClickedOnce = true; + component.filterOptions({ target: { value: '' } }); + expect(component.backspaceClickedOnce).toBe(false); + }); + }); + + describe('onSelectNode', () => { + it('should reset backspaceClickedOnce', () => { + component.backspaceClickedOnce = true; + component.onSelectNode(); + expect(component.backspaceClickedOnce).toBe(false); + }); + + it('should clear inputText', () => { + component.inputText = 'typed'; + component.onSelectNode(); + expect(component.inputText).toBe(''); + }); + + it('should close dropdown in single mode after selection', () => { + component.toggleOptions(true); + component.onSelectNode(); + expect(component.isOpened).toBe(false); + }); + }); +}); diff --git a/projects/cps-ui-kit/src/lib/components/cps-tree-select/cps-tree-select.component.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-tree-select/cps-tree-select.component.spec.ts new file mode 100644 index 000000000..9cf58e5da --- /dev/null +++ b/projects/cps-ui-kit/src/lib/components/cps-tree-select/cps-tree-select.component.spec.ts @@ -0,0 +1,185 @@ +import { NO_ERRORS_SCHEMA, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { CombineLabelsPipe } from '../../pipes/internal/combine-labels.pipe'; +import { CPS_ROOT_FONT_SIZE_SERVICE } from '../../services/cps-root-font-size/cps-root-font-size.service'; +import { CpsTreeSelectComponent } from './cps-tree-select.component'; + +const mockFontSize = signal(16); +const mockRootFontSizeService = { + fontSize: mockFontSize.asReadonly() +}; + +const OPTIONS = [ + { label: 'Option 1', value: 'opt1' }, + { label: 'Option 2', value: 'opt2' } +]; + +describe('CpsTreeSelectComponent', () => { + let component: CpsTreeSelectComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + FormsModule, + ReactiveFormsModule, + CpsTreeSelectComponent, + NoopAnimationsModule + ], + providers: [ + CombineLabelsPipe, + { + provide: CPS_ROOT_FONT_SIZE_SERVICE, + useValue: mockRootFontSizeService + } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(CpsTreeSelectComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('ariaLabel', 'Test tree select'); + fixture.componentRef.setInput('options', OPTIONS); + fixture.detectChanges(); + + let menuVisible = false; + const menu = component.optionsMenu; + jest.spyOn(menu, 'show').mockImplementation(() => { + menuVisible = true; + }); + jest.spyOn(menu, 'hide').mockImplementation(() => { + menuVisible = false; + }); + jest.spyOn(menu, 'toggle').mockImplementation(() => { + menuVisible = !menuVisible; + }); + jest.spyOn(menu, 'isVisible').mockImplementation(() => menuVisible); + }); + + it('should create the component', () => { + expect(component).toBeTruthy(); + }); + + describe('Display', () => { + it('should apply underlined appearance class', () => { + fixture.componentRef.setInput('appearance', 'underlined'); + fixture.detectChanges(); + const container = fixture.debugElement.query( + By.css('.cps-treeselect-container.underlined') + ); + expect(container).toBeTruthy(); + }); + + it('should apply borderless appearance class', () => { + fixture.componentRef.setInput('appearance', 'borderless'); + fixture.detectChanges(); + const container = fixture.debugElement.query( + By.css('.cps-treeselect-container.borderless') + ); + expect(container).toBeTruthy(); + }); + + it('should display placeholder when no value is selected', () => { + fixture.componentRef.setInput('placeholder', 'Choose an option'); + component.writeValue(undefined); + fixture.detectChanges(); + const placeholder = fixture.debugElement.query( + By.css('.cps-treeselect-box-placeholder') + ); + expect(placeholder.nativeElement.textContent.trim()).toBe( + 'Choose an option' + ); + }); + }); + + describe('onBoxClick', () => { + it('should toggle the dropdown', () => { + expect(component.isOpened).toBe(false); + component.onBoxClick(); + expect(component.isOpened).toBe(true); + }); + + it('should call stopPropagation when an event is passed', () => { + const event = new MouseEvent('click'); + jest.spyOn(event, 'stopPropagation'); + component.onBoxClick(event); + expect(event.stopPropagation).toHaveBeenCalled(); + }); + + it('should close the dropdown on second call', () => { + component.onBoxClick(); + expect(component.isOpened).toBe(true); + component.onBoxClick(); + expect(component.isOpened).toBe(false); + }); + }); + + describe('onBeforeOptionsHidden', () => { + it('should close the dropdown', () => { + component.toggleOptions(true); + expect(component.isOpened).toBe(true); + component.onBeforeOptionsHidden(); + expect(component.isOpened).toBe(false); + }); + }); + + describe('onKeyDown', () => { + function keyEvent(code: string): KeyboardEvent { + return new KeyboardEvent('keydown', { code, bubbles: true }); + } + + it('should close dropdown on Tab when open', () => { + component.toggleOptions(true); + component.onKeyDown(keyEvent('Tab')); + expect(component.isOpened).toBe(false); + }); + + it('should not call preventDefault on Tab', () => { + component.toggleOptions(true); + const event = keyEvent('Tab'); + jest.spyOn(event, 'preventDefault'); + component.onKeyDown(event); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + + it('should close dropdown and call preventDefault on Escape', () => { + component.toggleOptions(true); + const event = keyEvent('Escape'); + jest.spyOn(event, 'preventDefault'); + component.onKeyDown(event); + expect(component.isOpened).toBe(false); + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it('should toggle dropdown on Enter and also open on Space and NumpadEnter', () => { + component.onKeyDown(keyEvent('Enter')); + expect(component.isOpened).toBe(true); + component.onKeyDown(keyEvent('Enter')); + expect(component.isOpened).toBe(false); + + component.onKeyDown(keyEvent('Space')); + expect(component.isOpened).toBe(true); + component.toggleOptions(false); + + component.onKeyDown(keyEvent('NumpadEnter')); + expect(component.isOpened).toBe(true); + }); + + it('should open dropdown and call preventDefault on arrow keys', () => { + const event = keyEvent('ArrowDown'); + jest.spyOn(event, 'preventDefault'); + component.onKeyDown(event); + expect(component.isOpened).toBe(true); + expect(event.preventDefault).toHaveBeenCalled(); + + component.toggleOptions(false); + component.onKeyDown(keyEvent('ArrowUp')); + expect(component.isOpened).toBe(true); + }); + }); +}); diff --git a/projects/cps-ui-kit/src/lib/components/internal/cps-base-tree-dropdown/cps-base-tree-dropdown.component.spec.ts b/projects/cps-ui-kit/src/lib/components/internal/cps-base-tree-dropdown/cps-base-tree-dropdown.component.spec.ts new file mode 100644 index 000000000..958ab3171 --- /dev/null +++ b/projects/cps-ui-kit/src/lib/components/internal/cps-base-tree-dropdown/cps-base-tree-dropdown.component.spec.ts @@ -0,0 +1,459 @@ +import { NO_ERRORS_SCHEMA, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { CombineLabelsPipe } from '../../../pipes/internal/combine-labels.pipe'; +import { CPS_ROOT_FONT_SIZE_SERVICE } from '../../../services/cps-root-font-size/cps-root-font-size.service'; +import { CpsTreeSelectComponent } from '../../cps-tree-select/cps-tree-select.component'; + +// Tests use CpsTreeSelectComponent as the concrete implementation of the abstract base class. + +const mockFontSize = signal(16); +const mockRootFontSizeService = { + fontSize: mockFontSize.asReadonly() +}; + +const OPTIONS = [ + { label: 'Option 1', value: 'opt1' }, + { label: 'Option 2', value: 'opt2' }, + { + label: 'Parent', + value: 'parent', + children: [ + { label: 'Child 1', value: 'child1' }, + { label: 'Child 2', value: 'child2' } + ] + } +]; + +describe('CpsBaseTreeDropdownComponent', () => { + let component: CpsTreeSelectComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + FormsModule, + ReactiveFormsModule, + CpsTreeSelectComponent, + NoopAnimationsModule + ], + providers: [ + CombineLabelsPipe, + { + provide: CPS_ROOT_FONT_SIZE_SERVICE, + useValue: mockRootFontSizeService + } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(CpsTreeSelectComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('ariaLabel', 'Test tree select'); + fixture.componentRef.setInput('options', OPTIONS); + fixture.detectChanges(); + + let menuVisible = false; + const menu = component.optionsMenu; + jest.spyOn(menu, 'show').mockImplementation(() => { + menuVisible = true; + }); + jest.spyOn(menu, 'hide').mockImplementation(() => { + menuVisible = false; + }); + jest.spyOn(menu, 'toggle').mockImplementation(() => { + menuVisible = !menuVisible; + }); + jest.spyOn(menu, 'isVisible').mockImplementation(() => menuVisible); + }); + + it('should create the component', () => { + expect(component).toBeTruthy(); + }); + + describe('treeNodeToggleButtonPt', () => { + it('should expose the aria-label PT structure for toggle buttons', () => { + expect(component.treeNodeToggleButtonPt).toEqual({ + nodeToggleButton: { 'aria-label': 'Toggle node' } + }); + }); + }); + + describe('Display', () => { + it('should display the label when provided', () => { + fixture.componentRef.setInput('label', 'My Label'); + fixture.detectChanges(); + const label = fixture.debugElement.query( + By.css('.cps-treeselect-label label') + ); + expect(label.nativeElement.textContent.trim()).toBe('My Label'); + }); + + it('should not render the label element when label is empty', () => { + fixture.componentRef.setInput('label', ''); + fixture.detectChanges(); + const label = fixture.debugElement.query(By.css('.cps-treeselect-label')); + expect(label).toBeNull(); + }); + + it('should display single selected option label', () => { + component.writeValue(OPTIONS[0]); + fixture.detectChanges(); + const singleItem = fixture.debugElement.query(By.css('.single-item')); + expect(singleItem.nativeElement.textContent.trim()).toBe('Option 1'); + }); + + it('should display hint text', () => { + fixture.componentRef.setInput('hint', 'Some hint'); + fixture.detectChanges(); + const hint = fixture.debugElement.query(By.css('.cps-treeselect-hint')); + expect(hint.nativeElement.textContent.trim()).toBe('Some hint'); + }); + + it('should hide hint when hideDetails is true', () => { + fixture.componentRef.setInput('hint', 'Some hint'); + fixture.componentRef.setInput('hideDetails', true); + fixture.detectChanges(); + const hint = fixture.debugElement.query(By.css('.cps-treeselect-hint')); + expect(hint).toBeNull(); + }); + + it('should apply disabled class when disabled', () => { + fixture.componentRef.setInput('disabled', true); + fixture.detectChanges(); + const wrapper = fixture.debugElement.query( + By.css('.cps-treeselect.disabled') + ); + expect(wrapper).toBeTruthy(); + }); + + it('should apply error class when error is set', () => { + component.error = 'Something went wrong'; + fixture.detectChanges(); + const wrapper = fixture.debugElement.query( + By.css('.cps-treeselect.error') + ); + expect(wrapper).toBeTruthy(); + }); + }); + + describe('Initialization', () => { + it('should generate non-empty hintId, errorId, and optionsTreeId', () => { + expect(component.hintId).toBeTruthy(); + expect(component.errorId).toBeTruthy(); + expect(component.optionsTreeId).toBeTruthy(); + }); + + it('should initialise value as empty array in multiple mode when no value provided', () => { + fixture.componentRef.setInput('multiple', true); + component.ngOnInit(); + expect(Array.isArray(component.value)).toBe(true); + expect(component.value).toHaveLength(0); + }); + + it('should convert numeric width to pixel string', () => { + fixture.componentRef.setInput('width', 320); + component.ngOnChanges({ + width: { + currentValue: 320, + previousValue: '100%', + firstChange: false, + isFirstChange: () => false + } + } as any); + expect(component.cvtWidth).toBe('320px'); + }); + }); + + describe('Options Processing', () => { + it('should populate innerOptions from options input', () => { + expect(component.innerOptions).toHaveLength(3); + }); + + it('should map option labels to innerOptions', () => { + expect(component.innerOptions[0].label).toBe('Option 1'); + expect(component.innerOptions[1].label).toBe('Option 2'); + expect(component.innerOptions[2].label).toBe('Parent'); + }); + + it('should nest children inside parent innerOptions', () => { + const parent = component.innerOptions[2]; + expect(parent.children).toHaveLength(2); + expect(parent.children![0].label).toBe('Child 1'); + expect(parent.children![1].label).toBe('Child 2'); + }); + + it('should expand all parent nodes when initialExpandAll is true', () => { + fixture.componentRef.setInput('initialExpandAll', true); + fixture.componentRef.setInput('options', [...OPTIONS]); + fixture.detectChanges(); + expect(component.innerOptions[2].expanded).toBe(true); + }); + + it('should update innerOptions when options input changes', () => { + const newOptions = [{ label: 'New', value: 'new' }]; + fixture.componentRef.setInput('options', newOptions); + fixture.detectChanges(); + expect(component.innerOptions).toHaveLength(1); + expect(component.innerOptions[0].label).toBe('New'); + }); + }); + + describe('Value Handling', () => { + it('should store the value via writeValue', () => { + component.writeValue(OPTIONS[0]); + expect(component.value).toEqual(OPTIONS[0]); + }); + + it('should resolve treeSelection when writeValue is called with a known option', () => { + component.writeValue(OPTIONS[0]); + expect(component.treeSelection).toBeTruthy(); + expect(component.treeSelection.key).toBe('0'); + }); + + it('should resolve multiple treeSelection when writeValue is called with an array', () => { + fixture.componentRef.setInput('multiple', true); + component.writeValue([OPTIONS[0], OPTIONS[1]]); + expect(Array.isArray(component.treeSelection)).toBe(true); + expect(component.treeSelection).toHaveLength(2); + }); + + it('should not update treeSelection when writeValue is called with internal=true', () => { + component.treeSelection = undefined; + component.writeValue(OPTIONS[0], true); + expect(component.treeSelection).toBeUndefined(); + }); + + it('should not update treeSelection when writeValue is called with null', () => { + component.treeSelection = undefined; + component.writeValue(null); + expect(component.treeSelection).toBeUndefined(); + }); + + it('should return the original option via treeSelectionToValue (single)', () => { + const result = component.treeSelectionToValue(component.innerOptions[0]); + expect(result).toEqual(OPTIONS[0]); + }); + + it('should return array of original options via treeSelectionToValue (multiple)', () => { + fixture.componentRef.setInput('multiple', true); + const result = component.treeSelectionToValue([ + component.innerOptions[0], + component.innerOptions[1] + ]); + expect(result).toEqual([OPTIONS[0], OPTIONS[1]]); + }); + + it('should return undefined from treeSelectionToValue for undefined input (single)', () => { + expect(component.treeSelectionToValue(undefined)).toBeUndefined(); + }); + + it('should return empty array from treeSelectionToValue for undefined input (multiple)', () => { + fixture.componentRef.setInput('multiple', true); + expect(component.treeSelectionToValue(undefined)).toEqual([]); + }); + + it('should emit valueChanged on updateValue', () => { + jest.spyOn(component.valueChanged, 'emit'); + component.updateValue(OPTIONS[0]); + expect(component.valueChanged.emit).toHaveBeenCalledWith(OPTIONS[0]); + }); + + it('should invoke onChange callback on updateValue', () => { + const onChange = jest.fn(); + component.registerOnChange(onChange); + component.updateValue(OPTIONS[1]); + expect(onChange).toHaveBeenCalledWith(OPTIONS[1]); + }); + }); + + describe('ControlValueAccessor', () => { + it('should register and invoke onChange callback', () => { + const fn = jest.fn(); + component.registerOnChange(fn); + component.writeValue(OPTIONS[0]); + expect(fn).toHaveBeenCalledWith(OPTIONS[0]); + }); + + it('should register onTouched callback', () => { + const fn = jest.fn(); + component.registerOnTouched(fn); + expect(component.onTouched).toBe(fn); + }); + }); + + describe('Clear & Remove', () => { + it('should clear single value and emit undefined', () => { + jest.spyOn(component.valueChanged, 'emit'); + component.writeValue(OPTIONS[0]); + fixture.detectChanges(); + component.clear(); + expect(component.value).toBeUndefined(); + expect(component.treeSelection).toBeUndefined(); + expect(component.valueChanged.emit).toHaveBeenCalledWith(undefined); + }); + + it('should clear multiple value and emit empty array', () => { + fixture.componentRef.setInput('multiple', true); + component.writeValue([OPTIONS[0], OPTIONS[1]]); + jest.spyOn(component.valueChanged, 'emit'); + component.clear(); + expect(component.value).toEqual([]); + expect(component.treeSelection).toEqual([]); + expect(component.valueChanged.emit).toHaveBeenCalledWith([]); + }); + + it('should not emit when clear is called with no single value', () => { + jest.spyOn(component.valueChanged, 'emit'); + component.writeValue(undefined); + component.clear(); + expect(component.valueChanged.emit).not.toHaveBeenCalled(); + }); + + it('should not emit when clear is called with empty multiple value', () => { + fixture.componentRef.setInput('multiple', true); + component.writeValue([]); + jest.spyOn(component.valueChanged, 'emit'); + component.clear(); + expect(component.valueChanged.emit).not.toHaveBeenCalled(); + }); + + it('should open dropdown when openOnClear is true', () => { + fixture.componentRef.setInput('openOnClear', true); + component.writeValue(OPTIONS[0]); + component.clear(); + expect(component.isOpened).toBe(true); + }); + + it('should not open dropdown when openOnClear is false', () => { + fixture.componentRef.setInput('openOnClear', false); + component.writeValue(OPTIONS[0]); + component.clear(); + expect(component.isOpened).toBe(false); + }); + + it('should remove the given node from multiple treeSelection', () => { + fixture.componentRef.setInput('multiple', true); + component.writeValue([OPTIONS[0], OPTIONS[1]]); + const toRemove = component.treeSelection[0]; + component.remove(toRemove); + expect(component.treeSelection).toHaveLength(1); + expect(component.value).not.toContainEqual(OPTIONS[0]); + }); + + it('should be a no-op when remove is called in single mode', () => { + component.writeValue(OPTIONS[0]); + jest.spyOn(component.valueChanged, 'emit'); + component.remove(component.treeSelection); + expect(component.valueChanged.emit).not.toHaveBeenCalled(); + }); + }); + + describe('Dropdown Open / Close', () => { + it('should open dropdown via toggleOptions(true)', () => { + component.toggleOptions(true); + expect(component.isOpened).toBe(true); + }); + + it('should close dropdown via toggleOptions(false)', () => { + component.toggleOptions(true); + component.toggleOptions(false); + expect(component.isOpened).toBe(false); + }); + + it('should toggle the dropdown when toggleOptions is called without argument', () => { + expect(component.isOpened).toBe(false); + component.toggleOptions(); + expect(component.isOpened).toBe(true); + component.toggleOptions(); + expect(component.isOpened).toBe(false); + }); + + it('should not open dropdown when disabled', () => { + fixture.componentRef.setInput('disabled', true); + fixture.detectChanges(); + component.toggleOptions(true); + expect(component.isOpened).toBe(false); + }); + + it('should be a no-op when toggleOptions is called with the current state', () => { + expect(component.isOpened).toBe(false); + component.toggleOptions(false); + expect(component.isOpened).toBe(false); + }); + + it('should reset optionFocused and isArrowNavigating when opening', () => { + component.optionFocused = true; + component.isArrowNavigating = true; + component.toggleOptions(true); + expect(component.optionFocused).toBe(false); + expect(component.isArrowNavigating).toBe(false); + }); + }); + + describe('Focus / Blur', () => { + it('should emit focused on onFocus', () => { + jest.spyOn(component.focused, 'emit'); + component.onFocus(); + expect(component.focused.emit).toHaveBeenCalled(); + }); + + it('should emit blurred on onBlur', () => { + jest.spyOn(component.blurred, 'emit'); + component.onBlur(); + expect(component.blurred.emit).toHaveBeenCalled(); + }); + }); + + describe('Expand / Collapse', () => { + it('should expand all nodes that have children', () => { + component.expandAll(); + component.optionsMap.forEach((node) => { + if (node.children?.length) { + expect(node.expanded).toBe(true); + } + }); + }); + + it('should collapse all nodes that have children', () => { + component.expandAll(); + component.collapseAll(); + component.optionsMap.forEach((node) => { + if (node.children?.length) { + expect(node.expanded).toBe(false); + } + }); + }); + }); + + describe('describedBy and isRequired', () => { + it('should return null when hideDetails is true', () => { + fixture.componentRef.setInput('hideDetails', true); + expect(component.describedBy).toBeNull(); + }); + + it('should return errorId when error is set', () => { + component.error = 'Required'; + expect(component.describedBy).toBe(component.errorId); + }); + + it('should return hintId when hint is set', () => { + fixture.componentRef.setInput('hint', 'Hint text'); + expect(component.describedBy).toBe(component.hintId); + }); + + it('should return null when neither error nor hint is set', () => { + component.error = ''; + fixture.componentRef.setInput('hint', ''); + expect(component.describedBy).toBeNull(); + }); + + it('should return false for isRequired when no form control is bound', () => { + expect(component.isRequired).toBe(false); + }); + }); +}); From 94dbfaf6791096dea6ac181c921b0692833f7813 Mon Sep 17 00:00:00 2001 From: Andrei Fateev Date: Thu, 18 Jun 2026 23:14:57 +0200 Subject: [PATCH 12/13] fix no items list in virtual scroll case --- .../cps-tree-autocomplete/cps-tree-autocomplete.component.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/projects/cps-ui-kit/src/lib/components/cps-tree-autocomplete/cps-tree-autocomplete.component.ts b/projects/cps-ui-kit/src/lib/components/cps-tree-autocomplete/cps-tree-autocomplete.component.ts index 32ecda8d6..adad8a9e9 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-tree-autocomplete/cps-tree-autocomplete.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-tree-autocomplete/cps-tree-autocomplete.component.ts @@ -299,9 +299,6 @@ export class CpsTreeAutocompleteComponent extends CpsBaseTreeDropdownComponent { this.inputText = ''; this.activeSingle = false; this.updateOptions(); - setTimeout(() => { - this.recalcVirtualListHeight(); - }); } private _closeAndClear() { From 5dc7207bab2904e6ab03ba03b413c49a9c2a51e8 Mon Sep 17 00:00:00 2001 From: Andrei Fateev Date: Fri, 19 Jun 2026 10:40:49 +0200 Subject: [PATCH 13/13] address feedback from copilot --- .../cps-tree-autocomplete.component.html | 2 +- .../components/cps-tree-select/cps-tree-select.component.html | 4 ++-- .../cps-base-tree-dropdown.component.ts | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) 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 ba3f1a99f..eb94c6e3e 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 @@ -132,7 +132,7 @@ (click)="clear($event)" (mousedown)="$event.preventDefault()" (keydown.enter)="clear($event)" - (keydown.space)="clear($event)" + (keydown.space)="$event.preventDefault(); clear($event)" [style.visibility]=" persistentClear || (!persistentClear && 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 70a321702..44e1bca72 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 @@ -106,7 +106,7 @@ (click)="clear($event)" (mousedown)="$event.preventDefault()" (keydown.enter)="clear($event)" - (keydown.space)="clear($event)" + (keydown.space)="$event.preventDefault(); clear($event)" [style.visibility]=" persistentClear || (!persistentClear && @@ -127,7 +127,7 @@ [tabindex]="disabled ? -1 : 0" (mousedown)="$event.preventDefault()" (keydown.enter)="onBoxClick($event)" - (keydown.space)="onBoxClick($event)"> + (keydown.space)="$event.preventDefault(); onBoxClick($event)">