ecfr-navigator
Version:
A lightweight, reusable Vue 3 component with Pinia integration for navigating hierarchical eCFR-style content in existing Vue applications.
454 lines (395 loc) • 13.9 kB
JavaScript
import { ref, reactive, computed, watch } from 'vue';
import yourtownfinanceFinanceTheme from '../themes/your-town-finance.js';
// Global theme state with Your Town Finance brand colors as default
const themeState = reactive({
currentTheme: {
colors: {
primary: 'rgb(56, 96, 190)', // Your Town Finance matt-blue
secondary: 'rgb(1, 52, 116)', // Your Town Finance tardis-blue
success: 'rgb(50, 174, 136)', // Your Town Finance herbal green
warning: 'rgb(255, 188, 35)', // Your Town Finance warning yellow
danger: '#ef4444',
background: 'rgb(255, 255, 255)' // Your Town Finance white
},
typography: {
fontFamily: 'Muli, sans-serif', // Your Town Finance brand font
fontSize: 16,
lineHeight: 1.5,
letterSpacing: 0
},
spacing: {
base: 4,
borderRadius: 8,
padding: 16
},
components: {
buttonStyle: 'rounded',
cardShadow: 'medium',
inputStyle: 'outlined'
}
},
savedThemes: [],
preferences: {
autoSave: true,
syncAcrossDevices: false,
darkModePreference: 'system' // 'light', 'dark', 'system'
}
});
// Load saved themes from localStorage
function loadSavedThemes() {
try {
const saved = localStorage.getItem('savedThemes');
if (saved) {
themeState.savedThemes = JSON.parse(saved);
}
} catch (error) {
console.warn('Failed to load saved themes:', error);
}
}
// Load user preferences
function loadPreferences() {
try {
const preferences = localStorage.getItem('themePreferences');
if (preferences) {
Object.assign(themeState.preferences, JSON.parse(preferences));
}
} catch (error) {
console.warn('Failed to load theme preferences:', error);
}
}
// Save preferences to localStorage
function savePreferences() {
try {
localStorage.setItem('themePreferences', JSON.stringify(themeState.preferences));
} catch (error) {
console.warn('Failed to save theme preferences:', error);
}
}
// Apply theme to document
function applyThemeToDocument(theme) {
const root = document.documentElement;
// Apply CSS custom properties
Object.entries(theme.colors).forEach(([key, value]) => {
root.style.setProperty(`--theme-${key}`, value);
});
root.style.setProperty('--theme-font-family', theme.typography.fontFamily);
root.style.setProperty('--theme-font-size', `${theme.typography.fontSize}px`);
root.style.setProperty('--theme-line-height', theme.typography.lineHeight);
root.style.setProperty('--theme-letter-spacing', `${theme.typography.letterSpacing}em`);
root.style.setProperty('--theme-spacing-base', `${theme.spacing.base}px`);
root.style.setProperty('--theme-border-radius', `${theme.spacing.borderRadius}px`);
root.style.setProperty('--theme-padding', `${theme.spacing.padding}px`);
// Apply component-specific styles
const buttonRadius = theme.components.buttonStyle === 'pill' ? '9999px' :
theme.components.buttonStyle === 'sharp' ? '0px' :
`${theme.spacing.borderRadius}px`;
root.style.setProperty('--theme-button-radius', buttonRadius);
const cardShadow = {
none: 'none',
subtle: '0 1px 3px rgba(0, 0, 0, 0.1)',
medium: '0 4px 6px rgba(0, 0, 0, 0.1)',
strong: '0 10px 15px rgba(0, 0, 0, 0.1)'
}[theme.components.cardShadow] || '0 4px 6px rgba(0, 0, 0, 0.1)';
root.style.setProperty('--theme-card-shadow', cardShadow);
}
// Generate color variations (lighter/darker shades)
function generateColorVariations(color) {
// This is a simplified color manipulation
// In a real implementation, you might use a color manipulation library
const variations = {};
// Convert hex to RGB
const hex = color.replace('#', '');
const r = parseInt(hex.substr(0, 2), 16);
const g = parseInt(hex.substr(2, 2), 16);
const b = parseInt(hex.substr(4, 2), 16);
// Generate lighter shades
for (let i = 1; i <= 9; i++) {
const factor = i * 0.1;
const newR = Math.min(255, Math.round(r + (255 - r) * factor));
const newG = Math.min(255, Math.round(g + (255 - g) * factor));
const newB = Math.min(255, Math.round(b + (255 - b) * factor));
variations[`${i}00`] = `rgb(${newR}, ${newG}, ${newB})`;
}
// Generate darker shades
for (let i = 1; i <= 9; i++) {
const factor = i * 0.1;
const newR = Math.round(r * (1 - factor));
const newG = Math.round(g * (1 - factor));
const newB = Math.round(b * (1 - factor));
variations[`${i + 5}00`] = `rgb(${newR}, ${newG}, ${newB})`;
}
variations['500'] = color; // Base color
return variations;
}
// Detect user preferences from system
function detectSystemPreferences() {
const preferences = {};
// Detect dark mode preference
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
preferences.prefersDarkMode = true;
}
// Detect reduced motion preference
if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
preferences.prefersReducedMotion = true;
}
// Detect high contrast preference
if (window.matchMedia && window.matchMedia('(prefers-contrast: high)').matches) {
preferences.prefersHighContrast = true;
}
return preferences;
}
// Main theme composable
export function useTheme() {
// Initialize themes and preferences
if (themeState.savedThemes.length === 0) {
loadSavedThemes();
}
loadPreferences();
// Computed values
const currentTheme = computed(() => themeState.currentTheme);
const savedThemes = computed(() => themeState.savedThemes);
const preferences = computed(() => themeState.preferences);
const themeVariables = computed(() => {
const theme = themeState.currentTheme;
return {
'--theme-primary': theme.colors.primary,
'--theme-secondary': theme.colors.secondary,
'--theme-success': theme.colors.success,
'--theme-warning': theme.colors.warning,
'--theme-danger': theme.colors.danger,
'--theme-background': theme.colors.background,
'--theme-font-family': theme.typography.fontFamily,
'--theme-font-size': `${theme.typography.fontSize}px`,
'--theme-line-height': theme.typography.lineHeight,
'--theme-letter-spacing': `${theme.typography.letterSpacing}em`,
'--theme-spacing-base': `${theme.spacing.base}px`,
'--theme-border-radius': `${theme.spacing.borderRadius}px`,
'--theme-padding': `${theme.spacing.padding}px`
};
});
// Apply theme when it changes
watch(
() => themeState.currentTheme,
(newTheme) => {
applyThemeToDocument(newTheme);
if (themeState.preferences.autoSave) {
localStorage.setItem('currentTheme', JSON.stringify(newTheme));
}
},
{ deep: true, immediate: true }
);
// Methods
const setTheme = (theme) => {
themeState.currentTheme = { ...themeState.currentTheme, ...theme };
};
const updateColors = (colors) => {
Object.assign(themeState.currentTheme.colors, colors);
};
const updateTypography = (typography) => {
Object.assign(themeState.currentTheme.typography, typography);
};
const updateSpacing = (spacing) => {
Object.assign(themeState.currentTheme.spacing, spacing);
};
const updateComponents = (components) => {
Object.assign(themeState.currentTheme.components, components);
};
const saveTheme = (name, theme = null) => {
const themeToSave = theme || themeState.currentTheme;
const savedTheme = {
id: Date.now().toString(),
name,
theme: JSON.parse(JSON.stringify(themeToSave)),
createdAt: new Date().toISOString(),
lastModified: new Date().toISOString()
};
themeState.savedThemes.push(savedTheme);
localStorage.setItem('savedThemes', JSON.stringify(themeState.savedThemes));
return savedTheme;
};
const loadTheme = (themeId) => {
const theme = themeState.savedThemes.find(t => t.id === themeId);
if (theme) {
setTheme(theme.theme);
return true;
}
return false;
};
const deleteTheme = (themeId) => {
const index = themeState.savedThemes.findIndex(t => t.id === themeId);
if (index !== -1) {
themeState.savedThemes.splice(index, 1);
localStorage.setItem('savedThemes', JSON.stringify(themeState.savedThemes));
return true;
}
return false;
};
const exportTheme = (format = 'json') => {
const theme = themeState.currentTheme;
switch (format) {
case 'css':
return generateCSSVariables(theme);
case 'scss':
return generateSCSSVariables(theme);
case 'json':
return JSON.stringify(theme, null, 2);
case 'js':
return `export const theme = ${JSON.stringify(theme, null, 2)};`;
default:
return JSON.stringify(theme, null, 2);
}
};
const generateCSSVariables = (theme) => {
return `:root {
/* Colors */
--theme-primary: ${theme.colors.primary};
--theme-secondary: ${theme.colors.secondary};
--theme-success: ${theme.colors.success};
--theme-warning: ${theme.colors.warning};
--theme-danger: ${theme.colors.danger};
--theme-background: ${theme.colors.background};
/* Typography */
--theme-font-family: ${theme.typography.fontFamily};
--theme-font-size: ${theme.typography.fontSize}px;
--theme-line-height: ${theme.typography.lineHeight};
--theme-letter-spacing: ${theme.typography.letterSpacing}em;
/* Spacing */
--theme-spacing-base: ${theme.spacing.base}px;
--theme-border-radius: ${theme.spacing.borderRadius}px;
--theme-padding: ${theme.spacing.padding}px;
}`;
};
const generateSCSSVariables = (theme) => {
return `// Colors
$theme-primary: ${theme.colors.primary};
$theme-secondary: ${theme.colors.secondary};
$theme-success: ${theme.colors.success};
$theme-warning: ${theme.colors.warning};
$theme-danger: ${theme.colors.danger};
$theme-background: ${theme.colors.background};
// Typography
$theme-font-family: ${theme.typography.fontFamily};
$theme-font-size: ${theme.typography.fontSize}px;
$theme-line-height: ${theme.typography.lineHeight};
$theme-letter-spacing: ${theme.typography.letterSpacing}em;
// Spacing
$theme-spacing-base: ${theme.spacing.base}px;
$theme-border-radius: ${theme.spacing.borderRadius}px;
$theme-padding: ${theme.spacing.padding}px;`;
};
const resetToDefault = () => {
themeState.currentTheme = {
colors: {
primary: '#3b82f6',
secondary: '#6b7280',
success: '#10b981',
warning: '#f59e0b',
danger: '#ef4444',
background: '#ffffff'
},
typography: {
fontFamily: 'Inter, system-ui, sans-serif',
fontSize: 16,
lineHeight: 1.5,
letterSpacing: 0
},
spacing: {
base: 4,
borderRadius: 8,
padding: 16
},
components: {
buttonStyle: 'rounded',
cardShadow: 'medium',
inputStyle: 'outlined'
}
};
};
const updatePreferences = (newPreferences) => {
Object.assign(themeState.preferences, newPreferences);
savePreferences();
};
const getColorVariations = (colorKey) => {
const color = themeState.currentTheme.colors[colorKey];
return generateColorVariations(color);
};
const detectAndApplySystemPreferences = () => {
const systemPrefs = detectSystemPreferences();
if (systemPrefs.prefersDarkMode && themeState.preferences.darkModePreference === 'system') {
// Apply dark theme
updateColors({
background: '#111827',
primary: '#3b82f6',
secondary: '#6b7280'
});
}
return systemPrefs;
};
// Initialize
const initialize = () => {
// Load saved current theme if auto-save is enabled
if (themeState.preferences.autoSave) {
try {
const savedCurrent = localStorage.getItem('currentTheme');
if (savedCurrent) {
const parsed = JSON.parse(savedCurrent);
setTheme(parsed);
}
} catch (error) {
console.warn('Failed to load current theme:', error);
}
}
// Apply system preferences if configured
if (themeState.preferences.darkModePreference === 'system') {
detectAndApplySystemPreferences();
}
// Listen for system preference changes
if (window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (themeState.preferences.darkModePreference === 'system') {
detectAndApplySystemPreferences();
}
});
}
};
return {
// State
currentTheme,
savedThemes,
preferences,
themeVariables,
// Methods
setTheme,
updateColors,
updateTypography,
updateSpacing,
updateComponents,
saveTheme,
loadTheme,
deleteTheme,
exportTheme,
resetToDefault,
updatePreferences,
getColorVariations,
detectAndApplySystemPreferences,
initialize
};
}
// Theme provider for app-wide theme management
export function createThemeProvider() {
const theme = useTheme();
// Initialize theme system
theme.initialize();
return {
install(app) {
// Provide theme globally
app.provide('theme', theme);
// Add global properties
app.config.globalProperties.$theme = theme;
}
};
}
// Hook for components to use theme
export function useThemeProvider() {
return useTheme();
}