Skip to content

Instantly share code, notes, and snippets.

@jacobg
Created March 1, 2019 14:55
Show Gist options
  • Save jacobg/8503eb18ca3754f1749eb6ce2879c0d2 to your computer and use it in GitHub Desktop.
Save jacobg/8503eb18ca3754f1749eb6ce2879c0d2 to your computer and use it in GitHub Desktop.
Extensions for the vue-multiselect component
<template>
<multiselect
v-bind="$attrs"
v-on="listeners"
:value="completeValue"
:options="options"
:track-by="trackBy"
:taggable="taggable"
@tag="addTag"
class="key-multiselect"
>
<!-- Pass on all named slots -->
<slot v-for="slot in Object.keys($slots)" :name="slot" :slot="slot"/>
<!-- Pass on all scoped slots -->
<template v-for="slot in Object.keys($scopedSlots)" :slot="slot" slot-scope="scope">
<slot :name="slot" v-bind="scope"/>
</template>
</multiselect>
</template>
<script>
import Multiselect from 'vue-multiselect'
// See discussion on this issue:
// https://github.com/shentao/vue-multiselect/issues/385
export default {
name: 'KeyMultiselect',
inheritAttrs: false,
components: {
Multiselect
},
props: {
value: [Number, String, Array],
options: Array,
trackBy: String,
taggable: {
type: Boolean,
default: false
}
},
computed: {
completeValue: {
get () {
if (!this.value) return null
if (this.$attrs['multiple']) {
// TODO: handle value not found if taggable
return this.value.map(value => this.findOption(value)).filter(value => value)
} else {
const completeValue = this.findOption(this.value)
if (completeValue === undefined && this.taggable) {
this.addTag(this.value)
}
return completeValue
}
},
set (v) {
this.$emit('input', this.$attrs['multiple']
? v.map(value => value[this.trackBy])
: (v && v[this.trackBy])
)
}
},
listeners () {
return {
...this.$listeners,
input: this.onChange
}
}
},
watch: {
completeValue (value) {
this.$emit('fullValueChange', value)
}
},
methods: {
onChange (value) {
this.completeValue = value
},
findOption (value) {
return this.options.find(option => option[this.trackBy] === value)
},
addTag (value) {
const newOption = {
[this.trackBy]: value,
[this.$attrs.label]: value
}
this.options.push(newOption)
// TODO: if multiple then push
this.completeValue = newOption
}
}
}
</script>
<style lang="scss">
.key-multiselect {
.multiselect__option, .multiselect__single {
max-width: 100%;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
}
</style>
<template>
<key-multiselect
v-bind="$attrs"
v-on="listeners"
:id="id"
:value="value"
:options="sortedOptions"
:loading="loading"
:clearOnSelect="false"
:preserveSearch="true"
:class="['remote-multiselect', {selected: !!value}]"
@open="onOpen"
@search-change="onSearchChange"
@select="onSelect"
>
<li slot="beforeList"
class="limit-text"
v-show="options.length >= limit">
* More than {{ limit }} results found. Type some letters to narrow search criteria.
</li>
<!-- Pass on all named slots -->
<slot v-for="slot in Object.keys($slots)" :name="slot" :slot="slot"/>
<!-- Pass on all scoped slots -->
<template v-for="slot in Object.keys($scopedSlots)" :slot="slot" slot-scope="scope">
<slot :name="slot" v-bind="scope"/>
</template>
</key-multiselect>
</template>
<script>
import KeyMultiselect from './KeyMultiselect'
import _ from 'lodash'
export default {
name: 'RemoteMultiselect',
inheritAttrs: false,
components: {
KeyMultiselect
},
data () {
return {
listLoaded: false,
onlyValueLoaded: false,
loading: false,
options: []
}
},
props: {
id: {
type: [Number, String]
},
value: Number,
limit: {
type: Number,
default: 25
},
serviceFetch: {
type: Function,
required: true
}
// clearOnSelect: {
// type: Boolean,
// default: true
// },
// preserveSearch: {
// type: Boolean,
// default: false
// }
},
computed: {
listeners () {
return {
...this.$listeners,
input: this.onChange
}
},
sortedOptions () {
return _.sortBy(this.options, item => item[this.$attrs.label])
}
},
watch: {
// If the newly-set value is not already loaded in the local list of options,
// then we will remotely request it.
value: {
handler (newValue, oldValue) {
if (!newValue) {
// value not set
return
}
if (this.findOptionWithCurrentValue(this.options)) {
// value already loaded
return
}
// invalidate any pending request
this.loading = false
// Before we pass the value onto the child component, we need to download it
// from server.
this.search(null, newValue)
.then(() => {
if (newValue) {
if (this.options.length === 1) {
this.onlyValueLoaded = newValue
}
}
})
.catch(() => {})
},
immediate: true
},
id () {
// Clear search text on VueMultiselect component
// when parent id (e.g., model id) changes. We
// need to do this, because we hard code preserveSearch
// prop to true.
this.$children[0].$children[0].search = ''
}
},
methods: {
onChange (value) {
// prevent infinite feedback loop
if (value !== this.value) {
this.$emit('input', value)
}
},
onOpen () {
// be smart about removing onlyValueLoaded, and only loading if necessary
if (this.onlyValueLoaded !== this.value) {
this.options = this.options.filter(item => item[this.$attrs['track-by']] !== this.onlyValueLoaded)
this.onlyValueLoaded = null
if (this.options.length === 0) this.listLoaded = false
}
if (!this.listLoaded) {
this.trySearch()
}
},
onSelect () {
// When the clearOnSelect prop is true, then selecting an option will
// automatically clear the search field. We need to make sure in such
// a case that we don't invoke a remote search. So the following boolean
// flag prevents that from happening.
this.ignoreNextSearchChange = true
},
onSearchChange (searchText) {
// if (this.ignoreNextSearchChange) {
// this.ignoreNextSearchChange = false
// return
// }
this.debouncedSearch(searchText)
},
debouncedSearch: _.debounce(function (searchText) {
this.trySearch(searchText, null)
}, 500),
trySearch (searchText, value) {
this.search(searchText, value).catch(() => {})
},
search (searchText, value) {
if (this.loading) return Promise.reject(new Error('Already loading'))
this.loading = true
const currentValue = this.value
return this.serviceFetch(searchText, value, this.limit)
.then(options => {
// TODO: If the following condition doesn't properly eliminate requests when the input
// TODO: changes, then instead consider adding a prop for a model id.
if (currentValue !== this.value && (currentValue || this.value)) {
return Promise.reject(
new Error(`Value changed from "${currentValue}" to "${this.value}", so this request no longer valid`))
}
if (this.onlyValueLoaded && this.onlyValueLoaded === this.value && this.options.length === 1 && !this.findOptionWithCurrentValue(options)) {
this.options.push(...options)
this.listLoaded = true
} else {
this.options = options
this.onlyValueLoaded = value
this.listLoaded = !this.onlyValueLoaded
}
// Value removed from list of options after user search
if (value && !this.findOptionWithCurrentValue(this.options)) {
// console.log(`RemoteMultiSelect: value ${value} removed from list of options`)
this.onChange(null)
}
})
.catch(error => {
// TODO
console.warn('failed to load selections', error)
})
.finally(() => {
this.loading = false
})
},
findOptionWithCurrentValue (options) {
return this.value && options.find(option => option[this.$attrs['track-by']] === this.value)
},
clear () {
this.options = []
this.listLoaded = false
this.onlyValueLoaded = null
this.onChange(null)
}
}
}
</script>
<style lang="scss">
$v-select-width: 15rem;
.remote-multiselect {
// TODO: Originally we set width (not max-width) to deal with select box
// TODO: being really wide on wide screens. But it's still too wide on
// TODO: narrow devices (phone), so we'll change it to max-width. I'm
// TODO: still not really sure if this is the right approach.
max-width: $v-select-width;
.multiselect__content {
max-width: 100%;
.active > a {
background-color: map-get($theme-colors, primary);
color: #fff !important;
}
// hover background
> .highlight > a {
background-color: map-get($theme-colors, primary);
color: #fff !important;
}
li {
line-height: 2.5;
> a {
text-overflow: ellipsis;
overflow-x: hidden;
}
}
.limit-text {
background-color: #F39C12;
color: #fff;
font-size: 12px;
line-height: 1.2;
padding: 10px;
font-weight: bold;
}
}
&.selected {
input[type=search] {
// override the strange styling of this element to width:auto when
// value is selected. it causes a line break in the select box for
// long values. we must use width:0 instead of hiding the element,
// because it must be visible in order for the blur event to trigger.
width: 0 !important;
}
&.open {
input[type=search] {
display: inline-block;
}
}
}
.dropdown-toggle {
display: block;
overflow: hidden;
white-space: nowrap;
}
.selected-tag {
margin-bottom: 4px;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: calc(100% - 43px); // subtract width of clear and dropdown icons
}
}
</style>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment