Files
Webklar.com/components/AnimatedList.tsx
Basilosaurusrex f027651f9b main repo
2025-11-24 18:09:40 +01:00

174 lines
5.5 KiB
TypeScript

import { useRef, useState, useEffect, ReactNode } from 'react';
import { motion, useInView } from 'motion/react';
import './AnimatedList.css';
interface AnimatedItemProps {
children: ReactNode;
delay?: number;
index: number;
onMouseEnter: () => void;
onClick: () => void;
}
const AnimatedItem = ({ children, delay = 0, index, onMouseEnter, onClick }: AnimatedItemProps) => {
const ref = useRef<HTMLDivElement>(null);
const inView = useInView(ref, { amount: 0.5, triggerOnce: false });
return (
<motion.div
ref={ref}
data-index={index}
onMouseEnter={onMouseEnter}
onClick={onClick}
initial={{ scale: 0.7, opacity: 0 }}
animate={inView ? { scale: 1, opacity: 1 } : { scale: 0.7, opacity: 0 }}
transition={{ duration: 0.2, delay }}
style={{ marginBottom: '1rem', cursor: 'pointer' }}
>
{children}
</motion.div>
);
};
interface AnimatedListProps {
items?: string[];
onItemSelect?: (item: string, index: number) => void;
showGradients?: boolean;
enableArrowNavigation?: boolean;
className?: string;
itemClassName?: string;
displayScrollbar?: boolean;
initialSelectedIndex?: number;
}
const AnimatedList = ({
items = [
'Item 1',
'Item 2',
'Item 3',
'Item 4',
'Item 5',
'Item 6',
'Item 7',
'Item 8',
'Item 9',
'Item 10',
'Item 11',
'Item 12',
'Item 13',
'Item 14',
'Item 15'
],
onItemSelect,
showGradients = true,
enableArrowNavigation = true,
className = '',
itemClassName = '',
displayScrollbar = true,
initialSelectedIndex = -1
}: AnimatedListProps) => {
const listRef = useRef<HTMLDivElement>(null);
const [selectedIndex, setSelectedIndex] = useState(initialSelectedIndex);
const [keyboardNav, setKeyboardNav] = useState(false);
const [topGradientOpacity, setTopGradientOpacity] = useState(0);
const [bottomGradientOpacity, setBottomGradientOpacity] = useState(1);
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
setTopGradientOpacity(Math.min(scrollTop / 50, 1));
const bottomDistance = scrollHeight - (scrollTop + clientHeight);
setBottomGradientOpacity(scrollHeight <= clientHeight ? 0 : Math.min(bottomDistance / 50, 1));
};
useEffect(() => {
if (!enableArrowNavigation) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowDown' || (e.key === 'Tab' && !e.shiftKey)) {
e.preventDefault();
setKeyboardNav(true);
setSelectedIndex(prev => Math.min(prev + 1, items.length - 1));
} else if (e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey)) {
e.preventDefault();
setKeyboardNav(true);
setSelectedIndex(prev => Math.max(prev - 1, 0));
} else if (e.key === 'Enter') {
if (selectedIndex >= 0 && selectedIndex < items.length) {
e.preventDefault();
if (onItemSelect) {
onItemSelect(items[selectedIndex], selectedIndex);
}
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [items, selectedIndex, onItemSelect, enableArrowNavigation]);
useEffect(() => {
if (!keyboardNav || selectedIndex < 0 || !listRef.current) return;
const container = listRef.current;
const selectedItem = container.querySelector(`[data-index="${selectedIndex}"]`);
if (selectedItem) {
const extraMargin = 50;
const containerScrollTop = container.scrollTop;
const containerHeight = container.clientHeight;
const itemTop = selectedItem.offsetTop;
const itemBottom = itemTop + selectedItem.offsetHeight;
if (itemTop < containerScrollTop + extraMargin) {
container.scrollTo({ top: itemTop - extraMargin, behavior: 'smooth' });
} else if (itemBottom > containerScrollTop + containerHeight - extraMargin) {
container.scrollTo({
top: itemBottom - containerHeight + extraMargin,
behavior: 'smooth'
});
}
}
setKeyboardNav(false);
}, [selectedIndex, keyboardNav]);
return (
<div className={`scroll-list-container ${className}`}>
<div ref={listRef} className={`scroll-list ${!displayScrollbar ? 'no-scrollbar' : ''}`} onScroll={handleScroll}>
{items.map((item, index) => (
<AnimatedItem
key={index}
delay={0.1}
index={index}
onMouseEnter={() => setSelectedIndex(index)}
onClick={() => {
setSelectedIndex(index);
if (onItemSelect) {
onItemSelect(item, index);
}
}}
>
<div className={`item ${selectedIndex === index ? 'selected' : ''} ${itemClassName}`}>
{typeof item === 'string' && item.includes('\n') ? (
<div>
{item.split('\n').map((line, i) => (
<p key={i} className={i === 0 ? 'item-title' : 'item-description'}>
{line}
</p>
))}
</div>
) : (
<p className="item-text">{item}</p>
)}
</div>
</AnimatedItem>
))}
</div>
{showGradients && (
<>
<div className="top-gradient" style={{ opacity: topGradientOpacity }}></div>
<div className="bottom-gradient" style={{ opacity: bottomGradientOpacity }}></div>
</>
)}
</div>
);
};
export default AnimatedList;