Ditch old popover handler
All checks were successful
buildbot/Hondaforum Site Build done.

This commit is contained in:
László Károlyi 2023-11-19 17:37:25 +01:00
parent 7a15361e17
commit c6ac4466f0
Signed by: karolyi
GPG key ID: 2DCAF25E55735BFE
6 changed files with 360 additions and 812 deletions

View file

@ -1,17 +1,4 @@
{% macro topic_comment_action_template() -%}
{% if request.user.is_authenticated -%}
<div class="btn-group btn-group-sm" role="group" aria-label="{{ _('Comment vote button group') }}">
<button class="btn btn-outline-secondary text-success vote-up" data-bs-toggle="tooltip" title="{{ _('Vote up') }}">
<i class="fa fa-fw fa-thumbs-o-up" aria-hidden="true"></i>
<span class="visually-hidden">{{ _('Vote up') }}</span>
</button>
<button class="btn btn-outline-secondary text-danger vote-down" data-bs-toggle="tooltip" title="{{ _('Vote down') }}">
<i class="fa fa-fw fa-thumbs-o-down" aria-hidden="true"></i>
<span class="visually-hidden">{{ _('Vote down') }}</span>
</button>
</div>
{% endif -%}
<a class="btn btn-outline-secondary btn-sm expand-comments-down" data-bs-toggle="tooltip" title="{{ _('Show all previous comments') }}" tabindex="0">
<i class="icon" aria-hidden="true"></i>
<span class="visually-hidden">{{ _('Show all previous comments') }}</span>

View file

