@redseed/redseed-ui-vue3 8.29.2 → 8.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -1
- package/src/components/FormField/FormFieldCombobox.vue +1 -1
- package/src/components/FormField/FormFieldSearchAsync.vue +484 -0
- package/src/components/FormField/FormFieldTreeSelect.vue +263 -0
- package/src/components/FormField/TreeSelectInternal/SelectableItem.vue +85 -0
- package/src/components/FormField/TreeSelectInternal/SelectableTree.vue +86 -0
- package/src/components/FormField/index.js +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@redseed/redseed-ui-vue3",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.31.0",
|
|
4
4
|
"description": "RedSeed UI Vue 3 components",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"repository": "https://github.com/redseedtraining/redseed-ui",
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
"@heroicons/vue": "2.2.0",
|
|
15
15
|
"@vueuse/components": "14.2.1",
|
|
16
16
|
"@vueuse/core": "14.2.1",
|
|
17
|
+
"@vueuse/integrations": "14.2.1",
|
|
18
|
+
"fuse.js": "7.1.0",
|
|
17
19
|
"lodash": "4.17.23",
|
|
18
20
|
"lottie-web": "5.13.0",
|
|
19
21
|
"vue": "3.5.30"
|
|
@@ -573,7 +573,7 @@ defineExpose({
|
|
|
573
573
|
{{ option.label }}
|
|
574
574
|
</div>
|
|
575
575
|
<div class="rsui-form-field-combobox__option-icon" aria-hidden="true">
|
|
576
|
-
<CheckIcon v-if="option.value === model"></CheckIcon>
|
|
576
|
+
<CheckIcon v-if="!navigable && option.value === model"></CheckIcon>
|
|
577
577
|
</div>
|
|
578
578
|
</div>
|
|
579
579
|
</template>
|
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref, computed, onMounted, onUnmounted, watch, useAttrs } from 'vue'
|
|
3
|
+
import { onClickOutside, useElementBounding } from '@vueuse/core'
|
|
4
|
+
import FormFieldSlot from './FormFieldSlot.vue'
|
|
5
|
+
import { MagnifyingGlassIcon } from '@heroicons/vue/24/outline'
|
|
6
|
+
import { useFormFieldA11y } from '../../composables/useFormFieldA11y.js'
|
|
7
|
+
|
|
8
|
+
defineOptions({
|
|
9
|
+
inheritAttrs: false,
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
const props = defineProps({
|
|
13
|
+
searchFunction: {
|
|
14
|
+
type: Function,
|
|
15
|
+
required: true,
|
|
16
|
+
},
|
|
17
|
+
debounceMs: {
|
|
18
|
+
type: Number,
|
|
19
|
+
default: 300,
|
|
20
|
+
},
|
|
21
|
+
minSearchLength: {
|
|
22
|
+
type: Number,
|
|
23
|
+
default: 2,
|
|
24
|
+
},
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const emit = defineEmits(['input', 'query-change', 'navigate', 'keyup-enter'])
|
|
28
|
+
|
|
29
|
+
const attrs = useAttrs()
|
|
30
|
+
const { inputId, ariaDescribedby, ariaInvalid } = useFormFieldA11y()
|
|
31
|
+
|
|
32
|
+
const query = ref('')
|
|
33
|
+
const isOpen = ref(false)
|
|
34
|
+
const inputElement = ref(null)
|
|
35
|
+
const rootElement = ref(null)
|
|
36
|
+
const dropdownElement = ref(null)
|
|
37
|
+
const highlightedIndex = ref(-1)
|
|
38
|
+
|
|
39
|
+
const isLoading = ref(false)
|
|
40
|
+
const searchError = ref(null)
|
|
41
|
+
const results = ref([])
|
|
42
|
+
const debounceTimeout = ref(null)
|
|
43
|
+
let latestRequestId = 0
|
|
44
|
+
|
|
45
|
+
const effectiveId = computed(() => inputId.value || attrs.id)
|
|
46
|
+
|
|
47
|
+
const liveAnnouncement = computed(() => {
|
|
48
|
+
if (!isOpen.value) return ''
|
|
49
|
+
if (isLoading.value) return 'Searching'
|
|
50
|
+
if (searchError.value) return 'Search failed'
|
|
51
|
+
if (results.value.length > 0) {
|
|
52
|
+
return `${results.value.length} result${results.value.length === 1 ? '' : 's'} available`
|
|
53
|
+
}
|
|
54
|
+
if (query.value.length >= props.minSearchLength) return 'No results found'
|
|
55
|
+
return ''
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
const groupedResults = computed(() => {
|
|
59
|
+
const items = results.value
|
|
60
|
+
if (!items.some((r) => r.group)) return null
|
|
61
|
+
|
|
62
|
+
const groups = []
|
|
63
|
+
let currentGroup = null
|
|
64
|
+
|
|
65
|
+
items.forEach((result, flatIndex) => {
|
|
66
|
+
const groupName = result.group || ''
|
|
67
|
+
if (!currentGroup || currentGroup.name !== groupName) {
|
|
68
|
+
currentGroup = { name: groupName, items: [] }
|
|
69
|
+
groups.push(currentGroup)
|
|
70
|
+
}
|
|
71
|
+
currentGroup.items.push({ result, flatIndex })
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
return groups
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const dropdownContent = computed(() => {
|
|
78
|
+
if (isLoading.value) return 'loading'
|
|
79
|
+
if (searchError.value) return 'error'
|
|
80
|
+
if (!query.value) return 'start-typing'
|
|
81
|
+
if (query.value.length < props.minSearchLength) return 'min-length'
|
|
82
|
+
if (results.value.length === 0) return 'no-results'
|
|
83
|
+
return 'results'
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
function open() {
|
|
87
|
+
isOpen.value = true
|
|
88
|
+
highlightedIndex.value = -1
|
|
89
|
+
setTimeout(() => {
|
|
90
|
+
inputElement.value?.focus()
|
|
91
|
+
calculateDropdownPosition()
|
|
92
|
+
}, 1)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function close() {
|
|
96
|
+
isOpen.value = false
|
|
97
|
+
highlightedIndex.value = -1
|
|
98
|
+
|
|
99
|
+
if (debounceTimeout.value) {
|
|
100
|
+
clearTimeout(debounceTimeout.value)
|
|
101
|
+
debounceTimeout.value = null
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function clear() {
|
|
106
|
+
latestRequestId++
|
|
107
|
+
if (debounceTimeout.value) {
|
|
108
|
+
clearTimeout(debounceTimeout.value)
|
|
109
|
+
debounceTimeout.value = null
|
|
110
|
+
}
|
|
111
|
+
query.value = ''
|
|
112
|
+
results.value = []
|
|
113
|
+
searchError.value = null
|
|
114
|
+
isLoading.value = false
|
|
115
|
+
highlightedIndex.value = -1
|
|
116
|
+
emit('query-change', '')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function navigate(result) {
|
|
120
|
+
emit('navigate', result.value, result)
|
|
121
|
+
clear()
|
|
122
|
+
close()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function performSearch(q) {
|
|
126
|
+
const requestId = ++latestRequestId
|
|
127
|
+
isLoading.value = true
|
|
128
|
+
searchError.value = null
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const response = await props.searchFunction(q)
|
|
132
|
+
if (requestId !== latestRequestId) return
|
|
133
|
+
results.value = Array.isArray(response) ? response : []
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if (requestId !== latestRequestId) return
|
|
136
|
+
console.error('FormFieldSearchAsync search error:', error)
|
|
137
|
+
searchError.value = error
|
|
138
|
+
results.value = []
|
|
139
|
+
} finally {
|
|
140
|
+
if (requestId === latestRequestId) {
|
|
141
|
+
isLoading.value = false
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function debouncedSearch(q) {
|
|
147
|
+
if (debounceTimeout.value) {
|
|
148
|
+
clearTimeout(debounceTimeout.value)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
debounceTimeout.value = setTimeout(() => {
|
|
152
|
+
if (q.length >= props.minSearchLength) {
|
|
153
|
+
performSearch(q)
|
|
154
|
+
} else {
|
|
155
|
+
latestRequestId++
|
|
156
|
+
results.value = []
|
|
157
|
+
isLoading.value = false
|
|
158
|
+
}
|
|
159
|
+
}, props.debounceMs)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function handleInput(event) {
|
|
163
|
+
query.value = event.target.value
|
|
164
|
+
if (!isOpen.value) {
|
|
165
|
+
isOpen.value = true
|
|
166
|
+
setTimeout(() => calculateDropdownPosition(), 1)
|
|
167
|
+
}
|
|
168
|
+
debouncedSearch(query.value)
|
|
169
|
+
emit('query-change', query.value)
|
|
170
|
+
emit('input', event)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function handleKeyup(event) {
|
|
174
|
+
if (event.key === 'Enter') {
|
|
175
|
+
if (highlightedIndex.value >= 0 && highlightedIndex.value < results.value.length) {
|
|
176
|
+
navigate(results.value[highlightedIndex.value])
|
|
177
|
+
}
|
|
178
|
+
emit('keyup-enter', event)
|
|
179
|
+
} else if (event.key === 'Escape') {
|
|
180
|
+
if (query.value) {
|
|
181
|
+
clear()
|
|
182
|
+
} else {
|
|
183
|
+
close()
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function handleKeydown(event) {
|
|
189
|
+
if (event.key === 'ArrowDown') {
|
|
190
|
+
event.preventDefault()
|
|
191
|
+
if (!isOpen.value) {
|
|
192
|
+
open()
|
|
193
|
+
} else if (results.value.length > 0) {
|
|
194
|
+
highlightedIndex.value = Math.min(
|
|
195
|
+
highlightedIndex.value + 1,
|
|
196
|
+
results.value.length - 1
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
} else if (event.key === 'ArrowUp') {
|
|
200
|
+
event.preventDefault()
|
|
201
|
+
if (isOpen.value && results.value.length > 0) {
|
|
202
|
+
highlightedIndex.value = Math.max(highlightedIndex.value - 1, 0)
|
|
203
|
+
}
|
|
204
|
+
} else if (event.key === 'Home' && isOpen.value && results.value.length > 0) {
|
|
205
|
+
event.preventDefault()
|
|
206
|
+
highlightedIndex.value = 0
|
|
207
|
+
} else if (event.key === 'End' && isOpen.value && results.value.length > 0) {
|
|
208
|
+
event.preventDefault()
|
|
209
|
+
highlightedIndex.value = results.value.length - 1
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
watch(results, () => {
|
|
214
|
+
highlightedIndex.value = -1
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
onClickOutside(rootElement, () => {
|
|
218
|
+
if (isOpen.value) close()
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
const inputElementBounding = useElementBounding(inputElement)
|
|
222
|
+
|
|
223
|
+
function calculateDropdownPosition() {
|
|
224
|
+
if (!dropdownElement.value) return
|
|
225
|
+
|
|
226
|
+
const viewportHeight = window.innerHeight
|
|
227
|
+
const dropdownHeight = dropdownElement.value.offsetHeight
|
|
228
|
+
const spaceAbove = inputElementBounding.top.value
|
|
229
|
+
const spaceBelow = viewportHeight - inputElementBounding.bottom.value
|
|
230
|
+
|
|
231
|
+
dropdownElement.value.style.width = `${inputElementBounding.width.value}px`
|
|
232
|
+
dropdownElement.value.style.left = `${inputElementBounding.left.value}px`
|
|
233
|
+
|
|
234
|
+
if (spaceAbove <= dropdownHeight && spaceBelow <= dropdownHeight) {
|
|
235
|
+
dropdownElement.value.style.top = '0'
|
|
236
|
+
dropdownElement.value.style.bottom = 'auto'
|
|
237
|
+
} else if (spaceBelow > dropdownHeight) {
|
|
238
|
+
dropdownElement.value.style.top = `${inputElementBounding.bottom.value + window.scrollY}px`
|
|
239
|
+
dropdownElement.value.style.bottom = 'auto'
|
|
240
|
+
} else if (spaceAbove > dropdownHeight) {
|
|
241
|
+
dropdownElement.value.style.top = 'auto'
|
|
242
|
+
dropdownElement.value.style.bottom = `${spaceBelow + inputElementBounding.height.value + 8 - window.scrollY}px`
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
onMounted(() => {
|
|
247
|
+
window.addEventListener('resize', calculateDropdownPosition)
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
onUnmounted(() => {
|
|
251
|
+
window.removeEventListener('resize', calculateDropdownPosition)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
defineExpose({
|
|
255
|
+
focus() {
|
|
256
|
+
inputElement.value?.focus()
|
|
257
|
+
},
|
|
258
|
+
open,
|
|
259
|
+
close,
|
|
260
|
+
clear,
|
|
261
|
+
})
|
|
262
|
+
</script>
|
|
263
|
+
|
|
264
|
+
<template>
|
|
265
|
+
<FormFieldSlot
|
|
266
|
+
ref="rootElement"
|
|
267
|
+
:id="$attrs.id"
|
|
268
|
+
:class="[$attrs.class, 'rsui-form-field-search-async', 'rsui-form-field-search', 'rsui-form-field-text']"
|
|
269
|
+
:required="$attrs.required"
|
|
270
|
+
:showAsterisk="$attrs.showAsterisk"
|
|
271
|
+
:compact="$attrs.compact"
|
|
272
|
+
>
|
|
273
|
+
<template #label v-if="$slots.label">
|
|
274
|
+
<slot name="label"></slot>
|
|
275
|
+
</template>
|
|
276
|
+
|
|
277
|
+
<div class="rsui-form-field-text__group">
|
|
278
|
+
<div
|
|
279
|
+
class="rsui-form-field-text__prefix"
|
|
280
|
+
aria-hidden="true"
|
|
281
|
+
@click.prevent="inputElement?.focus()"
|
|
282
|
+
>
|
|
283
|
+
<MagnifyingGlassIcon class="rsui-form-field-search__icon"></MagnifyingGlassIcon>
|
|
284
|
+
</div>
|
|
285
|
+
|
|
286
|
+
<input
|
|
287
|
+
ref="inputElement"
|
|
288
|
+
role="combobox"
|
|
289
|
+
class="rsui-form-field-search"
|
|
290
|
+
:aria-activedescendant="isOpen && dropdownContent === 'results' && highlightedIndex >= 0 ? `${effectiveId}-result-${highlightedIndex}` : undefined"
|
|
291
|
+
:aria-autocomplete="'list'"
|
|
292
|
+
:aria-busy="isLoading || undefined"
|
|
293
|
+
:aria-controls="`${effectiveId}-listbox`"
|
|
294
|
+
:aria-describedby="ariaDescribedby"
|
|
295
|
+
:aria-expanded="isOpen"
|
|
296
|
+
:aria-haspopup="'listbox'"
|
|
297
|
+
:aria-invalid="ariaInvalid"
|
|
298
|
+
:aria-required="$attrs.required || undefined"
|
|
299
|
+
:value="query"
|
|
300
|
+
:autocomplete="$attrs.autocomplete || 'off'"
|
|
301
|
+
:autofocus="$attrs.autofocus"
|
|
302
|
+
:disabled="$attrs.disabled"
|
|
303
|
+
:id="effectiveId"
|
|
304
|
+
:name="$attrs.name"
|
|
305
|
+
:placeholder="$attrs.placeholder || 'Search...'"
|
|
306
|
+
:required="$attrs.required"
|
|
307
|
+
type="search"
|
|
308
|
+
@input="handleInput"
|
|
309
|
+
@keyup="handleKeyup"
|
|
310
|
+
@keydown="handleKeydown"
|
|
311
|
+
@click="!isOpen && open()"
|
|
312
|
+
>
|
|
313
|
+
|
|
314
|
+
<Teleport to="body">
|
|
315
|
+
<transition
|
|
316
|
+
enter-active-class="enter-active-class"
|
|
317
|
+
enter-from-class="enter-from-class"
|
|
318
|
+
enter-to-class="enter-to-class"
|
|
319
|
+
leave-active-class="leave-active-class"
|
|
320
|
+
leave-from-class="leave-from-class"
|
|
321
|
+
leave-to-class="leave-to-class"
|
|
322
|
+
>
|
|
323
|
+
<div
|
|
324
|
+
ref="dropdownElement"
|
|
325
|
+
v-show="isOpen"
|
|
326
|
+
:id="`${effectiveId}-listbox`"
|
|
327
|
+
role="listbox"
|
|
328
|
+
:class="[
|
|
329
|
+
'rsui-form-field-combobox__options',
|
|
330
|
+
{ 'rsui-form-field-combobox__options--open': isOpen }
|
|
331
|
+
]"
|
|
332
|
+
>
|
|
333
|
+
<div
|
|
334
|
+
v-if="dropdownContent === 'loading'"
|
|
335
|
+
class="rsui-form-field-combobox__option rsui-form-field-combobox__option--disabled"
|
|
336
|
+
role="option"
|
|
337
|
+
aria-disabled="true"
|
|
338
|
+
aria-selected="false"
|
|
339
|
+
>
|
|
340
|
+
<div class="rsui-form-field-combobox__option-label">
|
|
341
|
+
<slot name="loading-text">Searching...</slot>
|
|
342
|
+
</div>
|
|
343
|
+
<div class="rsui-form-field-combobox__option-icon">
|
|
344
|
+
<div class="rsui-form-field-combobox__spinner"></div>
|
|
345
|
+
</div>
|
|
346
|
+
</div>
|
|
347
|
+
|
|
348
|
+
<div
|
|
349
|
+
v-if="dropdownContent === 'error'"
|
|
350
|
+
class="rsui-form-field-combobox__option rsui-form-field-combobox__option--disabled rsui-form-field-combobox__option--error"
|
|
351
|
+
role="option"
|
|
352
|
+
aria-disabled="true"
|
|
353
|
+
aria-selected="false"
|
|
354
|
+
>
|
|
355
|
+
<div class="rsui-form-field-combobox__option-label">
|
|
356
|
+
<slot name="error-text">Search failed</slot>
|
|
357
|
+
</div>
|
|
358
|
+
</div>
|
|
359
|
+
|
|
360
|
+
<div
|
|
361
|
+
v-if="dropdownContent === 'start-typing'"
|
|
362
|
+
class="rsui-form-field-combobox__option rsui-form-field-combobox__option--disabled rsui-form-field-combobox__option--hint"
|
|
363
|
+
role="option"
|
|
364
|
+
aria-disabled="true"
|
|
365
|
+
aria-selected="false"
|
|
366
|
+
>
|
|
367
|
+
<div class="rsui-form-field-combobox__option-label">
|
|
368
|
+
<slot name="start-typing-text">Start typing to search</slot>
|
|
369
|
+
</div>
|
|
370
|
+
</div>
|
|
371
|
+
|
|
372
|
+
<div
|
|
373
|
+
v-if="dropdownContent === 'min-length'"
|
|
374
|
+
class="rsui-form-field-combobox__option rsui-form-field-combobox__option--disabled"
|
|
375
|
+
role="option"
|
|
376
|
+
aria-disabled="true"
|
|
377
|
+
aria-selected="false"
|
|
378
|
+
>
|
|
379
|
+
<div class="rsui-form-field-combobox__option-label">
|
|
380
|
+
<slot name="min-length-text">Type at least {{ minSearchLength }} characters to search</slot>
|
|
381
|
+
</div>
|
|
382
|
+
</div>
|
|
383
|
+
|
|
384
|
+
<div
|
|
385
|
+
v-if="dropdownContent === 'no-results'"
|
|
386
|
+
class="rsui-form-field-combobox__option rsui-form-field-combobox__option--disabled"
|
|
387
|
+
role="option"
|
|
388
|
+
aria-disabled="true"
|
|
389
|
+
aria-selected="false"
|
|
390
|
+
>
|
|
391
|
+
<div class="rsui-form-field-combobox__option-label">
|
|
392
|
+
<slot name="no-results-text">No results found</slot>
|
|
393
|
+
</div>
|
|
394
|
+
</div>
|
|
395
|
+
|
|
396
|
+
<template v-if="dropdownContent === 'results' && groupedResults">
|
|
397
|
+
<div
|
|
398
|
+
v-for="group in groupedResults"
|
|
399
|
+
:key="group.name"
|
|
400
|
+
role="group"
|
|
401
|
+
:aria-label="group.name || undefined"
|
|
402
|
+
>
|
|
403
|
+
<div v-if="group.name" class="rsui-form-field-combobox__group-header" aria-hidden="true">
|
|
404
|
+
{{ group.name }}
|
|
405
|
+
</div>
|
|
406
|
+
<div
|
|
407
|
+
v-for="{ result, flatIndex } in group.items"
|
|
408
|
+
:key="result.value"
|
|
409
|
+
:id="`${effectiveId}-result-${flatIndex}`"
|
|
410
|
+
role="option"
|
|
411
|
+
aria-selected="false"
|
|
412
|
+
:class="[
|
|
413
|
+
'rsui-form-field-combobox__option',
|
|
414
|
+
{ 'rsui-form-field-combobox__option--highlighted': flatIndex === highlightedIndex }
|
|
415
|
+
]"
|
|
416
|
+
@click="navigate(result)"
|
|
417
|
+
>
|
|
418
|
+
<slot
|
|
419
|
+
name="result"
|
|
420
|
+
:result="result"
|
|
421
|
+
:index="flatIndex"
|
|
422
|
+
:highlighted="flatIndex === highlightedIndex"
|
|
423
|
+
>
|
|
424
|
+
<div
|
|
425
|
+
class="rsui-form-field-combobox__option-label"
|
|
426
|
+
:title="result.label"
|
|
427
|
+
>
|
|
428
|
+
{{ result.label }}
|
|
429
|
+
</div>
|
|
430
|
+
</slot>
|
|
431
|
+
</div>
|
|
432
|
+
</div>
|
|
433
|
+
</template>
|
|
434
|
+
|
|
435
|
+
<template v-if="dropdownContent === 'results' && !groupedResults">
|
|
436
|
+
<div
|
|
437
|
+
v-for="(result, index) in results"
|
|
438
|
+
:key="result.value"
|
|
439
|
+
:id="`${effectiveId}-result-${index}`"
|
|
440
|
+
role="option"
|
|
441
|
+
aria-selected="false"
|
|
442
|
+
:class="[
|
|
443
|
+
'rsui-form-field-combobox__option',
|
|
444
|
+
{ 'rsui-form-field-combobox__option--highlighted': index === highlightedIndex }
|
|
445
|
+
]"
|
|
446
|
+
@click="navigate(result)"
|
|
447
|
+
>
|
|
448
|
+
<slot
|
|
449
|
+
name="result"
|
|
450
|
+
:result="result"
|
|
451
|
+
:index="index"
|
|
452
|
+
:highlighted="index === highlightedIndex"
|
|
453
|
+
>
|
|
454
|
+
<div
|
|
455
|
+
class="rsui-form-field-combobox__option-label"
|
|
456
|
+
:title="result.label"
|
|
457
|
+
>
|
|
458
|
+
{{ result.label }}
|
|
459
|
+
</div>
|
|
460
|
+
</slot>
|
|
461
|
+
</div>
|
|
462
|
+
</template>
|
|
463
|
+
</div>
|
|
464
|
+
</transition>
|
|
465
|
+
</Teleport>
|
|
466
|
+
|
|
467
|
+
<div
|
|
468
|
+
aria-live="polite"
|
|
469
|
+
aria-atomic="true"
|
|
470
|
+
class="rsui-form-field-combobox__live-region"
|
|
471
|
+
>
|
|
472
|
+
{{ liveAnnouncement }}
|
|
473
|
+
</div>
|
|
474
|
+
</div>
|
|
475
|
+
|
|
476
|
+
<template #help v-if="$slots.help">
|
|
477
|
+
<slot name="help"></slot>
|
|
478
|
+
</template>
|
|
479
|
+
|
|
480
|
+
<template #error v-if="$slots.error">
|
|
481
|
+
<slot name="error"></slot>
|
|
482
|
+
</template>
|
|
483
|
+
</FormFieldSlot>
|
|
484
|
+
</template>
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref, computed, useAttrs } from 'vue'
|
|
3
|
+
import { useFuse } from '@vueuse/integrations/useFuse'
|
|
4
|
+
import FormFieldSlot from './FormFieldSlot.vue'
|
|
5
|
+
import FormFieldSearch from './FormFieldSearch.vue'
|
|
6
|
+
import SelectableTree from './TreeSelectInternal/SelectableTree.vue'
|
|
7
|
+
import Modal from '../Modal/Modal.vue'
|
|
8
|
+
import ButtonSecondary from '../Button/ButtonSecondary.vue'
|
|
9
|
+
import { useFormFieldA11y } from '../../composables/useFormFieldA11y.js'
|
|
10
|
+
|
|
11
|
+
defineOptions({
|
|
12
|
+
inheritAttrs: false,
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
const props = defineProps({
|
|
16
|
+
options: {
|
|
17
|
+
type: Array,
|
|
18
|
+
default: () => [],
|
|
19
|
+
},
|
|
20
|
+
multiple: {
|
|
21
|
+
type: Boolean,
|
|
22
|
+
default: false,
|
|
23
|
+
},
|
|
24
|
+
fullWidth: {
|
|
25
|
+
type: Boolean,
|
|
26
|
+
default: false,
|
|
27
|
+
},
|
|
28
|
+
disabled: {
|
|
29
|
+
type: Boolean,
|
|
30
|
+
default: false,
|
|
31
|
+
},
|
|
32
|
+
triggerPlaceholder: {
|
|
33
|
+
type: String,
|
|
34
|
+
default: 'Select an option',
|
|
35
|
+
},
|
|
36
|
+
modalHeader: {
|
|
37
|
+
type: String,
|
|
38
|
+
default: 'Select an option',
|
|
39
|
+
},
|
|
40
|
+
searchPlaceholder: {
|
|
41
|
+
type: String,
|
|
42
|
+
default: 'Search...',
|
|
43
|
+
},
|
|
44
|
+
closeLabel: {
|
|
45
|
+
type: String,
|
|
46
|
+
default: 'Close',
|
|
47
|
+
},
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
const emit = defineEmits(['change'])
|
|
51
|
+
|
|
52
|
+
const attrs = useAttrs()
|
|
53
|
+
const { inputId, ariaDescribedby, ariaInvalid } = useFormFieldA11y()
|
|
54
|
+
|
|
55
|
+
const model = defineModel({
|
|
56
|
+
default: null,
|
|
57
|
+
validator: () => true,
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const effectiveId = computed(() => inputId.value || attrs.id)
|
|
61
|
+
|
|
62
|
+
const show = ref(false)
|
|
63
|
+
function openModal() {
|
|
64
|
+
if (props.disabled) return
|
|
65
|
+
show.value = true
|
|
66
|
+
}
|
|
67
|
+
function closeModal() {
|
|
68
|
+
show.value = false
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Flatten all option trees into a linear list (excluding children references).
|
|
72
|
+
const linear = computed(() => {
|
|
73
|
+
const out = []
|
|
74
|
+
const walk = (node) => {
|
|
75
|
+
const { children, ...rest } = node
|
|
76
|
+
out.push(rest)
|
|
77
|
+
if (children && children.length) children.forEach(walk)
|
|
78
|
+
}
|
|
79
|
+
props.options.forEach(walk)
|
|
80
|
+
return out
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
// Build the label shown in the trigger button.
|
|
84
|
+
const triggerLabel = computed(() => {
|
|
85
|
+
if (props.multiple) {
|
|
86
|
+
const count = Array.isArray(model.value) ? model.value.length : 0
|
|
87
|
+
if (count === 0) return props.triggerPlaceholder
|
|
88
|
+
return `${count} of ${linear.value.length} selected`
|
|
89
|
+
}
|
|
90
|
+
if (model.value === null || model.value === undefined || model.value === '') return props.triggerPlaceholder
|
|
91
|
+
const found = linear.value.find((o) => o.value === model.value)
|
|
92
|
+
return found ? found.label : props.triggerPlaceholder
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
// Search ----------------------------------------------------------------
|
|
96
|
+
const searchInput = ref('')
|
|
97
|
+
|
|
98
|
+
const searchOptions = {
|
|
99
|
+
fuseOptions: {
|
|
100
|
+
keys: ['label'],
|
|
101
|
+
isCaseSensitive: false,
|
|
102
|
+
threshold: 0.2,
|
|
103
|
+
},
|
|
104
|
+
matchAllWhenSearchEmpty: true,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const { results } = useFuse(searchInput, linear, searchOptions)
|
|
108
|
+
|
|
109
|
+
const loading = ref(false)
|
|
110
|
+
|
|
111
|
+
const openChildren = computed(() => {
|
|
112
|
+
// Force the tree to re-render so each node's local isOpen state resets.
|
|
113
|
+
loading.value = true
|
|
114
|
+
setTimeout(() => { loading.value = false }, 1)
|
|
115
|
+
if (!searchInput.value) return false
|
|
116
|
+
return linear.value.length !== results.value.length
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
// Trim the option trees to only the branches that contain matches.
|
|
120
|
+
const searchedTrees = computed(() => {
|
|
121
|
+
const matchValues = results.value.map((r) => r.item.value)
|
|
122
|
+
const check = (node) => {
|
|
123
|
+
if (matchValues.includes(node.value)) return node
|
|
124
|
+
if (node.children && node.children.length) {
|
|
125
|
+
const kept = node.children.map(check).filter(Boolean)
|
|
126
|
+
if (kept.length) return { ...node, children: kept }
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return props.options.map(check).filter(Boolean)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
function searchChecker(node) {
|
|
133
|
+
const matchValues = results.value.map((r) => r.item.value)
|
|
134
|
+
return matchValues.includes(node.value) && matchValues.length !== linear.value.length
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function selectionChecker(node) {
|
|
138
|
+
if (props.multiple) {
|
|
139
|
+
return Array.isArray(model.value) && model.value.includes(node.value)
|
|
140
|
+
}
|
|
141
|
+
return model.value === node.value
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Selection -------------------------------------------------------------
|
|
145
|
+
function selectOption(node) {
|
|
146
|
+
if (!props.multiple) {
|
|
147
|
+
model.value = node.value
|
|
148
|
+
emit('change', node.value)
|
|
149
|
+
closeModal()
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
if (selectionChecker(node)) return
|
|
153
|
+
const next = Array.isArray(model.value) ? [...model.value] : []
|
|
154
|
+
const addCascade = (n) => {
|
|
155
|
+
if (!next.includes(n.value)) next.push(n.value)
|
|
156
|
+
if (n.children && n.children.length) n.children.forEach(addCascade)
|
|
157
|
+
}
|
|
158
|
+
addCascade(node)
|
|
159
|
+
model.value = next
|
|
160
|
+
emit('change', next)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function deselectOption(node) {
|
|
164
|
+
if (!props.multiple) {
|
|
165
|
+
model.value = null
|
|
166
|
+
emit('change', null)
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
if (!selectionChecker(node)) return
|
|
170
|
+
const next = Array.isArray(model.value) ? [...model.value] : []
|
|
171
|
+
const removeCascade = (n) => {
|
|
172
|
+
const i = next.indexOf(n.value)
|
|
173
|
+
if (i >= 0) next.splice(i, 1)
|
|
174
|
+
if (n.children && n.children.length) n.children.forEach(removeCascade)
|
|
175
|
+
}
|
|
176
|
+
removeCascade(node)
|
|
177
|
+
model.value = next
|
|
178
|
+
emit('change', next)
|
|
179
|
+
}
|
|
180
|
+
</script>
|
|
181
|
+
|
|
182
|
+
<template>
|
|
183
|
+
<FormFieldSlot
|
|
184
|
+
:id="$attrs.id"
|
|
185
|
+
:class="[$attrs.class, 'rsui-form-field-tree-select']"
|
|
186
|
+
:required="$attrs.required"
|
|
187
|
+
:showAsterisk="$attrs.showAsterisk"
|
|
188
|
+
:compact="$attrs.compact"
|
|
189
|
+
>
|
|
190
|
+
<template #label v-if="$slots.label">
|
|
191
|
+
<slot name="label"></slot>
|
|
192
|
+
</template>
|
|
193
|
+
|
|
194
|
+
<div class="rsui-form-field-tree-select__group">
|
|
195
|
+
<button
|
|
196
|
+
type="button"
|
|
197
|
+
:class="[
|
|
198
|
+
'rsui-form-field-tree-select__trigger',
|
|
199
|
+
{ 'rsui-form-field-tree-select__trigger--full': fullWidth },
|
|
200
|
+
{ 'rsui-form-field-tree-select__trigger--disabled': disabled },
|
|
201
|
+
]"
|
|
202
|
+
:id="effectiveId"
|
|
203
|
+
:aria-describedby="ariaDescribedby"
|
|
204
|
+
:aria-invalid="ariaInvalid"
|
|
205
|
+
:aria-required="$attrs.required || undefined"
|
|
206
|
+
:aria-haspopup="'dialog'"
|
|
207
|
+
:aria-expanded="show"
|
|
208
|
+
:disabled="disabled"
|
|
209
|
+
@click="openModal"
|
|
210
|
+
>
|
|
211
|
+
{{ triggerLabel }}
|
|
212
|
+
</button>
|
|
213
|
+
</div>
|
|
214
|
+
|
|
215
|
+
<Modal :show="show" lg closeable @close="closeModal">
|
|
216
|
+
<template #header>
|
|
217
|
+
<div class="rsui-form-field-tree-select__modal-header">
|
|
218
|
+
<span>
|
|
219
|
+
<slot name="modal-header">{{ modalHeader }}</slot>
|
|
220
|
+
</span>
|
|
221
|
+
</div>
|
|
222
|
+
</template>
|
|
223
|
+
|
|
224
|
+
<div class="rsui-form-field-tree-select__search">
|
|
225
|
+
<FormFieldSearch
|
|
226
|
+
v-model="searchInput"
|
|
227
|
+
:placeholder="searchPlaceholder"
|
|
228
|
+
></FormFieldSearch>
|
|
229
|
+
</div>
|
|
230
|
+
|
|
231
|
+
<div class="rsui-form-field-tree-select__tree">
|
|
232
|
+
<template v-if="!loading">
|
|
233
|
+
<SelectableTree
|
|
234
|
+
v-for="(tree, i) in searchedTrees"
|
|
235
|
+
:key="tree.value ?? i"
|
|
236
|
+
:node="tree"
|
|
237
|
+
:top="true"
|
|
238
|
+
:open="true"
|
|
239
|
+
:openChildren="openChildren"
|
|
240
|
+
:searchChecker="searchChecker"
|
|
241
|
+
:selectionChecker="selectionChecker"
|
|
242
|
+
@select="selectOption"
|
|
243
|
+
@deselect="deselectOption"
|
|
244
|
+
></SelectableTree>
|
|
245
|
+
</template>
|
|
246
|
+
</div>
|
|
247
|
+
|
|
248
|
+
<template #footer="{ close }">
|
|
249
|
+
<ButtonSecondary @click="close">
|
|
250
|
+
{{ closeLabel }}
|
|
251
|
+
</ButtonSecondary>
|
|
252
|
+
</template>
|
|
253
|
+
</Modal>
|
|
254
|
+
|
|
255
|
+
<template #help v-if="$slots.help">
|
|
256
|
+
<slot name="help"></slot>
|
|
257
|
+
</template>
|
|
258
|
+
|
|
259
|
+
<template #error v-if="$slots.error">
|
|
260
|
+
<slot name="error"></slot>
|
|
261
|
+
</template>
|
|
262
|
+
</FormFieldSlot>
|
|
263
|
+
</template>
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref, watch } from 'vue'
|
|
3
|
+
import { ChevronRightIcon } from '@heroicons/vue/24/outline'
|
|
4
|
+
import FormFieldCheckbox from '../FormFieldCheckbox.vue'
|
|
5
|
+
|
|
6
|
+
const props = defineProps({
|
|
7
|
+
showOpener: {
|
|
8
|
+
type: Boolean,
|
|
9
|
+
default: false,
|
|
10
|
+
},
|
|
11
|
+
hasChildren: {
|
|
12
|
+
type: Boolean,
|
|
13
|
+
default: false,
|
|
14
|
+
},
|
|
15
|
+
isOpen: {
|
|
16
|
+
type: Boolean,
|
|
17
|
+
default: false,
|
|
18
|
+
},
|
|
19
|
+
isSearched: {
|
|
20
|
+
type: Boolean,
|
|
21
|
+
default: false,
|
|
22
|
+
},
|
|
23
|
+
isSelected: {
|
|
24
|
+
type: Boolean,
|
|
25
|
+
default: false,
|
|
26
|
+
},
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const emit = defineEmits(['change', 'open'])
|
|
30
|
+
|
|
31
|
+
const selected = ref(false)
|
|
32
|
+
|
|
33
|
+
watch(() => props.isSelected, value => selected.value = value, { immediate: true, flush: 'post' })
|
|
34
|
+
|
|
35
|
+
function change(event) {
|
|
36
|
+
emit('change', event)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function open() {
|
|
40
|
+
if (!props.hasChildren) return
|
|
41
|
+
emit('open')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function clickName() {
|
|
45
|
+
selected.value = !selected.value
|
|
46
|
+
emit('change', { target: { checked: selected.value } })
|
|
47
|
+
}
|
|
48
|
+
</script>
|
|
49
|
+
<template>
|
|
50
|
+
<div class="rsui-form-field-tree-select-item">
|
|
51
|
+
<div class="rsui-form-field-tree-select-item__controls">
|
|
52
|
+
<div v-if="showOpener"
|
|
53
|
+
:class="[
|
|
54
|
+
'rsui-form-field-tree-select-item__opener',
|
|
55
|
+
hasChildren ? 'rsui-form-field-tree-select-item__opener--clickable' : '',
|
|
56
|
+
]"
|
|
57
|
+
@click="open"
|
|
58
|
+
>
|
|
59
|
+
<ChevronRightIcon v-if="hasChildren"
|
|
60
|
+
:class="[
|
|
61
|
+
'rsui-form-field-tree-select-item__opener-icon',
|
|
62
|
+
isOpen ? 'rsui-form-field-tree-select-item__opener-icon--open' : '',
|
|
63
|
+
]"
|
|
64
|
+
/>
|
|
65
|
+
</div>
|
|
66
|
+
<div class="rsui-form-field-tree-select-item__selector">
|
|
67
|
+
<FormFieldCheckbox
|
|
68
|
+
v-model="selected"
|
|
69
|
+
@input="change"
|
|
70
|
+
>
|
|
71
|
+
<template #label>
|
|
72
|
+
<div :class="[
|
|
73
|
+
'rsui-form-field-tree-select-item__name',
|
|
74
|
+
isSearched ? 'rsui-form-field-tree-select-item__name--searched' : '',
|
|
75
|
+
]"
|
|
76
|
+
@click.stop="clickName"
|
|
77
|
+
>
|
|
78
|
+
<slot name="name"></slot>
|
|
79
|
+
</div>
|
|
80
|
+
</template>
|
|
81
|
+
</FormFieldCheckbox>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
</template>
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref, computed } from 'vue'
|
|
3
|
+
import SelectableItem from './SelectableItem.vue'
|
|
4
|
+
|
|
5
|
+
const props = defineProps({
|
|
6
|
+
node: {
|
|
7
|
+
type: Object,
|
|
8
|
+
required: true,
|
|
9
|
+
},
|
|
10
|
+
top: {
|
|
11
|
+
type: Boolean,
|
|
12
|
+
default: false,
|
|
13
|
+
},
|
|
14
|
+
open: {
|
|
15
|
+
type: Boolean,
|
|
16
|
+
default: false,
|
|
17
|
+
},
|
|
18
|
+
openChildren: {
|
|
19
|
+
type: Boolean,
|
|
20
|
+
default: false,
|
|
21
|
+
},
|
|
22
|
+
searchChecker: {
|
|
23
|
+
type: Function,
|
|
24
|
+
required: true,
|
|
25
|
+
},
|
|
26
|
+
selectionChecker: {
|
|
27
|
+
type: Function,
|
|
28
|
+
required: true,
|
|
29
|
+
},
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const emit = defineEmits(['select', 'deselect'])
|
|
33
|
+
|
|
34
|
+
const hasChildren = computed(() => !!props.node.children && !!props.node.children.length)
|
|
35
|
+
|
|
36
|
+
const showOpener = computed(() => {
|
|
37
|
+
return (props.top && hasChildren.value) || !props.top
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
const isOpen = ref(props.open)
|
|
41
|
+
|
|
42
|
+
function toggleOpen() {
|
|
43
|
+
if (!hasChildren.value) return
|
|
44
|
+
isOpen.value = !isOpen.value
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function toggleSelect(event) {
|
|
48
|
+
if (event.target.checked) {
|
|
49
|
+
emit('select', props.node)
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
emit('deselect', props.node)
|
|
53
|
+
}
|
|
54
|
+
</script>
|
|
55
|
+
|
|
56
|
+
<template>
|
|
57
|
+
<SelectableItem
|
|
58
|
+
:showOpener="showOpener"
|
|
59
|
+
:hasChildren="hasChildren"
|
|
60
|
+
:isOpen="isOpen"
|
|
61
|
+
:isSearched="searchChecker(node)"
|
|
62
|
+
:isSelected="selectionChecker(node)"
|
|
63
|
+
@open="toggleOpen"
|
|
64
|
+
@change="toggleSelect"
|
|
65
|
+
>
|
|
66
|
+
<template #name>
|
|
67
|
+
{{ node.label }}
|
|
68
|
+
</template>
|
|
69
|
+
</SelectableItem>
|
|
70
|
+
|
|
71
|
+
<div v-if="hasChildren && isOpen"
|
|
72
|
+
class="rsui-form-field-tree-select-children"
|
|
73
|
+
>
|
|
74
|
+
<SelectableTree v-for="child in node.children"
|
|
75
|
+
:key="child.value"
|
|
76
|
+
:node="child"
|
|
77
|
+
:open="openChildren"
|
|
78
|
+
:openChildren="openChildren"
|
|
79
|
+
:searchChecker="searchChecker"
|
|
80
|
+
:selectionChecker="selectionChecker"
|
|
81
|
+
@select="(n) => $emit('select', n)"
|
|
82
|
+
@deselect="(n) => $emit('deselect', n)"
|
|
83
|
+
>
|
|
84
|
+
</SelectableTree>
|
|
85
|
+
</div>
|
|
86
|
+
</template>
|
|
@@ -7,11 +7,13 @@ import FormFieldPassword from './FormFieldPassword.vue'
|
|
|
7
7
|
import FormFieldPasswordToggle from './FormFieldPasswordToggle.vue'
|
|
8
8
|
import FormFieldRadioGroup from './FormFieldRadioGroup.vue'
|
|
9
9
|
import FormFieldSearch from './FormFieldSearch.vue'
|
|
10
|
+
import FormFieldSearchAsync from './FormFieldSearchAsync.vue'
|
|
10
11
|
import FormFieldSelect from './FormFieldSelect.vue'
|
|
11
12
|
import FormFieldSlot from './FormFieldSlot.vue'
|
|
12
13
|
import FormFieldText from './FormFieldText.vue'
|
|
13
14
|
import FormFieldTextarea from './FormFieldTextarea.vue'
|
|
14
15
|
import FormFieldTextSuffix from './FormFieldTextSuffix.vue'
|
|
16
|
+
import FormFieldTreeSelect from './FormFieldTreeSelect.vue'
|
|
15
17
|
import FormFieldUploaderWrapper from './FormFieldUploaderWrapper.vue'
|
|
16
18
|
export {
|
|
17
19
|
FormFieldCheckbox,
|
|
@@ -23,10 +25,12 @@ export {
|
|
|
23
25
|
FormFieldPasswordToggle,
|
|
24
26
|
FormFieldRadioGroup,
|
|
25
27
|
FormFieldSearch,
|
|
28
|
+
FormFieldSearchAsync,
|
|
26
29
|
FormFieldSelect,
|
|
27
30
|
FormFieldSlot,
|
|
28
31
|
FormFieldText,
|
|
29
32
|
FormFieldTextarea,
|
|
30
33
|
FormFieldTextSuffix,
|
|
34
|
+
FormFieldTreeSelect,
|
|
31
35
|
FormFieldUploaderWrapper,
|
|
32
36
|
}
|