@aerogel/core 0.0.0-next.980a397d575dcb5ff8c5a0bff769d09f938ea03c → 0.0.0-next.9a1c5ba39a454b316eba36ec7bdf579fed3d95d2

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 (139) hide show
  1. package/dist/aerogel-core.d.ts +2255 -1046
  2. package/dist/aerogel-core.js +2763 -0
  3. package/dist/aerogel-core.js.map +1 -0
  4. package/package.json +23 -37
  5. package/src/bootstrap/bootstrap.test.ts +7 -11
  6. package/src/bootstrap/index.ts +36 -16
  7. package/src/bootstrap/options.ts +4 -1
  8. package/src/components/AGAppLayout.vue +3 -2
  9. package/src/components/AGAppModals.vue +1 -1
  10. package/src/components/AGAppOverlays.vue +6 -2
  11. package/src/components/AGAppSnackbars.vue +1 -1
  12. package/src/components/composition.ts +23 -0
  13. package/src/components/contracts/Modal.ts +16 -0
  14. package/src/components/contracts/index.ts +2 -0
  15. package/src/components/contracts/shared.ts +9 -0
  16. package/src/components/forms/AGButton.vue +2 -2
  17. package/src/components/forms/AGCheckbox.vue +10 -3
  18. package/src/components/forms/AGForm.vue +11 -12
  19. package/src/components/forms/AGInput.vue +13 -7
  20. package/src/components/forms/AGSelect.story.vue +21 -3
  21. package/src/components/forms/AGSelect.vue +20 -19
  22. package/src/components/headless/forms/AGHeadlessButton.ts +3 -0
  23. package/src/components/headless/forms/AGHeadlessButton.vue +24 -13
  24. package/src/components/headless/forms/AGHeadlessInput.ts +22 -14
  25. package/src/components/headless/forms/AGHeadlessInput.vue +19 -6
  26. package/src/components/headless/forms/AGHeadlessInputDescription.vue +28 -0
  27. package/src/components/headless/forms/AGHeadlessInputError.vue +2 -2
  28. package/src/components/headless/forms/AGHeadlessInputInput.vue +45 -6
  29. package/src/components/headless/forms/AGHeadlessInputLabel.vue +1 -1
  30. package/src/components/headless/forms/AGHeadlessInputTextArea.vue +43 -0
  31. package/src/components/headless/forms/AGHeadlessSelect.ts +20 -22
  32. package/src/components/headless/forms/AGHeadlessSelect.vue +34 -33
  33. package/src/components/headless/forms/AGHeadlessSelectError.vue +2 -2
  34. package/src/components/headless/forms/AGHeadlessSelectOption.vue +14 -22
  35. package/src/components/headless/forms/AGHeadlessSelectOptions.vue +19 -0
  36. package/src/components/headless/forms/AGHeadlessSelectTrigger.vue +25 -0
  37. package/src/components/headless/forms/composition.ts +10 -0
  38. package/src/components/headless/forms/index.ts +6 -3
  39. package/src/components/headless/modals/AGHeadlessModal.ts +25 -8
  40. package/src/components/headless/modals/AGHeadlessModal.vue +12 -12
  41. package/src/components/headless/modals/AGHeadlessModalContent.vue +25 -0
  42. package/src/components/headless/modals/index.ts +3 -2
  43. package/src/components/headless/snackbars/index.ts +25 -10
  44. package/src/components/index.ts +2 -0
  45. package/src/components/lib/AGErrorMessage.vue +4 -4
  46. package/src/components/lib/AGMarkdown.vue +24 -6
  47. package/src/components/lib/AGMeasured.vue +16 -0
  48. package/src/components/lib/AGProgressBar.vue +55 -0
  49. package/src/components/lib/AGStartupCrash.vue +1 -1
  50. package/src/components/lib/index.ts +2 -0
  51. package/src/components/modals/AGAlertModal.ts +18 -0
  52. package/src/components/modals/AGAlertModal.vue +3 -14
  53. package/src/components/modals/AGConfirmModal.ts +33 -1
  54. package/src/components/modals/AGConfirmModal.vue +10 -13
  55. package/src/components/modals/AGErrorReportModal.ts +32 -3
  56. package/src/components/modals/AGErrorReportModal.vue +9 -17
  57. package/src/components/modals/AGErrorReportModalButtons.vue +14 -12
  58. package/src/components/modals/AGErrorReportModalTitle.vue +2 -2
  59. package/src/components/modals/AGLoadingModal.ts +21 -1
  60. package/src/components/modals/AGLoadingModal.vue +3 -7
  61. package/src/components/modals/AGModal.vue +25 -22
  62. package/src/components/modals/AGModalContext.ts +1 -1
  63. package/src/components/modals/AGModalContext.vue +15 -5
  64. package/src/components/modals/AGModalTitle.vue +1 -1
  65. package/src/components/modals/AGPromptModal.ts +41 -0
  66. package/src/components/modals/AGPromptModal.vue +35 -0
  67. package/src/components/modals/index.ts +11 -20
  68. package/src/components/snackbars/AGSnackbar.vue +3 -9
  69. package/src/components/utils.ts +63 -0
  70. package/src/directives/index.ts +13 -5
  71. package/src/directives/measure.ts +40 -0
  72. package/src/errors/Errors.state.ts +1 -1
  73. package/src/errors/Errors.ts +32 -30
  74. package/src/errors/JobCancelledError.ts +3 -0
  75. package/src/errors/index.ts +11 -17
  76. package/src/errors/utils.ts +35 -0
  77. package/src/forms/Form.test.ts +32 -3
  78. package/src/forms/Form.ts +81 -20
  79. package/src/forms/composition.ts +2 -2
  80. package/src/forms/index.ts +3 -1
  81. package/src/forms/utils.ts +34 -3
  82. package/src/forms/validation.ts +19 -0
  83. package/src/{main.ts → index.ts} +3 -0
  84. package/src/jobs/Job.ts +147 -0
  85. package/src/jobs/index.ts +10 -0
  86. package/src/jobs/listeners.ts +3 -0
  87. package/src/jobs/status.ts +4 -0
  88. package/src/lang/DefaultLangProvider.ts +46 -0
  89. package/src/lang/Lang.state.ts +11 -0
  90. package/src/lang/Lang.ts +44 -29
  91. package/src/lang/index.ts +8 -6
  92. package/src/plugins/Plugin.ts +1 -1
  93. package/src/plugins/index.ts +10 -7
  94. package/src/services/App.state.ts +27 -5
  95. package/src/services/App.ts +32 -6
  96. package/src/services/Cache.ts +43 -0
  97. package/src/services/Events.test.ts +39 -0
  98. package/src/services/Events.ts +112 -32
  99. package/src/services/Service.ts +150 -49
  100. package/src/services/Storage.ts +20 -0
  101. package/src/services/index.ts +15 -6
  102. package/src/services/store.ts +8 -5
  103. package/src/services/utils.ts +18 -0
  104. package/src/testing/index.ts +26 -0
  105. package/src/testing/setup.ts +11 -0
  106. package/src/ui/UI.state.ts +9 -2
  107. package/src/ui/UI.ts +233 -45
  108. package/src/ui/index.ts +13 -7
  109. package/src/ui/utils.ts +16 -0
  110. package/src/utils/composition/events.ts +3 -2
  111. package/src/utils/composition/persistent.test.ts +33 -0
  112. package/src/utils/composition/persistent.ts +11 -0
  113. package/src/utils/composition/state.test.ts +47 -0
  114. package/src/utils/composition/state.ts +24 -0
  115. package/src/utils/index.ts +3 -0
  116. package/src/utils/markdown.test.ts +50 -0
  117. package/src/utils/markdown.ts +19 -6
  118. package/src/utils/tailwindcss.test.ts +26 -0
  119. package/src/utils/tailwindcss.ts +7 -0
  120. package/src/utils/vue.ts +33 -17
  121. package/dist/aerogel-core.cjs.js +0 -2
  122. package/dist/aerogel-core.cjs.js.map +0 -1
  123. package/dist/aerogel-core.esm.js +0 -2
  124. package/dist/aerogel-core.esm.js.map +0 -1
  125. package/histoire.config.ts +0 -7
  126. package/noeldemartin.config.js +0 -5
  127. package/postcss.config.js +0 -6
  128. package/src/assets/histoire.css +0 -3
  129. package/src/components/headless/forms/AGHeadlessSelectButton.vue +0 -24
  130. package/src/components/headless/forms/AGHeadlessSelectLabel.vue +0 -24
  131. package/src/components/headless/forms/AGHeadlessSelectOptions.ts +0 -3
  132. package/src/components/headless/modals/AGHeadlessModalPanel.vue +0 -28
  133. package/src/components/headless/modals/AGHeadlessModalTitle.vue +0 -13
  134. package/src/components/modals/AGModal.ts +0 -10
  135. package/src/directives/initial-focus.ts +0 -11
  136. package/src/main.histoire.ts +0 -1
  137. package/tailwind.config.js +0 -4
  138. package/tsconfig.json +0 -11
  139. package/vite.config.ts +0 -14
