@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.
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
@@ -1,140 +1,362 @@
1
1
  <script setup lang="ts">
2
- import { FileText, LoaderCircle, UploadCloud, X } from 'lucide-vue-next';
3
- import { computed, onBeforeUnmount, ref } from 'vue';
2
+ import {
3
+ ChevronDown,
4
+ ChevronUp,
5
+ Download,
6
+ ExternalLink,
7
+ FileText,
8
+ GripVertical,
9
+ LoaderCircle,
10
+ Pencil,
11
+ UploadCloud,
12
+ X,
13
+ } from 'lucide-vue-next';
14
+ import { computed, defineAsyncComponent, nextTick, onBeforeUnmount, ref } from 'vue';
4
15
  import { useField } from '../../composables/useField';
16
+ import { prepareImage } from '../../core/images';
17
+ import { useSortable } from '../../composables/useSortable';
5
18
  import { HttpError } from '../../core/http';
6
- import { cn, formatBytes } from '../../lib/utils';
19
+ import { cn, focusFirstEnabled, formatBytes } from '../../lib/utils';
7
20
  import type { SchemaNode } from '../../schema/types';
21
+ import { moveItem } from '../../ui/options';
8
22
  import XqButton from '../../ui/XqButton.vue';
9
23
  import XqFieldWrapper from '../XqFieldWrapper.vue';
24
+ import { useTranslator } from '../../composables/context';
25
+
26
+ const t = useTranslator();
27
+ const XqImageEditor = defineAsyncComponent(() => import('./XqImageEditor.vue'));
28
+
29
+ interface PendingFile {
30
+ id: number;
31
+ file: File;
32
+ name: string;
33
+ size: number;
34
+ percent: number;
35
+ status: 'waiting' | 'uploading' | 'finished';
36
+ token: string | null;
37
+ controller: AbortController;
38
+ }
10
39
 
11
40
  const props = defineProps<{ node: SchemaNode }>();
12
- const { store, path, value, error, describedBy, required, disabled, id } = useField<string | null>(
13
- props.node,
14
- );
41
+ const { store, path, value, error, describedBy, required, disabled, id } = useField<
42
+ string | string[] | null
43
+ >(props.node);
15
44
  const options = computed(() => props.node.props ?? {});
45
+ const isMultiple = computed(() => !!options.value.multiple);
46
+ // One at a time by default: on SQLite two uploads at once hit "database is locked" in the rate limiter.
47
+ const parallelLimit = computed<number>(() => options.value.maxParallelUploads ?? 1);
16
48
 
17
49
  const input = ref<HTMLInputElement | null>(null);
50
+ const root = ref<HTMLElement | null>(null);
51
+ const fileList = ref<HTMLElement | null>(null);
18
52
  const dragging = ref(false);
19
- const progress = ref<{ name: string; size: number; percent: number } | null>(null);
20
53
  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;
54
+ const pending = ref<PendingFile[]>([]);
55
+ const editing = ref<ReturnType<typeof describe> | null>(null);
56
+ /** What this page knows about files it uploaded, before they have a stored path. */
57
+ const uploaded = ref(new Map<string, { name: string; size: number; preview: string | null }>());
58
+ let nextPendingId = 0;
59
+ let running = 0;
25
60
 
