1/* global H5P */ 2H5P.Tooltip = (function () { 3 // Position (allowed and default) 4 const Position = { 5 allowed: ['top', 'bottom', 'left', 'right'], 6 default: 'top', 7 }; 8 9 /** {number} DELAY_SHOW_MS Delay before tooltip is shown */ 10 const DELAY_SHOW_MS = 500; 11 12 /** {number} DELAY_HIDE_MS Delay before tooltip is hidden */ 13 const DELAY_HIDE_MS = 500; 14 15 /** 16 * Strips html tags and converts special characters. 17 * Example: "<div>Me & you</div>" is converted to "Me & you". 18 * 19 * @param {String} text The text to be parsed 20 * @returns {String} The parsed text 21 */ 22 function parseString(text) { 23 if (text === null || text === undefined) { 24 return ''; 25 } 26 const div = document.createElement('div'); 27 div.innerHTML = text; 28 return div.textContent; 29 } 30 31 /** 32 * Keep track of whether the user is using their mouse or keyboard to 33 * navigate. Will determine whether tooltip should be shown on focus. 34 */ 35 let usingMouse; 36 37 function debounce(callback, delay) { 38 let timeout = null; 39 40 return function (...args) { 41 clearTimeout(timeout); 42 timeout = setTimeout(() => { 43 callback(...args); 44 }, delay); 45 }; 46 } 47 48 // The mousemove listener is debounced for performance reasons 49 document.addEventListener('mousemove', debounce(() => usingMouse = true, 100)); 50 document.addEventListener('mousedown', () => usingMouse = true); 51 document.addEventListener('keydown', () => usingMouse = false); 52 53 /** 54 * Create an accessible tooltip 55 * 56 * @param {HTMLElement} triggeringElement The element that should trigger the tooltip 57 * @param {Object} options Options for tooltip 58 * @param {String} options.text The text to be displayed in the tooltip 59 * If not set, will attempt to set text = options.tooltipSource of triggeringElement 60 * @param {String[]} options.classes Extra css classes for the tooltip 61 * @param {Boolean} options.ariaHidden Whether the hover should be read by screen readers or not (default: true) 62 * @param {String} options.position Where the tooltip should appear in relation to the 63 * triggeringElement. Accepted positions are "top" (default), "left", "right" and "bottom" 64 * @param {String} options.tooltipSource 65 * 66 * @returns {object} returns all the public functions 67 * 68 * @constructor 69 */ 70 71 function Tooltip(triggeringElement, options) { 72 // Make sure tooltips have unique id 73 H5P.Tooltip.uniqueId += 1; 74 const tooltipId = `h5p-tooltip-${H5P.Tooltip.uniqueId}`; 75 76 // Default options 77 options = options || {}; 78 options.classes = options.classes || []; 79 options.ariaHidden = options.ariaHidden || true; 80 options.tooltipSource = options.tooltipSource || 'aria-label'; 81 options.position = (options.position && Position.allowed.includes(options.position)) 82 ? options.position 83 : Position.default; 84 85 // Add our internal classes 86 options.classes.push('h5p-tooltip'); 87 if (options.position === 'left' || options.position === 'right') { 88 options.classes.push('h5p-tooltip-narrow'); 89 } 90 91 // Initiate state 92 let hover = false; 93 let focus = false; 94 95 // Function used by the escape listener 96 const hideOnEscape = function (event) { 97 if (event.key === 'Escape') { 98 tooltip.classList.remove('h5p-tooltip-visible'); 99 } 100 }; 101 102 // Create element 103 const tooltip = document.createElement('div'); 104 tooltip.id = tooltipId; 105 tooltip.role = 'tooltip'; 106 tooltip.textContent = parseString(options.text || triggeringElement.getAttribute(options.tooltipSource) || ''); 107 tooltip.setAttribute('aria-hidden', options.ariaHidden); 108 tooltip.classList.add(...options.classes); 109 110 document.body.appendChild(tooltip); 111 112 // Aria-describedby will override aria-hidden 113 if (!options.ariaHidden) { 114 triggeringElement.setAttribute('aria-describedby', tooltipId); 115 } 116 117 // Use a mutation observer to listen for options.tooltipSource being 118 // changed for the triggering element. If so, update the tooltip. 119 // Mutation observer will be used even if the original elements 120 // doesn't have any options.tooltipSource. 121 this.observer = new MutationObserver((mutations) => { 122 const updatedText = mutations[0].target.getAttribute(options.tooltipSource); 123 124 if (tooltip.parentNode === null) { 125 triggeringElement.appendChild(tooltip); 126 } 127 128 tooltip.textContent = parseString(options.text || updatedText); 129 130 if (tooltip.textContent.trim().length === 0 && tooltip.classList.contains('h5p-tooltip-visible')) { 131 tooltip.classList.remove('h5p-tooltip-visible'); 132 } 133 }); 134 this.observer.observe(triggeringElement, { 135 attributes: true, 136 attributeFilter: [options.tooltipSource, 'class'], 137 }); 138 139 // A reference to the H5P container (if any). If null, it means 140 // this tooltip is not whithin an H5P. 141 let h5pContainer; 142 143 // Timer responsible for displaying the tooltip x ms after it has been 144 // triggered (either by mouseenter or focusin) 145 let showTooltipTimer; 146 147 // Timer responsible for hiding the tooltip x ms after it has been untriggered 148 let hideTooltipTimer; 149 150 // This timer makes sure the tooltip is not hidden when the mouse 151 // moves from the trigger to the tooltip. 152 let triggerMouseLeaveTimer; 153 154 /** 155 * Makes the tooltip visible and activates it's functionality 156 * 157 * @param {UIEvent} event The triggering event 158 */ 159 const showTooltip = function (event, wait = true) { 160 if (!event.target || event.target.disabled || event.target.getAttribute('aria-disabled') === 'true') { 161 return; 162 } 163 164 clearTimeout(hideTooltipTimer); // Prevent from hiding while supposed to show 165 166 if (wait === true) { 167 // We don't want to show the tooltip right away. 168 // Adding a 300 ms waiting period here. 169 clearTimeout(showTooltipTimer); 170 showTooltipTimer = setTimeout(() => { 171 showTooltip(event, false); 172 }, DELAY_SHOW_MS); 173 return; 174 } 175 176 // Don't show tooltip if it is empty 177 if (tooltip.textContent.trim().length === 0) { 178 return; 179 } 180 181 if (event.type === 'mouseenter') { 182 hover = true; 183 } 184 else { 185 focus = true; 186 } 187 188 // Reset placement 189 tooltip.style.left = ''; 190 tooltip.style.top = ''; 191 192 tooltip.classList.add('h5p-tooltip-visible'); 193 194 // Add listener to iframe body, as esc keypress would not be detected otherwise 195 document.body.addEventListener('keydown', hideOnEscape, true); 196 197 // The section below makes sure the tooltip is completely visible 198 199 // H5P.Tooltip can be used both from within an H5P and elsewhere. 200 // The below code is for figuring out the containing element. 201 // h5pContainer has to be looked up the first time we show the tooltip, 202 // since it might not be added to the DOM when H5P.Tooltip is invoked. 203 if (h5pContainer === undefined) { 204 // After the below, h5pContainer is either null or a reference to the 205 // DOM element 206 h5pContainer = triggeringElement.closest('.h5p-container'); 207 } 208 const rootRect = h5pContainer ? h5pContainer.getBoundingClientRect() : document.documentElement.getBoundingClientRect(); 209 const triggerRect = triggeringElement.getBoundingClientRect(); 210 let tooltipRect = tooltip.getBoundingClientRect(); 211 212 if (options.position === 'top') { 213 // Places it centered above 214 tooltip.style.left = `${triggerRect.left + (triggerRect.width / 2) - (tooltipRect.width / 2)}px`; 215 tooltip.style.top = `${triggerRect.top - tooltipRect.height}px`; 216 } 217 else if (options.position === 'bottom') { 218 // Places it centered below 219 tooltip.style.left = `${triggerRect.left + (triggerRect.width / 2) - (tooltipRect.width / 2)}px`; 220 tooltip.style.top = `${triggerRect.bottom}px`; 221 } 222 else if (options.position === 'left') { 223 tooltip.style.left = `${triggerRect.left - tooltipRect.width}px`; 224 tooltip.style.top = `${triggerRect.top + (triggerRect.height - tooltipRect.height) / 2}px`; 225 // We trust this option makes the tooltip being shown 226 return; 227 } 228 else if (options.position === 'right') { 229 tooltip.style.left = `${triggerRect.right}px`; 230 tooltip.style.top = `${triggerRect.top + (triggerRect.height - tooltipRect.height) / 2}px`; 231 // We trust this option makes the tooltip being shown 232 return; 233 } 234 235 tooltipRect = tooltip.getBoundingClientRect(); 236 const isVisible = tooltipRect.left >= 0 237 && tooltipRect.top >= 0 238 && tooltipRect.right <= rootRect.width 239 && tooltipRect.bottom <= rootRect.height; 240 241 if (!isVisible) {
242 // The tooltip placement needs to be adjusted. This logic will move the 243 // tooltip either left or right if it's placed outside the root element 244 tooltipRect = tooltip.getBoundingClientRect(); 245 if (tooltipRect.left < 0) { 246 tooltip.style.left = 0; 247 } 248 else if (tooltipRect.right > rootRect.width) { 249 tooltip.style.left = ''; 250 tooltip.style.right = 0; 251 } 252 } 253 }; 254 255 /** 256 * Hides the tooltip and removes listeners 257 * 258 * @param {UIEvent} event The triggering event 259 */ 260 const hideTooltip = function (event) { 261 let hide = false; 262 let wait = false; 263 264 if (event.type === 'click') { 265 hide = true; 266 } 267 else { 268 if (event.type === 'mouseleave') { 269 wait = true; // Tooltip should not disappear right away 270 hover = false; 271 } 272 else { 273 focus = false; 274 } 275 276 hide = (!hover && !focus); 277 } 278 279 // Only hide tooltip if neither hovered nor focused 280 if (hide) { 281 clearTimeout(showTooltipTimer); // Prevent from showing while supposed to hide 282 283 const cleanupTooltip = () => { 284 tooltip.classList.remove('h5p-tooltip-visible'); 285 document.body.removeEventListener('keydown', hideOnEscape, true); // Remove iframe body listener 286 }; 287 288 if (wait) { 289 clearTimeout(hideTooltipTimer); 290 hideTooltipTimer = setTimeout(() => { 291 cleanupTooltip(); 292 }, DELAY_HIDE_MS); 293 } 294 else { 295 cleanupTooltip(); 296 } 297 } 298 }; 299 300 // Add event listeners to triggeringElement 301 triggeringElement.addEventListener('mouseenter', showTooltip); 302 triggeringElement.addEventListener('mouseleave', (event) => { 303 triggerMouseLeaveTimer = setTimeout(() => { 304 hideTooltip(event); 305 }, 1); 306 }); 307 triggeringElement.addEventListener('focusin', (event) => { 308 if (!usingMouse) { 309 showTooltip(event); 310 } 311 }); 312 triggeringElement.addEventListener('focusout', hideTooltip); 313 triggeringElement.addEventListener('click', hideTooltip); 314 tooltip.addEventListener('mouseenter', () => { 315 clearTimeout(triggerMouseLeaveTimer); 316 }); 317 tooltip.addEventListener('mouseleave', hideTooltip); 318 319 tooltip.addEventListener('click', (event) => { 320 // Prevent clicks on the tooltip from triggering click 321 // listeners on the triggering element 322 event.stopPropagation(); 323 event.preventDefault(); 324 325 // Hide the tooltip when it is clicked 326 hideTooltip(event); 327 }); 328 329 /** 330 * Change the text displayed by the tooltip 331 * 332 * @param {String} text The new text to be displayed 333 * Set to null to use options.tooltipSource of triggeringElement instead 334 */ 335 this.setText = function (text) { 336 options.text = text; 337 tooltip.textContent = parseString(options.text || triggeringElement.getAttribute(options.tooltipSource) || ''); 338 }; 339 340 /** 341 * Hide the tooltip 342 */ 343 this.hide = function () { 344 hover = focus = false; 345 tooltip.classList.remove('h5p-tooltip-visible'); 346 }; 347 348 /** 349 * Retrieve tooltip 350 * 351 * @return {HTMLElement} 352 */ 353 this.getElement = function () { 354 return tooltip; 355 }; 356 357 /** 358 * Remove tooltip 359 */ 360 this.remove = function () { 361 this.observer?.disconnect(); 362 tooltip.remove(); 363 }; 364 365 return { 366 setText: this.setText, 367 hide: this.hide, 368 getElement: this.getElement, 369 remove: this.remove, 370 observer: this.observer, 371 }; 372 } 373 374 return Tooltip; 375}()); 376 377H5P.Tooltip.uniqueId = -1;
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.