fix(stats): sub-nav edge fades + scrollspy jump on chip click

- Edge fades like the header tabs: chips moved into an inner scroll track so
  the fade mask only affects the chips, leaving the pill background/rounded
  ends crisp. updateSubnavFades toggles fade-left/right from the track's
  overflow (on track scroll, resize, setActiveChip, init).
- Scrollspy no longer jumps when a chip is clicked: the page-scroll spy is
  suppressed for the duration of the programmatic smooth-scroll (700ms; 0 under
  reduced-motion), so the clicked chip stays active instead of flipping back to
  the old section mid-animation and forward again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Lucas Kalil 2026-06-17 11:26:05 -03:00
parent 941a519891
commit 9c62cbd7c1
2 changed files with 69 additions and 9 deletions

View file

@ -687,12 +687,9 @@ button.record-card:hover { border-color: var(--accent-gold); background: var(--g
position: sticky;
top: var(--header-h, 64px);
z-index: 20;
display: flex;
gap: 0.35rem;
margin: 1.4rem 0;
padding: 0.4rem;
overflow-x: auto;
scrollbar-width: none;
overflow: hidden; /* clip the inner track to the rounded pill */
background: rgba(8, 20, 33, 0.88);
backdrop-filter: blur(10px) saturate(1.2);
-webkit-backdrop-filter: blur(10px) saturate(1.2);
@ -700,7 +697,33 @@ button.record-card:hover { border-color: var(--accent-gold); background: var(--g
border-radius: 999px;
}
.stats-subnav::-webkit-scrollbar { display: none; }
/* the scrolling chip track the edge-fade mask goes here, not on the pill, so
the pill's background + rounded ends stay crisp while only chips fade out. */
.stats-subnav-track {
position: relative;
display: flex;
gap: 0.35rem;
overflow-x: auto;
scrollbar-width: none;
}
.stats-subnav-track::-webkit-scrollbar { display: none; }
/* edge fades — toggled by JS only on a side that still has chips to scroll to */
.stats-subnav.fade-right .stats-subnav-track {
-webkit-mask-image: linear-gradient(to left, transparent 0, #000 28px);
mask-image: linear-gradient(to left, transparent 0, #000 28px);
}
.stats-subnav.fade-left .stats-subnav-track {
-webkit-mask-image: linear-gradient(to right, transparent 0, #000 28px);
mask-image: linear-gradient(to right, transparent 0, #000 28px);
}
.stats-subnav.fade-left.fade-right .stats-subnav-track {
-webkit-mask-image: linear-gradient(to right, transparent 0, #000 28px, #000 calc(100% - 28px), transparent 100%);
mask-image: linear-gradient(to right, transparent 0, #000 28px, #000 calc(100% - 28px), transparent 100%);
}
.stats-subnav-chip {
flex: 0 0 auto;

View file

@ -433,13 +433,23 @@ function subNavHTML(sections) {
const chips = sections.map((section, i) => `
<a class="stats-subnav-chip${i === 0 ? ' active' : ''}" href="#stats-${section.id}"
data-section="${section.id}" aria-current="${i === 0 ? 'true' : 'false'}">${t(section.navKey)}</a>`).join('');
return `<nav class="stats-subnav" aria-label="${t('stats.sectionsNav')}">${chips}</nav>`;
// chips live in an inner scroll track so the edge-fade mask never clips the
// pill's background/rounded ends (only the chips fade at the edges).
return `<nav class="stats-subnav" aria-label="${t('stats.sectionsNav')}"><div class="stats-subnav-track">${chips}</div></nav>`;
}
let spyScrollHandler = null;
let subnavResizeHandler = null;
// While a chip-click smooth-scroll is in flight, the page-scroll spy must NOT
// fight it: early in the animation the viewport is still over the old section,
// so the spy would flip the active chip back and then forward again (a visible
// jump). Suppress spy updates until the programmatic scroll has settled.
let suppressSpyUntil = 0;
function setupSubNav(root, sections) {
const nav = root.querySelector('.stats-subnav');
if (!nav) return;
const track = nav.querySelector('.stats-subnav-track');
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// chip → smooth-scroll to the section WITHOUT touching location.hash: the tab
@ -449,11 +459,16 @@ function setupSubNav(root, sections) {
const chip = event.target.closest('.stats-subnav-chip');
if (!chip) return;
event.preventDefault();
suppressSpyUntil = Date.now() + (reduce ? 0 : 700); // hold the spy off during the animated scroll
document.getElementById(`stats-${chip.dataset.section}`)
?.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth', block: 'start' });
setActiveChip(nav, chip.dataset.section);
});
// edge fades while the chip track overflows horizontally (mirrors the header
// tabs): a fade shows only on a side that still has chips to scroll toward.
track?.addEventListener('scroll', () => updateSubnavFades(nav), { passive: true });
// scrollspy: active = the last section whose heading has scrolled under the
// sticky sub-nav line; at the page bottom the last section always wins (a short
// final section may never reach the line — the classic scrollspy edge case an
@ -461,6 +476,7 @@ function setupSubNav(root, sections) {
// handful of sections per frame is cheap and always correct on short pages.
const ids = sections.map((section) => section.id);
const updateSpy = () => {
if (Date.now() < suppressSpyUntil) return; // a chip-click scroll owns the active chip
const headerH = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--header-h')) || 64;
const line = headerH + 80; // just beneath the sticky sub-nav
let activeId = ids[0];
@ -480,19 +496,40 @@ function setupSubNav(root, sections) {
raf = requestAnimationFrame(() => { raf = 0; updateSpy(); });
};
window.addEventListener('scroll', spyScrollHandler, { passive: true });
// re-evaluate the fades when the viewport width changes the track's overflow
if (subnavResizeHandler) window.removeEventListener('resize', subnavResizeHandler);
subnavResizeHandler = () => updateSubnavFades(nav);
window.addEventListener('resize', subnavResizeHandler, { passive: true });
updateSpy();
updateSubnavFades(nav);
}
// Toggle the edge-fade mask classes on the pill based on the inner track's
// horizontal overflow (the mask lives on the track, so the pill stays crisp).
function updateSubnavFades(nav) {
const track = nav.querySelector('.stats-subnav-track');
if (!track) return;
const overflowing = track.scrollWidth - track.clientWidth > 1;
const atStart = track.scrollLeft <= 1;
const atEnd = track.scrollLeft >= track.scrollWidth - track.clientWidth - 1;
nav.classList.toggle('fade-left', overflowing && !atStart);
nav.classList.toggle('fade-right', overflowing && !atEnd);
}
function setActiveChip(nav, id) {
const track = nav.querySelector('.stats-subnav-track');
for (const chip of nav.querySelectorAll('.stats-subnav-chip')) {
const on = chip.dataset.section === id;
chip.classList.toggle('active', on);
chip.setAttribute('aria-current', on ? 'true' : 'false');
}
// keep the active chip visible when the nav scrolls horizontally on mobile
// (only moves the nav's own scroll, never the page).
// keep the active chip visible when the track scrolls horizontally on mobile
// (only moves the track's own scroll, never the page).
const active = nav.querySelector('.stats-subnav-chip.active');
if (active) nav.scrollLeft = active.offsetLeft - (nav.clientWidth - active.clientWidth) / 2;
if (active && track) track.scrollLeft = active.offsetLeft - (track.clientWidth - active.clientWidth) / 2;
updateSubnavFades(nav);
}
// ----------------------------------------------------------- flags