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