diff --git a/api-goldens/element-ng/select/index.api.md b/api-goldens/element-ng/select/index.api.md index 8e767203f1..255483c404 100644 --- a/api-goldens/element-ng/select/index.api.md +++ b/api-goldens/element-ng/select/index.api.md @@ -9,7 +9,9 @@ import { ConfigurableFocusTrap } from '@angular/cdk/a11y'; import { ConfigurableFocusTrapFactory } from '@angular/cdk/a11y'; import { ControlValueAccessor } from '@angular/forms'; import { ElementRef } from '@angular/core'; +import { FormValueControl } from '@angular/forms/signals'; import { InputSignal } from '@angular/core'; +import { ModelSignal } from '@angular/core'; import { Observable } from 'rxjs'; import { OnDestroy } from '@angular/core'; import { OnInit } from '@angular/core'; @@ -148,6 +150,8 @@ export class SiSelectModule { // @public export class SiSelectMultiValueDirective extends SiSelectSelectionStrategy { readonly allowMultiple = true; + // (undocumented) + readonly value: _angular_core.ModelSignal; } // @public @@ -163,8 +167,10 @@ export class SiSelectSimpleOptionsDirective extends SiSelectOptionsS } // @public -export class SiSelectSingleValueDirective extends SiSelectSelectionStrategy { +export class SiSelectSingleValueDirective extends SiSelectSelectionStrategy { allowMultiple: boolean; + // (undocumented) + readonly value: _angular_core.ModelSignal; } // (No @packageDocumentation comment for this package) diff --git a/projects/element-ng/date-range-filter/si-relative-date.component.ts b/projects/element-ng/date-range-filter/si-relative-date.component.ts index 22dcfdc152..e613f9d437 100644 --- a/projects/element-ng/date-range-filter/si-relative-date.component.ts +++ b/projects/element-ng/date-range-filter/si-relative-date.component.ts @@ -130,8 +130,9 @@ export class SiRelativeDateComponent implements OnChanges { this.value.set(nextValue); } - protected changeUnit(newUnit: string): void { - this.unit.set(newUnit); + protected changeUnit(newUnit: string | undefined): void { + // It + this.unit.set(newUnit!); const item = this.offsetList().find(x => x.value === this.unit())!; const roundedOffset = Math.max(1, Math.round(this.value() / item.offset)); this.offset.set(roundedOffset); diff --git a/projects/element-ng/phone-number/si-phone-number-input.component.ts b/projects/element-ng/phone-number/si-phone-number-input.component.ts index 7542f13fce..6cf11e8774 100644 --- a/projects/element-ng/phone-number/si-phone-number-input.component.ts +++ b/projects/element-ng/phone-number/si-phone-number-input.component.ts @@ -337,8 +337,8 @@ export class SiPhoneNumberInputComponent this.writeValueToInput(); } - protected countryInput(num: CountryInfo): void { - this.country.set(num.isoCode); + protected countryInput(num: CountryInfo | undefined): void { + this.country.set(num?.isoCode); this.refreshValueAfterCountryChange(); this.handleChange(); } 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 407c219d7a..2a78cace7c 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 @@ -93,7 +93,7 @@ export class SiSelectInputComponent { protected blur(): void { if (!this.open()) { - this.selectionStrategy.onTouched(); + this.selectionStrategy.touch.emit(); } } diff --git a/projects/element-ng/select/selection/si-select-multi-value.directive.ts b/projects/element-ng/select/selection/si-select-multi-value.directive.ts index 14e0cc2c43..9c3c775652 100644 --- a/projects/element-ng/select/selection/si-select-multi-value.directive.ts +++ b/projects/element-ng/select/selection/si-select-multi-value.directive.ts @@ -2,8 +2,7 @@ * Copyright (c) Siemens 2016 - 2026 * SPDX-License-Identifier: MIT */ -import { Directive } from '@angular/core'; -import { NG_VALUE_ACCESSOR } from '@angular/forms'; +import { Directive, model } from '@angular/core'; import { SiSelectSelectionStrategy } from './si-select-selection-strategy'; @@ -23,14 +22,7 @@ import { SiSelectSelectionStrategy } from './si-select-selection-strategy'; @Directive({ // eslint-disable-next-line @angular-eslint/directive-selector selector: 'si-select[multi]', - providers: [ - { provide: SiSelectSelectionStrategy, useExisting: SiSelectMultiValueDirective }, - { - provide: NG_VALUE_ACCESSOR, - useExisting: SiSelectMultiValueDirective, - multi: true - } - ] + providers: [{ provide: SiSelectSelectionStrategy, useExisting: SiSelectMultiValueDirective }] }) export class SiSelectMultiValueDirective extends SiSelectSelectionStrategy { /** @@ -39,6 +31,9 @@ export class SiSelectMultiValueDirective extends SiSelectSelectionStrategy([]); + protected fromArrayValue(value: T[]): T[] { return value; } diff --git a/projects/element-ng/select/selection/si-select-selection-strategy.ts b/projects/element-ng/select/selection/si-select-selection-strategy.ts index e83314c1ed..f8310b6007 100644 --- a/projects/element-ng/select/selection/si-select-selection-strategy.ts +++ b/projects/element-ng/select/selection/si-select-selection-strategy.ts @@ -6,14 +6,15 @@ import { booleanAttribute, computed, Directive, + effect, inject, input, - Input, + ModelSignal, output, - signal, - Signal + Signal, + untracked } from '@angular/core'; -import { ControlValueAccessor } from '@angular/forms'; +import { FormValueControl } from '@angular/forms/signals'; import { SI_SELECT_OPTIONS_STRATEGY, @@ -28,24 +29,20 @@ import { '[class.disabled]': 'disabled()' } }) -export abstract class SiSelectSelectionStrategy implements ControlValueAccessor { +export abstract class SiSelectSelectionStrategy implements FormValueControl { /** * Whether the select input is disabled. * * @defaultValue false */ - // eslint-disable-next-line @angular-eslint/no-input-rename - readonly disabledInput = input(false, { alias: 'disabled', transform: booleanAttribute }); + readonly disabled = input(false, { transform: booleanAttribute }); /** * The selected value(s). */ - @Input() set value(value: IV | undefined) { - this.updateFromInput(this.toArrayValue(value)); - } + abstract readonly value: ModelSignal; - /** Emitted when the selection is changed */ - readonly valueChange = output(); + readonly touch = output(); /** * Whether the select control allows to select multiple values. @@ -61,23 +58,15 @@ export abstract class SiSelectSelectionStrategy implements Cont this.selectOptions.selectedRows().map(option => option.value) ); - /** - * Registered form callback which shall be called on blur. - * @internal - */ - onTouched: () => void = () => {}; /** @internal */ - public readonly disabled = computed(() => this.disabledInput() || this.disabledNgControl()); - protected onChange: (_: any) => void = () => {}; - private readonly disabledNgControl = signal(false); private readonly selectOptions = inject>(SI_SELECT_OPTIONS_STRATEGY); - registerOnTouched(fn: any): void { - this.onTouched = fn; - } - - registerOnChange(fn: any): void { - this.onChange = fn; + constructor() { + effect(() => { + const arrayValue = this.toArrayValue(this.value()); + // That way the effect only tracks value(), and the reads/writes of selectedRows/rows inside onValueChange no longer feed back into it. + untracked(() => this.selectOptions.onValueChange(arrayValue)); + }); } /** @@ -86,24 +75,10 @@ export abstract class SiSelectSelectionStrategy implements Cont */ updateFromUser(values: T[]): void { const parsedValue = this.fromArrayValue(values); - this.onChange(parsedValue); - this.valueChange.emit(parsedValue); - this.selectOptions.onValueChange(values); - } - - setDisabledState(isDisabled: boolean): void { - this.disabledNgControl.set(isDisabled); - } - - writeValue(obj: any): void { - this.updateFromInput(this.toArrayValue(obj)); + this.value.set(parsedValue); } protected abstract toArrayValue(value: IV | undefined): readonly T[]; protected abstract fromArrayValue(value: readonly T[]): IV; - - private updateFromInput(values: readonly T[]): void { - this.selectOptions.onValueChange(values); - } } diff --git a/projects/element-ng/select/selection/si-select-single-value.directive.ts b/projects/element-ng/select/selection/si-select-single-value.directive.ts index ce794d1b83..6be0a7393a 100644 --- a/projects/element-ng/select/selection/si-select-single-value.directive.ts +++ b/projects/element-ng/select/selection/si-select-single-value.directive.ts @@ -2,8 +2,7 @@ * Copyright (c) Siemens 2016 - 2026 * SPDX-License-Identifier: MIT */ -import { Directive } from '@angular/core'; -import { NG_VALUE_ACCESSOR } from '@angular/forms'; +import { Directive, model } from '@angular/core'; import { SiSelectSelectionStrategy } from './si-select-selection-strategy'; @@ -23,22 +22,17 @@ import { SiSelectSelectionStrategy } from './si-select-selection-strategy'; @Directive({ // eslint-disable-next-line @angular-eslint/directive-selector selector: 'si-select:not([multi])', - providers: [ - { provide: SiSelectSelectionStrategy, useExisting: SiSelectSingleValueDirective }, - { - provide: NG_VALUE_ACCESSOR, - useExisting: SiSelectSingleValueDirective, - multi: true - } - ] + providers: [{ provide: SiSelectSelectionStrategy, useExisting: SiSelectSingleValueDirective }] }) -export class SiSelectSingleValueDirective extends SiSelectSelectionStrategy { +export class SiSelectSingleValueDirective extends SiSelectSelectionStrategy { /** * {@inheritDoc SiSelectSelectionStrategy#allowMultiple} * @defaultValue false */ override allowMultiple = false; + override readonly value = model(); + protected toArrayValue(value: T | undefined): readonly T[] { return value !== undefined ? [value] : []; } diff --git a/projects/element-ng/select/si-select.component.spec.ts b/projects/element-ng/select/si-select.component.spec.ts index 6fb3f8380a..ee0df30626 100644 --- a/projects/element-ng/select/si-select.component.spec.ts +++ b/projects/element-ng/select/si-select.component.spec.ts @@ -114,7 +114,7 @@ class TestHostNumberComponent { class TestHostMultiComponent { readonly selectComponent = viewChild.required(SiSelectComponent); - readonly values = signal(undefined); + readonly values = signal([]); readonly options = signal[] | undefined>([ { type: 'option', value: 'good', label: 'Good' }, { type: 'option', value: 'average', label: 'Average' }, @@ -438,7 +438,8 @@ describe('SiSelectComponent', () => { it('updates the value in the form', async () => { await selectHarness.clickItemsByText('Poor'); - expect(component.form.controls.input.value).toBe('average'); // form will update after blur + // Angular bug: signal control interop ignores updateOn: 'blur' and commits the value immediately. + expect(component.form.controls.input.value).toBe('poor'); await selectHarness.blur(); expect(component.form.controls.input.value).toEqual('poor'); @@ -452,6 +453,8 @@ describe('SiSelectComponent', () => { it('sets the disabled state', async () => { component.form.disable(); + await fixture.whenStable(); + expect(component.valueDirective().disabled()).toBe(true); expect(await selectHarness.getTabindex()).toBe('-1'); }); diff --git a/projects/element-ng/select/si-select.component.ts b/projects/element-ng/select/si-select.component.ts index 6a29adec74..1b7efcdd0b 100644 --- a/projects/element-ng/select/si-select.component.ts +++ b/projects/element-ng/select/si-select.component.ts @@ -171,7 +171,7 @@ export class SiSelectComponent implements SiFormItemControl { this.trigger().nativeElement.focus(); } else { this.backdropClicked = false; - this.selectionStrategy.onTouched(); + this.selectionStrategy.touch.emit(); } this.openChange.emit(false); } diff --git a/src/app/examples/si-select/si-select.ts b/src/app/examples/si-select/si-select.ts index 56844b6f85..2d8771ea7c 100644 --- a/src/app/examples/si-select/si-select.ts +++ b/src/app/examples/si-select/si-select.ts @@ -159,7 +159,7 @@ export class SampleComponent { ); } - selectionChanged(value: string): void { + selectionChanged(value: string | undefined): void { const option = this.optionsList.find(o => o.type === 'option' && o.value === value); this.logEvent(`Selection: ${this.value}, '${option?.label}'`); }