@xelqun/forms 0.1.1 → 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.
- package/README.md +46 -61
- package/package.json +32 -2
- package/resources/css/xelqun.css +121 -0
- package/resources/js/components/XqAction.vue +143 -0
- package/resources/js/components/XqFieldSlot.vue +20 -0
- package/resources/js/components/XqFieldWrapper.vue +74 -30
- package/resources/js/components/XqForm.vue +36 -9
- package/resources/js/components/XqNode.vue +8 -1
- package/resources/js/components/XqUnknownNode.vue +2 -1
- package/resources/js/components/defaults.ts +36 -0
- package/resources/js/components/fields/XqBlockPicker.vue +68 -0
- package/resources/js/components/fields/XqCheckboxField.vue +35 -9
- package/resources/js/components/fields/XqCheckboxListField.vue +115 -0
- package/resources/js/components/fields/XqCodeEditorField.vue +92 -0
- package/resources/js/components/fields/XqColorPickerField.vue +156 -0
- package/resources/js/components/fields/XqDatePickerField.vue +149 -53
- package/resources/js/components/fields/XqFileUploadField.vue +524 -165
- package/resources/js/components/fields/XqImageEditor.vue +165 -0
- package/resources/js/components/fields/XqKeyValueField.vue +168 -0
- package/resources/js/components/fields/XqMarkdownEditorField.vue +245 -0
- package/resources/js/components/fields/XqRadioField.vue +9 -5
- package/resources/js/components/fields/XqRepeaterField.vue +314 -79
- package/resources/js/components/fields/XqRepeaterRowActions.vue +101 -0
- package/resources/js/components/fields/XqRichEditorField.vue +280 -0
- package/resources/js/components/fields/XqSelectField.vue +29 -12
- package/resources/js/components/fields/XqSliderField.vue +47 -0
- package/resources/js/components/fields/XqTagsInputField.vue +127 -0
- package/resources/js/components/fields/XqTextInputField.vue +121 -12
- package/resources/js/components/fields/XqTextareaField.vue +39 -16
- package/resources/js/components/fields/XqToggleButtonsField.vue +75 -0
- package/resources/js/components/fields/XqToggleField.vue +33 -9
- package/resources/js/components/layouts/XqActions.vue +19 -0
- package/resources/js/components/layouts/XqBlock.vue +52 -0
- package/resources/js/components/layouts/XqCallout.vue +46 -0
- package/resources/js/components/layouts/XqEmptyState.vue +37 -0
- package/resources/js/components/layouts/XqFlex.vue +23 -0
- package/resources/js/components/layouts/XqFusedGroup.vue +48 -0
- package/resources/js/components/layouts/XqIconNode.vue +32 -0
- package/resources/js/components/layouts/XqImageNode.vue +30 -0
- package/resources/js/components/layouts/XqSection.vue +3 -1
- package/resources/js/components/layouts/XqTabsLayout.vue +3 -21
- package/resources/js/components/layouts/XqTextNode.vue +49 -0
- package/resources/js/components/layouts/XqUnorderedList.vue +18 -0
- package/resources/js/components/layouts/XqWizard.vue +168 -0
- package/resources/js/composables/context.ts +14 -0
- package/resources/js/composables/useField.ts +21 -3
- package/resources/js/composables/usePanelErrors.ts +37 -0
- package/resources/js/composables/useSortable.ts +70 -0
- package/resources/js/composables/useUnsavedChangesAlert.ts +48 -0
- package/resources/js/core/dates.ts +38 -0
- package/resources/js/core/images.ts +110 -0
- package/resources/js/core/log.ts +1 -1
- package/resources/js/core/markdown.ts +61 -0
- package/resources/js/core/mask.ts +42 -0
- package/resources/js/core/patch.ts +6 -3
- package/resources/js/core/paths.ts +11 -14
- package/resources/js/core/store.ts +515 -25
- package/resources/js/core/translate.ts +17 -0
- package/resources/js/core/validation.ts +126 -0
- package/resources/js/index.ts +4 -3
- package/resources/js/lib/utils.ts +15 -0
- package/resources/js/plugin.ts +103 -6
- package/resources/js/schema/types.ts +102 -0
- package/resources/js/testing.ts +50 -0
- package/resources/js/ui/XqCalendar.vue +4 -0
- package/resources/js/ui/XqCombobox.vue +89 -32
- package/resources/js/ui/XqConfirmDialog.vue +58 -0
- package/resources/js/ui/XqDialog.vue +46 -0
- package/resources/js/ui/XqIcon.vue +28 -0
- package/resources/js/ui/XqSelect.vue +38 -18
- package/resources/js/ui/XqTabs.vue +1 -1
- package/resources/js/ui/XqTimeField.vue +66 -0
- package/resources/js/ui/classes.ts +33 -0
- package/resources/js/ui/icons.ts +95 -0
- package/resources/js/ui/index.ts +5 -0
- package/resources/js/ui/options.ts +46 -0
|
@@ -1,18 +1,38 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
48
|
-
|
|
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
|
-
|
|
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="
|
|
77
|
-
: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 {
|
|
9
|
-
|
|
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
|
-
<
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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 } =
|
|
11
|
-
|
|
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
|
-
<
|
|
29
|
-
|
|
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="
|
|
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 ??
|
|
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
|
-
<
|
|
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>
|