Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/app-degree-pages/.storybook/preview.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import ListingPage from "../src/components/ListingPage/index.stories";
import DetailPage from "../src/components/DetailPage/index.stories";
import "@asu/unity-bootstrap-theme/src/scss/unity-bootstrap-theme.bundle.scss";
import "@asu/unity-bootstrap-theme/src/js/unity-bootstrap.js";
import "bootstrap/dist/js/bootstrap.bundle.js";

const parameters = {
Expand Down
262 changes: 177 additions & 85 deletions packages/unity-bootstrap-theme/src/js/anchor-menu.js
Original file line number Diff line number Diff line change
@@ -1,24 +1,29 @@
// @ts-check
import { EventHandler } from "./bootstrap-helper";
import { throttle } from "@asu/shared";

/**
* Initializes the anchor menu functionality.
*
* @param {string} idPrefix - The prefix for the IDs of the anchor menu elements
* @returns {void}
* @param {Object} [options] - Configuration options
* @param {boolean} [options.ignoreReactCheck] - If true, bypasses the check for React/Styled Components
* @returns {function} Cleanup function to remove event listeners
*/
function initAnchorMenu() {
const HEADER_IDS = ["asu-header", "asuHeader"];
function initAnchorMenu(options = { ignoreReactCheck: false }) {
const HEADER_IDS = ["asu-header", "asuHeader", "headerContainer"];
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");
if (!navbar || !globalHeader) {
console.warn(
"Anchor menu initialization failed: required elements not found"
);
return;

// Check if this is the React version (Styled Components) to exclude react version
// Styled components generate classes starting with "sc-"
if (
!options.ignoreReactCheck &&
navbar &&
Array.from(navbar.classList).some(cls => cls.startsWith("sc-"))
) {
return () => {};
}

const navbarOriginalParent = navbar.parentNode;
Expand All @@ -29,20 +34,89 @@ function initAnchorMenu() {
let previousScrollPosition = window.scrollY;
let isNavbarAttached = false;

// These values are for optionally present Drupal admin toolbars. They
// are not present in Storybook and not required in implementations.
const toolbarBarHeight =
document.getElementById("toolbar-bar")?.offsetHeight || 0;
const toolbarItemAdministrationTrayHeight =
document.getElementById("toolbar-item-administration-tray")?.offsetHeight ||
0;

const combinedToolbarHeightOffset =
toolbarBarHeight + toolbarItemAdministrationTrayHeight;
const navbarInitialTop =
navbar.getBoundingClientRect().top +
window.scrollY -
combinedToolbarHeightOffset;
const navbarInitialTop = navbar.getBoundingClientRect().top + window.scrollY;

// Cache toolbar elements
const toolbarBar = document.getElementById("toolbar-bar");
const toolbarTray = document.getElementById(
"toolbar-item-administration-tray"
);

/**
* Determines scroll-margin-top based on header and toolbar presence.
* @returns {string} Scroll margin value in rem
*/
function getScrollMargin() {
const hasToolbar = toolbarBar || toolbarTray;
const hasHeader = !!globalHeader;

if (hasHeader && hasToolbar) {
return "10rem";
} else if (hasHeader && !hasToolbar) {
return "4rem";
} else {
return "2rem";
}
}

/**
* Updates scroll-margin-top on all anchor targets.
*/
function updateScrollMargins() {
const margin = getScrollMargin();
anchorTargets.forEach(target => {
target.style.scrollMarginTop = margin;
});
}

/**
* Gets the toolbar offset for fixed positioning.
* @returns {number} Offset in pixels
*/
function getToolbarOffset() {
return (toolbarBar?.offsetHeight || 0) + (toolbarTray?.offsetHeight || 0);
}

/**
* Attaches the navbar to the global header or body.
*/
function attachNavbar() {
if (isNavbarAttached) return;

if (globalHeader) {
globalHeader.appendChild(navbar);
} else {
document.body.appendChild(navbar);
navbar.style.position = "fixed";
navbar.style.top = `${getToolbarOffset()}px`;
navbar.style.width = "100%";
navbar.style.zIndex = "1000";
}

isNavbarAttached = true;
navbar.classList.add("uds-anchor-menu-attached");
updateScrollMargins();
}

/**
* Detaches the navbar and returns it to its original position.
*/
function detachNavbar() {
if (!isNavbarAttached) return;

navbarOriginalParent.insertBefore(navbar, navbarOriginalNextSibling);

if (!globalHeader) {
navbar.style.position = "";
navbar.style.top = "";
navbar.style.width = "";
navbar.style.zIndex = "";
}

isNavbarAttached = false;
navbar.classList.remove("uds-anchor-menu-attached");
updateScrollMargins();
}

for (let anchor of anchors) {
const href = anchor.getAttribute("href");
Expand All @@ -58,25 +132,28 @@ function initAnchorMenu() {
}
}

const shouldAttachNavbarOnLoad = window.scrollY > navbarInitialTop;
if (shouldAttachNavbarOnLoad) {
globalHeader.appendChild(navbar);
isNavbarAttached = true;
navbar.classList.add("uds-anchor-menu-attached");
// Set initial scroll margins
updateScrollMargins();

// Attach navbar on load if already scrolled past initial position
if (window.scrollY > navbarInitialTop) {
attachNavbar();
}

/**
* Calculates the percentage of an element that is visible in the viewport.
*
* @param {Element} el The element to calculate the visible percentage for.
* @param {HTMLElement} el The element to calculate the visible percentage for.
* @param {number} depth Recursion depth counter to prevent infinite loops.
* @return {number} The percentage of the element that is visible in the viewport.
*/
function calculateVisiblePercentage(el, depth = 0) {
if (!el || depth > 10) {
return 0;
}

if (!(el instanceof HTMLElement)) {
return 0;
}
if (el.offsetHeight === 0 || el.offsetWidth === 0) {
return calculateVisiblePercentage(el.parentElement, depth + 1);
}
Expand Down Expand Up @@ -104,16 +181,14 @@ function initAnchorMenu() {
}

const scrollHandlerLogic = function () {
// Custom code added for Drupal - Handle active anchor highlighting
// Handle active anchor highlighting
let maxVisibility = 0;
let mostVisibleElementId = null;

// Find the element with highest visibility
anchors.forEach(anchor => {
const target = anchorTargets.get(anchor);
if (!target) {
return;
}
if (!target) return;

const visiblePercentage = calculateVisiblePercentage(target);
if (visiblePercentage > 0 && visiblePercentage > maxVisibility) {
Expand All @@ -124,51 +199,44 @@ function initAnchorMenu() {

// Update active class if we found a visible element
if (mostVisibleElementId) {
const activeAnchor = document.querySelector(
'[href="#' + mostVisibleElementId + '"]'
const activeAnchor = navbar.querySelector(
`[href="#${mostVisibleElementId}"]`
);
if (activeAnchor) {
activeAnchor.classList.add("active");
activeAnchor.setAttribute("aria-current", "location");
}

// Remove active class from all other nav links in the navbar
// Remove active class from all other nav links
navbar
.querySelectorAll(
'a.nav-link:not([href="#' + mostVisibleElementId + '"])'
)
.forEach(function (e) {
e.classList.remove("active");
.querySelectorAll(`a.nav-link:not([href="#${mostVisibleElementId}"])`)
.forEach(link => {
link.classList.remove("active");
link.removeAttribute("aria-current");
});
}

// Handle navbar attachment/detachment
const navbarY = navbar.getBoundingClientRect().top;
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 (isScrollingDown && !isNavbarAttached) {
const shouldAttach = globalHeader
? globalHeader.getBoundingClientRect().bottom >=
navbar.getBoundingClientRect().top
: window.scrollY >= navbarInitialTop;

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

// Only detach if we're back to the initial navbar position or if header no longer overlaps navbar
if (
window.scrollY <= navbarInitialTop ||
currentHeaderBottom < navbarCurrentTop
) {
navbarOriginalParent.insertBefore(navbar, navbarOriginalNextSibling);
isNavbarAttached = false;
navbar.classList.remove("uds-anchor-menu-attached");
if (shouldAttach) {
attachNavbar();
}
} else if (!isScrollingDown && isNavbarAttached) {
const shouldDetach = globalHeader
? window.scrollY <= navbarInitialTop ||
globalHeader.getBoundingClientRect().bottom <
navbar.getBoundingClientRect().top
: window.scrollY <= navbarInitialTop;

if (shouldDetach) {
detachNavbar();
}
}

Expand All @@ -192,42 +260,66 @@ function initAnchorMenu() {

window.addEventListener("scroll", throttledScrollHandler, { passive: true });

// Update scroll margins on resize (handles zoom and font-size changes)
let resizeTimeout;
const handleResize = () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
updateScrollMargins();
}, 150);
};
window.addEventListener("resize", handleResize, { passive: true });

// Set click event handlers for all valid anchors
// Only anchors with valid targets were added to anchorTargets Map
for (let [anchor, anchorTarget] of anchorTargets) {
anchor.addEventListener("click", function (e) {
e.preventDefault();
const hash = anchor.getAttribute("href");

// Update URL hash
history?.pushState
? history.pushState(null, "", hash)
: (window.location.hash = hash);

if (!anchorTarget || !document.body.contains(anchorTarget)) {
console.warn("Anchor target no longer exists in DOM");
return;
}

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

const targetAbsoluteTop =
anchorTarget.getBoundingClientRect().top + window.scrollY;

let scrollToPosition = targetAbsoluteTop - targetQuarterPosition;

window.scrollTo({
top: scrollToPosition,
// scrollIntoView now respects scroll-margin-top
anchorTarget.scrollIntoView({
behavior: "smooth",
block: "start",
});

// Remove active class from other anchor in navbar, and add it to the clicked anchor
const active = navbar.querySelector(".nav-link.active");
// Focus for accessibility and correct tab order
anchorTarget.setAttribute("tabindex", "-1");
anchorTarget.focus({ preventScroll: true });

if (active) {
active.classList.remove("active");
}
// Update active states
navbar.querySelectorAll(".nav-link.active").forEach(link => {
link.classList.remove("active");
link.removeAttribute("aria-current");
});

e.target.classList.add("active");
e.target.setAttribute("aria-current", "location");
});
}

// Cleanup function
return () => {
window.removeEventListener("scroll", throttledScrollHandler);
window.removeEventListener("resize", handleResize);
clearTimeout(resizeTimeout);
if (isNavbarAttached && navbarOriginalParent) {
detachNavbar();
}
// Reset scroll margins
anchorTargets.forEach(target => {
target.style.scrollMarginTop = "";
});
};
}

EventHandler.on(window, "load.uds.anchor-menu", initAnchorMenu);
Expand Down
2 changes: 1 addition & 1 deletion packages/unity-react-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"main": "./dist/unityReactCore.umd.js",
"module": "./dist/unityReactCore.es.js",
"browser": "./dist/unityReactCore.umd.js",
"types": "./dist/types/index.d.ts",
"types": "./dist/types/unity-react-core/src/index.d.ts",
"description": "Core UDS React UI components required by other higher-order React packages",
"author": "Nathan Rollins <nathan.rollins@asu.edu>",
"homepage": "https://github.com/ASU/asu-unity-stack#readme",
Expand Down
Loading