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;