@aerogel/core 0.0.0-next.e4c0d5bd2801fbe93545477ea515af53df69b522 → 0.0.0-next.eb6fcafb87cdccbc72933f616799ca3124d1eca8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/aerogel-core.d.ts +257 -83
  2. package/dist/aerogel-core.js +1607 -1230
  3. package/dist/aerogel-core.js.map +1 -1
  4. package/package.json +2 -1
  5. package/src/components/contracts/AlertModal.ts +1 -1
  6. package/src/components/contracts/DropdownMenu.ts +2 -2
  7. package/src/components/contracts/Select.ts +2 -2
  8. package/src/components/headless/HeadlessInputInput.vue +16 -5
  9. package/src/components/headless/HeadlessSwitch.vue +96 -0
  10. package/src/components/headless/index.ts +1 -0
  11. package/src/components/ui/Button.vue +15 -0
  12. package/src/components/ui/Input.vue +2 -2
  13. package/src/components/ui/Modal.vue +12 -4
  14. package/src/components/ui/SelectLabel.vue +5 -1
  15. package/src/components/ui/Setting.vue +31 -0
  16. package/src/components/ui/StartupCrash.vue +51 -6
  17. package/src/components/ui/Switch.vue +11 -0
  18. package/src/components/ui/TextArea.vue +56 -0
  19. package/src/components/ui/index.ts +3 -0
  20. package/src/errors/Errors.state.ts +1 -0
  21. package/src/errors/Errors.ts +27 -6
  22. package/src/errors/index.ts +6 -2
  23. package/src/errors/settings/Debug.vue +14 -0
  24. package/src/errors/settings/index.ts +10 -0
  25. package/src/forms/FormController.test.ts +3 -0
  26. package/src/forms/FormController.ts +25 -16
  27. package/src/forms/utils.ts +25 -0
  28. package/src/forms/validation.ts +31 -0
  29. package/src/index.css +3 -0
  30. package/src/lang/index.ts +1 -1
  31. package/src/lang/settings/Language.vue +1 -1
  32. package/src/services/Service.ts +11 -6
  33. package/src/services/index.ts +2 -2
  34. package/src/testing/index.ts +4 -0
  35. package/src/ui/UI.ts +20 -4
  36. package/src/utils/app.ts +7 -0
  37. package/src/utils/index.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aerogel/core",
