VAutocomplete.mjs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446
  1. import { createTextVNode as _createTextVNode, mergeProps as _mergeProps, createVNode as _createVNode, Fragment as _Fragment } from "vue";
  2. // Styles
  3. import "./VAutocomplete.css";
  4. // Components
  5. import { VCheckboxBtn } from "../VCheckbox/index.mjs";
  6. import { VChip } from "../VChip/index.mjs";
  7. import { VDefaultsProvider } from "../VDefaultsProvider/index.mjs";
  8. import { VIcon } from "../VIcon/index.mjs";
  9. import { VList, VListItem } from "../VList/index.mjs";
  10. import { VMenu } from "../VMenu/index.mjs";
  11. import { makeSelectProps } from "../VSelect/VSelect.mjs";
  12. import { makeVTextFieldProps, VTextField } from "../VTextField/VTextField.mjs";
  13. import { VVirtualScroll } from "../VVirtualScroll/index.mjs"; // Composables
  14. import { useScrolling } from "../VSelect/useScrolling.mjs";
  15. import { useTextColor } from "../../composables/color.mjs";
  16. import { makeFilterProps, useFilter } from "../../composables/filter.mjs";
  17. import { useForm } from "../../composables/form.mjs";
  18. import { forwardRefs } from "../../composables/forwardRefs.mjs";
  19. import { useItems } from "../../composables/list-items.mjs";
  20. import { useLocale } from "../../composables/locale.mjs";
  21. import { useProxiedModel } from "../../composables/proxiedModel.mjs";
  22. import { makeTransitionProps } from "../../composables/transition.mjs"; // Utilities
  23. import { computed, mergeProps, nextTick, ref, shallowRef, watch } from 'vue';
  24. import { genericComponent, getPropertyFromItem, matchesSelector, noop, omit, propsFactory, useRender, wrapInArray } from "../../util/index.mjs"; // Types
  25. function highlightResult(text, matches, length) {
  26. if (matches == null) return text;
  27. if (Array.isArray(matches)) throw new Error('Multiple matches is not implemented');
  28. return typeof matches === 'number' && ~matches ? _createVNode(_Fragment, null, [_createVNode("span", {
  29. "class": "v-autocomplete__unmask"
  30. }, [text.substr(0, matches)]), _createVNode("span", {
  31. "class": "v-autocomplete__mask"
  32. }, [text.substr(matches, length)]), _createVNode("span", {
  33. "class": "v-autocomplete__unmask"
  34. }, [text.substr(matches + length)])]) : text;
  35. }
  36. export const makeVAutocompleteProps = propsFactory({
  37. autoSelectFirst: {
  38. type: [Boolean, String]
  39. },
  40. search: String,
  41. ...makeFilterProps({
  42. filterKeys: ['title']
  43. }),
  44. ...makeSelectProps(),
  45. ...omit(makeVTextFieldProps({
  46. modelValue: null
  47. }), ['validationValue', 'dirty', 'appendInnerIcon']),
  48. ...makeTransitionProps({
  49. transition: false
  50. })
  51. }, 'VAutocomplete');
  52. export const VAutocomplete = genericComponent()({
  53. name: 'VAutocomplete',
  54. props: makeVAutocompleteProps(),
  55. emits: {
  56. 'update:focused': focused => true,
  57. 'update:search': val => true,
  58. 'update:modelValue': val => true,
  59. 'update:menu': val => true
  60. },
  61. setup(props, _ref) {
  62. let {
  63. slots
  64. } = _ref;
  65. const {
  66. t
  67. } = useLocale();
  68. const vTextFieldRef = ref();
  69. const isFocused = shallowRef(false);
  70. const isPristine = shallowRef(true);
  71. const listHasFocus = shallowRef(false);
  72. const vMenuRef = ref();
  73. const _menu = useProxiedModel(props, 'menu');
  74. const menu = computed({
  75. get: () => _menu.value,
  76. set: v => {
  77. if (_menu.value && !v && vMenuRef.value?.ΨopenChildren) return;
  78. _menu.value = v;
  79. }
  80. });
  81. const selectionIndex = shallowRef(-1);
  82. const color = computed(() => vTextFieldRef.value?.color);
  83. const {
  84. items,
  85. transformIn,
  86. transformOut
  87. } = useItems(props);
  88. const {
  89. textColorClasses,
  90. textColorStyles
  91. } = useTextColor(color);
  92. const search = useProxiedModel(props, 'search', '');
  93. const model = useProxiedModel(props, 'modelValue', [], v => transformIn(v === null ? [null] : wrapInArray(v)), v => {
  94. const transformed = transformOut(v);
  95. return props.multiple ? transformed : transformed[0] ?? null;
  96. });
  97. const form = useForm();
  98. const {
  99. filteredItems,
  100. getMatches
  101. } = useFilter(props, items, () => isPristine.value ? '' : search.value);
  102. const selections = computed(() => {
  103. return model.value.map(v => {
  104. return items.value.find(item => {
  105. const itemRawValue = getPropertyFromItem(item.raw, props.itemValue);
  106. const modelRawValue = getPropertyFromItem(v.raw, props.itemValue);
  107. if (itemRawValue === undefined || modelRawValue === undefined) return false;
  108. return props.returnObject ? props.valueComparator(itemRawValue, modelRawValue) : props.valueComparator(item.value, v.value);
  109. }) || v;
  110. });
  111. });
  112. const displayItems = computed(() => {
  113. if (props.hideSelected) {
  114. return filteredItems.value.filter(filteredItem => !selections.value.some(s => s.value === filteredItem.value));
  115. }
  116. return filteredItems.value;
  117. });
  118. const selected = computed(() => selections.value.map(selection => selection.props.value));
  119. const selection = computed(() => selections.value[selectionIndex.value]);
  120. const highlightFirst = computed(() => {
  121. const selectFirst = props.autoSelectFirst === true || props.autoSelectFirst === 'exact' && search.value === displayItems.value[0]?.title;
  122. return selectFirst && displayItems.value.length > 0 && !isPristine.value && !listHasFocus.value;
  123. });
  124. const menuDisabled = computed(() => props.hideNoData && !items.value.length || props.readonly || form?.isReadonly.value);
  125. const listRef = ref();
  126. const {
  127. onListScroll,
  128. onListKeydown
  129. } = useScrolling(listRef, vTextFieldRef);
  130. function onClear(e) {
  131. if (props.openOnClear) {
  132. menu.value = true;
  133. }
  134. search.value = '';
  135. }
  136. function onMousedownControl() {
  137. if (menuDisabled.value) return;
  138. menu.value = true;
  139. }
  140. function onMousedownMenuIcon(e) {
  141. if (menuDisabled.value) return;
  142. if (isFocused.value) {
  143. e.preventDefault();
  144. e.stopPropagation();
  145. }
  146. menu.value = !menu.value;
  147. }
  148. function onKeydown(e) {
  149. if (props.readonly || form?.isReadonly.value) return;
  150. const selectionStart = vTextFieldRef.value.selectionStart;
  151. const length = selected.value.length;
  152. if (selectionIndex.value > -1 || ['Enter', 'ArrowDown', 'ArrowUp'].includes(e.key)) {
  153. e.preventDefault();
  154. }
  155. if (['Enter', 'ArrowDown'].includes(e.key)) {
  156. menu.value = true;
  157. }
  158. if (['Escape'].includes(e.key)) {
  159. menu.value = false;
  160. }
  161. if (highlightFirst.value && ['Enter', 'Tab'].includes(e.key)) {
  162. select(filteredItems.value[0]);
  163. }
  164. if (e.key === 'ArrowDown' && highlightFirst.value) {
  165. listRef.value?.focus('next');
  166. }
  167. if (!props.multiple) return;
  168. if (['Backspace', 'Delete'].includes(e.key)) {
  169. if (selectionIndex.value < 0) {
  170. if (e.key === 'Backspace' && !search.value) {
  171. selectionIndex.value = length - 1;
  172. }
  173. return;
  174. }
  175. const originalSelectionIndex = selectionIndex.value;
  176. if (selection.value) select(selection.value);
  177. selectionIndex.value = originalSelectionIndex >= length - 1 ? length - 2 : originalSelectionIndex;
  178. }
  179. if (e.key === 'ArrowLeft') {
  180. if (selectionIndex.value < 0 && selectionStart > 0) return;
  181. const prev = selectionIndex.value > -1 ? selectionIndex.value - 1 : length - 1;
  182. if (selections.value[prev]) {
  183. selectionIndex.value = prev;
  184. } else {
  185. selectionIndex.value = -1;
  186. vTextFieldRef.value.setSelectionRange(search.value?.length, search.value?.length);
  187. }
  188. }
  189. if (e.key === 'ArrowRight') {
  190. if (selectionIndex.value < 0) return;
  191. const next = selectionIndex.value + 1;
  192. if (selections.value[next]) {
  193. selectionIndex.value = next;
  194. } else {
  195. selectionIndex.value = -1;
  196. vTextFieldRef.value.setSelectionRange(0, 0);
  197. }
  198. }
  199. }
  200. function onInput(e) {
  201. search.value = e.target.value;
  202. }
  203. function onChange(e) {
  204. if (matchesSelector(vTextFieldRef.value, ':autofill') || matchesSelector(vTextFieldRef.value, ':-webkit-autofill')) {
  205. const item = items.value.find(item => item.title === e.target.value);
  206. if (item) {
  207. select(item);
  208. }
  209. }
  210. }
  211. function onAfterLeave() {
  212. if (isFocused.value) {
  213. isPristine.value = true;
  214. vTextFieldRef.value?.focus();
  215. }
  216. }
  217. function onFocusin(e) {
  218. isFocused.value = true;
  219. setTimeout(() => {
  220. listHasFocus.value = true;
  221. });
  222. }
  223. function onFocusout(e) {
  224. listHasFocus.value = false;
  225. }
  226. function onUpdateModelValue(v) {
  227. if (v == null || v === '' && !props.multiple) model.value = [];
  228. }
  229. const isSelecting = shallowRef(false);
  230. function select(item) {
  231. if (props.multiple) {
  232. const index = selected.value.findIndex(selection => props.valueComparator(selection, item.value));
  233. if (index === -1) {
  234. model.value = [...model.value, item];
  235. } else {
  236. const value = [...model.value];
  237. value.splice(index, 1);
  238. model.value = value;
  239. }
  240. } else {
  241. model.value = [item];
  242. isSelecting.value = true;
  243. search.value = item.title;
  244. menu.value = false;
  245. isPristine.value = true;
  246. nextTick(() => isSelecting.value = false);
  247. }
  248. }
  249. watch(isFocused, (val, oldVal) => {
  250. if (val === oldVal) return;
  251. if (val) {
  252. isSelecting.value = true;
  253. search.value = props.multiple ? '' : String(selections.value.at(-1)?.props.title ?? '');
  254. isPristine.value = true;
  255. nextTick(() => isSelecting.value = false);
  256. } else {
  257. if (!props.multiple && !search.value) model.value = [];else if (highlightFirst.value && !listHasFocus.value && !selections.value.some(_ref2 => {
  258. let {
  259. value
  260. } = _ref2;
  261. return value === displayItems.value[0].value;
  262. })) {
  263. select(displayItems.value[0]);
  264. }
  265. menu.value = false;
  266. search.value = '';
  267. selectionIndex.value = -1;
  268. }
  269. });
  270. watch(search, val => {
  271. if (!isFocused.value || isSelecting.value) return;
  272. if (val) menu.value = true;
  273. isPristine.value = !val;
  274. });
  275. useRender(() => {
  276. const hasChips = !!(props.chips || slots.chip);
  277. const hasList = !!(!props.hideNoData || displayItems.value.length || slots['prepend-item'] || slots['append-item'] || slots['no-data']);
  278. const isDirty = model.value.length > 0;
  279. const [textFieldProps] = VTextField.filterProps(props);
  280. return _createVNode(VTextField, _mergeProps({
  281. "ref": vTextFieldRef
  282. }, textFieldProps, {
  283. "modelValue": search.value,
  284. "onUpdate:modelValue": onUpdateModelValue,
  285. "focused": isFocused.value,
  286. "onUpdate:focused": $event => isFocused.value = $event,
  287. "validationValue": model.externalValue,
  288. "dirty": isDirty,
  289. "onInput": onInput,
  290. "onChange": onChange,
  291. "class": ['v-autocomplete', `v-autocomplete--${props.multiple ? 'multiple' : 'single'}`, {
  292. 'v-autocomplete--active-menu': menu.value,
  293. 'v-autocomplete--chips': !!props.chips,
  294. 'v-autocomplete--selection-slot': !!slots.selection,
  295. 'v-autocomplete--selecting-index': selectionIndex.value > -1
  296. }, props.class],
  297. "style": props.style,
  298. "readonly": props.readonly,
  299. "placeholder": isDirty ? undefined : props.placeholder,
  300. "onClick:clear": onClear,
  301. "onMousedown:control": onMousedownControl,
  302. "onKeydown": onKeydown
  303. }), {
  304. ...slots,
  305. default: () => _createVNode(_Fragment, null, [_createVNode(VMenu, _mergeProps({
  306. "ref": vMenuRef,
  307. "modelValue": menu.value,
  308. "onUpdate:modelValue": $event => menu.value = $event,
  309. "activator": "parent",
  310. "contentClass": "v-autocomplete__content",
  311. "disabled": menuDisabled.value,
  312. "eager": props.eager,
  313. "maxHeight": 310,
  314. "openOnClick": false,
  315. "closeOnContentClick": false,
  316. "transition": props.transition,
  317. "onAfterLeave": onAfterLeave
  318. }, props.menuProps), {
  319. default: () => [hasList && _createVNode(VList, {
  320. "ref": listRef,
  321. "selected": selected.value,
  322. "selectStrategy": props.multiple ? 'independent' : 'single-independent',
  323. "onMousedown": e => e.preventDefault(),
  324. "onKeydown": onListKeydown,
  325. "onFocusin": onFocusin,
  326. "onFocusout": onFocusout,
  327. "onScrollPassive": onListScroll,
  328. "tabindex": "-1",
  329. "color": props.itemColor ?? props.color
  330. }, {
  331. default: () => [slots['prepend-item']?.(), !displayItems.value.length && !props.hideNoData && (slots['no-data']?.() ?? _createVNode(VListItem, {
  332. "title": t(props.noDataText)
  333. }, null)), _createVNode(VVirtualScroll, {
  334. "renderless": true,
  335. "items": displayItems.value
  336. }, {
  337. default: _ref3 => {
  338. let {
  339. item,
  340. index,
  341. itemRef
  342. } = _ref3;
  343. const itemProps = mergeProps(item.props, {
  344. ref: itemRef,
  345. key: index,
  346. active: highlightFirst.value && index === 0 ? true : undefined,
  347. onClick: () => select(item)
  348. });
  349. return slots.item?.({
  350. item,
  351. index,
  352. props: itemProps
  353. }) ?? _createVNode(VListItem, itemProps, {
  354. prepend: _ref4 => {
  355. let {
  356. isSelected
  357. } = _ref4;
  358. return _createVNode(_Fragment, null, [props.multiple && !props.hideSelected ? _createVNode(VCheckboxBtn, {
  359. "key": item.value,
  360. "modelValue": isSelected,
  361. "ripple": false,
  362. "tabindex": "-1"
  363. }, null) : undefined, item.props.prependIcon && _createVNode(VIcon, {
  364. "icon": item.props.prependIcon
  365. }, null)]);
  366. },
  367. title: () => {
  368. return isPristine.value ? item.title : highlightResult(item.title, getMatches(item)?.title, search.value?.length ?? 0);
  369. }
  370. });
  371. }
  372. }), slots['append-item']?.()]
  373. })]
  374. }), selections.value.map((item, index) => {
  375. function onChipClose(e) {
  376. e.stopPropagation();
  377. e.preventDefault();
  378. select(item);
  379. }
  380. const slotProps = {
  381. 'onClick:close': onChipClose,
  382. onMousedown(e) {
  383. e.preventDefault();
  384. e.stopPropagation();
  385. },
  386. modelValue: true,
  387. 'onUpdate:modelValue': undefined
  388. };
  389. return _createVNode("div", {
  390. "key": item.value,
  391. "class": ['v-autocomplete__selection', index === selectionIndex.value && ['v-autocomplete__selection--selected', textColorClasses.value]],
  392. "style": index === selectionIndex.value ? textColorStyles.value : {}
  393. }, [hasChips ? !slots.chip ? _createVNode(VChip, _mergeProps({
  394. "key": "chip",
  395. "closable": props.closableChips,
  396. "size": "small",
  397. "text": item.title
  398. }, slotProps), null) : _createVNode(VDefaultsProvider, {
  399. "key": "chip-defaults",
  400. "defaults": {
  401. VChip: {
  402. closable: props.closableChips,
  403. size: 'small',
  404. text: item.title
  405. }
  406. }
  407. }, {
  408. default: () => [slots.chip?.({
  409. item,
  410. index,
  411. props: slotProps
  412. })]
  413. }) : slots.selection?.({
  414. item,
  415. index
  416. }) ?? _createVNode("span", {
  417. "class": "v-autocomplete__selection-text"
  418. }, [item.title, props.multiple && index < selections.value.length - 1 && _createVNode("span", {
  419. "class": "v-autocomplete__selection-comma"
  420. }, [_createTextVNode(",")])])]);
  421. })]),
  422. 'append-inner': function () {
  423. for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) {
  424. args[_key] = arguments[_key];
  425. }
  426. return _createVNode(_Fragment, null, [slots['append-inner']?.(...args), props.menuIcon ? _createVNode(VIcon, {
  427. "class": "v-autocomplete__menu-icon",
  428. "icon": props.menuIcon,
  429. "onMousedown": onMousedownMenuIcon,
  430. "onClick": noop
  431. }, null) : undefined]);
  432. }
  433. });
  434. });
  435. return forwardRefs({
  436. isFocused,
  437. isPristine,
  438. menu,
  439. search,
  440. filteredItems,
  441. select
  442. }, vTextFieldRef);
  443. }
  444. });
  445. //# sourceMappingURL=VAutocomplete.mjs.map