Fix carousel: swipe snap, disabled state, pointer events, resize

- Snap clamps after rounding so it can't overshoot max on narrow viewports
- atEnd computed inline each render from live measureMax()
- forceUpdate on resize guarantees re-render even when left doesn't change
- Track gets explicit width (TOTAL_W) so gap areas are hit-testable
- Resize handler always calls setLeft (clamp or no-op) to keep state consistent

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Philip Peterson 2026-06-03 01:54:29 -07:00
parent d693e87e02
commit bccac386a7

View file

@ -1,5 +1,5 @@
import { h, render } from "https://esm.sh/preact@10"; import { h, render } from "https://esm.sh/preact@10";
import { useState, useEffect, useRef } from "https://esm.sh/preact@10/hooks"; import { useState, useEffect, useReducer, useRef } from "https://esm.sh/preact@10/hooks";
import { html } from "https://esm.sh/htm@3/preact"; import { html } from "https://esm.sh/htm@3/preact";
const TESTIMONIALS = [ const TESTIMONIALS = [
@ -39,6 +39,7 @@ function Testimonials() {
const containerRef = useRef(null); const containerRef = useRef(null);
const trackRef = useRef(null); const trackRef = useRef(null);
const [left, setLeft] = useState(0); const [left, setLeft] = useState(0);
const [, forceUpdate] = useReducer(function (n) { return n + 1; }, 0);
function measureMax() { function measureMax() {
if (!containerRef.current) return 0; if (!containerRef.current) return 0;
@ -63,9 +64,8 @@ function Testimonials() {
clearTimeout(timer); clearTimeout(timer);
timer = setTimeout(function () { timer = setTimeout(function () {
var max = measureMax(); var max = measureMax();
if (-leftRef.current > max) { setLeft(function (l) { return Math.min(0, Math.max(-max, l)); });
setLeft(-max); forceUpdate();
}
}, 150); }, 150);
} }
window.addEventListener("resize", onResize); window.addEventListener("resize", onResize);
@ -75,19 +75,35 @@ function Testimonials() {
}; };
}, []); }, []);
var touchStartX = useRef(0); var drag = useRef(null); // null when idle, {x, left} when dragging
var onTouchStart = function (e) { var onPointerDown = function (e) {
touchStartX.current = e.touches[0].clientX; drag.current = { x: e.clientX, left: leftRef.current };
trackRef.current.style.transition = "none";
e.currentTarget.setPointerCapture(e.pointerId);
}; };
var onTouchEnd = function (e) { var onPointerMove = function (e) {
var delta = e.changedTouches[0].clientX - touchStartX.current; if (!drag.current) return;
if (delta < -50) next(); var delta = e.clientX - drag.current.x;
else if (delta > 50) prev(); var max = measureMax();
setLeft(Math.min(0, Math.max(-max, drag.current.left + delta)));
};
var onPointerUp = function (e) {
if (!drag.current) return;
drag.current = null;
trackRef.current.style.transition = "left 0.5s ease";
var max = measureMax();
setLeft(function (l) {
var clamped = Math.min(0, Math.max(-max, l));
var snapped = -Math.round(-clamped / STEP) * STEP;
return Math.max(-max, snapped);
});
}; };
var atStart = left >= 0; var atStart = left >= 0;
var atEnd = !!containerRef.current && left <= -measureMax();
return html` return html`
<div ref=${wrapRef} style="overflow:hidden"> <div ref=${wrapRef} style="overflow:hidden">
@ -108,7 +124,7 @@ function Testimonials() {
>${String.fromCharCode(8592)}</button> >${String.fromCharCode(8592)}</button>
<button <button
onClick=${next} onClick=${next}
disabled=${false} disabled=${atEnd}
aria-label="Next testimonials" aria-label="Next testimonials"
class="w-10 h-10 rounded-full border border-gray-300 flex items-center justify-center text-gray-500 hover:bg-gray-100 transition-colors disabled:opacity-30" class="w-10 h-10 rounded-full border border-gray-300 flex items-center justify-center text-gray-500 hover:bg-gray-100 transition-colors disabled:opacity-30"
>${String.fromCharCode(8594)}</button> >${String.fromCharCode(8594)}</button>
@ -117,9 +133,10 @@ function Testimonials() {
</div> </div>
<div <div
ref=${trackRef} ref=${trackRef}
onTouchStart=${onTouchStart} onPointerDown=${onPointerDown}
onTouchEnd=${onTouchEnd} onPointerMove=${onPointerMove}
style=${{ position: "relative", top: 0, left: left + "px", transition: "left 0.5s ease", display: "flex", gap: GAP + "px", paddingBottom: "2px" }} onPointerUp=${onPointerUp}
style=${{ position: "relative", top: 0, left: left + "px", transition: "left 0.5s ease", display: "flex", gap: GAP + "px", paddingBottom: "2px", width: TOTAL_W + "px" }}
> >
${TESTIMONIALS.map(function (t, i) { return html` ${TESTIMONIALS.map(function (t, i) { return html`
<div key=${i} style=${{ width: CARD_W + "px", flexShrink: 0 }} class="border border-gray-200 rounded-lg p-6 flex flex-col gap-5 bg-white"> <div key=${i} style=${{ width: CARD_W + "px", flexShrink: 0 }} class="border border-gray-200 rounded-lg p-6 flex flex-col gap-5 bg-white">