@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.
Files changed (116) hide show
  1. package/dist/aerogel-core.d.ts +2317 -667
  2. package/dist/aerogel-core.js +2789 -0
  3. package/dist/aerogel-core.js.map +1 -0
  4. package/package.json +19 -34
  5. package/src/bootstrap/bootstrap.test.ts +4 -7
  6. package/src/bootstrap/index.ts +25 -16
  7. package/src/bootstrap/options.ts +1 -1
  8. package/src/components/AGAppLayout.vue +1 -1
  9. package/src/components/AGAppModals.vue +1 -1
  10. package/src/components/AGAppOverlays.vue +1 -1
  11. package/src/components/composition.ts +1 -1
  12. package/src/components/forms/AGButton.vue +2 -2
  13. package/src/components/forms/AGCheckbox.vue +4 -3
  14. package/src/components/forms/AGForm.vue +2 -2
  15. package/src/components/forms/AGInput.vue +6 -4
  16. package/src/components/forms/AGSelect.vue +3 -3
  17. package/src/components/headless/forms/AGHeadlessButton.ts +1 -1
  18. package/src/components/headless/forms/AGHeadlessButton.vue +2 -2
  19. package/src/components/headless/forms/AGHeadlessInput.ts +11 -4
  20. package/src/components/headless/forms/AGHeadlessInput.vue +3 -3
  21. package/src/components/headless/forms/AGHeadlessInputDescription.vue +1 -1
  22. package/src/components/headless/forms/AGHeadlessInputError.vue +2 -2
  23. package/src/components/headless/forms/AGHeadlessInputInput.vue +4 -4
  24. package/src/components/headless/forms/AGHeadlessInputLabel.vue +1 -1
  25. package/src/components/headless/forms/AGHeadlessInputTextArea.vue +3 -3
  26. package/src/components/headless/forms/AGHeadlessSelect.ts +3 -3
  27. package/src/components/headless/forms/AGHeadlessSelect.vue +5 -5
  28. package/src/components/headless/forms/AGHeadlessSelectButton.vue +2 -2
  29. package/src/components/headless/forms/AGHeadlessSelectError.vue +2 -2
  30. package/src/components/headless/forms/AGHeadlessSelectLabel.vue +2 -2
  31. package/src/components/headless/forms/AGHeadlessSelectOption.vue +3 -3
  32. package/src/components/headless/forms/composition.ts +1 -1
  33. package/src/components/headless/modals/AGHeadlessModal.ts +6 -4
  34. package/src/components/headless/modals/AGHeadlessModal.vue +14 -8
  35. package/src/components/headless/modals/AGHeadlessModalPanel.vue +13 -9
  36. package/src/components/headless/modals/AGHeadlessModalTitle.vue +14 -4
  37. package/src/components/headless/snackbars/index.ts +3 -3
  38. package/src/components/lib/AGErrorMessage.vue +3 -3
  39. package/src/components/lib/AGMarkdown.vue +16 -3
  40. package/src/components/lib/AGMeasured.vue +1 -1
  41. package/src/components/lib/AGProgressBar.vue +55 -0
  42. package/src/components/lib/index.ts +1 -0
  43. package/src/components/modals/AGAlertModal.ts +6 -3
  44. package/src/components/modals/AGConfirmModal.ts +16 -7
  45. package/src/components/modals/AGConfirmModal.vue +2 -2
  46. package/src/components/modals/AGErrorReportModal.ts +8 -5
  47. package/src/components/modals/AGErrorReportModalButtons.vue +9 -9
  48. package/src/components/modals/AGErrorReportModalTitle.vue +2 -2
  49. package/src/components/modals/AGLoadingModal.ts +11 -5
  50. package/src/components/modals/AGModal.ts +1 -0
  51. package/src/components/modals/AGModal.vue +5 -2
  52. package/src/components/modals/AGModalContext.ts +1 -1
  53. package/src/components/modals/AGModalContext.vue +15 -5
  54. package/src/components/modals/AGPromptModal.ts +12 -7
  55. package/src/components/snackbars/AGSnackbar.vue +2 -2
  56. package/src/components/utils.ts +7 -4
  57. package/src/directives/index.ts +3 -5
  58. package/src/directives/measure.ts +1 -1
  59. package/src/errors/Errors.state.ts +1 -1
  60. package/src/errors/Errors.ts +12 -12
  61. package/src/errors/JobCancelledError.ts +3 -0
  62. package/src/errors/index.ts +9 -6
  63. package/src/errors/utils.ts +17 -1
  64. package/src/forms/Form.test.ts +4 -3
  65. package/src/forms/Form.ts +27 -17
  66. package/src/forms/composition.ts +2 -2
  67. package/src/forms/index.ts +2 -1
  68. package/src/forms/utils.ts +20 -4
  69. package/src/forms/validation.ts +19 -0
  70. package/src/jobs/Job.ts +144 -2
  71. package/src/jobs/index.ts +4 -1
  72. package/src/jobs/listeners.ts +3 -0
  73. package/src/jobs/status.ts +4 -0
  74. package/src/lang/DefaultLangProvider.ts +7 -4
  75. package/src/lang/Lang.state.ts +1 -1
  76. package/src/lang/Lang.ts +5 -1
  77. package/src/lang/index.ts +7 -5
  78. package/src/plugins/Plugin.ts +1 -1
  79. package/src/plugins/index.ts +10 -7
  80. package/src/services/App.state.ts +13 -4
  81. package/src/services/App.ts +8 -3
  82. package/src/services/Cache.ts +1 -1
  83. package/src/services/Events.ts +15 -5
  84. package/src/services/Service.ts +116 -53
  85. package/src/services/Storage.ts +20 -0
  86. package/src/services/index.ts +10 -4
  87. package/src/services/utils.ts +18 -0
  88. package/src/testing/index.ts +4 -3
  89. package/src/testing/setup.ts +5 -19
  90. package/src/ui/UI.state.ts +2 -2
  91. package/src/ui/UI.ts +139 -54
  92. package/src/ui/index.ts +4 -4
  93. package/src/ui/utils.ts +1 -1
  94. package/src/utils/composition/events.ts +2 -2
  95. package/src/utils/composition/persistent.test.ts +33 -0
  96. package/src/utils/composition/persistent.ts +11 -0
  97. package/src/utils/composition/state.test.ts +47 -0
  98. package/src/utils/composition/state.ts +24 -0
  99. package/src/utils/index.ts +2 -0
  100. package/src/utils/markdown.test.ts +50 -0
  101. package/src/utils/markdown.ts +19 -6
  102. package/src/utils/vue.ts +14 -4
  103. package/dist/aerogel-core.cjs.js +0 -2
  104. package/dist/aerogel-core.cjs.js.map +0 -1
  105. package/dist/aerogel-core.esm.js +0 -2
  106. package/dist/aerogel-core.esm.js.map +0 -1
  107. package/histoire.config.ts +0 -7
  108. package/noeldemartin.config.js +0 -5
  109. package/postcss.config.js +0 -6
  110. package/src/assets/histoire.css +0 -3
  111. package/src/directives/initial-focus.ts +0 -11
  112. package/src/main.histoire.ts +0 -1
  113. package/tailwind.config.js +0 -4
  114. package/tsconfig.json +0 -11
  115. package/vite.config.ts +0 -17
  116. /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 Events from '@/services/Events';
