@aerogel/core 0.1.1 → 0.1.2-next.0532ffc3845be5e86d770e8e823f5216ef34dfbf

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 (70) hide show
  1. package/dist/aerogel-core.d.ts +539 -317
  2. package/dist/aerogel-core.js +1938 -1546
  3. package/dist/aerogel-core.js.map +1 -1
  4. package/package.json +4 -2
  5. package/src/bootstrap/index.ts +2 -1
  6. package/src/components/AppLayout.vue +1 -1
  7. package/src/components/AppOverlays.vue +3 -2
  8. package/src/components/contracts/AlertModal.ts +1 -1
  9. package/src/components/contracts/Button.ts +1 -1
  10. package/src/components/contracts/Combobox.ts +5 -0
  11. package/src/components/contracts/ConfirmModal.ts +5 -2
  12. package/src/components/contracts/Modal.ts +8 -3
  13. package/src/components/contracts/PromptModal.ts +6 -2
  14. package/src/components/contracts/Select.ts +98 -4
  15. package/src/components/contracts/Toast.ts +1 -1
  16. package/src/components/contracts/index.ts +1 -0
  17. package/src/components/headless/HeadlessInputInput.vue +27 -5
  18. package/src/components/headless/HeadlessModal.vue +6 -34
  19. package/src/components/headless/HeadlessModalContent.vue +5 -12
  20. package/src/components/headless/HeadlessSelect.vue +10 -91
  21. package/src/components/headless/HeadlessSelectOption.vue +1 -5
  22. package/src/components/index.ts +1 -1
  23. package/src/components/ui/AdvancedOptions.vue +4 -13
  24. package/src/components/ui/Button.vue +1 -0
  25. package/src/components/ui/Combobox.vue +94 -0
  26. package/src/components/ui/ComboboxLabel.vue +29 -0
  27. package/src/components/ui/ComboboxOption.vue +46 -0
  28. package/src/components/ui/ComboboxOptions.vue +71 -0
  29. package/src/components/ui/ComboboxTrigger.vue +67 -0
  30. package/src/components/ui/ConfirmModal.vue +7 -2
  31. package/src/components/ui/Details.vue +33 -0
  32. package/src/components/ui/Form.vue +1 -1
  33. package/src/components/ui/Input.vue +12 -4
  34. package/src/components/ui/LoadingModal.vue +1 -2
  35. package/src/components/ui/Modal.vue +53 -33
  36. package/src/components/ui/ProgressBar.vue +16 -2
  37. package/src/components/ui/PromptModal.vue +7 -2
  38. package/src/components/ui/Select.vue +2 -0
  39. package/src/components/ui/SelectTrigger.vue +13 -2
  40. package/src/components/ui/SettingsModal.vue +1 -1
  41. package/src/components/ui/Toast.vue +1 -0
  42. package/src/components/ui/index.ts +6 -1
  43. package/src/components/vue/Provide.vue +11 -0
  44. package/src/components/vue/index.ts +1 -0
  45. package/src/directives/index.ts +7 -7
  46. package/src/errors/Errors.ts +4 -0
  47. package/src/errors/index.ts +5 -0
  48. package/src/forms/FormController.test.ts +4 -4
  49. package/src/forms/FormController.ts +23 -13
  50. package/src/forms/index.ts +11 -0
  51. package/src/forms/utils.ts +36 -17
  52. package/src/forms/validation.ts +5 -1
  53. package/src/index.css +10 -0
  54. package/src/jobs/Job.ts +1 -1
  55. package/src/plugins/index.ts +1 -3
  56. package/src/services/App.state.ts +1 -0
  57. package/src/services/App.ts +4 -0
  58. package/src/services/index.ts +7 -0
  59. package/src/testing/index.ts +1 -2
  60. package/src/ui/UI.state.ts +0 -11
  61. package/src/ui/UI.ts +43 -125
  62. package/src/ui/index.ts +1 -0
  63. package/src/ui/modals.ts +36 -0
  64. package/src/utils/classes.ts +2 -3
  65. package/src/utils/composition/reactiveSet.test.ts +32 -0
  66. package/src/utils/composition/reactiveSet.ts +61 -0
  67. package/src/utils/index.ts +2 -0
  68. package/src/utils/time.ts +7 -0
  69. package/src/components/AppModals.vue +0 -14
  70. package/src/components/ui/ModalContext.vue +0 -31
