174 lines
5.4 KiB
Vue
174 lines
5.4 KiB
Vue
<script setup>
|
|
import { ref, computed, watch } from "vue";
|
|
import { Popover, PopoverTrigger, PopoverContent } from "@/Components/ui/popover";
|
|
import {
|
|
Command,
|
|
CommandInput,
|
|
CommandList,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandItem,
|
|
} from "@/Components/ui/command";
|
|
import { Button } from "@/Components/ui/button";
|
|
import { Badge } from "@/Components/ui/badge";
|
|
import { Check, ChevronsUpDown, X } from "lucide-vue-next";
|
|
|
|
const props = defineProps({
|
|
modelValue: { type: Array, default: () => [] },
|
|
items: { type: Array, default: () => [] }, // [{ value, label }]
|
|
placeholder: { type: String, default: "Izberi..." },
|
|
searchPlaceholder: { type: String, default: "Išči..." },
|
|
emptyText: { type: String, default: "Ni zadetkov." },
|
|
disabled: { type: Boolean, default: false },
|
|
max: { type: Number, default: null },
|
|
clearable: { type: Boolean, default: true },
|
|
contentClass: { type: String, default: "p-0 w-[300px]" },
|
|
showSelectedChips: { type: Boolean, default: true },
|
|
chipVariant: { type: String, default: "secondary" },
|
|
});
|
|
|
|
const emit = defineEmits(["update:modelValue"]);
|
|
|
|
const open = ref(false);
|
|
const query = ref("");
|
|
const internal = ref([...props.modelValue]);
|
|
|
|
watch(
|
|
() => props.modelValue,
|
|
(val) => {
|
|
if (!Array.isArray(val)) return;
|
|
internal.value = [...val];
|
|
}
|
|
);
|
|
|
|
const valueSet = computed(() => new Set(internal.value.map(String)));
|
|
|
|
const filteredItems = computed(() => {
|
|
const q = query.value.trim().toLowerCase();
|
|
if (!q) return props.items;
|
|
return props.items.filter((i) => i.label.toLowerCase().includes(q));
|
|
});
|
|
|
|
function toggle(value) {
|
|
if (props.disabled) return;
|
|
const v = String(value);
|
|
const set = new Set(internal.value.map(String));
|
|
if (set.has(v)) {
|
|
set.delete(v);
|
|
} else {
|
|
if (props.max && set.size >= props.max) return;
|
|
set.add(v);
|
|
}
|
|
internal.value = Array.from(set);
|
|
emit("update:modelValue", internal.value);
|
|
// Clear search so full list remains visible after selection
|
|
query.value = "";
|
|
}
|
|
|
|
function removeChip(value) {
|
|
const v = String(value);
|
|
internal.value = internal.value.filter((x) => String(x) !== v);
|
|
emit("update:modelValue", internal.value);
|
|
}
|
|
|
|
function clearAll() {
|
|
if (!props.clearable || props.disabled) return;
|
|
internal.value = [];
|
|
emit("update:modelValue", internal.value);
|
|
}
|
|
|
|
const summaryText = computed(() => {
|
|
if (internal.value.length === 0) return props.placeholder;
|
|
if (!props.showSelectedChips) return `${internal.value.length} izbranih`;
|
|
const labels = internal.value.map((v) => {
|
|
const found = props.items.find((i) => String(i.value) === String(v));
|
|
return found?.label || v;
|
|
});
|
|
if (labels.length <= 3) return labels.join(', ');
|
|
const firstThree = labels.slice(0, 3).join(', ');
|
|
const remaining = labels.length - 3;
|
|
return `${firstThree}, … +${remaining}`; // show ellipsis and remaining count
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<Popover v-model:open="open">
|
|
<PopoverTrigger as-child>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
:disabled="disabled"
|
|
class="w-full justify-between gap-2"
|
|
>
|
|
<span
|
|
class="truncate"
|
|
:class="{ 'text-muted-foreground': internal.length === 0 }"
|
|
>
|
|
{{ summaryText }}
|
|
</span>
|
|
<ChevronsUpDown class="h-4 w-4 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent :class="contentClass">
|
|
<Command>
|
|
<CommandInput v-model="query" :placeholder="searchPlaceholder" />
|
|
|
|
<CommandList>
|
|
<CommandEmpty class="px-3 py-2 text-sm text-muted-foreground">{{
|
|
emptyText
|
|
}}</CommandEmpty>
|
|
<CommandGroup>
|
|
<CommandItem
|
|
v-for="item in filteredItems"
|
|
:key="item.value"
|
|
:value="String(item.value)"
|
|
@select="() => toggle(item.value)"
|
|
class="flex items-center gap-2 px-2 py-1.5 rounded-sm cursor-pointer hover:bg-accent hover:text-accent-foreground"
|
|
>
|
|
<Check
|
|
class="h-4 w-4"
|
|
:class="valueSet.has(String(item.value)) ? 'opacity-100' : 'opacity-0'"
|
|
/>
|
|
<span class="flex-1">{{ item.label }}</span>
|
|
</CommandItem>
|
|
</CommandGroup>
|
|
</CommandList>
|
|
<div class="border-t p-2 flex items-center justify-between gap-2">
|
|
<Button
|
|
v-if="clearable"
|
|
variant="outline"
|
|
size="sm"
|
|
:disabled="internal.length === 0 || disabled"
|
|
@click="clearAll"
|
|
>Počisti</Button
|
|
>
|
|
<Button size="sm" :disabled="disabled" @click="open = false">Zapri</Button>
|
|
</div>
|
|
<div
|
|
v-if="showSelectedChips && internal.length"
|
|
class="border-t p-2 flex flex-wrap gap-1"
|
|
>
|
|
<Badge
|
|
v-for="val in internal"
|
|
:key="val"
|
|
:variant="chipVariant"
|
|
class="flex items-center gap-1"
|
|
>
|
|
<span class="truncate max-w-[140px]">
|
|
{{ items.find((i) => String(i.value) === String(val))?.label || val }}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
class="hover:text-foreground/80"
|
|
@click.stop="removeChip(val)"
|
|
:title="'Odstrani'"
|
|
>
|
|
<X class="h-3 w-3" />
|
|
</button>
|
|
</Badge>
|
|
</div>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</template>
|