refactor(core): add framework support for binding form controls (#63773)

Move most of the implementation of the `Control` directive into core
framework instructions. This allows field state changes to be propagated
to their corresponding UI controls directly during execution of a
template update block, instead of relying on `effect()`s to synchronize
each change later during the update (and too late in the case of
required inputs).

* Define a private API in `@angular/core` for signal forms to implement:
  * `ɵControl` for the `Control` directive.
  * `ɵFieldState` for the control's associated `FieldState`.
* Emit specialized instructions when compiling a `[control]` binding:
  * `ɵɵcontrolCreate` sets up the `ɵControl` directive if present,
    determines whether it's bound to a native control element or a
    custom control component, and adds the appropriate event listeners
    to notify the `ɵFieldState` of UI changes.
  * `ɵɵcontrol` propagates changes from `ɵFieldState` properties to their
    corresponding UI control properties (in additional to binding the `control`
    property itself).

PR Close #63773
This commit is contained in:
Leon Senft
2025-09-11 13:30:32 -07:00
committed by Miles Malerba
parent 42e1062bc5
commit effccffde0
23 changed files with 761 additions and 623 deletions
@@ -7,39 +7,17 @@
*/
import {
afterNextRender,
computed,
DestroyRef,
Directive,
effect,
ElementRef,
EventEmitter,
inject,
InjectionToken,
Injector,
Input,
InputSignal,
OutputEmitterRef,
OutputRef,
OutputRefSubscription,
reflectComponentType,
Renderer2,
signal,
Type,
untracked,
inject,
input,
ɵCONTROL,
ɵControl,
} from '@angular/core';
import {ControlValueAccessor, NG_VALUE_ACCESSOR, NgControl} from '@angular/forms';
import {InteropNgControl} from '../controls/interop_ng_control';
import type {FieldNode} from '../field/node';
import {
privateGetComponentInstance,
privateIsModelInput,
privateIsSignalInput,
privateRunEffect,
privateSetComponentInput as privateSetInputSignal,
} from '../util/private';
import {FormCheckboxControl, FormUiControl, FormValueControl} from './control';
import {AggregateProperty, MAX, MAX_LENGTH, MIN, MIN_LENGTH, PATTERN, REQUIRED} from './property';
import type {FieldTree} from './types';
/**
@@ -53,124 +31,38 @@ export const CONTROL = new InjectionToken<Control<unknown>>(
* Binds a form `FieldTree` to a UI control that edits it. A UI control can be one of several things:
* 1. A native HTML input or textarea
* 2. A signal forms custom control that implements `FormValueControl` or `FormCheckboxControl`
* 3. A component that provides a ControlValueAccessor. This should only be used to backwards
* 3. TODO: https://github.com/orgs/angular/projects/60/views/1?pane=issue&itemId=131712274. A
* component that provides a ControlValueAccessor. This should only be used to backwards
* compatibility with reactive forms. Prefer options (1) and (2).
*
* This directive has several responsibilities:
* 1. Two-way binds the field's value with the UI control's value
* 2. Binds additional forms related state on the field to the UI control (disabled, required, etc.)
* 3. Relays relevant events on the control to the field (e.g. marks field touched on blur)
* 4. Provides a fake `NgControl` that implements a subset of the features available on the reactive
* forms `NgControl`. This is provided to improve interoperability with controls designed to work
* with reactive forms. It should not be used by controls written for signal forms.
* 4. TODO: https://github.com/orgs/angular/projects/60/views/1?pane=issue&itemId=131712274.
* Provides a fake `NgControl` that implements a subset of the features available on the
* reactive forms `NgControl`. This is provided to improve interoperability with controls
* designed to work with reactive forms. It should not be used by controls written for signal
* forms.
*
* @category control
* @experimental 21.0.0
*/
@Directive({
selector: '[control]',
providers: [
{
provide: NgControl,
useFactory: () => inject(Control).ngControl,
},
{
provide: CONTROL,
useExisting: Control,
},
],
})
export class Control<T> {
/** The injector for this component. */
@Directive({selector: '[control]', providers: [{provide: CONTROL, useExisting: Control}]})
export class Control<T> implements ɵControl<T> {
private readonly injector = inject(Injector);
private readonly renderer = inject(Renderer2);
/** Whether state synchronization with the field has been setup yet. */
private initialized = false;
/** The field that is bound to this control. */
readonly field = signal<FieldTree<T>>(undefined as any);
// If `[control]` is applied to a custom UI control, it wants to synchronize state in the field w/
// the inputs of that custom control. This is difficult to do in user-land. We use `effect`, but
// effects don't run before the lifecycle hooks of the component. This is usually okay, but has
// one significant issue: the UI control's required inputs won't be set in time for those
// lifecycle hooks to run.
//
// Eventually we can build custom functionality for the `Control` directive into the framework,
// but for now we work around this limitation with a hack. We use an `@Input` instead of a
// signal-based `input()` for the `[control]` to hook the exact moment inputs are being set,
// before the important lifecycle hooks of the UI control. We can then initialize all our effects
// and force them to run immediately, ensuring all required inputs have values.
@Input({required: true, alias: 'control'})
set _field(value: FieldTree<T>) {
this.field.set(value);
if (!this.initialized) {
this.initialize();
}
}
/** The field state of the bound field. */
readonly field = input.required<FieldTree<T>>({alias: 'control'});
readonly state = computed(() => this.field()());
readonly [ɵCONTROL] = undefined;
/** The HTMLElement this directive is attached to. */
readonly el: ElementRef<HTMLElement> = inject(ElementRef);
/** The NG_VALUE_ACCESSOR array for the host component. */
readonly cvaArray = inject<ControlValueAccessor[]>(NG_VALUE_ACCESSOR, {optional: true});
/** The Cached value for the lazily created interop NgControl. */
private _ngControl: InteropNgControl | undefined;
/** A fake NgControl provided for better interop with reactive forms. */
get ngControl(): NgControl {
return (this._ngControl ??= new InteropNgControl(() => this.state())) as unknown as NgControl;
}
/** The ControlValueAccessor for the host component. */
get cva(): ControlValueAccessor | undefined {
return this.cvaArray?.[0] ?? this._ngControl?.valueAccessor ?? undefined;
}
/** Initializes state synchronization between the field and the host UI control. */
private initialize() {
this.initialized = true;
const injector = this.injector;
const cmp = privateGetComponentInstance(injector);
// If component has a `control` input, we assume that it will handle binding the field to the
// appropriate native/custom control in its template, so we do not attempt to bind any inputs on
// this component.
if (cmp && isShadowedControlComponent(cmp)) {
return;
}
if (cmp && isFormUiControl(cmp)) {
// If we're binding to a component that follows the standard form ui control contract,
// set up state synchronization based on the contract.
this.setupCustomUiControl(cmp);
} else if (this.cva !== undefined) {
// If we're binding to a component that doesn't follow the standard contract, but provides a
// control value accessor, set up state synchronization based on th CVA.
this.setupControlValueAccessor(this.cva);
} else if (
this.el.nativeElement instanceof HTMLInputElement ||
this.el.nativeElement instanceof HTMLTextAreaElement ||
this.el.nativeElement instanceof HTMLSelectElement
) {
// If we're binding to a native html input, set up state synchronization with its native
// properties / attributes.
this.setupNativeInput(this.el.nativeElement);
} else {
throw new Error(`Unhandled control?`);
}
// TODO: https://github.com/orgs/angular/projects/60/views/1?pane=issue&itemId=131861631
register() {
// Register this control on the field it is currently bound to. We do this at the end of
// initialization so that it only runs if we are actually syncing with this control
// (as opposed to just passing the field through to its `control` input).
effect(
(onCleanup) => {
const fieldNode = this.state() as unknown as FieldNode;
const fieldNode = this.state() as FieldNode;
fieldNode.nodeState.controls.update((controls) => [...controls, this as Control<unknown>]);
onCleanup(() => {
fieldNode.nodeState.controls.update((controls) => controls.filter((c) => c !== this));
@@ -179,346 +71,4 @@ export class Control<T> {
{injector: this.injector},
);
}
/**
* Set up state synchronization between the field and a native <input>, <textarea>, or <select>.
*/
private setupNativeInput(
input: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement,
): void {
const inputType =
input instanceof HTMLTextAreaElement
? 'text'
: input instanceof HTMLSelectElement
? 'select'
: input.type;
input.addEventListener('input', () => {
switch (inputType) {
case 'checkbox':
this.state().value.set((input as HTMLInputElement).checked as T);
break;
case 'radio':
// The `input` event only fires when a radio button becomes selected, so write its `value`
// into the state.
this.state().value.set((input as HTMLInputElement).value as T);
break;
case 'number':
case 'range':
case 'datetime-local':
// We can read a `number` or a `string` from this input type.
// Prefer whichever is consistent with the current type.
if (typeof this.state().value() === 'number') {
this.state().value.set((input as HTMLInputElement).valueAsNumber as T);
} else {
this.state().value.set(input.value as T);
}
break;
case 'date':
case 'month':
case 'week':
case 'time':
// We can read a `Date | null` or a `number` or a `string` from this input type.
// Prefer whichever is consistent with the current type.
if (isDateOrNull(this.state().value())) {
this.state().value.set((input as HTMLInputElement).valueAsDate as T);
} else if (typeof this.state().value() === 'number') {
this.state().value.set((input as HTMLInputElement).valueAsNumber as T);
} else {
this.state().value.set(input.value as T);
}
break;
default:
this.state().value.set(input.value as T);
break;
}
this.state().markAsDirty();
});
input.addEventListener('blur', () => this.state().markAsTouched());
this.maybeSynchronize(
() => this.state().readonly(),
this.withBooleanAttribute(input, 'readonly'),
);
// TODO: consider making a global configuration option for using aria-disabled instead.
this.maybeSynchronize(
() => this.state().disabled(),
this.withBooleanAttribute(input, 'disabled'),
);
this.maybeSynchronize(() => this.state().name(), this.withAttribute(input, 'name'));
this.maybeSynchronize(
this.propertySource(REQUIRED),
this.withBooleanAttribute(input, 'required'),
);
this.maybeSynchronize(this.propertySource(MIN), this.withAttribute(input, 'min'));
this.maybeSynchronize(this.propertySource(MIN_LENGTH), this.withAttribute(input, 'minLength'));
this.maybeSynchronize(this.propertySource(MAX), this.withAttribute(input, 'max'));
this.maybeSynchronize(this.propertySource(MAX_LENGTH), this.withAttribute(input, 'maxLength'));
switch (inputType) {
case 'checkbox':
this.maybeSynchronize(
() => this.state().value(),
(value) => ((input as HTMLInputElement).checked = value as boolean),
);
break;
case 'radio':
this.maybeSynchronize(
() => this.state().value(),
(value) => {
// Although HTML behavior is to clear the input already, we do this just in case.
// It seems like it might be necessary in certain environments (e.g. Domino).
(input as HTMLInputElement).checked = input.value === value;
},
);
break;
case 'select':
this.maybeSynchronize(
() => this.state().value(),
(value) => {
// A select will not take a value unil the value's option has rendered.
afterNextRender(() => (input.value = value as string), {injector: this.injector});
},
);
break;
case 'number':
case 'range':
case 'datetime-local':
// This input type can receive a `number` or a `string`.
this.maybeSynchronize(
() => this.state().value(),
(value) => {
if (typeof value === 'number') {
(input as HTMLInputElement).valueAsNumber = value;
} else {
input.value = value as string;
}
},
);
break;
case 'date':
case 'month':
case 'week':
case 'time':
// This input type can receive a `Date | null` or a `number` or a `string`.
this.maybeSynchronize(
() => this.state().value(),
(value) => {
if (isDateOrNull(value)) {
(input as HTMLInputElement).valueAsDate = value;
} else if (typeof value === 'number') {
(input as HTMLInputElement).valueAsNumber = value;
} else {
input.value = value as string;
}
},
);
break;
default:
this.maybeSynchronize(
() => this.state().value(),
(value) => {
input.value = value as string;
},
);
break;
}
}
/** Set up state synchronization between the field and a ControlValueAccessor. */
private setupControlValueAccessor(cva: ControlValueAccessor): void {
cva.registerOnChange((value: T) => this.state().value.set(value));
cva.registerOnTouched(() => this.state().markAsTouched());
this.maybeSynchronize(
() => this.state().value(),
(value) => cva.writeValue(value),
);
if (cva.setDisabledState) {
this.maybeSynchronize(
() => this.state().disabled(),
(value) => cva.setDisabledState!(value),
);
}
cva.writeValue(this.state().value());
cva.setDisabledState?.(this.state().disabled());
}
/** Set up state synchronization between the field and a FormUiControl. */
private setupCustomUiControl(cmp: FormUiControl) {
// Handle the property side of the model binding. How we do this depends on the shape of the
// component. There are 2 options:
// * it provides a `value` model (most controls that edit a single value)
// * it provides a `checked` model with no `value` signal (custom checkbox)
let cleanupValue: OutputRefSubscription | undefined;
if (isFormValueControl(cmp)) {
// <custom-input [(value)]="state().value">
this.maybeSynchronize(() => this.state().value(), withInput(cmp.value));
cleanupValue = cmp.value.subscribe((newValue) => this.state().value.set(newValue as T));
} else if (isFormCheckboxControl(cmp)) {
// <custom-checkbox [(checked)]="state().value" />
this.maybeSynchronize(() => this.state().value() as boolean, withInput(cmp.checked));
cleanupValue = cmp.checked.subscribe((newValue) => this.state().value.set(newValue as T));
} else {
throw new Error(`Unknown custom control subtype`);
}
this.maybeSynchronize(() => this.state().name(), withInput(cmp.name));
this.maybeSynchronize(() => this.state().disabled(), withInput(cmp.disabled));
this.maybeSynchronize(() => this.state().disabledReasons(), withInput(cmp.disabledReasons));
this.maybeSynchronize(() => this.state().readonly(), withInput(cmp.readonly));
this.maybeSynchronize(() => this.state().hidden(), withInput(cmp.hidden));
this.maybeSynchronize(() => this.state().errors(), withInput(cmp.errors));
if (privateIsModelInput(cmp.touched) || privateIsSignalInput(cmp.touched)) {
this.maybeSynchronize(() => this.state().touched(), withInput(cmp.touched));
}
this.maybeSynchronize(() => this.state().dirty(), withInput(cmp.dirty));
this.maybeSynchronize(() => this.state().invalid(), withInput(cmp.invalid));
this.maybeSynchronize(() => this.state().pending(), withInput(cmp.pending));
this.maybeSynchronize(this.propertySource(REQUIRED), withInput(cmp.required));
this.maybeSynchronize(this.propertySource(MIN), withInput(cmp.min));
this.maybeSynchronize(this.propertySource(MIN_LENGTH), withInput(cmp.minLength));
this.maybeSynchronize(this.propertySource(MAX), withInput(cmp.max));
this.maybeSynchronize(this.propertySource(MAX_LENGTH), withInput(cmp.maxLength));
this.maybeSynchronize(this.propertySource(PATTERN), withInput(cmp.pattern));
let cleanupTouch: OutputRefSubscription | undefined;
let cleanupDefaultTouch: (() => void) | undefined;
if (privateIsModelInput(cmp.touched) || isOutputRef(cmp.touched)) {
cleanupTouch = cmp.touched.subscribe(() => this.state().markAsTouched());
} else {
// If the component did not give us a touch event stream, use the standard touch logic,
// marking it touched when the focus moves from inside the host element to outside.
const listener = (event: FocusEvent) => {
const newActiveEl = event.relatedTarget;
if (!this.el.nativeElement.contains(newActiveEl as Element | null)) {
this.state().markAsTouched();
}
};
this.el.nativeElement.addEventListener('focusout', listener);
cleanupDefaultTouch = () => this.el.nativeElement.removeEventListener('focusout', listener);
}
// Cleanup for output binding subscriptions:
this.injector.get(DestroyRef).onDestroy(() => {
cleanupValue?.unsubscribe();
cleanupTouch?.unsubscribe();
cleanupDefaultTouch?.();
});
}
/** Synchronize a value from a reactive source to a given sink. */
private maybeSynchronize<T>(source: () => T, sink: ((value: T) => void) | undefined): void {
if (!sink) {
return undefined;
}
const ref = effect(
() => {
const value = source();
untracked(() => sink(value));
},
{injector: this.injector},
);
// Run the effect immediately to ensure sinks which are required inputs are set before they can
// be observed. See the note on `_field` for more details.
privateRunEffect(ref);
}
/** Creates a reactive value source by reading the given AggregateProperty from the field. */
private propertySource<T>(key: AggregateProperty<T, any>): () => T {
const metaSource = computed(() =>
this.state().hasProperty(key) ? this.state().property(key) : key.getInitial,
);
return () => metaSource()?.();
}
/** Creates a (non-boolean) value sync that writes the given attribute of the given element. */
private withAttribute(
element: HTMLElement,
attribute: string,
): (value: {toString(): string} | undefined) => void {
return (value) => {
if (value !== undefined) {
this.renderer.setAttribute(element, attribute, value.toString());
} else {
this.renderer.removeAttribute(element, attribute);
}
};
}
/** Creates a boolean value sync that writes the given attribute of the given element. */
private withBooleanAttribute(element: HTMLElement, attribute: string): (value: boolean) => void {
return (value) => {
if (value) {
this.renderer.setAttribute(element, attribute, '');
} else {
this.renderer.removeAttribute(element, attribute);
}
};
}
}
/** Creates a value sync from an input signal. */
function withInput<T>(input: InputSignal<T> | undefined): ((value: T) => void) | undefined {
return input ? (value: T) => privateSetInputSignal(input, value) : undefined;
}
/**
* Checks whether the given component matches the contract for either FormValueControl or
* FormCheckboxControl.
*/
function isFormUiControl(cmp: unknown): cmp is FormUiControl {
const castCmp = cmp as FormUiControl;
return (
(isFormValueControl(castCmp) || isFormCheckboxControl(castCmp)) &&
(castCmp.readonly === undefined || privateIsSignalInput(castCmp.readonly)) &&
(castCmp.disabled === undefined || privateIsSignalInput(castCmp.disabled)) &&
(castCmp.disabledReasons === undefined || privateIsSignalInput(castCmp.disabledReasons)) &&
(castCmp.errors === undefined || privateIsSignalInput(castCmp.errors)) &&
(castCmp.invalid === undefined || privateIsSignalInput(castCmp.invalid)) &&
(castCmp.pending === undefined || privateIsSignalInput(castCmp.pending)) &&
(castCmp.touched === undefined ||
privateIsModelInput(castCmp.touched) ||
privateIsSignalInput(castCmp.touched) ||
isOutputRef(castCmp.touched)) &&
(castCmp.dirty === undefined || privateIsSignalInput(castCmp.dirty)) &&
(castCmp.min === undefined || privateIsSignalInput(castCmp.min)) &&
(castCmp.minLength === undefined || privateIsSignalInput(castCmp.minLength)) &&
(castCmp.max === undefined || privateIsSignalInput(castCmp.max)) &&
(castCmp.maxLength === undefined || privateIsSignalInput(castCmp.maxLength))
);
}
/** Checks whether the given FormUiControl is a FormValueControl. */
function isFormValueControl(cmp: FormUiControl): cmp is FormValueControl<unknown> {
return privateIsModelInput((cmp as FormValueControl<unknown>).value);
}
/** Checks whether the given FormUiControl is a FormCheckboxControl. */
function isFormCheckboxControl(cmp: FormUiControl): cmp is FormCheckboxControl {
return (
privateIsModelInput((cmp as FormCheckboxControl).checked) &&
(cmp as FormCheckboxControl).value === undefined
);
}
/** Checks whether the given component has an input called `control`. */
function isShadowedControlComponent(cmp: unknown): boolean {
const mirror = reflectComponentType((cmp as {}).constructor as Type<unknown>);
return mirror?.inputs.some((input) => input.templateName === 'control') ?? false;
}
/** Checks whether the given object is an output ref. */
function isOutputRef(value: unknown): value is OutputRef<unknown> {
return value instanceof OutputEmitterRef || value instanceof EventEmitter;
}
/** Checks if a given value is a Date or null */
function isDateOrNull(value: unknown): value is Date | null {
return value === null || value instanceof Date;
}
+5 -40
View File
@@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.dev/license
*/
import {Signal, WritableSignal} from '@angular/core';
import {Signal, ɵFieldState} from '@angular/core';
import type {Control} from './control_directive';
import {AggregateProperty, Property} from './property';
import type {ValidationError, WithOptionalField, WithoutField} from './validation_errors';
@@ -237,16 +237,8 @@ export type MaybeFieldTree<TValue, TKey extends string | number = string | numbe
* @category structure
* @experimental 21.0.0
*/
export interface FieldState<TValue, TKey extends string | number = string | number> {
/**
* A writable signal containing the value for this field. Updating this signal will update the
* data model that the field is bound to.
*/
readonly value: WritableSignal<TValue>;
/**
* A signal indicating whether the field has been touched by the user.
*/
readonly touched: Signal<boolean>;
export interface FieldState<TValue, TKey extends string | number = string | number>
extends ɵFieldState<TValue> {
/**
* A signal indicating whether field value has been changed by user.
*/
@@ -265,27 +257,14 @@ export interface FieldState<TValue, TKey extends string | number = string | numb
* ```
*/
readonly hidden: Signal<boolean>;
/**
* A signal indicating whether the field is currently disabled.
*/
readonly disabled: Signal<boolean>;
/**
* A signal containing the reasons why the field is currently disabled.
*/
readonly disabledReasons: Signal<readonly DisabledReason[]>;
/**
* A signal indicating whether the field is currently readonly.
*/
readonly readonly: Signal<boolean>;
/**
* A signal containing the current errors for the field.
*/
readonly errors: Signal<ValidationError[]>;
/**
* A signal containing the {@link errors} of the field and its descendants.
*/
readonly errorSummary: Signal<ValidationError[]>;
/**
* A signal indicating whether the field's value is currently valid.
*
@@ -318,10 +297,6 @@ export interface FieldState<TValue, TKey extends string | number = string | numb
* A signal indicating whether the field is currently in the process of being submitted.
*/
readonly submitting: Signal<boolean>;
/**
* A signal of a unique name for the field, by default based on the name of its parent field.
*/
readonly name: Signal<string>;
/**
* The property key in the parent field under which this field is stored. If the parent field is
@@ -350,16 +325,6 @@ export interface FieldState<TValue, TKey extends string | number = string | numb
*/
hasProperty(key: Property<any> | AggregateProperty<any, any>): boolean;
/**
* Sets the touched status of the field to `true`.
*/
markAsTouched(): void;
/**
* Sets the dirty status of the field to `true`.
*/
markAsDirty(): void;
/**
* Resets the {@link touched} and {@link dirty} state of the field and its descendants.
*
+34 -1
View File
@@ -8,7 +8,16 @@
import type {Signal, WritableSignal} from '@angular/core';
import type {Control} from '../api/control_directive';
import {AggregateProperty, Property} from '../api/property';
import {
AggregateProperty,
MAX,
MAX_LENGTH,
MIN,
MIN_LENGTH,
Property,
REQUIRED,
PATTERN,
} from '../api/property';
import type {DisabledReason, FieldContext, FieldState, FieldTree} from '../api/types';
import type {ValidationError} from '../api/validation_errors';
import {LogicNode} from '../schema/logic_node';
@@ -137,6 +146,30 @@ export class FieldNode implements FieldState<unknown> {
return this.nodeState.name;
}
get max(): Signal<number | undefined> {
return this.property(MAX);
}
get maxLength(): Signal<number | undefined> {
return this.property(MAX_LENGTH);
}
get min(): Signal<number | undefined> {
return this.property(MIN);
}
get minLength(): Signal<number | undefined> {
return this.property(MIN_LENGTH);
}
get pattern(): Signal<readonly RegExp[]> {
return this.property(PATTERN);
}
get required(): Signal<boolean> {
return this.property(REQUIRED);
}
property<M>(prop: AggregateProperty<M, any>): Signal<M>;
property<M>(prop: Property<M>): M | undefined;
property<M>(prop: Property<M> | AggregateProperty<M, any>): Signal<M> | M | undefined {
@@ -1,75 +0,0 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {EffectRef, Injector, InputSignal, ModelSignal, ɵSIGNAL as SIGNAL} from '@angular/core';
import {isObject} from './type_guards';
// TODO: These utilities to be replaced with proper integration into framework.
export function privateGetComponentInstance(injector: Injector): unknown {
assertIsNodeInjector(injector);
if (injector._tNode.directiveStart === 0 || injector._tNode.componentOffset === -1) {
return undefined;
}
return injector._lView[injector._tNode.directiveStart + injector._tNode.componentOffset];
}
export function privateSetComponentInput<T>(inputSignal: InputSignal<T>, value: T): void {
inputSignal[SIGNAL].applyValueToInputSignal(inputSignal[SIGNAL], value);
}
export function privateIsSignalInput(value: unknown): value is InputSignal<unknown> {
return isInputSignal(value);
}
export function privateIsModelInput<T>(value: unknown): value is ModelSignal<T> {
return isInputSignal(value) && isObject(value) && 'subscribe' in value;
}
export function privateRunEffect(ref: EffectRef): void {
(ref as EffectRefImpl)[SIGNAL].run();
}
function assertIsNodeInjector(injector: Injector): asserts injector is NgNodeInjector {
if (!('_tNode' in injector)) {
throw new Error('Expected a Node Injector');
}
}
function isInputSignal(value: unknown): value is NgInputSignal {
if (!isObject(value) || !(SIGNAL in value)) {
return false;
}
const node = value[SIGNAL];
return isObject(node) && 'applyValueToInputSignal' in node;
}
interface NgNodeInjector extends Injector {
_tNode: TNode;
_lView: Array<unknown>;
}
interface TNode {
directiveStart: number;
componentOffset: number;
}
interface NgInputSignal {
[SIGNAL]: NgInputSignalNode;
}
interface NgInputSignalNode {
applyValueToInputSignal(node: NgInputSignalNode, value: unknown): void;
}
interface EffectRefImpl extends EffectRef {
readonly [SIGNAL]: {
run(): void;
};
}
@@ -24,7 +24,6 @@ import {
form,
hidden,
max,
MAX,
maxLength,
min,
minLength,
@@ -425,7 +424,6 @@ describe('control directive', () => {
const comp = act(() => TestBed.createComponent(CustomPropsTestCmp)).componentInstance;
expect(comp.f.number().property(MAX)()).toBe(100);
expect(comp.textInput().nativeElement.required).toBe(true);
expect(comp.textInput().nativeElement.minLength).toBe(0);
expect(comp.textInput().nativeElement.maxLength).toBe(100);