|
@@ -31,13 +31,35 @@ const Popover = {
|
|
|
|
|
|
// If true, subtract padding when calculating position for the popover,
|
|
|
// use it when popover offset looks to be different on top vs bottom.
|
|
|
- removePadding: Boolean
|
|
|
+ removePadding: Boolean,
|
|
|
+
|
|
|
+ // self-explanatory (i hope)
|
|
|
+ disabled: Boolean,
|
|
|
+
|
|
|
+ // Instead of putting popover next to anchor, overlay popover's center on top of anchor's center
|
|
|
+ overlayCenters: Boolean,
|
|
|
+
|
|
|
+ // What selector (witin popover!) to use for determining center of popover
|
|
|
+ overlayCentersSelector: String,
|
|
|
+
|
|
|
+ // Lets hover popover stay when clicking inside of it
|
|
|
+ stayOnClick: Boolean
|
|
|
},
|
|
|
+ inject: ['popoversZLayer'], // override popover z layer
|
|
|
data () {
|
|
|
return {
|
|
|
+ // lockReEntry is a flag that is set when mouse cursor is leaving the popover's content
|
|
|
+ // so that if mouse goes back into popover it won't be re-shown again to prevent annoyance
|
|
|
+ // with popovers refusing to be hidden when user wants to interact with something in below popover
|
|
|
+ lockReEntry: false,
|
|
|
hidden: true,
|
|
|
- styles: { opacity: 0 },
|
|
|
- oldSize: { width: 0, height: 0 }
|
|
|
+ styles: {},
|
|
|
+ oldSize: { width: 0, height: 0 },
|
|
|
+ scrollable: null,
|
|
|
+ // used to avoid blinking if hovered onto popover
|
|
|
+ graceTimeout: null,
|
|
|
+ parentPopover: null,
|
|
|
+ childrenShown: new Set()
|
|
|
}
|
|
|
},
|
|
|
methods: {
|
|
@@ -47,9 +69,7 @@ const Popover = {
|
|
|
},
|
|
|
updateStyles () {
|
|
|
if (this.hidden) {
|
|
|
- this.styles = {
|
|
|
- opacity: 0
|
|
|
- }
|
|
|
+ this.styles = {}
|
|
|
return
|
|
|
}
|
|
|
|
|
@@ -57,14 +77,26 @@ const Popover = {
|
|
|
// its children are what are inside the slot. Expect only one v-slot:trigger.
|
|
|
const anchorEl = (this.$refs.trigger && this.$refs.trigger.children[0]) || this.$el
|
|
|
// SVGs don't have offsetWidth/Height, use fallback
|
|
|
- const anchorWidth = anchorEl.offsetWidth || anchorEl.clientWidth
|
|
|
const anchorHeight = anchorEl.offsetHeight || anchorEl.clientHeight
|
|
|
- const screenBox = anchorEl.getBoundingClientRect()
|
|
|
- // Screen position of the origin point for popover
|
|
|
- const origin = { x: screenBox.left + screenBox.width * 0.5, y: screenBox.top }
|
|
|
+ const anchorWidth = anchorEl.offsetWidth || anchorEl.clientWidth
|
|
|
+ const anchorScreenBox = anchorEl.getBoundingClientRect()
|
|
|
+
|
|
|
+ const anchorStyle = getComputedStyle(anchorEl)
|
|
|
+ const topPadding = parseFloat(anchorStyle.paddingTop)
|
|
|
+ const bottomPadding = parseFloat(anchorStyle.paddingBottom)
|
|
|
+
|
|
|
+ // Screen position of the origin point for popover = center of the anchor
|
|
|
+ const origin = {
|
|
|
+ x: anchorScreenBox.left + anchorWidth * 0.5,
|
|
|
+ y: anchorScreenBox.top + anchorHeight * 0.5
|
|
|
+ }
|
|
|
const content = this.$refs.content
|
|
|
+ const overlayCenter = this.overlayCenters
|
|
|
+ ? this.$refs.content.querySelector(this.overlayCentersSelector)
|
|
|
+ : null
|
|
|
+
|
|
|
// Minor optimization, don't call a slow reflow call if we don't have to
|
|
|
- const parentBounds = this.boundTo &&
|
|
|
+ const parentScreenBox = this.boundTo &&
|
|
|
(this.boundTo.x === 'container' || this.boundTo.y === 'container') &&
|
|
|
this.containerBoundingClientRect()
|
|
|
|
|
@@ -73,81 +105,151 @@ const Popover = {
|
|
|
// What are the screen bounds for the popover? Viewport vs container
|
|
|
// when using viewport, using default margin values to dodge the navbar
|
|
|
const xBounds = this.boundTo && this.boundTo.x === 'container' ? {
|
|
|
- min: parentBounds.left + (margin.left || 0),
|
|
|
- max: parentBounds.right - (margin.right || 0)
|
|
|
+ min: parentScreenBox.left + (margin.left || 0),
|
|
|
+ max: parentScreenBox.right - (margin.right || 0)
|
|
|
} : {
|
|
|
min: 0 + (margin.left || 10),
|
|
|
max: window.innerWidth - (margin.right || 10)
|
|
|
}
|
|
|
|
|
|
const yBounds = this.boundTo && this.boundTo.y === 'container' ? {
|
|
|
- min: parentBounds.top + (margin.top || 0),
|
|
|
- max: parentBounds.bottom - (margin.bottom || 0)
|
|
|
+ min: parentScreenBox.top + (margin.top || 0),
|
|
|
+ max: parentScreenBox.bottom - (margin.bottom || 0)
|
|
|
} : {
|
|
|
min: 0 + (margin.top || 50),
|
|
|
max: window.innerHeight - (margin.bottom || 5)
|
|
|
}
|
|
|
|
|
|
let horizOffset = 0
|
|
|
+ let vertOffset = 0
|
|
|
+
|
|
|
+ if (overlayCenter) {
|
|
|
+ const box = content.getBoundingClientRect()
|
|
|
+ const overlayCenterScreenBox = overlayCenter.getBoundingClientRect()
|
|
|
+ const leftInnerOffset = overlayCenterScreenBox.left - box.left
|
|
|
+ const topInnerOffset = overlayCenterScreenBox.top - box.top
|
|
|
+ horizOffset = -leftInnerOffset - overlayCenter.offsetWidth * 0.5
|
|
|
+ vertOffset = -topInnerOffset - overlayCenter.offsetHeight * 0.5
|
|
|
+ } else {
|
|
|
+ horizOffset = content.offsetWidth * -0.5
|
|
|
+ vertOffset = content.offsetHeight * -0.5
|
|
|
+ }
|
|
|
+
|
|
|
+ const leftBorder = origin.x + horizOffset
|
|
|
+ const rightBorder = leftBorder + content.offsetWidth
|
|
|
+ const topBorder = origin.y + vertOffset
|
|
|
+ const bottomBorder = topBorder + content.offsetHeight
|
|
|
|
|
|
// If overflowing from left, move it so that it doesn't
|
|
|
- if ((origin.x - content.offsetWidth * 0.5) < xBounds.min) {
|
|
|
- horizOffset += -(origin.x - content.offsetWidth * 0.5) + xBounds.min
|
|
|
+ if (leftBorder < xBounds.min) {
|
|
|
+ horizOffset += xBounds.min - leftBorder
|
|
|
}
|
|
|
|
|
|
// If overflowing from right, move it so that it doesn't
|
|
|
- if ((origin.x + horizOffset + content.offsetWidth * 0.5) > xBounds.max) {
|
|
|
- horizOffset -= (origin.x + horizOffset + content.offsetWidth * 0.5) - xBounds.max
|
|
|
+ if (rightBorder > xBounds.max) {
|
|
|
+ horizOffset -= rightBorder - xBounds.max
|
|
|
}
|
|
|
|
|
|
- // Default to whatever user wished with placement prop
|
|
|
- let usingTop = this.placement !== 'bottom'
|
|
|
-
|
|
|
- // Handle special cases, first force to displaying on top if there's not space on bottom,
|
|
|
- // regardless of what placement value was. Then check if there's not space on top, and
|
|
|
- // force to bottom, again regardless of what placement value was.
|
|
|
- if (origin.y + content.offsetHeight > yBounds.max) usingTop = true
|
|
|
- if (origin.y - content.offsetHeight < yBounds.min) usingTop = false
|
|
|
+ // If overflowing from top, move it so that it doesn't
|
|
|
+ if (topBorder < yBounds.min) {
|
|
|
+ vertOffset += yBounds.min - topBorder
|
|
|
+ }
|
|
|
|
|
|
- let vPadding = 0
|
|
|
- if (this.removePadding && usingTop) {
|
|
|
- const anchorStyle = getComputedStyle(anchorEl)
|
|
|
- vPadding = parseFloat(anchorStyle.paddingTop) + parseFloat(anchorStyle.paddingBottom)
|
|
|
+ // If overflowing from bottom, move it so that it doesn't
|
|
|
+ if (bottomBorder > yBounds.max) {
|
|
|
+ vertOffset -= bottomBorder - yBounds.max
|
|
|
}
|
|
|
|
|
|
- const yOffset = (this.offset && this.offset.y) || 0
|
|
|
- const translateY = usingTop
|
|
|
- ? -anchorHeight + vPadding - yOffset - content.offsetHeight
|
|
|
- : yOffset
|
|
|
+ let translateX = 0
|
|
|
+ let translateY = 0
|
|
|
+
|
|
|
+ if (overlayCenter) {
|
|
|
+ translateX = origin.x + horizOffset
|
|
|
+ translateY = origin.y + vertOffset
|
|
|
+ } else {
|
|
|
+ // Default to whatever user wished with placement prop
|
|
|
+ let usingTop = this.placement !== 'bottom'
|
|
|
+
|
|
|
+ // Handle special cases, first force to displaying on top if there's not space on bottom,
|
|
|
+ // regardless of what placement value was. Then check if there's not space on top, and
|
|
|
+ // force to bottom, again regardless of what placement value was.
|
|
|
+ const topBoundary = origin.y - anchorHeight * 0.5 + (this.removePadding ? topPadding : 0)
|
|
|
+ const bottomBoundary = origin.y + anchorHeight * 0.5 - (this.removePadding ? bottomPadding : 0)
|
|
|
+ if (bottomBoundary + content.offsetHeight > yBounds.max) usingTop = true
|
|
|
+ if (topBoundary - content.offsetHeight < yBounds.min) usingTop = false
|
|
|
|
|
|
- const xOffset = (this.offset && this.offset.x) || 0
|
|
|
- const translateX = anchorWidth * 0.5 - content.offsetWidth * 0.5 + horizOffset + xOffset
|
|
|
+ const yOffset = (this.offset && this.offset.y) || 0
|
|
|
+ translateY = usingTop
|
|
|
+ ? topBoundary - yOffset - content.offsetHeight
|
|
|
+ : bottomBoundary + yOffset
|
|
|
+
|
|
|
+ const xOffset = (this.offset && this.offset.x) || 0
|
|
|
+ translateX = origin.x + horizOffset + xOffset
|
|
|
+ }
|
|
|
|
|
|
- // Note, separate translateX and translateY avoids blurry text on chromium,
|
|
|
- // single translate or translate3d resulted in blurry text.
|
|
|
this.styles = {
|
|
|
- opacity: 1,
|
|
|
- transform: `translateX(${Math.round(translateX)}px) translateY(${Math.round(translateY)}px)`
|
|
|
+ left: `${Math.round(translateX)}px`,
|
|
|
+ top: `${Math.round(translateY)}px`
|
|
|
+ }
|
|
|
+
|
|
|
+ if (this.popoversZLayer) {
|
|
|
+ this.styles['--ZI_popover_override'] = `var(--ZI_${this.popoversZLayer}_popovers)`
|
|
|
+ }
|
|
|
+ if (parentScreenBox) {
|
|
|
+ this.styles.maxWidth = `${Math.round(parentScreenBox.width)}px`
|
|
|
}
|
|
|
},
|
|
|
showPopover () {
|
|
|
+ if (this.disabled) return
|
|
|
const wasHidden = this.hidden
|
|
|
this.hidden = false
|
|
|
+ this.parentPopover && this.parentPopover.onChildPopoverState(this, true)
|
|
|
+ if (this.trigger === 'click' || this.stayOnClick) {
|
|
|
+ document.addEventListener('click', this.onClickOutside)
|
|
|
+ }
|
|
|
+ this.scrollable.addEventListener('scroll', this.onScroll)
|
|
|
+ this.scrollable.addEventListener('resize', this.onResize)
|
|
|
this.$nextTick(() => {
|
|
|
if (wasHidden) this.$emit('show')
|
|
|
this.updateStyles()
|
|
|
})
|
|
|
},
|
|
|
hidePopover () {
|
|
|
+ if (this.disabled) return
|
|
|
if (!this.hidden) this.$emit('close')
|
|
|
this.hidden = true
|
|
|
- this.styles = { opacity: 0 }
|
|
|
+ this.parentPopover && this.parentPopover.onChildPopoverState(this, false)
|
|
|
+ if (this.trigger === 'click') {
|
|
|
+ document.removeEventListener('click', this.onClickOutside)
|
|
|
+ }
|
|
|
+ this.scrollable.removeEventListener('scroll', this.onScroll)
|
|
|
+ this.scrollable.removeEventListener('resize', this.onResize)
|
|
|
},
|
|
|
onMouseenter (e) {
|
|
|
- if (this.trigger === 'hover') this.showPopover()
|
|
|
+ if (this.trigger === 'hover') {
|
|
|
+ this.lockReEntry = false
|
|
|
+ clearTimeout(this.graceTimeout)
|
|
|
+ this.graceTimeout = null
|
|
|
+ this.showPopover()
|
|
|
+ }
|
|
|
},
|
|
|
onMouseleave (e) {
|
|
|
- if (this.trigger === 'hover') this.hidePopover()
|
|
|
+ if (this.trigger === 'hover' && this.childrenShown.size === 0) {
|
|
|
+ this.graceTimeout = setTimeout(() => this.hidePopover(), 1)
|
|
|
+ }
|
|
|
+ },
|
|
|
+ onMouseenterContent (e) {
|
|
|
+ if (this.trigger === 'hover' && !this.lockReEntry) {
|
|
|
+ this.lockReEntry = true
|
|
|
+ clearTimeout(this.graceTimeout)
|
|
|
+ this.graceTimeout = null
|
|
|
+ this.showPopover()
|
|
|
+ }
|
|
|
+ },
|
|
|
+ onMouseleaveContent (e) {
|
|
|
+ if (this.trigger === 'hover' && this.childrenShown.size === 0) {
|
|
|
+ this.graceTimeout = setTimeout(() => this.hidePopover(), 1)
|
|
|
+ }
|
|
|
},
|
|
|
onClick (e) {
|
|
|
if (this.trigger === 'click') {
|
|
@@ -160,8 +262,24 @@ const Popover = {
|
|
|
},
|
|
|
onClickOutside (e) {
|
|
|
if (this.hidden) return
|
|
|
+ if (this.$refs.content && this.$refs.content.contains(e.target)) return
|
|
|
if (this.$el.contains(e.target)) return
|
|
|
+ if (this.childrenShown.size > 0) return
|
|
|
this.hidePopover()
|
|
|
+ if (this.parentPopover) this.parentPopover.onClickOutside(e)
|
|
|
+ },
|
|
|
+ onScroll (e) {
|
|
|
+ this.updateStyles()
|
|
|
+ },
|
|
|
+ onResize (e) {
|
|
|
+ this.updateStyles()
|
|
|
+ },
|
|
|
+ onChildPopoverState (childRef, state) {
|
|
|
+ if (state) {
|
|
|
+ this.childrenShown.add(childRef)
|
|
|
+ } else {
|
|
|
+ this.childrenShown.delete(childRef)
|
|
|
+ }
|
|
|
}
|
|
|
},
|
|
|
updated () {
|
|
@@ -175,11 +293,18 @@ const Popover = {
|
|
|
this.oldSize = { width: content.offsetWidth, height: content.offsetHeight }
|
|
|
}
|
|
|
},
|
|
|
- created () {
|
|
|
- document.addEventListener('click', this.onClickOutside)
|
|
|
+ mounted () {
|
|
|
+ let scrollable = this.$refs.trigger.closest('.column.-scrollable') ||
|
|
|
+ this.$refs.trigger.closest('.mobile-notifications')
|
|
|
+ if (!scrollable) scrollable = window
|
|
|
+ this.scrollable = scrollable
|
|
|
+ let parent = this.$parent
|
|
|
+ while (parent && parent.$.type.name !== 'Popover') {
|
|
|
+ parent = parent.$parent
|
|
|
+ }
|
|
|
+ this.parentPopover = parent
|
|
|
},
|
|
|
- unmounted () {
|
|
|
- document.removeEventListener('click', this.onClickOutside)
|
|
|
+ beforeUnmount () {
|
|
|
this.hidePopover()
|
|
|
}
|
|
|
}
|