cabloy 5.1.166 → 5.1.168

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.
Files changed (84) hide show
  1. package/.cabloy-version +1 -1
  2. package/.claude/scheduled_tasks.lock +1 -1
  3. package/.claude/skills/cabloy-frontend-scaffold/SKILL.md +1 -0
  4. package/CHANGELOG.md +20 -0
  5. package/package.json +1 -1
  6. package/repo-docs/frontend/page-params-guide.md +1 -1
  7. package/repo-docs/frontend/page-query-guide.md +6 -0
  8. package/repo-docs/frontend/zod-guide.md +9 -7
  9. package/repo-e2e/specs/cabloy-basic.spec.ts +75 -0
  10. package/repo-e2e/specs/routed-dialog.spec.ts +368 -0
  11. package/vona/pnpm-lock.yaml +237 -44
  12. package/vona/src/suite/a-training/modules/training-record/src/entity/record.tsx +2 -0
  13. package/zova/package.original.json +1 -1
  14. package/zova/packages-cli/cli/package.json +2 -2
  15. package/zova/packages-cli/cli-set-front/package.json +1 -1
  16. package/zova/packages-cli/cli-set-front/src/lib/bean/toolsMetadata/generateBeanGenerals.ts +1 -1
  17. package/zova/packages-cli/cli-set-front/src/lib/bean/toolsMetadata/generateScopeResources.ts +1 -1
  18. package/zova/packages-zova/zova/package.json +2 -2
  19. package/zova/pnpm-lock.yaml +1998 -2008
  20. package/zova/pnpm-workspace.yaml +1 -1
  21. package/zova/src/suite/a-demo/modules/demo-basic/src/.metadata/index.ts +66 -18
  22. package/zova/src/suite/a-demo/modules/demo-basic/src/.metadata/page/routedDialog.ts +9 -0
  23. package/zova/src/suite/a-demo/modules/demo-basic/src/.metadata/page/routedDialogDetail.ts +23 -0
  24. package/zova/src/suite/a-demo/modules/demo-basic/src/.metadata/page/routedDialogEntry.ts +23 -0
  25. package/zova/src/suite/a-demo/modules/demo-basic/src/page/routedDialog/controller.tsx +301 -0
  26. package/zova/src/suite/a-demo/modules/demo-basic/src/page/routedDialogDetail/controller.tsx +135 -0
  27. package/zova/src/suite/a-demo/modules/demo-basic/src/page/routedDialogEntry/controller.tsx +108 -0
  28. package/zova/src/suite/a-demo/modules/demo-basic/src/routes.ts +19 -0
  29. package/zova/src/suite/cabloy-basic/modules/basic-app/src/.metadata/component/routedDialog.ts +64 -0
  30. package/zova/src/suite/cabloy-basic/modules/basic-app/src/.metadata/index.ts +44 -2
  31. package/zova/src/suite/cabloy-basic/modules/basic-app/src/bean/behavior.appModal.tsx +306 -18
  32. package/zova/src/suite/cabloy-basic/modules/basic-app/src/component/routedDialog/controller.tsx +55 -0
  33. package/zova/src/suite/cabloy-basic/modules/basic-app/src/config/config.ts +19 -0
  34. package/zova/src/suite/cabloy-basic/modules/basic-app/src/config/locale/en-us.ts +1 -0
  35. package/zova/src/suite/cabloy-basic/modules/basic-app/src/config/locale/zh-cn.ts +1 -0
  36. package/zova/src/suite/cabloy-basic/modules/basic-app/src/lib/routedDialogHistory.ts +76 -0
  37. package/zova/src/suite/cabloy-basic/modules/basic-app/src/service/appModal.ts +208 -1
  38. package/zova/src/suite/cabloy-basic/modules/basic-app/src/types/appModal.ts +124 -13
  39. package/zova/src/suite/cabloy-basic/modules/basic-app/test/lib/routedDialogHistory.test.ts +92 -0
  40. package/zova/src/suite/cabloy-basic/modules/basic-app/test/lib/routedDialogRouter.test.ts +35 -0
  41. package/zova/src/suite/cabloy-basic/modules/basic-page/src/.metadata/index.ts +6 -6
  42. package/zova/src/suite/cabloy-basic/modules/basic-page/src/component/blockPage/controller.tsx +95 -10
  43. package/zova/src/suite/cabloy-basic/modules/basic-page/src/component/blockTable/controller.tsx +1 -0
  44. package/zova/src/suite/cabloy-basic/modules/basic-page/src/config/locale/en-us.ts +1 -0
  45. package/zova/src/suite/cabloy-basic/modules/basic-page/src/config/locale/zh-cn.ts +1 -0
  46. package/zova/src/suite/cabloy-basic/modules/basic-page/src/index.ts +1 -0
  47. package/zova/src/suite/cabloy-basic/modules/basic-page/src/lib/selection.ts +35 -0
  48. package/zova/src/suite/cabloy-basic/modules/basic-page/test/lib/selection.test.ts +24 -0
  49. package/zova/src/suite/cabloy-basic/modules/basic-resource/src/.metadata/component/blockResourcePickerActions.ts +34 -0
  50. package/zova/src/suite/cabloy-basic/modules/basic-resource/src/.metadata/index.ts +30 -8
  51. package/zova/src/suite/cabloy-basic/modules/basic-resource/src/.metadata/locales.ts +7 -0
  52. package/zova/src/suite/cabloy-basic/modules/basic-resource/src/component/blockResourcePickerActions/controller.tsx +92 -0
  53. package/zova/src/suite/cabloy-basic/modules/basic-resource/src/component/formFieldResourcePicker/controller.tsx +219 -11
  54. package/zova/src/suite/cabloy-basic/modules/basic-resource/src/config/locale/en-us.ts +8 -0
  55. package/zova/src/suite/cabloy-basic/modules/basic-resource/src/config/locale/zh-cn.ts +7 -0
  56. package/zova/src/suite/cabloy-basic/modules/basic-resource/src/index.ts +2 -0
  57. package/zova/src/suite/cabloy-basic/modules/basic-resource/src/lib/resourcePicker.ts +29 -0
  58. package/zova/src/suite/cabloy-basic/modules/basic-resource/test/lib/resourcePicker.test.ts +32 -0
  59. package/zova/src/suite/cabloy-basic/modules/basic-table/src/component/table/render.tsx +18 -12
  60. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/package.json +1 -1
  61. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/src/.metadata/component/resourcePickerContext.ts +31 -0
  62. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/src/.metadata/index.ts +48 -5
  63. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/src/.metadata/page/resourcePicker.ts +16 -0
  64. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/src/component/resourcePickerContext/controller.tsx +26 -0
  65. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/src/index.ts +2 -0
  66. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/src/lib/resourcePicker.ts +1 -0
  67. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/src/model/resource.ts +1 -1
  68. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/src/page/resourcePicker/controller.tsx +144 -0
  69. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/src/routes.ts +9 -0
  70. package/zova/src/suite-vendor/a-cabloy/package.json +2 -2
  71. package/zova/src/suite-vendor/a-zova/modules/a-bean/cli/controller/metadata/generateFileComponent.ts +40 -1
  72. package/zova/src/suite-vendor/a-zova/modules/a-bean/package.json +1 -1
  73. package/zova/src/suite-vendor/a-zova/modules/a-router/package.json +1 -1
  74. package/zova/src/suite-vendor/a-zova/modules/a-router/src/.metadata/index.ts +6 -6
  75. package/zova/src/suite-vendor/a-zova/modules/a-router/src/bean/bean.router.ts +60 -3
  76. package/zova/src/suite-vendor/a-zova/modules/a-router/src/bean/sys.router.ts +12 -0
  77. package/zova/src/suite-vendor/a-zova/modules/a-router/src/component/routerViewEmpty/controller.tsx +4 -1
  78. package/zova/src/suite-vendor/a-zova/modules/a-router/src/lib/routerViewBase.tsx +10 -1
  79. package/zova/src/suite-vendor/a-zova/modules/a-routerstack/package.json +1 -1
  80. package/zova/src/suite-vendor/a-zova/modules/a-routerstack/src/component/routerViewStack/controller.tsx +7 -1
  81. package/zova/src/suite-vendor/a-zova/modules/a-table/package.json +1 -1
  82. package/zova/src/suite-vendor/a-zova/modules/a-table/src/component/table/controller.tsx +2 -0
  83. package/zova/src/suite-vendor/a-zova/modules/a-table/src/component/table/render.tsx +13 -10
  84. package/zova/src/suite-vendor/a-zova/package.json +5 -5
