@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.
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,96 @@
1
+ <script setup lang="ts">
2
+ import { isHttpError } from "@/api/base"
3
+ import type { ReqMethod } from "@/api/client"
4
+ import { useAppFlasher, useBaseAPI } from "@/composables"
5
+ import { statusMessages } from "@/composables/http"
6
+ import {
7
+ useFieldsSchema,
8
+ type FieldsSchema,
9
+ } from "@/composables/useFieldsSchema"
10
+ import FormCell from "@/lib-components/forms/FormCell.vue"
11
+ import FormGrid from "@/lib-components/forms/FormGrid.vue"
12
+ import FormSection from "@/lib-components/forms/FormSection.vue"
13
+
14
+ const props = withDefaults(
15
+ defineProps<{
16
+ action: string
17
+ btnText?: string
18
+ btnDisabled?: boolean
19
+ columns: 1 | 2
20
+ flashError?: boolean
21
+ flashSuccess?: boolean
22
+ method: ReqMethod
23
+ schema: FieldsSchema
24
+ }>(),
25
+ {
26
+ flashError: true,
27
+ flashSuccess: true,
28
+ btnText: "Submit",
29
+ btnDisabled: false,
30
+ }
31
+ )
32
+
33
+ const emit = defineEmits<{
34
+ error: [error: any]
35
+ success: [result: any]
36
+ }>()
37
+
38
+ const model = defineModel<Record<string, any>>({
39
+ default: () => ({}),
40
+ required: false,
41
+ })
42
+
43
+ const { fieldSections } = useFieldsSchema(model, () => props.schema)
44
+
45
+ const { execute, isLoading } = useBaseAPI(props.action, props.method, {
46
+ withDelay: 500,
47
+ })
48
+
49
+ const submit = () => {
50
+ execute(model.value)
51
+ .then((d) => {
52
+ if (props.flashSuccess !== false) {
53
+ useAppFlasher.success("Success!")
54
+ }
55
+
56
+ emit("success", d)
57
+ })
58
+ .catch((e) => {
59
+ if (props.flashError === false) {
60
+ emit("error", e)
61
+ return
62
+ }
63
+
64
+ if (isHttpError(e) && statusMessages[e.status]) {
65
+ useAppFlasher.error(statusMessages[e.status])
66
+ } else {
67
+ useAppFlasher.genericError()
68
+ }
69
+
70
+ emit("error", e)
71
+ })
72
+ }
73
+ </script>
74
+
75
+ <template>
76
+ <FormGrid
77
+ :btn-disabled="isLoading || btnDisabled"
78
+ :btn-text="btnText"
79
+ :columns="columns"
80
+ @submit.prevent="submit"
81
+ >
82
+ <template v-for="(section, sectionIdx) in fieldSections" :key="sectionIdx">
83
+ <FormSection :title="section.title" :description="section.description">
84
+ <template v-for="input in section.fields" :key="input.name">
85
+ <FormCell
86
+ v-if="input.show"
87
+ :span="input.span || 'full'"
88
+ :start="input.start"
89
+ >
90
+ <component :is="input.$component" v-bind="input.$props" />
91
+ </FormCell>
92
+ </template>
93
+ </FormSection>
94
+ </template>
95
+ </FormGrid>
96
+ </template>
@@ -0,0 +1,23 @@
1
+ <script setup lang="ts">
2
+ withDefaults(
3
+ defineProps<{
4
+ as?: string
5
+ btnText?: string
6
+ btnDisabled?: boolean
7
+ columns: 1 | 2
8
+ }>(),
9
+ { as: "form", btnDisabled: false, btnText: "" }
10
+ )
11
+ </script>
12
+
13
+ <template>
14
+ <component :is="as" class="space-y-10">
15
+ <slot />
16
+
17
+ <div v-if="btnText" class="flex justify-end">
18
+ <button class="xy-btn" type="submit" :disabled="btnDisabled">
19
+ {{ btnText }}
20
+ </button>
21
+ </div>
22
+ </component>
23
+ </template>
@@ -0,0 +1,57 @@
1
+ <script setup lang="ts">
2
+ import { useInlineMarkdown } from "@/composables/useInlineMarkdown"
3
+ import { computed, getCurrentInstance } from "vue"
4
+
5
+ const props = withDefaults(
6
+ defineProps<{
7
+ description?: string
8
+ title?: string
9
+ }>(),
10
+ {
11
+ description: "",
12
+ title: "",
13
+ }
14
+ )
15
+ const currentInstance = getCurrentInstance()
16
+ // reference the parent instance props
17
+ // to determine the layout mode we are in.
18
+ const layout = computed(() => {
19
+ const parentLayout = currentInstance?.parent?.props?.columns
20
+ if (typeof parentLayout !== "number") {
21
+ return 1
22
+ }
23
+
24
+ if ([1, 2].includes(parentLayout)) {
25
+ return parentLayout
26
+ }
27
+
28
+ return 1
29
+ })
30
+
31
+ const descriptionNodes = useInlineMarkdown(() => props.description, {
32
+ graphs: true,
33
+ })
34
+ </script>
35
+
36
+ <template>
37
+ <div
38
+ class="grid grid-cols-1 gap-x-8 border-b border-gray-900/10 pb-10 xl:grid-cols-3 last:border-none last:pb-0"
39
+ :class="title || description ? 'gap-y-6' : 'gap-y-0'"
40
+ >
41
+ <div :class="layout == 2 ? 'xl:col-span-1' : 'xl:col-span-3'">
42
+ <h3 v-if="title" class="text-base font-semibold leading-8 text-gray-800">
43
+ {{ title }}
44
+ </h3>
45
+ <div v-if="description" class="mt-1 text-sm leading-6 text-gray-600">
46
+ <component :is="descriptionNodes" />
47
+ </div>
48
+ </div>
49
+
50
+ <div
51
+ class="grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-12 [&>*:not(.xy-form-cell)]:col-span-full"
52
+ :class="layout == 2 ? 'xl:col-span-2' : 'xl:col-span-3'"
53
+ >
54
+ <slot />
55
+ </div>
56
+ </div>
57
+ </template>
@@ -0,0 +1,36 @@
1
+ <script setup lang="ts">
2
+ import {
3
+ useFieldsSchema,
4
+ type FieldsSchema,
5
+ } from "@/composables/useFieldsSchema"
6
+ import FormCell from "@/lib-components/forms/FormCell.vue"
7
+ import FormGrid from "@/lib-components/forms/FormGrid.vue"
8
+ import FormSection from "@/lib-components/forms/FormSection.vue"
9
+
10
+ const props = defineProps<{
11
+ columns: 1 | 2
12
+ schema: FieldsSchema
13
+ }>()
14
+
15
+ const model = defineModel<Record<string, any>>({ required: true })
16
+
17
+ const { fieldSections } = useFieldsSchema(model, () => props.schema)
18
+ </script>
19
+
20
+ <template>
21
+ <FormGrid as="div" :columns="columns">
22
+ <template v-for="(section, sectionIdx) in fieldSections" :key="sectionIdx">
23
+ <FormSection :title="section.title" :description="section.description">
24
+ <template v-for="input in section.fields" :key="input.name">
25
+ <FormCell
26
+ v-if="input.show"
27
+ :span="input.span || 'full'"
28
+ :start="input.start"
29
+ >
30
+ <component :is="input.$component" v-bind="input.$props" />
31
+ </FormCell>
32
+ </template>
33
+ </FormSection>
34
+ </template>
35
+ </FormGrid>
36
+ </template>
@@ -1,5 +1,7 @@
1
1
  <script setup lang="ts">
