Dashboard final version, TODO: update main sidebar menu
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user