@@ -0,0 +1,94 @@
1
+ <template>
2
+ <ComboboxRoot
3
+ ignore-filter
4
+ :open
5
+ :reset-search-term-on-blur="false"
6
+ :reset-search-term-on-select="false"
7
+ :model-value="acceptableValue"
8
+ :by="compareOptions"
9
+ @update:model-value="update($event)"
10
+ >
11
+ <Provide name="combobox" :value="combobox">
12
+ <ComboboxLabel />
13
+ <ComboboxTrigger @focus="open = true" @change="open = true" @blur="open = false" />
14
+ <HeadlessSelectError class="mt-2 text-sm text-red-600" />
15
+ <ComboboxOptions :new-input-value @select="open = false" />
16
+ </Provide>
17
+ </ComboboxRoot>
18
+ </template>
19
+
20
+ <script setup lang="ts" generic="T extends Nullable<FormFieldValue>">
21
+ import { ComboboxRoot } from 'reka-ui';
22
+ import { computed, ref, watch } from 'vue';
23
+ import type { AcceptableValue } from 'reka-ui';
24
+ import type { Nullable } from '@noeldemartin/utils';
25
+
26
+ import Provide from '@aerogel/core/components/vue/Provide.vue';
27
+ import { useSelect } from '@aerogel/core/components/contracts/Select';
28
+ import type { AcceptRefs } from '@aerogel/core/utils';
29
+ import type { ComboboxContext } from '@aerogel/core/components/contracts/Combobox';
30
+ import type { SelectEmits, SelectProps } from '@aerogel/core/components/contracts/Select';
31
+ import type { FormFieldValue } from '@aerogel/core/forms';
32
+
33
+ import ComboboxOptions from './ComboboxOptions.vue';
34
+ import ComboboxTrigger from './ComboboxTrigger.vue';
35
+ import ComboboxLabel from './ComboboxLabel.vue';
36
+ import HeadlessSelectError from '../headless/HeadlessSelectError.vue';
37
+
38
+ const emit = defineEmits<SelectEmits<T>>();
39
+ const {
40
+ as = 'div',
41
+ compareOptions = (a, b) => a === b,
42
+ newInputValue,
43
+ ...props
44
+ } = defineProps<SelectProps<T> & { newInputValue?: (value: string) => T }>();
45
+ const {
46
+ expose,
47
+ acceptableValue,
48
+ update: baseUpdate,
49
+ renderOption,
50
+ } = useSelect(
51
+ computed(() => ({ as, compareOptions, ...props })),
52
+ emit,
53
+ );
54
+ const open = ref(false);
55
+ const optionsByLabel = computed(() =>
56
+ Object.fromEntries(expose.options.value?.map((option) => [option.label, option.value]) ?? []));
57
+ const combobox = {
58
+ input: ref(acceptableValue.value ? renderOption(acceptableValue.value as T) : ''),
59
+ preventChange: ref(false),
60
+ $group: ref(null),
61
+ } satisfies AcceptRefs<ComboboxContext>;
62
+
63
+ function update(value: AcceptableValue) {
64
+ combobox.input.value = renderOption(value as T);
65
+
66
+ baseUpdate(value);
67
+ }
68
+
69
+ watch(expose.value, (value) => {
70
+ const newOptionLabel = renderOption(value as T);
71
+
72
+ if (combobox.input.value === newOptionLabel) {
73
+ return;
74
+ }
75
+
76
+ combobox.preventChange.value = true;
77
+ combobox.input.value = newOptionLabel;
78
+ });
79
+
80
+ watch(combobox.input, (value) => {
81
+ const newInputOption = newInputValue ? (newInputValue(value) as AcceptableValue) : value;
82
+ const newInputOptionLabel = renderOption(newInputOption as T);
83
+
84
+ if (newInputOptionLabel in optionsByLabel.value) {
85
+ update(optionsByLabel.value[newInputOptionLabel] as AcceptableValue);
86
+
87
+ return;
88
+ }
89
+
90
+ update(newInputOption);
91
+ });
92
+
93
+ defineExpose(expose);
94
+ </script>
@@ -0,0 +1,29 @@
1
+ <template>
2
+ <Label
3
+ v-if="show"
4
+ :for="select.id"
5
+ :class="renderedClasses"
6
+ v-bind="$props"
7
+ >
8
+ <slot>
9
+ {{ select.label }}
10
+ </slot>
11
+ </Label>
12
+ </template>
13
+
14
+ <script setup lang="ts">
15
+ import { computed, useSlots } from 'vue';
16
+ import { Label } from 'reka-ui';
17
+ import type { LabelProps } from 'reka-ui';
18
+
19
+ import { classes } from '@aerogel/core/utils';
20
+ import { injectReactiveOrFail } from '@aerogel/core/utils/vue';
21
+ import type { SelectExpose } from '@aerogel/core/components/contracts/Select';
22
+
23
+ defineProps<Omit<LabelProps, 'for'>>();
24
+
25
+ const select = injectReactiveOrFail<SelectExpose>('select', '<ComboboxLabel> must be a child of a <Combobox>');
26
+ const slots = useSlots();
27
+ const show = computed(() => !!(select.label || slots.default));
28
+ const renderedClasses = computed(() => classes('block text-sm leading-6 font-medium text-gray-900', select.labelClass));
29
+ </script>
@@ -0,0 +1,46 @@
1
+ <template>
2
+ <ComboboxItem v-bind="$props" class="group p-1 outline-none" @select="onSelect($event)">
3
+ <div
4
+ class="relative flex max-w-[calc(100vw-2rem)] cursor-pointer items-center gap-2 truncate rounded-md px-2 py-1 text-sm select-none *:truncate group-data-[highlighted]:bg-gray-100 group-data-[state=checked]:font-semibold group-data-[state=unchecked]:opacity-50"
5
+ >
6
+ <slot>
7
+ {{ renderedLabel }}
8
+ </slot>
9
+ </div>
10
+ </ComboboxItem>
11
+ </template>
12
+
13
+ <script setup lang="ts">
14
+ import { computed } from 'vue';
15
+ import { ComboboxItem, injectComboboxRootContext } from 'reka-ui';
16
+ import { toString } from '@noeldemartin/utils';
17
+ import type { ComboboxItemProps, SelectItemSelectEvent } from 'reka-ui';
18
+
19
+ import { injectReactiveOrFail } from '@aerogel/core/utils';
20
+ import type { ComboboxContext } from '@aerogel/core/components/contracts/Combobox';
21
+ import type { SelectExpose } from '@aerogel/core/components/contracts/Select';
22
+
23
+ const emit = defineEmits<{ select: [] }>();
24
+
25
+ const { value } = defineProps<ComboboxItemProps>();
26
+ const select = injectReactiveOrFail<SelectExpose>('select', '<ComboboxOption> must be a child of a <Combobox>');
27
+ const rootContext = injectComboboxRootContext();
28
+ const combobox = injectReactiveOrFail<ComboboxContext>('combobox');
29
+ const renderedLabel = computed(() => {
30
+ const itemOption = select.options?.find((option) => option.value === value);
31
+
32
+ return itemOption ? select.renderOption(itemOption.value) : toString(value);
33
+ });
34
+
35
+ function onSelect(event: SelectItemSelectEvent<unknown>) {
36
+ if (rootContext.multiple.value || rootContext.disabled.value) {
37
+ return;
38
+ }
39
+
40
+ event.preventDefault();
41
+ combobox.preventChange = true;
42
+ rootContext.modelValue.value = value;
43
+
44
+ emit('select');
45
+ }
46
+ </script>
@@ -0,0 +1,71 @@
1
+ <template>
2
+ <ComboboxPortal>
3
+ <ComboboxContent
4
+ position="popper"
5
+ :align="select.align"
6
+ :side="select.side"
7
+ :side-offset="4"
8
+ :class="renderedClasses"
9
+ >
10
+ <ComboboxViewport>
11
+ <ComboboxEmpty class="group p-1 outline-none">
12
+ <div
13
+ class="relative flex max-w-[calc(100vw-2rem)] items-center gap-2 truncate rounded-md px-2 py-1 text-sm select-none *:truncate"
14
+ >
15
+ {{ $td('ui.comboboxEmpty', 'No options found') }}
16
+ </div>
17
+ </ComboboxEmpty>
18
+
19
+ <ComboboxGroup ref="$group">
20
+ <ComboboxOption
21
+ v-if="showInputOption"
22
+ :value="newInputValue?.(combobox.input) ?? (combobox.input as AcceptableValue)"
23
+ @select="$emit('select')"
24
+ >
25
+ {{ combobox.input }}
26
+ </ComboboxOption>
27
+ <ComboboxOption
28
+ v-for="option in filteredOptions"
29
+ :key="option.key"
30
+ :value="option.value"
31
+ @select="$emit('select')"
32
+ />
33
+ </ComboboxGroup>
34
+ </ComboboxViewport>
35
+ </ComboboxContent>
36
+ </ComboboxPortal>
37
+ </template>
38
+
39
+ <script setup lang="ts">
40
+ import { ComboboxContent, ComboboxEmpty, ComboboxGroup, ComboboxPortal, ComboboxViewport, useFilter } from 'reka-ui';
41
+ import { computed, useTemplateRef, watch } from 'vue';
42
+ import type { Nullable } from '@noeldemartin/utils';
43
+ import type { AcceptableValue } from 'reka-ui';
44
+
45
+ import { classes, injectReactiveOrFail } from '@aerogel/core/utils';
46
+ import type { FormFieldValue } from '@aerogel/core/forms';
47
+ import type { SelectExpose } from '@aerogel/core/components/contracts/Select';
48
+ import type { ComboboxContext } from '@aerogel/core/components/contracts/Combobox';
49
+
50
+ import ComboboxOption from './ComboboxOption.vue';
51
+
52
+ defineEmits<{ select: [] }>();
53
+
54
+ const { newInputValue } = defineProps<{ newInputValue?: (value: string) => Nullable<FormFieldValue> }>();
55
+ const { contains } = useFilter({ sensitivity: 'base' });
56
+ const select = injectReactiveOrFail<SelectExpose>('select', '<ComboboxOptions> must be a child of a <Combobox>');
57
+ const combobox = injectReactiveOrFail<ComboboxContext>('combobox');
58
+ const $group = useTemplateRef('$group');
59
+ const filteredOptions = computed(
60
+ () => select.options?.filter((option) => contains(option.label, combobox.input)) ?? [],
61
+ );
62
+ const showInputOption = computed(
63
+ () => combobox.input && !filteredOptions.value.some((option) => option.label === combobox.input),
64
+ );
65
+ const renderedClasses = classes(
66
+ 'max-h-(--reka-combobox-content-available-height) min-w-(--reka-combobox-trigger-width)',
67
+ 'z-50 overflow-auto rounded-lg bg-white text-base shadow-lg ring-1 ring-black/5 focus:outline-hidden',
68
+ );
69
+
70
+ watch($group, () => (combobox.$group = $group.value?.$el ?? null));
71
+ </script>
@@ -0,0 +1,67 @@
1
+ <template>
2
+ <ComboboxAnchor class="relative">
3
+ <ComboboxInput
4
+ :id="select.id"
5
+ v-model="combobox.input"
6
+ :placeholder="select.placeholder"
7
+ :class="renderedRootClasses"
8
+ :display-value="select.renderOption"
9
+ :name="select.name"
10
+ @focus="$emit('focus')"
11
+ @blur="onBlur()"
12
+ @keydown.esc="$emit('blur')"
13
+ />
14
+ <div v-if="select?.errors" class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
15
+ <IconExclamationSolid class="size-5 text-red-500" />
16
+ </div>
17
+ </ComboboxAnchor>
18
+ </template>
19
+
20
+ <script setup lang="ts">
21
+ import IconExclamationSolid from '~icons/zondicons/exclamation-solid';
22
+
23
+ import { ComboboxAnchor, ComboboxInput } from 'reka-ui';
24
+ import { computed, watch } from 'vue';
25
+
26
+ import { classes, injectReactiveOrFail } from '@aerogel/core/utils';
27
+ import type { SelectExpose } from '@aerogel/core/components/contracts/Select';
28
+ import type { ComboboxContext } from '@aerogel/core/components/contracts/Combobox';
29
+
30
+ const emit = defineEmits<{ focus: []; change: []; blur: [] }>();
31
+ const select = injectReactiveOrFail<SelectExpose>('select', '<ComboboxTrigger> must be a child of a <Combobox>');
32
+ const combobox = injectReactiveOrFail<ComboboxContext>('combobox');
33
+ const renderedRootClasses = computed(() =>
34
+ classes(
35
+ // eslint-disable-next-line vue/max-len
36
+ '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',
37
+ {
38
+ 'mt-1': select.label,
39
+ 'focus:ring-primary-600': !select.errors,
40
+ 'text-gray-900 shadow-2xs ring-gray-900/10 placeholder:text-gray-400': !select.errors,
41
+ 'pr-10 text-red-900 ring-red-900/10 placeholder:text-red-300 focus:ring-red-500': select.errors,
42
+ },
43
+ ));
44
+
45
+ function onBlur() {
46
+ const elements = Array.from(document.querySelectorAll(':hover'));
47
+
48
+ if (elements.some((element) => combobox.$group?.contains(element))) {
49
+ return;
50
+ }
51
+
52
+ emit('blur');
53
+ }
54
+
55
+ watch(
56
+ () => combobox.input,
57
+ () => {
58
+ if (combobox.preventChange) {
59
+ combobox.preventChange = false;
60
+
61
+ return;
62
+ }
63
+
64
+ emit('change');
65
+ },
66
+ );
67
+ </script>
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <!-- @vue-generic {import('@aerogel/core/ui/UI').ModalExposeResult<ConfirmModalExpose>} -->
2
+ <!-- @vue-generic {import('@aerogel/core/components/contracts/ConfirmModal').ConfirmModalResult} -->
3
3
  <Modal
