chore: format codebase and move SectionAccordion to entities/Section

This commit is contained in:
Ilia Mashkov
2026-04-23 20:52:43 +03:00
parent 8aff27f8ac
commit 1d333fd945
73 changed files with 1201 additions and 1153 deletions
+4 -4
View File
@@ -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';
+4 -4
View File
@@ -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' },
],
},
}
};
+31 -31
View File
@@ -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();
});
});
});
+13 -13
View File
@@ -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' },
],
},
}
};
+35 -35
View File
@@ -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);
});
});
});
+36 -28
View File
@@ -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 = {};
+20 -20
View File
@@ -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');
});
});
});
+5 -8
View File
@@ -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>
)
);
}