678 lines
23 KiB
JavaScript
678 lines
23 KiB
JavaScript
/**
|
|
* Tab Management System
|
|
*
|
|
* Provides a centralized TabManager class and helper functions for creating
|
|
* tabbed interfaces with consistent styling, lazy loading, and state management.
|
|
*
|
|
* @module components/tabs
|
|
*/
|
|
|
|
/**
|
|
* TabManager - Manages a tabbed interface
|
|
*
|
|
* Features:
|
|
* - Lazy loading of tab content
|
|
* - State management (active tab, initialized tabs)
|
|
* - Automatic event handling
|
|
* - Visibility control
|
|
* - Consistent styling
|
|
*
|
|
* @example
|
|
* const tabManager = new TabManager({
|
|
* container: document.getElementById('myTabs'),
|
|
* onTabSwitch: (tabId) => console.log('Switched to:', tabId)
|
|
* });
|
|
*
|
|
* tabManager.addTab('home', 'Home', (container) => {
|
|
* container.innerHTML = '<p>Home content</p>';
|
|
* });
|
|
*
|
|
* tabManager.addTab('settings', 'Settings', (container) => {
|
|
* container.innerHTML = '<p>Settings content</p>';
|
|
* }, { visible: true });
|
|
*
|
|
* tabManager.init();
|
|
*/
|
|
export class TabManager {
|
|
/**
|
|
* Create a TabManager
|
|
* @param {Object} options - Configuration options
|
|
* @param {HTMLElement} options.container - Parent container element
|
|
* @param {Function} [options.onTabSwitch] - Callback when tab is switched (tabId)
|
|
* @param {Function} [options.onTabInit] - Callback when tab is initialized (tabId)
|
|
* @param {string} [options.defaultTab] - Default active tab ID
|
|
* @param {boolean} [options.lazyLoad=true] - Enable lazy loading of tab content
|
|
* @param {Object} [options.styles] - Custom style overrides
|
|
*/
|
|
constructor(options = {}) {
|
|
this.container = options.container;
|
|
this.onTabSwitch = options.onTabSwitch || null;
|
|
this.onTabInit = options.onTabInit || null;
|
|
this.defaultTab = options.defaultTab || null;
|
|
this.lazyLoad = options.lazyLoad !== false;
|
|
this.customStyles = options.styles || {};
|
|
|
|
// State
|
|
this.tabs = new Map(); // tabId -> tab config
|
|
this.activeTabId = null;
|
|
this.initializedTabs = new Set();
|
|
|
|
// DOM elements
|
|
this.tabHeader = null;
|
|
this.tabContent = null;
|
|
this.tabButtons = new Map(); // tabId -> button element
|
|
this.tabContainers = new Map(); // tabId -> content container
|
|
}
|
|
|
|
/**
|
|
* Initialize the tab manager - creates header and content areas
|
|
*/
|
|
init() {
|
|
if (!this.container) {
|
|
throw new Error('TabManager: container element is required');
|
|
}
|
|
|
|
// Create tab header
|
|
this.tabHeader = document.createElement('div');
|
|
this.tabHeader.className = 'tab-header';
|
|
this.tabHeader.style.cssText = `
|
|
display: flex;
|
|
background: #1e1e1e;
|
|
border-bottom: 2px solid #333;
|
|
padding: 0 10px;
|
|
gap: 2px;
|
|
overflow-x: auto;
|
|
min-height: 50px;
|
|
position: relative;
|
|
${this.customStyles.header || ''}
|
|
`;
|
|
|
|
// Create tab content area
|
|
this.tabContent = document.createElement('div');
|
|
this.tabContent.className = 'tab-content-area';
|
|
this.tabContent.style.cssText = `
|
|
flex: 1;
|
|
overflow: auto;
|
|
${this.customStyles.content || ''}
|
|
`;
|
|
|
|
this.container.appendChild(this.tabHeader);
|
|
this.container.appendChild(this.tabContent);
|
|
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Add a tab to the manager
|
|
* @param {string} id - Unique tab identifier
|
|
* @param {string} label - Tab button label
|
|
* @param {Function} contentFactory - Function to create tab content: (container) => void
|
|
* @param {Object} [options] - Tab options
|
|
* @param {boolean} [options.visible=true] - Whether tab is visible
|
|
* @param {boolean} [options.active=false] - Whether tab should be active initially
|
|
* @param {string} [options.icon] - Optional icon/emoji for tab button
|
|
* @param {Object} [options.data] - Custom data associated with tab
|
|
* @returns {TabManager} Returns this for chaining
|
|
*/
|
|
addTab(id, label, contentFactory, options = {}) {
|
|
if (this.tabs.has(id)) {
|
|
console.warn(`TabManager: tab with id '${id}' already exists`);
|
|
return this;
|
|
}
|
|
|
|
const tabConfig = {
|
|
id,
|
|
label,
|
|
contentFactory,
|
|
visible: options.visible !== false,
|
|
icon: options.icon || '',
|
|
data: options.data || {}
|
|
};
|
|
|
|
this.tabs.set(id, tabConfig);
|
|
|
|
// Create tab button if visible
|
|
if (tabConfig.visible) {
|
|
this.createTabButton(id);
|
|
}
|
|
|
|
// Create content container
|
|
this.createTabContainer(id);
|
|
|
|
// Set as default tab if specified
|
|
if (options.active && !this.defaultTab) {
|
|
this.defaultTab = id;
|
|
}
|
|
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Create a tab button
|
|
* @private
|
|
* @param {string} tabId - Tab ID
|
|
*/
|
|
createTabButton(tabId) {
|
|
const config = this.tabs.get(tabId);
|
|
if (!config) return;
|
|
|
|
const button = document.createElement('button');
|
|
button.className = 'tab-button';
|
|
button.dataset.tabId = tabId;
|
|
|
|
const displayText = config.icon ? `${config.icon} ${config.label}` : config.label;
|
|
button.textContent = displayText;
|
|
|
|
button.style.cssText = `
|
|
padding: 10px 20px;
|
|
border: none;
|
|
background: #2a2a2a;
|
|
color: #ccc;
|
|
cursor: pointer;
|
|
border-radius: 6px 6px 0 0;
|
|
font-size: 13px;
|
|
font-weight: normal;
|
|
transition: all 0.2s ease;
|
|
border-bottom: 2px solid transparent;
|
|
position: relative;
|
|
top: 2px;
|
|
flex-shrink: 0;
|
|
${this.customStyles.button || ''}
|
|
`;
|
|
|
|
// Hover effects
|
|
button.addEventListener('mouseenter', () => {
|
|
if (!button.classList.contains('active')) {
|
|
button.style.background = '#3a3a3a';
|
|
button.style.color = 'white';
|
|
button.style.transform = 'translateY(-1px)';
|
|
}
|
|
});
|
|
|
|
button.addEventListener('mouseleave', () => {
|
|
if (!button.classList.contains('active')) {
|
|
button.style.background = '#2a2a2a';
|
|
button.style.color = '#ccc';
|
|
button.style.transform = 'translateY(0)';
|
|
}
|
|
});
|
|
|
|
// Click handler
|
|
button.addEventListener('click', () => this.switchTab(tabId));
|
|
|
|
this.tabButtons.set(tabId, button);
|
|
this.tabHeader.appendChild(button);
|
|
}
|
|
|
|
/**
|
|
* Create a tab content container
|
|
* @private
|
|
* @param {string} tabId - Tab ID
|
|
*/
|
|
createTabContainer(tabId) {
|
|
const container = document.createElement('div');
|
|
container.className = 'tab-container';
|
|
container.dataset.tabId = tabId;
|
|
container.style.cssText = `
|
|
display: none;
|
|
height: 100%;
|
|
overflow: auto;
|
|
`;
|
|
|
|
this.tabContainers.set(tabId, container);
|
|
this.tabContent.appendChild(container);
|
|
}
|
|
|
|
/**
|
|
* Switch to a specific tab
|
|
* @param {string} tabId - ID of tab to activate
|
|
* @returns {boolean} True if switch was successful
|
|
*/
|
|
switchTab(tabId) {
|
|
const config = this.tabs.get(tabId);
|
|
if (!config) {
|
|
console.error(`TabManager: Unknown tab '${tabId}'`);
|
|
return false;
|
|
}
|
|
|
|
if (!config.visible) {
|
|
console.warn(`TabManager: Cannot switch to hidden tab '${tabId}'`);
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
// Update button styles
|
|
this.tabButtons.forEach((button, id) => {
|
|
const isActive = id === tabId;
|
|
button.classList.toggle('active', isActive);
|
|
button.style.background = isActive ? '#4CAF50' : '#2a2a2a';
|
|
button.style.color = isActive ? 'white' : '#ccc';
|
|
button.style.fontWeight = isActive ? 'bold' : 'normal';
|
|
button.style.borderBottom = isActive ? '2px solid #4CAF50' : '2px solid transparent';
|
|
button.style.transform = isActive ? 'translateY(-1px)' : 'translateY(0)';
|
|
});
|
|
|
|
// Hide all containers
|
|
this.tabContainers.forEach(container => {
|
|
container.style.display = 'none';
|
|
});
|
|
|
|
// Show active container
|
|
const container = this.tabContainers.get(tabId);
|
|
if (container) {
|
|
container.style.display = 'block';
|
|
}
|
|
|
|
// Initialize tab content if needed (lazy loading)
|
|
if (this.lazyLoad && !this.initializedTabs.has(tabId)) {
|
|
this.initializeTab(tabId, true); // Show loading for user-initiated switches
|
|
}
|
|
|
|
this.activeTabId = tabId;
|
|
|
|
// Callback
|
|
if (this.onTabSwitch) {
|
|
this.onTabSwitch(tabId);
|
|
}
|
|
|
|
return true;
|
|
} catch (error) {
|
|
console.error(`TabManager: Error switching to tab '${tabId}':`, error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize tab content
|
|
* @private
|
|
* @param {string} tabId - Tab ID
|
|
* @param {boolean} [showLoading=true] - Whether to show loading state
|
|
*/
|
|
initializeTab(tabId, showLoading = true) {
|
|
const config = this.tabs.get(tabId);
|
|
const container = this.tabContainers.get(tabId);
|
|
|
|
if (!config || !container) return;
|
|
|
|
// Skip if already initialized
|
|
if (this.initializedTabs.has(tabId)) {
|
|
console.debug(`TabManager: Tab '${tabId}' already initialized, skipping`);
|
|
return;
|
|
}
|
|
|
|
console.debug(`TabManager: Initializing tab '${tabId}'`);
|
|
|
|
try {
|
|
// Show loading state only if requested (skip for background preloading)
|
|
if (showLoading) {
|
|
container.innerHTML = `
|
|
<div style="text-align: center; padding: 40px; color: #888;">
|
|
<div style="font-size: 16px; margin-bottom: 10px;">Loading ${config.label}...</div>
|
|
<div style="font-size: 12px;">Please wait</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// Initialize content immediately for background loading, with small delay for user-initiated
|
|
const initDelay = showLoading ? 50 : 0;
|
|
|
|
setTimeout(() => {
|
|
try {
|
|
container.innerHTML = ''; // Clear loading message
|
|
config.contentFactory(container);
|
|
this.initializedTabs.add(tabId);
|
|
console.debug(`TabManager: Tab '${tabId}' initialized successfully`);
|
|
|
|
// Callback
|
|
if (this.onTabInit) {
|
|
this.onTabInit(tabId);
|
|
}
|
|
} catch (error) {
|
|
console.error(`TabManager: Error initializing tab '${tabId}':`, error);
|
|
container.innerHTML = `
|
|
<div style="color: #f44336; padding: 20px; text-align: center;">
|
|
<div style="font-size: 16px; margin-bottom: 10px;">Error loading ${config.label}</div>
|
|
<div style="font-size: 14px; opacity: 0.8;">${error.message}</div>
|
|
</div>
|
|
`;
|
|
}
|
|
}, initDelay);
|
|
} catch (error) {
|
|
console.error(`TabManager: Error setting up initialization for tab '${tabId}':`, error);
|
|
container.innerHTML = `
|
|
<div style="color: #f44336; padding: 20px; text-align: center;">
|
|
Error: ${error.message}
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove a tab
|
|
* @param {string} tabId - Tab ID to remove
|
|
* @returns {boolean} True if removal was successful
|
|
*/
|
|
removeTab(tabId) {
|
|
if (!this.tabs.has(tabId)) {
|
|
console.warn(`TabManager: Cannot remove non-existent tab '${tabId}'`);
|
|
return false;
|
|
}
|
|
|
|
// Remove button
|
|
const button = this.tabButtons.get(tabId);
|
|
if (button && button.parentNode) {
|
|
button.parentNode.removeChild(button);
|
|
}
|
|
this.tabButtons.delete(tabId);
|
|
|
|
// Remove container
|
|
const container = this.tabContainers.get(tabId);
|
|
if (container && container.parentNode) {
|
|
container.parentNode.removeChild(container);
|
|
}
|
|
this.tabContainers.delete(tabId);
|
|
|
|
// Remove from state
|
|
this.tabs.delete(tabId);
|
|
this.initializedTabs.delete(tabId);
|
|
|
|
// If active tab was removed, switch to first available
|
|
if (this.activeTabId === tabId) {
|
|
const firstTab = Array.from(this.tabs.keys()).find(id => this.tabs.get(id).visible);
|
|
if (firstTab) {
|
|
this.switchTab(firstTab);
|
|
} else {
|
|
this.activeTabId = null;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Show a hidden tab
|
|
* @param {string} tabId - Tab ID to show
|
|
* @returns {boolean} True if successful
|
|
*/
|
|
showTab(tabId) {
|
|
const config = this.tabs.get(tabId);
|
|
if (!config) {
|
|
console.warn(`TabManager: Cannot show non-existent tab '${tabId}'`);
|
|
return false;
|
|
}
|
|
|
|
if (config.visible) {
|
|
return true; // Already visible
|
|
}
|
|
|
|
config.visible = true;
|
|
|
|
// Create button if it doesn't exist
|
|
if (!this.tabButtons.has(tabId)) {
|
|
this.createTabButton(tabId);
|
|
} else {
|
|
const button = this.tabButtons.get(tabId);
|
|
button.style.display = 'block';
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Hide a tab
|
|
* @param {string} tabId - Tab ID to hide
|
|
* @returns {boolean} True if successful
|
|
*/
|
|
hideTab(tabId) {
|
|
const config = this.tabs.get(tabId);
|
|
if (!config) {
|
|
console.warn(`TabManager: Cannot hide non-existent tab '${tabId}'`);
|
|
return false;
|
|
}
|
|
|
|
config.visible = false;
|
|
|
|
const button = this.tabButtons.get(tabId);
|
|
if (button) {
|
|
button.style.display = 'none';
|
|
}
|
|
|
|
// If active tab is being hidden, switch to first visible
|
|
if (this.activeTabId === tabId) {
|
|
const firstVisible = Array.from(this.tabs.keys()).find(id => this.tabs.get(id).visible);
|
|
if (firstVisible) {
|
|
this.switchTab(firstVisible);
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Get the currently active tab ID
|
|
* @returns {string|null} Active tab ID or null if none active
|
|
*/
|
|
getActiveTab() {
|
|
return this.activeTabId;
|
|
}
|
|
|
|
/**
|
|
* Check if a tab has been initialized
|
|
* @param {string} tabId - Tab ID to check
|
|
* @returns {boolean} True if tab has been initialized
|
|
*/
|
|
isTabInitialized(tabId) {
|
|
return this.initializedTabs.has(tabId);
|
|
}
|
|
|
|
/**
|
|
* Get all tab IDs
|
|
* @returns {string[]} Array of tab IDs
|
|
*/
|
|
getTabIds() {
|
|
return Array.from(this.tabs.keys());
|
|
}
|
|
|
|
/**
|
|
* Get all visible tab IDs
|
|
* @returns {string[]} Array of visible tab IDs
|
|
*/
|
|
getVisibleTabIds() {
|
|
return Array.from(this.tabs.keys()).filter(id => this.tabs.get(id).visible);
|
|
}
|
|
|
|
/**
|
|
* Activate the first visible tab (usually called after adding all tabs)
|
|
* @returns {boolean} True if a tab was activated
|
|
*/
|
|
activateFirstTab() {
|
|
const firstTab = this.defaultTab || this.getVisibleTabIds()[0];
|
|
if (firstTab) {
|
|
return this.switchTab(firstTab);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Preload tabs during idle time
|
|
* Initializes uninitialized tabs in the background to improve responsiveness
|
|
* Uses requestIdleCallback to avoid blocking the main thread
|
|
* @param {Object} [options] - Preload options
|
|
* @param {number} [options.maxIdleTime=50] - Maximum time to spend in each idle callback (ms)
|
|
* @param {number} [options.timeout=2000] - Timeout for idle callback (ms)
|
|
* @param {string[]} [options.priority] - Array of tab IDs to prioritize for preloading
|
|
*/
|
|
preloadTabsDuringIdle(options = {}) {
|
|
const maxIdleTime = options.maxIdleTime || 50; // kept for API compatibility (no longer a hard gate)
|
|
const timeout = options.timeout || 2000;
|
|
const priorityTabs = options.priority || [];
|
|
|
|
// Get uninitialized tabs
|
|
const uninitializedTabs = this.getVisibleTabIds().filter(
|
|
tabId => !this.initializedTabs.has(tabId) && tabId !== this.activeTabId
|
|
);
|
|
|
|
if (uninitializedTabs.length === 0) {
|
|
console.debug('[TabManager] All tabs already initialized');
|
|
return;
|
|
}
|
|
|
|
// Sort tabs by priority
|
|
const sortedTabs = [...uninitializedTabs].sort((a, b) => {
|
|
const aPriority = priorityTabs.indexOf(a);
|
|
const bPriority = priorityTabs.indexOf(b);
|
|
|
|
// If both have priority, sort by priority order
|
|
if (aPriority !== -1 && bPriority !== -1) {
|
|
return aPriority - bPriority;
|
|
}
|
|
// Priority tabs come first
|
|
if (aPriority !== -1) return -1;
|
|
if (bPriority !== -1) return 1;
|
|
// Otherwise maintain original order
|
|
return 0;
|
|
});
|
|
|
|
console.debug(`[TabManager] Starting background preload for ${sortedTabs.length} tabs:`, sortedTabs);
|
|
|
|
// Use requestIdleCallback to initialize tabs during idle time
|
|
const preloadNextTab = (index) => {
|
|
if (index >= sortedTabs.length) {
|
|
console.debug('[TabManager] Background preload complete');
|
|
return;
|
|
}
|
|
|
|
const tabId = sortedTabs[index];
|
|
|
|
// Skip if tab was initialized by user interaction in the meantime
|
|
if (this.initializedTabs.has(tabId)) {
|
|
console.debug(`[TabManager] Tab '${tabId}' already initialized, skipping preload`);
|
|
preloadNextTab(index + 1);
|
|
return;
|
|
}
|
|
|
|
// Check if browser supports requestIdleCallback
|
|
if (typeof requestIdleCallback === 'function') {
|
|
requestIdleCallback(() => {
|
|
// Skip if tab was initialized while waiting
|
|
if (this.initializedTabs.has(tabId)) {
|
|
console.debug(`[TabManager] Tab '${tabId}' already initialized, skipping preload`);
|
|
preloadNextTab(index + 1);
|
|
return;
|
|
}
|
|
|
|
console.debug(`[TabManager] Preloading tab '${tabId}' during idle time`);
|
|
this.initializeTab(tabId, false); // No loading message for background
|
|
|
|
// Schedule next tab
|
|
preloadNextTab(index + 1);
|
|
}, { timeout });
|
|
} else {
|
|
// Fallback for browsers without requestIdleCallback
|
|
console.debug(`[TabManager] Preloading tab '${tabId}' using setTimeout fallback`);
|
|
setTimeout(() => {
|
|
if (!this.initializedTabs.has(tabId)) {
|
|
this.initializeTab(tabId, false); // No loading message for background
|
|
}
|
|
preloadNextTab(index + 1);
|
|
}, 100);
|
|
}
|
|
};
|
|
|
|
// Start preloading
|
|
preloadNextTab(0);
|
|
}
|
|
|
|
/**
|
|
* Update tab visibility based on settings object
|
|
* @param {Object} visibilitySettings - Object mapping tab IDs to boolean visibility
|
|
* @example
|
|
* tabManager.updateVisibility({
|
|
* 'home': true,
|
|
* 'settings': false,
|
|
* 'about': true
|
|
* });
|
|
*/
|
|
updateVisibility(visibilitySettings) {
|
|
Object.entries(visibilitySettings).forEach(([tabId, visible]) => {
|
|
if (visible) {
|
|
this.showTab(tabId);
|
|
} else {
|
|
this.hideTab(tabId);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Cleanup and destroy the tab manager
|
|
*/
|
|
destroy() {
|
|
// Remove all tabs
|
|
this.getTabIds().forEach(id => this.removeTab(id));
|
|
|
|
// Remove DOM elements
|
|
if (this.tabHeader && this.tabHeader.parentNode) {
|
|
this.tabHeader.parentNode.removeChild(this.tabHeader);
|
|
}
|
|
if (this.tabContent && this.tabContent.parentNode) {
|
|
this.tabContent.parentNode.removeChild(this.tabContent);
|
|
}
|
|
|
|
// Clear state
|
|
this.tabs.clear();
|
|
this.tabButtons.clear();
|
|
this.tabContainers.clear();
|
|
this.initializedTabs.clear();
|
|
this.activeTabId = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a standalone tab button (for use outside TabManager)
|
|
* @param {string} text - Button text
|
|
* @param {boolean} isActive - Whether the button is initially active
|
|
* @param {Object} [options] - Style options
|
|
* @returns {HTMLElement} Tab button element
|
|
*
|
|
* @deprecated Use TabManager class instead for better state management
|
|
*/
|
|
export function createTabButton(text, isActive = false, options = {}) {
|
|
const button = document.createElement('button');
|
|
button.textContent = text;
|
|
button.style.cssText = `
|
|
padding: 10px 20px;
|
|
border: none;
|
|
background: ${isActive ? '#4CAF50' : '#2a2a2a'};
|
|
color: ${isActive ? 'white' : '#ccc'};
|
|
cursor: pointer;
|
|
border-radius: 6px 6px 0 0;
|
|
margin-right: 2px;
|
|
font-size: 13px;
|
|
font-weight: ${isActive ? 'bold' : 'normal'};
|
|
transition: all 0.2s ease;
|
|
border-bottom: 2px solid ${isActive ? '#4CAF50' : 'transparent'};
|
|
position: relative;
|
|
top: 2px;
|
|
${options.customStyles || ''}
|
|
`;
|
|
|
|
// Hover effects
|
|
button.addEventListener('mouseenter', () => {
|
|
if (!button.classList.contains('active')) {
|
|
button.style.background = '#3a3a3a';
|
|
button.style.color = 'white';
|
|
button.style.transform = 'translateY(-1px)';
|
|
}
|
|
});
|
|
|
|
button.addEventListener('mouseleave', () => {
|
|
if (!button.classList.contains('active')) {
|
|
button.style.background = '#2a2a2a';
|
|
button.style.color = '#ccc';
|
|
button.style.transform = 'translateY(0)';
|
|
}
|
|
});
|
|
|
|
return button;
|
|
}
|