internal/dashboard/frontend/src/components/Filter/DropdownFilter.vue

<script setup lang="ts">
import { IconChevronDown, type Icon } from "@tabler/icons-vue";
import FilterPill from "components/Filter/FilterPill.vue";
import { onMounted, onUnmounted, ref } from "vue";

const props = defineProps<{
  modelValue: string[];
  label: string;
  options: { value: string; label: string }[];
  placeholder?: string;
  icon?: Icon;
}>();

const emit = defineEmits<{
  (e: "update:modelValue", value: string[]): void;
  (e: "change"): void;
}>();

const isOpen = ref(false);
const dropdownRef = ref<HTMLElement | null>(null);

function toggle() {
  isOpen.value = !isOpen.value;
}

function handleClickOutside(e: MouseEvent) {
  if (dropdownRef.value && !dropdownRef.value.contains(e.target as Node)) {
    isOpen.value = false;
  }
}

onMounted(() => {
  document.addEventListener("mousedown", handleClickOutside);
});

onUnmounted(() => {
  document.removeEventListener("mousedown", handleClickOutside);
});

function addValue(value: string) {
  if (!props.modelValue.includes(value)) {
    emit("update:modelValue", [...props.modelValue, value]);
    emit("change");
  }
  isOpen.value = false;
}

function removeValue(value: string) {
  emit(
    "update:modelValue",
    props.modelValue.filter((v) => v !== value),
  );
  emit("change");
}

function clear() {
  emit("update:modelValue", []);
  emit("change");
}
</script>

<template>
  <div class="flex items-center gap-2" ref="dropdownRef">
    <span
      class="filter-label"
      :class="{ 'cursor-pointer': modelValue.length > 0 }"
      @click="clear"
    >
      <component :is="icon" size="16" />
      {{ label }}:
    </span>
    <div class="relative">
      <button
        @click="toggle"
        class="flex min-w-[128px] items-center justify-between gap-2 rounded border border-stone-700 bg-stone-900/70 px-2 py-0.5 text-xs text-stone-300 transition-colors hover:border-stone-600 hover:text-white"
      >
        <span>{{ placeholder || "Select..." }}</span>
        <IconChevronDown
          size="14"
          :class="{ 'rotate-180': isOpen }"
          class="transition-transform"
        />
      </button>

      <div
        v-if="isOpen"
        class="absolute top-full left-0 z-100 mt-1 max-h-60 w-full min-w-[150px] overflow-hidden overflow-y-auto rounded border border-stone-700 bg-stone-800 shadow-xl"
      >
        <button
          v-for="opt in options"
          :key="opt.value"
          @click="addValue(opt.value)"
          class="flex w-full items-center px-3 py-1.5 text-left text-xs text-stone-300 transition-colors hover:bg-stone-700 hover:text-white disabled:cursor-not-allowed disabled:opacity-30"
          :disabled="modelValue.includes(opt.value)"
        >
          {{ opt.label }}
        </button>
      </div>
    </div>
    <div class="flex flex-wrap items-center" v-if="modelValue.length > 0">
      <FilterPill
        v-for="value in modelValue"
        :key="value"
        :label="options.find((o) => o.value === value)?.label || value"
        @click="removeValue(value)"
      />
    </div>
  </div>
</template>