UNPKG

vuetify

Version:

Vue Material Component Framework

869 lines (778 loc) 24.6 kB
// Styles import '../VTextField/VTextField.sass' import './VSelect.sass' // Components import VChip from '../VChip' import VMenu from '../VMenu' import VSelectList from './VSelectList' // Extensions import VInput from '../VInput' import VTextField from '../VTextField/VTextField' // Mixins import Comparable from '../../mixins/comparable' import Dependent from '../../mixins/dependent' import Filterable from '../../mixins/filterable' // Directives import ClickOutside from '../../directives/click-outside' // Utilities import mergeData from '../../util/mergeData' import { getPropertyFromItem, getObjectValueByPath, keyCodes } from '../../util/helpers' import { consoleError } from '../../util/console' // Types import mixins from '../../util/mixins' import { VNode, VNodeDirective, PropType, VNodeData } from 'vue' import { PropValidator } from 'vue/types/options' import { SelectItemKey } from 'vuetify/types' export const defaultMenuProps = { closeOnClick: false, closeOnContentClick: false, disableKeys: true, openOnClick: false, maxHeight: 304, } // Types const baseMixins = mixins( VTextField, Comparable, Dependent, Filterable ) interface options extends InstanceType<typeof baseMixins> { $refs: { menu: InstanceType<typeof VMenu> content: HTMLElement label: HTMLElement input: HTMLInputElement 'prepend-inner': HTMLElement 'append-inner': HTMLElement prefix: HTMLElement suffix: HTMLElement } } /* @vue/component */ export default baseMixins.extend<options>().extend({ name: 'v-select', directives: { ClickOutside, }, props: { appendIcon: { type: String, default: '$dropdown', }, attach: { type: null as unknown as PropType<string | boolean | Element | VNode>, default: false, }, cacheItems: Boolean, chips: Boolean, clearable: Boolean, deletableChips: Boolean, disableLookup: Boolean, eager: Boolean, hideSelected: Boolean, items: { type: Array, default: () => [], } as PropValidator<any[]>, itemColor: { type: String, default: 'primary', }, itemDisabled: { type: [String, Array, Function] as PropType<SelectItemKey>, default: 'disabled', }, itemText: { type: [String, Array, Function] as PropType<SelectItemKey>, default: 'text', }, itemValue: { type: [String, Array, Function] as PropType<SelectItemKey>, default: 'value', }, menuProps: { type: [String, Array, Object], default: () => defaultMenuProps, }, multiple: Boolean, openOnClear: Boolean, returnObject: Boolean, smallChips: Boolean, }, data () { return { cachedItems: this.cacheItems ? this.items : [], menuIsBooted: 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: [] as any[], keyboardLookupPrefix: '', keyboardLookupLastTime: 0, } }, computed: { /* All items that the select has */ allItems (): object[] { return this.filterDuplicates(this.cachedItems.concat(this.items)) }, classes (): object { 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, 'v-select--is-multi': this.multiple, } }, /* Used by other components to overwrite */ computedItems (): object[] { return this.allItems }, computedOwns (): string { return `list-${this._uid}` }, computedCounterValue (): number { return this.multiple ? this.selectedItems.length : (this.getText(this.selectedItems[0]) || '').toString().length }, directives (): VNodeDirective[] | undefined { return this.isFocused ? [{ name: 'click-outside', value: { handler: this.blur, closeConditional: this.closeConditional, include: () => this.getOpenDependentElements(), }, }] : undefined }, dynamicHeight () { return 'auto' }, hasChips (): boolean { return this.chips || this.smallChips }, hasSlot (): boolean { return Boolean(this.hasChips || this.$scopedSlots.selection) }, isDirty (): boolean { return this.selectedItems.length > 0 }, listData (): object { const scopeId = this.$vnode && (this.$vnode.context!.$options as { [key: string]: any })._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, itemDisabled: this.itemDisabled, itemText: this.itemText, itemValue: this.itemValue, noDataText: this.$vuetify.lang.t(this.noDataText), selectedItems: this.selectedItems, }, on: { select: this.selectItem, }, scopedSlots: { item: this.$scopedSlots.item, }, } }, staticList (): VNode { 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 (): object[] { return (this.$_menuProps as any).auto ? this.computedItems : this.computedItems.slice(0, this.lastItem) }, menuCanShow: () => true, $_menuProps (): object { 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, // convert to int ...normalisedProps, } }, }, watch: { internalValue (val) { this.initialValue = val this.setSelectedItems() }, isMenuActive (val) { window.setTimeout(() => this.onMenuActiveChange(val)) }, 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() }, }, }, methods: { /** @public */ blur (e?: Event) { VTextField.options.methods.blur.call(this, e) this.isMenuActive = false this.isFocused = false this.selectedIndex = -1 }, /** @public */ activateMenu () { if ( !this.isInteractive || this.isMenuActive ) return this.isMenuActive = true }, clearableCallback () { this.setValue(this.multiple ? [] : undefined) this.setMenuIndex(-1) this.$nextTick(() => this.$refs.input && this.$refs.input.focus()) if (this.openOnClear) this.isMenuActive = true }, closeConditional (e: Event) { if (!this.isMenuActive) return true return ( !this._isDestroyed && // Click originates from outside the menu content // Multiple selects don't close when an item is clicked (!this.getContent() || !this.getContent().contains(e.target as Node)) && // Click originates from outside the element this.$el && !this.$el.contains(e.target as Node) && e.target !== this.$el ) }, filterDuplicates (arr: any[]) { const uniqueValues = new Map() for (let index = 0; index < arr.length; ++index) { const item = arr[index] // Do not deduplicate headers or dividers (#12517) if (item.header || item.divider) { uniqueValues.set(item, item) continue } const val = this.getValue(item) // TODO: comparator !uniqueValues.has(val) && uniqueValues.set(val, item) } return Array.from(uniqueValues.values()) }, findExistingIndex (item: object) { const itemValue = this.getValue(item) return (this.internalValue || []).findIndex((i: object) => this.valueComparator(this.getValue(i), itemValue)) }, getContent () { return this.$refs.menu && this.$refs.menu.$refs.content }, genChipSelection (item: object, index: number) { const isDisabled = ( !this.isInteractive || 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: MouseEvent) => { if (isDisabled) return e.stopPropagation() this.selectedIndex = index }, 'click:close': () => this.onChipInput(item), }, key: JSON.stringify(this.getValue(item)), }, this.getText(item)) }, genCommaSelection (item: object, index: number, last: boolean) { const color = index === this.selectedIndex && this.computedColor const isDisabled = ( !this.isInteractive || 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 (): (VNode | VNode[] | null)[] { 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.genHiddenInput(), ]), this.genMenu(), this.genProgress(), ] }, genIcon ( type: string, cb?: (e: Event) => void, extraData?: VNodeData ) { const icon = VInput.options.methods.genIcon.call(this, type, cb, extraData) if (type === 'append') { // Don't allow the dropdown icon to be focused icon.children![0].data = mergeData(icon.children![0].data!, { attrs: { tabindex: icon.children![0].componentOptions!.listeners && '-1', 'aria-hidden': 'true', 'aria-label': undefined, }, }) } return icon }, genInput (): VNode { const input = VTextField.options.methods.genInput.call(this) delete input.data!.attrs!.name input.data = mergeData(input.data!, { domProps: { value: null }, attrs: { readonly: true, type: 'text', 'aria-readonly': String(this.isReadonly), 'aria-activedescendant': getObjectValueByPath(this.$refs.menu, 'activeTile.id'), autocomplete: getObjectValueByPath(input.data!, 'attrs.autocomplete', 'off'), }, on: { keypress: this.onKeyPress }, }) return input }, genHiddenInput (): VNode { return this.$createElement('input', { domProps: { value: this.lazyValue }, attrs: { type: 'hidden', name: this.attrs$.name, }, }) }, genInputSlot (): VNode { 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 (): VNode { // 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 (): VNode { 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 (): VNode { const props = this.$_menuProps as any 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: boolean) => { this.isMenuActive = val this.isFocused = val }, scroll: this.onScroll, }, ref: 'menu', }, [this.genList()]) }, genSelections (): VNode { 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: object, index: number): VNode[] | undefined { return this.$scopedSlots.selection!({ attrs: { class: 'v-chip--select', }, parent: this, item, index, select: (e: Event) => { e.stopPropagation() this.selectedIndex = index }, selected: index === this.selectedIndex, disabled: !this.isInteractive, }) }, getMenuIndex () { return this.$refs.menu ? (this.$refs.menu as { [key: string]: any }).listIndex : -1 }, getDisabled (item: object) { return getPropertyFromItem(item, this.itemDisabled, false) }, getText (item: object) { return getPropertyFromItem(item, this.itemText, item) }, getValue (item: object) { return getPropertyFromItem(item, this.itemValue, this.getText(item)) }, onBlur (e?: Event) { e && this.$emit('blur', e) }, onChipInput (item: object) { 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 (e: MouseEvent) { if (!this.isInteractive) return if (!this.isAppendInner(e.target)) { this.isMenuActive = true } if (!this.isFocused) { this.isFocused = true this.$emit('focus') } this.$emit('click', e) }, onEscDown (e: Event) { e.preventDefault() if (this.isMenuActive) { e.stopPropagation() this.isMenuActive = false } }, onKeyPress (e: KeyboardEvent) { if ( this.multiple || !this.isInteractive || this.disableLookup ) 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.lastItem = Math.max(this.lastItem, index + 5) this.setValue(this.returnObject ? item : this.getValue(item)) this.$nextTick(() => this.$refs.menu.getTiles()) setTimeout(() => this.setMenuIndex(index)) } }, onKeyDown (e: KeyboardEvent) { if (this.isReadonly && e.keyCode !== keyCodes.tab) return const keyCode = e.keyCode const menu = this.$refs.menu // If enter, space, open menu if ([ keyCodes.enter, keyCodes.space, ].includes(keyCode)) this.activateMenu() this.$emit('keydown', e) if (!menu) return // If menu is active, allow default // listIndex change from menu if (this.isMenuActive && keyCode !== keyCodes.tab) { this.$nextTick(() => { menu.changeListIndex(e) this.$emit('update:list-index', menu.listIndex) }) } // 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: boolean) { // 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: MouseEvent) { // eslint-disable-next-line sonarjs/no-collapsible-if if ( this.hasMouseDown && e.which !== 3 && this.isInteractive ) { // If append inner is present // and the target is itself // or inside, toggle menu if (this.isAppendInner(e.target)) { this.$nextTick(() => (this.isMenuActive = !this.isMenuActive)) } } VTextField.options.methods.onMouseUp.call(this, e) }, onScroll () { if (!this.isMenuActive) { requestAnimationFrame(() => (this.getContent().scrollTop = 0)) } else { if (this.lastItem > this.computedItems.length) return const showMoreItems = ( this.getContent().scrollHeight - (this.getContent().scrollTop + this.getContent().clientHeight) ) < 200 if (showMoreItems) { this.lastItem += 20 } } }, onSpaceDown (e: KeyboardEvent) { e.preventDefault() }, onTabDown (e: KeyboardEvent) { 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: KeyboardEvent) { 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.isBooted = true window.requestAnimationFrame(() => { menu.getTiles() keyCodes.up === keyCode ? menu.prevTile() : menu.nextTile() menu.activeTile && menu.activeTile.click() }) }, selectItem (item: object) { 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: object) => { return this.returnObject ? i : this.getValue(i) })) // When selecting multiple // adjust menu after each // selection this.$nextTick(() => { this.$refs.menu && (this.$refs.menu as { [key: string]: any }).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: number) { this.$refs.menu && ((this.$refs.menu as { [key: string]: any }).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: any) { const oldValue = this.internalValue this.internalValue = value value !== oldValue && this.$emit('change', value) }, isAppendInner (target: any) { // return true if append inner is present // and the target is itself or inside const appendInner = this.$refs['append-inner'] return appendInner && (appendInner === target || appendInner.contains(target)) }, }, })