From cf41d5e0fa71b943deda8a845c60b9230abae995 Mon Sep 17 00:00:00 2001 From: david ornelas Date: Mon, 2 Feb 2026 12:41:00 -0700 Subject: [PATCH 1/3] fix(unity-bootstrap-theme): fix anchor menu zoom position and tab order for sections N --- .../src/js/anchor-menu.js | 128 +++++++++++++----- 1 file changed, 94 insertions(+), 34 deletions(-) diff --git a/packages/unity-bootstrap-theme/src/js/anchor-menu.js b/packages/unity-bootstrap-theme/src/js/anchor-menu.js index 8afdddc12..462a2a59c 100644 --- a/packages/unity-bootstrap-theme/src/js/anchor-menu.js +++ b/packages/unity-bootstrap-theme/src/js/anchor-menu.js @@ -1,24 +1,37 @@ +// @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) { + + // 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 () => {}; + } + + if (!navbar) { console.warn( "Anchor menu initialization failed: required elements not found" ); - return; + return () => {}; } const navbarOriginalParent = navbar.parentNode; @@ -68,7 +81,7 @@ function initAnchorMenu() { /** * 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. */ @@ -76,7 +89,9 @@ function initAnchorMenu() { 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); } @@ -129,6 +144,7 @@ function initAnchorMenu() { ); if (activeAnchor) { activeAnchor.classList.add("active"); + activeAnchor.setAttribute("aria-current", "location"); } // Remove active class from all other nav links in the navbar @@ -138,35 +154,69 @@ function initAnchorMenu() { ) .forEach(function (e) { e.classList.remove("active"); + e.removeAttribute("aria-current"); }); } // Handle navbar attachment/detachment const navbarY = navbar.getBoundingClientRect().top; - const headerBottom = globalHeader.getBoundingClientRect().bottom; + const headerBottom = globalHeader + ? globalHeader.getBoundingClientRect().bottom + : 0; 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) { + if (globalHeader) { + if (headerBottom >= navbarY && !isNavbarAttached) { + // Attach navbar to globalHeader + globalHeader.appendChild(navbar); + isNavbarAttached = true; + navbar.classList.add("uds-anchor-menu-attached"); + } + } else { + if (window.scrollY >= navbarInitialTop && !isNavbarAttached) { + // Attach fixed to body + document.body.appendChild(navbar); + navbar.style.position = "fixed"; + navbar.style.top = combinedToolbarHeightOffset + "px"; + navbar.style.width = "100%"; + navbar.style.zIndex = "1000"; + 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; - // Only detach if we're back to the initial navbar position or if header no longer overlaps navbar - if ( - window.scrollY <= navbarInitialTop || - currentHeaderBottom < navbarCurrentTop - ) { + let shouldDetach = false; + + if (globalHeader) { + const currentHeaderBottom = globalHeader.getBoundingClientRect().bottom; + const navbarCurrentTop = navbar.getBoundingClientRect().top; + if ( + window.scrollY <= navbarInitialTop || + currentHeaderBottom < navbarCurrentTop + ) { + shouldDetach = true; + } + } else { + if (window.scrollY <= navbarInitialTop) { + shouldDetach = true; + } + } + + if (shouldDetach) { navbarOriginalParent.insertBefore(navbar, navbarOriginalNextSibling); + if (!globalHeader) { + // Reset styles + navbar.style.position = ""; + navbar.style.top = ""; + navbar.style.width = ""; + navbar.style.zIndex = ""; + } isNavbarAttached = false; navbar.classList.remove("uds-anchor-menu-attached"); } @@ -197,37 +247,47 @@ function initAnchorMenu() { for (let [anchor, anchorTarget] of anchorTargets) { anchor.addEventListener("click", function (e) { e.preventDefault(); + const hash = anchor.getAttribute("href"); + 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, + anchorTarget.scrollIntoView({ behavior: "smooth", + block: "start", }); + // Focus the target element to ensure correct tab order after navigation + anchorTarget.setAttribute("tabindex", "-1"); + anchorTarget.focus({ preventScroll: true }); + // Remove active class from other anchor in navbar, and add it to the clicked anchor const active = navbar.querySelector(".nav-link.active"); if (active) { active.classList.remove("active"); + active.removeAttribute("aria-current"); } + // @ts-ignore e.target.classList.add("active"); + // @ts-ignore + e.target.setAttribute("aria-current", "location"); }); } + + // Cleanup function + return () => { + window.removeEventListener("scroll", throttledScrollHandler); + if (isNavbarAttached && navbarOriginalParent) { + navbarOriginalParent.insertBefore(navbar, navbarOriginalNextSibling); + } + }; } EventHandler.on(window, "load.uds.anchor-menu", initAnchorMenu); From 1597b39dc7dd0814fa495cd718399ba2b7ada93a Mon Sep 17 00:00:00 2001 From: david ornelas Date: Mon, 2 Feb 2026 17:05:13 -0700 Subject: [PATCH 2/3] feat(unity-react-core): remove all anchor menu logic in react component Logic only lives in unity-bootstrap-theme js to be more in line with how the rest of components behave BREAKING CHANGE: All attach/detach and scroll logic is removed from anchor menu react component and relies on unity-bootstrap-theme javascript --- packages/unity-react-core/package.json | 2 +- .../src/components/AnchorMenu/AnchorMenu.jsx | 293 ------------------ ...enu.stories.jsx => AnchorMenu.stories.tsx} | 16 +- .../AnchorMenu/AnchorMenu.styles.js | 41 --- ...nchorMenu.test.jsx => AnchorMenu.test.tsx} | 12 +- .../src/components/AnchorMenu/AnchorMenu.tsx | 163 ++++++++++ 6 files changed, 178 insertions(+), 349 deletions(-) delete mode 100644 packages/unity-react-core/src/components/AnchorMenu/AnchorMenu.jsx rename packages/unity-react-core/src/components/AnchorMenu/{AnchorMenu.stories.jsx => AnchorMenu.stories.tsx} (90%) delete mode 100644 packages/unity-react-core/src/components/AnchorMenu/AnchorMenu.styles.js rename packages/unity-react-core/src/components/AnchorMenu/{AnchorMenu.test.jsx => AnchorMenu.test.tsx} (80%) create mode 100644 packages/unity-react-core/src/components/AnchorMenu/AnchorMenu.tsx diff --git a/packages/unity-react-core/package.json b/packages/unity-react-core/package.json index de81705d2..098c1e59c 100644 --- a/packages/unity-react-core/package.json +++ b/packages/unity-react-core/package.json @@ -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 ", "homepage": "https://github.com/ASU/asu-unity-stack#readme", diff --git a/packages/unity-react-core/src/components/AnchorMenu/AnchorMenu.jsx b/packages/unity-react-core/src/components/AnchorMenu/AnchorMenu.jsx deleted file mode 100644 index 8e53cf556..000000000 --- a/packages/unity-react-core/src/components/AnchorMenu/AnchorMenu.jsx +++ /dev/null @@ -1,293 +0,0 @@ -// @ts-check -/** - * - * - * TODO: Does not work with Bootstrap Framework - * Requires functionality UDS-1664 - * - * - */ -import { - debounce, - queryFirstFocusable, - throttle, - useMediaQuery, -} from "@asu/shared"; -import classNames from "classnames"; -import PropTypes from "prop-types"; -import React, { useState, useEffect, useRef } from "react"; - -import { Button } from "../Button/Button"; -import { GaEventWrapper } from "../GaEventWrapper/GaEventWrapper"; -import { useBaseSpecificFramework } from "../GaEventWrapper/useBaseSpecificFramework"; -import { AnchorMenuWrapper } from "./AnchorMenu.styles"; - -const menuTitle = "On This Page"; - -const defaultMobileGAEvent = { - event: "collapse", - name: "onclick", - type: "click", - text: menuTitle, -}; - -/** - * @typedef { import('../../core/types/shared-types').AnchorMenuProps } AnchorMenuProps - */ - -/** - * @param {AnchorMenuProps} props - * @returns {JSX.Element} - */ -export const AnchorMenu = ({ - items, - firstElementId, - focusFirstFocusableElement = false, -}) => { - const { isReact, isBootstrap } = useBaseSpecificFramework(); - - const anchorMenuRef = useRef(null); - const isSmallDevice = useMediaQuery("(max-width: 991px)"); - const [state, setState] = useState({ - hasHeader: false, - hasAltMenuSpacing: false, - containerClass: "container-xl", - activeContainer: "", - showMenu: false, - sticky: false, - }); - const headerHeight = isSmallDevice ? 110 : 142; - - const handleWindowScroll = () => { - const newState = {}; - const curPos = window.scrollY; - // Select first next sibling element of the anchor menu - const firstElement = document - .getElementById(firstElementId) - ?.getBoundingClientRect().top; - const anchorMenuHeight = 103; - - // Scroll position - if (firstElement >= 0) { - newState.sticky = false; - newState.activeContainer = ""; - } - if (curPos > anchorMenuRef.current.getBoundingClientRect().top) - newState.sticky = true; - - // Change active containers on scroll - const subsHeight = state.hasHeader - ? headerHeight + anchorMenuHeight - : anchorMenuHeight; - items?.forEach(({ targetIdName }) => { - const container = document.getElementById(targetIdName); - const containerTop = container?.getBoundingClientRect().top - subsHeight; - const containerBottom = - container?.getBoundingClientRect().bottom - subsHeight; - if (containerTop < 0 && containerBottom > 0) { - newState.activeContainer = targetIdName; - } - }); - - setState(prevState => ({ - ...prevState, - ...newState, - })); - }; - - const throttleWindowScroll = () => { - const timeout = 150; - // prevent function from being called excessively - throttle(handleWindowScroll, timeout); - // ensure function executes after scrolling stops - debounce(handleWindowScroll, timeout); - }; - - // Is ASU Header on the document - const isHeader = () => { - const pageHeader = - document.getElementById("asu-header") || - document.getElementById("headerContainer") || - document.getElementById("asuHeader"); - return !!pageHeader; - }; - - // Is element present which requires different spacing for the ASU Header - // Sets prop for styled-component to change anchor menu style - const isAltMenuSpacing = () => { - const degreeDetailPageContainer = document.getElementById( - "degreeDetailPageContainer" - ); - return !!degreeDetailPageContainer; - }; - - // Returns the first container class found from ancestors or default - function getContainerClass(el = null) { - if (el === null) return state.containerClass; - - const result = Object.values(el.classList).filter(c => - [ - "container-sm", - "container-md", - "container", - "container-lg", - "container-xl", - "container-fluid", - ].includes(c) - ); - - if (result.length > 0) return result.join(" "); - - return getContainerClass(el.parentElement); - } - - // get values from outside this component - // set initial state from external values - useEffect(() => { - const firstElement = document.getElementById(firstElementId) || null; - const newState = { - hasHeader: isHeader(), - hasAltMenuSpacing: isAltMenuSpacing(), - containerClass: getContainerClass(firstElement), - }; - setState(prevState => ({ - ...prevState, - ...newState, - })); - }, []); - - useEffect(() => { - window?.addEventListener("scroll", throttleWindowScroll); - return () => window.removeEventListener("scroll", throttleWindowScroll); - }, [state.hasHeader]); - - const handleClickLink = container => { - // Set scroll position considering if ASU Header is setted or not - const curScroll = - window.scrollY - (state.hasHeader ? headerHeight + 100 : 100); - const anchorMenuHeight = isSmallDevice ? 410 : 90; - // Set where to scroll to - let scrollTo = - document.getElementById(container)?.getBoundingClientRect().top + - curScroll; - - if (!anchorMenuRef.current.classList.contains("sticky")) - scrollTo -= anchorMenuHeight; - - if (focusFirstFocusableElement) - queryFirstFocusable(`#${container}`)?.focus(); - - window.scrollTo({ top: scrollTo, behavior: "smooth" }); - }; - - const handleMenuVisibility = () => { - setState(prevState => ({ - ...prevState, - showMenu: !prevState.showMenu, - })); - }; - - return ( - items?.length > 0 && ( - -
- {isSmallDevice ? ( - - - - ) : ( -

{menuTitle}:

- )} - -
- -
-
-
- ) - ); -}; - -AnchorMenu.propTypes = { - /** - * Anchor menu items - */ - items: PropTypes.arrayOf( - PropTypes.shape({ - text: PropTypes.string.isRequired, - targetIdName: PropTypes.string.isRequired, - icon: PropTypes.arrayOf(PropTypes.string), - }) - ).isRequired, - /** - * First next sibling element of the anchor menu - */ - firstElementId: PropTypes.string.isRequired, - /** - * If true it focus the first focusable element into the section - * If false it focus the next menu item into the nav bar - */ - focusFirstFocusableElement: PropTypes.bool, -}; diff --git a/packages/unity-react-core/src/components/AnchorMenu/AnchorMenu.stories.jsx b/packages/unity-react-core/src/components/AnchorMenu/AnchorMenu.stories.tsx similarity index 90% rename from packages/unity-react-core/src/components/AnchorMenu/AnchorMenu.stories.jsx rename to packages/unity-react-core/src/components/AnchorMenu/AnchorMenu.stories.tsx index ae14629ab..a6ede23a3 100644 --- a/packages/unity-react-core/src/components/AnchorMenu/AnchorMenu.stories.jsx +++ b/packages/unity-react-core/src/components/AnchorMenu/AnchorMenu.stories.tsx @@ -1,15 +1,16 @@ -/* eslint react/jsx-props-no-spreading: "off" */ import { getLoremSentences, titleCaseDefinition } from "@asu/shared"; +import { Meta, StoryFn } from "@storybook/react"; import classNames from "classnames"; import React from "react"; import { Basic as Header } from "../../../../unity-bootstrap-theme/stories/organisms/global-header/global-header.templates"; import { Divider } from "../Divider/Divider"; import { useBaseSpecificFramework } from "../GaEventWrapper/useBaseSpecificFramework"; -import { AnchorMenu } from "./AnchorMenu"; +import { AnchorMenu, AnchorMenuProps } from "./AnchorMenu"; const titleCaseTitle = "Anchor Menus Should Always be Formatted with Title Case"; + export default { title: "Components/AnchorMenu", component: AnchorMenu, @@ -34,7 +35,8 @@ This story includes another components for demostration purposes. }, }, }, -}; +} as Meta; + const items = [ { text: "Title Case is Required", @@ -50,7 +52,7 @@ const items = [ }, ]; -export const Containers = () => { +export const Containers: React.FC = () => { return ( <> {items && @@ -78,9 +80,9 @@ export const Containers = () => {

{ getLoremSentences( - 40, + 200, index * 3 - ) /* 40 sentences, index * 3 offset just creates some variety */ + ) /* 200 sentences, index * 3 offset just creates some variety */ }

@@ -90,7 +92,7 @@ export const Containers = () => { ); }; -const Template = args => { +const Template: StoryFn = args => { const { isBootstrap } = useBaseSpecificFramework(); return ( diff --git a/packages/unity-react-core/src/components/AnchorMenu/AnchorMenu.styles.js b/packages/unity-react-core/src/components/AnchorMenu/AnchorMenu.styles.js deleted file mode 100644 index d0db166d6..000000000 --- a/packages/unity-react-core/src/components/AnchorMenu/AnchorMenu.styles.js +++ /dev/null @@ -1,41 +0,0 @@ -import styled from "styled-components"; - -const AnchorMenuWrapper = styled.div` - &.sticky { - position: fixed; - top: 0; - left: 0; - width: 100%; - &.with-header { - top: ${({ requiresAltMenuSpacing }) => - requiresAltMenuSpacing ? "112px" : "142px"}; - @media (max-width: 992px) { - top: 110px; - } - } - } - .mobile-menu-toggler { - background-color: transparent; - border: none; - cursor: default; - h4 { - align-items: center; - } - i { - transition: all 0.3s; - } - } - .show-menu i { - transform: rotate(-180deg); - } - .nav-link { - border: none; - background-color: #ffffff; - i { - width: 2rem !important; - text-align: center !important; - } - } -`; - -export { AnchorMenuWrapper }; diff --git a/packages/unity-react-core/src/components/AnchorMenu/AnchorMenu.test.jsx b/packages/unity-react-core/src/components/AnchorMenu/AnchorMenu.test.tsx similarity index 80% rename from packages/unity-react-core/src/components/AnchorMenu/AnchorMenu.test.jsx rename to packages/unity-react-core/src/components/AnchorMenu/AnchorMenu.test.tsx index db03a1a57..f220011c8 100644 --- a/packages/unity-react-core/src/components/AnchorMenu/AnchorMenu.test.jsx +++ b/packages/unity-react-core/src/components/AnchorMenu/AnchorMenu.test.tsx @@ -1,12 +1,11 @@ -// @ts-check -import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { render, screen, fireEvent, cleanup, RenderResult } from "@testing-library/react"; import React from "react"; import { expect, describe, it, afterEach, beforeEach } from "vitest"; -import { AnchorMenu } from "./AnchorMenu"; +import { AnchorMenu, AnchorMenuProps } from "./AnchorMenu"; import { Containers } from "./AnchorMenu.stories"; -const defaultArgs = { +const defaultArgs: AnchorMenuProps = { items: [ { text: "First container", targetIdName: "first-container" }, { text: "Second container", targetIdName: "second-container" }, @@ -16,7 +15,7 @@ const defaultArgs = { firstElementId: "first-container", }; -const renderAnchorMenu = props => { +const renderAnchorMenu = (props: AnchorMenuProps) => { return render( <> @@ -26,8 +25,7 @@ const renderAnchorMenu = props => { }; describe("#Anchor Menu", () => { - /** @type {import("@testing-library/react").RenderResult} */ - let component; + let component: RenderResult; beforeEach(() => { component = renderAnchorMenu(defaultArgs); diff --git a/packages/unity-react-core/src/components/AnchorMenu/AnchorMenu.tsx b/packages/unity-react-core/src/components/AnchorMenu/AnchorMenu.tsx new file mode 100644 index 000000000..4a155d940 --- /dev/null +++ b/packages/unity-react-core/src/components/AnchorMenu/AnchorMenu.tsx @@ -0,0 +1,163 @@ +import { useMediaQuery } from "@asu/shared"; +import classNames from "classnames"; +import React, { useState, useEffect, useRef } from "react"; +import { initAnchorMenu } from "../../../../unity-bootstrap-theme/src/js/anchor-menu"; +import { GaEventWrapper } from "../GaEventWrapper/GaEventWrapper"; + +const menuTitle = "On This Page"; + +const defaultMobileGAEvent = { + event: "collapse", + name: "onclick", + type: "click", + text: menuTitle, +}; + +export interface AnchorMenuItem { + text: string; + targetIdName: string; + icon?: string | string[]; +} + +export interface AnchorMenuProps { + items: AnchorMenuItem[]; + firstElementId: string; + focusFirstFocusableElement?: boolean; +} + +export const AnchorMenu: React.FC = ({ + items, + firstElementId, +}) => { + const anchorMenuRef = useRef(null); + const isSmallDevice = useMediaQuery("(max-width: 991px)"); + const [showMenu, setShowMenu] = useState(false); + + // Initialize shared logic (scroll, stickiness, active states) + useEffect(() => { + let cleanupFn: Function; + + const initialize = () => { + // requestAnimationFrame ensures we execute after layout paint implies the DOM is stable + requestAnimationFrame(() => { + cleanupFn = initAnchorMenu({ ignoreReactCheck: true }); + }); + }; + + // If the page is already fully loaded run immediately. + // Otherwise, wait for the load event to ensure global headers/styles are ready. + if (document.readyState === "complete") { + initialize(); + } else { + window.addEventListener("load", initialize, { once: true }); + } + + return () => { + window.removeEventListener("load", initialize); + if (cleanupFn) cleanupFn(); + }; + }, []); + + const [containerClass, setContainerClass] = useState("container-xl"); + + // Determine container class for initial layout + function getContainerClass(el: HTMLElement | null = null): string { + if (el === null) return containerClass; + const result = Array.from(el.classList).filter(c => + [ + "container-sm", + "container-md", + "container", + "container-lg", + "container-xl", + "container-fluid", + ].includes(c) + ); + if (result.length > 0) return result.join(" "); + + const parent = el.parentElement; + return parent ? getContainerClass(parent) : containerClass; + } + + useEffect(() => { + const firstElement = document.getElementById(firstElementId); + setContainerClass(getContainerClass(firstElement)); + }, [firstElementId]); + + const handleMenuVisibility = () => { + setShowMenu(!showMenu); + }; + + return ( + items?.length > 0 && ( +
+
+ {isSmallDevice ? ( + + + + ) : ( +

{menuTitle}:

+ )} + +
+