diff --git a/playwright/cps-accessibility.spec.ts b/playwright/cps-accessibility.spec.ts
index 97cecc415..c943563a4 100644
--- a/playwright/cps-accessibility.spec.ts
+++ b/playwright/cps-accessibility.spec.ts
@@ -242,25 +242,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-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 @@
@if (label) {
@@ -21,14 +25,14 @@
}
+ [class.keyboard-focused]="isKeyboardFocused"
+ [class.persistent-clear]="persistentClear"
+ [class.borderless]="appearance === 'borderless'"
+ [class.underlined]="appearance === 'underlined'">
@if (prefixIcon) {
@@ -63,68 +67,72 @@
}
@if (multiple && !chips) {
-
+
@for (val of treeSelection; track val; let last = $last) {
+ [class.about-to-remove]="last && backspaceClickedOnce">
{{ val.label }}{{ !last ? ',' : '' }}
}
-
+
+
+
}
@if (multiple && chips) {
-
+
@for (val of treeSelection; track val; let last = $last) {
}
-
+
+
+
}
} @else {
-
+
+
}
@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)">
+ [class.arrow-navigating]="isArrowNavigating"
+ [style.width.px]="boxWidthPx"
+ (keydown)="onOptionsKeyDown($event)">
@if (!error && !hideDetails) {
-
+
{{ hint }}
}
@if (error && !hideDetails) {
-
+
{{ error }}
}
@@ -238,6 +258,17 @@
#treeAutocompleteInput
class="cps-treeautocomplete-box-input"
spellcheck="false"
+ role="combobox"
+ [disabled]="disabled"
+ 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-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 f92c077d1..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
@@ -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,17 @@ $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;
+ }
+
+ .cps-treeautocomplete-input-listitem {
+ flex-grow: 1;
+ min-width: 1.875rem;
+ display: flex;
+ align-items: center;
}
.chips-group {
@@ -191,9 +214,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 +225,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 +245,40 @@ $hover-transition-duration: 0.2s;
display: flex;
.cps-treeautocomplete-box-clear-icon {
- cursor: pointer;
display: flex;
color: $color-error;
- margin-left: 8px;
+ margin-left: 0.5rem;
+ 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;
+ margin-left: 0.5rem;
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();
}
}
}
@@ -303,9 +337,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 +356,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 +364,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 +381,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 +412,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;
}
@@ -423,6 +463,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
@@ -462,7 +507,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 {
@@ -480,6 +525,11 @@ $hover-transition-duration: 0.2s;
:hover {
color: $color-calm;
}
+ &:focus,
+ &:focus-visible {
+ outline: none;
+ box-shadow: none;
+ }
}
.p-tree
@@ -509,20 +559,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.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-autocomplete/cps-tree-autocomplete.component.ts b/projects/cps-ui-kit/src/lib/components/cps-tree-autocomplete/cps-tree-autocomplete.component.ts
index 87148a0ed..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
@@ -1,13 +1,9 @@
-import { CommonModule, DOCUMENT } from '@angular/common';
+import { NgTemplateOutlet } from '@angular/common';
import {
- AfterViewInit,
ChangeDetectorRef,
Component,
ElementRef,
- Inject,
Input,
- OnDestroy,
- OnInit,
Optional,
ViewChild
} from '@angular/core';
@@ -40,7 +36,7 @@ export type CpsTreeAutocompleteAppearanceType =
*/
@Component({
imports: [
- CommonModule,
+ NgTemplateOutlet,
FormsModule,
TreeModule,
CpsIconComponent,
@@ -53,10 +49,7 @@ export type CpsTreeAutocompleteAppearanceType =
templateUrl: './cps-tree-autocomplete.component.html',
styleUrls: ['./cps-tree-autocomplete.component.scss']
})
-export class CpsTreeAutocompleteComponent
- extends CpsBaseTreeDropdownComponent
- implements OnInit, AfterViewInit, OnDestroy
-{
+export class CpsTreeAutocompleteComponent extends CpsBaseTreeDropdownComponent {
/**
* Message if array of items is empty.
* @group Props
@@ -81,35 +74,41 @@ export class CpsTreeAutocompleteComponent
inputText = '';
backspaceClickedOnce = false;
activeSingle = false;
+ isKeyboardFocused = false;
+
+ private _mouseClicked = false;
constructor(
@Optional() public override control: NgControl,
- @Inject(DOCUMENT) private document: Document,
public override cdRef: ChangeDetectorRef
) {
super(control, cdRef);
- }
-
- override ngOnInit() {
- super.ngOnInit();
- }
-
- override ngAfterViewInit() {
this.isAutocomplete = true;
- super.ngAfterViewInit();
- }
-
- override ngOnDestroy() {
- super.ngOnDestroy();
}
override onSelectNode() {
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();
}
@@ -125,6 +124,7 @@ export class CpsTreeAutocompleteComponent
}
onBoxClick() {
+ this._mouseClicked = true;
if (!this.multiple) {
this.activeSingle = true;
if (!this.inputText) this.inputText = this._getValueLabel();
@@ -134,36 +134,68 @@ export class CpsTreeAutocompleteComponent
this.optionFocused = false;
}
- onContainerKeyDown(event: any) {
- const code = event.keyCode;
- // escape
- if (code === 27) {
+ 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') {
+ if (this.isOpened) this._closeAndClear();
+ } else if (code === 'Escape') {
this._closeAndClear();
- }
- // click down arrow
- else if (code === 40) {
- this.initArrowsNavigaton();
+ } 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);
+ }
}
}
- 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();
}
}
}
onChevronClick(event: any) {
- event.stopPropagation();
+ event.preventDefault();
if (this.isOpened) {
this._closeAndClear();
@@ -172,10 +204,19 @@ export class CpsTreeAutocompleteComponent
}
}
+ onContainerMouseDown(event: MouseEvent) {
+ const input = this.treeAutocompleteInput?.nativeElement;
+ if (event.target !== input) event.preventDefault();
+ if (input && input !== this._document.activeElement) {
+ this._mouseClicked = true;
+ this.focusInput();
+ }
+ }
+
isActive() {
return (
this.isOpened ||
- this.document.activeElement === this.treeAutocompleteInput?.nativeElement
+ this._document.activeElement === this.treeAutocompleteInput?.nativeElement
);
}
@@ -198,7 +239,7 @@ export class CpsTreeAutocompleteComponent
}
focusInput() {
- this.componentContainer?.nativeElement?.querySelector('input')?.focus();
+ this.treeAutocompleteInput?.nativeElement?.focus();
}
override focus() {
@@ -215,6 +256,7 @@ export class CpsTreeAutocompleteComponent
this.toggleOptions(true);
}
this.backspaceClickedOnce = false;
+ this.optionFocused = false;
const searchVal = (event?.target?.value || '').toLowerCase();
if (!searchVal) this.treeList.resetFilter();
@@ -253,12 +295,10 @@ export class CpsTreeAutocompleteComponent
private _clearInput() {
this.treeList.resetFilter();
+ this.treeList?.cd?.markForCheck();
this.inputText = '';
this.activeSingle = false;
this.updateOptions();
- setTimeout(() => {
- this.recalcVirtualListHeight();
- });
}
private _closeAndClear() {
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 4524438d7..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
@@ -1,12 +1,8 @@
+ [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)">
@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..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
@@ -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,18 @@ $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-left: 0.75rem;
+ padding-right: 0.5rem;
+ 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 +131,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 +161,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 +174,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 +259,15 @@ $hover-transition-duration: 0.2s;
.cps-treeselect-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;
@@ -237,7 +278,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 +286,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 +303,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 +334,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 +355,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 +392,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 +436,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 +454,11 @@ $hover-transition-duration: 0.2s;
:hover {
color: $color-calm;
}
+ &:focus,
+ &:focus-visible {
+ outline: none;
+ box-shadow: none;
+ }
}
.p-tree
@@ -431,13 +489,18 @@ $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;
+ }
+
+ *:focus-visible {
+ &::before,
+ &::after {
+ display: none !important;
+ }
}
}
}
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/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..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
@@ -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,37 @@ 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;
+ if (code === 'Tab') {
+ if (this.isOpened) this.toggleOptions(false);
+ return;
+ }
+
event.preventDefault();
- const code = event.keyCode;
- // escape
- if (code === 27) {
+
+ if (code === 'Escape') {
this.toggleOptions(false);
- }
- // click down arrow
- else if (code === 40) {
- this.initArrowsNavigaton();
+ this.componentContainer?.nativeElement?.focus();
+ } else if (code === 'Enter' || code === 'NumpadEnter' || code === 'Space') {
+ this.toggleOptions(!this.isOpened);
+ } else if (code === 'ArrowUp' || code === 'ArrowDown') {
+ const up = code === 'ArrowUp';
+ if (!this.isOpened) {
+ this.toggleOptions(true);
+ setTimeout(() => this.initArrowsNavigaton(up));
+ } else {
+ this.navigateIntoOptions(up);
+ }
}
}
}
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);
+ });
+ });
+});
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..c85463461 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,
@@ -11,11 +13,16 @@ import {
Optional,
Output,
Self,
- SimpleChanges,
- ViewChild
+ ViewChild,
+ type SimpleChanges
} 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,61 @@ export class CpsBaseTreeDropdownComponent
innerOptions: TreeNode[] = [];
optionsMap = new Map();
+
+ private _treeRefreshKey = 0;
+
+ readonly treeNodeToggleButtonPt = {
+ nodeToggleButton: { 'aria-label': 'Toggle node' }
+ };
+
+ 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 +331,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 +358,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 +481,7 @@ export class CpsBaseTreeDropdownComponent
onSelectNode() {
if (!this.multiple) {
this.toggleOptions(false);
+ this.componentContainer?.nativeElement?.focus();
}
}
@@ -411,8 +495,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 +509,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 +538,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 +586,154 @@ 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);
+ }
+
+ 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': {
+ event.preventDefault();
+ 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'
+ ) 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 +779,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 +807,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 +902,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(