@xelqun/forms 0.1.0 → 0.2.0

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 (76) hide show
  1. package/README.md +46 -61
  2. package/package.json +32 -2
  3. package/resources/css/xelqun.css +121 -0
  4. package/resources/js/components/XqAction.vue +143 -0
  5. package/resources/js/components/XqFieldSlot.vue +20 -0
  6. package/resources/js/components/XqFieldWrapper.vue +74 -30
  7. package/resources/js/components/XqForm.vue +36 -9
  8. package/resources/js/components/XqNode.vue +8 -1
  9. package/resources/js/components/XqUnknownNode.vue +2 -1
  10. package/resources/js/components/defaults.ts +36 -0
  11. package/resources/js/components/fields/XqBlockPicker.vue +68 -0
  12. package/resources/js/components/fields/XqCheckboxField.vue +35 -9
  13. package/resources/js/components/fields/XqCheckboxListField.vue +115 -0
  14. package/resources/js/components/fields/XqCodeEditorField.vue +92 -0
  15. package/resources/js/components/fields/XqColorPickerField.vue +156 -0
  16. package/resources/js/components/fields/XqDatePickerField.vue +149 -53
  17. package/resources/js/components/fields/XqFileUploadField.vue +524 -165
  18. package/resources/js/components/fields/XqImageEditor.vue +165 -0
  19. package/resources/js/components/fields/XqKeyValueField.vue +168 -0
  20. package/resources/js/components/fields/XqMarkdownEditorField.vue +245 -0
  21. package/resources/js/components/fields/XqRadioField.vue +9 -5
  22. package/resources/js/components/fields/XqRepeaterField.vue +314 -79
  23. package/resources/js/components/fields/XqRepeaterRowActions.vue +101 -0
  24. package/resources/js/components/fields/XqRichEditorField.vue +280 -0
  25. package/resources/js/components/fields/XqSelectField.vue +29 -12
  26. package/resources/js/components/fields/XqSliderField.vue +47 -0
  27. package/resources/js/components/fields/XqTagsInputField.vue +127 -0
  28. package/resources/js/components/fields/XqTextInputField.vue +121 -12
  29. package/resources/js/components/fields/XqTextareaField.vue +39 -16
  30. package/resources/js/components/fields/XqToggleButtonsField.vue +75 -0
  31. package/resources/js/components/fields/XqToggleField.vue +33 -9
  32. package/resources/js/components/layouts/XqActions.vue +19 -0
  33. package/resources/js/components/layouts/XqBlock.vue +52 -0
  34. package/resources/js/components/layouts/XqCallout.vue +46 -0
  35. package/resources/js/components/layouts/XqEmptyState.vue +37 -0
  36. package/resources/js/components/layouts/XqFlex.vue +23 -0
  37. package/resources/js/components/layouts/XqFusedGroup.vue +48 -0
  38. package/resources/js/components/layouts/XqIconNode.vue +32 -0
  39. package/resources/js/components/layouts/XqImageNode.vue +30 -0
  40. package/resources/js/components/layouts/XqSection.vue +3 -1
  41. package/resources/js/components/layouts/XqTabsLayout.vue +3 -21
  42. package/resources/js/components/layouts/XqTextNode.vue +49 -0
  43. package/resources/js/components/layouts/XqUnorderedList.vue +18 -0
  44. package/resources/js/components/layouts/XqWizard.vue +168 -0
  45. package/resources/js/composables/context.ts +14 -0
  46. package/resources/js/composables/useField.ts +21 -3
  47. package/resources/js/composables/usePanelErrors.ts +37 -0
  48. package/resources/js/composables/useSortable.ts +70 -0
  49. package/resources/js/composables/useUnsavedChangesAlert.ts +48 -0
  50. package/resources/js/core/dates.ts +38 -0
  51. package/resources/js/core/images.ts +110 -0
  52. package/resources/js/core/log.ts +1 -1
  53. package/resources/js/core/markdown.ts +61 -0
  54. package/resources/js/core/mask.ts +42 -0
  55. package/resources/js/core/patch.ts +6 -3
  56. package/resources/js/core/paths.ts +11 -14
  57. package/resources/js/core/store.ts +515 -25
  58. package/resources/js/core/translate.ts +17 -0
  59. package/resources/js/core/validation.ts +126 -0
  60. package/resources/js/index.ts +4 -3
  61. package/resources/js/lib/utils.ts +15 -0
  62. package/resources/js/plugin.ts +103 -6
  63. package/resources/js/schema/types.ts +102 -0
  64. package/resources/js/testing.ts +50 -0
  65. package/resources/js/ui/XqCalendar.vue +4 -0
  66. package/resources/js/ui/XqCombobox.vue +89 -32
  67. package/resources/js/ui/XqConfirmDialog.vue +58 -0
  68. package/resources/js/ui/XqDialog.vue +46 -0
  69. package/resources/js/ui/XqIcon.vue +28 -0
  70. package/resources/js/ui/XqSelect.vue +38 -18
  71. package/resources/js/ui/XqTabs.vue +1 -1
  72. package/resources/js/ui/XqTimeField.vue +66 -0
  73. package/resources/js/ui/classes.ts +33 -0
  74. package/resources/js/ui/icons.ts +95 -0
  75. package/resources/js/ui/index.ts +5 -0
  76. package/resources/js/ui/options.ts +46 -0
