feat: add Button component to shared/ui

This commit is contained in:
Ilia Mashkov
2026-04-19 08:19:23 +03:00
parent 169988906c
commit 11e7aa9a96
3 changed files with 117 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
export { Button } from './ui/Button'
export type { ButtonVariant, ButtonSize } from './ui/Button'
+67
View File
@@ -0,0 +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'
describe('Button', () => {
describe('rendering', () => {
it('renders children', () => {
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()
})
})
describe('variants', () => {
it('applies primary variant by default', () => {
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')
})
it('applies outline variant', () => {
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')
})
})
describe('sizes', () => {
it('applies md size by default', () => {
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')
})
it('applies lg size', () => {
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()
})
it('is disabled when disabled prop is set', () => {
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')
})
})
})
+48
View File
@@ -0,0 +1,48 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react'
import { cn } from '$shared/lib'
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost'
export type ButtonSize = 'sm' | 'md' | 'lg'
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
/**
* Visual variant
* @default 'primary'
*/
variant?: ButtonVariant
/**
* Size preset
* @default 'md'
*/
size?: ButtonSize
/**
* Button content
*/
children: ReactNode
}
const VARIANTS: Record<ButtonVariant, string> = {
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',
}
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'
/**
* Brutalist button with variants and sizes.
*/
export function Button({ variant = 'primary', size = 'md', className, children, ...props }: Props) {
return (
<button className={cn(BASE, VARIANTS[variant], SIZES[size], className)} {...props}>
{children}
</button>
)
}