Files
client/www/js/dot-menu.mjs
2026-04-17 16:52:51 -04:00

233 lines
6.8 KiB
JavaScript

function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function normalizeItems(items) {
return Array.isArray(items) ? items.filter(Boolean) : [];
}
function isSubmenuItem(item) {
return item && typeof item === 'object' && Array.isArray(item.submenu);
}
function positionSubmenuPanel(detailsEl) {
if (!detailsEl) return;
const summaryEl = detailsEl.querySelector('.dot-menu-submenu-label');
const panelEl = detailsEl.querySelector('.dot-menu-submenu-panel');
if (!summaryEl || !panelEl) return;
const gap = 6;
const pad = 8;
const summaryRect = summaryEl.getBoundingClientRect();
const panelRect = panelEl.getBoundingClientRect();
const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 0;
const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0;
const panelWidth = panelRect.width || panelEl.offsetWidth || 180;
const panelHeight = panelRect.height || panelEl.offsetHeight || 40;
let left = summaryRect.right + gap;
if (left + panelWidth > viewportWidth - pad) {
left = summaryRect.left - panelWidth - gap;
}
left = clamp(left, pad, Math.max(pad, viewportWidth - panelWidth - pad));
let top = summaryRect.top;
if (top + panelHeight > viewportHeight - pad) {
top = viewportHeight - panelHeight - pad;
}
top = clamp(top, pad, Math.max(pad, viewportHeight - panelHeight - pad));
panelEl.style.left = `${Math.round(left)}px`;
panelEl.style.top = `${Math.round(top)}px`;
}
function closeSiblingSubmenus(parentEl, exceptEl = null) {
if (!parentEl) return;
const siblings = parentEl.querySelectorAll(':scope > .dot-menu-submenu[open]');
siblings.forEach((detailsEl) => {
if (exceptEl && detailsEl === exceptEl) return;
detailsEl.open = false;
});
}
function renderItems(containerEl, items, closeMenu) {
containerEl.innerHTML = '';
const normalized = normalizeItems(items);
normalized.forEach((item) => {
if (!item || typeof item !== 'object') return;
if (item.divider) {
const divider = document.createElement('div');
divider.className = 'dot-menu-divider';
divider.setAttribute('role', 'separator');
containerEl.appendChild(divider);
return;
}
if (isSubmenuItem(item)) {
const detailsEl = document.createElement('details');
detailsEl.className = 'dot-menu-submenu';
const summaryEl = document.createElement('summary');
summaryEl.className = 'dot-menu-submenu-label';
summaryEl.textContent = String(item.label || 'More');
const submenuPanel = document.createElement('div');
submenuPanel.className = 'dot-menu-submenu-panel';
renderItems(submenuPanel, item.submenu, closeMenu);
detailsEl.appendChild(summaryEl);
detailsEl.appendChild(submenuPanel);
containerEl.appendChild(detailsEl);
detailsEl.addEventListener('toggle', () => {
if (!detailsEl.open) return;
closeSiblingSubmenus(containerEl, detailsEl);
requestAnimationFrame(() => positionSubmenuPanel(detailsEl));
});
summaryEl.addEventListener('click', (event) => {
event.stopPropagation();
});
submenuPanel.addEventListener('click', (event) => {
event.stopPropagation();
});
return;
}
const button = document.createElement('button');
button.type = 'button';
button.className = 'dot-menu-item';
button.textContent = String(item.label || 'Untitled');
button.addEventListener('click', (event) => {
event.stopPropagation();
try {
if (typeof item.onClick === 'function') {
item.onClick();
}
} finally {
closeMenu();
}
});
containerEl.appendChild(button);
});
}
export function mountDotMenu(hostEl, config = {}) {
if (!(hostEl instanceof Element)) {
throw new Error('mountDotMenu(hostEl, config) requires a valid host element.');
}
let currentItems = normalizeItems(config.items);
const triggerLabel = typeof config.triggerLabel === 'string' ? config.triggerLabel : '⋯';
const ariaLabel = typeof config.ariaLabel === 'string' && config.ariaLabel.trim()
? config.ariaLabel.trim()
: 'Menu options';
const position = config.position === 'left' ? 'left' : 'right';
const wrap = document.createElement('div');
wrap.className = 'dot-menu-wrap';
const trigger = document.createElement('button');
trigger.type = 'button';
trigger.className = 'dot-menu-trigger';
trigger.setAttribute('aria-label', ariaLabel);
trigger.setAttribute('aria-haspopup', 'menu');
trigger.setAttribute('aria-expanded', 'false');
trigger.textContent = triggerLabel;
const panel = document.createElement('div');
panel.className = 'dot-menu-panel';
panel.dataset.position = position;
panel.setAttribute('role', 'menu');
wrap.appendChild(trigger);
wrap.appendChild(panel);
hostEl.innerHTML = '';
hostEl.appendChild(wrap);
const close = () => {
panel.classList.remove('is-open');
wrap.classList.remove('is-open');
trigger.setAttribute('aria-expanded', 'false');
panel.querySelectorAll('.dot-menu-submenu[open]').forEach((detailsEl) => {
detailsEl.open = false;
});
};
const open = () => {
panel.classList.add('is-open');
wrap.classList.add('is-open');
trigger.setAttribute('aria-expanded', 'true');
};
const toggle = () => {
if (panel.classList.contains('is-open')) {
close();
} else {
open();
}
};
const onTriggerClick = (event) => {
event.stopPropagation();
toggle();
};
const onDocumentClick = (event) => {
if (!wrap.contains(event.target)) {
close();
}
};
const onDocumentKeyDown = (event) => {
if (event.key === 'Escape') {
close();
}
};
const onViewportChange = () => {
panel.querySelectorAll('.dot-menu-submenu[open]').forEach((detailsEl) => {
positionSubmenuPanel(detailsEl);
});
};
trigger.addEventListener('click', onTriggerClick);
panel.addEventListener('click', (event) => event.stopPropagation());
document.addEventListener('click', onDocumentClick);
document.addEventListener('keydown', onDocumentKeyDown);
window.addEventListener('resize', onViewportChange);
window.addEventListener('scroll', onViewportChange, true);
renderItems(panel, currentItems, close);
const updateItems = (newItems) => {
currentItems = normalizeItems(newItems);
renderItems(panel, currentItems, close);
};
const destroy = () => {
close();
trigger.removeEventListener('click', onTriggerClick);
document.removeEventListener('click', onDocumentClick);
document.removeEventListener('keydown', onDocumentKeyDown);
window.removeEventListener('resize', onViewportChange);
window.removeEventListener('scroll', onViewportChange, true);
if (wrap.parentNode) {
wrap.parentNode.removeChild(wrap);
}
};
return { open, close, destroy, updateItems };
}