Skip to content

Autocomplete Component Code ​

Dependencies

This component requires:

  • Vue 3 with Composition API
  • OMenu component
  • OChip component

Full Component Code ​

vue
<script setup>
import { ref, computed, watch } from "vue";
import OMenu from "./OMenu.vue";
import OChip from "./OChip.vue";

// Props
const props = defineProps({
  // Core functionality
  items: { type: Array, default: () => [] },
  modelValue: { type: [String, Number, Array, Object], default: null },
  multiple: { type: Boolean, default: false },

  // Item configuration
  itemText: { type: String, default: "text" },
  itemValue: { type: String, default: "value" },
  itemDisabled: { type: String, default: "disabled" },

  // Autocomplete specific
  filterFunction: { type: Function, default: null },
  selectedOnTop: { type: Boolean, default: false },

  // Menu configuration
  placement: { type: String, default: "bottom" },
  offset: { type: Array, default: () => [0, 0.125] },
  menuWidth: { type: [String, Number], default: null },
  matchTriggerWidth: { type: Boolean, default: true },

  // Input props (pass-through)
  placeholder: { type: String, default: "" },
  disabled: { type: Boolean, default: false },
  loading: { type: Boolean, default: false },
  clearable: { type: Boolean, default: false },
  readonly: { type: Boolean, default: false },
});

// Emits
const emit = defineEmits([
  "update:modelValue",
  "change",
  "select",
  "remove",
  "search",
]);

// Refs
const menuRef = ref(null);
const inputRef = ref(null);
const isMenuOpen = ref(false);
const searchText = ref("");
const isFocused = ref(false);

// Computed
const selectedItems = computed(() => {
  if (!props.multiple) {
    if (props.modelValue == null) return [];
    const selected = props.items.find(
      (item) => getItemValue(item) === props.modelValue
    );
    return selected ? [selected] : [];
  }

  if (!Array.isArray(props.modelValue)) return [];

  return props.modelValue
    .map((value) => {
      return props.items.find((item) => getItemValue(item) === value);
    })
    .filter(Boolean);
});

const selectedItem = computed(() => {
  if (props.multiple) return null;
  return selectedItems.value[0] || null;
});

const hasSelection = computed(() => {
  return props.multiple
    ? selectedItems.value.length > 0
    : props.modelValue != null;
});

const sortedItems = computed(() => {
  let items = props.items.filter((item) => !isDisabled(item));

  if (!searchText.value.trim()) {
    // No search - just sort by selected if needed
    if (!props.selectedOnTop) {
      return items;
    }

    const selected = [];
    const unselected = [];

    items.forEach((item) => {
      if (isSelected(item)) {
        selected.push(item);
      } else {
        unselected.push(item);
      }
    });

    return [...selected, ...unselected];
  }

  // With search - filter and sort by relevance
  const search = searchText.value.toLowerCase().trim();

  const scoredItems = items.map((item) => {
    const text = getItemText(item).toLowerCase();
    let score = 0;

    // First check if item passes custom filter (if provided)
    if (props.filterFunction && !props.filterFunction(item, searchText.value)) {
      return { item, score: 0 }; // Item doesn't pass custom filter
    }

    // Apply default scoring system
    if (text === search) {
      score = 100; // Exact match
    } else if (text.startsWith(search)) {
      score = 50; // Starts with search
    } else if (text.includes(search)) {
      score = 25; // Contains search
    } else {
      score = 0; // No match
    }

    // Boost score for selected items if selectedOnTop is true
    if (props.selectedOnTop && isSelected(item)) {
      score += 1000;
    }

    return { item, score };
  });

  // Filter out items with score 0 (no match) and sort by score (highest first)
  return scoredItems
    .filter((scored) => scored.score > 0)
    .sort((a, b) => {
      if (a.score !== b.score) {
        return b.score - a.score;
      }
      // Maintain original order for items with same score
      return items.indexOf(a.item) - items.indexOf(b.item);
    })
    .map((scored) => scored.item);
});

