let activeDropdown = null; function normalizeItems(items) { const value = typeof items === 'function' ? items() : items; return Array.isArray(value) ? value.filter(Boolean) : []; } function resolvePlacement({ placement, align }) { if (placement === 'bottom-start' || placement === 'top-start') return 'left'; if (placement === 'bottom-end' || placement === 'top-end') return 'right'; return align === 'left' ? 'left' : 'right'; } export function createDropdownMenu({ anchorEl, items = [], renderContent = null, className = '', minWidth = 210, offset = 7, placement = 'bottom-end', align = null, leftShift = 0, transparent = false, dimBackground = true, keepAnchorPressed = true, onOpen = null, onClose = null, } = {}) { let portal = null; let menuEl = null; let destroyed = false; const setAnchorOpen = (isOpen) => { if (!anchorEl) return; anchorEl.setAttribute('aria-haspopup', 'menu'); anchorEl.setAttribute('aria-expanded', isOpen ? 'true' : 'false'); if (keepAnchorPressed && isOpen) anchorEl.dataset.open = 'true'; else delete anchorEl.dataset.open; }; const close = ({ focusAnchor = false } = {}) => { if (!portal) return; portal.remove(); portal = null; menuEl = null; if (activeDropdown === api) activeDropdown = null; setAnchorOpen(false); onClose?.(); if (focusAnchor) anchorEl?.focus?.(); }; const position = () => { if (!portal || !menuEl || !anchorEl) return; const rect = anchorEl.getBoundingClientRect(); const margin = 10; const menuWidth = menuEl.offsetWidth || minWidth; const side = resolvePlacement({ placement, align }); const baseLeft = side === 'left' ? rect.left : rect.right - menuWidth; const desiredLeft = baseLeft - Number(leftShift || 0); const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, desiredLeft)); const menuHeight = menuEl.offsetHeight || 180; const prefersTop = String(placement || '').startsWith('top-'); let top = prefersTop ? rect.top - menuHeight - offset : rect.bottom + offset; if (!prefersTop && top + menuHeight > window.innerHeight - margin) { top = Math.max(margin, rect.top - menuHeight - offset); } else if (prefersTop && top < margin) { top = Math.min(window.innerHeight - menuHeight - margin, rect.bottom + offset); } menuEl.style.left = `${Math.round(left)}px`; menuEl.style.top = `${Math.round(top)}px`; }; const appendItems = () => { normalizeItems(items).forEach((item) => { if (item.divider) { const divider = document.createElement('div'); divider.className = 'dropdown-menu__divider'; divider.setAttribute('role', 'separator'); menuEl.append(divider); return; } const button = document.createElement('button'); button.type = 'button'; button.className = `dropdown-menu__item${item.selected ? ' is-selected' : ''}${item.danger ? ' is-danger' : ''}${item.className ? ` ${item.className}` : ''}`; button.setAttribute('role', 'menuitem'); button.disabled = Boolean(item.disabled); if (item.iconHtml) { const icon = document.createElement('span'); icon.className = 'dropdown-menu__icon'; icon.innerHTML = item.iconHtml; button.append(icon); } else if (item.iconSrc) { const icon = document.createElement('img'); icon.className = 'dropdown-menu__icon'; icon.src = item.iconSrc; icon.alt = ''; icon.setAttribute('aria-hidden', 'true'); button.append(icon); } const label = document.createElement('span'); label.textContent = String(item.label || ''); button.append(label); button.addEventListener('click', async (event) => { event.preventDefault(); event.stopPropagation(); if (button.disabled) return; close(); await item.action?.(); }); menuEl.append(button); }); }; const open = () => { if (destroyed || !anchorEl || portal) return; if (activeDropdown && activeDropdown !== api) activeDropdown.close(); portal = document.createElement('div'); portal.className = `dropdown-portal${dimBackground ? ' dropdown-portal--dim' : ''}`; if (dimBackground) { const backdrop = document.createElement('div'); backdrop.className = 'dropdown-backdrop'; backdrop.setAttribute('aria-hidden', 'true'); backdrop.addEventListener('pointerdown', (event) => { if (event.target === backdrop) close(); }); portal.append(backdrop); } menuEl = document.createElement('div'); menuEl.className = `dropdown-menu${transparent ? ' dropdown-menu--transparent' : ''}${className ? ` ${className}` : ''}`; menuEl.setAttribute('role', 'menu'); menuEl.style.minWidth = `${minWidth}px`; if (typeof renderContent === 'function') { const content = renderContent({ close, menuEl }); if (content instanceof Node) menuEl.append(content); } else { appendItems(); } menuEl.addEventListener('pointerdown', (event) => event.stopPropagation()); menuEl.addEventListener('click', (event) => event.stopPropagation()); portal.append(menuEl); document.body.append(portal); activeDropdown = api; setAnchorOpen(true); onOpen?.(); position(); }; const toggle = () => { if (portal) close(); else open(); }; const onAnchorClick = (event) => { event.preventDefault(); event.stopPropagation(); toggle(); }; const onOutsidePointerDown = (event) => { if (!portal) return; if (menuEl?.contains(event.target) || anchorEl?.contains(event.target)) return; close(); }; const onKeydown = (event) => { if (event.key !== 'Escape' || !portal) return; close({ focusAnchor: true }); }; const onNavigation = () => close(); const onViewportChange = () => position(); setAnchorOpen(false); anchorEl?.addEventListener('click', onAnchorClick); document.addEventListener('pointerdown', onOutsidePointerDown, true); document.addEventListener('keydown', onKeydown); window.addEventListener('popstate', onNavigation); window.addEventListener('hashchange', onNavigation); window.addEventListener('resize', onViewportChange, { passive: true }); window.addEventListener('scroll', onViewportChange, { passive: true, capture: true }); const api = { open, close, toggle, position, get isOpen() { return Boolean(portal); }, destroy() { if (destroyed) return; destroyed = true; close(); anchorEl?.removeEventListener('click', onAnchorClick); document.removeEventListener('pointerdown', onOutsidePointerDown, true); document.removeEventListener('keydown', onKeydown); window.removeEventListener('popstate', onNavigation); window.removeEventListener('hashchange', onNavigation); window.removeEventListener('resize', onViewportChange); window.removeEventListener('scroll', onViewportChange, true); setAnchorOpen(false); }, }; return api; }