logo

pleroma-fe

My custom branche(s) on git.pleroma.social/pleroma/pleroma-fe git clone https://anongit.hacktivis.me/git/pleroma-fe.git/

popover.js (13437B)


  1. const Popover = {
  2. name: 'Popover',
  3. props: {
  4. // Action to trigger popover: either 'hover' or 'click'
  5. trigger: String,
  6. // 'top', 'bottom', 'left', 'right'
  7. placement: String,
  8. // Takes object with properties 'x' and 'y', values of these can be
  9. // 'container' for using offsetParent as boundaries for either axis
  10. // or 'viewport'
  11. boundTo: Object,
  12. // Takes a selector to use as a replacement for the parent container
  13. // for getting boundaries for x an y axis
  14. boundToSelector: String,
  15. // Takes a top/bottom/left/right object, how much space to leave
  16. // between boundary and popover element
  17. margin: Object,
  18. // Takes a x/y object and tells how many pixels to offset from
  19. // anchor point on either axis
  20. offset: Object,
  21. // Replaces the classes you may want for the popover container.
  22. // Use 'popover-default' in addition to get the default popover
  23. // styles with your custom class.
  24. popoverClass: String,
  25. // If true, subtract padding when calculating position for the popover,
  26. // use it when popover offset looks to be different on top vs bottom.
  27. removePadding: Boolean,
  28. // self-explanatory (i hope)
  29. disabled: Boolean,
  30. // Instead of putting popover next to anchor, overlay popover's center on top of anchor's center
  31. overlayCenters: Boolean,
  32. // What selector (witin popover!) to use for determining center of popover
  33. overlayCentersSelector: String,
  34. // Lets hover popover stay when clicking inside of it
  35. stayOnClick: Boolean,
  36. // Use styled button (to avoid nested buttons)
  37. normalButton: Boolean,
  38. triggerAttrs: {
  39. type: Object,
  40. default: {}
  41. }
  42. },
  43. inject: { // override popover z layer
  44. popoversZLayer: {
  45. default: ''
  46. }
  47. },
  48. data () {
  49. return {
  50. // lockReEntry is a flag that is set when mouse cursor is leaving the popover's content
  51. // so that if mouse goes back into popover it won't be re-shown again to prevent annoyance
  52. // with popovers refusing to be hidden when user wants to interact with something in below popover
  53. anchorEl: null,
  54. // There's an issue where having teleport enabled by default causes things just...
  55. // not render at all, i.e. main post status form and its emoji inputs
  56. teleport: false,
  57. lockReEntry: false,
  58. hidden: true,
  59. styles: {},
  60. oldSize: { width: 0, height: 0 },
  61. scrollable: null,
  62. // used to avoid blinking if hovered onto popover
  63. graceTimeout: null,
  64. parentPopover: null,
  65. disableClickOutside: false,
  66. childrenShown: new Set()
  67. }
  68. },
  69. methods: {
  70. setAnchorEl (el) {
  71. this.anchorEl = el
  72. this.updateStyles()
  73. },
  74. containerBoundingClientRect () {
  75. const container = this.boundToSelector ? this.$el.closest(this.boundToSelector) : this.$el.offsetParent
  76. return container.getBoundingClientRect()
  77. },
  78. updateStyles () {
  79. if (this.hidden) {
  80. this.styles = {}
  81. return
  82. }
  83. // Popover will be anchored around this element, trigger ref is the container, so
  84. // its children are what are inside the slot. Expect only one v-slot:trigger.
  85. const anchorEl = this.anchorEl || (this.$refs.trigger && this.$refs.trigger.children[0]) || this.$el
  86. // SVGs don't have offsetWidth/Height, use fallback
  87. const anchorHeight = anchorEl.offsetHeight || anchorEl.clientHeight
  88. const anchorWidth = anchorEl.offsetWidth || anchorEl.clientWidth
  89. const anchorScreenBox = anchorEl.getBoundingClientRect()
  90. const anchorStyle = getComputedStyle(anchorEl)
  91. const topPadding = parseFloat(anchorStyle.paddingTop)
  92. const bottomPadding = parseFloat(anchorStyle.paddingBottom)
  93. const rightPadding = parseFloat(anchorStyle.paddingRight)
  94. const leftPadding = parseFloat(anchorStyle.paddingLeft)
  95. // Screen position of the origin point for popover = center of the anchor
  96. const origin = {
  97. x: anchorScreenBox.left + anchorWidth * 0.5,
  98. y: anchorScreenBox.top + anchorHeight * 0.5
  99. }
  100. const content = this.$refs.content
  101. const overlayCenter = this.overlayCenters
  102. ? this.$refs.content.querySelector(this.overlayCentersSelector)
  103. : null
  104. // Minor optimization, don't call a slow reflow call if we don't have to
  105. const parentScreenBox = this.boundTo &&
  106. (this.boundTo.x === 'container' || this.boundTo.y === 'container') &&
  107. this.containerBoundingClientRect()
  108. const margin = this.margin || {}
  109. // What are the screen bounds for the popover? Viewport vs container
  110. // when using viewport, using default margin values to dodge the navbar
  111. const xBounds = this.boundTo && this.boundTo.x === 'container'
  112. ? {
  113. min: parentScreenBox.left + (margin.left || 0),
  114. max: parentScreenBox.right - (margin.right || 0)
  115. }
  116. : {
  117. min: 0 + (margin.left || 10),
  118. max: window.innerWidth - (margin.right || 10)
  119. }
  120. const yBounds = this.boundTo && this.boundTo.y === 'container'
  121. ? {
  122. min: parentScreenBox.top + (margin.top || 0),
  123. max: parentScreenBox.bottom - (margin.bottom || 0)
  124. }
  125. : {
  126. min: 0 + (margin.top || 50),
  127. max: window.innerHeight - (margin.bottom || 5)
  128. }
  129. let horizOffset = 0
  130. let vertOffset = 0
  131. if (overlayCenter) {
  132. const box = content.getBoundingClientRect()
  133. const overlayCenterScreenBox = overlayCenter.getBoundingClientRect()
  134. const leftInnerOffset = overlayCenterScreenBox.left - box.left
  135. const topInnerOffset = overlayCenterScreenBox.top - box.top
  136. horizOffset = -leftInnerOffset - overlayCenter.offsetWidth * 0.5
  137. vertOffset = -topInnerOffset - overlayCenter.offsetHeight * 0.5
  138. } else {
  139. horizOffset = content.offsetWidth * -0.5
  140. vertOffset = content.offsetHeight * -0.5
  141. }
  142. const leftBorder = origin.x + horizOffset
  143. const rightBorder = leftBorder + content.offsetWidth
  144. const topBorder = origin.y + vertOffset
  145. const bottomBorder = topBorder + content.offsetHeight
  146. // If overflowing from left, move it so that it doesn't
  147. if (leftBorder < xBounds.min) {
  148. horizOffset += xBounds.min - leftBorder
  149. }
  150. // If overflowing from right, move it so that it doesn't
  151. if (rightBorder > xBounds.max) {
  152. horizOffset -= rightBorder - xBounds.max
  153. }
  154. // If overflowing from top, move it so that it doesn't
  155. if (topBorder < yBounds.min) {
  156. vertOffset += yBounds.min - topBorder
  157. }
  158. // If overflowing from bottom, move it so that it doesn't
  159. if (bottomBorder > yBounds.max) {
  160. vertOffset -= bottomBorder - yBounds.max
  161. }
  162. let translateX = 0
  163. let translateY = 0
  164. if (overlayCenter) {
  165. translateX = origin.x + horizOffset
  166. translateY = origin.y + vertOffset
  167. } else if (this.placement !== 'right' && this.placement !== 'left') {
  168. // Default to whatever user wished with placement prop
  169. let usingTop = this.placement !== 'bottom'
  170. // Handle special cases, first force to displaying on top if there's no space on bottom,
  171. // regardless of what placement value was. Then check if there's no space on top, and
  172. // force to bottom, again regardless of what placement value was.
  173. const topBoundary = origin.y - anchorHeight * 0.5 + (this.removePadding ? topPadding : 0)
  174. const bottomBoundary = origin.y + anchorHeight * 0.5 - (this.removePadding ? bottomPadding : 0)
  175. if (bottomBoundary + content.offsetHeight > yBounds.max) usingTop = true
  176. if (topBoundary - content.offsetHeight < yBounds.min) usingTop = false
  177. const yOffset = (this.offset && this.offset.y) || 0
  178. translateY = usingTop
  179. ? topBoundary - yOffset - content.offsetHeight
  180. : bottomBoundary + yOffset
  181. const xOffset = (this.offset && this.offset.x) || 0
  182. translateX = origin.x + horizOffset + xOffset
  183. } else {
  184. // Default to whatever user wished with placement prop
  185. let usingLeft = this.placement !== 'right'
  186. // Handle special cases, first force to displaying on left if there's no space on right,
  187. // regardless of what placement value was. Then check if there's no space on right, and
  188. // force to left, again regardless of what placement value was.
  189. const leftBoundary = origin.x - anchorWidth * 0.5 + (this.removePadding ? leftPadding : 0)
  190. const rightBoundary = origin.x + anchorWidth * 0.5 - (this.removePadding ? rightPadding : 0)
  191. if (rightBoundary + content.offsetWidth > xBounds.max) usingLeft = true
  192. if (leftBoundary - content.offsetWidth < xBounds.min) usingLeft = false
  193. const xOffset = (this.offset && this.offset.x) || 0
  194. translateX = usingLeft
  195. ? leftBoundary - xOffset - content.offsetWidth
  196. : rightBoundary + xOffset
  197. const yOffset = (this.offset && this.offset.y) || 0
  198. translateY = origin.y + vertOffset + yOffset
  199. }
  200. this.styles = {
  201. left: `${Math.round(translateX)}px`,
  202. top: `${Math.round(translateY)}px`
  203. }
  204. if (this.popoversZLayer) {
  205. this.styles['--ZI_popover_override'] = `var(--ZI_${this.popoversZLayer}_popovers)`
  206. }
  207. if (parentScreenBox) {
  208. this.styles.maxWidth = `${Math.round(parentScreenBox.width)}px`
  209. }
  210. },
  211. showPopover () {
  212. if (this.disabled) return
  213. this.disableClickOutside = true
  214. setTimeout(() => {
  215. this.disableClickOutside = false
  216. }, 0)
  217. const wasHidden = this.hidden
  218. this.hidden = false
  219. this.parentPopover && this.parentPopover.onChildPopoverState(this, true)
  220. if (this.trigger === 'click' || this.stayOnClick) {
  221. document.addEventListener('click', this.onClickOutside)
  222. }
  223. this.scrollable.addEventListener('scroll', this.onScroll)
  224. this.scrollable.addEventListener('resize', this.onResize)
  225. this.$nextTick(() => {
  226. if (wasHidden) this.$emit('show')
  227. this.updateStyles()
  228. })
  229. },
  230. hidePopover () {
  231. if (this.disabled) return
  232. if (!this.hidden) this.$emit('close')
  233. this.hidden = true
  234. this.parentPopover && this.parentPopover.onChildPopoverState(this, false)
  235. if (this.trigger === 'click') {
  236. document.removeEventListener('click', this.onClickOutside)
  237. }
  238. this.scrollable.removeEventListener('scroll', this.onScroll)
  239. this.scrollable.removeEventListener('resize', this.onResize)
  240. },
  241. resizePopover () {
  242. setTimeout(() => {
  243. this.updateStyles()
  244. }, 1)
  245. },
  246. onMouseenter (e) {
  247. if (this.trigger === 'hover') {
  248. this.lockReEntry = false
  249. clearTimeout(this.graceTimeout)
  250. this.graceTimeout = null
  251. this.showPopover()
  252. }
  253. },
  254. onMouseleave (e) {
  255. if (this.trigger === 'hover' && this.childrenShown.size === 0) {
  256. this.graceTimeout = setTimeout(() => this.hidePopover(), 1)
  257. }
  258. },
  259. onMouseenterContent (e) {
  260. if (this.trigger === 'hover' && !this.lockReEntry) {
  261. this.lockReEntry = true
  262. clearTimeout(this.graceTimeout)
  263. this.graceTimeout = null
  264. this.showPopover()
  265. }
  266. },
  267. onMouseleaveContent (e) {
  268. if (this.trigger === 'hover' && this.childrenShown.size === 0) {
  269. this.graceTimeout = setTimeout(() => this.hidePopover(), 1)
  270. }
  271. },
  272. onClick (e) {
  273. if (this.trigger === 'click') {
  274. if (this.hidden) {
  275. this.showPopover()
  276. } else {
  277. this.hidePopover()
  278. }
  279. }
  280. },
  281. onClickOutside (e) {
  282. if (this.disableClickOutside) return
  283. if (this.hidden) return
  284. if (this.$refs.content && this.$refs.content.contains(e.target)) return
  285. if (this.$el.contains(e.target)) return
  286. if (this.childrenShown.size > 0) return
  287. this.hidePopover()
  288. if (this.parentPopover) this.parentPopover.onClickOutside(e)
  289. },
  290. onScroll (e) {
  291. this.updateStyles()
  292. },
  293. onResize (e) {
  294. const content = this.$refs.content
  295. if (!content) return
  296. if (this.oldSize.width !== content.offsetWidth || this.oldSize.height !== content.offsetHeight) {
  297. this.updateStyles()
  298. this.oldSize = { width: content.offsetWidth, height: content.offsetHeight }
  299. }
  300. },
  301. onChildPopoverState (childRef, state) {
  302. if (state) {
  303. this.childrenShown.add(childRef)
  304. } else {
  305. this.childrenShown.delete(childRef)
  306. }
  307. }
  308. },
  309. updated () {
  310. // Monitor changes to content size, update styles only when content sizes have changed,
  311. // that should be the only time we need to move the popover box if we don't care about scroll
  312. // or resize
  313. this.onResize()
  314. },
  315. mounted () {
  316. this.teleport = true
  317. let scrollable = this.$refs.trigger.closest('.column.-scrollable') ||
  318. this.$refs.trigger.closest('.mobile-notifications')
  319. if (!scrollable) scrollable = window
  320. this.scrollable = scrollable
  321. let parent = this.$parent
  322. while (parent && parent.$.type.name !== 'Popover') {
  323. parent = parent.$parent
  324. }
  325. this.parentPopover = parent
  326. },
  327. beforeUnmount () {
  328. this.hidePopover()
  329. }
  330. }
  331. export default Popover