diff --git a/docs/components/ActionPanel.md b/docs/components/ActionPanel.md new file mode 100644 index 0000000..ef5f87b --- /dev/null +++ b/docs/components/ActionPanel.md @@ -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>` | 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. diff --git a/docs/components/ContractDetail.md b/docs/components/ContractDetail.md index 942706e..1d58aa4 100644 --- a/docs/components/ContractDetail.md +++ b/docs/components/ContractDetail.md @@ -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>` +- `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 @@ -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. diff --git a/src/components/ActionPanel.tsx b/src/components/ActionPanel.tsx index 8cba46c..5bfc5f7 100644 --- a/src/components/ActionPanel.tsx +++ b/src/components/ActionPanel.tsx @@ -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 ( - - ); -}; +'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>; + 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 = { + 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 = { + 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 +) => { + const handlerMap: Record 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 ( + + ); +}; export default ActionPanel; diff --git a/src/components/__tests__/ActionPanel.test.tsx b/src/components/__tests__/ActionPanel.test.tsx index 620ebe5..bc06250 100644 --- a/src/components/__tests__/ActionPanel.test.tsx +++ b/src/components/__tests__/ActionPanel.test.tsx @@ -1,8 +1,8 @@ -import React from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; -import ActionPanel from '../ActionPanel'; - -describe('ActionPanel', () => { +import React from 'react'; +import { render, screen, fireEvent, within } from '@testing-library/react'; +import ActionPanel from '../ActionPanel'; + +describe('ActionPanel', () => { it('renders Active actions when status is Active', () => { const onSubmitMilestone = jest.fn(); const onReleaseFunds = jest.fn(); @@ -26,13 +26,99 @@ describe('ActionPanel', () => { fireEvent.click(screen.getByRole('button', { name: /Dispute/i })); expect(onSubmitMilestone).toHaveBeenCalledTimes(1); - expect(onReleaseFunds).toHaveBeenCalledTimes(1); - expect(onDispute).toHaveBeenCalledTimes(1); - }); - - it('renders View Summary action for Completed status', () => { - const onViewSummary = jest.fn(); - render(); + expect(onReleaseFunds).toHaveBeenCalledTimes(1); + expect(onDispute).toHaveBeenCalledTimes(1); + }); + + it('keeps actions in a logical keyboard tab order with visible focus rings', () => { + render( + + ); + + const panel = screen.getByRole('complementary', { name: /what would you like to do/i }); + const buttons = within(panel).getAllByRole('button'); + + expect(buttons.map((button) => button.textContent)).toEqual([ + 'Submit Milestone', + 'Release Funds', + 'Dispute', + ]); + + buttons.forEach((button) => { + expect(button).not.toHaveAttribute('tabindex', '-1'); + expect(button).toHaveClass('focus-visible:outline'); + expect(button).toHaveClass('focus-visible:outline-4'); + expect(button).toHaveClass('focus-visible:outline-offset-2'); + button.focus(); + expect(button).toHaveFocus(); + }); + }); + + it('renders unavailable actions as disabled controls with accessible reasons', () => { + const onDispute = jest.fn(); + + render( + + ); + + const releaseFunds = screen.getByRole('button', { name: /release funds to the contractor/i }); + const dispute = screen.getByRole('button', { name: /open a dispute/i }); + + expect(releaseFunds).toBeDisabled(); + expect(releaseFunds).toHaveAccessibleDescription( + 'Connect a wallet with client permissions to release funds.' + ); + + fireEvent.click(releaseFunds); + fireEvent.click(dispute); + + expect(onDispute).toHaveBeenCalledTimes(1); + }); + + it('disables visible actions while loading contract data', () => { + const onSubmitMilestone = jest.fn(); + + render(); + + const buttons = screen.getAllByRole('button'); + + expect(buttons).toHaveLength(3); + buttons.forEach((button) => { + expect(button).toBeDisabled(); + expect(button).toHaveAccessibleDescription('Action is disabled while contract data is loading.'); + }); + + fireEvent.click(screen.getByRole('button', { name: /submit milestone for approval/i })); + expect(onSubmitMilestone).not.toHaveBeenCalled(); + }); + + it('announces action panel errors without changing keyboard order', () => { + render( + + ); + + expect(screen.getByRole('alert')).toHaveTextContent('Network is slow. Try again in a moment.'); + expect(screen.getAllByRole('button').map((button) => button.textContent)).toEqual(['Dispute']); + }); + + it('renders View Summary action for Completed status', () => { + const onViewSummary = jest.fn(); + render(); expect(screen.getByRole('button', { name: /View contract summary details/i })).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: /View contract summary details/i }));