@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.
- 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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|