Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 82 additions & 36 deletions docs/components/EmptyState.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -25,21 +28,33 @@ import EmptyState from '@/components/EmptyState';
/>
```

### Empty State with Icon
```tsx
### Empty State with Icon

```tsx
import EmptyState from '@/components/EmptyState';

<EmptyState
icon={<SearchIcon className="w-16 h-16" />}
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';

<EmptyState
illustration="contracts"
title="No contracts found"
description="Start by creating your first contract."
/>
```

### Empty State with Action

```tsx
import EmptyState from '@/components/EmptyState';

<EmptyState
Expand All @@ -48,26 +63,55 @@ import EmptyState from '@/components/EmptyState';
description="You haven't created any contracts yet. Start by creating your first contract."
actionLabel="Create Contract"
onAction={() => 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';

<EmptyState
illustration="milestones"
title="No milestones tracked"
description="Track delivery and escrow release points by adding milestones."
actionLabel="Add Milestone"
onAction={() => 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

Expand All @@ -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.
Integration tests ensure the component appears correctly in empty data scenarios across the implemented views.
14 changes: 7 additions & 7 deletions src/app/contracts/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ const ContractsPage: React.FC = () => {
return (
<main className="min-h-screen p-8">
<h1 className="text-2xl font-bold mb-6">Contracts</h1>
{contracts.length === 0 ? (
<EmptyState
icon={<svg className="w-16 h-16" fill="currentColor" viewBox="0 0 20 20"><path fillRule="evenodd" d="M4 4a2 2 0 00-2 2v8a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2H4zm0 2h12v8H4V6z" clipRule="evenodd" /></svg>}
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 ? (
<EmptyState
illustration="contracts"
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"
onAction={handleCreateContract}
/>
) : (
Expand All @@ -30,4 +30,4 @@ const ContractsPage: React.FC = () => {
);
};

export default ContractsPage;
export default ContractsPage;
14 changes: 7 additions & 7 deletions src/app/milestones/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ const MilestonesPage: React.FC = () => {
return (
<main className="min-h-screen p-8">
<h1 className="text-2xl font-bold mb-6">Milestones</h1>
{milestones.length === 0 ? (
<EmptyState
icon={<svg className="w-16 h-16" fill="currentColor" viewBox="0 0 20 20"><path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" /></svg>}
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 ? (
<EmptyState
illustration="milestones"
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"
onAction={handleAddMilestone}
/>
) : (
Expand All @@ -30,4 +30,4 @@ const MilestonesPage: React.FC = () => {
);
};

export default MilestonesPage;
export default MilestonesPage;
14 changes: 7 additions & 7 deletions src/app/reputation/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ const ReputationPage: React.FC = () => {
return (
<main className="min-h-screen p-8">
<h1 className="text-2xl font-bold mb-6">Reputation</h1>
{reputation.length === 0 ? (
<EmptyState
icon={<svg className="w-16 h-16" fill="currentColor" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" /></svg>}
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 ? (
<EmptyState
illustration="reputation"
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."
/>
) : (
// TODO: Render reputation list
<div>Reputation list</div>
Expand All @@ -21,4 +21,4 @@ const ReputationPage: React.FC = () => {
);
};

export default ReputationPage;
export default ReputationPage;
163 changes: 115 additions & 48 deletions src/components/EmptyState.tsx
Original file line number Diff line number Diff line change
@@ -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<EmptyStateVariant, string> = {
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<EmptyStateVariant, React.ReactNode> = {
contracts: (
<svg className="h-16 w-16" fill="none" viewBox="0 0 64 64" aria-hidden="true">
<rect x="14" y="12" width="36" height="44" rx="6" stroke="currentColor" strokeWidth="4" />
<path d="M23 26h18M23 36h18M23 46h12" stroke="currentColor" strokeLinecap="round" strokeWidth="4" />
</svg>
),
milestones: (
<svg className="h-16 w-16" fill="none" viewBox="0 0 64 64" aria-hidden="true">
<path d="M18 18h28M18 32h28M18 46h28" stroke="currentColor" strokeLinecap="round" strokeWidth="4" />
<circle cx="18" cy="18" r="6" fill="currentColor" />
<circle cx="18" cy="32" r="6" fill="currentColor" opacity="0.75" />
<circle cx="18" cy="46" r="6" fill="currentColor" opacity="0.45" />
</svg>
),
reputation: (
<svg className="h-16 w-16" fill="none" viewBox="0 0 64 64" aria-hidden="true">
<path
d="M32 10l6.2 12.6 13.9 2-10 9.8 2.4 13.8L32 41.7 19.6 48.2l2.4-13.8-10-9.8 13.9-2L32 10z"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="4"
/>
<path d="M24 54h16" stroke="currentColor" strokeLinecap="round" strokeWidth="4" />
</svg>
),
};

const EmptyState: React.FC<EmptyStateProps> = ({
icon,
illustration,
title,
description,
actionLabel,
onAction,
secondaryActionLabel,
onSecondaryAction,
}) => {
const titleId = useId();
const renderedIllustration = illustration ? illustrations[illustration] : undefined;

return (
<div
className="flex flex-col items-center justify-center px-4 py-8 text-center sm:px-8 sm:py-10"
role="region"
aria-labelledby={titleId}
>
{(icon || renderedIllustration) && (
<div
className={`mb-5 inline-flex h-24 w-24 items-center justify-center rounded-2xl ring-1 ${
illustration ? illustrationClassNames[illustration] : 'bg-slate-50 text-slate-500 ring-slate-200'
}`}
aria-hidden="true"
>
{icon ?? renderedIllustration}
</div>
)}
<h2
id={titleId}
className="mb-2 max-w-xl text-xl font-semibold text-gray-950"
>
{title}
</h2>
<p className="mb-5 max-w-md text-sm leading-6 text-gray-700 sm:text-base">{description}</p>
{(actionLabel && onAction) || (secondaryActionLabel && onSecondaryAction) ? (
<div className="flex w-full max-w-md flex-col items-stretch justify-center gap-3 sm:w-auto sm:flex-row">
{actionLabel && onAction && (
<button
type="button"
onClick={onAction}
className="rounded-md bg-blue-700 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-blue-800 focus-visible:outline focus-visible:outline-4 focus-visible:outline-offset-2 focus-visible:outline-blue-900"
aria-label={actionLabel}
>
{actionLabel}
</button>
)}
{secondaryActionLabel && onSecondaryAction && (
<button
type="button"
onClick={onSecondaryAction}
className="rounded-md border border-gray-400 bg-white px-4 py-2.5 text-sm font-semibold text-gray-950 transition-colors hover:border-gray-600 hover:bg-gray-50 focus-visible:outline focus-visible:outline-4 focus-visible:outline-offset-2 focus-visible:outline-blue-900"
aria-label={secondaryActionLabel}
>
{secondaryActionLabel}
</button>
)}
</div>
) : null}
</div>
);
};

interface EmptyStateProps {
icon?: React.ReactNode;
title: string;
description: string;
actionLabel?: string;
onAction?: () => void;
}

const EmptyState: React.FC<EmptyStateProps> = ({
icon,
title,
description,
actionLabel,
onAction,
}) => {
return (
<div
className="flex flex-col items-center justify-center p-8 text-center"
role="region"
aria-labelledby="empty-state-title"
>
{icon && (
<div className="mb-4 text-gray-400" aria-hidden="true">
{icon}
</div>
)}
<h2
id="empty-state-title"
className="text-xl font-semibold mb-2 text-gray-900"
>
{title}
</h2>
<p className="text-gray-600 mb-4 max-w-md">{description}</p>
{actionLabel && onAction && (
<button
onClick={onAction}
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md transition-colors"
aria-label={actionLabel}
>
{actionLabel}
</button>
)}
</div>
);
};

export default EmptyState;
export default EmptyState;
Loading
Loading