From 26860b27e597d76c238c42e802ec0014b93a0858 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sun, 19 Apr 2026 08:19:58 +0300 Subject: [PATCH] feat: add Card component family to shared/ui --- src/shared/ui/Card/index.ts | 2 + src/shared/ui/Card/ui/Card.test.tsx | 79 ++++++++++++++++++++++++++ src/shared/ui/Card/ui/Card.tsx | 88 +++++++++++++++++++++++++++++ 3 files changed, 169 insertions(+) create mode 100644 src/shared/ui/Card/index.ts create mode 100644 src/shared/ui/Card/ui/Card.test.tsx create mode 100644 src/shared/ui/Card/ui/Card.tsx diff --git a/src/shared/ui/Card/index.ts b/src/shared/ui/Card/index.ts new file mode 100644 index 0000000..b5c2138 --- /dev/null +++ b/src/shared/ui/Card/index.ts @@ -0,0 +1,2 @@ +export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './ui/Card' +export type { CardBackground } from './ui/Card' diff --git a/src/shared/ui/Card/ui/Card.test.tsx b/src/shared/ui/Card/ui/Card.test.tsx new file mode 100644 index 0000000..1d49f3d --- /dev/null +++ b/src/shared/ui/Card/ui/Card.test.tsx @@ -0,0 +1,79 @@ +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(Content) + expect(screen.getByText('Content')).toBeInTheDocument() + }) + it('has brutal-border and brutal-shadow classes', () => { + const { container } = render(Content) + expect(container.firstChild).toHaveClass('brutal-border', 'brutal-shadow') + }) + }) + describe('background variants', () => { + it('defaults to ochre background', () => { + const { container } = render(Content) + expect(container.firstChild).toHaveClass('bg-ochre-clay') + }) + it('applies slate background', () => { + const { container } = render(Content) + expect(container.firstChild).toHaveClass('bg-slate-indigo') + }) + it('applies white background', () => { + const { container } = render(Content) + expect(container.firstChild).toHaveClass('bg-white') + }) + }) + describe('padding', () => { + it('has default padding', () => { + const { container } = render(Content) + expect(container.firstChild).toHaveClass('p-6') + }) + it('removes padding when noPadding is true', () => { + const { container } = render(Content) + expect(container.firstChild).not.toHaveClass('p-6') + }) + }) + describe('className passthrough', () => { + it('merges custom className', () => { + const { container } = render(Content) + expect(container.firstChild).toHaveClass('group') + }) + }) +}) +describe('CardHeader', () => { + it('renders children with bottom margin', () => { + render(Header) + expect(screen.getByText('Header')).toHaveClass('mb-4') + }) +}) +describe('CardTitle', () => { + it('renders children as h3', () => { + render(Title) + expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('Title') + }) +}) +describe('CardDescription', () => { + it('renders children as paragraph with opacity', () => { + render(Desc) + const el = screen.getByText('Desc') + expect(el.tagName).toBe('P') + expect(el).toHaveClass('opacity-80') + }) +}) +describe('CardContent', () => { + it('renders children in a div', () => { + render(Body) + expect(screen.getByText('Body')).toBeInTheDocument() + }) +}) +describe('CardFooter', () => { + it('renders children with top border', () => { + render(Footer) + const el = screen.getByText('Footer') + expect(el).toHaveClass('brutal-border-top', 'mt-6', 'pt-6') + }) +}) diff --git a/src/shared/ui/Card/ui/Card.tsx b/src/shared/ui/Card/ui/Card.tsx new file mode 100644 index 0000000..cadf0d1 --- /dev/null +++ b/src/shared/ui/Card/ui/Card.tsx @@ -0,0 +1,88 @@ +import type { ReactNode } from 'react' +import { cn } from '$shared/lib' + +export type CardBackground = 'ochre' | 'slate' | 'white' + +interface CardProps { + /** + * Card content + */ + children: ReactNode + /** + * Additional CSS classes + */ + className?: string + /** + * Background color preset + * @default 'ochre' + */ + background?: CardBackground + /** + * Remove default padding + * @default false + */ + noPadding?: boolean +} + +const BG: Record = { + ochre: 'bg-ochre-clay', + slate: 'bg-slate-indigo text-ochre-clay', + white: 'bg-white', +} + +/** + * Brutalist card container with background and padding variants. + */ +export function Card({ children, className, background = 'ochre', noPadding = false }: CardProps) { + return ( +
+ {children} +
+ ) +} + +interface SlotProps { + /** + * Slot content + */ + children: ReactNode + /** + * Additional CSS classes + */ + className?: string +} + +/** + * Card header wrapper — adds bottom margin. + */ +export function CardHeader({ children, className }: SlotProps) { + return
{children}
+} + +/** + * Card title — renders as h3. + */ +export function CardTitle({ children, className }: SlotProps) { + return

{children}

+} + +/** + * Card description — muted paragraph below the title. + */ +export function CardDescription({ children, className }: SlotProps) { + return

{children}

+} + +/** + * Card body content area. + */ +export function CardContent({ children, className }: SlotProps) { + return
{children}
+} + +/** + * Card footer — separated by a brutal border-top. + */ +export function CardFooter({ children, className }: SlotProps) { + return
{children}
+}