diff --git a/docs/components/EmptyState.md b/docs/components/EmptyState.md index 6e60a4b..2823714 100644 --- a/docs/components/EmptyState.md +++ b/docs/components/EmptyState.md @@ -4,13 +4,16 @@ The `EmptyState` component is a reusable UI element designed to provide clear gu ## Props -| Prop | Type | Required | Description | -|------|------|----------|-------------| -| `icon` | `React.ReactNode` | No | An optional icon or graphic to visually represent the empty state. | -| `title` | `string` | Yes | A clear, concise heading describing the empty state. | -| `description` | `string` | Yes | Short explanatory text providing context and guidance. | -| `actionLabel` | `string` | No | The label for the optional call-to-action button. | -| `onAction` | `() => void` | No | The callback function executed when the action button is clicked. | +| Prop | Type | Required | Description | +|------|------|----------|-------------| +| `icon` | `React.ReactNode` | No | An optional icon or graphic to visually represent the empty state. | +| `illustration` | `'contracts' \| 'milestones' \| 'reputation'` | No | A named decorative illustration variant for common onboarding contexts. | +| `title` | `string` | Yes | A clear, concise heading describing the empty state. | +| `description` | `string` | Yes | Short explanatory text providing context and guidance. | +| `actionLabel` | `string` | No | The label for the optional call-to-action button. | +| `onAction` | `() => void` | No | The callback function executed when the action button is clicked. | +| `secondaryActionLabel` | `string` | No | The label for an optional secondary action. | +| `onSecondaryAction` | `() => void` | No | The callback function executed when the secondary action button is clicked. | ## Usage Examples @@ -25,21 +28,33 @@ import EmptyState from '@/components/EmptyState'; /> ``` -### Empty State with Icon - -```tsx +### Empty State with Icon + +```tsx import EmptyState from '@/components/EmptyState'; } title="No search results" description="Try adjusting your search criteria." -/> -``` - -### Empty State with Action - -```tsx +/> +``` + +### Empty State with Illustration Variant + +```tsx +import EmptyState from '@/components/EmptyState'; + + +``` + +### Empty State with Action + +```tsx import EmptyState from '@/components/EmptyState'; navigate('/contracts/new')} -/> -``` - -## Accessibility - -The component is designed with accessibility in mind: - -- Uses semantic HTML with a `role="region"` for screen readers. -- The title has an `id` and is referenced by `aria-labelledby`. -- The action button includes an `aria-label` for clarity. -- Icons are marked with `aria-hidden="true"` to avoid cluttering screen reader output. +/> +``` + +### Empty State with Secondary Action + +```tsx +import EmptyState from '@/components/EmptyState'; + + navigate('/milestones/new')} + secondaryActionLabel="View Contracts" + onSecondaryAction={() => navigate('/contracts')} +/> +``` + +## Illustration Variants + +| Variant | Intended context | +|---------|------------------| +| `contracts` | Empty contract list or first-contract onboarding. | +| `milestones` | Empty milestone tracker or contract setup guidance. | +| `reputation` | Empty reputation history before completed work. | + +## Accessibility + +The component is designed with accessibility in mind: + +- Uses semantic HTML with a `role="region"` for screen readers. +- The title has an `id` and is referenced by `aria-labelledby`. +- Action buttons include `aria-label` values for clarity. +- Decorative icons and illustration variants are marked with `aria-hidden="true"` to avoid cluttering screen reader output. +- Primary and secondary actions are native `button` elements, so they are reachable by keyboard and operable with `Enter` and `Space`. +- Focus states use visible high-contrast `focus-visible` outlines. +- Secondary actions use an outlined style so they are visually subordinate without being hidden from keyboard or screen reader users. ## Styling The component uses Tailwind CSS classes for consistent styling: -- Centered layout with flexbox. -- Responsive padding and text sizing. -- Blue-themed action button with hover states. -- Gray color scheme for text to maintain readability. +- Centered layout with flexbox. +- Responsive padding and text sizing. +- Primary action button with hover and focus states. +- Secondary outlined action button with hover and focus states. +- Variant illustration colors for contract, milestone, and reputation contexts. +- Gray color scheme for text to maintain readability. ## Contexts @@ -81,9 +125,11 @@ This component is currently used in the following views: The component includes comprehensive unit tests covering: -- Rendering of title and description. -- Conditional rendering of icon and action button. -- Accessibility attributes. -- Button click functionality. +- Rendering of title and description. +- Conditional rendering of icon and action button. +- Secondary action rendering and callback behavior. +- Named illustration variants with decorative `aria-hidden` wrappers. +- Accessibility attributes. +- Button click functionality. -Integration tests ensure the component appears correctly in empty data scenarios across the implemented views. \ No newline at end of file +Integration tests ensure the component appears correctly in empty data scenarios across the implemented views. diff --git a/src/app/contracts/page.tsx b/src/app/contracts/page.tsx index 2850328..288141c 100644 --- a/src/app/contracts/page.tsx +++ b/src/app/contracts/page.tsx @@ -14,12 +14,12 @@ const ContractsPage: React.FC = () => { return (

