Files
design-system/src/design-system/composites/LogViewer/LogViewer.tsx
hsiegeln 8c1c953259 feat: add LogViewer composite for timestamped, severity-colored log display
Scrollable log viewer with auto-scroll behavior, level badges (info/warn/
error/debug) with semantic colors, monospace font, and role="log" for
accessibility. Includes 7 tests and barrel exports.

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

78 lines
2.0 KiB
TypeScript

import { useRef, useEffect, useCallback } from 'react'
import styles from './LogViewer.module.css'
export interface LogEntry {
timestamp: string
level: 'info' | 'warn' | 'error' | 'debug'
message: string
}
export interface LogViewerProps {
entries: LogEntry[]
maxHeight?: number | string
className?: string
}
const LEVEL_CLASS: Record<LogEntry['level'], string> = {
info: styles.levelInfo,
warn: styles.levelWarn,
error: styles.levelError,
debug: styles.levelDebug,
}
function formatTime(iso: string): string {
try {
return new Date(iso).toLocaleTimeString('en-GB', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})
} catch {
return iso
}
}
export function LogViewer({ entries, maxHeight = 400, className }: LogViewerProps) {
const scrollRef = useRef<HTMLDivElement>(null)
const isAtBottomRef = useRef(true)
const handleScroll = useCallback(() => {
const el = scrollRef.current
if (!el) return
isAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 20
}, [])
useEffect(() => {
const el = scrollRef.current
if (el && isAtBottomRef.current) {
el.scrollTop = el.scrollHeight
}
}, [entries])
const heightStyle = typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight
return (
<div
ref={scrollRef}
className={[styles.container, className].filter(Boolean).join(' ')}
style={{ maxHeight: heightStyle }}
onScroll={handleScroll}
role="log"
>
{entries.map((entry, i) => (
<div key={i} className={styles.line}>
<span className={styles.timestamp}>{formatTime(entry.timestamp)}</span>
<span className={[styles.levelBadge, LEVEL_CLASS[entry.level]].join(' ')}>
{entry.level.toUpperCase()}
</span>
<span className={styles.message}>{entry.message}</span>
</div>
))}
{entries.length === 0 && (
<div className={styles.empty}>No log entries.</div>
)}
</div>
)
}