ckeditor_taxonomy_glossary-1.0.0-alpha1/js/glossary-tooltip.js
js/glossary-tooltip.js
/**
* @file
* Glossary tooltip functionality.
*/
((Drupal, drupalSettings, once) => {
/**
* Attaches glossary tooltip behavior.
*
* @type {Drupal~behavior}
*/
Drupal.behaviors.glossaryTooltip = {
attach: (context) => {
// Apply glossary settings from drupalSettings
if (drupalSettings.ckeditorTaxonomyGlossary) {
Drupal.behaviors.glossaryTooltip.applyGlossarySettings(
drupalSettings.ckeditorTaxonomyGlossary,
);
}
const elements = once(
"glossary-tooltip",
".glossary-link[data-glossary-id]",
context,
);
if (elements.length === 0) {
return;
}
// Initialize tooltips for each glossary link.
for (const element of elements) {
const termId = element.getAttribute("data-glossary-id");
if (!termId) {
continue;
}
// Create tooltip instance.
const tooltip = new GlossaryTooltip(element, termId);
tooltip.init();
}
},
/**
* Apply glossary settings to the page.
*/
applyGlossarySettings: (settings) => {
// Set CSS custom properties for dynamic styling
const root = document.documentElement;
if (settings.maxWidth) {
root.style.setProperty(
"--glossary-max-width",
`${settings.maxWidth}px`,
);
}
// Apply link style classes to all glossary links
const glossaryLinks = document.querySelectorAll(".glossary-link");
const linkStyle = settings.linkStyle || "dotted";
for (const link of glossaryLinks) {
// Remove existing style classes
link.classList.remove(
"link-style-dotted",
"link-style-solid",
"link-style-dashed",
"link-style-highlight",
);
// Add the current style class
link.classList.add(`link-style-${linkStyle}`);
}
},
};
/**
* Glossary tooltip class.
*/
class GlossaryTooltip {
/**
* Constructor.
*
* @param {HTMLElement} element The glossary link element.
* @param {string} termId The term ID.
*/
constructor(element, termId) {
this.element = element;
this.termId = termId;
this.tooltip = null;
this.isVisible = false;
this.hideTimeout = null;
this.showTimeout = null;
this.positionUpdateFrame = null;
this.boundUpdatePosition = null;
// Get settings from drupalSettings
this.settings = drupalSettings.ckeditorTaxonomyGlossary || {};
this.showOnHover =
this.settings.showOnHover !== false && this.settings.showOnHover !== 0;
this.showOnClick =
this.settings.showOnClick !== false && this.settings.showOnClick !== 0;
this.delay = Number.parseInt(this.settings.delay) || 200;
this.showCloseButton =
this.settings.showCloseButton !== false &&
this.settings.showCloseButton !== 0;
}
/**
* Initializes the tooltip.
*/
init() {
// Add event listeners based on settings
if (this.showOnHover) {
this.element.addEventListener(
"mouseenter",
this.handleMouseEnter.bind(this),
);
this.element.addEventListener(
"mouseleave",
this.handleMouseLeave.bind(this),
);
}
if (this.showOnClick) {
this.element.addEventListener("click", this.handleClick.bind(this));
// Add keyboard support for Enter and Space keys
this.element.addEventListener("keydown", this.handleKeydown.bind(this));
}
// Always add focus/blur for accessibility
this.element.addEventListener("focus", this.handleFocus.bind(this));
this.element.addEventListener("blur", this.handleBlur.bind(this));
// Add ARIA attributes for better accessibility
this.element.setAttribute(
"aria-describedby",
`glossary-tooltip-${this.termId}`,
);
// Add aria-expanded for click mode
if (this.showOnClick) {
this.element.setAttribute("role", "button");
this.element.setAttribute("aria-expanded", "false");
this.element.setAttribute("tabindex", "0");
}
}
/**
* Handles mouse enter event.
*/
handleMouseEnter() {
if (!this.showOnHover) {
return;
}
clearTimeout(this.hideTimeout);
this.showTimeout = setTimeout(() => {
this.show();
}, this.delay);
}
/**
* Handles mouse leave event.
*/
handleMouseLeave() {
if (!this.showOnHover) {
return;
}
clearTimeout(this.showTimeout);
this.hideTimeout = setTimeout(() => {
this.hide();
}, 300);
}
/**
* Handles focus event.
*/
handleFocus() {
// Only show on focus if hover mode is enabled (for keyboard accessibility)
// In click-only mode, let the click handler manage visibility
if (this.showOnHover) {
this.show();
}
}
/**
* Handles blur event.
*/
handleBlur() {
// Only hide on blur if hover mode is enabled
// In click-only mode, let the click handler manage visibility
if (this.showOnHover) {
this.hide();
}
}
/**
* Handles click event.
*
* @param {Event} event The click event.
*/
handleClick(event) {
if (!this.showOnClick) {
return;
}
event.preventDefault();
event.stopPropagation();
if (this.isVisible) {
this.hide();
} else {
this.show();
}
}
/**
* Handles keydown event for keyboard accessibility.
*
* @param {KeyboardEvent} event The keydown event.
*/
handleKeydown(event) {
if (!this.showOnClick) {
return;
}
// Handle Enter and Space keys like click
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
event.stopPropagation();
if (this.isVisible) {
this.hide();
} else {
this.show();
}
}
// Handle Escape key to close tooltip
else if (event.key === "Escape" && this.isVisible) {
event.preventDefault();
event.stopPropagation();
this.hide();
}
}
/**
* Shows the tooltip.
*/
async show() {
if (this.isVisible) {
return;
}
// Get or create tooltip element.
if (!this.tooltip) {
this.tooltip = await this.createTooltip();
if (!this.tooltip) {
return;
}
}
// Position and show tooltip.
document.body.appendChild(this.tooltip);
this.positionTooltip();
// Update ARIA expanded state for click mode
if (this.showOnClick) {
this.element.setAttribute("aria-expanded", "true");
}
// Add visible class after a frame to trigger animation.
requestAnimationFrame(() => {
this.tooltip.classList.add("is-visible");
// Announce to screen readers using aria-live region
this.announceTooltip();
});
this.isVisible = true;
// Add scroll and resize listeners for position updates
this.boundUpdatePosition = this.updatePosition.bind(this);
window.addEventListener("scroll", this.boundUpdatePosition, {
passive: true,
});
window.addEventListener("resize", this.boundUpdatePosition, {
passive: true,
});
// Add event listener to tooltip for mouse interaction only if hover is enabled
if (this.showOnHover) {
this.tooltip.addEventListener("mouseenter", () => {
clearTimeout(this.hideTimeout);
});
this.tooltip.addEventListener("mouseleave", () => {
this.hideTimeout = setTimeout(() => {
this.hide();
}, 300);
});
} else if (this.showOnClick) {
// For click-only mode, add click outside to close
this.handleClickOutside = (event) => {
if (
!this.tooltip.contains(event.target) &&
!this.element.contains(event.target)
) {
this.hide();
}
};
// Use setTimeout with 0 to put the listener addition in the next event loop cycle
// This ensures the current click event is completely finished processing
setTimeout(() => {
document.addEventListener("click", this.handleClickOutside);
}, 0);
}
}
/**
* Throttled position update using requestAnimationFrame.
*/
updatePosition() {
if (this.positionUpdateFrame) {
return; // Already scheduled
}
this.positionUpdateFrame = requestAnimationFrame(() => {
if (this.isVisible && this.tooltip) {
this.positionTooltip();
}
this.positionUpdateFrame = null;
});
}
/**
* Hides the tooltip.
*/
hide() {
if (!this.isVisible || !this.tooltip) {
return;
}
this.tooltip.classList.remove("is-visible");
// Update ARIA expanded state for click mode
if (this.showOnClick) {
this.element.setAttribute("aria-expanded", "false");
}
// Remove after animation completes.
setTimeout(() => {
this.tooltip?.parentNode?.removeChild(this.tooltip);
}, 200);
this.isVisible = false;
// Clean up position update listeners and frame
if (this.boundUpdatePosition) {
window.removeEventListener("scroll", this.boundUpdatePosition);
window.removeEventListener("resize", this.boundUpdatePosition);
this.boundUpdatePosition = null;
}
if (this.positionUpdateFrame) {
cancelAnimationFrame(this.positionUpdateFrame);
this.positionUpdateFrame = null;
}
// Clean up click outside listener if it exists
if (this.handleClickOutside) {
document.removeEventListener("click", this.handleClickOutside);
this.handleClickOutside = null;
}
}
/**
* Announces tooltip content to screen readers.
*/
announceTooltip() {
// Create or update a live region for screen reader announcements
let liveRegion = document.getElementById("glossary-live-region");
if (!liveRegion) {
liveRegion = document.createElement("div");
liveRegion.id = "glossary-live-region";
liveRegion.setAttribute("aria-live", "polite");
liveRegion.setAttribute("aria-atomic", "true");
liveRegion.style.position = "absolute";
liveRegion.style.left = "-10000px";
liveRegion.style.width = "1px";
liveRegion.style.height = "1px";
liveRegion.style.overflow = "hidden";
document.body.appendChild(liveRegion);
}
// Get the tooltip content for announcement
const title = this.tooltip.querySelector(".glossary-tooltip__title");
const description = this.tooltip.querySelector(
".glossary-tooltip__description",
);
let announcement = "";
if (title) {
announcement += `Glossary term: ${title.textContent}. `;
}
if (description) {
announcement += description.textContent;
}
// Update the live region content
liveRegion.textContent = announcement;
}
/**
* Creates the tooltip element.
*
* @returns {Promise<HTMLElement|null>} The tooltip element or null.
*/
async createTooltip() {
// Check if term data is preloaded.
const preloadedData =
drupalSettings.ckeditorTaxonomyGlossary?.terms?.[this.termId];
let termData;
if (preloadedData) {
termData = preloadedData;
} else {
// Fetch term description from server.
try {
const cacheDuration =
drupalSettings.ckeditorTaxonomyGlossary?.cacheDuration;
const fetchOptions = {};
// Add cache headers based on cache duration setting
if (cacheDuration === "0") {
// No cache - force fresh request
fetchOptions.cache = "no-cache";
fetchOptions.headers = {
"Cache-Control": "no-cache, no-store, must-revalidate",
Pragma: "no-cache",
Expires: "0",
};
} else if (cacheDuration) {
// Use cache for the specified duration
fetchOptions.cache = "default";
}
const response = await fetch(
`/glossary/description/${this.termId}`,
fetchOptions,
);
if (!response.ok) {
throw new Error("Failed to fetch term description");
}
termData = await response.json();
} catch (error) {
console.error("Error fetching glossary term:", error);
return null;
}
}
// Create tooltip element.
const tooltip = document.createElement("div");
tooltip.className = "glossary-tooltip";
tooltip.id = `glossary-tooltip-${this.termId}`;
tooltip.setAttribute("role", "tooltip");
// Add close button if enabled
if (this.showCloseButton) {
const closeButton = document.createElement("button");
closeButton.className = "glossary-tooltip__close";
closeButton.innerHTML = "×";
closeButton.setAttribute("aria-label", "Close tooltip");
closeButton.setAttribute("type", "button");
closeButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
this.hide();
});
// Add keyboard support for the close button
closeButton.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
event.stopPropagation();
this.hide();
// Return focus to the original element
this.element.focus();
}
});
tooltip.appendChild(closeButton);
}
// Add content.
const title = document.createElement("div");
title.className = "glossary-tooltip__title";
title.textContent = termData.name;
const description = document.createElement("div");
description.className = "glossary-tooltip__description";
if (termData.description) {
const tempDiv = document.createElement("div");
tempDiv.innerHTML = termData.description;
const scripts = tempDiv.querySelectorAll("script");
for (const script of scripts) {
script.remove();
}
const elements = tempDiv.querySelectorAll("*");
for (const element of elements) {
const attributes = [...element.attributes];
for (const attr of attributes) {
if (
attr.name.startsWith("on") ||
attr.name === "javascript:" ||
attr.name === "vbscript:"
) {
element.removeAttribute(attr.name);
}
}
}
description.innerHTML = tempDiv.innerHTML;
}
tooltip.appendChild(title);
tooltip.appendChild(description);
return tooltip;
}
/**
* Positions the tooltip relative to the element.
*/
positionTooltip() {
if (!this.tooltip) {
return;
}
const rect = this.element.getBoundingClientRect();
const tooltipRect = this.tooltip.getBoundingClientRect();
// Calculate position.
let top = rect.bottom + window.scrollY + 8;
let left =
rect.left + window.scrollX + rect.width / 2 - tooltipRect.width / 2;
// Ensure tooltip stays within viewport.
const padding = 10;
// Check right edge.
if (left + tooltipRect.width > window.innerWidth - padding) {
left = window.innerWidth - tooltipRect.width - padding;
}
// Check left edge.
if (left < padding) {
left = padding;
}
// Check if tooltip would go below viewport.
if (
top + tooltipRect.height >
window.innerHeight + window.scrollY - padding
) {
// Position above the element instead.
top = rect.top + window.scrollY - tooltipRect.height - 8;
this.tooltip.classList.add("is-above");
} else {
this.tooltip.classList.remove("is-above");
}
// Apply position.
this.tooltip.style.top = `${top}px`;
this.tooltip.style.left = `${left}px`;
}
}
})(Drupal, drupalSettings, once);
if (this.showCloseButton) {
const closeButton = document.createElement("button");
closeButton.className = "glossary-tooltip__close";
closeButton.innerHTML = "×";
closeButton.setAttribute("aria-label", "Close tooltip");
closeButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
this.hide();
});
tooltip.appendChild(closeButton);
}
