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