// Helper functions
const getItemText = (item) => {
  if (!item) return "";
  if (typeof item === "string" || typeof item === "number") {
    return item.toString();
  }
  return item[props.itemText] || "";
};

const getItemValue = (item) => {
  if (!item) return null;
  if (typeof item === "string" || typeof item === "number") {
    return item;
  }
  return item[props.itemValue] !== undefined ? item[props.itemValue] : item;
};

const isDisabled = (item) => {
  if (!item) return false;
  if (typeof item === "string" || typeof item === "number") return false;
  return Boolean(item[props.itemDisabled]);
};

const isSelected = (item) => {
  const itemValue = getItemValue(item);
  if (props.multiple) {
    return (
      Array.isArray(props.modelValue) && props.modelValue.includes(itemValue)
    );
  }
  return props.modelValue === itemValue;
};

// Event handlers
const handleMenuOpen = () => {
  isMenuOpen.value = true;
};

const handleMenuClose = () => {
  isMenuOpen.value = false;

  // Restore selected item text when menu closes (single mode only)
  if (!props.multiple && hasSelection.value && selectedItem.value) {
    searchText.value = getItemText(selectedItem.value);
  } else if (props.multiple) {
    // Clear search text in multiple mode
    searchText.value = "";
  }
};

const handleInputFocus = () => {
  isFocused.value = true;
  if (!props.disabled && !isMenuOpen.value) {
    menuRef.value?.openMenu();
  }
  // When user focuses and starts typing after selection, allow filtering
  // The searchText will be updated by handleSearchInput as user types
};

const toggleMenu = () => {
  if (isMenuOpen.value) {
    menuRef.value?.closeMenu();
  } else {
    menuRef.value?.openMenu();
  }
};

const handleSearchInput = (value) => {
  searchText.value = value;
  emit("search", value);

  // Keep menu open while focused and has input
  if (isFocused.value && !isMenuOpen.value) {
    menuRef.value?.openMenu();
  }
};

const handleOptionClick = (item) => {
  if (isDisabled(item)) return;

  const itemValue = getItemValue(item);

  if (props.multiple) {
    let newValue = Array.isArray(props.modelValue) ? [...props.modelValue] : [];

    if (isSelected(item)) {
      // Remove item
      newValue = newValue.filter((val) => val !== itemValue);
      emit("remove", item, itemValue);
    } else {
      // Add item
      newValue.push(itemValue);
      emit("select", item, itemValue);
    }

    emit("update:modelValue", newValue);
    emit("change", newValue);

    // Clear search after selection in multiple mode
    searchText.value = "";
  } else {
    emit("update:modelValue", itemValue);
    emit("change", itemValue);
    emit("select", item, itemValue);

    // Set display text and close menu
    searchText.value = getItemText(item);
    menuRef.value?.closeMenu();
  }
};

const handleClear = () => {
  const newValue = props.multiple ? [] : null;
  emit("update:modelValue", newValue);
  emit("change", newValue);
  searchText.value = "";
};

const removeItem = (itemValue) => {
  if (!props.multiple || props.disabled) return;

  const item = props.items.find((item) => getItemValue(item) === itemValue);
  const newValue = Array.isArray(props.modelValue)
    ? props.modelValue.filter((val) => val !== itemValue)
    : [];

  emit("update:modelValue", newValue);
  emit("change", newValue);
  if (item) {
    emit("remove", item, itemValue);
  }
};

const handleKeyDown = (event) => {
  if (props.disabled || !props.multiple) return;

  // Handle backspace
  if (event.key === "Backspace") {
    // Remove all if Shift/Ctrl/Cmd is pressed
    if (event.shiftKey || event.ctrlKey || event.metaKey) {
      if (selectedItems.value.length > 0) {
        event.preventDefault();
        emit("update:modelValue", []);
        emit("change", []);
        searchText.value = "";
      }
      return;
    }

    // Remove one item at a time if input is empty and there are selected items
    if (!searchText.value && selectedItems.value.length > 0) {
      event.preventDefault();
      const lastItem = selectedItems.value[selectedItems.value.length - 1];
      const lastValue = getItemValue(lastItem);
      removeItem(lastValue);
    }
  }
};