package/src/ui/UI.ts CHANGED
@@ -1,12 +1,22 @@
1
- import { after, facade, fail, uuid } from '@noeldemartin/utils';
1
+ import { after, facade, fail, isDevelopment, required, uuid } from '@noeldemartin/utils';
2
2
  import { markRaw, nextTick } from 'vue';
3
3
  import type { Component } from 'vue';
4
4
  import type { ObjectValues } from '@noeldemartin/utils';
5
5
 
6
- import Events from '@/services/Events';
7
- import type { SnackbarAction, SnackbarColor } from '@/components/headless/snackbars';
6
+ import App from '@aerogel/core/services/App';
7
+ import Events from '@aerogel/core/services/Events';
8
+ import type { AcceptRefs } from '@aerogel/core/utils';
9
+ import type { Color } from '@aerogel/core/components/constants';
10
+ import type { SnackbarAction, SnackbarColor } from '@aerogel/core/components/headless/snackbars';
11
+ import type {
12
+ AGAlertModalProps,
13
+ AGConfirmModalProps,
14
+ AGLoadingModalProps,
15
+ AGPromptModalProps,
16
+ } from '@aerogel/core/components';
8
17
 
9
18
  import Service from './UI.state';
19
+ import { MOBILE_BREAKPOINT, getCurrentLayout } from './utils';
10
20
  import type { Modal, ModalComponent, Snackbar } from './UI.state';
