@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
JavaScript
/**
* 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,
};
}