@rdlabo/ionic-angular-kit 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,354 @@
1
+ # @rdlabo/ionic-angular-kit
2
+
3
+ A small ergonomic kit for Ionic Angular applications. It provides:
4
+
5
+ - **KitStorageService** — a typed, write-loss-safe wrapper around `@ionic/storage-angular`
6
+ - **KitOverlayController** — a unified presenter for Ionic Modal, Toast, and Alert
7
+ - **Auth guards** — functional `CanActivateFn` guards for a 4-state auth model
8
+ - **HTTP interceptor** — a fleet-canonical auth + retry + error-hook interceptor
9
+ - **KitAutofillDirective** — an iOS autofill workaround for `ion-input`
10
+
11
+ ---
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ npm install @rdlabo/ionic-angular-kit
17
+ ```
18
+
19
+ ### Peer dependencies
20
+
21
+ | Package | Version |
22
+ |---|---|
23
+ | `@angular/common` | `^21.0.0` |
24
+ | `@angular/core` | `^21.0.0` |
25
+ | `@angular/router` | `^21.0.0` |
26
+ | `@ionic/angular` | `^8.0.0` |
27
+ | `@ionic/storage-angular` | `^4.0.0` |
28
+ | `@capacitor/core` | `>=6.0.0 <9.0.0` |
29
+ | `@capacitor/keyboard` | `>=6.0.0 <9.0.0` |
30
+ | `@capacitor/network` | `>=6.0.0 <9.0.0` |
31
+ | `rxjs` | `^7.8.0` |
32
+
33
+ ---
34
+
35
+ ## Features
36
+
37
+ ### KitStorageService
38
+
39
+ A typed wrapper around `@ionic/storage-angular` that guarantees writes are never silently dropped even when called immediately after service creation.
40
+
41
+ **How it works:** `Storage.create()` is awaited exactly once internally (via a private `#ready` promise). Every public method awaits `#ready` before operating, so callers never need a separate init step.
42
+
43
+ **Setup** — provide `IonicStorageModule` (or equivalent) alongside the service:
44
+
45
+ ```typescript
46
+ // app.config.ts
47
+ import { IonicStorageModule } from '@ionic/storage-angular';
48
+ import { importProvidersFrom } from '@angular/core';
49
+
50
+ export const appConfig: ApplicationConfig = {
51
+ providers: [
52
+ importProvidersFrom(IonicStorageModule.withConfig({ name: '__mydb' })),
53
+ ],
54
+ };
55
+ ```
56
+
57
+ **Usage**
58
+
59
+ ```typescript
60
+ import { KitStorageService } from '@rdlabo/ionic-angular-kit';
61
+
62
+ @Injectable({ providedIn: 'root' })
63
+ export class TokenService {
64
+ readonly #storage = inject(KitStorageService);
65
+
66
+ async saveToken(token: string): Promise<void> {
67
+ await this.#storage.set('token', token);
68
+ }
69
+
70
+ async getToken(): Promise<string | null> {
71
+ return this.#storage.get<string>('token');
72
+ }
73
+ }
74
+ ```
75
+
76
+ **API**
77
+
78
+ ```typescript
79
+ set<T>(key: string, value: T): Promise<void>
80
+ get<T>(key: string): Promise<T | null> // returns null (not undefined) for missing keys
81
+ remove(key: string): Promise<void>
82
+ clear(): Promise<void>
83
+ keys(): Promise<string[]>
84
+ ```
85
+
86
+ ---
87
+
88
+ ### KitOverlayController + provideKitOverlay
89
+
90
+ A unified presenter for Ionic Modal, Toast, and Alert that folds create → present → dismiss into a single awaitable call.
91
+
92
+ **Convention:** button labels (`close`, `cancel`) are **not hard-coded** in the kit. The consuming application must inject them via `provideKitOverlay`. This keeps the kit independent of `@angular/localize` and lets each app supply translated strings.
93
+
94
+ **Setup**
95
+
96
+ ```typescript
97
+ // app.config.ts
98
+ import { provideKitOverlay } from '@rdlabo/ionic-angular-kit';
99
+
100
+ export const appConfig: ApplicationConfig = {
101
+ providers: [
102
+ provideKitOverlay({
103
+ labels: {
104
+ close: $localize`閉じる`,
105
+ cancel: $localize`キャンセル`,
106
+ },
107
+ }),
108
+ ],
109
+ };
110
+ ```
111
+
112
+ **Usage**
113
+
114
+ ```typescript
115
+ import { KitOverlayController } from '@rdlabo/ionic-angular-kit';
116
+
117
+ @Component({ ... })
118
+ export class MyPage {
119
+ readonly #overlay = inject(KitOverlayController);
120
+
121
+ async openDetail(): Promise<void> {
122
+ const result = await this.#overlay.presentModal<{ id: number }>(DetailPage, { item });
123
+ // result is the data passed to modal.dismiss()
124
+ }
125
+
126
+ async confirm(): Promise<void> {
127
+ const ok = await this.#overlay.alertConfirm({
128
+ header: 'Delete',
129
+ message: 'Are you sure?',
130
+ okText: 'Delete',
131
+ });
132
+ if (ok) { /* proceed */ }
133
+ }
134
+
135
+ async notify(message: string): Promise<void> {
136
+ await this.#overlay.presentToast({ message });
137
+ }
138
+ }
139
+ ```
140
+
141
+ **API**
142
+
143
+ ```typescript
144
+ presentModal<O>(
145
+ component: ModalOptions['component'],
146
+ componentProps?: ModalOptions['componentProps'],
147
+ options?: KitModalPresentOptions, // Omit<ModalOptions, 'component'|'componentProps'> + watchKeyboard?
148
+ ): Promise<O | undefined>
149
+
150
+ presentToast(options: ToastOptions): Promise<HTMLIonToastElement>
151
+ // kit defaults: position='top', duration=2000, swipeGesture='vertical'
152
+ // caller options spread over the defaults — any field can be overridden
153
+
154
+ alertClose(options: { header: string; message: string; subHeader?: string }): Promise<void>
155
+
156
+ alertConfirm(options: {
157
+ header: string;
158
+ message: string;
159
+ okText: string;
160
+ subHeader?: string;
161
+ }): Promise<boolean> // true iff role === 'confirm'
162
+ ```
163
+
164
+ `watchKeyboard: true` (on `presentModal` options) expands a bottom sheet to full height when the native keyboard appears (iOS/Android only; no-op on web).
165
+
166
+ ---
167
+
168
+ ### Auth guards + provideKitAuth
169
+
170
+ Functional `CanActivateFn` guards for a four-state auth model:
171
+
172
+ | State | Meaning |
173
+ |---|---|
174
+ | `'user'` | Fully authenticated |
175
+ | `'confirm'` | Authenticated but email confirmation pending |
176
+ | `'required'` | Not authenticated |
177
+ | `'anonymous'` | Anonymous login active (can be prompted to register) |
178
+
179
+ **Convention:** every redirect path and every app-specific hook (`onAuthorized`, `onUnauthenticated`) is supplied via `provideKitAuth`. The kit does not hard-code any routes.
180
+
181
+ **Setup**
182
+
183
+ ```typescript
184
+ // app.config.ts
185
+ import { provideKitAuth } from '@rdlabo/ionic-angular-kit';
186
+
187
+ export const appConfig: ApplicationConfig = {
188
+ providers: [
189
+ provideKitAuth(() => {
190
+ const auth = inject(AuthService);
191
+ return {
192
+ authState: () => auth.state$, // Observable<KitAuthState>
193
+ onAuthorized: async (state) => {
194
+ // Called for 'user' — perform token refresh, permission check, etc.
195
+ // Return true to proceed, UrlTree to redirect, false to block.
196
+ await auth.refreshToken();
197
+ return true;
198
+ },
199
+ onUnauthenticated: async (state) => {
200
+ // Called for 'required'/'confirm' in kitRequireAuthorizedGuard.
201
+ // Return true to allow anonymous access, false to redirect.
202
+ return false;
203
+ },
204
+ redirects: {
205
+ whenAuthorized: '/home', // kitRequiredUnauthorizedGuard
206
+ whenConfirming: '/auth/confirm', // kitRequiredUnauthorizedGuard
207
+ whenNotConfirming: '/auth/signin',// kitRequireConfirmingGuard
208
+ whenUnauthorized: '/auth', // kitRequireAuthorizedGuard
209
+ },
210
+ };
211
+ }),
212
+ ],
213
+ };
214
+ ```
215
+
216
+ **Guards**
217
+
218
+ ```typescript
219
+ // routes.ts
220
+ import {
221
+ kitRequiredUnauthorizedGuard,
222
+ kitRequireConfirmingGuard,
223
+ kitRequireAuthorizedGuard,
224
+ } from '@rdlabo/ionic-angular-kit';
225
+
226
+ export const routes: Routes = [
227
+ {
228
+ path: 'auth',
229
+ canActivate: [kitRequiredUnauthorizedGuard],
230
+ // Blocks 'user' → redirects whenAuthorized
231
+ // Blocks 'confirm' → redirects whenConfirming
232
+ // Allows 'required' and 'anonymous'
233
+ loadChildren: () => import('./auth/routes'),
234
+ },
235
+ {
236
+ path: 'confirm',
237
+ canActivate: [kitRequireConfirmingGuard],
238
+ // Allows only 'confirm'
239
+ // 'anonymous' → redirects whenAuthorized
240
+ // 'required'/'user' → redirects whenNotConfirming
241
+ loadComponent: () => import('./confirm/confirm.page'),
242
+ },
243
+ {
244
+ path: 'app',
245
+ canActivate: [kitRequireAuthorizedGuard],
246
+ // 'user' → calls onAuthorized → proceeds on true, redirects on UrlTree
247
+ // 'anonymous' → allowed (anonymous browsing)
248
+ // 'required'/'confirm' → calls onUnauthenticated → proceeds on true/UrlTree, redirects whenUnauthorized on false
249
+ loadChildren: () => import('./main/routes'),
250
+ },
251
+ ];
252
+ ```
253
+
254
+ ---
255
+
256
+ ### kitAuthInterceptor + provideKitHttp
257
+
258
+ A fleet-canonical HTTP interceptor with:
259
+
260
+ - Per-request auth header injection
261
+ - Configurable bypass (CDN, S3, external URLs)
262
+ - Exponential-backoff retry (count: 2) skipping `[400, 403, 404, 418, 500, 502]` and `401`
263
+ - Offline fallback (short-circuit error with a cached response)
264
+ - Error hooks for 401, 403, network errors, and server errors with `error.message`
265
+
266
+ **Convention:** all app-specific logic (auth headers, error UI) lives in the config factory. The retry policy, bypass evaluation, and error dispatch are fixed in the kit and not overridable per-call.
267
+
268
+ **Setup**
269
+
270
+ ```typescript
271
+ // app.config.ts
272
+ import { provideHttpClient, withInterceptors } from '@angular/common/http';
273
+ import { kitAuthInterceptor, provideKitHttp } from '@rdlabo/ionic-angular-kit';
274
+
275
+ export const appConfig: ApplicationConfig = {
276
+ providers: [
277
+ provideHttpClient(withInterceptors([kitAuthInterceptor])),
278
+ provideKitHttp(() => {
279
+ const auth = inject(AuthService);
280
+ const router = inject(Router);
281
+ const toast = inject(KitOverlayController);
282
+ return {
283
+ bypass: (req) => req.url.startsWith('https://cdn.example.com'),
284
+ getAuthHeaders: async (req) => ({
285
+ Authorization: `Bearer ${await auth.getToken()}`,
286
+ }),
287
+ buildExtraHeaders: (req) => ({ 'X-App-Version': '1.0.0' }),
288
+ offlineFallback: (req, err) => null, // no offline queue
289
+ onUnauthorized: (req) => auth.signOut(),
290
+ onForbidden: (req) => router.navigate(['/403']),
291
+ onNetworkError: (status) => toast.presentToast({ message: 'Network error' }),
292
+ onServerError: (message) => toast.presentToast({ message }),
293
+ };
294
+ }),
295
+ ],
296
+ };
297
+ ```
298
+
299
+ **Error dispatch order** (in `catchError`):
300
+ 1. `offlineFallback` non-null → return fallback observable (no further hooks called)
301
+ 2. `401` → `onUnauthorized`
302
+ 3. `403` → `onForbidden`
303
+ 4. Non-400/500 status AND device connected → `onNetworkError`
304
+ 5. 400 or 500 with `error.message` → `onServerError`
305
+
306
+ ---
307
+
308
+ ### KitAutofillDirective
309
+
310
+ An iOS workaround for `ion-input` autofill (password managers, iCloud Keychain). Without it, autofilled values are not reflected in the Angular form model on iOS native.
311
+
312
+ ```html
313
+ <ion-input rdlaboAutofill formControlName="password" type="password" />
314
+ ```
315
+
316
+ The directive is a no-op on non-iOS platforms.
317
+
318
+ ---
319
+
320
+ ## Consumer Vitest setup notes
321
+
322
+ When testing a consumer app that declares `@rdlabo/ionic-angular-kit` as a `file:` symlink dependency, add the following to your `vitest.config.ts`:
323
+
324
+ ```typescript
325
+ // vitest.config.ts
326
+ export default defineConfig({
327
+ resolve: {
328
+ dedupe: [
329
+ '@angular/core',
330
+ '@angular/common',
331
+ '@angular/router',
332
+ '@ionic/angular',
333
+ '@ionic/core',
334
+ 'rxjs',
335
+ ],
336
+ },
337
+ test: {
338
+ server: {
339
+ deps: {
340
+ inline: [
341
+ /@ionic\/angular/,
342
+ /@ionic\/core/,
343
+ /ionicons/,
344
+ /@rdlabo\/ionic-angular-kit/, // inline the kit itself
345
+ ],
346
+ },
347
+ },
348
+ },
349
+ });
350
+ ```
351
+
352
+ - `resolve.dedupe` prevents Angular's `inject()` from throwing `NG0203 (must be called in an injection context)` when the symlinked kit resolves a different copy of `@angular/core`.
353
+ - `server.deps.inline` is required for ESM packages that Vite cannot handle as external CJS.
354
+ - In test configs, provide all required tokens before testing kit-dependent code: `provideKitOverlay(...)`, `provideKitAuth(...)`, `provideKitHttp(...)`.