Created
March 1, 2019 14:55
-
-
Save jacobg/8503eb18ca3754f1749eb6ce2879c0d2 to your computer and use it in GitHub Desktop.
Extensions for the vue-multiselect component
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<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