gongxiangp2/components/upload/CertificateDatePicker.tsx

207 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { addDays, addMonths, addYears, differenceInCalendarDays, endOfMonth, endOfWeek, format, isSameDay, isSameMonth, isToday, isValid, parseISO, startOfMonth, startOfWeek, subMonths, subYears } from 'date-fns'
import { CalendarDays, ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react'
import { createPortal } from 'react-dom'
import { useAnchoredPopover } from '@/components/ui/useAnchoredPopover'
import { fieldControlClass } from './upload-field-styles'
const WEEKDAYS = ['一', '二', '三', '四', '五', '六', '日']
function parseDate(value: string): Date | null {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return null
const date = parseISO(value)
return isValid(date) && format(date, 'yyyy-MM-dd') === value ? date : null
}
interface CertificateDatePickerProps {
id: string
label: string
value: string
onChange: (value: string) => void
disabled?: boolean
placeholder?: string
suggestedDate?: string
min?: string
max?: string
className?: string
}
export default function CertificateDatePicker({ id, label, value, onChange, disabled = false, placeholder = '选择日期', suggestedDate = '', min = '', max = '', className = '' }: CertificateDatePickerProps) {
const containerRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const calendarRef = useRef<HTMLDivElement>(null)
const selectedDate = useMemo(() => parseDate(value), [value])
const calendarDate = useMemo(() => selectedDate || parseDate(suggestedDate), [selectedDate, suggestedDate])
const [isOpen, setIsOpen] = useState(false)
const [isMonthPickerOpen, setIsMonthPickerOpen] = useState(false)
const [visibleMonth, setVisibleMonth] = useState(() => startOfMonth(calendarDate || new Date()))
const position = useAnchoredPopover(isOpen, triggerRef, 320, 460)
const outsideRange = (date: Date) => {
const key = format(date, 'yyyy-MM-dd')
return !!((min && key < min) || (max && key > max))
}
useEffect(() => { if (disabled) setIsOpen(false) }, [disabled])
useEffect(() => {
if (calendarDate) setVisibleMonth(startOfMonth(calendarDate))
}, [calendarDate])
useEffect(() => {
if (!isOpen || !calendarRef.current) return
const calendar = calendarRef.current
if (calendar.contains(document.activeElement)) return
const preferred = calendar.querySelector<HTMLButtonElement>(`[data-date="${format(calendarDate || new Date(), 'yyyy-MM-dd')}"]:not([disabled])`)
const firstDay = calendar.querySelector<HTMLButtonElement>(`[data-date^="${format(visibleMonth, 'yyyy-MM')}"]:not([disabled])`)
const focusTarget = preferred || firstDay || calendar.querySelector<HTMLButtonElement>('button:not([disabled])')
focusTarget?.focus()
}, [isOpen, isMonthPickerOpen, calendarDate, visibleMonth])
useEffect(() => {
if (!isOpen) return
const handlePointerDown = (event: PointerEvent) => {
if (!containerRef.current?.contains(event.target as Node) && !calendarRef.current?.contains(event.target as Node)) setIsOpen(false)
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsOpen(false)
triggerRef.current?.focus()
}
}
document.addEventListener('pointerdown', handlePointerDown)
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('pointerdown', handlePointerDown)
document.removeEventListener('keydown', handleKeyDown)
}
}, [isOpen])
const days = useMemo(() => {
const firstDay = startOfWeek(visibleMonth, { weekStartsOn: 1 })
const lastDay = endOfWeek(endOfMonth(visibleMonth), { weekStartsOn: 1 })
return Array.from({ length: differenceInCalendarDays(lastDay, firstDay) + 1 }, (_, index) => addDays(firstDay, index))
}, [visibleMonth])
const selectDate = (date: Date) => {
if (outsideRange(date)) return
onChange(format(date, 'yyyy-MM-dd'))
setIsOpen(false)
triggerRef.current?.focus()
}
return (
<div ref={containerRef} className={`relative min-w-0 ${className}`} onKeyDown={(event) => {
if (event.key === 'Escape' && isOpen) { event.preventDefault(); event.stopPropagation(); setIsOpen(false); triggerRef.current?.focus() }
}}>
<button
ref={triggerRef}
id={id}
type="button"
disabled={disabled}
aria-label={`${label},${selectedDate ? format(selectedDate, 'yyyy年M月d日') : suggestedDate ? placeholder : '未选择'}`}
aria-haspopup="dialog"
aria-expanded={isOpen}
aria-controls={`${id}-calendar`}
onClick={() => {
if (!isOpen) setIsMonthPickerOpen(false)
setIsOpen((open) => !open)
}}
style={{ paddingInline: 12, gap: 8 }}
className={`${fieldControlClass} flex min-h-11 items-center justify-between gap-3 text-left`}
>
<span className={`min-w-0 truncate ${selectedDate ? 'text-[#1a1c1d]' : 'text-[#66758b]'}`}>
{selectedDate ? format(selectedDate, 'yyyy-MM-dd') : placeholder}
</span>
<CalendarDays className="h-[18px] w-[18px] shrink-0 text-[#5c6470]" aria-hidden="true" />
</button>
{isOpen && createPortal(
<div
ref={calendarRef}
id={`${id}-calendar`}
role="dialog"
aria-label={`选择${label}`}
style={position}
onKeyDown={(event) => {
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); setIsOpen(false); triggerRef.current?.focus() }
if (event.key === 'Tab') {
const buttons = Array.from(calendarRef.current?.querySelectorAll<HTMLButtonElement>('button:not([disabled])') || [])
if ((event.shiftKey && event.target === buttons[0]) || (!event.shiftKey && event.target === buttons[buttons.length - 1])) {
event.preventDefault(); event.stopPropagation(); setIsOpen(false); triggerRef.current?.focus()
}
}
const offsets: Record<string, number> = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 }
const key = (event.target as HTMLElement).dataset.date
if (key && offsets[event.key]) {
event.preventDefault()
const next = addDays(parseISO(key), offsets[event.key])
if (outsideRange(next)) return
setVisibleMonth(startOfMonth(next))
requestAnimationFrame(() => calendarRef.current?.querySelector<HTMLButtonElement>(`[data-date="${format(next, 'yyyy-MM-dd')}"]`)?.focus())
}
}}
className="z-[500] overflow-y-auto overscroll-contain rounded-[12px] bg-white p-3 text-[#1a1c1d] shadow-[0_12px_32px_rgba(16,24,40,0.18)] ring-1 ring-[#e2e8f0]"
>
<div className="mb-3 flex items-center justify-between gap-2">
<button type="button" aria-label={isMonthPickerOpen ? '上一年' : '上个月'} onClick={() => setVisibleMonth((month) => isMonthPickerOpen ? subYears(month, 1) : subMonths(month, 1))} className="flex h-9 w-9 items-center justify-center rounded-[8px] text-[#5c6470] hover:bg-[#f3f4f5] hover:text-[#1a1c1d] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]">
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
</button>
<button type="button" aria-label="选择年份和月份" aria-expanded={isMonthPickerOpen} onClick={() => setIsMonthPickerOpen((open) => !open)} className="flex items-center gap-1 rounded-[8px] px-2 py-1 text-sm font-semibold tabular-nums hover:bg-[#f3f4f5] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]">
{format(visibleMonth, isMonthPickerOpen ? 'yyyy年' : 'yyyy年M月')}
<ChevronDown className="h-3.5 w-3.5 text-[#6b7280]" aria-hidden="true" />
</button>
<button type="button" aria-label={isMonthPickerOpen ? '下一年' : '下个月'} onClick={() => setVisibleMonth((month) => isMonthPickerOpen ? addYears(month, 1) : addMonths(month, 1))} className="flex h-9 w-9 items-center justify-center rounded-[8px] text-[#5c6470] hover:bg-[#f3f4f5] hover:text-[#1a1c1d] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]">
<ChevronRight className="h-4 w-4" aria-hidden="true" />
</button>
</div>
{isMonthPickerOpen ? (
<div className="grid grid-cols-3 gap-2 py-2">
{Array.from({ length: 12 }, (_, index) => (
<button key={index} type="button" aria-label={`${index + 1}月`} onClick={() => { setVisibleMonth(new Date(visibleMonth.getFullYear(), index, 1)); setIsMonthPickerOpen(false) }} className={`h-10 rounded-[8px] text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] ${visibleMonth.getMonth() === index ? 'bg-[#1a1c1d] font-semibold text-white' : 'hover:bg-[#f3f4f5]'}`}>
{index + 1}月
</button>
))}
</div>
) : (
<>
<div className="grid grid-cols-7 gap-1 border-b border-[#e7e8ea] pb-1 text-center text-xs font-medium text-[#6b7280]" aria-hidden="true">
{WEEKDAYS.map((day) => <span key={day} className="flex h-7 items-center justify-center">{day}</span>)}
</div>
<div className="mt-1 grid grid-cols-7 gap-1">
{days.map((day) => {
const dateKey = format(day, 'yyyy-MM-dd')
const isSelected = selectedDate ? isSameDay(day, selectedDate) : false
const inMonth = isSameMonth(day, visibleMonth)
return (
<button
key={dateKey}
type="button"
data-date={dateKey}
disabled={outsideRange(day)}
aria-label={format(day, 'yyyy年M月d日')}
aria-pressed={isSelected}
aria-current={isToday(day) ? 'date' : undefined}
onClick={() => selectDate(day)}
className={`flex h-10 items-center justify-center rounded-[8px] text-sm tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] disabled:cursor-not-allowed disabled:opacity-30 ${isSelected ? 'bg-[#1a1c1d] font-semibold text-white' : inMonth ? 'text-[#1a1c1d] hover:bg-[#f3f4f5]' : 'text-[#66758b] hover:bg-[#f3f4f5]'}`}
>
{format(day, 'd')}
</button>
)
})}
</div>
</>
)}
<div className="mt-2 flex items-center justify-between border-t border-[#e7e8ea] pt-2">
<button type="button" onClick={() => { onChange(''); setIsOpen(false); triggerRef.current?.focus() }} className="rounded-[6px] px-2 py-1 text-sm text-[#5c6470] hover:bg-[#f3f4f5] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]">清除</button>
<button type="button" disabled={outsideRange(new Date())} onClick={() => selectDate(new Date())} className="rounded-[6px] px-2 py-1 text-sm font-medium text-[#315f91] hover:bg-[#eef4fb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] disabled:opacity-40">今天</button>
</div>
</div>, document.body
)}
</div>
)
}