@aerogel/core 0.0.0-next.b243de4e2590f02709edeebd8c13b74087592c04 → 0.0.0-next.b3caf219a503ce9b8c65ef1463132c9507f56c0a

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 (162) hide show
  1. package/dist/aerogel-core.d.ts +1346 -1647
  2. package/dist/aerogel-core.js +2960 -0
  3. package/dist/aerogel-core.js.map +1 -0
  4. package/package.json +27 -37
  5. package/src/bootstrap/bootstrap.test.ts +4 -7
  6. package/src/bootstrap/index.ts +25 -16
  7. package/src/bootstrap/options.ts +1 -1
  8. package/src/components/{AGAppLayout.vue → AppLayout.vue} +4 -4
  9. package/src/components/{AGAppModals.vue → AppModals.vue} +3 -4
  10. package/src/components/{AGAppOverlays.vue → AppOverlays.vue} +5 -5
  11. package/src/components/composition.ts +1 -1
  12. package/src/components/contracts/AlertModal.ts +4 -0
  13. package/src/components/contracts/Button.ts +15 -0
  14. package/src/components/contracts/ConfirmModal.ts +41 -0
  15. package/src/components/contracts/ErrorReportModal.ts +29 -0
  16. package/src/components/contracts/Input.ts +26 -0
  17. package/src/components/contracts/LoadingModal.ts +18 -0
  18. package/src/components/contracts/Modal.ts +9 -0
  19. package/src/components/contracts/PromptModal.ts +28 -0
  20. package/src/components/contracts/index.ts +7 -0
  21. package/src/components/contracts/shared.ts +9 -0
  22. package/src/components/forms/AGSelect.vue +11 -17
  23. package/src/components/forms/index.ts +0 -4
  24. package/src/components/headless/HeadlessButton.vue +45 -0
  25. package/src/components/headless/HeadlessInput.vue +59 -0
  26. package/src/components/headless/{forms/AGHeadlessInputDescription.vue → HeadlessInputDescription.vue} +5 -6
  27. package/src/components/headless/{forms/AGHeadlessInputError.vue → HeadlessInputError.vue} +4 -8
  28. package/src/components/headless/{forms/AGHeadlessInputInput.vue → HeadlessInputInput.vue} +10 -19
  29. package/src/components/headless/{forms/AGHeadlessInputLabel.vue → HeadlessInputLabel.vue} +3 -7
  30. package/src/components/headless/{forms/AGHeadlessInputTextArea.vue → HeadlessInputTextArea.vue} +6 -9
  31. package/src/components/headless/{modals/AGHeadlessModal.vue → HeadlessModal.vue} +16 -18
  32. package/src/components/headless/HeadlessModalContent.vue +24 -0
  33. package/src/components/headless/HeadlessModalOverlay.vue +12 -0
  34. package/src/components/headless/HeadlessModalTitle.vue +12 -0
  35. package/src/components/headless/forms/AGHeadlessSelect.ts +3 -3
  36. package/src/components/headless/forms/AGHeadlessSelect.vue +16 -16
  37. package/src/components/headless/forms/AGHeadlessSelectError.vue +2 -2
  38. package/src/components/headless/forms/AGHeadlessSelectOption.vue +10 -18
  39. package/src/components/headless/forms/AGHeadlessSelectOptions.vue +19 -0
  40. package/src/components/headless/forms/AGHeadlessSelectTrigger.vue +25 -0
  41. package/src/components/headless/forms/composition.ts +2 -2
  42. package/src/components/headless/forms/index.ts +2 -12
  43. package/src/components/headless/index.ts +12 -1
  44. package/src/components/headless/snackbars/index.ts +3 -3
  45. package/src/components/index.ts +5 -5
  46. package/src/components/lib/AGErrorMessage.vue +3 -3
  47. package/src/components/lib/AGMarkdown.vue +16 -3
  48. package/src/components/lib/AGMeasured.vue +2 -2
  49. package/src/components/lib/AGStartupCrash.vue +6 -6
  50. package/src/components/lib/index.ts +0 -1
  51. package/src/components/snackbars/AGSnackbar.vue +8 -6
  52. package/src/components/ui/AlertModal.vue +13 -0
  53. package/src/components/ui/Button.vue +58 -0
  54. package/src/components/ui/Checkbox.vue +49 -0
  55. package/src/components/ui/ConfirmModal.vue +42 -0
  56. package/src/components/ui/ErrorReportModal.vue +62 -0
  57. package/src/components/{modals/AGErrorReportModalButtons.vue → ui/ErrorReportModalButtons.vue} +29 -20
  58. package/src/components/ui/ErrorReportModalTitle.vue +24 -0
  59. package/src/components/{forms/AGForm.vue → ui/Form.vue} +4 -5
  60. package/src/components/ui/Input.vue +52 -0
  61. package/src/components/ui/Link.vue +12 -0
  62. package/src/components/ui/LoadingModal.vue +32 -0
  63. package/src/components/ui/Modal.vue +55 -0
  64. package/src/components/ui/ModalContext.vue +30 -0
  65. package/src/components/ui/ProgressBar.vue +50 -0
  66. package/src/components/ui/PromptModal.vue +35 -0
  67. package/src/components/ui/index.ts +15 -0
  68. package/src/components/utils.ts +106 -9
  69. package/src/directives/index.ts +9 -5
  70. package/src/directives/measure.ts +1 -1
  71. package/src/errors/Errors.state.ts +1 -1
  72. package/src/errors/Errors.ts +14 -14
  73. package/src/errors/JobCancelledError.ts +3 -0
  74. package/src/errors/index.ts +9 -6
  75. package/src/errors/utils.ts +17 -1
  76. package/src/forms/{Form.test.ts → FormController.test.ts} +5 -4
  77. package/src/forms/{Form.ts → FormController.ts} +22 -19
  78. package/src/forms/composition.ts +4 -4
  79. package/src/forms/index.ts +2 -2
  80. package/src/forms/utils.ts +2 -2
  81. package/src/index.css +8 -0
  82. package/src/jobs/Job.ts +144 -2
  83. package/src/jobs/index.ts +4 -1
  84. package/src/jobs/listeners.ts +3 -0
  85. package/src/jobs/status.ts +4 -0
  86. package/src/lang/DefaultLangProvider.ts +7 -4
  87. package/src/lang/Lang.state.ts +1 -1
  88. package/src/lang/Lang.ts +1 -1
  89. package/src/lang/index.ts +8 -6
  90. package/src/plugins/Plugin.ts +1 -1
  91. package/src/plugins/index.ts +10 -7
  92. package/src/services/App.state.ts +13 -4
  93. package/src/services/App.ts +8 -3
  94. package/src/services/Cache.ts +1 -1
  95. package/src/services/Events.ts +15 -5
  96. package/src/services/Service.ts +116 -53
  97. package/src/services/Storage.ts +20 -0
  98. package/src/services/index.ts +11 -5
  99. package/src/services/utils.ts +18 -0
  100. package/src/testing/index.ts +4 -3
  101. package/src/testing/setup.ts +5 -13
  102. package/src/ui/UI.state.ts +10 -5
  103. package/src/ui/UI.ts +145 -59
  104. package/src/ui/index.ts +16 -16
  105. package/src/utils/composition/events.ts +2 -2
  106. package/src/utils/composition/forms.ts +4 -3
  107. package/src/utils/composition/persistent.test.ts +33 -0
  108. package/src/utils/composition/persistent.ts +11 -0
  109. package/src/utils/composition/state.test.ts +47 -0
  110. package/src/utils/composition/state.ts +24 -0
  111. package/src/utils/index.ts +2 -0
  112. package/src/utils/markdown.test.ts +50 -0
  113. package/src/utils/markdown.ts +19 -6
  114. package/src/utils/vue.ts +11 -13
  115. package/dist/aerogel-core.cjs.js +0 -2
  116. package/dist/aerogel-core.cjs.js.map +0 -1
  117. package/dist/aerogel-core.esm.js +0 -2
  118. package/dist/aerogel-core.esm.js.map +0 -1
  119. package/histoire.config.ts +0 -7
  120. package/noeldemartin.config.js +0 -5
  121. package/postcss.config.js +0 -6
  122. package/src/assets/histoire.css +0 -3
  123. package/src/components/forms/AGButton.vue +0 -44
  124. package/src/components/forms/AGCheckbox.vue +0 -41
  125. package/src/components/forms/AGInput.vue +0 -40
  126. package/src/components/headless/forms/AGHeadlessButton.ts +0 -3
  127. package/src/components/headless/forms/AGHeadlessButton.vue +0 -62
  128. package/src/components/headless/forms/AGHeadlessInput.ts +0 -34
  129. package/src/components/headless/forms/AGHeadlessInput.vue +0 -70
  130. package/src/components/headless/forms/AGHeadlessSelectButton.vue +0 -24
  131. package/src/components/headless/forms/AGHeadlessSelectLabel.vue +0 -24
  132. package/src/components/headless/forms/AGHeadlessSelectOptions.ts +0 -3
  133. package/src/components/headless/modals/AGHeadlessModal.ts +0 -34
  134. package/src/components/headless/modals/AGHeadlessModalPanel.vue +0 -28
  135. package/src/components/headless/modals/AGHeadlessModalTitle.vue +0 -13
  136. package/src/components/headless/modals/index.ts +0 -4
  137. package/src/components/interfaces.ts +0 -24
  138. package/src/components/lib/AGLink.vue +0 -9
  139. package/src/components/modals/AGAlertModal.ts +0 -15
  140. package/src/components/modals/AGAlertModal.vue +0 -14
  141. package/src/components/modals/AGConfirmModal.ts +0 -33
  142. package/src/components/modals/AGConfirmModal.vue +0 -26
  143. package/src/components/modals/AGErrorReportModal.ts +0 -46
  144. package/src/components/modals/AGErrorReportModal.vue +0 -54
  145. package/src/components/modals/AGErrorReportModalTitle.vue +0 -25
  146. package/src/components/modals/AGLoadingModal.ts +0 -23
  147. package/src/components/modals/AGLoadingModal.vue +0 -15
  148. package/src/components/modals/AGModal.ts +0 -10
  149. package/src/components/modals/AGModal.vue +0 -39
  150. package/src/components/modals/AGModalContext.ts +0 -8
  151. package/src/components/modals/AGModalContext.vue +0 -22
  152. package/src/components/modals/AGModalTitle.vue +0 -9
  153. package/src/components/modals/AGPromptModal.ts +0 -36
  154. package/src/components/modals/AGPromptModal.vue +0 -34
  155. package/src/components/modals/index.ts +0 -17
  156. package/src/directives/initial-focus.ts +0 -11
  157. package/src/main.histoire.ts +0 -1
  158. package/tailwind.config.js +0 -4
  159. package/tsconfig.json +0 -11
  160. package/vite.config.ts +0 -17
  161. /package/src/components/{AGAppSnackbars.vue → AppSnackbars.vue} +0 -0
  162. /package/src/{main.ts → index.ts} +0 -0