11
21
 
12
22
  interface ModalCallbacks<T = unknown> {
@@ -15,20 +25,53 @@ interface ModalCallbacks<T = unknown> {
15
25
  }
16
26
 
17
27
  type ModalProperties<TComponent> = TComponent extends ModalComponent<infer TProperties, unknown> ? TProperties : never;
18
- type ModalResult<TComponent> = TComponent extends ModalComponent<Record<string, unknown>, infer TResult>
19
- ? TResult
20
- : never;
28
+ type ModalResult<TComponent> =
29
+ TComponent extends ModalComponent<Record<string, unknown>, infer TResult> ? TResult : never;
21
30
 
22
31
  export const UIComponents = {
23
32
  AlertModal: 'alert-modal',
24
33
  ConfirmModal: 'confirm-modal',
25
34
  ErrorReportModal: 'error-report-modal',
26
35
  LoadingModal: 'loading-modal',
36
+ PromptModal: 'prompt-modal',
27
37
  Snackbar: 'snackbar',
38
+ StartupCrash: 'startup-crash',
28
39
  } as const;
29
40
 
30
41
  export type UIComponent = ObjectValues<typeof UIComponents>;
31
42
 
43
+ export type ConfirmCheckboxes = Record<string, { label: string; default?: boolean; required?: boolean }>;
44
+
45
+ export type ConfirmOptions = AcceptRefs<{
46
+ acceptText?: string;
47
+ acceptColor?: Color;
48
+ cancelText?: string;
49
+ cancelColor?: Color;
50
+ actions?: Record<string, () => unknown>;
51
+ required?: boolean;
52
+ }>;
53
+
54
+ export type LoadingOptions = AcceptRefs<{
55
+ title?: string;
56
+ message?: string;
57
+ progress?: number;
58
+ }>;
59
+
60
+ export interface ConfirmOptionsWithCheckboxes<T extends ConfirmCheckboxes = ConfirmCheckboxes> extends ConfirmOptions {
61
+ checkboxes?: T;
62
+ }
63
+
64
+ export type PromptOptions = AcceptRefs<{
65
+ label?: string;
66
+ defaultValue?: string;
67
+ placeholder?: string;
68
+ acceptText?: string;
69
+ acceptColor?: Color;
70
+ cancelText?: string;
71
+ cancelColor?: Color;
72
+ trim?: boolean;
73
+ }>;
74
+
32
75
  export interface ShowSnackbarOptions {
33
76
  component?: Component;
34
77
  color?: SnackbarColor;
@@ -47,34 +90,146 @@ export class UIService extends Service {
47
90
  public alert(message: string): void;
48
91
  public alert(title: string, message: string): void;
49
92
  public alert(messageOrTitle: string, message?: string): void {
50
- const options = typeof message === 'string' ? { title: messageOrTitle, message } : { message: messageOrTitle };
93
+ const getProperties = (): AGAlertModalProps => {
94
+ if (typeof message !== 'string') {
95
+ return { message: messageOrTitle };
96
+ }
51
97
 
52
- this.openModal(this.requireComponent(UIComponents.AlertModal), options);
98
+ return {
99
+ title: messageOrTitle,
100
+ message,
101
+ };
102
+ };
103
+
104
+ this.openModal(this.requireComponent(UIComponents.AlertModal), getProperties());
53
105
  }
54
106
 
55
- public async confirm(message: string): Promise<boolean>;
56
- public async confirm(title: string, message: string): Promise<boolean>;
57
- public async confirm(messageOrTitle: string, message?: string): Promise<boolean> {
58
- const options = typeof message === 'string' ? { title: messageOrTitle, message } : { message: messageOrTitle };
59
- const modal = await this.openModal<ModalComponent<{ message: string }, boolean>>(
60
- this.requireComponent(UIComponents.ConfirmModal),
61
- options,
62
- );
107
+ /* eslint-disable max-len */
108
+ public async confirm(message: string, options?: ConfirmOptions): Promise<boolean>;
109
+ public async confirm(title: string, message: string, options?: ConfirmOptions): Promise<boolean>;
110
+ public async confirm<T extends ConfirmCheckboxes>(message: string, options?: ConfirmOptionsWithCheckboxes<T>): Promise<[boolean, Record<keyof T, boolean>]>; // prettier-ignore
111
+ public async confirm<T extends ConfirmCheckboxes>(title: string, message: string, options?: ConfirmOptionsWithCheckboxes<T>): Promise<[boolean, Record<keyof T, boolean>]>; // prettier-ignore
112
+ /* eslint-enable max-len */
113
+
114
+ public async confirm(
115
+ messageOrTitle: string,
116
+ messageOrOptions?: string | ConfirmOptions | ConfirmOptionsWithCheckboxes,
117
+ options?: ConfirmOptions | ConfirmOptionsWithCheckboxes,
118
+ ): Promise<boolean | [boolean, Record<string, boolean>]> {
119
+ const getProperties = (): AGConfirmModalProps => {
120
+ if (typeof messageOrOptions !== 'string') {
121
+ return {
122
+ ...(messageOrOptions ?? {}),
123
+ message: messageOrTitle,
124
+ required: !!messageOrOptions?.required,
125
+ };
126
+ }
127
+
128
+ return {
129
+ ...(options ?? {}),
130
+ title: messageOrTitle,
131
+ message: messageOrOptions,
132
+ required: !!options?.required,
133
+ };
134
+ };
135
+ const properties = getProperties();
136
+ const modal = await this.openModal<
137
+ ModalComponent<AGConfirmModalProps, boolean | [boolean, Record<string, boolean>]>
138
+ >(this.requireComponent(UIComponents.ConfirmModal), properties);
63
139
  const result = await modal.beforeClose;
64
140
 
65
- return result ?? false;
141
+ const confirmed = typeof result === 'object' ? result[0] : (result ?? false);
142
+ const checkboxes =
143
+ typeof result === 'object'
144
+ ? result[1]
145
+ : Object.entries(properties.checkboxes ?? {}).reduce(
146
+ (values, [checkbox, { default: defaultValue }]) => ({
147
+ [checkbox]: defaultValue ?? false,
148
+ ...values,
149
+ }),
150
+ {} as Record<string, boolean>,
151
+ );
152
+
153
+ for (const [name, checkbox] of Object.entries(properties.checkboxes ?? {})) {
154
+ if (!checkbox.required || checkboxes[name]) {
155
+ continue;
156
+ }
157
+
158
+ if (confirmed && isDevelopment()) {
159
+ // eslint-disable-next-line no-console
160
+ console.warn(`Confirmed confirm modal was suppressed because required '${name}' checkbox was missing`);
161
+ }
162
+
163
+ return [false, checkboxes];
164
+ }
165
+
166
+ return 'checkboxes' in properties ? [confirmed, checkboxes] : confirmed;
66
167
  }
67
168
 
68
- public async loading<T>(operation: Promise<T>): Promise<T>;
69
- public async loading<T>(message: string, operation: Promise<T>): Promise<T>;
70
- public async loading<T>(messageOrOperation: string | Promise<T>, operation?: Promise<T>): Promise<T> {
71
- operation = typeof messageOrOperation === 'string' ? (operation as Promise<T>) : messageOrOperation;
169
+ public async prompt(message: string, options?: PromptOptions): Promise<string | null>;
170
+ public async prompt(title: string, message: string, options?: PromptOptions): Promise<string | null>;
171
+ public async prompt(
172
+ messageOrTitle: string,
173
+ messageOrOptions?: string | PromptOptions,
174
+ options?: PromptOptions,
175
+ ): Promise<string | null> {
176
+ const trim = options?.trim ?? true;
177
+ const getProperties = (): AGPromptModalProps => {
178
+ if (typeof messageOrOptions !== 'string') {
179
+ return {
180
+ message: messageOrTitle,
181
+ ...(messageOrOptions ?? {}),
182
+ } as AGPromptModalProps;
183
+ }
184
+
185
+ return {
186
+ title: messageOrTitle,
187
+ message: messageOrOptions,
188
+ ...(options ?? {}),
189
+ } as AGPromptModalProps;
190
+ };
191
+
192
+ const modal = await this.openModal<ModalComponent<AGPromptModalProps, string | null>>(
193
+ this.requireComponent(UIComponents.PromptModal),
194
+ getProperties(),
195
+ );
196
+ const rawResult = await modal.beforeClose;
197
+ const result = trim && typeof rawResult === 'string' ? rawResult?.trim() : rawResult;
72
198
 
73
- const message = typeof messageOrOperation === 'string' ? messageOrOperation : undefined;
74
- const modal = await this.openModal(this.requireComponent(UIComponents.LoadingModal), { message });
199
+ return result ?? null;
200
+ }
201
+
202
+ public async loading<T>(operation: Promise<T> | (() => T)): Promise<T>;
203
+ public async loading<T>(message: string, operation: Promise<T> | (() => T)): Promise<T>;
204
+ public async loading<T>(options: LoadingOptions, operation: Promise<T> | (() => T)): Promise<T>;
205
+ public async loading<T>(
206
+ operationOrMessageOrOptions: string | LoadingOptions | Promise<T> | (() => T),
207
+ operation?: Promise<T> | (() => T),
208
+ ): Promise<T> {
209
+ const processOperation = (o: Promise<T> | (() => T)) => (typeof o === 'function' ? Promise.resolve(o()) : o);
210
+ const processArgs = (): { operationPromise: Promise<T>; props?: AGLoadingModalProps } => {
211
+ if (typeof operationOrMessageOrOptions === 'string') {
212
+ return {
213
+ props: { message: operationOrMessageOrOptions },
214
+ operationPromise: processOperation(operation as Promise<T> | (() => T)),
215
+ };
216
+ }
217
+
218
+ if (typeof operationOrMessageOrOptions === 'function' || operationOrMessageOrOptions instanceof Promise) {
219
+ return { operationPromise: processOperation(operationOrMessageOrOptions) };
220
+ }
221
+
222
+ return {
223
+ props: operationOrMessageOrOptions,
224
+ operationPromise: processOperation(operation as Promise<T> | (() => T)),
225
+ };
226
+ };
227
+
228
+ const { operationPromise, props } = processArgs();
229
+ const modal = await this.openModal(this.requireComponent(UIComponents.LoadingModal), props);
75
230
 
76
231
  try {
77
- const [result] = await Promise.all([operation, after({ seconds: 1 })]);
232
+ const [result] = await Promise.all([operationPromise, after({ seconds: 1 })]);
78
233
 
79
234
  return result;
80
235
  } finally {
@@ -86,7 +241,7 @@ export class UIService extends Service {
86
241
  const snackbar: Snackbar = {
87
242
  id: uuid(),
88
243
  properties: { message, ...options },
89
- component: options.component ?? markRaw(this.requireComponent(UIComponents.Snackbar)),
244
+ component: markRaw(options.component ?? this.requireComponent(UIComponents.Snackbar)),
90
245
  };
91
246
 
92
247
  this.setState('snackbars', this.snackbars.concat(snackbar));
@@ -136,12 +291,40 @@ export class UIService extends Service {
136
291
  }
137
292
 
138
293
  public async closeModal(id: string, result?: unknown): Promise<void> {
294
+ if (!App.isMounted()) {
295
+ await this.removeModal(id, result);
296
+
297
+ return;
298
+ }
299
+
139
300
  await Events.emit('close-modal', { id, result });
140
301
  }
141
302
 
142
- protected async boot(): Promise<void> {
303
+ public async closeAllModals(): Promise<void> {
304
+ while (this.modals.length > 0) {
305
+ await this.closeModal(required(this.modals[this.modals.length - 1]).id);
306
+ }
307
+ }
308
+
309
+ protected override async boot(): Promise<void> {
143
310
  this.watchModalEvents();
144
311
  this.watchMountedEvent();
312
+ this.watchViewportBreakpoints();
313
+ }
314
+
315
+ private async removeModal(id: string, result?: unknown): Promise<void> {
316
+ this.setState(
317
+ 'modals',
318
+ this.modals.filter((m) => m.id !== id),
319
+ );
320
+
321
+ this.modalCallbacks[id]?.closed?.(result);
322
+
323
+ delete this.modalCallbacks[id];
324
+
325
+ const activeModal = this.modals.at(-1);
326
+
327
+ await (activeModal && Events.emit('show-modal', { id: activeModal.id }));
145
328
  }
146
329
 
147
330
  private watchModalEvents(): void {
@@ -153,31 +336,24 @@ export class UIService extends Service {
153
336
  }
154
337
  });
155
338
 
156
- Events.on('modal-closed', async ({ modal, result }) => {
157
- this.setState(
158
- 'modals',
159
- this.modals.filter((m) => m.id !== modal.id),
160
- );
161
-
162
- this.modalCallbacks[modal.id]?.closed?.(result);
163
-
164
- delete this.modalCallbacks[modal.id];
165
-
166
- const activeModal = this.modals.at(-1);
167
-
168
- await (activeModal && Events.emit('show-modal', { id: activeModal.id }));
339
+ Events.on('modal-closed', async ({ modal: { id }, result }) => {
340
+ await this.removeModal(id, result);
169
341
  });
170
342
  }
171
343
 
172
344
  private watchMountedEvent(): void {
173
345
  Events.once('application-mounted', async () => {
174
- const splash = document.getElementById('splash');
346
+ if (!globalThis.document || !globalThis.getComputedStyle) {
347
+ return;
348
+ }
349
+
350
+ const splash = globalThis.document.getElementById('splash');
175
351
 
176
352
  if (!splash) {
177
353
  return;
178
354
  }
179
355
 
180
- if (window.getComputedStyle(splash).opacity !== '0') {
356
+ if (globalThis.getComputedStyle(splash).opacity !== '0') {
181
357
  splash.style.opacity = '0';
182
358
 
183
359
  await after({ ms: 600 });
@@ -187,16 +363,28 @@ export class UIService extends Service {
187
363
  });
188
364
  }
189
365
 
366
+ private watchViewportBreakpoints(): void {
367
+ if (!globalThis.matchMedia) {
368
+ return;
369
+ }
370
+
371
+ const media = globalThis.matchMedia(`(min-width: ${MOBILE_BREAKPOINT}px)`);
372
+
373
+ media.addEventListener('change', () => this.setState({ layout: getCurrentLayout() }));
374
+ }
375
+
190
376
  }
191
377
 
192
- export default facade(new UIService());
378
+ export default facade(UIService);
193
379
 
194
- declare module '@/services/Events' {
380
+ declare module '@aerogel/core/services/Events' {
195
381
  export interface EventsPayload {
196
- 'modal-will-close': { modal: Modal; result?: unknown };
197
- 'modal-closed': { modal: Modal; result?: unknown };
198
382
  'close-modal': { id: string; result?: unknown };
199
383
  'hide-modal': { id: string };
384
+ 'hide-overlays-backdrop': void;
385
+ 'modal-closed': { modal: Modal; result?: unknown };
386
+ 'modal-will-close': { modal: Modal; result?: unknown };
200
387
  'show-modal': { id: string };
388
+ 'show-overlays-backdrop': void;
201
389
  }
202
390
  }
package/src/ui/index.ts CHANGED
@@ -1,20 +1,24 @@
1
1
  import type { Component } from 'vue';
2
2
 
3
- import { bootServices } from '@/services';
4
- import { definePlugin } from '@/plugins';
3
+ import { bootServices } from '@aerogel/core/services';
4
+ import { definePlugin } from '@aerogel/core/plugins';
5
5
 
6
6
  import UI, { UIComponents } from './UI';
7
7
  import AGAlertModal from '../components/modals/AGAlertModal.vue';
8
8
  import AGConfirmModal from '../components/modals/AGConfirmModal.vue';
9
9
  import AGErrorReportModal from '../components/modals/AGErrorReportModal.vue';
10
10
  import AGLoadingModal from '../components/modals/AGLoadingModal.vue';
11
+ import AGPromptModal from '../components/modals/AGPromptModal.vue';
11
12
  import AGSnackbar from '../components/snackbars/AGSnackbar.vue';
13
+ import AGStartupCrash from '../components/lib/AGStartupCrash.vue';
12
14
  import type { UIComponent } from './UI';
13
15
 
14
- export { UI, UIComponents, UIComponent };
15
-
16
16
  const services = { $ui: UI };
17
17
 
18
+ export * from './UI';
19
+ export * from './utils';
20
+ export { default as UI } from './UI';
21
+
18
22
  export type UIServices = typeof services;
19
23
 
20
24
  export default definePlugin({
@@ -24,7 +28,9 @@ export default definePlugin({
24
28
  [UIComponents.ConfirmModal]: AGConfirmModal,
25
29
  [UIComponents.ErrorReportModal]: AGErrorReportModal,
26
30
  [UIComponents.LoadingModal]: AGLoadingModal,
31
+ [UIComponents.PromptModal]: AGPromptModal,
27
32
  [UIComponents.Snackbar]: AGSnackbar,
33
+ [UIComponents.StartupCrash]: AGStartupCrash,
28
34
  };
29
35
 
30
36
  Object.entries({
@@ -36,12 +42,12 @@ export default definePlugin({
36
42
  },
37
43
  });
38
44
 
39
- declare module '@/bootstrap/options' {
40
- interface AerogelOptions {
45
+ declare module '@aerogel/core/bootstrap/options' {
46
+ export interface AerogelOptions {
41
47
  components?: Partial<Record<UIComponent, Component>>;
42
48
  }
43
49
  }
44
50
 
45
- declare module '@/services' {
51
+ declare module '@aerogel/core/services' {
46
52
  export interface Services extends UIServices {}
47
53
  }
@@ -0,0 +1,16 @@
1
+ export const MOBILE_BREAKPOINT = 768;
2
+
3
+ export const Layouts = {
4
+ Mobile: 'mobile',
5
+ Desktop: 'desktop',
6
+ } as const;
7
+
8
+ export type Layout = (typeof Layouts)[keyof typeof Layouts];
9
+
10
+ export function getCurrentLayout(): Layout {
11
+ if (globalThis.innerWidth > MOBILE_BREAKPOINT) {
12
+ return Layouts.Desktop;
13
+ }
14
+
15
+ return Layouts.Mobile;
16
+ }
@@ -1,19 +1,20 @@
1
1
  import { onUnmounted } from 'vue';
2
2
 
3
- import Events from '@/services/Events';
3
+ import Events from '@aerogel/core/services/Events';
4
4
  import type {
5
5
  EventListener,
6
6
  EventWithPayload,
7
7
  EventWithoutPayload,
8
8
  EventsPayload,
9
9
  UnknownEvent,
10
- } from '@/services/Events';
10
+ } from '@aerogel/core/services/Events';
11
11
 
12
12
  export function useEvent<Event extends EventWithoutPayload>(event: Event, listener: () => unknown): void;
13
13
  export function useEvent<Event extends EventWithPayload>(
14
14
  event: Event,
15
15
  listener: EventListener<EventsPayload[Event]>
16
16
  ): void;
17
+ export function useEvent<Payload>(event: string, listener: (payload: Payload) => unknown): void;
17
18
  export function useEvent<Event extends string>(event: UnknownEvent<Event>, listener: EventListener): void;
18
19
 
19
20
  export function useEvent(event: string, listener: EventListener): void {
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { nextTick } from 'vue';
3
+ import { Storage } from '@noeldemartin/utils';
4
+
5
+ import { persistent } from './persistent';
6
+
7
+ describe('Vue persistent helper', () => {
8
+
9
+ it('serializes to localStorage', async () => {
10
+ // Arrange
11
+ const store = persistent<{ foo?: string }>('foobar', {});
12
+
13
+ // Act
14
+ store.foo = 'bar';
15
+
16
+ await nextTick();
17
+
18
+ // Assert
19
+ expect(Storage.get('foobar')).toEqual({ foo: 'bar' });
20
+ });
21
+
22
+ it('reads from localStorage', async () => {
23
+ // Arrange
24
+ Storage.set('foobar', { foo: 'bar' });
25
+
26
+ // Act
27
+ const store = persistent<{ foo?: string }>('foobar', {});
28
+
29
+ // Assert
30
+ expect(store.foo).toEqual('bar');
31
+ });
32
+
33
+ });
@@ -0,0 +1,11 @@
1
+ import { reactive, toRaw, watch } from 'vue';
2
+ import { Storage } from '@noeldemartin/utils';
3
+ import type { UnwrapNestedRefs } from 'vue';
4
+
5
+ export function persistent<T extends object>(name: string, defaults: T): UnwrapNestedRefs<T> {
6
+ const store = reactive<T>(Storage.get<T>(name) ?? defaults);
7
+
8
+ watch(store, () => Storage.set(name, toRaw(store)));
9
+
10
+ return store;
11
+ }
@@ -0,0 +1,47 @@
1
+ import { after } from '@noeldemartin/utils';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { ref } from 'vue';
4
+
5
+ import { computedDebounce } from './state';
6
+
7
+ describe('Vue state helpers', () => {
8
+
9
+ it('computes debounced state', async () => {
10
+ // Initial
11
+ const state = ref(0);
12
+ const value = computedDebounce({ delay: 90 }, () => state.value);
13
+
14
+ expect(value.value).toBe(null);
15
+
16
+ await after({ ms: 100 });
17
+
18
+ expect(value.value).toBe(0);
19
+
20
+ // Update
21
+ state.value = 42;
22
+
23
+ expect(value.value).toBe(0);
24
+
25
+ await after({ ms: 100 });
26
+
27
+ expect(value.value).toBe(42);
28
+
29
+ // Debounced Update
30
+ state.value = 23;
31
+
32
+ expect(value.value).toBe(42);
33
+
34
+ await after({ ms: 50 });
35
+
36
+ state.value = 32;
37
+
38
+ await after({ ms: 50 });
39
+
40
+ expect(value.value).toBe(42);
41
+
42
+ await after({ ms: 100 });
43
+
44
+ expect(value.value).toBe(32);
45
+ });
46
+
47
+ });
@@ -0,0 +1,24 @@
1
+ import { debounce } from '@noeldemartin/utils';
2
+ import { ref, watchEffect } from 'vue';
3
+ import type { ComputedGetter, ComputedRef } from '@vue/runtime-core';
4
+
5
+ export interface ComputedDebounceOptions<T> {
6
+ initial?: T;
7
+ delay?: number;
8
+ }
9
+
10
+ export function computedDebounce<T>(options: ComputedDebounceOptions<T>, getter: ComputedGetter<T>): ComputedRef<T>;
11
+ export function computedDebounce<T>(getter: ComputedGetter<T>): ComputedRef<T | null>;
12
+ export function computedDebounce<T>(
13
+ optionsOrGetter: ComputedGetter<T> | ComputedDebounceOptions<T>,
14
+ inputGetter?: ComputedGetter<T>,
15
+ ): ComputedRef<T> {
16
+ const inputOptions = inputGetter ? (optionsOrGetter as ComputedDebounceOptions<T>) : {};
17
+ const getter = inputGetter ?? (optionsOrGetter as ComputedGetter<T>);
18
+ const state = ref(inputOptions.initial ?? null);
19
+ const update = debounce((value) => (state.value = value), inputOptions.delay ?? 300);
20
+
21
+ watchEffect(() => update(getter()));
22
+
23
+ return state as unknown as ComputedRef<T>;
24
+ }
@@ -1,4 +1,7 @@
1
1
  export * from './composition/events';
2
2
  export * from './composition/forms';
3
3
  export * from './composition/hooks';
4
+ export * from './composition/persistent';
5
+ export * from './markdown';
6
+ export * from './tailwindcss';
4
7
  export * from './vue';
@@ -0,0 +1,50 @@
1
+ /* eslint-disable max-len */
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import { renderMarkdown } from './markdown';
5
+
6
+ describe('Markdown utils', () => {
7
+
8
+ it('renders basic markdown', () => {
9
+ // Arrange
10
+ const expectedHTML = `
11
+ <h1>Title</h1>
12
+ <p>body with <a target="_blank" href="https://example.com">link</a></p>
13
+ <ul>
14
+ <li>One</li>
15
+ <li>Two</li>
16
+ <li>Three</li>
17
+ </ul>
18
+ `;
19
+
20
+ // Act
21
+ const html = renderMarkdown(
22
+ ['# Title', 'body with [link](https://example.com)', '- One', '- Two', '- Three'].join('\n'),
23
+ );
24
+
25
+ // Assert
26
+ expect(normalizeHTML(html)).toMatch(new RegExp(normalizeHTML(expectedHTML)));
27
+ });
28
+
29
+ it('renders button links', () => {
30
+ // Arrange
31
+ const expectedHTML = `
32
+ <p><button type="button" data-markdown-action="do-something">link</button></p>
33
+ `;
34
+
35
+ // Act
36
+ const html = renderMarkdown('[link](#action:do-something)');
37
+
38
+ // Assert
39
+ expect(normalizeHTML(html)).toMatch(new RegExp(normalizeHTML(expectedHTML)));
40
+ });
41
+
42
+ });
43
+
44
+ function normalizeHTML(html: string): string {
45
+ return html
46
+ .split('\n')
47
+ .map((line) => line.trim())
48
+ .join('\n')
49
+ .trim();
50
+ }