|
1 | 1 | import { EventHandler } from "./bootstrap-helper"; |
2 | 2 |
|
3 | | -function initAnchorMenu () { |
4 | | - const HEADER_IDS = ['asu-header', 'asuHeader']; |
5 | | - |
6 | | - const globalHeaderId = HEADER_IDS.find((id) => document.getElementById(id)); |
7 | | - |
8 | | - if (globalHeaderId === undefined) { |
9 | | - // Asu header not found in the DOM. |
10 | | - return; |
11 | | - } |
12 | | - |
| 3 | +/** |
| 4 | + * Throttles a function so it's called at most once during a specified delay. |
| 5 | + * |
| 6 | + * @param {function} func The function to throttle. |
| 7 | + * @param {number} delay The delay in milliseconds. |
| 8 | + * @return {function} The throttled function. |
| 9 | + */ |
| 10 | +function throttle(func, delay) { |
| 11 | + let timeoutId; |
| 12 | + let lastArgs; |
| 13 | + let lastThis; |
| 14 | + let calledDuringDelay = false; |
| 15 | + |
| 16 | + return function (...args) { |
| 17 | + lastArgs = args; |
| 18 | + lastThis = this; |
| 19 | + |
| 20 | + if (!timeoutId) { |
| 21 | + func.apply(lastThis, lastArgs); |
| 22 | + calledDuringDelay = false; |
| 23 | + timeoutId = setTimeout(() => { |
| 24 | + timeoutId = null; |
| 25 | + if (calledDuringDelay) { |
| 26 | + func.apply(lastThis, lastArgs); |
| 27 | + calledDuringDelay = false; |
| 28 | + } |
| 29 | + }, delay); |
| 30 | + } else { |
| 31 | + calledDuringDelay = true; |
| 32 | + } |
| 33 | + }; |
| 34 | +} |
| 35 | + |
| 36 | +/** |
| 37 | + * Initializes the anchor menu functionality. |
| 38 | + * |
| 39 | + * @param {string} idPrefix - The prefix for the IDs of the anchor menu elements |
| 40 | + * @returns {void} |
| 41 | + */ |
| 42 | +function initAnchorMenu() { |
| 43 | + const HEADER_IDS = ["asu-header", "asuHeader"]; |
| 44 | + const SCROLL_DELAY = 100; |
| 45 | + |
| 46 | + const globalHeaderId = HEADER_IDS.find(id => document.getElementById(id)); |
13 | 47 | const globalHeader = document.getElementById(globalHeaderId); |
14 | | - const navbar = document.getElementById('uds-anchor-menu'); |
| 48 | + const navbar = document.getElementById("uds-anchor-menu"); |
15 | 49 | const navbarOriginalParent = navbar.parentNode; |
16 | 50 | const navbarOriginalNextSibling = navbar.nextSibling; |
17 | | - const anchors = navbar.getElementsByClassName('nav-link'); |
| 51 | + const anchors = navbar.getElementsByClassName("nav-link"); |
18 | 52 | const anchorTargets = new Map(); |
19 | 53 | let previousScrollPosition = window.scrollY; |
20 | | - let isNavbarAttached = false; // Flag to track if navbar is attached to header |
21 | | - const body = document.body; |
| 54 | + let isNavbarAttached = false; |
22 | 55 |
|
23 | 56 | // These values are for optionally present Drupal admin toolbars. They |
24 | 57 | // are not present in Storybook and not required in implementations. |
25 | | - let toolbarBar = document.getElementById('toolbar-bar'); |
26 | | - let toolbarItemAdministrationTray = document.getElementById('toolbar-item-administration-tray'); |
27 | | - |
28 | | - let toolbarBarHeight = toolbarBar ? toolbarBar.offsetHeight : 0; |
29 | | - let toolbarItemAdministrationTrayHeight = toolbarItemAdministrationTray ? toolbarItemAdministrationTray.offsetHeight : 0; |
30 | | - |
31 | | - let combinedToolbarHeightOffset = toolbarBarHeight + toolbarItemAdministrationTrayHeight; |
32 | | - const navbarInitialTop = navbar.getBoundingClientRect().top + window.scrollY - combinedToolbarHeightOffset; |
| 58 | + const toolbarBarHeight = |
| 59 | + document.getElementById("toolbar-bar")?.toolbarBarHeight || 0; |
| 60 | + const toolbarItemAdministrationTrayHeight = |
| 61 | + document.getElementById("toolbar-item-administration-tray")?.offsetHeight || |
| 62 | + 0; |
| 63 | + |
| 64 | + const combinedToolbarHeightOffset = |
| 65 | + toolbarBarHeight + toolbarItemAdministrationTrayHeight; |
| 66 | + const navbarInitialTop = |
| 67 | + navbar.getBoundingClientRect().top + |
| 68 | + window.scrollY - |
| 69 | + combinedToolbarHeightOffset; |
33 | 70 |
|
34 | 71 | // Cache the anchor target elements |
35 | 72 | for (let anchor of anchors) { |
36 | | - const targetId = anchor.getAttribute('href').replace('#', ''); |
| 73 | + const targetId = anchor.getAttribute("href").replace("#", ""); |
37 | 74 | const target = document.getElementById(targetId); |
38 | 75 | anchorTargets.set(anchor, target); |
39 | 76 | } |
40 | 77 |
|
41 | | - /* |
42 | | - Bootstrap needs to be loaded as a variable in order for this to work. |
43 | | - 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 |
44 | | - See https://getbootstrap.com/docs/5.3/components/scrollspy/ for more info |
45 | | - */ |
46 | | - const scrollSpy = new bootstrap.ScrollSpy(body, { |
47 | | - target: '#uds-anchor-menu nav', |
48 | | - rootMargin: '20%' |
49 | | - }); |
50 | | - |
51 | 78 | const shouldAttachNavbarOnLoad = window.scrollY > navbarInitialTop; |
52 | 79 | if (shouldAttachNavbarOnLoad) { |
53 | 80 | globalHeader.appendChild(navbar); |
54 | 81 | isNavbarAttached = true; |
55 | 82 | navbar.classList.add("uds-anchor-menu-attached"); |
56 | 83 | } |
57 | 84 |
|
58 | | - window.addEventListener("scroll", function () { |
| 85 | + /** |
| 86 | + * Calculates the percentage of an element that is visible in the viewport. |
| 87 | + * |
| 88 | + * @param {Element} el The element to calculate the visible percentage for. |
| 89 | + * @return {number} The percentage of the element that is visible in the viewport. |
| 90 | + */ |
| 91 | + function calculateVisiblePercentage(el) { |
| 92 | + if (el.offsetHeight === 0 || el.offsetWidth === 0) { |
| 93 | + return calculateVisiblePercentage(el.parentElement); |
| 94 | + } |
| 95 | + const rect = el.getBoundingClientRect(); |
| 96 | + const windowHeight = |
| 97 | + window.innerHeight || document.documentElement.clientHeight; |
| 98 | + const windowWidth = |
| 99 | + window.innerWidth || document.documentElement.clientWidth; |
| 100 | + |
| 101 | + const elHeight = rect.bottom - rect.top; |
| 102 | + const elWidth = rect.right - rect.left; |
| 103 | + |
| 104 | + const elArea = elHeight * elWidth; |
| 105 | + |
| 106 | + // Calculate the visible area of the element in the viewport |
| 107 | + const visibleHeight = |
| 108 | + Math.min(windowHeight, rect.bottom) - Math.max(0, rect.top); |
| 109 | + const visibleWidth = |
| 110 | + Math.min(windowWidth, rect.right) - Math.max(0, rect.left); |
| 111 | + const visibleArea = visibleHeight * visibleWidth; |
| 112 | + |
| 113 | + // Calculate the percentage of the element that is visible in the viewport |
| 114 | + const visiblePercentage = (visibleArea / elArea) * 100; |
| 115 | + return visiblePercentage; |
| 116 | + } |
| 117 | + |
| 118 | + const scrollHandlerLogic = function () { |
| 119 | + // Custom code added for Drupal - Handle active anchor highlighting |
| 120 | + let maxVisibility = 0; |
| 121 | + let mostVisibleElementId = null; |
| 122 | + |
| 123 | + // Find the element with highest visibility |
| 124 | + Array.from(anchors).forEach(anchor => { |
| 125 | + let elementId = anchor.getAttribute("href").replace("#", ""); |
| 126 | + let el = document.getElementById(elementId); |
| 127 | + const visiblePercentage = calculateVisiblePercentage(el); |
| 128 | + if (visiblePercentage > 0 && visiblePercentage > maxVisibility) { |
| 129 | + maxVisibility = visiblePercentage; |
| 130 | + mostVisibleElementId = el.id; |
| 131 | + } |
| 132 | + }); |
| 133 | + |
| 134 | + // Update active class if we found a visible element |
| 135 | + if (mostVisibleElementId) { |
| 136 | + document |
| 137 | + .querySelector('[href="#' + mostVisibleElementId + '"]') |
| 138 | + .classList.add("active"); |
| 139 | + navbar |
| 140 | + .querySelectorAll( |
| 141 | + `nav > a.nav-link:not([href="#` + mostVisibleElementId + '"])' |
| 142 | + ) |
| 143 | + .forEach(function (e) { |
| 144 | + e.classList.remove("active"); |
| 145 | + }); |
| 146 | + } |
| 147 | + |
| 148 | + // Handle navbar attachment/detachment |
59 | 149 | const navbarY = navbar.getBoundingClientRect().top; |
60 | | - const headerHeight = globalHeader.classList.contains("scrolled") ? globalHeader.offsetHeight - 32 : globalHeader.offsetHeight; // 32 is the set height of the gray toolbar above the global header. |
| 150 | + const headerBottom = globalHeader.getBoundingClientRect().bottom; |
| 151 | + const isScrollingDown = window.scrollY > previousScrollPosition; |
| 152 | + |
| 153 | + // If scrolling DOWN and the bottom of globalHeader touches or overlaps the top of navbar |
| 154 | + if (isScrollingDown && headerBottom >= navbarY) { |
| 155 | + if (!isNavbarAttached) { |
| 156 | + // Attach navbar to globalHeader |
| 157 | + globalHeader.appendChild(navbar); |
| 158 | + isNavbarAttached = true; |
| 159 | + navbar.classList.add("uds-anchor-menu-attached"); |
| 160 | + } |
| 161 | + } |
| 162 | + |
| 163 | + // If scrolling UP and the header bottom no longer overlaps with the navbar |
| 164 | + if (!isScrollingDown && isNavbarAttached) { |
| 165 | + const currentHeaderBottom = globalHeader.getBoundingClientRect().bottom; |
| 166 | + const navbarCurrentTop = navbar.getBoundingClientRect().top; |
61 | 167 |
|
62 | | - // If scrolling DOWN and navbar touches the globalHeader |
63 | | - if ( |
64 | | - window.scrollY > previousScrollPosition && |
65 | | - navbarY > 0 && navbarY < headerHeight |
| 168 | + // Only detach if we're back to the initial navbar position or if header no longer overlaps navbar |
| 169 | + if ( |
| 170 | + window.scrollY <= navbarInitialTop || |
| 171 | + currentHeaderBottom < navbarCurrentTop |
66 | 172 | ) { |
67 | | - if (!isNavbarAttached) { |
68 | | - // Attach navbar to globalHeader |
69 | | - globalHeader.appendChild(navbar); |
70 | | - isNavbarAttached = true; |
71 | | - navbar.classList.add('uds-anchor-menu-attached'); |
72 | | - } |
73 | | - previousScrollPosition = window.scrollY; |
| 173 | + navbarOriginalParent.insertBefore(navbar, navbarOriginalNextSibling); |
| 174 | + isNavbarAttached = false; |
| 175 | + navbar.classList.remove("uds-anchor-menu-attached"); |
74 | 176 | } |
75 | | - |
76 | | - // If scrolling UP and past the initial navbar position |
77 | | - if ( |
78 | | - window.scrollY < previousScrollPosition && |
79 | | - window.scrollY <= navbarInitialTop && isNavbarAttached |
80 | | - ) { |
81 | | - // Detach navbar and return to original position |
82 | | - navbarOriginalParent.insertBefore(navbar, navbarOriginalNextSibling); |
83 | | - isNavbarAttached = false; |
84 | | - navbar.classList.remove('uds-anchor-menu-attached'); |
85 | 177 | } |
86 | 178 |
|
87 | 179 | previousScrollPosition = window.scrollY; |
88 | | - }, { passive: true }); |
| 180 | + }; |
| 181 | + |
| 182 | + const throttledScrollHandler = throttle(scrollHandlerLogic, SCROLL_DELAY); |
| 183 | + |
| 184 | + window.addEventListener("scroll", throttledScrollHandler, { passive: true }); |
89 | 185 |
|
90 | 186 | // Set click event of anchors |
91 | 187 | for (let [anchor, anchorTarget] of anchorTargets) { |
92 | | - anchor.addEventListener('click', function (e) { |
| 188 | + anchor.addEventListener("click", function (e) { |
93 | 189 | e.preventDefault(); |
94 | 190 |
|
95 | | - // Compensate for height of navbar so content appears below it |
96 | | - let scrollBy = |
97 | | - anchorTarget.getBoundingClientRect().top - navbar.offsetHeight; |
| 191 | + // Get current viewport height and calculate the 1/4 position so that the |
| 192 | + // top of section is visible when you click on the anchor. |
| 193 | + const viewportHeight = window.innerHeight; |
| 194 | + const targetQuarterPosition = Math.round(viewportHeight * 0.25); |
98 | 195 |
|
99 | | - // If window hasn't been scrolled, compensate for header shrinking. |
100 | | - const approximateHeaderSize = 65; |
101 | | - if (window.scrollY === 0) scrollBy += approximateHeaderSize; |
| 196 | + const targetAbsoluteTop = |
| 197 | + anchorTarget.getBoundingClientRect().top + window.scrollY; |
102 | 198 |
|
103 | | - // If navbar hasn't been stickied yet, that means global header is still in view, so compensate for header height |
104 | | - if (!navbar.classList.contains('uds-anchor-menu-sticky')) { |
105 | | - if (window.scrollY > 0) scrollBy += 24; |
106 | | - scrollBy -= globalHeader.offsetHeight; |
107 | | - } |
| 199 | + let scrollToPosition = targetAbsoluteTop - targetQuarterPosition; |
108 | 200 |
|
109 | | - window.scrollBy({ |
110 | | - top: scrollBy, |
111 | | - behavior: 'smooth', |
| 201 | + window.scrollTo({ |
| 202 | + top: scrollToPosition, |
| 203 | + behavior: "smooth", |
112 | 204 | }); |
113 | 205 |
|
114 | 206 | // Remove active class from other anchor in navbar, and add it to the clicked anchor |
115 | | - const active = navbar.querySelector('.nav-link.active'); |
| 207 | + const active = navbar.querySelector(".nav-link.active"); |
116 | 208 |
|
117 | | - if (active) active.classList.remove('active'); |
| 209 | + if (active) { |
| 210 | + active.classList.remove("active"); |
| 211 | + } |
118 | 212 |
|
119 | | - e.target.classList.add('active'); |
| 213 | + e.target.classList.add("active"); |
120 | 214 | }); |
121 | 215 | } |
122 | | -}; |
| 216 | +} |
123 | 217 |
|
124 | | -EventHandler.on(window, 'load.uds.anchor-menu', initAnchorMenu); |
| 218 | +EventHandler.on(window, "load.uds.anchor-menu", initAnchorMenu); |
125 | 219 |
|
126 | 220 | export { initAnchorMenu }; |
0 commit comments