mirror of
https://github.com/LucasKalil-Programador/world-2026-hub.git
synced 2026-07-04 17:41:28 -03:00
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:
parent
941a519891
commit
9c62cbd7c1
2 changed files with 69 additions and 9 deletions
|
|
@ -687,12 +687,9 @@ button.record-card:hover { border-color: var(--accent-gold); background: var(--g
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: var(--header-h, 64px);
|
top: var(--header-h, 64px);
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
display: flex;
|
|
||||||
gap: 0.35rem;
|
|
||||||
margin: 1.4rem 0;
|
margin: 1.4rem 0;
|
||||||
padding: 0.4rem;
|
padding: 0.4rem;
|
||||||
overflow-x: auto;
|
overflow: hidden; /* clip the inner track to the rounded pill */
|
||||||
scrollbar-width: none;
|
|
||||||
background: rgba(8, 20, 33, 0.88);
|
background: rgba(8, 20, 33, 0.88);
|
||||||
backdrop-filter: blur(10px) saturate(1.2);
|
backdrop-filter: blur(10px) saturate(1.2);
|
||||||
-webkit-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;
|
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 {
|
.stats-subnav-chip {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
|
|
||||||
|
|
@ -433,13 +433,23 @@ function subNavHTML(sections) {
|
||||||
const chips = sections.map((section, i) => `
|
const chips = sections.map((section, i) => `
|
||||||
<a class="stats-subnav-chip${i === 0 ? ' active' : ''}" href="#stats-${section.id}"
|
<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('');
|
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 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) {
|
function setupSubNav(root, sections) {
|
||||||
const nav = root.querySelector('.stats-subnav');
|
const nav = root.querySelector('.stats-subnav');
|
||||||
if (!nav) return;
|
if (!nav) return;
|
||||||
|
const track = nav.querySelector('.stats-subnav-track');
|
||||||
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
|
||||||
// chip → smooth-scroll to the section WITHOUT touching location.hash: the tab
|
// 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');
|
const chip = event.target.closest('.stats-subnav-chip');
|
||||||
if (!chip) return;
|
if (!chip) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
suppressSpyUntil = Date.now() + (reduce ? 0 : 700); // hold the spy off during the animated scroll
|
||||||
document.getElementById(`stats-${chip.dataset.section}`)
|
document.getElementById(`stats-${chip.dataset.section}`)
|
||||||
?.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth', block: 'start' });
|
?.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth', block: 'start' });
|
||||||
setActiveChip(nav, chip.dataset.section);
|
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
|
// 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
|
// 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
|
// 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.
|
// handful of sections per frame is cheap and always correct on short pages.
|
||||||
const ids = sections.map((section) => section.id);
|
const ids = sections.map((section) => section.id);
|
||||||
const updateSpy = () => {
|
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 headerH = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--header-h')) || 64;
|
||||||
const line = headerH + 80; // just beneath the sticky sub-nav
|
const line = headerH + 80; // just beneath the sticky sub-nav
|
||||||
let activeId = ids[0];
|
let activeId = ids[0];
|
||||||
|
|
@ -480,19 +496,40 @@ function setupSubNav(root, sections) {
|
||||||
raf = requestAnimationFrame(() => { raf = 0; updateSpy(); });
|
raf = requestAnimationFrame(() => { raf = 0; updateSpy(); });
|
||||||
};
|
};
|
||||||
window.addEventListener('scroll', spyScrollHandler, { passive: true });
|
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();
|
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) {
|
function setActiveChip(nav, id) {
|
||||||
|
const track = nav.querySelector('.stats-subnav-track');
|
||||||
for (const chip of nav.querySelectorAll('.stats-subnav-chip')) {
|
for (const chip of nav.querySelectorAll('.stats-subnav-chip')) {
|
||||||
const on = chip.dataset.section === id;
|
const on = chip.dataset.section === id;
|
||||||
chip.classList.toggle('active', on);
|
chip.classList.toggle('active', on);
|
||||||
chip.setAttribute('aria-current', on ? 'true' : 'false');
|
chip.setAttribute('aria-current', on ? 'true' : 'false');
|
||||||
}
|
}
|
||||||
// keep the active chip visible when the nav scrolls horizontally on mobile
|
// keep the active chip visible when the track scrolls horizontally on mobile
|
||||||
// (only moves the nav's own scroll, never the page).
|
// (only moves the track's own scroll, never the page).
|
||||||
const active = nav.querySelector('.stats-subnav-chip.active');
|
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
|
// ----------------------------------------------------------- flags
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue