@xy-planning-network/trees 0.15.2 → 0.15.4
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/dist/trees.es.js +17051 -12088
- package/dist/trees.umd.js +81 -13
- package/package.json +3 -2
- package/src/index.css +10 -0
- package/src/lib-components/forms/Combobox.vue +240 -0
- package/src/lib-components/forms/FileUpload.vue +596 -0
- package/src/lib-components/forms/FormCell.vue +52 -0
- package/src/lib-components/forms/FormDisplay.vue +96 -0
- package/src/lib-components/forms/FormGrid.vue +23 -0
- package/src/lib-components/forms/FormSection.vue +57 -0
- package/src/lib-components/forms/InputDisplay.vue +36 -0
- package/src/lib-components/forms/InputHelp.vue +9 -2
- package/src/lib-components/forms/MultiSelect.vue +404 -0
- package/src/lib-components/forms/RadioCards.vue +14 -1
- package/src/lib-components/forms/RepeaterDisplay.vue +214 -0
- package/src/lib-components/indicators/DismissableBadge.vue +31 -0
- package/types/composables/badge.d.ts +8 -0
- package/types/composables/forms.d.ts +22 -0
- package/types/composables/http.d.ts +8 -0
- package/types/composables/index.d.ts +4 -0
- package/types/composables/useFieldsSchema.d.ts +115 -0
- package/types/composables/useInlineMarkdown.d.ts +20 -0
- package/types/composables/useRepeater.d.ts +116 -0
- package/types/entry.d.ts +2 -1
- package/types/helpers/Bytes.d.ts +14 -0
- package/types/helpers/GetProperty.d.ts +21 -0
- package/types/helpers/SetProperty.d.ts +18 -0
- package/types/lib-components/forms/BaseInput.vue.d.ts +1 -1
- package/types/lib-components/forms/Checkbox.vue.d.ts +1 -1
- package/types/lib-components/forms/Combobox.vue.d.ts +16 -0
- package/types/lib-components/forms/DateRangePicker.vue.d.ts +3 -3
- package/types/lib-components/forms/DateTimeInput.vue.d.ts +1 -1
- package/types/lib-components/forms/FileUpload.vue.d.ts +19 -0
- package/types/lib-components/forms/FormCell.vue.d.ts +15 -0
- package/types/lib-components/forms/FormDisplay.vue.d.ts +30 -0
- package/types/lib-components/forms/FormGrid.vue.d.ts +19 -0
- package/types/lib-components/forms/FormSection.vue.d.ts +16 -0
- package/types/lib-components/forms/InputDisplay.vue.d.ts +14 -0
- package/types/lib-components/forms/MultiCheckboxes.vue.d.ts +1 -1
- package/types/lib-components/forms/MultiSelect.vue.d.ts +22 -0
- package/types/lib-components/forms/NumberInput.vue.d.ts +3 -3
- package/types/lib-components/forms/Radio.vue.d.ts +1 -1
- package/types/lib-components/forms/RadioCards.vue.d.ts +3 -0
- package/types/lib-components/forms/RangeCalendar.vue.d.ts +1 -1
- package/types/lib-components/forms/RepeaterDisplay.vue.d.ts +29 -0
- package/types/lib-components/forms/Select.vue.d.ts +1 -1
- package/types/lib-components/forms/TextArea.vue.d.ts +1 -1
- package/types/lib-components/forms/YesOrNoRadio.vue.d.ts +1 -1
- package/types/lib-components/index.d.ts +54 -89
- package/types/lib-components/indicators/DismissableBadge.vue.d.ts +13 -0
- package/types/lib-components/overlays/ContentModal.vue.d.ts +1 -1
- package/types/lib-components/overlays/Modal.vue.d.ts +1 -1
- package/types/lib-components/overlays/Slideover.vue.d.ts +1 -1
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
ArrowPathIcon,
|
|
4
|
+
ArrowTopRightOnSquareIcon,
|
|
5
|
+
ArrowUpTrayIcon,
|
|
6
|
+
DocumentIcon,
|
|
7
|
+
PlusIcon,
|
|
8
|
+
TrashIcon,
|
|
9
|
+
} from "@heroicons/vue/outline"
|
|
10
|
+
import { computed, onBeforeUnmount, ref, useTemplateRef, watch } from "vue"
|
|
11
|
+
import BaseAPI from "@/api/base"
|
|
12
|
+
import type { TrailsResp } from "@/api/client"
|
|
13
|
+
import {
|
|
14
|
+
defaultInputProps,
|
|
15
|
+
defaultModelOpts,
|
|
16
|
+
type FileUploadInput,
|
|
17
|
+
type UploadedFile,
|
|
18
|
+
useInputField,
|
|
19
|
+
} from "@/composables/forms"
|
|
20
|
+
import bytes from "@/helpers/Bytes"
|
|
21
|
+
import InputError from "./InputError.vue"
|
|
22
|
+
import InputHelp from "./InputHelp.vue"
|
|
23
|
+
import InputLabel from "./InputLabel.vue"
|
|
24
|
+
|
|
25
|
+
defineOptions({
|
|
26
|
+
inheritAttrs: false,
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const props = withDefaults(defineProps<FileUploadInput>(), {
|
|
30
|
+
...defaultInputProps,
|
|
31
|
+
accept: () => [],
|
|
32
|
+
fileField: "file",
|
|
33
|
+
multiple: false,
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const modelState = defineModel<FileUploadInput["modelValue"]>(defaultModelOpts)
|
|
37
|
+
const { aria, errorState, inputID, isDisabled, isRequired } =
|
|
38
|
+
useInputField(props)
|
|
39
|
+
|
|
40
|
+
// Rejected files failed local validation. Failed files reached the server and
|
|
41
|
+
// can be retried without asking the user to select them again.
|
|
42
|
+
type Status = "rejected" | "queued" | "uploading" | "failed"
|
|
43
|
+
|
|
44
|
+
interface Upload {
|
|
45
|
+
error: string
|
|
46
|
+
file: File
|
|
47
|
+
key: number
|
|
48
|
+
status: Status
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Uploaded and local files share the same list item layout,
|
|
52
|
+
// but have unique fields.
|
|
53
|
+
type Item =
|
|
54
|
+
| {
|
|
55
|
+
key: string
|
|
56
|
+
kind: "uploaded"
|
|
57
|
+
file: UploadedFile
|
|
58
|
+
index: number
|
|
59
|
+
}
|
|
60
|
+
| { key: string; kind: "pending"; upload: Upload }
|
|
61
|
+
|
|
62
|
+
const inputRef = useTemplateRef<HTMLInputElement>("input")
|
|
63
|
+
const validationRef = useTemplateRef<HTMLInputElement>("validation")
|
|
64
|
+
|
|
65
|
+
// Keep local files until an upload succeeds so failed requests can be retried.
|
|
66
|
+
const pending = ref<Upload[]>([])
|
|
67
|
+
|
|
68
|
+
let nextKey = 0
|
|
69
|
+
let isProcessing = false
|
|
70
|
+
|
|
71
|
+
// NOTE(spk): Full-page navigation tears down the request, but conditional
|
|
72
|
+
// fields and repeater items can unmount during an upload. We do not cancel
|
|
73
|
+
// in-flight requests, so ignore their results after teardown.
|
|
74
|
+
let isUnmounted = false
|
|
75
|
+
|
|
76
|
+
// Normalize both model modes to an array for shared rendering and operations.
|
|
77
|
+
const uploaded = computed<UploadedFile[]>(() => {
|
|
78
|
+
const value = modelState.value
|
|
79
|
+
|
|
80
|
+
if (props.multiple) {
|
|
81
|
+
return Array.isArray(value) ? value : []
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return value && !Array.isArray(value) ? [value] : []
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const counts = computed(() => {
|
|
88
|
+
const totals = {
|
|
89
|
+
queued: 0,
|
|
90
|
+
uploading: 0,
|
|
91
|
+
errors: 0,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
for (const upload of pending.value) {
|
|
95
|
+
if (upload.status === "queued" || upload.status === "uploading") {
|
|
96
|
+
totals[upload.status] += 1
|
|
97
|
+
} else {
|
|
98
|
+
totals.errors += 1
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return totals
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
const hasActive = computed(
|
|
106
|
+
() => counts.value.queued > 0 || counts.value.uploading > 0
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
const items = computed<Item[]>(() => [
|
|
110
|
+
...uploaded.value.map(
|
|
111
|
+
(file, index): Item => ({
|
|
112
|
+
key: `uploaded-${file.id}-${index}`,
|
|
113
|
+
kind: "uploaded",
|
|
114
|
+
file,
|
|
115
|
+
index,
|
|
116
|
+
})
|
|
117
|
+
),
|
|
118
|
+
...pending.value.map(
|
|
119
|
+
(upload): Item => ({
|
|
120
|
+
key: `pending-${upload.key}`,
|
|
121
|
+
kind: "pending",
|
|
122
|
+
upload,
|
|
123
|
+
})
|
|
124
|
+
),
|
|
125
|
+
])
|
|
126
|
+
|
|
127
|
+
const isPickerDisabled = computed(
|
|
128
|
+
() => isDisabled.value || (!props.multiple && hasActive.value)
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
const pickerLabel = computed(() => {
|
|
132
|
+
if (props.multiple) {
|
|
133
|
+
return items.value.length ? "Add files" : "Choose files"
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return uploaded.value.length ? "Change file" : "Choose file"
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
const instructions = computed(() => {
|
|
140
|
+
const values: string[] = []
|
|
141
|
+
|
|
142
|
+
if (props.accept.length > 0) {
|
|
143
|
+
values.push(`Accepted: ${props.accept.join(", ")}`)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (props.maxFileBytes !== undefined) {
|
|
147
|
+
values.push(`Maximum per file: ${bytes.format(props.maxFileBytes)}`)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (props.multiple && props.maxFiles !== undefined) {
|
|
151
|
+
values.push(`Maximum files: ${props.maxFiles}`)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return values.join(" · ")
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
const describedBy = computed(
|
|
158
|
+
() =>
|
|
159
|
+
[
|
|
160
|
+
aria.value.describedby,
|
|
161
|
+
instructions.value ? `${inputID.value}-instructions` : undefined,
|
|
162
|
+
]
|
|
163
|
+
.filter(Boolean)
|
|
164
|
+
.join(" ") || undefined
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
// Validation errors are ordered by what the user must resolve first.
|
|
168
|
+
const validationError = computed(() => {
|
|
169
|
+
if (isDisabled.value) {
|
|
170
|
+
return ""
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (hasActive.value) {
|
|
174
|
+
return "Please wait for the selected files to finish uploading."
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (counts.value.errors > 0) {
|
|
178
|
+
return "Remove invalid files or retry failed uploads before continuing."
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (isRequired.value && uploaded.value.length === 0) {
|
|
182
|
+
return props.multiple
|
|
183
|
+
? "Please upload at least one file."
|
|
184
|
+
: "Please upload a file."
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return ""
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
// The native accept attribute only filters the picker. Apply the same rules
|
|
191
|
+
// before uploading so extensions, MIME types, and MIME wildcards are enforced.
|
|
192
|
+
const isAccepted = (file: File): boolean => {
|
|
193
|
+
if (props.accept.length === 0) {
|
|
194
|
+
return true
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const filename = file.name.toLowerCase()
|
|
198
|
+
const contentType = file.type.toLowerCase()
|
|
199
|
+
|
|
200
|
+
return props.accept.some((untrimmedRule) => {
|
|
201
|
+
const rule = untrimmedRule.trim().toLowerCase()
|
|
202
|
+
if (rule === "") {
|
|
203
|
+
return false
|
|
204
|
+
}
|
|
205
|
+
if (rule.startsWith(".")) {
|
|
206
|
+
return filename.endsWith(rule)
|
|
207
|
+
}
|
|
208
|
+
if (rule.endsWith("/*")) {
|
|
209
|
+
return contentType.startsWith(rule.slice(0, -1))
|
|
210
|
+
}
|
|
211
|
+
return contentType === rule
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const getFileError = (file: File, fileCount: number): string => {
|
|
216
|
+
if (!isAccepted(file)) {
|
|
217
|
+
return "This file type is not accepted."
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (props.maxFileBytes !== undefined && file.size > props.maxFileBytes) {
|
|
221
|
+
return `This file exceeds the ${bytes.format(
|
|
222
|
+
props.maxFileBytes
|
|
223
|
+
)} per-file limit.`
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const limit = props.multiple ? props.maxFiles : 1
|
|
227
|
+
if (limit !== undefined && fileCount >= limit) {
|
|
228
|
+
return `No more than ${limit} file${
|
|
229
|
+
limit === 1 ? "" : "s"
|
|
230
|
+
} may be uploaded.`
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return ""
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Update the model only after a request succeeds so a single-file replacement
|
|
237
|
+
// keeps the previous file while the new file uploads.
|
|
238
|
+
const addUploaded = (file: UploadedFile) => {
|
|
239
|
+
if (props.multiple) {
|
|
240
|
+
modelState.value = [...uploaded.value, file]
|
|
241
|
+
} else {
|
|
242
|
+
modelState.value = file
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Keep uploads sequential within this component.
|
|
247
|
+
const processQueue = async () => {
|
|
248
|
+
if (isProcessing || isUnmounted || isDisabled.value) {
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
isProcessing = true
|
|
253
|
+
|
|
254
|
+
try {
|
|
255
|
+
while (!isUnmounted && !isDisabled.value) {
|
|
256
|
+
const upload = pending.value.find(
|
|
257
|
+
(candidate) => candidate.status === "queued"
|
|
258
|
+
)
|
|
259
|
+
if (!upload) {
|
|
260
|
+
break
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
upload.status = "uploading"
|
|
264
|
+
upload.error = ""
|
|
265
|
+
|
|
266
|
+
const form = new FormData()
|
|
267
|
+
form.append(props.fileField, upload.file)
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
const response = await BaseAPI.post<TrailsResp<UploadedFile>>(
|
|
271
|
+
props.action,
|
|
272
|
+
form,
|
|
273
|
+
{ skipLoader: true }
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
if (isUnmounted) {
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
addUploaded(response.data)
|
|
281
|
+
pending.value = pending.value.filter(
|
|
282
|
+
(candidate) => candidate.key !== upload.key
|
|
283
|
+
)
|
|
284
|
+
} catch {
|
|
285
|
+
if (isUnmounted) {
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
upload.status = "failed"
|
|
290
|
+
upload.error = "This file could not be uploaded."
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
} finally {
|
|
294
|
+
isProcessing = false
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const openPicker = () => {
|
|
299
|
+
inputRef.value?.click()
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const onSelect = (event: Event) => {
|
|
303
|
+
const input = event.target as HTMLInputElement
|
|
304
|
+
const files = input.files ? Array.from(input.files) : []
|
|
305
|
+
|
|
306
|
+
// Clear the value so selecting the same file triggers another change event.
|
|
307
|
+
input.value = ""
|
|
308
|
+
|
|
309
|
+
if (files.length === 0 || isDisabled.value) {
|
|
310
|
+
return
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (!props.multiple) {
|
|
314
|
+
// Replace a failed selection when another single file is selected.
|
|
315
|
+
pending.value = []
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Locally rejected files do not consume a slot; retryable failures do.
|
|
319
|
+
let fileCount = props.multiple
|
|
320
|
+
? uploaded.value.length +
|
|
321
|
+
pending.value.filter((upload) => upload.status !== "rejected").length
|
|
322
|
+
: 0
|
|
323
|
+
|
|
324
|
+
const additions: Upload[] = []
|
|
325
|
+
for (const file of files) {
|
|
326
|
+
const error = getFileError(file, fileCount)
|
|
327
|
+
nextKey += 1
|
|
328
|
+
|
|
329
|
+
additions.push({
|
|
330
|
+
error,
|
|
331
|
+
file,
|
|
332
|
+
key: nextKey,
|
|
333
|
+
status: error === "" ? "queued" : "rejected",
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
if (error === "") {
|
|
337
|
+
fileCount += 1
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
pending.value = [...pending.value, ...additions]
|
|
342
|
+
processQueue()
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const retry = (upload: Upload) => {
|
|
346
|
+
upload.error = ""
|
|
347
|
+
upload.status = "queued"
|
|
348
|
+
|
|
349
|
+
processQueue()
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const removePending = (upload: Upload) => {
|
|
353
|
+
pending.value = pending.value.filter((p) => p.key !== upload.key)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const removeUploaded = (index: number) => {
|
|
357
|
+
if (props.multiple) {
|
|
358
|
+
modelState.value = uploaded.value.filter(
|
|
359
|
+
(_candidate, candidateIndex) => candidateIndex !== index
|
|
360
|
+
)
|
|
361
|
+
} else {
|
|
362
|
+
modelState.value = null
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const onInvalid = () => {
|
|
367
|
+
errorState.value = validationError.value
|
|
368
|
+
validationRef.value?.setCustomValidity(validationError.value)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Keep native validation and the visible error in sync as queue state changes.
|
|
372
|
+
watch(validationError, (message) => {
|
|
373
|
+
validationRef.value?.setCustomValidity(message)
|
|
374
|
+
|
|
375
|
+
if (!message) {
|
|
376
|
+
errorState.value = ""
|
|
377
|
+
} else if (errorState.value) {
|
|
378
|
+
errorState.value = message
|
|
379
|
+
}
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
// Let the active request finish when disabled, then resume the queue when the
|
|
383
|
+
// input is enabled again.
|
|
384
|
+
watch(isDisabled, (disabled) => {
|
|
385
|
+
if (!disabled) {
|
|
386
|
+
processQueue()
|
|
387
|
+
}
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
onBeforeUnmount(() => {
|
|
391
|
+
isUnmounted = true
|
|
392
|
+
})
|
|
393
|
+
</script>
|
|
394
|
+
|
|
395
|
+
<template>
|
|
396
|
+
<div class="relative">
|
|
397
|
+
<InputLabel
|
|
398
|
+
:id="aria.labelledby"
|
|
399
|
+
class="mb-1"
|
|
400
|
+
:for="`${inputID}-picker`"
|
|
401
|
+
:label="label"
|
|
402
|
+
:required="isRequired"
|
|
403
|
+
/>
|
|
404
|
+
<InputHelp :id="aria.describedby" class="mb-3" :text="help" />
|
|
405
|
+
|
|
406
|
+
<ul
|
|
407
|
+
v-if="items.length"
|
|
408
|
+
class="space-y-3"
|
|
409
|
+
:aria-labelledby="aria.labelledby"
|
|
410
|
+
>
|
|
411
|
+
<li v-for="item in items" :key="item.key" class="flex items-center gap-3">
|
|
412
|
+
<div
|
|
413
|
+
:class="[
|
|
414
|
+
'grid min-w-0 flex-1 grid-cols-[1.25rem_minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1 rounded-md border px-3 py-2 shadow-sm sm:flex',
|
|
415
|
+
item.kind === 'pending' &&
|
|
416
|
+
(item.upload.status === 'rejected' ||
|
|
417
|
+
item.upload.status === 'failed')
|
|
418
|
+
? 'border-red-700'
|
|
419
|
+
: 'border-gray-300',
|
|
420
|
+
isDisabled ? 'bg-gray-50' : 'bg-white',
|
|
421
|
+
]"
|
|
422
|
+
>
|
|
423
|
+
<ArrowPathIcon
|
|
424
|
+
v-if="item.kind === 'pending' && item.upload.status === 'uploading'"
|
|
425
|
+
class="h-5 w-5 shrink-0 animate-spin text-xy-blue motion-reduce:animate-none"
|
|
426
|
+
aria-hidden="true"
|
|
427
|
+
/>
|
|
428
|
+
|
|
429
|
+
<DocumentIcon
|
|
430
|
+
v-else
|
|
431
|
+
class="h-5 w-5 shrink-0 text-gray-500"
|
|
432
|
+
aria-hidden="true"
|
|
433
|
+
/>
|
|
434
|
+
|
|
435
|
+
<div class="min-w-0 flex-1">
|
|
436
|
+
<template v-if="item.kind === 'uploaded'">
|
|
437
|
+
<span class="text-sm text-gray-800 [overflow-wrap:anywhere]">
|
|
438
|
+
{{ item.file.filename }}
|
|
439
|
+
</span>
|
|
440
|
+
<p class="mt-0.5 text-xs text-gray-600">Uploaded</p>
|
|
441
|
+
</template>
|
|
442
|
+
|
|
443
|
+
<template v-else>
|
|
444
|
+
<p class="text-sm text-gray-800 [overflow-wrap:anywhere]">
|
|
445
|
+
{{ item.upload.file.name }}
|
|
446
|
+
</p>
|
|
447
|
+
<p
|
|
448
|
+
v-if="item.upload.status === 'queued'"
|
|
449
|
+
class="mt-0.5 text-xs text-gray-600"
|
|
450
|
+
>
|
|
451
|
+
Waiting to upload
|
|
452
|
+
</p>
|
|
453
|
+
<p
|
|
454
|
+
v-else-if="item.upload.status === 'uploading'"
|
|
455
|
+
class="mt-0.5 text-xs text-gray-600"
|
|
456
|
+
>
|
|
457
|
+
Uploading…
|
|
458
|
+
</p>
|
|
459
|
+
<p v-else class="mt-0.5 text-xs text-red-700">
|
|
460
|
+
{{ item.upload.error }}
|
|
461
|
+
</p>
|
|
462
|
+
</template>
|
|
463
|
+
</div>
|
|
464
|
+
|
|
465
|
+
<a
|
|
466
|
+
v-if="item.kind === 'uploaded' && item.file.url"
|
|
467
|
+
class="xy-btn-neutral-sm"
|
|
468
|
+
:href="item.file.url"
|
|
469
|
+
target="_blank"
|
|
470
|
+
rel="noopener noreferrer"
|
|
471
|
+
:aria-label="`Open ${item.file.filename} in a new window`"
|
|
472
|
+
>
|
|
473
|
+
<ArrowTopRightOnSquareIcon class="h-5 w-5" aria-hidden="true" />
|
|
474
|
+
</a>
|
|
475
|
+
|
|
476
|
+
<button
|
|
477
|
+
v-if="item.kind === 'pending' && item.upload.status === 'failed'"
|
|
478
|
+
type="button"
|
|
479
|
+
class="xy-btn-neutral-sm col-start-2 shrink-0 justify-self-start"
|
|
480
|
+
:disabled="isDisabled"
|
|
481
|
+
:aria-label="`Retry ${item.upload.file.name}`"
|
|
482
|
+
@click="retry(item.upload)"
|
|
483
|
+
>
|
|
484
|
+
Retry
|
|
485
|
+
</button>
|
|
486
|
+
</div>
|
|
487
|
+
|
|
488
|
+
<button
|
|
489
|
+
type="button"
|
|
490
|
+
class="xy-btn-neutral-sm shrink-0"
|
|
491
|
+
:disabled="
|
|
492
|
+
isDisabled ||
|
|
493
|
+
(item.kind === 'pending' && item.upload.status === 'uploading')
|
|
494
|
+
"
|
|
495
|
+
:aria-label="`Remove ${
|
|
496
|
+
item.kind === 'uploaded'
|
|
497
|
+
? item.file.filename
|
|
498
|
+
: item.upload.file.name
|
|
499
|
+
}`"
|
|
500
|
+
@click="
|
|
501
|
+
item.kind === 'uploaded'
|
|
502
|
+
? removeUploaded(item.index)
|
|
503
|
+
: removePending(item.upload)
|
|
504
|
+
"
|
|
505
|
+
>
|
|
506
|
+
<TrashIcon class="h-4 w-4" aria-hidden="true" />
|
|
507
|
+
</button>
|
|
508
|
+
</li>
|
|
509
|
+
</ul>
|
|
510
|
+
|
|
511
|
+
<div
|
|
512
|
+
:class="[
|
|
513
|
+
'flex flex-wrap items-center gap-3',
|
|
514
|
+
items.length ? 'mt-3' : 'rounded-md border px-3 py-2 shadow-sm',
|
|
515
|
+
!items.length
|
|
516
|
+
? errorState
|
|
517
|
+
? 'border-red-700'
|
|
518
|
+
: 'border-gray-300'
|
|
519
|
+
: '',
|
|
520
|
+
!items.length ? (isDisabled ? 'bg-gray-50' : 'bg-white') : '',
|
|
521
|
+
]"
|
|
522
|
+
>
|
|
523
|
+
<template v-if="!items.length">
|
|
524
|
+
<DocumentIcon
|
|
525
|
+
class="h-5 w-5 shrink-0 text-gray-500"
|
|
526
|
+
aria-hidden="true"
|
|
527
|
+
/>
|
|
528
|
+
<span class="min-w-0 flex-1 text-sm text-gray-500">
|
|
529
|
+
{{
|
|
530
|
+
placeholder || (multiple ? "No files uploaded" : "No file uploaded")
|
|
531
|
+
}}
|
|
532
|
+
</span>
|
|
533
|
+
</template>
|
|
534
|
+
|
|
535
|
+
<button
|
|
536
|
+
:id="`${inputID}-picker`"
|
|
537
|
+
type="button"
|
|
538
|
+
:class="[
|
|
539
|
+
'xy-btn-neutral-sm shrink-0 gap-x-1.5',
|
|
540
|
+
!items.length ? 'bg-white ring-1 ring-gray-300 ring-inset' : '',
|
|
541
|
+
]"
|
|
542
|
+
:disabled="isPickerDisabled"
|
|
543
|
+
:aria-label="`${pickerLabel}: ${label || 'file upload'}`"
|
|
544
|
+
:aria-describedby="describedBy"
|
|
545
|
+
:aria-errormessage="aria.errormessage"
|
|
546
|
+
:aria-invalid="errorState ? true : undefined"
|
|
547
|
+
@click="openPicker"
|
|
548
|
+
>
|
|
549
|
+
<PlusIcon
|
|
550
|
+
v-if="multiple && items.length"
|
|
551
|
+
class="h-4 w-4"
|
|
552
|
+
aria-hidden="true"
|
|
553
|
+
/>
|
|
554
|
+
<ArrowUpTrayIcon v-else class="h-4 w-4" aria-hidden="true" />
|
|
555
|
+
{{ pickerLabel }}
|
|
556
|
+
</button>
|
|
557
|
+
</div>
|
|
558
|
+
|
|
559
|
+
<p
|
|
560
|
+
v-if="instructions"
|
|
561
|
+
:id="`${inputID}-instructions`"
|
|
562
|
+
class="mt-2 text-xs text-gray-600"
|
|
563
|
+
>
|
|
564
|
+
{{ instructions }}
|
|
565
|
+
</p>
|
|
566
|
+
|
|
567
|
+
<InputError :id="aria.errormessage" class="mt-1" :text="errorState" />
|
|
568
|
+
|
|
569
|
+
<input
|
|
570
|
+
:id="inputID"
|
|
571
|
+
ref="input"
|
|
572
|
+
class="sr-only"
|
|
573
|
+
type="file"
|
|
574
|
+
tabindex="-1"
|
|
575
|
+
:accept="accept.length ? accept.join(',') : undefined"
|
|
576
|
+
:aria-labelledby="aria.labelledby"
|
|
577
|
+
:aria-describedby="describedBy"
|
|
578
|
+
:aria-errormessage="aria.errormessage"
|
|
579
|
+
:disabled="isPickerDisabled"
|
|
580
|
+
:multiple="multiple"
|
|
581
|
+
@change="onSelect"
|
|
582
|
+
/>
|
|
583
|
+
|
|
584
|
+
<!-- Native form validation support. -->
|
|
585
|
+
<input
|
|
586
|
+
v-if="validationError"
|
|
587
|
+
ref="validation"
|
|
588
|
+
required
|
|
589
|
+
class="sr-only top-1 left-1"
|
|
590
|
+
type="checkbox"
|
|
591
|
+
aria-hidden="true"
|
|
592
|
+
:tabindex="errorState ? undefined : -1"
|
|
593
|
+
@invalid="onInvalid"
|
|
594
|
+
/>
|
|
595
|
+
</div>
|
|
596
|
+
</template>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import { computed } from "vue"
|
|
3
|
+
|
|
4
|
+
const props = withDefaults(
|
|
5
|
+
defineProps<{
|
|
6
|
+
start?: boolean
|
|
7
|
+
span: "xs" | "sm" | "md" | "lg" | "xl" | "2xl" | "full"
|
|
8
|
+
}>(),
|
|
9
|
+
{
|
|
10
|
+
start: false,
|
|
11
|
+
}
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
const classname = computed(() => {
|
|
15
|
+
let classname = ""
|
|
16
|
+
switch (props.span) {
|
|
17
|
+
case "xs":
|
|
18
|
+
classname = "sm:col-span-2"
|
|
19
|
+
break
|
|
20
|
+
case "sm":
|
|
21
|
+
classname = "sm:col-span-3"
|
|
22
|
+
break
|
|
23
|
+
case "md":
|
|
24
|
+
classname = "sm:col-span-4"
|
|
25
|
+
break
|
|
26
|
+
case "lg":
|
|
27
|
+
classname = "sm:col-span-5"
|
|
28
|
+
break
|
|
29
|
+
case "xl":
|
|
30
|
+
classname = "sm:col-span-6"
|
|
31
|
+
break
|
|
32
|
+
case "2xl":
|
|
33
|
+
classname = "sm:col-span-8"
|
|
34
|
+
break
|
|
35
|
+
case "full":
|
|
36
|
+
classname = "sm:col-span-full"
|
|
37
|
+
break
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (props.start) {
|
|
41
|
+
classname += " sm:col-start-1"
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return classname
|
|
45
|
+
})
|
|
46
|
+
</script>
|
|
47
|
+
|
|
48
|
+
<template>
|
|
49
|
+
<div class="xy-form-cell" :class="classname">
|
|
50
|
+
<slot />
|
|
51
|
+
</div>
|
|
52
|
+
</template>
|