26
- const file = computed(() => {
27
- if (!value.value) {
28
- return null;
61
+ const values = computed<string[]>(() => {
62
+ if (Array.isArray(value.value)) {
63
+ return value.value;
29
64
  }
30
65
 
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
- }
66
+ return value.value ? [value.value] : [];
67
+ });
39
68
 
40
- const existing = options.value.files?.[value.value];
69
+ function setValues(next: string[]): void {
70
+ value.value = isMultiple.value ? next : (next[0] ?? null);
71
+ }
72
+
73
+ function describe(file: string) {
74
+ const local = uploaded.value.get(file);
75
+ const known = options.value.files?.[file];
76
+ const name = local?.name ?? known?.name ?? file.split('/').pop() ?? t('File');
77
+ const url: string | null = local?.preview ?? known?.url ?? null;
41
78
 
42
79
  return {
43
- name: existing?.name ?? value.value.split('/').pop() ?? 'File',
44
- size: existing?.size ?? null,
45
- url: existing?.url ?? null,
46
- isNew: false,
80
+ value: file,
81
+ name,
82
+ size: local?.size ?? known?.size ?? null,
83
+ url,
84
+ isNew: !!local,
85
+ isImage:
86
+ !!url &&
87
+ options.value.previewable !== false &&
88
+ (options.value.image || /\.(png|jpe?g|gif|webp|avif|svg)$/i.test(name)),
47
89
  };
48
- });
90
+ }
49
91
 
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)),
92
+ const files = computed(() => values.value.map(describe));
93
+ const hasRoom = computed(() =>
94
+ isMultiple.value
95
+ ? values.value.length + pending.value.length < (options.value.maxFiles ?? Infinity)
96
+ : values.value.length === 0 && pending.value.length === 0,
97
+ );
98
+ const canReorder = computed(
99
+ () => !!options.value.reorderable && isMultiple.value && !disabled.value,
54
100
  );
55
-
56
101
  const inputDescribedBy = computed(() => (uploadError.value ? `${id}-error` : describedBy.value));
102
+ const isAvatar = computed(() => !!options.value.avatar);
57
103
 
