diff options
Diffstat (limited to 'src/site/components/search-input/SearchInput.vue')
| -rw-r--r-- | src/site/components/search-input/SearchInput.vue | 517 |
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> |