From f640b5fa4ad88ad4418bab50addebb9e44cd5ee5 Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Wed, 1 Oct 2025 14:41:10 -0700 Subject: [PATCH] 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 --- .../button-toggle/button-toggle.spec.ts | 87 ++++++++++++++++++- src/material/button-toggle/button-toggle.ts | 18 +++- 2 files changed, 100 insertions(+), 5 deletions(-) diff --git a/src/material/button-toggle/button-toggle.spec.ts b/src/material/button-toggle/button-toggle.spec.ts index 6a1177dcee73..3769e1c68d0b 100644 --- a/src/material/button-toggle/button-toggle.spec.ts +++ b/src/material/button-toggle/button-toggle.spec.ts @@ -1,15 +1,24 @@ -import {createKeyboardEvent, dispatchEvent, dispatchMouseEvent} from '@angular/cdk/testing/private'; import {DOWN_ARROW, LEFT_ARROW, RIGHT_ARROW, UP_ARROW} from '@angular/cdk/keycodes'; -import {Component, DebugElement, QueryList, ViewChild, ViewChildren} from '@angular/core'; +import {createKeyboardEvent, dispatchEvent, dispatchMouseEvent} from '@angular/cdk/testing/private'; +import { + Component, + DebugElement, + QueryList, + signal, + viewChild, + ViewChild, + ViewChildren, +} from '@angular/core'; import { ComponentFixture, - TestBed, fakeAsync, flush, + TestBed, tick, waitForAsync, } from '@angular/core/testing'; import {FormControl, FormsModule, NgModel, ReactiveFormsModule} from '@angular/forms'; +import {Control, disabled, form} from '@angular/forms/signals'; import {By} from '@angular/platform-browser'; import { MAT_BUTTON_TOGGLE_DEFAULT_OPTIONS, @@ -318,6 +327,43 @@ describe('MatButtonToggle with forms', () => { })); }); +describe('MatButtonToggle with signal forms', () => { + it('should sync single-select value', () => { + const fixture = TestBed.createComponent(SignalFormsButtonToggle); + TestBed.tick(); + expect(fixture.componentInstance.group().value).toBe('two'); + fixture.nativeElement.querySelector('.mat-button-toggle-button').click(); + TestBed.tick(); + expect(fixture.componentInstance.group().value).toBe('one'); + }); + + it('should sync multi-select value', () => { + const fixture = TestBed.createComponent(SignalFormsMultiButtonToggle); + TestBed.tick(); + expect(fixture.componentInstance.group().value).toEqual(['two']); + fixture.nativeElement.querySelector('.mat-button-toggle-button').click(); + TestBed.tick(); + expect(fixture.componentInstance.group().value).toEqual(['two', 'one']); + }); + + it('should sync disabled state', () => { + const fixture = TestBed.createComponent(SignalFormsButtonToggle); + TestBed.tick(); + expect(fixture.componentInstance.group().disabled).toBe(false); + fixture.componentInstance.isDisabled.set(true); + TestBed.tick(); + expect(fixture.componentInstance.group().disabled).toBe(true); + }); + + it('should sync name', () => { + const fixture = TestBed.createComponent(SignalFormsButtonToggle); + TestBed.tick(); + expect(fixture.componentInstance.group().name).toMatch( + fixture.componentInstance.field().name(), + ); + }); +}); + describe('MatButtonToggle without forms', () => { describe('inside of an exclusive selection group', () => { let fixture: ComponentFixture; @@ -1151,6 +1197,41 @@ describe('MatButtonToggle without forms', () => { }); }); +@Component({ + template: ` + + @for (opt of options; track $index) { + {{opt}} + } + + `, + imports: [MatButtonToggle, MatButtonToggleGroup, Control], +}) +class SignalFormsButtonToggle { + options = ['one', 'two', 'three']; + isDisabled = signal(false); + field = form(signal('two'), p => { + disabled(p, this.isDisabled); + }); + group = viewChild.required(MatButtonToggleGroup); +} + +@Component({ + template: ` + + @for (opt of options; track $index) { + {{opt}} + } + + `, + imports: [MatButtonToggle, MatButtonToggleGroup, Control], +}) +class SignalFormsMultiButtonToggle { + options = ['one', 'two', 'three']; + field = form(signal(['two'])); + group = viewChild.required(MatButtonToggleGroup); +} + @Component({ template: ` ; + private _cachedSignalFormsControl: Control | null; + private get _signalFormsControl() { + // Lazily inject to avoid circular DI dependency. + this._cachedSignalFormsControl = this._injector.get(Control, null, { + optional: true, + self: true, + }); + return this._cachedSignalFormsControl; + } + /** * 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 @@ -178,7 +191,8 @@ export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, After /** `name` attribute for the underlying `input` element. */ @Input() get name(): string { - return this._name; + // When using signal forms, prefer the signal forms name. + return this._signalFormsControl?.state().name() ?? this._name; } set name(value: string) { this._name = value;