58
- function revokePreview(): void {
59
- if (local.value?.preview) {
60
- URL.revokeObjectURL(local.value.preview);
104
+ useSortable(fileList, {
105
+ draggable: '> [data-xq-file]',
106
+ handle: `[data-xq-drag="${path}"]`,
107
+ enabled: () => canReorder.value && values.value.length > 1,
108
+ onMove: (from, to) => move(values.value[from], to - from),
109
+ });
110
+
111
+ function move(file: string, offset: number): void {
112
+ setValues(moveItem(values.value, file, offset));
113
+ }
114
+
115
+ function fileSelector(file: string): string {
116
+ return `[data-xq-file="${CSS.escape(file)}"]`;
117
+ }
118
+
119
+ /** The button moved with its row, and a move ending at the edge disables it. */
120
+ async function moveWithButton(file: string, offset: number): Promise<void> {
121
+ move(file, offset);
122
+
123
+ await nextTick();
124
+
125
+ focusFirstEnabled(root.value, [
126
+ `${fileSelector(file)} [data-xq-move="${offset}"]`,
127
+ `${fileSelector(file)} [data-xq-move="${-offset}"]`,
128
+ ]);
129
+ }
130
+
131
+ function forget(file: string): void {
132
+ const local = uploaded.value.get(file);
133
+
134
+ if (local?.preview) {
135
+ URL.revokeObjectURL(local.preview);
61
136
  }
137
+
138
+ uploaded.value.delete(file);
62
139
  }
63
140
 
64
- async function upload(picked: File): Promise<void> {
65
- uploadError.value = null;
141
+ function canRemove(file: { isNew: boolean }): boolean {
142
+ return !disabled.value && (file.isNew || options.value.deletable !== false);
143
+ }
66
144
 
145
+ function sizeProblem(picked: File): string | null {
67
146
  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)}.`;
147
+ return t('The file may not be larger than :size.', {
148
+ size: formatBytes(options.value.maxSize * 1024),
149
+ });
150
+ }
69
151
 
70
- return;
152
+ if (options.value.minSize && picked.size < options.value.minSize * 1024) {
153
+ return t('The file must be at least :size.', {
154
+ size: formatBytes(options.value.minSize * 1024),
155
+ });
71
156
  }
72
157
 
73
- controller?.abort();
74
- controller = new AbortController();
75
- progress.value = { name: picked.name, size: picked.size, percent: 0 };
158
+ return null;
159
+ }
160
+
161
+ /**
162
+ * Add finished files in the order they were picked, whichever finished first.
163
+ */
164
+ function collectFinished(): void {
165
+ while (pending.value[0]?.status === 'finished') {
166
+ const { token } = pending.value.shift()!;
167
+
168
+ if (token) {
169
+ setValues(
170
+ isMultiple.value && options.value.appendFiles !== false
171
+ ? [...values.value, token]
172
+ : [token, ...values.value],
173
+ );
174
+ }
175
+ }
176
+ }
177
+
178
+ async function uploadOne(item: PendingFile): Promise<void> {
179
+ item.status = 'uploading';
76
180
 
77
181
  try {
182
+ const file = await prepareImage(item.file, options.value.imageResize);
78
183
  const result = await store.upload(
79
184
  path,
80
- picked,
81
- (percent) => progress.value && (progress.value.percent = percent),
82
- controller.signal,
185
+ file,
186
+ (percent) => (item.percent = percent),
187
+ item.controller.signal,
83
188
  );
84
189
 
85
- revokePreview();
86
- local.value = {
87
- token: result.token,
190
+ uploaded.value.set(result.token, {
88
191
  name: result.name,
89
192
  size: result.size,
90
- preview: picked.type.startsWith('image/') ? URL.createObjectURL(picked) : null,
91
- };
92
- value.value = result.token;
193
+ preview: file.type.startsWith('image/') ? URL.createObjectURL(file) : null,
194
+ });
195
+ item.token = result.token;
93
196
  } catch (failure) {
94
- if (controller?.signal.aborted) {
95
- return;
197
+ if (!item.controller.signal.aborted) {
198
+ uploadError.value =
199
+ failure instanceof HttpError
200
+ ? (failure.validationErrors.file?.[0] ?? failure.message)
201
+ : t('The upload failed. Please try again.');
96
202
  }
203
+ } finally {
204
+ item.status = 'finished';
205
+ collectFinished();
206
+ }
207
+ }
208
+
209
+ async function work(): Promise<void> {
210
+ running++;
97
211
 
212
+ for (
213
+ let item = pending.value.find(({ status }) => status === 'waiting');
214
+ item;
215
+ item = pending.value.find(({ status }) => status === 'waiting')
216
+ ) {
217
+ await uploadOne(item);
218
+ }
219
+
220
+ running--;
221
+ }
222
+
223
+ function addFiles(picked: File[]): void {
224
+ uploadError.value = null;
225
+
226
+ const room = isMultiple.value
227
+ ? (options.value.maxFiles ?? Infinity) - values.value.length - pending.value.length
228
+ : 1;
229
+
230
+ if (picked.length > room) {
98
231
  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;
232
+ room === 1
233
+ ? t('You can add 1 more file.')
234
+ : t('You can add :count more files.', { count: room });
235
+ }
236
+
237
+ for (const file of picked.slice(0, room)) {
238
+ const problem = sizeProblem(file);
239
+
240
+ if (problem) {
241
+ uploadError.value = problem;
104
242
 
105
- if (input.value) {
106
- input.value.value = '';
243
+ continue;
107
244
  }
245
+
246
+ pending.value.push({
247
+ id: nextPendingId++,
248
+ file,
249
+ name: file.name,
250
+ size: file.size,
251
+ percent: 0,
252
+ status: 'waiting',
253
+ token: null,
254
+ controller: new AbortController(),
255
+ });
108
256
  }
109
- }
110
257
 
111
- function onChange(event: Event): void {
112
- const picked = (event.target as HTMLInputElement).files?.[0];
258
+ if (input.value) {
259
+ input.value.value = '';
260
+ }
113
261
 
114
- if (picked) {
115
- void upload(picked);
262
+ const workers = Math.min(
263
+ parallelLimit.value - running,
264
+ pending.value.filter(({ status }) => status === 'waiting').length,
265
+ );
266
+
267
+ for (let count = 0; count < workers; count++) {
268
+ store.trackUpload(work());
116
269
  }
117
270
  }
118
271
 
272
+ function onChange(event: Event): void {
273
+ addFiles([...((event.target as HTMLInputElement).files ?? [])]);
274
+ }
275
+
119
276
  function onDrop(event: DragEvent): void {
120
277
  dragging.value = false;
121
- const picked = event.dataTransfer?.files?.[0];
122
278
 
123
- if (picked && !disabled.value) {
124
- void upload(picked);
279
+ if (!disabled.value) {
280
+ addFiles([...(event.dataTransfer?.files ?? [])]);
281
+ }
282
+ }
283
+
284
+ function onPaste(event: ClipboardEvent): void {
285
+ const pasted = [...(event.clipboardData?.files ?? [])];
286
+
287
+ if (options.value.pasteable !== false && pasted.length && !disabled.value && hasRoom.value) {
288
+ event.preventDefault();
289
+ addFiles(pasted);
125
290
  }
126
291
  }
127
292
 
128
- function remove(): void {
129
- controller?.abort();
130
- revokePreview();
131
- local.value = null;
132
- value.value = null;
293
+ function cancel(item: PendingFile): void {
294
+ item.controller.abort();
295
+ pending.value = pending.value.filter((other) => other.id !== item.id);
296
+ collectFinished();
297
+ }
298
+
299
+ function cancelAll(): void {
300
+ pending.value.forEach((item) => item.controller.abort());
301
+ pending.value = [];
302
+ }
303
+
304
+ /**
305
+ * The edited image uploads as a new file, in the place of the old one. The server
306
+ * gives a stored file back if it may not go, so that one can't be edited.
307
+ */
308
+ function canEdit(file: ReturnType<typeof describe>): boolean {
309
+ return (
310
+ !!options.value.imageEditor &&
311
+ file.isImage &&
312
+ !disabled.value &&
313
+ (file.isNew || options.value.deletable !== false)
314
+ );
315
+ }
316
+
317
+ async function saveEdit(edited: File): Promise<void> {
318
+ const original = editing.value!.value;
319
+
320
+ editing.value = null;
321
+ uploadError.value = null;
322
+
323
+ try {
324
+ const result = await store.upload(path, edited, () => {});
325
+
326
+ uploaded.value.set(result.token, {
327
+ name: result.name,
328
+ size: result.size,
329
+ preview: URL.createObjectURL(edited),
330
+ });
331
+ setValues(values.value.map((file) => (file === original ? result.token : file)));
332
+ forget(original);
333
+ } catch (failure) {
334
+ uploadError.value =
335
+ failure instanceof HttpError
336
+ ? (failure.validationErrors.file?.[0] ?? failure.message)
337
+ : t('The upload failed. Please try again.');
338
+ }
339
+ }
340
+
341
+ async function remove(file: string): Promise<void> {
342
+ const index = values.value.indexOf(file);
343
+
344
+ forget(file);
345
+ setValues(values.value.filter((other) => other !== file));
346
+
347
+ await nextTick();
348
+
349
+ const next = values.value[Math.min(index, values.value.length - 1)];
350
+
351
+ focusFirstEnabled(root.value, [
352
+ ...(next ? [`${fileSelector(next)} [data-xq-remove]`] : []),
353
+ 'input[type="file"]',
354
+ ]);
133
355
  }
134
356
 
135
357
  onBeforeUnmount(() => {
136
- controller?.abort();
137
- revokePreview();
358
+ cancelAll();
359
+ [...uploaded.value.keys()].forEach(forget);
138
360
  });
139
361
  </script>
140
362
 
@@ -146,102 +368,239 @@ onBeforeUnmount(() => {
146
368
  :required="required"
147
369
  :announce-error="!!uploadError"
148
370
  >
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
- />
371
+ <div ref="root" class="gap-2 grid" @paste="onPaste">
159
372
  <div
160
- v-else
161
- class="size-10 flex shrink-0 items-center justify-center rounded-xq-sm bg-xq-muted"
373
+ v-if="isAvatar && files[0]"
374
+ class="size-24 relative"
375
+ :data-xq-file="files[0].value"
162
376
  >
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"
377
+ <img
378
+ v-if="files[0].isImage"
379
+ :src="files[0].url!"
380
+ :alt="files[0].name"
381
+ class="size-24 rounded-full object-cover"
382
+ />
383
+ <div
384
+ v-else
385
+ class="size-24 flex items-center justify-center rounded-full bg-xq-muted"
386
+ >
387
+ <FileText class="size-6 text-xq-muted-foreground" />
388
+ </div>
389
+ <XqButton
390
+ v-if="canEdit(files[0])"
391
+ type="button"
392
+ variant="outline"
393
+ size="icon-sm"
394
+ class="right-0 bottom-0 absolute rounded-full"
395
+ :aria-label="t('Edit :name', { name: files[0].name })"
396
+ data-xq-edit-image
397
+ @click="editing = files[0]"
398
+ >
399
+ <Pencil />
400
+ </XqButton>
401
+ <XqButton
402
+ v-if="canRemove(files[0])"
403
+ type="button"
404
+ variant="outline"
405
+ size="icon-sm"
406
+ class="top-0 right-0 absolute rounded-full"
407
+ :aria-label="t('Remove :name', { name: files[0].name })"
408
+ data-xq-remove
409
+ @click="remove(files[0].value)"
172
410
  >
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>
411
+ <X />
412
+ </XqButton>
180
413
  </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"
414
+ <div v-else-if="files.length" ref="fileList" class="gap-2 grid">
415
+ <div
416
+ v-for="(file, index) in files"
417
+ :key="file.value"
418
+ :data-xq-file="file.value"
419
+ class="gap-3 p-2 pr-3 flex items-center rounded-xq-md border border-xq-border bg-xq-background"
217
420
  >
218
421
  <span
219
- class="block h-full bg-xq-primary transition-[width]"
220
- :style="{ width: `${progress.percent}%` }"
422
+ v-if="canReorder && files.length > 1"
423
+ aria-hidden="true"
424
+ :data-xq-drag="path"
425
+ class="cursor-grab touch-none text-xq-muted-foreground"
426
+ >
427
+ <GripVertical class="size-4" />
428
+ </span>
429
+ <img
430
+ v-if="file.isImage"
431
+ :src="file.url!"
432
+ alt=""
433
+ class="size-10 shrink-0 rounded-xq-sm object-cover"
221
434
  />
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
435
+ <div
436
+ v-else
437
+ class="size-10 flex shrink-0 items-center justify-center rounded-xq-sm bg-xq-muted"
438
+ >
439
+ <FileText class="size-5 text-xq-muted-foreground" />
440
+ </div>
441
+ <div class="min-w-0 flex-1">
442
+ <p class="text-sm font-medium truncate">{{ file.name }}</p>
443
+ <p class="text-xs text-xq-muted-foreground">
444
+ {{ file.isNew ? t('Uploaded, saved when you submit') : t('Stored')
445
+ }}<template v-if="file.size"> · {{ formatBytes(file.size) }}</template>
446
+ </p>
447
+ </div>
448
+ <template v-if="canReorder && files.length > 1">
449
+ <XqButton
450
+ type="button"
451
+ variant="ghost"
452
+ size="icon-sm"
453
+ :disabled="index === 0"
454
+ :aria-label="t('Move :name up', { name: file.name })"
455
+ data-xq-move="-1"
456
+ @click="moveWithButton(file.value, -1)"
457
+ >
458
+ <ChevronUp />
459
+ </XqButton>
460
+ <XqButton
461
+ type="button"
462
+ variant="ghost"
463
+ size="icon-sm"
464
+ :disabled="index === files.length - 1"
465
+ :aria-label="t('Move :name down', { name: file.name })"
466
+ data-xq-move="1"
467
+ @click="moveWithButton(file.value, 1)"
468
+ >
469
+ <ChevronDown />
470
+ </XqButton>
471
+ </template>
472
+ <XqButton
473
+ v-if="canEdit(file)"
474
+ type="button"
475
+ variant="ghost"
476
+ size="icon-sm"
477
+ :aria-label="t('Edit :name', { name: file.name })"
478
+ data-xq-edit-image
479
+ @click="editing = file"
480
+ >
481
+ <Pencil />
482
+ </XqButton>
483
+ <XqButton
484
+ v-if="options.openable && file.url && !file.isNew"
485
+ as="a"
486
+ :href="file.url"
487
+ target="_blank"
488
+ rel="noopener"
489
+ variant="ghost"
490
+ size="icon-sm"
491
+ :aria-label="t('Open :name', { name: file.name })"
492
+ >
493
+ <ExternalLink />
494
+ </XqButton>
495
+ <XqButton
496
+ v-if="options.downloadable && file.url && !file.isNew"
497
+ as="a"
498
+ :href="file.url"
499
+ :download="file.name"
500
+ variant="ghost"
501
+ size="icon-sm"
502
+ :aria-label="t('Download :name', { name: file.name })"
503
+ >
504
+ <Download />
505
+ </XqButton>
506
+ <XqButton
507
+ v-if="canRemove(file)"
508
+ type="button"
509
+ variant="ghost"
510
+ size="icon-sm"
511
+ :aria-label="
512
+ isMultiple ? t('Remove :name', { name: file.name }) : t('Remove file')
513
+ "
514
+ data-xq-remove
515
+ @click="remove(file.value)"
516
+ >
517
+ <X />
518
+ </XqButton>
519
+ </div>
520
+ </div>
521
+ <div
522
+ v-for="item in pending"
523
+ :key="item.id"
524
+ class="gap-3 p-2 pr-3 flex items-center rounded-xq-md border border-dashed border-xq-border bg-xq-background"
525
+ >
526
+ <LoaderCircle class="size-5 motion-safe:animate-spin shrink-0 text-xq-primary" />
527
+ <div class="min-w-0 gap-1 grid flex-1">
528
+ <span class="text-sm font-medium truncate">{{ item.name }}</span>
529
+ <span
530
+ role="progressbar"
531
+ aria-valuemin="0"
532
+ aria-valuemax="100"
533
+ :aria-valuenow="item.percent"
534
+ :aria-label="t('Uploading :name', { name: item.name })"
535
+ class="h-1.5 w-full overflow-hidden rounded-full bg-xq-muted"
536
+ >
537
+ <span
538
+ class="block h-full bg-xq-primary transition-[width]"
539
+ :style="{ width: `${item.percent}%` }"
540
+ />
541
+ </span>
542
+ </div>
543
+ <XqButton
544
+ type="button"
545
+ variant="ghost"
546
+ size="icon-sm"
547
+ :aria-label="t('Cancel :name', { name: item.name })"
548
+ @click="cancel(item)"
232
549
  >
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>
550
+ <X />
551
+ </XqButton>
552
+ </div>
553
+ <label
554
+ v-if="hasRoom"
555
+ :for="id"
556
+ :class="
557
+ cn(
558
+ 'gap-1.5 px-4 py-5 flex cursor-pointer flex-col items-center justify-center 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',
559
+ isAvatar ? 'size-24 rounded-full' : 'rounded-xq-md',
560
+ dragging && 'border-xq-primary bg-xq-primary/5',
561
+ (error || uploadError) && 'border-xq-destructive',
562
+ disabled && 'pointer-events-none opacity-60',
563
+ )
564
+ "
565
+ @dragover.prevent="dragging = true"
566
+ @dragleave.prevent="dragging = false"
567
+ @drop.prevent="onDrop"
568
+ >
569
+ <UploadCloud class="size-5 text-xq-muted-foreground" />
570
+ <template v-if="!isAvatar">
571
+ <span class="text-sm"
572
+ ><span class="font-medium text-xq-primary">{{
573
+ isMultiple ? t('Choose files') : t('Choose a file')
574
+ }}</span>
575
+ {{ isMultiple ? t('or drag them here') : t('or drag it here') }}</span
576
+ >
577
+ <span class="text-xs text-xq-muted-foreground">{{
578
+ t('Up to :size', { size: formatBytes((options.maxSize ?? 10240) * 1024) })
579
+ }}</span>
580
+ </template>
581
+ <span v-else class="sr-only">{{ t('Choose an image') }}</span>
582
+ <input
583
+ :id="id"
584
+ ref="input"
585
+ type="file"
586
+ class="sr-only"
587
+ :multiple="isMultiple"
588
+ :accept="options.accept ?? undefined"
589
+ :disabled="disabled"
590
+ :aria-invalid="error || uploadError ? true : undefined"
591
+ :aria-describedby="inputDescribedBy"
592
+ @change="onChange"
593
+ />
594
+ </label>
595
+ </div>
596
+ <XqImageEditor
597
+ v-if="editing"
598
+ :src="editing.url!"
599
+ :name="editing.name"
600
+ :aspect-ratios="options.imageEditor?.aspectRatios"
601
+ :fill-color="options.imageEditor?.fillColor"
602
+ @save="saveEdit"
603
+ @cancel="editing = null"
604
+ />
246
605
  </XqFieldWrapper>
247
606
  </template>