From f7956523caffaf302c9b6dcd3b235ea72dae7aa9 Mon Sep 17 00:00:00 2001 From: NikoHelle Date: Wed, 15 Nov 2023 15:35:07 +0200 Subject: [PATCH 01/10] (HDS-1975) Fix typos --- .../src/components/header/Header.stories.tsx | 48 +++++++++++-------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/packages/react/src/components/header/Header.stories.tsx b/packages/react/src/components/header/Header.stories.tsx index 592bc32f52..154e7a543e 100644 --- a/packages/react/src/components/header/Header.stories.tsx +++ b/packages/react/src/components/header/Header.stories.tsx @@ -79,7 +79,7 @@ const translations = { basicEducation: 'Basic education', business: 'Business', businessAndWork: 'Business and work', - childHoodAndEducation: 'Childhood and education', + childhoodAndEducation: 'Childhood and education', clearFinnish: 'Clear finnish', close: 'Close', dentalCare: 'Dental care', @@ -96,8 +96,8 @@ const translations = { headerLogin: 'Login', headerMenuTitle: 'Other languages', headerTitle: 'City of Helsinki', - healtcare: 'Healt care', - healthAndndSocialServices: 'Health and social services', + healthCare: 'Health care', + healthAndSocialServices: 'Health and social services', infoOtherLanguages: 'Information in other languages', jobSeekers: 'Jobseekers', loginOptions: 'Login options', @@ -118,7 +118,7 @@ const translations = { basicEducation: 'Perusopetus', business: 'Yritykset', businessAndWork: 'Yritykset ja työ', - childHoodAndEducation: 'Kasvatus ja koulutus', + childhoodAndEducation: 'Kasvatus ja koulutus', clearFinnish: 'Selkosuomi', close: 'Sulje', dentalCare: 'Hammashoito', @@ -135,8 +135,8 @@ const translations = { headerLogin: 'Kirjaudu', headerMenuTitle: 'Tietoa muilla kielillä', headerTitle: 'Helsingin kaupunki', - healtcare: 'Terveydenhoito', - healthAndndSocialServices: 'Sosiaali- ja terveyspalvelut', + healthCare: 'Terveydenhoito', + healthAndSocialServices: 'Sosiaali- ja terveyspalvelut', infoOtherLanguages: 'Tietoa muilla kielillä', jobSeekers: 'Työnhakijat', loginOptions: 'Kirjautumisvalinnat', @@ -157,7 +157,7 @@ const translations = { basicEducation: 'Grundläggande utbildning', business: 'Företag', businessAndWork: 'Företag och arbete', - childHoodAndEducation: 'Fostran och utbildning', + childhoodAndEducation: 'Fostran och utbildning', clearFinnish: 'Klara finska', close: 'Stäng', dentalCare: 'Tandvård', @@ -174,8 +174,8 @@ const translations = { headerLogin: 'Logga in', headerMenuTitle: 'Andra språk', headerTitle: 'Helsingfors Stad', - healtcare: 'Hälsovärd', - healthAndndSocialServices: 'Social- och hälsovårdstjänster', + healthCare: 'Hälsovärd', + healthAndSocialServices: 'Social- och hälsovårdstjänster', infoOtherLanguages: 'information på andra språk', jobSeekers: 'Arbetssökande', loginOptions: 'Logga in optioner', @@ -196,7 +196,7 @@ const translations = { basicEducation: 'École élémentaire', business: 'Enteprises', businessAndWork: 'Entreprises et travail', - childHoodAndEducation: 'Éducation', + childhoodAndEducation: 'Éducation', clearFinnish: 'Finnois simple', close: 'Fermer', dentalCare: 'Soins dentaires', @@ -213,8 +213,8 @@ const translations = { headerLogin: 'Se connecter', headerMenuTitle: 'Autres langues', headerTitle: 'Ville de Helsinki', - healtcare: 'Soins de santé', - healthAndndSocialServices: 'Services sociaux et de santé', + healthCare: 'Soins de santé', + healthAndSocialServices: 'Services sociaux et de santé', infoOtherLanguages: 'Information en autres langues', jobSeekers: "Demandeurs d'emploi", loginOptions: 'Choix pour se connecter', @@ -276,11 +276,19 @@ const FullFeaturedActionBar = ({ I18n, lang, theme }) => { ); }; -const FullFeaturedNavigationMenu = ({ I18n, href, setHref }) => { +const FullFeaturedNavigationMenu = ({ + I18n, + href, + setHref, +}: { + I18n: typeof translations['fi']; + href: string; + setHref: (anchor: string) => void; +}) => { return ( { event.preventDefault(); setHref('#sosiaali-_ja_terveyspalvelut'); @@ -292,7 +300,7 @@ const FullFeaturedNavigationMenu = ({ I18n, href, setHref }) => { event.preventDefault(); setHref('#sosiaali-_ja_terveyspalvelut#terveydenhoito'); }} - label={I18n.healtcare} + label={I18n.healthCare} active={href.includes('#terveydenhoito')} dropdownLinks={[ { event.preventDefault(); setHref('#kasvatus_ja_koulutus'); }} - label={I18n.childHoodAndEducation} + label={I18n.childhoodAndEducation} dropdownLinks={[ { { event.preventDefault(); setHref('#sosiaali-_ja_terveyspalvelut'); @@ -587,7 +595,7 @@ export const ManualLanguageSorting = (args) => { event.preventDefault(); setHref('#kasvatus_ja_koulutus'); }} - label={I18n.childHoodAndEducation} + label={I18n.childhoodAndEducation} /> @@ -623,7 +631,7 @@ export const ManualLanguageOptions = (args) => { { event.preventDefault(); setHref('#sosiaali-_ja_terveyspalvelut'); @@ -636,7 +644,7 @@ export const ManualLanguageOptions = (args) => { event.preventDefault(); setHref('#kasvatus_ja_koulutus'); }} - label={I18n.childHoodAndEducation} + label={I18n.childhoodAndEducation} /> From 191b8ba78d8a463ef1287ba7022d5a76f3b35f96 Mon Sep 17 00:00:00 2001 From: NikoHelle Date: Tue, 28 Nov 2023 13:24:18 +0200 Subject: [PATCH 02/10] (HDS-1975) Simplify mobile menu rendering Same code was repeated too many times. --- .../HeaderActionBarNavigationMenu.tsx | 185 +++++++++--------- 1 file changed, 91 insertions(+), 94 deletions(-) diff --git a/packages/react/src/components/header/components/headerActionBar/HeaderActionBarNavigationMenu.tsx b/packages/react/src/components/header/components/headerActionBar/HeaderActionBarNavigationMenu.tsx index 037a9e77d8..5dd226a42b 100644 --- a/packages/react/src/components/header/components/headerActionBar/HeaderActionBarNavigationMenu.tsx +++ b/packages/react/src/components/header/components/headerActionBar/HeaderActionBarNavigationMenu.tsx @@ -325,6 +325,52 @@ export const HeaderActionBarNavigationMenu = ({ setMobileMenuOpen(false); }; + const RenderNavigationSection = ({ + links, + activeLink, + activeLinkId, + ariaHidden, + previousLink, + showPreviousLink, + className, + }: { + links: React.ReactNode[]; + activeLink: React.ReactElement; + activeLinkId?: string; + previousLink?: React.ReactElement; + showPreviousLink?: boolean; + ariaHidden: boolean; + className: string; + }) => { + // PreviousDropdownLink defaults to titleHref if link is undefined + return ( + } + > + {showPreviousLink && ( + + )} + + + + ); + }; + return (
- {navigationContent && ( - <> - {/* Previous menu links */} - {openMainLinks.length >= 1 && ( - } - > - - - - )} - {/* Currently open links */} - } - > - {openMainLinks.length > 0 && ( - - )} - - - - {/* Next links. Rendered at the deepest level. */} - {!openingLink && ( - } - > - - - - - )} - {/* Render the menu animating into view for better UX. */} - {openingLink && typeof openingLink !== 'string' && ( - } - > - - - - - )} - + {/* Previous menu links */} + {openMainLinks.length >= 1 && ( + + )} + + {/* Currently open links */} + 0 ? previousDropdownLink : undefined} + showPreviousLink={openMainLinks.length > 0} + /> + + {/* Next links. Rendered at the deepest level. */} + {!openingLink && ( + + )} + + {/* Render the menu animating into view for better UX. */} + {openingLink && typeof openingLink !== 'string' && ( + )}
From 41b007cd80e964c27b6413f3dd09149e5aa3fa55 Mon Sep 17 00:00:00 2001 From: NikoHelle Date: Tue, 28 Nov 2023 23:52:18 +0200 Subject: [PATCH 03/10] (HDS-1975) Convert mobile menu data handling to index based Current state based system stores a lot of information in different React states. The menus do not change, just the selected ones. So storing selected menu indexes makes the data structure simpler. State based rendering has problem with data updates. Some rendered data is old until state changes. That's why there is the bug with rendering correct menus when animating. --- .../HeaderActionBarNavigationMenu.tsx | 322 +++++++++--------- 1 file changed, 168 insertions(+), 154 deletions(-) diff --git a/packages/react/src/components/header/components/headerActionBar/HeaderActionBarNavigationMenu.tsx b/packages/react/src/components/header/components/headerActionBar/HeaderActionBarNavigationMenu.tsx index 5dd226a42b..b0f65e9fd2 100644 --- a/packages/react/src/components/header/components/headerActionBar/HeaderActionBarNavigationMenu.tsx +++ b/packages/react/src/components/header/components/headerActionBar/HeaderActionBarNavigationMenu.tsx @@ -14,7 +14,6 @@ import styles from './HeaderActionBarNavigationMenu.module.scss'; import { getChildrenAsArray } from '../../../../utils/getChildren'; import { HeaderLink } from '../headerLink'; import { IconAngleLeft } from '../../../../icons'; -import useIsomorphicLayoutEffect from '../../../../hooks/useIsomorphicLayoutEffect'; import getIsElementLoaded from '../../../../utils/getIsElementLoaded'; import { LinkProps } from '../../../../internal/LinkItem'; import HeaderActionBarLogo from './HeaderActionBarLogo'; @@ -160,35 +159,15 @@ const Logo = ({ logo, logoProps }) => ( /> ); -/** - * Find only the active links in the given objects model. - * @param obj - * @returns - */ -function findActiveLinks(obj) { - const activeLinks = []; - - if (obj.props.active) { - activeLinks.push(obj); - } - - if (obj.props.dropdownLinks && obj.props.dropdownLinks.length > 0) { - obj.props.dropdownLinks.forEach((link) => { - activeLinks.push(...findActiveLinks(link)); - }); - } - - return activeLinks; -} - -function getActiveMainLevelLink(links) { - return getChildrenAsArray(links).find((link) => { - return isValidElement(link) && link.props.active; - }); -} - // These are used as clasNames as well type Position = 'left0' | 'left100' | 'left200'; +type MenuInfo = { + active: boolean; + animating: boolean; + index: number; + key: string; + root: boolean; +}; type HeaderActionBarNavigationMenuProps = { frontPageLabel: string; titleHref: string; @@ -212,63 +191,141 @@ export const HeaderActionBarNavigationMenu = ({ const { navigationContent, mobileMenuOpen, hasUniversalContent, universalContent } = useHeaderContext(); const { setMobileMenuOpen } = useSetHeaderContext(); // State for which link menu is open but not necessarily active. Needed for browsing the menu. - const [openMainLinks, setOpenMainLinks] = useState([]); - // State for the link with dropdowns that the user is opening. Needed for rendering next menu and to show its data while animating. - const [openingLink, setOpeningLink] = useState(null); // State for the wide wrapping element's position. Value is also used as a class for animation. const [position, setPosition] = useState('left0'); - const [isAnimating, setIsAnimating] = useState(false); const navContainerRef = useRef(); const currentActiveLinkId = 'current-active-link'; - const isOpeningFrontPageLinks = typeof openingLink === 'string' && openingLink === titleHref; - const isUserInDropdownTree = openMainLinks.length > 1; - const currentlyActiveMainLink = openMainLinks[openMainLinks.length - 1]; - const previousDropdownLink = isUserInDropdownTree - ? openMainLinks[openMainLinks.indexOf(currentlyActiveMainLink) - 1] - : null; - - const menuLinks = openMainLinks.length > 0 ? currentlyActiveMainLink.props.dropdownLinks : navigationContent; - const previousMenuLinks = isUserInDropdownTree - ? openMainLinks[openMainLinks.indexOf(currentlyActiveMainLink) - 1].props.dropdownLinks - : navigationContent; + const universalLinks = hasUniversalContent ? getChildrenAsArray(universalContent) : []; - const isRenderingDeepestMenu = position === 'left200'; - const isOpeningLinkFromBefore = (link: React.ReactElement | string) => { - // When typeof string, it's the front page. That's handled elsewhere. - if (typeof link === 'string') return false; - return !!openMainLinks.includes(link); + + const [selectedMenuLevels, setSelectedMenuLevels] = useState([ + // first menu is always the same - the root menu. + { root: true, index: -1, active: true, animating: false, key: 'root' }, + ]); + + const menuPositions: Record = { + 0: 'left0', + 1: 'left100', + 2: 'left200', }; - useIsomorphicLayoutEffect(() => { - // Set active main links with dropdowns if any - const mainLevelActiveLink = getActiveMainLevelLink(navigationContent); - if (mainLevelActiveLink) { - const mainLinkElement = mainLevelActiveLink as React.ReactElement; - const activeLinks = findActiveLinks(cloneElement(mainLinkElement)); - const activeMainLinks = activeLinks.filter((link) => link.props.dropdownLinks); - const correctMenuPosition = { - 0: 'left0', - 1: 'left100', - 2: 'left200', - }; - setOpenMainLinks(activeMainLinks); - // In case there are active links set, set the menu position and focus order correctly - setPosition(correctMenuPosition[activeMainLinks.length]); + const getActiveMenus = () => selectedMenuLevels.filter((level) => level.active); + + const isMenuActive = (index: number) => { + const menuItem = selectedMenuLevels[index]; + return !!(menuItem && menuItem.active); + }; + + // Menu is current when it is the last active one + const isMenuCurrent = (index: number) => { + return isMenuActive(index) && getActiveMenus().length - 1 === index; + }; + + const isAnimating = () => { + return selectedMenuLevels.some((level) => level.animating); + }; + + const addSelectedMenuLevel = (selectedIndex: number) => { + if (selectedIndex === -1) { + return; } - }, [navigationContent]); + const parent = selectedMenuLevels[selectedMenuLevels.length - 1]; + setSelectedMenuLevels([ + ...selectedMenuLevels, + { index: selectedIndex, animating: true, active: true, root: false, key: `${parent.key}_${selectedIndex}` }, + ]); + }; - /* When opening link, start animation */ - useEffect(() => { - if ((openingLink && isOpeningLinkFromBefore(openingLink)) || openingLink === titleHref) { - // Going backwards in the navigation tree - if (position === 'left100') setPosition('left0'); - else if (position === 'left200') setPosition('left100'); - } else if (openingLink && !isOpeningLinkFromBefore(openingLink)) { - // Going forward in the navigation tree - if (position === 'left0') setPosition('left100'); - else if (position === 'left100') setPosition('left200'); + const goToPreviousMenuLevel = () => { + const last = selectedMenuLevels.pop(); + + // Last item is removed after animation is done. + // It is marked as inactive here and removed in resetMenusAfterAnimation() + setSelectedMenuLevels([ + ...selectedMenuLevels.map((item) => { + return { ...item, animating: true }; + }), + { ...last, active: false, animating: true }, + ]); + }; + + const resetMenusAfterAnimation = () => { + const newArray = getActiveMenus(); + setSelectedMenuLevels( + newArray.map((i) => { + return { ...i, animating: false }; + }), + ); + return newArray.length; + }; + + const getLinksOrChildren = (parent: React.ReactElement) => { + return parent.props && parent.props.dropdownLinks + ? parent.props.dropdownLinks + : getChildrenAsArray(((parent as unknown) as React.PropsWithChildren).children); + }; + + // Picks given child by MenuInfo.index + const findParentElement = (levels: MenuInfo[]): React.ReactElement | undefined => { + return levels.reduce((parent: React.ReactElement, current: MenuInfo) => { + const { index, root } = current; + if (root) { + // Root element contains top level navigation elements - navigationContent which is an array + // Root element is never selected, only one of its children + return { children: navigationContent }; + } + if (!parent) { + return undefined; + } + const source = getLinksOrChildren(parent); + return source ? source[index] : undefined; + }, undefined); + }; + + const findLinkIndex = (link: React.ReactElement) => { + const linkParent = findParentElement(selectedMenuLevels); + return linkParent ? getLinksOrChildren(linkParent).indexOf(link) : -1; + }; + + const getLinks = (level: number) => { + const parent = findParentElement(selectedMenuLevels.slice(0, level + 1)); + return parent ? getLinksOrChildren(parent) : []; + }; + + const getActiveLink = (level: number) => { + if (level === 0) { + return undefined; + } + return findParentElement(selectedMenuLevels.slice(0, level + 1)); + }; + + const getPreviousLink = (level: number) => { + if (level === 0) { + return undefined; } - }, [openingLink]); + // PreviousDropdownLink defaults to titleHref, if link is undefined. + // If level === 1, link should be titleHref, so return undefined here + if (level === 1) { + return undefined; + } + + return findParentElement(selectedMenuLevels.slice(0, level)); + }; + + const getMenuContents = (level: number) => { + return { + links: getLinks(level), + previousLink: getPreviousLink(level), + activeLink: getActiveLink(level), + key: selectedMenuLevels[level].key, + }; + }; + + /* Start animation when active menu has changed */ + const activeMenuIndex = getActiveMenus().length - 1; + useEffect(() => { + setPosition(menuPositions[activeMenuIndex]); + }, [activeMenuIndex]); useEffect(() => { if (mobileMenuOpen && navContainerRef?.current) { @@ -280,51 +337,38 @@ export const HeaderActionBarNavigationMenu = ({ }, [mobileMenuOpen]); const goDeeper = (link: React.ReactElement) => { - setOpeningLink(link); - setIsAnimating(true); + if (isAnimating()) { + return; + } + addSelectedMenuLevel(findLinkIndex(link)); }; - const goBack = (link: React.ReactElement | string) => { - setOpeningLink(link); - setIsAnimating(true); + const goBack = () => { + if (isAnimating()) { + return; + } + goToPreviousMenuLevel(); + }; + + const handleLinkClick = () => { + setMobileMenuOpen(false); }; const menuSectionsAnimationDone = async (e: TransitionEvent) => { const targetElement = e.target as HTMLElement; - // If user was opening a dropdown, set the active open link - if (openingLink && !isOpeningFrontPageLinks) { - let newLinks = []; - const newlyOpenedLink = openingLink; - // Going backwards - if (isOpeningLinkFromBefore(openingLink)) newLinks = openMainLinks.slice(0, -1); - // Going deeper - else newLinks = [...openMainLinks, newlyOpenedLink]; - setOpenMainLinks(newLinks); - setOpeningLink(null); - } else if (isOpeningFrontPageLinks) { - // Opening front page links, reset state links - setOpenMainLinks([]); - setOpeningLink(null); - } - setIsAnimating(false); - - // If the animation was related to moving menus, set the focus to the currently active page link if (e.propertyName === 'transform' && targetElement.firstChild.nodeName === 'SECTION') { + // Set the height of the menu container + const renderedChildIndex = resetMenusAfterAnimation() - 1; + const currentMenuSection = targetElement.children[renderedChildIndex]; + navContainerRef.current.style.height = `${currentMenuSection.clientHeight}px`; + + // If the animation was related to moving menus, set the focus to the currently active page link // Set the focus to the currently active page link const linkElement = await getIsElementLoaded(`#${currentActiveLinkId}`); linkElement.focus(); - - // Set the height of the menu container - const renderedChildIndex = Math.abs(navContainerRef.current.getBoundingClientRect().left / window.innerWidth); - const currentTargetHeight = targetElement.children[renderedChildIndex].clientHeight; - navContainerRef.current.style.height = `${currentTargetHeight}px`; } }; - const handleLinkClick = () => { - setMobileMenuOpen(false); - }; - const RenderNavigationSection = ({ links, activeLink, @@ -342,7 +386,6 @@ export const HeaderActionBarNavigationMenu = ({ ariaHidden: boolean; className: string; }) => { - // PreviousDropdownLink defaults to titleHref if link is undefined return ( - {/* Previous menu links */} - {openMainLinks.length >= 1 && ( - - )} - - {/* Currently open links */} - 0 ? previousDropdownLink : undefined} - showPreviousLink={openMainLinks.length > 0} - /> - - {/* Next links. Rendered at the deepest level. */} - {!openingLink && ( - - )} - - {/* Render the menu animating into view for better UX. */} - {openingLink && typeof openingLink !== 'string' && ( - - )} + {selectedMenuLevels.map((data, i) => { + const { links, previousLink, activeLink, key } = getMenuContents(i); + const isCurrentMenu = isMenuCurrent(i); + const isCurrentlyAnimating = isAnimating(); + return ( + 0} + /> + ); + })} ); From 4cb7d4e4789756ddb71f7e3a7a39d37194006bcc Mon Sep 17 00:00:00 2001 From: NikoHelle Date: Wed, 29 Nov 2023 13:07:15 +0200 Subject: [PATCH 04/10] (HDS-1975) Optimize render counts Menu position can be set immediately. Render count is reduced. --- .../HeaderActionBarNavigationMenu.tsx | 34 +++++-------------- 1 file changed, 8 insertions(+), 26 deletions(-) diff --git a/packages/react/src/components/header/components/headerActionBar/HeaderActionBarNavigationMenu.tsx b/packages/react/src/components/header/components/headerActionBar/HeaderActionBarNavigationMenu.tsx index b0f65e9fd2..8c157d6cc6 100644 --- a/packages/react/src/components/header/components/headerActionBar/HeaderActionBarNavigationMenu.tsx +++ b/packages/react/src/components/header/components/headerActionBar/HeaderActionBarNavigationMenu.tsx @@ -1,12 +1,4 @@ -import React, { - cloneElement, - isValidElement, - MouseEventHandler, - TransitionEvent, - useEffect, - useRef, - useState, -} from 'react'; +import React, { cloneElement, isValidElement, MouseEventHandler, TransitionEvent, useRef, useState } from 'react'; import { useHeaderContext, useSetHeaderContext } from '../../HeaderContext'; import classNames from '../../../../utils/classNames'; @@ -191,8 +183,6 @@ export const HeaderActionBarNavigationMenu = ({ const { navigationContent, mobileMenuOpen, hasUniversalContent, universalContent } = useHeaderContext(); const { setMobileMenuOpen } = useSetHeaderContext(); // State for which link menu is open but not necessarily active. Needed for browsing the menu. - // State for the wide wrapping element's position. Value is also used as a class for animation. - const [position, setPosition] = useState('left0'); const navContainerRef = useRef(); const currentActiveLinkId = 'current-active-link'; @@ -211,6 +201,11 @@ export const HeaderActionBarNavigationMenu = ({ const getActiveMenus = () => selectedMenuLevels.filter((level) => level.active); + const getMenuPositionStyle = () => { + const activeMenuIndex = getActiveMenus().length - 1; + return styles[menuPositions[activeMenuIndex]]; + }; + const isMenuActive = (index: number) => { const menuItem = selectedMenuLevels[index]; return !!(menuItem && menuItem.active); @@ -321,20 +316,7 @@ export const HeaderActionBarNavigationMenu = ({ }; }; - /* Start animation when active menu has changed */ - const activeMenuIndex = getActiveMenus().length - 1; - useEffect(() => { - setPosition(menuPositions[activeMenuIndex]); - }, [activeMenuIndex]); - - useEffect(() => { - if (mobileMenuOpen && navContainerRef?.current) { - // Set the height of the menu container - const renderedChildIndex = Math.abs(navContainerRef.current.getBoundingClientRect().left / window.innerWidth); - const currentTargetHeight = navContainerRef.current.children[renderedChildIndex].clientHeight; - navContainerRef.current.style.height = `${currentTargetHeight}px`; - } - }, [mobileMenuOpen]); + if (!mobileMenuOpen) return null; const goDeeper = (link: React.ReactElement) => { if (isAnimating()) { @@ -417,7 +399,7 @@ export const HeaderActionBarNavigationMenu = ({ return (
From feed4e1da10035e02604a9f98a43e6b565f49a18 Mon Sep 17 00:00:00 2001 From: NikoHelle Date: Wed, 29 Nov 2023 13:59:03 +0200 Subject: [PATCH 05/10] (HDS-1975) Removed ActiveDropdownLink id The id was used just for focusing. The id was not very unique, so might conflict. --- .../HeaderActionBarNavigationMenu.tsx | 47 ++++++++++++------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/packages/react/src/components/header/components/headerActionBar/HeaderActionBarNavigationMenu.tsx b/packages/react/src/components/header/components/headerActionBar/HeaderActionBarNavigationMenu.tsx index 8c157d6cc6..88e6f26565 100644 --- a/packages/react/src/components/header/components/headerActionBar/HeaderActionBarNavigationMenu.tsx +++ b/packages/react/src/components/header/components/headerActionBar/HeaderActionBarNavigationMenu.tsx @@ -1,4 +1,12 @@ -import React, { cloneElement, isValidElement, MouseEventHandler, TransitionEvent, useRef, useState } from 'react'; +import React, { + cloneElement, + isValidElement, + MouseEventHandler, + TransitionEvent, + useEffect, + useRef, + useState, +} from 'react'; import { useHeaderContext, useSetHeaderContext } from '../../HeaderContext'; import classNames from '../../../../utils/classNames'; @@ -6,7 +14,6 @@ import styles from './HeaderActionBarNavigationMenu.module.scss'; import { getChildrenAsArray } from '../../../../utils/getChildren'; import { HeaderLink } from '../headerLink'; import { IconAngleLeft } from '../../../../icons'; -import getIsElementLoaded from '../../../../utils/getIsElementLoaded'; import { LinkProps } from '../../../../internal/LinkItem'; import HeaderActionBarLogo from './HeaderActionBarLogo'; @@ -81,14 +88,12 @@ type ActiveDropdownLinkProps = { link: React.ReactElement; frontPageLabel: string; titleHref?: string; - id?: string; onLinkClick?: MouseEventHandler; }; -const ActiveDropdownLink = ({ id, link, frontPageLabel, titleHref, onLinkClick }: ActiveDropdownLinkProps) => { +const ActiveDropdownLink = ({ link, frontPageLabel, titleHref, onLinkClick }: ActiveDropdownLinkProps) => { const className = styles.activeMobileLink; const activeLink = link ? ( cloneElement(link, { - id, className, dropdownButtonClassName: styles.hideDropdownButton, wrapperClassName: styles.mobileLinkWrapper, @@ -98,7 +103,7 @@ const ActiveDropdownLink = ({ id, link, frontPageLabel, titleHref, onLinkClick } }, }) ) : ( - + ); return (
  • @@ -184,7 +189,7 @@ export const HeaderActionBarNavigationMenu = ({ const { setMobileMenuOpen } = useSetHeaderContext(); // State for which link menu is open but not necessarily active. Needed for browsing the menu. const navContainerRef = useRef(); - const currentActiveLinkId = 'current-active-link'; + const shouldSetFocus = useRef(false); const universalLinks = hasUniversalContent ? getChildrenAsArray(universalContent) : []; @@ -316,6 +321,24 @@ export const HeaderActionBarNavigationMenu = ({ }; }; + const getActiveLinkElement = (): HTMLAnchorElement | null => { + const container = navContainerRef.current; + const activeMenuIndex = getActiveMenus().length - 1; + const activeMenuElement = container ? (container.childNodes[activeMenuIndex] as HTMLElement) : null; + return activeMenuElement ? activeMenuElement.querySelector(`a.${styles.activeMobileLink}`) : null; + }; + + useEffect(() => { + // Set the focus to the currently active page link + if (shouldSetFocus.current) { + shouldSetFocus.current = false; + const linkElement = getActiveLinkElement(); + if (linkElement) { + linkElement.focus(); + } + } + }); + if (!mobileMenuOpen) return null; const goDeeper = (link: React.ReactElement) => { @@ -339,22 +362,17 @@ export const HeaderActionBarNavigationMenu = ({ const menuSectionsAnimationDone = async (e: TransitionEvent) => { const targetElement = e.target as HTMLElement; if (e.propertyName === 'transform' && targetElement.firstChild.nodeName === 'SECTION') { + shouldSetFocus.current = true; // Set the height of the menu container const renderedChildIndex = resetMenusAfterAnimation() - 1; const currentMenuSection = targetElement.children[renderedChildIndex]; navContainerRef.current.style.height = `${currentMenuSection.clientHeight}px`; - - // If the animation was related to moving menus, set the focus to the currently active page link - // Set the focus to the currently active page link - const linkElement = await getIsElementLoaded(`#${currentActiveLinkId}`); - linkElement.focus(); } }; const RenderNavigationSection = ({ links, activeLink, - activeLinkId, ariaHidden, previousLink, showPreviousLink, @@ -362,7 +380,6 @@ export const HeaderActionBarNavigationMenu = ({ }: { links: React.ReactNode[]; activeLink: React.ReactElement; - activeLinkId?: string; previousLink?: React.ReactElement; showPreviousLink?: boolean; ariaHidden: boolean; @@ -385,7 +402,6 @@ export const HeaderActionBarNavigationMenu = ({ /> )} Date: Wed, 29 Nov 2023 14:11:35 +0200 Subject: [PATCH 06/10] (HDS-1975) Hide menus when not needed --- .../headerActionBar/HeaderActionBarNavigationMenu.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/react/src/components/header/components/headerActionBar/HeaderActionBarNavigationMenu.tsx b/packages/react/src/components/header/components/headerActionBar/HeaderActionBarNavigationMenu.tsx index 88e6f26565..d8cf2dc5f2 100644 --- a/packages/react/src/components/header/components/headerActionBar/HeaderActionBarNavigationMenu.tsx +++ b/packages/react/src/components/header/components/headerActionBar/HeaderActionBarNavigationMenu.tsx @@ -423,11 +423,15 @@ export const HeaderActionBarNavigationMenu = ({ const { links, previousLink, activeLink, key } = getMenuContents(i); const isCurrentMenu = isMenuCurrent(i); const isCurrentlyAnimating = isAnimating(); + const distanceToLast = selectedMenuLevels.length - 1 - i; + // Maximum of 2 menus can be seen at the same time and only when animating. + // Otherwise only one. If there are 3 menus, then root should not be shown. + const shouldBeVisible = (isCurrentlyAnimating && distanceToLast < 2) || isCurrentMenu; return ( Date: Fri, 1 Dec 2023 23:24:23 +0200 Subject: [PATCH 07/10] (HDS-1975) Added tests --- .../HeaderActionBarNavigationMenu.test.tsx | 468 ++++++++++++ ...eaderActionBarNavigationMenu.test.tsx.snap | 695 ++++++++++++++++++ 2 files changed, 1163 insertions(+) create mode 100644 packages/react/src/components/header/components/headerActionBar/HeaderActionBarNavigationMenu.test.tsx create mode 100644 packages/react/src/components/header/components/headerActionBar/__snapshots__/HeaderActionBarNavigationMenu.test.tsx.snap diff --git a/packages/react/src/components/header/components/headerActionBar/HeaderActionBarNavigationMenu.test.tsx b/packages/react/src/components/header/components/headerActionBar/HeaderActionBarNavigationMenu.test.tsx new file mode 100644 index 0000000000..f86e69770b --- /dev/null +++ b/packages/react/src/components/header/components/headerActionBar/HeaderActionBarNavigationMenu.test.tsx @@ -0,0 +1,468 @@ +import { RenderResult, fireEvent, render, waitFor } from '@testing-library/react'; +import { axe } from 'jest-axe'; +import React from 'react'; + +import { getActiveElement } from '../../../cookieConsent/test.util'; +import { Header } from '../../Header'; +// eslint-disable-next-line jest/no-mocks-import +import mockWindowLocation from '../../../login/__mocks__/mockWindowLocation'; + +jest.mock('../../../../hooks/useMediaQuery', () => ({ + ...(jest.requireActual('../../../../hooks/useMediaQuery') as Record), + useMediaQueryLessThan: () => { + // if this returns true, mobile menu is rendered. + return true; + }, +})); + +type MenuItem = { + id: string; + submenus?: MenuItem[]; +}; + +type TestTools = RenderResult & { + openMobileMenu: () => Promise; + closeMobileMenu: () => Promise; + findVisibleSectionLinksByMenuIds: (menuIds: string[]) => Promise; + getNavSections: () => ReturnType; + getActiveLink: () => HTMLAnchorElement; + getPreviousLink: () => HTMLAnchorElement | null; + selectMenuItem: (menuItem: MenuItem) => Promise; + verifyActiveItem: (menuItem: MenuItem | string) => boolean; + verifyPreviousItem: (menuItem: MenuItem | string | null) => boolean; + triggerAnimationEnd: (focusedItem: MenuItem | string) => Promise; + getFocusedElement: () => Element | null; + navigateTo: (menuItem: MenuItem) => Promise; + navigateBack: (waitForParentItem: MenuItem | string) => Promise; +}; + +const mockedWindowControls = mockWindowLocation(); + +const onClickTracker = jest.fn(); + +const onClickHandler = (e: React.MouseEvent) => { + e.preventDefault(); + onClickTracker(e); +}; + +const frontPageLabel = 'frontPageLabel'; +const titleHref = 'https://domain.fi'; + +const menus: MenuItem[] = [ + { + id: '0', + submenus: [ + { + id: '0_0', + submenus: [{ id: '0_0_0' }, { id: '0_0_1' }], + }, + { + id: '0_1', + }, + { + id: '0_2', + submenus: [{ id: '0_2_0' }, { id: '0_2_1' }, { id: '0_2_2' }], + }, + ], + }, + { + id: '1', + submenus: [ + { + id: '1_0', + submenus: [{ id: '1_0_0' }, { id: '1_0_1' }, { id: '1_0_2' }], + }, + ], + }, + { + id: '2', + }, + { + id: '3', + submenus: [ + { + id: '3_0', + }, + { + id: '3_1', + submenus: [{ id: '3_1_0' }, { id: '3_1_1' }, { id: '3_1_2' }], + }, + { + id: '3_2', + submenus: [{ id: '3_2_0' }, { id: '3_2_1' }, { id: '3_2_2' }], + }, + ], + }, +]; + +const getMenuItem = (path: number[]): MenuItem => { + return path.reduce( + (current, index) => { + return (current.submenus as MenuItem[])[index] as MenuItem; + }, + { submenus: menus } as MenuItem, + ); +}; + +const createLabel = (id: string) => `TestLabel_${id}`; +const createHref = (id: string) => `http://test-link-${id}.com`; + +const HeaderWithMenus = () => { + return ( +
    + } + logoAriaLabel="logoAriaLabel" + logoHref="https://hel.fi" + menuButtonAriaLabel="menuButtonAriaLabel" + openFrontPageLinksAriaLabel="openFrontPageLinksAriaLabel" + > + ActionBar + + + {menus.map((item) => { + // these mappings are a bit akward way to create link structure, + // but NavigationMenu child components are cloned and their props are copied and re-used, + // so cannot use other components or funcs to return components for dropdownLinks. + return ( + { + onClickHandler(event); + }} + dropdownLinks={ + item.submenus + ? item.submenus.map((i) => ( + { + onClickHandler(event); + }} + dropdownLinks={ + i.submenus + ? i.submenus.map((jj) => ( + { + onClickHandler(event); + }} + /> + )) + : undefined + } + /> + )) + : undefined + } + /> + ); + })} + +
    + ); +}; + +const renderHeader = (): TestTools => { + const result = render(HeaderWithMenus()); + const { container, getByText, getAllByText } = result; + + const getNavSections: TestTools['getNavSections'] = () => container.querySelectorAll('section > nav'); + const isElementAriaHidden = (el: Element) => el.getAttribute('aria-hidden') === 'true'; + + const isElementAriaVisible = (el: Element) => !isElementAriaHidden(el); + const getDropdownButtonForLink = (el: Element) => el.parentElement?.querySelector('button') as HTMLButtonElement; + const getVisibleNav = () => { + return Array.from(getNavSections()).find((el) => + isElementAriaVisible(el.parentElement as HTMLElement), + ) as HTMLElement; + }; + const toggleMobileMenu = async (shouldBeOpen: boolean) => { + const menuButton = getByText('Menu') as HTMLButtonElement; + fireEvent.click(menuButton); + await waitFor(() => { + const isClosed = getNavSections().length === 0; + if (isClosed === shouldBeOpen) { + throw new Error('Navigation element mismatch'); + } + }); + }; + + const openMobileMenu: TestTools['openMobileMenu'] = async () => { + await toggleMobileMenu(true); + }; + + const closeMobileMenu: TestTools['closeMobileMenu'] = async () => { + await toggleMobileMenu(false); + }; + + const findVisibleSectionLinksByMenuIds: TestTools['findVisibleSectionLinksByMenuIds'] = async (menuIds) => { + const visibleNav = getVisibleNav(); + // ignore links listed in activeListItem. They are invisible. + const activeListItem = visibleNav.querySelector('li.activeListItem'); + return waitFor(() => { + return menuIds.map((id) => { + const hits = getAllByText(createLabel(id)).filter( + (el) => visibleNav.contains(el) && (!activeListItem || !activeListItem.contains(el)), + ); + if (hits.length !== 1) { + throw new Error(`Label ${createLabel(id)} is found in ${hits.length} elements`); + } + return hits[0]; + }); + }); + }; + + const getActiveLink: TestTools['getActiveLink'] = () => { + const visibleNav = getVisibleNav(); + return visibleNav.querySelector('a.activeMobileLink') as HTMLAnchorElement; + }; + + const getPreviousLink: TestTools['getPreviousLink'] = () => { + const visibleNav = getVisibleNav(); + return visibleNav.querySelector('span.previousMobileLink') as HTMLAnchorElement; + }; + + const selectMenuItem: TestTools['selectMenuItem'] = async (item) => { + if (!item.submenus) { + return Promise.reject(new Error('Menu item has no submenus')); + } + const links = await findVisibleSectionLinksByMenuIds([item.id]); + if (links.length !== 1) { + return Promise.reject(new Error(`Menu item ${item.id} not found`)); + } + const getCurrentNavIndex = () => { + const currentNav = getVisibleNav(); + const index = Array.from(getNavSections()).indexOf(currentNav); + if (index === -1) { + throw new Error('getCurrentNavIndex is -1'); + } + return index; + }; + const currentIndex = getCurrentNavIndex(); + fireEvent.click(getDropdownButtonForLink(links[0])); + await waitFor(() => { + if (getCurrentNavIndex() === currentIndex) { + throw new Error('Current nav not changed.'); + } + }); + return findVisibleSectionLinksByMenuIds(item.submenus.map((subItem) => subItem.id)); + }; + + const verifyActiveItem: TestTools['verifyActiveItem'] = (itemOrString) => { + const activeLink = getActiveLink(); + const comparison = typeof itemOrString === 'string' ? itemOrString : createLabel(itemOrString.id); + // console.log('vai', activeLink.innerHTML, comparison); + return activeLink.innerHTML === comparison; + }; + + const verifyPreviousItem: TestTools['verifyPreviousItem'] = (itemOrString) => { + const previousLink = getPreviousLink(); + if (!itemOrString) { + return !previousLink; + } + const comparison = typeof itemOrString === 'string' ? itemOrString : createLabel(itemOrString.id); + return !!previousLink && previousLink.innerHTML === comparison; + }; + + const getFocusedElement: TestTools['getFocusedElement'] = () => { + return getActiveElement(container); + }; + + const triggerAnimationEnd: TestTools['triggerAnimationEnd'] = (focusedItemOrString) => { + const getFocusedElementContents = () => { + const el = getFocusedElement(); + return el ? el.innerHTML : ''; + }; + const expectedFocusedElementContents = + typeof focusedItemOrString === 'string' ? focusedItemOrString : createLabel(focusedItemOrString.id); + const animatedElement = (container.querySelector('section') as HTMLElement).parentElement as HTMLElement; + // For some reasong event will not be triggered without bubbles + const ev = new Event('transitionend', { bubbles: true }); + // "propertyName" will not end up to the component in any other way than setting it to the object + // @ts-ignore + ev.propertyName = 'transform'; + fireEvent(animatedElement, ev); + return waitFor(() => { + if (getFocusedElementContents() !== expectedFocusedElementContents) { + throw new Error('Focus is not in correct element'); + } + }); + }; + + const navigateTo: TestTools['navigateTo'] = async (item) => { + await selectMenuItem(item); + await triggerAnimationEnd(item); + if (verifyActiveItem(item) !== true) { + throw new Error('Active item is not set'); + } + }; + + const navigateBack: TestTools['navigateBack'] = async (waitForParentItemOrString) => { + const previousLink = getPreviousLink() as HTMLElement; + fireEvent.click(previousLink); + await triggerAnimationEnd(waitForParentItemOrString); + }; + + return { + ...result, + openMobileMenu, + closeMobileMenu, + findVisibleSectionLinksByMenuIds, + getNavSections, + getActiveLink, + selectMenuItem, + verifyActiveItem, + triggerAnimationEnd, + getFocusedElement, + getPreviousLink, + verifyPreviousItem, + navigateTo, + navigateBack, + }; +}; + +afterEach(async () => { + mockedWindowControls.reset(); + jest.resetAllMocks(); +}); + +afterAll(() => { + mockedWindowControls.restore(); +}); + +describe(' spec', () => { + it('renders the component and menu can be opened', async () => { + const { openMobileMenu, asFragment } = renderHeader(); + await openMobileMenu(); + expect(asFragment()).toMatchSnapshot(); + }); + + it('should not have basic accessibility issues when menu is open', async () => { + const { container, openMobileMenu } = renderHeader(); + await openMobileMenu(); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('should not have basic accessibility issues when user has navigated to the deepest level is open', async () => { + const { container, openMobileMenu, navigateTo } = renderHeader(); + await openMobileMenu(); + await navigateTo(getMenuItem([0])); + await navigateTo(getMenuItem([0, 2])); + const results = await axe(container); + expect(results).toHaveNoViolations(); + // this can take a long time so increased timeout + }, 10000); + + it('When mobile menu is opened, first level links are shown', async () => { + const { openMobileMenu, findVisibleSectionLinksByMenuIds, getNavSections } = renderHeader(); + await openMobileMenu(); + await findVisibleSectionLinksByMenuIds(menus.map((item) => item.id)); + expect(getNavSections()).toHaveLength(1); + }); + it('Nav sections are rendered only when needed and removed when not needed', async () => { + const { openMobileMenu, navigateTo, getNavSections, navigateBack, closeMobileMenu } = renderHeader(); + expect(getNavSections()).toHaveLength(0); + await openMobileMenu(); + expect(getNavSections()).toHaveLength(1); + await navigateTo(getMenuItem([0])); + expect(getNavSections()).toHaveLength(2); + await navigateTo(getMenuItem([0, 2])); + expect(getNavSections()).toHaveLength(3); + await navigateBack(getMenuItem([0])); + expect(getNavSections()).toHaveLength(2); + await navigateBack(frontPageLabel); + expect(getNavSections()).toHaveLength(1); + await closeMobileMenu(); + expect(getNavSections()).toHaveLength(0); + }); + it('Previous and active links change while navigating', async () => { + const { + openMobileMenu, + navigateTo, + getNavSections, + navigateBack, + verifyActiveItem, + verifyPreviousItem, + } = renderHeader(); + const menu3 = getMenuItem([3]); + const menu31 = getMenuItem([3, 1]); + const menu32 = getMenuItem([3, 2]); + const menu0 = getMenuItem([0]); + const menu02 = getMenuItem([0, 2]); + expect(getNavSections()).toHaveLength(0); + await openMobileMenu(); + expect(verifyActiveItem(frontPageLabel)).toBeTruthy(); + expect(verifyPreviousItem(null)).toBeTruthy(); + + await navigateTo(menu3); + expect(verifyActiveItem(menu3)).toBeTruthy(); + expect(verifyPreviousItem(frontPageLabel)).toBeTruthy(); + + await navigateTo(menu31); + expect(verifyActiveItem(menu31)).toBeTruthy(); + expect(verifyPreviousItem(menu3)).toBeTruthy(); + + await navigateBack(menu3); + expect(verifyActiveItem(menu3)).toBeTruthy(); + expect(verifyPreviousItem(frontPageLabel)).toBeTruthy(); + + await navigateTo(menu32); + expect(verifyActiveItem(menu32)).toBeTruthy(); + expect(verifyPreviousItem(menu3)).toBeTruthy(); + + await navigateBack(menu3); + await navigateBack(frontPageLabel); + expect(verifyActiveItem(frontPageLabel)).toBeTruthy(); + expect(verifyPreviousItem(null)).toBeTruthy(); + + await navigateTo(menu0); + expect(verifyActiveItem(menu0)).toBeTruthy(); + expect(verifyPreviousItem(frontPageLabel)).toBeTruthy(); + + await navigateTo(menu02); + expect(verifyActiveItem(menu02)).toBeTruthy(); + expect(verifyPreviousItem(menu0)).toBeTruthy(); + + await navigateBack(menu0); + await navigateBack(frontPageLabel); + expect(verifyActiveItem(frontPageLabel)).toBeTruthy(); + expect(verifyPreviousItem(null)).toBeTruthy(); + }); + it('If the top level active link is clicked, menu is closed.', async () => { + const { openMobileMenu, getNavSections, getActiveLink } = renderHeader(); + expect(getNavSections()).toHaveLength(0); + await openMobileMenu(); + + const activeLinkToFrontpage = getActiveLink(); + // suppress jsdom "navigation not implemented" error + activeLinkToFrontpage.setAttribute('href', ''); + fireEvent.click(activeLinkToFrontpage); + await waitFor(() => { + expect(getNavSections()).toHaveLength(0); + }); + }); + it('If a lower level active link is clicked, menu is closed and onClick handler is called.', async () => { + const { openMobileMenu, getNavSections, getActiveLink, selectMenuItem } = renderHeader(); + expect(getNavSections()).toHaveLength(0); + await openMobileMenu(); + await selectMenuItem(getMenuItem([0])); + + const activeLinkToFrontpage = getActiveLink(); + fireEvent.click(activeLinkToFrontpage); + await waitFor(() => { + expect(onClickTracker).toHaveBeenCalledTimes(1); + expect(getNavSections()).toHaveLength(0); + }); + }); +}); diff --git a/packages/react/src/components/header/components/headerActionBar/__snapshots__/HeaderActionBarNavigationMenu.test.tsx.snap b/packages/react/src/components/header/components/headerActionBar/__snapshots__/HeaderActionBarNavigationMenu.test.tsx.snap new file mode 100644 index 0000000000..8f1f39212d --- /dev/null +++ b/packages/react/src/components/header/components/headerActionBar/__snapshots__/HeaderActionBarNavigationMenu.test.tsx.snap @@ -0,0 +1,695 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` spec renders the component and menu can be opened 1`] = ` + +
    +
    +
    +
    +`; From a3329e5c777324cf222f58b77be79bb41190e02b Mon Sep 17 00:00:00 2001 From: NikoHelle Date: Mon, 4 Dec 2023 11:41:22 +0200 Subject: [PATCH 08/10] (HDS-1975) Fix critical axe error The menu never had the id that the menu button 's aria-controls refers to. --- .../headerActionBar/HeaderActionBarNavigationMenu.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/react/src/components/header/components/headerActionBar/HeaderActionBarNavigationMenu.tsx b/packages/react/src/components/header/components/headerActionBar/HeaderActionBarNavigationMenu.tsx index d8cf2dc5f2..460f9da510 100644 --- a/packages/react/src/components/header/components/headerActionBar/HeaderActionBarNavigationMenu.tsx +++ b/packages/react/src/components/header/components/headerActionBar/HeaderActionBarNavigationMenu.tsx @@ -308,7 +308,6 @@ export const HeaderActionBarNavigationMenu = ({ if (level === 1) { return undefined; } - return findParentElement(selectedMenuLevels.slice(0, level)); }; @@ -413,7 +412,10 @@ export const HeaderActionBarNavigationMenu = ({ }; return ( -
    +
    Date: Mon, 4 Dec 2023 12:04:32 +0200 Subject: [PATCH 09/10] (HDS-1975) Fixed critical axe error for span having aria-expanded Moved the drop to the dropdown button --- .../components/header/components/headerLink/HeaderLink.tsx | 6 +----- .../headerLink/headerLinkDropdown/HeaderLinkDropdown.tsx | 1 + 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/react/src/components/header/components/headerLink/HeaderLink.tsx b/packages/react/src/components/header/components/headerLink/HeaderLink.tsx index 3ab2dc7c1f..e96417d5db 100644 --- a/packages/react/src/components/header/components/headerLink/HeaderLink.tsx +++ b/packages/react/src/components/header/components/headerLink/HeaderLink.tsx @@ -214,11 +214,7 @@ export const HeaderLink = ({ }); return ( - + {label} diff --git a/packages/react/src/components/header/components/headerLink/headerLinkDropdown/HeaderLinkDropdown.tsx b/packages/react/src/components/header/components/headerLink/headerLinkDropdown/HeaderLinkDropdown.tsx index b42365ff13..0a3ced537f 100644 --- a/packages/react/src/components/header/components/headerLink/headerLinkDropdown/HeaderLinkDropdown.tsx +++ b/packages/react/src/components/header/components/headerLink/headerLinkDropdown/HeaderLinkDropdown.tsx @@ -135,6 +135,7 @@ export const HeaderLinkDropdown = ({ onClick={handleMenuButtonClick} data-testid={`dropdown-button-${index}`} aria-label={getDefaultButtonAriaLabel()} + aria-expanded={open} > {renderIcon()} From 0ca84bc443c3c86f70619f9fc0da337330bf5b18 Mon Sep 17 00:00:00 2001 From: NikoHelle Date: Mon, 18 Dec 2023 15:26:56 +0200 Subject: [PATCH 10/10] (HDS-1975) Adjust to new animation and render style Another PR made the mobileMenu element always visible and animates it vertically. --- .../HeaderActionBarNavigationMenu.module.scss | 9 +- .../HeaderActionBarNavigationMenu.test.tsx | 138 +++++++++-- .../HeaderActionBarNavigationMenu.tsx | 230 ++++++++++++------ ...eaderActionBarNavigationMenu.test.tsx.snap | 1 + packages/react/src/hooks/useForceRender.ts | 13 + 5 files changed, 295 insertions(+), 96 deletions(-) create mode 100644 packages/react/src/hooks/useForceRender.ts diff --git a/packages/react/src/components/header/components/headerActionBar/HeaderActionBarNavigationMenu.module.scss b/packages/react/src/components/header/components/headerActionBar/HeaderActionBarNavigationMenu.module.scss index 315732c20b..76cc633ae2 100644 --- a/packages/react/src/components/header/components/headerActionBar/HeaderActionBarNavigationMenu.module.scss +++ b/packages/react/src/components/header/components/headerActionBar/HeaderActionBarNavigationMenu.module.scss @@ -45,7 +45,9 @@ overflow: hidden; position: absolute; transform: translateY(-100%) translateY(1px); - transition: var(--animation-duration-dropwdown) transform var(--animation-close-delay-dropdown), var(--animation-duration-dropwdown) min-height calc(var(--animation-duration-dropwdown) + var(--animation-close-delay-dropdown)); + transition: var(--animation-duration-dropwdown) transform var(--animation-close-delay-dropdown), + var(--animation-duration-dropwdown) min-height + calc(var(--animation-duration-dropwdown) + var(--animation-close-delay-dropdown)); width: 100%; } @@ -209,21 +211,20 @@ display: flex; min-height: calc(100vh - var(--action-bar-container-height)); overflow: hidden; + transform: translateX(0); + transition: transform 0.3s ease; width: 300%; &.left0 { transform: translateX(0); - transition: transform 0.3s ease; } &.left100 { transform: translateX(-100vw); - transition: transform 0.3s ease; } &.left200 { transform: translateX(-200vw); - transition: transform 0.3s ease; } } diff --git a/packages/react/src/components/header/components/headerActionBar/HeaderActionBarNavigationMenu.test.tsx b/packages/react/src/components/header/components/headerActionBar/HeaderActionBarNavigationMenu.test.tsx index f86e69770b..d3bf4e2c6d 100644 --- a/packages/react/src/components/header/components/headerActionBar/HeaderActionBarNavigationMenu.test.tsx +++ b/packages/react/src/components/header/components/headerActionBar/HeaderActionBarNavigationMenu.test.tsx @@ -25,6 +25,7 @@ type TestTools = RenderResult & { closeMobileMenu: () => Promise; findVisibleSectionLinksByMenuIds: (menuIds: string[]) => Promise; getNavSections: () => ReturnType; + getCSSVisibleSections: () => HTMLElement[]; getActiveLink: () => HTMLAnchorElement; getPreviousLink: () => HTMLAnchorElement | null; selectMenuItem: (menuItem: MenuItem) => Promise; @@ -34,6 +35,7 @@ type TestTools = RenderResult & { getFocusedElement: () => Element | null; navigateTo: (menuItem: MenuItem) => Promise; navigateBack: (waitForParentItem: MenuItem | string) => Promise; + triggerMenuAnimationEnd: () => void; }; const mockedWindowControls = mockWindowLocation(); @@ -176,21 +178,43 @@ const renderHeader = (): TestTools => { const result = render(HeaderWithMenus()); const { container, getByText, getAllByText } = result; + const getSections = () => container.querySelectorAll('section'); const getNavSections: TestTools['getNavSections'] = () => container.querySelectorAll('section > nav'); const isElementAriaHidden = (el: Element) => el.getAttribute('aria-hidden') === 'true'; const isElementAriaVisible = (el: Element) => !isElementAriaHidden(el); const getDropdownButtonForLink = (el: Element) => el.parentElement?.querySelector('button') as HTMLButtonElement; - const getVisibleNav = () => { + const getAriaVisibleNav = () => { return Array.from(getNavSections()).find((el) => isElementAriaVisible(el.parentElement as HTMLElement), ) as HTMLElement; }; + + const getCSSVisibleSections = () => { + return Array.from(getSections()).filter((el) => { + const classes = String(el.getAttribute('class')); + return !classes.includes('hidden'); + }) as HTMLElement[]; + }; + + const triggerMenuAnimationEnd = () => { + const menu = container.querySelector('#hds-mobile-menu') as HTMLElement; + // For some reasong event will not be triggered without bubbles + const ev = new Event('transitionend', { bubbles: true }); + // "propertyName" will not end up to the component in any other way than setting it to the object + // @ts-ignore + ev.propertyName = 'transform'; + fireEvent(menu, ev); + }; + const toggleMobileMenu = async (shouldBeOpen: boolean) => { const menuButton = getByText('Menu') as HTMLButtonElement; fireEvent.click(menuButton); + if (!shouldBeOpen) { + triggerMenuAnimationEnd(); + } await waitFor(() => { - const isClosed = getNavSections().length === 0; + const isClosed = getCSSVisibleSections().length === 0; if (isClosed === shouldBeOpen) { throw new Error('Navigation element mismatch'); } @@ -206,7 +230,7 @@ const renderHeader = (): TestTools => { }; const findVisibleSectionLinksByMenuIds: TestTools['findVisibleSectionLinksByMenuIds'] = async (menuIds) => { - const visibleNav = getVisibleNav(); + const visibleNav = getAriaVisibleNav(); // ignore links listed in activeListItem. They are invisible. const activeListItem = visibleNav.querySelector('li.activeListItem'); return waitFor(() => { @@ -223,12 +247,12 @@ const renderHeader = (): TestTools => { }; const getActiveLink: TestTools['getActiveLink'] = () => { - const visibleNav = getVisibleNav(); + const visibleNav = getAriaVisibleNav(); return visibleNav.querySelector('a.activeMobileLink') as HTMLAnchorElement; }; const getPreviousLink: TestTools['getPreviousLink'] = () => { - const visibleNav = getVisibleNav(); + const visibleNav = getAriaVisibleNav(); return visibleNav.querySelector('span.previousMobileLink') as HTMLAnchorElement; }; @@ -241,7 +265,7 @@ const renderHeader = (): TestTools => { return Promise.reject(new Error(`Menu item ${item.id} not found`)); } const getCurrentNavIndex = () => { - const currentNav = getVisibleNav(); + const currentNav = getAriaVisibleNav(); const index = Array.from(getNavSections()).indexOf(currentNav); if (index === -1) { throw new Error('getCurrentNavIndex is -1'); @@ -328,6 +352,8 @@ const renderHeader = (): TestTools => { verifyPreviousItem, navigateTo, navigateBack, + getCSSVisibleSections, + triggerMenuAnimationEnd, }; }; @@ -370,11 +396,22 @@ describe(' spec', () => { await findVisibleSectionLinksByMenuIds(menus.map((item) => item.id)); expect(getNavSections()).toHaveLength(1); }); - it('Nav sections are rendered only when needed and removed when not needed', async () => { - const { openMobileMenu, navigateTo, getNavSections, navigateBack, closeMobileMenu } = renderHeader(); - expect(getNavSections()).toHaveLength(0); - await openMobileMenu(); + it('Nav sections are rendered and visible only when needed and removed when not needed', async () => { + const { + openMobileMenu, + navigateTo, + getNavSections, + navigateBack, + closeMobileMenu, + getCSSVisibleSections, + } = renderHeader(); + // one is always rendered expect(getNavSections()).toHaveLength(1); + // but it is hidden + expect(getCSSVisibleSections()).toHaveLength(0); + await openMobileMenu(); + expect(getCSSVisibleSections()).toHaveLength(1); + // await navigateTo(getMenuItem([0])); expect(getNavSections()).toHaveLength(2); await navigateTo(getMenuItem([0, 2])); @@ -384,23 +421,24 @@ describe(' spec', () => { await navigateBack(frontPageLabel); expect(getNavSections()).toHaveLength(1); await closeMobileMenu(); - expect(getNavSections()).toHaveLength(0); + expect(getCSSVisibleSections()).toHaveLength(0); + expect(getNavSections()).toHaveLength(1); }); it('Previous and active links change while navigating', async () => { const { openMobileMenu, navigateTo, - getNavSections, navigateBack, verifyActiveItem, verifyPreviousItem, + getCSSVisibleSections, } = renderHeader(); const menu3 = getMenuItem([3]); const menu31 = getMenuItem([3, 1]); const menu32 = getMenuItem([3, 2]); const menu0 = getMenuItem([0]); const menu02 = getMenuItem([0, 2]); - expect(getNavSections()).toHaveLength(0); + expect(getCSSVisibleSections()).toHaveLength(0); await openMobileMenu(); expect(verifyActiveItem(frontPageLabel)).toBeTruthy(); expect(verifyPreviousItem(null)).toBeTruthy(); @@ -440,29 +478,91 @@ describe(' spec', () => { expect(verifyPreviousItem(null)).toBeTruthy(); }); it('If the top level active link is clicked, menu is closed.', async () => { - const { openMobileMenu, getNavSections, getActiveLink } = renderHeader(); - expect(getNavSections()).toHaveLength(0); + const { openMobileMenu, getActiveLink, getCSSVisibleSections, triggerMenuAnimationEnd } = renderHeader(); await openMobileMenu(); const activeLinkToFrontpage = getActiveLink(); // suppress jsdom "navigation not implemented" error activeLinkToFrontpage.setAttribute('href', ''); fireEvent.click(activeLinkToFrontpage); + triggerMenuAnimationEnd(); await waitFor(() => { - expect(getNavSections()).toHaveLength(0); + expect(getCSSVisibleSections()).toHaveLength(0); }); }); it('If a lower level active link is clicked, menu is closed and onClick handler is called.', async () => { - const { openMobileMenu, getNavSections, getActiveLink, selectMenuItem } = renderHeader(); - expect(getNavSections()).toHaveLength(0); + const { + openMobileMenu, + getActiveLink, + selectMenuItem, + getCSSVisibleSections, + triggerMenuAnimationEnd, + } = renderHeader(); await openMobileMenu(); await selectMenuItem(getMenuItem([0])); const activeLinkToFrontpage = getActiveLink(); fireEvent.click(activeLinkToFrontpage); + triggerMenuAnimationEnd(); await waitFor(() => { expect(onClickTracker).toHaveBeenCalledTimes(1); - expect(getNavSections()).toHaveLength(0); + expect(getCSSVisibleSections()).toHaveLength(0); }); }); + it('When re-opened, the menus are restored to the state when closed.', async () => { + const { + openMobileMenu, + closeMobileMenu, + navigateTo, + navigateBack, + verifyActiveItem, + verifyPreviousItem, + getCSSVisibleSections, + getNavSections, + } = renderHeader(); + const menu3 = getMenuItem([3]); + const menu31 = getMenuItem([3, 1]); + expect(getCSSVisibleSections()).toHaveLength(0); + await openMobileMenu(); + + await navigateTo(menu3); + await navigateTo(menu31); + + expect(verifyActiveItem(menu31)).toBeTruthy(); + expect(verifyPreviousItem(menu3)).toBeTruthy(); + expect(getNavSections()).toHaveLength(3); + expect(getCSSVisibleSections()).toHaveLength(3); + + await closeMobileMenu(); + await openMobileMenu(); + + expect(verifyActiveItem(menu31)).toBeTruthy(); + expect(verifyPreviousItem(menu3)).toBeTruthy(); + expect(getNavSections()).toHaveLength(3); + expect(getCSSVisibleSections()).toHaveLength(3); + + await navigateBack(menu3); + expect(verifyActiveItem(menu3)).toBeTruthy(); + expect(verifyPreviousItem(frontPageLabel)).toBeTruthy(); + + await closeMobileMenu(); + await openMobileMenu(); + + expect(getNavSections()).toHaveLength(2); + expect(getCSSVisibleSections()).toHaveLength(2); + + expect(verifyActiveItem(menu3)).toBeTruthy(); + expect(verifyPreviousItem(frontPageLabel)).toBeTruthy(); + + await navigateBack(frontPageLabel); + await closeMobileMenu(); + await openMobileMenu(); + + expect(getNavSections()).toHaveLength(1); + expect(getCSSVisibleSections()).toHaveLength(1); + + expect(verifyActiveItem(frontPageLabel)).toBeTruthy(); + expect(verifyPreviousItem(null)).toBeTruthy(); + // this is slow process with many updates, so increased timeout + }, 10000); }); diff --git a/packages/react/src/components/header/components/headerActionBar/HeaderActionBarNavigationMenu.tsx b/packages/react/src/components/header/components/headerActionBar/HeaderActionBarNavigationMenu.tsx index 460f9da510..48d44da911 100644 --- a/packages/react/src/components/header/components/headerActionBar/HeaderActionBarNavigationMenu.tsx +++ b/packages/react/src/components/header/components/headerActionBar/HeaderActionBarNavigationMenu.tsx @@ -1,12 +1,4 @@ -import React, { - cloneElement, - isValidElement, - MouseEventHandler, - TransitionEvent, - useEffect, - useRef, - useState, -} from 'react'; +import React, { cloneElement, isValidElement, MouseEventHandler, TransitionEvent, useEffect, useRef } from 'react'; import { useHeaderContext, useSetHeaderContext } from '../../HeaderContext'; import classNames from '../../../../utils/classNames'; @@ -16,6 +8,8 @@ import { HeaderLink } from '../headerLink'; import { IconAngleLeft } from '../../../../icons'; import { LinkProps } from '../../../../internal/LinkItem'; import HeaderActionBarLogo from './HeaderActionBarLogo'; +import useIsomorphicLayoutEffect from '../../../../hooks/useIsomorphicLayoutEffect'; +import useForceRender from '../../../../hooks/useForceRender'; type NavigationSectionType = { logo: React.ReactNode; @@ -160,11 +154,17 @@ const Logo = ({ logo, logoProps }) => ( type Position = 'left0' | 'left100' | 'left200'; type MenuInfo = { active: boolean; - animating: boolean; index: number; key: string; root: boolean; }; +type State = { + isOpen: boolean; + menus: MenuInfo[]; + isChangingLevel: boolean; + isClosingOrOpening: boolean; + shouldSetFocusToActiveLink: boolean; +}; type HeaderActionBarNavigationMenuProps = { frontPageLabel: string; titleHref: string; @@ -186,77 +186,136 @@ export const HeaderActionBarNavigationMenu = ({ openFrontPageLinksAriaLabel, }: HeaderActionBarNavigationMenuProps) => { const { navigationContent, mobileMenuOpen, hasUniversalContent, universalContent } = useHeaderContext(); + const universalLinks = hasUniversalContent ? getChildrenAsArray(universalContent) : []; const { setMobileMenuOpen } = useSetHeaderContext(); // State for which link menu is open but not necessarily active. Needed for browsing the menu. const navContainerRef = useRef(); - const shouldSetFocus = useRef(false); + const reRender = useForceRender(); + + const state = useRef({ + isOpen: mobileMenuOpen, + menus: [{ root: true, index: -1, active: false, key: 'root' }], + isChangingLevel: false, + isClosingOrOpening: false, + shouldSetFocusToActiveLink: false, + }); - const universalLinks = hasUniversalContent ? getChildrenAsArray(universalContent) : []; + const getState = () => state.current; - const [selectedMenuLevels, setSelectedMenuLevels] = useState([ - // first menu is always the same - the root menu. - { root: true, index: -1, active: true, animating: false, key: 'root' }, - ]); + const updateState = (newStateProps: Partial) => { + state.current = { ...getState(), ...newStateProps }; + }; - const menuPositions: Record = { - 0: 'left0', - 1: 'left100', - 2: 'left200', + const getMenuLevels = (): State['menus'] => { + return getState().menus; }; - const getActiveMenus = () => selectedMenuLevels.filter((level) => level.active); + const updateMenuLevelsAndRender = (newLevels: State['menus'], animationOver = false) => { + updateState({ menus: newLevels, isChangingLevel: !animationOver }); + reRender(); + }; + + const modifyMenuLevels = ({ + setActiveIndex, + newMenuIndex, + removeLastIfInActive, + deactivateLast, + activateLast, + }: { + setActiveIndex?: number; + newMenuIndex?: number; + removeLastIfInActive?: boolean; + activateLast?: boolean; + deactivateLast?: boolean; + }) => { + const currentMenus = [...getMenuLevels()]; + const getNewActiveIndex = (): number | null => { + if (typeof setActiveIndex === 'number') { + return setActiveIndex; + } + if (activateLast) { + return currentMenus.length - 1; + } + if (deactivateLast) { + return currentMenus.length - 2; + } + return null; + }; + const activeIndex = getNewActiveIndex(); + if (activeIndex !== null) { + currentMenus.forEach((menu, index) => { + // eslint-disable-next-line no-param-reassign + menu.active = index === activeIndex; + }); + } + if (typeof newMenuIndex !== 'undefined') { + const parent = currentMenus[currentMenus.length - 1]; + parent.active = false; + currentMenus.push({ index: newMenuIndex, active: true, root: false, key: `${parent.key}_${newMenuIndex}` }); + } + if (removeLastIfInActive) { + const lastMenu = currentMenus[currentMenus.length - 1]; + if (!lastMenu.root && !lastMenu.active) { + currentMenus.pop(); + } + } + return currentMenus; + }; + + if (getState().isOpen !== mobileMenuOpen) { + updateState({ isClosingOrOpening: true, isOpen: mobileMenuOpen, isChangingLevel: false }); + // when closed, all menus are inactive. + // when menu is opened the last menu item is set active. + // when menus was closed, there can be 1-3 menus still stored + // and this way the previous state is restored. + if (mobileMenuOpen) { + modifyMenuLevels({ activateLast: true }); + } + } + + const getActiveMenuIndex = () => { + return getMenuLevels().findIndex((level) => level.active); + }; const getMenuPositionStyle = () => { - const activeMenuIndex = getActiveMenus().length - 1; + const menuPositions: Record = { + 0: 'left0', + 1: 'left100', + 2: 'left200', + }; + const { isClosingOrOpening, isOpen } = getState(); + // when opening / closing the menu should be positioned to the last + const activeMenuIndex = isClosingOrOpening || !isOpen ? getMenuLevels().length - 1 : getActiveMenuIndex(); return styles[menuPositions[activeMenuIndex]]; }; const isMenuActive = (index: number) => { - const menuItem = selectedMenuLevels[index]; + const menuItem = getMenuLevels()[index]; return !!(menuItem && menuItem.active); }; // Menu is current when it is the last active one const isMenuCurrent = (index: number) => { - return isMenuActive(index) && getActiveMenus().length - 1 === index; + return isMenuActive(index) && getActiveMenuIndex() === index; }; const isAnimating = () => { - return selectedMenuLevels.some((level) => level.animating); + return getState().isChangingLevel; }; const addSelectedMenuLevel = (selectedIndex: number) => { if (selectedIndex === -1) { return; } - const parent = selectedMenuLevels[selectedMenuLevels.length - 1]; - setSelectedMenuLevels([ - ...selectedMenuLevels, - { index: selectedIndex, animating: true, active: true, root: false, key: `${parent.key}_${selectedIndex}` }, - ]); + updateMenuLevelsAndRender(modifyMenuLevels({ newMenuIndex: selectedIndex })); }; const goToPreviousMenuLevel = () => { - const last = selectedMenuLevels.pop(); - - // Last item is removed after animation is done. - // It is marked as inactive here and removed in resetMenusAfterAnimation() - setSelectedMenuLevels([ - ...selectedMenuLevels.map((item) => { - return { ...item, animating: true }; - }), - { ...last, active: false, animating: true }, - ]); + updateMenuLevelsAndRender(modifyMenuLevels({ deactivateLast: true })); }; const resetMenusAfterAnimation = () => { - const newArray = getActiveMenus(); - setSelectedMenuLevels( - newArray.map((i) => { - return { ...i, animating: false }; - }), - ); - return newArray.length; + updateMenuLevelsAndRender(modifyMenuLevels({ removeLastIfInActive: true }), true); }; const getLinksOrChildren = (parent: React.ReactElement) => { @@ -283,12 +342,12 @@ export const HeaderActionBarNavigationMenu = ({ }; const findLinkIndex = (link: React.ReactElement) => { - const linkParent = findParentElement(selectedMenuLevels); + const linkParent = findParentElement(getMenuLevels()); return linkParent ? getLinksOrChildren(linkParent).indexOf(link) : -1; }; const getLinks = (level: number) => { - const parent = findParentElement(selectedMenuLevels.slice(0, level + 1)); + const parent = findParentElement(getMenuLevels().slice(0, level + 1)); return parent ? getLinksOrChildren(parent) : []; }; @@ -296,7 +355,7 @@ export const HeaderActionBarNavigationMenu = ({ if (level === 0) { return undefined; } - return findParentElement(selectedMenuLevels.slice(0, level + 1)); + return findParentElement(getMenuLevels().slice(0, level + 1)); }; const getPreviousLink = (level: number) => { @@ -308,7 +367,7 @@ export const HeaderActionBarNavigationMenu = ({ if (level === 1) { return undefined; } - return findParentElement(selectedMenuLevels.slice(0, level)); + return findParentElement(getMenuLevels().slice(0, level)); }; const getMenuContents = (level: number) => { @@ -316,21 +375,21 @@ export const HeaderActionBarNavigationMenu = ({ links: getLinks(level), previousLink: getPreviousLink(level), activeLink: getActiveLink(level), - key: selectedMenuLevels[level].key, + key: getMenuLevels()[level].key, }; }; const getActiveLinkElement = (): HTMLAnchorElement | null => { const container = navContainerRef.current; - const activeMenuIndex = getActiveMenus().length - 1; + const activeMenuIndex = getActiveMenuIndex(); const activeMenuElement = container ? (container.childNodes[activeMenuIndex] as HTMLElement) : null; return activeMenuElement ? activeMenuElement.querySelector(`a.${styles.activeMobileLink}`) : null; }; useEffect(() => { // Set the focus to the currently active page link - if (shouldSetFocus.current) { - shouldSetFocus.current = false; + if (getState().shouldSetFocusToActiveLink) { + updateState({ shouldSetFocusToActiveLink: false }); const linkElement = getActiveLinkElement(); if (linkElement) { linkElement.focus(); @@ -338,8 +397,6 @@ export const HeaderActionBarNavigationMenu = ({ } }); - if (!mobileMenuOpen) return null; - const goDeeper = (link: React.ReactElement) => { if (isAnimating()) { return; @@ -358,17 +415,46 @@ export const HeaderActionBarNavigationMenu = ({ setMobileMenuOpen(false); }; - const menuSectionsAnimationDone = async (e: TransitionEvent) => { + const setCurrentMenuHeight = () => { + const containerElement = navContainerRef.current; + if (!containerElement) { + return; + } + // Set the height of the menu container + const renderedChildIndex = getActiveMenuIndex(); + const currentMenuSection = containerElement.children[renderedChildIndex]; + if (!currentMenuSection) { + return; + } + navContainerRef.current.style.height = `${currentMenuSection.clientHeight}px`; + }; + + const animationDone = async (e: TransitionEvent) => { + if (e.propertyName !== 'transform') { + return; + } const targetElement = e.target as HTMLElement; - if (e.propertyName === 'transform' && targetElement.firstChild.nodeName === 'SECTION') { - shouldSetFocus.current = true; - // Set the height of the menu container - const renderedChildIndex = resetMenusAfterAnimation() - 1; - const currentMenuSection = targetElement.children[renderedChildIndex]; - navContainerRef.current.style.height = `${currentMenuSection.clientHeight}px`; + const isNavContainer = targetElement === navContainerRef.current; + const isMenuElement = targetElement === navContainerRef.current.parentElement; + const { isChangingLevel, isClosingOrOpening } = getState(); + if (isNavContainer && isChangingLevel) { + updateState({ shouldSetFocusToActiveLink: true }); + setCurrentMenuHeight(); + resetMenusAfterAnimation(); + } else if (isMenuElement && isClosingOrOpening) { + updateState({ isClosingOrOpening: false }); + if (!getState().isOpen) { + updateMenuLevelsAndRender(modifyMenuLevels({ setActiveIndex: -1 }), true); + } } }; + useIsomorphicLayoutEffect(() => { + if (mobileMenuOpen) { + setCurrentMenuHeight(); + } + }, [mobileMenuOpen]); + const RenderNavigationSection = ({ links, activeLink, @@ -411,29 +497,27 @@ export const HeaderActionBarNavigationMenu = ({ ); }; + const { isClosingOrOpening } = getState(); return (
    -
    - {selectedMenuLevels.map((data, i) => { +
    + {getMenuLevels().map((data, i) => { const { links, previousLink, activeLink, key } = getMenuContents(i); const isCurrentMenu = isMenuCurrent(i); const isCurrentlyAnimating = isAnimating(); - const distanceToLast = selectedMenuLevels.length - 1 - i; + const distanceToLast = getMenuLevels().length - 1 - i; // Maximum of 2 menus can be seen at the same time and only when animating. // Otherwise only one. If there are 3 menus, then root should not be shown. const shouldBeVisible = (isCurrentlyAnimating && distanceToLast < 2) || isCurrentMenu; return ( spec renders the component and menu c >