@redseed/redseed-ui-vue3 8.30.1 → 8.31.1

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.30.1",
3
+ "version": "8.31.1",
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"
@@ -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>
@@ -13,6 +13,7 @@ import FormFieldSlot from './FormFieldSlot.vue'
13
13
  import FormFieldText from './FormFieldText.vue'
14
14
  import FormFieldTextarea from './FormFieldTextarea.vue'
15
15
  import FormFieldTextSuffix from './FormFieldTextSuffix.vue'
16
+ import FormFieldTreeSelect from './FormFieldTreeSelect.vue'
16
17
  import FormFieldUploaderWrapper from './FormFieldUploaderWrapper.vue'
17
18
  export {
18
19
  FormFieldCheckbox,
@@ -30,5 +31,6 @@ export {
30
31
  FormFieldText,
31
32
  FormFieldTextarea,
32
33
  FormFieldTextSuffix,
34
+ FormFieldTreeSelect,
33
35
  FormFieldUploaderWrapper,
34
36
  }
@@ -1,6 +1,5 @@
1
1
  <script setup>
2
2
  import { ref, computed, watch } from 'vue'
3
- import { useImage } from '@vueuse/core'
4
3
  import Icon from '../Icon/Icon.vue'
5
4
  import { PhotoIcon } from '@heroicons/vue/24/outline'
6
5
 
@@ -31,108 +30,70 @@ const props = defineProps({
31
30
  },
32
31
  })
33
32
 
34
- const imageElement = ref(null)
35
-
36
- const large = computed(() => props.largeUrl || props.originalUrl)
37
- const medium = computed(() => props.mediumUrl || props.originalUrl)
38
- const small = computed(() => props.smallUrl || props.originalUrl)
39
-
40
- const originalOptions = computed(() => ({
41
- src: props.originalUrl,
42
- }))
43
-
44
- const largeOptions = computed(() => ({
45
- src: large.value,
46
- }))
47
-
48
- const mediumOptions = computed(() => ({
49
- src: medium.value,
50
- }))
51
-
52
- const smallOptions = computed(() => ({
53
- src: small.value,
54
- }))
33
+ const emit = defineEmits(['loading', 'error', 'ready'])
55
34
 
56
- const originalImage = ref(useImage(originalOptions.value))
57
- const largeImage = ref(useImage(largeOptions.value))
58
- const mediumImage = ref(useImage(mediumOptions.value))
59
- const smallImage = ref(useImage(smallOptions.value))
35
+ const imageElement = ref(null)
36
+ const isLoaded = ref(false)
37
+ const error = ref(null)
60
38
 
61
39
  const isEmpty = computed(() => !props.originalUrl)
62
-
63
- const isLoading = computed(() => {
64
- if (isEmpty.value) return false
65
-
66
- return originalImage.value.isLoading
67
- || largeImage.value.isLoading
68
- || mediumImage.value.isLoading
69
- || smallImage.value.isLoading
40
+ const isLoading = computed(() => !isEmpty.value && !isLoaded.value && !error.value)
41
+ const isReady = computed(() => isLoaded.value)
42
+
43
+ // Reset state when the source URL changes so the new image goes through
44
+ // loading → ready/error again.
45
+ watch(() => props.originalUrl, () => {
46
+ isLoaded.value = false
47
+ error.value = null
70
48
  })
71
49
 
72
- const isReady = computed(() => {
73
- if (isEmpty.value) return false
74
-
75
- return originalImage.value.isReady
76
- && largeImage.value.isReady
77
- && mediumImage.value.isReady
78
- && smallImage.value.isReady
79
- })
80
-
81
- const error = computed(() => {
82
- if (isEmpty.value) return false
83
-
84
- return originalImage.value.error
85
- || largeImage.value.error
86
- || mediumImage.value.error
87
- || smallImage.value.error
88
- })
89
-
90
- const isLoaded = ref(false)
91
-
92
- const emit = defineEmits(['loading', 'error', 'ready'])
93
-
94
50
  watch(isLoading, () => {
95
- if (isLoading.value) emit('loading', isLoading.value)
51
+ if (isLoading.value) emit('loading', true)
96
52
  }, { immediate: true, flush: 'post' })
97
53
 
98
54
  watch(error, () => {
99
55
  if (error.value) emit('error', error.value)
100
- }, { immediate: true, flush: 'post' })
56
+ }, { flush: 'post' })
101
57
 
