Skip to content
Merged
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
1 change: 1 addition & 0 deletions angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions projects/kit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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)

<!-- rdlabo-docs-omit -->
**Full documentation:** [https://docs.rdlabo.dev/projects/ionic-angular-kit](https://docs.rdlabo.dev/projects/ionic-angular-kit)
Expand Down
40 changes: 40 additions & 0 deletions projects/kit/docs/forms.md
Original file line number Diff line number Diff line change
@@ -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 }],
},
}
```
6 changes: 6 additions & 0 deletions projects/kit/forms/ng-package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"$schema": "../../../node_modules/ng-packagr/ng-package.schema.json",
"lib": {
"entryFile": "src/public-api.ts"
}
}
77 changes: 77 additions & 0 deletions projects/kit/forms/src/kit-ionic-form-field.spec.ts
Original file line number Diff line number Diff line change
@@ -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: `
<input [formField]="fields.native" />
<ion-input [formField]="fields.input"></ion-input>
<ion-textarea [formField]="fields.textarea"></ion-textarea>
<ion-select [formField]="fields.select"></ion-select>
<ion-checkbox [formField]="fields.checkbox"></ion-checkbox>
<ion-radio-group [formField]="fields.radio"></ion-radio-group>
<ion-toggle [formField]="fields.toggle"></ion-toggle>
<ion-input [formField]="fields.explicit" errorText="Cross-field error"></ion-input>
<ion-input [formField]="fields.explicit" [errorText]="boundError"></ion-input>
`,
})
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();
});
});
29 changes: 29 additions & 0 deletions projects/kit/forms/src/kit-ionic-form-field.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>);
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);
});
}
}
18 changes: 18 additions & 0 deletions projects/kit/forms/src/provide-kit-ionic-signal-forms.ts
Original file line number Diff line number Diff line change
@@ -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(),
},
});
2 changes: 2 additions & 0 deletions projects/kit/forms/src/public-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './kit-ionic-form-field';
export * from './provide-kit-ionic-signal-forms';
1 change: 1 addition & 0 deletions projects/kit/tsconfig.spec.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 2 additions & 1 deletion scripts/test-package-consumer.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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)];
`,
);
Expand Down