@xelqun/forms 0.0.0-stage → 0.1.1

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 (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +116 -2
  3. package/package.json +56 -5
  4. package/resources/css/xelqun.css +162 -0
  5. package/resources/js/components/XqFieldWrapper.vue +59 -0
  6. package/resources/js/components/XqForm.vue +128 -0
  7. package/resources/js/components/XqNode.vue +22 -0
  8. package/resources/js/components/XqUnknownNode.vue +15 -0
  9. package/resources/js/components/defaults.ts +44 -0
  10. package/resources/js/components/fields/XqCheckboxField.vue +48 -0
  11. package/resources/js/components/fields/XqDatePickerField.vue +96 -0
  12. package/resources/js/components/fields/XqFileUploadField.vue +247 -0
  13. package/resources/js/components/fields/XqRadioField.vue +45 -0
  14. package/resources/js/components/fields/XqRepeaterField.vue +166 -0
  15. package/resources/js/components/fields/XqSelectField.vue +151 -0
  16. package/resources/js/components/fields/XqTextInputField.vue +100 -0
  17. package/resources/js/components/fields/XqTextareaField.vue +28 -0
  18. package/resources/js/components/fields/XqToggleField.vue +47 -0
  19. package/resources/js/components/layouts/XqFieldset.vue +16 -0
  20. package/resources/js/components/layouts/XqGrid.vue +13 -0
  21. package/resources/js/components/layouts/XqSection.vue +62 -0
  22. package/resources/js/components/layouts/XqTabsLayout.vue +64 -0
  23. package/resources/js/composables/context.ts +22 -0
  24. package/resources/js/composables/layout.ts +55 -0
  25. package/resources/js/composables/registry.ts +25 -0
  26. package/resources/js/composables/useField.ts +41 -0
  27. package/resources/js/composables/useXqForm.ts +16 -0
  28. package/resources/js/core/conditions.ts +52 -0
  29. package/resources/js/core/expressions.ts +131 -0
  30. package/resources/js/core/http.ts +101 -0
  31. package/resources/js/core/log.ts +93 -0
  32. package/resources/js/core/patch.ts +65 -0
  33. package/resources/js/core/paths.ts +122 -0
  34. package/resources/js/core/queue.ts +190 -0
  35. package/resources/js/core/rows.ts +26 -0
  36. package/resources/js/core/store.ts +560 -0
  37. package/resources/js/core/values.ts +161 -0
  38. package/resources/js/index.ts +24 -0
  39. package/resources/js/lib/utils.ts +18 -0
  40. package/resources/js/plugin.ts +17 -0
  41. package/resources/js/schema/types.ts +99 -0
  42. package/resources/js/ui/XqBadge.vue +33 -0
  43. package/resources/js/ui/XqButton.vue +59 -0
  44. package/resources/js/ui/XqCalendar.vue +90 -0
  45. package/resources/js/ui/XqCard.vue +20 -0
  46. package/resources/js/ui/XqCheckbox.vue +28 -0
  47. package/resources/js/ui/XqCombobox.vue +184 -0
  48. package/resources/js/ui/XqInput.vue +16 -0
  49. package/resources/js/ui/XqLabel.vue +17 -0
  50. package/resources/js/ui/XqRadioGroup.vue +24 -0
  51. package/resources/js/ui/XqRadioGroupItem.vue +31 -0
  52. package/resources/js/ui/XqSelect.vue +79 -0
  53. package/resources/js/ui/XqSwitch.vue +27 -0
  54. package/resources/js/ui/XqTabs.vue +19 -0
  55. package/resources/js/ui/XqTabsContent.vue +17 -0
  56. package/resources/js/ui/XqTabsList.vue +21 -0
  57. package/resources/js/ui/XqTabsTrigger.vue +27 -0
  58. package/resources/js/ui/XqTextarea.vue +16 -0
  59. package/resources/js/ui/classes.ts +12 -0
  60. package/resources/js/ui/index.ts +17 -0
@@ -0,0 +1,48 @@
1
+ <script setup lang="ts">
2
+ import { LoaderCircle } from 'lucide-vue-next';
3
+ import { computed } from 'vue';
4
+ import { useField } from '../../composables/useField';
5
+ import type { SchemaNode } from '../../schema/types';
6
+ import XqCheckbox from '../../ui/XqCheckbox.vue';
7
+ import XqLabel from '../../ui/XqLabel.vue';
8
+
9
+ 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));
12
+ const describedBy = computed(() =>
13
+ error.value ? `${id}-error` : hasHint.value ? `${id}-hint` : undefined,
14
+ );
15
+ </script>
16
+
17
+ <template>
18
+ <div class="gap-2 grid" :data-xq-path="node.path" :aria-busy="loading || undefined">
19
+ <div class="gap-3 flex items-start">
20
+ <XqCheckbox
21
+ :id="id"
22
+ v-model="value"
23
+ :disabled="disabled"
24
+ :aria-invalid="error ? true : undefined"
25
+ :aria-describedby="describedBy"
26
+ class="mt-0.5"
27
+ />
28
+ <div class="gap-1.5 grid">
29
+ <XqLabel :for="id" class="gap-0.5 leading-snug cursor-pointer">
30
+ {{ node.label }}
31
+ <span v-if="required" class="text-xq-destructive" aria-hidden="true">*</span>
32
+ <span v-if="required" class="sr-only">(required)</span>
33
+ <LoaderCircle
34
+ v-if="loading"
35
+ class="ml-1 size-3 motion-safe:animate-spin text-xq-muted-foreground"
36
+ aria-hidden="true"
37
+ />
38
+ </XqLabel>
39
+ <p v-if="hasHint" :id="`${id}-hint`" class="text-xs text-xq-muted-foreground">
40
+ {{ node.props?.description ?? node.hint }}
41
+ </p>
42
+ </div>
43
+ </div>
44
+ <p v-if="error" :id="`${id}-error`" class="text-xs font-medium text-xq-destructive">
45
+ {{ error }}
46
+ </p>
47
+ </div>
48
+ </template>
@@ -0,0 +1,96 @@
1
+ <script setup lang="ts">
2
+ import { getLocalTimeZone, parseDate } from '@internationalized/date';
3
+ import type { DateValue } from '@internationalized/date';
4
+ import { CalendarDays, X } from 'lucide-vue-next';
5
+ import { PopoverContent, PopoverPortal, PopoverRoot, PopoverTrigger } from 'reka-ui';
6
+ import { computed, ref } from 'vue';
7
+ import { useField } from '../../composables/useField';
8
+ import { cn } from '../../lib/utils';
9
+ import type { SchemaNode } from '../../schema/types';
10
+ import { controlClass, popoverClass } from '../../ui/classes';
11
+ import XqCalendar from '../../ui/XqCalendar.vue';
12
+ import XqFieldWrapper from '../XqFieldWrapper.vue';
13
+
14
+ const props = defineProps<{ node: SchemaNode }>();
15
+ const { value, error, describedBy, required, disabled, id, onBlur } = useField<string | null>(
16
+ props.node,
17
+ );
18
+ const open = ref(false);
19
+
20
+ function parse(input: unknown): DateValue | undefined {
21
+ if (typeof input !== 'string' || input === '') {
22
+ return undefined;
23
+ }
24
+
25
+ try {
26
+ return parseDate(input.slice(0, 10));
27
+ } catch {
28
+ return undefined;
29
+ }
30
+ }
31
+
32
+ const date = computed<DateValue | undefined>({
33
+ get: () => parse(value.value),
34
+ set: (next) => {
35
+ value.value = next ? next.toString() : null;
36
+ open.value = false;
37
+ onBlur();
38
+ },
39
+ });
40
+
41
+ const label = computed(() =>
42
+ date.value
43
+ ? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(
44
+ date.value.toDate(getLocalTimeZone()),
45
+ )
46
+ : null,
47
+ );
48
+ </script>
49
+
50
+ <template>
51
+ <XqFieldWrapper :node="node" :id="id" :error="error" :required="required">
52
+ <PopoverRoot v-model:open="open">
53
+ <div class="relative">
54
+ <PopoverTrigger as-child>
55
+ <button
56
+ :id="id"
57
+ type="button"
58
+ :disabled="disabled"
59
+ :aria-invalid="error ? true : undefined"
60
+ :aria-describedby="describedBy"
61
+ :class="
62
+ cn(
63
+ controlClass,
64
+ 'h-9 gap-2 px-3 flex cursor-pointer items-center text-left',
65
+ !label && 'text-xq-muted-foreground',
66
+ )
67
+ "
68
+ >
69
+ <CalendarDays class="size-4 shrink-0 opacity-60" />
70
+ <span class="truncate">{{
71
+ label ?? node.placeholder ?? 'Pick a date'
72
+ }}</span>
73
+ </button>
74
+ </PopoverTrigger>
75
+ <button
76
+ v-if="label && !required && !disabled"
77
+ type="button"
78
+ class="right-2 size-6 rounded-sm absolute top-1/2 flex -translate-y-1/2 cursor-pointer items-center justify-center text-xq-muted-foreground hover:text-xq-foreground"
79
+ aria-label="Clear date"
80
+ @click="date = undefined"
81
+ >
82
+ <X class="size-3.5" />
83
+ </button>
84
+ </div>
85
+ <PopoverPortal>
86
+ <PopoverContent align="start" :side-offset="4" :class="cn(popoverClass, 'w-auto')">
87
+ <XqCalendar
88
+ v-model="date"
89
+ :min-value="parse(node.props?.min)"
90
+ :max-value="parse(node.props?.max)"
91
+ />
92
+ </PopoverContent>
93
+ </PopoverPortal>
94
+ </PopoverRoot>
95
+ </XqFieldWrapper>
96
+ </template>
@@ -0,0 +1,247 @@
1
+ <script setup lang="ts">
2
+ import { FileText, LoaderCircle, UploadCloud, X } from 'lucide-vue-next';
3
+ import { computed, onBeforeUnmount, ref } from 'vue';
4
+ import { useField } from '../../composables/useField';
5
+ import { HttpError } from '../../core/http';
6
+ import { cn, formatBytes } from '../../lib/utils';
7
+ import type { SchemaNode } from '../../schema/types';
8
+ import XqButton from '../../ui/XqButton.vue';
9
+ import XqFieldWrapper from '../XqFieldWrapper.vue';
10
+
11
+ const props = defineProps<{ node: SchemaNode }>();
12
+ const { store, path, value, error, describedBy, required, disabled, id } = useField<string | null>(
13
+ props.node,
14
+ );
15
+ const options = computed(() => props.node.props ?? {});
16
+
17
+ const input = ref<HTMLInputElement | null>(null);
18
+ const dragging = ref(false);
19
+ const progress = ref<{ name: string; size: number; percent: number } | null>(null);
20
+ const uploadError = ref<string | null>(null);
21
+ const local = ref<{ token: string; name: string; size: number; preview: string | null } | null>(
22
+ null,
23
+ );
24
+ let controller: AbortController | null = null;
25
+
26
+ const file = computed(() => {
27
+ if (!value.value) {
28
+ return null;
29
+ }
30
+
31
+ if (local.value?.token === value.value) {
32
+ return {
33
+ name: local.value.name,
34
+ size: local.value.size,
35
+ url: local.value.preview,
36
+ isNew: true,
37
+ };
38
+ }
39
+
40
+ const existing = options.value.files?.[value.value];
41
+
42
+ return {
43
+ name: existing?.name ?? value.value.split('/').pop() ?? 'File',
44
+ size: existing?.size ?? null,
45
+ url: existing?.url ?? null,
46
+ isNew: false,
47
+ };
48
+ });
49
+
50
+ const isImage = computed(
51
+ () =>
52
+ !!file.value?.url &&
53
+ (options.value.image || /\.(png|jpe?g|gif|webp|avif|svg)$/i.test(file.value.name)),
54
+ );
55
+
56
+ const inputDescribedBy = computed(() => (uploadError.value ? `${id}-error` : describedBy.value));
57
+
58
+ function revokePreview(): void {
59
+ if (local.value?.preview) {
60
+ URL.revokeObjectURL(local.value.preview);
61
+ }
62
+ }
63
+
64
+ async function upload(picked: File): Promise<void> {
65
+ uploadError.value = null;
66
+
67
+ if (options.value.maxSize && picked.size > options.value.maxSize * 1024) {
68
+ uploadError.value = `The file may not be larger than ${formatBytes(options.value.maxSize * 1024)}.`;
69
+
70
+ return;
71
+ }
72
+
73
+ controller?.abort();
74
+ controller = new AbortController();
75
+ progress.value = { name: picked.name, size: picked.size, percent: 0 };
76
+
77
+ try {
78
+ const result = await store.upload(
79
+ path,
80
+ picked,
81
+ (percent) => progress.value && (progress.value.percent = percent),
82
+ controller.signal,
83
+ );
84
+
85
+ revokePreview();
86
+ local.value = {
87
+ token: result.token,
88
+ name: result.name,
89
+ size: result.size,
90
+ preview: picked.type.startsWith('image/') ? URL.createObjectURL(picked) : null,
91
+ };
92
+ value.value = result.token;
93
+ } catch (failure) {
94
+ if (controller?.signal.aborted) {
95
+ return;
96
+ }
97
+
98
+ uploadError.value =
99
+ failure instanceof HttpError
100
+ ? (failure.validationErrors.file?.[0] ?? failure.message)
101
+ : 'The upload failed. Please try again.';
102
+ } finally {
103
+ progress.value = null;
104
+
105
+ if (input.value) {
106
+ input.value.value = '';
107
+ }
108
+ }
109
+ }
110
+
111
+ function onChange(event: Event): void {
112
+ const picked = (event.target as HTMLInputElement).files?.[0];
113
+
114
+ if (picked) {
115
+ void upload(picked);
116
+ }
117
+ }
118
+
119
+ function onDrop(event: DragEvent): void {
120
+ dragging.value = false;
121
+ const picked = event.dataTransfer?.files?.[0];
122
+
123
+ if (picked && !disabled.value) {
124
+ void upload(picked);
125
+ }
126
+ }
127
+
128
+ function remove(): void {
129
+ controller?.abort();
130
+ revokePreview();
131
+ local.value = null;
132
+ value.value = null;
133
+ }
134
+
135
+ onBeforeUnmount(() => {
136
+ controller?.abort();
137
+ revokePreview();
138
+ });
139
+ </script>
140
+
141
+ <template>
142
+ <XqFieldWrapper
143
+ :node="node"
144
+ :id="id"
145
+ :error="error ?? uploadError"
146
+ :required="required"
147
+ :announce-error="!!uploadError"
148
+ >
149
+ <div
150
+ v-if="file && !progress"
151
+ class="gap-3 p-2 pr-3 flex items-center rounded-xq-md border border-xq-border bg-xq-background"
152
+ >
153
+ <img
154
+ v-if="isImage"
155
+ :src="file.url!"
156
+ alt=""
157
+ class="size-10 shrink-0 rounded-xq-sm object-cover"
158
+ />
159
+ <div
160
+ v-else
161
+ class="size-10 flex shrink-0 items-center justify-center rounded-xq-sm bg-xq-muted"
162
+ >
163
+ <FileText class="size-5 text-xq-muted-foreground" />
164
+ </div>
165
+ <div class="min-w-0 flex-1">
166
+ <a
167
+ v-if="file.url && !file.isNew"
168
+ :href="file.url"
169
+ target="_blank"
170
+ rel="noopener"
171
+ class="text-sm font-medium block truncate hover:underline"
172
+ >
173
+ {{ file.name }}
174
+ </a>
175
+ <p v-else class="text-sm font-medium truncate">{{ file.name }}</p>
176
+ <p class="text-xs text-xq-muted-foreground">
177
+ {{ file.isNew ? 'Uploaded, saved when you submit' : 'Stored'
178
+ }}<template v-if="file.size"> · {{ formatBytes(file.size) }}</template>
179
+ </p>
180
+ </div>
181
+ <XqButton
182
+ v-if="!disabled"
183
+ type="button"
184
+ variant="ghost"
185
+ size="icon-sm"
186
+ aria-label="Remove file"
187
+ @click="remove"
188
+ >
189
+ <X />
190
+ </XqButton>
191
+ </div>
192
+ <label
193
+ v-else
194
+ :for="id"
195
+ :class="
196
+ cn(
197
+ 'gap-1.5 px-4 py-5 flex cursor-pointer flex-col items-center justify-center rounded-xq-md border border-dashed border-xq-input bg-xq-background text-center transition-[color,background-color,border-color,box-shadow] outline-none hover:bg-xq-muted/50 has-[input:focus-visible]:border-xq-ring has-[input:focus-visible]:ring-[3px] has-[input:focus-visible]:ring-xq-ring/25',
198
+ dragging && 'border-xq-primary bg-xq-primary/5',
199
+ (error || uploadError) && 'border-xq-destructive',
200
+ disabled && 'pointer-events-none opacity-60',
201
+ )
202
+ "
203
+ @dragover.prevent="dragging = true"
204
+ @dragleave.prevent="dragging = false"
205
+ @drop.prevent="onDrop"
206
+ >
207
+ <template v-if="progress">
208
+ <LoaderCircle class="size-5 motion-safe:animate-spin text-xq-primary" />
209
+ <span class="text-sm font-medium max-w-full truncate">{{ progress.name }}</span>
210
+ <span
211
+ role="progressbar"
212
+ aria-valuemin="0"
213
+ aria-valuemax="100"
214
+ :aria-valuenow="progress.percent"
215
+ :aria-label="`Uploading ${progress.name}`"
216
+ class="h-1.5 w-40 overflow-hidden rounded-full bg-xq-muted"
217
+ >
218
+ <span
219
+ class="block h-full bg-xq-primary transition-[width]"
220
+ :style="{ width: `${progress.percent}%` }"
221
+ />
222
+ </span>
223
+ </template>
224
+ <template v-else>
225
+ <UploadCloud class="size-5 text-xq-muted-foreground" />
226
+ <span class="text-sm"
227
+ ><span class="font-medium text-xq-primary">Choose a file</span> or drag it
228
+ here</span
229
+ >
230
+ <span class="text-xs text-xq-muted-foreground"
231
+ >Up to {{ formatBytes((options.maxSize ?? 10240) * 1024) }}</span
232
+ >
233
+ </template>
234
+ <input
235
+ :id="id"
236
+ ref="input"
237
+ type="file"
238
+ class="sr-only"
239
+ :accept="options.accept ?? undefined"
240
+ :disabled="disabled"
241
+ :aria-invalid="error || uploadError ? true : undefined"
242
+ :aria-describedby="inputDescribedBy"
243
+ @change="onChange"
244
+ />
245
+ </label>
246
+ </XqFieldWrapper>
247
+ </template>
@@ -0,0 +1,45 @@
1
+ <script setup lang="ts">
2
+ import { useField } from '../../composables/useField';
3
+ import { cn } from '../../lib/utils';
4
+ import type { SchemaNode, SelectOption } from '../../schema/types';
5
+ import XqLabel from '../../ui/XqLabel.vue';
6
+ import XqRadioGroup from '../../ui/XqRadioGroup.vue';
7
+ import XqRadioGroupItem from '../../ui/XqRadioGroupItem.vue';
8
+ import XqFieldWrapper from '../XqFieldWrapper.vue';
9
+
10
+ const props = defineProps<{ node: SchemaNode }>();
11
+ const { value, error, describedBy, required, disabled, id } = useField<string | number | null>(
12
+ props.node,
13
+ );
14
+ </script>
15
+
16
+ <template>
17
+ <XqFieldWrapper :node="node" :id="id" :label-for="null" :error="error" :required="required">
18
+ <XqRadioGroup
19
+ v-model="value"
20
+ :disabled="disabled"
21
+ :orientation="node.props?.inline ? 'horizontal' : 'vertical'"
22
+ :aria-invalid="error ? true : undefined"
23
+ :aria-required="required || undefined"
24
+ :aria-describedby="describedBy"
25
+ :aria-labelledby="node.label ? `${id}-label` : undefined"
26
+ :class="cn(node.props?.inline && 'gap-x-6 gap-y-2 flex flex-wrap')"
27
+ >
28
+ <div
29
+ v-for="(option, index) in (node.props?.options ?? []) as SelectOption[]"
30
+ :key="String(option.value)"
31
+ class="gap-2.5 flex items-start"
32
+ >
33
+ <XqRadioGroupItem :id="`${id}-${index}`" :value="option.value" />
34
+ <div class="gap-1 grid">
35
+ <XqLabel :for="`${id}-${index}`" class="leading-4 font-normal cursor-pointer">{{
36
+ option.label
37
+ }}</XqLabel>
38
+ <p v-if="option.description" class="text-xs text-xq-muted-foreground">
39
+ {{ option.description }}
40
+ </p>
41
+ </div>
42
+ </div>
43
+ </XqRadioGroup>
44
+ </XqFieldWrapper>
45
+ </template>
@@ -0,0 +1,166 @@
1
+ <script setup lang="ts">
2
+ import { ArrowDown, ArrowUp, Plus, Trash2 } from 'lucide-vue-next';
3
+ import { computed, nextTick, ref } from 'vue';
4
+ import { gridStyle } from '../../composables/layout';
5
+ import { useField } from '../../composables/useField';
6
+ import type { SchemaNode } from '../../schema/types';
7
+ import XqButton from '../../ui/XqButton.vue';
8
+ import XqFieldWrapper from '../XqFieldWrapper.vue';
9
+ import XqNode from '../XqNode.vue';
10
+
11
+ const props = defineProps<{ node: SchemaNode }>();
12
+ const { store, error, required, disabled, id } = useField(props.node);
13
+ const options = computed(() => props.node.props ?? {});
14
+ const keys = computed(() => store.rowKeys(props.node));
15
+ const rows = computed<Record<string, SchemaNode[]>>(() => options.value.rows ?? {});
16
+ const container = ref<HTMLElement | null>(null);
17
+ const canAdd = computed(
18
+ () =>
19
+ !disabled.value &&
20
+ (options.value.maxItems === null ||
21
+ options.value.maxItems === undefined ||
22
+ keys.value.length < options.value.maxItems),
23
+ );
24
+ const canRemove = computed(
25
+ () => !disabled.value && keys.value.length > (options.value.minItems ?? 0),
26
+ );
27
+
28
+ /**
29
+ * Keep keyboard users in place: focus the first of these that can take it.
30
+ */
31
+ function focusFirstEnabled(selectors: string[]): void {
32
+ for (const selector of selectors) {
33
+ const button = container.value?.querySelector<HTMLButtonElement>(selector);
34
+
35
+ if (button && !button.disabled) {
36
+ button.focus();
37
+
38
+ return;
39
+ }
40
+ }
41
+ }
42
+
43
+ function rowSelector(key: string): string {
44
+ return `[data-xq-row="${CSS.escape(key)}"]`;
45
+ }
46
+
47
+ async function remove(key: string, index: number): Promise<void> {
48
+ store.removeRow(props.node, key);
49
+
50
+ await nextTick();
51
+
52
+ const next = keys.value[Math.min(index, keys.value.length - 1)];
53
+
54
+ focusFirstEnabled([
55
+ ...(next ? [`${rowSelector(next)} [data-xq-remove]`] : []),
56
+ '[data-xq-add]',
57
+ ]);
58
+ }
59
+
60
+ async function move(key: string, direction: -1 | 1): Promise<void> {
61
+ store.moveRow(props.node, key, direction);
62
+
63
+ await nextTick();
64
+
65
+ const row = rowSelector(key);
66
+
67
+ focusFirstEnabled([
68
+ `${row} [data-xq-move="${direction}"]`,
69
+ `${row} [data-xq-move="${-direction}"]`,
70
+ ]);
71
+ }
72
+
73
+ async function add(): Promise<void> {
74
+ const key = store.addRow(props.node);
75
+
76
+ await nextTick();
77
+ store.root.value
78
+ ?.querySelector<HTMLElement>(
79
+ `[data-xq-row="${CSS.escape(key)}"] input:not([type=hidden]), [data-xq-row="${CSS.escape(key)}"] button[role=combobox]`,
80
+ )
81
+ ?.focus();
82
+ }
83
+ </script>
84
+
85
+ <template>
86
+ <XqFieldWrapper :node="node" :id="id" :error="error" :required="required">
87
+ <div ref="container" class="gap-3 grid">
88
+ <div
89
+ v-for="(key, index) in keys"
90
+ :key="key"
91
+ :data-xq-row="key"
92
+ role="group"
93
+ :aria-label="`${options.itemLabel} ${index + 1}`"
94
+ class="shadow-xs rounded-xq-lg border border-xq-border bg-xq-card"
95
+ >
96
+ <div
97
+ class="gap-2 px-3 py-1.5 flex items-center justify-between border-b border-xq-border"
98
+ >
99
+ <span class="text-sm font-medium text-xq-muted-foreground"
100
+ >{{ options.itemLabel }} {{ index + 1 }}</span
101
+ >
102
+ <div class="gap-0.5 flex items-center">
103
+ <template v-if="options.orderable && keys.length > 1">
104
+ <XqButton
105
+ type="button"
106
+ variant="ghost"
107
+ size="icon-sm"
108
+ :disabled="disabled || index === 0"
109
+ :aria-label="`Move ${options.itemLabel} ${index + 1} up`"
110
+ data-xq-move="-1"
111
+ @click="move(key, -1)"
112
+ >
113
+ <ArrowUp />
114
+ </XqButton>
115
+ <XqButton
116
+ type="button"
117
+ variant="ghost"
118
+ size="icon-sm"
119
+ :disabled="disabled || index === keys.length - 1"
120
+ :aria-label="`Move ${options.itemLabel} ${index + 1} down`"
121
+ data-xq-move="1"
122
+ @click="move(key, 1)"
123
+ >
124
+ <ArrowDown />
125
+ </XqButton>
126
+ </template>
127
+ <XqButton
128
+ type="button"
129
+ variant="ghost"
130
+ size="icon-sm"
131
+ class="text-xq-muted-foreground hover:text-xq-destructive"
132
+ :disabled="!canRemove"
133
+ :aria-label="`Remove ${options.itemLabel} ${index + 1}`"
134
+ data-xq-remove
135
+ @click="remove(key, index)"
136
+ >
137
+ <Trash2 />
138
+ </XqButton>
139
+ </div>
140
+ </div>
141
+ <div class="xq-grid p-3" :style="gridStyle(options.columns)">
142
+ <XqNode v-for="child in rows[key] ?? []" :key="child.key" :node="child" />
143
+ </div>
144
+ </div>
145
+ <div
146
+ v-if="keys.length === 0"
147
+ class="px-4 py-6 text-sm rounded-xq-lg border border-dashed border-xq-border text-center text-xq-muted-foreground"
148
+ >
149
+ No {{ String(options.itemLabel ?? 'item').toLowerCase() }}s yet.
150
+ </div>
151
+ <div>
152
+ <XqButton
153
+ type="button"
154
+ variant="outline"
155
+ size="sm"
156
+ :disabled="!canAdd"
157
+ data-xq-add
158
+ @click="add"
159
+ >
160
+ <Plus />
161
+ {{ options.addLabel }}
162
+ </XqButton>
163
+ </div>
164
+ </div>
165
+ </XqFieldWrapper>
166
+ </template>