233 lines
6.8 KiB
JavaScript
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 };
|
|
}
|