123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374 |
- <template>
- <ui-popover
- :id="componentId"
- v-model="state.showPopover"
- :class="{ block }"
- :padding="`p-2 max-h-56 overflow-auto scroll ${componentId}`"
- trigger-width
- trigger="manual"
- class="ui-autocomplete"
- >
- <template #trigger>
- <slot />
- </template>
- <p v-if="filteredItems.length === 0" class="text-center">
- {{ t('message.noData') }}
- </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, true)"
- :class="{ 'bg-box-transparent': state.activeIndex === index }"
- class="cursor-pointer"
- @mousedown="selectItem(index, true)"
- @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,
- onBeforeUnmount,
- shallowReactive,
- watch,
- } from 'vue';
- import { useI18n } from 'vue-i18n';
- import { useComponentId } from '@/composable/componentId';
- import { debounce } from '@/utils/helper';
- const props = defineProps({
- modelValue: {
- type: String,
- default: '',
- },
- items: {
- type: [Array, Object],
- default: () => [],
- },
- itemKey: {
- type: String,
- default: '',
- },
- itemLabel: {
- type: String,
- default: '',
- },
- triggerChar: {
- type: Array,
- default: () => [],
- },
- customFilter: {
- type: Function,
- default: null,
- },
- replaceAfter: {
- type: [String, Array],
- default: null,
- },
- block: Boolean,
- disabled: Boolean,
- hideEmpty: Boolean,
- });
- const emit = defineEmits([
- 'update:modelValue',
- 'change',
- 'search',
- 'select',
- 'cancel',
- 'selected',
- ]);
- let input = null;
- const { t } = useI18n();
- const componentId = useComponentId('autocomplete');
- const state = shallowReactive({
- charIndex: -1,
- searchText: '',
- activeIndex: -1,
- showPopover: false,
- inputChanged: false,
- });
- const getItem = (item, key) =>
- item[key ? props.itemKey : props.itemLabel] || item;
- const filteredItems = computed(() => {
- if (!state.showPopover) return [];
- const triggerChar = props.triggerChar.length > 0;
- const searchText = (
- triggerChar ? state.searchText : props.modelValue
- ).toLocaleLowerCase();
- const defaultFilter = ({ item, text }) => {
- return getItem(item)?.toLocaleLowerCase().includes(text);
- };
- const filterFunction = props.customFilter || defaultFilter;
- return props.items.filter(
- (item, index) =>
- !state.inputChanged || filterFunction({ item, index, text: searchText })
- );
- });
- function getLastKeyBeforeCaret(caretIndex) {
- const getPosition = (val, index) => ({
- index,
- charIndex: input.value.lastIndexOf(val, caretIndex - 1),
- });
- const [charData] = props.triggerChar
- .map(getPosition)
- .sort((a, b) => b.charIndex - a.charIndex);
- if (charData.index > 0) return -1;
- return charData.charIndex;
- }
- function getSearchText(caretIndex, charIndex) {
- if (charIndex !== -1) {
- const charsLength = props.triggerChar.length;
- const text = input.value.substring(charIndex + charsLength, caretIndex);
- if (!/\s/.test(text)) {
- return text;
- }
- }
- return null;
- }
- function showPopover() {
- if (props.disabled) return;
- if (props.triggerChar.length < 1) {
- state.showPopover = true;
- return;
- }
- const { selectionStart } = input;
- if (selectionStart >= 0) {
- const charIndex = getLastKeyBeforeCaret(selectionStart);
- const text = getSearchText(selectionStart, charIndex);
- emit('search', text);
- if (charIndex >= 0 && text) {
- state.inputChanged = true;
- state.showPopover = true;
- state.searchText = text;
- state.charIndex = charIndex;
- return;
- }
- }
- state.charIndex = -1;
- state.searchText = '';
- state.showPopover = false;
- }
- 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) {
- state.inputChanged = true;
- emit('change', value);
- emit('update:modelValue', value);
- input.value = value;
- input.dispatchEvent(new Event('input'));
- }
- function selectItem(itemIndex, selected) {
- let selectedItem = filteredItems.value[itemIndex];
- if (!selectedItem) return;
- selectedItem = getItem(selectedItem);
- let caretPosition;
- const isTriggerChar = state.charIndex >= 0 && state.searchText;
- if (isTriggerChar) {
- const val = input.value;
- const index = state.charIndex;
- const charLength = props.triggerChar[0].length;
- const lastSearchIndex = state.searchText.length + index + charLength;
- let charLastIndex = 0;
- if (props.replaceAfter) {
- const lastChars = Array.isArray(props.replaceAfter)
- ? props.replaceAfter
- : [props.replaceAfter];
- lastChars.forEach((char) => {
- const searchText = val.slice(0, lastSearchIndex);
- const lastIndex = searchText.lastIndexOf(char);
- if (lastIndex > charLastIndex && lastIndex > index) {
- charLastIndex = lastIndex - 1;
- }
- });
- }
- caretPosition = index + charLength + selectedItem.length + charLastIndex;
- selectedItem =
- val.slice(0, index + charLength + charLastIndex) +
- selectedItem +
- val.slice(lastSearchIndex, val.length);
- }
- updateValue(selectedItem);
- if (selected) {
- emit('selected', {
- index: itemIndex,
- item: filteredItems.value[itemIndex],
- });
- }
- if (isTriggerChar) {
- input.selectionEnd = caretPosition;
- const isNotTextarea = input.tagName !== 'TEXTAREA';
- if (isNotTextarea) {
- input.blur();
- input.focus();
- }
- }
- }
- function handleKeydown(event) {
- 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();
- } else if (event.key === 'Enter' && state.showPopover) {
- selectItem(state.activeIndex, true);
- event.preventDefault();
- } else if (event.key === 'Escape') {
- emit('cancel');
- state.showPopover = false;
- }
- }
- function handleBlur() {
- state.showPopover = false;
- }
- function handleFocus() {
- if (props.triggerChar.length < 1) return;
- showPopover();
- }
- function handleInput() {
- state.inputChanged = true;
- }
- function attachEvents() {
- if (!input) return;
- input.addEventListener('blur', handleBlur);
- input.addEventListener('input', handleInput);
- input.addEventListener('focus', handleFocus);
- input.addEventListener('input', showPopover);
- input.addEventListener('keydown', handleKeydown);
- }
- function detachEvents() {
- if (!input) return;
- input.removeEventListener('blur', handleBlur);
- input.removeEventListener('input', handleInput);
- input.removeEventListener('focus', handleFocus);
- input.removeEventListener('input', showPopover);
- input.removeEventListener('keydown', handleKeydown);
- }
- watch(
- () => state.activeIndex,
- debounce((activeIndex) => {
- const container = document.querySelector(`.${componentId}`);
- const element = container.querySelector(`#list-item-${activeIndex}`);
- if (element && !checkInView(container, element)) {
- element.scrollIntoView({
- block: 'nearest',
- behavior: 'smooth',
- });
- }
- emit('select', {
- index: activeIndex,
- item: filteredItems.value[activeIndex],
- });
- }, 100)
- );
- watch(
- () => filteredItems,
- () => {
- if (filteredItems.value.length === 0 && props.hideEmpty) {
- state.showPopover = false;
- }
- },
- { deep: true }
- );
- watch(
- () => state.showPopover,
- (value) => {
- if (!value) state.inputChanged = false;
- }
- );
- onMounted(() => {
- if (props.modelValue) {
- const activeIndex = props.items.findIndex(
- (item) => getItem(item) === props.modelValue
- );
- if (activeIndex !== -1) state.activeIndex = activeIndex;
- }
- input = document.querySelector(
- `#${componentId} input, #${componentId} textarea`
- );
- attachEvents();
- });
- onBeforeUnmount(() => {
- detachEvents();
- });
- defineExpose({
- state,
- });
- </script>
- <style>
- .ui-autocomplete.block,
- .ui-autocomplete.block .ui-popover__trigger {
- width: 100%;
- display: block;
- }
- </style>
|