Compare commits
10 Commits
759f579695
...
41edc7edf7
| Author | SHA1 | Date | |
|---|---|---|---|
| 41edc7edf7 | |||
| f3b4e1d064 | |||
| fce4672218 | |||
| d89dc2ee70 | |||
| f0fccd55f1 | |||
| 5dbf5e34c2 | |||
| 68d5de3716 | |||
| 1d333fd945 | |||
| 8aff27f8ac | |||
| 9c139adbf5 |
@@ -20,6 +20,8 @@
|
|||||||
# production
|
# production
|
||||||
/build
|
/build
|
||||||
|
|
||||||
|
/docs
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
import type { Preview } from '@storybook/nextjs-vite'
|
|
||||||
import '../app/globals.css'
|
|
||||||
|
|
||||||
const preview: Preview = {
|
|
||||||
parameters: {
|
|
||||||
controls: {
|
|
||||||
matchers: {
|
|
||||||
color: /(background|color)$/i,
|
|
||||||
date: /Date$/i,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
a11y: {
|
|
||||||
// 'todo' - show a11y violations in the test UI only
|
|
||||||
// 'error' - fail CI on a11y violations
|
|
||||||
// 'off' - skip a11y checks entirely
|
|
||||||
test: 'todo',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export default preview
|
|
||||||
+6
-8
@@ -1,11 +1,11 @@
|
|||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next';
|
||||||
import { fraunces, publicSans } from '$shared/lib'
|
import { fraunces, publicSans } from '$shared/lib';
|
||||||
import './globals.css'
|
import './globals.css';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Portfolio',
|
title: 'Portfolio',
|
||||||
description: 'Portfolio',
|
description: 'Portfolio',
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Root layout — injects font CSS variables used by theme.css
|
* Root layout — injects font CSS variables used by theme.css
|
||||||
@@ -13,9 +13,7 @@ export const metadata: Metadata = {
|
|||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className={`${fraunces.variable} ${publicSans.variable}`}>
|
<body className={`${fraunces.variable} ${publicSans.variable}`}>{children}</body>
|
||||||
{children}
|
|
||||||
</body>
|
|
||||||
</html>
|
</html>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-20
@@ -1,36 +1,29 @@
|
|||||||
import Image from "next/image";
|
import Image from 'next/image';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||||
<Image
|
<Image className="dark:invert" src="/next.svg" alt="Next.js logo" width={100} height={20} priority />
|
||||||
className="dark:invert"
|
|
||||||
src="/next.svg"
|
|
||||||
alt="Next.js logo"
|
|
||||||
width={100}
|
|
||||||
height={20}
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||||
To get started, edit the page.tsx file.
|
To get started, edit the page.tsx file.
|
||||||
</h1>
|
</h1>
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
Looking for a starting point or more instructions? Head over to{' '}
|
||||||
<a
|
<a
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||||
>
|
>
|
||||||
Templates
|
Templates
|
||||||
</a>{" "}
|
</a>{' '}
|
||||||
or the{" "}
|
or the{' '}
|
||||||
<a
|
<a
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||||
>
|
>
|
||||||
Learning
|
Learning
|
||||||
</a>{" "}
|
</a>{' '}
|
||||||
center.
|
center.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -41,13 +34,7 @@ export default function Home() {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
<Image
|
<Image className="dark:invert" src="/vercel.svg" alt="Vercel logomark" width={16} height={16} />
|
||||||
className="dark:invert"
|
|
||||||
src="/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Deploy Now
|
Deploy Now
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
|
|||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/2.4.13/schema.json",
|
||||||
|
"vcs": {
|
||||||
|
"enabled": true,
|
||||||
|
"clientKind": "git",
|
||||||
|
"useIgnoreFile": true
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"ignoreUnknown": false,
|
||||||
|
"includes": ["src/**/*", "app/**/*"]
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"indentStyle": "space",
|
||||||
|
"indentWidth": 2,
|
||||||
|
"lineWidth": 120
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"recommended": true,
|
||||||
|
"correctness": {
|
||||||
|
"noUnusedVariables": "warn"
|
||||||
|
},
|
||||||
|
"style": {
|
||||||
|
"noNonNullAssertion": "warn",
|
||||||
|
"useBlockStatements": "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"formatter": {
|
||||||
|
"quoteStyle": "single",
|
||||||
|
"jsxQuoteStyle": "double",
|
||||||
|
"semicolons": "always",
|
||||||
|
"trailingCommas": "all"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"json": {
|
||||||
|
"formatter": {
|
||||||
|
"trailingCommas": "none"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"css": {
|
||||||
|
"parser": {
|
||||||
|
"tailwindDirectives": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"assist": {
|
||||||
|
"enabled": true,
|
||||||
|
"actions": {
|
||||||
|
"source": {
|
||||||
|
"organizeImports": "on"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
pre-commit:
|
||||||
|
parallel: true
|
||||||
|
commands:
|
||||||
|
biome-check:
|
||||||
|
glob: "*.{js,ts,jsx,tsx,json,css}"
|
||||||
|
run: yarn biome check --write {staged_files}
|
||||||
|
stage_fixed: true
|
||||||
|
tests:
|
||||||
|
run: yarn test
|
||||||
+8
-1
@@ -6,7 +6,12 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "biome lint --write .",
|
||||||
|
"format": "biome format --write .",
|
||||||
|
"check": "biome check --write .",
|
||||||
|
"lint:ci": "biome lint .",
|
||||||
|
"format:ci": "biome format .",
|
||||||
|
"check:ci": "biome check .",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"storybook": "storybook dev -p 6006",
|
"storybook": "storybook dev -p 6006",
|
||||||
@@ -20,6 +25,7 @@
|
|||||||
"tailwind-merge": "^3.5.0"
|
"tailwind-merge": "^3.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "2.4.13",
|
||||||
"@chromatic-com/storybook": "^5.1.2",
|
"@chromatic-com/storybook": "^5.1.2",
|
||||||
"@storybook/addon-a11y": "^10.3.5",
|
"@storybook/addon-a11y": "^10.3.5",
|
||||||
"@storybook/addon-docs": "^10.3.5",
|
"@storybook/addon-docs": "^10.3.5",
|
||||||
@@ -40,6 +46,7 @@
|
|||||||
"eslint-config-next": "16.2.4",
|
"eslint-config-next": "16.2.4",
|
||||||
"eslint-plugin-storybook": "^10.3.5",
|
"eslint-plugin-storybook": "^10.3.5",
|
||||||
"jsdom": "^29.0.2",
|
"jsdom": "^29.0.2",
|
||||||
|
"lefthook": "^2.1.6",
|
||||||
"playwright": "^1.59.1",
|
"playwright": "^1.59.1",
|
||||||
"storybook": "^10.3.5",
|
"storybook": "^10.3.5",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './model/types';
|
||||||
|
export * from './ui';
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { BaseRecord } from '$shared/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PocketBase collection for site sections and routing.
|
||||||
|
*/
|
||||||
|
export type SectionRecord = BaseRecord & {
|
||||||
|
/**
|
||||||
|
* URL-friendly identifier used for routing
|
||||||
|
*/
|
||||||
|
slug: string;
|
||||||
|
/**
|
||||||
|
* Display name of the section
|
||||||
|
*/
|
||||||
|
title: string;
|
||||||
|
/**
|
||||||
|
* Visual numbering prefix (e.g., "01")
|
||||||
|
*/
|
||||||
|
number: string;
|
||||||
|
/**
|
||||||
|
* Sorting weight for section order
|
||||||
|
*/
|
||||||
|
order: number;
|
||||||
|
};
|
||||||
+9
-13
@@ -1,5 +1,5 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||||
import { SectionAccordion } from './SectionAccordion'
|
import { SectionAccordion } from './SectionAccordion';
|
||||||
|
|
||||||
const meta: Meta<typeof SectionAccordion> = {
|
const meta: Meta<typeof SectionAccordion> = {
|
||||||
title: 'Shared/SectionAccordion',
|
title: 'Shared/SectionAccordion',
|
||||||
@@ -11,11 +11,11 @@ const meta: Meta<typeof SectionAccordion> = {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
}
|
};
|
||||||
|
|
||||||
export default meta
|
export default meta;
|
||||||
|
|
||||||
type Story = StoryObj<typeof SectionAccordion>
|
type Story = StoryObj<typeof SectionAccordion>;
|
||||||
|
|
||||||
export const Active: Story = {
|
export const Active: Story = {
|
||||||
args: {
|
args: {
|
||||||
@@ -24,11 +24,9 @@ export const Active: Story = {
|
|||||||
id: 'bio',
|
id: 'bio',
|
||||||
isActive: true,
|
isActive: true,
|
||||||
onClick: () => {},
|
onClick: () => {},
|
||||||
children: (
|
children: <p>This is the expanded section content. It is visible because isActive is true.</p>,
|
||||||
<p>This is the expanded section content. It is visible because isActive is true.</p>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
export const Collapsed: Story = {
|
export const Collapsed: Story = {
|
||||||
args: {
|
args: {
|
||||||
@@ -37,8 +35,6 @@ export const Collapsed: Story = {
|
|||||||
id: 'work',
|
id: 'work',
|
||||||
isActive: false,
|
isActive: false,
|
||||||
onClick: () => console.log('section clicked'),
|
onClick: () => console.log('section clicked'),
|
||||||
children: (
|
children: <p>This content is hidden in collapsed state.</p>,
|
||||||
<p>This content is hidden in collapsed state.</p>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
+34
-35
@@ -1,7 +1,6 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest'
|
import { render, screen } from '@testing-library/react';
|
||||||
import { render, screen } from '@testing-library/react'
|
import userEvent from '@testing-library/user-event';
|
||||||
import userEvent from '@testing-library/user-event'
|
import { SectionAccordion } from './SectionAccordion';
|
||||||
import { SectionAccordion } from './SectionAccordion'
|
|
||||||
|
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
number: '01',
|
number: '01',
|
||||||
@@ -10,48 +9,48 @@ const defaultProps = {
|
|||||||
isActive: false,
|
isActive: false,
|
||||||
onClick: vi.fn(),
|
onClick: vi.fn(),
|
||||||
children: <p>Content here</p>,
|
children: <p>Content here</p>,
|
||||||
}
|
};
|
||||||
|
|
||||||
describe('SectionAccordion', () => {
|
describe('SectionAccordion', () => {
|
||||||
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(<SectionAccordion {...defaultProps} />);
|
||||||
expect(container.querySelector('section#about')).toBeInTheDocument()
|
expect(container.querySelector('section#about')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
it('renders a button with number and title', () => {
|
it('renders a button with number and title', () => {
|
||||||
render(<SectionAccordion {...defaultProps} />)
|
render(<SectionAccordion {...defaultProps} />);
|
||||||
expect(screen.getByRole('button', { name: /01.*About/i })).toBeInTheDocument()
|
expect(screen.getByRole('button', { name: /01.*About/i })).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
it('does not render children', () => {
|
it('does not render children', () => {
|
||||||
render(<SectionAccordion {...defaultProps} />)
|
render(<SectionAccordion {...defaultProps} />);
|
||||||
expect(screen.queryByText('Content here')).not.toBeInTheDocument()
|
expect(screen.queryByText('Content here')).not.toBeInTheDocument();
|
||||||
})
|
});
|
||||||
it('calls onClick when button is clicked', async () => {
|
it('calls onClick when button is clicked', async () => {
|
||||||
const onClick = vi.fn()
|
const onClick = vi.fn();
|
||||||
render(<SectionAccordion {...defaultProps} onClick={onClick} />)
|
render(<SectionAccordion {...defaultProps} onClick={onClick} />);
|
||||||
await userEvent.click(screen.getByRole('button'))
|
await userEvent.click(screen.getByRole('button'));
|
||||||
expect(onClick).toHaveBeenCalledOnce()
|
expect(onClick).toHaveBeenCalledOnce();
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('active state (isActive=true)', () => {
|
describe('active state (isActive=true)', () => {
|
||||||
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(<SectionAccordion {...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(<SectionAccordion {...activeProps} />);
|
||||||
expect(screen.getByText('Content here')).toBeInTheDocument()
|
expect(screen.getByText('Content here')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
it('does not render a button', () => {
|
it('does not render a button', () => {
|
||||||
render(<SectionAccordion {...activeProps} />)
|
render(<SectionAccordion {...activeProps} />);
|
||||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||||
})
|
});
|
||||||
it('content wrapper has animate-fadeIn class', () => {
|
it('content wrapper has animate-fadeIn class', () => {
|
||||||
const { container } = render(<SectionAccordion {...activeProps} />)
|
const { container } = render(<SectionAccordion {...activeProps} />);
|
||||||
expect(container.querySelector('.animate-fadeIn')).toBeInTheDocument()
|
expect(container.querySelector('.animate-fadeIn')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
+10
-11
@@ -1,30 +1,30 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
interface SectionAccordionProps {
|
interface SectionAccordionProps {
|
||||||
/**
|
/**
|
||||||
* Display number prefix (e.g. "01")
|
* Display number prefix (e.g. "01")
|
||||||
*/
|
*/
|
||||||
number: string
|
number: string;
|
||||||
/**
|
/**
|
||||||
* Section title
|
* Section title
|
||||||
*/
|
*/
|
||||||
title: string
|
title: string;
|
||||||
/**
|
/**
|
||||||
* HTML id for anchor navigation
|
* HTML id for anchor navigation
|
||||||
*/
|
*/
|
||||||
id: string
|
id: string;
|
||||||
/**
|
/**
|
||||||
* Whether this section is expanded
|
* Whether this section is expanded
|
||||||
*/
|
*/
|
||||||
isActive: boolean
|
isActive: boolean;
|
||||||
/**
|
/**
|
||||||
* Called when the collapsed header is clicked
|
* Called when the collapsed header is clicked
|
||||||
*/
|
*/
|
||||||
onClick: () => void
|
onClick: () => void;
|
||||||
/**
|
/**
|
||||||
* Section content, shown when active
|
* Section content, shown when active
|
||||||
*/
|
*/
|
||||||
children: ReactNode
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,12 +43,11 @@ export function SectionAccordion({ number, title, id, isActive, onClick, childre
|
|||||||
{number}. {title}
|
{number}. {title}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="animate-fadeIn">
|
<div className="animate-fadeIn">{children}</div>
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className="w-full text-left mb-3 py-3 transition-all duration-200 hover:opacity-60 group"
|
className="w-full text-left mb-3 py-3 transition-all duration-200 hover:opacity-60 group"
|
||||||
>
|
>
|
||||||
@@ -61,5 +60,5 @@ export function SectionAccordion({ number, title, id, isActive, onClick, childre
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './SectionAccordion';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './SectionAccordion';
|
||||||
@@ -1 +1 @@
|
|||||||
export { ExperienceCard } from './ui/ExperienceCard'
|
export { ExperienceCard } from './ui/ExperienceCard';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||||
import { ExperienceCard } from './ExperienceCard'
|
import { ExperienceCard } from './ExperienceCard';
|
||||||
|
|
||||||
const meta: Meta<typeof ExperienceCard> = {
|
const meta: Meta<typeof ExperienceCard> = {
|
||||||
title: 'Entities/ExperienceCard',
|
title: 'Entities/ExperienceCard',
|
||||||
@@ -11,30 +11,28 @@ const meta: Meta<typeof ExperienceCard> = {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
}
|
};
|
||||||
|
|
||||||
export default meta
|
export default meta;
|
||||||
|
|
||||||
type Story = StoryObj<typeof ExperienceCard>
|
type Story = StoryObj<typeof ExperienceCard>;
|
||||||
|
|
||||||
const baseArgs = {
|
const baseArgs = {
|
||||||
title: 'Senior Frontend Engineer',
|
title: 'Senior Frontend Engineer',
|
||||||
company: 'Acme Corp',
|
company: 'Acme Corp',
|
||||||
period: '2021 – 2024',
|
period: '2021 – 2024',
|
||||||
description: 'Led frontend development for the core product, established design system practices, and mentored junior engineers across two distributed teams.',
|
description:
|
||||||
}
|
'Led frontend development for the core product, established design system practices, and mentored junior engineers across two distributed teams.',
|
||||||
|
};
|
||||||
|
|
||||||
export const Default: Story = {
|
export const Default: Story = {
|
||||||
args: baseArgs,
|
args: baseArgs,
|
||||||
}
|
};
|
||||||
|
|
||||||
export const SlateBackground: Story = {
|
export const SlateBackground: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
<div className="bg-slate-indigo p-8 max-w-2xl">
|
<div className="bg-slate-indigo p-8 max-w-2xl">
|
||||||
<ExperienceCard
|
<ExperienceCard {...baseArgs} className="border-ochre-clay" />
|
||||||
{...baseArgs}
|
|
||||||
className="border-ochre-clay"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,72 +1,71 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { render, screen } from '@testing-library/react';
|
||||||
import { render, screen } from '@testing-library/react'
|
import { ExperienceCard } from './ExperienceCard';
|
||||||
import { ExperienceCard } from './ExperienceCard'
|
|
||||||
|
|
||||||
const DEFAULT_PROPS = {
|
const DEFAULT_PROPS = {
|
||||||
title: 'Senior Developer',
|
title: 'Senior Developer',
|
||||||
company: 'Acme Corp',
|
company: 'Acme Corp',
|
||||||
period: '2021 – 2024',
|
period: '2021 – 2024',
|
||||||
description: 'Built scalable frontend systems.',
|
description: 'Built scalable frontend systems.',
|
||||||
}
|
};
|
||||||
|
|
||||||
describe('ExperienceCard', () => {
|
describe('ExperienceCard', () => {
|
||||||
describe('rendering', () => {
|
describe('rendering', () => {
|
||||||
it('renders the job title', () => {
|
it('renders the job title', () => {
|
||||||
render(<ExperienceCard {...DEFAULT_PROPS} />)
|
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
||||||
expect(screen.getByText('Senior Developer')).toBeInTheDocument()
|
expect(screen.getByText('Senior Developer')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('renders the company name', () => {
|
it('renders the company name', () => {
|
||||||
render(<ExperienceCard {...DEFAULT_PROPS} />)
|
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
||||||
expect(screen.getByText('Acme Corp')).toBeInTheDocument()
|
expect(screen.getByText('Acme Corp')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('renders the period badge', () => {
|
it('renders the period badge', () => {
|
||||||
render(<ExperienceCard {...DEFAULT_PROPS} />)
|
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
||||||
expect(screen.getByText('2021 – 2024')).toBeInTheDocument()
|
expect(screen.getByText('2021 – 2024')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('renders the description', () => {
|
it('renders the description', () => {
|
||||||
render(<ExperienceCard {...DEFAULT_PROPS} />)
|
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
||||||
expect(screen.getByText('Built scalable frontend systems.')).toBeInTheDocument()
|
expect(screen.getByText('Built scalable frontend systems.')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('structure', () => {
|
describe('structure', () => {
|
||||||
it('title is rendered as an h4', () => {
|
it('title is rendered as an h4', () => {
|
||||||
render(<ExperienceCard {...DEFAULT_PROPS} />)
|
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
||||||
expect(screen.getByRole('heading', { level: 4 })).toHaveTextContent('Senior Developer')
|
expect(screen.getByRole('heading', { level: 4 })).toHaveTextContent('Senior Developer');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('period badge has brutal-border, bg-carbon-black, text-ochre-clay, text-sm', () => {
|
it('period badge has brutal-border, bg-carbon-black, text-ochre-clay, text-sm', () => {
|
||||||
render(<ExperienceCard {...DEFAULT_PROPS} />)
|
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
||||||
const badge = screen.getByText('2021 – 2024')
|
const badge = screen.getByText('2021 – 2024');
|
||||||
expect(badge).toHaveClass('brutal-border', 'bg-carbon-black', 'text-ochre-clay', 'text-sm')
|
expect(badge).toHaveClass('brutal-border', 'bg-carbon-black', 'text-ochre-clay', 'text-sm');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('company paragraph has opacity-80', () => {
|
it('company paragraph has opacity-80', () => {
|
||||||
render(<ExperienceCard {...DEFAULT_PROPS} />)
|
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
||||||
const company = screen.getByText('Acme Corp')
|
const company = screen.getByText('Acme Corp');
|
||||||
expect(company.tagName).toBe('P')
|
expect(company.tagName).toBe('P');
|
||||||
expect(company).toHaveClass('opacity-80')
|
expect(company).toHaveClass('opacity-80');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('description paragraph has text-base and max-w-[700px]', () => {
|
it('description paragraph has text-base and max-w-[700px]', () => {
|
||||||
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).toHaveClass('text-base', 'max-w-[700px]')
|
expect(desc).toHaveClass('text-base', 'max-w-[700px]');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('card has brutal-border class (from Card component)', () => {
|
it('card has brutal-border class (from Card component)', () => {
|
||||||
const { container } = render(<ExperienceCard {...DEFAULT_PROPS} />)
|
const { container } = render(<ExperienceCard {...DEFAULT_PROPS} />);
|
||||||
expect(container.firstChild).toHaveClass('brutal-border')
|
expect(container.firstChild).toHaveClass('brutal-border');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('className passthrough', () => {
|
describe('className passthrough', () => {
|
||||||
it('forwards className to the card', () => {
|
it('forwards className to the card', () => {
|
||||||
const { container } = render(<ExperienceCard {...DEFAULT_PROPS} className="custom-class" />)
|
const { container } = render(<ExperienceCard {...DEFAULT_PROPS} className="custom-class" />);
|
||||||
expect(container.firstChild).toHaveClass('custom-class')
|
expect(container.firstChild).toHaveClass('custom-class');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
import { Card } from '$shared/ui'
|
import { Card } from '$shared/ui';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/**
|
/**
|
||||||
* Job title
|
* Job title
|
||||||
*/
|
*/
|
||||||
title: string
|
title: string;
|
||||||
/**
|
/**
|
||||||
* Company name
|
* Company name
|
||||||
*/
|
*/
|
||||||
company: string
|
company: string;
|
||||||
/**
|
/**
|
||||||
* Employment period (e.g. "2021 – 2024")
|
* Employment period (e.g. "2021 – 2024")
|
||||||
*/
|
*/
|
||||||
period: string
|
period: string;
|
||||||
/**
|
/**
|
||||||
* Description of responsibilities and achievements
|
* Description of responsibilities and achievements
|
||||||
*/
|
*/
|
||||||
description: string
|
description: string;
|
||||||
/**
|
/**
|
||||||
* Additional CSS classes forwarded to the card
|
* Additional CSS classes forwarded to the card
|
||||||
*/
|
*/
|
||||||
className?: string
|
className?: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Work experience card with title, company, period, and description.
|
* Work experience card with title, company, period, and description.
|
||||||
@@ -34,11 +34,9 @@ export function ExperienceCard({ title, company, period, description, className
|
|||||||
<h4>{title}</h4>
|
<h4>{title}</h4>
|
||||||
<p className="text-base opacity-80">{company}</p>
|
<p className="text-base opacity-80">{company}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="brutal-border px-4 py-2 bg-carbon-black text-ochre-clay text-sm self-start">
|
<span className="brutal-border px-4 py-2 bg-carbon-black text-ochre-clay text-sm self-start">{period}</span>
|
||||||
{period}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-base max-w-[700px]">{description}</p>
|
<p className="text-base max-w-[700px]">{description}</p>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export * from './project'
|
export * from './experience';
|
||||||
export * from './experience'
|
export * from './project';
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export { ProjectMetadata } from './ui/ProjectMetadata'
|
export { DetailedProjectCard } from './ui/DetailedProjectCard';
|
||||||
export { ProjectCard } from './ui/ProjectCard'
|
export { ProjectCard } from './ui/ProjectCard';
|
||||||
export { DetailedProjectCard } from './ui/DetailedProjectCard'
|
export { ProjectMetadata } from './ui/ProjectMetadata';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||||
import { DetailedProjectCard } from './DetailedProjectCard'
|
import { DetailedProjectCard } from './DetailedProjectCard';
|
||||||
|
|
||||||
const meta: Meta<typeof DetailedProjectCard> = {
|
const meta: Meta<typeof DetailedProjectCard> = {
|
||||||
title: 'Entities/DetailedProjectCard',
|
title: 'Entities/DetailedProjectCard',
|
||||||
@@ -11,32 +11,33 @@ const meta: Meta<typeof DetailedProjectCard> = {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
}
|
};
|
||||||
|
|
||||||
export default meta
|
export default meta;
|
||||||
|
|
||||||
type Story = StoryObj<typeof DetailedProjectCard>
|
type Story = StoryObj<typeof DetailedProjectCard>;
|
||||||
|
|
||||||
const baseArgs = {
|
const baseArgs = {
|
||||||
title: 'Design System',
|
title: 'Design System',
|
||||||
year: '2024',
|
year: '2024',
|
||||||
role: 'Lead Frontend Engineer',
|
role: 'Lead Frontend Engineer',
|
||||||
stack: ['React', 'TypeScript', 'Tailwind CSS', 'Storybook'],
|
stack: ['React', 'TypeScript', 'Tailwind CSS', 'Storybook'],
|
||||||
description: 'A comprehensive design system built for a large-scale SaaS product, covering components, tokens, and documentation.',
|
description:
|
||||||
|
'A comprehensive design system built for a large-scale SaaS product, covering components, tokens, and documentation.',
|
||||||
details: [
|
details: [
|
||||||
'Established token system covering color, spacing, and typography.',
|
'Established token system covering color, spacing, and typography.',
|
||||||
'Built 40+ accessible components with full test coverage.',
|
'Built 40+ accessible components with full test coverage.',
|
||||||
'Integrated Storybook for visual regression testing and documentation.',
|
'Integrated Storybook for visual regression testing and documentation.',
|
||||||
],
|
],
|
||||||
}
|
};
|
||||||
|
|
||||||
export const Default: Story = {
|
export const Default: Story = {
|
||||||
args: baseArgs,
|
args: baseArgs,
|
||||||
}
|
};
|
||||||
|
|
||||||
export const WithImage: Story = {
|
export const WithImage: Story = {
|
||||||
args: {
|
args: {
|
||||||
...baseArgs,
|
...baseArgs,
|
||||||
imageUrl: 'https://placehold.co/800x450/3B4A59/D9B48F?text=Project',
|
imageUrl: 'https://placehold.co/800x450/3B4A59/D9B48F?text=Project',
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { render, screen } from '@testing-library/react';
|
||||||
import { render, screen } from '@testing-library/react'
|
import { DetailedProjectCard } from './DetailedProjectCard';
|
||||||
import { DetailedProjectCard } from './DetailedProjectCard'
|
|
||||||
|
|
||||||
const DEFAULT_PROPS = {
|
const DEFAULT_PROPS = {
|
||||||
title: 'Big Project',
|
title: 'Big Project',
|
||||||
@@ -9,83 +8,82 @@ const DEFAULT_PROPS = {
|
|||||||
stack: ['Vue', 'Go'],
|
stack: ['Vue', 'Go'],
|
||||||
description: 'A detailed project description',
|
description: 'A detailed project description',
|
||||||
details: ['First detail point', 'Second detail point'],
|
details: ['First detail point', 'Second detail point'],
|
||||||
}
|
};
|
||||||
|
|
||||||
describe('DetailedProjectCard', () => {
|
describe('DetailedProjectCard', () => {
|
||||||
describe('rendering', () => {
|
describe('rendering', () => {
|
||||||
it('renders the project title', () => {
|
it('renders the project title', () => {
|
||||||
render(<DetailedProjectCard {...DEFAULT_PROPS} />)
|
render(<DetailedProjectCard {...DEFAULT_PROPS} />);
|
||||||
expect(screen.getByText('Big Project')).toBeInTheDocument()
|
expect(screen.getByText('Big Project')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('renders the description', () => {
|
it('renders the description', () => {
|
||||||
render(<DetailedProjectCard {...DEFAULT_PROPS} />)
|
render(<DetailedProjectCard {...DEFAULT_PROPS} />);
|
||||||
expect(screen.getByText('A detailed project description')).toBeInTheDocument()
|
expect(screen.getByText('A detailed project description')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('renders each detail item', () => {
|
it('renders each detail item', () => {
|
||||||
render(<DetailedProjectCard {...DEFAULT_PROPS} />)
|
render(<DetailedProjectCard {...DEFAULT_PROPS} />);
|
||||||
expect(screen.getByText('First detail point')).toBeInTheDocument()
|
expect(screen.getByText('First detail point')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Second detail point')).toBeInTheDocument()
|
expect(screen.getByText('Second detail point')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('renders ProjectMetadata with year, role, and stack', () => {
|
it('renders ProjectMetadata with year, role, and stack', () => {
|
||||||
render(<DetailedProjectCard {...DEFAULT_PROPS} />)
|
render(<DetailedProjectCard {...DEFAULT_PROPS} />);
|
||||||
expect(screen.getByText('2023')).toBeInTheDocument()
|
expect(screen.getByText('2023')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Lead Dev')).toBeInTheDocument()
|
expect(screen.getByText('Lead Dev')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Vue')).toBeInTheDocument()
|
expect(screen.getByText('Vue')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Go')).toBeInTheDocument()
|
expect(screen.getByText('Go')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('structure', () => {
|
describe('structure', () => {
|
||||||
it('outer grid has grid-cols-1 and lg:grid-cols-12', () => {
|
it('outer grid has grid-cols-1 and lg:grid-cols-12', () => {
|
||||||
const { container } = render(<DetailedProjectCard {...DEFAULT_PROPS} />)
|
const { container } = render(<DetailedProjectCard {...DEFAULT_PROPS} />);
|
||||||
expect(container.firstChild).toHaveClass('grid', 'grid-cols-1', 'lg:grid-cols-12')
|
expect(container.firstChild).toHaveClass('grid', 'grid-cols-1', 'lg:grid-cols-12');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('title is rendered as an h3', () => {
|
it('title is rendered as an h3', () => {
|
||||||
render(<DetailedProjectCard {...DEFAULT_PROPS} />)
|
render(<DetailedProjectCard {...DEFAULT_PROPS} />);
|
||||||
expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('Big Project')
|
expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('Big Project');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('detail items are rendered as <p> tags with text-base', () => {
|
it('detail items are rendered as <p> tags with text-base', () => {
|
||||||
render(<DetailedProjectCard {...DEFAULT_PROPS} />)
|
render(<DetailedProjectCard {...DEFAULT_PROPS} />);
|
||||||
const detail = screen.getByText('First detail point')
|
const detail = screen.getByText('First detail point');
|
||||||
expect(detail.tagName).toBe('P')
|
expect(detail.tagName).toBe('P');
|
||||||
expect(detail).toHaveClass('text-base')
|
expect(detail).toHaveClass('text-base');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('details list has brutal-border-top and pt-6', () => {
|
it('details list has brutal-border-top and pt-6', () => {
|
||||||
render(<DetailedProjectCard {...DEFAULT_PROPS} />)
|
render(<DetailedProjectCard {...DEFAULT_PROPS} />);
|
||||||
const detail = screen.getByText('First detail point')
|
const detail = screen.getByText('First detail point');
|
||||||
const detailList = detail.parentElement
|
const detailList = detail.parentElement;
|
||||||
expect(detailList).toHaveClass('brutal-border-top', 'pt-6')
|
expect(detailList).toHaveClass('brutal-border-top', 'pt-6');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('description has text-lg and mb-6', () => {
|
it('description has text-lg and mb-6', () => {
|
||||||
render(<DetailedProjectCard {...DEFAULT_PROPS} />)
|
render(<DetailedProjectCard {...DEFAULT_PROPS} />);
|
||||||
const desc = screen.getByText('A detailed project description')
|
const desc = screen.getByText('A detailed project description');
|
||||||
expect(desc).toHaveClass('text-lg', 'mb-6')
|
expect(desc).toHaveClass('text-lg', 'mb-6');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('conditional image rendering', () => {
|
describe('conditional image rendering', () => {
|
||||||
it('does not render image when imageUrl is absent', () => {
|
it('does not render image when imageUrl is absent', () => {
|
||||||
const { container } = render(<DetailedProjectCard {...DEFAULT_PROPS} />)
|
const { container } = render(<DetailedProjectCard {...DEFAULT_PROPS} />);
|
||||||
expect(container.querySelector('img')).toBeNull()
|
expect(container.querySelector('img')).toBeNull();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('renders image when imageUrl is provided', () => {
|
it('renders image when imageUrl is provided', () => {
|
||||||
render(<DetailedProjectCard {...DEFAULT_PROPS} imageUrl="/detail.jpg" />)
|
render(<DetailedProjectCard {...DEFAULT_PROPS} imageUrl="/detail.jpg" />);
|
||||||
const img = screen.getByRole('img')
|
expect(screen.getByRole('img')).toBeInTheDocument();
|
||||||
expect(img).toHaveAttribute('src', '/detail.jpg')
|
});
|
||||||
})
|
|
||||||
|
|
||||||
it('image wrapper has aspect-video and brutal-border when imageUrl is provided', () => {
|
it('image wrapper has aspect-video and brutal-border when imageUrl is provided', () => {
|
||||||
const { container } = render(<DetailedProjectCard {...DEFAULT_PROPS} imageUrl="/detail.jpg" />)
|
const { container } = render(<DetailedProjectCard {...DEFAULT_PROPS} imageUrl="/detail.jpg" />);
|
||||||
const imgWrapper = container.querySelector('img')!.parentElement
|
const imgWrapper = container.querySelector('img')?.parentElement;
|
||||||
expect(imgWrapper).toHaveClass('aspect-video', 'brutal-border')
|
expect(imgWrapper).toHaveClass('aspect-video', 'brutal-border');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,54 +1,47 @@
|
|||||||
import { Card } from '$shared/ui'
|
import Image from 'next/image';
|
||||||
import { ProjectMetadata } from './ProjectMetadata'
|
import { Card } from '$shared/ui';
|
||||||
|
import { ProjectMetadata } from './ProjectMetadata';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/**
|
/**
|
||||||
* Project name
|
* Project name
|
||||||
*/
|
*/
|
||||||
title: string
|
title: string;
|
||||||
/**
|
/**
|
||||||
* Year the project was completed
|
* Year the project was completed
|
||||||
*/
|
*/
|
||||||
year: string
|
year: string;
|
||||||
/**
|
/**
|
||||||
* Developer role on the project
|
* Developer role on the project
|
||||||
*/
|
*/
|
||||||
role: string
|
role: string;
|
||||||
/**
|
/**
|
||||||
* Technology stack list
|
* Technology stack list
|
||||||
*/
|
*/
|
||||||
stack: string[]
|
stack: string[];
|
||||||
/**
|
/**
|
||||||
* Project description paragraph
|
* Project description paragraph
|
||||||
*/
|
*/
|
||||||
description: string
|
description: string;
|
||||||
/**
|
/**
|
||||||
* Bullet-style detail points listed below the description
|
* Bullet-style detail points listed below the description
|
||||||
*/
|
*/
|
||||||
details: string[]
|
details: string[];
|
||||||
/**
|
/**
|
||||||
* Optional hero image URL
|
* Optional hero image URL
|
||||||
*/
|
*/
|
||||||
imageUrl?: string
|
imageUrl?: string;
|
||||||
/**
|
/**
|
||||||
* Reverse layout (reserved for future use)
|
* Reverse layout (reserved for future use)
|
||||||
* @default false
|
* @default false
|
||||||
*/
|
*/
|
||||||
reverse?: boolean
|
reverse?: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full-width detailed project card with metadata sidebar.
|
* Full-width detailed project card with metadata sidebar.
|
||||||
*/
|
*/
|
||||||
export function DetailedProjectCard({
|
export function DetailedProjectCard({ title, year, role, stack, description, details, imageUrl }: Props) {
|
||||||
title,
|
|
||||||
year,
|
|
||||||
role,
|
|
||||||
stack,
|
|
||||||
description,
|
|
||||||
details,
|
|
||||||
imageUrl,
|
|
||||||
}: Props) {
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 mb-16">
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 mb-16">
|
||||||
<div className="lg:col-span-2 order-2 lg:order-1">
|
<div className="lg:col-span-2 order-2 lg:order-1">
|
||||||
@@ -61,18 +54,20 @@ export function DetailedProjectCard({
|
|||||||
<p className="text-lg mb-6">{description}</p>
|
<p className="text-lg mb-6">{description}</p>
|
||||||
|
|
||||||
{imageUrl && (
|
{imageUrl && (
|
||||||
<div className="brutal-border aspect-video bg-slate-indigo overflow-hidden">
|
<div className="brutal-border aspect-video bg-slate-indigo overflow-hidden relative">
|
||||||
<img src={imageUrl} alt={title} className="w-full h-full object-cover" />
|
<Image src={imageUrl} alt={title} fill className="object-cover" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="max-w-[700px] space-y-4 brutal-border-top pt-6">
|
<div className="max-w-[700px] space-y-4 brutal-border-top pt-6">
|
||||||
{details.map((detail, index) => (
|
{details.map((detail) => (
|
||||||
<p key={index} className="text-base">{detail}</p>
|
<p key={detail} className="text-base">
|
||||||
|
{detail}
|
||||||
|
</p>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||||
import { ProjectCard } from './ProjectCard'
|
import { ProjectCard } from './ProjectCard';
|
||||||
|
|
||||||
const meta: Meta<typeof ProjectCard> = {
|
const meta: Meta<typeof ProjectCard> = {
|
||||||
title: 'Entities/ProjectCard',
|
title: 'Entities/ProjectCard',
|
||||||
@@ -11,11 +11,11 @@ const meta: Meta<typeof ProjectCard> = {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
}
|
};
|
||||||
|
|
||||||
export default meta
|
export default meta;
|
||||||
|
|
||||||
type Story = StoryObj<typeof ProjectCard>
|
type Story = StoryObj<typeof ProjectCard>;
|
||||||
|
|
||||||
export const Default: Story = {
|
export const Default: Story = {
|
||||||
args: {
|
args: {
|
||||||
@@ -24,7 +24,7 @@ export const Default: Story = {
|
|||||||
description: 'A brutalist portfolio site built with Next.js and Tailwind CSS.',
|
description: 'A brutalist portfolio site built with Next.js and Tailwind CSS.',
|
||||||
tags: ['React', 'TypeScript', 'Next.js'],
|
tags: ['React', 'TypeScript', 'Next.js'],
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
export const WithImage: Story = {
|
export const WithImage: Story = {
|
||||||
args: {
|
args: {
|
||||||
@@ -34,4 +34,4 @@ export const WithImage: Story = {
|
|||||||
tags: ['React', 'TypeScript', 'Next.js'],
|
tags: ['React', 'TypeScript', 'Next.js'],
|
||||||
imageUrl: 'https://placehold.co/800x450/3B4A59/D9B48F?text=Project',
|
imageUrl: 'https://placehold.co/800x450/3B4A59/D9B48F?text=Project',
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,79 +1,84 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { render, screen } from '@testing-library/react';
|
||||||
import { render, screen } from '@testing-library/react'
|
import { ProjectCard } from './ProjectCard';
|
||||||
import { ProjectCard } from './ProjectCard'
|
|
||||||
|
|
||||||
const DEFAULT_PROPS = {
|
const DEFAULT_PROPS = {
|
||||||
title: 'My Project',
|
title: 'My Project',
|
||||||
year: '2024',
|
year: '2024',
|
||||||
description: 'A cool project description',
|
description: 'A cool project description',
|
||||||
tags: ['React', 'Node'],
|
tags: ['React', 'Node'],
|
||||||
}
|
};
|
||||||
|
|
||||||
describe('ProjectCard', () => {
|
describe('ProjectCard', () => {
|
||||||
describe('rendering', () => {
|
describe('rendering', () => {
|
||||||
it('renders the project title', () => {
|
it('renders the project title', () => {
|
||||||
render(<ProjectCard {...DEFAULT_PROPS} />)
|
render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
expect(screen.getByText('My Project')).toBeInTheDocument()
|
expect(screen.getByText('My Project')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('renders the year badge', () => {
|
it('renders the year badge', () => {
|
||||||
render(<ProjectCard {...DEFAULT_PROPS} />)
|
render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
expect(screen.getByText('2024')).toBeInTheDocument()
|
expect(screen.getByText('2024')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('renders the description', () => {
|
it('renders the description', () => {
|
||||||
render(<ProjectCard {...DEFAULT_PROPS} />)
|
render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
expect(screen.getByText('A cool project description')).toBeInTheDocument()
|
expect(screen.getByText('A cool project description')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('renders each tag', () => {
|
it('renders each tag', () => {
|
||||||
render(<ProjectCard {...DEFAULT_PROPS} />)
|
render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
expect(screen.getByText('React')).toBeInTheDocument()
|
expect(screen.getByText('React')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Node')).toBeInTheDocument()
|
expect(screen.getByText('Node')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('renders the View Project button', () => {
|
it('renders the View Project button', () => {
|
||||||
render(<ProjectCard {...DEFAULT_PROPS} />)
|
render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
expect(screen.getByRole('button', { name: /view project/i })).toBeInTheDocument()
|
expect(screen.getByRole('button', { name: /view project/i })).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('structure', () => {
|
describe('structure', () => {
|
||||||
it('card has hover transition classes', () => {
|
it('card has hover transition classes', () => {
|
||||||
const { container } = render(<ProjectCard {...DEFAULT_PROPS} />)
|
const { container } = render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
const card = container.firstChild as HTMLElement
|
const card = container.firstChild as HTMLElement;
|
||||||
expect(card).toHaveClass('group', 'transition-all', 'duration-300')
|
expect(card).toHaveClass('group', 'transition-all', 'duration-300');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('year badge has correct classes', () => {
|
it('year badge has correct classes', () => {
|
||||||
render(<ProjectCard {...DEFAULT_PROPS} />)
|
render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
const yearBadge = screen.getByText('2024')
|
const yearBadge = screen.getByText('2024');
|
||||||
expect(yearBadge).toHaveClass('brutal-border', 'bg-carbon-black', 'text-ochre-clay', 'text-sm')
|
expect(yearBadge).toHaveClass('brutal-border', 'bg-carbon-black', 'text-ochre-clay', 'text-sm');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('tags have correct classes', () => {
|
it('tags have correct classes', () => {
|
||||||
render(<ProjectCard {...DEFAULT_PROPS} />)
|
render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
const tag = screen.getByText('React')
|
const tag = screen.getByText('React');
|
||||||
expect(tag).toHaveClass('brutal-border', 'bg-white', 'text-carbon-black', 'text-sm', 'uppercase', 'tracking-wide')
|
expect(tag).toHaveClass(
|
||||||
})
|
'brutal-border',
|
||||||
})
|
'bg-white',
|
||||||
|
'text-carbon-black',
|
||||||
|
'text-sm',
|
||||||
|
'uppercase',
|
||||||
|
'tracking-wide',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('conditional image rendering', () => {
|
describe('conditional image rendering', () => {
|
||||||
it('does not render image when imageUrl is absent', () => {
|
it('does not render image when imageUrl is absent', () => {
|
||||||
const { container } = render(<ProjectCard {...DEFAULT_PROPS} />)
|
const { container } = render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
expect(container.querySelector('img')).toBeNull()
|
expect(container.querySelector('img')).toBeNull();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('renders image when imageUrl is provided', () => {
|
it('renders image when imageUrl is provided', () => {
|
||||||
render(<ProjectCard {...DEFAULT_PROPS} imageUrl="/project.jpg" />)
|
render(<ProjectCard {...DEFAULT_PROPS} imageUrl="/project.jpg" />);
|
||||||
const img = screen.getByRole('img')
|
expect(screen.getByRole('img')).toBeInTheDocument();
|
||||||
expect(img).toHaveAttribute('src', '/project.jpg')
|
});
|
||||||
})
|
|
||||||
|
|
||||||
it('image wrapper has aspect-video and overflow-hidden when imageUrl is provided', () => {
|
it('image wrapper has aspect-video and overflow-hidden when imageUrl is provided', () => {
|
||||||
const { container } = render(<ProjectCard {...DEFAULT_PROPS} imageUrl="/project.jpg" />)
|
const { container } = render(<ProjectCard {...DEFAULT_PROPS} imageUrl="/project.jpg" />);
|
||||||
const imgWrapper = container.querySelector('img')!.parentElement
|
const imgWrapper = container.querySelector('img')?.parentElement;
|
||||||
expect(imgWrapper).toHaveClass('aspect-video', 'overflow-hidden', 'brutal-border')
|
expect(imgWrapper).toHaveClass('aspect-video', 'overflow-hidden', 'brutal-border');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,28 +1,29 @@
|
|||||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, Button } from '$shared/ui'
|
import Image from 'next/image';
|
||||||
import { cn } from '$shared/lib'
|
import { cn } from '$shared/lib';
|
||||||
|
import { Button, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '$shared/ui';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/**
|
/**
|
||||||
* Project name
|
* Project name
|
||||||
*/
|
*/
|
||||||
title: string
|
title: string;
|
||||||
/**
|
/**
|
||||||
* Year the project was completed
|
* Year the project was completed
|
||||||
*/
|
*/
|
||||||
year: string
|
year: string;
|
||||||
/**
|
/**
|
||||||
* Short project description
|
* Short project description
|
||||||
*/
|
*/
|
||||||
description: string
|
description: string;
|
||||||
/**
|
/**
|
||||||
* Technology or category tags
|
* Technology or category tags
|
||||||
*/
|
*/
|
||||||
tags: string[]
|
tags: string[];
|
||||||
/**
|
/**
|
||||||
* Optional preview image URL
|
* Optional preview image URL
|
||||||
*/
|
*/
|
||||||
imageUrl?: string
|
imageUrl?: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compact project card for grid/list display.
|
* Compact project card for grid/list display.
|
||||||
@@ -44,8 +45,8 @@ export function ProjectCard({ title, year, description, tags, imageUrl }: Props)
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
{imageUrl && (
|
{imageUrl && (
|
||||||
<div className="brutal-border my-6 aspect-video bg-slate-indigo overflow-hidden">
|
<div className="brutal-border my-6 aspect-video bg-slate-indigo overflow-hidden relative">
|
||||||
<img src={imageUrl} alt={title} className="w-full h-full object-cover" />
|
<Image src={imageUrl} alt={title} fill className="object-cover" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -61,8 +62,10 @@ export function ProjectCard({ title, year, description, tags, imageUrl }: Props)
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
<CardFooter>
|
<CardFooter>
|
||||||
<Button variant="primary" className="w-full">View Project</Button>
|
<Button variant="primary" className="w-full">
|
||||||
|
View Project
|
||||||
|
</Button>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||||
import { ProjectMetadata } from './ProjectMetadata'
|
import { ProjectMetadata } from './ProjectMetadata';
|
||||||
|
|
||||||
const meta: Meta<typeof ProjectMetadata> = {
|
const meta: Meta<typeof ProjectMetadata> = {
|
||||||
title: 'Entities/ProjectMetadata',
|
title: 'Entities/ProjectMetadata',
|
||||||
@@ -11,11 +11,11 @@ const meta: Meta<typeof ProjectMetadata> = {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
}
|
};
|
||||||
|
|
||||||
export default meta
|
export default meta;
|
||||||
|
|
||||||
type Story = StoryObj<typeof ProjectMetadata>
|
type Story = StoryObj<typeof ProjectMetadata>;
|
||||||
|
|
||||||
export const Default: Story = {
|
export const Default: Story = {
|
||||||
args: {
|
args: {
|
||||||
@@ -23,4 +23,4 @@ export const Default: Story = {
|
|||||||
role: 'Lead Frontend Engineer',
|
role: 'Lead Frontend Engineer',
|
||||||
stack: ['React', 'TypeScript', 'Next.js', 'Tailwind'],
|
stack: ['React', 'TypeScript', 'Next.js', 'Tailwind'],
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,96 +1,95 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { render, screen } from '@testing-library/react';
|
||||||
import { render, screen } from '@testing-library/react'
|
import { ProjectMetadata } from './ProjectMetadata';
|
||||||
import { ProjectMetadata } from './ProjectMetadata'
|
|
||||||
|
|
||||||
const DEFAULT_PROPS = {
|
const DEFAULT_PROPS = {
|
||||||
year: '2024',
|
year: '2024',
|
||||||
role: 'Frontend Engineer',
|
role: 'Frontend Engineer',
|
||||||
stack: ['React', 'TypeScript', 'Tailwind'],
|
stack: ['React', 'TypeScript', 'Tailwind'],
|
||||||
}
|
};
|
||||||
|
|
||||||
describe('ProjectMetadata', () => {
|
describe('ProjectMetadata', () => {
|
||||||
describe('rendering', () => {
|
describe('rendering', () => {
|
||||||
it('renders the year value', () => {
|
it('renders the year value', () => {
|
||||||
render(<ProjectMetadata {...DEFAULT_PROPS} />)
|
render(<ProjectMetadata {...DEFAULT_PROPS} />);
|
||||||
expect(screen.getByText('2024')).toBeInTheDocument()
|
expect(screen.getByText('2024')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('renders the YEAR label', () => {
|
it('renders the YEAR label', () => {
|
||||||
render(<ProjectMetadata {...DEFAULT_PROPS} />)
|
render(<ProjectMetadata {...DEFAULT_PROPS} />);
|
||||||
expect(screen.getByText('YEAR')).toBeInTheDocument()
|
expect(screen.getByText('YEAR')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('renders the role value', () => {
|
it('renders the role value', () => {
|
||||||
render(<ProjectMetadata {...DEFAULT_PROPS} />)
|
render(<ProjectMetadata {...DEFAULT_PROPS} />);
|
||||||
expect(screen.getByText('Frontend Engineer')).toBeInTheDocument()
|
expect(screen.getByText('Frontend Engineer')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('renders the ROLE label', () => {
|
it('renders the ROLE label', () => {
|
||||||
render(<ProjectMetadata {...DEFAULT_PROPS} />)
|
render(<ProjectMetadata {...DEFAULT_PROPS} />);
|
||||||
expect(screen.getByText('ROLE')).toBeInTheDocument()
|
expect(screen.getByText('ROLE')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('renders the STACK label', () => {
|
it('renders the STACK label', () => {
|
||||||
render(<ProjectMetadata {...DEFAULT_PROPS} />)
|
render(<ProjectMetadata {...DEFAULT_PROPS} />);
|
||||||
expect(screen.getByText('STACK')).toBeInTheDocument()
|
expect(screen.getByText('STACK')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('renders each stack technology', () => {
|
it('renders each stack technology', () => {
|
||||||
render(<ProjectMetadata {...DEFAULT_PROPS} />)
|
render(<ProjectMetadata {...DEFAULT_PROPS} />);
|
||||||
expect(screen.getByText('React')).toBeInTheDocument()
|
expect(screen.getByText('React')).toBeInTheDocument();
|
||||||
expect(screen.getByText('TypeScript')).toBeInTheDocument()
|
expect(screen.getByText('TypeScript')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Tailwind')).toBeInTheDocument()
|
expect(screen.getByText('Tailwind')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('structure', () => {
|
describe('structure', () => {
|
||||||
it('outer div has space-y-6', () => {
|
it('outer div has space-y-6', () => {
|
||||||
const { container } = render(<ProjectMetadata {...DEFAULT_PROPS} />)
|
const { container } = render(<ProjectMetadata {...DEFAULT_PROPS} />);
|
||||||
expect(container.firstChild).toHaveClass('space-y-6')
|
expect(container.firstChild).toHaveClass('space-y-6');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('year section has no brutal-border-top (first section)', () => {
|
it('year section has no brutal-border-top (first section)', () => {
|
||||||
const { container } = render(<ProjectMetadata {...DEFAULT_PROPS} />)
|
const { container } = render(<ProjectMetadata {...DEFAULT_PROPS} />);
|
||||||
const sections = container.firstChild!.childNodes
|
const sections = container.firstChild?.childNodes;
|
||||||
expect(sections[0]).not.toHaveClass('brutal-border-top')
|
expect(sections[0]).not.toHaveClass('brutal-border-top');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('role section has brutal-border-top and pt-6', () => {
|
it('role section has brutal-border-top and pt-6', () => {
|
||||||
const { container } = render(<ProjectMetadata {...DEFAULT_PROPS} />)
|
const { container } = render(<ProjectMetadata {...DEFAULT_PROPS} />);
|
||||||
const sections = container.firstChild!.childNodes
|
const sections = container.firstChild?.childNodes;
|
||||||
expect(sections[1]).toHaveClass('brutal-border-top', 'pt-6')
|
expect(sections[1]).toHaveClass('brutal-border-top', 'pt-6');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('stack section has brutal-border-top and pt-6', () => {
|
it('stack section has brutal-border-top and pt-6', () => {
|
||||||
const { container } = render(<ProjectMetadata {...DEFAULT_PROPS} />)
|
const { container } = render(<ProjectMetadata {...DEFAULT_PROPS} />);
|
||||||
const sections = container.firstChild!.childNodes
|
const sections = container.firstChild?.childNodes;
|
||||||
expect(sections[2]).toHaveClass('brutal-border-top', 'pt-6')
|
expect(sections[2]).toHaveClass('brutal-border-top', 'pt-6');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('label has text-xs uppercase tracking-wider opacity-60', () => {
|
it('label has text-xs uppercase tracking-wider opacity-60', () => {
|
||||||
render(<ProjectMetadata {...DEFAULT_PROPS} />)
|
render(<ProjectMetadata {...DEFAULT_PROPS} />);
|
||||||
const yearLabel = screen.getByText('YEAR')
|
const yearLabel = screen.getByText('YEAR');
|
||||||
expect(yearLabel).toHaveClass('text-xs', 'uppercase', 'tracking-wider', 'opacity-60')
|
expect(yearLabel).toHaveClass('text-xs', 'uppercase', 'tracking-wider', 'opacity-60');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('year value has text-base font-bold', () => {
|
it('year value has text-base font-bold', () => {
|
||||||
render(<ProjectMetadata {...DEFAULT_PROPS} />)
|
render(<ProjectMetadata {...DEFAULT_PROPS} />);
|
||||||
const yearValue = screen.getByText('2024')
|
const yearValue = screen.getByText('2024');
|
||||||
expect(yearValue).toHaveClass('text-base', 'font-bold')
|
expect(yearValue).toHaveClass('text-base', 'font-bold');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('each stack tech is rendered as a <p> with text-sm', () => {
|
it('each stack tech is rendered as a <p> with text-sm', () => {
|
||||||
render(<ProjectMetadata {...DEFAULT_PROPS} />)
|
render(<ProjectMetadata {...DEFAULT_PROPS} />);
|
||||||
const techEl = screen.getByText('React')
|
const techEl = screen.getByText('React');
|
||||||
expect(techEl.tagName).toBe('P')
|
expect(techEl.tagName).toBe('P');
|
||||||
expect(techEl).toHaveClass('text-sm')
|
expect(techEl).toHaveClass('text-sm');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('className passthrough', () => {
|
describe('className passthrough', () => {
|
||||||
it('merges custom className onto outer div', () => {
|
it('merges custom className onto outer div', () => {
|
||||||
const { container } = render(<ProjectMetadata {...DEFAULT_PROPS} className="my-custom" />)
|
const { container } = render(<ProjectMetadata {...DEFAULT_PROPS} className="my-custom" />);
|
||||||
expect(container.firstChild).toHaveClass('my-custom')
|
expect(container.firstChild).toHaveClass('my-custom');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
import { cn } from '$shared/lib'
|
import { cn } from '$shared/lib';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/**
|
/**
|
||||||
* Project year
|
* Project year
|
||||||
*/
|
*/
|
||||||
year: string
|
year: string;
|
||||||
/**
|
/**
|
||||||
* Developer role on the project
|
* Developer role on the project
|
||||||
*/
|
*/
|
||||||
role: string
|
role: string;
|
||||||
/**
|
/**
|
||||||
* Technology stack list
|
* Technology stack list
|
||||||
*/
|
*/
|
||||||
stack: string[]
|
stack: string[];
|
||||||
/**
|
/**
|
||||||
* Additional CSS classes
|
* Additional CSS classes
|
||||||
*/
|
*/
|
||||||
className?: string
|
className?: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sidebar metadata display for a project: year, role, and stack.
|
* Sidebar metadata display for a project: year, role, and stack.
|
||||||
@@ -36,9 +36,11 @@ export function ProjectMetadata({ year, role, stack, className }: Props) {
|
|||||||
<div className="brutal-border-top pt-6">
|
<div className="brutal-border-top pt-6">
|
||||||
<p className="text-xs uppercase tracking-wider opacity-60">STACK</p>
|
<p className="text-xs uppercase tracking-wider opacity-60">STACK</p>
|
||||||
{stack.map((tech) => (
|
{stack.map((tech) => (
|
||||||
<p key={tech} className="text-sm">{tech}</p>
|
<p key={tech} className="text-sm">
|
||||||
|
{tech}
|
||||||
|
</p>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import type { ListResponse } from './types';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Native fetch wrapper for PocketBase API requests.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PB_URL =
|
||||||
|
process.env.NEXT_PUBLIC_PB_URL ||
|
||||||
|
(process.env.NODE_ENV === 'production'
|
||||||
|
? (() => {
|
||||||
|
throw new Error('NEXT_PUBLIC_PB_URL is not set');
|
||||||
|
})()
|
||||||
|
: 'http://127.0.0.1:8090');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for PocketBase collection fetching.
|
||||||
|
*/
|
||||||
|
export type PBFetchOptions = {
|
||||||
|
/**
|
||||||
|
* Sorting criteria (e.g., "-created,order")
|
||||||
|
*/
|
||||||
|
sort?: string;
|
||||||
|
/**
|
||||||
|
* Filter query string
|
||||||
|
*/
|
||||||
|
filter?: string;
|
||||||
|
/**
|
||||||
|
* Fields to expand (e.g., "stack")
|
||||||
|
*/
|
||||||
|
expand?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a list of records from a PocketBase collection.
|
||||||
|
*/
|
||||||
|
export async function getCollection<T>(collection: string, options: PBFetchOptions = {}): Promise<ListResponse<T>> {
|
||||||
|
const { sort, filter, expand } = options;
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (sort) {
|
||||||
|
params.set('sort', sort);
|
||||||
|
}
|
||||||
|
if (filter) {
|
||||||
|
params.set('filter', filter);
|
||||||
|
}
|
||||||
|
if (expand) {
|
||||||
|
params.set('expand', expand);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${PB_URL}/api/collections/${collection}/records?${params.toString()}`;
|
||||||
|
|
||||||
|
/* force-cache deduplicates identical fetches during the static build phase;
|
||||||
|
* it has no runtime effect in `output: 'export'` mode. */
|
||||||
|
const res = await fetch(url, { cache: 'force-cache' });
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`PocketBase ${res.status} ${res.statusText} on collection "${collection}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the first record matching an optional filter from a PocketBase collection.
|
||||||
|
*/
|
||||||
|
export async function getFirstRecord<T>(collection: string, options: PBFetchOptions = {}): Promise<T | null> {
|
||||||
|
const data = await getCollection<T>(collection, options);
|
||||||
|
return data.items[0] ?? null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './client';
|
||||||
|
export * from './types';
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* Common properties for all PocketBase records.
|
||||||
|
*/
|
||||||
|
export type BaseRecord = {
|
||||||
|
/**
|
||||||
|
* Unique record ID
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
/**
|
||||||
|
* ID of the collection this record belongs to
|
||||||
|
*/
|
||||||
|
collectionId: string;
|
||||||
|
/**
|
||||||
|
* Name of the collection this record belongs to
|
||||||
|
*/
|
||||||
|
collectionName: string;
|
||||||
|
/**
|
||||||
|
* Record creation timestamp (ISO 8601)
|
||||||
|
*/
|
||||||
|
created: string;
|
||||||
|
/**
|
||||||
|
* Record last update timestamp (ISO 8601)
|
||||||
|
*/
|
||||||
|
updated: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PocketBase collection for simple text blocks (Intro, Bio).
|
||||||
|
*/
|
||||||
|
export type PageContentRecord = BaseRecord & {
|
||||||
|
/**
|
||||||
|
* Slug corresponding to the parent section
|
||||||
|
*/
|
||||||
|
slug: string;
|
||||||
|
/**
|
||||||
|
* HTML or Markdown content string
|
||||||
|
*/
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PocketBase collection for technology skills.
|
||||||
|
*/
|
||||||
|
export type SkillRecord = BaseRecord & {
|
||||||
|
/**
|
||||||
|
* Name of the technology or tool
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* Grouping category (e.g., 'Frontend', 'Backend')
|
||||||
|
*/
|
||||||
|
category: 'Frontend' | 'Backend' | 'Tools' | 'Design' | string;
|
||||||
|
/**
|
||||||
|
* Sorting weight within the category
|
||||||
|
*/
|
||||||
|
order: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PocketBase collection for work experience history.
|
||||||
|
*/
|
||||||
|
export type ExperienceRecord = BaseRecord & {
|
||||||
|
/**
|
||||||
|
* Name of the organization
|
||||||
|
*/
|
||||||
|
company: string;
|
||||||
|
/**
|
||||||
|
* Professional title held
|
||||||
|
*/
|
||||||
|
role: string;
|
||||||
|
/**
|
||||||
|
* Start date of the tenure
|
||||||
|
*/
|
||||||
|
start_date: string;
|
||||||
|
/**
|
||||||
|
* End date of the tenure, or null if currently employed
|
||||||
|
*/
|
||||||
|
end_date: string | null;
|
||||||
|
/**
|
||||||
|
* Rich text description of responsibilities and achievements
|
||||||
|
*/
|
||||||
|
description: string;
|
||||||
|
/**
|
||||||
|
* Sorting weight for chronological display
|
||||||
|
*/
|
||||||
|
order: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PocketBase collection for portfolio projects.
|
||||||
|
*/
|
||||||
|
export type ProjectRecord = BaseRecord & {
|
||||||
|
/**
|
||||||
|
* Full title of the project
|
||||||
|
*/
|
||||||
|
title: string;
|
||||||
|
/**
|
||||||
|
* Completion or duration year (e.g., "2024")
|
||||||
|
*/
|
||||||
|
year: string;
|
||||||
|
/**
|
||||||
|
* Role performed on the project
|
||||||
|
*/
|
||||||
|
role: string;
|
||||||
|
/**
|
||||||
|
* Short summary of the project
|
||||||
|
*/
|
||||||
|
description: string;
|
||||||
|
/**
|
||||||
|
* List of specific feature or achievement points
|
||||||
|
*/
|
||||||
|
details: string[];
|
||||||
|
/**
|
||||||
|
* List of SkillRecord IDs used in the project
|
||||||
|
*/
|
||||||
|
stack: string[];
|
||||||
|
/**
|
||||||
|
* Primary thumbnail or hero image filename
|
||||||
|
*/
|
||||||
|
image: string;
|
||||||
|
/**
|
||||||
|
* Sorting weight for the project list
|
||||||
|
*/
|
||||||
|
order: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic response for a list of PocketBase records.
|
||||||
|
*/
|
||||||
|
export type ListResponse<T> = {
|
||||||
|
/**
|
||||||
|
* Current page index
|
||||||
|
*/
|
||||||
|
page: number;
|
||||||
|
/**
|
||||||
|
* Number of items per page
|
||||||
|
*/
|
||||||
|
perPage: number;
|
||||||
|
/**
|
||||||
|
* Total number of items across all pages
|
||||||
|
*/
|
||||||
|
totalItems: number;
|
||||||
|
/**
|
||||||
|
* Total number of pages available
|
||||||
|
*/
|
||||||
|
totalPages: number;
|
||||||
|
/**
|
||||||
|
* Array of records for the current page
|
||||||
|
*/
|
||||||
|
items: T[];
|
||||||
|
};
|
||||||
+3
-2
@@ -1,2 +1,3 @@
|
|||||||
export * from './ui'
|
export * from './api';
|
||||||
export * from './lib'
|
export * from './lib';
|
||||||
|
export * from './ui';
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Static contact and social links shown in navigation.
|
||||||
|
*/
|
||||||
|
export const CONTACT_LINKS = {
|
||||||
|
/**
|
||||||
|
* Primary contact email address
|
||||||
|
*/
|
||||||
|
email: 'hello@allmy.work',
|
||||||
|
/**
|
||||||
|
* LinkedIn profile URL
|
||||||
|
*/
|
||||||
|
linkedin: 'https://linkedin.com',
|
||||||
|
/**
|
||||||
|
* Instagram profile URL
|
||||||
|
*/
|
||||||
|
instagram: 'https://instagram.com',
|
||||||
|
/**
|
||||||
|
* Are.na profile URL
|
||||||
|
*/
|
||||||
|
arena: 'https://are.na',
|
||||||
|
} as const;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Fraunces, Public_Sans } from 'next/font/google'
|
import { Fraunces, Public_Sans } from 'next/font/google';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Heading font — variable axes for brutalist variation settings
|
* Heading font — variable axes for brutalist variation settings
|
||||||
@@ -7,7 +7,7 @@ export const fraunces = Fraunces({
|
|||||||
subsets: ['latin'],
|
subsets: ['latin'],
|
||||||
variable: '--font-fraunces',
|
variable: '--font-fraunces',
|
||||||
axes: ['opsz', 'SOFT', 'WONK'],
|
axes: ['opsz', 'SOFT', 'WONK'],
|
||||||
})
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Body font
|
* Body font
|
||||||
@@ -15,4 +15,4 @@ export const fraunces = Fraunces({
|
|||||||
export const publicSans = Public_Sans({
|
export const publicSans = Public_Sans({
|
||||||
subsets: ['latin'],
|
subsets: ['latin'],
|
||||||
variable: '--font-public-sans',
|
variable: '--font-public-sans',
|
||||||
})
|
});
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
export { cn } from './cn'
|
export type { ClassValue } from 'clsx';
|
||||||
export type { ClassValue } from 'clsx'
|
export { CONTACT_LINKS } from './config/config';
|
||||||
export * from './fonts'
|
export * from './fonts/fonts';
|
||||||
|
export { cn } from './utils/cn/cn';
|
||||||
|
export * from './utils/formatDate/formatDate';
|
||||||
|
export { groupByKey } from './utils/groupByKey/groupByKey';
|
||||||
|
|||||||
@@ -1,40 +1,39 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { cn } from './cn';
|
||||||
import { cn } from './cn'
|
|
||||||
|
|
||||||
describe('cn', () => {
|
describe('cn', () => {
|
||||||
describe('basic merging', () => {
|
describe('basic merging', () => {
|
||||||
it('returns single class unchanged', () => {
|
it('returns single class unchanged', () => {
|
||||||
expect(cn('foo')).toBe('foo')
|
expect(cn('foo')).toBe('foo');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('joins multiple classes', () => {
|
it('joins multiple classes', () => {
|
||||||
expect(cn('foo', 'bar')).toBe('foo bar')
|
expect(cn('foo', 'bar')).toBe('foo bar');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('conditional classes', () => {
|
describe('conditional classes', () => {
|
||||||
it('includes truthy conditional', () => {
|
it('includes truthy conditional', () => {
|
||||||
expect(cn('foo', true && 'bar')).toBe('foo bar')
|
expect(cn('foo', true && 'bar')).toBe('foo bar');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('excludes falsy conditional', () => {
|
it('excludes falsy conditional', () => {
|
||||||
expect(cn('foo', false && 'bar')).toBe('foo')
|
expect(cn('foo', false && 'bar')).toBe('foo');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('object syntax', () => {
|
describe('object syntax', () => {
|
||||||
it('includes classes with truthy object values', () => {
|
it('includes classes with truthy object values', () => {
|
||||||
expect(cn({ foo: true, bar: false })).toBe('foo')
|
expect(cn({ foo: true, bar: false })).toBe('foo');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('tailwind conflict resolution', () => {
|
describe('tailwind conflict resolution', () => {
|
||||||
it('last padding wins', () => {
|
it('last padding wins', () => {
|
||||||
expect(cn('px-2', 'px-4')).toBe('px-4')
|
expect(cn('px-2', 'px-4')).toBe('px-4');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('last text color wins', () => {
|
it('last text color wins', () => {
|
||||||
expect(cn('text-red-500', 'text-blue-500')).toBe('text-blue-500')
|
expect(cn('text-red-500', 'text-blue-500')).toBe('text-blue-500');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { clsx, type ClassValue } from 'clsx'
|
import { type ClassValue, clsx } from 'clsx';
|
||||||
import { twMerge } from 'tailwind-merge'
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merges Tailwind classes, resolving conflicts in favor of the last value.
|
* Merges Tailwind classes, resolving conflicts in favor of the last value.
|
||||||
*/
|
*/
|
||||||
export function cn(...inputs: ClassValue[]): string {
|
export function cn(...inputs: ClassValue[]): string {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { formatYearRange } from './formatDate';
|
||||||
|
|
||||||
|
describe('formatYearRange', () => {
|
||||||
|
describe('Success Paths', () => {
|
||||||
|
it('formats a date range within the same year', () => {
|
||||||
|
const start = '2024-01-01 12:00:00.000Z';
|
||||||
|
const end = '2024-12-31 12:00:00.000Z';
|
||||||
|
expect(formatYearRange(start, end)).toBe('2024');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats a range between different years', () => {
|
||||||
|
const start = '2021-05-15 12:00:00.000Z';
|
||||||
|
const end = '2024-03-20 12:00:00.000Z';
|
||||||
|
expect(formatYearRange(start, end)).toBe('2021 — 2024');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats a range with null end date as "Present"', () => {
|
||||||
|
const start = '2022-08-01 12:00:00.000Z';
|
||||||
|
const end = null;
|
||||||
|
expect(formatYearRange(start, end)).toBe('2022 — Present');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error & Edge Cases', () => {
|
||||||
|
it('throws if start date is invalid', () => {
|
||||||
|
const start = 'not-a-date';
|
||||||
|
const end = '2024-01-01';
|
||||||
|
expect(() => formatYearRange(start, end)).toThrow('Invalid start date');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws if end date is provided but invalid', () => {
|
||||||
|
const start = '2024-01-01';
|
||||||
|
const end = 'invalid';
|
||||||
|
expect(() => formatYearRange(start, end)).toThrow('Invalid end date');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws if start year is after end year', () => {
|
||||||
|
const start = '2024-01-01';
|
||||||
|
const end = '2020-01-01';
|
||||||
|
expect(() => formatYearRange(start, end)).toThrow('Start year cannot be after end year');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty strings by throwing', () => {
|
||||||
|
expect(() => formatYearRange('', null)).toThrow('Invalid start date');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Formats a PocketBase date string into a localized year string or "Present".
|
||||||
|
* @throws {Error} if any date is invalid or if the range is logically impossible.
|
||||||
|
*/
|
||||||
|
export function formatYearRange(start: string, end: string | null): string {
|
||||||
|
const startDate = new Date(start);
|
||||||
|
if (Number.isNaN(startDate.getTime())) {
|
||||||
|
throw new Error('Invalid start date');
|
||||||
|
}
|
||||||
|
const startYear = startDate.getFullYear();
|
||||||
|
|
||||||
|
if (end === null) {
|
||||||
|
return `${startYear} — Present`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const endDate = new Date(end);
|
||||||
|
if (Number.isNaN(endDate.getTime())) {
|
||||||
|
throw new Error('Invalid end date');
|
||||||
|
}
|
||||||
|
const endYear = endDate.getFullYear();
|
||||||
|
|
||||||
|
if (startYear > endYear) {
|
||||||
|
throw new Error('Start year cannot be after end year');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startYear === endYear) {
|
||||||
|
return `${startYear}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${startYear} — ${endYear}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { groupByKey } from './groupByKey';
|
||||||
|
|
||||||
|
describe('groupByKey', () => {
|
||||||
|
describe('basic grouping', () => {
|
||||||
|
it('groups items by a string key', () => {
|
||||||
|
const items = [
|
||||||
|
{ category: 'Frontend', name: 'React' },
|
||||||
|
{ category: 'Backend', name: 'Node' },
|
||||||
|
{ category: 'Frontend', name: 'Vue' },
|
||||||
|
];
|
||||||
|
expect(groupByKey(items, 'category')).toEqual({
|
||||||
|
Frontend: [
|
||||||
|
{ category: 'Frontend', name: 'React' },
|
||||||
|
{ category: 'Frontend', name: 'Vue' },
|
||||||
|
],
|
||||||
|
Backend: [{ category: 'Backend', name: 'Node' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves insertion order within each group', () => {
|
||||||
|
const items = [
|
||||||
|
{ category: 'A', order: 1 },
|
||||||
|
{ category: 'A', order: 2 },
|
||||||
|
];
|
||||||
|
expect(groupByKey(items, 'category')['A']).toEqual([
|
||||||
|
{ category: 'A', order: 1 },
|
||||||
|
{ category: 'A', order: 2 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('returns empty object for empty array', () => {
|
||||||
|
expect(groupByKey<{ category: string }>([], 'category')).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles all items in same group', () => {
|
||||||
|
const items = [
|
||||||
|
{ type: 'X', id: 1 },
|
||||||
|
{ type: 'X', id: 2 },
|
||||||
|
];
|
||||||
|
const result = groupByKey(items, 'type');
|
||||||
|
expect(Object.keys(result)).toHaveLength(1);
|
||||||
|
expect(result['X']).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles single item', () => {
|
||||||
|
const items = [{ category: 'Only', name: 'One' }];
|
||||||
|
expect(groupByKey(items, 'category')).toEqual({
|
||||||
|
Only: [{ category: 'Only', name: 'One' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Groups an array of objects by a shared key into a record of arrays.
|
||||||
|
* @param items - Array of objects to group
|
||||||
|
* @param key - Key whose value determines the group
|
||||||
|
* @returns Record mapping each unique key value to an array of matching items
|
||||||
|
*/
|
||||||
|
export function groupByKey<T>(items: T[], key: keyof T): Record<string, T[]> {
|
||||||
|
return items.reduce(
|
||||||
|
(acc, item) => {
|
||||||
|
const k = String(item[key]);
|
||||||
|
if (!acc[k]) {
|
||||||
|
acc[k] = [];
|
||||||
|
}
|
||||||
|
acc[k].push(item);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, T[]>,
|
||||||
|
);
|
||||||
|
}
|
||||||
+64
-23
@@ -2,7 +2,7 @@
|
|||||||
/* === TYPOGRAPHY SCALE (Augmented Fourth 1.414) === */
|
/* === TYPOGRAPHY SCALE (Augmented Fourth 1.414) === */
|
||||||
--font-size: 16px;
|
--font-size: 16px;
|
||||||
--text-xs: 0.707rem;
|
--text-xs: 0.707rem;
|
||||||
--text-sm: 0.840rem;
|
--text-sm: 0.84rem;
|
||||||
--text-base: 1rem;
|
--text-base: 1rem;
|
||||||
--text-lg: 1.414rem;
|
--text-lg: 1.414rem;
|
||||||
--text-xl: 2rem;
|
--text-xl: 2rem;
|
||||||
@@ -29,9 +29,9 @@
|
|||||||
--fraunces-soft: 0;
|
--fraunces-soft: 0;
|
||||||
|
|
||||||
/* === COLOR PALETTE === */
|
/* === COLOR PALETTE === */
|
||||||
--ochre-clay: #D9B48F;
|
--ochre-clay: #d9b48f;
|
||||||
--slate-indigo: #3B4A59;
|
--slate-indigo: #3b4a59;
|
||||||
--burnt-oxide: #A64B35;
|
--burnt-oxide: #a64b35;
|
||||||
--carbon-black: #121212;
|
--carbon-black: #121212;
|
||||||
|
|
||||||
/* === SEMANTIC COLORS === */
|
/* === SEMANTIC COLORS === */
|
||||||
@@ -126,7 +126,7 @@
|
|||||||
|
|
||||||
/* Paper grain texture */
|
/* Paper grain texture */
|
||||||
body::before {
|
body::before {
|
||||||
content: '';
|
content: "";
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background-image:
|
background-image:
|
||||||
@@ -138,19 +138,36 @@
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
font-family: var(--font-heading);
|
font-family: var(--font-heading);
|
||||||
font-weight: var(--font-weight-heading);
|
font-weight: var(--font-weight-heading);
|
||||||
line-height: var(--line-height-tight);
|
line-height: var(--line-height-tight);
|
||||||
font-variation-settings: 'WONK' var(--fraunces-wonk), 'SOFT' var(--fraunces-soft);
|
font-variation-settings:
|
||||||
|
"WONK" var(--fraunces-wonk),
|
||||||
|
"SOFT" var(--fraunces-soft);
|
||||||
color: var(--carbon-black);
|
color: var(--carbon-black);
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 { font-size: var(--text-4xl); }
|
h1 {
|
||||||
h2 { font-size: var(--text-3xl); }
|
font-size: var(--text-4xl);
|
||||||
h3 { font-size: var(--text-2xl); }
|
}
|
||||||
h4 { font-size: var(--text-xl); }
|
h2 {
|
||||||
h5 { font-size: var(--text-lg); }
|
font-size: var(--text-3xl);
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
}
|
||||||
|
h4 {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
}
|
||||||
|
h5 {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
@@ -180,18 +197,42 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Brutalist utility classes */
|
/* Brutalist utility classes */
|
||||||
.brutal-shadow { box-shadow: var(--shadow-brutal); }
|
.brutal-shadow {
|
||||||
.brutal-shadow-sm { box-shadow: var(--shadow-brutal-sm); }
|
box-shadow: var(--shadow-brutal);
|
||||||
.brutal-shadow-lg { box-shadow: var(--shadow-brutal-lg); }
|
}
|
||||||
.brutal-border { border: var(--border-width) solid var(--carbon-black); }
|
.brutal-shadow-sm {
|
||||||
.brutal-border-top { border-top: var(--border-width) solid var(--carbon-black); }
|
box-shadow: var(--shadow-brutal-sm);
|
||||||
.brutal-border-bottom { border-bottom: var(--border-width) solid var(--carbon-black); }
|
}
|
||||||
.brutal-border-left { border-left: var(--border-width) solid var(--carbon-black); }
|
.brutal-shadow-lg {
|
||||||
.brutal-border-right { border-right: var(--border-width) solid var(--carbon-black); }
|
box-shadow: var(--shadow-brutal-lg);
|
||||||
|
}
|
||||||
|
.brutal-border {
|
||||||
|
border: var(--border-width) solid var(--carbon-black);
|
||||||
|
}
|
||||||
|
.brutal-border-top {
|
||||||
|
border-top: var(--border-width) solid var(--carbon-black);
|
||||||
|
}
|
||||||
|
.brutal-border-bottom {
|
||||||
|
border-bottom: var(--border-width) solid var(--carbon-black);
|
||||||
|
}
|
||||||
|
.brutal-border-left {
|
||||||
|
border-left: var(--border-width) solid var(--carbon-black);
|
||||||
|
}
|
||||||
|
.brutal-border-right {
|
||||||
|
border-right: var(--border-width) solid var(--carbon-black);
|
||||||
|
}
|
||||||
|
|
||||||
/* Animations */
|
/* Animations */
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
from { opacity: 0; transform: translateY(10px); }
|
from {
|
||||||
to { opacity: 1; transform: translateY(0); }
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animate-fadeIn {
|
||||||
|
animation: fadeIn 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
.animate-fadeIn { animation: fadeIn 0.5s cubic-bezier(0.4, 0, 0.2, 1); }
|
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export { Badge } from './ui/Badge'
|
export type { BadgeVariant } from './ui/Badge';
|
||||||
export type { BadgeVariant } from './ui/Badge'
|
export { Badge } from './ui/Badge';
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||||
import { Badge } from './Badge'
|
import { Badge } from './Badge';
|
||||||
|
|
||||||
const meta: Meta<typeof Badge> = {
|
const meta: Meta<typeof Badge> = {
|
||||||
title: 'Shared/Badge',
|
title: 'Shared/Badge',
|
||||||
component: Badge,
|
component: Badge,
|
||||||
}
|
};
|
||||||
|
|
||||||
export default meta
|
export default meta;
|
||||||
|
|
||||||
type Story = StoryObj<typeof Badge>
|
type Story = StoryObj<typeof Badge>;
|
||||||
|
|
||||||
export const AllVariants: Story = {
|
export const AllVariants: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
@@ -19,4 +19,4 @@ export const AllVariants: Story = {
|
|||||||
<Badge variant="outline">Outline</Badge>
|
<Badge variant="outline">Outline</Badge>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,52 +1,51 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { render, screen } from '@testing-library/react';
|
||||||
import { render, screen } from '@testing-library/react'
|
import { Badge } from './Badge';
|
||||||
import { Badge } from './Badge'
|
|
||||||
|
|
||||||
describe('Badge', () => {
|
describe('Badge', () => {
|
||||||
describe('rendering', () => {
|
describe('rendering', () => {
|
||||||
it('renders children', () => {
|
it('renders children', () => {
|
||||||
render(<Badge>React</Badge>)
|
render(<Badge>React</Badge>);
|
||||||
expect(screen.getByText('React')).toBeInTheDocument()
|
expect(screen.getByText('React')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('renders as inline span', () => {
|
it('renders as inline span', () => {
|
||||||
render(<Badge>Tag</Badge>)
|
render(<Badge>Tag</Badge>);
|
||||||
expect(screen.getByText('Tag').tagName).toBe('SPAN')
|
expect(screen.getByText('Tag').tagName).toBe('SPAN');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('variants', () => {
|
describe('variants', () => {
|
||||||
it('applies default variant classes', () => {
|
it('applies default variant classes', () => {
|
||||||
render(<Badge variant="default">Tag</Badge>)
|
render(<Badge variant="default">Tag</Badge>);
|
||||||
const el = screen.getByText('Tag')
|
const el = screen.getByText('Tag');
|
||||||
expect(el).toHaveClass('bg-carbon-black', 'text-ochre-clay')
|
expect(el).toHaveClass('bg-carbon-black', 'text-ochre-clay');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('applies primary variant classes', () => {
|
it('applies primary variant classes', () => {
|
||||||
render(<Badge variant="primary">Tag</Badge>)
|
render(<Badge variant="primary">Tag</Badge>);
|
||||||
expect(screen.getByText('Tag')).toHaveClass('bg-burnt-oxide')
|
expect(screen.getByText('Tag')).toHaveClass('bg-burnt-oxide');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('applies secondary variant classes', () => {
|
it('applies secondary variant classes', () => {
|
||||||
render(<Badge variant="secondary">Tag</Badge>)
|
render(<Badge variant="secondary">Tag</Badge>);
|
||||||
expect(screen.getByText('Tag')).toHaveClass('bg-slate-indigo')
|
expect(screen.getByText('Tag')).toHaveClass('bg-slate-indigo');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('applies outline variant classes', () => {
|
it('applies outline variant classes', () => {
|
||||||
render(<Badge variant="outline">Tag</Badge>)
|
render(<Badge variant="outline">Tag</Badge>);
|
||||||
expect(screen.getByText('Tag')).toHaveClass('bg-transparent')
|
expect(screen.getByText('Tag')).toHaveClass('bg-transparent');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('defaults to default variant when unspecified', () => {
|
it('defaults to default variant when unspecified', () => {
|
||||||
render(<Badge>Tag</Badge>)
|
render(<Badge>Tag</Badge>);
|
||||||
expect(screen.getByText('Tag')).toHaveClass('bg-carbon-black')
|
expect(screen.getByText('Tag')).toHaveClass('bg-carbon-black');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('className passthrough', () => {
|
describe('className passthrough', () => {
|
||||||
it('merges custom className', () => {
|
it('merges custom className', () => {
|
||||||
render(<Badge className="mt-4">Tag</Badge>)
|
render(<Badge className="mt-4">Tag</Badge>);
|
||||||
expect(screen.getByText('Tag')).toHaveClass('mt-4')
|
expect(screen.getByText('Tag')).toHaveClass('mt-4');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react';
|
||||||
import { cn } from '$shared/lib'
|
import { cn } from '$shared/lib';
|
||||||
|
|
||||||
export type BadgeVariant = 'default' | 'primary' | 'secondary' | 'outline'
|
export type BadgeVariant = 'default' | 'primary' | 'secondary' | 'outline';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
* Badge content
|
* Badge content
|
||||||
*/
|
*/
|
||||||
children: ReactNode
|
children: ReactNode;
|
||||||
/**
|
/**
|
||||||
* Visual variant
|
* Visual variant
|
||||||
* @default 'default'
|
* @default 'default'
|
||||||
*/
|
*/
|
||||||
variant?: BadgeVariant
|
variant?: BadgeVariant;
|
||||||
/**
|
/**
|
||||||
* Additional CSS classes
|
* Additional CSS classes
|
||||||
*/
|
*/
|
||||||
className?: string
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VARIANTS: Record<BadgeVariant, string> = {
|
const VARIANTS: Record<BadgeVariant, string> = {
|
||||||
@@ -24,7 +24,7 @@ const VARIANTS: Record<BadgeVariant, string> = {
|
|||||||
primary: 'brutal-border bg-burnt-oxide text-ochre-clay',
|
primary: 'brutal-border bg-burnt-oxide text-ochre-clay',
|
||||||
secondary: 'brutal-border bg-slate-indigo text-ochre-clay',
|
secondary: 'brutal-border bg-slate-indigo text-ochre-clay',
|
||||||
outline: 'brutal-border bg-transparent text-carbon-black',
|
outline: 'brutal-border bg-transparent text-carbon-black',
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Small label for categorization or status.
|
* Small label for categorization or status.
|
||||||
@@ -34,5 +34,5 @@ export function Badge({ children, variant = 'default', className }: Props) {
|
|||||||
<span className={cn('inline-block px-3 py-1 text-xs uppercase tracking-wider', VARIANTS[variant], className)}>
|
<span className={cn('inline-block px-3 py-1 text-xs uppercase tracking-wider', VARIANTS[variant], className)}>
|
||||||
{children}
|
{children}
|
||||||
</span>
|
</span>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export { Button } from './ui/Button'
|
export type { ButtonSize, ButtonVariant } from './ui/Button';
|
||||||
export type { ButtonVariant, ButtonSize } from './ui/Button'
|
export { Button } from './ui/Button';
|
||||||
|
|||||||
@@ -1,35 +1,49 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||||
import { Button } from './Button'
|
import { Button } from './Button';
|
||||||
|
|
||||||
const meta: Meta<typeof Button> = {
|
const meta: Meta<typeof Button> = {
|
||||||
title: 'Shared/Button',
|
title: 'Shared/Button',
|
||||||
component: Button,
|
component: Button,
|
||||||
}
|
};
|
||||||
|
|
||||||
export default meta
|
export default meta;
|
||||||
|
|
||||||
type Story = StoryObj<typeof Button>
|
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">Primary</Button>
|
<Button variant="primary" size="md">
|
||||||
<Button variant="secondary" size="md">Secondary</Button>
|
Primary
|
||||||
<Button variant="outline" size="md">Outline</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="md">Ghost</Button>
|
<Button variant="secondary" size="md">
|
||||||
|
Secondary
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="md">
|
||||||
|
Outline
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="md">
|
||||||
|
Ghost
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}
|
};
|
||||||
|
|
||||||
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">Small</Button>
|
<Button variant="primary" size="sm">
|
||||||
<Button variant="primary" size="md">Medium</Button>
|
Small
|
||||||
<Button variant="primary" size="lg">Large</Button>
|
</Button>
|
||||||
|
<Button variant="primary" size="md">
|
||||||
|
Medium
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" size="lg">
|
||||||
|
Large
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}
|
};
|
||||||
|
|
||||||
export const Disabled: Story = {
|
export const Disabled: Story = {
|
||||||
args: {
|
args: {
|
||||||
@@ -44,4 +58,4 @@ export const Disabled: Story = {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,67 +1,66 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest'
|
import { render, screen } from '@testing-library/react';
|
||||||
import { render, screen } from '@testing-library/react'
|
import userEvent from '@testing-library/user-event';
|
||||||
import userEvent from '@testing-library/user-event'
|
import { Button } from './Button';
|
||||||
import { Button } from './Button'
|
|
||||||
|
|
||||||
describe('Button', () => {
|
describe('Button', () => {
|
||||||
describe('rendering', () => {
|
describe('rendering', () => {
|
||||||
it('renders children', () => {
|
it('renders children', () => {
|
||||||
render(<Button>Click me</Button>)
|
render(<Button>Click me</Button>);
|
||||||
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument()
|
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
it('renders as button element', () => {
|
it('renders as button element', () => {
|
||||||
render(<Button>Click</Button>)
|
render(<Button>Click</Button>);
|
||||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
expect(screen.getByRole('button')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
describe('variants', () => {
|
describe('variants', () => {
|
||||||
it('applies primary variant by default', () => {
|
it('applies primary variant by default', () => {
|
||||||
render(<Button>Go</Button>)
|
render(<Button>Go</Button>);
|
||||||
expect(screen.getByRole('button')).toHaveClass('bg-burnt-oxide')
|
expect(screen.getByRole('button')).toHaveClass('bg-burnt-oxide');
|
||||||
})
|
});
|
||||||
it('applies secondary variant', () => {
|
it('applies secondary variant', () => {
|
||||||
render(<Button variant="secondary">Go</Button>)
|
render(<Button variant="secondary">Go</Button>);
|
||||||
expect(screen.getByRole('button')).toHaveClass('bg-slate-indigo')
|
expect(screen.getByRole('button')).toHaveClass('bg-slate-indigo');
|
||||||
})
|
});
|
||||||
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-transparent')
|
expect(screen.getByRole('button')).toHaveClass('bg-transparent');
|
||||||
})
|
});
|
||||||
it('applies ghost variant', () => {
|
it('applies ghost variant', () => {
|
||||||
render(<Button variant="ghost">Go</Button>)
|
render(<Button variant="ghost">Go</Button>);
|
||||||
expect(screen.getByRole('button')).toHaveClass('bg-ochre-clay')
|
expect(screen.getByRole('button')).toHaveClass('bg-ochre-clay');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
describe('sizes', () => {
|
describe('sizes', () => {
|
||||||
it('applies md size by default', () => {
|
it('applies md size by default', () => {
|
||||||
render(<Button>Go</Button>)
|
render(<Button>Go</Button>);
|
||||||
expect(screen.getByRole('button')).toHaveClass('px-6', 'py-3')
|
expect(screen.getByRole('button')).toHaveClass('px-6', 'py-3');
|
||||||
})
|
});
|
||||||
it('applies sm size', () => {
|
it('applies sm size', () => {
|
||||||
render(<Button size="sm">Go</Button>)
|
render(<Button size="sm">Go</Button>);
|
||||||
expect(screen.getByRole('button')).toHaveClass('px-4', 'py-2')
|
expect(screen.getByRole('button')).toHaveClass('px-4', 'py-2');
|
||||||
})
|
});
|
||||||
it('applies lg size', () => {
|
it('applies lg size', () => {
|
||||||
render(<Button size="lg">Go</Button>)
|
render(<Button size="lg">Go</Button>);
|
||||||
expect(screen.getByRole('button')).toHaveClass('px-8', 'py-4')
|
expect(screen.getByRole('button')).toHaveClass('px-8', 'py-4');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
describe('interactions', () => {
|
describe('interactions', () => {
|
||||||
it('calls onClick when clicked', async () => {
|
it('calls onClick when clicked', async () => {
|
||||||
const onClick = vi.fn()
|
const onClick = vi.fn();
|
||||||
render(<Button onClick={onClick}>Go</Button>)
|
render(<Button onClick={onClick}>Go</Button>);
|
||||||
await userEvent.click(screen.getByRole('button'))
|
await userEvent.click(screen.getByRole('button'));
|
||||||
expect(onClick).toHaveBeenCalledOnce()
|
expect(onClick).toHaveBeenCalledOnce();
|
||||||
})
|
});
|
||||||
it('is disabled when disabled prop is set', () => {
|
it('is disabled when disabled prop is set', () => {
|
||||||
render(<Button disabled>Go</Button>)
|
render(<Button disabled>Go</Button>);
|
||||||
expect(screen.getByRole('button')).toBeDisabled()
|
expect(screen.getByRole('button')).toBeDisabled();
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
describe('className passthrough', () => {
|
describe('className passthrough', () => {
|
||||||
it('merges custom className', () => {
|
it('merges custom className', () => {
|
||||||
render(<Button className="w-full">Go</Button>)
|
render(<Button className="w-full">Go</Button>);
|
||||||
expect(screen.getByRole('button')).toHaveClass('w-full')
|
expect(screen.getByRole('button')).toHaveClass('w-full');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||||
import { cn } from '$shared/lib'
|
import { cn } from '$shared/lib';
|
||||||
|
|
||||||
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost'
|
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost';
|
||||||
export type ButtonSize = 'sm' | 'md' | 'lg'
|
export type ButtonSize = 'sm' | 'md' | 'lg';
|
||||||
|
|
||||||
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
|
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
/**
|
/**
|
||||||
* Visual variant
|
* Visual variant
|
||||||
* @default 'primary'
|
* @default 'primary'
|
||||||
*/
|
*/
|
||||||
variant?: ButtonVariant
|
variant?: ButtonVariant;
|
||||||
/**
|
/**
|
||||||
* Size preset
|
* Size preset
|
||||||
* @default 'md'
|
* @default 'md'
|
||||||
*/
|
*/
|
||||||
size?: ButtonSize
|
size?: ButtonSize;
|
||||||
/**
|
/**
|
||||||
* Button content
|
* Button content
|
||||||
*/
|
*/
|
||||||
children: ReactNode
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VARIANTS: Record<ButtonVariant, string> = {
|
const VARIANTS: Record<ButtonVariant, string> = {
|
||||||
@@ -26,15 +26,16 @@ const VARIANTS: Record<ButtonVariant, string> = {
|
|||||||
secondary: 'bg-slate-indigo text-ochre-clay',
|
secondary: 'bg-slate-indigo text-ochre-clay',
|
||||||
outline: 'bg-transparent text-carbon-black border-carbon-black',
|
outline: 'bg-transparent text-carbon-black border-carbon-black',
|
||||||
ghost: 'bg-ochre-clay text-carbon-black border-carbon-black',
|
ghost: 'bg-ochre-clay text-carbon-black border-carbon-black',
|
||||||
}
|
};
|
||||||
|
|
||||||
const SIZES: Record<ButtonSize, string> = {
|
const SIZES: Record<ButtonSize, string> = {
|
||||||
sm: 'px-4 py-2 text-sm',
|
sm: 'px-4 py-2 text-sm',
|
||||||
md: 'px-6 py-3 text-base',
|
md: 'px-6 py-3 text-base',
|
||||||
lg: 'px-8 py-4 text-lg',
|
lg: 'px-8 py-4 text-lg',
|
||||||
}
|
};
|
||||||
|
|
||||||
const BASE = 'brutal-border brutal-shadow transition-all duration-200 hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-[6px_6px_0_var(--carbon-black)] active:translate-x-[4px] active:translate-y-[4px] active:shadow-[4px_4px_0_var(--carbon-black)] uppercase tracking-wider'
|
const BASE =
|
||||||
|
'brutal-border brutal-shadow transition-all duration-200 hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-[6px_6px_0_var(--carbon-black)] active:translate-x-[4px] active:translate-y-[4px] active:shadow-[4px_4px_0_var(--carbon-black)] uppercase tracking-wider';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Brutalist button with variants and sizes.
|
* Brutalist button with variants and sizes.
|
||||||
@@ -44,5 +45,5 @@ export function Button({ variant = 'primary', size = 'md', className, children,
|
|||||||
<button className={cn(BASE, VARIANTS[variant], SIZES[size], className)} {...props}>
|
<button className={cn(BASE, VARIANTS[variant], SIZES[size], className)} {...props}>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './ui/Card'
|
export type { CardBackground } from './ui/Card';
|
||||||
export type { CardBackground } from './ui/Card'
|
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from './ui/Card';
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card'
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from './Card';
|
||||||
|
|
||||||
const meta: Meta<typeof Card> = {
|
const meta: Meta<typeof Card> = {
|
||||||
title: 'Shared/Card',
|
title: 'Shared/Card',
|
||||||
component: Card,
|
component: Card,
|
||||||
}
|
};
|
||||||
|
|
||||||
export default meta
|
export default meta;
|
||||||
|
|
||||||
type Story = StoryObj<typeof Card>
|
type Story = StoryObj<typeof Card>;
|
||||||
|
|
||||||
export const AllBackgrounds: Story = {
|
export const AllBackgrounds: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
@@ -36,19 +36,17 @@ export const AllBackgrounds: Story = {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}
|
};
|
||||||
|
|
||||||
export const NoPadding: Story = {
|
export const NoPadding: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
<div className="p-8 bg-ochre-clay">
|
<div className="p-8 bg-ochre-clay">
|
||||||
<Card noPadding className="w-64 overflow-hidden">
|
<Card noPadding className="w-64 overflow-hidden">
|
||||||
<div className="h-40 bg-slate-indigo flex items-center justify-center text-ochre-clay">
|
<div className="h-40 bg-slate-indigo flex items-center justify-center text-ochre-clay">Image placeholder</div>
|
||||||
Image placeholder
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}
|
};
|
||||||
|
|
||||||
export const FullComposition: Story = {
|
export const FullComposition: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
@@ -67,4 +65,4 @@ export const FullComposition: Story = {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,79 +1,78 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { render, screen } from '@testing-library/react';
|
||||||
import { render, screen } from '@testing-library/react'
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from './Card';
|
||||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card'
|
|
||||||
|
|
||||||
describe('Card', () => {
|
describe('Card', () => {
|
||||||
describe('rendering', () => {
|
describe('rendering', () => {
|
||||||
it('renders children', () => {
|
it('renders children', () => {
|
||||||
render(<Card>Content</Card>)
|
render(<Card>Content</Card>);
|
||||||
expect(screen.getByText('Content')).toBeInTheDocument()
|
expect(screen.getByText('Content')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
it('has brutal-border and brutal-shadow classes', () => {
|
it('has brutal-border and brutal-shadow classes', () => {
|
||||||
const { container } = render(<Card>Content</Card>)
|
const { container } = render(<Card>Content</Card>);
|
||||||
expect(container.firstChild).toHaveClass('brutal-border', 'brutal-shadow')
|
expect(container.firstChild).toHaveClass('brutal-border', 'brutal-shadow');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
describe('background variants', () => {
|
describe('background variants', () => {
|
||||||
it('defaults to ochre background', () => {
|
it('defaults to ochre background', () => {
|
||||||
const { container } = render(<Card>Content</Card>)
|
const { container } = render(<Card>Content</Card>);
|
||||||
expect(container.firstChild).toHaveClass('bg-ochre-clay')
|
expect(container.firstChild).toHaveClass('bg-ochre-clay');
|
||||||
})
|
});
|
||||||
it('applies slate background', () => {
|
it('applies slate background', () => {
|
||||||
const { container } = render(<Card background="slate">Content</Card>)
|
const { container } = render(<Card background="slate">Content</Card>);
|
||||||
expect(container.firstChild).toHaveClass('bg-slate-indigo')
|
expect(container.firstChild).toHaveClass('bg-slate-indigo');
|
||||||
})
|
});
|
||||||
it('applies white background', () => {
|
it('applies white background', () => {
|
||||||
const { container } = render(<Card background="white">Content</Card>)
|
const { container } = render(<Card background="white">Content</Card>);
|
||||||
expect(container.firstChild).toHaveClass('bg-white')
|
expect(container.firstChild).toHaveClass('bg-white');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
describe('padding', () => {
|
describe('padding', () => {
|
||||||
it('has default padding', () => {
|
it('has default padding', () => {
|
||||||
const { container } = render(<Card>Content</Card>)
|
const { container } = render(<Card>Content</Card>);
|
||||||
expect(container.firstChild).toHaveClass('p-6')
|
expect(container.firstChild).toHaveClass('p-6');
|
||||||
})
|
});
|
||||||
it('removes padding when noPadding is true', () => {
|
it('removes padding when noPadding is true', () => {
|
||||||
const { container } = render(<Card noPadding>Content</Card>)
|
const { container } = render(<Card noPadding>Content</Card>);
|
||||||
expect(container.firstChild).not.toHaveClass('p-6')
|
expect(container.firstChild).not.toHaveClass('p-6');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
describe('className passthrough', () => {
|
describe('className passthrough', () => {
|
||||||
it('merges custom className', () => {
|
it('merges custom className', () => {
|
||||||
const { container } = render(<Card className="group">Content</Card>)
|
const { container } = render(<Card className="group">Content</Card>);
|
||||||
expect(container.firstChild).toHaveClass('group')
|
expect(container.firstChild).toHaveClass('group');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
describe('CardHeader', () => {
|
describe('CardHeader', () => {
|
||||||
it('renders children with bottom margin', () => {
|
it('renders children with bottom margin', () => {
|
||||||
render(<CardHeader>Header</CardHeader>)
|
render(<CardHeader>Header</CardHeader>);
|
||||||
expect(screen.getByText('Header')).toHaveClass('mb-4')
|
expect(screen.getByText('Header')).toHaveClass('mb-4');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
describe('CardTitle', () => {
|
describe('CardTitle', () => {
|
||||||
it('renders children as h3', () => {
|
it('renders children as h3', () => {
|
||||||
render(<CardTitle>Title</CardTitle>)
|
render(<CardTitle>Title</CardTitle>);
|
||||||
expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('Title')
|
expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('Title');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
describe('CardDescription', () => {
|
describe('CardDescription', () => {
|
||||||
it('renders children as paragraph with opacity', () => {
|
it('renders children as paragraph with opacity', () => {
|
||||||
render(<CardDescription>Desc</CardDescription>)
|
render(<CardDescription>Desc</CardDescription>);
|
||||||
const el = screen.getByText('Desc')
|
const el = screen.getByText('Desc');
|
||||||
expect(el.tagName).toBe('P')
|
expect(el.tagName).toBe('P');
|
||||||
expect(el).toHaveClass('opacity-80')
|
expect(el).toHaveClass('opacity-80');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
describe('CardContent', () => {
|
describe('CardContent', () => {
|
||||||
it('renders children in a div', () => {
|
it('renders children in a div', () => {
|
||||||
render(<CardContent>Body</CardContent>)
|
render(<CardContent>Body</CardContent>);
|
||||||
expect(screen.getByText('Body')).toBeInTheDocument()
|
expect(screen.getByText('Body')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
describe('CardFooter', () => {
|
describe('CardFooter', () => {
|
||||||
it('renders children with top border', () => {
|
it('renders children with top border', () => {
|
||||||
render(<CardFooter>Footer</CardFooter>)
|
render(<CardFooter>Footer</CardFooter>);
|
||||||
const el = screen.getByText('Footer')
|
const el = screen.getByText('Footer');
|
||||||
expect(el).toHaveClass('brutal-border-top', 'mt-6', 'pt-6')
|
expect(el).toHaveClass('brutal-border-top', 'mt-6', 'pt-6');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,34 +1,34 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react';
|
||||||
import { cn } from '$shared/lib'
|
import { cn } from '$shared/lib';
|
||||||
|
|
||||||
export type CardBackground = 'ochre' | 'slate' | 'white'
|
export type CardBackground = 'ochre' | 'slate' | 'white';
|
||||||
|
|
||||||
interface CardProps {
|
interface CardProps {
|
||||||
/**
|
/**
|
||||||
* Card content
|
* Card content
|
||||||
*/
|
*/
|
||||||
children: ReactNode
|
children: ReactNode;
|
||||||
/**
|
/**
|
||||||
* Additional CSS classes
|
* Additional CSS classes
|
||||||
*/
|
*/
|
||||||
className?: string
|
className?: string;
|
||||||
/**
|
/**
|
||||||
* Background color preset
|
* Background color preset
|
||||||
* @default 'ochre'
|
* @default 'ochre'
|
||||||
*/
|
*/
|
||||||
background?: CardBackground
|
background?: CardBackground;
|
||||||
/**
|
/**
|
||||||
* Remove default padding
|
* Remove default padding
|
||||||
* @default false
|
* @default false
|
||||||
*/
|
*/
|
||||||
noPadding?: boolean
|
noPadding?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BG: Record<CardBackground, string> = {
|
const BG: Record<CardBackground, string> = {
|
||||||
ochre: 'bg-ochre-clay',
|
ochre: 'bg-ochre-clay',
|
||||||
slate: 'bg-slate-indigo text-ochre-clay',
|
slate: 'bg-slate-indigo text-ochre-clay',
|
||||||
white: 'bg-white',
|
white: 'bg-white',
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Brutalist card container with background and padding variants.
|
* Brutalist card container with background and padding variants.
|
||||||
@@ -38,51 +38,51 @@ export function Card({ children, className, background = 'ochre', noPadding = fa
|
|||||||
<div className={cn('brutal-border brutal-shadow', BG[background], !noPadding && 'p-6 md:p-8', className)}>
|
<div className={cn('brutal-border brutal-shadow', BG[background], !noPadding && 'p-6 md:p-8', className)}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SlotProps {
|
interface SlotProps {
|
||||||
/**
|
/**
|
||||||
* Slot content
|
* Slot content
|
||||||
*/
|
*/
|
||||||
children: ReactNode
|
children: ReactNode;
|
||||||
/**
|
/**
|
||||||
* Additional CSS classes
|
* Additional CSS classes
|
||||||
*/
|
*/
|
||||||
className?: string
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Card header wrapper — adds bottom margin.
|
* Card header wrapper — adds bottom margin.
|
||||||
*/
|
*/
|
||||||
export function CardHeader({ children, className }: SlotProps) {
|
export function CardHeader({ children, className }: SlotProps) {
|
||||||
return <div className={cn('mb-4', className)}>{children}</div>
|
return <div className={cn('mb-4', className)}>{children}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Card title — renders as h3.
|
* Card title — renders as h3.
|
||||||
*/
|
*/
|
||||||
export function CardTitle({ children, className }: SlotProps) {
|
export function CardTitle({ children, className }: SlotProps) {
|
||||||
return <h3 className={className}>{children}</h3>
|
return <h3 className={className}>{children}</h3>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Card description — muted paragraph below the title.
|
* Card description — muted paragraph below the title.
|
||||||
*/
|
*/
|
||||||
export function CardDescription({ children, className }: SlotProps) {
|
export function CardDescription({ children, className }: SlotProps) {
|
||||||
return <p className={cn('mt-2 opacity-80', className)}>{children}</p>
|
return <p className={cn('mt-2 opacity-80', className)}>{children}</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Card body content area.
|
* Card body content area.
|
||||||
*/
|
*/
|
||||||
export function CardContent({ children, className }: SlotProps) {
|
export function CardContent({ children, className }: SlotProps) {
|
||||||
return <div className={className}>{children}</div>
|
return <div className={className}>{children}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Card footer — separated by a brutal border-top.
|
* Card footer — separated by a brutal border-top.
|
||||||
*/
|
*/
|
||||||
export function CardFooter({ children, className }: SlotProps) {
|
export function CardFooter({ children, className }: SlotProps) {
|
||||||
return <div className={cn('mt-6 pt-6 brutal-border-top', className)}>{children}</div>
|
return <div className={cn('mt-6 pt-6 brutal-border-top', className)}>{children}</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export { Input, Textarea } from './ui/Input'
|
export { Input, Textarea } from './ui/Input';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||||
import { Input, Textarea } from './Input'
|
import { Input, Textarea } from './Input';
|
||||||
|
|
||||||
const meta: Meta<typeof Input> = {
|
const meta: Meta<typeof Input> = {
|
||||||
title: 'Shared/Input',
|
title: 'Shared/Input',
|
||||||
@@ -11,35 +11,35 @@ const meta: Meta<typeof Input> = {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
}
|
};
|
||||||
|
|
||||||
export default meta
|
export default meta;
|
||||||
|
|
||||||
type Story = StoryObj<typeof Input>
|
type Story = StoryObj<typeof Input>;
|
||||||
|
|
||||||
export const Default: Story = {
|
export const Default: Story = {
|
||||||
args: {},
|
args: {},
|
||||||
}
|
};
|
||||||
|
|
||||||
export const WithLabel: Story = {
|
export const WithLabel: Story = {
|
||||||
args: {
|
args: {
|
||||||
label: 'Email address',
|
label: 'Email address',
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
export const WithError: Story = {
|
export const WithError: Story = {
|
||||||
args: {
|
args: {
|
||||||
label: 'Email',
|
label: 'Email',
|
||||||
error: 'This field is required',
|
error: 'This field is required',
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
export const WithPlaceholder: Story = {
|
export const WithPlaceholder: Story = {
|
||||||
args: {
|
args: {
|
||||||
placeholder: 'Enter your email',
|
placeholder: 'Enter your email',
|
||||||
type: 'email',
|
type: 'email',
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
export const TextareaStory: Story = {
|
export const TextareaStory: Story = {
|
||||||
name: 'Textarea',
|
name: 'Textarea',
|
||||||
@@ -48,7 +48,7 @@ export const TextareaStory: Story = {
|
|||||||
<Textarea label="Message" rows={4} />
|
<Textarea label="Message" rows={4} />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}
|
};
|
||||||
|
|
||||||
export const TextareaWithError: Story = {
|
export const TextareaWithError: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
@@ -56,4 +56,4 @@ export const TextareaWithError: Story = {
|
|||||||
<Textarea label="Message" error="Too short" rows={4} />
|
<Textarea label="Message" error="Too short" rows={4} />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,110 +1,109 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { render, screen } from '@testing-library/react';
|
||||||
import { render, screen } from '@testing-library/react'
|
import { Input, Textarea } from './Input';
|
||||||
import { Input, Textarea } from './Input'
|
|
||||||
|
|
||||||
describe('Input', () => {
|
describe('Input', () => {
|
||||||
describe('rendering', () => {
|
describe('rendering', () => {
|
||||||
it('renders an input element', () => {
|
it('renders an input element', () => {
|
||||||
render(<Input />)
|
render(<Input />);
|
||||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
it('renders label when provided', () => {
|
it('renders label when provided', () => {
|
||||||
render(<Input label="Email" />)
|
render(<Input label="Email" />);
|
||||||
expect(screen.getByText('Email')).toBeInTheDocument()
|
expect(screen.getByText('Email')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
it('does not render label when omitted', () => {
|
it('does not render label when omitted', () => {
|
||||||
const { container } = render(<Input />)
|
const { container } = render(<Input />);
|
||||||
expect(container.querySelector('label')).toBeNull()
|
expect(container.querySelector('label')).toBeNull();
|
||||||
})
|
});
|
||||||
it('renders error message when provided', () => {
|
it('renders error message when provided', () => {
|
||||||
render(<Input error="Required" />)
|
render(<Input error="Required" />);
|
||||||
expect(screen.getByText('Required')).toBeInTheDocument()
|
expect(screen.getByText('Required')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
it('does not render error when omitted', () => {
|
it('does not render error when omitted', () => {
|
||||||
render(<Input />)
|
render(<Input />);
|
||||||
expect(screen.queryByText('Required')).toBeNull()
|
expect(screen.queryByText('Required')).toBeNull();
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
describe('accessibility', () => {
|
describe('accessibility', () => {
|
||||||
it('label is associated with input via htmlFor/id', () => {
|
it('label is associated with input via htmlFor/id', () => {
|
||||||
render(<Input label="Email" />)
|
render(<Input label="Email" />);
|
||||||
expect(screen.getByLabelText('Email')).toBeInTheDocument()
|
expect(screen.getByLabelText('Email')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
it('error span is referenced by aria-describedby', () => {
|
it('error span is referenced by aria-describedby', () => {
|
||||||
render(<Input error="Required" />)
|
render(<Input error="Required" />);
|
||||||
const input = screen.getByRole('textbox')
|
const input = screen.getByRole('textbox');
|
||||||
const errorId = input.getAttribute('aria-describedby')
|
const errorId = input.getAttribute('aria-describedby');
|
||||||
expect(errorId).toBeTruthy()
|
expect(errorId).toBeTruthy();
|
||||||
expect(document.getElementById(errorId!)).toHaveTextContent('Required')
|
expect(document.getElementById(errorId as string)).toHaveTextContent('Required');
|
||||||
})
|
});
|
||||||
it('no aria-describedby when no error', () => {
|
it('no aria-describedby when no error', () => {
|
||||||
render(<Input />)
|
render(<Input />);
|
||||||
expect(screen.getByRole('textbox')).not.toHaveAttribute('aria-describedby')
|
expect(screen.getByRole('textbox')).not.toHaveAttribute('aria-describedby');
|
||||||
})
|
});
|
||||||
it('uses provided id prop', () => {
|
it('uses provided id prop', () => {
|
||||||
render(<Input id="my-input" label="Email" />)
|
render(<Input id="my-input" label="Email" />);
|
||||||
expect(screen.getByLabelText('Email')).toHaveAttribute('id', 'my-input')
|
expect(screen.getByLabelText('Email')).toHaveAttribute('id', 'my-input');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
describe('styling', () => {
|
describe('styling', () => {
|
||||||
it('has brutal-border class', () => {
|
it('has brutal-border class', () => {
|
||||||
render(<Input />)
|
render(<Input />);
|
||||||
expect(screen.getByRole('textbox')).toHaveClass('brutal-border')
|
expect(screen.getByRole('textbox')).toHaveClass('brutal-border');
|
||||||
})
|
});
|
||||||
it('applies custom className', () => {
|
it('applies custom className', () => {
|
||||||
render(<Input className="w-full" />)
|
render(<Input className="w-full" />);
|
||||||
expect(screen.getByRole('textbox')).toHaveClass('w-full')
|
expect(screen.getByRole('textbox')).toHaveClass('w-full');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
describe('forwarded props', () => {
|
describe('forwarded props', () => {
|
||||||
it('passes placeholder to input', () => {
|
it('passes placeholder to input', () => {
|
||||||
render(<Input placeholder="Enter email" />)
|
render(<Input placeholder="Enter email" />);
|
||||||
expect(screen.getByPlaceholderText('Enter email')).toBeInTheDocument()
|
expect(screen.getByPlaceholderText('Enter email')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
it('passes type to input', () => {
|
it('passes type to input', () => {
|
||||||
render(<Input type="email" />)
|
render(<Input type="email" />);
|
||||||
expect(screen.getByRole('textbox')).toHaveAttribute('type', 'email')
|
expect(screen.getByRole('textbox')).toHaveAttribute('type', 'email');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
describe('Textarea', () => {
|
describe('Textarea', () => {
|
||||||
describe('rendering', () => {
|
describe('rendering', () => {
|
||||||
it('renders a textarea element', () => {
|
it('renders a textarea element', () => {
|
||||||
render(<Textarea />)
|
render(<Textarea />);
|
||||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
it('renders label when provided', () => {
|
it('renders label when provided', () => {
|
||||||
render(<Textarea label="Message" />)
|
render(<Textarea label="Message" />);
|
||||||
expect(screen.getByText('Message')).toBeInTheDocument()
|
expect(screen.getByText('Message')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
it('renders error when provided', () => {
|
it('renders error when provided', () => {
|
||||||
render(<Textarea error="Too short" />)
|
render(<Textarea error="Too short" />);
|
||||||
expect(screen.getByText('Too short')).toBeInTheDocument()
|
expect(screen.getByText('Too short')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
it('defaults to 4 rows', () => {
|
it('defaults to 4 rows', () => {
|
||||||
render(<Textarea />)
|
render(<Textarea />);
|
||||||
expect(screen.getByRole('textbox')).toHaveAttribute('rows', '4')
|
expect(screen.getByRole('textbox')).toHaveAttribute('rows', '4');
|
||||||
})
|
});
|
||||||
it('accepts custom rows', () => {
|
it('accepts custom rows', () => {
|
||||||
render(<Textarea rows={8} />)
|
render(<Textarea rows={8} />);
|
||||||
expect(screen.getByRole('textbox')).toHaveAttribute('rows', '8')
|
expect(screen.getByRole('textbox')).toHaveAttribute('rows', '8');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
describe('accessibility', () => {
|
describe('accessibility', () => {
|
||||||
it('label is associated with textarea via htmlFor/id', () => {
|
it('label is associated with textarea via htmlFor/id', () => {
|
||||||
render(<Textarea label="Message" />)
|
render(<Textarea label="Message" />);
|
||||||
expect(screen.getByLabelText('Message')).toBeInTheDocument()
|
expect(screen.getByLabelText('Message')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
it('error span is referenced by aria-describedby', () => {
|
it('error span is referenced by aria-describedby', () => {
|
||||||
render(<Textarea error="Too short" />)
|
render(<Textarea error="Too short" />);
|
||||||
const textarea = screen.getByRole('textbox')
|
const textarea = screen.getByRole('textbox');
|
||||||
const errorId = textarea.getAttribute('aria-describedby')
|
const errorId = textarea.getAttribute('aria-describedby');
|
||||||
expect(errorId).toBeTruthy()
|
expect(errorId).toBeTruthy();
|
||||||
expect(document.getElementById(errorId!)).toHaveTextContent('Too short')
|
expect(document.getElementById(errorId as string)).toHaveTextContent('Too short');
|
||||||
})
|
});
|
||||||
it('no aria-describedby when no error', () => {
|
it('no aria-describedby when no error', () => {
|
||||||
render(<Textarea />)
|
render(<Textarea />);
|
||||||
expect(screen.getByRole('textbox')).not.toHaveAttribute('aria-describedby')
|
expect(screen.getByRole('textbox')).not.toHaveAttribute('aria-describedby');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,68 +1,81 @@
|
|||||||
import { useId, type InputHTMLAttributes, type TextareaHTMLAttributes } from 'react'
|
import { type InputHTMLAttributes, type TextareaHTMLAttributes, useId } from 'react';
|
||||||
import { cn } from '$shared/lib'
|
import { cn } from '$shared/lib';
|
||||||
|
|
||||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
/**
|
/**
|
||||||
* Visible label rendered above the input
|
* Visible label rendered above the input
|
||||||
*/
|
*/
|
||||||
label?: string
|
label?: string;
|
||||||
/**
|
/**
|
||||||
* Validation error shown below the input
|
* Validation error shown below the input
|
||||||
*/
|
*/
|
||||||
error?: string
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const INPUT_BASE = 'brutal-border bg-white px-4 py-3 text-carbon-black focus:outline-none focus:ring-2 focus:ring-burnt-oxide focus:ring-offset-2 focus:ring-offset-ochre-clay transition-all'
|
const INPUT_BASE =
|
||||||
|
'brutal-border bg-white px-4 py-3 text-carbon-black focus:outline-none focus:ring-2 focus:ring-burnt-oxide focus:ring-offset-2 focus:ring-offset-ochre-clay transition-all';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Text input with optional label and error state.
|
* Text input with optional label and error state.
|
||||||
*/
|
*/
|
||||||
export function Input({ label, error, className, id, ...props }: InputProps) {
|
export function Input({ label, error, className, id, ...props }: InputProps) {
|
||||||
const generatedId = useId()
|
const generatedId = useId();
|
||||||
const inputId = id ?? generatedId
|
const inputId = id ?? generatedId;
|
||||||
const errorId = `${inputId}-error`
|
const errorId = `${inputId}-error`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{label && <label htmlFor={inputId} className="text-carbon-black">{label}</label>}
|
{label && (
|
||||||
|
<label htmlFor={inputId} className="text-carbon-black">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
<input
|
<input
|
||||||
id={inputId}
|
id={inputId}
|
||||||
className={cn(INPUT_BASE, className)}
|
className={cn(INPUT_BASE, className)}
|
||||||
aria-describedby={error ? errorId : undefined}
|
aria-describedby={error ? errorId : undefined}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
{error && <span id={errorId} className="text-sm text-burnt-oxide">{error}</span>}
|
{error && (
|
||||||
|
<span id={errorId} className="text-sm text-burnt-oxide">
|
||||||
|
{error}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||||
/**
|
/**
|
||||||
* Visible label rendered above the textarea
|
* Visible label rendered above the textarea
|
||||||
*/
|
*/
|
||||||
label?: string
|
label?: string;
|
||||||
/**
|
/**
|
||||||
* Validation error shown below the textarea
|
* Validation error shown below the textarea
|
||||||
*/
|
*/
|
||||||
error?: string
|
error?: string;
|
||||||
/**
|
/**
|
||||||
* Number of visible rows
|
* Number of visible rows
|
||||||
* @default 4
|
* @default 4
|
||||||
*/
|
*/
|
||||||
rows?: number
|
rows?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Multiline textarea with optional label and error state.
|
* Multiline textarea with optional label and error state.
|
||||||
*/
|
*/
|
||||||
export function Textarea({ label, error, rows = 4, className, id, ...props }: TextareaProps) {
|
export function Textarea({ label, error, rows = 4, className, id, ...props }: TextareaProps) {
|
||||||
const generatedId = useId()
|
const generatedId = useId();
|
||||||
const textareaId = id ?? generatedId
|
const textareaId = id ?? generatedId;
|
||||||
const errorId = `${textareaId}-error`
|
const errorId = `${textareaId}-error`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{label && <label htmlFor={textareaId} className="text-carbon-black">{label}</label>}
|
{label && (
|
||||||
|
<label htmlFor={textareaId} className="text-carbon-black">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
<textarea
|
<textarea
|
||||||
id={textareaId}
|
id={textareaId}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
@@ -70,7 +83,11 @@ export function Textarea({ label, error, rows = 4, className, id, ...props }: Te
|
|||||||
aria-describedby={error ? errorId : undefined}
|
aria-describedby={error ? errorId : undefined}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
{error && <span id={errorId} className="text-sm text-burnt-oxide">{error}</span>}
|
{error && (
|
||||||
|
<span id={errorId} className="text-sm text-burnt-oxide">
|
||||||
|
{error}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export { Section, Container } from './ui/Section'
|
export type { ContainerSize, SectionBackground } from './ui/Section';
|
||||||
export type { SectionBackground, ContainerSize } from './ui/Section'
|
export { Container, Section } from './ui/Section';
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||||
import { Section, Container } from './Section'
|
import { Container, Section } from './Section';
|
||||||
|
|
||||||
const meta: Meta<typeof Section> = {
|
const meta: Meta<typeof Section> = {
|
||||||
title: 'Shared/Section',
|
title: 'Shared/Section',
|
||||||
component: Section,
|
component: Section,
|
||||||
}
|
};
|
||||||
|
|
||||||
export default meta
|
export default meta;
|
||||||
|
|
||||||
type Story = StoryObj<typeof Section>
|
type Story = StoryObj<typeof Section>;
|
||||||
|
|
||||||
export const AllBackgrounds: Story = {
|
export const AllBackgrounds: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
@@ -16,32 +16,44 @@ export const AllBackgrounds: Story = {
|
|||||||
<Section background="ochre" className="py-12">
|
<Section background="ochre" className="py-12">
|
||||||
<Container>
|
<Container>
|
||||||
<h2>Ochre Section</h2>
|
<h2>Ochre Section</h2>
|
||||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>
|
<p>
|
||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et
|
||||||
|
dolore magna aliqua.
|
||||||
|
</p>
|
||||||
</Container>
|
</Container>
|
||||||
</Section>
|
</Section>
|
||||||
<Section background="slate" className="py-12">
|
<Section background="slate" className="py-12">
|
||||||
<Container>
|
<Container>
|
||||||
<h2>Slate Section</h2>
|
<h2>Slate Section</h2>
|
||||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>
|
<p>
|
||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et
|
||||||
|
dolore magna aliqua.
|
||||||
|
</p>
|
||||||
</Container>
|
</Container>
|
||||||
</Section>
|
</Section>
|
||||||
<Section background="white" className="py-12">
|
<Section background="white" className="py-12">
|
||||||
<Container>
|
<Container>
|
||||||
<h2>White Section</h2>
|
<h2>White Section</h2>
|
||||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>
|
<p>
|
||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et
|
||||||
|
dolore magna aliqua.
|
||||||
|
</p>
|
||||||
</Container>
|
</Container>
|
||||||
</Section>
|
</Section>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}
|
};
|
||||||
|
|
||||||
export const Bordered: Story = {
|
export const Bordered: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
<Section background="ochre" bordered className="py-12">
|
<Section background="ochre" bordered className="py-12">
|
||||||
<Container>
|
<Container>
|
||||||
<h2>Bordered Section</h2>
|
<h2>Bordered Section</h2>
|
||||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>
|
<p>
|
||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore
|
||||||
|
magna aliqua.
|
||||||
|
</p>
|
||||||
</Container>
|
</Container>
|
||||||
</Section>
|
</Section>
|
||||||
),
|
),
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,95 +1,102 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { render, screen } from '@testing-library/react';
|
||||||
import { render, screen } from '@testing-library/react'
|
import { Container, Section } from './Section';
|
||||||
import { Section, Container } from './Section'
|
|
||||||
|
|
||||||
describe('Section', () => {
|
describe('Section', () => {
|
||||||
describe('rendering', () => {
|
describe('rendering', () => {
|
||||||
it('renders a section element', () => {
|
it('renders a section element', () => {
|
||||||
const { container } = render(<Section>content</Section>)
|
const { container } = render(<Section>content</Section>);
|
||||||
expect(container.querySelector('section')).toBeInTheDocument()
|
expect(container.querySelector('section')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
it('renders children', () => {
|
it('renders children', () => {
|
||||||
render(<Section><span>hello</span></Section>)
|
render(
|
||||||
expect(screen.getByText('hello')).toBeInTheDocument()
|
<Section>
|
||||||
})
|
<span>hello</span>
|
||||||
})
|
</Section>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('hello')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('background variants', () => {
|
describe('background variants', () => {
|
||||||
it('defaults to ochre background', () => {
|
it('defaults to ochre background', () => {
|
||||||
const { container } = render(<Section>x</Section>)
|
const { container } = render(<Section>x</Section>);
|
||||||
expect(container.querySelector('section')).toHaveClass('bg-ochre-clay', 'text-carbon-black')
|
expect(container.querySelector('section')).toHaveClass('bg-ochre-clay', 'text-carbon-black');
|
||||||
})
|
});
|
||||||
it('applies slate background', () => {
|
it('applies slate background', () => {
|
||||||
const { container } = render(<Section background="slate">x</Section>)
|
const { container } = render(<Section background="slate">x</Section>);
|
||||||
expect(container.querySelector('section')).toHaveClass('bg-slate-indigo', 'text-ochre-clay')
|
expect(container.querySelector('section')).toHaveClass('bg-slate-indigo', 'text-ochre-clay');
|
||||||
})
|
});
|
||||||
it('applies white background', () => {
|
it('applies white background', () => {
|
||||||
const { container } = render(<Section background="white">x</Section>)
|
const { container } = render(<Section background="white">x</Section>);
|
||||||
expect(container.querySelector('section')).toHaveClass('bg-white', 'text-carbon-black')
|
expect(container.querySelector('section')).toHaveClass('bg-white', 'text-carbon-black');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('bordered', () => {
|
describe('bordered', () => {
|
||||||
it('no border classes by default', () => {
|
it('no border classes by default', () => {
|
||||||
const { container } = render(<Section>x</Section>)
|
const { container } = render(<Section>x</Section>);
|
||||||
const el = container.querySelector('section')!
|
const el = container.querySelector('section') as HTMLElement;
|
||||||
expect(el).not.toHaveClass('brutal-border-top')
|
expect(el).not.toHaveClass('brutal-border-top');
|
||||||
expect(el).not.toHaveClass('brutal-border-bottom')
|
expect(el).not.toHaveClass('brutal-border-bottom');
|
||||||
})
|
});
|
||||||
it('adds top and bottom borders when bordered=true', () => {
|
it('adds top and bottom borders when bordered=true', () => {
|
||||||
const { container } = render(<Section bordered>x</Section>)
|
const { container } = render(<Section bordered>x</Section>);
|
||||||
const el = container.querySelector('section')!
|
const el = container.querySelector('section') as HTMLElement;
|
||||||
expect(el).toHaveClass('brutal-border-top')
|
expect(el).toHaveClass('brutal-border-top');
|
||||||
expect(el).toHaveClass('brutal-border-bottom')
|
expect(el).toHaveClass('brutal-border-bottom');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('className', () => {
|
describe('className', () => {
|
||||||
it('applies custom className', () => {
|
it('applies custom className', () => {
|
||||||
const { container } = render(<Section className="py-16">x</Section>)
|
const { container } = render(<Section className="py-16">x</Section>);
|
||||||
expect(container.querySelector('section')).toHaveClass('py-16')
|
expect(container.querySelector('section')).toHaveClass('py-16');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('Container', () => {
|
describe('Container', () => {
|
||||||
describe('rendering', () => {
|
describe('rendering', () => {
|
||||||
it('renders a div with children', () => {
|
it('renders a div with children', () => {
|
||||||
render(<Container><span>inner</span></Container>)
|
render(
|
||||||
expect(screen.getByText('inner')).toBeInTheDocument()
|
<Container>
|
||||||
})
|
<span>inner</span>
|
||||||
})
|
</Container>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('inner')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('size variants', () => {
|
describe('size variants', () => {
|
||||||
it('defaults to max-w-7xl', () => {
|
it('defaults to max-w-7xl', () => {
|
||||||
const { container } = render(<Container>x</Container>)
|
const { container } = render(<Container>x</Container>);
|
||||||
expect(container.firstChild).toHaveClass('max-w-7xl')
|
expect(container.firstChild).toHaveClass('max-w-7xl');
|
||||||
})
|
});
|
||||||
it('wide applies max-w-[1920px]', () => {
|
it('wide applies max-w-[1920px]', () => {
|
||||||
const { container } = render(<Container size="wide">x</Container>)
|
const { container } = render(<Container size="wide">x</Container>);
|
||||||
expect(container.firstChild).toHaveClass('max-w-[1920px]')
|
expect(container.firstChild).toHaveClass('max-w-[1920px]');
|
||||||
})
|
});
|
||||||
it('ultra-wide applies max-w-[2560px]', () => {
|
it('ultra-wide applies max-w-[2560px]', () => {
|
||||||
const { container } = render(<Container size="ultra-wide">x</Container>)
|
const { container } = render(<Container size="ultra-wide">x</Container>);
|
||||||
expect(container.firstChild).toHaveClass('max-w-[2560px]')
|
expect(container.firstChild).toHaveClass('max-w-[2560px]');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('layout', () => {
|
describe('layout', () => {
|
||||||
it('centers content horizontally', () => {
|
it('centers content horizontally', () => {
|
||||||
const { container } = render(<Container>x</Container>)
|
const { container } = render(<Container>x</Container>);
|
||||||
expect(container.firstChild).toHaveClass('mx-auto')
|
expect(container.firstChild).toHaveClass('mx-auto');
|
||||||
})
|
});
|
||||||
it('applies horizontal padding', () => {
|
it('applies horizontal padding', () => {
|
||||||
const { container } = render(<Container>x</Container>)
|
const { container } = render(<Container>x</Container>);
|
||||||
expect(container.firstChild).toHaveClass('px-6')
|
expect(container.firstChild).toHaveClass('px-6');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('className', () => {
|
describe('className', () => {
|
||||||
it('applies custom className', () => {
|
it('applies custom className', () => {
|
||||||
const { container } = render(<Container className="my-custom">x</Container>)
|
const { container } = render(<Container className="my-custom">x</Container>);
|
||||||
expect(container.firstChild).toHaveClass('my-custom')
|
expect(container.firstChild).toHaveClass('my-custom');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,82 +1,72 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react';
|
||||||
import { cn } from '$shared/lib'
|
import { cn } from '$shared/lib';
|
||||||
|
|
||||||
export type SectionBackground = 'ochre' | 'slate' | 'white'
|
export type SectionBackground = 'ochre' | 'slate' | 'white';
|
||||||
export type ContainerSize = 'default' | 'wide' | 'ultra-wide'
|
export type ContainerSize = 'default' | 'wide' | 'ultra-wide';
|
||||||
|
|
||||||
interface SectionProps {
|
interface SectionProps {
|
||||||
/**
|
/**
|
||||||
* Section content
|
* Section content
|
||||||
*/
|
*/
|
||||||
children: ReactNode
|
children: ReactNode;
|
||||||
/**
|
/**
|
||||||
* Background color variant
|
* Background color variant
|
||||||
* @default 'ochre'
|
* @default 'ochre'
|
||||||
*/
|
*/
|
||||||
background?: SectionBackground
|
background?: SectionBackground;
|
||||||
/**
|
/**
|
||||||
* Adds top and bottom brutal borders
|
* Adds top and bottom brutal borders
|
||||||
* @default false
|
* @default false
|
||||||
*/
|
*/
|
||||||
bordered?: boolean
|
bordered?: boolean;
|
||||||
/**
|
/**
|
||||||
* CSS classes
|
* CSS classes
|
||||||
*/
|
*/
|
||||||
className?: string
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BACKGROUNDS: Record<SectionBackground, string> = {
|
const BACKGROUNDS: Record<SectionBackground, string> = {
|
||||||
ochre: 'bg-ochre-clay text-carbon-black',
|
ochre: 'bg-ochre-clay text-carbon-black',
|
||||||
slate: 'bg-slate-indigo text-ochre-clay',
|
slate: 'bg-slate-indigo text-ochre-clay',
|
||||||
white: 'bg-white text-carbon-black',
|
white: 'bg-white text-carbon-black',
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full-width page section with background and optional borders.
|
* Full-width page section with background and optional borders.
|
||||||
*/
|
*/
|
||||||
export function Section({ children, background = 'ochre', bordered = false, className }: SectionProps) {
|
export function Section({ children, background = 'ochre', bordered = false, className }: SectionProps) {
|
||||||
return (
|
return (
|
||||||
<section
|
<section className={cn(BACKGROUNDS[background], bordered && 'brutal-border-top brutal-border-bottom', className)}>
|
||||||
className={cn(
|
|
||||||
BACKGROUNDS[background],
|
|
||||||
bordered && 'brutal-border-top brutal-border-bottom',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</section>
|
</section>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ContainerProps {
|
interface ContainerProps {
|
||||||
/**
|
/**
|
||||||
* Container content
|
* Container content
|
||||||
*/
|
*/
|
||||||
children: ReactNode
|
children: ReactNode;
|
||||||
/**
|
/**
|
||||||
* Max-width constraint
|
* Max-width constraint
|
||||||
* @default 'default'
|
* @default 'default'
|
||||||
*/
|
*/
|
||||||
size?: ContainerSize
|
size?: ContainerSize;
|
||||||
/**
|
/**
|
||||||
* CSS classes
|
* CSS classes
|
||||||
*/
|
*/
|
||||||
className?: string
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SIZES: Record<ContainerSize, string> = {
|
const SIZES: Record<ContainerSize, string> = {
|
||||||
'default': 'max-w-7xl',
|
default: 'max-w-7xl',
|
||||||
'wide': 'max-w-[1920px]',
|
wide: 'max-w-[1920px]',
|
||||||
'ultra-wide': 'max-w-[2560px]',
|
'ultra-wide': 'max-w-[2560px]',
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Centered content container with responsive horizontal padding.
|
* Centered content container with responsive horizontal padding.
|
||||||
*/
|
*/
|
||||||
export function Container({ children, size = 'default', className }: ContainerProps) {
|
export function Container({ children, size = 'default', className }: ContainerProps) {
|
||||||
return (
|
return <div className={cn(SIZES[size], 'mx-auto px-6 md:px-12 lg:px-16', className)}>{children}</div>;
|
||||||
<div className={cn(SIZES[size], 'mx-auto px-6 md:px-12 lg:px-16', className)}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export { SectionAccordion } from './ui/SectionAccordion'
|
|
||||||
@@ -1 +1 @@
|
|||||||
export { TechStackBrick, TechStackGrid } from './ui/TechStack'
|
export { TechStackBrick, TechStackGrid } from './ui/TechStack';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||||
import { TechStackGrid, TechStackBrick } from './TechStack'
|
import { TechStackBrick, TechStackGrid } from './TechStack';
|
||||||
|
|
||||||
const meta: Meta<typeof TechStackGrid> = {
|
const meta: Meta<typeof TechStackGrid> = {
|
||||||
title: 'Shared/TechStack',
|
title: 'Shared/TechStack',
|
||||||
@@ -11,11 +11,11 @@ const meta: Meta<typeof TechStackGrid> = {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
}
|
};
|
||||||
|
|
||||||
export default meta
|
export default meta;
|
||||||
|
|
||||||
type Story = StoryObj<typeof TechStackGrid>
|
type Story = StoryObj<typeof TechStackGrid>;
|
||||||
|
|
||||||
export const Grid: Story = {
|
export const Grid: Story = {
|
||||||
args: {
|
args: {
|
||||||
@@ -34,7 +34,7 @@ export const Grid: Story = {
|
|||||||
'Rust',
|
'Rust',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
export const SingleBrick: Story = {
|
export const SingleBrick: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
@@ -42,4 +42,4 @@ export const SingleBrick: Story = {
|
|||||||
<TechStackBrick name="TypeScript" />
|
<TechStackBrick name="TypeScript" />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,62 +1,61 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { render, screen } from '@testing-library/react';
|
||||||
import { render, screen } from '@testing-library/react'
|
import { TechStackBrick, TechStackGrid } from './TechStack';
|
||||||
import { TechStackBrick, TechStackGrid } from './TechStack'
|
|
||||||
|
|
||||||
describe('TechStackBrick', () => {
|
describe('TechStackBrick', () => {
|
||||||
describe('rendering', () => {
|
describe('rendering', () => {
|
||||||
it('renders the technology name', () => {
|
it('renders the technology name', () => {
|
||||||
render(<TechStackBrick name="TypeScript" />)
|
render(<TechStackBrick name="TypeScript" />);
|
||||||
expect(screen.getByText('TypeScript')).toBeInTheDocument()
|
expect(screen.getByText('TypeScript')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('styling', () => {
|
describe('styling', () => {
|
||||||
it('has brutal-border class', () => {
|
it('has brutal-border class', () => {
|
||||||
const { container } = render(<TechStackBrick name="React" />)
|
const { container } = render(<TechStackBrick name="React" />);
|
||||||
expect(container.firstChild).toHaveClass('brutal-border')
|
expect(container.firstChild).toHaveClass('brutal-border');
|
||||||
})
|
});
|
||||||
it('has brutal-shadow class', () => {
|
it('has brutal-shadow class', () => {
|
||||||
const { container } = render(<TechStackBrick name="React" />)
|
const { container } = render(<TechStackBrick name="React" />);
|
||||||
expect(container.firstChild).toHaveClass('brutal-shadow')
|
expect(container.firstChild).toHaveClass('brutal-shadow');
|
||||||
})
|
});
|
||||||
it('name span has uppercase and tracking-wide', () => {
|
it('name span has uppercase and tracking-wide', () => {
|
||||||
render(<TechStackBrick name="Go" />)
|
render(<TechStackBrick name="Go" />);
|
||||||
const span = screen.getByText('Go')
|
const span = screen.getByText('Go');
|
||||||
expect(span).toHaveClass('uppercase', 'tracking-wide')
|
expect(span).toHaveClass('uppercase', 'tracking-wide');
|
||||||
})
|
});
|
||||||
it('applies custom className', () => {
|
it('applies custom className', () => {
|
||||||
const { container } = render(<TechStackBrick name="Go" className="w-full" />)
|
const { container } = render(<TechStackBrick name="Go" className="w-full" />);
|
||||||
expect(container.firstChild).toHaveClass('w-full')
|
expect(container.firstChild).toHaveClass('w-full');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('TechStackGrid', () => {
|
describe('TechStackGrid', () => {
|
||||||
describe('rendering', () => {
|
describe('rendering', () => {
|
||||||
it('renders all skill names', () => {
|
it('renders all skill names', () => {
|
||||||
render(<TechStackGrid skills={['React', 'TypeScript', 'Go']} />)
|
render(<TechStackGrid skills={['React', 'TypeScript', 'Go']} />);
|
||||||
expect(screen.getByText('React')).toBeInTheDocument()
|
expect(screen.getByText('React')).toBeInTheDocument();
|
||||||
expect(screen.getByText('TypeScript')).toBeInTheDocument()
|
expect(screen.getByText('TypeScript')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Go')).toBeInTheDocument()
|
expect(screen.getByText('Go')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
it('renders correct number of bricks', () => {
|
it('renders correct number of bricks', () => {
|
||||||
const { container } = render(<TechStackGrid skills={['A', 'B', 'C']} />)
|
const { container } = render(<TechStackGrid skills={['A', 'B', 'C']} />);
|
||||||
expect(container.firstChild!.childNodes).toHaveLength(3)
|
expect(container.firstChild?.childNodes).toHaveLength(3);
|
||||||
})
|
});
|
||||||
it('renders empty grid with no skills', () => {
|
it('renders empty grid with no skills', () => {
|
||||||
const { container } = render(<TechStackGrid skills={[]} />)
|
const { container } = render(<TechStackGrid skills={[]} />);
|
||||||
expect(container.firstChild!.childNodes).toHaveLength(0)
|
expect(container.firstChild?.childNodes).toHaveLength(0);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('layout', () => {
|
describe('layout', () => {
|
||||||
it('has grid class', () => {
|
it('has grid class', () => {
|
||||||
const { container } = render(<TechStackGrid skills={['A']} />)
|
const { container } = render(<TechStackGrid skills={['A']} />);
|
||||||
expect(container.firstChild).toHaveClass('grid')
|
expect(container.firstChild).toHaveClass('grid');
|
||||||
})
|
});
|
||||||
it('applies custom className', () => {
|
it('applies custom className', () => {
|
||||||
const { container } = render(<TechStackGrid skills={[]} className="my-custom" />)
|
const { container } = render(<TechStackGrid skills={[]} className="my-custom" />);
|
||||||
expect(container.firstChild).toHaveClass('my-custom')
|
expect(container.firstChild).toHaveClass('my-custom');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { cn } from '$shared/lib'
|
import { cn } from '$shared/lib';
|
||||||
|
|
||||||
interface TechStackBrickProps {
|
interface TechStackBrickProps {
|
||||||
/**
|
/**
|
||||||
* Technology name displayed in the brick
|
* Technology name displayed in the brick
|
||||||
*/
|
*/
|
||||||
name: string
|
name: string;
|
||||||
/**
|
/**
|
||||||
* CSS classes
|
* CSS classes
|
||||||
*/
|
*/
|
||||||
className?: string
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,18 +25,18 @@ export function TechStackBrick({ name, className }: TechStackBrickProps) {
|
|||||||
>
|
>
|
||||||
<span className="text-sm uppercase tracking-wide">{name}</span>
|
<span className="text-sm uppercase tracking-wide">{name}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TechStackGridProps {
|
interface TechStackGridProps {
|
||||||
/**
|
/**
|
||||||
* List of technology names to render as bricks
|
* List of technology names to render as bricks
|
||||||
*/
|
*/
|
||||||
skills: string[]
|
skills: string[];
|
||||||
/**
|
/**
|
||||||
* CSS classes
|
* CSS classes
|
||||||
*/
|
*/
|
||||||
className?: string
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,14 +45,11 @@ interface TechStackGridProps {
|
|||||||
export function TechStackGrid({ skills, className }: TechStackGridProps) {
|
export function TechStackGrid({ skills, className }: TechStackGridProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn('grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4', className)}
|
||||||
'grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{skills.map((skill, index) => (
|
{skills.map((skill) => (
|
||||||
<TechStackBrick key={index} name={skill} />
|
<TechStackBrick key={skill} name={skill} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-15
@@ -1,17 +1,12 @@
|
|||||||
export { Badge } from './Badge'
|
export type { BadgeVariant } from './Badge';
|
||||||
export type { BadgeVariant } from './Badge'
|
export { Badge } from './Badge';
|
||||||
|
export type { ButtonSize, ButtonVariant } from './Button';
|
||||||
|
export { Button } from './Button';
|
||||||
|
export type { CardBackground } from './Card';
|
||||||
|
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from './Card';
|
||||||
|
|
||||||
export { Button } from './Button'
|
export { Input, Textarea } from './Input';
|
||||||
export type { ButtonVariant, ButtonSize } from './Button'
|
export type { ContainerSize, SectionBackground } from './Section';
|
||||||
|
export { Container, Section } from './Section';
|
||||||
|
|
||||||
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card'
|
export { TechStackBrick, TechStackGrid } from './TechStack';
|
||||||
export type { CardBackground } from './Card'
|
|
||||||
|
|
||||||
export { Input, Textarea } from './Input'
|
|
||||||
|
|
||||||
export { Section, Container } from './Section'
|
|
||||||
export type { SectionBackground, ContainerSize } from './Section'
|
|
||||||
|
|
||||||
export { SectionAccordion } from './SectionAccordion'
|
|
||||||
|
|
||||||
export { TechStackBrick, TechStackGrid } from './TechStack'
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
|
const mockFont = () => ({
|
||||||
|
variable: '--font-mock',
|
||||||
|
className: 'mock-font',
|
||||||
|
style: { fontFamily: 'mock-font' },
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('next/font/google', () => ({
|
||||||
|
Fraunces: mockFont,
|
||||||
|
Public_Sans: mockFont,
|
||||||
|
}));
|
||||||
+2
-1
@@ -1 +1,2 @@
|
|||||||
import '@testing-library/jest-dom'
|
import '@testing-library/jest-dom';
|
||||||
|
import './__mocks__/next-font-google';
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { PageContentRecord } from '$shared/api';
|
||||||
|
import { getFirstRecord } from '$shared/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bio section component.
|
||||||
|
* Displays personal biography content from PocketBase.
|
||||||
|
*/
|
||||||
|
export default async function BioSection() {
|
||||||
|
const data = await getFirstRecord<PageContentRecord>('bio', {
|
||||||
|
filter: 'slug = "bio"',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return <p>Loading bio content...</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="prose prose-lg dark:prose-invert max-w-none">
|
||||||
|
<p>{data.content}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { PageContentRecord } from '$shared/api';
|
||||||
|
import { getFirstRecord } from '$shared/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intro section component.
|
||||||
|
* Displays primary introduction content from PocketBase.
|
||||||
|
*/
|
||||||
|
export default async function IntroSection() {
|
||||||
|
const data = await getFirstRecord<PageContentRecord>('intro', {
|
||||||
|
filter: 'slug = "intro"',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return <p>Loading intro content...</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="prose prose-lg dark:prose-invert max-w-none">
|
||||||
|
<p>{data.content}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export { MobileNav } from './ui/MobileNav'
|
export type { NavItem } from './model/types';
|
||||||
export { SidebarNav } from './ui/SidebarNav'
|
export { MobileNav } from './ui/MobileNav';
|
||||||
export { UtilityBar } from './ui/UtilityBar'
|
export { SidebarNav } from './ui/SidebarNav';
|
||||||
export type { NavItem } from './model/types'
|
export { UtilityBar } from './ui/UtilityBar';
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ export type NavItem = {
|
|||||||
/**
|
/**
|
||||||
* Section HTML id for anchor scrolling
|
* Section HTML id for anchor scrolling
|
||||||
*/
|
*/
|
||||||
id: string
|
id: string;
|
||||||
/**
|
/**
|
||||||
* Display label
|
* Display label
|
||||||
*/
|
*/
|
||||||
label: string
|
label: string;
|
||||||
/**
|
/**
|
||||||
* Display number prefix (e.g. "01")
|
* Display number prefix (e.g. "01")
|
||||||
*/
|
*/
|
||||||
number: string
|
number: string;
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||||
import { MobileNav } from './MobileNav'
|
import { MobileNav } from './MobileNav';
|
||||||
|
|
||||||
// MobileNav is lg:hidden — it renders only on mobile viewports.
|
// 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.
|
// 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',
|
defaultViewport: 'mobile1',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
export default meta
|
export default meta;
|
||||||
|
|
||||||
type Story = StoryObj<typeof MobileNav>
|
type Story = StoryObj<typeof MobileNav>;
|
||||||
|
|
||||||
export const Default: Story = {
|
export const Default: Story = {
|
||||||
args: {
|
args: {
|
||||||
@@ -25,4 +25,4 @@ export const Default: Story = {
|
|||||||
{ id: 'contact', label: 'Contact', number: '03' },
|
{ id: 'contact', label: 'Contact', number: '03' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,46 +1,45 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest'
|
import { render, screen } from '@testing-library/react';
|
||||||
import { render, screen } from '@testing-library/react'
|
import userEvent from '@testing-library/user-event';
|
||||||
import userEvent from '@testing-library/user-event'
|
import type { NavItem } from '../model/types';
|
||||||
import { MobileNav } from './MobileNav'
|
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('MobileNav', () => {
|
||||||
describe('rendering', () => {
|
describe('rendering', () => {
|
||||||
it('renders title "allmy.work"', () => {
|
it('renders title "allmy.work"', () => {
|
||||||
render(<MobileNav items={ITEMS} />)
|
render(<MobileNav items={ITEMS} />);
|
||||||
expect(screen.getByText('allmy.work')).toBeInTheDocument()
|
expect(screen.getByText('allmy.work')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('renders toggle button with text "Menu" initially', () => {
|
it('renders toggle button with text "Menu" initially', () => {
|
||||||
render(<MobileNav items={ITEMS} />)
|
render(<MobileNav items={ITEMS} />);
|
||||||
expect(screen.getByRole('button', { name: 'Menu' })).toBeInTheDocument()
|
expect(screen.getByRole('button', { name: 'Menu' })).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('menu items are hidden initially', () => {
|
it('menu items are hidden initially', () => {
|
||||||
render(<MobileNav items={ITEMS} />)
|
render(<MobileNav items={ITEMS} />);
|
||||||
expect(screen.queryByRole('button', { name: /about/i })).not.toBeInTheDocument()
|
expect(screen.queryByRole('button', { name: /about/i })).not.toBeInTheDocument();
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('interactions', () => {
|
describe('interactions', () => {
|
||||||
it('click toggle shows item buttons and changes label to "Close"', async () => {
|
it('click toggle shows item buttons and changes label to "Close"', async () => {
|
||||||
render(<MobileNav items={ITEMS} />)
|
render(<MobileNav items={ITEMS} />);
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Menu' }))
|
await userEvent.click(screen.getByRole('button', { name: 'Menu' }));
|
||||||
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument()
|
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
|
||||||
expect(screen.getByText('About')).toBeInTheDocument()
|
expect(screen.getByText('About')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('click item button closes the menu', async () => {
|
it('click item button closes the menu', async () => {
|
||||||
render(<MobileNav items={ITEMS} />)
|
render(<MobileNav items={ITEMS} />);
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Menu' }))
|
await userEvent.click(screen.getByRole('button', { name: 'Menu' }));
|
||||||
// item button label contains number + label text; find by accessible name fragment
|
// item button label contains number + label text; find by accessible name fragment
|
||||||
const itemBtn = screen.getAllByRole('button').find(b => b.textContent?.includes('About'))
|
const itemBtn = screen.getAllByRole('button').find((b) => b.textContent?.includes('About'));
|
||||||
expect(itemBtn).toBeDefined()
|
expect(itemBtn).toBeDefined();
|
||||||
await userEvent.click(itemBtn!)
|
await userEvent.click(itemBtn as HTMLElement);
|
||||||
expect(screen.queryByText('Close')).not.toBeInTheDocument()
|
expect(screen.queryByText('Close')).not.toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: 'Menu' })).toBeInTheDocument()
|
expect(screen.getByRole('button', { name: 'Menu' })).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,32 +1,32 @@
|
|||||||
'use client'
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react';
|
||||||
import { cn } from '$shared/lib'
|
import { cn } from '$shared/lib';
|
||||||
import type { NavItem } from '../model/types'
|
import type { NavItem } from '../model/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
* Navigation items to render
|
* Navigation items to render
|
||||||
*/
|
*/
|
||||||
items: NavItem[]
|
items: NavItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mobile navigation overlay, hidden on lg+ screens.
|
* Mobile navigation overlay, hidden on lg+ screens.
|
||||||
*/
|
*/
|
||||||
export function MobileNav({ items }: Props) {
|
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.
|
* Scrolls to the section by id with a 100px offset, then closes the menu.
|
||||||
*/
|
*/
|
||||||
function scrollToSection(id: string) {
|
function scrollToSection(id: string) {
|
||||||
const el = document.getElementById(id)
|
const el = document.getElementById(id);
|
||||||
if (el) {
|
if (el) {
|
||||||
const top = el.getBoundingClientRect().top + window.scrollY - 100
|
const top = el.getBoundingClientRect().top + window.scrollY - 100;
|
||||||
window.scrollTo({ top, behavior: 'smooth' })
|
window.scrollTo({ top, behavior: 'smooth' });
|
||||||
}
|
}
|
||||||
setIsOpen(false)
|
setIsOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -34,7 +34,8 @@ export function MobileNav({ items }: Props) {
|
|||||||
<div className="px-6 py-4 flex items-center justify-between">
|
<div className="px-6 py-4 flex items-center justify-between">
|
||||||
<h4>allmy.work</h4>
|
<h4>allmy.work</h4>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsOpen(prev => !prev)}
|
type="button"
|
||||||
|
onClick={() => setIsOpen((prev) => !prev)}
|
||||||
className="brutal-border px-4 py-2 bg-carbon-black text-ochre-clay"
|
className="brutal-border px-4 py-2 bg-carbon-black text-ochre-clay"
|
||||||
>
|
>
|
||||||
{isOpen ? 'Close' : 'Menu'}
|
{isOpen ? 'Close' : 'Menu'}
|
||||||
@@ -42,8 +43,9 @@ export function MobileNav({ items }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="px-6 py-6 brutal-border-top space-y-2 max-h-[80vh] overflow-y-auto">
|
<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
|
<button
|
||||||
|
type="button"
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => scrollToSection(item.id)}
|
onClick={() => scrollToSection(item.id)}
|
||||||
className="w-full text-left brutal-border bg-ochre-clay px-4 py-3"
|
className="w-full text-left brutal-border bg-ochre-clay px-4 py-3"
|
||||||
@@ -62,5 +64,5 @@ export function MobileNav({ items }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||||
import { SidebarNav } from './SidebarNav'
|
import { SidebarNav } from './SidebarNav';
|
||||||
|
|
||||||
// SidebarNav is hidden lg:block — it renders only on desktop viewports.
|
// 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.
|
// 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',
|
defaultViewport: 'desktop',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
export default meta
|
export default meta;
|
||||||
|
|
||||||
type Story = StoryObj<typeof SidebarNav>
|
type Story = StoryObj<typeof SidebarNav>;
|
||||||
|
|
||||||
export const Default: Story = {
|
export const Default: Story = {
|
||||||
args: {
|
args: {
|
||||||
@@ -26,4 +26,4 @@ export const Default: Story = {
|
|||||||
{ id: 'contact', label: 'Contact', number: '03' },
|
{ id: 'contact', label: 'Contact', number: '03' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,62 +1,59 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { render, screen } from '@testing-library/react';
|
||||||
import { render, screen } from '@testing-library/react'
|
import type { NavItem } from '../model/types';
|
||||||
import { SidebarNav } from './SidebarNav'
|
import { SidebarNav } from './SidebarNav';
|
||||||
import type { NavItem } from '../model/types'
|
|
||||||
|
|
||||||
const ITEMS: NavItem[] = [
|
const ITEMS: NavItem[] = [
|
||||||
{ id: 'bio', label: 'Bio', number: '01' },
|
{ id: 'bio', label: 'Bio', number: '01' },
|
||||||
{ id: 'work', label: 'Work', number: '02' },
|
{ id: 'work', label: 'Work', number: '02' },
|
||||||
]
|
];
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
global.IntersectionObserver = vi.fn(function () {
|
global.IntersectionObserver = class {
|
||||||
return {
|
observe = vi.fn();
|
||||||
observe: vi.fn(),
|
disconnect = vi.fn();
|
||||||
disconnect: vi.fn(),
|
unobserve = vi.fn();
|
||||||
unobserve: vi.fn(),
|
} as unknown as typeof IntersectionObserver;
|
||||||
}
|
});
|
||||||
}) as unknown as typeof IntersectionObserver
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('SidebarNav', () => {
|
describe('SidebarNav', () => {
|
||||||
describe('rendering', () => {
|
describe('rendering', () => {
|
||||||
it('renders a nav element', () => {
|
it('renders a nav element', () => {
|
||||||
render(<SidebarNav items={ITEMS} />)
|
render(<SidebarNav items={ITEMS} />);
|
||||||
expect(screen.getByRole('navigation')).toBeInTheDocument()
|
expect(screen.getByRole('navigation')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('renders "Index" heading', () => {
|
it('renders "Index" heading', () => {
|
||||||
render(<SidebarNav items={ITEMS} />)
|
render(<SidebarNav items={ITEMS} />);
|
||||||
expect(screen.getByText('Index')).toBeInTheDocument()
|
expect(screen.getByText('Index')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('renders "Digital Monograph" subtitle', () => {
|
it('renders "Digital Monograph" subtitle', () => {
|
||||||
render(<SidebarNav items={ITEMS} />)
|
render(<SidebarNav items={ITEMS} />);
|
||||||
expect(screen.getByText('Digital Monograph')).toBeInTheDocument()
|
expect(screen.getByText('Digital Monograph')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('renders each item label and number', () => {
|
it('renders each item label and number', () => {
|
||||||
render(<SidebarNav items={ITEMS} />)
|
render(<SidebarNav items={ITEMS} />);
|
||||||
expect(screen.getByText('Bio')).toBeInTheDocument()
|
expect(screen.getByText('Bio')).toBeInTheDocument();
|
||||||
expect(screen.getByText('01')).toBeInTheDocument()
|
expect(screen.getByText('01')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Work')).toBeInTheDocument()
|
expect(screen.getByText('Work')).toBeInTheDocument();
|
||||||
expect(screen.getByText('02')).toBeInTheDocument()
|
expect(screen.getByText('02')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('renders "Quick Links" section', () => {
|
it('renders "Quick Links" section', () => {
|
||||||
render(<SidebarNav items={ITEMS} />)
|
render(<SidebarNav items={ITEMS} />);
|
||||||
expect(screen.getByText('Quick Links')).toBeInTheDocument()
|
expect(screen.getByText('Quick Links')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('renders Email quick link', () => {
|
it('renders Email quick link', () => {
|
||||||
render(<SidebarNav items={ITEMS} />)
|
render(<SidebarNav items={ITEMS} />);
|
||||||
expect(screen.getByRole('link', { name: 'Email' })).toBeInTheDocument()
|
expect(screen.getByRole('link', { name: 'Email' })).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('renders a button for each item', () => {
|
it('renders a button for each item', () => {
|
||||||
render(<SidebarNav items={ITEMS} />)
|
render(<SidebarNav items={ITEMS} />);
|
||||||
const buttons = screen.getAllByRole('button')
|
const buttons = screen.getAllByRole('button');
|
||||||
expect(buttons.length).toBeGreaterThanOrEqual(ITEMS.length)
|
expect(buttons.length).toBeGreaterThanOrEqual(ITEMS.length);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,50 +1,52 @@
|
|||||||
'use client'
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useEffect, useState } from 'react';
|
||||||
import { cn } from '$shared/lib'
|
import { CONTACT_LINKS, cn } from '$shared/lib';
|
||||||
import type { NavItem } from '../model/types'
|
import type { NavItem } from '../model/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
* Navigation items to render
|
* Navigation items to render
|
||||||
*/
|
*/
|
||||||
items: NavItem[]
|
items: NavItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fixed sidebar navigation, visible on lg+ screens.
|
* Fixed sidebar navigation, visible on lg+ screens.
|
||||||
*/
|
*/
|
||||||
export function SidebarNav({ items }: Props) {
|
export function SidebarNav({ items }: Props) {
|
||||||
const [activeSection, setActiveSection] = useState('bio')
|
const [activeSection, setActiveSection] = useState('bio');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
entries => {
|
(entries) => {
|
||||||
entries.forEach(entry => {
|
entries.forEach((entry) => {
|
||||||
if (entry.isIntersecting) {
|
if (entry.isIntersecting) {
|
||||||
setActiveSection(entry.target.id)
|
setActiveSection(entry.target.id);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
{ rootMargin: '-20% 0px -70% 0px', threshold: 0 },
|
{ rootMargin: '-20% 0px -70% 0px', threshold: 0 },
|
||||||
)
|
);
|
||||||
|
|
||||||
items.forEach(item => {
|
items.forEach((item) => {
|
||||||
const el = document.getElementById(item.id)
|
const el = document.getElementById(item.id);
|
||||||
if (el) observer.observe(el)
|
if (el) {
|
||||||
})
|
observer.observe(el);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return () => observer.disconnect()
|
return () => observer.disconnect();
|
||||||
}, [items])
|
}, [items]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scrolls to the section by id with a 40px offset.
|
* Scrolls to the section by id with a 40px offset.
|
||||||
*/
|
*/
|
||||||
function scrollToSection(id: string) {
|
function scrollToSection(id: string) {
|
||||||
const el = document.getElementById(id)
|
const el = document.getElementById(id);
|
||||||
if (el) {
|
if (el) {
|
||||||
const top = el.getBoundingClientRect().top + window.scrollY - 40
|
const top = el.getBoundingClientRect().top + window.scrollY - 40;
|
||||||
window.scrollTo({ top, behavior: 'smooth' })
|
window.scrollTo({ top, behavior: 'smooth' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,10 +60,11 @@ export function SidebarNav({ items }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{items.map(item => {
|
{items.map((item) => {
|
||||||
const isActive = activeSection === item.id
|
const isActive = activeSection === item.id;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => scrollToSection(item.id)}
|
onClick={() => scrollToSection(item.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -76,19 +79,27 @@ export function SidebarNav({ items }: Props) {
|
|||||||
<span className="font-heading text-xl font-black">{item.label}</span>
|
<span className="font-heading text-xl font-black">{item.label}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<div className="mt-12 pt-12 brutal-border-top">
|
<div className="mt-12 pt-12 brutal-border-top">
|
||||||
<p className="text-sm uppercase tracking-wider mb-4 opacity-60">Quick Links</p>
|
<p className="text-sm uppercase tracking-wider mb-4 opacity-60">Quick Links</p>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<a href="mailto:hello@allmy.work" className="block">Email</a>
|
<a href={`mailto:${CONTACT_LINKS.email}`} className="block">
|
||||||
<a href="https://linkedin.com" className="block">LinkedIn</a>
|
Email
|
||||||
<a href="https://instagram.com" className="block">Instagram</a>
|
</a>
|
||||||
<a href="https://are.na" className="block">Are.na</a>
|
<a href={CONTACT_LINKS.linkedin} className="block">
|
||||||
|
LinkedIn
|
||||||
|
</a>
|
||||||
|
<a href={CONTACT_LINKS.instagram} className="block">
|
||||||
|
Instagram
|
||||||
|
</a>
|
||||||
|
<a href={CONTACT_LINKS.arena} className="block">
|
||||||
|
Are.na
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||||
import { UtilityBar } from './UtilityBar'
|
import { UtilityBar } from './UtilityBar';
|
||||||
|
|
||||||
const meta: Meta<typeof UtilityBar> = {
|
const meta: Meta<typeof UtilityBar> = {
|
||||||
title: 'Widgets/UtilityBar',
|
title: 'Widgets/UtilityBar',
|
||||||
@@ -11,10 +11,10 @@ const meta: Meta<typeof UtilityBar> = {
|
|||||||
</div>
|
</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,29 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { render, screen } from '@testing-library/react';
|
||||||
import { render, screen } from '@testing-library/react'
|
import { UtilityBar } from './UtilityBar';
|
||||||
import { UtilityBar } from './UtilityBar'
|
|
||||||
|
|
||||||
describe('UtilityBar', () => {
|
describe('UtilityBar', () => {
|
||||||
describe('rendering', () => {
|
describe('rendering', () => {
|
||||||
it('renders "Contact" label', () => {
|
it('renders "Contact" label', () => {
|
||||||
render(<UtilityBar />)
|
render(<UtilityBar />);
|
||||||
expect(screen.getByText('Contact')).toBeInTheDocument()
|
expect(screen.getByText('Contact')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('renders email link with correct href', () => {
|
it('renders email link with correct href', () => {
|
||||||
render(<UtilityBar />)
|
render(<UtilityBar />);
|
||||||
const link = screen.getByRole('link', { name: 'hello@allmy.work' })
|
const link = screen.getByRole('link', { name: 'hello@allmy.work' });
|
||||||
expect(link).toBeInTheDocument()
|
expect(link).toBeInTheDocument();
|
||||||
expect(link).toHaveAttribute('href', 'mailto:hello@allmy.work')
|
expect(link).toHaveAttribute('href', 'mailto:hello@allmy.work');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('renders "Download CV" button', () => {
|
it('renders "Download CV" button', () => {
|
||||||
render(<UtilityBar />)
|
render(<UtilityBar />);
|
||||||
expect(screen.getByRole('button', { name: /download cv/i })).toBeInTheDocument()
|
expect(screen.getByRole('button', { name: /download cv/i })).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('Download CV button has primary variant class', () => {
|
it('Download CV button has primary variant class', () => {
|
||||||
render(<UtilityBar />)
|
render(<UtilityBar />);
|
||||||
const btn = screen.getByRole('button', { name: /download cv/i })
|
const btn = screen.getByRole('button', { name: /download cv/i });
|
||||||
expect(btn).toHaveClass('bg-burnt-oxide')
|
expect(btn).toHaveClass('bg-burnt-oxide');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client';
|
||||||
|
|
||||||
import { Button } from '$shared/ui'
|
import { CONTACT_LINKS } from '$shared/lib';
|
||||||
|
import { Button } from '$shared/ui';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fixed bottom utility bar with contact info and CV download.
|
* Fixed bottom utility bar with contact info and CV download.
|
||||||
@@ -10,7 +11,7 @@ export function UtilityBar() {
|
|||||||
* Handles CV download action.
|
* Handles CV download action.
|
||||||
*/
|
*/
|
||||||
function handleDownloadCV() {
|
function handleDownloadCV() {
|
||||||
console.log('Downloading CV...')
|
console.log('Downloading CV...');
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -18,11 +19,8 @@ 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="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">
|
<div className="flex items-center gap-4">
|
||||||
<span className="text-sm uppercase tracking-wider">Contact</span>
|
<span className="text-sm uppercase tracking-wider">Contact</span>
|
||||||
<a
|
<a href={`mailto:${CONTACT_LINKS.email}`} className="text-base hover:text-burnt-oxide transition-colors">
|
||||||
href="mailto:hello@allmy.work"
|
{CONTACT_LINKS.email}
|
||||||
className="text-base hover:text-burnt-oxide transition-colors"
|
|
||||||
>
|
|
||||||
hello@allmy.work
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="primary" size="sm" onClick={handleDownloadCV}>
|
<Button variant="primary" size="sm" onClick={handleDownloadCV}>
|
||||||
@@ -30,5 +28,5 @@ export function UtilityBar() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registry of dynamic section widgets.
|
||||||
|
*/
|
||||||
|
const SECTIONS: Record<string, () => Promise<{ default: React.ComponentType<Record<string, unknown>> }>> = {
|
||||||
|
intro: () => import('../../../IntroSection/ui/IntroSection/IntroSection'),
|
||||||
|
bio: () => import('../../../BioSection/ui/BioSection/BioSection'),
|
||||||
|
skills: () => import('../../../SkillsSection/ui/SkillsSection/SkillsSection'),
|
||||||
|
experience: () => import('../../../ExperienceSection/ui/ExperienceSection/ExperienceSection'),
|
||||||
|
projects: () => import('../../../ProjectsSection/ui/ProjectsSection/ProjectsSection'),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for the SectionFactory widget.
|
||||||
|
*/
|
||||||
|
export type SectionFactoryProps = {
|
||||||
|
/**
|
||||||
|
* Section slug to render
|
||||||
|
*/
|
||||||
|
slug: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory widget that dynamically imports and renders the correct section widget.
|
||||||
|
* Based on the provided slug.
|
||||||
|
*/
|
||||||
|
export async function SectionFactory({ slug }: SectionFactoryProps) {
|
||||||
|
const loadSection = SECTIONS[slug];
|
||||||
|
|
||||||
|
if (!loadSection) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { default: SectionComponent } = await loadSection();
|
||||||
|
|
||||||
|
return <SectionComponent />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { SkillRecord } from '$shared/api';
|
||||||
|
import { getCollection } from '$shared/api';
|
||||||
|
import { Badge } from '$shared/ui';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skills section component.
|
||||||
|
* Displays technology skills grouped by category.
|
||||||
|
*/
|
||||||
|
export default async function SkillsSection() {
|
||||||
|
const data = await getCollection<SkillRecord>('skills', {
|
||||||
|
sort: 'category,order',
|
||||||
|
});
|
||||||
|
|
||||||
|
const categories = data.items.reduce(
|
||||||
|
(acc, skill) => {
|
||||||
|
if (!acc[skill.category]) {
|
||||||
|
acc[skill.category] = [];
|
||||||
|
}
|
||||||
|
acc[skill.category].push(skill);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, SkillRecord[]>,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-12">
|
||||||
|
{Object.entries(categories).map(([category, items]) => (
|
||||||
|
<div key={category} className="space-y-4">
|
||||||
|
<h3 className="text-xl font-bold uppercase tracking-widest opacity-50">{category}</h3>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{items.map((skill) => (
|
||||||
|
<Badge key={skill.id}>{skill.name}</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
export * from './Navigation'
|
export * from './Navigation';
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"name": "next"
|
"name": "next"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"types": ["vitest/globals"],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./*"],
|
"@/*": ["./*"],
|
||||||
"$shared/*": ["./src/shared/*"],
|
"$shared/*": ["./src/shared/*"],
|
||||||
|
|||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="@vitest/browser-playwright" />
|
||||||
@@ -248,6 +248,97 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"@biomejs/biome@npm:2.4.13":
|
||||||
|
version: 2.4.13
|
||||||
|
resolution: "@biomejs/biome@npm:2.4.13"
|
||||||
|
dependencies:
|
||||||
|
"@biomejs/cli-darwin-arm64": "npm:2.4.13"
|
||||||
|
"@biomejs/cli-darwin-x64": "npm:2.4.13"
|
||||||
|
"@biomejs/cli-linux-arm64": "npm:2.4.13"
|
||||||
|
"@biomejs/cli-linux-arm64-musl": "npm:2.4.13"
|
||||||
|
"@biomejs/cli-linux-x64": "npm:2.4.13"
|
||||||
|
"@biomejs/cli-linux-x64-musl": "npm:2.4.13"
|
||||||
|
"@biomejs/cli-win32-arm64": "npm:2.4.13"
|
||||||
|
"@biomejs/cli-win32-x64": "npm:2.4.13"
|
||||||
|
dependenciesMeta:
|
||||||
|
"@biomejs/cli-darwin-arm64":
|
||||||
|
optional: true
|
||||||
|
"@biomejs/cli-darwin-x64":
|
||||||
|
optional: true
|
||||||
|
"@biomejs/cli-linux-arm64":
|
||||||
|
optional: true
|
||||||
|
"@biomejs/cli-linux-arm64-musl":
|
||||||
|
optional: true
|
||||||
|
"@biomejs/cli-linux-x64":
|
||||||
|
optional: true
|
||||||
|
"@biomejs/cli-linux-x64-musl":
|
||||||
|
optional: true
|
||||||
|
"@biomejs/cli-win32-arm64":
|
||||||
|
optional: true
|
||||||
|
"@biomejs/cli-win32-x64":
|
||||||
|
optional: true
|
||||||
|
bin:
|
||||||
|
biome: bin/biome
|
||||||
|
checksum: 10c0/a8c09d7c05d834243a76704e31bda05346d2a06a75e90e6de2ef0d4edc33bd7d382b380bad9275ddd379e9e44ceaea9907a9c0de2156859b36b057c155f20a0e
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"@biomejs/cli-darwin-arm64@npm:2.4.13":
|
||||||
|
version: 2.4.13
|
||||||
|
resolution: "@biomejs/cli-darwin-arm64@npm:2.4.13"
|
||||||
|
conditions: os=darwin & cpu=arm64
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"@biomejs/cli-darwin-x64@npm:2.4.13":
|
||||||
|
version: 2.4.13
|
||||||
|
resolution: "@biomejs/cli-darwin-x64@npm:2.4.13"
|
||||||
|
conditions: os=darwin & cpu=x64
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"@biomejs/cli-linux-arm64-musl@npm:2.4.13":
|
||||||
|
version: 2.4.13
|
||||||
|
resolution: "@biomejs/cli-linux-arm64-musl@npm:2.4.13"
|
||||||
|
conditions: os=linux & cpu=arm64 & libc=musl
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"@biomejs/cli-linux-arm64@npm:2.4.13":
|
||||||
|
version: 2.4.13
|
||||||
|
resolution: "@biomejs/cli-linux-arm64@npm:2.4.13"
|
||||||
|
conditions: os=linux & cpu=arm64 & libc=glibc
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"@biomejs/cli-linux-x64-musl@npm:2.4.13":
|
||||||
|
version: 2.4.13
|
||||||
|
resolution: "@biomejs/cli-linux-x64-musl@npm:2.4.13"
|
||||||
|
conditions: os=linux & cpu=x64 & libc=musl
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"@biomejs/cli-linux-x64@npm:2.4.13":
|
||||||
|
version: 2.4.13
|
||||||
|
resolution: "@biomejs/cli-linux-x64@npm:2.4.13"
|
||||||
|
conditions: os=linux & cpu=x64 & libc=glibc
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"@biomejs/cli-win32-arm64@npm:2.4.13":
|
||||||
|
version: 2.4.13
|
||||||
|
resolution: "@biomejs/cli-win32-arm64@npm:2.4.13"
|
||||||
|
conditions: os=win32 & cpu=arm64
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"@biomejs/cli-win32-x64@npm:2.4.13":
|
||||||
|
version: 2.4.13
|
||||||
|
resolution: "@biomejs/cli-win32-x64@npm:2.4.13"
|
||||||
|
conditions: os=win32 & cpu=x64
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"@blazediff/core@npm:1.9.1":
|
"@blazediff/core@npm:1.9.1":
|
||||||
version: 1.9.1
|
version: 1.9.1
|
||||||
resolution: "@blazediff/core@npm:1.9.1"
|
resolution: "@blazediff/core@npm:1.9.1"
|
||||||
@@ -4826,6 +4917,117 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"lefthook-darwin-arm64@npm:2.1.6":
|
||||||
|
version: 2.1.6
|
||||||
|
resolution: "lefthook-darwin-arm64@npm:2.1.6"
|
||||||
|
conditions: os=darwin & cpu=arm64
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"lefthook-darwin-x64@npm:2.1.6":
|
||||||
|
version: 2.1.6
|
||||||
|
resolution: "lefthook-darwin-x64@npm:2.1.6"
|
||||||
|
conditions: os=darwin & cpu=x64
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"lefthook-freebsd-arm64@npm:2.1.6":
|
||||||
|
version: 2.1.6
|
||||||
|
resolution: "lefthook-freebsd-arm64@npm:2.1.6"
|
||||||
|
conditions: os=freebsd & cpu=arm64
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"lefthook-freebsd-x64@npm:2.1.6":
|
||||||
|
version: 2.1.6
|
||||||
|
resolution: "lefthook-freebsd-x64@npm:2.1.6"
|
||||||
|
conditions: os=freebsd & cpu=x64
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"lefthook-linux-arm64@npm:2.1.6":
|
||||||
|
version: 2.1.6
|
||||||
|
resolution: "lefthook-linux-arm64@npm:2.1.6"
|
||||||
|
conditions: os=linux & cpu=arm64
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"lefthook-linux-x64@npm:2.1.6":
|
||||||
|
version: 2.1.6
|
||||||
|
resolution: "lefthook-linux-x64@npm:2.1.6"
|
||||||
|
conditions: os=linux & cpu=x64
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"lefthook-openbsd-arm64@npm:2.1.6":
|
||||||
|
version: 2.1.6
|
||||||
|
resolution: "lefthook-openbsd-arm64@npm:2.1.6"
|
||||||
|
conditions: os=openbsd & cpu=arm64
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"lefthook-openbsd-x64@npm:2.1.6":
|
||||||
|
version: 2.1.6
|
||||||
|
resolution: "lefthook-openbsd-x64@npm:2.1.6"
|
||||||
|
conditions: os=openbsd & cpu=x64
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"lefthook-windows-arm64@npm:2.1.6":
|
||||||
|
version: 2.1.6
|
||||||
|
resolution: "lefthook-windows-arm64@npm:2.1.6"
|
||||||
|
conditions: os=win32 & cpu=arm64
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"lefthook-windows-x64@npm:2.1.6":
|
||||||
|
version: 2.1.6
|
||||||
|
resolution: "lefthook-windows-x64@npm:2.1.6"
|
||||||
|
conditions: os=win32 & cpu=x64
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"lefthook@npm:^2.1.6":
|
||||||
|
version: 2.1.6
|
||||||
|
resolution: "lefthook@npm:2.1.6"
|
||||||
|
dependencies:
|
||||||
|
lefthook-darwin-arm64: "npm:2.1.6"
|
||||||
|
lefthook-darwin-x64: "npm:2.1.6"
|
||||||
|
lefthook-freebsd-arm64: "npm:2.1.6"
|
||||||
|
lefthook-freebsd-x64: "npm:2.1.6"
|
||||||
|
lefthook-linux-arm64: "npm:2.1.6"
|
||||||
|
lefthook-linux-x64: "npm:2.1.6"
|
||||||
|
lefthook-openbsd-arm64: "npm:2.1.6"
|
||||||
|
lefthook-openbsd-x64: "npm:2.1.6"
|
||||||
|
lefthook-windows-arm64: "npm:2.1.6"
|
||||||
|
lefthook-windows-x64: "npm:2.1.6"
|
||||||
|
dependenciesMeta:
|
||||||
|
lefthook-darwin-arm64:
|
||||||
|
optional: true
|
||||||
|
lefthook-darwin-x64:
|
||||||
|
optional: true
|
||||||
|
lefthook-freebsd-arm64:
|
||||||
|
optional: true
|
||||||
|
lefthook-freebsd-x64:
|
||||||
|
optional: true
|
||||||
|
lefthook-linux-arm64:
|
||||||
|
optional: true
|
||||||
|
lefthook-linux-x64:
|
||||||
|
optional: true
|
||||||
|
lefthook-openbsd-arm64:
|
||||||
|
optional: true
|
||||||
|
lefthook-openbsd-x64:
|
||||||
|
optional: true
|
||||||
|
lefthook-windows-arm64:
|
||||||
|
optional: true
|
||||||
|
lefthook-windows-x64:
|
||||||
|
optional: true
|
||||||
|
bin:
|
||||||
|
lefthook: bin/index.js
|
||||||
|
checksum: 10c0/3ccbe60951ebf59e35e02ca10dc8942a4455ec106f0f14a5fed2e40f000b5b57190594f3be87715d5a9a8b0cf93de33902cbd3c94688e116ffc07ad1760cfe9e
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"levn@npm:^0.4.1":
|
"levn@npm:^0.4.1":
|
||||||
version: 0.4.1
|
version: 0.4.1
|
||||||
resolution: "levn@npm:0.4.1"
|
resolution: "levn@npm:0.4.1"
|
||||||
@@ -5636,6 +5838,7 @@ __metadata:
|
|||||||
version: 0.0.0-use.local
|
version: 0.0.0-use.local
|
||||||
resolution: "portfolio@workspace:."
|
resolution: "portfolio@workspace:."
|
||||||
dependencies:
|
dependencies:
|
||||||
|
"@biomejs/biome": "npm:2.4.13"
|
||||||
"@chromatic-com/storybook": "npm:^5.1.2"
|
"@chromatic-com/storybook": "npm:^5.1.2"
|
||||||
"@storybook/addon-a11y": "npm:^10.3.5"
|
"@storybook/addon-a11y": "npm:^10.3.5"
|
||||||
"@storybook/addon-docs": "npm:^10.3.5"
|
"@storybook/addon-docs": "npm:^10.3.5"
|
||||||
@@ -5657,6 +5860,7 @@ __metadata:
|
|||||||
eslint-config-next: "npm:16.2.4"
|
eslint-config-next: "npm:16.2.4"
|
||||||
eslint-plugin-storybook: "npm:^10.3.5"
|
eslint-plugin-storybook: "npm:^10.3.5"
|
||||||
jsdom: "npm:^29.0.2"
|
jsdom: "npm:^29.0.2"
|
||||||
|
lefthook: "npm:^2.1.6"
|
||||||
next: "npm:16.2.4"
|
next: "npm:16.2.4"
|
||||||
playwright: "npm:^1.59.1"
|
playwright: "npm:^1.59.1"
|
||||||
react: "npm:19.2.4"
|
react: "npm:19.2.4"
|
||||||
|
|||||||
Reference in New Issue
Block a user