@aerogel/core 0.0.0-next.9a02fcd3bcf698211dd7a71d4c48257c96dd7832 → 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 (132) hide show
  1. package/dist/aerogel-core.d.ts +2130 -1361
  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 +4 -8
  6. package/src/bootstrap/index.ts +25 -16
  7. package/src/bootstrap/options.ts +1 -1
  8. package/src/components/AGAppLayout.vue +1 -1
  9. package/src/components/AGAppModals.vue +1 -1
  10. package/src/components/AGAppOverlays.vue +1 -1
  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 +4 -3
  18. package/src/components/forms/AGForm.vue +11 -12
  19. package/src/components/forms/AGInput.vue +8 -4
  20. package/src/components/forms/AGSelect.vue +11 -17
  21. package/src/components/headless/forms/AGHeadlessButton.ts +3 -0
  22. package/src/components/headless/forms/AGHeadlessButton.vue +16 -5
  23. package/src/components/headless/forms/AGHeadlessInput.ts +18 -5
  24. package/src/components/headless/forms/AGHeadlessInput.vue +19 -6
  25. package/src/components/headless/forms/AGHeadlessInputDescription.vue +28 -0
  26. package/src/components/headless/forms/AGHeadlessInputError.vue +2 -2
  27. package/src/components/headless/forms/AGHeadlessInputInput.vue +43 -6
  28. package/src/components/headless/forms/AGHeadlessInputLabel.vue +1 -1
  29. package/src/components/headless/forms/AGHeadlessInputTextArea.vue +43 -0
  30. package/src/components/headless/forms/AGHeadlessSelect.ts +3 -3
  31. package/src/components/headless/forms/AGHeadlessSelect.vue +15 -15
  32. package/src/components/headless/forms/AGHeadlessSelectError.vue +2 -2
  33. package/src/components/headless/forms/AGHeadlessSelectOption.vue +10 -18
  34. package/src/components/headless/forms/AGHeadlessSelectOptions.vue +19 -0
  35. package/src/components/headless/forms/AGHeadlessSelectTrigger.vue +25 -0
  36. package/src/components/headless/forms/composition.ts +10 -0
  37. package/src/components/headless/forms/index.ts +6 -3
  38. package/src/components/headless/modals/AGHeadlessModal.ts +17 -18
  39. package/src/components/headless/modals/AGHeadlessModal.vue +12 -10
  40. package/src/components/headless/modals/AGHeadlessModalContent.vue +25 -0
  41. package/src/components/headless/modals/index.ts +3 -2
  42. package/src/components/headless/snackbars/index.ts +3 -3
  43. package/src/components/index.ts +2 -0
  44. package/src/components/lib/AGErrorMessage.vue +3 -3
  45. package/src/components/lib/AGMarkdown.vue +24 -6
  46. package/src/components/lib/AGMeasured.vue +3 -2
  47. package/src/components/lib/AGProgressBar.vue +55 -0
  48. package/src/components/lib/AGStartupCrash.vue +1 -1
  49. package/src/components/lib/index.ts +1 -0
  50. package/src/components/modals/AGAlertModal.ts +6 -3
  51. package/src/components/modals/AGConfirmModal.ts +19 -4
  52. package/src/components/modals/AGConfirmModal.vue +6 -5
  53. package/src/components/modals/AGErrorReportModal.ts +8 -5
  54. package/src/components/modals/AGErrorReportModal.vue +2 -2
  55. package/src/components/modals/AGErrorReportModalButtons.vue +10 -10
  56. package/src/components/modals/AGErrorReportModalTitle.vue +2 -2
  57. package/src/components/modals/AGLoadingModal.ts +11 -5
  58. package/src/components/modals/AGModal.vue +20 -19
  59. package/src/components/modals/AGModalContext.ts +1 -1
  60. package/src/components/modals/AGModalContext.vue +15 -5
  61. package/src/components/modals/AGModalTitle.vue +1 -1
  62. package/src/components/modals/AGPromptModal.ts +15 -4
  63. package/src/components/modals/AGPromptModal.vue +5 -4
  64. package/src/components/modals/index.ts +0 -1
  65. package/src/components/snackbars/AGSnackbar.vue +2 -2
  66. package/src/components/utils.ts +62 -9
  67. package/src/directives/index.ts +11 -5
  68. package/src/directives/measure.ts +34 -6
  69. package/src/errors/Errors.state.ts +1 -1
  70. package/src/errors/Errors.ts +12 -12
  71. package/src/errors/JobCancelledError.ts +3 -0
  72. package/src/errors/index.ts +9 -6
  73. package/src/errors/utils.ts +17 -1
  74. package/src/forms/Form.test.ts +32 -3
  75. package/src/forms/Form.ts +80 -20
  76. package/src/forms/composition.ts +2 -2
  77. package/src/forms/index.ts +3 -1
  78. package/src/forms/utils.ts +34 -3
  79. package/src/forms/validation.ts +19 -0
  80. package/src/{main.ts → index.ts} +1 -0
  81. package/src/jobs/Job.ts +144 -2
  82. package/src/jobs/index.ts +4 -1
  83. package/src/jobs/listeners.ts +3 -0
  84. package/src/jobs/status.ts +4 -0
  85. package/src/lang/DefaultLangProvider.ts +46 -0
  86. package/src/lang/Lang.state.ts +11 -0
  87. package/src/lang/Lang.ts +48 -21
  88. package/src/lang/index.ts +8 -6
  89. package/src/plugins/Plugin.ts +1 -1
  90. package/src/plugins/index.ts +10 -7
  91. package/src/services/App.state.ts +26 -3
  92. package/src/services/App.ts +11 -3
  93. package/src/services/Cache.ts +43 -0
  94. package/src/services/Events.ts +15 -5
  95. package/src/services/Service.ts +125 -54
  96. package/src/services/Storage.ts +20 -0
  97. package/src/services/index.ts +13 -5
  98. package/src/services/utils.ts +18 -0
  99. package/src/testing/index.ts +4 -3
  100. package/src/testing/setup.ts +11 -0
  101. package/src/ui/UI.state.ts +9 -2
  102. package/src/ui/UI.ts +157 -52
  103. package/src/ui/index.ts +5 -4
  104. package/src/ui/utils.ts +16 -0
  105. package/src/utils/composition/events.ts +2 -2
  106. package/src/utils/composition/persistent.test.ts +33 -0
  107. package/src/utils/composition/persistent.ts +11 -0
  108. package/src/utils/composition/state.test.ts +47 -0
  109. package/src/utils/composition/state.ts +24 -0
  110. package/src/utils/index.ts +2 -0
  111. package/src/utils/markdown.test.ts +50 -0
  112. package/src/utils/markdown.ts +19 -6
  113. package/src/utils/vue.ts +22 -15
  114. package/dist/aerogel-core.cjs.js +0 -2
  115. package/dist/aerogel-core.cjs.js.map +0 -1
  116. package/dist/aerogel-core.esm.js +0 -2
  117. package/dist/aerogel-core.esm.js.map +0 -1
  118. package/histoire.config.ts +0 -7
  119. package/noeldemartin.config.js +0 -5
  120. package/postcss.config.js +0 -6
  121. package/src/assets/histoire.css +0 -3
  122. package/src/components/headless/forms/AGHeadlessSelectButton.vue +0 -24
  123. package/src/components/headless/forms/AGHeadlessSelectLabel.vue +0 -24
  124. package/src/components/headless/forms/AGHeadlessSelectOptions.ts +0 -3
  125. package/src/components/headless/modals/AGHeadlessModalPanel.vue +0 -28
  126. package/src/components/headless/modals/AGHeadlessModalTitle.vue +0 -13
  127. package/src/components/modals/AGModal.ts +0 -10
  128. package/src/directives/initial-focus.ts +0 -11
  129. package/src/main.histoire.ts +0 -1
  130. package/tailwind.config.js +0 -4
  131. package/tsconfig.json +0 -11
  132. package/vite.config.ts +0 -14
