diff --git a/goldens/public-api/core/errors.api.md b/goldens/public-api/core/errors.api.md index fc9e20147c7..e56919de3e6 100644 --- a/goldens/public-api/core/errors.api.md +++ b/goldens/public-api/core/errors.api.md @@ -76,6 +76,8 @@ export const enum RuntimeErrorCode { // (undocumented) INVALID_BINDING_TARGET = 316, // (undocumented) + INVALID_CONTROL_HOST = 318, + // (undocumented) INVALID_DIFFER_INPUT = 900, // (undocumented) INVALID_EVENT_BINDING = 306, diff --git a/goldens/public-api/forms/signals/index.api.md b/goldens/public-api/forms/signals/index.api.md index 0c894b06dbc..81737dad5a7 100644 --- a/goldens/public-api/forms/signals/index.api.md +++ b/goldens/public-api/forms/signals/index.api.md @@ -4,9 +4,7 @@ ```ts -import { ControlValueAccessor } from '@angular/forms'; import { DestroyableInjector } from '@angular/core'; -import { ElementRef } from '@angular/core'; import { HttpResourceOptions } from '@angular/common/http'; import { HttpResourceRequest } from '@angular/common/http'; import * as i0 from '@angular/core'; @@ -14,12 +12,14 @@ import { InjectionToken } from '@angular/core'; import { Injector } from '@angular/core'; import { InputSignal } from '@angular/core'; import { ModelSignal } from '@angular/core'; -import { NgControl } from '@angular/forms'; import { OutputRef } from '@angular/core'; import { ResourceRef } from '@angular/core'; import { Signal } from '@angular/core'; import { StandardSchemaV1 } from '@standard-schema/spec'; import { WritableSignal } from '@angular/core'; +import { ɵCONTROL } from '@angular/core'; +import { ɵControl } from '@angular/core'; +import { ɵFieldState } from '@angular/core'; // @public export class AggregateProperty { @@ -69,17 +69,17 @@ export interface ChildFieldContext extends RootFieldContext { export const CONTROL: InjectionToken>; // @public -export class Control { - get cva(): ControlValueAccessor | undefined; - readonly cvaArray: ControlValueAccessor[] | null; - readonly el: ElementRef; - readonly field: i0.WritableSignal>; +export class Control implements ɵControl { + // (undocumented) + readonly [ɵCONTROL]: undefined; + // (undocumented) + readonly field: i0.InputSignal>; + // (undocumented) + register(): void; // (undocumented) - set _field(value: FieldTree); - get ngControl(): NgControl; readonly state: i0.Signal>; // (undocumented) - static ɵdir: i0.ɵɵDirectiveDeclaration, "[control]", never, { "_field": { "alias": "control"; "required": true; }; }, {}, never, never, true, never>; + static ɵdir: i0.ɵɵDirectiveDeclaration, "[control]", never, { "field": { "alias": "control"; "required": true; "isSignal": true; }; }, {}, never, never, true, never>; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; } @@ -137,29 +137,24 @@ export type FieldPath = { } : unknown); // @public -export interface FieldState { +export interface FieldState extends ɵFieldState { readonly controls: Signal[]>; readonly dirty: Signal; - readonly disabled: Signal; + // (undocumented) readonly disabledReasons: Signal; + // (undocumented) readonly errors: Signal; readonly errorSummary: Signal; hasProperty(key: Property | AggregateProperty): boolean; readonly hidden: Signal; readonly invalid: Signal; readonly keyInParent: Signal; - markAsDirty(): void; - markAsTouched(): void; - readonly name: Signal; readonly pending: Signal; property(prop: AggregateProperty): Signal; property(prop: Property): M | undefined; - readonly readonly: Signal; reset(): void; readonly submitting: Signal; - readonly touched: Signal; readonly valid: Signal; - readonly value: WritableSignal; } // @public diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/control_bindings/GOLDEN_PARTIAL.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/control_bindings/GOLDEN_PARTIAL.js index a31f34f9084..e23e82ddf2d 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/control_bindings/GOLDEN_PARTIAL.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/control_bindings/GOLDEN_PARTIAL.js @@ -21,12 +21,16 @@ export class MyComponent { } MyComponent.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: MyComponent, isStandalone: true, selector: "ng-component", ngImport: i0, template: ` +
+
Not a form control either.
`, isInline: true, dependencies: [{ kind: "directive", type: Control, selector: "[control]", inputs: ["control"] }] }); i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyComponent, decorators: [{ type: Component, args: [{ template: ` +
+
Not a form control either.
`, imports: [Control], diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/control_bindings/control_bindings.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/control_bindings/control_bindings.js index 0144a3be488..2ea074d9c99 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/control_bindings/control_bindings.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/control_bindings/control_bindings.js @@ -1,14 +1,23 @@ MyComponent.ɵcmp = /* @__PURE__ */i0.ɵɵdefineComponent({ type: MyComponent, selectors: [["ng-component"]], - decls: 1, - vars: 1, - consts: [[3, "control"]], + decls: 4, + vars: 2, + consts: [["control", "Not a form control"], [3, "control"]], template: function MyComponent_Template(rf, ctx) { if (rf & 1) { - i0.ɵɵelement(0, "input", 0); + i0.ɵɵelement(0, "div", 0); + i0.ɵɵelementStart(1, "div"); + i0.ɵɵtext(2, "Not a form control either."); + i0.ɵɵelementEnd(); + i0.ɵɵelementStart(3, "input", 1); + i0.ɵɵcontrolCreate(); + i0.ɵɵelementEnd(); } if (rf & 2) { + i0.ɵɵadvance(); + i0.ɵɵattribute("control", ctx.value); + i0.ɵɵadvance(2); i0.ɵɵcontrol(ctx.value); } }, diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/control_bindings/control_bindings.ts b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/control_bindings/control_bindings.ts index 356e247c182..366a16bd5e3 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/control_bindings/control_bindings.ts +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/control_bindings/control_bindings.ts @@ -7,6 +7,8 @@ export class Control { @Component({ template: ` +
+
Not a form control either.
`, imports: [Control], diff --git a/packages/compiler/src/render3/r3_identifiers.ts b/packages/compiler/src/render3/r3_identifiers.ts index 894bf60128f..f343ca91f70 100644 --- a/packages/compiler/src/render3/r3_identifiers.ts +++ b/packages/compiler/src/render3/r3_identifiers.ts @@ -254,7 +254,9 @@ export class Identifiers { static ariaProperty: o.ExternalReference = {name: 'ɵɵariaProperty', moduleName: CORE}; static property: o.ExternalReference = {name: 'ɵɵproperty', moduleName: CORE}; + static control: o.ExternalReference = {name: 'ɵɵcontrol', moduleName: CORE}; + static controlCreate: o.ExternalReference = {name: 'ɵɵcontrolCreate', moduleName: CORE}; static animationEnterListener: o.ExternalReference = { name: 'ɵɵanimateEnterListener', diff --git a/packages/compiler/src/template/pipeline/ir/src/enums.ts b/packages/compiler/src/template/pipeline/ir/src/enums.ts index ab60ccc7109..7fa8aa1ae8b 100644 --- a/packages/compiler/src/template/pipeline/ir/src/enums.ts +++ b/packages/compiler/src/template/pipeline/ir/src/enums.ts @@ -301,9 +301,17 @@ export enum OpKind { AnimationListener, /** - * An operation to bind a signal form field to a control directive. + * An operation to bind an expression to a `control` property of an element. */ Control, + + /** + * An operation to set up a corresponding {@link Control} operation. + * + * This is responsible for setting up event listeners on a native or custom form control when + * bound to a specialized control directive. + */ + ControlCreate, } /** diff --git a/packages/compiler/src/template/pipeline/ir/src/expression.ts b/packages/compiler/src/template/pipeline/ir/src/expression.ts index e6b099e3a0b..41e507b638f 100644 --- a/packages/compiler/src/template/pipeline/ir/src/expression.ts +++ b/packages/compiler/src/template/pipeline/ir/src/expression.ts @@ -1234,6 +1234,7 @@ export function transformExpressionsInOp( case OpKind.SourceLocation: case OpKind.ConditionalCreate: case OpKind.ConditionalBranchCreate: + case OpKind.ControlCreate: // These operations contain no expressions. break; default: diff --git a/packages/compiler/src/template/pipeline/ir/src/ops/create.ts b/packages/compiler/src/template/pipeline/ir/src/ops/create.ts index 23d0b687283..906843a5a7a 100644 --- a/packages/compiler/src/template/pipeline/ir/src/ops/create.ts +++ b/packages/compiler/src/template/pipeline/ir/src/ops/create.ts @@ -33,7 +33,7 @@ import { import {ListEndOp, NEW_OP, StatementOp, VariableOp} from './shared'; -import type {Interpolation, UpdateOp} from './update'; +import type {BindingOp, Interpolation, UpdateOp} from './update'; /** * An operation usable on the creation side of the IR. @@ -77,7 +77,8 @@ export type CreateOp = | AnimationListenerOp | AnimationStringOp | AnimationOp - | SourceLocationOp; + | SourceLocationOp + | ControlCreateOp; /** * An operation representing the creation of an element or container. @@ -1950,6 +1951,21 @@ export function createSourceLocationOp( }; } +/** + * An operation that determines whether a `[control]` binding targets a specialized control + * directive on a native or custom form control, and if so, adds event listeners to synchronize the + * bound form field to the form control. + */ +export interface ControlCreateOp extends Op { + kind: OpKind.ControlCreate; + sourceSpan: ParseSourceSpan; +} + +/** Creates a {@link ControlCreateOp}. */ +export function createControlCreateOp(sourceSpan: ParseSourceSpan): ControlCreateOp { + return {kind: OpKind.ControlCreate, sourceSpan, ...NEW_OP}; +} + /** * An index into the `consts` array which is shared across the compilation of all views in a * component. diff --git a/packages/compiler/src/template/pipeline/src/ingest.ts b/packages/compiler/src/template/pipeline/src/ingest.ts index 3aa640c975c..ffd903c7252 100644 --- a/packages/compiler/src/template/pipeline/src/ingest.ts +++ b/packages/compiler/src/template/pipeline/src/ingest.ts @@ -1344,6 +1344,12 @@ function ingestElementBindings( input.sourceSpan, ), ); + + // If the input name is 'control', this could be a form control binding which requires a + // `ControlCreateOp` to properly initialize. + if (input.type === e.BindingType.Property && input.name === 'control') { + unit.create.push(ir.createControlCreateOp(input.sourceSpan)); + } } unit.create.push( diff --git a/packages/compiler/src/template/pipeline/src/instruction.ts b/packages/compiler/src/template/pipeline/src/instruction.ts index 818da8799e1..e1796f4aed2 100644 --- a/packages/compiler/src/template/pipeline/src/instruction.ts +++ b/packages/compiler/src/template/pipeline/src/instruction.ts @@ -616,6 +616,10 @@ export function control( return call(Identifiers.control, args, sourceSpan); } +export function controlCreate(sourceSpan: ParseSourceSpan): ir.CreateOp { + return call(Identifiers.controlCreate, [], sourceSpan); +} + export function twoWayProperty( name: string, expression: o.Expression, diff --git a/packages/compiler/src/template/pipeline/src/phases/reify.ts b/packages/compiler/src/template/pipeline/src/phases/reify.ts index 54ef04dc962..989775e51cb 100644 --- a/packages/compiler/src/template/pipeline/src/phases/reify.ts +++ b/packages/compiler/src/template/pipeline/src/phases/reify.ts @@ -571,6 +571,9 @@ function reifyCreateOperations(unit: CompilationUnit, ops: ir.OpList(); + const tNode = getCurrentTNode()!; + + // TODO(https://github.com/orgs/angular/projects/60/views/1?pane=issue&itemId=131712274) + // * cache the control directive index or instance for reuse. + const control = getControlDirective(tNode, lView); + if (!control) { + return; + } + + // TODO(https://github.com/orgs/angular/projects/60/views/1?pane=issue&itemId=131712274): + // * cache the custom control component index or instance for reuse. + // * cache the control model name for reuse. + const customControl = getCustomControlComponent(tNode); + if (customControl) { + const [componentIndex, modelName] = customControl; + listenToCustomControl(lView, tNode, control, componentIndex, modelName); + } else if (isNativeControl(lView, tNode)) { + listenToNativeControl(lView, tNode, control); + } else { + // For example, user wrote
. + // TODO: https://github.com/orgs/angular/projects/60/views/1?pane=issue&itemId=131860276 + const tagName = tNode.value; + throw new RuntimeError( + RuntimeErrorCode.INVALID_CONTROL_HOST, + `'<${tagName}>' is an invalid control host. The host must be a native form control (such ` + + `as ', '