@@ -222,6 +280,7 @@
{{ item[optionInfo] }}
@if (item[optionIcon]) {
diff --git a/projects/cps-ui-kit/src/lib/components/cps-select/cps-select.component.scss b/projects/cps-ui-kit/src/lib/components/cps-select/cps-select.component.scss
index d2f607b7..7b8ab99a 100644
--- a/projects/cps-ui-kit/src/lib/components/cps-select/cps-select.component.scss
+++ b/projects/cps-ui-kit/src/lib/components/cps-select/cps-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;
@@ -11,7 +13,8 @@ $option-hover-background: var(--cps-color-highlight-hover);
$selected-option-background: var(--cps-color-highlight-selected);
$option-highlight-background: var(--cps-color-highlight-active);
$option-highlight-selected-background: var(--cps-color-highlight-selected-dark);
-$select-option-info-color: var(--cps-color-text-light);
+$select-option-info-color: var(--cps-color-text-medium);
+$select-selected-option-info-color: var(--cps-color-text-dark);
$select-option-value-color: var(--cps-color-text-dark);
$select-prefix-icon-color: var(--cps-color-text-dark);
$select-border-color: var(--cps-color-line-light);
@@ -31,10 +34,12 @@ $hover-transition-duration: 0.2s;
.cps-select-container {
position: relative;
+ outline: none;
+
.select-progress-bar {
position: absolute;
- bottom: 1px;
- padding: 0 1px;
+ bottom: 0.0625rem;
+ padding: 0 0.0625rem;
}
&.borderless,
@@ -47,22 +52,43 @@ $hover-transition-duration: 0.2s;
}
&.underlined {
.cps-select-box {
- border-bottom: 1px solid $select-border-color !important;
+ border-bottom: 0.0625rem solid $select-border-color !important;
}
}
- }
- &.active {
- .cps-select-box {
- border: 1px solid $color-calm;
- .cps-select-box-left {
- .prefix-icon {
- color: $color-calm;
+ &.focused {
+ .cps-select-box {
+ border: 0.0625rem solid $color-calm;
+ .cps-select-box-left {
+ .prefix-icon {
+ color: $color-calm;
+ }
}
}
- .cps-select-box-chevron {
- top: 22px;
- transform: rotate(180deg);
+ }
+
+ &.active {
+ .cps-select-box {
+ border: 0.0625rem solid $color-calm;
+ .cps-select-box-left {
+ .prefix-icon {
+ color: $color-calm;
+ }
+ }
+ .cps-select-box-chevron {
+ top: 1.375rem;
+ cps-icon {
+ transform: rotate(180deg);
+ }
+ }
+ }
+ }
+
+ &:focus-visible {
+ @include focus-ring(0, -0.0625rem, 0.25rem);
+ &::before,
+ &::after {
+ pointer-events: none;
}
}
}
@@ -75,17 +101,18 @@ $hover-transition-duration: 0.2s;
font-size: 0.875rem;
font-weight: 600;
.cps-select-label-info-circle {
- margin-left: 8px;
+ margin-left: 0.5rem;
pointer-events: all;
}
}
.persistent-clear,
.cps-select-container.focused,
+ .cps-select-container.active,
.cps-select-container:hover {
.cps-select-box {
.cps-select-box-icons {
- .cps-select-box-clear-icon {
+ .cps-select-box-clear-icon:not(:focus):not(:hover) {
cps-icon {
opacity: 0.5;
}
@@ -97,17 +124,18 @@ $hover-transition-duration: 0.2s;
.cps-select-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 $select-border-color;
+ border: 0.0625rem solid $select-border-color;
transition-duration: $hover-transition-duration;
&-placeholder {
@@ -116,19 +144,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: $select-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 {
@@ -146,7 +174,7 @@ $hover-transition-duration: 0.2s;
}
&:hover {
- border: 1px solid $color-calm;
+ border: 0.0625rem solid $color-calm;
.cps-select-box-left {
.prefix-icon {
color: $color-calm;
@@ -159,20 +187,41 @@ $hover-transition-duration: 0.2s;
.cps-select-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-select-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);
+ }
}
}
}
@@ -223,11 +272,11 @@ $hover-transition-duration: 0.2s;
.cps-select-options {
background: white;
overflow-x: hidden;
- max-height: 242px;
+ max-height: 15.125rem;
overflow-y: auto;
.cps-select-options-option {
- padding: 12px;
+ padding: 0.75rem;
justify-content: space-between;
display: flex;
cursor: pointer;
@@ -243,7 +292,7 @@ $hover-transition-duration: 0.2s;
&-left {
display: flex;
align-items: center;
- margin-right: 8px;
+ margin-right: 0.5rem;
}
&-right {
@@ -251,15 +300,15 @@ $hover-transition-duration: 0.2s;
display: flex;
align-items: center;
&-icon {
- margin-left: 8px;
+ margin-left: 0.5rem;
}
}
&-check {
background-color: transparent;
border: 0;
- width: 16px;
- height: 16px;
+ width: 1rem;
+ height: 1rem;
cursor: pointer;
display: inline-block;
vertical-align: middle;
@@ -269,21 +318,21 @@ $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);
}
}
@@ -299,6 +348,9 @@ $hover-transition-duration: 0.2s;
}
&.selected {
background: $selected-option-background;
+ .cps-select-options-option-right {
+ color: $select-selected-option-info-color;
+ }
}
&.highlighten {
background: $option-highlight-background;
@@ -309,7 +361,7 @@ $hover-transition-duration: 0.2s;
}
.select-all-option {
- border-bottom: 1px solid lightgrey;
+ border-bottom: 0.0625rem solid lightgrey;
font-weight: 600;
}
diff --git a/projects/cps-ui-kit/src/lib/components/cps-select/cps-select.component.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-select/cps-select.component.spec.ts
new file mode 100644
index 00000000..f6bf1faf
--- /dev/null
+++ b/projects/cps-ui-kit/src/lib/components/cps-select/cps-select.component.spec.ts
@@ -0,0 +1,574 @@
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import {
+ ComponentFixture,
+ TestBed,
+ fakeAsync,
+ tick
+} from '@angular/core/testing';
+import { FormsModule, ReactiveFormsModule } from '@angular/forms';
+import { By } from '@angular/platform-browser';
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { CheckOptionSelectedPipe } from '../../pipes/internal/check-option-selected.pipe';
+import { CombineLabelsPipe } from '../../pipes/internal/combine-labels.pipe';
+import { LabelByValuePipe } from '../../pipes/internal/label-by-value.pipe';
+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 { CpsSelectComponent } from './cps-select.component';
+
+const mockRootFontSizeService = {
+ fontSize: () => 16
+};
+
+const OPTIONS = [
+ { label: 'Option 1', value: 'opt1' },
+ { label: 'Option 2', value: 'opt2' },
+ { label: 'Option 3', value: 'opt3' }
+];
+
+describe('CpsSelectComponent', () => {
+ let component: CpsSelectComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [
+ FormsModule,
+ ReactiveFormsModule,
+ CpsSelectComponent,
+ NoopAnimationsModule
+ ],
+ providers: [
+ LabelByValuePipe,
+ CombineLabelsPipe,
+ CheckOptionSelectedPipe,
+ {
+ provide: CPS_ROOT_FONT_SIZE_SERVICE,
+ useValue: mockRootFontSizeService
+ }
+ ],
+ schemas: [NO_ERRORS_SCHEMA]
+ }).compileComponents();
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(CpsSelectComponent);
+ component = fixture.componentInstance;
+ fixture.componentRef.setInput('ariaLabel', 'Test select');
+ fixture.componentRef.setInput('options', OPTIONS);
+ fixture.detectChanges();
+ });
+
+ it('should create the component', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('Display', () => {
+ it('should display the label when provided', () => {
+ fixture.componentRef.setInput('label', 'Test Label');
+ fixture.detectChanges();
+ const label = fixture.debugElement.query(
+ By.css('.cps-select-label label')
+ );
+ expect(label.nativeElement.textContent).toBe('Test 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-select-label'));
+ expect(label).toBeNull();
+ });
+
+ it('should display placeholder when no value is selected', () => {
+ fixture.componentRef.setInput('placeholder', 'Pick one');
+ component.writeValue(undefined);
+ fixture.detectChanges();
+ const placeholder = fixture.debugElement.query(
+ By.css('.cps-select-box-placeholder')
+ );
+ expect(placeholder.nativeElement.textContent.trim()).toBe('Pick one');
+ });
+
+ it('should display single selected option label', () => {
+ component.writeValue(OPTIONS[1]);
+ fixture.detectChanges();
+ const singleItem = fixture.debugElement.query(By.css('.single-item'));
+ expect(singleItem.nativeElement.textContent.trim()).toBe('Option 2');
+ });
+
+ it('should display multiple selected options as chips', () => {
+ fixture.componentRef.setInput('multiple', true);
+ fixture.componentRef.setInput('chips', true);
+ component.writeValue([OPTIONS[0], OPTIONS[2]]);
+ fixture.changeDetectorRef.markForCheck();
+ fixture.detectChanges();
+ const chips = fixture.debugElement.queryAll(By.css('cps-chip'));
+ expect(chips.length).toBe(2);
+ });
+
+ it('should display hint when provided', () => {
+ fixture.componentRef.setInput('hint', 'Hint text');
+ fixture.detectChanges();
+ const hint = fixture.debugElement.query(By.css('.cps-select-hint'));
+ expect(hint.nativeElement.textContent.trim()).toBe('Hint text');
+ });
+
+ it('should display info tooltip icon when infoTooltip is provided', () => {
+ fixture.componentRef.setInput('label', 'My Label');
+ fixture.componentRef.setInput('infoTooltip', 'More info');
+ fixture.detectChanges();
+ const info = fixture.debugElement.query(
+ By.css('.cps-select-label-info-circle')
+ );
+ expect(info).toBeTruthy();
+ });
+
+ it('should apply underlined appearance class', () => {
+ fixture.componentRef.setInput('appearance', 'underlined');
+ fixture.detectChanges();
+ const container = fixture.debugElement.query(
+ By.css('.cps-select-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-select-container.borderless')
+ );
+ expect(container).toBeTruthy();
+ });
+
+ it('should apply disabled class when disabled', () => {
+ fixture.componentRef.setInput('disabled', true);
+ fixture.detectChanges();
+ const wrapper = fixture.debugElement.query(
+ By.css('.cps-select.disabled')
+ );
+ expect(wrapper).toBeTruthy();
+ });
+ });
+
+ describe('Value Handling', () => {
+ it('should write value via writeValue', () => {
+ component.writeValue(OPTIONS[0]);
+ expect(component.value).toEqual(OPTIONS[0]);
+ });
+
+ it('should initialise multiple value as empty array', () => {
+ fixture.componentRef.setInput('multiple', true);
+ component.ngOnInit();
+ expect(Array.isArray(component.value)).toBe(true);
+ });
+
+ it('should emit valueChanged when value is updated', () => {
+ jest.spyOn(component.valueChanged, 'emit');
+ component.select(OPTIONS[0], false);
+ expect(component.valueChanged.emit).toHaveBeenCalledWith(OPTIONS[0]);
+ });
+
+ it('should call onChange when value is updated', () => {
+ const onChange = jest.fn();
+ component.registerOnChange(onChange);
+ component.select(OPTIONS[1], false);
+ expect(onChange).toHaveBeenCalledWith(OPTIONS[1]);
+ });
+
+ it('should add item to multiple value on select', () => {
+ fixture.componentRef.setInput('multiple', true);
+ component.writeValue([]);
+ component.select(OPTIONS[0], false);
+ expect(component.value).toContainEqual(OPTIONS[0]);
+ });
+
+ it('should remove item from multiple value on re-select', () => {
+ fixture.componentRef.setInput('multiple', true);
+ component.writeValue([OPTIONS[0], OPTIONS[1]]);
+ component.select(OPTIONS[0], false);
+ expect(component.value).not.toContainEqual(OPTIONS[0]);
+ expect(component.value).toContainEqual(OPTIONS[1]);
+ });
+
+ it('hasSelectedValue should return false for undefined', () => {
+ component.writeValue(undefined);
+ expect(component.hasSelectedValue()).toBe(false);
+ });
+
+ it('hasSelectedValue should return false for null', () => {
+ component.writeValue(null);
+ expect(component.hasSelectedValue()).toBe(false);
+ });
+
+ it('hasSelectedValue should return true for a valid object', () => {
+ component.writeValue(OPTIONS[0]);
+ expect(component.hasSelectedValue()).toBe(true);
+ });
+ });
+
+ describe('ControlValueAccessor', () => {
+ it('should register 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('Dropdown Open / Close', () => {
+ it('should open dropdown on box click', () => {
+ const box = fixture.debugElement.query(By.css('.cps-select-box'));
+ box.nativeElement.dispatchEvent(new Event('mousedown'));
+ fixture.detectChanges();
+ expect(component.isOpened).toBe(true);
+ });
+
+ it('should not open dropdown when disabled', () => {
+ fixture.componentRef.setInput('disabled', true);
+ fixture.detectChanges();
+ const box = fixture.debugElement.query(By.css('.cps-select-box'));
+ box.nativeElement.dispatchEvent(new Event('mousedown'));
+ fixture.detectChanges();
+ expect(component.isOpened).toBe(false);
+ });
+
+ it('should close dropdown on second box click when already open', () => {
+ component.onBoxClick();
+ expect(component.isOpened).toBe(true);
+ component.onBoxClick();
+ expect(component.isOpened).toBe(true);
+ });
+
+ it('should toggle dropdown via chevron click', () => {
+ const event = new MouseEvent('mousedown');
+ jest.spyOn(event, 'stopPropagation');
+ jest.spyOn(event, 'preventDefault');
+
+ component.onChevronClick(event);
+ expect(component.isOpened).toBe(true);
+
+ component.onChevronClick(event);
+ expect(component.isOpened).toBe(false);
+ });
+
+ it('should close dropdown on blur', () => {
+ component.onBoxClick();
+ expect(component.isOpened).toBe(true);
+
+ component.onBlur();
+ expect(component.isOpened).toBe(false);
+ });
+
+ it('should set isActive true on focus', () => {
+ component.onFocus();
+ expect(component.isActive).toBe(true);
+ });
+
+ it('should set isActive false on blur', () => {
+ component.onFocus();
+ component.onBlur();
+ expect(component.isActive).toBe(false);
+ });
+
+ it('should not set isActive when disabled', () => {
+ fixture.componentRef.setInput('disabled', true);
+ fixture.detectChanges();
+ component.onFocus();
+ expect(component.isActive).toBe(false);
+ });
+
+ it('should emit focused on focus', () => {
+ jest.spyOn(component.focused, 'emit');
+ component.onFocus();
+ expect(component.focused.emit).toHaveBeenCalled();
+ });
+
+ it('should emit blurred on blur', () => {
+ jest.spyOn(component.blurred, 'emit');
+ component.onBlur();
+ expect(component.blurred.emit).toHaveBeenCalled();
+ });
+ });
+
+ describe('Keyboard Navigation', () => {
+ function keydown(code: number): KeyboardEvent {
+ const event = new KeyboardEvent('keydown', {
+ keyCode: code,
+ bubbles: true
+ } as any);
+ jest.spyOn(event, 'preventDefault');
+ return event;
+ }
+
+ it('should open dropdown on Enter when closed', () => {
+ const event = keydown(13);
+ component.onContainerKeyDown(event);
+ expect(component.isOpened).toBe(true);
+ });
+
+ it('should close dropdown on Enter when open with no highlighted option', () => {
+ component.onBoxClick();
+ expect(component.isOpened).toBe(true);
+ const event = keydown(13);
+ component.onContainerKeyDown(event);
+ expect(component.isOpened).toBe(false);
+ });
+
+ it('should open dropdown on Space when closed', () => {
+ const event = keydown(32);
+ component.onContainerKeyDown(event);
+ expect(component.isOpened).toBe(true);
+ });
+
+ it('should open dropdown on ArrowDown when closed', () => {
+ const event = keydown(40);
+ component.onContainerKeyDown(event);
+ expect(component.isOpened).toBe(true);
+ });
+
+ it('should open dropdown on ArrowUp when closed', () => {
+ const event = keydown(38);
+ component.onContainerKeyDown(event);
+ expect(component.isOpened).toBe(true);
+ });
+
+ it('should prevent default on Enter/Space', () => {
+ const event = keydown(13);
+ component.onContainerKeyDown(event);
+ expect(event.preventDefault).toHaveBeenCalled();
+ });
+
+ it('should not open dropdown on unrelated key', () => {
+ const event = keydown(9); // Tab
+ component.onContainerKeyDown(event);
+ expect(component.isOpened).toBe(false);
+ });
+ });
+
+ describe('Clear', () => {
+ beforeEach(() => {
+ fixture.componentRef.setInput('clearable', true);
+ fixture.detectChanges();
+ });
+
+ it('should clear single value when clear is called with a value', fakeAsync(() => {
+ component.writeValue(OPTIONS[0]);
+ fixture.componentRef.setInput('openOnClear', false);
+ component.clear();
+ tick();
+ expect(component.hasSelectedValue()).toBe(false);
+ }));
+
+ it('should clear multiple value when clear is called', fakeAsync(() => {
+ fixture.componentRef.setInput('multiple', true);
+ component.writeValue([OPTIONS[0], OPTIONS[1]]);
+ fixture.componentRef.setInput('openOnClear', false);
+ component.clear();
+ tick();
+ expect(component.value).toEqual([]);
+ }));
+
+ it('should emit valueChanged on clear', () => {
+ jest.spyOn(component.valueChanged, 'emit');
+ component.writeValue(OPTIONS[0]);
+ fixture.componentRef.setInput('openOnClear', false);
+ component.clear();
+ expect(component.valueChanged.emit).toHaveBeenCalled();
+ });
+
+ it('should not clear or refocus when value is already empty', fakeAsync(() => {
+ jest.spyOn(component.valueChanged, 'emit');
+ component.writeValue(undefined);
+ component.clear();
+ tick();
+ expect(component.valueChanged.emit).not.toHaveBeenCalled();
+ }));
+
+ it('should open dropdown on clear when openOnClear is true', fakeAsync(() => {
+ component.writeValue(OPTIONS[0]);
+ fixture.componentRef.setInput('openOnClear', true);
+ component.clear();
+ tick();
+ expect(component.isOpened).toBe(true);
+ }));
+
+ it('should show clear icon when value is set', () => {
+ component.writeValue(OPTIONS[0]);
+ fixture.detectChanges();
+ const clearIcon = fixture.debugElement.query(
+ By.css('.cps-select-box-clear-icon')
+ );
+ expect(clearIcon.nativeElement.style.visibility).toBe('visible');
+ });
+
+ it('should hide clear icon when no value', () => {
+ component.writeValue(undefined);
+ fixture.detectChanges();
+ const clearIcon = fixture.debugElement.query(
+ By.css('.cps-select-box-clear-icon')
+ );
+ expect(clearIcon.nativeElement.style.visibility).toBe('hidden');
+ });
+ });
+
+ describe('Select All', () => {
+ beforeEach(() => {
+ fixture.componentRef.setInput('multiple', true);
+ fixture.componentRef.setInput('selectAll', true);
+ component.writeValue([]);
+ fixture.changeDetectorRef.markForCheck();
+ fixture.detectChanges();
+ });
+
+ it('should select all options when toggleAll is called with empty value', () => {
+ component.toggleAll();
+ expect(component.value.length).toBe(OPTIONS.length);
+ });
+
+ it('should deselect all options when toggleAll is called with all selected', () => {
+ component.writeValue([...OPTIONS]);
+ component.toggleAll();
+ expect(component.value.length).toBe(0);
+ });
+
+ it('isSelectAllVisible should be true for multiple with selectAll and >1 options', () => {
+ expect(component.isSelectAllVisible).toBe(true);
+ });
+
+ it('isSelectAllVisible should be false for single mode', () => {
+ fixture.componentRef.setInput('multiple', false);
+ fixture.detectChanges();
+ expect(component.isSelectAllVisible).toBe(false);
+ });
+
+ it('isSelectAllVisible should be false when virtualScroll is enabled', () => {
+ fixture.componentRef.setInput('virtualScroll', true);
+ fixture.detectChanges();
+ expect(component.isSelectAllVisible).toBe(false);
+ });
+ });
+
+ describe('onBeforeOptionsHidden', () => {
+ it('should close dropdown on SCROLL', () => {
+ component.onBoxClick();
+ component.onBeforeOptionsHidden(CpsMenuHideReason.SCROLL);
+ expect(component.isOpened).toBe(false);
+ });
+
+ it('should close dropdown on RESIZE', () => {
+ component.onBoxClick();
+ component.onBeforeOptionsHidden(CpsMenuHideReason.RESIZE);
+ expect(component.isOpened).toBe(false);
+ });
+
+ it('should close dropdown on CLICK_OUTSIDE', () => {
+ component.onBoxClick();
+ component.onBeforeOptionsHidden(CpsMenuHideReason.CLICK_OUTSIDE);
+ expect(component.isOpened).toBe(false);
+ });
+
+ it('should reset highlighted index on CLICK_OUTSIDE', () => {
+ component.onBoxClick();
+ component.optionHighlightedIndex = 1;
+ component.onBeforeOptionsHidden(CpsMenuHideReason.CLICK_OUTSIDE);
+ expect(component.optionHighlightedIndex).toBe(-1);
+ });
+ });
+
+ describe('Accessibility', () => {
+ it('should set aria-label from ariaLabel input', () => {
+ fixture.componentRef.setInput('ariaLabel', 'My Select');
+ fixture.detectChanges();
+ const container = fixture.debugElement.query(
+ By.css('.cps-select-container')
+ );
+ expect(container.nativeElement.getAttribute('aria-label')).toContain(
+ 'My Select'
+ );
+ });
+
+ it('should set aria-label from label when ariaLabel is not provided', () => {
+ fixture.componentRef.setInput('ariaLabel', '');
+ fixture.componentRef.setInput('label', 'Country');
+ fixture.detectChanges();
+ const container = fixture.debugElement.query(
+ By.css('.cps-select-container')
+ );
+ expect(container.nativeElement.getAttribute('aria-label')).toContain(
+ 'Country'
+ );
+ });
+
+ it('should prefer ariaLabel over label', () => {
+ fixture.componentRef.setInput('label', 'Label');
+ fixture.componentRef.setInput('ariaLabel', 'Override');
+ fixture.detectChanges();
+ const container = fixture.debugElement.query(
+ By.css('.cps-select-container')
+ );
+ expect(container.nativeElement.getAttribute('aria-label')).toContain(
+ 'Override'
+ );
+ });
+
+ it('should log error when neither label nor ariaLabel is provided', () => {
+ jest.spyOn(console, 'error').mockImplementation(() => {});
+ fixture.componentRef.setInput('label', '');
+ fixture.componentRef.setInput('ariaLabel', '');
+ fixture.detectChanges();
+ expect(console.error).toHaveBeenCalledWith(
+ expect.stringContaining('ariaLabel')
+ );
+ });
+
+ it('should set aria-expanded to false when closed', () => {
+ const container = fixture.debugElement.query(
+ By.css('.cps-select-container')
+ );
+ expect(container.nativeElement.getAttribute('aria-expanded')).toBe(
+ 'false'
+ );
+ });
+
+ it('should set aria-expanded to true when open', () => {
+ component.onBoxClick();
+ fixture.detectChanges();
+ const container = fixture.debugElement.query(
+ By.css('.cps-select-container')
+ );
+ expect(container.nativeElement.getAttribute('aria-expanded')).toBe(
+ 'true'
+ );
+ });
+
+ it('should set aria-disabled when disabled', () => {
+ fixture.componentRef.setInput('disabled', true);
+ fixture.detectChanges();
+ const container = fixture.debugElement.query(
+ By.css('.cps-select-container')
+ );
+ expect(container.nativeElement.getAttribute('aria-disabled')).toBe(
+ 'true'
+ );
+ });
+
+ it('clear button should have role="button" and aria-label', () => {
+ fixture.componentRef.setInput('clearable', true);
+ fixture.detectChanges();
+ const clearBtn = fixture.debugElement.query(
+ By.css('.cps-select-box-clear-icon')
+ );
+ expect(clearBtn.nativeElement.getAttribute('role')).toBe('button');
+ expect(clearBtn.nativeElement.getAttribute('aria-label')).toBe(
+ 'Clear selection'
+ );
+ });
+ });
+});
diff --git a/projects/cps-ui-kit/src/lib/components/cps-select/cps-select.component.ts b/projects/cps-ui-kit/src/lib/components/cps-select/cps-select.component.ts
index 887762de..9cdbbf05 100644
--- a/projects/cps-ui-kit/src/lib/components/cps-select/cps-select.component.ts
+++ b/projects/cps-ui-kit/src/lib/components/cps-select/cps-select.component.ts
@@ -2,19 +2,32 @@ import { CommonModule } from '@angular/common';
import {
AfterViewInit,
Component,
+ computed,
ElementRef,
EventEmitter,
+ inject,
Input,
+ OnChanges,
OnDestroy,
OnInit,
Optional,
Output,
Self,
- ViewChild
+ ViewChild,
+ type SimpleChanges
} from '@angular/core';
-import { ControlValueAccessor, FormsModule, NgControl } from '@angular/forms';
+import {
+ ControlValueAccessor,
+ FormsModule,
+ NgControl,
+ Validators
+} from '@angular/forms';
import { Subscription } from 'rxjs';
import { convertSize } from '../../utils/internal/size-utils';
+import {
+ generateUniqueId,
+ getComputedLabel
+} from '../../utils/internal/accessibility-utils';
import {
CpsIconComponent,
iconSizeType,
@@ -28,7 +41,11 @@ import { CombineLabelsPipe } from '../../pipes/internal/combine-labels.pipe';
import { CheckOptionSelectedPipe } from '../../pipes/internal/check-option-selected.pipe';
import { isEqual } from 'lodash-es';
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';
+import {
+ CpsMenuComponent,
+ CpsMenuHideReason
+} from '../cps-menu/cps-menu.component';
import { Scroller, ScrollerModule } from 'primeng/scroller';
/**
@@ -37,6 +54,9 @@ import { Scroller, ScrollerModule } from 'primeng/scroller';
*/
export type CpsSelectAppearanceType = 'outlined' | 'underlined' | 'borderless';
+const VIRTUAL_SCROLL_ITEM_SIZE_REM = 2.75;
+const VIRTUAL_SCROLL_MAX_VISIBLE_ITEMS = 5.5;
+
/**
* CpsSelectComponent is used to select items from a collection.
* @group Components
@@ -61,7 +81,7 @@ export type CpsSelectAppearanceType = 'outlined' | 'underlined' | 'borderless';
styleUrls: ['./cps-select.component.scss']
})
export class CpsSelectComponent
- implements ControlValueAccessor, OnInit, AfterViewInit, OnDestroy
+ implements ControlValueAccessor, OnInit, OnChanges, AfterViewInit, OnDestroy
{
/**
* Label of the select component.
@@ -69,6 +89,12 @@ export class CpsSelectComponent
*/
@Input() label = '';
+ /**
+ * Aria label for the select component, used for accessibility, it takes precedence over label.
+ * @group Props
+ */
+ @Input() ariaLabel = '';
+
/**
* Placeholder text for the select component.
* @group Props
@@ -199,7 +225,7 @@ export class CpsSelectComponent
* Size of icon before input value.
* @group Props
*/
- @Input() prefixIconSize: iconSizeType = '18px';
+ @Input() prefixIconSize: iconSizeType = '1.125rem';
/**
* When enabled, a loading bar is displayed.
@@ -323,14 +349,29 @@ export class CpsSelectComponent
error = '';
cvtWidth = '';
isOpened = false;
+ isActive = false;
optionHighlightedIndex = -1;
+ isArrowNavigating = false;
+
+ readonly virtualScrollItemSizePx = computed(
+ () =>
+ (this._cpsRootFontSizeService?.fontSize() || 16) *
+ VIRTUAL_SCROLL_ITEM_SIZE_REM
+ );
- virtualListHeight = 242;
- virtualScrollItemSize = 44;
+ virtualListHeightRem =
+ VIRTUAL_SCROLL_ITEM_SIZE_REM * VIRTUAL_SCROLL_MAX_VISIBLE_ITEMS;
- selectBoxWidth = 0;
+ selectBoxWidthPx = 0;
resizeObserver: ResizeObserver;
+ private readonly _cpsRootFontSizeService = inject(CPS_ROOT_FONT_SIZE_SERVICE);
+
+ readonly optionsListId = generateUniqueId('cps-select-options-list');
+ readonly selectAllOptionId = generateUniqueId('cps-select-option-select-all');
+ private readonly _optionIdPrefix = generateUniqueId('cps-select-option');
+ private _optionIds = new WeakMap