@@ -1,18 +1,38 @@
1
1
  <script setup lang="ts">
2
- import { computed, ref, watch } from 'vue';
2
+ import { Check, Copy, Eye, EyeOff, LoaderCircle } from 'lucide-vue-next';
3
+ import { computed, onBeforeUnmount, ref, watch } from 'vue';
3
4
  import { useField } from '../../composables/useField';
5
+ import { applyMask } from '../../core/mask';
4
6
  import { isNumeric, toNumber } from '../../core/values';
5
7
  import { cn } from '../../lib/utils';
6
8
  import type { SchemaNode } from '../../schema/types';
7
- import { controlClass } from '../../ui/classes';
9
+ import { controlClass, spinnerClass } from '../../ui/classes';
10
+ import XqIcon from '../../ui/XqIcon.vue';
8
11
  import XqFieldWrapper from '../XqFieldWrapper.vue';
12
+ import { useTranslator } from '../../composables/context';
9
13
 
14
+ const t = useTranslator();
10
15
  const props = defineProps<{ node: SchemaNode }>();
11
- const { path, value, error, describedBy, required, disabled, id, onBlur } = useField(props.node);
16
+ const {
17
+ path,
18
+ value,
19
+ error,
20
+ placeholder,
21
+ describedBy,
22
+ showLoading,
23
+ required,
24
+ disabled,
25
+ readOnly,
26
+ id,
27
+ onBlur,
28
+ } = useField(props.node);
12
29
  const options = computed(() => props.node.props ?? {});
30
+ const inputDisabled = computed(() => disabled.value && !readOnly.value);
13
31
 
14
32
  function format(next: unknown): string {
15
- return next === null || next === undefined ? '' : String(next);
33
+ const value = next === null || next === undefined ? '' : String(next);
34
+
35
+ return options.value.mask ? applyMask(value, options.value.mask) : value;
16
36
  }
17
37
 
