Skip to content

Commit 7d5c7cf

Browse files
mmalerbakirjs
authored andcommitted
feat(forms): add DI option for classes on Field directive
Adds a DI configuration option for signal forms that allows the developer to specify CSS classes that should be automatically added by the `Field` directive based on the field's status. (cherry picked from commit c70e246)
1 parent 477df38 commit 7d5c7cf

File tree

12 files changed

+278
-8
lines changed

12 files changed

+278
-8
lines changed

goldens/public-api/forms/signals/compat/index.api.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ export class CompatValidationError<T = unknown> implements ValidationError {
5252
readonly message?: string;
5353
}
5454

55+
// @public
56+
export const NG_STATUS_CLASSES: SignalFormsConfig['classes'];
57+
5558
// (No @packageDocumentation comment for this package)
5659

5760
```

goldens/public-api/forms/signals/index.api.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { InputSignal } from '@angular/core';
1818
import { ModelSignal } from '@angular/core';
1919
import { NgControl } from '@angular/forms';
2020
import { OutputRef } from '@angular/core';
21+
import { Provider } from '@angular/core';
2122
import { ResourceRef } from '@angular/core';
2223
import { Signal } from '@angular/core';
2324
import { StandardSchemaV1 } from '@standard-schema/spec';
@@ -145,6 +146,8 @@ export class Field<T> implements ɵControl<T> {
145146
// (undocumented)
146147
readonlyCONTROL]: undefined;
147148
// (undocumented)
149+
readonly classes: (readonly [string, i0.Signal<boolean>])[];
150+
// (undocumented)
148151
readonly field: i0.InputSignal<FieldTree<T>>;
149152
protected getOrCreateNgControl(): InteropNgControl;
150153
// (undocumented)
@@ -427,6 +430,9 @@ export class PatternValidationError extends _NgValidationError {
427430
readonly pattern: RegExp;
428431
}
429432

433+
// @public
434+
export function provideSignalFormsConfig(config: SignalFormsConfig): Provider[];
435+
430436
// @public
431437
export function readonly<TValue, TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>, logic?: NoInfer<LogicFn<TValue, boolean, TPathKind>>): void;
432438

@@ -509,6 +515,13 @@ export type SchemaPathTree<TModel, TPathKind extends PathKind = PathKind.Root> =
509515
[K in keyof TModel]: MaybeSchemaPathTree<TModel[K], PathKind.Child>;
510516
} : unknown);
511517

518+
// @public
519+
export interface SignalFormsConfig {
520+
classes?: {
521+
[className: string]: (state: FieldState<unknown>) => boolean;
522+
};
523+
}
524+
512525
// @public
513526
export function standardSchemaError(issue: StandardSchemaV1.Issue, options: WithField<ValidationErrorOptions>): StandardSchemaValidationError;
514527

packages/core/src/render3/instructions/control.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ export function ɵɵcontrol<T>(value: T, sanitizer?: SanitizerFn | null): void {
9595

9696
const control = getControlDirective(tNode, lView);
9797
if (control) {
98+
updateControlClasses(lView, tNode, control);
99+
98100
if (tNode.flags & TNodeFlags.isFormValueControl) {
99101
updateCustomControl(tNode, lView, control, 'value');
100102
} else if (tNode.flags & TNodeFlags.isFormCheckboxControl) {
@@ -449,6 +451,34 @@ function isRelevantSelectMutation(mutation: MutationRecord) {
449451
return false;
450452
}
451453

454+
/**
455+
* Updates the configured classes for the control.
456+
*
457+
* @param lView The `LView` that contains the control.
458+
* @param tNode The `TNode` of the control.
459+
* @param control The `ɵControl` directive instance.
460+
*/
461+
function updateControlClasses(lView: LView, tNode: TNode, control: ɵControl<unknown>) {
462+
if (control.classes) {
463+
const bindings = getControlBindings(lView);
464+
bindings.classes ??= {};
465+
const state = control.state();
466+
const renderer = lView[RENDERER];
467+
const element = getNativeByTNode(tNode, lView) as HTMLElement;
468+
469+
for (const [className, enabled] of control.classes) {
470+
const isEnabled = enabled();
471+
if (controlClassBindingUpdated(bindings.classes, className, isEnabled)) {
472+
if (isEnabled) {
473+
renderer.addClass(element, className);
474+
} else {
475+
renderer.removeClass(element, className);
476+
}
477+
}
478+
}
479+
}
480+
}
481+
452482
/**
453483
* Updates the inputs of a custom form control component with the latest state from the `field`.
454484
*
@@ -839,6 +869,8 @@ type ControlBindingKeys = Exclude<
839869
*/
840870
type ControlBindings = {
841871
[K in ControlBindingKeys]?: unknown;
872+
} & {
873+
classes: {[className: string]: boolean};
842874
};
843875

844876
/**
@@ -893,7 +925,7 @@ function getControlBindings(lView: LView): ControlBindings {
893925
*/
894926
function controlBindingUpdated(
895927
bindings: ControlBindings,
896-
key: ControlBindingKeys,
928+
key: Exclude<ControlBindingKeys, 'classes'>,
897929
value: unknown,
898930
): boolean {
899931
const oldValue = bindings[key];
@@ -904,6 +936,27 @@ function controlBindingUpdated(
904936
return true;
905937
}
906938

939+
/**
940+
* Updates a control class binding if changed, then returns whether it was updated.
941+
*
942+
* @param bindings The control class bindings to check.
943+
* @param className The class name to check.
944+
* @param value The new value to check against.
945+
* @returns `true` if the class binding has changed.
946+
*/
947+
function controlClassBindingUpdated(
948+
bindings: {[className: string]: boolean},
949+
className: string,
950+
value: boolean,
951+
): boolean {
952+
const oldValue = bindings[className];
953+
if (Object.is(oldValue, value)) {
954+
return false;
955+
}
956+
bindings[className] = value;
957+
return true;
958+
}
959+
907960
/**
908961
* Sets a boolean attribute on an element.
909962
*

packages/core/src/render3/interfaces/control.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ export interface ɵControl<T> {
2121
/** The state of the field bound to this control. */
2222
readonly state: Signal<ɵFieldState<T>>;
2323

24+
/** Options for the control. */
25+
readonly classes: ReadonlyArray<readonly [string, Signal<boolean>]>;
26+
2427
/** A reference to the interoperable control, if one is present. */
2528
readonly ɵinteropControl: ɵInteropControl | undefined;
2629

packages/forms/signals/compat/public_api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@
1313
*/
1414
export * from './src/api/compat_form';
1515
export * from './src/api/compat_validation_error';
16+
export * from './src/api/di';
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import type {SignalFormsConfig} from '../../../src/api/di';
10+
11+
/**
12+
* A value that can be used for `SignalFormsConfig.classes` to automatically add
13+
* the `ng-*` status classes from reactive forms.
14+
*
15+
* @experimental 21.0.1
16+
*/
17+
export const NG_STATUS_CLASSES: SignalFormsConfig['classes'] = {
18+
'ng-touched': (state) => state.touched(),
19+
'ng-untouched': (state) => !state.touched(),
20+
'ng-dirty': (state) => state.dirty(),
21+
'ng-pristine': (state) => !state.dirty(),
22+
'ng-valid': (state) => state.valid(),
23+
'ng-invalid': (state) => state.invalid(),
24+
'ng-pending': (state) => state.pending(),
25+
};

packages/forms/signals/public_api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
export * from './src/api/async';
1515
export * from './src/api/control';
1616
export * from './src/api/debounce';
17+
export * from './src/api/di';
1718
export * from './src/api/field_directive';
1819
export * from './src/api/logic';
1920
export * from './src/api/metadata';
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {type Provider} from '@angular/core';
10+
import {SIGNAL_FORMS_CONFIG} from '../field/di';
11+
import type {FieldState} from './types';
12+
13+
/**
14+
* Configuration options for signal forms.
15+
*
16+
* @experimental 21.0.1
17+
*/
18+
export interface SignalFormsConfig {
19+
/** A map of CSS class names to predicate functions that determine when to apply them. */
20+
classes?: {[className: string]: (state: FieldState<unknown>) => boolean};
21+
}
22+
23+
/**
24+
* Provides configuration options for signal forms.
25+
*
26+
* @experimental 21.0.1
27+
*/
28+
export function provideSignalFormsConfig(config: SignalFormsConfig): Provider[] {
29+
return [{provide: SIGNAL_FORMS_CONFIG, useValue: config}];
30+
}

packages/forms/signals/src/api/field_directive.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
} from '@angular/core';
2121
import {NG_VALUE_ACCESSOR, NgControl} from '@angular/forms';
2222
import {InteropNgControl} from '../controls/interop_ng_control';
23+
import {SIGNAL_FORMS_CONFIG} from '../field/di';
2324
import type {FieldNode} from '../field/node';
2425
import type {FieldTree} from './types';
2526

@@ -61,6 +62,10 @@ export const FIELD = new InjectionToken<Field<unknown>>(
6162
})
6263
export class Field<T> implements ɵControl<T> {
6364
private readonly injector = inject(Injector);
65+
private config = inject(SIGNAL_FORMS_CONFIG, {optional: true});
66+
readonly classes = Object.entries(this.config?.classes ?? {}).map(
67+
([className, computation]) => [className, computed(() => computation(this.state()))] as const,
68+
);
6469
readonly field = input.required<FieldTree<T>>();
6570
readonly state = computed(() => this.field()());
6671
readonly [ɵCONTROL] = undefined;
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {InjectionToken} from '@angular/core';
10+
import type {SignalFormsConfig} from '../api/di';
11+
12+
/** Injection token for the signal forms configuration. */
13+
export const SIGNAL_FORMS_CONFIG = new InjectionToken<SignalFormsConfig>(
14+
typeof ngDevMode !== 'undefined' && ngDevMode ? 'SIGNAL_FORMS_CONFIG' : '',
15+
);

0 commit comments

Comments
 (0)