Skip to content
Snippets Groups Projects
RemoteTypeahead.vue 5.67 KiB
Newer Older
<template>
  <input
    class="form-control"
    ref="input"
    :value="modelValue"
    :placeholder="placeholder"
    @input="$emit('update:modelValue', $event.target.value)"
  />
</template>

<script>
import { Dropdown } from "bootstrap";

// Autocomplete code taken from https://github.com/gch1p/bootstrap-5-autocomplete as it is was not available as a npm package at that moment
const DEFAULTS = {
  treshold: 2,
  maximumItems: 5,
  highlightTyped: true,
  highlightClass: "text-primary",
};

class Autocomplete {
  constructor(field, options) {
    this.field = field;
    this.options = Object.assign({}, DEFAULTS, options);
    this.dropdown = null;

    field.parentNode.classList.add("dropdown");
    field.setAttribute("data-bs-toggle", "dropdown");
    field.classList.add("dropdown-toggle");

    const dropdown = ce(`<div class="dropdown-menu" ></div>`);
    if (this.options.dropdownClass)
      this.options.dropdownClass
        .split(" ")
        .forEach((x) => dropdown.classList.add(x));

    insertAfter(dropdown, field);

    this.dropdown = new Dropdown(field, this.options.dropdownOptions);

    field.addEventListener("click", (e) => {
      if (this.createItems() === 0) {
        e.stopPropagation();
        this.dropdown.hide();
      }
    });

    field.addEventListener("input", () => {
      if (this.options.onInput) this.options.onInput(this.field.value);
      this.renderIfNeeded();
    });

    field.addEventListener("keydown", (e) => {
      if (e.keyCode === 27) {
        // esc
        this.dropdown.hide();
        return;
      }
      if (e.keyCode === 37) {
        // arrow up
        this.dropdown._menu.children[0]?.focus();
        return;
      }
      if (e.keyCode === 40) {
        // arrow down
        this.dropdown._menu.children[0]?.focus();
        return;
      }
    });
  }

  setData(data) {
    this.options.data = data;
    this.renderIfNeeded();
  }

  renderIfNeeded() {
    if (this.createItems() > 0) this.dropdown.show();
    else this.field.click();
  }

  createItem(lookup, item) {
    let label;
    if (this.options.highlightTyped) {
      const idx = item.label.toLowerCase().indexOf(lookup.toLowerCase());
      const className = Array.isArray(this.options.highlightClass)
        ? this.options.highlightClass.join(" ")
        : typeof this.options.highlightClass == "string"
        ? this.options.highlightClass
        : "";
      label =
        item.label.substring(0, idx) +
        `<span class="${className}">${item.label.substring(
          idx,
          idx + lookup.length
        )}</span>` +
        item.label.substring(idx + lookup.length, item.label.length);
    } else label = item.label;
    let button = ce(
      `<button type="button" class="dropdown-item" data-value="${item.value}">${label}</button>`
    );
    return button;
  }

  createItems() {
    const lookup = this.field.value;
    if (lookup.length < this.options.treshold) {
      this.dropdown.hide();
      return 0;
    }

    const items = this.field.nextSibling;
    items.innerHTML = "";

    let count = 0;
    for (let i = 0; i < this.options.data.length; i++) {
      const { label, value } = this.options.data[i];
      const item = { label, value };
      if (item.label.toLowerCase().indexOf(lookup.toLowerCase()) >= 0) {
        items.appendChild(this.createItem(lookup, item));
        if (
          this.options.maximumItems > 0 &&
          ++count >= this.options.maximumItems
        )
          break;
      }
    }

    this.field.nextSibling
      .querySelectorAll(".dropdown-item")
      .forEach((item) => {
        item.addEventListener("click", (e) => {
          let dataValue = e.target.getAttribute("data-value");
          this.field.value = e.target.innerText;
          if (this.options.onSelectItem)
            this.options.onSelectItem({
              value: e.target.dataset.value,
              label: e.target.innerText,
            });
          this.dropdown.hide();
        });
      });

    return items.childNodes.length;
  }
}

/**
 * @param html
 * @returns {Node}
 */
function ce(html) {
  let div = document.createElement("div");
  div.innerHTML = html;
  return div.firstChild;
}

/**
 * @param elem
 * @param refElem
 * @returns {*}
 */
function insertAfter(elem, refElem) {
  return refElem.parentNode.insertBefore(elem, refElem.nextSibling);
}

export default {
  props: {
    debounce: { type: Number, default: 250 },
    suggestionLookup: {
      type: Function,
      default: function(input, result) {
        return [];
      },
    },
    modelValue: { type: String, default: "" },
    placeholder: { type: String, default: "" },
    threshold: { type: Number, default: 3 },
  },
  data: function() {
    return {
      autocomplete: null,
      data: [],
      timeout: null,
    };
  },
  methods: {
    lookupSuggestions: function(input) {
      const vm = this;
      if (vm.timeout) {
        clearTimeout(vm.timeout);
      }
Lukas Jelonek's avatar
Lukas Jelonek committed
      if (input && input.length >= vm.threshold) {
        vm.timeout = setTimeout(function() {
          vm.timeout = null;
          vm.suggestionLookup(input, (x) => vm.autocomplete.setData(x));
        }, vm.debounce);
Lukas Jelonek's avatar
Lukas Jelonek committed
      } else {
        vm.autocomplete.setData([]);
      }
    },
    update: function(value) {
      this.$emit("update:modelValue", value);
    },
  },
  mounted: function() {
    const vm = this;
    vm.autocomplete = new Autocomplete(this.$refs.input, {
      data: vm.data,
      maximumItems: 0,
      treshold: vm.threshold,
      dropdownClass: "suggestions",
      onInput: function(input) {
        vm.lookupSuggestions(input);
      },
      onSelectItem: function(item) {
        vm.update(item.value);
      },
    });
  },
};
</script>

<style>
.suggestions {
  max-height: 200px;
  overflow-y: auto;
}
</style>