feat(calendar): 年月拨盘(iOS 闹钟式滚轮)替换工具条的月份文字

- 新增 components/MonthYearWheel.jsx:年、月两列滚轮
  · scroll-snap 吸附 + 指针按住拖动 + 鼠标滚轮/触摸板,停稳 140ms 后落值
  · 滚动过程中实时算居中行,中间行放大加粗高亮、相邻行缩小淡出(像苹果拨盘)
  · 点某一行直接跳到该行;「回到今天」一键回当月
  · 年份范围默认前后 12/20 年,两侧夹在视口内,下方放不下自动翻到上方
- 两个入口:
  · 工具条上的「2026年9月」由 <strong> 换成按钮,点击弹出拨盘,再点一次收起
  · 日期跳转浮层里的标题同样可点:点击后浮层收起、同位弹出拨盘
- 拨盘选完年月,主日历与跳转浮层同步过去

修掉一个竞态:两列在同一帧内先后停稳时,后一次会读到渲染期闭包里的旧值,
把前一次的结果覆盖(滚到 2028年12月 会退回 2026年12月)。
改为组件内用 ref 保存最新年月、每次落值先更新 ref 再回调。

顺带修正跳转浮层标题多出来的一个右花括号。
This commit is contained in:
Drsakura
2026-09-11 11:06:36 +08:00
parent 4c3ec995b6
commit 0453a49e4e
3 changed files with 294 additions and 3 deletions
+170
View File
@@ -0,0 +1,170 @@
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
/**
* 年月拨盘(iOS 闹钟式滚轮)
* - 两列各自滚动吸附,停稳后把最终值抛给父级(onChange)
* - 支持鼠标滚轮 / 触摸板 / 按住拖动;点某一行直接跳到该行
* - 外部值变化时反向同步滚动位置(且不会打回环)
*/
const ITEM_HEIGHT = 32 // 与 luxury.css 的 --wheel-item 保持一致
const SETTLE_DELAY = 140
function WheelColumn({ values, value, onSettle, format, ariaLabel }) {
const listRef = useRef(null)
const timerRef = useRef(null)
const dragRef = useRef(null)
const frameRef = useRef(0)
const muteRef = useRef(false)
const [dragging, setDragging] = useState(false)
const index = Math.max(0, values.indexOf(value))
const [centered, setCentered] = useState(index)
// 滚动过程中实时算出居中那一行,用来做 iOS 拨盘那样的放大/淡出
const syncCentered = useCallback(() => {
window.cancelAnimationFrame(frameRef.current)
frameRef.current = window.requestAnimationFrame(() => {
const node = listRef.current
if (!node) return
setCentered(Math.min(values.length - 1, Math.max(0, Math.round(node.scrollTop / ITEM_HEIGHT))))
})
}, [values.length])
const snapTo = useCallback((target, behavior) => {
const node = listRef.current
if (!node) return
if (Math.abs(node.scrollTop - target) < 1) return
muteRef.current = true
node.scrollTo({ top: target, behavior })
window.setTimeout(() => { muteRef.current = false }, behavior === 'smooth' ? 340 : 60)
}, [])
// 外部值变了(含父级纠正)就把滚轮挪到对应行
useLayoutEffect(() => {
snapTo(index * ITEM_HEIGHT, 'auto')
}, [index, snapTo])
// 值变化(含外部纠正)后,让居中高亮跟过来
useLayoutEffect(() => {
setCentered(index)
}, [index])
const settle = useCallback(() => {
const node = listRef.current
if (!node) return
const next = Math.min(values.length - 1, Math.max(0, Math.round(node.scrollTop / ITEM_HEIGHT)))
if (values[next] !== value) onSettle(values[next])
snapTo(next * ITEM_HEIGHT, 'smooth')
}, [onSettle, snapTo, value, values])
const handleScroll = () => {
if (muteRef.current || dragRef.current) return
syncCentered()
window.clearTimeout(timerRef.current)
timerRef.current = window.setTimeout(settle, SETTLE_DELAY)
}
useEffect(() => () => {
window.clearTimeout(timerRef.current)
window.cancelAnimationFrame(frameRef.current)
}, [])
const onPointerDown = (event) => {
const node = listRef.current
if (!node || event.button > 0) return
window.clearTimeout(timerRef.current)
dragRef.current = { startY: event.clientY, startTop: node.scrollTop, moved: false }
setDragging(true)
try { node.setPointerCapture(event.pointerId) } catch { /* 忽略不支持捕获的浏览器 */ }
}
const onPointerMove = (event) => {
if (!dragRef.current) return
const node = listRef.current
if (!node) return
const delta = event.clientY - dragRef.current.startY
if (Math.abs(delta) > 3) dragRef.current.moved = true
node.scrollTop = dragRef.current.startTop - delta
syncCentered()
}
const endDrag = () => {
if (!dragRef.current) return
const moved = dragRef.current.moved
dragRef.current = null
setDragging(false)
if (moved) settle()
}
return (
<div className="ymw-col" role="group" aria-label={ariaLabel}>
<div
className={`ymw-list${dragging ? ' is-dragging' : ''}`}
ref={listRef}
onScroll={handleScroll}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
>
<div className="ymw-pad" aria-hidden="true" />
{values.map((item, position) => {
const distance = Math.abs(position - centered)
return (
<button
type="button"
key={item}
className={`ymw-cell${distance === 0 ? ' is-active' : ''}`}
style={{
transform: `scale(${distance === 0 ? 1 : distance === 1 ? 0.92 : 0.84})`,
opacity: distance === 0 ? 1 : distance === 1 ? 0.7 : 0.38
}}
tabIndex={-1}
onClick={() => onSettle(item)}
>
{format(item)}
</button>
)
})}
<div className="ymw-pad" aria-hidden="true" />
</div>
</div>
)
}
export default function MonthYearWheel({ year, month, onChange, onToday, minYear, maxYear }) {
const years = useMemo(() => {
const list = []
for (let y = minYear; y <= maxYear; y += 1) list.push(y)
return list
}, [minYear, maxYear])
const months = useMemo(() => Array.from({ length: 12 }, (_, index) => index + 1), [])
// 两列可能在同一帧内先后停稳,各自只认自己那一列时不能读渲染期闭包,
// 否则后一次会用旧值覆盖前一次(滚到 2028年12月 会退回 2026年12月)。
const latest = useRef({ year, month })
latest.current = { year, month }
const pickYear = useCallback((nextYear) => {
latest.current = { ...latest.current, year: nextYear }
onChange(latest.current.year, latest.current.month)
}, [onChange])
const pickMonth = useCallback((nextMonth) => {
latest.current = { ...latest.current, month: nextMonth }
onChange(latest.current.year, latest.current.month)
}, [onChange])
return (
<div className="ymw-body">
<div className="ymw-drum">
<span className="ymw-highlight" aria-hidden="true" />
<WheelColumn values={years} value={year} onSettle={pickYear} format={item => `${item}`} ariaLabel="年份" />
<span className="ymw-unit">年</span>
<WheelColumn values={months} value={month} onSettle={pickMonth} format={item => `${item}`} ariaLabel="月份" />
<span className="ymw-unit">月</span>
</div>
<button type="button" className="ymw-today" onClick={onToday}>回到今天</button>
</div>
)
}