v-uixy 1.3.1 → 1.4.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 +1 -1
- package/templates/assets/css/main.css +4 -0
- package/templates/components/Avatar/Avatar.component.vue +7 -3
- package/templates/components/Avatar/Avatar.styles.ts +3 -3
- package/templates/components/Button/Button.styles.ts +3 -3
- package/templates/components/Calendar/Calendar.component.vue +26 -15
- package/templates/components/Calendar/Calendar.styles.ts +11 -3
- package/templates/components/Calendar/Calendar.types.ts +1 -0
- package/templates/components/Checkbox/Checkbox.styles.ts +1 -1
- package/templates/components/ColorPicker/ColorPicker.component.vue +4 -4
- package/templates/components/Command/Command.component.vue +6 -6
- package/templates/components/Command/Command.types.ts +1 -0
- package/templates/components/Input/Input.component.vue +27 -27
- package/templates/components/Input/Input.styles.ts +5 -5
- package/templates/components/Input/Input.types.ts +1 -0
- package/templates/components/Modal/Modal.component.vue +28 -4
- package/templates/components/Modal/Modal.types.ts +3 -1
- package/templates/components/Popover/Popover.component.vue +33 -2
- package/templates/components/Popover/Popover.types.ts +1 -0
- package/templates/components/Select/Select.component.vue +253 -108
- package/templates/components/Select/Select.styles.ts +6 -6
- package/templates/components/Select/Select.types.ts +12 -0
- package/templates/components/Select/SelectTreeItem.vue +154 -0
- package/templates/components/Select/composables/useSelect.composable.ts +260 -42
- package/templates/components/Tabs/Tabs.component.vue +11 -2
- package/templates/components/Tabs/Tabs.types.ts +1 -0
- package/templates/components/Textarea/Textarea.component.vue +23 -32
- package/templates/components/Textarea/Textarea.styles.ts +4 -4
- package/templates/components/Textarea/Textarea.types.ts +1 -0
- package/templates/components/TimePicker/TimePicker.component.vue +21 -18
- package/templates/components/TimePicker/TimePicker.styles.ts +1 -1
- package/templates/components/TimePicker/TimePicker.types.ts +1 -0
- package/templates/components.json +1 -1
- package/templates/composables/usePosition.ts +17 -2
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
:class="selectStyles({ status }, $attrs.class as string)"
|
|
6
6
|
:tabIndex="props.disabled ? -1 : 0"
|
|
7
7
|
:auto-focus="props.autoFocus"
|
|
8
|
+
ref="refTrigger"
|
|
8
9
|
@click="handleOpen"
|
|
9
10
|
@keydown="handleKeydownSelect"
|
|
10
11
|
v-bind="filteredAttrs"
|
|
@@ -20,7 +21,7 @@
|
|
|
20
21
|
:class="badgesWrapperStyles({ disabled: props.disabled })"
|
|
21
22
|
>
|
|
22
23
|
<uixy-badge
|
|
23
|
-
v-for="(item, index) in model"
|
|
24
|
+
v-for="(item, index) in model as string[]"
|
|
24
25
|
data-badge
|
|
25
26
|
variant="tertiary"
|
|
26
27
|
size="xs"
|
|
@@ -28,94 +29,135 @@
|
|
|
28
29
|
:key="`${item}--${index}`"
|
|
29
30
|
@click.stop="handleClickSelectedOption(item)"
|
|
30
31
|
>
|
|
31
|
-
{{ item }}
|
|
32
|
+
{{ getLabel(item) }}
|
|
32
33
|
</uixy-badge>
|
|
33
34
|
</div>
|
|
34
|
-
<p v-else>{{ model }}</p>
|
|
35
|
+
<p v-else>{{ getLabel(model as string) }}</p>
|
|
35
36
|
|
|
37
|
+
<uixy-icon
|
|
38
|
+
v-if="props.deselectable && !props.multiple && model"
|
|
39
|
+
name="close"
|
|
40
|
+
class="absolute right-7 top-1/2 h-4 w-4 -translate-y-1/2 cursor-pointer z-10 text-gray-500 dark:text-gray-600 hover:text-black dark:hover:text-gray-400"
|
|
41
|
+
@click.stop="handleDeselect"
|
|
42
|
+
/>
|
|
36
43
|
<uixy-icon name="chevron-down" :class="iconStyles({ open })" />
|
|
37
44
|
</div>
|
|
38
|
-
|
|
45
|
+
|
|
46
|
+
<teleport to="body">
|
|
47
|
+
<animate-presence>
|
|
48
|
+
<motion.div
|
|
49
|
+
v-if="open && !props.disabled"
|
|
50
|
+
class="scrollbar z-50 max-h-60 overflow-y-auto rounded-1 border border-gray-300 bg-white p-1 text-sm shadow-sm dark:border-gray-900 dark:bg-gray-1000"
|
|
51
|
+
:style="dropdownStyles"
|
|
52
|
+
:initial="{ opacity: 0, y: -4, scale: 0.98 }"
|
|
53
|
+
:animate="{ opacity: 1, y: 0, scale: 1 }"
|
|
54
|
+
:exit="{ opacity: 0, scale: 0.98 }"
|
|
55
|
+
:transition="{ duration: 0.12, ease: 'easeInOut' }"
|
|
56
|
+
>
|
|
57
|
+
<div ref="refDropdownEl" select-dropdown>
|
|
58
|
+
<div v-if="props.search" class="mb-2" @click.stop>
|
|
59
|
+
<uixy-input
|
|
60
|
+
ref="refSearchInput"
|
|
61
|
+
v-model="search"
|
|
62
|
+
status="default"
|
|
63
|
+
icon="search"
|
|
64
|
+
iconPositon="right"
|
|
65
|
+
placeholder="Search..."
|
|
66
|
+
hide-helper
|
|
67
|
+
/>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<ul v-if="props.tree" class="flex flex-col gap-1 py-1">
|
|
71
|
+
<template
|
|
72
|
+
v-for="option in filteredOptions"
|
|
73
|
+
:key="option.value ?? option.label"
|
|
74
|
+
>
|
|
75
|
+
<SelectTreeItem
|
|
76
|
+
:option="option"
|
|
77
|
+
:depth="0"
|
|
78
|
+
:is-selected="isSelected"
|
|
79
|
+
:is-partially-selected="isPartiallySelected"
|
|
80
|
+
:expanded-items="effectiveExpandedItems"
|
|
81
|
+
:leaf-only="props.leafOnly"
|
|
82
|
+
:multiple="props.multiple"
|
|
83
|
+
:search-term="search"
|
|
84
|
+
:original-options="props.options"
|
|
85
|
+
@toggle-expand="toggleExpand"
|
|
86
|
+
@select="handleClick"
|
|
87
|
+
/>
|
|
88
|
+
</template>
|
|
89
|
+
|
|
90
|
+
<li
|
|
91
|
+
v-if="filteredOptions.length === 0"
|
|
92
|
+
class="px-3 text-center dark:text-gray-600"
|
|
93
|
+
>
|
|
94
|
+
No results found
|
|
95
|
+
</li>
|
|
96
|
+
</ul>
|
|
97
|
+
|
|
98
|
+
<ul v-else class="flex flex-col gap-1 py-1">
|
|
99
|
+
<li
|
|
100
|
+
v-for="option in filteredOptions"
|
|
101
|
+
:tabIndex="option.disabled ? -1 : 0"
|
|
102
|
+
:key="option.value ?? option.label"
|
|
103
|
+
:class="
|
|
104
|
+
itemStyles({
|
|
105
|
+
selected: isSelected(option),
|
|
106
|
+
disabled: !!option.disabled,
|
|
107
|
+
})
|
|
108
|
+
"
|
|
109
|
+
@keydown="handleKeydownOption($event, option)"
|
|
110
|
+
@click="handleClick(option.value ?? option.label)"
|
|
111
|
+
>
|
|
112
|
+
{{ option.label }}
|
|
113
|
+
<uixy-icon
|
|
114
|
+
v-if="isSelected(option)"
|
|
115
|
+
name="check"
|
|
116
|
+
class="absolute right-2 top-2 size-4"
|
|
117
|
+
/>
|
|
118
|
+
</li>
|
|
119
|
+
|
|
120
|
+
<li
|
|
121
|
+
v-if="customAddVisible"
|
|
122
|
+
class="relative py-2 px-3 rounded-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-900 text-gray-500 dark:text-gray-600"
|
|
123
|
+
@click="handleCustomAdd"
|
|
124
|
+
>
|
|
125
|
+
Add: "{{ search }}"
|
|
126
|
+
</li>
|
|
127
|
+
|
|
128
|
+
<li
|
|
129
|
+
v-if="filteredOptions.length === 0 && !customAddVisible"
|
|
130
|
+
class="px-3 text-center dark:text-gray-600"
|
|
131
|
+
>
|
|
132
|
+
No results found
|
|
133
|
+
</li>
|
|
134
|
+
</ul>
|
|
135
|
+
</div>
|
|
136
|
+
</motion.div>
|
|
137
|
+
</animate-presence>
|
|
138
|
+
</teleport>
|
|
139
|
+
<div v-if="props.label" :class="labelStyles({ status })">
|
|
140
|
+
{{ props.label }}
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
<div v-if="!props.hideHelper" class="h-4">
|
|
144
|
+
<animate-presence mode="wait" :initial="false">
|
|
39
145
|
<motion.div
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
:
|
|
43
|
-
:
|
|
44
|
-
:
|
|
45
|
-
:transition="{ duration: 0.15, ease: 'easeInOut' }"
|
|
46
|
-
select-dropdown
|
|
146
|
+
:initial="{ opacity: 0, y: '-4px' }"
|
|
147
|
+
:animate="{ opacity: 1, y: 0 }"
|
|
148
|
+
:exit="{ opacity: 0, y: '-4px' }"
|
|
149
|
+
:transition="{ duration: 0.125 }"
|
|
150
|
+
:key="status"
|
|
47
151
|
>
|
|
48
|
-
<
|
|
49
|
-
<uixy-input
|
|
50
|
-
v-model="search"
|
|
51
|
-
status="default"
|
|
52
|
-
icon="search"
|
|
53
|
-
iconPositon="right"
|
|
54
|
-
placeholder="Search..."
|
|
55
|
-
auto-focus
|
|
56
|
-
/>
|
|
57
|
-
</div>
|
|
58
|
-
<ul class="flex flex-col gap-1 py-1">
|
|
59
|
-
<li
|
|
60
|
-
v-for="option in filteredOptions"
|
|
61
|
-
:tabIndex="option.disabled ? -1 : 0"
|
|
62
|
-
:key="option.label"
|
|
63
|
-
:class="
|
|
64
|
-
itemStyles({
|
|
65
|
-
selected: isSelected(option),
|
|
66
|
-
disabled: !!option.disabled,
|
|
67
|
-
})
|
|
68
|
-
"
|
|
69
|
-
@keydown="handleKeydownOption($event, option)"
|
|
70
|
-
@click="handleClick(option.label)"
|
|
71
|
-
>
|
|
72
|
-
{{ option.label }}
|
|
73
|
-
<uixy-icon
|
|
74
|
-
v-if="isSelected(option)"
|
|
75
|
-
name="check"
|
|
76
|
-
class="absolute right-2 top-2 size-4"
|
|
77
|
-
/>
|
|
78
|
-
</li>
|
|
79
|
-
<li
|
|
80
|
-
v-if="filteredOptions.length === 0"
|
|
81
|
-
class="px-3 text-center dark:text-gray-600"
|
|
82
|
-
>
|
|
83
|
-
No results found
|
|
84
|
-
</li>
|
|
85
|
-
</ul>
|
|
152
|
+
<p :class="helperStyles({ status })">{{ text }}</p>
|
|
86
153
|
</motion.div>
|
|
87
154
|
</animate-presence>
|
|
88
|
-
<div v-if="props.label" :class="labelStyles({ status })">
|
|
89
|
-
{{ props.label }}
|
|
90
|
-
</div>
|
|
91
155
|
</div>
|
|
92
|
-
<animate-presence mode="wait" :initial="false">
|
|
93
|
-
<motion.div
|
|
94
|
-
v-if="!!text"
|
|
95
|
-
class="overflow-hidden"
|
|
96
|
-
:initial="{ height: 0 }"
|
|
97
|
-
:animate="{ height: 'auto' }"
|
|
98
|
-
:exit="{ height: 0 }"
|
|
99
|
-
:transition="{ duration: 0.1, ease: 'easeInOut' }"
|
|
100
|
-
>
|
|
101
|
-
<animate-presence mode="wait" :initial="false">
|
|
102
|
-
<motion.p
|
|
103
|
-
:key="status + text"
|
|
104
|
-
:class="helperStyles({ status })"
|
|
105
|
-
:initial="{ opacity: 0, y: -4 }"
|
|
106
|
-
:animate="{ opacity: 1, y: 0 }"
|
|
107
|
-
:exit="{ opacity: 0, y: 4 }"
|
|
108
|
-
:transition="{ duration: 0.12, ease: 'easeInOut', delay: 0.1 }"
|
|
109
|
-
>
|
|
110
|
-
{{ text }}
|
|
111
|
-
</motion.p>
|
|
112
|
-
</animate-presence>
|
|
113
|
-
</motion.div>
|
|
114
|
-
</animate-presence>
|
|
115
156
|
</div>
|
|
116
157
|
</template>
|
|
117
158
|
|
|
118
|
-
<script setup lang="ts" generic="T extends
|
|
159
|
+
<script setup lang="ts" generic="T extends string | string[]">
|
|
160
|
+
import { nextTick, onWatcherCleanup, onMounted } from "vue";
|
|
119
161
|
import {
|
|
120
162
|
selectStyles,
|
|
121
163
|
iconStyles,
|
|
@@ -124,16 +166,13 @@
|
|
|
124
166
|
helperStyles,
|
|
125
167
|
badgesWrapperStyles,
|
|
126
168
|
} from "./Select.styles";
|
|
127
|
-
import type {
|
|
128
|
-
UixySelectEmits,
|
|
129
|
-
UixySelectOptionType,
|
|
130
|
-
UixySelectProps,
|
|
131
|
-
} from "./Select.types";
|
|
169
|
+
import type { UixySelectEmits, UixySelectProps } from "./Select.types";
|
|
132
170
|
import { UixyBadge } from "../Badge";
|
|
133
171
|
import { UixyIcon } from "../Icon";
|
|
134
172
|
import { UixyInput } from "../Input";
|
|
135
173
|
import { motion, AnimatePresence } from "motion-v";
|
|
136
174
|
import { useSelect } from "./composables";
|
|
175
|
+
import SelectTreeItem from "./SelectTreeItem.vue";
|
|
137
176
|
|
|
138
177
|
const props = defineProps<UixySelectProps>();
|
|
139
178
|
|
|
@@ -153,49 +192,155 @@
|
|
|
153
192
|
return rest;
|
|
154
193
|
});
|
|
155
194
|
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
195
|
+
const refTrigger = ref<HTMLElement>();
|
|
196
|
+
const refDropdownEl = ref<HTMLElement>();
|
|
197
|
+
const refSearchInput = ref<{ focus: () => void } | null>(null);
|
|
159
198
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
(model.value as string[]) = [...prevArray.slice(1), v];
|
|
167
|
-
|
|
168
|
-
(model.value as string[]) = [...prevArray, v];
|
|
169
|
-
}
|
|
170
|
-
} else {
|
|
171
|
-
(model.value as string) = v;
|
|
172
|
-
}
|
|
173
|
-
};
|
|
199
|
+
const dropdownStyles = ref<Record<string, string>>({
|
|
200
|
+
position: "absolute",
|
|
201
|
+
top: "0px",
|
|
202
|
+
left: "0px",
|
|
203
|
+
width: "0px",
|
|
204
|
+
});
|
|
174
205
|
|
|
175
206
|
const {
|
|
176
207
|
refOptions,
|
|
177
208
|
open,
|
|
178
209
|
search,
|
|
179
210
|
filteredOptions,
|
|
211
|
+
effectiveExpandedItems,
|
|
212
|
+
customAddVisible,
|
|
213
|
+
isSelected,
|
|
214
|
+
isPartiallySelected,
|
|
215
|
+
getLabel,
|
|
216
|
+
toggleExpand,
|
|
180
217
|
handleOpen,
|
|
181
218
|
handleKeydownSelect,
|
|
182
219
|
handleKeydownOption,
|
|
183
220
|
handleClickSelectedOption,
|
|
184
|
-
|
|
221
|
+
handleClick,
|
|
222
|
+
handleDeselect,
|
|
223
|
+
handleCustomAdd,
|
|
224
|
+
} = useSelect<T>(props, model as Ref<T>, emits, refDropdownEl);
|
|
225
|
+
|
|
226
|
+
const updateDropdownPosition = async () => {
|
|
227
|
+
if (!open.value || props.disabled) return;
|
|
228
|
+
|
|
229
|
+
await nextTick();
|
|
230
|
+
await new Promise<void>((r) => requestAnimationFrame(() => r()));
|
|
231
|
+
|
|
232
|
+
const trigger = refTrigger.value;
|
|
233
|
+
const dropdown = refDropdownEl.value;
|
|
234
|
+
if (!trigger || !dropdown) return;
|
|
235
|
+
|
|
236
|
+
const triggerRect = trigger.getBoundingClientRect();
|
|
237
|
+
const gap = 4;
|
|
238
|
+
const pad = 8;
|
|
239
|
+
const width = Math.max(80, triggerRect.width);
|
|
185
240
|
|
|
186
|
-
|
|
187
|
-
|
|
241
|
+
const scrollY = window.scrollY;
|
|
242
|
+
const scrollX = window.scrollX;
|
|
188
243
|
|
|
189
|
-
|
|
244
|
+
const cs = window.getComputedStyle(dropdown);
|
|
245
|
+
const maxH = Number.parseFloat(cs.maxHeight || "") || 240;
|
|
246
|
+
const desiredH = Math.min(dropdown.scrollHeight || 0, maxH);
|
|
247
|
+
|
|
248
|
+
const availableBelow = window.innerHeight - triggerRect.bottom - gap - pad;
|
|
249
|
+
const availableAbove = triggerRect.top - gap - pad;
|
|
250
|
+
|
|
251
|
+
const placeBelow =
|
|
252
|
+
desiredH <= availableBelow || availableBelow >= availableAbove;
|
|
253
|
+
|
|
254
|
+
let top =
|
|
255
|
+
(placeBelow
|
|
256
|
+
? triggerRect.bottom + gap
|
|
257
|
+
: triggerRect.top - gap - desiredH) + scrollY;
|
|
258
|
+
|
|
259
|
+
const minTop = scrollY + pad;
|
|
260
|
+
const maxTop = scrollY + window.innerHeight - desiredH - pad;
|
|
261
|
+
top = Math.min(Math.max(minTop, top), Math.max(minTop, maxTop));
|
|
262
|
+
|
|
263
|
+
let left = triggerRect.left + scrollX;
|
|
264
|
+
const minLeft = scrollX + pad;
|
|
265
|
+
const maxLeft = scrollX + window.innerWidth - width - pad;
|
|
266
|
+
left = Math.min(Math.max(minLeft, left), Math.max(minLeft, maxLeft));
|
|
267
|
+
|
|
268
|
+
dropdownStyles.value = {
|
|
269
|
+
position: "absolute",
|
|
270
|
+
top: `${Math.round(top)}px`,
|
|
271
|
+
left: `${Math.round(left)}px`,
|
|
272
|
+
width: `${Math.round(width)}px`,
|
|
273
|
+
};
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const onResize = () => {
|
|
277
|
+
void updateDropdownPosition();
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const onAnyScroll = () => {
|
|
281
|
+
void updateDropdownPosition();
|
|
190
282
|
};
|
|
191
283
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
284
|
+
watch(
|
|
285
|
+
() => open.value,
|
|
286
|
+
(isOpen) => {
|
|
287
|
+
emits("openChange", isOpen);
|
|
288
|
+
|
|
289
|
+
if (!isOpen) return;
|
|
290
|
+
|
|
291
|
+
void updateDropdownPosition();
|
|
292
|
+
|
|
293
|
+
if (props.search) {
|
|
294
|
+
nextTick(() => refSearchInput.value?.focus());
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
window.addEventListener("resize", onResize);
|
|
298
|
+
if (props.followOnScroll) {
|
|
299
|
+
window.addEventListener("scroll", onAnyScroll, true);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
onWatcherCleanup(() => {
|
|
303
|
+
window.removeEventListener("resize", onResize);
|
|
304
|
+
window.removeEventListener("scroll", onAnyScroll, true);
|
|
305
|
+
});
|
|
306
|
+
},
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
onMounted(() => {
|
|
310
|
+
if (props.autoOpen && !props.disabled) {
|
|
311
|
+
const delayMs = (props.delay ?? 0) * 1000;
|
|
312
|
+
setTimeout(() => {
|
|
313
|
+
nextTick(() => {
|
|
314
|
+
open.value = true;
|
|
315
|
+
});
|
|
316
|
+
}, delayMs);
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
watch(search, () => {
|
|
321
|
+
if (!open.value) return;
|
|
322
|
+
void updateDropdownPosition();
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
watch(
|
|
326
|
+
() => filteredOptions.value.length,
|
|
327
|
+
() => {
|
|
328
|
+
if (!open.value) return;
|
|
329
|
+
void updateDropdownPosition();
|
|
330
|
+
},
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
watch(
|
|
334
|
+
() => model.value,
|
|
335
|
+
() => {
|
|
336
|
+
if (!open.value) return;
|
|
337
|
+
void updateDropdownPosition();
|
|
338
|
+
},
|
|
339
|
+
{ deep: true },
|
|
340
|
+
);
|
|
196
341
|
|
|
197
342
|
const status = computed(() =>
|
|
198
|
-
props.disabled ? "disabled" : props.status ?? "default"
|
|
343
|
+
props.disabled ? "disabled" : (props.status ?? "default"),
|
|
199
344
|
);
|
|
200
345
|
|
|
201
346
|
const text = computed(
|
|
@@ -205,6 +350,6 @@
|
|
|
205
350
|
default: props.helperText,
|
|
206
351
|
disabled: "",
|
|
207
352
|
valid: "",
|
|
208
|
-
}[props.status ?? "default"]
|
|
353
|
+
})[props.status ?? "default"],
|
|
209
354
|
);
|
|
210
355
|
</script>
|
|
@@ -13,7 +13,7 @@ export const selectStyles = styles(
|
|
|
13
13
|
valid:
|
|
14
14
|
"bg-white dark:bg-gray-1000 focus-visible:border-gray-500 dark:focus-visible:border-gray-600 cursor-pointer",
|
|
15
15
|
},
|
|
16
|
-
}
|
|
16
|
+
},
|
|
17
17
|
);
|
|
18
18
|
|
|
19
19
|
export const iconStyles = styles(
|
|
@@ -23,11 +23,11 @@ export const iconStyles = styles(
|
|
|
23
23
|
true: "rotate-180",
|
|
24
24
|
false: "",
|
|
25
25
|
},
|
|
26
|
-
}
|
|
26
|
+
},
|
|
27
27
|
);
|
|
28
28
|
|
|
29
29
|
export const labelStyles = styles(
|
|
30
|
-
"transition-colors ease-in-out duration-150 text-
|
|
30
|
+
"transition-colors ease-in-out duration-150 text-sm font-500",
|
|
31
31
|
{
|
|
32
32
|
status: {
|
|
33
33
|
default: "",
|
|
@@ -36,7 +36,7 @@ export const labelStyles = styles(
|
|
|
36
36
|
"text-gray-500 dark:text-gray-700 pointer-events-none select-none",
|
|
37
37
|
valid: "",
|
|
38
38
|
},
|
|
39
|
-
}
|
|
39
|
+
},
|
|
40
40
|
);
|
|
41
41
|
|
|
42
42
|
export const itemStyles = styles(
|
|
@@ -50,7 +50,7 @@ export const itemStyles = styles(
|
|
|
50
50
|
true: "pointer-events-none text-gray-500 dark:text-gray-700",
|
|
51
51
|
false: "",
|
|
52
52
|
},
|
|
53
|
-
}
|
|
53
|
+
},
|
|
54
54
|
);
|
|
55
55
|
|
|
56
56
|
export const helperStyles = styles(
|
|
@@ -63,7 +63,7 @@ export const helperStyles = styles(
|
|
|
63
63
|
"text-gray-400 dark:text-gray-800 pointer-events-none select-none",
|
|
64
64
|
valid: "text-gray-600",
|
|
65
65
|
},
|
|
66
|
-
}
|
|
66
|
+
},
|
|
67
67
|
);
|
|
68
68
|
|
|
69
69
|
export const badgesWrapperStyles = styles("flex flex-wrap gap-1", {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export interface UixySelectOptionType {
|
|
2
2
|
label: string;
|
|
3
|
+
value?: string;
|
|
3
4
|
disabled?: boolean;
|
|
5
|
+
children?: UixySelectOptionType[];
|
|
4
6
|
}
|
|
5
7
|
|
|
6
8
|
export interface UixyMultipleSelectProps {
|
|
@@ -13,6 +15,7 @@ export interface UixySingleSelectProps {
|
|
|
13
15
|
|
|
14
16
|
export interface UixySelectEmits {
|
|
15
17
|
(event: "change", v: string): void;
|
|
18
|
+
(event: "openChange", isOpen: boolean): void;
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
export type UixySelectProps = {
|
|
@@ -27,4 +30,13 @@ export type UixySelectProps = {
|
|
|
27
30
|
errorText?: string;
|
|
28
31
|
autoFocus?: boolean;
|
|
29
32
|
max?: number;
|
|
33
|
+
tree?: boolean;
|
|
34
|
+
leafOnly?: boolean;
|
|
35
|
+
autoOpen?: boolean;
|
|
36
|
+
delay?: number;
|
|
37
|
+
hideHelper?: boolean;
|
|
38
|
+
deselectable?: boolean;
|
|
39
|
+
allowCustom?: boolean;
|
|
40
|
+
clearSearchOnSelect?: boolean;
|
|
41
|
+
followOnScroll?: boolean;
|
|
30
42
|
} & (UixyMultipleSelectProps | UixySingleSelectProps);
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<li class="flex flex-col">
|
|
3
|
+
<div
|
|
4
|
+
:tabIndex="option.disabled ? -1 : 0"
|
|
5
|
+
:class="
|
|
6
|
+
itemStyles({
|
|
7
|
+
selected: isSelected(option),
|
|
8
|
+
disabled: !!option.disabled,
|
|
9
|
+
})
|
|
10
|
+
"
|
|
11
|
+
:style="{
|
|
12
|
+
paddingLeft: `${depth * 28 + 12}px`,
|
|
13
|
+
cursor: option.disabled ? 'not-allowed' : 'pointer',
|
|
14
|
+
}"
|
|
15
|
+
@click.stop="handleItemClick"
|
|
16
|
+
>
|
|
17
|
+
<div class="flex items-center gap-2 flex-1 min-h-6">
|
|
18
|
+
<div
|
|
19
|
+
v-if="hasChildren"
|
|
20
|
+
class="shrink-0 size-6 flex items-center justify-center -ml-1 hover:bg-gray-200 dark:hover:bg-gray-800 rounded transition cursor-pointer"
|
|
21
|
+
@click.stop="$emit('toggle-expand', getValue(option))"
|
|
22
|
+
>
|
|
23
|
+
<uixy-icon
|
|
24
|
+
name="chevron-right"
|
|
25
|
+
class="size-4.5 transition-transform"
|
|
26
|
+
:class="{ 'rotate-90': isExpanded }"
|
|
27
|
+
/>
|
|
28
|
+
</div>
|
|
29
|
+
<span
|
|
30
|
+
class="truncate"
|
|
31
|
+
:class="{
|
|
32
|
+
'text-gray-400 dark:text-gray-600':
|
|
33
|
+
isNotSelectable && !option.disabled,
|
|
34
|
+
}"
|
|
35
|
+
>{{ option.label }}</span
|
|
36
|
+
>
|
|
37
|
+
</div>
|
|
38
|
+
<uixy-icon
|
|
39
|
+
v-if="isSelected(option)"
|
|
40
|
+
name="check"
|
|
41
|
+
class="absolute right-2 top-1/2 -translate-y-1/2 size-4"
|
|
42
|
+
/>
|
|
43
|
+
<div
|
|
44
|
+
v-else-if="isPartiallySelected?.(option)"
|
|
45
|
+
class="absolute right-2 top-1/2 -translate-y-1/2 size-4 flex items-center justify-center"
|
|
46
|
+
>
|
|
47
|
+
<div class="h-0.5 w-2.5 rounded bg-current"></div>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
<animate-presence>
|
|
52
|
+
<motion.ul
|
|
53
|
+
v-if="hasChildren && isExpanded"
|
|
54
|
+
class="flex flex-col gap-1 pt-1 overflow-hidden"
|
|
55
|
+
:initial="{ opacity: 0, height: 0 }"
|
|
56
|
+
:animate="{ opacity: 1, height: 'auto' }"
|
|
57
|
+
:exit="{ opacity: 0, height: 0 }"
|
|
58
|
+
:transition="{ duration: 0.15 }"
|
|
59
|
+
>
|
|
60
|
+
<SelectTreeItem
|
|
61
|
+
v-for="child in option.children"
|
|
62
|
+
:key="child.value ?? child.label"
|
|
63
|
+
:option="child"
|
|
64
|
+
:depth="depth + 1"
|
|
65
|
+
:is-selected="isSelected"
|
|
66
|
+
:is-partially-selected="isPartiallySelected"
|
|
67
|
+
:expanded-items="expandedItems"
|
|
68
|
+
:leaf-only="leafOnly"
|
|
69
|
+
:multiple="multiple"
|
|
70
|
+
:search-term="searchTerm"
|
|
71
|
+
:original-options="originalOptions"
|
|
72
|
+
@toggle-expand="$emit('toggle-expand', $event)"
|
|
73
|
+
@select="$emit('select', $event)"
|
|
74
|
+
/>
|
|
75
|
+
</motion.ul>
|
|
76
|
+
</animate-presence>
|
|
77
|
+
</li>
|
|
78
|
+
</template>
|
|
79
|
+
|
|
80
|
+
<script setup lang="ts">
|
|
81
|
+
import { motion, AnimatePresence } from "motion-v";
|
|
82
|
+
import { itemStyles } from "./Select.styles";
|
|
83
|
+
import type { UixySelectOptionType } from "./Select.types";
|
|
84
|
+
import { UixyIcon } from "../Icon";
|
|
85
|
+
|
|
86
|
+
const props = defineProps<{
|
|
87
|
+
option: UixySelectOptionType;
|
|
88
|
+
depth: number;
|
|
89
|
+
isSelected: (option: UixySelectOptionType) => boolean;
|
|
90
|
+
isPartiallySelected?: (option: UixySelectOptionType) => boolean;
|
|
91
|
+
expandedItems: string[];
|
|
92
|
+
leafOnly?: boolean;
|
|
93
|
+
multiple?: boolean;
|
|
94
|
+
searchTerm?: string;
|
|
95
|
+
originalOptions?: UixySelectOptionType[];
|
|
96
|
+
}>();
|
|
97
|
+
|
|
98
|
+
const emit = defineEmits<{
|
|
99
|
+
(e: "toggle-expand", value: string): void;
|
|
100
|
+
(e: "select", value: string): void;
|
|
101
|
+
}>();
|
|
102
|
+
|
|
103
|
+
const getValue = (option: UixySelectOptionType) =>
|
|
104
|
+
option.value ?? option.label;
|
|
105
|
+
|
|
106
|
+
const hasChildren = computed(
|
|
107
|
+
() => props.option.children && props.option.children.length > 0,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const hasChildrenInOriginal = computed(() => {
|
|
111
|
+
if (!props.originalOptions) return hasChildren.value;
|
|
112
|
+
|
|
113
|
+
const findInTree = (
|
|
114
|
+
options: UixySelectOptionType[],
|
|
115
|
+
value: string,
|
|
116
|
+
): UixySelectOptionType | null => {
|
|
117
|
+
for (const opt of options) {
|
|
118
|
+
if ((opt.value ?? opt.label) === value) return opt;
|
|
119
|
+
if (opt.children && opt.children.length > 0) {
|
|
120
|
+
const found = findInTree(opt.children, value);
|
|
121
|
+
if (found) return found;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const originalOption = findInTree(
|
|
128
|
+
props.originalOptions,
|
|
129
|
+
getValue(props.option),
|
|
130
|
+
);
|
|
131
|
+
return originalOption?.children && originalOption.children.length > 0;
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const isExpanded = computed(() =>
|
|
135
|
+
props.expandedItems.includes(getValue(props.option)),
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const isNotSelectable = computed(
|
|
139
|
+
() =>
|
|
140
|
+
props.option.disabled ||
|
|
141
|
+
(props.leafOnly && hasChildrenInOriginal.value && !props.multiple),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const handleItemClick = () => {
|
|
145
|
+
if (props.option.disabled) return;
|
|
146
|
+
|
|
147
|
+
if (props.leafOnly && hasChildrenInOriginal.value && !props.multiple) {
|
|
148
|
+
emit("toggle-expand", getValue(props.option));
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
emit("select", getValue(props.option));
|
|
153
|
+
};
|
|
154
|
+
</script>
|