-
Notifications
You must be signed in to change notification settings - Fork 8
fix(unity-bootstrap-theme): update anchor menu logic to be more universal #1543
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
davidornelas11
wants to merge
1
commit into
dev
Choose a base branch
from
uds-1444
base: dev
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,126 +1,220 @@ | ||
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); | ||
isNavbarAttached = true; | ||
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 }; |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
can you remove this Throttle function and instead use https://github.com/ASU/asu-unity-stack/blob/dev/shared/utils/timers.js