aboutsummaryrefslogtreecommitdiff
path: root/src/site/components/search-input/SearchInput.vue
diff options
context:
space:
mode:
Diffstat (limited to 'src/site/components/search-input/SearchInput.vue')
-rw-r--r--src/site/components/search-input/SearchInput.vue517
1 files changed, 517 insertions, 0 deletions
diff --git a/src/site/components/search-input/SearchInput.vue b/src/site/components/search-input/SearchInput.vue
new file mode 100644
index 0000000..10728a0
--- /dev/null
+++ b/src/site/components/search-input/SearchInput.vue
@@ -0,0 +1,517 @@
+<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>