diff --git a/api-goldens/element-ng/select/index.api.md b/api-goldens/element-ng/select/index.api.md index 36d4cb9ed0..41f495c926 100644 --- a/api-goldens/element-ng/select/index.api.md +++ b/api-goldens/element-ng/select/index.api.md @@ -91,6 +91,7 @@ export class SiSelectComboboxValueComponent { // @public (undocumented) export class SiSelectComponent implements SiFormItemControl { + constructor(); readonly ariaLabel: _angular_core.InputSignal; close(): void; readonly errormessageId: _angular_core.InputSignal; diff --git a/playwright/e2e/element-examples/si-select.spec.ts b/playwright/e2e/element-examples/si-select.spec.ts index 1a22098e76..d66b596e62 100644 --- a/playwright/e2e/element-examples/si-select.spec.ts +++ b/playwright/e2e/element-examples/si-select.spec.ts @@ -54,7 +54,7 @@ test.describe('si-select', () => { const selectFormControlInput = page.getByRole('combobox', { name: 'FormControl' }).nth(1); await expect(selectFormControlInput).toBeFocused(); await expect(selectFormControlInput).toHaveAttribute('aria-expanded', 'true'); - await expect(formControlSelect.first()).toContainClass('active'); + await expect(formControlSelect).toHaveAttribute('aria-expanded', 'true'); await si.runVisualAndA11yTests('filter-opened'); await selectFormControlInput.pressSequentially('Bad'); @@ -69,7 +69,7 @@ test.describe('si-select', () => { await formControlSelect.click(); await expect(selectFormControlInput).toBeFocused(); await expect(selectFormControlInput).toHaveAttribute('aria-expanded', 'true'); - await expect(formControlSelect.first()).toContainClass('active'); + await expect(formControlSelect).toHaveAttribute('aria-expanded', 'true'); await selectFormControlInput.pressSequentially('no-value-found'); await si.runVisualAndA11yTests('filter-no-value-found'); @@ -83,7 +83,7 @@ test.describe('si-select', () => { .nth(1); await expect(selectInputWithActions).toBeFocused(); await expect(selectInputWithActions).toHaveAttribute('aria-expanded', 'true'); - await expect(selectWithActions.first()).toContainClass('active'); + await expect(selectWithActions.first()).toHaveAttribute('aria-expanded', 'true'); await selectInputWithActions.pressSequentially('New option'); await si.runVisualAndA11yTests('actions-search'); diff --git a/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select--readonly.yaml b/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select--readonly.yaml index 6b9744e9ac..784d21f5ba 100644 --- a/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select--readonly.yaml +++ b/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select--readonly.yaml @@ -1,15 +1,15 @@ - heading "Inline" [level=4] -- textbox "Inline" +- combobox "Inline" [disabled]: Fair - heading "FormControl" [level=4] -- textbox "FormControl" +- combobox "FormControl" [disabled]: Good - heading "Multi select" [level=4] -- textbox "Multi select" +- combobox "Multi select" [disabled]: Good , Fair - heading "Multi-select with groups" [level=4] -- textbox "Multi-select with groups" +- combobox "Multi-select with groups" [disabled]: Value 1.1 , Value 2.2 - heading "Select with custom template" [level=4] -- textbox "Select with custom template" +- combobox "Select with custom template" [disabled]: Beer 2 (Alc.:7%) - heading "Select with actions" [level=4] -- textbox "Select with actions" +- combobox "Select with actions" [disabled]: Select an option - text: "Control panel Current value: fair" - checkbox "Readonly" [checked] - text: Readonly diff --git a/projects/element-ng/select/select-input/si-select-input.component.ts b/projects/element-ng/select/select-input/si-select-input.component.ts index b4aabc60dc..ee5a240c79 100644 --- a/projects/element-ng/select/select-input/si-select-input.component.ts +++ b/projects/element-ng/select/select-input/si-select-input.component.ts @@ -2,15 +2,8 @@ * Copyright (c) Siemens 2016 - 2026 * SPDX-License-Identifier: MIT */ -import { - booleanAttribute, - Component, - computed, - inject, - input, - output, - TemplateRef -} from '@angular/core'; +import { Combobox } from '@angular/aria/combobox'; +import { booleanAttribute, Component, computed, inject, input, TemplateRef } from '@angular/core'; import { elementDown2 } from '@siemens/element-icons'; import { SiAutoCollapsableListModule } from '@siemens/element-ng/auto-collapsable-list'; import { addIcons, SiIconComponent } from '@siemens/element-ng/icon'; @@ -30,27 +23,10 @@ import { SelectOption } from '../si-select.types'; templateUrl: './si-select-input.component.html', styleUrl: './si-select-input.component.scss', host: { - // In readonly mode, the select needs to be announced as a textbox. - // Otherwise, screen-reader won't announce the readonly state. class: 'select focus-none dropdown-toggle d-flex align-items-center ps-4', - 'aria-autocomplete': 'none', - '[attr.role]': 'readonly() ? "textbox": "combobox"', - '[attr.aria-haspopup]': 'readonly() ? undefined : "listbox"', - '[attr.aria-expanded]': 'readonly() ? undefined : open()', - '[attr.aria-controls]': 'readonly() ? undefined : controls()', - '[attr.aria-readonly]': 'readonly()', '[attr.aria-labelledby]': 'labeledBy()', - '[attr.aria-disabled]': 'selectionStrategy.disabled()', - '[attr.tabindex]': 'selectionStrategy.disabled() ? "-1" : "0"', '[class.disabled]': 'selectionStrategy.disabled()', - '[class.active]': 'open()', - '(blur)': 'blur()', - '(click)': 'click($event)', - '(keydown.arrowDown)': 'click($event)', - '(keydown.alt.arrowDown)': 'click($event)', - '(keydown.arrowUp)': 'click($event)', - '(keydown.enter)': 'click($event)', - '(keydown.space)': 'click($event)' + '(blur)': 'blur()' } }) export class SiSelectInputComponent { @@ -70,20 +46,10 @@ export class SiSelectInputComponent { * @defaultValue null */ readonly ariaLabel = input(null); - /** - * Whether the listbox is open. - * - * @defaultValue false - */ - readonly open = input(false, { transform: booleanAttribute }); /** * Text shown when no option is selected. */ readonly placeholder = input(); - /** - * ID of the associated listbox. - */ - readonly controls = input.required(); /** * Custom template for rendering selected options. */ @@ -100,10 +66,6 @@ export class SiSelectInputComponent { */ readonly readonly = input(false, { transform: booleanAttribute }); - /** - * Emits when the user requests to open the listbox. - */ - readonly openListbox = output(); protected readonly selectionStrategy = inject>( SiSelectSelectionStrategy ); @@ -112,14 +74,11 @@ export class SiSelectInputComponent { protected readonly labeledBy = computed(() => `${this.baseId()}-aria-label ${this.labelledby()}`); protected readonly icons = addIcons({ elementDown2 }); + private readonly ngCombobox = inject(Combobox); + protected blur(): void { - if (!this.open()) { + if (!this.ngCombobox.expanded()) { this.selectionStrategy.onTouched(); } } - - protected click(event?: Event): void { - event?.preventDefault(); - this.openListbox.emit(); - } } diff --git a/projects/element-ng/select/select-list/si-select-list.component.html b/projects/element-ng/select/select-list/si-select-list.component.html index 77876ed083..b0b3fa5a0b 100644 --- a/projects/element-ng/select/select-list/si-select-list.component.html +++ b/projects/element-ng/select/select-list/si-select-list.component.html @@ -1,11 +1,18 @@ diff --git a/projects/element-ng/select/select-list/si-select-list.component.ts b/projects/element-ng/select/select-list/si-select-list.component.ts index b3f3fbe6c5..c7cc727aeb 100644 --- a/projects/element-ng/select/select-list/si-select-list.component.ts +++ b/projects/element-ng/select/select-list/si-select-list.component.ts @@ -2,43 +2,47 @@ * Copyright (c) Siemens 2016 - 2026 * SPDX-License-Identifier: MIT */ -import { CdkListbox, CdkOption, ListboxValueChangeEvent } from '@angular/cdk/listbox'; +import { ComboboxWidget } from '@angular/aria/combobox'; +import { Listbox, Option } from '@angular/aria/listbox'; import { CommonModule } from '@angular/common'; -import { Component, ElementRef, OnInit, viewChild } from '@angular/core'; +import { Component, linkedSignal, OnInit, viewChild } from '@angular/core'; import { SiTranslatePipe } from '@siemens/element-translate-ng/translate'; import { SiSelectOptionRowComponent } from '../select-option/si-select-option-row.component'; import { SiSelectGroupTemplateDirective } from '../si-select-group-template.directive'; import { SiSelectOptionRowTemplateDirective } from '../si-select-option-row-template.directive'; +import { SelectOption } from '../si-select.types'; import { SiSelectListBase } from './si-select-list.base'; @Component({ selector: 'si-select-list', imports: [ CommonModule, - CdkListbox, SiTranslatePipe, - CdkOption, SiSelectOptionRowTemplateDirective, SiSelectGroupTemplateDirective, - SiSelectOptionRowComponent + SiSelectOptionRowComponent, + Listbox, + Option, + ComboboxWidget ], templateUrl: './si-select-list.component.html' }) export class SiSelectListComponent extends SiSelectListBase implements OnInit { - private readonly listbox = viewChild.required>( - CdkListbox, - { - read: ElementRef - } - ); + /** @internal */ + readonly listbox = viewChild.required('listbox', { read: Listbox }); - override ngOnInit(): void { - super.ngOnInit(); - setTimeout(() => this.listbox().nativeElement.focus()); + protected listBoxValueChange(changeEvent: SelectOption[]): void { + if (!this.selectionStrategy.allowMultiple && changeEvent.length === 0) { + return; + } + const selectedOptions = this.rows() + .flatMap(row => (row.type === 'group' ? row.options : [row])) + .filter(option => changeEvent.includes(option)); + this.selectionStrategy.updateFromUser(selectedOptions.map(option => option.value)); } - protected listBoxValueChange(changeEvent: ListboxValueChangeEvent): void { - this.selectionStrategy.updateFromUser(changeEvent.value.slice()); - } + protected readonly selectedValues = linkedSignal[]>(() => [ + ...this.selectOptions.selectedRows() + ]); } diff --git a/projects/element-ng/select/si-select-action.directive.ts b/projects/element-ng/select/si-select-action.directive.ts index 3b48964371..317cb46148 100644 --- a/projects/element-ng/select/si-select-action.directive.ts +++ b/projects/element-ng/select/si-select-action.directive.ts @@ -2,7 +2,7 @@ * Copyright (c) Siemens 2016 - 2026 * SPDX-License-Identifier: MIT */ -import { booleanAttribute, Directive, inject, input } from '@angular/core'; +import { booleanAttribute, Directive, ElementRef, inject, input } from '@angular/core'; import { SiSelectComponent } from './si-select.component'; @@ -10,12 +10,14 @@ import { SiSelectComponent } from './si-select.component'; selector: '[siSelectAction]', host: { class: 'mx-5 my-4', - '(click)': 'close()' + '(click)': 'close()', + '(focusout)': 'focusout($event)' }, exportAs: 'si-select-action' }) export class SiSelectActionDirective { private readonly select = inject(SiSelectComponent); + private readonly elementRef = inject(ElementRef); /** * Close the select drop down on click. * @defaultValue false @@ -27,4 +29,17 @@ export class SiSelectActionDirective { this.select.close(); } } + + protected focusout(event: FocusEvent): void { + // angular aria combobox will close the overlay on focusout which can happen if + // action is disabled after click, so we need to prevent the event from propagating + // if the select is disabled and auto close is false + if ( + !this.selectActionAutoClose() && + !event.relatedTarget && + this.elementRef.nativeElement.disabled + ) { + event.stopPropagation(); + } + } } diff --git a/projects/element-ng/select/si-select.component.html b/projects/element-ng/select/si-select.component.html index 83bb518021..a18a0655e3 100644 --- a/projects/element-ng/select/si-select.component.html +++ b/projects/element-ng/select/si-select.component.html @@ -1,50 +1,56 @@ - - @if (!hasFilter()) { - - } @else { - - } + + + @if (!hasFilter()) { + + } @else { + + } + diff --git a/projects/element-ng/select/si-select.component.ts b/projects/element-ng/select/si-select.component.ts index 8d3bb7285b..5c7f4101b4 100644 --- a/projects/element-ng/select/si-select.component.ts +++ b/projects/element-ng/select/si-select.component.ts @@ -2,13 +2,14 @@ * Copyright (c) Siemens 2016 - 2026 * SPDX-License-Identifier: MIT */ -import { CdkOverlayOrigin, OverlayModule } from '@angular/cdk/overlay'; +import { Combobox, ComboboxPopup, ComboboxWidget } from '@angular/aria/combobox'; +import { OverlayModule } from '@angular/cdk/overlay'; import { + afterRenderEffect, booleanAttribute, Component, computed, contentChild, - ElementRef, inject, input, output, @@ -35,7 +36,10 @@ import { SelectGroup, SelectItem, SelectOption } from './si-select.types'; OverlayModule, SiSelectInputComponent, SiSelectListComponent, - SiSelectListHasFilterComponent + SiSelectListHasFilterComponent, + ComboboxPopup, + Combobox, + ComboboxWidget ], templateUrl: './si-select.component.html', styleUrl: './si-select.component.scss', @@ -126,13 +130,6 @@ export class SiSelectComponent implements SiFormItemControl { { read: TemplateRef } ); - private readonly trigger = viewChild.required>( - CdkOverlayOrigin, - { - read: ElementRef - } - ); - /** @internal */ readonly labelledby = computed(() => this.labelledbyInput() ?? this.id() + '-label'); /** @@ -148,7 +145,12 @@ export class SiSelectComponent implements SiFormItemControl { readonly errormessageId = input(`${this.id()}-errormessage`); protected rows: readonly SelectItem[] = []; - protected overlayWidth = 0; + protected readonly overlayWidth = computed(() => { + if (this.isOpen()) { + return this.combobox().element.getBoundingClientRect().width + 2; + } + return 0; + }); protected readonly selectionStrategy = inject(SiSelectSelectionStrategy); private backdropClicked = false; @@ -160,26 +162,32 @@ export class SiSelectComponent implements SiFormItemControl { */ readonly hasFilter = input(false, { transform: booleanAttribute }); + private readonly combobox = viewChild.required(Combobox); + private readonly siSelectList = viewChild(SiSelectListComponent); + constructor() { + afterRenderEffect(() => { + if (this.combobox()?.expanded() === true) { + this.siSelectList()?.listbox()?.scrollActiveItemIntoView(); + } + }); + } /** Opens the `si-select`. */ open(): void { if (this.readonly() || this.selectionStrategy.disabled()) { return; } - this.overlayWidth = this.trigger().nativeElement.getBoundingClientRect().width + 2; // 2px border this.isOpen.set(true); - this.openChange.emit(true); } /** Closes the `si-select`. */ close(): void { this.isOpen.set(false); if (!this.backdropClicked) { - this.trigger().nativeElement.focus(); + this.combobox().element.focus(); } else { this.backdropClicked = false; this.selectionStrategy.onTouched(); } - this.openChange.emit(false); } protected backdropClick(): void { diff --git a/projects/element-ng/select/testing/si-select-list.harness.ts b/projects/element-ng/select/testing/si-select-list.harness.ts index bacf9ce31b..e004cae68d 100644 --- a/projects/element-ng/select/testing/si-select-list.harness.ts +++ b/projects/element-ng/select/testing/si-select-list.harness.ts @@ -7,7 +7,7 @@ import { HarnessPredicate, TestKey } from '@angular/cdk/testing'; import { SiSelectListBaseHarness } from '../../select/testing/si-select-list-base.harness'; export class SiSelectListHarness extends SiSelectListBaseHarness { - static hostSelector = '.cdk-listbox'; + static hostSelector = '[ngListbox]'; static with(id: string): HarnessPredicate { return new HarnessPredicate(SiSelectListHarness, { selector: `#${CSS.escape(id)}` }); diff --git a/projects/element-ng/select/testing/si-select.harness.ts b/projects/element-ng/select/testing/si-select.harness.ts index 88a77dcf9e..8fc4931ce8 100644 --- a/projects/element-ng/select/testing/si-select.harness.ts +++ b/projects/element-ng/select/testing/si-select.harness.ts @@ -21,8 +21,12 @@ export class SiSelectHarness extends ComponentHarness { * Clicks one or multiple items by their index. In the case of multi-select, this will toggle the selection state. */ async clickItems(itemIndex: number | number[]): Promise { - await this.open('click'); - const list = await this.getList(); + let list = await this.getList(); + if (!list) { + await this.open('click'); + list = await this.getList(); + } + for (const index of [itemIndex].flat()) { await list!.getItem(index).then(item => item.click()); } @@ -32,8 +36,11 @@ export class SiSelectHarness extends ComponentHarness { * Clicks one or multiple items by their text. In the case of multi-select, this will toggle the selection state. */ async clickItemsByText(texts: string | string[]): Promise { - await this.open('click'); - const list = await this.getList(); + let list = await this.getList(); + if (!list) { + await this.open('click'); + list = await this.getList(); + } for (const text of [texts].flat()) { await list!.getItemByText(text).then(item => item.click());