// Styles import "../../../src/components/VTextField/VTextField.sass"; import "../../../src/components/VSelect/VSelect.sass"; // Components import VChip from '../VChip'; import VMenu from '../VMenu'; import VSelectList from './VSelectList'; // Extensions import VTextField from '../VTextField/VTextField'; // Mixins import Comparable from '../../mixins/comparable'; import Filterable from '../../mixins/filterable'; // Directives import ClickOutside from '../../directives/click-outside'; // Utilities import { getPropertyFromItem, keyCodes } from '../../util/helpers'; import { consoleError } from '../../util/console'; // Types import mixins from '../../util/mixins'; export const defaultMenuProps = { closeOnClick: false, closeOnContentClick: false, disableKeys: true, openOnClick: false, maxHeight: 300 }; const baseMixins = mixins(VTextField, Comparable, Filterable); /* @vue/component */ export default baseMixins.extend().extend({ name: 'v-select', directives: { ClickOutside }, props: { appendIcon: { type: String, default: '$vuetify.icons.dropdown' }, attach: { default: false }, cacheItems: Boolean, chips: Boolean, clearable: Boolean, deletableChips: Boolean, dense: Boolean, eager: Boolean, hideSelected: Boolean, items: { type: Array, default: () => [] }, itemColor: { type: String, default: 'primary' }, itemDisabled: { type: [String, Array, Function], default: 'disabled' }, itemText: { type: [String, Array, Function], default: 'text' }, itemValue: { type: [String, Array, Function], default: 'value' }, menuProps: { type: [String, Array, Object], default: () => defaultMenuProps }, multiple: Boolean, openOnClear: Boolean, returnObject: Boolean, smallChips: Boolean }, data() { return { cachedItems: this.cacheItems ? this.items : [], content: null, isBooted: false, isMenuActive: false, lastItem: 20, // As long as a value is defined, show it // Otherwise, check if multiple // to determine which default to provide lazyValue: this.value !== undefined ? this.value : this.multiple ? [] : undefined, selectedIndex: -1, selectedItems: [], keyboardLookupPrefix: '', keyboardLookupLastTime: 0 }; }, computed: { /* All items that the select has */ allItems() { return this.filterDuplicates(this.cachedItems.concat(this.items)); }, classes() { return { ...VTextField.options.computed.classes.call(this), 'v-select': true, 'v-select--chips': this.hasChips, 'v-select--chips--small': this.smallChips, 'v-select--is-menu-active': this.isMenuActive }; }, /* Used by other components to overwrite */ computedItems() { return this.allItems; }, computedOwns() { return `list-${this._uid}`; }, counterValue() { return this.multiple ? this.selectedItems.length : (this.getText(this.selectedItems[0]) || '').toString().length; }, directives() { return this.isFocused ? [{ name: 'click-outside', value: this.blur, args: { closeConditional: this.closeConditional } }] : undefined; }, dynamicHeight() { return 'auto'; }, hasChips() { return this.chips || this.smallChips; }, hasSlot() { return Boolean(this.hasChips || this.$scopedSlots.selection); }, isDirty() { return this.selectedItems.length > 0; }, listData() { const scopeId = this.$vnode && this.$vnode.context.$options._scopeId; const attrs = scopeId ? { [scopeId]: true } : {}; return { attrs: { ...attrs, id: this.computedOwns }, props: { action: this.multiple, color: this.itemColor, dense: this.dense, hideSelected: this.hideSelected, items: this.virtualizedItems, noDataText: this.$vuetify.lang.t(this.noDataText), selectedItems: this.selectedItems, itemDisabled: this.itemDisabled, itemValue: this.itemValue, itemText: this.itemText }, on: { select: this.selectItem }, scopedSlots: { item: this.$scopedSlots.item } }; }, staticList() { if (this.$slots['no-data'] || this.$slots['prepend-item'] || this.$slots['append-item']) { consoleError('assert: staticList should not be called if slots are used'); } return this.$createElement(VSelectList, this.listData); }, virtualizedItems() { return this.$_menuProps.auto ? this.computedItems : this.computedItems.slice(0, this.lastItem); }, menuCanShow: () => true, $_menuProps() { let normalisedProps = typeof this.menuProps === 'string' ? this.menuProps.split(',') : this.menuProps; if (Array.isArray(normalisedProps)) { normalisedProps = normalisedProps.reduce((acc, p) => { acc[p.trim()] = true; return acc; }, {}); } return { ...defaultMenuProps, eager: this.eager, value: this.menuCanShow && this.isMenuActive, nudgeBottom: normalisedProps.offsetY ? 1 : 0, ...normalisedProps }; } }, watch: { internalValue(val) { this.initialValue = val; this.setSelectedItems(); }, isBooted() { this.$nextTick(() => { if (this.content && this.content.addEventListener) { this.content.addEventListener('scroll', this.onScroll, false); } }); }, isMenuActive(val) { this.$nextTick(() => this.onMenuActiveChange(val)); if (!val) return; this.isBooted = true; }, items: { immediate: true, handler(val) { if (this.cacheItems) { // Breaks vue-test-utils if // this isn't calculated // on the next tick this.$nextTick(() => { this.cachedItems = this.filterDuplicates(this.cachedItems.concat(val)); }); } this.setSelectedItems(); } } }, mounted() { this.content = this.$refs.menu && this.$refs.menu.$refs.content; }, methods: { /** @public */ blur(e) { VTextField.options.methods.blur.call(this, e); this.isMenuActive = false; this.isFocused = false; this.selectedIndex = -1; }, /** @public */ activateMenu() { if (this.disabled || this.readonly || this.isMenuActive) return; this.isMenuActive = true; }, clearableCallback() { this.setValue(this.multiple ? [] : undefined); this.$nextTick(() => this.$refs.input && this.$refs.input.focus()); if (this.openOnClear) this.isMenuActive = true; }, closeConditional(e) { return (// Click originates from outside the menu content this.content && !this.content.contains(e.target) && // Click originates from outside the element this.$el && !this.$el.contains(e.target) && e.target !== this.$el ); }, filterDuplicates(arr) { const uniqueValues = new Map(); for (let index = 0; index < arr.length; ++index) { const item = arr[index]; const val = this.getValue(item); // TODO: comparator !uniqueValues.has(val) && uniqueValues.set(val, item); } return Array.from(uniqueValues.values()); }, findExistingIndex(item) { const itemValue = this.getValue(item); return (this.internalValue || []).findIndex(i => this.valueComparator(this.getValue(i), itemValue)); }, genChipSelection(item, index) { const isDisabled = this.disabled || this.readonly || this.getDisabled(item); return this.$createElement(VChip, { staticClass: 'v-chip--select', attrs: { tabindex: -1 }, props: { close: this.deletableChips && !isDisabled, disabled: isDisabled, inputValue: index === this.selectedIndex, small: this.smallChips }, on: { click: e => { if (isDisabled) return; e.stopPropagation(); this.selectedIndex = index; }, focus, 'click:close': () => this.onChipInput(item) }, key: JSON.stringify(this.getValue(item)) }, this.getText(item)); }, genCommaSelection(item, index, last) { const color = index === this.selectedIndex && this.color; const isDisabled = this.disabled || this.getDisabled(item); return this.$createElement('div', this.setTextColor(color, { staticClass: 'v-select__selection v-select__selection--comma', class: { 'v-select__selection--disabled': isDisabled }, key: JSON.stringify(this.getValue(item)) }), `${this.getText(item)}${last ? '' : ', '}`); }, genDefaultSlot() { const selections = this.genSelections(); const input = this.genInput(); // If the return is an empty array // push the input if (Array.isArray(selections)) { selections.push(input); // Otherwise push it into children } else { selections.children = selections.children || []; selections.children.push(input); } return [this.genFieldset(), this.$createElement('div', { staticClass: 'v-select__slot', directives: this.directives }, [this.genLabel(), this.prefix ? this.genAffix('prefix') : null, selections, this.suffix ? this.genAffix('suffix') : null, this.genClearIcon(), this.genIconSlot()]), this.genMenu(), this.genProgress()]; }, genInput() { const input = VTextField.options.methods.genInput.call(this); input.data.domProps.value = null; input.data.attrs.readonly = true; input.data.attrs.type = 'text'; input.data.attrs['aria-readonly'] = true; input.data.on.keypress = this.onKeyPress; return input; }, genInputSlot() { const render = VTextField.options.methods.genInputSlot.call(this); render.data.attrs = { ...render.data.attrs, role: 'button', 'aria-haspopup': 'listbox', 'aria-expanded': String(this.isMenuActive), 'aria-owns': this.computedOwns }; return render; }, genList() { // If there's no slots, we can use a cached VNode to improve performance if (this.$slots['no-data'] || this.$slots['prepend-item'] || this.$slots['append-item']) { return this.genListWithSlot(); } else { return this.staticList; } }, genListWithSlot() { const slots = ['prepend-item', 'no-data', 'append-item'].filter(slotName => this.$slots[slotName]).map(slotName => this.$createElement('template', { slot: slotName }, this.$slots[slotName])); // Requires destructuring due to Vue // modifying the `on` property when passed // as a referenced object return this.$createElement(VSelectList, { ...this.listData }, slots); }, genMenu() { const props = this.$_menuProps; props.activator = this.$refs['input-slot']; // Attach to root el so that // menu covers prepend/append icons if ( // TODO: make this a computed property or helper or something this.attach === '' || // If used as a boolean prop () this.attach === true || // If bound to a boolean () this.attach === 'attach' // If bound as boolean prop in pug (v-menu(attach)) ) { props.attach = this.$el; } else { props.attach = this.attach; } return this.$createElement(VMenu, { attrs: { role: undefined }, props, on: { input: val => { this.isMenuActive = val; this.isFocused = val; } }, ref: 'menu' }, [this.genList()]); }, genSelections() { let length = this.selectedItems.length; const children = new Array(length); let genSelection; if (this.$scopedSlots.selection) { genSelection = this.genSlotSelection; } else if (this.hasChips) { genSelection = this.genChipSelection; } else { genSelection = this.genCommaSelection; } while (length--) { children[length] = genSelection(this.selectedItems[length], length, length === children.length - 1); } return this.$createElement('div', { staticClass: 'v-select__selections' }, children); }, genSlotSelection(item, index) { return this.$scopedSlots.selection({ attrs: { class: 'v-chip--select' }, parent: this, item, index, select: e => { e.stopPropagation(); this.selectedIndex = index; }, selected: index === this.selectedIndex, disabled: this.disabled || this.readonly }); }, getMenuIndex() { return this.$refs.menu ? this.$refs.menu.listIndex : -1; }, getDisabled(item) { return getPropertyFromItem(item, this.itemDisabled, false); }, getText(item) { return getPropertyFromItem(item, this.itemText, item); }, getValue(item) { return getPropertyFromItem(item, this.itemValue, this.getText(item)); }, onBlur(e) { e && this.$emit('blur', e); }, onChipInput(item) { if (this.multiple) this.selectItem(item);else this.setValue(null); // If all items have been deleted, // open `v-menu` if (this.selectedItems.length === 0) { this.isMenuActive = true; } else { this.isMenuActive = false; } this.selectedIndex = -1; }, onClick() { if (this.isDisabled) return; this.isMenuActive = true; if (!this.isFocused) { this.isFocused = true; this.$emit('focus'); } }, onEscDown(e) { e.preventDefault(); if (this.isMenuActive) { e.stopPropagation(); this.isMenuActive = false; } }, onKeyPress(e) { if (this.multiple) return; const KEYBOARD_LOOKUP_THRESHOLD = 1000; // milliseconds const now = performance.now(); if (now - this.keyboardLookupLastTime > KEYBOARD_LOOKUP_THRESHOLD) { this.keyboardLookupPrefix = ''; } this.keyboardLookupPrefix += e.key.toLowerCase(); this.keyboardLookupLastTime = now; const index = this.allItems.findIndex(item => { const text = (this.getText(item) || '').toString(); return text.toLowerCase().startsWith(this.keyboardLookupPrefix); }); const item = this.allItems[index]; if (index !== -1) { this.setValue(this.returnObject ? item : this.getValue(item)); setTimeout(() => this.setMenuIndex(index)); } }, onKeyDown(e) { const keyCode = e.keyCode; const menu = this.$refs.menu; // If enter, space, open menu if ([keyCodes.enter, keyCodes.space].includes(keyCode)) this.activateMenu(); if (!menu) return; // If menu is active, allow default // listIndex change from menu if (this.isMenuActive && keyCode !== keyCodes.tab) { menu.changeListIndex(e); } // If menu is not active, up and down can do // one of 2 things. If multiple, opens the // menu, if not, will cycle through all // available options if (!this.isMenuActive && [keyCodes.up, keyCodes.down].includes(keyCode)) return this.onUpDown(e); // If escape deactivate the menu if (keyCode === keyCodes.esc) return this.onEscDown(e); // If tab - select item or close menu if (keyCode === keyCodes.tab) return this.onTabDown(e); // If space preventDefault if (keyCode === keyCodes.space) return this.onSpaceDown(e); }, onMenuActiveChange(val) { // If menu is closing and mulitple // or menuIndex is already set // skip menu index recalculation if (this.multiple && !val || this.getMenuIndex() > -1) return; const menu = this.$refs.menu; if (!menu || !this.isDirty) return; // When menu opens, set index of first active item for (let i = 0; i < menu.tiles.length; i++) { if (menu.tiles[i].getAttribute('aria-selected') === 'true') { this.setMenuIndex(i); break; } } }, onMouseUp(e) { if (this.hasMouseDown && e.which !== 3) { const appendInner = this.$refs['append-inner']; // If append inner is present // and the target is itself // or inside, toggle menu if (this.isMenuActive && appendInner && (appendInner === e.target || appendInner.contains(e.target))) { this.$nextTick(() => this.isMenuActive = !this.isMenuActive); // If user is clicking in the container // and field is enclosed, activate it } else if (this.isEnclosed && !this.isDisabled) { this.isMenuActive = true; } } VTextField.options.methods.onMouseUp.call(this, e); }, onScroll() { if (!this.isMenuActive) { requestAnimationFrame(() => this.content.scrollTop = 0); } else { if (this.lastItem >= this.computedItems.length) return; const showMoreItems = this.content.scrollHeight - (this.content.scrollTop + this.content.clientHeight) < 200; if (showMoreItems) { this.lastItem += 20; } } }, onSpaceDown(e) { e.preventDefault(); }, onTabDown(e) { const menu = this.$refs.menu; if (!menu) return; const activeTile = menu.activeTile; // An item that is selected by // menu-index should toggled if (!this.multiple && activeTile && this.isMenuActive) { e.preventDefault(); e.stopPropagation(); activeTile.click(); } else { // If we make it here, // the user has no selected indexes // and is probably tabbing out this.blur(e); } }, onUpDown(e) { const menu = this.$refs.menu; if (!menu) return; e.preventDefault(); // Multiple selects do not cycle their value // when pressing up or down, instead activate // the menu if (this.multiple) return this.activateMenu(); const keyCode = e.keyCode; // Cycle through available values to achieve // select native behavior menu.getTiles(); keyCodes.up === keyCode ? menu.prevTile() : menu.nextTile(); menu.activeTile && menu.activeTile.click(); }, selectItem(item) { if (!this.multiple) { this.setValue(this.returnObject ? item : this.getValue(item)); this.isMenuActive = false; } else { const internalValue = (this.internalValue || []).slice(); const i = this.findExistingIndex(item); i !== -1 ? internalValue.splice(i, 1) : internalValue.push(item); this.setValue(internalValue.map(i => { return this.returnObject ? i : this.getValue(i); })); // When selecting multiple // adjust menu after each // selection this.$nextTick(() => { this.$refs.menu && this.$refs.menu.updateDimensions(); }); // We only need to reset list index for multiple // to keep highlight when an item is toggled // on and off if (!this.multiple) return; const listIndex = this.getMenuIndex(); this.setMenuIndex(-1); // There is no item to re-highlight // when selections are hidden if (this.hideSelected) return; this.$nextTick(() => this.setMenuIndex(listIndex)); } }, setMenuIndex(index) { this.$refs.menu && (this.$refs.menu.listIndex = index); }, setSelectedItems() { const selectedItems = []; const values = !this.multiple || !Array.isArray(this.internalValue) ? [this.internalValue] : this.internalValue; for (const value of values) { const index = this.allItems.findIndex(v => this.valueComparator(this.getValue(v), this.getValue(value))); if (index > -1) { selectedItems.push(this.allItems[index]); } } this.selectedItems = selectedItems; }, setValue(value) { const oldValue = this.internalValue; this.internalValue = value; value !== oldValue && this.$emit('change', value); } } }); //# sourceMappingURL=VSelect.js.map