Merge pull request #99 from shy3130/fix/datepicker-portal-overflow

fix(datepicker): 弹层改用 Portal+fixed 定位, 修复被布局遮挡
This commit is contained in:
wshy
2026-07-11 10:58:31 +08:00
committed by GitHub
+68 -16
View File
@@ -1,7 +1,15 @@
import { useState, useRef, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { motion, AnimatePresence } from 'framer-motion'
import { Calendar, ChevronLeft, ChevronRight } from 'lucide-react'
/** 弹层固定宽度(px) — 与下方 w-[260px] 保持一致,用于水平边界裁剪计算 */
const POPUP_WIDTH = 260
/** 弹层预估高度(px) — 用于判断是否需要向上翻转 */
const POPUP_HEIGHT = 320
/** 弹层与触发按钮的间距(px) */
const POPUP_GAP = 6
interface DatePickerProps {
value: string // YYYY-MM-DD
onChange: (v: string) => void
@@ -43,7 +51,12 @@ export function DatePicker({
}: DatePickerProps) {
const [open, setOpen] = useState(false)
const [showYearPicker, setShowYearPicker] = useState(false)
const ref = useRef<HTMLDivElement>(null)
// 触发按钮 ref (外部点击检测 + 计算弹层坐标)
const btnRef = useRef<HTMLButtonElement>(null)
// 弹层 portal ref (外部点击检测)
const popRef = useRef<HTMLDivElement>(null)
// 弹层视口坐标 + 展开方向(open 时计算一次,避免滚动时漂移)
const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
// 当前显示的月份
const [viewYear, setViewYear] = useState(() => viewDate(value, min, max).year)
@@ -56,16 +69,50 @@ export function DatePicker({
setViewMonth(next.month)
}, [value, min, max])
// 点击外部关闭
// 打开弹层: 按钮的视口坐标计算 + 智能方向翻转 + 水平边界裁剪
const handleOpen = () => {
if (open) { setOpen(false); return }
if (!btnRef.current) { setOpen(true); return }
const r = btnRef.current.getBoundingClientRect()
const spaceBelow = window.innerHeight - r.bottom
// 下方空间不足 → 向上展开
const dropUp = spaceBelow < POPUP_HEIGHT + POPUP_GAP && r.top > POPUP_HEIGHT + POPUP_GAP
const top = dropUp
? Math.max(8, r.top - POPUP_HEIGHT - POPUP_GAP)
: r.bottom + POPUP_GAP
// 水平: 默认按 align 贴齐按钮左/右边缘, 再做右侧溢出裁剪
const rawLeft = align === 'left' ? r.left : r.right - POPUP_WIDTH
const left = Math.max(8, Math.min(rawLeft, window.innerWidth - POPUP_WIDTH - 8))
setPos({ top, left })
setOpen(true)
}
// 点击外部关闭 (Portal 下弹层不在 ref 树内, 分别判断按钮与弹层)
useEffect(() => {
if (!open) return
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
const t = e.target as Node
if (btnRef.current?.contains(t)) return
if (popRef.current?.contains(t)) return
setOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
// 滚动 / resize 时关闭弹层 (fixed 定位不跟随滚动,关闭比重算更可靠)
useEffect(() => {
if (!open) return
const close = () => setOpen(false)
// capture: true → 捕获到任意祖先滚动容器的 scroll
window.addEventListener('scroll', close, true)
window.addEventListener('resize', close)
return () => {
window.removeEventListener('scroll', close, true)
window.removeEventListener('resize', close)
}
}, [open])
const prevMonth = () => {
if (viewMonth === 0) { setViewMonth(11); setViewYear(viewYear - 1) }
else setViewMonth(viewMonth - 1)
@@ -109,11 +156,12 @@ export function DatePicker({
const today = todayStr()
return (
<div ref={ref} className={`relative inline-flex ${className}`}>
<div className={`relative inline-flex ${className}`}>
{/* 触发按钮 */}
<button
ref={btnRef}
type="button"
onClick={() => setOpen(!open)}
onClick={handleOpen}
className={`inline-flex items-center gap-1.5 h-7 px-2.5 rounded-input border border-border
bg-elevated hover:border-accent/50 text-xs text-foreground num
focus:outline-none focus:border-accent/60 transition-colors duration-150 cursor-pointer ${buttonClassName}`}
@@ -122,17 +170,20 @@ export function DatePicker({
<span className={value ? undefined : 'text-muted'}>{displayLabel}</span>
</button>
{/* 弹出日历 */}
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, y: -4, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -4, scale: 0.97 }}
transition={{ duration: 0.15, ease: [0.16, 1, 0.3, 1] }}
className={`absolute ${align === 'left' ? 'left-0' : 'right-0'} top-full mt-1.5 z-50 w-[260px] rounded-card border border-border
bg-surface shadow-[0_8px_30px_rgba(0,0,0,0.4)] p-3`}
>
{/* 弹出日历 — Portal 到 body, 逃逸祖先 overflow 裁剪与 framer-motion transform 包含块 */}
{createPortal(
<AnimatePresence>
{open && pos && (
<motion.div
ref={popRef}
initial={{ opacity: 0, y: -4, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -4, scale: 0.97 }}
transition={{ duration: 0.15, ease: [0.16, 1, 0.3, 1] }}
style={{ position: 'fixed', top: pos.top, left: pos.left }}
className="z-[9999] w-[260px] rounded-card border border-border
bg-surface shadow-[0_8px_30px_rgba(0,0,0,0.4)] p-3"
>
{/* 月份导航 */}
<div className="flex items-center justify-between mb-2">
<button
@@ -230,6 +281,7 @@ export function DatePicker({
</motion.div>
)}
</AnimatePresence>
, document.body)}
</div>
)
}