UNPKG

@rxxuzi/gumi

Version:

Clean & minimal design system with delightful interactions

1,301 lines (1,292 loc) 104 kB
/*! * Gumi.js v1.0.0 * Clean & minimal design system with delightful interactions 🍬 * https://github.com/rxxuzi/gumi * (c) 2025 rxxuzi * Released under the MIT License */ (function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : typeof define === 'function' && define.amd ? define(factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.gumi = factory()); })(this, (function () { 'use strict'; // core/dom.ts // Gumi.js v1.0.0 - DOM Utilities /** * Query selector helper */ function $(selector) { if (typeof selector === 'string') { return document.querySelector(selector); } return selector; } /** * Query selector all helper */ function $$(selector) { return document.querySelectorAll(selector); } /** * Ready function - fires when DOM is ready */ function ready(fn) { if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', fn); } else { fn(); } } function createElement(tag, options = {}) { const el = document.createElement(tag); if (options.className) el.className = options.className; if (options.id) el.id = options.id; if (options.text) el.textContent = options.text; if (options.html) el.innerHTML = options.html; if (options.attributes) { Object.entries(options.attributes).forEach(([key, value]) => { el.setAttribute(key, value); }); } if (options.style) { Object.assign(el.style, options.style); } if (options.events) { Object.entries(options.events).forEach(([event, handler]) => { el.addEventListener(event, handler); }); } if (options.children) { options.children.forEach(child => { if (typeof child === 'string') { el.appendChild(document.createTextNode(child)); } else { el.appendChild(child); } }); } return el; } /** * Add event listener with delegation */ function on(element, event, selectorOrHandler, handler) { const el = element instanceof Document || element instanceof Window ? element : $(element); if (!el) return; if (typeof selectorOrHandler === 'function') { el.addEventListener(event, selectorOrHandler); } else { el.addEventListener(event, (e) => { const target = e.target; const delegateTarget = target.closest(selectorOrHandler); if (delegateTarget && handler) { handler.call(delegateTarget, e); } }); } } /** * Remove event listener */ function off(element, event, handler) { const el = element instanceof Document || element instanceof Window ? element : $(element); if (!el) return; el.removeEventListener(event, handler); } /** * Trigger custom event */ function trigger(element, eventName, detail) { const el = $(element); if (!el) return; const event = new CustomEvent(eventName, { bubbles: true, cancelable: true, detail }); el.dispatchEvent(event); } /** * Check if element has class */ function hasClass(element, className) { const el = $(element); return el ? el.classList.contains(className) : false; } /** * Add class(es) to element */ function addClass(element, ...classNames) { const el = $(element); if (!el) return; el.classList.add(...classNames); } /** * Remove class(es) from element */ function removeClass(element, ...classNames) { const el = $(element); if (!el) return; el.classList.remove(...classNames); } /** * Show element */ function show(element) { const el = $(element); if (!el) return; const display = el.style.display; if (display === 'none') { el.style.display = ''; } if (window.getComputedStyle(el).display === 'none') { el.style.display = 'block'; } } /** * Hide element */ function hide(element) { const el = $(element); if (!el) return; el.style.display = 'none'; } /** * Toggle element visibility */ function toggle(element) { const el = $(element); if (!el) return; if (window.getComputedStyle(el).display === 'none') { show(el); } else { hide(el); } } // core/animation.ts // Gumi.js v1.0.0 - Animation Utilities /** * Animate element using Web Animations API */ function animate(element, keyframes, options = {}) { const el = $(element); if (!el) return Promise.resolve(); const defaultOptions = { duration: options.duration || 300, easing: options.easing || 'cubic-bezier(0.4, 0, 0.2, 1)', fill: options.fill || 'forwards', delay: options.delay || 0, iterations: options.iterations || 1, direction: options.direction || 'normal' }; const animation = el.animate(keyframes, defaultOptions); return new Promise((resolve) => { animation.onfinish = () => resolve(); animation.oncancel = () => resolve(); }); } /** * Fade in animation */ function fadeIn(element, options = {}) { const el = $(element); if (!el) return Promise.resolve(); el.style.opacity = '0'; el.style.visibility = 'visible'; return animate(el, [ { opacity: 0 }, { opacity: 1 } ], { duration: options.duration || 600, ...options }); } /** * Fade out animation */ function fadeOut(element, options = {}) { const el = $(element); if (!el) return Promise.resolve(); return animate(el, [ { opacity: 1 }, { opacity: 0 } ], { duration: options.duration || 300, ...options }).then(() => { if (options.fill !== 'none') { el.style.visibility = 'hidden'; } }); } /** * Slide up animation (hide) */ function slideUp(element, options = {}) { const el = $(element); if (!el) return Promise.resolve(); const height = el.offsetHeight; el.style.overflow = 'hidden'; return animate(el, [ { height: `${height}px`, opacity: 1 }, { height: '0px', opacity: 0 } ], { duration: options.duration || 300, ...options }).then(() => { el.style.display = 'none'; el.style.height = ''; el.style.overflow = ''; }); } /** * Slide down animation (show) */ function slideDown(element, options = {}) { const el = $(element); if (!el) return Promise.resolve(); // Get the height el.style.display = 'block'; el.style.height = 'auto'; const height = el.offsetHeight; el.style.height = '0px'; el.style.overflow = 'hidden'; return animate(el, [ { height: '0px', opacity: 0 }, { height: `${height}px`, opacity: 1 } ], { duration: options.duration || 300, ...options }).then(() => { el.style.height = ''; el.style.overflow = ''; }); } /** * Scale in animation */ function scaleIn(element, options = {}) { const el = $(element); if (!el) return Promise.resolve(); el.style.visibility = 'visible'; return animate(el, [ { transform: 'scale(0.9)', opacity: 0 }, { transform: 'scale(1)', opacity: 1 } ], { duration: options.duration || 300, ...options }); } /** * Scale out animation */ function scaleOut(element, options = {}) { const el = $(element); if (!el) return Promise.resolve(); return animate(el, [ { transform: 'scale(1)', opacity: 1 }, { transform: 'scale(0.9)', opacity: 0 } ], { duration: options.duration || 300, ...options }); } /** * Slide in from direction */ function slideIn(element, direction = 'left', options = {}) { const el = $(element); if (!el) return Promise.resolve(); const transforms = { left: 'translateX(-100%)', right: 'translateX(100%)', top: 'translateY(-100%)', bottom: 'translateY(100%)' }; el.style.visibility = 'visible'; return animate(el, [ { transform: transforms[direction], opacity: 0 }, { transform: 'translate(0)', opacity: 1 } ], { duration: options.duration || 300, ...options }); } /** * Bounce animation */ function bounce(element, options = {}) { const el = $(element); if (!el) return Promise.resolve(); return animate(el, [ { transform: 'translateY(0)' }, { transform: 'translateY(-20px)' }, { transform: 'translateY(0)' } ], { duration: options.duration || 1000, ...options }); } /** * Shake animation */ function shake(element, options = {}) { const el = $(element); if (!el) return Promise.resolve(); return animate(el, [ { transform: 'translateX(0)' }, { transform: 'translateX(-10px)' }, { transform: 'translateX(10px)' }, { transform: 'translateX(-10px)' }, { transform: 'translateX(10px)' }, { transform: 'translateX(0)' } ], { duration: options.duration || 500, ...options }); } /** * Pulse animation */ function pulse(element, options = {}) { const el = $(element); if (!el) return Promise.resolve(); return animate(el, [ { transform: 'scale(1)', opacity: 1 }, { transform: 'scale(1.05)', opacity: 0.8 }, { transform: 'scale(1)', opacity: 1 } ], { duration: options.duration || 2000, ...options }); } /** * Ripple effect */ function ripple(event, element) { const el = element ? $(element) : event.currentTarget; if (!el) return; const ripple = document.createElement('span'); const rect = el.getBoundingClientRect(); const size = Math.max(rect.width, rect.height); const x = event.clientX - rect.left - size / 2; const y = event.clientY - rect.top - size / 2; ripple.style.cssText = ` position: absolute; width: ${size}px; height: ${size}px; background: rgba(255, 255, 255, 0.5); border-radius: 50%; top: ${y}px; left: ${x}px; pointer-events: none; transform: scale(0); `; el.style.position = 'relative'; el.style.overflow = 'hidden'; el.appendChild(ripple); animate(ripple, [ { transform: 'scale(0)', opacity: 1 }, { transform: 'scale(4)', opacity: 0 } ], { duration: 600 }).then(() => { ripple.remove(); }); } // components/theme.ts // Gumi.js v1.0.0 - Theme Management class ThemeManager { constructor() { this.currentTheme = 'light'; this.storageKey = 'apple-theme'; this.init(); } /** * Initialize theme */ init() { // Check for saved theme or system preference const savedTheme = localStorage.getItem(this.storageKey); const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; if (savedTheme) { this.setTheme(savedTheme); } else if (systemPrefersDark) { this.setTheme('dark'); } // Listen for system theme changes window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => { if (!localStorage.getItem(this.storageKey)) { this.setTheme(e.matches ? 'dark' : 'light'); } }); } /** * Get current theme */ getTheme() { return this.currentTheme; } /** * Set theme */ setTheme(theme) { if (theme === 'auto') { const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; theme = systemPrefersDark ? 'dark' : 'light'; } this.currentTheme = theme; document.documentElement.setAttribute('data-theme', theme); // Save preference localStorage.setItem(this.storageKey, theme); // Dispatch theme change event trigger(document.documentElement, 'apple-theme-change', { theme }); } /** * Toggle theme */ toggleTheme() { const newTheme = this.currentTheme === 'light' ? 'dark' : 'light'; this.setTheme(newTheme); } /** * Clear theme preference */ clearPreference() { localStorage.removeItem(this.storageKey); this.init(); } /** * Create theme toggle button */ createToggleButton(options = {}) { const button = document.createElement('button'); button.className = options.className || 'btn-icon btn-ghost'; button.setAttribute('aria-label', 'Toggle theme'); const updateIcon = () => { const isDark = this.currentTheme === 'dark'; button.innerHTML = isDark ? (options.darkIcon || '<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg>') : (options.lightIcon || '<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"></circle><line x1="12" y1="1" x2="12" y2="3"></line><line x1="12" y1="21" x2="12" y2="23"></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line><line x1="1" y1="12" x2="3" y2="12"></line><line x1="21" y1="12" x2="23" y2="12"></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line></svg>'); }; updateIcon(); button.addEventListener('click', () => { this.toggleTheme(); updateIcon(); }); // Listen for theme changes from other sources window.addEventListener('apple-theme-change', updateIcon); return button; } } // utils/helpers.ts // Gumi.js v1.0.0 - Helper Utilities /** * Debounce function */ function debounce(func, wait) { let timeout = null; return function executedFunction(...args) { const later = () => { timeout = null; func(...args); }; if (timeout) clearTimeout(timeout); timeout = setTimeout(later, wait); }; } /** * Throttle function */ function throttle(func, limit) { let inThrottle = false; return function executedFunction(...args) { if (!inThrottle) { func(...args); inThrottle = true; setTimeout(() => { inThrottle = false; }, limit); } }; } /** * Generate unique ID */ function generateId(prefix = 'apple') { return `${prefix}-${Math.random().toString(36).substr(2, 9)}`; } /** * Clamp number between min and max */ function clamp(num, min, max) { return Math.max(min, Math.min(max, num)); } /** * Copy text to clipboard */ async function copyToClipboard(text) { try { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text); return true; } else { // Fallback for older browsers const textArea = document.createElement('textarea'); textArea.value = text; textArea.style.position = 'fixed'; textArea.style.left = '-999999px'; textArea.style.top = '-999999px'; document.body.appendChild(textArea); textArea.focus(); textArea.select(); const successful = document.execCommand('copy'); textArea.remove(); return successful; } } catch (err) { console.error('Failed to copy text:', err); return false; } } // components/modal.ts // Gumi.js v1.0.0 - Modal Component class Modal { constructor(element, options = {}) { this.backdrop = null; this.isOpen = false; this.escapeHandler = null; this.keydownListener = null; const el = $(element); if (!el) throw new Error('Modal element not found'); this.element = el; this.options = { backdrop: true, keyboard: true, focus: true, ...options }; this.init(); } /** * Initialize modal */ init() { // Set initial styles this.element.style.display = 'none'; this.element.setAttribute('role', 'dialog'); this.element.setAttribute('aria-modal', 'true'); if (!this.element.id) { this.element.id = generateId('modal'); } } /** * Open modal */ open() { if (this.isOpen) return; this.isOpen = true; // Create backdrop if needed if (this.options.backdrop) { this.createBackdrop(); } // Style the modal Object.assign(this.element.style, { position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%) scale(0.9)', zIndex: '1050', opacity: '0', maxWidth: '90vw', maxHeight: '90vh', overflow: 'auto', display: 'block' }); // Prevent body scroll document.body.style.overflow = 'hidden'; // Animate in if (this.backdrop) { animate(this.backdrop, [ { opacity: 0 }, { opacity: 1 } ], { duration: 200 }); } animate(this.element, [ { opacity: 0, transform: 'translate(-50%, -50%) scale(0.9)' }, { opacity: 1, transform: 'translate(-50%, -50%) scale(1)' } ], { duration: 300 }); // Focus management if (this.options.focus) { this.element.focus(); } // Keyboard support if (this.options.keyboard) { this.escapeHandler = (e) => { if (e.key === 'Escape') { this.close(); } }; this.keydownListener = (e) => { if (this.escapeHandler) { this.escapeHandler(e); } }; on(document, 'keydown', this.keydownListener); } // Dispatch open event trigger(this.element, 'modal-open', { modal: this.element }); } /** * Close modal */ close() { if (!this.isOpen) return; this.isOpen = false; // Remove escape handler if (this.keydownListener) { off(document, 'keydown', this.keydownListener); this.keydownListener = null; this.escapeHandler = null; } // Animate out const animations = [ animate(this.element, [ { opacity: 1, transform: 'translate(-50%, -50%) scale(1)' }, { opacity: 0, transform: 'translate(-50%, -50%) scale(0.9)' } ], { duration: 200 }) ]; if (this.backdrop) { animations.push(animate(this.backdrop, [ { opacity: 1 }, { opacity: 0 } ], { duration: 200 })); } Promise.all(animations).then(() => { this.element.style.display = 'none'; document.body.style.overflow = ''; if (this.backdrop) { this.backdrop.remove(); this.backdrop = null; } // Dispatch close event trigger(this.element, 'modal-close', { modal: this.element }); }); } /** * Toggle modal */ toggle() { if (this.isOpen) { this.close(); } else { this.open(); } } /** * Create backdrop */ createBackdrop() { // Remove any existing backdrop const existingBackdrop = $('.gumi-modal-backdrop'); if (existingBackdrop) { existingBackdrop.remove(); } this.backdrop = document.createElement('div'); this.backdrop.className = 'gumi-modal-backdrop'; Object.assign(this.backdrop.style, { position: 'fixed', top: '0', left: '0', width: '100%', height: '100%', background: 'rgba(0, 0, 0, 0.5)', backdropFilter: 'blur(4px)', zIndex: '1040', opacity: '0' }); // Close on backdrop click on(this.backdrop, 'click', () => this.close()); document.body.appendChild(this.backdrop); } /** * Destroy modal instance */ destroy() { this.close(); this.element.removeAttribute('role'); this.element.removeAttribute('aria-modal'); } /** * Static method to initialize modals from triggers */ static initFromTriggers(selector = '[data-modal]') { const triggers = $$(selector); const modals = []; triggers.forEach(trigger => { const modalId = trigger.getAttribute('data-modal'); if (!modalId) return; const modalEl = $(modalId); if (!modalEl) return; const modal = new Modal(modalEl); modals.push(modal); on(trigger, 'click', (e) => { e.preventDefault(); modal.open(); }); }); return modals; } } // toast.ts // Beautiful toast notifications class Toast { /** * Show a toast notification */ static show(message, options = {}) { const config = { type: 'info', duration: 4000, position: 'top-right', ...options }; const id = `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; // Create container if needed this.createContainer(config.position); // Create toast element const toast = createElement('div', { className: `gumi-toast ${config.type} entering`, html: ` ${this.getIcon(config.type)} <span>${message}</span> ` }); // Store reference this.toasts.set(id, toast); // Add to container this.container.appendChild(toast); // Auto-remove after duration setTimeout(() => { this.remove(id); }, config.duration); // Click to dismiss on(toast, 'click', () => { this.remove(id); }); // Trigger custom event trigger(document.body, 'gumi-toast-show', { id, message, type: config.type }); return id; } /** * Remove a specific toast */ static remove(id) { const toast = this.toasts.get(id); if (!toast) return; removeClass(toast, 'entering'); addClass(toast, 'exiting'); setTimeout(() => { if (toast.parentNode) { toast.parentNode.removeChild(toast); } this.toasts.delete(id); }, 200); trigger(document.body, 'gumi-toast-hide', { id }); } /** * Remove all toasts */ static removeAll() { Array.from(this.toasts.keys()).forEach(id => this.remove(id)); } /** * Create container for toasts */ static createContainer(position) { if (this.container) return; this.container = createElement('div', { className: `gumi-toast-container ${position}` }); document.body.appendChild(this.container); } /** * Get icon for toast type */ static getIcon(type) { const icons = { success: `<svg class="toast-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M9 12l2 2 4-4"></path> <circle cx="12" cy="12" r="9"></circle> </svg>`, error: `<svg class="toast-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <circle cx="12" cy="12" r="9"></circle> <line x1="15" y1="9" x2="9" y2="15"></line> <line x1="9" y1="9" x2="15" y2="15"></line> </svg>`, warning: `<svg class="toast-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path> <line x1="12" y1="9" x2="12" y2="13"></line> <line x1="12" y1="17" x2="12.01" y2="17"></line> </svg>`, info: `<svg class="toast-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <circle cx="12" cy="12" r="9"></circle> <line x1="12" y1="8" x2="12" y2="12"></line> <line x1="12" y1="16" x2="12.01" y2="16"></line> </svg>` }; return icons[type] || icons.info; } /** * Helper methods for different toast types */ static success(message, options = {}) { return this.show(message, { ...options, type: 'success' }); } static error(message, options = {}) { return this.show(message, { ...options, type: 'error' }); } static warning(message, options = {}) { return this.show(message, { ...options, type: 'warning' }); } static info(message, options = {}) { return this.show(message, { ...options, type: 'info' }); } } Toast.container = null; Toast.toasts = new Map(); // components/tabs.ts // Gumi.js v1.0.0 - Tabs Component 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)); } } // utils/icons.ts // Gumi.js v1.0.0 - SVG Icons const icons = { check: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>', x: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>', info: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>', warning: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>', error: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="15" y1="9" x2="9" y2="15"></line><line x1="9" y1="9" x2="15" y2="15"></line></svg>', success: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline></svg>', chevronDown: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>', chevronUp: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"></polyline></svg>', chevronLeft: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"></polyline></svg>', chevronRight: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>', moon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg>', sun: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"></circle><line x1="12" y1="1" x2="12" y2="3"></line><line x1="12" y1="21" x2="12" y2="23"></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line><line x1="1" y1="12" x2="3" y2="12"></line><line x1="21" y1="12" x2="23" y2="12"></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line></svg>', loader: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="2" x2="12" y2="6"></line><line x1="12" y1="18" x2="12" y2="22"></line><line x1="4.93" y1="4.93" x2="7.76" y2="7.76"></line><line x1="16.24" y1="16.24" x2="19.07" y2="19.07"></line><line x1="2" y1="12" x2="6" y2="12"></line><line x1="18" y1="12" x2="22" y2="12"></line><line x1="4.93" y1="19.07" x2="7.76" y2="16.24"></line><line x1="16.24" y1="7.76" x2="19.07" y2="4.93"></line></svg>', menu: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="12" x2="21" y2="12"></line><line x1="3" y1="6" x2="21" y2="6"></line><line x1="3" y1="18" x2="21" y2="18"></line></svg>', search: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.35-4.35"></path></svg>', plus: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>', minus: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"></line></svg>', copy: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>', heart: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path></svg>', star: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg>' }; // components/accordion.ts // Gumi.js v1.0.0 - Accordion Component class Accordion { constructor(container, options = {}) { this.items = []; const el = $(container); if (!el) throw new Error('Accordion container not found'); this.container = el; this.options = { multiple: false, collapsed: true, ...options }; this.init(); } /** * Initialize accordion */ init() { // Set data attribute for multiple if (this.options.multiple) { this.container.setAttribute('data-multiple', 'true'); } // Find all accordion items this.items = Array.from(this.container.querySelectorAll('.accordion-item')); this.items.forEach((item, index) => { const header = item.querySelector('.accordion-header'); const content = item.querySelector('.accordion-content'); if (!header || !content) return; // Set ARIA attributes header.setAttribute('role', 'button'); header.setAttribute('aria-expanded', 'false'); if (!header.id) header.id = `accordion-header-${index}`; content.setAttribute('role', 'region'); content.setAttribute('aria-labelledby', header.id); // Add chevron icon if not exists if (!header.querySelector('.accordion-icon')) { const iconSpan = document.createElement('span'); iconSpan.className = 'accordion-icon'; iconSpan.innerHTML = icons.chevronDown; header.appendChild(iconSpan); } // Set initial state if (this.options.collapsed && !hasClass(item, 'active')) { removeClass(item, 'active'); header.setAttribute('aria-expanded', 'false'); } else if (hasClass(item, 'active')) { header.setAttribute('aria-expanded', 'true'); } // Add click handler on(header, 'click', () => this.toggle(index)); }); } /** * Toggle accordion item */ toggle(index) { const item = this.items[index]; if (!item) return; const isActive = hasClass(item, 'active'); if (isActive) { this.close(index); } else { this.open(index); } } /** * Open accordion item */ open(index) { const item = this.items[index]; if (!item) return; const header = item.querySelector('.accordion-header'); const content = item.querySelector('.accordion-content'); if (!header || !content) return; // Close other items if not multiple if (!this.options.multiple) { this.items.forEach((otherItem, otherIndex) => { if (otherIndex !== index && hasClass(otherItem, 'active')) { this.close(otherIndex); } }); } // Open current item addClass(item, 'active'); addClass(header, 'active'); header.setAttribute('aria-expanded', 'true'); // Dispatch event trigger(item, 'accordion-toggle', { item, open: true }); } /** * Close accordion item */ close(index) { const item = this.items[index]; if (!item) return; const header = item.querySelector('.accordion-header'); const content = item.querySelector('.accordion-content'); if (!header || !content) return; removeClass(item, 'active'); removeClass(header, 'active'); header.setAttribute('aria-expanded', 'false'); // Dispatch event trigger(item, 'accordion-toggle', { item, open: false }); } /** * Open all items */ openAll() { this.items.forEach((_, index) => this.open(index)); } /** * Close all items */ closeAll() { this.items.forEach((_, index) => this.close(index)); } /** * Get active items */ getActiveItems() { return this.items .map((item, index) => hasClass(item, 'active') ? index : -1) .filter(index => index !== -1); } /** * Enable keyboard navigation */ enableKeyboardNavigation() { this.items.forEach((item, index) => { const header = item.querySelector('.accordion-header'); if (!header) return; header.setAttribute('tabindex', '0'); on(header, 'keydown', (e) => { const event = e; switch (event.key) { case 'Enter': case ' ': event.preventDefault(); this.toggle(index); break; case 'ArrowDown': event.preventDefault(); const nextIndex = (index + 1) % this.items.length; const nextHeader = this.items[nextIndex].querySelector('.accordion-header'); nextHeader === null || nextHeader === void 0 ? void 0 : nextHeader.focus(); break; case 'ArrowUp': event.preventDefault(); const prevIndex = index === 0 ? this.items.length - 1 : index