From 203712b5189fff5e5e95a48001c92e9383651ff4 Mon Sep 17 00:00:00 2001 From: Casimir43 Date: Tue, 28 Jul 2026 22:11:29 +0300 Subject: [PATCH 1/8] feat(nav): add per-item dropdown --- .../ui/NavbarDesktopV3/NavbarDesktop.tsx | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) 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..e3effb15a 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, useMemo, useRef, useState } from 'react'; import Image from 'next/image'; import { classNames } from '@/shared/lib/classNames/classNames'; import { useClientTranslation } from '@/shared/i18n'; @@ -32,6 +32,30 @@ const NavbarDesktop = memo((props: NavbarProps) => { const { navbarBuild, marginTop, className = '', isCollapsed = false } = props; const [isMouseOver, setIsMouseOver] = useState(false); + const [hoveredItem, setHoveredItem] = useState(null); + const closeTimerRef = useRef | null>(null); + + const clearCloseTimer = useCallback(() => { + if (closeTimerRef.current) { + clearTimeout(closeTimerRef.current); + closeTimerRef.current = null; + } + }, []); + + const handleItemEnter = useCallback( + (name: string) => { + clearCloseTimer(); + setHoveredItem(name); + }, + [clearCloseTimer], + ); + + const handleItemLeave = useCallback(() => { + closeTimerRef.current = setTimeout(() => { + setHoveredItem(null); + }, 200); + }, []); + const { checkPermissionFor } = useUserPermissionsV2(); const permissionToLogin = checkPermissionFor('login'); const permissionToLogout = checkPermissionFor('logout'); From dfa79a1234f008372319787fb27d2e5d9352fe97 Mon Sep 17 00:00:00 2001 From: Casimir43 Date: Wed, 29 Jul 2026 15:24:59 +0300 Subject: [PATCH 2/8] refactor(nav): replace shared dropdown bar with per-item dropdown menus --- .../NavbarDesktopV3/NavbarDesktop.module.scss | 57 +++++---- .../ui/NavbarDesktopV3/NavbarDesktop.tsx | 118 ++++++++++-------- 2 files changed, 91 insertions(+), 84 deletions(-) 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..a50ee694f 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 @@ -13,10 +13,6 @@ &.collapsed { overflow: hidden; } - - &.noBorder { - border-bottom: none; - } } .inner { @@ -118,6 +114,11 @@ margin-left: 6px; font-size: 18px; color: var(--white); + transition: transform 0.2s ease; + + &.chevronOpen { + transform: rotate(180deg); + } } .actions { @@ -262,41 +263,39 @@ } } -.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; + top: 100%; + left: 0; + z-index: var(--navbar-z-index); + min-width: 180px; + padding: var(--spacing-200); + margin-top: var(--spacing-100); 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 { +.dropdownItem { padding: var(--spacing-100) var(--spacing-300); min-width: auto; display: block; 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 e3effb15a..0d6618c74 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, useCallback, useMemo, useRef, useState } from 'react'; +import { CSSProperties, memo, useCallback, useRef, useState } from 'react'; import Image from 'next/image'; import { classNames } from '@/shared/lib/classNames/classNames'; import { useClientTranslation } from '@/shared/i18n'; @@ -31,7 +31,6 @@ export interface NavbarProps { const NavbarDesktop = memo((props: NavbarProps) => { const { navbarBuild, marginTop, className = '', isCollapsed = false } = props; - const [isMouseOver, setIsMouseOver] = useState(false); const [hoveredItem, setHoveredItem] = useState(null); const closeTimerRef = useRef | null>(null); @@ -56,6 +55,14 @@ const NavbarDesktop = memo((props: NavbarProps) => { }, 200); }, []); + const handleNavMouseEnter = useCallback(() => { + clearCloseTimer(); + }, [clearCloseTimer]); + + const handleNavMouseLeave = useCallback(() => { + handleItemLeave(); + }, [handleItemLeave]); + const { checkPermissionFor } = useUserPermissionsV2(); const permissionToLogin = checkPermissionFor('login'); const permissionToLogout = checkPermissionFor('logout'); @@ -70,17 +77,6 @@ const NavbarDesktop = memo((props: NavbarProps) => { 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(); @@ -97,15 +93,11 @@ const NavbarDesktop = memo((props: NavbarProps) => { return ( ); }); From 67839de5c7a23ef792efbb041e49433ab3e31fda Mon Sep 17 00:00:00 2001 From: Casimir43 Date: Thu, 30 Jul 2026 19:20:35 +0300 Subject: [PATCH 3/8] feat(nav): add Escape key and outside-click to close dropdowns --- .../ui/NavbarDesktopV3/NavbarDesktop.tsx | 65 ++++++++++--------- 1 file changed, 35 insertions(+), 30 deletions(-) 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 0d6618c74..0df2e523a 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, useCallback, useRef, 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'; @@ -18,21 +18,18 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; const CHEVRON_ITEMS = new Set(['game', 'gallery', 'gameart', 'community']); export interface NavbarProps { - /** Pushes the bar down by this many pixels. */ marginTop?: number; className?: string; - /** The menu structure (links, dropdowns, logo). */ navbarBuild: NavbarBuild; - /** Collapse into a thin strip when true. */ isCollapsed?: boolean; } -/** Desktop navbar — hover dropdowns, auth/lang toggles, collapse support. */ const NavbarDesktop = memo((props: NavbarProps) => { const { navbarBuild, marginTop, className = '', isCollapsed = false } = props; const [hoveredItem, setHoveredItem] = useState(null); const closeTimerRef = useRef | null>(null); + const navRef = useRef(null); const clearCloseTimer = useCallback(() => { if (closeTimerRef.current) { @@ -41,6 +38,31 @@ const NavbarDesktop = memo((props: NavbarProps) => { } }, []); + 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(); @@ -50,18 +72,11 @@ const NavbarDesktop = memo((props: NavbarProps) => { ); const handleItemLeave = useCallback(() => { - closeTimerRef.current = setTimeout(() => { - setHoveredItem(null); - }, 200); + closeTimerRef.current = setTimeout(() => setHoveredItem(null), 200); }, []); - const handleNavMouseEnter = useCallback(() => { - clearCloseTimer(); - }, [clearCloseTimer]); - - const handleNavMouseLeave = useCallback(() => { - handleItemLeave(); - }, [handleItemLeave]); + const handleNavMouseEnter = useCallback(() => clearCloseTimer(), [clearCloseTimer]); + const handleNavMouseLeave = useCallback(() => handleItemLeave(), [handleItemLeave]); const { checkPermissionFor } = useUserPermissionsV2(); const permissionToLogin = checkPermissionFor('login'); @@ -74,36 +89,28 @@ 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 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 (