// Watch for model value changes to update search text
watch(
  () => props.modelValue,
  (newValue) => {
    if (!props.multiple && !isFocused.value && !isMenuOpen.value) {
      const selected = props.items.find(
        (item) => getItemValue(item) === newValue
      );
      searchText.value = selected ? getItemText(selected) : "";
    } else if (props.multiple && !isFocused.value && !isMenuOpen.value) {
      // In multiple mode, clear search when not focused
      searchText.value = "";
    }
  },
  { immediate: true }
);

// Clear search text when menu closes in multiple mode
watch(isMenuOpen, (isOpen) => {
  if (!isOpen && props.multiple) {
    searchText.value = "";
  }
});

// Expose methods
defineExpose({
  focus: () => {
    if (inputRef.value) {
      inputRef.value.focus();
    }
  },
  blur: () => {
    if (inputRef.value) {
      inputRef.value.blur();
    }
  },
  openMenu: () => menuRef.value?.openMenu(),
  closeMenu: () => menuRef.value?.closeMenu(),
  search: (value) => {
    searchText.value = value;
  },
});
</script>

<template>
  <div class="autocomplete">
    <OMenu
      ref="menuRef"
      trigger-type="click"
      :placement="placement"
      :offset="offset"
      :width="menuWidth"
      :match-trigger-width="matchTriggerWidth"
      :disabled="disabled || readonly"
      @open="handleMenuOpen"
      @close="handleMenuClose"
    >
      <template #trigger>
        <div
          class="trigger-container"
          :class="{
            'has-selected-items': multiple && selectedItems.length > 0,
            disabled: disabled,
          }"
          @click.stop
        >
          <!-- Main container with chips and input (flex: 1, wrappable) -->
          <div class="main-container">
            <!-- Chips display -->
            <template v-if="multiple">
              <OChip
                v-for="item in selectedItems"
                :key="getItemValue(item)"
                :chip="item"
                :text-key="itemText"
                :value-key="itemValue"
                :closable="!disabled"
                @on-delete-chip="removeItem"
              />
            </template>

            <input
              ref="inputRef"
              type="text"
              :value="searchText"
              :placeholder="
                multiple && selectedItems.length > 0 ? '' : placeholder
              "
              :disabled="disabled || loading"
              :readonly="readonly"
              class="autocomplete-input"
              @input="handleSearchInput($event.target.value)"
              @focus="handleInputFocus"
              @keydown="handleKeyDown"
            />
          </div>

          <!-- Append wrapper with loading, clear, and arrow -->
          <div class="append-wrapper">
            <div v-if="loading" class="loading-indicator">
              <svg
                class="spinner"
                viewBox="0 0 50 50"
                xmlns="http://www.w3.org/2000/svg"
              >
                <circle
                  class="path"
                  cx="25"
                  cy="25"
                  r="20"
                  fill="none"
                  stroke-width="4"
                />
              </svg>
            </div>
            <div
              v-else-if="clearable && (hasSelection || searchText)"
              class="clear-button"
              @click.stop="handleClear"
            >
              <svg
                viewBox="0 0 24 24"
                fill="none"
                xmlns="http://www.w3.org/2000/svg"
              >
                <path
                  d="M18 6L6 18M6 6L18 18"
                  stroke="currentColor"
                  stroke-width="2"
                  stroke-linecap="round"
                  stroke-linejoin="round"
                />
              </svg>
            </div>
            <div
              class="select-arrow"
              :class="{ rotated: isMenuOpen }"
              @click.stop="toggleMenu"
            >
              <svg
                viewBox="0 0 24 24"
                fill="none"
                xmlns="http://www.w3.org/2000/svg"
              >
                <path
                  d="M6 9L12 15L18 9"
                  stroke="currentColor"
                  stroke-width="2"
                  stroke-linecap="round"
                  stroke-linejoin="round"
                />
              </svg>
            </div>
          </div>
        </div>
      </template>

      <template #content>
        <div class="autocomplete-dropdown">
          <!-- Options list -->
          <div
            v-for="item in sortedItems"
            :key="getItemValue(item)"
            class="select-option"
            :class="{
              selected: isSelected(item),
              disabled: isDisabled(item),
            }"
            @click="handleOptionClick(item)"
          >
            <div class="option-content">
              <!-- Checkbox for multiple selection -->
              <div v-if="multiple" class="option-checkbox">
                <svg
                  v-if="isSelected(item)"
                  viewBox="0 0 24 24"
                  fill="none"
                  xmlns="http://www.w3.org/2000/svg"
                >
                  <path
                    d="M20 6L9 17L4 12"
                    stroke="currentColor"
                    stroke-width="2"
                    stroke-linecap="round"
                    stroke-linejoin="round"
                  />
                </svg>
              </div>
              <span class="option-text">{{ getItemText(item) }}</span>
            </div>
          </div>

          <!-- Empty state -->
          <div v-if="sortedItems.length === 0 && !loading" class="select-empty">
            <slot name="no-data">
              <span class="empty-text">
                {{
                  searchText
                    ? "No matching options found"
                    : "No options available"
                }}
              </span>
            </slot>
          </div>

          <!-- Loading state -->
          <div v-if="loading" class="select-loading">
            <div class="loading-spinner">
              <svg viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg">
                <circle
                  class="path"
                  cx="25"
                  cy="25"
                  r="20"
                  fill="none"
                  stroke-width="4"
                />
              </svg>
            </div>
            <span>Loading...</span>
          </div>
        </div>
      </template>
    </OMenu>
  </div>
