react-vertical-feed
Version:
A React component for creating vertical media feeds (videos) similar to TikTok or Instagram
112 lines (109 loc) • 5.09 kB
JavaScript
import { jsxs, jsx } from 'react/jsx-runtime';
import { useRef, useState, useCallback, useEffect, useMemo } from 'react';
const VerticalFeed = ({ items, onEndReached, loadingComponent, errorComponent, className, style, onItemVisible, onItemHidden, onItemClick, threshold = 0.75, scrollBehavior = 'smooth', renderItemOverlay, }) => {
const containerRef = useRef(null);
const [loadingStates, setLoadingStates] = useState({});
const [errorStates, setErrorStates] = useState({});
const handleMediaLoad = useCallback((index) => {
setLoadingStates(prev => ({ ...prev, [index]: false }));
}, []);
const handleMediaError = useCallback((index) => {
setErrorStates(prev => ({ ...prev, [index]: true }));
setLoadingStates(prev => ({ ...prev, [index]: false }));
}, []);
useEffect(() => {
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
const index = parseInt(entry.target.getAttribute('data-index') || '0', 10);
const item = items[index];
if (entry.isIntersecting) {
const video = entry.target.querySelector('video');
if (video) {
video.play().catch(error => {
console.error('Error playing video:', error);
});
}
onItemVisible?.(item, index);
}
else {
const video = entry.target.querySelector('video');
if (video) {
video.pause();
}
onItemHidden?.(item, index);
}
});
}, {
threshold,
});
const mediaElements = containerRef.current?.querySelectorAll('[data-index]') || [];
mediaElements.forEach(media => observer.observe(media));
return () => {
observer.disconnect();
};
}, [items, onItemVisible, onItemHidden, threshold]);
const handleScroll = useCallback(() => {
if (!containerRef.current || !onEndReached)
return;
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
if (scrollTop + clientHeight >= scrollHeight - 100) {
onEndReached();
}
}, [onEndReached]);
const handleKeyDown = useCallback((e) => {
if (!containerRef.current)
return;
const { scrollTop, clientHeight } = containerRef.current;
const scrollAmount = clientHeight;
if (!containerRef.current.scrollTo)
return;
switch (e.key) {
case 'ArrowDown':
containerRef.current.scrollTo({
top: scrollTop + scrollAmount,
behavior: scrollBehavior,
});
break;
case 'ArrowUp':
containerRef.current.scrollTo({
top: scrollTop - scrollAmount,
behavior: scrollBehavior,
});
break;
}
}, [scrollBehavior]);
const defaultRenderItem = useCallback((item, index) => {
const isLoading = loadingStates[index] ?? true;
const hasError = errorStates[index] ?? false;
return (jsxs("div", { "data-index": index, onClick: () => onItemClick?.(item, index), style: {
height: '100vh',
scrollSnapAlign: 'start',
position: 'relative',
cursor: onItemClick ? 'pointer' : 'default',
}, role: "region", "aria-label": `video ${index + 1}`, children: [isLoading && loadingComponent, hasError && errorComponent, jsx("video", { src: item.src, muted: item.muted ?? true, playsInline: item.playsInline ?? true, controls: item.controls ?? false, autoPlay: item.autoPlay ?? true, onLoadedData: () => handleMediaLoad(index), onError: () => handleMediaError(index), style: {
width: '100%',
height: '100%',
objectFit: 'cover',
display: isLoading || hasError ? 'none' : 'block',
} }), renderItemOverlay && renderItemOverlay(item, index)] }, item.id || index));
}, [
loadingStates,
errorStates,
loadingComponent,
errorComponent,
handleMediaLoad,
handleMediaError,
onItemClick,
renderItemOverlay,
]);
const mediaElements = useMemo(() => items.map((item, index) => defaultRenderItem(item, index)), [items, defaultRenderItem]);
return (jsx("div", { ref: containerRef, onScroll: handleScroll, onKeyDown: handleKeyDown, tabIndex: 0, role: "feed", "aria-label": "Vertical video feed", className: className, style: {
height: '100vh',
overflowY: 'scroll',
scrollSnapType: 'y mandatory',
outline: 'none',
...style,
}, children: mediaElements }));
};
export { VerticalFeed };
//# sourceMappingURL=index.esm.js.map