import { isDomElement, isElement, onClickOutside, } from '@baserow/modules/core/utils/dom' import { clone } from '@baserow/modules/core/utils/object' import dropdownHelpers from './dropdownHelpers' import _ from 'lodash' export default { mixins: [dropdownHelpers], provide() { return { // This is needed to tell all the child components that the dropdown is going // to be in multiple state. // The reactiveMultiple is an object to deal with the reactivity issue when you // use provide inject pattern. Don't change it. multiple: this.reactiveMultiple, } }, props: { /** * The size of the dropdown. */ size: { type: String, required: false, validator: function (value) { return ['regular', 'large'].includes(value) }, default: 'regular', }, /** * The value of the dropdown. This can be a single value or an array of values if * the multiple property is set to true. */ value: { type: [String, Number, Boolean, Object, Array], required: false, default: null, }, /** * A string that is used to filter the dropdown items. */ searchText: { type: [String, null], required: false, default: null, }, /** * The dropdown placeholder that is shown when no value is selected. */ placeholder: { type: [String, null], required: false, default: null, }, /** * Wether or not to show the search input field. */ showSearch: { type: Boolean, required: false, default: true, }, /** * Wether or not to show the input field. This is different from the search input. */ showInput: { type: Boolean, required: false, default: true, }, /** * Wether or not to show the footer in the dropdown. */ showFooter: { type: Boolean, required: false, default: false, }, /** * Wether or not the dropdown is disabled. */ disabled: { type: Boolean, required: false, default: false, }, /** * The tabindex of the dropdown. */ tabindex: { type: Number, required: false, default: 0, }, /** * If this property is true, it will position the items element fixed. This can be * useful if the parent element has an `overflow: hidden|scroll`, and you still * want the dropdown to break out of it. This property is immutable, so changing * it afterwards has no point. */ fixedItems: { type: Boolean, required: false, default: false, }, /** * Apply max width to the dropdown items container. */ maxWidth: { type: Boolean, required: false, default: false, }, /** * If true, the dropdown will allow multiple values to be selected. */ multiple: { type: Boolean, required: false, default: false, }, /** * Before show let the opportunity to execute something before actually opening the * dropdown. Useful when, for instance, you want to populate the item on demand only * when the items are dynamic and you want to avoid the non reactivity issue with * the added dom elements. */ beforeShow: { type: Function, required: false, default: null, }, /* Error prop is used to show the dropdown in error state. */ error: { type: Boolean, required: false, default: false, }, }, data() { return { loaded: false, open: false, name: null, icon: null, query: '', hasItems: true, hasDropdownItem: true, hover: null, opening: false, fixedItemsImmutable: this.fixedItems, reactiveMultiple: { value: this.multiple }, // Used for provide isDropdown: true, // Used for dropdown items to retrieve the parent dropdown component } }, computed: { selectedName() { return this.getSelectedProperty(this.value, 'name') }, selectedIcon() { return this.getSelectedProperty(this.value, 'icon') }, selectedImage() { return this.getSelectedProperty(this.value, 'image') }, realTabindex() { // We don't want to be able focus if the dropdown is disabled or if we have // opened it and the search input is currently focused if (this.disabled || (this.open && this.showSearch)) { return '' } return this.tabindex }, }, watch: { value() { this.$nextTick(() => { // When the value changes we want to forcefully reload the selectName and // selectedIcon a little bit later because the children might have changed. this.forceRefreshSelectedValue() }) }, multiple(newValue) { this.reactiveMultiple.value = newValue }, }, mounted() { // When the component is mounted we want to forcefully reload the selectedName and // selectedIcon. this.forceRefreshSelectedValue() // The child dropdown item components determine what the possible options are. // Because is not no "Vue way" of watching these components, we're using the // mutation observer to monitor changes. This is needed because we need to // update the select value display value. this.observer = new MutationObserver(() => { this.forceRefreshSelectedValue() }) this.observer.observe(this.$refs.items, { attributes: false, childList: true, characterData: false, subtree: false, }) }, beforeDestroy() { this.observer.disconnect() }, methods: { /** * Recursively finds all the children of this component that have the flag * 'isDropdownItem' set. */ getDropdownItemComponents() { const traverse = (children) => children.reduce( (items, child) => child.isDropdownItem ? [...items, ...traverse(child.$children), child] : [...items, ...traverse(child.$children)], [] ) const components = traverse(this.$children) this.hasDropdownItem = components.length > 0 return components }, focusout(event) { // Hide only if we loose focus in favor of another element which is not a // child of this one. This will make sure the `show` and `hide` will not be // called multiple times when the search of being focussed on immediately // after opening. if (event.relatedTarget && !isElement(this.$el, event.relatedTarget)) { this.hide() } }, /** * Toggles the open state of the dropdown menu. */ toggle(target, value) { if (value === undefined) { value = !this.open } if (value) { this.show(target) } else { this.hide() } }, /** * Shows the lists of choices, so a user can change the value. */ async show(target) { if (this.disabled || this.open || this.opening) { return } if (this.beforeShow) { this.opening = true await this.beforeShow() this.opening = false } const isElementOrigin = isDomElement(target) this.open = true this.hover = this.value this.opener = isElementOrigin ? target : null this.$emit('show') this.$nextTick(() => { // We have to wait for the input to be visible before we can focus. this.showSearch && this.$refs.search.focus() // Scroll to the selected child. this.getDropdownItemComponents().forEach((child) => { if (child.value === this.value) { // This is a bit of weird scenario. This $refs.items uses the // `v-auto-overflow-scroll` directive. That one uses the resize observer // to detect if the element needs a scrollbar. This one has not fired before // this part of the code runs, resulting in no scrollbar. After it will // run, it can create a scrollbar, which changes the `offsetTop` values, and // will therefore not scroll to the correct item. Running this function // in advance will make sure that the scrollbar is added immediately if // needed, `offsetTop` are going to be correct. this.$refs.items.autoOverflowScrollHeightObserverFunction?.() const childTop = child.$el.offsetTop const childBottom = child.$el.offsetTop + child.$el.clientHeight const itemsScrollTop = this.$refs.items.scrollTop const itemsScrollBottom = this.$refs.items.scrollTop + this.$refs.items.clientHeight if (childTop < itemsScrollTop) { // If the selected item is above the visible scroll area, we want to // change the scroll offset, so that the item is visible at the top. this.$refs.items.scrollTop = child.$el.offsetTop - 10 } else if (childBottom > itemsScrollBottom) { // If the selected item is below the visible scroll area, we want to // change the scroll offset, so that the item is visible at the bottom. this.$refs.items.scrollTop = child.$el.offsetTop - this.$refs.items.clientHeight + child.$el.clientHeight + 10 } } }) }) // If the user clicks outside the dropdown while the list of choices of open we // have to hide them. const clickOutsideEventCancel = onClickOutside(this.$el, (target) => { if ( // Check if the context menu is still open this.open && // If the click was not on the opener because they can trigger the toggle // method. !isElement(this.opener, target) ) { this.hide() } }) this.$once('hide', clickOutsideEventCancel) const keydownEvent = (event) => { if ( // Check if the context menu is still open this.open && // Check if the user has hit either of the keys we care about. If not, // ignore. (event.key === 'ArrowUp' || event.key === 'ArrowDown') ) { // Prevent scrolling up and down while pressing the up and down key. event.stopPropagation() event.preventDefault() this.handleUpAndDownArrowPress(event) } // Allow the Enter key to select the value that is currently being hovered // over. if (this.open && event.key === 'Enter') { // Prevent submitting the whole form when pressing the enter key while the // dropdown is open. event.preventDefault() this.select(this.hover) } // Close on escape if (this.open && event.key === 'Escape') { this.hide() } } document.body.addEventListener('keydown', keydownEvent) this.$once('hide', () => { document.body.removeEventListener('keydown', keydownEvent) }) if (this.fixedItemsImmutable) { const updatePosition = () => { const element = this.$refs.itemsContainer const targetRect = this.$el.getBoundingClientRect() element.style.left = targetRect.left + 'px' element.style['min-width'] = targetRect.width + 'px' // 140 is ~ the size of 1 item + optional footer const minHeight = 140 let offset = 0 if ( // If the target is two low on the page targetRect.top > window.innerHeight - minHeight && // and we have more space above targetRect.bottom > window.innerHeight - targetRect.top ) { // if not enough space below the target, let's display the dropdown above offset = window.innerHeight - targetRect.bottom element.style.top = 'auto' element.style.bottom = `${window.innerHeight - targetRect.bottom}px` } else { offset = Math.min(targetRect.top, window.innerHeight - minHeight) element.style.top = `${offset}px` element.style.bottom = 'auto' } element.style['max-height'] = `calc(100vh - ${offset + 20}px)` } // Delay the position update to the next tick to let the Context content // be available in DOM for accurate positioning. this.$nextTick(() => { updatePosition() window.addEventListener('scroll', updatePosition, true) window.addEventListener('resize', updatePosition) this.$once('hide', () => { window.removeEventListener('scroll', updatePosition, true) window.removeEventListener('resize', updatePosition) }) this.$once('hook:beforeDestroy', () => { window.removeEventListener('scroll', updatePosition, true) window.removeEventListener('resize', updatePosition) }) }) } }, /** * Hides the list of choices. If something change in this method, you might need * to update the hide method of the `PaginatedDropdown` component because it * contains a partial copy of this code. */ hide() { if (this.disabled || !this.open) { return } this.open = false this.$emit('hide') // Make sure that all the items are visible the next time we open the dropdown. this.query = '' this.search(this.query) }, /** * Selects a new value which will also be */ select(value) { if (this.multiple) { const newValue = clone(this.value) const index = newValue.indexOf(value) if (index === -1) { newValue.push(value) } else { newValue.splice(index, 1) } this.$emit('input', newValue) this.$emit('change', newValue) } else { this.$emit('input', value) this.$emit('change', value) this.hide() } }, /** * If not empty it will only show children that contain the given query. */ search(query) { this.hasItems = query === '' this.getDropdownItemComponents().forEach((item) => { if (item.search(query)) { this.hasItems = true } }) }, /** * Loops over all children to see if any of the values match with given value. If * so the requested property of the child is returned */ getSelectedProperty(value, property) { const get = (value, property) => { for (const i in this.getDropdownItemComponents()) { const item = this.getDropdownItemComponents()[i] if (_.isEqual(item.value, value)) { return item[property] } } return '' } if (this.multiple) { return value.map((valueItem) => get(valueItem, property)) } else { return get(value, property) } }, /** * Returns true if there is a value. * @return {boolean} */ hasValue() { for (const item of this.getDropdownItemComponents()) { if (this.multiple) { for (const value of this.value) { if (_.isEqual(item.value, value)) { return true } } } else if (_.isEqual(item.value, this.value)) { return true } } return false }, /** * A nasty hack, but in some cases the dropdownItemComponents have not yet been loaded when the * `selectName` and `selectIcon` are computed. This would result in an empty * initial value of the Dropdown because the correct value can't be extracted from * the DropdownItem. With this hack we force the computed properties to recompute * when the component is mounted. At this moment the dropdownItemComponents have been added. */ forceRefreshSelectedValue() { this._computedWatchers.selectedName.run() this._computedWatchers.selectedIcon.run() this.$forceUpdate() }, /** * Method that is called when the arrow up or arrow down key is pressed. Based on * the index of the current child, the next child enabled child is set as hover. */ handleUpAndDownArrowPress(event) { const children = this.getDropdownItemComponents().filter( (child) => !child.disabled && child.isVisible(this.query) ) const isArrowUp = event.key === 'ArrowUp' let index = children.findIndex((item) => _.isEqual(item.value, this.hover) ) index = isArrowUp ? index - 1 : index + 1 // Check if the new index is within the allowed range. if (index < 0 || index > children.length - 1) { return } const next = children[index] this.hover = next.value this.$refs.items.scrollTop = this.getScrollTopAmountForNextChild( next, isArrowUp ) }, /** * This method calculates the expected container scroll top offset if the next * child is selected. This is for example used when navigating with the arrow keys. * If the element to scroll to is below the current dropdown's bottom scroll * position, then scroll so that the item to scroll to is the last visible item * in the dropdown window. Conversely if the element to scroll to is above the * current dropdown's top scroll position then scroll so that the item to scroll * to is the first viewable item in the dropdown window. */ getScrollTopAmountForNextChild(itemToScrollTo, isArrowUp) { const { parentContainerHeight, parentContainerAfterHeight, parentContainerBeforeHeight, itemHeightWithMargins, itemMarginTop, itemsInView, } = this.getStyleProperties(this.$refs.items, itemToScrollTo.$el) // Get the direction of the scrolling. const movingDownwards = !isArrowUp const movingUpwards = isArrowUp // nextItemOutOfView can be used if one wants to check if the item to scroll // to is out of view of the current dropdowns bottom scroll position. // This happens when the difference between the element to scroll to's // offsetTop and the current scrollTop of the dropdown is smaller than height // of the dropdown minus the full height of the element const nextItemOutOfView = itemToScrollTo.$el.offsetTop - this.$refs.items.scrollTop > parentContainerHeight - itemHeightWithMargins // prevItemOutOfView can be used if one wants to check if the item to scroll // to is out of view of the current dropdowns top scroll position. // This happens when the element to scroll to's offsetTop is smaller than the // current scrollTop of the dropdown const prevItemOutOfView = itemToScrollTo.$el.offsetTop < this.$refs.items.scrollTop // When the user is scrolling downwards (i.e. pressing key down) // and the itemToScrollTo is out of view we want to add the height of the // elements preceding the itemToScrollTo plus the parentContainerBeforeHeight. // This can be achieved by removing said heights from the itemToScrollTo's // offsetTop if (nextItemOutOfView && movingDownwards) { const elementsHeightBeforeItemToScrollTo = itemHeightWithMargins * (itemsInView - 1) return ( itemToScrollTo.$el.offsetTop - elementsHeightBeforeItemToScrollTo - parentContainerBeforeHeight ) } // When the user is scrolling upwards (i.e. pressing key up) and the // itemToScrollTo is out of view we want to set the scrollPosition to be the // offsetTop of the element minus it's top margin and the height of the // ::after pseudo element of the ref items element if (prevItemOutOfView && movingUpwards) { return ( itemToScrollTo.$el.offsetTop - itemMarginTop - parentContainerAfterHeight ) } // In the case that the next item to scroll to is completely visible we simply // return the current scroll position so that no scrolling happens return this.$refs.items.scrollTop }, }, }