frappe-ui 0.1.24 → 0.1.26
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
|
@@ -2,24 +2,102 @@
|
|
|
2
2
|
import { ref } from 'vue'
|
|
3
3
|
import Autocomplete from './Autocomplete.vue'
|
|
4
4
|
|
|
5
|
-
const
|
|
5
|
+
const single = ref('')
|
|
6
|
+
const people = ref(null)
|
|
6
7
|
const options = [
|
|
7
|
-
{
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
{
|
|
8
|
+
{
|
|
9
|
+
label: 'John Doe',
|
|
10
|
+
value: 'john-doe',
|
|
11
|
+
image: 'https://randomuser.me/api/portraits/men/59.jpg',
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
label: 'Jane Doe',
|
|
15
|
+
value: 'jane-doe',
|
|
16
|
+
image: 'https://randomuser.me/api/portraits/women/58.jpg',
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
label: 'John Smith',
|
|
20
|
+
value: 'john-smith',
|
|
21
|
+
image: 'https://randomuser.me/api/portraits/men/59.jpg',
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
label: 'Jane Smith',
|
|
25
|
+
value: 'jane-smith',
|
|
26
|
+
image: 'https://randomuser.me/api/portraits/women/59.jpg',
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
label: 'John Wayne',
|
|
30
|
+
value: 'john-wayne',
|
|
31
|
+
image: 'https://randomuser.me/api/portraits/men/57.jpg',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
label: 'Jane Wayne',
|
|
35
|
+
value: 'jane-wayne',
|
|
36
|
+
image: 'https://randomuser.me/api/portraits/women/51.jpg',
|
|
37
|
+
},
|
|
13
38
|
]
|
|
14
39
|
</script>
|
|
15
40
|
<template>
|
|
16
41
|
<Story :layout="{ width: 500, type: 'grid' }" autoPropsDisabled>
|
|
17
|
-
<
|
|
18
|
-
<
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
42
|
+
<Variant title="Single option">
|
|
43
|
+
<div class="p-2">
|
|
44
|
+
<Autocomplete
|
|
45
|
+
:options="options"
|
|
46
|
+
v-model="single"
|
|
47
|
+
placeholder="Select person"
|
|
48
|
+
/>
|
|
49
|
+
</div>
|
|
50
|
+
</Variant>
|
|
51
|
+
<Variant title="Single option with prefix slots">
|
|
52
|
+
<div class="p-2">
|
|
53
|
+
<Autocomplete
|
|
54
|
+
:options="options"
|
|
55
|
+
v-model="single"
|
|
56
|
+
placeholder="Select person"
|
|
57
|
+
>
|
|
58
|
+
<template #prefix>
|
|
59
|
+
<img
|
|
60
|
+
v-if="single"
|
|
61
|
+
:src="single.image"
|
|
62
|
+
class="mr-2 h-4 w-4 rounded-full"
|
|
63
|
+
/>
|
|
64
|
+
</template>
|
|
65
|
+
<template #item-prefix="{ option }">
|
|
66
|
+
<img :src="option.image" class="h-4 w-4 rounded-full" />
|
|
67
|
+
</template>
|
|
68
|
+
</Autocomplete>
|
|
69
|
+
</div>
|
|
70
|
+
</Variant>
|
|
71
|
+
<Variant title="Single option without search">
|
|
72
|
+
<div class="p-2">
|
|
73
|
+
<Autocomplete
|
|
74
|
+
:options="options"
|
|
75
|
+
v-model="single"
|
|
76
|
+
placeholder="Select person"
|
|
77
|
+
hide-search="true"
|
|
78
|
+
/>
|
|
79
|
+
</div>
|
|
80
|
+
</Variant>
|
|
81
|
+
<Variant title="Multiple options">
|
|
82
|
+
<div class="p-2">
|
|
83
|
+
<Autocomplete
|
|
84
|
+
:options="options"
|
|
85
|
+
v-model="people"
|
|
86
|
+
placeholder="Select people"
|
|
87
|
+
multiple="true"
|
|
88
|
+
/>
|
|
89
|
+
</div>
|
|
90
|
+
</Variant>
|
|
91
|
+
<Variant title="Multiple options without search">
|
|
92
|
+
<div class="p-2">
|
|
93
|
+
<Autocomplete
|
|
94
|
+
:options="options"
|
|
95
|
+
v-model="people"
|
|
96
|
+
placeholder="Select people"
|
|
97
|
+
multiple="true"
|
|
98
|
+
hide-search="true"
|
|
99
|
+
/>
|
|
100
|
+
</div>
|
|
101
|
+
</Variant>
|
|
24
102
|
</Story>
|
|
25
103
|
</template>
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<Combobox
|
|
2
|
+
<Combobox
|
|
3
|
+
v-model="selectedValue"
|
|
4
|
+
:multiple="multiple"
|
|
5
|
+
nullable
|
|
6
|
+
v-slot="{ open: isComboboxOpen }"
|
|
7
|
+
>
|
|
3
8
|
<Popover class="w-full" v-model:show="showOptions">
|
|
4
9
|
<template #target="{ open: openPopover, togglePopover }">
|
|
5
10
|
<slot name="target" v-bind="{ open: openPopover, togglePopover }">
|
|
@@ -9,15 +14,12 @@
|
|
|
9
14
|
:class="{ 'bg-gray-200': isComboboxOpen }"
|
|
10
15
|
@click="() => togglePopover()"
|
|
11
16
|
>
|
|
12
|
-
<div class="flex items-center">
|
|
17
|
+
<div class="flex items-center overflow-hidden">
|
|
13
18
|
<slot name="prefix" />
|
|
14
|
-
<span
|
|
15
|
-
class="overflow-hidden text-ellipsis whitespace-nowrap text-base leading-5"
|
|
16
|
-
v-if="selectedValue"
|
|
17
|
-
>
|
|
19
|
+
<span class="truncate text-base leading-5" v-if="selectedValue">
|
|
18
20
|
{{ displayValue(selectedValue) }}
|
|
19
21
|
</span>
|
|
20
|
-
<span class="text-base leading-5 text-gray-
|
|
22
|
+
<span class="text-base leading-5 text-gray-600" v-else>
|
|
21
23
|
{{ placeholder || '' }}
|
|
22
24
|
</span>
|
|
23
25
|
</div>
|
|
@@ -30,97 +32,161 @@
|
|
|
30
32
|
</div>
|
|
31
33
|
</slot>
|
|
32
34
|
</template>
|
|
33
|
-
<template #body="{ isOpen }">
|
|
35
|
+
<template #body="{ isOpen, togglePopover }">
|
|
34
36
|
<div v-show="isOpen">
|
|
35
|
-
<
|
|
36
|
-
class="mt-1
|
|
37
|
-
|
|
37
|
+
<div
|
|
38
|
+
class="relative mt-1 rounded-lg bg-white text-base shadow-2xl"
|
|
39
|
+
:class="bodyClasses"
|
|
38
40
|
>
|
|
39
|
-
<
|
|
40
|
-
class="
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
<ComboboxInput
|
|
44
|
-
ref="search"
|
|
45
|
-
class="form-input w-full"
|
|
46
|
-
type="text"
|
|
47
|
-
@change="
|
|
48
|
-
(e) => {
|
|
49
|
-
query = e.target.value
|
|
50
|
-
}
|
|
51
|
-
"
|
|
52
|
-
:value="query"
|
|
53
|
-
autocomplete="off"
|
|
54
|
-
placeholder="Search"
|
|
55
|
-
/>
|
|
56
|
-
<button
|
|
57
|
-
class="absolute right-0 inline-flex h-7 w-7 items-center justify-center"
|
|
58
|
-
@click="selectedValue = null"
|
|
59
|
-
>
|
|
60
|
-
<FeatherIcon name="x" class="w-4" />
|
|
61
|
-
</button>
|
|
62
|
-
</div>
|
|
63
|
-
</div>
|
|
64
|
-
<div
|
|
65
|
-
class="mt-1.5"
|
|
66
|
-
v-for="group in groups"
|
|
67
|
-
:key="group.key"
|
|
68
|
-
v-show="group.items.length > 0"
|
|
41
|
+
<ComboboxOptions
|
|
42
|
+
class="max-h-[15rem] overflow-y-auto px-1.5 pb-1.5"
|
|
43
|
+
:class="{ 'pt-1.5': hideSearch }"
|
|
44
|
+
static
|
|
69
45
|
>
|
|
70
46
|
<div
|
|
71
|
-
v-if="
|
|
72
|
-
class="
|
|
47
|
+
v-if="!hideSearch"
|
|
48
|
+
class="sticky top-0 z-10 flex items-stretch space-x-1.5 bg-white py-1.5"
|
|
73
49
|
>
|
|
74
|
-
|
|
50
|
+
<div class="relative w-full">
|
|
51
|
+
<ComboboxInput
|
|
52
|
+
ref="searchInput"
|
|
53
|
+
class="form-input w-full"
|
|
54
|
+
type="text"
|
|
55
|
+
@change="
|
|
56
|
+
(e) => {
|
|
57
|
+
query = e.target.value
|
|
58
|
+
}
|
|
59
|
+
"
|
|
60
|
+
:value="query"
|
|
61
|
+
autocomplete="off"
|
|
62
|
+
placeholder="Search"
|
|
63
|
+
/>
|
|
64
|
+
<button
|
|
65
|
+
class="absolute right-0 inline-flex h-7 w-7 items-center justify-center"
|
|
66
|
+
@click="selectedValue = null"
|
|
67
|
+
>
|
|
68
|
+
<FeatherIcon name="x" class="w-4" />
|
|
69
|
+
</button>
|
|
70
|
+
</div>
|
|
75
71
|
</div>
|
|
76
|
-
<
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
:value="option"
|
|
81
|
-
v-slot="{ active, selected }"
|
|
72
|
+
<div
|
|
73
|
+
v-for="group in groups"
|
|
74
|
+
:key="group.key"
|
|
75
|
+
v-show="group.items.length > 0"
|
|
82
76
|
>
|
|
83
|
-
<
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
77
|
+
<div
|
|
78
|
+
v-if="group.group && !group.hideLabel"
|
|
79
|
+
class="sticky top-10 truncate bg-white px-2.5 py-1.5 text-sm font-medium text-gray-600"
|
|
80
|
+
>
|
|
81
|
+
{{ group.group }}
|
|
82
|
+
</div>
|
|
83
|
+
<ComboboxOption
|
|
84
|
+
as="template"
|
|
85
|
+
v-for="(option, idx) in group.items.slice(0, 50)"
|
|
86
|
+
:key="option?.value || idx"
|
|
87
|
+
:value="option"
|
|
88
|
+
v-slot="{ active, selected }"
|
|
88
89
|
>
|
|
89
|
-
<
|
|
90
|
-
|
|
91
|
-
|
|
90
|
+
<li
|
|
91
|
+
:class="[
|
|
92
|
+
'flex cursor-pointer items-center justify-between rounded px-2.5 py-1.5 text-base',
|
|
93
|
+
{ 'bg-gray-100': active },
|
|
94
|
+
]"
|
|
95
|
+
>
|
|
96
|
+
<div class="flex flex-1 gap-2 overflow-hidden">
|
|
97
|
+
<div
|
|
98
|
+
v-if="$slots['item-prefix'] || $props.multiple"
|
|
99
|
+
class="flex-shrink-0"
|
|
100
|
+
>
|
|
101
|
+
<slot
|
|
102
|
+
name="item-prefix"
|
|
103
|
+
v-bind="{ active, selected, option }"
|
|
104
|
+
>
|
|
105
|
+
<FeatherIcon
|
|
106
|
+
name="check"
|
|
107
|
+
v-if="isOptionSelected(option)"
|
|
108
|
+
class="h-4 w-4 text-gray-700"
|
|
109
|
+
/>
|
|
110
|
+
<div v-else class="h-4 w-4" />
|
|
111
|
+
</slot>
|
|
112
|
+
</div>
|
|
113
|
+
<span class="flex-1 truncate">
|
|
114
|
+
{{ getLabel(option) }}
|
|
115
|
+
</span>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<div
|
|
119
|
+
v-if="$slots['item-suffix'] || option?.description"
|
|
120
|
+
class="ml-2 flex-shrink-0"
|
|
121
|
+
>
|
|
122
|
+
<slot
|
|
123
|
+
name="item-suffix"
|
|
124
|
+
v-bind="{ active, selected, option }"
|
|
125
|
+
>
|
|
126
|
+
<div
|
|
127
|
+
v-if="option?.description"
|
|
128
|
+
class="text-sm text-gray-600"
|
|
129
|
+
>
|
|
130
|
+
{{ option.description }}
|
|
131
|
+
</div>
|
|
132
|
+
</slot>
|
|
133
|
+
</div>
|
|
134
|
+
</li>
|
|
135
|
+
</ComboboxOption>
|
|
136
|
+
</div>
|
|
137
|
+
<li
|
|
138
|
+
v-if="groups.length == 0"
|
|
139
|
+
class="rounded-md px-2.5 py-1.5 text-base text-gray-600"
|
|
140
|
+
>
|
|
141
|
+
No results found
|
|
142
|
+
</li>
|
|
143
|
+
</ComboboxOptions>
|
|
144
|
+
|
|
145
|
+
<div v-if="$slots.footer || multiple" class="border-t p-1">
|
|
146
|
+
<slot name="footer" v-bind="{ togglePopover }">
|
|
147
|
+
<div v-if="multiple" class="flex items-center justify-end">
|
|
148
|
+
<Button
|
|
149
|
+
v-if="!areAllOptionsSelected"
|
|
150
|
+
label="Select All"
|
|
151
|
+
@click.stop="selectAll"
|
|
92
152
|
/>
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
153
|
+
<Button
|
|
154
|
+
v-if="areAllOptionsSelected"
|
|
155
|
+
label="Clear All"
|
|
156
|
+
@click.stop="clearAll"
|
|
157
|
+
/></div
|
|
158
|
+
></slot>
|
|
96
159
|
</div>
|
|
97
|
-
|
|
98
|
-
v-if="groups.length == 0"
|
|
99
|
-
class="rounded-md px-2.5 py-1.5 text-base text-gray-600"
|
|
100
|
-
>
|
|
101
|
-
No results found
|
|
102
|
-
</li>
|
|
103
|
-
</ComboboxOptions>
|
|
160
|
+
</div>
|
|
104
161
|
</div>
|
|
105
162
|
</template>
|
|
106
163
|
</Popover>
|
|
107
164
|
</Combobox>
|
|
108
165
|
</template>
|
|
166
|
+
|
|
109
167
|
<script>
|
|
110
168
|
import {
|
|
111
169
|
Combobox,
|
|
170
|
+
ComboboxButton,
|
|
112
171
|
ComboboxInput,
|
|
113
|
-
ComboboxOptions,
|
|
114
172
|
ComboboxOption,
|
|
115
|
-
|
|
173
|
+
ComboboxOptions,
|
|
116
174
|
} from '@headlessui/vue'
|
|
175
|
+
import { nextTick } from 'vue'
|
|
117
176
|
import Popover from './Popover.vue'
|
|
118
177
|
import Button from './Button.vue'
|
|
119
178
|
import FeatherIcon from './FeatherIcon.vue'
|
|
120
179
|
|
|
121
180
|
export default {
|
|
122
181
|
name: 'Autocomplete',
|
|
123
|
-
props: [
|
|
182
|
+
props: [
|
|
183
|
+
'modelValue',
|
|
184
|
+
'options',
|
|
185
|
+
'placeholder',
|
|
186
|
+
'bodyClasses',
|
|
187
|
+
'multiple',
|
|
188
|
+
'hideSearch',
|
|
189
|
+
],
|
|
124
190
|
emits: ['update:modelValue', 'update:query', 'change'],
|
|
125
191
|
components: {
|
|
126
192
|
Popover,
|
|
@@ -132,6 +198,7 @@ export default {
|
|
|
132
198
|
ComboboxOption,
|
|
133
199
|
ComboboxButton,
|
|
134
200
|
},
|
|
201
|
+
expose: ['togglePopover'],
|
|
135
202
|
data() {
|
|
136
203
|
return {
|
|
137
204
|
query: '',
|
|
@@ -139,19 +206,25 @@ export default {
|
|
|
139
206
|
}
|
|
140
207
|
},
|
|
141
208
|
computed: {
|
|
142
|
-
valuePropPassed() {
|
|
143
|
-
return 'value' in this.$attrs
|
|
144
|
-
},
|
|
145
209
|
selectedValue: {
|
|
146
210
|
get() {
|
|
147
|
-
|
|
211
|
+
if (!this.multiple) {
|
|
212
|
+
return this.findOption(this.modelValue)
|
|
213
|
+
}
|
|
214
|
+
// in case of `multiple`, modelValue is an array of values
|
|
215
|
+
// if the modelValue is a list of values, convert them to options
|
|
216
|
+
return isOptionOrValue(this.modelValue?.[0]) === 'value'
|
|
217
|
+
? this.modelValue?.map((v) => this.findOption(v))
|
|
218
|
+
: this.modelValue
|
|
148
219
|
},
|
|
149
220
|
set(val) {
|
|
150
221
|
this.query = ''
|
|
151
|
-
if (val)
|
|
152
|
-
|
|
222
|
+
if (val && !this.multiple) this.showOptions = false
|
|
223
|
+
if (!this.multiple) {
|
|
224
|
+
this.$emit('update:modelValue', val)
|
|
225
|
+
return
|
|
153
226
|
}
|
|
154
|
-
this.$emit(
|
|
227
|
+
this.$emit('update:modelValue', val)
|
|
155
228
|
},
|
|
156
229
|
},
|
|
157
230
|
groups() {
|
|
@@ -159,7 +232,7 @@ export default {
|
|
|
159
232
|
|
|
160
233
|
let groups = this.options[0]?.group
|
|
161
234
|
? this.options
|
|
162
|
-
: [{ group: '', items: this.options }]
|
|
235
|
+
: [{ group: '', items: this.sanitizeOptions(this.options) }]
|
|
163
236
|
|
|
164
237
|
return groups
|
|
165
238
|
.map((group, i) => {
|
|
@@ -167,47 +240,89 @@ export default {
|
|
|
167
240
|
key: i,
|
|
168
241
|
group: group.group,
|
|
169
242
|
hideLabel: group.hideLabel || false,
|
|
170
|
-
items: this.filterOptions(group.items),
|
|
243
|
+
items: this.filterOptions(this.sanitizeOptions(group.items)),
|
|
171
244
|
}
|
|
172
245
|
})
|
|
173
246
|
.filter((group) => group.items.length > 0)
|
|
174
247
|
},
|
|
248
|
+
allOptions() {
|
|
249
|
+
return this.groups.flatMap((group) => group.items)
|
|
250
|
+
},
|
|
251
|
+
areAllOptionsSelected() {
|
|
252
|
+
if (!this.multiple) return false
|
|
253
|
+
return this.allOptions.length === this.selectedValue?.length
|
|
254
|
+
},
|
|
175
255
|
},
|
|
176
256
|
watch: {
|
|
177
257
|
query(q) {
|
|
178
258
|
this.$emit('update:query', q)
|
|
179
259
|
},
|
|
180
260
|
showOptions(val) {
|
|
181
|
-
if (val)
|
|
182
|
-
this.$nextTick(() => {
|
|
183
|
-
this.$refs.search.el.focus()
|
|
184
|
-
})
|
|
185
|
-
}
|
|
261
|
+
if (val) nextTick(() => this.$refs.searchInput?.$el?.focus())
|
|
186
262
|
},
|
|
187
263
|
},
|
|
188
264
|
methods: {
|
|
265
|
+
togglePopover(val) {
|
|
266
|
+
this.showOptions = val ?? !this.showOptions
|
|
267
|
+
},
|
|
268
|
+
findOption(option) {
|
|
269
|
+
if (!option) return option
|
|
270
|
+
const value = isOptionOrValue(option) === 'value' ? option : option.value
|
|
271
|
+
return this.allOptions.find((o) => o.value === value)
|
|
272
|
+
},
|
|
189
273
|
filterOptions(options) {
|
|
190
|
-
if (!this.query)
|
|
191
|
-
return options
|
|
192
|
-
}
|
|
274
|
+
if (!this.query) return options
|
|
193
275
|
return options.filter((option) => {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
(
|
|
197
|
-
.toString()
|
|
198
|
-
.toLowerCase()
|
|
199
|
-
.includes(this.query.toLowerCase())
|
|
276
|
+
return (
|
|
277
|
+
option.label.toLowerCase().includes(this.query.toLowerCase()) ||
|
|
278
|
+
option.value.toLowerCase().includes(this.query.toLowerCase())
|
|
200
279
|
)
|
|
201
280
|
})
|
|
202
281
|
},
|
|
203
282
|
displayValue(option) {
|
|
204
|
-
if (
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
return
|
|
283
|
+
if (!option) return ''
|
|
284
|
+
|
|
285
|
+
if (!this.multiple) {
|
|
286
|
+
return this.getLabel(this.findOption(option))
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (!Array.isArray(option)) return ''
|
|
290
|
+
|
|
291
|
+
// in case of `multiple`, option is an array of values
|
|
292
|
+
// so the display value should be comma separated labels
|
|
293
|
+
return option.map((v) => this.getLabel(this.findOption(v))).join(', ')
|
|
294
|
+
},
|
|
295
|
+
getLabel(option) {
|
|
296
|
+
if (isOptionOrValue(option) === 'value') return option
|
|
297
|
+
return option?.label || option?.value || 'No label'
|
|
298
|
+
},
|
|
299
|
+
sanitizeOptions(options) {
|
|
300
|
+
if (!options) return []
|
|
301
|
+
// in case the options are just values, convert them to objects
|
|
302
|
+
return options.map((option) => {
|
|
303
|
+
return isOptionOrValue(option) === 'option'
|
|
304
|
+
? option
|
|
305
|
+
: { label: option, value: option }
|
|
306
|
+
})
|
|
307
|
+
},
|
|
308
|
+
isOptionSelected(option) {
|
|
309
|
+
if (!this.selectedValue) return false
|
|
310
|
+
const value = isOptionOrValue(option) === 'value' ? option : option.value
|
|
311
|
+
if (!this.multiple) {
|
|
312
|
+
return this.selectedValue?.value === value
|
|
208
313
|
}
|
|
209
|
-
return
|
|
314
|
+
return this.selectedValue?.find((v) => v && v.value === value)
|
|
315
|
+
},
|
|
316
|
+
selectAll() {
|
|
317
|
+
this.selectedValue = this.allOptions
|
|
318
|
+
},
|
|
319
|
+
clearAll() {
|
|
320
|
+
this.selectedValue = []
|
|
210
321
|
},
|
|
211
322
|
},
|
|
212
323
|
}
|
|
324
|
+
|
|
325
|
+
function isOptionOrValue(optionOrValue) {
|
|
326
|
+
return typeof optionOrValue === 'object' ? 'option' : 'value'
|
|
327
|
+
}
|
|
213
328
|
</script>
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
<slot name="body-main">
|
|
54
54
|
<div class="bg-white px-4 pb-6 pt-5 sm:px-6">
|
|
55
55
|
<div class="flex">
|
|
56
|
-
<div class="flex-1">
|
|
56
|
+
<div class="w-full flex-1">
|
|
57
57
|
<div class="mb-6 flex items-center justify-between">
|
|
58
58
|
<div class="flex items-center space-x-2">
|
|
59
59
|
<div
|