@@ -0,0 +1,55 @@
1
+ import type { IComponentOptions } from 'zova';
2
+
3
+ import { h } from 'vue';
4
+ import { BeanControllerBase, cast } from 'zova';
5
+ import { Controller } from 'zova-module-a-bean';
6
+ import { ZRouterViewStack } from 'zova-module-a-routerstack';
7
+
8
+ import { IModalRoutedDialogItem, routedDialogContextKey } from '../../types/appModal.js';
9
+
10
+ export interface ControllerRoutedDialogProps {
11
+ item: IModalRoutedDialogItem;
12
+ }
13
+
14
+ @Controller()
15
+ export class ControllerRoutedDialog extends BeanControllerBase {
16
+ static $propsDefault = {};
17
+ static $componentOptions: IComponentOptions = { inheritAttrs: false };
18
+
19
+ private $$modelStack: object | undefined;
20
+
21
+ protected async __init__() {
22
+ const item = (this.$props as ControllerRoutedDialogProps).item as IModalRoutedDialogItem;
23
+ const router = item.state.router;
24
+ if (!router) return;
25
+ const scene = `routedDialog:${item.id}`;
26
+ this.bean._setBean('a-router.bean.router', router);
27
+ this.$$modelStack = await this.bean._getBeanSelector(
28
+ 'a-routerstack.model.stack',
29
+ true,
30
+ scene,
31
+ scene,
32
+ {},
33
+ );
34
+ }
35
+
36
+ protected render() {
37
+ const item = (this.$props as ControllerRoutedDialogProps).item as IModalRoutedDialogItem;
38
+ if (item.state.status === 'error') {
39
+ return <div class="text-error whitespace-pre-wrap">{String(item.state.error)}</div>;
40
+ }
41
+ if (!this.$$modelStack) return <div class="loading loading-spinner"></div>;
42
+ const router = item.state.router;
43
+ if (!router) return <div class="text-error">Router unavailable</div>;
44
+ const vnode = h(ZRouterViewStack, {
45
+ pageHostProviders: {
46
+ [routedDialogContextKey]: item.state.context,
47
+ },
48
+ });
49
+ cast(vnode).zovaHostProviders = {
50
+ 'a-router.bean.router': router,
51
+ '$$modelStack': this.$$modelStack,
52
+ };
53
+ return vnode;
54
+ }
55
+ }
@@ -40,6 +40,25 @@ export const config = (_sys: ZovaSys) => {
40
40
  showCloseButton: false,
41
41
  },