7
- import type { Color } from '@/components/constants';
8
- import type { SnackbarAction, SnackbarColor } from '@/components/headless/snackbars';
9
- import type { AGAlertModalProps, AGConfirmModalProps, AGLoadingModalProps, AGPromptModalProps } from '@/components';
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> = TComponent extends ModalComponent<Record<string, unknown>, infer TResult>
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 interface ConfirmOptions {
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 interface PromptOptions {
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
- ...(options ?? {}),
132
+ required: !!options?.required,
106
133
  };
107
134
  };
108
-
109
- const modal = await this.openModal<ModalComponent<AGConfirmModalProps, boolean>>(
110
- this.requireComponent(UIComponents.ConfirmModal),
111
- getProperties(),
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
- return result ?? false;
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
- messageOrOperation: string | Promise<T> | (() => T),
206
+ operationOrMessageOrOptions: string | LoadingOptions | Promise<T> | (() => T),
155
207
  operation?: Promise<T> | (() => T),
156
208
  ): Promise<T> {
157
- const getProperties = (): AGLoadingModalProps => {
158
- if (typeof messageOrOperation !== 'string') {
159
- return {};
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 { message: messageOrOperation };
222
+ return {
223
+ props: operationOrMessageOrOptions,
224
+ operationPromise: processOperation(operation as Promise<T> | (() => T)),
225
+ };
163
226
  };
164
227
 
165
- const modal = await this.openModal(this.requireComponent(UIComponents.LoadingModal), getProperties());
228
+ const { operationPromise, props } = processArgs();
229
+ const modal = await this.openModal(this.requireComponent(UIComponents.LoadingModal), props);
166
230
 
167
231
  try {
168
- operation = typeof messageOrOperation === 'string' ? (operation as () => T) : messageOrOperation;
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
- protected async boot(): Promise<void> {
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.watchWindowMedia();
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.setState(
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
- const splash = document.getElementById('splash');
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 (window.getComputedStyle(splash).opacity !== '0') {
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 watchWindowMedia(): void {
286
- const media = window.matchMedia(`(min-width: ${MOBILE_BREAKPOINT}px)`);
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 '@/services/Events' {
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 '@/services';
4
- import { definePlugin } from '@/plugins';
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 '@/bootstrap/options' {
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 '@/services' {
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 (window.innerWidth > MOBILE_BREAKPOINT) {
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 '@/services/Events';
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 '@/services/Events';
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
+ }
@@ -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,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(href, title, text) {
8
- return Renderer.prototype.link.apply(this, [href, title, text]).replace('<a', '<a target="_blank"');
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
- return safeHtml(marked(markdown, { mangle: false, headerIds: false, renderer: makeRenderer() }));
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>;