mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
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:
committed by
Miles Malerba
parent
42e1062bc5
commit
effccffde0
+4
@@ -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: `
|
||||
<div control="Not a form control"></div>
|
||||
<div [attr.control]="value">Not a form control either.</div>
|
||||
<input [control]="value">
|
||||
`, 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: `
|
||||
<div control="Not a form control"></div>
|
||||
<div [attr.control]="value">Not a form control either.</div>
|
||||
<input [control]="value">
|
||||
`,
|
||||
imports: [Control],
|
||||
|
||||
+13
-4
@@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
+2
@@ -7,6 +7,8 @@ export class Control {
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<div control="Not a form control"></div>
|
||||
<div [attr.control]="value">Not a form control either.</div>
|
||||
<input [control]="value">
|
||||
`,
|
||||
imports: [Control],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<CreateOp> {
|
||||
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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -571,6 +571,9 @@ function reifyCreateOperations(unit: CompilationUnit, ops: ir.OpList<ir.CreateOp
|
||||
|
||||
ir.OpList.replace(op, ng.attachSourceLocation(op.templatePath, locationsLiteral));
|
||||
break;
|
||||
case ir.OpKind.ControlCreate:
|
||||
ir.OpList.replace(op, ng.controlCreate(op.sourceSpan));
|
||||
break;
|
||||
case ir.OpKind.Statement:
|
||||
// Pass statement operations directly through.
|
||||
break;
|
||||
|
||||
@@ -94,6 +94,7 @@ export {
|
||||
ɵɵcontentQuery,
|
||||
ɵɵcontentQuerySignal,
|
||||
ɵɵcontrol,
|
||||
ɵɵcontrolCreate,
|
||||
ɵɵcomponentInstance,
|
||||
ɵɵCopyDefinitionFeature,
|
||||
ɵɵdefineComponent,
|
||||
@@ -242,6 +243,7 @@ export {
|
||||
} from './render3/index';
|
||||
export {CONTAINER_HEADER_OFFSET as ɵCONTAINER_HEADER_OFFSET} from './render3/interfaces/container';
|
||||
export {LContext as ɵLContext} from './render3/interfaces/context';
|
||||
export {ɵCONTROL, ɵControl, ɵFieldState} from './render3/interfaces/control';
|
||||
export {setDocument as ɵsetDocument} from './render3/interfaces/document';
|
||||
export {
|
||||
compileComponent as ɵcompileComponent,
|
||||
|
||||
@@ -64,6 +64,7 @@ export const enum RuntimeErrorCode {
|
||||
NO_BINDING_TARGET = 315,
|
||||
INVALID_BINDING_TARGET = 316,
|
||||
INVALID_SET_INPUT_CALL = 317,
|
||||
INVALID_CONTROL_HOST = 318,
|
||||
|
||||
// Bootstrap Errors
|
||||
MULTIPLE_PLATFORMS = 400,
|
||||
|
||||
@@ -96,6 +96,7 @@ export {
|
||||
ɵɵprojectionDef,
|
||||
ɵɵproperty,
|
||||
ɵɵcontrol,
|
||||
ɵɵcontrolCreate,
|
||||
ɵɵcontentQuery,
|
||||
ɵɵcontentQuerySignal,
|
||||
ɵɵloadQuery,
|
||||
|
||||
@@ -5,24 +5,73 @@
|
||||
* 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 {RuntimeError, RuntimeErrorCode} from '../../errors';
|
||||
import {bindingUpdated} from '../bindings';
|
||||
import {ɵCONTROL, ɵControl} from '../interfaces/control';
|
||||
import {ComponentDef} from '../interfaces/definition';
|
||||
import {InputFlags} from '../interfaces/input_flags';
|
||||
import {TNode} from '../interfaces/node';
|
||||
import {Renderer} from '../interfaces/renderer';
|
||||
import {SanitizerFn} from '../interfaces/sanitization';
|
||||
import {RENDERER} from '../interfaces/view';
|
||||
import {getLView, getSelectedTNode, getTView, nextBindingIndex} from '../state';
|
||||
|
||||
import {isComponentHost} from '../interfaces/type_checks';
|
||||
import {LView, RENDERER} from '../interfaces/view';
|
||||
import {getLView, getCurrentTNode, getSelectedTNode, getTView, nextBindingIndex} from '../state';
|
||||
import {getNativeByTNode} from '../util/view_utils';
|
||||
import {listenToOutput} from '../view/directive_outputs';
|
||||
import {listenToDomEvent, wrapListener} from '../view/listeners';
|
||||
import {setPropertyAndInputs, storePropertyBindingMetadata} from './shared';
|
||||
import {writeToDirectiveInput} from './write_to_directive_input';
|
||||
|
||||
/**
|
||||
* Update a `control` property on a selected element.
|
||||
* Possibly sets up a {@link ɵControl} to manage a native or custom form control.
|
||||
*
|
||||
* Operates on the element selected by index via the {@link select} instruction.
|
||||
* Setup occurs if a `control` input is bound to a {@link ɵControl} directive on the current node,
|
||||
* but not to a component. If a `control` input is bound to a component, we assume the component
|
||||
* will manage the control in its own template.
|
||||
*
|
||||
* If the property name also exists as an input property on one of the element's directives,
|
||||
* the component property will be set instead of the element property. This check must
|
||||
* be conducted at runtime so child components that add new `@Inputs` don't have to be re-compiled
|
||||
* @codeGenApi
|
||||
*/
|
||||
export function ɵɵcontrolCreate(): void {
|
||||
const lView = getLView<{} | null>();
|
||||
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 <div [control]="f">.
|
||||
// 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 <input>', '<select>', or '<textarea>') or a custom form control component with a ` +
|
||||
`'value' or 'checked' model.`,
|
||||
);
|
||||
}
|
||||
|
||||
control.register();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a `control` property, and possibly other form control properties, on the current element.
|
||||
*
|
||||
* This is a specialized version of the `ɵɵproperty` instruction that handles updating additional
|
||||
* form control properties, if set up to do so by {@link ɵɵcontrolCreate} during creation.
|
||||
*
|
||||
* @param propName Name of property. Because it is going to DOM, this is not subject to
|
||||
* renaming as part of minification.
|
||||
* @param value New value to write.
|
||||
* @param sanitizer An optional function used to sanitize the value.
|
||||
*
|
||||
@@ -31,10 +80,450 @@ import {setPropertyAndInputs, storePropertyBindingMetadata} from './shared';
|
||||
export function ɵɵcontrol<T>(value: T, sanitizer?: SanitizerFn | null): void {
|
||||
const lView = getLView();
|
||||
const bindingIndex = nextBindingIndex();
|
||||
const tNode = getSelectedTNode();
|
||||
|
||||
if (bindingUpdated(lView, bindingIndex, value)) {
|
||||
const tView = getTView();
|
||||
const tNode = getSelectedTNode();
|
||||
setPropertyAndInputs(tNode, lView, 'control', value, lView[RENDERER], sanitizer);
|
||||
ngDevMode && storePropertyBindingMetadata(tView.data, tNode, 'control', bindingIndex);
|
||||
}
|
||||
|
||||
// TODO: https://github.com/orgs/angular/projects/60/views/1?pane=issue&itemId=131711472
|
||||
// * only run if this is really a control binding determine in the create pass.
|
||||
const control = getControlDirective(tNode, lView);
|
||||
if (!control) {
|
||||
return;
|
||||
}
|
||||
|
||||
const customControl = getCustomControlComponent(tNode);
|
||||
if (customControl) {
|
||||
const [componentIndex, modelName] = customControl;
|
||||
updateCustomControl(lView, componentIndex, modelName, control);
|
||||
} else {
|
||||
updateNativeControl(tNode, lView, control);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the {@link ɵControl} directive on the specified node, if one is present and a `control`
|
||||
* input is bound to it, but not to a component. If a `control` input is bound to a component, we
|
||||
* assume the component will manage the control in its own template and return nothing to indicate
|
||||
* that the directive should not be set up.
|
||||
*
|
||||
* @param tNode The `TNode` of the element to check.
|
||||
* @param lView The `LView` that contains the element.
|
||||
*/
|
||||
function getControlDirective<T>(tNode: TNode, lView: LView): ɵControl<T> | undefined {
|
||||
const directiveIndices = tNode.inputs?.['control'];
|
||||
if (!directiveIndices) {
|
||||
// There are no matching inputs for the `[control]` property binding.
|
||||
return;
|
||||
}
|
||||
|
||||
if (isComponentHost(tNode)) {
|
||||
const componentIndex = tNode.directiveStart + tNode.componentOffset;
|
||||
if (directiveIndices.includes(componentIndex)) {
|
||||
// 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.
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Search for the `Control` directive.
|
||||
for (let index of directiveIndices) {
|
||||
const directive = lView[index];
|
||||
if (ɵCONTROL in directive) {
|
||||
return directive;
|
||||
}
|
||||
}
|
||||
|
||||
// The `Control` directive was not imported by this component.
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* The name of the property that represents a control's value.
|
||||
*/
|
||||
type ControlModelName = 'value' | 'checked';
|
||||
|
||||
/**
|
||||
* Returns information about the component on the specified node, if it appears to be a custom form
|
||||
* control.
|
||||
*
|
||||
* A component is considered a custom form control if it has a model input named `value` or
|
||||
* `checked`.
|
||||
*
|
||||
* @param tNode The `TNode` of the element to check.
|
||||
* @returns an array containing the component index and model input name if it's a custom form
|
||||
* control, or undefined.
|
||||
*/
|
||||
function getCustomControlComponent(tNode: TNode): [number, ControlModelName] | undefined {
|
||||
if (!isComponentHost(tNode)) {
|
||||
return;
|
||||
}
|
||||
const tView = getTView();
|
||||
const componentIndex = tNode.directiveStart + tNode.componentOffset;
|
||||
const componentDef = tView.data[componentIndex] as ComponentDef<unknown>;
|
||||
if (hasModelInput(componentDef, 'value')) {
|
||||
return [componentIndex, 'value'];
|
||||
}
|
||||
if (hasModelInput(componentDef, 'checked')) {
|
||||
return [componentIndex, 'checked'];
|
||||
}
|
||||
// TODO: https://github.com/orgs/angular/projects/60/views/1?pane=issue&itemId=131861022
|
||||
// * should we check that any additional field state inputs are signal based?
|
||||
return;
|
||||
}
|
||||
|
||||
/** Returns whether the specified `componentDef` has a model input named `name`. */
|
||||
function hasModelInput(componentDef: ComponentDef<unknown>, name: string): boolean {
|
||||
return hasSignalInput(componentDef, name) && hasOutput(componentDef, name + 'Change');
|
||||
}
|
||||
|
||||
/** Returns whether the specified `componentDef` has a signal-based input named `name`.*/
|
||||
function hasSignalInput(componentDef: ComponentDef<unknown>, name: string): boolean {
|
||||
const input = componentDef.inputs[name];
|
||||
return input && (input[1] & InputFlags.SignalBased) !== 0;
|
||||
}
|
||||
|
||||
/** Returns whether the specified `componentDef` has an output named `name`. */
|
||||
function hasOutput(componentDef: ComponentDef<unknown>, name: string): boolean {
|
||||
return name in componentDef.outputs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds event listeners to a custom form control component to notify the `control` of changes.
|
||||
*
|
||||
* @param lView The `LView` that contains the custom form control.
|
||||
* @param tNode The `TNode` of the custom form control.
|
||||
* @param control The `ɵControl` directive instance.
|
||||
* @param componentIndex The index of the custom form control component in the `LView`.
|
||||
* @param modelName The name of the model property on the custom form control.
|
||||
*/
|
||||
function listenToCustomControl(
|
||||
lView: LView<{} | null>,
|
||||
tNode: TNode,
|
||||
control: ɵControl<unknown>,
|
||||
componentIndex: number,
|
||||
modelName: ControlModelName,
|
||||
) {
|
||||
const outputName = modelName + 'Change';
|
||||
listenToOutput(
|
||||
tNode,
|
||||
lView,
|
||||
componentIndex,
|
||||
outputName,
|
||||
outputName,
|
||||
wrapListener(tNode, lView, (newValue: unknown) => {
|
||||
control.state().value.set(newValue);
|
||||
}),
|
||||
);
|
||||
|
||||
const tView = getTView();
|
||||
const componentDef = tView.data[componentIndex] as ComponentDef<unknown>;
|
||||
const touchedOutputName = 'touchedChange';
|
||||
if (hasOutput(componentDef, touchedOutputName)) {
|
||||
listenToOutput(
|
||||
tNode,
|
||||
lView,
|
||||
componentIndex,
|
||||
touchedOutputName,
|
||||
touchedOutputName,
|
||||
wrapListener(tNode, lView, () => {
|
||||
control.state().markAsTouched();
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A type for {@link HTMLTextAreaElement} that can be narrowed by its `type` property.
|
||||
*
|
||||
* The `type` property of a {@link HTMLTextAreaElement} should always be 'textarea', but the
|
||||
* TypeScript DOM API type definition lacks this detail.
|
||||
*
|
||||
* https://developer.mozilla.org/en-US/docs/Web/API/HTMLTextAreaElement/type
|
||||
*/
|
||||
interface HTMLTextAreaElementNarrowed extends HTMLTextAreaElement {
|
||||
readonly type: 'textarea';
|
||||
}
|
||||
|
||||
/**
|
||||
* Supported native control element types.
|
||||
*/
|
||||
type NativeControlElement = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElementNarrowed;
|
||||
|
||||
function isNativeControl(lView: LView<unknown>, tNode: TNode): boolean {
|
||||
const element = lView[tNode.index];
|
||||
return (
|
||||
element instanceof HTMLInputElement ||
|
||||
element instanceof HTMLSelectElement ||
|
||||
element instanceof HTMLTextAreaElement
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds event listeners to a native form control element to notify the `control` of changes.
|
||||
*
|
||||
* @param lView The `LView` that contains the native form control.
|
||||
* @param tNode The `TNode` of the native form control.
|
||||
* @param control The `ɵControl` directive instance.
|
||||
*/
|
||||
function listenToNativeControl(lView: LView<{} | null>, tNode: TNode, control: ɵControl<unknown>) {
|
||||
const tView = getTView();
|
||||
const renderer = lView[RENDERER];
|
||||
const inputListener = () => {
|
||||
const element = getNativeByTNode(tNode, lView) as NativeControlElement;
|
||||
const value = control.state().value;
|
||||
value.set(getNativeControlValue(element, value));
|
||||
};
|
||||
listenToDomEvent(
|
||||
tNode,
|
||||
tView,
|
||||
lView,
|
||||
undefined,
|
||||
renderer,
|
||||
'input',
|
||||
inputListener,
|
||||
wrapListener(tNode, lView, inputListener),
|
||||
);
|
||||
|
||||
const blurListener = () => {
|
||||
// TODO: https://github.com/orgs/angular/projects/60/views/1?pane=issue&itemId=131860538
|
||||
control.state().markAsTouched();
|
||||
};
|
||||
listenToDomEvent(
|
||||
tNode,
|
||||
tView,
|
||||
lView,
|
||||
undefined,
|
||||
renderer,
|
||||
'blur',
|
||||
blurListener,
|
||||
wrapListener(tNode, lView, blurListener),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the inputs of a custom form control component with the latest state from the `control`.
|
||||
*
|
||||
* @param lView The `LView` that contains the custom form control.
|
||||
* @param componentIndex The index of the custom form control component in the `LView`.
|
||||
* @param modelName The name of the model property on the custom form control.
|
||||
* @param control The `ɵControl` directive instance.
|
||||
*/
|
||||
function updateCustomControl(
|
||||
lView: LView,
|
||||
componentIndex: number,
|
||||
modelName: ControlModelName,
|
||||
control: ɵControl<unknown>,
|
||||
) {
|
||||
const tView = getTView();
|
||||
const component = lView[componentIndex];
|
||||
const componentDef = tView.data[componentIndex] as ComponentDef<{}>;
|
||||
const state = control.state();
|
||||
// TODO: https://github.com/orgs/angular/projects/60/views/1?pane=issue&itemId=131711472
|
||||
// * check if bindings changed before writing.
|
||||
// * cache which inputs exist.
|
||||
writeToDirectiveInput(componentDef, component, modelName, state.value());
|
||||
maybeWriteToDirectiveInput(componentDef, component, 'errors', state.errors);
|
||||
maybeWriteToDirectiveInput(componentDef, component, 'disabled', state.disabled);
|
||||
maybeWriteToDirectiveInput(componentDef, component, 'disabledReasons', state.disabledReasons);
|
||||
maybeWriteToDirectiveInput(componentDef, component, 'max', state.max);
|
||||
maybeWriteToDirectiveInput(componentDef, component, 'maxLength', state.maxLength);
|
||||
maybeWriteToDirectiveInput(componentDef, component, 'min', state.max);
|
||||
maybeWriteToDirectiveInput(componentDef, component, 'minLength', state.minLength);
|
||||
maybeWriteToDirectiveInput(componentDef, component, 'name', state.name);
|
||||
maybeWriteToDirectiveInput(componentDef, component, 'pattern', state.pattern);
|
||||
maybeWriteToDirectiveInput(componentDef, component, 'readOnly', state.readonly);
|
||||
maybeWriteToDirectiveInput(componentDef, component, 'required', state.required);
|
||||
maybeWriteToDirectiveInput(componentDef, component, 'touched', state.touched);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the specified value to a directive input if the input exists.
|
||||
*
|
||||
* @param componentDef The definition of the component that owns the input.
|
||||
* @param component The component instance.
|
||||
* @param inputName The name of the input to write to.
|
||||
* @param source A function that returns the value to write.
|
||||
*/
|
||||
function maybeWriteToDirectiveInput(
|
||||
componentDef: ComponentDef<unknown>,
|
||||
component: unknown,
|
||||
inputName: string,
|
||||
source: () => unknown,
|
||||
) {
|
||||
if (inputName in componentDef.inputs) {
|
||||
writeToDirectiveInput(componentDef, component, inputName, source());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the properties of a native form control element with the latest state from the `control`.
|
||||
*
|
||||
* @param tNode The `TNode` of the native form control.
|
||||
* @param lView The `LView` that contains the native form control.
|
||||
* @param control The `ɵControl` directive instance.
|
||||
*/
|
||||
function updateNativeControl(tNode: TNode, lView: LView, control: ɵControl<unknown>): void {
|
||||
const input = getNativeByTNode(tNode, lView) as HTMLInputElement;
|
||||
const renderer = lView[RENDERER];
|
||||
const state = control.state();
|
||||
|
||||
// TODO: https://github.com/orgs/angular/projects/60/views/1?pane=issue&itemId=131711472
|
||||
// * check if bindings changed before writing.
|
||||
setNativeControlValue(input, state.value());
|
||||
renderer.setAttribute(input, 'name', state.name());
|
||||
setBooleanAttribute(renderer, input, 'disable', state.disabled());
|
||||
setBooleanAttribute(renderer, input, 'readonly', state.readonly());
|
||||
setBooleanAttribute(renderer, input, 'required', state.required());
|
||||
|
||||
// TODO: https://github.com/orgs/angular/projects/60/views/1?pane=issue&itemId=131711472
|
||||
// * use tag and type attribute to determine which of these properties to bind.
|
||||
setOptionalAttribute(renderer, input, 'max', state.max());
|
||||
setOptionalAttribute(renderer, input, 'maxLength', state.maxLength());
|
||||
setOptionalAttribute(renderer, input, 'min', state.min());
|
||||
setOptionalAttribute(renderer, input, 'minLength', state.minLength());
|
||||
}
|
||||
|
||||
/** Checks if a given value is a Date or null */
|
||||
function isDateOrNull(value: unknown): value is Date | null {
|
||||
return value === null || value instanceof Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value from a native control element.
|
||||
*
|
||||
* @param element The native control element.
|
||||
* @param currentValue A function that returns the current value from the control's corresponding
|
||||
* field state.
|
||||
*
|
||||
* The type of the returned value depends on the `type` property of the control, and will attempt to
|
||||
* match the current value's type. For example, the value of `<input type="number">` can be read as
|
||||
* a `string` or a `number`. If the current value is a `number`, then this will return a `number`.
|
||||
* Otherwise, this will return the value as a `string`.
|
||||
*/
|
||||
function getNativeControlValue(
|
||||
element: NativeControlElement,
|
||||
currentValue: () => unknown,
|
||||
): unknown {
|
||||
// Special cases for specific input types.
|
||||
switch (element.type) {
|
||||
case 'checkbox':
|
||||
return element.checked;
|
||||
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 currentValue() === 'number') {
|
||||
return element.valueAsNumber;
|
||||
}
|
||||
break;
|
||||
case 'date':
|
||||
case 'month':
|
||||
case 'time':
|
||||
case 'week':
|
||||
// We can read a `Date | null`, `number`, or `string` from this input type. Prefer whichever
|
||||
// is consistent with the current type.
|
||||
const value = currentValue();
|
||||
if (isDateOrNull(value)) {
|
||||
return element.valueAsDate;
|
||||
} else if (typeof value === 'number') {
|
||||
return element.valueAsNumber;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Default to reading the value as a string.
|
||||
return element.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a native control element's value.
|
||||
*
|
||||
* @param element The native control element.
|
||||
* @param value The new value to set.
|
||||
*/
|
||||
function setNativeControlValue(element: NativeControlElement, value: unknown) {
|
||||
// Special cases for specific input types.
|
||||
switch (element.type) {
|
||||
case 'checkbox':
|
||||
element.checked = value as boolean;
|
||||
return;
|
||||
case 'radio':
|
||||
// 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).
|
||||
element.checked = value === element.value;
|
||||
return;
|
||||
case 'number':
|
||||
case 'range':
|
||||
case 'datetime-local':
|
||||
// This input type can receive a `number` or a `string`.
|
||||
if (typeof value === 'number') {
|
||||
element.valueAsNumber = value;
|
||||
return;
|
||||
}
|
||||
break;
|
||||
case 'date':
|
||||
case 'month':
|
||||
case 'time':
|
||||
case 'week':
|
||||
// This input type can receive a `Date | null` or a `number` or a `string`.
|
||||
if (isDateOrNull(value)) {
|
||||
element.valueAsDate = value;
|
||||
return;
|
||||
} else if (typeof value === 'number') {
|
||||
element.valueAsNumber = value;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Default to setting the value as a string.
|
||||
element.value = value as string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a boolean attribute on an element.
|
||||
*
|
||||
* @param renderer The `Renderer` instance to use.
|
||||
* @param element The element to set the attribute on.
|
||||
* @param name The name of the attribute.
|
||||
* @param value The boolean value of the attribute. If `true`, the attribute is added. If `false`,
|
||||
* the attribute is removed.
|
||||
*/
|
||||
function setBooleanAttribute(
|
||||
renderer: Renderer,
|
||||
element: HTMLElement,
|
||||
name: string,
|
||||
value: boolean,
|
||||
) {
|
||||
if (value) {
|
||||
renderer.setAttribute(element, name, '');
|
||||
} else {
|
||||
renderer.removeAttribute(element, name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets an attribute on an element if the value is not `undefined`.
|
||||
*
|
||||
* @param renderer The `Renderer` instance to use.
|
||||
* @param element The element to set the attribute on.
|
||||
* @param name The name of the attribute.
|
||||
* @param value The value of the attribute. If `undefined`, the attribute is removed.
|
||||
*/
|
||||
function setOptionalAttribute(
|
||||
renderer: Renderer,
|
||||
element: HTMLElement,
|
||||
name: string,
|
||||
value?: {toString(): string},
|
||||
) {
|
||||
if (value !== undefined) {
|
||||
renderer.setAttribute(element, name, value.toString());
|
||||
} else {
|
||||
renderer.removeAttribute(element, name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* @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 {Signal} from '../reactivity/api';
|
||||
import {WritableSignal} from '../reactivity/signal';
|
||||
|
||||
/** A unique symbol used to identify {@link ɵControl} implementations. */
|
||||
export const ɵCONTROL: unique symbol = Symbol('CONTROL');
|
||||
|
||||
/**
|
||||
* A directive that binds a {@link ɵFieldState} to a form control.
|
||||
*/
|
||||
export interface ɵControl<T> {
|
||||
readonly [ɵCONTROL]: undefined;
|
||||
|
||||
/** The state of the field bound to this control. */
|
||||
readonly state: Signal<ɵFieldState<T>>;
|
||||
|
||||
/**
|
||||
* Registers this directive as a control of its associated form field.
|
||||
*
|
||||
* The presence of this directive alone is not sufficient to determine whether it'll control
|
||||
* the bound field. If this directive's host is a component with a `control` input, we assume
|
||||
* the component will forward the bound field to another control directive in its own template,
|
||||
* and do nothing.
|
||||
*/
|
||||
register(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The state of a form field to be synchronized with its bound control.
|
||||
*/
|
||||
export interface ɵFieldState<T> {
|
||||
/**
|
||||
* A signal containing the current errors for the field.
|
||||
*/
|
||||
readonly errors: Signal<unknown>;
|
||||
|
||||
/**
|
||||
* 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<unknown>;
|
||||
|
||||
/**
|
||||
* A signal indicating the field's maximum value, if applicable.
|
||||
*
|
||||
* Applies to `<input>` with a numeric or date `type` attribute and custom controls.
|
||||
*/
|
||||
readonly max: Signal<number | undefined>;
|
||||
|
||||
/**
|
||||
* A signal indicating the field's maximum string length, if applicable.
|
||||
*
|
||||
* Applies to `<input>`, `<textarea>`, and custom controls.
|
||||
*/
|
||||
readonly maxLength: Signal<number | undefined>;
|
||||
|
||||
/**
|
||||
* A signal indicating the field's minimum value, if applicable.
|
||||
*
|
||||
* Applies to `<input>` with a numeric or date `type` attribute and custom controls.
|
||||
*/
|
||||
readonly min: Signal<number | undefined>;
|
||||
|
||||
/**
|
||||
* A signal indicating the field's minimum string length, if applicable.
|
||||
*
|
||||
* Applies to `<input>`, `<textarea>`, and custom controls.
|
||||
*/
|
||||
readonly minLength: Signal<number | undefined>;
|
||||
|
||||
/**
|
||||
* A signal of a unique name for the field, by default based on the name of its parent field.
|
||||
*/
|
||||
readonly name: Signal<string>;
|
||||
|
||||
/**
|
||||
* A signal indicating the patterns the field must match.
|
||||
*/
|
||||
readonly pattern: Signal<readonly RegExp[]>;
|
||||
|
||||
/**
|
||||
* A signal indicating whether the field is currently readonly.
|
||||
*/
|
||||
readonly readonly: Signal<boolean>;
|
||||
|
||||
/**
|
||||
* A signal indicating whether the field is required.
|
||||
*/
|
||||
readonly required: Signal<boolean>;
|
||||
|
||||
/**
|
||||
* A signal indicating whether the field has been touched by the user.
|
||||
*/
|
||||
readonly touched: Signal<boolean>;
|
||||
|
||||
/**
|
||||
* 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<T>;
|
||||
|
||||
/**
|
||||
* Sets the dirty status of the field to `true`.
|
||||
*/
|
||||
markAsDirty(): void;
|
||||
|
||||
/**
|
||||
* Sets the touched status of the field to `true`.
|
||||
*/
|
||||
markAsTouched(): void;
|
||||
}
|
||||
@@ -91,6 +91,7 @@ export const angularCoreEnv: {[name: string]: unknown} = (() => ({
|
||||
'ɵɵariaProperty': r3.ɵɵariaProperty,
|
||||
'ɵɵproperty': r3.ɵɵproperty,
|
||||
'ɵɵcontrol': r3.ɵɵcontrol,
|
||||
'ɵɵcontrolCreate': r3.ɵɵcontrolCreate,
|
||||
'ɵɵpipe': r3.ɵɵpipe,
|
||||
'ɵɵqueryRefresh': r3.ɵɵqueryRefresh,
|
||||
'ɵɵqueryAdvance': r3.ɵɵqueryAdvance,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user