diff --git a/src/components/ScheduleCarousel.tsx b/src/components/ScheduleCarousel.tsx
index 41ab442..a45e640 100644
--- a/src/components/ScheduleCarousel.tsx
+++ b/src/components/ScheduleCarousel.tsx
@@ -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
) und verfehlte den Einrastpunkt, sodass "Weiter"
+ * zwei Tage sprang und "Zurueck" am Ende haengen blieb.
*/
export function ScheduleCarousel({ days }: { days: ScheduleCard[] }) {
const track = useRef(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 (
+
+
+ {days.map((day, index) => (
+ 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"
+ >
+
+
+ ))}
+
+
+
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">
+
+
+
+
+
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">
+
+
+
+
+
+
+
{days.map((day, index) => (
{
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)]"
>
{day.label} · {day.title}
@@ -78,35 +145,6 @@ export function ScheduleCarousel({ days }: { days: ScheduleCard[] }) {
))}
-
-
-
- {days.map((day, index) => (
- 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"
- >
-
-
- ))}
-
-
-
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">
-
-
-
-
-
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">
-
-
-
-
-
-
);
}