@aerogel/core 0.0.0-next.b85327579d32f21c6a9fa21142f0165cdd320d7e → 0.0.0-next.bb9dcdbb118a15d146d3a1c4cf861ca2f4f1eebd

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 (137) hide show
  1. package/dist/aerogel-core.cjs.js +1 -1
  2. package/dist/aerogel-core.cjs.js.map +1 -1
  3. package/dist/aerogel-core.d.ts +1880 -250
  4. package/dist/aerogel-core.esm.js +1 -1
  5. package/dist/aerogel-core.esm.js.map +1 -1
  6. package/histoire.config.ts +7 -0
  7. package/noeldemartin.config.js +4 -1
  8. package/package.json +13 -4
  9. package/postcss.config.js +6 -0
  10. package/src/assets/histoire.css +3 -0
  11. package/src/bootstrap/bootstrap.test.ts +3 -3
  12. package/src/bootstrap/index.ts +35 -5
  13. package/src/bootstrap/options.ts +3 -0
  14. package/src/components/AGAppLayout.vue +7 -2
  15. package/src/components/AGAppModals.vue +15 -0
  16. package/src/components/AGAppOverlays.vue +10 -8
  17. package/src/components/AGAppSnackbars.vue +13 -0
  18. package/src/components/composition.ts +23 -0
  19. package/src/components/constants.ts +8 -0
  20. package/src/components/forms/AGButton.vue +25 -15
  21. package/src/components/forms/AGCheckbox.vue +7 -1
  22. package/src/components/forms/AGForm.vue +9 -10
  23. package/src/components/forms/AGInput.vue +10 -6
  24. package/src/components/forms/AGSelect.story.vue +46 -0
  25. package/src/components/forms/AGSelect.vue +60 -0
  26. package/src/components/forms/index.ts +5 -6
  27. package/src/components/headless/forms/AGHeadlessButton.ts +3 -0
  28. package/src/components/headless/forms/AGHeadlessButton.vue +24 -12
  29. package/src/components/headless/forms/AGHeadlessInput.ts +30 -4
  30. package/src/components/headless/forms/AGHeadlessInput.vue +23 -7
  31. package/src/components/headless/forms/AGHeadlessInputDescription.vue +28 -0
  32. package/src/components/headless/forms/AGHeadlessInputInput.vue +44 -5
  33. package/src/components/headless/forms/AGHeadlessInputLabel.vue +8 -2
  34. package/src/components/headless/forms/AGHeadlessInputTextArea.vue +43 -0
  35. package/src/components/headless/forms/AGHeadlessSelect.ts +42 -0
  36. package/src/components/headless/forms/AGHeadlessSelect.vue +77 -0
  37. package/src/components/headless/forms/AGHeadlessSelectButton.vue +24 -0
  38. package/src/components/headless/forms/AGHeadlessSelectError.vue +26 -0
  39. package/src/components/headless/forms/AGHeadlessSelectLabel.vue +24 -0
  40. package/src/components/headless/forms/AGHeadlessSelectOption.ts +4 -0
  41. package/src/components/headless/forms/AGHeadlessSelectOption.vue +39 -0
  42. package/src/components/headless/forms/AGHeadlessSelectOptions.ts +3 -0
  43. package/src/components/headless/forms/composition.ts +10 -0
  44. package/src/components/headless/forms/index.ts +13 -1
  45. package/src/components/headless/index.ts +1 -0
  46. package/src/components/headless/modals/AGHeadlessModal.ts +29 -0
  47. package/src/components/headless/modals/AGHeadlessModal.vue +13 -9
  48. package/src/components/headless/modals/AGHeadlessModalPanel.vue +10 -6
  49. package/src/components/headless/modals/AGHeadlessModalTitle.vue +14 -4
  50. package/src/components/headless/modals/index.ts +4 -6
  51. package/src/components/headless/snackbars/AGHeadlessSnackbar.vue +10 -0
  52. package/src/components/headless/snackbars/index.ts +40 -0
  53. package/src/components/index.ts +5 -1
  54. package/src/components/interfaces.ts +24 -0
  55. package/src/components/lib/AGErrorMessage.vue +16 -0
  56. package/src/components/lib/AGLink.vue +9 -0
  57. package/src/components/lib/AGMarkdown.vue +54 -0
  58. package/src/components/lib/AGMeasured.vue +16 -0
  59. package/src/components/lib/AGProgressBar.vue +30 -0
  60. package/src/components/lib/AGStartupCrash.vue +31 -0
  61. package/src/components/lib/index.ts +6 -0
  62. package/src/components/modals/AGAlertModal.ts +18 -0
  63. package/src/components/modals/AGAlertModal.vue +4 -16
  64. package/src/components/modals/AGConfirmModal.ts +41 -0
  65. package/src/components/modals/AGConfirmModal.vue +10 -14
  66. package/src/components/modals/AGErrorReportModal.ts +49 -0
  67. package/src/components/modals/AGErrorReportModal.vue +54 -0
  68. package/src/components/modals/AGErrorReportModalButtons.vue +111 -0
  69. package/src/components/modals/AGErrorReportModalTitle.vue +25 -0
  70. package/src/components/modals/AGLoadingModal.ts +29 -0
  71. package/src/components/modals/AGLoadingModal.vue +4 -8
  72. package/src/components/modals/AGModal.ts +2 -1
  73. package/src/components/modals/AGModal.vue +16 -13
  74. package/src/components/modals/AGModalContext.vue +14 -4
  75. package/src/components/modals/AGModalTitle.vue +9 -0
  76. package/src/components/modals/AGPromptModal.ts +41 -0
  77. package/src/components/modals/AGPromptModal.vue +34 -0
  78. package/src/components/modals/index.ts +16 -7
  79. package/src/components/snackbars/AGSnackbar.vue +36 -0
  80. package/src/components/snackbars/index.ts +3 -0
  81. package/src/components/utils.ts +10 -0
  82. package/src/directives/index.ts +20 -3
  83. package/src/directives/measure.ts +40 -0
  84. package/src/errors/Errors.ts +65 -12
  85. package/src/errors/JobCancelledError.ts +3 -0
  86. package/src/errors/index.ts +26 -1
  87. package/src/errors/utils.ts +35 -0
  88. package/src/forms/Form.test.ts +28 -0
  89. package/src/forms/Form.ts +80 -14
  90. package/src/forms/index.ts +3 -1
  91. package/src/forms/utils.ts +34 -3
  92. package/src/forms/validation.ts +19 -0
  93. package/src/jobs/Job.ts +147 -0
  94. package/src/jobs/index.ts +10 -0
  95. package/src/jobs/listeners.ts +3 -0
  96. package/src/jobs/status.ts +4 -0
  97. package/src/lang/DefaultLangProvider.ts +43 -0
  98. package/src/lang/Lang.state.ts +11 -0
  99. package/src/lang/Lang.ts +44 -29
  100. package/src/main.histoire.ts +1 -0
  101. package/src/main.ts +3 -2
  102. package/src/plugins/Plugin.ts +1 -0
  103. package/src/plugins/index.ts +19 -0
  104. package/src/services/App.state.ts +25 -4
  105. package/src/services/App.ts +43 -5
  106. package/src/services/Cache.ts +43 -0
  107. package/src/services/Events.test.ts +39 -0
  108. package/src/services/Events.ts +111 -31
  109. package/src/services/Service.ts +151 -41
  110. package/src/services/Storage.ts +20 -0
  111. package/src/services/index.ts +16 -5
  112. package/src/services/store.ts +8 -5
  113. package/src/services/utils.ts +18 -0
  114. package/src/testing/index.ts +25 -0
  115. package/src/testing/setup.ts +27 -0
  116. package/src/ui/UI.state.ts +17 -1
  117. package/src/ui/UI.ts +267 -35
  118. package/src/ui/index.ts +13 -3
  119. package/src/ui/utils.ts +16 -0
  120. package/src/utils/composition/events.ts +1 -0
  121. package/src/utils/composition/persistent.test.ts +33 -0
  122. package/src/utils/composition/persistent.ts +11 -0
  123. package/src/utils/composition/state.test.ts +47 -0
  124. package/src/utils/composition/state.ts +24 -0
  125. package/src/utils/index.ts +2 -0
  126. package/src/utils/markdown.test.ts +50 -0
  127. package/src/utils/markdown.ts +26 -2
  128. package/src/utils/tailwindcss.test.ts +26 -0
  129. package/src/utils/tailwindcss.ts +7 -0
  130. package/src/utils/vue.ts +29 -6
  131. package/tailwind.config.js +4 -0
  132. package/tsconfig.json +1 -0
  133. package/vite.config.ts +6 -2
  134. package/.eslintrc.js +0 -3
  135. package/src/components/basic/AGMarkdown.vue +0 -35
  136. package/src/components/basic/index.ts +0 -3
  137. package/src/globals.ts +0 -6
