diff --git a/src/components/select/functional-options.vue b/src/components/select/functional-options.vue new file mode 100644 index 0000000..a319709 --- /dev/null +++ b/src/components/select/functional-options.vue @@ -0,0 +1,29 @@ + +<script> + const returnArrayFn = () => []; + + export default { + props: { + options: { + type: Array, + default: returnArrayFn + }, + slotOptions: { + type: Array, + default: returnArrayFn + }, + slotUpdateHook: { + type: Function, + default: () => {} + }, + }, + functional: true, + render(h, {props, parent}){ + // to detect changes in the $slot children/options we do this hack + // so we can trigger the parents computed properties and have everything reactive + // although $slot.default is not + if (props.slotOptions !== parent.$slots.default) props.slotUpdateHook(); + return props.options; + } + }; +</script> diff --git a/src/components/select/option.vue b/src/components/select/option.vue index 0733bb8..749bb0c 100644 --- a/src/components/select/option.vue +++ b/src/components/select/option.vue @@ -1,5 +1,5 @@ <template> - <li :class="classes" @click.stop="select" @mouseout.stop="blur" v-show="!hidden"><slot>{{ showLabel }}</slot></li> + <li :class="classes" @click.stop="select"><slot>{{ showLabel }}</slot></li> </template> <script> import Emitter from '../../mixins/emitter'; @@ -22,15 +22,19 @@ disabled: { type: Boolean, default: false + }, + selected: { + type: Boolean, + default: false + }, + isFocused: { + type: Boolean, + default: false } }, data () { return { - selected: false, - index: 0, // for up and down to focus - isFocus: false, - hidden: false, // for search - searchLabel: '', // the value is slot,only for search + searchLabel: '', // the slot value (textContent) autoComplete: false }; }, @@ -41,53 +45,34 @@ { [`${prefixCls}-disabled`]: this.disabled, [`${prefixCls}-selected`]: this.selected && !this.autoComplete, - [`${prefixCls}-focus`]: this.isFocus + [`${prefixCls}-focus`]: this.isFocused } ]; }, showLabel () { return (this.label) ? this.label : this.value; + }, + optionLabel(){ + return (this.$el && this.$el.textContent) || this.label; } }, methods: { select () { - if (this.disabled) { - return false; - } + if (this.disabled) return false; - this.dispatch('iSelect', 'on-select-selected', this.value); + this.dispatch('iSelect', 'on-select-selected', { + value: this.value, + label: this.optionLabel, + }); + this.$emit('on-select-selected', { + value: this.value, + label: this.optionLabel, + }); }, - blur () { - this.isFocus = false; - }, - queryChange (val) { - const parsedQuery = val.replace(/(\^|\(|\)|\[|\]|\$|\*|\+|\.|\?|\\|\{|\}|\|)/g, '\\$1'); - this.hidden = !new RegExp(parsedQuery, 'i').test(this.searchLabel); - }, - // 在使用函数防抖后,设置 key 后,不更新组件了,导致SearchLabel 不更新 #1865 - updateSearchLabel () { - this.searchLabel = this.$el.textContent; - }, - onSelectClose(){ - this.isFocus = false; - }, - onQueryChange(val){ - this.queryChange(val); - } }, mounted () { - this.updateSearchLabel(); - this.dispatch('iSelect', 'append'); - this.$on('on-select-close', this.onSelectClose); - this.$on('on-query-change',this.onQueryChange); - const Select = findComponentUpward(this, 'iSelect'); if (Select) this.autoComplete = Select.autoComplete; }, - beforeDestroy () { - this.dispatch('iSelect', 'remove'); - this.$off('on-select-close', this.onSelectClose); - this.$off('on-query-change',this.onQueryChange); - } }; </script> diff --git a/src/components/select/select-head.vue b/src/components/select/select-head.vue new file mode 100644 index 0000000..f617ca5 --- /dev/null +++ b/src/components/select/select-head.vue @@ -0,0 +1,196 @@ +<template> + <div @click="onHeaderClick"> + <div class="ivu-tag ivu-tag-checked" v-for="item in selectedMultiple"> + <span class="ivu-tag-text">{{ item.label }}</span> + <Icon type="ios-close-empty" @click.native.stop="removeTag(item)"></Icon> + </div> + <span + :class="singleDisplayClasses" + v-show="singleDisplayValue" + >{{ singleDisplayValue }}</span> + <input + :id="inputElementId" + type="text" + v-if="filterable" + v-model="query" + :disabled="disabled" + :class="[prefixCls + '-input']" + :placeholder="showPlaceholder ? localePlaceholder : ''" + :style="inputStyle" + autocomplete="off" + spellcheck="false" + @keydown="resetInputState" + @keydown.delete="handleInputDelete" + @focus="onInputFocus" + @blur="onInputFocus" + + ref="input"> + <Icon type="ios-close" :class="[prefixCls + '-arrow']" v-if="resetSelect" @click.native.stop="resetSelect"></Icon> + <Icon type="arrow-down-b" :class="[prefixCls + '-arrow']" v-if="!resetSelect && !remote && !disabled"></Icon> + </div> +</template> +<script> + import Icon from '../icon'; + import Emitter from '../../mixins/emitter'; + import Locale from '../../mixins/locale'; + + const prefixCls = 'ivu-select'; + + export default { + name: 'iSelectHead', + mixins: [ Emitter, Locale ], + components: { Icon }, + props: { + disabled: { + type: Boolean, + default: false + }, + filterable: { + type: Boolean, + default: false + }, + multiple: { + type: Boolean, + default: false + }, + remote: { + type: Boolean, + default: false + }, + initialLabel: { + type: String, + }, + values: { + type: Array, + default: () => [] + }, + clearable: { + type: [Function, Boolean], + default: false, + }, + inputElementId: { + type: String + }, + placeholder: { + type: String + }, + queryProp: { + type: String, + default: '' + } + }, + data () { + return { + prefixCls: prefixCls, + query: '', + inputLength: 20, + remoteInitialLabel: this.initialLabel, + preventRemoteCall: false, + }; + }, + computed: { + singleDisplayClasses(){ + const {filterable, multiple, showPlaceholder} = this; + return [{ + [prefixCls + '-placeholder']: showPlaceholder && !filterable, + [prefixCls + '-selected-value']: !showPlaceholder && !multiple && !filterable, + }]; + }, + singleDisplayValue(){ + if ((this.multiple && this.values.length > 0) || this.filterable) return ''; + return `${this.selectedSingle}` || this.localePlaceholder; + }, + showPlaceholder () { + let status = false; + if (!this.multiple) { + const value = this.values[0]; + if (typeof value === 'undefined' || String(value).trim() === ''){ + status = !this.remoteInitialLabel; + } + } else { + if (!this.values.length > 0) { + status = true; + } + } + return status; + }, + resetSelect(){ + return !this.showPlaceholder && this.clearable; + }, + inputStyle () { + let style = {}; + + if (this.multiple) { + if (this.showPlaceholder) { + style.width = '100%'; + } else { + style.width = `${this.inputLength}px`; + } + } + + return style; + }, + localePlaceholder () { + if (this.placeholder === undefined) { + return this.t('i.select.placeholder'); + } else { + return this.placeholder; + } + }, + selectedSingle(){ + const selected = this.values[0]; + return selected ? selected.label : (this.remoteInitialLabel || ''); + }, + selectedMultiple(){ + return this.multiple ? this.values : []; + } + }, + methods: { + onInputFocus(e){ + this.$emit(e.type === 'focus' ? 'on-input-focus' : 'on-input-blur'); + }, + removeTag (value) { + if (this.disabled) return false; + this.dispatch('iSelect', 'on-select-selected', value); + }, + resetInputState () { + this.inputLength = this.$refs.input.value.length * 12 + 20; + }, + handleInputDelete () { + if (this.multiple && this.selectedMultiple.length && this.query === '') { + this.removeTag(this.selectedMultiple[this.selectedMultiple.length - 1]); + } + }, + onHeaderClick(e){ + if (this.filterable && e.target === this.$el){ + this.$refs.input.focus(); + } + } + }, + watch: { + values ([value]) { + if (!this.filterable) return; + this.preventRemoteCall = true; + if (this.multiple){ + this.query = ''; + this.preventRemoteCall = false; // this should be after the query change setter above + return; + } + // #982 + if (typeof value === 'undefined' || value === '' || value === null) this.query = ''; + else this.query = value.label; + }, + query (val) { + if (this.preventRemoteCall) { + this.preventRemoteCall = false; + return; + } + + this.$emit('on-query-change', val); + }, + queryProp(query){ + if (query !== this.query) this.query = query; + }, + } + }; +</script> diff --git a/src/components/select/select.vue b/src/components/select/select.vue index 7a8ea3f..ba98364 100644 --- a/src/components/select/select.vue +++ b/src/components/select/select.vue @@ -1,38 +1,50 @@ <template> <div - tabindex="0" - @keydown.down="handleFocus" :class="classes" - v-clickoutside="handleClose"> + v-click-outside.capture="onClickOutside" + > <div - :class="selectionCls" ref="reference" - @click="toggleMenu"> + + :class="selectionCls" + :tabindex="selectTabindex" + + @blur="toggleHeaderFocus" + @focus="toggleHeaderFocus" + + @click="toggleMenu" + @keydown.esc="handleKeydown" + @keydown.enter="handleKeydown" + @keydown.up="handleKeydown" + @keydown.down="handleKeydown" + @keydown.tab="handleKeydown" + @keydown.delete="handleKeydown" + + + @mouseenter="hasMouseHoverHead = true" + @mouseleave="hasMouseHoverHead = false" + + > <slot name="input"> - <input type="hidden" :name="name" :value="model"> - <div class="ivu-tag ivu-tag-checked" v-for="(item, index) in selectedMultiple"> - <span class="ivu-tag-text">{{ item.label }}</span> - <Icon type="ios-close-empty" @click.native.stop="removeTag(index)"></Icon> - </div> - <span :class="[prefixCls + '-placeholder']" v-show="showPlaceholder && !filterable">{{ localePlaceholder }}</span> - <span :class="[prefixCls + '-selected-value']" v-show="!showPlaceholder && !multiple && !filterable">{{ selectedSingle }}</span> - <input - :id="elementId" - type="text" - v-if="filterable" - v-model="query" + <input type="hidden" :name="name" :value="publicValue"> + <select-head + :filterable="filterable" + :multiple="multiple" + :values="values" + :clearable="canBeCleared" :disabled="disabled" - :class="[prefixCls + '-input']" - :placeholder="showPlaceholder ? localePlaceholder : ''" - :style="inputStyle" - autocomplete="off" - spellcheck="false" - @blur="handleBlur" - @keydown="resetInputState" - @keydown.delete="handleInputDelete" - ref="input"> - <Icon type="ios-close" :class="[prefixCls + '-arrow']" v-show="showCloseIcon" @click.native.stop="clearSingleSelect"></Icon> - <Icon type="arrow-down-b" :class="[prefixCls + '-arrow']" v-if="!remote"></Icon> + :remote="remote" + :input-element-id="elementId" + :initial-label="initialLabel" + :placeholder="placeholder" + :query-prop="query" + + @on-query-change="onQueryChange" + @on-input-focus="isFocused = true" + @on-input-blur="isFocused = false" + + ref="selectHead" + /> </slot> </div> <transition name="transition-drop"> @@ -42,9 +54,17 @@ :placement="placement" ref="dropdown" :data-transfer="transfer" - v-transfer-dom> - <ul v-show="notFoundShow" :class="[prefixCls + '-not-found']"><li>{{ localeNotFoundText }}</li></ul> - <ul v-show="(!notFound && !remote) || (remote && !loading && !notFound)" :class="[prefixCls + '-dropdown-list']"><slot></slot></ul> + v-transfer-dom + > + <ul v-show="showNotFoundLabel" :class="[prefixCls + '-not-found']"><li>{{ localeNotFoundText }}</li></ul> + <ul :class="prefixCls + '-dropdown-list'"> + <functional-options + v-if="(!remote) || (remote && !loading)" + :options="selectOptions" + :slot-update-hook="updateSlotOptions" + :slot-options="slotOptions" + ></functional-options> + </ul> <ul v-show="loading" :class="[prefixCls + '-loading']">{{ localeLoadingText }}</ul> </Drop> </transition> @@ -53,20 +73,32 @@ <script> import Icon from '../icon'; import Drop from './dropdown.vue'; - import clickoutside from '../../directives/clickoutside'; + import vClickOutside from 'v-click-outside-x/index'; import TransferDom from '../../directives/transfer-dom'; - import { oneOf, findComponentDownward } from '../../utils/assist'; + import { oneOf } from '../../utils/assist'; import Emitter from '../../mixins/emitter'; import Locale from '../../mixins/locale'; - import { debounce } from './utils'; + import SelectHead from './select-head.vue'; + import FunctionalOptions from './functional-options.vue'; const prefixCls = 'ivu-select'; + const optionGroupRegexp = /option\-?group/i; + + const findChild = (instance, checkFn) => { + let match = checkFn(instance); + if (match) return instance; + for (let i = 0, l = instance.$children.length; i < l; i++){ + const child = instance.$children[i]; + match = findChild(child, checkFn); + if (match) return match; + } + }; export default { name: 'iSelect', mixins: [ Emitter, Locale ], - components: { Icon, Drop }, - directives: { clickoutside, TransferDom }, + components: { FunctionalOptions, Drop, Icon, SelectHead }, + directives: { clickOutside: vClickOutside.directive, TransferDom }, props: { value: { type: [String, Number, Array], @@ -99,10 +131,6 @@ filterMethod: { type: Function }, - remote: { - type: Boolean, - default: false - }, remoteMethod: { type: Function }, @@ -147,23 +175,29 @@ type: String } }, + mounted(){ + this.$on('on-select-selected', this.onOptionClick); + + // set the initial values if there are any + if (this.values.length > 0 && !this.remote){ + this.values = this.values.map(this.getOptionData); + } + }, data () { + return { prefixCls: prefixCls, + values: this.getInitialValue(), + dropDownWidth: 0, visible: false, - options: [], - optionInstances: [], - selectedSingle: '', // label - selectedMultiple: [], - focusIndex: 0, + focusIndex: -1, + isFocused: false, query: '', - lastQuery: '', - selectToChangeQuery: false, // when select an option, set this first and set query, because query is watching, it will emit event - inputLength: 20, - notFound: false, - slotChangeDuration: false, // if slot change duration and in multiple, set true and after slot change, set false - model: this.value, - currentLabel: this.label + initialLabel: this.label, + hasMouseHoverHead: false, + slotOptions: this.$slots.default, + caretPosition: -1, + lastRemoteQuery: '', }; }, computed: { @@ -189,58 +223,19 @@ }, selectionCls () { return { - [`${prefixCls}-selection`]: !this.autoComplete + [`${prefixCls}-selection`]: !this.autoComplete, + [`${prefixCls}-selection-focused`]: this.isFocused }; }, - showPlaceholder () { - let status = false; - - if ((typeof this.model) === 'string') { - if (this.model === '') { - status = true; - } - } else if (Array.isArray(this.model)) { - if (!this.model.length) { - status = true; - } - } else if( this.model === null){ - status = true; - } - - return status; - }, - showCloseIcon () { - return !this.multiple && this.clearable && !this.showPlaceholder; - }, - inputStyle () { - let style = {}; - - if (this.multiple) { - if (this.showPlaceholder) { - style.width = '100%'; - } else { - style.width = `${this.inputLength}px`; - } - } - - return style; - }, - localePlaceholder () { - if (this.placeholder === undefined) { - return this.t('i.select.placeholder'); - } else { - return this.placeholder; - } - }, localeNotFoundText () { - if (this.notFoundText === undefined) { + if (typeof this.notFoundText === 'undefined') { return this.t('i.select.noMatch'); } else { return this.notFoundText; } }, localeLoadingText () { - if (this.loadingText === undefined) { + if (typeof this.loadingText === 'undefined') { return this.t('i.select.loading'); } else { return this.loadingText; @@ -251,574 +246,358 @@ }, dropVisible () { let status = true; - const options = this.$slots.default || []; + const options = this.selectOptions; if (!this.loading && this.remote && this.query === '' && !options.length) status = false; if (this.autoComplete && !options.length) status = false; return this.visible && status; }, - notFoundShow () { - const options = this.$slots.default || []; - return (this.notFound && !this.remote) || (this.remote && !this.loading && !options.length); - } - }, - methods: { - // open when focus on Select and press `down` key - handleFocus () { - if (!this.visible) this.toggleMenu(); - }, - toggleMenu () { - if (this.disabled || this.autoComplete) { - return false; - } - this.visible = !this.visible; + showNotFoundLabel () { + const {loading, remote, selectOptions} = this; + return selectOptions.length === 0 && (!remote || (remote && !loading)); }, - hideMenu () { - this.visible = false; - this.focusIndex = 0; - this.broadcast('iOption', 'on-select-close'); - }, - // find option component - findChild (cb) { - const find = function (child) { - const name = child.$options.componentName; - - if (name) { - cb(child); - } else if (child.$children.length) { - child.$children.forEach((innerChild) => { - find(innerChild, cb); - }); - } - }; - - if (this.optionInstances.length) { - this.optionInstances.forEach((child) => { - find(child); - }); + publicValue(){ + if (this.labelInValue){ + return this.multiple ? this.values : this.values[0]; } else { - this.$children.forEach((child) => { - find(child); - }); + return this.multiple ? this.values.map(option => option.value) : (this.values[0] || {}).value; } }, - updateOptions (slot = false) { - let options = []; - let index = 1; - - this.findChild((child) => { - options.push({ - value: child.value, - label: (child.label === undefined) ? child.$el.textContent : child.label - }); - child.index = index++; + canBeCleared(){ + const uiStateMatch = this.hasMouseHoverHead || this.active; + const qualifiesForClear = !this.multiple && this.clearable; + return uiStateMatch && qualifiesForClear && this.reset; // we return a function + }, + selectOptions() { + const selectOptions = []; + let optionCounter = -1; + const currentIndex = this.focusIndex; + const selectedValues = this.values.map(({value}) => value); + for (let option of (this.slotOptions || [])) { - this.optionInstances.push(child); - }); + if (!option.componentOptions) continue; - this.options = options; + if (option.componentOptions.tag.match(optionGroupRegexp)){ + let children = option.componentOptions.children; - if (!this.remote) { - this.updateSingleSelected(true, slot); - this.updateMultipleSelected(true, slot); - } - }, - updateSingleSelected (init = false, slot = false) { - const type = typeof this.model; + // remove filtered children + if (this.filterable){ + children = children.filter( + ({componentOptions}) => this.validateOption(componentOptions) + ); + } - if (type === 'string' || type === 'number') { - let findModel = false; + option.componentOptions.children = children.map(opt => { + optionCounter = optionCounter + 1; + return this.processOption(opt, selectedValues, optionCounter === currentIndex); + }); - for (let i = 0; i < this.options.length; i++) { - if (this.model === this.options[i].value) { - this.selectedSingle = this.options[i].label; - findModel = true; - break; - } - } + // keep the group if it still has children + if (option.componentOptions.children.length > 0) selectOptions.push({...option}); + } else { + // ignore option if not passing filter + const optionPassesFilter = this.filterable ? this.validateOption(option.componentOptions) : option; + if (!optionPassesFilter) continue; - if (slot && !findModel) { - this.model = ''; - this.query = ''; + optionCounter = optionCounter + 1; + selectOptions.push(this.processOption(option, selectedValues, optionCounter === currentIndex)); } } - this.toggleSingleSelected(this.model, init); + return selectOptions; }, - clearSingleSelect () { - if (this.showCloseIcon) { - this.findChild((child) => { - child.selected = false; - }); - this.model = ''; - - if (this.filterable) { - this.query = ''; - } + flatOptions(){ + return this.selectOptions.reduce((options, option) => { + const isOptionGroup = option.componentOptions.tag.match(optionGroupRegexp); + if (isOptionGroup) return options.concat(option.componentOptions.children || []); + else return options.concat(option); + }, []); + }, + selectTabindex(){ + return this.disabled || this.filterable ? -1 : 0; + }, + remote(){ + return typeof this.remoteMethod === 'function'; + } + }, + methods: { + setQuery(query){ // PUBLIC API + if (query) { + this.onQueryChange(query); + return; + } + if (query === null) { + this.onQueryChange(''); + this.values = []; } }, - updateMultipleSelected (init = false, slot = false) { - if (this.multiple && Array.isArray(this.model)) { - let selected = this.remote ? this.selectedMultiple : []; - - for (let i = 0; i < this.model.length; i++) { - const model = this.model[i]; - - for (let j = 0; j < this.options.length; j++) { - const option = this.options[j]; + clearSingleSelect(){ // PUBLIC API + if (this.clearable) this.values = []; + }, + getOptionData(value){ + const option = this.flatOptions.find(({componentOptions}) => componentOptions.propsData.value === value); + const textContent = option.componentOptions.children.reduce((str, child) => str + (child.text || ''), ''); + const label = option.componentOptions.propsData.label || textContent || ''; + return { + value: value, + label: label, + }; + }, + getInitialValue(){ + const {multiple, value} = this; + let initialValue = Array.isArray(value) ? value : [value]; + if (!multiple && (typeof initialValue[0] === 'undefined' || String(initialValue[0]).trim() === '')) initialValue = []; + return initialValue; + }, + processOption(option, values, isFocused){ + if (!option.componentOptions) return option; + const optionValue = option.componentOptions.propsData.value; + const disabled = option.componentOptions.propsData.disabled; + const isSelected = values.includes(optionValue); + + const propsData = { + ...option.componentOptions.propsData, + selected: isSelected, + isFocused: isFocused, + disabled: typeof disabled === 'undefined' ? false : disabled !== false, + }; - if (model === option.value) { - selected.push({ - value: option.value, - label: option.label - }); - } - } + return { + ...option, + componentOptions: { + ...option.componentOptions, + propsData: propsData } + }; + }, - const selectedArray = []; - const selectedObject = {}; - - selected.forEach(item => { - if (!selectedObject[item.value]) { - selectedArray.push(item); - selectedObject[item.value] = 1; - } - }); - - // #2066 - this.selectedMultiple = this.remote ? this.model.length ? selectedArray : [] : selected; - - if (slot) { - let selectedModel = []; - - for (let i = 0; i < selected.length; i++) { - selectedModel.push(selected[i].value); - } - - // if slot change and remove a selected option, emit user - if (this.model.length === selectedModel.length) { - this.slotChangeDuration = true; - } - - this.model = selectedModel; - } - } - this.toggleMultipleSelected(this.model, init); + validateOption({elm, propsData}){ + const value = propsData.value; + const label = propsData.label || ''; + const textContent = elm && elm.textContent || ''; + const stringValues = JSON.stringify([value, label, textContent]); + return stringValues.toLowerCase().includes(this.query.toLowerCase()); }, - removeTag (index) { - if (this.disabled) { - return false; - } - if (this.remote) { - const tag = this.model[index]; - this.selectedMultiple = this.selectedMultiple.filter(item => item.value !== tag); + toggleMenu (e, force) { + if (this.disabled || this.autoComplete) { + return false; } + this.focusIndex = -1; - this.model.splice(index, 1); - - if (this.filterable && this.visible) { - this.$refs.input.focus(); + this.visible = typeof force !== 'undefined' ? force : !this.visible; + if (this.visible){ + this.dropDownWidth = this.$el.getBoundingClientRect().width; } - - this.broadcast('Drop', 'on-update-popper'); }, - // to select option for single - toggleSingleSelected (value, init = false) { - if (!this.multiple) { - let label = ''; - - this.findChild((child) => { - if (child.value === value) { - child.selected = true; - label = (child.label === undefined) ? child.$el.innerHTML : child.label; - } else { - child.selected = false; - } - }); - - this.hideMenu(); - - if (!init) { - if (this.labelInValue) { - this.$emit('on-change', { - value: value, - label: label - }); - this.dispatch('FormItem', 'on-form-change', { - value: value, - label: label - }); - } else { - this.$emit('on-change', value); - this.dispatch('FormItem', 'on-form-change', value); - } - } - } + hideMenu () { + this.toggleMenu(null, false); }, - // to select option for multiple - toggleMultipleSelected (value, init = false) { - if (this.multiple) { - let hybridValue = []; - for (let i = 0; i < value.length; i++) { - hybridValue.push({ - value: value[i] + onClickOutside(event){ + if (this.visible) { + + if (this.filterable) { + const input = this.$refs.selectHead.$refs.input; + this.caretPosition = input.selectionStart; + this.$nextTick(() => { + const caretPosition = this.caretPosition === -1 ? input.value.length : this.caretPosition; + input.setSelectionRange(caretPosition, caretPosition); }); } - this.findChild((child) => { - const index = value.indexOf(child.value); - - if (index >= 0) { - child.selected = true; - hybridValue[index].label = (child.label === undefined) ? child.$el.innerHTML : child.label; - } else { - child.selected = false; - } - }); - - if (!init) { - if (this.labelInValue) { - this.$emit('on-change', hybridValue); - this.dispatch('FormItem', 'on-form-change', hybridValue); - } else { - this.$emit('on-change', value); - this.dispatch('FormItem', 'on-form-change', value); - } - } + event.stopPropagation(); + event.preventDefault(); + this.hideMenu(); + this.isFocused = true; + } else { + this.caretPosition = -1; + this.isFocused = false; } }, - handleClose () { - this.hideMenu(); + reset(){ + this.values = []; }, handleKeydown (e) { + if (e.key === 'Backspace'){ + return; // so we don't call preventDefault + } + if (this.visible) { - const keyCode = e.keyCode; + e.preventDefault(); + if (e.key === 'Tab'){ + e.stopPropagation(); + } + // Esc slide-up - if (keyCode === 27) { - e.preventDefault(); + if (e.key === 'Escape') { this.hideMenu(); } // next - if (keyCode === 40) { - e.preventDefault(); - this.navigateOptions('next'); + if (e.key === 'ArrowUp') { + this.navigateOptions(-1); } // prev - if (keyCode === 38) { - e.preventDefault(); - this.navigateOptions('prev'); + if (e.key === 'ArrowDown') { + this.navigateOptions(1); } // enter - if (keyCode === 13) { - e.preventDefault(); - - this.findChild((child) => { - if (child.isFocus) { - child.select(); - } - }); + if (e.key === 'Enter' && this.focusIndex > -1) { + const optionComponent = this.flatOptions[this.focusIndex]; + const option = this.getOptionData(optionComponent.componentOptions.propsData.value); + this.onOptionClick(option); } - } - }, - navigateOptions (direction) { - if (direction === 'next') { - const next = this.focusIndex + 1; - this.focusIndex = (this.focusIndex === this.options.length) ? 1 : next; - } else if (direction === 'prev') { - const prev = this.focusIndex - 1; - this.focusIndex = (this.focusIndex <= 1) ? this.options.length : prev; + } else { + const keysThatCanOpenSelect = ['ArrowUp', 'ArrowDown']; + if (keysThatCanOpenSelect.includes(e.key)) this.toggleMenu(null, true); } - let child_status = { - disabled: false, - hidden: false - }; - let find_deep = false; // can next find allowed + }, + navigateOptions(direction){ + const optionsLength = this.flatOptions.length - 1; - this.findChild((child) => { - if (child.index === this.focusIndex) { - child_status.disabled = child.disabled; - child_status.hidden = child.hidden; + let index = this.focusIndex + direction; + if (index < 0) index = optionsLength; + if (index > optionsLength) index = 0; - if (!child.disabled && !child.hidden) { - child.isFocus = true; - } - } else { - child.isFocus = false; + // find nearest option in case of disabled options in between + if (direction > 0){ + let nearestActiveOption = -1; + for (let i = 0; i < this.flatOptions.length; i++){ + const optionIsActive = !this.flatOptions[i].componentOptions.propsData.disabled; + if (optionIsActive) nearestActiveOption = i; + if (nearestActiveOption >= index) break; } - - if (!child.hidden && !child.disabled) { - find_deep = true; + index = nearestActiveOption; + } else { + let nearestActiveOption = this.flatOptions.length; + for (let i = optionsLength; i >= 0; i--){ + const optionIsActive = !this.flatOptions[i].componentOptions.propsData.disabled; + if (optionIsActive) nearestActiveOption = i; + if (nearestActiveOption <= index) break; } - }); - - this.resetScrollTop(); - - if ((child_status.disabled || child_status.hidden) && find_deep) { - this.navigateOptions(direction); + index = nearestActiveOption; } - }, - resetScrollTop () { - const index = this.focusIndex - 1; - if (!this.optionInstances.length) return; - let bottomOverflowDistance = this.optionInstances[index].$el.getBoundingClientRect().bottom - this.$refs.dropdown.$el.getBoundingClientRect().bottom; - let topOverflowDistance = this.optionInstances[index].$el.getBoundingClientRect().top - this.$refs.dropdown.$el.getBoundingClientRect().top; - if (bottomOverflowDistance > 0) { - this.$refs.dropdown.$el.scrollTop += bottomOverflowDistance; - } - if (topOverflowDistance < 0) { - this.$refs.dropdown.$el.scrollTop += topOverflowDistance; - } + this.focusIndex = index; }, - handleBlur () { - setTimeout(() => { - if (this.autoComplete) return; - const model = this.model; + onOptionClick(option) { + if (this.multiple){ + + // keep the query for remote select + if (this.remote) this.lastRemoteQuery = this.lastRemoteQuery || this.query; + else this.lastRemoteQuery = ''; - if (this.multiple) { - this.query = ''; + const valueIsSelected = this.values.find(({value}) => value === option.value); + if (valueIsSelected){ + this.values = this.values.filter(({value}) => value !== option.value); } else { - if (model !== '') { - this.findChild((child) => { - if (child.value === model) { - this.query = child.label === undefined ? child.searchLabel : child.label; - } - }); - // 如果删除了搜索词,下拉列表也清空了,所以强制调用一次remoteMethod - if (this.remote && this.query !== this.lastQuery) { - this.$nextTick(() => { - this.query = this.lastQuery; - }); - } - } else { - this.query = ''; - } + this.values = this.values.concat(option); } - }, 300); - }, - resetInputState () { - this.inputLength = this.$refs.input.value.length * 12 + 20; - }, - handleInputDelete () { - if (this.multiple && this.model.length && this.query === '') { - this.removeTag(this.model.length - 1); + + this.isFocused = true; // so we put back focus after clicking with mouse on option elements + } else { + this.values = [option]; + this.lastRemoteQuery = ''; + this.hideMenu(); + } + + if (this.filterable){ + const inputField = this.$refs.selectHead.$refs.input; + this.$nextTick(() => inputField.focus()); } }, - // use when slot changed - slotChange () { - this.options = []; - this.optionInstances = []; - }, - setQuery (query) { - if (!this.filterable) return; + onQueryChange(query) { this.query = query; + if (this.query.length > 0) this.visible = true; }, - modelToQuery() { - if (!this.multiple && this.filterable && this.model !== undefined) { - this.findChild((child) => { - if (this.model === child.value) { - if (child.label) { - this.query = child.label; - } else if (child.searchLabel) { - this.query = child.searchLabel; - } else { - this.query = child.value; - } - } - }); - } - }, - broadcastQuery (val) { - if (findComponentDownward(this, 'OptionGroup')) { - this.broadcast('OptionGroup', 'on-query-change', val); - this.broadcast('iOption', 'on-query-change', val); - } else { - this.broadcast('iOption', 'on-query-change', val); + toggleHeaderFocus({type}){ + if (this.disabled) { + return; } + this.isFocused = type === 'focus'; }, - debouncedAppendRemove(){ - return debounce(function(){ - if (!this.remote) { - this.modelToQuery(); - this.$nextTick(() => this.broadcastQuery('')); - } else { - this.findChild((child) => { - child.updateSearchLabel(); // #1865 - child.selected = this.multiple ? this.model.indexOf(child.value) > -1 : this.model === child.value; - }); - } - this.slotChange(); - this.updateOptions(true); - }); - }, - // 处理 remote 初始值 - updateLabel () { - if (this.remote) { - if (!this.multiple && this.model !== '') { - this.selectToChangeQuery = true; - if (this.currentLabel === '') this.currentLabel = this.model; - this.lastQuery = this.currentLabel; - this.query = this.currentLabel; - } else if (this.multiple && this.model.length) { - if (this.currentLabel.length !== this.model.length) this.currentLabel = this.model; - this.selectedMultiple = this.model.map((item, index) => { - return { - value: item, - label: this.currentLabel[index] - }; - }); - } else if (this.multiple && !this.model.length) { - this.selectedMultiple = []; - } - } + updateSlotOptions(){ + this.slotOptions = this.$slots.default; } }, - mounted () { - this.modelToQuery(); - // 处理 remote 初始值 - this.updateLabel(); - this.$nextTick(() => { - this.broadcastQuery(''); - }); - - this.updateOptions(); - document.addEventListener('keydown', this.handleKeydown); - - this.$on('append', this.debouncedAppendRemove()); - this.$on('remove', this.debouncedAppendRemove()); - - this.$on('on-select-selected', (value) => { - if (this.model === value) { - if (this.autoComplete) this.$emit('on-change', value); - this.hideMenu(); - } else { - if (this.multiple) { - const index = this.model.indexOf(value); - if (index >= 0) { - this.removeTag(index); - } else { - this.model.push(value); - this.broadcast('Drop', 'on-update-popper'); - } - - if (this.filterable) { - // remote&filterable&multiple时,一次点多项,不应该设置true,因为无法置为false,下次的搜索会失效 - if (this.query !== '') this.selectToChangeQuery = true; - this.query = ''; - this.$refs.input.focus(); - } - } else { - this.model = value; - - if (this.filterable) { - this.findChild((child) => { - if (child.value === value) { - if (this.query !== '') this.selectToChangeQuery = true; - this.lastQuery = this.query = child.label === undefined ? child.searchLabel : child.label; - } - }); - } - } - } - }); - }, - beforeDestroy () { - document.removeEventListener('keydown', this.handleKeydown); - }, watch: { - value (val) { - this.model = val; - // #982 - if (val === '' || val === null) this.query = ''; - }, - label (val) { - this.currentLabel = val; - this.updateLabel(); - }, - model () { - this.$emit('input', this.model); - this.modelToQuery(); - if (this.multiple) { - if (this.slotChangeDuration) { - this.slotChangeDuration = false; - } else { - this.updateMultipleSelected(); - } - } else { - this.updateSingleSelected(); + value(value){ + const {getInitialValue, getOptionData, publicValue} = this; + + if (value === '') this.values = []; + else if (JSON.stringify(value) !== JSON.stringify(publicValue)) { + this.$nextTick(() => this.values = getInitialValue().map(getOptionData)); } - // #957 - if (!this.visible && this.filterable) { - this.$nextTick(() => { - this.broadcastQuery(''); - }); + }, + values(now, before){ + const newValue = JSON.stringify(now); + const oldValue = JSON.stringify(before); + const shouldEmitInput = newValue !== oldValue; + + if (shouldEmitInput) { + // v-model is always just the value, event with labelInValue === true + const vModelValue = this.labelInValue ? + (this.multiple ? this.publicValue.map(({value}) => value) + : + this.publicValue.value) : this.publicValue; + this.$emit('input', vModelValue); // to update v-model + this.$emit('on-change', this.publicValue); + this.dispatch('FormItem', 'on-form-change', this.publicValue); } }, - visible (val) { - if (val) { - if (this.filterable) { - if (this.multiple) { - this.$refs.input.focus(); - } else { - if (!this.autoComplete) this.$refs.input.select(); - } - if (this.remote) { - this.findChild(child => { - child.selected = this.multiple ? this.model.indexOf(child.value) > -1 : this.model === child.value; - }); - // remote下,设置了默认值,第一次打开时,搜索一次 - const options = this.$slots.default || []; - if (this.query !== '' && !options.length) { - this.remoteMethod(this.query); - } - } - } - this.broadcast('Drop', 'on-update-popper'); - } else { - if (this.filterable) { - if (!this.autoComplete) this.$refs.input.blur(); - // #566 reset options visible - setTimeout(() => { - this.broadcastQuery(''); - }, 300); + query (query) { + this.$emit('on-query-change', query); + const {remoteMethod, lastRemoteQuery} = this; + const hasValidQuery = query !== '' && (query !== lastRemoteQuery || !lastRemoteQuery); + const shouldCallRemoteMethod = remoteMethod && hasValidQuery; + + if (shouldCallRemoteMethod){ + this.focusIndex = -1; + const promise = this.remoteMethod(query); + this.initialLabel = ''; + if (promise && promise.then){ + promise.then(options => { + if (options) this.options = options; + }); } - this.broadcast('Drop', 'on-destroy-popper'); } + if (query !== '' && this.remote) this.lastRemoteQuery = query; }, - query (val) { - if (this.remote && this.remoteMethod) { - if (!this.selectToChangeQuery) { - this.$emit('on-query-change', val); - this.remoteMethod(val); - } - this.focusIndex = 0; - this.findChild(child => { - child.isFocus = false; - }); - } else { - if (!this.selectToChangeQuery) { - this.$emit('on-query-change', val); - } - this.broadcastQuery(val); + loading(state){ + if (state === false){ + this.updateSlotOptions(); + } + }, + isFocused(focused){ + const {selectHead, reference} = this.$refs; + const el = this.filterable ? selectHead.$el.querySelector('input') : reference; + el[this.isFocused ? 'focus' : 'blur'](); - let is_hidden = true; + // restore query value in filterable single selects + const [selectedOption] = this.values; + if (selectedOption && this.filterable && !this.multiple && !focused){ + const selectedLabel = selectedOption.label || selectedOption.value; + if (this.query !== selectedLabel) this.query = selectedLabel; + } + }, + focusIndex(index){ + if (index < 0) return; + // update scroll + const optionValue = this.flatOptions[index].componentOptions.propsData.value; + const optionInstance = findChild(this, ({$options}) => { + return $options.componentName === 'select-item' && $options.propsData.value === optionValue; + }); - this.$nextTick(() => { - this.findChild((child) => { - if (!child.hidden) { - is_hidden = false; - } - }); - this.notFound = is_hidden; - }); + let bottomOverflowDistance = optionInstance.$el.getBoundingClientRect().bottom - this.$refs.dropdown.$el.getBoundingClientRect().bottom; + let topOverflowDistance = optionInstance.$el.getBoundingClientRect().top - this.$refs.dropdown.$el.getBoundingClientRect().top; + if (bottomOverflowDistance > 0) { + this.$refs.dropdown.$el.scrollTop += bottomOverflowDistance; + } + if (topOverflowDistance < 0) { + this.$refs.dropdown.$el.scrollTop += topOverflowDistance; } - this.selectToChangeQuery = false; - this.broadcast('Drop', 'on-update-popper'); } } }; diff --git a/src/styles/components/select.less b/src/styles/components/select.less index adb6c6e..046501f 100644 --- a/src/styles/components/select.less +++ b/src/styles/components/select.less @@ -25,23 +25,14 @@ border: 1px solid @border-color-base; transition: all @transition-time @ease-in-out; - .@{select-prefix-cls}-arrow:nth-of-type(1) { - display: none; - cursor: pointer; - } - - &:hover { + &:hover, &-focused { .hover(); - .@{select-prefix-cls}-arrow:nth-of-type(1) { + .@{select-prefix-cls}-arrow { display: inline-block; } } } - &-show-clear &-selection:hover .@{select-prefix-cls}-arrow:nth-of-type(2){ - display: none; - } - &-arrow { .inner-arrow(); } @@ -51,14 +42,9 @@ .active(); } - .@{select-prefix-cls}-arrow:nth-of-type(2) { + .@{select-prefix-cls}-arrow { transform: rotate(180deg); - } - } - &:focus{ - outline: 0; - .@{select-prefix-cls}-selection{ - .active(); + display: inline-block; } } @@ -66,7 +52,7 @@ .@{select-prefix-cls}-selection { .disabled(); - .@{select-prefix-cls}-arrow:nth-of-type(1) { + .@{select-prefix-cls}-arrow { display: none; } @@ -74,7 +60,7 @@ border-color: @border-color-base; box-shadow: none; - .@{select-prefix-cls}-arrow:nth-of-type(2) { + .@{select-prefix-cls}-arrow { display: inline-block; } } -- libgit2 0.21.4