From 7d94fe8a57e391e700db88bbf3cf030e5885d823 Mon Sep 17 00:00:00 2001 From: ayomidearegbeshola29-dev Date: Mon, 29 Jun 2026 17:12:00 +0000 Subject: [PATCH] feat(timeline): add expandable attestation evidence detail panel Each row becomes an accessible disclosure revealing full evidence, signer, status badge, and a copyable tx hash, with reduced-motion-safe transitions. --- ATTESTATION-EVIDENCE-DETAIL-PANEL.md | 116 ++++++++++++++++ src/components/ActivityTimeline.css | 54 ++++---- src/components/ActivityTimeline.test.tsx | 166 +++++++++++++++++++++-- src/components/ActivityTimeline.tsx | 130 +++++++++++++----- src/components/CopyableHash.css | 48 +++++++ src/components/CopyableHash.test.tsx | 36 +++++ src/components/CopyableHash.tsx | 77 +++++++++++ 7 files changed, 558 insertions(+), 69 deletions(-) create mode 100644 ATTESTATION-EVIDENCE-DETAIL-PANEL.md create mode 100644 src/components/CopyableHash.css create mode 100644 src/components/CopyableHash.test.tsx create mode 100644 src/components/CopyableHash.tsx diff --git a/ATTESTATION-EVIDENCE-DETAIL-PANEL.md b/ATTESTATION-EVIDENCE-DETAIL-PANEL.md new file mode 100644 index 0000000..b8681e1 --- /dev/null +++ b/ATTESTATION-EVIDENCE-DETAIL-PANEL.md @@ -0,0 +1,116 @@ +# Attestation Evidence Detail Panel Implementation + +This document describes the implementation of the expandable attestation-evidence detail panel for the ActivityTimeline component. + +## Closes #451 + +## Overview + +The ActivityTimeline component renders attestation/verification events as compact rows. This implementation adds an expandable detail panel that reveals full evidence information when a row is activated. + +## Implementation Details + +### Component Structure + +``` +ActivityTimeline +├── ActivityItem[] (items prop) +│ ├── id, timestamp, title, description, actor, statusLabel, tone, meta +├── toneToBadgeVariant(tone) → BadgeVariant +├── isTxHash(meta) → boolean +└── Disclosure Pattern + ├── Button trigger (aria-expanded/aria-controls) + └── Detail panel (hidden attribute) + ├── Actor (text) + └── Meta (CopyableHash if tx hash, else plain text) +``` + +### Tone to Badge Variant Mapping + +| ActivityTone | BadgeVariant | Visual Meaning | +|-------------|-------------|----------------| +| success | active | Green state | +| warning | grace-period | Amber warning | +| info | locked | Blue informational | + +### Transaction Hash Detection + +Meta strings matching the pattern `/^Tx\s+0x/i` are rendered as CopyableHash components with copy-to-clipboard functionality. All other meta values render as plain text. + +## Accessibility Features + +### Disclosure Pattern + +The implementation follows the TierLadder disclosure pattern with: + +- **aria-expanded**: Toggle state on the disclosure button +- **aria-controls**: References the panel ID +- **hidden attribute**: Controls panel visibility + +### Keyboard Support + +| Key | Action | +|-----|--------| +| Enter | Toggles panel visibility | +| Space | Toggles panel visibility | +| Escape | Closes panel, returns focus to trigger | + +### Focus Management + +- On panel expansion: Trigger button retains focus +- On Escape key: Panel closes and focus returns to trigger button +- Focus indicator: 2px solid outline using `--credence-color-primary` + +## Reduced Motion Support + +The component respects `prefers-reduced-motion` via CSS: + +```css +@media (prefers-reduced-motion: reduce) { + .activity-row__detail-panel { + transition: none; + } +} +``` + +Note: The current implementation uses the `hidden` attribute for instant show/hide behavior, which inherently respects reduced motion preferences. + +## Files Changed + +### New Files +- `src/components/CopyableHash.tsx` - Reusable hash display with copy functionality +- `src/components/CopyableHash.css` - Styles for CopyableHash + +### Modified Files +- `src/components/ActivityTimeline.tsx` - Core implementation +- `src/components/ActivityTimeline.css` - Panel and disclosure styles +- `src/components/ActivityTimeline.test.tsx` - Test coverage + +## Test Coverage + +### Helper Functions +- `toneToBadgeVariant` - Maps all three tone values to correct badge variants +- `isTxHash` - Detects tx hash patterns correctly + +### Disclosure Interaction +- Button renders with `aria-expanded="false"` initially +- Button has correct `aria-controls` reference +- Click expands panel and sets `aria-expanded="true"` +- Click on expanded button collapses panel +- Enter key toggles panel +- Space key toggles panel +- Escape key closes panel and returns focus + +### Meta Rendering +- Tx hash meta renders via CopyableHash component +- Non-tx meta renders as plain text + +## Branch + +`feat/attestation-evidence-detail` + +## PR + +https://github.com/CredenceOrg/Credence-Frontend/pull/494 + +Closes #451 \ No newline at end of file diff --git a/src/components/ActivityTimeline.css b/src/components/ActivityTimeline.css index 27716d0..8639323 100644 --- a/src/components/ActivityTimeline.css +++ b/src/components/ActivityTimeline.css @@ -121,49 +121,55 @@ font-weight: var(--credence-font-weight-semibold); } -.activity-row__status { - display: inline-flex; - align-items: center; - padding: var(--credence-space-1) var(--credence-space-2); - border-radius: var(--credence-radius-full); - font-size: var(--credence-font-size-xs); - line-height: var(--credence-line-height-tight); - font-weight: var(--credence-font-weight-semibold); +.activity-row__disclosure { + background: none; + border: none; + padding: 0; + margin-top: var(--credence-space-2); + color: var(--credence-text-secondary); + cursor: pointer; + font-size: var(--credence-font-size-sm); + text-decoration: underline; + text-align: left; } -.activity-row__status--success { - background: var(--credence-color-success-surface); - color: var(--credence-color-success-text); +.activity-row__disclosure:focus-visible { + outline: 2px solid var(--credence-color-primary); + outline-offset: 2px; } -.activity-row__status--warning { - background: var(--credence-color-warning-surface); - color: var(--credence-color-warning-text); +.activity-row__detail-panel { + margin-top: var(--credence-space-3); + padding: var(--credence-space-3); + background: var(--credence-surface-hover); + border-radius: var(--credence-radius-md); } -.activity-row__status--info { - background: var(--credence-color-info-surface); - color: var(--credence-color-info-text); +.activity-row__detail-panel[hidden] { + display: none; } .activity-row__description, -.activity-row__actor, -.activity-row__meta { +.activity-row__actor { font-size: var(--credence-font-size-xs); line-height: var(--credence-line-height-base); -} - -.activity-row__description, -.activity-row__actor { color: var(--credence-text-secondary); } .activity-row__meta { + font-size: var(--credence-font-size-xs); + line-height: var(--credence-line-height-base); color: var(--credence-text-secondary); white-space: nowrap; padding-top: var(--credence-space-1); } +@media (prefers-reduced-motion: reduce) { + .activity-row__detail-panel { + transition: none; + } +} + @media (max-width: 767px) { .activity-surface { padding: var(--credence-space-4); @@ -182,4 +188,4 @@ .activity-row__meta { padding-top: 0; } -} +} \ No newline at end of file diff --git a/src/components/ActivityTimeline.test.tsx b/src/components/ActivityTimeline.test.tsx index 9555f1d..3b64c90 100644 --- a/src/components/ActivityTimeline.test.tsx +++ b/src/components/ActivityTimeline.test.tsx @@ -1,6 +1,7 @@ -import { describe, it, expect } from 'vitest' -import { render, screen } from '@testing-library/react' -import ActivityTimeline, { ActivityItem } from './ActivityTimeline' +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import ActivityTimeline, { ActivityItem, toneToBadgeVariant, isTxHash } from './ActivityTimeline' const makeItem = (overrides: Partial = {}): ActivityItem => ({ id: 'test-1', @@ -14,6 +15,46 @@ const makeItem = (overrides: Partial = {}): ActivityItem => ({ ...overrides, }) +// Mock CopyableHash to avoid clipboard complexity in tests +vi.mock('./CopyableHash', () => ({ + default: ({ hash }: { hash: string }) => ( + {hash} + ), +})) + +// Mock Badge to test variant mapping +vi.mock('./Badge', () => ({ + default: ({ variant, label }: { variant: string; label?: string }) => ( + {label || variant} + ), +})) + +describe('toneToBadgeVariant', () => { + it.each([ + ['success', 'active'], + ['warning', 'grace-period'], + ['info', 'locked'], + ] as const)( + 'maps tone "%s" to Badge variant "%s"', + (tone, expectedVariant) => { + expect(toneToBadgeVariant(tone)).toBe(expectedVariant) + } + ) +}) + +describe('isTxHash', () => { + it.each([ + ['Tx 0x93a1...22f4', true], + ['tx 0x1234...5678', true], + ['Tx 0xabc', true], + ['Rule AV-17', false], + ['Window +90d', false], + ['some other meta', false], + ])('correctly identifies "%s" as %s', (meta, expected) => { + expect(isTxHash(meta)).toBe(expected) + }) +}) + describe('ActivityTimeline', () => { describe('default (no props)', () => { it('renders the section with the correct aria-label', () => { @@ -112,13 +153,21 @@ describe('ActivityTimeline', () => { describe('tone classes', () => { it.each(['success', 'warning', 'info'] as const)( - 'applies tone class "%s" to node and status pill', + 'applies tone class "%s" to node', (tone) => { const { container } = render( ) expect(container.querySelector(`.activity-row__node--${tone}`)).not.toBeNull() - expect(container.querySelector(`.activity-row__status--${tone}`)).not.toBeNull() + } + ) + + it.each(['success', 'warning', 'info'] as const)( + 'renders Badge with correct variant for tone "%s"', + (tone) => { + render() + const expectedVariant = toneToBadgeVariant(tone) + expect(screen.getByTestId(`badge-${expectedVariant}`)).toBeInTheDocument() } ) }) @@ -134,10 +183,109 @@ describe('ActivityTimeline', () => { const { container } = render() expect(container.querySelector('.activity-row__rail')).toHaveAttribute('aria-hidden', 'true') }) + }) + + describe('disclosure interaction', () => { + it('renders disclosure button in collapsed state with aria-expanded="false"', () => { + render() + const button = screen.getByRole('button', { name: /show details/i }) + expect(button).toHaveAttribute('aria-expanded', 'false') + }) + + it('renders disclosure button with aria-controls pointing to panel', () => { + render() + const button = screen.getByRole('button', { name: /show details/i }) + expect(button).toHaveAttribute('aria-controls', 'details-test-item') + }) + + it('expands panel and sets aria-expanded="true" on click', async () => { + const user = userEvent.setup() + render() + + const button = screen.getByRole('button', { name: /show details/i }) + await user.click(button) + + expect(button).toHaveAttribute('aria-expanded', 'true') + expect(screen.getByText('Actor:')).toBeInTheDocument() + expect(screen.getByText('Meta:')).toBeInTheDocument() + }) + + it('collapses panel and sets aria-expanded="false" on second click', async () => { + const user = userEvent.setup() + render() + + const button = screen.getByRole('button', { name: /show details/i }) + await user.click(button) + await user.click(button) + + expect(button).toHaveAttribute('aria-expanded', 'false') + // Panel should have hidden attribute when collapsed + const panel = document.getElementById('details-test-item') + expect(panel).toHaveAttribute('hidden') + }) + + it('toggles panel visibility via Enter key', async () => { + const user = userEvent.setup() + render() + + const button = screen.getByRole('button', { name: /show details/i }) + button.focus() + await user.keyboard('{Enter}') + + expect(button).toHaveAttribute('aria-expanded', 'true') + }) + + it('toggles panel visibility via Space key', async () => { + const user = userEvent.setup() + render() + + const button = screen.getByRole('button', { name: /show details/i }) + button.focus() + await user.keyboard(' ') + + expect(button).toHaveAttribute('aria-expanded', 'true') + }) + + it('closes panel via Escape key and returns focus to trigger', async () => { + const user = userEvent.setup() + render() + + const button = screen.getByRole('button', { name: /show details/i }) + await user.click(button) + + const panel = document.getElementById('details-test-item') + expect(panel).toBeInTheDocument() + expect(panel).not.toHaveAttribute('hidden') + + // Escape should close the panel - fire on panel element + fireEvent.keyDown(panel!, { key: 'Escape' }) + + expect(button).toHaveAttribute('aria-expanded', 'false') + expect(button).toHaveFocus() + }) + }) + + describe('meta rendering', () => { + it('renders tx hash meta via CopyableHash component', async () => { + const user = userEvent.setup() + render() + + const button = screen.getByRole('button', { name: /show details/i }) + await user.click(button) + + expect(screen.getByTestId('copyable-hash')).toBeInTheDocument() + expect(screen.getByTestId('copyable-hash').textContent).toBe('Tx 0x93a1...22f4') + }) - it('renders actor prefixed with "By"', () => { - render() - expect(screen.getByText('By Node 99')).toBeInTheDocument() + it('renders non-tx meta as plain text', async () => { + const user = userEvent.setup() + render() + + const button = screen.getByRole('button', { name: /show details/i }) + await user.click(button) + + expect(screen.getByText('Rule AV-17')).toBeInTheDocument() + expect(screen.queryByTestId('copyable-hash')).toBeNull() }) }) -}) +}) \ No newline at end of file diff --git a/src/components/ActivityTimeline.tsx b/src/components/ActivityTimeline.tsx index cc1b2da..b10e4cb 100644 --- a/src/components/ActivityTimeline.tsx +++ b/src/components/ActivityTimeline.tsx @@ -1,9 +1,32 @@ -import { useState } from 'react' +import { useState, useRef, useCallback } from 'react' +import Badge, { type BadgeVariant } from './Badge' +import CopyableHash from './CopyableHash' import './ActivityTimeline.css' import EmptyState from './states/EmptyState' export type ActivityTone = 'success' | 'warning' | 'info' +/** + * Maps ActivityTimeline tone values to Badge variants. + * Tones represent attestation status severity levels. + */ +export function toneToBadgeVariant(tone: ActivityTone): BadgeVariant { + const mapping: Record = { + success: 'active', + warning: 'grace-period', + info: 'locked', + } + return mapping[tone] +} + +/** + * Detects if meta string represents a transaction hash. + * Returns true if meta starts with "Tx 0x" pattern. + */ +export function isTxHash(meta: string): boolean { + return /^Tx\s+0x/i.test(meta) +} + export interface ActivityItem { id: string timestamp: string @@ -56,17 +79,50 @@ export const SAMPLE_ACTIVITY: ActivityItem[] = [ export const ACTIVITY_ITEMS: ActivityItem[] = SAMPLE_ACTIVITY +/** + * Attestation evidence detail panel component. + * Displays full evidence details including actor, status badge, and meta. + * + * Implements accessible disclosure pattern with: + * - aria-expanded/aria-controls wiring + * - Enter/Space toggle activation + * - Escape key to close and return focus + * - Focus management on open/close + */ export default function ActivityTimeline({ compact = false, items = ACTIVITY_ITEMS, }: ActivityTimelineProps) { const [expandedId, setExpandedId] = useState(null) + const triggerRefs = useRef>(new Map()) + const expandedIdRef = useRef(null) + const count = items.length const summary = `${count} recent ${count === 1 ? 'event' : 'events'}` - const toggleExpand = (id: string) => { + // Keep the ref in sync with state + expandedIdRef.current = expandedId + + const closePanel = useCallback(() => { + setExpandedId(null) + }, []) + + const toggleExpand = useCallback((id: string) => { setExpandedId((prev) => (prev === id ? null : id)) - } + }, []) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Escape' && expandedIdRef.current !== null) { + const triggerElement = triggerRefs.current.get(expandedIdRef.current) + closePanel() + if (triggerElement) { + triggerElement.focus() + } + } + }, + [closePanel] + ) return (
{items.map((item) => { const isExpanded = expandedId === item.id + const panelId = `details-${item.id}` + const buttonId = `trigger-${item.id}` return (
  • ) @@ -155,4 +213,4 @@ export default function ActivityTimeline({ )}
    ) -} +} \ No newline at end of file diff --git a/src/components/CopyableHash.css b/src/components/CopyableHash.css new file mode 100644 index 0000000..788b3fd --- /dev/null +++ b/src/components/CopyableHash.css @@ -0,0 +1,48 @@ +.copyable-hash { + display: inline-flex; + align-items: center; + gap: var(--credence-space-2); + font-family: inherit; +} + +.copyable-hash__value { + font-family: 'Courier New', Courier, monospace; + font-size: var(--credence-font-size-xs); + background: var(--credence-surface-input); + padding: var(--credence-space-1) var(--credence-space-2); + border-radius: var(--credence-radius-sm); +} + +.copyable-hash__button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--credence-space-1); + padding: var(--credence-space-1); + border: 1px solid var(--credence-border-default); + border-radius: var(--credence-radius-sm); + background: var(--credence-surface-card); + color: var(--credence-text-secondary); + cursor: pointer; + transition: background-color var(--credence-motion-fast), color var(--credence-motion-fast); +} + +.copyable-hash__button:hover { + background: var(--credence-surface-hover); +} + +.copyable-hash__button:focus-visible { + outline: 2px solid var(--credence-color-primary); + outline-offset: 2px; +} + +.copyable-hash__feedback { + font-size: var(--credence-font-size-xs); + color: var(--credence-color-success-text); +} + +@media (prefers-reduced-motion: reduce) { + .copyable-hash__button { + transition: none; + } +} \ No newline at end of file diff --git a/src/components/CopyableHash.test.tsx b/src/components/CopyableHash.test.tsx new file mode 100644 index 0000000..fee66f3 --- /dev/null +++ b/src/components/CopyableHash.test.tsx @@ -0,0 +1,36 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import CopyableHash from './CopyableHash' + +// Mock useCopyToClipboard hook +vi.mock('@/hooks/useCopyToClipboard', () => ({ + default: vi.fn(() => ({ + copy: vi.fn().mockResolvedValue(true), + copied: false, + reset: vi.fn(), + })), +})) + +describe('CopyableHash', () => { + it('renders the hash value in a code element', () => { + render() + expect(screen.getByText('0xabc123')).toBeInTheDocument() + }) + + it('renders a copy button', () => { + render() + const button = screen.getByRole('button', { name: /copy hash to clipboard/i }) + expect(button).toBeInTheDocument() + }) + + it('renders with custom aria-label when provided', () => { + render() + const button = screen.getByRole('button', { name: /copy transaction hash/i }) + expect(button).toBeInTheDocument() + }) + + it('applies custom className when provided', () => { + const { container } = render() + expect(container.querySelector('.copyable-hash')).toHaveClass('custom-class') + }) +}) \ No newline at end of file diff --git a/src/components/CopyableHash.tsx b/src/components/CopyableHash.tsx new file mode 100644 index 0000000..eed5a64 --- /dev/null +++ b/src/components/CopyableHash.tsx @@ -0,0 +1,77 @@ +import { useCallback, useRef } from 'react' +import useCopyToClipboard from '@/hooks/useCopyToClipboard' + +export interface CopyableHashProps { + hash: string + /** Optional class name for styling. */ + className?: string + /** Aria label for the copy button. */ + copyLabel?: string +} + +export default function CopyableHash({ hash, className = '', copyLabel = 'Copy hash to clipboard' }: CopyableHashProps) { + const { copy, copied } = useCopyToClipboard() + const buttonRef = useRef(null) + + const handleCopy = useCallback(async () => { + await copy(hash) + }, [copy, hash]) + + return ( + + {hash} + + + ) +} \ No newline at end of file