From 466161b570231f13ec3f901ff848d46db1f91a9b Mon Sep 17 00:00:00 2001 From: rdlabo Date: Thu, 2 Jul 2026 13:19:35 +0900 Subject: [PATCH 1/4] feat(kit): add reference-counted KitLoadingController MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps Ionic's LoadingController so at most one loading indicator is on screen across concurrent async work: presentLoading increments a counter and presents on the 0 → 1 transition, dismissLoading decrements and dismisses on the N → 0 transition. All operations are serialized through an internal promise chain, so a dismiss that arrives mid-presentation runs after present() settles and tears the element down instead of orphaning it. A failed create/present rolls back its reference so the counter cannot stay elevated and wedge a later cycle into a stuck spinner. --- .../overlay/kit-loading.controller.spec.ts | 126 ++++++++++++++++++ .../src/lib/overlay/kit-loading.controller.ts | 119 +++++++++++++++++ projects/kit/src/public-api.ts | 1 + 3 files changed, 246 insertions(+) create mode 100644 projects/kit/src/lib/overlay/kit-loading.controller.spec.ts create mode 100644 projects/kit/src/lib/overlay/kit-loading.controller.ts diff --git a/projects/kit/src/lib/overlay/kit-loading.controller.spec.ts b/projects/kit/src/lib/overlay/kit-loading.controller.spec.ts new file mode 100644 index 0000000..76c4931 --- /dev/null +++ b/projects/kit/src/lib/overlay/kit-loading.controller.spec.ts @@ -0,0 +1,126 @@ +import { provideZonelessChangeDetection } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { LoadingController } from '@ionic/angular/standalone'; + +import { KitLoadingController } from './kit-loading.controller'; + +// --------------------------------------------------------------------------- +// Fake loading element factories +// --------------------------------------------------------------------------- +function fakeLoading() { + return { + present: vi.fn().mockResolvedValue(undefined), + dismiss: vi.fn().mockResolvedValue(undefined), + }; +} + +// A loading whose present() stays pending until we resolve it, to reproduce a dismiss that arrives +// mid-presentation. +function deferredLoading() { + let resolvePresent!: () => void; + const loading = { + present: vi.fn().mockReturnValue(new Promise((r) => (resolvePresent = r))), + dismiss: vi.fn().mockResolvedValue(undefined), + }; + return { loading, resolvePresent: () => resolvePresent() }; +} + +function setup(loading: ReturnType = fakeLoading()) { + const loadingCtrl = { create: vi.fn().mockResolvedValue(loading) }; + + TestBed.configureTestingModule({ + providers: [provideZonelessChangeDetection(), KitLoadingController, { provide: LoadingController, useValue: loadingCtrl }], + }); + + return { controller: TestBed.inject(KitLoadingController), loadingCtrl, loading }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- +describe('KitLoadingController', () => { + afterEach(() => { + TestBed.resetTestingModule(); + }); + + it('presents a single indicator and passes options through to create', async () => { + const { controller, loadingCtrl, loading } = setup(); + await controller.presentLoading({ message: 'Loading…' }); + expect(loadingCtrl.create).toHaveBeenCalledOnce(); + expect(loadingCtrl.create).toHaveBeenCalledWith({ message: 'Loading…' }); + expect(loading.present).toHaveBeenCalledOnce(); + await controller.dismissLoading(); + }); + + it('shows one indicator for concurrent presents and dismisses only after the last release', async () => { + const { controller, loadingCtrl, loading } = setup(); + await controller.presentLoading(); + await controller.presentLoading(); + expect(loadingCtrl.create).toHaveBeenCalledOnce(); + + await controller.dismissLoading(); + expect(loading.dismiss).not.toHaveBeenCalled(); // one reference still outstanding + + await controller.dismissLoading(); + expect(loading.dismiss).toHaveBeenCalledOnce(); + }); + + it('is a no-op when dismissing while nothing is loading', async () => { + const { controller, loadingCtrl, loading } = setup(); + await controller.dismissLoading(); + expect(loadingCtrl.create).not.toHaveBeenCalled(); + expect(loading.dismiss).not.toHaveBeenCalled(); + }); + + it('shows nothing when a present is immediately balanced by a dismiss', async () => { + const { controller, loadingCtrl } = setup(); + const p = controller.presentLoading(); + const d = controller.dismissLoading(); + await Promise.all([p, d]); + expect(loadingCtrl.create).not.toHaveBeenCalled(); + }); + + it('does not orphan the indicator when dismiss arrives before present() settles', async () => { + const deferred = deferredLoading(); + const { controller, loadingCtrl } = setup(deferred.loading); + + const p1 = controller.presentLoading(); // create resolves, then present() is left pending + // Wait until we are genuinely mid-presentation (present() called but not settled), so the dismiss + // lands inside the create→present window — the exact race a naive ref-count would orphan. + await vi.waitFor(() => expect(deferred.loading.present).toHaveBeenCalled()); + + const p2 = controller.dismissLoading(); // count 0 → queued behind the still-pending present + deferred.resolvePresent(); // present() settles → present task finishes → queued dismiss runs + await Promise.all([p1, p2]); + + expect(loadingCtrl.create).toHaveBeenCalledOnce(); + expect(deferred.loading.present).toHaveBeenCalledOnce(); + // The key assertion: the indicator is torn down after present() settled, not left orphaned. + expect(deferred.loading.dismiss).toHaveBeenCalledOnce(); + }); + + it('creates a fresh indicator on a new cycle after full dismissal', async () => { + const { controller, loadingCtrl } = setup(); + await controller.presentLoading(); + await controller.dismissLoading(); + await controller.presentLoading(); + expect(loadingCtrl.create).toHaveBeenCalledTimes(2); + await controller.dismissLoading(); + }); + + it('a failed present rolls back its reference and does not wedge later operations', async () => { + const { controller, loadingCtrl, loading } = setup(); + loadingCtrl.create.mockRejectedValueOnce(new Error('boom')); + + // The failed reference is rolled back internally — no compensating dismissLoading() is needed. + await expect(controller.presentLoading()).rejects.toThrow('boom'); + + loadingCtrl.create.mockResolvedValue(loading); + await controller.presentLoading(); + expect(loading.present).toHaveBeenCalledOnce(); + + // A single dismiss tears the indicator down: proof the counter is balanced (not stuck at 1). + await controller.dismissLoading(); + expect(loading.dismiss).toHaveBeenCalledOnce(); + }); +}); diff --git a/projects/kit/src/lib/overlay/kit-loading.controller.ts b/projects/kit/src/lib/overlay/kit-loading.controller.ts new file mode 100644 index 0000000..f889c3a --- /dev/null +++ b/projects/kit/src/lib/overlay/kit-loading.controller.ts @@ -0,0 +1,119 @@ +import { inject, Injectable } from '@angular/core'; +import type { LoadingOptions } from '@ionic/angular/standalone'; +import { LoadingController } from '@ionic/angular/standalone'; + +/** + * Reference-counted wrapper around Ionic's `LoadingController` that keeps at most one loading + * indicator on screen across concurrent async work. + * + * @remarks + * Each {@link presentLoading} increments a counter and each {@link dismissLoading} decrements it; the + * indicator is presented on the `0 → 1` transition and dismissed on the `N → 0` transition. This + * removes the flicker / "stuck spinner" bugs that come from every service calling + * `LoadingController.create/dismiss` independently. + * + * All operations are serialized through an internal promise chain, so a `create → present` sequence + * can never interleave with a concurrent `dismiss`. That is what makes the counter race-safe: a + * dismiss that arrives while the indicator is still being presented runs *after* `present()` settles + * and therefore tears the element down instead of leaving it orphaned on screen. + * + * Always pair every `presentLoading()` with exactly one `dismissLoading()` — a `try/finally` is the + * safest shape. + * + * @example + * ```ts + * constructor(private readonly loading: KitLoadingController) {} + * + * async save(): Promise { + * await this.loading.presentLoading({ message: 'Saving…' }); + * try { + * await this.api.save(); + * } finally { + * await this.loading.dismissLoading(); + * } + * } + * ``` + */ +@Injectable({ + providedIn: 'root', +}) +export class KitLoadingController { + readonly #loadingCtrl = inject(LoadingController); + + /** Outstanding {@link presentLoading} calls not yet balanced by {@link dismissLoading}. */ + #count = 0; + + /** The single presented loading element, or `null` when none is on screen. */ + #loading: HTMLIonLoadingElement | null = null; + + /** + * Serializes present/dismiss operations. Each call chains onto this promise, runs after the + * previous operation has fully settled, then reads {@link #count} and acts accordingly. + */ + #queue: Promise = Promise.resolve(); + + /** + * Show the loading indicator, or join the one already on screen. + * + * @param options - Ionic loading options; only applied by the call that actually creates the + * indicator (the `0 → 1` transition). Ignored while an indicator is already present. + * @returns a Promise that resolves once the indicator is on screen (or immediately when one already is) + */ + async presentLoading(options: LoadingOptions = {}): Promise { + this.#count++; + try { + await this.#enqueue(async () => { + // Create only on the transition into "something is loading"; concurrent callers ride the same + // element. Re-check the count in case a dismiss already balanced this call while queued. + if (this.#count > 0 && this.#loading === null) { + const loading = await this.#loadingCtrl.create(options); + await loading.present(); + this.#loading = loading; + } + }); + } catch (error) { + // Roll back the reference this call took: a failed create/present must not leave the counter + // elevated, otherwise a later cycle never reaches N → 0 and the spinner stays stuck on screen. + this.#count--; + throw error; + } + } + + /** + * Release one reference; dismiss the indicator once the last reference is gone. + * + * @returns a Promise that resolves once the reference is released (and the indicator dismissed if + * this was the last one). No-ops when the counter is already at zero. + */ + async dismissLoading(): Promise { + if (this.#count === 0) { + return; + } + this.#count--; + await this.#enqueue(async () => { + // Tear down only when the last consumer is gone. Because this runs after any in-flight + // present() has settled (via the queue), there is never an orphaned loading element. + if (this.#count === 0 && this.#loading !== null) { + const loading = this.#loading; + this.#loading = null; + await loading.dismiss(); + } + }); + } + + /** + * Append `task` to the serialization chain and return its completion. + * + * @remarks + * The stored chain swallows rejections so a single failing operation cannot wedge every future + * overlay; the returned promise still rejects so the caller observes the error. + */ + #enqueue(task: () => Promise): Promise { + const run = this.#queue.then(task); + this.#queue = run.then( + () => undefined, + () => undefined, + ); + return run; + } +} diff --git a/projects/kit/src/public-api.ts b/projects/kit/src/public-api.ts index cfc7d84..002aaf4 100644 --- a/projects/kit/src/public-api.ts +++ b/projects/kit/src/public-api.ts @@ -8,6 +8,7 @@ export * from './lib/storage/kit-storage.service'; // Overlay: wrapper around the Ionic Modal / Toast / Alert controllers. export * from './lib/overlay/overlay-config'; export * from './lib/overlay/kit-overlay.controller'; +export * from './lib/overlay/kit-loading.controller'; export * from './lib/overlay/kit-reload-alert.controller'; export * from './lib/overlay/kit-auth-failed-alert'; export * from './lib/overlay/kit-language-action-sheet'; From be140766909f7905a41e85612c5b0b33ea898b7f Mon Sep 17 00:00:00 2001 From: rdlabo Date: Thu, 2 Jul 2026 13:19:43 +0900 Subject: [PATCH 2/4] feat(kit)!: infer presentModal props and return type from the component presentModal now infers componentProps from the component's input() fields (the single source of truth, so props can never drift from a hand-written declaration): required inputs become required props and the compiler rejects a call that omits them; default-less input() fields are optional. Because Angular's types cannot distinguish input.required() from a defaulted input(default), a defaulted input is treated as required. Components with no signal inputs fall back to loose, untyped props. The return type is inferred from an optional `declare static modalReturn` phantom type; a component without one resolves to `void`, so the modal is treated as returning no dismiss data and reading the result does not compile. BREAKING CHANGE: the single-type-argument form presentModal(...) no longer sets the return type. Return data is inferred from a static modalReturn on the component; declare it on modals that resolve with data. --- .../overlay/kit-overlay.controller.spec.ts | 64 ++++++++++- .../src/lib/overlay/kit-overlay.controller.ts | 102 +++++++++++++++++- 2 files changed, 159 insertions(+), 7 deletions(-) diff --git a/projects/kit/src/lib/overlay/kit-overlay.controller.spec.ts b/projects/kit/src/lib/overlay/kit-overlay.controller.spec.ts index 5af810c..deadce4 100644 --- a/projects/kit/src/lib/overlay/kit-overlay.controller.spec.ts +++ b/projects/kit/src/lib/overlay/kit-overlay.controller.spec.ts @@ -1,4 +1,4 @@ -import { provideZonelessChangeDetection } from '@angular/core'; +import { Component, input, provideZonelessChangeDetection } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { AlertController, ModalController, PopoverController, ToastController } from '@ionic/angular/standalone'; import { Capacitor } from '@capacitor/core'; @@ -283,13 +283,30 @@ describe('KitOverlayController', () => { expect(createArgs.componentProps).toEqual({ id: 1 }); }); - it('returns the dismiss data', async () => { + it('returns the dismiss data when the component declares modalReturn', async () => { + @Component({ template: '' }) + class ResultModal { + declare static modalReturn: { selected: string }; + } const dismissData = { selected: 'foo' }; const { controller } = setup({ modalOverlay: fakeOverlay(undefined, dismissData) }); - const result = await controller.presentModal<{ selected: string }>(FakeComponent); + const result = await controller.presentModal(ResultModal); // typed `{ selected: string } | undefined` + expect(result?.selected).toBe('foo'); expect(result).toEqual(dismissData); }); + it('type: a modal without modalReturn resolves to void', async () => { + @Component({ template: '' }) + class NoReturnModal {} + const { controller } = setup({ modalOverlay: fakeOverlay() }); + const result = await controller.presentModal(NoReturnModal); + // `result` is `void`: it is assignable to void, and reading a property off it must not compile. + const assertVoid: void = result; + expect(assertVoid).toBeUndefined(); + // @ts-expect-error — a void result carries no dismiss data. + result?.anything; + }); + it('presents the modal', async () => { const overlay = fakeOverlay(undefined, null); const { controller } = setup({ modalOverlay: overlay }); @@ -303,6 +320,47 @@ describe('KitOverlayController', () => { await controller.presentModal(FakeComponent); expect(overlay.onDidDismiss).toHaveBeenCalledOnce(); }); + + it('infers props from input() fields and the return type from static modalReturn', async () => { + @Component({ template: '' }) + class TypedModal { + declare static modalReturn: { saved: boolean }; + readonly id = input.required(); // required input → required prop + readonly note = input(); // default-less input() → optional prop + } + const { controller, modalCtrl } = setup({ modalOverlay: fakeOverlay(undefined, { saved: true }) }); + // `{ id: 1 }` is type-checked against the inferred props; `result` is typed + // `{ saved: boolean } | undefined`, so `result?.saved` only compiles when inference is wired up. + const result = await controller.presentModal(TypedModal, { id: 1, note: 'hi' }); + expect(modalCtrl.create.mock.calls[0][0].componentProps).toEqual({ id: 1, note: 'hi' }); + expect(result?.saved).toBe(true); + }); + + it('type: required input makes its prop required and a defaulted input is required too', async () => { + @Component({ template: '' }) + class RequiredModal { + readonly id = input.required(); + readonly count = input(0); // defaulted input() is (safely) treated as required + } + const { controller } = setup({ modalOverlay: fakeOverlay() }); + + // @ts-expect-error — `id` is required, omitting the props object must not compile. + await controller.presentModal(RequiredModal); + // @ts-expect-error — `count` (defaulted input) is treated as required, so it cannot be omitted. + await controller.presentModal(RequiredModal, { id: 1 }); + + await controller.presentModal(RequiredModal, { id: 1, count: 5 }); // fully specified → ok + }); + + it('type: a component with only optional inputs allows omitting the props argument', async () => { + @Component({ template: '' }) + class OptionalModal { + readonly note = input(); + } + const { controller } = setup({ modalOverlay: fakeOverlay() }); + await controller.presentModal(OptionalModal); // props argument optional → ok + await controller.presentModal(OptionalModal, { note: 'hi' }); // still accepts the props + }); }); // ---- presentPopover ------------------------------------------------------- diff --git a/projects/kit/src/lib/overlay/kit-overlay.controller.ts b/projects/kit/src/lib/overlay/kit-overlay.controller.ts index fd6b488..9664981 100644 --- a/projects/kit/src/lib/overlay/kit-overlay.controller.ts +++ b/projects/kit/src/lib/overlay/kit-overlay.controller.ts @@ -1,4 +1,5 @@ import { inject, Injectable } from '@angular/core'; +import type { InputSignalWithTransform } from '@angular/core'; import type { ModalOptions, PopoverOptions, ToastOptions } from '@ionic/angular/standalone'; import { AlertController, ModalController, PopoverController, ToastController } from '@ionic/angular/standalone'; import type { PluginListenerHandle } from '@capacitor/core'; @@ -24,6 +25,92 @@ export interface KitModalPresentOptions extends Omit(); // props are inferred from here + * readonly note = input(); // default-less input() → optional prop + * } + * + * // Caller — `id` is required (input.required), `note` optional; result is `{ saved: boolean } | undefined`: + * const result = await overlay.presentModal(EditPage, { id: 1 }); + * ``` + */ +export interface ModalMetadata { + /** Shape of the data the modal resolves with when dismissed. */ + modalReturn?: R; +} + +/** + * Write type of a signal `input()` field (unwraps `input.required`, `input()` and transform inputs). + * + * @remarks + * Matched with `any` (not `unknown`): `InputSignalWithTransform`'s `TransformT` is contravariant, so + * `InputSignal` is not assignable to `InputSignalWithTransform`. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type InputWriteType = F extends InputSignalWithTransform ? W : never; + +/** Instance type of a component constructor; `never` for non-class components (string / HTMLElement refs). */ +type InstanceOf = C extends abstract new (...args: never[]) => infer I ? I : never; + +/** Keys of the `input()` signal fields on a component instance. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type InputFieldKeys = { [K in keyof I]-?: I[K] extends InputSignalWithTransform ? K : never }[keyof I]; + +/** + * Props object inferred from a component's `input()` fields. Required vs. optional is decided by the write + * type: `input.required()` / `input(default)` yield `T` (no `undefined`) → required prop, while a + * default-less `input()` yields `T | undefined` → optional prop. + * + * @remarks + * Angular's types cannot distinguish `input.required()` from a defaulted `input(default)` — both are + * `InputSignal` — so a defaulted input is (safely) treated as required. Declare inputs you want to omit at + * the call site as default-less `input()`. + */ +type ModalPropsOf = { [K in InputFieldKeys as undefined extends InputWriteType ? never : K]: InputWriteType } & { + [K in InputFieldKeys as undefined extends InputWriteType ? K : never]?: InputWriteType; +}; + +/** `input()` keys whose write type excludes `undefined` (required / defaulted inputs). Empty when none. */ +type RequiredInputKeys = { [K in InputFieldKeys]: undefined extends InputWriteType ? never : K }[InputFieldKeys]; + +/** + * Dismiss-data type inferred from a component's static {@link ModalMetadata.modalReturn}. A component that + * declares no `modalReturn` resolves to `void`: it is treated as returning no dismiss data, so the compiler + * rejects any attempt to read the result. Declare `modalReturn` on modals that do resolve with data. + */ +type ModalReturnOf = C extends { modalReturn: infer R } ? R : void; + +/** Loose trailing args (optional, untyped props) — used when a component exposes no signal `input()` fields. */ +type LooseModalPresentArgs = [componentProps?: ModalOptions['componentProps'], options?: KitModalPresentOptions]; + +/** + * Trailing `presentModal` args derived from a component's `input()` fields: props are inferred and typed via + * {@link ModalPropsOf}. When the component declares at least one required input the props argument is required; + * when every input is optional the props argument itself is optional. Components with no signal inputs (plain + * classes, `@Input()`-decorator components, or non-class refs) fall back to loose, untyped props. + */ +type ModalPresentArgs> = [I] extends [never] + ? LooseModalPresentArgs + : [InputFieldKeys] extends [never] + ? LooseModalPresentArgs + : [RequiredInputKeys] extends [never] + ? [componentProps?: ModalPropsOf, options?: KitModalPresentOptions] + : [componentProps: ModalPropsOf, options?: KitModalPresentOptions]; + /** * Options for {@link KitOverlayController.alertClose}. */ @@ -115,22 +202,29 @@ export class KitOverlayController { * @remarks * Presenting a modal triggers light native haptic feedback as an intentional kit UX choice, * consistent with {@link presentPopover} and {@link presentToast}. + * + * Props are inferred from the component's `input()` fields (see {@link ModalPropsOf}): required inputs + * become required props, so the compiler rejects a call that omits them. The return type is inferred from + * a static `modalReturn` (see {@link ModalMetadata}); a component with no `modalReturn` resolves to `void` + * — the modal is treated as returning no dismiss data. * @example * ```ts - * const data = await overlay.presentModal<{ saved: boolean }>(EditPage, { id: 1 }, { watchKeyboard: true }); + * // Inferred — `id` required (input.required), `note` optional; result is `{ saved: boolean } | undefined`: + * const result = await overlay.presentModal(EditPage, { id: 1 }); * ``` */ - async presentModal( + presentModal(component: C, ...args: ModalPresentArgs): Promise | undefined>; + async presentModal( component: ModalOptions['component'], componentProps?: ModalOptions['componentProps'], options: KitModalPresentOptions = {}, - ): Promise { + ): Promise { void kitImpact(); const { watchKeyboard, ...modalOptions } = options; const modal = await this.#modalCtrl.create({ component, componentProps, ...modalOptions }); await modal.present(); const handle = watchKeyboard ? await watchModalKeyboard(modal) : null; - const { data } = await modal.onDidDismiss(); + const { data } = await modal.onDidDismiss(); await handle?.remove(); return data; } From 4e91075ed7bc632769bedce123a1f6f0b62b346a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 04:26:20 +0000 Subject: [PATCH 3/4] ci: add kit to test matrix Co-Authored-By: rdlabo --- .github/workflows/lint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 36f5b66..f5c4229 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -33,7 +33,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - project: [ demo, photo-editor, scroll-header, scroll-strategies] + project: [ demo, kit, photo-editor, scroll-header, scroll-strategies] steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v5 From 1c4d8ec7768ca8603f4451d181746c813d89178f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 04:30:45 +0000 Subject: [PATCH 4/4] docs: update presentModal examples to new inferred-typing API Co-Authored-By: rdlabo --- package-lock.json | 34 +++++++++++++++++++ projects/kit/README.md | 26 ++++++++------ .../src/lib/overlay/kit-overlay.controller.ts | 2 +- 3 files changed, 51 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index 97799fe..0feae9e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4329,6 +4329,24 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/@ionic/angular-toolkit/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@ionic/angular-toolkit/node_modules/cli-spinners": { "version": "2.9.2", "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", @@ -4396,6 +4414,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@ionic/angular-toolkit/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@ionic/angular-toolkit/node_modules/stdin-discarder": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", diff --git a/projects/kit/README.md b/projects/kit/README.md index c51a35f..31a2bdf 100644 --- a/projects/kit/README.md +++ b/projects/kit/README.md @@ -127,8 +127,8 @@ export class MyPage { readonly #overlay = inject(KitOverlayController); async openDetail(): Promise { - const result = await this.#overlay.presentModal<{ id: number }>(DetailPage, { item }); - // result is the data passed to modal.dismiss() + const result = await this.#overlay.presentModal(DetailPage, { item }); + // result type is inferred from `declare static modalReturn` on DetailPage } async confirm(): Promise { @@ -149,11 +149,12 @@ export class MyPage { **API** ```typescript -presentModal( - component: ModalOptions['component'], - componentProps?: ModalOptions['componentProps'], - options?: KitModalPresentOptions, // Omit + watchKeyboard? -): Promise +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'], @@ -182,9 +183,14 @@ alertConfirm(options: { **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 -export const launchDetailPage = (overlay: KitOverlayController, props: DetailProps): Promise => - overlay.presentModal(DetailPage, props, { backdropDismiss: false }); +// detail.page.ts — component declares its return type: +export class DetailPage { + declare static 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. diff --git a/projects/kit/src/lib/overlay/kit-overlay.controller.ts b/projects/kit/src/lib/overlay/kit-overlay.controller.ts index 9664981..9360e6a 100644 --- a/projects/kit/src/lib/overlay/kit-overlay.controller.ts +++ b/projects/kit/src/lib/overlay/kit-overlay.controller.ts @@ -167,7 +167,7 @@ const watchModalKeyboard = async (modal: HTMLIonModalElement): Promise { - * const result = await this.overlay.presentModal(EditPage, { id: 1 }); + * const result = await this.overlay.presentModal(EditPage, { id: 1 }); * if (result) { * await this.overlay.presentToast({ message: 'Saved' }); * }