---
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 = `
`;
}
public showError(message = 'Failed to load', onRetry?: () => void): void {
this.content.innerHTML = `
${message}
${onRetry ? '
Retry ' : ''}
`;
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 `