207 lines
11 KiB
TypeScript
207 lines
11 KiB
TypeScript
'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>
|
||
)
|
||
}
|