Files
basecoat/src/js/dropdown-menu.js
T

175 lines
5.3 KiB
JavaScript
Raw Normal View History

2025-06-12 18:44:17 +08:00
(() => {
const initDropdownMenu = (dropdownMenuComponent) => {
const trigger = dropdownMenuComponent.querySelector(':scope > button');
const popover = dropdownMenuComponent.querySelector(':scope > [data-popover]');
const menu = popover.querySelector('[role="menu"]');
if (!trigger || !menu || !popover) {
2025-06-18 19:42:26 +08:00
const missing = [];
if (!trigger) missing.push('trigger');
if (!menu) missing.push('menu');
if (!popover) missing.push('popover');
console.error(`Dropdown menu initialisation failed. Missing element(s): ${missing.join(', ')}`, dropdownMenuComponent);
return;
}
2025-05-01 20:53:03 +08:00
2025-06-12 18:44:17 +08:00
let menuItems = [];
let activeIndex = -1;
2025-05-01 20:53:03 +08:00
const isDisabled = (item) =>
item.hasAttribute('disabled') || item.getAttribute('aria-disabled') === 'true';
2025-06-18 20:55:32 +08:00
const closePopover = (focusOnTrigger = true) => {
if (trigger.getAttribute('aria-expanded') === 'false') return;
trigger.setAttribute('aria-expanded', 'false');
trigger.removeAttribute('aria-activedescendant');
popover.setAttribute('aria-hidden', 'true');
2025-06-18 20:55:32 +08:00
if (focusOnTrigger) {
trigger.focus();
}
2025-06-18 19:42:26 +08:00
setActiveItem(-1);
};
const openPopover = (initialSelection = false) => {
2025-06-18 20:55:32 +08:00
document.dispatchEvent(new CustomEvent('basecoat:popover', {
detail: { source: dropdownMenuComponent }
}));
trigger.setAttribute('aria-expanded', 'true');
popover.setAttribute('aria-hidden', 'false');
menuItems = Array.from(menu.querySelectorAll('[role^="menuitem"]')).filter(item => !isDisabled(item));
if (menuItems.length > 0 && initialSelection) {
if (initialSelection === 'first') {
setActiveItem(0);
} else if (initialSelection === 'last') {
setActiveItem(menuItems.length - 1);
}
}
};
2025-06-12 18:44:17 +08:00
const setActiveItem = (index) => {
if (activeIndex > -1 && menuItems[activeIndex]) {
menuItems[activeIndex].classList.remove('active');
2025-05-01 20:53:03 +08:00
}
2025-06-12 18:44:17 +08:00
activeIndex = index;
if (activeIndex > -1 && menuItems[activeIndex]) {
const activeItem = menuItems[activeIndex];
activeItem.classList.add('active');
if (activeItem.id) {
trigger.setAttribute('aria-activedescendant', activeItem.id);
}
2025-05-01 20:53:03 +08:00
} else {
2025-06-12 18:44:17 +08:00
trigger.removeAttribute('aria-activedescendant');
}
};
trigger.addEventListener('click', () => {
const isExpanded = trigger.getAttribute('aria-expanded') === 'true';
if (isExpanded) {
2025-06-18 20:55:32 +08:00
closePopover();
} else {
openPopover(false);
}
});
2025-06-18 19:42:26 +08:00
dropdownMenuComponent.addEventListener('keydown', (event) => {
const isExpanded = trigger.getAttribute('aria-expanded') === 'true';
2025-06-18 19:42:26 +08:00
if (event.key === 'Escape') {
2025-06-18 20:55:32 +08:00
if (isExpanded) closePopover();
return;
}
if (!isExpanded) {
if (['Enter', ' '].includes(event.key)) {
2025-06-18 19:42:26 +08:00
event.preventDefault();
openPopover(false);
} else if (event.key === 'ArrowDown') {
event.preventDefault();
openPopover('first');
} else if (event.key === 'ArrowUp') {
event.preventDefault();
openPopover('last');
2025-06-12 18:44:17 +08:00
}
return;
2025-05-01 20:53:03 +08:00
}
2025-06-12 18:44:17 +08:00
if (menuItems.length === 0) return;
let nextIndex = activeIndex;
2025-06-18 19:42:26 +08:00
switch (event.key) {
2025-06-12 18:44:17 +08:00
case 'ArrowDown':
2025-06-18 19:42:26 +08:00
event.preventDefault();
nextIndex = activeIndex === -1 ? 0 : Math.min(activeIndex + 1, menuItems.length - 1);
2025-06-18 20:55:32 +08:00
break;
2025-06-12 18:44:17 +08:00
case 'ArrowUp':
2025-06-18 19:42:26 +08:00
event.preventDefault();
nextIndex = activeIndex === -1 ? menuItems.length - 1 : Math.max(activeIndex - 1, 0);
2025-06-12 18:44:17 +08:00
break;
case 'Home':
2025-06-18 19:42:26 +08:00
event.preventDefault();
2025-06-12 18:44:17 +08:00
nextIndex = 0;
break;
case 'End':
2025-06-18 19:42:26 +08:00
event.preventDefault();
2025-06-12 18:44:17 +08:00
nextIndex = menuItems.length - 1;
break;
case 'Enter':
case ' ':
2025-06-18 19:42:26 +08:00
event.preventDefault();
2025-06-12 18:44:17 +08:00
menuItems[activeIndex]?.click();
2025-06-18 20:55:32 +08:00
closePopover();
return;
2025-05-01 20:53:03 +08:00
}
2025-06-12 18:44:17 +08:00
if (nextIndex !== activeIndex) {
setActiveItem(nextIndex);
}
});
2025-05-01 20:53:03 +08:00
menu.addEventListener('mousemove', (event) => {
const menuItem = event.target.closest('[role^="menuitem"]');
if (menuItem && !isDisabled(menuItem) && menuItems.includes(menuItem)) {
const index = menuItems.indexOf(menuItem);
if (index !== activeIndex) {
setActiveItem(index);
}
}
});
menu.addEventListener('mouseleave', () => {
setActiveItem(-1);
});
2025-06-18 19:42:26 +08:00
menu.addEventListener('click', (event) => {
const menuItem = event.target.closest('[role^="menuitem"]');
if (menuItem && !isDisabled(menuItem)) {
2025-06-18 20:55:32 +08:00
closePopover();
2025-06-12 18:44:17 +08:00
}
});
2025-06-18 19:42:26 +08:00
document.addEventListener('click', (event) => {
if (!dropdownMenuComponent.contains(event.target)) {
2025-06-18 20:55:32 +08:00
closePopover();
}
});
document.addEventListener('basecoat:popover', (event) => {
if (event.detail.source !== dropdownMenuComponent) {
closePopover(false);
2025-06-12 18:44:17 +08:00
}
});
dropdownMenuComponent.dataset.dropdownMenuInitialized = true;
2025-06-19 15:21:12 +08:00
dropdownMenuComponent.dispatchEvent(new CustomEvent('basecoat:initialized'));
2025-06-12 18:44:17 +08:00
};
if (window.basecoat) {
window.basecoat.register('dropdown-menu', '.dropdown-menu:not([data-dropdown-menu-initialized])', initDropdownMenu);
}
})();