@pm7/core
Version:
The First UI Library Built for AI Coding Agents - Core CSS and JavaScript
1,614 lines (1,352 loc) • 116 kB
JavaScript
/**
* 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-toast-title';
titleEl.textContent = title;
textContainer.appendChild(titleEl);
}
if (description) {
const descriptionEl = document.createElement('p');
descriptionEl.className = 'pm7-toast-description';
descriptionEl.textContent = description;
textContainer.appendChild(descriptionEl);
}
toastHeader.appendChild(textContainer);
const closeButton = document.createElement('button');
closeButton.className = 'pm7-toast-close';
closeButton.setAttribute('aria-label', 'Close');
closeButton.innerHTML = '×';
toastHeader.appendChild(closeButton);
toast.appendChild(toastHeader);
if (action) {
const actionContainer = document.createElement('div');
actionContainer.className = 'pm7-toast-action';
actionContainer.innerHTML = action;
toast.appendChild(actionContainer);
}
if (duration > 0) {
const progressBar = document.createElement('div');
progressBar.className = 'pm7-toast-progress';
progressBar.style.animationDuration = `${duration}ms`;
toast.appendChild(progressBar);
}
// Add close handler
closeButton.addEventListener('click', () => this.close(id));
// Add to viewport
this.viewport.appendChild(toast);
this.toasts.set(id, { element: toast, onClose });
// Auto-dismiss
if (duration > 0) {
setTimeout(() => this.close(id), duration);
}
return id;
}
close(id) {
const toast = this.toasts.get(id);
if (!toast) return;
const { element, onClose } = toast;
// Trigger closing animation
element.setAttribute('data-state', 'closed');
// Remove after animation
setTimeout(() => {
element.remove();
this.toasts.delete(id);
if (onClose) onClose();
}, 200);
}
closeAll() {
this.toasts.forEach((_, id) => this.close(id));
}
}
// Create global instance
let globalToast = null;
// Helper functions
function showToast(options) {
if (!globalToast) {
globalToast = new PM7Toast();
}
return globalToast.show(options);
}
function closeToast(id) {
if (globalToast) {
globalToast.close(id);
}
}
function closeAllToasts() {
if (globalToast) {
globalToast.closeAll();
}
}
// Remove auto-initialization - toast will be created on first use
/**
* PM7TabSelector - Tab selector component with self-healing
* Handles tab navigation with automatic recovery from framework re-renders
*/
class PM7TabSelector {
static instances = new WeakMap();
constructor(element) {
// Self-healing: Check if element was re-rendered by framework
const wasInitialized = element.hasAttribute('data-pm7-tab-initialized');
const hasInstance = PM7TabSelector.instances.has(element);
// If initialized but no instance, element was re-rendered
if (wasInitialized && !hasInstance) {
console.log('[PM7TabSelector] Self-healing: Re-initializing tabs after framework re-render');
element.removeAttribute('data-pm7-tab-initialized');
}
// Check if this element already has a tab selector instance
if (PM7TabSelector.instances.has(element)) {
return PM7TabSelector.instances.get(element);