VSelect.mjs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431
  1. import { createTextVNode as _createTextVNode, mergeProps as _mergeProps, createVNode as _createVNode, Fragment as _Fragment } from "vue";
  2. // Styles
  3. import "./VSelect.css";
  4. // Components
  5. import { VDialogTransition } from "../transitions/index.mjs";
  6. import { VAvatar } from "../VAvatar/index.mjs";
  7. import { VCheckboxBtn } from "../VCheckbox/index.mjs";
  8. import { VChip } from "../VChip/index.mjs";
  9. import { VDefaultsProvider } from "../VDefaultsProvider/index.mjs";
  10. import { VIcon } from "../VIcon/index.mjs";
  11. import { VList, VListItem } from "../VList/index.mjs";
  12. import { VMenu } from "../VMenu/index.mjs";
  13. import { makeVTextFieldProps, VTextField } from "../VTextField/VTextField.mjs";
  14. import { VVirtualScroll } from "../VVirtualScroll/index.mjs"; // Composables
  15. import { useScrolling } from "./useScrolling.mjs";
  16. import { useForm } from "../../composables/form.mjs";
  17. import { forwardRefs } from "../../composables/forwardRefs.mjs";
  18. import { IconValue } from "../../composables/icons.mjs";
  19. import { makeItemsProps, 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 { checkPrintable, ensureValidVNode, genericComponent, IN_BROWSER, matchesSelector, omit, propsFactory, useRender, wrapInArray } from "../../util/index.mjs"; // Types
  25. export const makeSelectProps = propsFactory({
  26. chips: Boolean,
  27. closableChips: Boolean,
  28. closeText: {
  29. type: String,
  30. default: '$vuetify.close'
  31. },
  32. openText: {
  33. type: String,
  34. default: '$vuetify.open'
  35. },
  36. eager: Boolean,
  37. hideNoData: Boolean,
  38. hideSelected: Boolean,
  39. listProps: {
  40. type: Object
  41. },
  42. menu: Boolean,
  43. menuIcon: {
  44. type: IconValue,
  45. default: '$dropdown'
  46. },
  47. menuProps: {
  48. type: Object
  49. },
  50. multiple: Boolean,
  51. noDataText: {
  52. type: String,
  53. default: '$vuetify.noDataText'
  54. },
  55. openOnClear: Boolean,
  56. itemColor: String,
  57. ...makeItemsProps({
  58. itemChildren: false
  59. })
  60. }, 'Select');
  61. export const makeVSelectProps = propsFactory({
  62. ...makeSelectProps(),
  63. ...omit(makeVTextFieldProps({
  64. modelValue: null,
  65. role: 'combobox'
  66. }), ['validationValue', 'dirty', 'appendInnerIcon']),
  67. ...makeTransitionProps({
  68. transition: {
  69. component: VDialogTransition
  70. }
  71. })
  72. }, 'VSelect');
  73. export const VSelect = genericComponent()({
  74. name: 'VSelect',
  75. props: makeVSelectProps(),
  76. emits: {
  77. 'update:focused': focused => true,
  78. 'update:modelValue': value => true,
  79. 'update:menu': ue => true
  80. },
  81. setup(props, _ref) {
  82. let {
  83. slots
  84. } = _ref;
  85. const {
  86. t
  87. } = useLocale();
  88. const vTextFieldRef = ref();
  89. const vMenuRef = ref();
  90. const vVirtualScrollRef = ref();
  91. const _menu = useProxiedModel(props, 'menu');
  92. const menu = computed({
  93. get: () => _menu.value,
  94. set: v => {
  95. if (_menu.value && !v && vMenuRef.value?.ΨopenChildren.size) return;
  96. _menu.value = v;
  97. }
  98. });
  99. const {
  100. items,
  101. transformIn,
  102. transformOut
  103. } = useItems(props);
  104. const model = useProxiedModel(props, 'modelValue', [], v => transformIn(v === null ? [null] : wrapInArray(v)), v => {
  105. const transformed = transformOut(v);
  106. return props.multiple ? transformed : transformed[0] ?? null;
  107. });
  108. const counterValue = computed(() => {
  109. return typeof props.counterValue === 'function' ? props.counterValue(model.value) : typeof props.counterValue === 'number' ? props.counterValue : model.value.length;
  110. });
  111. const form = useForm(props);
  112. const selectedValues = computed(() => model.value.map(selection => selection.value));
  113. const isFocused = shallowRef(false);
  114. const label = computed(() => menu.value ? props.closeText : props.openText);
  115. let keyboardLookupPrefix = '';
  116. let keyboardLookupLastTime;
  117. const displayItems = computed(() => {
  118. if (props.hideSelected) {
  119. return items.value.filter(item => !model.value.some(s => props.valueComparator(s, item)));
  120. }
  121. return items.value;
  122. });
  123. const menuDisabled = computed(() => props.hideNoData && !displayItems.value.length || form.isReadonly.value || form.isDisabled.value);
  124. const computedMenuProps = computed(() => {
  125. return {
  126. ...props.menuProps,
  127. activatorProps: {
  128. ...(props.menuProps?.activatorProps || {}),
  129. 'aria-haspopup': 'listbox' // Set aria-haspopup to 'listbox'
  130. }
  131. };
  132. });
  133. const listRef = ref();
  134. const listEvents = useScrolling(listRef, vTextFieldRef);
  135. function onClear(e) {
  136. if (props.openOnClear) {
  137. menu.value = true;
  138. }
  139. }
  140. function onMousedownControl() {
  141. if (menuDisabled.value) return;
  142. menu.value = !menu.value;
  143. }
  144. function onListKeydown(e) {
  145. if (checkPrintable(e)) {
  146. onKeydown(e);
  147. }
  148. }
  149. function onKeydown(e) {
  150. if (!e.key || form.isReadonly.value) return;
  151. if (['Enter', ' ', 'ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) {
  152. e.preventDefault();
  153. }
  154. if (['Enter', 'ArrowDown', ' '].includes(e.key)) {
  155. menu.value = true;
  156. }
  157. if (['Escape', 'Tab'].includes(e.key)) {
  158. menu.value = false;
  159. }
  160. if (e.key === 'Home') {
  161. listRef.value?.focus('first');
  162. } else if (e.key === 'End') {
  163. listRef.value?.focus('last');
  164. }
  165. // html select hotkeys
  166. const KEYBOARD_LOOKUP_THRESHOLD = 1000; // milliseconds
  167. if (props.multiple || !checkPrintable(e)) return;
  168. const now = performance.now();
  169. if (now - keyboardLookupLastTime > KEYBOARD_LOOKUP_THRESHOLD) {
  170. keyboardLookupPrefix = '';
  171. }
  172. keyboardLookupPrefix += e.key.toLowerCase();
  173. keyboardLookupLastTime = now;
  174. const item = items.value.find(item => item.title.toLowerCase().startsWith(keyboardLookupPrefix));
  175. if (item !== undefined) {
  176. model.value = [item];
  177. const index = displayItems.value.indexOf(item);
  178. IN_BROWSER && window.requestAnimationFrame(() => {
  179. index >= 0 && vVirtualScrollRef.value?.scrollToIndex(index);
  180. });
  181. }
  182. }
  183. /** @param set - null means toggle */
  184. function select(item) {
  185. let set = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : true;
  186. if (item.props.disabled) return;
  187. if (props.multiple) {
  188. const index = model.value.findIndex(selection => props.valueComparator(selection.value, item.value));
  189. const add = set == null ? !~index : set;
  190. if (~index) {
  191. const value = add ? [...model.value, item] : [...model.value];
  192. value.splice(index, 1);
  193. model.value = value;
  194. } else if (add) {
  195. model.value = [...model.value, item];
  196. }
  197. } else {
  198. const add = set !== false;
  199. model.value = add ? [item] : [];
  200. nextTick(() => {
  201. menu.value = false;
  202. });
  203. }
  204. }
  205. function onBlur(e) {
  206. if (!listRef.value?.$el.contains(e.relatedTarget)) {
  207. menu.value = false;
  208. }
  209. }
  210. function onAfterEnter() {
  211. if (props.eager) {
  212. vVirtualScrollRef.value?.calculateVisibleItems();
  213. }
  214. }
  215. function onAfterLeave() {
  216. if (isFocused.value) {
  217. vTextFieldRef.value?.focus();
  218. }
  219. }
  220. function onFocusin(e) {
  221. isFocused.value = true;
  222. }
  223. function onModelUpdate(v) {
  224. if (v == null) model.value = [];else if (matchesSelector(vTextFieldRef.value, ':autofill') || matchesSelector(vTextFieldRef.value, ':-webkit-autofill')) {
  225. const item = items.value.find(item => item.title === v);
  226. if (item) {
  227. select(item);
  228. }
  229. } else if (vTextFieldRef.value) {
  230. vTextFieldRef.value.value = '';
  231. }
  232. }
  233. watch(menu, () => {
  234. if (!props.hideSelected && menu.value && model.value.length) {
  235. const index = displayItems.value.findIndex(item => model.value.some(s => props.valueComparator(s.value, item.value)));
  236. IN_BROWSER && window.requestAnimationFrame(() => {
  237. index >= 0 && vVirtualScrollRef.value?.scrollToIndex(index);
  238. });
  239. }
  240. });
  241. watch(() => props.items, (newVal, oldVal) => {
  242. if (menu.value) return;
  243. if (isFocused.value && !oldVal.length && newVal.length) {
  244. menu.value = true;
  245. }
  246. });
  247. useRender(() => {
  248. const hasChips = !!(props.chips || slots.chip);
  249. const hasList = !!(!props.hideNoData || displayItems.value.length || slots['prepend-item'] || slots['append-item'] || slots['no-data']);
  250. const isDirty = model.value.length > 0;
  251. const textFieldProps = VTextField.filterProps(props);
  252. const placeholder = isDirty || !isFocused.value && props.label && !props.persistentPlaceholder ? undefined : props.placeholder;
  253. return _createVNode(VTextField, _mergeProps({
  254. "ref": vTextFieldRef
  255. }, textFieldProps, {
  256. "modelValue": model.value.map(v => v.props.value).join(', '),
  257. "onUpdate:modelValue": onModelUpdate,
  258. "focused": isFocused.value,
  259. "onUpdate:focused": $event => isFocused.value = $event,
  260. "validationValue": model.externalValue,
  261. "counterValue": counterValue.value,
  262. "dirty": isDirty,
  263. "class": ['v-select', {
  264. 'v-select--active-menu': menu.value,
  265. 'v-select--chips': !!props.chips,
  266. [`v-select--${props.multiple ? 'multiple' : 'single'}`]: true,
  267. 'v-select--selected': model.value.length,
  268. 'v-select--selection-slot': !!slots.selection
  269. }, props.class],
  270. "style": props.style,
  271. "inputmode": "none",
  272. "placeholder": placeholder,
  273. "onClick:clear": onClear,
  274. "onMousedown:control": onMousedownControl,
  275. "onBlur": onBlur,
  276. "onKeydown": onKeydown,
  277. "aria-label": t(label.value),
  278. "title": t(label.value)
  279. }), {
  280. ...slots,
  281. default: () => _createVNode(_Fragment, null, [_createVNode(VMenu, _mergeProps({
  282. "ref": vMenuRef,
  283. "modelValue": menu.value,
  284. "onUpdate:modelValue": $event => menu.value = $event,
  285. "activator": "parent",
  286. "contentClass": "v-select__content",
  287. "disabled": menuDisabled.value,
  288. "eager": props.eager,
  289. "maxHeight": 310,
  290. "openOnClick": false,
  291. "closeOnContentClick": false,
  292. "transition": props.transition,
  293. "onAfterEnter": onAfterEnter,
  294. "onAfterLeave": onAfterLeave
  295. }, computedMenuProps.value), {
  296. default: () => [hasList && _createVNode(VList, _mergeProps({
  297. "ref": listRef,
  298. "selected": selectedValues.value,
  299. "selectStrategy": props.multiple ? 'independent' : 'single-independent',
  300. "onMousedown": e => e.preventDefault(),
  301. "onKeydown": onListKeydown,
  302. "onFocusin": onFocusin,
  303. "tabindex": "-1",
  304. "aria-live": "polite",
  305. "color": props.itemColor ?? props.color
  306. }, listEvents, props.listProps), {
  307. default: () => [slots['prepend-item']?.(), !displayItems.value.length && !props.hideNoData && (slots['no-data']?.() ?? _createVNode(VListItem, {
  308. "key": "no-data",
  309. "title": t(props.noDataText)
  310. }, null)), _createVNode(VVirtualScroll, {
  311. "ref": vVirtualScrollRef,
  312. "renderless": true,
  313. "items": displayItems.value
  314. }, {
  315. default: _ref2 => {
  316. let {
  317. item,
  318. index,
  319. itemRef
  320. } = _ref2;
  321. const itemProps = mergeProps(item.props, {
  322. ref: itemRef,
  323. key: item.value,
  324. onClick: () => select(item, null)
  325. });
  326. return slots.item?.({
  327. item,
  328. index,
  329. props: itemProps
  330. }) ?? _createVNode(VListItem, _mergeProps(itemProps, {
  331. "role": "option"
  332. }), {
  333. prepend: _ref3 => {
  334. let {
  335. isSelected
  336. } = _ref3;
  337. return _createVNode(_Fragment, null, [props.multiple && !props.hideSelected ? _createVNode(VCheckboxBtn, {
  338. "key": item.value,
  339. "modelValue": isSelected,
  340. "ripple": false,
  341. "tabindex": "-1"
  342. }, null) : undefined, item.props.prependAvatar && _createVNode(VAvatar, {
  343. "image": item.props.prependAvatar
  344. }, null), item.props.prependIcon && _createVNode(VIcon, {
  345. "icon": item.props.prependIcon
  346. }, null)]);
  347. }
  348. });
  349. }
  350. }), slots['append-item']?.()]
  351. })]
  352. }), model.value.map((item, index) => {
  353. function onChipClose(e) {
  354. e.stopPropagation();
  355. e.preventDefault();
  356. select(item, false);
  357. }
  358. const slotProps = {
  359. 'onClick:close': onChipClose,
  360. onKeydown(e) {
  361. if (e.key !== 'Enter' && e.key !== ' ') return;
  362. e.preventDefault();
  363. e.stopPropagation();
  364. onChipClose(e);
  365. },
  366. onMousedown(e) {
  367. e.preventDefault();
  368. e.stopPropagation();
  369. },
  370. modelValue: true,
  371. 'onUpdate:modelValue': undefined
  372. };
  373. const hasSlot = hasChips ? !!slots.chip : !!slots.selection;
  374. const slotContent = hasSlot ? ensureValidVNode(hasChips ? slots.chip({
  375. item,
  376. index,
  377. props: slotProps
  378. }) : slots.selection({
  379. item,
  380. index
  381. })) : undefined;
  382. if (hasSlot && !slotContent) return undefined;
  383. return _createVNode("div", {
  384. "key": item.value,
  385. "class": "v-select__selection"
  386. }, [hasChips ? !slots.chip ? _createVNode(VChip, _mergeProps({
  387. "key": "chip",
  388. "closable": props.closableChips,
  389. "size": "small",
  390. "text": item.title,
  391. "disabled": item.props.disabled
  392. }, slotProps), null) : _createVNode(VDefaultsProvider, {
  393. "key": "chip-defaults",
  394. "defaults": {
  395. VChip: {
  396. closable: props.closableChips,
  397. size: 'small',
  398. text: item.title
  399. }
  400. }
  401. }, {
  402. default: () => [slotContent]
  403. }) : slotContent ?? _createVNode("span", {
  404. "class": "v-select__selection-text"
  405. }, [item.title, props.multiple && index < model.value.length - 1 && _createVNode("span", {
  406. "class": "v-select__selection-comma"
  407. }, [_createTextVNode(",")])])]);
  408. })]),
  409. 'append-inner': function () {
  410. for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) {
  411. args[_key] = arguments[_key];
  412. }
  413. return _createVNode(_Fragment, null, [slots['append-inner']?.(...args), props.menuIcon ? _createVNode(VIcon, {
  414. "class": "v-select__menu-icon",
  415. "icon": props.menuIcon
  416. }, null) : undefined]);
  417. }
  418. });
  419. });
  420. return forwardRefs({
  421. isFocused,
  422. menu,
  423. select
  424. }, vTextFieldRef);
  425. }
  426. });
  427. //# sourceMappingURL=VSelect.mjs.map