@aerogel/core 0.0.0-next.669133ce9c5f0eeaff1b63c4e2962e90129cfc1a → 0.0.0-next.71f28064caa2ea968f0e99396b672de218176260

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.669133ce9c5f0eeaff1b63c4e2962e90129cfc1a",
4
+ "version": "0.0.0-next.71f28064caa2ea968f0e99396b672de218176260",
5
5
  "main": "dist/aerogel-core.cjs.js",
6
6
  "module": "dist/aerogel-core.esm.js",
7
7
  "types": "dist/aerogel-core.d.ts",
@@ -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,12 +1,13 @@
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
5
 
6
6
  export interface IAGHeadlessInput {
7
7
  id: string;
8
8
  name: ComputedRef<string | null>;
9
9
  label: ComputedRef<string | null>;
10
+ description: ComputedRef<string | boolean | null>;
10
11
  value: ComputedRef<string | number | boolean | null>;
11
12
  errors: DeepReadonly<Ref<string[] | null>>;
12
13
  update(value: string | number | boolean | null): void;
@@ -15,6 +16,8 @@ export interface IAGHeadlessInput {
15
16
  export const inputProps = {
16
17
  name: stringProp(),
17
18
  label: stringProp(),
19
+ description: stringProp(),
20
+ modelValue: mixedProp<string | number | boolean>([String, Number, Boolean]),
18
21
  };
19
22
 
20
23
  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 { mixedProp, stringProp } from '@/utils/vue';
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,6 +32,7 @@ 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
38
  return form.getFieldValue(props.name) as string | number | boolean | null;
@@ -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>
@@ -2,10 +2,13 @@
2
2
  <input
3
3
  :id="input.id"
4
4
  ref="$input"
5
+ :name="name"
5
6
  :type="type"
6
7
  :value="value"
7
8
  :aria-invalid="input.errors ? 'true' : 'false'"
8
- :aria-describedby="input.errors ? `${input.id}-error` : undefined"
9
+ :aria-describedby="
10
+ input.errors ? `${input.id}-error` : input.description ? `${input.id}-description` : undefined
11
+ "
9
12
  :checked="checked"
10
13
  @input="update"
11
14
  >
@@ -17,6 +20,8 @@ import { computed, ref } from 'vue';
17
20
  import { injectReactiveOrFail, stringProp } from '@/utils';
18
21
  import type { IAGHeadlessInput } from '@/components/headless/forms/AGHeadlessInput';
19
22
 
23
+ import { onFormFocus } from './composition';
24
+
20
25
  const props = defineProps({
21
26
  type: stringProp('text'),
22
27
  });
@@ -26,6 +31,7 @@ const input = injectReactiveOrFail<IAGHeadlessInput>(
26
31
  'input',
27
32
  '<AGHeadlessInputInput> must be a child of a <AGHeadlessInput>',
28
33
  );
34
+ const name = computed(() => input.name ?? undefined);
29
35
  const value = computed(() => input.value);
30
36
  const checked = computed(() => {
31
37
  if (props.type !== 'checkbox') {
@@ -42,4 +48,6 @@ function update() {
42
48
 
43
49
  input.update(props.type === 'checkbox' ? $input.value.checked : $input.value.value);
44
50
  }
51
+
52
+ onFormFocus(input, () => $input.value?.focus());
45
53
  </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,8 +1,10 @@
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';
@@ -5,12 +5,12 @@
5
5
  <script setup lang="ts">
6
6
  import { computed } from 'vue';
7
7
 
8
- import Errors from '@/errors/Errors';
9
8
  import { requiredObjectProp } from '@/utils/vue';
9
+ import { getErrorMessage } from '@/errors/utils';
10
10
  import type { ErrorSource } from '@/errors/Errors.state';
11
11
 
12
12
  import AGMarkdown from './AGMarkdown.vue';
13
13
 
14
14
  const props = defineProps({ error: requiredObjectProp<ErrorSource>() });
15
- const message = computed(() => Errors.getErrorMessage(props.error));
15
+ const message = computed(() => getErrorMessage(props.error));
16
16
  </script>
@@ -3,7 +3,7 @@
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
9
  import { booleanProp, objectProp, stringProp } from '@/utils/vue';
@@ -17,6 +17,7 @@ const props = defineProps({
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'), { class: props.inline ? '' : 'prose', innerHTML: html.value });
36
+ h(props.as ?? (props.inline ? 'span' : 'div'), {
37
+ class: props.inline ? '' : 'prose',
38
+ innerHTML: html.value,
39
+ ...attrs,
40
+ });
36
41
  </script>
@@ -10,6 +10,8 @@ const builtInDirectives: Record<string, Directive> = {
10
10
  'measure': measure,
11
11
  };
12
12
 
13
+ export * from './measure';
14
+
13
15
  export default definePlugin({
14
16
  install(app, options) {
15
17
  const directives = {
@@ -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 }: { value?: () => unknown }) {
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
- value?.();
19
+ listener?.({ width: sizes.width, height: sizes.height });
11
20
  },
12
21
  });
@@ -7,6 +7,7 @@ import { translateWithDefault } from '@/lang/utils';
7
7
 
8
8
  import Service from './Errors.state';
9
9
  import { Colors } from '@/components/constants';
10
+ import { Events } from '@/services';
10
11
  import type { AGErrorReportModalProps } from '@/components/modals/AGErrorReportModal';
11
12
  import type { ErrorReport, ErrorReportLog, ErrorSource } from './Errors.state';
12
13
  import type { ModalComponent } from '@/ui/UI.state';
@@ -39,6 +40,8 @@ export class ErrorsService extends Service {
39
40
  }
40
41
 
41
42
  public async report(error: ErrorSource, message?: string): Promise<void> {
43
+ await Events.emit('error', { error, message });
44
+
42
45
  if (App.testing) {
43
46
  throw error;
44
47
  }
@@ -114,22 +117,6 @@ export class ErrorsService extends Service {
114
117
  });
115
118
  }
116
119
 
117
- public getErrorMessage(error: ErrorSource): string {
118
- if (typeof error === 'string') {
119
- return error;
120
- }
121
-
122
- if (error instanceof Error || error instanceof JSError) {
123
- return error.message;
124
- }
125
-
126
- if (isObject(error)) {
127
- return toString(error['message'] ?? error['description'] ?? 'Unknown error object');
128
- }
129
-
130
- return translateWithDefault('errors.unknown', 'Unknown Error');
131
- }
132
-
133
120
  private logError(error: unknown): void {
134
121
  // eslint-disable-next-line no-console
135
122
  console.error(error);
@@ -190,3 +177,9 @@ export class ErrorsService extends Service {
190
177
  }
191
178
 
192
179
  export default facade(ErrorsService);
180
+
181
+ declare module '@/services/Events' {
182
+ export interface EventsPayload {
183
+ error: { error: ErrorSource; message?: string };
184
+ }
185
+ }
@@ -6,6 +6,7 @@ import { definePlugin } from '@/plugins';
6
6
  import Errors from './Errors';
7
7
  import { ErrorReport, ErrorReportLog, ErrorSource } from './Errors.state';
8
8
 
9
+ export * from './utils';
9
10
  export { Errors, ErrorSource, ErrorReport, ErrorReportLog };
10
11
 
11
12
  const services = { $errors: Errors };
@@ -0,0 +1,19 @@
1
+ import { JSError, isObject, toString } from '@noeldemartin/utils';
2
+ import { translateWithDefault } from '@/lang/utils';
3
+ import type { ErrorSource } from './Errors.state';
4
+
5
+ export function getErrorMessage(error: ErrorSource): string {
6
+ if (typeof error === 'string') {
7
+ return error;
8
+ }
9
+
10
+ if (error instanceof Error || error instanceof JSError) {
11
+ return error.message;
12
+ }
13
+
14
+ if (isObject(error)) {
15
+ return toString(error['message'] ?? error['description'] ?? 'Unknown error object');
16
+ }
17
+
18
+ return translateWithDefault('errors.unknown', 'Unknown Error');
19
+ }
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
 
@@ -44,6 +44,8 @@ export type GetFormFieldValue<TType> = TType extends typeof FormFieldTypes.Strin
44
44
 
45
45
  const validForms: WeakMap<Form, ComputedRef<boolean>> = new WeakMap();
46
46
 
47
+ export type FormListener = (input: string) => unknown;
48
+
47
49
  export default class Form<Fields extends FormFieldDefinitions = FormFieldDefinitions> extends MagicObject {
48
50
 
49
51
  public errors: DeepReadonly<UnwrapNestedRefs<FormErrors<Fields>>>;
@@ -52,6 +54,7 @@ export default class Form<Fields extends FormFieldDefinitions = FormFieldDefinit
52
54
  private _data: FormData<Fields>;
53
55
  private _submitted: Ref<boolean>;
54
56
  private _errors: FormErrors<Fields>;
57
+ private _listeners: Record<string, FormListener[]> = {};
55
58
 
56
59
  constructor(fields: Fields) {
57
60
  super();
@@ -114,6 +117,23 @@ export default class Form<Fields extends FormFieldDefinitions = FormFieldDefinit
114
117
  return this.validate();
115
118
  }
116
119
 
120
+ public on(event: string, listener: FormListener): () => void {
121
+ this._listeners[event] ??= [];
122
+ this._listeners[event]?.push(listener);
123
+
124
+ return () => this.off(event, listener);
125
+ }
126
+
127
+ public off(event: string, listener: FormListener): void {
128
+ arrayRemove(this._listeners[event] ?? [], listener);
129
+ }
130
+
131
+ public async focus(input: string): Promise<void> {
132
+ await nextTick();
133
+
134
+ this._listeners['focus']?.forEach((listener) => listener(input));
135
+ }
136
+
117
137
  protected __get(property: string): unknown {
118
138
  if (!(property in this._fields)) {
119
139
  return super.__get(property);
package/src/lang/Lang.ts CHANGED
@@ -1,10 +1,11 @@
1
- import { facade, toString } from '@noeldemartin/utils';
1
+ import { facade } from '@noeldemartin/utils';
2
2
 
3
3
  import App from '@/services/App';
4
4
  import Service from '@/services/Service';
5
5
 
6
6
  export interface LangProvider {
7
7
  translate(key: string, parameters?: Record<string, unknown>): string;
8
+ translateWithDefault(key: string, defaultMessage: string, parameters?: Record<string, unknown>): string;
8
9
  }
9
10
 
10
11
  export class LangService extends Service {
@@ -21,6 +22,12 @@ export class LangService extends Service {
21
22
 
22
23
  return key;
23
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
+ },
24
31
  };
25
32
  }
26
33
 
@@ -32,27 +39,8 @@ export class LangService extends Service {
32
39
  return this.provider.translate(key, parameters) ?? key;
33
40
  }
34
41
 
35
- public translateWithDefault(key: string, defaultMessage: string): string;
36
- public translateWithDefault(key: string, parameters: Record<string, unknown>, defaultMessage: string): string;
37
- public translateWithDefault(
38
- key: string,
39
- defaultMessageOrParameters?: string | Record<string, unknown>,
40
- defaultMessage?: string,
41
- ): string {
42
- defaultMessage ??= defaultMessageOrParameters as string;
43
-
44
- const parameters = typeof defaultMessageOrParameters === 'string' ? {} : defaultMessageOrParameters ?? {};
45
- const message = this.provider.translate(key, parameters) ?? key;
46
-
47
- if (message === key) {
48
- return Object.entries(parameters).reduce(
49
- (renderedMessage, [name, value]) =>
50
- renderedMessage.replace(new RegExp(`\\{\\s*${name}\\s*\\}`, 'g'), toString(value)),
51
- defaultMessage,
52
- );
53
- }
54
-
55
- return message;
42
+ public translateWithDefault(key: string, defaultMessage: string, parameters: Record<string, unknown> = {}): string {
43
+ return this.provider.translateWithDefault(key, defaultMessage, parameters);
56
44
  }
57
45
 
58
46
  }
package/src/main.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from './bootstrap';
2
2
  export * from './components';
3
+ export * from './directives';
3
4
  export * from './errors';
4
5
  export * from './forms';
5
6
  export * from './jobs';
@@ -6,6 +6,8 @@ export interface EventsPayload {}
6
6
  export interface EventListenerOptions {
7
7
  priority: number;
8
8
  }
9
+ export type AerogelGlobalEvents = Partial<{ [Event in EventWithoutPayload]: () => unknown }> &
10
+ Partial<{ [Event in EventWithPayload]: EventListener<EventsPayload[Event]> }>;
9
11
 
10
12
  export type EventListener<T = unknown> = (payload: T) => unknown;
11
13
  export type UnknownEvent<T> = T extends keyof EventsPayload ? never : T;
@@ -28,6 +30,11 @@ export class EventsService extends Service {
28
30
 
29
31
  private listeners: Record<string, { priorities: number[]; handlers: Record<number, EventListener[]> }> = {};
30
32
 
33
+ protected async boot(): Promise<void> {
34
+ Object.entries(globalThis.__aerogelEvents__ ?? {}).forEach(([event, listener]) =>
35
+ this.on(event as string, listener as EventListener));
36
+ }
37
+
31
38
  public emit<Event extends EventWithoutPayload>(event: Event): Promise<void>;
32
39
  public emit<Event extends EventWithPayload>(event: Event, payload: EventsPayload[Event]): Promise<void>;
33
40
  public emit<Event extends string>(event: UnknownEvent<Event>, payload?: unknown): Promise<void>;
@@ -141,3 +148,8 @@ export class EventsService extends Service {
141
148
  }
142
149
 
143
150
  export default facade(EventsService);
151
+
152
+ declare global {
153
+ // eslint-disable-next-line no-var
154
+ var __aerogelEvents__: AerogelGlobalEvents | undefined;
155
+ }
@@ -1,6 +1,11 @@
1
+ import type { GetClosureArgs } from '@noeldemartin/utils';
2
+
3
+ import Events from '@/services/Events';
1
4
  import { definePlugin } from '@/plugins';
2
5
 
3
- export interface AerogelTestingRuntime {}
6
+ export interface AerogelTestingRuntime {
7
+ on: (typeof Events)['on'];
8
+ }
4
9
 
5
10
  export default definePlugin({
6
11
  async install() {
@@ -8,12 +13,13 @@ export default definePlugin({
8
13
  return;
9
14
  }
10
15
 
11
- window.testingRuntime = {};
16
+ globalThis.testingRuntime = {
17
+ on: ((...args: GetClosureArgs<(typeof Events)['on']>) => Events.on(...args)) as (typeof Events)['on'],
18
+ };
12
19
  },
13
20
  });
14
21
 
15
22
  declare global {
16
- interface Window {
17
- testingRuntime?: AerogelTestingRuntime;
18
- }
23
+ // eslint-disable-next-line no-var
24
+ var testingRuntime: AerogelTestingRuntime | undefined;
19
25
  }
package/src/ui/UI.ts CHANGED
@@ -167,7 +167,7 @@ export class UIService extends Service {
167
167
  const snackbar: Snackbar = {
168
168
  id: uuid(),
169
169
  properties: { message, ...options },
170
- component: options.component ?? markRaw(this.requireComponent(UIComponents.Snackbar)),
170
+ component: markRaw(options.component ?? this.requireComponent(UIComponents.Snackbar)),
171
171
  };
172
172
 
173
173
  this.setState('snackbars', this.snackbars.concat(snackbar));
@@ -274,10 +274,12 @@ export default facade(UIService);
274
274
 
275
275
  declare module '@/services/Events' {
276
276
  export interface EventsPayload {
277
- 'modal-will-close': { modal: Modal; result?: unknown };
278
- 'modal-closed': { modal: Modal; result?: unknown };
279
277
  'close-modal': { id: string; result?: unknown };
280
278
  'hide-modal': { id: string };
279
+ 'hide-overlays-backdrop': void;
280
+ 'modal-closed': { modal: Modal; result?: unknown };
281
+ 'modal-will-close': { modal: Modal; result?: unknown };
281
282
  'show-modal': { id: string };
283
+ 'show-overlays-backdrop': void;
282
284
  }
283
285
  }