@aerogel/core 0.0.0-next.f16bd1d894543c5303039c49f6f33488a1ffe931 → 0.0.0-next.f86b4b09f066c4aef21796a37dbc8417b7dce3cd

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 (118) hide show
  1. package/dist/aerogel-core.cjs.js +1 -1
  2. package/dist/aerogel-core.cjs.js.map +1 -1
  3. package/dist/aerogel-core.d.ts +1396 -235
  4. package/dist/aerogel-core.esm.js +1 -1
  5. package/dist/aerogel-core.esm.js.map +1 -1
  6. package/histoire.config.ts +7 -0
  7. package/package.json +14 -5
  8. package/postcss.config.js +6 -0
  9. package/src/assets/histoire.css +3 -0
  10. package/src/bootstrap/bootstrap.test.ts +3 -3
  11. package/src/bootstrap/index.ts +35 -5
  12. package/src/bootstrap/options.ts +3 -0
  13. package/src/components/AGAppLayout.vue +7 -2
  14. package/src/components/AGAppOverlays.vue +5 -1
  15. package/src/components/AGAppSnackbars.vue +2 -2
  16. package/src/components/composition.ts +23 -0
  17. package/src/components/forms/AGCheckbox.vue +7 -1
  18. package/src/components/forms/AGForm.vue +9 -10
  19. package/src/components/forms/AGInput.vue +10 -6
  20. package/src/components/forms/AGSelect.story.vue +46 -0
  21. package/src/components/forms/AGSelect.vue +60 -0
  22. package/src/components/forms/index.ts +5 -6
  23. package/src/components/headless/forms/AGHeadlessButton.ts +3 -0
  24. package/src/components/headless/forms/AGHeadlessButton.vue +24 -12
  25. package/src/components/headless/forms/AGHeadlessInput.ts +30 -4
  26. package/src/components/headless/forms/AGHeadlessInput.vue +23 -7
  27. package/src/components/headless/forms/AGHeadlessInputDescription.vue +28 -0
  28. package/src/components/headless/forms/AGHeadlessInputInput.vue +44 -5
  29. package/src/components/headless/forms/AGHeadlessInputLabel.vue +8 -2
  30. package/src/components/headless/forms/AGHeadlessInputTextArea.vue +43 -0
  31. package/src/components/headless/forms/AGHeadlessSelect.ts +42 -0
  32. package/src/components/headless/forms/AGHeadlessSelect.vue +77 -0
  33. package/src/components/headless/forms/AGHeadlessSelectButton.vue +24 -0
  34. package/src/components/headless/forms/AGHeadlessSelectError.vue +26 -0
  35. package/src/components/headless/forms/AGHeadlessSelectLabel.vue +24 -0
  36. package/src/components/headless/forms/AGHeadlessSelectOption.ts +4 -0
  37. package/src/components/headless/forms/AGHeadlessSelectOption.vue +39 -0
  38. package/src/components/headless/forms/AGHeadlessSelectOptions.ts +3 -0
  39. package/src/components/headless/forms/composition.ts +10 -0
  40. package/src/components/headless/forms/index.ts +13 -1
  41. package/src/components/headless/modals/AGHeadlessModal.ts +27 -0
  42. package/src/components/headless/modals/AGHeadlessModal.vue +3 -5
  43. package/src/components/headless/modals/index.ts +4 -6
  44. package/src/components/headless/snackbars/index.ts +23 -8
  45. package/src/components/index.ts +3 -1
  46. package/src/components/interfaces.ts +24 -0
  47. package/src/components/lib/AGErrorMessage.vue +16 -0
  48. package/src/components/lib/AGLink.vue +9 -0
  49. package/src/components/{basic → lib}/AGMarkdown.vue +12 -11
  50. package/src/components/lib/AGMeasured.vue +16 -0
  51. package/src/components/lib/AGStartupCrash.vue +31 -0
  52. package/src/components/lib/index.ts +5 -0
  53. package/src/components/modals/AGAlertModal.ts +15 -0
  54. package/src/components/modals/AGAlertModal.vue +4 -16
  55. package/src/components/modals/AGConfirmModal.ts +35 -0
  56. package/src/components/modals/AGConfirmModal.vue +9 -13
  57. package/src/components/modals/AGErrorReportModal.ts +27 -1
  58. package/src/components/modals/AGErrorReportModal.vue +8 -16
  59. package/src/components/modals/AGErrorReportModalButtons.vue +6 -1
  60. package/src/components/modals/AGErrorReportModalTitle.vue +2 -2
  61. package/src/components/modals/AGLoadingModal.ts +23 -0
  62. package/src/components/modals/AGLoadingModal.vue +4 -8
  63. package/src/components/modals/AGModal.ts +1 -1
  64. package/src/components/modals/AGModal.vue +15 -12
  65. package/src/components/modals/AGModalTitle.vue +9 -0
  66. package/src/components/modals/AGPromptModal.ts +36 -0
  67. package/src/components/modals/AGPromptModal.vue +34 -0
  68. package/src/components/modals/index.ts +13 -17
  69. package/src/components/snackbars/AGSnackbar.vue +4 -10
  70. package/src/components/utils.ts +10 -0
  71. package/src/directives/index.ts +5 -1
  72. package/src/directives/measure.ts +40 -0
  73. package/src/errors/Errors.ts +36 -12
  74. package/src/errors/index.ts +10 -23
  75. package/src/errors/utils.ts +35 -0
  76. package/src/forms/Form.test.ts +28 -0
  77. package/src/forms/Form.ts +80 -14
  78. package/src/forms/index.ts +3 -1
  79. package/src/forms/utils.ts +34 -3
  80. package/src/forms/validation.ts +19 -0
  81. package/src/jobs/Job.ts +5 -0
  82. package/src/jobs/index.ts +7 -0
  83. package/src/lang/DefaultLangProvider.ts +43 -0
  84. package/src/lang/Lang.state.ts +11 -0
  85. package/src/lang/Lang.ts +44 -29
  86. package/src/main.histoire.ts +1 -0
  87. package/src/main.ts +3 -0
  88. package/src/plugins/Plugin.ts +1 -0
  89. package/src/plugins/index.ts +19 -0
  90. package/src/services/App.state.ts +24 -6
  91. package/src/services/App.ts +43 -5
  92. package/src/services/Cache.ts +43 -0
  93. package/src/services/Events.test.ts +39 -0
  94. package/src/services/Events.ts +100 -30
  95. package/src/services/Service.ts +64 -17
  96. package/src/services/index.ts +11 -5
  97. package/src/services/store.ts +8 -5
  98. package/src/testing/index.ts +25 -0
  99. package/src/testing/setup.ts +19 -0
  100. package/src/ui/UI.state.ts +7 -0
  101. package/src/ui/UI.ts +194 -27
  102. package/src/ui/index.ts +9 -3
  103. package/src/ui/utils.ts +16 -0
  104. package/src/utils/composition/events.ts +1 -0
  105. package/src/utils/composition/state.test.ts +47 -0
  106. package/src/utils/composition/state.ts +24 -0
  107. package/src/utils/index.ts +1 -0
  108. package/src/utils/markdown.ts +11 -2
  109. package/src/utils/tailwindcss.test.ts +26 -0
  110. package/src/utils/tailwindcss.ts +7 -0
  111. package/src/utils/vue.ts +23 -5
  112. package/tailwind.config.js +4 -0
  113. package/tsconfig.json +1 -1
  114. package/vite.config.ts +4 -1
  115. package/.eslintrc.js +0 -3
  116. package/dist/virtual.d.ts +0 -11
  117. package/src/components/basic/index.ts +0 -3
  118. package/src/types/virtual.d.ts +0 -11
