diff --git a/projects/kit/forms/src/kit-ionic-form-field.spec.ts b/projects/kit/forms/src/kit-ionic-form-field.spec.ts
index 5b7d81a..217bc21 100644
--- a/projects/kit/forms/src/kit-ionic-form-field.spec.ts
+++ b/projects/kit/forms/src/kit-ionic-form-field.spec.ts
@@ -15,8 +15,10 @@ import { provideKitIonicSignalForms } from './provide-kit-ionic-signal-forms';
-
-
+
+
+
+
`,
})
class Host {
@@ -29,19 +31,19 @@ class Host {
checkbox: false,
radio: '',
toggle: false,
+ custom: '',
+ unknown: '',
explicit: '',
});
readonly fields = form(this.model, (path) => {
- validate(path.input, ({ value }) =>
- value() ? undefined : { kind: 'non-string', message: 123 as unknown as string },
- );
- validate(path.input, ({ value }) => (value() ? undefined : { kind: 'empty', message: ' ' }));
- required(path.input, { message: 'Input required' });
- required(path.textarea, { message: 'Textarea required' });
- required(path.select, { message: 'Select required' });
- required(path.checkbox, { message: 'Checkbox required' });
- required(path.radio, { message: 'Radio required' });
- required(path.toggle, { message: 'Toggle required' });
+ required(path.input);
+ required(path.textarea);
+ required(path.select);
+ required(path.checkbox);
+ required(path.radio);
+ required(path.toggle);
+ required(path.custom, { message: 'Localized required' });
+ validate(path.unknown, ({ value }) => (value() ? undefined : { kind: 'domain-error' }));
required(path.explicit, { message: 'Generated error' });
});
}
@@ -54,11 +56,20 @@ describe('KitIonicFormField', () => {
expect(
[...fixture.nativeElement.querySelectorAll('ion-input, ion-textarea, ion-select, ion-checkbox, ion-radio-group, ion-toggle')]
- .slice(0, 6)
+ .slice(0, 8)
.map((element: { errorText?: string }) => element.errorText),
- ).toEqual(['Input required', 'Textarea required', 'Select required', 'Checkbox required', 'Radio required', 'Toggle required']);
- expect(fixture.nativeElement.querySelectorAll('ion-input')[1].errorText).toBe('Cross-field error');
- expect(fixture.nativeElement.querySelectorAll('ion-input')[2].errorText).toBe('Bound cross-field error');
+ ).toEqual([
+ 'This field is required.',
+ 'This field is required.',
+ 'This field is required.',
+ 'This field is required.',
+ 'This field is required.',
+ 'This field is required.',
+ 'Localized required',
+ 'Enter a valid value.',
+ ]);
+ expect(fixture.nativeElement.querySelector('[data-testid="static-error"]').errorText).toBe('Cross-field error');
+ expect(fixture.nativeElement.querySelector('[data-testid="bound-error"]').errorText).toBe('Bound cross-field error');
const input = fixture.nativeElement.querySelector('ion-input');
expect(input.classList.contains('ion-invalid')).toBe(true);
diff --git a/projects/kit/forms/src/kit-ionic-form-field.ts b/projects/kit/forms/src/kit-ionic-form-field.ts
index 9c9fceb..f7c5295 100644
--- a/projects/kit/forms/src/kit-ionic-form-field.ts
+++ b/projects/kit/forms/src/kit-ionic-form-field.ts
@@ -1,5 +1,6 @@
import { Directive, effect, ElementRef, inject, Renderer2 } from '@angular/core';
import { FORM_FIELD } from '@angular/forms/signals';
+import { KIT_SIGNAL_FORM_ERROR_MESSAGE_RESOLVER } from './kit-signal-form-error-message';
@Directive({
// Angular's FormField owns [formField]. This sibling directive only adapts its validation message to Ionic.
@@ -16,12 +17,18 @@ export class KitIonicFormField {
readonly #field = inject(FORM_FIELD, { self: true });
readonly #element = inject(ElementRef);
readonly #renderer = inject(Renderer2);
+ readonly #resolveErrorMessage = inject(KIT_SIGNAL_FORM_ERROR_MESSAGE_RESOLVER);
constructor() {
effect(() => {
const message = this.#field
.errors()
- .map((error) => error.message)
+ .map((error) => {
+ if (typeof error.message === 'string' && error.message.trim().length > 0) {
+ return error.message;
+ }
+ return this.#resolveErrorMessage(error);
+ })
.find((candidate): candidate is string => typeof candidate === 'string' && candidate.trim().length > 0);
this.#renderer.setProperty(this.#element.nativeElement, 'errorText', message);
});
diff --git a/projects/kit/forms/src/kit-signal-form-error-message.spec.ts b/projects/kit/forms/src/kit-signal-form-error-message.spec.ts
new file mode 100644
index 0000000..2942f8b
--- /dev/null
+++ b/projects/kit/forms/src/kit-signal-form-error-message.spec.ts
@@ -0,0 +1,30 @@
+import {
+ emailError,
+ maxDateError,
+ maxError,
+ maxLengthError,
+ minDateError,
+ minError,
+ minLengthError,
+ patternError,
+ requiredError,
+} from '@angular/forms/signals';
+import { kitDefaultSignalFormErrorMessage } from './kit-signal-form-error-message';
+
+describe('kitDefaultSignalFormErrorMessage', () => {
+ it.each([
+ [requiredError(), 'This field is required.'],
+ [emailError(), 'Enter a valid email address.'],
+ [minError(2), 'Enter a value of at least 2.'],
+ [maxError(5), 'Enter a value of no more than 5.'],
+ [minLengthError(3), 'Enter at least 3 characters.'],
+ [maxLengthError(10), 'Enter no more than 10 characters.'],
+ [minDateError(new Date('2026-01-02T00:00:00.000Z')), 'Enter a date on or after 2026-01-02.'],
+ [maxDateError(new Date('2026-12-31T00:00:00.000Z')), 'Enter a date on or before 2026-12-31.'],
+ [minDateError(new Date(Number.NaN)), 'Enter a date on or after a valid date.'],
+ [patternError(/\d+/u), 'Enter a value in the required format.'],
+ [{ kind: 'custom' }, 'Enter a valid value.'],
+ ])('resolves $kind without a configured message', (error, expected) => {
+ expect(kitDefaultSignalFormErrorMessage(error)).toBe(expected);
+ });
+});
diff --git a/projects/kit/forms/src/kit-signal-form-error-message.ts b/projects/kit/forms/src/kit-signal-form-error-message.ts
new file mode 100644
index 0000000..d8799bc
--- /dev/null
+++ b/projects/kit/forms/src/kit-signal-form-error-message.ts
@@ -0,0 +1,49 @@
+import { InjectionToken } from '@angular/core';
+import type {
+ MaxLengthValidationError,
+ MaxValidationError,
+ MinLengthValidationError,
+ MinValidationError,
+ ValidationError,
+} from '@angular/forms/signals';
+
+export type KitSignalFormErrorMessageResolver = (error: ValidationError) => string | undefined;
+
+const formatDate = (value: Date): string => (Number.isNaN(value.getTime()) ? 'a valid date' : value.toISOString().slice(0, 10));
+
+/** Resolves Angular Signal Forms built-in validation errors to generic English messages. */
+export const kitDefaultSignalFormErrorMessage: KitSignalFormErrorMessageResolver = (error) => {
+ switch (error.kind) {
+ case 'required':
+ return 'This field is required.';
+ case 'email':
+ return 'Enter a valid email address.';
+ case 'min':
+ return `Enter a value of at least ${(error as MinValidationError).min}.`;
+ case 'max':
+ return `Enter a value of no more than ${(error as MaxValidationError).max}.`;
+ case 'minLength':
+ return `Enter at least ${(error as MinLengthValidationError).minLength} characters.`;
+ case 'maxLength':
+ return `Enter no more than ${(error as MaxLengthValidationError).maxLength} characters.`;
+ case 'minDate':
+ return `Enter a date on or after ${formatDate((error as ValidationError & { minDate: Date }).minDate)}.`;
+ case 'maxDate':
+ return `Enter a date on or before ${formatDate((error as ValidationError & { maxDate: Date }).maxDate)}.`;
+ case 'pattern':
+ return 'Enter a value in the required format.';
+ case 'parse':
+ return 'Enter a valid value.';
+ default:
+ return 'Enter a valid value.';
+ }
+};
+
+/** Override to localize or otherwise customize Signal Forms fallback messages. */
+export const KIT_SIGNAL_FORM_ERROR_MESSAGE_RESOLVER = new InjectionToken(
+ 'KIT_SIGNAL_FORM_ERROR_MESSAGE_RESOLVER',
+ {
+ providedIn: 'root',
+ factory: () => kitDefaultSignalFormErrorMessage,
+ },
+);
diff --git a/projects/kit/forms/src/public-api.ts b/projects/kit/forms/src/public-api.ts
index 6e408be..7f7b561 100644
--- a/projects/kit/forms/src/public-api.ts
+++ b/projects/kit/forms/src/public-api.ts
@@ -1,2 +1,3 @@
export * from './kit-ionic-form-field';
+export * from './kit-signal-form-error-message';
export * from './provide-kit-ionic-signal-forms';