Skip to content

Commit 9629c31

Browse files
fix(unity-bootstrap-theme): update anchor menu logic to be more universal
1 parent 293c379 commit 9629c31

File tree

1 file changed

+171
-77
lines changed

1 file changed

+171
-77
lines changed
Lines changed: 171 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,126 +1,220 @@
11
import { EventHandler } from "./bootstrap-helper";
22

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));
1347
const globalHeader = document.getElementById(globalHeaderId);
14-
const navbar = document.getElementById('uds-anchor-menu');
48+
const navbar = document.getElementById("uds-anchor-menu");
1549
const navbarOriginalParent = navbar.parentNode;
1650
const navbarOriginalNextSibling = navbar.nextSibling;
17-
const anchors = navbar.getElementsByClassName('nav-link');
51+
const anchors = navbar.getElementsByClassName("nav-link");
1852
const anchorTargets = new Map();
1953
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;
2255

2356
// These values are for optionally present Drupal admin toolbars. They
2457
// 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;
3370

3471
// Cache the anchor target elements
3572
for (let anchor of anchors) {
36-
const targetId = anchor.getAttribute('href').replace('#', '');
73+
const targetId = anchor.getAttribute("href").replace("#", "");
3774
const target = document.getElementById(targetId);
3875
anchorTargets.set(anchor, target);
3976
}
4077

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-
5178
const shouldAttachNavbarOnLoad = window.scrollY > navbarInitialTop;
5279
if (shouldAttachNavbarOnLoad) {
5380
globalHeader.appendChild(navbar);
5481
isNavbarAttached = true;
5582
navbar.classList.add("uds-anchor-menu-attached");
5683
}
5784

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
59149
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;
61167

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
66172
) {
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");
74176
}
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');
85177
}
86178

87179
previousScrollPosition = window.scrollY;
88-
}, { passive: true });
180+
};
181+
182+
const throttledScrollHandler = throttle(scrollHandlerLogic, SCROLL_DELAY);
183+
184+
window.addEventListener("scroll", throttledScrollHandler, { passive: true });
89185

90186
// Set click event of anchors
91187
for (let [anchor, anchorTarget] of anchorTargets) {
92-
anchor.addEventListener('click', function (e) {
188+
anchor.addEventListener("click", function (e) {
93189
e.preventDefault();
94190

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

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;
102198

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;
108200

109-
window.scrollBy({
110-
top: scrollBy,
111-
behavior: 'smooth',
201+
window.scrollTo({
202+
top: scrollToPosition,
203+
behavior: "smooth",
112204
});
113205

114206
// 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");
116208

117-
if (active) active.classList.remove('active');
209+
if (active) {
210+
active.classList.remove("active");
211+
}
118212

119-
e.target.classList.add('active');
213+
e.target.classList.add("active");
120214
});
121215
}
122-
};
216+
}
123217

124-
EventHandler.on(window, 'load.uds.anchor-menu', initAnchorMenu);
218+
EventHandler.on(window, "load.uds.anchor-menu", initAnchorMenu);
125219

126220
export { initAnchorMenu };

0 commit comments

Comments
 (0)