fix: Tagesablauf – Zurück-Knopf funktioniert, Karten nicht mehr abgeschnitten

Positionen kommen aus getBoundingClientRect statt offsetLeft (bezog sich auf <body> und verfehlte den Einrastpunkt: Weiter sprang zwei Tage, Zurück hing am Ende). Weiter ist am Ende deaktiviert, Pfeile stehen über den Karten, ab md passen die Karten vollständig in den Inhaltsbereich.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-06 06:55:08 +02:00
1 parent df105aafca
commit 51b6d79bbf
1 file changed
+84 -46
+84 -46
View File
@@ -1,50 +1,118 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { ScheduleItem } from "@/content/site";
export type ScheduleCard = { label: string; date: string | null; title: string; items: ScheduleItem[] };
/** Toleranz in Pixeln fuer "ganz am Anfang/Ende" (Rundung beim Einrasten). */
const EDGE_TOLERANCE = 2;
/**
* Tagesablauf als waagerechte Kartenreihe mit Einrasten (scroll-snap).
* Wischen auf dem Smartphone, Pfeile und Punkte auf allen Groessen. Die
* Liste bleibt eine echte Liste: ohne JavaScript scrollt sie trotzdem.
*
* Positionen werden aus getBoundingClientRect berechnet, nicht aus
* offsetLeft: offsetLeft bezieht sich auf das naechste positionierte
* Element (hier <body>) und verfehlte den Einrastpunkt, sodass "Weiter"
* zwei Tage sprang und "Zurueck" am Ende haengen blieb.
*/
export function ScheduleCarousel({ days }: { days: ScheduleCard[] }) {
const track = useRef<HTMLOListElement>(null);
const cards = useRef<(HTMLLIElement | null)[]>([]);
const [current, setCurrent] = useState(0);
const [atStart, setAtStart] = useState(true);
const [atEnd, setAtEnd] = useState(days.length <= 1);
const snapInset = (root: HTMLElement) => parseFloat(getComputedStyle(root).scrollPaddingLeft) || 0;
const update = useCallback(() => {
const root = track.current;
if (!root) return;
const anchor = root.getBoundingClientRect().left + snapInset(root);
let nearest = 0;
let best = Infinity;
cards.current.forEach((card, index) => {
if (!card) return;
const distance = Math.abs(card.getBoundingClientRect().left - anchor);
if (distance < best) {
best = distance;
nearest = index;
}
});
setCurrent(nearest);
setAtStart(root.scrollLeft <= EDGE_TOLERANCE);
setAtEnd(root.scrollLeft >= root.scrollWidth - root.clientWidth - EDGE_TOLERANCE);
}, []);
useEffect(() => {
const root = track.current;
if (!root) return;
const observer = new IntersectionObserver(
(entries) => {
const visible = entries.filter((e) => e.isIntersecting).map((e) => Number((e.target as HTMLElement).dataset.index));
if (visible.length) setCurrent(Math.min(...visible));
},
{ root, threshold: 0.6 },
);
cards.current.forEach((card) => card && observer.observe(card));
return () => observer.disconnect();
}, []);
let frame = 0;
const onScroll = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(update);
};
update();
root.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
return () => {
cancelAnimationFrame(frame);
root.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onScroll);
};
}, [update]);
const go = (index: number) => {
const target = cards.current[Math.max(0, Math.min(days.length - 1, index))];
const root = track.current;
if (!target || !root) return;
const target = cards.current[Math.max(0, Math.min(days.length - 1, index))];
if (!root || !target) return;
const left = root.scrollLeft + target.getBoundingClientRect().left - root.getBoundingClientRect().left - snapInset(root);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
root.scrollTo({ left: target.offsetLeft - root.offsetLeft, behavior: reduce ? "auto" : "smooth" });
root.scrollTo({ left, behavior: reduce ? "auto" : "smooth" });
};
return (
<div>
<div className="mb-6 flex items-center justify-between gap-4">
<div className="flex flex-wrap items-center gap-1">
{days.map((day, index) => (
<button
key={day.label}
type="button"
onClick={() => go(index)}
aria-label={`${day.label}${day.date ? `, ${day.date}` : ""} zeigen`}
aria-current={index === current ? "true" : undefined}
className="grid h-8 w-6 place-items-center"
>
<span className={`block h-1.5 rounded-full transition-all duration-300 ${index === current ? "w-5 bg-ink" : "w-1.5 bg-ink/30"}`} />
</button>
))}
</div>
<div className="flex gap-2">
<button type="button" onClick={() => go(current - 1)} disabled={atStart} aria-label="Vorheriger Tag" className="grid h-11 w-11 place-items-center rounded-full border border-ink/25 hover:border-ink disabled:opacity-35">
<svg viewBox="0 0 24 24" className="h-5 w-5" aria-hidden>
<path d="M15 6l-6 6 6 6" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<button type="button" onClick={() => go(current + 1)} disabled={atEnd} aria-label="Nächster Tag" className="grid h-11 w-11 place-items-center rounded-full border border-ink/25 hover:border-ink disabled:opacity-35">
<svg viewBox="0 0 24 24" className="h-5 w-5" aria-hidden>
<path d="M9 6l6 6-6 6" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
</div>
<ol
ref={track}
aria-label="Tage im Camp"
// Per Tastatur scrollbar (Pfeiltasten), WCAG 2.1.1.
tabIndex={0}
className="-mx-4 flex snap-x snap-mandatory scroll-px-4 gap-4 overflow-x-auto px-4 pb-4 md:scroll-px-10 [scrollbar-width:none] md:-mx-10 md:gap-6 md:px-10 [&::-webkit-scrollbar]:hidden"
// Smartphone: Karten ragen bis an den Rand, die naechste lugt hervor.
// Ab md: Karten passen vollstaendig in den Inhaltsbereich (2 bzw. 3
// nebeneinander), nichts wird am Rand abgeschnitten.
className="-mx-4 flex snap-x snap-mandatory scroll-px-4 gap-4 overflow-x-auto px-4 pb-4 [scrollbar-width:none] md:mx-0 md:scroll-px-0 md:gap-6 md:px-0 [&::-webkit-scrollbar]:hidden"
>
{days.map((day, index) => (
<li
@@ -52,8 +120,7 @@ export function ScheduleCarousel({ days }: { days: ScheduleCard[] }) {
ref={(el) => {
cards.current[index] = el;
}}
data-index={index}
className="flex w-[88%] shrink-0 snap-start flex-col rounded-2xl bg-paper p-6 shadow-[0_1px_0_var(--color-line)] sm:w-[62%] md:w-[46%] md:p-8 lg:w-[34%]"
className="flex w-[85%] shrink-0 snap-start flex-col rounded-2xl bg-paper p-6 shadow-[0_1px_0_var(--color-line)] sm:w-[62%] md:w-[calc((100%-1.5rem)/2)] md:p-8 lg:w-[calc((100%-3rem)/3)]"
>
<p className="text-sm font-semibold uppercase tracking-[0.16em] text-rosso">
{day.label} · {day.title}
@@ -78,35 +145,6 @@ export function ScheduleCarousel({ days }: { days: ScheduleCard[] }) {
</li>
))}
</ol>
<div className="mt-6 flex items-center justify-between gap-4">
<div className="flex flex-wrap items-center gap-1">
{days.map((day, index) => (
<button
key={day.label}
type="button"
onClick={() => go(index)}
aria-label={`${day.label}${day.date ? `, ${day.date}` : ""} zeigen`}
aria-current={index === current ? "true" : undefined}
className="grid h-8 w-6 place-items-center"
>
<span className={`block h-1.5 rounded-full transition-all duration-300 ${index === current ? "w-5 bg-ink" : "w-1.5 bg-ink/30"}`} />
</button>
))}
</div>
<div className="flex gap-2">
<button type="button" onClick={() => go(current - 1)} disabled={current === 0} aria-label="Vorheriger Tag" className="grid h-11 w-11 place-items-center rounded-full border border-ink/25 hover:border-ink disabled:opacity-35">
<svg viewBox="0 0 24 24" className="h-5 w-5" aria-hidden>
<path d="M15 6l-6 6 6 6" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<button type="button" onClick={() => go(current + 1)} disabled={current >= days.length - 1} aria-label="Nächster Tag" className="grid h-11 w-11 place-items-center rounded-full border border-ink/25 hover:border-ink disabled:opacity-35">
<svg viewBox="0 0 24 24" className="h-5 w-5" aria-hidden>
<path d="M9 6l6 6-6 6" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
</div>
</div>
);
}