package/src/ui/UI.ts CHANGED
@@ -1,12 +1,18 @@
1
- import { facade, fail, uuid } from '@noeldemartin/utils';
1
+ import { after, facade, fail, 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 App from '@/services/App';
6
7
  import Events from '@/services/Events';
8
+ import type { AcceptRefs } from '@/utils';
9
+ import type { Color } from '@/components/constants';
10
+ import type { SnackbarAction, SnackbarColor } from '@/components/headless/snackbars';
11
+ import type { AGAlertModalProps, AGConfirmModalProps, AGLoadingModalProps, AGPromptModalProps } from '@/components';
7
12
 
8
13
  import Service from './UI.state';
9
- import type { Modal, ModalComponent } from './UI.state';
14
+ import { MOBILE_BREAKPOINT, getCurrentLayout } from './utils';
15
+ import type { Modal, ModalComponent, Snackbar } from './UI.state';
10
16
 
11
17
  interface ModalCallbacks<T = unknown> {
12
18
  willClose(result: T | undefined): void;
@@ -21,49 +27,226 @@ type ModalResult<TComponent> = TComponent extends ModalComponent<Record<string,
21
27
  export const UIComponents = {
22
28
  AlertModal: 'alert-modal',
23
29
  ConfirmModal: 'confirm-modal',
30
+ ErrorReportModal: 'error-report-modal',
24
31
  LoadingModal: 'loading-modal',
32
+ PromptModal: 'prompt-modal',
33
+ Snackbar: 'snackbar',
34
+ StartupCrash: 'startup-crash',
25
35
  } as const;
26
36
 
27
37
  export type UIComponent = ObjectValues<typeof UIComponents>;
28
38
 
39
+ export type ConfirmCheckboxes = Record<string, { label: string; default?: boolean; required?: boolean }>;
40
+
41
+ export type ConfirmOptions = AcceptRefs<{
42
+ acceptText?: string;
43
+ acceptColor?: Color;
44
+ cancelText?: string;
45
+ cancelColor?: Color;
46
+ actions?: Record<string, () => unknown>;
47
+ }>;
48
+
49
+ export type LoadingOptions = AcceptRefs<{
50
+ title?: string;
51
+ message?: string;
52
+ progress?: number;
53
+ }>;
54
+
55
+ export interface ConfirmOptionsWithCheckboxes<T extends ConfirmCheckboxes = ConfirmCheckboxes> extends ConfirmOptions {
56
+ checkboxes?: T;
57
+ }
58
+
59
+ export type PromptOptions = AcceptRefs<{
60
+ label?: string;
61
+ defaultValue?: string;
62
+ placeholder?: string;
63
+ acceptText?: string;
64
+ acceptColor?: Color;
65
+ cancelText?: string;
66
+ cancelColor?: Color;
67
+ trim?: boolean;
68
+ }>;
69
+
70
+ export interface ShowSnackbarOptions {
71
+ component?: Component;
72
+ color?: SnackbarColor;
73
+ actions?: SnackbarAction[];
74
+ }
75
+
29
76
  export class UIService extends Service {
30
77
 
31
78
  private modalCallbacks: Record<string, Partial<ModalCallbacks>> = {};
32
79
  private components: Partial<Record<UIComponent, Component>> = {};
33
80
 
81
+ public requireComponent(name: UIComponent): Component {
82
+ return this.components[name] ?? fail(`UI Component '${name}' is not defined!`);
83
+ }
84
+
34
85
  public alert(message: string): void;
35
86
  public alert(title: string, message: string): void;
36
87
  public alert(messageOrTitle: string, message?: string): void {
37
- const options = typeof message === 'string' ? { title: messageOrTitle, message } : { message: messageOrTitle };
88
+ const getProperties = (): AGAlertModalProps => {
89
+ if (typeof message !== 'string') {
90
+ return { message: messageOrTitle };
91
+ }
38
92
 
39
- this.openModal(this.requireComponent(UIComponents.AlertModal), options);
93
+ return {
94
+ title: messageOrTitle,
95
+ message,
96
+ };
97
+ };
98
+
99
+ this.openModal(this.requireComponent(UIComponents.AlertModal), getProperties());
40
100
  }
41
101
 
42
- public async confirm(message: string): Promise<boolean>;
43
- public async confirm(title: string, message: string): Promise<boolean>;
44
- public async confirm(messageOrTitle: string, message?: string): Promise<boolean> {
45
- const options = typeof message === 'string' ? { title: messageOrTitle, message } : { message: messageOrTitle };
46
- const modal = await this.openModal<ModalComponent<{ message: string }, boolean>>(
47
- this.requireComponent(UIComponents.ConfirmModal),
48
- options,
49
- );
102
+ /* eslint-disable max-len */
103
+ public async confirm(message: string, options?: ConfirmOptions): Promise<boolean>;
104
+ public async confirm(title: string, message: string, options?: ConfirmOptions): Promise<boolean>;
105
+ public async confirm<T extends ConfirmCheckboxes>(message: string, options?: ConfirmOptionsWithCheckboxes<T>): Promise<[boolean, Record<keyof T, boolean>]>; // prettier-ignore
106
+ public async confirm<T extends ConfirmCheckboxes>(title: string, message: string, options?: ConfirmOptionsWithCheckboxes<T>): Promise<[boolean, Record<keyof T, boolean>]>; // prettier-ignore
107
+ /* eslint-enable max-len */
108
+
109
+ public async confirm(
110
+ messageOrTitle: string,
111
+ messageOrOptions?: string | ConfirmOptions | ConfirmOptionsWithCheckboxes,
112
+ options?: ConfirmOptions | ConfirmOptionsWithCheckboxes,
113
+ ): Promise<boolean | [boolean, Record<string, boolean>]> {
114
+ const getProperties = (): AGConfirmModalProps => {
115
+ if (typeof messageOrOptions !== 'string') {
116
+ return {
117
+ message: messageOrTitle,
118
+ ...(messageOrOptions ?? {}),
119
+ };
120
+ }
121
+
122
+ return {
123
+ title: messageOrTitle,
124
+ message: messageOrOptions,
125
+ ...(options ?? {}),
126
+ };
127
+ };
128
+ const properties = getProperties();
129
+ const modal = await this.openModal<
130
+ ModalComponent<AGConfirmModalProps, boolean | [boolean, Record<string, boolean>]>
131
+ >(this.requireComponent(UIComponents.ConfirmModal), properties);
50
132
  const result = await modal.beforeClose;
51
133
 
52
- return result ?? false;
134
+ const confirmed = typeof result === 'object' ? result[0] : result ?? false;
135
+ const checkboxes =
136
+ typeof result === 'object'
137
+ ? result[1]
138
+ : Object.entries(properties.checkboxes ?? {}).reduce(
139
+ (values, [checkbox, { default: defaultValue }]) => ({
140
+ [checkbox]: defaultValue ?? false,
141
+ ...values,
142
+ }),
143
+ {} as Record<string, boolean>,
144
+ );
145
+
146
+ for (const [name, checkbox] of Object.entries(properties.checkboxes ?? {})) {
147
+ if (!checkbox.required || checkboxes[name]) {
148
+ continue;
149
+ }
150
+
151
+ if (confirmed && App.development) {
152
+ // eslint-disable-next-line no-console
153
+ console.warn(`Confirmed confirm modal was suppressed because required '${name}' checkbox was missing`);
154
+ }
155
+
156
+ return [false, checkboxes];
157
+ }
158
+
159
+ return 'checkboxes' in properties ? [confirmed, checkboxes] : confirmed;
160
+ }
161
+
162
+ public async prompt(message: string, options?: PromptOptions): Promise<string | null>;
163
+ public async prompt(title: string, message: string, options?: PromptOptions): Promise<string | null>;
164
+ public async prompt(
165
+ messageOrTitle: string,
166
+ messageOrOptions?: string | PromptOptions,
167
+ options?: PromptOptions,
168
+ ): Promise<string | null> {
169
+ const trim = options?.trim ?? true;
170
+ const getProperties = (): AGPromptModalProps => {
171
+ if (typeof messageOrOptions !== 'string') {
172
+ return {
173
+ message: messageOrTitle,
174
+ ...(messageOrOptions ?? {}),
175
+ } as AGPromptModalProps;
176
+ }
177
+
178
+ return {
179
+ title: messageOrTitle,
180
+ message: messageOrOptions,
181
+ ...(options ?? {}),
182
+ } as AGPromptModalProps;
183
+ };
184
+
185
+ const modal = await this.openModal<ModalComponent<AGPromptModalProps, string | null>>(
186
+ this.requireComponent(UIComponents.PromptModal),
187
+ getProperties(),
188
+ );
189
+ const rawResult = await modal.beforeClose;
190
+ const result = trim && typeof rawResult === 'string' ? rawResult?.trim() : rawResult;
191
+
192
+ return result ?? null;
193
+ }
194
+
195
+ public async loading<T>(operation: Promise<T> | (() => T)): Promise<T>;
196
+ public async loading<T>(message: string, operation: Promise<T> | (() => T)): Promise<T>;
197
+ public async loading<T>(options: LoadingOptions, operation: Promise<T> | (() => T)): Promise<T>;
198
+ public async loading<T>(
199
+ operationOrMessageOrOptions: string | LoadingOptions | Promise<T> | (() => T),
200
+ operation?: Promise<T> | (() => T),
201
+ ): Promise<T> {
202
+ const processOperation = (o: Promise<T> | (() => T)) => (typeof o === 'function' ? Promise.resolve(o()) : o);
203
+ const processArgs = (): { operationPromise: Promise<T>; props?: AGLoadingModalProps } => {
204
+ if (typeof operationOrMessageOrOptions === 'string') {
205
+ return {
206
+ props: { message: operationOrMessageOrOptions },
207
+ operationPromise: processOperation(operation as Promise<T> | (() => T)),
208
+ };
209
+ }
210
+
211
+ if (typeof operationOrMessageOrOptions === 'function' || operationOrMessageOrOptions instanceof Promise) {
212
+ return { operationPromise: processOperation(operationOrMessageOrOptions) };
213
+ }
214
+
215
+ return {
216
+ props: operationOrMessageOrOptions,
217
+ operationPromise: processOperation(operation as Promise<T> | (() => T)),
218
+ };
219
+ };
220
+
221
+ const { operationPromise, props } = processArgs();
222
+ const modal = await this.openModal(this.requireComponent(UIComponents.LoadingModal), props);
223
+
224
+ try {
225
+ const [result] = await Promise.all([operationPromise, after({ seconds: 1 })]);
226
+
227
+ return result;
228
+ } finally {
229
+ await this.closeModal(modal.id);
230
+ }
53
231
  }
54
232
 
55
- public async loading<T>(operation: Promise<T>): Promise<T>;
56
- public async loading<T>(message: string, operation: Promise<T>): Promise<T>;
57
- public async loading<T>(messageOrOperation: string | Promise<T>, operation?: Promise<T>): Promise<T> {
58
- operation = typeof messageOrOperation === 'string' ? (operation as Promise<T>) : messageOrOperation;
233
+ public showSnackbar(message: string, options: ShowSnackbarOptions = {}): void {
234
+ const snackbar: Snackbar = {
235
+ id: uuid(),
236
+ properties: { message, ...options },
237
+ component: markRaw(options.component ?? this.requireComponent(UIComponents.Snackbar)),
238
+ };
59
239
 
60
- const message = typeof messageOrOperation === 'string' ? messageOrOperation : undefined;
61
- const modal = await this.openModal(this.requireComponent(UIComponents.LoadingModal), { message });
62
- const result = await operation;
240
+ this.setState('snackbars', this.snackbars.concat(snackbar));
63
241
 
64
- await this.closeModal(modal.id);
242
+ setTimeout(() => this.hideSnackbar(snackbar.id), 5000);
243
+ }
65
244
 
66
- return result;
245
+ public hideSnackbar(id: string): void {
246
+ this.setState(
247
+ 'snackbars',
248
+ this.snackbars.filter((snackbar) => snackbar.id !== id),
249
+ );
67
250
  }
68
251
 
69
252
  public registerComponent(name: UIComponent, component: Component): void {
@@ -101,17 +284,40 @@ export class UIService extends Service {
101
284
  }
102
285
 
103
286
  public async closeModal(id: string, result?: unknown): Promise<void> {
287
+ if (!App.isMounted()) {
288
+ await this.removeModal(id, result);
289
+
290
+ return;
291
+ }
292
+
104
293
  await Events.emit('close-modal', { id, result });
105
294
  }
106
295
 
107
- protected async boot(): Promise<void> {
108
- await super.boot();
296
+ public async closeAllModals(): Promise<void> {
297
+ while (this.modals.length > 0) {
298
+ await this.closeModal(required(this.modals[this.modals.length - 1]).id);
299
+ }
300
+ }
109
301
 
302
+ protected async boot(): Promise<void> {
110
303
  this.watchModalEvents();
304
+ this.watchMountedEvent();
305
+ this.watchViewportBreakpoints();
111
306
  }
112
307
 
113
- private requireComponent(name: UIComponent): Component {
114
- return this.components[name] ?? fail(`UI Component '${name}' is not defined!`);
308
+ private async removeModal(id: string, result?: unknown): Promise<void> {
309
+ this.setState(
310
+ 'modals',
311
+ this.modals.filter((m) => m.id !== id),
312
+ );
313
+
314
+ this.modalCallbacks[id]?.closed?.(result);
315
+
316
+ delete this.modalCallbacks[id];
317
+
318
+ const activeModal = this.modals.at(-1);
319
+
320
+ await (activeModal && Events.emit('show-modal', { id: activeModal.id }));
115
321
  }
116
322
 
117
323
  private watchModalEvents(): void {
@@ -123,29 +329,55 @@ export class UIService extends Service {
123
329
  }
124
330
  });
125
331
 
126
- Events.on('modal-closed', async ({ modal, result }) => {
127
- this.setState({ modals: this.modals.filter((m) => m.id !== modal.id) });
332
+ Events.on('modal-closed', async ({ modal: { id }, result }) => {
333
+ await this.removeModal(id, result);
334
+ });
335
+ }
128
336
 
129
- this.modalCallbacks[modal.id]?.closed?.(result);
337
+ private watchMountedEvent(): void {
338
+ Events.once('application-mounted', async () => {
339
+ if (!globalThis.document || !globalThis.getComputedStyle) {
340
+ return;
341
+ }
130
342
 
131
- delete this.modalCallbacks[modal.id];
343
+ const splash = globalThis.document.getElementById('splash');
132
344
 
133
- const activeModal = this.modals.at(-1);
345
+ if (!splash) {
346
+ return;
347
+ }
348
+
349
+ if (globalThis.getComputedStyle(splash).opacity !== '0') {
350
+ splash.style.opacity = '0';
351
+
352
+ await after({ ms: 600 });
353
+ }
134
354
 
135
- await (activeModal && Events.emit('show-modal', { id: activeModal.id }));
355
+ splash.remove();
136
356
  });
137
357
  }
138
358
 
359
+ private watchViewportBreakpoints(): void {
360
+ if (!globalThis.matchMedia) {
361
+ return;
362
+ }
363
+
364
+ const media = globalThis.matchMedia(`(min-width: ${MOBILE_BREAKPOINT}px)`);
365
+
366
+ media.addEventListener('change', () => this.setState({ layout: getCurrentLayout() }));
367
+ }
368
+
139
369
  }
140
370
 
141
- export default facade(new UIService());
371
+ export default facade(UIService);
142
372
 
143
373
  declare module '@/services/Events' {
144
374
  export interface EventsPayload {
145
- 'modal-will-close': { modal: Modal; result?: unknown };
146
- 'modal-closed': { modal: Modal; result?: unknown };
147
375
  'close-modal': { id: string; result?: unknown };
148
376
  'hide-modal': { id: string };
377
+ 'hide-overlays-backdrop': void;
378
+ 'modal-closed': { modal: Modal; result?: unknown };
379
+ 'modal-will-close': { modal: Modal; result?: unknown };
149
380
  'show-modal': { id: string };
381
+ 'show-overlays-backdrop': void;
150
382
  }
151
383
  }
package/src/ui/index.ts CHANGED
@@ -6,13 +6,19 @@ import { definePlugin } from '@/plugins';
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
+ import AGErrorReportModal from '../components/modals/AGErrorReportModal.vue';
9
10
  import AGLoadingModal from '../components/modals/AGLoadingModal.vue';
11
+ import AGPromptModal from '../components/modals/AGPromptModal.vue';
12
+ import AGSnackbar from '../components/snackbars/AGSnackbar.vue';
13
+ import AGStartupCrash from '../components/lib/AGStartupCrash.vue';
10
14
  import type { UIComponent } from './UI';
11
15
 
12
- export { UI, UIComponents, UIComponent };
13
-
14
16
  const services = { $ui: UI };
15
17
 
18
+ export * from './UI';
19
+ export * from './utils';
20
+ export { default as UI } from './UI';
21
+
16
22
  export type UIServices = typeof services;
17
23
 
18
24
  export default definePlugin({
@@ -20,7 +26,11 @@ export default definePlugin({
20
26
  const defaultComponents = {
21
27
  [UIComponents.AlertModal]: AGAlertModal,
22
28
  [UIComponents.ConfirmModal]: AGConfirmModal,
29
+ [UIComponents.ErrorReportModal]: AGErrorReportModal,
23
30
  [UIComponents.LoadingModal]: AGLoadingModal,
31
+ [UIComponents.PromptModal]: AGPromptModal,
32
+ [UIComponents.Snackbar]: AGSnackbar,
33
+ [UIComponents.StartupCrash]: AGStartupCrash,
24
34
  };
25
35
 
26
36
  Object.entries({
@@ -33,7 +43,7 @@ export default definePlugin({
33
43
  });
34
44
 
35
45
  declare module '@/bootstrap/options' {
36
- interface AerogelOptions {
46
+ export interface AerogelOptions {
37
47
  components?: Partial<Record<UIComponent, Component>>;
38
48
  }
39
49
  }
@@ -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
+ }
@@ -14,6 +14,7 @@ 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,6 @@
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 './tailwindcss';
4
6
  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
+ }
@@ -1,8 +1,32 @@
1
1
  import DOMPurify from 'dompurify';
2
- import { marked } from 'marked';
2
+ import { stringMatchAll, tap } from '@noeldemartin/utils';
3
+ import { Renderer, marked } from 'marked';
4
+
5
+ function makeRenderer(): Renderer {
6
+ return tap(new Renderer(), (renderer) => {
7
+ renderer.link = function(href, title, text) {
8
+ return Renderer.prototype.link.apply(this, [href, title, text]).replace('<a', '<a target="_blank"');
9
+ };
10
+ });
11
+ }
12
+
13
+ function renderActionLinks(html: string): string {
14
+ const matches = stringMatchAll<3>(html, /<a[^>]*href="#action:([^"]+)"[^>]*>([^<]+)<\/a>/g);
15
+
16
+ for (const [link, action, text] of matches) {
17
+ html = html.replace(link, `<button type="button" data-markdown-action="${action}">${text}</button>`);
18
+ }
19
+
20
+ return html;
21
+ }
3
22
 
4
23
  export function renderMarkdown(markdown: string): string {
5
- return safeHtml(marked(markdown, { mangle: false, headerIds: false }));
24
+ let html = marked(markdown, { mangle: false, headerIds: false, renderer: makeRenderer() });
25
+
26
+ html = safeHtml(html);
27
+ html = renderActionLinks(html);
28
+
29
+ return html;
6
30
  }
7
31
 
8
32
  export function safeHtml(html: string): string {