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

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc