forked from CredenceOrg/Credence-Frontend
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathActivityTimeline.tsx
More file actions
198 lines (182 loc) · 6.41 KB
/
Copy pathActivityTimeline.tsx
File metadata and controls
198 lines (182 loc) · 6.41 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
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<ActivityTone, BadgeVariant> = {
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
title: string
description: string
actor: string
statusLabel: string
tone: ActivityTone
meta: string
}
export interface ActivityTimelineProps {
compact?: boolean
items?: ActivityItem[]
}
export const SAMPLE_ACTIVITY: ActivityItem[] = [
{
id: 'evt-001',
timestamp: 'Apr 28, 14:22 UTC',
title: 'Attestation submitted',
description: 'Identity evidence package uploaded and signed for review.',
actor: 'Validator Node 12',
statusLabel: 'Accepted',
tone: 'success',
meta: 'Tx 0x93a1...22f4',
},
{
id: 'evt-002',
timestamp: 'Apr 27, 09:48 UTC',
title: 'Proof mismatch detected',
description: 'Signature payload differed from expected checksum for one field.',
actor: 'Automated Verifier',
statusLabel: 'Needs update',
tone: 'warning',
meta: 'Rule AV-17',
},
{
id: 'evt-003',
timestamp: 'Apr 26, 20:11 UTC',
title: 'Credential refreshed',
description: 'Expiration window extended after successful periodic check.',
actor: 'System process',
statusLabel: 'In review',
tone: 'info',
meta: 'Window +90d',
},
]
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<string | null>(null)
const count = items.length
const summary = `${count} recent ${count === 1 ? 'event' : 'events'}`
const toggleExpand = useCallback((id: string) => {
setExpandedId((prev) => (prev === id ? null : id))
}, [])
return (
<section
className={`activity-surface${compact ? ' activity-surface--compact' : ''}`}
aria-label="Activity and attestations"
onKeyDown={handleKeyDown}
>
<header className="activity-surface__header">
<div>
<p className="activity-surface__eyebrow">Activity Surface Concept</p>
<h2 className="activity-surface__title">Attestation timeline</h2>
</div>
{count > 0 && <p className="activity-surface__summary">{summary}</p>}
</header>
{count === 0 ? (
<EmptyState
illustration="activity"
title="No activity yet"
description="Attestations and events will appear here once activity begins."
/>
) : (
<ul className="activity-timeline" aria-label="Recent timeline events">
{items.map((item) => {
const isExpanded = expandedId === item.id
const panelId = `details-${item.id}`
return (
<li className="activity-row" key={item.id}>
<div className="activity-row__rail" aria-hidden="true">
<span className={`activity-row__node activity-row__node--${item.tone}`} />
<span className="activity-row__line" />
</div>
<time className="activity-row__time">{item.timestamp}</time>
<div className="activity-row__content">
<div className="activity-row__title-wrap">
<p className="activity-row__title">{item.title}</p>
<Badge variant={toneToBadgeVariant(item.tone)} label={item.statusLabel} />
</div>
<p className="activity-row__description">{item.description}</p>
<button
id={buttonId}
ref={(el) => {
if (el) triggerRefs.current.set(item.id, el)
else triggerRefs.current.delete(item.id)
}}
type="button"
id={buttonId}
aria-expanded={isExpanded}
aria-controls={panelId}
onClick={() => toggleExpand(item.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
toggleExpand(item.id)
}
}}
className="activity-row__disclosure"
ref={(el) => {
if (el) triggerRefs.current.set(item.id, el)
else triggerRefs.current.delete(item.id)
}}
>
{isExpanded ? 'Hide details' : 'Show details'}
</button>
<div
id={panelId}
className="activity-row__detail-panel"
hidden={!isExpanded}
onKeyDown={handleKeyDown}
tabIndex={-1}
>
<p className="activity-row__actor" style={{ marginBottom: 'var(--credence-space-1)' }}>
<strong>Actor:</strong> {item.actor}
</p>
<p className="activity-row__meta">
<strong>Meta:</strong>{' '}
{isTxHash(item.meta) ? (
<CopyableHash hash={item.meta} />
) : (
item.meta
)}
</p>
</div>
</div>
</li>
)
})}
</ul>
)}
</section>
)
}