@aerogel/core 0.0.0-next.bde642c4a8096c5fc3d5e676c2115da23f4bf1d8 → 0.0.0-next.c4825c5cbe0fe3257e478c2a7ec8df27d5a72305
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 +371 -69
- package/dist/aerogel-core.esm.js +1 -1
- package/dist/aerogel-core.esm.js.map +1 -1
- package/package.json +3 -3
- package/src/bootstrap/index.ts +12 -2
- package/src/components/headless/forms/AGHeadlessInput.ts +1 -0
- package/src/components/headless/forms/AGHeadlessInput.vue +7 -0
- package/src/components/headless/forms/AGHeadlessInputInput.vue +1 -0
- package/src/components/headless/forms/AGHeadlessInputTextArea.vue +1 -0
- package/src/components/headless/modals/AGHeadlessModal.ts +3 -1
- package/src/components/headless/modals/AGHeadlessModal.vue +10 -4
- package/src/components/headless/modals/AGHeadlessModalPanel.vue +10 -6
- package/src/components/headless/modals/AGHeadlessModalTitle.vue +14 -4
- package/src/components/lib/AGMarkdown.vue +14 -1
- package/src/components/lib/AGMeasured.vue +1 -0
- package/src/components/lib/AGProgressBar.vue +30 -0
- package/src/components/lib/index.ts +1 -0
- package/src/components/modals/AGAlertModal.ts +5 -2
- package/src/components/modals/AGConfirmModal.ts +14 -5
- package/src/components/modals/AGConfirmModal.vue +2 -2
- package/src/components/modals/AGErrorReportModal.ts +5 -2
- package/src/components/modals/AGLoadingModal.ts +10 -4
- package/src/components/modals/AGModal.ts +1 -0
- package/src/components/modals/AGModalContext.vue +14 -4
- package/src/components/modals/AGPromptModal.ts +9 -4
- package/src/directives/measure.ts +24 -5
- package/src/errors/JobCancelledError.ts +3 -0
- package/src/errors/utils.ts +16 -0
- package/src/forms/Form.test.ts +28 -0
- package/src/forms/Form.ts +24 -6
- 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/Lang.ts +4 -0
- package/src/services/App.state.ts +9 -1
- package/src/services/App.ts +5 -0
- package/src/services/Events.ts +13 -3
- package/src/services/Service.ts +107 -44
- package/src/services/Storage.ts +20 -0
- package/src/services/index.ts +7 -2
- package/src/services/utils.ts +18 -0
- package/src/testing/setup.ts +11 -3
- package/src/ui/UI.state.ts +7 -0
- package/src/ui/UI.ts +136 -43
- package/src/ui/index.ts +1 -0
- package/src/ui/utils.ts +16 -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 +1 -0
- package/src/utils/markdown.test.ts +50 -0
- package/src/utils/markdown.ts +17 -2
- package/src/utils/vue.ts +11 -1
package/src/ui/UI.ts
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
|
-
import { after, 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';
|
|
7
9
|
import type { Color } from '@/components/constants';
|
|
8
10
|
import type { SnackbarAction, SnackbarColor } from '@/components/headless/snackbars';
|
|
9
11
|
import type { AGAlertModalProps, AGConfirmModalProps, AGLoadingModalProps, AGPromptModalProps } from '@/components';
|
|
10
12
|
|
|
11
13
|
import Service from './UI.state';
|
|
14
|
+
import { MOBILE_BREAKPOINT, getCurrentLayout } from './utils';
|
|
12
15
|
import type { Modal, ModalComponent, Snackbar } from './UI.state';
|
|
13
16
|
|
|
14
17
|
interface ModalCallbacks<T = unknown> {
|
|
@@ -33,14 +36,28 @@ export const UIComponents = {
|
|
|
33
36
|
|
|
34
37
|
export type UIComponent = ObjectValues<typeof UIComponents>;
|
|
35
38
|
|
|
36
|
-
export
|
|
39
|
+
export type ConfirmCheckboxes = Record<string, { label: string; default?: boolean; required?: boolean }>;
|
|
40
|
+
|
|
41
|
+
export type ConfirmOptions = AcceptRefs<{
|
|
37
42
|
acceptText?: string;
|
|
38
43
|
acceptColor?: Color;
|
|
39
44
|
cancelText?: string;
|
|
40
45
|
cancelColor?: Color;
|
|
46
|
+
actions?: Record<string, () => unknown>;
|
|
47
|
+
required?: boolean;
|
|
48
|
+
}>;
|
|
49
|
+
|
|
50
|
+
export type LoadingOptions = AcceptRefs<{
|
|
51
|
+
title?: string;
|
|
52
|
+
message?: string;
|
|
53
|
+
progress?: number;
|
|
54
|
+
}>;
|
|
55
|
+
|
|
56
|
+
export interface ConfirmOptionsWithCheckboxes<T extends ConfirmCheckboxes = ConfirmCheckboxes> extends ConfirmOptions {
|
|
57
|
+
checkboxes?: T;
|
|
41
58
|
}
|
|
42
59
|
|
|
43
|
-
export
|
|
60
|
+
export type PromptOptions = AcceptRefs<{
|
|
44
61
|
label?: string;
|
|
45
62
|
defaultValue?: string;
|
|
46
63
|
placeholder?: string;
|
|
@@ -49,7 +66,7 @@ export interface PromptOptions {
|
|
|
49
66
|
cancelText?: string;
|
|
50
67
|
cancelColor?: Color;
|
|
51
68
|
trim?: boolean;
|
|
52
|
-
}
|
|
69
|
+
}>;
|
|
53
70
|
|
|
54
71
|
export interface ShowSnackbarOptions {
|
|
55
72
|
component?: Component;
|
|
@@ -83,35 +100,66 @@ export class UIService extends Service {
|
|
|
83
100
|
this.openModal(this.requireComponent(UIComponents.AlertModal), getProperties());
|
|
84
101
|
}
|
|
85
102
|
|
|
103
|
+
/* eslint-disable max-len */
|
|
86
104
|
public async confirm(message: string, options?: ConfirmOptions): Promise<boolean>;
|
|
87
105
|
public async confirm(title: string, message: string, options?: ConfirmOptions): Promise<boolean>;
|
|
106
|
+
public async confirm<T extends ConfirmCheckboxes>(message: string, options?: ConfirmOptionsWithCheckboxes<T>): Promise<[boolean, Record<keyof T, boolean>]>; // prettier-ignore
|
|
107
|
+
public async confirm<T extends ConfirmCheckboxes>(title: string, message: string, options?: ConfirmOptionsWithCheckboxes<T>): Promise<[boolean, Record<keyof T, boolean>]>; // prettier-ignore
|
|
108
|
+
/* eslint-enable max-len */
|
|
109
|
+
|
|
88
110
|
public async confirm(
|
|
89
111
|
messageOrTitle: string,
|
|
90
|
-
messageOrOptions?: string | ConfirmOptions,
|
|
91
|
-
options?: ConfirmOptions,
|
|
92
|
-
): Promise<boolean> {
|
|
112
|
+
messageOrOptions?: string | ConfirmOptions | ConfirmOptionsWithCheckboxes,
|
|
113
|
+
options?: ConfirmOptions | ConfirmOptionsWithCheckboxes,
|
|
114
|
+
): Promise<boolean | [boolean, Record<string, boolean>]> {
|
|
93
115
|
const getProperties = (): AGConfirmModalProps => {
|
|
94
116
|
if (typeof messageOrOptions !== 'string') {
|
|
95
117
|
return {
|
|
96
|
-
message: messageOrTitle,
|
|
97
118
|
...(messageOrOptions ?? {}),
|
|
119
|
+
message: messageOrTitle,
|
|
120
|
+
required: !!messageOrOptions?.required,
|
|
98
121
|
};
|
|
99
122
|
}
|
|
100
123
|
|
|
101
124
|
return {
|
|
125
|
+
...(options ?? {}),
|
|
102
126
|
title: messageOrTitle,
|
|
103
127
|
message: messageOrOptions,
|
|
104
|
-
|
|
128
|
+
required: !!options?.required,
|
|
105
129
|
};
|
|
106
130
|
};
|
|
107
|
-
|
|
108
|
-
const modal = await this.openModal<
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
);
|
|
131
|
+
const properties = getProperties();
|
|
132
|
+
const modal = await this.openModal<
|
|
133
|
+
ModalComponent<AGConfirmModalProps, boolean | [boolean, Record<string, boolean>]>
|
|
134
|
+
>(this.requireComponent(UIComponents.ConfirmModal), properties);
|
|
112
135
|
const result = await modal.beforeClose;
|
|
113
136
|
|
|
114
|
-
|
|
137
|
+
const confirmed = typeof result === 'object' ? result[0] : result ?? false;
|
|
138
|
+
const checkboxes =
|
|
139
|
+
typeof result === 'object'
|
|
140
|
+
? result[1]
|
|
141
|
+
: Object.entries(properties.checkboxes ?? {}).reduce(
|
|
142
|
+
(values, [checkbox, { default: defaultValue }]) => ({
|
|
143
|
+
[checkbox]: defaultValue ?? false,
|
|
144
|
+
...values,
|
|
145
|
+
}),
|
|
146
|
+
{} as Record<string, boolean>,
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
for (const [name, checkbox] of Object.entries(properties.checkboxes ?? {})) {
|
|
150
|
+
if (!checkbox.required || checkboxes[name]) {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (confirmed && App.development) {
|
|
155
|
+
// eslint-disable-next-line no-console
|
|
156
|
+
console.warn(`Confirmed confirm modal was suppressed because required '${name}' checkbox was missing`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return [false, checkboxes];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return 'checkboxes' in properties ? [confirmed, checkboxes] : confirmed;
|
|
115
163
|
}
|
|
116
164
|
|
|
117
165
|
public async prompt(message: string, options?: PromptOptions): Promise<string | null>;
|
|
@@ -127,14 +175,14 @@ export class UIService extends Service {
|
|
|
127
175
|
return {
|
|
128
176
|
message: messageOrTitle,
|
|
129
177
|
...(messageOrOptions ?? {}),
|
|
130
|
-
};
|
|
178
|
+
} as AGPromptModalProps;
|
|
131
179
|
}
|
|
132
180
|
|
|
133
181
|
return {
|
|
134
182
|
title: messageOrTitle,
|
|
135
183
|
message: messageOrOptions,
|
|
136
184
|
...(options ?? {}),
|
|
137
|
-
};
|
|
185
|
+
} as AGPromptModalProps;
|
|
138
186
|
};
|
|
139
187
|
|
|
140
188
|
const modal = await this.openModal<ModalComponent<AGPromptModalProps, string | null>>(
|
|
@@ -147,23 +195,37 @@ export class UIService extends Service {
|
|
|
147
195
|
return result ?? null;
|
|
148
196
|
}
|
|
149
197
|
|
|
150
|
-
public async loading<T>(operation: Promise<T>): Promise<T>;
|
|
151
|
-
public async loading<T>(message: string, operation: Promise<T>): Promise<T>;
|
|
152
|
-
public async loading<T>(
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
198
|
+
public async loading<T>(operation: Promise<T> | (() => T)): Promise<T>;
|
|
199
|
+
public async loading<T>(message: string, operation: Promise<T> | (() => T)): Promise<T>;
|
|
200
|
+
public async loading<T>(options: LoadingOptions, operation: Promise<T> | (() => T)): Promise<T>;
|
|
201
|
+
public async loading<T>(
|
|
202
|
+
operationOrMessageOrOptions: string | LoadingOptions | Promise<T> | (() => T),
|
|
203
|
+
operation?: Promise<T> | (() => T),
|
|
204
|
+
): Promise<T> {
|
|
205
|
+
const processOperation = (o: Promise<T> | (() => T)) => (typeof o === 'function' ? Promise.resolve(o()) : o);
|
|
206
|
+
const processArgs = (): { operationPromise: Promise<T>; props?: AGLoadingModalProps } => {
|
|
207
|
+
if (typeof operationOrMessageOrOptions === 'string') {
|
|
208
|
+
return {
|
|
209
|
+
props: { message: operationOrMessageOrOptions },
|
|
210
|
+
operationPromise: processOperation(operation as Promise<T> | (() => T)),
|
|
211
|
+
};
|
|
156
212
|
}
|
|
157
213
|
|
|
158
|
-
|
|
214
|
+
if (typeof operationOrMessageOrOptions === 'function' || operationOrMessageOrOptions instanceof Promise) {
|
|
215
|
+
return { operationPromise: processOperation(operationOrMessageOrOptions) };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
props: operationOrMessageOrOptions,
|
|
220
|
+
operationPromise: processOperation(operation as Promise<T> | (() => T)),
|
|
221
|
+
};
|
|
159
222
|
};
|
|
160
223
|
|
|
161
|
-
const
|
|
224
|
+
const { operationPromise, props } = processArgs();
|
|
225
|
+
const modal = await this.openModal(this.requireComponent(UIComponents.LoadingModal), props);
|
|
162
226
|
|
|
163
227
|
try {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
const [result] = await Promise.all([operation, after({ seconds: 1 })]);
|
|
228
|
+
const [result] = await Promise.all([operationPromise, after({ seconds: 1 })]);
|
|
167
229
|
|
|
168
230
|
return result;
|
|
169
231
|
} finally {
|
|
@@ -225,12 +287,40 @@ export class UIService extends Service {
|
|
|
225
287
|
}
|
|
226
288
|
|
|
227
289
|
public async closeModal(id: string, result?: unknown): Promise<void> {
|
|
290
|
+
if (!App.isMounted()) {
|
|
291
|
+
await this.removeModal(id, result);
|
|
292
|
+
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
228
296
|
await Events.emit('close-modal', { id, result });
|
|
229
297
|
}
|
|
230
298
|
|
|
299
|
+
public async closeAllModals(): Promise<void> {
|
|
300
|
+
while (this.modals.length > 0) {
|
|
301
|
+
await this.closeModal(required(this.modals[this.modals.length - 1]).id);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
231
305
|
protected async boot(): Promise<void> {
|
|
232
306
|
this.watchModalEvents();
|
|
233
307
|
this.watchMountedEvent();
|
|
308
|
+
this.watchViewportBreakpoints();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private async removeModal(id: string, result?: unknown): Promise<void> {
|
|
312
|
+
this.setState(
|
|
313
|
+
'modals',
|
|
314
|
+
this.modals.filter((m) => m.id !== id),
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
this.modalCallbacks[id]?.closed?.(result);
|
|
318
|
+
|
|
319
|
+
delete this.modalCallbacks[id];
|
|
320
|
+
|
|
321
|
+
const activeModal = this.modals.at(-1);
|
|
322
|
+
|
|
323
|
+
await (activeModal && Events.emit('show-modal', { id: activeModal.id }));
|
|
234
324
|
}
|
|
235
325
|
|
|
236
326
|
private watchModalEvents(): void {
|
|
@@ -242,31 +332,24 @@ export class UIService extends Service {
|
|
|
242
332
|
}
|
|
243
333
|
});
|
|
244
334
|
|
|
245
|
-
Events.on('modal-closed', async ({ modal, result }) => {
|
|
246
|
-
this.
|
|
247
|
-
'modals',
|
|
248
|
-
this.modals.filter((m) => m.id !== modal.id),
|
|
249
|
-
);
|
|
250
|
-
|
|
251
|
-
this.modalCallbacks[modal.id]?.closed?.(result);
|
|
252
|
-
|
|
253
|
-
delete this.modalCallbacks[modal.id];
|
|
254
|
-
|
|
255
|
-
const activeModal = this.modals.at(-1);
|
|
256
|
-
|
|
257
|
-
await (activeModal && Events.emit('show-modal', { id: activeModal.id }));
|
|
335
|
+
Events.on('modal-closed', async ({ modal: { id }, result }) => {
|
|
336
|
+
await this.removeModal(id, result);
|
|
258
337
|
});
|
|
259
338
|
}
|
|
260
339
|
|
|
261
340
|
private watchMountedEvent(): void {
|
|
262
341
|
Events.once('application-mounted', async () => {
|
|
263
|
-
|
|
342
|
+
if (!globalThis.document || !globalThis.getComputedStyle) {
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const splash = globalThis.document.getElementById('splash');
|
|
264
347
|
|
|
265
348
|
if (!splash) {
|
|
266
349
|
return;
|
|
267
350
|
}
|
|
268
351
|
|
|
269
|
-
if (
|
|
352
|
+
if (globalThis.getComputedStyle(splash).opacity !== '0') {
|
|
270
353
|
splash.style.opacity = '0';
|
|
271
354
|
|
|
272
355
|
await after({ ms: 600 });
|
|
@@ -276,6 +359,16 @@ export class UIService extends Service {
|
|
|
276
359
|
});
|
|
277
360
|
}
|
|
278
361
|
|
|
362
|
+
private watchViewportBreakpoints(): void {
|
|
363
|
+
if (!globalThis.matchMedia) {
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const media = globalThis.matchMedia(`(min-width: ${MOBILE_BREAKPOINT}px)`);
|
|
368
|
+
|
|
369
|
+
media.addEventListener('change', () => this.setState({ layout: getCurrentLayout() }));
|
|
370
|
+
}
|
|
371
|
+
|
|
279
372
|
}
|
|
280
373
|
|
|
281
374
|
export default facade(UIService);
|
package/src/ui/index.ts
CHANGED
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
|
+
}
|
|
@@ -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 {
|
package/src/utils/vue.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { fail } 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 {
|
|
@@ -73,6 +76,13 @@ export function injectOrFail<T>(key: InjectionKey<T> | string, errorMessage?: st
|
|
|
73
76
|
return inject(key) ?? fail(errorMessage ?? `Could not resolve '${key}' injection key`);
|
|
74
77
|
}
|
|
75
78
|
|
|
79
|
+
export function listenerProp<T extends Function = Function>(): OptionalProp<T | null> {
|
|
80
|
+
return {
|
|
81
|
+
type: Function as PropType<T>,
|
|
82
|
+
default: null,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
76
86
|
export function mixedProp<T>(type?: PropType<T>): OptionalProp<T | null>;
|
|
77
87
|
export function mixedProp<T>(type: PropType<T>, defaultValue: T): OptionalProp<T>;
|
|
78
88
|
export function mixedProp<T>(type?: PropType<T>, defaultValue?: T): OptionalProp<T | null> {
|