@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redseed/redseed-ui-vue3",
3
- "version": "8.29.2",
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
  }