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
44 changes: 44 additions & 0 deletions docs/components/ActionPanel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# ActionPanel Component

`ActionPanel` renders the contract actions available from the escrow detail page. The component is intentionally built from native `button` controls so actions remain reachable and operable by keyboard without custom key handling.

## Props

| Prop | Type | Required | Description |
|------|------|----------|-------------|
| `status` | `'Active' \| 'Completed' \| 'Disputed' \| 'Pending'` | Yes | Determines which actions are shown and their tab order. |
| `onSubmitMilestone` | `() => void` | No | Callback for submitting milestone work for approval. |
| `onDispute` | `() => void` | No | Callback for opening the dispute flow. |
| `onReleaseFunds` | `() => void` | No | Callback for releasing escrow funds. |
| `onViewSummary` | `() => void` | No | Callback for viewing the completed contract summary. |
| `disabledReasons` | `Partial<Record<ActionKey, string>>` | No | Disables a specific visible action and exposes the reason through `aria-describedby`. |
| `errorMessage` | `string` | No | Announces transient API or network errors with `role="alert"`. |
| `isLoading` | `boolean` | No | Disables all visible actions while contract or wallet state is loading. |

## Accessibility

- Buttons use browser-native keyboard support for `Tab`, `Enter`, and `Space`.
- Visible focus rings use high-contrast Tailwind `focus-visible:outline` utilities and are not removed in any state.
- Actions are rendered in contract workflow order: submit milestone, release funds, dispute, then summary when applicable.
- Unavailable actions stay visible as disabled buttons with an accessible reason. Use `disabledReasons` for states such as no wallet, missing permissions, pending API responses, or unmet milestone conditions.
- Loading states disable all visible actions and describe that contract data is still loading.
- Error states are announced through `role="alert"` without moving focus or changing the action order.

## Status Mapping

| Status | Visible actions |
|--------|-----------------|
| `Active` | Submit Milestone, Release Funds, Dispute |
| `Pending` | Release Funds, Dispute |
| `Disputed` | Dispute |
| `Completed` | View Summary |

## Testing Notes

The component tests cover:

- Action rendering and callback behavior for active and completed contracts.
- Logical button order for keyboard navigation.
- Visible focus ring classes on every enabled action.
- Disabled action semantics and screen-reader descriptions.
- Loading, slow-network error, and missing-handler edge cases.
17 changes: 10 additions & 7 deletions docs/components/ContractDetail.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,14 @@ Description: Renders a scrollable milestone roster, each showing the title, due
Props:
- `status: 'Active' | 'Completed' | 'Disputed' | 'Pending'`
- `onSubmitMilestone?: () => void`
- `onDispute?: () => void`
- `onReleaseFunds?: () => void`
- `onViewSummary?: () => void`

Description: Chooses appropriate action buttons based on the current contract status.
- `onDispute?: () => void`
- `onReleaseFunds?: () => void`
- `onViewSummary?: () => void`
- `disabledReasons?: Partial<Record<ActionKey, string>>`
- `errorMessage?: string`
- `isLoading?: boolean`

Description: Chooses appropriate action buttons based on the current contract status. See `docs/components/ActionPanel.md` for keyboard support, disabled-state reasons, loading, and error guidance.

## Adding a new action type

Expand All @@ -51,5 +54,5 @@ The contract detail page uses a responsive grid:
## Accessibility

- Status badges use high contrast color combinations.
- Buttons include descriptive `aria-label` attributes.
- Section headers use semantic landmarks and visible labels.
- Buttons include descriptive `aria-label` attributes, visible focus rings, and disabled-state descriptions.
- Section headers use semantic landmarks and visible labels.
247 changes: 160 additions & 87 deletions src/components/ActionPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,89 +1,162 @@
'use client';

export type ActionPanelProps = {
status: 'Active' | 'Completed' | 'Disputed' | 'Pending';
onSubmitMilestone?: () => void;
onDispute?: () => void;
onReleaseFunds?: () => void;
onViewSummary?: () => void;
};

const getActionButtons = (status: ActionPanelProps['status']) => {
if (status === 'Active') {
return ['Submit Milestone', 'Release Funds', 'Dispute'];
}
if (status === 'Pending') {
return ['Release Funds', 'Dispute'];
}
if (status === 'Disputed') {
return ['Dispute'];
}
return ['View Summary'];
};

const ActionPanel = ({
status,
onSubmitMilestone,
onDispute,
onReleaseFunds,
onViewSummary,
}: ActionPanelProps) => {
const actions = getActionButtons(status);

return (
<aside className="sticky top-6 rounded-3xl border border-slate-200 bg-white p-6 shadow-sm">
<div className="mb-6">
<p className="text-sm text-slate-500 uppercase tracking-[0.24em]">Action Panel</p>
<h2 className="mt-2 text-xl font-semibold text-slate-900">What would you like to do?</h2>
</div>

<div className="space-y-3">
{actions.includes('Submit Milestone') && onSubmitMilestone && (
<button
type="button"
onClick={onSubmitMilestone}
aria-label="Submit milestone for approval"
className="w-full rounded-2xl bg-blue-600 px-4 py-3 text-sm font-semibold text-white transition hover:bg-blue-700"
>
Submit Milestone
</button>
)}

{actions.includes('Release Funds') && onReleaseFunds && (
<button
type="button"
onClick={onReleaseFunds}
aria-label="Release funds to the contractor"
className="w-full rounded-2xl border border-slate-300 bg-white px-4 py-3 text-sm font-semibold text-slate-900 transition hover:border-slate-400"
>
Release Funds
</button>
)}

{actions.includes('Dispute') && onDispute && (
<button
type="button"
onClick={onDispute}
aria-label="Open a dispute for this contract"
className="w-full rounded-2xl bg-rose-600 px-4 py-3 text-sm font-semibold text-white transition hover:bg-rose-700"
>
Dispute
</button>
)}

{actions.includes('View Summary') && onViewSummary && (
<button
type="button"
onClick={onViewSummary}
aria-label="View contract summary details"
className="w-full rounded-2xl border border-slate-300 bg-white px-4 py-3 text-sm font-semibold text-slate-900 transition hover:border-slate-400"
>
View Summary
</button>
)}
</div>
</aside>
);
};
'use client';

