@aerogel/core 0.0.0-next.d34923f3b144e8f6720e6a9cdadb2cd4fb4ab289 → 0.0.0-next.d7394c3aa6aac799b0971e63819a8713d05a5123
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 +67 -10
- package/dist/aerogel-core.esm.js +1 -1
- package/dist/aerogel-core.esm.js.map +1 -1
- package/package.json +1 -1
- package/src/bootstrap/bootstrap.test.ts +0 -1
- package/src/components/composition.ts +23 -0
- package/src/components/headless/forms/AGHeadlessButton.ts +3 -0
- package/src/components/headless/forms/AGHeadlessButton.vue +7 -1
- package/src/components/headless/forms/AGHeadlessInput.ts +1 -0
- package/src/components/headless/forms/AGHeadlessInput.vue +10 -4
- package/src/components/headless/forms/AGHeadlessInputInput.vue +3 -2
- package/src/components/headless/forms/AGHeadlessInputTextArea.vue +3 -2
- package/src/components/headless/forms/index.ts +1 -0
- package/src/components/index.ts +1 -0
- package/src/components/interfaces.ts +17 -2
- package/src/components/lib/AGMeasured.vue +1 -0
- package/src/directives/measure.ts +24 -5
- package/src/forms/Form.test.ts +28 -0
- package/src/forms/Form.ts +15 -4
- package/src/lang/DefaultLangProvider.ts +43 -0
- package/src/lang/Lang.state.ts +11 -0
- package/src/lang/Lang.ts +36 -17
- package/src/services/Service.ts +10 -2
- package/src/testing/setup.ts +25 -0
- package/src/ui/UI.state.ts +7 -0
- package/src/ui/UI.ts +16 -4
- package/src/ui/index.ts +1 -0
- package/src/ui/utils.ts +16 -0
- package/src/utils/vue.ts +4 -2
- package/vite.config.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.d7394c3aa6aac799b0971e63819a8713d05a5123",
|
|
5
5
|
"main": "dist/aerogel-core.cjs.js",
|
|
6
6
|
"module": "dist/aerogel-core.esm.js",
|
|
7
7
|
"types": "dist/aerogel-core.d.ts",
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { customRef } from 'vue';
|
|
2
|
+
import type { Ref } from 'vue';
|
|
3
|
+
|
|
4
|
+
import { getElement } from '@/components/interfaces';
|
|
5
|
+
|
|
6
|
+
export function elementRef(): Ref<HTMLElement | undefined> {
|
|
7
|
+
return customRef((track, trigger) => {
|
|
8
|
+
let value: HTMLElement | undefined = undefined;
|
|
9
|
+
|
|
10
|
+
return {
|
|
11
|
+
get() {
|
|
12
|
+
track();
|
|
13
|
+
|
|
14
|
+
return value;
|
|
15
|
+
},
|
|
16
|
+
set(newValue) {
|
|
17
|
+
value = getElement(newValue);
|
|
18
|
+
|
|
19
|
+
trigger();
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<component :is="component.as" v-bind="component.props">
|
|
2
|
+
<component :is="component.as" ref="$root" v-bind="component.props">
|
|
3
3
|
<slot />
|
|
4
4
|
</component>
|
|
5
5
|
</template>
|
|
@@ -9,6 +9,9 @@ import { computed } from 'vue';
|
|
|
9
9
|
import { objectWithoutEmpty } from '@noeldemartin/utils';
|
|
10
10
|
|
|
11
11
|
import { booleanProp, objectProp, stringProp } from '@/utils/vue';
|
|
12
|
+
import { elementRef } from '@/components/composition';
|
|
13
|
+
|
|
14
|
+
import type { IAGHeadlessButton } from './AGHeadlessButton';
|
|
12
15
|
|
|
13
16
|
const props = defineProps({
|
|
14
17
|
as: objectProp(),
|
|
@@ -20,6 +23,7 @@ const props = defineProps({
|
|
|
20
23
|
submit: booleanProp(),
|
|
21
24
|
});
|
|
22
25
|
|
|
26
|
+
const $root = elementRef();
|
|
23
27
|
const component = computed(() => {
|
|
24
28
|
if (props.as) {
|
|
25
29
|
return { as: props.as, props: {} };
|
|
@@ -53,4 +57,6 @@ const component = computed(() => {
|
|
|
53
57
|
props: { type: props.submit ? 'submit' : 'button' },
|
|
54
58
|
};
|
|
55
59
|
});
|
|
60
|
+
|
|
61
|
+
defineExpose<IAGHeadlessButton>({ $el: $root });
|
|
56
62
|
</script>
|
|
@@ -11,6 +11,7 @@ export interface IAGHeadlessInput extends HasElement {
|
|
|
11
11
|
label: ComputedRef<string | null>;
|
|
12
12
|
description: ComputedRef<string | boolean | null>;
|
|
13
13
|
value: ComputedRef<FormFieldValue | null>;
|
|
14
|
+
required: ComputedRef<boolean | null>;
|
|
14
15
|
errors: DeepReadonly<Ref<string[] | null>>;
|
|
15
16
|
update(value: FormFieldValue | null): void;
|
|
16
17
|
}
|
|
@@ -8,11 +8,10 @@
|
|
|
8
8
|
<script setup lang="ts">
|
|
9
9
|
import { computed, inject, provide, readonly, ref } from 'vue';
|
|
10
10
|
import { uuid } from '@noeldemartin/utils';
|
|
11
|
-
import type { Ref } from 'vue';
|
|
12
11
|
|
|
13
12
|
import { stringProp } from '@/utils/vue';
|
|
14
13
|
import type Form from '@/forms/Form';
|
|
15
|
-
import type {
|
|
14
|
+
import type { __SetsElement } from '@/components/interfaces';
|
|
16
15
|
|
|
17
16
|
import { useInputProps } from './AGHeadlessInput';
|
|
18
17
|
import type { IAGHeadlessInput } from './AGHeadlessInput';
|
|
@@ -31,8 +30,8 @@ const errors = computed(() => {
|
|
|
31
30
|
return form.errors[props.name] ?? null;
|
|
32
31
|
});
|
|
33
32
|
const form = inject<Form | null>('form', null);
|
|
34
|
-
const api: IAGHeadlessInput &
|
|
35
|
-
$el
|
|
33
|
+
const api: IAGHeadlessInput & __SetsElement = {
|
|
34
|
+
$el,
|
|
36
35
|
id: `input-${uuid()}`,
|
|
37
36
|
name: computed(() => props.name),
|
|
38
37
|
label: computed(() => props.label),
|
|
@@ -45,6 +44,13 @@ const api: IAGHeadlessInput & __HasElement = {
|
|
|
45
44
|
return props.modelValue;
|
|
46
45
|
}),
|
|
47
46
|
errors: readonly(errors),
|
|
47
|
+
required: computed(() => {
|
|
48
|
+
if (!props.name || !form) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return form.getFieldRules(props.name).includes('required');
|
|
53
|
+
}),
|
|
48
54
|
update(value) {
|
|
49
55
|
if (form && props.name) {
|
|
50
56
|
form.setFieldValue(props.name, value);
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
ref="$input"
|
|
5
5
|
:name="name"
|
|
6
6
|
:type="type"
|
|
7
|
+
:required="input.required ?? undefined"
|
|
7
8
|
:aria-invalid="input.errors ? 'true' : 'false'"
|
|
8
9
|
:aria-describedby="
|
|
9
10
|
input.errors ? `${input.id}-error` : input.description ? `${input.id}-description` : undefined
|
|
@@ -17,7 +18,7 @@
|
|
|
17
18
|
import { computed, ref, watchEffect } from 'vue';
|
|
18
19
|
|
|
19
20
|
import { injectReactiveOrFail, stringProp } from '@/utils/vue';
|
|
20
|
-
import type {
|
|
21
|
+
import type { __SetsElement } from '@/components/interfaces';
|
|
21
22
|
import type { FormFieldValue } from '@/forms/Form';
|
|
22
23
|
import type { IAGHeadlessInput } from '@/components/headless/forms/AGHeadlessInput';
|
|
23
24
|
|
|
@@ -66,7 +67,7 @@ function getValue(): FormFieldValue | null {
|
|
|
66
67
|
}
|
|
67
68
|
|
|
68
69
|
onFormFocus(input, () => $input.value?.focus());
|
|
69
|
-
watchEffect(() => (input as unknown as
|
|
70
|
+
watchEffect(() => (input as unknown as __SetsElement).__setElement($input.value));
|
|
70
71
|
watchEffect(() => {
|
|
71
72
|
if (!$input.value) {
|
|
72
73
|
return;
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
:id="input.id"
|
|
4
4
|
ref="$textArea"
|
|
5
5
|
:name="name"
|
|
6
|
+
:required="input.required ?? undefined"
|
|
6
7
|
:value="value"
|
|
7
8
|
:aria-invalid="input.errors ? 'true' : 'false'"
|
|
8
9
|
:aria-describedby="
|
|
@@ -17,7 +18,7 @@ import { computed, ref, watchEffect } from 'vue';
|
|
|
17
18
|
|
|
18
19
|
import { injectReactiveOrFail } from '@/utils/vue';
|
|
19
20
|
import type { IAGHeadlessInput } from '@/components/headless/forms/AGHeadlessInput';
|
|
20
|
-
import type {
|
|
21
|
+
import type { __SetsElement } from '@/components/interfaces';
|
|
21
22
|
|
|
22
23
|
import { onFormFocus } from './composition';
|
|
23
24
|
|
|
@@ -37,6 +38,6 @@ function update() {
|
|
|
37
38
|
input.update($textArea.value.value);
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
watchEffect(() => (input as unknown as
|
|
41
|
+
watchEffect(() => (input as unknown as __SetsElement).__setElement($textArea.value));
|
|
41
42
|
onFormFocus(input, () => $textArea.value?.focus());
|
|
42
43
|
</script>
|
package/src/components/index.ts
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { isObject } from '@noeldemartin/utils';
|
|
2
|
+
import type { Ref, UnwrapNestedRefs } from 'vue';
|
|
2
3
|
|
|
3
|
-
export
|
|
4
|
+
export function getElement(value: unknown): HTMLElement | undefined {
|
|
5
|
+
if (value instanceof HTMLElement) {
|
|
6
|
+
return value;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
if (hasElement(value)) {
|
|
10
|
+
return value.$el;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function hasElement(value: unknown): value is UnwrapNestedRefs<HasElement> {
|
|
15
|
+
return isObject(value) && '$el' in value;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface __SetsElement {
|
|
4
19
|
__setElement(element?: HTMLElement): void;
|
|
5
20
|
}
|
|
6
21
|
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { defineDirective } from '@/utils/vue';
|
|
2
|
+
import { tap } from '@noeldemartin/utils';
|
|
3
|
+
|
|
4
|
+
const resizeObservers: WeakMap<HTMLElement, ResizeObserver> = new WeakMap();
|
|
2
5
|
|
|
3
6
|
export interface ElementSize {
|
|
4
7
|
width: number;
|
|
@@ -9,13 +12,29 @@ export type MeasureDirectiveListener = (size: ElementSize) => unknown;
|
|
|
9
12
|
|
|
10
13
|
export default defineDirective({
|
|
11
14
|
mounted(element: HTMLElement, { value }) {
|
|
15
|
+
// TODO replace with argument when typed properly
|
|
16
|
+
const modifiers = { css: true, watch: true };
|
|
17
|
+
|
|
12
18
|
const listener = typeof value === 'function' ? (value as MeasureDirectiveListener) : null;
|
|
13
|
-
const
|
|
19
|
+
const update = () => {
|
|
20
|
+
const sizes = element.getBoundingClientRect();
|
|
21
|
+
|
|
22
|
+
if (modifiers.css) {
|
|
23
|
+
element.style.setProperty('--width', `${sizes.width}px`);
|
|
24
|
+
element.style.setProperty('--height', `${sizes.height}px`);
|
|
25
|
+
}
|
|
14
26
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
element.style.setProperty('--height', `${sizes.height}px`);
|
|
27
|
+
listener?.({ width: sizes.width, height: sizes.height });
|
|
28
|
+
};
|
|
18
29
|
|
|
19
|
-
|
|
30
|
+
if (modifiers.watch) {
|
|
31
|
+
resizeObservers.set(element, tap(new ResizeObserver(update)).observe(element));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
update();
|
|
35
|
+
},
|
|
36
|
+
unmounted(element) {
|
|
37
|
+
resizeObservers.get(element)?.unobserve(element);
|
|
38
|
+
resizeObservers.delete(element);
|
|
20
39
|
},
|
|
21
40
|
});
|
package/src/forms/Form.test.ts
CHANGED
|
@@ -55,4 +55,32 @@ describe('Form', () => {
|
|
|
55
55
|
expect(form.name).toBeNull();
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
+
it('trims values', () => {
|
|
59
|
+
// Arrange
|
|
60
|
+
const form = useForm({
|
|
61
|
+
trimmed: {
|
|
62
|
+
type: FormFieldTypes.String,
|
|
63
|
+
rules: 'required',
|
|
64
|
+
},
|
|
65
|
+
untrimmed: {
|
|
66
|
+
type: FormFieldTypes.String,
|
|
67
|
+
rules: 'required',
|
|
68
|
+
trim: false,
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Act
|
|
73
|
+
form.trimmed = ' ';
|
|
74
|
+
form.untrimmed = ' ';
|
|
75
|
+
|
|
76
|
+
form.submit();
|
|
77
|
+
|
|
78
|
+
// Assert
|
|
79
|
+
expect(form.valid).toBe(false);
|
|
80
|
+
expect(form.submitted).toBe(true);
|
|
81
|
+
expect(form.trimmed).toEqual('');
|
|
82
|
+
expect(form.untrimmed).toEqual(' ');
|
|
83
|
+
expect(form.errors).toEqual({ trimmed: ['required'], untrimmed: null });
|
|
84
|
+
});
|
|
85
|
+
|
|
58
86
|
});
|
package/src/forms/Form.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { MagicObject, arrayRemove } from '@noeldemartin/utils';
|
|
1
|
+
import { MagicObject, arrayRemove, fail, toString } from '@noeldemartin/utils';
|
|
2
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';
|
|
@@ -13,6 +13,7 @@ export const FormFieldTypes = {
|
|
|
13
13
|
|
|
14
14
|
export interface FormFieldDefinition<TType extends FormFieldType = FormFieldType, TRules extends string = string> {
|
|
15
15
|
type: TType;
|
|
16
|
+
trim?: boolean;
|
|
16
17
|
default?: GetFormFieldValue<TType>;
|
|
17
18
|
rules?: TRules;
|
|
18
19
|
}
|
|
@@ -85,7 +86,13 @@ export default class Form<Fields extends FormFieldDefinitions = FormFieldDefinit
|
|
|
85
86
|
}
|
|
86
87
|
|
|
87
88
|
public setFieldValue<T extends keyof Fields>(field: T, value: FormData<Fields>[T]): void {
|
|
88
|
-
|
|
89
|
+
const definition =
|
|
90
|
+
this._fields[field] ?? fail<FormFieldDefinition>(`Trying to set undefined '${toString(field)}' field`);
|
|
91
|
+
|
|
92
|
+
this._data[field] =
|
|
93
|
+
definition.type === FormFieldTypes.String && (definition.trim ?? true)
|
|
94
|
+
? (toString(value).trim() as FormData<Fields>[T])
|
|
95
|
+
: value;
|
|
89
96
|
|
|
90
97
|
if (this._submitted.value) {
|
|
91
98
|
this.validate();
|
|
@@ -96,6 +103,10 @@ export default class Form<Fields extends FormFieldDefinitions = FormFieldDefinit
|
|
|
96
103
|
return this._data[field] as unknown as GetFormFieldValue<Fields[T]['type']>;
|
|
97
104
|
}
|
|
98
105
|
|
|
106
|
+
public getFieldRules<T extends keyof Fields>(field: T): string[] {
|
|
107
|
+
return this._fields[field]?.rules?.split('|') ?? [];
|
|
108
|
+
}
|
|
109
|
+
|
|
99
110
|
public data(): FormData<Fields> {
|
|
100
111
|
return { ...this._data };
|
|
101
112
|
}
|
|
@@ -158,7 +169,7 @@ export default class Form<Fields extends FormFieldDefinitions = FormFieldDefinit
|
|
|
158
169
|
return super.__get(property);
|
|
159
170
|
}
|
|
160
171
|
|
|
161
|
-
return this.
|
|
172
|
+
return this.getFieldValue(property);
|
|
162
173
|
}
|
|
163
174
|
|
|
164
175
|
protected __set(property: string, value: unknown): void {
|
|
@@ -168,7 +179,7 @@ export default class Form<Fields extends FormFieldDefinitions = FormFieldDefinit
|
|
|
168
179
|
return;
|
|
169
180
|
}
|
|
170
181
|
|
|
171
|
-
|
|
182
|
+
this.setFieldValue(property, value as FormData<Fields>[string]);
|
|
172
183
|
}
|
|
173
184
|
|
|
174
185
|
private getFieldErrors(name: keyof Fields, definition: FormFieldDefinition): string[] | null {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import App from '@/services/App';
|
|
2
|
+
|
|
3
|
+
import type { LangProvider } from './Lang';
|
|
4
|
+
|
|
5
|
+
export default class DefaultLangProvider implements LangProvider {
|
|
6
|
+
|
|
7
|
+
constructor(private locale: string, private fallbackLocale: string) {}
|
|
8
|
+
|
|
9
|
+
public getLocale(): string {
|
|
10
|
+
return this.locale;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
public async setLocale(locale: string): Promise<void> {
|
|
14
|
+
this.locale = locale;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
public getFallbackLocale(): string {
|
|
18
|
+
return this.fallbackLocale;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
public async setFallbackLocale(fallbackLocale: string): Promise<void> {
|
|
22
|
+
this.fallbackLocale = fallbackLocale;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
public getLocales(): string[] {
|
|
26
|
+
return ['en'];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
public translate(key: string): string {
|
|
30
|
+
// eslint-disable-next-line no-console
|
|
31
|
+
App.development && console.warn('Lang provider is missing');
|
|
32
|
+
|
|
33
|
+
return key;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
public translateWithDefault(_: string, defaultMessage: string): string {
|
|
37
|
+
// eslint-disable-next-line no-console
|
|
38
|
+
App.development && console.warn('Lang provider is missing');
|
|
39
|
+
|
|
40
|
+
return defaultMessage;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { defineServiceState } from '@/services/Service';
|
|
2
|
+
|
|
3
|
+
export default defineServiceState({
|
|
4
|
+
name: 'lang',
|
|
5
|
+
persist: ['locale', 'fallbackLocale'],
|
|
6
|
+
initialState: {
|
|
7
|
+
locale: null as string | null,
|
|
8
|
+
locales: ['en'],
|
|
9
|
+
fallbackLocale: 'en',
|
|
10
|
+
},
|
|
11
|
+
});
|
package/src/lang/Lang.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { facade } from '@noeldemartin/utils';
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
import Service from '
|
|
3
|
+
import DefaultLangProvider from './DefaultLangProvider';
|
|
4
|
+
import Service from './Lang.state';
|
|
5
5
|
|
|
6
6
|
export interface LangProvider {
|
|
7
|
+
getLocale(): string;
|
|
8
|
+
setLocale(locale: string): Promise<void>;
|
|
9
|
+
getFallbackLocale(): string;
|
|
10
|
+
setFallbackLocale(fallbackLocale: string): Promise<void>;
|
|
11
|
+
getLocales(): string[];
|
|
7
12
|
translate(key: string, parameters?: Record<string, unknown> | number): string;
|
|
8
13
|
translateWithDefault(key: string, defaultMessage: string, parameters?: Record<string, unknown> | number): string;
|
|
9
14
|
}
|
|
@@ -15,24 +20,18 @@ export class LangService extends Service {
|
|
|
15
20
|
constructor() {
|
|
16
21
|
super();
|
|
17
22
|
|
|
18
|
-
this.provider =
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
return key;
|
|
24
|
-
},
|
|
25
|
-
translateWithDefault: (_, defaultMessage) => {
|
|
26
|
-
// eslint-disable-next-line no-console
|
|
27
|
-
App.development && console.warn('Lang provider is missing');
|
|
28
|
-
|
|
29
|
-
return defaultMessage;
|
|
30
|
-
},
|
|
31
|
-
};
|
|
23
|
+
this.provider = new DefaultLangProvider(
|
|
24
|
+
this.getState('locale') ?? this.getBrowserLocale(),
|
|
25
|
+
this.getState('fallbackLocale'),
|
|
26
|
+
);
|
|
32
27
|
}
|
|
33
28
|
|
|
34
|
-
public setProvider(provider: LangProvider): void {
|
|
29
|
+
public async setProvider(provider: LangProvider): Promise<void> {
|
|
35
30
|
this.provider = provider;
|
|
31
|
+
this.locales = provider.getLocales();
|
|
32
|
+
|
|
33
|
+
await provider.setLocale(this.locale ?? this.getBrowserLocale());
|
|
34
|
+
await provider.setFallbackLocale(this.fallbackLocale);
|
|
36
35
|
}
|
|
37
36
|
|
|
38
37
|
public translate(key: string, parameters?: Record<string, unknown> | number): string {
|
|
@@ -47,6 +46,26 @@ export class LangService extends Service {
|
|
|
47
46
|
return this.provider.translateWithDefault(key, defaultMessage, parameters);
|
|
48
47
|
}
|
|
49
48
|
|
|
49
|
+
public getBrowserLocale(): string {
|
|
50
|
+
const locales = this.getState('locales');
|
|
51
|
+
|
|
52
|
+
return navigator.languages.find((locale) => locales.includes(locale)) ?? 'en';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
protected async boot(): Promise<void> {
|
|
56
|
+
this.requireStore().$subscribe(
|
|
57
|
+
async () => {
|
|
58
|
+
await this.provider.setLocale(this.locale ?? this.getBrowserLocale());
|
|
59
|
+
await this.provider.setFallbackLocale(this.fallbackLocale);
|
|
60
|
+
|
|
61
|
+
this.locale
|
|
62
|
+
? document.querySelector('html')?.setAttribute('lang', this.locale)
|
|
63
|
+
: document.querySelector('html')?.removeAttribute('lang');
|
|
64
|
+
},
|
|
65
|
+
{ immediate: true },
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
50
69
|
}
|
|
51
70
|
|
|
52
71
|
export default facade(LangService);
|
package/src/services/Service.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { MagicObject, PromisedValue, Storage, isEmpty, objectDeepClone, objectOnly } from '@noeldemartin/utils';
|
|
1
|
+
import { MagicObject, PromisedValue, Storage, fail, isEmpty, objectDeepClone, objectOnly } from '@noeldemartin/utils';
|
|
2
2
|
import type { Constructor } from '@noeldemartin/utils';
|
|
3
3
|
import type { MaybeRef } from 'vue';
|
|
4
4
|
import type { Store } from 'pinia';
|
|
@@ -91,7 +91,7 @@ export default class Service<
|
|
|
91
91
|
protected _name: string;
|
|
92
92
|
private _booted: PromisedValue<void>;
|
|
93
93
|
private _computedStateKeys: Set<keyof State>;
|
|
94
|
-
private _store: Store | false;
|
|
94
|
+
private _store: Store<string, State, ComputedState, {}> | false;
|
|
95
95
|
|
|
96
96
|
constructor() {
|
|
97
97
|
super();
|
|
@@ -250,4 +250,12 @@ export default class Service<
|
|
|
250
250
|
Storage.set(this._name, objectOnly(this.getState(), persist));
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
+
protected requireStore(): Store<string, State, ComputedState, {}> {
|
|
254
|
+
if (!this._store) {
|
|
255
|
+
return fail(`Failed getting '${this._name}' store`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return this._store;
|
|
259
|
+
}
|
|
260
|
+
|
|
253
261
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { mock, tap } from '@noeldemartin/utils';
|
|
2
|
+
import { beforeEach, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
5
|
+
tap(globalThis, (global: any) => {
|
|
6
|
+
global.jest = vi;
|
|
7
|
+
global.navigator = { languages: ['en'] };
|
|
8
|
+
global.window = mock<Window>({
|
|
9
|
+
matchMedia: () =>
|
|
10
|
+
mock<MediaQueryList>({
|
|
11
|
+
addEventListener: () => null,
|
|
12
|
+
}),
|
|
13
|
+
});
|
|
14
|
+
global.localStorage = mock<Storage>({
|
|
15
|
+
getItem: () => null,
|
|
16
|
+
setItem: () => null,
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
vi.stubGlobal('document', {
|
|
22
|
+
querySelector: () => null,
|
|
23
|
+
getElementById: () => null,
|
|
24
|
+
});
|
|
25
|
+
});
|
package/src/ui/UI.state.ts
CHANGED
|
@@ -2,6 +2,8 @@ import type { Component } from 'vue';
|
|
|
2
2
|
|
|
3
3
|
import { defineServiceState } from '@/services/Service';
|
|
4
4
|
|
|
5
|
+
import { Layouts, getCurrentLayout } from './utils';
|
|
6
|
+
|
|
5
7
|
export interface Modal<T = unknown> {
|
|
6
8
|
id: string;
|
|
7
9
|
properties: Record<string, unknown>;
|
|
@@ -28,5 +30,10 @@ export default defineServiceState({
|
|
|
28
30
|
initialState: {
|
|
29
31
|
modals: [] as Modal[],
|
|
30
32
|
snackbars: [] as Snackbar[],
|
|
33
|
+
layout: getCurrentLayout(),
|
|
34
|
+
},
|
|
35
|
+
computed: {
|
|
36
|
+
mobile: ({ layout }) => layout === Layouts.Mobile,
|
|
37
|
+
desktop: ({ layout }) => layout === Layouts.Desktop,
|
|
31
38
|
},
|
|
32
39
|
});
|
package/src/ui/UI.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type { SnackbarAction, SnackbarColor } from '@/components/headless/snackb
|
|
|
9
9
|
import type { AGAlertModalProps, AGConfirmModalProps, AGLoadingModalProps, AGPromptModalProps } from '@/components';
|
|
10
10
|
|
|
11
11
|
import Service from './UI.state';
|
|
12
|
+
import { MOBILE_BREAKPOINT, getCurrentLayout } from './utils';
|
|
12
13
|
import type { Modal, ModalComponent, Snackbar } from './UI.state';
|
|
13
14
|
|
|
14
15
|
interface ModalCallbacks<T = unknown> {
|
|
@@ -147,9 +148,12 @@ export class UIService extends Service {
|
|
|
147
148
|
return result ?? null;
|
|
148
149
|
}
|
|
149
150
|
|
|
150
|
-
public async loading<T>(operation: Promise<T>): Promise<T>;
|
|
151
|
-
public async loading<T>(message: string, operation: Promise<T>): Promise<T>;
|
|
152
|
-
public async loading<T>(
|
|
151
|
+
public async loading<T>(operation: Promise<T> | (() => T)): Promise<T>;
|
|
152
|
+
public async loading<T>(message: string, operation: Promise<T> | (() => T)): Promise<T>;
|
|
153
|
+
public async loading<T>(
|
|
154
|
+
messageOrOperation: string | Promise<T> | (() => T),
|
|
155
|
+
operation?: Promise<T> | (() => T),
|
|
156
|
+
): Promise<T> {
|
|
153
157
|
const getProperties = (): AGLoadingModalProps => {
|
|
154
158
|
if (typeof messageOrOperation !== 'string') {
|
|
155
159
|
return {};
|
|
@@ -161,7 +165,8 @@ export class UIService extends Service {
|
|
|
161
165
|
const modal = await this.openModal(this.requireComponent(UIComponents.LoadingModal), getProperties());
|
|
162
166
|
|
|
163
167
|
try {
|
|
164
|
-
operation = typeof messageOrOperation === 'string' ? (operation as
|
|
168
|
+
operation = typeof messageOrOperation === 'string' ? (operation as () => T) : messageOrOperation;
|
|
169
|
+
operation = typeof operation === 'function' ? Promise.resolve(operation()) : operation;
|
|
165
170
|
|
|
166
171
|
const [result] = await Promise.all([operation, after({ seconds: 1 })]);
|
|
167
172
|
|
|
@@ -231,6 +236,7 @@ export class UIService extends Service {
|
|
|
231
236
|
protected async boot(): Promise<void> {
|
|
232
237
|
this.watchModalEvents();
|
|
233
238
|
this.watchMountedEvent();
|
|
239
|
+
this.watchWindowMedia();
|
|
234
240
|
}
|
|
235
241
|
|
|
236
242
|
private watchModalEvents(): void {
|
|
@@ -276,6 +282,12 @@ export class UIService extends Service {
|
|
|
276
282
|
});
|
|
277
283
|
}
|
|
278
284
|
|
|
285
|
+
private watchWindowMedia(): void {
|
|
286
|
+
const media = window.matchMedia(`(min-width: ${MOBILE_BREAKPOINT}px)`);
|
|
287
|
+
|
|
288
|
+
media.addEventListener('change', () => this.setState({ layout: getCurrentLayout() }));
|
|
289
|
+
}
|
|
290
|
+
|
|
279
291
|
}
|
|
280
292
|
|
|
281
293
|
export default facade(UIService);
|
package/src/ui/index.ts
CHANGED
package/src/ui/utils.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const MOBILE_BREAKPOINT = 768;
|
|
2
|
+
|
|
3
|
+
export const Layouts = {
|
|
4
|
+
Mobile: 'mobile',
|
|
5
|
+
Desktop: 'desktop',
|
|
6
|
+
} as const;
|
|
7
|
+
|
|
8
|
+
export type Layout = (typeof Layouts)[keyof typeof Layouts];
|
|
9
|
+
|
|
10
|
+
export function getCurrentLayout(): Layout {
|
|
11
|
+
if (window.innerWidth > MOBILE_BREAKPOINT) {
|
|
12
|
+
return Layouts.Desktop;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return Layouts.Mobile;
|
|
16
|
+
}
|
package/src/utils/vue.ts
CHANGED
|
@@ -73,10 +73,12 @@ export function injectOrFail<T>(key: InjectionKey<T> | string, errorMessage?: st
|
|
|
73
73
|
return inject(key) ?? fail(errorMessage ?? `Could not resolve '${key}' injection key`);
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
export function mixedProp<T>(type?: PropType<T>): OptionalProp<T | null
|
|
76
|
+
export function mixedProp<T>(type?: PropType<T>): OptionalProp<T | null>;
|
|
77
|
+
export function mixedProp<T>(type: PropType<T>, defaultValue: T): OptionalProp<T>;
|
|
78
|
+
export function mixedProp<T>(type?: PropType<T>, defaultValue?: T): OptionalProp<T | null> {
|
|
77
79
|
return {
|
|
78
80
|
type,
|
|
79
|
-
default: null,
|
|
81
|
+
default: defaultValue ?? null,
|
|
80
82
|
};
|
|
81
83
|
}
|
|
82
84
|
|