Skip to content

Commit 23247d4

Browse files
Merge pull request #1543 from ASU/uds-1444
fix(unity-bootstrap-theme): update anchor menu logic to be more universal
2 parents 4dfc931 + d66b84d commit 23247d4

File tree

1 file changed

+118
-70
lines changed

1 file changed

+118
-70
lines changed

packages/unity-bootstrap-theme/src/js/anchor-menu.js

Lines changed: 118 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,35 @@
11
import { EventHandler } from "./bootstrap-helper";
2-
2+
import { throttle } from "@asu/shared";
3+
4+
/**
5+
* Initializes the anchor menu functionality.
6+
*
7+
* @param {string} idPrefix - The prefix for the IDs of the anchor menu elements
8+
* @returns {void}
9+
*/
310
function initAnchorMenu() {
411
const HEADER_IDS = ["asu-header", "asuHeader"];
12+
const SCROLL_DELAY = 100;
513

614
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-
1315
const globalHeader = document.getElementById(globalHeaderId);
1416
const navbar = document.getElementById("uds-anchor-menu");
1517
const navbarOriginalParent = navbar.parentNode;
1618
const navbarOriginalNextSibling = navbar.nextSibling;
1719
const anchors = navbar.getElementsByClassName("nav-link");
1820
const anchorTargets = new Map();
1921
let previousScrollPosition = window.scrollY;
20-
let isNavbarAttached = false; // Flag to track if navbar is attached to header
21-
const body = document.body;
22+
let isNavbarAttached = false;
2223

2324
// These values are for optionally present Drupal admin toolbars. They
2425
// are not present in Storybook and not required in implementations.
25-
let toolbarBar = document.getElementById("toolbar-bar");
26-
let toolbarItemAdministrationTray = document.getElementById(
27-
"toolbar-item-administration-tray"
28-
);
29-
30-
let toolbarBarHeight = toolbarBar ? toolbarBar.offsetHeight : 0;
31-
let toolbarItemAdministrationTrayHeight = toolbarItemAdministrationTray
32-
? toolbarItemAdministrationTray.offsetHeight
33-
: 0;
26+
const toolbarBarHeight =
27+
document.getElementById("toolbar-bar")?.offsetHeight || 0;
28+
const toolbarItemAdministrationTrayHeight =
29+
document.getElementById("toolbar-item-administration-tray")?.offsetHeight ||
30+
0;
3431

35-
let combinedToolbarHeightOffset =
32+
const combinedToolbarHeightOffset =
3633
toolbarBarHeight + toolbarItemAdministrationTrayHeight;
3734
const navbarInitialTop =
3835
navbar.getBoundingClientRect().top +
@@ -46,60 +43,113 @@ function initAnchorMenu() {
4643
anchorTargets.set(anchor, target);
4744
}
4845

49-
/*
50-
Bootstrap needs to be loaded as a variable in order for this to work.
51-
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
52-
See https://getbootstrap.com/docs/5.3/components/scrollspy/ for more info
53-
*/
54-
const scrollSpy = new bootstrap.ScrollSpy(body, {
55-
target: "#uds-anchor-menu nav",
56-
rootMargin: "20%",
57-
});
58-
5946
const shouldAttachNavbarOnLoad = window.scrollY > navbarInitialTop;
6047
if (shouldAttachNavbarOnLoad) {
6148
globalHeader.appendChild(navbar);
6249
isNavbarAttached = true;
6350
navbar.classList.add("uds-anchor-menu-attached");
6451
}
6552

66-
window.addEventListener(
67-
"scroll",
68-
function () {
69-
const navbarY = navbar.getBoundingClientRect().top;
70-
const headerHeight = globalHeader.classList.contains("scrolled")
71-
? globalHeader.offsetHeight - 32
72-
: globalHeader.offsetHeight; // 32 is the set height of the gray toolbar above the global header.
53+
/**
54+
* Calculates the percentage of an element that is visible in the viewport.
55+
*
56+
* @param {Element} el The element to calculate the visible percentage for.
57+
* @return {number} The percentage of the element that is visible in the viewport.
58+
*/
59+
function calculateVisiblePercentage(el) {
60+
if (el.offsetHeight === 0 || el.offsetWidth === 0) {
61+
return calculateVisiblePercentage(el.parentElement);
62+
}
63+
const rect = el.getBoundingClientRect();
64+
const windowHeight =
65+
window.innerHeight || document.documentElement.clientHeight;
66+
const windowWidth =
67+
window.innerWidth || document.documentElement.clientWidth;
68+
69+
const elHeight = rect.bottom - rect.top;
70+
const elWidth = rect.right - rect.left;
71+
72+
const elArea = elHeight * elWidth;
73+
74+
// Calculate the visible area of the element in the viewport
75+
const visibleHeight =
76+
Math.min(windowHeight, rect.bottom) - Math.max(0, rect.top);
77+
const visibleWidth =
78+
Math.min(windowWidth, rect.right) - Math.max(0, rect.left);
79+
const visibleArea = visibleHeight * visibleWidth;
80+
81+
// Calculate the percentage of the element that is visible in the viewport
82+
const visiblePercentage = (visibleArea / elArea) * 100;
83+
return visiblePercentage;
84+
}
7385

74-
// If scrolling DOWN and navbar touches the globalHeader
75-
if (
76-
window.scrollY > previousScrollPosition &&
77-
navbarY > 0 &&
78-
navbarY < headerHeight
79-
) {
80-
if (!isNavbarAttached) {
81-
// Attach navbar to globalHeader
82-
globalHeader.appendChild(navbar);
83-
isNavbarAttached = true;
84-
navbar.classList.add("uds-anchor-menu-attached");
85-
}
86-
previousScrollPosition = window.scrollY;
86+
const scrollHandlerLogic = function () {
87+
// Custom code added for Drupal - Handle active anchor highlighting
88+
let maxVisibility = 0;
89+
let mostVisibleElementId = null;
90+
91+
// Find the element with highest visibility
92+
Array.from(anchors).forEach(anchor => {
93+
let elementId = anchor.getAttribute("href").replace("#", "");
94+
let el = document.getElementById(elementId);
95+
const visiblePercentage = calculateVisiblePercentage(el);
96+
if (visiblePercentage > 0 && visiblePercentage > maxVisibility) {
97+
maxVisibility = visiblePercentage;
98+
mostVisibleElementId = el.id;
8799
}
100+
});
101+
102+
// Update active class if we found a visible element
103+
if (mostVisibleElementId) {
104+
document
105+
.querySelector('[href="#' + mostVisibleElementId + '"]')
106+
.classList.add("active");
107+
navbar
108+
.querySelectorAll(
109+
`nav > a.nav-link:not([href="#` + mostVisibleElementId + '"])'
110+
)
111+
.forEach(function (e) {
112+
e.classList.remove("active");
113+
});
114+
}
115+
116+
// Handle navbar attachment/detachment
117+
const navbarY = navbar.getBoundingClientRect().top;
118+
const headerBottom = globalHeader.getBoundingClientRect().bottom;
119+
const isScrollingDown = window.scrollY > previousScrollPosition;
120+
121+
// If scrolling DOWN and the bottom of globalHeader touches or overlaps the top of navbar
122+
if (isScrollingDown && headerBottom >= navbarY) {
123+
if (!isNavbarAttached) {
124+
// Attach navbar to globalHeader
125+
globalHeader.appendChild(navbar);
126+
isNavbarAttached = true;
127+
navbar.classList.add("uds-anchor-menu-attached");
128+
}
129+
}
130+
131+
// If scrolling UP and the header bottom no longer overlaps with the navbar
132+
if (!isScrollingDown && isNavbarAttached) {
133+
const currentHeaderBottom = globalHeader.getBoundingClientRect().bottom;
134+
const navbarCurrentTop = navbar.getBoundingClientRect().top;
88135

89-
// If scrolling UP and past the initial navbar position
136+
// Only detach if we're back to the initial navbar position or if header no longer overlaps navbar
90137
if (
91-
window.scrollY < previousScrollPosition &&
92-
window.scrollY <= navbarInitialTop &&
93-
isNavbarAttached
138+
window.scrollY <= navbarInitialTop ||
139+
currentHeaderBottom < navbarCurrentTop
94140
) {
95-
// Detach navbar and return to original position
96141
navbarOriginalParent.insertBefore(navbar, navbarOriginalNextSibling);
97142
isNavbarAttached = false;
98143
navbar.classList.remove("uds-anchor-menu-attached");
99144
}
145+
}
100146

101-
previousScrollPosition = window.scrollY;
102-
},
147+
previousScrollPosition = window.scrollY;
148+
};
149+
150+
window.addEventListener(
151+
"scroll",
152+
() => throttle(scrollHandlerLogic, SCROLL_DELAY),
103153
{ passive: true }
104154
);
105155

@@ -108,29 +158,27 @@ function initAnchorMenu() {
108158
anchor.addEventListener("click", function (e) {
109159
e.preventDefault();
110160

111-
// Compensate for height of navbar so content appears below it
112-
let scrollBy =
113-
anchorTarget.getBoundingClientRect().top - navbar.offsetHeight;
161+
// Get current viewport height and calculate the 1/4 position so that the
162+
// top of section is visible when you click on the anchor.
163+
const viewportHeight = window.innerHeight;
164+
const targetQuarterPosition = Math.round(viewportHeight * 0.25);
114165

115-
// If window hasn't been scrolled, compensate for header shrinking.
116-
const approximateHeaderSize = 65;
117-
if (window.scrollY === 0) scrollBy += approximateHeaderSize;
166+
const targetAbsoluteTop =
167+
anchorTarget.getBoundingClientRect().top + window.scrollY;
118168

119-
// If navbar hasn't been stickied yet, that means global header is still in view, so compensate for header height
120-
if (!navbar.classList.contains("uds-anchor-menu-sticky")) {
121-
if (window.scrollY > 0) scrollBy += 24;
122-
scrollBy -= globalHeader.offsetHeight;
123-
}
169+
let scrollToPosition = targetAbsoluteTop - targetQuarterPosition;
124170

125-
window.scrollBy({
126-
top: scrollBy,
171+
window.scrollTo({
172+
top: scrollToPosition,
127173
behavior: "smooth",
128174
});
129175

130176
// Remove active class from other anchor in navbar, and add it to the clicked anchor
131177
const active = navbar.querySelector(".nav-link.active");
132178

133-
if (active) active.classList.remove("active");
179+
if (active) {
180+
active.classList.remove("active");
181+
}
134182

135183
e.target.classList.add("active");
136184
});

0 commit comments

Comments
 (0)