3
- "version": "0.0.0-next.e4c0d5bd2801fbe93545477ea515af53df69b522",
3
+ "version": "0.0.0-next.eb6fcafb87cdccbc72933f616799ca3124d1eca8",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "exports": {
@@ -33,6 +33,7 @@
33
33
  "class-variance-authority": "^0.7.1",
34
34
  "clsx": "^2.1.1",
35
35
  "dompurify": "^3.2.4",
36
+ "eruda": "^3.4.1",
36
37
  "marked": "^15.0.7",
37
38
  "pinia": "^2.1.6",
38
39
  "reka-ui": "^2.2.0",
@@ -1,6 +1,6 @@
1
1
  import { computed } from 'vue';
2
2
 
3
- import { translateWithDefault } from '@aerogel/core/lang';
3
+ import { translateWithDefault } from '@aerogel/core/lang/utils';
4
4
  import type { ModalExpose } from '@aerogel/core/components/contracts/Modal';
5
5
 
6
6
  export interface AlertModalProps {
@@ -15,11 +15,11 @@ export type DropdownMenuOptionData = {
15
15
  export interface DropdownMenuProps {
16
16
  align?: DropdownMenuContentProps['align'];
17
17
  side?: DropdownMenuContentProps['side'];
18
- options?: Falsifiable<DropdownMenuOptionData>[];
18
+ options?: readonly Falsifiable<DropdownMenuOptionData>[];
19
19
  }
20
20
 
21
21
  export interface DropdownMenuExpose {
22
22
  align?: DropdownMenuContentProps['align'];
23
23
  side?: DropdownMenuContentProps['side'];
24
- options?: DropdownMenuOptionData[];
24
+ options?: readonly DropdownMenuOptionData[];
25
25
  }
@@ -18,7 +18,7 @@ export interface HasSelectOptionLabel {
18
18
 
19
19
  export interface SelectProps<T extends Nullable<FormFieldValue> = Nullable<FormFieldValue>> extends InputProps<T> {
20
20
  as?: AsTag | Component;
21
- options?: T[];
21
+ options?: readonly T[];
22
22
  placeholder?: string;
23
23
  renderOption?: (option: T) => string;
24
24
  compareOptions?: (a: T, b: T) => boolean;
@@ -31,7 +31,7 @@ export interface SelectProps<T extends Nullable<FormFieldValue> = Nullable<FormF
31
31
  export interface SelectEmits<T extends Nullable<FormFieldValue> = Nullable<FormFieldValue>> extends InputEmits<T> {}
32
32
 
33
33
  export interface SelectExpose<T extends Nullable<FormFieldValue> = Nullable<FormFieldValue>> extends InputExpose<T> {
34
- options: ComputedRef<Nullable<SelectOptionData[]>>;
34
+ options: ComputedRef<Nullable<readonly SelectOptionData[]>>;
35
35
  selectedOption: ComputedRef<Nullable<SelectOptionData>>;
36
36
  placeholder: ComputedRef<string>;
37
37
  labelClass?: HTMLAttributes['class'];
@@ -3,8 +3,8 @@
3
3
  :id="input.id"
4
4
  ref="$inputRef"
5
5
  :name
6
- :type
7
6
  :checked
7
+ :type="renderedType"
8
8
  :required="input.required ?? undefined"
9
9
  :aria-invalid="input.errors ? 'true' : 'false'"
10
10
  :aria-describedby="
@@ -15,18 +15,29 @@
15
15
  </template>
16
16
 
17
17
  <script setup lang="ts">
18
- import { computed, useTemplateRef, watchEffect } from 'vue';
18
+ import { computed, inject, useTemplateRef, watchEffect } from 'vue';
19
19
 
20
20
  import { injectReactiveOrFail } from '@aerogel/core/utils/vue';
21
21
  import { onFormFocus } from '@aerogel/core/utils/composition/forms';
22
+ import type FormController from '@aerogel/core/forms/FormController';
22
23
  import type { FormFieldValue } from '@aerogel/core/forms/FormController';
23
24
  import type { InputExpose } from '@aerogel/core/components/contracts/Input';
24
25
 
25
- const { type = 'text' } = defineProps<{ type?: string }>();
26
+ const { type } = defineProps<{ type?: string }>();
26
27
  const $input = useTemplateRef('$inputRef');
27
28
  const input = injectReactiveOrFail<InputExpose>('input', '<HeadlessInputInput> must be a child of a <HeadlessInput>');
29
+ const form = inject<FormController | null>('form', null);
28
30
  const name = computed(() => input.name ?? undefined);
29
31
  const value = computed(() => input.value);
32
+ const renderedType = computed(() => {
33
+ if (type) {
34
+ return type;
35
+ }
36
+
37
+ const fieldType = (name.value && form?.getFieldType(name.value)) ?? '';
38
+
39
+ return ['text', 'email', 'number', 'tel', 'url'].includes(fieldType) ? fieldType : 'text';
40
+ });
30
41
  const checked = computed(() => {
31
42
  if (type !== 'checkbox') {
32
43
  return;
@@ -64,8 +75,8 @@ watchEffect(() => {
64
75
  return;
65
76
  }
66
77
 
67
- if (type === 'date') {
68
- $input.value.valueAsDate = value.value as Date;
78
+ if (type === 'date' && value.value instanceof Date) {
79
+ $input.value.valueAsDate = value.value;
69
80
 
70
81
  return;
71
82
  }
@@ -0,0 +1,96 @@
1
+ <template>
2
+ <div :class="rootClass">
3
+ <label v-if="label" :for="expose.id" :class="labelClass">
4
+ {{ label }}
5
+ </label>
6
+ <SwitchRoot
7
+ :id="expose.id"
8
+ :name
9
+ :model-value="expose.value.value"
10
+ v-bind="$attrs"
11
+ :class="inputClass"
12
+ @update:model-value="$emit('update:modelValue', $event)"
13
+ >
14
+ <SwitchThumb :class="thumbClass" />
15
+ </SwitchRoot>
16
+ </div>
17
+ </template>
18
+
19
+ <script setup lang="ts" generic="T extends boolean = boolean">
20
+ import { SwitchRoot, SwitchThumb } from 'reka-ui';
21
+ import { computed, inject, readonly, watchEffect } from 'vue';
22
+ import { uuid } from '@noeldemartin/utils';
23
+ import type { HTMLAttributes } from 'vue';
24
+
25
+ import type FormController from '@aerogel/core/forms/FormController';
26
+ import type { FormFieldValue } from '@aerogel/core/forms/FormController';
27
+ import type { InputEmits, InputExpose, InputProps } from '@aerogel/core/components/contracts/Input';
28
+
29
+ defineOptions({ inheritAttrs: false });
30
+
31
+ const {
32
+ name,
33
+ label,
34
+ description,
35
+ modelValue,
36
+ class: rootClass,
37
+ } = defineProps<
38
+ InputProps<T> & {
39
+ class?: HTMLAttributes['class'];
40
+ labelClass?: HTMLAttributes['class'];
41
+ inputClass?: HTMLAttributes['class'];
42
+ thumbClass?: HTMLAttributes['class'];
43
+ }
44
+ >();
45
+ const emit = defineEmits<InputEmits>();
46
+ const form = inject<FormController | null>('form', null);
47
+ const errors = computed(() => {
48
+ if (!form || !name) {
49
+ return null;
50
+ }
51
+
52
+ return form.errors[name] ?? null;
53
+ });
54
+
55
+ const expose = {
56
+ id: `switch-${uuid()}`,
57
+ name: computed(() => name),
58
+ label: computed(() => label),
59
+ description: computed(() => description),
60
+ value: computed(() => {
61
+ if (form && name) {
62
+ return form.getFieldValue(name) as boolean;
63
+ }
64
+
65
+ return modelValue;
66
+ }),
67
+ errors: readonly(errors),
68
+ required: computed(() => {
69
+ if (!name || !form) {
70
+ return;
71
+ }
72
+
73
+ return form.getFieldRules(name).includes('required');
74
+ }),
75
+ update(value) {
76
+ if (form && name) {
77
+ form.setFieldValue(name, value as FormFieldValue);
78
+
79
+ return;
80
+ }
81
+
82
+ emit('update:modelValue', value);
83
+ },
84
+ } satisfies InputExpose;
85
+
86
+ defineExpose(expose);
87
+
88
+ watchEffect(() => {
89
+ if (!description && !errors.value) {
90
+ return;
91
+ }
92
+
93
+ // eslint-disable-next-line no-console
94
+ console.warn('Errors and description not implemented in <HeadlessSwitch>');
95
+ });
96
+ </script>
@@ -16,4 +16,5 @@ export { default as HeadlessSelectOption } from './HeadlessSelectOption.vue';
16
16
  export { default as HeadlessSelectOptions } from './HeadlessSelectOptions.vue';
17
17
  export { default as HeadlessSelectTrigger } from './HeadlessSelectTrigger.vue';
18
18
  export { default as HeadlessSelectValue } from './HeadlessSelectValue.vue';
19
+ export { default as HeadlessSwitch } from './HeadlessSwitch.vue';
19
20
  export { default as HeadlessToast } from './HeadlessToast.vue';
@@ -88,6 +88,21 @@ const renderedClasses = computed(() => variantClasses<Variants<Pick<ButtonProps,
88
88
  disabled: false,
89
89
  class: 'hover:underline',
90
90
  },
91
+ {
92
+ variant: 'link',
93
+ size: 'small',
94
+ class: 'leading-6',
95
+ },
96
+ {
97
+ variant: 'link',
98
+ size: 'default',
99
+ class: 'leading-8',
100
+ },
101
+ {
102
+ variant: 'link',
103
+ size: 'large',
104
+ class: 'leading-10',
105
+ },
91
106
  ],
92
107
  defaultVariants: {
93
108
  variant: 'default',
@@ -48,8 +48,8 @@ const renderedInputClasses = computed(() =>
48
48
  'block w-full rounded-md border-0 py-1.5 ring-1 ring-inset focus:ring-2 focus:ring-inset sm:text-sm sm:leading-6',
49
49
  {
50
50
  'focus:ring-primary-600': !$input.value?.errors,
51
- 'text-gray-900 shadow-2xs ring-gray-300 placeholder:text-gray-400': !$input.value?.errors,
52
- 'pr-10 text-red-900 ring-red-300 placeholder:text-red-300 focus:ring-red-500': $input.value?.errors,
51
+ 'text-gray-900 shadow-2xs ring-gray-900/10 placeholder:text-gray-400': !$input.value?.errors,
52
+ 'pr-10 text-red-900 ring-red-900/10 placeholder:text-red-300 focus:ring-red-500': $input.value?.errors,
53
53
  },
54
54
  inputClass,
55
55
  ));
@@ -27,14 +27,22 @@
27
27
 
28
28
  <HeadlessModalTitle
29
29
  v-if="title"
30
- class="px-4 pt-5 pb-2 text-base font-semibold text-gray-900"
31
- :class="{ 'sr-only': titleHidden }"
30
+ class="px-4 pt-5 text-base font-semibold text-gray-900"
31
+ :class="{
32
+ 'sr-only': titleHidden,
33
+ 'pb-0': description && !descriptionHidden,
34
+ 'pb-2': !description || descriptionHidden,
35
+ }"
32
36
  >
33
37
  <Markdown :text="title" inline />
34
38
  </HeadlessModalTitle>
35
39
 
36
- <HeadlessModalDescription v-if="description" :class="{ 'sr-only': descriptionHidden }">
37
- <Markdown :text="description" class="mt-1 text-sm leading-6 text-gray-500" />
40
+ <HeadlessModalDescription
41
+ v-if="description"
42
+ class="px-4 pt-1 pb-2"
43
+ :class="{ 'sr-only': descriptionHidden }"
44
+ >
45
+ <Markdown :text="description" class="text-sm leading-6 text-gray-500" />
38
46
  </HeadlessModalDescription>
39
47
 
40
48
  <div :class="renderedContentClass">
@@ -7,11 +7,15 @@
7
7
 
8
8
  <script setup lang="ts">
9
9
  import { computed } from 'vue';
10
+ import type { HTMLAttributes } from 'vue';
10
11
 
11
12
  import HeadlessSelectLabel from '@aerogel/core/components/headless/HeadlessSelectLabel.vue';
12
13
  import { classes, injectReactiveOrFail } from '@aerogel/core/utils';
13
14
  import type { SelectExpose } from '@aerogel/core/components/contracts/Select';
14
15
 
16
+ const { class: rootClasses } = defineProps<{ class?: HTMLAttributes['class'] }>();
17
+
15
18
  const select = injectReactiveOrFail<SelectExpose>('select', '<SelectLabel> must be a child of a <Select>');
16
- const renderedClasses = computed(() => classes('block text-sm leading-6 font-medium text-gray-900', select.labelClass));
19
+ const renderedClasses = computed(() =>
20
+ classes('block text-sm leading-6 font-medium text-gray-900', select.labelClass, rootClasses));
17
21
  </script>
@@ -0,0 +1,31 @@
1
+ <template>
2
+ <div class="mt-4 flex" :class="{ 'flex-col': layout === 'vertical' }">
3
+ <div class="flex-grow">
4
+ <h3 :id="titleId" class="text-base font-semibold">
5
+ {{ title }}
6
+ </h3>
7
+ <Markdown v-if="description" :text="description" class="mt-1 text-sm text-gray-500" />
8
+ </div>
9
+
10
+ <div :class="renderedRootClass">
11
+ <slot />
12
+ </div>
13
+ </div>
14
+ </template>
15
+
16
+ <script setup lang="ts">
17
+ import { computed } from 'vue';
18
+ import type { HTMLAttributes } from 'vue';
19
+
20
+ import Markdown from '@aerogel/core/components/ui/Markdown.vue';
21
+ import { classes } from '@aerogel/core/utils';
22
+
23
+ const { layout = 'horizontal', class: rootClass } = defineProps<{
24
+ title: string;
25
+ titleId?: string;
26
+ description?: string;
27
+ class?: HTMLAttributes['class'];
28
+ layout?: 'vertical' | 'horizontal';
29
+ }>();
30
+ const renderedRootClass = computed(() => classes(rootClass, 'flex flex-col justify-center gap-1'));
31
+ </script>
@@ -1,31 +1,76 @@
1
1
  <template>
2
2
  <div class="grid grow place-items-center">
3
- <div class="flex flex-col items-center space-y-6 p-8">
4
- <h1 class="mt-2 text-center text-4xl font-medium text-red-600">
5
- {{ $td('startupCrash.title', 'Something went wrong!') }}
3
+ <div class="flex flex-col items-center p-8">
4
+ <i-majesticons-exclamation class="size-20 text-red-600" />
5
+ <h1 class="mt-0 mb-0 text-center text-4xl font-medium text-red-600">
6
+ {{ $td('startupCrash.title', 'Oops, something went wrong!') }}
6
7
  </h1>
7
8
  <Markdown
8
9
  :text="
9
10
  $td(
10
11
  'startupCrash.message',
11
- 'Something failed trying to start the application.\n\nHere\'s some things you can do:'
12
+ 'There was a problem starting the application, but here\'s some things you can do:'
12
13
  )
13
14
  "
14
15
  class="mt-4 text-center"
15
16
  />
16
- <div class="mt-4 flex flex-col space-y-4">
17
+ <div
18
+ class="mt-4 grid grid-cols-1 gap-2 sm:grid-cols-2 [&_button]:justify-start sm:[&_button]:size-32 sm:[&_button]:flex-col sm:[&_button]:justify-center [&_svg]:size-6 sm:[&_svg]:size-8"
19
+ >
17
20
  <Button variant="danger" @click="$app.reload()">
21
+ <IconRefresh />
18
22
  {{ $td('startupCrash.reload', 'Try again') }}
19
23
  </Button>
20
24
  <Button variant="danger" @click="$errors.inspect($errors.startupErrors)">
25
+ <IconBug />
21
26
  {{ $td('startupCrash.inspect', 'View error details') }}
22
27
  </Button>
28
+ <Button variant="danger" @click="purgeDevice()">
29
+ <IconDelete />
30
+ {{ $td('startupCrash.purge', 'Purge device') }}
31
+ </Button>
32
+ <Button variant="danger" @click="$errors.debug = !$errors.debug">
33
+ <IconFrameInspect />
34
+ {{ $td('startupCrash.debug', 'Toggle debugging') }}
35
+ </Button>
23
36
  </div>
24
37
  </div>
25
38
  </div>
26
39
  </template>
27
40
 
28
41
  <script setup lang="ts">
29
- import Markdown from '@aerogel/core/components/ui/Markdown.vue';
42
+ import IconBug from '~icons/material-symbols/bug-report';
43
+ import IconDelete from '~icons/material-symbols/delete-forever-rounded';
44
+ import IconFrameInspect from '~icons/material-symbols/frame-inspect';
45
+ import IconRefresh from '~icons/material-symbols/refresh-rounded';
46
+
47
+ import App from '@aerogel/core/services/App';
30
48
  import Button from '@aerogel/core/components/ui/Button.vue';
49
+ import Markdown from '@aerogel/core/components/ui/Markdown.vue';
50
+ import Storage from '@aerogel/core/services/Storage';
51
+ import UI from '@aerogel/core/ui/UI';
52
+ import { translateWithDefault } from '@aerogel/core/lang/utils';
53
+
54
+ async function purgeDevice() {
55
+ const confirmed = await UI.confirm(
56
+ translateWithDefault('startupCrash.purgeConfirmTitle', 'Delete everything?'),
57
+ translateWithDefault(
58
+ 'startupCrash.purgeConfirmMessage',
59
+ 'If the problem persists, one drastic solution may be to wipe the storage in this device ' +
60
+ 'to start from scratch. However, keep in mind that **all the data that you haven\'t ' +
61
+ 'synchronized will be deleted forever**.\n\nDo you still want to proceed?',
62
+ ),
63
+ {
64
+ acceptVariant: 'danger',
65
+ acceptText: translateWithDefault('startupCrash.purgeConfirmAccept', 'Purge device'),
66
+ },
67
+ );
68
+
69
+ if (!confirmed) {
70
+ return;
71
+ }
72
+
73
+ await Storage.purge();
74
+ await App.reload();
75
+ }
31
76
  </script>
@@ -0,0 +1,11 @@
1
+ <template>
2
+ <HeadlessSwitch
3
+ class="flex flex-row items-center gap-1"
4
+ input-class="disabled:opacity-50 disabled:cursor-not-allowed data-[state=checked]:bg-primary-600 data-[state=unchecked]:bg-gray-200 relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focusdisabled:opacity-50 disabled:cursor-not-allowed :ring-2 focus:ring-primary-600 focus:ring-offset-2 focus:outline-hidden"
5
+ thumb-class="data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0 pointer-events-none inline-block size-5 transform rounded-full bg-white shadow-sm ring-0 transition duration-200 ease-in-out"
6
+ />
7
+ </template>
8
+
9
+ <script setup lang="ts">
10
+ import HeadlessSwitch from '@aerogel/core/components/headless/HeadlessSwitch.vue';
11
+ </script>
@@ -0,0 +1,56 @@
1
+ <template>
2
+ <HeadlessInput
3
+ ref="$inputRef"
4
+ :label="label"
5
+ :class="rootClasses"
6
+ v-bind="props"
7
+ @update:model-value="$emit('update:modelValue', $event)"
8
+ >
9
+ <HeadlessInputLabel class="block text-sm leading-6 font-medium text-gray-900" />
10
+ <div :class="renderedWrapperClasses">
11
+ <HeadlessInputTextArea v-bind="inputAttrs" :class="renderedInputClasses" />
12
+ <div v-if="$input?.errors" class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
13
+ <IconExclamationSolid class="size-5 text-red-500" />
14
+ </div>
15
+ </div>
16
+ <HeadlessInputDescription class="mt-2 text-sm text-gray-600" />
17
+ <HeadlessInputError class="mt-2 text-sm text-red-600" />
18
+ </HeadlessInput>
19
+ </template>
20
+
21
+ <script setup lang="ts">
22
+ import IconExclamationSolid from '~icons/zondicons/exclamation-solid';
23
+
24
+ import { computed, useTemplateRef } from 'vue';
25
+ import type { HTMLAttributes } from 'vue';
26
+
27
+ import HeadlessInput from '@aerogel/core/components/headless/HeadlessInput.vue';
28
+ import HeadlessInputLabel from '@aerogel/core/components/headless/HeadlessInputLabel.vue';
29
+ import HeadlessInputTextArea from '@aerogel/core/components/headless/HeadlessInputTextArea.vue';
30
+ import HeadlessInputDescription from '@aerogel/core/components/headless/HeadlessInputDescription.vue';
31
+ import HeadlessInputError from '@aerogel/core/components/headless/HeadlessInputError.vue';
32
+ import { classes } from '@aerogel/core/utils/classes';
33
+ import { useInputAttrs } from '@aerogel/core/utils/composition/forms';
34
+ import type { InputEmits, InputProps } from '@aerogel/core/components/contracts/Input';
35
+
36
+ defineOptions({ inheritAttrs: false });
37
+ defineEmits<InputEmits>();
38
+ const { label, inputClass, wrapperClass, ...props } = defineProps<
39
+ InputProps & { inputClass?: HTMLAttributes['class']; wrapperClass?: HTMLAttributes['class'] }
40
+ >();
41
+ const $input = useTemplateRef('$inputRef');
42
+ const [inputAttrs, rootClasses] = useInputAttrs();
43
+ const renderedWrapperClasses = computed(() =>
44
+ classes('relative rounded-md shadow-2xs', { 'mt-1': label }, wrapperClass));
45
+ const renderedInputClasses = computed(() =>
46
+ classes(
47
+ // eslint-disable-next-line vue/max-len
48
+ 'block w-full rounded-md border-0 py-1.5 ring-1 ring-inset focus:ring-2 focus:ring-inset sm:text-sm sm:leading-6',
49
+ {
50
+ 'focus:ring-primary-600': !$input.value?.errors,
51
+ 'text-gray-900 shadow-2xs ring-gray-900/10 placeholder:text-gray-400': !$input.value?.errors,
52
+ 'pr-10 text-red-900 ring-red-900/10 placeholder:text-red-300 focus:ring-red-500': $input.value?.errors,
53
+ },
54
+ inputClass,
55
+ ));
56
+ </script>
@@ -27,6 +27,9 @@ export { default as SelectLabel } from './SelectLabel.vue';
27
27
  export { default as SelectOption } from './SelectOption.vue';
28
28
  export { default as SelectOptions } from './SelectOptions.vue';
29
29
  export { default as SelectTrigger } from './SelectTrigger.vue';
30
+ export { default as Setting } from './Setting.vue';
30
31
  export { default as SettingsModal } from './SettingsModal.vue';
31
32
  export { default as StartupCrash } from './StartupCrash.vue';
33
+ export { default as Switch } from './Switch.vue';
34
+ export { default as TextArea } from './TextArea.vue';
32
35
  export { default as Toast } from './Toast.vue';
@@ -22,6 +22,7 @@ export default defineServiceState({
22
22
  initialState: {
23
23
  logs: [] as ErrorReportLog[],
24
24
  startupErrors: [] as ErrorReport[],
25
+ debug: false,
25
26
  },
26
27
  computed: {
27
28
  hasErrors: ({ logs }) => logs.length > 0,
@@ -1,4 +1,6 @@
1
1
  import { JSError, facade, isDevelopment, isObject, isTesting, objectWithoutEmpty, toString } from '@noeldemartin/utils';
2
+ import { watchEffect } from 'vue';
3
+ import type Eruda from 'eruda';
2
4
 
3
5
  import App from '@aerogel/core/services/App';
4
6
  import ServiceBootError from '@aerogel/core/errors/ServiceBootError';
@@ -13,6 +15,7 @@ export class ErrorsService extends Service {
13
15
 
14
16
  public forceReporting: boolean = false;
15
17
  private enabled: boolean = true;
18
+ private eruda: typeof Eruda | null = null;
16
19
 
17
20
  public enable(): void {
18
21
  this.enabled = true;
@@ -91,6 +94,19 @@ export class ErrorsService extends Service {
91
94
  this.setState({ logs: [log].concat(this.logs) });
92
95
  }
93
96
 
97
+ public reportDevelopmentError(error: ErrorSource, message?: string): void {
98
+ if (!isDevelopment()) {
99
+ return;
100
+ }
101
+
102
+ if (message) {
103
+ // eslint-disable-next-line no-console
104
+ console.warn(message);
105
+ }
106
+
107
+ this.logError(error);
108
+ }
109
+
94
110
  public see(report: ErrorReport): void {
95
111
  this.setState({
96
112
  logs: this.logs.map((log) => {
@@ -106,12 +122,17 @@ export class ErrorsService extends Service {
106
122
  });
107
123
  }
108
124
 
109
- public seeAll(): void {
110
- this.setState({
111
- logs: this.logs.map((log) => ({
112
- ...log,
113
- seen: true,
114
- })),
125
+ protected override async boot(): Promise<void> {
126
+ watchEffect(async () => {
127
+ if (!this.debug) {
128
+ this.eruda?.destroy();
129
+
130
+ return;
131
+ }
132
+
133
+ this.eruda ??= (await import('eruda')).default;
134
+
135
+ this.eruda.init();
115
136
  });
116
137
  }
117
138
 
@@ -1,9 +1,11 @@
1
- import type { App } from 'vue';
1
+ import type { App as AppInstance } from 'vue';
2
2
 
3
+ import App from '@aerogel/core/services/App';
3
4
  import { bootServices } from '@aerogel/core/services';
4
5
  import { definePlugin } from '@aerogel/core/plugins';
5
6
 
6
7
  import Errors from './Errors';
8
+ import settings from './settings';
7
9
  import type { ErrorReport, ErrorReportLog, ErrorSource } from './Errors.state';
8
10
 
9
11
  export * from './utils';
@@ -19,7 +21,7 @@ const frameworkHandler: ErrorHandler = (error) => {
19
21
  return true;
20
22
  };
21
23
 
22
- function setUpErrorHandler(app: App, baseHandler: ErrorHandler = () => false): void {
24
+ function setUpErrorHandler(app: AppInstance, baseHandler: ErrorHandler = () => false): void {
23
25
  const errorHandler: ErrorHandler = (error) => baseHandler(error) || frameworkHandler(error);
24
26
 
25
27
  app.config.errorHandler = errorHandler;
@@ -34,6 +36,8 @@ export default definePlugin({
34
36
  async install(app, options) {
35
37
  setUpErrorHandler(app, options.handleError);
36
38
 
39
+ settings.forEach((setting) => App.addSetting(setting));
40
+
37
41
  await bootServices(app, services);
38
42
  },
39
43
  });
@@ -0,0 +1,14 @@
1
+ <template>
2
+ <Setting
3
+ title-id="debug-setting"
4
+ :title="$td('settings.debug', 'Debugging')"
5
+ :description="$td('settings.debugDescription', 'Enable debugging with [Eruda](https://eruda.liriliri.io/).')"
6
+ >
7
+ <Switch v-model="$errors.debug" aria-labelledby="debug-setting" />
8
+ </Setting>
9
+ </template>
10
+
11
+ <script setup lang="ts">
12
+ import Setting from '@aerogel/core/components/ui/Setting.vue';
13
+ import Switch from '@aerogel/core/components/ui/Switch.vue';
14
+ </script>
@@ -0,0 +1,10 @@
1
+ import { defineSettings } from '@aerogel/core/services';
2
+
3
+ import Debug from './Debug.vue';
4
+
5
+ export default defineSettings([
6
+ {
7
+ priority: 10,
8
+ component: Debug,
9
+ },
10
+ ]);
@@ -4,6 +4,7 @@ import type { Equals } from '@noeldemartin/utils';
4
4
  import type { Expect } from '@noeldemartin/testing';
5
5
 
6
6
  import {
7
+ enumInput,
7
8
  numberInput,
8
9
  objectInput,
9
10
  requiredObjectInput,
@@ -97,6 +98,7 @@ describe('FormController', () => {
97
98
  two: requiredStringInput(),
98
99
  three: objectInput(),
99
100
  four: requiredObjectInput<{ foo: string; bar?: number }>(),
101
+ five: enumInput(['foo', 'bar']),
100
102
  });
101
103
 
102
104
  tt<
@@ -104,6 +106,7 @@ describe('FormController', () => {
104
106
  | Expect<Equals<typeof form.two, string>>
105
107
  | Expect<Equals<typeof form.three, object | null>>
106
108
  | Expect<Equals<typeof form.four, { foo: string; bar?: number }>>
109
+ | Expect<Equals<typeof form.five, 'foo' | 'bar' | null>>
107
110
  >();
108
111
  });
109
112