UiAutocomplete.vue 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. <template>
  2. <ui-popover
  3. :id="componentId"
  4. v-model="state.showPopover"
  5. :class="{ block }"
  6. :padding="`p-2 max-h-56 overflow-auto scroll ${componentId}`"
  7. trigger-width
  8. trigger="manual"
  9. class="ui-autocomplete"
  10. >
  11. <template #trigger>
  12. <slot />
  13. </template>
  14. <p v-if="filteredItems.length === 0" class="text-center">
  15. {{ t('message.noData') }}
  16. </p>
  17. <ui-list v-else class="space-y-1">
  18. <ui-list-item
  19. v-for="(item, index) in filteredItems"
  20. :id="`list-item-${index}`"
  21. :key="getItem(item, true)"
  22. :class="{ 'bg-box-transparent': state.activeIndex === index }"
  23. class="cursor-pointer"
  24. @mousedown="selectItem(index, true)"
  25. @mouseenter="state.activeIndex = index"
  26. >
  27. <slot name="item" :item="item">
  28. {{ getItem(item) }}
  29. </slot>
  30. </ui-list-item>
  31. </ui-list>
  32. </ui-popover>
  33. </template>
  34. <script setup>
  35. import {
  36. computed,
  37. onMounted,
  38. onBeforeUnmount,
  39. shallowReactive,
  40. watch,
  41. } from 'vue';
  42. import { useI18n } from 'vue-i18n';
  43. import { useComponentId } from '@/composable/componentId';
  44. import { debounce } from '@/utils/helper';
  45. const props = defineProps({
  46. modelValue: {
  47. type: String,
  48. default: '',
  49. },
  50. items: {
  51. type: [Array, Object],
  52. default: () => [],
  53. },
  54. itemKey: {
  55. type: String,
  56. default: '',
  57. },
  58. itemLabel: {
  59. type: String,
  60. default: '',
  61. },
  62. triggerChar: {
  63. type: Array,
  64. default: () => [],
  65. },
  66. customFilter: {
  67. type: Function,
  68. default: null,
  69. },
  70. replaceAfter: {
  71. type: [String, Array],
  72. default: null,
  73. },
  74. block: Boolean,
  75. disabled: Boolean,
  76. hideEmpty: Boolean,
  77. });
  78. const emit = defineEmits([
  79. 'update:modelValue',
  80. 'change',
  81. 'search',
  82. 'select',
  83. 'cancel',
  84. 'selected',
  85. ]);
  86. let input = null;
  87. const { t } = useI18n();
  88. const componentId = useComponentId('autocomplete');
  89. const state = shallowReactive({
  90. charIndex: -1,
  91. searchText: '',
  92. activeIndex: -1,
  93. showPopover: false,
  94. inputChanged: false,
  95. });
  96. const getItem = (item, key) =>
  97. item[key ? props.itemKey : props.itemLabel] || item;
  98. const filteredItems = computed(() => {
  99. if (!state.showPopover) return [];
  100. const triggerChar = props.triggerChar.length > 0;
  101. const searchText = (
  102. triggerChar ? state.searchText : props.modelValue
  103. ).toLocaleLowerCase();
  104. const defaultFilter = ({ item, text }) => {
  105. return getItem(item)?.toLocaleLowerCase().includes(text);
  106. };
  107. const filterFunction = props.customFilter || defaultFilter;
  108. return props.items.filter(
  109. (item, index) =>
  110. !state.inputChanged || filterFunction({ item, index, text: searchText })
  111. );
  112. });
  113. function getLastKeyBeforeCaret(caretIndex) {
  114. const getPosition = (val, index) => ({
  115. index,
  116. charIndex: input.value.lastIndexOf(val, caretIndex - 1),
  117. });
  118. const [charData] = props.triggerChar
  119. .map(getPosition)
  120. .sort((a, b) => b.charIndex - a.charIndex);
  121. if (charData.index > 0) return -1;
  122. return charData.charIndex;
  123. }
  124. function getSearchText(caretIndex, charIndex) {
  125. if (charIndex !== -1) {
  126. const charsLength = props.triggerChar.length;
  127. const text = input.value.substring(charIndex + charsLength, caretIndex);
  128. if (!/\s/.test(text)) {
  129. return text;
  130. }
  131. }
  132. return null;
  133. }
  134. function showPopover() {
  135. if (props.disabled) return;
  136. if (props.triggerChar.length < 1) {
  137. state.showPopover = true;
  138. return;
  139. }
  140. const { selectionStart } = input;
  141. if (selectionStart >= 0) {
  142. const charIndex = getLastKeyBeforeCaret(selectionStart);
  143. const text = getSearchText(selectionStart, charIndex);
  144. emit('search', text);
  145. if (charIndex >= 0 && text) {
  146. state.inputChanged = true;
  147. state.showPopover = true;
  148. state.searchText = text;
  149. state.charIndex = charIndex;
  150. return;
  151. }
  152. }
  153. state.charIndex = -1;
  154. state.searchText = '';
  155. state.showPopover = false;
  156. }
  157. function checkInView(container, element, partial = false) {
  158. const cTop = container.scrollTop;
  159. const cBottom = cTop + container.clientHeight;
  160. const eTop = element.offsetTop;
  161. const eBottom = eTop + element.clientHeight;
  162. const isTotal = eTop >= cTop && eBottom <= cBottom;
  163. const isPartial =
  164. partial &&
  165. ((eTop < cTop && eBottom > cTop) || (eBottom > cBottom && eTop < cBottom));
  166. return isTotal || isPartial;
  167. }
  168. function updateValue(value) {
  169. state.inputChanged = true;
  170. emit('change', value);
  171. emit('update:modelValue', value);
  172. input.value = value;
  173. input.dispatchEvent(new Event('input'));
  174. }
  175. function selectItem(itemIndex, selected) {
  176. let selectedItem = filteredItems.value[itemIndex];
  177. if (!selectedItem) return;
  178. selectedItem = getItem(selectedItem);
  179. let caretPosition;
  180. const isTriggerChar = state.charIndex >= 0 && state.searchText;
  181. if (isTriggerChar) {
  182. const val = input.value;
  183. const index = state.charIndex;
  184. const charLength = props.triggerChar[0].length;
  185. const lastSearchIndex = state.searchText.length + index + charLength;
  186. let charLastIndex = 0;
  187. if (props.replaceAfter) {
  188. const lastChars = Array.isArray(props.replaceAfter)
  189. ? props.replaceAfter
  190. : [props.replaceAfter];
  191. lastChars.forEach((char) => {
  192. const searchText = val.slice(0, lastSearchIndex);
  193. const lastIndex = searchText.lastIndexOf(char);
  194. if (lastIndex > charLastIndex && lastIndex > index) {
  195. charLastIndex = lastIndex - 1;
  196. }
  197. });
  198. }
  199. caretPosition = index + charLength + selectedItem.length + charLastIndex;
  200. selectedItem =
  201. val.slice(0, index + charLength + charLastIndex) +
  202. selectedItem +
  203. val.slice(lastSearchIndex, val.length);
  204. }
  205. updateValue(selectedItem);
  206. if (selected) {
  207. emit('selected', {
  208. index: itemIndex,
  209. item: filteredItems.value[itemIndex],
  210. });
  211. }
  212. if (isTriggerChar) {
  213. input.selectionEnd = caretPosition;
  214. const isNotTextarea = input.tagName !== 'TEXTAREA';
  215. if (isNotTextarea) {
  216. input.blur();
  217. input.focus();
  218. }
  219. }
  220. }
  221. function handleKeydown(event) {
  222. const itemsLength = filteredItems.value.length - 1;
  223. if (event.key === 'ArrowUp') {
  224. if (state.activeIndex <= 0) state.activeIndex = itemsLength;
  225. else state.activeIndex -= 1;
  226. event.preventDefault();
  227. } else if (event.key === 'ArrowDown') {
  228. if (state.activeIndex >= itemsLength) state.activeIndex = 0;
  229. else state.activeIndex += 1;
  230. event.preventDefault();
  231. } else if (event.key === 'Enter' && state.showPopover) {
  232. selectItem(state.activeIndex, true);
  233. event.preventDefault();
  234. } else if (event.key === 'Escape') {
  235. emit('cancel');
  236. state.showPopover = false;
  237. }
  238. }
  239. function handleBlur() {
  240. state.showPopover = false;
  241. }
  242. function handleFocus() {
  243. if (props.triggerChar.length < 1) return;
  244. showPopover();
  245. }
  246. function handleInput() {
  247. state.inputChanged = true;
  248. }
  249. function attachEvents() {
  250. if (!input) return;
  251. input.addEventListener('blur', handleBlur);
  252. input.addEventListener('input', handleInput);
  253. input.addEventListener('focus', handleFocus);
  254. input.addEventListener('input', showPopover);
  255. input.addEventListener('keydown', handleKeydown);
  256. }
  257. function detachEvents() {
  258. if (!input) return;
  259. input.removeEventListener('blur', handleBlur);
  260. input.removeEventListener('input', handleInput);
  261. input.removeEventListener('focus', handleFocus);
  262. input.removeEventListener('input', showPopover);
  263. input.removeEventListener('keydown', handleKeydown);
  264. }
  265. watch(
  266. () => state.activeIndex,
  267. debounce((activeIndex) => {
  268. const container = document.querySelector(`.${componentId}`);
  269. const element = container.querySelector(`#list-item-${activeIndex}`);
  270. if (element && !checkInView(container, element)) {
  271. element.scrollIntoView({
  272. block: 'nearest',
  273. behavior: 'smooth',
  274. });
  275. }
  276. emit('select', {
  277. index: activeIndex,
  278. item: filteredItems.value[activeIndex],
  279. });
  280. }, 100)
  281. );
  282. watch(
  283. () => filteredItems,
  284. () => {
  285. if (filteredItems.value.length === 0 && props.hideEmpty) {
  286. state.showPopover = false;
  287. }
  288. },
  289. { deep: true }
  290. );
  291. watch(
  292. () => state.showPopover,
  293. (value) => {
  294. if (!value) state.inputChanged = false;
  295. }
  296. );
  297. onMounted(() => {
  298. if (props.modelValue) {
  299. const activeIndex = props.items.findIndex(
  300. (item) => getItem(item) === props.modelValue
  301. );
  302. if (activeIndex !== -1) state.activeIndex = activeIndex;
  303. }
  304. input = document.querySelector(
  305. `#${componentId} input, #${componentId} textarea`
  306. );
  307. attachEvents();
  308. });
  309. onBeforeUnmount(() => {
  310. detachEvents();
  311. });
  312. defineExpose({
  313. state,
  314. });
  315. </script>
  316. <style>
  317. .ui-autocomplete.block,
  318. .ui-autocomplete.block .ui-popover__trigger {
  319. width: 100%;
  320. display: block;
  321. }
  322. </style>