From 9cedbea0c2a52023d1a234780f9e0a38589dfc03 Mon Sep 17 00:00:00 2001 From: Jon Froehlich Date: Mon, 15 Jun 2026 09:01:30 -0700 Subject: [PATCH] Replace Bootstrap 3 popover (jQuery) with a vanilla popover (#1288) Track A (#1253). citationPopoverSimple.js was already ~95% vanilla; the only jQuery left was the Bootstrap popover API (init/toggle/hide). This swaps that for a small self-contained popover that builds Bootstrap-3-styled markup (.popover / .popover-title / .popover-content / .arrow), so the existing Bootstrap + custom popover CSS still applies unchanged. - Placement mirrors Bootstrap's "auto right": right of the trigger, flipping left when there's no room, vertically centered and viewport-clamped with the arrow tracking the trigger. - Content is rebuilt from data-content on each open (format toggle resets to "Text", same as before); only one popover open at a time. - Moves the trigger's title to data-original-title so the browser's native tooltip doesn't show (Bootstrap did this for us before). - Adds Escape-to-close (restoring focus to the trigger) as an a11y improvement. All the existing vanilla logic (copy, download, BibTeX/text toggle, aria state, "Copied!" feedback) is unchanged. No jQuery / Bootstrap JS dependency. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../website/js/citationPopoverSimple.js | 305 ++++++++++++++---- 1 file changed, 235 insertions(+), 70 deletions(-) diff --git a/website/static/website/js/citationPopoverSimple.js b/website/static/website/js/citationPopoverSimple.js index 0377b1d0..ef9ea1e2 100644 --- a/website/static/website/js/citationPopoverSimple.js +++ b/website/static/website/js/citationPopoverSimple.js @@ -2,26 +2,39 @@ * ============================================================================ * CITATION POPOVER MODULE * ============================================================================ - * + * * Handles citation popover functionality for publication links. Provides * copy-to-clipboard and download functionality for both plain text citations * and BibTeX format. - * + * * USAGE: * // Initialize on page load * document.addEventListener('DOMContentLoaded', function() { * CitationPopover.init('.publication-citation-link'); * }); - * + * * DEPENDENCIES: - * - Bootstrap 3 Popover (requires jQuery for popover API only) - * + * - None. This is self-contained vanilla JS (no jQuery, no Bootstrap JS). + * It builds a Bootstrap-3-styled popover (.popover / .popover-title / + * .popover-content / .arrow markup) so the existing Bootstrap + custom + * popover CSS still applies. See issues #1288 / #1253 (Track A) for the + * jQuery / Bootstrap-JS removal this is part of. + * + * BEHAVIOR (matches the previous Bootstrap-popover version): + * - Trigger uses `title` + `data-content` (HTML) on the link. + * - Placement is "auto right": the popover sits to the right of the trigger, + * flipping to the left when there isn't room. It is vertically centered on + * the trigger and clamped to the viewport, with the arrow tracking the + * trigger. Only one popover is open at a time. + * - Content is rebuilt from `data-content` on each open, so the format + * toggle always starts on "Text" (same as the Bootstrap version). + * * ACCESSIBILITY: * - Manages aria-expanded state on trigger elements * - Manages aria-pressed state on format toggle buttons * - Provides visual feedback for copy operations - * - * @version 3.0.0 + * + * @version 4.0.0 - Replaced Bootstrap 3 popover (jQuery) with vanilla JS * @author Makeability Lab * ============================================================================ */ @@ -35,6 +48,16 @@ const CitationPopover = (function () { /** Duration (ms) to show the "Copied!" feedback */ const COPY_FEEDBACK_DURATION = 1500; + /** Gap (px) between the trigger and the popover — matches Bootstrap's + * `.popover.right { margin-left: 10px }` / `.popover.left { margin-left: -10px }`. */ + const POPOVER_GAP = 10; + + /** Half the popover arrow's box size (Bootstrap's `.arrow` border-width is 11px). */ + const ARROW_HALF = 11; + + /** Viewport padding (px) used when clamping the popover on screen. */ + const VIEWPORT_PADDING = 8; + /** CSS selectors used throughout the module */ const SELECTORS = { popoverTrigger: '[data-toggle="popover"]', @@ -46,6 +69,16 @@ const CitationPopover = (function () { downloadBtn: '.citation-download' }; + /* =========================================================================== + POPOVER STATE + =========================================================================== + Only one citation popover is open at a time. `activeTrigger` is the trigger + whose popover is currently shown (null if none); `activePopover` is its + `.popover` element (appended to ). */ + + let activeTrigger = null; + let activePopover = null; + /* =========================================================================== PRIVATE FUNCTIONS @@ -53,7 +86,7 @@ const CitationPopover = (function () { /** * Gets the currently visible citation text (plain text or BibTeX). - * + * * @returns {string|null} The citation text, or null if not found */ function getVisibleCitationText() { @@ -73,7 +106,7 @@ const CitationPopover = (function () { /** * Checks if BibTeX format is currently active. - * + * * @returns {boolean} True if BibTeX is the active format */ function isBibtexActive() { @@ -83,7 +116,7 @@ const CitationPopover = (function () { /** * Updates aria-expanded attribute on a trigger element. - * + * * @param {HTMLElement} trigger - The popover trigger element * @param {boolean} isExpanded - Whether the popover is expanded */ @@ -93,7 +126,7 @@ const CitationPopover = (function () { /** * Updates aria-pressed attributes on format toggle buttons. - * + * * @param {string} activeFormat - The active format ('text' or 'bibtex') */ function setAriaPressed(activeFormat) { @@ -105,7 +138,7 @@ const CitationPopover = (function () { /** * Shows temporary feedback (e.g., "Copied!") near an element. - * + * * @param {HTMLElement} element - Element to append feedback to * @param {string} message - Message to display */ @@ -125,31 +158,155 @@ const CitationPopover = (function () { }, COPY_FEEDBACK_DURATION); } + + /* =========================================================================== + POPOVER SHOW / HIDE / POSITION + =========================================================================== */ + /** - * Closes all open popovers except the specified one. - * - * @param {HTMLElement|null} exceptTrigger - Trigger to exclude from closing + * Builds the popover element for a trigger, mirroring Bootstrap 3's popover + * markup so the existing `.popover` CSS applies. Content/title come from the + * trigger's `data-content` and `data-original-title` (set up in `init`). + * + * @param {HTMLElement} trigger - The trigger element + * @returns {HTMLElement} The `.popover` element (not yet positioned) + */ + function buildPopover(trigger) { + const popover = document.createElement('div'); + popover.className = 'popover'; + popover.setAttribute('role', 'tooltip'); + + const arrow = document.createElement('div'); + arrow.className = 'arrow'; + popover.appendChild(arrow); + + const title = trigger.getAttribute('data-original-title') || ''; + if (title) { + const titleEl = document.createElement('h3'); + titleEl.className = 'popover-title'; + titleEl.textContent = title; + popover.appendChild(titleEl); + } + + const contentEl = document.createElement('div'); + contentEl.className = 'popover-content'; + // data-content is trusted, author-authored HTML (rendered server-side from + // the Publication), matching the previous `html: true` Bootstrap popover. + contentEl.innerHTML = trigger.getAttribute('data-content') || ''; + popover.appendChild(contentEl); + + return popover; + } + + /** + * Positions an already-rendered popover relative to its trigger using + * Bootstrap's "auto right" rule: prefer the right side, flip to the left when + * there isn't room. Vertically centers on the trigger, clamps to the viewport, + * and moves the arrow to keep pointing at the trigger. + * + * @param {HTMLElement} trigger - The trigger element + * @param {HTMLElement} popover - The `.popover` element (already in the DOM) + */ + function positionPopover(trigger, popover) { + const rect = trigger.getBoundingClientRect(); + const scrollX = window.pageXOffset; + const scrollY = window.pageYOffset; + const viewportWidth = document.documentElement.clientWidth; + const viewportHeight = document.documentElement.clientHeight; + const width = popover.offsetWidth; + const height = popover.offsetHeight; + + // "auto right": prefer right, flip to left if the popover would overflow. + const placement = + rect.right + POPOVER_GAP + width > viewportWidth ? 'left' : 'right'; + popover.classList.remove('left', 'right'); + popover.classList.add(placement); + + // Horizontal: the CSS margin (POPOVER_GAP) creates the visible gap, so the + // popover edge sits flush against the trigger here. + const left = placement === 'right' + ? rect.right + scrollX + : rect.left + scrollX - width; + + // Vertical: center on the trigger, then clamp within the viewport. + const desiredTop = rect.top + scrollY + rect.height / 2 - height / 2; + const minTop = scrollY + VIEWPORT_PADDING; + const maxTop = scrollY + viewportHeight - height - VIEWPORT_PADDING; + const top = Math.max(minTop, Math.min(desiredTop, Math.max(minTop, maxTop))); + + popover.style.left = left + 'px'; + popover.style.top = top + 'px'; + + // Move the arrow so it still points at the trigger's vertical center even + // after clamping. (Bootstrap does the same via an inline top + margin.) + const arrow = popover.querySelector('.arrow'); + if (arrow) { + const triggerCenterY = rect.top + scrollY + rect.height / 2; + const arrowCenter = Math.max( + ARROW_HALF + VIEWPORT_PADDING, + Math.min(triggerCenterY - top, height - ARROW_HALF - VIEWPORT_PADDING) + ); + arrow.style.top = (arrowCenter - ARROW_HALF) + 'px'; + arrow.style.marginTop = '0'; + } + } + + /** + * Shows the popover for a trigger (closing any other open popover first). + * + * @param {HTMLElement} trigger - The trigger element + */ + function showPopover(trigger) { + closeOtherPopovers(trigger); + + const popover = buildPopover(trigger); + // Render hidden first so we can measure it, then position and reveal. + popover.style.display = 'block'; + popover.style.visibility = 'hidden'; + document.body.appendChild(popover); + + activeTrigger = trigger; + activePopover = popover; + + positionPopover(trigger, popover); + popover.style.visibility = 'visible'; + + setAriaExpanded(trigger, true); + } + + /** + * Hides the popover for a trigger, if it is the one currently open. + * + * @param {HTMLElement} trigger - The trigger element + */ + function hidePopover(trigger) { + if (activeTrigger === trigger && activePopover) { + activePopover.remove(); + activePopover = null; + activeTrigger = null; + } + setAriaExpanded(trigger, false); + } + + /** + * Closes any open popover except the one for the specified trigger. + * + * @param {HTMLElement|null} exceptTrigger - Trigger to leave open */ function closeOtherPopovers(exceptTrigger) { - document.querySelectorAll(SELECTORS.popoverTrigger).forEach(trigger => { - if (trigger !== exceptTrigger) { - // Bootstrap 3 popover API requires jQuery - $(trigger).popover('hide'); - setAriaExpanded(trigger, false); - } - }); + if (activeTrigger && activeTrigger !== exceptTrigger) { + hidePopover(activeTrigger); + } } /** * Checks if a popover is currently visible for a trigger. - * + * * @param {HTMLElement} trigger - The trigger element * @returns {boolean} True if popover is visible */ function isPopoverVisible(trigger) { - const popover = trigger.nextElementSibling; - return popover && popover.classList.contains('popover') && - popover.style.display !== 'none'; + return activeTrigger === trigger && activePopover !== null; } @@ -200,7 +357,7 @@ const CitationPopover = (function () { /** * Copies the currently visible citation to clipboard. - * + * * @param {HTMLElement} button - The button that triggered the copy */ function copyCitation(button) { @@ -227,7 +384,7 @@ const CitationPopover = (function () { /** * Downloads the currently visible citation as a file. - * + * * @param {string} filenameBase - Base filename without extension */ function downloadCitation(filenameBase) { @@ -265,7 +422,7 @@ const CitationPopover = (function () { /** * Handles clicks on format toggle buttons. - * + * * @param {Event} event - The click event */ function handleFormatClick(event) { @@ -281,7 +438,7 @@ const CitationPopover = (function () { /** * Handles clicks on the copy button. - * + * * @param {Event} event - The click event */ function handleCopyClick(event) { @@ -293,7 +450,7 @@ const CitationPopover = (function () { /** * Handles clicks on the download button. - * + * * @param {Event} event - The click event */ function handleDownloadClick(event) { @@ -307,45 +464,38 @@ const CitationPopover = (function () { } /** - * Handles clicks outside popovers to close them. - * + * Handles clicks outside the open popover to close it. + * * @param {Event} event - The click event - * @param {NodeList} triggers - The popover trigger elements */ - function handleOutsideClick(event, triggers) { - const target = event.target; + function handleOutsideClick(event) { + if (!activeTrigger || !activePopover) { + return; + } - triggers.forEach(trigger => { - const isClickOnTrigger = trigger.contains(target); - const popover = document.querySelector(SELECTORS.popover); - const isClickInPopover = popover && popover.contains(target); + const target = event.target; + const isClickOnTrigger = activeTrigger.contains(target); + const isClickInPopover = activePopover.contains(target); - if (!isClickOnTrigger && !isClickInPopover) { - $(trigger).popover('hide'); - setAriaExpanded(trigger, false); - } - }); + if (!isClickOnTrigger && !isClickInPopover) { + hidePopover(activeTrigger); + } } /** * Handles click on a popover trigger. - * + * * @param {Event} event - The click event * @param {HTMLElement} trigger - The trigger element */ function handleTriggerClick(event, trigger) { event.preventDefault(); - // Close other popovers first - closeOtherPopovers(trigger); - - // Toggle this popover (Bootstrap 3 API) - $(trigger).popover('toggle'); - - // Update aria-expanded after Bootstrap finishes - setTimeout(() => { - setAriaExpanded(trigger, isPopoverVisible(trigger)); - }, 10); + if (isPopoverVisible(trigger)) { + hidePopover(trigger); + } else { + showPopover(trigger); + } } @@ -355,9 +505,9 @@ const CitationPopover = (function () { /** * Initializes citation popover functionality. - * + * * @param {string} selector - CSS selector for citation link elements - * + * * @example * CitationPopover.init('.publication-citation-link'); */ @@ -368,28 +518,43 @@ const CitationPopover = (function () { return; } - // Initialize Bootstrap popovers and click handlers on each trigger triggers.forEach(trigger => { - // Initialize Bootstrap popover (requires jQuery) - $(trigger).popover({ - placement: 'auto right', - trigger: 'manual', - html: true - }); + // Stash the title in data-original-title and remove the title attribute so + // the browser's native tooltip doesn't show on hover. (Bootstrap did this + // for us before; we now do it explicitly.) + if (trigger.hasAttribute('title')) { + trigger.setAttribute('data-original-title', trigger.getAttribute('title')); + trigger.removeAttribute('title'); + } - // Handle trigger clicks trigger.addEventListener('click', event => handleTriggerClick(event, trigger)); }); - // Global click handler for closing popovers when clicking outside - document.addEventListener('click', event => handleOutsideClick(event, triggers)); + // Close the open popover when clicking outside of it. + document.addEventListener('click', handleOutsideClick); + + // Close the open popover on Escape (a11y) and restore focus to its trigger. + document.addEventListener('keydown', event => { + if (event.key === 'Escape' && activeTrigger) { + const trigger = activeTrigger; + hidePopover(trigger); + trigger.focus(); + } + }); - // Event delegation for popover content (since it's dynamically created) + // Event delegation for popover content (it is created dynamically on open). document.addEventListener('click', event => { handleFormatClick(event); handleCopyClick(event); handleDownloadClick(event); }); + + // Keep the open popover anchored to its trigger when the page reflows. + window.addEventListener('resize', () => { + if (activeTrigger && activePopover) { + positionPopover(activeTrigger, activePopover); + } + }); } // Expose public API @@ -397,4 +562,4 @@ const CitationPopover = (function () { init }; -})(); \ No newline at end of file +})();