Skip to content
93 changes: 76 additions & 17 deletions goldens/public-api/forms/signals/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ export type CompatSchemaPath<TControl extends AbstractControl, TPathKind extends
};
};

// @public
export function createLimitSelectionKey(): LimitSelectionKey;

// @public
export function createManagedMetadataKey<TRead, TWrite>(create: (state: FieldState<unknown>, data: Signal<TWrite | undefined>) => TRead): MetadataKey<TRead, TWrite, TWrite | undefined>;

Expand Down Expand Up @@ -158,7 +161,7 @@ export function form<TModel>(model: WritableSignal<TModel>, schema: SchemaOrSche
export const FORM_FIELD: InjectionToken<FormField<unknown>>;

// @public
export interface FormCheckboxControl extends FormUiControl {
export interface FormCheckboxControl extends FormUiControl<boolean> {
readonly checked: ModelSignal<boolean>;
readonly value?: undefined;
}
Expand Down Expand Up @@ -225,17 +228,17 @@ export interface FormSubmitOptions<TRootModel, TSubmittedModel> {
}

// @public
export interface FormUiControl {
export interface FormUiControl<TValue> {
readonly dirty?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
readonly disabled?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
readonly disabledReasons?: InputSignal<readonly WithOptionalFieldTree<DisabledReason>[]> | InputSignalWithTransform<readonly WithOptionalFieldTree<DisabledReason>[], unknown>;
readonly errors?: InputSignal<readonly ValidationError.WithOptionalFieldTree[]> | InputSignalWithTransform<readonly ValidationError.WithOptionalFieldTree[], unknown>;
focus?(options?: FocusOptions): void;
readonly hidden?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
readonly invalid?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
readonly max?: InputSignal<number | undefined> | InputSignalWithTransform<number | undefined, unknown>;
readonly max?: InputSignal<NonNullable<TValue> | undefined> | InputSignalWithTransform<NonNullable<TValue> | undefined, unknown>;
readonly maxLength?: InputSignal<number | undefined> | InputSignalWithTransform<number | undefined, unknown>;
readonly min?: InputSignal<number | undefined> | InputSignalWithTransform<number | undefined, unknown>;
readonly min?: InputSignal<NonNullable<TValue> | undefined> | InputSignalWithTransform<NonNullable<TValue> | undefined, unknown>;
readonly minLength?: InputSignal<number | undefined> | InputSignalWithTransform<number | undefined, unknown>;
readonly name?: InputSignal<string> | InputSignalWithTransform<string, unknown>;
readonly pattern?: InputSignal<readonly RegExp[]> | InputSignalWithTransform<readonly RegExp[], unknown>;
Expand All @@ -247,7 +250,7 @@ export interface FormUiControl {
}

// @public
export interface FormValueControl<TValue> extends FormUiControl {
export interface FormValueControl<TValue> extends FormUiControl<TValue> {
readonly checked?: undefined;
readonly value: ModelSignal<TValue>;
}
Expand Down Expand Up @@ -280,6 +283,14 @@ export interface ItemFieldContext<TValue> extends ChildFieldContext<TValue> {
// @public
export type ItemType<T extends Object> = T extends ReadonlyArray<any> ? T[number] : T[keyof T];

// @public
export type LimitKey<TLimit> = MetadataKey<Signal<NonNullable<TLimit> | undefined>, NonNullable<TLimit> | undefined, NonNullable<TLimit> | undefined>;

// @public
export type LimitSelectionKey = MetadataKey<Signal<LimitKey<unknown> | undefined>, LimitKey<unknown>, LimitKey<unknown> | undefined> & {
[LIMIT_SELECTION_KEY]: true;
};

// @public
export type LogicFn<TValue, TReturn, TPathKind extends PathKind = PathKind.Root> = (ctx: FieldContext<TValue, TPathKind>) => TReturn;

Expand All @@ -292,13 +303,37 @@ export interface MarkAsTouchedOptions {
}

// @public
export const MAX: MetadataKey<Signal<number | undefined>, number | undefined, number | undefined>;
export const MAX: LimitSelectionKey;

// @public
export function max<TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<number | string | null, SchemaPathRules.Supported, TPathKind>, maxValue: number | LogicFn<number | string | null, number | undefined, TPathKind>, config?: BaseValidatorConfig<number | string | null, TPathKind>): void;
export function max<TValue extends number | null, TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>, maxValue: number | LogicFn<TValue, number | undefined, TPathKind>, config?: BaseValidatorConfig<TValue, TPathKind>): void;

// @public
export const MAX_LENGTH: MetadataKey<Signal<number | undefined>, number | undefined, number | undefined>;
export const MAX_DATE: LimitKey<Date>;

// @public
export const MAX_LENGTH: LimitKey<number>;

// @public
export const MAX_NUMBER: LimitKey<number>;

// @public
export function maxDate<TValue extends Date | null, TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>, maxDateValue: Date | LogicFn<TValue, Date | undefined, TPathKind>, config?: BaseValidatorConfig<TValue, TPathKind>): void;

// @public
export function maxDateError(maxDate: Date, options: WithFieldTree<ValidationErrorOptions>): MaxDateValidationError;

// @public
export function maxDateError(maxDate: Date, options?: ValidationErrorOptions): WithoutFieldTree<MaxDateValidationError>;

// @public
export class MaxDateValidationError extends BaseNgValidationError {
constructor(maxDate: Date, options?: ValidationErrorOptions);
// (undocumented)
readonly kind = "maxDate";
// (undocumented)
readonly maxDate: Date;
}

// @public
export function maxError(max: number, options: WithFieldTree<ValidationErrorOptions>): MaxValidationError;
Expand Down Expand Up @@ -340,7 +375,7 @@ export type MaybeFieldTree<TModel, TKey extends string | number = string | numbe
export type MaybeSchemaPathTree<TModel, TPathKind extends PathKind = PathKind.Root> = (TModel & undefined) | SchemaPathTree<Exclude<TModel, undefined>, TPathKind>;

// @public
export function metadata<TValue, TKey extends MetadataKey<any, any, any>, TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>, key: TKey, logic: NoInfer<LogicFn<TValue, MetadataSetterType<TKey>, TPathKind>>): TKey;
export function metadata<TValue, TKey extends MetadataKey<any, any, any>, TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>, key: TKey, logic: NoInfer<LogicFn<TValue, TKey extends LimitSelectionKey ? LimitKey<TValue> : MetadataSetterType<TKey>, TPathKind>>): TKey;

// @public
export class MetadataKey<TRead, TWrite, TAcc> {
Expand All @@ -360,8 +395,8 @@ export interface MetadataReducer<TAcc, TItem> {
// @public (undocumented)
export const MetadataReducer: {
readonly list: <TItem>() => MetadataReducer<TItem[], TItem | undefined>;
readonly min: () => MetadataReducer<number | undefined, number | undefined>;
readonly max: () => MetadataReducer<number | undefined, number | undefined>;
readonly min: <T extends Date | number>() => MetadataReducer<T | undefined, T | undefined>;
readonly max: <T extends Date | number>() => MetadataReducer<T | undefined, T | undefined>;
readonly or: () => MetadataReducer<boolean, boolean>;
readonly and: () => MetadataReducer<boolean, boolean>;
readonly override: typeof override;
Expand All @@ -371,13 +406,37 @@ export const MetadataReducer: {
export type MetadataSetterType<TKey> = TKey extends MetadataKey<any, infer TWrite, any> ? TWrite : never;

// @public
export const MIN: MetadataKey<Signal<number | undefined>, number | undefined, number | undefined>;
export const MIN: LimitSelectionKey;

// @public
export function min<TValue extends number | null, TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>, minValue: number | LogicFn<TValue, number | undefined, TPathKind>, config?: BaseValidatorConfig<TValue, TPathKind>): void;

// @public
export const MIN_DATE: LimitKey<Date>;

// @public
export const MIN_LENGTH: LimitKey<number>;

// @public
export function min<TValue extends number | string | null, TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>, minValue: number | LogicFn<TValue, number | undefined, TPathKind>, config?: BaseValidatorConfig<TValue, TPathKind>): void;
export const MIN_NUMBER: LimitKey<number>;

// @public
export const MIN_LENGTH: MetadataKey<Signal<number | undefined>, number | undefined, number | undefined>;
export function minDate<TValue extends Date | null, TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>, minDateValue: Date | LogicFn<TValue, Date | undefined, TPathKind>, config?: BaseValidatorConfig<TValue, TPathKind>): void;

// @public
export function minDateError(minDate: Date, options: WithFieldTree<ValidationErrorOptions>): MinDateValidationError;

// @public
export function minDateError(minDate: Date, options?: ValidationErrorOptions): WithoutFieldTree<MinDateValidationError>;

// @public
export class MinDateValidationError extends BaseNgValidationError {
constructor(minDate: Date, options?: ValidationErrorOptions);
// (undocumented)
readonly kind = "minDate";
// (undocumented)
readonly minDate: Date;
}

// @public
export function minError(min: number, options: WithFieldTree<ValidationErrorOptions>): MinValidationError;
Expand Down Expand Up @@ -422,7 +481,7 @@ export class NativeInputParseError extends BaseNgValidationError {
export const NgValidationError: abstract new () => NgValidationError;

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

// @public
export type OneOrMany<T> = T | readonly T[];
Expand Down Expand Up @@ -508,10 +567,10 @@ export interface ReadonlyFieldState<TValue, TKey extends string | number = strin
readonly hidden: Signal<boolean>;
readonly invalid: Signal<boolean>;
readonly keyInParent: Signal<TKey>;
readonly max: Signal<number | undefined> | undefined;
readonly max: Signal<NonNullable<TValue> | undefined> | undefined;
readonly maxLength: Signal<number | undefined> | undefined;
metadata<M>(key: MetadataKey<M, any, any>): M | undefined;
readonly min: Signal<number | undefined> | undefined;
readonly min: Signal<NonNullable<TValue> | undefined> | undefined;
readonly minLength: Signal<number | undefined> | undefined;
readonly name: Signal<string>;
readonly pattern: Signal<readonly RegExp[]>;
Expand Down
48 changes: 48 additions & 0 deletions packages/compiler-cli/test/ngtsc/signal_forms_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,54 @@ runInEachFileSystem(() => {
);
});

it('should allow min/max bindings on date inputs', () => {
env.write(
'test.ts',
`
import {Component, signal} from '@angular/core';
import {FormField, form} from '@angular/forms/signals';

@Component({
template: '<input type="date" [formField]="f" min="2026-01-01" max="2026-12-31"/>',
imports: [FormField]
})
export class Comp {
f = form(signal(new Date('2026-01-15')));
}
`,
);

const diags = env.driveDiagnostics();
expect(diags.length).toBe(0);
});

it('should prohibit min/max bindings on non-date inputs', () => {
env.write(
'test.ts',
`
import {Component, signal} from '@angular/core';
import {FormField, form} from '@angular/forms/signals';

@Component({
template: '<input type="number" [formField]="f" min="1" max="10"/>',
imports: [FormField]
})
export class Comp {
f = form(signal(5));
}
`,
);

const diags = env.driveDiagnostics();
expect(diags.length).toBe(2);
expect(extractMessage(diags[0])).toBe(
`Setting the 'min' attribute is not allowed on nodes using the '[formField]' directive`,
);
expect(extractMessage(diags[1])).toBe(
`Setting the 'max' attribute is not allowed on nodes using the '[formField]' directive`,
);
});

it('should infer the type of a custom value control', () => {
env.write(
'test.ts',
Expand Down
37 changes: 28 additions & 9 deletions packages/compiler/src/typecheck/ops/signal_forms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,12 @@ export class TcbNativeFieldOp extends TcbOp {
'minlength',
]);

/**
* Whether the host element has a dynamic `type` binding, meaning we cannot
* statically determine the input type.
*/
private readonly hasDynamicType: boolean;

override get optional() {
return false;
}
Expand All @@ -92,6 +98,27 @@ export class TcbNativeFieldOp extends TcbOp {
private inputType: string | null,
) {
super();

this.hasDynamicType =
this.inputType === null &&
this.node.inputs.some(
(input) =>
(input.type === BindingType.Property || input.type === BindingType.Attribute) &&
input.name === 'type',
);

const isPossiblyDateOrTime =
this.hasDynamicType ||
this.inputType === 'date' ||
this.inputType === 'time' ||
this.inputType === 'month' ||
this.inputType === 'week' ||
this.inputType === 'datetime-local';

if (isPossiblyDateOrTime) {
this.unsupportedBindingFields.delete('min');
this.unsupportedBindingFields.delete('max');
}
}

override execute(): null {
Expand Down Expand Up @@ -170,16 +197,8 @@ export class TcbNativeFieldOp extends TcbOp {
return 'string | number | Date | null';
}

const hasDynamicType =
this.inputType === null &&
this.node.inputs.some(
(input) =>
(input.type === BindingType.Property || input.type === BindingType.Attribute) &&
input.name === 'type',
);

// If the type is dynamic, check it as if it can be any of the types above.
if (hasDynamicType) {
if (this.hasDynamicType) {
return 'string | number | boolean | Date | null';
}

Expand Down
16 changes: 8 additions & 8 deletions packages/forms/signals/src/api/control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import type {DisabledReason} from './types';
* @category control
* @experimental 21.0.0
*/
export interface FormUiControl {
export interface FormUiControl<TValue> {
/**
* An input to receive the errors for the field. If implemented, the `Field` directive will
* automatically bind errors from the bound field to this input.
Expand Down Expand Up @@ -82,8 +82,8 @@ export interface FormUiControl {
* automatically bind the min value from the bound field to this input.
*/
readonly min?:
| InputSignal<number | undefined>
| InputSignalWithTransform<number | undefined, unknown>;
| InputSignal<NonNullable<TValue> | undefined>
| InputSignalWithTransform<NonNullable<TValue> | undefined, unknown>;
/**
* An input to receive the min length for the field. If implemented, the `Field` directive will
* automatically bind the min length from the bound field to this input.
Expand All @@ -96,8 +96,8 @@ export interface FormUiControl {
* automatically bind the max value from the bound field to this input.
*/
readonly max?:
| InputSignal<number | undefined>
| InputSignalWithTransform<number | undefined, unknown>;
| InputSignal<NonNullable<TValue> | undefined>
| InputSignalWithTransform<NonNullable<TValue> | undefined, unknown>;
/**
* An input to receive the max length for the field. If implemented, the `Field` directive will
* automatically bind the max length from the bound field to this input.
Expand Down Expand Up @@ -130,7 +130,7 @@ export interface FormUiControl {
// However, we don't want to add it as an actual `extends` clause to avoid confusing users.
type Check<T extends true> = T;
type FormUiControlImplementsFormFieldBindingOptions = Check<
FormUiControl extends FormFieldBindingOptions ? true : false
FormUiControl<unknown> extends FormFieldBindingOptions ? true : false
>;

/**
Expand All @@ -146,7 +146,7 @@ type FormUiControlImplementsFormFieldBindingOptions = Check<
* @category control
* @experimental 21.0.0
*/
export interface FormValueControl<TValue> extends FormUiControl {
export interface FormValueControl<TValue> extends FormUiControl<TValue> {
/**
* The value is the only required property in this contract. A component that wants to integrate
* with the `Field` directive via this contract, *must* provide a `model()` that will be kept in
Expand Down Expand Up @@ -176,7 +176,7 @@ export interface FormValueControl<TValue> extends FormUiControl {
* @experimental 21.0.0
*/
// TODO: should we make this generic extends `boolean | null` so people can use `null` for parse error?
export interface FormCheckboxControl extends FormUiControl {
export interface FormCheckboxControl extends FormUiControl<boolean> {
/**
* The checked is the only required property in this contract. A component that wants to integrate
* with the `Field` directive, *must* provide a `model()` that will be kept in sync with the
Expand Down
Loading
Loading