diff --git a/web/src/monitors/Monitor.tsx b/web/src/monitors/Monitor.tsx new file mode 100644 index 0000000..e301955 --- /dev/null +++ b/web/src/monitors/Monitor.tsx @@ -0,0 +1,70 @@ +import { useState } from 'react'; +import type { CSSProperties, ReactNode } from 'react'; + +interface MonitorProps { + title: string; + count?: number; + pinned: boolean; + defaultOpen?: boolean; + children: ReactNode; +} + +const wrap: CSSProperties = { + borderBottom: '1px solid var(--hair)', +}; + +const header: CSSProperties = { + height: 32, + display: 'flex', + alignItems: 'center', + gap: 8, + padding: '0 12px', + fontFamily: 'var(--ff-sans)', + fontSize: 11, + color: 'var(--ink-2)', + cursor: 'pointer', + userSelect: 'none', + background: 'var(--bg-page)', +}; + +export function Monitor({ title, count, pinned, defaultOpen = false, children }: MonitorProps) { + const [open, setOpen] = useState(pinned || defaultOpen); + return ( +
+
setOpen((v) => !v)} + style={header} + > + + {open ? '▾' : '▸'} + + {pinned && ( + + )} + + {title} + + {count !== undefined && ( + + {count} + + )} +
+ {open && ( +
+ {children} +
+ )} +
+ ); +} diff --git a/web/tests/component/Monitor.test.tsx b/web/tests/component/Monitor.test.tsx new file mode 100644 index 0000000..97444d7 --- /dev/null +++ b/web/tests/component/Monitor.test.tsx @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen, fireEvent } from '../_helpers/render'; +import { Monitor } from '@/monitors/Monitor'; + +describe('', () => { + it('renders title + optional count', () => { + render( + +
body content
+
, + ); + expect(screen.getByText('Selected')).toBeInTheDocument(); + expect(screen.getByText('3')).toBeInTheDocument(); + expect(screen.getByText('body content')).toBeInTheDocument(); + }); + + it('omits count display when count is undefined', () => { + render( + +
body
+
, + ); + expect(screen.getByText('Health')).toBeInTheDocument(); + expect(screen.queryByText(/^\d+$/)).not.toBeInTheDocument(); + }); + + it('renders pinned dot when pinned=true', () => { + const { container } = render( + +
body
+
, + ); + expect(container.firstChild).toHaveAttribute('data-pinned', 'true'); + expect(container.querySelector('[data-pinned-dot]')).not.toBeNull(); + }); + + it('starts collapsed by default unless defaultOpen', () => { + const { container, rerender } = render( + +
body
+
, + ); + expect(container.firstChild).toHaveAttribute('data-collapsed', 'true'); + expect(screen.queryByText('body')).not.toBeInTheDocument(); + + rerender( + +
body
+
, + ); + expect(screen.getByText('body')).toBeInTheDocument(); + }); + + it('toggles open/closed on header click', () => { + const { container } = render( + +
body
+
, + ); + expect(screen.queryByText('body')).not.toBeInTheDocument(); + fireEvent.click(screen.getByText('X')); + expect(screen.getByText('body')).toBeInTheDocument(); + expect(container.firstChild).toHaveAttribute('data-collapsed', 'false'); + }); +});