lib/components/SelectableDropdown.vue
<template>
<div
v-if="!hide"
class="selectable-dropdown show"
:class="{ 'selectable-dropdown--multiple': multiple, [listClass]: true }"
>
<recycle-scroller
v-slot="{ item, active }"
:style="cssProps"
class="scroller"
:items="items_"
:key-field="keyField"
:item-size="itemSize"
>
<span
:class="{ 'recycle_scroller-item--active': active, active: itemActivated(item), [itemClass]: true }"
class="selectable-dropdown__item px-3 d-flex"
@click.exact="clickToSelectItem(item)"
@click.ctrl="clickToAddItem(item)"
@click.shift="clickToSelectRangeToItem(item)"
>
<!-- @slot Item content -->
<slot name="item" :item="item">
<div v-if="multiple" class="selectable-dropdown__item__check">
<fa :icon="indexIcon(item)" class="mr-2" />
</div>
<div class="flex-grow-1 text-truncate selectable-dropdown__item__label">
<!-- @slot Item's label content -->
<slot name="item-label" :item="item">
{{ serializer(item) }}
</slot>
</div>
</slot>
</span>
</recycle-scroller>
</div>
</template>
<script>
import castArray from 'lodash/castArray'
import eq from 'lodash/eq'
import findIndex from 'lodash/findIndex'
import filter from 'lodash/filter'
import identity from 'lodash/identity'
import isEqual from 'lodash/isEqual'
import uniqueId from 'lodash/uniqueId'
import { faCheckSquare, faSquare } from '@fortawesome/free-regular-svg-icons'
import { RecycleScroller } from 'vue-virtual-scroller'
import Fa from './Fa'
const KEY_ESC_CODE = 27
const KEY_UP_CODE = 38
const KEY_DOWN_CODE = 40
export default {
name: 'SelectableDropdown',
components: {
Fa,
RecycleScroller
},
props: {
/**
* The items of the list.
*/
items: {
type: Array,
default() {
return []
}
},
/**
* The actual selected item.
*/
value: {
type: [String, Object, Array, Number],
default: null
},
/**
* If true, the dropdown is hidden and deactivated.
*/
hide: {
type: Boolean
},
/**
* If true, the key events won't be propagated.
*/
propagate: {
type: Boolean
},
/**
* The user can select values.
*/
multiple: {
type: Boolean
},
/**
* A function to change the label rendering.
*/
serializer: {
type: Function,
default: identity
},
/**
* The class to apply to the list.
*/
listClass: {
type: String,
default: 'dropdown-menu'
},
/**
* The class to apply to each item.
*/
itemClass: {
type: String,
default: 'dropdown-item'
},
/**
* Set to true to deactivate action when arrow keys are pressed
*/
deactivateKeys: {
type: Boolean
},
/**
* Comparaison function to verify equality between selected items.
*/
eq: {
type: Function,
default: eq
},
/**
* Display height of the items in pixels used to calculate the scroll size and position
* Default value is 32 (32px)
*/
itemSize: {
type: Number,
default: 32
},
/**
* Height of the scroll container to specify especially if using the virtual scroll feature
* Default value is 'inherit'
*/
scrollerHeight: {
type: String,
default: 'inherit'
}
},
data() {
return {
activeItems: []
}
},
computed: {
cssProps() {
return {
'--scroller-height': this.scrollerHeight
}
},
keyField() {
return typeof this.items_[0] === 'string' ? null : 'recycle_scroller_id'
},
items_() {
if (typeof this.items[0] === 'string') {
return this.items
}
return this.items.map((item) => ({ ...item, recycle_scroller_id: `id-${uniqueId()}` }))
},
firstActiveItemIndex() {
return this.activeItems.length ? this.items_.indexOf(this.activeItems[0]) : -1
},
lastActiveItemIndex() {
return this.activeItems.length ? this.items_.indexOf(this.activeItems.slice(-1)) : -1
},
keysMap() {
return {
[KEY_UP_CODE]: this.activatePreviousItem,
[KEY_DOWN_CODE]: this.activateNextItem,
[KEY_ESC_CODE]: this.deactivateItems
}
}
},
watch: {
hide() {
this.toggleKeys()
},
activeItems() {
/**
* Fired when the selected value change. It will pass a canonical value
* or an array of values if the property `multiple` is set to true.
*
* @event input
* @type {String, Object, Array, Number}
*/
this.$emit('input', this.multiple ? this.activeItems : this.activeItems[0])
},
value(itemOrItems) {
const items = castArray(itemOrItems)
if (!isEqual(this.activeItems, items)) {
this.activateItemOrItems(items)
}
}
},
mounted() {
this.activateItemOrItems()
this.toggleKeys()
},
destroyed() {
this.unbindKeys()
},
methods: {
indexIcon(item) {
return this.itemActivated(item) ? faCheckSquare : faSquare
},
itemActivated(item) {
return findIndex(this.activeItems, (i) => this.eq(item, i)) > -1
},
clickToSelectItem(item) {
/**
* Fired when user click on an item
*
* @event click
* @type {String, Object, Array, Number}
*/
this.$emit('click', item)
if (this.multiple) {
this.addItem(item)
} else {
this.selectItem(item)
}
},
clickToAddItem(item) {
/**
* Fired when user click on an item
*
* @event click
* @type {String, Object, Array, Number}
*/
this.$emit('click', item)
this.addItem(item)
},
clickToSelectRangeToItem(item) {
/**
* Fired when user click on an item
*
* @event click
* @type {String, Object, Array, Number}
*/
this.$emit('click', item)
this.selectRangeToItem(item)
},
emitEventOnItem(name, item) {
this.$emit(name, item)
},
selectItem(item) {
if (this.itemActivated(item) && this.activeItems.length === 1) {
this.activeItems = filter(this.activeItems, (i) => !this.eq(item, i))
} else {
this.activeItems = [item]
}
},
addItem(item) {
if (this.itemActivated(item)) {
this.activeItems = filter(this.activeItems, (i) => !this.eq(item, i))
} else {
this.activeItems.push(item)
}
},
selectRangeToItem(item) {
// No activated items
if (!this.activeItems.length || !this.multiple) {
this.selectItem(item)
} else {
const index = this.items_.indexOf(item)
if (index > this.firstActiveItemIndex) {
this.activeItems = this.items_.slice(this.firstActiveItemIndex, index + 1)
} else {
this.activeItems = this.items_.slice(index, this.firstActiveItemIndex + 1)
}
}
},
activateItemOrItems(itemOrItems = this.value) {
const items = castArray(itemOrItems)
this.activeItems = [...items]
},
activatePreviousItem() {
this.activeItems = [this.items_[Math.max(this.firstActiveItemIndex - 1, -1)]]
},
activateNextItem() {
this.activeItems = [this.items_[Math.min(this.firstActiveItemIndex + 1, this.items_.length - 1)]]
},
deactivateItems() {
this.activeItems = []
/**
* Fired when items selection is deactivated
*
* @event deactivate
*/
this.$emit('deactivate')
},
keyDown(event) {
const keyCode = event.keyCode || event.which
// The dropdown must be active
if (this.deactivateKeys || this.hide || !this.isKnownKey(keyCode)) return
// Should we stop the event propagation?
if (!this.propagate && event.stopPropagation) {
event.stopPropagation()
event.preventDefault()
}
// Then call the right method
this.keysMap[keyCode].call(this)
},
isKnownKey(keycode) {
return Object.keys(this.keysMap).map(Number).indexOf(keycode) > -1
},
unbindKeys() {
window.removeEventListener('keydown', this.keyDown)
},
bindKeys() {
window.addEventListener('keydown', this.keyDown)
},
toggleKeys() {
if (this.hide) {
this.unbindKeys()
} else {
this.bindKeys()
}
}
}
}
</script>
<style lang="scss">
@import 'node_modules/vue-virtual-scroller/dist/vue-virtual-scroller.css';
.selectable-dropdown {
--scroller-height: 'inherit';
user-select: none;
&.dropdown-menu {
position: relative;
top: 0;
left: 0;
float: none;
}
& .scroller {
height: var(--scroller-height);
}
}
</style>