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

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 (179) hide show
  1. package/dist/aerogel-core.css +1 -0
  2. package/dist/aerogel-core.d.ts +2310 -664
  3. package/dist/aerogel-core.js +3542 -0
  4. package/dist/aerogel-core.js.map +1 -0
  5. package/package.json +38 -34
  6. package/src/bootstrap/bootstrap.test.ts +7 -10
  7. package/src/bootstrap/index.ts +43 -14
  8. package/src/bootstrap/options.ts +4 -1
  9. package/src/components/AppLayout.vue +14 -0
  10. package/src/components/AppModals.vue +14 -0
  11. package/src/components/AppOverlays.vue +9 -0
  12. package/src/components/AppToasts.vue +16 -0
  13. package/src/components/contracts/AlertModal.ts +19 -0
  14. package/src/components/contracts/Button.ts +16 -0
  15. package/src/components/contracts/ConfirmModal.ts +48 -0
  16. package/src/components/contracts/DropdownMenu.ts +25 -0
  17. package/src/components/contracts/ErrorReportModal.ts +33 -0
  18. package/src/components/contracts/Input.ts +26 -0
  19. package/src/components/contracts/LoadingModal.ts +26 -0
  20. package/src/components/contracts/Modal.ts +21 -0
  21. package/src/components/contracts/PromptModal.ts +34 -0
  22. package/src/components/contracts/Select.ts +45 -0
  23. package/src/components/contracts/Toast.ts +15 -0
  24. package/src/components/contracts/index.ts +11 -0
  25. package/src/components/headless/HeadlessButton.vue +51 -0
  26. package/src/components/headless/HeadlessInput.vue +59 -0
  27. package/src/components/headless/HeadlessInputDescription.vue +27 -0
  28. package/src/components/headless/{forms/AGHeadlessInputError.vue → HeadlessInputError.vue} +4 -8
  29. package/src/components/headless/HeadlessInputInput.vue +75 -0
  30. package/src/components/headless/HeadlessInputLabel.vue +18 -0
  31. package/src/components/headless/HeadlessInputTextArea.vue +40 -0
  32. package/src/components/headless/HeadlessModal.vue +57 -0
  33. package/src/components/headless/HeadlessModalContent.vue +30 -0
  34. package/src/components/headless/HeadlessModalDescription.vue +12 -0
  35. package/src/components/headless/HeadlessModalOverlay.vue +12 -0
  36. package/src/components/headless/HeadlessModalTitle.vue +12 -0
  37. package/src/components/headless/HeadlessSelect.vue +120 -0
  38. package/src/components/headless/HeadlessSelectError.vue +25 -0
  39. package/src/components/headless/HeadlessSelectLabel.vue +25 -0
  40. package/src/components/headless/HeadlessSelectOption.vue +34 -0
  41. package/src/components/headless/HeadlessSelectOptions.vue +42 -0
  42. package/src/components/headless/HeadlessSelectTrigger.vue +22 -0
  43. package/src/components/headless/HeadlessSelectValue.vue +18 -0
  44. package/src/components/headless/HeadlessSwitch.vue +96 -0
  45. package/src/components/headless/HeadlessToast.vue +18 -0
  46. package/src/components/headless/HeadlessToastAction.vue +13 -0
  47. package/src/components/headless/index.ts +20 -2
  48. package/src/components/index.ts +6 -7
  49. package/src/components/ui/AdvancedOptions.vue +18 -0
  50. package/src/components/ui/AlertModal.vue +17 -0
  51. package/src/components/ui/Button.vue +100 -0
  52. package/src/components/ui/Checkbox.vue +56 -0
  53. package/src/components/ui/ConfirmModal.vue +50 -0
  54. package/src/components/ui/DropdownMenu.vue +32 -0
  55. package/src/components/ui/DropdownMenuOption.vue +22 -0
  56. package/src/components/ui/DropdownMenuOptions.vue +44 -0
  57. package/src/components/ui/EditableContent.vue +82 -0
  58. package/src/components/ui/ErrorLogs.vue +19 -0
  59. package/src/components/ui/ErrorLogsModal.vue +48 -0
  60. package/src/components/ui/ErrorMessage.vue +15 -0
  61. package/src/components/ui/ErrorReportModal.vue +73 -0
  62. package/src/components/ui/ErrorReportModalButtons.vue +118 -0
  63. package/src/components/ui/ErrorReportModalTitle.vue +24 -0
  64. package/src/components/ui/Form.vue +24 -0
  65. package/src/components/ui/Input.vue +56 -0
  66. package/src/components/ui/Link.vue +12 -0
  67. package/src/components/ui/LoadingModal.vue +34 -0
  68. package/src/components/ui/Markdown.vue +97 -0
  69. package/src/components/ui/Modal.vue +123 -0
  70. package/src/components/ui/ModalContext.vue +31 -0
  71. package/src/components/ui/ProgressBar.vue +51 -0
  72. package/src/components/ui/PromptModal.vue +38 -0
  73. package/src/components/ui/Select.vue +27 -0
  74. package/src/components/ui/SelectLabel.vue +21 -0
  75. package/src/components/ui/SelectOption.vue +29 -0
  76. package/src/components/ui/SelectOptions.vue +35 -0
  77. package/src/components/ui/SelectTrigger.vue +29 -0
  78. package/src/components/ui/SettingsModal.vue +15 -0
  79. package/src/components/ui/StartupCrash.vue +31 -0
  80. package/src/components/ui/Switch.vue +11 -0
  81. package/src/components/ui/Toast.vue +46 -0
  82. package/src/components/ui/index.ts +33 -0
  83. package/src/directives/index.ts +27 -6
  84. package/src/directives/measure.ts +46 -0
  85. package/src/errors/Errors.state.ts +1 -1
  86. package/src/errors/Errors.ts +70 -15
  87. package/src/errors/JobCancelledError.ts +3 -0
  88. package/src/errors/index.ts +38 -6
  89. package/src/errors/settings/Debug.vue +39 -0
  90. package/src/errors/settings/index.ts +10 -0
  91. package/src/errors/utils.ts +35 -0
  92. package/src/forms/FormController.test.ts +110 -0
  93. package/src/forms/FormController.ts +246 -0
  94. package/src/forms/index.ts +3 -2
  95. package/src/forms/utils.ts +51 -20
  96. package/src/forms/validation.ts +19 -0
  97. package/src/index.css +73 -0
  98. package/src/{main.ts → index.ts} +3 -2
  99. package/src/jobs/Job.ts +147 -0
  100. package/src/jobs/index.ts +10 -0
  101. package/src/jobs/listeners.ts +3 -0
  102. package/src/jobs/status.ts +4 -0
  103. package/src/lang/DefaultLangProvider.ts +46 -0
  104. package/src/lang/Lang.state.ts +11 -0
  105. package/src/lang/Lang.ts +44 -29
  106. package/src/lang/index.ts +12 -6
  107. package/src/lang/settings/Language.vue +48 -0
  108. package/src/lang/settings/index.ts +10 -0
  109. package/src/plugins/Plugin.ts +2 -1
  110. package/src/plugins/index.ts +22 -0
  111. package/src/services/App.state.ts +42 -5
  112. package/src/services/App.ts +52 -6
  113. package/src/services/Cache.ts +43 -0
  114. package/src/services/Events.test.ts +39 -0
  115. package/src/services/Events.ts +110 -36
  116. package/src/services/Service.ts +160 -50
  117. package/src/services/Storage.ts +20 -0
  118. package/src/services/index.ts +25 -10
  119. package/src/services/store.ts +8 -5
  120. package/src/services/utils.ts +18 -0
  121. package/src/testing/index.ts +26 -0
  122. package/src/testing/setup.ts +11 -0
  123. package/src/ui/UI.state.ts +20 -9
  124. package/src/ui/UI.ts +322 -74
  125. package/src/ui/index.ts +32 -23
  126. package/src/ui/utils.ts +16 -0
  127. package/src/utils/classes.ts +41 -0
  128. package/src/utils/composition/events.ts +4 -5
  129. package/src/utils/composition/forms.ts +20 -4
  130. package/src/utils/composition/persistent.test.ts +33 -0
  131. package/src/utils/composition/persistent.ts +11 -0
  132. package/src/utils/composition/state.test.ts +47 -0
  133. package/src/utils/composition/state.ts +33 -0
  134. package/src/utils/index.ts +5 -0
  135. package/src/utils/markdown.test.ts +50 -0
  136. package/src/utils/markdown.ts +60 -4
  137. package/src/utils/types.ts +3 -0
  138. package/src/utils/vue.ts +38 -121
  139. package/.eslintrc.js +0 -3
  140. package/dist/aerogel-core.cjs.js +0 -2
  141. package/dist/aerogel-core.cjs.js.map +0 -1
  142. package/dist/aerogel-core.esm.js +0 -2
  143. package/dist/aerogel-core.esm.js.map +0 -1
  144. package/noeldemartin.config.js +0 -2
  145. package/src/components/AGAppLayout.vue +0 -11
  146. package/src/components/AGAppOverlays.vue +0 -39
  147. package/src/components/basic/AGMarkdown.vue +0 -35
  148. package/src/components/basic/index.ts +0 -3
  149. package/src/components/forms/AGButton.vue +0 -34
  150. package/src/components/forms/AGCheckbox.vue +0 -35
  151. package/src/components/forms/AGForm.vue +0 -26
  152. package/src/components/forms/AGInput.vue +0 -36
  153. package/src/components/forms/index.ts +0 -6
  154. package/src/components/headless/forms/AGHeadlessButton.vue +0 -50
  155. package/src/components/headless/forms/AGHeadlessInput.ts +0 -8
  156. package/src/components/headless/forms/AGHeadlessInput.vue +0 -54
  157. package/src/components/headless/forms/AGHeadlessInputInput.vue +0 -45
  158. package/src/components/headless/forms/AGHeadlessInputLabel.vue +0 -16
  159. package/src/components/headless/forms/index.ts +0 -6
  160. package/src/components/headless/modals/AGHeadlessModal.ts +0 -7
  161. package/src/components/headless/modals/AGHeadlessModal.vue +0 -88
  162. package/src/components/headless/modals/AGHeadlessModalPanel.vue +0 -28
  163. package/src/components/headless/modals/AGHeadlessModalTitle.vue +0 -13
  164. package/src/components/headless/modals/index.ts +0 -6
  165. package/src/components/modals/AGAlertModal.vue +0 -26
  166. package/src/components/modals/AGConfirmModal.vue +0 -30
  167. package/src/components/modals/AGLoadingModal.vue +0 -19
  168. package/src/components/modals/AGModal.ts +0 -10
  169. package/src/components/modals/AGModal.vue +0 -36
  170. package/src/components/modals/AGModalContext.ts +0 -8
  171. package/src/components/modals/AGModalContext.vue +0 -22
  172. package/src/components/modals/index.ts +0 -8
  173. package/src/directives/initial-focus.ts +0 -11
  174. package/src/forms/Form.test.ts +0 -58
  175. package/src/forms/Form.ts +0 -176
  176. package/src/forms/composition.ts +0 -6
  177. package/src/globals.ts +0 -6
  178. package/tsconfig.json +0 -10
  179. package/vite.config.ts +0 -13
