@aerogel/core 0.0.0-next.d7394c3aa6aac799b0971e63819a8713d05a5123 → 0.0.0-next.eed7a057cf5b844cd9f7fc6bda2d8df49fcd6736
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.d.ts +2317 -667
- package/dist/aerogel-core.js +2789 -0
- package/dist/aerogel-core.js.map +1 -0
- package/package.json +19 -34
- package/src/bootstrap/bootstrap.test.ts +4 -7
- package/src/bootstrap/index.ts +25 -16
- package/src/bootstrap/options.ts +1 -1
- package/src/components/AGAppLayout.vue +1 -1
- package/src/components/AGAppModals.vue +1 -1
- package/src/components/AGAppOverlays.vue +1 -1
- package/src/components/composition.ts +1 -1
- package/src/components/forms/AGButton.vue +2 -2
- package/src/components/forms/AGCheckbox.vue +4 -3
- package/src/components/forms/AGForm.vue +2 -2
- package/src/components/forms/AGInput.vue +6 -4
- package/src/components/forms/AGSelect.vue +3 -3
- package/src/components/headless/forms/AGHeadlessButton.ts +1 -1
- package/src/components/headless/forms/AGHeadlessButton.vue +2 -2
- package/src/components/headless/forms/AGHeadlessInput.ts +11 -4
- package/src/components/headless/forms/AGHeadlessInput.vue +3 -3
- package/src/components/headless/forms/AGHeadlessInputDescription.vue +1 -1
- package/src/components/headless/forms/AGHeadlessInputError.vue +2 -2
- package/src/components/headless/forms/AGHeadlessInputInput.vue +4 -4
- package/src/components/headless/forms/AGHeadlessInputLabel.vue +1 -1
- package/src/components/headless/forms/AGHeadlessInputTextArea.vue +3 -3
- package/src/components/headless/forms/AGHeadlessSelect.ts +3 -3
- package/src/components/headless/forms/AGHeadlessSelect.vue +5 -5
- package/src/components/headless/forms/AGHeadlessSelectButton.vue +2 -2
- package/src/components/headless/forms/AGHeadlessSelectError.vue +2 -2
- package/src/components/headless/forms/AGHeadlessSelectLabel.vue +2 -2
- package/src/components/headless/forms/AGHeadlessSelectOption.vue +3 -3
- package/src/components/headless/forms/composition.ts +1 -1
- package/src/components/headless/modals/AGHeadlessModal.ts +6 -4
- package/src/components/headless/modals/AGHeadlessModal.vue +14 -8
- package/src/components/headless/modals/AGHeadlessModalPanel.vue +13 -9
- package/src/components/headless/modals/AGHeadlessModalTitle.vue +14 -4
- package/src/components/headless/snackbars/index.ts +3 -3
- package/src/components/lib/AGErrorMessage.vue +3 -3
- package/src/components/lib/AGMarkdown.vue +16 -3
- package/src/components/lib/AGMeasured.vue +1 -1
- package/src/components/lib/AGProgressBar.vue +55 -0
- package/src/components/lib/index.ts +1 -0
- package/src/components/modals/AGAlertModal.ts +6 -3
- package/src/components/modals/AGConfirmModal.ts +16 -7
- package/src/components/modals/AGConfirmModal.vue +2 -2
- package/src/components/modals/AGErrorReportModal.ts +8 -5
- package/src/components/modals/AGErrorReportModalButtons.vue +9 -9
- package/src/components/modals/AGErrorReportModalTitle.vue +2 -2
- package/src/components/modals/AGLoadingModal.ts +11 -5
- package/src/components/modals/AGModal.ts +1 -0
- package/src/components/modals/AGModal.vue +5 -2
- package/src/components/modals/AGModalContext.ts +1 -1
- package/src/components/modals/AGModalContext.vue +15 -5
- package/src/components/modals/AGPromptModal.ts +12 -7
- package/src/components/snackbars/AGSnackbar.vue +2 -2
- package/src/components/utils.ts +7 -4
- package/src/directives/index.ts +3 -5
- package/src/directives/measure.ts +1 -1
- package/src/errors/Errors.state.ts +1 -1
- package/src/errors/Errors.ts +12 -12
- package/src/errors/JobCancelledError.ts +3 -0
- package/src/errors/index.ts +9 -6
- package/src/errors/utils.ts +17 -1
- package/src/forms/Form.test.ts +4 -3
- package/src/forms/Form.ts +27 -17
- package/src/forms/composition.ts +2 -2
- package/src/forms/index.ts +2 -1
- package/src/forms/utils.ts +20 -4
- package/src/forms/validation.ts +19 -0
- package/src/jobs/Job.ts +144 -2
- package/src/jobs/index.ts +4 -1
- package/src/jobs/listeners.ts +3 -0
- package/src/jobs/status.ts +4 -0
- package/src/lang/DefaultLangProvider.ts +7 -4
- package/src/lang/Lang.state.ts +1 -1
- package/src/lang/Lang.ts +5 -1
- package/src/lang/index.ts +7 -5
- package/src/plugins/Plugin.ts +1 -1
- package/src/plugins/index.ts +10 -7
- package/src/services/App.state.ts +13 -4
- package/src/services/App.ts +8 -3
- package/src/services/Cache.ts +1 -1
- package/src/services/Events.ts +15 -5
- package/src/services/Service.ts +116 -53
- package/src/services/Storage.ts +20 -0
- package/src/services/index.ts +10 -4
- package/src/services/utils.ts +18 -0
- package/src/testing/index.ts +4 -3
- package/src/testing/setup.ts +5 -19
- package/src/ui/UI.state.ts +2 -2
- package/src/ui/UI.ts +139 -54
- package/src/ui/index.ts +4 -4
- package/src/ui/utils.ts +1 -1
- package/src/utils/composition/events.ts +2 -2
- 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 +19 -6
- package/src/utils/vue.ts +14 -4
- package/dist/aerogel-core.cjs.js +0 -2
- package/dist/aerogel-core.cjs.js.map +0 -1
- package/dist/aerogel-core.esm.js +0 -2
- package/dist/aerogel-core.esm.js.map +0 -1
- package/histoire.config.ts +0 -7
- package/noeldemartin.config.js +0 -5
- package/postcss.config.js +0 -6
- package/src/assets/histoire.css +0 -3
- package/src/directives/initial-focus.ts +0 -11
- package/src/main.histoire.ts +0 -1
- package/tailwind.config.js +0 -4
- package/tsconfig.json +0 -11
- package/vite.config.ts +0 -17
- /package/src/{main.ts → index.ts} +0 -0
package/src/ui/UI.ts
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
|
-
import { after, facade, fail, uuid } from '@noeldemartin/utils';
|
|
1
|
+
import { after, facade, fail, isDevelopment, required, uuid } from '@noeldemartin/utils';
|
|
2
2
|
import { markRaw, nextTick } from 'vue';
|
|
3
3
|
import type { Component } from 'vue';
|
|
4
4
|
import type { ObjectValues } from '@noeldemartin/utils';
|
|
5
5
|
|
|
6
|
-
import
|
|
7
|
-
import
|
|
8
|
-
import type {
|
|
9
|
-
import type {
|
|
6
|
+
import App from '@aerogel/core/services/App';
|
|
7
|
+
import Events from '@aerogel/core/services/Events';
|
|
8
|
+
import type { AcceptRefs } from '@aerogel/core/utils';
|
|
9
|
+
import type { Color } from '@aerogel/core/components/constants';
|
|
10
|
+
import type { SnackbarAction, SnackbarColor } from '@aerogel/core/components/headless/snackbars';
|
|
11
|
+
import type {
|
|
12
|
+
AGAlertModalProps,
|
|
13
|
+
AGConfirmModalProps,
|
|
14
|
+
AGLoadingModalProps,
|
|
15
|
+
AGPromptModalProps,
|
|
16
|
+
} from '@aerogel/core/components';
|
|
10
17
|
|
|
11
18
|
import Service from './UI.state';
|
|
12
19
|
import { MOBILE_BREAKPOINT, getCurrentLayout } from './utils';
|
|
@@ -18,9 +25,8 @@ interface ModalCallbacks<T = unknown> {
|
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
type ModalProperties<TComponent> = TComponent extends ModalComponent<infer TProperties, unknown> ? TProperties : never;
|
|
21
|
-
type ModalResult<TComponent> =
|
|
22
|
-
? TResult
|
|
23
|
-
: never;
|
|
28
|
+
type ModalResult<TComponent> =
|
|
29
|
+
TComponent extends ModalComponent<Record<string, unknown>, infer TResult> ? TResult : never;
|
|
24
30
|
|
|
25
31
|
export const UIComponents = {
|
|
26
32
|
AlertModal: 'alert-modal',
|
|
@@ -34,14 +40,28 @@ export const UIComponents = {
|
|
|
34
40
|
|
|
35
41
|
export type UIComponent = ObjectValues<typeof UIComponents>;
|
|
36
42
|
|
|
37
|
-
export
|
|
43
|
+
export type ConfirmCheckboxes = Record<string, { label: string; default?: boolean; required?: boolean }>;
|
|
44
|
+
|
|
45
|
+
export type ConfirmOptions = AcceptRefs<{
|
|
38
46
|
acceptText?: string;
|
|
39
47
|
acceptColor?: Color;
|
|
40
48
|
cancelText?: string;
|
|
41
49
|
cancelColor?: Color;
|
|
50
|
+
actions?: Record<string, () => unknown>;
|
|
51
|
+
required?: boolean;
|
|
52
|
+
}>;
|
|
53
|
+
|
|
54
|
+
export type LoadingOptions = AcceptRefs<{
|
|
55
|
+
title?: string;
|
|
56
|
+
message?: string;
|
|
57
|
+
progress?: number;
|
|
58
|
+
}>;
|
|
59
|
+
|
|
60
|
+
export interface ConfirmOptionsWithCheckboxes<T extends ConfirmCheckboxes = ConfirmCheckboxes> extends ConfirmOptions {
|
|
61
|
+
checkboxes?: T;
|
|
42
62
|
}
|
|
43
63
|
|
|
44
|
-
export
|
|
64
|
+
export type PromptOptions = AcceptRefs<{
|
|
45
65
|
label?: string;
|
|
46
66
|
defaultValue?: string;
|
|
47
67
|
placeholder?: string;
|
|
@@ -50,7 +70,7 @@ export interface PromptOptions {
|
|
|
50
70
|
cancelText?: string;
|
|
51
71
|
cancelColor?: Color;
|
|
52
72
|
trim?: boolean;
|
|
53
|
-
}
|
|
73
|
+
}>;
|
|
54
74
|
|
|
55
75
|
export interface ShowSnackbarOptions {
|
|
56
76
|
component?: Component;
|
|
@@ -84,35 +104,66 @@ export class UIService extends Service {
|
|
|
84
104
|
this.openModal(this.requireComponent(UIComponents.AlertModal), getProperties());
|
|
85
105
|
}
|
|
86
106
|
|
|
107
|
+
/* eslint-disable max-len */
|
|
87
108
|
public async confirm(message: string, options?: ConfirmOptions): Promise<boolean>;
|
|
88
109
|
public async confirm(title: string, message: string, options?: ConfirmOptions): Promise<boolean>;
|
|
110
|
+
public async confirm<T extends ConfirmCheckboxes>(message: string, options?: ConfirmOptionsWithCheckboxes<T>): Promise<[boolean, Record<keyof T, boolean>]>; // prettier-ignore
|
|
111
|
+
public async confirm<T extends ConfirmCheckboxes>(title: string, message: string, options?: ConfirmOptionsWithCheckboxes<T>): Promise<[boolean, Record<keyof T, boolean>]>; // prettier-ignore
|
|
112
|
+
/* eslint-enable max-len */
|
|
113
|
+
|
|
89
114
|
public async confirm(
|
|
90
115
|
messageOrTitle: string,
|
|
91
|
-
messageOrOptions?: string | ConfirmOptions,
|
|
92
|
-
options?: ConfirmOptions,
|
|
93
|
-
): Promise<boolean> {
|
|
116
|
+
messageOrOptions?: string | ConfirmOptions | ConfirmOptionsWithCheckboxes,
|
|
117
|
+
options?: ConfirmOptions | ConfirmOptionsWithCheckboxes,
|
|
118
|
+
): Promise<boolean | [boolean, Record<string, boolean>]> {
|
|
94
119
|
const getProperties = (): AGConfirmModalProps => {
|
|
95
120
|
if (typeof messageOrOptions !== 'string') {
|
|
96
121
|
return {
|
|
97
|
-
message: messageOrTitle,
|
|
98
122
|
...(messageOrOptions ?? {}),
|
|
123
|
+
message: messageOrTitle,
|
|
124
|
+
required: !!messageOrOptions?.required,
|
|
99
125
|
};
|
|
100
126
|
}
|
|
101
127
|
|
|
102
128
|
return {
|
|
129
|
+
...(options ?? {}),
|
|
103
130
|
title: messageOrTitle,
|
|
104
131
|
message: messageOrOptions,
|
|
105
|
-
|
|
132
|
+
required: !!options?.required,
|
|
106
133
|
};
|
|
107
134
|
};
|
|
108
|
-
|
|
109
|
-
const modal = await this.openModal<
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
);
|
|
135
|
+
const properties = getProperties();
|
|
136
|
+
const modal = await this.openModal<
|
|
137
|
+
ModalComponent<AGConfirmModalProps, boolean | [boolean, Record<string, boolean>]>
|
|
138
|
+
>(this.requireComponent(UIComponents.ConfirmModal), properties);
|
|
113
139
|
const result = await modal.beforeClose;
|
|
114
140
|
|
|
115
|
-
|
|
141
|
+
const confirmed = typeof result === 'object' ? result[0] : (result ?? false);
|
|
142
|
+
const checkboxes =
|
|
143
|
+
typeof result === 'object'
|
|
144
|
+
? result[1]
|
|
145
|
+
: Object.entries(properties.checkboxes ?? {}).reduce(
|
|
146
|
+
(values, [checkbox, { default: defaultValue }]) => ({
|
|
147
|
+
[checkbox]: defaultValue ?? false,
|
|
148
|
+
...values,
|
|
149
|
+
}),
|
|
150
|
+
{} as Record<string, boolean>,
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
for (const [name, checkbox] of Object.entries(properties.checkboxes ?? {})) {
|
|
154
|
+
if (!checkbox.required || checkboxes[name]) {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (confirmed && isDevelopment()) {
|
|
159
|
+
// eslint-disable-next-line no-console
|
|
160
|
+
console.warn(`Confirmed confirm modal was suppressed because required '${name}' checkbox was missing`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return [false, checkboxes];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return 'checkboxes' in properties ? [confirmed, checkboxes] : confirmed;
|
|
116
167
|
}
|
|
117
168
|
|
|
118
169
|
public async prompt(message: string, options?: PromptOptions): Promise<string | null>;
|
|
@@ -128,14 +179,14 @@ export class UIService extends Service {
|
|
|
128
179
|
return {
|
|
129
180
|
message: messageOrTitle,
|
|
130
181
|
...(messageOrOptions ?? {}),
|
|
131
|
-
};
|
|
182
|
+
} as AGPromptModalProps;
|
|
132
183
|
}
|
|
133
184
|
|
|
134
185
|
return {
|
|
135
186
|
title: messageOrTitle,
|
|
136
187
|
message: messageOrOptions,
|
|
137
188
|
...(options ?? {}),
|
|
138
|
-
};
|
|
189
|
+
} as AGPromptModalProps;
|
|
139
190
|
};
|
|
140
191
|
|
|
141
192
|
const modal = await this.openModal<ModalComponent<AGPromptModalProps, string | null>>(
|
|
@@ -150,25 +201,35 @@ export class UIService extends Service {
|
|
|
150
201
|
|
|
151
202
|
public async loading<T>(operation: Promise<T> | (() => T)): Promise<T>;
|
|
152
203
|
public async loading<T>(message: string, operation: Promise<T> | (() => T)): Promise<T>;
|
|
204
|
+
public async loading<T>(options: LoadingOptions, operation: Promise<T> | (() => T)): Promise<T>;
|
|
153
205
|
public async loading<T>(
|
|
154
|
-
|
|
206
|
+
operationOrMessageOrOptions: string | LoadingOptions | Promise<T> | (() => T),
|
|
155
207
|
operation?: Promise<T> | (() => T),
|
|
156
208
|
): Promise<T> {
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
209
|
+
const processOperation = (o: Promise<T> | (() => T)) => (typeof o === 'function' ? Promise.resolve(o()) : o);
|
|
210
|
+
const processArgs = (): { operationPromise: Promise<T>; props?: AGLoadingModalProps } => {
|
|
211
|
+
if (typeof operationOrMessageOrOptions === 'string') {
|
|
212
|
+
return {
|
|
213
|
+
props: { message: operationOrMessageOrOptions },
|
|
214
|
+
operationPromise: processOperation(operation as Promise<T> | (() => T)),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (typeof operationOrMessageOrOptions === 'function' || operationOrMessageOrOptions instanceof Promise) {
|
|
219
|
+
return { operationPromise: processOperation(operationOrMessageOrOptions) };
|
|
160
220
|
}
|
|
161
221
|
|
|
162
|
-
return {
|
|
222
|
+
return {
|
|
223
|
+
props: operationOrMessageOrOptions,
|
|
224
|
+
operationPromise: processOperation(operation as Promise<T> | (() => T)),
|
|
225
|
+
};
|
|
163
226
|
};
|
|
164
227
|
|
|
165
|
-
const
|
|
228
|
+
const { operationPromise, props } = processArgs();
|
|
229
|
+
const modal = await this.openModal(this.requireComponent(UIComponents.LoadingModal), props);
|
|
166
230
|
|
|
167
231
|
try {
|
|
168
|
-
|
|
169
|
-
operation = typeof operation === 'function' ? Promise.resolve(operation()) : operation;
|
|
170
|
-
|
|
171
|
-
const [result] = await Promise.all([operation, after({ seconds: 1 })]);
|
|
232
|
+
const [result] = await Promise.all([operationPromise, after({ seconds: 1 })]);
|
|
172
233
|
|
|
173
234
|
return result;
|
|
174
235
|
} finally {
|
|
@@ -230,13 +291,40 @@ export class UIService extends Service {
|
|
|
230
291
|
}
|
|
231
292
|
|
|
232
293
|
public async closeModal(id: string, result?: unknown): Promise<void> {
|
|
294
|
+
if (!App.isMounted()) {
|
|
295
|
+
await this.removeModal(id, result);
|
|
296
|
+
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
233
300
|
await Events.emit('close-modal', { id, result });
|
|
234
301
|
}
|
|
235
302
|
|
|
236
|
-
|
|
303
|
+
public async closeAllModals(): Promise<void> {
|
|
304
|
+
while (this.modals.length > 0) {
|
|
305
|
+
await this.closeModal(required(this.modals[this.modals.length - 1]).id);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
protected override async boot(): Promise<void> {
|
|
237
310
|
this.watchModalEvents();
|
|
238
311
|
this.watchMountedEvent();
|
|
239
|
-
this.
|
|
312
|
+
this.watchViewportBreakpoints();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private async removeModal(id: string, result?: unknown): Promise<void> {
|
|
316
|
+
this.setState(
|
|
317
|
+
'modals',
|
|
318
|
+
this.modals.filter((m) => m.id !== id),
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
this.modalCallbacks[id]?.closed?.(result);
|
|
322
|
+
|
|
323
|
+
delete this.modalCallbacks[id];
|
|
324
|
+
|
|
325
|
+
const activeModal = this.modals.at(-1);
|
|
326
|
+
|
|
327
|
+
await (activeModal && Events.emit('show-modal', { id: activeModal.id }));
|
|
240
328
|
}
|
|
241
329
|
|
|
242
330
|
private watchModalEvents(): void {
|
|
@@ -248,31 +336,24 @@ export class UIService extends Service {
|
|
|
248
336
|
}
|
|
249
337
|
});
|
|
250
338
|
|
|
251
|
-
Events.on('modal-closed', async ({ modal, result }) => {
|
|
252
|
-
this.
|
|
253
|
-
'modals',
|
|
254
|
-
this.modals.filter((m) => m.id !== modal.id),
|
|
255
|
-
);
|
|
256
|
-
|
|
257
|
-
this.modalCallbacks[modal.id]?.closed?.(result);
|
|
258
|
-
|
|
259
|
-
delete this.modalCallbacks[modal.id];
|
|
260
|
-
|
|
261
|
-
const activeModal = this.modals.at(-1);
|
|
262
|
-
|
|
263
|
-
await (activeModal && Events.emit('show-modal', { id: activeModal.id }));
|
|
339
|
+
Events.on('modal-closed', async ({ modal: { id }, result }) => {
|
|
340
|
+
await this.removeModal(id, result);
|
|
264
341
|
});
|
|
265
342
|
}
|
|
266
343
|
|
|
267
344
|
private watchMountedEvent(): void {
|
|
268
345
|
Events.once('application-mounted', async () => {
|
|
269
|
-
|
|
346
|
+
if (!globalThis.document || !globalThis.getComputedStyle) {
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const splash = globalThis.document.getElementById('splash');
|
|
270
351
|
|
|
271
352
|
if (!splash) {
|
|
272
353
|
return;
|
|
273
354
|
}
|
|
274
355
|
|
|
275
|
-
if (
|
|
356
|
+
if (globalThis.getComputedStyle(splash).opacity !== '0') {
|
|
276
357
|
splash.style.opacity = '0';
|
|
277
358
|
|
|
278
359
|
await after({ ms: 600 });
|
|
@@ -282,8 +363,12 @@ export class UIService extends Service {
|
|
|
282
363
|
});
|
|
283
364
|
}
|
|
284
365
|
|
|
285
|
-
private
|
|
286
|
-
|
|
366
|
+
private watchViewportBreakpoints(): void {
|
|
367
|
+
if (!globalThis.matchMedia) {
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const media = globalThis.matchMedia(`(min-width: ${MOBILE_BREAKPOINT}px)`);
|
|
287
372
|
|
|
288
373
|
media.addEventListener('change', () => this.setState({ layout: getCurrentLayout() }));
|
|
289
374
|
}
|
|
@@ -292,7 +377,7 @@ export class UIService extends Service {
|
|
|
292
377
|
|
|
293
378
|
export default facade(UIService);
|
|
294
379
|
|
|
295
|
-
declare module '
|
|
380
|
+
declare module '@aerogel/core/services/Events' {
|
|
296
381
|
export interface EventsPayload {
|
|
297
382
|
'close-modal': { id: string; result?: unknown };
|
|
298
383
|
'hide-modal': { id: string };
|
package/src/ui/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Component } from 'vue';
|
|
2
2
|
|
|
3
|
-
import { bootServices } from '
|
|
4
|
-
import { definePlugin } from '
|
|
3
|
+
import { bootServices } from '@aerogel/core/services';
|
|
4
|
+
import { definePlugin } from '@aerogel/core/plugins';
|
|
5
5
|
|
|
6
6
|
import UI, { UIComponents } from './UI';
|
|
7
7
|
import AGAlertModal from '../components/modals/AGAlertModal.vue';
|
|
@@ -42,12 +42,12 @@ export default definePlugin({
|
|
|
42
42
|
},
|
|
43
43
|
});
|
|
44
44
|
|
|
45
|
-
declare module '
|
|
45
|
+
declare module '@aerogel/core/bootstrap/options' {
|
|
46
46
|
export interface AerogelOptions {
|
|
47
47
|
components?: Partial<Record<UIComponent, Component>>;
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
declare module '
|
|
51
|
+
declare module '@aerogel/core/services' {
|
|
52
52
|
export interface Services extends UIServices {}
|
|
53
53
|
}
|
package/src/ui/utils.ts
CHANGED
|
@@ -8,7 +8,7 @@ export const Layouts = {
|
|
|
8
8
|
export type Layout = (typeof Layouts)[keyof typeof Layouts];
|
|
9
9
|
|
|
10
10
|
export function getCurrentLayout(): Layout {
|
|
11
|
-
if (
|
|
11
|
+
if (globalThis.innerWidth > MOBILE_BREAKPOINT) {
|
|
12
12
|
return Layouts.Desktop;
|
|
13
13
|
}
|
|
14
14
|
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { onUnmounted } from 'vue';
|
|
2
2
|
|
|
3
|
-
import Events from '
|
|
3
|
+
import Events from '@aerogel/core/services/Events';
|
|
4
4
|
import type {
|
|
5
5
|
EventListener,
|
|
6
6
|
EventWithPayload,
|
|
7
7
|
EventWithoutPayload,
|
|
8
8
|
EventsPayload,
|
|
9
9
|
UnknownEvent,
|
|
10
|
-
} from '
|
|
10
|
+
} from '@aerogel/core/services/Events';
|
|
11
11
|
|
|
12
12
|
export function useEvent<Event extends EventWithoutPayload>(event: Event, listener: () => unknown): void;
|
|
13
13
|
export function useEvent<Event extends EventWithPayload>(
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { nextTick } from 'vue';
|
|
3
|
+
import { Storage } from '@noeldemartin/utils';
|
|
4
|
+
|
|
5
|
+
import { persistent } from './persistent';
|
|
6
|
+
|
|
7
|
+
describe('Vue persistent helper', () => {
|
|
8
|
+
|
|
9
|
+
it('serializes to localStorage', async () => {
|
|
10
|
+
// Arrange
|
|
11
|
+
const store = persistent<{ foo?: string }>('foobar', {});
|
|
12
|
+
|
|
13
|
+
// Act
|
|
14
|
+
store.foo = 'bar';
|
|
15
|
+
|
|
16
|
+
await nextTick();
|
|
17
|
+
|
|
18
|
+
// Assert
|
|
19
|
+
expect(Storage.get('foobar')).toEqual({ foo: 'bar' });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('reads from localStorage', async () => {
|
|
23
|
+
// Arrange
|
|
24
|
+
Storage.set('foobar', { foo: 'bar' });
|
|
25
|
+
|
|
26
|
+
// Act
|
|
27
|
+
const store = persistent<{ foo?: string }>('foobar', {});
|
|
28
|
+
|
|
29
|
+
// Assert
|
|
30
|
+
expect(store.foo).toEqual('bar');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { reactive, toRaw, watch } from 'vue';
|
|
2
|
+
import { Storage } from '@noeldemartin/utils';
|
|
3
|
+
import type { UnwrapNestedRefs } from 'vue';
|
|
4
|
+
|
|
5
|
+
export function persistent<T extends object>(name: string, defaults: T): UnwrapNestedRefs<T> {
|
|
6
|
+
const store = reactive<T>(Storage.get<T>(name) ?? defaults);
|
|
7
|
+
|
|
8
|
+
watch(store, () => Storage.set(name, toRaw(store)));
|
|
9
|
+
|
|
10
|
+
return store;
|
|
11
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { after } from '@noeldemartin/utils';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { ref } from 'vue';
|
|
4
|
+
|
|
5
|
+
import { computedDebounce } from './state';
|
|
6
|
+
|
|
7
|
+
describe('Vue state helpers', () => {
|
|
8
|
+
|
|
9
|
+
it('computes debounced state', async () => {
|
|
10
|
+
// Initial
|
|
11
|
+
const state = ref(0);
|
|
12
|
+
const value = computedDebounce({ delay: 90 }, () => state.value);
|
|
13
|
+
|
|
14
|
+
expect(value.value).toBe(null);
|
|
15
|
+
|
|
16
|
+
await after({ ms: 100 });
|
|
17
|
+
|
|
18
|
+
expect(value.value).toBe(0);
|
|
19
|
+
|
|
20
|
+
// Update
|
|
21
|
+
state.value = 42;
|
|
22
|
+
|
|
23
|
+
expect(value.value).toBe(0);
|
|
24
|
+
|
|
25
|
+
await after({ ms: 100 });
|
|
26
|
+
|
|
27
|
+
expect(value.value).toBe(42);
|
|
28
|
+
|
|
29
|
+
// Debounced Update
|
|
30
|
+
state.value = 23;
|
|
31
|
+
|
|
32
|
+
expect(value.value).toBe(42);
|
|
33
|
+
|
|
34
|
+
await after({ ms: 50 });
|
|
35
|
+
|
|
36
|
+
state.value = 32;
|
|
37
|
+
|
|
38
|
+
await after({ ms: 50 });
|
|
39
|
+
|
|
40
|
+
expect(value.value).toBe(42);
|
|
41
|
+
|
|
42
|
+
await after({ ms: 100 });
|
|
43
|
+
|
|
44
|
+
expect(value.value).toBe(32);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { debounce } from '@noeldemartin/utils';
|
|
2
|
+
import { ref, watchEffect } from 'vue';
|
|
3
|
+
import type { ComputedGetter, ComputedRef } from '@vue/runtime-core';
|
|
4
|
+
|
|
5
|
+
export interface ComputedDebounceOptions<T> {
|
|
6
|
+
initial?: T;
|
|
7
|
+
delay?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function computedDebounce<T>(options: ComputedDebounceOptions<T>, getter: ComputedGetter<T>): ComputedRef<T>;
|
|
11
|
+
export function computedDebounce<T>(getter: ComputedGetter<T>): ComputedRef<T | null>;
|
|
12
|
+
export function computedDebounce<T>(
|
|
13
|
+
optionsOrGetter: ComputedGetter<T> | ComputedDebounceOptions<T>,
|
|
14
|
+
inputGetter?: ComputedGetter<T>,
|
|
15
|
+
): ComputedRef<T> {
|
|
16
|
+
const inputOptions = inputGetter ? (optionsOrGetter as ComputedDebounceOptions<T>) : {};
|
|
17
|
+
const getter = inputGetter ?? (optionsOrGetter as ComputedGetter<T>);
|
|
18
|
+
const state = ref(inputOptions.initial ?? null);
|
|
19
|
+
const update = debounce((value) => (state.value = value), inputOptions.delay ?? 300);
|
|
20
|
+
|
|
21
|
+
watchEffect(() => update(getter()));
|
|
22
|
+
|
|
23
|
+
return state as unknown as ComputedRef<T>;
|
|
24
|
+
}
|
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,21 +1,34 @@
|
|
|
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 {
|
|
6
6
|
return tap(new Renderer(), (renderer) => {
|
|
7
|
-
renderer.link = function(
|
|
8
|
-
return Renderer.prototype.link.apply(this, [
|
|
7
|
+
renderer.link = function(link) {
|
|
8
|
+
return Renderer.prototype.link.apply(this, [link]).replace('<a', '<a target="_blank"');
|
|
9
9
|
};
|
|
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, { renderer: makeRenderer(), async: false });
|
|
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 {
|
|
18
|
-
// TODO improve target="_blank" exception
|
|
19
|
-
// See https://github.com/cure53/DOMPurify/issues/317
|
|
20
33
|
return DOMPurify.sanitize(html, { ADD_ATTR: ['target'] });
|
|
21
34
|
}
|
package/src/utils/vue.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { fail } from '@noeldemartin/utils';
|
|
1
|
+
import { fail, toString } from '@noeldemartin/utils';
|
|
2
2
|
import { computed, inject, reactive, ref, watch } from 'vue';
|
|
3
|
-
import type { Directive, InjectionKey, PropType, Ref, UnwrapNestedRefs } from 'vue';
|
|
3
|
+
import type { Directive, InjectionKey, MaybeRef, PropType, Ref, UnwrapNestedRefs } from 'vue';
|
|
4
4
|
|
|
5
5
|
type BaseProp<T> = {
|
|
6
6
|
type?: PropType<T>;
|
|
@@ -10,7 +10,10 @@ type BaseProp<T> = {
|
|
|
10
10
|
type RequiredProp<T> = BaseProp<T> & { required: true };
|
|
11
11
|
type OptionalProp<T> = BaseProp<T> & { default: T | (() => T) | null };
|
|
12
12
|
|
|
13
|
+
export type AcceptRefs<T> = { [K in keyof T]: T[K] | RefUnion<T[K]> };
|
|
13
14
|
export type ComponentProps = Record<string, unknown>;
|
|
15
|
+
export type RefUnion<T> = T extends infer R ? Ref<R> : never;
|
|
16
|
+
export type Unref<T> = { [K in keyof T]: T[K] extends MaybeRef<infer Value> ? Value : T[K] };
|
|
14
17
|
|
|
15
18
|
export function arrayProp<T>(defaultValue?: () => T[]): OptionalProp<T[]> {
|
|
16
19
|
return {
|
|
@@ -66,11 +69,18 @@ export function injectReactiveOrFail<T extends object>(
|
|
|
66
69
|
key: InjectionKey<T> | string,
|
|
67
70
|
errorMessage?: string,
|
|
68
71
|
): UnwrapNestedRefs<T> {
|
|
69
|
-
return injectReactive(key) ?? fail(errorMessage ?? `Could not resolve '${key}' injection key`);
|
|
72
|
+
return injectReactive(key) ?? fail(errorMessage ?? `Could not resolve '${toString(key)}' injection key`);
|
|
70
73
|
}
|
|
71
74
|
|
|
72
75
|
export function injectOrFail<T>(key: InjectionKey<T> | string, errorMessage?: string): T {
|
|
73
|
-
return inject(key) ?? fail(errorMessage ?? `Could not resolve '${key}' injection key`);
|
|
76
|
+
return inject(key) ?? fail(errorMessage ?? `Could not resolve '${toString(key)}' injection key`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function listenerProp<T extends Function = Function>(): OptionalProp<T | null> {
|
|
80
|
+
return {
|
|
81
|
+
type: Function as PropType<T>,
|
|
82
|
+
default: null,
|
|
83
|
+
};
|
|
74
84
|
}
|
|
75
85
|
|
|
76
86
|
export function mixedProp<T>(type?: PropType<T>): OptionalProp<T | null>;
|