Skip to content

Commit f640b5f

Browse files
committed
feat(material/button-toggle): improve signal forms support
Adds support for using the `name` from signal formsm and adds tests to verify state synchronization via the `[control]` directive
1 parent 1784c52 commit f640b5f

File tree

2 files changed

+100
-5
lines changed

2 files changed

+100
-5
lines changed

src/material/button-toggle/button-toggle.spec.ts

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,24 @@
1-
import {createKeyboardEvent, dispatchEvent, dispatchMouseEvent} from '@angular/cdk/testing/private';
21
import {DOWN_ARROW, LEFT_ARROW, RIGHT_ARROW, UP_ARROW} from '@angular/cdk/keycodes';
3-
import {Component, DebugElement, QueryList, ViewChild, ViewChildren} from '@angular/core';
2+
import {createKeyboardEvent, dispatchEvent, dispatchMouseEvent} from '@angular/cdk/testing/private';
3+
import {
4+
Component,
5+
DebugElement,
6+
QueryList,
7+
signal,
8+
viewChild,
9+
ViewChild,
10+
ViewChildren,
11+
} from '@angular/core';
412
import {
513
ComponentFixture,
6-
TestBed,
714
fakeAsync,
815
flush,
16+
TestBed,
917
tick,
1018
waitForAsync,
1119
} from '@angular/core/testing';
1220
import {FormControl, FormsModule, NgModel, ReactiveFormsModule} from '@angular/forms';
21+
import {Control, disabled, form} from '@angular/forms/signals';
1322
import {By} from '@angular/platform-browser';
1423
import {
1524
MAT_BUTTON_TOGGLE_DEFAULT_OPTIONS,
@@ -318,6 +327,43 @@ describe('MatButtonToggle with forms', () => {
318327
}));
319328
});
320329

330+
describe('MatButtonToggle with signal forms', () => {
331+
it('should sync single-select value', () => {
332+
const fixture = TestBed.createComponent(SignalFormsButtonToggle);
333+
TestBed.tick();
334+
expect(fixture.componentInstance.group().value).toBe('two');
335+
fixture.nativeElement.querySelector('.mat-button-toggle-button').click();
336+
TestBed.tick();
337+
expect(fixture.componentInstance.group().value).toBe('one');
338+
});
339+
340+
it('should sync multi-select value', () => {
341+
const fixture = TestBed.createComponent(SignalFormsMultiButtonToggle);
342+
TestBed.tick();
343+
expect(fixture.componentInstance.group().value).toEqual(['two']);
344+
fixture.nativeElement.querySelector('.mat-button-toggle-button').click();
345+
TestBed.tick();
346+
expect(fixture.componentInstance.group().value).toEqual(['two', 'one']);
347+
});
348+
349+
it('should sync disabled state', () => {
350+
const fixture = TestBed.createComponent(SignalFormsButtonToggle);
351+
TestBed.tick();
352+
expect(fixture.componentInstance.group().disabled).toBe(false);
353+
fixture.componentInstance.isDisabled.set(true);
354+
TestBed.tick();
355+
expect(fixture.componentInstance.group().disabled).toBe(true);
356+
});
357+
358+
it('should sync name', () => {
359+
const fixture = TestBed.createComponent(SignalFormsButtonToggle);
360+
TestBed.tick();
361+
expect(fixture.componentInstance.group().name).toMatch(
362+
fixture.componentInstance.field().name(),
363+
);
364+
});
365+
});
366+
321367
describe('MatButtonToggle without forms', () => {
322368
describe('inside of an exclusive selection group', () => {
323369
let fixture: ComponentFixture<ButtonTogglesInsideButtonToggleGroup>;
@@ -1151,6 +1197,41 @@ describe('MatButtonToggle without forms', () => {
11511197
});
11521198
});
11531199

1200+
@Component({
1201+
template: `
1202+
<mat-button-toggle-group [control]="field">
1203+
@for (opt of options; track $index) {
1204+
<mat-button-toggle [value]="opt">{{opt}}</mat-button-toggle>
1205+
}
1206+
</mat-button-toggle-group>
1207+
`,
1208+
imports: [MatButtonToggle, MatButtonToggleGroup, Control],
1209+
})
1210+
class SignalFormsButtonToggle {
1211+
options = ['one', 'two', 'three'];
1212+
isDisabled = signal(false);
1213+
field = form(signal('two'), p => {
1214+
disabled(p, this.isDisabled);
1215+
});
1216+
group = viewChild.required(MatButtonToggleGroup);
1217+
}
1218+
1219+
@Component({
1220+
template: `
1221+
<mat-button-toggle-group multiple [control]="field">
1222+
@for (opt of options; track $index) {
1223+
<mat-button-toggle [value]="opt">{{opt}}</mat-button-toggle>
1224+
}
1225+
</mat-button-toggle-group>
1226+
`,
1227+
imports: [MatButtonToggle, MatButtonToggleGroup, Control],
1228+
})
1229+
class SignalFormsMultiButtonToggle {
1230+
options = ['one', 'two', 'three'];
1231+
field = form(signal(['two']));
1232+
group = viewChild.required(MatButtonToggleGroup);
1233+
}
1234+
11541235
@Component({
11551236
template: `
11561237
<mat-button-toggle-group

src/material/button-toggle/button-toggle.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@ import {SelectionModel} from '@angular/cdk/collections';
1212
import {
1313
DOWN_ARROW,
1414
ENTER,
15+
hasModifierKey,
1516
LEFT_ARROW,
1617
RIGHT_ARROW,
1718
SPACE,
1819
UP_ARROW,
19-
hasModifierKey,
2020
} from '@angular/cdk/keycodes';
2121
import {_CdkPrivateStyleLoader} from '@angular/cdk/private';
2222
import {
@@ -34,6 +34,7 @@ import {
3434
HostAttributeToken,
3535
inject,
3636
InjectionToken,
37+
Injector,
3738
Input,
3839
OnDestroy,
3940
OnInit,
@@ -45,6 +46,7 @@ import {
4546
WritableSignal,
4647
} from '@angular/core';
4748
import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';
49+
import {Control} from '@angular/forms/signals';
4850
import {_animationsDisabled, _StructuralStylesLoader, MatPseudoCheckbox, MatRipple} from '../core';
4951

5052
/**
@@ -141,12 +143,23 @@ export class MatButtonToggleChange {
141143
export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, AfterContentInit {
142144
private _changeDetector = inject(ChangeDetectorRef);
143145
private _dir = inject(Directionality, {optional: true});
146+
private _injector = inject(Injector);
144147

145148
private _multiple = false;
146149
private _disabled = false;
147150
private _disabledInteractive = false;
148151
private _selectionModel: SelectionModel<MatButtonToggle>;
149152

153+
private _cachedSignalFormsControl: Control<unknown> | null;
154+
private get _signalFormsControl() {
155+
// Lazily inject to avoid circular DI dependency.
156+
this._cachedSignalFormsControl = this._injector.get(Control, null, {
157+
optional: true,
158+
self: true,
159+
});
160+
return this._cachedSignalFormsControl;
161+
}
162+
150163
/**
151164
* Reference to the raw value that the consumer tried to assign. The real
152165
* value will exclude any values from this one that don't correspond to a
@@ -178,7 +191,8 @@ export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, After
178191
/** `name` attribute for the underlying `input` element. */
179192
@Input()
180193
get name(): string {
181-
return this._name;
194+
// When using signal forms, prefer the signal forms name.
195+
return this._signalFormsControl?.state().name() ?? this._name;
182196
}
183197
set name(value: string) {
184198
this._name = value;

0 commit comments

Comments
 (0)