/** Scroll snapping is the baseline; arrows, dots and controllable playback enhance it. */ export function initSliders(doc = document) { const view = doc.defaultView; const cleanups = []; doc.querySelectorAll('.block-slider').forEach(slider => { const slides = [...slider.querySelectorAll(':scope > .slider-slide')]; if (slides.length < 2 || slider.dataset.enhanced) return; slider.dataset.enhanced = 'true'; const events = new view.AbortController(), signal = events.signal; const preference = view.matchMedia('(prefers-reduced-motion: reduce)'); // The browser preference counts only when the website says so (Einstellungen → Bewegung). const reduced = () => doc.documentElement.classList.contains('motion-respect') && preference.matches; const seconds = Number(slider.dataset.autoplay || 0); let playing = seconds >= 3 && !reduced(), hover = false, visible = false, timer; const controls = doc.createElement('div'); controls.className = 'slider-controls'; for (const name of slider.classList) if (name.startsWith('visibility-')) controls.classList.add(name); controls.setAttribute('role', 'group'); controls.setAttribute('aria-label', 'Slider bedienen'); const button = (text, label, action) => { const node = doc.createElement('button'); node.type = 'button'; node.textContent = text; node.setAttribute('aria-label', label); node.setAttribute('aria-controls', slider.id); node.addEventListener('click', event => { event.preventDefault(); event.stopPropagation(); action(); }, { signal }); controls.append(node); return node; }; const left = slide => slide.getBoundingClientRect().left - slider.getBoundingClientRect().left + slider.scrollLeft; const available = () => slides.map((slide, i) => slide.getClientRects().length ? i : -1).filter(i => i >= 0); const active = () => { let index = 0, distance = Infinity; available().forEach(i => { const delta = Math.abs(left(slides[i]) - slider.scrollLeft); if (delta < distance) { distance = delta; index = i; } }); return index; }; let settle = null; const go = index => { if (!available().includes(index)) return; const target = Math.max(0, Math.min(slider.scrollWidth - slider.clientWidth, left(slides[index]))); if (reduced()) { slider.scrollTo({ left: target, behavior: 'instant' }); return; } // Safari cancels a smooth scroll on a snapping container and snaps back to the slide it // came from - the slider stutters instead of moving on. Snapping is switched off for the // ride and put back once the movement has settled, on the exact start of a slide, so // restoring it moves nothing. clearInterval(settle); const snap = slider.style.scrollSnapType; slider.style.scrollSnapType = 'none'; slider.scrollTo({ left: target, behavior: 'smooth' }); // Restored on arrival, or after two seconds if the ride was interrupted - a background tab // pauses smooth scrolling, and snapping must not come back halfway. let ticks = 0; settle = setInterval(() => { if (Math.abs(slider.scrollLeft - target) < 1 || ++ticks > 25) { clearInterval(settle); settle = null; slider.style.scrollSnapType = snap; } }, 80); }; const step = direction => { const visible = available(), position = visible.indexOf(active()); if (!visible.length) return; const last = slider.scrollLeft + slider.clientWidth >= slider.scrollWidth - 2; const first = slider.scrollLeft <= 2; go(direction > 0 ? (last ? visible[0] : visible[Math.min(visible.length - 1, position + 1)]) : (first ? visible.at(-1) : visible[Math.max(0, position - 1)])); }; const manual = action => { playing = false; clearTimeout(timer); update(); action(); }; const previous = button('←', 'Vorherige Folie', () => manual(() => step(-1))); previous.className = 'slider-prev'; const count = doc.createElement('span'); count.className = 'slider-position'; controls.append(count); const next = button('→', 'Nächste Folie', () => manual(() => step(1))); next.className = 'slider-next'; const dots = []; if (slider.classList.contains('slider-dots')) { const group = doc.createElement('div'); group.className = 'slider-control-dots'; controls.append(group); slides.forEach((slide, i) => { const dot = button('●', `Folie ${i + 1} anzeigen`, () => manual(() => go(i))); group.append(dot); dots.push(dot); }); } const play = seconds >= 3 ? button('', 'Automatische Wiedergabe pausieren', () => { playing = !playing; update(); schedule(); }) : null; if (play) play.className = 'slider-play'; const update = () => { const visible = available(); const fits = visible.length < 2 || slider.scrollWidth <= slider.clientWidth + 2; controls.hidden = fits; previous.disabled = next.disabled = fits; const index = active(); count.textContent = `${Math.max(0, visible.indexOf(index) + 1)} / ${visible.length}`; dots.forEach((dot, i) => { dot.hidden = !visible.includes(i); dot.setAttribute('aria-pressed', String(i === index)); }); if (play) { play.textContent = playing ? 'Pause' : 'Wiedergabe'; play.dataset.playing = String(playing); // Der zugängliche Name beginnt mit dem sichtbaren Text (WCAG 2.5.3): wer „Pause“ liest, kann „Pause“ sagen. play.setAttribute('aria-label', playing ? 'Pause: automatische Wiedergabe anhalten' : 'Wiedergabe: automatisch weiterblättern'); play.disabled = reduced(); play.title = reduced() ? 'Automatik ist bei reduzierter Bewegung deaktiviert.' : ''; } }; const schedule = () => { clearTimeout(timer); if (playing && !reduced()) timer = setTimeout(() => { if (playing && !hover && visible && !doc.hidden) step(1); schedule(); }, seconds * 1000); }; const pause = () => { playing = false; update(); clearTimeout(timer); }; // Slider and controls share a positioned shell, so arrows and dots can lie on the picture // the way the Kant-IT hero slider draws them. const shell = doc.createElement('div'); shell.className = 'slider-shell'; slider.replaceWith(shell); shell.append(slider, controls); slider.classList.add('slider-controlled'); update(); slider.addEventListener('scroll', update, { passive: true, signal }); view.addEventListener('resize', update, { passive: true, signal }); slider.addEventListener('pointerenter', () => { hover = true; }, { signal }); slider.addEventListener('pointerleave', () => { hover = false; schedule(); }, { signal }); for (const event of ['pointerdown', 'wheel', 'touchstart', 'focusin']) slider.addEventListener(event, pause, { passive: true, signal }); slider.addEventListener('keydown', event => { if (event.target !== slider || !['ArrowLeft', 'ArrowRight'].includes(event.key)) return; event.preventDefault(); manual(() => step(event.key === 'ArrowRight' ? 1 : -1)); }, { signal }); const observer = new view.IntersectionObserver(entries => { visible = entries[0].isIntersecting; }, { threshold: 0 }); observer.observe(slider); const resize = new view.ResizeObserver(update); resize.observe(slider); preference.addEventListener('change', () => { if (reduced()) pause(); update(); }, { signal }); schedule(); cleanups.push(() => { clearTimeout(timer); clearInterval(settle); slider.style.scrollSnapType = ''; events.abort(); observer.disconnect(); resize.disconnect(); controls.remove(); shell.replaceWith(slider); slider.classList.remove('slider-controlled'); delete slider.dataset.enhanced; }); }); return () => cleanups.forEach(cleanup => cleanup()); } if (!document.querySelector('#editor')) initSliders();