svarog-ui-core
Version:
Core components of Svarog UI without themes
487 lines (414 loc) • 13.9 kB
JavaScript
// src/components/Tabs/Tabs.js
import { createBaseComponent } from '../../utils/baseComponent.js';
import { createElement } from '../../utils/componentFactory.js';
import { validateRequiredProps } from '../../utils/validation.js';
import { createStyleInjector } from '../../utils/styleInjection.js';
import { tabsStyles } from './Tabs.styles.js';
// Create style injector for Tabs component (cached automatically)
const injectTabsStyles = createStyleInjector('Tabs');
/**
* Creates a Tabs component
* @param {Object} props - Tabs properties
* @param {Array} props.tabs - Array of tab objects (id, label, content)
* @param {number} [props.defaultActiveTab=0] - Index of initially active tab (deprecated, use defaultValue)
* @param {number} [props.defaultValue=0] - Index of initially active tab
* @param {Function} [props.onChange] - Callback when active tab changes
* @param {Function} [props.onTabChange] - Callback when active tab changes (deprecated, use onChange)
* @param {string} [props.className] - Additional CSS class
* @param {string} [props.variant='default'] - Tab style variant ('default', 'simple', 'border')
* @param {string} [props.align='left'] - Tab alignment ('left', 'center', 'right')
* @returns {Object} Tabs component
*/
const createTabs = (props) => {
// Inject styles on component creation
injectTabsStyles(tabsStyles);
// Migrate legacy props to new standard props
const normalizedProps = migrateLegacyProps(props);
// Validate props
validateRequiredProps(
normalizedProps,
{
tabs: {
required: true,
type: 'array',
minLength: 1,
validator: (tabs) => tabs.length > 0 || 'tabs array must not be empty',
},
defaultValue: {
required: false,
type: 'number',
},
variant: {
required: false,
type: 'string',
allowedValues: ['default', 'simple', 'border'],
},
align: {
required: false,
type: 'string',
allowedValues: ['left', 'center', 'right'],
},
},
'Tabs'
);
// Create initial state by merging props with defaults
const state = {
tabs: normalizedProps.tabs || [],
defaultValue: normalizedProps.defaultValue || 0,
onChange: normalizedProps.onChange || null,
className: normalizedProps.className || '',
variant: normalizedProps.variant || 'default',
align: normalizedProps.align || 'left',
activeTab: normalizedProps.defaultValue || 0,
};
// References to DOM elements for easier access in switchTab
let buttonRefs = [];
let panelRefs = [];
/**
* Switches to a specific tab
* @param {number} index - Index of tab to switch to
* @returns {boolean} Whether the tab was switched
*/
const switchTab = (index) => {
// Don't switch if index is out of bounds or already active
if (index === state.activeTab || index < 0 || index >= state.tabs.length) {
return false;
}
const previousTab = state.activeTab;
state.activeTab = index;
// Update buttons
buttonRefs.forEach((button, i) => {
const isActive = i === index;
button.classList.toggle('tabs__button--active', isActive);
button.setAttribute('aria-selected', isActive ? 'true' : 'false');
});
// Update panels
panelRefs.forEach((panel, i) => {
const isActive = i === index;
panel.classList.toggle('tabs__panel--active', isActive);
panel.hidden = !isActive; // Hidden for inactive panels
});
// Call callback if provided
if (state.onChange) {
state.onChange(index, previousTab);
}
return true;
};
/**
* Adds content to a tab panel based on its type
* @param {HTMLElement} panel - The panel element to add content to
* @param {Object} tab - The tab object containing content
* @private
*/
const addContentToPanel = (panel, tab) => {
const content = tab.content;
// Handle string content
if (typeof content === 'string') {
panel.innerHTML = content;
return;
}
// Handle HTMLElement content
if (content instanceof HTMLElement) {
panel.appendChild(content);
return;
}
// Handle component with getElement method
if (content && typeof content.getElement === 'function') {
try {
const element = content.getElement();
if (element instanceof HTMLElement) {
panel.appendChild(element);
} else {
console.warn(
`Tabs: content.getElement() did not return an HTMLElement for tab "${tab.id}"`
);
}
} catch (error) {
console.error(
`Tabs: Error calling getElement() for tab "${tab.id}":`,
error
);
}
return;
}
// Handle function that returns content
if (typeof content === 'function') {
try {
const contentElement = content();
if (contentElement instanceof HTMLElement) {
panel.appendChild(contentElement);
} else if (
contentElement &&
typeof contentElement.getElement === 'function'
) {
const el = contentElement.getElement();
if (el instanceof HTMLElement) {
panel.appendChild(el);
} else {
console.warn(
`Tabs: content.getElement() did not return an HTMLElement for tab "${tab.id}"`
);
}
} else {
console.warn(
`Tabs: Function content did not return a valid element or component for tab "${tab.id}"`
);
}
} catch (error) {
console.error(
`Tabs: Error rendering content for tab "${tab.id}":`,
error
);
}
return;
}
// Handle null or undefined content
if (content === null || content === undefined) {
console.warn(`Tabs: No content provided for tab "${tab.id}"`);
return;
}
// Handle unknown content type
console.warn(
`Tabs: Unsupported content type for tab "${tab.id}": ${typeof content}`
);
};
/**
* Renders the tabs based on current state
* @param {Object} state - Current component state
* @returns {HTMLElement} Tabs element
*/
const renderTabs = (state) => {
// Create container with proper class names
const containerClasses = ['tabs'];
if (state.variant !== 'default') {
containerClasses.push(`tabs--${state.variant}`);
}
if (state.align !== 'left') {
containerClasses.push(`tabs--align-${state.align}`);
}
if (state.className) {
containerClasses.push(state.className);
}
const container = createElement('div', {
classes: containerClasses,
});
// Create tab buttons list
const tabList = createElement('div', {
classes: 'tabs__list',
attributes: { role: 'tablist' },
});
// Create tab panels container
const tabPanelsContainer = createElement('div', {
classes: 'tabs__panels',
});
// Reset button and panel references
buttonRefs = [];
panelRefs = [];
// Add tab buttons and panels
state.tabs.forEach((tab, index) => {
const isActive = index === state.activeTab;
const isFirst = index === 0;
// Build class names for tab buttons
const buttonClasses = ['tabs__button'];
if (isActive) {
buttonClasses.push('tabs__button--active');
}
if (state.variant === 'border') {
buttonClasses.push('tabs__button--bordered');
if (isFirst) {
buttonClasses.push('tabs__button--first');
}
}
// Create tab button
const button = createElement('button', {
classes: buttonClasses,
attributes: {
role: 'tab',
'aria-selected': isActive ? 'true' : 'false',
'aria-controls': `panel-${tab.id}`,
id: `tab-${tab.id}`,
},
events: {
click: (e) => {
e.preventDefault();
switchTab(index);
},
},
});
// Set button text directly
button.textContent = tab.label;
buttonRefs.push(button);
tabList.appendChild(button);
// Build class names for tab panels
const panelClasses = ['tabs__panel'];
if (isActive) {
panelClasses.push('tabs__panel--active');
}
if (state.variant === 'border') {
panelClasses.push('tabs__panel--bordered');
}
// Create tab panel
const panel = createElement('div', {
classes: panelClasses,
attributes: {
role: 'tabpanel',
'aria-labelledby': `tab-${tab.id}`,
id: `panel-${tab.id}`,
},
});
// Set hidden attribute manually
panel.hidden = !isActive;
// Add content to the panel with improved error handling
addContentToPanel(panel, tab);
panelRefs.push(panel);
tabPanelsContainer.appendChild(panel);
});
container.appendChild(tabList);
container.appendChild(tabPanelsContainer);
return container;
};
// Create component using baseComponent
const baseComponent = createBaseComponent(renderTabs)(state);
/**
* Determines if component needs to fully re-render based on prop changes
* @param {Object} newProps - New properties
* @returns {boolean} Whether a full re-render is required
*/
const shouldRerender = (newProps) => {
// Normalize new props
const normalizedNewProps = migrateLegacyProps(newProps);
// Critical props that require a full re-render
const criticalProps = ['tabs', 'variant', 'align'];
return Object.keys(normalizedNewProps).some((key) =>
criticalProps.includes(key)
);
};
/**
* Perform partial update without full re-render
* @param {HTMLElement} element - Current element
* @param {Object} newProps - New properties
*/
const partialUpdate = (element, newProps) => {
// Normalize new props
const normalizedNewProps = migrateLegacyProps(newProps);
// Handle className update without re-rendering
if (normalizedNewProps.className !== undefined) {
// Get the current class list
const classList = element.className.split(' ');
// Find and remove the old className if it exists
const oldClassIndex = classList.findIndex(
(cls) => cls !== 'tabs' && !cls.startsWith('tabs--')
);
if (oldClassIndex !== -1) {
classList.splice(oldClassIndex, 1);
}
// Add the new className if it exists
if (normalizedNewProps.className) {
classList.push(normalizedNewProps.className);
}
// Set the new class list
element.className = classList.join(' ');
}
// Handle activeTab/value change - IMPORTANT: do this before updating state
// to ensure switchTab works with the correct values
if (
normalizedNewProps.activeTab !== undefined ||
normalizedNewProps.value !== undefined
) {
const newValue =
normalizedNewProps.value !== undefined
? normalizedNewProps.value
: normalizedNewProps.activeTab;
switchTab(newValue);
}
// Update state with new props after processing any tab changes
Object.assign(state, normalizedNewProps);
};
// Extended component with custom methods
const tabsComponent = {
...baseComponent,
/**
* Determines if component should fully re-render
* @param {Object} newProps - New properties
* @returns {boolean} Whether a full re-render is required
*/
shouldRerender,
/**
* Performs efficient partial updates
* @param {HTMLElement} element - Current element
* @param {Object} newProps - New properties
*/
partialUpdate,
/**
* Switch to a specific tab
* @param {number} index - Tab index to switch to
* @returns {Object} Tabs component (for chaining)
*/
switchTab(index) {
switchTab(index);
return this;
},
/**
* Get the index of the active tab
* @returns {number} Active tab index
*/
getActiveTab() {
return state.activeTab;
},
/**
* Get the current value (alias for getActiveTab)
* @returns {number} Current value (active tab index)
*/
getValue() {
return state.activeTab;
},
/**
* Get the number of tabs
* @returns {number} Number of tabs
*/
getTabCount() {
return state.tabs.length;
},
};
// Add theme change handler
tabsComponent.onThemeChange = (newTheme, previousTheme) => {
// This could apply theme-specific adjustments if needed
console.debug(`Tabs: theme changed from ${previousTheme} to ${newTheme}`);
};
return tabsComponent;
};
/**
* Migrate legacy props to new standard props
* @param {Object} props - Component props
* @returns {Object} Normalized props
*/
const migrateLegacyProps = (props) => {
const normalized = { ...props };
// Handle onTabChange -> onChange
if ('onTabChange' in props && !('onChange' in props)) {
console.warn('[Tabs] onTabChange is deprecated, use onChange instead');
normalized.onChange = props.onTabChange;
delete normalized.onTabChange;
}
// Handle defaultActiveTab -> defaultValue
if ('defaultActiveTab' in props && !('defaultValue' in props)) {
console.warn(
'[Tabs] defaultActiveTab is deprecated, use defaultValue instead'
);
normalized.defaultValue = props.defaultActiveTab;
delete normalized.defaultActiveTab;
}
// Handle activeTab -> value (add alias)
if ('activeTab' in props && !('value' in props)) {
normalized.value = props.activeTab;
}
// Handle value -> activeTab (for internal state)
if ('value' in props && !('activeTab' in props)) {
normalized.activeTab = props.value;
}
return normalized;
};
// Define required props for validation
createTabs.requiredProps = ['tabs'];
// Export as a factory function
export default createTabs;