Contracts

- {contracts.length === 0 ? ( - } - title="No contracts found" - description="You haven't created any contracts yet. Start by creating your first contract to begin freelancing securely." - actionLabel="Create Contract" + {contracts.length === 0 ? ( + ) : ( @@ -30,4 +30,4 @@ const ContractsPage: React.FC = () => { ); }; -export default ContractsPage; \ No newline at end of file +export default ContractsPage; diff --git a/src/app/milestones/page.tsx b/src/app/milestones/page.tsx index def95b1..d842b54 100644 --- a/src/app/milestones/page.tsx +++ b/src/app/milestones/page.tsx @@ -14,12 +14,12 @@ const MilestonesPage: React.FC = () => { return (

Milestones

- {milestones.length === 0 ? ( - } - title="No milestones tracked" - description="Track your progress by adding milestones to your contracts. Milestones help you stay organized and ensure timely delivery." - actionLabel="Add Milestone" + {milestones.length === 0 ? ( + ) : ( @@ -30,4 +30,4 @@ const MilestonesPage: React.FC = () => { ); }; -export default MilestonesPage; \ No newline at end of file +export default MilestonesPage; diff --git a/src/app/reputation/page.tsx b/src/app/reputation/page.tsx index ca290d6..899dd6e 100644 --- a/src/app/reputation/page.tsx +++ b/src/app/reputation/page.tsx @@ -7,12 +7,12 @@ const ReputationPage: React.FC = () => { return (

Reputation

- {reputation.length === 0 ? ( - } - title="No reputation yet" - description="Your reputation will be built as you complete contracts and receive feedback from clients. Start by creating and fulfilling your first contract." - /> + {reputation.length === 0 ? ( + ) : ( // TODO: Render reputation list
Reputation list
@@ -21,4 +21,4 @@ const ReputationPage: React.FC = () => { ); }; -export default ReputationPage; \ No newline at end of file +export default ReputationPage; diff --git a/src/components/EmptyState.tsx b/src/components/EmptyState.tsx index 9797343..27248ee 100644 --- a/src/components/EmptyState.tsx +++ b/src/components/EmptyState.tsx @@ -1,49 +1,116 @@ -import React from 'react'; +'use client'; + +import React, { useId } from 'react'; + +export type EmptyStateVariant = 'contracts' | 'milestones' | 'reputation'; + +interface EmptyStateProps { + icon?: React.ReactNode; + illustration?: EmptyStateVariant; + title: string; + description: string; + actionLabel?: string; + onAction?: () => void; + secondaryActionLabel?: string; + onSecondaryAction?: () => void; +} + +const illustrationClassNames: Record = { + contracts: 'bg-blue-50 text-blue-700 ring-blue-100', + milestones: 'bg-emerald-50 text-emerald-700 ring-emerald-100', + reputation: 'bg-amber-50 text-amber-700 ring-amber-100', +}; + +const illustrations: Record = { + contracts: ( + + ), + milestones: ( + + ), + reputation: ( + + ), +}; + +const EmptyState: React.FC = ({ + icon, + illustration, + title, + description, + actionLabel, + onAction, + secondaryActionLabel, + onSecondaryAction, +}) => { + const titleId = useId(); + const renderedIllustration = illustration ? illustrations[illustration] : undefined; + + return ( +
+ {(icon || renderedIllustration) && ( + + )} +

+ {title} +

+

{description}

+ {(actionLabel && onAction) || (secondaryActionLabel && onSecondaryAction) ? ( +
+ {actionLabel && onAction && ( + + )} + {secondaryActionLabel && onSecondaryAction && ( + + )} +
+ ) : null} +
+ ); +}; -interface EmptyStateProps { - icon?: React.ReactNode; - title: string; - description: string; - actionLabel?: string; - onAction?: () => void; -} - -const EmptyState: React.FC = ({ - icon, - title, - description, - actionLabel, - onAction, -}) => { - return ( -
- {icon && ( - - )} -

- {title} -

-

{description}

- {actionLabel && onAction && ( - - )} -
- ); -}; - -export default EmptyState; \ No newline at end of file +export default EmptyState; diff --git a/src/components/__tests__/EmptyState.test.tsx b/src/components/__tests__/EmptyState.test.tsx index 12b3b18..0568d0c 100644 --- a/src/components/__tests__/EmptyState.test.tsx +++ b/src/components/__tests__/EmptyState.test.tsx @@ -15,18 +15,19 @@ describe('EmptyState', () => { expect(screen.getByText('Test Description')).toBeInTheDocument(); }); - it('renders icon when provided', () => { - const icon = Icon; - render( + it('renders icon when provided', () => { + const icon = Icon; + render( ); - - expect(screen.getByTestId('test-icon')).toBeInTheDocument(); - }); + + expect(screen.getByTestId('test-icon')).toBeInTheDocument(); + expect(screen.getByTestId('test-icon').parentElement).toHaveAttribute('aria-hidden', 'true'); + }); it('does not render icon when not provided', () => { render( @@ -39,8 +40,8 @@ describe('EmptyState', () => { expect(screen.queryByTestId('test-icon')).not.toBeInTheDocument(); }); - it('renders action button when actionLabel and onAction are provided', () => { - const mockOnAction = jest.fn(); + it('renders action button when actionLabel and onAction are provided', () => { + const mockOnAction = jest.fn(); render( { expect(button).toBeInTheDocument(); fireEvent.click(button); - expect(mockOnAction).toHaveBeenCalledTimes(1); - }); - - it('does not render action button when actionLabel or onAction is missing', () => { - render( - { + const mockOnAction = jest.fn(); + const mockOnSecondaryAction = jest.fn(); + + render( + + ); + + const primaryButton = screen.getByRole('button', { name: 'Create Contract' }); + const secondaryButton = screen.getByRole('button', { name: 'Learn More' }); + + expect(primaryButton).not.toHaveAttribute('tabindex', '-1'); + expect(secondaryButton).not.toHaveAttribute('tabindex', '-1'); + expect(primaryButton).toHaveClass('focus-visible:outline'); + expect(secondaryButton).toHaveClass('focus-visible:outline'); + expect(secondaryButton).toHaveClass('border'); + + fireEvent.click(primaryButton); + fireEvent.click(secondaryButton); + + expect(mockOnAction).toHaveBeenCalledTimes(1); + expect(mockOnSecondaryAction).toHaveBeenCalledTimes(1); + }); + + it('does not render secondary action when label or handler is missing', () => { + const { rerender } = render( + + ); + + expect(screen.queryByRole('button', { name: 'Learn More' })).not.toBeInTheDocument(); + + rerender( + + ); + + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('renders named illustration variants as decorative content', () => { + const { rerender } = render( + + ); + + let illustration = screen.getByRole('region').querySelector('[aria-hidden="true"]'); + expect(illustration).toBeInTheDocument(); + expect(illustration).toHaveClass('bg-blue-50'); + + rerender( + + ); + + illustration = screen.getByRole('region').querySelector('[aria-hidden="true"]'); + expect(illustration).toHaveClass('bg-emerald-50'); + + rerender( + + ); + + illustration = screen.getByRole('region').querySelector('[aria-hidden="true"]'); + expect(illustration).toHaveClass('bg-amber-50'); + }); + + it('does not render action button when actionLabel or onAction is missing', () => { + render( + @@ -75,9 +165,10 @@ describe('EmptyState', () => { description="Test Description" /> ); - - const region = screen.getByRole('region'); - expect(region).toBeInTheDocument(); - expect(screen.getByText('Test Title')).toHaveAttribute('id', 'empty-state-title'); - }); -}); \ No newline at end of file + + const region = screen.getByRole('region'); + expect(region).toBeInTheDocument(); + expect(region).toHaveAccessibleName('Test Title'); + expect(screen.getByText('Test Title')).toHaveAttribute('id'); + }); +});