@ -4,9 +4,12 @@ import {
unsetButtonLoading
} from './utils/common.ts'
import { Paginator } from './utils/paginator.ts'
import { add as popoverAdd } from './utils/popover.ts'
import {
type ForumPopoverEl, add as popoverAdd, teardown as popoverTeardown
} from './utils/popover.ts'
import { initAllTimeElements } from './utils/time-actualizer.ts'
import { add as usernameAdd } from './utils/username.ts'
import { addOnremoveCallback } from './utils/mutation-observer.ts'
class LoadArchivedError extends Error {
elSvg?: SVGElement
@ -104,8 +107,8 @@ class TopicGroup {
throw error
}
private getPopoverContent(element: HTMLAnchorElement): string {
const slug = element.dataset.slug
private getPopoverContent(el: ForumPopoverEl<HTMLAnchorElement>): string {
const slug = el.dataset.slug
if (slug == null) return '?'
const tipTemplate = this.root.querySelector(
`.forum-topic-tooltip-template[data-slug="${slug}"]`)
@ -124,17 +127,17 @@ class TopicGroup {
const topicLinkElements =
this.root.querySelectorAll<HTMLAnchorElement>('.topic-link')
for (const node of topicLinkElements) {
popoverAdd<HTMLAnchorElement>(node, {
callbacks: { getContent: this.getPopoverContent.bind(this) },
html: true,
sanitize: false,
delay: {
show: 250,
hide: 500
},
customClass: 'topicgroup-popover-wrapper',
showOnClick: false
popoverAdd(node, {
popoverOpts: {
content: this.getPopoverContent.bind(this),
html: true,
sanitize: false,
delay: { show: 250, hide: 500 },
customClass: 'topicgroup-popover-wrapper',
container: <HTMLDivElement>node.parentElement
}
})
addOnremoveCallback(node, popoverTeardown)
}
this.wrappers.paginators =
this.root.querySelectorAll(this.options.selectors.paginationWrapper)

View file

@ -1,10 +1,8 @@
import { getCookie, getSvgIcon, loaderTemplate } from './common'
import {
type ForumPopoverEl,
type ForumPopoverTipEl,
add as popoverAdd,
type ForumPopoverEl, type ForumPopoverTipEl, add as popoverAdd,
teardown as popoverTeardown
} from './new-popover.ts'
} from './popover.ts'
import { urlTemplates, stringsPassed } from '../topic-comment-listing'
import { mdiLinkOff, mdiThumbUp, mdiThumbDown } from '@mdi/js'
import { add as usernameAdd } from './username.ts'

View file

@ -1,306 +0,0 @@
import Popover from 'bootstrap/js/src/popover.js'
import { addOnremoveCallback } from './mutation-observer'
export type ForumPopoverEl<ElCls extends HTMLElement> = ElCls & {
forumPopoverConfig: StoredConfigType<ElCls>
forumPopoverTip?: ForumPopoverTipEl<ElCls>
}
export type ForumPopoverTipEl<ElCls extends HTMLElement> = ElCls & {
forumPopoverEl: ForumPopoverEl<ElCls>
}
type ComputedPopoverOpts<ElCls extends HTMLElement> =
Omit<Partial<Popover.Options>, 'trigger' | 'content'> & {
delay: { show: number, hide: number }
trigger: 'manual'
content: (el: ForumPopoverEl<ElCls>) => string | Element
}
type PassedPopoverOpts<ElCls extends HTMLElement> =
Omit<Partial<Popover.Options>, 'trigger' | 'content'> & {
content?: (el: ForumPopoverEl<ElCls>) => string | Element
}
interface CallbacksType<ElCls extends HTMLElement> {
onTipInserted?: (el: ForumPopoverEl<ElCls>) => void
onTipRemoved?: (el: ForumPopoverEl<ElCls>) => void
}
interface InitConfigType<ElCls extends HTMLElement> {
popoverOpts?: PassedPopoverOpts<ElCls>
callbacks?: CallbacksType<ElCls>
}
interface StoredConfigType<ElCls extends HTMLElement> {
popoverObj: Popover
computedOpts: ComputedPopoverOpts<ElCls>
showTimeoutId?: ReturnType<typeof setTimeout>
hideTimeoutId?: ReturnType<typeof setTimeout>
isShown: boolean
isPointerenteredElement: boolean
isPointerEnteredTip: boolean
isFocusedElement: boolean
isFocusedTip: boolean
callbacks?: CallbacksType<ElCls>
origGetContent?: (el: ForumPopoverEl<ElCls>) => string | Element
cachedContent?: string | Element
}
function onFocusinTip<ElCls extends HTMLElement>(
this: ForumPopoverTipEl<ElCls>
): void {
console.debug('onFocusinTip', document.activeElement)
this.forumPopoverEl.forumPopoverConfig.isFocusedTip = true
showPopover(this.forumPopoverEl.forumPopoverConfig)
}
function onFocusoutTip<ElCls extends HTMLElement>(
this: ForumPopoverTipEl<ElCls>
): void {
console.debug('onFocusoutTip', document.activeElement)
this.forumPopoverEl.forumPopoverConfig.isFocusedTip = false
hidePopover(this.forumPopoverEl.forumPopoverConfig)
}
function onTipRemoved<ElCls extends HTMLElement>(
elTip: ForumPopoverTipEl<ElCls>
): void {
// console.debug('onTipRemoved', elTip)
elTip.removeEventListener('pointerenter', onPointerEnterTip)
elTip.removeEventListener('pointerleave', onPointerLeaveTip)
elTip.removeEventListener('focusin', onFocusinTip)
elTip.removeEventListener('focusout', onFocusoutTip)
const config = elTip.forumPopoverEl.forumPopoverConfig
if (config.callbacks?.onTipRemoved) {
config.callbacks.onTipRemoved(elTip.forumPopoverEl)
}
config.isPointerenteredElement = false
config.isFocusedTip = false
delete elTip.forumPopoverEl.forumPopoverTip
// @ts-expect-error Allowed because element will get destroyed
delete elTip.forumPopoverEl
}
function onTipInserted<ElCls extends HTMLElement>(
this: ForumPopoverEl<ElCls>
): void {
const config = this.forumPopoverConfig
// @ts-expect-error `tip` exists, it's just not in the type definition
const elTip = <ForumPopoverTipEl<ElCls>>config.popoverObj.tip
elTip.forumPopoverEl = this
this.forumPopoverTip = elTip
if (config.callbacks?.onTipInserted) config.callbacks.onTipInserted(this)
elTip.addEventListener('pointerenter', onPointerEnterTip)
elTip.addEventListener('pointerleave', onPointerLeaveTip)
elTip.addEventListener('focusin', onFocusinTip)
elTip.addEventListener('focusout', onFocusoutTip)
addOnremoveCallback(elTip, onTipRemoved)
}
function onPointerEnterTip<ElCls extends HTMLElement>(
this: ForumPopoverTipEl<ElCls>
): void {
// console.debug('onPointerEnterTip', this, ev)
const config = this.forumPopoverEl.forumPopoverConfig
config.isPointerEnteredTip = true
showPopover(config)
}
function onPointerLeaveTip<ElCls extends HTMLElement>(
this: ForumPopoverTipEl<ElCls>
): void {
// console.debug('onPointerLeaveTip', this)
const config = this.forumPopoverEl.forumPopoverConfig
config.isPointerEnteredTip = false
hidePopover(config)
}
function onFocusinElement<ElCls extends HTMLElement>(
this: ForumPopoverEl<ElCls>
): void {
// console.debug('onFocusinElement', document.activeElement)
const config = this.forumPopoverConfig
config.isFocusedElement = true
showPopover(config)
}
function onFocusoutElement<ElCls extends HTMLElement>(
this: ForumPopoverEl<ElCls>
): void {
// console.debug('onFocusoutElement', document.activeElement)
const config = this.forumPopoverConfig
config.isFocusedElement = false
hidePopover(config)
}
function onPointerEnterElement<ElCls extends HTMLElement>(
this: ForumPopoverEl<ElCls>
): void {
// console.debug('onPointerenterElement')
const config = this.forumPopoverConfig
config.isPointerenteredElement = true
showPopover(config)
}
function onPointerLeaveElement<ElCls extends HTMLElement>(
this: ForumPopoverEl<ElCls>
): void {
// console.debug('onPointerleaveElement')
const config = this.forumPopoverConfig
config.isPointerenteredElement = false
hidePopover(config)
}
function onPopoverHidden<ElCls extends HTMLElement>(
this: ForumPopoverEl<ElCls>
): void {
const config = this.forumPopoverConfig
config.isShown = config.isFocusedTip = config.isPointerEnteredTip = false
}
// The content() function is executed twice (probable bootstrap bug):
// use this function as an umbrella to only call it once
function onGetContent<ElCls extends HTMLElement>(
el: ForumPopoverEl<ElCls>
): string | Element {
if (el.forumPopoverConfig.cachedContent == null) {
el.forumPopoverConfig.cachedContent = el.forumPopoverConfig.origGetContent
? el.forumPopoverConfig.origGetContent(el)
: ''
}
return el.forumPopoverConfig.cachedContent
}
function isForumPopoverEl<ElCls extends HTMLElement>(
el: ElCls | ForumPopoverEl<ElCls>
): el is ForumPopoverEl<ElCls> {
return Object.hasOwn(el, 'forumPopoverConfig')
}
function clearHideTimeoutId<ElCls extends HTMLElement>(
config: StoredConfigType<ElCls>
): void {
if (config.hideTimeoutId == null) return
clearTimeout(config.hideTimeoutId)
delete config.hideTimeoutId
}
function clearShowTimeoutId<ElCls extends HTMLElement>(
config: StoredConfigType<ElCls>
): void {
if (config.showTimeoutId == null) return
clearTimeout(config.showTimeoutId)
delete config.showTimeoutId
}
function showPopoverImmediately<ElCls extends HTMLElement>(
config: StoredConfigType<ElCls>
): void {
delete config.showTimeoutId
if (config.isShown) return
if (
!config.isFocusedElement &&
!config.isPointerenteredElement
) return
config.isShown = true
config.popoverObj.show()
delete config.cachedContent
}
function showPopover<ElCls extends HTMLElement>(
config: StoredConfigType<ElCls>
): void {
// console.debug('showPopover', config)
clearHideTimeoutId(config)
if (config.showTimeoutId != null) return
config.showTimeoutId =
setTimeout(showPopoverImmediately, config.computedOpts.delay.show, config)
}
function hidePopoverImmediately<ElCls extends HTMLElement>(
config: StoredConfigType<ElCls>
): void {
delete config.hideTimeoutId
if (
!config.isShown ||
config.isPointerenteredElement ||
config.isPointerEnteredTip ||
config.isFocusedElement ||
config.isFocusedTip
) return
config.popoverObj.hide()
}
function hidePopover<ElCls extends HTMLElement>(
config: StoredConfigType<ElCls>
): void {
// console.debug('hidePopover', config)
clearShowTimeoutId(config)
if (config.hideTimeoutId != null) return
config.hideTimeoutId =
setTimeout(hidePopoverImmediately, config.computedOpts.delay.hide, config)
}
function computeOpts<ElCls extends HTMLElement>(
origOpts?: PassedPopoverOpts<ElCls>
): ComputedPopoverOpts<ElCls> {
const computedOpts: ComputedPopoverOpts<ElCls> = {
...origOpts,
delay: { show: 250, hide: 0 },
trigger: 'manual',
content: onGetContent
}
if (origOpts?.delay != null) {
computedOpts.delay = {
show: typeof origOpts.delay === 'number'
? origOpts.delay
: origOpts.delay.show,
hide: typeof origOpts.delay === 'number'
? origOpts.delay
: origOpts.delay.hide
}
}
return computedOpts
}
export function add<ElCls extends HTMLElement>(
el: ElCls, config: InitConfigType<ElCls>
): void {
if (isForumPopoverEl(el)) return
const computedOpts = computeOpts<ElCls>(config.popoverOpts)
const popoverConfig: StoredConfigType<ElCls> = {
popoverObj: new Popover(el, <Partial<Popover.Options>>computedOpts),
computedOpts,
isShown: false,
isPointerenteredElement: false,
isPointerEnteredTip: false,
isFocusedElement: false,
isFocusedTip: false,
callbacks: config.callbacks,
origGetContent: config.popoverOpts?.content
}
Object.defineProperty(el, 'forumPopoverConfig', {
value: popoverConfig,
configurable: true
})
el.addEventListener('inserted.bs.popover', onTipInserted)
el.addEventListener('hidden.bs.popover', onPopoverHidden)
el.addEventListener('focusin', onFocusinElement)
el.addEventListener('focusout', onFocusoutElement)
el.addEventListener('pointerenter', onPointerEnterElement)
el.addEventListener('pointerleave', onPointerLeaveElement)
}
export function teardown<ElCls extends HTMLElement>(el: ElCls): void {
if (!isForumPopoverEl(el)) return
el.forumPopoverConfig.popoverObj.dispose()
el.removeEventListener('inserted.bs.popover', onTipInserted)
el.removeEventListener('hidden.bs.popover', onPopoverHidden)
el.removeEventListener('focusin', onFocusinElement)
el.removeEventListener('focusout', onFocusoutElement)
el.removeEventListener('pointerenter', onPointerEnterElement)
el.removeEventListener('pointerleave', onPointerLeaveElement)
// @ts-expect-error Popover is being destroyed so it's okay
delete el.forumPopoverConfig
}

View file

@ -1,6 +1,8 @@
import { getScrollTop, getSvgIcon, extractTemplateContent } from './common.ts'
import Tooltip from 'bootstrap/js/src/tooltip.js'
import { add as popoverAdd } from './popover.ts'
import {
type ForumPopoverEl, add as popoverAdd, teardown as popoverTeardown
} from './popover.ts'
import { add as toastAdd } from './toast.ts'
import { add as usernameAdd } from './username.ts'
import { add as timeActualizerAdd } from './time-actualizer.ts'
@ -84,29 +86,46 @@ export class OneComment {
toastAdd({ message: stringsPassed.noClipboardError })
}
private onPopoverDomInsertedCommentAction(el: HTMLButtonElement): void {
}
private onPopoverDomRemovedCommentAction(el: HTMLButtonElement): void {
}
private onTeardownActionsPopoverContent(el: HTMLButtonElement): void {
private onPopoverTipInsertedCommentAction(
el: ForumPopoverEl<HTMLButtonElement>
): void {
if (!this.actionsPopoverReferences) return
if (this.actionsPopoverReferences?.elExpandCommentsDown) {
Tooltip.getOrCreateInstance(
this.actionsPopoverReferences.elExpandCommentsDown).dispose()
this.actionsPopoverReferences.elExpandCommentsDown)
}
if (this.actionsPopoverReferences?.elExpandCommentsUp) {
Tooltip.getOrCreateInstance(
this.actionsPopoverReferences.elExpandCommentsUp).dispose()
this.actionsPopoverReferences.elExpandCommentsUp)
}
if (this.actionsPopoverReferences?.elExpandCommentsUpRecursive) {
Tooltip.getOrCreateInstance(
this.actionsPopoverReferences.elExpandCommentsUpRecursive).dispose()
this.actionsPopoverReferences.elExpandCommentsUpRecursive)
}
if (this.actionsPopoverReferences?.elExpandCommentsInThread) {
Tooltip.getOrCreateInstance(
this.actionsPopoverReferences.elExpandCommentsInThread)
}
delete this.actionsPopoverReferences
}
private onPopoverTipRemovedCommentAction(el: HTMLButtonElement): void {
if (this.actionsPopoverReferences?.elExpandCommentsDown) {
Tooltip.getInstance(
this.actionsPopoverReferences.elExpandCommentsDown)?.dispose()
}
if (this.actionsPopoverReferences?.elExpandCommentsUp) {
Tooltip.getInstance(
this.actionsPopoverReferences.elExpandCommentsUp)?.dispose()
}
if (this.actionsPopoverReferences?.elExpandCommentsUpRecursive) {
Tooltip.getInstance(
this.actionsPopoverReferences.elExpandCommentsUpRecursive)?.dispose()
}
if (this.actionsPopoverReferences?.elExpandCommentsInThread) {
Tooltip.getInstance(
this.actionsPopoverReferences.elExpandCommentsInThread
).dispose()
)?.dispose()
}
delete this.actionsPopoverReferences
}
@ -115,8 +134,7 @@ export class OneComment {
const commentLink = event.currentTarget
if (!(commentLink instanceof HTMLAnchorElement)) return
event.preventDefault()
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
if (!navigator.clipboard) {
if (navigator.clipboard == null) {
this.onErrorClipboardCopy()
return
}
@ -201,7 +219,9 @@ export class OneComment {
}
}
private initializePopoverOfCommentActions(): HTMLDivElement {
private getContentCommentActions(
el: ForumPopoverEl<HTMLButtonElement>
): HTMLElement {
const elRoot = <HTMLDivElement>elCommentActionTemplate.cloneNode(true)
this.actionsPopoverReferences = {
elContentRoot: elRoot
@ -212,7 +232,6 @@ export class OneComment {
if (this.elPrevCommentLink) {
elAnchorEcd.getElementsByClassName('icon')[0]
.replaceWith(getSvgIcon(mdiArrowLeft))
Tooltip.getOrCreateInstance(elAnchorEcd)
this.actionsPopoverReferences.elExpandCommentsDown = elAnchorEcd
} else elAnchorEcd.remove()
const elAnchorEcu =
@ -224,10 +243,8 @@ export class OneComment {
if (this.oneReplies.size) {
elAnchorEcu.getElementsByClassName('icon')[0]
.replaceWith(getSvgIcon(mdiArrowRight))
Tooltip.getOrCreateInstance(elAnchorEcu)
elAnchorEcur.getElementsByClassName('icon')[0]
.replaceWith(getSvgIcon(myArrowRightDouble))
Tooltip.getOrCreateInstance(elAnchorEcur)
this.actionsPopoverReferences.elExpandCommentsUp = elAnchorEcu
this.actionsPopoverReferences.elExpandCommentsUpRecursive = elAnchorEcur
} else {
@ -240,7 +257,6 @@ export class OneComment {
if (this.oneReplies.size || this.elPrevCommentLink) {
elAnchorEcit.getElementsByClassName('icon')[0]
.replaceWith(getSvgIcon(mdiArrowLeftRight))
Tooltip.getOrCreateInstance(elAnchorEcit)
this.actionsPopoverReferences.elExpandCommentsInThread = elAnchorEcit
} else elAnchorEcit.remove()
this.initPopoverLinks()
@ -254,30 +270,24 @@ export class OneComment {
}
this.elBtnCommentActions.replaceChildren(elSvgCog.cloneNode(true))
popoverAdd(this.elBtnCommentActions, {
popoverOpts: {
content: el => {
return this.getContentCommentActions(el)
},
html: true,
delay: { show: 500, hide: 500 },
sanitize: false,
customClass: 'forum-topic-comment-action-popovercontent',
container: <HTMLDivElement>this.elBtnCommentActions.parentElement
},
callbacks: {
getContent: (el) => {
return this.initializePopoverOfCommentActions()
onTipInserted: el => {
this.onPopoverTipInsertedCommentAction(el)
},
onDomInsertedTip: (el: HTMLButtonElement) => {
this.onPopoverDomInsertedCommentAction(el)
},
onDomRemovedTip: (el) => {
this.onPopoverDomRemovedCommentAction(el)
},
onInvalidateContent: (el: HTMLButtonElement) => {
this.onTeardownActionsPopoverContent(el)
},
onTeardown: (el: HTMLButtonElement) => {
this.onTeardownActionsPopoverContent(el)
onTipRemoved: el => {
this.onPopoverTipRemovedCommentAction(el)
}
},
html: true,
delay: {
show: 500, hide: 500
},
sanitize: false,
customClass: 'forum-topic-comment-action-popovercontent',
showOnClick: true
}
})
}
@ -327,6 +337,9 @@ export class OneComment {
for (const el of this.oneReplies.values()) this.teardownOneReply(el)
this.oneReplies.clear()
this.teardownVotingValue()
if (this.elBtnCommentActions.parentElement) {
popoverTeardown(this.elBtnCommentActions)
}
}
flashHighlight(): void {

View file

@ -1,453 +1,306 @@
import Popover from 'bootstrap/js/src/popover.js'
import { addOnremoveCallback } from './mutation-observer.ts'
import { addOnremoveCallback } from './mutation-observer'
interface PopoverInitOptions<WatchedType> {
callbacks: {
getTitle?: (element: WatchedType) => string | HTMLElement
getContent: (element: WatchedType) => string | HTMLElement
onDomInsertedTip?: (element: WatchedType) => void
onDomRemovedTip?: (element: WatchedType) => void
onInvalidateContent?: (element: WatchedType) => void
onInvalidateTitle?: (element: WatchedType) => void
onTeardown?: (element: WatchedType) => void
export type ForumPopoverEl<ElCls extends HTMLElement> = ElCls & {
forumPopoverConfig: StoredConfigType<ElCls>
forumPopoverTip?: ForumPopoverTipEl<ElCls>
}
export type ForumPopoverTipEl<ElCls extends HTMLElement> = ElCls & {
forumPopoverEl: ForumPopoverEl<ElCls>
}
type ComputedPopoverOpts<ElCls extends HTMLElement> =
Omit<Partial<Popover.Options>, 'trigger' | 'content'> & {
delay: { show: number, hide: number }
trigger: 'manual'
content: (el: ForumPopoverEl<ElCls>) => string | Element
}
html: Popover.Options['html']
delay: {
show: number
hide: number
type PassedPopoverOpts<ElCls extends HTMLElement> =
Omit<Partial<Popover.Options>, 'trigger' | 'content'> & {
content?: (el: ForumPopoverEl<ElCls>) => string | Element
}
showOnClick: boolean
sanitize: Popover.Options['sanitize']
customClass?: Popover.Options['customClass']
initialContent?: string
interface CallbacksType<ElCls extends HTMLElement> {
onTipInserted?: (el: ForumPopoverEl<ElCls>) => void
onTipRemoved?: (el: ForumPopoverEl<ElCls>) => void
}
export interface GetFunctionsReturnType {
invalidateTitle: () => void
invalidateContent: () => void
isShown: () => boolean
interface InitConfigType<ElCls extends HTMLElement> {
popoverOpts?: PassedPopoverOpts<ElCls>
callbacks?: CallbacksType<ElCls>
}
interface BoundFunctions {
mouseEnterPopover: (event: Event) => void
mouseLeavePopover: (event: Event) => void
clickElement: (event: Event) => void
mouseEnterElement: (event: Event) => void
mouseLeaveElement: (event: Event) => void
focusInPopover: (event: Event) => void
focusOutPopover: (event: Event) => void
focusInElement: (event: Event) => void
focusOutElement: (event: Event) => void
popoverInserted: (event: Event) => void
}
interface ObjConfig<WatchedType> {
popoverObj?: Popover
cachedTitle?: string | HTMLElement
cachedContent?: string | HTMLElement
isShown: boolean
isTipHovered: boolean
isPopoverFocused: boolean
isElementHovered: boolean
// isFocused is for keyboard focus
isElementFocused: boolean
interface StoredConfigType<ElCls extends HTMLElement> {
popoverObj: Popover
computedOpts: ComputedPopoverOpts<ElCls>
showTimeoutId?: ReturnType<typeof setTimeout>
hideTimeoutId?: ReturnType<typeof setTimeout>
delay: {
show: number
hide: number
}
elPopover?: HTMLDivElement
boundFunctions: BoundFunctions
exportedFunctions: GetFunctionsReturnType
callbacks: {
getTitle?: (element: WatchedType) => string | HTMLElement
getContent: (element: WatchedType) => string | HTMLElement
onDomInsertedTip?: (el: WatchedType) => void
onDomRemovedTip?: (el: WatchedType) => void
onInvalidateTitle?: (el: WatchedType) => void
onInvalidateContent?: (el: WatchedType) => void
onTeardown?: (el: WatchedType) => void
}
showOnClick: boolean
isShown: boolean
isPointerenteredElement: boolean
isPointerEnteredTip: boolean
isFocusedElement: boolean
isFocusedTip: boolean
callbacks?: CallbacksType<ElCls>
origGetContent?: (el: ForumPopoverEl<ElCls>) => string | Element
cachedContent?: string | Element
}
class PopoverHandler {
private readonly instances =
new Map<HTMLElement, ObjConfig<HTMLElement>>()
private onPopoverInserted<WatchedType extends HTMLElement>(
event: Event
): void {
const el = <WatchedType>event.currentTarget
const config = this.getConfigForElement(el)
config.elPopover = <HTMLDivElement>config.popoverObj?.tip
const parentNode = el.parentNode
if (!parentNode) return
config.elPopover.addEventListener(
'mouseenter', config.boundFunctions.mouseEnterPopover)
config.elPopover.addEventListener(
'mouseleave', config.boundFunctions.mouseLeavePopover)
config.elPopover.addEventListener(
'focusin', config.boundFunctions.focusInPopover)
config.elPopover.addEventListener(
'focusout', config.boundFunctions.focusOutPopover)
const removeCallback = (elPopover: HTMLDivElement): void => {
this.onPopoverRemoved(el, elPopover)
}
addOnremoveCallback(config.elPopover, removeCallback)
if (config.callbacks.onDomInsertedTip) {
config.callbacks.onDomInsertedTip(el)
}
}
private onPopoverRemoved<WatchedType extends HTMLElement>(
el: WatchedType, elPopover: HTMLDivElement
): void {
const config = this.getConfigForElement(el)
elPopover.removeEventListener(
'mouseenter', config.boundFunctions.mouseEnterPopover)
elPopover.removeEventListener(
'mouseleave', config.boundFunctions.mouseLeavePopover)
elPopover.removeEventListener(
'focusin', config.boundFunctions.focusInPopover)
elPopover.removeEventListener(
'focusout', config.boundFunctions.focusOutPopover)
delete config.elPopover
if (config.callbacks.onDomRemovedTip) config.callbacks.onDomRemovedTip(el)
}
private onMouseenterPopover<WatchedType extends HTMLElement>(
element: WatchedType
): void {
const config = this.getConfigForElement(element)
config.isTipHovered = true
}
private onMouseleavePopover<WatchedType extends HTMLElement>(
element: WatchedType
): void {
const config = this.getConfigForElement(element)
config.isTipHovered = false
this.hidePopover(config)
}
private onClickElement(event: Event): void {
const config = this.getConfigForEvent(event)
config.isElementHovered = true
this.showPopoverImmediately(config)
}
private onMouseenterElement(event: Event): void {
const config = this.getConfigForEvent(event)
config.isElementHovered = true
this.showPopover(config)
}
private onMouseleaveElement(event: Event): void {
const config = this.getConfigForEvent(event)
config.isElementHovered = false
this.hidePopover(config)
}
private onFocusinPopover<WatchedType extends HTMLElement>(
element: WatchedType
): void {
const config = this.getConfigForElement(element)
config.isPopoverFocused = true
this.showPopover(config)
}
private onFocusoutPopover<WatchedType extends HTMLElement>(
element: WatchedType
): void {
const config = this.getConfigForElement(element)
config.isPopoverFocused = false
this.hidePopover(config)
}
private onFocusinElement(event: Event): void {
const config = this.getConfigForEvent(event)
config.isElementFocused = true
this.showPopover(config)
}
private onFocusoutElement(event: Event): void {
const config = this.getConfigForEvent(event)
config.isElementFocused = false
this.hidePopover(config)
}
private getConfigForElement<WatchedType extends HTMLElement>(
element: WatchedType
): ObjConfig<WatchedType> {
const config: ObjConfig<WatchedType> | undefined =
this.instances.get(element)
if (!config) {
throw Error(`PopoverHandler: No instance for ${element.outerHTML}`)
}
return config
}
private getConfigForEvent<WatchedType extends HTMLElement>(
event: Event
): ObjConfig<WatchedType> {
const element = <WatchedType>event.currentTarget
return this.getConfigForElement(element)
}
private clearHideTimeoutId<WatchedType>(
config: ObjConfig<WatchedType>
): void {
if (config.hideTimeoutId == null) return
clearTimeout(config.hideTimeoutId)
delete config.hideTimeoutId
}
private clearShowTimeoutId<WatchedType>(
config: ObjConfig<WatchedType>
): void {
if (config.showTimeoutId == null) return
clearTimeout(config.showTimeoutId)
delete config.showTimeoutId
}
private showPopoverImmediately<WatchedType>(
config: ObjConfig<WatchedType>
): void {
delete config.showTimeoutId
if (config.isShown) return
if (
!config.isElementFocused &&
!config.isElementHovered
) return
config.popoverObj?.show()
config.isShown = true
}
private showPopover<WatchedType>(config: ObjConfig<WatchedType>): void {
this.clearHideTimeoutId(config)
if (config.showTimeoutId != null) return
config.showTimeoutId = setTimeout(() => {
this.showPopoverImmediately(config)
}, config.delay.show)
}
private hidePopover<WatchedType>(config: ObjConfig<WatchedType>): void {
this.clearShowTimeoutId(config)
if (config.hideTimeoutId != null) return
config.hideTimeoutId = setTimeout(() => {
delete config.hideTimeoutId
if (
!config.isShown ||
config.isTipHovered ||
config.isElementFocused ||
config.isElementHovered ||
config.isPopoverFocused
) return
config.popoverObj?.hide()
delete config.elPopover
config.isShown = false
}, config.delay.hide)
}
private invalidateTitle<WatchedType extends HTMLElement>(
element: WatchedType
): void {
const config = this.getConfigForElement(element)
delete config.cachedTitle
if (config.callbacks.onInvalidateTitle) {
config.callbacks.onInvalidateTitle(element)
}
if (!config.isShown || !config.callbacks.getTitle) return
config.popoverObj?.setContent({
'.popover-header': config.callbacks.getTitle(element)
})
}
private invalidateContent<WatchedType extends HTMLElement>(
element: WatchedType
): void {
const config = this.getConfigForElement(element)
delete config.cachedContent
console.debug('invalidateContent', config.isShown)
if (config.callbacks.onInvalidateContent) {
config.callbacks.onInvalidateContent(element)
}
if (!config.isShown) return
config.popoverObj?.setContent({
'.popover-body': config.callbacks.getContent(element)
})
}
private teardown<WatchedType extends HTMLElement>(
element: WatchedType
): void {
const config = this.getConfigForElement(element)
element.removeEventListener(
'mouseenter', config.boundFunctions.mouseEnterElement)
element.removeEventListener(
'mouseleave', config.boundFunctions.mouseLeaveElement)
if (config.showOnClick) {
element.removeEventListener(
'click', config.boundFunctions.clickElement)
}
element.removeEventListener(
'focusin', config.boundFunctions.focusInElement)
element.removeEventListener(
'focusout', config.boundFunctions.focusOutElement)
element.removeEventListener(
'inserted.bs.popover', config.boundFunctions.popoverInserted)
if (config.callbacks.onTeardown) config.callbacks.onTeardown(element)
config.popoverObj?.dispose()
delete config.popoverObj
this.clearHideTimeoutId(config)
this.clearShowTimeoutId(config)
this.instances.delete(element)
}
private initReturnedFunctions<WatchedType extends HTMLElement>(
element: WatchedType
): GetFunctionsReturnType {
return {
invalidateTitle: (): void => {
this.invalidateTitle(element)
},
invalidateContent: (): void => {
this.invalidateContent(element)
},
isShown: (): boolean => {
const config = this.getConfigForElement(element)
return config.isShown
}
}
}
private initBoundFunctions<WatchedType extends HTMLElement>(
element: WatchedType
): BoundFunctions {
return {
mouseEnterPopover: (): void => { this.onMouseenterPopover(element) },
mouseLeavePopover: (): void => { this.onMouseleavePopover(element) },
clickElement: this.onClickElement.bind(this),
mouseEnterElement: this.onMouseenterElement.bind(this),
mouseLeaveElement: this.onMouseleaveElement.bind(this),
focusInPopover: () => { this.onFocusinPopover(element) },
focusOutPopover: () => { this.onFocusoutPopover(element) },
focusInElement: this.onFocusinElement.bind(this),
focusOutElement: this.onFocusoutElement.bind(this),
popoverInserted: this.onPopoverInserted.bind(this),
}
}
private getTitle<WatchedType extends HTMLElement>(
element: WatchedType,
cbGetTitle: (element: WatchedType) => string | HTMLElement
): string | HTMLElement {
const objConfig = this.getConfigForElement(element)
if (objConfig.cachedTitle != null) return objConfig.cachedTitle
const content = objConfig.cachedTitle = cbGetTitle(element)
return content
}
private getContent<WatchedType extends HTMLElement>(
element: WatchedType
): string | HTMLElement {
const objConfig = this.getConfigForElement(element)
if (objConfig.cachedContent != null) return objConfig.cachedContent
const content = objConfig.cachedContent =
objConfig.callbacks.getContent(element)
return content
}
private initPopoverObj<WatchedType extends HTMLElement>(
element: WatchedType, parentNode: Element,
options: PopoverInitOptions<WatchedType>
): Popover {
const popoverOpts: Partial<Popover.Options> = {
container: parentNode,
content: () => {
return this.getContent(element)
},
placement: 'auto',
trigger: 'manual',
html: options.html,
sanitize: options.sanitize,
customClass: options.customClass ?? '',
delay: options.delay
}
if (options.callbacks.getTitle) {
const cbGetTitle = options.callbacks.getTitle
popoverOpts.title = () => {
return this.getTitle(element, cbGetTitle)
}
}
return new Popover(element, popoverOpts)
}
getFunctions<WatchedType extends HTMLElement>(
element: WatchedType
): GetFunctionsReturnType {
return this.getConfigForElement(element).exportedFunctions
}
setup<WatchedType extends HTMLElement>(
element: WatchedType,
options: PopoverInitOptions<WatchedType>
): void {
const parentNode = element.parentNode
if (!(parentNode instanceof HTMLElement)) {
throw Error(
`PopoverHandler.add: parentNode of ${element.outerHTML} must be ` +
'HTMLElement')
}
const popoverObj = this.initPopoverObj(element, parentNode, options)
const boundFunctions = this.initBoundFunctions(element)
const objConfig: ObjConfig<WatchedType> = {
popoverObj,
cachedContent: options.initialContent,
isShown: false,
isTipHovered: false,
isPopoverFocused: false,
isElementHovered: false,
isElementFocused: false,
delay: options.delay,
exportedFunctions: this.initReturnedFunctions(element),
boundFunctions,
callbacks: {
getTitle: options.callbacks.getTitle,
getContent: options.callbacks.getContent,
onDomInsertedTip: options.callbacks.onDomInsertedTip,
onDomRemovedTip: options.callbacks.onDomRemovedTip,
onInvalidateTitle: options.callbacks.onInvalidateTitle,
onInvalidateContent: options.callbacks.onInvalidateContent,
onTeardown: options.callbacks.onTeardown
},
showOnClick: options.showOnClick
}
this.instances.set(element, <ObjConfig<HTMLElement>>objConfig)
element.addEventListener('mouseenter', boundFunctions.mouseEnterElement)
element.addEventListener('mouseleave', boundFunctions.mouseLeaveElement)
if (options.showOnClick) {
element.addEventListener('click', boundFunctions.clickElement)
}
element.addEventListener('focusin', boundFunctions.focusInElement)
element.addEventListener('focusout', boundFunctions.focusOutElement)
element.addEventListener(
'inserted.bs.popover', boundFunctions.popoverInserted)
addOnremoveCallback(element, this.teardown.bind(this))
}
function onFocusinTip<ElCls extends HTMLElement>(
this: ForumPopoverTipEl<ElCls>
): void {
// console.debug('onFocusinTip', document.activeElement)
this.forumPopoverEl.forumPopoverConfig.isFocusedTip = true
showPopover(this.forumPopoverEl.forumPopoverConfig)
}
const popoverHandler = new PopoverHandler()
export function add<WatchedType extends HTMLElement>(
element: WatchedType,
options: PopoverInitOptions<WatchedType>): void {
popoverHandler.setup(element, options)
function onFocusoutTip<ElCls extends HTMLElement>(
this: ForumPopoverTipEl<ElCls>
): void {
// console.debug('onFocusoutTip', document.activeElement)
this.forumPopoverEl.forumPopoverConfig.isFocusedTip = false
hidePopover(this.forumPopoverEl.forumPopoverConfig)
}
export function getFunctions<WatchedType extends HTMLElement>(
element: WatchedType): GetFunctionsReturnType {
return popoverHandler.getFunctions(element)
function onTipRemoved<ElCls extends HTMLElement>(
elTip: ForumPopoverTipEl<ElCls>
): void {
// console.debug('onTipRemoved', elTip)
elTip.removeEventListener('pointerenter', onPointerEnterTip)
elTip.removeEventListener('pointerleave', onPointerLeaveTip)
elTip.removeEventListener('focusin', onFocusinTip)
elTip.removeEventListener('focusout', onFocusoutTip)
const config = elTip.forumPopoverEl.forumPopoverConfig
if (config.callbacks?.onTipRemoved) {
config.callbacks.onTipRemoved(elTip.forumPopoverEl)
}
config.isPointerenteredElement = false
config.isFocusedTip = false
delete elTip.forumPopoverEl.forumPopoverTip
// @ts-expect-error Allowed because element will get destroyed
delete elTip.forumPopoverEl
}
function onTipInserted<ElCls extends HTMLElement>(
this: ForumPopoverEl<ElCls>
): void {
const config = this.forumPopoverConfig
// @ts-expect-error `tip` exists, it's just not in the type definition
const elTip = <ForumPopoverTipEl<ElCls>>config.popoverObj.tip
elTip.forumPopoverEl = this
this.forumPopoverTip = elTip
if (config.callbacks?.onTipInserted) config.callbacks.onTipInserted(this)
elTip.addEventListener('pointerenter', onPointerEnterTip)
elTip.addEventListener('pointerleave', onPointerLeaveTip)
elTip.addEventListener('focusin', onFocusinTip)
elTip.addEventListener('focusout', onFocusoutTip)
addOnremoveCallback(elTip, onTipRemoved)
}
function onPointerEnterTip<ElCls extends HTMLElement>(
this: ForumPopoverTipEl<ElCls>
): void {
// console.debug('onPointerEnterTip', this, ev)
const config = this.forumPopoverEl.forumPopoverConfig
config.isPointerEnteredTip = true
showPopover(config)
}
function onPointerLeaveTip<ElCls extends HTMLElement>(
this: ForumPopoverTipEl<ElCls>
): void {
// console.debug('onPointerLeaveTip', this)
const config = this.forumPopoverEl.forumPopoverConfig
config.isPointerEnteredTip = false
hidePopover(config)
}
function onFocusinElement<ElCls extends HTMLElement>(
this: ForumPopoverEl<ElCls>
): void {
// console.debug('onFocusinElement', document.activeElement)
const config = this.forumPopoverConfig
config.isFocusedElement = true
showPopover(config)
}
function onFocusoutElement<ElCls extends HTMLElement>(
this: ForumPopoverEl<ElCls>
): void {
// console.debug('onFocusoutElement', document.activeElement)
const config = this.forumPopoverConfig
config.isFocusedElement = false
hidePopover(config)
}
function onPointerEnterElement<ElCls extends HTMLElement>(
this: ForumPopoverEl<ElCls>
): void {
// console.debug('onPointerenterElement')
const config = this.forumPopoverConfig
config.isPointerenteredElement = true
showPopover(config)
}
function onPointerLeaveElement<ElCls extends HTMLElement>(
this: ForumPopoverEl<ElCls>
): void {
// console.debug('onPointerleaveElement')
const config = this.forumPopoverConfig
config.isPointerenteredElement = false
hidePopover(config)
}
function onPopoverHidden<ElCls extends HTMLElement>(
this: ForumPopoverEl<ElCls>
): void {
const config = this.forumPopoverConfig
config.isShown = config.isFocusedTip = config.isPointerEnteredTip = false
}
// The content() function is executed twice (probable bootstrap bug):
// use this function as an umbrella to only call it once
function onGetContent<ElCls extends HTMLElement>(
el: ForumPopoverEl<ElCls>
): string | Element {
if (el.forumPopoverConfig.cachedContent == null) {
el.forumPopoverConfig.cachedContent = el.forumPopoverConfig.origGetContent
? el.forumPopoverConfig.origGetContent(el)
: ''
}
return el.forumPopoverConfig.cachedContent
}
function isForumPopoverEl<ElCls extends HTMLElement>(
el: ElCls | ForumPopoverEl<ElCls>
): el is ForumPopoverEl<ElCls> {
return Object.hasOwn(el, 'forumPopoverConfig')
}
function clearHideTimeoutId<ElCls extends HTMLElement>(
config: StoredConfigType<ElCls>
): void {
if (config.hideTimeoutId == null) return
clearTimeout(config.hideTimeoutId)
delete config.hideTimeoutId
}
function clearShowTimeoutId<ElCls extends HTMLElement>(
config: StoredConfigType<ElCls>
): void {
if (config.showTimeoutId == null) return
clearTimeout(config.showTimeoutId)
delete config.showTimeoutId
}
function showPopoverImmediately<ElCls extends HTMLElement>(
config: StoredConfigType<ElCls>
): void {
delete config.showTimeoutId
if (config.isShown) return
if (
!config.isFocusedElement &&
!config.isPointerenteredElement
) return
config.isShown = true
config.popoverObj.show()
delete config.cachedContent
}
function showPopover<ElCls extends HTMLElement>(
config: StoredConfigType<ElCls>
): void {
// console.debug('showPopover', config)
clearHideTimeoutId(config)
if (config.showTimeoutId != null) return
config.showTimeoutId =
setTimeout(showPopoverImmediately, config.computedOpts.delay.show, config)
}
function hidePopoverImmediately<ElCls extends HTMLElement>(
config: StoredConfigType<ElCls>
): void {
delete config.hideTimeoutId
if (
!config.isShown ||
config.isPointerenteredElement ||
config.isPointerEnteredTip ||
config.isFocusedElement ||
config.isFocusedTip
) return
config.popoverObj.hide()
}
function hidePopover<ElCls extends HTMLElement>(
config: StoredConfigType<ElCls>
): void {
// console.debug('hidePopover', config)
clearShowTimeoutId(config)
if (config.hideTimeoutId != null) return
config.hideTimeoutId =
setTimeout(hidePopoverImmediately, config.computedOpts.delay.hide, config)
}
function computeOpts<ElCls extends HTMLElement>(
origOpts?: PassedPopoverOpts<ElCls>
): ComputedPopoverOpts<ElCls> {
const computedOpts: ComputedPopoverOpts<ElCls> = {
...origOpts,
delay: { show: 250, hide: 0 },
trigger: 'manual',
content: onGetContent
}
if (origOpts?.delay != null) {
computedOpts.delay = {
show: typeof origOpts.delay === 'number'
? origOpts.delay
: origOpts.delay.show,
hide: typeof origOpts.delay === 'number'
? origOpts.delay
: origOpts.delay.hide
}
}
return computedOpts
}
export function add<ElCls extends HTMLElement>(
el: ElCls, config: InitConfigType<ElCls>
): void {
if (isForumPopoverEl(el)) return
const computedOpts = computeOpts<ElCls>(config.popoverOpts)
const popoverConfig: StoredConfigType<ElCls> = {
popoverObj: new Popover(el, <Partial<Popover.Options>>computedOpts),
computedOpts,
isShown: false,
isPointerenteredElement: false,
isPointerEnteredTip: false,
isFocusedElement: false,
isFocusedTip: false,
callbacks: config.callbacks,
origGetContent: config.popoverOpts?.content
}
Object.defineProperty(el, 'forumPopoverConfig', {
value: popoverConfig,
configurable: true
})
el.addEventListener('inserted.bs.popover', onTipInserted)
el.addEventListener('hidden.bs.popover', onPopoverHidden)
el.addEventListener('focusin', onFocusinElement)
el.addEventListener('focusout', onFocusoutElement)
el.addEventListener('pointerenter', onPointerEnterElement)
el.addEventListener('pointerleave', onPointerLeaveElement)
}
export function teardown<ElCls extends HTMLElement>(el: ElCls): void {
if (!isForumPopoverEl(el)) return
el.forumPopoverConfig.popoverObj.dispose()
el.removeEventListener('inserted.bs.popover', onTipInserted)
el.removeEventListener('hidden.bs.popover', onPopoverHidden)
el.removeEventListener('focusin', onFocusinElement)
el.removeEventListener('focusout', onFocusoutElement)
el.removeEventListener('pointerenter', onPointerEnterElement)
el.removeEventListener('pointerleave', onPointerLeaveElement)
// @ts-expect-error Popover is being destroyed so it's okay
delete el.forumPopoverConfig
}