import { useId } from 'react';

export type ActionKey = 'submitMilestone' | 'releaseFunds' | 'dispute' | 'viewSummary';

type ActionConfig = {
key: ActionKey;
label: string;
ariaLabel: string;
intent: 'primary' | 'secondary' | 'danger';
};

export type ActionPanelProps = {
status: 'Active' | 'Completed' | 'Disputed' | 'Pending';
onSubmitMilestone?: () => void;
onDispute?: () => void;
onReleaseFunds?: () => void;
onViewSummary?: () => void;
disabledReasons?: Partial<Record<ActionKey, string>>;
errorMessage?: string;
isLoading?: boolean;
};

const getActionButtons = (status: ActionPanelProps['status']) => {
if (status === 'Active') {
return ['submitMilestone', 'releaseFunds', 'dispute'] as ActionKey[];
}
if (status === 'Pending') {
return ['releaseFunds', 'dispute'] as ActionKey[];
}
if (status === 'Disputed') {
return ['dispute'] as ActionKey[];
}
return ['viewSummary'] as ActionKey[];
};

const actionConfig: Record<ActionKey, ActionConfig> = {
submitMilestone: {
key: 'submitMilestone',
label: 'Submit Milestone',
ariaLabel: 'Submit milestone for approval',
intent: 'primary',
},
releaseFunds: {
key: 'releaseFunds',
label: 'Release Funds',
ariaLabel: 'Release funds to the contractor',
intent: 'secondary',
},
dispute: {
key: 'dispute',
label: 'Dispute',
ariaLabel: 'Open a dispute for this contract',
intent: 'danger',
},
viewSummary: {
key: 'viewSummary',
label: 'View Summary',
ariaLabel: 'View contract summary details',
intent: 'secondary',
},
};

const actionClassNames: Record<ActionConfig['intent'], string> = {
primary:
'bg-blue-700 text-white hover:bg-blue-800 focus-visible:outline-blue-900 disabled:bg-blue-200 disabled:text-blue-950',
secondary:
'border border-slate-400 bg-white text-slate-950 hover:border-slate-600 focus-visible:outline-blue-900 disabled:border-slate-200 disabled:bg-slate-100 disabled:text-slate-500',
danger:
'bg-rose-700 text-white hover:bg-rose-800 focus-visible:outline-rose-950 disabled:bg-rose-200 disabled:text-rose-950',
};

const getActionHandler = (
key: ActionKey,
handlers: Pick<ActionPanelProps, 'onSubmitMilestone' | 'onDispute' | 'onReleaseFunds' | 'onViewSummary'>
) => {
const handlerMap: Record<ActionKey, (() => void) | undefined> = {
submitMilestone: handlers.onSubmitMilestone,
releaseFunds: handlers.onReleaseFunds,
dispute: handlers.onDispute,
viewSummary: handlers.onViewSummary,
};

return handlerMap[key];
};

const ActionPanel = ({
status,
onSubmitMilestone,
onDispute,
onReleaseFunds,
onViewSummary,
disabledReasons = {},
errorMessage,
isLoading = false,
}: ActionPanelProps) => {
const actions = getActionButtons(status);
const panelId = useId();
const headingId = `${panelId}-heading`;

return (
<aside
aria-labelledby={headingId}
className="sticky top-6 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm sm:p-6"
>
<div className="mb-6">
<p className="text-sm text-slate-500 uppercase tracking-[0.24em]">Action Panel</p>
<h2 id={headingId} className="mt-2 text-xl font-semibold text-slate-900">
What would you like to do?
</h2>
</div>

{errorMessage && (
<p
role="alert"
className="mb-4 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-sm font-medium text-rose-900"
>
{errorMessage}
</p>
)}

<div className="space-y-3">
{actions.map((actionKey) => {
const action = actionConfig[actionKey];
const handler = getActionHandler(actionKey, {
onSubmitMilestone,
onDispute,
onReleaseFunds,
onViewSummary,
});
const unavailableReason = disabledReasons[actionKey] ?? 'This action is unavailable right now.';
const disabledReason = isLoading ? 'Action is disabled while contract data is loading.' : unavailableReason;
const isDisabled = isLoading || !handler || Boolean(disabledReasons[actionKey]);
const descriptionId = isDisabled ? `${panelId}-${action.key}-reason` : undefined;

return (
<div key={action.key}>
<button
type="button"
onClick={handler}
disabled={isDisabled}
aria-label={action.ariaLabel}
aria-describedby={descriptionId}
className={`w-full rounded-xl px-4 py-3 text-sm font-semibold transition focus-visible:outline focus-visible:outline-4 focus-visible:outline-offset-2 disabled:cursor-not-allowed ${actionClassNames[action.intent]}`}
>
{action.label}
</button>
{descriptionId && (
<p id={descriptionId} className="sr-only">
{disabledReason}
</p>
)}
</div>
);
})}
</div>
</aside>
);
};

export default ActionPanel;
Loading
Loading