@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 +3 -1
- 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 +2 -0
- package/src/components/Image/Image.vue +69 -102
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@redseed/redseed-ui-vue3",
|
|
3
|
-
"version": "8.
|
|
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
|
|
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
|
|
57
|
-
const
|
|
58
|
-
const
|
|
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
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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',
|
|
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
|
-
}, {
|
|
56
|
+
}, { flush: 'post' })
|
|
101
57
|
|
|
102
58
|
watch(isLoaded, () => {
|
|
103
|
-
if (isLoaded.value
|
|
59
|
+
if (isLoaded.value) {
|
|
104
60
|
emit('ready', {
|
|
105
|
-
ready:
|
|
61
|
+
ready: true,
|
|
106
62
|
imageElement: imageElement.value,
|
|
107
63
|
})
|
|
108
64
|
}
|
|
109
|
-
}, {
|
|
65
|
+
}, { flush: 'post' })
|
|
110
66
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
<
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
130
|
+
<slot name="loading"></slot>
|
|
131
|
+
</div>
|
|
132
|
+
<div v-else-if="error"
|
|
133
|
+
class="rsui-image__message"
|
|
171
134
|
>
|
|
172
|
-
|
|
135
|
+
<slot name="error">
|
|
136
|
+
Could not load image
|
|
137
|
+
</slot>
|
|
138
|
+
</div>
|
|
139
|
+
</template>
|
|
173
140
|
</div>
|
|
174
141
|
</template>
|