Files
design-system/src/design-system/composites/EventFeed/EventFeed.tsx
hsiegeln 433d582da6
All checks were successful
Build & Publish / publish (push) Successful in 1m2s
feat: migrate all icons to Lucide React
Replace unicode characters, emoji, and inline SVGs with lucide-react
components across the entire design system and page layer. Update
tests to assert on SVG elements instead of text content.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 23:25:43 +01:00

209 lines
6.2 KiB
TypeScript

import { type ReactNode, useEffect, useRef, useState, useCallback } from 'react'
import { X as XIcon, AlertTriangle, Play, Loader } from 'lucide-react'
import styles from './EventFeed.module.css'
import { ButtonGroup } from '../../primitives/ButtonGroup/ButtonGroup'
import type { ButtonGroupItem } from '../../primitives/ButtonGroup/ButtonGroup'
export interface FeedEvent {
id: string
severity: 'error' | 'warning' | 'success' | 'running'
message: string | ReactNode
/** Plain-text version of message for search filtering (required when message is ReactNode) */
searchText?: string
icon?: ReactNode
timestamp: Date
}
type SeverityFilter = 'error' | 'warning' | 'success' | 'running'
interface EventFeedProps {
events: FeedEvent[]
maxItems?: number
className?: string
}
function formatRelativeTime(date: Date): string {
const now = Date.now()
const diff = now - date.getTime()
if (diff < 1000) return 'just now'
if (diff < 60_000) return `${Math.floor(diff / 1000)}s ago`
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`
return `${Math.floor(diff / 86_400_000)}d ago`
}
function formatAbsoluteTime(date: Date): string {
const day = date.getDate()
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']
const month = months[date.getMonth()]
const year = date.getFullYear()
const h = String(date.getHours()).padStart(2, '0')
const m = String(date.getMinutes()).padStart(2, '0')
return `${day} ${month} ${year}, ${h}:${m}`
}
function getSearchableText(event: FeedEvent): string {
if (event.searchText) return event.searchText
if (typeof event.message === 'string') return event.message
return ''
}
const DEFAULT_ICONS: Record<SeverityFilter, ReactNode> = {
error: <XIcon size={14} />,
warning: <AlertTriangle size={14} />,
success: <Play size={14} />,
running: <Loader size={14} />,
}
const SEVERITY_COLORS: Record<SeverityFilter, string> = {
error: 'var(--error)',
warning: 'var(--warning)',
success: 'var(--success)',
running: 'var(--running)',
}
const SEVERITY_LABELS: Record<SeverityFilter, string> = {
error: 'Error',
warning: 'Warning',
success: 'Success',
running: 'Info',
}
export function EventFeed({ events, maxItems = 200, className }: EventFeedProps) {
const scrollRef = useRef<HTMLDivElement>(null)
const [isPaused, setIsPaused] = useState(false)
const [activeFilters, setActiveFilters] = useState<Set<SeverityFilter>>(new Set())
const [search, setSearch] = useState('')
const searchLower = search.toLowerCase()
const displayed = events
.slice(-maxItems)
.filter((e) => activeFilters.size === 0 || activeFilters.has(e.severity))
.filter((e) => !searchLower || getSearchableText(e).toLowerCase().includes(searchLower))
// Auto-scroll to top (newest entries are at top in desc sort)
const scrollToTop = useCallback(() => {
const el = scrollRef.current
if (el) {
el.scrollTop = 0
}
}, [])
useEffect(() => {
if (!isPaused) {
scrollToTop()
}
}, [events, isPaused, scrollToTop])
function handleScroll() {
const el = scrollRef.current
if (!el) return
const atTop = el.scrollTop < 8
setIsPaused(!atTop)
}
function toggleFilter(severity: SeverityFilter) {
setActiveFilters((prev) => {
const next = new Set(prev)
if (next.has(severity)) {
next.delete(severity)
} else {
next.add(severity)
}
return next
})
}
const allSeverities: SeverityFilter[] = ['error', 'warning', 'success', 'running']
return (
<div className={`${styles.feed} ${className ?? ''}`}>
{/* Search + filter bar */}
<div className={styles.toolbar}>
<div className={styles.searchWrap}>
<input
type="text"
className={styles.searchInput}
placeholder="Search events…"
value={search}
onChange={(e) => setSearch(e.target.value)}
aria-label="Search events"
/>
{search && (
<button
type="button"
className={styles.searchClear}
onClick={() => setSearch('')}
aria-label="Clear search"
>
<XIcon size={12} />
</button>
)}
</div>
<div className={styles.filters}>
<ButtonGroup
items={allSeverities.map((sev): ButtonGroupItem => ({
value: sev,
label: SEVERITY_LABELS[sev],
color: SEVERITY_COLORS[sev],
}))}
value={activeFilters as Set<string>}
onChange={(next) => setActiveFilters(next as Set<SeverityFilter>)}
/>
{activeFilters.size > 0 && (
<button
className={styles.clearBtn}
onClick={() => setActiveFilters(new Set())}
>
Clear
</button>
)}
</div>
</div>
{/* Event list */}
<div
ref={scrollRef}
className={styles.list}
onScroll={handleScroll}
aria-live="polite"
aria-label="Event feed"
>
{displayed.length === 0 ? (
<div className={styles.empty}>
{search ? 'No matching events' : 'No events'}
</div>
) : (
displayed.map((event) => (
<div key={event.id} className={styles.item}>
<div className={`${styles.icon} ${styles[`icon_${event.severity}`]}`}>
{event.icon ?? DEFAULT_ICONS[event.severity]}
</div>
<div className={styles.body}>
<div className={styles.message}>{event.message}</div>
<div className={styles.time}>
{formatRelativeTime(event.timestamp)} &middot; {formatAbsoluteTime(event.timestamp)}
</div>
</div>
</div>
))
)}
</div>
{/* Pause indicator */}
{isPaused && (
<button
className={styles.resumeBtn}
onClick={() => {
setIsPaused(false)
scrollToTop()
}}
>
&uarr; Scroll to latest
</button>
)}
</div>
)
}