@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.
- 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
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { Image } from '@tiptap/extension-image';
|
|
3
|
+
import { Subscript } from '@tiptap/extension-subscript';
|
|
4
|
+
import { Superscript } from '@tiptap/extension-superscript';
|
|
5
|
+
import { TableKit } from '@tiptap/extension-table';
|
|
6
|
+
import { TextAlign } from '@tiptap/extension-text-align';
|
|
7
|
+
import StarterKit from '@tiptap/starter-kit';
|
|
8
|
+
import { EditorContent, useEditor } from '@tiptap/vue-3';
|
|
9
|
+
import type { Editor } from '@tiptap/vue-3';
|
|
10
|
+
import {
|
|
11
|
+
AlignCenter,
|
|
12
|
+
AlignJustify,
|
|
13
|
+
AlignLeft,
|
|
14
|
+
AlignRight,
|
|
15
|
+
Bold,
|
|
16
|
+
Code,
|
|
17
|
+
Heading1,
|
|
18
|
+
Heading2,
|
|
19
|
+
Heading3,
|
|
20
|
+
Heading4,
|
|
21
|
+
Heading5,
|
|
22
|
+
Heading6,
|
|
23
|
+
ImagePlus,
|
|
24
|
+
Italic,
|
|
25
|
+
Link as LinkIcon,
|
|
26
|
+
List,
|
|
27
|
+
ListOrdered,
|
|
28
|
+
Minus,
|
|
29
|
+
Quote,
|
|
30
|
+
Redo,
|
|
31
|
+
Strikethrough,
|
|
32
|
+
Subscript as SubscriptIcon,
|
|
33
|
+
Superscript as SuperscriptIcon,
|
|
34
|
+
Table,
|
|
35
|
+
Underline,
|
|
36
|
+
Undo,
|
|
37
|
+
} from 'lucide-vue-next';
|
|
38
|
+
import type { Component } from 'vue';
|
|
39
|
+
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
|
40
|
+
import { useField } from '../../composables/useField';
|
|
41
|
+
import { cn } from '../../lib/utils';
|
|
42
|
+
import type { SchemaNode } from '../../schema/types';
|
|
43
|
+
import { controlClass, toolbarButtonClass } from '../../ui/classes';
|
|
44
|
+
import XqFieldWrapper from '../XqFieldWrapper.vue';
|
|
45
|
+
|
|
46
|
+
const props = defineProps<{ node: SchemaNode }>();
|
|
47
|
+
const { store, path, value, error, label, describedBy, required, disabled, id, onBlur } = useField<
|
|
48
|
+
string | null
|
|
49
|
+
>(props.node);
|
|
50
|
+
const { t } = store;
|
|
51
|
+
const options = computed(() => props.node.props ?? {});
|
|
52
|
+
const fileInput = ref<HTMLInputElement | null>(null);
|
|
53
|
+
const objectUrls: string[] = [];
|
|
54
|
+
|
|
55
|
+
/** Holds the upload's token until the server stores it and writes the real address. */
|
|
56
|
+
const UploadedImage = Image.extend({
|
|
57
|
+
addAttributes() {
|
|
58
|
+
return {
|
|
59
|
+
...this.parent?.(),
|
|
60
|
+
upload: {
|
|
61
|
+
default: null,
|
|
62
|
+
parseHTML: (element) => element.getAttribute('data-xq-upload'),
|
|
63
|
+
renderHTML: (attributes) =>
|
|
64
|
+
attributes.upload ? { 'data-xq-upload': attributes.upload } : {},
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const editor = useEditor({
|
|
71
|
+
content: value.value ?? '',
|
|
72
|
+
editable: !disabled.value,
|
|
73
|
+
extensions: [
|
|
74
|
+
StarterKit.configure({ link: { openOnClick: false } }),
|
|
75
|
+
UploadedImage,
|
|
76
|
+
Subscript,
|
|
77
|
+
Superscript,
|
|
78
|
+
TableKit,
|
|
79
|
+
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
|
80
|
+
],
|
|
81
|
+
editorProps: {
|
|
82
|
+
attributes: {
|
|
83
|
+
class: 'xq-rich-content px-3 py-2 text-sm',
|
|
84
|
+
role: 'textbox',
|
|
85
|
+
'aria-multiline': 'true',
|
|
86
|
+
'aria-label': label.value ?? '',
|
|
87
|
+
'aria-describedby': describedBy.value ?? '',
|
|
88
|
+
style: `min-height: ${options.value.minHeight ?? '10rem'}`,
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
onUpdate: ({ editor: current }) => {
|
|
92
|
+
value.value = current.isEmpty ? null : current.getHTML();
|
|
93
|
+
},
|
|
94
|
+
onBlur: () => onBlur(),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
watch(value, (next) => {
|
|
98
|
+
const current = editor.value;
|
|
99
|
+
|
|
100
|
+
if (current && (next ?? '') !== (current.isEmpty ? '' : current.getHTML())) {
|
|
101
|
+
current.commands.setContent(next ?? '', { emitUpdate: false });
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
watch(disabled, (isDisabled) => editor.value?.setEditable(!isDisabled));
|
|
106
|
+
|
|
107
|
+
interface Tool {
|
|
108
|
+
label: () => string;
|
|
109
|
+
icon: Component;
|
|
110
|
+
run: (editor: Editor) => void;
|
|
111
|
+
active?: (editor: Editor) => boolean;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function chain(editor: Editor) {
|
|
115
|
+
return editor.chain().focus();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function heading(level: 1 | 2 | 3 | 4 | 5 | 6, icon: Component): Tool {
|
|
119
|
+
return {
|
|
120
|
+
label: () => t('Heading :level', { level }),
|
|
121
|
+
icon,
|
|
122
|
+
run: (current) => chain(current).toggleHeading({ level }).run(),
|
|
123
|
+
active: (current) => current.isActive('heading', { level }),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function mark(name: string, label: () => string, icon: Component, toggle: string): Tool {
|
|
128
|
+
return {
|
|
129
|
+
label,
|
|
130
|
+
icon,
|
|
131
|
+
run: (current) => (chain(current) as any)[toggle]().run(),
|
|
132
|
+
active: (current) => current.isActive(name),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function align(side: string, label: () => string, icon: Component): Tool {
|
|
137
|
+
return {
|
|
138
|
+
label,
|
|
139
|
+
icon,
|
|
140
|
+
run: (current) => chain(current).setTextAlign(side).run(),
|
|
141
|
+
active: (current) => current.isActive({ textAlign: side }),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// shortcut: a browser prompt for the address, swap in a popover when someone asks for one.
|
|
146
|
+
function editLink(current: Editor): void {
|
|
147
|
+
const url = window.prompt(t('Link address'), current.getAttributes('link').href ?? 'https://');
|
|
148
|
+
|
|
149
|
+
if (url === null) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (url === '') {
|
|
154
|
+
chain(current).unsetLink().run();
|
|
155
|
+
} else {
|
|
156
|
+
chain(current).extendMarkRange('link').setLink({ href: url }).run();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const tools: Record<string, Tool> = {
|
|
161
|
+
bold: mark('bold', () => t('Bold'), Bold, 'toggleBold'),
|
|
162
|
+
italic: mark('italic', () => t('Italic'), Italic, 'toggleItalic'),
|
|
163
|
+
underline: mark('underline', () => t('Underline'), Underline, 'toggleUnderline'),
|
|
164
|
+
strike: mark('strike', () => t('Strikethrough'), Strikethrough, 'toggleStrike'),
|
|
165
|
+
subscript: mark('subscript', () => t('Subscript'), SubscriptIcon, 'toggleSubscript'),
|
|
166
|
+
superscript: mark('superscript', () => t('Superscript'), SuperscriptIcon, 'toggleSuperscript'),
|
|
167
|
+
link: {
|
|
168
|
+
label: () => t('Link'),
|
|
169
|
+
icon: LinkIcon,
|
|
170
|
+
run: editLink,
|
|
171
|
+
active: (current) => current.isActive('link'),
|
|
172
|
+
},
|
|
173
|
+
h1: heading(1, Heading1),
|
|
174
|
+
h2: heading(2, Heading2),
|
|
175
|
+
h3: heading(3, Heading3),
|
|
176
|
+
h4: heading(4, Heading4),
|
|
177
|
+
h5: heading(5, Heading5),
|
|
178
|
+
h6: heading(6, Heading6),
|
|
179
|
+
alignStart: align('left', () => t('Align left'), AlignLeft),
|
|
180
|
+
alignCenter: align('center', () => t('Align center'), AlignCenter),
|
|
181
|
+
alignEnd: align('right', () => t('Align right'), AlignRight),
|
|
182
|
+
alignJustify: align('justify', () => t('Justify'), AlignJustify),
|
|
183
|
+
blockquote: mark('blockquote', () => t('Quote'), Quote, 'toggleBlockquote'),
|
|
184
|
+
codeBlock: mark('codeBlock', () => t('Code block'), Code, 'toggleCodeBlock'),
|
|
185
|
+
bulletList: mark('bulletList', () => t('Bulleted list'), List, 'toggleBulletList'),
|
|
186
|
+
orderedList: mark('orderedList', () => t('Numbered list'), ListOrdered, 'toggleOrderedList'),
|
|
187
|
+
horizontalRule: {
|
|
188
|
+
label: () => t('Divider'),
|
|
189
|
+
icon: Minus,
|
|
190
|
+
run: (current) => chain(current).setHorizontalRule().run(),
|
|
191
|
+
},
|
|
192
|
+
table: {
|
|
193
|
+
label: () => t('Table'),
|
|
194
|
+
icon: Table,
|
|
195
|
+
run: (current) =>
|
|
196
|
+
chain(current).insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(),
|
|
197
|
+
},
|
|
198
|
+
attachFiles: {
|
|
199
|
+
label: () => t('Attach a file'),
|
|
200
|
+
icon: ImagePlus,
|
|
201
|
+
run: () => fileInput.value?.click(),
|
|
202
|
+
},
|
|
203
|
+
undo: { label: () => t('Undo'), icon: Undo, run: (current) => chain(current).undo().run() },
|
|
204
|
+
redo: { label: () => t('Redo'), icon: Redo, run: (current) => chain(current).redo().run() },
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
async function attach(event: Event): Promise<void> {
|
|
208
|
+
const input = event.target as HTMLInputElement;
|
|
209
|
+
const file = input.files?.[0];
|
|
210
|
+
|
|
211
|
+
input.value = '';
|
|
212
|
+
|
|
213
|
+
if (file) {
|
|
214
|
+
const { token } = await store.upload(path, file, () => {});
|
|
215
|
+
const src = URL.createObjectURL(file);
|
|
216
|
+
|
|
217
|
+
objectUrls.push(src);
|
|
218
|
+
editor.value
|
|
219
|
+
?.chain()
|
|
220
|
+
.focus()
|
|
221
|
+
.setImage({ src, alt: file.name, upload: token } as any)
|
|
222
|
+
.run();
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
onBeforeUnmount(() => objectUrls.forEach((url) => URL.revokeObjectURL(url)));
|
|
227
|
+
</script>
|
|
228
|
+
|
|
229
|
+
<template>
|
|
230
|
+
<XqFieldWrapper :node="node" :id="id" :label-for="null" :error="error" :required="required">
|
|
231
|
+
<div
|
|
232
|
+
:id="id"
|
|
233
|
+
:class="
|
|
234
|
+
cn(
|
|
235
|
+
controlClass,
|
|
236
|
+
'focus-within:border-xq-ring focus-within:ring-[3px] focus-within:ring-xq-ring/25',
|
|
237
|
+
disabled && 'opacity-60',
|
|
238
|
+
)
|
|
239
|
+
"
|
|
240
|
+
>
|
|
241
|
+
<div
|
|
242
|
+
v-if="editor && !disabled"
|
|
243
|
+
class="gap-1 p-1 flex flex-wrap items-center border-b border-xq-border"
|
|
244
|
+
role="toolbar"
|
|
245
|
+
:aria-label="label ?? undefined"
|
|
246
|
+
>
|
|
247
|
+
<div
|
|
248
|
+
v-for="(group, index) in options.toolbar ?? []"
|
|
249
|
+
:key="index"
|
|
250
|
+
class="gap-0.5 flex"
|
|
251
|
+
>
|
|
252
|
+
<template v-for="name in group" :key="name">
|
|
253
|
+
<button
|
|
254
|
+
v-if="tools[name]"
|
|
255
|
+
type="button"
|
|
256
|
+
:class="toolbarButtonClass"
|
|
257
|
+
:aria-label="tools[name].label()"
|
|
258
|
+
:aria-pressed="tools[name].active?.(editor) ?? undefined"
|
|
259
|
+
:title="tools[name].label()"
|
|
260
|
+
:data-xq-tool="name"
|
|
261
|
+
@click="tools[name].run(editor)"
|
|
262
|
+
>
|
|
263
|
+
<component :is="tools[name].icon" class="size-4" aria-hidden="true" />
|
|
264
|
+
</button>
|
|
265
|
+
</template>
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
268
|
+
<EditorContent :editor="editor" />
|
|
269
|
+
<input
|
|
270
|
+
ref="fileInput"
|
|
271
|
+
type="file"
|
|
272
|
+
class="sr-only"
|
|
273
|
+
tabindex="-1"
|
|
274
|
+
aria-hidden="true"
|
|
275
|
+
:accept="options.accept"
|
|
276
|
+
@change="attach"
|
|
277
|
+
/>
|
|
278
|
+
</div>
|
|
279
|
+
</XqFieldWrapper>
|
|
280
|
+
</template>
|
|
@@ -6,17 +6,29 @@ import type { SchemaNode, SelectOption } from '../../schema/types';
|
|
|
6
6
|
import XqCombobox from '../../ui/XqCombobox.vue';
|
|
7
7
|
import XqSelect from '../../ui/XqSelect.vue';
|
|
8
8
|
import XqFieldWrapper from '../XqFieldWrapper.vue';
|
|
9
|
+
import { useTranslator } from '../../composables/context';
|
|
9
10
|
|
|
11
|
+
const t = useTranslator();
|
|
10
12
|
const props = defineProps<{ node: SchemaNode }>();
|
|
11
|
-
const {
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
const {
|
|
14
|
+
store,
|
|
15
|
+
path,
|
|
16
|
+
value,
|
|
17
|
+
error,
|
|
18
|
+
placeholder,
|
|
19
|
+
describedBy,
|
|
20
|
+
showLoading,
|
|
21
|
+
required,
|
|
22
|
+
disabled,
|
|
23
|
+
id,
|
|
24
|
+
onBlur,
|
|
25
|
+
} = useField(props.node);
|
|
14
26
|
const options = computed<SelectOption[]>(() => props.node.props?.options ?? []);
|
|
15
27
|
const useCombobox = computed(() => !!(props.node.props?.searchable || props.node.props?.multiple));
|
|
16
28
|
const isRemote = computed(() => !!props.node.props?.async);
|
|
17
29
|
|
|
18
30
|
const remoteOptions = ref<SelectOption[]>([]);
|
|
19
|
-
const
|
|
31
|
+
const searching = ref(false);
|
|
20
32
|
const searchFailed = ref(false);
|
|
21
33
|
const debounce = computed<number>(() => props.node.props?.searchDebounce ?? 250);
|
|
22
34
|
const cache = new Map<string, SelectOption[]>();
|
|
@@ -41,7 +53,7 @@ function cancelPendingSearch(): void {
|
|
|
41
53
|
}
|
|
42
54
|
|
|
43
55
|
controller?.abort();
|
|
44
|
-
|
|
56
|
+
searching.value = false;
|
|
45
57
|
}
|
|
46
58
|
|
|
47
59
|
/**
|
|
@@ -82,7 +94,7 @@ function onSearch(input: string): void {
|
|
|
82
94
|
timer = setTimeout(async () => {
|
|
83
95
|
timer = null;
|
|
84
96
|
controller = new AbortController();
|
|
85
|
-
|
|
97
|
+
searching.value = true;
|
|
86
98
|
|
|
87
99
|
try {
|
|
88
100
|
const results = await store.search(path, term, controller.signal);
|
|
@@ -96,7 +108,7 @@ function onSearch(input: string): void {
|
|
|
96
108
|
console.error('[xelqun] Search failed.', searchError);
|
|
97
109
|
}
|
|
98
110
|
} finally {
|
|
99
|
-
|
|
111
|
+
searching.value = false;
|
|
100
112
|
}
|
|
101
113
|
}, debounce.value);
|
|
102
114
|
}
|
|
@@ -109,7 +121,7 @@ const hasValue = computed(
|
|
|
109
121
|
</script>
|
|
110
122
|
|
|
111
123
|
<template>
|
|
112
|
-
<XqFieldWrapper :node="node" :id="id" :error="error" :required="required">
|
|
124
|
+
<XqFieldWrapper :node="node" :id="id" :error="error" :required="required" control-shows-loading>
|
|
113
125
|
<XqCombobox
|
|
114
126
|
v-if="useCombobox"
|
|
115
127
|
:id="id"
|
|
@@ -117,12 +129,15 @@ const hasValue = computed(
|
|
|
117
129
|
:options="isRemote ? remoteOptions : options"
|
|
118
130
|
:multiple="!!node.props?.multiple"
|
|
119
131
|
:remote="isRemote"
|
|
120
|
-
:loading="
|
|
121
|
-
:
|
|
132
|
+
:loading="showLoading"
|
|
133
|
+
:searching="searching"
|
|
134
|
+
:placeholder="placeholder"
|
|
122
135
|
:disabled="disabled"
|
|
123
136
|
:invalid="!!error"
|
|
124
137
|
:described-by="describedBy"
|
|
125
138
|
:search-failed="searchFailed"
|
|
139
|
+
:messages="node.props?.messages"
|
|
140
|
+
:reorderable="node.props?.reorderable"
|
|
126
141
|
@search="onSearch"
|
|
127
142
|
@blur="onBlur"
|
|
128
143
|
/>
|
|
@@ -131,17 +146,19 @@ const hasValue = computed(
|
|
|
131
146
|
:id="id"
|
|
132
147
|
v-model="value"
|
|
133
148
|
:options="options"
|
|
134
|
-
:
|
|
149
|
+
:loading="showLoading"
|
|
150
|
+
:placeholder="placeholder"
|
|
135
151
|
:disabled="disabled"
|
|
136
152
|
:invalid="!!error"
|
|
137
153
|
:described-by="describedBy"
|
|
154
|
+
:messages="node.props?.messages"
|
|
138
155
|
:class="!required && hasValue ? '[&>span:first-child]:pr-6' : undefined"
|
|
139
156
|
/>
|
|
140
157
|
<button
|
|
141
158
|
v-if="!required && hasValue && !disabled"
|
|
142
159
|
type="button"
|
|
143
160
|
class="right-9 size-5 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"
|
|
144
|
-
aria-label="Clear selection"
|
|
161
|
+
:aria-label="t('Clear selection')"
|
|
145
162
|
@click="value = null"
|
|
146
163
|
>
|
|
147
164
|
<X class="size-3.5" />
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { SliderRange, SliderRoot, SliderThumb, SliderTrack } from 'reka-ui';
|
|
3
|
+
import { computed } from 'vue';
|
|
4
|
+
import { useField } from '../../composables/useField';
|
|
5
|
+
import type { SchemaNode } from '../../schema/types';
|
|
6
|
+
import XqFieldWrapper from '../XqFieldWrapper.vue';
|
|
7
|
+
|
|
8
|
+
const props = defineProps<{ node: SchemaNode }>();
|
|
9
|
+
const { value, error, label, required, disabled, id, onBlur } = useField<number | number[] | null>(
|
|
10
|
+
props.node,
|
|
11
|
+
);
|
|
12
|
+
const options = computed(() => props.node.props ?? {});
|
|
13
|
+
|
|
14
|
+
const handles = computed<number[]>({
|
|
15
|
+
get: () => (Array.isArray(value.value) ? value.value : [value.value ?? options.value.min]),
|
|
16
|
+
set: (next) => (value.value = Array.isArray(value.value) ? next : next[0]),
|
|
17
|
+
});
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<template>
|
|
21
|
+
<XqFieldWrapper :node="node" :id="id" :label-for="null" :error="error" :required="required">
|
|
22
|
+
<div class="gap-3 flex items-center">
|
|
23
|
+
<SliderRoot
|
|
24
|
+
v-model="handles"
|
|
25
|
+
:min="options.min"
|
|
26
|
+
:max="options.max"
|
|
27
|
+
:step="options.step"
|
|
28
|
+
:disabled="disabled"
|
|
29
|
+
class="h-5 relative flex flex-1 touch-none items-center select-none data-[disabled]:opacity-50"
|
|
30
|
+
@value-commit="onBlur"
|
|
31
|
+
>
|
|
32
|
+
<SliderTrack class="h-1.5 relative grow rounded-full bg-xq-muted">
|
|
33
|
+
<SliderRange class="absolute h-full rounded-full bg-xq-primary" />
|
|
34
|
+
</SliderTrack>
|
|
35
|
+
<SliderThumb
|
|
36
|
+
v-for="(_, index) in handles"
|
|
37
|
+
:key="index"
|
|
38
|
+
:aria-label="label ?? undefined"
|
|
39
|
+
class="size-4 shadow-xs block cursor-pointer rounded-full border border-xq-primary bg-xq-background outline-none focus-visible:ring-[3px] focus-visible:ring-xq-ring/30"
|
|
40
|
+
/>
|
|
41
|
+
</SliderRoot>
|
|
42
|
+
<output v-if="options.tooltips" class="text-sm text-right tabular-nums">
|
|
43
|
+
{{ handles.join(' – ') }}
|
|
44
|
+
</output>
|
|
45
|
+
</div>
|
|
46
|
+
</XqFieldWrapper>
|
|
47
|
+
</template>
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ChevronLeft, ChevronRight, X } from 'lucide-vue-next';
|
|
3
|
+
import {
|
|
4
|
+
TagsInputInput,
|
|
5
|
+
TagsInputItem,
|
|
6
|
+
TagsInputItemDelete,
|
|
7
|
+
TagsInputItemText,
|
|
8
|
+
TagsInputRoot,
|
|
9
|
+
} from 'reka-ui';
|
|
10
|
+
import { computed, ref } from 'vue';
|
|
11
|
+
import { useField } from '../../composables/useField';
|
|
12
|
+
import { useSortable } from '../../composables/useSortable';
|
|
13
|
+
import { cn } from '../../lib/utils';
|
|
14
|
+
import type { SchemaNode } from '../../schema/types';
|
|
15
|
+
import { controlClass } from '../../ui/classes';
|
|
16
|
+
import { moveItem } from '../../ui/options';
|
|
17
|
+
import XqFieldWrapper from '../XqFieldWrapper.vue';
|
|
18
|
+
import { useTranslator } from '../../composables/context';
|
|
19
|
+
|
|
20
|
+
const t = useTranslator();
|
|
21
|
+
const props = defineProps<{ node: SchemaNode }>();
|
|
22
|
+
const { value, error, placeholder, describedBy, required, disabled, id, onBlur } = useField<
|
|
23
|
+
string[]
|
|
24
|
+
>(props.node);
|
|
25
|
+
const options = computed(() => props.node.props ?? {});
|
|
26
|
+
const suggestions = computed<string[]>(() => options.value.suggestions ?? []);
|
|
27
|
+
const splitKeys = computed<string[]>(() => options.value.splitKeys ?? []);
|
|
28
|
+
|
|
29
|
+
const tags = computed<string[]>({
|
|
30
|
+
get: () => value.value ?? [],
|
|
31
|
+
set: (next) => (value.value = next),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/** Enter always finishes a tag. Reka takes the other keys as one pattern, and a comma without one. */
|
|
35
|
+
const delimiter = computed(() => {
|
|
36
|
+
const characters = splitKeys.value.filter((key) => key.length === 1);
|
|
37
|
+
|
|
38
|
+
return characters.length
|
|
39
|
+
? new RegExp(`[${characters.map((key) => `\\${key}`).join('')}]`)
|
|
40
|
+
: undefined;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
function move(tag: string, offset: number): void {
|
|
44
|
+
tags.value = moveItem(tags.value, tag, offset);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const tagList = ref<HTMLElement | null>(null);
|
|
48
|
+
|
|
49
|
+
useSortable(tagList, {
|
|
50
|
+
draggable: '> [data-xq-chip]',
|
|
51
|
+
enabled: () => !!options.value.reorderable && !disabled.value && tags.value.length > 1,
|
|
52
|
+
onMove: (from, to) => move(tags.value[from], to - from),
|
|
53
|
+
});
|
|
54
|
+
</script>
|
|
55
|
+
|
|
56
|
+
<template>
|
|
57
|
+
<XqFieldWrapper :node="node" :id="id" :error="error" :required="required">
|
|
58
|
+
<TagsInputRoot
|
|
59
|
+
v-model="tags"
|
|
60
|
+
:disabled="disabled"
|
|
61
|
+
:delimiter="delimiter ?? ''"
|
|
62
|
+
:add-on-tab="splitKeys.includes('Tab')"
|
|
63
|
+
add-on-paste
|
|
64
|
+
add-on-blur
|
|
65
|
+
:class="
|
|
66
|
+
cn(
|
|
67
|
+
controlClass,
|
|
68
|
+
'min-h-9 gap-1 px-2 py-1 flex flex-wrap items-center focus-within:border-xq-ring focus-within:ring-[3px] focus-within:ring-xq-ring/25',
|
|
69
|
+
error && 'border-xq-destructive',
|
|
70
|
+
)
|
|
71
|
+
"
|
|
72
|
+
>
|
|
73
|
+
<span ref="tagList" class="gap-1 flex max-w-full flex-wrap">
|
|
74
|
+
<TagsInputItem
|
|
75
|
+
data-xq-chip
|
|
76
|
+
v-for="(tag, index) in tags"
|
|
77
|
+
:key="tag"
|
|
78
|
+
:value="tag"
|
|
79
|
+
:class="[options.reorderable && 'cursor-grab touch-none']"
|
|
80
|
+
class="gap-1 py-0.5 pr-1 pl-2 text-xs font-medium inline-flex items-center rounded-xq-sm bg-xq-secondary text-xq-secondary-foreground data-[state=active]:ring-2 data-[state=active]:ring-xq-ring"
|
|
81
|
+
>
|
|
82
|
+
<button
|
|
83
|
+
v-if="options.reorderable && index > 0"
|
|
84
|
+
type="button"
|
|
85
|
+
class="rounded-sm cursor-pointer opacity-60 hover:opacity-100"
|
|
86
|
+
:aria-label="t('Move :name earlier', { name: tag })"
|
|
87
|
+
:disabled="disabled"
|
|
88
|
+
@click="move(tag, -1)"
|
|
89
|
+
>
|
|
90
|
+
<ChevronLeft class="size-3" />
|
|
91
|
+
</button>
|
|
92
|
+
<span v-if="options.tagPrefix">{{ options.tagPrefix }}</span>
|
|
93
|
+
<TagsInputItemText />
|
|
94
|
+
<span v-if="options.tagSuffix">{{ options.tagSuffix }}</span>
|
|
95
|
+
<button
|
|
96
|
+
v-if="options.reorderable && index < tags.length - 1"
|
|
97
|
+
type="button"
|
|
98
|
+
class="rounded-sm cursor-pointer opacity-60 hover:opacity-100"
|
|
99
|
+
:aria-label="t('Move :name later', { name: tag })"
|
|
100
|
+
:disabled="disabled"
|
|
101
|
+
@click="move(tag, 1)"
|
|
102
|
+
>
|
|
103
|
+
<ChevronRight class="size-3" />
|
|
104
|
+
</button>
|
|
105
|
+
<TagsInputItemDelete
|
|
106
|
+
class="rounded-sm cursor-pointer opacity-60 hover:opacity-100"
|
|
107
|
+
:aria-label="t('Remove :name', { name: tag })"
|
|
108
|
+
>
|
|
109
|
+
<X class="size-3" />
|
|
110
|
+
</TagsInputItemDelete>
|
|
111
|
+
</TagsInputItem>
|
|
112
|
+
</span>
|
|
113
|
+
<TagsInputInput
|
|
114
|
+
:id="id"
|
|
115
|
+
:placeholder="placeholder ?? t('Add a tag…')"
|
|
116
|
+
:list="suggestions.length ? `${id}-list` : undefined"
|
|
117
|
+
:aria-invalid="error ? true : undefined"
|
|
118
|
+
:aria-describedby="describedBy"
|
|
119
|
+
class="h-7 min-w-24 px-1 text-sm flex-1 bg-transparent outline-none placeholder:text-xq-muted-foreground"
|
|
120
|
+
@blur="onBlur"
|
|
121
|
+
/>
|
|
122
|
+
<datalist v-if="suggestions.length" :id="`${id}-list`">
|
|
123
|
+
<option v-for="suggestion in suggestions" :key="suggestion" :value="suggestion" />
|
|
124
|
+
</datalist>
|
|
125
|
+
</TagsInputRoot>
|
|
126
|
+
</XqFieldWrapper>
|
|
127
|
+
</template>
|