diff --git a/src/shared/ui/Button/index.ts b/src/shared/ui/Button/index.ts new file mode 100644 index 0000000..ee20067 --- /dev/null +++ b/src/shared/ui/Button/index.ts @@ -0,0 +1,2 @@ +export { Button } from './ui/Button' +export type { ButtonVariant, ButtonSize } from './ui/Button' diff --git a/src/shared/ui/Button/ui/Button.test.tsx b/src/shared/ui/Button/ui/Button.test.tsx new file mode 100644 index 0000000..48410b8 --- /dev/null +++ b/src/shared/ui/Button/ui/Button.test.tsx @@ -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() + expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument() + }) + it('renders as button element', () => { + render() + expect(screen.getByRole('button')).toBeInTheDocument() + }) + }) + describe('variants', () => { + it('applies primary variant by default', () => { + render() + expect(screen.getByRole('button')).toHaveClass('bg-burnt-oxide') + }) + it('applies secondary variant', () => { + render() + expect(screen.getByRole('button')).toHaveClass('bg-slate-indigo') + }) + it('applies outline variant', () => { + render() + expect(screen.getByRole('button')).toHaveClass('bg-transparent') + }) + it('applies ghost variant', () => { + render() + expect(screen.getByRole('button')).toHaveClass('bg-ochre-clay') + }) + }) + describe('sizes', () => { + it('applies md size by default', () => { + render() + expect(screen.getByRole('button')).toHaveClass('px-6', 'py-3') + }) + it('applies sm size', () => { + render() + expect(screen.getByRole('button')).toHaveClass('px-4', 'py-2') + }) + it('applies lg size', () => { + render() + expect(screen.getByRole('button')).toHaveClass('px-8', 'py-4') + }) + }) + describe('interactions', () => { + it('calls onClick when clicked', async () => { + const onClick = vi.fn() + render() + await userEvent.click(screen.getByRole('button')) + expect(onClick).toHaveBeenCalledOnce() + }) + it('is disabled when disabled prop is set', () => { + render() + expect(screen.getByRole('button')).toBeDisabled() + }) + }) + describe('className passthrough', () => { + it('merges custom className', () => { + render() + expect(screen.getByRole('button')).toHaveClass('w-full') + }) + }) +}) diff --git a/src/shared/ui/Button/ui/Button.tsx b/src/shared/ui/Button/ui/Button.tsx new file mode 100644 index 0000000..c1f256c --- /dev/null +++ b/src/shared/ui/Button/ui/Button.tsx @@ -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 { + /** + * Visual variant + * @default 'primary' + */ + variant?: ButtonVariant + /** + * Size preset + * @default 'md' + */ + size?: ButtonSize + /** + * Button content + */ + children: ReactNode +} + +const VARIANTS: Record = { + 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 = { + 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 ( + + ) +}