Skip to content

EpSearchTypeahead

Props

NameDescriptionTypeDefault
resultsKey-string''
returnedSearchResults-array-
inputProps-object{}

Events

NameDescriptionPayload
clear--
search--
selection--

INFO

This component does not use slots.

Component Code

vue
<template>
  <div class="ep-search-typeahead">
    <ep-input
      v-model="searchQuery"
      v-bind="computedInputProps"
      spellcheck="false"
      @update:model-value="onInput"
      @clear="resetSearch"
      @keydown.prevent.down="updateactiveItemIndex(1)"
      @keydown.prevent.up="updateactiveItemIndex(-1)"
      @keydown.enter="onEnter"
      @keydown.esc="resetSearch"
    />
    <div
      v-if="returnedSearchResults.length"
      ref="resultsListRef"
      class="ep-search-typeahead-dropdown"
    >
      <ul>
        <li
          v-for="(result, index) in returnedSearchResults"
          :key="index"
          :class="[
            'ep-search-typeahead-dropdown__item',
            { 'ep-search-typeahead-dropdown__item--active': index === activeItemIndex, }
          ]"
          @click="onSelection(result)"
          @mouseenter="onMouseEnter(index)"
        >
          {{ result[resultsKey] }}
        </li>
      </ul>
    </div>
  </div>
</template>

<script setup>
  import { onClickOutside, useDebounceFn } from '@vueuse/core'
  import { computed, ref, watch } from 'vue'

  import EpInput from '../input/EpInput.vue'

  defineOptions({
    name: 'EpSearchTypeahead'
  })

  const searchQuery = ref('')
  const activeItemIndex = ref(-1)

  const props = defineProps({
    resultsKey: {
      type: String,
      default: '',
    },
    returnedSearchResults: {
      type: Array,
      required: true,
    },
    inputProps: {
      type: Object,
      default: () => ({}),
    },
  })

  const emit = defineEmits(['clear', 'search', 'selection'])

  const activeItem = computed(() => {
    return props.returnedSearchResults[activeItemIndex.value]
  })

  watch(activeItem, (newValue) => {
    if (newValue) {
      searchQuery.value = newValue[props.resultsKey]
    }
  })

  const computedInputProps = computed(() => {
    return {
      size: 'default',
      placeholder: 'Search…',
      clearable: true,
      ...props.inputProps,
    }
  })

  const resetSearch = () => {
    searchQuery.value = ''
    activeItemIndex.value = -1
    emit('clear')
  }

  const resultsListRef = ref(null)

  onClickOutside(resultsListRef, resetSearch)

  const updateactiveItemIndex = (delta) => {
    const newIndex = activeItemIndex.value + delta

    if (props.returnedSearchResults.length === 0 || newIndex < 0 || newIndex >= props.returnedSearchResults.length) {
      return
    }

    activeItemIndex.value = newIndex

    scrollToSelectedItem()
  }

  const scrollToSelectedItem = () => {
    const list = resultsListRef.value.children[0]
    const selectedItem = list.children[activeItemIndex.value]

    if (!selectedItem) return

    const dropdownHeight = resultsListRef.value.offsetHeight
    const itemTop = selectedItem.offsetTop
    const itemBottom = itemTop + selectedItem.offsetHeight

    if (itemBottom > dropdownHeight + resultsListRef.value.scrollTop) {
      resultsListRef.value.scrollTop = itemBottom - dropdownHeight
    } else if (itemTop < resultsListRef.value.scrollTop) {
      resultsListRef.value.scrollTop = itemTop
    }
  }

  const debouncedSearch = useDebounceFn((value) => emit('search', value), 200)

  const onInput = () => {
    activeItemIndex.value = -1
    debouncedSearch(searchQuery.value)
  }

  const onEnter = () => {
    if (props.returnedSearchResults.length === 0) {
      return
    }
    onSelection(props.returnedSearchResults[activeItemIndex.value])
  }

  const onMouseEnter = (index) => {
    activeItemIndex.value = index
  }

  const onSelection = (result) => {
    emit('selection', result)
  }
</script>

Styles (SCSS)

scss
.ep-search-typeahead {
  position: relative;
}

.ep-search-typeahead-dropdown {
  position: absolute;
  top: calc(100% + 0.4rem);
  left: 0;
  width: 100%;
  max-height: 50vh;
  padding: 1rem;
  overflow-y: auto;
  background-color: var(--interface-overlay);
  border: 1px solid var(--border-color--lighter);
  border-radius: var(--border-radius);
  z-index: var(--z-index--dropdown);

  &__item {
    font-size: var(--font-size--small);
    line-height: 1;
    padding: 0.9rem 1.4rem;
    border-radius: var(--border-radius);
    cursor: pointer;

    &--active {
      background-color: var(--primary-color-base);
      color: var(--text-color--loud);
    }
  }
}