diff --git a/react/src/components/Button.test.tsx b/react/src/components/Button.test.tsx new file mode 100644 index 0000000..6d14d53 --- /dev/null +++ b/react/src/components/Button.test.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { Button } from './Button'; + +describe('Button Component', () => { + it('renders children text correctly', () => { + render(); + expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument(); + }); + + it('applies custom variant classes matching tokens', () => { + const { rerender } = render(); + expect(screen.getByRole('button')).toHaveClass('bg-red-600'); + + rerender(); + expect(screen.getByRole('button')).toHaveClass('border-gray-300'); + }); + + it('triggers onClick handler when clicked', () => { + const handleClick = jest.fn(); + render(); + + fireEvent.click(screen.getByRole('button')); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it('handles loading state accessibility and disables interaction safely', () => { + const handleClick = jest.fn(); + render(); + + const button = screen.getByRole('button'); + expect(button).toBeDisabled(); + expect(button).toHaveAttribute('aria-disabled', 'true'); + expect(screen.getByText('Loading...')).toBeInTheDocument(); + + fireEvent.click(button); + expect(handleClick).not.toHaveBeenCalled(); + }); + + it('supports passing traditional forwarding refs correctly', () => { + const ref = React.createRef(); + render(); + expect(ref.current).toBeInstanceOf(HTMLButtonElement); + }); +}); \ No newline at end of file diff --git a/react/src/components/Button.tsx b/react/src/components/Button.tsx new file mode 100644 index 0000000..e77a4ed --- /dev/null +++ b/react/src/components/Button.tsx @@ -0,0 +1,79 @@ +import React, { forwardRef } from 'react'; + +export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'outline'; +export type ButtonSize = 'sm' | 'md' | 'lg'; + +export interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: ButtonVariant; + size?: ButtonSize; + isLoading?: boolean; +} + +export const Button = forwardRef( + ( + { + children, + variant = 'primary', + size = 'md', + isLoading = false, + disabled, + className = '', + type = 'button', + ...props + }, + ref + ) => { + // Base design tokens including structural, layout, and accessible focus outlines + const baseStyles = + 'inline-flex items-center justify-center font-medium rounded-md transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none'; + + const variants: Record = { + primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500', + secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-500', + danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500', + outline: 'border border-gray-300 bg-transparent text-gray-700 hover:bg-gray-50 focus:ring-blue-500', + }; + + const sizes: Record = { + sm: 'px-3 py-1.5 text-sm', + md: 'px-4 py-2 text-base', + lg: 'px-6 py-3 text-lg', + }; + + const isInteractionDisabled = disabled || isLoading; + + return ( + + ); + } +); + +Button.displayName = 'Button'; \ No newline at end of file diff --git a/react/src/components/index.ts b/react/src/components/index.ts index aa7c45f..bef3ff3 100644 --- a/react/src/components/index.ts +++ b/react/src/components/index.ts @@ -3,3 +3,9 @@ export type { AlertProps, AlertVariant } from './Alert'; export { Badge } from './Badge'; export type { BadgeProps, BadgeVariant, BadgeSize } from './Badge'; + +export { Button } from './Button'; +export type { ButtonProps, ButtonVariant, ButtonSize } from './Button'; + +export { Modal } from './Modal'; +export type { ModalProps } from './Modal'; \ No newline at end of file