From f9cdb06632f0b4da5237a47a9618fe6eb0f6c0dc Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Thu, 7 May 2026 12:54:53 +0300 Subject: [PATCH] refactor: SidebarNav uses usePathname and Link instead of IntersectionObserver --- src/widgets/Navigation/ui/SidebarNav.test.tsx | 47 ++++++++--- src/widgets/Navigation/ui/SidebarNav.tsx | 81 +++++++------------ 2 files changed, 66 insertions(+), 62 deletions(-) diff --git a/src/widgets/Navigation/ui/SidebarNav.test.tsx b/src/widgets/Navigation/ui/SidebarNav.test.tsx index 4716a7e..10d16f3 100644 --- a/src/widgets/Navigation/ui/SidebarNav.test.tsx +++ b/src/widgets/Navigation/ui/SidebarNav.test.tsx @@ -2,21 +2,23 @@ import { render, screen } from '@testing-library/react'; import type { NavItem } from '../model/types'; import { SidebarNav } from './SidebarNav'; +vi.mock('next/navigation', () => ({ + usePathname: vi.fn(), +})); + +import { usePathname } from 'next/navigation'; + const ITEMS: NavItem[] = [ { id: 'bio', label: 'Bio', number: '01' }, { id: 'work', label: 'Work', number: '02' }, ]; -beforeEach(() => { - global.IntersectionObserver = class { - observe = vi.fn(); - disconnect = vi.fn(); - unobserve = vi.fn(); - } as unknown as typeof IntersectionObserver; -}); - describe('SidebarNav', () => { describe('rendering', () => { + beforeEach(() => { + vi.mocked(usePathname).mockReturnValue('/bio'); + }); + it('renders a nav element', () => { render(); expect(screen.getByRole('navigation')).toBeInTheDocument(); @@ -50,10 +52,33 @@ describe('SidebarNav', () => { expect(screen.getByRole('link', { name: 'Email' })).toBeInTheDocument(); }); - it('renders a button for each item', () => { + it('renders a link for each nav item', () => { render(); - const buttons = screen.getAllByRole('button'); - expect(buttons.length).toBeGreaterThanOrEqual(ITEMS.length); + expect(screen.getByRole('link', { name: /Bio/i })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /Work/i })).toBeInTheDocument(); + }); + }); + + describe('active state', () => { + it('marks matching pathname item as active (no opacity-40)', () => { + vi.mocked(usePathname).mockReturnValue('/bio'); + render(); + const activeLink = screen.getByRole('link', { name: /Bio/i }); + expect(activeLink).not.toHaveClass('opacity-40'); + }); + + it('marks non-matching item as inactive (opacity-40)', () => { + vi.mocked(usePathname).mockReturnValue('/bio'); + render(); + const inactiveLink = screen.getByRole('link', { name: /Work/i }); + expect(inactiveLink).toHaveClass('opacity-40'); + }); + + it('marks first item active at root path', () => { + vi.mocked(usePathname).mockReturnValue('/'); + render(); + const firstLink = screen.getByRole('link', { name: /Bio/i }); + expect(firstLink).not.toHaveClass('opacity-40'); }); }); }); diff --git a/src/widgets/Navigation/ui/SidebarNav.tsx b/src/widgets/Navigation/ui/SidebarNav.tsx index f11158e..ab1aa2c 100644 --- a/src/widgets/Navigation/ui/SidebarNav.tsx +++ b/src/widgets/Navigation/ui/SidebarNav.tsx @@ -1,6 +1,7 @@ 'use client'; -import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; import { CONTACT_LINKS, cn } from '$shared/lib'; import type { NavItem } from '../model/types'; @@ -13,41 +14,23 @@ interface Props { /** * Fixed sidebar navigation, visible on lg+ screens. + * Active section determined by current URL pathname. */ export function SidebarNav({ items }: Props) { - const [activeSection, setActiveSection] = useState('bio'); - - useEffect(() => { - const observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - setActiveSection(entry.target.id); - } - }); - }, - { rootMargin: '-20% 0px -70% 0px', threshold: 0 }, - ); - - items.forEach((item) => { - const el = document.getElementById(item.id); - if (el) { - observer.observe(el); - } - }); - - return () => observer.disconnect(); - }, [items]); + const pathname = usePathname(); /** - * Scrolls to the section by id with a 40px offset. + * An item is active when its slug matches the current pathname, + * or when the pathname is root and it is the first item. */ - function scrollToSection(id: string) { - const el = document.getElementById(id); - if (el) { - const top = el.getBoundingClientRect().top + window.scrollY - 40; - window.scrollTo({ top, behavior: 'smooth' }); + function isActive(item: NavItem): boolean { + if (pathname === `/${item.id}`) { + return true; } + if (pathname === '/' && items[0]?.id === item.id) { + return true; + } + return false; } return ( @@ -60,27 +43,23 @@ export function SidebarNav({ items }: Props) { - {items.map((item) => { - const isActive = activeSection === item.id; - return ( - - ); - })} + {items.map((item) => ( + +
+ {item.number} + {item.label} +
+ + ))}

Quick Links