From 51b6d79bbfef1569f806c16ca153b4120704b152 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Tue, 6 Oct 2026 06:55:08 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20Tagesablauf=20=E2=80=93=20Zur=C3=BCck-Kn?= =?UTF-8?q?opf=20funktioniert,=20Karten=20nicht=20mehr=20abgeschnitten?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Positionen kommen aus getBoundingClientRect statt offsetLeft (bezog sich auf 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) --- src/components/ScheduleCarousel.tsx | 130 ++++++++++++++++++---------- 1 file changed, 84 insertions(+), 46 deletions(-) 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) => ( + + ))} +
+
+ + +
+
+
    {days.map((day, index) => (
  1. { 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[] }) {

  2. ))}
- -
-
- {days.map((day, index) => ( - - ))} -
-
- - -
-
); }