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.
This commit is contained in:
Ilia Mashkov
2026-04-19 08:40:08 +03:00
parent 590147adb1
commit cfe50069b7
7 changed files with 346 additions and 0 deletions
+14
View File
@@ -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
}
@@ -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(<MobileNav items={ITEMS} />)
expect(screen.getByText('allmy.work')).toBeInTheDocument()
})
it('renders toggle button with text "Menu" initially', () => {
render(<MobileNav items={ITEMS} />)
expect(screen.getByRole('button', { name: 'Menu' })).toBeInTheDocument()
})
it('menu items are hidden initially', () => {
render(<MobileNav items={ITEMS} />)
expect(screen.queryByRole('button', { name: /about/i })).not.toBeInTheDocument()
})
})
describe('interactions', () => {
it('click toggle shows item buttons and changes label to "Close"', async () => {
render(<MobileNav items={ITEMS} />)
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(<MobileNav items={ITEMS} />)
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()
})
})
})
+66
View File
@@ -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 (
<div className="lg:hidden fixed top-0 left-0 right-0 bg-ochre-clay brutal-border-bottom z-50">
<div className="px-6 py-4 flex items-center justify-between">
<h4>allmy.work</h4>
<button
onClick={() => setIsOpen(prev => !prev)}
className="brutal-border px-4 py-2 bg-carbon-black text-ochre-clay"
>
{isOpen ? 'Close' : 'Menu'}
</button>
</div>
{isOpen && (
<div className="px-6 py-6 brutal-border-top space-y-2 max-h-[80vh] overflow-y-auto">
{items.map(item => (
<button
key={item.id}
onClick={() => scrollToSection(item.id)}
className="w-full text-left brutal-border bg-ochre-clay px-4 py-3"
>
<div className={cn('flex items-baseline gap-3')}>
<span className="text-sm opacity-60 font-body">{item.number}</span>
<span
className="font-heading text-lg font-black"
style={{ fontVariationSettings: '"WONK" 1, "SOFT" 0' }}
>
{item.label}
</span>
</div>
</button>
))}
</div>
)}
</div>
)
}
@@ -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(<SidebarNav items={ITEMS} />)
expect(screen.getByRole('navigation')).toBeInTheDocument()
})
it('renders "Index" heading', () => {
render(<SidebarNav items={ITEMS} />)
expect(screen.getByText('Index')).toBeInTheDocument()
})
it('renders "Digital Monograph" subtitle', () => {
render(<SidebarNav items={ITEMS} />)
expect(screen.getByText('Digital Monograph')).toBeInTheDocument()
})
it('renders each item label and number', () => {
render(<SidebarNav items={ITEMS} />)
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(<SidebarNav items={ITEMS} />)
expect(screen.getByText('Quick Links')).toBeInTheDocument()
})
it('renders Email quick link', () => {
render(<SidebarNav items={ITEMS} />)
expect(screen.getByRole('link', { name: 'Email' })).toBeInTheDocument()
})
it('renders a button for each item', () => {
render(<SidebarNav items={ITEMS} />)
const buttons = screen.getAllByRole('button')
expect(buttons.length).toBeGreaterThanOrEqual(ITEMS.length)
})
})
})
+94
View File
@@ -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 (
<nav className="fixed top-0 left-0 h-screen w-full lg:w-1/3 bg-ochre-clay brutal-border-right hidden lg:block overflow-y-auto z-50">
<div className="px-8 py-12 space-y-2">
<div className="mb-12">
<h2>Index</h2>
<div className="brutal-border-top pt-4">
<p className="text-sm opacity-60">Digital Monograph</p>
</div>
</div>
{items.map(item => {
const isActive = activeSection === item.id
return (
<button
key={item.id}
onClick={() => scrollToSection(item.id)}
className={cn(
'w-full text-left brutal-border bg-ochre-clay px-6 py-4 transition-all duration-300',
isActive
? 'shadow-[12px_12px_0_var(--carbon-black)] opacity-100 translate-x-0'
: 'opacity-40 shadow-none hover:opacity-60',
)}
>
<div className="flex items-baseline gap-4">
<span className="text-sm opacity-60">{item.number}</span>
<span className="font-heading text-xl font-black">{item.label}</span>
</div>
</button>
)
})}
<div className="mt-12 pt-12 brutal-border-top">
<p className="text-sm uppercase tracking-wider mb-4 opacity-60">Quick Links</p>
<div className="space-y-3">
<a href="mailto:hello@allmy.work" className="block">Email</a>
<a href="https://linkedin.com" className="block">LinkedIn</a>
<a href="https://instagram.com" className="block">Instagram</a>
<a href="https://are.na" className="block">Are.na</a>
</div>
</div>
</div>
</nav>
)
}
@@ -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(<UtilityBar />)
expect(screen.getByText('Contact')).toBeInTheDocument()
})
it('renders email link with correct href', () => {
render(<UtilityBar />)
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(<UtilityBar />)
expect(screen.getByRole('button', { name: /download cv/i })).toBeInTheDocument()
})
it('Download CV button has primary variant class', () => {
render(<UtilityBar />)
const btn = screen.getByRole('button', { name: /download cv/i })
expect(btn).toHaveClass('bg-burnt-oxide')
})
})
})
+34
View File
@@ -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 (
<div className="fixed bottom-0 left-0 right-0 bg-ochre-clay brutal-border-top z-40">
<div className="max-w-[2560px] mx-auto px-6 md:px-12 lg:px-16 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<span className="text-sm uppercase tracking-wider">Contact</span>
<a
href="mailto:hello@allmy.work"
className="text-base hover:text-burnt-oxide transition-colors"
>
hello@allmy.work
</a>
</div>
<Button variant="primary" size="sm" onClick={handleDownloadCV}>
Download CV
</Button>
</div>
</div>
)
}