From 5c1a53db18ecd5dae056393f94efc1b70518c30c Mon Sep 17 00:00:00 2001 From: Henri Kuokkanen Date: Wed, 13 Nov 2024 16:14:51 +0200 Subject: [PATCH] OPHJOD-937: Close header header/popup-menus if clicked outside of element --- src/components/LanguageMenu/LanguageMenu.tsx | 17 ++-- src/components/MegaMenu/MegaMenu.tsx | 9 +- .../NavigationBar/NavigationBar.test.tsx | 12 ++- .../NavigationBar/NavigationBar.tsx | 15 +-- .../__snapshots__/NavigationBar.test.tsx.snap | 76 +++++++++------ .../OpportunityCard/OpportunityCard.tsx | 25 +++-- src/components/UserButton/UserButton.tsx | 85 +++++++++++++---- src/hooks/useMenuClickHandler/index.ts | 31 +++++++ src/routes/Root/Root.tsx | 93 ++++++------------- 9 files changed, 219 insertions(+), 144 deletions(-) create mode 100644 src/hooks/useMenuClickHandler/index.ts diff --git a/src/components/LanguageMenu/LanguageMenu.tsx b/src/components/LanguageMenu/LanguageMenu.tsx index 97b5cc34..661fd26b 100644 --- a/src/components/LanguageMenu/LanguageMenu.tsx +++ b/src/components/LanguageMenu/LanguageMenu.tsx @@ -1,6 +1,7 @@ import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes'; import { langLabels, supportedLanguageCodes } from '@/i18n/config'; import { PopupList, cx } from '@jod/design-system'; +import { forwardRef } from 'react'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; @@ -30,12 +31,14 @@ const ListItems = ({ onClick }: { onClick: LanguageMenuProps['onClick'] }) => { )); }; -export const LanguageMenu = ({ inline, onClick }: LanguageMenuProps) => { - return inline ? ( - +export const LanguageMenu = forwardRef(function LanguageMenuWithRef(props, ref) { + return props.inline ? ( + ) : ( - - - +
+ + + +
); -}; +}); diff --git a/src/components/MegaMenu/MegaMenu.tsx b/src/components/MegaMenu/MegaMenu.tsx index 52d2c5c3..da4a81cf 100644 --- a/src/components/MegaMenu/MegaMenu.tsx +++ b/src/components/MegaMenu/MegaMenu.tsx @@ -1,5 +1,4 @@ import { LanguageButton, LanguageMenu, RoutesNavigationList, SimpleNavigationList, UserButton } from '@/components'; -import { NavigationBarProps } from '@/components/NavigationBar/NavigationBar'; import { useAppRoutes } from '@/hooks/useAppRoutes'; import { useEnvironment } from '@/hooks/useEnvironment'; import { useMediaQueries } from '@jod/design-system'; @@ -8,14 +7,14 @@ import { useTranslation } from 'react-i18next'; import { MdClose, MdKeyboardBackspace, MdOutlineCancel } from 'react-icons/md'; export { LanguageButton, UserButton } from '@/components'; -type MegaMenuProps = { +interface MegaMenuProps { loggedIn: boolean; onClose: () => void; onLanguageClick: () => void; logout: () => void; -} & Pick; +} -export const MegaMenu = ({ loggedIn, onClose, onLanguageClick, user, logout }: MegaMenuProps) => { +export const MegaMenu = ({ loggedIn, onClose, onLanguageClick, logout }: MegaMenuProps) => { const { sm } = useMediaQueries(); const { t } = useTranslation(); const [megaMenuState, setMegaMenuState] = React.useState<'main' | 'lang'>('main'); @@ -53,7 +52,7 @@ export const MegaMenu = ({ loggedIn, onClose, onLanguageClick, user, logout }: M
  • - +
  • )} diff --git a/src/components/NavigationBar/NavigationBar.test.tsx b/src/components/NavigationBar/NavigationBar.test.tsx index 433932b2..7b19dbe2 100644 --- a/src/components/NavigationBar/NavigationBar.test.tsx +++ b/src/components/NavigationBar/NavigationBar.test.tsx @@ -12,11 +12,15 @@ vi.mock('react-router-dom', () => ({ useLocation: () => ({ pathname: 'callback-url', }), + useLoaderData: () => ({ + etunimi: 'Reetta', + sukunimi: 'Räppänä', + csrf: 'not null', + }), })); describe('NavigationBar', () => { vi.mocked(useMediaQueries).mockReturnValue({ sm: true, md: false, lg: false, xl: false }); - const logo =
    logo
    ; const user = { @@ -31,13 +35,11 @@ describe('NavigationBar', () => { const onLanguageClick = vi.fn(); it('renders only user', () => { - const { container } = render(); - + const { container } = render(); // Assert snapshot expect(container.firstChild).toMatchSnapshot(); - // Assert user - const userAvatar = screen.queryByTitle(user.name); + const userAvatar = screen.queryByLabelText(user.name); expect(userAvatar).toBeInTheDocument(); }); diff --git a/src/components/NavigationBar/NavigationBar.tsx b/src/components/NavigationBar/NavigationBar.tsx index 19cc3ea8..cfedeeb7 100644 --- a/src/components/NavigationBar/NavigationBar.tsx +++ b/src/components/NavigationBar/NavigationBar.tsx @@ -1,5 +1,6 @@ import { LanguageButton, UserButton } from '@/components'; import { useMediaQueries } from '@jod/design-system'; +import React from 'react'; export interface NavigationBarLinkProps { className?: string; @@ -17,17 +18,17 @@ export interface NavigationBarProps { menuComponent?: React.ReactNode; /** For language selection button **/ onLanguageClick: () => void; - /** Navigation avatar */ - user?: { - name: string; - component: NavigationBarLink; + + /** HTML Element refs */ + refs?: { + langMenuButtonRef: React.RefObject; }; } /** * This component is a navigation bar that displays a logo, and an avatar. */ -export const NavigationBar = ({ logo, menuComponent, onLanguageClick, user }: NavigationBarProps) => { +export const NavigationBar = ({ logo, menuComponent, onLanguageClick, refs }: NavigationBarProps) => { const { sm } = useMediaQueries(); return ( @@ -41,11 +42,11 @@ export const NavigationBar = ({ logo, menuComponent, onLanguageClick, user }: Na {menuComponent &&
  • {menuComponent}
  • } {sm && ( <> -
  • +
  • - +
  • )} diff --git a/src/components/NavigationBar/__snapshots__/NavigationBar.test.tsx.snap b/src/components/NavigationBar/__snapshots__/NavigationBar.test.tsx.snap index dd4daadd..39dbbf14 100644 --- a/src/components/NavigationBar/__snapshots__/NavigationBar.test.tsx.snap +++ b/src/components/NavigationBar/__snapshots__/NavigationBar.test.tsx.snap @@ -41,29 +41,33 @@ exports[`NavigationBar > renders no navigation items and no user 1`] = `
  • - - - + + +
  • @@ -111,15 +115,33 @@ exports[`NavigationBar > renders only user 1`] = `
  • - - RR - + + +
  • diff --git a/src/components/OpportunityCard/OpportunityCard.tsx b/src/components/OpportunityCard/OpportunityCard.tsx index 95ce9467..ec18b484 100644 --- a/src/components/OpportunityCard/OpportunityCard.tsx +++ b/src/components/OpportunityCard/OpportunityCard.tsx @@ -1,6 +1,7 @@ import { components } from '@/api/schema'; import { ActionButton, FavoriteToggle, LoginModal } from '@/components'; import { useEnvironment } from '@/hooks/useEnvironment'; +import { useMenuClickHandler } from '@/hooks/useMenuClickHandler'; import { MahdollisuusTyyppi } from '@/routes/types'; import { cx, PopupList, PopupListItem } from '@jod/design-system'; import React from 'react'; @@ -59,19 +60,23 @@ const MoreActionsDropdown = ({ compareTo }: { compareTo?: To }) => { const [open, setOpen] = React.useState(false); const listId = React.useId(); + const actionButtonRef = React.useRef(null); const onClose = React.useCallback(() => setOpen(false), []); + const actionMenuRef = useMenuClickHandler(() => setOpen(false), actionButtonRef); return ( -
    - } - aria-controls={listId} - aria-expanded={open} - aria-haspopup="listbox" - className={open ? 'text-accent' : ''} - onClick={() => setOpen(!open)} - /> +
    +
    + } + aria-controls={listId} + aria-expanded={open} + aria-haspopup="listbox" + className={open ? 'text-accent' : ''} + onClick={() => setOpen(!open)} + /> +
    {open && ( /* Preventing the click through of the wrapper
    if not able to click exactly at the list items */ /* eslint-disable jsx-a11y/click-events-have-key-events */ diff --git a/src/components/UserButton/UserButton.tsx b/src/components/UserButton/UserButton.tsx index 9732869b..5aedc3b7 100644 --- a/src/components/UserButton/UserButton.tsx +++ b/src/components/UserButton/UserButton.tsx @@ -1,27 +1,76 @@ -import { NavigationBarProps } from '@/components/NavigationBar/NavigationBar'; +import { components } from '@/api/schema'; import { useLoginLink } from '@/hooks/useLoginLink'; +import { useMenuClickHandler } from '@/hooks/useMenuClickHandler'; +import { useToolStore } from '@/stores/useToolStore'; +import { PopupList, PopupListItem, useMediaQueries } from '@jod/design-system'; +import React from 'react'; import { useTranslation } from 'react-i18next'; import { MdOutlinePerson } from 'react-icons/md'; +import { NavLink, useLoaderData } from 'react-router-dom'; + +export const UserButton = () => { + const { + t, + i18n: { language }, + } = useTranslation(); -export const UserButton = ({ user }: Pick) => { - const { t } = useTranslation(); const loginLink = useLoginLink(); + const { sm } = useMediaQueries(); + const data = useLoaderData() as components['schemas']['YksiloCsrfDto'] | null; + + const [userMenuOpen, setUserMenuOpen] = React.useState(false); + const userMenuButtonRef = React.useRef(null); + const userMenuRef = useMenuClickHandler(() => setUserMenuOpen(false), userMenuButtonRef); + + const logoutForm = React.useRef(null); + const toolStore = useToolStore(); + + const logout = () => { + toolStore.reset(); + logoutForm.current?.submit(); + }; + + const userMenuPreferencesUrl = `${t('slugs.profile.index')}/${t('slugs.profile.preferences')}`; + + // Highlight menu element when active + const getActiveClassNames = ({ isActive }: { isActive: boolean }) => (isActive ? 'bg-secondary-1-50 rounded-sm' : ''); + const login = { url: loginLink, text: t('login') }; - const initials = user?.name - .split(' ') - .map((part) => part[0]) - .splice(0, 2) - .join('') - .toUpperCase(); - - return user ? ( - - {initials} - + const fullName = `${data?.etunimi} ${data?.sukunimi}`; + const initials = !!data?.etunimi && !!data?.sukunimi ? data.etunimi[0] + data.sukunimi[0] : ''; + + return data?.csrf ? ( +
    + + + {sm && userMenuOpen && ( +
    + + setUserMenuOpen(false)} + className={(props) => `w-full ${getActiveClassNames(props)}`.trim()} + > + {t('profile.index')} + + + +
    + )} +
    ) : ( void, + menuButtonRef: + | React.RefObject + | React.RefObject + | React.RefObject + | null, +) => { + const ref = React.useRef(null); + + React.useEffect(() => { + const clickHandler = (event: MouseEvent) => { + if ( + ref.current && + !ref.current.contains(event.target as Node) && + menuButtonRef?.current && + !menuButtonRef.current.contains(event.target as Node) + ) { + handleOutsideClick(event); + } + }; + document.addEventListener('mousedown', clickHandler, true); + return () => { + document.removeEventListener('mousedown', clickHandler, true); + }; + }, [ref, handleOutsideClick, menuButtonRef]); + + return ref; +}; diff --git a/src/routes/Root/Root.tsx b/src/routes/Root/Root.tsx index bd4e98ba..d62f2ff0 100644 --- a/src/routes/Root/Root.tsx +++ b/src/routes/Root/Root.tsx @@ -2,10 +2,10 @@ import { components } from '@/api/schema'; import { LanguageMenu, LogoIconRgb, LogoRgbEn, LogoRgbFi, LogoRgbSv, NavigationBar } from '@/components'; import { ErrorNote, useErrorNote } from '@/components/ErrorNote'; import { MegaMenu } from '@/components/MegaMenu/MegaMenu'; -import { NavigationBarProps } from '@/components/NavigationBar/NavigationBar'; import { ActionBarContext } from '@/hooks/useActionBar'; +import { useMenuClickHandler } from '@/hooks/useMenuClickHandler'; import { useToolStore } from '@/stores/useToolStore'; -import { Footer, PopupList, PopupListItem, SkipLink, useMediaQueries } from '@jod/design-system'; +import { Footer, SkipLink, useMediaQueries } from '@jod/design-system'; import React from 'react'; import { Helmet } from 'react-helmet-async'; import { useTranslation } from 'react-i18next'; @@ -55,7 +55,6 @@ const Root = () => { const { sm } = useMediaQueries(); const [megaMenuOpen, setMegaMenuOpen] = React.useState(false); - const [userMenuOpen, setUserMenuOpen] = React.useState(false); const [langMenuOpen, setLangMenuOpen] = React.useState(false); const userGuide = t('slugs.user-guide.index'); @@ -68,6 +67,13 @@ const Root = () => { NavigationBarItem(`${basicInformation}/${t('slugs.accessibility-statement')}`, t('accessibility-statement')), NavigationBarItem(`${basicInformation}/${t('slugs.privacy-policy')}`, t('privacy-policy')), ]; + + const megaMenuButtonRef = React.useRef(null); + const langMenuButtonRef = React.useRef(null); + + const megaMenuRef = useMenuClickHandler(() => setMegaMenuOpen(false), megaMenuButtonRef); + const langMenuRef = useMenuClickHandler(() => setLangMenuOpen(false), langMenuButtonRef); + const logos = React.useMemo(() => { switch (language) { case 'sv': @@ -135,28 +141,17 @@ const Root = () => { logoutForm.current?.submit(); }; - const profileIndexPath = t('slugs.profile.index'); - const userMenuUrls = { - preferences: `${profileIndexPath}/${t('slugs.profile.preferences')}`, - }; - const footerRef = React.useRef(null); const logoutForm = React.useRef(null); - const getActiveClassNames = ({ isActive }: { isActive: boolean }) => (isActive ? 'bg-secondary-1-50 rounded-sm' : ''); - const name = `${data?.etunimi} ${data?.sukunimi}`; - - const toggleMenu = (menu: 'mega' | 'user' | 'lang') => () => { + const toggleMenu = (menu: 'mega' | 'lang') => () => { setMegaMenuOpen(false); - setUserMenuOpen(false); setLangMenuOpen(false); + switch (menu) { case 'mega': setMegaMenuOpen(!megaMenuOpen); break; - case 'user': - setUserMenuOpen(!userMenuOpen); - break; case 'lang': setLangMenuOpen(!langMenuOpen); break; @@ -168,45 +163,6 @@ const Root = () => { setMegaMenuOpen(false); }; - const getUserData: () => NavigationBarProps['user'] = () => - data?.csrf && { - name, - /* eslint-disable-next-line sonarjs/no-unstable-nested-components */ - component: ({ children, className }) => { - return ( -
    - - - {sm && userMenuOpen && ( -
    - - setUserMenuOpen(false)} - className={(props) => `w-full ${getActiveClassNames(props)}`.trim()} - > - {t('profile.index')} - - - -
    - )} -
    - ); - }, - }; - return ( <> @@ -232,6 +188,7 @@ const Root = () => { className="flex gap-4 justify-center items-center select-none" aria-label={t('open-menu')} onClick={toggleMenu('mega')} + ref={megaMenuButtonRef} > {t('menu')} @@ -239,7 +196,12 @@ const Root = () => { ) : ( - ) } - user={getUserData()} + refs={{ langMenuButtonRef: langMenuButtonRef }} /> {langMenuOpen && (
    -
    +
    )} {error && } {megaMenuOpen && ( - setMegaMenuOpen(false)} - onLanguageClick={changeLanguage} - /> +
    + setMegaMenuOpen(false)} + onLanguageClick={changeLanguage} + /> +
    )}