diff --git a/frontend-next-migration/src/shared/i18n/locales/en/navbar.json b/frontend-next-migration/src/shared/i18n/locales/en/navbar.json index 0893727d4..18f66c6d3 100644 --- a/frontend-next-migration/src/shared/i18n/locales/en/navbar.json +++ b/frontend-next-migration/src/shared/i18n/locales/en/navbar.json @@ -35,5 +35,20 @@ "about": "About", "education": "Education", "prg": "PRG", - "contactUs": "Contact us" + "contactUs": "Contact us", + "learnTheGame": "Get to know the game", + "story": "Story", + "defenseHeroes": "Defense heroes", + "balanceVoting": "Balance voting", + "scoreboard": "Scoreboard", + "sarjakuvat": "Comics", + "characterDevelopment": "Character development arc", + "soulCastleFurniture": "Soul castle furniture", + "gameIcons": "Other game icons", + "gameMusic": "Game music", + "opetuspaketti": "Teaching package", + "pelitaide": "Game art", + "whatIsPrg": "What is PRG", + "altZoneHistory": "ALT Zone's history", + "developersAndDesigners": "Developers and designers" } diff --git a/frontend-next-migration/src/shared/i18n/locales/fi/navbar.json b/frontend-next-migration/src/shared/i18n/locales/fi/navbar.json index 8f22ccba6..1b8f97cc8 100644 --- a/frontend-next-migration/src/shared/i18n/locales/fi/navbar.json +++ b/frontend-next-migration/src/shared/i18n/locales/fi/navbar.json @@ -33,5 +33,20 @@ "about": "Meistä", "education": "Opetus", "prg": "PRG", - "contactUs": "Ota yhteyttä" + "contactUs": "Ota yhteyttä", + "learnTheGame": "Tutustu peliin", + "story": "Tarina", + "defenseHeroes": "Defenssisoturit", + "balanceVoting": "Balanssiäänestys", + "scoreboard": "Tulostaulukko", + "sarjakuvat": "Sarjakuvat", + "characterDevelopment": "Pelihahmojen kehityskaari", + "soulCastleFurniture": "Sielunlinnan huonekalut", + "gameIcons": "Muut pelin ikonit", + "gameMusic": "Pelimusiikki", + "opetuspaketti": "Opetuspaketti", + "pelitaide": "Pelitaide", + "whatIsPrg": "Mikä on PRG", + "altZoneHistory": "ALT Zonen historia", + "developersAndDesigners": "Kehittäjät ja suunnittelijat" } diff --git a/frontend-next-migration/src/widgets/Navbar/model/data/dropdowns.ts b/frontend-next-migration/src/widgets/Navbar/model/data/dropdowns.ts index 13584ff0f..cdea346f3 100644 --- a/frontend-next-migration/src/widgets/Navbar/model/data/dropdowns.ts +++ b/frontend-next-migration/src/widgets/Navbar/model/data/dropdowns.ts @@ -1,139 +1,134 @@ -import { AppExternalLinks } from '@/shared/appLinks/appExternalLinks'; import { - getRouteHeroDevPage, - getRouteMyClanPage, getRouteComicsPage, - getRouteGalleryPage, getRouteGameArtPage, getRouteDefenseGalleryPage, - getRouteAllClanSearchPage, - //getRouteAllFurnitureSetsPage, - getRouteAllCollectionsPage, - getRouteJoinUsPage, + getRouteAllFurnitureSetsPage, + getRouteAllMusicCollectionsPage, getRouteAboutPage, getRouteTeamPage, getRoutePrgPage, + getRouteGamePage, + getRouteComingSoonPage, } from '@/shared/appLinks/RoutePaths'; export const dropdowns = { community: [ { - elementText: 'clanpage', + elementText: 'whatIsPrg', link: { - path: getRouteMyClanPage(), + path: getRoutePrgPage(), isExternal: false, }, }, { - elementText: 'join', + elementText: 'altZoneHistory', link: { - path: getRouteJoinUsPage(), + path: getRouteAboutPage(), isExternal: false, }, }, { - elementText: 'forum', + elementText: 'developersAndDesigners', link: { - path: AppExternalLinks.reddit, - isExternal: true, + path: getRouteTeamPage(), + isExternal: false, }, }, + ], + game: [ { - elementText: 'about', + elementText: 'learnTheGame', link: { - path: getRouteAboutPage(), + path: getRouteGamePage(), isExternal: false, }, }, - ], - game: [ { - elementText: 'play', + elementText: 'story', link: { - path: AppExternalLinks.webgl, - isExternal: true, + path: getRouteComingSoonPage(), + isExternal: false, }, }, { - elementText: 'heroes', + elementText: 'defenseHeroes', link: { path: getRouteDefenseGalleryPage(), isExternal: false, }, }, { - elementText: 'development', + elementText: 'balanceVoting', link: { - path: getRouteHeroDevPage(), + path: getRouteComingSoonPage(), isExternal: false, }, }, { elementText: 'clans', link: { - path: getRouteAllClanSearchPage(), + path: getRouteComingSoonPage(), isExternal: false, }, - isDisabled: { - status: false, - reason: 'Kirjaudu ensin!', + }, + { + elementText: 'scoreboard', + link: { + path: getRouteComingSoonPage(), + isExternal: false, }, }, - // { - // elementText: 'leaderboard', - // link: { - // path: getRouteClanLeadeboardPage(), - // isExternal: false, - // }, - // }, ], gallery: [ { - elementText: 'pictures', + elementText: 'sarjakuvat', link: { - path: getRouteGalleryPage(), + path: getRouteComicsPage(), isExternal: false, }, }, { - elementText: 'comics', + elementText: 'characterDevelopment', link: { - path: getRouteComicsPage(), + path: getRouteComingSoonPage(), isExternal: false, }, }, { - elementText: 'collections', + elementText: 'soulCastleFurniture', link: { - path: getRouteAllCollectionsPage(), + path: getRouteAllFurnitureSetsPage(), isExternal: false, }, - isDisabled: { - status: false, - reason: 'Kirjaudu ensin!', + }, + { + elementText: 'gameIcons', + link: { + path: getRouteComingSoonPage(), + isExternal: false, }, }, - ], - gameart: [ { - elementText: 'gameart', + elementText: 'gameMusic', link: { - path: getRouteGameArtPage(), + path: getRouteAllMusicCollectionsPage(), isExternal: false, }, }, + ], + gameart: [ { - elementText: 'dlPackage', + elementText: 'opetuspaketti', link: { - path: AppExternalLinks.dlpackage, - isExternal: true, + path: getRouteGameArtPage(), + isExternal: false, }, }, { - elementText: 'pastversions', + elementText: 'pelitaide', link: { - path: AppExternalLinks.pastversions, - isExternal: true, + path: getRouteComingSoonPage(), + isExternal: false, }, }, ], diff --git a/frontend-next-migration/src/widgets/NavbarV3/model/data/navbarMenuDesktop.ts b/frontend-next-migration/src/widgets/NavbarV3/model/data/navbarMenuDesktop.ts index 8c4160f32..3d83fdd0a 100644 --- a/frontend-next-migration/src/widgets/NavbarV3/model/data/navbarMenuDesktop.ts +++ b/frontend-next-migration/src/widgets/NavbarV3/model/data/navbarMenuDesktop.ts @@ -4,7 +4,6 @@ import { getRouteTeamPage, getRouteMainPage, getRouteAllNewsPage, - getRouteGameArtPage, } from '@/shared/appLinks/RoutePaths'; import { NavbarBuilder } from './NavbarBuilder'; @@ -13,7 +12,7 @@ navbarBuilder.addLogo('Nav logo', img as unknown as string, getRouteMainPage()); navbarBuilder.addLink('news', getRouteAllNewsPage()); navbarBuilder.addDropDown('game', dropdowns.game); navbarBuilder.addDropDown('gallery', dropdowns.gallery); -navbarBuilder.addDropDown('education', dropdowns.gameart, getRouteGameArtPage()); +navbarBuilder.addDropDown('education', dropdowns.gameart); navbarBuilder.addDropDown('community', dropdowns.community); navbarBuilder.addLink('contactUs', getRouteTeamPage()); diff --git a/frontend-next-migration/src/widgets/NavbarV3/ui/NavbarDesktopV3/NavbarDesktop.module.scss b/frontend-next-migration/src/widgets/NavbarV3/ui/NavbarDesktopV3/NavbarDesktop.module.scss index 3dbdaec39..b3d40ccb8 100644 --- a/frontend-next-migration/src/widgets/NavbarV3/ui/NavbarDesktopV3/NavbarDesktop.module.scss +++ b/frontend-next-migration/src/widgets/NavbarV3/ui/NavbarDesktopV3/NavbarDesktop.module.scss @@ -9,14 +9,6 @@ z-index: var(--navbar-z-index); user-select: none; overflow-y: visible; - - &.collapsed { - overflow: hidden; - } - - &.noBorder { - border-bottom: none; - } } .inner { @@ -30,12 +22,6 @@ height: 110px; transition: width 0.3s ease, height 0.3s ease; position: relative; - - &.collapsed { - width: 50px; - overflow: hidden; - } - } .logoSlot { @@ -45,12 +31,6 @@ height: 92px; transition: opacity 0.3s ease, transform 0.3s ease; - &.collapsed { - opacity: 0; - transform: scaleX(0); - pointer-events: none; - } - a { display: block; position: absolute; @@ -118,6 +98,11 @@ margin-left: 6px; font-size: 18px; color: var(--white); + transition: transform 0.2s ease; + + &.chevronOpen { + transform: rotate(180deg); + } } .actions { @@ -130,12 +115,6 @@ flex: none; margin-left: auto; transition: opacity 0.3s ease, transform 0.3s ease; - - &.collapsed { - opacity: 0; - transform: translateX(20px); - pointer-events: none; - } } .iconBtn { @@ -262,44 +241,45 @@ } } -.navDropdownCenter { - position: fixed !important; - left: 0 !important; - right: 0 !important; - top: 110px !important; - transform: none !important; - width: 100% !important; - max-width: none !important; - - display: flex; - justify-content: center; - gap: 1.5rem; - padding: var(--spacing-300) !important; - +.dropdownMenu { + position: absolute; + /* the li is 60px tall and vertically centered in the 110px navbar, + so the extra 25px pushes the panel flush against the navbar's bottom edge */ + top: calc(100% + 25px); + left: 0; + z-index: var(--navbar-z-index); + min-width: 180px; + padding: var(--spacing-200); background: var(--base-card-background); - border-bottom: 2px solid var(--black); + border: 2px solid var(--black); + border-radius: var(--border-radius-lg); box-shadow: var(--m); - + display: flex; + flex-direction: column; + gap: 0; opacity: 0; visibility: hidden; - transition: opacity 0.25s ease, visibility 0.25s ease; + transform: translateY(-4px); + transition: + opacity 0.2s ease, + visibility 0.2s ease, + transform 0.2s ease; + pointer-events: none; - &.open { + &.dropdownMenuOpen { opacity: 1; visibility: visible; + transform: translateY(0); + pointer-events: auto; } } -.dropdownGroup { - display: flex; - flex-direction: column; - gap: 0; -} - -.navDropdownCenter .dropdownItem { - padding: var(--spacing-100) var(--spacing-300); +.dropdownItem { + padding: 0 var(--spacing-300); min-width: auto; - display: block; + display: flex; + align-items: center; + height: 65px; white-space: nowrap; font: var(--font-dm-bold-l); color: var(--white); diff --git a/frontend-next-migration/src/widgets/NavbarV3/ui/NavbarDesktopV3/NavbarDesktop.test.tsx b/frontend-next-migration/src/widgets/NavbarV3/ui/NavbarDesktopV3/NavbarDesktop.test.tsx new file mode 100644 index 000000000..5c1c57d7a --- /dev/null +++ b/frontend-next-migration/src/widgets/NavbarV3/ui/NavbarDesktopV3/NavbarDesktop.test.tsx @@ -0,0 +1,156 @@ +import { render, screen, fireEvent, act } from '@testing-library/react'; +import NavbarDesktop from './NavbarDesktop'; +import { NavbarBuild, ItemType } from '../../model/types'; + +jest.mock('@/shared/i18n', () => ({ + useClientTranslation: jest.fn().mockReturnValue({ + t: jest.fn((key: string) => key), + }), +})); + +jest.mock('@/entities/Auth', () => ({ + useLogoutMutation: () => [jest.fn()], + useUserPermissionsV2: () => ({ + checkPermissionFor: () => ({ isGranted: false }), + }), +})); + +jest.mock('@/features/AuthByUsername', () => ({ + LoginForm: () =>
, +})); + +jest.mock('@/features/LangSwitcher', () => ({ + LangSwitcher: () =>
, +})); + +jest.mock('@/shared/ui/AppLink/AppLink', () => ({ + AppLink: ({ children, to, isExternal, ...props }: any) => ( + + {children} + + ), + AppLinkTheme: { PRIMARY: 'primary' }, +})); + +jest.mock('@fortawesome/react-fontawesome', () => ({ + FontAwesomeIcon: () => , +})); + +jest.mock('@/shared/lib/hooks/useDropdownManager', () => ({ + useDropdownManager: () => ({ + state: { isOpen: false, isToggled: false }, + actions: { toggle: jest.fn(), reset: jest.fn() }, + }), +})); + +const makeNavbarBuild = (): NavbarBuild => ({ + namedMenu: {}, + menu: [ + { + name: 'Nav logo', + src: 'logo.png', + path: '/', + type: ItemType.navLogo, + }, + { + name: 'news', + path: '/news', + type: ItemType.navLink, + }, + { + name: 'contactUs', + path: '/team', + type: ItemType.navLink, + }, + { + name: 'game', + type: ItemType.navDropDown, + elements: [ + { + elementText: 'learnTheGame', + link: { path: '/peli', isExternal: false }, + }, + { + elementText: 'scoreboard', + link: { path: '/leaderboard', isExternal: false }, + }, + ], + }, + ], +}); + +describe('NavbarDesktopV3', () => { + const navbarBuild = makeNavbarBuild(); + + test('renders nav element', () => { + render(); + expect(screen.getByRole('navigation', { name: 'Nav menu' })).toBeInTheDocument(); + }); + + test('renders top-level triggers', () => { + render(); + expect(screen.getByText('game')).toBeInTheDocument(); + expect(screen.getByText('news')).toBeInTheDocument(); + expect(screen.getByText('contactUs')).toBeInTheDocument(); + }); + + test('dropdown opens on hover and closes on leave', () => { + render(); + const gameTrigger = screen.getByText('game').closest('li')!; + const dropdown = document.getElementById('navbar-dropdown-game')!; + + expect(dropdown).not.toHaveClass('dropdownMenuOpen'); + + fireEvent.mouseEnter(gameTrigger); + expect(dropdown).toHaveClass('dropdownMenuOpen'); + expect(screen.getByText('learnTheGame')).toBeInTheDocument(); + expect(screen.getByText('scoreboard')).toBeInTheDocument(); + + jest.useFakeTimers(); + act(() => { + fireEvent.mouseLeave(gameTrigger); + }); + act(() => { + jest.advanceTimersByTime(300); + }); + expect(dropdown).not.toHaveClass('dropdownMenuOpen'); + jest.useRealTimers(); + }); + + test('escape closes the dropdown', () => { + render(); + const gameTrigger = screen.getByText('game').closest('li')!; + fireEvent.mouseEnter(gameTrigger); + + const dropdown = document.getElementById('navbar-dropdown-game')!; + expect(dropdown).toHaveClass('dropdownMenuOpen'); + + fireEvent.keyDown(document, { key: 'Escape' }); + expect(dropdown).not.toHaveClass('dropdownMenuOpen'); + }); + + test('applies marginTop style when provided', () => { + const { container } = render( + , + ); + const nav = container.querySelector('nav')!; + expect(nav).toHaveStyle({ marginTop: '40px' }); + }); + + test('applies custom className', () => { + const { container } = render( + , + ); + const nav = container.querySelector('nav')!; + expect(nav.className).toContain('myClass'); + }); +}); diff --git a/frontend-next-migration/src/widgets/NavbarV3/ui/NavbarDesktopV3/NavbarDesktop.tsx b/frontend-next-migration/src/widgets/NavbarV3/ui/NavbarDesktopV3/NavbarDesktop.tsx index 623e3b8db..73c90be57 100644 --- a/frontend-next-migration/src/widgets/NavbarV3/ui/NavbarDesktopV3/NavbarDesktop.tsx +++ b/frontend-next-migration/src/widgets/NavbarV3/ui/NavbarDesktopV3/NavbarDesktop.tsx @@ -1,5 +1,5 @@ 'use client'; -import { CSSProperties, memo, useMemo, useState } from 'react'; +import { CSSProperties, memo, useCallback, useEffect, useRef, useState } from 'react'; import Image from 'next/image'; import { classNames } from '@/shared/lib/classNames/classNames'; import { useClientTranslation } from '@/shared/i18n'; @@ -15,23 +15,71 @@ import searchIcon from '@/shared/assets/icons/search.png'; import { faChevronDown } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -const CHEVRON_ITEMS = new Set(['game', 'gallery', 'gameart', 'community']); +const CHEVRON_ITEMS = new Set(['game', 'gallery', 'education', 'community']); export interface NavbarProps { - /** Pushes the bar down by this many pixels. */ + /** Adds some space above the navbar, useful when it would otherwise sit right at the top. */ marginTop?: number; className?: string; - /** The menu structure (links, dropdowns, logo). */ + /** The full menu setup — links, dropdowns, and the logo. */ navbarBuild: NavbarBuild; - /** Collapse into a thin strip when true. */ - isCollapsed?: boolean; } -/** Desktop navbar — hover dropdowns, auth/lang toggles, collapse support. */ +/** Desktop navbar — dropdowns open on hover, auth/lang toggles. */ const NavbarDesktop = memo((props: NavbarProps) => { - const { navbarBuild, marginTop, className = '', isCollapsed = false } = props; + const { navbarBuild, marginTop, className = '' } = props; + + const [hoveredItem, setHoveredItem] = useState(null); + const closeTimerRef = useRef | null>(null); + const navRef = useRef(null); + + const clearCloseTimer = useCallback(() => { + if (closeTimerRef.current) { + clearTimeout(closeTimerRef.current); + closeTimerRef.current = null; + } + }, []); + + const closeDropdown = useCallback(() => { + clearCloseTimer(); + setHoveredItem(null); + }, [clearCloseTimer]); + + useEffect(() => { + if (!hoveredItem) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') closeDropdown(); + }; + const handleClickOutside = (event: MouseEvent) => { + if (navRef.current && !navRef.current.contains(event.target as Node)) { + closeDropdown(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('keydown', handleKeyDown); + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [hoveredItem, closeDropdown]); + + const handleItemEnter = useCallback( + (name: string) => { + clearCloseTimer(); + setHoveredItem(name); + }, + [clearCloseTimer], + ); + + const handleItemLeave = useCallback(() => { + closeTimerRef.current = setTimeout(() => setHoveredItem(null), 200); + }, []); + + const handleNavMouseEnter = useCallback(() => clearCloseTimer(), [clearCloseTimer]); + const handleNavMouseLeave = useCallback(() => handleItemLeave(), [handleItemLeave]); - const [isMouseOver, setIsMouseOver] = useState(false); const { checkPermissionFor } = useUserPermissionsV2(); const permissionToLogin = checkPermissionFor('login'); const permissionToLogout = checkPermissionFor('logout'); @@ -43,52 +91,29 @@ const NavbarDesktop = memo((props: NavbarProps) => { const langDropdown = useDropdownManager(); const style = marginTop ? ({ marginTop: `${marginTop}px` } as CSSProperties) : {}; - const nonLogoItems = navbarBuild.menu.filter((item) => item.type !== 'navLogo'); - const allDropdownElements = useMemo( - () => - navbarBuild.menu - .filter((item) => item.type === 'navDropDown') - .map((item) => ({ - name: item.name, - elements: item.elements, - })), - [navbarBuild], - ); - const handleDropdownClick = (dropdown: 'auth' | 'lang') => { if (dropdown === 'auth') { authDropdown.actions.toggle(); - if (!authDropdown.state.isToggled) { - langDropdown.actions.reset(); - } + if (!authDropdown.state.isToggled) langDropdown.actions.reset(); } else { langDropdown.actions.toggle(); - if (!langDropdown.state.isToggled) { - authDropdown.actions.reset(); - } + if (!langDropdown.state.isToggled) authDropdown.actions.reset(); } }; return ( ); });