diff --git a/projects/kit/README.md b/projects/kit/README.md index 0209df3..7ffa1ca 100644 --- a/projects/kit/README.md +++ b/projects/kit/README.md @@ -1,1337 +1,47 @@ # @rdlabo/ionic-angular-kit -A small ergonomic kit for Ionic Angular applications. It provides: +`@rdlabo/ionic-angular-kit` provides shared application infrastructure for Ionic Angular applications. It keeps product-specific screens, domain policy, and translations in the consuming app. -**Documentation:** [Read the full documentation](https://docs.rdlabo.dev/projects/ionic-angular-kit) - -- **KitStorageService** — a typed, write-loss-safe wrapper around `@ionic/storage-angular` -- **KitOverlayController** — a unified presenter for Ionic Modal, Toast, and Alert -- **Auth guards** — functional guards plus shared `none` / `local` / `remote` runtime access -- **HTTP interceptor** — a fleet-canonical auth + retry + error-hook interceptor -- **KitRealtimeConnection** — foreground/network-aware Hibernation WebSocket reconnect and resync -- **KitAuthInputDirective** — sign-in email remember/prefill + iOS autofill workaround for `ion-input` -- **kitClearStoragePreservingKeys** — `clear()` that restores selected keys (`KIT_LAST_AUTH_EMAIL_KEY`, `KIT_THEME_STORAGE_KEY`, …) - ---- - -## Install - -```bash +```sh npm install @rdlabo/ionic-angular-kit -# prereleases (np: X.Y.Z-N) are on the npm dist-tag `beta`: -# npm install @rdlabo/ionic-angular-kit@beta -``` - -Kit shares the repo `v*` release line with the other libraries (see root README § Release). - -### Peer dependencies - -| Package | Version | -| ------------------------------------ | ---------------- | -| `@angular/common` | `^21.0.0` | -| `@angular/core` | `^21.0.0` | -| `@angular/router` | `^21.0.0` | -| `@ionic/angular` | `^8.0.0` | -| `@ionic/storage-angular` | `^4.0.0` | -| `@capacitor/core` | `>=6.0.0 <9.0.0` | -| `@capacitor/app` | `>=6.0.0 <9.0.0` | -| `@capacitor/haptics` | `>=6.0.0 <9.0.0` | -| `@capacitor/keyboard` | `>=6.0.0 <9.0.0` | -| `@capacitor/network` | `>=6.0.0 <9.0.0` | -| `@capacitor/preferences` | `>=6.0.0 <9.0.0` | -| `@capacitor/status-bar` | `>=6.0.0 <9.0.0` | -| `@capacitor-community/in-app-review` | `>=6.0.0 <9.0.0` | -| `@rdlabo/capacitor-brotherprint` | `>=6.0.0 <9.0.0` | -| `dom-to-image-more` | `^3.0.0` | -| `rxjs` | `^7.8.0` | - -Feature-scoped peers are only needed by the features that use them (`status-bar` → `KitThemeController`; `preferences` + `in-app-review` → `kitRequestReview`; `capacitor-brotherprint` + `dom-to-image-more` → the Brother/PNG printer helpers; `pdf-lib` → the PDF printer helper); an app that doesn't use a feature can ignore its unmet-peer warning. - ---- - -## Features - -### KitRealtimeConnection - -An abstract Hibernation WebSocket client for application realtime services. Subclasses supply -connection intent and one or more `{ url, protocols }` targets; the kit owns foreground/network -suspension, target-scoped reconnect that preserves healthy sockets, exponential backoff, open and half-open detection, ping/pong, -self-echo annotation, and `reconnected$` resync signaling. Use `kitRealtimeProtocols()` to pass -authentication and the stable `KIT_REALTIME_CLIENT_ID` through WebSocket subprotocols without -putting credentials in the URL. - -Domain event types, authorization, room selection, and REST resync behavior remain in the app. -Offline-capable authenticated clients set `requireRemoteAccess: true` in `realtimeOptions`; sockets then close on -`local` / `none` and reopen only after `KitAuthAccessService` publishes `remote`. - ---- - -### KitStorageService - -A typed wrapper around `@ionic/storage-angular` that guarantees writes are never silently dropped even when called immediately after service creation. - -**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. - -**Setup** — provide `IonicStorageModule` (or equivalent) alongside the service: - -```typescript -// app.config.ts -import { IonicStorageModule } from '@ionic/storage-angular'; -import { importProvidersFrom } from '@angular/core'; - -export const appConfig: ApplicationConfig = { - providers: [importProvidersFrom(IonicStorageModule.withConfig({ name: '__mydb' }))], -}; -``` - -**Usage** - -```typescript -import { KitStorageService } from '@rdlabo/ionic-angular-kit'; - -@Injectable({ providedIn: 'root' }) -export class TokenService { - readonly #storage = inject(KitStorageService); - - async saveToken(token: string): Promise { - await this.#storage.set('token', token); - } - - async getToken(): Promise { - return this.#storage.get('token'); - } -} -``` - -**API** - -```typescript -set(key: string, value: T): Promise -get(key: string): Promise // returns null (not undefined) for missing keys -remove(key: string): Promise -clear(): Promise -keys(): Promise -``` - ---- - -### KitOverlayController + provideKitOverlay - -A unified presenter for Ionic Modal, Toast, and Alert that folds create → present → dismiss into a single awaitable call. - -**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. - -**Setup** - -```typescript -// app.config.ts -import { provideKitOverlay } from '@rdlabo/ionic-angular-kit'; - -export const appConfig: ApplicationConfig = { - providers: [ - provideKitOverlay({ - labels: { - close: $localize`閉じる`, - cancel: $localize`キャンセル`, - }, - }), - ], -}; -``` - -**Usage** - -```typescript -import { KitOverlayController } from '@rdlabo/ionic-angular-kit'; - -@Component({ ... }) -export class MyPage { - readonly #overlay = inject(KitOverlayController); - - async openDetail(): Promise { - const result = await this.#overlay.presentModal(DetailPage, { item }); - // result type is inferred from `declare static modalReturn` on DetailPage - } - - async confirm(): Promise { - const ok = await this.#overlay.alertConfirm({ - header: 'Delete', - message: 'Are you sure?', - okText: 'Delete', - }); - if (ok) { /* proceed */ } - } - - async notify(message: string): Promise { - await this.#overlay.presentToast({ message }); - } -} ``` -**API** - -```typescript -presentModal( - component: C, - ...args: ModalPresentArgs, // props inferred from input() fields; options?: KitModalPresentOptions -): Promise | undefined> -// Props inferred from the component's input() fields (required/optional). -// Return type inferred from `declare static modalReturn: T` on the component (void if absent). - -presentPopover( - component: PopoverOptions['component'], - componentProps?: PopoverOptions['componentProps'], - options?: Omit, // e.g. { event } to anchor it -): Promise - -presentToast(options: ToastOptions): Promise -// kit defaults: position='bottom', duration=2000, swipeGesture='vertical' -// A bottom toast with no explicit positionAnchor auto-anchors above a visible bottom -// (`slot="top"` bars are ignored) so it clears the tabs; keyboard avoidance rides the native keyboard resize. -// caller options spread over the defaults — any field can be overridden - -alertClose(options: { header: string; message: string; subHeader?: string }): Promise - -alertConfirm(options: { - header: string; - message: string; - okText: string; - subHeader?: string; -}): Promise // true iff role === 'confirm' -``` - -`watchKeyboard: true` (on `presentModal` options) expands a bottom sheet to full height when the native keyboard appears (iOS/Android only; no-op on web). - -**How `presentModal` decides required vs. optional props.** Props are inferred from the component's `input()` fields, and whether each prop is **required** or **optional** is decided by a single rule: _does the input's type include `undefined`?_ A default value is not "optional" — providing a default removes `undefined` from the input's type, so a defaulted input becomes a **required** prop. +## Requirements -| Declaration | Input type | Includes `undefined`? | Prop | -| ------------------------ | ---------------- | --------------------- | ------------------------------------------ | -| `input.required()` | `T` | No | required | -| `input(defaultValue)` | `T` | No | **required** ← a default makes it required | -| `input()` (no arg) | `T \| undefined` | Yes | optional | +| Package | Supported version | +| ---------------------------------- | ----------------- | +| Angular | 21.x | +| Ionic Angular | 8.x | +| RxJS | 7.8.x | +| Capacitor core and feature plugins | 6.x through 8.x | -To make a prop **optional**, drop the default and use a bare `input()` (its type is `T | undefined`), then apply your fallback where you read it (e.g. `this.enabled() ?? true`). If a component has at least one required input, the `componentProps` argument itself becomes mandatory; if it has no required inputs, `componentProps` may be omitted; a component with no `input()` fields at all accepts loose, untyped props. +Install `@ionic/storage-angular` when using storage. Other peers are feature-scoped: install only the Capacitor, Firebase, printing, or Live Update packages used by your selected entry points. -**Best practice — the modal launcher pattern.** Never call `modalController.create(...)` inline in a component. Instead, each modal/popover page exports a typed launcher next to itself and every call site goes through `KitOverlayController`: - -```typescript -// detail.page.ts — component declares its return type: -export class DetailPage { - declare static readonly modalReturn: DetailResult; - readonly item = input.required(); -} - -export const launchDetailPage = (overlay: KitOverlayController, props: { item: Item }): Promise => - overlay.presentModal(DetailPage, props, { backdropDismiss: false }); -``` - -This centralizes presentation options, keeps component props and dismiss data type-safe, and makes every modal discoverable. A well-disciplined app has **zero** inline `controller.create()` calls. - ---- - -### Auth guards + provideKitAuth - -Functional `CanActivateFn` guards for a five-state auth model: - -| State | Meaning | -| --------------- | -------------------------------------------------------- | -| `'user'` | Fully authenticated | -| `'confirm'` | Authenticated but email confirmation pending | -| `'required'` | Not authenticated | -| `'anonymous'` | Anonymous login active (can be prompted to register) | -| `'unavailable'` | The authentication authority cannot currently be reached | - -**Convention:** every redirect path is supplied via `provideKitAuth`; the kit does not hard-code any routes. -`authState` and `redirects` are required. The app-specific hooks `onAuthorized`, `onUnauthenticated`, and -`onUnavailable` are optional. An authenticated user is allowed by default; unauthenticated and unavailable states -redirect by default. - -`'required'` is an authoritative signed-out result. It must never be converted into offline access. -`'unavailable'` means the authentication authority could not produce a result. Likewise, -`isUnavailableError` must classify transport failures only; HTTP 401/403 are explicit denials and must return -`false`. `onUnavailable` authorizes the route for local-replica use only—it does not create an HTTP or realtime -credential. - -`KitAuthAccessService` is the authoritative capability state for the rest of the application: - -| Access mode | Local replica / outbox | Authenticated HTTP / realtime / sync | -| ----------- | ---------------------- | ------------------------------------ | -| `none` | blocked | blocked | -| `local` | allowed | blocked | -| `remote` | allowed | allowed | - -Remote activation has two ordered phases. `activate()` installs the remotely verified identity without starting -transport. The guard then publishes `remote`, and only then calls `resume()` to start pull, outbox replay, and -realtime work. Returning plain `true` remains supported for applications that do not need phased activation. -When a protected guard starts a new asynchronous decision, any previously published `remote` capability is -immediately suspended to `none`; it is granted again only after the current lease completes successfully. -Once the authority returns `required` or `confirm`, an existing `local` capability is also suspended before any -anonymous-sign-in fallback runs. Only the `unavailable` path may retain or re-grant verified local access. - -**Setup** - -```typescript -// app.config.ts -import { provideKitAuth } from '@rdlabo/ionic-angular-kit'; - -export const appConfig: ApplicationConfig = { - providers: [ - provideKitAuth(() => { - const auth = inject(AuthService); - return { - authState: () => auth.state$, // Observable - redirects: { - whenAuthorized: '/home', // kitRequiredUnauthorizedGuard - whenConfirming: '/auth/confirm', // kitRequiredUnauthorizedGuard - whenNotConfirming: '/auth/signin', // kitRequireConfirmingGuard - whenUnauthorized: '/auth', // kitRequireAuthorizedGuard - }, - // onAuthorized / onUnauthenticated omitted → defaults (allow / redirect). - // Supply onAuthorized only when 'user' needs extra work. A phased result is preferred when - // activating the offline runtime: - // onAuthorized: async () => { - // const session = await auth.exchangeCredential(); - // return { - // activate: (lease) => offline.prepareRemoteSession( - // session.userId, session.scopeIds, session.subject, lease, - // ), - // resume: () => offline.resumeRemoteSession(), - // }; - // }, - // Supply onUnauthenticated only for a fallback such as anonymous sign-in: - // onUnauthenticated: async () => { await auth.signInAnonymously(); return true; }, - // Supply onUnavailable only for a previously verified local replica: - // onUnavailable: async (_state, _error, lease) => - // (await offline.activateOfflineSession(auth.currentSubject(), lease)) !== null, - // isUnavailableError: (error) => isOfflineFallbackError(error), - // Optionally recover automatically after the authority is reachable again: - // remoteRecovery: { - // availability: () => auth.authorityAvailable$, - // reauthenticate: async () => { - // const session = await auth.tryExchangeCredential(); - // return session - // ? { - // activate: (lease) => offline.prepareRemoteSession( - // session.userId, session.scopeIds, session.subject, lease, - // ), - // resume: () => offline.resumeRemoteSession(), - // } - // : false; - // }, - // }, - }; - }), - ], -}; -``` +## Entry points -### kitPresentAuthFailedAlert +| Import | Responsibility | +| ----------------------------------------- | ------------------------------------------------------------------------------ | +| `@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/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 | +| `@rdlabo/ionic-angular-kit/live-update` | Capawesome Live Update readiness provider | -The fleet's canonical "sign-in / token exchange failed" alert: an informative alert (header + optional server error as sub-header + detail message) with a single close button that reloads the app so the user restarts cleanly. Text is passed in (no hardcoded i18n); the caller signs the user out around it. A standalone helper (takes `AlertController`) since `location.reload()` is navigation policy the overlay controller does not hold. - -```typescript -import { kitPresentAuthFailedAlert } from '@rdlabo/ionic-angular-kit'; - -const logged = await auth.tokenLogin().catch(async (e) => { - await kitPresentAuthFailedAlert(alertCtrl, { - header: 'ログインできませんでした', - subHeader: e.error.error, - message: e.error.detail, - closeText: '閉じる', - }); - await auth.signOut(); - return undefined; -}); -``` - -**Guards** - -```typescript -// routes.ts -import { kitRequiredUnauthorizedGuard, kitRequireConfirmingGuard, kitRequireAuthorizedGuard } from '@rdlabo/ionic-angular-kit'; - -export const routes: Routes = [ - { - path: 'auth', - canActivate: [kitRequiredUnauthorizedGuard], - // Blocks 'user' → redirects whenAuthorized - // Blocks 'confirm' → redirects whenConfirming - // Allows 'required' and 'anonymous' - loadChildren: () => import('./auth/routes'), - }, - { - path: 'confirm', - canActivate: [kitRequireConfirmingGuard], - // Allows only 'confirm' - // 'anonymous' → redirects whenAuthorized - // 'required'/'user' → redirects whenNotConfirming - loadComponent: () => import('./confirm/confirm.page'), - }, - { - path: 'app', - canActivate: [kitRequireAuthorizedGuard], - // 'user' → calls onAuthorized → proceeds on true, redirects on UrlTree - // 'anonymous' → allowed (anonymous browsing) - // 'required'/'confirm' → calls onUnauthenticated → proceeds on true/UrlTree, redirects whenUnauthorized on false - loadChildren: () => import('./main/routes'), - }, -]; -``` - ---- - -### kitAuthInterceptor + provideKitHttp - -A fleet-canonical HTTP interceptor with: - -- Per-request auth header injection -- Configurable bypass (CDN, S3, external URLs) -- **Safe retry**: only idempotent methods (`GET`/`HEAD`/`OPTIONS`, or a request with an `Idempotency-Key`) are retried, and only on a transient status `[0, 408, 429, 502, 503, 504]`, up to 2 times with a short jittered backoff (honoring `Retry-After`). **Writes are never auto-retried** (no duplicate saves). -- **Offline fast-fail**: when the device is offline the interceptor stops retrying immediately and hands off to `offlineFallback` instead of waiting out the backoff. - -### Scoped local replica and outbox (`@rdlabo/ionic-angular-kit/offline`) - -The optional `offline` entry point provides a user/partition-scoped local replica, durable outbox, authenticated -session boundary, cursor-based delta pull, aggregate-ordered replay, optimistic updates, retry classification, and a -request-policy interceptor. Synchronized applications provide URL/DTO read policies, optional local-first mutation -policies, a replica puller, and a command executor through `provideOffline(...)`. External-source or HTTP read caches use -`mode: 'readCacheOnly'`; the kit then supplies the empty pull/executor boundary instead of making every product -declare dummy adapters. -Mutations may still call `OfflineSyncService.enqueue` explicitly. A product that must keep ordinary HTTP services -offline-unaware can instead register `mutationPolicies`; a matched `POST` / `PUT` / `PATCH` / `DELETE` is prepared -locally before transport and returns an optimistic response while the Outbox owns remote replay. -Web storage uses Ionic Storage and supports `readCacheOnly` only. `synchronized` fails fast on Web because the current repository has no cross-tab lock; iOS and Android use encrypted `@capacitor-community/sqlite`. Importing either the -primary entry point or `/offline` does not pull the optional native SQLite plugin into web-only applications. - -For cold-start offline route access, `OfflineCoordinatorService.activateOfflineSession()` restores only a manifest -that is bound to a non-null authentication-provider subject. Supplying a currently known subject also rejects a -different user on a shared device. It activates local replica writes and durable outbox enqueue, but remote pull and -command replay remain disabled until online authentication completes the ordered -`prepareRemoteSession(...)` → publish `remote` → `resumeRemoteSession()` transition. `activateSession(...)` is -available as a one-step API for callers that do not enforce shared access mode. -Explicit sign-out must first call `KitAuthAccessService.clear()` and then await `clearActiveSession()`. The first -step immediately invalidates every in-flight auth lease; the second serializes cleanup after local persistence -already in progress and removes the manifest and user replica. - -```ts -import { createOfflineAuthBridge, isOfflineFallbackError } from '@rdlabo/ionic-angular-kit/offline'; - -provideKitAuth(() => ({ - authState: () => auth.state$, - ...createOfflineAuthBridge({ - exchange: async (ctx) => { - const remote = await auth.exchangeCredential(ctx); - const authSubject = auth.currentSubject(); - return remote && authSubject ? { ...remote, authSubject } : false; - }, - currentAuthSubject: () => auth.currentSubject(), - isUnavailableError: isOfflineFallbackError, - availability: () => auth.authorityAvailable$, - }), - redirects, -})); -``` - -`createOfflineAuthBridge` owns `exchange` → `prepareRemoteSession` → kit `grantRemote` → `resumeRemoteSession` -ordering via `KitRemoteAccessRecovery`. Product code keeps consent, error UI, and credential mapping inside -`exchange(context)` (`phase: 'authorize' | 'recover'`, optional route state, lease). Return `null`/`false` to decline -without throwing; thrown errors are never swallowed and remain available to the guard's unavailable/denial -classification. The returned identity requires a positive `userId`, non-empty string `scopeIds`, and a non-empty -`authSubject`. A scope may be a numeric database id serialized as a string or a domain UUID such as an organization -or venue id. The bridge compares the subject before -session activation, immediately before the coordinator commits through a composite lease, and around remote resume. -Optional `isIdentityCurrent` can add provider object-identity checks. `onRemoteResumed` receives the identity, phase, -route state, and post-grant lease for safe redirects or other product side effects. Default recovery availability -follows `offline.networkState !== 'offline'`. - -Register `offlineInterceptor` before `kitAuthInterceptor`. In local mode the auth interceptor synthesizes a -transport-unavailable error before generating credentials or touching the network; the outer offline interceptor -may then serve a matched `GET` from the replica. In `none` mode the same request is rejected and no local data is -returned. - -The offline interceptor observes real transport responses to update API reachability. For matched `GET` -requests only, a transport failure with `status=0` may return a local replica response tagged -`X-Offline-Response: local`. A read policy's optional `projectResponse(response, source)` hook receives both remote -and local results, so the remote branch can persist first and both branches can return a body from one composer. -Matched mutation policies return `X-Offline-Response: optimistic` without starting transport. Unmatched writes go to -transport unchanged. Outbox replay requests bypass policy with `OFFLINE_BYPASS` while still using the same auth -interceptor and transport observation. Mutation policy matchers must be mutually exclusive: zero matches use normal -transport, exactly one match runs its `prepare()`, and multiple matches throw `OfflineMutationPolicyConflictError` -before either `prepare()` or transport starts. This fail-fast boundary prevents provider registration order from -silently selecting the wrong replica/outbox mutation. - -```ts -@Injectable() -class ItemReadPolicy implements OfflineRequestPolicy { - resolve(request: HttpRequest): OfflineRequestPlan | null { - if (request.method !== 'GET' || !request.url.endsWith('/items')) return null; - return { - kind: 'read', - readLocal: () => this.items.readResponse(), - projectResponse: async (response, source) => { - if (source === 'remote') await this.items.persist(response.body); - return response.clone({ body: await this.items.compose() }); - }, - }; - } -} - -@Injectable() -class ItemMutationPolicy implements OfflineMutationRequestPolicy { - resolve(request: HttpRequest): OfflineMutationRequestPlan | null { - if (request.method !== 'POST' || !request.url.endsWith('/items')) return null; - return { - kind: 'mutation', - prepare: async () => { - const optimistic = await this.items.enqueueCreate(request.body); - return new HttpResponse({ status: 202, body: optimistic }); - }, - }; - } -} - -provideOffline({ - // repository/schema/executor/puller options omitted - requestPolicies: [ItemReadPolicy], - mutationPolicies: [ItemMutationPolicy], -}); -``` - -Products may make durable mutation saving device-configurable without disabling replica reads. Supply only the -durable preference adapter; Kit owns the concurrency-sensitive transition. It starts admission closed while loading, -gates every `enqueue` / `enqueuePrepared` / `enqueuePreparedBatch`, and routes matched HTTP writes to normal transport -while disabled. Disabling closes admission synchronously, waits for already accepted commits, flushes pending -commands, verifies an empty Outbox, and then persists `false`. A failed transition restores the last successfully -persisted state. Replacement and discard APIs remain available because they resolve existing commands rather than -create additional pending work. - -```ts -@Injectable({ providedIn: 'root' }) -class ProductMutationPersistenceAdapter implements OfflineMutationPersistenceAdapter { - readonly #settings = inject(ProductSettingsService); - - loadEnabled(): Promise { - return this.#settings.get('offlineMutationPersistence'); - } - - saveEnabled(enabled: boolean): Promise { - return this.#settings.set('offlineMutationPersistence', enabled); - } -} - -provideOffline({ - // repository/schema/executor/puller options omitted - mutationPersistence: { - adapter: ProductMutationPersistenceAdapter, - defaultEnabled: true, - }, -}); - -const offline = inject(OfflineCoordinatorService); -await offline.mutationPersistence.setEnabled(false); -``` - -The product owns settings UI, labels, confirmation copy, and the storage key. Omitting `mutationPersistence` keeps -the historical always-enabled behavior. `readCacheOnly` applications do not expose this setting. - -The native offline runtime uses `@capacitor-community/sqlite` on iOS and Android. Install the plugin in the app and -sync native projects: - -```bash -# Capacitor 8 -npm install @capacitor-community/sqlite@^8.1.0 -npx cap sync -``` - -Use the plugin major matching the application's Capacitor major (`^6` for Capacitor 6, `^7` for Capacitor 7, -`^8.1.0` for Capacitor 8). - -Add the required `CapacitorSQLite` plugin block to `capacitor.config.ts`. Encryption must be enabled — the kit opens -databases in encrypted mode and relies on the plugin's built-in secure secret storage (`isSecretStored` / -`setEncryptionSecret` in the device keychain / Android keystore): - -```ts -// capacitor.config.ts -plugins: { - CapacitorSQLite: { - iosDatabaseLocation: 'Library/CapacitorDatabase', - iosIsEncryption: true, - iosKeychainPrefix: '', - androidIsEncryption: true, - }, -}, -``` - -Follow the [@capacitor-community/sqlite installation guide](https://github.com/capacitor-community/sqlite#installation) -for platform-specific steps and SQLCipher export-compliance notes. - -Android must not back up or transfer the encrypted database independently from its keystore secret. Set -`android:allowBackup="false"`, `android:fullBackupContent="false"`, and -`android:dataExtractionRules="@xml/data_extraction_rules"` on ``. The referenced Android 12+ rules must -exclude at least the `database`, `sharedpref`, `root`, and `external` domains from both `cloud-backup` and -`device-transfer`, as shown in the plugin installation guide. - -Create the community plugin connection in the application, then pass it with a stable `databaseName` and a -`createEncryptionKey` generator to `provideOffline`. Keeping the runtime object application-supplied prevents the optional -native plugin from entering web-only `/offline` bundles. The kit invokes the generator only -when the plugin has no secret yet, then stores the result in the plugin's Keychain / Android keystore. Later opens use -that stored secret without invoking the generator. Never hard-code or derive the key from a user identifier, device -identifier, or access token; generate a cryptographically random value for the first installation. - -```ts -import { CapacitorSQLite, SQLiteConnection } from '@capacitor-community/sqlite'; -import { createRandomOfflineEncryptionKey, provideOffline } from '@rdlabo/ionic-angular-kit/offline'; - -provideOffline({ - databaseName: 'product-offline', - sqliteConnection: new SQLiteConnection(CapacitorSQLite), - createEncryptionKey: createRandomOfflineEncryptionKey, - // ...product policies, puller, and executor -}); -``` - -An explicit user-requested local reset must run before Angular and Kit initialize SQLite. Use the cold-start helpers -instead of reproducing the community plugin's encrypted connection lifecycle in every application. Kit never invokes -reset automatically after a storage error. The product must show destructive confirmation first and owns the marker key. -Only databases that use Kit's same encrypted secret-mode, connection-version-1, read/write lifecycle belong in -`kitCompatibleDatabaseNames`; delete differently configured databases and files in `additionalCleanup`. The marker is -removed only after every database and product cleanup succeeds, so a partial failure is retried on the next cold launch. - -```ts -// Settings action -await requestOfflineLocalReset({ - markerStore: Preferences, - markerKey: 'product:offline:reset', -}); - -// main.ts, before bootstrapApplication(...). Report reset failure but always continue startup. -await recoverOfflineLocalReset({ - markerStore: Preferences, - markerKey: 'product:offline:reset', - sqliteConnection, - kitCompatibleDatabaseNames: ['product-offline', 'product-offline-media'], - additionalCleanup: removeOfflineMediaFiles, -}).catch((error: unknown) => { - console.error('Offline local reset failed; it will retry on the next launch.', error); -}); - -await bootstrapApplication(AppComponent, appConfig); -``` - -Replica identity follows the product database: - -| Identity declaration | SQLite primary key | Row / executor identity | -| ---------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------- | -| `generatedId('integer' \| 'text')` | scope columns + immutable `local_id`; scoped nullable unique `server_id` | `{ kind: 'generated', localId, remoteId }` | -| `naturalKey(['a', 'b'])` | scope columns plus mapped `a`, `b` columns | `{ kind: 'natural', naturalKey: { a, b } }` | -| `identity: localOnly()` | scope columns + immutable `local_id` | `{ kind: 'local', localId }` | - -Natural-key tables contain neither `local_id` nor `server_id`; their scoped key columns are the SQLite composite -`PRIMARY KEY`. Generated remote ids may be positive safe integers or non-empty text ids such as server-generated -UUIDs. A successful create adds `remoteId` without replacing `localId`. Local-only rows cannot enter the Outbox. -All TEXT identity components are opaque and compared byte-for-byte with SQLite's `BINARY` collation. The server -database must use the same binary equality, or the Hono/API converter must return one canonical form before the -value reaches `naturalKey(...)` or `generatedId('text')`. Do not pass case- or accent-insensitive primary keys -through unchanged: values that the server considers equal must first be canonicalized to identical TEXT. - -`OfflineScope.userId` accepts `number | string`. A type-tagged TEXT codec keeps numeric `7` and text `"7"` in -different metadata, manifest, cursor, Outbox, and entity boundaries. - -The write lifecycle is: persist the immutable Outbox command → rematerialize the aggregate from confirmed -base/localOnly values plus remaining FIFO intents → render that optimistic projection → replay in the background → -keep the transported command until pull acknowledges `commandId` → rematerialize again from the new confirmed -baseline. The server remains authoritative; SQLite is the durable local working database, not an HTTP response cache. - -Synchronized mode requires `aggregateIntentProjector`. Kit calls it inside `OfflineReplicaMutationCoordinator` after -enqueue, batch enqueue, replacement, discard, transport success, and pull acknowledgement. The adapter must derive -only from authoritative confirmed values plus the complete remaining intent chain. Do not call it from product code. -Commands persist an immutable payload plus Kit-owned metadata (`baseRevision`, state, footprint keys). Kit updates -only `baseRevision` when a newer server revision is known, and sets it to `null` after a generated remote identity is -released; the product executor always receives the original payload. Optimistic snapshots and companion before/after -images are not durable truth. - -When an API response is assembled from a base replica row plus product-owned local-only view rows, use -`enqueuePrepared()`. Its callback runs inside the shared replica mutation lane, so every read used to derive the -payload happens after earlier enqueue/ACK/pull applies. Kit then rematerializes the aggregate and commits the -Outbox command with that projection in one `transactReplica()` call. Preparation failure, invalid footprint keys, -non-JSON values, or an attempt to mutate commands/cursors leaves durable state unchanged. - -```ts -await offlineSync.enqueuePrepared(async (repository) => { - const view = await readProductView(repository, scope, row); - return { - request: { - scopeId: scope.scopeId, - aggregateType: 'items', - identity: { kind: 'generated', localId: row.identity.localId }, - operation: 'items.rename', - payload: { title }, - localOnlyFootprint: [viewKey(scope, view)], - }, - }; -}); -``` - -Product-specific pullers that materialize their own aggregate tables must keep HTTP transport outside -`OfflineReplicaMutationCoordinator.run()` and wrap only the response apply read/derive/write section. This shares the -same short local critical section as enqueue, ACK, discard, and the generic pull service without holding a lock during -network latency. - -For a DB row whose deletion is represented by absence, enqueue the same full row values with -`replicaMutation: 'delete'`. The runtime stores a library-owned durable tombstone: normal `getReplicaRow()` and -`getReplicaRows()` reads stop returning the row immediately, while synchronization retains its immutable generated -or natural identity, confirmed baseline, and Outbox command. A successful tombstone acknowledgement removes the row -physically; retry, authorization failure, and conflict keep it hidden; discarding restores the latest confirmed -baseline unless the server has also deleted it. - -Custom `OfflineRepository` implementations must implement `getReplicaRowIncludingPendingDelete()` before they can -accept `replicaMutation: 'delete'`; the runtime rejects that enqueue otherwise. This is intentional: ordinary product -reads stay hidden, while the library's remote-identity lookup and synchronization path must still resolve the durable -tombstone for replay, lost-ACK reconciliation, conflict handling, and discard. - -```ts -await offlineSync.enqueue({ - scopeId, - aggregateType: 'favorites', - identity: { - kind: 'natural', - naturalKey: { favFrom: favorite.values.favFrom, favTo: favorite.values.favTo }, - }, - operation: 'favorite.delete', - payload: { favTo: favorite.values.favTo }, - baseRevision: favorite.serverRevision, - replicaMutation: 'delete', -}); -``` - -The runtime retains pending, rejected, and conflicted commands until synchronization or an explicit user discard; -it never evicts an unconfirmed mutation because of age or storage pressure. To keep a device that remains offline for -months from exhausting SQLite, enqueue applies backpressure at 1,000 commands or 10 MiB per user by default. Products -may lower these limits with `outboxLimits: { maxCommandsPerUser, maxBytesPerUser }`. When a limit is reached, the -existing replica and Outbox remain unchanged and enqueue rejects with `OfflineOutboxCapacityError`, so the UI can ask -the user to reconnect or resolve/discard an attention item. - -Use `generatedId('integer')` for a positive safe-integer `AUTO_INCREMENT` key and `generatedId('text')` for a -server-generated text key. Use `naturalKey([...])` for an existing single or composite natural primary key. -Natural-key components must be required mapped `text()` or `integer()` columns; nullable, ignored, JSON, empty -declarations, duplicates, and mixed generated/natural declarations reject. Text identities reject NUL, malformed -Unicode, and values larger than 1,024 UTF-8 bytes. - -When the application already knows the numeric server id but the first replica pull has not materialized the row, -pass that identity explicitly while adopting the entity. This is required for updates and especially deletes: an -omitted id would otherwise make the executor interpret the row as a not-yet-created local entity. - -```ts -await offlineSync.enqueue({ - scopeId, - aggregateType: 'items', - identity: { kind: 'generated', localId, remoteId: existingApiItem.id }, - operation: 'items.delete', - payload: { method: 'DELETE' }, -}); -``` +Secondary entry points isolate optional native and SDK dependencies from the core bundle. -The mapping is immutable and unique inside its effective replica scope. Reassigning one generated `localId` to -another `remoteId`, or assigning the same remote identity to another `localId`, rejects before persistence. A -natural-key change is a delete of the old key plus an insert of the new key, never an update of the primary key. -Web storage enforces the same rule transactionally. SQLite uses a scoped partial unique index for generated -`server_id`; natural identity is enforced by the table's scoped composite primary key. If an adopted row has -no confirmed baseline and -its final command is discarded, the local row is removed; the next pull may materialize the authoritative server -row again. A row with a confirmed baseline rolls back to that baseline instead. +## Configure only what you use -Each synchronization cycle pulls authoritative server deltas before replaying the outbox. Every page carries the -replica schema version/hash and advances a durable user/partition cursor in the same transaction as its rows. A schema -mismatch, malformed row, or non-advancing cursor rejects synchronization without advancing that cursor. If a remote -revision changed while a local command is pending, the optimistic row remains visible and both row and command move -to `conflict`; the new server value is retained as the confirmed baseline. A remote tombstone follows the same -retention rule: without pending commands it removes the row, while a pending row and its Outbox remain under -`remote_deleted` conflict so the user can resolve or discard them. +Most features expose a provider whose callbacks keep routes, copy, credentials, and application side effects outside the kit. Start with [Storage and Overlays](./docs/storage-overlays.md), then add authentication, offline, or native features as your app needs them. -Hono payloads may call a generated database key `serverId`, while the database-independent runtime calls it -`remoteId`. Product pullers must use `normalizeOfflineReplicaPullPage(response)` at the HTTP boundary; do not -reimplement `serverId` stripping/injection per application. Natural-key changes pass through unchanged. +## Documentation -The command adapter must send `commandId` as the server-side idempotency key. The server persists that key with the -mutation and returns all keys represented by a delta row as `acknowledgedCommandIds`. This correlation is required: -if the server commits a create/update/delete but its HTTP acknowledgement is lost, the next pull reconciles the -server result into the same generated or natural identity, removes the acknowledged Outbox prefix, and rebases later -commands without creating a second local identity. - -Versioned replica schemas lock web and native storage. Web metadata stores -`replicaSchemaVersion` and `replicaSchemaHash`; native stores the same pair in -`offline_replica_schema_metadata`. Bump `version` for every intentional shape change and supply a -complete one-step migration chain. Native runs each step's SQL `statements`; web runs -`migrateWebRow`, which receives only `{ sourceKey, values, confirmedValues }` and may return the same -shape or `null` to delete a row. Identity and sync metadata (`identity`, scope, revision, -`syncState`) stay outside the callback. The bundle fingerprint hashes `version`, entity layouts, and -migration `fromVersion`/`statements` — never function bodies. - -Changing a natural-key component list or its order changes the schema fingerprint and SQLite primary key. For a -released schema, bump `version`; native migration SQL must rebuild the table with the new composite primary key, and -`migrateWebRow` must preserve every natural-key value. Keep the transaction journal and schema-migration recovery -records until the replacement rows, index, and metadata commit together. This natural-key API is currently -unreleased, so adopting it before the first release needs no compatibility helper or legacy-row migration. - -```typescript -import { - defineOfflineReplicaSchema, - defineReplicaEntity, - generatedId, - integer, - localOnly, - naturalKey, - provideOffline, - text, -} from '@rdlabo/ionic-angular-kit/offline'; - -// This is the Hono package's existing `typeof items.$inferSelect` export. -import type { Items as ItemSelect } from '@product/hono/db/schema'; - -const itemEntityV2 = defineReplicaEntity()({ - table: 'items', - sourceKey: 'items', - scope: 'partition', - fields: { - id: generatedId('integer'), - title: text(), - subtitle: text(), - }, -}); - -// Mirrors a product DB TableScheme whose primary key is (favFrom, favTo). -type FavoriteSelect = { - favFrom: number; - favTo: number; - label: string; -}; - -const favoriteEntity = defineReplicaEntity()({ - table: 'favorites', - sourceKey: 'favorites', - scope: 'user', - identity: naturalKey(['favFrom', 'favTo']), - fields: { - favFrom: integer(), - favTo: integer(), - label: text(), - }, -}); - -const replicaSchema = defineOfflineReplicaSchema({ - version: 2, - entities: [itemEntityV2, favoriteEntity], - migrations: [ - { - fromVersion: 1, - statements: ['ALTER TABLE items ADD COLUMN subtitle TEXT NOT NULL DEFAULT ""'], - migrateWebRow: (row) => ({ - sourceKey: row.sourceKey, - values: { ...row.values, subtitle: '' }, - confirmedValues: row.confirmedValues === null ? null : { ...row.confirmedValues, subtitle: '' }, - }), - }, - ], -}); - -provideOffline({ - replicaSchema, - replicaPuller: ProductReplicaPuller, - commandExecutor: ProductCommandExecutor, - aggregateIntentProjector: ProductAggregateIntentProjector, - // ...request policies, databaseName, createEncryptionKey -}); - -// Stripe or another external system is the source of truth; no Outbox exists. -provideOffline({ - mode: 'readCacheOnly', - replicaSchema: readCacheSchema, - requestPolicies: [ReadCacheRequestPolicy], - databaseName: 'product-read-cache', - createEncryptionKey, -}); -``` - -Offline replica schema consumers must compile with TypeScript `strictNullChecks: true`. This is part of the standard, -not a compatibility option: without strict null checking, TypeScript cannot distinguish a nullable Hono property -from a required one and the schema lock cannot prove the SQLite mapping. - -The schema definition must import the Hono package's exported `$inferSelect` type and map every key exactly once as -a SQLite column, `generatedId('integer' | 'text')`, or `ignored(reason)`. A remotely replicated entity declares -either one generated-id field or one ordered `naturalKey([...])`; a local-only projection explicitly declares -`identity: localOnly()`. -Nullable Hono columns require `nullable(...)`; non-null columns reject it. Therefore adding, -removing, or changing nullability of a Drizzle column breaks the app build until its replica mapping is updated. -At runtime, `values` contains only the mapped column projection. Identity is a discriminated union: generated rows -carry `localId`/`remoteId`, natural rows carry only `naturalKey`, and local-only rows carry only `localId`. Ignored -server fields are never persisted. - -- **Status classification**: `0`→`onNetworkError` (connected only), `429`→`onRateLimited`, `502/503/504`→`onServerBusy`, `400/422/500`+message→`onServerError`, `401`→`onUnauthorized`, `403`→`onForbidden`. Other statuses (e.g. `404`) are left to the caller. -- **Universal 60s timeout** — every request fails with a synthetic (retryable) `408` if it hangs for 60s. Deliberately generous (catches a dead server without cutting off a large upload / AI generation; `timeout({ each })` resets per emission, so streaming is unaffected). Not configurable — one fleet-wide behavior. -- **Optional `treatAsError(response)`** — reject a 2xx (e.g. `204`/`206`) as an error when a backend uses it to signal a condition. The one genuinely app-specific bit (some apps receive a normal `204`), kept optional so class interceptors with a 2xx-as-error convention can migrate to `provideKitHttp`. - -**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. - -**Only `getAuthHeaders` is required.** Every other hook is optional and defaults to a safe no-op (`buildExtraHeaders` → `{}`, `bypass` → `false`, `offlineFallback` → `null`), so a config specifies only the behavior that actually differs from the baseline. - -**Setup** - -```typescript -// app.config.ts -import { provideHttpClient, withInterceptors } from '@angular/common/http'; -import { isIdentityAuthFailure, kitAuthInterceptor, provideKitHttp, KitReloadAlertController } from '@rdlabo/ionic-angular-kit'; - -export const appConfig: ApplicationConfig = { - providers: [ - provideHttpClient(withInterceptors([kitAuthInterceptor])), - provideKitHttp(() => { - const auth = inject(AuthService); - const reload = inject(KitReloadAlertController); - return { - // Required for the new offline auth boundary. Kept opt-in so existing applications retain - // their current interceptor behavior until they wire KitAuthAccessService. - enforceAuthAccessMode: true, - // Only an explicitly tagged global identity failure may revoke shared access. - // Omission keeps the legacy 401/403 revoke policy for existing applications. - isAuthAccessDenial: (_req, error) => isIdentityAuthFailure(error), - getAuthHeaders: async (req) => ({ - Authorization: `Bearer ${await auth.getToken()}`, - }), - // Status-independent: handles both new identity 401 and tagged historical identity 403. - onAuthAccessDenial: () => auth.signOut(), - onUnauthorized: (_req, error) => { - // Feature UX for reauthentication/credential failures may inspect the error here. - }, - // Fleet-canonical "network error → offer reload" (see KitReloadAlertController). - onNetworkError: (status) => - reload.present({ - header: 'ネットワークエラー', - message: `通信できませんでした。リフレッシュしますか?(${status})`, - okText: 'リフレッシュ', - }), - // Auto-dismiss the stale alert once connectivity is back. - onResponse: () => void reload.dismiss(), - // buildExtraHeaders / bypass / offlineFallback / onForbidden / onServerError omitted → kit defaults. - }; - }), - ], -}; -``` - -For an offline replica, use -`withInterceptors([offlineInterceptor, kitAuthInterceptor])` in that order. The credential-exchange -request that must run before `remote` is granted sets `KIT_AUTH_BOOTSTRAP_REQUEST` in its -`HttpContext`, together with the offline entry point's `OFFLINE_BYPASS`: - -```ts -const context = new HttpContext().set(KIT_AUTH_BOOTSTRAP_REQUEST, true).set(OFFLINE_BYPASS, true); -http.post('/login', body, { context }); -``` - -It still receives authentication headers and uses the normal denial/error pipeline; only the -pre-existing `remote` requirement is deferred. The auth interceptor never consults its configured -offline fallback for this request, while `OFFLINE_BYPASS` prevents an outer offline interceptor -from replacing a transport failure with local data. Do not use the broader HTTP `bypass` hook, -because that also skips authentication headers and error handling, and do not globally relax -`enforceAuthAccessMode`. - -**Error dispatch** (after retries, in `catchError`): - -1. With `enforceAuthAccessMode`, `401` / `403` never consults `offlineFallback`. The matching hook is - notified and the error is rejected. Shared access is revoked only when `isAuthAccessDenial` - returns `true`; omitting it preserves the legacy behavior that revokes on both 401 and 403. -2. Otherwise, `offlineFallback` non-null → return fallback observable (no further hooks called) -3. `401` → `onUnauthorized` · `403` → `onForbidden` -4. `0` (connected) → `onNetworkError` · `429` → `onRateLimited(retryAfter?)` · `502/503/504` → `onServerBusy(status, retryAfter?)` -5. `400/422/500` with `error.message` → `onServerError` -6. anything else (`404`, …) → not handled here; the caller decides - -Plus: a `getAuthHeaders` rejection → `onAuthError(request, error)` (the request is never sent). - -The shared `401` body carries `authFailureScope`: - -- `identity`: the Firebase/global identity cannot be established; global session and replica/outbox - invalidation is allowed. -- `reauthentication`: the identity remains valid, but a recent sign-in/step-up is required. -- `credential`: only a feature-owned delegated credential is invalid. - -`getAuthFailureScope(error)` reads this explicit field and returns `null` for untagged legacy -responses. It also accepts an explicitly tagged historical `403` identity failure for products -whose installed clients still require that status; new APIs use `401`. -`isIdentityAuthFailure(error)` is therefore intentionally strict. Put destructive global cleanup in -`onAuthAccessDenial`; it runs for a classified identity failure independently of whether a compatible -server returned 401 or 403. `onUnauthorized` receives the complete `HttpErrorResponse` for status-specific UX. -An untagged `403` remains a resource/scope/business permission failure and never implies global -identity loss. Existing callbacks that accept only the request remain source-compatible, and applications -that omit `isAuthAccessDenial` retain the historical 401/403 revocation behavior. - -Deploy the API contract before enabling the strict client classifier: - -| Combination | Result | -| ---------------------------- | ----------------------------------------------------------------------------------------------------------- | -| Tagged API + legacy client | The extra body fields are ignored and the client's existing status behavior is preserved. | -| Untagged API + strict client | Global identity loss cannot be identified safely, so the client retains local identity state. | -| Tagged API + strict client | Only `identity` with `AUTH_IDENTITY_INVALID` revokes global access; narrower failures remain feature-owned. | - -For products whose installed clients historically require auth failure as `403`, deploy an explicitly -tagged legacy `403` identity response first. The strict classifier accepts it only with the matching -`statusCode`, scope, and `AUTH_IDENTITY_INVALID` code. Do not infer identity from an untagged legacy -401/403: that would collapse `credential` and `reauthentication` back into destructive global logout. - -**Note (0.0.9):** `onNetworkError` is now narrowed to genuine network failures (status `0`); `502/503/429` moved to `onServerBusy`/`onRateLimited`. Existing configs stay valid — they just fire less often — so adopt the new hooks only if you want to distinguish server-busy / rate-limit from a connection loss. - -### KitReloadAlertController - -The fleet's canonical "network error → offer to reload" alert, as a stateful controller that unifies the good-UX variant that had drifted across apps: - -- **De-dup** — never stacks; a second `present()` while one is showing is a no-op. -- **Backdrop lock** — `backdropDismiss: false`, so a critical error isn't dismissed by an accidental backdrop tap. -- **Auto-dismiss on reconnect** — `dismiss()` (called from a later successful response) clears a now-stale error alert. -- **Reload on confirm** — the confirm button calls `location.reload()`; cancel uses the configured `labels.cancel`. - -All text is passed in, so the kit stays free of hardcoded i18n. Wire `present` from a network-class error and `dismiss` from a success (interceptor `onResponse`, or a class interceptor's success path). - -```typescript -import { KitReloadAlertController } from '@rdlabo/ionic-angular-kit'; - -const reload = inject(KitReloadAlertController); -await reload.present({ - header: 'ネットワークエラー', - message: `通信できませんでした。リフレッシュしますか?(${status})`, - okText: 'リフレッシュ', -}); -// later, on a successful response: -await reload.dismiss(); -``` - ---- - -### KitAuthInputDirective (`kitAuthInput`) - -Sign-in / sign-up conveniences on `ion-input`: - -- `'email'` — remember + prefill the last well-formed address (and forget when the user clears it) -- `'email-remember'` — remember on change only (no prefill — use on sign-up) -- `'autofill'` — iOS autofill propagation only (password fields) - -```html - -``` - -### kitClearStoragePreservingKeys - -Fleet apps typically `storage.clear()` on sign-out. Pass keys that must survive (e.g. the last sign-in email): - -```typescript -import { KIT_LAST_AUTH_EMAIL_KEY, KIT_THEME_STORAGE_KEY, kitClearStoragePreservingKeys } from '@rdlabo/ionic-angular-kit'; - -await kitSignOut(auth, { - success: () => kitClearStoragePreservingKeys(this.storage, [KIT_LAST_AUTH_EMAIL_KEY, KIT_THEME_STORAGE_KEY]), -}); -``` - -It snapshots the listed keys, clears the store, then writes non-null values back. - ---- - -### kitKeyboardInit - -A plain function (no DI — reads the platform from `Capacitor`, so nothing to inject) that registers native keyboard show/hide listeners to reposition an element when the soft keyboard appears — useful for a footer input bar that must stay above the keyboard. A no-op on web (returns `[]`); SSR-safe (the global `document`/`window` are only read inside native callbacks). Three adjustment strategies: - -- `transform` — `translateY(-keyboardHeight + safeAreaBottom)` (smooth iOS animation; typical for `ion-footer`) -- `offset` — sets the `--offset-bottom` custom property -- `keyboard-offset` — sets the `--padding-bottom` custom property - -```typescript -import { kitKeyboardInit } from '@rdlabo/ionic-angular-kit'; - -export class ComposePage { - readonly #footer = viewChild.required('footer'); - #handles: PluginListenerHandle[] = []; - - async ngAfterViewInit() { - this.#handles = await kitKeyboardInit(this.#footer(), 'transform'); - } - ngOnDestroy() { - this.#handles.forEach((h) => h.remove()); // caller owns the handles - } -} -``` - ---- - -### KitThemeController + provideKitTheme - -Light/dark theme controller that unifies the theme logic that had drifted across the fleet: it persists the user's choice, follows the OS `prefers-color-scheme` until the user overrides it, toggles the configured palette classes, and syncs the native Android status bar. It also fixes a latent leak in one variant where the system-theme listener stayed registered after a manual toggle — `changeTheme()` always detaches the listener first, so a later OS change can't silently flip an app the user pinned. - -Per-app CSS differences are absorbed by config: `darkClasses` are toggled on when dark, `lightClasses` on when light. The kit ships no class names of its own. Subscribe to `themeSubject` (a `BehaviorSubject`) to reflect the current mode in the UI. It is a controller (not a plain function) because the subject and OS-listener are shared state across the app lifetime. - -```typescript -// app.config.ts -provideKitTheme({ - storageKey: StorageKeyEnum.theme, - darkClasses: ['ion-palette-dark', 'a2ui-dark'], - lightClasses: ['a2ui-light'], -}); - -// app.component.ts — apply on boot -inject(KitThemeController).setDefaultThemeMode(); - -// settings page — bind a toggle -const theme = inject(KitThemeController); -theme.themeSubject.subscribe((mode) => this.isDark.set(mode === 'dark')); -theme.changeTheme(true); // force dark, stop following the OS -``` - ---- - -### kitRequestReview - -A plain function (no DI — `@capacitor/preferences`, `@capacitor-community/in-app-review` and `Capacitor` are all static) that requests the native in-app review dialog, throttled so the user is prompted at most once per window. A no-op on web. The wait/throttle/record sequence was previously copy-pasted verbatim across the fleet; centralizing it means a single place to tune the prompt cadence. The storage key and throttle window are passed as arguments, so the kit ships no config of its own. - -```typescript -import { kitRequestReview } from '@rdlabo/ionic-angular-kit'; - -await kitRequestReview({ storageKey: StorageEnum.lastRequestRate, throttleMonths: 3 }); -``` - ---- - -### Utilities - -Framework-agnostic helpers (no DI required unless noted): - -```typescript -import { kitImpact, arrayConcatById, objectEqual, disableHandler } from '@rdlabo/ionic-angular-kit'; - -// Native light haptic (no-op on web). -await kitImpact(); - -// Merge a paginated page into an existing list by numeric id, sorted; new items win on duplicates. -// Optional 5th arg `secondaryKey` drops old items sharing that secondary field with any new item. -const merged = arrayConcatById(loaded, nextPage, 'id', 'DESC', 'parentId'); - -// Order-independent deep equality (sorted-entries JSON) for cheap "did this state change?" checks. -if (!objectEqual(prev, next)) { /* changed */ } - -// Disable the clicked button while an async op runs, re-enabling it after (even on error). -async onSubmit(event: Event) { - await disableHandler(event, this.save()); -} -``` - -Ionic-event / lifecycle helpers: - -```typescript -import { kitChangeEventDisabled, kitCreateDidEnter } from '@rdlabo/ionic-angular-kit'; - -// Toggle a signal-held ion-infinite-scroll / ion-refresher's `disabled` (no-op when empty). -kitChangeEventDisabled(infiniteScrollSignal, true); - -// Observe an Ionic page's "is entered" state from its lifecycle DOM events (true on didEnter). -readonly isEntered = toSignal(kitCreateDidEnter(inject(ElementRef)), { initialValue: false }); -``` - ---- - -### kitPresentLanguageActionSheet - -A plain function (the `ActionSheetController` is passed in — nothing injected) that presents a language picker and, on a new selection, reloads the app at that locale's entry point. Unifies the language-switch flow duplicated across apps: it stashes the current path in `sessionStorage` (to restore after reload), records the chosen locale in `localStorage`, and calls `window.location.replace()` with the app-provided URL. Being a navigation helper, it stays standalone rather than part of a controller. All text, the locale list, and the per-locale URL mapping are injected, so the kit stays free of i18n strings. - -```typescript -import { kitPresentLanguageActionSheet } from '@rdlabo/ionic-angular-kit'; - -await kitPresentLanguageActionSheet(inject(ActionSheetController), { - header: $localize`言語設定`, - locales: [ - { text: 'English', data: 'en-US' }, - { text: '日本語', data: 'ja' }, - ], - cancelText: $localize`キャンセル`, - currentLocale: normalizedLocale, - currentPath: this.#router.url, - pathnameStorageKey: StorageKeyEnum.pathnameBeforeRedirect, - buildRedirectUrl: (locale) => location.origin + (localePath[locale.toLowerCase()] ?? '/index.html'), - enabled: environment.production, -}); -``` - ---- - -### Printer (label image and PDF plumbing) - -Pure functions (no DI) that extract the i18n-free core of the fleet's label printing, so a device-quirk, layout, or PDF fix lands in every app at once. The UI orchestration — paper-selection alerts, loading overlays, storage, printer transport, and app-specific copies policy — stays in each app. - -- `kitDomToPng(element, { rotate?, scale? })` — render a DOM element to a base64 PNG with the fleet's device fixes (iOS +2px to avoid bottom clipping, none on Android to avoid a black line; retries up to 10×). The caller presents its own loading UI. -- `kitRotationImage(base64)` — rotate a base64 image 90° via canvas. -- `kitBuildBrotherPrintSettings({ modelName, printBase64, label, numberOfCopies, halftoneThreshold })` — assemble the canonical `BRLMPrintOptions` (fit-page, centered, best quality, threshold halftone, standard margins, tape size parsed from the label's `WH` code). Merge `{ port, channelInfo }` from the selected channel before calling `BrotherPrint.printImage()`. -- `kitCalculatePrintLayout({ paper, labelWidthPx, labelHeightPx, copies, measure?, marginMm? })` — calculate row-major positions across as many pages as required. The default outer margin is 5mm. -- `kitBuildLabelPdf({ imageData, ...layoutOptions })` — embed the PNG artwork at the calculated positions and return PDF bytes. It deliberately does not open a browser tab or call a native printer. -- `kitPrintPaperSizes` — A4/B5 presets. Callers may pass any other `KitPrintPaper` dimensions without changing the kit. - -```typescript -import { kitBuildBrotherPrintSettings, kitBuildLabelPdf, kitDomToPng, kitPrintPaperSizes } from '@rdlabo/ionic-angular-kit/printer'; - -const png = await kitDomToPng(this.preview().nativeElement, { rotate: true }); -const settings = kitBuildBrotherPrintSettings({ - modelName, - printBase64: png, - label, - numberOfCopies: printOptions.printNum, - halftoneThreshold: printOptions.halftoneThreshold, -}); -await BrotherPrint.printImage({ ...settings, port: channel.port, channelInfo: channel.channelInfo }); - -const pdfBytes = await kitBuildLabelPdf({ - imageData: png, - paper: kitPrintPaperSizes.a4, - labelWidthPx: 200, - labelHeightPx: 100, - copies: 6, - marginMm: 5, -}); -``` - ---- - -### Firebase auth (`@rdlabo/ionic-angular-kit/auth-firebase`) - -A secondary entry point so only apps that use it pull in `firebase` (declared as an optional peer dependency — install `firebase` in the app). It exists to **isolate the Firebase SDK**: `firebase/auth` is initialized in exactly one place — the DI provider — so apps import `KIT_FIREBASE_AUTH` and call these functions, never wiring `firebase/auth` themselves. The kit uses the vanilla modular `firebase/auth` SDK directly (no `@angular/fire`). - -**Design principle: the kit performs no UI.** Every function runs the Firebase operation and nothing else; loading overlays, prompts and error alerts are app side effects. The flow functions take the uniform lifecycle hooks `{ before, success, error, finally }` and, rather than throwing, resolve value flows to `null` and boolean flows to `false`, handing the raw error to the `error` hook so the app presents it from its own dictionary. For anything the functions don't express, drop down to `firebase/auth` directly. - -```typescript -// app.config.ts — Firebase is initialized only here -provideKitFirebase({ firebaseConfig: environment.firebase }), -provideKitFirebaseAnalytics(), -``` - -```typescript -import { inject, Injectable } from '@angular/core'; -import { - KIT_FIREBASE_AUTH, - kitSignIn, - kitSignOut, - kitResolveAuthStatus, - kitReauthWithRetry, - type User, -} from '@rdlabo/ionic-angular-kit/auth-firebase'; -import { updatePassword } from 'firebase/auth'; // escape hatch for the reauth mutation - -@Injectable({ providedIn: 'root' }) -export class AuthService { - readonly #auth = inject(KIT_FIREBASE_AUTH); - - // Simple flow: hooks carry the app's side effects; errors go to the app's own dictionary. - signIn(email: string, password: string) { - return kitSignIn(this.#auth, email, password, { - error: (e) => this.presentError(e), - success: () => this.nav.navigateRoot('/'), - }); - } - - // Re-auth: the kit owns only the re-auth + wrong-password-retry mechanic; the app supplies - // the password prompt and the loading overlay, and catches the thrown (non-wrong-password) error. - async changePassword(currentEmail: string, newPassword: string) { - const ok = await kitReauthWithRetry(this.#auth, currentEmail, { - prompt: (retry) => this.promptPassword(retry), - mutate: (user) => updatePassword(user, newPassword), - withLoading: (run) => this.withLoading(run), - }).catch((e) => (this.presentError(e), false)); - if (ok) this.overlay.alertClose({ header: 'Saved', message: '…' }); - } - - // A delayed denial must not sign out a newer Firebase session. Capture the User that owns the - // request and pass it as expectedUser; the kit checks object identity after `before` completes - // and immediately before invoking Firebase signOut. - signOutDeniedSession(expectedUser: User) { - return kitSignOut(this.#auth, { before: () => this.clearDeniedSessionTransport() }, { expectedUser }); - } -} -``` - -Surface: - -- **DI** — `KIT_FIREBASE_AUTH` (`InjectionToken`), `provideKitFirebase({ firebaseConfig })`, `provideKitFirebaseAnalytics()`. -- **Flow functions** (uniform hooks + no-throw null/false) — `kitSignIn`, `kitSignUp` (create + send verification), `kitSignOut`, `kitSendPasswordReset`, `kitSendEmailVerification`, `kitUnlinkProvider`. `kitSignOut(..., { expectedUser })` prevents an old asynchronous denial from signing out a newer Firebase User object. -- **Mechanics** — `kitReauthWithRetry` (app injects `prompt` / `withLoading` / `mutate`; boolean result, non-wrong-password errors thrown), `kitResolveAuthStatus` (`'user' | 'confirm' | 'required'` from the user; social counts as verified; `allowWhen` bypass), `kitAuthState`, `kitGetIdToken`. -- **Error dictionary** — `KIT_DEFAULT_AUTH_TEXT` (importable canonical constant; the kit does not present it — the app renders its own alert). -- **Social** (`@rdlabo/ionic-angular-kit/auth-firebase/social`, separate nested entry to isolate the Capacitor plugins) — `kitFacebookLogin`, `kitAppleLogin`, `kitFacebookLogout`; options carry the same `{ before, success, error, finally }` hooks (`success` receives the identity payload for a backend call). - ---- - -### Live Update (`@rdlabo/ionic-angular-kit/live-update`) - -A secondary entry point (so only apps that use it pull in `@capawesome/capacitor-live-update`, declared as an optional peer) for shipping [Capawesome Live Updates](https://capawesome.io/plugins/live-update/) — over-the-air replacement of the Web (Angular/Ionic) layer without a store review. - -#### `provideLiveUpdateReadiness()` - -Marks the running Live Update bundle **healthy** once the app has actually rendered, so Capawesome does not auto-roll-back a good bundle. It waits for Angular to become stable **and** the first route to finish (`NavigationEnd` + one animation frame) before calling `LiveUpdate.ready()`. This replaces a fixed `readyTimeout` in `capacitor.config.ts` with a signal tied to real readiness. **Native only** — a no-op on web. - -```typescript -// app.config.ts -import { provideLiveUpdateReadiness } from '@rdlabo/ionic-angular-kit/live-update'; - -export const appConfig: ApplicationConfig = { - providers: [ - provideZonelessChangeDetection(), - provideLiveUpdateReadiness(), - // ... - ], -}; -``` - -#### Release and channel model - -Each app's release workflow runs when a `vX.Y.Z` or `vX.Y.Z-N` tag is pushed. The shared `classify-mobile-release` composite action in [`ionic-angular-library/.github/actions`](https://github.com/rdlabo-dev/ionic-angular-library/tree/main/.github/actions) compares the tag with the previous release and selects the delivery path. Patch and prerelease updates within the same `major.minor` line use `publish-live-update`; major and minor updates use Capawesome Cloud Native Builds and App Store Publishing. - -Every delivery channel is named **`production-`**, where the Android `versionCode` and iOS `CURRENT_PROJECT_VERSION` must match. A Live Update replaces only the JS, HTML, and CSS on an existing native binary, so channels are isolated by build number and updates reach only compatible devices. The upload pins `--android-min/max` and `--ios-min/max` to that build number. - -- **Same channel: eligible for Live Update** - Keep the native build number unchanged. This path is for JS, HTML, and CSS changes only, including bug fixes, copy, UI, application logic, and npm dependencies that do not affect native code. Incrementing only the patch version within the same `major.minor` line keeps the channel unchanged, so existing users receive the update without installing a new store build. - Example: `9.0.0` (build `9000000`) followed by a web-only `9.0.1` update; both use `production-9000000`. - -- **New channel: store release required** - Increment the major or minor version and the native build number. Include changes to `app/android/**`, `app/ios/**`, `capacitor.config.ts` (or `.json`), and Capacitor plugin versions in this release type. The workflow submits iOS builds to TestFlight and Android builds to the Google Play Internal track; promotion to production happens in each store. - Example: a native update to `9.1.0` (build `9010000`) creates `production-9010000`. Devices still running `9.0.x` remain on `production-9000000` and are unaffected. - -The build number encodes the major and minor versions at the front: `floor(buildNumber / 10000) === major * 100 + minor`. - -| Version | Build number | Channel | -| -------- | ------------ | --------------------- | -| `9.0.x` | `9000000` | `production-9000000` | -| `9.1.x` | `9010000` | `production-9010000` | -| `10.2.x` | `10020000` | `production-10020000` | - -`classify-mobile-release` fails CI when a patch or prerelease contains native, configuration, or Capacitor dependency changes and requires a major or minor bump instead. For store releases, it verifies that the tag matches the native marketing version, the Android and iOS versions and build numbers agree, the build number increases, and the encoding above is valid. `validate-live-update` remains available for compatibility with existing consumers. - ---- - -## Consumer Vitest setup notes - -When testing a consumer app that declares `@rdlabo/ionic-angular-kit` as a `file:` symlink dependency, add the following to your `vitest.config.ts`: - -```typescript -// vitest.config.ts -export default defineConfig({ - resolve: { - dedupe: ['@angular/core', '@angular/common', '@angular/router', '@ionic/angular', '@ionic/core', 'rxjs'], - }, - test: { - server: { - deps: { - inline: [ - /@ionic\/angular/, - /@ionic\/core/, - /ionicons/, - /@rdlabo\/ionic-angular-kit/, // inline the kit itself - ], - }, - }, - }, -}); -``` +- [Storage and Overlays](./docs/storage-overlays.md) +- [Authentication and HTTP](./docs/auth-http.md) +- [Offline and Realtime](./docs/offline-realtime.md) +- [Optional Features](./docs/optional-features.md) -- `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`. -- `server.deps.inline` is required for ESM packages that Vite cannot handle as external CJS. -- In test configs, provide all required tokens before testing kit-dependent code: `provideKitOverlay(...)`, `provideKitAuth(...)`, `provideKitHttp(...)`. + +**Full documentation:** [https://docs.rdlabo.dev/projects/ionic-angular-kit](https://docs.rdlabo.dev/projects/ionic-angular-kit) + diff --git a/projects/kit/docs/auth-http.md b/projects/kit/docs/auth-http.md new file mode 100644 index 0000000..d279aed --- /dev/null +++ b/projects/kit/docs/auth-http.md @@ -0,0 +1,36 @@ +## Access capability + +`provideKitAuth()` configures functional route guards for `user`, `confirm`, `required`, `anonymous`, and `unavailable` authentication states. Redirect routes and application side effects remain in the app. + +`KitAuthAccessService` publishes what the current session may do: + +| Mode | Local replica and outbox | Authenticated HTTP, realtime, and sync | +| -------- | ------------------------ | -------------------------------------- | +| `none` | Blocked | Blocked | +| `local` | Allowed | Blocked | +| `remote` | Allowed | Allowed | + +An authoritative `required` result is signed out and must not become offline access. Only an `unavailable` transport result may activate a previously verified local session. + +```ts +provideKitAuth(() => ({ + authState: () => inject(AuthService).state$, + redirects: { + whenAuthorized: '/home', + whenConfirming: '/auth/confirm', + whenNotConfirming: '/auth/signin', + whenUnauthorized: '/auth', + }, + isUnavailableError: (error) => isOfflineFallbackError(error), +})); +``` + +Use `kitRequiredUnauthorizedGuard`, `kitRequireConfirmingGuard`, and `kitRequireAuthorizedGuard` in route definitions. A protected asynchronous decision suspends previously published remote capability until the current authorization lease succeeds. + +## HTTP policy + +`provideKitHttp()` configures `kitAuthInterceptor` for credential injection, bypass rules, transient failure handling, and application error hooks. + +Automatic retry is limited to `GET`, `HEAD`, `OPTIONS`, or requests carrying an `Idempotency-Key`. Ordinary writes are never retried automatically. Retries cover transient statuses `0`, `408`, `429`, `502`, `503`, and `504`, and honor `Retry-After`. + +When offline support is enabled, register `offlineInterceptor` before `kitAuthInterceptor`. Local mode then prevents credential generation and network transport while allowing a matched read policy to serve the scoped replica. diff --git a/projects/kit/docs/offline-realtime.md b/projects/kit/docs/offline-realtime.md new file mode 100644 index 0000000..7e95cc1 --- /dev/null +++ b/projects/kit/docs/offline-realtime.md @@ -0,0 +1,36 @@ +## Scoped offline runtime + +The `/offline` entry point provides a user- and partition-scoped local replica, durable outbox, cursor-based delta pull, aggregate-ordered replay, optimistic mutation policies, and request-policy interception. + +Use `mode: 'readCacheOnly'` for external-source or HTTP caches. Synchronized mode uses encrypted `@capacitor-community/sqlite` on iOS and Android; it fails fast on the web because the current runtime has no cross-tab synchronization lock. + +Cold-start offline access restores only a manifest bound to a non-null authentication-provider subject. Remote work follows this order: + +1. Prepare the verified remote session. +2. Publish `remote` access. +3. Resume pull, outbox replay, and realtime work. + +`createOfflineAuthBridge()` connects this ordering to `provideKitAuth()` while leaving consent, error UI, and credential exchange in the app. + +```ts +import { createOfflineAuthBridge, isOfflineFallbackError } from '@rdlabo/ionic-angular-kit/offline'; + +provideKitAuth(() => ({ + authState: () => auth.state$, + ...createOfflineAuthBridge({ + exchange: async (context) => exchangeCredential(context), + currentAuthSubject: () => auth.currentSubject(), + isUnavailableError, + availability: () => auth.authorityAvailable$, + }), + redirects, +})); +``` + +On explicit sign-out, clear `KitAuthAccessService` first, then await offline session cleanup so in-flight leases are invalidated before persisted user data is removed. + +## Realtime connection + +Subclass `KitRealtimeConnection` to supply connection intent and `{ url, protocols }` targets. The kit owns foreground and network suspension, target-scoped reconnect, exponential backoff, ping/pong detection, self-echo annotation, and `reconnected$` resync signaling. + +Use `kitRealtimeProtocols()` to carry authentication and the stable `KIT_REALTIME_CLIENT_ID` in WebSocket subprotocols instead of URL parameters. Offline-capable authenticated clients set `requireRemoteAccess: true`; sockets then remain closed in `none` and `local` modes. diff --git a/projects/kit/docs/optional-features.md b/projects/kit/docs/optional-features.md new file mode 100644 index 0000000..9c4a130 --- /dev/null +++ b/projects/kit/docs/optional-features.md @@ -0,0 +1,31 @@ +Optional features use secondary entry points so their native plugins and SDKs do not enter applications that do not use them. + +## Theme and review + +`provideKitTheme()` and `KitThemeController` persist a user preference, follow `prefers-color-scheme` until overridden, toggle app-provided palette classes, and synchronize the Android status bar. + +```ts +provideKitTheme({ + storageKey: 'theme', + darkClasses: ['ion-palette-dark'], + lightClasses: ['ion-palette-light'], +}); +``` + +Import `kitRequestReview()` from `/review` to request the native review dialog at most once per application-defined window. It is a no-op on the web. + +## Printer + +The `/printer` entry point contains pure helpers for DOM-to-PNG rendering, image rotation, Brother print settings, multi-page label layout, and PDF generation. The consuming app owns paper-selection UI, loading overlays, storage, transport, and copy policy. + +## Firebase authentication + +The `/auth-firebase` entry point initializes `firebase/auth` through `provideKitFirebase()` and exposes `KIT_FIREBASE_AUTH` plus flow helpers such as `kitSignIn`, `kitSignUp`, `kitSignOut`, `kitResolveAuthStatus`, and `kitReauthWithRetry`. + +The kit performs no UI. Hooks carry loading, navigation, and error presentation back to the application. Social providers are isolated further under `/auth-firebase/social`. + +## Live Update + +`provideLiveUpdateReadiness()` from `/live-update` waits for Angular stability, the first completed route, and one animation frame before calling Capawesome `LiveUpdate.ready()`. It is a no-op on the web. + +A Live Update replaces only the web layer of an existing native binary. Native code, Capacitor configuration, or plugin version changes require a store build and a new build-number-specific channel. diff --git a/projects/kit/docs/storage-overlays.md b/projects/kit/docs/storage-overlays.md new file mode 100644 index 0000000..354713c --- /dev/null +++ b/projects/kit/docs/storage-overlays.md @@ -0,0 +1,52 @@ +## Typed storage + +Provide Ionic Storage once. `KitStorageService` initializes it lazily and every public operation waits for that initialization, so writes made immediately after service creation are not dropped. + +```ts +import { importProvidersFrom } from '@angular/core'; +import { IonicStorageModule } from '@ionic/storage-angular'; + +export const appConfig: ApplicationConfig = { + providers: [importProvidersFrom(IonicStorageModule.withConfig({ name: '__mydb' }))], +}; +``` + +```ts +const storage = inject(KitStorageService); + +await storage.set('token', token); +const saved = await storage.get('token'); +await storage.remove('token'); +``` + +`get()` returns `null` for a missing key. `kitClearStoragePreservingKeys()` clears application data while restoring selected values such as the last authentication email or theme. + +## Typed overlays + +Configure application-owned labels with `provideKitOverlay()` and inject `KitOverlayController`. The kit does not hard-code localized copy. + +```ts +provideKitOverlay({ + labels: { + close: $localize`Close`, + cancel: $localize`Cancel`, + }, +}); +``` + +```ts +export class DetailPage { + declare static readonly modalReturn: DetailResult; + readonly item = input.required(); +} + +export const launchDetailPage = ( + overlay: KitOverlayController, + props: { item: Item }, +): Promise => + overlay.presentModal(DetailPage, props, { backdropDismiss: false }); +``` + +Component props are inferred from Angular `input()` fields and dismiss data from the component's static `modalReturn` declaration. Keep a typed launcher beside each modal or popover instead of calling an Ionic controller inline. + +The same controller provides `presentPopover()`, `presentToast()`, `alertClose()`, and `alertConfirm()`. Modal option `watchKeyboard: true` expands a bottom sheet while the native keyboard is visible. diff --git a/projects/kit/ng-package.json b/projects/kit/ng-package.json index 8fb5c50..6eb202d 100644 --- a/projects/kit/ng-package.json +++ b/projects/kit/ng-package.json @@ -3,5 +3,8 @@ "dest": "../../dist/kit", "lib": { "entryFile": "src/public-api.ts" - } + }, + "assets": [ + "docs" + ] } diff --git a/projects/photo-editor/README.md b/projects/photo-editor/README.md index 7013f39..fbc398b 100644 --- a/projects/photo-editor/README.md +++ b/projects/photo-editor/README.md @@ -1,13 +1,36 @@ # @rdlabo/ionic-angular-photo-editor +## Overview + This is a photo editor and viewer for modal page of Ionic Angular project using Capacitor. -**Documentation:** [Read the full documentation](https://docs.rdlabo.dev/projects/ionic-angular-photo-editor) +## Features + +### Choose by editing goal + +| Goal | Guide | +| --- | --- | +| Load a photo from camera or album | [PhotoFileService](./docs/photo-file.md) | +| Crop and edit in a modal | [Photo Editor](./docs/editor.md) | +| Browse images in a modal | [Photo Viewer](./docs/viewer.md) | +| Override editor colors | [Theme](./docs/theme.md) | + +## Quick start + +After [Installation](#installation), load a photo: + +```typescript +import { PhotoFileService } from '@rdlabo/ionic-angular-photo-editor'; + +const files = await this.photoFileService.loadPhoto(1); +``` + +Then present the editor or viewer. Details: [PhotoFileService](./docs/photo-file.md), [Photo Editor](./docs/editor.md), [Photo Viewer](./docs/viewer.md). ## Installation ```bash -npm install @rdlabo/ionic-angular-photo-editor +npm install @rdlabo/ionic-angular-photo-editor ``` If you use capacitor, you need to install plugin: @@ -26,154 +49,16 @@ If you public your project to the web, you need to add the following input tag t ``` -## Theme - -Default color is set, but user can overwrite it: https://github.com/rdlabo-dev/ionic-angular-library/blob/main/projects/photo-editor/src/lib/pages/core.scss - -### How to overwrite - -```scss -:root { - --ion-photo-editor-background: #2a2a2a; - --ion-photo-editor-background-tint: #414141; - - --ion-photo-editor-color: #f0f0f0; - --ion-photo-editor-color-tint: #dbdbdb; - - --ion-photo-editor-primary: #4d8dff; - --ion-photo-editor-danger: #f24c58; - --ion-photo-editor-success: #2dd55b; -} -``` - -## Usage - -### PhotoFileService - -```typescript -import { PhotoFileService } from '@rdlabo/ionic-angular-photo-editor'; - -export class AppComponent { - private photoFileService = inject(PhotoFileService); - - constructor() { - this.photoFileService.photoMaxSize = 1000; - this.photoFileService.labels = { - camera: 'Camera', - album: 'Album', - cancel: 'Cancel', - }; - } - - async upload() { - const file = await this.photoFileService.loadPhoto(); - if (file) { - // upload file - } - } -} -```` - -#### Options -##### photoMaxSize - -The maximum size of the photo. Default is 1000. - -##### labels - -If set, the label is overwritten. - - -### PhotoEditorPage - -```typescript -import { PhotoEditorPage, IPhotoEditorDismiss } from '@rdlabo/ionic-angular-photo-editor'; - -(async () => { - const modal = await this.modalCtrl.create({ - component: PhotoEditorPage, - componentProps: { - requireSquare: false, - value: 'https://picsum.photos/200/300', - label: { - save: '送信', // change '保存' to '送信' - }, - }, - }); - await modal.present(); - const { data } = await modal.onWillDismiss(); - if (data?.value) { - console.log(data.value); - } -})(); -``` - -### Options - -#### requireSquare: boolean - -If true, the image must be cropped to a square at first. - -#### value: string - -The image url or base64 string. - -#### labels: IDictionaryForEditor - -If set, the label is overwritten. - -List is [here](https://github.com/rdlabo-dev/ionic-angular-library/blob/main/projects/photo-editor/src/lib/dictionaries.ts). - - -### PhotoViewerPage - -```typescript -import { PhotoViewerPage, IPhotoViewerDismiss } from '@rdlabo/ionic-angular-photo-editor'; - -(async () => { - const modal = await this.modalCtrl.create({ - component: PhotoViewerPage, - componentProps: { - imageUrls: [ - 'https://picsum.photos/200/300', - 'https://picsum.photos/200/300', - ], - index: 0, - isCircle: false, - }, - }); - await modal.present(); - const { data } = await modal.onWillDismiss(); - if (data?.delete) { - // User delete image - } -})(); -``` - -### Options - -#### imageUrls: string[] - -The image url or base64 string[]. - -#### index: number - -The index of imageUrls. - -#### isCircle: boolean - -If set, the image is displayed in a circle. - -#### enableDelete: boolean - -If true, the delete button is displayed. - -#### enableFooterSafeArea: boolean -If true, enable footer safe area for iOS. +## Documentation -#### labels: IDictionaryForViewer +Start with [Installation](#installation), then pick a guide. -If set, the label is overwritten. +- [PhotoFileService](./docs/photo-file.md) — camera and album. +- [Photo Editor](./docs/editor.md) — crop and edit in a modal. +- [Photo Viewer](./docs/viewer.md) — browse images in a modal. +- [Theme](./docs/theme.md) — CSS variables. -List is [here](https://github.com/rdlabo-dev/ionic-angular-library/blob/main/projects/photo-editor/src/lib/dictionaries.ts). + +**Full documentation:** [https://docs.rdlabo.dev/projects/ionic-angular-photo-editor](https://docs.rdlabo.dev/projects/ionic-angular-photo-editor) + diff --git a/projects/photo-editor/docs/editor.md b/projects/photo-editor/docs/editor.md new file mode 100644 index 0000000..8f8e198 --- /dev/null +++ b/projects/photo-editor/docs/editor.md @@ -0,0 +1,39 @@ +Present `PhotoEditorPage` in an Ionic modal. Call this after [Installation](../README.md#installation). + +```typescript +import { PhotoEditorPage, IPhotoEditorDismiss } from '@rdlabo/ionic-angular-photo-editor'; + +(async () => { + const modal = await this.modalCtrl.create({ + component: PhotoEditorPage, + componentProps: { + requireSquare: false, + value: 'https://picsum.photos/200/300', + labels: { + save: '送信', // change '保存' to '送信' + }, + }, + }); + await modal.present(); + const { data } = await modal.onWillDismiss(); + if (data?.value) { + console.log(data.value); + } +})(); +``` + +### Options + +#### requireSquare: boolean + +If true, the image must be cropped to a square at first. + +#### value: string + +The image url or base64 string. + +#### labels: IDictionaryForEditor + +If set, the label is overwritten. + +List is [here](https://github.com/rdlabo-dev/ionic-angular-library/blob/v21.6.2/projects/photo-editor/src/lib/dictionaries.ts). diff --git a/projects/photo-editor/docs/photo-file.md b/projects/photo-editor/docs/photo-file.md new file mode 100644 index 0000000..25d0c07 --- /dev/null +++ b/projects/photo-editor/docs/photo-file.md @@ -0,0 +1,35 @@ +Load photos from the camera or album. Call this after [Installation](../README.md#installation). + +```typescript +import { PhotoFileService } from '@rdlabo/ionic-angular-photo-editor'; + +export class AppComponent { + private photoFileService = inject(PhotoFileService); + + constructor() { + this.photoFileService.photoMaxSize = 1000; + this.photoFileService.labels = { + camera: 'Camera', + album: 'Album', + cancel: 'Cancel', + }; + } + + async upload() { + const files = await this.photoFileService.loadPhoto(1); + if (files.length > 0) { + // upload files + } + } +} +``` + +#### Options + +##### photoMaxSize + +The maximum size of the photo. Default is 1000. + +##### labels + +If set, the label is overwritten. diff --git a/projects/photo-editor/docs/theme.md b/projects/photo-editor/docs/theme.md new file mode 100644 index 0000000..177128f --- /dev/null +++ b/projects/photo-editor/docs/theme.md @@ -0,0 +1,19 @@ +Override the editor colors after [Installation](../README.md#installation). + +Default color is set, but user can overwrite it: https://github.com/rdlabo-dev/ionic-angular-library/blob/v21.6.2/projects/photo-editor/src/lib/pages/core.scss + +## How to overwrite + +```scss +:root { + --ion-photo-editor-background: #2a2a2a; + --ion-photo-editor-background-tint: #414141; + + --ion-photo-editor-color: #f0f0f0; + --ion-photo-editor-color-tint: #dbdbdb; + + --ion-photo-editor-primary: #4d8dff; + --ion-photo-editor-danger: #f24c58; + --ion-photo-editor-success: #2dd55b; +} +``` diff --git a/projects/photo-editor/docs/viewer.md b/projects/photo-editor/docs/viewer.md new file mode 100644 index 0000000..7abe5b0 --- /dev/null +++ b/projects/photo-editor/docs/viewer.md @@ -0,0 +1,49 @@ +Present `PhotoViewerPage` in an Ionic modal. Call this after [Installation](../README.md#installation). + +```typescript +import { PhotoViewerPage, IPhotoViewerDismiss } from '@rdlabo/ionic-angular-photo-editor'; + +(async () => { + const modal = await this.modalCtrl.create({ + component: PhotoViewerPage, + componentProps: { + imageUrls: ['https://picsum.photos/200/300', 'https://picsum.photos/200/300'], + index: 0, + isCircle: false, + }, + }); + await modal.present(); + const { data } = await modal.onWillDismiss(); + if (data?.delete) { + // User delete image + } +})(); +``` + +### Options + +#### imageUrls: string[] + +The image url or base64 string[]. + +#### index: number + +The index of imageUrls. + +#### isCircle: boolean + +If set, the image is displayed in a circle. + +#### enableDelete: boolean + +If true, the delete button is displayed. + +#### enableFooterSafeArea: boolean + +If true, enable footer safe area for iOS. + +#### labels: IDictionaryForViewer + +If set, the label is overwritten. + +List is [here](https://github.com/rdlabo-dev/ionic-angular-library/blob/v21.6.2/projects/photo-editor/src/lib/dictionaries.ts). diff --git a/projects/photo-editor/ng-package.json b/projects/photo-editor/ng-package.json index 3377c1f..ecc9c0d 100644 --- a/projects/photo-editor/ng-package.json +++ b/projects/photo-editor/ng-package.json @@ -3,5 +3,8 @@ "dest": "../../dist/photo-editor", "lib": { "entryFile": "src/public-api.ts" - } -} \ No newline at end of file + }, + "assets": [ + "docs" + ] +} diff --git a/projects/scroll-header/README.md b/projects/scroll-header/README.md index f7400e4..d50af54 100644 --- a/projects/scroll-header/README.md +++ b/projects/scroll-header/README.md @@ -1,8 +1,22 @@ # @rdlabo/ionic-angular-scroll-header +## Overview + This is directive for scroll with Header. -**Documentation:** [Read the full documentation](https://docs.rdlabo.dev/projects/ionic-angular-scroll-header) +## Features + +### Choose by header layout + +| Goal | Guide | +| --- | --- | +| Hide and reveal headers on IonContent | [IonContent](./docs/ion-content.md) | +| Coordinate headers with CDK virtual scroll | [Virtual Scroll](./docs/virtual-scroll.md) | +| Keep a native header always visible | [Safe Area](./docs/safe-area.md) | + +## Quick start + +After [Installation](#installation), attach the directive to `ion-content`. See [IonContent](./docs/ion-content.md). ## Installation @@ -11,6 +25,7 @@ npm install @rdlabo/ionic-angular-scroll-header ``` And import CSS for directive: + ```diff + @import '@rdlabo/ionic-angular-scroll-header/css/scroll-header.directive.css'; @@ -24,103 +39,15 @@ And import CSS for directive: + } ``` -## Usage - -### Scroll of IonContent - -- Demo: https://rdlabo-ionic-angular-library.netlify.app/main/scroll-header -- Source: https://github.com/rdlabo-dev/ionic-angular-library/blob/main/projects/demo/src/app/scroll-header/scroll-header.page.html - -```ts -import { ScrollHeaderDirective } from '@rdlabo/ionic-angular-scroll-header'; -@Component({ - ... - imports: [ - ScrollHeaderDirective - ], -}) -``` - -```html - - - - ... - - ...Your Content - -``` - -### Scroll of CdkVirtualScroll (Angular Material) - -- Demo: https://rdlabo-ionic-angular-library.netlify.app/main/virtual-scroll-header -- Source: https://github.com/rdlabo-dev/ionic-angular-library/blob/main/projects/demo/src/app/virtual-scroll-header/virtual-scroll-header.page.html -```ts -import { VirtualScrollHeaderDirective } from '@rdlabo/ionic-angular-scroll-header'; +## Documentation -@Component({ - ... - imports: [ - VirtualScrollHeaderDirective - ], -}) -``` +Start with [Installation](#installation), then pick a guide. -```html - - - - ... - - - ...Your Content - - -``` +- [IonContent](./docs/ion-content.md) — scroll-aware Ionic headers. +- [Virtual Scroll](./docs/virtual-scroll.md) — CDK viewports and the flicker fix. +- [Safe Area](./docs/safe-area.md) — hidden and native headers. -### Fix https://github.com/angular/components/issues/27104 - -> bug(COMPONENT): CDK Virtual Scroller jump back/flickers to items on top #27104 - -```ts -import { FixVirtualScrollElementDirective } from '@rdlabo/ionic-angular-scroll-header'; - -@Component({ - ... - imports: [ - FixVirtualScrollElementDirective - ], -}) -``` - -```html - - - ...Your Content - - -``` - -# FQA -## Why do I need to set hidden header for safe-area? -Of course, it is also possible to set a safe-area in ion-content as follows. - -```css -ion-content { - padding-top: var(--ion-safe-area-top, 0); -} -``` - -But I preferred to explicitly set up ion-header and ion-toolbar for safe-area. - -## I also need a Header that is always visible, apart from the Header that follows Scroll and hides it - -it is possible: by adding `native-header` to the class name, you can have two Headers more smoothly. - -```diff -- -+ -+ Native Header -+ -``` + +**Full documentation:** [https://docs.rdlabo.dev/projects/ionic-angular-scroll-header](https://docs.rdlabo.dev/projects/ionic-angular-scroll-header) + diff --git a/projects/scroll-header/docs/ion-content.md b/projects/scroll-header/docs/ion-content.md new file mode 100644 index 0000000..e0f369b --- /dev/null +++ b/projects/scroll-header/docs/ion-content.md @@ -0,0 +1,26 @@ +Attach scroll-aware headers to Ionic content. Call this after [Installation](../README.md#installation). + +- Demo: https://rdlabo-ionic-angular-library.netlify.app/main/scroll-header +- Source: https://github.com/rdlabo-dev/ionic-angular-library/blob/v21.6.2/projects/demo/src/app/scroll-header/scroll-header.page.html + +```ts +import { ScrollHeaderDirective } from '@rdlabo/ionic-angular-scroll-header'; +@Component({ + ... + imports: [ + ScrollHeaderDirective + ], +}) +``` + +```html + + + + + ... + + + ...Your Content + +``` diff --git a/projects/scroll-header/docs/safe-area.md b/projects/scroll-header/docs/safe-area.md new file mode 100644 index 0000000..068e8ea --- /dev/null +++ b/projects/scroll-header/docs/safe-area.md @@ -0,0 +1,24 @@ +Hidden safe-area headers and always-visible native headers. + +## Why do I need to set hidden header for safe-area? + +Of course, it is also possible to set a safe-area in ion-content as follows. + +```css +ion-content { + padding-top: var(--ion-safe-area-top, 0); +} +``` + +But I preferred to explicitly set up ion-header and ion-toolbar for safe-area. + +## I also need a Header that is always visible, apart from the Header that follows Scroll and hides it + +it is possible: by adding `native-header` to the class name, you can have two Headers more smoothly. + +```diff +- ++ ++ Native Header ++ +``` diff --git a/projects/scroll-header/docs/virtual-scroll.md b/projects/scroll-header/docs/virtual-scroll.md new file mode 100644 index 0000000..766d90f --- /dev/null +++ b/projects/scroll-header/docs/virtual-scroll.md @@ -0,0 +1,63 @@ +Coordinate headers with Angular CDK virtual viewports. Call this after [Installation](../README.md#installation). + +- Demo: https://rdlabo-ionic-angular-library.netlify.app/main/virtual-scroll-header +- Source: https://github.com/rdlabo-dev/ionic-angular-library/blob/v21.6.2/projects/demo/src/app/virtual-scroll-header/virtual-scroll-header.page.html + +```ts +import { VirtualScrollHeaderDirective } from '@rdlabo/ionic-angular-scroll-header'; + +@Component({ + ... + imports: [ + VirtualScrollHeaderDirective + ], +}) +``` + +```html + + + + + ... + + + + ...Your Content + + +``` + +### Fix https://github.com/angular/components/issues/27104 + +> bug(COMPONENT): CDK Virtual Scroller jump back/flickers to items on top #27104 + +```ts +import { FixVirtualScrollElementDirective } from '@rdlabo/ionic-angular-scroll-header'; + +@Component({ + ... + imports: [ + FixVirtualScrollElementDirective + ], +}) +``` + +```html + + + ...Your Content + + +``` diff --git a/projects/scroll-header/ng-package.json b/projects/scroll-header/ng-package.json index 615cb82..b3a01f4 100644 --- a/projects/scroll-header/ng-package.json +++ b/projects/scroll-header/ng-package.json @@ -4,5 +4,9 @@ "lib": { "entryFile": "src/public-api.ts" }, - "assets": ["src/assets/**", "./css/**/*"] + "assets": [ + "src/assets/**", + "./css/**/*", + "docs" + ] } diff --git a/projects/scroll-strategies/README.md b/projects/scroll-strategies/README.md index 112e9d7..ac3754a 100644 --- a/projects/scroll-strategies/README.md +++ b/projects/scroll-strategies/README.md @@ -1,13 +1,15 @@ # @rdlabo/ngx-cdk-scroll-strategies -This is strategies of dynamic item size for `@angular/cdk/scrolling`. This allows you set specify each item size in the array to be used for Virtual Scroll. Although the repository name includes “Ionic” this strategy only works with Angular. +## Overview -**Documentation:** [Read the full documentation](https://docs.rdlabo.dev/projects/ngx-cdk-scroll-strategies) +This is strategies of dynamic item size for `@angular/cdk/scrolling`. This allows you set specify each item size in the array to be used for Virtual Scroll. Although the repository name includes “Ionic” this strategy only works with Angular. This is a simple coding concept: ```html - +
itemSize: {{ item }}
@@ -20,129 +22,36 @@ Every data item must have one corresponding `itemDynamicSizes` entry in the same This library is based largely on this blog: https://dev.to/georgii/virtual-scrolling-of-content-with-variable-height-with-angular-3a52 -## Installation - -```bash -npm install @rdlabo/ngx-cdk-scroll-strategies -``` - -## Usage - -### Simple Usage - -> This is a simple example of how to use it. - -- Demo: https://rdlabo-ionic-angular-library.netlify.app/main/scroll-strategies/simple -- Source: https://github.com/rdlabo-dev/ionic-angular-library/blob/main/projects/demo/src/app/scroll-strategies/pages/scroll-simple - -```ts -import { CdkDynamicSizeVirtualScroll, itemDynamicSize } from '@rdlabo/ngx-cdk-scroll-strategies'; - -@Component({ - ... - imports: [ - CdkDynamicSizeVirtualScroll - ], -}) -export class ScrollStrategiesPage implements OnInit { - readonly items = signal([]); - readonly dynamicSize = computed(() => { - return this.items().map((item) => ({ trackId: item.trackId, itemSize: item.itemSize })); - }); -} -``` - -```html - -
- itemSize: {{ item.itemSize }} -
-
-``` - -Other than this, it works the same way as `@angular/cdk/scrolling`. - -### Advanced Usage - -> This is a practical demo. Make scroll items separate components and get a height for each component. -> It is difficult without basic knowledge of Angular. +## Features -- Demo: https://rdlabo-ionic-angular-library.netlify.app/main/scroll-strategies/advanced -- Source: https://github.com/rdlabo-dev/ionic-angular-library/blob/main/projects/demo/src/app/scroll-strategies/pages/scroll-advanced +### Choose by scrolling goal -### Reverse Usage +| Goal | Guide | +| --- | --- | +| Specify each item height | [Simple Usage](./docs/simple.md) | +| Measure item components | [Advanced Usage](./docs/advanced.md) | +| Reverse chat-style scrolling | [Reverse Scroll](./docs/reverse.md) | -> This is a demo for reverse scrolling like WeChat. +## Quick start -- Demo: https://rdlabo-ionic-angular-library.netlify.app/main/scroll-strategies/reverse -- Source: https://github.com/rdlabo-dev/ionic-angular-library/blob/main/projects/demo/src/app/scroll-strategies/pages/scroll-reverse +After [Installation](#installation), bind `[itemDynamicSizes]` instead of `[itemSize]`. See [Simple Usage](./docs/simple.md). -If reverse scroll, add `isReverse` directive to `cdk-virtual-scroll-viewport` tag. - -```html - -
-
- itemSize: {{ item.itemSize }} -
-
-
-``` - -Add css to `cdk-virtual-scroll-viewport.reverse-scroll` at global css file like `styles.css`. - -```css -cdk-virtual-scroll-viewport { - width: 100%; - height: 100%; - - // .reverse-scroll class is added from this directive. - &.reverse-scroll { - display: flex; - flex-direction: column-reverse; - - .cdk-virtual-scroll-content-wrapper { - top: auto; - bottom: 0; - } - } -} -``` - -And add item wrapper. `div.reverse-items` class is example. You can decide this. - -```css -div.reverse-items { - height: 100%; - display: flex; - flex-direction: column-reverse; - - position: relative; - bottom: 0; -} -``` - -**In Reverse Scroll, CdkVirtualScrollViewport's measureScrollOffset does not work. Please use the scrollOffset of this directive.** -https://github.com/rdlabo-dev/ionic-angular-library/blob/main/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll-strategy.ts - -The reverse layout uses negative native `scrollTop` values. `scrollToIndex()` accepts a logical item index as usual and converts its cumulative offset to that native coordinate internally. - -### Optional - -This package contains a Helper Service that simplifies development with Virtual Scroll. +## Installation -```ts -import { DynamicSizeVirtualScrollService } from '@rdlabo/ngx-cdk-scroll-strategies'; +```bash +npm install @rdlabo/ngx-cdk-scroll-strategies ``` -Detail is here: https://github.com/rdlabo-dev/ionic-angular-library/blob/main/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll.service.ts - -## FQA -### Why don't use `autosize` directive? +## Documentation -`autosize` directive use average item size. This is not support "item size is changed" "item is removed". Because don't have item size cache. +Start with [Installation](#installation), then pick a guide. -https://github.com/angular/components/blob/main/src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts#L49C3-L59 +- [Simple Usage](./docs/simple.md) — per-item heights. +- [Advanced Usage](./docs/advanced.md) — measured item components. +- [Reverse Scroll](./docs/reverse.md) — chat-style reverse lists. +- [FAQ](./docs/faq.md) — why not `autosize`. -Dynamic size can be specified for more flexible application design. + +**Full documentation:** [https://docs.rdlabo.dev/projects/ngx-cdk-scroll-strategies](https://docs.rdlabo.dev/projects/ngx-cdk-scroll-strategies) + diff --git a/projects/scroll-strategies/docs/advanced.md b/projects/scroll-strategies/docs/advanced.md new file mode 100644 index 0000000..71993f0 --- /dev/null +++ b/projects/scroll-strategies/docs/advanced.md @@ -0,0 +1,7 @@ +Call this after [Installation](../README.md#installation). + +> This is a practical demo. Make scroll items separate components and get a height for each component. +> It is difficult without basic knowledge of Angular. + +- Demo: https://rdlabo-ionic-angular-library.netlify.app/main/scroll-strategies/advanced +- Source: https://github.com/rdlabo-dev/ionic-angular-library/tree/v21.6.2/projects/demo/src/app/scroll-strategies/pages/scroll-advanced diff --git a/projects/scroll-strategies/docs/faq.md b/projects/scroll-strategies/docs/faq.md new file mode 100644 index 0000000..715e55c --- /dev/null +++ b/projects/scroll-strategies/docs/faq.md @@ -0,0 +1,7 @@ +### Why don't use `autosize` directive? + +`autosize` directive use average item size. This is not support "item size is changed" "item is removed". Because don't have item size cache. + +https://github.com/angular/components/blob/main/src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts#L49C3-L59 + +Dynamic size can be specified for more flexible application design. diff --git a/projects/scroll-strategies/docs/reverse.md b/projects/scroll-strategies/docs/reverse.md new file mode 100644 index 0000000..32ff3c5 --- /dev/null +++ b/projects/scroll-strategies/docs/reverse.md @@ -0,0 +1,75 @@ +Call this after [Installation](../README.md#installation). + +> This is a demo for reverse scrolling like WeChat. + +- Demo: https://rdlabo-ionic-angular-library.netlify.app/main/scroll-strategies/reverse +- Source: https://github.com/rdlabo-dev/ionic-angular-library/tree/v21.6.2/projects/demo/src/app/scroll-strategies/pages/scroll-reverse + +If reverse scroll, add `isReverse` directive to `cdk-virtual-scroll-viewport` tag. + +```html + +
+
+ itemSize: {{ item.itemSize }} +
+
+
+``` + +Add css to `cdk-virtual-scroll-viewport.reverse-scroll` at global css file like `styles.css`. + +```css +cdk-virtual-scroll-viewport { + width: 100%; + height: 100%; + + /* .reverse-scroll class is added from this directive. */ + &.reverse-scroll { + display: flex; + flex-direction: column-reverse; + + .cdk-virtual-scroll-content-wrapper { + top: auto; + bottom: 0; + } + } +} +``` + +And add item wrapper. `div.reverse-items` class is example. You can decide this. + +```css +div.reverse-items { + height: 100%; + display: flex; + flex-direction: column-reverse; + + position: relative; + bottom: 0; +} +``` + +**In Reverse Scroll, CdkVirtualScrollViewport's measureScrollOffset does not work. Please use the scrollOffset of this directive.** +https://github.com/rdlabo-dev/ionic-angular-library/blob/v21.6.2/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll-strategy.ts + +The reverse layout uses negative native `scrollTop` values. `scrollToIndex()` accepts a logical item index as usual and converts its cumulative offset to that native coordinate internally. + +### Optional + +This package contains a Helper Service that simplifies development with Virtual Scroll. + +```ts +import { DynamicSizeVirtualScrollService } from '@rdlabo/ngx-cdk-scroll-strategies'; +``` + +Detail is here: https://github.com/rdlabo-dev/ionic-angular-library/blob/v21.6.2/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll.service.ts diff --git a/projects/scroll-strategies/docs/simple.md b/projects/scroll-strategies/docs/simple.md new file mode 100644 index 0000000..bf630fb --- /dev/null +++ b/projects/scroll-strategies/docs/simple.md @@ -0,0 +1,41 @@ +Call this after [Installation](../README.md#installation). + +> This is a simple example of how to use it. + +- Demo: https://rdlabo-ionic-angular-library.netlify.app/main/scroll-strategies/simple +- Source: https://github.com/rdlabo-dev/ionic-angular-library/tree/v21.6.2/projects/demo/src/app/scroll-strategies/pages/scroll-simple + +```ts +import { CdkDynamicSizeVirtualScroll, itemDynamicSize } from '@rdlabo/ngx-cdk-scroll-strategies'; + +@Component({ + ... + imports: [ + CdkDynamicSizeVirtualScroll + ], +}) +export class ScrollStrategiesPage implements OnInit { + readonly items = signal([]); + readonly dynamicSize = computed(() => { + return this.items().map((item) => ({ trackId: item.trackId, itemSize: item.itemSize })); + }); +} +``` + +```html + +
+ itemSize: {{ item.itemSize }} +
+
+``` + +Other than this, it works the same way as `@angular/cdk/scrolling`. diff --git a/projects/scroll-strategies/ng-package.json b/projects/scroll-strategies/ng-package.json index ad6ebb0..eb158cf 100644 --- a/projects/scroll-strategies/ng-package.json +++ b/projects/scroll-strategies/ng-package.json @@ -3,5 +3,8 @@ "dest": "../../dist/scroll-strategies", "lib": { "entryFile": "src/public-api.ts" - } + }, + "assets": [ + "docs" + ] }