diff --git a/public/app.js b/public/app.js index 58dd8b2f..d8d171ec 100644 --- a/public/app.js +++ b/public/app.js @@ -917,18 +917,112 @@ window.addEventListener('DOMContentLoaded', () => { link.addEventListener('click', closeNav); }); - // --- "More" dropdown (tablet Priority+ nav) --- + // --- "More" dropdown — JS-driven Priority+ (Issue #1102) --- const navMoreBtn = document.getElementById('navMoreBtn'); const navMoreMenu = document.getElementById('navMoreMenu'); - if (navMoreBtn && navMoreMenu) { - // Build More menu dynamically from non-priority nav links (DRY) - navMoreMenu.innerHTML = ''; - document.querySelectorAll('.nav-links a:not([data-priority="high"])').forEach(function(link) { - var clone = link.cloneNode(true); - clone.setAttribute('role', 'menuitem'); - clone.addEventListener('click', closeMoreMenu); - navMoreMenu.appendChild(clone); + const navMoreWrap = document.querySelector('.nav-more-wrap'); + if (navMoreBtn && navMoreMenu && navMoreWrap) { + // Measure available room and decide which links overflow. + // Algorithm: try to fit all links inline. If the link strip doesn't + // fit alongside .nav-right + .nav-brand, hide non-priority links one + // at a time (right-to-left, lowest priority first) until it does. + // Then mirror the hidden links into the "More ▾" menu so nothing + // disappears from the user's reach. + const navTop = document.querySelector('.top-nav'); + const navLeft = document.querySelector('.nav-left'); + const navRightEl = document.querySelector('.nav-right'); + const linksContainer = document.querySelector('.nav-links'); + const allLinks = Array.from(linksContainer.querySelectorAll('.nav-link')); + // High-priority links last in the overflow queue (kept visible). + // Source order is preserved; only `order:-1` flips render order. + const overflowQueue = allLinks.filter(a => a.dataset.priority !== 'high') + .reverse() // right-to-left + .concat(allLinks.filter(a => a.dataset.priority === 'high').reverse()); + + function rebuildMoreMenu() { + navMoreMenu.innerHTML = ''; + const hidden = allLinks.filter(a => a.classList.contains('is-overflow')); + hidden.forEach(function(link) { + var clone = link.cloneNode(true); + // The clone is in the overflow menu, not the inline strip. + clone.classList.remove('is-overflow'); + clone.setAttribute('role', 'menuitem'); + clone.addEventListener('click', closeMoreMenu); + navMoreMenu.appendChild(clone); + }); + // If nothing overflows, hide the More button entirely so wide + // viewports don't show a useless dropdown trigger. + navMoreWrap.classList.toggle('is-hidden', hidden.length === 0); + // Refresh active state on the More button (a hidden active link + // means the More menu currently "is" the active section). + var hasActiveMore = navMoreMenu.querySelector('.nav-link.active'); + navMoreBtn.classList.toggle('active', !!hasActiveMore); + } + + function applyNavPriority() { + // Skip on mobile (<768px) — hamburger CSS owns that layout. + if (window.innerWidth < 768) { + allLinks.forEach(a => a.classList.remove('is-overflow')); + navMoreWrap.classList.add('is-hidden'); + return; + } + // Reset: show everything, then hide as needed. + allLinks.forEach(a => a.classList.remove('is-overflow')); + navMoreWrap.classList.remove('is-hidden'); + // Iteratively hide low-priority links until the link strip fits. + // .top-nav has overflow:hidden and .nav-left has flex-shrink:1, so + // an overflowing strip silently clips rather than pushing + // nav-right out — bounding-rect math on .nav-left lies. Instead + // measure the *intrinsic* widths of the parts (independent of + // current clipping) and compare to the viewport. SAFETY absorbs + // the .top-nav side padding + nav-right inner gaps + sub-pixel + // rounding (the historic #1055 bug was a 6–20px overlap). + const navBrand = document.querySelector('.nav-brand'); + const GUTTER = 24; // matches .nav-left gap (--space-lg) + const SAFETY = 32; + function fits() { + const visibleLinks = allLinks.filter(a => !a.classList.contains('is-overflow')); + let linkW = 0; + visibleLinks.forEach(a => { linkW += a.getBoundingClientRect().width; }); + const gapPx = parseFloat(getComputedStyle(linksContainer).columnGap || + getComputedStyle(linksContainer).gap || '0'); + const linksGap = Math.max(0, visibleLinks.length - 1) * gapPx; + const brandW = navBrand ? navBrand.getBoundingClientRect().width : 0; + // Always reserve space for the More button if anything could + // overflow — measure it as if visible (use offsetWidth which + // is 0 when display:none, so we add a fixed reserve fallback). + const moreVis = !navMoreWrap.classList.contains('is-hidden'); + const moreW = moreVis ? navMoreWrap.getBoundingClientRect().width : 70; + const rightW = navRightEl.scrollWidth; // intrinsic, ignores clipping + const needed = brandW + GUTTER + linkW + linksGap + GUTTER + moreW + GUTTER + rightW + SAFETY; + return needed <= window.innerWidth; + } + let i = 0; + while (!fits() && i < overflowQueue.length) { + overflowQueue[i].classList.add('is-overflow'); + i++; + } + rebuildMoreMenu(); + } + + // Run once on load, again after fonts settle (label widths shift), + // and on resize (debounced via rAF). + applyNavPriority(); + if (document.fonts && document.fonts.ready) { + document.fonts.ready.then(applyNavPriority); + } + let rafId = 0; + window.addEventListener('resize', function() { + if (rafId) cancelAnimationFrame(rafId); + rafId = requestAnimationFrame(applyNavPriority); }); + // Re-apply on route change too: the active link gets bigger padding + // (background pill), so which links fit can shift between pages. + window.addEventListener('hashchange', function() { + // Defer so the route handler's class toggles run first. + requestAnimationFrame(applyNavPriority); + }); + navMoreBtn.addEventListener('click', (e) => { e.stopPropagation(); const opening = !navMoreMenu.classList.contains('open'); diff --git a/public/style.css b/public/style.css index 825889cc..0b66834d 100644 --- a/public/style.css +++ b/public/style.css @@ -1336,19 +1336,29 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; } .ch-main { width: 100%; } } -/* === Nav Priority+ — applies at ALL desktop widths (Issue #1055) === - Above the mobile hamburger breakpoint, only data-priority="high" links - render inline; the rest collapse into the "More ▾" overflow menu. - This guarantees nav-right cannot be pushed off-screen or overlapped by - the link strip at any common viewport (768/1024/1280/1440/1920+). */ +/* === Nav Priority+ — JS-driven measurement (Issue #1102) === + Above the mobile hamburger breakpoint we render ALL nav links inline + by default. A small JS pass in app.js (applyNavPriority) measures the + actual fit and adds .is-overflow to whichever links don't fit, then + moves them into the "More ▾" overflow menu. Pure CSS can't do this + correctly because we want every link visible at 2560px+ but only the + high-priority ones at 800px — and the breakpoint where each link + drops depends on its label width, the gutters, the active-link + padding, and the nav-stats badge. JS measurement is the only correct + answer. data-priority="high" links are pinned to the left via + order:-1 and are kept inline as long as possible (low-priority links + overflow first). Spacing/type still use #1054 fluid clamp() tokens. */ @media (min-width: 768px) { .nav-links { display: flex !important; flex-direction: row; gap: var(--space-xs); } - .nav-links a:not([data-priority="high"]) { display: none; } .nav-more-wrap { display: flex; align-items: center; } .hamburger { display: none; } .nav-link { padding: 14px clamp(8px, 0.6vw + 4px, 14px); font-size: var(--fs-sm); } .nav-links a[data-priority="high"] { order: -1; } .nav-link.active { background: var(--nav-active-bg); border-radius: 6px; margin: 4px 0; padding: 10px clamp(8px, 0.6vw + 4px, 14px); } + /* JS-managed: links assigned .is-overflow get hidden inline; the + "More ▾" wrap is hidden when nothing overflows. */ + .nav-links .nav-link.is-overflow { display: none; } + .nav-more-wrap.is-hidden { display: none !important; } } /* === Nav narrow-desktop tightening (Issue #1055 — 1024px overlap fix) === diff --git a/test-nav-priority-1102-e2e.js b/test-nav-priority-1102-e2e.js new file mode 100644 index 00000000..0c7ae3a0 --- /dev/null +++ b/test-nav-priority-1102-e2e.js @@ -0,0 +1,129 @@ +#!/usr/bin/env node +/* Issue #1102 — Nav Priority+ at very wide widths and "More" menu correctness. + * + * Regression from PR #1097 polish: at all widths >=768px the CSS rule + * .nav-links a:not([data-priority="high"]) { display: none; } + * unconditionally hides 6 of 11 links, even at 2560px where there is + * plenty of room for everything. The "More" menu is built once on load + * from the same selector, so it correctly shows the hidden links — but + * the bug here is the SET being hidden is wrong (way too aggressive). + * + * Acceptance: + * - At 2560px: ALL 11 links visible inline AND "More ▾" hidden. + * - At 1920px: at least 9 links visible (room for most). + * - At 1080px: 5 high-priority links visible AND More menu contains + * every link not currently visible inline. + * - At 768px (just above hamburger threshold): 5 high-priority links + * visible AND More menu non-empty. + */ +'use strict'; + +const { chromium } = require('playwright'); + +const BASE = process.env.BASE_URL || 'http://localhost:13581'; + +// [width, expected behavior] +const CASES = [ + // viewport, minVisible, moreVisible, label + { w: 2560, minVisible: 11, moreVisible: false, label: '2560px — all visible' }, + { w: 1920, minVisible: 9, moreVisible: null, label: '1920px — most visible' }, + { w: 1080, minVisible: 5, moreVisible: true, label: '1080px — collapsed' }, + { w: 800, minVisible: 5, moreVisible: true, label: '800px — collapsed' }, +]; + +const HEIGHT = 900; + +async function main() { + let browser; + try { + browser = await chromium.launch({ + headless: true, + executablePath: process.env.CHROMIUM_PATH || undefined, + args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'], + }); + } catch (err) { + if (process.env.CHROMIUM_REQUIRE === '1') { + console.error(`test-nav-priority-1102-e2e.js: FAIL — Chromium required but unavailable: ${err.message}`); + process.exit(1); + } + console.log(`test-nav-priority-1102-e2e.js: SKIP (Chromium unavailable: ${err.message.split('\n')[0]})`); + process.exit(0); + } + + let failures = 0; + let passes = 0; + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + page.setDefaultTimeout(15000); + + for (const c of CASES) { + await page.setViewportSize({ width: c.w, height: HEIGHT }); + await page.goto(`${BASE}/#/home`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('.top-nav .nav-links'); + await page.evaluate(() => document.fonts && document.fonts.ready ? document.fonts.ready : null); + // Settle layout (two consecutive frames identical for nav-right). + await page.waitForFunction(() => { + const el = document.querySelector('.top-nav .nav-right'); + if (!el) return false; + const r1 = el.getBoundingClientRect(); + return new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => { + const r2 = el.getBoundingClientRect(); + resolve(r1.right === r2.right && r1.left === r2.left); + })); + }); + }, null, { timeout: 5000 }); + + const data = await page.evaluate(() => { + const links = Array.from(document.querySelectorAll('.nav-links .nav-link')); + const visible = links.filter(a => getComputedStyle(a).display !== 'none'); + const visibleHrefs = visible.map(a => a.getAttribute('href')); + const allHrefs = links.map(a => a.getAttribute('href')); + const hiddenInline = allHrefs.filter(h => !visibleHrefs.includes(h)); + const moreWrap = document.querySelector('.nav-more-wrap'); + const moreVisible = moreWrap ? getComputedStyle(moreWrap).display !== 'none' : false; + const moreMenuLinks = Array.from(document.querySelectorAll('#navMoreMenu .nav-link')) + .map(a => a.getAttribute('href')); + return { totalLinks: links.length, visibleCount: visible.length, + visibleHrefs, hiddenInline, moreVisible, moreMenuLinks }; + }); + + const reasons = []; + if (data.visibleCount < c.minVisible) { + reasons.push(`only ${data.visibleCount}/${data.totalLinks} links visible (need >=${c.minVisible})`); + } + if (c.moreVisible === true && !data.moreVisible) { + reasons.push(`"More" button should be visible but is hidden`); + } + if (c.moreVisible === false && data.moreVisible) { + reasons.push(`"More" button should be HIDDEN at ${c.w}px (all links fit) but is visible`); + } + // More menu MUST contain every link not currently visible inline. + if (data.moreVisible) { + const missing = data.hiddenInline.filter(h => !data.moreMenuLinks.includes(h)); + if (missing.length) { + reasons.push(`More menu missing hidden links: ${missing.join(', ')} ` + + `(menu has ${data.moreMenuLinks.length}, expected ${data.hiddenInline.length})`); + } + } + + const tag = c.label; + if (reasons.length === 0) { + passes++; + console.log(` ✅ ${tag}: visible=${data.visibleCount}/${data.totalLinks} more=${data.moreVisible} menu=${data.moreMenuLinks.length}`); + } else { + failures++; + console.log(` ❌ ${tag}: ${reasons.join(' | ')} ` + + `(visible=${data.visibleCount}/${data.totalLinks} more=${data.moreVisible} menu=${data.moreMenuLinks.length})`); + } + } + + await browser.close(); + console.log(`\ntest-nav-priority-1102-e2e.js: ${failures === 0 ? 'OK' : 'FAIL'} — ${passes}/${CASES.length} passed`); + process.exit(failures === 0 ? 0 : 1); +} + +main().catch((err) => { + console.error('test-nav-priority-1102-e2e.js: fatal', err); + process.exit(1); +});