@@ -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
 
@@ -1,19 +1,11 @@
1
- import { mock, tap } from '@noeldemartin/utils';
1
+ import { FakeLocalStorage } from '@noeldemartin/testing';
2
2
  import { beforeEach, vi } from 'vitest';
3
3
 
4
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
5
- tap(globalThis, (global: any) => {
6
- global.jest = vi;
7
- global.navigator = { languages: ['en'] };
8
- global.localStorage = mock<Storage>({
9
- getItem: () => null,
10
- setItem: () => null,
11
- });
4
+ vi.mock('dompurify', async () => {
5
+ return { default: { sanitize: (html: string) => html } };
12
6
  });
13
7
 
14
8
  beforeEach(() => {
15
- vi.stubGlobal('document', {
16
- querySelector: () => null,
17
- getElementById: () => null,
18
- });
9
+ FakeLocalStorage.reset();
10
+ FakeLocalStorage.patchGlobal();
19
11
  });
@@ -1,10 +1,10 @@
1
1
  import type { Component } from 'vue';
2
2
 
3
- import { defineServiceState } from '@/services/Service';
3
+ import { defineServiceState } from '@aerogel/core/services/Service';
4
4
 
5
5
  import { Layouts, getCurrentLayout } from './utils';
6
6
 
7
- export interface Modal<T = unknown> {
7
+ export interface UIModal<T = unknown> {
8
8
  id: string;
9
9
  properties: Record<string, unknown>;
10
10
  component: Component;
@@ -12,11 +12,16 @@ export interface Modal<T = unknown> {
12
12
  afterClose: Promise<T | undefined>;
13
13
  }
14
14
 
15
+ export interface UIModalContext {
16
+ modal: UIModal;
17
+ childIndex?: number;
18
+ }
19
+
15
20
  export interface ModalComponent<
16
21
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
17
- Properties extends Record<string, unknown> = Record<string, unknown>,
22
+ Properties extends object = object,
18
23
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
19
- Result = unknown
24
+ Result = unknown,
20
25
  > {}
21
26
 
22
27
  export interface Snackbar {
@@ -28,7 +33,7 @@ export interface Snackbar {
28
33
  export default defineServiceState({
29
34
  name: 'ui',
30
35
  initialState: {
31
- modals: [] as Modal[],
36
+ modals: [] as UIModal[],
32
37
  snackbars: [] as Snackbar[],
33
38
  layout: getCurrentLayout(),
34
39
  },
package/src/ui/UI.ts CHANGED
@@ -1,16 +1,21 @@
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 { Color } from '@/components/constants';
8
- import type { SnackbarAction, SnackbarColor } from '@/components/headless/snackbars';
9
- 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 { AlertModalProps } from '@aerogel/core/components/contracts/AlertModal';
10
+ import type { ButtonVariant } from '@aerogel/core/components/contracts/Button';
11
+ import type { ConfirmModalCheckboxes, ConfirmModalProps } from '@aerogel/core/components/contracts/ConfirmModal';
12
+ import type { LoadingModalProps } from '@aerogel/core/components/contracts/LoadingModal';
13
+ import type { PromptModalProps } from '@aerogel/core/components/contracts/PromptModal';
14
+ import type { SnackbarAction, SnackbarColor } from '@aerogel/core/components/headless/snackbars';
10
15
 
11
16
  import Service from './UI.state';
12
17
  import { MOBILE_BREAKPOINT, getCurrentLayout } from './utils';
13
- import type { Modal, ModalComponent, Snackbar } from './UI.state';
18
+ import type { ModalComponent, Snackbar, UIModal } from './UI.state';
14
19
 
15
20
  interface ModalCallbacks<T = unknown> {
16
21
  willClose(result: T | undefined): void;
@@ -18,9 +23,8 @@ interface ModalCallbacks<T = unknown> {
18
23
  }
19
24
 
20
25
  type ModalProperties<TComponent> = TComponent extends ModalComponent<infer TProperties, unknown> ? TProperties : never;
21
- type ModalResult<TComponent> = TComponent extends ModalComponent<Record<string, unknown>, infer TResult>
22
- ? TResult
23
- : never;
26
+ type ModalResult<TComponent> =
27
+ TComponent extends ModalComponent<Record<string, unknown>, infer TResult> ? TResult : never;
24
28
 
25
29
  export const UIComponents = {
26
30
  AlertModal: 'alert-modal',
@@ -34,23 +38,36 @@ export const UIComponents = {
34
38
 
35
39
  export type UIComponent = ObjectValues<typeof UIComponents>;
36
40
 
37
- export interface ConfirmOptions {
41
+ export type ConfirmOptions = AcceptRefs<{
38
42
  acceptText?: string;
39
- acceptColor?: Color;
43
+ acceptVariant?: ButtonVariant;
40
44
  cancelText?: string;
41
- cancelColor?: Color;
45
+ cancelVariant?: ButtonVariant;
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 ConfirmModalCheckboxes = ConfirmModalCheckboxes>
57
+ extends ConfirmOptions {
58
+ checkboxes?: T;
42
59
  }
43
60
 
44
- export interface PromptOptions {
61
+ export type PromptOptions = AcceptRefs<{
45
62
  label?: string;
46
63
  defaultValue?: string;
47
64
  placeholder?: string;
48
65
  acceptText?: string;
49
- acceptColor?: Color;
66
+ acceptVariant?: ButtonVariant;
50
67
  cancelText?: string;
51
- cancelColor?: Color;
68
+ cancelVariant?: ButtonVariant;
52
69
  trim?: boolean;
53
- }
70
+ }>;
54
71
 
55
72
  export interface ShowSnackbarOptions {
56
73
  component?: Component;
@@ -70,7 +87,7 @@ export class UIService extends Service {
70
87
  public alert(message: string): void;
71
88
  public alert(title: string, message: string): void;
72
89
  public alert(messageOrTitle: string, message?: string): void {
73
- const getProperties = (): AGAlertModalProps => {
90
+ const getProperties = (): AlertModalProps => {
74
91
  if (typeof message !== 'string') {
75
92
  return { message: messageOrTitle };
76
93
  }
@@ -81,38 +98,79 @@ export class UIService extends Service {
81
98
  };
82
99
  };
83
100
 
84
- this.openModal(this.requireComponent(UIComponents.AlertModal), getProperties());
101
+ this.openModal<ModalComponent<AlertModalProps>>(
102
+ this.requireComponent(UIComponents.AlertModal),
103
+ getProperties(),
104
+ );
85
105
  }
86
106
 
107
+ /* eslint-disable max-len */
87
108
  public async confirm(message: string, options?: ConfirmOptions): Promise<boolean>;
88
109
  public async confirm(title: string, message: string, options?: ConfirmOptions): Promise<boolean>;
110
+ public async confirm<T extends ConfirmModalCheckboxes>(message: string, options?: ConfirmOptionsWithCheckboxes<T>): Promise<[boolean, Record<keyof T, boolean>]>; // prettier-ignore
111
+ public async confirm<T extends ConfirmModalCheckboxes>(title: string, message: string, options?: ConfirmOptionsWithCheckboxes<T>): Promise<[boolean, Record<keyof T, boolean>]>; // prettier-ignore
112
+ /* eslint-enable max-len */
113
+
89
114
  public async confirm(
90
115
  messageOrTitle: string,
91
- messageOrOptions?: string | ConfirmOptions,
92
- options?: ConfirmOptions,
93
- ): Promise<boolean> {
94
- const getProperties = (): AGConfirmModalProps => {
116
+ messageOrOptions?: string | ConfirmOptions | ConfirmOptionsWithCheckboxes,
117
+ options?: ConfirmOptions | ConfirmOptionsWithCheckboxes,
118
+ ): Promise<boolean | [boolean, Record<string, boolean>]> {
119
+ const getProperties = (): AcceptRefs<ConfirmModalProps> => {
95
120
  if (typeof messageOrOptions !== 'string') {
96
121
  return {
97
- message: messageOrTitle,
98
122
  ...(messageOrOptions ?? {}),
123
+ message: messageOrTitle,
124
+ required: !!messageOrOptions?.required,
99
125
  };
100
126
  }
101
127
 
102
128
  return {
129
+ ...(options ?? {}),
103
130
  title: messageOrTitle,
104
131
  message: messageOrOptions,
105
- ...(options ?? {}),
132
+ required: !!options?.required,
106
133
  };
107
134
  };
108
135
 
109
- const modal = await this.openModal<ModalComponent<AGConfirmModalProps, boolean>>(
136
+ type ConfirmModalComponent = ModalComponent<
137
+ AcceptRefs<ConfirmModalProps>,
138
+ boolean | [boolean, Record<string, boolean>]
139
+ >;
140
+
141
+ const properties = getProperties();
142
+ const modal = await this.openModal<ConfirmModalComponent>(
110
143
  this.requireComponent(UIComponents.ConfirmModal),
111
- getProperties(),
144
+ properties,
112
145
  );
113
146
  const result = await modal.beforeClose;
114
147
 
115
- return result ?? false;
148
+ const confirmed = typeof result === 'object' ? result[0] : (result ?? false);
149
+ const checkboxes =
150
+ typeof result === 'object'
151
+ ? result[1]
152
+ : Object.entries(properties.checkboxes ?? {}).reduce(
153
+ (values, [checkbox, { default: defaultValue }]) => ({
154
+ [checkbox]: defaultValue ?? false,
155
+ ...values,
156
+ }),
157
+ {} as Record<string, boolean>,
158
+ );
159
+
160
+ for (const [name, checkbox] of Object.entries(properties.checkboxes ?? {})) {
161
+ if (!checkbox.required || checkboxes[name]) {
162
+ continue;
163
+ }
164
+
165
+ if (confirmed && isDevelopment()) {
166
+ // eslint-disable-next-line no-console
167
+ console.warn(`Confirmed confirm modal was suppressed because required '${name}' checkbox was missing`);
168
+ }
169
+
170
+ return [false, checkboxes];
171
+ }
172
+
173
+ return 'checkboxes' in properties ? [confirmed, checkboxes] : confirmed;
116
174
  }
117
175
 
118
176
  public async prompt(message: string, options?: PromptOptions): Promise<string | null>;
@@ -123,22 +181,22 @@ export class UIService extends Service {
123
181
  options?: PromptOptions,
124
182
  ): Promise<string | null> {
125
183
  const trim = options?.trim ?? true;
126
- const getProperties = (): AGPromptModalProps => {
184
+ const getProperties = (): PromptModalProps => {
127
185
  if (typeof messageOrOptions !== 'string') {
128
186
  return {
129
187
  message: messageOrTitle,
130
188
  ...(messageOrOptions ?? {}),
131
- };
189
+ } as PromptModalProps;
132
190
  }
133
191
 
134
192
  return {
135
193
  title: messageOrTitle,
136
194
  message: messageOrOptions,
137
195
  ...(options ?? {}),
138
- };
196
+ } as PromptModalProps;
139
197
  };
140
198
 
141
- const modal = await this.openModal<ModalComponent<AGPromptModalProps, string | null>>(
199
+ const modal = await this.openModal<ModalComponent<PromptModalProps, string | null>>(
142
200
  this.requireComponent(UIComponents.PromptModal),
143
201
  getProperties(),
144
202
  );
@@ -150,25 +208,37 @@ export class UIService extends Service {
150
208
 
151
209
  public async loading<T>(operation: Promise<T> | (() => T)): Promise<T>;
152
210
  public async loading<T>(message: string, operation: Promise<T> | (() => T)): Promise<T>;
211
+ public async loading<T>(options: LoadingOptions, operation: Promise<T> | (() => T)): Promise<T>;
153
212
  public async loading<T>(
154
- messageOrOperation: string | Promise<T> | (() => T),
213
+ operationOrMessageOrOptions: string | LoadingOptions | Promise<T> | (() => T),
155
214
  operation?: Promise<T> | (() => T),
156
215
  ): Promise<T> {
157
- const getProperties = (): AGLoadingModalProps => {
158
- if (typeof messageOrOperation !== 'string') {
159
- return {};
216
+ const processOperation = (o: Promise<T> | (() => T)) => (typeof o === 'function' ? Promise.resolve(o()) : o);
217
+ const processArgs = (): { operationPromise: Promise<T>; props?: AcceptRefs<LoadingModalProps> } => {
218
+ if (typeof operationOrMessageOrOptions === 'string') {
219
+ return {
220
+ props: { message: operationOrMessageOrOptions },
221
+ operationPromise: processOperation(operation as Promise<T> | (() => T)),
222
+ };
223
+ }
224
+
225
+ if (typeof operationOrMessageOrOptions === 'function' || operationOrMessageOrOptions instanceof Promise) {
226
+ return { operationPromise: processOperation(operationOrMessageOrOptions) };
160
227
  }
161
228
 
162
- return { message: messageOrOperation };
229
+ return {
230
+ props: operationOrMessageOrOptions,
231
+ operationPromise: processOperation(operation as Promise<T> | (() => T)),
232
+ };
163
233
  };
164
234
 
165
- const modal = await this.openModal(this.requireComponent(UIComponents.LoadingModal), getProperties());
235
+ const { operationPromise, props } = processArgs();
236
+ const modal = await this.openModal(this.requireComponent(UIComponents.LoadingModal), props);
166
237
 
167
238
  try {
168
- operation = typeof messageOrOperation === 'string' ? (operation as () => T) : messageOrOperation;
169
- operation = typeof operation === 'function' ? Promise.resolve(operation()) : operation;
239
+ const result = await operationPromise;
170
240
 
171
- const [result] = await Promise.all([operation, after({ seconds: 1 })]);
241
+ await after({ ms: 500 });
172
242
 
173
243
  return result;
174
244
  } finally {
@@ -202,10 +272,10 @@ export class UIService extends Service {
202
272
  public async openModal<TModalComponent extends ModalComponent>(
203
273
  component: TModalComponent,
204
274
  properties?: ModalProperties<TModalComponent>,
205
- ): Promise<Modal<ModalResult<TModalComponent>>> {
275
+ ): Promise<UIModal<ModalResult<TModalComponent>>> {
206
276
  const id = uuid();
207
277
  const callbacks: Partial<ModalCallbacks<ModalResult<TModalComponent>>> = {};
208
- const modal: Modal<ModalResult<TModalComponent>> = {
278
+ const modal: UIModal<ModalResult<TModalComponent>> = {
209
279
  id,
210
280
  properties: properties ?? {},
211
281
  component: markRaw(component),
@@ -230,15 +300,42 @@ export class UIService extends Service {
230
300
  }
231
301
 
232
302
  public async closeModal(id: string, result?: unknown): Promise<void> {
303
+ if (!App.isMounted()) {
304
+ await this.removeModal(id, result);
305
+
306
+ return;
307
+ }
308
+
233
309
  await Events.emit('close-modal', { id, result });
234
310
  }
235
311
 
236
- protected async boot(): Promise<void> {
312
+ public async closeAllModals(): Promise<void> {
313
+ while (this.modals.length > 0) {
314
+ await this.closeModal(required(this.modals[this.modals.length - 1]).id);
315
+ }
316
+ }
317
+
318
+ protected override async boot(): Promise<void> {
237
319
  this.watchModalEvents();
238
320
  this.watchMountedEvent();
239
321
  this.watchViewportBreakpoints();
240
322
  }
241
323
 
324
+ private async removeModal(id: string, result?: unknown): Promise<void> {
325
+ this.setState(
326
+ 'modals',
327
+ this.modals.filter((m) => m.id !== id),
328
+ );
329
+
330
+ this.modalCallbacks[id]?.closed?.(result);
331
+
332
+ delete this.modalCallbacks[id];
333
+
334
+ const activeModal = this.modals.at(-1);
335
+
336
+ await (activeModal && Events.emit('show-modal', { id: activeModal.id }));
337
+ }
338
+
242
339
  private watchModalEvents(): void {
243
340
  Events.on('modal-will-close', ({ modal, result }) => {
244
341
  this.modalCallbacks[modal.id]?.willClose?.(result);
@@ -248,19 +345,8 @@ export class UIService extends Service {
248
345
  }
249
346
  });
250
347
 
251
- Events.on('modal-closed', async ({ modal, result }) => {
252
- this.setState(
253
- 'modals',
254
- this.modals.filter((m) => m.id !== modal.id),
255
- );
256
-
257
- this.modalCallbacks[modal.id]?.closed?.(result);
258
-
259
- delete this.modalCallbacks[modal.id];
260
-
261
- const activeModal = this.modals.at(-1);
262
-
263
- await (activeModal && Events.emit('show-modal', { id: activeModal.id }));
348
+ Events.on('modal-closed', async ({ modal: { id }, result }) => {
349
+ await this.removeModal(id, result);
264
350
  });
265
351
  }
266
352
 
@@ -300,13 +386,13 @@ export class UIService extends Service {
300
386
 
301
387
  export default facade(UIService);
302
388
 
303
- declare module '@/services/Events' {
389
+ declare module '@aerogel/core/services/Events' {
304
390
  export interface EventsPayload {
305
391
  'close-modal': { id: string; result?: unknown };
306
392
  'hide-modal': { id: string };
307
393
  'hide-overlays-backdrop': void;
308
- 'modal-closed': { modal: Modal; result?: unknown };
309
- 'modal-will-close': { modal: Modal; result?: unknown };
394
+ 'modal-closed': { modal: UIModal; result?: unknown };
395
+ 'modal-will-close': { modal: UIModal; result?: unknown };
310
396
  'show-modal': { id: string };
311
397
  'show-overlays-backdrop': void;
312
398
  }
package/src/ui/index.ts CHANGED
@@ -1,16 +1,16 @@
1
1
  import type { Component } from 'vue';
2
2
 
3
- import { bootServices } from '@/services';
4
- import { definePlugin } from '@/plugins';
3
+ import AGSnackbar from '@aerogel/core/components/snackbars/AGSnackbar.vue';
4
+ import AGStartupCrash from '@aerogel/core/components/lib/AGStartupCrash.vue';
5
+ import AlertModal from '@aerogel/core/components/ui/AlertModal.vue';
6
+ import ConfirmModal from '@aerogel/core/components/ui/ConfirmModal.vue';
7
+ import ErrorReportModal from '@aerogel/core/components/ui/ErrorReportModal.vue';
8
+ import LoadingModal from '@aerogel/core/components/ui/LoadingModal.vue';
9
+ import PromptModal from '@aerogel/core/components/ui/PromptModal.vue';
10
+ import { bootServices } from '@aerogel/core/services';
11
+ import { definePlugin } from '@aerogel/core/plugins';
5
12
 
6
13
  import UI, { UIComponents } from './UI';
7
- import AGAlertModal from '../components/modals/AGAlertModal.vue';
8
- import AGConfirmModal from '../components/modals/AGConfirmModal.vue';
9
- import AGErrorReportModal from '../components/modals/AGErrorReportModal.vue';
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';
14
14
  import type { UIComponent } from './UI';
15
15
 
16
16
  const services = { $ui: UI };
@@ -24,11 +24,11 @@ export type UIServices = typeof services;
24
24
  export default definePlugin({
25
25
  async install(app, options) {
26
26
  const defaultComponents = {
27
- [UIComponents.AlertModal]: AGAlertModal,
28
- [UIComponents.ConfirmModal]: AGConfirmModal,
29
- [UIComponents.ErrorReportModal]: AGErrorReportModal,
30
- [UIComponents.LoadingModal]: AGLoadingModal,
31
- [UIComponents.PromptModal]: AGPromptModal,
27
+ [UIComponents.AlertModal]: AlertModal,
28
+ [UIComponents.ConfirmModal]: ConfirmModal,
29
+ [UIComponents.ErrorReportModal]: ErrorReportModal,
30
+ [UIComponents.LoadingModal]: LoadingModal,
31
+ [UIComponents.PromptModal]: PromptModal,
32
32
  [UIComponents.Snackbar]: AGSnackbar,
33
33
  [UIComponents.StartupCrash]: AGStartupCrash,
34
34
  };
@@ -42,12 +42,12 @@ export default definePlugin({
42
42
  },
43
43
  });
44
44
 
45
- declare module '@/bootstrap/options' {
45
+ declare module '@aerogel/core/bootstrap/options' {
46
46
  export interface AerogelOptions {
47
47
  components?: Partial<Record<UIComponent, Component>>;
48
48
  }
49
49
  }
50
50
 
51
- declare module '@/services' {
51
+ declare module '@aerogel/core/services' {
52
52
  export interface Services extends UIServices {}
53
53
  }
@@ -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>(
@@ -1,11 +1,12 @@
1
1
  import { objectWithout } from '@noeldemartin/utils';
2
2
  import { computed, useAttrs } from 'vue';
3
+ import type { ClassValue } from 'clsx';
3
4
  import type { ComputedRef } from 'vue';
4
5
 
5
- export function useInputAttrs(): [ComputedRef<{}>, ComputedRef<unknown>] {
6
+ export function useInputAttrs(): [ComputedRef<{}>, ComputedRef<ClassValue>] {
6
7
  const attrs = useAttrs();
7
- const className = computed(() => attrs.class);
8
+ const classes = computed(() => attrs.class);
8
9
  const inputAttrs = computed(() => objectWithout(attrs, 'class'));
9
10
 
10
- return [inputAttrs, className];
11
+ return [inputAttrs, classes as ComputedRef<ClassValue>];
11
12
  }
@@ -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';