@@ -1,5 +1,6 @@
1
- import { MagicObject, PromisedValue, Storage, isEmpty, objectDeepClone, objectOnly } from '@noeldemartin/utils';
1
+ import { MagicObject, PromisedValue, Storage, fail, isEmpty, objectDeepClone, objectOnly } from '@noeldemartin/utils';
2
2
  import type { Constructor } from '@noeldemartin/utils';
3
+ import type { MaybeRef } from 'vue';
3
4
  import type { Store } from 'pinia';
4
5
 
5
6
  import ServiceBootError from '@/errors/ServiceBootError';
@@ -8,9 +9,12 @@ import { defineServiceStore } from '@/services/store';
8
9
  export type ServiceState = Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
9
10
  export type DefaultServiceState = any; // eslint-disable-line @typescript-eslint/no-explicit-any
10
11
  export type ServiceConstructor<T extends Service = Service> = Constructor<T> & typeof Service;
12
+ export type UnrefServiceState<State extends ServiceState> = {
13
+ [K in keyof State]: State[K] extends MaybeRef<infer T> ? T : State[K];
14
+ };
11
15
 
12
16
  export type ComputedStateDefinition<TState extends ServiceState, TComputedState extends ServiceState> = {
13
- [K in keyof TComputedState]: (state: TState) => TComputedState[K];
17
+ [K in keyof TComputedState]: (state: UnrefServiceState<TState>) => TComputedState[K];
14
18
  } & ThisType<{
15
19
  readonly [K in keyof TComputedState]: TComputedState[K];
16
20
  }>;
