@aerogel/core 0.0.0-next.9a02fcd3bcf698211dd7a71d4c48257c96dd7832 → 0.0.0-next.9f9564ab9f8da05f60d7868db361edbc5601ee39
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 +138 -29
- package/dist/aerogel-core.esm.js +1 -1
- package/dist/aerogel-core.esm.js.map +1 -1
- package/package.json +1 -1
- package/src/components/AGAppSnackbars.vue +1 -1
- package/src/components/forms/AGForm.vue +9 -10
- package/src/components/forms/AGInput.vue +2 -0
- package/src/components/headless/forms/AGHeadlessButton.vue +9 -4
- package/src/components/headless/forms/AGHeadlessInput.ts +7 -3
- package/src/components/headless/forms/AGHeadlessInput.vue +3 -3
- package/src/components/headless/forms/AGHeadlessInputDescription.vue +28 -0
- package/src/components/headless/forms/AGHeadlessInputInput.vue +38 -4
- package/src/components/headless/forms/AGHeadlessInputTextArea.vue +40 -0
- package/src/components/headless/forms/composition.ts +10 -0
- package/src/components/headless/forms/index.ts +3 -0
- package/src/components/lib/AGMarkdown.vue +9 -4
- package/src/directives/index.ts +2 -0
- package/src/directives/measure.ts +11 -2
- package/src/forms/Form.ts +42 -3
- package/src/forms/index.ts +1 -0
- package/src/forms/utils.ts +15 -0
- package/src/lang/Lang.ts +8 -4
- package/src/main.ts +1 -0
- package/src/services/Cache.ts +43 -0
- package/src/services/index.ts +3 -1
- package/src/ui/UI.ts +4 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aerogel/core",
|
|
3
3
|
"description": "The Lightest Solid",
|
|
4
|
-
"version": "0.0.0-next.
|
|
4
|
+
"version": "0.0.0-next.9f9564ab9f8da05f60d7868db361edbc5601ee39",
|
|
5
5
|
"main": "dist/aerogel-core.cjs.js",
|
|
6
6
|
"module": "dist/aerogel-core.esm.js",
|
|
7
7
|
"types": "dist/aerogel-core.d.ts",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div aria-live="assertive" class="
|
|
2
|
+
<div aria-live="assertive" class="pointer-events-none fixed inset-0 z-50 flex items-end px-4 py-6 sm:p-6">
|
|
3
3
|
<div class="flex w-full flex-col items-end space-y-4">
|
|
4
4
|
<component
|
|
5
5
|
:is="snackbar.component"
|
|
@@ -1,26 +1,25 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<form @submit.prevent="submit">
|
|
2
|
+
<form @submit.prevent="form?.submit()">
|
|
3
3
|
<slot />
|
|
4
4
|
</form>
|
|
5
5
|
</template>
|
|
6
6
|
|
|
7
7
|
<script setup lang="ts">
|
|
8
|
-
import { provide } from 'vue';
|
|
8
|
+
import { provide, watchEffect } from 'vue';
|
|
9
9
|
|
|
10
10
|
import { objectProp } from '@/utils/vue';
|
|
11
11
|
import type Form from '@/forms/Form';
|
|
12
12
|
|
|
13
|
+
let offSubmit: (() => void) | undefined;
|
|
13
14
|
const props = defineProps({ form: objectProp<Form>() });
|
|
14
|
-
|
|
15
15
|
const emit = defineEmits<{ submit: [] }>();
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
watchEffect((onCleanup) => {
|
|
18
|
+
offSubmit?.();
|
|
19
|
+
offSubmit = props.form?.on('submit', () => emit('submit'));
|
|
18
20
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
return;
|
|
22
|
-
}
|
|
21
|
+
onCleanup(() => offSubmit?.());
|
|
22
|
+
});
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
}
|
|
24
|
+
provide('form', props.form);
|
|
26
25
|
</script>
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
'ring-1 ring-red-500': $input?.errors,
|
|
14
14
|
}"
|
|
15
15
|
/>
|
|
16
|
+
<AGHeadlessInputDescription />
|
|
16
17
|
<div class="absolute bottom-0 left-0 translate-y-full">
|
|
17
18
|
<AGHeadlessInputError class="mt-1 text-sm text-red-500" />
|
|
18
19
|
</div>
|
|
@@ -26,6 +27,7 @@ import { useInputProps } from '@/components/headless/forms/AGHeadlessInput';
|
|
|
26
27
|
import type { IAGHeadlessInput } from '@/components/headless/forms/AGHeadlessInput';
|
|
27
28
|
|
|
28
29
|
import AGHeadlessInput from '../headless/forms/AGHeadlessInput.vue';
|
|
30
|
+
import AGHeadlessInputDescription from '../headless/forms/AGHeadlessInputDescription.vue';
|
|
29
31
|
import AGHeadlessInputError from '../headless/forms/AGHeadlessInputError.vue';
|
|
30
32
|
import AGHeadlessInputInput from '../headless/forms/AGHeadlessInputInput.vue';
|
|
31
33
|
import AGHeadlessInputLabel from '../headless/forms/AGHeadlessInputLabel.vue';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<component :is="component.
|
|
2
|
+
<component :is="component.as" v-bind="component.props">
|
|
3
3
|
<slot />
|
|
4
4
|
</component>
|
|
5
5
|
</template>
|
|
@@ -11,6 +11,7 @@ import { objectWithoutEmpty } from '@noeldemartin/utils';
|
|
|
11
11
|
import { booleanProp, objectProp, stringProp } from '@/utils/vue';
|
|
12
12
|
|
|
13
13
|
const props = defineProps({
|
|
14
|
+
as: objectProp(),
|
|
14
15
|
href: stringProp(),
|
|
15
16
|
url: stringProp(),
|
|
16
17
|
route: stringProp(),
|
|
@@ -20,9 +21,13 @@ const props = defineProps({
|
|
|
20
21
|
});
|
|
21
22
|
|
|
22
23
|
const component = computed(() => {
|
|
24
|
+
if (props.as) {
|
|
25
|
+
return { as: props.as, props: {} };
|
|
26
|
+
}
|
|
27
|
+
|
|
23
28
|
if (props.route) {
|
|
24
29
|
return {
|
|
25
|
-
|
|
30
|
+
as: 'router-link',
|
|
26
31
|
props: {
|
|
27
32
|
to: objectWithoutEmpty({
|
|
28
33
|
name: props.route,
|
|
@@ -35,7 +40,7 @@ const component = computed(() => {
|
|
|
35
40
|
|
|
36
41
|
if (props.href || props.url) {
|
|
37
42
|
return {
|
|
38
|
-
|
|
43
|
+
as: 'a',
|
|
39
44
|
props: {
|
|
40
45
|
target: '_blank',
|
|
41
46
|
href: props.href || props.url,
|
|
@@ -44,7 +49,7 @@ const component = computed(() => {
|
|
|
44
49
|
}
|
|
45
50
|
|
|
46
51
|
return {
|
|
47
|
-
|
|
52
|
+
as: 'button',
|
|
48
53
|
props: { type: props.submit ? 'submit' : 'button' },
|
|
49
54
|
};
|
|
50
55
|
});
|
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
import type { ComputedRef, DeepReadonly, ExtractPropTypes, Ref } from 'vue';
|
|
2
2
|
|
|
3
|
-
import { stringProp } from '@/utils';
|
|
3
|
+
import { mixedProp, stringProp } from '@/utils';
|
|
4
4
|
import { extractComponentProps } from '@/components/utils';
|
|
5
|
+
import type { FormFieldValue } from '@/forms/Form';
|
|
5
6
|
|
|
6
7
|
export interface IAGHeadlessInput {
|
|
7
8
|
id: string;
|
|
8
9
|
name: ComputedRef<string | null>;
|
|
9
10
|
label: ComputedRef<string | null>;
|
|
10
|
-
|
|
11
|
+
description: ComputedRef<string | boolean | null>;
|
|
12
|
+
value: ComputedRef<FormFieldValue | null>;
|
|
11
13
|
errors: DeepReadonly<Ref<string[] | null>>;
|
|
12
|
-
update(value:
|
|
14
|
+
update(value: FormFieldValue | null): void;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
export const inputProps = {
|
|
16
18
|
name: stringProp(),
|
|
17
19
|
label: stringProp(),
|
|
20
|
+
description: stringProp(),
|
|
21
|
+
modelValue: mixedProp<FormFieldValue>([String, Number, Boolean]),
|
|
18
22
|
};
|
|
19
23
|
|
|
20
24
|
export function useInputProps(): typeof inputProps {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { computed, inject, provide, readonly } from 'vue';
|
|
10
10
|
import { uuid } from '@noeldemartin/utils';
|
|
11
11
|
|
|
12
|
-
import {
|
|
12
|
+
import { stringProp } from '@/utils/vue';
|
|
13
13
|
import type Form from '@/forms/Form';
|
|
14
14
|
|
|
15
15
|
import { useInputProps } from './AGHeadlessInput';
|
|
@@ -18,7 +18,6 @@ import type { IAGHeadlessInput } from './AGHeadlessInput';
|
|
|
18
18
|
const emit = defineEmits(['update:modelValue']);
|
|
19
19
|
const props = defineProps({
|
|
20
20
|
as: stringProp('div'),
|
|
21
|
-
modelValue: mixedProp<string | number | boolean>([String, Number, Boolean]),
|
|
22
21
|
...useInputProps(),
|
|
23
22
|
});
|
|
24
23
|
const errors = computed(() => {
|
|
@@ -33,9 +32,10 @@ const api: IAGHeadlessInput = {
|
|
|
33
32
|
id: `input-${uuid()}`,
|
|
34
33
|
name: computed(() => props.name),
|
|
35
34
|
label: computed(() => props.label),
|
|
35
|
+
description: computed(() => props.description),
|
|
36
36
|
value: computed(() => {
|
|
37
37
|
if (form && props.name) {
|
|
38
|
-
return form.getFieldValue(props.name)
|
|
38
|
+
return form.getFieldValue(props.name);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
return props.modelValue;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<slot :id="`${input.id}-description`">
|
|
3
|
+
<AGMarkdown
|
|
4
|
+
v-if="show"
|
|
5
|
+
v-bind="$attrs"
|
|
6
|
+
:id="`${input.id}-description`"
|
|
7
|
+
:text="text"
|
|
8
|
+
/>
|
|
9
|
+
</slot>
|
|
10
|
+
</template>
|
|
11
|
+
|
|
12
|
+
<script setup lang="ts">
|
|
13
|
+
import { computed } from 'vue';
|
|
14
|
+
|
|
15
|
+
import { injectReactiveOrFail } from '@/utils/vue';
|
|
16
|
+
|
|
17
|
+
import AGMarkdown from '../../lib/AGMarkdown.vue';
|
|
18
|
+
import type { IAGHeadlessInput } from './AGHeadlessInput';
|
|
19
|
+
|
|
20
|
+
defineOptions({ inheritAttrs: false });
|
|
21
|
+
|
|
22
|
+
const input = injectReactiveOrFail<IAGHeadlessInput>(
|
|
23
|
+
'input',
|
|
24
|
+
'<AGHeadlessInputDescription> must be a child of a <AGHeadlessInput>',
|
|
25
|
+
);
|
|
26
|
+
const text = computed(() => (typeof input.description === 'string' ? input.description : ''));
|
|
27
|
+
const show = computed(() => !!input.description);
|
|
28
|
+
</script>
|
|
@@ -4,19 +4,23 @@
|
|
|
4
4
|
ref="$input"
|
|
5
5
|
:name="name"
|
|
6
6
|
:type="type"
|
|
7
|
-
:value="value"
|
|
8
7
|
:aria-invalid="input.errors ? 'true' : 'false'"
|
|
9
|
-
:aria-describedby="
|
|
8
|
+
:aria-describedby="
|
|
9
|
+
input.errors ? `${input.id}-error` : input.description ? `${input.id}-description` : undefined
|
|
10
|
+
"
|
|
10
11
|
:checked="checked"
|
|
11
12
|
@input="update"
|
|
12
13
|
>
|
|
13
14
|
</template>
|
|
14
15
|
|
|
15
16
|
<script setup lang="ts">
|
|
16
|
-
import { computed, ref } from 'vue';
|
|
17
|
+
import { computed, ref, watchEffect } from 'vue';
|
|
17
18
|
|
|
18
19
|
import { injectReactiveOrFail, stringProp } from '@/utils';
|
|
19
20
|
import type { IAGHeadlessInput } from '@/components/headless/forms/AGHeadlessInput';
|
|
21
|
+
import type { FormFieldValue } from '@/forms/Form';
|
|
22
|
+
|
|
23
|
+
import { onFormFocus } from './composition';
|
|
20
24
|
|
|
21
25
|
const props = defineProps({
|
|
22
26
|
type: stringProp('text'),
|
|
@@ -42,6 +46,36 @@ function update() {
|
|
|
42
46
|
return;
|
|
43
47
|
}
|
|
44
48
|
|
|
45
|
-
input.update(
|
|
49
|
+
input.update(getValue());
|
|
46
50
|
}
|
|
51
|
+
|
|
52
|
+
function getValue(): FormFieldValue | null {
|
|
53
|
+
if (!$input.value) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
switch (props.type) {
|
|
58
|
+
case 'checkbox':
|
|
59
|
+
return $input.value.checked;
|
|
60
|
+
case 'date':
|
|
61
|
+
return $input.value.valueAsDate;
|
|
62
|
+
default:
|
|
63
|
+
return $input.value.value;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
onFormFocus(input, () => $input.value?.focus());
|
|
68
|
+
watchEffect(() => {
|
|
69
|
+
if (!$input.value) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (props.type === 'date') {
|
|
74
|
+
$input.value.valueAsDate = value.value as Date;
|
|
75
|
+
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
$input.value.value = value.value as string;
|
|
80
|
+
});
|
|
47
81
|
</script>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<textarea
|
|
3
|
+
:id="input.id"
|
|
4
|
+
ref="$textArea"
|
|
5
|
+
:name="name"
|
|
6
|
+
:value="value"
|
|
7
|
+
:aria-invalid="input.errors ? 'true' : 'false'"
|
|
8
|
+
:aria-describedby="
|
|
9
|
+
input.errors ? `${input.id}-error` : input.description ? `${input.id}-description` : undefined
|
|
10
|
+
"
|
|
11
|
+
@input="update"
|
|
12
|
+
/>
|
|
13
|
+
</template>
|
|
14
|
+
|
|
15
|
+
<script setup lang="ts">
|
|
16
|
+
import { computed, ref } from 'vue';
|
|
17
|
+
|
|
18
|
+
import { injectReactiveOrFail } from '@/utils';
|
|
19
|
+
import type { IAGHeadlessInput } from '@/components/headless/forms/AGHeadlessInput';
|
|
20
|
+
|
|
21
|
+
import { onFormFocus } from './composition';
|
|
22
|
+
|
|
23
|
+
const $textArea = ref<HTMLTextAreaElement>();
|
|
24
|
+
const input = injectReactiveOrFail<IAGHeadlessInput>(
|
|
25
|
+
'input',
|
|
26
|
+
'<AGHeadlessInputTextArea> must be a child of a <AGHeadlessInput>',
|
|
27
|
+
);
|
|
28
|
+
const name = computed(() => input.name ?? undefined);
|
|
29
|
+
const value = computed(() => input.value as string);
|
|
30
|
+
|
|
31
|
+
function update() {
|
|
32
|
+
if (!$textArea.value) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
input.update($textArea.value.value);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
onFormFocus(input, () => $textArea.value?.focus());
|
|
40
|
+
</script>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { inject, onUnmounted } from 'vue';
|
|
2
|
+
|
|
3
|
+
import type Form from '@/forms/Form';
|
|
4
|
+
|
|
5
|
+
export function onFormFocus(input: { name: string | null }, listener: () => unknown): void {
|
|
6
|
+
const form = inject<Form | null>('form', null);
|
|
7
|
+
const stop = form?.on('focus', (name) => input.name === name && listener());
|
|
8
|
+
|
|
9
|
+
onUnmounted(() => stop?.());
|
|
10
|
+
}
|
|
@@ -1,11 +1,14 @@
|
|
|
1
|
+
export * from './composition';
|
|
1
2
|
export * from './AGHeadlessInput';
|
|
2
3
|
export * from './AGHeadlessSelect';
|
|
3
4
|
export * from './AGHeadlessSelectOption';
|
|
4
5
|
export { default as AGHeadlessButton } from './AGHeadlessButton.vue';
|
|
5
6
|
export { default as AGHeadlessInput } from './AGHeadlessInput.vue';
|
|
7
|
+
export { default as AGHeadlessInputDescription } from './AGHeadlessInputDescription.vue';
|
|
6
8
|
export { default as AGHeadlessInputError } from './AGHeadlessInputError.vue';
|
|
7
9
|
export { default as AGHeadlessInputInput } from './AGHeadlessInputInput.vue';
|
|
8
10
|
export { default as AGHeadlessInputLabel } from './AGHeadlessInputLabel.vue';
|
|
11
|
+
export { default as AGHeadlessInputTextArea } from './AGHeadlessInputTextArea.vue';
|
|
9
12
|
export { default as AGHeadlessSelect } from './AGHeadlessSelect.vue';
|
|
10
13
|
export { default as AGHeadlessSelectButton } from './AGHeadlessSelectButton.vue';
|
|
11
14
|
export { default as AGHeadlessSelectError } from './AGHeadlessSelectError.vue';
|
|
@@ -3,20 +3,21 @@
|
|
|
3
3
|
</template>
|
|
4
4
|
|
|
5
5
|
<script setup lang="ts">
|
|
6
|
-
import { computed, h } from 'vue';
|
|
6
|
+
import { computed, h, useAttrs } from 'vue';
|
|
7
7
|
|
|
8
8
|
import { renderMarkdown } from '@/utils/markdown';
|
|
9
|
-
import { booleanProp,
|
|
9
|
+
import { booleanProp, mixedProp, stringProp } from '@/utils/vue';
|
|
10
10
|
import { translate } from '@/lang';
|
|
11
11
|
|
|
12
12
|
const props = defineProps({
|
|
13
13
|
as: stringProp(),
|
|
14
14
|
inline: booleanProp(),
|
|
15
15
|
langKey: stringProp(),
|
|
16
|
-
langParams:
|
|
16
|
+
langParams: mixedProp<number | Record<string, unknown>>(),
|
|
17
17
|
text: stringProp(),
|
|
18
18
|
});
|
|
19
19
|
|
|
20
|
+
const attrs = useAttrs();
|
|
20
21
|
const markdown = computed(() => props.text ?? (props.langKey && translate(props.langKey, props.langParams ?? {})));
|
|
21
22
|
const html = computed(() => {
|
|
22
23
|
if (!markdown.value) {
|
|
@@ -32,5 +33,9 @@ const html = computed(() => {
|
|
|
32
33
|
return renderedHtml;
|
|
33
34
|
});
|
|
34
35
|
const root = () =>
|
|
35
|
-
h(props.as ?? (props.inline ? 'span' : 'div'), {
|
|
36
|
+
h(props.as ?? (props.inline ? 'span' : 'div'), {
|
|
37
|
+
innerHTML: html.value,
|
|
38
|
+
...attrs,
|
|
39
|
+
class: `${attrs.class ?? ''} ${props.inline ? '' : 'prose'}`,
|
|
40
|
+
});
|
|
36
41
|
</script>
|
package/src/directives/index.ts
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
import { defineDirective } from '@/utils/vue';
|
|
2
2
|
|
|
3
|
+
export interface ElementSize {
|
|
4
|
+
width: number;
|
|
5
|
+
height: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type MeasureDirectiveListener = (size: ElementSize) => unknown;
|
|
9
|
+
|
|
3
10
|
export default defineDirective({
|
|
4
|
-
mounted(element: HTMLElement, { value }
|
|
11
|
+
mounted(element: HTMLElement, { value }) {
|
|
12
|
+
const listener = typeof value === 'function' ? (value as MeasureDirectiveListener) : null;
|
|
5
13
|
const sizes = element.getBoundingClientRect();
|
|
6
14
|
|
|
15
|
+
// TODO guard with modifiers.css once typed properly
|
|
7
16
|
element.style.setProperty('--width', `${sizes.width}px`);
|
|
8
17
|
element.style.setProperty('--height', `${sizes.height}px`);
|
|
9
18
|
|
|
10
|
-
|
|
19
|
+
listener?.({ width: sizes.width, height: sizes.height });
|
|
11
20
|
},
|
|
12
21
|
});
|
package/src/forms/Form.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { MagicObject } from '@noeldemartin/utils';
|
|
2
|
-
import { computed, reactive, readonly, ref } from 'vue';
|
|
1
|
+
import { MagicObject, arrayRemove } from '@noeldemartin/utils';
|
|
2
|
+
import { computed, nextTick, reactive, readonly, ref } from 'vue';
|
|
3
3
|
import type { ObjectValues } from '@noeldemartin/utils';
|
|
4
4
|
import type { ComputedRef, DeepReadonly, Ref, UnwrapNestedRefs } from 'vue';
|
|
5
5
|
|
|
@@ -8,6 +8,7 @@ export const FormFieldTypes = {
|
|
|
8
8
|
Number: 'number',
|
|
9
9
|
Boolean: 'boolean',
|
|
10
10
|
Object: 'object',
|
|
11
|
+
Date: 'date',
|
|
11
12
|
} as const;
|
|
12
13
|
|
|
13
14
|
export interface FormFieldDefinition<TType extends FormFieldType = FormFieldType, TRules extends string = string> {
|
|
@@ -40,10 +41,15 @@ export type GetFormFieldValue<TType> = TType extends typeof FormFieldTypes.Strin
|
|
|
40
41
|
? boolean
|
|
41
42
|
: TType extends typeof FormFieldTypes.Object
|
|
42
43
|
? object
|
|
44
|
+
: TType extends typeof FormFieldTypes.Date
|
|
45
|
+
? Date
|
|
43
46
|
: never;
|
|
44
47
|
|
|
45
48
|
const validForms: WeakMap<Form, ComputedRef<boolean>> = new WeakMap();
|
|
46
49
|
|
|
50
|
+
export type SubmitFormListener = () => unknown;
|
|
51
|
+
export type FocusFormListener = (input: string) => unknown;
|
|
52
|
+
|
|
47
53
|
export default class Form<Fields extends FormFieldDefinitions = FormFieldDefinitions> extends MagicObject {
|
|
48
54
|
|
|
49
55
|
public errors: DeepReadonly<UnwrapNestedRefs<FormErrors<Fields>>>;
|
|
@@ -52,6 +58,7 @@ export default class Form<Fields extends FormFieldDefinitions = FormFieldDefinit
|
|
|
52
58
|
private _data: FormData<Fields>;
|
|
53
59
|
private _submitted: Ref<boolean>;
|
|
54
60
|
private _errors: FormErrors<Fields>;
|
|
61
|
+
private _listeners: { focus?: FocusFormListener[]; submit?: SubmitFormListener[] } = {};
|
|
55
62
|
|
|
56
63
|
constructor(fields: Fields) {
|
|
57
64
|
super();
|
|
@@ -89,6 +96,10 @@ export default class Form<Fields extends FormFieldDefinitions = FormFieldDefinit
|
|
|
89
96
|
return this._data[field] as unknown as GetFormFieldValue<Fields[T]['type']>;
|
|
90
97
|
}
|
|
91
98
|
|
|
99
|
+
public data(): FormData<Fields> {
|
|
100
|
+
return { ...this._data };
|
|
101
|
+
}
|
|
102
|
+
|
|
92
103
|
public validate(): boolean {
|
|
93
104
|
const errors = Object.entries(this._fields).reduce((formErrors, [name, definition]) => {
|
|
94
105
|
formErrors[name] = this.getFieldErrors(name, definition);
|
|
@@ -111,7 +122,35 @@ export default class Form<Fields extends FormFieldDefinitions = FormFieldDefinit
|
|
|
111
122
|
public submit(): boolean {
|
|
112
123
|
this._submitted.value = true;
|
|
113
124
|
|
|
114
|
-
|
|
125
|
+
const valid = this.validate();
|
|
126
|
+
|
|
127
|
+
valid && this._listeners['submit']?.forEach((listener) => listener());
|
|
128
|
+
|
|
129
|
+
return valid;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
public on(event: 'focus', listener: FocusFormListener): () => void;
|
|
133
|
+
public on(event: 'submit', listener: SubmitFormListener): () => void;
|
|
134
|
+
public on(event: 'focus' | 'submit', listener: FocusFormListener | SubmitFormListener): () => void {
|
|
135
|
+
this._listeners[event] ??= [];
|
|
136
|
+
|
|
137
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
138
|
+
this._listeners[event]?.push(listener as any);
|
|
139
|
+
|
|
140
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
141
|
+
return () => this.off(event as any, listener);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
public off(event: 'focus', listener: FocusFormListener): void;
|
|
145
|
+
public off(event: 'submit', listener: SubmitFormListener): void;
|
|
146
|
+
public off(event: 'focus' | 'submit', listener: FocusFormListener | SubmitFormListener): void {
|
|
147
|
+
arrayRemove(this._listeners[event] ?? [], listener);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
public async focus(input: string): Promise<void> {
|
|
151
|
+
await nextTick();
|
|
152
|
+
|
|
153
|
+
this._listeners['focus']?.forEach((listener) => listener(input));
|
|
115
154
|
}
|
|
116
155
|
|
|
117
156
|
protected __get(property: string): unknown {
|
package/src/forms/index.ts
CHANGED
package/src/forms/utils.ts
CHANGED
|
@@ -8,6 +8,13 @@ export function booleanInput(defaultValue?: boolean): FormFieldDefinition<typeof
|
|
|
8
8
|
};
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
export function dateInput(defaultValue?: Date): FormFieldDefinition<typeof FormFieldTypes.Date> {
|
|
12
|
+
return {
|
|
13
|
+
default: defaultValue,
|
|
14
|
+
type: FormFieldTypes.Date,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
11
18
|
export function requiredBooleanInput(
|
|
12
19
|
defaultValue?: boolean,
|
|
13
20
|
): FormFieldDefinition<typeof FormFieldTypes.Boolean, 'required'> {
|
|
@@ -18,6 +25,14 @@ export function requiredBooleanInput(
|
|
|
18
25
|
};
|
|
19
26
|
}
|
|
20
27
|
|
|
28
|
+
export function requiredDateInput(defaultValue?: Date): FormFieldDefinition<typeof FormFieldTypes.Date> {
|
|
29
|
+
return {
|
|
30
|
+
default: defaultValue,
|
|
31
|
+
type: FormFieldTypes.Date,
|
|
32
|
+
rules: 'required',
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
21
36
|
export function requiredNumberInput(
|
|
22
37
|
defaultValue?: number,
|
|
23
38
|
): FormFieldDefinition<typeof FormFieldTypes.Number, 'required'> {
|
package/src/lang/Lang.ts
CHANGED
|
@@ -4,8 +4,8 @@ import App from '@/services/App';
|
|
|
4
4
|
import Service from '@/services/Service';
|
|
5
5
|
|
|
6
6
|
export interface LangProvider {
|
|
7
|
-
translate(key: string, parameters?: Record<string, unknown>): string;
|
|
8
|
-
translateWithDefault(key: string, defaultMessage: string, parameters?: Record<string, unknown>): string;
|
|
7
|
+
translate(key: string, parameters?: Record<string, unknown> | number): string;
|
|
8
|
+
translateWithDefault(key: string, defaultMessage: string, parameters?: Record<string, unknown> | number): string;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
export class LangService extends Service {
|
|
@@ -35,11 +35,15 @@ export class LangService extends Service {
|
|
|
35
35
|
this.provider = provider;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
public translate(key: string, parameters?: Record<string, unknown>): string {
|
|
38
|
+
public translate(key: string, parameters?: Record<string, unknown> | number): string {
|
|
39
39
|
return this.provider.translate(key, parameters) ?? key;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
public translateWithDefault(
|
|
42
|
+
public translateWithDefault(
|
|
43
|
+
key: string,
|
|
44
|
+
defaultMessage: string,
|
|
45
|
+
parameters: Record<string, unknown> | number = {},
|
|
46
|
+
): string {
|
|
43
47
|
return this.provider.translateWithDefault(key, defaultMessage, parameters);
|
|
44
48
|
}
|
|
45
49
|
|
package/src/main.ts
CHANGED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { PromisedValue, facade, tap } from '@noeldemartin/utils';
|
|
2
|
+
|
|
3
|
+
import Service from '@/services/Service';
|
|
4
|
+
|
|
5
|
+
export class CacheService extends Service {
|
|
6
|
+
|
|
7
|
+
private cache?: PromisedValue<Cache> = undefined;
|
|
8
|
+
|
|
9
|
+
public async get(url: string): Promise<Response | null> {
|
|
10
|
+
const cache = await this.open();
|
|
11
|
+
const response = await cache.match(url);
|
|
12
|
+
|
|
13
|
+
return response ?? null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
public async store(url: string, response: Response): Promise<void> {
|
|
17
|
+
const cache = await this.open();
|
|
18
|
+
|
|
19
|
+
await cache.put(url, response);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
public async replace(url: string, response: Response): Promise<void> {
|
|
23
|
+
const cache = await this.open();
|
|
24
|
+
const keys = await cache.keys(url);
|
|
25
|
+
|
|
26
|
+
if (keys.length === 0) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
await cache.put(url, response);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
protected async open(): Promise<Cache> {
|
|
34
|
+
return (this.cache =
|
|
35
|
+
this.cache ??
|
|
36
|
+
tap(new PromisedValue<Cache>(), (cache) => {
|
|
37
|
+
caches.open('app').then((instance) => cache.resolve(instance));
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export default facade(CacheService);
|
package/src/services/index.ts
CHANGED
|
@@ -3,16 +3,18 @@ import type { App as VueApp } from 'vue';
|
|
|
3
3
|
import { definePlugin } from '@/plugins';
|
|
4
4
|
|
|
5
5
|
import App from './App';
|
|
6
|
+
import Cache from './Cache';
|
|
6
7
|
import Events from './Events';
|
|
7
8
|
import Service from './Service';
|
|
8
9
|
import { getPiniaStore } from './store';
|
|
9
10
|
|
|
10
11
|
export * from './App';
|
|
12
|
+
export * from './Cache';
|
|
11
13
|
export * from './Events';
|
|
12
14
|
export * from './Service';
|
|
13
15
|
export * from './store';
|
|
14
16
|
|
|
15
|
-
export { App, Events, Service };
|
|
17
|
+
export { App, Cache, Events, Service };
|
|
16
18
|
|
|
17
19
|
const defaultServices = {
|
|
18
20
|
$app: App,
|
package/src/ui/UI.ts
CHANGED
|
@@ -43,6 +43,7 @@ export interface PromptOptions {
|
|
|
43
43
|
placeholder?: string;
|
|
44
44
|
acceptText?: string;
|
|
45
45
|
cancelText?: string;
|
|
46
|
+
trim?: boolean;
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
export interface ShowSnackbarOptions {
|
|
@@ -115,6 +116,7 @@ export class UIService extends Service {
|
|
|
115
116
|
messageOrOptions?: string | PromptOptions,
|
|
116
117
|
options?: PromptOptions,
|
|
117
118
|
): Promise<string | null> {
|
|
119
|
+
const trim = options?.trim ?? true;
|
|
118
120
|
const getProperties = (): AGPromptModalProps => {
|
|
119
121
|
if (typeof messageOrOptions !== 'string') {
|
|
120
122
|
return {
|
|
@@ -134,7 +136,8 @@ export class UIService extends Service {
|
|
|
134
136
|
this.requireComponent(UIComponents.PromptModal),
|
|
135
137
|
getProperties(),
|
|
136
138
|
);
|
|
137
|
-
const
|
|
139
|
+
const rawResult = await modal.beforeClose;
|
|
140
|
+
const result = trim ? rawResult?.trim() : rawResult;
|
|
138
141
|
|
|
139
142
|
return result ?? null;
|
|
140
143
|
}
|