feat: add responsive task calendar picker
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 3m29s

This commit is contained in:
2026-09-09 17:44:49 +08:00
parent 33790aaeb7
commit 30705e1cec
8 changed files with 391 additions and 22 deletions
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import {
addCalendarDays,
buildCalendarMonth,
formatLocalDate,
moveCalendarFocus,
parseLocalDate,
positionCalendarPopover,
} from './calendar-picker'
describe('CalendarPicker local calendar dates', () => {
it('round-trips a local natural day without UTC conversion', () => {
const value = parseLocalDate('2026-09-09')
expect(value && [value.getFullYear(), value.getMonth(), value.getDate()]).toEqual([2026, 8, 9])
expect(formatLocalDate(value!)).toBe('2026-09-09')
expect(parseLocalDate('2026-02-30')).toBeNull()
})
it('builds a six-week Monday-first grid including adjacent months', () => {
const days = buildCalendarMonth(new Date(2026, 8, 1), new Date(2026, 8, 9), new Date(2026, 8, 12))
expect(days).toHaveLength(42)
expect(formatLocalDate(days[0].date)).toBe('2026-08-31')
expect(formatLocalDate(days[41].date)).toBe('2026-10-11')
expect(days.find((day) => day.isToday)?.value).toBe('2026-09-09')
expect(days.find((day) => day.isSelected)?.value).toBe('2026-09-12')
expect(days[0].inMonth).toBe(false)
})
it('moves keyboard focus by day, week, month and week boundary', () => {
const start = new Date(2026, 8, 9)
expect(formatLocalDate(addCalendarDays(start, 1))).toBe('2026-09-10')
expect(formatLocalDate(moveCalendarFocus(start, 'ArrowUp'))).toBe('2026-09-02')
expect(formatLocalDate(moveCalendarFocus(start, 'PageDown'))).toBe('2026-10-09')
expect(formatLocalDate(moveCalendarFocus(start, 'Home'))).toBe('2026-09-07')
expect(formatLocalDate(moveCalendarFocus(start, 'End'))).toBe('2026-09-13')
})
it('flips above on bottom collision and shifts inside viewport edges', () => {
expect(positionCalendarPopover({ left: 900, top: 700, bottom: 744, width: 100, height: 44 }, 1024, 768, 388)).toEqual({ left: 684, top: 304 })
expect(positionCalendarPopover({ left: 4, top: 20, bottom: 64, width: 100, height: 44 }, 1024, 768, 388)).toEqual({ left: 12, top: 72 })
})
it('uses the rendered height and vertically shifts when neither side fits', () => {
expect(positionCalendarPopover({ left: 100, top: 220, bottom: 264, width: 120, height: 44 }, 1024, 500, 388)).toEqual({ left: 100, top: 100 })
expect(positionCalendarPopover({ left: 100, top: 120, bottom: 164, width: 120, height: 44 }, 1024, 300, 388)).toEqual({ left: 100, top: 12 })
})
})
+85
View File
@@ -0,0 +1,85 @@
export type CalendarDay = {
date: Date
value: string
inMonth: boolean
isToday: boolean
isSelected: boolean
}
export type AnchorRect = Pick<DOMRect, 'left' | 'top' | 'bottom' | 'width' | 'height'>
const DAY_MS = 86_400_000
export function parseLocalDate(value: string): Date | null {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value)
if (!match) return null
const year = Number(match[1])
const month = Number(match[2]) - 1
const day = Number(match[3])
const date = new Date(year, month, day)
return date.getFullYear() === year && date.getMonth() === month && date.getDate() === day ? date : null
}
export function formatLocalDate(date: Date): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
export function addCalendarDays(date: Date, amount: number): Date {
const next = new Date(date.getFullYear(), date.getMonth(), date.getDate())
next.setDate(next.getDate() + amount)
return next
}
function addCalendarMonths(date: Date, amount: number): Date {
const wantedDay = date.getDate()
const next = new Date(date.getFullYear(), date.getMonth() + amount, 1)
const finalDay = new Date(next.getFullYear(), next.getMonth() + 1, 0).getDate()
next.setDate(Math.min(wantedDay, finalDay))
return next
}
export function moveCalendarFocus(date: Date, key: string): Date {
if (key === 'ArrowLeft') return addCalendarDays(date, -1)
if (key === 'ArrowRight') return addCalendarDays(date, 1)
if (key === 'ArrowUp') return addCalendarDays(date, -7)
if (key === 'ArrowDown') return addCalendarDays(date, 7)
if (key === 'PageUp') return addCalendarMonths(date, -1)
if (key === 'PageDown') return addCalendarMonths(date, 1)
const mondayIndex = (date.getDay() + 6) % 7
if (key === 'Home') return addCalendarDays(date, -mondayIndex)
if (key === 'End') return addCalendarDays(date, 6 - mondayIndex)
return new Date(date)
}
export function buildCalendarMonth(month: Date, today: Date, selected: Date | null): CalendarDay[] {
const first = new Date(month.getFullYear(), month.getMonth(), 1)
const start = addCalendarDays(first, -((first.getDay() + 6) % 7))
const todayValue = formatLocalDate(today)
const selectedValue = selected ? formatLocalDate(selected) : ''
return Array.from({ length: 42 }, (_, index) => {
const date = addCalendarDays(start, index)
const value = formatLocalDate(date)
return { date, value, inMonth: date.getMonth() === month.getMonth(), isToday: value === todayValue, isSelected: value === selectedValue }
})
}
export function positionCalendarPopover(anchor: AnchorRect, viewportWidth: number, viewportHeight: number, height: number) {
const width = 328
const gap = 8
const edge = 12
const left = Math.min(Math.max(anchor.left, edge), viewportWidth - width - edge)
const below = anchor.bottom + gap
const above = anchor.top - height - gap
const maxTop = Math.max(edge, viewportHeight - height - edge)
const top = below + height <= viewportHeight - edge
? below
: above >= edge
? above
: Math.min(Math.max(anchor.bottom - anchor.height + gap, edge), maxTop)
return { left, top }
}
export const calendarDayDistance = (from: Date, to: Date) => Math.round((Date.UTC(to.getFullYear(), to.getMonth(), to.getDate()) - Date.UTC(from.getFullYear(), from.getMonth(), from.getDate())) / DAY_MS)