From ac07b02fcada022fb4c9696460a2167995a9a81b Mon Sep 17 00:00:00 2001 From: Scott Williams <5209283+scott-williams-az@users.noreply.github.com> Date: Wed, 18 Feb 2026 11:35:39 -0700 Subject: [PATCH 01/10] feat(component-header-footer): add multi column dropdown, limit mega menu width --- .../src/header/components/Button/index.js | 7 +- .../NavbarContainer/DropdownItem/index.js | 104 ++- .../DropdownItem/index.styles.js | 61 +- .../NavbarContainer/NavItem/index.js | 20 +- .../NavbarContainer/NavItem/index.styles.js | 9 +- .../src/header/components/HeaderMain/index.js | 11 +- .../components/HeaderMain/index.styles.js | 1 + .../UniversalNavbar/Search/index.js | 4 +- .../src/header/core/constants/classNames.js | 1 + .../src/header/core/utils/data-mock.js | 809 ++++++++++++++++-- .../src/header/index.stories.js | 54 +- 11 files changed, 977 insertions(+), 104 deletions(-) diff --git a/packages/component-header-footer/src/header/components/Button/index.js b/packages/component-header-footer/src/header/components/Button/index.js index 1daf12413e..2b22c7b357 100644 --- a/packages/component-header-footer/src/header/components/Button/index.js +++ b/packages/component-header-footer/src/header/components/Button/index.js @@ -17,6 +17,7 @@ import { ButtonWrapper } from "./index.styles"; * @param {string} props.text - The text content to display inside the button * @param {string} [props.classes] - Additional CSS classes to apply to the button * @param {function} [props.onClick] - Event handler function called when the button is clicked + * @param {function} [props.onKeyDown] - Event handler function called when a key is pressed while the button is focused * @param {function} [props.onFocus] - Event handler function called when the button receives focus * @param {string|React.Component} [props.as] - The element type or component to render as * @returns {JSX.Element} The rendered button component @@ -27,6 +28,7 @@ const Button = ({ text, classes, onClick, + onKeyDown, onFocus, as, ...props @@ -35,8 +37,9 @@ const Button = ({ onClick(event) : undefined} - onFocus={onFocus ? event => onFocus(event) : undefined} + onClick={onClick} + onKeyDown={onKeyDown} + onFocus={onFocus} as={as} {...props} > diff --git a/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/DropdownItem/index.js b/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/DropdownItem/index.js index 0a189ac64a..a90b77bdce 100644 --- a/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/DropdownItem/index.js +++ b/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/DropdownItem/index.js @@ -28,14 +28,15 @@ HeadingItem.propTypes = { }; const ButtonItem = ({ link, dropdownName, handleLinkEvent }) => ( -
  • +
  • + ); ButtonItem.propTypes = { @@ -74,7 +75,7 @@ LinkItem.propTypes = { * @typedef { import("../../../../core/models/types").Button } Button * @typedef {{ * dropdownName: string - * items: [object][] + * items: Array> * buttons: Button[] * classes?: string, * listId: string @@ -97,7 +98,12 @@ const DropdownItem = ({ parentLink, }) => { const { breakpoint } = useAppContext(); - const isMega = items?.length > 2; + let cols = 0; + items.map(lists => { + cols += lists[0].span || 1; + }); + + const isMega = cols > 2; /** * @type {React.MutableRefObject} */ @@ -121,14 +127,13 @@ const DropdownItem = ({ const focusNextLink = () => { const nextLink = parentElement.nextElementSibling?.firstChild; - if (nextLink) nextLink.focus(); + if (typeof nextLink?.focus === "function") nextLink.focus(); }; const focusPrevLink = () => { const prevLink = parentElement.previousElementSibling?.firstChild; - if (prevLink) prevLink.focus(); + if (typeof prevLink?.focus === "function") prevLink.focus(); }; - stopPropagation(e); if (key === "ArrowDown") { @@ -139,7 +144,9 @@ const DropdownItem = ({ focusPrevLink(); } else if (key === "Escape") { setItemOpened(); - if (parentLink?.current) parentLink.current.focus(); + if (typeof parentLink?.current?.focus === "function") { + parentLink.current.focus(); + } } else if (key === "Enter" || key === " " || type === "click") { link?.onClick?.(e); trackGAEvent({ ...LINK_DEFAULT_PROPS, text: link.text }); @@ -147,7 +154,7 @@ const DropdownItem = ({ }; const renderItem = (link, index) => { - const key = `${link.text}-${link.href || index}`; + const key = `${link.text}-${link.href}-${index}`; if (link.type === "heading") return ; if (link.type === "button") @@ -178,6 +185,7 @@ const DropdownItem = ({ breakpoint={breakpoint} >
    @@ -190,6 +198,67 @@ const DropdownItem = ({ ); })} +======= + style={{ "--cols": cols < 3 ? 4 : cols }} + id={MULTIPLE_SUBMENUS ? listId : ""} + className={CLASS_NAMES.DROPDOWN_CONTAINER} + > + <> + {items?.map((item, index0) => { + const genKey = idGenerator(`dropdown-item-${index0}-`); + const key = genKey.next().value; + return ( +
    + {(() => { + let currentUl = []; + const uls = []; + item.forEach((link, index) => { + if (link.type === "heading") { + if (currentUl.length > 0) { + uls.push(currentUl); + currentUl = []; + } + uls.push([link]); + } else if (link.type === "button") { + if (currentUl.length > 0) { + uls.push(currentUl); + currentUl = []; + } + uls.push([link]); + } else { + currentUl.push(link); + } + }); + + if (currentUl.length > 0) { + uls.push(currentUl); + } + + return uls.map((group, groupIndex) => { + const groupKey = `${key}-group-${groupIndex}`; + if (group.length === 1 && group[0].type === "heading") { + return renderItem(group[0], groupIndex); + } + if (group.length === 1 && group[0].type === "button") { + return renderItem(group[0], groupIndex); + } + return ( +
      + {group.map((link, index) => renderItem(link, index))} +
    + ); + }); + })()} +
    + ); + })} + +>>>>>>> 2d6efe516 (feat(component-header-footer): add multi column dropdown, limit mega menu width)
    {buttons && (
    @@ -213,19 +282,20 @@ const DropdownItem = ({ DropdownItem.propTypes = { dropdownName: PropTypes.string, items: PropTypes.arrayOf( - PropTypes.shape({ - text: PropTypes.string, - selected: PropTypes.bool, - onClick: PropTypes.func, - href: PropTypes.string, - }) + PropTypes.arrayOf( + PropTypes.shape({ + text: PropTypes.string, + selected: PropTypes.bool, + onClick: PropTypes.func, + href: PropTypes.string, + }) + ) ), buttons: PropTypes.arrayOf(PropTypes.shape(ButtonPropTypes)), classes: PropTypes.string, listId: PropTypes.string, setItemOpened: PropTypes.func, parentLink: PropTypes.shape({ - focus: PropTypes.func, current: PropTypes.instanceOf(HTMLElement), }), }; diff --git a/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/DropdownItem/index.styles.js b/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/DropdownItem/index.styles.js index bd40e41c28..ef6035000d 100644 --- a/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/DropdownItem/index.styles.js +++ b/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/DropdownItem/index.styles.js @@ -8,6 +8,7 @@ import { import { CLASS_NAMES } from "../../../../core/constants/classNames"; const DropdownWrapper = styled.div` + --gap: 2rem; position: fixed; background-color: ${ASU_WHITE}; border: 1px solid ${ASU_GRAY5}; @@ -19,9 +20,8 @@ const DropdownWrapper = styled.div` visibility: visible; } &.mega { - width: 100%; - left: 0; - margin-left: 0 !important; + width: min(1200px, 100%); + left: calc((100% - 1200px) / 2); } &.aligned-right:not(.mega) { position: absolute; @@ -30,17 +30,23 @@ const DropdownWrapper = styled.div` > .${CLASS_NAMES.DROPDOWN_CONTAINER} { max-width: 1200px; margin: 0 auto; + max-height: 50vh; + overflow-y: auto; + overflow-x: hidden; display: flex; - justify-content: center; - padding: 2rem; - ul { - width: 16rem; - max-width: 282px; + justify-content: stretch; + + .${CLASS_NAMES.DROPDOWN_CONTAINER_COLUMN} { + --span: 1; /* component overrides using inline style */ + --col: 1; /* component overrides using inline style */ + width: calc(1200px / var(--cols) * var(--span)); + padding: var(--gap); display: flex; flex-direction: column; + justify-content: flex-start; + row-gap: var(--gap); + &:not(:last-child) { - padding-right: 2rem; - margin-right: 2rem; border-right: 1px solid ${ASU_GRAY5}; } .${CLASS_NAMES.UL_HEADING} { @@ -50,11 +56,15 @@ const DropdownWrapper = styled.div` font-weight: 700; text-align: left; opacity: 1; - margin: 1rem 0; + margin: 0; + margin-bottom: -1.5rem; line-height: calc(100% + 0.12em); } .${CLASS_NAMES.NAV_LINK} { padding: 0; + flex-basis: calc( + (100% / var(--span)) - var(--ul-gap) * (var(--span) - 1) / var(--span) + ); a { width: 100%; display: inline-block; @@ -68,8 +78,9 @@ const DropdownWrapper = styled.div` } } & + .${CLASS_NAMES.NAV_BUTTON} { + width: 100%; margin-top: auto; - padding-top: 2rem; + padding-top: var(--gap); & + .${CLASS_NAMES.NAV_BUTTON} { margin-top: 1rem; } @@ -77,15 +88,23 @@ const DropdownWrapper = styled.div` } } } + + .${CLASS_NAMES.NAV_BUTTON} { + flex: 1 1; + display: inline-flex; + flex-direction: column; + justify-content: flex-end; + flex-wrap: nowrap; + width: fit-content; + } .${CLASS_NAMES.DROPDOWN_BUTTON_CONTAINER} { - border-top: 1px solid ${ASU_GRAY5}; - border-bottom: 1px solid ${ASU_GRAY5}; - margin-top: 1rem; + border-top: 1px solid #d0d0d0; + border-bottom: 1px solid #d0d0d0; > div { max-width: 1200px; margin: 0 auto; display: flex; - padding: 1rem 0; + padding: 1rem 2rem; } } @media (max-width: ${({ breakpoint }) => breakpoint}) { @@ -99,12 +118,19 @@ const DropdownWrapper = styled.div` } > .${CLASS_NAMES.DROPDOWN_CONTAINER} { max-width: 100%; + max-height: unset; + overflow-y: unset; + overflow-x: unset; padding: 1rem 2rem; flex-direction: column; + + .${CLASS_NAMES.DROPDOWN_CONTAINER_COLUMN} { + width: 100%; + } ul { width: 100%; max-width: 100%; - padding: 0 1rem; + padding: 0; margin-bottom: 1rem; &:not(:last-child) { padding-right: 1rem; @@ -118,6 +144,7 @@ const DropdownWrapper = styled.div` padding-top: 1.5rem; } .${CLASS_NAMES.NAV_LINK} { + flex-basis: 100%; padding: 0 1rem; &:not(:last-child) { border-bottom: 1px solid ${ASU_GRAY5}; diff --git a/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/NavItem/index.js b/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/NavItem/index.js index 721a2beac9..87271854d8 100644 --- a/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/NavItem/index.js +++ b/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/NavItem/index.js @@ -158,28 +158,30 @@ const NavItem = ({ link, setItemOpened, itemOpened }) => { if (navigableKeys.includes(key)) { e.preventDefault(); if (key === "Escape" && opened) { + if (typeof clickRef?.current?.focus === "function") { + clickRef.current.focus(); + } setItemOpened(); return; } // Handle Enter or Space key if (key === "Enter" || key === " ") { if (link.items) { - if (!expandOnHover && !isMobile) { - setItemOpened(); - } else if (isMobile) { - setItemOpened(); - } + // Regardless of state or props mobile/desktop/hover/click + // if the item has a dropdown, we want to toggle it on Enter/Space + setItemOpened(); } dispatchGAEvent(); link.onClick?.(e); } if (key === "ArrowDown" || key === "ArrowRight") { if (opened) { - const dropdownItems = document.querySelectorAll( + // Only need first matching item + const dropdownItem = document.querySelector( `.${getDropdownClass(link.id)} li.${CLASS_NAMES.NAV_LINK} a` ); - if (dropdownItems.length) { - dropdownItems[0].focus(); + if (typeof dropdownItem?.focus === "function") { + dropdownItem.focus(); } } } @@ -242,7 +244,7 @@ const NavItem = ({ link, setItemOpened, itemOpened }) => { )} listId={`dropdown-${link.id}`} setItemOpened={setItemOpened} - parentLink={parentLink?.current} + parentLink={parentLink} /> )} diff --git a/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/NavItem/index.styles.js b/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/NavItem/index.styles.js index 3d965f3780..dc1461a81c 100644 --- a/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/NavItem/index.styles.js +++ b/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/NavItem/index.styles.js @@ -1,5 +1,5 @@ import styled from "styled-components"; -import { ASU_GRAY1, ASU_GOLD, ASU_GRAY4 } from "../../../../colors"; +import { ASU_GRAY1, ASU_GOLD, ASU_GRAY4, ASU_WHITE } from "../../../../colors"; import { CLASS_NAMES } from "../../../../core/constants/classNames"; const NavItemWrapper = styled.li` @@ -16,6 +16,13 @@ const NavItemWrapper = styled.li` padding: 0.5rem 0.75rem; line-height: 1rem; color: ${ASU_GRAY1}; + + &[aria-expanded="true"] { + background-color: ${ASU_WHITE}; + position: sticky; + top: 0; + z-index: 1; + } &:after { transition: 0.5s cubic-bezier(0.19, 1, 0.19, 1); content: ""; diff --git a/packages/component-header-footer/src/header/components/HeaderMain/index.js b/packages/component-header-footer/src/header/components/HeaderMain/index.js index e6c731fecb..68d3b6117f 100644 --- a/packages/component-header-footer/src/header/components/HeaderMain/index.js +++ b/packages/component-header-footer/src/header/components/HeaderMain/index.js @@ -1,6 +1,6 @@ // @ts-check import { trackGAEvent } from "@asu/shared"; -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import { faTimes } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import mobileMenuSearchIcon from "../../../../public/assets/icons/menu-search-icon.png?inline"; @@ -25,6 +25,15 @@ const HeaderMain = () => { setMobileMenuOpen(prevState => !prevState); }; + useEffect(() => { + document.body.style.overflow = mobileMenuOpen ? "hidden" : "unset"; + + // Clean up function to re-enable scrolling + return () => { + document.body.style.overflow = "unset"; + }; + }, [mobileMenuOpen]); + const handleClickMobileMenu = () => { handleChangeMenuVisibility(); trackGAEvent({ diff --git a/packages/component-header-footer/src/header/components/HeaderMain/index.styles.js b/packages/component-header-footer/src/header/components/HeaderMain/index.styles.js index 1c24418e04..4df140e00e 100644 --- a/packages/component-header-footer/src/header/components/HeaderMain/index.styles.js +++ b/packages/component-header-footer/src/header/components/HeaderMain/index.styles.js @@ -79,6 +79,7 @@ const HeaderMainWrapper = styled.div` cursor: pointer; min-width: 44px; min-height: 44px; + padding: 0.25rem 0.45rem; .${CLASS_NAMES.MENU_SEARCH_ICON} { display: none; diff --git a/packages/component-header-footer/src/header/components/UniversalNavbar/Search/index.js b/packages/component-header-footer/src/header/components/UniversalNavbar/Search/index.js index 8d0056305a..c5650fc425 100644 --- a/packages/component-header-footer/src/header/components/UniversalNavbar/Search/index.js +++ b/packages/component-header-footer/src/header/components/UniversalNavbar/Search/index.js @@ -29,7 +29,9 @@ const Search = () => { const [hasInputValue, setHasInputValue] = useState(false); useEffect(() => { - if (open && inputRef.current) inputRef.current.focus(); + if (open && typeof inputRef?.current?.focus === "function") { + inputRef.current.focus(); + } }, [open]); /** diff --git a/packages/component-header-footer/src/header/core/constants/classNames.js b/packages/component-header-footer/src/header/core/constants/classNames.js index 738151bb7d..1ed51e9ca6 100644 --- a/packages/component-header-footer/src/header/core/constants/classNames.js +++ b/packages/component-header-footer/src/header/core/constants/classNames.js @@ -20,6 +20,7 @@ export const CLASS_NAMES = { HEADER_DROPDOWN: id => `${CLASS_PREFIX}header-dropdown-${id}`, OPENED: `${CLASS_PREFIX}opened`, DROPDOWN_CONTAINER: `${CLASS_PREFIX}dropdown-container`, + DROPDOWN_CONTAINER_COLUMN: `${CLASS_PREFIX}dropdown-container-column`, DROPDOWN_BUTTON_CONTAINER: `${CLASS_PREFIX}dropdown-button-container`, // Search diff --git a/packages/component-header-footer/src/header/core/utils/data-mock.js b/packages/component-header-footer/src/header/core/utils/data-mock.js index 7f996155c4..1c91e3e96c 100644 --- a/packages/component-header-footer/src/header/core/utils/data-mock.js +++ b/packages/component-header-footer/src/header/core/utils/data-mock.js @@ -334,7 +334,7 @@ const navTreeMega = [ href: "#", }, { - text: "Link option 2", + text: "Col1", href: "/", items: [ [ @@ -370,7 +370,7 @@ const navTreeMega = [ ], }, { - text: "Link option 3", + text: "Col2", href: "/", items: [ [ @@ -434,7 +434,7 @@ const navTreeMega = [ ], }, { - text: "Link option 4", + text: "Col3", href: "#", items: [ [ @@ -530,7 +530,7 @@ const navTreeMega = [ ], }, { - text: "Link option 5", + text: "A", href: "#", items: [ [ @@ -599,6 +599,7 @@ const navTreeMega = [ href: "https://asuonline.asu.edu/", type: "heading", text: "Column three", + span: 3, }, { classes: "border first", @@ -618,16 +619,106 @@ const navTreeMega = [ text: "Polytechnic", }, { - href: "https://campus.asu.edu/downtown/", - type: "button", - text: "Another Button", + classes: "border first", + href: "https://www.asu.edu/map/", + text: "Maps and Directions", }, - ], - [ { - href: "https://asuonline.asu.edu/", - type: "heading", - text: "Column four", + href: "https://campus.asu.edu/tempe/", + text: "Office of the technology", + }, + { + href: "https://campus.asu.edu/west/", + text: "Office of the business", + }, + { + href: "https://campus.asu.edu/polytechnic/", + text: "Some longer text office of longtext", + }, + { + classes: "border first", + href: "https://www.asu.edu/map/", + text: "University Technology Office", + }, + { + href: "https://campus.asu.edu/tempe/", + text: "Sun Devil Football", + }, + { + href: "https://campus.asu.edu/west/", + text: "The School of Something", + }, + { + href: "https://campus.asu.edu/polytechnic/", + text: "Polytechnic", + }, + { + classes: "border first", + href: "https://www.asu.edu/map/", + text: "Maps and Directions", + }, + { + href: "https://campus.asu.edu/tempe/", + text: "Office of the technology", + }, + { + href: "https://campus.asu.edu/west/", + text: "Office of the business", + }, + { + href: "https://campus.asu.edu/polytechnic/", + text: "Some longer text office of longtext", + }, + { + classes: "border first", + href: "https://www.asu.edu/map/", + text: "University Technology Office", + }, + { + href: "https://campus.asu.edu/tempe/", + text: "Sun Devil Football", + }, + { + href: "https://campus.asu.edu/west/", + text: "The School of Something", + }, + { + href: "https://campus.asu.edu/polytechnic/", + text: "Polytechnic", + }, + { + classes: "border first", + href: "https://www.asu.edu/map/", + text: "Maps and Directions", + }, + { + href: "https://campus.asu.edu/tempe/", + text: "Office of the technology", + }, + { + href: "https://campus.asu.edu/west/", + text: "Office of the business", + }, + { + href: "https://campus.asu.edu/polytechnic/", + text: "Some longer text office of longtext", + }, + { + classes: "border first", + href: "https://www.asu.edu/map/", + text: "University Technology Office", + }, + { + href: "https://campus.asu.edu/tempe/", + text: "Sun Devil Football", + }, + { + href: "https://campus.asu.edu/west/", + text: "The School of Something", + }, + { + href: "https://campus.asu.edu/polytechnic/", + text: "Polytechnic", }, { classes: "border first", @@ -655,20 +746,8 @@ const navTreeMega = [ ], }, { - text: "Link option 6", + text: "B", href: "#", - buttons: [ - { - text: "CTA One", - href: "https://asu.edu", - color: "maroon", - }, - { - text: "CTA Two", - href: "https://asu.edu", - color: "gold", - }, - ], items: [ [ { @@ -678,7 +757,7 @@ const navTreeMega = [ }, { href: "https://havasu.asu.edu/", - text: "The Lake Havasu Campus", + text: "Lake Havasu", }, { href: "https://www.thunderbird.edu/about-thunderbird/locations/phoenix-arizona", @@ -701,13 +780,6 @@ const navTreeMega = [ href: "https://wpcarey.asu.edu/mba/china-program/english/", text: "China", }, - { - href: "https://campus.asu.edu/downtown/", - type: "button", - text: "Call to Action", - }, - ], - [ { href: "https://asuonline.asu.edu/", type: "heading", @@ -733,7 +805,7 @@ const navTreeMega = [ { href: "https://campus.asu.edu/downtown/", type: "button", - text: "Action Button", + text: "Action", }, ], [ @@ -741,6 +813,7 @@ const navTreeMega = [ href: "https://asuonline.asu.edu/", type: "heading", text: "Column three", + span: 3, }, { classes: "border first", @@ -760,16 +833,38 @@ const navTreeMega = [ text: "Polytechnic", }, { - href: "https://campus.asu.edu/downtown/", - type: "button", - text: "Another Button", + classes: "border first", + href: "https://www.asu.edu/map/", + text: "Maps and Directions", }, - ], - [ { - href: "https://asuonline.asu.edu/", - type: "heading", - text: "Column four", + href: "https://campus.asu.edu/tempe/", + text: "Office of the technology", + }, + { + href: "https://campus.asu.edu/west/", + text: "Office of the business", + }, + { + href: "https://campus.asu.edu/polytechnic/", + text: "Some longer text office of longtext", + }, + { + classes: "border first", + href: "https://www.asu.edu/map/", + text: "University Technology Office", + }, + { + href: "https://campus.asu.edu/tempe/", + text: "Sun Devil Football", + }, + { + href: "https://campus.asu.edu/west/", + text: "The School of Something", + }, + { + href: "https://campus.asu.edu/polytechnic/", + text: "Polytechnic", }, { classes: "border first", @@ -789,30 +884,636 @@ const navTreeMega = [ text: "Some longer text office of longtext", }, { - href: "https://campus.asu.edu/downtown/", - type: "button", - text: "Downtown Phoenix", + classes: "border first", + href: "https://www.asu.edu/map/", + text: "University Technology Office", }, - ], - [ { - href: "https://asuonline.asu.edu/", - type: "heading", - text: "Column Five", + href: "https://campus.asu.edu/tempe/", + text: "Sun Devil Football", + }, + { + href: "https://campus.asu.edu/west/", + text: "The School of Something", + }, + { + href: "https://campus.asu.edu/polytechnic/", + text: "Polytechnic", }, { classes: "border first", href: "https://www.asu.edu/map/", - text: "Buildings and directory", + text: "Maps and Directions", }, { href: "https://campus.asu.edu/tempe/", - text: "Some good news", + text: "Office of the technology", }, { href: "https://campus.asu.edu/west/", - selected: true, - text: "Directory Admin Tools", + text: "Office of the business", + }, + { + href: "https://campus.asu.edu/polytechnic/", + text: "Some longer text office of longtext", + }, + { + classes: "border first", + href: "https://www.asu.edu/map/", + text: "University Technology Office", + }, + { + href: "https://campus.asu.edu/tempe/", + text: "Sun Devil Football", + }, + { + href: "https://campus.asu.edu/west/", + text: "The School of Something", + }, + { + href: "https://campus.asu.edu/polytechnic/", + text: "Polytechnic", + }, + { + classes: "border first", + href: "https://www.asu.edu/map/", + text: "Maps and Directions", + }, + { + href: "https://campus.asu.edu/tempe/", + text: "Office of the technology", + }, + { + href: "https://campus.asu.edu/west/", + text: "Office of the business", + }, + { + href: "https://campus.asu.edu/polytechnic/", + text: "Some longer text office of longtext", + }, + { + href: "https://campus.asu.edu/downtown/", + type: "button", + text: "Downtown Phoenix", + }, + ], + ], + }, + { + text: "C", + href: "#", + buttons: [ + { + text: "CTA One", + href: "https://asu.edu", + color: "maroon", + }, + { + text: "CTA Two", + href: "https://asu.edu", + color: "gold", + }, + ], + items: [ + [ + { + href: "https://asuonline.asu.edu/", + type: "heading", + text: "Menu One - span 2", + span: 2, + }, + { + href: "https://havasu.asu.edu/", + text: "The Lake Havasu Campus", + }, + { + href: "https://www.thunderbird.edu/about-thunderbird/locations/phoenix-arizona", + classes: "border", + text: "Thunderbird", + }, + { + href: "https://skysong.asu.edu/", + text: "Skysong", + }, + { + href: "https://asuresearchpark.com/", + text: "Research Park", + }, + { + href: "https://washingtoncenter.asu.edu/", + text: "Washington D.C.", + }, + { + href: "https://www.thunderbird.edu/about-thunderbird/locations/phoenix-arizona", + classes: "border", + text: "Thunderbird", + }, + { + href: "https://skysong.asu.edu/", + text: "Skysong", + }, + { + href: "https://asuresearchpark.com/", + text: "Research Park", + }, + { + href: "https://washingtoncenter.asu.edu/", + text: "Washington D.C.", + }, + { + href: "https://www.thunderbird.edu/about-thunderbird/locations/phoenix-arizona", + classes: "border", + text: "Thunderbird", + }, + { + href: "https://skysong.asu.edu/", + text: "Skysong", + }, + { + href: "https://asuresearchpark.com/", + text: "Research Park", + }, + { + href: "https://washingtoncenter.asu.edu/", + text: "Washington D.C.", + }, + { + href: "https://www.thunderbird.edu/about-thunderbird/locations/phoenix-arizona", + classes: "border", + text: "Thunderbird", + }, + { + href: "https://skysong.asu.edu/", + text: "Skysong", + }, + { + href: "https://asuresearchpark.com/", + text: "Research Park", + }, + { + href: "https://washingtoncenter.asu.edu/", + text: "Washington D.C.", + }, + { + href: "https://asuonline.asu.edu/", + type: "heading", + text: "Menu Two - type header, in same array - inherits span", + }, + { + href: "https://www.asu.edu/map/", + text: "Faculty and Staff Directory", + }, + { + href: "https://campus.asu.edu/tempe/", + text: "The Tempe Campus", + }, + { + href: "https://campus.asu.edu/west/", + text: "Sun Devils and Things", + }, + { + href: "https://campus.asu.edu/polytechnic/", + text: "Another nav link", + }, + { + href: "https://campus.asu.edu/downtown/", + type: "button", + text: "Action Button", + }, + ], + [ + { + href: "https://asuonline.asu.edu/", + type: "heading", + text: "Menu three - new array = new column", + }, + { + href: "https://www.asu.edu/map/", + text: "University Technology Office", + }, + { + href: "https://campus.asu.edu/tempe/", + text: "Sun Devil Football", + }, + { + href: "https://campus.asu.edu/west/", + text: "The School of Something", + }, + { + href: "https://campus.asu.edu/polytechnic/", + text: "Polytechnic", + }, + { + href: "https://www.asu.edu/map/", + text: "University Technology Office", + }, + { + href: "https://campus.asu.edu/tempe/", + text: "Sun Devil Football", + }, + { + href: "https://campus.asu.edu/west/", + text: "The School of Something", + }, + { + href: "https://campus.asu.edu/polytechnic/", + text: "Polytechnic", + }, + { + href: "https://www.asu.edu/map/", + text: "University Technology Office", + }, + { + href: "https://campus.asu.edu/tempe/", + text: "Sun Devil Football", + }, + { + href: "https://campus.asu.edu/west/", + text: "The School of Something", + }, + { + href: "https://campus.asu.edu/polytechnic/", + text: "Polytechnic", + }, + { + href: "https://campus.asu.edu/downtown/", + type: "button", + text: "Another Button", + }, + ], + [ + { + href: "https://asuonline.asu.edu/", + type: "heading", + text: "Menu Four - new array = new column", + }, + { + classes: "border first", + href: "https://www.asu.edu/map/", + text: "Maps and Directions", + }, + { + href: "https://campus.asu.edu/tempe/", + text: "Office of the technology", + }, + { + href: "https://campus.asu.edu/west/", + text: "Office of the business", + }, + { + href: "https://campus.asu.edu/polytechnic/", + text: "Some longer text office of longtext", + }, + { + href: "https://campus.asu.edu/downtown/", + // type: "button", + text: "Downtown Phoenix", + }, + { + href: "https://asuonline.asu.edu/", + type: "heading", + text: "Column Five", + }, + { + classes: "border first", + href: "https://www.asu.edu/map/", + text: "Buildings and directory", + }, + { + href: "https://campus.asu.edu/tempe/", + text: "Some good news", + }, + { + href: "https://campus.asu.edu/west/", + selected: true, + text: "Directory Admin Tools", + }, + ], + ], + }, + + { + text: "D", + href: "#", + buttons: [ + { + text: "CTA One", + href: "https://asu.edu", + color: "maroon", + }, + { + text: "CTA Two", + href: "https://asu.edu", + color: "gold", + }, + ], + items: [ + [ + { + href: "https://asuonline.asu.edu/", + type: "heading", + text: "Menu One - span 2", + span: 2, + }, + { + href: "https://havasu.asu.edu/", + text: "The Lake Havasu Campus", + }, + { + href: "https://www.thunderbird.edu/about-thunderbird/locations/phoenix-arizona", + classes: "border", + text: "Thunderbird", + }, + { + href: "https://skysong.asu.edu/", + text: "Skysong", + }, + { + href: "https://asuresearchpark.com/", + text: "Research Park", + }, + { + href: "https://washingtoncenter.asu.edu/", + text: "Washington D.C.", + }, + { + href: "https://www.thunderbird.edu/about-thunderbird/locations/phoenix-arizona", + classes: "border", + text: "Thunderbird", + }, + { + href: "https://skysong.asu.edu/", + text: "Skysong", + }, + { + href: "https://asuresearchpark.com/", + text: "Research Park", + }, + { + href: "https://washingtoncenter.asu.edu/", + text: "Washington D.C.", + }, + { + href: "https://www.thunderbird.edu/about-thunderbird/locations/phoenix-arizona", + classes: "border", + text: "Thunderbird", + }, + { + href: "https://skysong.asu.edu/", + text: "Skysong", + }, + { + href: "https://asuresearchpark.com/", + text: "Research Park", + }, + { + href: "https://washingtoncenter.asu.edu/", + text: "Washington D.C.", + }, + { + href: "https://asuonline.asu.edu/", + type: "heading", + text: "Menu Two - type header, in same array - inherits span", + }, + { + href: "https://www.asu.edu/map/", + text: "Faculty and Staff Directory", + }, + { + href: "https://campus.asu.edu/tempe/", + text: "The Tempe Campus", + }, + { + href: "https://campus.asu.edu/west/", + text: "Sun Devils and Things", + }, + { + href: "https://campus.asu.edu/polytechnic/", + text: "Another nav link", + }, + { + href: "https://campus.asu.edu/downtown/", + type: "button", + text: "Action Button", + }, + ], + [ + { + href: "https://asuonline.asu.edu/", + type: "heading", + text: "Menu three - span 3", + span: 3, + }, + { + href: "https://www.asu.edu/map/", + text: "University Technology Office", + }, + { + href: "https://campus.asu.edu/tempe/", + text: "Sun Devil Football", + }, + { + href: "https://campus.asu.edu/west/", + text: "The School of Something", + }, + { + href: "https://campus.asu.edu/polytechnic/", + text: "Polytechnic", + }, + { + href: "https://www.asu.edu/map/", + text: "University Technology Office", + }, + { + href: "https://campus.asu.edu/tempe/", + text: "Sun Devil Football", + }, + { + href: "https://campus.asu.edu/west/", + text: "The School of Something", + }, + { + href: "https://campus.asu.edu/polytechnic/", + text: "Polytechnic", + }, + { + href: "https://www.asu.edu/map/", + text: "University Technology Office", + }, + { + href: "https://campus.asu.edu/tempe/", + text: "Sun Devil Football", + }, + { + href: "https://campus.asu.edu/west/", + text: "The School of Something", + }, + { + href: "https://campus.asu.edu/polytechnic/", + text: "Polytechnic", + }, + { + href: "https://asuonline.asu.edu/", + type: "heading", + text: "Menu Four", + }, + { + classes: "border first", + href: "https://www.asu.edu/map/", + text: "Maps and Directions", + }, + { + href: "https://campus.asu.edu/tempe/", + text: "Office of the technology", + }, + { + href: "https://campus.asu.edu/west/", + text: "Office of the business", + }, + { + href: "https://campus.asu.edu/polytechnic/", + text: "Some longer text office of longtext", + }, + { + href: "https://campus.asu.edu/downtown/", + // type: "button", + text: "Downtown Phoenix", + }, + { + classes: "border first", + href: "https://www.asu.edu/map/", + text: "Buildings and directory", + }, + { + href: "https://campus.asu.edu/tempe/", + text: "Some good news", + }, + { + href: "https://campus.asu.edu/west/", + selected: true, + text: "Directory Admin Tools", + }, + { + href: "https://skysong.asu.edu/", + text: "Skysong", + }, + { + href: "https://asuresearchpark.com/", + text: "Research Park", + }, + { + href: "https://washingtoncenter.asu.edu/", + text: "Washington D.C.", + }, + { + href: "https://www.thunderbird.edu/about-thunderbird/locations/phoenix-arizona", + classes: "border", + text: "Thunderbird", + }, + { + href: "https://skysong.asu.edu/", + text: "Skysong", + }, + ], + ], + }, + + { + text: "Col1", + href: "/", + items: [ + [ + { + href: "https://www.asu.edu/", + text: "A test navigation item", + }, + { + href: "https://www.asu.edu/", + text: "Mauris viverra, sem nec", + }, + { + href: "https://www.asu.edu/?feature=athletics", + text: "Massa nunc dictum nam venenatis", + }, + { + href: "https://www.asu.edu/?feature=alumni", + text: "Alumni", + }, + { + href: "https://www.asu.edu/?feature=giving", + text: "Giving", + }, + { + href: "https://www.asu.edu/?feature=president", + text: "President", + }, + { + href: "https://www.asu.edu/about", + text: "About ASU", + }, + ], + ], + }, + { + text: "Col2", + href: "/", + items: [ + [ + { + type: "heading", + text: "Column One", + }, + { + href: "https://www.asu.edu/", + text: "Pellentesque ornare", + }, + { + href: "https://www.asu.edu/", + text: "Curabitur viverra arcu nisl", + }, + { + href: "https://www.asu.edu/?feature=athletics", + text: "Aenean pharetra", + }, + { + href: "https://www.asu.edu/?feature=alumni", + text: "Pellentesque", + }, + { + href: "https://www.asu.edu/?feature=giving", + text: "Donec sagittis nulla", + }, + { + href: "https://www.asu.edu/?feature=president", + text: "Quisque fringilla", + }, + { + href: "https://www.asu.edu/about", + text: "Integer vel gravida lectus", + }, + ], + [ + { + href: "https://www.asu.edu/?feature=newsevents", + type: "heading", + text: "Column two", + }, + { + href: "https://www.asu.edu/?feature=academics", + text: "Nunc in libero odio", + }, + { + href: "https://www.asu.edu/?feature=research", + text: "Maecenas quam elit", + }, + { + href: "https://www.asu.edu/?feature=academics", + text: "Ut at vehicula neque", + }, + { + href: "https://www.asu.edu/?feature=athletics", + type: "button", + text: "Sed molestie", }, ], ], diff --git a/packages/component-header-footer/src/header/index.stories.js b/packages/component-header-footer/src/header/index.stories.js index 31a713f0d5..fe3a49a121 100644 --- a/packages/component-header-footer/src/header/index.stories.js +++ b/packages/component-header-footer/src/header/index.stories.js @@ -22,21 +22,71 @@ export default { }, }; +const disabledText = "Disabled"; +const enabledText = "Enabled"; + +function handlePageUnload(event) { + event.preventDefault(); + event.returnValue = ""; +} + +function togglePageUnloadWarning(e) { + if (e.target.textContent === disabledText) { + window.addEventListener("beforeunload", handlePageUnload); + e.target.textContent = enabledText; + } else { + window.removeEventListener("beforeunload", handlePageUnload); + e.target.textContent = disabledText; + } +} + const Template = args => ( <>
    -

    - Scroll section +

    + Scroll section to test header scroll behavior{" "} + {/* This button adds a page unload warning so clicking links will not navigate away. It also serves as a focus test for accessibility. */}

    +
    +

    Add Page Unload Warning

    +

    + This button toggles a page unload warning that prompts the user to + confirm before leaving the page. This is useful for testing the + header's behavior when there is a potential for unintentionally + navigating away from the page. +

    +

    + This button also serves as a focusable element for testing + accessibility. +

    +

    +

    ); From 9d3466e1a3a2201b0f0656c3c7cdd438542ebe7c Mon Sep 17 00:00:00 2001 From: Scott Williams <5209283+scott-williams-az@users.noreply.github.com> Date: Wed, 18 Feb 2026 12:05:07 -0700 Subject: [PATCH 02/10] linting --- .../src/components/Button/Button.stories.jsx | 67 ++++++++++++------- .../CardArrangement.stories.jsx | 12 ++-- .../ContentSpotlight.stories.jsx | 2 +- 3 files changed, 51 insertions(+), 30 deletions(-) diff --git a/packages/unity-react-core/src/components/Button/Button.stories.jsx b/packages/unity-react-core/src/components/Button/Button.stories.jsx index 6880a26c2a..8926f09781 100644 --- a/packages/unity-react-core/src/components/Button/Button.stories.jsx +++ b/packages/unity-react-core/src/components/Button/Button.stories.jsx @@ -281,45 +281,65 @@ export const AllVariants = () => (

    UDS Button Variants

    +
    @@ -332,4 +352,3 @@ AllVariants.parameters = { }, controls: { disable: true }, }; - diff --git a/packages/unity-react-core/src/components/CardArrangement/CardArrangement.stories.jsx b/packages/unity-react-core/src/components/CardArrangement/CardArrangement.stories.jsx index 36dd2ba001..03509e869a 100644 --- a/packages/unity-react-core/src/components/CardArrangement/CardArrangement.stories.jsx +++ b/packages/unity-react-core/src/components/CardArrangement/CardArrangement.stories.jsx @@ -5,7 +5,6 @@ import { CardArrangement } from "./CardArrangement"; const img1 = imageAny(); - export default { title: "Components/Card Arrangement", component: CardArrangement, @@ -198,25 +197,28 @@ const imageCards = [ src: img1, alt: "Image card 1", captionTitle: "Image Card One", - caption: "This is the body content for the first image card with dropshadow and with cardLink prop provided. Card acts as anchor/link", + caption: + "This is the body content for the first image card with dropshadow and with cardLink prop provided. Card acts as anchor/link", border: true, dropShadow: true, cardLink: "https://example.com", - title: "example" + title: "example", }, { type: "image", src: img1, alt: "Image card 2", captionTitle: "Image Card Two", - caption: "This is the body content for the second image card with no border", + caption: + "This is the body content for the second image card with no border", }, { type: "image", src: img1, alt: "Image card 3", captionTitle: "Image Card Three", - caption: "This is the body content for the third image card with no drop shadow.", + caption: + "This is the body content for the third image card with no drop shadow.", border: true, dropShadow: false, }, diff --git a/packages/unity-react-core/src/components/ContentSpotlight/ContentSpotlight.stories.jsx b/packages/unity-react-core/src/components/ContentSpotlight/ContentSpotlight.stories.jsx index 899824a178..997002d842 100644 --- a/packages/unity-react-core/src/components/ContentSpotlight/ContentSpotlight.stories.jsx +++ b/packages/unity-react-core/src/components/ContentSpotlight/ContentSpotlight.stories.jsx @@ -1,7 +1,7 @@ // @ts-check import React from "react"; import { ContentSpotlight } from "./ContentSpotlight"; -import {imageName} from "@asu/shared"; +import { imageName } from "@asu/shared"; export default { title: "Components/ContentSpotlight", From e6ad7e80a87ad87b123510ce1e62e445c4b3af32 Mon Sep 17 00:00:00 2001 From: Scott Williams <5209283+scott-williams-az@users.noreply.github.com> Date: Wed, 18 Feb 2026 16:02:20 -0700 Subject: [PATCH 03/10] fix resolving merge --- .../NavbarContainer/DropdownItem/index.styles.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/DropdownItem/index.styles.js b/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/DropdownItem/index.styles.js index ef6035000d..61d8892ba7 100644 --- a/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/DropdownItem/index.styles.js +++ b/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/DropdownItem/index.styles.js @@ -49,6 +49,8 @@ const DropdownWrapper = styled.div` &:not(:last-child) { border-right: 1px solid ${ASU_GRAY5}; } + } + .${CLASS_NAMES.UL_HEADING} { margin-top: 0; font-size: 1.5rem; @@ -60,6 +62,18 @@ const DropdownWrapper = styled.div` margin-bottom: -1.5rem; line-height: calc(100% + 0.12em); } + ul { + --ul-gap: 1rem; + &:is(:last-child) { + flex-grow: 0; + } + margin: 0; + width: calc(1200px / var(--cols, 1) * var(--span, 1) - var(--gap) * 2); + // max-width: 282px; + display: flex; + flex-direction: row; + column-gap: var(--ul-gap); + flex-wrap: wrap; .${CLASS_NAMES.NAV_LINK} { padding: 0; flex-basis: calc( From d98eb0d3b6ed0dfcb940684b42771b9fd3205a6b Mon Sep 17 00:00:00 2001 From: Scott Williams <5209283+scott-williams-az@users.noreply.github.com> Date: Thu, 19 Feb 2026 11:47:57 -0700 Subject: [PATCH 04/10] feat(component-header-footer): adjust dropdown open/close and height --- .../NavbarContainer/DropdownItem/index.js | 3 ++- .../DropdownItem/index.styles.js | 25 ++++++++++--------- .../NavbarContainer/NavItem/index.js | 2 +- .../HeaderMain/NavbarContainer/index.js | 7 +++++- .../HeaderMain/NavbarContainer/index.test.js | 6 +++-- .../src/header/core/context/app-context.js | 4 +-- .../src/header/core/models/app-prop-types.js | 5 ++++ .../src/header/core/models/types.js | 5 ++++ .../src/header/header.js | 11 +++++++- 9 files changed, 48 insertions(+), 20 deletions(-) diff --git a/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/DropdownItem/index.js b/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/DropdownItem/index.js index a90b77bdce..fe9ce08aa9 100644 --- a/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/DropdownItem/index.js +++ b/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/DropdownItem/index.js @@ -97,7 +97,7 @@ const DropdownItem = ({ setItemOpened, parentLink, }) => { - const { breakpoint } = useAppContext(); + const { breakpoint, headerHeight } = useAppContext(); let cols = 0; items.map(lists => { cols += lists[0].span || 1; @@ -183,6 +183,7 @@ const DropdownItem = ({ isMega ? " mega" : "" }`} breakpoint={breakpoint} + headerHeight={headerHeight} >
    .${CLASS_NAMES.DROPDOWN_CONTAINER} { max-width: 1200px; margin: 0 auto; - max-height: 50vh; + max-height: calc(80vh - ${({ headerHeight }) => headerHeight}px - 2rem); overflow-y: auto; overflow-x: hidden; display: flex; @@ -40,6 +40,7 @@ const DropdownWrapper = styled.div` --span: 1; /* component overrides using inline style */ --col: 1; /* component overrides using inline style */ width: calc(1200px / var(--cols) * var(--span)); + height: fit-content; padding: var(--gap); display: flex; flex-direction: column; @@ -51,17 +52,17 @@ const DropdownWrapper = styled.div` } } - .${CLASS_NAMES.UL_HEADING} { - margin-top: 0; - font-size: 1.5rem; - letter-spacing: -0.035em; - font-weight: 700; - text-align: left; - opacity: 1; - margin: 0; - margin-bottom: -1.5rem; - line-height: calc(100% + 0.12em); - } + .${CLASS_NAMES.UL_HEADING} { + margin-top: 0; + font-size: 1.5rem; + letter-spacing: -0.035em; + font-weight: 700; + text-align: left; + opacity: 1; + margin: 0; + margin-bottom: -1.5rem; + line-height: calc(100% + 0.12em); + } ul { --ul-gap: 1rem; &:is(:last-child) { diff --git a/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/NavItem/index.js b/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/NavItem/index.js index 87271854d8..d825084f04 100644 --- a/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/NavItem/index.js +++ b/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/NavItem/index.js @@ -94,7 +94,7 @@ const NavItem = ({ link, setItemOpened, itemOpened }) => { document.removeEventListener("click", handleClickOutside, true); document.removeEventListener("focusin", handleFocusChange); }; - }, [opened]); + }, [opened, setItemOpened]); const renderNavLinks = useMemo(() => { if (link.type === "icon-home") { diff --git a/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/index.js b/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/index.js index 05735ee9b1..98a118dec7 100644 --- a/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/index.js +++ b/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/index.js @@ -19,7 +19,12 @@ const NavbarContainer = () => { const [itemOpened, setItemOpened] = useState(undefined); const handleSetItemOpened = itemId => { - setItemOpened(prev => (itemOpened === itemId ? undefined : itemId)); + if (itemOpened === itemId) { + setItemOpened(() => undefined); + } else { + // Adding a slight delay so opening the dropdown is triggered after any close events have finished processing + setTimeout(() => setItemOpened(() => itemId), 1); + } }; const validateButton = button => { diff --git a/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/index.test.js b/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/index.test.js index afb0258ba8..552610da17 100644 --- a/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/index.test.js +++ b/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/index.test.js @@ -1,5 +1,5 @@ // @ts-check -import { render, fireEvent, cleanup } from "@testing-library/react"; +import { render, fireEvent, cleanup, waitFor } from "@testing-library/react"; import React from "react"; import { NavbarContainer } from "."; @@ -66,7 +66,9 @@ describe("#Navbar Container Component opened/closed on hover", () => { it("should open dropdown", () => { fireEvent.mouseEnter(navItems[0].parentElement); - expect(navItems[0].className).toContain("open-link"); + waitFor(() => { + expect(navItems[0].className).toContain("open-link"); + }); }); it("should close dropdown", () => { fireEvent.mouseEnter(navItems[0].parentElement); diff --git a/packages/component-header-footer/src/header/core/context/app-context.js b/packages/component-header-footer/src/header/core/context/app-context.js index fb6ea5dd06..c965252e3d 100644 --- a/packages/component-header-footer/src/header/core/context/app-context.js +++ b/packages/component-header-footer/src/header/core/context/app-context.js @@ -1,7 +1,7 @@ import PropTypes from "prop-types"; import React, { createContext, useContext } from "react"; -import { HeaderPropTypes } from "../models/app-prop-types"; +import { HeaderContextPropTypes } from "../models/app-prop-types"; const breakpoints = { Lg: "992px", Xl: "1260px" }; @@ -17,7 +17,7 @@ const AppContextProvider = ({ initialValue, children }) => { }; AppContextProvider.propTypes = { - initialValue: PropTypes.shape(HeaderPropTypes).isRequired, + initialValue: PropTypes.shape(HeaderContextPropTypes).isRequired, children: PropTypes.node.isRequired, }; diff --git a/packages/component-header-footer/src/header/core/models/app-prop-types.js b/packages/component-header-footer/src/header/core/models/app-prop-types.js index c1bace2e15..7afa4ef661 100644 --- a/packages/component-header-footer/src/header/core/models/app-prop-types.js +++ b/packages/component-header-footer/src/header/core/models/app-prop-types.js @@ -68,9 +68,14 @@ const HeaderPropTypes = { site: PropTypes.string, renderDiv: PropTypes.oneOf(["true", "false"]), }; +const HeaderContextPropTypes = { + ...HeaderPropTypes, + headerHeight: [PropTypes.number], +}; export { HeaderPropTypes, + HeaderContextPropTypes, LoginPropTypes, LogoPropTypes, TitlePropTypes, diff --git a/packages/component-header-footer/src/header/core/models/types.js b/packages/component-header-footer/src/header/core/models/types.js index 0b2bd38c04..c2ef37e22d 100644 --- a/packages/component-header-footer/src/header/core/models/types.js +++ b/packages/component-header-footer/src/header/core/models/types.js @@ -61,4 +61,9 @@ * @property {string} renderDiv - Can be either "true" or "false". */ +/** + * @typedef {HeaderProps} HeaderContext + * @property {number} headerHeight + */ + export const JSDOC = "jsdoc"; diff --git a/packages/component-header-footer/src/header/header.js b/packages/component-header-footer/src/header/header.js index 51630ba37e..c0dc0bd69b 100644 --- a/packages/component-header-footer/src/header/header.js +++ b/packages/component-header-footer/src/header/header.js @@ -1,7 +1,7 @@ // @ts-check import { trackReactComponent } from "@asu/shared"; import { throttle } from "@asu/shared/utils/timers"; -import React, { useEffect, useRef } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { HeaderMain } from "./components/HeaderMain"; import { AppContextProvider } from "./core/context/app-context"; @@ -51,6 +51,7 @@ const ASUHeader = ({ * @type {React.MutableRefObject} */ const headerRef = useRef(null); + const [headerHeight, setHeaderHeight] = useState(150); const handleWindowScroll = () => { const curPos = window.scrollY; @@ -60,6 +61,7 @@ const ASUHeader = ({ } else { headerRef.current.classList.remove("scrolled"); } + setHeaderHeight(headerRef.current.getBoundingClientRect().bottom); }; useEffect(() => { @@ -81,6 +83,12 @@ const ASUHeader = ({ } }, []); + useEffect(() => { + if (headerRef?.current) { + setHeaderHeight(headerRef.current.getBoundingClientRect().bottom); + } + }, [headerRef]); + useEffect(() => { const throttledScroll = () => throttle(handleWindowScroll, 100); window.addEventListener("scroll", throttledScroll); @@ -125,6 +133,7 @@ const ASUHeader = ({ mobileNavTree, hasNavigation: !!navTree?.length || !!mobileNavTree?.length, searchUrl, + headerHeight, site, }} > From 1b096e5aab37da7a4d8d672077e3612a9611aa95 Mon Sep 17 00:00:00 2001 From: Scott Williams <5209283+scott-williams-az@users.noreply.github.com> Date: Thu, 19 Feb 2026 12:58:21 -0700 Subject: [PATCH 05/10] feat(component-header-footer): mobile styles --- .../NavbarContainer/DropdownItem/index.js | 13 +++++++ .../DropdownItem/index.styles.js | 3 +- .../NavbarContainer/NavItem/index.js | 39 +++++++++++-------- 3 files changed, 37 insertions(+), 18 deletions(-) diff --git a/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/DropdownItem/index.js b/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/DropdownItem/index.js index fe9ce08aa9..862710a11d 100644 --- a/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/DropdownItem/index.js +++ b/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/DropdownItem/index.js @@ -79,6 +79,7 @@ LinkItem.propTypes = { * buttons: Button[] * classes?: string, * listId: string + * opened: boolean * setItemOpened: Function * parentLink: React.RefObject | null * }} DropdownItemProps @@ -94,6 +95,7 @@ const DropdownItem = ({ buttons, classes, listId, + opened, setItemOpened, parentLink, }) => { @@ -118,6 +120,16 @@ const DropdownItem = ({ setAlignedRight(elPosition > breakpointPosition); } }, []); + useEffect(() => { + if (opened && dropdownRef?.current?.parentElement) { + dropdownRef.current.parentElement.scrollIntoView( + /** @type {ScrollIntoViewOptions} */ { + behavior: "smooth", + block: "start", + } + ); + } + }, [dropdownRef, opened]); const stopPropagation = e => e.stopPropagation(); @@ -295,6 +307,7 @@ DropdownItem.propTypes = { buttons: PropTypes.arrayOf(PropTypes.shape(ButtonPropTypes)), classes: PropTypes.string, listId: PropTypes.string, + opened: PropTypes.bool, setItemOpened: PropTypes.func, parentLink: PropTypes.shape({ current: PropTypes.instanceOf(HTMLElement), diff --git a/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/DropdownItem/index.styles.js b/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/DropdownItem/index.styles.js index def2595c16..f334082d9b 100644 --- a/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/DropdownItem/index.styles.js +++ b/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/DropdownItem/index.styles.js @@ -48,7 +48,7 @@ const DropdownWrapper = styled.div` row-gap: var(--gap); &:not(:last-child) { - border-right: 1px solid ${ASU_GRAY5}; + } } @@ -141,6 +141,7 @@ const DropdownWrapper = styled.div` .${CLASS_NAMES.DROPDOWN_CONTAINER_COLUMN} { width: 100%; + border-right: unset; } ul { width: 100%; diff --git a/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/NavItem/index.js b/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/NavItem/index.js index d825084f04..e8dc245053 100644 --- a/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/NavItem/index.js +++ b/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/NavItem/index.js @@ -71,25 +71,29 @@ const NavItem = ({ link, setItemOpened, itemOpened }) => { const { breakpoint, expandOnHover, title } = useAppContext(); const isMobile = useIsMobile(breakpoint); - useEffect(() => { - const handleClickOutside = event => { - if (opened && !clickRef?.current?.contains(event.target)) { - setItemOpened(); - } - }; - - const handleFocusChange = () => { - requestAnimationFrame(() => { - const node = clickRef.current; - if (opened && node && !node.contains(document.activeElement)) { - setItemOpened(); - } - }); - }; + const handleClickOutside = event => { + if (opened && !clickRef?.current?.contains(event.target)) { + setItemOpened(); + } + }; - document.addEventListener("click", handleClickOutside, true); - document.addEventListener("focusin", handleFocusChange); + const handleFocusChange = () => { + requestAnimationFrame(() => { + const node = clickRef.current; + if (opened && node && !node.contains(document.activeElement)) { + setItemOpened(); + } + }); + }; + useEffect(() => { + if (opened) { + document.addEventListener("click", handleClickOutside, true); + document.addEventListener("focusin", handleFocusChange); + } else { + document.removeEventListener("click", handleClickOutside, true); + document.removeEventListener("focusin", handleFocusChange); + } return () => { document.removeEventListener("click", handleClickOutside, true); document.removeEventListener("focusin", handleFocusChange); @@ -243,6 +247,7 @@ const NavItem = ({ link, setItemOpened, itemOpened }) => { opened && CLASS_NAMES.OPENED )} listId={`dropdown-${link.id}`} + opened={opened} setItemOpened={setItemOpened} parentLink={parentLink} /> From c5e43a921be22cbc36e62732d934cafa5bd0f90b Mon Sep 17 00:00:00 2001 From: Scott Williams <5209283+scott-williams-az@users.noreply.github.com> Date: Thu, 19 Feb 2026 13:05:57 -0700 Subject: [PATCH 06/10] feat(component-header-footer): add style that was removed --- .../HeaderMain/NavbarContainer/DropdownItem/index.styles.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/DropdownItem/index.styles.js b/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/DropdownItem/index.styles.js index f334082d9b..83c0192833 100644 --- a/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/DropdownItem/index.styles.js +++ b/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/DropdownItem/index.styles.js @@ -48,7 +48,7 @@ const DropdownWrapper = styled.div` row-gap: var(--gap); &:not(:last-child) { - + border-right: 1px solid ${ASU_GRAY5}; } } From d85bde9171ebcf69ae29c7c4ba7823339f210473 Mon Sep 17 00:00:00 2001 From: Scott Williams <5209283+scott-williams-az@users.noreply.github.com> Date: Mon, 2 Mar 2026 12:32:49 -0700 Subject: [PATCH 07/10] feat(component-header-footer): adjustments --- .../NavbarContainer/DropdownItem/index.js | 18 +++++-- .../DropdownItem/index.styles.js | 21 +++++--- .../NavbarContainer/NavItem/index.js | 23 +++++++- .../HeaderMain/NavbarContainer/index.js | 10 +++- .../NavbarContainer/index.styles.js | 3 +- .../src/header/components/HeaderMain/index.js | 30 ++++++++--- .../components/HeaderMain/index.styles.js | 52 +++++++++++++++++++ .../UniversalNavbar/Search/index.styles.js | 2 +- .../UniversalNavbar/index.styles.js | 9 ++++ .../src/header/core/constants/classNames.js | 1 + .../src/header/core/models/app-prop-types.js | 7 ++- .../src/header/core/models/types.js | 5 ++ .../src/header/header.js | 35 +++++++++++++ 13 files changed, 192 insertions(+), 24 deletions(-) diff --git a/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/DropdownItem/index.js b/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/DropdownItem/index.js index 862710a11d..62d6740b4a 100644 --- a/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/DropdownItem/index.js +++ b/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/DropdownItem/index.js @@ -80,7 +80,6 @@ LinkItem.propTypes = { * classes?: string, * listId: string * opened: boolean - * setItemOpened: Function * parentLink: React.RefObject | null * }} DropdownItemProps */ @@ -96,10 +95,15 @@ const DropdownItem = ({ classes, listId, opened, - setItemOpened, parentLink, }) => { - const { breakpoint, headerHeight } = useAppContext(); + const { + breakpoint, + headerHeight, + setItemOpened, + setMobileMenuOpen, + mobileMenuOpen, + } = useAppContext(); let cols = 0; items.map(lists => { cols += lists[0].span || 1; @@ -154,12 +158,18 @@ const DropdownItem = ({ } else if (key === "ArrowUp") { e.preventDefault(); focusPrevLink(); - } else if (key === "Escape") { + } else if (key === "Escape" && opened) { setItemOpened(); if (typeof parentLink?.current?.focus === "function") { parentLink.current.focus(); } + } else if (key === "Escape" && !opened && mobileMenuOpen) { + setMobileMenuOpen(false); } else if (key === "Enter" || key === " " || type === "click") { + // Single page apps do not leave the page on link click, + // so we need to manually close the menu and trigger the onClick event + setMobileMenuOpen(false); + setItemOpened(); link?.onClick?.(e); trackGAEvent({ ...LINK_DEFAULT_PROPS, text: link.text }); } diff --git a/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/DropdownItem/index.styles.js b/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/DropdownItem/index.styles.js index 83c0192833..e8aa23cf0c 100644 --- a/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/DropdownItem/index.styles.js +++ b/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/DropdownItem/index.styles.js @@ -30,7 +30,16 @@ const DropdownWrapper = styled.div` > .${CLASS_NAMES.DROPDOWN_CONTAINER} { max-width: 1200px; margin: 0 auto; - max-height: calc(80vh - ${({ headerHeight }) => headerHeight}px - 2rem); + /* + max-height is calculated from + 100% view Height + minus header + minus 2rem padding + minus 4rem cta buttons - future we could add a ref to measure if needed + */ + max-height: calc( + 100vh - ${({ headerHeight }) => headerHeight}px - 2rem - 4rem + ); overflow-y: auto; overflow-x: hidden; display: flex; @@ -41,6 +50,7 @@ const DropdownWrapper = styled.div` --col: 1; /* component overrides using inline style */ width: calc(1200px / var(--cols) * var(--span)); height: fit-content; + height: -webkit-fill-available; padding: var(--gap); display: flex; flex-direction: column; @@ -136,28 +146,27 @@ const DropdownWrapper = styled.div` max-height: unset; overflow-y: unset; overflow-x: unset; - padding: 1rem 2rem; flex-direction: column; .${CLASS_NAMES.DROPDOWN_CONTAINER_COLUMN} { width: 100%; border-right: unset; + &:not(:first-child) { + padding-top: 0; + } } ul { width: 100%; max-width: 100%; padding: 0; - margin-bottom: 1rem; &:not(:last-child) { - padding-right: 1rem; - margin: 0 0 1rem 0; border: none; } .${CLASS_NAMES.UL_HEADING} { font-size: 1.25rem; } .${CLASS_NAMES.NAV_BUTTON} { - padding-top: 1.5rem; + // padding-top: 1.5rem; } .${CLASS_NAMES.NAV_LINK} { flex-basis: 100%; diff --git a/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/NavItem/index.js b/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/NavItem/index.js index e8dc245053..e09204fb48 100644 --- a/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/NavItem/index.js +++ b/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/NavItem/index.js @@ -68,7 +68,13 @@ const NavItem = ({ link, setItemOpened, itemOpened }) => { const clickRef = useRef(null); const parentLink = useRef(null); const opened = link.id === itemOpened; - const { breakpoint, expandOnHover, title } = useAppContext(); + const { + breakpoint, + expandOnHover, + title, + mobileMenuOpen, + setMobileMenuOpen, + } = useAppContext(); const isMobile = useIsMobile(breakpoint); const handleClickOutside = event => { @@ -144,8 +150,16 @@ const NavItem = ({ link, setItemOpened, itemOpened }) => { }; const handleKeyDown = e => { - if (!link.items && link.href) { + if ( + !link.items && + (link.href || link.onClick) && + (e.key === "Enter" || e.key === " " || e.type === "click") + ) { trackGAEvent({ ...LINK_DEFAULT_PROPS, text: link.text }); + // Single page apps do not leave the page on link click, + // so we need to manually close the menu and trigger the onClick event + setMobileMenuOpen(false); + setItemOpened(); return; } const { key } = e; @@ -168,6 +182,11 @@ const NavItem = ({ link, setItemOpened, itemOpened }) => { setItemOpened(); return; } + + if (key === "Escape" && !opened && mobileMenuOpen) { + setMobileMenuOpen(false); + return; + } // Handle Enter or Space key if (key === "Enter" || key === " ") { if (link.items) { diff --git a/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/index.js b/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/index.js index 98a118dec7..99b5b8391a 100644 --- a/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/index.js +++ b/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/index.js @@ -14,9 +14,15 @@ export const BUTTON_ERROR_MESSAGE = "Header buttons cannot have both an onClick and an href property as this breaks accessibility. Please remove one"; const NavbarContainer = () => { - const { navTree, mobileNavTree, buttons, breakpoint } = useAppContext(); + const { + navTree, + mobileNavTree, + buttons, + breakpoint, + itemOpened, + setItemOpened, + } = useAppContext(); const isMobile = useIsMobile(breakpoint); - const [itemOpened, setItemOpened] = useState(undefined); const handleSetItemOpened = itemId => { if (itemOpened === itemId) { diff --git a/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/index.styles.js b/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/index.styles.js index 73546643ee..5013bcdbbb 100644 --- a/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/index.styles.js +++ b/packages/component-header-footer/src/header/components/HeaderMain/NavbarContainer/index.styles.js @@ -24,8 +24,7 @@ const Wrapper = styled.nav` flex-direction: column; justify-content: flex-start; overflow-y: auto; - min-height: calc(100vh - 277px); - max-height: calc(100vh - 277px); + min-height: 0; > *:last-child { margin-bottom: min(75px, 15vw); } diff --git a/packages/component-header-footer/src/header/components/HeaderMain/index.js b/packages/component-header-footer/src/header/components/HeaderMain/index.js index 68d3b6117f..23cfbbecae 100644 --- a/packages/component-header-footer/src/header/components/HeaderMain/index.js +++ b/packages/component-header-footer/src/header/components/HeaderMain/index.js @@ -17,8 +17,16 @@ import { Search } from "../UniversalNavbar/Search"; import { Title } from "./Title"; const HeaderMain = () => { - const { breakpoint, isPartner, hasNavigation } = useAppContext(); - const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const { + breakpoint, + isPartner, + hasNavigation, + itemOpened, + setItemOpened, + mobileMenuOpen, + setMobileMenuOpen, + mobileMenuToggleRef, + } = useAppContext(); const isMobile = useIsMobile(breakpoint); const handleChangeMenuVisibility = () => { @@ -26,13 +34,19 @@ const HeaderMain = () => { }; useEffect(() => { - document.body.style.overflow = mobileMenuOpen ? "hidden" : "unset"; + document.body.style.overflow = + mobileMenuOpen || itemOpened !== undefined ? "hidden" : "unset"; // Clean up function to re-enable scrolling return () => { document.body.style.overflow = "unset"; }; - }, [mobileMenuOpen]); + }, [mobileMenuOpen, itemOpened, isMobile]); + + useEffect(() => { + setMobileMenuOpen(false); + setItemOpened(); + }, [isMobile]); const handleClickMobileMenu = () => { handleChangeMenuVisibility(); @@ -55,11 +69,13 @@ const HeaderMain = () => { className={buildClassName( CLASS_NAMES.NAVBAR, CLASS_NAMES.NAVBAR_EXPAND_XL, - isPartner && CLASS_NAMES.PARTNER + isPartner && CLASS_NAMES.PARTNER, + mobileMenuOpen && CLASS_NAMES.MOBILE_MENU_OPEN )} > {!isPartner && }