feat: CardSidebar layout component and ExperienceCard sidebar redesign
Sidebar: period badge, company, stack tags. Main: role title and rich-text description.
This commit is contained in:
@@ -32,6 +32,32 @@ describe('ExperienceCard', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('layout', () => {
|
||||||
|
it('period badge is inside the sidebar column', () => {
|
||||||
|
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
||||||
|
const badge = screen.getByText('2021 – 2024');
|
||||||
|
expect(badge.closest('.brutal-border-sidebar')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('company name is inside the sidebar column', () => {
|
||||||
|
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
||||||
|
const company = screen.getByText('Acme Corp');
|
||||||
|
expect(company.closest('.brutal-border-sidebar')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('title is outside the sidebar column', () => {
|
||||||
|
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
||||||
|
const title = screen.getByText('Senior Developer');
|
||||||
|
expect(title.closest('.brutal-border-sidebar')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('description is outside the sidebar column', () => {
|
||||||
|
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
||||||
|
const desc = screen.getByText('Built scalable frontend systems.');
|
||||||
|
expect(desc.closest('.brutal-border-sidebar')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('structure', () => {
|
describe('structure', () => {
|
||||||
it('title is rendered as an h3', () => {
|
it('title is rendered as an h3', () => {
|
||||||
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
||||||
@@ -44,25 +70,33 @@ describe('ExperienceCard', () => {
|
|||||||
expect(badge).toHaveClass('brutal-border', 'bg-blue', 'text-cream', 'text-sm');
|
expect(badge).toHaveClass('brutal-border', 'bg-blue', 'text-cream', 'text-sm');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('company paragraph has opacity-60', () => {
|
|
||||||
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
|
||||||
const company = screen.getByText('Acme Corp');
|
|
||||||
expect(company.tagName).toBe('P');
|
|
||||||
expect(company).toHaveClass('opacity-60');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('description renders via RichText with rich-text class', () => {
|
it('description renders via RichText with rich-text class', () => {
|
||||||
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
||||||
const desc = screen.getByText('Built scalable frontend systems.');
|
const desc = screen.getByText('Built scalable frontend systems.');
|
||||||
expect(desc.closest('.rich-text')).toBeInTheDocument();
|
expect(desc.closest('.rich-text')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('card has brutal-border class (from Card component)', () => {
|
it('card has brutal-border class', () => {
|
||||||
const { container } = render(<ExperienceCard {...DEFAULT_PROPS} />);
|
const { container } = render(<ExperienceCard {...DEFAULT_PROPS} />);
|
||||||
expect(container.firstChild).toHaveClass('brutal-border');
|
expect(container.firstChild).toHaveClass('brutal-border');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('stack tags', () => {
|
||||||
|
it('renders stack tags in the sidebar', () => {
|
||||||
|
render(<ExperienceCard {...DEFAULT_PROPS} stack={['React', 'TypeScript']} />);
|
||||||
|
const react = screen.getByText('React');
|
||||||
|
const ts = screen.getByText('TypeScript');
|
||||||
|
expect(react.closest('.brutal-border-sidebar')).toBeInTheDocument();
|
||||||
|
expect(ts.closest('.brutal-border-sidebar')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing extra when stack is empty', () => {
|
||||||
|
render(<ExperienceCard {...DEFAULT_PROPS} stack={[]} />);
|
||||||
|
expect(screen.queryByRole('list')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('className passthrough', () => {
|
describe('className passthrough', () => {
|
||||||
it('forwards className to the card', () => {
|
it('forwards className to the card', () => {
|
||||||
const { container } = render(<ExperienceCard {...DEFAULT_PROPS} className="custom-class" />);
|
const { container } = render(<ExperienceCard {...DEFAULT_PROPS} className="custom-class" />);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle, RichText } from '$shared/ui';
|
import { Card, CardSidebar, CardTitle, RichText } from '$shared/ui';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/**
|
/**
|
||||||
@@ -28,30 +28,38 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Work experience card with title, company, period, description, and tech stack.
|
* Work experience card with sidebar layout.
|
||||||
|
* Sidebar: period badge, company, stack tags.
|
||||||
|
* Main: job title and rich-text description.
|
||||||
*/
|
*/
|
||||||
export function ExperienceCard({ title, company, period, description, stack, className }: Props) {
|
export function ExperienceCard({ title, company, period, description, stack, className }: Props) {
|
||||||
return (
|
return (
|
||||||
<Card className={className}>
|
<Card className={className}>
|
||||||
<CardHeader className="flex flex-col md:flex-row md:items-start md:justify-between gap-4 pb-6 md:pb-8 brutal-border-bottom">
|
<CardSidebar
|
||||||
<div className="flex-1">
|
sidebar={
|
||||||
<CardTitle className="font-heading">{title}</CardTitle>
|
<div className="flex flex-col gap-4">
|
||||||
<p className="text-base opacity-60">{company}</p>
|
|
||||||
</div>
|
|
||||||
<span className="brutal-border px-4 py-2 bg-blue text-cream text-sm self-start">{period}</span>
|
<span className="brutal-border px-4 py-2 bg-blue text-cream text-sm self-start">{period}</span>
|
||||||
</CardHeader>
|
<p className="text-base font-medium">{company}</p>
|
||||||
<CardContent>
|
|
||||||
<RichText html={description} />
|
|
||||||
</CardContent>
|
|
||||||
{stack.length > 0 && (
|
{stack.length > 0 && (
|
||||||
<CardFooter className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{stack.map((tech) => (
|
{stack.map((tech) => (
|
||||||
<span key={tech} className="brutal-border px-3 py-1 bg-cream text-blue text-sm uppercase tracking-wide">
|
<span
|
||||||
|
key={tech}
|
||||||
|
className="brutal-border px-3 py-1 bg-cream text-blue text-sm uppercase tracking-wide"
|
||||||
|
>
|
||||||
{tech}
|
{tech}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</CardFooter>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<CardTitle className="font-heading">{title}</CardTitle>
|
||||||
|
<RichText html={description} />
|
||||||
|
</div>
|
||||||
|
</CardSidebar>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -269,6 +269,16 @@
|
|||||||
.brutal-border-right {
|
.brutal-border-right {
|
||||||
border-right: var(--border-width) solid var(--blue);
|
border-right: var(--border-width) solid var(--blue);
|
||||||
}
|
}
|
||||||
|
/* Sidebar divider: bottom border on mobile, right border on desktop */
|
||||||
|
.brutal-border-sidebar {
|
||||||
|
border-bottom: var(--border-width) solid var(--blue);
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.brutal-border-sidebar {
|
||||||
|
border-bottom: none;
|
||||||
|
border-right: var(--border-width) solid var(--blue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Editorial rich-text typography */
|
/* Editorial rich-text typography */
|
||||||
.rich-text {
|
.rich-text {
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export type { CardBackground } from './ui/Card';
|
export type { CardBackground } from './ui/Card';
|
||||||
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from './ui/Card';
|
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardSidebar, CardTitle } from './ui/Card';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from './Card';
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardSidebar, CardTitle } from './Card';
|
||||||
|
|
||||||
describe('Card', () => {
|
describe('Card', () => {
|
||||||
describe('rendering', () => {
|
describe('rendering', () => {
|
||||||
@@ -72,3 +72,51 @@ describe('CardFooter', () => {
|
|||||||
expect(el).toHaveClass('brutal-border-top', 'mt-6', 'pt-6', 'md:mt-8', 'md:pt-8');
|
expect(el).toHaveClass('brutal-border-top', 'mt-6', 'pt-6', 'md:mt-8', 'md:pt-8');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
describe('CardSidebar', () => {
|
||||||
|
describe('rendering', () => {
|
||||||
|
it('renders sidebar content', () => {
|
||||||
|
render(<CardSidebar sidebar={<span>Sidebar</span>}>Main</CardSidebar>);
|
||||||
|
expect(screen.getByText('Sidebar')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders main content', () => {
|
||||||
|
render(<CardSidebar sidebar={<span>Sidebar</span>}>Main</CardSidebar>);
|
||||||
|
expect(screen.getByText('Main')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('structure', () => {
|
||||||
|
it('root wrapper is a flex container', () => {
|
||||||
|
const { container } = render(<CardSidebar sidebar={<span>S</span>}>M</CardSidebar>);
|
||||||
|
expect(container.firstChild).toHaveClass('flex');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sidebar column has brutal-border-sidebar class', () => {
|
||||||
|
render(<CardSidebar sidebar={<span>Sidebar</span>}>Main</CardSidebar>);
|
||||||
|
const sidebar = screen.getByText('Sidebar').parentElement;
|
||||||
|
expect(sidebar).toHaveClass('brutal-border-sidebar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sidebar column has fixed width on md', () => {
|
||||||
|
render(<CardSidebar sidebar={<span>Sidebar</span>}>Main</CardSidebar>);
|
||||||
|
const sidebar = screen.getByText('Sidebar').parentElement;
|
||||||
|
expect(sidebar).toHaveClass('md:w-52');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('main column fills remaining space', () => {
|
||||||
|
render(<CardSidebar sidebar={<span>Sidebar</span>}>Main</CardSidebar>);
|
||||||
|
expect(screen.getByText('Main')).toHaveClass('flex-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('className passthrough', () => {
|
||||||
|
it('forwards className to the root wrapper', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<CardSidebar sidebar={<span>S</span>} className="custom">
|
||||||
|
M
|
||||||
|
</CardSidebar>,
|
||||||
|
);
|
||||||
|
expect(container.firstChild).toHaveClass('custom');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -85,3 +85,32 @@ export function CardContent({ children, className }: SlotProps) {
|
|||||||
export function CardFooter({ children, className }: SlotProps) {
|
export function CardFooter({ children, className }: SlotProps) {
|
||||||
return <div className={cn('mt-6 md:mt-8 pt-6 md:pt-8 brutal-border-top', className)}>{children}</div>;
|
return <div className={cn('mt-6 md:mt-8 pt-6 md:pt-8 brutal-border-top', className)}>{children}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CardSidebarProps {
|
||||||
|
/**
|
||||||
|
* Left sidebar content — metadata such as period, company, stack
|
||||||
|
*/
|
||||||
|
sidebar: ReactNode;
|
||||||
|
/**
|
||||||
|
* Main content — primary info such as role title and description
|
||||||
|
*/
|
||||||
|
children: ReactNode;
|
||||||
|
/**
|
||||||
|
* Additional CSS classes for the root wrapper
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two-column card layout: narrow sidebar on the left, main content on the right.
|
||||||
|
* On mobile the columns stack vertically with a bottom border separator;
|
||||||
|
* on md+ they sit side-by-side with a right border separator.
|
||||||
|
*/
|
||||||
|
export function CardSidebar({ sidebar, children, className }: CardSidebarProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col md:flex-row', className)}>
|
||||||
|
<div className="shrink-0 md:w-52 brutal-border-sidebar pb-6 md:pb-0 md:pr-8 mb-6 md:mb-0">{sidebar}</div>
|
||||||
|
<div className="flex-1 min-w-0 md:pl-8">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ export { Badge } from './Badge';
|
|||||||
export type { ButtonSize, ButtonVariant } from './Button';
|
export type { ButtonSize, ButtonVariant } from './Button';
|
||||||
export { Button } from './Button';
|
export { Button } from './Button';
|
||||||
export type { CardBackground } from './Card';
|
export type { CardBackground } from './Card';
|
||||||
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from './Card';
|
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardSidebar, CardTitle } from './Card';
|
||||||
|
|
||||||
export { Input, Textarea } from './Input';
|
export { Input, Textarea } from './Input';
|
||||||
export { RichText } from './RichText';
|
export { RichText } from './RichText';
|
||||||
|
|||||||
Reference in New Issue
Block a user