Browse Source

feat: add autocomplete component

Ahmad Kholid 3 years ago
parent
commit
9c03b7a5c8
1 changed files with 169 additions and 0 deletions
  1. 169 0
      src/components/ui/UiAutocomplete.vue

+ 169 - 0
src/components/ui/UiAutocomplete.vue

@@ -0,0 +1,169 @@
+<template>
+  <ui-popover
+    v-model="state.showPopover"
+    trigger-width
+    trigger="manual"
+    :padding="`p-2 max-h-56 overflow-auto scroll ${componentId}`"
+  >
+    <template #trigger>
+      <ui-input
+        v-bind="{ modelValue, placeholder, label, prependIcon }"
+        autocomplete="off"
+        @focus="state.showPopover = true"
+        @blur="state.showPopover = false"
+        @keydown="handleKeydown"
+        @change="updateValue"
+        @keyup.enter="selectItem(state.activeIndex)"
+        @keyup.esc="state.showPopover = false"
+      />
+    </template>
+    <p v-if="filteredItems.length === 0" class="text-center">No data to show</p>
+    <ui-list v-else class="space-y-1">
+      <ui-list-item
+        v-for="(item, index) in filteredItems"
+        :id="`list-item-${index}`"
+        :key="getItem(item)"
+        :class="{ 'bg-box-transparent': state.activeIndex === index }"
+        class="cursor-pointer"
+        @mousedown="selectItem(index)"
+        @mouseenter="state.activeIndex = index"
+      >
+        <slot name="item" :item="item">
+          {{ getItem(item) }}
+        </slot>
+      </ui-list-item>
+    </ui-list>
+  </ui-popover>
+</template>
+<script setup>
+import { computed, onMounted, shallowReactive, watch } from 'vue';
+import { useComponentId } from '@/composable/componentId';
+import { debounce } from '@/utils/helper';
+
+const props = defineProps({
+  modelValue: {
+    type: String,
+    default: '',
+  },
+  items: {
+    type: Array,
+    default: () => [],
+  },
+  itemKey: {
+    type: String,
+    default: '',
+  },
+  label: {
+    type: String,
+    default: '',
+  },
+  placeholder: {
+    type: String,
+    default: '',
+  },
+  prependIcon: {
+    type: String,
+    default: '',
+  },
+});
+const emit = defineEmits(['update:modelValue', 'change']);
+
+const componentId = useComponentId('autocomplete');
+
+const state = shallowReactive({
+  activeIndex: -1,
+  showPopover: false,
+  inputChanged: false,
+});
+
+const getItem = (item) => item[props.itemLabel] || item;
+
+const filteredItems = computed(() =>
+  props.items.filter(
+    (item) =>
+      !state.inputChanged ||
+      getItem(item)
+        ?.toLocaleLowerCase()
+        .includes(props.modelValue.toLocaleLowerCase())
+  )
+);
+
+function handleKeydown(event) {
+  if (!state.showPopover) state.showPopover = true;
+
+  const itemsLength = filteredItems.value.length - 1;
+
+  if (event.key === 'ArrowUp') {
+    if (state.activeIndex <= 0) state.activeIndex = itemsLength;
+    else state.activeIndex -= 1;
+
+    event.preventDefault();
+  } else if (event.key === 'ArrowDown') {
+    if (state.activeIndex >= itemsLength) state.activeIndex = 0;
+    else state.activeIndex += 1;
+
+    event.preventDefault();
+  }
+}
+function checkInView(container, element, partial = false) {
+  const cTop = container.scrollTop;
+  const cBottom = cTop + container.clientHeight;
+
+  const eTop = element.offsetTop;
+  const eBottom = eTop + element.clientHeight;
+
+  const isTotal = eTop >= cTop && eBottom <= cBottom;
+  const isPartial =
+    partial &&
+    ((eTop < cTop && eBottom > cTop) || (eBottom > cBottom && eTop < cBottom));
+
+  return isTotal || isPartial;
+}
+function updateValue(value) {
+  if (!state.showPopover) state.showPopover = true;
+
+  state.inputChanged = true;
+
+  emit('change', value);
+  emit('update:modelValue', value);
+}
+function selectItem(index) {
+  const selectedItem = filteredItems.value[index];
+
+  if (!selectedItem) return;
+
+  updateValue(getItem(selectedItem));
+  state.showPopover = false;
+}
+
+watch(
+  () => state.activeIndex,
+  debounce((activeIndex, prevIndex) => {
+    const container = document.querySelector(`.${componentId}`);
+    const element = container.querySelector(`#list-item-${activeIndex}`);
+
+    if (element && !checkInView(container, element)) {
+      element.scrollIntoView({
+        behavior: 'smooth',
+        block: activeIndex > prevIndex ? 'end' : 'start',
+      });
+    }
+  }, 100)
+);
+watch(
+  () => state.showPopover,
+  (value) => {
+    if (!value) state.inputChanged = false;
+  }
+);
+
+onMounted(() => {
+  if (props.modelValue) {
+    const activeIndex = props.items(
+      (item) => getItem(item) === props.modelValue
+    );
+
+    if (activeIndex !== -1) state.activeIndex = activeIndex;
+  }
+});
+</script>