@@ -1,22 +1,28 @@
1
1
  import type { App as VueApp } from 'vue';
2
2
 
3
- import { definePlugin } from '@/plugins';
3
+ import { definePlugin } from '@aerogel/core/plugins';
4
+ import { isDevelopment, isTesting } from '@noeldemartin/utils';
4
5
 
5
6
  import App from './App';
7
+ import Cache from './Cache';
6
8
  import Events from './Events';
7
9
  import Service from './Service';
10
+ import Storage from './Storage';
8
11
  import { getPiniaStore } from './store';
9
12
 
10
13
  export * from './App';
14
+ export * from './Cache';
11
15
  export * from './Events';
12
16
  export * from './Service';
13
17
  export * from './store';
18
+ export * from './utils';
14
19
 
15
- export { App, Events, Service };
20
+ export { App, Cache, Events, Storage, Service };
16
21
 
17
22
  const defaultServices = {
18
23
  $app: App,
19
24
  $events: Events,
25
+ $storage: Storage,
20
26
  };
21
27
 
22
28
  export type DefaultServices = typeof defaultServices;
@@ -34,7 +40,9 @@ export async function bootServices(app: VueApp, services: Record<string, Service
34
40
 
35
41
  Object.assign(app.config.globalProperties, services);
36
42
 
37
- App.development && Object.assign(window, services);
43
+ if (isDevelopment() || isTesting()) {
44
+ Object.assign(globalThis, services);
45
+ }
38
46
  }
