From 11ab25b370704e9b1c5004aaa45af1e2d403cdad Mon Sep 17 00:00:00 2001 From: Alvaro Mateo Date: Fri, 24 Oct 2025 11:39:21 +0200 Subject: [PATCH 1/8] WD-30045 - Move changes from global-nav to snapcraft.io --- package.json | 2 +- static/js/base/base.ts | 1 - static/js/base/global-nav.ts | 63 ----- static/js/base/navigation/dropdownUtils.ts | 130 +++++++++++ static/js/base/navigation/globalNav.ts | 36 +++ static/js/base/navigation/index.ts | 12 + static/js/base/navigation/listeners.ts | 218 ++++++++++++++++++ .../{navigation.ts => navigation/login.ts} | 1 + static/sass/_snapcraft_p-navigation.scss | 36 +-- templates/_header.html | 6 +- templates/_header_macros.html | 2 +- yarn.lock | 15 +- 12 files changed, 430 insertions(+), 92 deletions(-) delete mode 100644 static/js/base/global-nav.ts create mode 100644 static/js/base/navigation/dropdownUtils.ts create mode 100644 static/js/base/navigation/globalNav.ts create mode 100644 static/js/base/navigation/index.ts create mode 100644 static/js/base/navigation/listeners.ts rename static/js/base/{navigation.ts => navigation/login.ts} (99%) diff --git a/package.json b/package.json index c82796149c..7f726408d8 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "@babel/preset-react": "7.26.3", "@babel/preset-typescript": "7.26.0", "@canonical/cookie-policy": "3.6.5", - "@canonical/global-nav": "3.7.3", + "@canonical/global-nav": "3.8.0", "@canonical/react-components": "3.6.0", "@canonical/store-components": "0.54.2", "@dnd-kit/core": "6.3.1", diff --git a/static/js/base/base.ts b/static/js/base/base.ts index 7d63bb8d0e..baf26564f0 100644 --- a/static/js/base/base.ts +++ b/static/js/base/base.ts @@ -1,4 +1,3 @@ -import "./navigation"; import "./ga"; import "./contactForm"; import "./sentry"; diff --git a/static/js/base/global-nav.ts b/static/js/base/global-nav.ts deleted file mode 100644 index e0be3d23e9..0000000000 --- a/static/js/base/global-nav.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { createNav } from "@canonical/global-nav"; - -/** - * Add custom listeners to control animation of dropdowns and be able to remove the - * display of some hidden elements from global-nav that block clicks for underneath - * elements (see WD-26684). - * - * We must set these listeners before the global-nav sets its own listeners for them - * to work properly. - */ - -const DROPDOWN_ANIMATION_DURATION = 333; -const toggles = [ - ...document.querySelectorAll( - ".p-navigation__nav .p-navigation__link[aria-controls]:not(.js-back-button)", - ), -].filter((element) => element.id !== "all-canonical-link"); - -const toggleAnimationPlaying = (element: Element) => { - const endAnimation = () => { - element.classList.toggle("js-animation-playing", false); - }; - - element.classList.toggle("js-animation-playing", true); - setTimeout(endAnimation, DROPDOWN_ANIMATION_DURATION); -}; - -const setAnimationPlaying = () => { - // get all open toggles to add the animation playing to them - toggles - .filter( - (toggle: Element): toggle is Element => - toggle.parentElement != null && - toggle.parentElement.classList.contains("is-active"), - ) - .forEach((toggle: Element) => { - toggleAnimationPlaying(toggle.parentElement!); - }); -}; - -const handleToggle = (e: Event, toggle: Element) => { - e.preventDefault(); - - const toggleParent = toggle.parentElement; - if (toggleParent) { - toggleAnimationPlaying(toggleParent); - } - - e.stopPropagation(); -}; - -toggles.forEach((toggle: Element) => { - const handler = (e: Event) => handleToggle(e, toggle); - toggle.addEventListener("click", handler); -}); - -// when clicking outside navigation, set js-animation-playing on all open dropdowns -document.addEventListener("click", setAnimationPlaying); - -// Once our listeners are added then we run global-nav, which will add others -window.addEventListener("DOMContentLoaded", function () { - createNav({ isSliding: true, closeMenuAnimationDuration: 200 }); -}); diff --git a/static/js/base/navigation/dropdownUtils.ts b/static/js/base/navigation/dropdownUtils.ts new file mode 100644 index 0000000000..e2088a581f --- /dev/null +++ b/static/js/base/navigation/dropdownUtils.ts @@ -0,0 +1,130 @@ +export const setActiveDropdown = ( + dropdownToggleButton: HTMLElement, + isActive = true, +) => { + // set active state of the dropdown toggle (to slide the panel into view) + const dropdownToggleEl = dropdownToggleButton.closest( + ".p-navigation__item--dropdown-toggle", + ); + if (dropdownToggleEl) { + dropdownToggleEl.classList.toggle("is-active", isActive); + dropdownToggleEl.classList.toggle("is-selected", isActive); + } + + // set active state of the parent dropdown panel (to fade it out of view) + const parentLevelDropdown = dropdownToggleEl?.closest( + ".p-navigation__dropdown", + ); + if (parentLevelDropdown) { + parentLevelDropdown.classList.toggle("is-active", isActive); + } + + // set active state of the top navigation list under p-navigation__nav + // to set the position of the sliding panel properly + const topLevelNavigation = dropdownToggleButton.closest(".p-navigation__nav"); + if (topLevelNavigation) { + const topLevelItems = topLevelNavigation.querySelectorAll( + ":scope > .p-navigation__items", + ); + + for (const item of topLevelItems) { + // in case there are more than one top level navigation lists, we need to + // mark as active the one that contains the clicked button and hide the rest + if (item.contains(dropdownToggleButton)) { + item.classList.toggle("is-active", isActive); + } else { + item.classList.toggle("u-hide", isActive); + } + } + } +}; + +export const setListFocusable = (list: Element) => { + // turn on focusability for all direct children in the target dropdown + if (list) { + for (const item of list.children) { + item.children[0].setAttribute("tabindex", "0"); + } + } +}; + +export const setFocusable = (target: Element) => { + // if target dropdown is not a list, find the list in it + const isList = + target.classList.contains("p-navigation__dropdown") || + target.classList.contains("p-navigation__items"); + + if (!isList) { + // find all lists in the target dropdown and make them focusable + target.querySelectorAll(".p-navigation__dropdown").forEach((element) => { + setListFocusable(element); + }); + } else { + setListFocusable(target); + } +}; + +export const collapseDropdown = ( + dropdownToggleButton: HTMLElement, + targetDropdown: HTMLElement, + animationDuration?: number, + animated = false, +) => { + const closeHandler = () => { + targetDropdown.setAttribute("aria-hidden", "true"); + setActiveDropdown(dropdownToggleButton, false); + }; + + if (animated) { + setTimeout(closeHandler, animationDuration); + } else { + closeHandler(); + } +}; + +export const expandDropdown = ( + dropdownToggleButton: HTMLElement, + targetDropdown: HTMLElement, + animationDuration?: number, + animated = false, +) => { + const expandHandler = () => { + setActiveDropdown(dropdownToggleButton); + targetDropdown.setAttribute("aria-hidden", "false"); + setFocusable(targetDropdown); + }; + + if (animated) { + setTimeout(expandHandler, animationDuration); + } else { + expandHandler(); + } +}; + +export const toggleAnimationPlaying = ( + element: Element, + animationDuration?: number, +) => { + const endAnimation = () => { + element.classList.toggle("js-animation-playing", false); + }; + + element.classList.toggle("js-animation-playing", true); + setTimeout(endAnimation, animationDuration); +}; + +export const setupAnimationStart = ( + elements: Element[], + animationDuration?: number, +) => { + // get all open toggles to add the animation playing to them + elements + .filter( + (toggle: Element): toggle is Element => + toggle.parentElement != null && + toggle.parentElement.classList.contains("is-active"), + ) + .forEach((toggle: Element) => { + toggleAnimationPlaying(toggle.parentElement!, animationDuration); + }); +}; diff --git a/static/js/base/navigation/globalNav.ts b/static/js/base/navigation/globalNav.ts new file mode 100644 index 0000000000..28f95803bd --- /dev/null +++ b/static/js/base/navigation/globalNav.ts @@ -0,0 +1,36 @@ +export function patchAllCanonicalMobile() { + const allCanonicalMobile = document.getElementById("all-canonical-mobile"); + const topMobileSections = allCanonicalMobile?.querySelectorAll( + ".global-nav__dropdown-toggle", + ); + + topMobileSections?.forEach((section: Element) => { + const sectionButton = section.querySelector("button"); + const sectionHref = sectionButton?.getAttribute("href"); + + const sectionLinksList = section.querySelector("ul"); + sectionLinksList?.setAttribute("aria-hidden", "true"); + + // add the back button as the first item of the section + if (sectionHref && sectionLinksList) { + sectionLinksList.prepend(createBackButtonItem(sectionHref)); + } + }); + return allCanonicalMobile; +} + +function createFromHTML(html: string) { + const div = window.document.createElement("div"); + div.innerHTML = html; + return div.childNodes[0]; +} + +function createBackButtonItem(href: string) { + // remove the # from the href + const ariaControls = href.slice(1); + return createFromHTML(`
  • + +
  • `); +} diff --git a/static/js/base/navigation/index.ts b/static/js/base/navigation/index.ts new file mode 100644 index 0000000000..4be548e36c --- /dev/null +++ b/static/js/base/navigation/index.ts @@ -0,0 +1,12 @@ +import "./login"; + +import { createNav as createAllCanonicalLink } from "@canonical/global-nav"; +import { initNavigationListeners } from "./listeners"; +import { patchAllCanonicalMobile } from "./globalNav"; + +// initialize global-nav ("All Canonical" link) and the rest of the navigation +window.addEventListener("DOMContentLoaded", function () { + createAllCanonicalLink(); + patchAllCanonicalMobile(); + initNavigationListeners(); +}); diff --git a/static/js/base/navigation/listeners.ts b/static/js/base/navigation/listeners.ts new file mode 100644 index 0000000000..1c86e395ab --- /dev/null +++ b/static/js/base/navigation/listeners.ts @@ -0,0 +1,218 @@ +import { + expandDropdown, + collapseDropdown, + setFocusable, + setActiveDropdown, + toggleAnimationPlaying, + setupAnimationStart, +} from "./dropdownUtils"; + +const ANIMATION_DURATION = 333; + +export const initNavigationListeners = () => { + const navigation = document.querySelector( + ".p-navigation--sliding", + )! as HTMLElement; + const menuButton = document.querySelector( + ".p-navigation__banner .p-navigation__toggle--open", + )! as HTMLElement; + // all-canonical-link has it's own toggle listener coming from global-nav + const toggles = [ + ...document.querySelectorAll( + ".p-navigation__nav .p-navigation__link[aria-controls]:not(.js-back-button)", + ), + ] + .map((element) => element as HTMLElement) + .filter((htmlElement) => htmlElement.id !== "all-canonical-link"); + const topNavItemsLists = document.querySelectorAll( + ".p-navigation__nav > .p-navigation__items", + ); + const dropdownLinksLists = document.querySelectorAll( + ".p-navigation__dropdown", + ); + + /** + * Collapses all dropdowns with an optional exception. + * + * @param excludedToggle a toggle Element that is to be excluded from the reset. + */ + const collapseAllDropdowns = (excludedToggle?: HTMLElement) => { + toggles.forEach((toggle) => { + const ariaControls = toggle.getAttribute("aria-controls"); + if (ariaControls) { + const target = document.getElementById(ariaControls); + if (!target || target === excludedToggle) { + return; + } + collapseDropdown(toggle, target); + } + }); + }; + + const resetNavigation = () => { + navigation.classList.add("menu-closing"); + + const closeMenuHandler = () => { + navigation.classList.remove("has-menu-open"); + navigation.classList.remove("menu-closing"); + collapseAllDropdowns(); + }; + + // the time is aproximately the time of the sliding animation + setTimeout(closeMenuHandler, ANIMATION_DURATION); + }; + + const unfocusAllLinks = () => { + // turn off focusability for all dropdown lists in the navigation + dropdownLinksLists.forEach((list) => { + const elements = list.querySelectorAll("ul > li > a, ul > li > button"); + elements.forEach((element) => { + element.setAttribute("tabindex", "-1"); + }); + }); + }; + + const goBackOneLevel = (e: Event, backButton: HTMLElement) => { + e.preventDefault(); + const target = backButton.closest(".p-navigation__dropdown"); + if (target && target.parentNode) { + unfocusAllLinks(); + if (target.parentNode.parentNode) { + setFocusable(target.parentNode.parentNode as Element); + } + + const links = target.parentNode.querySelector(".p-navigation__link"); + if (links && links instanceof HTMLElement) { + links.focus(); + } + + target.setAttribute("aria-hidden", "true"); + setActiveDropdown(backButton, false); + } + }; + + const handleMenuButtonClick = (e: Event) => { + e.preventDefault(); + + if (navigation.classList.contains("has-menu-open")) { + resetNavigation(); + menuButton.innerHTML = "Menu"; + // reshow scroll bar + document.body.style.overflow = "visible"; + } else { + navigation.classList.add("has-menu-open"); + unfocusAllLinks(); + menuButton.innerHTML = "Close menu"; + for (const topNavItemsList of topNavItemsLists) { + setFocusable(topNavItemsList); + } + // hide scroll bar + document.body.style.overflow = "hidden"; + console.log(navigation.scrollHeight, window.innerHeight); + [...navigation.querySelectorAll("*")].forEach((el) => { + const rect = el.getBoundingClientRect(); + if (rect.bottom > window.innerHeight) { + console.log( + "Overflowing element:", + el, + rect.bottom - window.innerHeight, + "px beyond viewport", + ); + } + }); + } + }; + + const handleClickOutsideNavigation = (e: Event) => { + const target = e.target; + if (target && target instanceof HTMLElement) { + // check if the click was outside the navigation + const topNavigationElement = target.closest( + ".p-navigation, .p-navigation--sliding, .p-navigation--reduced", + ); + if (!topNavigationElement) { + resetNavigation(); + // set js-animation-playing on all open dropdowns + setupAnimationStart(toggles, ANIMATION_DURATION); + } + } + }; + + const handleToggle = (e: Event, toggle: HTMLElement) => { + e.preventDefault(); + + const ariaControls = toggle.getAttribute("aria-controls"); + if (ariaControls) { + const target = document.getElementById(ariaControls); + if (target && target.parentNode) { + // check if the toggled dropdown is child of another dropdown + const isNested = !!(target.parentNode as HTMLElement).closest( + ".p-navigation__dropdown", + ); + + if (!isNested) { + collapseAllDropdowns(target); + } + + const toggleParent = toggle.parentElement; + if (toggleParent) { + requestAnimationFrame(() => { + toggleAnimationPlaying(toggleParent); + }); + } + + if (target.getAttribute("aria-hidden") === "true") { + unfocusAllLinks(); + expandDropdown(toggle, target, ANIMATION_DURATION, true); + navigation.classList.add("has-menu-open"); + } else { + collapseDropdown(toggle, target, ANIMATION_DURATION, true); + if (!isNested) { + navigation.classList.remove("has-menu-open"); + } + } + } + } + + e.stopPropagation(); + }; + + const handledropdownLinksLists = (e: Event, dropdown: HTMLElement) => { + if ( + e instanceof KeyboardEvent && + e.shiftKey && + e.key === "Tab" && + window.getComputedStyle(dropdown.children[0], null).display === "none" + ) { + const backButton = dropdown.children[1].children[0] as HTMLElement; + goBackOneLevel(e, backButton); + const focusElement = dropdown.parentNode?.children[0] as HTMLElement; + focusElement?.focus({ preventScroll: true }); + } + }; + + const handleGoBackOneLevel = (e: Event, backButton: HTMLElement) => { + goBackOneLevel(e, backButton); + }; + + // add listeners to control the navigation + menuButton.addEventListener("click", handleMenuButtonClick); + toggles.forEach((toggle) => { + const handler = (e: Event) => handleToggle(e, toggle); + toggle.addEventListener("click", handler); + }); + dropdownLinksLists.forEach((dropdown) => { + const handler = (e: Event) => + handledropdownLinksLists(e, dropdown as HTMLElement); + dropdown.children[1].addEventListener("keydown", handler); + }); + + document.querySelectorAll(".js-back-button").forEach((backButton) => { + const handler = (e: Event) => + handleGoBackOneLevel(e, backButton as HTMLElement); + backButton.addEventListener("click", handler); + }); + + // when clicking outside navigation, close all dropdowns + document.addEventListener("click", handleClickOutsideNavigation); +}; diff --git a/static/js/base/navigation.ts b/static/js/base/navigation/login.ts similarity index 99% rename from static/js/base/navigation.ts rename to static/js/base/navigation/login.ts index 3df27a51d4..70051c97d9 100644 --- a/static/js/base/navigation.ts +++ b/static/js/base/navigation/login.ts @@ -1,4 +1,5 @@ // Login + const navAccountContainer = document.querySelector(".js-nav-account") as | HTMLElement | undefined; diff --git a/static/sass/_snapcraft_p-navigation.scss b/static/sass/_snapcraft_p-navigation.scss index bf727b082f..764bb98795 100644 --- a/static/sass/_snapcraft_p-navigation.scss +++ b/static/sass/_snapcraft_p-navigation.scss @@ -19,6 +19,15 @@ // starts on the right outside of the screen --right: 110%; + + &--wrapper { + position: relative; + } + } + + .p-navigation__items { + position: static; + padding-bottom: 0; } &.has-menu-open { @@ -33,9 +42,20 @@ --right: 110%; } - .p-navigation__items--section { - margin: 0; - padding: 0; + .p-navigation__dropdown { + // to match the styling from p-navigation__items coming from vanilla + margin-top: -1px; + + &[aria-hidden=false] { + transform: translateX(-100vw); + } + } + + // fix some stylings from vanilla-framework + #all-canonical-mobile { + .global-nav__header-link-anchor:after { + right: 1rem; + } } } } @@ -52,16 +72,6 @@ syntax: ""; } - .p-navigation__items { - justify-content: space-between; - - &--section { - display: flex; - margin: 0; - padding: 0; - } - } - .p-navigation__item--dropdown-toggle { position: relative; } diff --git a/templates/_header.html b/templates/_header.html index 2e5bb2acb5..5546a3bbf0 100644 --- a/templates/_header.html +++ b/templates/_header.html @@ -29,8 +29,8 @@
    {% endif %}