diff --git a/app/index.html b/app/index.html index d2324f7..f8dff1a 100644 --- a/app/index.html +++ b/app/index.html @@ -371,6 +371,33 @@ body { } .topbar-nav .nav-item.active { border-left: none; background: var(--nav-active-bg); } +/* ─── Topbar grouped navigation (dropdowns) ─── */ +.topbar-nav .nav-group { position: relative; } +.topbar-nav .nav-trigger { + display: flex; align-items: center; gap: 0.25rem; + padding: 0.35rem 0.75rem; border-radius: 5px; + background: none; border: none; cursor: pointer; + color: var(--font-colour); font-size: 0.85rem; + font-family: inherit; white-space: nowrap; text-decoration: none; line-height: inherit; +} +.topbar-nav .nav-trigger:hover, +.topbar-nav .nav-group.open > .nav-trigger { background: var(--nav-hover-bg); } +.topbar-nav .nav-group.has-active > .nav-trigger { background: var(--nav-active-bg); } +.topbar-nav .nav-caret { font-size: 0.6rem; color: var(--font-colour-muted); opacity: 0.55; line-height: 1; } +.topbar-nav .nav-dropdown { + display: none; position: absolute; top: calc(100% + 4px); left: 0; + background: var(--bg-nav); border: 1px solid var(--divider); + border-radius: 6px; box-shadow: 0 4px 16px rgba(0,0,0,0.1); + min-width: 160px; z-index: 200; padding: 0.25rem 0; +} +.topbar-nav .nav-group.open > .nav-dropdown { display: block; } +.topbar-nav .nav-dropdown .nav-item { + display: block; padding: 0.45rem 1rem; + border-left: none; border-radius: 0; white-space: nowrap; +} +.topbar-nav .nav-dropdown .nav-item:hover { background: var(--nav-hover-bg); } +.topbar-nav .nav-dropdown .nav-item.active { background: var(--nav-active-bg); font-weight: 600; } + .topbar-actions { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; } .topbar-search { position: relative; } @@ -732,6 +759,19 @@ body { border-left: none; } .layout-topbar .mobile-nav-panel .nav-item.active { border-left: none; font-weight: 600; } + .layout-topbar .mobile-nav-panel .nav-group-row { display: flex; align-items: stretch; } + .layout-topbar .mobile-nav-panel .nav-group-row .nav-item { flex: 1; } + .layout-topbar .mobile-nav-panel .nav-section-label { + flex: 1; display: flex; align-items: center; + padding: 0.6rem 1.25rem; font-size: 1rem; color: var(--font-colour); font-weight: 500; + } + .layout-topbar .mobile-nav-panel .nav-expand-btn { + background: none; border: none; cursor: pointer; + color: var(--font-colour-muted); padding: 0 1.25rem; font-size: 1.2rem; line-height: 1; flex-shrink: 0; + } + .layout-topbar .mobile-nav-panel .nav-group-children { display: none; } + .layout-topbar .mobile-nav-panel .nav-group-children.open { display: block; } + .layout-topbar .mobile-nav-panel .nav-group-children .nav-item { padding-left: 2.5rem; } } @media (max-width: 480px) { @@ -1800,6 +1840,10 @@ function fmtDatetime(dtStr) { if (panel) panel.classList.remove('open'); hamburger.innerHTML = ICONS.menu; }; + + document.addEventListener('click', () => { + document.querySelectorAll('.topbar-nav .nav-group.open').forEach(g => g.classList.remove('open')); + }); } function buildSearchWidget() { @@ -2100,18 +2144,141 @@ function fmtDatetime(dtStr) { tree.forEach(root => renderTreeSection(container, root, 0, groups)); } - function renderFlat(container) { - // Topbar inline: pages only, sorted by global sort, draft-section pages excluded. + function buildTopbarNavItems() { const byCode = {}; navSections.forEach(s => { if (s.code) byCode[s.code] = s; }); - const visible = navData.filter(p => { - const sid = p['section-id']; - return !sid || !isDraftSection(sid, byCode); + const items = []; + + // Sections (non-draft), each becomes a dropdown trigger + navSections.forEach(s => { + if (!s.code || isDraftSection(s.code, byCode)) return; + const pages = navData.filter(p => p['section-id'] === s.code && pageShouldDisplay(p)); + pages.sort((a, b) => ((a.sort ?? 999) - (b.sort ?? 999)) || a.file.localeCompare(b.file)); + if (!pages.length) return; + items.push({ type: 'section', sort: s.sort ?? 999, section: s, pages }); }); - visible.sort((a, b) => ((a.sort ?? 999) - (b.sort ?? 999)) || a.file.localeCompare(b.file)); - visible.forEach(p => { - const item = makeNavItem(p, 0); - if (item) container.appendChild(item); + + // Unsectioned pages (or pages whose section isn't in nav), grouped by sort century + const unsectioned = navData.filter(p => { + if (!pageShouldDisplay(p)) return false; + const sid = p['section-id']; + return !sid || !byCode[sid]; + }); + unsectioned.sort((a, b) => ((a.sort ?? 999) - (b.sort ?? 999)) || a.file.localeCompare(b.file)); + const centuryMap = new Map(); + unsectioned.forEach(p => { + const c = Math.floor((p.sort ?? 999) / 100); + if (!centuryMap.has(c)) centuryMap.set(c, []); + centuryMap.get(c).push(p); + }); + for (const [, pgs] of centuryMap) { + items.push({ type: 'group', sort: pgs[0].sort ?? 999, primary: pgs[0], children: pgs.slice(1) }); + } + + items.sort((a, b) => a.sort - b.sort); + return items; + } + + function makeTopbarPageGroup({ primary, children }, isMobile) { + const group = el('div', { className: 'nav-group' }); + const hasChildren = children.length > 0; + + if (isMobile) { + const row = el('div', { className: 'nav-group-row' }); + const link = makeNavItem(primary, 0); + if (link) row.appendChild(link); + if (hasChildren) { + const childrenEl = el('div', { className: 'nav-group-children' }); + children.forEach(p => { const it = makeNavItem(p, 1); if (it) childrenEl.appendChild(it); }); + const btn = el('button', { className: 'nav-expand-btn', 'aria-label': 'Expand', textContent: '+' }); + btn.addEventListener('click', () => { + const open = childrenEl.classList.toggle('open'); + btn.textContent = open ? '−' : '+'; + }); + row.appendChild(btn); + group.appendChild(row); + group.appendChild(childrenEl); + } else { + group.appendChild(row); + } + } else { + const title = pageDisplayTitle(primary); + const trigger = el('a', { className: 'nav-trigger', href: '#' + primary.file, 'data-file': primary.file }); + trigger.appendChild(el('span', { textContent: title })); + if (hasChildren) trigger.appendChild(el('span', { className: 'nav-caret', textContent: '▾' })); + group.appendChild(trigger); + + if (hasChildren) { + const dropdown = el('div', { className: 'nav-dropdown' }); + children.forEach(p => { const it = makeNavItem(p, 0); if (it) dropdown.appendChild(it); }); + group.appendChild(dropdown); + group.addEventListener('mouseenter', () => group.classList.add('open')); + group.addEventListener('mouseleave', () => group.classList.remove('open')); + trigger.addEventListener('click', e => { + e.preventDefault(); + e.stopPropagation(); + group.classList.toggle('open'); + navigateTo(primary.file); + if (window._closeMobileMenu) window._closeMobileMenu(); + }); + } else { + trigger.addEventListener('click', e => { + e.preventDefault(); + navigateTo(primary.file); + if (window._closeMobileMenu) window._closeMobileMenu(); + }); + } + } + + return group; + } + + function makeTopbarSection({ section, pages }, isMobile) { + const group = el('div', { className: 'nav-group' }); + const isHidden = section.pagesvisibility === 'hidden'; + const name = sectionDisplayName(section); + + if (isMobile) { + const row = el('div', { className: 'nav-group-row' }); + row.appendChild(el('span', { className: 'nav-section-label', textContent: name })); + const childrenEl = el('div', { className: 'nav-group-children' + (isHidden ? '' : ' open') }); + pages.forEach(p => { const it = makeNavItem(p, 1); if (it) childrenEl.appendChild(it); }); + const btn = el('button', { className: 'nav-expand-btn', 'aria-label': 'Expand', textContent: isHidden ? '+' : '−' }); + btn.addEventListener('click', () => { + const open = childrenEl.classList.toggle('open'); + btn.textContent = open ? '−' : '+'; + }); + row.appendChild(btn); + group.appendChild(row); + group.appendChild(childrenEl); + } else { + const trigger = el('button', { className: 'nav-trigger', type: 'button' }); + trigger.appendChild(el('span', { textContent: name })); + trigger.appendChild(el('span', { className: 'nav-caret', textContent: '▾' })); + trigger.addEventListener('click', e => { e.stopPropagation(); group.classList.toggle('open'); }); + group.appendChild(trigger); + + const dropdown = el('div', { className: 'nav-dropdown' }); + pages.forEach(p => { const it = makeNavItem(p, 0); if (it) dropdown.appendChild(it); }); + group.appendChild(dropdown); + + if (!isHidden) { + group.addEventListener('mouseenter', () => group.classList.add('open')); + group.addEventListener('mouseleave', () => group.classList.remove('open')); + } + } + + return group; + } + + function renderTopbarGrouped(container, isMobile) { + const items = buildTopbarNavItems(); + items.forEach(item => { + container.appendChild( + item.type === 'group' + ? makeTopbarPageGroup(item, isMobile) + : makeTopbarSection(item, isMobile) + ); }); } @@ -2121,19 +2288,23 @@ function fmtDatetime(dtStr) { const mobile = document.getElementById('mobileNavLinks'); if (main) { main.innerHTML = ''; - if (topbar) renderFlat(main); + if (topbar) renderTopbarGrouped(main, false); else renderTree(main); } if (mobile) { mobile.innerHTML = ''; - renderTree(mobile); + if (topbar) renderTopbarGrouped(mobile, true); + else renderTree(mobile); } } function highlightNav(file) { - document.querySelectorAll('.nav-item').forEach(item => { + document.querySelectorAll('.nav-item, .nav-trigger[data-file]').forEach(item => { item.classList.toggle('active', item.getAttribute('data-file') === file); }); + document.querySelectorAll('.topbar-nav .nav-group').forEach(group => { + group.classList.toggle('has-active', !!group.querySelector('[data-file].active')); + }); } // ─── Page loading ─────────────────────────────────────────