102
58
  watch(isLoaded, () => {
103
- if (isLoaded.value && isReady.value) {
59
+ if (isLoaded.value) {
104
60
  emit('ready', {
105
- ready: isReady.value,
61
+ ready: true,
106
62
  imageElement: imageElement.value,
107
63
  })
108
64
  }
109
- }, { immediate: true, flush: 'post' })
65
+ }, { flush: 'post' })
110
66
 
111
- const sources = computed(() => {
112
- return [
113
- {
114
- media: '(min-width:1280px)',
115
- srcset: encodeURI(large.value)
116
- },
117
- {
118
- media: '(min-width:640px)',
119
- srcset: encodeURI(medium.value)
120
- },
121
- {
122
- media: '(min-width:320px)',
123
- srcset: encodeURI(small.value)
124
- },
125
- ]
126
- })
67
+ function handleLoad() {
68
+ isLoaded.value = true
69
+ }
70
+
71
+ function handleError(event) {
72
+ error.value = event
73
+ }
74
+
75
+ const sources = computed(() => [
76
+ {
77
+ media: '(min-width:1280px)',
78
+ srcset: encodeURI(props.largeUrl || props.originalUrl),
79
+ },
80
+ {
81
+ media: '(min-width:640px)',
82
+ srcset: encodeURI(props.mediumUrl || props.originalUrl),
83
+ },
84
+ {
85
+ media: '(min-width:320px)',
86
+ srcset: encodeURI(props.smallUrl || props.originalUrl),
87
+ },
88
+ ])
127
89
 
128
90
  const imageClass = computed(() => [
129
91
  'rsui-image',
130
92
  {
131
93
  'rsui-image--rounded': props.rounded,
132
94
  'rsui-image--empty': isEmpty.value,
133
- 'rsui-image--error': error.value,
134
- }
135
-
95
+ 'rsui-image--error': !!error.value,
96
+ },
136
97
  ])
137
98
  </script>
138
99
  <template>
@@ -146,29 +107,35 @@ const imageClass = computed(() => [
146
107
  </Icon>
147
108
  </slot>
148
109
  </div>
149
- <div v-else-if="isLoading"
150
- class="rsui-image__message"
151
- >
152
- <slot name="loading"></slot>
153
- </div>
154
- <div v-else-if="error"
155
- class="rsui-image__message"
156
- >
157
- <slot name="error">
158
- Could not load image
159
- </slot>
160
- </div>
161
- <picture v-else>
162
- <source v-for="{ media, srcset } in sources"
163
- :key="media"
164
- :media="media"
165
- :srcset="srcset"
110
+ <template v-else>
111
+ <!-- The <picture> stays mounted (v-show, not v-if) so the <img> drives
112
+ load/error state via its native events. Hidden while loading; the
113
+ browser still fetches the source because the element is in the DOM. -->
114
+ <picture v-show="isReady">
115
+ <source v-for="{ media, srcset } in sources"
116
+ :key="media"
117
+ :media="media"
118
+ :srcset="srcset"
119
+ >
120
+ <img ref="imageElement"
121
+ :src="originalUrl"
122
+ :alt="alt"
123
+ @load="handleLoad"
124
+ @error="handleError"
125
+ >
126
+ </picture>
127
+ <div v-if="isLoading"
128
+ class="rsui-image__message"
166
129
  >
167
- <img ref="imageElement"
168
- :src="originalUrl"
169
- :alt="alt"
170
- @load="isLoaded = true"
130
+ <slot name="loading"></slot>
131
+ </div>
132
+ <div v-else-if="error"
133
+ class="rsui-image__message"
171
134
  >
172
- </picture>
135
+ <slot name="error">
136
+ Could not load image
137
+ </slot>
138
+ </div>
139
+ </template>
173
140
  </div>
174
141
  </template>