@xelqun/forms 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +46 -61
  2. package/package.json +32 -2
  3. package/resources/css/xelqun.css +121 -0
  4. package/resources/js/components/XqAction.vue +143 -0
  5. package/resources/js/components/XqFieldSlot.vue +20 -0
  6. package/resources/js/components/XqFieldWrapper.vue +74 -30
  7. package/resources/js/components/XqForm.vue +36 -9
  8. package/resources/js/components/XqNode.vue +8 -1
  9. package/resources/js/components/XqUnknownNode.vue +2 -1
  10. package/resources/js/components/defaults.ts +36 -0
  11. package/resources/js/components/fields/XqBlockPicker.vue +68 -0
  12. package/resources/js/components/fields/XqCheckboxField.vue +35 -9
  13. package/resources/js/components/fields/XqCheckboxListField.vue +115 -0
  14. package/resources/js/components/fields/XqCodeEditorField.vue +92 -0
  15. package/resources/js/components/fields/XqColorPickerField.vue +156 -0
  16. package/resources/js/components/fields/XqDatePickerField.vue +149 -53
  17. package/resources/js/components/fields/XqFileUploadField.vue +524 -165
  18. package/resources/js/components/fields/XqImageEditor.vue +165 -0
  19. package/resources/js/components/fields/XqKeyValueField.vue +168 -0
  20. package/resources/js/components/fields/XqMarkdownEditorField.vue +245 -0
  21. package/resources/js/components/fields/XqRadioField.vue +9 -5
  22. package/resources/js/components/fields/XqRepeaterField.vue +314 -79
  23. package/resources/js/components/fields/XqRepeaterRowActions.vue +101 -0
  24. package/resources/js/components/fields/XqRichEditorField.vue +280 -0
  25. package/resources/js/components/fields/XqSelectField.vue +29 -12
  26. package/resources/js/components/fields/XqSliderField.vue +47 -0
  27. package/resources/js/components/fields/XqTagsInputField.vue +127 -0
  28. package/resources/js/components/fields/XqTextInputField.vue +121 -12
  29. package/resources/js/components/fields/XqTextareaField.vue +39 -16
  30. package/resources/js/components/fields/XqToggleButtonsField.vue +75 -0
  31. package/resources/js/components/fields/XqToggleField.vue +33 -9
  32. package/resources/js/components/layouts/XqActions.vue +19 -0
  33. package/resources/js/components/layouts/XqBlock.vue +52 -0
  34. package/resources/js/components/layouts/XqCallout.vue +46 -0
  35. package/resources/js/components/layouts/XqEmptyState.vue +37 -0
  36. package/resources/js/components/layouts/XqFlex.vue +23 -0
  37. package/resources/js/components/layouts/XqFusedGroup.vue +48 -0
  38. package/resources/js/components/layouts/XqIconNode.vue +32 -0
  39. package/resources/js/components/layouts/XqImageNode.vue +30 -0
  40. package/resources/js/components/layouts/XqSection.vue +3 -1
  41. package/resources/js/components/layouts/XqTabsLayout.vue +3 -21
  42. package/resources/js/components/layouts/XqTextNode.vue +49 -0
  43. package/resources/js/components/layouts/XqUnorderedList.vue +18 -0
  44. package/resources/js/components/layouts/XqWizard.vue +168 -0
  45. package/resources/js/composables/context.ts +14 -0
  46. package/resources/js/composables/useField.ts +21 -3
  47. package/resources/js/composables/usePanelErrors.ts +37 -0
  48. package/resources/js/composables/useSortable.ts +70 -0
  49. package/resources/js/composables/useUnsavedChangesAlert.ts +48 -0
  50. package/resources/js/core/dates.ts +38 -0
  51. package/resources/js/core/images.ts +110 -0
  52. package/resources/js/core/log.ts +1 -1
  53. package/resources/js/core/markdown.ts +61 -0
  54. package/resources/js/core/mask.ts +42 -0
  55. package/resources/js/core/patch.ts +6 -3
  56. package/resources/js/core/paths.ts +11 -14
  57. package/resources/js/core/store.ts +515 -25
  58. package/resources/js/core/translate.ts +17 -0
  59. package/resources/js/core/validation.ts +126 -0
  60. package/resources/js/index.ts +4 -3
  61. package/resources/js/lib/utils.ts +15 -0
  62. package/resources/js/plugin.ts +103 -6
  63. package/resources/js/schema/types.ts +102 -0
  64. package/resources/js/testing.ts +50 -0
  65. package/resources/js/ui/XqCalendar.vue +4 -0
  66. package/resources/js/ui/XqCombobox.vue +89 -32
  67. package/resources/js/ui/XqConfirmDialog.vue +58 -0
  68. package/resources/js/ui/XqDialog.vue +46 -0
  69. package/resources/js/ui/XqIcon.vue +28 -0
  70. package/resources/js/ui/XqSelect.vue +38 -18
  71. package/resources/js/ui/XqTabs.vue +1 -1
  72. package/resources/js/ui/XqTimeField.vue +66 -0
  73. package/resources/js/ui/classes.ts +33 -0
  74. package/resources/js/ui/icons.ts +95 -0
  75. package/resources/js/ui/index.ts +5 -0
  76. package/resources/js/ui/options.ts +46 -0
@@ -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 { store, path, value, error, describedBy, required, disabled, id, onBlur } = useField(
12
- props.node,
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 loading = ref(false);
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
- loading.value = false;
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
- loading.value = true;
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
- loading.value = false;
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="loading"
121
- :placeholder="node.placeholder"
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
- :placeholder="node.placeholder"
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>