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 %}