diff --git a/entrypoint b/entrypoint index b0b9f3a56c..8d02c59e02 100755 --- a/entrypoint +++ b/entrypoint @@ -6,6 +6,7 @@ RUN_COMMAND="gunicorn webapp.app:create_app() --bind $1 --worker-class gevent -- if [ "${FLASK_DEBUG}" = true ] || [ "${FLASK_DEBUG}" = 1 ]; then RUN_COMMAND="${RUN_COMMAND} --reload --log-level debug --timeout 9999" + RUN_COMMAND="${RUN_COMMAND} --logger-class canonicalwebteam.flask_base.log_utils.GunicornDevLogger" fi ${RUN_COMMAND} 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..ae36c16bc2 --- /dev/null +++ b/static/js/base/navigation/dropdownUtils.ts @@ -0,0 +1,112 @@ +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); + const globalNavButton = dropdownToggleEl.querySelector( + ":scope > .p-navigation__link", + ); + // fix some states from global-nav elements in mobile + if (globalNavButton) { + globalNavButton.setAttribute("aria-expanded", isActive.toString()); + globalNavButton.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, +) => { + targetDropdown.setAttribute("aria-hidden", "true"); + setActiveDropdown(dropdownToggleButton, false); +}; + +export const expandDropdown = ( + dropdownToggleButton: HTMLElement, + targetDropdown: HTMLElement, +) => { + setActiveDropdown(dropdownToggleButton); + targetDropdown.setAttribute("aria-hidden", "false"); + setFocusable(targetDropdown); +}; + +const toggleAnimationPlaying = ( + element: Element, + animationDuration?: number, +) => { + const endAnimation = () => { + element.classList.toggle("js-animation-playing", false); + }; + element.classList.toggle("js-animation-playing", true); + // force browser to flush all pending style and layout calculations immediately + void (element as HTMLElement).offsetWidth; + setTimeout(endAnimation, animationDuration); +}; + +export const setupAnimationStart = ( + elements: Element[], + animationDuration?: number, +) => { + elements.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..809910ed7c --- /dev/null +++ b/static/js/base/navigation/globalNav.ts @@ -0,0 +1,35 @@ +export function patchAllCanonicalMobileMarkup() { + const allCanonicalMobile = document.getElementById("all-canonical-mobile"); + const topMobileSections = allCanonicalMobile?.querySelectorAll( + ".global-nav__dropdown-toggle", + ); + + topMobileSections?.forEach((section: Element) => { + const sectionLink = section.querySelector("button.p-navigation__link"); + const sectionHref = sectionLink?.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)); + } + }); +} + +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(`
  • + + Back + +
  • `); +} diff --git a/static/js/base/navigation/index.ts b/static/js/base/navigation/index.ts new file mode 100644 index 0000000000..f3eca24209 --- /dev/null +++ b/static/js/base/navigation/index.ts @@ -0,0 +1,12 @@ +import "./login"; + +import { createNav as createAllCanonicalNav } from "@canonical/global-nav"; +import { initNavigationListeners } from "./listeners"; +import { patchAllCanonicalMobileMarkup } from "./globalNav"; + +// initialize global-nav ("All Canonical" link) and the rest of the navigation +window.addEventListener("DOMContentLoaded", function () { + createAllCanonicalNav(); + patchAllCanonicalMobileMarkup(); + initNavigationListeners(); +}); diff --git a/static/js/base/navigation/listeners.ts b/static/js/base/navigation/listeners.ts new file mode 100644 index 0000000000..d1e7dccc44 --- /dev/null +++ b/static/js/base/navigation/listeners.ts @@ -0,0 +1,220 @@ +import { + expandDropdown, + collapseDropdown, + setFocusable, + setActiveDropdown, + 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", + ); + const allCanonicalLink = document.getElementById("all-canonical-link")!; + + // global-nav related elements to fix certain problems for mobile view + const desktopGlobalNav = document.getElementById("all-canonical-desktop")!; + const overlayGlobalNav = document.getElementById("all-canonical-overlay")!; + const togglesMobileGlobalNav = [ + ...document.querySelectorAll( + "#all-canonical-mobile button.global-nav__header-link-anchor", + ), + ].map((element) => element as HTMLElement); + + const collapseAllDropdowns = (excludedToggle?: HTMLElement) => { + toggles.forEach((toggle) => { + const ariaControls = toggle.getAttribute("aria-controls"); + if (ariaControls) { + const target = document.getElementById(ariaControls); + if (target && !(target === excludedToggle)) { + collapseDropdown(toggle, target); + } + } + }); + // handle the dropdowns from global-nav mobile too + togglesMobileGlobalNav.forEach((toggle) => { + const parent = toggle.parentElement; + const dropdownContents = parent?.querySelector( + ":scope > .p-navigation__dropdown", + ); + if (dropdownContents) { + collapseDropdown(toggle, dropdownContents as HTMLElement); + } + }); + }; + + const closeMenu = () => { + navigation.classList.add("menu-closing"); + + const closeMenuHandler = () => { + navigation.classList.remove("has-menu-open"); + navigation.classList.remove("menu-closing"); + }; + + // the time is aproximately the time of the sliding animation + setTimeout(closeMenuHandler, ANIMATION_DURATION); + }; + + const resetNavigation = () => { + collapseAllDropdowns(); + closeMenu(); + }; + + 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"; + // remove classes set for desktop global nav + desktopGlobalNav.classList.remove("show-content"); + overlayGlobalNav.classList.remove("show-overlay"); + // 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"; + } + }; + + 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 || target === allCanonicalLink) { + // set js-animation-playing on all dropdowns + setupAnimationStart(toggles, ANIMATION_DURATION); + resetNavigation(); + } + } + }; + + const handleToggle = (e: Event, toggle: HTMLElement) => { + e.preventDefault(); + + setupAnimationStart(toggles, ANIMATION_DURATION); + 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); + } + + if (target.getAttribute("aria-hidden") === "true") { + unfocusAllLinks(); + expandDropdown(toggle, target); + navigation.classList.add("has-menu-open"); + } else { + collapseDropdown(toggle, target); + if (!isNested) { + closeMenu(); + } + } + } + } + + 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 or in "All Canonical", close all dropdowns + document.addEventListener("click", handleClickOutsideNavigation); + allCanonicalLink.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..1ea6423599 100644 --- a/static/sass/_snapcraft_p-navigation.scss +++ b/static/sass/_snapcraft_p-navigation.scss @@ -19,6 +19,19 @@ // starts on the right outside of the screen --right: 110%; + + .p-navigation__items-wrapper { + position: relative; + } + } + + .l-docs__main .p-navigation__nav { + margin-top: 0; + } + + .p-navigation__items { + padding-bottom: 0; + position: static; } &.has-menu-open { @@ -33,9 +46,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,14 +76,11 @@ syntax: ""; } - .p-navigation__items { + .p-navigation__items-wrapper { + display: flex; + flex-direction: row; justify-content: space-between; - - &--section { - display: flex; - margin: 0; - padding: 0; - } + width: 100%; } .p-navigation__item--dropdown-toggle { diff --git a/templates/_header.html b/templates/_header.html index 2e5bb2acb5..a1d33a0cbd 100644 --- a/templates/_header.html +++ b/templates/_header.html @@ -29,8 +29,8 @@
    {% endif %}