From 81772b420dcda2cbe2a8cb75e50c6da2e1ecdc68 Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Tue, 9 Dec 2025 23:19:55 -0800 Subject: [PATCH] feat(forms): pass field directive to class config Updates signal forms to pass the full `Field` directive to the class configuration functions, rather than just the state. This allows developers to take the element as well as the state into consideration when deciding classes to apply. Closes #65762 BREAKING CHANGE: The shape of `SignalFormsConfig.classes` has changed Previously each function in the `classes` map took a `FieldState`. Now it takes a `Field` directive. For example if you previously had: ``` provideSignalFormsConfig({ classes: { 'my-valid': (state) => state.valid() } }) ``` You would need to update to: ``` provideSignalFormsConfig({ classes: { 'my-valid': ({state}) => state().valid() } }) ``` (cherry picked from commit 348f149e8b06d6885f54bac4cf03a9481a8b19b7) --- goldens/public-api/forms/signals/index.api.md | 2 +- packages/forms/signals/compat/src/api/di.ts | 14 ++++----- packages/forms/signals/src/api/di.ts | 4 +-- .../forms/signals/src/api/field_directive.ts | 3 +- .../signals/test/web/field_directive.spec.ts | 31 ++++++++++++++++++- 5 files changed, 42 insertions(+), 12 deletions(-) diff --git a/goldens/public-api/forms/signals/index.api.md b/goldens/public-api/forms/signals/index.api.md index 6d7289d404f..dc19307b37b 100644 --- a/goldens/public-api/forms/signals/index.api.md +++ b/goldens/public-api/forms/signals/index.api.md @@ -520,7 +520,7 @@ export type SchemaPathTree = // @public export interface SignalFormsConfig { classes?: { - [className: string]: (state: FieldState) => boolean; + [className: string]: (state: Field) => boolean; }; } diff --git a/packages/forms/signals/compat/src/api/di.ts b/packages/forms/signals/compat/src/api/di.ts index 635eae2c532..07e1d211328 100644 --- a/packages/forms/signals/compat/src/api/di.ts +++ b/packages/forms/signals/compat/src/api/di.ts @@ -15,11 +15,11 @@ import type {SignalFormsConfig} from '../../../src/api/di'; * @experimental 21.0.1 */ export const NG_STATUS_CLASSES: SignalFormsConfig['classes'] = { - 'ng-touched': (state) => state.touched(), - 'ng-untouched': (state) => !state.touched(), - 'ng-dirty': (state) => state.dirty(), - 'ng-pristine': (state) => !state.dirty(), - 'ng-valid': (state) => state.valid(), - 'ng-invalid': (state) => state.invalid(), - 'ng-pending': (state) => state.pending(), + 'ng-touched': ({state}) => state().touched(), + 'ng-untouched': ({state}) => !state().touched(), + 'ng-dirty': ({state}) => state().dirty(), + 'ng-pristine': ({state}) => !state().dirty(), + 'ng-valid': ({state}) => state().valid(), + 'ng-invalid': ({state}) => state().invalid(), + 'ng-pending': ({state}) => state().pending(), }; diff --git a/packages/forms/signals/src/api/di.ts b/packages/forms/signals/src/api/di.ts index 44b024ca652..86e100fae68 100644 --- a/packages/forms/signals/src/api/di.ts +++ b/packages/forms/signals/src/api/di.ts @@ -8,7 +8,7 @@ import {type Provider} from '@angular/core'; import {SIGNAL_FORMS_CONFIG} from '../field/di'; -import type {FieldState} from './types'; +import type {Field} from './field_directive'; /** * Configuration options for signal forms. @@ -17,7 +17,7 @@ import type {FieldState} from './types'; */ export interface SignalFormsConfig { /** A map of CSS class names to predicate functions that determine when to apply them. */ - classes?: {[className: string]: (state: FieldState) => boolean}; + classes?: {[className: string]: (state: Field) => boolean}; } /** diff --git a/packages/forms/signals/src/api/field_directive.ts b/packages/forms/signals/src/api/field_directive.ts index d4eb9ff5353..cb605eb76ac 100644 --- a/packages/forms/signals/src/api/field_directive.ts +++ b/packages/forms/signals/src/api/field_directive.ts @@ -85,7 +85,8 @@ export class Field { private config = inject(SIGNAL_FORMS_CONFIG, {optional: true}); /** @internal */ readonly classes = Object.entries(this.config?.classes ?? {}).map( - ([className, computation]) => [className, computed(() => computation(this.state()))] as const, + ([className, computation]) => + [className, computed(() => computation(this as Field))] as const, ); /** Any `ControlValueAccessor` instances provided on the host element. */ diff --git a/packages/forms/signals/test/web/field_directive.spec.ts b/packages/forms/signals/test/web/field_directive.spec.ts index e57b1e04bd4..5309fcd4122 100644 --- a/packages/forms/signals/test/web/field_directive.spec.ts +++ b/packages/forms/signals/test/web/field_directive.spec.ts @@ -2653,7 +2653,7 @@ describe('field directive', () => { providers: [ provideSignalFormsConfig({ classes: { - 'my-invalid-class': (state) => state.invalid(), + 'my-invalid-class': ({state}) => state().invalid(), }, }), ], @@ -2770,6 +2770,35 @@ describe('field directive', () => { expect(customCtrl.classList.contains('always')).toBe(true); expect(customSubform.classList.contains('always')).toBe(false); }); + + it('should apply classes based on element', () => { + TestBed.configureTestingModule({ + providers: [ + provideSignalFormsConfig({ + classes: { + 'multiline': ({element}) => element.tagName.toLowerCase() === 'textarea', + }, + }), + ], + }); + + @Component({ + imports: [Field], + template: ` + + + `, + }) + class TestCmp { + readonly f = form(signal('')); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const input = fixture.nativeElement.querySelector('input'); + const textarea = fixture.nativeElement.querySelector('textarea'); + expect(input.classList.contains('multiline')).toBe(false); + expect(textarea.classList.contains('multiline')).toBe(true); + }); }); it('should create & bind input when a macro task is running', async () => {