From 6842bd755e035fbd8b0f128f73eaefb4b7cbdee8 Mon Sep 17 00:00:00 2001 From: brysonbw Date: Thu, 13 Aug 2026 09:58:27 -0500 Subject: [PATCH] feat(forms): add nativeAttribute option to maxLength validator Adds nativeAttribute to maxLength to opt out of native HTML attribute binding while keeping signal validation intact. --- .../content/guide/forms/signals/validation.md | 16 ++-- goldens/public-api/forms/signals/index.api.md | 7 +- .../src/api/rules/validation/max_length.ts | 22 +++++- .../node/api/validators/max_length.spec.ts | 43 +++++++++++ .../forms/signals/test/web/form_field.spec.ts | 76 +++++++++++++++++++ .../forms/signals/test/web/interop.spec.ts | 45 +++++++++++ 6 files changed, 199 insertions(+), 10 deletions(-) diff --git a/adev/src/content/guide/forms/signals/validation.md b/adev/src/content/guide/forms/signals/validation.md index 5f3b293eb812..63d296074514 100644 --- a/adev/src/content/guide/forms/signals/validation.md +++ b/adev/src/content/guide/forms/signals/validation.md @@ -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: @@ -505,9 +511,7 @@ interface User { lastName: string; } -@Component({ - /* ... */ -}) +@Component({/* ... */}) export class UserFormComponent { readonly userModel = model({ firstName: '', @@ -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'}); diff --git a/goldens/public-api/forms/signals/index.api.md b/goldens/public-api/forms/signals/index.api.md index ed56a694c3d9..9a2763f7a196 100644 --- a/goldens/public-api/forms/signals/index.api.md +++ b/goldens/public-api/forms/signals/index.api.md @@ -369,7 +369,7 @@ export function maxError(max: number, options: WithFieldTree; // @public -export function maxLength(path: SchemaPath, maxLength: number | LogicFn, config?: BaseValidatorConfig): void; +export function maxLength(path: SchemaPath, maxLength: number | LogicFn, config?: MaxLengthValidatorConfig): void; // @public export function maxLengthError(maxLength: number, options: WithFieldTree): MaxLengthValidationError; @@ -386,6 +386,11 @@ export class MaxLengthValidationError extends BaseNgValidationError { readonly maxLength: number; } +// @public +export type MaxLengthValidatorConfig = BaseValidatorConfig & { + nativeAttribute?: boolean; +}; + // @public export class MaxValidationError extends BaseNgValidationError { constructor(max: number, options?: ValidationErrorOptions); diff --git a/packages/forms/signals/src/api/rules/validation/max_length.ts b/packages/forms/signals/src/api/rules/validation/max_length.ts index 9514961ac2d7..101536248246 100644 --- a/packages/forms/signals/src/api/rules/validation/max_length.ts +++ b/packages/forms/signals/src/api/rules/validation/max_length.ts @@ -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 & { + /** + * 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`. @@ -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. @@ -42,7 +58,7 @@ export function maxLength< >( path: SchemaPath, maxLength: number | LogicFn, - config?: BaseValidatorConfig, + config?: MaxLengthValidatorConfig, ) { const MAX_LENGTH_MEMO = metadata(path, createMetadataKey(), (ctx) => { if (config?.when && !config.when(ctx)) { @@ -50,7 +66,9 @@ export function maxLength< } 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; diff --git a/packages/forms/signals/test/node/api/validators/max_length.spec.ts b/packages/forms/signals/test/node/api/validators/max_length.spec.ts index ff0ae1b014d0..96ada52d3d8c 100644 --- a/packages/forms/signals/test/node/api/validators/max_length.spec.ts +++ b/packages/forms/signals/test/node/api/validators/max_length.spec.ts @@ -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'}); diff --git a/packages/forms/signals/test/web/form_field.spec.ts b/packages/forms/signals/test/web/form_field.spec.ts index 150f04911321..f3ade947f506 100644 --- a/packages/forms/signals/test/web/form_field.spec.ts +++ b/packages/forms/signals/test/web/form_field.spec.ts @@ -60,6 +60,7 @@ import { requiredError, validateAsync, transformedValue, + maxLengthError, type DisabledReason, type Field, type FormCheckboxControl, @@ -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: ``, + }) + 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 { + readonly value = model(''); + readonly maxLength = input(); + } + + @Component({ + imports: [FormField, CustomControl], + template: ``, + }) + 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: ``, + }) + 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', () => { diff --git a/packages/forms/signals/test/web/interop.spec.ts b/packages/forms/signals/test/web/interop.spec.ts index 13319cb31be3..b1cf3499ff3f 100644 --- a/packages/forms/signals/test/web/interop.spec.ts +++ b/packages/forms/signals/test/web/interop.spec.ts @@ -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: ``, + }) + 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: ``, + }) + 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', () => {