diff --git a/docs/guides/migration.en.md b/docs/guides/migration.en.md index 67db40028..8054b4138 100644 --- a/docs/guides/migration.en.md +++ b/docs/guides/migration.en.md @@ -654,6 +654,21 @@ group.emitChangeEvent(toggle); **`markForCheck()` on a toggle is no longer called by the library.** A toggle derives `checked` and `disabled` from signals owned by its group and re-renders on its own. The method is kept for back-compatibility. +**A `value` that matches no toggle is kept instead of being dropped.** The group used to report whatever was selected, so an assignment made before the toggles were rendered — or naming a toggle that never appears — came back out as an empty selection, wiping a `[(value)]` model and leaving an `NG0100` behind. The assigned value now stays reported until a toggle takes it, the same contract `KbqRadioGroup` documents, which means it is applied to a toggle rendered later: + +```html + + + @if (show()) { + Blue + } + +``` + +It follows that `value` can name a toggle `selected` does not hold — `selected` only ever reports toggles that exist, so it stays `null` (or `[]`) while the value waits. Code that read `group.value` as proof of a selection has to check `group.selected` instead. A user interaction replaces the waiting value, and so does the toggle holding it leaving the selection. + +**`valueChange` no longer echoes a value that was just assigned.** It fires when the group's value actually changes, not for every write: assigning what the group already reports, or what it answers with unchanged, emits nothing. That is what stops a two-way binding from being written back over. Code that used `(valueChange)` as an "assignment happened" signal, or a test counting emissions during init, needs re-checking — `(change)` still fires per interaction. + **The group implements `OnDestroy` and no longer emits after teardown.** A selected toggle schedules its own removal from the selection on a microtask, which used to outlive the group and reach it with a `valueChange` once the whole group had already been destroyed. The group ignores that late sync now. A test asserting the old emission, or code that relied on it to clean up after a destroyed group, needs re-checking. **Styles.** The keyboard-focus `border-color` is set by the theme alone, from `--kbq-button-toggle-item-states-focused-outline`; the structural stylesheet no longer declares it from the raw `--kbq-states-line-focus-theme` token, so overriding the component token works regardless of import order. The theme also stopped targeting `.kbq-icon-button`, a class `KbqButton` never emitted, in favour of `.kbq-button-icon`. diff --git a/docs/guides/migration.ru.md b/docs/guides/migration.ru.md index f580e48d9..c131817ee 100644 --- a/docs/guides/migration.ru.md +++ b/docs/guides/migration.ru.md @@ -654,6 +654,21 @@ group.emitChangeEvent(toggle); **Библиотека больше не вызывает `markForCheck()` у кнопки.** Кнопка выводит `checked` и `disabled` из сигналов группы и перерисовывается сама. Метод оставлен для обратной совместимости. +**`value`, которому не соответствует ни одна кнопка, сохраняется, а не отбрасывается.** Раньше группа сообщала только то, что выбрано, поэтому значение, присвоенное до того, как кнопки отрисованы, — или указывающее на кнопку, которая так и не появится, — возвращалось наружу пустой выборкой, затирая модель `[(value)]` и оставляя `NG0100`. Теперь присвоенное значение остаётся тем, что группа сообщает, пока его не заберёт кнопка, — тот же контракт, что описан у `KbqRadioGroup`, — а значит, оно применяется к кнопке, которая появится позже: + +```html + + + @if (show()) { + Blue + } + +``` + +Отсюда следует, что `value` может указывать на кнопку, которой нет в `selected`: `selected` сообщает только существующие кнопки, поэтому пока значение ждёт свою кнопку, там остаётся `null` (или `[]`). Код, считавший `group.value` доказательством наличия выборки, должен проверять `group.selected`. Действие пользователя заменяет ожидающее значение — как и выход из выборки кнопки, которая его держала. + +**`valueChange` больше не повторяет только что присвоенное значение.** Событие отправляется, когда значение группы действительно меняется, а не на каждую запись: присвоение того, что группа уже сообщает, или того, что она возвращает без изменений, не отправляет ничего. Именно это не даёт перезаписать двустороннюю привязку. Код, использовавший `(valueChange)` как признак «было присвоение», и тест, считающий отправки во время инициализации, нужно перепроверить — `(change)` по-прежнему отправляется на каждое действие пользователя. + **Группа реализует `OnDestroy` и больше не отправляет события после разрушения.** Выбранная кнопка планирует своё удаление из выборки в микрозадаче, которая раньше переживала группу и доходила до неё с `valueChange` уже после того, как вся группа была уничтожена. Теперь группа игнорирует такую запоздавшую синхронизацию. Тест, проверявший прежнюю отправку, и код, который на неё опирался при очистке после разрушенной группы, нужно перепроверить. **Стили.** Цвет рамки при фокусе с клавиатуры задаёт только тема, из токена `--kbq-button-toggle-item-states-focused-outline`; структурные стили больше не объявляют его из сырого `--kbq-states-line-focus-theme`, поэтому переопределение токена компонента работает независимо от порядка импортов. Тема также перестала обращаться к классу `.kbq-icon-button`, который `KbqButton` никогда не выставлял, — вместо него используется `.kbq-button-icon`. diff --git a/packages/components/button-toggle/button-toggle.component.spec.ts b/packages/components/button-toggle/button-toggle.component.spec.ts index eee9c43ed..74ae43309 100644 --- a/packages/components/button-toggle/button-toggle.component.spec.ts +++ b/packages/components/button-toggle/button-toggle.component.spec.ts @@ -29,11 +29,36 @@ describe('KbqButtonToggle with forms', () => { FormsModule, ReactiveFormsModule, ButtonToggleGroupWithNgModel, - ButtonToggleGroupWithFormControl + ButtonToggleGroupWithFormControl, + RemountedButtonToggleGroupWithFormControl ] }).compileComponents(); }); + describe('with a toggle remounted after an interaction', () => { + it('should not re-check a toggle for a value the user has moved off', () => { + const fixture = TestBed.createComponent(RemountedButtonToggleGroupWithFormControl); + + fixture.detectChanges(); + + fixture.nativeElement.querySelectorAll('button')[1].click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.control.value).toBe('green'); + + fixture.componentInstance.renderRed = false; + fixture.detectChanges(); + fixture.componentInstance.renderRed = true; + fixture.detectChanges(); + + expect(fixture.componentInstance.control.value).toBe('green'); + expect(fixture.componentInstance.toggles().map((toggle) => [toggle.value, toggle.checked])).toEqual([ + ['red', false], + ['green', true] + ]); + }); + }); + describe('using FormControl', () => { let fixture: ComponentFixture; let groupDebugElement: DebugElement; @@ -180,7 +205,11 @@ describe('KbqButtonToggle without forms', () => { FalsyButtonTogglesInsideButtonToggleGroupMultiple, ButtonToggleGroupWithInitialValue, StandaloneButtonToggle, - RepeatedButtonTogglesWithPreselectedValue + RepeatedButtonTogglesWithPreselectedValue, + ButtonToggleGroupWithValueReadBeforeIt, + MultipleButtonToggleGroupWithValueReadBeforeIt, + UnmatchedButtonToggleGroupWithValueReadBeforeIt, + ButtonToggleGroupWithPrecheckedToggle ] }).compileComponents(); }); @@ -397,6 +426,140 @@ describe('KbqButtonToggle without forms', () => { }); }); + describe('with a value read before the group is bound', () => { + const recordValueChange = (fixture: ComponentFixture): unknown[] => { + const group = fixture.debugElement + .query(By.directive(KbqButtonToggleGroup)) + .injector.get(KbqButtonToggleGroup); + const values: unknown[] = []; + + group.valueChange.subscribe((value) => values.push(value)); + + return values; + }; + + it('should preserve a two-way bound value', () => { + const fixture = TestBed.createComponent(ButtonToggleGroupWithValueReadBeforeIt); + + expect(() => fixture.detectChanges()).not.toThrow(); + expect(fixture.componentInstance.value).toBe('green'); + expect(fixture.componentInstance.group().value).toBe('green'); + expect(fixture.nativeElement.querySelector('span').title).toBe('green'); + }); + + it('should announce nothing for a value it answers unchanged', () => { + const fixture = TestBed.createComponent(ButtonToggleGroupWithValueReadBeforeIt); + const values = recordValueChange(fixture); + + fixture.detectChanges(); + + // Every emission is written back through `[(value)]`, so the count is part of the + // contract: an `undefined` among them is the unresolved selection clobbering the model. + expect(values).toEqual([]); + }); + + it('should preserve a two-way bound array value in multiple selection mode', () => { + const fixture = TestBed.createComponent(MultipleButtonToggleGroupWithValueReadBeforeIt); + const values = recordValueChange(fixture); + + expect(() => fixture.detectChanges()).not.toThrow(); + expect(fixture.componentInstance.values).toEqual(['one', 'two']); + expect(fixture.componentInstance.group().value).toEqual(['one', 'two']); + expect(values).toEqual([]); + }); + + it('should hand out a copy of the assigned array rather than the array itself', () => { + const fixture = TestBed.createComponent(MultipleButtonToggleGroupWithValueReadBeforeIt); + const assigned = fixture.componentInstance.values; + + fixture.detectChanges(); + + expect(fixture.componentInstance.group().value).not.toBe(assigned); + }); + + it('should not announce an empty selection for an unset value in multiple selection mode', () => { + const fixture = TestBed.createComponent(MultipleButtonToggleGroupWithValueReadBeforeIt); + const values = recordValueChange(fixture); + + fixture.componentInstance.values = undefined; + + expect(() => fixture.detectChanges()).not.toThrow(); + expect(fixture.componentInstance.values).toBeUndefined(); + expect(fixture.componentInstance.group().value).toEqual([]); + expect(values).toEqual([]); + }); + + it('should keep an unmatched value instead of writing the empty selection back', () => { + const fixture = TestBed.createComponent(UnmatchedButtonToggleGroupWithValueReadBeforeIt); + + expect(() => fixture.detectChanges()).not.toThrow(); + expect(fixture.componentInstance.value).toBe('missing'); + expect(fixture.componentInstance.group().value).toBe('missing'); + expect(fixture.componentInstance.group().selected).toBeNull(); + }); + + it('should apply an unmatched value to a toggle rendered later', () => { + const fixture = TestBed.createComponent(UnmatchedButtonToggleGroupWithValueReadBeforeIt); + + fixture.componentInstance.value = 'late'; + fixture.detectChanges(); + + fixture.componentInstance.renderLate = true; + fixture.detectChanges(); + + expect(fixture.componentInstance.value).toBe('late'); + expect(fixture.componentInstance.group().value).toBe('late'); + expect((fixture.componentInstance.group().selected as KbqButtonToggle).value).toBe('late'); + }); + + it('should keep a value assigned after init while no toggle is rendered', () => { + const fixture = TestBed.createComponent(UnmatchedButtonToggleGroupWithValueReadBeforeIt); + + fixture.detectChanges(); + + const values = recordValueChange(fixture); + + fixture.componentInstance.value = 'late'; + + expect(() => fixture.detectChanges()).not.toThrow(); + expect(fixture.componentInstance.value).toBe('late'); + expect(fixture.componentInstance.group().value).toBe('late'); + expect(values).toEqual([]); + }); + + it('should not announce the empty selection between two toggles of a single-selection group', () => { + const fixture = TestBed.createComponent(ButtonToggleGroupWithValueReadBeforeIt); + + fixture.detectChanges(); + + const values = recordValueChange(fixture); + + fixture.nativeElement.querySelectorAll('button')[0].click(); + fixture.detectChanges(); + + expect(values).toEqual(['red']); + expect(fixture.componentInstance.value).toBe('red'); + }); + + it('should reject a scalar assigned to a multiple selection group from a template binding', () => { + const fixture = TestBed.createComponent(MultipleButtonToggleGroupWithValueReadBeforeIt); + + fixture.componentInstance.values = 'one' as unknown as string[]; + + expect(() => fixture.detectChanges()).toThrow('Value must be an array in multiple-selection mode.'); + }); + + it('should report a toggle preselected through `checked` rather than the empty selection', () => { + const fixture = TestBed.createComponent(ButtonToggleGroupWithPrecheckedToggle); + const values = recordValueChange(fixture); + + expect(() => fixture.detectChanges()).not.toThrow(); + expect(fixture.componentInstance.value).toBe('green'); + expect(fixture.componentInstance.group().value).toBe('green'); + expect(values).toEqual(['green']); + }); + }); + describe('inside of a multiple selection group', () => { let fixture: ComponentFixture; let groupDebugElement: DebugElement; @@ -1353,6 +1516,71 @@ class ButtonToggleGroupWithInitialValue { lastEvent: KbqButtonToggleChange; } +// The group is read by a binding that runs before the one assigning its value. +@Component({ + imports: [KbqButtonModule, KbqButtonToggleModule], + template: ` + + + Value Red + Value Green + + ` +}) +class ButtonToggleGroupWithValueReadBeforeIt { + readonly group = viewChild.required(KbqButtonToggleGroup); + value = 'green'; +} + +// Two-way bound, so that an emission the group has no business making is written back and observable. +@Component({ + imports: [KbqButtonModule, KbqButtonToggleModule], + template: ` + + + Value One + Value Two + + ` +}) +class MultipleButtonToggleGroupWithValueReadBeforeIt { + readonly group = viewChild.required(KbqButtonToggleGroup); + values: string[] | undefined = ['one', 'two']; +} + +// The only toggle is rendered on demand, so that the group spends a pass with a value and no toggles. +@Component({ + imports: [KbqButtonModule, KbqButtonToggleModule], + template: ` + + + @if (renderLate) { + Value Late + } + + ` +}) +class UnmatchedButtonToggleGroupWithValueReadBeforeIt { + readonly group = viewChild.required(KbqButtonToggleGroup); + value = 'missing'; + renderLate = false; +} + +@Component({ + imports: [KbqButtonModule, KbqButtonToggleModule], + template: ` + + + Value Red + Value Green + + ` +}) +class ButtonToggleGroupWithPrecheckedToggle { + readonly group = viewChild.required(KbqButtonToggleGroup); + value: string | undefined; +} + @Component({ imports: [KbqButtonModule, KbqButtonToggleModule, FormsModule, ReactiveFormsModule], template: ` @@ -1367,6 +1595,25 @@ class ButtonToggleGroupWithFormControl { control = new UntypedFormControl(); } +// A form model is written to, never written back from, so nothing re-assigns the group when the +// user moves off the value the control was holding. +@Component({ + imports: [KbqButtonModule, KbqButtonToggleModule, FormsModule, ReactiveFormsModule], + template: ` + + @if (renderRed) { + Value Red + } + Value Green + + ` +}) +class RemountedButtonToggleGroupWithFormControl { + readonly toggles = viewChildren(KbqButtonToggle); + control = new UntypedFormControl('red'); + renderRed = true; +} + @Component({ imports: [KbqButtonModule, KbqButtonToggleModule], template: ` diff --git a/packages/components/button-toggle/button-toggle.component.ts b/packages/components/button-toggle/button-toggle.component.ts index 63e4d5258..da695e249 100644 --- a/packages/components/button-toggle/button-toggle.component.ts +++ b/packages/components/button-toggle/button-toggle.component.ts @@ -65,6 +65,23 @@ const getContentNodes = (element: Node): Node[] => (node) => node.nodeType !== Node.TEXT_NODE || !!node.textContent?.trim() ); +/** + * Whether two group values describe the same selection. An unset value and an empty selection are + * the same thing, which is what keeps a multiple-selection group from announcing `[]` over a + * `[(value)]` binding that still holds `undefined`. + */ +const sameValue = (a: unknown, b: unknown): boolean => { + if (a === b) { + return true; + } + + const toArray = (value: unknown) => (Array.isArray(value) ? value : value == null ? [] : null); + const arrayA = toArray(a); + const arrayB = toArray(b); + + return !!arrayA && !!arrayB && arrayA.length === arrayB.length && arrayA.every((item, i) => item === arrayB[i]); +}; + /** Change event object emitted by KbqButtonToggle. */ export class KbqButtonToggleChange { constructor( @@ -98,7 +115,7 @@ export class KbqButtonToggleChange { }, exportAs: 'kbqButtonToggleGroup' }) -export class KbqButtonToggleGroup implements ControlValueAccessor, OnInit, AfterContentInit, OnDestroy { +export class KbqButtonToggleGroup implements ControlValueAccessor, OnInit, OnDestroy { private _changeDetector = inject(ChangeDetectorRef); /** Whether the toggle group is vertical. */ @@ -111,10 +128,14 @@ export class KbqButtonToggleGroup implements ControlValueAccessor, OnInit, After readonly multiple = input(false, { transform: booleanAttribute }); /** - * Value of the toggle group. + * Value of the toggle group: an array in multiple-selection mode, a single value otherwise. + * + * An accessor rather than a `model()`: writing it walks the toggles to find the ones that match, + * and reading it reports the selection. `[(value)]` works all the same. * - * An accessor rather than a `model()`: reading it derives the value from the selection, and - * writing it walks the toggles to find the ones that match. `[(value)]` works all the same. + * An assigned value that corresponds to no toggle is kept and reported as is, so that it applies + * to a toggle rendered later — the same contract as `KbqRadioGroup`. It follows that `value` can + * name a toggle `selected` does not hold: `selected` only ever reports toggles that exist. */ @Input() get value(): any { @@ -123,10 +144,23 @@ export class KbqButtonToggleGroup implements ControlValueAccessor, OnInit, After set value(newValue: any) { this.setSelectionByValue(newValue); - this.valueChange.emit(this.value); + + // A write the group answers with the value it was given has nothing to announce: the binding + // slot already holds it, and echoing the group's own normalisation of it back over that slot + // — `[]` for an unset multiple-selection group — is the `NG0100` this guards. + if (sameValue(newValue, this.value)) { + this.reportedValue = this.value; + + return; + } + + this.emitValueChange(); } - /** Selected button toggles in the group: an array in multiple-selection mode, one toggle otherwise. */ + /** + * Selected button toggles in the group: an array in multiple-selection mode, one toggle otherwise. + * Only toggles that exist, so a `value` waiting for its toggle is not represented here. + */ get selected(): KbqButtonToggle | KbqButtonToggle[] | null { return this.currentSelection(); } @@ -164,15 +198,34 @@ export class KbqButtonToggleGroup implements ControlValueAccessor, OnInit, After */ private readonly selectedToggles = signal([]); - /** Computed once per selection change, so a repeated read hands back the same array reference. */ - private readonly currentValue = computed(() => { + /** Value the selection alone describes, with nothing said about an assignment waiting for its toggle. */ + private readonly selectedValue = computed(() => { const selected = this.selectedToggles(); + return this.multiple() ? selected.map((toggle) => toggle.value) : selected[0]?.value; + }); + + /** + * Computed once per selection change, so a repeated read hands back the same array reference. + * + * The selection is authoritative for the toggles it holds; whatever the assignment names beyond + * them is appended, because those toggles may still be rendered. Reporting the empty selection + * instead would push `undefined` back through a `[(value)]` binding and leave `NG0100` behind. + */ + private readonly currentValue = computed(() => { + const selected = this.selectedValue(); + const assigned = this.rawValue(); + if (this.multiple()) { - return selected.map((toggle) => toggle.value); + const values = selected as unknown[]; + const pending = (Array.isArray(assigned) ? assigned : []).filter((value) => !values.includes(value)); + + // A fresh array every time: the assigned one belongs to the consumer, and handing it + // back would let them mutate what feeds their own `[value]` binding. + return [...values, ...pending]; } - return selected[0] ? selected[0].value : undefined; + return selected !== undefined ? selected : assigned; }); private readonly currentSelection = computed(() => { @@ -204,12 +257,17 @@ export class KbqButtonToggleGroup implements ControlValueAccessor, OnInit, After private destroyed = false; /** - * Reference to the raw value that the consumer tried to assign. The real - * value will exclude any values from this one that don't correspond to a - * toggle. Useful for the cases where the value is assigned before the toggles - * have been initialized or at the same that they're being swapped out. + * Value the consumer assigned, kept for as long as it is not represented by the selection: the + * toggle it names may be created later — behind an `@if`, or simply after the assignment, which + * always lands before `ngOnInit` and so before there is a selection model to apply it to. + * + * An interaction replaces it, so a value the user has moved off cannot resurface on a toggle + * rendered afterwards. */ - private rawValue: unknown; + private readonly rawValue = signal(undefined); + + /** Value last reported through `valueChange`, so that an echo of it stays silent. */ + private reportedValue: unknown; /** * The method to be called in order to update ngModel. @@ -224,11 +282,6 @@ export class KbqButtonToggleGroup implements ControlValueAccessor, OnInit, After this.selectionModel = new SelectionModel(this.multiple(), undefined, false); } - ngAfterContentInit() { - this.selectionModel.select(...this.buttonToggles().filter((toggle) => toggle.checked)); - this.publishSelection(); - } - ngOnDestroy() { this.destroyed = true; } @@ -293,16 +346,9 @@ export class KbqButtonToggleGroup implements ControlValueAccessor, OnInit, After return; } - // Deselect the currently-selected toggle, if we're in single-selection - // mode and the button being toggled isn't selected at the moment. - if (!this.multiple() && !toggle.checked) { - const previous = this.selectedToggles()[0]; - - if (previous) { - previous.checked = false; - } - } - + // The previously selected toggle of a single-selection group is dropped by the selection + // model itself, and re-renders from the selection: unchecking it by hand would re-enter here + // and announce the empty selection it leaves behind for the length of one call. if (select) { this.selectionModel.select(toggle); } else { @@ -311,14 +357,22 @@ export class KbqButtonToggleGroup implements ControlValueAccessor, OnInit, After this.publishSelection(); + if (isUserInput) { + // The interaction is the new assignment: anything the consumer assigned before it has + // been answered, and keeping it would let it come back on a toggle rendered later. + this.rawValue.set(this.selectedValue()); + } else if (!select) { + this.dropPendingValue(toggle.value); + } + // Only emit the change event for user input. if (isUserInput) { this.emitChangeEvent(toggle); } - // Note: we emit this one no matter whether it was a user interaction, because - // it is used by Angular to sync up the two-way data binding. - this.valueChange.emit(this.value); + // Note: this one is not limited to user interactions, because it is what Angular syncs the + // two-way data binding up with — an interaction is only one of the things that move a value. + this.emitValueChange(); } /** Checks whether a button toggle is selected. */ @@ -326,17 +380,15 @@ export class KbqButtonToggleGroup implements ControlValueAccessor, OnInit, After return this.selectedToggles().includes(toggle); } - /** Determines whether a button toggle should be checked on init. */ + /** Determines whether a button toggle should be checked on init, from the value waiting for it. */ isPrechecked(toggle: KbqButtonToggle) { - if (this.rawValue === undefined) { - return false; - } + const rawValue = this.rawValue(); - if (this.multiple() && Array.isArray(this.rawValue)) { - return this.rawValue.some((value) => toggle.value != null && value === toggle.value); + if (rawValue === undefined || toggle.value == null) { + return false; } - return toggle.value === this.rawValue; + return this.multiple() && Array.isArray(rawValue) ? rawValue.includes(toggle.value) : toggle.value === rawValue; } /** Mirrors the selection model into the signal the toggles derive their state from. */ @@ -344,45 +396,56 @@ export class KbqButtonToggleGroup implements ControlValueAccessor, OnInit, After this.selectedToggles.set([...this.selectionModel.selected]); } - /** Updates the selection state of the toggles in the group based on a value. */ - private setSelectionByValue(value: any | any[]) { - this.rawValue = value; + /** + * Forgets a value that a toggle has just stopped representing. A value only waits for a toggle + * until one takes it: a toggle leaving the selection — destroyed, or unchecked from code — + * answers it, and keeping it would make it resurface on the next toggle rendered with it. + */ + private dropPendingValue(value: unknown): void { + const assigned = this.rawValue(); - if (!this.selectionModel) { - return; + if (!this.multiple()) { + if (assigned === value) { + this.rawValue.set(undefined); + } + } else if (Array.isArray(assigned) && assigned.includes(value)) { + this.rawValue.set(assigned.filter((item) => item !== value)); } + } - if (this.multiple() && value) { - if (!Array.isArray(value)) { - throw Error('Value must be an array in multiple-selection mode.'); - } + /** Announces the current value, unless it is the one already reported. */ + private emitValueChange(): void { + const value = this.value; - this.clearSelection(); - value.forEach((currentValue: any) => this.selectValue(currentValue)); - } else { - this.clearSelection(); - this.selectValue(value); + if (sameValue(this.reportedValue, value)) { + return; } - } - /** Clears the selected toggles. */ - private clearSelection() { - this.selectionModel.clear(); - this.publishSelection(); - this.buttonToggles().forEach((toggle) => (toggle.checked = false)); + this.reportedValue = value; + this.valueChange.emit(value); } - /** Selects a value if there's a toggle that corresponds to it. */ - private selectValue(value: any) { - const correspondingOption = this.buttonToggles().find((toggle) => { - return toggle.value != null && toggle.value === value; - }); + /** + * Updates the selection state of the toggles in the group based on a value. Resolved in one pass: + * walking the toggles one by one would publish an intermediate empty selection and announce it. + */ + private setSelectionByValue(value: any | any[]) { + if (this.multiple() && value && !Array.isArray(value)) { + throw Error('Value must be an array in multiple-selection mode.'); + } + + this.rawValue.set(value); - if (correspondingOption) { - correspondingOption.checked = true; - this.selectionModel.select(correspondingOption); - this.publishSelection(); + if (!this.selectionModel) { + return; } + + const values: unknown[] = this.multiple() ? (value ?? []) : [value]; + const matched = this.buttonToggles().filter((toggle) => toggle.value != null && values.includes(toggle.value)); + + this.selectionModel.clear(); + this.selectionModel.select(...(this.multiple() ? matched : matched.slice(0, 1))); + this.publishSelection(); } } @@ -461,7 +524,9 @@ export class KbqButtonToggle implements OnInit, AfterContentInit, AfterViewInit, } set checked(value: boolean) { - if (value === this._checked()) { + // Against the effective state rather than `_checked`, which a grouped toggle does not own: + // its group selects it directly, so the two drift apart the moment a value is assigned. + if (value === this.checked) { return; } @@ -642,9 +707,9 @@ export class KbqButtonToggle implements OnInit, AfterContentInit, AfterViewInit, return; } - const newChecked = this.isSingleSelector() ? true : !this._checked(); + const newChecked = this.isSingleSelector() ? true : !this.checked; - if (newChecked !== this._checked()) { + if (newChecked !== this.checked) { this._checked.set(newChecked); if (this.buttonToggleGroup) { diff --git a/tools/public_api_guard/components/button-toggle.api.md b/tools/public_api_guard/components/button-toggle.api.md index 886a66095..8b0b8fcea 100644 --- a/tools/public_api_guard/components/button-toggle.api.md +++ b/tools/public_api_guard/components/button-toggle.api.md @@ -70,7 +70,7 @@ export class KbqButtonToggleChange { } // @public -export class KbqButtonToggleGroup implements ControlValueAccessor, OnInit, AfterContentInit, OnDestroy { +export class KbqButtonToggleGroup implements ControlValueAccessor, OnInit, OnDestroy { protected readonly ariaOrientation: _angular_core.Signal<"vertical" | "horizontal" | null>; readonly buttonToggles: _angular_core.Signal; readonly change: _angular_core.OutputEmitterRef; @@ -84,8 +84,6 @@ export class KbqButtonToggleGroup implements ControlValueAccessor, OnInit, After // (undocumented) static ngAcceptInputType_disabled: unknown; // (undocumented) - ngAfterContentInit(): void; - // (undocumented) ngOnDestroy(): void; // (undocumented) ngOnInit(): void;