feat: add Card component family to shared/ui
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './ui/Card'
|
||||||
|
export type { CardBackground } from './ui/Card'
|
||||||
@@ -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(<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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
describe('background variants', () => {
|
||||||
|
it('defaults to ochre background', () => {
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
it('applies white background', () => {
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
it('removes padding when noPadding is true', () => {
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
describe('CardHeader', () => {
|
||||||
|
it('renders children with bottom margin', () => {
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
describe('CardContent', () => {
|
||||||
|
it('renders children in a div', () => {
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<CardBackground, string> = {
|
||||||
|
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 (
|
||||||
|
<div className={cn('brutal-border brutal-shadow', BG[background], !noPadding && 'p-6 md:p-8', className)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SlotProps {
|
||||||
|
/**
|
||||||
|
* Slot content
|
||||||
|
*/
|
||||||
|
children: ReactNode
|
||||||
|
/**
|
||||||
|
* Additional CSS classes
|
||||||
|
*/
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card header wrapper — adds bottom margin.
|
||||||
|
*/
|
||||||
|
export function CardHeader({ children, className }: SlotProps) {
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card description — muted paragraph below the title.
|
||||||
|
*/
|
||||||
|
export function CardDescription({ children, className }: SlotProps) {
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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>
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user