chore: format codebase and move SectionAccordion to entities/Section
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
export { MobileNav } from './ui/MobileNav'
|
||||
export { SidebarNav } from './ui/SidebarNav'
|
||||
export { UtilityBar } from './ui/UtilityBar'
|
||||
export type { NavItem } from './model/types'
|
||||
export { MobileNav } from './ui/MobileNav';
|
||||
export { SidebarNav } from './ui/SidebarNav';
|
||||
export { UtilityBar } from './ui/UtilityBar';
|
||||
export type { NavItem } from './model/types';
|
||||
|
||||
@@ -2,13 +2,13 @@ export type NavItem = {
|
||||
/**
|
||||
* Section HTML id for anchor scrolling
|
||||
*/
|
||||
id: string
|
||||
id: string;
|
||||
/**
|
||||
* Display label
|
||||
*/
|
||||
label: string
|
||||
label: string;
|
||||
/**
|
||||
* Display number prefix (e.g. "01")
|
||||
*/
|
||||
number: string
|
||||
}
|
||||
number: string;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import { MobileNav } from './MobileNav'
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||
import { MobileNav } from './MobileNav';
|
||||
|
||||
// MobileNav is lg:hidden — it renders only on mobile viewports.
|
||||
// Use the viewport toolbar in Storybook to switch to a mobile size to see it.
|
||||
@@ -11,11 +11,11 @@ const meta: Meta<typeof MobileNav> = {
|
||||
defaultViewport: 'mobile1',
|
||||
},
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export default meta
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof MobileNav>
|
||||
type Story = StoryObj<typeof MobileNav>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
@@ -25,4 +25,4 @@ export const Default: Story = {
|
||||
{ id: 'contact', label: 'Contact', number: '03' },
|
||||
],
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,46 +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'
|
||||
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' }]
|
||||
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()
|
||||
})
|
||||
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()
|
||||
})
|
||||
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()
|
||||
})
|
||||
})
|
||||
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()
|
||||
})
|
||||
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' }))
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
'use client'
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react'
|
||||
import { cn } from '$shared/lib'
|
||||
import type { NavItem } from '../model/types'
|
||||
import { useState } from 'react';
|
||||
import { cn } from '$shared/lib';
|
||||
import type { NavItem } from '../model/types';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Navigation items to render
|
||||
*/
|
||||
items: NavItem[]
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile navigation overlay, hidden on lg+ screens.
|
||||
*/
|
||||
export function MobileNav({ items }: Props) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
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)
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
const top = el.getBoundingClientRect().top + window.scrollY - 100
|
||||
window.scrollTo({ top, behavior: 'smooth' })
|
||||
const top = el.getBoundingClientRect().top + window.scrollY - 100;
|
||||
window.scrollTo({ top, behavior: 'smooth' });
|
||||
}
|
||||
setIsOpen(false)
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -34,7 +34,7 @@ export function MobileNav({ items }: Props) {
|
||||
<div className="px-6 py-4 flex items-center justify-between">
|
||||
<h4>allmy.work</h4>
|
||||
<button
|
||||
onClick={() => setIsOpen(prev => !prev)}
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
className="brutal-border px-4 py-2 bg-carbon-black text-ochre-clay"
|
||||
>
|
||||
{isOpen ? 'Close' : 'Menu'}
|
||||
@@ -42,7 +42,7 @@ export function MobileNav({ items }: Props) {
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className="px-6 py-6 brutal-border-top space-y-2 max-h-[80vh] overflow-y-auto">
|
||||
{items.map(item => (
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => scrollToSection(item.id)}
|
||||
@@ -62,5 +62,5 @@ export function MobileNav({ items }: Props) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import { SidebarNav } from './SidebarNav'
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||
import { SidebarNav } from './SidebarNav';
|
||||
|
||||
// SidebarNav is hidden lg:block — it renders only on desktop viewports.
|
||||
// Use the viewport toolbar in Storybook to switch to a desktop size to see it.
|
||||
@@ -12,11 +12,11 @@ const meta: Meta<typeof SidebarNav> = {
|
||||
defaultViewport: 'desktop',
|
||||
},
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export default meta
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof SidebarNav>
|
||||
type Story = StoryObj<typeof SidebarNav>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
@@ -26,4 +26,4 @@ export const Default: Story = {
|
||||
{ id: 'contact', label: 'Contact', number: '03' },
|
||||
],
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
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'
|
||||
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 () {
|
||||
@@ -14,49 +14,49 @@ beforeEach(() => {
|
||||
observe: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
}
|
||||
}) as unknown as typeof IntersectionObserver
|
||||
})
|
||||
};
|
||||
}) as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
describe('SidebarNav', () => {
|
||||
describe('rendering', () => {
|
||||
it('renders a nav element', () => {
|
||||
render(<SidebarNav items={ITEMS} />)
|
||||
expect(screen.getByRole('navigation')).toBeInTheDocument()
|
||||
})
|
||||
render(<SidebarNav items={ITEMS} />);
|
||||
expect(screen.getByRole('navigation')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Index" heading', () => {
|
||||
render(<SidebarNav items={ITEMS} />)
|
||||
expect(screen.getByText('Index')).toBeInTheDocument()
|
||||
})
|
||||
render(<SidebarNav items={ITEMS} />);
|
||||
expect(screen.getByText('Index')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Digital Monograph" subtitle', () => {
|
||||
render(<SidebarNav items={ITEMS} />)
|
||||
expect(screen.getByText('Digital Monograph')).toBeInTheDocument()
|
||||
})
|
||||
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()
|
||||
})
|
||||
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()
|
||||
})
|
||||
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()
|
||||
})
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
render(<SidebarNav items={ITEMS} />);
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(ITEMS.length);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,50 +1,50 @@
|
||||
'use client'
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { cn } from '$shared/lib'
|
||||
import type { NavItem } from '../model/types'
|
||||
import { useState, useEffect } from 'react';
|
||||
import { cn } from '$shared/lib';
|
||||
import type { NavItem } from '../model/types';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Navigation items to render
|
||||
*/
|
||||
items: NavItem[]
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixed sidebar navigation, visible on lg+ screens.
|
||||
*/
|
||||
export function SidebarNav({ items }: Props) {
|
||||
const [activeSection, setActiveSection] = useState('bio')
|
||||
const [activeSection, setActiveSection] = useState('bio');
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
entries => {
|
||||
entries.forEach(entry => {
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
setActiveSection(entry.target.id)
|
||||
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)
|
||||
})
|
||||
items.forEach((item) => {
|
||||
const el = document.getElementById(item.id);
|
||||
if (el) observer.observe(el);
|
||||
});
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [items])
|
||||
return () => observer.disconnect();
|
||||
}, [items]);
|
||||
|
||||
/**
|
||||
* Scrolls to the section by id with a 40px offset.
|
||||
*/
|
||||
function scrollToSection(id: string) {
|
||||
const el = document.getElementById(id)
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
const top = el.getBoundingClientRect().top + window.scrollY - 40
|
||||
window.scrollTo({ top, behavior: 'smooth' })
|
||||
const top = el.getBoundingClientRect().top + window.scrollY - 40;
|
||||
window.scrollTo({ top, behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,8 +58,8 @@ export function SidebarNav({ items }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{items.map(item => {
|
||||
const isActive = activeSection === item.id
|
||||
{items.map((item) => {
|
||||
const isActive = activeSection === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
@@ -76,19 +76,27 @@ export function SidebarNav({ items }: Props) {
|
||||
<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>
|
||||
<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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import { UtilityBar } from './UtilityBar'
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||
import { UtilityBar } from './UtilityBar';
|
||||
|
||||
const meta: Meta<typeof UtilityBar> = {
|
||||
title: 'Widgets/UtilityBar',
|
||||
@@ -11,10 +11,10 @@ const meta: Meta<typeof UtilityBar> = {
|
||||
</div>
|
||||
),
|
||||
],
|
||||
}
|
||||
};
|
||||
|
||||
export default meta
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof UtilityBar>
|
||||
type Story = StoryObj<typeof UtilityBar>;
|
||||
|
||||
export const Default: Story = {}
|
||||
export const Default: Story = {};
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { UtilityBar } from './UtilityBar'
|
||||
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()
|
||||
})
|
||||
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')
|
||||
})
|
||||
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()
|
||||
})
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
render(<UtilityBar />);
|
||||
const btn = screen.getByRole('button', { name: /download cv/i });
|
||||
expect(btn).toHaveClass('bg-burnt-oxide');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
'use client';
|
||||
|
||||
import { Button } from '$shared/ui'
|
||||
import { Button } from '$shared/ui';
|
||||
|
||||
/**
|
||||
* Fixed bottom utility bar with contact info and CV download.
|
||||
@@ -10,7 +10,7 @@ export function UtilityBar() {
|
||||
* Handles CV download action.
|
||||
*/
|
||||
function handleDownloadCV() {
|
||||
console.log('Downloading CV...')
|
||||
console.log('Downloading CV...');
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -18,10 +18,7 @@ export function UtilityBar() {
|
||||
<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"
|
||||
>
|
||||
<a href="mailto:hello@allmy.work" className="text-base hover:text-burnt-oxide transition-colors">
|
||||
hello@allmy.work
|
||||
</a>
|
||||
</div>
|
||||
@@ -30,5 +27,5 @@ export function UtilityBar() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export * from './Navigation'
|
||||
export * from './Navigation';
|
||||
|
||||
Reference in New Issue
Block a user