@@ -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
+ }
@@ -0,0 +1,26 @@
1
+ import { isTesting } from '@noeldemartin/utils';
2
+ import type { GetClosureArgs } from '@noeldemartin/utils';
3
+
4
+ import Events from '@aerogel/core/services/Events';
5
+ import { definePlugin } from '@aerogel/core/plugins';
6
+
7
+ export interface AerogelTestingRuntime {
8
+ on: (typeof Events)['on'];
9
+ }
10
+
11
+ export default definePlugin({
12
+ async install() {
13
+ if (!isTesting()) {
14
+ return;
15
+ }
16
+
17
+ globalThis.testingRuntime = {
18
+ on: ((...args: GetClosureArgs<(typeof Events)['on']>) => Events.on(...args)) as (typeof Events)['on'],
19
+ };
20
+ },
21
+ });
22
+
23
+ declare global {
24
+ // eslint-disable-next-line no-var
25
+ var testingRuntime: AerogelTestingRuntime | undefined;
26
+ }
@@ -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,23 +1,34 @@
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
- export interface Modal<T = unknown> {
5
+ import { Layouts, getCurrentLayout } from './utils';
6
+
7
+ export interface UIModal<T = unknown> {
6
8
  id: string;
7
9
  properties: Record<string, unknown>;
8
10
  component: Component;
11
+ closing: boolean;
9
12
  beforeClose: Promise<T | undefined>;
10
13
  afterClose: Promise<T | undefined>;
11
14
  }
12
15
 
13
- export interface ModalComponent<
14
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
15
- Properties extends Record<string, unknown> = Record<string, unknown>,
16
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
17
- Result = unknown
18
- > {}
16
+ export interface UIToast {
17
+ id: string;
18
+ component: Component;
19
+ properties: Record<string, unknown>;
20
+ }
19
21
 
20
22
  export default defineServiceState({
21
23
  name: 'ui',
22
- initialState: { modals: [] as Modal[] },
24
+ initialState: {
25
+ modals: [] as UIModal[],
26
+ toasts: [] as UIToast[],
27
+ layout: getCurrentLayout(),
28
+ },
29
+ computed: {
30
+ desktop: ({ layout }) => layout === Layouts.Desktop,
31
+ mobile: ({ layout }) => layout === Layouts.Mobile,
32
+ openModals: ({ modals }) => modals.filter(({ closing }) => !closing),
33
+ },
23
34
  });
package/src/ui/UI.ts CHANGED
@@ -1,89 +1,283 @@
1
- import { 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
+ import type { ComponentExposed, ComponentProps } from 'vue-component-type-helpers';
3
4
  import type { Component } from 'vue';
4
- import type { ObjectValues } from '@noeldemartin/utils';
5
-
6
- import Events from '@/services/Events';
5
+ import type { ClosureArgs } from '@noeldemartin/utils';
6
+
7
+ import App from '@aerogel/core/services/App';
8
+ import Events from '@aerogel/core/services/Events';
9
+ import type {
10
+ ConfirmModalCheckboxes,
11
+ ConfirmModalExpose,
12
+ ConfirmModalProps,
13
+ } from '@aerogel/core/components/contracts/ConfirmModal';
14
+ import type {
15
+ ErrorReportModalExpose,
16
+ ErrorReportModalProps,
17
+ } from '@aerogel/core/components/contracts/ErrorReportModal';
18
+ import type { AcceptRefs } from '@aerogel/core/utils';
19
+ import type { AlertModalExpose, AlertModalProps } from '@aerogel/core/components/contracts/AlertModal';
20
+ import type { ButtonVariant } from '@aerogel/core/components/contracts/Button';
21
+ import type { LoadingModalExpose, LoadingModalProps } from '@aerogel/core/components/contracts/LoadingModal';
22
+ import type { PromptModalExpose, PromptModalProps } from '@aerogel/core/components/contracts/PromptModal';
23
+ import type { ToastAction, ToastExpose, ToastProps, ToastVariant } from '@aerogel/core/components/contracts/Toast';
7
24
 
8
25
  import Service from './UI.state';
9
- import type { Modal, ModalComponent } from './UI.state';
26
+ import { MOBILE_BREAKPOINT, getCurrentLayout } from './utils';
27
+ import type { UIModal, UIToast } from './UI.state';
10
28
 
11
29
  interface ModalCallbacks<T = unknown> {
12
30
  willClose(result: T | undefined): void;
13
- closed(result: T | undefined): void;
31
+ hasClosed(result: T | undefined): void;
14
32
  }
15
33
 
16
- type ModalProperties<TComponent> = TComponent extends ModalComponent<infer TProperties, unknown> ? TProperties : never;
17
- type ModalResult<TComponent> = TComponent extends ModalComponent<Record<string, unknown>, infer TResult>
18
- ? TResult
19
- : never;
34
+ export type ModalResult<T> = ModalExposeResult<ComponentExposed<T>>;
35
+ export type ModalExposeResult<T> = T extends { close(result?: infer Result): Promise<void> } ? Result : unknown;
36
+ export type UIComponent<Props = {}, Exposed = {}> = { new (...args: ClosureArgs): Exposed & { $props: Props } };
37
+
38
+ export interface UIComponents {
39
+ 'alert-modal': UIComponent<AlertModalProps, AlertModalExpose>;
40
+ 'confirm-modal': UIComponent<ConfirmModalProps, ConfirmModalExpose>;
41
+ 'error-report-modal': UIComponent<ErrorReportModalProps, ErrorReportModalExpose>;
42
+ 'loading-modal': UIComponent<LoadingModalProps, LoadingModalExpose>;
43
+ 'prompt-modal': UIComponent<PromptModalProps, PromptModalExpose>;
44
+ 'router-link': UIComponent;
45
+ 'startup-crash': UIComponent;
46
+ toast: UIComponent<ToastProps, ToastExpose>;
47
+ }
20
48
 
21
- export const UIComponents = {
22
- AlertModal: 'alert-modal',
23
- ConfirmModal: 'confirm-modal',
24
- LoadingModal: 'loading-modal',
25
- } as const;
49
+ export interface UIModalContext {
50
+ modal: UIModal;
51
+ childIndex?: number;
52
+ }
26
53
 
27
- export type UIComponent = ObjectValues<typeof UIComponents>;
54
+ export type ConfirmOptions = AcceptRefs<{
55
+ acceptText?: string;
56
+ acceptVariant?: ButtonVariant;
57
+ cancelText?: string;
58
+ cancelVariant?: ButtonVariant;
59
+ actions?: Record<string, () => unknown>;
60
+ required?: boolean;
61
+ }>;
62
+
63
+ export type LoadingOptions = AcceptRefs<{
64
+ title?: string;
65
+ message?: string;
66
+ progress?: number;
67
+ }>;
68
+
69
+ export interface ConfirmOptionsWithCheckboxes<T extends ConfirmModalCheckboxes = ConfirmModalCheckboxes>
70
+ extends ConfirmOptions {
71
+ checkboxes?: T;
72
+ }
73
+
74
+ export type PromptOptions = AcceptRefs<{
75
+ label?: string;
76
+ defaultValue?: string;
77
+ placeholder?: string;
78
+ acceptText?: string;
79
+ acceptVariant?: ButtonVariant;
80
+ cancelText?: string;
81
+ cancelVariant?: ButtonVariant;
82
+ trim?: boolean;
83
+ }>;
84
+
85
+ export interface ToastOptions {
86
+ component?: Component;
87
+ variant?: ToastVariant;
88
+ actions?: ToastAction[];
89
+ }
28
90
 
29
91
  export class UIService extends Service {
30
92
 
31
93
  private modalCallbacks: Record<string, Partial<ModalCallbacks>> = {};
32
- private components: Partial<Record<UIComponent, Component>> = {};
94
+ private components: Partial<UIComponents> = {};
95
+
96
+ public registerComponent<T extends keyof UIComponents>(name: T, component: UIComponents[T]): void {
97
+ this.components[name] = component;
98
+ }
99
+
100
+ public resolveComponent<T extends keyof UIComponents>(name: T): UIComponents[T] | null {
101
+ return this.components[name] ?? null;
102
+ }
103
+
104
+ public requireComponent<T extends keyof UIComponents>(name: T): UIComponents[T] {
105
+ return this.resolveComponent(name) ?? fail(`UI Component '${name}' is not defined!`);
106
+ }
33
107
 
34
108
  public alert(message: string): void;
35
109
  public alert(title: string, message: string): void;
36
110
  public alert(messageOrTitle: string, message?: string): void {
37
- const options = typeof message === 'string' ? { title: messageOrTitle, message } : { message: messageOrTitle };
111
+ const getProperties = (): AlertModalProps => {
112
+ if (typeof message !== 'string') {
113
+ return { message: messageOrTitle };
114
+ }
115
+
116
+ return {
117
+ title: messageOrTitle,
118
+ message,
119
+ };
120
+ };
38
121
 
39
- this.openModal(this.requireComponent(UIComponents.AlertModal), options);
122
+ this.modal(this.requireComponent('alert-modal'), getProperties());
40
123
  }
41
124
 
42
- public async confirm(message: string): Promise<boolean>;
43
- public async confirm(title: string, message: string): Promise<boolean>;
44
- public async confirm(messageOrTitle: string, message?: string): Promise<boolean> {
45
- const options = typeof message === 'string' ? { title: messageOrTitle, message } : { message: messageOrTitle };
46
- const modal = await this.openModal<ModalComponent<{ message: string }, boolean>>(
47
- this.requireComponent(UIComponents.ConfirmModal),
48
- options,
49
- );
50
- const result = await modal.beforeClose;
125
+ /* eslint-disable max-len */
126
+ public async confirm(message: string, options?: ConfirmOptions): Promise<boolean>;
127
+ public async confirm(title: string, message: string, options?: ConfirmOptions): Promise<boolean>;
128
+ public async confirm<T extends ConfirmModalCheckboxes>(message: string, options?: ConfirmOptionsWithCheckboxes<T>): Promise<[boolean, Record<keyof T, boolean>]>; // prettier-ignore
129
+ public async confirm<T extends ConfirmModalCheckboxes>(title: string, message: string, options?: ConfirmOptionsWithCheckboxes<T>): Promise<[boolean, Record<keyof T, boolean>]>; // prettier-ignore
130
+ /* eslint-enable max-len */
131
+
132
+ public async confirm(
133
+ messageOrTitle: string,
134
+ messageOrOptions?: string | ConfirmOptions | ConfirmOptionsWithCheckboxes,
135
+ options?: ConfirmOptions | ConfirmOptionsWithCheckboxes,
136
+ ): Promise<boolean | [boolean, Record<string, boolean>]> {
137
+ const getProperties = (): AcceptRefs<ConfirmModalProps> => {
138
+ if (typeof messageOrOptions !== 'string') {
139
+ return {
140
+ ...(messageOrOptions ?? {}),
141
+ message: messageOrTitle,
142
+ required: !!messageOrOptions?.required,
143
+ };
144
+ }
145
+
146
+ return {
147
+ ...(options ?? {}),
148
+ title: messageOrTitle,
149
+ message: messageOrOptions,
150
+ required: !!options?.required,
151
+ };
152
+ };
153
+
154
+ const properties = getProperties();
155
+ const result = await this.modalForm(this.requireComponent('confirm-modal'), properties);
156
+ const confirmed = typeof result === 'object' ? result[0] : (result ?? false);
157
+ const checkboxes =
158
+ typeof result === 'object'
159
+ ? result[1]
160
+ : Object.entries(properties.checkboxes ?? {}).reduce(
161
+ (values, [checkbox, { default: defaultValue }]) => ({
162
+ [checkbox]: defaultValue ?? false,
163
+ ...values,
164
+ }),
165
+ {} as Record<string, boolean>,
166
+ );
167
+
168
+ for (const [name, checkbox] of Object.entries(properties.checkboxes ?? {})) {
169
+ if (!checkbox.required || checkboxes[name]) {
170
+ continue;
171
+ }
172
+
173
+ if (confirmed && isDevelopment()) {
174
+ // eslint-disable-next-line no-console
175
+ console.warn(`Confirmed confirm modal was suppressed because required '${name}' checkbox was missing`);
176
+ }
177
+
178
+ return [false, checkboxes];
179
+ }
51
180
 
52
- return result ?? false;
181
+ return 'checkboxes' in properties ? [confirmed, checkboxes] : confirmed;
53
182
  }
54
183
 
55
- public async loading<T>(operation: Promise<T>): Promise<T>;
56
- public async loading<T>(message: string, operation: Promise<T>): Promise<T>;
57
- public async loading<T>(messageOrOperation: string | Promise<T>, operation?: Promise<T>): Promise<T> {
58
- operation = typeof messageOrOperation === 'string' ? (operation as Promise<T>) : messageOrOperation;
184
+ public async prompt(message: string, options?: PromptOptions): Promise<string | null>;
185
+ public async prompt(title: string, message: string, options?: PromptOptions): Promise<string | null>;
186
+ public async prompt(
187
+ messageOrTitle: string,
188
+ messageOrOptions?: string | PromptOptions,
189
+ options?: PromptOptions,
190
+ ): Promise<string | null> {
191
+ const trim = options?.trim ?? true;
192
+ const getProperties = (): PromptModalProps => {
193
+ if (typeof messageOrOptions !== 'string') {
194
+ return {
195
+ message: messageOrTitle,
196
+ ...(messageOrOptions ?? {}),
197
+ } as PromptModalProps;
198
+ }
59
199
 
60
- const message = typeof messageOrOperation === 'string' ? messageOrOperation : undefined;
61
- const modal = await this.openModal(this.requireComponent(UIComponents.LoadingModal), { message });
62
- const result = await operation;
200
+ return {
201
+ title: messageOrTitle,
202
+ message: messageOrOptions,
203
+ ...(options ?? {}),
204
+ } as PromptModalProps;
205
+ };
63
206
 
64
- await this.closeModal(modal.id);
207
+ const rawResult = await this.modalForm(this.requireComponent('prompt-modal'), getProperties());
208
+ const result = trim && typeof rawResult === 'string' ? rawResult?.trim() : rawResult;
65
209
 
66
- return result;
210
+ return result ?? null;
67
211
  }
68
212
 
69
- public registerComponent(name: UIComponent, component: Component): void {
70
- this.components[name] = component;
213
+ public async loading<T>(operation: Promise<T> | (() => T)): Promise<T>;
214
+ public async loading<T>(message: string, operation: Promise<T> | (() => T)): Promise<T>;
215
+ public async loading<T>(options: LoadingOptions, operation: Promise<T> | (() => T)): Promise<T>;
216
+ public async loading<T>(
217
+ operationOrMessageOrOptions: string | LoadingOptions | Promise<T> | (() => T),
218
+ operation?: Promise<T> | (() => T),
219
+ ): Promise<T> {
220
+ const processOperation = (o: Promise<T> | (() => T)) => (typeof o === 'function' ? Promise.resolve(o()) : o);
221
+ const processArgs = (): { operationPromise: Promise<T>; props?: AcceptRefs<LoadingModalProps> } => {
222
+ if (typeof operationOrMessageOrOptions === 'string') {
223
+ return {
224
+ props: { message: operationOrMessageOrOptions },
225
+ operationPromise: processOperation(operation as Promise<T> | (() => T)),
226
+ };
227
+ }
228
+
229
+ if (typeof operationOrMessageOrOptions === 'function' || operationOrMessageOrOptions instanceof Promise) {
230
+ return { operationPromise: processOperation(operationOrMessageOrOptions) };
231
+ }
232
+
233
+ return {
234
+ props: operationOrMessageOrOptions,
235
+ operationPromise: processOperation(operation as Promise<T> | (() => T)),
236
+ };
237
+ };
238
+
239
+ const { operationPromise, props } = processArgs();
240
+ const modal = await this.modal(this.requireComponent('loading-modal'), props);
241
+
242
+ try {
243
+ const result = await operationPromise;
244
+
245
+ await after({ ms: 500 });
246
+
247
+ return result;
248
+ } finally {
249
+ await this.closeModal(modal.id);
250
+ }
251
+ }
252
+
253
+ public toast(message: string, options: ToastOptions = {}): void {
254
+ const { component, ...otherOptions } = options;
255
+ const toast: UIToast = {
256
+ id: uuid(),
257
+ properties: { message, ...otherOptions },
258
+ component: markRaw(component ?? this.requireComponent('toast')),
259
+ };
260
+
261
+ this.setState('toasts', this.toasts.concat(toast));
71
262
  }
72
263
 
73
- public async openModal<TModalComponent extends ModalComponent>(
74
- component: TModalComponent,
75
- properties?: ModalProperties<TModalComponent>,
76
- ): Promise<Modal<ModalResult<TModalComponent>>> {
264
+ public modal<T extends Component>(
265
+ ...args: {} extends ComponentProps<T>
266
+ ? [component: T, props?: AcceptRefs<ComponentProps<T>>]
267
+ : [component: T, props: AcceptRefs<ComponentProps<T>>]
268
+ ): Promise<UIModal<ModalResult<T>>>;
269
+
270
+ public async modal<T extends Component>(component: T, props?: ComponentProps<T>): Promise<UIModal<ModalResult<T>>> {
77
271
  const id = uuid();
78
- const callbacks: Partial<ModalCallbacks<ModalResult<TModalComponent>>> = {};
79
- const modal: Modal<ModalResult<TModalComponent>> = {
272
+ const callbacks: Partial<ModalCallbacks<ModalResult<T>>> = {};
273
+ const modal: UIModal<ModalResult<T>> = {
80
274
  id,
81
- properties: properties ?? {},
275
+ closing: false,
276
+ properties: props ?? {},
82
277
  component: markRaw(component),
83
278
  beforeClose: new Promise((resolve) => (callbacks.willClose = resolve)),
84
- afterClose: new Promise((resolve) => (callbacks.closed = resolve)),
279
+ afterClose: new Promise((resolve) => (callbacks.hasClosed = resolve)),
85
280
  };
86
- const activeModal = this.modals.at(-1);
87
281
  const modals = this.modals.concat(modal);
88
282
 
89
283
  this.modalCallbacks[modal.id] = callbacks;
@@ -91,61 +285,115 @@ export class UIService extends Service {
91
285
  this.setState({ modals });
92
286
 
93
287
  await nextTick();
94
- await (activeModal && Events.emit('hide-modal', { id: activeModal.id }));
95
- await Promise.all([
96
- activeModal || Events.emit('show-overlays-backdrop'),
97
- Events.emit('show-modal', { id: modal.id }),
98
- ]);
99
288
 
100
289
  return modal;
101
290
  }
102
291
 
292
+ public modalForm<T extends Component>(
293
+ ...args: {} extends ComponentProps<T>
294
+ ? [component: T, props?: AcceptRefs<ComponentProps<T>>]
295
+ : [component: T, props: AcceptRefs<ComponentProps<T>>]
296
+ ): Promise<ModalResult<T> | undefined>;
297
+
298
+ public async modalForm<T extends Component>(
299
+ component: T,
300
+ props?: ComponentProps<T>,
301
+ ): Promise<ModalResult<T> | undefined> {
302
+ const modal = await this.modal<T>(component, props as ComponentProps<T>);
303
+ const result = await modal.beforeClose;
304
+
305
+ return result;
306
+ }
307
+
103
308
  public async closeModal(id: string, result?: unknown): Promise<void> {
309
+ if (!App.isMounted()) {
310
+ await this.removeModal(id, result);
311
+
312
+ return;
313
+ }
314
+
104
315
  await Events.emit('close-modal', { id, result });
105
316
  }
106
317
 
107
- protected async boot(): Promise<void> {
108
- await super.boot();
318
+ public async closeAllModals(): Promise<void> {
319
+ while (this.modals.length > 0) {
320
+ await this.closeModal(required(this.modals[this.modals.length - 1]).id);
321
+ }
322
+ }
109
323
 
324
+ protected override async boot(): Promise<void> {
110
325
  this.watchModalEvents();
326
+ this.watchMountedEvent();
327
+ this.watchViewportBreakpoints();
111
328
  }
112
329
 
113
- private requireComponent(name: UIComponent): Component {
114
- return this.components[name] ?? fail(`UI Component '${name}' is not defined!`);
330
+ private async removeModal(id: string, result?: unknown): Promise<void> {
331
+ this.setState(
332
+ 'modals',
333
+ this.modals.filter((m) => m.id !== id),
334
+ );
335
+
336
+ this.modalCallbacks[id]?.hasClosed?.(result);
337
+
338
+ delete this.modalCallbacks[id];
115
339
  }
116
340
 
117
341
  private watchModalEvents(): void {
118
- Events.on('modal-will-close', ({ modal, result }) => {
119
- this.modalCallbacks[modal.id]?.willClose?.(result);
342
+ Events.on('modal-will-close', ({ modal: { id }, result }) => {
343
+ const modal = this.modals.find((_modal) => id === _modal.id);
120
344
 
121
- if (this.modals.length === 1) {
122
- Events.emit('hide-overlays-backdrop');
345
+ if (modal) {
346
+ modal.closing = true;
123
347
  }
348
+
349
+ this.modalCallbacks[id]?.willClose?.(result);
350
+ });
351
+
352
+ Events.on('modal-has-closed', async ({ modal: { id }, result }) => {
353
+ await this.removeModal(id, result);
124
354
  });
355
+ }
125
356
 
126
- Events.on('modal-closed', async ({ modal, result }) => {
127
- this.setState({ modals: this.modals.filter((m) => m.id !== modal.id) });
357
+ private watchMountedEvent(): void {
358
+ Events.once('application-mounted', async () => {
359
+ if (!globalThis.document || !globalThis.getComputedStyle) {
360
+ return;
361
+ }
362
+
363
+ const splash = globalThis.document.getElementById('splash');
128
364
 
129
- this.modalCallbacks[modal.id]?.closed?.(result);
365
+ if (!splash) {
366
+ return;
367
+ }
130
368
 
131
- delete this.modalCallbacks[modal.id];
369
+ if (globalThis.getComputedStyle(splash).opacity !== '0') {
370
+ splash.style.opacity = '0';
132
371
 
133
- const activeModal = this.modals.at(-1);
372
+ await after({ ms: 600 });
373
+ }
134
374
 
135
- await (activeModal && Events.emit('show-modal', { id: activeModal.id }));
375
+ splash.remove();
136
376
  });
137
377
  }
138
378
 
379
+ private watchViewportBreakpoints(): void {
380
+ if (!globalThis.matchMedia) {
381
+ return;
382
+ }
383
+
384
+ const media = globalThis.matchMedia(`(min-width: ${MOBILE_BREAKPOINT}px)`);
385
+
386
+ media.addEventListener('change', () => this.setState({ layout: getCurrentLayout() }));
387
+ }
388
+
139
389
  }
140
390
 
141
- export default facade(new UIService());
391
+ export default facade(UIService);
142
392
 
143
- declare module '@/services/Events' {
393
+ declare module '@aerogel/core/services/Events' {
144
394
  export interface EventsPayload {
145
- 'modal-will-close': { modal: Modal; result?: unknown };
146
- 'modal-closed': { modal: Modal; result?: unknown };
147
395
  'close-modal': { id: string; result?: unknown };
148
- 'hide-modal': { id: string };
149
- 'show-modal': { id: string };
396
+ 'modal-will-close': { modal: UIModal; result?: unknown };
397
+ 'modal-has-closed': { modal: UIModal; result?: unknown };
150
398
  }
151
399
  }
package/src/ui/index.ts CHANGED
@@ -1,43 +1,52 @@
1
+ import AlertModal from '@aerogel/core/components/ui/AlertModal.vue';
2
+ import ConfirmModal from '@aerogel/core/components/ui/ConfirmModal.vue';
3
+ import ErrorReportModal from '@aerogel/core/components/ui/ErrorReportModal.vue';
4
+ import LoadingModal from '@aerogel/core/components/ui/LoadingModal.vue';
5
+ import PromptModal from '@aerogel/core/components/ui/PromptModal.vue';
6
+ import StartupCrash from '@aerogel/core/components/ui/StartupCrash.vue';
7
+ import Toast from '@aerogel/core/components/ui/Toast.vue';
8
+ import { bootServices } from '@aerogel/core/services';
9
+ import { definePlugin } from '@aerogel/core/plugins';
10
+
11
+ import UI from './UI';
12
+ import type { UIComponents } from './UI';
1
13
  import type { Component } from 'vue';
2
14
 
3
- import { bootServices } from '@/services';
4
- import { definePlugin } from '@/plugins';
5
-
6
- import UI, { UIComponents } from './UI';
7
- import AGAlertModal from '../components/modals/AGAlertModal.vue';
8
- import AGConfirmModal from '../components/modals/AGConfirmModal.vue';
9
- import AGLoadingModal from '../components/modals/AGLoadingModal.vue';
10
- import type { UIComponent } from './UI';
11
-
12
- export { UI, UIComponents, UIComponent };
13
-
14
15
  const services = { $ui: UI };
15
16
 
17
+ export * from './UI';
18
+ export * from './utils';
19
+ export { default as UI } from './UI';
20
+
16
21
  export type UIServices = typeof services;
17
22
 
18
23
  export default definePlugin({
19
24
  async install(app, options) {
20
- const defaultComponents = {
21
- [UIComponents.AlertModal]: AGAlertModal,
22
- [UIComponents.ConfirmModal]: AGConfirmModal,
23
- [UIComponents.LoadingModal]: AGLoadingModal,
25
+ const components: Partial<Record<keyof UIComponents, Component>> = {
26
+ 'alert-modal': AlertModal,
27
+ 'confirm-modal': ConfirmModal,
28
+ 'error-report-modal': ErrorReportModal,
29
+ 'loading-modal': LoadingModal,
30
+ 'prompt-modal': PromptModal,
31
+ 'startup-crash': StartupCrash,
32
+ 'toast': Toast,
33
+ ...options.components,
24
34
  };
25
35
 
26
- Object.entries({
27
- ...defaultComponents,
28
- ...options.components,
29
- }).forEach(([name, component]) => UI.registerComponent(name as UIComponent, component));
36
+ for (const [name, component] of Object.entries(components)) {
37
+ UI.registerComponent(name as keyof UIComponents, component as UIComponents[keyof UIComponents]);
38
+ }
30
39
 
31
40
  await bootServices(app, services);
32
41
  },
33
42
  });
34
43
 
35
- declare module '@/bootstrap/options' {
36
- interface AerogelOptions {
37
- components?: Partial<Record<UIComponent, Component>>;
44
+ declare module '@aerogel/core/bootstrap/options' {
45
+ export interface AerogelOptions {
46
+ components?: Partial<Partial<UIComponents>>;
38
47
  }
39
48
  }
40
49
 
41
- declare module '@/services' {
50
+ declare module '@aerogel/core/services' {
42
51
  export interface Services extends UIServices {}
43
52
  }
@@ -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
+ }