@@ -20,12 +24,14 @@ export function defineServiceState<
20
24
  ComputedState extends ServiceState = {}
21
25
  >(options: {
22
26
  name: string;
23
- initialState: State;
27
+ initialState: State | (() => State);
24
28
  persist?: (keyof State)[];
25
29
  computed?: ComputedStateDefinition<State, ComputedState>;
26
30
  serialize?: (state: Partial<State>) => Partial<State>;
27
- }): Constructor<State> & Constructor<ComputedState> & Constructor<Service<State, ComputedState, Partial<State>>> {
28
- return class extends Service<State, ComputedState> {
31
+ }): Constructor<UnrefServiceState<State>> &
32
+ Constructor<ComputedState> &
33
+ Constructor<Service<UnrefServiceState<State>, ComputedState, Partial<UnrefServiceState<State>>>> {
34
+ return class extends Service<UnrefServiceState<State>, ComputedState> {
29
35
 
30
36
  public static persist = (options.persist as string[]) ?? [];
31
37
 
@@ -37,21 +43,41 @@ export function defineServiceState<
37
43
  return options.name ?? null;
38
44
  }
39
45
 
40
- protected getInitialState(): State {
41
- return options.initialState;
46
+ protected getInitialState(): UnrefServiceState<State> {
47
+ if (typeof options.initialState === 'function') {
48
+ return options.initialState();
49
+ }
50
+
51
+ return Object.entries(options.initialState).reduce((state, [key, value]) => {
52
+ try {
53
+ value = structuredClone(value);
54
+ } catch (error) {
55
+ // eslint-disable-next-line no-console
56
+ console.warn(
57
+ `Could not clone '${key}' state from ${this.getName()} service, ` +
58
+ 'this may cause problems if you\'re using multiple instances of the service ' +
59
+ '(for example, in unit tests).\n' +
60
+ 'To fix this problem, declare your initialState as a function instead.',
61
+ );
62
+ }
63
+
64
+ state[key as keyof State] = value;
65
+
66
+ return state;
67
+ }, {} as UnrefServiceState<State>);
42
68
  }
43
69
 
44
- protected getComputedStateDefinition(): ComputedStateDefinition<State, ComputedState> {
45
- return options.computed ?? ({} as ComputedStateDefinition<State, ComputedState>);
70
+ protected getComputedStateDefinition(): ComputedStateDefinition<UnrefServiceState<State>, ComputedState> {
71
+ return (options.computed ?? {}) as ComputedStateDefinition<UnrefServiceState<State>, ComputedState>;
46
72
  }
47
73
 
48
74
  protected serializePersistedState(state: Partial<State>): Partial<State> {
49
75
  return options.serialize?.(state) ?? state;
50
76
  }
51
-
52
- } as unknown as Constructor<State> &
77
+
78
+ } as unknown as Constructor<UnrefServiceState<State>> &
53
79
  Constructor<ComputedState> &
54
- Constructor<Service<State, ComputedState, Partial<State>>>;
80
+ Constructor<Service<UnrefServiceState<State>, ComputedState, Partial<UnrefServiceState<State>>>>;
55
81
  }
56
82
 
57
83
  export default class Service<
@@ -65,7 +91,7 @@ export default class Service<
65
91
  protected _name: string;
66
92
  private _booted: PromisedValue<void>;
67
93
  private _computedStateKeys: Set<keyof State>;
68
- private _store?: Store | false;
94
+ private _store: Store<string, State, ComputedState, {}> | false;
69
95
 
