Files
SkinbaseNova/resources/js/nova.js

352 lines
11 KiB
JavaScript

// Nova toolbar interactions
// - dropdown menus via [data-dropdown]
// - mobile menu toggle via [data-mobile-toggle] + #mobileMenu
// Gallery navigation context: stores artwork list for prev/next on artwork page
import './lib/nav-context.js';
(function () {
function initBlurPreviewImages() {
var selector = 'img[data-blur-preview]';
function markLoaded(img) {
if (!img) return;
img.classList.remove('blur-sm', 'scale-[1.02]');
img.classList.add('is-loaded');
}
document.querySelectorAll(selector).forEach(function (img) {
if (img.complete && img.naturalWidth > 0) {
markLoaded(img);
return;
}
img.addEventListener('load', function () { markLoaded(img); }, { once: true });
img.addEventListener('error', function () { markLoaded(img); }, { once: true });
});
document.addEventListener('load', function (event) {
var target = event.target;
if (target && target.matches && target.matches(selector)) {
markLoaded(target);
}
}, true);
}
initBlurPreviewImages();
function closest(el, selector) {
while (el && el.nodeType === 1) {
if (el.matches(selector)) return el;
el = el.parentElement;
}
return null;
}
function canHover() {
return window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches;
}
function setExpanded(toggleEl, expanded) {
if (!toggleEl) return;
toggleEl.setAttribute('aria-expanded', expanded ? 'true' : 'false');
}
function closeAllDropdowns(except) {
var dropdowns = document.querySelectorAll('[data-dropdown]');
dropdowns.forEach(function (dropdown) {
if (except && dropdown === except) return;
var menu = dropdown.querySelector('[data-dropdown-menu]');
var toggle = dropdown.querySelector('[data-dropdown-toggle]');
if (menu) menu.classList.add('hidden');
setExpanded(toggle, false);
// Close any submenus
dropdown.querySelectorAll('[data-submenu-menu]').forEach(function (sm) {
sm.classList.add('hidden');
});
dropdown.querySelectorAll('[data-submenu-toggle]').forEach(function (st) {
setExpanded(st, false);
});
});
}
function openDropdown(dropdown) {
var menu = dropdown.querySelector('[data-dropdown-menu]');
var toggle = dropdown.querySelector('[data-dropdown-toggle]');
if (!menu || !toggle) return;
closeAllDropdowns(dropdown);
menu.classList.remove('hidden');
setExpanded(toggle, true);
}
function closeDropdown(dropdown) {
var menu = dropdown.querySelector('[data-dropdown-menu]');
var toggle = dropdown.querySelector('[data-dropdown-toggle]');
if (menu) menu.classList.add('hidden');
setExpanded(toggle, false);
}
function toggleDropdown(dropdown) {
var menu = dropdown.querySelector('[data-dropdown-menu]');
var toggle = dropdown.querySelector('[data-dropdown-toggle]');
if (!menu || !toggle) return;
var isOpen = !menu.classList.contains('hidden');
closeAllDropdowns(isOpen ? null : dropdown);
if (isOpen) {
menu.classList.add('hidden');
setExpanded(toggle, false);
} else {
menu.classList.remove('hidden');
setExpanded(toggle, true);
}
}
function getMobileMenu() {
return document.getElementById('mobileMenu');
}
function closeMobileMenu() {
var menu = getMobileMenu();
if (!menu) return;
menu.classList.add('hidden');
var toggle = document.querySelector('[data-mobile-toggle]');
setExpanded(toggle, false);
}
function toggleMobileMenu() {
var menu = getMobileMenu();
if (!menu) return;
var isOpen = !menu.classList.contains('hidden');
if (isOpen) {
closeMobileMenu();
} else {
menu.classList.remove('hidden');
var toggle = document.querySelector('[data-mobile-toggle]');
setExpanded(toggle, true);
closeAllDropdowns();
}
}
document.addEventListener('click', function (e) {
var dropdownToggle = closest(e.target, '[data-dropdown-toggle]');
// legacy shorthand toggles: data-dd="name" -> menu id = dd-name
var legacyToggle = closest(e.target, '[data-dd]');
if (dropdownToggle) {
// On pointer/hover-capable devices prefer hover; ignore mouse clicks
if (canHover() && e.detail > 0) {
// allow keyboard activation (e.detail === 0) to fall through
return;
}
e.preventDefault();
var dropdown = closest(dropdownToggle, '[data-dropdown]');
if (dropdown) toggleDropdown(dropdown);
return;
}
if (legacyToggle) {
// On pointer/hover-capable devices prefer hover; ignore mouse clicks
if (canHover() && e.detail > 0) {
return;
}
e.preventDefault();
var ddName = legacyToggle.getAttribute('data-dd');
if (!ddName) return;
var menu = document.getElementById('dd-' + ddName);
if (!menu) return;
// treat this pair (toggle + menu) similarly to our dropdown API
var isOpen = !menu.classList.contains('hidden');
// close other dropdowns
closeAllDropdowns();
if (isOpen) {
menu.classList.add('hidden');
setExpanded(legacyToggle, false);
} else {
menu.classList.remove('hidden');
setExpanded(legacyToggle, true);
}
return;
}
var mobileToggle = closest(e.target, '[data-mobile-toggle]');
if (mobileToggle) {
e.preventDefault();
toggleMobileMenu();
return;
}
// Submenu toggle (touch/click fallback)
var submenuToggle = closest(e.target, '[data-submenu-toggle]');
if (submenuToggle) {
if (canHover()) {
// On desktop, submenu opens on hover via CSS.
e.preventDefault();
return;
}
e.preventDefault();
var submenu = closest(submenuToggle, '[data-submenu]');
if (!submenu) return;
var menu = submenu.querySelector('[data-submenu-menu]');
if (!menu) return;
// Close other submenus within the same dropdown
var dropdown = closest(submenuToggle, '[data-dropdown]');
if (dropdown) {
dropdown.querySelectorAll('[data-submenu-menu]').forEach(function (sm) {
if (sm !== menu) sm.classList.add('hidden');
});
dropdown.querySelectorAll('[data-submenu-toggle]').forEach(function (st) {
if (st !== submenuToggle) setExpanded(st, false);
});
}
var isOpen = !menu.classList.contains('hidden');
if (isOpen) {
menu.classList.add('hidden');
setExpanded(submenuToggle, false);
} else {
menu.classList.remove('hidden');
setExpanded(submenuToggle, true);
}
return;
}
if (!closest(e.target, '[data-dropdown]')) {
closeAllDropdowns();
}
});
// Hover-to-open for desktop pointers
var hoverCloseTimers = new WeakMap();
function clearHoverTimer(dropdown) {
var t = hoverCloseTimers.get(dropdown);
if (t) window.clearTimeout(t);
hoverCloseTimers.delete(dropdown);
}
function scheduleClose(dropdown) {
clearHoverTimer(dropdown);
hoverCloseTimers.set(
dropdown,
window.setTimeout(function () {
closeMenuElement(dropdown);
}, 140)
);
}
// Close a menu element or its parent dropdown wrapper.
function closeMenuElement(el) {
if (!el) return;
// If this is a dropdown wrapper, find the menu inside
if (el.hasAttribute && el.hasAttribute('data-dropdown')) {
var menu = el.querySelector('[data-dropdown-menu]');
var toggle = el.querySelector('[data-dropdown-toggle]');
if (menu) menu.classList.add('hidden');
setExpanded(toggle, false);
// also close submenus inside
el.querySelectorAll('[data-submenu-menu]').forEach(function (sm) {
sm.classList.add('hidden');
});
el.querySelectorAll('[data-submenu-toggle]').forEach(function (st) {
setExpanded(st, false);
});
return;
}
// If it's a menu element (e.g., legacy id=dd-name) hide it and try to find its toggle
var menuEl = el;
if (!menuEl.id && el.getAttribute && el.getAttribute('data-dropdown-menu')) {
// explicit menu element
}
// hide the element if possible
try { menuEl.classList.add('hidden'); } catch (e) {}
// Try to map back to a toggle: id like dd-name -> data-dd="name"
if (menuEl.id && menuEl.id.indexOf('dd-') === 0) {
var name = menuEl.id.slice(3);
var toggle = document.querySelector('[data-dd="' + name + '"]');
if (toggle) setExpanded(toggle, false);
} else {
// fallback: if menu is inside a [data-dropdown], handled above; nothing more to do
}
}
function bindHoverHandlers() {
if (!canHover()) return;
document.querySelectorAll('[data-dropdown]').forEach(function (dropdown) {
dropdown.addEventListener('mouseenter', function () {
clearHoverTimer(dropdown);
openDropdown(dropdown);
});
dropdown.addEventListener('mouseleave', function () {
scheduleClose(dropdown);
});
});
// legacy hover binding for shorthand toggles (data-dd)
document.querySelectorAll('[data-dd]').forEach(function (el) {
var ddName = el.getAttribute('data-dd');
if (!ddName) return;
var menu = document.getElementById('dd-' + ddName);
if (!menu) return;
// when pointer enters either toggle or menu, open
function enter() {
clearHoverTimer(menu);
menu.classList.remove('hidden');
setExpanded(el, true);
}
function leave() {
scheduleClose(menu);
}
el.addEventListener('mouseenter', enter);
el.addEventListener('mouseleave', leave);
menu.addEventListener('mouseenter', enter);
menu.addEventListener('mouseleave', leave);
});
}
bindHoverHandlers();
// Submenu hover handlers: ensure flyouts open on pointer devices
if (canHover()) {
document.querySelectorAll('[data-submenu]').forEach(function (group) {
var toggle = group.querySelector('[data-submenu-toggle]');
var menu = group.querySelector('[data-submenu-menu]');
if (!menu) return;
group.addEventListener('mouseenter', function () {
menu.classList.remove('hidden');
if (toggle) setExpanded(toggle, true);
});
group.addEventListener('mouseleave', function () {
menu.classList.add('hidden');
if (toggle) setExpanded(toggle, false);
});
});
}
document.addEventListener('keydown', function (e) {
if (e.key !== 'Escape') return;
closeAllDropdowns();
closeMobileMenu();
});
window.addEventListener('resize', function () {
if (window.matchMedia('(min-width: 768px)').matches) {
closeMobileMenu();
}
});
})();