diff --git a/goldens/public-api/forms/signals/index.api.md b/goldens/public-api/forms/signals/index.api.md index ff0a6701c970..2fbdfb836955 100644 --- a/goldens/public-api/forms/signals/index.api.md +++ b/goldens/public-api/forms/signals/index.api.md @@ -76,6 +76,9 @@ export type CompatSchemaPath(create: (state: FieldState, data: Signal) => TRead): MetadataKey; @@ -158,7 +161,7 @@ export function form(model: WritableSignal, schema: SchemaOrSche export const FORM_FIELD: InjectionToken>; // @public -export interface FormCheckboxControl extends FormUiControl { +export interface FormCheckboxControl extends FormUiControl { readonly checked: ModelSignal; readonly value?: undefined; } @@ -225,7 +228,7 @@ export interface FormSubmitOptions { } // @public -export interface FormUiControl { +export interface FormUiControl { readonly dirty?: InputSignal | InputSignalWithTransform; readonly disabled?: InputSignal | InputSignalWithTransform; readonly disabledReasons?: InputSignal[]> | InputSignalWithTransform[], unknown>; @@ -233,9 +236,9 @@ export interface FormUiControl { focus?(options?: FocusOptions): void; readonly hidden?: InputSignal | InputSignalWithTransform; readonly invalid?: InputSignal | InputSignalWithTransform; - readonly max?: InputSignal | InputSignalWithTransform; + readonly max?: InputSignal | undefined> | InputSignalWithTransform | undefined, unknown>; readonly maxLength?: InputSignal | InputSignalWithTransform; - readonly min?: InputSignal | InputSignalWithTransform; + readonly min?: InputSignal | undefined> | InputSignalWithTransform | undefined, unknown>; readonly minLength?: InputSignal | InputSignalWithTransform; readonly name?: InputSignal | InputSignalWithTransform; readonly pattern?: InputSignal | InputSignalWithTransform; @@ -247,7 +250,7 @@ export interface FormUiControl { } // @public -export interface FormValueControl extends FormUiControl { +export interface FormValueControl extends FormUiControl { readonly checked?: undefined; readonly value: ModelSignal; } @@ -280,6 +283,14 @@ export interface ItemFieldContext extends ChildFieldContext { // @public export type ItemType = T extends ReadonlyArray ? T[number] : T[keyof T]; +// @public +export type LimitKey = MetadataKey | undefined>, NonNullable | undefined, NonNullable | undefined>; + +// @public +export type LimitSelectionKey = MetadataKey | undefined>, LimitKey, LimitKey | undefined> & { + [LIMIT_SELECTION_KEY]: true; +}; + // @public export type LogicFn = (ctx: FieldContext) => TReturn; @@ -292,13 +303,37 @@ export interface MarkAsTouchedOptions { } // @public -export const MAX: MetadataKey, number | undefined, number | undefined>; +export const MAX: LimitSelectionKey; // @public -export function max(path: SchemaPath, maxValue: number | LogicFn, config?: BaseValidatorConfig): void; +export function max(path: SchemaPath, maxValue: number | LogicFn, config?: BaseValidatorConfig): void; // @public -export const MAX_LENGTH: MetadataKey, number | undefined, number | undefined>; +export const MAX_DATE: LimitKey; + +// @public +export const MAX_LENGTH: LimitKey; + +// @public +export const MAX_NUMBER: LimitKey; + +// @public +export function maxDate(path: SchemaPath, maxDateValue: Date | LogicFn, config?: BaseValidatorConfig): void; + +// @public +export function maxDateError(maxDate: Date, options: WithFieldTree): MaxDateValidationError; + +// @public +export function maxDateError(maxDate: Date, options?: ValidationErrorOptions): WithoutFieldTree; + +// @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): MaxValidationError; @@ -340,7 +375,7 @@ export type MaybeFieldTree = (TModel & undefined) | SchemaPathTree, TPathKind>; // @public -export function metadata, TPathKind extends PathKind = PathKind.Root>(path: SchemaPath, key: TKey, logic: NoInfer, TPathKind>>): TKey; +export function metadata, TPathKind extends PathKind = PathKind.Root>(path: SchemaPath, key: TKey, logic: NoInfer : MetadataSetterType, TPathKind>>): TKey; // @public export class MetadataKey { @@ -360,8 +395,8 @@ export interface MetadataReducer { // @public (undocumented) export const MetadataReducer: { readonly list: () => MetadataReducer; - readonly min: () => MetadataReducer; - readonly max: () => MetadataReducer; + readonly min: () => MetadataReducer; + readonly max: () => MetadataReducer; readonly or: () => MetadataReducer; readonly and: () => MetadataReducer; readonly override: typeof override; @@ -371,13 +406,37 @@ export const MetadataReducer: { export type MetadataSetterType = TKey extends MetadataKey ? TWrite : never; // @public -export const MIN: MetadataKey, number | undefined, number | undefined>; +export const MIN: LimitSelectionKey; + +// @public +export function min(path: SchemaPath, minValue: number | LogicFn, config?: BaseValidatorConfig): void; + +// @public +export const MIN_DATE: LimitKey; + +// @public +export const MIN_LENGTH: LimitKey; // @public -export function min(path: SchemaPath, minValue: number | LogicFn, config?: BaseValidatorConfig): void; +export const MIN_NUMBER: LimitKey; // @public -export const MIN_LENGTH: MetadataKey, number | undefined, number | undefined>; +export function minDate(path: SchemaPath, minDateValue: Date | LogicFn, config?: BaseValidatorConfig): void; + +// @public +export function minDateError(minDate: Date, options: WithFieldTree): MinDateValidationError; + +// @public +export function minDateError(minDate: Date, options?: ValidationErrorOptions): WithoutFieldTree; + +// @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): MinValidationError; @@ -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 | readonly T[]; @@ -508,10 +567,10 @@ export interface ReadonlyFieldState; readonly invalid: Signal; readonly keyInParent: Signal; - readonly max: Signal | undefined; + readonly max: Signal | undefined> | undefined; readonly maxLength: Signal | undefined; metadata(key: MetadataKey): M | undefined; - readonly min: Signal | undefined; + readonly min: Signal | undefined> | undefined; readonly minLength: Signal | undefined; readonly name: Signal; readonly pattern: Signal; diff --git a/packages/compiler-cli/test/ngtsc/signal_forms_spec.ts b/packages/compiler-cli/test/ngtsc/signal_forms_spec.ts index 4977fe18bbcc..5d14e126f328 100644 --- a/packages/compiler-cli/test/ngtsc/signal_forms_spec.ts +++ b/packages/compiler-cli/test/ngtsc/signal_forms_spec.ts @@ -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: '', + 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: '', + 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', diff --git a/packages/compiler/src/typecheck/ops/signal_forms.ts b/packages/compiler/src/typecheck/ops/signal_forms.ts index 3e89888d8405..94f0db1bd738 100644 --- a/packages/compiler/src/typecheck/ops/signal_forms.ts +++ b/packages/compiler/src/typecheck/ops/signal_forms.ts @@ -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; } @@ -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 { @@ -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'; } diff --git a/packages/forms/signals/src/api/control.ts b/packages/forms/signals/src/api/control.ts index 4c30c8c42542..a67d10e9311c 100644 --- a/packages/forms/signals/src/api/control.ts +++ b/packages/forms/signals/src/api/control.ts @@ -17,7 +17,7 @@ import type {DisabledReason} from './types'; * @category control * @experimental 21.0.0 */ -export interface FormUiControl { +export interface FormUiControl { /** * 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. @@ -82,8 +82,8 @@ export interface FormUiControl { * automatically bind the min value from the bound field to this input. */ readonly min?: - | InputSignal - | InputSignalWithTransform; + | InputSignal | undefined> + | InputSignalWithTransform | 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. @@ -96,8 +96,8 @@ export interface FormUiControl { * automatically bind the max value from the bound field to this input. */ readonly max?: - | InputSignal - | InputSignalWithTransform; + | InputSignal | undefined> + | InputSignalWithTransform | 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. @@ -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; type FormUiControlImplementsFormFieldBindingOptions = Check< - FormUiControl extends FormFieldBindingOptions ? true : false + FormUiControl extends FormFieldBindingOptions ? true : false >; /** @@ -146,7 +146,7 @@ type FormUiControlImplementsFormFieldBindingOptions = Check< * @category control * @experimental 21.0.0 */ -export interface FormValueControl extends FormUiControl { +export interface FormValueControl extends FormUiControl { /** * 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 @@ -176,7 +176,7 @@ export interface FormValueControl 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 { /** * 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 diff --git a/packages/forms/signals/src/api/rules/metadata.ts b/packages/forms/signals/src/api/rules/metadata.ts index 090ba4909231..54cc063b83a2 100644 --- a/packages/forms/signals/src/api/rules/metadata.ts +++ b/packages/forms/signals/src/api/rules/metadata.ts @@ -34,7 +34,13 @@ export function metadata< >( path: SchemaPath, key: TKey, - logic: NoInfer, TPathKind>>, + logic: NoInfer< + LogicFn< + TValue, + TKey extends LimitSelectionKey ? LimitKey : MetadataSetterType, + TPathKind + > + >, ): TKey { assertPathIsCurrent(path); @@ -67,26 +73,26 @@ export const MetadataReducer = { }, /** Creates a reducer that accumulates the min of its individual item values. */ - min(): MetadataReducer { + min(): MetadataReducer { return { reduce: (acc, item) => { if (acc === undefined || item === undefined) { return acc ?? item; } - return Math.min(acc, item); + return item < acc ? item : acc; }, getInitial: () => undefined, }; }, /** Creates a reducer that accumulates a the max of its individual item values. */ - max(): MetadataReducer { + max(): MetadataReducer { return { - reduce: (prev, next) => { - if (prev === undefined || next === undefined) { - return prev ?? next; + reduce: (acc, item) => { + if (acc === undefined || item === undefined) { + return acc ?? item; } - return Math.max(prev, next); + return item > acc ? item : acc; }, getInitial: () => undefined, }; @@ -142,18 +148,51 @@ export const IS_ASYNC_VALIDATION_RESOURCE: unique symbol = Symbol('IS_ASYNC_VALI * @experimental 21.0.0 */ export class MetadataKey { - private brand!: [TRead, TWrite, TAcc]; + private brand!: (write: TWrite) => [TRead, TAcc]; /** @internal */ [IS_ASYNC_VALIDATION_RESOURCE]?: true; - /** Use {@link reducedMetadataKey}. */ + /** Use {@link createMetadataKey}. */ protected constructor( readonly reducer: MetadataReducer, readonly create: ((state: FieldState, data: Signal) => TRead) | undefined, ) {} } +/** + * Represents metadata that is used to define a valid limit for a field. + * + * @template TLimit The type the limit value. + * @experimental 22.0.0 + */ +export type LimitKey = MetadataKey< + Signal | undefined>, + NonNullable | undefined, + NonNullable | undefined +>; + +/** + * A symbol used to tag a `MetadataKey` as representing a limit selection key. + */ +declare const LIMIT_SELECTION_KEY: unique symbol; + +/** + * Used to select a {@link LimitKey}. + * + * This indirection allows rules to bind a {@link LimitKey} of a specific limit type (e.g. `number` + * or `Date`) matching the field's type to a generic {@link MetadataKey}. + * + * @experimental 22.0.0 + */ +export type LimitSelectionKey = MetadataKey< + Signal | undefined>, + LimitKey, + LimitKey | undefined +> & { + [LIMIT_SELECTION_KEY]: true; +}; + /** * Extracts the the type that can be set into the given metadata key type using the `metadata()` rule. * @@ -242,6 +281,15 @@ export function createManagedMetadataKey( ) => MetadataKey)(reducer ?? MetadataReducer.override(), create); } +/** + * Creates a {@link LimitSelectionKey}. + * + * @experimental 22.0.0 + */ +export function createLimitSelectionKey(): LimitSelectionKey { + return createMetadataKey() as LimitSelectionKey; +} + /** * A {@link MetadataKey} representing whether the field is required. * @@ -253,28 +301,58 @@ export const REQUIRED: MetadataKey, boolean, boolean> = createMe ); /** - * A {@link MetadataKey} representing the min value of the field. + * A {@link MetadataKey} that points to another key determining the minimum value of the field. + * + * This indirection allows different keys to be used for different types of values with their + * own reducers, such as {@link MIN_DATE} and {@link MIN_NUMBER}. * * @category validation * @experimental 21.0.0 */ -export const MIN: MetadataKey< - Signal, - number | undefined, - number | undefined -> = createMetadataKey(MetadataReducer.max()); +export const MIN: LimitSelectionKey = createLimitSelectionKey(); /** - * A {@link MetadataKey} representing the max value of the field. + * A {@link MetadataKey} representing the minimum valid value of a date field. + * + * @category validation + * @experimental 22.0.0 + */ +export const MIN_DATE: LimitKey = createMetadataKey(MetadataReducer.max()); + +/** + * A {@link MetadataKey} representing the minimum valid value of a number field. + * + * @category validation + * @experimental 22.0.0 + */ +export const MIN_NUMBER: LimitKey = createMetadataKey(MetadataReducer.max()); + +/** + * A {@link MetadataKey} that points to another key determining the maximum value of the field. + * + * This indirection allows different keys to be used for different types of values with their + * own reducers, such as {@link MAX_DATE} and {@link MAX_NUMBER}. * * @category validation * @experimental 21.0.0 */ -export const MAX: MetadataKey< - Signal, - number | undefined, - number | undefined -> = createMetadataKey(MetadataReducer.min()); +export const MAX: LimitSelectionKey = createLimitSelectionKey(); + +/** + * A {@link MetadataKey} representing the maximum valid value of a date field. + * + * @category validation + * @experimental 22.0.0 + */ +export const MAX_DATE: LimitKey = createMetadataKey(MetadataReducer.min()); + +/** + * A {@link MetadataKey} representing the maximum valid value of a number field. + * + * @category validation + * @experimental 22.0.0 + */ +export const MAX_NUMBER: LimitKey = createMetadataKey(MetadataReducer.min()); /** * A {@link MetadataKey} representing the min length of the field. @@ -282,11 +360,7 @@ export const MAX: MetadataKey< * @category validation * @experimental 21.0.0 */ -export const MIN_LENGTH: MetadataKey< - Signal, - number | undefined, - number | undefined -> = createMetadataKey(MetadataReducer.max()); +export const MIN_LENGTH: LimitKey = createMetadataKey(MetadataReducer.max()); /** * A {@link MetadataKey} representing the max length of the field. @@ -294,11 +368,7 @@ export const MIN_LENGTH: MetadataKey< * @category validation * @experimental 21.0.0 */ -export const MAX_LENGTH: MetadataKey< - Signal, - number | undefined, - number | undefined -> = createMetadataKey(MetadataReducer.min()); +export const MAX_LENGTH: LimitKey = createMetadataKey(MetadataReducer.min()); /** * A {@link MetadataKey} representing the patterns the field must match. diff --git a/packages/forms/signals/src/api/rules/validation/index.ts b/packages/forms/signals/src/api/rules/validation/index.ts index 542120ce3139..38a3c6bde54b 100644 --- a/packages/forms/signals/src/api/rules/validation/index.ts +++ b/packages/forms/signals/src/api/rules/validation/index.ts @@ -8,8 +8,10 @@ export * from './email'; export * from './max'; +export * from './max_date'; export * from './max_length'; export * from './min'; +export * from './min_date'; export * from './min_length'; export * from './pattern'; export * from './required'; diff --git a/packages/forms/signals/src/api/rules/validation/max.ts b/packages/forms/signals/src/api/rules/validation/max.ts index ce13fb27cef8..a2c9340d787b 100644 --- a/packages/forms/signals/src/api/rules/validation/max.ts +++ b/packages/forms/signals/src/api/rules/validation/max.ts @@ -7,8 +7,8 @@ */ import {LogicFn, PathKind, SchemaPath, SchemaPathRules} from '../../types'; -import {createMetadataKey, MAX, metadata} from '../metadata'; -import {BaseValidatorConfig, getOption, isEmpty} from './util'; +import {createMetadataKey, MAX, MAX_NUMBER, metadata, LimitKey} from '../metadata'; +import {BaseValidatorConfig, getOption} from './util'; import {validate} from './validate'; import {maxError} from './validation_errors'; @@ -16,6 +16,7 @@ import {maxError} from './validation_errors'; * Binds a validator to the given path that requires the value to be less than or equal to the * given `maxValue`. * This function can only be called on number paths. + * This function can only be called on number paths. * In addition to binding a validator, this function adds `MAX` property to the field. * * @param path Path of the field to validate @@ -29,26 +30,33 @@ import {maxError} from './validation_errors'; * @category validation * @experimental 21.0.0 */ -export function max( - path: SchemaPath, - maxValue: number | LogicFn, - config?: BaseValidatorConfig, -) { - const MAX_MEMO = metadata(path, createMetadataKey(), (ctx) => - typeof maxValue === 'number' ? maxValue : maxValue(ctx), - ); - metadata(path, MAX, ({state}) => state.metadata(MAX_MEMO)!()); +export function max( + path: SchemaPath, + maxValue: number | LogicFn, + config?: BaseValidatorConfig, +): void { + const MAX_MEMO = createMetadataKey(); + + // Memoize the maximum valid value. + metadata(path, MAX_MEMO, (ctx) => (typeof maxValue === 'function' ? maxValue(ctx) : maxValue)); + + // Publish the memoized maximum value for aggregation. + metadata(path, MAX_NUMBER, ({state}) => state.metadata(MAX_MEMO)!()); + + // Use `MAX_NUMBER` to define the `max` property of the field. + metadata(path, MAX, () => MAX_NUMBER as LimitKey); + + // Validate that the field value is not greater than the maximum value. validate(path, (ctx) => { - if (isEmpty(ctx.value())) { + const value = ctx.value(); + if (value === null || Number.isNaN(value)) { return undefined; } const max = ctx.state.metadata(MAX_MEMO)!(); if (max === undefined || Number.isNaN(max)) { return undefined; } - const value = ctx.value(); - const numValue = !value && value !== 0 ? NaN : Number(value); // Treat `''` and `null` as `NaN` - if (numValue > max) { + if (value > max) { if (config?.error) { return getOption(config.error, ctx); } else { diff --git a/packages/forms/signals/src/api/rules/validation/max_date.ts b/packages/forms/signals/src/api/rules/validation/max_date.ts new file mode 100644 index 000000000000..caef2baaa049 --- /dev/null +++ b/packages/forms/signals/src/api/rules/validation/max_date.ts @@ -0,0 +1,69 @@ +/** + * @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 {LogicFn, PathKind, SchemaPath, SchemaPathRules} from '../../types'; +import {createMetadataKey, LimitKey, MAX, MAX_DATE, metadata} from '../metadata'; +import {BaseValidatorConfig, getOption} from './util'; +import {validate} from './validate'; +import {maxDateError} from './validation_errors'; + +/** + * Binds a validator to the given path that requires the value to be less than or equal to the + * given `maxDate`. + * This function can only be called on date paths. + * In addition to binding a validator, this function adds `MAX` property to the field. + * + * @param path Path of the field to validate + * @param maxDate The maximum date, or a LogicFn that returns the maximum date. + * @param config Optional, allows providing any of the following options: + * - `error`: Custom validation error(s) to be used instead of the default `ValidationError.max(maxDate)` + * or a function that receives the `FieldContext` and returns custom validation error(s). + * @template TPathKind The kind of path the logic is bound to (a root path, child path, or item of an array) + * + * @see [Signal Form Max Validation](guide/forms/signals/validation#min-and-max) + * @category validation + * @experimental 22.0.0 + */ +export function maxDate( + path: SchemaPath, + maxDateValue: Date | LogicFn, + config?: BaseValidatorConfig, +): void { + const MAX_MEMO = createMetadataKey(); + + // Memoize the maximum valid date. + metadata(path, MAX_MEMO, (ctx) => + typeof maxDateValue === 'function' ? maxDateValue(ctx) : maxDateValue, + ); + + // Publish the memoized maximum date for aggregation. + metadata(path, MAX_DATE, ({state}) => state.metadata(MAX_MEMO)!()); + + // Use `MAX_DATE` to define the `max` property of the field. + metadata(path, MAX, () => MAX_DATE as LimitKey); + + // Validate that the field value is not greater than the maximum date. + validate(path, (ctx) => { + const value = ctx.value(); + if (value === null || Number.isNaN(value.getTime())) { + return undefined; + } + const max = ctx.state.metadata(MAX_MEMO)!(); + if (max === undefined || Number.isNaN(max.getTime())) { + return undefined; + } + if (value > max) { + if (config?.error) { + return getOption(config.error, ctx); + } else { + return maxDateError(max, {message: getOption(config?.message, ctx)}); + } + } + return undefined; + }); +} diff --git a/packages/forms/signals/src/api/rules/validation/min.ts b/packages/forms/signals/src/api/rules/validation/min.ts index 14712ad01785..9b9ac4fbc521 100644 --- a/packages/forms/signals/src/api/rules/validation/min.ts +++ b/packages/forms/signals/src/api/rules/validation/min.ts @@ -7,8 +7,8 @@ */ import {LogicFn, PathKind, SchemaPath, SchemaPathRules} from '../../types'; -import {createMetadataKey, metadata, MIN} from '../metadata'; -import {BaseValidatorConfig, getOption, isEmpty} from './util'; +import {createMetadataKey, metadata, LimitKey, MIN, MIN_NUMBER} from '../metadata'; +import {BaseValidatorConfig, getOption} from './util'; import {validate} from './validate'; import {minError} from './validation_errors'; @@ -16,6 +16,7 @@ import {minError} from './validation_errors'; * Binds a validator to the given path that requires the value to be greater than or equal to * the given `minValue`. * This function can only be called on number paths. + * This function can only be called on number paths. * In addition to binding a validator, this function adds `MIN` property to the field. * * @param path Path of the field to validate @@ -29,29 +30,33 @@ import {minError} from './validation_errors'; * @category validation * @experimental 21.0.0 */ -export function min< - TValue extends number | string | null, - TPathKind extends PathKind = PathKind.Root, ->( +export function min( path: SchemaPath, minValue: number | LogicFn, config?: BaseValidatorConfig, -) { - const MIN_MEMO = metadata(path, createMetadataKey(), (ctx) => - typeof minValue === 'number' ? minValue : minValue(ctx), - ); - metadata(path, MIN, ({state}) => state.metadata(MIN_MEMO)!()); +): void { + const MIN_MEMO = createMetadataKey(); + + // Memomize the minimum valid. + metadata(path, MIN_MEMO, (ctx) => (typeof minValue === 'function' ? minValue(ctx) : minValue)); + + // Publish the memoized mininum value for aggregation. + metadata(path, MIN_NUMBER, ({state}) => state.metadata(MIN_MEMO)!()); + + // Use `MIN_NUMBER` to define the `min` property of the field. + metadata(path, MIN, () => MIN_NUMBER as LimitKey); + + // Validate that the field value is not less than the minimum value. validate(path, (ctx) => { - if (isEmpty(ctx.value())) { + const value = ctx.value(); + if (value === null || Number.isNaN(value)) { return undefined; } const min = ctx.state.metadata(MIN_MEMO)!(); if (min === undefined || Number.isNaN(min)) { return undefined; } - const value = ctx.value(); - const numValue = !value && value !== 0 ? NaN : Number(value); // Treat `''` and `null` as `NaN` - if (numValue < min) { + if (value < min) { if (config?.error) { return getOption(config.error, ctx); } else { diff --git a/packages/forms/signals/src/api/rules/validation/min_date.ts b/packages/forms/signals/src/api/rules/validation/min_date.ts new file mode 100644 index 000000000000..1c013b37488a --- /dev/null +++ b/packages/forms/signals/src/api/rules/validation/min_date.ts @@ -0,0 +1,69 @@ +/** + * @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 {LogicFn, PathKind, SchemaPath, SchemaPathRules} from '../../types'; +import {createMetadataKey, LimitKey, metadata, MIN, MIN_DATE} from '../metadata'; +import {BaseValidatorConfig, getOption} from './util'; +import {validate} from './validate'; +import {minDateError} from './validation_errors'; + +/** + * Binds a validator to the given path that requires the value to be greater than or equal to + * the given `minDate`. + * This function can only be called on date paths. + * In addition to binding a validator, this function adds `MIN` property to the field. + * + * @param path Path of the field to validate + * @param minDate The minimum date, or a LogicFn that returns the minimum date. + * @param config Optional, allows providing any of the following options: + * - `error`: Custom validation error(s) to be used instead of the default `ValidationError.min(minDate)` + * or a function that receives the `FieldContext` and returns custom validation error(s). + * @template TPathKind The kind of path the logic is bound to (a root path, child path, or item of an array) + * + * @see [Signal Form Min Validation](guide/forms/signals/validation#min-and-max) + * @category validation + * @experimental 22.0.0 + */ +export function minDate( + path: SchemaPath, + minDateValue: Date | LogicFn, + config?: BaseValidatorConfig, +): void { + const MIN_MEMO = createMetadataKey(); + + // Memoize the minimum valid date. + metadata(path, MIN_MEMO, (ctx) => + typeof minDateValue === 'function' ? minDateValue(ctx) : minDateValue, + ); + + // Publish the memoized minimum date for aggregation. + metadata(path, MIN_DATE, ({state}) => state.metadata(MIN_MEMO)!()); + + // Use `MIN_DATE` to define the `min` property of the field. + metadata(path, MIN, () => MIN_DATE as LimitKey); + + // Validate that the field value is not less than the minimum date. + validate(path, (ctx) => { + const value = ctx.value(); + if (value === null || Number.isNaN(value.getTime())) { + return undefined; + } + const min = ctx.state.metadata(MIN_MEMO)!(); + if (min === undefined || Number.isNaN(min.getTime())) { + return undefined; + } + if (value < min) { + if (config?.error) { + return getOption(config.error, ctx); + } else { + return minDateError(min, {message: getOption(config?.message, ctx)}); + } + } + return undefined; + }); +} diff --git a/packages/forms/signals/src/api/rules/validation/validation_errors.ts b/packages/forms/signals/src/api/rules/validation/validation_errors.ts index 834dbe51cb7d..e42ac90de5c3 100644 --- a/packages/forms/signals/src/api/rules/validation/validation_errors.ts +++ b/packages/forms/signals/src/api/rules/validation/validation_errors.ts @@ -100,6 +100,37 @@ export function minError( return new MinValidationError(min, options); } +/** + * Create a minDate error associated with the target field + * @param minDate The min date constraint + * @param options The validation error options + * + * @category validation + * @experimental 22.0.0 + */ +export function minDateError( + minDate: Date, + options: WithFieldTree, +): MinDateValidationError; +/** + * Create a minDate error + * @param minDate The min date constraint + * @param options The optional validation error options + * + * @category validation + * @experimental 22.0.0 + */ +export function minDateError( + minDate: Date, + options?: ValidationErrorOptions, +): WithoutFieldTree; +export function minDateError( + minDate: Date, + options?: ValidationErrorOptions, +): WithOptionalFieldTree { + return new MinDateValidationError(minDate, options); +} + /** * Create a max value error associated with the target field * @param max The max value constraint @@ -131,6 +162,37 @@ export function maxError( return new MaxValidationError(max, options); } +/** + * Create a maxDate error associated with the target field + * @param maxDate The max date constraint + * @param options The validation error options + * + * @category validation + * @experimental 22.0.0 + */ +export function maxDateError( + maxDate: Date, + options: WithFieldTree, +): MaxDateValidationError; +/** + * Create a maxDate error + * @param maxDate The max date constraint + * @param options The optional validation error options + * + * @category validation + * @experimental 22.0.0 + */ +export function maxDateError( + maxDate: Date, + options?: ValidationErrorOptions, +): WithoutFieldTree; +export function maxDateError( + maxDate: Date, + options?: ValidationErrorOptions, +): WithOptionalFieldTree { + return new MaxDateValidationError(maxDate, options); +} + /** * Create a minLength error associated with the target field * @param minLength The minLength constraint @@ -370,6 +432,23 @@ export class MinValidationError extends BaseNgValidationError { } } +/** + * An error used to indicate that a date value is earlier than the minimum allowed. + * + * @category validation + * @experimental 22.0.0 + */ +export class MinDateValidationError extends BaseNgValidationError { + override readonly kind = 'minDate'; + + constructor( + readonly minDate: Date, + options?: ValidationErrorOptions, + ) { + super(options); + } +} + /** * An error used to indicate that a value is higher than the maximum allowed. * @@ -387,6 +466,23 @@ export class MaxValidationError extends BaseNgValidationError { } } +/** + * An error used to indicate that a date value is later than the maximum allowed. + * + * @category validation + * @experimental 22.0.0 + */ +export class MaxDateValidationError extends BaseNgValidationError { + override readonly kind = 'maxDate'; + + constructor( + readonly maxDate: Date, + options?: ValidationErrorOptions, + ) { + super(options); + } +} + /** * An error used to indicate that a value is shorter than the minimum allowed length. * @@ -487,7 +583,9 @@ export const NgValidationError: abstract new () => NgValidationError = BaseNgVal export type NgValidationError = | RequiredValidationError | MinValidationError + | MinDateValidationError | MaxValidationError + | MaxDateValidationError | MinLengthValidationError | MaxLengthValidationError | PatternValidationError diff --git a/packages/forms/signals/src/api/types.ts b/packages/forms/signals/src/api/types.ts index bb25df6a46db..95c3486dee5a 100644 --- a/packages/forms/signals/src/api/types.ts +++ b/packages/forms/signals/src/api/types.ts @@ -346,7 +346,7 @@ export interface ReadonlyFieldState` with a numeric or date `type` attribute and custom controls. */ - readonly max: Signal | undefined; + readonly max: Signal | undefined> | undefined; /** * A signal indicating the field's maximum string length, if applicable. @@ -360,7 +360,7 @@ export interface ReadonlyFieldState` with a numeric or date `type` attribute and custom controls. */ - readonly min: Signal | undefined; + readonly min: Signal | undefined> | undefined; /** * A signal indicating the field's minimum string length, if applicable. diff --git a/packages/forms/signals/src/directive/control_custom.ts b/packages/forms/signals/src/directive/control_custom.ts index d4767a61e7db..fc6d04709504 100644 --- a/packages/forms/signals/src/directive/control_custom.ts +++ b/packages/forms/signals/src/directive/control_custom.ts @@ -7,7 +7,7 @@ */ import type {ɵControlDirectiveHost as ControlDirectiveHost} from '@angular/core'; -import type {FormField} from './form_field'; +import type {FormField, FormFieldBindingOptions} from './form_field'; import { bindingUpdated, CONTROL_BINDING_NAMES, @@ -15,8 +15,7 @@ import { createBindings, readFieldStateBindingValue, } from './bindings'; -import {setNativeDomProperty} from './native'; -import {FormUiControl} from '../api/control'; +import {formatDateForMinMax, setNativeDomProperty} from './native'; export function customControlCreate( host: ControlDirectiveHost, @@ -25,7 +24,7 @@ export function customControlCreate( host.listenToCustomControlModel((value) => parent.state().controlValue.set(value)); host.listenToCustomControlOutput('touch', () => parent.state().markAsTouched()); - parent.registerAsBinding(host.customControl as FormUiControl); + parent.registerAsBinding(host.customControl as FormFieldBindingOptions); const bindings = createBindings(); return () => { @@ -50,11 +49,12 @@ export function customControlCreate( // If the host node is a native control, we can bind field state properties to native // properties for any that weren't defined as inputs on the custom control. if (parent.elementAcceptsNativeProperty(name) && !host.customControlHasInput(name)) { + const domValue = formatDateForMinMax(name, value, parent.nativeFormElement.type); setNativeDomProperty( parent.renderer, - parent.nativeFormElement!, + parent.nativeFormElement, name, - value as string | number | undefined, + domValue as string | number | boolean | undefined, ); } } diff --git a/packages/forms/signals/src/directive/control_native.ts b/packages/forms/signals/src/directive/control_native.ts index d90537886b38..584990c6c8b1 100644 --- a/packages/forms/signals/src/directive/control_native.ts +++ b/packages/forms/signals/src/directive/control_native.ts @@ -22,9 +22,10 @@ import { import type {FormField} from './form_field'; import {InputValidityMonitor} from './input_validity_monitor'; import { + formatDateForMinMax, getNativeControlValue, - isInput, inputRequiresValidityTracking, + isInput, setNativeControlValue, setNativeDomProperty, } from './native'; @@ -101,7 +102,13 @@ export function nativeControlCreate( if (bindingUpdated(bindings, name, value)) { host.setInputOnDirectives(name, value); if (parent.elementAcceptsNativeProperty(name)) { - setNativeDomProperty(parent.renderer, input, name, value as string | number | undefined); + const domValue = formatDateForMinMax(name, value, input.type); + setNativeDomProperty( + parent.renderer, + input, + name, + domValue as string | number | boolean | undefined, + ); } } } diff --git a/packages/forms/signals/src/directive/form_field.ts b/packages/forms/signals/src/directive/form_field.ts index cc9081613c49..a2c3561ad256 100644 --- a/packages/forms/signals/src/directive/form_field.ts +++ b/packages/forms/signals/src/directive/form_field.ts @@ -45,8 +45,8 @@ import {customControlCreate} from './control_custom'; import {cvaControlCreate} from './control_cva'; import {nativeControlCreate} from './control_native'; import { + elementAcceptsMinMax, isNativeFormElement, - isNumericFormElement, isTextualFormElement, type NativeFormControl, } from './native'; @@ -135,7 +135,7 @@ export class FormField { // Compute some helper booleans about the type of element we're sitting on. private readonly elementIsNativeFormElement = isNativeFormElement(this.element); private readonly elementAcceptsTextualValues = isTextualFormElement(this.element); - private _elementAcceptsNumericValues: boolean | undefined; + private _elementAcceptsMinMax: boolean | undefined; /** * Utility that casts `this.element` to `NativeFormControl` to avoid repeated type guards. Only @@ -378,8 +378,7 @@ export class FormField { switch (key) { case 'min': case 'max': - return (this._elementAcceptsNumericValues ??= isNumericFormElement(this.element)); - + return (this._elementAcceptsMinMax ??= elementAcceptsMinMax(this.element)); case 'minLength': case 'maxLength': return this.elementAcceptsTextualValues; diff --git a/packages/forms/signals/src/directive/native.ts b/packages/forms/signals/src/directive/native.ts index 6b299e9a58f5..13abd445e730 100644 --- a/packages/forms/signals/src/directive/native.ts +++ b/packages/forms/signals/src/directive/native.ts @@ -14,7 +14,7 @@ import type {InputValidityMonitor} from './input_validity_monitor'; // Re-export shared native utilities from main forms package export { ɵisNativeFormElement as isNativeFormElement, - ɵisNumericFormElement as isNumericFormElement, + ɵelementAcceptsMinMax as elementAcceptsMinMax, ɵisTextualFormElement as isTextualFormElement, ɵsetNativeDomProperty as setNativeDomProperty, type ɵNativeFormControl as NativeFormControl, @@ -177,3 +177,25 @@ export function inputRequiresValidityTracking(input: HTMLInputElement): boolean input.type === 'week' ); } + +function formatDateForInput(date: Date, type: 'date' | 'month'): string { + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, '0'); + + if (type === 'month') { + return `${year}-${month}`; + } + const day = String(date.getUTCDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +export function formatDateForMinMax(name: string, value: unknown, type: string): unknown { + if ( + value instanceof Date && + (name === 'min' || name === 'max') && + (type === 'date' || type === 'month') + ) { + return formatDateForInput(value, type); + } + return value; +} diff --git a/packages/forms/signals/src/field/debounce.ts b/packages/forms/signals/src/field/debounce.ts index 06f46b0b88c8..d24197201a82 100644 --- a/packages/forms/signals/src/field/debounce.ts +++ b/packages/forms/signals/src/field/debounce.ts @@ -17,6 +17,6 @@ import {Debouncer} from '../api/types'; */ export const DEBOUNCER: MetadataKey< Signal | undefined> | undefined, - Debouncer | undefined, + Debouncer, Debouncer | undefined > = createMetadataKey(); diff --git a/packages/forms/signals/src/field/node.ts b/packages/forms/signals/src/field/node.ts index d494ab7d67a3..cc78f6bc42df 100644 --- a/packages/forms/signals/src/field/node.ts +++ b/packages/forms/signals/src/field/node.ts @@ -224,16 +224,18 @@ export class FieldNode implements FieldState { return this.nodeState.name; } - get max(): Signal | undefined { - return this.metadata(MAX); + get max(): Signal<{} | undefined> | undefined { + const maxKey = this.metadata(MAX)?.(); + return maxKey ? this.metadata(maxKey) : undefined; } get maxLength(): Signal | undefined { return this.metadata(MAX_LENGTH); } - get min(): Signal | undefined { - return this.metadata(MIN); + get min(): Signal<{} | undefined> | undefined { + const minKey = this.metadata(MIN)?.(); + return minKey ? this.metadata(minKey) : undefined; } get minLength(): Signal | undefined { diff --git a/packages/forms/signals/test/node/api/validators/max.spec.ts b/packages/forms/signals/test/node/api/validators/max.spec.ts index 6b208f63b9f5..e1e117c64a04 100644 --- a/packages/forms/signals/test/node/api/validators/max.spec.ts +++ b/packages/forms/signals/test/node/api/validators/max.spec.ts @@ -58,7 +58,7 @@ describe('max validator', () => { (p) => { max(p.age, 5, { error: ({value}) => { - return {kind: 'special-max', message: value()?.toString()}; + return {kind: 'special-max', message: value().toString()}; }, }); }, @@ -103,7 +103,7 @@ describe('max validator', () => { error: ({value, valueOf}) => { return valueOf(p.name) === 'disabled' ? [] - : {kind: 'special-max', message: value()?.toString()}; + : {kind: 'special-max', message: value().toString()}; }, }); }, @@ -152,7 +152,7 @@ describe('max validator', () => { (p) => { max(p.age, 5, { error: ({value}) => { - return {kind: 'special-max', message: value()?.toString()}; + return {kind: 'special-max', message: value().toString()}; }, }); }, @@ -302,30 +302,4 @@ describe('max validator', () => { expect(f.age().errors()).toEqual([]); }); }); - - it('should validate properly formatted strings', () => { - const f = form( - signal('4'), - (p) => { - max(p, -10); - }, - {injector: TestBed.inject(Injector)}, - ); - expect(f().errors()).toEqual([jasmine.objectContaining({kind: 'max'})]); - }); - - it('should not validate improperly formatted strings or null', () => { - const f = form( - signal('4f'), - (p) => { - max(p, -10); - }, - {injector: TestBed.inject(Injector)}, - ); - expect(f().errors()).toEqual([]); - f().value.set(null); - expect(f().errors()).toEqual([]); - f().value.set(4); - expect(f().errors()).toEqual([jasmine.objectContaining({kind: 'max'})]); - }); }); diff --git a/packages/forms/signals/test/node/api/validators/max_date.spec.ts b/packages/forms/signals/test/node/api/validators/max_date.spec.ts new file mode 100644 index 000000000000..2ab9a5c16923 --- /dev/null +++ b/packages/forms/signals/test/node/api/validators/max_date.spec.ts @@ -0,0 +1,70 @@ +/** + * @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 {Injector, signal} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; +import {form, maxDate, maxDateError} from '../../../../public_api'; + +describe('maxDate validator', () => { + it('returns max error when the date is larger', () => { + const today = new Date('2026-04-01'); + const tomorrow = new Date('2026-04-02'); + const model = signal(tomorrow); + const f = form( + model, + (p) => { + maxDate(p, today); + }, + {injector: TestBed.inject(Injector)}, + ); + + expect(f().errors()).toEqual([maxDateError(today, {fieldTree: f})]); + }); + + it('returns no error when the date is equal', () => { + const today = new Date('2026-04-01'); + const model = signal(today); + const f = form( + model, + (p) => { + maxDate(p, today); + }, + {injector: TestBed.inject(Injector)}, + ); + + expect(f().errors()).toEqual([]); + }); + + it('returns no error when the date is smaller', () => { + const today = new Date('2026-04-01'); + const yesterday = new Date('2026-03-31'); + const model = signal(yesterday); + const f = form( + model, + (p) => { + maxDate(p, today); + }, + {injector: TestBed.inject(Injector)}, + ); + + expect(f().errors()).toEqual([]); + }); + + it('handles invalid dates', () => { + const model = signal(new Date('invalid')); + const f = form( + model, + (p) => { + maxDate(p, new Date('2026-04-01')); + }, + {injector: TestBed.inject(Injector)}, + ); + + expect(f().errors()).toEqual([]); + }); +}); diff --git a/packages/forms/signals/test/node/api/validators/min.spec.ts b/packages/forms/signals/test/node/api/validators/min.spec.ts index a6eb9e7ac9b4..64d19b56125c 100644 --- a/packages/forms/signals/test/node/api/validators/min.spec.ts +++ b/packages/forms/signals/test/node/api/validators/min.spec.ts @@ -8,7 +8,7 @@ import {Injector, signal} from '@angular/core'; import {TestBed} from '@angular/core/testing'; -import {form, min, minError} from '../../../../public_api'; +import {form, min, minDate, minError} from '../../../../public_api'; describe('min validator', () => { it('returns min error when the value is smaller', () => { @@ -311,30 +311,4 @@ describe('min validator', () => { expect(f.age().errors()).toEqual([]); }); }); - - it('should validate properly formatted strings', () => { - const f = form( - signal('4'), - (p) => { - min(p, 10); - }, - {injector: TestBed.inject(Injector)}, - ); - expect(f().errors()).toEqual([jasmine.objectContaining({kind: 'min'})]); - }); - - it('should not validate improperly formatted strings or null', () => { - const f = form( - signal('4f'), - (p) => { - min(p, 10); - }, - {injector: TestBed.inject(Injector)}, - ); - expect(f().errors()).toEqual([]); - f().value.set(null); - expect(f().errors()).toEqual([]); - f().value.set(4); - expect(f().errors()).toEqual([jasmine.objectContaining({kind: 'min'})]); - }); }); diff --git a/packages/forms/signals/test/node/api/validators/min_date.spec.ts b/packages/forms/signals/test/node/api/validators/min_date.spec.ts new file mode 100644 index 000000000000..3c735119405c --- /dev/null +++ b/packages/forms/signals/test/node/api/validators/min_date.spec.ts @@ -0,0 +1,70 @@ +/** + * @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 {Injector, signal} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; +import {form, minDate, minDateError} from '../../../../public_api'; + +describe('minDate validator', () => { + it('returns min error when the date is smaller', () => { + const today = new Date('2026-04-01'); + const yesterday = new Date('2026-03-31'); + const model = signal(yesterday); + const f = form( + model, + (p) => { + minDate(p, today); + }, + {injector: TestBed.inject(Injector)}, + ); + + expect(f().errors()).toEqual([minDateError(today, {fieldTree: f})]); + }); + + it('returns no error when the date is equal', () => { + const today = new Date('2026-04-01'); + const model = signal(today); + const f = form( + model, + (p) => { + minDate(p, today); + }, + {injector: TestBed.inject(Injector)}, + ); + + expect(f().errors()).toEqual([]); + }); + + it('returns no error when the date is larger', () => { + const today = new Date('2026-04-01'); + const tomorrow = new Date('2026-04-02'); + const model = signal(tomorrow); + const f = form( + model, + (p) => { + minDate(p, today); + }, + {injector: TestBed.inject(Injector)}, + ); + + expect(f().errors()).toEqual([]); + }); + + it('handles invalid dates', () => { + const model = signal(new Date('invalid')); + const f = form( + model, + (p) => { + minDate(p, new Date('2026-04-01')); + }, + {injector: TestBed.inject(Injector)}, + ); + + expect(f().errors()).toEqual([]); + }); +}); diff --git a/packages/forms/signals/test/node/types.spec.ts b/packages/forms/signals/test/node/types.spec.ts index 0ac7b0550e6c..7b311e4e2b49 100644 --- a/packages/forms/signals/test/node/types.spec.ts +++ b/packages/forms/signals/test/node/types.spec.ts @@ -8,8 +8,17 @@ import {signal, WritableSignal} from '@angular/core'; import { + createMetadataKey, FieldTree, form, + LimitKey, + MIN, + MIN_DATE, + MIN_NUMBER, + MAX, + MAX_DATE, + MAX_NUMBER, + metadata, provideSignalFormsConfig, ReadonlyFieldState, required, @@ -163,5 +172,60 @@ function typeVerificationOnlyDoNotRunMe() { }, }); }); + + describe('metadata', () => { + it('should prevent assigning a number limit to a Date field', () => { + interface EventBooking { + date: Date; + } + + schema((p) => { + metadata(p.date, MIN, () => MIN_DATE); + metadata( + p.date, + MIN, + // @ts-expect-error + () => MIN_NUMBER, + ); + }); + }); + + it('should prevent assigning a Date limit to a number field', () => { + interface PriceConstraint { + amount: number; + } + + schema((p) => { + metadata(p.amount, MAX, () => MAX_NUMBER); + metadata( + p.amount, + MAX, + // @ts-expect-error + () => MAX_DATE, + ); + }); + }); + + it('should not interpret a MetadataKey as a LimitSelectionKey', () => { + interface EventBooking { + date: Date; + } + + // Structurally this key *looks* like a `LimitSelectionKey`, but it's not created by + // `createLimitSelectionKey()`, so it doesn't satisfy the `LimitSelectionKey` constraint in + // `metadata()`. Therefore, type checking enforces that assignments are based on the key + // type, rather than the field value type. + const key = createMetadataKey>(); + schema((p) => { + metadata(p.date, key, () => MIN_NUMBER); + metadata( + p.date, + key, + // @ts-expect-error + () => MAX_DATE, + ); + }); + }); + }); }); } diff --git a/packages/forms/signals/test/web/form_field.spec.ts b/packages/forms/signals/test/web/form_field.spec.ts index 39c28122e7bd..defd4000ef88 100644 --- a/packages/forms/signals/test/web/form_field.spec.ts +++ b/packages/forms/signals/test/web/form_field.spec.ts @@ -50,8 +50,10 @@ import { FormField, hidden, max, + maxDate, maxLength, min, + minDate, minLength, pattern, provideSignalFormsConfig, @@ -2381,6 +2383,182 @@ describe('field directive', () => { expect(element.max).toBe('5'); }); + it('should bind maxDate to native control as string', () => { + @Component({ + imports: [FormField], + template: ``, + }) + class TestCmp { + readonly max = signal(new Date('2026-12-31')); + readonly f = form(signal(new Date('2026-01-15')), (p) => { + maxDate(p, this.max); + }); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const element = fixture.nativeElement.firstChild as HTMLInputElement; + expect(element.max).toBe('2026-12-31'); + + act(() => fixture.componentInstance.max.set(new Date('2026-11-01'))); + expect(element.max).toBe('2026-11-01'); + }); + + // Firefox doesn't support + (!isFirefox() ? it : xit)('should bind maxDate to native month control as string', () => { + @Component({ + imports: [FormField], + template: ``, + }) + class TestCmp { + readonly max = signal(new Date('2026-12-01')); + readonly f = form(signal(new Date('2026-01-15')), (p) => { + maxDate(p, this.max); + }); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const element = fixture.nativeElement.firstChild as HTMLInputElement; + expect(element.max).toBe('2026-12'); + + act(() => fixture.componentInstance.max.set(new Date('2026-11-01'))); + expect(element.max).toBe('2026-11'); + }); + + it('should allow string binding to max in template', () => { + @Component({ + imports: [FormField], + template: ``, + }) + class TestCmp { + readonly f = form(signal(new Date('2026-01-15'))); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const element = fixture.nativeElement.firstChild as HTMLInputElement; + expect(element.max).toBe('2026-12-31'); + }); + + it('should allow string binding to max in template with dynamic type', () => { + @Component({ + imports: [FormField], + template: ``, + }) + class TestCmp { + readonly inputType = signal('date'); + readonly f = form(signal(new Date('2026-01-15'))); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const element = fixture.nativeElement.firstChild as HTMLInputElement; + expect(element.max).toBe('2026-12-31'); + }); + + it('should bind max to custom control', () => { + @Component({ + selector: 'custom-control', + template: '', + }) + class CustomControl implements FormValueControl { + readonly value = model.required(); + readonly max = input(); + } + + @Component({ + imports: [FormField, CustomControl], + template: ``, + }) + class TestCmp { + readonly max = signal(new Date('2026-12-31')); + readonly f = form(signal(new Date('2026-01-15')), (p) => { + maxDate(p, this.max); + }); + readonly customControl = viewChild.required(CustomControl); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const component = fixture.componentInstance; + expect(component.customControl().max()).toEqual(new Date('2026-12-31')); + }); + + it('should validate max on native text input', async () => { + @Component({ + imports: [FormField], + template: ``, + }) + class TestCmp { + readonly f = form(signal(5), (p) => { + max(p, 10); + }); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const element = fixture.nativeElement.firstChild as HTMLInputElement; + + act(() => { + element.value = '15'; + element.dispatchEvent(new Event('input')); + }); + + await fixture.whenStable(); + + const component = fixture.componentInstance; + expect(component.f().errors()).toEqual([jasmine.objectContaining({kind: 'max'})]); + }); + + it('should ignore empty input for max validation', async () => { + @Component({ + imports: [FormField], + template: ``, + }) + class TestCmp { + readonly f = form(signal(5), (p) => { + max(p, 10); + }); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const element = fixture.nativeElement.firstChild as HTMLInputElement; + + act(() => { + element.value = ''; + element.dispatchEvent(new Event('input')); + }); + + await fixture.whenStable(); + + const component = fixture.componentInstance; + expect(component.f().value()).toBeNull(); + expect(component.f().errors()).toEqual([]); + }); + + it('should validate max on native date input', async () => { + @Component({ + imports: [FormField], + template: ``, + }) + class TestCmp { + readonly f = form(signal(new Date('2026-04-06')), (p) => { + maxDate(p, new Date('2026-04-05')); + }); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const element = fixture.nativeElement.firstChild as HTMLInputElement; + + await fixture.whenStable(); + const component = fixture.componentInstance; + expect(component.f().errors()).toEqual([jasmine.objectContaining({kind: 'maxDate'})]); + + act(() => { + element.value = '2026-04-04'; + element.dispatchEvent(new Event('input')); + }); + + await fixture.whenStable(); + + expect(component.f().errors()).toEqual([]); + }); + it('should bind to a custom control host directive', () => { @Directive() class CustomControlDir implements FormValueControl { @@ -2652,6 +2830,182 @@ describe('field directive', () => { expect(input.min).toBe('10'); }); + it('should bind minDate to native control as string', () => { + @Component({ + imports: [FormField], + template: ``, + }) + class TestCmp { + readonly min = signal(new Date('2026-01-01')); + readonly f = form(signal(new Date('2026-01-15')), (p) => { + minDate(p, this.min); + }); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const element = fixture.nativeElement.firstChild as HTMLInputElement; + expect(element.min).toBe('2026-01-01'); + + act(() => fixture.componentInstance.min.set(new Date('2026-02-01'))); + expect(element.min).toBe('2026-02-01'); + }); + + // Firefox doesn't support + (!isFirefox() ? it : xit)('should bind minDate to native month control as string', () => { + @Component({ + imports: [FormField], + template: ``, + }) + class TestCmp { + readonly min = signal(new Date('2026-01-01')); + readonly f = form(signal(new Date('2026-01-15')), (p) => { + minDate(p, this.min); + }); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const element = fixture.nativeElement.firstChild as HTMLInputElement; + expect(element.min).toBe('2026-01'); + + act(() => fixture.componentInstance.min.set(new Date('2026-02-01'))); + expect(element.min).toBe('2026-02'); + }); + + it('should allow string binding to min in template', () => { + @Component({ + imports: [FormField], + template: ``, + }) + class TestCmp { + readonly f = form(signal(new Date('2026-01-15'))); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const element = fixture.nativeElement.firstChild as HTMLInputElement; + expect(element.min).toBe('2026-01-01'); + }); + + it('should allow string binding to min in template with dynamic type', () => { + @Component({ + imports: [FormField], + template: ``, + }) + class TestCmp { + readonly inputType = signal('date'); + readonly f = form(signal(new Date('2026-01-15'))); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const element = fixture.nativeElement.firstChild as HTMLInputElement; + expect(element.min).toBe('2026-01-01'); + }); + + it('should bind min to custom control', () => { + @Component({ + selector: 'custom-control', + template: '', + }) + class CustomControl implements FormValueControl { + readonly value = model.required(); + readonly min = input(); + } + + @Component({ + imports: [FormField, CustomControl], + template: ``, + }) + class TestCmp { + readonly min = signal(new Date('2026-01-01')); + readonly f = form(signal(new Date('2026-01-15')), (p) => { + minDate(p, this.min); + }); + readonly customControl = viewChild.required(CustomControl); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const component = fixture.componentInstance; + expect(component.customControl().min()).toEqual(new Date('2026-01-01')); + }); + + it('should validate min on native text input', async () => { + @Component({ + imports: [FormField], + template: ``, + }) + class TestCmp { + readonly f = form(signal(15), (p) => { + min(p, 10); + }); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const element = fixture.nativeElement.firstChild as HTMLInputElement; + + act(() => { + element.value = '5'; + element.dispatchEvent(new Event('input')); + }); + + await fixture.whenStable(); + + const component = fixture.componentInstance; + expect(component.f().errors()).toEqual([jasmine.objectContaining({kind: 'min'})]); + }); + + it('should ignore empty input for min validation', async () => { + @Component({ + imports: [FormField], + template: ``, + }) + class TestCmp { + readonly f = form(signal(15), (p) => { + min(p, 10); + }); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const element = fixture.nativeElement.firstChild as HTMLInputElement; + + act(() => { + element.value = ''; + element.dispatchEvent(new Event('input')); + }); + + await fixture.whenStable(); + + const component = fixture.componentInstance; + expect(component.f().value()).toBeNull(); + expect(component.f().errors()).toEqual([]); + }); + + it('should validate min on native date input', async () => { + @Component({ + imports: [FormField], + template: ``, + }) + class TestCmp { + readonly f = form(signal(new Date('2026-04-02')), (p) => { + minDate(p, new Date('2026-04-05')); + }); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const element = fixture.nativeElement.firstChild as HTMLInputElement; + + await fixture.whenStable(); + const component = fixture.componentInstance; + expect(component.f().errors()).toEqual([jasmine.objectContaining({kind: 'minDate'})]); + + act(() => { + element.value = '2026-04-06'; + element.dispatchEvent(new Event('input')); + }); + + await fixture.whenStable(); + + expect(component.f().errors()).toEqual([]); + }); + it('should bind to a custom control host directive', () => { @Directive() class CustomControlDir implements FormValueControl { @@ -3633,8 +3987,6 @@ describe('field directive', () => { readonly pending = input(false); readonly dirty = input(false); readonly touched = input(false); - readonly min = input(1); - readonly max = input(1_0000); readonly minLength = input(1); readonly maxLength = input(5); } diff --git a/packages/forms/src/directives/native.ts b/packages/forms/src/directives/native.ts index 14b82f07470c..a3a078a47607 100644 --- a/packages/forms/src/directives/native.ts +++ b/packages/forms/src/directives/native.ts @@ -27,21 +27,13 @@ export function isNativeFormElement(element: HTMLElement): element is NativeForm ); } -export function isNumericFormElement(element: HTMLElement): boolean { +export function elementAcceptsMinMax(element: HTMLElement): boolean { if (element.tagName !== 'INPUT') { return false; } const type = (element as HTMLInputElement).type; - return ( - type === 'date' || - type === 'datetime-local' || - type === 'month' || - type === 'number' || - type === 'range' || - type === 'time' || - type === 'week' - ); + return type === 'number' || type === 'range' || type === 'date' || type === 'month'; } export function isTextualFormElement(element: HTMLElement): boolean { diff --git a/packages/forms/src/forms.ts b/packages/forms/src/forms.ts index 41d8bda1f52b..725fed2f8a9d 100644 --- a/packages/forms/src/forms.ts +++ b/packages/forms/src/forms.ts @@ -125,7 +125,7 @@ export {VERSION} from './version'; export { isNativeFormElement as ɵisNativeFormElement, - isNumericFormElement as ɵisNumericFormElement, + elementAcceptsMinMax as ɵelementAcceptsMinMax, isTextualFormElement as ɵisTextualFormElement, setNativeDomProperty as ɵsetNativeDomProperty, type NativeFormControl as ɵNativeFormControl,