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

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

const props = defineProps<{
  modelValue: string[];
  label: string;
  placeholder?: string;
  title?: string;
  inputClass?: string;
  validateRegex?: RegExp;
  icon?: Icon;
}>();

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

const inputValue = ref("");
const error = ref(false);
// This flag determines if we have ever errored, to start live checks
const errorTriggered = ref(false);

function checkError() {
  if (!props.validateRegex) {
    error.value = false;
    return;
  }
  // check all current comma-separated parts for regex validity
  const val = inputValue.value;
  const parts = val
    .split(",")
    .map((s) => s.trim())
    .filter(Boolean);

  if (parts.length === 0) {
    error.value = false;
    return;
  }

  error.value = !parts.every((p) => props.validateRegex!.test(p));
}

function onInputChange() {
  const val = inputValue.value;
  if (!val) return;

  const parts = val
    .split(",")
    .map((s) => s.trim())
    .filter(Boolean);

  if (
    props.validateRegex &&
    !parts.every((p) => props.validateRegex!.test(p))
  ) {
    error.value = true;
    errorTriggered.value = true;
    return;
  }

  if (parts.length > 0) {
    emit("update:modelValue", [...new Set([...props.modelValue, ...parts])]);
    emit("change");
    // Reset error validation after a valid input
    error.value = false;
    errorTriggered.value = false;
    inputValue.value = "";
    return;
  }
  error.value = false;
  inputValue.value = "";
}

function handleInput() {
  if (errorTriggered.value) {
    checkError();
  }
  if (inputValue.value.includes(",")) {
    onInputChange();
  }
}

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

<template>
  <div class="flex items-center gap-2">
    <span class="filter-label">
      <component :is="icon" size="16" />
      {{ label }}:
    </span>
    <div class="relative flex items-center gap-1">
      <input
        type="text"
        v-model="inputValue"
        @input="handleInput"
        @keypress.enter="onInputChange"
        @blur="onInputChange"
        :placeholder="placeholder"
        :class="[inputClass || 'w-48 pr-5', { 'border-red-500': error }]"
      />
      <button
        v-if="inputValue.length > 0"
        @mousedown.prevent
        @click="inputValue = ''"
        class="absolute top-1 right-1 text-stone-500 hover:text-stone-300"
        :title="`Clear ${label.toLowerCase()} input`"
      >
        <IconX size="16" />
      </button>
    </div>
    <div class="flex flex-wrap items-center" v-if="modelValue.length > 0">
      <FilterPill
        v-for="value in modelValue"
        :key="value"
        :label="value"
        @click="removeValue(value)"
      />
    </div>
  </div>
</template>