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}
+
+
+ ))}