70
96
  constructor() {
71
97
  super();
@@ -93,7 +119,8 @@ export default class Service<
93
119
  const handleError = (error: unknown) => this._booted.reject(new ServiceBootError(this._name, error));
94
120
 
95
121
  try {
96
- this.boot()
122
+ this.frameworkBoot()
123
+ .then(() => this.boot())
97
124
  .then(() => this._booted.resolve())
98
125
  .catch(handleError);
99
126
  } catch (error) {
@@ -103,6 +130,10 @@ export default class Service<
103
130
  return this._booted;
104
131
  }
105
132
 
133
+ public hasPersistedState(): boolean {
134
+ return Storage.has(this._name);
135
+ }
136
+
106
137
  public hasState<P extends keyof State>(property: P): boolean {
107
138
  if (!this._store) {
108
139
  return false;
@@ -161,7 +192,11 @@ export default class Service<
161
192
  return;
162
193
  }
163
194
 
164
- const storage = Storage.require<ServiceStorage>(this._name);
195
+ const storage = Storage.get<ServiceStorage>(this._name);
196
+
197
+ if (!storage) {
198
+ return;
199
+ }
165
200
 
166
201
  Storage.set(this._name, {
167
202
  ...storage,
@@ -189,11 +224,15 @@ export default class Service<
189
224
  return state;
190
225
  }
191
226
 
227
+ protected async frameworkBoot(): Promise<void> {
228
+ this.initializePersistedState();
229
+ }
230
+
192
231
  protected async boot(): Promise<void> {
193
- this.restorePersistedState();
232
+ // Placeholder for overrides, don't place any functionality here.
194
233
  }
195
234
 
196
- protected restorePersistedState(): void {
235
+ protected initializePersistedState(): void {
197
236
  // TODO fix this.static()
198
237
  const persist = (this.constructor as unknown as { persist: string[] }).persist;
199
238
 
@@ -211,4 +250,12 @@ export default class Service<
211
250
  Storage.set(this._name, objectOnly(this.getState(), persist));
212
251
  }
213
252
 
253
+ protected requireStore(): Store<string, State, ComputedState, {}> {
254
+ if (!this._store) {
255
+ return fail(`Failed getting '${this._name}' store`);
256
+ }
257
+
258
+ return this._store;
259
+ }
260
+
214
261
  }
@@ -3,15 +3,18 @@ import type { App as VueApp } from 'vue';
3
3
  import { definePlugin } from '@/plugins';
4
4
 
5
5
  import App from './App';
6
+ import Cache from './Cache';
6
7
  import Events from './Events';
7
8
  import Service from './Service';
8
9
  import { getPiniaStore } from './store';
9
10
 
10
11
  export * from './App';
12
+ export * from './Cache';
11
13
  export * from './Events';
12
14
  export * from './Service';
15
+ export * from './store';
13
16
 
14
- export { App, Events, Service };
17
+ export { App, Cache, Events, Service };
15
18
 
16
19
  const defaultServices = {
17
20
  $app: App,
@@ -24,13 +27,16 @@ export interface Services extends DefaultServices {}
24
27
 
25
28
  export async function bootServices(app: VueApp, services: Record<string, Service>): Promise<void> {
26
29
  await Promise.all(
27
- Object.entries(services).map(async ([_, service]) => {
28
- // eslint-disable-next-line no-console
29
- await service.launch().catch((error) => console.error(error));
30
+ Object.entries(services).map(async ([name, service]) => {
31
+ await service
32
+ .launch()
33
+ .catch((error) => app.config.errorHandler?.(error, null, `Failed launching ${name}.`));
30
34
  }),
31
35
  );
32
36
 
33
37
  Object.assign(app.config.globalProperties, services);
38
+
39
+ App.development && Object.assign(window, services);
34
40
  }
35
41
 
36
42
  export default definePlugin({
@@ -47,7 +53,7 @@ export default definePlugin({
47
53
  });
48
54
 
49
55
  declare module '@/bootstrap/options' {
50
- interface AerogelOptions {
56
+ export interface AerogelOptions {
51
57
  services?: Record<string, Service>;
52
58
  }
53
59
  }
@@ -1,16 +1,19 @@
1
+ import { tap } from '@noeldemartin/utils';
1
2
  import { createPinia, defineStore, setActivePinia } from 'pinia';
2
3
  import type { DefineStoreOptions, Pinia, StateTree, Store, _GettersTree } from 'pinia';
3
4
 
4
5
  let _store: Pinia | null = null;
5
6
 
6
7
  function initializePiniaStore(): Pinia {
7
- if (!_store) {
8
- _store = createPinia();
8
+ return _store ?? resetPiniaStore();
9
+ }
9
10
 
10
- setActivePinia(_store);
11
- }
11
+ export function resetPiniaStore(): Pinia {
12
+ return tap(createPinia(), (store) => {
13
+ _store = store;
12
14
 
13
- return _store;
15
+ setActivePinia(store);
16
+ });
14
17
  }
15
18
 
16
19
  export function getPiniaStore(): Pinia {
@@ -0,0 +1,25 @@
1
+ import type { GetClosureArgs } from '@noeldemartin/utils';
2
+
3
+ import Events from '@/services/Events';
4
+ import { definePlugin } from '@/plugins';
5
+
6
+ export interface AerogelTestingRuntime {
7
+ on: (typeof Events)['on'];
8
+ }
9
+
10
+ export default definePlugin({
11
+ async install() {
12
+ if (import.meta.env.MODE !== 'testing') {
13
+ return;
14
+ }
15
+
16
+ globalThis.testingRuntime = {
17
+ on: ((...args: GetClosureArgs<(typeof Events)['on']>) => Events.on(...args)) as (typeof Events)['on'],
18
+ };
19
+ },
20
+ });
21
+
22
+ declare global {
23
+ // eslint-disable-next-line no-var
24
+ var testingRuntime: AerogelTestingRuntime | undefined;
25
+ }
@@ -0,0 +1,19 @@
1
+ import { mock, tap } from '@noeldemartin/utils';
2
+ import { beforeEach, vi } from 'vitest';
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
+ });
12
+ });
13
+
14
+ beforeEach(() => {
15
+ vi.stubGlobal('document', {
16
+ querySelector: () => null,
17
+ getElementById: () => null,
18
+ });
19
+ });
@@ -2,6 +2,8 @@ import type { Component } from 'vue';
2
2
 
3
3
  import { defineServiceState } from '@/services/Service';
4
4
 
5
+ import { Layouts, getCurrentLayout } from './utils';
6
+
5
7
  export interface Modal<T = unknown> {
6
8
  id: string;
7
9
  properties: Record<string, unknown>;
@@ -28,5 +30,10 @@ export default defineServiceState({
28
30
  initialState: {
29
31
  modals: [] as Modal[],
30
32
  snackbars: [] as Snackbar[],
33
+ layout: getCurrentLayout(),
34
+ },
35
+ computed: {
36
+ mobile: ({ layout }) => layout === Layouts.Mobile,
37
+ desktop: ({ layout }) => layout === Layouts.Desktop,
31
38
  },
32
39
  });
package/src/ui/UI.ts CHANGED
@@ -1,12 +1,16 @@
1
- import { facade, fail, uuid } from '@noeldemartin/utils';
1
+ import { after, facade, fail, uuid } from '@noeldemartin/utils';
2
2
  import { markRaw, nextTick } from 'vue';
3
3
  import type { Component } from 'vue';
4
4
  import type { ObjectValues } from '@noeldemartin/utils';
5
5
 
6
+ import App from '@/services/App';
6
7
  import Events from '@/services/Events';
8
+ import type { Color } from '@/components/constants';
7
9
  import type { SnackbarAction, SnackbarColor } from '@/components/headless/snackbars';
10
+ import type { AGAlertModalProps, AGConfirmModalProps, AGLoadingModalProps, AGPromptModalProps } from '@/components';
8
11
 
9
12
  import Service from './UI.state';
13
+ import { MOBILE_BREAKPOINT, getCurrentLayout } from './utils';
10
14
  import type { Modal, ModalComponent, Snackbar } from './UI.state';
11
15
 
12
16
  interface ModalCallbacks<T = unknown> {
@@ -24,11 +28,37 @@ export const UIComponents = {
24
28
  ConfirmModal: 'confirm-modal',
25
29
  ErrorReportModal: 'error-report-modal',
26
30
  LoadingModal: 'loading-modal',
31
+ PromptModal: 'prompt-modal',
27
32
  Snackbar: 'snackbar',
33
+ StartupCrash: 'startup-crash',
28
34
  } as const;
29
35
 
30
36
  export type UIComponent = ObjectValues<typeof UIComponents>;
31
37
 
38
+ export type ConfirmCheckboxes = Record<string, { label: string; default?: boolean; required?: boolean }>;
39
+
40
+ export interface ConfirmOptions {
41
+ acceptText?: string;
42
+ acceptColor?: Color;
43
+ cancelText?: string;
44
+ cancelColor?: Color;
45
+ }
46
+
47
+ export interface ConfirmOptionsWithCheckboxes<T extends ConfirmCheckboxes = ConfirmCheckboxes> extends ConfirmOptions {
48
+ checkboxes?: T;
49
+ }
50
+
51
+ export interface PromptOptions {
52
+ label?: string;
53
+ defaultValue?: string;
54
+ placeholder?: string;
55
+ acceptText?: string;
56
+ acceptColor?: Color;
57
+ cancelText?: string;
58
+ cancelColor?: Color;
59
+ trim?: boolean;
60
+ }
61
+
32
62
  export interface ShowSnackbarOptions {
33
63
  component?: Component;
34
64
  color?: SnackbarColor;
@@ -47,43 +77,146 @@ export class UIService extends Service {
47
77
  public alert(message: string): void;
48
78
  public alert(title: string, message: string): void;
49
79
  public alert(messageOrTitle: string, message?: string): void {
50
- const options = typeof message === 'string' ? { title: messageOrTitle, message } : { message: messageOrTitle };
80
+ const getProperties = (): AGAlertModalProps => {
81
+ if (typeof message !== 'string') {
82
+ return { message: messageOrTitle };
83
+ }
84
+
85
+ return {
86
+ title: messageOrTitle,
87
+ message,
88
+ };
89
+ };
51
90
 
52
- this.openModal(this.requireComponent(UIComponents.AlertModal), options);
91
+ this.openModal(this.requireComponent(UIComponents.AlertModal), getProperties());
53
92
  }
54
93
 
55
- public async confirm(message: string): Promise<boolean>;
56
- public async confirm(title: string, message: string): Promise<boolean>;
57
- public async confirm(messageOrTitle: string, message?: string): Promise<boolean> {
58
- const options = typeof message === 'string' ? { title: messageOrTitle, message } : { message: messageOrTitle };
59
- const modal = await this.openModal<ModalComponent<{ message: string }, boolean>>(
60
- this.requireComponent(UIComponents.ConfirmModal),
61
- options,
62
- );
94
+ /* eslint-disable max-len */
95
+ public async confirm(message: string, options?: ConfirmOptions): Promise<boolean>;
96
+ public async confirm(title: string, message: string, options?: ConfirmOptions): Promise<boolean>;
97
+ public async confirm<T extends ConfirmCheckboxes>(message: string, options?: ConfirmOptionsWithCheckboxes<T>): Promise<[boolean, Record<keyof T, boolean>]>; // prettier-ignore
98
+ public async confirm<T extends ConfirmCheckboxes>(title: string, message: string, options?: ConfirmOptionsWithCheckboxes<T>): Promise<[boolean, Record<keyof T, boolean>]>; // prettier-ignore
99
+ /* eslint-enable max-len */
100
+
101
+ public async confirm(
102
+ messageOrTitle: string,
103
+ messageOrOptions?: string | ConfirmOptions | ConfirmOptionsWithCheckboxes,
104
+ options?: ConfirmOptions | ConfirmOptionsWithCheckboxes,
105
+ ): Promise<boolean | [boolean, Record<string, boolean>]> {
106
+ const getProperties = (): AGConfirmModalProps => {
107
+ if (typeof messageOrOptions !== 'string') {
108
+ return {
109
+ message: messageOrTitle,
110
+ ...(messageOrOptions ?? {}),
111
+ };
112
+ }
113
+
114
+ return {
115
+ title: messageOrTitle,
116
+ message: messageOrOptions,
117
+ ...(options ?? {}),
118
+ };
119
+ };
120
+ const properties = getProperties();
121
+ const modal = await this.openModal<
122
+ ModalComponent<AGConfirmModalProps, boolean | [boolean, Record<string, boolean>]>
123
+ >(this.requireComponent(UIComponents.ConfirmModal), properties);
63
124
  const result = await modal.beforeClose;
64
125
 
65
- return result ?? false;
126
+ const confirmed = typeof result === 'object' ? result[0] : result ?? false;
127
+ const checkboxes =
128
+ typeof result === 'object'
129
+ ? result[1]
130
+ : Object.entries(properties.checkboxes ?? {}).reduce(
131
+ (values, [checkbox, { default: defaultValue }]) => ({
132
+ [checkbox]: defaultValue ?? false,
133
+ ...values,
134
+ }),
135
+ {} as Record<string, boolean>,
136
+ );
137
+
138
+ for (const [name, checkbox] of Object.entries(properties.checkboxes ?? {})) {
139
+ if (!checkbox.required || checkboxes[name]) {
140
+ continue;
141
+ }
142
+
143
+ if (confirmed && App.development) {
144
+ // eslint-disable-next-line no-console
145
+ console.warn(`Confirmed confirm modal was suppressed because required '${name}' checkbox was missing`);
146
+ }
147
+
148
+ return [false, checkboxes];
149
+ }
150
+
151
+ return 'checkboxes' in properties ? [confirmed, checkboxes] : confirmed;
66
152
  }
67
153
 
68
- public async loading<T>(operation: Promise<T>): Promise<T>;
69
- public async loading<T>(message: string, operation: Promise<T>): Promise<T>;
70
- public async loading<T>(messageOrOperation: string | Promise<T>, operation?: Promise<T>): Promise<T> {
71
- operation = typeof messageOrOperation === 'string' ? (operation as Promise<T>) : messageOrOperation;
154
+ public async prompt(message: string, options?: PromptOptions): Promise<string | null>;
155
+ public async prompt(title: string, message: string, options?: PromptOptions): Promise<string | null>;
156
+ public async prompt(
157
+ messageOrTitle: string,
158
+ messageOrOptions?: string | PromptOptions,
159
+ options?: PromptOptions,
160
+ ): Promise<string | null> {
161
+ const trim = options?.trim ?? true;
162
+ const getProperties = (): AGPromptModalProps => {
163
+ if (typeof messageOrOptions !== 'string') {
164
+ return {
165
+ message: messageOrTitle,
166
+ ...(messageOrOptions ?? {}),
167
+ };
168
+ }
72
169
 
73
- const message = typeof messageOrOperation === 'string' ? messageOrOperation : undefined;
74
- const modal = await this.openModal(this.requireComponent(UIComponents.LoadingModal), { message });
75
- const result = await operation;
170
+ return {
171
+ title: messageOrTitle,
172
+ message: messageOrOptions,
173
+ ...(options ?? {}),
174
+ };
175
+ };
76
176
 
77
- await this.closeModal(modal.id);
177
+ const modal = await this.openModal<ModalComponent<AGPromptModalProps, string | null>>(
178
+ this.requireComponent(UIComponents.PromptModal),
179
+ getProperties(),
180
+ );
181
+ const rawResult = await modal.beforeClose;
182
+ const result = trim && typeof rawResult === 'string' ? rawResult?.trim() : rawResult;
78
183
 
79
- return result;
184
+ return result ?? null;
185
+ }
186
+
187
+ public async loading<T>(operation: Promise<T> | (() => T)): Promise<T>;
188
+ public async loading<T>(message: string, operation: Promise<T> | (() => T)): Promise<T>;
189
+ public async loading<T>(
190
+ messageOrOperation: string | Promise<T> | (() => T),
191
+ operation?: Promise<T> | (() => T),
192
+ ): Promise<T> {
193
+ const getProperties = (): AGLoadingModalProps => {
194
+ if (typeof messageOrOperation !== 'string') {
195
+ return {};
196
+ }
197
+
198
+ return { message: messageOrOperation };
199
+ };
200
+
201
+ const modal = await this.openModal(this.requireComponent(UIComponents.LoadingModal), getProperties());
202
+
203
+ try {
204
+ operation = typeof messageOrOperation === 'string' ? (operation as () => T) : messageOrOperation;
205
+ operation = typeof operation === 'function' ? Promise.resolve(operation()) : operation;
206
+
207
+ const [result] = await Promise.all([operation, after({ seconds: 1 })]);
208
+
209
+ return result;
210
+ } finally {
211
+ await this.closeModal(modal.id);
212
+ }
80
213
  }
81
214
 
82
215
  public showSnackbar(message: string, options: ShowSnackbarOptions = {}): void {
83
216
  const snackbar: Snackbar = {
84
217
  id: uuid(),
85
218
  properties: { message, ...options },
86
- component: options.component ?? markRaw(this.requireComponent(UIComponents.Snackbar)),
219
+ component: markRaw(options.component ?? this.requireComponent(UIComponents.Snackbar)),
87
220
  };
88
221
 
89
222
  this.setState('snackbars', this.snackbars.concat(snackbar));
@@ -137,9 +270,9 @@ export class UIService extends Service {
137
270
  }
138
271
 
139
272
  protected async boot(): Promise<void> {
140
- await super.boot();
141
-
142
273
  this.watchModalEvents();
274
+ this.watchMountedEvent();
275
+ this.watchViewportBreakpoints();
143
276
  }
144
277
 
145
278
  private watchModalEvents(): void {
@@ -167,16 +300,50 @@ export class UIService extends Service {
167
300
  });
168
301
  }
169
302
 
303
+ private watchMountedEvent(): void {
304
+ Events.once('application-mounted', async () => {
305
+ if (!globalThis.document || !globalThis.getComputedStyle) {
306
+ return;
307
+ }
308
+
309
+ const splash = globalThis.document.getElementById('splash');
310
+
311
+ if (!splash) {
312
+ return;
313
+ }
314
+
315
+ if (globalThis.getComputedStyle(splash).opacity !== '0') {
316
+ splash.style.opacity = '0';
317
+
318
+ await after({ ms: 600 });
319
+ }
320
+
321
+ splash.remove();
322
+ });
323
+ }
324
+
325
+ private watchViewportBreakpoints(): void {
326
+ if (!globalThis.matchMedia) {
327
+ return;
328
+ }
329
+
330
+ const media = globalThis.matchMedia(`(min-width: ${MOBILE_BREAKPOINT}px)`);
331
+
332
+ media.addEventListener('change', () => this.setState({ layout: getCurrentLayout() }));
333
+ }
334
+
170
335
  }
171
336
 
172
- export default facade(new UIService());
337
+ export default facade(UIService);
173
338
 
174
339
  declare module '@/services/Events' {
175
340
  export interface EventsPayload {
176
- 'modal-will-close': { modal: Modal; result?: unknown };
177
- 'modal-closed': { modal: Modal; result?: unknown };
178
341
  'close-modal': { id: string; result?: unknown };
179
342
  'hide-modal': { id: string };
343
+ 'hide-overlays-backdrop': void;
344
+ 'modal-closed': { modal: Modal; result?: unknown };
345
+ 'modal-will-close': { modal: Modal; result?: unknown };
180
346
  'show-modal': { id: string };
347
+ 'show-overlays-backdrop': void;
181
348
  }
182
349
  }
package/src/ui/index.ts CHANGED
@@ -8,13 +8,17 @@ import AGAlertModal from '../components/modals/AGAlertModal.vue';
8
8
  import AGConfirmModal from '../components/modals/AGConfirmModal.vue';
9
9
  import AGErrorReportModal from '../components/modals/AGErrorReportModal.vue';
10
10
  import AGLoadingModal from '../components/modals/AGLoadingModal.vue';
11
+ import AGPromptModal from '../components/modals/AGPromptModal.vue';
11
12
  import AGSnackbar from '../components/snackbars/AGSnackbar.vue';
13
+ import AGStartupCrash from '../components/lib/AGStartupCrash.vue';
12
14
  import type { UIComponent } from './UI';
13
15
 
14
- export { UI, UIComponents, UIComponent };
15
-
16
16
  const services = { $ui: UI };
17
17
 
18
+ export * from './UI';
19
+ export * from './utils';
20
+ export { default as UI } from './UI';
21
+
18
22
  export type UIServices = typeof services;
19
23
 
20
24
  export default definePlugin({
@@ -24,7 +28,9 @@ export default definePlugin({
24
28
  [UIComponents.ConfirmModal]: AGConfirmModal,
25
29
  [UIComponents.ErrorReportModal]: AGErrorReportModal,
26
30
  [UIComponents.LoadingModal]: AGLoadingModal,
31
+ [UIComponents.PromptModal]: AGPromptModal,
27
32
  [UIComponents.Snackbar]: AGSnackbar,
33
+ [UIComponents.StartupCrash]: AGStartupCrash,
28
34
  };
29
35
 
30
36
  Object.entries({
@@ -37,7 +43,7 @@ export default definePlugin({
37
43
  });
38
44
 
39
45
  declare module '@/bootstrap/options' {
40
- interface AerogelOptions {
46
+ export interface AerogelOptions {
41
47
  components?: Partial<Record<UIComponent, Component>>;
42
48
  }
43
49
  }
@@ -0,0 +1,16 @@
1
+ export const MOBILE_BREAKPOINT = 768;
2
+
3
+ export const Layouts = {
4
+ Mobile: 'mobile',
5
+ Desktop: 'desktop',
6
+ } as const;
7
+
8
+ export type Layout = (typeof Layouts)[keyof typeof Layouts];
9
+
10
+ export function getCurrentLayout(): Layout {
11
+ if (globalThis.innerWidth > MOBILE_BREAKPOINT) {
12
+ return Layouts.Desktop;
13
+ }
14
+
15
+ return Layouts.Mobile;
16
+ }
@@ -14,6 +14,7 @@ export function useEvent<Event extends EventWithPayload>(
14
14
  event: Event,
15
15
  listener: EventListener<EventsPayload[Event]>
16
16
  ): void;
17
+ export function useEvent<Payload>(event: string, listener: (payload: Payload) => unknown): void;
17
18
  export function useEvent<Event extends string>(event: UnknownEvent<Event>, listener: EventListener): void;
18
19
 
19
20
  export function useEvent(event: string, listener: EventListener): void {