UNPKG

wave-ui

Version:

A UI framework for Vue.js 3 (and 2) with only the bright side. :sunny:

389 lines (338 loc) 16.1 kB
/** * A detachable element is an element that can be appended to another DOM node * (but keeping data-driven Vue DOM refreshes). * This mixin is used by w-tooltip & w-menu. */ import { consoleWarn } from '../utils/console' export default { props: { // Position. appendTo: { type: [String, Boolean, Object] }, fixed: { type: Boolean }, top: { type: Boolean }, bottom: { type: Boolean }, left: { type: Boolean }, right: { type: Boolean }, alignTop: { type: Boolean }, alignBottom: { type: Boolean }, alignLeft: { type: Boolean }, alignRight: { type: Boolean }, noPosition: { type: Boolean }, zIndex: { type: [Number, String, Boolean] }, // Optionally designate an external activator. // The activator can be a DOM string selector, a ref or a DOM node. activator: { type: [String, Object] } }, inject: { detachableDefaultRoot: { default: null } }, data: () => ({ // The event listeners handlers have to be removed the exact same way they have been attached. // Since the handler functions have variables that change after hot-reload, keep them exactly // as is in an array so we can delete them on destroy. // This only applies to the activatorEventHandlers, the other events listeners can be removed // normally. docEventListenersHandlers: [], // The user may open and close the detachable so fast (like when toggling on hover) that it // should not show up at all. Keep the ability to cancel the opening timer (if there is a set // delay prop). openTimeout: null }), computed: { // DOM element to attach tooltip/menu to. // ! \ This computed uses the DOM - NO SSR (only trigger from beforeMount and later). appendToTarget () { let defaultTarget = '.w-app' // If used inside a w-dialog, w-drawer, or w-menu without an appendTo, default to that open // element instead of the w-app. if (typeof this.detachableDefaultRoot === 'function') { defaultTarget = this.detachableDefaultRoot() || defaultTarget } let target = this.appendTo || defaultTarget if (target === true) target = defaultTarget else if (this.appendTo === 'activator') target = this.$el.previousElementSibling else if (target && !['object', 'string'].includes(typeof target)) target = defaultTarget else if (typeof target === 'object' && !target.nodeType) { target = defaultTarget consoleWarn(`Invalid node provided in ${this.$options.name} \`append-to\`. Falling back to .w-app.`, this) } if (typeof target === 'string') target = document.querySelector(target) if (!target) { consoleWarn(`Unable to locate ${this.appendTo ? `target ${this.appendTo}` : defaultTarget}`, this) target = document.querySelector(defaultTarget) } return target }, // DOM element that will receive the tooltip/menu. // ! \ This computed uses the DOM - NO SSR (only trigger from beforeMount and later). detachableParentEl () { return this.appendToTarget }, hasSeparateActivator () { if (this.$slots.activator) return false const activatorIsString = typeof this.activator === 'string' const activatorIsDomEl = (this.activator?.$el || this.activator) instanceof HTMLElement return activatorIsString || activatorIsDomEl }, activatorEl: { get () { if (this.hasSeparateActivator) { const activator = this.activator?.$el || this.activator if (activator instanceof HTMLElement) return activator return document.querySelector(this.activator) } return this.$el.nextElementSibling }, set () {} }, position () { return ( (this.top && 'top') || (this.bottom && 'bottom') || (this.left && 'left') || (this.right && 'right') || 'bottom' ) }, alignment () { return ( (['top', 'bottom'].includes(this.position) && this.alignLeft && 'left') || (['top', 'bottom'].includes(this.position) && this.alignRight && 'right') || (['left', 'right'].includes(this.position) && this.alignTop && 'top') || (['left', 'right'].includes(this.position) && this.alignBottom && 'bottom') || '' ) }, shouldShowOnClick () { // For props simplicity, the w-tooltip component has the `showOnHover` prop, // whereas the w-menu has `showOnClick`. return (this.$options.props.showOnHover && !this.showOnHover) || (this.$options.props.showOnClick && this.showOnClick) } }, methods: { // ! \ This function uses the DOM - NO SSR (only trigger from beforeMount and later). async open (e) { // A tiny delay may help positioning the detachable correctly in case of multiple activators // with different menu contents. if (this.delay) await new Promise(resolve => (this.openTimeout = setTimeout(resolve, this.delay))) // Cancel opening if the timeout has been cancelled by blur event (when going fast). if (this.delay && !this.openTimeout) return this.detachableVisible = true // If the activator is external, there might be multiple, // so on open, the activator will be set to the event target. if (this.activator) this.activatorEl = e.target await this.insertInDOM() if (this.minWidth === 'activator') this.activatorWidth = this.activatorEl.offsetWidth if (!this.noPosition) this.computeDetachableCoords() // In `getActivatorCoordinates` accessing the menu computed styles takes a few ms (less than 10ms), // if we don't postpone the Menu apparition it will start transition from a visible menu and // thus will not transition. this.timeoutId = setTimeout(() => { this.$emit('update:modelValue', true) this.$emit('input', true) this.$emit('open') }, 0) if (!this.persistent) document.addEventListener('mousedown', this.onOutsideMousedown) if (!this.noPosition) window.addEventListener('resize', this.onResize) }, // ! \ This function uses the DOM - NO SSR (only trigger from beforeMount and later). getActivatorCoordinates () { // Get the activator coordinates relative to window. const { top, left, width, height } = this.activatorEl.getBoundingClientRect() let coords = { top, left, width, height } // If absolute position, adjust top & left. if (!this.fixed) { const { top: targetTop, left: targetLeft } = this.detachableParentEl.getBoundingClientRect() const computedStyles = window.getComputedStyle(this.detachableParentEl, null) coords = { ...coords, top: top - targetTop + this.detachableParentEl.scrollTop - parseInt(computedStyles.getPropertyValue('border-top-width')), left: left - targetLeft + this.detachableParentEl.scrollLeft - parseInt(computedStyles.getPropertyValue('border-left-width')) } } return coords }, // ! \ This function uses the DOM - NO SSR (only trigger from beforeMount and later). computeDetachableCoords () { // Get the activator coordinates. let { top, left, width, height } = this.getActivatorCoordinates() // Prevent error in case the detachable component unmounted hook is fired but the activator // is still in the DOM until the end of a transition and the user toggles it. // Unmounted is called straight away from beforeLeave: https://github.com/vuejs/core/issues/994 if (!this.detachableEl) return // 1. First display the menu but hide it (So we can get its dimension). // -------------------------------------------------- this.detachableEl.style.visibility = 'hidden' this.detachableEl.style.display = 'flex' const computedStyles = window.getComputedStyle(this.detachableEl, null) // 2. Position the menu top, left, right, bottom and apply chosen alignment. // -------------------------------------------------- // Subtract half or full activator width or height and menu width or height according to the // menu alignment. // Note: the menu position relies on transform translate, the custom animation may override the // css transform property so do without it i.e. no translateX(-50%), and recalculate top & left // manually. switch (this.position) { case 'top': { top -= this.detachableEl.offsetHeight if (this.alignRight) { // left: 100% of activator. left += width - this.detachableEl.offsetWidth + parseInt(computedStyles.getPropertyValue('border-right-width')) } else if (!this.alignLeft) left += (width - this.detachableEl.offsetWidth) / 2 // left: 50% of activator - half menu width. break } case 'bottom': { top += height if (this.alignRight) { // left: 100% of activator. left += width - this.detachableEl.offsetWidth + parseInt(computedStyles.getPropertyValue('border-right-width')) } else if (!this.alignLeft) left += (width - this.detachableEl.offsetWidth) / 2 // left: 50% of activator - half menu width. break } case 'left': { left -= this.detachableEl.offsetWidth if (this.alignBottom) top += height - this.detachableEl.offsetHeight else if (!this.alignTop) top += (height - this.detachableEl.offsetHeight) / 2 // top: 50% of activator - half menu height. break } case 'right': { left += width if (this.alignBottom) { top += height - this.detachableEl.offsetHeight + parseInt(computedStyles.getPropertyValue('margin-top')) } else if (!this.alignTop) { top += (height - this.detachableEl.offsetHeight) / 2 + // top: 50% of activator - half menu height. parseInt(computedStyles.getPropertyValue('margin-top')) } break } } // 3. Keep fully in viewport. // @todo: do this. // -------------------------------------------------- // if (this.position === 'top' && ((top - this.detachableEl.offsetHeight) < 0)) { // const margin = - parseInt(computedStyles.getPropertyValue('margin-top')) // top -= top - this.detachableEl.offsetHeight - margin - marginFromWindowSide // } // else if (this.position === 'left' && left - this.detachableEl.offsetWidth < 0) { // const margin = - parseInt(computedStyles.getPropertyValue('margin-left')) // left -= left - this.detachableEl.offsetWidth - margin - marginFromWindowSide // } // else if (this.position === 'right' && left + width + this.detachableEl.offsetWidth > window.innerWidth) { // const margin = parseInt(computedStyles.getPropertyValue('margin-left')) // left -= left + width + this.detachableEl.offsetWidth - window.innerWidth + margin + marginFromWindowSide // } // else if (this.position === 'bottom' && top + height + this.detachableEl.offsetHeight > window.innerHeight) { // const margin = parseInt(computedStyles.getPropertyValue('margin-top')) // top -= top + height + this.detachableEl.offsetHeight - window.innerHeight + margin + marginFromWindowSide // } // 4. Hide the menu again so the transition happens correctly. // -------------------------------------------------- this.detachableEl.style.visibility = null // The menu coordinates are also recalculated while resizing window with open menu: keep the menu visible. if (!this.detachableVisible) this.detachableEl.style.display = 'none' this.detachableCoords = { top, left } }, onResize () { if (this.minWidth === 'activator') this.activatorWidth = this.activatorEl.offsetWidth this.computeDetachableCoords() }, // ! \ This function uses the DOM - NO SSR (only trigger from beforeMount and later). onOutsideMousedown (e) { if (!this.detachableEl.contains(e.target) && !this.activatorEl.contains(e.target)) { this.$emit('update:modelValue', (this.detachableVisible = false)) this.$emit('input', false) this.$emit('close') document.removeEventListener('mousedown', this.onOutsideMousedown) window.removeEventListener('resize', this.onResize) } }, insertInDOM () { return new Promise(resolve => { this.$nextTick(() => { this.detachableEl = this.$refs.detachable?.$el || this.$refs.detachable // Move the tooltip/menu elsewhere in the DOM. if (this.detachableEl) this.appendToTarget.appendChild(this.detachableEl) resolve() }) }) }, removeFromDOM () { document.removeEventListener('mousedown', this.onOutsideMousedown) window.removeEventListener('resize', this.onResize) if (this.detachableEl && this.detachableEl.parentNode) { this.detachableVisible = false this.detachableEl.remove() this.detachableEl = null } }, // If the activator is external, add event listeners to the document and check the target is // the activator when toggling. // This way, the activator can be a future DOM element, that is not yet in the DOM. bindActivatorEvents () { const activatorIsString = typeof this.activator === 'string' Object.entries(this.activatorEventHandlers).forEach(([eventName, handler]) => { // Convert mouseenter to mouseover & mouseleave to mouseout because we are attaching // event to the document, so it can accept future DOM nodes. eventName = eventName.replace('mouseenter', 'mouseover').replace('mouseleave', 'mouseout') const handlerWrap = e => { // The activator can be a DOM string selector a ref or a DOM node. if (activatorIsString && e.target?.matches && e.target.matches(this.activator)) handler(e) else if (e.target === this.activatorEl || this.activatorEl.contains(e.target)) handler(e) } document.addEventListener(eventName, handlerWrap) // The event listeners handlers have to be removed the exact same way they have been attached. // Since the handler functions have variables that change after hot-reload, keep them exactly // as is in an array so we can delete them on destroy. this.docEventListenersHandlers.push({ eventName, handler: handlerWrap }) }) } }, mounted () { // If the activator is external. if (this.activator) this.bindActivatorEvents() // If the activator seems to be undefined, it is probably a DOM node or Vue ref, // so check it on nextTick. else { this.$nextTick(() => { if (this.activator) this.bindActivatorEvents() if (this.modelValue) this.open({ target: this.activatorEl }) }) } // Unwrap the overlay if any. if (this.overlay) this.overlayEl = this.$refs.overlay?.$el if (this.modelValue && this.activator) { this.toggle({ type: this.shouldShowOnClick ? 'click' : 'mouseenter', target: this.activatorEl }) } else if (this.modelValue) this.open({ target: this.activatorEl }) }, unmounted () { this.close() this.removeFromDOM() // Remove the event listeners the exact same way they have been defined. // Fixes issues on hot-reloading. if (this.docEventListenersHandlers.length) { this.docEventListenersHandlers.forEach(({ eventName, handler }) => { document.removeEventListener(eventName, handler) }) } }, watch: { modelValue (bool) { if (!!bool !== this.detachableVisible) { if (bool) this.open({ target: this.activatorEl }) else this.close() } }, appendTo () { this.removeFromDOM() this.insertInDOM() } } }