| 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:
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;
private retryAttempts = 0;
private maxRetries = 3;
private retryDelay = 1000;
constructor(options: PanelOptions) {
this.panelId = options.id;
this.element = document.createElement('div');
this.element.className = `panel ${options.className || ''}`;
this.element.dataset.panel = options.id;
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);
if (options.showCount) {
this.countEl = document.createElement('span');
this.countEl.className = 'panel-count';
this.countEl.textContent = '0';
this.header.appendChild(this.countEl);
}
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 getElement(): HTMLElement { return this.element; }
public showLoading(message = 'Loading...'): void {
this.content.innerHTML = `
<div class="panel-loading">
<div class="panel-loading-spinner"></div>
<div class="panel-loading-text">${message}</div>
</div>`;
}
public showError(message = 'Failed to load', onRetry?: () => void): void {
this.content.innerHTML = `
<div class="panel-error-state">
<div class="panel-error-msg">${message}</div>
${onRetry ? '<button class="panel-retry-btn" data-panel-retry>Retry</button>' : ''}
</div>`;
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 setFetching(v: boolean): void { this._fetching = v; }
protected get isFetching(): boolean { return this._fetching; }
protected async fetchWithRetry(url: string): Promise<unknown> {
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;
}
}
}
protected resetRetry(): void {
this.retryAttempts = 0;
this.retryDelay = 1000;
}
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<unknown> | 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 |
|
Creating a Concrete Panel (Example: StockPanel)
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<typeof setInterval> | null = null;
constructor() {
super({ id: 'stocks', title: 'Stock Market', showCount: true });
this.loadState();
this.fetchData();
this.refreshTimer = setInterval(() => this.fetchData(), 60_000);
}
private async (): <> {
(.) ;
.();
.();
{
quotes = .() [];
.(quotes);
.(quotes.);
.();
} (: ) {
msg = err ? err. : ;
.(, .());
} {
.();
}
}
(: []): {
rows = quotes.( ).();
.();
}
(): {
(.) (.);
.();
}
}
Key Patterns for Self-Fetching Panels
- Constructor →
super() → loadState() → initial fetchData() → start refresh timer
- fetchData() →
isFetching guard → resetRetry() → fetchWithRetry() → render() + saveState()
- render() builds HTML strings →
this.setContent(html)
- destroy() clears timers, calls
super.destroy()
- Use
showLoading() during initial load (auto-called in constructor)
- 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 <style> tag exactly once
Skeleton
export interface FooData {
title: string;
items: { id: string; label: string; value: number }[];
lastUpdated: Date;
}
interface SectionRefs {
root: HTMLElement;
titleEl: HTMLElement;
listEl: HTMLElement;
footerEl: HTMLElement;
}
let sidebarEl: HTMLElement | null = null;
let refs: SectionRefs | null = null;
let stylesInjected = false;
function injectStyles(): void {
if (stylesInjected) return;
stylesInjected = ;
style = .();
style.. = ;
style. = ;
..(style);
}
(): {
(sidebarEl) sidebarEl;
();
root = .();
root. = ;
titleEl = .();
titleEl. = ;
root.(titleEl);
listEl = .();
listEl. = ;
root.(listEl);
footerEl = .();
footerEl. = ;
root.(footerEl);
refs = { root, titleEl, listEl, footerEl };
sidebarEl = root;
root;
}
(): {
(!refs) ();
r = refs!;
r.. = data.;
r.. = data.
.( )
.();
r.. =
;
}
Real-World Example: TodayFocusSidebar
A sidebar that summarises the user's day — greeting, meeting countdown, inbox counts, stock alerts, CI failures, and an AI briefing with truncate/expand toggle. Data is pushed in from an external orchestrator.
export interface FocusData {
userName: string;
nextMeeting: { title: string; startsInMinutes: number } | null;
inboxCounts: { email: number; slack: number; github: number };
stockAlerts: { symbol: string; changePercent: number }[];
ciFailures: { repo: string; branch: string }[];
aiBriefing: string;
}
interface SectionRefs {
root: HTMLElement;
greetingEl: HTMLElement;
meetingEl: HTMLElement;
inboxEl: HTMLElement;
stocksEl: HTMLElement;
ciEl: HTMLElement;
briefingEl: HTMLElement;
}
let sidebarEl: HTMLElement | = ;
: | = ;
stylesInjected = ;
(): {
h = ().();
salutation = h < ? : h < ? : ;
;
}
(): {
(minutes <= ) ;
(minutes < ) ;
;
}
(): {
(stylesInjected) ;
stylesInjected = ;
s = .();
s.. = ;
s. = ;
..(s);
}
(): {
(sidebarEl) sidebarEl;
();
make = (: , : ): {
el = .(tag);
el. = cls;
el ;
};
root = (, );
greetingEl = (, );
meetingEl = (, );
inboxEl = (, );
stocksEl = (, );
ciEl = (, );
briefingEl = (, );
root.(greetingEl, meetingEl, inboxEl, stocksEl, ciEl, briefingEl);
refs = { root, greetingEl, meetingEl, inboxEl, stocksEl, ciEl, briefingEl };
sidebarEl = root;
root;
}
= ;
(): {
(!refs) ();
r = refs!;
r.. = (data.);
(data.) {
r.. =
;
r.. = ;
} {
r.. = ;
}
{ email, slack, github } = data.;
r.. = ;
alerts = data..( .(s.) >= );
r.. = alerts. ===
?
: +
alerts.( {
cls = s. >= ? : ;
sign = s. >= ? : ;
;
}).();
r.. = data.. ===
?
: +
data.
.( )
.();
text = data.;
(text. <= ) {
r.. =
;
} {
short = text.(, ) + ;
r.. = ;
r..()
?.(, () {
div = r..<>()!;
expanded = div.. === ;
div. = (
expanded ? div..! : div..!);
div.. = (!expanded);
. = expanded ? : ;
});
}
}
Push-Update Checklist
Barrel File Wiring
After creating a push-update sidebar, register it in both barrel files:
export { createTodayFocusSidebar, updateTodayFocus } from './TodayFocusSidebar';
export type { FocusData } from './TodayFocusSidebar';
export * from './components';
Part 3 — Sparkline Utility
Used by both patterns for inline SVG sparklines:
export function miniSparkline(
data: number[] | undefined,
change: number | null,
w = 50,
h = 16,
): string {
if (!data || data.length < 2) return '';
const min = Math.min(...data);
const max = Math.max(...data);
const range = max - min || 1;
const color = change != null && change >= 0 ? 'var(--green)' : 'var(--red)';
const points = data.map((v, i) => {
const x = (i / (data.length - 1)) * w;
const y = h - ((v - min) / range) * (h - 2) - 1;
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(' ');
return `<svg width="${w}" height="${h}" viewBox="0 0 ">` +
+
;
}
Quick-Reference Decision Tree
Need a new dashboard component?
│
├─ Will it fetch its own data (polling / one-shot)?
│ └─ YES → extend Panel (Part 1)
│ • constructor → super() → loadState() → fetchData() → setInterval
│ • fetchData() → resetRetry() → fetchWithRetry() → render() → saveState()
│ • destroy() → clearInterval → super.destroy()
│
└─ Will data be pushed from outside?
└─ YES → functional push-update module (Part 2)
• createXSidebar() — idempotent, builds DOM, fills SectionRefs
• updateX(data) — mutates only SectionRefs nodes
• injectStyles() — guarded, runs once
• export createX, updateX, XData; keep SectionRefs private