mirror of
https://gitlab.com/bramw/baserow.git
synced 2024-11-21 23:37:55 +00:00
609 lines
20 KiB
JavaScript
609 lines
20 KiB
JavaScript
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
|
|
},
|
|
},
|
|
}
|