@aerogel/core 0.0.0-next.88c59e62f64db70aedfbc4c31b5bbc287be44483 → 0.0.0-next.906ec80f260b7e5cb54a0c97bd4905bdaf4bf916

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