934 lines
31 KiB
JavaScript
934 lines
31 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
|
|
*/
|
|
|
|
import { loadComponentStyles as ensureComponentStyles } from './styleLoader.js';
|
|
|
|
console.log('[SageUtils] tabs.js imported');
|
|
|
|
function loadComponentStyles() {
|
|
ensureComponentStyles('tabs.js');
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
*/
|
|
|
|
function isTabDebugEnabled() {
|
|
try {
|
|
if (typeof window === 'undefined') {
|
|
return false;
|
|
}
|
|
|
|
const debugFlag = window.localStorage?.getItem('sageutils_tab_debug') === 'true';
|
|
const queryFlag = new URLSearchParams(window.location.search).get('sage_tab_debug') === '1';
|
|
return debugFlag || queryFlag;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function logTabDebug(...args) {
|
|
if (isTabDebugEnabled()) {
|
|
console.debug(...args);
|
|
}
|
|
}
|
|
|
|
function ensureTabManagerStyles() {
|
|
loadComponentStyles();
|
|
}
|
|
|
|
function applyCustomStyles(element, cssText) {
|
|
if (!cssText || typeof cssText !== 'string') {
|
|
return;
|
|
}
|
|
|
|
cssText.split(';').forEach((declaration) => {
|
|
const [rawName, rawValue] = declaration.split(':');
|
|
if (!rawName || rawValue === undefined) {
|
|
return;
|
|
}
|
|
|
|
const styleName = rawName.trim().replace(/-([a-z])/g, (match, letter) => letter.toUpperCase());
|
|
const styleValue = rawValue.trim();
|
|
if (styleValue) {
|
|
element.style[styleName] = styleValue;
|
|
}
|
|
});
|
|
}
|
|
|
|
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
|
|
this.initializingTabs = new Set();
|
|
this.cleanupCallbacks = new Set();
|
|
this.pendingInitTimeouts = new Map();
|
|
this.activePreloadSession = null;
|
|
this.isDestroyed = false;
|
|
}
|
|
|
|
/**
|
|
* Normalize tab definition into a lifecycle contract.
|
|
* Supports legacy factory functions and newer lifecycle objects.
|
|
* @private
|
|
* @param {Function|Object} tabDefinition - Function(container) or { mount, unmount, refresh }
|
|
* @returns {{mount: Function, unmount: Function|null, refresh: Function|null}}
|
|
*/
|
|
resolveTabLifecycle(tabDefinition) {
|
|
if (typeof tabDefinition === 'function') {
|
|
return {
|
|
mount: tabDefinition,
|
|
unmount: null,
|
|
refresh: null
|
|
};
|
|
}
|
|
|
|
if (tabDefinition && typeof tabDefinition === 'object' && typeof tabDefinition.mount === 'function') {
|
|
return {
|
|
mount: tabDefinition.mount,
|
|
unmount: typeof tabDefinition.unmount === 'function' ? tabDefinition.unmount : null,
|
|
refresh: typeof tabDefinition.refresh === 'function' ? tabDefinition.refresh : null
|
|
};
|
|
}
|
|
|
|
throw new Error('TabManager: tab definition must be a function or an object with a mount method');
|
|
}
|
|
|
|
/**
|
|
* Execute tab refresh if available.
|
|
* @private
|
|
* @param {Object} config - Tab configuration
|
|
*/
|
|
executeTabRefresh(config) {
|
|
const refreshFn = config?.instance?.refresh || config?.lifecycle?.refresh;
|
|
if (typeof refreshFn !== 'function') {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
refreshFn({ tabId: config.id, tabManager: this });
|
|
} catch (error) {
|
|
console.warn(`TabManager: refresh failed for tab '${config.id}':`, error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Execute tab unmount if available.
|
|
* @private
|
|
* @param {Object} config - Tab configuration
|
|
*/
|
|
executeTabUnmount(config) {
|
|
const hasMounted = this.initializedTabs.has(config?.id);
|
|
const unmountFn = config?.instance?.unmount || (hasMounted ? config?.lifecycle?.unmount : null);
|
|
if (typeof unmountFn !== 'function') {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
unmountFn({ tabId: config.id, tabManager: this });
|
|
} catch (error) {
|
|
console.warn(`TabManager: unmount failed for tab '${config.id}':`, error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Register a cleanup callback that should run on destroy.
|
|
* @param {Function} cleanup - Callback executed during destroy
|
|
* @returns {Function} Unregister cleanup callback
|
|
*/
|
|
registerCleanup(cleanup) {
|
|
if (typeof cleanup !== 'function') {
|
|
return () => {};
|
|
}
|
|
|
|
this.cleanupCallbacks.add(cleanup);
|
|
return () => {
|
|
this.cleanupCallbacks.delete(cleanup);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Cancel a pending initialization timeout.
|
|
* @private
|
|
* @param {string} tabId - Tab ID
|
|
*/
|
|
cancelPendingInit(tabId) {
|
|
const timeoutId = this.pendingInitTimeouts.get(tabId);
|
|
if (timeoutId === undefined) {
|
|
return;
|
|
}
|
|
|
|
clearTimeout(timeoutId);
|
|
this.pendingInitTimeouts.delete(tabId);
|
|
this.initializingTabs.delete(tabId);
|
|
}
|
|
|
|
/**
|
|
* Cancel all pending initialization timeouts.
|
|
* @private
|
|
*/
|
|
cancelAllPendingInits() {
|
|
this.pendingInitTimeouts.forEach((timeoutId) => {
|
|
clearTimeout(timeoutId);
|
|
});
|
|
this.pendingInitTimeouts.clear();
|
|
this.initializingTabs.clear();
|
|
}
|
|
|
|
/**
|
|
* Dispose a mount result that resolved after the tab manager was torn down.
|
|
* @private
|
|
* @param {string} tabId - Tab ID
|
|
* @param {Function|Object|null} mountResult - Resolved mount result
|
|
*/
|
|
cleanupDeferredMountResult(tabId, mountResult) {
|
|
const cleanup = typeof mountResult === 'function'
|
|
? mountResult
|
|
: (typeof mountResult?.unmount === 'function'
|
|
? mountResult.unmount
|
|
: (typeof mountResult?.destroy === 'function' ? mountResult.destroy : null));
|
|
|
|
if (typeof cleanup !== 'function') {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
cleanup({ tabId, tabManager: this });
|
|
} catch (error) {
|
|
console.warn(`TabManager: deferred cleanup failed for tab '${tabId}':`, error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cancel the active background preload session, if any.
|
|
*/
|
|
cancelScheduledPreloads() {
|
|
const session = this.activePreloadSession;
|
|
if (!session) {
|
|
return;
|
|
}
|
|
|
|
session.cancelled = true;
|
|
|
|
if (session.idleCallbackId !== null && typeof cancelIdleCallback === 'function') {
|
|
cancelIdleCallback(session.idleCallbackId);
|
|
}
|
|
|
|
if (session.timeoutId !== null) {
|
|
clearTimeout(session.timeoutId);
|
|
}
|
|
|
|
this.activePreloadSession = null;
|
|
}
|
|
|
|
/**
|
|
* Initialize the tab manager - creates header and content areas
|
|
*/
|
|
init() {
|
|
if (!this.container) {
|
|
throw new Error('TabManager: container element is required');
|
|
}
|
|
|
|
this.isDestroyed = false;
|
|
ensureTabManagerStyles();
|
|
|
|
// Create tab header
|
|
this.tabHeader = document.createElement('div');
|
|
this.tabHeader.className = 'tab-header sage-tabs-header';
|
|
if (this.customStyles.header) {
|
|
applyCustomStyles(this.tabHeader, this.customStyles.header);
|
|
}
|
|
|
|
// Create tab content area
|
|
this.tabContent = document.createElement('div');
|
|
this.tabContent.className = 'tab-content-area sage-tabs-content-area';
|
|
if (this.customStyles.content) {
|
|
applyCustomStyles(this.tabContent, 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|Object} tabDefinition - Legacy factory (container => void) or lifecycle object
|
|
* with { mount(container, context), optional unmount(context), optional refresh(context) }
|
|
* @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, tabDefinition, options = {}) {
|
|
if (this.tabs.has(id)) {
|
|
console.warn(`TabManager: tab with id '${id}' already exists`);
|
|
return this;
|
|
}
|
|
|
|
const tabConfig = {
|
|
id,
|
|
label,
|
|
lifecycle: this.resolveTabLifecycle(tabDefinition),
|
|
instance: null,
|
|
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 sage-tab-button';
|
|
button.dataset.tabId = tabId;
|
|
|
|
const displayText = config.icon ? `${config.icon} ${config.label}` : config.label;
|
|
button.textContent = displayText;
|
|
|
|
if (this.customStyles.button) {
|
|
applyCustomStyles(button, this.customStyles.button);
|
|
}
|
|
|
|
// 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 sage-tab-container';
|
|
container.dataset.tabId = tabId;
|
|
|
|
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);
|
|
});
|
|
|
|
// Hide all containers
|
|
this.tabContainers.forEach(container => {
|
|
container.classList.remove('active');
|
|
});
|
|
|
|
// Show active container
|
|
const container = this.tabContainers.get(tabId);
|
|
if (container) {
|
|
container.classList.add('active');
|
|
}
|
|
|
|
// Initialize tab content if needed (lazy loading)
|
|
if (this.lazyLoad && !this.initializedTabs.has(tabId)) {
|
|
this.initializeTab(tabId, true); // Show loading for user-initiated switches
|
|
} else if (this.initializedTabs.has(tabId)) {
|
|
// Optional hook for tab content that needs refresh-on-show behavior.
|
|
this.executeTabRefresh(config);
|
|
}
|
|
|
|
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)) {
|
|
logTabDebug(`TabManager: Tab '${tabId}' already initialized, skipping`);
|
|
return;
|
|
}
|
|
|
|
// Skip duplicate initialization attempts while async mount is in progress.
|
|
if (this.initializingTabs.has(tabId)) {
|
|
logTabDebug(`TabManager: Tab '${tabId}' is already initializing, skipping`);
|
|
return;
|
|
}
|
|
|
|
this.initializingTabs.add(tabId);
|
|
|
|
logTabDebug(`TabManager: Initializing tab '${tabId}'`);
|
|
|
|
try {
|
|
// Show loading state only if requested (skip for background preloading)
|
|
if (showLoading) {
|
|
container.innerHTML = `
|
|
<div class="sage-tab-loading">
|
|
<div class="sage-tab-loading-heading">Loading ${config.label}...</div>
|
|
<div class="sage-tab-loading-message">Please wait</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// Initialize content immediately for background loading, with small delay for user-initiated
|
|
const initDelay = showLoading ? 50 : 0;
|
|
|
|
const timeoutId = setTimeout(async () => {
|
|
this.pendingInitTimeouts.delete(tabId);
|
|
|
|
if (this.isDestroyed || !this.tabs.has(tabId) || this.tabContainers.get(tabId) !== container) {
|
|
this.initializingTabs.delete(tabId);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
container.innerHTML = ''; // Clear loading message
|
|
const mountResult = await config.lifecycle.mount(container, {
|
|
tabId,
|
|
tabManager: this,
|
|
isBackground: !showLoading
|
|
});
|
|
|
|
if (this.isDestroyed || !this.tabs.has(tabId) || this.tabContainers.get(tabId) !== container) {
|
|
this.cleanupDeferredMountResult(tabId, mountResult);
|
|
return;
|
|
}
|
|
|
|
if (typeof mountResult === 'function') {
|
|
config.instance = {
|
|
unmount: mountResult,
|
|
refresh: null
|
|
};
|
|
} else if (mountResult && typeof mountResult === 'object') {
|
|
config.instance = {
|
|
unmount: typeof mountResult.unmount === 'function'
|
|
? mountResult.unmount
|
|
: (typeof mountResult.destroy === 'function' ? mountResult.destroy : null),
|
|
refresh: typeof mountResult.refresh === 'function' ? mountResult.refresh : null
|
|
};
|
|
} else {
|
|
config.instance = null;
|
|
}
|
|
|
|
this.initializedTabs.add(tabId);
|
|
logTabDebug(`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 class="sage-tab-error">
|
|
<div class="sage-tab-error-heading">Error loading ${config.label}</div>
|
|
<div class="sage-tab-error-text">${error.message}</div>
|
|
</div>
|
|
`;
|
|
} finally {
|
|
this.initializingTabs.delete(tabId);
|
|
}
|
|
}, initDelay);
|
|
|
|
this.pendingInitTimeouts.set(tabId, timeoutId);
|
|
} catch (error) {
|
|
console.error(`TabManager: Error setting up initialization for tab '${tabId}':`, error);
|
|
container.innerHTML = `
|
|
<div class="sage-tab-error">
|
|
<div class="sage-tab-error-text">Error: ${error.message}</div>
|
|
</div>
|
|
`;
|
|
this.initializingTabs.delete(tabId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove a tab
|
|
* @param {string} tabId - Tab ID to remove
|
|
* @returns {boolean} True if removal was successful
|
|
*/
|
|
removeTab(tabId) {
|
|
const config = this.tabs.get(tabId);
|
|
if (!config) {
|
|
console.warn(`TabManager: Cannot remove non-existent tab '${tabId}'`);
|
|
return false;
|
|
}
|
|
|
|
this.cancelPendingInit(tabId);
|
|
this.executeTabUnmount(config);
|
|
|
|
// 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);
|
|
this.initializingTabs.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.classList.remove('sage-tab-hidden');
|
|
}
|
|
|
|
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.classList.add('sage-tab-hidden');
|
|
}
|
|
|
|
// 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 = {}) {
|
|
this.cancelScheduledPreloads();
|
|
|
|
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) {
|
|
logTabDebug('[TabManager] All tabs already initialized');
|
|
return { cancel: () => {} };
|
|
}
|
|
|
|
// 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;
|
|
});
|
|
|
|
logTabDebug(`[TabManager] Starting background preload for ${sortedTabs.length} tabs:`, sortedTabs);
|
|
|
|
const session = {
|
|
cancelled: false,
|
|
idleCallbackId: null,
|
|
timeoutId: null,
|
|
cancel: () => {
|
|
if (this.activePreloadSession !== session) {
|
|
return;
|
|
}
|
|
this.cancelScheduledPreloads();
|
|
}
|
|
};
|
|
this.activePreloadSession = session;
|
|
|
|
const finishSession = () => {
|
|
if (this.activePreloadSession === session) {
|
|
this.activePreloadSession = null;
|
|
}
|
|
};
|
|
|
|
// Use requestIdleCallback to initialize tabs during idle time
|
|
const preloadNextTab = (index) => {
|
|
if (session.cancelled || this.isDestroyed) {
|
|
finishSession();
|
|
return;
|
|
}
|
|
|
|
if (index >= sortedTabs.length) {
|
|
logTabDebug('[TabManager] Background preload complete');
|
|
finishSession();
|
|
return;
|
|
}
|
|
|
|
const tabId = sortedTabs[index];
|
|
|
|
// Skip if tab was initialized by user interaction in the meantime
|
|
if (this.initializedTabs.has(tabId)) {
|
|
logTabDebug(`[TabManager] Tab '${tabId}' already initialized, skipping preload`);
|
|
preloadNextTab(index + 1);
|
|
return;
|
|
}
|
|
|
|
// Check if browser supports requestIdleCallback
|
|
if (typeof requestIdleCallback === 'function') {
|
|
session.idleCallbackId = requestIdleCallback(() => {
|
|
session.idleCallbackId = null;
|
|
|
|
if (session.cancelled || this.isDestroyed) {
|
|
finishSession();
|
|
return;
|
|
}
|
|
|
|
// Skip if tab was initialized while waiting
|
|
if (this.initializedTabs.has(tabId)) {
|
|
logTabDebug(`[TabManager] Tab '${tabId}' already initialized, skipping preload`);
|
|
preloadNextTab(index + 1);
|
|
return;
|
|
}
|
|
|
|
logTabDebug(`[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
|
|
logTabDebug(`[TabManager] Preloading tab '${tabId}' using setTimeout fallback`);
|
|
session.timeoutId = setTimeout(() => {
|
|
session.timeoutId = null;
|
|
|
|
if (session.cancelled || this.isDestroyed) {
|
|
finishSession();
|
|
return;
|
|
}
|
|
|
|
if (!this.initializedTabs.has(tabId)) {
|
|
this.initializeTab(tabId, false); // No loading message for background
|
|
}
|
|
preloadNextTab(index + 1);
|
|
}, 100);
|
|
}
|
|
};
|
|
|
|
// Start preloading
|
|
preloadNextTab(0);
|
|
return session;
|
|
}
|
|
|
|
/**
|
|
* 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() {
|
|
this.isDestroyed = true;
|
|
this.cancelScheduledPreloads();
|
|
this.cancelAllPendingInits();
|
|
|
|
// Run externally registered cleanup callbacks first.
|
|
this.cleanupCallbacks.forEach((cleanup) => {
|
|
try {
|
|
cleanup();
|
|
} catch (error) {
|
|
console.warn('TabManager: cleanup callback failed:', error);
|
|
}
|
|
});
|
|
this.cleanupCallbacks.clear();
|
|
|
|
// Legacy observer compatibility for existing call sites.
|
|
if (this.settingsButtonResizeObserver && typeof this.settingsButtonResizeObserver.disconnect === 'function') {
|
|
try {
|
|
this.settingsButtonResizeObserver.disconnect();
|
|
} catch (error) {
|
|
console.warn('TabManager: failed to disconnect settingsButtonResizeObserver:', error);
|
|
}
|
|
this.settingsButtonResizeObserver = null;
|
|
}
|
|
|
|
// 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.className = `tab-button sage-tab-button${isActive ? ' active' : ''}`;
|
|
if (options.customStyles) {
|
|
applyCustomStyles(button, options.customStyles);
|
|
}
|
|
return button;
|
|
}
|