@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.
Files changed (78) hide show
  1. package/dist/aerogel-core.cjs.js +1 -1
  2. package/dist/aerogel-core.cjs.js.map +1 -1
  3. package/dist/aerogel-core.d.ts +653 -91
  4. package/dist/aerogel-core.esm.js +1 -1
  5. package/dist/aerogel-core.esm.js.map +1 -1
  6. package/package.json +3 -3
  7. package/src/bootstrap/bootstrap.test.ts +0 -1
  8. package/src/bootstrap/index.ts +12 -2
  9. package/src/components/AGAppSnackbars.vue +1 -1
  10. package/src/components/composition.ts +23 -0
  11. package/src/components/forms/AGForm.vue +9 -10
  12. package/src/components/forms/AGInput.vue +2 -0
  13. package/src/components/headless/forms/AGHeadlessButton.ts +3 -0
  14. package/src/components/headless/forms/AGHeadlessButton.vue +15 -4
  15. package/src/components/headless/forms/AGHeadlessInput.ts +10 -4
  16. package/src/components/headless/forms/AGHeadlessInput.vue +18 -5
  17. package/src/components/headless/forms/AGHeadlessInputDescription.vue +28 -0
  18. package/src/components/headless/forms/AGHeadlessInputInput.vue +42 -5
  19. package/src/components/headless/forms/AGHeadlessInputTextArea.vue +43 -0
  20. package/src/components/headless/forms/composition.ts +10 -0
  21. package/src/components/headless/forms/index.ts +4 -0
  22. package/src/components/headless/modals/AGHeadlessModal.ts +3 -1
  23. package/src/components/headless/modals/AGHeadlessModal.vue +10 -4
  24. package/src/components/headless/modals/AGHeadlessModalPanel.vue +10 -6
  25. package/src/components/headless/modals/AGHeadlessModalTitle.vue +14 -4
  26. package/src/components/index.ts +2 -0
  27. package/src/components/interfaces.ts +24 -0
  28. package/src/components/lib/AGMarkdown.vue +22 -4
  29. package/src/components/lib/AGMeasured.vue +1 -0
  30. package/src/components/lib/AGProgressBar.vue +55 -0
  31. package/src/components/lib/index.ts +1 -0
  32. package/src/components/modals/AGAlertModal.ts +5 -2
  33. package/src/components/modals/AGConfirmModal.ts +18 -3
  34. package/src/components/modals/AGConfirmModal.vue +3 -3
  35. package/src/components/modals/AGErrorReportModal.ts +5 -2
  36. package/src/components/modals/AGLoadingModal.ts +10 -4
  37. package/src/components/modals/AGModal.ts +1 -0
  38. package/src/components/modals/AGModalContext.vue +14 -4
  39. package/src/components/modals/AGPromptModal.ts +14 -3
  40. package/src/components/modals/AGPromptModal.vue +2 -2
  41. package/src/directives/measure.ts +24 -5
  42. package/src/errors/JobCancelledError.ts +3 -0
  43. package/src/errors/index.ts +2 -0
  44. package/src/errors/utils.ts +16 -0
  45. package/src/forms/Form.test.ts +28 -0
  46. package/src/forms/Form.ts +65 -8
  47. package/src/forms/index.ts +3 -1
  48. package/src/forms/utils.ts +34 -3
  49. package/src/forms/validation.ts +19 -0
  50. package/src/jobs/Job.ts +144 -2
  51. package/src/jobs/index.ts +4 -1
  52. package/src/jobs/listeners.ts +3 -0
  53. package/src/jobs/status.ts +4 -0
  54. package/src/lang/DefaultLangProvider.ts +43 -0
  55. package/src/lang/Lang.state.ts +11 -0
  56. package/src/lang/Lang.ts +48 -21
  57. package/src/services/App.state.ts +22 -0
  58. package/src/services/App.ts +8 -0
  59. package/src/services/Cache.ts +43 -0
  60. package/src/services/Events.ts +13 -3
  61. package/src/services/Service.ts +116 -45
  62. package/src/services/Storage.ts +20 -0
  63. package/src/services/index.ts +9 -2
  64. package/src/services/utils.ts +18 -0
  65. package/src/testing/setup.ts +27 -0
  66. package/src/ui/UI.state.ts +7 -0
  67. package/src/ui/UI.ts +145 -44
  68. package/src/ui/index.ts +1 -0
  69. package/src/ui/utils.ts +16 -0
  70. package/src/utils/composition/persistent.test.ts +33 -0
  71. package/src/utils/composition/persistent.ts +11 -0
  72. package/src/utils/composition/state.test.ts +47 -0
  73. package/src/utils/composition/state.ts +24 -0
  74. package/src/utils/index.ts +2 -0
  75. package/src/utils/markdown.test.ts +50 -0
  76. package/src/utils/markdown.ts +17 -2
  77. package/src/utils/vue.ts +15 -3
  78. 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 interface ConfirmOptions {
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 interface PromptOptions {
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
- ...(options ?? {}),
128
+ required: !!options?.required,
99
129
  };
100
130
  };
101
-
102
- const modal = await this.openModal<ModalComponent<AGConfirmModalProps, boolean>>(
103
- this.requireComponent(UIComponents.ConfirmModal),
104
- getProperties(),
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
- return result ?? false;
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 result = await modal.beforeClose;
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>(messageOrOperation: string | Promise<T>, operation?: Promise<T>): Promise<T> {
145
- const getProperties = (): AGLoadingModalProps => {
146
- if (typeof messageOrOperation !== 'string') {
147
- return {};
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
- return { message: messageOrOperation };
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 modal = await this.openModal(this.requireComponent(UIComponents.LoadingModal), getProperties());
224
+ const { operationPromise, props } = processArgs();
225
+ const modal = await this.openModal(this.requireComponent(UIComponents.LoadingModal), props);
154
226
 
155
227
  try {
156
- operation = typeof messageOrOperation === 'string' ? (operation as Promise<T>) : messageOrOperation;
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.setState(
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
- const splash = document.getElementById('splash');
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 (window.getComputedStyle(splash).opacity !== '0') {
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
@@ -16,6 +16,7 @@ import type { UIComponent } from './UI';
16
16
  const services = { $ui: UI };
17
17
 
18
18
  export * from './UI';
19
+ export * from './utils';
19
20
  export { default as UI } from './UI';
20
21
 
21
22
  export type UIServices = typeof services;
@@ -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
+ }
@@ -1,5 +1,7 @@
1
1
  export * from './composition/events';
2
2
  export * from './composition/forms';
3
3
  export * from './composition/hooks';
4
+ export * from './composition/persistent';
5
+ export * from './markdown';
4
6
  export * from './tailwindcss';
5
7
  export * from './vue';
@@ -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
+ }
@@ -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
- return safeHtml(marked(markdown, { mangle: false, headerIds: false, renderer: makeRenderer() }));
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 mixedProp<T>(type?: PropType<T>): OptionalProp<T | null> {
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: { clearMocks: true },
7
+ test: {
8
+ clearMocks: true,
9
+ setupFiles: ['./src/testing/setup.ts'],
10
+ },
8
11
  plugins: [Aerogel(), Icons()],
9
12
  resolve: {
10
13
  alias: {