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:
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user