4
4
  v-slot="{ close }"
5
5
  :title="renderedTitle"
@@ -41,10 +41,15 @@ import Markdown from '@aerogel/core/components/ui/Markdown.vue';
41
41
  import Button from '@aerogel/core/components/ui/Button.vue';
42
42
  import Modal from '@aerogel/core/components/ui/Modal.vue';
43
43
  import { useConfirmModal } from '@aerogel/core/components/contracts/ConfirmModal';
44
- import type { ConfirmModalExpose, ConfirmModalProps } from '@aerogel/core/components/contracts/ConfirmModal';
44
+ import type {
45
+ ConfirmModalEmits,
46
+ ConfirmModalExpose,
47
+ ConfirmModalProps,
48
+ } from '@aerogel/core/components/contracts/ConfirmModal';
45
49
 
46
50
  const { cancelVariant = 'secondary', ...props } = defineProps<ConfirmModalProps>();
47
51
  const { form, renderedTitle, titleHidden, renderedAcceptText, renderedCancelText } = useConfirmModal(props);
48
52
 
53
+ defineEmits<ConfirmModalEmits>();
49
54
  defineExpose<ConfirmModalExpose>();
50
55
  </script>
@@ -0,0 +1,33 @@
1
+ <template>
2
+ <details class="group">
3
+ <summary :class="renderedSummaryClasses">
4
+ <IconCheveronRight class="size-6 transition-transform group-open:rotate-90" />
5
+ <slot name="label">
6
+ <span>{{ label ?? $td('ui.details', 'Details') }}</span>
7
+ </slot>
8
+ </summary>
9
+
10
+ <div :class="renderedContentClasses">
11
+ <slot />
12
+ </div>
13
+ </details>
14
+ </template>
15
+
16
+ <script setup lang="ts">
17
+ import IconCheveronRight from '~icons/zondicons/cheveron-right';
18
+ import { classes } from '@aerogel/core/utils';
19
+ import { type HTMLAttributes, computed } from 'vue';
20
+
21
+ const {
22
+ label = undefined,
23
+ contentClass,
24
+ summaryClass,
25
+ } = defineProps<{ label?: string; contentClass?: HTMLAttributes['class']; summaryClass?: HTMLAttributes['class'] }>();
26
+ const renderedContentClasses = computed(() => classes('pt-2 pl-4', contentClass));
27
+ const renderedSummaryClasses = computed(() =>
28
+ classes(
29
+ '-ml-2 flex w-[max-content] items-center rounded-lg py-2 pr-3 pl-1 max-w-full',
30
+ 'hover:bg-gray-100 focus-visible:outline focus-visible:outline-gray-700',
31
+ summaryClass,
32
+ ));
33
+ </script>
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <form @submit.prevent="form?.submit()">
2
+ <form @submit.prevent="form ? form.submit() : $emit('submit')">
3
3
  <slot />