42
42
  },
43
+ routedDialog: {
44
+ default: {
45
+ maxWidth: {
46
+ default: 640,
47
+ md: 768,
48
+ lg: 1024,
49
+ },
50
+ topGutter: {
51
+ default: 16,
52
+ md: 32,
53
+ lg: 48,
54
+ },
55
+ maxHeight: 'calc(100vh - 2rem)',
56
+ closeOnBackdrop: false,
57
+ closeOnEscape: true,
58
+ showCloseButton: true,
59
+ showBackButton: true,
60
+ },
61
+ },
43
62
  dialog: {
44
63
  default: {
45
64
  maxWidth: 640,
@@ -1,4 +1,5 @@
1
1
  export default {
2
+ Back: 'Back',
2
3
  Close: 'Close',
3
4
  Yes: 'Yes',
4
5
  No: 'No',
@@ -1,4 +1,5 @@
1
1
  export default {
2
+ Back: '返回',
2
3
  Close: '关闭',
3
4
  Yes: '是',
4
5
  No: '否',
@@ -0,0 +1,76 @@
1
+ import type { RouterHistory } from '@cabloy/vue-router';
2
+
3
+ import { createMemoryHistory } from '@cabloy/vue-router';
4
+
5
+ export class RoutedDialogHistory {
6
+ private readonly sourceHistory = createMemoryHistory();
7
+ private position = 0;
8
+ private length = 1;
9
+ private listeners = new Set<(canGoBack: boolean) => void>();
10
+ public readonly history: RouterHistory;
11
+
12
+ constructor() {
13
+ const sourceHistory = this.sourceHistory;
14
+ this.history = {
15
+ get base() {
16
+ return sourceHistory.base;
17
+ },
18
+ get location() {
19
+ return sourceHistory.location;
20
+ },
21
+ get state() {
22
+ return sourceHistory.state;
23
+ },
24
+ createHref: (...args) => sourceHistory.createHref(...args),
25
+ push: (...args) => {
26
+ this._push();
27
+ sourceHistory.push(...args);
28
+ },
29
+ replace: (...args) => {
30
+ sourceHistory.replace(...args);
31
+ },
32
+ go: (...args) => {
33
+ const [delta] = args;
34
+ const position = Math.max(0, Math.min(this.position + delta, this.length - 1));
35
+ this._setPosition(position);
36
+ sourceHistory.go(...args);
37
+ },
38
+ listen: (...args) => sourceHistory.listen(...args),
39
+ destroy: () => {
40
+ sourceHistory.destroy();
41
+ },
42
+ };
43
+ }
44
+
45
+ public get canGoBack() {
46
+ return this.position > 0;
47
+ }
48
+
49
+ public onCanGoBackChange(listener: (canGoBack: boolean) => void) {
50
+ this.listeners.add(listener);
51
+ return () => {
52
+ this.listeners.delete(listener);
53
+ };
54
+ }
55
+
56
+ public destroy() {
57
+ this.history.destroy();
58
+ this.position = 0;
59
+ this.length = 1;
60
+ this.listeners.clear();
61
+ }
62
+
63
+ private _push() {
64
+ this.length = this.position + 2;
65
+ this._setPosition(this.position + 1);
66
+ }
67
+
68
+ private _setPosition(position: number) {
69
+ const canGoBack = this.canGoBack;
70
+ this.position = position;
71
+ if (this.canGoBack === canGoBack) return;
72
+ for (const listener of this.listeners) {
73
+ listener(this.canGoBack);
74
+ }
75
+ }
76
+ }
@@ -1,7 +1,9 @@
1
+ import { shallowReactive } from 'vue';
1
2
  import { BeanBase } from 'zova';
2
3
  import { Service } from 'zova-module-a-bean';
3
4
 
4
5
  import { AppModalItem } from '../lib/appModalItem.js';
6
+ import { RoutedDialogHistory } from '../lib/routedDialogHistory.js';
5
7
  import {
6
8
  IModalAlertOptions,
7
9
  IModalConfirmOptions,
@@ -10,12 +12,29 @@ import {
10
12
  IModalItem,
11
13
  IModalMessageOptions,
12
14
  IModalPromptOptions,
15
+ IModalRoutedDialogOptions,
16
+ IModalRoutedDialogPresentationOptions,
17
+ IModalRoutedDialogItem,
18
+ IModalRoutedDialogState,
19
+ IRoutedDialogContext,
20
+ IRoutedDialogHandle,
13
21
  } from '../types/appModal.js';
14
22
 
15
23
  @Service()
16
24
  export class ServiceAppModal extends BeanBase {
17
25
  public modalItems: IModalItem[] = [];
18
26
  private modalItemIdCounter: number = 0;
27
+ private routedDialogReadySettlers = new Map<
28
+ number,
29
+ { resolve: () => void; reject: (error: unknown) => void }
30
+ >();
31
+
32
+ private routedDialogResultSettlers = new Map<
33
+ number,
34
+ { resolve: (value: unknown | undefined) => void; reject: (error: unknown) => void }
35
+ >();
36
+
37
+ private routedDialogHistories = new Map<number, RoutedDialogHistory>();
19
38
 
20
39
  protected async __init__() {}
21
40
 
@@ -89,10 +108,198 @@ export class ServiceAppModal extends BeanBase {
89
108
  return new AppModalItem(this, modalItem);
90
109
  }
91
110
 
92
- public close(id: number) {
111
+ public routedDialog<
112
+ TResult = unknown,
113
+ TProps extends Record<string, unknown> = Record<string, unknown>,
114
+ TSession = unknown,
115
+ >(
116
+ options: IModalRoutedDialogOptions<TProps, TSession>,
117
+ dialogOptions?: IModalRoutedDialogPresentationOptions,
118
+ ): IRoutedDialogHandle<TResult> {
119
+ const id = this.newModalItemId();
120
+ let resolveReady!: () => void;
121
+ let rejectReady!: (error: unknown) => void;
122
+ const ready = new Promise<void>((resolve, reject) => {
123
+ resolveReady = resolve;
124
+ rejectReady = reject;
125
+ });
126
+ let resolveResult!: (value: TResult | undefined) => void;
127
+ let rejectResult!: (error: unknown) => void;
128
+ const result = new Promise<TResult | undefined>((resolve, reject) => {
129
+ resolveResult = resolve;
130
+ rejectResult = reject;
131
+ });
132
+ // Both promises remain observable through the handle without becoming
133
+ // unhandled rejections when callers only need one lifecycle channel.
134
+ void ready.catch(() => {});
135
+ void result.catch(() => {});
136
+ let modalItem!: IModalRoutedDialogItem<TResult, TProps, TSession>;
137
+ const context: IRoutedDialogContext<TResult, TProps, TSession> = {
138
+ id,
139
+ props: options.props,
140
+ session: options.session as TSession,
141
+ resolve: value => this._resolveRoutedDialog(modalItem, value),
142
+ cancel: () => this.close(id),
143
+ };
144
+ modalItem = {
145
+ id,
146
+ type: 'routedDialog',
147
+ options,
148
+ dialogOptions,
149
+ state: shallowReactive<IModalRoutedDialogState<TResult, TProps, TSession>>({
150
+ status: 'loading',
151
+ canGoBack: false,
152
+ ready,
153
+ result,
154
+ context,
155
+ }),
156
+ };
157
+ this.modalItems.push(modalItem);
158
+ this.routedDialogReadySettlers.set(id, { resolve: resolveReady, reject: rejectReady });
159
+ this.routedDialogResultSettlers.set(id, {
160
+ resolve: value => resolveResult(value as TResult | undefined),
161
+ reject: rejectResult,
162
+ });
163
+ const handle = new AppModalItem(this, modalItem) as IRoutedDialogHandle<TResult>;
164
+ Object.defineProperties(handle, {
165
+ ready: { enumerable: true, get: () => ready },
166
+ result: { enumerable: true, get: () => result },
167
+ push: {
168
+ enumerable: true,
169
+ value: (to: any) => this._navigateRoutedDialog(modalItem, to, false),
170
+ },
171
+ replace: {
172
+ enumerable: true,
173
+ value: (to: any) => this._navigateRoutedDialog(modalItem, to, true),
174
+ },
175
+ });
176
+ void this._initRoutedDialog(modalItem);
177
+ return handle;
178
+ }
179
+
180
+ private async _initRoutedDialog(modalItem: Extract<IModalItem, { type: 'routedDialog' }>) {
181
+ let router: IModalRoutedDialogState['router'];
182
+ try {
183
+ if (!process.env.CLIENT) throw new Error('routedDialog is client-only');
184
+ const history = new RoutedDialogHistory();
185
+ this.routedDialogHistories.set(modalItem.id, history);
186
+ history.onCanGoBackChange(canGoBack => {
187
+ if (!this._isRoutedDialogOpen(modalItem)) return;
188
+ modalItem.state.canGoBack = canGoBack;
189
+ });
190
+ router = await this.app.bean._newBean('a-router.bean.router', false, {
191
+ mainRouter: false,
192
+ embeddedView: 'page',
193
+ prepareNavigation: to => this.app.meta.$router.ensureRoute(String(to)),
194
+ routerOptions: {
195
+ history: history.history,
196
+ },
197
+ });
198
+ router.afterEach((to, _from, failure) => {
199
+ if (failure || !this._isRoutedDialogOpen(modalItem)) return;
200
+ modalItem.state.currentRoute = to;
201
+ modalItem.state.canGoBack = history.canGoBack;
202
+ });
203
+ if (!this._isRoutedDialogOpen(modalItem)) {
204
+ router.dispose();
205
+ history.destroy();
206
+ this.routedDialogHistories.delete(modalItem.id);
207
+ return;
208
+ }
209
+ modalItem.state.router = router;
210
+ const result = await router.replace(modalItem.options.route as any);
211
+ if (result) throw result;
212
+ await router.isReady();
213
+ if (!this._isRoutedDialogOpen(modalItem)) {
214
+ router.dispose();
215
+ history.destroy();
216
+ this.routedDialogHistories.delete(modalItem.id);
217
+ modalItem.state.router = undefined;
218
+ return;
219
+ }
220
+ modalItem.state.currentRoute = router.currentRoute.value;
221
+ modalItem.state.canGoBack = history.canGoBack;
222
+ modalItem.state.status = 'ready';
223
+ this._settleRoutedDialogReady(modalItem.id, 'resolve');
224
+ } catch (error) {
225
+ router?.dispose();
226
+ this.routedDialogHistories.get(modalItem.id)?.destroy();
227
+ this.routedDialogHistories.delete(modalItem.id);
228
+ if (!this._isRoutedDialogOpen(modalItem)) return;
229
+ modalItem.state.router = undefined;
230
+ modalItem.state.status = 'error';
231
+ modalItem.state.error = error;
232
+ this._settleRoutedDialogReady(modalItem.id, 'reject', error);
233
+ this._settleRoutedDialogResult(modalItem.id, 'reject', error);
234
+ }
235
+ }
236
+
237
+ private async _navigateRoutedDialog(
238
+ modalItem: Extract<IModalItem, { type: 'routedDialog' }>,
239
+ to: any,
240
+ replace: boolean,
241
+ ) {
242
+ this._ensureRoutedDialogOpen(modalItem);
243
+ await modalItem.state.ready;
244
+ this._ensureRoutedDialogOpen(modalItem);
245
+ const router = modalItem.state.router;
246
+ if (!router) throw new Error('routedDialog router is unavailable');
247
+ const result = replace ? await router.replace(to) : await router.push(to);
248
+ this._ensureRoutedDialogOpen(modalItem);
249
+ return result;
250
+ }
251
+
252
+ private _isRoutedDialogOpen(modalItem: Extract<IModalItem, { type: 'routedDialog' }>) {
253
+ return !modalItem.state.closed && this.modalItems.some(item => item.id === modalItem.id);
254
+ }
255
+
256
+ private _ensureRoutedDialogOpen(modalItem: Extract<IModalItem, { type: 'routedDialog' }>) {
257
+ if (!this._isRoutedDialogOpen(modalItem)) {
258
+ throw new Error('routedDialog is closed');
259
+ }
260
+ }
261
+
262
+ private _settleRoutedDialogReady(id: number, action: 'resolve' | 'reject', error?: unknown) {
263
+ const settler = this.routedDialogReadySettlers.get(id);
264
+ if (!settler) return;
265
+ this.routedDialogReadySettlers.delete(id);
266
+ if (action === 'resolve') settler.resolve();
267
+ else settler.reject(error);
268
+ }
269
+
270
+ private _settleRoutedDialogResult(id: number, action: 'resolve' | 'reject', value?: unknown) {
271
+ const settler = this.routedDialogResultSettlers.get(id);
272
+ if (!settler) return;
273
+ this.routedDialogResultSettlers.delete(id);
274
+ if (action === 'resolve') settler.resolve(value);
275
+ else settler.reject(value);
276
+ }
277
+
278
+ private _resolveRoutedDialog(
279
+ modalItem: Extract<IModalItem, { type: 'routedDialog' }>,
280
+ value: unknown,
281
+ ) {
282
+ if (!this._isRoutedDialogOpen(modalItem)) return;
283
+ this._settleRoutedDialogResult(modalItem.id, 'resolve', value);
284
+ this.close(modalItem.id);
285
+ }
286
+
287
+ public close(id: number, _reason: string = 'api') {
93
288
  const [index, modalItem] = this.findModalItem(id);
94
289
  if (index === -1 || !modalItem) return;
95
290
  this.modalItems.splice(index, 1);
291
+ if (modalItem.type === 'routedDialog') {
292
+ modalItem.state.closed = true;
293
+ modalItem.state.status = 'closed';
294
+ modalItem.state.router?.dispose();
295
+ this.routedDialogHistories.get(modalItem.id)?.destroy();
296
+ this.routedDialogHistories.delete(modalItem.id);
297
+ modalItem.state.router = undefined;
298
+ this._settleRoutedDialogReady(modalItem.id, 'reject', new Error('routedDialog is closed'));
299
+ this._settleRoutedDialogResult(modalItem.id, 'resolve');
300
+ modalItem.options.onClose?.();
301
+ return;
302
+ }
96
303
  if (modalItem.type === 'dialog') {
97
304
  const options = modalItem.options as IModalDialogRenderOptions | undefined;
98
305
  options?.onClose?.();
@@ -1,14 +1,44 @@
1
+ import type {
2
+ NavigationFailure,
3
+ RouteLocationNormalizedLoadedGeneric,
4
+ RouteLocationRaw,
5
+ } from '@cabloy/vue-router';
1
6
  import type { VNode } from 'vue';
2
7
  import type { IIconRecord } from 'zova-module-a-icon';
8
+ import type { BeanRouter } from 'zova-module-a-router';
3
9
 
10
+ import type { AppModalItem } from '../lib/appModalItem.js';
4
11
  import type { ServiceAppModal } from '../service/appModal.js';
5
12
 
6
- export type ModalType = 'alert' | 'confirm' | 'prompt' | 'dialog';
13
+ export type ModalType = 'alert' | 'confirm' | 'prompt' | 'dialog' | 'routedDialog';
14
+ export type ModalCloseReason = 'api' | 'backdrop' | 'escape' | 'button' | 'error' | 'unmount';
7
15
  export type AlertType = 'success' | 'info' | 'warning' | 'error';
16
+ export type ModalWidth = number | string;
17
+
18
+ /**
19
+ * Mobile-first maximum-width values for routed dialogs.
20
+ *
21
+ * `md` and `lg` use the Cabloy Basic Tailwind breakpoints. Omitted values
22
+ * inherit the last applicable lower breakpoint through the CSS cascade.
23
+ */
24
+ export interface IModalResponsiveMaxWidth {
25
+ default?: ModalWidth;
26
+ md?: ModalWidth;
27
+ lg?: ModalWidth;
28
+ }
29
+
30
+ export interface IModalResponsiveTopGutter {
31
+ default?: ModalWidth;
32
+ md?: ModalWidth;
33
+ lg?: ModalWidth;
34
+ }
35
+
36
+ export type ModalRoutedDialogMaxWidth = ModalWidth | IModalResponsiveMaxWidth;
37
+ export type ModalRoutedDialogTopGutter = ModalWidth | IModalResponsiveTopGutter;
8
38
 
9
39
  export interface IModalBaseOptions {
10
- maxWidth?: number | string;
11
- maxHeight?: number | string;
40
+ maxWidth?: ModalWidth;
41
+ maxHeight?: ModalWidth;
12
42
  closeOnBackdrop?: boolean;
13
43
  closeOnEscape?: boolean;
14
44
  }
@@ -19,6 +49,15 @@ export interface IModalDialogOptions extends IModalBaseOptions {
19
49
 
20
50
  export interface IModalMessageOptions extends IModalBaseOptions {}
21
51
 
52
+ export interface IModalRoutedDialogPresentationOptions extends Omit<
53
+ IModalDialogOptions,
54
+ 'maxWidth'
55
+ > {
56
+ maxWidth?: ModalRoutedDialogMaxWidth;
57
+ topGutter?: ModalRoutedDialogTopGutter;
58
+ showBackButton?: boolean;
59
+ }
60
+
22
61
  export interface IModalDialogRenderContext {
23
62
  id: number;
24
63
  close: () => void;
@@ -32,6 +71,76 @@ export interface IModalDialogRenderOptions {
32
71
  onClose?: () => void;
33
72
  }
34
73
 
74
+ export const routedDialogContextKey = '$routedDialogContext';
75
+
76
+ export interface IRoutedDialogContext<
77
+ TResult = unknown,
78
+ TProps extends Record<string, unknown> = Record<string, unknown>,
79
+ TSession = unknown,
80
+ > {
81
+ readonly id: number;
82
+ readonly props?: TProps;
83
+ readonly session: TSession;
84
+ resolve(value: TResult): void;
85
+ cancel(): void;
86
+ }
87
+
88
+ export interface IModalRoutedDialogOptions<
89
+ TProps extends Record<string, unknown> = Record<string, unknown>,
90
+ TSession = unknown,
91
+ > extends IModalRoutedDialogPresentationOptions {
92
+ route: RouteLocationRaw;
93
+ icon?: keyof IIconRecord;
94
+ title?: string;
95
+ props?: TProps;
96
+ session?: TSession;
97
+ onClose?: () => void;
98
+ }
99
+
100
+ export interface IModalRoutedDialogState<
101
+ TResult = unknown,
102
+ TProps extends Record<string, unknown> = Record<string, unknown>,
103
+ TSession = unknown,
104
+ > {
105
+ status: 'loading' | 'ready' | 'error' | 'closed';
106
+ closed?: boolean;
107
+ error?: unknown;
108
+ router?: BeanRouter;
109
+ currentRoute?: RouteLocationNormalizedLoadedGeneric;
110
+ canGoBack: boolean;
111
+ ready: Promise<void>;
112
+ result: Promise<TResult | undefined>;
113
+ context: IRoutedDialogContext<TResult, TProps, TSession>;
114
+ }
115
+
116
+ export type IModalRoutedDialogInput<
117
+ TProps extends Record<string, unknown> = Record<string, unknown>,
118
+ TSession = unknown,
119
+ > = Omit<IModalRoutedDialogOptions<TProps, TSession>, 'route'>;
120
+
121
+ export interface IModalRoutedDialogNavigate {
122
+ navigate(to: RouteLocationRaw, replace?: boolean): Promise<NavigationFailure | void | undefined>;
123
+ }
124
+
125
+ export interface IModalRoutedDialogItem<
126
+ TResult = unknown,
127
+ TProps extends Record<string, unknown> = Record<string, unknown>,
128
+ TSession = unknown,
129
+ > {
130
+ id: number;
131
+ type: 'routedDialog';
132
+ options: IModalRoutedDialogOptions<TProps, TSession>;
133
+ dialogOptions?: IModalRoutedDialogPresentationOptions;
134
+ state: IModalRoutedDialogState<TResult, TProps, TSession>;
135
+ }
136
+
137
+ export interface IRoutedDialogHandle<TResult = unknown> extends AppModalItem {
138
+ readonly ready: Promise<void>;
139
+ readonly result: Promise<TResult | undefined>;
140
+ push(to: RouteLocationRaw): Promise<NavigationFailure | void | undefined>;
141
+ replace(to: RouteLocationRaw): Promise<NavigationFailure | void | undefined>;
142
+ }
143
+
35
144
  export interface IModalAlertOptions {
36
145
  type?: AlertType;
37
146
  icon?: keyof IIconRecord;
@@ -60,16 +169,18 @@ export interface IModalPromptOptionsInner extends IModalPromptOptions {
60
169
  onCallback?: (res: string | undefined) => void;
61
170
  }
62
171
 
63
- export interface IModalItem {
64
- id: number;
65
- type: ModalType;
66
- options?:
67
- | IModalAlertOptions
68
- | IModalConfirmOptionsInner
69
- | IModalPromptOptionsInner
70
- | IModalDialogRenderOptions;
71
- dialogOptions?: IModalDialogOptions | IModalMessageOptions;
72
- }
172
+ export type IModalItem =
173
+ | {
174
+ id: number;
175
+ type: 'alert' | 'confirm' | 'prompt' | 'dialog';
176
+ options?:
177
+ | IModalAlertOptions
178
+ | IModalConfirmOptionsInner
179
+ | IModalPromptOptionsInner
180
+ | IModalDialogRenderOptions;
181
+ dialogOptions?: IModalDialogOptions | IModalMessageOptions;
182
+ }
183
+ | IModalRoutedDialogItem;
73
184
 
74
185
  declare module 'zova' {
75
186
  export interface BeanBase {
@@ -0,0 +1,92 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+
4
+ import { RoutedDialogHistory } from '../../src/lib/routedDialogHistory.js';
5
+
6
+ function createHistory() {
7
+ const routedDialogHistory = new RoutedDialogHistory();
8
+ return { history: routedDialogHistory.history, routedDialogHistory };
9
+ }
10
+
11
+ test('routed dialog history establishes replace as the root entry', () => {
12
+ const { history, routedDialogHistory } = createHistory();
13
+
14
+ history.replace('/entry');
15
+
16
+ assert.equal(routedDialogHistory.canGoBack, false);
17
+ });
18
+
19
+ test('routed dialog history updates Back availability before navigation listeners', () => {
20
+ const { history, routedDialogHistory } = createHistory();
21
+ const canGoBackOnChange: boolean[] = [];
22
+ const canGoBackOnNavigation: boolean[] = [];
23
+
24
+ routedDialogHistory.onCanGoBackChange(canGoBack => {
25
+ canGoBackOnChange.push(canGoBack);
26
+ });
27
+ history.listen(() => {
28
+ canGoBackOnNavigation.push(routedDialogHistory.canGoBack);
29
+ });
30
+ history.replace('/entry');
31
+ history.push('/detail');
32
+ history.replace('/detail-edited');
33
+ assert.equal(routedDialogHistory.canGoBack, true);
34
+
35
+ history.go(-1);
36
+ assert.equal(routedDialogHistory.canGoBack, false);
37
+ history.go(1);
38
+ assert.equal(routedDialogHistory.canGoBack, true);
39
+
40
+ assert.deepEqual(canGoBackOnChange, [true, false, true]);
41
+ assert.deepEqual(canGoBackOnNavigation, [false, true]);
42
+ });
43
+
44
+ test('routed dialog history truncates forward entries after a new push', () => {
45
+ const { history, routedDialogHistory } = createHistory();
46
+
47
+ history.replace('/entry');
48
+ history.push('/detail/1');
49
+ history.push('/detail/2');
50
+ history.go(-1);
51
+ history.push('/detail/3');
52
+ history.go(1);
53
+ assert.equal(history.location, '/detail/3');
54
+ assert.equal(routedDialogHistory.canGoBack, true);
55
+
56
+ history.go(-2);
57
+ assert.equal(history.location, '/entry');
58
+ assert.equal(routedDialogHistory.canGoBack, false);
59
+ });
60
+
61
+ test('routed dialog history mirrors a silent rejected-pop repair', () => {
62
+ const { history, routedDialogHistory } = createHistory();
63
+
64
+ history.replace('/entry');
65
+ history.push('/detail');
66
+ history.go(-1);
67
+ assert.equal(routedDialogHistory.canGoBack, false);
68
+
69
+ // Vue Router repairs a rejected pop with history.go(-delta, false).
70
+ history.go(1, false);
71
+ assert.equal(history.location, '/detail');
72
+ assert.equal(routedDialogHistory.canGoBack, true);
73
+ });
74
+
75
+ test('routed dialog histories remain independent and reset after destruction', () => {
76
+ const first = createHistory();
77
+ const second = createHistory();
78
+ const firstChanges: boolean[] = [];
79
+
80
+ first.routedDialogHistory.onCanGoBackChange(canGoBack => {
81
+ firstChanges.push(canGoBack);
82
+ });
83
+ first.history.replace('/first');
84
+ second.history.replace('/second');
85
+ first.history.push('/first-detail');
86
+ assert.equal(first.routedDialogHistory.canGoBack, true);
87
+ assert.equal(second.routedDialogHistory.canGoBack, false);
88
+
89
+ first.routedDialogHistory.destroy();
90
+ assert.deepEqual(firstChanges, [true]);
91
+ assert.equal(first.routedDialogHistory.canGoBack, false);
92
+ });