314 lines
9.6 KiB
JavaScript
314 lines
9.6 KiB
JavaScript
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 animationRef = useRef(0);
|
|
const suppressClickRef = useRef(false);
|
|
const dragStateRef = useRef({
|
|
active: false,
|
|
started: false,
|
|
captured: false,
|
|
pointerId: null,
|
|
pointerType: 'mouse',
|
|
startX: 0,
|
|
startY: 0,
|
|
startOffset: 0,
|
|
startedOnLink: false,
|
|
});
|
|
|
|
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;
|
|
if (!strip) return;
|
|
|
|
const onPointerDown = (event) => {
|
|
if (event.pointerType === 'mouse' && event.button !== 0) return;
|
|
|
|
if (animationRef.current) {
|
|
cancelAnimationFrame(animationRef.current);
|
|
animationRef.current = 0;
|
|
}
|
|
|
|
dragStateRef.current.active = true;
|
|
dragStateRef.current.started = false;
|
|
dragStateRef.current.captured = false;
|
|
dragStateRef.current.pointerId = event.pointerId;
|
|
dragStateRef.current.pointerType = event.pointerType || 'mouse';
|
|
dragStateRef.current.startX = event.clientX;
|
|
dragStateRef.current.startY = event.clientY;
|
|
dragStateRef.current.startOffset = offset;
|
|
dragStateRef.current.startedOnLink = !!event.target.closest('.nb-react-pill');
|
|
|
|
setDragging(false);
|
|
};
|
|
|
|
const onPointerMove = (event) => {
|
|
const state = dragStateRef.current;
|
|
if (!state.active || state.pointerId !== event.pointerId) return;
|
|
|
|
const dx = event.clientX - state.startX;
|
|
const dy = event.clientY - state.startY;
|
|
const threshold = state.pointerType === 'touch'
|
|
? 12
|
|
: (state.startedOnLink ? 24 : 12);
|
|
if (!state.started) {
|
|
if (Math.abs(dx) <= threshold || Math.abs(dx) <= Math.abs(dy)) {
|
|
return;
|
|
}
|
|
|
|
state.started = true;
|
|
if (!state.captured && strip.setPointerCapture) {
|
|
try {
|
|
strip.setPointerCapture(event.pointerId);
|
|
state.captured = true;
|
|
} catch (_) {
|
|
state.captured = false;
|
|
}
|
|
}
|
|
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.startedOnLink = false;
|
|
state.pointerId = null;
|
|
setDragging(false);
|
|
|
|
if (state.captured && strip.releasePointerCapture) {
|
|
try { strip.releasePointerCapture(event.pointerId); } catch (_) { /* no-op */ }
|
|
}
|
|
state.captured = false;
|
|
};
|
|
|
|
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);
|
|
|
|
return () => {
|
|
strip.removeEventListener('pointerdown', onPointerDown);
|
|
strip.removeEventListener('pointermove', onPointerMove);
|
|
strip.removeEventListener('pointerup', onPointerUpOrCancel);
|
|
strip.removeEventListener('pointercancel', onPointerUpOrCancel);
|
|
strip.removeEventListener('click', onClickCapture, true);
|
|
};
|
|
}, [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 (
|
|
<div className={`nb-react-carousel ${atStart ? 'at-start' : ''} ${atEnd ? 'at-end' : ''} ${className}`.trim()}>
|
|
<div className="nb-react-fade nb-react-fade--left" aria-hidden="true" />
|
|
<button
|
|
type="button"
|
|
className="nb-react-arrow nb-react-arrow--left"
|
|
aria-label="Previous categories"
|
|
onClick={() => moveToPill(-1)}
|
|
>
|
|
<svg viewBox="0 0 20 20" fill="currentColor" className="w-[18px] h-[18px]" aria-hidden="true"><path fillRule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clipRule="evenodd"/></svg>
|
|
</button>
|
|
|
|
<div className="nb-react-viewport" ref={viewportRef} role="list" aria-label={ariaLabel}>
|
|
<div
|
|
ref={stripRef}
|
|
className={`nb-react-strip ${dragging ? 'is-dragging' : ''}`}
|
|
style={{ transform: `translateX(${offset}px)` }}
|
|
>
|
|
{items.map((item) => (
|
|
<a
|
|
key={`${item.href}-${item.label}`}
|
|
href={item.href}
|
|
className={`nb-react-pill ${item.active ? 'nb-react-pill--active' : ''}`}
|
|
aria-current={item.active ? 'page' : 'false'}
|
|
data-active-pill={item.active ? 'true' : undefined}
|
|
draggable={false}
|
|
onDragStart={(event) => event.preventDefault()}
|
|
>
|
|
{item.label}
|
|
</a>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="nb-react-fade nb-react-fade--right" aria-hidden="true" />
|
|
<button
|
|
type="button"
|
|
className="nb-react-arrow nb-react-arrow--right"
|
|
aria-label="Next categories"
|
|
onClick={() => moveToPill(1)}
|
|
>
|
|
<svg viewBox="0 0 20 20" fill="currentColor" className="w-[18px] h-[18px]" aria-hidden="true"><path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd"/></svg>
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|