diff --git a/components/ui/PrimaryButton.test.tsx b/components/ui/PrimaryButton.test.tsx new file mode 100644 index 00000000..dc0ff464 --- /dev/null +++ b/components/ui/PrimaryButton.test.tsx @@ -0,0 +1,175 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import PrimaryButton from './PrimaryButton' + +describe('PrimaryButton', () => { + // ────────────────────────────────────────────────────────────────────────── + // Rendering + // ────────────────────────────────────────────────────────────────────────── + + it('renders with children', () => { + render(Submit) + expect(screen.getByRole('button', { name: 'Submit' })).toBeInTheDocument() + }) + + it('renders with complex children', () => { + render( + + + Pay Now + , + ) + expect(screen.getByRole('button')).toBeInTheDocument() + expect(screen.getByTestId('icon')).toBeInTheDocument() + expect(screen.getByText('Pay Now')).toBeInTheDocument() + }) + + // ────────────────────────────────────────────────────────────────────────── + // Default styling + // ────────────────────────────────────────────────────────────────────────── + + it('applies base visual classes', () => { + render(Send) + const button = screen.getByRole('button') + // Base layout classes + expect(button.className).toContain('inline-flex') + expect(button.className).toContain('items-center') + expect(button.className).toContain('justify-center') + // Brand colour + expect(button.className).toContain('bg-brand.red') + expect(button.className).toContain('text-white') + // Sizing + expect(button.className).toContain('px-6') + expect(button.className).toContain('py-3') + expect(button.className).toContain('rounded-lg') + expect(button.className).toContain('font-semibold') + // Transition + expect(button.className).toContain('transition-colors') + expect(button.className).toContain('duration-150') + }) + + it('applies hover classes', () => { + render(Send) + const button = screen.getByRole('button') + expect(button.className).toContain('hover:bg-brand.redHover') + }) + + it('applies focus classes', () => { + render(Send) + const button = screen.getByRole('button') + expect(button.className).toContain('focus:outline-none') + expect(button.className).toContain('focus:ring-2') + expect(button.className).toContain('focus:ring-brand.red') + expect(button.className).toContain('focus:ring-offset-2') + }) + + // ────────────────────────────────────────────────────────────────────────── + // Disabled state + // ────────────────────────────────────────────────────────────────────────── + + it('renders disabled when disabled prop is true', () => { + render(Send) + const button = screen.getByRole('button') + expect(button).toBeDisabled() + }) + + it('sets aria-disabled when disabled', () => { + render(Send) + const button = screen.getByRole('button') + expect(button).toHaveAttribute('aria-disabled', 'true') + }) + + it('does not set aria-disabled when enabled', () => { + render(Send) + const button = screen.getByRole('button') + expect(button).not.toHaveAttribute('aria-disabled') + }) + + it('applies disabled visual classes', () => { + render(Send) + const button = screen.getByRole('button') + expect(button.className).toContain('disabled:opacity-50') + expect(button.className).toContain('disabled:cursor-not-allowed') + expect(button.className).toContain('disabled:hover:bg-brand.red') + }) + + it('does not trigger onClick when disabled', async () => { + const user = userEvent.setup() + let clicked = false + render( + { clicked = true }}> + Send + , + ) + await user.click(screen.getByRole('button')) + expect(clicked).toBe(false) + }) + + // ────────────────────────────────────────────────────────────────────────── + // Custom className merging + // ────────────────────────────────────────────────────────────────────────── + + it('merges custom className with default classes', () => { + render(Send) + const button = screen.getByRole('button') + expect(button.className).toContain('extra-class') + // Default classes should still be present + expect(button.className).toContain('bg-brand.red') + expect(button.className).toContain('inline-flex') + }) + + // ────────────────────────────────────────────────────────────────────────── + // forwardRef support + // ────────────────────────────────────────────────────────────────────────── + + it('forwards ref to the button element', () => { + const ref = { current: null as HTMLButtonElement | null } + render(Send) + expect(ref.current).toBeInstanceOf(HTMLButtonElement) + expect(ref.current?.tagName).toBe('BUTTON') + }) + + it('has correct displayName', () => { + expect(PrimaryButton.displayName).toBe('PrimaryButton') + }) + + // ────────────────────────────────────────────────────────────────────────── + // Additional HTML button attributes + // ────────────────────────────────────────────────────────────────────────── + + it('supports type attribute', () => { + render(Submit) + expect(screen.getByRole('button')).toHaveAttribute('type', 'submit') + }) + + it('supports aria-label', () => { + render(X) + expect(screen.getByRole('button', { name: 'Close dialog' })).toBeInTheDocument() + }) + + it('supports data-* attributes', () => { + render(Send) + expect(screen.getByTestId('submit-btn')).toBeInTheDocument() + }) + + // ────────────────────────────────────────────────────────────────────────── + // Interaction parity: hover then focus + // ────────────────────────────────────────────────────────────────────────── + + it('maintains hover class when focused', async () => { + const user = userEvent.setup() + render(Send) + const button = screen.getByRole('button') + + await user.hover(button) + // Hover classes are present + expect(button.className).toContain('hover:bg-brand.redHover') + + // Focus does not remove hover + button.focus() + expect(button.className).toContain('hover:bg-brand.redHover') + // Focus classes are also present + expect(button.className).toContain('focus:ring-2') + }) +}) \ No newline at end of file diff --git a/lib/utils.ts b/lib/utils.ts index 365058ce..607cb86e 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,6 +1 @@ -import { type ClassValue, clsx } from "clsx"; -import { twMerge } from "tailwind-merge"; - -export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); -} +export { cn } from "./utils/cn"; diff --git a/lib/utils/index.ts b/lib/utils/index.ts index 37295e1c..c25627d5 100644 --- a/lib/utils/index.ts +++ b/lib/utils/index.ts @@ -1,6 +1 @@ -import { clsx } from "clsx"; -import { twMerge } from "tailwind-merge"; - -export function cn(...inputs: Parameters) { - return twMerge(clsx(inputs)); -} +export { cn } from "./cn";