Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85824175
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Flag For Later
Award Token
Authored By
Unknown
Size
22 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/src/components/popover/popover.js b/src/components/popover/popover.js
index f77c6ff483..3e22b48230 100644
--- a/src/components/popover/popover.js
+++ b/src/components/popover/popover.js
@@ -1,550 +1,545 @@
import { useId } from 'vue'
const Popover = {
name: 'Popover',
inheritAttrs: false,
props: {
// Action to trigger popover: either 'hover' or 'click'
trigger: String,
// Accessible label
label: String,
// 'top', 'bottom', 'left', 'right'
placement: String,
// Takes object with properties 'x' and 'y', values of these can be
// 'container' for using offsetParent as boundaries for either axis
// or 'viewport'
boundTo: Object,
// Takes a selector to use as a replacement for the parent container
// for getting boundaries for x an y axis
boundToSelector: String,
// Takes a top/bottom/left/right object, how much space to leave
// between boundary and popover element
margin: Object,
// Takes a x/y object and tells how many pixels to offset from
// anchor point on either axis
offset: Object,
// Replaces the classes you may want for the popover container.
// Use 'popover-default' in addition to get the default popover
// styles with your custom class.
popoverClass: String,
// 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,
// 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,
// Use styled button (to avoid nested buttons)
normalButton: Boolean,
// Use <a> instead of <button>
link: Boolean,
// Use <router-link> instead of <button>
routerLink: Boolean,
// Don't put button classes on trigger
menuitem: Boolean,
// Whether to hide the trigger totally
hideTrigger: Boolean,
// Used to set or unset aria-haspopup (attribute not set for tooltips)
tooltip: Boolean,
},
emits: ['show', 'close', 'forwardTabContentFocus', 'reverseTabContentFocus'],
inject: {
// override popover z layer
popoversZLayer: {
default: '',
},
},
setup() {
const uniqueId = useId()
return { uniqueId }
},
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
anchorEl: null,
// There's an issue where having teleport enabled by default causes things just...
// not render at all, i.e. main post status form and its emoji inputs
teleport: false,
lockReEntry: false,
hidden: true,
nextFocus: null,
redirectFocus: false,
styles: {},
oldSize: { width: 0, height: 0 },
scrollable: null,
// used to avoid blinking if hovered onto popover
graceTimeout: null,
parentPopover: null,
disableClickOutside: false,
childrenShown: new Set(),
}
},
computed: {
idPrefix() {
return `popover-${this.uniqueId}-`
},
allTriggerAttrs() {
if (process.env.NODE_ENV === 'development') {
if ('aria-hidden' in this.$attrs) {
throw new Error(
'Do not use aria-hidden in attrs. Instead set hideTrigger to true',
)
}
}
const allAttrs = {
...this.$attrs,
}
const {
'aria-hidden': _unused0,
'aria-label': _unused1,
...rest
} = allAttrs
const attrs = { ...rest }
if (this.hideTrigger) {
attrs['aria-hidden'] = true
attrs.tabindex = -1
}
return attrs
},
ariaHasPopup() {
if (this.tooltip || this.disabled) {
return undefined
} else {
return this.$attrs['aria-haspopup'] ?? 'menu'
}
},
triggerClass() {
if (this.menuitem) {
return ''
} else if (this.normalButton) {
return 'button-default btn'
} else {
return 'button-unstyled'
}
},
},
methods: {
setAnchorEl(el) {
this.anchorEl = el
this.updateStyles()
},
containerBoundingClientRect() {
const container = this.boundToSelector
? this.$refs.trigger.closest(this.boundToSelector)
: this.$refs.trigger.offsetParent
return container.getBoundingClientRect()
},
updateStyles() {
if (this.hidden) {
this.styles = {}
return
}
// Popover will be anchored around this element, trigger ref is the container, so
// its children are what are inside the slot. Expect only one v-slot:trigger.
const anchorEl =
this.anchorEl || this.$refs.trigger?.children[0] || this.$refs.trigger
// SVGs don't have offsetWidth/Height, use fallback
const anchorHeight = anchorEl.offsetHeight || anchorEl.clientHeight
const anchorWidth = anchorEl.offsetWidth || anchorEl.clientWidth
const anchorScreenBox = anchorEl.getBoundingClientRect()
const anchorStyle = getComputedStyle(anchorEl)
const topPadding = Number.parseFloat(anchorStyle.paddingTop)
const bottomPadding = Number.parseFloat(anchorStyle.paddingBottom)
const rightPadding = Number.parseFloat(anchorStyle.paddingRight)
const leftPadding = Number.parseFloat(anchorStyle.paddingLeft)
// 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 parentScreenBox =
(this.boundTo?.x === 'container' || this.boundTo?.y === 'container') &&
this.containerBoundingClientRect()
const margin = this.margin || {}
// 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?.x === 'container'
? {
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?.y === 'container'
? {
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 (leftBorder < xBounds.min) {
horizOffset += xBounds.min - leftBorder
}
// If overflowing from right, move it so that it doesn't
if (rightBorder > xBounds.max) {
horizOffset -= rightBorder - xBounds.max
}
// If overflowing from top, move it so that it doesn't
if (topBorder < yBounds.min) {
vertOffset += yBounds.min - topBorder
}
// If overflowing from bottom, move it so that it doesn't
if (bottomBorder > yBounds.max) {
vertOffset -= bottomBorder - yBounds.max
}
let translateX = 0
let translateY = 0
if (overlayCenter) {
translateX = origin.x + horizOffset
translateY = origin.y + vertOffset
} else if (this.placement !== 'right' && this.placement !== 'left') {
// 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 no space on bottom,
// regardless of what placement value was. Then check if there's no 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 yOffset = this.offset?.y || 0
translateY = usingTop
? topBoundary - yOffset - content.offsetHeight
: bottomBoundary + yOffset
const xOffset = this.offset?.x || 0
translateX = origin.x + horizOffset + xOffset
} else {
// Default to whatever user wished with placement prop
let usingLeft = this.placement !== 'right'
// Handle special cases, first force to displaying on left if there's no space on right,
// regardless of what placement value was. Then check if there's no space on right, and
// force to left, again regardless of what placement value was.
const leftBoundary =
origin.x - anchorWidth * 0.5 + (this.removePadding ? leftPadding : 0)
const rightBoundary =
origin.x + anchorWidth * 0.5 - (this.removePadding ? rightPadding : 0)
if (rightBoundary + content.offsetWidth > xBounds.max) usingLeft = true
if (leftBoundary - content.offsetWidth < xBounds.min) usingLeft = false
const xOffset = this.offset?.x || 0
translateX = usingLeft
? leftBoundary - xOffset - content.offsetWidth
: rightBoundary + xOffset
const yOffset = this.offset?.y || 0
translateY = origin.y + vertOffset + yOffset
}
this.styles = {
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
this.disableClickOutside = true
setTimeout(() => {
this.disableClickOutside = false
}, 0)
const wasHidden = this.hidden
this.hidden = false
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)
// My assumption is that upon showing popover initially has different size
// as its contents are getting populating, so logic uses those incorrect
// sizes as basis
setTimeout(() => {
if (wasHidden) this.$emit('show')
this.updateStyles()
}, 1)
},
hidePopover() {
if (this.disabled) return
if (!this.hidden) this.$emit('close')
this.redirectFocus = false
this.nextFocus = null
this.hidden = true
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)
},
resizePopover() {
setTimeout(() => {
this.updateStyles()
}, 1)
},
onMouseenter() {
if (this.trigger === 'hover') {
this.lockReEntry = false
clearTimeout(this.graceTimeout)
this.graceTimeout = null
this.showPopover()
}
},
onMouseleave() {
if (this.trigger === 'hover' && this.childrenShown.size === 0) {
this.graceTimeout = setTimeout(() => this.hidePopover(), 1)
}
},
onMouseenterContent() {
if (this.trigger === 'hover' && !this.lockReEntry) {
this.lockReEntry = true
clearTimeout(this.graceTimeout)
this.graceTimeout = null
this.showPopover()
}
},
onMouseleaveContent() {
if (this.trigger === 'hover' && this.childrenShown.size === 0) {
this.graceTimeout = setTimeout(() => this.hidePopover(), 1)
}
},
// # Focus management.
//
// Since our content is far away in separate #modals or #popovers container
// the native tabindex flow is broken, but we still want user to be able to
// focus popover contents. To do so we do a bit of focus management.
//
// We do a little focus trapping and redirecting. First, we must establish
// our order. Opening a popover (including auto-open by hover/focus) and
// hitting tab should select content's first focusable element. Shift-tab
// away from popover trigger should hide popover and continue on as normal.
// While inside content, shift-tabbing from first focusable element should
// focus trigger again, and tabbing beyond last focusable element should
// focus element next in line relative to trigger.
//
// We use two tricks here:
// 1. we add two bogus ".focus-trap" elements before and after popover
// contents. Those are aria-hidden="1" and empty spans so they don't
// interfere with the UI or accessibility. We use them to detect when
// user tab-navigated beyond first and last focusable elements and act
// accordingly
// 2. when we open up a popover we listen for "Tab" keypress and engage
// "redirectFocus", so that when trigger loses focus (immideately after)
// we a) focus content's first focusable element and b) save reference
// to the next intended focus target.
//
// There is a blind spot in this approach. If user opened a popover and
// navigated in it with a mouse we never get to know the "next intended
// focus target" so focus order ends up being broken.
//
// There's probably a better way to figure out this "next target" but for
// now this will do.
onFocus(e) {
if (this.trigger === 'hover') {
this.showPopover()
}
},
onBlur(e) {
if (this.redirectFocus) {
const firstFocusable = this.$refs.content
?.querySelector('*:not(.focus-trap)')
?.querySelector('a[href], button, [tabindex="0"], input')
if (firstFocusable) {
firstFocusable?.focus()
this.nextFocus = e.relatedTarget
} else {
this.hidePopover()
}
}
this.redirectFocus = false
},
onTab(e) {
this.redirectFocus = true
},
onForwardTabContentFocus() {
this.nextFocus?.focus()
this.hidePopover()
this.$emit('forwardTabContentFocus')
},
onReverseTabContentFocus() {
const triggerer = this.anchorEl ?? this.$refs.trigger
triggerer.focus()
this.$emit('reverseTabContentFocus')
},
onClick(e, navigate) {
- const modifierKeysHeld = e.ctrlKey || e.altKey || e.shiftKey || e.metaKey
- const leftButton = e.button === 0
const singleClick = e.detail <= 1
- if (
- this.trigger === 'click' &&
- !modifierKeysHeld &&
- leftButton &&
- singleClick
- ) {
+ if (!singleClick) return // Let the browser do whatever
+
+ if (this.trigger === 'click') {
e.preventDefault()
if (this.hidden) {
this.showPopover()
} else {
this.hidePopover()
}
return
}
// Router-link navigation
if (navigate) {
e.preventDefault()
navigate()
return
}
// Otherwise let browser navigate the link
},
onClickOutside(e) {
if (this.disableClickOutside) return
if (this.hidden) return
if (this.$refs.content?.contains(e.target)) return
if (this.$refs.trigger.contains(e.target)) return
if (this.childrenShown.size > 0) return
this.hidePopover()
if (this.parentPopover) this.parentPopover.onClickOutside(e)
},
onScroll() {
this.updateStyles()
},
onResize() {
const content = this.$refs.content
if (!content) return
if (
this.oldSize.width !== content.offsetWidth ||
this.oldSize.height !== content.offsetHeight
) {
this.updateStyles()
this.oldSize = {
width: content.offsetWidth,
height: content.offsetHeight,
}
}
},
onChildPopoverState(childRef, state) {
if (state) {
this.childrenShown.add(childRef)
} else {
this.childrenShown.delete(childRef)
}
},
},
updated() {
// Monitor changes to content size, update styles only when content sizes have changed,
// that should be the only time we need to move the popover box if we don't care about scroll
// or resize
this.onResize()
},
mounted() {
this.teleport = true
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
},
beforeUnmount() {
this.hidePopover()
},
}
export default Popover
diff --git a/src/components/popover/popover.vue b/src/components/popover/popover.vue
index 40409758d4..0dd7eed31a 100644
--- a/src/components/popover/popover.vue
+++ b/src/components/popover/popover.vue
@@ -1,124 +1,124 @@
<template>
<router-link
v-if="routerLink"
:to="$attrs.to"
custom
v-slot="{ href, navigate }"
>
<a
ref="trigger"
class="popover-trigger-button"
:href="href"
:aria-haspopup="ariaHasPopup"
:aria-owns="hidden ? undefined : `${idPrefix}content`"
:aria-description="trigger === 'click' ? $t('general.popover_link_a11y') : undefined"
:aria-describedby="tooltip ? `${idPrefix}content` : undefined"
:aria-expanded="!hidden ? 'true' : 'false'"
:aria-label="label ?? $attrs['aria-label']"
@mouseenter="onMouseenter"
@mouseleave="onMouseleave"
- @click="e => onClick(e, navigate)"
+ @click.left.exact="e => onClick(e, navigate)"
@focus="onFocus"
@blur="onBlur"
@keydown.tab.exact="onTab"
@keydown.tab.shift.exact="hidePopover"
v-bind="allTriggerAttrs"
>
<slot name="trigger"/>
</a>
</router-link>
<a
v-else-if="link"
ref="trigger"
class="popover-trigger-button"
:href="$attrs.href"
target="_blank"
:aria-haspopup="ariaHasPopup"
:aria-owns="hidden ? undefined : `${idPrefix}content`"
:aria-description="trigger === 'click' ? $t('general.popover_link_a11y') : undefined"
:aria-describedby="tooltip ? `${idPrefix}content` : undefined"
:aria-expanded="!hidden ? 'true' : 'false'"
:aria-label="label ?? $attrs['aria-label']"
@mouseenter="onMouseenter"
@mouseleave="onMouseleave"
- @click="onClick"
+ @click.left.exact="onClick"
@focus="onFocus"
@blur="onBlur"
@keydown.tab.exact="onTab"
@keydown.tab.shift.exact="hidePopover"
v-bind="allTriggerAttrs"
>
<slot name="trigger"/>
</a>
<button
v-else
ref="trigger"
class="popover-trigger-button"
:class="triggerClass"
type="button"
:aria-haspopup="ariaHasPopup"
:aria-owns="hidden ? undefined : `${idPrefix}content`"
:aria-describedby="tooltip ? `${idPrefix}content` : undefined"
:aria-expanded="!hidden ? 'true' : 'false'"
:aria-label="label ?? $attrs['aria-label']"
@mouseenter="onMouseenter"
@mouseleave="onMouseleave"
- @click="onClick"
+ @click.left.exact="onClick"
@focus="onFocus"
@blur="onBlur"
@keydown.tab.exact="onTab"
@keydown.tab.shift.exact="hidePopover"
v-bind="allTriggerAttrs"
>
<slot name="trigger"/>
</button>
<teleport
:disabled="!teleport"
to="#popovers"
>
<transition name="fade">
<div
v-if="!hidden"
:id="($attrs.id ?? idPrefix) + 'content'"
ref="content"
:style="styles"
class="popover"
:role="tooltip ? 'tooltip' : 'dialog'"
:aria-label="$attrs['aria-label']"
:class="popoverClass || 'popover-default'"
@mouseenter="onMouseenterContent"
@mouseleave="onMouseleaveContent"
@click="onClickContent"
>
<span
class="focus-trap -before"
aria-hidden="1"
tabindex="0"
@focus="onReverseTabContentFocus"
/>
<slot
name="content"
class="popover-inner"
:close="hidePopover"
:resize="resizePopover"
/>
<span
class="focus-trap -after"
aria-hidden="1"
tabindex="0"
@focus="onForwardTabContentFocus"
/>
</div>
</transition>
</teleport>
</template>
<script src="./popover.js" />
<style src="./popover.scss" lang="scss"></style>
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Thu, Oct 29, 12:36 PM (1 d, 1 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1798099
Default Alt Text
(22 KB)
Attached To
Mode
rPUFE pleroma-fe-upstream
Attached
Detach File
Event Timeline