SHA256
215 lines
7.0 KiB
JavaScript
215 lines
7.0 KiB
JavaScript
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;
|
|
}
|