diff --git a/src/app/components/Navigation/index.canonical.test.tsx b/src/app/components/Navigation/index.canonical.test.tsx index ef85c308783..9c1a663a589 100644 --- a/src/app/components/Navigation/index.canonical.test.tsx +++ b/src/app/components/Navigation/index.canonical.test.tsx @@ -1,5 +1,94 @@ +import CanonicalNavigationContainer from './index.canonical'; +import { + render, + screen, + fireEvent, +} from '../react-testing-library-with-providers'; + describe('Navigation - Canonical', () => { it('should render', () => { expect(true).toBeTruthy(); }); + + describe('CanonicalNavigationContainer sticky nav', () => { + const topScrollableListItems = ( + + ); + const bottomScrollableListItems = ( + + ); + const dropdownListItems = ( + + ); + const menuAnnouncedText = 'Menu'; + const dir = 'ltr'; + + it('renders sticky nav container but hides it by default', () => { + render( + , + ); + const stickyNav = screen.queryByLabelText('Sticky navigation'); + expect(stickyNav).toBeInTheDocument(); + expect(stickyNav).toHaveAttribute('aria-hidden', 'true'); + expect(stickyNav).toHaveStyle('transform: translateY(-100%)'); + }); + + it('does not render sticky nav on lite site', () => { + render( + , + { isLite: true }, + ); + const stickyNav = screen.queryByLabelText('Sticky navigation'); + expect(stickyNav).toBeNull(); + }); + + it('hides sticky nav when keyboard navigation is detected', () => { + render( + , + ); + fireEvent.keyDown(window, { key: 'Tab' }); + const stickyNav = screen.queryByLabelText('Sticky navigation'); + expect(stickyNav).toBeNull(); + }); + + it('shows sticky nav again after pointer interaction', () => { + render( + , + ); + fireEvent.keyDown(window, { key: 'Tab' }); + fireEvent.mouseDown(window); + const stickyNav = screen.queryByLabelText('Sticky navigation'); + expect(stickyNav).toBeInTheDocument(); + }); + }); }); diff --git a/src/app/components/Navigation/index.canonical.tsx b/src/app/components/Navigation/index.canonical.tsx index 027774497fc..2bafc21a241 100644 --- a/src/app/components/Navigation/index.canonical.tsx +++ b/src/app/components/Navigation/index.canonical.tsx @@ -1,4 +1,5 @@ -import React, { useState, use } from 'react'; +import React, { useState, useRef, useEffect, use } from 'react'; +import { css } from '@emotion/react'; import Navigation from '#psammead/psammead-navigation/src'; import { ScrollableNavigation } from '#psammead/psammead-navigation/src/ScrollableNavigation'; import { @@ -35,16 +36,85 @@ const CanonicalNavigationContainer: React.FC< }) => { const { isLite } = use(RequestContext); const { enabled: topBarOJsEnabled } = useToggle('topBarOJs'); - const [isOpen, setIsOpen] = useState(false); + // Track which nav's dropdown is open: 'main', 'sticky', or null + const [openDropdown, setOpenDropdown] = useState( + null, + ); + const [showSticky, setShowSticky] = useState(false); + // Refs and state for sticky nav + const [lastScrollY, setLastScrollY] = useState(0); + const [isKeyboardNav, setIsKeyboardNav] = useState(false); // this is to prevent showing sticky nav when keyboard navigation is detected + const navRef = useRef(null); + const stickyNavRef = useRef(null); useMediaQuery(`(max-width: ${GROUP_2_MAX_WIDTH_BP}rem)`, event => { if (!event.matches) { - setIsOpen(false); + setOpenDropdown(null); } }); - return ( - + // Sticky nav scroll logic + useEffect(() => { + let ticking = false; + const handleScroll = () => { + if (!navRef.current) return; + if (!ticking) { + window.requestAnimationFrame(() => { + const navElement = navRef.current; + if (!navElement) return; + const mainNavBar = navElement.getBoundingClientRect(); + const { scrollY } = window; + const scrollingUp = scrollY < lastScrollY; // detects scroll direction + setLastScrollY(scrollY); + // Only show sticky nav if nav is fully out of view and user is scrolling up + // Hide sticky nav before original nav is visible (with threshold) + const threshold = 70; // px, adjust for not seeing both original and sticky nav at the same time + if (mainNavBar.bottom < -threshold && scrollingUp && !isKeyboardNav) { + // do not show sticky nav if keyboard navigation is detected + setShowSticky(true); + } else { + setShowSticky(false); + setOpenDropdown(null); // Close dropdown when sticky nav hides + } + ticking = false; // the ticking flag is used to prevent multiple requestAnimationFrame calls from stacking up and causing performance issues during fast scrolling. + }); + ticking = true; + } + }; + window.addEventListener('scroll', handleScroll, { passive: true }); + return () => window.removeEventListener('scroll', handleScroll); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [lastScrollY, isKeyboardNav]); + + // Keyboard navigation detection + // do we need to add other keys? + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Tab') { + setIsKeyboardNav(true); + } + }; + const handlePointer = () => { + setIsKeyboardNav(false); + }; + window.addEventListener('keydown', handleKeyDown); + window.addEventListener('mousedown', handlePointer); + window.addEventListener('touchstart', handlePointer); + return () => { + window.removeEventListener('keydown', handleKeyDown); + window.removeEventListener('mousedown', handlePointer); + window.removeEventListener('touchstart', handlePointer); + }; + }, []); + + // Main nav (normal) + const mainNav = ( +
@@ -59,13 +129,18 @@ const CanonicalNavigationContainer: React.FC< setIsOpen(!isOpen)} + isOpen={openDropdown === 'main'} + onClick={() => + setOpenDropdown(openDropdown === 'main' ? null : 'main') + } dir={dir} /> )}
- + {dropdownListItems}
@@ -83,6 +158,90 @@ const CanonicalNavigationContainer: React.FC< {topBarOJsEnabled && } ); + + // Sticky nav is rendered if it is not the lite site and if keyboard navigation has not been indicated with te tab key + const stickyNav = + !isLite && !isKeyboardNav ? ( +