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
15 changes: 15 additions & 0 deletions docs/guides/migration.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<!-- `group.value` is 'blue' from the start; the toggle picks it up when `show` turns true -->
<kbq-button-toggle-group [(value)]="color">
@if (show()) {
<kbq-button-toggle [value]="'blue'">Blue</kbq-button-toggle>
}
</kbq-button-toggle-group>
```

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`.
Expand Down
15 changes: 15 additions & 0 deletions docs/guides/migration.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,21 @@ group.emitChangeEvent(toggle);

**Библиотека больше не вызывает `markForCheck()` у кнопки.** Кнопка выводит `checked` и `disabled` из сигналов группы и перерисовывается сама. Метод оставлен для обратной совместимости.

**`value`, которому не соответствует ни одна кнопка, сохраняется, а не отбрасывается.** Раньше группа сообщала только то, что выбрано, поэтому значение, присвоенное до того, как кнопки отрисованы, — или указывающее на кнопку, которая так и не появится, — возвращалось наружу пустой выборкой, затирая модель `[(value)]` и оставляя `NG0100`. Теперь присвоенное значение остаётся тем, что группа сообщает, пока его не заберёт кнопка, — тот же контракт, что описан у `KbqRadioGroup`, — а значит, оно применяется к кнопке, которая появится позже:

```html
<!-- `group.value` равно 'blue' с самого начала; кнопка забирает его, когда `show` станет true -->
<kbq-button-toggle-group [(value)]="color">
@if (show()) {
<kbq-button-toggle [value]="'blue'">Blue</kbq-button-toggle>
}
</kbq-button-toggle-group>
```

Отсюда следует, что `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`.
Expand Down
251 changes: 249 additions & 2 deletions packages/components/button-toggle/button-toggle.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ButtonToggleGroupWithFormControl>;
let groupDebugElement: DebugElement;
Expand Down Expand Up @@ -180,7 +205,11 @@ describe('KbqButtonToggle without forms', () => {
FalsyButtonTogglesInsideButtonToggleGroupMultiple,
ButtonToggleGroupWithInitialValue,
StandaloneButtonToggle,
RepeatedButtonTogglesWithPreselectedValue
RepeatedButtonTogglesWithPreselectedValue,
ButtonToggleGroupWithValueReadBeforeIt,
MultipleButtonToggleGroupWithValueReadBeforeIt,
UnmatchedButtonToggleGroupWithValueReadBeforeIt,
ButtonToggleGroupWithPrecheckedToggle
]
}).compileComponents();
});
Expand Down Expand Up @@ -397,6 +426,140 @@ describe('KbqButtonToggle without forms', () => {
});
});

describe('with a value read before the group is bound', () => {
const recordValueChange = (fixture: ComponentFixture<unknown>): 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', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test passes against the unfixed component — it guards nothing.

I compiled origin/main's KbqButtonToggleGroup into a second module and ran this exact fixture against it: all four assertions go green.

Pre-fix trace: the setter emits undefined and the [(value)] listener clobbers the parent — but later in the same pass the green toggle's ngOnInitisPrecheckedsyncButtonToggle emits 'green' and restores it, and the signal-dirtied view re-runs before checkNoChanges. The settled state is identical on both revisions; the transient undefined write-back — the only observable symptom — is never sampled.

Of the four new tests, only should not report the empty selection… (:413) and should stabilize an unmatched value… (:437) actually fail without the production change.

Sampling the emissions instead of the end state would fix it:

expect(values).toEqual(['green', 'green']);

That fails pre-fix ([undefined, 'green']) and pins the init emission count.

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<ButtonTogglesInsideButtonToggleGroupMultiple>;
let groupDebugElement: DebugElement;
Expand Down Expand Up @@ -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: `
<span [title]="group.value"></span>
<kbq-button-toggle-group #group="kbqButtonToggleGroup" [(value)]="value">
<kbq-button-toggle [value]="'red'">Value Red</kbq-button-toggle>
<kbq-button-toggle [value]="'green'">Value Green</kbq-button-toggle>
</kbq-button-toggle-group>
`
})
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: `
<span [title]="group.value"></span>
<kbq-button-toggle-group #group="kbqButtonToggleGroup" multiple [(value)]="values">
<kbq-button-toggle [value]="'one'">Value One</kbq-button-toggle>
<kbq-button-toggle [value]="'two'">Value Two</kbq-button-toggle>
</kbq-button-toggle-group>
`
})
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: `
<span [attr.data-value]="group.value"></span>
<kbq-button-toggle-group #group="kbqButtonToggleGroup" [(value)]="value">
@if (renderLate) {
<kbq-button-toggle [value]="'late'">Value Late</kbq-button-toggle>
}
</kbq-button-toggle-group>
`
})
class UnmatchedButtonToggleGroupWithValueReadBeforeIt {
readonly group = viewChild.required(KbqButtonToggleGroup);
value = 'missing';
renderLate = false;
}

@Component({
imports: [KbqButtonModule, KbqButtonToggleModule],
template: `
<span [title]="group.value"></span>
<kbq-button-toggle-group #group="kbqButtonToggleGroup" [(value)]="value">
<kbq-button-toggle [value]="'red'">Value Red</kbq-button-toggle>
<kbq-button-toggle [value]="'green'" [checked]="true">Value Green</kbq-button-toggle>
</kbq-button-toggle-group>
`
})
class ButtonToggleGroupWithPrecheckedToggle {
readonly group = viewChild.required(KbqButtonToggleGroup);
value: string | undefined;
}

@Component({
imports: [KbqButtonModule, KbqButtonToggleModule, FormsModule, ReactiveFormsModule],
template: `
Expand All @@ -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: `
<kbq-button-toggle-group [formControl]="control">
@if (renderRed) {
<kbq-button-toggle [value]="'red'">Value Red</kbq-button-toggle>
}
<kbq-button-toggle [value]="'green'">Value Green</kbq-button-toggle>
</kbq-button-toggle-group>
`
})
class RemountedButtonToggleGroupWithFormControl {
readonly toggles = viewChildren(KbqButtonToggle);
control = new UntypedFormControl('red');
renderRed = true;
}

@Component({
imports: [KbqButtonModule, KbqButtonToggleModule],
template: `
Expand Down
Loading