@aerogel/core 0.0.0-next.7f6ed5a1f91688a86bf5ede2adc465e4fd6cfdea → 0.0.0-next.8323c60b905020dcb3bd9d4b0bc8d9b6529e1082
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.css +1 -0
- package/dist/aerogel-core.d.ts +2547 -422
- package/dist/aerogel-core.js +3722 -0
- package/dist/aerogel-core.js.map +1 -0
- package/package.json +39 -37
- package/src/bootstrap/bootstrap.test.ts +7 -66
- package/src/bootstrap/index.ts +46 -33
- package/src/bootstrap/options.ts +8 -1
- package/src/components/AppLayout.vue +14 -0
- package/src/components/AppModals.vue +14 -0
- package/src/components/AppOverlays.vue +9 -0
- package/src/components/AppToasts.vue +16 -0
- package/src/components/contracts/AlertModal.ts +19 -0
- package/src/components/contracts/Button.ts +16 -0
- package/src/components/contracts/ConfirmModal.ts +48 -0
- package/src/components/contracts/DropdownMenu.ts +25 -0
- package/src/components/contracts/ErrorReportModal.ts +33 -0
- package/src/components/contracts/Input.ts +26 -0
- package/src/components/contracts/LoadingModal.ts +26 -0
- package/src/components/contracts/Modal.ts +21 -0
- package/src/components/contracts/PromptModal.ts +34 -0
- package/src/components/contracts/Select.ts +45 -0
- package/src/components/contracts/Toast.ts +15 -0
- package/src/components/contracts/index.ts +11 -0
- package/src/components/headless/HeadlessButton.vue +51 -0
- package/src/components/headless/HeadlessInput.vue +59 -0
- package/src/components/headless/HeadlessInputDescription.vue +27 -0
- package/src/components/headless/HeadlessInputError.vue +22 -0
- package/src/components/headless/HeadlessInputInput.vue +86 -0
- package/src/components/headless/HeadlessInputLabel.vue +18 -0
- package/src/components/headless/HeadlessInputTextArea.vue +40 -0
- package/src/components/headless/HeadlessModal.vue +57 -0
- package/src/components/headless/HeadlessModalContent.vue +30 -0
- package/src/components/headless/HeadlessModalDescription.vue +12 -0
- package/src/components/headless/HeadlessModalOverlay.vue +12 -0
- package/src/components/headless/HeadlessModalTitle.vue +12 -0
- package/src/components/headless/HeadlessSelect.vue +120 -0
- package/src/components/headless/HeadlessSelectError.vue +25 -0
- package/src/components/headless/HeadlessSelectLabel.vue +25 -0
- package/src/components/headless/HeadlessSelectOption.vue +34 -0
- package/src/components/headless/HeadlessSelectOptions.vue +42 -0
- package/src/components/headless/HeadlessSelectTrigger.vue +22 -0
- package/src/components/headless/HeadlessSelectValue.vue +18 -0
- package/src/components/headless/HeadlessSwitch.vue +96 -0
- package/src/components/headless/HeadlessToast.vue +18 -0
- package/src/components/headless/HeadlessToastAction.vue +13 -0
- package/src/components/headless/index.ts +20 -2
- package/src/components/index.ts +6 -6
- package/src/components/ui/AdvancedOptions.vue +18 -0
- package/src/components/ui/AlertModal.vue +17 -0
- package/src/components/ui/Button.vue +115 -0
- package/src/components/ui/Checkbox.vue +56 -0
- package/src/components/ui/ConfirmModal.vue +50 -0
- package/src/components/ui/DropdownMenu.vue +32 -0
- package/src/components/ui/DropdownMenuOption.vue +22 -0
- package/src/components/ui/DropdownMenuOptions.vue +44 -0
- package/src/components/ui/EditableContent.vue +82 -0
- package/src/components/ui/ErrorLogs.vue +19 -0
- package/src/components/ui/ErrorLogsModal.vue +48 -0
- package/src/components/ui/ErrorMessage.vue +15 -0
- package/src/components/ui/ErrorReportModal.vue +73 -0
- package/src/components/ui/ErrorReportModalButtons.vue +118 -0
- package/src/components/ui/ErrorReportModalTitle.vue +24 -0
- package/src/components/ui/Form.vue +24 -0
- package/src/components/ui/Input.vue +56 -0
- package/src/components/ui/Link.vue +12 -0
- package/src/components/ui/LoadingModal.vue +34 -0
- package/src/components/ui/Markdown.vue +97 -0
- package/src/components/ui/Modal.vue +131 -0
- package/src/components/ui/ModalContext.vue +31 -0
- package/src/components/ui/ProgressBar.vue +51 -0
- package/src/components/ui/PromptModal.vue +38 -0
- package/src/components/ui/Select.vue +27 -0
- package/src/components/ui/SelectLabel.vue +21 -0
- package/src/components/ui/SelectOption.vue +29 -0
- package/src/components/ui/SelectOptions.vue +35 -0
- package/src/components/ui/SelectTrigger.vue +29 -0
- package/src/components/ui/Setting.vue +31 -0
- package/src/components/ui/SettingsModal.vue +15 -0
- package/src/components/ui/StartupCrash.vue +31 -0
- package/src/components/ui/Switch.vue +11 -0
- package/src/components/ui/TextArea.vue +56 -0
- package/src/components/ui/Toast.vue +46 -0
- package/src/components/ui/index.ts +35 -0
- package/src/directives/index.ts +29 -6
- package/src/directives/measure.ts +46 -0
- package/src/errors/Errors.state.ts +31 -0
- package/src/errors/Errors.ts +200 -0
- package/src/errors/JobCancelledError.ts +3 -0
- package/src/errors/index.ts +53 -0
- package/src/errors/settings/Debug.vue +32 -0
- package/src/errors/settings/index.ts +10 -0
- package/src/errors/utils.ts +35 -0
- package/src/forms/FormController.test.ts +113 -0
- package/src/forms/FormController.ts +255 -0
- package/src/forms/index.ts +3 -2
- package/src/forms/utils.ts +87 -14
- package/src/forms/validation.ts +50 -0
- package/src/index.css +76 -0
- package/src/{main.ts → index.ts} +5 -0
- package/src/jobs/Job.ts +147 -0
- package/src/jobs/index.ts +10 -0
- package/src/jobs/listeners.ts +3 -0
- package/src/jobs/status.ts +4 -0
- package/src/lang/DefaultLangProvider.ts +46 -0
- package/src/lang/Lang.state.ts +11 -0
- package/src/lang/Lang.ts +63 -9
- package/src/lang/index.ts +22 -75
- package/src/lang/settings/Language.vue +48 -0
- package/src/lang/settings/index.ts +10 -0
- package/src/lang/utils.ts +4 -0
- package/src/plugins/Plugin.ts +8 -0
- package/src/plugins/index.ts +29 -0
- package/src/services/App.state.ts +50 -0
- package/src/services/App.ts +63 -0
- package/src/services/Cache.ts +43 -0
- package/src/services/Events.test.ts +39 -0
- package/src/services/Events.ts +110 -36
- package/src/services/Service.ts +273 -35
- package/src/services/Storage.ts +20 -0
- package/src/services/index.ts +45 -8
- package/src/services/store.ts +30 -0
- package/src/services/utils.ts +18 -0
- package/src/testing/index.ts +30 -0
- package/src/testing/setup.ts +11 -0
- package/src/types/vite.d.ts +0 -2
- package/src/ui/UI.state.ts +21 -13
- package/src/ui/UI.ts +350 -53
- package/src/ui/index.ts +40 -25
- package/src/ui/utils.ts +16 -0
- package/src/utils/app.ts +7 -0
- package/src/utils/classes.ts +41 -0
- package/src/utils/composition/events.ts +4 -5
- package/src/utils/composition/forms.ts +27 -0
- package/src/utils/composition/hooks.ts +9 -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 +33 -0
- package/src/utils/index.ts +9 -0
- package/src/utils/markdown.test.ts +50 -0
- package/src/utils/markdown.ts +60 -4
- package/src/utils/types.ts +3 -0
- package/src/utils/vue.ts +38 -121
- package/.eslintrc.js +0 -3
- package/dist/aerogel-core.cjs.js +0 -2
- package/dist/aerogel-core.cjs.js.map +0 -1
- package/dist/aerogel-core.esm.js +0 -2
- package/dist/aerogel-core.esm.js.map +0 -1
- package/noeldemartin.config.js +0 -2
- package/src/bootstrap/hooks.ts +0 -19
- package/src/components/AGAppLayout.vue +0 -11
- package/src/components/AGAppOverlays.vue +0 -39
- package/src/components/basic/AGMarkdown.vue +0 -20
- package/src/components/basic/index.ts +0 -3
- package/src/components/forms/AGButton.vue +0 -11
- package/src/components/forms/AGForm.vue +0 -26
- package/src/components/forms/AGInput.vue +0 -32
- package/src/components/forms/index.ts +0 -5
- package/src/components/headless/forms/AGHeadlessButton.vue +0 -51
- package/src/components/headless/forms/AGHeadlessInput.ts +0 -8
- package/src/components/headless/forms/AGHeadlessInput.vue +0 -54
- package/src/components/headless/forms/AGHeadlessInputError.vue +0 -22
- package/src/components/headless/forms/AGHeadlessInputInput.vue +0 -29
- package/src/components/headless/forms/index.ts +0 -4
- package/src/components/headless/modals/AGHeadlessModal.ts +0 -7
- package/src/components/headless/modals/AGHeadlessModal.vue +0 -84
- package/src/components/headless/modals/AGHeadlessModalPanel.vue +0 -20
- package/src/components/headless/modals/AGHeadlessModalTitle.vue +0 -13
- package/src/components/headless/modals/index.ts +0 -6
- package/src/components/modals/AGAlertModal.vue +0 -15
- package/src/components/modals/AGModal.ts +0 -6
- package/src/components/modals/AGModal.vue +0 -18
- package/src/components/modals/AGModalContext.ts +0 -8
- package/src/components/modals/AGModalContext.vue +0 -22
- package/src/components/modals/index.ts +0 -5
- package/src/directives/initial-focus.ts +0 -11
- package/src/forms/Form.test.ts +0 -37
- package/src/forms/Form.ts +0 -154
- package/src/forms/composition.ts +0 -6
- package/src/lang/helpers.ts +0 -5
- package/src/models/index.ts +0 -18
- package/src/routing/index.ts +0 -33
- package/src/testing/stubs/lang/en.yaml +0 -1
- package/src/testing/stubs/models/User.ts +0 -3
- package/tsconfig.json +0 -19
- package/vite.config.ts +0 -17
package/src/ui/index.ts
CHANGED
|
@@ -1,37 +1,52 @@
|
|
|
1
|
+
import AlertModal from '@aerogel/core/components/ui/AlertModal.vue';
|
|
2
|
+
import ConfirmModal from '@aerogel/core/components/ui/ConfirmModal.vue';
|
|
3
|
+
import ErrorReportModal from '@aerogel/core/components/ui/ErrorReportModal.vue';
|
|
4
|
+
import LoadingModal from '@aerogel/core/components/ui/LoadingModal.vue';
|
|
5
|
+
import PromptModal from '@aerogel/core/components/ui/PromptModal.vue';
|
|
6
|
+
import StartupCrash from '@aerogel/core/components/ui/StartupCrash.vue';
|
|
7
|
+
import Toast from '@aerogel/core/components/ui/Toast.vue';
|
|
8
|
+
import { bootServices } from '@aerogel/core/services';
|
|
9
|
+
import { definePlugin } from '@aerogel/core/plugins';
|
|
10
|
+
|
|
11
|
+
import UI from './UI';
|
|
12
|
+
import type { UIComponents } from './UI';
|
|
1
13
|
import type { Component } from 'vue';
|
|
2
14
|
|
|
3
|
-
import { bootServices } from '@/services';
|
|
4
|
-
import { defineBootstrapHook } from '@/bootstrap/hooks';
|
|
5
|
-
|
|
6
|
-
import UI, { UIComponents } from './UI';
|
|
7
|
-
import AGAlertModal from '../components/modals/AGAlertModal.vue';
|
|
8
|
-
import type { UIComponent } from './UI';
|
|
9
|
-
|
|
10
|
-
export { UI, UIComponents, UIComponent };
|
|
11
|
-
|
|
12
15
|
const services = { $ui: UI };
|
|
13
16
|
|
|
14
|
-
export
|
|
15
|
-
|
|
16
|
-
export default
|
|
17
|
-
const defaultComponents = {
|
|
18
|
-
[UIComponents.AlertModal]: AGAlertModal,
|
|
19
|
-
};
|
|
17
|
+
export * from './UI';
|
|
18
|
+
export * from './utils';
|
|
19
|
+
export { default as UI } from './UI';
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
...defaultComponents,
|
|
23
|
-
...options.components,
|
|
24
|
-
}).forEach(([name, component]) => UI.registerComponent(name as UIComponent, component));
|
|
21
|
+
export type UIServices = typeof services;
|
|
25
22
|
|
|
26
|
-
|
|
23
|
+
export default definePlugin({
|
|
24
|
+
async install(app, options) {
|
|
25
|
+
const components: Partial<Record<keyof UIComponents, Component>> = {
|
|
26
|
+
'alert-modal': AlertModal,
|
|
27
|
+
'confirm-modal': ConfirmModal,
|
|
28
|
+
'error-report-modal': ErrorReportModal,
|
|
29
|
+
'loading-modal': LoadingModal,
|
|
30
|
+
'prompt-modal': PromptModal,
|
|
31
|
+
'startup-crash': StartupCrash,
|
|
32
|
+
'toast': Toast,
|
|
33
|
+
...options.components,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
for (const [name, component] of Object.entries(components)) {
|
|
37
|
+
UI.registerComponent(name as keyof UIComponents, component as UIComponents[keyof UIComponents]);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
await bootServices(app, services);
|
|
41
|
+
},
|
|
27
42
|
});
|
|
28
43
|
|
|
29
|
-
declare module '
|
|
30
|
-
interface
|
|
31
|
-
components?: Partial<
|
|
44
|
+
declare module '@aerogel/core/bootstrap/options' {
|
|
45
|
+
export interface AerogelOptions {
|
|
46
|
+
components?: Partial<Partial<UIComponents>>;
|
|
32
47
|
}
|
|
33
48
|
}
|
|
34
49
|
|
|
35
|
-
declare module '
|
|
36
|
-
interface Services extends UIServices {}
|
|
50
|
+
declare module '@aerogel/core/services' {
|
|
51
|
+
export interface Services extends UIServices {}
|
|
37
52
|
}
|
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
|
+
}
|
package/src/utils/app.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import clsx from 'clsx';
|
|
2
|
+
import { unref } from 'vue';
|
|
3
|
+
import { cva } from 'class-variance-authority';
|
|
4
|
+
import { twMerge } from 'tailwind-merge';
|
|
5
|
+
import type { ClassValue } from 'clsx';
|
|
6
|
+
import type { PropType } from 'vue';
|
|
7
|
+
import type { GetClosureArgs, GetClosureResult } from '@noeldemartin/utils';
|
|
8
|
+
|
|
9
|
+
export type CVAConfig<T> = NonNullable<GetClosureArgs<typeof cva<T>>[1]>;
|
|
10
|
+
export type CVAProps<T> = NonNullable<GetClosureArgs<GetClosureResult<typeof cva<T>>>[0]>;
|
|
11
|
+
export type Variants<T extends Record<string, string | boolean>> = Required<{
|
|
12
|
+
[K in keyof T]: Exclude<T[K], undefined> extends string
|
|
13
|
+
? { [key in Exclude<T[K], undefined>]: string | null }
|
|
14
|
+
: { true: string | null; false: string | null };
|
|
15
|
+
}>;
|
|
16
|
+
|
|
17
|
+
export type ComponentPropDefinitions<T> = {
|
|
18
|
+
[K in keyof T]: {
|
|
19
|
+
type?: PropType<T[K]>;
|
|
20
|
+
default: T[K] | (() => T[K]) | null;
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type PickComponentProps<TValues, TDefinitions> = {
|
|
25
|
+
[K in keyof TValues]: K extends keyof TDefinitions ? TValues[K] : never;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function variantClasses<T>(
|
|
29
|
+
value: { baseClasses?: string } & CVAProps<T>,
|
|
30
|
+
config: { baseClasses?: string } & CVAConfig<T>,
|
|
31
|
+
): string {
|
|
32
|
+
const { baseClasses: valueBaseClasses, ...values } = value;
|
|
33
|
+
const { baseClasses: configBaseClasses, ...configs } = config;
|
|
34
|
+
const variants = cva(configBaseClasses, configs as CVAConfig<T>);
|
|
35
|
+
|
|
36
|
+
return classes(variants(values as CVAProps<T>), unref(valueBaseClasses));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function classes(...inputs: ClassValue[]): string {
|
|
40
|
+
return twMerge(clsx(inputs));
|
|
41
|
+
}
|
|
@@ -1,23 +1,22 @@
|
|
|
1
1
|
import { onUnmounted } from 'vue';
|
|
2
2
|
|
|
3
|
-
import Events from '
|
|
3
|
+
import Events from '@aerogel/core/services/Events';
|
|
4
4
|
import type {
|
|
5
5
|
EventListener,
|
|
6
6
|
EventWithPayload,
|
|
7
7
|
EventWithoutPayload,
|
|
8
8
|
EventsPayload,
|
|
9
|
-
|
|
10
|
-
} from '@/services/Events';
|
|
9
|
+
} from '@aerogel/core/services/Events';
|
|
11
10
|
|
|
12
11
|
export function useEvent<Event extends EventWithoutPayload>(event: Event, listener: () => unknown): void;
|
|
13
12
|
export function useEvent<Event extends EventWithPayload>(
|
|
14
13
|
event: Event,
|
|
15
14
|
listener: EventListener<EventsPayload[Event]>
|
|
16
15
|
): void;
|
|
17
|
-
export function useEvent<Event extends string>(event: UnknownEvent<Event>, listener: EventListener): void;
|
|
18
16
|
|
|
19
17
|
export function useEvent(event: string, listener: EventListener): void {
|
|
20
|
-
|
|
18
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
19
|
+
const unsubscribe = Events.on(event as any, listener);
|
|
21
20
|
|
|
22
21
|
onUnmounted(() => unsubscribe());
|
|
23
22
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { objectWithout } from '@noeldemartin/utils';
|
|
2
|
+
import { computed, inject, onUnmounted, useAttrs } from 'vue';
|
|
3
|
+
import type { ClassValue } from 'clsx';
|
|
4
|
+
import type { ComputedRef } from 'vue';
|
|
5
|
+
import type { Nullable } from '@noeldemartin/utils';
|
|
6
|
+
|
|
7
|
+
import FormController from '@aerogel/core/forms/FormController';
|
|
8
|
+
import type { FormData, FormFieldDefinitions } from '@aerogel/core/forms/FormController';
|
|
9
|
+
|
|
10
|
+
export function onFormFocus(input: { name: Nullable<string> }, listener: () => unknown): void {
|
|
11
|
+
const form = inject<FormController | null>('form', null);
|
|
12
|
+
const stop = form?.on('focus', (name) => input.name === name && listener());
|
|
13
|
+
|
|
14
|
+
onUnmounted(() => stop?.());
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function useForm<const T extends FormFieldDefinitions>(fields: T): FormController<T> & FormData<T> {
|
|
18
|
+
return new FormController(fields) as FormController<T> & FormData<T>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function useInputAttrs(): [ComputedRef<{}>, ComputedRef<ClassValue>] {
|
|
22
|
+
const attrs = useAttrs();
|
|
23
|
+
const classes = computed(() => attrs.class);
|
|
24
|
+
const inputAttrs = computed(() => objectWithout(attrs, 'class'));
|
|
25
|
+
|
|
26
|
+
return [inputAttrs, classes as ComputedRef<ClassValue>];
|
|
27
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { noop } from '@noeldemartin/utils';
|
|
2
|
+
import { onMounted, onUnmounted } from 'vue';
|
|
3
|
+
|
|
4
|
+
export function onCleanMounted(operation: () => Function): void {
|
|
5
|
+
let cleanUp: Function = noop;
|
|
6
|
+
|
|
7
|
+
onMounted(() => (cleanUp = operation()));
|
|
8
|
+
onUnmounted(() => cleanUp());
|
|
9
|
+
}
|
|
@@ -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,33 @@
|
|
|
1
|
+
import { debounce } from '@noeldemartin/utils';
|
|
2
|
+
import { computed, ref, watch, watchEffect } from 'vue';
|
|
3
|
+
import type { ComputedGetter, ComputedRef, Ref } from 'vue';
|
|
4
|
+
|
|
5
|
+
export interface ComputedDebounceOptions<T> {
|
|
6
|
+
initial?: T;
|
|
7
|
+
delay?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function computedAsync<T>(getter: () => Promise<T>): Ref<T | undefined> {
|
|
11
|
+
const result = ref<T>();
|
|
12
|
+
const asyncValue = computed(getter);
|
|
13
|
+
|
|
14
|
+
watch(asyncValue, async () => (result.value = await asyncValue.value), { immediate: true });
|
|
15
|
+
|
|
16
|
+
return result;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function computedDebounce<T>(options: ComputedDebounceOptions<T>, getter: ComputedGetter<T>): ComputedRef<T>;
|
|
20
|
+
export function computedDebounce<T>(getter: ComputedGetter<T>): ComputedRef<T | null>;
|
|
21
|
+
export function computedDebounce<T>(
|
|
22
|
+
optionsOrGetter: ComputedGetter<T> | ComputedDebounceOptions<T>,
|
|
23
|
+
inputGetter?: ComputedGetter<T>,
|
|
24
|
+
): ComputedRef<T> {
|
|
25
|
+
const inputOptions = inputGetter ? (optionsOrGetter as ComputedDebounceOptions<T>) : {};
|
|
26
|
+
const getter = inputGetter ?? (optionsOrGetter as ComputedGetter<T>);
|
|
27
|
+
const state = ref(inputOptions.initial ?? null);
|
|
28
|
+
const update = debounce((value) => (state.value = value), inputOptions.delay ?? 300);
|
|
29
|
+
|
|
30
|
+
watchEffect(() => update(getter()));
|
|
31
|
+
|
|
32
|
+
return state as unknown as ComputedRef<T>;
|
|
33
|
+
}
|
package/src/utils/index.ts
CHANGED
|
@@ -1 +1,10 @@
|
|
|
1
|
+
export * from './app';
|
|
2
|
+
export * from './classes';
|
|
3
|
+
export * from './composition/events';
|
|
4
|
+
export * from './composition/forms';
|
|
5
|
+
export * from './composition/hooks';
|
|
6
|
+
export * from './composition/persistent';
|
|
7
|
+
export * from './composition/state';
|
|
8
|
+
export * from './markdown';
|
|
9
|
+
export * from './types';
|
|
1
10
|
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
|
+
}
|
package/src/utils/markdown.ts
CHANGED
|
@@ -1,12 +1,68 @@
|
|
|
1
1
|
import DOMPurify from 'dompurify';
|
|
2
|
-
import {
|
|
2
|
+
import { stringMatchAll, tap } from '@noeldemartin/utils';
|
|
3
|
+
import { Renderer, marked } from 'marked';
|
|
4
|
+
|
|
5
|
+
let router: MarkdownRouter | null = null;
|
|
6
|
+
|
|
7
|
+
function makeRenderer(): Renderer {
|
|
8
|
+
return tap(new Renderer(), (renderer) => {
|
|
9
|
+
renderer.link = function(link) {
|
|
10
|
+
const defaultLink = Renderer.prototype.link.apply(this, [link]);
|
|
11
|
+
|
|
12
|
+
if (!link.href.startsWith('#')) {
|
|
13
|
+
return defaultLink.replace('<a', '<a target="_blank"');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return defaultLink;
|
|
17
|
+
};
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function renderActionLinks(html: string): string {
|
|
22
|
+
const matches = stringMatchAll<3>(html, /<a[^>]*href="#action:([^"]+)"[^>]*>([^<]+)<\/a>/g);
|
|
23
|
+
|
|
24
|
+
for (const [link, action, text] of matches) {
|
|
25
|
+
html = html.replace(link, `<button type="button" data-markdown-action="${action}">${text}</button>`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return html;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function renderRouteLinks(html: string): string {
|
|
32
|
+
const matches = stringMatchAll<3>(html, /<a[^>]*href="#route:([^"]+)"[^>]*>([^<]+)<\/a>/g);
|
|
33
|
+
|
|
34
|
+
for (const [link, route, text] of matches) {
|
|
35
|
+
const url = router?.resolve(route) ?? route;
|
|
36
|
+
|
|
37
|
+
html = html.replace(link, `<a data-markdown-route="${route}" href="${url}">${text}</a>`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return html;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface MarkdownRouter {
|
|
44
|
+
resolve(route: string): string;
|
|
45
|
+
visit(route: string): Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function getMarkdownRouter(): MarkdownRouter | null {
|
|
49
|
+
return router;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function setMarkdownRouter(markdownRouter: MarkdownRouter): void {
|
|
53
|
+
router = markdownRouter;
|
|
54
|
+
}
|
|
3
55
|
|
|
4
56
|
export function renderMarkdown(markdown: string): string {
|
|
5
|
-
|
|
57
|
+
let html = marked(markdown, { renderer: makeRenderer(), async: false });
|
|
58
|
+
|
|
59
|
+
html = safeHtml(html);
|
|
60
|
+
html = renderActionLinks(html);
|
|
61
|
+
html = renderRouteLinks(html);
|
|
62
|
+
|
|
63
|
+
return html;
|
|
6
64
|
}
|
|
7
65
|
|
|
8
66
|
export function safeHtml(html: string): string {
|
|
9
|
-
// TODO improve target="_blank" exception
|
|
10
|
-
// See https://github.com/cure53/DOMPurify/issues/317
|
|
11
67
|
return DOMPurify.sanitize(html, { ADD_ATTR: ['target'] });
|
|
12
68
|
}
|
package/src/utils/vue.ts
CHANGED
|
@@ -1,50 +1,26 @@
|
|
|
1
|
-
import { fail } from '@noeldemartin/utils';
|
|
2
|
-
import { inject, reactive
|
|
3
|
-
import type { Directive, InjectionKey,
|
|
4
|
-
|
|
5
|
-
type
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
return {
|
|
22
|
-
type: Boolean,
|
|
23
|
-
default: defaultValue,
|
|
24
|
-
};
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function componentRef<T>(): Ref<UnwrapNestedRefs<T> | undefined> {
|
|
28
|
-
return ref<UnwrapNestedRefs<T>>();
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export function defineDirective(directive: Directive): Directive {
|
|
1
|
+
import { fail, toString } from '@noeldemartin/utils';
|
|
2
|
+
import { Comment, Static, Text, inject, reactive } from 'vue';
|
|
3
|
+
import type { Directive, InjectionKey, MaybeRef, Ref, UnwrapNestedRefs, VNode } from 'vue';
|
|
4
|
+
|
|
5
|
+
export type AcceptRefs<T> = { [K in keyof T]: T[K] | RefUnion<T[K]> };
|
|
6
|
+
export type RefUnion<T> = T extends infer R ? Ref<R> : never;
|
|
7
|
+
export type Unref<T> = { [K in keyof T]: T[K] extends MaybeRef<infer Value> ? Value : T[K] };
|
|
8
|
+
|
|
9
|
+
function renderVNodeAttrs(node: VNode): string {
|
|
10
|
+
return Object.entries(node.props ?? {}).reduce((attrs, [name, value]) => {
|
|
11
|
+
return attrs + `${name}="${toString(value)}"`;
|
|
12
|
+
}, '');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
16
|
+
export function defineDirective<TValue = any, TModifiers extends string = string>(
|
|
17
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
18
|
+
directive: Directive<any, TValue, TModifiers>,
|
|
19
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
20
|
+
): Directive<any, TValue, TModifiers> {
|
|
32
21
|
return directive;
|
|
33
22
|
}
|
|
34
23
|
|
|
35
|
-
export function enumProp<Enum extends Record<string, unknown>>(
|
|
36
|
-
enumeration: Enum,
|
|
37
|
-
defaultValue?: Enum[keyof Enum],
|
|
38
|
-
): OptionalProp<Enum[keyof Enum]> {
|
|
39
|
-
const values = Object.values(enumeration) as Enum[keyof Enum][];
|
|
40
|
-
|
|
41
|
-
return {
|
|
42
|
-
type: String as unknown as PropType<Enum[keyof Enum]>,
|
|
43
|
-
default: defaultValue ?? values[0] ?? null,
|
|
44
|
-
validator: (value) => values.includes(value as Enum[keyof Enum]),
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
|
|
48
24
|
export function injectReactive<T extends object>(key: InjectionKey<T> | string): UnwrapNestedRefs<T> | undefined {
|
|
49
25
|
const value = inject(key);
|
|
50
26
|
|
|
@@ -55,90 +31,31 @@ export function injectReactiveOrFail<T extends object>(
|
|
|
55
31
|
key: InjectionKey<T> | string,
|
|
56
32
|
errorMessage?: string,
|
|
57
33
|
): UnwrapNestedRefs<T> {
|
|
58
|
-
return injectReactive(key) ?? fail(errorMessage ?? `Could not resolve '${key}' injection key`);
|
|
34
|
+
return injectReactive(key) ?? fail(errorMessage ?? `Could not resolve '${toString(key)}' injection key`);
|
|
59
35
|
}
|
|
60
36
|
|
|
61
37
|
export function injectOrFail<T>(key: InjectionKey<T> | string, errorMessage?: string): T {
|
|
62
|
-
return inject(key) ?? fail(errorMessage ?? `Could not resolve '${key}' injection key`);
|
|
38
|
+
return inject(key) ?? fail(errorMessage ?? `Could not resolve '${toString(key)}' injection key`);
|
|
63
39
|
}
|
|
64
40
|
|
|
65
|
-
export function
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
};
|
|
70
|
-
}
|
|
41
|
+
export function renderVNode(node: VNode | string): string {
|
|
42
|
+
if (typeof node === 'string') {
|
|
43
|
+
return node;
|
|
44
|
+
}
|
|
71
45
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
return {
|
|
76
|
-
type: Number,
|
|
77
|
-
default: defaultValue,
|
|
78
|
-
};
|
|
79
|
-
}
|
|
46
|
+
if (node.type === Comment) {
|
|
47
|
+
return '';
|
|
48
|
+
}
|
|
80
49
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
return {
|
|
85
|
-
type: Object,
|
|
86
|
-
default: defaultValue,
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export function requiredArrayProp<T>(): RequiredProp<T[]> {
|
|
91
|
-
return {
|
|
92
|
-
type: Array as PropType<T[]>,
|
|
93
|
-
required: true,
|
|
94
|
-
};
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
export function requiredEnumProp<Enum extends Record<string, unknown>>(
|
|
98
|
-
enumeration: Enum,
|
|
99
|
-
): RequiredProp<Enum[keyof Enum]> {
|
|
100
|
-
const values = Object.values(enumeration);
|
|
50
|
+
if (node.type === Text || node.type === Static) {
|
|
51
|
+
return node.children as string;
|
|
52
|
+
}
|
|
101
53
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
validator: (value) => values.includes(value),
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
export function requiredMixedProp<T>(type: PropType<T>): RequiredProp<T> {
|
|
110
|
-
return {
|
|
111
|
-
type,
|
|
112
|
-
required: true,
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export function requiredNumberProp(): RequiredProp<number> {
|
|
117
|
-
return {
|
|
118
|
-
type: Number,
|
|
119
|
-
required: true,
|
|
120
|
-
};
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
export function requiredObjectProp<T = Object>(): RequiredProp<T> {
|
|
124
|
-
return {
|
|
125
|
-
type: Object,
|
|
126
|
-
required: true,
|
|
127
|
-
};
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
export function requiredStringProp(): RequiredProp<string> {
|
|
131
|
-
return {
|
|
132
|
-
type: String,
|
|
133
|
-
required: true,
|
|
134
|
-
};
|
|
135
|
-
}
|
|
54
|
+
if (node.type === 'br') {
|
|
55
|
+
return '\n\n';
|
|
56
|
+
}
|
|
136
57
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
return {
|
|
141
|
-
type: String,
|
|
142
|
-
default: defaultValue,
|
|
143
|
-
};
|
|
58
|
+
return `<${node.type} ${renderVNodeAttrs(node)}>${Array.from(node.children as Array<VNode | string>)
|
|
59
|
+
.map(renderVNode)
|
|
60
|
+
.join('')}</${node.type}>`;
|
|
144
61
|
}
|
package/.eslintrc.js
DELETED