Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions goldens/public-api/forms/signals/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -416,15 +416,27 @@ export class MinValidationError extends BaseNgValidationError {
readonly min: number;
}

// @public
export class NativeInputParseError extends BaseNgValidationError {
// (undocumented)
readonly kind = "parse";
}

// @public
export const NgValidationError: abstract new () => NgValidationError;

// @public (undocumented)
export type NgValidationError = RequiredValidationError | MinValidationError | MaxValidationError | MinLengthValidationError | MaxLengthValidationError | PatternValidationError | EmailValidationError | StandardSchemaValidationError;
export type NgValidationError = RequiredValidationError | MinValidationError | MaxValidationError | MinLengthValidationError | MaxLengthValidationError | PatternValidationError | EmailValidationError | StandardSchemaValidationError | NativeInputParseError;

// @public
export type OneOrMany<T> = T | readonly T[];

// @public
export interface ParseResult<TValue> {
readonly errors?: readonly ValidationError.WithoutFieldTree[];
readonly value?: TValue;
}

// @public
export type PathKind = PathKind.Root | PathKind.Child | PathKind.Item;

Expand Down Expand Up @@ -587,10 +599,7 @@ export function transformedValue<TValue, TRaw>(value: ModelSignal<TValue>, optio
// @public
export interface TransformedValueOptions<TValue, TRaw> {
format: (value: TValue) => TRaw;
parse: (rawValue: TRaw) => {
value?: TValue;
errors?: readonly ValidationError.WithoutFieldTree[];
};
parse: (rawValue: TRaw) => ParseResult<TValue>;
Comment thread
mmalerba marked this conversation as resolved.
}

// @public
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/*!
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
Expand Down Expand Up @@ -147,6 +147,7 @@ export class TcbNativeFieldOp extends TcbOp {
return ts.factory.createUnionTypeNode([
ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword),
ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword),
ts.factory.createLiteralTypeNode(ts.factory.createNull()),
]);

case 'date':
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2728,9 +2728,9 @@ describe('type check blocks', () => {
{inputType: 'text', expectedType: 'string'},
{inputType: 'radio', expectedType: 'string'},
{inputType: 'checkbox', expectedType: 'boolean'},
{inputType: 'number', expectedType: 'string | number'},
{inputType: 'range', expectedType: 'string | number'},
{inputType: 'datetime-local', expectedType: 'string | number'},
{inputType: 'number', expectedType: 'string | number | null'},
{inputType: 'range', expectedType: 'string | number | null'},
{inputType: 'datetime-local', expectedType: 'string | number | null'},
{inputType: 'date', expectedType: 'string | number | Date | null'},
{inputType: 'month', expectedType: 'string | number | Date | null'},
{inputType: 'time', expectedType: 'string | number | Date | null'},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
* found in the LICENSE file at https://angular.dev/license
*/

import {ValidationErrors} from '@angular/forms';
import type {FormField} from '../../../directive/form_field_directive';
import type {FieldTree} from '../../types';
import type {StandardSchemaValidationError} from './standard_schema';
Expand Down Expand Up @@ -453,6 +452,16 @@ export class EmailValidationError extends BaseNgValidationError {
override readonly kind = 'email';
}

/**
* An error used to indicate that a value entered in a native input does not parse.
*
* @category validation
* @experimental 21.2.0
*/
export class NativeInputParseError extends BaseNgValidationError {
override readonly kind = 'parse';
}

/**
* The base class for all built-in, non-custom errors. This class can be used to check if an error
* is one of the standard kinds, allowing you to switch on the kind to further narrow the type.
Expand Down Expand Up @@ -487,4 +496,5 @@ export type NgValidationError =
| MaxLengthValidationError
| PatternValidationError
| EmailValidationError
| StandardSchemaValidationError;
| StandardSchemaValidationError
| NativeInputParseError;
37 changes: 23 additions & 14 deletions packages/forms/signals/src/api/transformed_value.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,26 @@ import {
linkedSignal,
type ModelSignal,
type Signal,
signal,
type WritableSignal,
} from '@angular/core';
import {FORM_FIELD_PARSE_ERRORS} from '../directive/parse_errors';
import {createParser} from '../util/parser';
import type {ValidationError} from './rules';

/**
* Result of parsing a raw value into a model value.
*/
export interface ParseResult<TValue> {
/**
* The parsed value, if parsing was successful.
*/
readonly value?: TValue;
/**
* Errors encountered during parsing, if any.
*/
readonly errors?: readonly ValidationError.WithoutFieldTree[];
}

/**
* Options for `transformedValue`.
*
Expand All @@ -30,7 +44,7 @@ export interface TransformedValueOptions<TValue, TRaw> {
* - `value`: The parsed model value. If `undefined`, the model will not be updated.
* - `errors`: Any parse errors encountered. If `undefined`, no errors are reported.
*/
parse: (rawValue: TRaw) => {value?: TValue; errors?: readonly ValidationError.WithoutFieldTree[]};
parse: (rawValue: TRaw) => ParseResult<TValue>;

/**
* Format the model value into the raw value.
Expand Down Expand Up @@ -93,35 +107,30 @@ export function transformedValue<TValue, TRaw>(
options: TransformedValueOptions<TValue, TRaw>,
): TransformedValueSignal<TRaw> {
const {parse, format} = options;
const parser = createParser(value, value.set, parse);

const parseErrors = signal<readonly ValidationError.WithoutFieldTree[]>([]);
const rawValue = linkedSignal(() => format(value()));

// Wire up the parse errors from the parser to the form field.
const formFieldParseErrors = inject(FORM_FIELD_PARSE_ERRORS, {self: true, optional: true});
if (formFieldParseErrors) {
formFieldParseErrors.set(parseErrors);
formFieldParseErrors.set(parser.errors);
}

// Create the result signal with overridden set/update and a `parseErrors` property.
const rawValue = linkedSignal(() => format(value()));
const result = rawValue as WritableSignal<TRaw> & {
parseErrors: Signal<readonly ValidationError.WithoutFieldTree[]>;
};
result.parseErrors = parser.errors;
const originalSet = result.set.bind(result);

// Notify the parser when `set` or `update` is called on the raw value
result.set = (newRawValue: TRaw) => {
const result = parse(newRawValue);
parseErrors.set(result.errors ?? []);
if (result.value !== undefined) {
value.set(result.value);
}
parser.setRawValue(newRawValue);
originalSet(newRawValue);
};

result.update = (updateFn: (value: TRaw) => TRaw) => {
result.set(updateFn(rawValue()));
};

result.parseErrors = parseErrors.asReadonly();

return result;
}
36 changes: 27 additions & 9 deletions packages/forms/signals/src/directive/control_native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,30 +5,48 @@
* 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 type {ɵControlDirectiveHost as ControlDirectiveHost} from '@angular/core';
import type {FormField} from './form_field_directive';
import {getNativeControlValue, setNativeControlValue, setNativeDomProperty} from './native';
import {observeSelectMutations} from './select';
import {
type ɵControlDirectiveHost as ControlDirectiveHost,
type Signal,
type WritableSignal,
} from '@angular/core';
import type {ValidationError} from '../api/rules';
import {createParser} from '../util/parser';
import {
bindingUpdated,
CONTROL_BINDING_NAMES,
type ControlBindingKey,
createBindings,
readFieldStateBindingValue,
type ControlBindingKey,
} from './bindings';
import type {FormField} from './form_field_directive';
import {getNativeControlValue, setNativeControlValue, setNativeDomProperty} from './native';
import {observeSelectMutations} from './select';

export function nativeControlCreate(
host: ControlDirectiveHost,
parent: FormField<unknown>,
parseErrorsSource: WritableSignal<
Signal<readonly ValidationError.WithoutFieldTree[]> | undefined
>,
): () => void {
let updateMode = false;
const input = parent.nativeFormElement;

host.listenToDom('input', () => {
const state = parent.state();
state.controlValue.set(getNativeControlValue(input, state.value));
});
// TODO: (perf) ok to always create this?
const parser = createParser(
// Read from the model value
() => parent.state().value(),
// Write to the buffered "control value"
(rawValue: unknown) => parent.state().controlValue.set(rawValue),
// Our parse function doesn't care about the raw value that gets passed in,
// It just reads the newly parsed value directly off the input element.
() => getNativeControlValue(input, parent.state().value),
);

parseErrorsSource.set(parser.errors);
// Pass undefined as the raw value since the parse function doesn't care about it.
host.listenToDom('input', () => parser.setRawValue(undefined));
host.listenToDom('blur', () => parent.state().markAsTouched());

parent.registerAsBinding();
Expand Down
8 changes: 6 additions & 2 deletions packages/forms/signals/src/directive/form_field_directive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ export class FormField<T> {
private readonly config = inject(SIGNAL_FORMS_CONFIG, {optional: true});

private readonly parseErrorsSource = signal<
Signal<ValidationError.WithoutFieldTree[]> | undefined
Signal<readonly ValidationError.WithoutFieldTree[]> | undefined
>(undefined);

/** A lazily instantiated fake `NgControl`. */
Expand Down Expand Up @@ -297,7 +297,11 @@ export class FormField<T> {
} else if (host.customControl) {
this.ɵngControlUpdate = customControlCreate(host, this as FormField<unknown>);
} else if (this.elementIsNativeFormElement) {
this.ɵngControlUpdate = nativeControlCreate(host, this as FormField<unknown>);
this.ɵngControlUpdate = nativeControlCreate(
host,
this as FormField<unknown>,
this.parseErrorsSource,
);
} else {
throw new RuntimeError(
RuntimeErrorCode.INVALID_FIELD_DIRECTIVE_HOST,
Expand Down
34 changes: 24 additions & 10 deletions packages/forms/signals/src/directive/native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
*/

import {type Renderer2, untracked} from '@angular/core';
import {NativeInputParseError, WithoutFieldTree} from '../api/rules';
import type {ParseResult} from '../api/transformed_value';

/**
* Supported native control element types.
Expand Down Expand Up @@ -63,18 +65,27 @@ export function isTextualFormElement(element: HTMLElement): boolean {
export function getNativeControlValue(
element: NativeFormControl,
currentValue: () => unknown,
): unknown {
): ParseResult<unknown> {
let modelValue: unknown;
Comment thread
mmalerba marked this conversation as resolved.

if (element.validity.badInput) {
return {
errors: [new NativeInputParseError() as WithoutFieldTree<NativeInputParseError>],
};
}

// Special cases for specific input types.
switch (element.type) {
case 'checkbox':
return element.checked;
return {value: 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 untracked(currentValue) === 'number') {
return element.valueAsNumber;
modelValue = untracked(currentValue);
if (typeof modelValue === 'number' || modelValue === null) {
return {value: element.value === '' ? null : element.valueAsNumber};
Comment thread
mmalerba marked this conversation as resolved.
}
break;
case 'date':
Expand All @@ -83,17 +94,17 @@ export function getNativeControlValue(
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 = untracked(currentValue);
if (value === null || value instanceof Date) {
return element.valueAsDate;
} else if (typeof value === 'number') {
return element.valueAsNumber;
modelValue = untracked(currentValue);
if (modelValue === null || modelValue instanceof Date) {
return {value: element.valueAsDate};
} else if (typeof modelValue === 'number') {
return {value: element.valueAsNumber};
}
break;
}

// Default to reading the value as a string.
return element.value;
return {value: element.value};
}

/**
Expand All @@ -120,6 +131,9 @@ export function setNativeControlValue(element: NativeFormControl, value: unknown
if (typeof value === 'number') {
setNativeNumberControlValue(element, value);
return;
} else if (value === null) {
element.value = '';
return;
}
break;
case 'date':
Expand Down
54 changes: 54 additions & 0 deletions packages/forms/signals/src/util/parser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* @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 {type Signal, linkedSignal} from '@angular/core';
import type {ValidationError} from '../api/rules';
import type {ParseResult} from '../api/transformed_value';

/**
* An object that handles parsing raw UI values into model values.
*/
export interface Parser<TRaw> {
/**
* Errors encountered during the last parse attempt.
*/
errors: Signal<readonly ValidationError.WithoutFieldTree[]>;
/**
* Parses the given raw value and updates the underlying model value if successful.
*/
setRawValue: (rawValue: TRaw) => void;
}

/**
* Creates a {@link Parser} that synchronizes a raw value with an underlying model value.
*
* @param getValue Function to get the current model value.
* @param setValue Function to update the model value.
* @param parse Function to parse the raw value into a {@link ParseResult}.
* @returns A {@link Parser} instance.
*/
export function createParser<TValue, TRaw>(
getValue: () => TValue,
setValue: (value: TValue) => void,
parse: (raw: TRaw) => ParseResult<TValue>,
): Parser<TRaw> {
const errors = linkedSignal({
source: getValue,
computation: () => [] as readonly ValidationError.WithoutFieldTree[],
});

const setRawValue = (rawValue: TRaw) => {
const result = parse(rawValue);
errors.set(result.errors ?? []);
if (result.value !== undefined) {
setValue(result.value);
}
};

return {errors: errors.asReadonly(), setRawValue};
}
Loading