@aerogel/core 0.0.0-next.8e6b2bcc764fa682decbb41aa6848c77a744dec3 → 0.0.0-next.906ec80f260b7e5cb54a0c97bd4905bdaf4bf916
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 +653 -91
- 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/bootstrap.test.ts +0 -1
- package/src/bootstrap/index.ts +12 -2
- package/src/components/AGAppSnackbars.vue +1 -1
- package/src/components/composition.ts +23 -0
- package/src/components/forms/AGForm.vue +9 -10
- package/src/components/forms/AGInput.vue +2 -0
- package/src/components/headless/forms/AGHeadlessButton.ts +3 -0
- package/src/components/headless/forms/AGHeadlessButton.vue +15 -4
- package/src/components/headless/forms/AGHeadlessInput.ts +10 -4
- package/src/components/headless/forms/AGHeadlessInput.vue +18 -5
- package/src/components/headless/forms/AGHeadlessInputDescription.vue +28 -0
- package/src/components/headless/forms/AGHeadlessInputInput.vue +42 -5
- package/src/components/headless/forms/AGHeadlessInputTextArea.vue +43 -0
- package/src/components/headless/forms/composition.ts +10 -0
- package/src/components/headless/forms/index.ts +4 -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/index.ts +2 -0
- package/src/components/interfaces.ts +24 -0
- package/src/components/lib/AGMarkdown.vue +22 -4
- package/src/components/lib/AGMeasured.vue +1 -0
- package/src/components/lib/AGProgressBar.vue +55 -0
- package/src/components/lib/index.ts +1 -0
- package/src/components/modals/AGAlertModal.ts +5 -2
- package/src/components/modals/AGConfirmModal.ts +18 -3
- package/src/components/modals/AGConfirmModal.vue +3 -3
- 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 +14 -3
- package/src/components/modals/AGPromptModal.vue +2 -2
- package/src/directives/measure.ts +24 -5
- package/src/errors/JobCancelledError.ts +3 -0
- package/src/errors/index.ts +2 -0
- package/src/errors/utils.ts +16 -0
- package/src/forms/Form.test.ts +28 -0
- package/src/forms/Form.ts +65 -8
- 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 +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 +43 -0
- package/src/lang/Lang.state.ts +11 -0
- package/src/lang/Lang.ts +48 -21
- package/src/services/App.state.ts +22 -0
- package/src/services/App.ts +8 -0
- package/src/services/Cache.ts +43 -0
- package/src/services/Events.ts +13 -3
- package/src/services/Service.ts +116 -45
- package/src/services/Storage.ts +20 -0
- package/src/services/index.ts +9 -2
- package/src/services/utils.ts +18 -0
- package/src/testing/setup.ts +27 -0
- package/src/ui/UI.state.ts +7 -0
- package/src/ui/UI.ts +145 -44
- 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 +2 -0
- package/src/utils/markdown.test.ts +50 -0
- package/src/utils/markdown.ts +17 -2
- package/src/utils/vue.ts +15 -3
- package/vite.config.ts +4 -1
package/src/ui/UI.ts
CHANGED
|
@@ -1,13 +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';
|
|
9
|
+
import type { Color } from '@/components/constants';
|
|
7
10
|
import type { SnackbarAction, SnackbarColor } from '@/components/headless/snackbars';
|
|
8
11
|
import type { AGAlertModalProps, AGConfirmModalProps, AGLoadingModalProps, AGPromptModalProps } from '@/components';
|
|
9
12
|
|
|
10
13
|
import Service from './UI.state';
|
|
14
|
+
import { MOBILE_BREAKPOINT, getCurrentLayout } from './utils';
|
|
11
15
|
import type { Modal, ModalComponent, Snackbar } from './UI.state';
|
|
12
16
|
|
|
13
17
|
interface ModalCallbacks<T = unknown> {
|
|
@@ -32,18 +36,37 @@ export const UIComponents = {
|
|
|
32
36
|
|
|
33
37
|
export type UIComponent = ObjectValues<typeof UIComponents>;
|
|
34
38
|
|
|
35
|
-
export
|
|
39
|
+
export type ConfirmCheckboxes = Record<string, { label: string; default?: boolean; required?: boolean }>;
|
|
40
|
+
|
|
41
|
+
export type ConfirmOptions = AcceptRefs<{
|
|
36
42
|
acceptText?: string;
|
|
43
|
+
acceptColor?: Color;
|
|
37
44
|
cancelText?: string;
|
|
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;
|
|
38
58
|
}
|
|
39
59
|
|
|
40
|
-
export
|
|
60
|
+
export type PromptOptions = AcceptRefs<{
|
|
41
61
|
label?: string;
|
|
42
62
|
defaultValue?: string;
|
|
43
63
|
placeholder?: string;
|
|
44
64
|
acceptText?: string;
|
|
65
|
+
acceptColor?: Color;
|
|
45
66
|
cancelText?: string;
|
|
46
|
-
|
|
67
|
+
cancelColor?: Color;
|
|
68
|
+
trim?: boolean;
|
|
69
|
+
}>;
|
|
47
70
|
|
|
48
71
|
export interface ShowSnackbarOptions {
|
|
49
72
|
component?: Component;
|
|
@@ -77,35 +100,66 @@ export class UIService extends Service {
|
|
|
77
100
|
this.openModal(this.requireComponent(UIComponents.AlertModal), getProperties());
|
|
78
101
|
}
|
|
79
102
|
|
|
103
|
+
/* eslint-disable max-len */
|
|
80
104
|
public async confirm(message: string, options?: ConfirmOptions): Promise<boolean>;
|
|
81
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
|
+
|
|
82
110
|
public async confirm(
|
|
83
111
|
messageOrTitle: string,
|
|
84
|
-
messageOrOptions?: string | ConfirmOptions,
|
|
85
|
-
options?: ConfirmOptions,
|
|
86
|
-
): Promise<boolean> {
|
|
112
|
+
messageOrOptions?: string | ConfirmOptions | ConfirmOptionsWithCheckboxes,
|
|
113
|
+
options?: ConfirmOptions | ConfirmOptionsWithCheckboxes,
|
|
114
|
+
): Promise<boolean | [boolean, Record<string, boolean>]> {
|
|
87
115
|
const getProperties = (): AGConfirmModalProps => {
|
|
88
116
|
if (typeof messageOrOptions !== 'string') {
|
|
89
117
|
return {
|
|
90
|
-
message: messageOrTitle,
|
|
91
118
|
...(messageOrOptions ?? {}),
|
|
119
|
+
message: messageOrTitle,
|
|
120
|
+
required: !!messageOrOptions?.required,
|
|
92
121
|
};
|
|
93
122
|
}
|
|
94
123
|
|
|
95
124
|
return {
|
|
125
|
+
...(options ?? {}),
|
|
96
126
|
title: messageOrTitle,
|
|
97
127
|
message: messageOrOptions,
|
|
98
|
-
|
|
128
|
+
required: !!options?.required,
|
|
99
129
|
};
|
|
100
130
|
};
|
|
101
|
-
|
|
102
|
-
const modal = await this.openModal<
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
);
|
|
131
|
+
const properties = getProperties();
|
|
132
|
+
const modal = await this.openModal<
|
|
133
|
+
ModalComponent<AGConfirmModalProps, boolean | [boolean, Record<string, boolean>]>
|
|
134
|
+
>(this.requireComponent(UIComponents.ConfirmModal), properties);
|
|
106
135
|
const result = await modal.beforeClose;
|
|
107
136
|
|
|
108
|
-
|
|
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;
|
|
109
163
|
}
|
|
110
164
|
|
|
111
165
|
public async prompt(message: string, options?: PromptOptions): Promise<string | null>;
|
|
@@ -115,47 +169,63 @@ export class UIService extends Service {
|
|
|
115
169
|
messageOrOptions?: string | PromptOptions,
|
|
116
170
|
options?: PromptOptions,
|
|
117
171
|
): Promise<string | null> {
|
|
172
|
+
const trim = options?.trim ?? true;
|
|
118
173
|
const getProperties = (): AGPromptModalProps => {
|
|
119
174
|
if (typeof messageOrOptions !== 'string') {
|
|
120
175
|
return {
|
|
121
176
|
message: messageOrTitle,
|
|
122
177
|
...(messageOrOptions ?? {}),
|
|
123
|
-
};
|
|
178
|
+
} as AGPromptModalProps;
|
|
124
179
|
}
|
|
125
180
|
|
|
126
181
|
return {
|
|
127
182
|
title: messageOrTitle,
|
|
128
183
|
message: messageOrOptions,
|
|
129
184
|
...(options ?? {}),
|
|
130
|
-
};
|
|
185
|
+
} as AGPromptModalProps;
|
|
131
186
|
};
|
|
132
187
|
|
|
133
188
|
const modal = await this.openModal<ModalComponent<AGPromptModalProps, string | null>>(
|
|
134
189
|
this.requireComponent(UIComponents.PromptModal),
|
|
135
190
|
getProperties(),
|
|
136
191
|
);
|
|
137
|
-
const
|
|
192
|
+
const rawResult = await modal.beforeClose;
|
|
193
|
+
const result = trim && typeof rawResult === 'string' ? rawResult?.trim() : rawResult;
|
|
138
194
|
|
|
139
195
|
return result ?? null;
|
|
140
196
|
}
|
|
141
197
|
|
|
142
|
-
public async loading<T>(operation: Promise<T>): Promise<T>;
|
|
143
|
-
public async loading<T>(message: string, operation: Promise<T>): Promise<T>;
|
|
144
|
-
public async loading<T>(
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
+
};
|
|
148
212
|
}
|
|
149
213
|
|
|
150
|
-
|
|
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
|
+
};
|
|
151
222
|
};
|
|
152
223
|
|
|
153
|
-
const
|
|
224
|
+
const { operationPromise, props } = processArgs();
|
|
225
|
+
const modal = await this.openModal(this.requireComponent(UIComponents.LoadingModal), props);
|
|
154
226
|
|
|
155
227
|
try {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
const [result] = await Promise.all([operation, after({ seconds: 1 })]);
|
|
228
|
+
const [result] = await Promise.all([operationPromise, after({ seconds: 1 })]);
|
|
159
229
|
|
|
160
230
|
return result;
|
|
161
231
|
} finally {
|
|
@@ -217,12 +287,40 @@ export class UIService extends Service {
|
|
|
217
287
|
}
|
|
218
288
|
|
|
219
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
|
+
|
|
220
296
|
await Events.emit('close-modal', { id, result });
|
|
221
297
|
}
|
|
222
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
|
+
|
|
223
305
|
protected async boot(): Promise<void> {
|
|
224
306
|
this.watchModalEvents();
|
|
225
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 }));
|
|
226
324
|
}
|
|
227
325
|
|
|
228
326
|
private watchModalEvents(): void {
|
|
@@ -234,31 +332,24 @@ export class UIService extends Service {
|
|
|
234
332
|
}
|
|
235
333
|
});
|
|
236
334
|
|
|
237
|
-
Events.on('modal-closed', async ({ modal, result }) => {
|
|
238
|
-
this.
|
|
239
|
-
'modals',
|
|
240
|
-
this.modals.filter((m) => m.id !== modal.id),
|
|
241
|
-
);
|
|
242
|
-
|
|
243
|
-
this.modalCallbacks[modal.id]?.closed?.(result);
|
|
244
|
-
|
|
245
|
-
delete this.modalCallbacks[modal.id];
|
|
246
|
-
|
|
247
|
-
const activeModal = this.modals.at(-1);
|
|
248
|
-
|
|
249
|
-
await (activeModal && Events.emit('show-modal', { id: activeModal.id }));
|
|
335
|
+
Events.on('modal-closed', async ({ modal: { id }, result }) => {
|
|
336
|
+
await this.removeModal(id, result);
|
|
250
337
|
});
|
|
251
338
|
}
|
|
252
339
|
|
|
253
340
|
private watchMountedEvent(): void {
|
|
254
341
|
Events.once('application-mounted', async () => {
|
|
255
|
-
|
|
342
|
+
if (!globalThis.document || !globalThis.getComputedStyle) {
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const splash = globalThis.document.getElementById('splash');
|
|
256
347
|
|
|
257
348
|
if (!splash) {
|
|
258
349
|
return;
|
|
259
350
|
}
|
|
260
351
|
|
|
261
|
-
if (
|
|
352
|
+
if (globalThis.getComputedStyle(splash).opacity !== '0') {
|
|
262
353
|
splash.style.opacity = '0';
|
|
263
354
|
|
|
264
355
|
await after({ ms: 600 });
|
|
@@ -268,6 +359,16 @@ export class UIService extends Service {
|
|
|
268
359
|
});
|
|
269
360
|
}
|
|
270
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
|
+
|
|
271
372
|
}
|
|
272
373
|
|
|
273
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,13 +76,22 @@ 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
|
|
|
76
|
-
export function
|
|
79
|
+
export function listenerProp<T extends Function = Function>(): OptionalProp<T | null> {
|
|
77
80
|
return {
|
|
78
|
-
type
|
|
81
|
+
type: Function as PropType<T>,
|
|
79
82
|
default: null,
|
|
80
83
|
};
|
|
81
84
|
}
|
|
82
85
|
|
|
86
|
+
export function mixedProp<T>(type?: PropType<T>): OptionalProp<T | null>;
|
|
87
|
+
export function mixedProp<T>(type: PropType<T>, defaultValue: T): OptionalProp<T>;
|
|
88
|
+
export function mixedProp<T>(type?: PropType<T>, defaultValue?: T): OptionalProp<T | null> {
|
|
89
|
+
return {
|
|
90
|
+
type,
|
|
91
|
+
default: defaultValue ?? null,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
83
95
|
export function numberProp(): OptionalProp<number | null>;
|
|
84
96
|
export function numberProp(defaultValue: number): OptionalProp<number>;
|
|
85
97
|
export function numberProp(defaultValue: number | null = null): OptionalProp<number | null> {
|
package/vite.config.ts
CHANGED
|
@@ -4,7 +4,10 @@ import { defineConfig } from 'vitest/config';
|
|
|
4
4
|
import { resolve } from 'path';
|
|
5
5
|
|
|
6
6
|
export default defineConfig({
|
|
7
|
-
test: {
|
|
7
|
+
test: {
|
|
8
|
+
clearMocks: true,
|
|
9
|
+
setupFiles: ['./src/testing/setup.ts'],
|
|
10
|
+
},
|
|
8
11
|
plugins: [Aerogel(), Icons()],
|
|
9
12
|
resolve: {
|
|
10
13
|
alias: {
|