712 lines
No EOL
20 KiB
JavaScript
712 lines
No EOL
20 KiB
JavaScript
// 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 (<v-menu attach>)
|
|
this.attach === true || // If bound to a boolean (<v-menu :attach="true">)
|
|
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
|