@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/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.d34923f3b144e8f6720e6a9cdadb2cd4fb4ab289",
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",
@@ -11,7 +11,6 @@ import { bootstrap } from './index';
11
11
  describe('Aerogel', () => {
12
12
 
13
13
  beforeEach(() => {
14
- vi.stubGlobal('document', { getElementById: () => null });
15
14
  vi.mock('vue', async () => {
16
15
  const vue = (await vi.importActual('vue')) as Object;
17
16
 
@@ -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
+ }
@@ -0,0 +1,3 @@
1
+ import type { HasElement } from '@/components/interfaces';
2
+
3
+ export interface IAGHeadlessButton extends HasElement {}
@@ -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 { __HasElement } from '@/components/interfaces';
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 & __HasElement = {
35
- $el: readonly($el) as Readonly<Ref<HTMLElement>>,
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 { __HasElement } from '@/components/interfaces';
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 __HasElement).__setElement($input.value));
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 { __HasElement } from '@/components/interfaces';
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 __HasElement).__setElement($textArea.value));
41
+ watchEffect(() => (input as unknown as __SetsElement).__setElement($textArea.value));
41
42
  onFormFocus(input, () => $textArea.value?.focus());
42
43
  </script>
@@ -1,4 +1,5 @@
1
1
  export * from './composition';
2
+ export * from './AGHeadlessButton';
2
3
  export * from './AGHeadlessInput';
3
4
  export * from './AGHeadlessSelect';
4
5
  export * from './AGHeadlessSelectOption';
@@ -3,6 +3,7 @@ import AGAppOverlays from './AGAppOverlays.vue';
3
3
 
4
4
  export { AGAppLayout, AGAppOverlays };
5
5
 
6
+ export * from './composition';
6
7
  export * from './constants';
7
8
  export * from './forms';
8
9
  export * from './headless';
@@ -1,6 +1,21 @@
1
- import type { Ref } from 'vue';
1
+ import { isObject } from '@noeldemartin/utils';
2
+ import type { Ref, UnwrapNestedRefs } from 'vue';
2
3
 
3
- export interface __HasElement {
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
 
@@ -11,5 +11,6 @@ import { stringProp } from '@/utils/vue';
11
11
 
12
12
  defineProps({ as: stringProp('span') });
13
13
 
14
+ // TODO use v-measure.css
14
15
  const measured = ref(false);
15
16
  </script>
@@ -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 sizes = element.getBoundingClientRect();
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
- // TODO guard with modifiers.css once typed properly
16
- element.style.setProperty('--width', `${sizes.width}px`);
17
- element.style.setProperty('--height', `${sizes.height}px`);
27
+ listener?.({ width: sizes.width, height: sizes.height });
28
+ };
18
29
 
19
- listener?.({ width: sizes.width, height: sizes.height });
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
  });
@@ -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
- this._data[field] = value;
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._data[property];
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
- Object.assign(this._data, { [property]: value });
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 App from '@/services/App';
4
- import Service from '@/services/Service';
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
- translate: (key) => {
20
- // eslint-disable-next-line no-console
21
- App.development && console.warn('Lang provider is missing');
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);
@@ -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
+ });
@@ -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>(messageOrOperation: string | Promise<T>, operation?: Promise<T>): Promise<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 Promise<T>) : messageOrOperation;
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
@@ -16,6 +16,7 @@ import type { UIComponent } from './UI';
16
16
  const services = { $ui: UI };
17
17
 
18
18
  export * from './UI';
19
+ export * from './utils';
19
20
  export { default as UI } from './UI';
20
21
 
21
22
  export type UIServices = typeof services;
@@ -0,0 +1,16 @@
1
+ export const MOBILE_BREAKPOINT = 768;
2
+
3
+ export const Layouts = {
4
+ Mobile: 'mobile',
5
+ Desktop: 'desktop',
6
+ } as const;
7
+
8
+ export type Layout = (typeof Layouts)[keyof typeof Layouts];
9
+
10
+ export function getCurrentLayout(): Layout {
11
+ if (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