UNPKG

@nestjsvn/swagger-sse

Version:

OpenAPI documentation and interactive Swagger UI for NestJS Server-Sent Events endpoints

1,552 lines (1,334 loc) 46.6 kB
/** * Vanilla JavaScript UI Components for SSE Plugin * Provides EventFilterComponent, EventListComponent, and ConnectionStatusComponent * with performance optimizations, virtual scrolling, and accessibility support. */ /** * Base Component Class * Provides common functionality for all UI components */ class BaseComponent { constructor(container, options = {}) { this.container = container; this.options = options; this.element = null; this.isDestroyed = false; this.eventListeners = []; // Performance monitoring this.renderCount = 0; this.lastRenderTime = 0; // Accessibility this.ariaLive = options.ariaLive || 'polite'; this.init(); } init() { this.createElement(); this.setupEventListeners(); this.render(); } createElement() { // Override in subclasses } setupEventListeners() { // Override in subclasses } render() { this.renderCount++; this.lastRenderTime = performance.now(); } addEventListener(element, event, handler, options = {}) { element.addEventListener(event, handler, options); this.eventListeners.push({ element, event, handler, options }); } removeAllEventListeners() { this.eventListeners.forEach(({ element, event, handler, options }) => { element.removeEventListener(event, handler, options); }); this.eventListeners = []; } destroy() { if (this.isDestroyed) return; this.removeAllEventListeners(); if (this.element && this.element.parentNode) { this.element.parentNode.removeChild(this.element); } this.isDestroyed = true; } // Utility methods debounce(func, wait) { let timeout; return function executedFunction(...args) { const later = () => { clearTimeout(timeout); func(...args); }; clearTimeout(timeout); timeout = setTimeout(later, wait); }; } throttle(func, limit) { let inThrottle; return function (...args) { if (!inThrottle) { func.apply(this, args); inThrottle = true; setTimeout(() => (inThrottle = false), limit); } }; } createIcon(iconId, className = '') { const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('class', `sse-icon ${className}`); svg.setAttribute('aria-hidden', 'true'); const use = document.createElementNS('http://www.w3.org/2000/svg', 'use'); use.setAttributeNS('http://www.w3.org/1999/xlink', 'href', `#${iconId}`); svg.appendChild(use); return svg; } } /** * Event Filter Component * Provides filtering controls for SSE events */ class EventFilterComponent extends BaseComponent { constructor(container, options = {}) { super(container, { eventTypes: ['message', 'error', 'open', 'close'], showSearch: true, showTypeFilter: true, showLevelFilter: true, debounceDelay: 300, ...options, }); this.filters = { search: '', types: new Set(this.options.eventTypes), levels: new Set(['info', 'warn', 'error', 'debug']), dateRange: { start: null, end: null }, }; this.onFilterChange = options.onFilterChange || (() => {}); } createElement() { this.element = document.createElement('div'); this.element.className = 'sse-event-filter'; this.element.setAttribute('role', 'region'); this.element.setAttribute('aria-label', 'Event Filters'); this.element.innerHTML = ` <div class="sse-filter-header"> <h3 class="sse-filter-title"> ${this.createIcon('filter').outerHTML} Event Filters </h3> <button class="sse-filter-toggle" aria-expanded="true" aria-controls="sse-filter-content"> ${this.createIcon('collapse').outerHTML} </button> </div> <div class="sse-filter-content" id="sse-filter-content"> ${this.options.showSearch ? this.createSearchFilter() : ''} ${this.options.showTypeFilter ? this.createTypeFilter() : ''} ${this.options.showLevelFilter ? this.createLevelFilter() : ''} ${this.createDateRangeFilter()} ${this.createFilterActions()} </div> `; this.container.appendChild(this.element); } createSearchFilter() { return ` <div class="sse-filter-group"> <label for="sse-search-input" class="sse-filter-label">Search Events</label> <div class="sse-search-container"> <input type="text" id="sse-search-input" class="sse-search-input" placeholder="Search event data..." aria-describedby="sse-search-help" /> ${this.createIcon('search', 'sse-search-icon').outerHTML} <button class="sse-search-clear" aria-label="Clear search" style="display: none;"> ${this.createIcon('close').outerHTML} </button> </div> <div id="sse-search-help" class="sse-filter-help"> Search in event data, type, or timestamp </div> </div> `; } createTypeFilter() { const typeOptions = this.options.eventTypes .map( type => ` <label class="sse-checkbox-label"> <input type="checkbox" value="${type}" checked class="sse-type-checkbox" /> <span class="sse-checkbox-custom"></span> <span class="sse-checkbox-text">${type}</span> </label> `, ) .join(''); return ` <div class="sse-filter-group"> <fieldset class="sse-filter-fieldset"> <legend class="sse-filter-label">Event Types</legend> <div class="sse-checkbox-group" role="group" aria-labelledby="type-filter-legend"> ${typeOptions} </div> </fieldset> </div> `; } createLevelFilter() { const levels = ['info', 'warn', 'error', 'debug']; const levelOptions = levels .map( level => ` <label class="sse-checkbox-label"> <input type="checkbox" value="${level}" checked class="sse-level-checkbox" /> <span class="sse-checkbox-custom"></span> <span class="sse-checkbox-text sse-level-${level}">${level}</span> </label> `, ) .join(''); return ` <div class="sse-filter-group"> <fieldset class="sse-filter-fieldset"> <legend class="sse-filter-label">Log Levels</legend> <div class="sse-checkbox-group" role="group" aria-labelledby="level-filter-legend"> ${levelOptions} </div> </fieldset> </div> `; } createDateRangeFilter() { return ` <div class="sse-filter-group"> <fieldset class="sse-filter-fieldset"> <legend class="sse-filter-label">Date Range</legend> <div class="sse-date-range"> <div class="sse-date-input-group"> <label for="sse-date-start" class="sse-date-label">From</label> <input type="datetime-local" id="sse-date-start" class="sse-date-input" /> </div> <div class="sse-date-input-group"> <label for="sse-date-end" class="sse-date-label">To</label> <input type="datetime-local" id="sse-date-end" class="sse-date-input" /> </div> </div> </fieldset> </div> `; } createFilterActions() { return ` <div class="sse-filter-actions"> <button class="sse-btn sse-btn-secondary sse-filter-reset"> ${this.createIcon('refresh').outerHTML} Reset Filters </button> <button class="sse-btn sse-btn-primary sse-filter-apply"> ${this.createIcon('filter').outerHTML} Apply Filters </button> </div> `; } setupEventListeners() { // Search input with debouncing const searchInput = this.element.querySelector('#sse-search-input'); const searchClear = this.element.querySelector('.sse-search-clear'); if (searchInput) { const debouncedSearch = this.debounce(value => { this.filters.search = value; this.updateSearchClearButton(); this.emitFilterChange(); }, this.options.debounceDelay); this.addEventListener(searchInput, 'input', e => { debouncedSearch(e.target.value); }); this.addEventListener(searchInput, 'keydown', e => { if (e.key === 'Escape') { e.target.value = ''; this.filters.search = ''; this.updateSearchClearButton(); this.emitFilterChange(); } }); } if (searchClear) { this.addEventListener(searchClear, 'click', () => { searchInput.value = ''; this.filters.search = ''; this.updateSearchClearButton(); this.emitFilterChange(); searchInput.focus(); }); } // Type checkboxes const typeCheckboxes = this.element.querySelectorAll('.sse-type-checkbox'); typeCheckboxes.forEach(checkbox => { this.addEventListener(checkbox, 'change', e => { if (e.target.checked) { this.filters.types.add(e.target.value); } else { this.filters.types.delete(e.target.value); } this.emitFilterChange(); }); }); // Level checkboxes const levelCheckboxes = this.element.querySelectorAll('.sse-level-checkbox'); levelCheckboxes.forEach(checkbox => { this.addEventListener(checkbox, 'change', e => { if (e.target.checked) { this.filters.levels.add(e.target.value); } else { this.filters.levels.delete(e.target.value); } this.emitFilterChange(); }); }); // Date range inputs const dateStart = this.element.querySelector('#sse-date-start'); const dateEnd = this.element.querySelector('#sse-date-end'); if (dateStart) { this.addEventListener(dateStart, 'change', e => { this.filters.dateRange.start = e.target.value ? new Date(e.target.value) : null; this.emitFilterChange(); }); } if (dateEnd) { this.addEventListener(dateEnd, 'change', e => { this.filters.dateRange.end = e.target.value ? new Date(e.target.value) : null; this.emitFilterChange(); }); } // Filter toggle const filterToggle = this.element.querySelector('.sse-filter-toggle'); const filterContent = this.element.querySelector('.sse-filter-content'); if (filterToggle && filterContent) { this.addEventListener(filterToggle, 'click', () => { const isExpanded = filterToggle.getAttribute('aria-expanded') === 'true'; filterToggle.setAttribute('aria-expanded', !isExpanded); filterContent.style.display = isExpanded ? 'none' : 'block'; const icon = filterToggle.querySelector('use'); icon.setAttributeNS( 'http://www.w3.org/1999/xlink', 'href', isExpanded ? '#expand' : '#collapse', ); }); } // Reset button const resetButton = this.element.querySelector('.sse-filter-reset'); if (resetButton) { this.addEventListener(resetButton, 'click', () => { this.resetFilters(); }); } // Apply button (for manual apply mode) const applyButton = this.element.querySelector('.sse-filter-apply'); if (applyButton) { this.addEventListener(applyButton, 'click', () => { this.emitFilterChange(); }); } } updateSearchClearButton() { const searchClear = this.element.querySelector('.sse-search-clear'); const searchInput = this.element.querySelector('#sse-search-input'); if (searchClear && searchInput) { searchClear.style.display = searchInput.value ? 'block' : 'none'; } } resetFilters() { // Reset search const searchInput = this.element.querySelector('#sse-search-input'); if (searchInput) { searchInput.value = ''; } // Reset checkboxes const checkboxes = this.element.querySelectorAll('input[type="checkbox"]'); checkboxes.forEach(checkbox => { checkbox.checked = true; }); // Reset date inputs const dateInputs = this.element.querySelectorAll('input[type="datetime-local"]'); dateInputs.forEach(input => { input.value = ''; }); // Reset filter state this.filters = { search: '', types: new Set(this.options.eventTypes), levels: new Set(['info', 'warn', 'error', 'debug']), dateRange: { start: null, end: null }, }; this.updateSearchClearButton(); this.emitFilterChange(); } emitFilterChange() { this.onFilterChange({ ...this.filters, types: Array.from(this.filters.types), levels: Array.from(this.filters.levels), }); } getFilters() { return { ...this.filters, types: Array.from(this.filters.types), levels: Array.from(this.filters.levels), }; } setFilters(newFilters) { Object.assign(this.filters, newFilters); this.updateUI(); this.emitFilterChange(); } updateUI() { // Update search input const searchInput = this.element.querySelector('#sse-search-input'); if (searchInput) { searchInput.value = this.filters.search; } // Update type checkboxes const typeCheckboxes = this.element.querySelectorAll('.sse-type-checkbox'); typeCheckboxes.forEach(checkbox => { checkbox.checked = this.filters.types.has(checkbox.value); }); // Update level checkboxes const levelCheckboxes = this.element.querySelectorAll('.sse-level-checkbox'); levelCheckboxes.forEach(checkbox => { checkbox.checked = this.filters.levels.has(checkbox.value); }); // Update date inputs const dateStart = this.element.querySelector('#sse-date-start'); const dateEnd = this.element.querySelector('#sse-date-end'); if (dateStart && this.filters.dateRange.start) { dateStart.value = this.filters.dateRange.start.toISOString().slice(0, 16); } if (dateEnd && this.filters.dateRange.end) { dateEnd.value = this.filters.dateRange.end.toISOString().slice(0, 16); } this.updateSearchClearButton(); } } /** * Event List Component * Displays SSE events with virtual scrolling for performance */ class EventListComponent extends BaseComponent { constructor(container, options = {}) { super(container, { maxEvents: 1000, virtualScrolling: true, itemHeight: 60, bufferSize: 10, autoScroll: true, showTimestamps: true, showEventIds: true, dateFormat: 'HH:mm:ss.SSS', ...options, }); this.events = []; this.filteredEvents = []; this.visibleRange = { start: 0, end: 0 }; this.scrollContainer = null; this.virtualContainer = null; this.isAutoScrolling = true; this.lastScrollTop = 0; // Performance tracking this.renderStats = { totalRenders: 0, averageRenderTime: 0, lastRenderTime: 0, }; this.onEventSelect = options.onEventSelect || (() => {}); } createElement() { this.element = document.createElement('div'); this.element.className = 'sse-event-list'; this.element.setAttribute('role', 'log'); this.element.setAttribute('aria-label', 'Server-Sent Events'); this.element.setAttribute('aria-live', this.ariaLive); this.element.innerHTML = ` <div class="sse-event-list-header"> <div class="sse-event-list-title"> <h3>Events</h3> <span class="sse-event-count" aria-live="polite">0 events</span> </div> <div class="sse-event-list-controls"> <button class="sse-btn sse-btn-sm sse-auto-scroll-toggle" aria-pressed="${this.isAutoScrolling}" title="Toggle auto-scroll"> ${this.createIcon('pause').outerHTML} Auto-scroll </button> <button class="sse-btn sse-btn-sm sse-scroll-to-bottom" title="Scroll to bottom"> ${this.createIcon('expand').outerHTML} </button> <button class="sse-btn sse-btn-sm sse-clear-events" title="Clear all events"> ${this.createIcon('clear').outerHTML} </button> <button class="sse-btn sse-btn-sm sse-export-events" title="Export events"> ${this.createIcon('export').outerHTML} </button> </div> </div> <div class="sse-event-list-container" tabindex="0" role="log" aria-label="Event log"> ${this.options.virtualScrolling ? this.createVirtualScrollContainer() : this.createSimpleContainer()} </div> <div class="sse-event-list-footer"> <div class="sse-performance-stats"> <span class="sse-render-stats">Renders: 0 | Avg: 0ms</span> </div> </div> `; this.scrollContainer = this.element.querySelector('.sse-event-list-container'); if (this.options.virtualScrolling) { this.virtualContainer = this.element.querySelector('.sse-virtual-container'); } this.container.appendChild(this.element); } createVirtualScrollContainer() { return ` <div class="sse-virtual-scroll" style="position: relative; overflow-y: auto; height: 400px;"> <div class="sse-virtual-spacer-top" style="height: 0px;"></div> <div class="sse-virtual-container"></div> <div class="sse-virtual-spacer-bottom" style="height: 0px;"></div> </div> `; } createSimpleContainer() { return `<div class="sse-simple-container"></div>`; } setupEventListeners() { // Auto-scroll toggle const autoScrollToggle = this.element.querySelector('.sse-auto-scroll-toggle'); if (autoScrollToggle) { this.addEventListener(autoScrollToggle, 'click', () => { this.toggleAutoScroll(); }); } // Scroll to bottom const scrollToBottom = this.element.querySelector('.sse-scroll-to-bottom'); if (scrollToBottom) { this.addEventListener(scrollToBottom, 'click', () => { this.scrollToBottom(); }); } // Clear events const clearEvents = this.element.querySelector('.sse-clear-events'); if (clearEvents) { this.addEventListener(clearEvents, 'click', () => { this.clearEvents(); }); } // Export events const exportEvents = this.element.querySelector('.sse-export-events'); if (exportEvents) { this.addEventListener(exportEvents, 'click', () => { this.exportEvents(); }); } // Virtual scrolling if (this.options.virtualScrolling && this.scrollContainer) { const virtualScroll = this.scrollContainer.querySelector('.sse-virtual-scroll'); if (virtualScroll) { this.addEventListener( virtualScroll, 'scroll', this.throttle(() => { this.handleVirtualScroll(); }, 16), ); // ~60fps } } // Keyboard navigation this.addEventListener(this.scrollContainer, 'keydown', e => { this.handleKeyNavigation(e); }); } addEvent(event) { const processedEvent = { ...event, id: event.id || `event-${Date.now()}-${Math.random()}`, timestamp: event.timestamp || new Date(), selected: false, }; this.events.push(processedEvent); // Limit events to prevent memory issues if (this.events.length > this.options.maxEvents) { this.events.shift(); } this.updateFilteredEvents(); this.render(); if (this.isAutoScrolling) { this.scrollToBottom(); } this.updateEventCount(); } addEvents(events) { const startTime = performance.now(); events.forEach(event => { const processedEvent = { ...event, id: event.id || `event-${Date.now()}-${Math.random()}`, timestamp: event.timestamp || new Date(), selected: false, }; this.events.push(processedEvent); }); // Limit events if (this.events.length > this.options.maxEvents) { this.events.splice(0, this.events.length - this.options.maxEvents); } this.updateFilteredEvents(); this.render(); if (this.isAutoScrolling) { this.scrollToBottom(); } this.updateEventCount(); this.updateRenderStats(performance.now() - startTime); } updateFilteredEvents() { // This will be called by the parent component with filter criteria this.filteredEvents = [...this.events]; } applyFilters(filters) { this.filteredEvents = this.events.filter(event => { // Search filter if (filters.search) { const searchTerm = filters.search.toLowerCase(); const eventData = JSON.stringify(event.data).toLowerCase(); const eventType = event.type.toLowerCase(); const timestamp = event.timestamp.toISOString().toLowerCase(); if ( !eventData.includes(searchTerm) && !eventType.includes(searchTerm) && !timestamp.includes(searchTerm) ) { return false; } } // Type filter if (filters.types && filters.types.length > 0) { if (!filters.types.includes(event.type)) { return false; } } // Level filter (if event has level property) if (filters.levels && filters.levels.length > 0 && event.level) { if (!filters.levels.includes(event.level)) { return false; } } // Date range filter if (filters.dateRange) { if (filters.dateRange.start && event.timestamp < filters.dateRange.start) { return false; } if (filters.dateRange.end && event.timestamp > filters.dateRange.end) { return false; } } return true; }); this.render(); this.updateEventCount(); } render() { const startTime = performance.now(); if (this.options.virtualScrolling) { this.renderVirtual(); } else { this.renderSimple(); } this.renderStats.totalRenders++; this.renderStats.lastRenderTime = performance.now() - startTime; this.renderStats.averageRenderTime = (this.renderStats.averageRenderTime * (this.renderStats.totalRenders - 1) + this.renderStats.lastRenderTime) / this.renderStats.totalRenders; this.updatePerformanceStats(); super.render(); } renderVirtual() { if (!this.scrollContainer) return; const virtualScroll = this.scrollContainer.querySelector('.sse-virtual-scroll'); const virtualContainer = this.scrollContainer.querySelector('.sse-virtual-container'); const spacerTop = this.scrollContainer.querySelector('.sse-virtual-spacer-top'); const spacerBottom = this.scrollContainer.querySelector('.sse-virtual-spacer-bottom'); if (!virtualScroll || !virtualContainer || !spacerTop || !spacerBottom) return; const containerHeight = virtualScroll.clientHeight; const scrollTop = virtualScroll.scrollTop; const totalHeight = this.filteredEvents.length * this.options.itemHeight; // Calculate visible range const startIndex = Math.max( 0, Math.floor(scrollTop / this.options.itemHeight) - this.options.bufferSize, ); const endIndex = Math.min( this.filteredEvents.length, Math.ceil((scrollTop + containerHeight) / this.options.itemHeight) + this.options.bufferSize, ); this.visibleRange = { start: startIndex, end: endIndex }; // Update spacers spacerTop.style.height = `${startIndex * this.options.itemHeight}px`; spacerBottom.style.height = `${(this.filteredEvents.length - endIndex) * this.options.itemHeight}px`; // Render visible items const visibleEvents = this.filteredEvents.slice(startIndex, endIndex); virtualContainer.innerHTML = visibleEvents .map((event, index) => this.renderEventItem(event, startIndex + index)) .join(''); // Add event listeners to rendered items this.addEventItemListeners(virtualContainer); } renderSimple() { const container = this.scrollContainer.querySelector('.sse-simple-container'); if (!container) return; container.innerHTML = this.filteredEvents .map((event, index) => this.renderEventItem(event, index)) .join(''); this.addEventItemListeners(container); } renderEventItem(event, index) { const timestamp = this.formatTimestamp(event.timestamp); const eventData = this.formatEventData(event.data); const eventLevel = event.level || 'info'; return ` <div class="sse-event-item sse-event-${event.type} sse-level-${eventLevel}" data-event-id="${event.id}" data-event-index="${index}" role="listitem" tabindex="0" aria-describedby="event-${event.id}-details"> <div class="sse-event-header"> <div class="sse-event-type"> ${this.createIcon(this.getEventTypeIcon(event.type), 'sse-event-type-icon').outerHTML} <span class="sse-event-type-text">${event.type}</span> </div> ${this.options.showTimestamps ? `<div class="sse-event-timestamp">${timestamp}</div>` : ''} ${this.options.showEventIds && event.id ? `<div class="sse-event-id">${event.id}</div>` : ''} </div> <div class="sse-event-content" id="event-${event.id}-details"> <pre class="sse-event-data">${eventData}</pre> </div> <div class="sse-event-actions"> <button class="sse-btn sse-btn-xs sse-event-copy" title="Copy event data"> ${this.createIcon('export').outerHTML} </button> <button class="sse-btn sse-btn-xs sse-event-expand" title="Expand/collapse"> ${this.createIcon('expand').outerHTML} </button> </div> </div> `; } addEventItemListeners(container) { // Event item selection const eventItems = container.querySelectorAll('.sse-event-item'); eventItems.forEach(item => { this.addEventListener(item, 'click', e => { if (!e.target.closest('.sse-event-actions')) { this.selectEvent(item); } }); this.addEventListener(item, 'keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.selectEvent(item); } }); }); // Copy buttons const copyButtons = container.querySelectorAll('.sse-event-copy'); copyButtons.forEach(button => { this.addEventListener(button, 'click', e => { e.stopPropagation(); this.copyEventData(button.closest('.sse-event-item')); }); }); // Expand buttons const expandButtons = container.querySelectorAll('.sse-event-expand'); expandButtons.forEach(button => { this.addEventListener(button, 'click', e => { e.stopPropagation(); this.toggleEventExpansion(button.closest('.sse-event-item')); }); }); } handleVirtualScroll() { const virtualScroll = this.scrollContainer.querySelector('.sse-virtual-scroll'); if (!virtualScroll) return; const scrollTop = virtualScroll.scrollTop; const scrollHeight = virtualScroll.scrollHeight; const clientHeight = virtualScroll.clientHeight; // Check if user scrolled up (disable auto-scroll) if (scrollTop < this.lastScrollTop && this.isAutoScrolling) { // Only disable if not at bottom if (scrollTop + clientHeight < scrollHeight - 10) { this.setAutoScroll(false); } } this.lastScrollTop = scrollTop; this.render(); } handleKeyNavigation(e) { const selectedItem = this.element.querySelector('.sse-event-item.selected'); let targetItem = null; switch (e.key) { case 'ArrowDown': e.preventDefault(); targetItem = selectedItem ? selectedItem.nextElementSibling : this.element.querySelector('.sse-event-item'); break; case 'ArrowUp': e.preventDefault(); targetItem = selectedItem ? selectedItem.previousElementSibling : null; break; case 'Home': e.preventDefault(); targetItem = this.element.querySelector('.sse-event-item'); break; case 'End': e.preventDefault(); const items = this.element.querySelectorAll('.sse-event-item'); targetItem = items[items.length - 1]; break; } if (targetItem) { this.selectEvent(targetItem); targetItem.focus(); } } selectEvent(eventItem) { // Remove previous selection const previousSelected = this.element.querySelector('.sse-event-item.selected'); if (previousSelected) { previousSelected.classList.remove('selected'); previousSelected.setAttribute('aria-selected', 'false'); } // Add new selection eventItem.classList.add('selected'); eventItem.setAttribute('aria-selected', 'true'); // Get event data const eventId = eventItem.dataset.eventId; const eventIndex = parseInt(eventItem.dataset.eventIndex); const event = this.filteredEvents[eventIndex]; if (event) { this.onEventSelect(event, eventIndex); } } copyEventData(eventItem) { const eventId = eventItem.dataset.eventId; const eventIndex = parseInt(eventItem.dataset.eventIndex); const event = this.filteredEvents[eventIndex]; if (event && navigator.clipboard) { const eventData = JSON.stringify(event, null, 2); navigator.clipboard .writeText(eventData) .then(() => { this.showCopyFeedback(eventItem); }) .catch(err => { console.error('Failed to copy event data:', err); }); } } showCopyFeedback(eventItem) { const copyButton = eventItem.querySelector('.sse-event-copy'); if (copyButton) { const originalTitle = copyButton.title; copyButton.title = 'Copied!'; copyButton.classList.add('copied'); setTimeout(() => { copyButton.title = originalTitle; copyButton.classList.remove('copied'); }, 2000); } } toggleEventExpansion(eventItem) { eventItem.classList.toggle('expanded'); const expandButton = eventItem.querySelector('.sse-event-expand'); const icon = expandButton.querySelector('use'); if (eventItem.classList.contains('expanded')) { icon.setAttributeNS('http://www.w3.org/1999/xlink', 'href', '#collapse'); expandButton.title = 'Collapse'; } else { icon.setAttributeNS('http://www.w3.org/1999/xlink', 'href', '#expand'); expandButton.title = 'Expand'; } } toggleAutoScroll() { this.setAutoScroll(!this.isAutoScrolling); } setAutoScroll(enabled) { this.isAutoScrolling = enabled; const toggle = this.element.querySelector('.sse-auto-scroll-toggle'); if (toggle) { toggle.setAttribute('aria-pressed', enabled); toggle.classList.toggle('active', enabled); const icon = toggle.querySelector('use'); icon.setAttributeNS('http://www.w3.org/1999/xlink', 'href', enabled ? '#pause' : '#play'); } if (enabled) { this.scrollToBottom(); } } scrollToBottom() { const scrollElement = this.options.virtualScrolling ? this.scrollContainer.querySelector('.sse-virtual-scroll') : this.scrollContainer.querySelector('.sse-simple-container'); if (scrollElement) { scrollElement.scrollTop = scrollElement.scrollHeight; } } clearEvents() { this.events = []; this.filteredEvents = []; this.render(); this.updateEventCount(); // Announce to screen readers const announcement = document.createElement('div'); announcement.setAttribute('aria-live', 'polite'); announcement.setAttribute('aria-atomic', 'true'); announcement.textContent = 'All events cleared'; announcement.style.position = 'absolute'; announcement.style.left = '-10000px'; document.body.appendChild(announcement); setTimeout(() => { document.body.removeChild(announcement); }, 1000); } exportEvents() { const dataStr = JSON.stringify(this.filteredEvents, null, 2); const dataBlob = new Blob([dataStr], { type: 'application/json' }); const url = URL.createObjectURL(dataBlob); const link = document.createElement('a'); link.href = url; link.download = `sse-events-${new Date().toISOString().slice(0, 19)}.json`; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); } updateEventCount() { const countElement = this.element.querySelector('.sse-event-count'); if (countElement) { const total = this.events.length; const filtered = this.filteredEvents.length; if (total === filtered) { countElement.textContent = `${total} events`; } else { countElement.textContent = `${filtered} of ${total} events`; } } } updatePerformanceStats() { const statsElement = this.element.querySelector('.sse-render-stats'); if (statsElement) { statsElement.textContent = `Renders: ${this.renderStats.totalRenders} | ` + `Avg: ${this.renderStats.averageRenderTime.toFixed(1)}ms`; } } formatTimestamp(timestamp) { if (!timestamp) return ''; const date = new Date(timestamp); return ( date.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit', }) + '.' + date.getMilliseconds().toString().padStart(3, '0') ); } formatEventData(data) { if (typeof data === 'string') { try { return JSON.stringify(JSON.parse(data), null, 2); } catch { return data; } } return JSON.stringify(data, null, 2); } getEventTypeIcon(eventType) { const iconMap = { message: 'message', error: 'error', open: 'connected', close: 'disconnected', warning: 'warning', info: 'info', }; return iconMap[eventType] || 'message'; } getEvents() { return this.events; } getFilteredEvents() { return this.filteredEvents; } getSelectedEvent() { const selectedItem = this.element.querySelector('.sse-event-item.selected'); if (selectedItem) { const eventIndex = parseInt(selectedItem.dataset.eventIndex); return this.filteredEvents[eventIndex]; } return null; } } /** * Connection Status Component * Shows SSE connection status with visual indicators */ class ConnectionStatusComponent extends BaseComponent { constructor(container, options = {}) { super(container, { showStats: true, showControls: true, autoReconnect: true, ...options, }); this.status = 'disconnected'; this.stats = { connectionTime: null, totalEvents: 0, reconnectCount: 0, uptime: 0, lastError: null, }; this.onConnect = options.onConnect || (() => {}); this.onDisconnect = options.onDisconnect || (() => {}); this.onReconnect = options.onReconnect || (() => {}); } createElement() { this.element = document.createElement('div'); this.element.className = 'sse-connection-status'; this.element.setAttribute('role', 'status'); this.element.setAttribute('aria-label', 'Connection Status'); this.element.innerHTML = ` <div class="sse-status-header"> <div class="sse-status-indicator"> <div class="sse-status-icon" aria-hidden="true"> ${this.createIcon('disconnected').outerHTML} </div> <div class="sse-status-text"> <div class="sse-status-label">Disconnected</div> <div class="sse-status-details">Not connected to server</div> </div> </div> ${this.options.showControls ? this.createControls() : ''} </div> ${this.options.showStats ? this.createStats() : ''} `; this.container.appendChild(this.element); } createControls() { return ` <div class="sse-status-controls"> <button class="sse-btn sse-btn-primary sse-connect-btn" aria-describedby="connect-help"> ${this.createIcon('play').outerHTML} Connect </button> <button class="sse-btn sse-btn-secondary sse-disconnect-btn" style="display: none;" aria-describedby="disconnect-help"> ${this.createIcon('stop').outerHTML} Disconnect </button> <button class="sse-btn sse-btn-secondary sse-reconnect-btn" style="display: none;" aria-describedby="reconnect-help"> ${this.createIcon('refresh').outerHTML} Reconnect </button> </div> <div class="sse-control-help" role="region" aria-label="Control descriptions"> <div id="connect-help" class="sse-help-text">Start SSE connection</div> <div id="disconnect-help" class="sse-help-text">Stop SSE connection</div> <div id="reconnect-help" class="sse-help-text">Reconnect to server</div> </div> `; } createStats() { return ` <div class="sse-status-stats" role="region" aria-label="Connection Statistics"> <div class="sse-stats-grid"> <div class="sse-stat-item"> <div class="sse-stat-label">Uptime</div> <div class="sse-stat-value sse-uptime-value">00:00:00</div> </div> <div class="sse-stat-item"> <div class="sse-stat-label">Events</div> <div class="sse-stat-value sse-events-value">0</div> </div> <div class="sse-stat-item"> <div class="sse-stat-label">Reconnects</div> <div class="sse-stat-value sse-reconnects-value">0</div> </div> <div class="sse-stat-item"> <div class="sse-stat-label">Status</div> <div class="sse-stat-value sse-status-value">Disconnected</div> </div> </div> <div class="sse-error-display" style="display: none;" role="alert"> <div class="sse-error-title">Last Error:</div> <div class="sse-error-message"></div> <div class="sse-error-time"></div> </div> </div> `; } setupEventListeners() { // Connect button const connectBtn = this.element.querySelector('.sse-connect-btn'); if (connectBtn) { this.addEventListener(connectBtn, 'click', () => { this.onConnect(); }); } // Disconnect button const disconnectBtn = this.element.querySelector('.sse-disconnect-btn'); if (disconnectBtn) { this.addEventListener(disconnectBtn, 'click', () => { this.onDisconnect(); }); } // Reconnect button const reconnectBtn = this.element.querySelector('.sse-reconnect-btn'); if (reconnectBtn) { this.addEventListener(reconnectBtn, 'click', () => { this.onReconnect(); }); } // Start uptime counter if connected if (this.status === 'connected') { this.startUptimeCounter(); } } setStatus(status, details = '') { if (this.status === status) return; const previousStatus = this.status; this.status = status; // Update visual indicator this.updateStatusIndicator(status, details); // Update controls this.updateControls(status); // Update stats this.updateStatusStats(status); // Handle uptime counter if (status === 'connected' && previousStatus !== 'connected') { this.stats.connectionTime = Date.now(); this.startUptimeCounter(); } else if (status !== 'connected' && previousStatus === 'connected') { this.stopUptimeCounter(); } // Announce status change to screen readers this.announceStatusChange(status, previousStatus); } updateStatusIndicator(status, details) { const icon = this.element.querySelector('.sse-status-icon use'); const label = this.element.querySelector('.sse-status-label'); const detailsEl = this.element.querySelector('.sse-status-details'); const indicator = this.element.querySelector('.sse-status-indicator'); // Update icon const iconMap = { connected: 'connected', connecting: 'connecting', disconnected: 'disconnected', error: 'error', reconnecting: 'connecting', }; if (icon) { icon.setAttributeNS( 'http://www.w3.org/1999/xlink', 'href', `#${iconMap[status] || 'disconnected'}`, ); } // Update text if (label) { label.textContent = this.getStatusLabel(status); } if (detailsEl) { detailsEl.textContent = details || this.getStatusDetails(status); } // Update CSS classes if (indicator) { indicator.className = `sse-status-indicator sse-status-${status}`; } } updateControls(status) { const connectBtn = this.element.querySelector('.sse-connect-btn'); const disconnectBtn = this.element.querySelector('.sse-disconnect-btn'); const reconnectBtn = this.element.querySelector('.sse-reconnect-btn'); if (!connectBtn || !disconnectBtn || !reconnectBtn) return; switch (status) { case 'disconnected': connectBtn.style.display = 'inline-flex'; disconnectBtn.style.display = 'none'; reconnectBtn.style.display = 'none'; connectBtn.disabled = false; break; case 'connecting': connectBtn.style.display = 'inline-flex'; disconnectBtn.style.display = 'none'; reconnectBtn.style.display = 'none'; connectBtn.disabled = true; break; case 'connected': connectBtn.style.display = 'none'; disconnectBtn.style.display = 'inline-flex'; reconnectBtn.style.display = 'inline-flex'; disconnectBtn.disabled = false; reconnectBtn.disabled = false; break; case 'error': connectBtn.style.display = 'inline-flex'; disconnectBtn.style.display = 'none'; reconnectBtn.style.display = 'inline-flex'; connectBtn.disabled = false; reconnectBtn.disabled = false; break; case 'reconnecting': connectBtn.style.display = 'none'; disconnectBtn.style.display = 'inline-flex'; reconnectBtn.style.display = 'inline-flex'; disconnectBtn.disabled = false; reconnectBtn.disabled = true; break; } } updateStatusStats(status) { const statusValue = this.element.querySelector('.sse-status-value'); if (statusValue) { statusValue.textContent = this.getStatusLabel(status); statusValue.className = `sse-stat-value sse-status-value sse-status-${status}`; } } startUptimeCounter() { this.stopUptimeCounter(); // Clear any existing interval this.uptimeInterval = setInterval(() => { this.updateUptimeDisplay(); }, 1000); } stopUptimeCounter() { if (this.uptimeInterval) { clearInterval(this.uptimeInterval); this.uptimeInterval = null; } } updateUptimeDisplay() { const uptimeValue = this.element.querySelector('.sse-uptime-value'); if (!uptimeValue || !this.stats.connectionTime) return; const uptime = Date.now() - this.stats.connectionTime; uptimeValue.textContent = this.formatUptime(uptime); } updateStats(newStats) { Object.assign(this.stats, newStats); // Update events count const eventsValue = this.element.querySelector('.sse-events-value'); if (eventsValue) { eventsValue.textContent = this.stats.totalEvents.toLocaleString(); } // Update reconnects count const reconnectsValue = this.element.querySelector('.sse-reconnects-value'); if (reconnectsValue) { reconnectsValue.textContent = this.stats.reconnectCount.toString(); } // Update error display this.updateErrorDisplay(); } updateErrorDisplay() { const errorDisplay = this.element.querySelector('.sse-error-display'); const errorMessage = this.element.querySelector('.sse-error-message'); const errorTime = this.element.querySelector('.sse-error-time'); if (!errorDisplay || !errorMessage || !errorTime) return; if (this.stats.lastError) { errorDisplay.style.display = 'block'; errorMessage.textContent = this.stats.lastError.message || 'Unknown error'; errorTime.textContent = this.stats.lastError.timestamp ? new Date(this.stats.lastError.timestamp).toLocaleString() : ''; } else { errorDisplay.style.display = 'none'; } } announceStatusChange(newStatus, previousStatus) { const announcement = document.createElement('div'); announcement.setAttribute('aria-live', 'assertive'); announcement.setAttribute('aria-atomic', 'true'); announcement.textContent = `Connection status changed from ${this.getStatusLabel(previousStatus)} to ${this.getStatusLabel(newStatus)}`; announcement.style.position = 'absolute'; announcement.style.left = '-10000px'; document.body.appendChild(announcement); setTimeout(() => { document.body.removeChild(announcement); }, 1000); } getStatusLabel(status) { const labels = { connected: 'Connected', connecting: 'Connecting', disconnected: 'Disconnected', error: 'Error', reconnecting: 'Reconnecting', }; return labels[status] || 'Unknown'; } getStatusDetails(status) { const details = { connected: 'Successfully connected to server', connecting: 'Establishing connection...', disconnected: 'Not connected to server', error: 'Connection failed', reconnecting: 'Attempting to reconnect...', }; return details[status] || ''; } formatUptime(milliseconds) { const seconds = Math.floor(milliseconds / 1000); const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const secs = seconds % 60; return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; } getStatus() { return this.status; } getStats() { return { ...this.stats }; } destroy() { this.stopUptimeCounter(); super.destroy(); } } // Export components if (typeof module !== 'undefined' && module.exports) { module.exports = { BaseComponent, EventFilterComponent, EventListComponent, ConnectionStatusComponent, }; } else if (typeof window !== 'undefined') { window.SSEComponents = { BaseComponent, EventFilterComponent, EventListComponent, ConnectionStatusComponent, }; }