2
- withDefaults(
2
+ import { useInlineMarkdown } from "@/composables/useInlineMarkdown"
3
+
4
+ const props = withDefaults(
3
5
  defineProps<{
4
6
  tag?: string
5
7
  text?: string
@@ -9,7 +11,12 @@ withDefaults(
9
11
  text: "",
10
12
  }
11
13
  )
14
+
15
+ const nodes = useInlineMarkdown(() => props.text, {
16
+ graphs: false,
17
+ })
12
18
  </script>
19
+
13
20
  <template>
14
21
  <component
15
22
  :is="tag"
@@ -17,6 +24,6 @@ withDefaults(
17
24
  class="text-sm leading-6 font-normal text-gray-600"
18
25
  v-bind="$attrs"
19
26
  >
20
- {{ text }}
27
+ <component :is="nodes" />
21
28
  </component>
22
29
  </template>
@@ -0,0 +1,404 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, useTemplateRef } from "vue"
3
+ import {
4
+ Combobox,
5
+ ComboboxButton,
6
+ ComboboxInput,
7
+ ComboboxLabel,
8
+ ComboboxOption,
9
+ ComboboxOptions,
10
+ } from "@headlessui/vue"
11
+ import { CheckIcon, ChevronUpDownIcon, XMarkIcon } from "@heroicons/vue/solid"
12
+ import {
13
+ looseToNumber,
14
+ useInputField,
15
+ type InputOption,
16
+ type MultiChoiceInput,
17
+ } from "@/composables/forms"
18
+ import DismissableBadge from "@/lib-components/indicators/DismissableBadge.vue"
19
+ import InputHelp from "@/lib-components/forms/InputHelp.vue"
20
+ import InputError from "@/lib-components/forms/InputError.vue"
21
+ import InputLabel from "@/lib-components/forms/InputLabel.vue"
22
+
23
+ // NOTE(spk): What this doesn't do:
24
+ //
25
+ // - support async options based on query
26
+ // - limited support for HTML5 input events
27
+ // - grouped options
28
+
29
+ defineOptions({
30
+ inheritAttrs: false,
31
+ })
32
+
33
+ const props = withDefaults(
34
+ defineProps<MultiChoiceInput & { customValues?: boolean }>(),
35
+ {
36
+ customValues: false,
37
+ help: "",
38
+ label: "",
39
+ modelValue: undefined,
40
+ placeholder: "",
41
+ }
42
+ )
43
+
44
+ // NOTE(spk): Our getter method ensures the modelState is always an Array type.
45
+ type Getter = Exclude<MultiChoiceInput["modelValue"], null | undefined>
46
+ const modelState = defineModel<MultiChoiceInput["modelValue"], never, Getter>({
47
+ default: undefined,
48
+ required: false,
49
+
50
+ // NOTE(spk): The Vue.js specific handling of initial prop values null vs undefined
51
+ // means we can't rely on the the "default" param of defineModel here. Ensuring the
52
+ // getter returns an array type allows for a consistent checkbox v-model binding similar to
53
+ // the example in the official Vue.js docs.
54
+ //
55
+ // When a parent component passes a null v-model the parent ref stays null until
56
+ // a mutation occurs which is consistent with other input components.
57
+ get: (v) => {
58
+ if (!Array.isArray(v)) {
59
+ return []
60
+ }
61
+
62
+ return v
63
+ },
64
+ set: (v) => {
65
+ if (!Array.isArray(v)) {
66
+ return v
67
+ }
68
+
69
+ // NOTE(spk): assume all number like inputs are meant to be parsed as numbers
70
+ // If this becomes inflexible, add support for v-model modifiers to this input
71
+ // and FieldsSchema
72
+ return v.map((value) => {
73
+ return looseToNumber(value)
74
+ })
75
+ },
76
+ })
77
+
78
+ const { aria, inputID, isDisabled, errorState, nameAttr } = useInputField(props)
79
+
80
+ const query = ref("")
81
+
82
+ const selectedOptions = computed((): InputOption[] => {
83
+ // NOTE(spk): custom values in the the modelState can lead to
84
+ // undefined results here, filter them out.
85
+ const opts = modelState.value
86
+ .map((value) => {
87
+ return props.options.find((opt) => opt.value === value)
88
+ })
89
+ .filter((value): value is InputOption => value !== undefined)
90
+
91
+ if (props.customValues) {
92
+ const customOpts = modelState.value
93
+ .filter((value) => {
94
+ return !props.options.find((opt) => opt.value === value)
95
+ })
96
+ .map((value) => {
97
+ return {
98
+ label: value.toString(),
99
+ value: value,
100
+ disabled: false,
101
+ }
102
+ })
103
+
104
+ return [...customOpts, ...opts]
105
+ }
106
+
107
+ return opts
108
+ })
109
+
110
+ const minCount = computed(() => {
111
+ return props.min || null
112
+ })
113
+
114
+ const maxCount = computed(() => {
115
+ return props.max || null
116
+ })
117
+
118
+ const countError = computed(() => {
119
+ const count = modelState.value?.length || 0
120
+
121
+ // min not reached, no max is set
122
+ if (minCount.value !== null && count < minCount.value) {
123
+ return `Please select at least ${minCount.value} of these options.`
124
+ }
125
+
126
+ // max is reached, no min set
127
+ if (maxCount.value !== null && count > maxCount.value) {
128
+ return `Please limit your selection to ${maxCount.value} of these options.`
129
+ }
130
+
131
+ // min and max are both set and at least on of them is out of range
132
+ if (
133
+ minCount.value !== null &&
134
+ maxCount.value !== null &&
135
+ (count < minCount.value || count > maxCount.value)
136
+ ) {
137
+ return `Please select between ${minCount.value} and ${maxCount.value} of these options.`
138
+ }
139
+
140
+ return ""
141
+ })
142
+
143
+ const showClearAll = computed(() => {
144
+ return modelState.value.length > 0 && !isDisabled.value
145
+ })
146
+
147
+ const errorInputRef = useTemplateRef("errorInput")
148
+ const setValidationError = () => {
149
+ if (!errorState.value) {
150
+ errorState.value = countError.value
151
+ // ensure the browser tooltip contains our error message
152
+ errorInputRef.value?.setCustomValidity(countError.value)
153
+ }
154
+ }
155
+
156
+ const deselect = (value: any) => {
157
+ modelState.value = selectedOptions.value
158
+ .filter((opts) => {
159
+ return opts.value !== value
160
+ })
161
+ .map((opts) => {
162
+ return opts.value
163
+ })
164
+ }
165
+
166
+ const onDeleteKeydown = () => {
167
+ if (query.value) {
168
+ return
169
+ }
170
+
171
+ const last = selectedOptions.value.findLast((opt) => opt)
172
+ if (!last) {
173
+ return
174
+ }
175
+
176
+ modelState.value = modelState.value.filter((val) => {
177
+ return val !== last.value
178
+ })
179
+ }
180
+
181
+ const onUpdate = (val: unknown) => {
182
+ if (val) {
183
+ query.value = ""
184
+ errorState.value = ""
185
+ }
186
+ }
187
+
188
+ const maxOptions = 50
189
+
190
+ const filteredOptions = computed(() => {
191
+ return query.value === ""
192
+ ? props.options
193
+ : props.options.filter((opt) => {
194
+ return (
195
+ opt.label?.toLowerCase().includes(query.value.toLowerCase()) ||
196
+ opt.help?.toLowerCase().includes(query.value.toLowerCase())
197
+ )
198
+ })
199
+ })
200
+
201
+ const renderableOptions = computed(() => {
202
+ const opts = filteredOptions.value.slice(0, maxOptions)
203
+
204
+ // NOTE(spk): if the query.value is already a "selected" value or
205
+ // is available in the options set, avoid displaying a duplicate option
206
+ if (props.customValues && query.value !== "") {
207
+ const selected = modelState.value.find((v) => v === query.value)
208
+ const exists = props.options.find(
209
+ (v) => v.value === looseToNumber(query.value)
210
+ )
211
+
212
+ if (!selected && !exists) {
213
+ opts.unshift({ label: query.value, value: query.value })
214
+ }
215
+ }
216
+
217
+ return opts
218
+ })
219
+
220
+ const totalOptions = computed(() => {
221
+ return filteredOptions.value.length
222
+ })
223
+
224
+ const isPaginated = computed(() => {
225
+ return totalOptions.value > maxOptions
226
+ })
227
+ </script>
228
+
229
+ <template>
230
+ <div
231
+ class="xy-multiselect relative"
232
+ :aria-labelledby="aria.labelledby"
233
+ :aria-describedby="aria.describedby"
234
+ :aria-errormessage="aria.errormessage"
235
+ role="fieldset"
236
+ >
237
+ <Combobox
238
+ v-model="modelState"
239
+ :disabled="isDisabled"
240
+ :name="nameAttr"
241
+ as="div"
242
+ multiple
243
+ @update:model-value="onUpdate"
244
+ >
245
+ <ComboboxLabel
246
+ v-if="label"
247
+ :id="aria.labelledby"
248
+ :as="InputLabel"
249
+ class="mb-2"
250
+ :label="label"
251
+ :required="minCount ? true : false"
252
+ />
253
+
254
+ <div
255
+ :class="[
256
+ 'relative w-full rounded-md border-0 py-2 pl-3 shadow-sm ring-1 ring-inset has-[input:focus]:ring-2',
257
+ 'has-[input:disabled]:cursor-not-allowed has-[input:disabled]:bg-gray-50 has-[input:disabled]:text-gray-700 has-[input:disabled]:ring-gray-200',
258
+ showClearAll ? 'pr-16' : 'pr-10',
259
+ errorState
260
+ ? 'text-red-900 ring-red-700 has-[input:focus]:ring-red-700'
261
+ : 'text-gray-900 ring-gray-300 has-[input:focus]:ring-xy-blue-500',
262
+ ]"
263
+ >
264
+ <div class="flex flex-wrap gap-y-1.5 items-center justify-start">
265
+ <template v-if="selectedOptions.length > 0">
266
+ <div
267
+ v-for="option in selectedOptions"
268
+ :key="option.value"
269
+ class="shrink-0 pr-1.5 max-w-full"
270
+ >
271
+ <div v-if="isDisabled || option.disabled" class="xy-badge">
272
+ {{ option.label }}
273
+ </div>
274
+
275
+ <DismissableBadge
276
+ v-else
277
+ :label="option.label"
278
+ @dismiss="deselect(option.value)"
279
+ />
280
+ </div>
281
+ </template>
282
+
283
+ <ComboboxButton as="div" class="flex-1 leading-none">
284
+ <ComboboxInput
285
+ :id="inputID"
286
+ :class="[
287
+ 'xy-multiselect-input w-full min-w-8 sm:text-sm sm:leading-6',
288
+ errorState
289
+ ? 'placeholder:text-red-300'
290
+ : 'placeholder:text-gray-400',
291
+ ]"
292
+ :disabled="isDisabled"
293
+ :placeholder="modelState.length === 0 ? placeholder : ''"
294
+ :value="query"
295
+ @change="query = $event.target.value"
296
+ @keydown.delete="onDeleteKeydown"
297
+ />
298
+ </ComboboxButton>
299
+ </div>
300
+
301
+ <div class="absolute flex inset-y-0 right-0">
302
+ <button
303
+ v-if="showClearAll"
304
+ class="shrink-0 flex items-center rounded-md px-1.5"
305
+ type="button"
306
+ @click="modelState = []"
307
+ >
308
+ <XMarkIcon class="h-4 w-4 text-gray-500" aria-hidden="true" />
309
+ </button>
310
+
311
+ <ComboboxButton
312
+ class="shrink-0 flex items-center px-1.5 focus:outline-none"
313
+ >
314
+ <ChevronUpDownIcon
315
+ class="h-5 w-5 text-gray-500"
316
+ aria-hidden="true"
317
+ />
318
+ </ComboboxButton>
319
+ </div>
320
+ </div>
321
+
322
+ <ComboboxOptions
323
+ v-if="renderableOptions.length > 0"
324
+ class="absolute z-[5] mt-1 max-h-60 w-full overflow-auto rounded-md bg-white text-base shadow-lg ring-1 ring-black/5 focus:outline-none sm:text-sm scroll-pt-10"
325
+ >
326
+ <li
327
+ v-if="isPaginated"
328
+ class="bg-neutral-100 font-medium border-b border-neutral-200 sticky top-0 py-1.5 px-3 z-[5]"
329
+ >
330
+ Showing {{ renderableOptions.length }} of {{ totalOptions }} results.
331
+ </li>
332
+
333
+ <ComboboxOption
334
+ v-for="option in renderableOptions"
335
+ v-slot="{
336
+ active,
337
+ disabled,
338
+ selected,
339
+ }: {
340
+ active: boolean
341
+ disabled: boolean
342
+ selected: boolean
343
+ }"
344
+ :key="option.value"
345
+ :value="option.value"
346
+ :disabled="isDisabled || option.disabled"
347
+ >
348
+ <div
349
+ :class="[
350
+ 'relative select-none py-2 pl-8 pr-4',
351
+ active
352
+ ? 'bg-xy-blue-600 font-semibold text-white outline-none'
353
+ : '',
354
+ disabled ? 'text-gray-500 cursor-not-allowed' : 'cursor-pointer',
355
+ ]"
356
+ >
357
+ <div>
358
+ <div
359
+ :class="[
360
+ 'block truncate',
361
+ selected ? 'font-semibold' : 'font-medium',
362
+ ]"
363
+ >
364
+ {{ option.label }}
365
+ </div>
366
+ <div v-if="option.help" class="block truncate">
367
+ {{ option.help }}
368
+ </div>
369
+ </div>
370
+
371
+ <span
372
+ v-if="selected"
373
+ class="absolute inset-y-0 left-0 flex items-center pl-1.5 text-xy-blue-500"
374
+ >
375
+ <CheckIcon class="h-5 w-5" aria-hidden="true" />
376
+ </span>
377
+ </div>
378
+ </ComboboxOption>
379
+ </ComboboxOptions>
380
+ </Combobox>
381
+
382
+ <InputHelp
383
+ v-if="help"
384
+ :id="aria.describedby"
385
+ class="mt-1"
386
+ tag="p"
387
+ :text="help"
388
+ />
389
+
390
+ <InputError :id="aria.errormessage" class="mt-1" :text="errorState" />
391
+
392
+ <!--Hidden input for custom validation-->
393
+ <input
394
+ v-if="countError"
395
+ ref="errorInput"
396
+ required
397
+ class="sr-only top-1 left-1"
398
+ aria-hidden
399
+ :tabindex="errorState ? undefined : -1"
400
+ type="checkbox"
401
+ @invalid="setValidationError"
402
+ />
403
+ </div>
404
+ </template>
@@ -38,7 +38,20 @@ const props = withDefaults(
38
38
  defineProps<RadioCards & ColumnedInput>(),
39
39
  defaultInputProps
40
40
  )
41
- const modelState = defineModel<RadioCards["modelValue"]>(defaultModelOpts)
41
+
42
+ // NOTE(spk): RadioGroup does not support a null v-model value
43
+ // use a getter to return undefined instead, which has the same UI result.
44
+ type Getter = Exclude<RadioCards["modelValue"], null>
45
+ const modelState = defineModel<RadioCards["modelValue"], never, Getter>({
46
+ ...defaultModelOpts,
47
+ get(v) {
48
+ if (v === null) {
49
+ return undefined
50
+ }
51
+
52
+ return v
53
+ },
54
+ })
42
55
 
43
56
  const { aria, isDisabled, isRequired, nameAttr, errorState, onInvalid } =
44
57
  useInputField(props)