18
38
  /**
@@ -32,7 +52,20 @@ watch(value, (next) => {
32
52
  });
33
53
 
34
54
  function onInput(event: Event): void {
35
- text.value = (event.target as HTMLInputElement).value;
55
+ const input = event.target as HTMLInputElement;
56
+
57
+ if (options.value.mask) {
58
+ const typed = input.value;
59
+ const caret = input.selectionStart ?? typed.length;
60
+
61
+ input.value = applyMask(typed, options.value.mask);
62
+
63
+ const position = Math.max(0, caret + input.value.length - typed.length);
64
+
65
+ input.setSelectionRange?.(position, position);
66
+ }
67
+
68
+ text.value = input.value;
36
69
 
37
70
  if (!options.value.numeric) {
38
71
  value.value = text.value;
@@ -44,20 +77,54 @@ function onInput(event: Event): void {
44
77
  value.value = trimmed === '' ? null : isNumeric(trimmed) ? toNumber(trimmed) : trimmed;
45
78
  }
46
79
 
47
- const inputType = computed(() =>
48
- options.value.numeric ? 'text' : (options.value.inputType ?? 'text'),
49
- );
80
+ const revealed = ref(false);
81
+ const inputType = computed(() => {
82
+ if (options.value.numeric || (revealed.value && options.value.revealable)) {
83
+ return 'text';
84
+ }
85
+
86
+ return options.value.inputType ?? 'text';
87
+ });
88
+
89
+ const copied = ref(false);
90
+ let copiedTimer: ReturnType<typeof setTimeout> | null = null;
91
+
92
+ async function copy(): Promise<void> {
93
+ if (!navigator.clipboard) {
94
+ return;
95
+ }
96
+
97
+ try {
98
+ await navigator.clipboard.writeText(text.value);
99
+ } catch {
100
+ return;
101
+ }
102
+
103
+ copied.value = true;
104
+
105
+ if (copiedTimer) {
106
+ clearTimeout(copiedTimer);
107
+ }
108
+
109
+ copiedTimer = setTimeout(() => (copied.value = false), options.value.copyable?.duration);
110
+ }
111
+
112
+ onBeforeUnmount(() => {
113
+ if (copiedTimer) {
114
+ clearTimeout(copiedTimer);
115
+ }
116
+ });
50
117
  </script>
51
118
 
52
119
  <template>
53
- <XqFieldWrapper :node="node" :id="id" :error="error" :required="required">
120
+ <XqFieldWrapper :node="node" :id="id" :error="error" :required="required" control-shows-loading>
54
121
  <div
55
122
  :class="
56
123
  cn(
57
124
  controlClass,
58
125
  'h-9 flex items-center overflow-hidden focus-within:border-xq-ring focus-within:ring-[3px] focus-within:ring-xq-ring/25',
59
126
  error && 'border-xq-destructive',
60
- disabled && 'bg-xq-muted/60',
127
+ inputDisabled && 'bg-xq-muted/60',
61
128
  )
62
129
  "
63
130
  >
@@ -67,17 +134,24 @@ const inputType = computed(() =>
67
134
  >
68
135
  {{ options.prefix }}
69
136
  </span>
137
+ <XqIcon
138
+ v-if="options.prefixIcon"
139
+ :name="options.prefixIcon"
140
+ class="ml-3 text-xq-muted-foreground"
141
+ />
70
142
  <input
71
143
  :id="id"
72
144
  :name="path"
73
145
  :value="text"
74
146
  :type="inputType"
75
147
  :inputmode="options.numeric ? 'decimal' : undefined"
76
- :placeholder="node.placeholder ?? undefined"
77
- :disabled="disabled"
148
+ :placeholder="placeholder ?? undefined"
149
+ :disabled="inputDisabled"
150
+ :readonly="readOnly"
78
151
  :required="required"
79
152
  :maxlength="options.maxLength ?? undefined"
80
153
  :autocomplete="options.autocomplete ?? undefined"
154
+ :list="options.datalist ? `${id}-list` : undefined"
81
155
  :aria-invalid="error ? true : undefined"
82
156
  :aria-describedby="describedBy"
83
157
  :class="
@@ -89,6 +163,41 @@ const inputType = computed(() =>
89
163
  @input="onInput"
90
164
  @blur="onBlur"
91
165
  />
166
+ <datalist v-if="options.datalist" :id="`${id}-list`">
167
+ <option
168
+ v-for="suggestion in options.datalist"
169
+ :key="suggestion"
170
+ :value="suggestion"
171
+ />
172
+ </datalist>
173
+ <LoaderCircle v-if="showLoading" :class="cn(spinnerClass, 'mr-3')" aria-hidden="true" />
174
+ <XqIcon
175
+ v-if="options.suffixIcon"
176
+ :name="options.suffixIcon"
177
+ class="mr-3 text-xq-muted-foreground"
178
+ />
179
+ <button
180
+ v-if="options.revealable"
181
+ type="button"
182
+ class="mr-2 size-6 rounded-sm flex cursor-pointer items-center justify-center text-xq-muted-foreground hover:text-xq-foreground"
183
+ :aria-label="revealed ? t('Hide password') : t('Show password')"
184
+ :aria-pressed="revealed"
185
+ @click="revealed = !revealed"
186
+ >
187
+ <component :is="revealed ? EyeOff : Eye" class="size-4" />
188
+ </button>
189
+ <button
190
+ v-if="options.copyable"
191
+ type="button"
192
+ class="mr-2 size-6 rounded-sm flex cursor-pointer items-center justify-center text-xq-muted-foreground hover:text-xq-foreground"
193
+ :aria-label="t('Copy')"
194
+ @click="copy"
195
+ >
196
+ <component :is="copied ? Check : Copy" class="size-4" />
197
+ </button>
198
+ <span v-if="options.copyable" role="status" class="sr-only">{{
199
+ copied ? options.copyable.message : ''
200
+ }}</span>
92
201
  <span
93
202
  v-if="options.suffix"
94
203
  class="px-3 text-sm flex h-full items-center border-l border-xq-input bg-xq-muted text-xq-muted-foreground"
@@ -1,28 +1,51 @@
1
1
  <script setup lang="ts">
2
+ import { LoaderCircle } from 'lucide-vue-next';
3
+ import { computed } from 'vue';
2
4
  import { useField } from '../../composables/useField';
5
+ import { cn } from '../../lib/utils';
3
6
  import type { SchemaNode } from '../../schema/types';
7
+ import { spinnerClass } from '../../ui/classes';
4
8
  import XqTextarea from '../../ui/XqTextarea.vue';
5
9
  import XqFieldWrapper from '../XqFieldWrapper.vue';
6
10
 
7
11
  const props = defineProps<{ node: SchemaNode }>();
8
- const { path, value, error, describedBy, required, disabled, id, onBlur } = useField<string | null>(
9
- props.node,
10
- );
12
+ const {
13
+ path,
14
+ value,
15
+ error,
16
+ placeholder,
17
+ describedBy,
18
+ showLoading,
19
+ required,
20
+ disabled,
21
+ readOnly,
22
+ id,
23
+ onBlur,
24
+ } = useField<string | null>(props.node);
25
+ const inputDisabled = computed(() => disabled.value && !readOnly.value);
11
26
  </script>
12
27
 
13
28
  <template>
14
- <XqFieldWrapper :node="node" :id="id" :error="error" :required="required">
15
- <XqTextarea
16
- :id="id"
17
- v-model="value"
18
- :name="path"
19
- :rows="node.props?.rows ?? 3"
20
- :maxlength="node.props?.maxLength ?? undefined"
21
- :placeholder="node.placeholder ?? undefined"
22
- :disabled="disabled"
23
- :aria-invalid="error ? true : undefined"
24
- :aria-describedby="describedBy"
25
- @blur="onBlur"
26
- />
29
+ <XqFieldWrapper :node="node" :id="id" :error="error" :required="required" control-shows-loading>
30
+ <div class="relative">
31
+ <XqTextarea
32
+ :id="id"
33
+ v-model="value"
34
+ :name="path"
35
+ :rows="node.props?.rows ?? 3"
36
+ :maxlength="node.props?.maxLength ?? undefined"
37
+ :placeholder="placeholder ?? undefined"
38
+ :disabled="inputDisabled"
39
+ :readonly="readOnly"
40
+ :aria-invalid="error ? true : undefined"
41
+ :aria-describedby="describedBy"
42
+ @blur="onBlur"
43
+ />
44
+ <LoaderCircle
45
+ v-if="showLoading"
46
+ :class="cn(spinnerClass, 'top-2.5 right-2.5 absolute')"
47
+ aria-hidden="true"
48
+ />
49
+ </div>
27
50
  </XqFieldWrapper>
28
51
  </template>
@@ -0,0 +1,75 @@
1
+ <script setup lang="ts">
2
+ import { ToggleGroupItem, ToggleGroupRoot } from 'reka-ui';
3
+ import { computed } from 'vue';
4
+ import { gridStyle } from '../../composables/layout';
5
+ import { useField } from '../../composables/useField';
6
+ import { cn } from '../../lib/utils';
7
+ import type { SchemaNode, SelectOption } from '../../schema/types';
8
+ import XqIcon from '../../ui/XqIcon.vue';
9
+ import XqFieldWrapper from '../XqFieldWrapper.vue';
10
+
11
+ type Value = string | number;
12
+ type ToggleOption = SelectOption & { color?: string | null; icon?: string | null };
13
+
14
+ const PRESSED: Record<string, string> = {
15
+ primary: 'data-[state=on]:bg-xq-primary data-[state=on]:text-xq-primary-foreground',
16
+ success: 'data-[state=on]:bg-xq-success data-[state=on]:text-white',
17
+ danger: 'data-[state=on]:bg-xq-destructive data-[state=on]:text-xq-destructive-foreground',
18
+ warning: 'data-[state=on]:bg-amber-500 data-[state=on]:text-black',
19
+ info: 'data-[state=on]:bg-sky-600 data-[state=on]:text-white',
20
+ };
21
+
22
+ const props = defineProps<{ node: SchemaNode }>();
23
+ const { value, error, label, describedBy, required, disabled, id } = useField<
24
+ Value | Value[] | null
25
+ >(props.node);
26
+ const options = computed<ToggleOption[]>(() => props.node.props?.options ?? []);
27
+ const isMultiple = computed(() => !!props.node.props?.multiple);
28
+ const isGrouped = computed(() => !!props.node.props?.grouped);
29
+ const isInline = computed(() => !!props.node.props?.inline);
30
+ const isGrid = computed(() => !isGrouped.value && !isInline.value);
31
+
32
+ const model = computed<any>({
33
+ get: () => value.value ?? undefined,
34
+ set: (next) => (value.value = next ?? (isMultiple.value ? [] : null)),
35
+ });
36
+ </script>
37
+
38
+ <template>
39
+ <XqFieldWrapper :node="node" :id="id" :label-for="null" :error="error" :required="required">
40
+ <ToggleGroupRoot
41
+ v-model="model"
42
+ :type="isMultiple ? 'multiple' : 'single'"
43
+ :disabled="disabled"
44
+ :aria-labelledby="label ? `${id}-label` : undefined"
45
+ :aria-describedby="describedBy"
46
+ :style="isGrid ? gridStyle(node.props?.columns) : undefined"
47
+ :class="
48
+ cn(
49
+ isGrid && 'xq-grid gap-2',
50
+ isInline && 'gap-2 flex flex-wrap',
51
+ isGrouped && 'shadow-xs inline-flex w-fit rounded-xq-md',
52
+ )
53
+ "
54
+ >
55
+ <ToggleGroupItem
56
+ v-for="option in options"
57
+ :key="String(option.value)"
58
+ :value="option.value"
59
+ :disabled="option.disabled"
60
+ :class="
61
+ cn(
62
+ 'gap-2 h-9 px-3 text-sm font-medium inline-flex cursor-pointer items-center justify-center border border-xq-input bg-xq-background text-xq-foreground transition-colors outline-none hover:bg-xq-accent focus-visible:ring-[3px] focus-visible:ring-xq-ring/25 disabled:cursor-not-allowed disabled:opacity-50',
63
+ isGrouped
64
+ ? 'first:ml-0 -ml-px first:rounded-l-xq-md last:rounded-r-xq-md'
65
+ : 'rounded-xq-md',
66
+ PRESSED[option.color ?? 'primary'],
67
+ )
68
+ "
69
+ >
70
+ <XqIcon v-if="option.icon" :name="option.icon" />
71
+ {{ option.label }}
72
+ </ToggleGroupItem>
73
+ </ToggleGroupRoot>
74
+ </XqFieldWrapper>
75
+ </template>
@@ -1,14 +1,19 @@
1
1
  <script setup lang="ts">
2
2
  import { LoaderCircle } from 'lucide-vue-next';
3
- import { computed } from 'vue';
3
+ import { computed, inject } from 'vue';
4
+ import XqFieldSlot from '../XqFieldSlot.vue';
5
+ import { FUSED_GROUP } from '../../composables/context';
4
6
  import { useField } from '../../composables/useField';
7
+ import { cn } from '../../lib/utils';
5
8
  import type { SchemaNode } from '../../schema/types';
6
9
  import XqLabel from '../../ui/XqLabel.vue';
7
10
  import XqSwitch from '../../ui/XqSwitch.vue';
8
11
 
9
12
  const props = defineProps<{ node: SchemaNode }>();
10
- const { value, error, loading, required, disabled, id } = useField<boolean>(props.node);
11
- const hasHint = computed(() => !!(props.node.props?.description || props.node.hint));
13
+ const { store, value, error, label, hint, loading, showLoading, required, disabled, id } =
14
+ useField<boolean>(props.node);
15
+ const fused = inject(FUSED_GROUP, false);
16
+ const hasHint = computed(() => !!(props.node.props?.description || hint.value));
12
17
  const describedBy = computed(() =>
13
18
  error.value ? `${id}-error` : hasHint.value ? `${id}-hint` : undefined,
14
19
  );
@@ -16,7 +21,10 @@ const describedBy = computed(() =>
16
21
 
17
22
  <template>
18
23
  <div class="gap-2 grid" :data-xq-path="node.path" :aria-busy="loading || undefined">
24
+ <XqFieldSlot :node="node" name="aboveContent" />
19
25
  <div class="gap-3 flex items-start">
26
+ <XqFieldSlot :node="node" name="beforeContent" />
27
+ <XqFieldSlot :node="node" name="prefixAction" />
20
28
  <XqSwitch
21
29
  :id="id"
22
30
  v-model="value"
@@ -25,23 +33,39 @@ const describedBy = computed(() =>
25
33
  :aria-describedby="describedBy"
26
34
  />
27
35
  <div class="gap-1.5 grid">
28
- <XqLabel :for="id" class="gap-0.5 leading-5 cursor-pointer">
29
- {{ node.label }}
36
+ <XqFieldSlot :node="node" name="aboveLabel" />
37
+ <XqFieldSlot :node="node" name="beforeLabel" />
38
+ <XqLabel
39
+ :for="id"
40
+ :class="cn('gap-0.5 leading-5 cursor-pointer', node.hiddenLabel && 'sr-only')"
41
+ >
42
+ {{ label }}
30
43
  <span v-if="required" class="text-xq-destructive" aria-hidden="true">*</span>
31
- <span v-if="required" class="sr-only">(required)</span>
44
+ <span v-if="required" class="sr-only">{{ store.t('(required)') }}</span>
32
45
  <LoaderCircle
33
- v-if="loading"
46
+ v-if="showLoading"
34
47
  class="ml-1 size-3 motion-safe:animate-spin text-xq-muted-foreground"
35
48
  aria-hidden="true"
36
49
  />
37
50
  </XqLabel>
51
+ <XqFieldSlot :node="node" name="afterLabel" />
52
+ <XqFieldSlot :node="node" name="belowLabel" />
38
53
  <p v-if="hasHint" :id="`${id}-hint`" class="text-xs text-xq-muted-foreground">
39
- {{ node.props?.description ?? node.hint }}
54
+ {{ node.props?.description ?? hint }}
40
55
  </p>
41
56
  </div>
57
+ <XqFieldSlot class="ms-auto" :node="node" name="suffixAction" />
58
+ <XqFieldSlot :node="node" name="afterContent" />
42
59
  </div>
43
- <p v-if="error" :id="`${id}-error`" class="text-xs font-medium text-xq-destructive">
60
+ <XqFieldSlot :node="node" name="belowContent" />
61
+ <XqFieldSlot :node="node" name="aboveErrorMessage" />
62
+ <p
63
+ v-if="error && !fused"
64
+ :id="`${id}-error`"
65
+ class="text-xs font-medium text-xq-destructive"
66
+ >
44
67
  {{ error }}
45
68
  </p>
69
+ <XqFieldSlot :node="node" name="belowErrorMessage" />
46
70
  </div>
47
71
  </template>
@@ -0,0 +1,19 @@
1
+ <script setup lang="ts">
2
+ import { cn } from '../../lib/utils';
3
+ import type { SchemaNode } from '../../schema/types';
4
+ import XqNode from '../XqNode.vue';
5
+
6
+ defineProps<{ node: SchemaNode }>();
7
+
8
+ const alignments: Record<string, string> = {
9
+ start: 'justify-start',
10
+ center: 'justify-center',
11
+ end: 'justify-end',
12
+ };
13
+ </script>
14
+
15
+ <template>
16
+ <div :class="cn('gap-2 flex flex-wrap items-center', alignments[node.props?.alignment])">
17
+ <XqNode v-for="child in node.children ?? []" :key="child.key" :node="child" />
18
+ </div>
19
+ </template>
@@ -0,0 +1,52 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from 'vue';
3
+ import { useFormStore, useTranslator } from '../../composables/context';
4
+ import { gridStyle } from '../../composables/layout';
5
+ import { eachNode } from '../../core/patch';
6
+ import type { SchemaNode } from '../../schema/types';
7
+ import XqButton from '../../ui/XqButton.vue';
8
+ import XqNode from '../XqNode.vue';
9
+
10
+ const props = defineProps<{ node: SchemaNode }>();
11
+ const store = useFormStore();
12
+ const t = useTranslator();
13
+ const editingByHand = ref(false);
14
+ const preview = computed<string | null>(() => props.node.props?.preview ?? null);
15
+ // An error has to be seen, so a block with one always shows its fields.
16
+ const hasError = computed(() => {
17
+ let found = false;
18
+
19
+ eachNode(props.node.children ?? [], (child) => {
20
+ found ||= !!(child.path && store.errors[child.path]);
21
+ });
22
+
23
+ return found;
24
+ });
25
+ const editing = computed(() => editingByHand.value || hasError.value);
26
+ </script>
27
+
28
+ <template>
29
+ <div class="gap-3 grid">
30
+ <template v-if="preview !== null">
31
+ <!-- Rendered on the server by the form's author, who escapes what users typed. -->
32
+ <div v-show="!editing" data-xq-block-preview v-html="preview" />
33
+ <div>
34
+ <XqButton
35
+ type="button"
36
+ variant="outline"
37
+ size="sm"
38
+ @click="editingByHand = !editing"
39
+ >
40
+ {{ editing ? t('Show preview') : t('Edit block') }}
41
+ </XqButton>
42
+ </div>
43
+ </template>
44
+ <div
45
+ v-show="preview === null || editing"
46
+ class="xq-grid"
47
+ :style="gridStyle(node.props?.columns)"
48
+ >
49
+ <XqNode v-for="child in node.children ?? []" :key="child.key" :node="child" />
50
+ </div>
51
+ </div>
52
+ </template>
@@ -0,0 +1,46 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import { cn } from '../../lib/utils';
4
+ import type { SchemaNode } from '../../schema/types';
5
+ import { toneClasses } from '../../ui/classes';
6
+ import XqIcon from '../../ui/XqIcon.vue';
7
+ import XqNode from '../XqNode.vue';
8
+
9
+ const props = defineProps<{ node: SchemaNode }>();
10
+ const options = computed(() => props.node.props ?? {});
11
+
12
+ const surfaces: Record<string, string> = {
13
+ danger: 'border-xq-destructive/40 bg-xq-destructive/10',
14
+ info: 'border-xq-info/40 bg-xq-info/10',
15
+ success: 'border-xq-success/40 bg-xq-success/10',
16
+ warning: 'border-xq-warning/40 bg-xq-warning/10',
17
+ };
18
+ </script>
19
+
20
+ <template>
21
+ <div
22
+ :class="
23
+ cn(
24
+ 'gap-3 p-4 flex items-start rounded-xq-lg border border-xq-border bg-xq-card text-xq-card-foreground',
25
+ surfaces[options.status],
26
+ )
27
+ "
28
+ >
29
+ <XqIcon
30
+ v-if="options.icon"
31
+ :name="options.icon"
32
+ :class="cn('mt-0.5 size-5', toneClasses[options.status])"
33
+ />
34
+ <div class="gap-3 min-w-0 grid flex-1">
35
+ <div class="gap-1 grid">
36
+ <p v-if="node.label" class="text-sm font-semibold">{{ node.label }}</p>
37
+ <p v-if="options.description" class="text-sm text-xq-muted-foreground">
38
+ {{ options.description }}
39
+ </p>
40
+ </div>
41
+ <div v-if="node.children?.length" class="gap-2 flex flex-wrap items-center">
42
+ <XqNode v-for="child in node.children" :key="child.key" :node="child" />
43
+ </div>
44
+ </div>
45
+ </div>
46
+ </template>
@@ -0,0 +1,37 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import { cn } from '../../lib/utils';
4
+ import type { SchemaNode } from '../../schema/types';
5
+ import XqIcon from '../../ui/XqIcon.vue';
6
+ import XqNode from '../XqNode.vue';
7
+
8
+ const props = defineProps<{ node: SchemaNode }>();
9
+ const options = computed(() => props.node.props ?? {});
10
+ </script>
11
+
12
+ <template>
13
+ <div
14
+ :class="
15
+ cn(
16
+ 'gap-3 p-8 flex flex-col items-center text-center',
17
+ options.contained && 'rounded-xq-lg border border-dashed border-xq-border',
18
+ )
19
+ "
20
+ >
21
+ <span
22
+ v-if="options.icon"
23
+ class="size-10 flex items-center justify-center rounded-full bg-xq-muted text-xq-muted-foreground"
24
+ >
25
+ <XqIcon :name="options.icon" class="size-5" />
26
+ </span>
27
+ <div class="gap-1 grid">
28
+ <p class="text-sm font-semibold">{{ node.label }}</p>
29
+ <p v-if="options.description" class="text-sm text-xq-muted-foreground">
30
+ {{ options.description }}
31
+ </p>
32
+ </div>
33
+ <div v-if="node.children?.length" class="gap-2 flex flex-wrap items-center justify-center">
34
+ <XqNode v-for="child in node.children" :key="child.key" :node="child" />
35
+ </div>
36
+ </div>
37
+ </template>
@@ -0,0 +1,23 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import { cn } from '../../lib/utils';
4
+ import type { SchemaNode } from '../../schema/types';
5
+ import XqNode from '../XqNode.vue';
6
+
7
+ const props = defineProps<{ node: SchemaNode }>();
8
+
9
+ const directions: Record<string, string> = {
10
+ sm: 'flex-col sm:flex-row',
11
+ md: 'flex-col md:flex-row',
12
+ lg: 'flex-col lg:flex-row',
13
+ xl: 'flex-col xl:flex-row',
14
+ '2xl': 'flex-col 2xl:flex-row',
15
+ };
16
+ const direction = computed(() => directions[props.node.props?.from] ?? 'flex-row');
17
+ </script>
18
+
19
+ <template>
20
+ <div :class="cn('xq-flex gap-4 flex', direction)">
21
+ <XqNode v-for="child in node.children ?? []" :key="child.key" :node="child" />
22
+ </div>
23
+ </template>
@@ -0,0 +1,48 @@
1
+ <script setup lang="ts">
2
+ import { computed, provide } from 'vue';
3
+ import { FUSED_GROUP, useFormStore } from '../../composables/context';
4
+ import { gridStyle } from '../../composables/layout';
5
+ import { fieldId } from '../../composables/useField';
6
+ import { eachNode } from '../../core/patch';
7
+ import type { SchemaNode } from '../../schema/types';
8
+ import XqNode from '../XqNode.vue';
9
+
10
+ const props = defineProps<{ node: SchemaNode }>();
11
+ const store = useFormStore();
12
+ const fields = computed(() => {
13
+ const found: SchemaNode[] = [];
14
+ eachNode(props.node.children ?? [], (child) => child.path && found.push(child));
15
+
16
+ return found;
17
+ });
18
+ const required = computed(() => fields.value.some((field) => store.isRequired(field)));
19
+ const errors = computed(() =>
20
+ fields.value.flatMap((field) =>
21
+ store.errors[field.path!]
22
+ ? [{ id: `${fieldId(store, field.path!)}-error`, message: store.errors[field.path!] }]
23
+ : [],
24
+ ),
25
+ );
26
+
27
+ provide(FUSED_GROUP, true);
28
+ </script>
29
+
30
+ <template>
31
+ <div class="gap-2 grid" role="group" :aria-label="node.label ?? undefined">
32
+ <p v-if="node.label" class="gap-0.5 text-sm font-medium flex leading-none">
33
+ {{ node.label }}
34
+ <span v-if="required" class="text-xq-destructive" aria-hidden="true">*</span>
35
+ </p>
36
+ <div class="xq-grid xq-fused" :style="gridStyle(node.props?.columns)">
37
+ <XqNode v-for="child in node.children ?? []" :key="child.key" :node="child" />
38
+ </div>
39
+ <p
40
+ v-for="item in errors"
41
+ :id="item.id"
42
+ :key="item.id"
43
+ class="text-xs font-medium text-xq-destructive"
44
+ >
45
+ {{ item.message }}
46
+ </p>
47
+ </div>
48
+ </template>