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(