</template>

<style scoped>
.autocomplete {
  width: 100%;
}

.trigger-container {
  position: relative;
  width: 100%;
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.5rem 0.75rem;
  border: 0.0625rem solid grey;
  border-radius: 0.25rem;
  background: #ffffff;
  min-height: 3.5rem;
  transition: border-color 0.2s ease, box-shadow 0.2s ease;

  &:focus-within {
    border-color: #1976d2;
    box-shadow: 0 0 0 2px rgba(25, 118, 210, 0.1);
  }

  &.has-selected-items {
    padding: 0.375rem 0.75rem;
    min-height: auto;
  }

  &.disabled {
    opacity: 0.5;
    pointer-events: none;
    background: #f5f5f5;
    cursor: not-allowed;
  }
}

/* Main container with chips and input (flex: 1, wrappable) */
.main-container {
  flex-grow: 1;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.25rem;
  min-width: 0;
}

.autocomplete-input {
  border: none;
  outline: none;
  background: transparent;
  padding: 0.5rem 0;
  font-size: 1rem;
  /* Let input take remaining space in flex parent, but min 30% wrapping if needed */
  flex: 1 1 20%;
  min-width: 20%;
  max-width: 100%;

  &:disabled {
    opacity: 0.5;
    cursor: not-allowed;
  }

  &::placeholder {
    color: #9ca3af;
  }
}

/* Append wrapper with loading, clear, and arrow */
.append-wrapper {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  flex-shrink: 0;
}

.clear-button,
.loading-indicator {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 1.5rem;
  height: 1.5rem;
  cursor: pointer;
  color: #6b7280;
  transition: color 0.2s ease;
}

.clear-button {
  &:hover {
    color: #374151;
  }

  svg {
    width: 1rem;
    height: 1rem;
  }
}

