code.gitea.io/gitea@v1.21.7/web_src/js/modules/tippy.js (about) 1 import tippy, {followCursor} from 'tippy.js'; 2 import {isDocumentFragmentOrElementNode} from '../utils/dom.js'; 3 4 const visibleInstances = new Set(); 5 6 export function createTippy(target, opts = {}) { 7 // the callback functions should be destructured from opts, 8 // because we should use our own wrapper functions to handle them, do not let the user override them 9 const {onHide, onShow, onDestroy, ...other} = opts; 10 const instance = tippy(target, { 11 appendTo: document.body, 12 animation: false, 13 allowHTML: false, 14 hideOnClick: false, 15 interactiveBorder: 20, 16 ignoreAttributes: true, 17 maxWidth: 500, // increase over default 350px 18 onHide: (instance) => { 19 visibleInstances.delete(instance); 20 return onHide?.(instance); 21 }, 22 onDestroy: (instance) => { 23 visibleInstances.delete(instance); 24 return onDestroy?.(instance); 25 }, 26 onShow: (instance) => { 27 // hide other tooltip instances so only one tooltip shows at a time 28 for (const visibleInstance of visibleInstances) { 29 if (visibleInstance.props.role === 'tooltip') { 30 visibleInstance.hide(); 31 } 32 } 33 visibleInstances.add(instance); 34 return onShow?.(instance); 35 }, 36 arrow: `<svg width="16" height="7"><path d="m0 7 8-7 8 7Z" class="tippy-svg-arrow-outer"/><path d="m0 8 8-7 8 7Z" class="tippy-svg-arrow-inner"/></svg>`, 37 role: 'menu', // HTML role attribute, only tooltips should use "tooltip" 38 theme: other.role || 'menu', // CSS theme, we support either "tooltip" or "menu" 39 plugins: [followCursor], 40 ...other, 41 }); 42 43 // for popups where content refers to a DOM element, we use the 'tippy-target' class 44 // to initially hide the content, now we can remove it as the content has been removed 45 // from the DOM by tippy 46 if (other.content instanceof Element) { 47 other.content.classList.remove('tippy-target'); 48 } 49 50 return instance; 51 } 52 53 /** 54 * Attach a tooltip tippy to the given target element. 55 * If the target element already has a tooltip tippy attached, the tooltip will be updated with the new content. 56 * If the target element has no content, then no tooltip will be attached, and it returns null. 57 * 58 * Note: "tooltip" doesn't equal to "tippy". "tooltip" means a auto-popup content, it just uses tippy as the implementation. 59 * 60 * @param target {HTMLElement} 61 * @param content {null|string} 62 * @returns {null|tippy} 63 */ 64 function attachTooltip(target, content = null) { 65 switchTitleToTooltip(target); 66 67 content = content ?? target.getAttribute('data-tooltip-content'); 68 if (!content) return null; 69 70 // when element has a clipboard target, we update the tooltip after copy 71 // in which case it is undesirable to automatically hide it on click as 72 // it would momentarily flash the tooltip out and in. 73 const hasClipboardTarget = target.hasAttribute('data-clipboard-target'); 74 const hideOnClick = !hasClipboardTarget; 75 76 const props = { 77 content, 78 delay: 100, 79 role: 'tooltip', 80 theme: 'tooltip', 81 hideOnClick, 82 placement: target.getAttribute('data-tooltip-placement') || 'top-start', 83 followCursor: target.getAttribute('data-tooltip-follow-cursor') || false, 84 ...(target.getAttribute('data-tooltip-interactive') === 'true' ? {interactive: true, aria: {content: 'describedby', expanded: false}} : {}), 85 }; 86 87 if (!target._tippy) { 88 createTippy(target, props); 89 } else { 90 target._tippy.setProps(props); 91 } 92 return target._tippy; 93 } 94 95 function switchTitleToTooltip(target) { 96 const title = target.getAttribute('title'); 97 if (title) { 98 target.setAttribute('data-tooltip-content', title); 99 target.setAttribute('aria-label', title); 100 // keep the attribute, in case there are some other "[title]" selectors 101 // and to prevent infinite loop with <relative-time> which will re-add 102 // title if it is absent 103 target.setAttribute('title', ''); 104 } 105 } 106 107 /** 108 * Creating tooltip tippy instance is expensive, so we only create it when the user hovers over the element 109 * According to https://www.w3.org/TR/DOM-Level-3-Events/#events-mouseevent-event-order , mouseover event is fired before mouseenter event 110 * Some browsers like PaleMoon don't support "addEventListener('mouseenter', capture)" 111 * The tippy by default uses "mouseenter" event to show, so we use "mouseover" event to switch to tippy 112 * @param e {Event} 113 */ 114 function lazyTooltipOnMouseHover(e) { 115 e.target.removeEventListener('mouseover', lazyTooltipOnMouseHover, true); 116 attachTooltip(this); 117 } 118 119 // Activate the tooltip for current element. 120 // If the element has no aria-label, use the tooltip content as aria-label. 121 function attachLazyTooltip(el) { 122 el.addEventListener('mouseover', lazyTooltipOnMouseHover, {capture: true}); 123 124 // meanwhile, if the element has no aria-label, use the tooltip content as aria-label 125 if (!el.hasAttribute('aria-label')) { 126 const content = el.getAttribute('data-tooltip-content'); 127 if (content) { 128 el.setAttribute('aria-label', content); 129 } 130 } 131 } 132 133 // Activate the tooltip for all children elements. 134 function attachChildrenLazyTooltip(target) { 135 for (const el of target.querySelectorAll('[data-tooltip-content]')) { 136 attachLazyTooltip(el); 137 } 138 } 139 140 export function initGlobalTooltips() { 141 // use MutationObserver to detect new "data-tooltip-content" elements added to the DOM, or attributes changed 142 const observerConnect = (observer) => observer.observe(document, { 143 subtree: true, 144 childList: true, 145 attributeFilter: ['data-tooltip-content', 'title'] 146 }); 147 const observer = new MutationObserver((mutationList, observer) => { 148 const pending = observer.takeRecords(); 149 observer.disconnect(); 150 for (const mutation of [...mutationList, ...pending]) { 151 if (mutation.type === 'childList') { 152 // mainly for Vue components and AJAX rendered elements 153 for (const el of mutation.addedNodes) { 154 if (!isDocumentFragmentOrElementNode(el)) continue; 155 attachChildrenLazyTooltip(el); 156 if (el.hasAttribute('data-tooltip-content')) { 157 attachLazyTooltip(el); 158 } 159 } 160 } else if (mutation.type === 'attributes') { 161 attachTooltip(mutation.target); 162 } 163 } 164 observerConnect(observer); 165 }); 166 observerConnect(observer); 167 168 attachChildrenLazyTooltip(document.documentElement); 169 } 170 171 export function showTemporaryTooltip(target, content) { 172 // if the target is inside a dropdown, don't show the tooltip because when the dropdown 173 // closes, the tippy would be pushed unsightly to the top-left of the screen like seen 174 // on the issue comment menu. 175 if (target.closest('.ui.dropdown > .menu')) return; 176 177 const tippy = target._tippy ?? attachTooltip(target, content); 178 tippy.setContent(content); 179 if (!tippy.state.isShown) tippy.show(); 180 tippy.setProps({ 181 onHidden: (tippy) => { 182 // reset the default tooltip content, if no default, then this temporary tooltip could be destroyed 183 if (!attachTooltip(target)) { 184 tippy.destroy(); 185 } 186 }, 187 }); 188 }