@rdlabo/ionic-angular-kit 0.0.2 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,233 @@
1
+ import { provideZonelessChangeDetection } from '@angular/core';
2
+ import { TestBed } from '@angular/core/testing';
3
+ import { AlertController, ModalController, ToastController } from '@ionic/angular/standalone';
4
+
5
+ import { KitOverlayController } from './kit-overlay.controller';
6
+ import { KIT_OVERLAY_CONFIG, provideKitOverlay } from './overlay-config';
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Mock Capacitor — KitOverlayController imports Capacitor/Keyboard for the
10
+ // watchKeyboard feature; we stub them so no native APIs are called in jsdom.
11
+ // ---------------------------------------------------------------------------
12
+ vi.mock('@capacitor/core', () => ({
13
+ Capacitor: {
14
+ isNativePlatform: vi.fn().mockReturnValue(false),
15
+ getPlatform: vi.fn().mockReturnValue('web'),
16
+ },
17
+ registerPlugin: vi.fn().mockReturnValue({}),
18
+ }));
19
+
20
+ vi.mock('@capacitor/keyboard', () => ({
21
+ Keyboard: {
22
+ addListener: vi.fn().mockResolvedValue({ remove: vi.fn().mockResolvedValue(undefined) }),
23
+ },
24
+ }));
25
+
26
+ vi.mock('@capacitor/haptics', () => ({
27
+ Haptics: { impact: vi.fn().mockResolvedValue(undefined) },
28
+ ImpactStyle: { Light: 'LIGHT', Medium: 'MEDIUM', Heavy: 'HEAVY' },
29
+ }));
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Fake overlay element factory
33
+ // ---------------------------------------------------------------------------
34
+ type Role = string | undefined;
35
+
36
+ function fakeOverlay(role: Role = undefined, data: unknown = undefined) {
37
+ return {
38
+ present: vi.fn().mockResolvedValue(undefined),
39
+ onWillDismiss: vi.fn().mockResolvedValue({ role, data }),
40
+ onDidDismiss: vi.fn().mockResolvedValue({ role, data }),
41
+ };
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Helpers
46
+ // ---------------------------------------------------------------------------
47
+ const TEST_LABELS = { close: 'Close', cancel: 'Cancel' };
48
+
49
+ function setup({
50
+ modalOverlay = fakeOverlay(),
51
+ toastOverlay = fakeOverlay(),
52
+ alertOverlay = fakeOverlay(),
53
+ }: {
54
+ modalOverlay?: ReturnType<typeof fakeOverlay>;
55
+ toastOverlay?: ReturnType<typeof fakeOverlay>;
56
+ alertOverlay?: ReturnType<typeof fakeOverlay>;
57
+ } = {}) {
58
+ const modalCtrl = { create: vi.fn().mockResolvedValue(modalOverlay) };
59
+ const toastCtrl = { create: vi.fn().mockResolvedValue(toastOverlay) };
60
+ const alertCtrl = { create: vi.fn().mockResolvedValue(alertOverlay) };
61
+
62
+ TestBed.configureTestingModule({
63
+ providers: [
64
+ provideZonelessChangeDetection(),
65
+ KitOverlayController,
66
+ provideKitOverlay({ labels: TEST_LABELS }),
67
+ { provide: ModalController, useValue: modalCtrl },
68
+ { provide: ToastController, useValue: toastCtrl },
69
+ { provide: AlertController, useValue: alertCtrl },
70
+ ],
71
+ });
72
+
73
+ return {
74
+ controller: TestBed.inject(KitOverlayController),
75
+ modalCtrl,
76
+ toastCtrl,
77
+ alertCtrl,
78
+ modalOverlay,
79
+ toastOverlay,
80
+ alertOverlay,
81
+ };
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Tests
86
+ // ---------------------------------------------------------------------------
87
+ describe('KitOverlayController', () => {
88
+ afterEach(() => {
89
+ TestBed.resetTestingModule();
90
+ });
91
+
92
+ // ---- alertConfirm ---------------------------------------------------------
93
+ describe('alertConfirm', () => {
94
+ const opts = { header: 'Confirm', message: 'Are you sure?', okText: 'OK' };
95
+
96
+ it("returns true when onWillDismiss role === 'confirm'", async () => {
97
+ const { controller } = setup({ alertOverlay: fakeOverlay('confirm') });
98
+ const result = await controller.alertConfirm(opts);
99
+ expect(result).toBe(true);
100
+ });
101
+
102
+ it("returns false when onWillDismiss role === 'cancel'", async () => {
103
+ const { controller } = setup({ alertOverlay: fakeOverlay('cancel') });
104
+ const result = await controller.alertConfirm(opts);
105
+ expect(result).toBe(false);
106
+ });
107
+
108
+ it('returns false when role is undefined (backdrop dismiss)', async () => {
109
+ const { controller } = setup({ alertOverlay: fakeOverlay(undefined) });
110
+ const result = await controller.alertConfirm(opts);
111
+ expect(result).toBe(false);
112
+ });
113
+
114
+ it('creates the alert with the correct buttons (cancel + confirm)', async () => {
115
+ const { controller, alertCtrl } = setup({ alertOverlay: fakeOverlay('confirm') });
116
+ await controller.alertConfirm(opts);
117
+ const createArgs = alertCtrl.create.mock.calls[0][0];
118
+ const roles = (createArgs.buttons as { role: string }[]).map((b) => b.role);
119
+ expect(roles).toContain('cancel');
120
+ expect(roles).toContain('confirm');
121
+ });
122
+
123
+ it('uses the injected cancel label', async () => {
124
+ const { controller, alertCtrl } = setup({ alertOverlay: fakeOverlay('cancel') });
125
+ await controller.alertConfirm(opts);
126
+ const createArgs = alertCtrl.create.mock.calls[0][0];
127
+ const cancelBtn = (createArgs.buttons as { text: string; role: string }[]).find((b) => b.role === 'cancel');
128
+ expect(cancelBtn?.text).toBe(TEST_LABELS.cancel);
129
+ });
130
+ });
131
+
132
+ // ---- alertClose -----------------------------------------------------------
133
+ describe('alertClose', () => {
134
+ it('creates and presents an alert then waits for dismiss', async () => {
135
+ const overlay = fakeOverlay();
136
+ const { controller, alertCtrl } = setup({ alertOverlay: overlay });
137
+ await controller.alertClose({ header: 'Info', message: 'Done' });
138
+ expect(alertCtrl.create).toHaveBeenCalledOnce();
139
+ expect(overlay.present).toHaveBeenCalledOnce();
140
+ expect(overlay.onWillDismiss).toHaveBeenCalledOnce();
141
+ });
142
+
143
+ it('uses the injected close label for the button', async () => {
144
+ const { controller, alertCtrl } = setup();
145
+ await controller.alertClose({ header: 'H', message: 'M' });
146
+ const createArgs = alertCtrl.create.mock.calls[0][0];
147
+ expect(createArgs.buttons).toContain(TEST_LABELS.close);
148
+ });
149
+ });
150
+
151
+ // ---- presentToast ---------------------------------------------------------
152
+ describe('presentToast', () => {
153
+ it('applies kit defaults (position=top, duration=2000) and presents the toast', async () => {
154
+ const overlay = fakeOverlay();
155
+ const { controller, toastCtrl } = setup({ toastOverlay: overlay });
156
+ await controller.presentToast({ message: 'Hello' });
157
+ const createArgs = toastCtrl.create.mock.calls[0][0];
158
+ expect(createArgs.position).toBe('top');
159
+ expect(createArgs.duration).toBe(2000);
160
+ expect(overlay.present).toHaveBeenCalledOnce();
161
+ });
162
+
163
+ it('caller options override kit defaults', async () => {
164
+ const { controller, toastCtrl } = setup();
165
+ await controller.presentToast({ message: 'Hi', position: 'bottom', duration: 5000 });
166
+ const createArgs = toastCtrl.create.mock.calls[0][0];
167
+ expect(createArgs.position).toBe('bottom');
168
+ expect(createArgs.duration).toBe(5000);
169
+ });
170
+
171
+ it('includes the close label button from config', async () => {
172
+ const { controller, toastCtrl } = setup();
173
+ await controller.presentToast({ message: 'Test' });
174
+ const createArgs = toastCtrl.create.mock.calls[0][0];
175
+ expect(createArgs.buttons).toContain(TEST_LABELS.close);
176
+ });
177
+
178
+ it('returns the toast element', async () => {
179
+ const overlay = fakeOverlay();
180
+ const { controller, toastCtrl } = setup({ toastOverlay: overlay });
181
+ toastCtrl.create.mockResolvedValue(overlay);
182
+ const result = await controller.presentToast({ message: 'Test' });
183
+ expect(result).toBe(overlay);
184
+ });
185
+ });
186
+
187
+ // ---- presentModal ---------------------------------------------------------
188
+ describe('presentModal', () => {
189
+ class FakeComponent {}
190
+
191
+ it('creates the modal with the given component and props', async () => {
192
+ const { controller, modalCtrl } = setup({ modalOverlay: fakeOverlay(undefined, { result: 42 }) });
193
+ await controller.presentModal(FakeComponent, { id: 1 });
194
+ const createArgs = modalCtrl.create.mock.calls[0][0];
195
+ expect(createArgs.component).toBe(FakeComponent);
196
+ expect(createArgs.componentProps).toEqual({ id: 1 });
197
+ });
198
+
199
+ it('returns the dismiss data', async () => {
200
+ const dismissData = { selected: 'foo' };
201
+ const { controller } = setup({ modalOverlay: fakeOverlay(undefined, dismissData) });
202
+ const result = await controller.presentModal<{ selected: string }>(FakeComponent);
203
+ expect(result).toEqual(dismissData);
204
+ });
205
+
206
+ it('presents the modal', async () => {
207
+ const overlay = fakeOverlay(undefined, null);
208
+ const { controller } = setup({ modalOverlay: overlay });
209
+ await controller.presentModal(FakeComponent);
210
+ expect(overlay.present).toHaveBeenCalledOnce();
211
+ });
212
+
213
+ it('waits for onDidDismiss before returning', async () => {
214
+ const overlay = fakeOverlay(undefined, 'done');
215
+ const { controller } = setup({ modalOverlay: overlay });
216
+ await controller.presentModal(FakeComponent);
217
+ expect(overlay.onDidDismiss).toHaveBeenCalledOnce();
218
+ });
219
+ });
220
+
221
+ // ---- provideKitOverlay / KIT_OVERLAY_CONFIG -------------------------------
222
+ describe('provideKitOverlay', () => {
223
+ it('injects the configured labels', () => {
224
+ TestBed.resetTestingModule();
225
+ TestBed.configureTestingModule({
226
+ providers: [provideZonelessChangeDetection(), provideKitOverlay({ labels: { close: 'Fermer', cancel: 'Annuler' } })],
227
+ });
228
+ const cfg = TestBed.inject(KIT_OVERLAY_CONFIG);
229
+ expect(cfg.labels.close).toBe('Fermer');
230
+ expect(cfg.labels.cancel).toBe('Annuler');
231
+ });
232
+ });
233
+ });
@@ -0,0 +1,206 @@
1
+ import { inject, Injectable } from '@angular/core';
2
+ import type { ModalOptions, ToastOptions } from '@ionic/angular/standalone';
3
+ import { AlertController, ModalController, ToastController } from '@ionic/angular/standalone';
4
+ import type { PluginListenerHandle } from '@capacitor/core';
5
+ import { Capacitor } from '@capacitor/core';
6
+ import { Keyboard } from '@capacitor/keyboard';
7
+ import { KIT_OVERLAY_CONFIG } from './overlay-config';
8
+ import { kitImpact } from '../utils/haptics';
9
+
10
+ /**
11
+ * Options for {@link KitOverlayController.presentModal}.
12
+ *
13
+ * @remarks
14
+ * Extends Ionic's `ModalOptions` but omits `component` and `componentProps`, which are passed as
15
+ * dedicated arguments instead.
16
+ */
17
+ export interface KitModalPresentOptions extends Omit<ModalOptions, 'component' | 'componentProps'> {
18
+ /**
19
+ * When `true`, expand the sheet to its maximum breakpoint while the native keyboard is shown.
20
+ *
21
+ * @remarks
22
+ * Only has an effect on native platforms; ignored on the web.
23
+ */
24
+ watchKeyboard?: boolean;
25
+ }
26
+
27
+ /**
28
+ * Options for {@link KitOverlayController.alertClose}.
29
+ */
30
+ export interface KitAlertCloseOptions {
31
+ /** Alert header text. */
32
+ header: string;
33
+ /** Alert body message. */
34
+ message: string;
35
+ /** Optional alert sub-header text shown beneath the header. */
36
+ subHeader?: string;
37
+ }
38
+
39
+ /**
40
+ * Options for {@link KitOverlayController.alertConfirm}.
41
+ *
42
+ * @remarks
43
+ * Extends {@link KitAlertCloseOptions} with the confirm-button text.
44
+ */
45
+ export interface KitAlertConfirmOptions extends KitAlertCloseOptions {
46
+ /**
47
+ * Text for the OK (confirm) button.
48
+ *
49
+ * @remarks
50
+ * Action-specific, so it is supplied by the caller rather than taken from the shared labels.
51
+ */
52
+ okText: string;
53
+ }
54
+
55
+ /**
56
+ * Attach a native keyboard listener that grows the modal to its maximum breakpoint when the
57
+ * keyboard appears.
58
+ *
59
+ * @param modal - the presented modal element to resize
60
+ * @returns a listener handle; on non-native platforms a no-op handle whose `remove()` does nothing
61
+ * @internal
62
+ */
63
+ const watchModalKeyboard = async (modal: HTMLIonModalElement): Promise<PluginListenerHandle> => {
64
+ if (!Capacitor.isNativePlatform()) {
65
+ return { remove: async () => undefined };
66
+ }
67
+ return Keyboard.addListener('keyboardDidShow', () => modal.setCurrentBreakpoint(1));
68
+ };
69
+
70
+ /**
71
+ * Ergonomic wrapper that consolidates Ionic's overlay controllers (Modal / Toast / Alert).
72
+ *
73
+ * @remarks
74
+ * Folds the repetitive create → present → onDidDismiss sequence into single calls and returns the
75
+ * relevant result directly. It holds no application-specific policy such as navigation; compose
76
+ * those concerns on the consuming side.
77
+ *
78
+ * @example
79
+ * ```ts
80
+ * constructor(private readonly overlay: KitOverlayController) {}
81
+ *
82
+ * async edit(): Promise<void> {
83
+ * const result = await this.overlay.presentModal<EditResult>(EditPage, { id: 1 });
84
+ * if (result) {
85
+ * await this.overlay.presentToast({ message: 'Saved' });
86
+ * }
87
+ * }
88
+ * ```
89
+ */
90
+ @Injectable({
91
+ providedIn: 'root',
92
+ })
93
+ export class KitOverlayController {
94
+ readonly #modalCtrl = inject(ModalController);
95
+ readonly #toastCtrl = inject(ToastController);
96
+ readonly #alertCtrl = inject(AlertController);
97
+ readonly #labels = inject(KIT_OVERLAY_CONFIG).labels;
98
+
99
+ /**
100
+ * Present a modal and resolve with the data passed to its dismissal.
101
+ *
102
+ * @typeParam O - type of the data returned when the modal is dismissed
103
+ * @param component - the component to render inside the modal
104
+ * @param componentProps - props to pass to the modal component
105
+ * @param options - additional modal options, including {@link KitModalPresentOptions.watchKeyboard}
106
+ * @returns the dismiss data, or `undefined` when the modal is dismissed without data
107
+ * @example
108
+ * ```ts
109
+ * const data = await overlay.presentModal<{ saved: boolean }>(EditPage, { id: 1 }, { watchKeyboard: true });
110
+ * ```
111
+ */
112
+ async presentModal<O = unknown>(
113
+ component: ModalOptions['component'],
114
+ componentProps?: ModalOptions['componentProps'],
115
+ options: KitModalPresentOptions = {},
116
+ ): Promise<O | undefined> {
117
+ const { watchKeyboard, ...modalOptions } = options;
118
+ const modal = await this.#modalCtrl.create({ component, componentProps, ...modalOptions });
119
+ await modal.present();
120
+ const handle = watchKeyboard ? await watchModalKeyboard(modal) : null;
121
+ const { data } = await modal.onDidDismiss<O>();
122
+ await handle?.remove();
123
+ return data;
124
+ }
125
+
126
+ /**
127
+ * Present a toast using kit defaults that the caller may override.
128
+ *
129
+ * @remarks
130
+ * Defaults to a top position, a 2000ms duration, a vertical swipe gesture, and a close button
131
+ * from the configured labels; any of these can be overridden via `options`. Presenting a toast
132
+ * also triggers light native haptic feedback as an intentional kit UX choice.
133
+ *
134
+ * @param options - Ionic toast options that override the kit defaults
135
+ * @returns the presented toast element
136
+ * @example
137
+ * ```ts
138
+ * await overlay.presentToast({ message: 'Copied to clipboard' });
139
+ * ```
140
+ */
141
+ async presentToast(options: ToastOptions): Promise<HTMLIonToastElement> {
142
+ void kitImpact();
143
+ const toast = await this.#toastCtrl.create({
144
+ position: 'top',
145
+ duration: 2000,
146
+ buttons: [this.#labels.close],
147
+ swipeGesture: 'vertical',
148
+ ...options,
149
+ });
150
+ await toast.present();
151
+ return toast;
152
+ }
153
+
154
+ /**
155
+ * Present a notification alert with a single "close" button and wait for it to be dismissed.
156
+ *
157
+ * @param options - alert content (header, message, optional sub-header)
158
+ * @returns a Promise that resolves once the alert has been dismissed
159
+ * @example
160
+ * ```ts
161
+ * await overlay.alertClose({ header: 'Done', message: 'Your changes were saved.' });
162
+ * ```
163
+ */
164
+ async alertClose(options: KitAlertCloseOptions): Promise<void> {
165
+ const alert = await this.#alertCtrl.create({
166
+ header: options.header,
167
+ subHeader: options.subHeader,
168
+ message: options.message,
169
+ buttons: [this.#labels.close],
170
+ });
171
+ await alert.present();
172
+ await alert.onWillDismiss();
173
+ }
174
+
175
+ /**
176
+ * Present a confirmation alert with cancel and OK buttons.
177
+ *
178
+ * @param options - alert content plus the OK button text via {@link KitAlertConfirmOptions.okText}
179
+ * @returns `true` when the user presses OK, `false` otherwise (cancel or backdrop dismissal)
180
+ * @example
181
+ * ```ts
182
+ * const ok = await overlay.alertConfirm({
183
+ * header: 'Delete item?',
184
+ * message: 'This cannot be undone.',
185
+ * okText: 'Delete',
186
+ * });
187
+ * if (ok) {
188
+ * await remove();
189
+ * }
190
+ * ```
191
+ */
192
+ async alertConfirm(options: KitAlertConfirmOptions): Promise<boolean> {
193
+ const alert = await this.#alertCtrl.create({
194
+ header: options.header,
195
+ subHeader: options.subHeader,
196
+ message: options.message,
197
+ buttons: [
198
+ { text: this.#labels.cancel, role: 'cancel' },
199
+ { text: options.okText, role: 'confirm' },
200
+ ],
201
+ });
202
+ await alert.present();
203
+ const { role } = await alert.onWillDismiss();
204
+ return role === 'confirm';
205
+ }
206
+ }
@@ -0,0 +1,105 @@
1
+ import { provideZonelessChangeDetection } from '@angular/core';
2
+ import { TestBed } from '@angular/core/testing';
3
+ import { AlertController } from '@ionic/angular/standalone';
4
+
5
+ import { KitReloadAlertController } from './kit-reload-alert.controller';
6
+ import { provideKitOverlay } from './overlay-config';
7
+
8
+ const TEST_LABELS = { close: 'Close', cancel: 'Cancel' };
9
+ const OPTS = { header: 'Network error', message: 'Reload? (0)', okText: 'Reload' };
10
+
11
+ function fakeAlert() {
12
+ let dismissResolve: () => void = () => undefined;
13
+ return {
14
+ present: vi.fn().mockResolvedValue(undefined),
15
+ dismiss: vi.fn().mockResolvedValue(undefined),
16
+ // onDidDismiss resolves when we trigger it, mirroring Ionic's lifecycle.
17
+ onDidDismiss: vi.fn().mockReturnValue(new Promise<void>((r) => (dismissResolve = r))),
18
+ triggerDismiss: () => dismissResolve(),
19
+ };
20
+ }
21
+
22
+ function setup(alert = fakeAlert()) {
23
+ const alertCtrl = { create: vi.fn().mockResolvedValue(alert) };
24
+ TestBed.configureTestingModule({
25
+ providers: [
26
+ provideZonelessChangeDetection(),
27
+ KitReloadAlertController,
28
+ provideKitOverlay({ labels: TEST_LABELS }),
29
+ { provide: AlertController, useValue: alertCtrl },
30
+ ],
31
+ });
32
+ return { controller: TestBed.inject(KitReloadAlertController), alertCtrl, alert };
33
+ }
34
+
35
+ describe('KitReloadAlertController', () => {
36
+ let reload: ReturnType<typeof vi.fn>;
37
+
38
+ beforeEach(() => {
39
+ reload = vi.fn();
40
+ Object.defineProperty(window, 'location', {
41
+ configurable: true,
42
+ value: { ...window.location, reload },
43
+ });
44
+ document.body.innerHTML = '';
45
+ });
46
+
47
+ afterEach(() => {
48
+ TestBed.resetTestingModule();
49
+ });
50
+
51
+ it('presents with backdrop lock and a cancel(role)/reload button pair', async () => {
52
+ const { controller, alertCtrl } = setup();
53
+ await controller.present(OPTS);
54
+ const args = alertCtrl.create.mock.calls[0][0];
55
+ expect(args.backdropDismiss).toBe(false);
56
+ expect(args.buttons[0]).toMatchObject({ text: 'Cancel', role: 'cancel' });
57
+ expect(args.buttons[1].text).toBe('Reload');
58
+ });
59
+
60
+ it('reloads the page from the confirm button handler', async () => {
61
+ const { controller, alertCtrl } = setup();
62
+ await controller.present(OPTS);
63
+ const args = alertCtrl.create.mock.calls[0][0];
64
+ args.buttons[1].handler();
65
+ expect(reload).toHaveBeenCalledTimes(1);
66
+ });
67
+
68
+ it('de-dups: a second present while showing is a no-op', async () => {
69
+ const { controller, alertCtrl } = setup();
70
+ await controller.present(OPTS);
71
+ await controller.present(OPTS);
72
+ expect(alertCtrl.create).toHaveBeenCalledTimes(1);
73
+ });
74
+
75
+ it('does not present when an ion-alert already exists in the DOM', async () => {
76
+ document.body.appendChild(document.createElement('ion-alert'));
77
+ const { controller, alertCtrl } = setup();
78
+ await controller.present(OPTS);
79
+ expect(alertCtrl.create).not.toHaveBeenCalled();
80
+ });
81
+
82
+ it('dismiss() dismisses the tracked alert (auto-dismiss on reconnect)', async () => {
83
+ const { controller, alert } = setup();
84
+ await controller.present(OPTS);
85
+ await controller.dismiss();
86
+ expect(alert.dismiss).toHaveBeenCalledTimes(1);
87
+ });
88
+
89
+ it('dismiss() is a no-op when nothing is showing', async () => {
90
+ const { controller, alert } = setup();
91
+ await controller.dismiss();
92
+ expect(alert.dismiss).not.toHaveBeenCalled();
93
+ });
94
+
95
+ it('allows presenting again after the alert is dismissed', async () => {
96
+ const first = fakeAlert();
97
+ const { controller, alertCtrl } = setup(first);
98
+ await controller.present(OPTS);
99
+ await controller.dismiss();
100
+ first.triggerDismiss();
101
+ await Promise.resolve();
102
+ await controller.present(OPTS);
103
+ expect(alertCtrl.create).toHaveBeenCalledTimes(2);
104
+ });
105
+ });
@@ -0,0 +1,108 @@
1
+ import { inject, Injectable } from '@angular/core';
2
+ import { AlertController } from '@ionic/angular/standalone';
3
+ import { KIT_OVERLAY_CONFIG } from './overlay-config';
4
+
5
+ /**
6
+ * Content for {@link KitReloadAlertController.present}.
7
+ */
8
+ export interface KitReloadAlertOptions {
9
+ /** Alert header text. */
10
+ header: string;
11
+ /** Alert body message. */
12
+ message: string;
13
+ /**
14
+ * Text for the reload (confirm) button, e.g. "リフレッシュ".
15
+ *
16
+ * @remarks
17
+ * Action-specific, so it is supplied by the caller rather than taken from the shared labels.
18
+ * The cancel button uses the configured {@link KitLabels.cancel}.
19
+ */
20
+ okText: string;
21
+ }
22
+
23
+ /**
24
+ * The fleet's canonical "network error → offer to reload" alert, as a stateful controller.
25
+ *
26
+ * @remarks
27
+ * Consolidates the good-UX variant that had drifted across the fleet into one behavior:
28
+ *
29
+ * - **De-dup** — never stacks; a second {@link present} while an alert is already shown is a no-op.
30
+ * - **Backdrop lock** — `backdropDismiss: false`, so a critical network error can't be dismissed by
31
+ * an accidental backdrop tap; the user consciously chooses cancel or reload.
32
+ * - **Auto-dismiss on reconnect** — the presented alert is tracked, so {@link dismiss} (called from a
33
+ * later successful response) clears a now-stale error alert instead of leaving it on screen.
34
+ * - **Reload on confirm** — the confirm button calls `location.reload()`.
35
+ *
36
+ * All user-facing text is supplied by the caller so the kit stays free of any hardcoded i18n; the
37
+ * cancel button reuses {@link KitOverlayConfig.labels}. Because it performs navigation
38
+ * (`location.reload()`) and holds state, it is a dedicated controller rather than part of
39
+ * {@link KitOverlayController}, which stays free of navigation policy.
40
+ *
41
+ * @example
42
+ * ```ts
43
+ * // In an HTTP interceptor:
44
+ * const reload = inject(KitReloadAlertController);
45
+ * // ...on a network-class error while connected:
46
+ * await reload.present({ header: 'ネットワークエラー', message: `…(${status})`, okText: 'リフレッシュ' });
47
+ * // ...on any later successful response:
48
+ * await reload.dismiss();
49
+ * ```
50
+ */
51
+ @Injectable({
52
+ providedIn: 'root',
53
+ })
54
+ export class KitReloadAlertController {
55
+ readonly #alertCtrl = inject(AlertController);
56
+ readonly #labels = inject(KIT_OVERLAY_CONFIG).labels;
57
+ #alert: HTMLIonAlertElement | null = null;
58
+
59
+ /**
60
+ * Present the reload alert, unless one is already on screen.
61
+ *
62
+ * @param options - alert content plus the reload-button text
63
+ * @returns a Promise that resolves once the alert has been presented (or immediately if suppressed)
64
+ */
65
+ async present(options: KitReloadAlertOptions): Promise<void> {
66
+ // この controller 経由でも直書き ion-alert でも、多重表示しない。
67
+ if (this.#alert || document.querySelector('ion-alert')) {
68
+ return;
69
+ }
70
+ const alert = await this.#alertCtrl.create({
71
+ header: options.header,
72
+ message: options.message,
73
+ backdropDismiss: false,
74
+ buttons: [
75
+ { text: this.#labels.cancel, role: 'cancel' },
76
+ {
77
+ text: options.okText,
78
+ handler: () => {
79
+ location.reload();
80
+ },
81
+ },
82
+ ],
83
+ });
84
+ this.#alert = alert;
85
+ void alert.onDidDismiss().then(() => {
86
+ // 別の present で置き換わっていない限り、追跡を解除する。
87
+ if (this.#alert === alert) {
88
+ this.#alert = null;
89
+ }
90
+ });
91
+ await alert.present();
92
+ }
93
+
94
+ /**
95
+ * Dismiss the tracked reload alert if one is showing.
96
+ *
97
+ * @remarks
98
+ * Typically called from a later successful response so a stale "network error" alert clears once
99
+ * connectivity is restored. A no-op when nothing is showing.
100
+ *
101
+ * @returns a Promise that resolves once the alert has been dismissed (or immediately if none)
102
+ */
103
+ async dismiss(): Promise<void> {
104
+ const alert = this.#alert;
105
+ this.#alert = null;
106
+ await alert?.dismiss();
107
+ }
108
+ }