chore: format codebase and move SectionAccordion to entities/Section

This commit is contained in:
Ilia Mashkov
2026-04-23 20:52:43 +03:00
parent 8aff27f8ac
commit 1d333fd945
73 changed files with 1201 additions and 1153 deletions
+2 -2
View File
@@ -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';
+6 -6
View File
@@ -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>
),
}
};
+32 -32
View File
@@ -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');
});
});
});
+11 -11
View File
@@ -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>
)
);
}
+2 -2
View File
@@ -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';
+29 -15
View File
@@ -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>
),
],
}
};
+48 -48
View File
@@ -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');
});
});
});
+15 -14
View File
@@ -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>
)
);
}
+2 -2
View File
@@ -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';
+9 -11
View File
@@ -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>
),
}
};
+55 -55
View File
@@ -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');
});
});
+16 -16
View File
@@ -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
View File
@@ -1 +1 @@
export { Input, Textarea } from './ui/Input'
export { Input, Textarea } from './ui/Input';
+11 -11
View File
@@ -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>
),
}
};
+80 -80
View File
@@ -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');
});
});
});
+37 -20
View File
@@ -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>
)
);
}
+2 -2
View File
@@ -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';
+23 -11
View File
@@ -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>
),
}
};
+70 -62
View File
@@ -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');
});
});
});
+18 -28
View File
@@ -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
View File
@@ -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
View File
@@ -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>
),
}
};
+42 -42
View File
@@ -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');
});
});
});
+8 -11
View File
@@ -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
View File
@@ -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';