4
4
  </form>
5
5
  </template>
@@ -13,8 +13,8 @@
13
13
  <IconExclamationSolid class="size-5 text-red-500" />
14
14
  </div>
15
15
  </div>
16
- <HeadlessInputDescription class="mt-2 text-sm text-gray-600" />
17
- <HeadlessInputError class="mt-2 text-sm text-red-600" />
16
+ <HeadlessInputDescription :class="renderedDescriptionClasses" />
17
+ <HeadlessInputError :class="renderedErrorClasses" />
18
18
  </HeadlessInput>
19
19
  </template>
20
20
 
@@ -35,13 +35,21 @@ import type { InputEmits, InputProps } from '@aerogel/core/components/contracts/
35
35
 
36
36
  defineOptions({ inheritAttrs: false });
37
37
  defineEmits<InputEmits>();
38
- const { label, inputClass, wrapperClass, ...props } = defineProps<
39
- InputProps & { inputClass?: HTMLAttributes['class']; wrapperClass?: HTMLAttributes['class'] }
38
+
39
+ const { label, inputClass, wrapperClass, descriptionClass, errorClass, ...props } = defineProps<
40
+ InputProps & {
41
+ inputClass?: HTMLAttributes['class'];
42
+ wrapperClass?: HTMLAttributes['class'];
43
+ descriptionClass?: HTMLAttributes['class'];
44
+ errorClass?: HTMLAttributes['class'];
45
+ }
40
46
  >();
