--- name: panel-component-with-push-sidebar description: Dashboard panel components (class-based, self-fetching) and push-update sidebar modules (functional, externally-driven) using vanilla TypeScript DOM API, with retry logic, localStorage persistence, and guarded CSS injection. --- # Panel & Push-Update Sidebar Patterns Two complementary patterns for building dashboard UI in **vanilla TypeScript** (no framework, no JSX): | Pattern | Use when… | |---|---| | **Panel class** (extends `Panel`) | The component fetches its own data on a timer | | **Push-update sidebar module** | Data arrives from outside (caller pushes it in) | --- ## Part 1 — Panel Class Pattern ### Architecture Overview ``` Panel (base class) ├── element: HTMLElement (outer container, .panel) │ ├── header: HTMLElement (.panel-header) │ │ ├── headerLeft (.panel-header-left) │ │ │ ├── title (.panel-title) │ │ │ └── newBadge (.panel-new-badge) [optional] │ │ ├── statusBadge (.panel-data-badge) [optional] │ │ └── countEl (.panel-count) [optional] │ ├── content: HTMLElement (.panel-content) │ └── resizeHandle (.panel-resize-handle) ``` ### Base Panel Class Create `src/components/Panel.ts`: ```typescript export interface PanelOptions { id: string; title: string; showCount?: boolean; className?: string; } export class Panel { protected element: HTMLElement; protected content: HTMLElement; protected header: HTMLElement; protected countEl: HTMLElement | null = null; protected panelId: string; private _fetching = false; // --- Retry state (reset before each logical fetch sequence) --- private retryAttempts = 0; private maxRetries = 3; private retryDelay = 1000; // ms; doubles on each attempt constructor(options: PanelOptions) { this.panelId = options.id; this.element = document.createElement('div'); this.element.className = `panel ${options.className || ''}`; this.element.dataset.panel = options.id; // Header this.header = document.createElement('div'); this.header.className = 'panel-header'; const headerLeft = document.createElement('div'); headerLeft.className = 'panel-header-left'; const title = document.createElement('span'); title.className = 'panel-title'; title.textContent = options.title; headerLeft.appendChild(title); this.header.appendChild(headerLeft); // Count badge (optional) if (options.showCount) { this.countEl = document.createElement('span'); this.countEl.className = 'panel-count'; this.countEl.textContent = '0'; this.header.appendChild(this.countEl); } // Content area this.content = document.createElement('div'); this.content.className = 'panel-content'; this.content.id = `${options.id}Content`; this.element.appendChild(this.header); this.element.appendChild(this.content); this.showLoading(); } // ---------------------------------------------------------------- // Public API // ---------------------------------------------------------------- public getElement(): HTMLElement { return this.element; } public showLoading(message = 'Loading...'): void { this.content.innerHTML = `
${message}
`; } public showError(message = 'Failed to load', onRetry?: () => void): void { this.content.innerHTML = `
${message}
${onRetry ? '' : ''}
`; if (onRetry) { this.content.querySelector('[data-panel-retry]') ?.addEventListener('click', onRetry); } } public setContent(html: string): void { this.content.innerHTML = html; } public setCount(count: number): void { if (this.countEl) this.countEl.textContent = count.toString(); } public show(): void { this.element.classList.remove('hidden'); } public hide(): void { this.element.classList.add('hidden'); } public destroy(): void { this.element.remove(); } // ---------------------------------------------------------------- // Protected API — available in subclasses // ---------------------------------------------------------------- protected setFetching(v: boolean): void { this._fetching = v; } protected get isFetching(): boolean { return this._fetching; } /** * Fetch a URL with exponential-backoff retry. * Call resetRetry() before each new fetch sequence. */ protected async fetchWithRetry(url: string): Promise { while (this.retryAttempts < this.maxRetries) { try { const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status}`); return await res.json(); } catch (err: unknown) { this.retryAttempts++; if (this.retryAttempts >= this.maxRetries) { const msg = err instanceof Error ? err.message : String(err); throw new Error(`Failed after ${this.maxRetries} attempts: ${msg}`); } await new Promise(r => setTimeout(r, this.retryDelay)); this.retryDelay *= 2; } } } /** Reset retry counters before a fresh fetch sequence. */ protected resetRetry(): void { this.retryAttempts = 0; this.retryDelay = 1000; } // ---------------------------------------------------------------- // State persistence (localStorage) // ---------------------------------------------------------------- public saveState(): void { localStorage.setItem(`panelState_${this.panelId}`, JSON.stringify({ isExpanded: !this.element.classList.contains('collapsed'), width: this.element.style.width, height: this.element.style.height, })); } public loadState(): void { const raw = localStorage.getItem(`panelState_${this.panelId}`); if (!raw) return; const { isExpanded, width, height } = JSON.parse(raw) as { isExpanded: boolean; width: string; height: string; }; if (!isExpanded) this.element.classList.add('collapsed'); if (width) this.element.style.width = width; if (height) this.element.style.height = height; } } ``` ### Protected API Reference | Member / Method | Type | Description | |---|---|---| | `element` | `HTMLElement` | Outer container div (`.panel`) | | `header` | `HTMLElement` | Header bar — append extra controls here | | `content` | `HTMLElement` | Scrollable content area | | `countEl` | `HTMLElement \| null` | Count badge, or `null` if `showCount` not set | | `panelId` | `string` | The `id` from `PanelOptions` | | `isFetching` | `boolean` getter | `true` while an async fetch is in progress | | `setFetching(v)` | `void` | Set/clear the fetching guard | | `showLoading(msg?)` | `void` | Replace content with a spinner | | `showError(msg?, onRetry?)` | `void` | Replace content with error + optional retry button | | `setContent(html)` | `void` | Set raw HTML into the content area | | `setCount(n)` | `void` | Update count badge (no-op if `countEl` is null) | | `fetchWithRetry(url)` | `Promise` | Fetch with exponential-backoff retry (3 attempts) | | `resetRetry()` | `void` | Reset retry counters before a new fetch sequence | | `saveState()` | `void` | Persist expanded/size state to localStorage | | `loadState()` | `void` | Restore state from localStorage | ### Creating a Concrete Panel (Example: StockPanel) ```typescript import { Panel } from './Panel'; interface StockQuote { symbol: string; name: string; price: number | null; change: number | null; sparkline?: number[]; } export class StockPanel extends Panel { private refreshTimer: ReturnType | null = null; constructor() { super({ id: 'stocks', title: 'Stock Market', showCount: true }); this.loadState(); // restore saved size/collapsed state this.fetchData(); this.refreshTimer = setInterval(() => this.fetchData(), 60_000); } private async fetchData(): Promise { if (this.isFetching) return; this.setFetching(true); this.resetRetry(); // start a fresh retry sequence try { const quotes = await this.fetchWithRetry('/api/stocks') as StockQuote[]; this.render(quotes); this.setCount(quotes.length); this.saveState(); } catch (err: unknown) { const msg = err instanceof Error ? err.message : 'Unknown error'; this.showError(`Failed to load stock data: ${msg}`, () => this.fetchData()); } finally { this.setFetching(false); } } private render(quotes: StockQuote[]): void { const rows = quotes.map(q => `
${q.symbol} ${q.name} ${q.price != null ? '$' + q.price.toFixed(2) : '—'} ${q.change != null ? (q.change >= 0 ? '+' : '') + q.change.toFixed(2) + '%' : '—'} ${miniSparkline(q.sparkline, q.change)}
`).join(''); this.setContent(`
${rows}
`); } public override destroy(): void { if (this.refreshTimer) clearInterval(this.refreshTimer); super.destroy(); } } ``` ### Key Patterns for Self-Fetching Panels 1. **Constructor** → `super()` → `loadState()` → initial `fetchData()` → start refresh timer 2. **fetchData()** → `isFetching` guard → `resetRetry()` → `fetchWithRetry()` → `render()` + `saveState()` 3. **render()** builds HTML strings → `this.setContent(html)` 4. **destroy()** clears timers, calls `super.destroy()` 5. Use `showLoading()` during initial load (auto-called in constructor) 6. Use `showError(msg, retryFn)` on failure; retryFn must call `fetchData()` (which calls `resetRetry()`) --- ## Part 2 — Push-Update Sidebar Pattern Use this pattern when the component **does not fetch data itself** — instead it receives data pushed by an external caller (e.g. a WebSocket handler, a store subscription, or a parent orchestrator). ### When to use this pattern vs. Panel class ``` Self-fetching? → Panel class (Part 1) Data pushed in? → Push-update sidebar module (Part 2) ``` ### Module Structure A push-update sidebar is a **plain TypeScript module** (not a class) with: | Export | Purpose | |---|---| | `createXSidebar(): HTMLElement` | Build and return the root element (idempotent singleton) | | `updateX(data: XData): void` | Re-render only the sections that changed | | `interface XData` | The data shape the caller must provide | Internally the module uses: - A **module-level singleton** `let sidebarEl: HTMLElement | null = null` - A **`SectionRefs` interface** caching live DOM node references to avoid repeated `querySelector` calls - A **guarded `injectStyles()`** function that inserts a `