Carousels: drag + position dots, and real Become-a-member photos

Convert the Hub / Become-a-member carousels from a pure-CSS marquee to a
JS-driven scroller with click-and-drag, touch swipe, and progress dots
(setupHubCarousel). Turn the "ways to be part of MSOS" cards into hub-style
photo cards and add the final Become-a-member photos (WebP).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
popovskikandClaude Opus 4.8 committed 2026-08-05 15:54:06 +02:00
1 parent a7782eb28f
commit 4c27ebb105
14 files changed
+238 -54

No files matched your search

+164 -12
View File
@@ -931,22 +931,174 @@ document.addEventListener('DOMContentLoaded', function() {
/**
* ===================================================================
* HUB USE-CASE CAROUSEL (auto-scrolling)
* HUB USE-CASE CAROUSEL (auto-scrolling + drag + position dots)
* ===================================================================
* Drives every .hub-carousel on the page (Hub "What you can do here"
* and the "ways to be part of MSOS" slider). The track is built from
* three identical .hub-group copies so the scroll loops seamlessly in
* both directions; auto-scroll is JS-driven (not a CSS animation) so it
* can co-exist with click-and-drag and touch swiping.
*/
function setupHubCarousel() {
const trackEl = document.querySelector('.hub-track');
if (!trackEl) return;
const group = trackEl.querySelector('.hub-group');
if (!group) return;
const carousels = document.querySelectorAll('.hub-carousel');
if (!carousels.length) return;
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const LOOP_MS = 55000; // time to travel one group width (matches old 55s marquee)
// Duplicate the group so translateX(-50%) loops back seamlessly (waterslide flow).
// Guard against double-cloning if the script somehow runs twice.
if (trackEl.querySelectorAll('.hub-group').length < 2) {
const clone = group.cloneNode(true);
clone.setAttribute('aria-hidden', 'true');
trackEl.appendChild(clone);
}
carousels.forEach(function (carousel) {
if (carousel.dataset.carouselInit) return;
carousel.dataset.carouselInit = '1';
const scroller = carousel.querySelector('.hub-uses');
const track = carousel.querySelector('.hub-track');
if (!scroller || !track) return;
const firstGroup = track.querySelector('.hub-group');
if (!firstGroup) return;
// Need 3 identical groups so a full neighbour is always visible at each wrap point.
while (track.querySelectorAll('.hub-group').length < 3) {
const clone = firstGroup.cloneNode(true);
clone.setAttribute('aria-hidden', 'true');
track.appendChild(clone);
}
const cards = firstGroup.children;
const cardCount = cards.length;
if (!cardCount) return;
// --- Position dots ---
const dotsWrap = document.createElement('div');
dotsWrap.className = 'hub-dots';
dotsWrap.setAttribute('role', 'tablist');
dotsWrap.setAttribute('aria-label', 'Carousel position');
const dotEls = [];
for (let i = 0; i < cardCount; i++) {
const b = document.createElement('button');
b.type = 'button';
b.className = 'hub-dot';
b.setAttribute('aria-label', 'Go to item ' + (i + 1));
b.addEventListener('click', (function (idx) {
return function () { goTo(idx); };
})(i));
dotsWrap.appendChild(b);
dotEls.push(b);
}
carousel.appendChild(dotsWrap);
// --- Measurements (recomputed on resize / image load) ---
let groupWidth = 0, step = 0;
function measure() {
groupWidth = firstGroup.offsetWidth;
step = cardCount > 1 ? (cards[1].offsetLeft - cards[0].offsetLeft) : groupWidth;
if (!step) step = groupWidth / cardCount;
}
// Keep scroll within the middle copy [groupWidth, 2*groupWidth) so it loops both ways.
function wrap() {
if (!groupWidth) return;
if (scroller.scrollLeft >= 2 * groupWidth) scroller.scrollLeft -= groupWidth;
else if (scroller.scrollLeft < groupWidth) scroller.scrollLeft += groupWidth;
}
function updateDots() {
if (!step || !groupWidth) return;
const within = ((scroller.scrollLeft % groupWidth) + groupWidth) % groupWidth;
const active = Math.round(within / step) % cardCount;
for (let i = 0; i < cardCount; i++) {
const dist = Math.min(Math.abs(i - active), cardCount - Math.abs(i - active));
dotEls[i].classList.toggle('is-active', i === active);
dotEls[i].classList.toggle('is-near', dist === 1);
dotEls[i].classList.toggle('is-far', dist >= 2);
}
}
function reset() {
measure();
if (groupWidth) scroller.scrollLeft = groupWidth;
updateDots();
}
reset();
window.addEventListener('load', reset);
window.addEventListener('resize', function () { measure(); wrap(); updateDots(); });
// --- Jump to a specific card when a dot is clicked ---
// Custom eased scroll (not native smooth) so it never fights the
// rAF auto-scroll loop. Target always sits in the middle copy
// [groupWidth, 2*groupWidth), so wrap() stays a no-op during it.
let programmatic = 0; // ms remaining where auto-scroll yields
let animId = 0;
function goTo(idx) {
if (!step) measure();
const start = scroller.scrollLeft;
const target = groupWidth + idx * step;
const dur = 450, t0 = performance.now();
const ease = function (t) { return 1 - Math.pow(1 - t, 3); };
cancelAnimationFrame(animId);
programmatic = dur + 80;
(function anim(now) {
const t = Math.min(1, (now - t0) / dur);
scroller.scrollLeft = start + (target - start) * ease(t);
updateDots();
if (t < 1) animId = requestAnimationFrame(anim);
})(t0);
}
// --- Auto-scroll loop ---
let paused = false;
let dragging = false;
let last = null;
function tick(ts) {
if (last == null) last = ts;
const dt = ts - last;
last = ts;
if (!reduceMotion && !paused && programmatic <= 0 && groupWidth) {
scroller.scrollLeft += (groupWidth / LOOP_MS) * dt;
}
if (programmatic > 0) programmatic -= dt;
wrap();
updateDots();
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
// Pause on hover (desktop).
carousel.addEventListener('mouseenter', function () { paused = true; });
carousel.addEventListener('mouseleave', function () { if (!dragging) paused = false; });
// --- Click-and-drag (mouse) + pause during touch (native scroll handles the swipe) ---
let lastX = 0;
scroller.addEventListener('pointerdown', function (e) {
paused = true;
if (e.pointerType !== 'mouse') return; // touch/pen: let the browser scroll natively
dragging = true;
lastX = e.clientX;
scroller.classList.add('is-dragging');
try { scroller.setPointerCapture(e.pointerId); } catch (err) {}
});
scroller.addEventListener('pointermove', function (e) {
if (!dragging) return;
const dx = e.clientX - lastX;
lastX = e.clientX;
scroller.scrollLeft -= dx;
wrap();
updateDots();
e.preventDefault();
});
function endDrag(e) {
if (dragging) {
dragging = false;
scroller.classList.remove('is-dragging');
try { scroller.releasePointerCapture(e.pointerId); } catch (err) {}
}
// Resume auto-scroll unless the cursor is still hovering the carousel.
if (!carousel.matches(':hover')) paused = false;
}
scroller.addEventListener('pointerup', endDrag);
scroller.addEventListener('pointercancel', endDrag);
// Prevent the browser's native image drag-ghost while dragging.
track.addEventListener('dragstart', function (e) { e.preventDefault(); });
});
}
/**