diff --git a/packages/unity-bootstrap-theme/src/js/anchor-menu.js b/packages/unity-bootstrap-theme/src/js/anchor-menu.js index d13fb7ea2..c2c21af23 100644 --- a/packages/unity-bootstrap-theme/src/js/anchor-menu.js +++ b/packages/unity-bootstrap-theme/src/js/anchor-menu.js @@ -1,53 +1,80 @@ import { EventHandler } from "./bootstrap-helper"; -function initAnchorMenu () { - const HEADER_IDS = ['asu-header', 'asuHeader']; - - const globalHeaderId = HEADER_IDS.find((id) => document.getElementById(id)); - - if (globalHeaderId === undefined) { - // Asu header not found in the DOM. - return; - } - +/** + * Throttles a function so it's called at most once during a specified delay. + * + * @param {function} func The function to throttle. + * @param {number} delay The delay in milliseconds. + * @return {function} The throttled function. + */ +function throttle(func, delay) { + let timeoutId; + let lastArgs; + let lastThis; + let calledDuringDelay = false; + + return function (...args) { + lastArgs = args; + lastThis = this; + + if (!timeoutId) { + func.apply(lastThis, lastArgs); + calledDuringDelay = false; + timeoutId = setTimeout(() => { + timeoutId = null; + if (calledDuringDelay) { + func.apply(lastThis, lastArgs); + calledDuringDelay = false; + } + }, delay); + } else { + calledDuringDelay = true; + } + }; +} + +/** + * Initializes the anchor menu functionality. + * + * @param {string} idPrefix - The prefix for the IDs of the anchor menu elements + * @returns {void} + */ +function initAnchorMenu() { + const HEADER_IDS = ["asu-header", "asuHeader"]; + const SCROLL_DELAY = 100; + + const globalHeaderId = HEADER_IDS.find(id => document.getElementById(id)); const globalHeader = document.getElementById(globalHeaderId); - const navbar = document.getElementById('uds-anchor-menu'); + const navbar = document.getElementById("uds-anchor-menu"); const navbarOriginalParent = navbar.parentNode; const navbarOriginalNextSibling = navbar.nextSibling; - const anchors = navbar.getElementsByClassName('nav-link'); + const anchors = navbar.getElementsByClassName("nav-link"); const anchorTargets = new Map(); let previousScrollPosition = window.scrollY; - let isNavbarAttached = false; // Flag to track if navbar is attached to header - const body = document.body; + let isNavbarAttached = false; // These values are for optionally present Drupal admin toolbars. They // are not present in Storybook and not required in implementations. - let toolbarBar = document.getElementById('toolbar-bar'); - let toolbarItemAdministrationTray = document.getElementById('toolbar-item-administration-tray'); - - let toolbarBarHeight = toolbarBar ? toolbarBar.offsetHeight : 0; - let toolbarItemAdministrationTrayHeight = toolbarItemAdministrationTray ? toolbarItemAdministrationTray.offsetHeight : 0; - - let combinedToolbarHeightOffset = toolbarBarHeight + toolbarItemAdministrationTrayHeight; - const navbarInitialTop = navbar.getBoundingClientRect().top + window.scrollY - combinedToolbarHeightOffset; + const toolbarBarHeight = + document.getElementById("toolbar-bar")?.toolbarBarHeight || 0; + const toolbarItemAdministrationTrayHeight = + document.getElementById("toolbar-item-administration-tray")?.offsetHeight || + 0; + + const combinedToolbarHeightOffset = + toolbarBarHeight + toolbarItemAdministrationTrayHeight; + const navbarInitialTop = + navbar.getBoundingClientRect().top + + window.scrollY - + combinedToolbarHeightOffset; // Cache the anchor target elements for (let anchor of anchors) { - const targetId = anchor.getAttribute('href').replace('#', ''); + const targetId = anchor.getAttribute("href").replace("#", ""); const target = document.getElementById(targetId); anchorTargets.set(anchor, target); } - /* - Bootstrap needs to be loaded as a variable in order for this to work. - An alternative is to remove this and add the data-bs-spy="scroll" data-bs-target="#uds-anchor-menu nav" attributes to the body tag - See https://getbootstrap.com/docs/5.3/components/scrollspy/ for more info - */ - const scrollSpy = new bootstrap.ScrollSpy(body, { - target: '#uds-anchor-menu nav', - rootMargin: '20%' - }); - const shouldAttachNavbarOnLoad = window.scrollY > navbarInitialTop; if (shouldAttachNavbarOnLoad) { globalHeader.appendChild(navbar); @@ -55,72 +82,139 @@ function initAnchorMenu () { navbar.classList.add("uds-anchor-menu-attached"); } - window.addEventListener("scroll", function () { + /** + * Calculates the percentage of an element that is visible in the viewport. + * + * @param {Element} el The element to calculate the visible percentage for. + * @return {number} The percentage of the element that is visible in the viewport. + */ + function calculateVisiblePercentage(el) { + if (el.offsetHeight === 0 || el.offsetWidth === 0) { + return calculateVisiblePercentage(el.parentElement); + } + const rect = el.getBoundingClientRect(); + const windowHeight = + window.innerHeight || document.documentElement.clientHeight; + const windowWidth = + window.innerWidth || document.documentElement.clientWidth; + + const elHeight = rect.bottom - rect.top; + const elWidth = rect.right - rect.left; + + const elArea = elHeight * elWidth; + + // Calculate the visible area of the element in the viewport + const visibleHeight = + Math.min(windowHeight, rect.bottom) - Math.max(0, rect.top); + const visibleWidth = + Math.min(windowWidth, rect.right) - Math.max(0, rect.left); + const visibleArea = visibleHeight * visibleWidth; + + // Calculate the percentage of the element that is visible in the viewport + const visiblePercentage = (visibleArea / elArea) * 100; + return visiblePercentage; + } + + const scrollHandlerLogic = function () { + // Custom code added for Drupal - Handle active anchor highlighting + let maxVisibility = 0; + let mostVisibleElementId = null; + + // Find the element with highest visibility + Array.from(anchors).forEach(anchor => { + let elementId = anchor.getAttribute("href").replace("#", ""); + let el = document.getElementById(elementId); + const visiblePercentage = calculateVisiblePercentage(el); + if (visiblePercentage > 0 && visiblePercentage > maxVisibility) { + maxVisibility = visiblePercentage; + mostVisibleElementId = el.id; + } + }); + + // Update active class if we found a visible element + if (mostVisibleElementId) { + document + .querySelector('[href="#' + mostVisibleElementId + '"]') + .classList.add("active"); + navbar + .querySelectorAll( + `nav > a.nav-link:not([href="#` + mostVisibleElementId + '"])' + ) + .forEach(function (e) { + e.classList.remove("active"); + }); + } + + // Handle navbar attachment/detachment const navbarY = navbar.getBoundingClientRect().top; - const headerHeight = globalHeader.classList.contains("scrolled") ? globalHeader.offsetHeight - 32 : globalHeader.offsetHeight; // 32 is the set height of the gray toolbar above the global header. + const headerBottom = globalHeader.getBoundingClientRect().bottom; + const isScrollingDown = window.scrollY > previousScrollPosition; + + // If scrolling DOWN and the bottom of globalHeader touches or overlaps the top of navbar + if (isScrollingDown && headerBottom >= navbarY) { + if (!isNavbarAttached) { + // Attach navbar to globalHeader + globalHeader.appendChild(navbar); + isNavbarAttached = true; + navbar.classList.add("uds-anchor-menu-attached"); + } + } + + // If scrolling UP and the header bottom no longer overlaps with the navbar + if (!isScrollingDown && isNavbarAttached) { + const currentHeaderBottom = globalHeader.getBoundingClientRect().bottom; + const navbarCurrentTop = navbar.getBoundingClientRect().top; - // If scrolling DOWN and navbar touches the globalHeader - if ( - window.scrollY > previousScrollPosition && - navbarY > 0 && navbarY < headerHeight + // Only detach if we're back to the initial navbar position or if header no longer overlaps navbar + if ( + window.scrollY <= navbarInitialTop || + currentHeaderBottom < navbarCurrentTop ) { - if (!isNavbarAttached) { - // Attach navbar to globalHeader - globalHeader.appendChild(navbar); - isNavbarAttached = true; - navbar.classList.add('uds-anchor-menu-attached'); - } - previousScrollPosition = window.scrollY; + navbarOriginalParent.insertBefore(navbar, navbarOriginalNextSibling); + isNavbarAttached = false; + navbar.classList.remove("uds-anchor-menu-attached"); } - - // If scrolling UP and past the initial navbar position - if ( - window.scrollY < previousScrollPosition && - window.scrollY <= navbarInitialTop && isNavbarAttached - ) { - // Detach navbar and return to original position - navbarOriginalParent.insertBefore(navbar, navbarOriginalNextSibling); - isNavbarAttached = false; - navbar.classList.remove('uds-anchor-menu-attached'); } previousScrollPosition = window.scrollY; - }, { passive: true }); + }; + + const throttledScrollHandler = throttle(scrollHandlerLogic, SCROLL_DELAY); + + window.addEventListener("scroll", throttledScrollHandler, { passive: true }); // Set click event of anchors for (let [anchor, anchorTarget] of anchorTargets) { - anchor.addEventListener('click', function (e) { + anchor.addEventListener("click", function (e) { e.preventDefault(); - // Compensate for height of navbar so content appears below it - let scrollBy = - anchorTarget.getBoundingClientRect().top - navbar.offsetHeight; + // Get current viewport height and calculate the 1/4 position so that the + // top of section is visible when you click on the anchor. + const viewportHeight = window.innerHeight; + const targetQuarterPosition = Math.round(viewportHeight * 0.25); - // If window hasn't been scrolled, compensate for header shrinking. - const approximateHeaderSize = 65; - if (window.scrollY === 0) scrollBy += approximateHeaderSize; + const targetAbsoluteTop = + anchorTarget.getBoundingClientRect().top + window.scrollY; - // If navbar hasn't been stickied yet, that means global header is still in view, so compensate for header height - if (!navbar.classList.contains('uds-anchor-menu-sticky')) { - if (window.scrollY > 0) scrollBy += 24; - scrollBy -= globalHeader.offsetHeight; - } + let scrollToPosition = targetAbsoluteTop - targetQuarterPosition; - window.scrollBy({ - top: scrollBy, - behavior: 'smooth', + window.scrollTo({ + top: scrollToPosition, + behavior: "smooth", }); // Remove active class from other anchor in navbar, and add it to the clicked anchor - const active = navbar.querySelector('.nav-link.active'); + const active = navbar.querySelector(".nav-link.active"); - if (active) active.classList.remove('active'); + if (active) { + active.classList.remove("active"); + } - e.target.classList.add('active'); + e.target.classList.add("active"); }); } -}; +} -EventHandler.on(window, 'load.uds.anchor-menu', initAnchorMenu); +EventHandler.on(window, "load.uds.anchor-menu", initAnchorMenu); export { initAnchorMenu };