ketekny-ui-kit 1.0.138 → 1.0.140
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/src/ui/kSelect.vue +97 -6
package/package.json
CHANGED
package/src/ui/kSelect.vue
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
{{ label }}
|
|
5
5
|
</label>
|
|
6
6
|
|
|
7
|
-
<SelectRoot v-model="internalValue" v-model:open="open" :disabled="disabled">
|
|
7
|
+
<SelectRoot v-model="internalValue" v-model:open="open" :disabled="disabled" :multiple="multiple">
|
|
8
8
|
<SelectTrigger as-child>
|
|
9
9
|
<div
|
|
10
10
|
:id="computedId"
|
|
@@ -13,9 +13,36 @@
|
|
|
13
13
|
:aria-label="label || 'Select option'"
|
|
14
14
|
>
|
|
15
15
|
<SelectValue
|
|
16
|
+
v-if="!multiple"
|
|
16
17
|
:placeholder="placeholder"
|
|
17
18
|
class="block pr-10 truncate text-gray-700 data-[placeholder]:text-gray-400 dark:text-slate-100 dark:data-[placeholder]:text-slate-500"
|
|
18
19
|
/>
|
|
20
|
+
<div v-else ref="selectedContainer" class="flex items-center gap-0.5 pr-10 overflow-hidden whitespace-nowrap">
|
|
21
|
+
<span v-if="selectedOptions.length === 0" class="text-gray-400 dark:text-slate-500">
|
|
22
|
+
{{ placeholder }}
|
|
23
|
+
</span>
|
|
24
|
+
<kChip
|
|
25
|
+
v-for="option in visibleSelectedOptions"
|
|
26
|
+
:key="option[optionValue]"
|
|
27
|
+
class="shrink-0"
|
|
28
|
+
size="small"
|
|
29
|
+
>
|
|
30
|
+
<span>{{ option[optionLabel] }}</span>
|
|
31
|
+
<button
|
|
32
|
+
v-if="!disabled"
|
|
33
|
+
type="button"
|
|
34
|
+
class="ml-1 text-primary/70 transition-colors hover:text-primary"
|
|
35
|
+
:aria-label="`Remove ${option[optionLabel]}`"
|
|
36
|
+
@pointerdown.stop.prevent
|
|
37
|
+
@click.stop.prevent="removeSelection(option[optionValue])"
|
|
38
|
+
>
|
|
39
|
+
<X class="w-3 h-3" />
|
|
40
|
+
</button>
|
|
41
|
+
</kChip>
|
|
42
|
+
<span v-if="hiddenSelectedCount > 0" class="text-xs text-gray-500 shrink-0 dark:text-slate-400">
|
|
43
|
+
+{{ hiddenSelectedCount }}...
|
|
44
|
+
</span>
|
|
45
|
+
</div>
|
|
19
46
|
|
|
20
47
|
<button
|
|
21
48
|
v-if="clearable && isSelectionSet && !disabled"
|
|
@@ -89,6 +116,14 @@
|
|
|
89
116
|
</SelectPortal>
|
|
90
117
|
</SelectRoot>
|
|
91
118
|
|
|
119
|
+
<div ref="chipMeasureContainer" class="fixed invisible flex items-center gap-0.5 pointer-events-none" aria-hidden="true">
|
|
120
|
+
<kChip v-for="option in selectedOptions" :key="option[optionValue]" size="small">
|
|
121
|
+
<span>{{ option[optionLabel] }}</span>
|
|
122
|
+
<X class="w-3 h-3 ml-1" />
|
|
123
|
+
</kChip>
|
|
124
|
+
<span ref="overflowLabelMeasure" class="text-xs">+{{ selectedOptions.length }}...</span>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
92
127
|
<div class="mt-1 text-sm text-red-500 dark:text-rose-400" v-if="hasError && error !== true && error !== ''">
|
|
93
128
|
{{ error }}
|
|
94
129
|
</div>
|
|
@@ -100,7 +135,7 @@
|
|
|
100
135
|
</template>
|
|
101
136
|
|
|
102
137
|
<script setup>
|
|
103
|
-
import { computed, nextTick, ref, watch } from "vue";
|
|
138
|
+
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
|
104
139
|
import {
|
|
105
140
|
SelectContent,
|
|
106
141
|
SelectItem,
|
|
@@ -113,10 +148,12 @@ import {
|
|
|
113
148
|
SelectViewport,
|
|
114
149
|
} from "reka-ui";
|
|
115
150
|
import { X, ChevronDown, Check, Search, SearchX } from "@lucide/vue";
|
|
151
|
+
import kChip from "./kChip.vue";
|
|
116
152
|
|
|
117
153
|
const props = defineProps({
|
|
118
154
|
options: { type: Array, required: true },
|
|
119
|
-
modelValue: [String, Number],
|
|
155
|
+
modelValue: [String, Number, Array],
|
|
156
|
+
multiple: { type: Boolean, default: false },
|
|
120
157
|
optionValue: { type: String, default: "value" },
|
|
121
158
|
optionLabel: { type: String, default: "label" },
|
|
122
159
|
label: String,
|
|
@@ -136,6 +173,11 @@ const open = ref(false);
|
|
|
136
173
|
const searchQuery = ref("");
|
|
137
174
|
const searchInput = ref(null);
|
|
138
175
|
const viewportRef = ref(null);
|
|
176
|
+
const selectedContainer = ref(null);
|
|
177
|
+
const chipMeasureContainer = ref(null);
|
|
178
|
+
const overflowLabelMeasure = ref(null);
|
|
179
|
+
const visibleSelectedCount = ref(0);
|
|
180
|
+
let selectedContainerObserver;
|
|
139
181
|
const generatedId = `select-${Math.random().toString(36).substr(2, 9)}`;
|
|
140
182
|
const defaultStyle = "w-full px-3 py-2 border rounded-lg transition shadow-sm focus-visible:outline-none text-gray-700 focus-visible:ring-2 focus-visible:ring-primary/20 focus-visible:border-primary bg-white placeholder-gray-400 dark:bg-slate-800 dark:text-slate-100 dark:border-slate-600 dark:placeholder-slate-500";
|
|
141
183
|
const errorStyle = "border-red-500 focus-visible:ring focus-visible:ring-red-300 dark:border-rose-500 dark:focus-visible:ring-rose-500/20";
|
|
@@ -177,15 +219,59 @@ function normalizeDropdownHeight(value) {
|
|
|
177
219
|
|
|
178
220
|
const computedId = computed(() => (props.id != null && props.id !== "" ? props.id : generatedId));
|
|
179
221
|
const hasError = computed(() => props.error != null && props.error !== false);
|
|
180
|
-
const isSelectionSet = computed(() =>
|
|
222
|
+
const isSelectionSet = computed(() => {
|
|
223
|
+
if (props.multiple) return Array.isArray(props.modelValue) && props.modelValue.length > 0;
|
|
224
|
+
return props.modelValue !== null && props.modelValue !== undefined && props.modelValue !== "";
|
|
225
|
+
});
|
|
181
226
|
const resolvedDropdownHeight = computed(() => normalizeDropdownHeight(props.dropdownHeight));
|
|
227
|
+
const selectedOptions = computed(() => {
|
|
228
|
+
if (!props.multiple || !Array.isArray(props.modelValue)) return [];
|
|
229
|
+
return props.modelValue
|
|
230
|
+
.map((value) => props.options.find((option) => option?.[props.optionValue] === value))
|
|
231
|
+
.filter(Boolean);
|
|
232
|
+
});
|
|
233
|
+
const visibleSelectedOptions = computed(() => selectedOptions.value.slice(0, visibleSelectedCount.value));
|
|
234
|
+
const hiddenSelectedCount = computed(() => selectedOptions.value.length - visibleSelectedOptions.value.length);
|
|
235
|
+
|
|
236
|
+
function updateVisibleSelectedCount() {
|
|
237
|
+
if (!props.multiple || !selectedContainer.value || !chipMeasureContainer.value) return;
|
|
238
|
+
|
|
239
|
+
const chipElements = Array.from(chipMeasureContainer.value.children).slice(0, selectedOptions.value.length);
|
|
240
|
+
const availableWidth = selectedContainer.value.clientWidth;
|
|
241
|
+
const overflowLabelWidth = overflowLabelMeasure.value?.offsetWidth ?? 0;
|
|
242
|
+
const gap = 2;
|
|
243
|
+
let usedWidth = 0;
|
|
244
|
+
let count = 0;
|
|
245
|
+
|
|
246
|
+
for (const chip of chipElements) {
|
|
247
|
+
const nextWidth = usedWidth + (count > 0 ? gap : 0) + chip.offsetWidth;
|
|
248
|
+
const remainingAfterNext = selectedOptions.value.length - count - 1;
|
|
249
|
+
const reservedWidth = remainingAfterNext > 0 ? gap + overflowLabelWidth : 0;
|
|
250
|
+
if (nextWidth + reservedWidth > availableWidth) break;
|
|
251
|
+
usedWidth = nextWidth;
|
|
252
|
+
count += 1;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
visibleSelectedCount.value = count;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
onMounted(() => {
|
|
259
|
+
selectedContainerObserver = new ResizeObserver(updateVisibleSelectedCount);
|
|
260
|
+
if (selectedContainer.value) selectedContainerObserver.observe(selectedContainer.value);
|
|
261
|
+
nextTick(updateVisibleSelectedCount);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
onBeforeUnmount(() => selectedContainerObserver?.disconnect());
|
|
265
|
+
|
|
266
|
+
watch(selectedOptions, () => nextTick(updateVisibleSelectedCount), { deep: true });
|
|
182
267
|
|
|
183
268
|
const internalValue = computed({
|
|
184
269
|
get() {
|
|
270
|
+
if (props.multiple) return Array.isArray(props.modelValue) ? props.modelValue : [];
|
|
185
271
|
return props.modelValue === null ? undefined : props.modelValue;
|
|
186
272
|
},
|
|
187
273
|
set(val) {
|
|
188
|
-
emit("update:modelValue", val ?? null);
|
|
274
|
+
emit("update:modelValue", props.multiple ? (Array.isArray(val) ? val : []) : (val ?? null));
|
|
189
275
|
},
|
|
190
276
|
});
|
|
191
277
|
|
|
@@ -219,10 +305,15 @@ watch(open, (val) => {
|
|
|
219
305
|
});
|
|
220
306
|
|
|
221
307
|
function clearSelection() {
|
|
222
|
-
emit("update:modelValue", null);
|
|
308
|
+
emit("update:modelValue", props.multiple ? [] : null);
|
|
223
309
|
open.value = false;
|
|
224
310
|
}
|
|
225
311
|
|
|
312
|
+
function removeSelection(value) {
|
|
313
|
+
if (props.disabled || !Array.isArray(props.modelValue)) return;
|
|
314
|
+
emit("update:modelValue", props.modelValue.filter((selectedValue) => selectedValue !== value));
|
|
315
|
+
}
|
|
316
|
+
|
|
226
317
|
function getOptionNodes() {
|
|
227
318
|
const el = viewportRef.value?.$el ?? viewportRef.value;
|
|
228
319
|
if (!el) return [];
|