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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions adev/src/content/guide/forms/signals/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,13 +249,19 @@ export class PasswordFormComponent {
minLength(schemaPath.password, 8, {message: 'Password must be at least 8 characters'});
maxLength(schemaPath.password, 100, {message: 'Password is too long'});

maxLength(schemaPath.bio, 500, {message: 'Bio cannot exceed 500 characters'});
// Prevents native 'maxlength' DOM attribute while keeping validation active
maxLength(schemaPath.bio, 500, {
message: ({value}) => `Bio is ${value().length} characters. Limit is 500.`,
nativeAttribute: false,
});
});
}
```

For strings, "length" means the number of characters. For arrays, "length" means the number of elements.

NOTE: `maxLength()` includes an additional `nativeAttribute` option (default: true). By default,`maxLength()` projects the HTML maxlength attribute onto the DOM element, which causes browsers to hard-truncate user input. Set `nativeAttribute` to false to disable DOM projection, allowing soft validation where users can continue typing past the character limit while the field correctly reflects an error state.

### pattern()

The `pattern()` validation rule validates against a regular expression:
Expand Down Expand Up @@ -505,9 +511,7 @@ interface User {
lastName: string;
}

@Component({
/* ... */
})
@Component({/* ... */})
export class UserFormComponent {
readonly userModel = model<User>({
firstName: '',
Expand Down Expand Up @@ -750,9 +754,7 @@ import {Component, computed, signal} from '@angular/core';
import {form, FormField, validateStandardSchema} from '@angular/forms/signals';
import z from 'zod';

@Component({
/* ... */
})
@Component({/* ... */})
export class DynamicSchema {
model = signal({document: '', type: 'dni'});

Expand Down
7 changes: 6 additions & 1 deletion goldens/public-api/forms/signals/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,7 @@ export function maxError(max: number, options: WithFieldTree<ValidationErrorOpti
export function maxError(max: number, options?: ValidationErrorOptions): WithoutFieldTree<MaxValidationError>;

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

// @public
export function maxLengthError(maxLength: number, options: WithFieldTree<ValidationErrorOptions>): MaxLengthValidationError;
Expand All @@ -386,6 +386,11 @@ export class MaxLengthValidationError extends BaseNgValidationError {
readonly maxLength: number;
}

// @public
export type MaxLengthValidatorConfig<TValue extends ValueWithLengthOrSize, TPathKind extends PathKind = PathKind.Root> = BaseValidatorConfig<TValue, TPathKind> & {
nativeAttribute?: boolean;
};

// @public
export class MaxValidationError extends BaseNgValidationError {
constructor(max: number, options?: ValidationErrorOptions);
Expand Down
22 changes: 20 additions & 2 deletions packages/forms/signals/src/api/rules/validation/max_length.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,21 @@ import {
import {validate} from './validate';
import {maxLengthError} from './validation_errors';

/**
* Configuration options for the `maxLength` validator.
*/
export type MaxLengthValidatorConfig<
TValue extends ValueWithLengthOrSize,
TPathKind extends PathKind = PathKind.Root,
> = BaseValidatorConfig<TValue, TPathKind> & {
/**
* Whether to apply the native `maxlength` HTML attribute to the bound DOM element.
*
* @default true
*/
nativeAttribute?: boolean;
};

/**
* Binds a validator to the given path that requires the length of the value to be less than or
* equal to the given `maxLength`.
Expand All @@ -27,6 +42,7 @@ import {maxLengthError} from './validation_errors';
* @param path Path of the field to validate
* @param maxLength The maximum length, or a LogicFn that returns the maximum length.
* @param config Optional, allows providing any of the following options:
* - `nativeAttribute`: Whether to write the native `maxlength` HTML attribute to the element. Defaults to `true`.
* - `error`: Custom validation error(s) to be used instead of the default `ValidationError.maxLength(maxLength)`
* or a function that receives the `FieldContext` and returns custom validation error(s).
* @template TValue The type of value stored in the field the logic is bound to.
Expand All @@ -42,15 +58,17 @@ export function maxLength<
>(
path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>,
maxLength: number | LogicFn<TValue, number | undefined, TPathKind>,
config?: BaseValidatorConfig<TValue, TPathKind>,
config?: MaxLengthValidatorConfig<TValue, TPathKind>,
) {
const MAX_LENGTH_MEMO = metadata(path, createMetadataKey<number | undefined>(), (ctx) => {
if (config?.when && !config.when(ctx)) {
return undefined;
}
return typeof maxLength === 'number' ? maxLength : maxLength(ctx);
});
metadata(path, MAX_LENGTH, ({state}) => state.metadata(MAX_LENGTH_MEMO)!());
if (config?.nativeAttribute !== false) {
metadata(path, MAX_LENGTH, ({state}) => state.metadata(MAX_LENGTH_MEMO)!());
}
validate(path, (ctx) => {
if (isEmpty(ctx.value())) {
return undefined;
Expand Down
43 changes: 43 additions & 0 deletions packages/forms/signals/test/node/api/validators/max_length.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,49 @@ describe('maxLength validator', () => {
expect(f().errors()).toEqual([]);
});

it('should expose maxLength property on field when nativeAttribute is true or omitted', () => {
const data = signal({email: 'test@example.com'});
const f = form(
data,
(p) => {
maxLength(p.email, 100);
},
{injector: TestBed.inject(Injector)},
);

expect(f.email().maxLength?.()).toBe(100);
});

it('should NOT expose maxLength property on field when nativeAttribute is false', () => {
const data = signal({email: 'test@example.com'});
const f = form(
data,
(p) => {
maxLength(p.email, 100, {nativeAttribute: false});
},
{injector: TestBed.inject(Injector)},
);

expect(f.email().maxLength).toBeUndefined();
});

it('should validate string length regardless of nativeAttribute flag state', () => {
const data = signal({username: 'abcdef'});
const f = form(
data,
(p) => {
maxLength(p.username, 3, {nativeAttribute: false});
},
{injector: TestBed.inject(Injector)},
);

expect(f.username().errors()).toEqual([
maxLengthError(3, {
fieldTree: f.username,
}),
]);
});

describe('custom properties', () => {
it('stores the MAX_LENGTH property on maxLength', () => {
const data = signal({text: 'abcdef'});
Expand Down
76 changes: 76 additions & 0 deletions packages/forms/signals/test/web/form_field.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import {
requiredError,
validateAsync,
transformedValue,
maxLengthError,
type DisabledReason,
type Field,
type FormCheckboxControl,
Expand Down Expand Up @@ -3524,6 +3525,81 @@ describe('field directive', () => {
expect(dir.maxLength()).toBe(5);
expect(element.maxLength).toBe(5);
});

it('should not bind native maxlength attribute when nativeAttribute is false', () => {
@Component({
imports: [FormField],
template: `<textarea [formField]="f"></textarea>`,
})
class TestCmp {
readonly maxLength = signal(20);
readonly f = form(signal(''), (p) => {
maxLength(p, this.maxLength, {nativeAttribute: false});
});
}

const fixture = act(() => TestBed.createComponent(TestCmp));
const element = fixture.nativeElement.firstChild as HTMLTextAreaElement;
expect(element.hasAttribute('maxlength')).toBeFalse();
expect(element.maxLength).toBe(-1);
});

it('should not bind maxLength input on custom control when nativeAttribute is false', () => {
@Component({selector: 'custom-control', template: ``})
class CustomControl implements FormValueControl<string> {
readonly value = model('');
readonly maxLength = input<number | undefined>();
}

@Component({
imports: [FormField, CustomControl],
template: `<custom-control [formField]="f" />`,
})
class TestCmp {
readonly maxLength = signal(10);
readonly f = form(signal(''), (p) => {
maxLength(p, this.maxLength, {nativeAttribute: false});
});
readonly customControl = viewChild.required(CustomControl);
}

const fixture = act(() => TestBed.createComponent(TestCmp));
const component = fixture.componentInstance;
expect(component.customControl().maxLength()).toBeUndefined();
});

it('should allow user to type past limit and trigger validation error when nativeAttribute is false', () => {
@Component({
imports: [FormField],
template: `<textarea [formField]="f"></textarea>`,
})
class TestCmp {
readonly f = form(signal(''), (p) => {
maxLength(p, 5, {nativeAttribute: false});
});
}

const fixture = act(() => TestBed.createComponent(TestCmp));
const element = fixture.nativeElement.firstChild as HTMLTextAreaElement;

// Native attribute was omitted
expect(element.hasAttribute('maxlength')).toBeFalse();

// Simulate typing past 5 characters
element.value = 'exceeds_limit';
element.dispatchEvent(new Event('input'));
fixture.detectChanges();

// Native value allowed by browser
expect(element.value).toBe('exceeds_limit');

// Form control still flags error
expect(fixture.componentInstance.f().errors()).toEqual([
maxLengthError(5, {
fieldTree: fixture.componentInstance.f,
}),
]);
});
});

describe('minLength', () => {
Expand Down
45 changes: 45 additions & 0 deletions packages/forms/signals/test/web/interop.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1172,6 +1172,51 @@ describe('ControlValueAccessor', () => {
act(() => fixture.componentInstance.maxLength.set(5));
expect(input.getAttribute('maxLength')).toBe('5');
});

it('should not sync maxlength DOM attribute when nativeAttribute is false', () => {
@Component({
imports: [FormField, CvaDir],
template: `<input [formField]="f" cvaDir />`,
})
class InteropCmp {
readonly f = form(signal('initial'), (p) => {
maxLength(p, 10, {nativeAttribute: false});
});
}

const fixture = act(() => TestBed.createComponent(InteropCmp));
const input = fixture.nativeElement.querySelector('input') as HTMLInputElement;

expect(input.hasAttribute('maxlength')).toBeFalse();
expect(input.maxLength).toBe(-1);
});

it('should preserve validation errors on formField when length exceeds limit', () => {
@Component({
imports: [FormField, CvaDir],
template: `<input [formField]="f" cvaDir />`,
})
class InteropCmp {
readonly f = form(signal(''), (p) => {
maxLength(p, 10, {nativeAttribute: false});
});
}

const fixture = act(() => TestBed.createComponent(InteropCmp));
const component = fixture.componentInstance;
const input = fixture.nativeElement.querySelector('input') as HTMLInputElement;

act(() => {
input.value = 'This string is way too long for 10 characters';
input.dispatchEvent(new Event('input'));
});

fixture.detectChanges();

expect(component.f().errors()).not.toBeNull();
// Native input value isn't clamped by browser maxlength
expect(input.value).toBe('This string is way too long for 10 characters');
});
});

describe('min', () => {
Expand Down