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>
78 lines
2.0 KiB
TypeScript
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>
|
|
)
|
|
}
|