39
47
 
40
48
  export default definePlugin({
@@ -50,12 +58,12 @@ export default definePlugin({
50
58
  },
51
59
  });
52
60
 
53
- declare module '@/bootstrap/options' {
61
+ declare module '@aerogel/core/bootstrap/options' {
54
62
  export interface AerogelOptions {
55
63
  services?: Record<string, Service>;
56
64
  }
57
65
  }
58
66
 
59
- declare module '@vue/runtime-core' {
67
+ declare module 'vue' {
60
68
  interface ComponentCustomProperties extends Services {}
61
69
  }
@@ -0,0 +1,18 @@
1
+ import { objectOnly } from '@noeldemartin/utils';
2
+
3
+ export type Replace<
4
+ TOriginal extends Record<string, unknown>,
5
+ TReplacements extends Partial<Record<keyof TOriginal, unknown>>,
6
+ > = {
7
+ [K in keyof TOriginal]: TReplacements extends Record<K, infer Replacement> ? Replacement : TOriginal[K];
8
+ };
9
+
10
+ export function replaceExisting<
11
+ TOriginal extends Record<string, unknown>,
12
+ TReplacements extends Partial<Record<keyof TOriginal, unknown>>,
13
+ >(original: TOriginal, replacements: TReplacements): Replace<TOriginal, TReplacements> {
14
+ return {
15
+ ...original,
16
+ ...objectOnly(replacements, Object.keys(original)),
17
+ } as Replace<TOriginal, TReplacements>;
18
+ }
@@ -1,7 +1,8 @@
1
+ import { isTesting } from '@noeldemartin/utils';
1
2
  import type { GetClosureArgs } from '@noeldemartin/utils';
2
3
 
3
- import Events from '@/services/Events';
4
- import { definePlugin } from '@/plugins';
4
+ import Events from '@aerogel/core/services/Events';
5
+ import { definePlugin } from '@aerogel/core/plugins';
5
6
 
6
7
  export interface AerogelTestingRuntime {
7
8
  on: (typeof Events)['on'];
@@ -9,7 +10,7 @@ export interface AerogelTestingRuntime {
9
10
 
10
11
  export default definePlugin({
11
12
  async install() {
12
- if (import.meta.env.MODE !== 'testing') {
13
+ if (!isTesting()) {
13
14
  return;
14
15
  }
15
16
 
@@ -0,0 +1,11 @@
1
+ import { FakeLocalStorage } from '@noeldemartin/testing';
2
+ import { beforeEach, vi } from 'vitest';
3
+
4
+ vi.mock('dompurify', async () => {
5
+ return { default: { sanitize: (html: string) => html } };
6
+ });
7
+
8
+ beforeEach(() => {
9
+ FakeLocalStorage.reset();
10
+ FakeLocalStorage.patchGlobal();
11
+ });
@@ -1,6 +1,8 @@
1
1
  import type { Component } from 'vue';
2
2
 
3
- import { defineServiceState } from '@/services/Service';
3
+ import { defineServiceState } from '@aerogel/core/services/Service';
4
+
5
+ import { Layouts, getCurrentLayout } from './utils';
4
6
 
5
7
  export interface Modal<T = unknown> {
6
8
  id: string;
@@ -14,7 +16,7 @@ export interface ModalComponent<
14
16
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
15
17
  Properties extends Record<string, unknown> = Record<string, unknown>,
16
18
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
17
- Result = unknown
19
+ Result = unknown,
18
20
  > {}
19
21
 
20
22
  export interface Snackbar {
@@ -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,13 +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';
8
- import type { AGAlertModalProps, AGConfirmModalProps, AGLoadingModalProps, AGPromptModalProps } from '@/components';
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';
9
17
 
10
18
  import Service from './UI.state';
19
+ import { MOBILE_BREAKPOINT, getCurrentLayout } from './utils';
11
20
  import type { Modal, ModalComponent, Snackbar } from './UI.state';
12
21
 
13
22
  interface ModalCallbacks<T = unknown> {
@@ -16,9 +25,8 @@ interface ModalCallbacks<T = unknown> {
16
25
  }
17
26
 
18
27
  type ModalProperties<TComponent> = TComponent extends ModalComponent<infer TProperties, unknown> ? TProperties : never;
19
- type ModalResult<TComponent> = TComponent extends ModalComponent<Record<string, unknown>, infer TResult>
20
- ? TResult
21
- : never;
28
+ type ModalResult<TComponent> =
29
+ TComponent extends ModalComponent<Record<string, unknown>, infer TResult> ? TResult : never;
22
30
 
23
31
  export const UIComponents = {
24
32
  AlertModal: 'alert-modal',
@@ -32,18 +40,37 @@ export const UIComponents = {
32
40
 
33
41
  export type UIComponent = ObjectValues<typeof UIComponents>;
34
42
 
35
- export interface ConfirmOptions {
43
+ export type ConfirmCheckboxes = Record<string, { label: string; default?: boolean; required?: boolean }>;
44
+
45
+ export type ConfirmOptions = AcceptRefs<{
36
46
  acceptText?: string;
47
+ acceptColor?: Color;
37
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;
38
62
  }
39
63
 
40
- export interface PromptOptions {
64
+ export type PromptOptions = AcceptRefs<{
41
65
  label?: string;
42
66
  defaultValue?: string;
43
67
  placeholder?: string;
44
68
  acceptText?: string;
69
+ acceptColor?: Color;
45
70
  cancelText?: string;
46
- }
71
+ cancelColor?: Color;
72
+ trim?: boolean;
73
+ }>;
47
74
 
48
75
  export interface ShowSnackbarOptions {
49
76
  component?: Component;
@@ -77,35 +104,66 @@ export class UIService extends Service {
77
104
  this.openModal(this.requireComponent(UIComponents.AlertModal), getProperties());
78
105
  }
79
106
 
107
+ /* eslint-disable max-len */
80
108
  public async confirm(message: string, options?: ConfirmOptions): Promise<boolean>;
81
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
+
82
114
  public async confirm(
83
115
  messageOrTitle: string,
84
- messageOrOptions?: string | ConfirmOptions,
85
- options?: ConfirmOptions,
86
- ): Promise<boolean> {
116
+ messageOrOptions?: string | ConfirmOptions | ConfirmOptionsWithCheckboxes,
117
+ options?: ConfirmOptions | ConfirmOptionsWithCheckboxes,
118
+ ): Promise<boolean | [boolean, Record<string, boolean>]> {
87
119
  const getProperties = (): AGConfirmModalProps => {
88
120
  if (typeof messageOrOptions !== 'string') {
89
121
  return {
90
- message: messageOrTitle,
91
122
  ...(messageOrOptions ?? {}),
123
+ message: messageOrTitle,
124
+ required: !!messageOrOptions?.required,
92
125
  };
93
126
  }
94
127
 
95
128
  return {
129
+ ...(options ?? {}),
96
130
  title: messageOrTitle,
97
131
  message: messageOrOptions,
98
- ...(options ?? {}),
132
+ required: !!options?.required,
99
133
  };
100
134
  };
101
-
102
- const modal = await this.openModal<ModalComponent<AGConfirmModalProps, boolean>>(
103
- this.requireComponent(UIComponents.ConfirmModal),
104
- getProperties(),
105
- );
135
+ const properties = getProperties();
136
+ const modal = await this.openModal<
137
+ ModalComponent<AGConfirmModalProps, boolean | [boolean, Record<string, boolean>]>
138
+ >(this.requireComponent(UIComponents.ConfirmModal), properties);
106
139
  const result = await modal.beforeClose;
107
140
 
108
- 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;
109
167
  }
110
168
 
111
169
  public async prompt(message: string, options?: PromptOptions): Promise<string | null>;
@@ -115,47 +173,63 @@ export class UIService extends Service {
115
173
  messageOrOptions?: string | PromptOptions,
116
174
  options?: PromptOptions,
117
175
  ): Promise<string | null> {
176
+ const trim = options?.trim ?? true;
118
177
  const getProperties = (): AGPromptModalProps => {
119
178
  if (typeof messageOrOptions !== 'string') {
120
179
  return {
121
180
  message: messageOrTitle,
122
181
  ...(messageOrOptions ?? {}),
123
- };
182
+ } as AGPromptModalProps;
124
183
  }
125
184
 
126
185
  return {
127
186
  title: messageOrTitle,
128
187
  message: messageOrOptions,
129
188
  ...(options ?? {}),
130
- };
189
+ } as AGPromptModalProps;
131
190
  };
132
191
 
133
192
  const modal = await this.openModal<ModalComponent<AGPromptModalProps, string | null>>(
134
193
  this.requireComponent(UIComponents.PromptModal),
135
194
  getProperties(),
136
195
  );
137
- const result = await modal.beforeClose;
196
+ const rawResult = await modal.beforeClose;
197
+ const result = trim && typeof rawResult === 'string' ? rawResult?.trim() : rawResult;
138
198
 
139
199
  return result ?? null;
140
200
  }
141
201
 
142
- public async loading<T>(operation: Promise<T>): Promise<T>;
143
- public async loading<T>(message: string, operation: Promise<T>): Promise<T>;
144
- public async loading<T>(messageOrOperation: string | Promise<T>, operation?: Promise<T>): Promise<T> {
145
- const getProperties = (): AGLoadingModalProps => {
146
- if (typeof messageOrOperation !== 'string') {
147
- return {};
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
+ };
148
216
  }
149
217
 
150
- return { message: messageOrOperation };
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
+ };
151
226
  };
152
227
 
153
- const modal = await this.openModal(this.requireComponent(UIComponents.LoadingModal), getProperties());
228
+ const { operationPromise, props } = processArgs();
229
+ const modal = await this.openModal(this.requireComponent(UIComponents.LoadingModal), props);
154
230
 
155
231
  try {
156
- operation = typeof messageOrOperation === 'string' ? (operation as Promise<T>) : messageOrOperation;
157
-
158
- const [result] = await Promise.all([operation, after({ seconds: 1 })]);
232
+ const [result] = await Promise.all([operationPromise, after({ seconds: 1 })]);
159
233
 
160
234
  return result;
161
235
  } finally {
@@ -217,12 +291,40 @@ export class UIService extends Service {
217
291
  }
218
292
 
219
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
+
220
300
  await Events.emit('close-modal', { id, result });
221
301
  }
222
302
 
223
- 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> {
224
310
  this.watchModalEvents();
225
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 }));
226
328
  }
227
329
 
228
330
  private watchModalEvents(): void {
@@ -234,31 +336,24 @@ export class UIService extends Service {
234
336
  }
235
337
  });
236
338
 
237
- Events.on('modal-closed', async ({ modal, result }) => {
238
- this.setState(
239
- 'modals',
240
- this.modals.filter((m) => m.id !== modal.id),
241
- );
242
-
243
- this.modalCallbacks[modal.id]?.closed?.(result);
244
-
245
- delete this.modalCallbacks[modal.id];
246
-
247
- const activeModal = this.modals.at(-1);
248
-
249
- await (activeModal && Events.emit('show-modal', { id: activeModal.id }));
339
+ Events.on('modal-closed', async ({ modal: { id }, result }) => {
340
+ await this.removeModal(id, result);
250
341
  });
251
342
  }
252
343
 
253
344
  private watchMountedEvent(): void {
254
345
  Events.once('application-mounted', async () => {
255
- const splash = document.getElementById('splash');
346
+ if (!globalThis.document || !globalThis.getComputedStyle) {
347
+ return;
348
+ }
349
+
350
+ const splash = globalThis.document.getElementById('splash');
256
351
 
257
352
  if (!splash) {
258
353
  return;
259
354
  }
260
355
 
261
- if (window.getComputedStyle(splash).opacity !== '0') {
356
+ if (globalThis.getComputedStyle(splash).opacity !== '0') {
262
357
  splash.style.opacity = '0';
263
358
 
264
359
  await after({ ms: 600 });
@@ -268,11 +363,21 @@ export class UIService extends Service {
268
363
  });
269
364
  }
270
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
+
271
376
  }
272
377
 
273
378
  export default facade(UIService);
274
379
 
275
- declare module '@/services/Events' {
380
+ declare module '@aerogel/core/services/Events' {
276
381
  export interface EventsPayload {
277
382
  'close-modal': { id: string; result?: unknown };
278
383
  'hide-modal': { id: string };
package/src/ui/index.ts CHANGED
@@ -1,7 +1,7 @@
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';
@@ -16,6 +16,7 @@ import type { UIComponent } from './UI';
16
16
  const services = { $ui: UI };
17
17
 
18
18
  export * from './UI';
19
+ export * from './utils';
19
20
  export { default as UI } from './UI';
20
21
 
21
22
  export type UIServices = typeof services;
@@ -41,12 +42,12 @@ export default definePlugin({
41
42
  },
42
43
  });
43
44
 
44
- declare module '@/bootstrap/options' {
45
+ declare module '@aerogel/core/bootstrap/options' {
45
46
  export interface AerogelOptions {
46
47
  components?: Partial<Record<UIComponent, Component>>;
47
48
  }
48
49
  }
49
50
 
50
- declare module '@/services' {
51
+ declare module '@aerogel/core/services' {
51
52
  export interface Services extends UIServices {}
52
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,13 +1,13 @@
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>(
@@ -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,5 +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';
4
6
  export * from './tailwindcss';
5
7
  export * from './vue';