v3.0.0/src/site/components/search-input/SearchInput.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>