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)]; `, );