Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api-goldens/element-ng/select/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export class SiSelectComboboxValueComponent {

// @public (undocumented)
export class SiSelectComponent<T> implements SiFormItemControl {
constructor();
readonly ariaLabel: _angular_core.InputSignal<string | null>;
close(): void;
readonly errormessageId: _angular_core.InputSignal<string>;
Expand Down
6 changes: 3 additions & 3 deletions playwright/e2e/element-examples/si-select.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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');
Expand All @@ -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');
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<T> {
Expand All @@ -70,20 +46,10 @@ export class SiSelectInputComponent<T> {
* @defaultValue null
*/
readonly ariaLabel = input<string | null>(null);
/**
* Whether the listbox is open.
*
* @defaultValue false
*/
readonly open = input(false, { transform: booleanAttribute });
/**
* Text shown when no option is selected.
*/
readonly placeholder = input<TranslatableString>();
/**
* ID of the associated listbox.
*/
readonly controls = input.required<string>();
/**
* Custom template for rendering selected options.
*/
Expand All @@ -100,10 +66,6 @@ export class SiSelectInputComponent<T> {
*/
readonly readonly = input(false, { transform: booleanAttribute });

/**
* Emits when the user requests to open the listbox.
*/
readonly openListbox = output<void>();
protected readonly selectionStrategy = inject<SiSelectSelectionStrategy<T>>(
SiSelectSelectionStrategy<T>
);
Expand All @@ -112,14 +74,11 @@ export class SiSelectInputComponent<T> {
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();
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
<div
cdkListbox
#listbox="ngListbox"
ngListbox
ngComboboxWidget
focusMode="activedescendant"
selectionMode="explicit"
class="dropdown-menu-scroller focus-none"
[tabindex]="-1"
[id]="baseId() + '-listbox'"
[cdkListboxMultiple]="selectionStrategy.allowMultiple"
[cdkListboxValue]="selectionStrategy.arrayValue()"
[multi]="selectionStrategy.allowMultiple"
[softDisabled]="false"
[attr.aria-labelledby]="baseId() + '-aria-label' + ' ' + labelledby()"
(cdkListboxValueChange)="listBoxValueChange($event)"
[activeDescendant]="listbox.activeDescendant()"
[(value)]="selectedValues"
(valueChange)="listBoxValueChange($event)"
(click)="closeOverlayIfSingle()"
(keydown.enter)="closeOverlayIfSingle()"
(keydown.space)="closeOverlayIfSingle()"
Expand Down Expand Up @@ -41,14 +48,14 @@
}
<ng-template #optionRowTemplate let-option siSelectOptionRowTemplate>
<si-select-option-row
#cdkOption="cdkOption"
#ngOption="ngOption"
ngOption
[value]="option"
[option]="option"
[optionTemplate]="optionTemplate()"
[cdkOption]="option.value"
[cdkOptionDisabled]="!!option.disabled"
[cdkOptionTypeaheadLabel]="(option.typeaheadLabel | translate)!"
[class.active]="cdkOption.isActive()"
[selected]="cdkOption.isSelected()"
[disabled]="option.disabled"
[class.active]="ngOption.active()"
[selected]="ngOption.selected()"
/>
</ng-template>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> extends SiSelectListBase<T> implements OnInit {
private readonly listbox = viewChild.required<CdkListbox, ElementRef<HTMLUListElement>>(
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<T>[]): 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<T>): void {
this.selectionStrategy.updateFromUser(changeEvent.value.slice());
}
protected readonly selectedValues = linkedSignal<SelectOption<T>[]>(() => [
...this.selectOptions.selectedRows()
]);
}
19 changes: 17 additions & 2 deletions projects/element-ng/select/si-select-action.directive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,22 @@
* 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';

@Directive({
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
Expand All @@ -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();
}
}
}
Loading
Loading