UNPKG

@rxxuzi/gumi

Version:

Clean & minimal design system with delightful interactions

206 lines 7.33 kB
// components/tabs.ts // Gumi.js v1.0.0 - Tabs Component import { $, $$, on, trigger, addClass, removeClass } from '../core/dom'; export class Tabs { constructor(container, options = {}) { this.tabs = []; this.panels = []; this.activeIndex = 0; const el = $(container); if (!el) throw new Error('Tabs container not found'); this.container = el; this.options = { activeIndex: 0, ...options }; this.init(); } /** * Initialize tabs */ init() { // Find tabs and panels - support both old and new structure const tabList = this.container.querySelector('.tab-list'); if (tabList) { // New structure: .tabs > .tab-list > .tab-button this.tabs = Array.from(tabList.querySelectorAll('.tab-button')); } else { // Legacy structure: .tabs > .tab this.tabs = Array.from(this.container.querySelectorAll('.tab')); } // Find panels by data-tab attribute this.tabs.forEach((tab, index) => { const panelId = tab.getAttribute('data-tab'); if (panelId) { const panel = $(panelId); if (panel) { this.panels[index] = panel; panel.setAttribute('role', 'tabpanel'); panel.setAttribute('aria-labelledby', tab.id || `tab-${index}`); panel.style.transition = 'opacity 0.2s ease, transform 0.2s ease'; } } // Set ARIA attributes tab.setAttribute('role', 'tab'); tab.setAttribute('aria-selected', 'false'); tab.setAttribute('tabindex', '-1'); if (!tab.id) tab.id = `tab-${index}`; // Add click handler on(tab, 'click', () => this.selectTab(index)); }); // Set container ARIA attributes if (tabList) { tabList.setAttribute('role', 'tablist'); } else { this.container.setAttribute('role', 'tablist'); } // Activate initial tab if (this.options.activeIndex !== undefined) { this.selectTab(this.options.activeIndex); } else if (this.tabs.length > 0) { // Check for active class const activeTab = this.tabs.findIndex(tab => tab.classList.contains('active')); this.selectTab(activeTab >= 0 ? activeTab : 0); } } /** * Select tab by index with smooth animation */ async selectTab(index) { if (index < 0 || index >= this.tabs.length || index === this.activeIndex) return; const previousPanel = this.panels[this.activeIndex]; const selectedTab = this.tabs[index]; const selectedPanel = this.panels[index]; // Update tab states immediately for visual feedback this.tabs.forEach((tab, i) => { removeClass(tab, 'active'); tab.setAttribute('aria-selected', 'false'); tab.setAttribute('tabindex', '-1'); }); addClass(selectedTab, 'active'); selectedTab.setAttribute('aria-selected', 'true'); selectedTab.setAttribute('tabindex', '0'); // If we have panels to animate if (previousPanel && selectedPanel && previousPanel !== selectedPanel) { // Add transition classes addClass(previousPanel, 'tab-panel-exit'); addClass(selectedPanel, 'tab-panel-enter'); // Set up the new panel selectedPanel.style.display = 'block'; selectedPanel.style.opacity = '0'; selectedPanel.style.transform = 'translateX(10px)'; // Start animations previousPanel.style.opacity = '0'; previousPanel.style.transform = 'translateX(-10px)'; // Wait for exit animation await new Promise(resolve => setTimeout(resolve, 150)); // Hide previous panel previousPanel.style.display = 'none'; previousPanel.style.opacity = ''; previousPanel.style.transform = ''; removeClass(previousPanel, 'tab-panel-exit'); // Animate in new panel selectedPanel.style.opacity = '1'; selectedPanel.style.transform = 'translateX(0)'; // Clean up after animation setTimeout(() => { removeClass(selectedPanel, 'tab-panel-enter'); selectedPanel.style.opacity = ''; selectedPanel.style.transform = ''; }, 200); } else if (selectedPanel) { // Simple display for first load this.panels.forEach((panel, i) => { if (panel) { panel.style.display = i === index ? 'block' : 'none'; } }); } this.activeIndex = index; // Call onChange callback if (this.options.onChange) { this.options.onChange(index); } // Dispatch event trigger(this.container, 'tab-change', { index }); } /** * Get active tab index */ getActiveIndex() { return this.activeIndex; } /** * Next tab */ next() { const nextIndex = (this.activeIndex + 1) % this.tabs.length; this.selectTab(nextIndex); } /** * Previous tab */ previous() { const prevIndex = this.activeIndex === 0 ? this.tabs.length - 1 : this.activeIndex - 1; this.selectTab(prevIndex); } /** * Add keyboard navigation */ enableKeyboardNavigation() { on(this.container, 'keydown', (e) => { const event = e; switch (event.key) { case 'ArrowLeft': event.preventDefault(); this.previous(); this.tabs[this.activeIndex].focus(); break; case 'ArrowRight': event.preventDefault(); this.next(); this.tabs[this.activeIndex].focus(); break; case 'Home': event.preventDefault(); this.selectTab(0); this.tabs[0].focus(); break; case 'End': event.preventDefault(); this.selectTab(this.tabs.length - 1); this.tabs[this.tabs.length - 1].focus(); break; } }); } /** * Destroy tabs instance */ destroy() { this.container.removeAttribute('role'); this.tabs.forEach(tab => { tab.removeAttribute('role'); tab.removeAttribute('aria-selected'); }); this.panels.forEach(panel => { panel.removeAttribute('role'); panel.removeAttribute('aria-labelledby'); }); } /** * Static method to initialize all tabs */ static initAll(selector = '.tabs') { const containers = $$(selector); return Array.from(containers).map(container => new Tabs(container)); } } //# sourceMappingURL=tabs.js.map