@nestjsvn/swagger-sse
Version:
OpenAPI documentation and interactive Swagger UI for NestJS Server-Sent Events endpoints
703 lines (592 loc) • 19.2 kB
JavaScript
/**
* 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;
}