From 4ca7c1c7d5b1b477d95b9169a36c1cc43b50c634 Mon Sep 17 00:00:00 2001 From: Leon Senft Date: Wed, 1 Apr 2026 16:32:47 -0700 Subject: [PATCH 1/7] refactor(forms): remove string support from min and max validation rules The `min` and `max` validation rules previously handled `string` values to accommodate numbers bound to text inputs. However, this is no longer necessary as the control binding itself handles the conversion. This change removes string support from these rules, simplifying the types to `number | null`. The validation logic has been updated to use concrete checks (`value === null || Number.isNaN(value)`) to ensure safe TypeScript narrowing. Associated tests have been updated to: - Remove string-specific validation checks. - Add coverage for text input bindings. - Add coverage for empty input handling (standard behavior where empty sets model to null and skips validation). BREAKING CHANGE: `min` and `max` validation rules no longer support string values. Bound values must be numbers or null. --- goldens/public-api/forms/signals/index.api.md | 4 +- .../signals/src/api/rules/validation/max.ts | 17 ++- .../signals/src/api/rules/validation/min.ts | 14 +-- .../test/node/api/validators/max.spec.ts | 32 +----- .../test/node/api/validators/min.spec.ts | 26 ----- .../forms/signals/test/web/form_field.spec.ts | 102 ++++++++++++++++++ 6 files changed, 120 insertions(+), 75 deletions(-) diff --git a/goldens/public-api/forms/signals/index.api.md b/goldens/public-api/forms/signals/index.api.md index ff0a6701c970..3b40d2961a49 100644 --- a/goldens/public-api/forms/signals/index.api.md +++ b/goldens/public-api/forms/signals/index.api.md @@ -295,7 +295,7 @@ export interface MarkAsTouchedOptions { export const MAX: MetadataKey, number | undefined, number | undefined>; // @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>; @@ -374,7 +374,7 @@ export type MetadataSetterType = TKey extends MetadataKey, number | undefined, number | undefined>; // @public -export function min(path: SchemaPath, minValue: number | LogicFn, config?: BaseValidatorConfig): void; +export function min(path: SchemaPath, minValue: number | LogicFn, config?: BaseValidatorConfig): void; // @public export const MIN_LENGTH: MetadataKey, number | undefined, number | undefined>; diff --git a/packages/forms/signals/src/api/rules/validation/max.ts b/packages/forms/signals/src/api/rules/validation/max.ts index ce13fb27cef8..26b3b85b2f29 100644 --- a/packages/forms/signals/src/api/rules/validation/max.ts +++ b/packages/forms/signals/src/api/rules/validation/max.ts @@ -8,7 +8,7 @@ import {LogicFn, PathKind, SchemaPath, SchemaPathRules} from '../../types'; import {createMetadataKey, MAX, metadata} from '../metadata'; -import {BaseValidatorConfig, getOption, isEmpty} from './util'; +import {BaseValidatorConfig, getOption} from './util'; import {validate} from './validate'; import {maxError} from './validation_errors'; @@ -29,26 +29,25 @@ import {maxError} from './validation_errors'; * @category validation * @experimental 21.0.0 */ -export function max( - path: SchemaPath, - maxValue: number | LogicFn, - config?: BaseValidatorConfig, +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)!()); 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/min.ts b/packages/forms/signals/src/api/rules/validation/min.ts index 14712ad01785..7dec7a644a35 100644 --- a/packages/forms/signals/src/api/rules/validation/min.ts +++ b/packages/forms/signals/src/api/rules/validation/min.ts @@ -8,7 +8,7 @@ import {LogicFn, PathKind, SchemaPath, SchemaPathRules} from '../../types'; import {createMetadataKey, metadata, MIN} from '../metadata'; -import {BaseValidatorConfig, getOption, isEmpty} from './util'; +import {BaseValidatorConfig, getOption} from './util'; import {validate} from './validate'; import {minError} from './validation_errors'; @@ -29,10 +29,7 @@ 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, @@ -42,16 +39,15 @@ export function min< ); metadata(path, MIN, ({state}) => state.metadata(MIN_MEMO)!()); 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/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/min.spec.ts b/packages/forms/signals/test/node/api/validators/min.spec.ts index a6eb9e7ac9b4..31dde0a72233 100644 --- a/packages/forms/signals/test/node/api/validators/min.spec.ts +++ b/packages/forms/signals/test/node/api/validators/min.spec.ts @@ -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/web/form_field.spec.ts b/packages/forms/signals/test/web/form_field.spec.ts index 39c28122e7bd..067b8a32700a 100644 --- a/packages/forms/signals/test/web/form_field.spec.ts +++ b/packages/forms/signals/test/web/form_field.spec.ts @@ -2381,6 +2381,57 @@ describe('field directive', () => { expect(element.max).toBe('5'); }); + 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 bind to a custom control host directive', () => { @Directive() class CustomControlDir implements FormValueControl { @@ -2652,6 +2703,57 @@ describe('field directive', () => { expect(input.min).toBe('10'); }); + 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 bind to a custom control host directive', () => { @Directive() class CustomControlDir implements FormValueControl { From e7abee5e141010b07ebb5fb5778d78e8537d2afd Mon Sep 17 00:00:00 2001 From: Leon Senft Date: Tue, 7 Apr 2026 15:24:40 -0700 Subject: [PATCH 2/7] refactor(forms): add validation rules for date constraints - Added `minDate()` and `maxDate()` for validating constraints on `Date` inputs. - `ReadonlyFieldState.min` and `.max` now return `Signal`. This ensures that `min` and `max` inputs on custom controls can accept a reliable type (matching their value type). - Made the `TWrite` type parameter of `MetadataKey` contravariant to properly indicate that it's writable. - Added `LimitKey` as a convenience type for defining validation limit metadata (e.g. `MAX_NUMBER`, `MIN_DATE`). - Added `LimitSelectionKey` which can be used to bind a `LimitKey` with value-specific aggregation logic, to a generic metadata key (e.g. use `MAX_NUMBER` to aggregate numbers for `MAX`). --- goldens/public-api/forms/signals/index.api.md | 89 ++++++++++-- packages/forms/signals/src/api/control.ts | 16 +-- .../forms/signals/src/api/rules/metadata.ts | 135 +++++++++++++----- .../signals/src/api/rules/validation/index.ts | 2 + .../signals/src/api/rules/validation/max.ts | 21 ++- .../src/api/rules/validation/max_date.ts | 69 +++++++++ .../signals/src/api/rules/validation/min.ts | 21 ++- .../src/api/rules/validation/min_date.ts | 69 +++++++++ .../api/rules/validation/validation_errors.ts | 98 +++++++++++++ packages/forms/signals/src/api/types.ts | 4 +- .../signals/src/directive/control_custom.ts | 4 +- packages/forms/signals/src/field/debounce.ts | 2 +- packages/forms/signals/src/field/node.ts | 10 +- .../test/node/api/validators/max_date.spec.ts | 70 +++++++++ .../test/node/api/validators/min.spec.ts | 2 +- .../test/node/api/validators/min_date.spec.ts | 70 +++++++++ .../forms/signals/test/node/types.spec.ts | 64 +++++++++ .../forms/signals/test/web/form_field.spec.ts | 60 +++++++- 18 files changed, 726 insertions(+), 80 deletions(-) create mode 100644 packages/forms/signals/src/api/rules/validation/max_date.ts create mode 100644 packages/forms/signals/src/api/rules/validation/min_date.ts create mode 100644 packages/forms/signals/test/node/api/validators/max_date.spec.ts create mode 100644 packages/forms/signals/test/node/api/validators/min_date.spec.ts diff --git a/goldens/public-api/forms/signals/index.api.md b/goldens/public-api/forms/signals/index.api.md index 3b40d2961a49..e9474e5aaae5 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, TLimit | undefined, TLimit | 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; // @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_LENGTH: MetadataKey, number | undefined, number | undefined>; +export const MIN_DATE: LimitKey; + +// @public +export const MIN_LENGTH: LimitKey; + +// @public +export const MIN_NUMBER: LimitKey; + +// @public +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/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..44996579c834 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,50 @@ 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. + */ +export type LimitKey = MetadataKey< + Signal, + TLimit | undefined, + TLimit | 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 21.3.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 +280,15 @@ export function createManagedMetadataKey( ) => MetadataKey)(reducer ?? MetadataReducer.override(), create); } +/** + * Creates a {@link LimitSelectionKey}. + * + * @experimental 21.3.0 + */ +export function createLimitSelectionKey(): LimitSelectionKey { + return createMetadataKey() as LimitSelectionKey; +} + /** * A {@link MetadataKey} representing whether the field is required. * @@ -253,28 +300,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 21.0.0 + * @experimental 21.3.0 + */ +export const MIN_DATE: LimitKey = createMetadataKey(MetadataReducer.max()); + +/** + * A {@link MetadataKey} representing the minimum valid value of a number field. + * + * @category validation + * @experimental 21.3.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.3.0 + */ +export const MAX: LimitSelectionKey = createLimitSelectionKey(); + +/** + * A {@link MetadataKey} representing the maximum valid value of a date field. + * + * @category validation + * @experimental 21.3.0 + */ +export const MAX_DATE: LimitKey = createMetadataKey(MetadataReducer.min()); + +/** + * A {@link MetadataKey} representing the maximum valid value of a number field. + * + * @category validation + * @experimental 21.3.0 */ -export const MAX: MetadataKey< - Signal, - number | undefined, - number | undefined -> = createMetadataKey(MetadataReducer.min()); +export const MAX_NUMBER: LimitKey = createMetadataKey(MetadataReducer.min()); /** * A {@link MetadataKey} representing the min length of the field. @@ -282,11 +359,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 +367,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 26b3b85b2f29..a2c9340d787b 100644 --- a/packages/forms/signals/src/api/rules/validation/max.ts +++ b/packages/forms/signals/src/api/rules/validation/max.ts @@ -7,7 +7,7 @@ */ import {LogicFn, PathKind, SchemaPath, SchemaPathRules} from '../../types'; -import {createMetadataKey, MAX, metadata} from '../metadata'; +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 @@ -33,11 +34,19 @@ export function max, 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)!()); +): 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) => { const value = ctx.value(); if (value === null || Number.isNaN(value)) { 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..a92d7e7e5f07 --- /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 21.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 7dec7a644a35..9b9ac4fbc521 100644 --- a/packages/forms/signals/src/api/rules/validation/min.ts +++ b/packages/forms/signals/src/api/rules/validation/min.ts @@ -7,7 +7,7 @@ */ import {LogicFn, PathKind, SchemaPath, SchemaPathRules} from '../../types'; -import {createMetadataKey, metadata, MIN} from '../metadata'; +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 @@ -33,11 +34,19 @@ export function min, 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) => { const value = ctx.value(); if (value === null || Number.isNaN(value)) { 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..17fb98f090b3 --- /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 21.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..41e90bfd4d7b 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 21.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 21.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 21.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 21.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 21.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 21.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..7b015b280fb6 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, @@ -25,7 +25,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 () => { 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_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 31dde0a72233..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', () => { 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 067b8a32700a..9e6bf659f04c 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, @@ -2432,6 +2434,34 @@ describe('field directive', () => { 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 { @@ -2754,6 +2784,34 @@ describe('field directive', () => { 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 { @@ -3735,8 +3793,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); } From c5f23801515354da9fb8ce80709a24d713347915 Mon Sep 17 00:00:00 2001 From: Leon Senft Date: Tue, 5 May 2026 10:35:47 -0700 Subject: [PATCH 3/7] refactor(forms): bind formatted date string to `min`/`max` for `minDate`/`maxDate` * Test that `minDate`/`maxDate` binds to `min`/`max` on date and time inputs * Test that `min`/`max` attribute can be set directly on date and time inputs * Relax type checker to allow `min`/`max` bindings on date and time inputs --- .../test/ngtsc/signal_forms_spec.ts | 48 +++++ .../src/typecheck/ops/signal_forms.ts | 37 +++- .../signals/src/directive/control_custom.ts | 8 +- .../signals/src/directive/control_native.ts | 11 +- .../forms/signals/src/directive/form_field.ts | 7 +- .../forms/signals/src/directive/native.ts | 24 ++- .../forms/signals/test/web/form_field.spec.ts | 192 ++++++++++++++++++ packages/forms/src/directives/native.ts | 12 +- packages/forms/src/forms.ts | 2 +- 9 files changed, 310 insertions(+), 31 deletions(-) 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/directive/control_custom.ts b/packages/forms/signals/src/directive/control_custom.ts index 7b015b280fb6..fc6d04709504 100644 --- a/packages/forms/signals/src/directive/control_custom.ts +++ b/packages/forms/signals/src/directive/control_custom.ts @@ -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, @@ -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/test/web/form_field.spec.ts b/packages/forms/signals/test/web/form_field.spec.ts index 9e6bf659f04c..41835874a873 100644 --- a/packages/forms/signals/test/web/form_field.spec.ts +++ b/packages/forms/signals/test/web/form_field.spec.ts @@ -2383,6 +2383,102 @@ 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-31T00:00:00Z')); + readonly f = form(signal(new Date('2026-01-15T00:00:00Z')), (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-01T00:00:00Z'))); + expect(element.max).toBe('2026-11-01'); + }); + + it('should bind maxDate to native month control as string', () => { + @Component({ + imports: [FormField], + template: ``, + }) + class TestCmp { + readonly max = signal(new Date('2026-12-01T00:00:00Z')); + readonly f = form(signal(new Date('2026-01-15T00:00:00Z')), (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-01T00:00:00Z'))); + 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], @@ -2733,6 +2829,102 @@ 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-01T00:00:00Z')); + readonly f = form(signal(new Date('2026-01-15T00:00:00Z')), (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-01T00:00:00Z'))); + expect(element.min).toBe('2026-02-01'); + }); + + it('should bind minDate to native month control as string', () => { + @Component({ + imports: [FormField], + template: ``, + }) + class TestCmp { + readonly min = signal(new Date('2026-01-01T00:00:00Z')); + readonly f = form(signal(new Date('2026-01-15T00:00:00Z')), (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-01T00:00:00Z'))); + 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], 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, From cb1e684f2b84bf83d83a1b7bb0969fa340d527af Mon Sep 17 00:00:00 2001 From: leonsenft Date: Tue, 5 May 2026 16:30:13 -0700 Subject: [PATCH 4/7] fixup! refactor(forms): bind formatted date string to `min`/`max` for `minDate`/`maxDate` --- packages/forms/signals/test/web/form_field.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/forms/signals/test/web/form_field.spec.ts b/packages/forms/signals/test/web/form_field.spec.ts index 41835874a873..5cacdbdfbd25 100644 --- a/packages/forms/signals/test/web/form_field.spec.ts +++ b/packages/forms/signals/test/web/form_field.spec.ts @@ -2403,7 +2403,8 @@ describe('field directive', () => { expect(element.max).toBe('2026-11-01'); }); - it('should bind maxDate to native month control as string', () => { + // Firefox doesn't support + (!isFirefox() ? it : xit)('should bind maxDate to native month control as string', () => { @Component({ imports: [FormField], template: ``, @@ -2849,7 +2850,8 @@ describe('field directive', () => { expect(element.min).toBe('2026-02-01'); }); - it('should bind minDate to native month control as string', () => { + // Firefox doesn't support + (!isFirefox() ? it : xit)('should bind minDate to native month control as string', () => { @Component({ imports: [FormField], template: ``, From caeaae31c08a3fd615038ce07efc0e7618ef67b5 Mon Sep 17 00:00:00 2001 From: leonsenft Date: Tue, 5 May 2026 16:46:04 -0700 Subject: [PATCH 5/7] fixup! refactor(forms): bind formatted date string to `min`/`max` for `minDate`/`maxDate` --- .../forms/signals/test/web/form_field.spec.ts | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/forms/signals/test/web/form_field.spec.ts b/packages/forms/signals/test/web/form_field.spec.ts index 5cacdbdfbd25..defd4000ef88 100644 --- a/packages/forms/signals/test/web/form_field.spec.ts +++ b/packages/forms/signals/test/web/form_field.spec.ts @@ -2389,8 +2389,8 @@ describe('field directive', () => { template: ``, }) class TestCmp { - readonly max = signal(new Date('2026-12-31T00:00:00Z')); - readonly f = form(signal(new Date('2026-01-15T00:00:00Z')), (p) => { + readonly max = signal(new Date('2026-12-31')); + readonly f = form(signal(new Date('2026-01-15')), (p) => { maxDate(p, this.max); }); } @@ -2399,7 +2399,7 @@ describe('field directive', () => { const element = fixture.nativeElement.firstChild as HTMLInputElement; expect(element.max).toBe('2026-12-31'); - act(() => fixture.componentInstance.max.set(new Date('2026-11-01T00:00:00Z'))); + act(() => fixture.componentInstance.max.set(new Date('2026-11-01'))); expect(element.max).toBe('2026-11-01'); }); @@ -2410,8 +2410,8 @@ describe('field directive', () => { template: ``, }) class TestCmp { - readonly max = signal(new Date('2026-12-01T00:00:00Z')); - readonly f = form(signal(new Date('2026-01-15T00:00:00Z')), (p) => { + readonly max = signal(new Date('2026-12-01')); + readonly f = form(signal(new Date('2026-01-15')), (p) => { maxDate(p, this.max); }); } @@ -2420,7 +2420,7 @@ describe('field directive', () => { const element = fixture.nativeElement.firstChild as HTMLInputElement; expect(element.max).toBe('2026-12'); - act(() => fixture.componentInstance.max.set(new Date('2026-11-01T00:00:00Z'))); + act(() => fixture.componentInstance.max.set(new Date('2026-11-01'))); expect(element.max).toBe('2026-11'); }); @@ -2836,8 +2836,8 @@ describe('field directive', () => { template: ``, }) class TestCmp { - readonly min = signal(new Date('2026-01-01T00:00:00Z')); - readonly f = form(signal(new Date('2026-01-15T00:00:00Z')), (p) => { + readonly min = signal(new Date('2026-01-01')); + readonly f = form(signal(new Date('2026-01-15')), (p) => { minDate(p, this.min); }); } @@ -2846,7 +2846,7 @@ describe('field directive', () => { const element = fixture.nativeElement.firstChild as HTMLInputElement; expect(element.min).toBe('2026-01-01'); - act(() => fixture.componentInstance.min.set(new Date('2026-02-01T00:00:00Z'))); + act(() => fixture.componentInstance.min.set(new Date('2026-02-01'))); expect(element.min).toBe('2026-02-01'); }); @@ -2857,8 +2857,8 @@ describe('field directive', () => { template: ``, }) class TestCmp { - readonly min = signal(new Date('2026-01-01T00:00:00Z')); - readonly f = form(signal(new Date('2026-01-15T00:00:00Z')), (p) => { + readonly min = signal(new Date('2026-01-01')); + readonly f = form(signal(new Date('2026-01-15')), (p) => { minDate(p, this.min); }); } @@ -2867,7 +2867,7 @@ describe('field directive', () => { const element = fixture.nativeElement.firstChild as HTMLInputElement; expect(element.min).toBe('2026-01'); - act(() => fixture.componentInstance.min.set(new Date('2026-02-01T00:00:00Z'))); + act(() => fixture.componentInstance.min.set(new Date('2026-02-01'))); expect(element.min).toBe('2026-02'); }); From 4fdb58e376dde1ba1b0a8661e7658944ba970041 Mon Sep 17 00:00:00 2001 From: leonsenft Date: Tue, 5 May 2026 17:02:00 -0700 Subject: [PATCH 6/7] fixup! refactor(forms): add validation rules for date constraints --- packages/forms/signals/src/api/rules/metadata.ts | 15 ++++++++------- .../signals/src/api/rules/validation/max_date.ts | 2 +- .../signals/src/api/rules/validation/min_date.ts | 2 +- .../src/api/rules/validation/validation_errors.ts | 12 ++++++------ 4 files changed, 16 insertions(+), 15 deletions(-) diff --git a/packages/forms/signals/src/api/rules/metadata.ts b/packages/forms/signals/src/api/rules/metadata.ts index 44996579c834..db41562a519d 100644 --- a/packages/forms/signals/src/api/rules/metadata.ts +++ b/packages/forms/signals/src/api/rules/metadata.ts @@ -164,6 +164,7 @@ export class MetadataKey { * 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, @@ -182,7 +183,7 @@ declare const LIMIT_SELECTION_KEY: unique symbol; * 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 21.3.0 + * @experimental 22.0.0 */ export type LimitSelectionKey = MetadataKey< Signal | undefined>, @@ -283,7 +284,7 @@ export function createManagedMetadataKey( /** * Creates a {@link LimitSelectionKey}. * - * @experimental 21.3.0 + * @experimental 22.0.0 */ export function createLimitSelectionKey(): LimitSelectionKey { return createMetadataKey() as LimitSelectionKey; @@ -314,7 +315,7 @@ export const MIN: LimitSelectionKey = createLimitSelectionKey(); * A {@link MetadataKey} representing the minimum valid value of a date field. * * @category validation - * @experimental 21.3.0 + * @experimental 22.0.0 */ export const MIN_DATE: LimitKey = createMetadataKey(MetadataReducer.max()); @@ -322,7 +323,7 @@ export const MIN_DATE: LimitKey = createMetadataKey(MetadataReducer.max()) * A {@link MetadataKey} representing the minimum valid value of a number field. * * @category validation - * @experimental 21.3.0 + * @experimental 22.0.0 */ export const MIN_NUMBER: LimitKey = createMetadataKey(MetadataReducer.max()); @@ -333,7 +334,7 @@ export const MIN_NUMBER: LimitKey = createMetadataKey(MetadataReducer.ma * own reducers, such as {@link MAX_DATE} and {@link MAX_NUMBER}. * * @category validation - * @experimental 21.3.0 + * @experimental 21.0.0 */ export const MAX: LimitSelectionKey = createLimitSelectionKey(); @@ -341,7 +342,7 @@ export const MAX: LimitSelectionKey = createLimitSelectionKey(); * A {@link MetadataKey} representing the maximum valid value of a date field. * * @category validation - * @experimental 21.3.0 + * @experimental 22.0.0 */ export const MAX_DATE: LimitKey = createMetadataKey(MetadataReducer.min()); @@ -349,7 +350,7 @@ export const MAX_DATE: LimitKey = createMetadataKey(MetadataReducer.min()) * A {@link MetadataKey} representing the maximum valid value of a number field. * * @category validation - * @experimental 21.3.0 + * @experimental 22.0.0 */ export const MAX_NUMBER: LimitKey = createMetadataKey(MetadataReducer.min()); diff --git a/packages/forms/signals/src/api/rules/validation/max_date.ts b/packages/forms/signals/src/api/rules/validation/max_date.ts index a92d7e7e5f07..caef2baaa049 100644 --- a/packages/forms/signals/src/api/rules/validation/max_date.ts +++ b/packages/forms/signals/src/api/rules/validation/max_date.ts @@ -27,7 +27,7 @@ import {maxDateError} from './validation_errors'; * * @see [Signal Form Max Validation](guide/forms/signals/validation#min-and-max) * @category validation - * @experimental 21.0.0 + * @experimental 22.0.0 */ export function maxDate( path: SchemaPath, diff --git a/packages/forms/signals/src/api/rules/validation/min_date.ts b/packages/forms/signals/src/api/rules/validation/min_date.ts index 17fb98f090b3..1c013b37488a 100644 --- a/packages/forms/signals/src/api/rules/validation/min_date.ts +++ b/packages/forms/signals/src/api/rules/validation/min_date.ts @@ -27,7 +27,7 @@ import {minDateError} from './validation_errors'; * * @see [Signal Form Min Validation](guide/forms/signals/validation#min-and-max) * @category validation - * @experimental 21.0.0 + * @experimental 22.0.0 */ export function minDate( path: SchemaPath, 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 41e90bfd4d7b..e42ac90de5c3 100644 --- a/packages/forms/signals/src/api/rules/validation/validation_errors.ts +++ b/packages/forms/signals/src/api/rules/validation/validation_errors.ts @@ -106,7 +106,7 @@ export function minError( * @param options The validation error options * * @category validation - * @experimental 21.0.0 + * @experimental 22.0.0 */ export function minDateError( minDate: Date, @@ -118,7 +118,7 @@ export function minDateError( * @param options The optional validation error options * * @category validation - * @experimental 21.0.0 + * @experimental 22.0.0 */ export function minDateError( minDate: Date, @@ -168,7 +168,7 @@ export function maxError( * @param options The validation error options * * @category validation - * @experimental 21.0.0 + * @experimental 22.0.0 */ export function maxDateError( maxDate: Date, @@ -180,7 +180,7 @@ export function maxDateError( * @param options The optional validation error options * * @category validation - * @experimental 21.0.0 + * @experimental 22.0.0 */ export function maxDateError( maxDate: Date, @@ -436,7 +436,7 @@ export class MinValidationError extends BaseNgValidationError { * An error used to indicate that a date value is earlier than the minimum allowed. * * @category validation - * @experimental 21.0.0 + * @experimental 22.0.0 */ export class MinDateValidationError extends BaseNgValidationError { override readonly kind = 'minDate'; @@ -470,7 +470,7 @@ export class MaxValidationError extends BaseNgValidationError { * An error used to indicate that a date value is later than the maximum allowed. * * @category validation - * @experimental 21.0.0 + * @experimental 22.0.0 */ export class MaxDateValidationError extends BaseNgValidationError { override readonly kind = 'maxDate'; From 1f41d00904e0b7896793db5d118e55491a9c6691 Mon Sep 17 00:00:00 2001 From: leonsenft Date: Wed, 6 May 2026 11:17:50 -0700 Subject: [PATCH 7/7] fixup! refactor(forms): add validation rules for date constraints --- goldens/public-api/forms/signals/index.api.md | 4 ++-- packages/forms/signals/src/api/rules/metadata.ts | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/goldens/public-api/forms/signals/index.api.md b/goldens/public-api/forms/signals/index.api.md index e9474e5aaae5..2fbdfb836955 100644 --- a/goldens/public-api/forms/signals/index.api.md +++ b/goldens/public-api/forms/signals/index.api.md @@ -284,10 +284,10 @@ export interface ItemFieldContext extends ChildFieldContext { export type ItemType = T extends ReadonlyArray ? T[number] : T[keyof T]; // @public -export type LimitKey = MetadataKey, TLimit | undefined, TLimit | undefined>; +export type LimitKey = MetadataKey | undefined>, NonNullable | undefined, NonNullable | undefined>; // @public -export type LimitSelectionKey = MetadataKey | undefined>, LimitKey, LimitKey | undefined> & { +export type LimitSelectionKey = MetadataKey | undefined>, LimitKey, LimitKey | undefined> & { [LIMIT_SELECTION_KEY]: true; }; diff --git a/packages/forms/signals/src/api/rules/metadata.ts b/packages/forms/signals/src/api/rules/metadata.ts index db41562a519d..54cc063b83a2 100644 --- a/packages/forms/signals/src/api/rules/metadata.ts +++ b/packages/forms/signals/src/api/rules/metadata.ts @@ -167,9 +167,9 @@ export class MetadataKey { * @experimental 22.0.0 */ export type LimitKey = MetadataKey< - Signal, - TLimit | undefined, - TLimit | undefined + Signal | undefined>, + NonNullable | undefined, + NonNullable | undefined >; /** @@ -186,9 +186,9 @@ declare const LIMIT_SELECTION_KEY: unique symbol; * @experimental 22.0.0 */ export type LimitSelectionKey = MetadataKey< - Signal | undefined>, - LimitKey, - LimitKey | undefined + Signal | undefined>, + LimitKey, + LimitKey | undefined > & { [LIMIT_SELECTION_KEY]: true; };