aboutsummaryrefslogtreecommitdiffstats
path: root/web_src/js/modules/tippy.js
blob: 0d57af4f0f6457e7c2ca33cd4fb70c424836316a (plain)
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
import tippy from 'tippy.js';

export function createTippy(target, opts = {}) {
  const instance = tippy(target, {
    appendTo: document.body,
    animation: false,
    allowHTML: false,
    hideOnClick: false,
    interactiveBorder: 30,
    ignoreAttributes: true,
    maxWidth: 500, // increase over default 350px
    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>`,
    ...(opts?.role && {theme: opts.role}),
    ...opts,
  });

  // 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 (opts.content instanceof Element) {
    opts.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) {
  content = content ?? target.getAttribute('data-tooltip-content');
  if (!content) return null;

  const props = {
    content,
    delay: 100,
    role: 'tooltip',
    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;
}

/**
 * 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 all children elements
 * And if the element has no aria-label, use the tooltip content as aria-label
 * @param target {HTMLElement}
 */
function attachChildrenLazyTooltip(target) {
  for (const el of target.querySelectorAll('[data-tooltip-content]')) {
    el.addEventListener('mouseover', lazyTooltipOnMouseHover, true);

    // meanwhile, if the element has no aria-label, use the tooltip content as aria-label
    if (!el.hasAttribute('aria-label')) {
      const content = target.getAttribute('data-tooltip-content');
      if (content) {
        el.setAttribute('aria-label', content);
      }
    }
  }
}

export function initGlobalTooltips() {
  // use MutationObserver to detect new elements added to the DOM, or attributes changed
  const observer = new MutationObserver((mutationList) => {
    for (const mutation of mutationList) {
      if (mutation.type === 'childList') {
        // mainly for Vue components and AJAX rendered elements
        for (const el of mutation.addedNodes) {
          // handle all "tooltip" elements in added nodes which have 'querySelectorAll' method, skip non-related nodes (eg: "#text")
          if ('querySelectorAll' in el) {
            attachChildrenLazyTooltip(el);
          }
        }
      } else if (mutation.type === 'attributes') {
        // sync the tooltip content if the attributes change
        attachTooltip(mutation.target);
      }
    }
  });
  observer.observe(document, {
    subtree: true,
    childList: true,
    attributeFilter: ['data-tooltip-content'],
  });

  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();
      }
    },
  });
}