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 348f149e8b)
This commit is contained in:
Miles Malerba 2025-12-09 23:19:55 -08:00 committed by Andrew Kushnir
parent 4c8fb3631d
commit 81772b420d
5 changed files with 42 additions and 12 deletions

View file

@ -520,7 +520,7 @@ export type SchemaPathTree<TModel, TPathKind extends PathKind = PathKind.Root> =
// @public
export interface SignalFormsConfig {
classes?: {
[className: string]: (state: FieldState<unknown>) => boolean;
[className: string]: (state: Field<unknown>) => boolean;
};
}

View file

@ -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(),
};

View file

@ -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<unknown>) => boolean};
classes?: {[className: string]: (state: Field<unknown>) => boolean};
}
/**

View file

@ -85,7 +85,8 @@ export class Field<T> {
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<unknown>))] as const,
);
/** Any `ControlValueAccessor` instances provided on the host element. */

View file

@ -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: `
<input [field]="f">
<textarea [field]="f"></textarea>
`,
})
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 () => {