UNPKG

vuetify

Version:

Vue.js 2 Semantic Component Framework

703 lines (637 loc) 19.3 kB
// Styles import '../../stylus/components/_text-fields.styl' import '../../stylus/components/_select.styl' // 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' // Helpers import { camelize, getPropertyFromItem, keyCodes } from '../../util/helpers' import { consoleError, consoleWarn } from '../../util/console' export const defaultMenuProps = { closeOnClick: false, closeOnContentClick: false, openOnClick: false, maxHeight: 300 } /* @vue/component */ export default { name: 'v-select', directives: { ClickOutside }, extends: VTextField, mixins: [ Comparable, Filterable ], props: { appendIcon: { type: String, default: '$vuetify.icons.dropdown' }, appendIconCb: Function, attach: { type: null, default: false }, browserAutocomplete: { type: String, default: 'on' }, cacheItems: Boolean, chips: Boolean, clearable: Boolean, deletableChips: Boolean, dense: Boolean, hideSelected: Boolean, items: { type: Array, default: () => [] }, itemAvatar: { type: [String, Array, Function], default: 'avatar' }, 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, searchInput: { default: null }, smallChips: Boolean }, data: vm => ({ attrsInput: { role: 'combobox' }, cachedItems: vm.cacheItems ? vm.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: vm.value !== undefined ? vm.value : vm.multiple ? [] : undefined, selectedIndex: -1, selectedItems: [] }), computed: { /* All items that the select has */ allItems () { return this.filterDuplicates(this.cachedItems.concat(this.items)) }, classes () { return Object.assign({}, VTextField.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 }, 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 return { attrs: scopeId ? { [scopeId]: true } : null, props: { action: this.multiple && !this.isHidingSelected, color: this.color, dense: this.dense, hideSelected: this.hideSelected, items: this.virtualizedItems, noDataText: this.$vuetify.t(this.noDataText), selectedItems: this.selectedItems, itemAvatar: this.itemAvatar, 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 () { return true }, $_menuProps () { let normalisedProps 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, value: this.menuCanShow && this.isMenuActive, nudgeBottom: this.nudgeBottom ? this.nudgeBottom : normalisedProps.offsetY ? 1 : 0, // convert to int ...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) { if (!val) return this.isBooted = true }, items: { immediate: true, handler (val) { if (this.cacheItems) { this.cachedItems = this.filterDuplicates(this.cachedItems.concat(val)) } this.setSelectedItems() } } }, mounted () { this.content = this.$refs.menu && this.$refs.menu.$refs.content }, methods: { /** @public */ blur () { this.isMenuActive = false this.isFocused = false this.$refs.input && this.$refs.input.blur() this.selectedIndex = -1 }, /** @public */ activateMenu () { this.isMenuActive = true }, clearableCallback () { this.setValue(this.multiple ? [] : undefined) this.$nextTick(() => 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) ) const focus = (e, cb) => { if (isDisabled) return e.stopPropagation() this.onFocus() cb && cb() } return this.$createElement(VChip, { staticClass: 'v-chip--select-multi', props: { close: this.deletableChips && !isDisabled, disabled: isDisabled, selected: index === this.selectedIndex, small: this.smallChips }, on: { click: e => { focus(e, () => { this.selectedIndex = index }) }, focus, input: () => this.onChipInput(item) }, key: this.getValue(item) }, this.getText(item)) }, genCommaSelection (item, index, last) { // Item may be an object // TODO: Remove JSON.stringify const key = JSON.stringify(this.getValue(item)) 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 }), `${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.$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.methods.genInput.call(this) input.data.domProps.value = null input.data.attrs.readonly = true input.data.attrs['aria-readonly'] = String(this.readonly) return input }, 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'] // Deprecate using menu props directly // TODO: remove (2.0) const inheritedProps = Object.keys(VMenu.options.props) const deprecatedProps = Object.keys(this.$attrs).reduce((acc, attr) => { if (inheritedProps.includes(camelize(attr))) acc.push(attr) return acc }, []) for (const prop of deprecatedProps) { props[camelize(prop)] = this.$attrs[prop] } if (process.env.NODE_ENV !== 'production') { if (deprecatedProps.length) { const multiple = deprecatedProps.length > 1 let replacement = deprecatedProps.reduce((acc, p) => { acc[camelize(p)] = this.$attrs[p] return acc }, {}) const props = deprecatedProps.map(p => `'${p}'`).join(', ') const separator = multiple ? '\n' : '\'' const onlyBools = Object.keys(replacement).every(prop => { const propType = VMenu.options.props[prop] const value = replacement[prop] return value === true || ((propType.type || propType) === Boolean && value === '') }) if (onlyBools) { replacement = Object.keys(replacement).join(', ') } else { replacement = JSON.stringify(replacement, null, multiple ? 2 : 0) .replace(/"([^(")"]+)":/g, '$1:') .replace(/"/g, '\'') } consoleWarn( `${props} ${multiple ? 'are' : 'is'} deprecated, use ` + `${separator}${onlyBools ? '' : ':'}menu-props="${replacement}"${separator} instead`, this ) } } // 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, { 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({ parent: this, item, 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) { this.blur() 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 } this.selectedIndex = -1 }, onClick () { if (this.isDisabled) return this.isMenuActive = true if (!this.isFocused) { this.isFocused = true this.$emit('focus') } }, onEnterDown () { this.$emit('blur', undefined) }, onEscDown (e) { e.preventDefault() if (this.isMenuActive) { e.stopPropagation() this.isMenuActive = false } }, onKeyDown (e) { const keyCode = e.keyCode // If enter, space, up, or down is pressed, open menu if (!this.readonly && !this.isMenuActive && [ keyCodes.enter, keyCodes.space, keyCodes.up, keyCodes.down ].includes(keyCode)) this.activateMenu() if (this.isMenuActive && this.$refs.menu) this.$refs.menu.changeListIndex(e) // This should do something different if (keyCode === keyCodes.enter) return this.onEnterDown(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) }, onMouseUp (e) { if (this.hasMouseDown) { 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.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 } } }, onTabDown (e) { const menuIndex = this.getMenuIndex() const listTile = this.$refs.menu.tiles[menuIndex] // An item that is selected by // menu-index should toggled if ( listTile && listTile.className.indexOf('v-list__tile--highlighted') > -1 && this.isMenuActive && menuIndex > -1 ) { e.preventDefault() e.stopPropagation() listTile.click() } else { // If we make it here, // the user has no selected indexes // and is probably tabbing out VTextField.methods.onBlur.call(this, e) } }, 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() }) } }, 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) { this.internalValue = value this.$emit('change', value) } } }