| name | panel-component-xss-safe |
| description | Create a dashboard panel component using vanilla TypeScript DOM API, following the worldmonitor Panel architecture. Panels have a header with title/count, scrollable content area, loading/error states, and resize handles. Includes XSS-safe rendering pattern with esc() helper for safely interpolating untrusted external API data into innerHTML. |
Panel Component Pattern (XSS-Safe)
Create dashboard panel components using vanilla TypeScript (no framework, no JSX). Each panel is a class extending a Panel base class.
Security note: Panels that consume external API data (calendar events, news feeds, stock names, user-supplied content, etc.) MUST escape all untrusted values before injecting them into innerHTML. Use the esc() helper documented below.
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)
XSS-Safe Rendering: the esc() Helper
Always use esc() when interpolating untrusted data into an HTML string.
Untrusted data includes anything from external APIs: event titles, locations, URLs, names, descriptions, symbols, etc.
Create src/utils/esc.ts:
export function esc(value: unknown): string {
return String(value ?? '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
When to use esc()
| Data source | Safe? | Action |
|---|
| Hardcoded string literal in source | ✅ Safe | No escaping needed |
| Enum / controlled constant | ✅ Safe | No escaping needed |
| External API string field (title, name, body…) | ❌ Unsafe | Use esc() |
| User input / localStorage value | ❌ Unsafe | Use esc() |
| Number/boolean (rendered as text) | ✅ Safe | String(n) is fine |
| URL from external API | ⚠️ Unsafe | Validate scheme + esc() |
URL Safety
For URLs from external sources, validate the scheme before injecting:
export function safeUrl(raw: unknown): string {
const s = String(raw ?? '').trim();
return /^https?:\/\//i.test(s) ? s : '#';
}
Usage: <a href="${safeUrl(item.url)}">${esc(item.title)}</a>
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;
constructor(options: PanelOptions) {
this.panelId = options.id;
this.element = document.createElement('div');
this.element.className = `panel ${options.className || ''}`;
this.element.. = options.;
. = .();
.. = ;
headerLeft = .();
headerLeft. = ;
title = .();
title. = ;
title. = options.;
headerLeft.(title);
..(headerLeft);
(options.) {
. = .();
.. = ;
.. = ;
..(.);
}
. = .();
.. = ;
.. = ;
..(.);
..(.);
.();
}
(): { .; }
(message = ): {
.. = ;
}
(message = , ?: ): {
.. = ;
(onRetry) {
..()?.(, onRetry);
}
}
(: ): {
.. = html;
}
(: ): {
(.) .. = count.();
}
(): { ...(); }
(): { ...(); }
(: ): { . = v; }
(): { .; }
(): {
..();
}
}
Creating a Concrete Panel (Example: StockPanel — XSS-safe)
Import esc and wrap every API-derived string field:
import { Panel } from './Panel';
import { esc } from '../utils/esc';
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.fetchData();
this.refreshTimer = setInterval(() => this.fetchData(), 60_000);
}
private async fetchData(): <> {
(.) ;
.();
{
quotes = ();
.(quotes);
.(quotes.);
} (err) {
.(, .());
} {
.();
}
}
(: []): {
rows = quotes.( ).();
.();
}
(): {
(.) (.);
.();
}
}
Creating a Concrete Panel (Example: EventPanel — calendar/news data)
Calendar and news panels are highest-risk because titles, locations, and URLs all come from untrusted sources:
import { Panel } from './Panel';
import { esc, safeUrl } from '../utils/esc';
interface CalendarEvent {
id: string;
title: string;
location?: string;
url?: string;
startTime: Date;
}
export class SchedulePanel extends Panel {
private refreshTimer: ReturnType<typeof setInterval> | null = null;
constructor() {
super({ id: 'schedule', title: "Today's Schedule", showCount: true });
this.fetchData();
this.refreshTimer = setInterval(() => this.fetchData(), 5 * );
}
(): <> {
(.) ;
.();
{
events = ();
.(events);
.(events.);
} (err) {
.(, .());
} {
.();
}
}
(: []): {
(events. === ) {
.();
;
}
rows = events.( ).();
.();
}
(): {
(.) (.);
.();
}
}
Key Patterns
- Constructor calls
super() with panel config, then triggers initial data fetch
- fetchData() is async, uses
isFetching guard, shows error on failure with retry
- render() builds HTML strings with
esc() around every API-derived string, then calls this.setContent(html)
- destroy() cleans up timers and event listeners
- Use
showLoading() during initial load (auto-called in constructor)
- Use
showError(msg, retryFn) on failure — msg should be a hardcoded string, not API data
- Import
esc from ../utils/esc in every panel that consumes external data
- Numbers and booleans rendered via
.toFixed() / .toString() / template arithmetic are safe — no esc() needed
- Use
textContent instead of innerHTML for single text nodes when convenient — it is always safe
Sparkline Utility
Sparklines use computed numbers only — no escaping needed:
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 ${w} "><polyline points="" fill="none" stroke="" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
}
Checklist Before Submitting a Panel