238 lines
7.6 KiB
TypeScript
238 lines
7.6 KiB
TypeScript
/* eslint-disable react-hooks/exhaustive-deps */
|
||
|
||
import React, { useEffect, useRef, useState } from 'react';
|
||
|
||
interface CapsuleSwitchProps {
|
||
options: { label: string; value: string; icon?: React.ReactNode }[];
|
||
active: string;
|
||
onChange: (value: string) => void;
|
||
className?: string;
|
||
}
|
||
|
||
const CapsuleSwitch: React.FC<CapsuleSwitchProps> = ({
|
||
options,
|
||
active,
|
||
onChange,
|
||
className,
|
||
}) => {
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||
const isScrollingRef = useRef(false);
|
||
const scrollTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||
const isDraggingRef = useRef(false);
|
||
const startXRef = useRef(0);
|
||
const scrollLeftRef = useRef(0);
|
||
const hasDraggedRef = useRef(false);
|
||
const [indicatorStyle, setIndicatorStyle] = useState<{
|
||
left: number;
|
||
width: number;
|
||
}>({ left: 0, width: 0 });
|
||
|
||
const activeIndex = options.findIndex((opt) => opt.value === active);
|
||
|
||
// 更新指示器位置(仅更新位置,不触发滚动)
|
||
const updateIndicatorPosition = (autoScroll = false) => {
|
||
if (
|
||
activeIndex >= 0 &&
|
||
buttonRefs.current[activeIndex] &&
|
||
containerRef.current &&
|
||
scrollContainerRef.current
|
||
) {
|
||
const button = buttonRefs.current[activeIndex];
|
||
const scrollContainer = scrollContainerRef.current;
|
||
|
||
if (button) {
|
||
const buttonOffsetLeft = button.offsetLeft;
|
||
const buttonWidth = button.offsetWidth;
|
||
|
||
setIndicatorStyle({
|
||
left: buttonOffsetLeft,
|
||
width: buttonWidth,
|
||
});
|
||
|
||
// 只在需要自动滚动时才执行
|
||
if (autoScroll && !isScrollingRef.current) {
|
||
const buttonRect = button.getBoundingClientRect();
|
||
const scrollContainerRect = scrollContainer.getBoundingClientRect();
|
||
const isVisible =
|
||
buttonRect.left >= scrollContainerRect.left &&
|
||
buttonRect.right <= scrollContainerRect.right;
|
||
|
||
if (!isVisible) {
|
||
// 将选中项滚动到视图中心
|
||
const scrollToPosition =
|
||
buttonOffsetLeft -
|
||
scrollContainer.offsetWidth / 2 +
|
||
buttonWidth / 2;
|
||
scrollContainer.scrollTo({
|
||
left: scrollToPosition,
|
||
behavior: 'smooth',
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
// 组件挂载时立即计算初始位置并滚动到选中项
|
||
useEffect(() => {
|
||
const timeoutId = setTimeout(() => updateIndicatorPosition(true), 0);
|
||
return () => clearTimeout(timeoutId);
|
||
}, []);
|
||
|
||
// 监听选中项变化,自动滚动到新选中项
|
||
useEffect(() => {
|
||
const timeoutId = setTimeout(() => updateIndicatorPosition(true), 0);
|
||
return () => clearTimeout(timeoutId);
|
||
}, [activeIndex]);
|
||
|
||
// 监听滚动事件,仅更新指示器位置
|
||
useEffect(() => {
|
||
const scrollContainer = scrollContainerRef.current;
|
||
if (!scrollContainer) return;
|
||
|
||
const handleScroll = () => {
|
||
// 标记正在滚动
|
||
isScrollingRef.current = true;
|
||
|
||
// 清除之前的超时
|
||
if (scrollTimeoutRef.current) {
|
||
clearTimeout(scrollTimeoutRef.current);
|
||
}
|
||
|
||
// 仅更新指示器位置,不触发自动滚动
|
||
updateIndicatorPosition(false);
|
||
|
||
// 滚动结束后重置标记
|
||
scrollTimeoutRef.current = setTimeout(() => {
|
||
isScrollingRef.current = false;
|
||
}, 150);
|
||
};
|
||
|
||
scrollContainer.addEventListener('scroll', handleScroll, { passive: true });
|
||
return () => {
|
||
scrollContainer.removeEventListener('scroll', handleScroll);
|
||
if (scrollTimeoutRef.current) {
|
||
clearTimeout(scrollTimeoutRef.current);
|
||
}
|
||
};
|
||
}, [activeIndex]);
|
||
|
||
// 鼠标拖动功能
|
||
useEffect(() => {
|
||
const scrollContainer = scrollContainerRef.current;
|
||
if (!scrollContainer) return;
|
||
|
||
const handleMouseDown = (e: MouseEvent) => {
|
||
isDraggingRef.current = true;
|
||
hasDraggedRef.current = false;
|
||
startXRef.current = e.pageX - scrollContainer.offsetLeft;
|
||
scrollLeftRef.current = scrollContainer.scrollLeft;
|
||
scrollContainer.style.cursor = 'grabbing';
|
||
scrollContainer.style.userSelect = 'none';
|
||
};
|
||
|
||
const handleMouseLeave = () => {
|
||
isDraggingRef.current = false;
|
||
scrollContainer.style.cursor = 'grab';
|
||
};
|
||
|
||
const handleMouseUp = () => {
|
||
isDraggingRef.current = false;
|
||
scrollContainer.style.cursor = 'grab';
|
||
// 短暂延迟后重置拖动标记,防止点击事件被触发
|
||
setTimeout(() => {
|
||
hasDraggedRef.current = false;
|
||
}, 50);
|
||
};
|
||
|
||
const handleMouseMove = (e: MouseEvent) => {
|
||
if (!isDraggingRef.current) return;
|
||
e.preventDefault();
|
||
const x = e.pageX - scrollContainer.offsetLeft;
|
||
const walk = (x - startXRef.current) * 1.5; // 调整拖动速度
|
||
|
||
// 如果移动距离超过5px,标记为已拖动
|
||
if (Math.abs(walk) > 5) {
|
||
hasDraggedRef.current = true;
|
||
}
|
||
|
||
scrollContainer.scrollLeft = scrollLeftRef.current - walk;
|
||
};
|
||
|
||
scrollContainer.style.cursor = 'grab';
|
||
scrollContainer.addEventListener('mousedown', handleMouseDown);
|
||
scrollContainer.addEventListener('mouseleave', handleMouseLeave);
|
||
scrollContainer.addEventListener('mouseup', handleMouseUp);
|
||
scrollContainer.addEventListener('mousemove', handleMouseMove);
|
||
|
||
return () => {
|
||
scrollContainer.removeEventListener('mousedown', handleMouseDown);
|
||
scrollContainer.removeEventListener('mouseleave', handleMouseLeave);
|
||
scrollContainer.removeEventListener('mouseup', handleMouseUp);
|
||
scrollContainer.removeEventListener('mousemove', handleMouseMove);
|
||
};
|
||
}, []);
|
||
|
||
return (
|
||
<div
|
||
ref={containerRef}
|
||
className={`relative inline-flex bg-gray-300/80 rounded-full p-1 dark:bg-gray-700 max-w-full ${
|
||
className || ''
|
||
}`}
|
||
>
|
||
{/* 可滚动容器 */}
|
||
<div
|
||
ref={scrollContainerRef}
|
||
className='relative flex overflow-x-auto scrollbar-hide'
|
||
style={{
|
||
scrollbarWidth: 'none',
|
||
msOverflowStyle: 'none',
|
||
}}
|
||
>
|
||
{/* 滑动的白色背景指示器 */}
|
||
{indicatorStyle.width > 0 && (
|
||
<div
|
||
className='absolute top-0 bottom-0 bg-white dark:bg-gray-500 rounded-full shadow-sm transition-all duration-300 ease-out pointer-events-none'
|
||
style={{
|
||
left: `${indicatorStyle.left}px`,
|
||
width: `${indicatorStyle.width}px`,
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{options.map((opt, index) => {
|
||
const isActive = active === opt.value;
|
||
return (
|
||
<button
|
||
key={opt.value}
|
||
ref={(el) => {
|
||
buttonRefs.current[index] = el;
|
||
}}
|
||
onClick={(e) => {
|
||
// 如果正在拖动,阻止点击
|
||
if (hasDraggedRef.current) {
|
||
e.preventDefault();
|
||
return;
|
||
}
|
||
onChange(opt.value);
|
||
}}
|
||
className={`relative z-10 flex items-center justify-center gap-1.5 px-3 py-1 text-xs sm:px-4 sm:py-2 sm:text-sm rounded-full font-medium transition-all duration-200 cursor-pointer whitespace-nowrap flex-shrink-0 ${
|
||
isActive
|
||
? 'text-gray-900 dark:text-gray-100'
|
||
: 'text-gray-700 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100'
|
||
}`}
|
||
>
|
||
{opt.icon && <span className='inline-flex items-center'>{opt.icon}</span>}
|
||
{opt.label}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default CapsuleSwitch;
|