41
47
  const $input = useTemplateRef('$inputRef');
42
48
  const [inputAttrs, rootClasses] = useInputAttrs();
43
49
  const renderedWrapperClasses = computed(() =>
44
50
  classes('relative rounded-md shadow-2xs', { 'mt-1': label }, wrapperClass));
51
+ const renderedDescriptionClasses = computed(() => classes('mt-2 text-sm text-gray-600', descriptionClass));
52
+ const renderedErrorClasses = computed(() => classes('mt-2 text-sm text-red-600', errorClass));
45
53
  const renderedInputClasses = computed(() =>
46
54
  classes(
47
55
  // eslint-disable-next-line vue/max-len
@@ -1,11 +1,10 @@
1
1
  <template>
2
2
  <Modal
3
3
  persistent
4
- class="flex"
5
4
  wrapper-class="w-auto"
6
5
  :title="renderedTitle"
7
6
  :title-hidden
8
- :class="{ 'flex-col-reverse': showProgress, 'items-center justify-center gap-2': !showProgress }"
7
+ :class="{ 'flex-col-reverse': showProgress, 'flex-row items-center justify-center gap-2': !showProgress }"
9
8
  >
10
9
  <ProgressBar
11
10
  v-if="showProgress"
@@ -7,14 +7,20 @@
7
7
  :persistent
8
8
  >
9
9
  <HeadlessModalOverlay
10
- class="fixed inset-0 animate-[fade-in_var(--tw-duration)_ease-in-out] transition-opacity duration-300 will-change-[opacity]"
10
+ class="fixed inset-0 transition-opacity duration-300 will-change-[opacity]"
11
11
  :class="{
12
- 'bg-black/30': context.childIndex === 1,
13
- 'opacity-0': context.childIndex === 1 && context.modal.closing,
12
+ 'animate-[fade-in_var(--tw-duration)_ease-in-out]': !hasRenderedModals,
13
+ 'bg-black/30': firstVisibleModal?.id === id || (!firstVisibleModal && modals[0]?.id === id),
14
+ 'opacity-0': !firstVisibleModal,
15
+ hidden: renderFullscreen,
14
16
  }"
15
17
  />
16
18
  <HeadlessModalContent v-bind="contentProps" :class="renderedWrapperClass">
17
- <div v-if="!persistent && !closeHidden" class="absolute top-0 right-0 hidden pt-3.5 pr-2.5 sm:block">
19
+ <div
20
+ v-if="!persistent && !closeHidden"
21
+ class="absolute top-0 right-0 pt-3.5 pr-2.5"
22
+ :class="{ 'hidden sm:block': !renderFullscreen }"
23
+ >
18
24
  <button
19
25
  type="button"
20
26
  class="clickable z-10 rounded-full p-2.5 text-gray-400 hover:text-gray-500"
@@ -52,14 +58,13 @@
52
58
  </HeadlessModal>
53
59
  </template>
54
60
 
55
- <script setup lang="ts" generic="T = void">
61
+ <script lang="ts">
56
62
  import IconClose from '~icons/zondicons/close';
57
63
 
58
- import { after } from '@noeldemartin/utils';
59
- import { computed } from 'vue';
60
64
  import { useForwardExpose } from 'reka-ui';
65
+ import { computed, onMounted } from 'vue';
61
66
  import type { ComponentPublicInstance, HTMLAttributes, Ref } from 'vue';
62
- import type { Nullable } from '@noeldemartin/utils';
67
+ import { type Nullable, after } from '@noeldemartin/utils';
63
68
 
64
69
  import Markdown from '@aerogel/core/components/ui/Markdown.vue';
65
70
  import HeadlessModal from '@aerogel/core/components/headless/HeadlessModal.vue';
@@ -69,13 +74,17 @@ import HeadlessModalOverlay from '@aerogel/core/components/headless/HeadlessModa
69
74
  import HeadlessModalTitle from '@aerogel/core/components/headless/HeadlessModalTitle.vue';
70
75
  import UI from '@aerogel/core/ui/UI';
71
76
  import { classes } from '@aerogel/core/utils/classes';
72
- import { injectReactiveOrFail } from '@aerogel/core/utils/vue';
73
- import { useEvent } from '@aerogel/core/utils/composition/events';
77
+ import { reactiveSet } from '@aerogel/core/utils';
78
+ import { injectModal, modals, useModal } from '@aerogel/core/ui/modals';
79
+ import type { ModalController } from '@aerogel/core/ui/modals';
74
80
  import type { AcceptRefs } from '@aerogel/core/utils/vue';
75
81
  import type { ModalExpose, ModalProps, ModalSlots } from '@aerogel/core/components/contracts/Modal';
76
- import type { UIModalContext } from '@aerogel/core/ui/UI';
77
82
 
78
- type HeadlessModalInstance = ComponentPublicInstance & ModalExpose<T>;
83
+ const renderedModals = reactiveSet<ModalController>();
84
+ </script>
85
+
86
+ <script setup lang="ts" generic="T = void">
87
+ type HeadlessModalInstance = ComponentPublicInstance & ModalExpose;
79
88
 
80
89
  const {
81
90
  class: contentClass = '',
@@ -85,6 +94,8 @@ const {
85
94
  description,
86
95
  persistent,
87
96
  closeHidden,
97
+ fullscreen,
98
+ fullscreenOnMobile,
88
99
  ...props
89
100
  } = defineProps<
90
101
  ModalProps & {
@@ -95,37 +106,46 @@ const {
95
106
  >();
96
107
 
97
108
  defineSlots<ModalSlots<T>>();
98
- defineExpose<AcceptRefs<ModalExpose<T>>>({
99
- close: async (result) => $modal.value?.close(result),
109
+ defineExpose<AcceptRefs<ModalExpose>>({
100
110
  $content: computed(() => $modal.value?.$content),
101
111
  });
102
112
 
103
113
  const { forwardRef, currentRef } = useForwardExpose<HeadlessModalInstance>();
114
+ const { id, visible } = useModal();
104
115
  const $modal = currentRef as Ref<Nullable<HeadlessModalInstance>>;
105
- const context = injectReactiveOrFail<UIModalContext>('modal');
106
- const inForeground = computed(() => !context.modal.closing && context.childIndex === UI.openModals.length);
116
+ const modal = injectModal();
117
+ const inForeground = computed(
118
+ () => visible.value && modals.value.toReversed().find((modal) => modal.visible.value)?.id === id.value,
119
+ );
120
+ const firstVisibleModal = computed(() => modals.value.find((modal) => modal.visible.value));
121
+ const hasRenderedModals = computed(() => modals.value.some((modal) => renderedModals.has(modal)));
107
122
  const contentProps = computed(() => (description ? {} : { 'aria-describedby': undefined }));
108
123
  const renderedContentClass = computed(() =>
109
- classes('max-h-[90vh] overflow-auto px-4 pb-4', { 'pt-4': !title || titleHidden }, contentClass));
124
+ classes(
125
+ 'overflow-auto px-4 pb-4 flex flex-col flex-1',
126
+ { 'pt-4': !title || titleHidden, 'max-h-[90vh]': !renderFullscreen.value },
127
+ contentClass,
128
+ ));
129
+ const renderFullscreen = computed(() => fullscreen || (fullscreenOnMobile && UI.mobile));
110
130
  const renderedWrapperClass = computed(() =>
111
131
  classes(
112
- 'isolate fixed top-1/2 left-1/2 z-50 w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2',
113
- 'overflow-hidden rounded-lg bg-white text-left shadow-xl sm:max-w-lg',
114
- 'animate-[fade-in_var(--tw-duration)_ease-in-out,grow_var(--tw-duration)_ease-in-out]',
115
- 'transition-[scale,opacity] will-change-[scale,opacity] duration-300',
116
- {
117
- 'scale-50 opacity-0': !inForeground.value,
118
- 'scale-100 opacity-100': inForeground.value,
119
- },
132
+ 'isolate fixed z-50 flex flex-col overflow-hidden bg-white text-left duration-300',
133
+ renderFullscreen.value
134
+ ? [
135
+ 'inset-0 transition-[transform,translate] will-change-[transform,translate]',
136
+ renderedModals.has(modal.value) || 'animate-[slide-in_var(--tw-duration)_ease-in-out]',
137
+ inForeground.value ? 'translate-y-0' : 'translate-y-full',
138
+ ]
139
+ : [
140
+ 'top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full',
141
+ 'max-w-[calc(100%-2rem)] rounded-lg shadow-xl sm:max-w-lg',
142
+ 'transition-[scale,opacity] will-change-[scale,opacity]',
143
+ renderedModals.has(modal.value) ||
144
+ 'animate-[fade-in_var(--tw-duration)_ease-in-out,grow_var(--tw-duration)_ease-in-out]',
145
+ inForeground.value ? 'scale-100 opacity-100' : 'scale-50 opacity-0',
146
+ ],
120
147
  wrapperClass,
121
148
  ));
122
149
 
123
- useEvent('modal-will-close', async ({ modal: { id } }) => {
124
- if (id !== context.modal.id) {
125
- return;
126
- }
127
-
128
- // Wait for transitions to finish
129
- await after({ ms: 300 });
130
- });
150
+ onMounted(() => after(500).then(() => renderedModals.add(modal.value)));
131
151
  </script>
@@ -1,6 +1,11 @@
1
1
  <template>
2
- <div class="mt-1 h-2 w-full overflow-hidden rounded-full bg-gray-200">
2
+ <div class="relative mt-1 h-2 w-full overflow-hidden rounded-full bg-gray-200">
3
3
  <div :class="filledClasses" :style="`transform:translateX(-${(1 - renderedProgress) * 100}%)`" />
4
+ <div
5
+ v-if="overflowWidthPercentage"
6
+ :class="overflowClasses"
7
+ :style="{ width: `${overflowWidthPercentage}%` }"
8
+ />
4
9
  <span class="sr-only">
5
10
  {{
6
11
  $td('ui.progress', '{progress}% complete', {
@@ -18,8 +23,9 @@ import { classes } from '@aerogel/core/utils/classes';
18
23
  import type Job from '@aerogel/core/jobs/Job';
19
24
  import type { Falsifiable } from '@aerogel/core/utils';
20
25
 
21
- const { filledClass, progress, job } = defineProps<{
26
+ const { filledClass, overflowClass, progress, job } = defineProps<{
22
27
  filledClass?: string;
28
+ overflowClass?: string;
23
29
  progress?: number;
24
30
  job?: Falsifiable<Job>;
25
31
  }>();
@@ -28,6 +34,12 @@ let cleanup: Falsifiable<Function>;
28
34
  const jobProgress = ref(0);
29
35
  const filledClasses = computed(() =>
30
36
  classes('size-full transition-transform duration-500 rounded-r-full ease-linear bg-primary-600', filledClass));
37
+ const overflowClasses = computed(() =>
38
+ classes(
39
+ 'absolute inset-y-0 right-0 size-full rounded-r-full',
40
+ 'bg-primary-900 transition-[width] duration-500 ease-linear',
41
+ overflowClass,
42
+ ));
31
43
  const renderedProgress = computed(() => {
32
44
  if (typeof progress === 'number') {
33
45
  return progress;
@@ -35,6 +47,8 @@ const renderedProgress = computed(() => {
35
47
 
36
48
  return jobProgress.value;
37
49
  });
50
+ const overflowWidthPercentage = computed(() =>
51
+ renderedProgress.value > 1 ? 100 * ((renderedProgress.value - 1) / renderedProgress.value) : null);
38
52
 
39
53
  watch(
40
54
  () => job,
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <!-- @vue-generic {import('@aerogel/core/ui/UI').ModalExposeResult<PromptModalExpose>} -->
2
+ <!-- @vue-generic {import('@aerogel/core/components/contracts/PromptModal').PromptModalResult} -->
3
3
  <Modal v-slot="{ close }" :title="renderedTitle" persistent>
4
4
  <Form :form @submit="close(form.draft)">
5
5
  <Markdown v-if="renderedMessage" :text="renderedMessage" />
@@ -29,10 +29,15 @@ import Form from '@aerogel/core/components/ui/Form.vue';
29
29
  import Input from '@aerogel/core/components/ui/Input.vue';
30
30
  import Modal from '@aerogel/core/components/ui/Modal.vue';
31
31
  import { usePromptModal } from '@aerogel/core/components/contracts/PromptModal';
32
- import type { PromptModalExpose, PromptModalProps } from '@aerogel/core/components/contracts/PromptModal';
32
+ import type {
33
+ PromptModalEmits,
34
+ PromptModalExpose,
35
+ PromptModalProps,
36
+ } from '@aerogel/core/components/contracts/PromptModal';
33
37
 
34
38
  const { cancelVariant = 'secondary', ...props } = defineProps<PromptModalProps>();
35
39
  const { form, renderedTitle, renderedMessage, renderedAcceptText, renderedCancelText } = usePromptModal(props);
36
40
 
41
+ defineEmits<PromptModalEmits>();
37
42
  defineExpose<PromptModalExpose>();
38
43
  </script>
@@ -3,6 +3,7 @@
3
3
  <SelectLabel />
4
4
  <slot>
5
5
  <SelectTrigger />
6
+ <HeadlessSelectError class="mt-2 text-sm text-red-600" />
6
7
  <SelectOptions />
7
8
  </slot>
8
9
  </HeadlessSelect>
@@ -19,6 +20,7 @@ import type { FormFieldValue } from '@aerogel/core/forms';
19
20
  import SelectLabel from './SelectLabel.vue';
20
21
  import SelectOptions from './SelectOptions.vue';
21
22
  import SelectTrigger from './SelectTrigger.vue';
23
+ import HeadlessSelectError from '../headless/HeadlessSelectError.vue';
22
24
 
23
25
  defineProps<SelectProps<T>>();
24
26
  defineEmits<SelectEmits<T>>();