// Nova toolbar interactions // - dropdown menus via [data-dropdown] // - mobile menu toggle via [data-mobile-toggle] + #mobileMenu // Alpine.js — powers x-data/x-show/@click in Blade layouts (e.g. cookie banner, toasts). // Guard: don't start a second instance if app.js already loaded Alpine on this page. import Alpine from 'alpinejs'; if (!window.Alpine) { window.Alpine = Alpine; Alpine.start(); } // Gallery navigation context: stores artwork list for prev/next on artwork page import './lib/nav-context.js'; import { sendTagInteractionEvent } from './lib/tagAnalytics'; function safeParseJson(value, fallback) { try { return JSON.parse(value || 'null') ?? fallback; } catch (_error) { return fallback; } } var reactRuntimePromise = null; function getReactRuntime() { if (!reactRuntimePromise) { reactRuntimePromise = Promise.all([ import('react'), import('react-dom/client'), ]).then(function (modules) { return { React: modules[0].default, createRoot: modules[1].createRoot, }; }); } return reactRuntimePromise; } function mountStoryEditor() { var storyEditorRoot = document.getElementById('story-editor-react-root'); if (!storyEditorRoot) return; if (storyEditorRoot.dataset.reactMounted === 'true') return; var mode = storyEditorRoot.getAttribute('data-mode') || 'create'; var storyRaw = storyEditorRoot.getAttribute('data-story') || '{}'; var storyTypesRaw = storyEditorRoot.getAttribute('data-story-types') || '[]'; var endpointsRaw = storyEditorRoot.getAttribute('data-endpoints') || '{}'; var csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''; var initialStory = {}; var storyTypes = []; var endpoints = {}; try { initialStory = JSON.parse(storyRaw); storyTypes = JSON.parse(storyTypesRaw); endpoints = JSON.parse(endpointsRaw); } catch (_error) { // If parsing fails, the editor falls back to component defaults. } storyEditorRoot.dataset.reactMounted = 'true'; void Promise.all([ import('./components/editor/StoryEditor'), getReactRuntime(), ]) .then(function (resolved) { var module = resolved[0]; var reactRuntime = resolved[1]; var StoryEditor = module.default; reactRuntime.createRoot(storyEditorRoot).render( reactRuntime.React.createElement(StoryEditor, { mode: mode, initialStory: initialStory, storyTypes: storyTypes, endpoints: endpoints, csrfToken: csrfToken, }) ); }) .catch(function () { storyEditorRoot.dataset.reactMounted = 'false'; storyEditorRoot.innerHTML = '
Failed to load editor. Please refresh the page.
'; }); } mountStoryEditor(); function mountToolbarNotifications() { var rootEl = document.getElementById('toolbar-notification-root'); if (!rootEl || rootEl.dataset.reactMounted === 'true') return; var props = safeParseJson(rootEl.getAttribute('data-props'), {}); rootEl.dataset.reactMounted = 'true'; void Promise.all([ import('./components/social/NotificationDropdown.jsx'), getReactRuntime(), ]) .then(function (resolved) { var module = resolved[0]; var reactRuntime = resolved[1]; var Component = module.default; reactRuntime.createRoot(rootEl).render(reactRuntime.React.createElement(Component, props)); }) .catch(function () { rootEl.dataset.reactMounted = 'false'; }); } function mountToolbarMessages() { var rootEl = document.getElementById('toolbar-messages-root'); if (!rootEl || rootEl.dataset.reactMounted === 'true') return; var props = safeParseJson(rootEl.getAttribute('data-props'), {}); rootEl.dataset.reactMounted = 'true'; void Promise.all([ import('./components/social/MessageInboxBadge.jsx'), getReactRuntime(), ]) .then(function (resolved) { var module = resolved[0]; var reactRuntime = resolved[1]; var Component = module.default; reactRuntime.createRoot(rootEl).render(reactRuntime.React.createElement(Component, props)); }) .catch(function () { rootEl.dataset.reactMounted = 'false'; }); } function mountStorySocial() { var socialRoot = document.getElementById('story-social-root'); if (socialRoot && socialRoot.dataset.reactMounted !== 'true') { var props = safeParseJson(socialRoot.getAttribute('data-props'), {}); socialRoot.dataset.reactMounted = 'true'; void Promise.all([ import('./components/social/StorySocialPanel.jsx'), getReactRuntime(), ]) .then(function (resolved) { var module = resolved[0]; var reactRuntime = resolved[1]; var Component = module.default; reactRuntime.createRoot(socialRoot).render(reactRuntime.React.createElement(Component, { story: props.story, creator: props.creator, initialState: props.state, initialComments: props.comments, isAuthenticated: Boolean(props.is_authenticated), })); }) .catch(function () { socialRoot.dataset.reactMounted = 'false'; }); } var followRoot = document.getElementById('story-creator-follow-root'); if (!followRoot || followRoot.dataset.reactMounted === 'true') return; var followProps = safeParseJson(followRoot.getAttribute('data-props'), {}); followRoot.dataset.reactMounted = 'true'; void Promise.all([ import('./components/social/FollowButton.jsx'), getReactRuntime(), ]) .then(function (resolved) { var module = resolved[0]; var reactRuntime = resolved[1]; var Component = module.default; reactRuntime.createRoot(followRoot).render(reactRuntime.React.createElement(Component, { username: followProps.username, initialFollowing: Boolean(followProps.following), initialCount: Number(followProps.followers_count || 0), className: 'w-full justify-center', })); }) .catch(function () { followRoot.dataset.reactMounted = 'false'; }); } mountToolbarMessages(); mountToolbarNotifications(); mountStorySocial(); function initStorySyntaxHighlighting() { var codeBlocks = Array.prototype.slice.call(document.querySelectorAll('.story-prose pre code')); if (!codeBlocks.length) return; function fallbackCopyText(text) { var textarea = document.createElement('textarea'); textarea.value = text; textarea.setAttribute('readonly', 'true'); textarea.style.position = 'fixed'; textarea.style.top = '-1000px'; textarea.style.left = '-1000px'; document.body.appendChild(textarea); textarea.select(); try { return document.execCommand('copy'); } catch (_error) { return false; } finally { document.body.removeChild(textarea); } } function copyText(text) { if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { return navigator.clipboard.writeText(text); } return fallbackCopyText(text) ? Promise.resolve() : Promise.reject(new Error('Clipboard unavailable')); } function attachCopyButton(block) { var pre = block.parentElement; if (!pre || pre.dataset.copyButtonMounted === 'true') return; var button = document.createElement('button'); var icon = document.createElement('span'); var label = document.createElement('span'); button.type = 'button'; button.className = 'story-code-copy-button'; icon.className = 'story-code-copy-icon'; icon.setAttribute('aria-hidden', 'true'); icon.textContent = '⧉'; label.className = 'story-code-copy-label'; label.textContent = 'Copy'; button.appendChild(icon); button.appendChild(label); button.dataset.copied = 'idle'; button.setAttribute('aria-label', 'Copy code block'); var resetTimer = 0; button.addEventListener('click', function () { var source = block.innerText || block.textContent || ''; copyText(source) .then(function () { icon.textContent = '✓'; label.textContent = 'Copied'; button.dataset.copied = 'true'; }) .catch(function () { icon.textContent = '!'; label.textContent = 'Failed'; button.dataset.copied = 'false'; }) .finally(function () { window.clearTimeout(resetTimer); resetTimer = window.setTimeout(function () { icon.textContent = '⧉'; label.textContent = 'Copy'; button.dataset.copied = 'idle'; }, 1800); }); }); pre.appendChild(button); pre.dataset.copyButtonMounted = 'true'; } void import('highlight.js/lib/common') .then(function (module) { var hljs = module.default; codeBlocks.forEach(function (block) { attachCopyButton(block); if (block.dataset.syntaxHighlighted === 'true') return; var language = (block.getAttribute('data-language') || '').trim(); if (language && !block.className.includes('language-')) { block.classList.add('language-' + language); } hljs.highlightElement(block); block.dataset.syntaxHighlighted = 'true'; }); }) .catch(function () { // Leave code blocks readable even if highlighting fails to load. }); } initStorySyntaxHighlighting(); function initTagsSearchAssist() { var roots = document.querySelectorAll('[data-tags-search-root]'); if (!roots.length) return; var recentSearchesKey = 'skinbase.tags.recent-searches'; function findClosest(el, selector) { while (el && el.nodeType === 1) { if (el.matches(selector)) return el; el = el.parentElement; } return null; } function escapeHtml(value) { return String(value || '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } roots.forEach(function (root, rootIndex) { var form = findClosest(root, '[data-tags-search-form]'); var input = root.querySelector('[data-tags-search-input]'); var panel = root.querySelector('[data-tags-search-panel]'); var title = root.querySelector('[data-tags-search-title]'); var results = root.querySelector('[data-tags-search-results]'); var endpoint = root.getAttribute('data-search-endpoint') || '/api/tags/search'; var popularEndpoint = root.getAttribute('data-popular-endpoint') || '/api/tags/popular'; var optionIdPrefix = 'tags-search-option-' + rootIndex + '-'; var debounceTimer = null; var abortController = null; var latestQuery = ''; var activeIndex = -1; if (!input || !panel || !results) return; function readRecentSearches() { try { var parsed = JSON.parse(window.localStorage.getItem(recentSearchesKey) || '[]'); return Array.isArray(parsed) ? parsed.filter(Boolean).slice(0, 5) : []; } catch (_error) { return []; } } function writeRecentSearch(query) { var normalized = (query || '').trim(); if (!normalized) return; var next = readRecentSearches().filter(function (item) { return item.toLowerCase() !== normalized.toLowerCase(); }); next.unshift(normalized); try { window.localStorage.setItem(recentSearchesKey, JSON.stringify(next.slice(0, 5))); } catch (_error) { // Ignore storage failures silently. } } function clearRecentSearches() { try { window.localStorage.removeItem(recentSearchesKey); } catch (_error) { // Ignore storage failures silently. } } function removeRecentSearch(query) { var normalized = (query || '').trim().toLowerCase(); if (!normalized) return; var next = readRecentSearches().filter(function (item) { return item.toLowerCase() !== normalized; }); try { window.localStorage.setItem(recentSearchesKey, JSON.stringify(next)); } catch (_error) { // Ignore storage failures silently. } } function getItems() { return Array.prototype.slice.call(results.querySelectorAll('[data-tags-search-item]')); } function setActiveItem(nextIndex) { var items = getItems(); activeIndex = nextIndex; items.forEach(function (item, index) { var active = index === nextIndex; item.classList.toggle('bg-white/[0.06]', active); item.classList.toggle('text-white', active); item.setAttribute('aria-selected', active ? 'true' : 'false'); if (!item.id) { item.id = optionIdPrefix + index; } if (active) { item.scrollIntoView({ block: 'nearest' }); } }); if (nextIndex >= 0 && items[nextIndex]) { input.setAttribute('aria-activedescendant', items[nextIndex].id); } else { input.removeAttribute('aria-activedescendant'); } } function focusItem(nextIndex) { var items = getItems(); if (!items.length) return; var boundedIndex = Math.max(0, Math.min(nextIndex, items.length - 1)); setActiveItem(boundedIndex); items[boundedIndex].focus(); } function setExpanded(expanded) { input.setAttribute('aria-expanded', expanded ? 'true' : 'false'); panel.classList.toggle('hidden', !expanded); if (!expanded) { activeIndex = -1; setActiveItem(-1); } } function clearResults() { results.innerHTML = ''; activeIndex = -1; } function hidePanel() { setExpanded(false); } function renderLoadingState(query) { title.textContent = query ? 'Searching tags' : 'Loading suggestions'; results.setAttribute('aria-busy', 'true'); results.innerHTML = '
' + '
' + '
' + '' + '
' + '
' + '
' + '' + '
' + '
' + '
' + '' + '
' + '
'; setExpanded(true); setActiveItem(-1); } function fetchJson(url, signal) { return fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' }, signal: signal, }).then(function (response) { if (!response.ok) throw new Error('Failed to load tag suggestions'); return response.json(); }); } function renderRecentSearches(items) { if (!items.length) return ''; return '
' + '
' + '
Recent searches
' + '' + '
' + '
' + items.map(function (item) { var encoded = encodeURIComponent(item); var escaped = escapeHtml(item); return '' + '' + escaped + '' + '' + ''; }).join('') + '
' + '
'; } function renderRescueSuggestions(items, query) { if (!items.length) return ''; return '
' + '
Try these instead
' + '
' + items.map(function (item, index) { var name = escapeHtml(item.name || ''); var slug = escapeHtml(item.slug || ''); return '' + '' + name + '#' + slug + '' + 'Popular' + ''; }).join('') + '
' + '
Browse full tags index
' + '
'; } function renderNoMatchState(query, rescueItems) { title.textContent = 'No matching tags'; results.setAttribute('aria-busy', 'false'); results.innerHTML = '
No direct matches for ' + escapeHtml(query) + '. Try a broader keyword or jump into one of these popular tags.
' + renderRescueSuggestions(rescueItems || [], query); setExpanded(true); setActiveItem(-1); } function renderItems(items, query) { clearResults(); results.setAttribute('aria-busy', 'false'); var recentSearches = !query ? readRecentSearches() : []; if (!items.length && recentSearches.length) { title.textContent = 'Recent searches'; results.innerHTML = renderRecentSearches(recentSearches); setExpanded(true); setActiveItem(-1); return; } if (!items.length) { return; } title.textContent = query ? 'Matching tags' : (recentSearches.length ? 'Recent and popular' : 'Popular tags'); if (recentSearches.length) { results.insertAdjacentHTML('beforeend', renderRecentSearches(recentSearches)); } items.forEach(function (item, index) { var link = document.createElement('a'); var itemName = escapeHtml(item.name || ''); var itemSlug = escapeHtml(item.slug || ''); var recentClicks = Number(item.recent_clicks || 0); var metricLabel = recentClicks > 0 ? recentClicks.toLocaleString() + ' recent' : Number(item.usage_count || 0).toLocaleString() + ' uses'; link.href = '/tag/' + item.slug; link.className = 'flex items-center justify-between gap-3 rounded-xl px-3 py-3 text-sm text-white/72 transition hover:bg-white/[0.06] hover:text-white'; link.setAttribute('data-tags-search-item', ''); link.setAttribute('data-tags-search-surface', 'search_suggestion'); link.setAttribute('data-tags-search-tag', item.slug || ''); link.setAttribute('data-tags-search-query', item.name || item.slug || ''); link.setAttribute('data-tags-search-input', query || ''); link.setAttribute('data-tags-search-position', String(index + 1)); link.setAttribute('aria-selected', 'false'); link.setAttribute('role', 'option'); link.tabIndex = -1; link.innerHTML = '' + '#' + '' + itemName + '#' + itemSlug + '' + '' + '' + metricLabel + ''; results.appendChild(link); }); setExpanded(true); setActiveItem(-1); } function fetchSuggestions(query) { if (abortController) abortController.abort(); abortController = new AbortController(); latestQuery = query; renderLoadingState(query); var params = new URLSearchParams(); if (query) params.set('q', query); fetchJson(endpoint + (params.toString() ? ('?' + params.toString()) : ''), abortController.signal) .then(function (payload) { if (input.value.trim() !== latestQuery) return; var items = Array.isArray(payload.data) ? payload.data.slice(0, 8) : []; if (items.length || query === '') { renderItems(items, query); return; } var popularParams = new URLSearchParams(); popularParams.set('limit', '4'); return fetchJson(popularEndpoint + '?' + popularParams.toString(), abortController.signal) .then(function (popularPayload) { if (input.value.trim() !== latestQuery) return; renderNoMatchState(query, Array.isArray(popularPayload.data) ? popularPayload.data : []); }); }) .catch(function (error) { if (error && error.name === 'AbortError') return; hidePanel(); }); } input.addEventListener('focus', function () { fetchSuggestions(input.value.trim()); }); input.addEventListener('input', function () { var query = input.value.trim(); window.clearTimeout(debounceTimer); debounceTimer = window.setTimeout(function () { fetchSuggestions(query); }, 180); }); input.addEventListener('keydown', function (event) { var items = getItems(); if ((event.key === 'ArrowDown' || event.key === 'ArrowUp') && items.length) { event.preventDefault(); if (event.key === 'ArrowDown') { setActiveItem(activeIndex < 0 ? 0 : Math.min(activeIndex + 1, items.length - 1)); } else { setActiveItem(activeIndex < 0 ? items.length - 1 : Math.max(activeIndex - 1, 0)); } return; } if (event.key === 'Enter' && activeIndex >= 0 && items[activeIndex]) { event.preventDefault(); writeRecentSearch(items[activeIndex].getAttribute('data-tags-search-query') || input.value); window.location.href = items[activeIndex].href; return; } if (event.key === 'Escape') { hidePanel(); } }); results.addEventListener('keydown', function (event) { var items = getItems(); var currentIndex = items.indexOf(document.activeElement); if (event.key === 'ArrowDown' && items.length) { event.preventDefault(); focusItem(currentIndex + 1); return; } if (event.key === 'ArrowUp' && items.length) { event.preventDefault(); if (currentIndex <= 0) { setActiveItem(-1); input.focus(); } else { focusItem(currentIndex - 1); } return; } if (event.key === 'Escape') { event.preventDefault(); hidePanel(); input.focus(); } }); results.addEventListener('mouseover', function (event) { var item = findClosest(event.target, '[data-tags-search-item]'); if (!item) return; var items = getItems(); setActiveItem(items.indexOf(item)); }); results.addEventListener('click', function (event) { var clearButton = findClosest(event.target, '[data-tags-search-clear-recent]'); if (clearButton) { event.preventDefault(); clearRecentSearches(); fetchSuggestions(input.value.trim()); return; } var removeButton = findClosest(event.target, '[data-tags-search-remove-recent]'); if (removeButton) { event.preventDefault(); removeRecentSearch(removeButton.getAttribute('data-tags-search-query') || ''); fetchSuggestions(input.value.trim()); return; } var item = findClosest(event.target, '[data-tags-search-item]'); if (!item) return; var query = item.getAttribute('data-tags-search-query') || item.textContent || ''; writeRecentSearch(query); var surface = item.getAttribute('data-tags-search-surface') || ''; var tagSlug = item.getAttribute('data-tags-search-tag') || ''; if (surface && (tagSlug || surface === 'recent_search')) { sendTagInteractionEvent({ event_type: 'click', surface: surface, tag_slug: tagSlug || null, query: item.getAttribute('data-tags-search-input') || item.getAttribute('data-tags-search-query') || input.value.trim() || null, position: Number(item.getAttribute('data-tags-search-position') || 0) || null, occurred_at: new Date().toISOString(), }); } }); if (form) { form.addEventListener('submit', function () { writeRecentSearch(input.value); }); } document.addEventListener('click', function (event) { if (!root.contains(event.target)) { hidePanel(); } }); }); } initTagsSearchAssist(); function initTagAnalyticsLinks() { document.addEventListener('click', function (event) { var el = event.target; while (el && el.nodeType === 1) { if (el.matches('[data-tag-analytics-link]')) { var tagSlug = el.getAttribute('data-tag-analytics-tag') || ''; var surface = el.getAttribute('data-tag-analytics-surface') || ''; if (tagSlug && surface) { sendTagInteractionEvent({ event_type: 'click', surface: surface, tag_slug: tagSlug, source_tag_slug: el.getAttribute('data-tag-analytics-source-tag') || null, position: Number(el.getAttribute('data-tag-analytics-position') || 0) || null, occurred_at: new Date().toISOString(), }); } return; } el = el.parentElement; } }); } initTagAnalyticsLinks(); (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.remove('is-open'); 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.add('is-open'); setExpanded(toggle, true); } function closeDropdown(dropdown) { var menu = dropdown.querySelector('[data-dropdown-menu]'); var toggle = dropdown.querySelector('[data-dropdown-toggle]'); if (menu) menu.classList.remove('is-open'); 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('is-open'); closeAllDropdowns(isOpen ? null : dropdown); if (isOpen) { menu.classList.remove('is-open'); setExpanded(toggle, false); } else { menu.classList.add('is-open'); setExpanded(toggle, true); } } function getMobileMenu() { return document.getElementById('mobileMenu'); } function setMobileToggleVisual(isOpen) { var toggle = document.querySelector('[data-mobile-toggle]') || document.getElementById('btnSidebar'); if (!toggle) return; setExpanded(toggle, !!isOpen); var hamburgerIcon = toggle.querySelector('[data-mobile-icon-hamburger]'); var closeIcon = toggle.querySelector('[data-mobile-icon-close]'); if (hamburgerIcon) hamburgerIcon.classList.toggle('hidden', !!isOpen); if (closeIcon) closeIcon.classList.toggle('hidden', !isOpen); } function closeMobileMenu() { var menu = getMobileMenu(); if (!menu) return; menu.classList.add('hidden'); setMobileToggleVisual(false); } function toggleMobileMenu() { var menu = getMobileMenu(); if (!menu) return; var isOpen = !menu.classList.contains('hidden'); if (isOpen) { closeMobileMenu(); } else { menu.classList.remove('hidden'); setMobileToggleVisual(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('is-open'); // close other dropdowns closeAllDropdowns(); // also close other legacy (data-dd) menus document.querySelectorAll('[data-dd]').forEach(function (other) { if (other === legacyToggle) return; var otherId = other.getAttribute('data-dd'); var otherMenu = otherId ? document.getElementById('dd-' + otherId) : null; if (otherMenu) otherMenu.classList.remove('is-open'); setExpanded(other, false); }); if (isOpen) { menu.classList.remove('is-open'); setExpanded(legacyToggle, false); } else { menu.classList.add('is-open'); setExpanded(legacyToggle, true); } return; } var mobileToggle = closest(e.target, '[data-mobile-toggle]'); if (mobileToggle) { e.preventDefault(); toggleMobileMenu(); return; } var mobileSectionToggle = closest(e.target, '[data-mobile-section-toggle]'); if (mobileSectionToggle) { e.preventDefault(); var panelId = mobileSectionToggle.getAttribute('aria-controls'); var panel = panelId ? document.getElementById(panelId) : null; if (!panel) return; var wasOpen = !panel.classList.contains('hidden'); var menuRoot = getMobileMenu(); // Keep mobile navigation tidy: close all sections first. if (menuRoot) { menuRoot.querySelectorAll('[data-mobile-section-panel]').forEach(function (el) { el.classList.add('hidden'); }); menuRoot.querySelectorAll('[data-mobile-section-toggle]').forEach(function (btn) { setExpanded(btn, false); var icon = btn.querySelector('[data-mobile-section-icon]'); if (icon) icon.classList.remove('rotate-180'); }); } // If it was closed, open it. If it was open, it stays closed (toggle behavior). if (!wasOpen) { panel.classList.remove('hidden'); setExpanded(mobileSectionToggle, true); var currentIcon = mobileSectionToggle.querySelector('[data-mobile-section-icon]'); if (currentIcon) currentIcon.classList.add('rotate-180'); } 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(); } // Close mobile menu when tapping outside of it and outside the hamburger toggle. var mobileMenu = getMobileMenu(); var mobileToggle = closest(e.target, '[data-mobile-toggle]') || closest(e.target, '#btnSidebar'); if (mobileMenu && !mobileMenu.classList.contains('hidden') && !mobileToggle && !closest(e.target, '#mobileMenu')) { closeMobileMenu(); } }); // 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.remove('is-open'); 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.remove('is-open'); } 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); // Instantly close any other open legacy dropdown to prevent overlap document.querySelectorAll('[data-dd]').forEach(function (other) { if (other === el) return; var otherId = other.getAttribute('data-dd'); var otherMenu = otherId ? document.getElementById('dd-' + otherId) : null; if (otherMenu) otherMenu.classList.remove('is-open'); setExpanded(other, false); }); menu.classList.add('is-open'); 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(); } }); })();