From cfe50069b7f46e23af92ee0b78a19df05976c8a2 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sun, 19 Apr 2026 08:40:08 +0300 Subject: [PATCH] feat: add MobileNav, SidebarNav, and UtilityBar widgets TDD implementation of three navigation widgets: mobile overlay toggle, fixed sidebar with IntersectionObserver-driven active section tracking, and utility bar with contact info and CV download action. --- src/widgets/Navigation/model/types.ts | 14 +++ src/widgets/Navigation/ui/MobileNav.test.tsx | 46 +++++++++ src/widgets/Navigation/ui/MobileNav.tsx | 66 +++++++++++++ src/widgets/Navigation/ui/SidebarNav.test.tsx | 62 ++++++++++++ src/widgets/Navigation/ui/SidebarNav.tsx | 94 +++++++++++++++++++ src/widgets/Navigation/ui/UtilityBar.test.tsx | 30 ++++++ src/widgets/Navigation/ui/UtilityBar.tsx | 34 +++++++ 7 files changed, 346 insertions(+) create mode 100644 src/widgets/Navigation/model/types.ts create mode 100644 src/widgets/Navigation/ui/MobileNav.test.tsx create mode 100644 src/widgets/Navigation/ui/MobileNav.tsx create mode 100644 src/widgets/Navigation/ui/SidebarNav.test.tsx create mode 100644 src/widgets/Navigation/ui/SidebarNav.tsx create mode 100644 src/widgets/Navigation/ui/UtilityBar.test.tsx create mode 100644 src/widgets/Navigation/ui/UtilityBar.tsx diff --git a/src/widgets/Navigation/model/types.ts b/src/widgets/Navigation/model/types.ts new file mode 100644 index 0000000..e23d3a7 --- /dev/null +++ b/src/widgets/Navigation/model/types.ts @@ -0,0 +1,14 @@ +export type NavItem = { + /** + * Section HTML id for anchor scrolling + */ + id: string + /** + * Display label + */ + label: string + /** + * Display number prefix (e.g. "01") + */ + number: string +} diff --git a/src/widgets/Navigation/ui/MobileNav.test.tsx b/src/widgets/Navigation/ui/MobileNav.test.tsx new file mode 100644 index 0000000..2addc9d --- /dev/null +++ b/src/widgets/Navigation/ui/MobileNav.test.tsx @@ -0,0 +1,46 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MobileNav } from './MobileNav' +import type { NavItem } from '../model/types' + +const ITEMS: NavItem[] = [{ id: 'about', label: 'About', number: '01' }] + +describe('MobileNav', () => { + describe('rendering', () => { + it('renders title "allmy.work"', () => { + render() + expect(screen.getByText('allmy.work')).toBeInTheDocument() + }) + + it('renders toggle button with text "Menu" initially', () => { + render() + expect(screen.getByRole('button', { name: 'Menu' })).toBeInTheDocument() + }) + + it('menu items are hidden initially', () => { + render() + expect(screen.queryByRole('button', { name: /about/i })).not.toBeInTheDocument() + }) + }) + + describe('interactions', () => { + it('click toggle shows item buttons and changes label to "Close"', async () => { + render() + await userEvent.click(screen.getByRole('button', { name: 'Menu' })) + expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument() + expect(screen.getByText('About')).toBeInTheDocument() + }) + + it('click item button closes the menu', async () => { + render() + await userEvent.click(screen.getByRole('button', { name: 'Menu' })) + // item button label contains number + label text; find by accessible name fragment + const itemBtn = screen.getAllByRole('button').find(b => b.textContent?.includes('About')) + expect(itemBtn).toBeDefined() + await userEvent.click(itemBtn!) + expect(screen.queryByText('Close')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Menu' })).toBeInTheDocument() + }) + }) +}) diff --git a/src/widgets/Navigation/ui/MobileNav.tsx b/src/widgets/Navigation/ui/MobileNav.tsx new file mode 100644 index 0000000..0940ed8 --- /dev/null +++ b/src/widgets/Navigation/ui/MobileNav.tsx @@ -0,0 +1,66 @@ +'use client' + +import { useState } from 'react' +import { cn } from '$shared/lib' +import type { NavItem } from '../model/types' + +interface Props { + /** + * Navigation items to render + */ + items: NavItem[] +} + +/** + * Mobile navigation overlay, hidden on lg+ screens. + */ +export function MobileNav({ items }: Props) { + const [isOpen, setIsOpen] = useState(false) + + /** + * Scrolls to the section by id with a 100px offset, then closes the menu. + */ + function scrollToSection(id: string) { + const el = document.getElementById(id) + if (el) { + const top = el.getBoundingClientRect().top + window.scrollY - 100 + window.scrollTo({ top, behavior: 'smooth' }) + } + setIsOpen(false) + } + + return ( +
+
+

allmy.work

+ +
+ {isOpen && ( +
+ {items.map(item => ( + + ))} +
+ )} +
+ ) +} diff --git a/src/widgets/Navigation/ui/SidebarNav.test.tsx b/src/widgets/Navigation/ui/SidebarNav.test.tsx new file mode 100644 index 0000000..d9f9784 --- /dev/null +++ b/src/widgets/Navigation/ui/SidebarNav.test.tsx @@ -0,0 +1,62 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import { SidebarNav } from './SidebarNav' +import type { NavItem } from '../model/types' + +const ITEMS: NavItem[] = [ + { id: 'bio', label: 'Bio', number: '01' }, + { id: 'work', label: 'Work', number: '02' }, +] + +beforeEach(() => { + global.IntersectionObserver = vi.fn(function () { + return { + observe: vi.fn(), + disconnect: vi.fn(), + unobserve: vi.fn(), + } + }) as unknown as typeof IntersectionObserver +}) + +describe('SidebarNav', () => { + describe('rendering', () => { + it('renders a nav element', () => { + render() + expect(screen.getByRole('navigation')).toBeInTheDocument() + }) + + it('renders "Index" heading', () => { + render() + expect(screen.getByText('Index')).toBeInTheDocument() + }) + + it('renders "Digital Monograph" subtitle', () => { + render() + expect(screen.getByText('Digital Monograph')).toBeInTheDocument() + }) + + it('renders each item label and number', () => { + render() + expect(screen.getByText('Bio')).toBeInTheDocument() + expect(screen.getByText('01')).toBeInTheDocument() + expect(screen.getByText('Work')).toBeInTheDocument() + expect(screen.getByText('02')).toBeInTheDocument() + }) + + it('renders "Quick Links" section', () => { + render() + expect(screen.getByText('Quick Links')).toBeInTheDocument() + }) + + it('renders Email quick link', () => { + render() + expect(screen.getByRole('link', { name: 'Email' })).toBeInTheDocument() + }) + + it('renders a button for each item', () => { + render() + const buttons = screen.getAllByRole('button') + expect(buttons.length).toBeGreaterThanOrEqual(ITEMS.length) + }) + }) +}) diff --git a/src/widgets/Navigation/ui/SidebarNav.tsx b/src/widgets/Navigation/ui/SidebarNav.tsx new file mode 100644 index 0000000..9962b03 --- /dev/null +++ b/src/widgets/Navigation/ui/SidebarNav.tsx @@ -0,0 +1,94 @@ +'use client' + +import { useState, useEffect } from 'react' +import { cn } from '$shared/lib' +import type { NavItem } from '../model/types' + +interface Props { + /** + * Navigation items to render + */ + items: NavItem[] +} + +/** + * Fixed sidebar navigation, visible on lg+ screens. + */ +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]) + + /** + * Scrolls to the section by id with a 40px offset. + */ + function scrollToSection(id: string) { + const el = document.getElementById(id) + if (el) { + const top = el.getBoundingClientRect().top + window.scrollY - 40 + window.scrollTo({ top, behavior: 'smooth' }) + } + } + + return ( + + ) +} diff --git a/src/widgets/Navigation/ui/UtilityBar.test.tsx b/src/widgets/Navigation/ui/UtilityBar.test.tsx new file mode 100644 index 0000000..5e4b99f --- /dev/null +++ b/src/widgets/Navigation/ui/UtilityBar.test.tsx @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { UtilityBar } from './UtilityBar' + +describe('UtilityBar', () => { + describe('rendering', () => { + it('renders "Contact" label', () => { + render() + expect(screen.getByText('Contact')).toBeInTheDocument() + }) + + it('renders email link with correct href', () => { + render() + const link = screen.getByRole('link', { name: 'hello@allmy.work' }) + expect(link).toBeInTheDocument() + expect(link).toHaveAttribute('href', 'mailto:hello@allmy.work') + }) + + it('renders "Download CV" button', () => { + render() + expect(screen.getByRole('button', { name: /download cv/i })).toBeInTheDocument() + }) + + it('Download CV button has primary variant class', () => { + render() + const btn = screen.getByRole('button', { name: /download cv/i }) + expect(btn).toHaveClass('bg-burnt-oxide') + }) + }) +}) diff --git a/src/widgets/Navigation/ui/UtilityBar.tsx b/src/widgets/Navigation/ui/UtilityBar.tsx new file mode 100644 index 0000000..a66bb40 --- /dev/null +++ b/src/widgets/Navigation/ui/UtilityBar.tsx @@ -0,0 +1,34 @@ +'use client' + +import { Button } from '$shared/ui' + +/** + * Fixed bottom utility bar with contact info and CV download. + */ +export function UtilityBar() { + /** + * Handles CV download action. + */ + function handleDownloadCV() { + console.log('Downloading CV...') + } + + return ( +
+
+ + +
+
+ ) +}