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:
1 parent
df105aafca
commit
51b6d79bbf
1 file changed
+84
-46
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user