UNPKG

@nestjsvn/swagger-sse

Version:

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

703 lines (592 loc) 19.2 kB
/** * Theme Manager for SSE Plugin * Handles theme switching, CSS custom properties, and user preferences * with support for system theme detection and accessibility features. */ class ThemeManager { constructor(options = {}) { this.options = { defaultTheme: 'light', storageKey: 'sse-plugin-theme', autoDetectSystem: true, supportedThemes: ['light', 'dark', 'auto'], cssVariablePrefix: '--sse-', ...options, }; this.currentTheme = null; this.systemTheme = null; this.mediaQuery = null; this.themeChangeListeners = new Set(); // Theme definitions this.themes = { light: { name: 'Light', variables: { 'primary-color': '#007bff', 'secondary-color': '#6c757d', 'success-color': '#28a745', 'warning-color': '#ffc107', 'error-color': '#dc3545', 'info-color': '#17a2b8', 'background-color': '#ffffff', 'surface-color': '#f8f9fa', 'card-color': '#ffffff', 'border-color': '#dee2e6', 'divider-color': '#e9ecef', 'text-primary': '#212529', 'text-secondary': '#6c757d', 'text-muted': '#868e96', 'text-inverse': '#ffffff', 'shadow-sm': '0 0.125rem 0.25rem rgba(0, 0, 0, 0.075)', shadow: '0 0.5rem 1rem rgba(0, 0, 0, 0.15)', 'shadow-lg': '0 1rem 3rem rgba(0, 0, 0, 0.175)', 'border-radius': '0.375rem', 'border-radius-sm': '0.25rem', 'border-radius-lg': '0.5rem', 'font-family': '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', 'font-size-base': '0.875rem', 'font-size-sm': '0.75rem', 'font-size-lg': '1rem', 'spacing-xs': '0.25rem', 'spacing-sm': '0.5rem', 'spacing-md': '1rem', 'spacing-lg': '1.5rem', 'spacing-xl': '2rem', 'transition-fast': '0.15s ease-in-out', 'transition-normal': '0.3s ease-in-out', 'transition-slow': '0.5s ease-in-out', }, }, dark: { name: 'Dark', variables: { 'primary-color': '#0d6efd', 'secondary-color': '#6c757d', 'success-color': '#198754', 'warning-color': '#ffc107', 'error-color': '#dc3545', 'info-color': '#0dcaf0', 'background-color': '#121212', 'surface-color': '#1e1e1e', 'card-color': '#2d2d2d', 'border-color': '#404040', 'divider-color': '#333333', 'text-primary': '#ffffff', 'text-secondary': '#b3b3b3', 'text-muted': '#888888', 'text-inverse': '#000000', 'shadow-sm': '0 0.125rem 0.25rem rgba(0, 0, 0, 0.3)', shadow: '0 0.5rem 1rem rgba(0, 0, 0, 0.4)', 'shadow-lg': '0 1rem 3rem rgba(0, 0, 0, 0.5)', 'border-radius': '0.375rem', 'border-radius-sm': '0.25rem', 'border-radius-lg': '0.5rem', 'font-family': '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', 'font-size-base': '0.875rem', 'font-size-sm': '0.75rem', 'font-size-lg': '1rem', 'spacing-xs': '0.25rem', 'spacing-sm': '0.5rem', 'spacing-md': '1rem', 'spacing-lg': '1.5rem', 'spacing-xl': '2rem', 'transition-fast': '0.15s ease-in-out', 'transition-normal': '0.3s ease-in-out', 'transition-slow': '0.5s ease-in-out', }, }, }; this.init(); } init() { this.detectSystemTheme(); this.setupMediaQueryListener(); this.loadSavedTheme(); this.applyTheme(); this.setupAccessibilityFeatures(); } /** * Detect system theme preference */ detectSystemTheme() { if (!window.matchMedia) { this.systemTheme = 'light'; return; } this.mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); this.systemTheme = this.mediaQuery.matches ? 'dark' : 'light'; } /** * Setup media query listener for system theme changes */ setupMediaQueryListener() { if (!this.mediaQuery) return; const handleSystemThemeChange = e => { this.systemTheme = e.matches ? 'dark' : 'light'; // If current theme is 'auto', apply the new system theme if (this.currentTheme === 'auto') { this.applyTheme(); this.notifyThemeChange(); } }; // Modern browsers if (this.mediaQuery.addEventListener) { this.mediaQuery.addEventListener('change', handleSystemThemeChange); } else { // Legacy browsers this.mediaQuery.addListener(handleSystemThemeChange); } } /** * Load saved theme from storage */ loadSavedTheme() { try { const savedTheme = localStorage.getItem(this.options.storageKey); if (savedTheme && this.options.supportedThemes.includes(savedTheme)) { this.currentTheme = savedTheme; } else { this.currentTheme = this.options.defaultTheme; } } catch (error) { console.warn('ThemeManager: Failed to load saved theme', error); this.currentTheme = this.options.defaultTheme; } } /** * Save theme to storage */ saveTheme(theme) { try { localStorage.setItem(this.options.storageKey, theme); } catch (error) { console.warn('ThemeManager: Failed to save theme', error); } } /** * Set theme */ setTheme(theme) { if (!this.options.supportedThemes.includes(theme)) { console.warn(`ThemeManager: Unsupported theme "${theme}"`); return; } const previousTheme = this.currentTheme; this.currentTheme = theme; this.saveTheme(theme); this.applyTheme(); if (previousTheme !== theme) { this.notifyThemeChange(); } } /** * Get current theme */ getCurrentTheme() { return this.currentTheme; } /** * Get effective theme (resolves 'auto' to actual theme) */ getEffectiveTheme() { if (this.currentTheme === 'auto') { return this.systemTheme || 'light'; } return this.currentTheme; } /** * Apply theme to document */ applyTheme() { const effectiveTheme = this.getEffectiveTheme(); const themeConfig = this.themes[effectiveTheme]; if (!themeConfig) { console.warn(`ThemeManager: Theme configuration not found for "${effectiveTheme}"`); return; } // Apply CSS custom properties this.applyCSSVariables(themeConfig.variables); // Update document classes this.updateDocumentClasses(effectiveTheme); // Update meta theme-color for mobile browsers this.updateMetaThemeColor(themeConfig.variables['primary-color']); console.log(`ThemeManager: Applied theme "${effectiveTheme}"`); } /** * Apply CSS custom properties */ applyCSSVariables(variables) { const root = document.documentElement; Object.entries(variables).forEach(([key, value]) => { const cssVar = `${this.options.cssVariablePrefix}${key}`; root.style.setProperty(cssVar, value); }); } /** * Update document classes */ updateDocumentClasses(theme) { const body = document.body; const html = document.documentElement; // Remove existing theme classes this.options.supportedThemes.forEach(t => { if (t !== 'auto') { body.classList.remove(`sse-theme-${t}`); html.classList.remove(`sse-theme-${t}`); } }); // Add current theme class body.classList.add(`sse-theme-${theme}`); html.classList.add(`sse-theme-${theme}`); // Add data attribute for CSS selectors body.setAttribute('data-sse-theme', theme); html.setAttribute('data-sse-theme', theme); } /** * Update meta theme-color for mobile browsers */ updateMetaThemeColor(color) { let metaThemeColor = document.querySelector('meta[name="theme-color"]'); if (!metaThemeColor) { metaThemeColor = document.createElement('meta'); metaThemeColor.name = 'theme-color'; document.head.appendChild(metaThemeColor); } metaThemeColor.content = color; } /** * Setup accessibility features */ setupAccessibilityFeatures() { // Respect user's motion preferences this.handleReducedMotion(); // Handle high contrast mode this.handleHighContrast(); // Setup focus management this.setupFocusManagement(); } /** * Handle reduced motion preference */ handleReducedMotion() { if (!window.matchMedia) return; const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); const updateMotionPreference = e => { const root = document.documentElement; if (e.matches) { root.style.setProperty(`${this.options.cssVariablePrefix}transition-fast`, 'none'); root.style.setProperty(`${this.options.cssVariablePrefix}transition-normal`, 'none'); root.style.setProperty(`${this.options.cssVariablePrefix}transition-slow`, 'none'); document.body.classList.add('sse-reduced-motion'); } else { const theme = this.getEffectiveTheme(); const themeConfig = this.themes[theme]; if (themeConfig) { root.style.setProperty( `${this.options.cssVariablePrefix}transition-fast`, themeConfig.variables['transition-fast'], ); root.style.setProperty( `${this.options.cssVariablePrefix}transition-normal`, themeConfig.variables['transition-normal'], ); root.style.setProperty( `${this.options.cssVariablePrefix}transition-slow`, themeConfig.variables['transition-slow'], ); } document.body.classList.remove('sse-reduced-motion'); } }; updateMotionPreference(prefersReducedMotion); if (prefersReducedMotion.addEventListener) { prefersReducedMotion.addEventListener('change', updateMotionPreference); } else { prefersReducedMotion.addListener(updateMotionPreference); } } /** * Handle high contrast mode */ handleHighContrast() { if (!window.matchMedia) return; const prefersHighContrast = window.matchMedia('(prefers-contrast: high)'); const updateContrastPreference = e => { if (e.matches) { document.body.classList.add('sse-high-contrast'); this.applyHighContrastTheme(); } else { document.body.classList.remove('sse-high-contrast'); this.applyTheme(); // Reapply normal theme } }; updateContrastPreference(prefersHighContrast); if (prefersHighContrast.addEventListener) { prefersHighContrast.addEventListener('change', updateContrastPreference); } else { prefersHighContrast.addListener(updateContrastPreference); } } /** * Apply high contrast theme adjustments */ applyHighContrastTheme() { const root = document.documentElement; const effectiveTheme = this.getEffectiveTheme(); if (effectiveTheme === 'dark') { // High contrast dark theme root.style.setProperty(`${this.options.cssVariablePrefix}background-color`, '#000000'); root.style.setProperty(`${this.options.cssVariablePrefix}text-primary`, '#ffffff'); root.style.setProperty(`${this.options.cssVariablePrefix}border-color`, '#ffffff'); } else { // High contrast light theme root.style.setProperty(`${this.options.cssVariablePrefix}background-color`, '#ffffff'); root.style.setProperty(`${this.options.cssVariablePrefix}text-primary`, '#000000'); root.style.setProperty(`${this.options.cssVariablePrefix}border-color`, '#000000'); } } /** * Setup focus management */ setupFocusManagement() { // Add focus-visible polyfill behavior document.addEventListener('keydown', e => { if (e.key === 'Tab') { document.body.classList.add('sse-keyboard-navigation'); } }); document.addEventListener('mousedown', () => { document.body.classList.remove('sse-keyboard-navigation'); }); } /** * Toggle between light and dark themes */ toggleTheme() { const currentEffective = this.getEffectiveTheme(); const newTheme = currentEffective === 'light' ? 'dark' : 'light'; this.setTheme(newTheme); } /** * Cycle through all available themes */ cycleTheme() { const currentIndex = this.options.supportedThemes.indexOf(this.currentTheme); const nextIndex = (currentIndex + 1) % this.options.supportedThemes.length; const nextTheme = this.options.supportedThemes[nextIndex]; this.setTheme(nextTheme); } /** * Get available themes */ getAvailableThemes() { return this.options.supportedThemes.map(theme => ({ value: theme, label: theme === 'auto' ? 'Auto (System)' : this.themes[theme]?.name || theme, current: theme === this.currentTheme, })); } /** * Add theme change listener */ onThemeChange(callback) { this.themeChangeListeners.add(callback); return () => this.themeChangeListeners.delete(callback); } /** * Notify theme change listeners */ notifyThemeChange() { const themeInfo = { current: this.currentTheme, effective: this.getEffectiveTheme(), system: this.systemTheme, }; this.themeChangeListeners.forEach(callback => { try { callback(themeInfo); } catch (error) { console.error('ThemeManager: Error in theme change callback', error); } }); } /** * Create theme selector UI */ createThemeSelector(container, options = {}) { const selectorOptions = { showLabel: true, label: 'Theme', className: 'sse-theme-selector', ...options, }; const selector = document.createElement('div'); selector.className = selectorOptions.className; const themes = this.getAvailableThemes(); selector.innerHTML = ` ${selectorOptions.showLabel ? `<label class="sse-theme-label">${selectorOptions.label}</label>` : ''} <select class="sse-theme-select" aria-label="Select theme"> ${themes .map( theme => `<option value="${theme.value}" ${theme.current ? 'selected' : ''}>${theme.label}</option>`, ) .join('')} </select> `; const select = selector.querySelector('.sse-theme-select'); select.addEventListener('change', e => { this.setTheme(e.target.value); }); // Update selector when theme changes const unsubscribe = this.onThemeChange(() => { const currentThemes = this.getAvailableThemes(); select.innerHTML = currentThemes .map( theme => `<option value="${theme.value}" ${theme.current ? 'selected' : ''}>${theme.label}</option>`, ) .join(''); }); // Cleanup function selector._cleanup = unsubscribe; if (container) { container.appendChild(selector); } return selector; } /** * Create theme toggle button */ createThemeToggle(container, options = {}) { const toggleOptions = { showLabel: true, className: 'sse-theme-toggle', ...options, }; const toggle = document.createElement('button'); toggle.className = `${toggleOptions.className} sse-btn sse-btn-secondary`; toggle.setAttribute('aria-label', 'Toggle theme'); toggle.setAttribute('title', 'Toggle between light and dark themes'); const updateToggle = () => { const effective = this.getEffectiveTheme(); const icon = effective === 'light' ? '🌙' : '☀️'; const label = effective === 'light' ? 'Dark' : 'Light'; toggle.innerHTML = ` <span class="sse-theme-icon" aria-hidden="true">${icon}</span> ${toggleOptions.showLabel ? `<span class="sse-theme-text">${label}</span>` : ''} `; }; updateToggle(); toggle.addEventListener('click', () => { this.toggleTheme(); }); // Update toggle when theme changes const unsubscribe = this.onThemeChange(updateToggle); // Cleanup function toggle._cleanup = unsubscribe; if (container) { container.appendChild(toggle); } return toggle; } /** * Add custom theme */ addTheme(name, config) { if (this.themes[name]) { console.warn(`ThemeManager: Theme "${name}" already exists`); } this.themes[name] = { name: config.displayName || name, variables: { ...config.variables }, }; if (!this.options.supportedThemes.includes(name)) { this.options.supportedThemes.push(name); } console.log(`ThemeManager: Added theme "${name}"`); } /** * Remove custom theme */ removeTheme(name) { if (['light', 'dark', 'auto'].includes(name)) { console.warn(`ThemeManager: Cannot remove built-in theme "${name}"`); return; } delete this.themes[name]; const index = this.options.supportedThemes.indexOf(name); if (index > -1) { this.options.supportedThemes.splice(index, 1); } // Switch to default theme if current theme was removed if (this.currentTheme === name) { this.setTheme(this.options.defaultTheme); } console.log(`ThemeManager: Removed theme "${name}"`); } /** * Get CSS variable value */ getCSSVariable(name) { const cssVar = `${this.options.cssVariablePrefix}${name}`; return getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim(); } /** * Set CSS variable value */ setCSSVariable(name, value) { const cssVar = `${this.options.cssVariablePrefix}${name}`; document.documentElement.style.setProperty(cssVar, value); } /** * Export current theme configuration */ exportTheme() { const effectiveTheme = this.getEffectiveTheme(); const themeConfig = this.themes[effectiveTheme]; return { name: effectiveTheme, displayName: themeConfig.name, variables: { ...themeConfig.variables }, timestamp: new Date().toISOString(), }; } /** * Import theme configuration */ importTheme(themeData) { if (!themeData.name || !themeData.variables) { throw new Error('Invalid theme data format'); } this.addTheme(themeData.name, { displayName: themeData.displayName, variables: themeData.variables, }); return themeData.name; } /** * Cleanup resources */ destroy() { // Remove media query listeners if (this.mediaQuery) { if (this.mediaQuery.removeEventListener) { this.mediaQuery.removeEventListener('change', this.handleSystemThemeChange); } else { this.mediaQuery.removeListener(this.handleSystemThemeChange); } } // Clear listeners this.themeChangeListeners.clear(); console.log('ThemeManager: Destroyed'); } } // Export for use in other modules if (typeof module !== 'undefined' && module.exports) { module.exports = ThemeManager; } else if (typeof window !== 'undefined') { window.ThemeManager = ThemeManager; }