.loading-indicator {
  cursor: default;

  .spinner {
    width: 1rem;
    height: 1rem;
    animation: rotate 2s linear infinite;

    .path {
      stroke: #1976d2;
      stroke-linecap: round;
      animation: dash 1.5s ease-in-out infinite;
    }
  }
}

.select-arrow {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 1.5rem;
  height: 1.5rem;
  cursor: pointer;
  color: #6b7280;
  transition: transform 0.2s ease, color 0.2s ease;

  &:hover {
    color: #374151;
  }

  svg {
    width: 1rem;
    height: 1rem;
  }

  &.rotated {
    transform: rotate(180deg);
  }
}

.autocomplete-dropdown {
  background: #ffffff;
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1), 0 2px 4px rgba(0, 0, 0, 0.06);
  max-height: 300px;
  overflow-y: auto;
  overflow-x: hidden;
  padding: 0.5rem 0;
  margin-top: 0.25rem;
  z-index: 1000;
}

/* Custom scrollbar styling */
.autocomplete-dropdown::-webkit-scrollbar {
  width: 8px;
}

.autocomplete-dropdown::-webkit-scrollbar-track {
  background: #f5f5f5;
  border-radius: 4px;
}

.autocomplete-dropdown::-webkit-scrollbar-thumb {
  background: #c0c0c0;
  border-radius: 4px;
}

.autocomplete-dropdown::-webkit-scrollbar-thumb:hover {
  background: #a0a0a0;
}

.select-option {
  padding: 0.625rem 1rem;
  cursor: pointer;
  transition: all 0.2s ease;
  margin: 0 0.25rem;
  border-radius: 6px;
  position: relative;
}

.select-option:hover:not(.disabled) {
  background: #f5f7fa;
  transform: translateX(2px);
}

.select-option.selected {
  background: linear-gradient(90deg, #e3f2fd 0%, #e8f4fd 100%);
  color: #1976d2;
  font-weight: 500;
}

.select-option.selected:hover:not(.disabled) {
  background: linear-gradient(90deg, #d0e7fc 0%, #d5e9fc 100%);
}

.select-option.disabled {
  opacity: 0.5;
  cursor: not-allowed;
  pointer-events: none;
}

.select-option.disabled:hover {
  background: transparent;
  transform: none;
}

.option-content {
  display: flex;
  align-items: center;
  gap: 0.75rem;
}

.option-checkbox {
  width: 1.125rem;
  height: 1.125rem;
  min-width: 1.125rem;
  border: 2px solid #9e9e9e;
  border-radius: 4px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #ffffff;
  transition: all 0.2s ease;
  flex-shrink: 0;
}

.select-option.selected .option-checkbox {
  background: #1976d2;
  border-color: #1976d2;
}

.select-option.selected .option-checkbox svg {
  color: #ffffff;
  stroke-width: 2.5;
}

.option-text {
  flex: 1;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: 0.9375rem;
  line-height: 1.5;
  color: inherit;
}

.select-empty,
.select-loading {
  padding: 2rem 1rem;
  text-align: center;
  color: #757575;
  font-size: 0.875rem;
}

.select-empty {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
}

.empty-text {
  color: #9e9e9e;
  font-style: italic;
}

.select-loading {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.75rem;
  min-height: 120px;
}

.loading-spinner {
  animation: rotate 2s linear infinite;
  width: 2rem;
  height: 2rem;
}

.loading-spinner .path {
  stroke: #1976d2;
  stroke-linecap: round;
  animation: dash 1.5s ease-in-out infinite;
}

.select-loading span {
  color: #757575;
  font-size: 0.875rem;
  font-weight: 500;
}

@keyframes rotate {
  100% {
    transform: rotate(360deg);
  }
}

@keyframes dash {
  0% {
    stroke-dasharray: 1, 150;
    stroke-dashoffset: 0;
  }
  50% {
    stroke-dasharray: 90, 150;
    stroke-dashoffset: -35;
  }
  100% {
    stroke-dasharray: 90, 150;
    stroke-dashoffset: -124;
  }
}
</style>