chore: format codebase and move SectionAccordion to entities/Section
This commit is contained in:
+20
-26
@@ -1,9 +1,9 @@
|
||||
import type { ListResponse } from './types'
|
||||
import type { ListResponse } from './types';
|
||||
|
||||
/**
|
||||
* Native fetch wrapper for PocketBase API requests.
|
||||
*/
|
||||
const PB_URL = process.env.NEXT_PUBLIC_PB_URL || 'http://127.0.0.1:8090'
|
||||
const PB_URL = process.env.NEXT_PUBLIC_PB_URL || 'http://127.0.0.1:8090';
|
||||
|
||||
/**
|
||||
* Options for PocketBase collection fetching.
|
||||
@@ -12,61 +12,55 @@ export type PBFetchOptions = {
|
||||
/**
|
||||
* Sorting criteria (e.g., "-created,order")
|
||||
*/
|
||||
sort?: string
|
||||
sort?: string;
|
||||
/**
|
||||
* Filter query string
|
||||
*/
|
||||
filter?: string
|
||||
filter?: string;
|
||||
/**
|
||||
* Fields to expand (e.g., "stack")
|
||||
*/
|
||||
expand?: string
|
||||
expand?: string;
|
||||
/**
|
||||
* Cache revalidation time in seconds
|
||||
* @default 3600
|
||||
*/
|
||||
revalidate?: number
|
||||
}
|
||||
revalidate?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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, revalidate = 3600 } = options
|
||||
export async function getCollection<T>(collection: string, options: PBFetchOptions = {}): Promise<ListResponse<T>> {
|
||||
const { sort, filter, expand, revalidate = 3600 } = options;
|
||||
|
||||
const params = new URLSearchParams()
|
||||
if (sort) params.set('sort', sort)
|
||||
if (filter) params.set('filter', filter)
|
||||
if (expand) params.set('expand', expand)
|
||||
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()}`
|
||||
const url = `${PB_URL}/api/collections/${collection}/records?${params.toString()}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
next: { revalidate },
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch collection: ${collection}`)
|
||||
throw new Error(`Failed to fetch collection: ${collection}`);
|
||||
}
|
||||
|
||||
return res.json()
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single record from a PocketBase collection by ID or filter.
|
||||
*/
|
||||
export async function getFirstRecord<T>(
|
||||
collection: string,
|
||||
options: PBFetchOptions = {}
|
||||
): Promise<T | null> {
|
||||
export async function getFirstRecord<T>(collection: string, options: PBFetchOptions = {}): Promise<T | null> {
|
||||
const data = await getCollection<T>(collection, {
|
||||
...options,
|
||||
// PocketBase convention for "first" or "singleton" patterns
|
||||
filter: options.filter,
|
||||
})
|
||||
});
|
||||
|
||||
return data.items[0] || null
|
||||
return data.items[0] || null;
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from './types'
|
||||
export * from './client'
|
||||
export * from './types';
|
||||
export * from './client';
|
||||
|
||||
+37
-58
@@ -5,60 +5,39 @@ export type BaseRecord = {
|
||||
/**
|
||||
* Unique record ID
|
||||
*/
|
||||
id: string
|
||||
id: string;
|
||||
/**
|
||||
* ID of the collection this record belongs to
|
||||
*/
|
||||
collectionId: string
|
||||
collectionId: string;
|
||||
/**
|
||||
* Name of the collection this record belongs to
|
||||
*/
|
||||
collectionName: string
|
||||
collectionName: string;
|
||||
/**
|
||||
* Record creation timestamp (ISO 8601)
|
||||
*/
|
||||
created: string
|
||||
created: string;
|
||||
/**
|
||||
* Record last update timestamp (ISO 8601)
|
||||
*/
|
||||
updated: string
|
||||
}
|
||||
updated: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* PocketBase collection for site sections and routing.
|
||||
*/
|
||||
export type SectionRecord = BaseRecord & {
|
||||
/**
|
||||
* URL-friendly identifier used for routing
|
||||
* PocketBase collection for simple text blocks (Intro, Bio).
|
||||
*/
|
||||
slug: string
|
||||
/**
|
||||
* Display name of the section
|
||||
*/
|
||||
title: string
|
||||
/**
|
||||
* Visual numbering prefix (e.g., "01")
|
||||
*/
|
||||
number: string
|
||||
/**
|
||||
* Sorting weight for section order
|
||||
*/
|
||||
order: number
|
||||
}
|
||||
export type PageContentRecord = BaseRecord & {
|
||||
|
||||
/**
|
||||
* PocketBase collection for simple text blocks (Intro, Bio).
|
||||
*/
|
||||
export type PageContentRecord = BaseRecord & {
|
||||
/**
|
||||
* Slug corresponding to the parent section
|
||||
*/
|
||||
slug: string
|
||||
slug: string;
|
||||
/**
|
||||
* HTML or Markdown content string
|
||||
*/
|
||||
content: string
|
||||
}
|
||||
content: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* PocketBase collection for technology skills.
|
||||
@@ -67,16 +46,16 @@ export type SkillRecord = BaseRecord & {
|
||||
/**
|
||||
* Name of the technology or tool
|
||||
*/
|
||||
name: string
|
||||
name: string;
|
||||
/**
|
||||
* Grouping category (e.g., 'Frontend', 'Backend')
|
||||
*/
|
||||
category: 'Frontend' | 'Backend' | 'Tools' | 'Design' | string
|
||||
category: 'Frontend' | 'Backend' | 'Tools' | 'Design' | string;
|
||||
/**
|
||||
* Sorting weight within the category
|
||||
*/
|
||||
order: number
|
||||
}
|
||||
order: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* PocketBase collection for work experience history.
|
||||
@@ -85,28 +64,28 @@ export type ExperienceRecord = BaseRecord & {
|
||||
/**
|
||||
* Name of the organization
|
||||
*/
|
||||
company: string
|
||||
company: string;
|
||||
/**
|
||||
* Professional title held
|
||||
*/
|
||||
role: string
|
||||
role: string;
|
||||
/**
|
||||
* Start date of the tenure
|
||||
*/
|
||||
start_date: string
|
||||
start_date: string;
|
||||
/**
|
||||
* End date of the tenure, or null if currently employed
|
||||
*/
|
||||
end_date: string | null
|
||||
end_date: string | null;
|
||||
/**
|
||||
* Rich text description of responsibilities and achievements
|
||||
*/
|
||||
description: string
|
||||
description: string;
|
||||
/**
|
||||
* Sorting weight for chronological display
|
||||
*/
|
||||
order: number
|
||||
}
|
||||
order: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* PocketBase collection for portfolio projects.
|
||||
@@ -115,36 +94,36 @@ export type ProjectRecord = BaseRecord & {
|
||||
/**
|
||||
* Full title of the project
|
||||
*/
|
||||
title: string
|
||||
title: string;
|
||||
/**
|
||||
* Completion or duration year (e.g., "2024")
|
||||
*/
|
||||
year: string
|
||||
year: string;
|
||||
/**
|
||||
* Role performed on the project
|
||||
*/
|
||||
role: string
|
||||
role: string;
|
||||
/**
|
||||
* Short summary of the project
|
||||
*/
|
||||
description: string
|
||||
description: string;
|
||||
/**
|
||||
* List of specific feature or achievement points
|
||||
*/
|
||||
details: string[]
|
||||
details: string[];
|
||||
/**
|
||||
* List of SkillRecord IDs used in the project
|
||||
*/
|
||||
stack: string[]
|
||||
stack: string[];
|
||||
/**
|
||||
* Primary thumbnail or hero image filename
|
||||
*/
|
||||
image: string
|
||||
image: string;
|
||||
/**
|
||||
* Sorting weight for the project list
|
||||
*/
|
||||
order: number
|
||||
}
|
||||
order: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generic response for a list of PocketBase records.
|
||||
@@ -153,21 +132,21 @@ export type ListResponse<T> = {
|
||||
/**
|
||||
* Current page index
|
||||
*/
|
||||
page: number
|
||||
page: number;
|
||||
/**
|
||||
* Number of items per page
|
||||
*/
|
||||
perPage: number
|
||||
perPage: number;
|
||||
/**
|
||||
* Total number of items across all pages
|
||||
*/
|
||||
totalItems: number
|
||||
totalItems: number;
|
||||
/**
|
||||
* Total number of pages available
|
||||
*/
|
||||
totalPages: number
|
||||
totalPages: number;
|
||||
/**
|
||||
* Array of records for the current page
|
||||
*/
|
||||
items: T[]
|
||||
}
|
||||
items: T[];
|
||||
};
|
||||
|
||||
+3
-3
@@ -1,3 +1,3 @@
|
||||
export * from './ui'
|
||||
export * from './lib'
|
||||
export * from './api'
|
||||
export * from './ui';
|
||||
export * from './lib';
|
||||
export * from './api';
|
||||
|
||||
+21
-21
@@ -1,40 +1,40 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { cn } from './cn'
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { cn } from './cn';
|
||||
|
||||
describe('cn', () => {
|
||||
describe('basic merging', () => {
|
||||
it('returns single class unchanged', () => {
|
||||
expect(cn('foo')).toBe('foo')
|
||||
})
|
||||
expect(cn('foo')).toBe('foo');
|
||||
});
|
||||
|
||||
it('joins multiple classes', () => {
|
||||
expect(cn('foo', 'bar')).toBe('foo bar')
|
||||
})
|
||||
})
|
||||
expect(cn('foo', 'bar')).toBe('foo bar');
|
||||
});
|
||||
});
|
||||
|
||||
describe('conditional classes', () => {
|
||||
it('includes truthy conditional', () => {
|
||||
expect(cn('foo', true && 'bar')).toBe('foo bar')
|
||||
})
|
||||
expect(cn('foo', true && 'bar')).toBe('foo bar');
|
||||
});
|
||||
|
||||
it('excludes falsy conditional', () => {
|
||||
expect(cn('foo', false && 'bar')).toBe('foo')
|
||||
})
|
||||
})
|
||||
expect(cn('foo', false && 'bar')).toBe('foo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('object syntax', () => {
|
||||
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', () => {
|
||||
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', () => {
|
||||
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 { twMerge } from 'tailwind-merge'
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
/**
|
||||
* Merges Tailwind classes, resolving conflicts in favor of the last value.
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs))
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -7,7 +7,7 @@ export const fraunces = Fraunces({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-fraunces',
|
||||
axes: ['opsz', 'SOFT', 'WONK'],
|
||||
})
|
||||
});
|
||||
|
||||
/**
|
||||
* Body font
|
||||
@@ -15,4 +15,4 @@ export const fraunces = Fraunces({
|
||||
export const publicSans = Public_Sans({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-public-sans',
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export { cn } from './cn'
|
||||
export type { ClassValue } from 'clsx'
|
||||
export * from './fonts'
|
||||
export { cn } from './cn';
|
||||
export type { ClassValue } from 'clsx';
|
||||
export * from './fonts';
|
||||
|
||||
+66
-25
@@ -2,7 +2,7 @@
|
||||
/* === TYPOGRAPHY SCALE (Augmented Fourth 1.414) === */
|
||||
--font-size: 16px;
|
||||
--text-xs: 0.707rem;
|
||||
--text-sm: 0.840rem;
|
||||
--text-sm: 0.84rem;
|
||||
--text-base: 1rem;
|
||||
--text-lg: 1.414rem;
|
||||
--text-xl: 2rem;
|
||||
@@ -29,9 +29,9 @@
|
||||
--fraunces-soft: 0;
|
||||
|
||||
/* === COLOR PALETTE === */
|
||||
--ochre-clay: #D9B48F;
|
||||
--slate-indigo: #3B4A59;
|
||||
--burnt-oxide: #A64B35;
|
||||
--ochre-clay: #d9b48f;
|
||||
--slate-indigo: #3b4a59;
|
||||
--burnt-oxide: #a64b35;
|
||||
--carbon-black: #121212;
|
||||
|
||||
/* === SEMANTIC COLORS === */
|
||||
@@ -126,31 +126,48 @@
|
||||
|
||||
/* Paper grain texture */
|
||||
body::before {
|
||||
content: '';
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image:
|
||||
repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.02) 2px, rgba(0,0,0,0.02) 4px),
|
||||
repeating-linear-gradient(90deg, transparent, transparent 2px, rgba(0,0,0,0.02) 2px, rgba(0,0,0,0.02) 4px);
|
||||
repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0, 0, 0, 0.02) 2px, rgba(0, 0, 0, 0.02) 4px),
|
||||
repeating-linear-gradient(90deg, transparent, transparent 2px, rgba(0, 0, 0, 0.02) 2px, rgba(0, 0, 0, 0.02) 4px);
|
||||
opacity: 0.4;
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: var(--font-heading);
|
||||
font-weight: var(--font-weight-heading);
|
||||
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);
|
||||
}
|
||||
|
||||
h1 { font-size: var(--text-4xl); }
|
||||
h2 { font-size: var(--text-3xl); }
|
||||
h3 { font-size: var(--text-2xl); }
|
||||
h4 { font-size: var(--text-xl); }
|
||||
h5 { font-size: var(--text-lg); }
|
||||
h1 {
|
||||
font-size: var(--text-4xl);
|
||||
}
|
||||
h2 {
|
||||
font-size: var(--text-3xl);
|
||||
}
|
||||
h3 {
|
||||
font-size: var(--text-2xl);
|
||||
}
|
||||
h4 {
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
h5 {
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
p {
|
||||
font-family: var(--font-body);
|
||||
@@ -180,18 +197,42 @@
|
||||
}
|
||||
|
||||
/* Brutalist utility classes */
|
||||
.brutal-shadow { box-shadow: var(--shadow-brutal); }
|
||||
.brutal-shadow-sm { box-shadow: var(--shadow-brutal-sm); }
|
||||
.brutal-shadow-lg { 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); }
|
||||
.brutal-shadow {
|
||||
box-shadow: var(--shadow-brutal);
|
||||
}
|
||||
.brutal-shadow-sm {
|
||||
box-shadow: var(--shadow-brutal-sm);
|
||||
}
|
||||
.brutal-shadow-lg {
|
||||
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 */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
from {
|
||||
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 { Badge } from './ui/Badge';
|
||||
export type { BadgeVariant } from './ui/Badge';
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import { Badge } from './Badge'
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||
import { Badge } from './Badge';
|
||||
|
||||
const meta: Meta<typeof Badge> = {
|
||||
title: 'Shared/Badge',
|
||||
component: Badge,
|
||||
}
|
||||
};
|
||||
|
||||
export default meta
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof Badge>
|
||||
type Story = StoryObj<typeof Badge>;
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render: () => (
|
||||
@@ -19,4 +19,4 @@ export const AllVariants: Story = {
|
||||
<Badge variant="outline">Outline</Badge>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,52 +1,52 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { Badge } from './Badge'
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { Badge } from './Badge';
|
||||
|
||||
describe('Badge', () => {
|
||||
describe('rendering', () => {
|
||||
it('renders children', () => {
|
||||
render(<Badge>React</Badge>)
|
||||
expect(screen.getByText('React')).toBeInTheDocument()
|
||||
})
|
||||
render(<Badge>React</Badge>);
|
||||
expect(screen.getByText('React')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders as inline span', () => {
|
||||
render(<Badge>Tag</Badge>)
|
||||
expect(screen.getByText('Tag').tagName).toBe('SPAN')
|
||||
})
|
||||
})
|
||||
render(<Badge>Tag</Badge>);
|
||||
expect(screen.getByText('Tag').tagName).toBe('SPAN');
|
||||
});
|
||||
});
|
||||
|
||||
describe('variants', () => {
|
||||
it('applies default variant classes', () => {
|
||||
render(<Badge variant="default">Tag</Badge>)
|
||||
const el = screen.getByText('Tag')
|
||||
expect(el).toHaveClass('bg-carbon-black', 'text-ochre-clay')
|
||||
})
|
||||
render(<Badge variant="default">Tag</Badge>);
|
||||
const el = screen.getByText('Tag');
|
||||
expect(el).toHaveClass('bg-carbon-black', 'text-ochre-clay');
|
||||
});
|
||||
|
||||
it('applies primary variant classes', () => {
|
||||
render(<Badge variant="primary">Tag</Badge>)
|
||||
expect(screen.getByText('Tag')).toHaveClass('bg-burnt-oxide')
|
||||
})
|
||||
render(<Badge variant="primary">Tag</Badge>);
|
||||
expect(screen.getByText('Tag')).toHaveClass('bg-burnt-oxide');
|
||||
});
|
||||
|
||||
it('applies secondary variant classes', () => {
|
||||
render(<Badge variant="secondary">Tag</Badge>)
|
||||
expect(screen.getByText('Tag')).toHaveClass('bg-slate-indigo')
|
||||
})
|
||||
render(<Badge variant="secondary">Tag</Badge>);
|
||||
expect(screen.getByText('Tag')).toHaveClass('bg-slate-indigo');
|
||||
});
|
||||
|
||||
it('applies outline variant classes', () => {
|
||||
render(<Badge variant="outline">Tag</Badge>)
|
||||
expect(screen.getByText('Tag')).toHaveClass('bg-transparent')
|
||||
})
|
||||
render(<Badge variant="outline">Tag</Badge>);
|
||||
expect(screen.getByText('Tag')).toHaveClass('bg-transparent');
|
||||
});
|
||||
|
||||
it('defaults to default variant when unspecified', () => {
|
||||
render(<Badge>Tag</Badge>)
|
||||
expect(screen.getByText('Tag')).toHaveClass('bg-carbon-black')
|
||||
})
|
||||
})
|
||||
render(<Badge>Tag</Badge>);
|
||||
expect(screen.getByText('Tag')).toHaveClass('bg-carbon-black');
|
||||
});
|
||||
});
|
||||
|
||||
describe('className passthrough', () => {
|
||||
it('merges custom className', () => {
|
||||
render(<Badge className="mt-4">Tag</Badge>)
|
||||
expect(screen.getByText('Tag')).toHaveClass('mt-4')
|
||||
})
|
||||
})
|
||||
})
|
||||
render(<Badge className="mt-4">Tag</Badge>);
|
||||
expect(screen.getByText('Tag')).toHaveClass('mt-4');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '$shared/lib'
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '$shared/lib';
|
||||
|
||||
export type BadgeVariant = 'default' | 'primary' | 'secondary' | 'outline'
|
||||
export type BadgeVariant = 'default' | 'primary' | 'secondary' | 'outline';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Badge content
|
||||
*/
|
||||
children: ReactNode
|
||||
children: ReactNode;
|
||||
/**
|
||||
* Visual variant
|
||||
* @default 'default'
|
||||
*/
|
||||
variant?: BadgeVariant
|
||||
variant?: BadgeVariant;
|
||||
/**
|
||||
* Additional CSS classes
|
||||
*/
|
||||
className?: string
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const VARIANTS: Record<BadgeVariant, string> = {
|
||||
default: 'brutal-border bg-carbon-black text-ochre-clay',
|
||||
primary: 'brutal-border bg-burnt-oxide text-ochre-clay',
|
||||
default: 'brutal-border bg-carbon-black text-ochre-clay',
|
||||
primary: 'brutal-border bg-burnt-oxide 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.
|
||||
@@ -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)}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { Button } from './ui/Button'
|
||||
export type { ButtonVariant, ButtonSize } from './ui/Button'
|
||||
export { Button } from './ui/Button';
|
||||
export type { ButtonVariant, ButtonSize } from './ui/Button';
|
||||
|
||||
@@ -1,35 +1,49 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import { Button } from './Button'
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||
import { Button } from './Button';
|
||||
|
||||
const meta: Meta<typeof Button> = {
|
||||
title: 'Shared/Button',
|
||||
component: Button,
|
||||
}
|
||||
};
|
||||
|
||||
export default meta
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof Button>
|
||||
type Story = StoryObj<typeof Button>;
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render: () => (
|
||||
<div className="flex gap-4 flex-wrap p-8 bg-ochre-clay">
|
||||
<Button variant="primary" size="md">Primary</Button>
|
||||
<Button variant="secondary" size="md">Secondary</Button>
|
||||
<Button variant="outline" size="md">Outline</Button>
|
||||
<Button variant="ghost" size="md">Ghost</Button>
|
||||
<Button variant="primary" size="md">
|
||||
Primary
|
||||
</Button>
|
||||
<Button variant="secondary" size="md">
|
||||
Secondary
|
||||
</Button>
|
||||
<Button variant="outline" size="md">
|
||||
Outline
|
||||
</Button>
|
||||
<Button variant="ghost" size="md">
|
||||
Ghost
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
};
|
||||
|
||||
export const Sizes: Story = {
|
||||
render: () => (
|
||||
<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="md">Medium</Button>
|
||||
<Button variant="primary" size="lg">Large</Button>
|
||||
<Button variant="primary" size="sm">
|
||||
Small
|
||||
</Button>
|
||||
<Button variant="primary" size="md">
|
||||
Medium
|
||||
</Button>
|
||||
<Button variant="primary" size="lg">
|
||||
Large
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
@@ -44,4 +58,4 @@ export const Disabled: Story = {
|
||||
</div>
|
||||
),
|
||||
],
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,67 +1,67 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { Button } from './Button'
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { Button } from './Button';
|
||||
|
||||
describe('Button', () => {
|
||||
describe('rendering', () => {
|
||||
it('renders children', () => {
|
||||
render(<Button>Click me</Button>)
|
||||
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument()
|
||||
})
|
||||
render(<Button>Click me</Button>);
|
||||
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
|
||||
});
|
||||
it('renders as button element', () => {
|
||||
render(<Button>Click</Button>)
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
render(<Button>Click</Button>);
|
||||
expect(screen.getByRole('button')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
describe('variants', () => {
|
||||
it('applies primary variant by default', () => {
|
||||
render(<Button>Go</Button>)
|
||||
expect(screen.getByRole('button')).toHaveClass('bg-burnt-oxide')
|
||||
})
|
||||
render(<Button>Go</Button>);
|
||||
expect(screen.getByRole('button')).toHaveClass('bg-burnt-oxide');
|
||||
});
|
||||
it('applies secondary variant', () => {
|
||||
render(<Button variant="secondary">Go</Button>)
|
||||
expect(screen.getByRole('button')).toHaveClass('bg-slate-indigo')
|
||||
})
|
||||
render(<Button variant="secondary">Go</Button>);
|
||||
expect(screen.getByRole('button')).toHaveClass('bg-slate-indigo');
|
||||
});
|
||||
it('applies outline variant', () => {
|
||||
render(<Button variant="outline">Go</Button>)
|
||||
expect(screen.getByRole('button')).toHaveClass('bg-transparent')
|
||||
})
|
||||
render(<Button variant="outline">Go</Button>);
|
||||
expect(screen.getByRole('button')).toHaveClass('bg-transparent');
|
||||
});
|
||||
it('applies ghost variant', () => {
|
||||
render(<Button variant="ghost">Go</Button>)
|
||||
expect(screen.getByRole('button')).toHaveClass('bg-ochre-clay')
|
||||
})
|
||||
})
|
||||
render(<Button variant="ghost">Go</Button>);
|
||||
expect(screen.getByRole('button')).toHaveClass('bg-ochre-clay');
|
||||
});
|
||||
});
|
||||
describe('sizes', () => {
|
||||
it('applies md size by default', () => {
|
||||
render(<Button>Go</Button>)
|
||||
expect(screen.getByRole('button')).toHaveClass('px-6', 'py-3')
|
||||
})
|
||||
render(<Button>Go</Button>);
|
||||
expect(screen.getByRole('button')).toHaveClass('px-6', 'py-3');
|
||||
});
|
||||
it('applies sm size', () => {
|
||||
render(<Button size="sm">Go</Button>)
|
||||
expect(screen.getByRole('button')).toHaveClass('px-4', 'py-2')
|
||||
})
|
||||
render(<Button size="sm">Go</Button>);
|
||||
expect(screen.getByRole('button')).toHaveClass('px-4', 'py-2');
|
||||
});
|
||||
it('applies lg size', () => {
|
||||
render(<Button size="lg">Go</Button>)
|
||||
expect(screen.getByRole('button')).toHaveClass('px-8', 'py-4')
|
||||
})
|
||||
})
|
||||
render(<Button size="lg">Go</Button>);
|
||||
expect(screen.getByRole('button')).toHaveClass('px-8', 'py-4');
|
||||
});
|
||||
});
|
||||
describe('interactions', () => {
|
||||
it('calls onClick when clicked', async () => {
|
||||
const onClick = vi.fn()
|
||||
render(<Button onClick={onClick}>Go</Button>)
|
||||
await userEvent.click(screen.getByRole('button'))
|
||||
expect(onClick).toHaveBeenCalledOnce()
|
||||
})
|
||||
const onClick = vi.fn();
|
||||
render(<Button onClick={onClick}>Go</Button>);
|
||||
await userEvent.click(screen.getByRole('button'));
|
||||
expect(onClick).toHaveBeenCalledOnce();
|
||||
});
|
||||
it('is disabled when disabled prop is set', () => {
|
||||
render(<Button disabled>Go</Button>)
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
render(<Button disabled>Go</Button>);
|
||||
expect(screen.getByRole('button')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
describe('className passthrough', () => {
|
||||
it('merges custom className', () => {
|
||||
render(<Button className="w-full">Go</Button>)
|
||||
expect(screen.getByRole('button')).toHaveClass('w-full')
|
||||
})
|
||||
})
|
||||
})
|
||||
render(<Button className="w-full">Go</Button>);
|
||||
expect(screen.getByRole('button')).toHaveClass('w-full');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,40 +1,41 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||
import { cn } from '$shared/lib'
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||
import { cn } from '$shared/lib';
|
||||
|
||||
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost'
|
||||
export type ButtonSize = 'sm' | 'md' | 'lg'
|
||||
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost';
|
||||
export type ButtonSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
/**
|
||||
* Visual variant
|
||||
* @default 'primary'
|
||||
*/
|
||||
variant?: ButtonVariant
|
||||
variant?: ButtonVariant;
|
||||
/**
|
||||
* Size preset
|
||||
* @default 'md'
|
||||
*/
|
||||
size?: ButtonSize
|
||||
size?: ButtonSize;
|
||||
/**
|
||||
* Button content
|
||||
*/
|
||||
children: ReactNode
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const VARIANTS: Record<ButtonVariant, string> = {
|
||||
primary: 'bg-burnt-oxide text-ochre-clay',
|
||||
primary: 'bg-burnt-oxide text-ochre-clay',
|
||||
secondary: 'bg-slate-indigo text-ochre-clay',
|
||||
outline: 'bg-transparent text-carbon-black border-carbon-black',
|
||||
ghost: 'bg-ochre-clay 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',
|
||||
};
|
||||
|
||||
const SIZES: Record<ButtonSize, string> = {
|
||||
sm: 'px-4 py-2 text-sm',
|
||||
md: 'px-6 py-3 text-base',
|
||||
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.
|
||||
@@ -44,5 +45,5 @@ export function Button({ variant = 'primary', size = 'md', className, children,
|
||||
<button className={cn(BASE, VARIANTS[variant], SIZES[size], className)} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './ui/Card'
|
||||
export type { CardBackground } from './ui/Card'
|
||||
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './ui/Card';
|
||||
export type { CardBackground } from './ui/Card';
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card'
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card';
|
||||
|
||||
const meta: Meta<typeof Card> = {
|
||||
title: 'Shared/Card',
|
||||
component: Card,
|
||||
}
|
||||
};
|
||||
|
||||
export default meta
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof Card>
|
||||
type Story = StoryObj<typeof Card>;
|
||||
|
||||
export const AllBackgrounds: Story = {
|
||||
render: () => (
|
||||
@@ -36,19 +36,17 @@ export const AllBackgrounds: Story = {
|
||||
</Card>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
};
|
||||
|
||||
export const NoPadding: Story = {
|
||||
render: () => (
|
||||
<div className="p-8 bg-ochre-clay">
|
||||
<Card noPadding className="w-64 overflow-hidden">
|
||||
<div className="h-40 bg-slate-indigo flex items-center justify-center text-ochre-clay">
|
||||
Image placeholder
|
||||
</div>
|
||||
<div className="h-40 bg-slate-indigo flex items-center justify-center text-ochre-clay">Image placeholder</div>
|
||||
</Card>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
};
|
||||
|
||||
export const FullComposition: Story = {
|
||||
render: () => (
|
||||
@@ -67,4 +65,4 @@ export const FullComposition: Story = {
|
||||
</Card>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,79 +1,79 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card'
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card';
|
||||
|
||||
describe('Card', () => {
|
||||
describe('rendering', () => {
|
||||
it('renders children', () => {
|
||||
render(<Card>Content</Card>)
|
||||
expect(screen.getByText('Content')).toBeInTheDocument()
|
||||
})
|
||||
render(<Card>Content</Card>);
|
||||
expect(screen.getByText('Content')).toBeInTheDocument();
|
||||
});
|
||||
it('has brutal-border and brutal-shadow classes', () => {
|
||||
const { container } = render(<Card>Content</Card>)
|
||||
expect(container.firstChild).toHaveClass('brutal-border', 'brutal-shadow')
|
||||
})
|
||||
})
|
||||
const { container } = render(<Card>Content</Card>);
|
||||
expect(container.firstChild).toHaveClass('brutal-border', 'brutal-shadow');
|
||||
});
|
||||
});
|
||||
describe('background variants', () => {
|
||||
it('defaults to ochre background', () => {
|
||||
const { container } = render(<Card>Content</Card>)
|
||||
expect(container.firstChild).toHaveClass('bg-ochre-clay')
|
||||
})
|
||||
const { container } = render(<Card>Content</Card>);
|
||||
expect(container.firstChild).toHaveClass('bg-ochre-clay');
|
||||
});
|
||||
it('applies slate background', () => {
|
||||
const { container } = render(<Card background="slate">Content</Card>)
|
||||
expect(container.firstChild).toHaveClass('bg-slate-indigo')
|
||||
})
|
||||
const { container } = render(<Card background="slate">Content</Card>);
|
||||
expect(container.firstChild).toHaveClass('bg-slate-indigo');
|
||||
});
|
||||
it('applies white background', () => {
|
||||
const { container } = render(<Card background="white">Content</Card>)
|
||||
expect(container.firstChild).toHaveClass('bg-white')
|
||||
})
|
||||
})
|
||||
const { container } = render(<Card background="white">Content</Card>);
|
||||
expect(container.firstChild).toHaveClass('bg-white');
|
||||
});
|
||||
});
|
||||
describe('padding', () => {
|
||||
it('has default padding', () => {
|
||||
const { container } = render(<Card>Content</Card>)
|
||||
expect(container.firstChild).toHaveClass('p-6')
|
||||
})
|
||||
const { container } = render(<Card>Content</Card>);
|
||||
expect(container.firstChild).toHaveClass('p-6');
|
||||
});
|
||||
it('removes padding when noPadding is true', () => {
|
||||
const { container } = render(<Card noPadding>Content</Card>)
|
||||
expect(container.firstChild).not.toHaveClass('p-6')
|
||||
})
|
||||
})
|
||||
const { container } = render(<Card noPadding>Content</Card>);
|
||||
expect(container.firstChild).not.toHaveClass('p-6');
|
||||
});
|
||||
});
|
||||
describe('className passthrough', () => {
|
||||
it('merges custom className', () => {
|
||||
const { container } = render(<Card className="group">Content</Card>)
|
||||
expect(container.firstChild).toHaveClass('group')
|
||||
})
|
||||
})
|
||||
})
|
||||
const { container } = render(<Card className="group">Content</Card>);
|
||||
expect(container.firstChild).toHaveClass('group');
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('CardHeader', () => {
|
||||
it('renders children with bottom margin', () => {
|
||||
render(<CardHeader>Header</CardHeader>)
|
||||
expect(screen.getByText('Header')).toHaveClass('mb-4')
|
||||
})
|
||||
})
|
||||
render(<CardHeader>Header</CardHeader>);
|
||||
expect(screen.getByText('Header')).toHaveClass('mb-4');
|
||||
});
|
||||
});
|
||||
describe('CardTitle', () => {
|
||||
it('renders children as h3', () => {
|
||||
render(<CardTitle>Title</CardTitle>)
|
||||
expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('Title')
|
||||
})
|
||||
})
|
||||
render(<CardTitle>Title</CardTitle>);
|
||||
expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('Title');
|
||||
});
|
||||
});
|
||||
describe('CardDescription', () => {
|
||||
it('renders children as paragraph with opacity', () => {
|
||||
render(<CardDescription>Desc</CardDescription>)
|
||||
const el = screen.getByText('Desc')
|
||||
expect(el.tagName).toBe('P')
|
||||
expect(el).toHaveClass('opacity-80')
|
||||
})
|
||||
})
|
||||
render(<CardDescription>Desc</CardDescription>);
|
||||
const el = screen.getByText('Desc');
|
||||
expect(el.tagName).toBe('P');
|
||||
expect(el).toHaveClass('opacity-80');
|
||||
});
|
||||
});
|
||||
describe('CardContent', () => {
|
||||
it('renders children in a div', () => {
|
||||
render(<CardContent>Body</CardContent>)
|
||||
expect(screen.getByText('Body')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
render(<CardContent>Body</CardContent>);
|
||||
expect(screen.getByText('Body')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
describe('CardFooter', () => {
|
||||
it('renders children with top border', () => {
|
||||
render(<CardFooter>Footer</CardFooter>)
|
||||
const el = screen.getByText('Footer')
|
||||
expect(el).toHaveClass('brutal-border-top', 'mt-6', 'pt-6')
|
||||
})
|
||||
})
|
||||
render(<CardFooter>Footer</CardFooter>);
|
||||
const el = screen.getByText('Footer');
|
||||
expect(el).toHaveClass('brutal-border-top', 'mt-6', 'pt-6');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '$shared/lib'
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '$shared/lib';
|
||||
|
||||
export type CardBackground = 'ochre' | 'slate' | 'white'
|
||||
export type CardBackground = 'ochre' | 'slate' | 'white';
|
||||
|
||||
interface CardProps {
|
||||
/**
|
||||
* Card content
|
||||
*/
|
||||
children: ReactNode
|
||||
children: ReactNode;
|
||||
/**
|
||||
* Additional CSS classes
|
||||
*/
|
||||
className?: string
|
||||
className?: string;
|
||||
/**
|
||||
* Background color preset
|
||||
* @default 'ochre'
|
||||
*/
|
||||
background?: CardBackground
|
||||
background?: CardBackground;
|
||||
/**
|
||||
* Remove default padding
|
||||
* @default false
|
||||
*/
|
||||
noPadding?: boolean
|
||||
noPadding?: boolean;
|
||||
}
|
||||
|
||||
const BG: Record<CardBackground, string> = {
|
||||
ochre: 'bg-ochre-clay',
|
||||
slate: 'bg-slate-indigo text-ochre-clay',
|
||||
white: 'bg-white',
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
interface SlotProps {
|
||||
/**
|
||||
* Slot content
|
||||
*/
|
||||
children: ReactNode
|
||||
children: ReactNode;
|
||||
/**
|
||||
* Additional CSS classes
|
||||
*/
|
||||
className?: string
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Card header wrapper — adds bottom margin.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
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 { Input, Textarea } from './Input'
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||
import { Input, Textarea } from './Input';
|
||||
|
||||
const meta: Meta<typeof Input> = {
|
||||
title: 'Shared/Input',
|
||||
@@ -11,35 +11,35 @@ const meta: Meta<typeof Input> = {
|
||||
</div>
|
||||
),
|
||||
],
|
||||
}
|
||||
};
|
||||
|
||||
export default meta
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof Input>
|
||||
type Story = StoryObj<typeof Input>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {},
|
||||
}
|
||||
};
|
||||
|
||||
export const WithLabel: Story = {
|
||||
args: {
|
||||
label: 'Email address',
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
label: 'Email',
|
||||
error: 'This field is required',
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export const WithPlaceholder: Story = {
|
||||
args: {
|
||||
placeholder: 'Enter your email',
|
||||
type: 'email',
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export const TextareaStory: Story = {
|
||||
name: 'Textarea',
|
||||
@@ -48,7 +48,7 @@ export const TextareaStory: Story = {
|
||||
<Textarea label="Message" rows={4} />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
};
|
||||
|
||||
export const TextareaWithError: Story = {
|
||||
render: () => (
|
||||
@@ -56,4 +56,4 @@ export const TextareaWithError: Story = {
|
||||
<Textarea label="Message" error="Too short" rows={4} />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,110 +1,110 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { Input, Textarea } from './Input'
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { Input, Textarea } from './Input';
|
||||
|
||||
describe('Input', () => {
|
||||
describe('rendering', () => {
|
||||
it('renders an input element', () => {
|
||||
render(<Input />)
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
render(<Input />);
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||
});
|
||||
it('renders label when provided', () => {
|
||||
render(<Input label="Email" />)
|
||||
expect(screen.getByText('Email')).toBeInTheDocument()
|
||||
})
|
||||
render(<Input label="Email" />);
|
||||
expect(screen.getByText('Email')).toBeInTheDocument();
|
||||
});
|
||||
it('does not render label when omitted', () => {
|
||||
const { container } = render(<Input />)
|
||||
expect(container.querySelector('label')).toBeNull()
|
||||
})
|
||||
const { container } = render(<Input />);
|
||||
expect(container.querySelector('label')).toBeNull();
|
||||
});
|
||||
it('renders error message when provided', () => {
|
||||
render(<Input error="Required" />)
|
||||
expect(screen.getByText('Required')).toBeInTheDocument()
|
||||
})
|
||||
render(<Input error="Required" />);
|
||||
expect(screen.getByText('Required')).toBeInTheDocument();
|
||||
});
|
||||
it('does not render error when omitted', () => {
|
||||
render(<Input />)
|
||||
expect(screen.queryByText('Required')).toBeNull()
|
||||
})
|
||||
})
|
||||
render(<Input />);
|
||||
expect(screen.queryByText('Required')).toBeNull();
|
||||
});
|
||||
});
|
||||
describe('accessibility', () => {
|
||||
it('label is associated with input via htmlFor/id', () => {
|
||||
render(<Input label="Email" />)
|
||||
expect(screen.getByLabelText('Email')).toBeInTheDocument()
|
||||
})
|
||||
render(<Input label="Email" />);
|
||||
expect(screen.getByLabelText('Email')).toBeInTheDocument();
|
||||
});
|
||||
it('error span is referenced by aria-describedby', () => {
|
||||
render(<Input error="Required" />)
|
||||
const input = screen.getByRole('textbox')
|
||||
const errorId = input.getAttribute('aria-describedby')
|
||||
expect(errorId).toBeTruthy()
|
||||
expect(document.getElementById(errorId!)).toHaveTextContent('Required')
|
||||
})
|
||||
render(<Input error="Required" />);
|
||||
const input = screen.getByRole('textbox');
|
||||
const errorId = input.getAttribute('aria-describedby');
|
||||
expect(errorId).toBeTruthy();
|
||||
expect(document.getElementById(errorId!)).toHaveTextContent('Required');
|
||||
});
|
||||
it('no aria-describedby when no error', () => {
|
||||
render(<Input />)
|
||||
expect(screen.getByRole('textbox')).not.toHaveAttribute('aria-describedby')
|
||||
})
|
||||
render(<Input />);
|
||||
expect(screen.getByRole('textbox')).not.toHaveAttribute('aria-describedby');
|
||||
});
|
||||
it('uses provided id prop', () => {
|
||||
render(<Input id="my-input" label="Email" />)
|
||||
expect(screen.getByLabelText('Email')).toHaveAttribute('id', 'my-input')
|
||||
})
|
||||
})
|
||||
render(<Input id="my-input" label="Email" />);
|
||||
expect(screen.getByLabelText('Email')).toHaveAttribute('id', 'my-input');
|
||||
});
|
||||
});
|
||||
describe('styling', () => {
|
||||
it('has brutal-border class', () => {
|
||||
render(<Input />)
|
||||
expect(screen.getByRole('textbox')).toHaveClass('brutal-border')
|
||||
})
|
||||
render(<Input />);
|
||||
expect(screen.getByRole('textbox')).toHaveClass('brutal-border');
|
||||
});
|
||||
it('applies custom className', () => {
|
||||
render(<Input className="w-full" />)
|
||||
expect(screen.getByRole('textbox')).toHaveClass('w-full')
|
||||
})
|
||||
})
|
||||
render(<Input className="w-full" />);
|
||||
expect(screen.getByRole('textbox')).toHaveClass('w-full');
|
||||
});
|
||||
});
|
||||
describe('forwarded props', () => {
|
||||
it('passes placeholder to input', () => {
|
||||
render(<Input placeholder="Enter email" />)
|
||||
expect(screen.getByPlaceholderText('Enter email')).toBeInTheDocument()
|
||||
})
|
||||
render(<Input placeholder="Enter email" />);
|
||||
expect(screen.getByPlaceholderText('Enter email')).toBeInTheDocument();
|
||||
});
|
||||
it('passes type to input', () => {
|
||||
render(<Input type="email" />)
|
||||
expect(screen.getByRole('textbox')).toHaveAttribute('type', 'email')
|
||||
})
|
||||
})
|
||||
})
|
||||
render(<Input type="email" />);
|
||||
expect(screen.getByRole('textbox')).toHaveAttribute('type', 'email');
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('Textarea', () => {
|
||||
describe('rendering', () => {
|
||||
it('renders a textarea element', () => {
|
||||
render(<Textarea />)
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
render(<Textarea />);
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||
});
|
||||
it('renders label when provided', () => {
|
||||
render(<Textarea label="Message" />)
|
||||
expect(screen.getByText('Message')).toBeInTheDocument()
|
||||
})
|
||||
render(<Textarea label="Message" />);
|
||||
expect(screen.getByText('Message')).toBeInTheDocument();
|
||||
});
|
||||
it('renders error when provided', () => {
|
||||
render(<Textarea error="Too short" />)
|
||||
expect(screen.getByText('Too short')).toBeInTheDocument()
|
||||
})
|
||||
render(<Textarea error="Too short" />);
|
||||
expect(screen.getByText('Too short')).toBeInTheDocument();
|
||||
});
|
||||
it('defaults to 4 rows', () => {
|
||||
render(<Textarea />)
|
||||
expect(screen.getByRole('textbox')).toHaveAttribute('rows', '4')
|
||||
})
|
||||
render(<Textarea />);
|
||||
expect(screen.getByRole('textbox')).toHaveAttribute('rows', '4');
|
||||
});
|
||||
it('accepts custom rows', () => {
|
||||
render(<Textarea rows={8} />)
|
||||
expect(screen.getByRole('textbox')).toHaveAttribute('rows', '8')
|
||||
})
|
||||
})
|
||||
render(<Textarea rows={8} />);
|
||||
expect(screen.getByRole('textbox')).toHaveAttribute('rows', '8');
|
||||
});
|
||||
});
|
||||
describe('accessibility', () => {
|
||||
it('label is associated with textarea via htmlFor/id', () => {
|
||||
render(<Textarea label="Message" />)
|
||||
expect(screen.getByLabelText('Message')).toBeInTheDocument()
|
||||
})
|
||||
render(<Textarea label="Message" />);
|
||||
expect(screen.getByLabelText('Message')).toBeInTheDocument();
|
||||
});
|
||||
it('error span is referenced by aria-describedby', () => {
|
||||
render(<Textarea error="Too short" />)
|
||||
const textarea = screen.getByRole('textbox')
|
||||
const errorId = textarea.getAttribute('aria-describedby')
|
||||
expect(errorId).toBeTruthy()
|
||||
expect(document.getElementById(errorId!)).toHaveTextContent('Too short')
|
||||
})
|
||||
render(<Textarea error="Too short" />);
|
||||
const textarea = screen.getByRole('textbox');
|
||||
const errorId = textarea.getAttribute('aria-describedby');
|
||||
expect(errorId).toBeTruthy();
|
||||
expect(document.getElementById(errorId!)).toHaveTextContent('Too short');
|
||||
});
|
||||
it('no aria-describedby when no error', () => {
|
||||
render(<Textarea />)
|
||||
expect(screen.getByRole('textbox')).not.toHaveAttribute('aria-describedby')
|
||||
})
|
||||
})
|
||||
})
|
||||
render(<Textarea />);
|
||||
expect(screen.getByRole('textbox')).not.toHaveAttribute('aria-describedby');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,68 +1,81 @@
|
||||
import { useId, type InputHTMLAttributes, type TextareaHTMLAttributes } from 'react'
|
||||
import { cn } from '$shared/lib'
|
||||
import { useId, type InputHTMLAttributes, type TextareaHTMLAttributes } from 'react';
|
||||
import { cn } from '$shared/lib';
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
/**
|
||||
* Visible label rendered above the input
|
||||
*/
|
||||
label?: string
|
||||
label?: string;
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function Input({ label, error, className, id, ...props }: InputProps) {
|
||||
const generatedId = useId()
|
||||
const inputId = id ?? generatedId
|
||||
const errorId = `${inputId}-error`
|
||||
const generatedId = useId();
|
||||
const inputId = id ?? generatedId;
|
||||
const errorId = `${inputId}-error`;
|
||||
|
||||
return (
|
||||
<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
|
||||
id={inputId}
|
||||
className={cn(INPUT_BASE, className)}
|
||||
aria-describedby={error ? errorId : undefined}
|
||||
{...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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
/**
|
||||
* Visible label rendered above the textarea
|
||||
*/
|
||||
label?: string
|
||||
label?: string;
|
||||
/**
|
||||
* Validation error shown below the textarea
|
||||
*/
|
||||
error?: string
|
||||
error?: string;
|
||||
/**
|
||||
* Number of visible rows
|
||||
* @default 4
|
||||
*/
|
||||
rows?: number
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiline textarea with optional label and error state.
|
||||
*/
|
||||
export function Textarea({ label, error, rows = 4, className, id, ...props }: TextareaProps) {
|
||||
const generatedId = useId()
|
||||
const textareaId = id ?? generatedId
|
||||
const errorId = `${textareaId}-error`
|
||||
const generatedId = useId();
|
||||
const textareaId = id ?? generatedId;
|
||||
const errorId = `${textareaId}-error`;
|
||||
|
||||
return (
|
||||
<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
|
||||
id={textareaId}
|
||||
rows={rows}
|
||||
@@ -70,7 +83,11 @@ export function Textarea({ label, error, rows = 4, className, id, ...props }: Te
|
||||
aria-describedby={error ? errorId : undefined}
|
||||
{...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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { Section, Container } from './ui/Section'
|
||||
export type { SectionBackground, ContainerSize } from './ui/Section'
|
||||
export { Section, Container } from './ui/Section';
|
||||
export type { SectionBackground, ContainerSize } from './ui/Section';
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import { Section, Container } from './Section'
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||
import { Section, Container } from './Section';
|
||||
|
||||
const meta: Meta<typeof Section> = {
|
||||
title: 'Shared/Section',
|
||||
component: Section,
|
||||
}
|
||||
};
|
||||
|
||||
export default meta
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof Section>
|
||||
type Story = StoryObj<typeof Section>;
|
||||
|
||||
export const AllBackgrounds: Story = {
|
||||
render: () => (
|
||||
@@ -16,32 +16,44 @@ export const AllBackgrounds: Story = {
|
||||
<Section background="ochre" className="py-12">
|
||||
<Container>
|
||||
<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>
|
||||
</Section>
|
||||
<Section background="slate" className="py-12">
|
||||
<Container>
|
||||
<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>
|
||||
</Section>
|
||||
<Section background="white" className="py-12">
|
||||
<Container>
|
||||
<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>
|
||||
</Section>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
};
|
||||
|
||||
export const Bordered: Story = {
|
||||
render: () => (
|
||||
<Section background="ochre" bordered className="py-12">
|
||||
<Container>
|
||||
<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>
|
||||
</Section>
|
||||
),
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,95 +1,103 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { Section, Container } from './Section'
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { Section, Container } from './Section';
|
||||
|
||||
describe('Section', () => {
|
||||
describe('rendering', () => {
|
||||
it('renders a section element', () => {
|
||||
const { container } = render(<Section>content</Section>)
|
||||
expect(container.querySelector('section')).toBeInTheDocument()
|
||||
})
|
||||
const { container } = render(<Section>content</Section>);
|
||||
expect(container.querySelector('section')).toBeInTheDocument();
|
||||
});
|
||||
it('renders children', () => {
|
||||
render(<Section><span>hello</span></Section>)
|
||||
expect(screen.getByText('hello')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
render(
|
||||
<Section>
|
||||
<span>hello</span>
|
||||
</Section>,
|
||||
);
|
||||
expect(screen.getByText('hello')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('background variants', () => {
|
||||
it('defaults to ochre background', () => {
|
||||
const { container } = render(<Section>x</Section>)
|
||||
expect(container.querySelector('section')).toHaveClass('bg-ochre-clay', 'text-carbon-black')
|
||||
})
|
||||
const { container } = render(<Section>x</Section>);
|
||||
expect(container.querySelector('section')).toHaveClass('bg-ochre-clay', 'text-carbon-black');
|
||||
});
|
||||
it('applies slate background', () => {
|
||||
const { container } = render(<Section background="slate">x</Section>)
|
||||
expect(container.querySelector('section')).toHaveClass('bg-slate-indigo', 'text-ochre-clay')
|
||||
})
|
||||
const { container } = render(<Section background="slate">x</Section>);
|
||||
expect(container.querySelector('section')).toHaveClass('bg-slate-indigo', 'text-ochre-clay');
|
||||
});
|
||||
it('applies white background', () => {
|
||||
const { container } = render(<Section background="white">x</Section>)
|
||||
expect(container.querySelector('section')).toHaveClass('bg-white', 'text-carbon-black')
|
||||
})
|
||||
})
|
||||
const { container } = render(<Section background="white">x</Section>);
|
||||
expect(container.querySelector('section')).toHaveClass('bg-white', 'text-carbon-black');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bordered', () => {
|
||||
it('no border classes by default', () => {
|
||||
const { container } = render(<Section>x</Section>)
|
||||
const el = container.querySelector('section')!
|
||||
expect(el).not.toHaveClass('brutal-border-top')
|
||||
expect(el).not.toHaveClass('brutal-border-bottom')
|
||||
})
|
||||
const { container } = render(<Section>x</Section>);
|
||||
const el = container.querySelector('section')!;
|
||||
expect(el).not.toHaveClass('brutal-border-top');
|
||||
expect(el).not.toHaveClass('brutal-border-bottom');
|
||||
});
|
||||
it('adds top and bottom borders when bordered=true', () => {
|
||||
const { container } = render(<Section bordered>x</Section>)
|
||||
const el = container.querySelector('section')!
|
||||
expect(el).toHaveClass('brutal-border-top')
|
||||
expect(el).toHaveClass('brutal-border-bottom')
|
||||
})
|
||||
})
|
||||
const { container } = render(<Section bordered>x</Section>);
|
||||
const el = container.querySelector('section')!;
|
||||
expect(el).toHaveClass('brutal-border-top');
|
||||
expect(el).toHaveClass('brutal-border-bottom');
|
||||
});
|
||||
});
|
||||
|
||||
describe('className', () => {
|
||||
it('applies custom className', () => {
|
||||
const { container } = render(<Section className="py-16">x</Section>)
|
||||
expect(container.querySelector('section')).toHaveClass('py-16')
|
||||
})
|
||||
})
|
||||
})
|
||||
const { container } = render(<Section className="py-16">x</Section>);
|
||||
expect(container.querySelector('section')).toHaveClass('py-16');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Container', () => {
|
||||
describe('rendering', () => {
|
||||
it('renders a div with children', () => {
|
||||
render(<Container><span>inner</span></Container>)
|
||||
expect(screen.getByText('inner')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
render(
|
||||
<Container>
|
||||
<span>inner</span>
|
||||
</Container>,
|
||||
);
|
||||
expect(screen.getByText('inner')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('size variants', () => {
|
||||
it('defaults to max-w-7xl', () => {
|
||||
const { container } = render(<Container>x</Container>)
|
||||
expect(container.firstChild).toHaveClass('max-w-7xl')
|
||||
})
|
||||
const { container } = render(<Container>x</Container>);
|
||||
expect(container.firstChild).toHaveClass('max-w-7xl');
|
||||
});
|
||||
it('wide applies max-w-[1920px]', () => {
|
||||
const { container } = render(<Container size="wide">x</Container>)
|
||||
expect(container.firstChild).toHaveClass('max-w-[1920px]')
|
||||
})
|
||||
const { container } = render(<Container size="wide">x</Container>);
|
||||
expect(container.firstChild).toHaveClass('max-w-[1920px]');
|
||||
});
|
||||
it('ultra-wide applies max-w-[2560px]', () => {
|
||||
const { container } = render(<Container size="ultra-wide">x</Container>)
|
||||
expect(container.firstChild).toHaveClass('max-w-[2560px]')
|
||||
})
|
||||
})
|
||||
const { container } = render(<Container size="ultra-wide">x</Container>);
|
||||
expect(container.firstChild).toHaveClass('max-w-[2560px]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('layout', () => {
|
||||
it('centers content horizontally', () => {
|
||||
const { container } = render(<Container>x</Container>)
|
||||
expect(container.firstChild).toHaveClass('mx-auto')
|
||||
})
|
||||
const { container } = render(<Container>x</Container>);
|
||||
expect(container.firstChild).toHaveClass('mx-auto');
|
||||
});
|
||||
it('applies horizontal padding', () => {
|
||||
const { container } = render(<Container>x</Container>)
|
||||
expect(container.firstChild).toHaveClass('px-6')
|
||||
})
|
||||
})
|
||||
const { container } = render(<Container>x</Container>);
|
||||
expect(container.firstChild).toHaveClass('px-6');
|
||||
});
|
||||
});
|
||||
|
||||
describe('className', () => {
|
||||
it('applies custom className', () => {
|
||||
const { container } = render(<Container className="my-custom">x</Container>)
|
||||
expect(container.firstChild).toHaveClass('my-custom')
|
||||
})
|
||||
})
|
||||
})
|
||||
const { container } = render(<Container className="my-custom">x</Container>);
|
||||
expect(container.firstChild).toHaveClass('my-custom');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,82 +1,72 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '$shared/lib'
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '$shared/lib';
|
||||
|
||||
export type SectionBackground = 'ochre' | 'slate' | 'white'
|
||||
export type ContainerSize = 'default' | 'wide' | 'ultra-wide'
|
||||
export type SectionBackground = 'ochre' | 'slate' | 'white';
|
||||
export type ContainerSize = 'default' | 'wide' | 'ultra-wide';
|
||||
|
||||
interface SectionProps {
|
||||
/**
|
||||
* Section content
|
||||
*/
|
||||
children: ReactNode
|
||||
children: ReactNode;
|
||||
/**
|
||||
* Background color variant
|
||||
* @default 'ochre'
|
||||
*/
|
||||
background?: SectionBackground
|
||||
background?: SectionBackground;
|
||||
/**
|
||||
* Adds top and bottom brutal borders
|
||||
* @default false
|
||||
*/
|
||||
bordered?: boolean
|
||||
bordered?: boolean;
|
||||
/**
|
||||
* CSS classes
|
||||
*/
|
||||
className?: string
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const BACKGROUNDS: Record<SectionBackground, string> = {
|
||||
ochre: 'bg-ochre-clay text-carbon-black',
|
||||
slate: 'bg-slate-indigo text-ochre-clay',
|
||||
white: 'bg-white text-carbon-black',
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Full-width page section with background and optional borders.
|
||||
*/
|
||||
export function Section({ children, background = 'ochre', bordered = false, className }: SectionProps) {
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
BACKGROUNDS[background],
|
||||
bordered && 'brutal-border-top brutal-border-bottom',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<section className={cn(BACKGROUNDS[background], bordered && 'brutal-border-top brutal-border-bottom', className)}>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
interface ContainerProps {
|
||||
/**
|
||||
* Container content
|
||||
*/
|
||||
children: ReactNode
|
||||
children: ReactNode;
|
||||
/**
|
||||
* Max-width constraint
|
||||
* @default 'default'
|
||||
*/
|
||||
size?: ContainerSize
|
||||
size?: ContainerSize;
|
||||
/**
|
||||
* CSS classes
|
||||
*/
|
||||
className?: string
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const SIZES: Record<ContainerSize, string> = {
|
||||
'default': 'max-w-7xl',
|
||||
'wide': 'max-w-[1920px]',
|
||||
default: 'max-w-7xl',
|
||||
wide: 'max-w-[1920px]',
|
||||
'ultra-wide': 'max-w-[2560px]',
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Centered content container with responsive horizontal padding.
|
||||
*/
|
||||
export function Container({ children, size = 'default', className }: ContainerProps) {
|
||||
return (
|
||||
<div className={cn(SIZES[size], 'mx-auto px-6 md:px-12 lg:px-16', className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
return <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,44 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import { SectionAccordion } from './SectionAccordion'
|
||||
|
||||
const meta: Meta<typeof SectionAccordion> = {
|
||||
title: 'Shared/SectionAccordion',
|
||||
component: SectionAccordion,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="p-8 bg-ochre-clay">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof SectionAccordion>
|
||||
|
||||
export const Active: Story = {
|
||||
args: {
|
||||
number: '01',
|
||||
title: 'Biography',
|
||||
id: 'bio',
|
||||
isActive: true,
|
||||
onClick: () => {},
|
||||
children: (
|
||||
<p>This is the expanded section content. It is visible because isActive is true.</p>
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
export const Collapsed: Story = {
|
||||
args: {
|
||||
number: '02',
|
||||
title: 'Work',
|
||||
id: 'work',
|
||||
isActive: false,
|
||||
onClick: () => console.log('section clicked'),
|
||||
children: (
|
||||
<p>This content is hidden in collapsed state.</p>
|
||||
),
|
||||
},
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { SectionAccordion } from './SectionAccordion'
|
||||
|
||||
const defaultProps = {
|
||||
number: '01',
|
||||
title: 'About',
|
||||
id: 'about',
|
||||
isActive: false,
|
||||
onClick: vi.fn(),
|
||||
children: <p>Content here</p>,
|
||||
}
|
||||
|
||||
describe('SectionAccordion', () => {
|
||||
describe('collapsed state (isActive=false)', () => {
|
||||
it('renders a section element with the given id', () => {
|
||||
const { container } = render(<SectionAccordion {...defaultProps} />)
|
||||
expect(container.querySelector('section#about')).toBeInTheDocument()
|
||||
})
|
||||
it('renders a button with number and title', () => {
|
||||
render(<SectionAccordion {...defaultProps} />)
|
||||
expect(screen.getByRole('button', { name: /01.*About/i })).toBeInTheDocument()
|
||||
})
|
||||
it('does not render children', () => {
|
||||
render(<SectionAccordion {...defaultProps} />)
|
||||
expect(screen.queryByText('Content here')).not.toBeInTheDocument()
|
||||
})
|
||||
it('calls onClick when button is clicked', async () => {
|
||||
const onClick = vi.fn()
|
||||
render(<SectionAccordion {...defaultProps} onClick={onClick} />)
|
||||
await userEvent.click(screen.getByRole('button'))
|
||||
expect(onClick).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('active state (isActive=true)', () => {
|
||||
const activeProps = { ...defaultProps, isActive: true }
|
||||
|
||||
it('renders an h1 with number and title', () => {
|
||||
render(<SectionAccordion {...activeProps} />)
|
||||
expect(screen.getByRole('heading', { level: 1, name: /01.*About/i })).toBeInTheDocument()
|
||||
})
|
||||
it('renders children', () => {
|
||||
render(<SectionAccordion {...activeProps} />)
|
||||
expect(screen.getByText('Content here')).toBeInTheDocument()
|
||||
})
|
||||
it('does not render a button', () => {
|
||||
render(<SectionAccordion {...activeProps} />)
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
it('content wrapper has animate-fadeIn class', () => {
|
||||
const { container } = render(<SectionAccordion {...activeProps} />)
|
||||
expect(container.querySelector('.animate-fadeIn')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,65 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface SectionAccordionProps {
|
||||
/**
|
||||
* Display number prefix (e.g. "01")
|
||||
*/
|
||||
number: string
|
||||
/**
|
||||
* Section title
|
||||
*/
|
||||
title: string
|
||||
/**
|
||||
* HTML id for anchor navigation
|
||||
*/
|
||||
id: string
|
||||
/**
|
||||
* Whether this section is expanded
|
||||
*/
|
||||
isActive: boolean
|
||||
/**
|
||||
* Called when the collapsed header is clicked
|
||||
*/
|
||||
onClick: () => void
|
||||
/**
|
||||
* Section content, shown when active
|
||||
*/
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Accordion-style section that collapses to a heading button when inactive.
|
||||
*/
|
||||
export function SectionAccordion({ number, title, id, isActive, onClick, children }: SectionAccordionProps) {
|
||||
return (
|
||||
<section id={id} className="scroll-mt-8">
|
||||
{isActive ? (
|
||||
<div className="mb-12">
|
||||
<div className="mb-16">
|
||||
<h1
|
||||
className="font-heading font-black text-5xl leading-[1.2] mb-0"
|
||||
style={{ fontVariationSettings: "'WONK' 1, 'SOFT' 0" }}
|
||||
>
|
||||
{number}. {title}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="animate-fadeIn">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="w-full text-left mb-3 py-3 transition-all duration-200 hover:opacity-60 group"
|
||||
>
|
||||
<h2
|
||||
className="font-heading font-black text-2xl sm:text-3xl opacity-30 group-hover:opacity-50 transition-opacity"
|
||||
style={{ fontVariationSettings: "'WONK' 1, 'SOFT' 0" }}
|
||||
>
|
||||
{number}. {title}
|
||||
</h2>
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -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 { TechStackGrid, TechStackBrick } from './TechStack'
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||
import { TechStackGrid, TechStackBrick } from './TechStack';
|
||||
|
||||
const meta: Meta<typeof TechStackGrid> = {
|
||||
title: 'Shared/TechStack',
|
||||
@@ -11,11 +11,11 @@ const meta: Meta<typeof TechStackGrid> = {
|
||||
</div>
|
||||
),
|
||||
],
|
||||
}
|
||||
};
|
||||
|
||||
export default meta
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof TechStackGrid>
|
||||
type Story = StoryObj<typeof TechStackGrid>;
|
||||
|
||||
export const Grid: Story = {
|
||||
args: {
|
||||
@@ -34,7 +34,7 @@ export const Grid: Story = {
|
||||
'Rust',
|
||||
],
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export const SingleBrick: Story = {
|
||||
render: () => (
|
||||
@@ -42,4 +42,4 @@ export const SingleBrick: Story = {
|
||||
<TechStackBrick name="TypeScript" />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,62 +1,62 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { TechStackBrick, TechStackGrid } from './TechStack'
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { TechStackBrick, TechStackGrid } from './TechStack';
|
||||
|
||||
describe('TechStackBrick', () => {
|
||||
describe('rendering', () => {
|
||||
it('renders the technology name', () => {
|
||||
render(<TechStackBrick name="TypeScript" />)
|
||||
expect(screen.getByText('TypeScript')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
render(<TechStackBrick name="TypeScript" />);
|
||||
expect(screen.getByText('TypeScript')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('styling', () => {
|
||||
it('has brutal-border class', () => {
|
||||
const { container } = render(<TechStackBrick name="React" />)
|
||||
expect(container.firstChild).toHaveClass('brutal-border')
|
||||
})
|
||||
const { container } = render(<TechStackBrick name="React" />);
|
||||
expect(container.firstChild).toHaveClass('brutal-border');
|
||||
});
|
||||
it('has brutal-shadow class', () => {
|
||||
const { container } = render(<TechStackBrick name="React" />)
|
||||
expect(container.firstChild).toHaveClass('brutal-shadow')
|
||||
})
|
||||
const { container } = render(<TechStackBrick name="React" />);
|
||||
expect(container.firstChild).toHaveClass('brutal-shadow');
|
||||
});
|
||||
it('name span has uppercase and tracking-wide', () => {
|
||||
render(<TechStackBrick name="Go" />)
|
||||
const span = screen.getByText('Go')
|
||||
expect(span).toHaveClass('uppercase', 'tracking-wide')
|
||||
})
|
||||
render(<TechStackBrick name="Go" />);
|
||||
const span = screen.getByText('Go');
|
||||
expect(span).toHaveClass('uppercase', 'tracking-wide');
|
||||
});
|
||||
it('applies custom className', () => {
|
||||
const { container } = render(<TechStackBrick name="Go" className="w-full" />)
|
||||
expect(container.firstChild).toHaveClass('w-full')
|
||||
})
|
||||
})
|
||||
})
|
||||
const { container } = render(<TechStackBrick name="Go" className="w-full" />);
|
||||
expect(container.firstChild).toHaveClass('w-full');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('TechStackGrid', () => {
|
||||
describe('rendering', () => {
|
||||
it('renders all skill names', () => {
|
||||
render(<TechStackGrid skills={['React', 'TypeScript', 'Go']} />)
|
||||
expect(screen.getByText('React')).toBeInTheDocument()
|
||||
expect(screen.getByText('TypeScript')).toBeInTheDocument()
|
||||
expect(screen.getByText('Go')).toBeInTheDocument()
|
||||
})
|
||||
render(<TechStackGrid skills={['React', 'TypeScript', 'Go']} />);
|
||||
expect(screen.getByText('React')).toBeInTheDocument();
|
||||
expect(screen.getByText('TypeScript')).toBeInTheDocument();
|
||||
expect(screen.getByText('Go')).toBeInTheDocument();
|
||||
});
|
||||
it('renders correct number of bricks', () => {
|
||||
const { container } = render(<TechStackGrid skills={['A', 'B', 'C']} />)
|
||||
expect(container.firstChild!.childNodes).toHaveLength(3)
|
||||
})
|
||||
const { container } = render(<TechStackGrid skills={['A', 'B', 'C']} />);
|
||||
expect(container.firstChild!.childNodes).toHaveLength(3);
|
||||
});
|
||||
it('renders empty grid with no skills', () => {
|
||||
const { container } = render(<TechStackGrid skills={[]} />)
|
||||
expect(container.firstChild!.childNodes).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
const { container } = render(<TechStackGrid skills={[]} />);
|
||||
expect(container.firstChild!.childNodes).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('layout', () => {
|
||||
it('has grid class', () => {
|
||||
const { container } = render(<TechStackGrid skills={['A']} />)
|
||||
expect(container.firstChild).toHaveClass('grid')
|
||||
})
|
||||
const { container } = render(<TechStackGrid skills={['A']} />);
|
||||
expect(container.firstChild).toHaveClass('grid');
|
||||
});
|
||||
it('applies custom className', () => {
|
||||
const { container } = render(<TechStackGrid skills={[]} className="my-custom" />)
|
||||
expect(container.firstChild).toHaveClass('my-custom')
|
||||
})
|
||||
})
|
||||
})
|
||||
const { container } = render(<TechStackGrid skills={[]} className="my-custom" />);
|
||||
expect(container.firstChild).toHaveClass('my-custom');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { cn } from '$shared/lib'
|
||||
import { cn } from '$shared/lib';
|
||||
|
||||
interface TechStackBrickProps {
|
||||
/**
|
||||
* Technology name displayed in the brick
|
||||
*/
|
||||
name: string
|
||||
name: string;
|
||||
/**
|
||||
* 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>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
interface TechStackGridProps {
|
||||
/**
|
||||
* List of technology names to render as bricks
|
||||
*/
|
||||
skills: string[]
|
||||
skills: string[];
|
||||
/**
|
||||
* CSS classes
|
||||
*/
|
||||
className?: string
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,14 +45,11 @@ interface TechStackGridProps {
|
||||
export function TechStackGrid({ skills, className }: TechStackGridProps) {
|
||||
return (
|
||||
<div
|
||||
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,
|
||||
)}
|
||||
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)}
|
||||
>
|
||||
{skills.map((skill, index) => (
|
||||
<TechStackBrick key={index} name={skill} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
+11
-11
@@ -1,17 +1,17 @@
|
||||
export { Badge } from './Badge'
|
||||
export type { BadgeVariant } from './Badge'
|
||||
export { Badge } from './Badge';
|
||||
export type { BadgeVariant } from './Badge';
|
||||
|
||||
export { Button } from './Button'
|
||||
export type { ButtonVariant, ButtonSize } from './Button'
|
||||
export { Button } from './Button';
|
||||
export type { ButtonVariant, ButtonSize } from './Button';
|
||||
|
||||
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card'
|
||||
export type { CardBackground } from './Card'
|
||||
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card';
|
||||
export type { CardBackground } from './Card';
|
||||
|
||||
export { Input, Textarea } from './Input'
|
||||
export { Input, Textarea } from './Input';
|
||||
|
||||
export { Section, Container } from './Section'
|
||||
export type { SectionBackground, ContainerSize } from './Section'
|
||||
export { Section, Container } from './Section';
|
||||
export type { SectionBackground, ContainerSize } from './Section';
|
||||
|
||||
export { SectionAccordion } from './SectionAccordion'
|
||||
export { SectionAccordion } from './SectionAccordion';
|
||||
|
||||
export { TechStackBrick, TechStackGrid } from './TechStack'
|
||||
export { TechStackBrick, TechStackGrid } from './TechStack';
|
||||
|
||||
Reference in New Issue
Block a user