diff --git a/angular.json b/angular.json
index 34fa3d31..589c6b03 100644
--- a/angular.json
+++ b/angular.json
@@ -263,6 +263,7 @@
"../review/src/**/*.spec.ts",
"../live-update/src/**/*.spec.ts",
"../app-update/src/**/*.spec.ts",
+ "../forms/src/**/*.spec.ts",
"../offline/src/**/*.spec.ts",
"../auth-firebase/src/**/*.spec.ts",
"../auth-firebase/social/src/**/*.spec.ts"
diff --git a/projects/kit/README.md b/projects/kit/README.md
index 37b4e159..d7d035ee 100644
--- a/projects/kit/README.md
+++ b/projects/kit/README.md
@@ -25,6 +25,7 @@ Install `@ionic/storage-angular` when using storage. Other peers are feature-sco
| `@rdlabo/ionic-angular-kit` | Storage, overlays, guards, HTTP, realtime, directives, keyboard, and utilities |
| `@rdlabo/ionic-angular-kit/offline` | Scoped local replica, outbox, pull, replay, and request policies |
| `@rdlabo/ionic-angular-kit/theme` | Persisted light/dark theme and native status bar sync |
+| `@rdlabo/ionic-angular-kit/forms` | Ionic error text and state classes for Angular Signal Forms |
| `@rdlabo/ionic-angular-kit/review` | Throttled native in-app review requests |
| `@rdlabo/ionic-angular-kit/printer` | DOM-to-PNG, Brother label, and PDF helpers |
| `@rdlabo/ionic-angular-kit/auth-firebase` | Firebase dependency wiring and authentication flows |
@@ -42,6 +43,7 @@ Most features expose a provider whose callbacks keep routes, copy, credentials,
- [Authentication and HTTP](./docs/auth-http.md)
- [Offline and Realtime](./docs/offline-realtime.md)
- [Optional Features](./docs/optional-features.md)
+- [Ionic Signal Forms](./docs/forms.md)
**Full documentation:** [https://docs.rdlabo.dev/projects/ionic-angular-kit](https://docs.rdlabo.dev/projects/ionic-angular-kit)
diff --git a/projects/kit/docs/forms.md b/projects/kit/docs/forms.md
new file mode 100644
index 00000000..aecdb908
--- /dev/null
+++ b/projects/kit/docs/forms.md
@@ -0,0 +1,40 @@
+# Ionic Signal Forms
+
+This entry point targets Angular 22 Signal Forms.
+
+Import Angular's `FormField` together with the kit adapter in each standalone component that binds Signal Forms to Ionic controls.
+
+```ts
+import { FormField } from '@angular/forms/signals';
+import { KitIonicFormField } from '@rdlabo/ionic-angular-kit/forms';
+
+@Component({
+ imports: [FormField, KitIonicFormField],
+})
+export class ProfilePage {}
+```
+
+The adapter copies the first non-empty string validation message to Ionic's `errorText` property for `ion-input`, `ion-textarea`, `ion-select`, `ion-checkbox`, `ion-radio-group`, and `ion-toggle`. Validation wording and localization remain the application's responsibility. An explicit `errorText` or `[errorText]` binding takes precedence and prevents the adapter from being instantiated.
+
+Install the state-class configuration once at application bootstrap:
+
+```ts
+import { provideKitIonicSignalForms } from '@rdlabo/ionic-angular-kit/forms';
+
+export const appConfig: ApplicationConfig = {
+ providers: [provideKitIonicSignalForms()],
+};
+```
+
+Angular does not merge multiple `provideSignalFormsConfig` class configurations. If the application provides its own configuration, combine all required class mappings in one provider instead of registering both providers and relying on their order.
+
+When using `@rdlabo/eslint-plugin-rules`, enable its adapter-aware mode only after every relevant standalone component has imported the adapter:
+
+```js
+{
+ files: ['**/*.html'],
+ rules: {
+ '@rdlabo/rules/require-ion-error-text': ['error', { formFieldProvidesErrorText: true }],
+ },
+}
+```
diff --git a/projects/kit/forms/ng-package.json b/projects/kit/forms/ng-package.json
new file mode 100644
index 00000000..d0a2dcdc
--- /dev/null
+++ b/projects/kit/forms/ng-package.json
@@ -0,0 +1,6 @@
+{
+ "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json",
+ "lib": {
+ "entryFile": "src/public-api.ts"
+ }
+}
diff --git a/projects/kit/forms/src/kit-ionic-form-field.spec.ts b/projects/kit/forms/src/kit-ionic-form-field.spec.ts
new file mode 100644
index 00000000..5b7d81ac
--- /dev/null
+++ b/projects/kit/forms/src/kit-ionic-form-field.spec.ts
@@ -0,0 +1,77 @@
+import { Component, signal } from '@angular/core';
+import { TestBed } from '@angular/core/testing';
+import { form, FormField, required, validate } from '@angular/forms/signals';
+import { IonCheckbox, IonInput, IonRadioGroup, IonSelect, IonTextarea, IonToggle } from '@ionic/angular';
+import { KitIonicFormField } from './kit-ionic-form-field';
+import { provideKitIonicSignalForms } from './provide-kit-ionic-signal-forms';
+
+@Component({
+ imports: [FormField, KitIonicFormField, IonInput, IonTextarea, IonSelect, IonCheckbox, IonRadioGroup, IonToggle],
+ template: `
+
+
+
+
+
+
+
+
+
+ `,
+})
+class Host {
+ readonly boundError = 'Bound cross-field error';
+ readonly model = signal({
+ native: '',
+ input: '',
+ textarea: '',
+ select: '',
+ checkbox: false,
+ radio: '',
+ toggle: false,
+ 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.explicit, { message: 'Generated error' });
+ });
+}
+
+describe('KitIonicFormField', () => {
+ it('adapts all supported Ionic controls without replacing Angular FormField', () => {
+ TestBed.configureTestingModule({ providers: [...provideKitIonicSignalForms()] });
+ const fixture = TestBed.createComponent(Host);
+ fixture.detectChanges();
+
+ expect(
+ [...fixture.nativeElement.querySelectorAll('ion-input, ion-textarea, ion-select, ion-checkbox, ion-radio-group, ion-toggle')]
+ .slice(0, 6)
+ .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');
+
+ const input = fixture.nativeElement.querySelector('ion-input');
+ expect(input.classList.contains('ion-invalid')).toBe(true);
+ expect(input.classList.contains('ng-invalid')).toBe(true);
+ input.dispatchEvent(new CustomEvent('ionBlur'));
+ fixture.detectChanges();
+ expect(input.classList.contains('ion-touched')).toBe(true);
+
+ fixture.componentInstance.model.update((value) => ({ ...value, input: 'value' }));
+ fixture.detectChanges();
+ expect(fixture.nativeElement.querySelector('ion-input').errorText).toBeUndefined();
+ expect(input.classList.contains('ion-valid')).toBe(true);
+ expect(input.classList.contains('ng-valid')).toBe(true);
+ expect(fixture.nativeElement.querySelector('input').getAttribute('errorText')).toBeNull();
+ });
+});
diff --git a/projects/kit/forms/src/kit-ionic-form-field.ts b/projects/kit/forms/src/kit-ionic-form-field.ts
new file mode 100644
index 00000000..9c9fcebc
--- /dev/null
+++ b/projects/kit/forms/src/kit-ionic-form-field.ts
@@ -0,0 +1,29 @@
+import { Directive, effect, ElementRef, inject, Renderer2 } from '@angular/core';
+import { FORM_FIELD } from '@angular/forms/signals';
+
+@Directive({
+ // Angular's FormField owns [formField]. This sibling directive only adapts its validation message to Ionic.
+ selector: `
+ ion-input[formField]:not([errorText]),
+ ion-textarea[formField]:not([errorText]),
+ ion-select[formField]:not([errorText]),
+ ion-checkbox[formField]:not([errorText]),
+ ion-radio-group[formField]:not([errorText]),
+ ion-toggle[formField]:not([errorText])
+ `,
+})
+export class KitIonicFormField {
+ readonly #field = inject(FORM_FIELD, { self: true });
+ readonly #element = inject(ElementRef);
+ readonly #renderer = inject(Renderer2);
+
+ constructor() {
+ effect(() => {
+ const message = this.#field
+ .errors()
+ .map((error) => error.message)
+ .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/provide-kit-ionic-signal-forms.ts b/projects/kit/forms/src/provide-kit-ionic-signal-forms.ts
new file mode 100644
index 00000000..d50268cb
--- /dev/null
+++ b/projects/kit/forms/src/provide-kit-ionic-signal-forms.ts
@@ -0,0 +1,18 @@
+import { provideSignalFormsConfig } from '@angular/forms/signals';
+
+/** Adds Angular and Ionic validity state classes to Signal Forms controls. */
+export const provideKitIonicSignalForms = () =>
+ provideSignalFormsConfig({
+ classes: {
+ 'ng-dirty': ({ state }) => state().dirty(),
+ 'ng-invalid': ({ state }) => state().invalid(),
+ 'ng-pending': ({ state }) => state().pending(),
+ 'ng-pristine': ({ state }) => !state().dirty(),
+ 'ng-touched': ({ state }) => state().touched(),
+ 'ng-untouched': ({ state }) => !state().touched(),
+ 'ng-valid': ({ state }) => state().valid(),
+ 'ion-invalid': ({ state }) => state().invalid(),
+ 'ion-touched': ({ state }) => state().touched(),
+ 'ion-valid': ({ state }) => state().valid(),
+ },
+ });
diff --git a/projects/kit/forms/src/public-api.ts b/projects/kit/forms/src/public-api.ts
new file mode 100644
index 00000000..6e408bee
--- /dev/null
+++ b/projects/kit/forms/src/public-api.ts
@@ -0,0 +1,2 @@
+export * from './kit-ionic-form-field';
+export * from './provide-kit-ionic-signal-forms';
diff --git a/projects/kit/tsconfig.spec.json b/projects/kit/tsconfig.spec.json
index 566f4c33..81426124 100644
--- a/projects/kit/tsconfig.spec.json
+++ b/projects/kit/tsconfig.spec.json
@@ -14,6 +14,7 @@
"review/src/**/*.spec.ts",
"live-update/src/**/*.spec.ts",
"app-update/src/**/*.spec.ts",
+ "forms/src/**/*.spec.ts",
"offline/src/**/*.spec.ts",
"auth-firebase/src/**/*.spec.ts",
"auth-firebase/social/src/**/*.spec.ts"
diff --git a/scripts/test-package-consumer.mjs b/scripts/test-package-consumer.mjs
index 8967f760..07ff1d89 100644
--- a/scripts/test-package-consumer.mjs
+++ b/scripts/test-package-consumer.mjs
@@ -96,6 +96,7 @@ try {
writeFileSync(
join(temporaryDirectory, 'consumer.ts'),
`import { type KitAuthInputMode } from '@rdlabo/ionic-angular-kit';
+import { KitIonicFormField, provideKitIonicSignalForms } from '@rdlabo/ionic-angular-kit/forms';
import { providePhotoEditor, type PhotoEditorProps, type PhotoEditorResult, type PhotoViewerProps, type PhotoViewerResult } from '@rdlabo/ionic-angular-photo-editor';
import { PhotoEditorPage } from '@rdlabo/ionic-angular-photo-editor/editor';
import { createTuiImageEditor } from '@rdlabo/ionic-angular-photo-editor/editor/tui';
@@ -111,7 +112,7 @@ const viewerProps: PhotoViewerProps = { imageUrls: [], toolbarColorScheme: 'ligh
const editorResult: PhotoEditorResult = { action: 'save', value: editorProps.value };
const viewerResult: PhotoViewerResult = { action: 'delete', index: 0, value: '' };
const photoProviders = providePhotoEditor({ maxSize: 1000, labels: { camera: 'Camera' } });
-const symbols = [providePhotoEditor, PhotoEditorPage, createTuiImageEditor, PhotoFileService, loadCapacitorPhotoCamera, PhotoViewerPage, ScrollHeaderDirective, CdkDynamicSizeVirtualScroll];
+const symbols = [KitIonicFormField, provideKitIonicSignalForms, providePhotoEditor, PhotoEditorPage, createTuiImageEditor, PhotoFileService, loadCapacitorPhotoCamera, PhotoViewerPage, ScrollHeaderDirective, CdkDynamicSizeVirtualScroll];
void [mode, viewerProps, editorResult, viewerResult, photoProviders, symbols, calculateItemCountForPixelDistance([{ itemSize: 10 }], 5)];
`,
);