vue-multiselect
Version:
Multiselect component for Vue
756 lines (723 loc) • 21.2 kB
JavaScript
function isEmpty (opt) {
if (opt === 0) return false
if (Array.isArray(opt) && opt.length === 0) return true
return !opt
}
function not (fun) {
return (...params) => !fun(...params)
}
function includes (str, query) {
/* istanbul ignore else */
if (str === undefined) str = 'undefined'
if (str === null) str = 'null'
if (str === false) str = 'false'
const text = str.toString().toLowerCase()
return text.indexOf(query.trim()) !== -1
}
function stripGroups (options) {
return options.filter((option) => !option.$isLabel)
}
function flattenOptions (values, label) {
return (options) =>
options.reduce((prev, curr) => {
/* istanbul ignore else */
if (curr[values] && curr[values].length) {
prev.push({
$groupLabel: curr[label],
$isLabel: true
})
return prev.concat(curr[values])
}
return prev
}, [])
}
const flow = (...fns) => (x) => fns.reduce((v, f) => f(v), x)
export default {
data () {
return {
search: '',
isOpen: false,
preferredOpenDirection: 'below',
optimizedHeight: this.maxHeight
}
},
props: {
/**
* Decide whether to filter the results based on search query.
* Useful for async filtering, where we search through more complex data.
* @type {Boolean}
*/
internalSearch: {
type: Boolean,
default: true
},
/**
* Array of available options: Objects, Strings or Integers.
* If array of objects, visible label will default to option.label.
* If `labal` prop is passed, label will equal option['label']
* @type {Array}
*/
options: {
type: Array,
required: true
},
/**
* Equivalent to the `multiple` attribute on a `<select>` input.
* @default false
* @type {Boolean}
*/
multiple: {
type: Boolean,
default: false
},
/**
* Key to compare objects
* @default 'id'
* @type {String}
*/
trackBy: {
type: String
},
/**
* Label to look for in option Object
* @default 'label'
* @type {String}
*/
label: {
type: String
},
/**
* Enable/disable search in options
* @default true
* @type {Boolean}
*/
searchable: {
type: Boolean,
default: true
},
/**
* Clear the search input after `)
* @default true
* @type {Boolean}
*/
clearOnSelect: {
type: Boolean,
default: true
},
/**
* Hide already selected options
* @default false
* @type {Boolean}
*/
hideSelected: {
type: Boolean,
default: false
},
/**
* Equivalent to the `placeholder` attribute on a `<select>` input.
* @default 'Select option'
* @type {String}
*/
placeholder: {
type: String,
default: 'Select option'
},
/**
* Allow to remove all selected values
* @default true
* @type {Boolean}
*/
allowEmpty: {
type: Boolean,
default: true
},
/**
* Reset this.internalValue, this.search after this.internalValue changes.
* Useful if want to create a stateless dropdown.
* @default false
* @type {Boolean}
*/
resetAfter: {
type: Boolean,
default: false
},
/**
* Enable/disable closing after selecting an option
* @default true
* @type {Boolean}
*/
closeOnSelect: {
type: Boolean,
default: true
},
/**
* Function to interpolate the custom label
* @default false
* @type {Function}
*/
customLabel: {
type: Function,
default (option, label) {
if (isEmpty(option)) return ''
return label ? option[label] : option
}
},
/**
* Disable / Enable tagging
* @default false
* @type {Boolean}
*/
taggable: {
type: Boolean,
default: false
},
/**
* String to show when highlighting a potential tag
* @default 'Press enter to create a tag'
* @type {String}
*/
tagPlaceholder: {
type: String,
default: 'Press enter to create a tag'
},
/**
* By default new tags will appear above the search results.
* Changing to 'bottom' will revert this behaviour
* and will proritize the search results
* @default 'top'
* @type {String}
*/
tagPosition: {
type: String,
default: 'top'
},
/**
* Number of allowed selected options. No limit if 0.
* @default 0
* @type {Number}
*/
max: {
type: [Number, Boolean],
default: false
},
/**
* Will be passed with all events as second param.
* Useful for identifying events origin.
* @default null
* @type {String|Integer}
*/
id: {
default: null
},
/**
* Limits the options displayed in the dropdown
* to the first X options.
* @default 1000
* @type {Integer}
*/
optionsLimit: {
type: Number,
default: 1000
},
/**
* Name of the property containing
* the group values
* @default 1000
* @type {String}
*/
groupValues: {
type: String
},
/**
* Name of the property containing
* the group label
* @default 1000
* @type {String}
*/
groupLabel: {
type: String
},
/**
* Allow to select all group values
* by selecting the group label
* @default false
* @type {Boolean}
*/
groupSelect: {
type: Boolean,
default: false
},
/**
* Array of keyboard keys to block
* when selecting
* @default 1000
* @type {String}
*/
blockKeys: {
type: Array,
default () {
return []
}
},
/**
* Prevent from wiping up the search value
* @default false
* @type {Boolean}
*/
preserveSearch: {
type: Boolean,
default: false
},
/**
* Select 1st options if value is empty
* @default false
* @type {Boolean}
*/
preselectFirst: {
type: Boolean,
default: false
},
/**
* Prevent autofocus
* @default false
* @type {Boolean}
*/
preventAutofocus: {
type: Boolean,
default: false
},
/**
* Allows a custom function for sorting search/filtered results.
* @default null
* @type {Function}
*/
filteringSortFunc: {
type: Function,
default: null
}
},
mounted () {
/* istanbul ignore else */
if (!this.multiple && this.max) {
console.warn('[Vue-Multiselect warn]: Max prop should not be used when prop Multiple equals false.')
}
if (
this.preselectFirst &&
!this.internalValue.length &&
this.options.length
) {
this.select(this.filteredOptions[0])
}
},
computed: {
internalValue () {
return this.modelValue || this.modelValue === 0
? Array.isArray(this.modelValue) ? this.modelValue : [this.modelValue]
: []
},
filteredOptions () {
const search = this.search || ''
const normalizedSearch = search.toLowerCase().trim()
let options = this.options.concat()
/* istanbul ignore else */
if (this.internalSearch) {
options = this.groupValues
? this.filterAndFlat(options, normalizedSearch, this.label)
: this.filterOptions(options, normalizedSearch, this.label, this.customLabel)
} else {
options = this.groupValues ? flattenOptions(this.groupValues, this.groupLabel)(options) : options
}
options = this.hideSelected
? options.filter(not(this.isSelected))
: options
/* istanbul ignore else */
if (this.taggable && normalizedSearch.length && !this.isExistingOption(normalizedSearch)) {
if (this.tagPosition === 'bottom') {
options.push({ isTag: true, label: search })
} else {
options.unshift({ isTag: true, label: search })
}
}
return options.slice(0, this.optionsLimit)
},
valueKeys () {
if (this.trackBy) {
return this.internalValue.map((element) => element[this.trackBy])
} else {
return this.internalValue
}
},
optionKeys () {
const options = this.groupValues ? this.flatAndStrip(this.options) : this.options
return options.map((element) => this.customLabel(element, this.label).toString().toLowerCase())
},
currentOptionLabel () {
return this.multiple
? this.searchable ? '' : this.placeholder
: this.internalValue.length
? this.getOptionLabel(this.internalValue[0])
: this.searchable ? '' : this.placeholder
}
},
watch: {
internalValue: {
handler () {
/* istanbul ignore else */
if (this.resetAfter && this.internalValue.length) {
this.search = ''
this.$emit('update:modelValue', this.multiple ? [] : null)
}
},
deep: true
},
search () {
this.$emit('search-change', this.search)
}
},
emits: ['open', 'search-change', 'close', 'select', 'update:modelValue', 'remove', 'tag'],
methods: {
/**
* Returns the internalValue in a way it can be emited to the parent
* @returns {Object||Array||String||Integer}
*/
getValue () {
return this.multiple
? this.internalValue
: this.internalValue.length === 0
? null
: this.internalValue[0]
},
/**
* Filters and then flattens the options list
* @param {Array}
* @return {Array} returns a filtered and flat options list
*/
filterAndFlat (options, search, label) {
return flow(
this.filterGroups(search, label, this.groupValues, this.groupLabel, this.customLabel),
flattenOptions(this.groupValues, this.groupLabel)
)(options)
},
/**
* Flattens and then strips the group labels from the options list
* @param {Array}
* @return {Array} returns a flat options list without group labels
*/
flatAndStrip (options) {
return flow(
flattenOptions(this.groupValues, this.groupLabel),
stripGroups
)(options)
},
/**
* Updates the search value
* @param {String}
*/
updateSearch (query) {
this.search = query
},
/**
* Finds out if the given query is already present
* in the available options
* @param {String}
* @return {Boolean} returns true if element is available
*/
isExistingOption (query) {
return !this.options
? false
: this.optionKeys.indexOf(query) > -1
},
/**
* Finds out if the given element is already present
* in the result value
* @param {Object||String||Integer} option passed element to check
* @returns {Boolean} returns true if element is selected
*/
isSelected (option) {
const opt = this.trackBy
? option[this.trackBy]
: option
return this.valueKeys.indexOf(opt) > -1
},
/**
* Finds out if the given option is disabled
* @param {Object||String||Integer} option passed element to check
* @returns {Boolean} returns true if element is disabled
*/
isOptionDisabled (option) {
return !!option.$isDisabled
},
/**
* Returns empty string when options is null/undefined
* Returns tag query if option is tag.
* Returns the customLabel() results and casts it to string.
*
* @param {Object||String||Integer} Passed option
* @returns {Object||String}
*/
getOptionLabel (option) {
if (isEmpty(option)) return ''
/* istanbul ignore else */
if (option.isTag) return option.label
/* istanbul ignore else */
if (option.$isLabel) return option.$groupLabel
const label = this.customLabel(option, this.label)
/* istanbul ignore else */
if (isEmpty(label)) return ''
return label
},
/**
* Add the given option to the list of selected options
* or sets the option as the selected option.
* If option is already selected -> remove it from the results.
*
* @param {Object||String||Integer} option to select/deselect
* @param {Boolean} block removing
*/
select (option, key) {
/* istanbul ignore else */
if (option.$isLabel && this.groupSelect) {
this.selectGroup(option)
return
}
if (this.blockKeys.indexOf(key) !== -1 ||
this.disabled ||
option.$isDisabled ||
option.$isLabel
) return
/* istanbul ignore else */
if (this.max && this.multiple && this.internalValue.length === this.max) return
/* istanbul ignore else */
if (key === 'Tab' && !this.pointerDirty) return
if (option.isTag) {
this.$emit('tag', option.label, this.id)
this.search = ''
if (this.closeOnSelect && !this.multiple) this.deactivate()
} else {
const isSelected = this.isSelected(option)
if (isSelected) {
if (key !== 'Tab') this.removeElement(option)
return
}
if (this.multiple) {
this.$emit('update:modelValue', this.internalValue.concat([option]))
} else {
this.$emit('update:modelValue', option)
}
this.$emit('select', option, this.id)
/* istanbul ignore else */
if (this.clearOnSelect) this.search = ''
}
/* istanbul ignore else */
if (this.closeOnSelect) this.deactivate()
},
/**
* Add the given group options to the list of selected options
* If all group optiona are already selected -> remove it from the results.
*
* @param {Object||String||Integer} group to select/deselect
*/
selectGroup (selectedGroup) {
const group = this.options.find((option) => {
return option[this.groupLabel] === selectedGroup.$groupLabel
})
if (!group) return
if (this.wholeGroupSelected(group)) {
this.$emit('remove', group[this.groupValues], this.id)
const groupValues = this.trackBy ? group[this.groupValues].map(val => val[this.trackBy]) : group[this.groupValues]
const newValue = this.internalValue.filter(
option => groupValues.indexOf(this.trackBy ? option[this.trackBy] : option) === -1
)
this.$emit('update:modelValue', newValue)
} else {
const optionsToAdd = group[this.groupValues].filter(
option => !(this.isOptionDisabled(option) || this.isSelected(option))
)
// if max is defined then just select options respecting max
if (this.max) {
optionsToAdd.splice(this.max - this.internalValue.length)
}
this.$emit('select', optionsToAdd, this.id)
this.$emit(
'update:modelValue',
this.internalValue.concat(optionsToAdd)
)
}
if (this.closeOnSelect) this.deactivate()
},
/**
* Helper to identify if all values in a group are selected
*
* @param {Object} group to validated selected values against
*/
wholeGroupSelected (group) {
return group[this.groupValues].every((option) => this.isSelected(option) || this.isOptionDisabled(option)
)
},
/**
* Helper to identify if all values in a group are disabled
*
* @param {Object} group to check for disabled values
*/
wholeGroupDisabled (group) {
return group[this.groupValues].every(this.isOptionDisabled)
},
/**
* Removes the given option from the selected options.
* Additionally checks this.allowEmpty prop if option can be removed when
* it is the last selected option.
*
* @param {type} option description
* @return {type} description
*/
removeElement (option, shouldClose = true) {
/* istanbul ignore else */
if (this.disabled) return
/* istanbul ignore else */
if (option.$isDisabled) return
/* istanbul ignore else */
if (!this.allowEmpty && this.internalValue.length <= 1) {
this.deactivate()
return
}
const index = typeof option === 'object'
? this.valueKeys.indexOf(option[this.trackBy])
: this.valueKeys.indexOf(option)
if (this.multiple) {
const newValue = this.internalValue.slice(0, index).concat(this.internalValue.slice(index + 1))
this.$emit('update:modelValue', newValue)
} else {
this.$emit('update:modelValue', null)
}
this.$emit('remove', option, this.id)
/* istanbul ignore else */
if (this.closeOnSelect && shouldClose) this.deactivate()
},
/**
* Calls this.removeElement() with the last element
* from this.internalValue (selected element Array)
*
* @fires this#removeElement
*/
removeLastElement () {
/* istanbul ignore else */
if (this.blockKeys.indexOf('Delete') !== -1) return
/* istanbul ignore else */
if (this.search.length === 0 && Array.isArray(this.internalValue) && this.internalValue.length) {
this.removeElement(this.internalValue[this.internalValue.length - 1], false)
}
},
/**
* Opens the multiselect’s dropdown.
* Sets this.isOpen to TRUE
*/
activate () {
/* istanbul ignore else */
if (this.isOpen || this.disabled) return
this.adjustPosition()
/* istanbul ignore else */
if (this.groupValues && this.pointer === 0 && this.filteredOptions.length) {
this.pointer = 1
}
this.isOpen = true
/* istanbul ignore else */
if (this.searchable) {
if (!this.preserveSearch) this.search = ''
if (!this.preventAutofocus) this.$nextTick(() => this.$refs.search && this.$refs.search.focus())
} else if (!this.preventAutofocus) {
if (typeof this.$el !== 'undefined') this.$el.focus()
}
this.$emit('open', this.id)
},
/**
* Closes the multiselect’s dropdown.
* Sets this.isOpen to FALSE
*/
deactivate () {
/* istanbul ignore else */
if (!this.isOpen) return
this.isOpen = false
/* istanbul ignore else */
if (this.searchable) {
if (this.$refs.search !== null && typeof this.$refs.search !== 'undefined') this.$refs.search.blur()
} else {
if (typeof this.$el !== 'undefined') this.$el.blur()
}
if (!this.preserveSearch) this.search = ''
this.$emit('close', this.getValue(), this.id)
},
/**
* Call this.activate() or this.deactivate()
* depending on this.isOpen value.
*
* @fires this#activate || this#deactivate
* @property {Boolean} isOpen indicates if dropdown is open
*/
toggle () {
this.isOpen
? this.deactivate()
: this.activate()
},
/**
* Updates the hasEnoughSpace variable used for
* detecting where to expand the dropdown
*/
adjustPosition () {
if (typeof window === 'undefined') return
const spaceAbove = this.$el.getBoundingClientRect().top
const spaceBelow = window.innerHeight - this.$el.getBoundingClientRect().bottom
const hasEnoughSpaceBelow = spaceBelow > this.maxHeight
if (hasEnoughSpaceBelow || spaceBelow > spaceAbove || this.openDirection === 'below' || this.openDirection === 'bottom') {
this.preferredOpenDirection = 'below'
this.optimizedHeight = Math.min(spaceBelow - 40, this.maxHeight)
} else {
this.preferredOpenDirection = 'above'
this.optimizedHeight = Math.min(spaceAbove - 40, this.maxHeight)
}
},
/**
* Filters and sorts the options ready for selection
* @param {Array} options
* @param {String} search
* @param {String} label
* @param {Function} customLabel
* @returns {Array}
*/
filterOptions (options, search, label, customLabel) {
return search
? options
.filter((option) => includes(customLabel(option, label), search))
.sort((a, b) => {
if (typeof this.filteringSortFunc === 'function') {
return this.filteringSortFunc(a, b)
}
return customLabel(a, label).length - customLabel(b, label).length
})
: options
},
/**
*
* @param {String} search
* @param {String} label
* @param {String} values
* @param {String} groupLabel
* @param {function} customLabel
* @returns {function(*): *}
*/
filterGroups (search, label, values, groupLabel, customLabel) {
return (groups) => groups.map((group) => {
/* istanbul ignore else */
if (!group[values]) {
console.warn('Options passed to vue-multiselect do not contain groups, despite the config.')
return []
}
const groupOptions = this.filterOptions(group[values], search, label, customLabel)
return groupOptions.length
? {
[groupLabel]: group[groupLabel], [values]: groupOptions
}
: []
})
}
}
}