Skip to content
Draft
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
30 changes: 27 additions & 3 deletions goldens/public-api/forms/signals/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,6 @@ export class FormField<T> {
// @public (undocumented)
export interface FormFieldBindingOptions {
readonly focus?: (focusOptions?: FocusOptions) => void;
readonly parseErrors?: Signal<ValidationError.WithoutFieldTree[]>;
}

// @public
Expand Down Expand Up @@ -234,7 +233,6 @@ export interface FormUiControl<TValue> {
readonly min?: InputSignal<number | undefined> | InputSignalWithTransform<number | undefined, unknown>;
readonly minLength?: InputSignal<number | undefined> | InputSignalWithTransform<number | undefined, unknown>;
readonly name?: InputSignal<string> | InputSignalWithTransform<string, unknown>;
readonly parseErrors?: Signal<ValidationError.WithoutFieldTree[]>;
readonly pattern?: InputSignal<readonly RegExp[]> | InputSignalWithTransform<readonly RegExp[], unknown>;
readonly pending?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
readonly readonly?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
Expand Down Expand Up @@ -399,11 +397,20 @@ export class MinValidationError extends BaseNgValidationError {
readonly min: number;
}

// @public
export class NativeInputParseError extends BaseNgValidationError {
constructor(raw?: string, options?: ValidationErrorOptions);
// (undocumented)
readonly kind = "parse";
// (undocumented)
readonly raw?: string;
}

// @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[];
Expand Down Expand Up @@ -564,6 +571,23 @@ export function submit<TModel>(form: FieldTree<TModel>, options?: FormSubmitOpti
// @public (undocumented)
export function submit<TModel>(form: FieldTree<TModel>, action: FormSubmitOptions<TModel>['action']): Promise<boolean>;

// @public
export function transformedValue<TValue, TRaw>(value: ModelSignal<TValue>, options: TransformedValueOptions<TValue, TRaw>): TransformedValueSignal<TRaw>;

// @public
export interface TransformedValueOptions<TValue, TRaw> {
format: (value: TValue) => TRaw;
parse: (rawValue: TRaw) => {
value?: TValue;
errors?: readonly ValidationError.WithoutFieldTree[];
};
}

// @public
export interface TransformedValueSignal<TRaw> extends WritableSignal<TRaw> {
readonly parseErrors: Signal<readonly ValidationError.WithoutFieldTree[]>;
}

// @public
export type TreeValidationResult<E extends ValidationError.WithOptionalFieldTree = ValidationError.WithOptionalFieldTree> = ValidationSuccess | OneOrMany<E>;

Expand Down
Original file line number Diff line number Diff line change
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
3 changes: 2 additions & 1 deletion packages/forms/signals/public_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@
*/
export * from './src/api/control';
export * from './src/api/di';
export * from './src/directive/form_field_directive';
export * from './src/api/rules';
export * from './src/api/rules/debounce';
export * from './src/api/rules/metadata';
export * from './src/api/rules/validation/validation_errors';
export * from './src/api/structure';
export * from './src/api/transformed_value';
export * from './src/api/types';
export * from './src/directive/form_field_directive';
8 changes: 1 addition & 7 deletions packages/forms/signals/src/api/control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.dev/license
*/

import {InputSignal, InputSignalWithTransform, ModelSignal, OutputRef, Signal} from '@angular/core';
import {InputSignal, InputSignalWithTransform, ModelSignal, OutputRef} from '@angular/core';
import type {FormFieldBindingOptions} from '../directive/form_field_directive';
import type {ValidationError, WithOptionalFieldTree} from './rules/validation/validation_errors';
import type {DisabledReason} from './types';
Expand Down Expand Up @@ -117,12 +117,6 @@ export interface FormUiControl<TValue> {
readonly pattern?:
| InputSignal<readonly RegExp[]>
| InputSignalWithTransform<readonly RegExp[], unknown>;
/**
* A signal containing the current parse errors for the control.
* This allows the control to communicate to the form that there are additional validation errors
* beyond those produced by the schema, due to being unable to parse the user's input.
*/
readonly parseErrors?: Signal<ValidationError.WithoutFieldTree[]>;
/**
* Focuses the UI control.
*
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,25 @@ 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';

readonly raw?: string;

constructor(raw?: string, options?: ValidationErrorOptions) {
super(options);
if (raw !== undefined) {
this.raw = raw;
}
}
}

/**
* 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 +505,5 @@ export type NgValidationError =
| MaxLengthValidationError
| PatternValidationError
| EmailValidationError
| StandardSchemaValidationError;
| StandardSchemaValidationError
| NativeInputParseError;
127 changes: 127 additions & 0 deletions packages/forms/signals/src/api/transformed_value.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* @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 {
inject,
linkedSignal,
type ModelSignal,
type Signal,
signal,
type WritableSignal,
} from '@angular/core';
import {FORM_FIELD_PARSE_ERRORS} from '../directive/parse_errors';
import type {ValidationError} from './rules';

/**
* Options for `transformedValue`.
*
* @experimental 21.2.0
*/
export interface TransformedValueOptions<TValue, TRaw> {
/**
* Parse the raw value into the model value.
*
* Should return an object containing the parsed result, which may contain:
* - `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[]};

/**
* Format the model value into the raw value.
*/
format: (value: TValue) => TRaw;
}

/**
* A writable signal representing a "raw" UI value that is synchronized with a model signal
* via parse/format transformations.
*
* @category control
* @experimental 21.2.0
*/
export interface TransformedValueSignal<TRaw> extends WritableSignal<TRaw> {
/**
* The current parse errors resulting from the last transformation.
*/
readonly parseErrors: Signal<readonly ValidationError.WithoutFieldTree[]>;
}

/**
* Creates a writable signal representing a "raw" UI value that is transformed to/from a model
* value via `parse` and `format` functions.
*
* This utility simplifies the creation of custom form controls that parse a user-facing value
* representation into an underlying model value. For example, a numeric input that displays and
* accepts string values but stores a number.
*
* @param value The model signal to synchronize with.
* @param options Configuration including `parse` and `format` functions.
* @returns A `TransformedValueSignal` representing the raw value with parse error tracking.
* @experimental 21.2.0
*
* @example
* ```ts
* @Component({
* selector: 'number-input',
* template: `<input [value]="rawValue()" (input)="rawValue.set($event.target.value)" />`,
* })
* export class NumberInput implements FormValueControl<number | null> {
* readonly value = model.required<number | null>();
*
* protected readonly rawValue = transformedValue(this.value, {
* parse: (val) => {
* if (val === '') return {value: null};
* const num = Number(val);
* if (Number.isNaN(num)) {
* return {errors: [{kind: 'parse', message: `${val} is not numeric`}]};
* }
* return {value: num};
* },
* format: (val) => val?.toString() ?? '',
* });
* }
* ```
*/
export function transformedValue<TValue, TRaw>(
value: ModelSignal<TValue>,
options: TransformedValueOptions<TValue, TRaw>,
): TransformedValueSignal<TRaw> {
const {parse, format} = options;

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

const formFieldParseErrors = inject(FORM_FIELD_PARSE_ERRORS, {optional: true});
if (formFieldParseErrors) {
formFieldParseErrors.set(parseErrors);
}

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

result.set = (newRawValue: TRaw) => {
const result = parse(newRawValue);
parseErrors.set(result.errors ?? []);
if (result.value !== undefined) {
value.set(result.value);
}
originalSet(newRawValue);
};

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

result.parseErrors = parseErrors.asReadonly();

return result;
}
28 changes: 22 additions & 6 deletions packages/forms/signals/src/directive/control_native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,44 @@
* 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 {
signal,
type ɵControlDirectiveHost as ControlDirectiveHost,
type Signal,
type WritableSignal,
} from '@angular/core';
import type {ValidationError} from '../api/rules';
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;
// TODO: (perf) ok to always create this?
const parseErrors = signal<readonly ValidationError.WithoutFieldTree[]>([]);
parseErrorsSource.set(parseErrors);

host.listenToDom('input', () => {
const state = parent.state();
state.controlValue.set(getNativeControlValue(input, state.value));
const {value, errors} = getNativeControlValue(input, state.value);
parseErrors.set(errors ?? []);
if (value !== undefined) {
state.controlValue.set(value);
}
});

host.listenToDom('blur', () => parent.state().markAsTouched());
Expand Down
Loading
Loading