518 lines
14 KiB
Vue
518 lines
14 KiB
Vue
<template>
|
|
<div
|
|
class="autocomplete control"
|
|
:class="{'is-expanded': expanded}">
|
|
<b-input
|
|
ref="input"
|
|
v-model="newValue"
|
|
type="text"
|
|
:size="size"
|
|
:loading="loading"
|
|
:rounded="rounded"
|
|
:icon="icon"
|
|
:icon-right="newIconRight"
|
|
:icon-right-clickable="newIconRightClickable"
|
|
:icon-pack="iconPack"
|
|
:maxlength="maxlength"
|
|
:autocomplete="newAutocomplete"
|
|
:use-html5-validation="false"
|
|
v-bind="$attrs"
|
|
@input="onInput"
|
|
@focus="focused"
|
|
@blur="onBlur"
|
|
@keyup.native.esc.prevent="isActive = false"
|
|
@keydown.native.tab="tabPressed"
|
|
@keydown.native.enter.prevent="enterPressed"
|
|
@keydown.native.up.prevent="keyArrows('up')"
|
|
@keydown.native.down.prevent="keyArrows('down')"
|
|
@icon-right-click="rightIconClick"
|
|
@icon-click="(event) => $emit('icon-click', event)" />
|
|
|
|
<transition name="fade">
|
|
<div
|
|
v-show="isActive && (data.length > 0 || hasEmptySlot || hasHeaderSlot)"
|
|
ref="dropdown"
|
|
class="dropdown-menu"
|
|
:class="{ 'is-opened-top': isOpenedTop && !appendToBody }"
|
|
:style="style">
|
|
<div
|
|
v-show="isActive"
|
|
class="dropdown-content"
|
|
:style="contentStyle">
|
|
<div
|
|
v-if="hasHeaderSlot"
|
|
class="dropdown-item">
|
|
<slot name="header" />
|
|
</div>
|
|
<a
|
|
v-for="(option, index) in data"
|
|
:key="index"
|
|
class="dropdown-item"
|
|
:class="{ 'is-hovered': option === hovered }"
|
|
@click="setSelected(option, undefined, $event)">
|
|
|
|
<slot
|
|
v-if="hasDefaultSlot"
|
|
:option="option"
|
|
:index="index" />
|
|
<span v-else>
|
|
{{ getValue(option, true) }}
|
|
</span>
|
|
</a>
|
|
<div
|
|
v-if="data.length === 0 && hasEmptySlot"
|
|
class="dropdown-item is-disabled">
|
|
<slot name="empty" />
|
|
</div>
|
|
<div
|
|
v-if="hasFooterSlot"
|
|
class="dropdown-item">
|
|
<slot name="footer" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</transition>
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
/* eslint-disable no-underscore-dangle */
|
|
/* eslint-disable vue/require-default-prop */
|
|
/* eslint-disable vue/no-reserved-keys */
|
|
import { getValueByPath, removeElement, createAbsoluteElement } from '../../../../node_modules/buefy/src/utils/helpers';
|
|
import FormElementMixin from '../../../../node_modules/buefy/src/utils/FormElementMixin';
|
|
|
|
export default {
|
|
name: 'SearchInput',
|
|
mixins: [FormElementMixin],
|
|
inheritAttrs: false,
|
|
props: {
|
|
value: [Number, String],
|
|
data: {
|
|
'type': Array,
|
|
'default': () => []
|
|
},
|
|
field: {
|
|
'type': String,
|
|
'default': 'value'
|
|
},
|
|
keepFirst: Boolean,
|
|
clearOnSelect: Boolean,
|
|
openOnFocus: Boolean,
|
|
customFormatter: Function,
|
|
checkInfiniteScroll: Boolean,
|
|
keepOpen: Boolean,
|
|
clearable: Boolean,
|
|
maxHeight: [String, Number],
|
|
dropdownPosition: {
|
|
'type': String,
|
|
'default': 'auto'
|
|
},
|
|
iconRight: String,
|
|
iconRightClickable: Boolean,
|
|
appendToBody: Boolean,
|
|
customSelector: Function
|
|
},
|
|
data() {
|
|
return {
|
|
selected: null,
|
|
hovered: null,
|
|
isActive: false,
|
|
newValue: this.value,
|
|
newAutocomplete: this.autocomplete || 'off',
|
|
isListInViewportVertically: true,
|
|
hasFocus: false,
|
|
style: {},
|
|
_isAutocomplete: true,
|
|
_elementRef: 'input',
|
|
_bodyEl: undefined // Used to append to body
|
|
};
|
|
},
|
|
computed: {
|
|
/**
|
|
* White-listed items to not close when clicked.
|
|
* Add input, dropdown and all children.
|
|
*/
|
|
whiteList() {
|
|
const whiteList = [];
|
|
whiteList.push(this.$refs.input.$el.querySelector('input'));
|
|
whiteList.push(this.$refs.dropdown);
|
|
// Add all chidren from dropdown
|
|
if (this.$refs.dropdown !== undefined) {
|
|
const children = this.$refs.dropdown.querySelectorAll('*');
|
|
for (const child of children) {
|
|
whiteList.push(child);
|
|
}
|
|
}
|
|
if (this.$parent.$data._isTaginput) {
|
|
// Add taginput container
|
|
whiteList.push(this.$parent.$el);
|
|
// Add .tag and .delete
|
|
const tagInputChildren = this.$parent.$el.querySelectorAll('*');
|
|
for (const tagInputChild of tagInputChildren) {
|
|
whiteList.push(tagInputChild);
|
|
}
|
|
}
|
|
return whiteList;
|
|
},
|
|
/**
|
|
* Check if exists default slot
|
|
*/
|
|
hasDefaultSlot() {
|
|
return Boolean(this.$scopedSlots.default);
|
|
},
|
|
/**
|
|
* Check if exists "empty" slot
|
|
*/
|
|
hasEmptySlot() {
|
|
return Boolean(this.$slots.empty);
|
|
},
|
|
/**
|
|
* Check if exists "header" slot
|
|
*/
|
|
hasHeaderSlot() {
|
|
return Boolean(this.$slots.header);
|
|
},
|
|
/**
|
|
* Check if exists "footer" slot
|
|
*/
|
|
hasFooterSlot() {
|
|
return Boolean(this.$slots.footer);
|
|
},
|
|
/**
|
|
* Apply dropdownPosition property
|
|
*/
|
|
isOpenedTop() {
|
|
return this.dropdownPosition === 'top' || (this.dropdownPosition === 'auto' && !this.isListInViewportVertically);
|
|
},
|
|
newIconRight() {
|
|
if (this.clearable && this.newValue) {
|
|
return 'close-circle';
|
|
}
|
|
return this.iconRight;
|
|
},
|
|
newIconRightClickable() {
|
|
if (this.clearable) {
|
|
return true;
|
|
}
|
|
return this.iconRightClickable;
|
|
},
|
|
contentStyle() {
|
|
return {
|
|
// eslint-disable-next-line no-nested-ternary
|
|
maxHeight: this.maxHeight === undefined
|
|
// eslint-disable-next-line no-restricted-globals
|
|
? null
|
|
: (isNaN(this.maxHeight) ? this.maxHeight : `${this.maxHeight}px`)
|
|
};
|
|
}
|
|
},
|
|
watch: {
|
|
/**
|
|
* When dropdown is toggled, check the visibility to know when
|
|
* to open upwards.
|
|
*/
|
|
isActive(active) {
|
|
if (this.dropdownPosition === 'auto') {
|
|
if (active) {
|
|
this.calcDropdownInViewportVertical();
|
|
} else {
|
|
// Timeout to wait for the animation to finish before recalculating
|
|
setTimeout(() => {
|
|
this.calcDropdownInViewportVertical();
|
|
}, 100);
|
|
}
|
|
}
|
|
if (active) this.$nextTick(() => this.setHovered(null));
|
|
},
|
|
/**
|
|
* When updating input's value
|
|
* 1. Emit changes
|
|
* 2. If value isn't the same as selected, set null
|
|
* 3. Close dropdown if value is clear or else open it
|
|
*/
|
|
newValue(value) {
|
|
this.$emit('input', value);
|
|
// Check if selected is invalid
|
|
const currentValue = this.getValue(this.selected);
|
|
if (currentValue && currentValue !== value) {
|
|
this.setSelected(null, false);
|
|
}
|
|
// Close dropdown if input is clear or else open it
|
|
if (this.hasFocus && (!this.openOnFocus || value)) {
|
|
this.isActive = Boolean(value);
|
|
}
|
|
},
|
|
/**
|
|
* When v-model is changed:
|
|
* 1. Update internal value.
|
|
* 2. If it's invalid, validate again.
|
|
*/
|
|
value(value) {
|
|
this.newValue = value;
|
|
},
|
|
/**
|
|
* Select first option if "keep-first
|
|
*/
|
|
data(value) {
|
|
// Keep first option always pre-selected
|
|
if (this.keepFirst) {
|
|
this.selectFirstOption(value);
|
|
}
|
|
}
|
|
},
|
|
created() {
|
|
if (typeof window !== 'undefined') {
|
|
document.addEventListener('click', this.clickedOutside);
|
|
if (this.dropdownPosition === 'auto') window.addEventListener('resize', this.calcDropdownInViewportVertical);
|
|
}
|
|
},
|
|
mounted() {
|
|
if (this.checkInfiniteScroll && this.$refs.dropdown && this.$refs.dropdown.querySelector('.dropdown-content')) {
|
|
const list = this.$refs.dropdown.querySelector('.dropdown-content');
|
|
list.addEventListener('scroll', () => this.checkIfReachedTheEndOfScroll(list));
|
|
}
|
|
if (this.appendToBody) {
|
|
this.$data._bodyEl = createAbsoluteElement(this.$refs.dropdown);
|
|
this.updateAppendToBody();
|
|
}
|
|
},
|
|
beforeDestroy() {
|
|
if (typeof window !== 'undefined') {
|
|
document.removeEventListener('click', this.clickedOutside);
|
|
if (this.dropdownPosition === 'auto') window.removeEventListener('resize', this.calcDropdownInViewportVertical);
|
|
}
|
|
if (this.checkInfiniteScroll && this.$refs.dropdown && this.$refs.dropdown.querySelector('.dropdown-content')) {
|
|
const list = this.$refs.dropdown.querySelector('.dropdown-content');
|
|
list.removeEventListener('scroll', this.checkIfReachedTheEndOfScroll);
|
|
}
|
|
if (this.appendToBody) {
|
|
removeElement(this.$data._bodyEl);
|
|
}
|
|
},
|
|
methods: {
|
|
/**
|
|
* Set which option is currently hovered.
|
|
*/
|
|
setHovered(option) {
|
|
if (option === undefined) return;
|
|
this.hovered = option;
|
|
},
|
|
/**
|
|
* Set which option is currently selected, update v-model,
|
|
* update input value and close dropdown.
|
|
*/
|
|
setSelected(option, closeDropdown = true, event = undefined) {
|
|
if (option === undefined) return;
|
|
this.selected = option;
|
|
this.$emit('select', this.selected, event);
|
|
if (this.selected !== null) {
|
|
if (this.customSelector) {
|
|
this.newValue = this.clearOnSelect ? '' : this.customSelector(this.selected, this.newValue);
|
|
} else {
|
|
this.newValue = this.clearOnSelect ? '' : this.getValue(this.selected);
|
|
}
|
|
this.setHovered(null);
|
|
}
|
|
// eslint-disable-next-line no-unused-expressions
|
|
closeDropdown && this.$nextTick(() => { this.isActive = false; });
|
|
this.checkValidity();
|
|
},
|
|
/**
|
|
* Select first option
|
|
*/
|
|
selectFirstOption(options) {
|
|
this.$nextTick(() => {
|
|
if (options.length) {
|
|
// If has visible data or open on focus, keep updating the hovered
|
|
if (this.openOnFocus || (this.newValue !== '' && this.hovered !== options[0])) {
|
|
this.setHovered(options[0]);
|
|
}
|
|
} else {
|
|
this.setHovered(null);
|
|
}
|
|
});
|
|
},
|
|
/**
|
|
* Enter key listener.
|
|
* Select the hovered option.
|
|
*/
|
|
enterPressed(event) {
|
|
if (this.hovered === null) return;
|
|
this.setSelected(this.hovered, !this.keepOpen, event);
|
|
},
|
|
/**
|
|
* Tab key listener.
|
|
* Select hovered option if it exists, close dropdown, then allow
|
|
* native handling to move to next tabbable element.
|
|
*/
|
|
tabPressed(event) {
|
|
if (this.hovered === null) {
|
|
this.isActive = false;
|
|
return;
|
|
}
|
|
this.setSelected(this.hovered, !this.keepOpen, event);
|
|
},
|
|
/**
|
|
* Close dropdown if clicked outside.
|
|
*/
|
|
clickedOutside(event) {
|
|
if (this.whiteList.indexOf(event.target) < 0) this.isActive = false;
|
|
},
|
|
/**
|
|
* Return display text for the input.
|
|
* If object, get value from path, or else just the value.
|
|
*/
|
|
getValue(option) {
|
|
if (option === null) return;
|
|
if (typeof this.customFormatter !== 'undefined') {
|
|
// eslint-disable-next-line consistent-return
|
|
return this.customFormatter(option);
|
|
}
|
|
// eslint-disable-next-line consistent-return
|
|
return typeof option === 'object'
|
|
? getValueByPath(option, this.field)
|
|
: option;
|
|
},
|
|
/**
|
|
* Check if the scroll list inside the dropdown
|
|
* reached it's end.
|
|
*/
|
|
checkIfReachedTheEndOfScroll(list) {
|
|
if (list.clientHeight !== list.scrollHeight &&
|
|
list.scrollTop + list.clientHeight >= list.scrollHeight) {
|
|
this.$emit('infinite-scroll');
|
|
}
|
|
},
|
|
/**
|
|
* Calculate if the dropdown is vertically visible when activated,
|
|
* otherwise it is openened upwards.
|
|
*/
|
|
calcDropdownInViewportVertical() {
|
|
this.$nextTick(() => {
|
|
/**
|
|
* this.$refs.dropdown may be undefined
|
|
* when Autocomplete is conditional rendered
|
|
*/
|
|
if (this.$refs.dropdown === undefined) return;
|
|
const rect = this.$refs.dropdown.getBoundingClientRect();
|
|
this.isListInViewportVertically = (
|
|
rect.top >= 0 &&
|
|
rect.bottom <= (window.innerHeight ||
|
|
document.documentElement.clientHeight)
|
|
);
|
|
if (this.appendToBody) {
|
|
this.updateAppendToBody();
|
|
}
|
|
});
|
|
},
|
|
/**
|
|
* Arrows keys listener.
|
|
* If dropdown is active, set hovered option, or else just open.
|
|
*/
|
|
keyArrows(direction) {
|
|
const sum = direction === 'down' ? 1 : -1;
|
|
if (this.isActive) {
|
|
let index = this.data.indexOf(this.hovered) + sum;
|
|
index = index > this.data.length - 1 ? this.data.length : index;
|
|
index = index < 0 ? 0 : index;
|
|
this.setHovered(this.data[index]);
|
|
const list = this.$refs.dropdown.querySelector('.dropdown-content');
|
|
const element = list.querySelectorAll('a.dropdown-item:not(.is-disabled)')[index];
|
|
if (!element) return;
|
|
const visMin = list.scrollTop;
|
|
const visMax = list.scrollTop + list.clientHeight - element.clientHeight;
|
|
if (element.offsetTop < visMin) {
|
|
list.scrollTop = element.offsetTop;
|
|
} else if (element.offsetTop >= visMax) {
|
|
list.scrollTop = (
|
|
element.offsetTop -
|
|
list.clientHeight +
|
|
element.clientHeight
|
|
);
|
|
}
|
|
} else {
|
|
this.isActive = true;
|
|
}
|
|
},
|
|
/**
|
|
* Focus listener.
|
|
* If value is the same as selected, select all text.
|
|
*/
|
|
focused(event) {
|
|
if (this.getValue(this.selected) === this.newValue) {
|
|
this.$el.querySelector('input').select();
|
|
}
|
|
if (this.openOnFocus) {
|
|
this.isActive = true;
|
|
if (this.keepFirst) {
|
|
this.selectFirstOption(this.data);
|
|
}
|
|
}
|
|
this.hasFocus = true;
|
|
this.$emit('focus', event);
|
|
},
|
|
/**
|
|
* Blur listener.
|
|
*/
|
|
onBlur(event) {
|
|
this.hasFocus = false;
|
|
this.$emit('blur', event);
|
|
},
|
|
onInput() {
|
|
const currentValue = this.getValue(this.selected);
|
|
if (currentValue && currentValue === this.newValue) return;
|
|
this.$emit('typing', this.newValue);
|
|
this.checkValidity();
|
|
},
|
|
rightIconClick(event) {
|
|
if (this.clearable) {
|
|
this.newValue = '';
|
|
if (this.openOnFocus) {
|
|
this.$el.focus();
|
|
}
|
|
} else {
|
|
this.$emit('icon-right-click', event);
|
|
}
|
|
},
|
|
checkValidity() {
|
|
if (this.useHtml5Validation) {
|
|
this.$nextTick(() => {
|
|
this.checkHtml5Validity();
|
|
});
|
|
}
|
|
},
|
|
updateAppendToBody() {
|
|
const dropdownMenu = this.$refs.dropdown;
|
|
const trigger = this.$refs.input.$el;
|
|
if (dropdownMenu && trigger) {
|
|
// update wrapper dropdown
|
|
const root = this.$data._bodyEl;
|
|
root.classList.forEach(item => root.classList.remove(item));
|
|
root.classList.add('autocomplete');
|
|
root.classList.add('control');
|
|
if (this.expandend) {
|
|
root.classList.add('is-expandend');
|
|
}
|
|
const rect = trigger.getBoundingClientRect();
|
|
let top = rect.top + window.scrollY;
|
|
const left = rect.left + window.scrollX;
|
|
if (this.isOpenedTop) {
|
|
top -= dropdownMenu.clientHeight;
|
|
} else {
|
|
top += trigger.clientHeight;
|
|
}
|
|
this.style = {
|
|
position: 'absolute',
|
|
top: `${top}px`,
|
|
left: `${left}px`,
|
|
width: `${trigger.clientWidth}px`,
|
|
maxWidth: `${trigger.clientWidth}px`,
|
|
zIndex: '99'
|
|
};
|
|
}
|
|
}
|
|
}
|
|
};
|
|
</script>
|