UNPKG

@pm7/core

Version:

The First UI Library Built for AI Coding Agents - Core CSS and JavaScript

1,503 lines (1,263 loc) 124 kB
(function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : typeof define === 'function' && define.amd ? define(['exports'], factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.PM7 = {})); })(this, (function (exports) { 'use strict'; /** * PM7Menu - Vanilla JavaScript menu component with self-healing * Handles dropdown menus with keyboard navigation and accessibility * Now with self-healing for framework re-renders (React, Vue, etc.) */ class PM7Menu { static instances = new WeakMap(); // Use WeakMap for better memory management constructor(element) { // Self-healing: Check if element was re-rendered by framework const wasInitialized = element.hasAttribute('data-pm7-menu-initialized'); const hasInstance = PM7Menu.instances.has(element); // If initialized but no instance, element was re-rendered if (wasInitialized && !hasInstance) { console.log('[PM7Menu] Self-healing: Re-initializing menu after framework re-render'); // Remove the initialized attribute to allow re-initialization element.removeAttribute('data-pm7-menu-initialized'); } // Check if this element already has a menu instance if (PM7Menu.instances.has(element)) { return PM7Menu.instances.get(element); } this.element = element; // Preserve state if this is a re-render const preservedState = this.preserveState(); // AI-Agent FIRST: Automatically add pm7-menu class if missing if (!this.element.classList.contains('pm7-menu')) { this.element.classList.add('pm7-menu'); } this.trigger = element.querySelector('.pm7-menu-trigger'); this.content = element.querySelector('.pm7-menu-content'); this.items = element.querySelectorAll('.pm7-menu-item'); this.isOpen = false; this.currentIndex = -1; this.hoverTimeouts = new Map(); if (!this.trigger || !this.content) { return; } // Store this instance PM7Menu.instances.set(element, this); // Store instance reference on element for self-healing element._pm7MenuInstance = this; this.init(); // Restore state if this was a re-render if (preservedState) { this.restoreState(preservedState); } // Mark as initialized element.setAttribute('data-pm7-menu-initialized', 'true'); } preserveState() { // Try to preserve state from previous instance const oldContent = this.element.querySelector('.pm7-menu-content'); if (!oldContent) return null; return { wasOpen: oldContent.classList.contains('pm7-menu-content--open') || oldContent.getAttribute('data-state') === 'open', triggerExpanded: this.element.querySelector('.pm7-menu-trigger')?.getAttribute('aria-expanded') === 'true' }; } restoreState(state) { if (state.wasOpen) { // Use setTimeout to ensure DOM is ready setTimeout(() => { this.open(); }, 0); } } init() { // Remove any existing event listeners to prevent duplicates this.cleanup(); // Check if this menu is part of a menu bar this.isInMenuBar = this.element.closest('.pm7-menu-bar') !== null; // Create bound event handlers for proper cleanup this.boundHandlers = { triggerClick: (e) => { e.stopPropagation(); // In menu bars, always open (don't toggle) if another menu is open if (this.isInMenuBar && this.isAnyMenuBarMenuOpen() && !this.isOpen) { this.open(); } else { this.toggle(); } }, triggerMouseEnter: () => { // Check if any other menu in the bar is open if (this.isAnyMenuBarMenuOpen()) { this.open(); } }, outsideClick: (e) => { // Check if the click is outside the menu element and not on a submenu if (!this.element.contains(e.target) && this.isOpen) { // Check if the click is on a submenu that is part of this menu const clickedSubmenu = e.target.closest('.pm7-submenu'); if (!clickedSubmenu || !this.element.contains(clickedSubmenu)) { this.close(); } } }, escape: (e) => { if (e.key === 'Escape' && this.isOpen) { e.stopPropagation(); this.close(); this.trigger.focus(); } }, reposition: () => { if (this.isOpen) { this.adjustPosition(); } } }; // Click handlers this.trigger.addEventListener('click', this.boundHandlers.triggerClick); // Hover handlers for menu bar menus if (this.isInMenuBar) { this.trigger.addEventListener('mouseenter', this.boundHandlers.triggerMouseEnter); } // Initialize submenu hover handling this.initSubmenuHoverHandling(); // Close on outside click document.addEventListener('click', this.boundHandlers.outsideClick); // Keyboard navigation this.trigger.addEventListener('keydown', (e) => this.handleTriggerKeyDown(e)); this.content.addEventListener('keydown', (e) => this.handleMenuKeyDown(e)); // Menu item clicks this.items.forEach((item, index) => { // Use mousedown to remove hover state INSTANTLY item.addEventListener('mousedown', (e) => { if (!item.disabled && !item.hasAttribute('disabled') && !item.classList.contains('pm7-menu-item--has-submenu')) { // Remove all hover effects immediately item.classList.add('pm7-menu-item--clicking'); // Mark that we're clicking this._clickingItem = true; } }); item.addEventListener('click', (e) => { if (!item.disabled && !item.hasAttribute('disabled')) { this.handleItemClick(e, item); } // Reset _clickingItem after the click event has been processed this._clickingItem = false; }); // Make items focusable item.setAttribute('tabindex', '-1'); }); // Reposition on window resize/scroll window.addEventListener('resize', this.boundHandlers.reposition); window.addEventListener('scroll', this.boundHandlers.reposition, true); } cleanup() { // Remove all event listeners if they exist if (this.boundHandlers) { this.trigger?.removeEventListener('click', this.boundHandlers.triggerClick); this.trigger?.removeEventListener('mouseenter', this.boundHandlers.triggerMouseEnter); document.removeEventListener('click', this.boundHandlers.outsideClick); document.removeEventListener('keydown', this.boundHandlers.escape); window.removeEventListener('resize', this.boundHandlers.reposition); window.removeEventListener('scroll', this.boundHandlers.reposition, true); } } destroy() { this.cleanup(); this.close(); PM7Menu.instances.delete(this.element); delete this.element._pm7MenuInstance; } toggle() { this.isOpen ? this.close() : this.open(); } open() { // Close all other open menus // Since we're using WeakMap, we need to track open menus differently document.querySelectorAll('.pm7-menu-content--open').forEach(content => { const menu = content.closest('[data-pm7-menu]'); if (menu && menu._pm7MenuInstance && menu._pm7MenuInstance !== this) { menu._pm7MenuInstance.close(); } }); this.isOpen = true; this.content.classList.add('pm7-menu-content--open'); this.content.setAttribute('data-state', 'open'); // Add data-state for better state tracking this.trigger.setAttribute('aria-expanded', 'true'); // Add escape handler when menu opens document.addEventListener('keydown', this.boundHandlers.escape); // Check viewport position and adjust if needed this.adjustPosition(); // Focus first item requestAnimationFrame(() => { this.currentIndex = 0; this.focusItem(0); }); // Dispatch custom event this.element.dispatchEvent(new CustomEvent('pm7:menu:open', { detail: { menu: this }, bubbles: true })); } close() { if (!this.isOpen) return; this.isOpen = false; this.content.classList.remove('pm7-menu-content--open'); this.content.setAttribute('data-state', 'closed'); this.trigger.setAttribute('aria-expanded', 'false'); this.currentIndex = -1; // Remove escape handler when menu closes document.removeEventListener('keydown', this.boundHandlers.escape); // Clear all hover timeouts this.hoverTimeouts.forEach(timeout => clearTimeout(timeout)); this.hoverTimeouts.clear(); // Close all submenus const submenuItems = this.element.querySelectorAll('.pm7-menu-item--has-submenu'); submenuItems.forEach(item => { item.setAttribute('data-submenu-open', 'false'); }); // Remove focus from items this.items.forEach(item => { item.setAttribute('tabindex', '-1'); item.classList.remove('pm7-menu-item--clicking'); }); // Dispatch custom event this.element.dispatchEvent(new CustomEvent('pm7:menu:close', { detail: { menu: this }, bubbles: true })); } handleTriggerKeyDown(e) { switch (e.key) { case 'Enter': case ' ': case 'ArrowDown': e.preventDefault(); this.open(); break; case 'ArrowUp': e.preventDefault(); this.open(); this.currentIndex = this.items.length - 1; this.focusItem(this.currentIndex); break; } } handleMenuKeyDown(e) { switch (e.key) { case 'ArrowDown': e.preventDefault(); this.focusNext(); break; case 'ArrowUp': e.preventDefault(); this.focusPrevious(); break; case 'Home': e.preventDefault(); this.focusItem(0); break; case 'End': e.preventDefault(); this.focusItem(this.items.length - 1); break; case 'Enter': case ' ': e.preventDefault(); const currentItem = this.items[this.currentIndex]; if (currentItem && !currentItem.disabled) { currentItem.click(); } break; case 'Tab': // Close menu on tab this.close(); break; } } focusNext() { const nextIndex = this.currentIndex + 1; if (nextIndex < this.items.length) { this.focusItem(nextIndex); } else { this.focusItem(0); // Wrap to first } } focusPrevious() { const prevIndex = this.currentIndex - 1; if (prevIndex >= 0) { this.focusItem(prevIndex); } else { this.focusItem(this.items.length - 1); // Wrap to last } } focusItem(index) { const item = this.items[index]; if (!item) return; // Only update if different item if (this.currentIndex === index) return; // Remove tabindex from previous item if (this.currentIndex >= 0 && this.items[this.currentIndex]) { this.items[this.currentIndex].setAttribute('tabindex', '-1'); } this.currentIndex = index; item.setAttribute('tabindex', '0'); item.focus(); } handleItemClick(e, item) { // Close menu immediately for regular items (not submenus) if (!item.classList.contains('pm7-menu-item--has-submenu')) { this.close(); this.trigger.focus(); } // Handle checkbox items if (item.classList.contains('pm7-menu-item--checkbox')) { const isChecked = item.getAttribute('data-checked') === 'true'; item.setAttribute('data-checked', !isChecked); } // Handle radio items if (item.classList.contains('pm7-menu-item--radio')) { // Uncheck all radio items in the same group const radioItems = this.element.querySelectorAll('.pm7-menu-item--radio'); radioItems.forEach(radio => radio.setAttribute('data-checked', 'false')); // Check the clicked item item.setAttribute('data-checked', 'true'); } // Handle submenu items if (item.classList.contains('pm7-menu-item--has-submenu')) { e.preventDefault(); e.stopPropagation(); // Toggle submenu const isOpen = item.getAttribute('data-submenu-open') === 'true'; item.setAttribute('data-submenu-open', !isOpen); return; // Don't close the main menu } // Dispatch custom event const event = new CustomEvent('pm7-menu-select', { detail: { item, menu: this }, bubbles: true }); this.element.dispatchEvent(event); } adjustPosition() { // Get dimensions const triggerRect = this.trigger.getBoundingClientRect(); const contentRect = this.content.getBoundingClientRect(); const viewportHeight = window.innerHeight; const viewportWidth = window.innerWidth; // Calculate space above and below const spaceBelow = viewportHeight - triggerRect.bottom; const spaceAbove = triggerRect.top; // Check vertical position if (contentRect.height > spaceBelow && spaceAbove > spaceBelow) { // Not enough space below, but more space above this.content.classList.add('pm7-menu-content--top'); } else { // Enough space below or more space below than above this.content.classList.remove('pm7-menu-content--top'); } // Check horizontal position for end-aligned menus if (this.content.classList.contains('pm7-menu-content--end')) { const rightEdge = triggerRect.right; if (rightEdge < contentRect.width) { // Not enough space on the right, switch to left alignment this.content.classList.remove('pm7-menu-content--end'); this.content.classList.add('pm7-menu-content--start'); } } // Check horizontal position for center-aligned menus if (this.content.classList.contains('pm7-menu-content--center')) { const centerX = triggerRect.left + (triggerRect.width / 2); const menuHalfWidth = contentRect.width / 2; if (centerX - menuHalfWidth < 0) { // Would overflow on the left this.content.classList.remove('pm7-menu-content--center'); this.content.classList.add('pm7-menu-content--start'); } else if (centerX + menuHalfWidth > viewportWidth) { // Would overflow on the right this.content.classList.remove('pm7-menu-content--center'); this.content.classList.add('pm7-menu-content--end'); } } } // Check if any menu in the same menu bar is open isAnyMenuBarMenuOpen() { if (!this.isInMenuBar) return false; const menuBar = this.element.closest('.pm7-menu-bar'); if (!menuBar) return false; // Check all menus in the same menu bar const menusInBar = menuBar.querySelectorAll('.pm7-menu'); for (const menuEl of menusInBar) { // Skip current menu if (menuEl === this.element) continue; // Check if menu content is visible (open) const menuContent = menuEl.querySelector('.pm7-menu-content'); if (menuContent && menuContent.classList.contains('pm7-menu-content--open')) { return true; } } return false; } // Initialize submenu hover handling with improved UX initSubmenuHoverHandling() { const submenuItems = this.element.querySelectorAll('.pm7-menu-item--has-submenu'); submenuItems.forEach((item, index) => { const submenu = item.nextElementSibling; if (!submenu || !submenu.classList.contains('pm7-submenu')) return; const timeoutKey = `submenu-${index}`; // Handle mouse enter on parent item item.addEventListener('mouseenter', () => { const timeout = this.hoverTimeouts.get(timeoutKey); if (timeout) { clearTimeout(timeout); this.hoverTimeouts.delete(timeoutKey); } item.setAttribute('data-submenu-open', 'true'); }); // Handle mouse leave on parent item item.addEventListener('mouseleave', (e) => { // Check if we're moving to the submenu const toElement = e.relatedTarget; if (toElement && (submenu.contains(toElement) || submenu === toElement)) { return; // Don't close if moving to submenu } // Add small delay before closing const timeout = setTimeout(() => { item.setAttribute('data-submenu-open', 'false'); this.hoverTimeouts.delete(timeoutKey); }, 100); // 100ms delay this.hoverTimeouts.set(timeoutKey, timeout); }); // Handle mouse enter on submenu submenu.addEventListener('mouseenter', () => { const timeout = this.hoverTimeouts.get(timeoutKey); if (timeout) { clearTimeout(timeout); this.hoverTimeouts.delete(timeoutKey); } item.setAttribute('data-submenu-open', 'true'); }); // Handle mouse leave on submenu submenu.addEventListener('mouseleave', (e) => { // Check if we're moving back to the parent const toElement = e.relatedTarget; if (toElement && (item.contains(toElement) || item === toElement)) { return; // Don't close if moving back to parent } // Add small delay before closing const timeout = setTimeout(() => { item.setAttribute('data-submenu-open', 'false'); this.hoverTimeouts.delete(timeoutKey); }, 100); // 100ms delay this.hoverTimeouts.set(timeoutKey, timeout); }); }); } } // Auto-initialize menus if (typeof document !== 'undefined' && !window.__PM7_MENU_INIT__) { window.__PM7_MENU_INIT__ = true; const initMenus = () => { // Regular initialization const menus = document.querySelectorAll('[data-pm7-menu]:not([data-pm7-menu-initialized])'); menus.forEach((menu) => { try { new PM7Menu(menu); } catch (error) { console.error('[PM7Menu] Error initializing menu:', error); } }); }; // Initialize immediately if DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initMenus, { once: true }); } else { setTimeout(initMenus, 0); } } /** * PM7Dialog - Vanilla JavaScript dialog/modal component * Handles modal dialogs with accessibility features */ class PM7Dialog { constructor(element) { this.element = element; // AI-Agent FIRST: Automatically add pm7-dialog class if missing if (!this.element.classList.contains('pm7-dialog')) { this.element.classList.add('pm7-dialog'); } this.backdrop = element.querySelector('.pm7-dialog-overlay'); this.closeButton = element.querySelector('.pm7-dialog-close'); this.isOpen = false; this.previousActiveElement = null; this.focusableElements = []; this.init(); } init() { // Close button if (this.closeButton) { this.closeButton.addEventListener('click', () => this.close()); } // Backdrop click if (this.backdrop) { this.backdrop.addEventListener('click', (e) => { if (e.target === this.backdrop) { this.close(); } }); } // Escape key - use bound function to allow proper removal this.handleEscape = (e) => { if (e.key === 'Escape' && this.isOpen) { e.stopImmediatePropagation(); // Prevent other escape handlers this.close(); } }; // Tab trap - use bound function to allow proper removal this.handleTab = (e) => { if (e.key === 'Tab' && this.isOpen) { this.trapFocus(e); } }; } open() { if (this.isOpen) return; // Close all open menus before opening dialog this.closeAllMenus(); this.isOpen = true; this.previousActiveElement = document.activeElement; // Show dialog this.element.setAttribute('data-state', 'open'); // Prevent body scroll document.body.classList.add('pm7-dialog-open'); // Setup focus trap this.setupFocusTrap(); // Add event listeners document.addEventListener('keydown', this.handleEscape); document.addEventListener('keydown', this.handleTab); // Focus first focusable element or close button requestAnimationFrame(() => { const firstFocusable = this.focusableElements[0]; if (firstFocusable) { firstFocusable.focus(); } else if (this.closeButton) { this.closeButton.focus(); } }); // Dispatch open event this.element.dispatchEvent(new CustomEvent('pm7-dialog-open', { detail: { dialog: this }, bubbles: true })); } close() { if (!this.isOpen) return; this.isOpen = false; // Hide dialog this.element.setAttribute('data-state', 'closed'); // Restore body scroll document.body.classList.remove('pm7-dialog-open'); // Remove event listeners document.removeEventListener('keydown', this.handleEscape); document.removeEventListener('keydown', this.handleTab); // Restore focus if (this.previousActiveElement) { this.previousActiveElement.focus(); } // Dispatch close event this.element.dispatchEvent(new CustomEvent('pm7-dialog-close', { detail: { dialog: this }, bubbles: true })); } setupFocusTrap() { // Find all focusable elements const selector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; this.focusableElements = Array.from(this.element.querySelectorAll(selector)) .filter(el => !el.disabled && el.offsetParent !== null); } trapFocus(e) { if (this.focusableElements.length === 0) return; const firstFocusable = this.focusableElements[0]; const lastFocusable = this.focusableElements[this.focusableElements.length - 1]; if (e.shiftKey) { // Shift + Tab if (document.activeElement === firstFocusable) { e.preventDefault(); lastFocusable.focus(); } } else { // Tab if (document.activeElement === lastFocusable) { e.preventDefault(); firstFocusable.focus(); } } } closeAllMenus() { // Close all open menus const openMenus = document.querySelectorAll('.pm7-menu-content--open, .pm7-menu-content[data-state="open"]'); openMenus.forEach(menu => { menu.classList.remove('pm7-menu-content--open'); menu.removeAttribute('data-state'); // Update trigger state const menuContainer = menu.closest('.pm7-menu'); if (menuContainer) { const trigger = menuContainer.querySelector('.pm7-menu-trigger'); if (trigger) { trigger.setAttribute('aria-expanded', 'false'); } } }); // Also try using PM7Menu instances if available if (typeof window !== 'undefined' && window.PM7?.Menu) { // Access the static instances Map from the Menu class const MenuClass = window.PM7.Menu; if (MenuClass.instances && MenuClass.instances.forEach) { MenuClass.instances.forEach((instance) => { if (instance.isOpen) { instance.close(); } }); } } } shake() { this.element.classList.add('pm7-dialog--shake'); setTimeout(() => { this.element.classList.remove('pm7-dialog--shake'); }, 200); } setLoading(loading) { if (loading) { this.element.classList.add('pm7-dialog--loading'); } else { this.element.classList.remove('pm7-dialog--loading'); } } } // Helper function to create dialogs programmatically function createDialog(options = {}) { const { title = 'Dialog', content = '', size = 'md', variant = '', showClose = true, buttons = [] } = options; // Create backdrop const overlay = document.createElement('div'); overlay.className = 'pm7-dialog-overlay'; // Create dialog const dialog = document.createElement('div'); dialog.className = `pm7-dialog pm7-dialog--${size}`; if (variant) { dialog.className += ` pm7-dialog--${variant}`; } // Create header const header = document.createElement('div'); header.className = 'pm7-dialog-header'; const titleEl = document.createElement('h2'); titleEl.className = 'pm7-dialog-title'; titleEl.textContent = title; header.appendChild(titleEl); if (showClose) { const closeBtn = document.createElement('button'); closeBtn.className = 'pm7-dialog-close'; closeBtn.innerHTML = '×'; closeBtn.setAttribute('aria-label', 'Close dialog'); header.appendChild(closeBtn); } dialog.appendChild(header); // Create body const body = document.createElement('div'); body.className = 'pm7-dialog-body'; if (typeof content === 'string') { body.innerHTML = content; } else { body.appendChild(content); } dialog.appendChild(body); // Create footer if buttons provided if (buttons.length > 0) { const footer = document.createElement('div'); footer.className = 'pm7-dialog-footer'; buttons.forEach(btnOptions => { const btn = document.createElement('button'); btn.className = `pm7-button pm7-button--${btnOptions.variant || 'primary'}`; btn.textContent = btnOptions.text; if (btnOptions.onClick) { btn.addEventListener('click', btnOptions.onClick); } footer.appendChild(btn); }); dialog.appendChild(footer); } // Create container const container = document.createElement('div'); container.appendChild(overlay); container.appendChild(dialog); // Add to body document.body.appendChild(container); // Initialize const dialogInstance = new PM7Dialog(container); // Clean up on close container.addEventListener('pm7-dialog-close', () => { setTimeout(() => { document.body.removeChild(container); }, 300); }); return dialogInstance; } // Confirm dialog helper function pm7Confirm(message, options = {}) { return new Promise((resolve) => { const dialog = createDialog({ title: options.title || 'Confirm', content: message, size: 'sm', buttons: [ { text: options.cancelText || 'Cancel', variant: 'outline', onClick: () => { dialog.close(); resolve(false); } }, { text: options.confirmText || 'Confirm', variant: options.variant || 'primary', onClick: () => { dialog.close(); resolve(true); } } ] }); dialog.open(); }); } // Alert dialog helper function pm7Alert(message, options = {}) { return new Promise((resolve) => { const dialog = createDialog({ title: options.title || 'Alert', content: message, size: 'sm', variant: options.variant, buttons: [ { text: options.buttonText || 'OK', variant: 'primary', onClick: () => { dialog.close(); resolve(); } } ] }); dialog.open(); }); } // Store ESC handlers to properly clean them up const escHandlers = new Map(); // Helper function to close all open menus function closeAllOpenMenus() { // First try to close menus by removing open classes const openMenus = document.querySelectorAll('.pm7-menu-content--open, .pm7-menu-content[data-state="open"]'); openMenus.forEach(menu => { menu.classList.remove('pm7-menu-content--open'); menu.removeAttribute('data-state'); // Update trigger state const menuContainer = menu.closest('.pm7-menu'); if (menuContainer) { const trigger = menuContainer.querySelector('.pm7-menu-trigger'); if (trigger) { trigger.setAttribute('aria-expanded', 'false'); } } }); // Also try using PM7Menu instances if available if (typeof window !== 'undefined' && window.PM7?.Menu) { // Access the static instances Map from the Menu class const MenuClass = window.PM7.Menu; if (MenuClass.instances && MenuClass.instances.forEach) { MenuClass.instances.forEach((instance) => { if (instance.isOpen) { instance.close(); } }); } } } // Predefined icons const dialogIcons = { info: `<svg class="pm7-dialog-icon-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" style="color: rgb(28, 134, 239);"> <path d="M3 12a9 9 0 1 0 18 0 9 9 0 0 0-18 0m9-3h.01"></path> <path d="M11 12h1v4h1"></path> </svg>`, warning: `<svg class="pm7-dialog-icon-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" style="color: rgb(245, 158, 11);"> <path d="M12 9v4m0 4h.01M5.07 19H19a2 2 0 0 0 1.75-2.95L13.75 4a2 2 0 0 0-3.5 0L3.25 16.05A2 2 0 0 0 5.07 19z"></path> </svg>`, error: `<svg class="pm7-dialog-icon-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" style="color: rgb(239, 68, 68);"> <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 class="pm7-dialog-icon-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" style="color: rgb(34, 197, 94);"> <circle cx="12" cy="12" r="10"></circle> <path d="M9 12l2 2 4-4"></path> </svg>` }; // Transform dialog based on content markers function transformDialog(dialogElement) { // Check if already transformed if (dialogElement.querySelector('.pm7-dialog-overlay')) { return; } // Read dialog attributes dialogElement.getAttribute('data-pm7-dialog'); const size = dialogElement.getAttribute('data-pm7-dialog-size') || 'md'; const showCloseButton = dialogElement.hasAttribute('data-pm7-show-close'); // Default behavior: ESC and overlay close are enabled unless explicitly disabled const preventEscapeClose = dialogElement.hasAttribute('data-pm7-no-escape'); const preventOverlayClose = dialogElement.hasAttribute('data-pm7-no-overlay-close'); // Get content sections - CRITICAL: Read ALL content before clearing! const headerEl = dialogElement.querySelector('[data-pm7-header]'); const bodyEl = dialogElement.querySelector('[data-pm7-body]'); const footerEl = dialogElement.querySelector('[data-pm7-footer]'); // Store section data IMMEDIATELY before anything can modify the DOM const sections = { header: headerEl ? { content: headerEl.innerHTML, title: headerEl.getAttribute('data-pm7-dialog-title'), subtitle: headerEl.getAttribute('data-pm7-dialog-subtitle'), icon: headerEl.getAttribute('data-pm7-dialog-icon'), separator: headerEl.hasAttribute('data-pm7-header-separator') } : null, body: bodyEl ? bodyEl.innerHTML : null, footer: footerEl ? footerEl.innerHTML : null }; // NOW clear dialog - AFTER we've safely stored all content dialogElement.innerHTML = ''; // Add pm7-dialog class if missing if (!dialogElement.classList.contains('pm7-dialog')) { dialogElement.classList.add('pm7-dialog'); } // Create overlay const overlay = document.createElement('div'); overlay.className = 'pm7-dialog-overlay'; dialogElement.appendChild(overlay); // Create content container const content = document.createElement('div'); content.className = `pm7-dialog-content pm7-dialog-content--${size}`; // Build header if exists if (sections.header) { const header = document.createElement('div'); header.className = 'pm7-dialog-header'; // Create a container for title and subtitle const textContainer = document.createElement('div'); textContainer.className = 'pm7-dialog-header-text'; // Add title if specified if (sections.header.title) { const titleEl = document.createElement('h2'); titleEl.className = 'pm7-dialog-title'; titleEl.textContent = sections.header.title; textContainer.appendChild(titleEl); } // Add subtitle if specified if (sections.header.subtitle) { const subtitleEl = document.createElement('p'); subtitleEl.className = 'pm7-dialog-description'; subtitleEl.textContent = sections.header.subtitle; textContainer.appendChild(subtitleEl); } header.appendChild(textContainer); // Create a container for actions (icon and close button) const actionsContainer = document.createElement('div'); actionsContainer.className = 'pm7-dialog-header-actions'; // Add icon if specified if (sections.header.icon) { const iconDiv = document.createElement('div'); iconDiv.className = 'pm7-dialog-icon'; iconDiv.innerHTML = dialogIcons[sections.header.icon] || ''; actionsContainer.appendChild(iconDiv); } // Add close button if requested if (showCloseButton) { const closeBtn = document.createElement('button'); closeBtn.className = 'pm7-dialog-close'; closeBtn.setAttribute('aria-label', 'Close'); closeBtn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <line x1="18" y1="6" x2="6" y2="18"/> <line x1="6" y1="6" x2="18" y2="18"/> </svg>`; actionsContainer.appendChild(closeBtn); } header.appendChild(actionsContainer); content.appendChild(header); // Add header separator if requested if (sections.header.separator) { const separator = document.createElement('div'); separator.className = 'pm7-dialog-header-separator'; content.appendChild(separator); } } // Add body if exists if (sections.body !== null) { const body = document.createElement('div'); body.className = 'pm7-dialog-body'; body.innerHTML = sections.body; content.appendChild(body); } // Add footer if exists if (sections.footer !== null) { const footer = document.createElement('div'); footer.className = 'pm7-dialog-footer'; footer.innerHTML = sections.footer; content.appendChild(footer); } dialogElement.appendChild(content); // Store dialog settings for openDialog function dialogElement._dialogSettings = { closeOnEscape: !preventEscapeClose, // Inverted: true by default closeOnOverlay: !preventOverlayClose // Inverted: true by default }; // Set initial state dialogElement.setAttribute('data-state', 'closed'); } // Simple helper functions for pm7-dialog elements function openDialog(dialogId) { const dialogElement = document.querySelector(`[data-pm7-dialog="${dialogId}"]`); if (!dialogElement) { console.warn(`Dialog with id "${dialogId}" not found`); return; } // Check if dialog needs transformation const needsTransform = !dialogElement.querySelector('.pm7-dialog-overlay'); // Self-healing: detect if framework re-rendered the original structure const hasOriginalMarkers = dialogElement.querySelector('[data-pm7-header]') || dialogElement.querySelector('[data-pm7-body]') || dialogElement.querySelector('[data-pm7-footer]'); if (hasOriginalMarkers && needsTransform) { // Dialog structure was restored by framework re-render const currentState = dialogElement.getAttribute('data-state'); // Only re-initialize if not currently open or closing if (currentState !== 'open' && currentState !== 'closing') { // Transform the dialog transformDialog(dialogElement); // Continue with normal open flow after transformation } else if (currentState === 'open') { // Dialog is already open, don't re-open return; } else if (currentState === 'closing') { // Dialog is closing, don't interfere return; } } else if (needsTransform) { // No markers but needs transform (shouldn't happen in normal flow) transformDialog(dialogElement); } // Check if already open (prevent double-open) if (dialogElement.getAttribute('data-state') === 'open') { return; } // Check if closing (prevent open during close animation) if (dialogElement.getAttribute('data-state') === 'closing') { return; } // Close all open menus before opening dialog closeAllOpenMenus(); // Set dialog to open state dialogElement.setAttribute('data-state', 'open'); document.body.classList.add('pm7-dialog-open'); // Get dialog settings const settings = dialogElement._dialogSettings || {}; // Add close handlers const overlay = dialogElement.querySelector('.pm7-dialog-overlay'); const closeBtn = dialogElement.querySelector('.pm7-dialog-close'); // Overlay click handler - enabled by default unless settings.closeOnOverlay is false if (overlay && settings.closeOnOverlay !== false) { overlay.onclick = () => closeDialog(dialogId); } if (closeBtn) { closeBtn.onclick = () => closeDialog(dialogId); } // ESC key handler - store it so we can remove it later if (settings.closeOnEscape !== false) { // Default true unless explicitly false const escHandler = (e) => { if (e.key === 'Escape') { closeDialog(dialogId); } }; // Remove any existing handler for this dialog if (escHandlers.has(dialogId)) { document.removeEventListener('keydown', escHandlers.get(dialogId)); } // Store and add new handler escHandlers.set(dialogId, escHandler); document.addEventListener('keydown', escHandler); } } function closeDialog(dialogId) { const dialogElement = document.querySelector(`[data-pm7-dialog="${dialogId}"]`); if (!dialogElement) return; // Add closing state for animation dialogElement.setAttribute('data-state', 'closing'); dialogElement.classList.remove('pm7-dialog--open'); // Wait for animation to complete before removing setTimeout(() => { dialogElement.setAttribute('data-state', 'closed'); // Check if any other dialogs are open const openDialogs = document.querySelectorAll('.pm7-dialog[data-state="open"]'); if (openDialogs.length === 0) { document.body.classList.remove('pm7-dialog-open'); } }, 200); // Match transition duration // Remove ESC handler for this dialog if (escHandlers.has(dialogId)) { document.removeEventListener('keydown', escHandlers.get(dialogId)); escHandlers.delete(dialogId); } } // Auto-initialize dialogs function autoInitDialogs() { const dialogs = document.querySelectorAll('[data-pm7-dialog]:not([data-state])'); dialogs.forEach(dialog => { // Transform dialog structure if needed transformDialog(dialog); // Set initial closed state dialog.setAttribute('data-state', 'closed'); }); } // Initialize on DOM ready for traditional apps if (typeof document !== 'undefined') { document.addEventListener('DOMContentLoaded', autoInitDialogs); } // Make openDialog and closeDialog available globally for convenience if (typeof window !== 'undefined') { window.openDialog = openDialog; window.closeDialog = closeDialog; } // Don't automatically pollute global scope // These functions are available via window.PM7 namespace /** * PM7 Button Component JavaScript * Adds 6stars effect to primary buttons */ class PM7Button { constructor(element) { this.element = element; this.init(); } init() { // Only add 6stars to primary buttons if (this.element.classList.contains('pm7-button--primary') || this.element.classList.contains('pm7-button--default')) { this.add6StarsEffect(); } // Initialize slider button functionality if (this.element.classList.contains('pm7-button--slider')) { this.initSlider(); } } add6StarsEffect() { // Create 6stars container const starsContainer = document.createElement('div'); starsContainer.className = 'pm7-button__6stars'; // Create 6 stars for (let i = 0; i < 6; i++) { const star = document.createElement('span'); star.className = 'star'; starsContainer.appendChild(star); } // Add to button this.element.appendChild(starsContainer); } initSlider() { this.handle = this.element.querySelector('.pm7-button--slider-handle'); this.text = this.element.querySelector('.pm7-button--slider-text'); if (!this.handle) return; this.isDragging = false; this.startX = 0; this.currentX = 0; this.handleX = 0; this.maxX = 0; this.threshold = 0.95; // 95% to complete // Bind event handlers this.handleMouseDown = this.handleMouseDown.bind(this); this.handleMouseMove = this.handleMouseMove.bind(this); this.handleMouseUp = this.handleMouseUp.bind(this); this.handleTouchStart = this.handleTouchStart.bind(this); this.handleTouchMove = this.handleTouchMove.bind(this); this.handleTouchEnd = this.handleTouchEnd.bind(this); // Add event listeners this.handle.addEventListener('mousedown', this.handleMouseDown); this.handle.addEventListener('touchstart', this.handleTouchStart, { passive: false }); // Calculate max position this.updateMaxPosition(); // Update on resize window.addEventListener('resize', () => this.updateMaxPosition()); } updateMaxPosition() { const buttonWidth = this.element.offsetWidth; const handleWidth = this.handle.offsetWidth; this.maxX = buttonWidth - handleWidth - 8; // 4px padding on each side } handleMouseDown(e) { if (this.element.disabled || this.element.hasAttribute('data-pm7-slider-complete')) return; this.isDragging = true; this.startX = e.clientX - this.handleX; this.element.setAttribute('data-pm7-slider-dragging', 'true'); document.addEventListener('mousemove', this.handleMouseMove); document.addEventListener('mouseup', this.handleMouseUp); e.preventDefault(); } handleTouchStart(e) { if (this.element.disabled || this.element.hasAttribute('data-pm7-slider-complete')) return; const touch = e.touches[0]; this.isDragging = true; this.startX = touch.clientX - this.handleX; this.element.setAttribute('data-pm7-slider-dragging', 'true'); document.addEventListener('touchmove', this.handleTouchMove, { passive: false }); document.addEventListener('touchend', this.handleTouchEnd); e.preventDefault(); } handleMouseMove(e) { if (!this.isDragging) return; this.currentX = e.clientX - this.startX; this.updateHandlePosition(); } handleTouchMove(e) { if (!this.isDragging) return; const touch = e.touches[0]; this.currentX = touch.clientX - this.startX; this.updateHandlePosition(); e.preventDefault(); } updateHandlePosition() { // Constrain position this.handleX = Math.max(0, Math.min(this.currentX, this.maxX)); // Update handle position this.handle.style.transform = `translateX(${this.handleX}px)`; // Check if threshold reached const progress = this.handleX / this.maxX; if (progress >= this.threshold) { this.complete(); } } handleMouseUp() { this.endDrag(); document.removeEventListener('mousemove', this.handleMouseMove); document.removeEventListener('mouseup', this.handleMouseUp); } handleTouchEnd() { this.endDrag(); document.removeEventListener('touchmove', this.handleTouchMove); document.removeEventListener('touchend', this.handleTouchEnd); } endDrag() { if (!this.isDragging) return; this.isDragging = false; this.element.removeAttribute('data-pm7-slider-dragging'); // If not completed, snap back const progress = this.handleX / this.maxX; if (progress < this.threshold && !this.element.hasAttribute('data-pm7-slider-complete')) { this.handleX = 0; this.handle.style.transform = 'translateX(0)'; } } complete() { if (this.element.hasAttribute('data-pm7-slider-complete')) return; // Snap to end this.handleX = this.maxX; this.handle.style.transform = `translateX(${this.maxX}px)`; // Mark as complete this.element.setAttribute('data-pm7-slider-complete', 'true'); // Dispatch event this.element.dispatchEvent(new CustomEvent('pm7:slider:complete', { bubbles: true, detail: { button: this.element } })); // Trigger click event after a small delay setTimeout(() => { this.element.click(); }, 300); } reset() { this.handleX = 0; this.handle.style.transform = 'translateX(0)'; this.element.removeAttribute('data-pm7-slider-complete'); this.element.removeAttribute('data-pm7-slider-dragging'); } } // Auto-initialize buttons function initButtons() { // Initialize primary/default buttons with 6stars document.querySelectorAll('.pm7-button--primary, .pm7-button--default').forEach(button => { if (!button.querySelector('.pm7-button__6stars')) { new PM7Button(button); } }); // Initialize slider buttons document.querySelectorAll('.pm7-button--slider').forEach(button => { if (!button.PM7Button) { button.PM7Button = new PM7Button(button); } }); } // Initialize on DOM ready if (typeof document !== 'undefined') { if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initButtons); } else { initButtons(); } } /** * PM7 Toast Component JavaScript * Provides toast notification functionality */ class PM7Toast { constructor() { this.viewport = null; this.toasts = new Map(); this.init(); } init() { // Create viewport if it doesn't exist if (!document.querySelector('.pm7-toast-viewport')) { this.viewport = document.createElement('div'); this.viewport.className = 'pm7-toast-viewport'; document.body.appendChild(this.viewport); } else { this.viewport = document.querySelector('.pm7-toast-viewport'); } } show(options = {}) { const { title = '', description = '', variant = 'default', duration = 5000, action = null, onClose = null } = options; // Create toast element const toast = document.createElement('div'); const id = Date.now().toString(); toast.className = `pm7-toast pm7-toast--${variant}`; toast.setAttribute('data-state', 'open'); toast.setAttribute('data-toast-id', id); // Build toast content const toastHeader = document.createElement('div'); toastHeader.className = 'pm7-toast-header'; const textContainer = document.createElement('div'); if (title) { const titleEl = document.createElement('h3'); titleEl.className = 'pm7-t