@xy-planning-network/trees 0.15.2 → 0.15.3

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 (53) hide show
  1. package/dist/trees.es.js +17051 -12088
  2. package/dist/trees.umd.js +81 -13
  3. package/package.json +3 -2
  4. package/src/index.css +10 -0
  5. package/src/lib-components/forms/Combobox.vue +240 -0
  6. package/src/lib-components/forms/FileUpload.vue +596 -0
  7. package/src/lib-components/forms/FormCell.vue +52 -0
  8. package/src/lib-components/forms/FormDisplay.vue +96 -0
  9. package/src/lib-components/forms/FormGrid.vue +23 -0
  10. package/src/lib-components/forms/FormSection.vue +57 -0
  11. package/src/lib-components/forms/InputDisplay.vue +36 -0
  12. package/src/lib-components/forms/InputHelp.vue +9 -2
  13. package/src/lib-components/forms/MultiSelect.vue +404 -0
  14. package/src/lib-components/forms/RadioCards.vue +14 -1
  15. package/src/lib-components/forms/RepeaterDisplay.vue +214 -0
  16. package/src/lib-components/indicators/DismissableBadge.vue +31 -0
  17. package/types/composables/badge.d.ts +8 -0
  18. package/types/composables/forms.d.ts +22 -0
  19. package/types/composables/http.d.ts +8 -0
  20. package/types/composables/index.d.ts +4 -0
  21. package/types/composables/useFieldsSchema.d.ts +115 -0
  22. package/types/composables/useInlineMarkdown.d.ts +20 -0
  23. package/types/composables/useRepeater.d.ts +116 -0
  24. package/types/entry.d.ts +2 -1
  25. package/types/helpers/Bytes.d.ts +14 -0
  26. package/types/helpers/GetProperty.d.ts +21 -0
  27. package/types/helpers/SetProperty.d.ts +18 -0
  28. package/types/lib-components/forms/BaseInput.vue.d.ts +1 -1
  29. package/types/lib-components/forms/Checkbox.vue.d.ts +1 -1
  30. package/types/lib-components/forms/Combobox.vue.d.ts +16 -0
  31. package/types/lib-components/forms/DateRangePicker.vue.d.ts +3 -3
  32. package/types/lib-components/forms/DateTimeInput.vue.d.ts +1 -1
  33. package/types/lib-components/forms/FileUpload.vue.d.ts +19 -0
  34. package/types/lib-components/forms/FormCell.vue.d.ts +15 -0
  35. package/types/lib-components/forms/FormDisplay.vue.d.ts +30 -0
  36. package/types/lib-components/forms/FormGrid.vue.d.ts +19 -0
  37. package/types/lib-components/forms/FormSection.vue.d.ts +16 -0
  38. package/types/lib-components/forms/InputDisplay.vue.d.ts +14 -0
  39. package/types/lib-components/forms/MultiCheckboxes.vue.d.ts +1 -1
  40. package/types/lib-components/forms/MultiSelect.vue.d.ts +22 -0
  41. package/types/lib-components/forms/NumberInput.vue.d.ts +3 -3
  42. package/types/lib-components/forms/Radio.vue.d.ts +1 -1
  43. package/types/lib-components/forms/RadioCards.vue.d.ts +3 -0
  44. package/types/lib-components/forms/RangeCalendar.vue.d.ts +1 -1
  45. package/types/lib-components/forms/RepeaterDisplay.vue.d.ts +29 -0
  46. package/types/lib-components/forms/Select.vue.d.ts +1 -1
  47. package/types/lib-components/forms/TextArea.vue.d.ts +1 -1
  48. package/types/lib-components/forms/YesOrNoRadio.vue.d.ts +1 -1
  49. package/types/lib-components/index.d.ts +54 -89
  50. package/types/lib-components/indicators/DismissableBadge.vue.d.ts +13 -0
  51. package/types/lib-components/overlays/ContentModal.vue.d.ts +1 -1
  52. package/types/lib-components/overlays/Modal.vue.d.ts +1 -1
  53. 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&hellip;
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>