--- name: panel-event-forms description: Create dashboard panel components with advanced event handling and form validation using vanilla TypeScript DOM API, following the worldmonitor Panel architecture. --- # Enhanced Panel Component Pattern Create dashboard panel components using **vanilla TypeScript** (no framework, no JSX). Each panel is a class extending a `Panel` base class, now with enhanced event handling and form validation examples. ## 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 (Simplified for this project) 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; 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 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'); } protected setFetching(v: boolean): void { this._fetching = v; } protected get isFetching(): boolean { return this._fetching; } public destroy(): void { this.element.remove(); } } ``` ## Creating a Concrete Panel (Example: FinancePanel) Each panel extends `Panel` and manages its own data fetching + rendering, now with enhanced event handling and form validation: ```typescript import { Panel } from './Panel'; interface Transaction { id: string; amount: number; description: string; date: string; category: string; } export class FinancePanel extends Panel { private transactions: Transaction[] = []; private balance: number = 0; constructor() { super({ id: 'finance', title: 'Finance Tracker', showCount: true }); this.loadTransactions(); this.render(); } private loadTransactions(): void { const saved = localStorage.getItem('finance-transactions'); if (saved) { this.transactions = JSON.parse(saved); this.balance = this.transactions.reduce((sum, t) => sum + t.amount, 0); } } private saveTransactions(): void { localStorage.setItem('finance-transactions', JSON.stringify(this.transactions)); } private render(): void { const formHtml = `
`; const transactionsHtml = this.transactions.map(t => `
${t.date} ${t.description} ${t.amount >= 0 ? '+' : ''}${t.amount.toFixed(2)}
`).join(''); this.setContent(`
Balance: $${this.balance.toFixed(2)}
${formHtml}
${transactionsHtml}
`); this.setupForm(); } private setupForm(): void { const form = this.content.querySelector('#financeForm') as HTMLFormElement; if (!form) return; form.addEventListener('submit', (e) => { e.preventDefault(); if (this.validateForm()) { this.addTransaction(); } }); } private validateForm(): boolean { let isValid = true; const amountInput = this.content.querySelector('#amount') as HTMLInputElement; const descriptionInput = this.content.querySelector('#description') as HTMLInputElement; const amountError = this.content.querySelector('#amountError') as HTMLElement; const descriptionError = this.content.querySelector('#descriptionError') as HTMLElement; // Reset errors amountError.textContent = ''; descriptionError.textContent = ''; // Validate amount if (!amountInput.value) { amountError.textContent = 'Amount is required'; isValid = false; } else if (isNaN(parseFloat(amountInput.value))) { amountError.textContent = 'Amount must be a number'; isValid = false; } // Validate description if (!descriptionInput.value) { descriptionError.textContent = 'Description is required'; isValid = false; } return isValid; } private addTransaction(): void { const amountInput = this.content.querySelector('#amount') as HTMLInputElement; const descriptionInput = this.content.querySelector('#description') as HTMLInputElement; const transaction: Transaction = { id: Date.now().toString(), amount: parseFloat(amountInput.value), description: descriptionInput.value, date: new Date().toLocaleDateString(), category: 'General', }; this.transactions.push(transaction); this.balance += transaction.amount; this.saveTransactions(); this.render(); } public override destroy(): void { // Clean up any event listeners super.destroy(); } } ``` ## Key Patterns 1. **Constructor** calls `super()` with panel config, then loads initial data 2. **Form Handling** includes validation and submission logic 3. **Event Listeners** are properly set up and cleaned up in `destroy()` 4. **Local Storage** is used for persistent data 5. **Validation** provides user feedback on form errors 6. **Dynamic Updates** reflect changes immediately in the UI ## Sparkline Utility ```typescript 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 ``; } ```