Fix/css tweaks and bugs #11
@@ -1,8 +1,8 @@
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import type { SectionRecord } from '$entities/Section';
|
import type { SectionRecord } from '$entities/Section';
|
||||||
import { getCollection } from '$shared/api';
|
import { getCollection } from '$shared/api';
|
||||||
|
import { SectionAccordion } from '$widgets/SectionAccordion';
|
||||||
import { SectionFactory } from '$widgets/SectionFactory';
|
import { SectionFactory } from '$widgets/SectionFactory';
|
||||||
import { SectionsAccordion } from '$widgets/SectionsAccordion';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional catchall: `/` → first section, `/:slug` → that section.
|
* Optional catchall: `/` → first section, `/:slug` → that section.
|
||||||
@@ -46,11 +46,11 @@ export default async function SectionPage({ params }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="px-4 py-6 sm:px-8 sm:py-12 lg:py-16 lg:px-16">
|
<main className="px-4 py-6 sm:px-8 sm:py-12 lg:py-16 lg:px-16">
|
||||||
<SectionsAccordion sections={sections} activeSlug={activeSlug}>
|
<SectionAccordion sections={sections} activeSlug={activeSlug}>
|
||||||
{sections.map((s) => (
|
{sections.map((s) => (
|
||||||
<SectionFactory key={s.slug} slug={s.slug} />
|
<SectionFactory key={s.slug} slug={s.slug} />
|
||||||
))}
|
))}
|
||||||
</SectionsAccordion>
|
</SectionAccordion>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export * from './SectionAccordion';
|
|
||||||
+5
-5
@@ -1,9 +1,9 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||||
import { SectionAccordion } from './SectionAccordion';
|
import { SectionPanel } from './SectionPanel';
|
||||||
|
|
||||||
const meta: Meta<typeof SectionAccordion> = {
|
const meta: Meta<typeof SectionPanel> = {
|
||||||
title: 'Shared/SectionAccordion',
|
title: 'Shared/SectionPanel',
|
||||||
component: SectionAccordion,
|
component: SectionPanel,
|
||||||
decorators: [
|
decorators: [
|
||||||
(Story) => (
|
(Story) => (
|
||||||
<div className="p-8 bg-ochre-clay">
|
<div className="p-8 bg-ochre-clay">
|
||||||
@@ -15,7 +15,7 @@ const meta: Meta<typeof SectionAccordion> = {
|
|||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
|
|
||||||
type Story = StoryObj<typeof SectionAccordion>;
|
type Story = StoryObj<typeof SectionPanel>;
|
||||||
|
|
||||||
export const Active: Story = {
|
export const Active: Story = {
|
||||||
args: {
|
args: {
|
||||||
+10
-10
@@ -1,5 +1,5 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import { SectionAccordion } from './SectionAccordion';
|
import { SectionPanel } from './SectionPanel';
|
||||||
|
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
number: '01',
|
number: '01',
|
||||||
@@ -10,30 +10,30 @@ const defaultProps = {
|
|||||||
children: <p>Content here</p>,
|
children: <p>Content here</p>,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('SectionAccordion', () => {
|
describe('SectionPanel', () => {
|
||||||
describe('collapsed state (isActive=false)', () => {
|
describe('collapsed state (isActive=false)', () => {
|
||||||
it('renders a section element with the given id', () => {
|
it('renders a section element with the given id', () => {
|
||||||
const { container } = render(<SectionAccordion {...defaultProps} />);
|
const { container } = render(<SectionPanel {...defaultProps} />);
|
||||||
expect(container.querySelector('section#about')).toBeInTheDocument();
|
expect(container.querySelector('section#about')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders a link with number and title', () => {
|
it('renders a link with number and title', () => {
|
||||||
render(<SectionAccordion {...defaultProps} />);
|
render(<SectionPanel {...defaultProps} />);
|
||||||
expect(screen.getByRole('link', { name: /01.*About/i })).toBeInTheDocument();
|
expect(screen.getByRole('link', { name: /01.*About/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('link points to the correct href', () => {
|
it('link points to the correct href', () => {
|
||||||
render(<SectionAccordion {...defaultProps} />);
|
render(<SectionPanel {...defaultProps} />);
|
||||||
expect(screen.getByRole('link', { name: /01.*About/i })).toHaveAttribute('href', '/about');
|
expect(screen.getByRole('link', { name: /01.*About/i })).toHaveAttribute('href', '/about');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not render children', () => {
|
it('does not render children', () => {
|
||||||
render(<SectionAccordion {...defaultProps} />);
|
render(<SectionPanel {...defaultProps} />);
|
||||||
expect(screen.queryByText('Content here')).not.toBeInTheDocument();
|
expect(screen.queryByText('Content here')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not render a button', () => {
|
it('does not render a button', () => {
|
||||||
render(<SectionAccordion {...defaultProps} />);
|
render(<SectionPanel {...defaultProps} />);
|
||||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -42,17 +42,17 @@ describe('SectionAccordion', () => {
|
|||||||
const activeProps = { ...defaultProps, isActive: true };
|
const activeProps = { ...defaultProps, isActive: true };
|
||||||
|
|
||||||
it('renders an h1 with number and title', () => {
|
it('renders an h1 with number and title', () => {
|
||||||
render(<SectionAccordion {...activeProps} />);
|
render(<SectionPanel {...activeProps} />);
|
||||||
expect(screen.getByRole('heading', { level: 1, name: /01.*About/i })).toBeInTheDocument();
|
expect(screen.getByRole('heading', { level: 1, name: /01.*About/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders children', () => {
|
it('renders children', () => {
|
||||||
render(<SectionAccordion {...activeProps} />);
|
render(<SectionPanel {...activeProps} />);
|
||||||
expect(screen.getByText('Content here')).toBeInTheDocument();
|
expect(screen.getByText('Content here')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not render a link', () => {
|
it('does not render a link', () => {
|
||||||
render(<SectionAccordion {...activeProps} />);
|
render(<SectionPanel {...activeProps} />);
|
||||||
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+9
-7
@@ -2,7 +2,7 @@ import Link from 'next/link';
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { ViewTransitionWrapper } from '$shared/ui';
|
import { ViewTransitionWrapper } from '$shared/ui';
|
||||||
|
|
||||||
interface SectionAccordionProps {
|
interface SectionPanelProps {
|
||||||
/**
|
/**
|
||||||
* Display number prefix (e.g. "01")
|
* Display number prefix (e.g. "01")
|
||||||
*/
|
*/
|
||||||
@@ -32,16 +32,18 @@ interface SectionAccordionProps {
|
|||||||
/**
|
/**
|
||||||
* Accordion-style section that collapses to a navigation link when inactive.
|
* Accordion-style section that collapses to a navigation link when inactive.
|
||||||
*/
|
*/
|
||||||
export function SectionAccordion({ number, title, id, isActive, href, children }: SectionAccordionProps) {
|
export function SectionPanel({ number, title, id, isActive, href, children }: SectionPanelProps) {
|
||||||
const heading = `${number}. ${title}`;
|
const heading = `${number}. ${title}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id={id} className="scroll-mt-8">
|
<section id={id} className="scroll-mt-8">
|
||||||
{isActive ? (
|
{isActive ? (
|
||||||
<div className="mb-6 sm:mb-12">
|
<div className="mb-section-gap">
|
||||||
<ViewTransitionWrapper name="section-title">
|
<ViewTransitionWrapper name="section-title">
|
||||||
<div className="mb-6 sm:mb-12">
|
<div className="mb-section-gap">
|
||||||
<h1 className="font-heading font-black text-xl sm:text-section-title leading-[1.2] mb-0">{heading}</h1>
|
<h1 className="font-heading font-black text-xl sm:text-section-title leading-[1.2] mb-0 py-0.5">
|
||||||
|
{heading}
|
||||||
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
</ViewTransitionWrapper>
|
</ViewTransitionWrapper>
|
||||||
<ViewTransitionWrapper name="section-body">
|
<ViewTransitionWrapper name="section-body">
|
||||||
@@ -52,9 +54,9 @@ export function SectionAccordion({ number, title, id, isActive, href, children }
|
|||||||
<Link
|
<Link
|
||||||
href={href}
|
href={href}
|
||||||
aria-label={heading}
|
aria-label={heading}
|
||||||
className="block w-full text-left mb-1 py-1 sm:mb-3 sm:py-3 group border-b-0 hover:border-b-0"
|
className="block w-full text-left text-xl sm:text-nav-title py-0.5 group border-b-0 hover:border-b-0"
|
||||||
>
|
>
|
||||||
<span className="block font-heading font-wonk font-black text-xl sm:text-3xl opacity-30 group-hover:opacity-60 transition-opacity duration-200">
|
<span className="block font-heading font-wonk font-black opacity-30 group-hover:opacity-60 transition-opacity duration-200">
|
||||||
{heading}
|
{heading}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './SectionPanel';
|
||||||
@@ -1 +1 @@
|
|||||||
export * from './SectionAccordion';
|
export * from './SectionPanel';
|
||||||
|
|||||||
@@ -36,25 +36,25 @@ describe('ExperienceCard', () => {
|
|||||||
it('period badge is inside the sidebar column', () => {
|
it('period badge is inside the sidebar column', () => {
|
||||||
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
||||||
const badge = screen.getByText('2021 – 2024');
|
const badge = screen.getByText('2021 – 2024');
|
||||||
expect(badge.closest('.brutal-border-sidebar')).toBeInTheDocument();
|
expect(badge.closest('[data-slot="card-sidebar"]')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('company name is inside the sidebar column', () => {
|
it('company name is inside the sidebar column', () => {
|
||||||
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
||||||
const company = screen.getByText('Acme Corp');
|
const company = screen.getByText('Acme Corp');
|
||||||
expect(company.closest('.brutal-border-sidebar')).toBeInTheDocument();
|
expect(company.closest('[data-slot="card-sidebar"]')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('title is outside the sidebar column', () => {
|
it('title is outside the sidebar column', () => {
|
||||||
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
||||||
const title = screen.getByText('Senior Developer');
|
const title = screen.getByText('Senior Developer');
|
||||||
expect(title.closest('.brutal-border-sidebar')).toBeNull();
|
expect(title.closest('[data-slot="card-sidebar"]')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('description is outside the sidebar column', () => {
|
it('description is outside the sidebar column', () => {
|
||||||
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
||||||
const desc = screen.getByText('Built scalable frontend systems.');
|
const desc = screen.getByText('Built scalable frontend systems.');
|
||||||
expect(desc.closest('.brutal-border-sidebar')).toBeNull();
|
expect(desc.closest('[data-slot="card-sidebar"]')).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -88,8 +88,8 @@ describe('ExperienceCard', () => {
|
|||||||
render(<ExperienceCard {...DEFAULT_PROPS} stack={['React', 'TypeScript']} />);
|
render(<ExperienceCard {...DEFAULT_PROPS} stack={['React', 'TypeScript']} />);
|
||||||
const react = screen.getByText('React');
|
const react = screen.getByText('React');
|
||||||
const ts = screen.getByText('TypeScript');
|
const ts = screen.getByText('TypeScript');
|
||||||
expect(react.closest('.brutal-border-sidebar')).toBeInTheDocument();
|
expect(react.closest('[data-slot="card-sidebar"]')).toBeInTheDocument();
|
||||||
expect(ts.closest('.brutal-border-sidebar')).toBeInTheDocument();
|
expect(ts.closest('[data-slot="card-sidebar"]')).toBeInTheDocument();
|
||||||
expect(react).toHaveClass('brutal-border', 'bg-transparent', 'px-2');
|
expect(react).toHaveClass('brutal-border', 'bg-transparent', 'px-2');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -51,29 +51,29 @@ describe('ProjectCard', () => {
|
|||||||
describe('layout', () => {
|
describe('layout', () => {
|
||||||
it('year is inside the sidebar column', () => {
|
it('year is inside the sidebar column', () => {
|
||||||
render(<ProjectCard {...DEFAULT_PROPS} />);
|
render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
expect(screen.getByText('2024').closest('.brutal-border-sidebar')).toBeInTheDocument();
|
expect(screen.getByText('2024').closest('[data-slot="card-sidebar"]')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('tags are inside the sidebar column', () => {
|
it('tags are inside the sidebar column', () => {
|
||||||
render(<ProjectCard {...DEFAULT_PROPS} />);
|
render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
expect(screen.getByText('React').closest('.brutal-border-sidebar')).toBeInTheDocument();
|
expect(screen.getByText('React').closest('[data-slot="card-sidebar"]')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Node').closest('.brutal-border-sidebar')).toBeInTheDocument();
|
expect(screen.getByText('Node').closest('[data-slot="card-sidebar"]')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('View Project button is inside the sidebar column', () => {
|
it('View Project button is inside the sidebar column', () => {
|
||||||
render(<ProjectCard {...DEFAULT_PROPS} />);
|
render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
const btn = screen.getByRole('link', { name: /view project/i });
|
const btn = screen.getByRole('link', { name: /view project/i });
|
||||||
expect(btn.closest('.brutal-border-sidebar')).toBeInTheDocument();
|
expect(btn.closest('[data-slot="card-sidebar"]')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('title is outside the sidebar column', () => {
|
it('title is outside the sidebar column', () => {
|
||||||
render(<ProjectCard {...DEFAULT_PROPS} />);
|
render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
expect(screen.getByText('My Project').closest('.brutal-border-sidebar')).toBeNull();
|
expect(screen.getByText('My Project').closest('[data-slot="card-sidebar"]')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('description is outside the sidebar column', () => {
|
it('description is outside the sidebar column', () => {
|
||||||
render(<ProjectCard {...DEFAULT_PROPS} />);
|
render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
expect(screen.getByText('A cool project description').closest('.brutal-border-sidebar')).toBeNull();
|
expect(screen.getByText('A cool project description').closest('[data-slot="card-sidebar"]')).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export function ProjectCard({ title, year, description, tags, url, imageUrl, pri
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button href={url} variant="primary" size="sm" className="self-start lg:w-full lg:self-auto text-center">
|
<Button href={url} variant="solid" size="sm" className="self-start lg:w-full lg:self-auto text-center">
|
||||||
View Project
|
View Project
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
export interface Props {
|
||||||
|
/**
|
||||||
|
* CSS classes on the svg element
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File with downward arrow — download icon (Lucide file-down).
|
||||||
|
*/
|
||||||
|
export function FileDownIcon({ className }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden={true}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" />
|
||||||
|
<path d="M14 2v5a1 1 0 0 0 1 1h5" />
|
||||||
|
<path d="M12 18v-6" />
|
||||||
|
<path d="m9 15 3 3 3-3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
export { CloseIcon } from './CloseIcon';
|
export { CloseIcon } from './CloseIcon';
|
||||||
|
export { FileDownIcon } from './FileDownIcon';
|
||||||
export { MagnifyIcon } from './MagnifyIcon';
|
export { MagnifyIcon } from './MagnifyIcon';
|
||||||
|
|||||||
+47
-65
@@ -20,8 +20,9 @@
|
|||||||
--font-weight-body: 600;
|
--font-weight-body: 600;
|
||||||
--font-weight-normal: 400;
|
--font-weight-normal: 400;
|
||||||
|
|
||||||
/* Fluid section title: scales from 2rem at ~267px to 8rem at ~1707px */
|
|
||||||
--text-section-title: clamp(2rem, 7.5vw, 8rem);
|
--text-section-title: clamp(2rem, 7.5vw, 8rem);
|
||||||
|
/* Half of the active title so the active/inactive size ratio holds at every width */
|
||||||
|
--text-nav-title: clamp(1.25rem, 3.75vw, 4rem);
|
||||||
|
|
||||||
/* === LINE HEIGHT === */
|
/* === LINE HEIGHT === */
|
||||||
--line-height-tight: 1.2;
|
--line-height-tight: 1.2;
|
||||||
@@ -72,8 +73,7 @@
|
|||||||
--border-width: 1.5px;
|
--border-width: 1.5px;
|
||||||
--radius: 5px;
|
--radius: 5px;
|
||||||
|
|
||||||
/* Muted offset shadow — quieter than the old hard-blue brutalist stamp.
|
/* Low-alpha blue so the offset shadow reads as a muted stamp, not a hard block. */
|
||||||
* Softer alpha + tighter offsets keep the offset-shadow feel, just lighter. */
|
|
||||||
--shadow-color: rgba(4, 28, 243, 0.25);
|
--shadow-color: rgba(4, 28, 243, 0.25);
|
||||||
|
|
||||||
/* === OFFSET SHADOWS === */
|
/* === OFFSET SHADOWS === */
|
||||||
@@ -127,9 +127,13 @@
|
|||||||
--radius-md: var(--radius);
|
--radius-md: var(--radius);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
--container-section: var(--section-content-width);
|
--container-section: var(--section-content-width);
|
||||||
--spacing-footer: 5rem;
|
/* Mobile shows compact 2rem icons, so it sits shorter than the desktop label row */
|
||||||
|
--spacing-footer: 3.5rem;
|
||||||
--spacing-footer-wide: 4rem;
|
--spacing-footer-wide: 4rem;
|
||||||
|
/* Active-section vertical rhythm — fluid so it tracks the clamp() title instead of jumping at sm */
|
||||||
|
--spacing-section-gap: clamp(1.5rem, 5vw, 3.25rem);
|
||||||
--text-section-title: var(--text-section-title);
|
--text-section-title: var(--text-section-title);
|
||||||
|
--text-nav-title: var(--text-nav-title);
|
||||||
|
|
||||||
--shadow-brutal-xs: var(--shadow-brutal-xs);
|
--shadow-brutal-xs: var(--shadow-brutal-xs);
|
||||||
--shadow-brutal-sm: var(--shadow-brutal-sm);
|
--shadow-brutal-sm: var(--shadow-brutal-sm);
|
||||||
@@ -166,6 +170,9 @@
|
|||||||
/* Reserve scrollbar gutter so locking body scroll (e.g. when a modal
|
/* Reserve scrollbar gutter so locking body scroll (e.g. when a modal
|
||||||
* opens) doesn't widen the viewport and shift fixed elements. */
|
* opens) doesn't widen the viewport and shift fixed elements. */
|
||||||
scrollbar-gutter: stable;
|
scrollbar-gutter: stable;
|
||||||
|
/* Clip at the root, not body: a scroll-container body makes Firefox clip
|
||||||
|
* descendant composited transforms that briefly overshoot the viewport. */
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -173,7 +180,6 @@
|
|||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
font-weight: var(--font-weight-body);
|
font-weight: var(--font-weight-body);
|
||||||
line-height: var(--line-height-tight);
|
line-height: var(--line-height-tight);
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Page-wide blue dot grain. z-index -1 drops it behind body's content so
|
/* Page-wide blue dot grain. z-index -1 drops it behind body's content so
|
||||||
@@ -211,7 +217,10 @@
|
|||||||
font-size: var(--text-3xl);
|
font-size: var(--text-3xl);
|
||||||
}
|
}
|
||||||
h3 {
|
h3 {
|
||||||
font-size: var(--text-2xl);
|
/* 5vw is tuned to stay under the nav h1 across the whole range: it only
|
||||||
|
* reaches the nav's 32px exactly at the 640px breakpoint where the nav
|
||||||
|
* itself steps up, so h3 never overtakes it. */
|
||||||
|
font-size: clamp(1.5rem, 5vw, var(--text-2xl));
|
||||||
}
|
}
|
||||||
h4 {
|
h4 {
|
||||||
font-size: var(--text-xl);
|
font-size: var(--text-xl);
|
||||||
@@ -241,46 +250,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Button elevation transition — transform + shadow animate together over 130ms
|
/* Spring feel comes from the easing curve, not keyframes. */
|
||||||
* on the micro easing curve; the spring feel comes from the curve, no keyframes. */
|
|
||||||
@utility btn-transition {
|
@utility btn-transition {
|
||||||
transition: transform 130ms var(--ease-micro);
|
transition: transform 130ms var(--ease-micro);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Offset "shadow" as a static pseudo-block instead of box-shadow. box-shadow
|
/* Hard offset block via box-shadow, so it sits outside the border box and never
|
||||||
* swims because it repaints on the main thread while the button's translate
|
* tints a light fill. Shadow rides the element's own transform, so it can't
|
||||||
* runs on the compositor — the two desync per frame. Here both the button and
|
* desync from the button's translate the way a separate pseudo-element could.
|
||||||
* its ::before are transforms, so they stay frame-synced. The ::before counter-
|
* Offsets are net of the translate: shadow lands at translate + shadow-offset. */
|
||||||
* translates to hold a fixed 3px world offset while the button lifts/presses,
|
|
||||||
* so the shadow appears frozen and only the button moves. */
|
|
||||||
.btn-shadow {
|
.btn-shadow {
|
||||||
position: relative;
|
box-shadow: 3px 3px 0 var(--shadow-color);
|
||||||
z-index: 0;
|
transition:
|
||||||
transition: transform 130ms var(--ease-micro);
|
transform 130ms var(--ease-micro),
|
||||||
}
|
box-shadow 130ms var(--ease-micro);
|
||||||
.btn-shadow::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
/* ponytail: inset:0 tracks the padding box, ~1.5px shy of the border box;
|
|
||||||
* imperceptible on a hard offset block. Widen with negative inset if it shows. */
|
|
||||||
inset: 0;
|
|
||||||
z-index: -1;
|
|
||||||
border-radius: inherit;
|
|
||||||
background: var(--shadow-color);
|
|
||||||
transform: translate(3px, 3px);
|
|
||||||
transition: transform 130ms var(--ease-micro);
|
|
||||||
}
|
}
|
||||||
.btn-shadow:hover {
|
.btn-shadow:hover {
|
||||||
transform: translate(-2px, -2px);
|
transform: translate(-2px, -2px);
|
||||||
}
|
box-shadow: 7px 7px 0 var(--shadow-color);
|
||||||
.btn-shadow:hover::before {
|
|
||||||
transform: translate(5px, 5px);
|
|
||||||
}
|
}
|
||||||
.btn-shadow:active {
|
.btn-shadow:active {
|
||||||
transform: translate(2px, 2px);
|
transform: translate(2px, 2px);
|
||||||
}
|
box-shadow: -1px -1px 0 var(--shadow-color);
|
||||||
.btn-shadow:active::before {
|
|
||||||
transform: translate(1px, 1px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Brutalist utility classes */
|
/* Brutalist utility classes */
|
||||||
@@ -323,30 +314,16 @@
|
|||||||
outline: var(--border-width) solid var(--blue);
|
outline: var(--border-width) solid var(--blue);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
}
|
}
|
||||||
/* Tiled blue dot pattern — applied to body::before (page-wide) and reusable
|
/* Shared paper-grain texture: a tiled blue dot for any surface that needs it. */
|
||||||
* on any surface that should share the same paper-grain texture. The SVG
|
|
||||||
* tile is rasterized once and composited cheaply via repeating background. */
|
|
||||||
@utility grain-pattern {
|
@utility grain-pattern {
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4'%3E%3Ccircle cx='1' cy='1' r='1' fill='%23041cf3' opacity='0.10'/%3E%3C/svg%3E");
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4'%3E%3Ccircle cx='1' cy='1' r='1' fill='%23041cf3' opacity='0.10'/%3E%3C/svg%3E");
|
||||||
}
|
}
|
||||||
/* Apply Fraunces variable axes to non-heading elements using the heading font */
|
|
||||||
.font-wonk {
|
.font-wonk {
|
||||||
font-variation-settings:
|
font-variation-settings:
|
||||||
"WONK" var(--fraunces-wonk),
|
"WONK" var(--fraunces-wonk),
|
||||||
"SOFT" var(--fraunces-soft);
|
"SOFT" var(--fraunces-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sidebar divider: bottom border on mobile, right border on desktop */
|
|
||||||
.brutal-border-sidebar {
|
|
||||||
border-bottom: var(--border-width) solid var(--blue);
|
|
||||||
}
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.brutal-border-sidebar {
|
|
||||||
border-bottom: none;
|
|
||||||
border-right: var(--border-width) solid var(--blue);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Editorial rich-text typography */
|
/* Editorial rich-text typography */
|
||||||
.rich-text {
|
.rich-text {
|
||||||
max-width: 65ch;
|
max-width: 65ch;
|
||||||
@@ -395,9 +372,8 @@
|
|||||||
.rich-text ul li::before {
|
.rich-text ul li::before {
|
||||||
content: "";
|
content: "";
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
/* 0.5em glyph + 1em gap = 1.5em advance, filling the hanging indent */
|
width: 0.4em;
|
||||||
width: 0.5em;
|
height: 0.4em;
|
||||||
height: 0.5em;
|
|
||||||
margin-right: 1em;
|
margin-right: 1em;
|
||||||
/* reset inherited text-indent so the box isn't shifted */
|
/* reset inherited text-indent so the box isn't shifted */
|
||||||
text-indent: 0;
|
text-indent: 0;
|
||||||
@@ -406,7 +382,10 @@
|
|||||||
border-radius: 30%;
|
border-radius: 30%;
|
||||||
/* biome-ignore lint/correctness/noUnknownProperty: corner-shape is valid CSS, newer than Biome's property list */
|
/* biome-ignore lint/correctness/noUnknownProperty: corner-shape is valid CSS, newer than Biome's property list */
|
||||||
corner-shape: squircle;
|
corner-shape: squircle;
|
||||||
|
/* Nudge up off the x-height midpoint so the bullet sits on the text's
|
||||||
|
* optical centre; em-relative so it scales with the text. */
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
|
transform: translateY(-0.1em);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Cross-section view transition (navigation between sections) */
|
/* Cross-section view transition (navigation between sections) */
|
||||||
@@ -507,14 +486,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Page-load entry animation for the footer. Previously also carried
|
/* Page-load entry animation for the footer. */
|
||||||
* `view-transition-name: site-footer` to layer above section-body's slide
|
|
||||||
* group, but the section-body group now has `animation: none` (purely
|
|
||||||
* horizontal slide of OLD/NEW snapshots), so the footer can live in the root
|
|
||||||
* snapshot during transitions — which also lets the lightbox dialog group
|
|
||||||
* stack cleanly above it. */
|
|
||||||
.footer-vt {
|
.footer-vt {
|
||||||
animation: footer-enter var(--duration-slow) var(--ease-spring) both;
|
animation: footer-enter var(--duration-slow) var(--ease-spring) both;
|
||||||
|
/* Own view-transition group so the footer is snapshotted separately instead
|
||||||
|
* of folding into the root group. Without it, every `section-body` transition
|
||||||
|
* paints the section (a named group) above the root — and thus above the
|
||||||
|
* footer — until the transition ends, flashing the cards over the footer. */
|
||||||
|
view-transition-name: site-footer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep the footer group above section-body during transitions, below the
|
||||||
|
* lightbox groups (z=20/30) so the lightbox still opens over it. */
|
||||||
|
::view-transition-group(site-footer) {
|
||||||
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes footer-enter {
|
@keyframes footer-enter {
|
||||||
@@ -555,12 +540,9 @@ dialog.lightbox {
|
|||||||
z-index: 30;
|
z-index: 30;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Lightbox image sizing — leaves vertical headroom for the fixed close button
|
/* Lightbox image sizing — max-height leaves vertical headroom so the fixed
|
||||||
* (sits at top-3, ~2.5rem tall). 8rem total = ~4rem top/bottom after centering,
|
* close button never overlaps the image. content-box keeps the border outside
|
||||||
* so the button never overlaps the image.
|
* the image pixels, avoiding subpixel clipping by the dialog's overflow:hidden. */
|
||||||
* box-sizing: content-box so max-w/max-h apply to the image pixels and the
|
|
||||||
* 3px brutal-border sits outside them — avoids subpixel clipping of the
|
|
||||||
* border by the dialog's overflow:hidden when both have border-box. */
|
|
||||||
@utility lightbox-image {
|
@utility lightbox-image {
|
||||||
box-sizing: content-box;
|
box-sizing: content-box;
|
||||||
max-width: calc(100vw - 2rem);
|
max-width: calc(100vw - 2rem);
|
||||||
|
|||||||
@@ -13,11 +13,8 @@ type Story = StoryObj<typeof Button>;
|
|||||||
export const AllVariants: Story = {
|
export const AllVariants: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
<div className="flex gap-4 flex-wrap p-8 bg-ochre-clay">
|
<div className="flex gap-4 flex-wrap p-8 bg-ochre-clay">
|
||||||
<Button variant="primary" size="md">
|
<Button variant="solid" size="md">
|
||||||
Primary
|
Solid
|
||||||
</Button>
|
|
||||||
<Button variant="secondary" size="md">
|
|
||||||
Secondary
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="md">
|
<Button variant="outline" size="md">
|
||||||
Outline
|
Outline
|
||||||
@@ -32,13 +29,13 @@ export const AllVariants: Story = {
|
|||||||
export const Sizes: Story = {
|
export const Sizes: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
<div className="flex gap-4 items-center flex-wrap p-8 bg-ochre-clay">
|
<div className="flex gap-4 items-center flex-wrap p-8 bg-ochre-clay">
|
||||||
<Button variant="primary" size="sm">
|
<Button variant="solid" size="sm">
|
||||||
Small
|
Small
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="primary" size="md">
|
<Button variant="solid" size="md">
|
||||||
Medium
|
Medium
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="primary" size="lg">
|
<Button variant="solid" size="lg">
|
||||||
Large
|
Large
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -47,7 +44,7 @@ export const Sizes: Story = {
|
|||||||
|
|
||||||
export const Disabled: Story = {
|
export const Disabled: Story = {
|
||||||
args: {
|
args: {
|
||||||
variant: 'primary',
|
variant: 'solid',
|
||||||
disabled: true,
|
disabled: true,
|
||||||
children: 'Disabled',
|
children: 'Disabled',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,14 +14,10 @@ describe('Button', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
describe('variants', () => {
|
describe('variants', () => {
|
||||||
it('applies primary variant by default', () => {
|
it('applies solid variant by default', () => {
|
||||||
render(<Button>Go</Button>);
|
render(<Button>Go</Button>);
|
||||||
expect(screen.getByRole('button')).toHaveClass('bg-blue');
|
expect(screen.getByRole('button')).toHaveClass('bg-blue');
|
||||||
});
|
});
|
||||||
it('applies secondary variant', () => {
|
|
||||||
render(<Button variant="secondary">Go</Button>);
|
|
||||||
expect(screen.getByRole('button')).toHaveClass('bg-blue');
|
|
||||||
});
|
|
||||||
it('applies outline variant', () => {
|
it('applies outline variant', () => {
|
||||||
render(<Button variant="outline">Go</Button>);
|
render(<Button variant="outline">Go</Button>);
|
||||||
expect(screen.getByRole('button')).toHaveClass('bg-cream');
|
expect(screen.getByRole('button')).toHaveClass('bg-cream');
|
||||||
@@ -82,7 +78,7 @@ describe('Button', () => {
|
|||||||
});
|
});
|
||||||
it('applies the same variant and size classes as button', () => {
|
it('applies the same variant and size classes as button', () => {
|
||||||
render(
|
render(
|
||||||
<Button href="/test" variant="primary" size="sm">
|
<Button href="/test" variant="solid" size="sm">
|
||||||
Go
|
Go
|
||||||
</Button>,
|
</Button>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactNode } from 'react';
|
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactNode } from 'react';
|
||||||
import { cn } from '$shared/lib';
|
import { cn } from '$shared/lib';
|
||||||
|
|
||||||
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost';
|
export type ButtonVariant = 'solid' | 'outline' | 'ghost';
|
||||||
export type ButtonSize = 'sm' | 'md' | 'lg';
|
export type ButtonSize = 'sm' | 'md' | 'lg';
|
||||||
|
|
||||||
type BaseProps = {
|
type BaseProps = {
|
||||||
/**
|
/**
|
||||||
* Visual variant
|
* Visual variant
|
||||||
* @default 'primary'
|
* @default 'solid'
|
||||||
*/
|
*/
|
||||||
variant?: ButtonVariant;
|
variant?: ButtonVariant;
|
||||||
/**
|
/**
|
||||||
@@ -41,10 +41,9 @@ function isAnchorProps(props: RestButton | RestAnchor): props is RestAnchor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const VARIANTS = {
|
const VARIANTS = {
|
||||||
primary: 'brutal-border bg-blue text-cream btn-shadow',
|
solid: 'brutal-border bg-blue text-cream btn-shadow',
|
||||||
secondary: 'brutal-border bg-blue text-cream btn-shadow',
|
// Reversed solid: same border + offset-block animation, cream fill instead of blue
|
||||||
outline:
|
outline: 'brutal-border bg-cream text-blue btn-shadow',
|
||||||
'brutal-border border-blue/35 bg-cream text-blue hover:border-blue hover:bg-blue/10 active:bg-blue active:text-cream',
|
|
||||||
ghost:
|
ghost:
|
||||||
'brutal-border bg-transparent text-blue btn-transition hover:-translate-x-0.5 hover:-translate-y-0.5 active:translate-x-0.5 active:translate-y-0.5',
|
'brutal-border bg-transparent text-blue btn-transition hover:-translate-x-0.5 hover:-translate-y-0.5 active:translate-x-0.5 active:translate-y-0.5',
|
||||||
} as const satisfies Record<ButtonVariant, string>;
|
} as const satisfies Record<ButtonVariant, string>;
|
||||||
@@ -55,15 +54,15 @@ const SIZES = {
|
|||||||
lg: 'px-8 py-4 text-lg',
|
lg: 'px-8 py-4 text-lg',
|
||||||
} as const satisfies Record<ButtonSize, string>;
|
} as const satisfies Record<ButtonSize, string>;
|
||||||
|
|
||||||
/* Elevation lives per-variant: primary/secondary use btn-shadow (static offset
|
/* Elevation lives per-variant: solid uses btn-shadow (static offset block,
|
||||||
* block, button moves), ghost uses btn-transition + translate. */
|
* button moves), ghost uses btn-transition + translate. */
|
||||||
const BASE = 'cursor-pointer uppercase tracking-wider';
|
const BASE = 'cursor-pointer uppercase tracking-wider';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Brutalist button with variants and sizes.
|
* Brutalist button with variants and sizes.
|
||||||
* Renders as <a> when href is provided, <button> otherwise.
|
* Renders as <a> when href is provided, <button> otherwise.
|
||||||
*/
|
*/
|
||||||
export function Button({ variant = 'primary', size = 'md', className, children, ...props }: Props) {
|
export function Button({ variant = 'solid', size = 'md', className, children, ...props }: Props) {
|
||||||
const cls = cn(BASE, VARIANTS[variant], SIZES[size], className);
|
const cls = cn(BASE, VARIANTS[variant], SIZES[size], className);
|
||||||
|
|
||||||
if (isAnchorProps(props)) {
|
if (isAnchorProps(props)) {
|
||||||
|
|||||||
@@ -91,10 +91,10 @@ describe('CardSidebar', () => {
|
|||||||
expect(container.firstChild).toHaveClass('flex');
|
expect(container.firstChild).toHaveClass('flex');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sidebar column has brutal-border-sidebar class', () => {
|
it('marks the sidebar column with a data-slot hook', () => {
|
||||||
render(<CardSidebar sidebar={<span>Sidebar</span>}>Main</CardSidebar>);
|
render(<CardSidebar sidebar={<span>Sidebar</span>}>Main</CardSidebar>);
|
||||||
const sidebar = screen.getByText('Sidebar').parentElement;
|
const sidebar = screen.getByText('Sidebar').parentElement;
|
||||||
expect(sidebar).toHaveClass('brutal-border-sidebar');
|
expect(sidebar).toHaveAttribute('data-slot', 'card-sidebar');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sidebar column has fixed width on lg', () => {
|
it('sidebar column has fixed width on lg', () => {
|
||||||
|
|||||||
@@ -109,7 +109,12 @@ interface CardSidebarProps {
|
|||||||
export function CardSidebar({ sidebar, children, className }: CardSidebarProps) {
|
export function CardSidebar({ sidebar, children, className }: CardSidebarProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col lg:flex-row', className)}>
|
<div className={cn('flex flex-col lg:flex-row', className)}>
|
||||||
<div className="shrink-0 lg:w-64 brutal-border-sidebar pb-6 lg:pb-0 lg:pr-8 mb-6 lg:mb-0">{sidebar}</div>
|
<div
|
||||||
|
data-slot="card-sidebar"
|
||||||
|
className="shrink-0 lg:w-64 brutal-border-bottom lg:border-b-0 lg:brutal-border-right pb-6 lg:pb-0 lg:pr-8 mb-6 lg:mb-0"
|
||||||
|
>
|
||||||
|
{sidebar}
|
||||||
|
</div>
|
||||||
<div className="flex-1 min-w-0 lg:pl-8">{children}</div>
|
<div className="flex-1 min-w-0 lg:pl-8">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -81,10 +81,10 @@ describe('ImageLightbox', () => {
|
|||||||
expect(document.body.style.overflow).toBe('');
|
expect(document.body.style.overflow).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('close button is positioned fixed', () => {
|
it('close button is pinned via a fixed wrapper', () => {
|
||||||
render(<ImageLightbox {...DEFAULT_PROPS} />);
|
render(<ImageLightbox {...DEFAULT_PROPS} />);
|
||||||
const closeBtn = screen.getByRole('button', { name: /close/i, hidden: true });
|
const closeBtn = screen.getByRole('button', { name: /close/i, hidden: true });
|
||||||
expect(closeBtn).toHaveClass('fixed');
|
expect(closeBtn.parentElement).toHaveClass('fixed');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -188,9 +188,11 @@ export function ImageLightbox({
|
|||||||
className="lightbox-image block w-auto h-auto"
|
className="lightbox-image block w-auto h-auto"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={close} aria-label="Close image" className="fixed top-3 right-3">
|
<div className="fixed top-3 right-3">
|
||||||
|
<Button variant="outline" size="sm" onClick={close} aria-label="Close image">
|
||||||
<CloseIcon />
|
<CloseIcon />
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -155,6 +155,12 @@ describe('Footer', () => {
|
|||||||
expect(link).toHaveClass('brutal-border', 'uppercase');
|
expect(link).toHaveClass('brutal-border', 'uppercase');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('CV link carries the download icon for the narrow-viewport fallback', async () => {
|
||||||
|
render(await Footer());
|
||||||
|
const link = screen.getByRole('link', { name: /download cv/i });
|
||||||
|
expect(link.querySelector('svg')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('does not render CV link when no cv field', async () => {
|
it('does not render CV link when no cv field', async () => {
|
||||||
vi.mocked(getFirstRecord).mockResolvedValue({ ...mockSettings, cv: '' });
|
vi.mocked(getFirstRecord).mockResolvedValue({ ...mockSettings, cv: '' });
|
||||||
render(await Footer());
|
render(await Footer());
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { SiteSettingsRecord } from '$shared/api';
|
import type { SiteSettingsRecord } from '$shared/api';
|
||||||
import { getFirstRecord } from '$shared/api';
|
import { getFirstRecord } from '$shared/api';
|
||||||
|
import { FileDownIcon } from '$shared/assets/icons';
|
||||||
import { buildFileUrl } from '$shared/lib';
|
import { buildFileUrl } from '$shared/lib';
|
||||||
import { Button, InlineSvg, Link } from '$shared/ui';
|
import { Button, InlineSvg, Link } from '$shared/ui';
|
||||||
|
|
||||||
@@ -42,8 +43,16 @@ export async function Footer() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{cvUrl && (
|
{cvUrl && (
|
||||||
<Button href={cvUrl} download size="sm" className="self-start sm:self-auto">
|
<Button
|
||||||
Download CV
|
href={cvUrl}
|
||||||
|
download
|
||||||
|
size="sm"
|
||||||
|
aria-label="Download CV"
|
||||||
|
className="inline-flex items-center self-start sm:self-auto"
|
||||||
|
>
|
||||||
|
{/* Icon-only below sm, matching the contact row's icon/label switch */}
|
||||||
|
<FileDownIcon className="w-5 h-5 sm:hidden" />
|
||||||
|
<span className="hidden sm:inline">Download CV</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { SectionAccordion } from './ui/SectionAccordion/SectionAccordion';
|
||||||
+16
-16
@@ -1,6 +1,6 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import type { SectionRecord } from '$entities/Section';
|
import type { SectionRecord } from '$entities/Section';
|
||||||
import { SectionsAccordion } from './SectionsAccordion';
|
import { SectionAccordion } from './SectionAccordion';
|
||||||
|
|
||||||
const baseRecord = { collectionId: 'c1', collectionName: 'sections', created: '', updated: '' };
|
const baseRecord = { collectionId: 'c1', collectionName: 'sections', created: '', updated: '' };
|
||||||
|
|
||||||
@@ -10,26 +10,26 @@ const sections: SectionRecord[] = [
|
|||||||
{ ...baseRecord, id: '3', slug: 'skills', title: 'Skills', order: 3 },
|
{ ...baseRecord, id: '3', slug: 'skills', title: 'Skills', order: 3 },
|
||||||
];
|
];
|
||||||
|
|
||||||
describe('SectionsAccordion', () => {
|
describe('SectionAccordion', () => {
|
||||||
describe('active section rendering', () => {
|
describe('active section rendering', () => {
|
||||||
it('renders the active section as an h1', () => {
|
it('renders the active section as an h1', () => {
|
||||||
render(
|
render(
|
||||||
<SectionsAccordion sections={sections} activeSlug="intro">
|
<SectionAccordion sections={sections} activeSlug="intro">
|
||||||
<div>Intro content</div>
|
<div>Intro content</div>
|
||||||
<div>Bio content</div>
|
<div>Bio content</div>
|
||||||
<div>Skills content</div>
|
<div>Skills content</div>
|
||||||
</SectionsAccordion>,
|
</SectionAccordion>,
|
||||||
);
|
);
|
||||||
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('01. Intro');
|
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('01. Intro');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders inactive sections as links', () => {
|
it('renders inactive sections as links', () => {
|
||||||
render(
|
render(
|
||||||
<SectionsAccordion sections={sections} activeSlug="intro">
|
<SectionAccordion sections={sections} activeSlug="intro">
|
||||||
<div>Intro content</div>
|
<div>Intro content</div>
|
||||||
<div>Bio content</div>
|
<div>Bio content</div>
|
||||||
<div>Skills content</div>
|
<div>Skills content</div>
|
||||||
</SectionsAccordion>,
|
</SectionAccordion>,
|
||||||
);
|
);
|
||||||
const links = screen.getAllByRole('link');
|
const links = screen.getAllByRole('link');
|
||||||
expect(links).toHaveLength(2);
|
expect(links).toHaveLength(2);
|
||||||
@@ -37,11 +37,11 @@ describe('SectionsAccordion', () => {
|
|||||||
|
|
||||||
it('inactive section links point to correct hrefs', () => {
|
it('inactive section links point to correct hrefs', () => {
|
||||||
render(
|
render(
|
||||||
<SectionsAccordion sections={sections} activeSlug="intro">
|
<SectionAccordion sections={sections} activeSlug="intro">
|
||||||
<div>Intro content</div>
|
<div>Intro content</div>
|
||||||
<div>Bio content</div>
|
<div>Bio content</div>
|
||||||
<div>Skills content</div>
|
<div>Skills content</div>
|
||||||
</SectionsAccordion>,
|
</SectionAccordion>,
|
||||||
);
|
);
|
||||||
expect(screen.getByRole('link', { name: /02.*Bio/i })).toHaveAttribute('href', '/bio');
|
expect(screen.getByRole('link', { name: /02.*Bio/i })).toHaveAttribute('href', '/bio');
|
||||||
expect(screen.getByRole('link', { name: /03.*Skills/i })).toHaveAttribute('href', '/skills');
|
expect(screen.getByRole('link', { name: /03.*Skills/i })).toHaveAttribute('href', '/skills');
|
||||||
@@ -49,22 +49,22 @@ describe('SectionsAccordion', () => {
|
|||||||
|
|
||||||
it('renders the correct active section for a given activeSlug', () => {
|
it('renders the correct active section for a given activeSlug', () => {
|
||||||
render(
|
render(
|
||||||
<SectionsAccordion sections={sections} activeSlug="bio">
|
<SectionAccordion sections={sections} activeSlug="bio">
|
||||||
<div>Intro content</div>
|
<div>Intro content</div>
|
||||||
<div>Bio content</div>
|
<div>Bio content</div>
|
||||||
<div>Skills content</div>
|
<div>Skills content</div>
|
||||||
</SectionsAccordion>,
|
</SectionAccordion>,
|
||||||
);
|
);
|
||||||
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('02. Bio');
|
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('02. Bio');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('only one section is active at a time', () => {
|
it('only one section is active at a time', () => {
|
||||||
render(
|
render(
|
||||||
<SectionsAccordion sections={sections} activeSlug="skills">
|
<SectionAccordion sections={sections} activeSlug="skills">
|
||||||
<div>Intro content</div>
|
<div>Intro content</div>
|
||||||
<div>Bio content</div>
|
<div>Bio content</div>
|
||||||
<div>Skills content</div>
|
<div>Skills content</div>
|
||||||
</SectionsAccordion>,
|
</SectionAccordion>,
|
||||||
);
|
);
|
||||||
expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1);
|
expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1);
|
||||||
});
|
});
|
||||||
@@ -73,22 +73,22 @@ describe('SectionsAccordion', () => {
|
|||||||
describe('content slots', () => {
|
describe('content slots', () => {
|
||||||
it('shows active section content', () => {
|
it('shows active section content', () => {
|
||||||
render(
|
render(
|
||||||
<SectionsAccordion sections={sections} activeSlug="intro">
|
<SectionAccordion sections={sections} activeSlug="intro">
|
||||||
<div>Intro content</div>
|
<div>Intro content</div>
|
||||||
<div>Bio content</div>
|
<div>Bio content</div>
|
||||||
<div>Skills content</div>
|
<div>Skills content</div>
|
||||||
</SectionsAccordion>,
|
</SectionAccordion>,
|
||||||
);
|
);
|
||||||
expect(screen.getByText('Intro content')).toBeInTheDocument();
|
expect(screen.getByText('Intro content')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not show inactive section content', () => {
|
it('does not show inactive section content', () => {
|
||||||
render(
|
render(
|
||||||
<SectionsAccordion sections={sections} activeSlug="intro">
|
<SectionAccordion sections={sections} activeSlug="intro">
|
||||||
<div>Intro content</div>
|
<div>Intro content</div>
|
||||||
<div>Bio content</div>
|
<div>Bio content</div>
|
||||||
<div>Skills content</div>
|
<div>Skills content</div>
|
||||||
</SectionsAccordion>,
|
</SectionAccordion>,
|
||||||
);
|
);
|
||||||
expect(screen.queryByText('Bio content')).not.toBeInTheDocument();
|
expect(screen.queryByText('Bio content')).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText('Skills content')).not.toBeInTheDocument();
|
expect(screen.queryByText('Skills content')).not.toBeInTheDocument();
|
||||||
+5
-5
@@ -1,7 +1,7 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { Children } from 'react';
|
import { Children } from 'react';
|
||||||
import type { SectionRecord } from '$entities/Section';
|
import type { SectionRecord } from '$entities/Section';
|
||||||
import { SectionAccordion } from '$entities/Section';
|
import { SectionPanel } from '$entities/Section';
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
/**
|
/**
|
||||||
@@ -24,13 +24,13 @@ export interface Props {
|
|||||||
* Active section is determined by the URL (activeSlug prop); inactive sections
|
* Active section is determined by the URL (activeSlug prop); inactive sections
|
||||||
* render as navigation links so the browser handles routing.
|
* render as navigation links so the browser handles routing.
|
||||||
*/
|
*/
|
||||||
export function SectionsAccordion({ sections, activeSlug, children }: Props) {
|
export function SectionAccordion({ sections, activeSlug, children }: Props) {
|
||||||
const slots = Children.toArray(children);
|
const slots = Children.toArray(children);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-0 sm:space-y-2">
|
<div>
|
||||||
{sections.map((section, i) => (
|
{sections.map((section, i) => (
|
||||||
<SectionAccordion
|
<SectionPanel
|
||||||
key={section.slug}
|
key={section.slug}
|
||||||
id={section.slug}
|
id={section.slug}
|
||||||
number={String(section.order).padStart(2, '0')}
|
number={String(section.order).padStart(2, '0')}
|
||||||
@@ -39,7 +39,7 @@ export function SectionsAccordion({ sections, activeSlug, children }: Props) {
|
|||||||
href={`/${section.slug}`}
|
href={`/${section.slug}`}
|
||||||
>
|
>
|
||||||
{slots[i]}
|
{slots[i]}
|
||||||
</SectionAccordion>
|
</SectionPanel>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { SectionsAccordion } from './ui/SectionsAccordion/SectionsAccordion';
|
|
||||||
Reference in New Issue
Block a user