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