@xelqun/forms 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/README.md +116 -2
- package/package.json +56 -5
- package/resources/css/xelqun.css +162 -0
- package/resources/js/components/XqFieldWrapper.vue +59 -0
- package/resources/js/components/XqForm.vue +128 -0
- package/resources/js/components/XqNode.vue +22 -0
- package/resources/js/components/XqUnknownNode.vue +15 -0
- package/resources/js/components/defaults.ts +44 -0
- package/resources/js/components/fields/XqCheckboxField.vue +48 -0
- package/resources/js/components/fields/XqDatePickerField.vue +96 -0
- package/resources/js/components/fields/XqFileUploadField.vue +247 -0
- package/resources/js/components/fields/XqRadioField.vue +45 -0
- package/resources/js/components/fields/XqRepeaterField.vue +166 -0
- package/resources/js/components/fields/XqSelectField.vue +151 -0
- package/resources/js/components/fields/XqTextInputField.vue +100 -0
- package/resources/js/components/fields/XqTextareaField.vue +28 -0
- package/resources/js/components/fields/XqToggleField.vue +47 -0
- package/resources/js/components/layouts/XqFieldset.vue +16 -0
- package/resources/js/components/layouts/XqGrid.vue +13 -0
- package/resources/js/components/layouts/XqSection.vue +62 -0
- package/resources/js/components/layouts/XqTabsLayout.vue +64 -0
- package/resources/js/composables/context.ts +22 -0
- package/resources/js/composables/layout.ts +55 -0
- package/resources/js/composables/registry.ts +25 -0
- package/resources/js/composables/useField.ts +41 -0
- package/resources/js/composables/useXqForm.ts +16 -0
- package/resources/js/core/conditions.ts +52 -0
- package/resources/js/core/expressions.ts +131 -0
- package/resources/js/core/http.ts +101 -0
- package/resources/js/core/log.ts +93 -0
- package/resources/js/core/patch.ts +65 -0
- package/resources/js/core/paths.ts +122 -0
- package/resources/js/core/queue.ts +190 -0
- package/resources/js/core/rows.ts +26 -0
- package/resources/js/core/store.ts +560 -0
- package/resources/js/core/values.ts +161 -0
- package/resources/js/index.ts +24 -0
- package/resources/js/lib/utils.ts +18 -0
- package/resources/js/plugin.ts +17 -0
- package/resources/js/schema/types.ts +99 -0
- package/resources/js/ui/XqBadge.vue +33 -0
- package/resources/js/ui/XqButton.vue +59 -0
- package/resources/js/ui/XqCalendar.vue +90 -0
- package/resources/js/ui/XqCard.vue +20 -0
- package/resources/js/ui/XqCheckbox.vue +28 -0
- package/resources/js/ui/XqCombobox.vue +184 -0
- package/resources/js/ui/XqInput.vue +16 -0
- package/resources/js/ui/XqLabel.vue +17 -0
- package/resources/js/ui/XqRadioGroup.vue +24 -0
- package/resources/js/ui/XqRadioGroupItem.vue +31 -0
- package/resources/js/ui/XqSelect.vue +79 -0
- package/resources/js/ui/XqSwitch.vue +27 -0
- package/resources/js/ui/XqTabs.vue +19 -0
- package/resources/js/ui/XqTabsContent.vue +17 -0
- package/resources/js/ui/XqTabsList.vue +21 -0
- package/resources/js/ui/XqTabsTrigger.vue +27 -0
- package/resources/js/ui/XqTextarea.vue +16 -0
- package/resources/js/ui/classes.ts +12 -0
- 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>
|