313 lines
9.6 KiB
TypeScript
313 lines
9.6 KiB
TypeScript
import { useState, useRef, useEffect, useCallback } from 'react';
|
|
import { Box, Group, Text } from '@mantine/core';
|
|
|
|
interface WheelTimePickerProps {
|
|
value: string; // "HH:mm" format
|
|
onChange: (time: string) => void;
|
|
}
|
|
|
|
function padZero(num: number): string {
|
|
return String(num).padStart(2, '0');
|
|
}
|
|
|
|
const ITEM_HEIGHT = 36;
|
|
const VISIBLE_ITEMS = 5;
|
|
const CONTAINER_HEIGHT = ITEM_HEIGHT * VISIBLE_ITEMS;
|
|
|
|
export function WheelTimePicker({ value, onChange }: WheelTimePickerProps) {
|
|
// 处理空值情况
|
|
const parseInitialValue = () => {
|
|
if (!value || value === ':') return { hours: 0, minutes: 0 };
|
|
const parts = value.split(':');
|
|
if (parts.length !== 2) return { hours: 0, minutes: 0 };
|
|
const h = parseInt(parts[0], 10);
|
|
const m = parseInt(parts[1], 10);
|
|
return {
|
|
hours: isNaN(h) || h < 0 || h > 23 ? 0 : h,
|
|
minutes: isNaN(m) || m < 0 || m > 59 ? 0 : m,
|
|
};
|
|
};
|
|
|
|
const { hours: initialHours, minutes: initialMinutes } = parseInitialValue();
|
|
const [hours, setHours] = useState(initialHours);
|
|
const [minutes, setMinutes] = useState(initialMinutes);
|
|
|
|
const hourScrollRef = useRef<HTMLDivElement>(null);
|
|
const minuteScrollRef = useRef<HTMLDivElement>(null);
|
|
|
|
// 获取中心位置对应的索引
|
|
const getIndexFromScroll = (scrollTop: number): number => {
|
|
const centerOffset = (CONTAINER_HEIGHT - ITEM_HEIGHT) / 2;
|
|
return Math.round((scrollTop - centerOffset) / ITEM_HEIGHT);
|
|
};
|
|
|
|
// 获取索引对应的滚动位置
|
|
const getScrollFromIndex = (index: number): number => {
|
|
const centerOffset = (CONTAINER_HEIGHT - ITEM_HEIGHT) / 2;
|
|
return index * ITEM_HEIGHT + centerOffset;
|
|
};
|
|
|
|
// 同步滚动到选中值
|
|
const syncScrollToValue = useCallback(() => {
|
|
if (hourScrollRef.current) {
|
|
hourScrollRef.current.scrollTop = getScrollFromIndex(hours);
|
|
}
|
|
if (minuteScrollRef.current) {
|
|
minuteScrollRef.current.scrollTop = getScrollFromIndex(minutes);
|
|
}
|
|
}, [hours, minutes]);
|
|
|
|
// 初始化滚动位置
|
|
useEffect(() => {
|
|
syncScrollToValue();
|
|
}, [syncScrollToValue]);
|
|
|
|
// 处理滚动事件
|
|
const handleScroll = useCallback((type: 'hour' | 'minute') => {
|
|
const scrollRef = type === 'hour' ? hourScrollRef : minuteScrollRef;
|
|
if (!scrollRef.current) return;
|
|
|
|
const scrollTop = scrollRef.current.scrollTop;
|
|
const index = getIndexFromScroll(scrollTop);
|
|
|
|
if (type === 'hour') {
|
|
const clampedIndex = Math.max(0, Math.min(23, index));
|
|
if (clampedIndex !== hours) {
|
|
setHours(clampedIndex);
|
|
onChange(`${padZero(clampedIndex)}:${padZero(minutes)}`);
|
|
}
|
|
} else {
|
|
const clampedIndex = Math.max(0, Math.min(59, index));
|
|
if (clampedIndex !== minutes) {
|
|
setMinutes(clampedIndex);
|
|
onChange(`${padZero(hours)}:${padZero(clampedIndex)}`);
|
|
}
|
|
}
|
|
}, [hours, minutes, minutes, onChange]);
|
|
|
|
// 绑定滚动事件
|
|
useEffect(() => {
|
|
const hourEl = hourScrollRef.current;
|
|
const minuteEl = minuteScrollRef.current;
|
|
|
|
if (hourEl) {
|
|
hourEl.addEventListener('scroll', () => handleScroll('hour'));
|
|
}
|
|
if (minuteEl) {
|
|
minuteEl.addEventListener('scroll', () => handleScroll('minute'));
|
|
}
|
|
|
|
return () => {
|
|
if (hourEl) {
|
|
hourEl.removeEventListener('scroll', () => handleScroll('hour'));
|
|
}
|
|
if (minuteEl) {
|
|
minuteEl.removeEventListener('scroll', () => handleScroll('minute'));
|
|
}
|
|
};
|
|
}, [handleScroll]);
|
|
|
|
// 点击选择
|
|
const handleClick = (index: number, type: 'hour' | 'minute') => {
|
|
if (type === 'hour') {
|
|
setHours(index);
|
|
onChange(`${padZero(index)}:${padZero(minutes)}`);
|
|
} else {
|
|
setMinutes(index);
|
|
onChange(`${padZero(hours)}:${padZero(index)}`);
|
|
}
|
|
};
|
|
|
|
const hourOptions = Array.from({ length: 24 }, (_, i) => i);
|
|
const minuteOptions = Array.from({ length: 60 }, (_, i) => i);
|
|
|
|
// 生成带填充的选项数组(用于循环滚动效果)
|
|
const getExtendedOptions = (options: number[], selected: number) => {
|
|
// 让当前选中项居中显示
|
|
const paddingBefore = Math.floor(VISIBLE_ITEMS / 2);
|
|
const paddingAfter = VISIBLE_ITEMS - 1 - paddingBefore;
|
|
|
|
const extended: { value: number; isSelected: boolean }[] = [];
|
|
|
|
// 添加前面的填充(使用末尾元素)
|
|
for (let i = paddingBefore - 1; i >= 0; i--) {
|
|
const value = options[options.length - 1 - i % options.length];
|
|
extended.push({ value, isSelected: false });
|
|
}
|
|
|
|
// 添加当前选项
|
|
options.forEach((opt) => {
|
|
extended.push({ value: opt, isSelected: opt === selected });
|
|
});
|
|
|
|
// 添加后面的填充(使用开头元素)
|
|
for (let i = 1; i <= paddingAfter; i++) {
|
|
const value = options[i % options.length];
|
|
extended.push({ value, isSelected: false });
|
|
}
|
|
|
|
return extended;
|
|
};
|
|
|
|
const extendedHours = getExtendedOptions(hourOptions, hours);
|
|
const extendedMinutes = getExtendedOptions(minuteOptions, minutes);
|
|
|
|
return (
|
|
<Box
|
|
style={{
|
|
background: '#fff',
|
|
borderRadius: 12,
|
|
padding: '8px 0',
|
|
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.08)',
|
|
}}
|
|
>
|
|
<Group gap={0} justify="center">
|
|
{/* 小时列 */}
|
|
<Box
|
|
ref={hourScrollRef}
|
|
style={{
|
|
width: 48,
|
|
height: CONTAINER_HEIGHT,
|
|
overflowY: 'auto',
|
|
textAlign: 'center',
|
|
position: 'relative',
|
|
scrollbarWidth: 'none',
|
|
msOverflowStyle: 'none',
|
|
}}
|
|
css={{
|
|
'&::-webkit-scrollbar': {
|
|
display: 'none',
|
|
},
|
|
}}
|
|
onWheel={(e) => {
|
|
e.preventDefault();
|
|
const delta = e.deltaY > 0 ? ITEM_HEIGHT : -ITEM_HEIGHT;
|
|
const scrollRef = hourScrollRef.current;
|
|
if (scrollRef) {
|
|
const newScrollTop = scrollRef.scrollTop + delta;
|
|
scrollRef.scrollTop = Math.max(0, Math.min(newScrollTop, 24 * ITEM_HEIGHT));
|
|
}
|
|
}}
|
|
>
|
|
{/* 选中区域背景 */}
|
|
<Box
|
|
style={{
|
|
position: 'absolute',
|
|
left: 0,
|
|
right: 0,
|
|
top: '50%',
|
|
height: ITEM_HEIGHT,
|
|
transform: 'translateY(-50%)',
|
|
background: 'rgba(0, 122, 255, 0.08)',
|
|
borderRadius: 8,
|
|
pointerEvents: 'none',
|
|
}}
|
|
/>
|
|
<Box style={{ paddingTop: (CONTAINER_HEIGHT - ITEM_HEIGHT) / 2 }}>
|
|
{extendedHours.map((item, idx) => (
|
|
<Box
|
|
key={`hour-${idx}`}
|
|
onClick={() => handleClick(item.value % 24, 'hour')}
|
|
style={{
|
|
height: ITEM_HEIGHT,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
color: item.isSelected ? '#007AFF' : '#374151',
|
|
fontSize: 14,
|
|
fontWeight: item.isSelected ? 600 : 400,
|
|
letterSpacing: '0.02em',
|
|
cursor: 'pointer',
|
|
userSelect: 'none',
|
|
}}
|
|
>
|
|
{padZero(item.value % 24)}
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
</Box>
|
|
|
|
<Text
|
|
size="lg"
|
|
fw={400}
|
|
style={{
|
|
color: '#666',
|
|
padding: '0 8px',
|
|
fontFeatureSettings: '"tnum"',
|
|
lineHeight: `${CONTAINER_HEIGHT}px`,
|
|
alignSelf: 'flex-start',
|
|
marginTop: (CONTAINER_HEIGHT - ITEM_HEIGHT) / 2,
|
|
}}
|
|
>
|
|
:
|
|
</Text>
|
|
|
|
{/* 分钟列 */}
|
|
<Box
|
|
ref={minuteScrollRef}
|
|
style={{
|
|
width: 48,
|
|
height: CONTAINER_HEIGHT,
|
|
overflowY: 'auto',
|
|
textAlign: 'center',
|
|
position: 'relative',
|
|
scrollbarWidth: 'none',
|
|
msOverflowStyle: 'none',
|
|
}}
|
|
css={{
|
|
'&::-webkit-scrollbar': {
|
|
display: 'none',
|
|
},
|
|
}}
|
|
onWheel={(e) => {
|
|
e.preventDefault();
|
|
const delta = e.deltaY > 0 ? ITEM_HEIGHT : -ITEM_HEIGHT;
|
|
const scrollRef = minuteScrollRef.current;
|
|
if (scrollRef) {
|
|
const newScrollTop = scrollRef.scrollTop + delta;
|
|
scrollRef.scrollTop = Math.max(0, Math.min(newScrollTop, 60 * ITEM_HEIGHT));
|
|
}
|
|
}}
|
|
>
|
|
{/* 选中区域背景 */}
|
|
<Box
|
|
style={{
|
|
position: 'absolute',
|
|
left: 0,
|
|
right: 0,
|
|
top: '50%',
|
|
height: ITEM_HEIGHT,
|
|
transform: 'translateY(-50%)',
|
|
background: 'rgba(0, 122, 255, 0.08)',
|
|
borderRadius: 8,
|
|
pointerEvents: 'none',
|
|
}}
|
|
/>
|
|
<Box style={{ paddingTop: (CONTAINER_HEIGHT - ITEM_HEIGHT) / 2 }}>
|
|
{extendedMinutes.map((item, idx) => (
|
|
<Box
|
|
key={`minute-${idx}`}
|
|
onClick={() => handleClick(item.value % 60, 'minute')}
|
|
style={{
|
|
height: ITEM_HEIGHT,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
color: item.isSelected ? '#007AFF' : '#374151',
|
|
fontSize: 14,
|
|
fontWeight: item.isSelected ? 600 : 400,
|
|
letterSpacing: '0.02em',
|
|
cursor: 'pointer',
|
|
userSelect: 'none',
|
|
}}
|
|
>
|
|
{padZero(item.value % 60)}
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
</Box>
|
|
</Group>
|
|
</Box>
|
|
);
|
|
}
|