import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import './CategoryPillCarousel.css'; function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } export default function CategoryPillCarousel({ items = [], ariaLabel = 'Filter by category', className = '', }) { const viewportRef = useRef(null); const stripRef = useRef(null); const dragZoneRef = useRef(null); const animationRef = useRef(0); const suppressClickRef = useRef(false); const dragStateRef = useRef({ active: false, started: false, pointerId: null, pointerType: 'mouse', startX: 0, startOffset: 0, }); const [offset, setOffset] = useState(0); const [dragging, setDragging] = useState(false); const [maxScroll, setMaxScroll] = useState(0); const activeIndex = useMemo(() => { const idx = items.findIndex((item) => !!item.active); return idx >= 0 ? idx : 0; }, [items]); const maxOffset = useCallback(() => { const viewport = viewportRef.current; const strip = stripRef.current; if (!viewport || !strip) return 0; return Math.max(0, strip.scrollWidth - viewport.clientWidth); }, []); const recalcBounds = useCallback(() => { const max = maxOffset(); setMaxScroll(max); setOffset((prev) => clamp(prev, -max, 0)); }, [maxOffset]); const moveTo = useCallback((nextOffset) => { const max = maxOffset(); const clamped = clamp(nextOffset, -max, 0); setOffset(clamped); }, [maxOffset]); const animateTo = useCallback((targetOffset, duration = 380) => { if (animationRef.current) { cancelAnimationFrame(animationRef.current); animationRef.current = 0; } const max = maxOffset(); const target = clamp(targetOffset, -max, 0); const start = offset; const delta = target - start; if (Math.abs(delta) < 1) { setOffset(target); return; } const startTime = performance.now(); setDragging(false); const easeOutCubic = (t) => 1 - ((1 - t) ** 3); const step = (now) => { const elapsed = now - startTime; const progress = Math.min(1, elapsed / duration); const eased = easeOutCubic(progress); setOffset(start + (delta * eased)); if (progress < 1) { animationRef.current = requestAnimationFrame(step); } else { animationRef.current = 0; setOffset(target); } }; animationRef.current = requestAnimationFrame(step); }, [maxOffset, offset]); const moveToPill = useCallback((direction) => { const strip = stripRef.current; if (!strip) return; const pills = Array.from(strip.querySelectorAll('.nb-react-pill')); if (!pills.length) return; const viewLeft = -offset; if (direction > 0) { const next = pills.find((pill) => pill.offsetLeft > viewLeft + 6); if (next) animateTo(-next.offsetLeft); else animateTo(-maxOffset()); return; } for (let i = pills.length - 1; i >= 0; i -= 1) { const left = pills[i].offsetLeft; if (left < viewLeft - 6) { animateTo(-left); return; } } animateTo(0); }, [animateTo, maxOffset, offset]); useEffect(() => { const viewport = viewportRef.current; const strip = stripRef.current; if (!viewport || !strip) return; const activeEl = strip.querySelector('[data-active-pill="true"]'); if (!activeEl) { moveTo(0); return; } const centered = -(activeEl.offsetLeft - (viewport.clientWidth / 2) + (activeEl.offsetWidth / 2)); moveTo(centered); recalcBounds(); }, [activeIndex, items, moveTo, recalcBounds]); useEffect(() => { const viewport = viewportRef.current; const strip = stripRef.current; if (!viewport || !strip) return; const measure = () => recalcBounds(); const rafId = requestAnimationFrame(measure); window.addEventListener('resize', measure, { passive: true }); let ro = null; if ('ResizeObserver' in window) { ro = new ResizeObserver(measure); ro.observe(viewport); ro.observe(strip); } return () => { cancelAnimationFrame(rafId); window.removeEventListener('resize', measure); if (ro) ro.disconnect(); }; }, [items, recalcBounds]); useEffect(() => { const strip = stripRef.current; const dragZone = dragZoneRef.current; if (!strip || !dragZone) return; const onPointerDown = (event) => { const isMouse = event.pointerType === 'mouse'; const fromDragZone = event.currentTarget === dragZone; if (isMouse && !fromDragZone) return; if (animationRef.current) { cancelAnimationFrame(animationRef.current); animationRef.current = 0; } dragStateRef.current.active = true; dragStateRef.current.started = false; dragStateRef.current.pointerId = event.pointerId; dragStateRef.current.pointerType = event.pointerType || 'mouse'; dragStateRef.current.startX = event.clientX; dragStateRef.current.startOffset = offset; setDragging(false); if (strip.setPointerCapture) { try { strip.setPointerCapture(event.pointerId); } catch (_) { /* no-op */ } } }; const onPointerMove = (event) => { const state = dragStateRef.current; if (!state.active || state.pointerId !== event.pointerId) return; const dx = event.clientX - state.startX; const threshold = state.pointerType === 'touch' ? 12 : 8; if (!state.started) { if (Math.abs(dx) <= threshold) { return; } state.started = true; setDragging(true); } if (state.started) { event.preventDefault(); } moveTo(state.startOffset + dx); }; const onPointerUpOrCancel = (event) => { const state = dragStateRef.current; if (!state.active || state.pointerId !== event.pointerId) return; suppressClickRef.current = state.started; state.active = false; state.started = false; state.pointerId = null; setDragging(false); if (strip.releasePointerCapture) { try { strip.releasePointerCapture(event.pointerId); } catch (_) { /* no-op */ } } }; const onClickCapture = (event) => { if (!suppressClickRef.current) return; event.preventDefault(); event.stopPropagation(); suppressClickRef.current = false; }; strip.addEventListener('pointerdown', onPointerDown); strip.addEventListener('pointermove', onPointerMove); strip.addEventListener('pointerup', onPointerUpOrCancel); strip.addEventListener('pointercancel', onPointerUpOrCancel); strip.addEventListener('click', onClickCapture, true); dragZone.addEventListener('pointerdown', onPointerDown); dragZone.addEventListener('pointermove', onPointerMove); dragZone.addEventListener('pointerup', onPointerUpOrCancel); dragZone.addEventListener('pointercancel', onPointerUpOrCancel); return () => { strip.removeEventListener('pointerdown', onPointerDown); strip.removeEventListener('pointermove', onPointerMove); strip.removeEventListener('pointerup', onPointerUpOrCancel); strip.removeEventListener('pointercancel', onPointerUpOrCancel); strip.removeEventListener('click', onClickCapture, true); dragZone.removeEventListener('pointerdown', onPointerDown); dragZone.removeEventListener('pointermove', onPointerMove); dragZone.removeEventListener('pointerup', onPointerUpOrCancel); dragZone.removeEventListener('pointercancel', onPointerUpOrCancel); }; }, [moveTo, offset]); useEffect(() => () => { if (animationRef.current) { cancelAnimationFrame(animationRef.current); animationRef.current = 0; } }, []); const max = maxScroll; const atStart = offset >= -2; const atEnd = offset <= -(max - 2); return (