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
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
const app = readFileSync('src/App.vue', 'utf8')
const picker = readFileSync('src/components/CalendarPicker.vue', 'utf8')
const css = readFileSync('src/style.css', 'utf8')
describe('add-task CalendarPicker integration', () => {
it('replaces only the composer native date input with the custom picker', () => {
expect(app).toContain("import CalendarPicker from './components/CalendarPicker.vue'")
expect(app).toContain('ref="composeDateButton"')
expect(app).toContain('aria-haspopup="dialog" :aria-expanded="composeCalendarOpen" @click="composeCalendarOpen=true"')
expect(app).toContain('watch(composeDueAt, (value) => {')
expect(app).toContain("if (!value) { composeHasTime.value = false; composeRepeat.value = 'none' }")
expect(app).not.toContain('ref="composeDatePicker"')
expect(app).not.toContain('function openComposeDuePicker()')
expect(app).toContain('type="datetime-local"')
expect(app).toContain('v-model="composeTime" type="time"')
})
it('uses a fixed desktop popover and opaque mobile bottom sheet above task compose', () => {
expect(css).toContain('.calendar-picker-layer{position:fixed;z-index:90;inset:0;pointer-events:none}')
expect(css).toContain('.calendar-picker{position:fixed;width:328px;background:#fffdf8;border:1px solid #ded6ca;')
expect(css).toContain('.calendar-picker__grid button.selected{background:var(--accent);color:#fff}')
expect(css).toContain('@media(max-width:600px){.calendar-picker-layer{pointer-events:auto;background:rgba(45,38,31,.3);display:flex;align-items:flex-end}')
expect(css).toContain('height:min(520px,72dvh)')
expect(css).not.toContain('.calendar-picker{backdrop-filter')
})
it('exposes dialog, grid, selected/current states and draft completion actions', () => {
expect(picker).toContain('role="dialog" aria-modal="true"')
expect(picker).toContain('role="grid"')
expect(picker).toContain(':aria-selected="day.isSelected"')
expect(picker).toContain(':aria-current="day.isToday?\'date\':undefined"')
expect(picker).toContain('data-action="today"')
expect(picker).toContain('data-action="clear"')
expect(picker).toContain('data-action="cancel"')
expect(picker).toContain('data-action="done"')
})
})
@@ -0,0 +1,86 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, h, nextTick, ref } from 'vue'
import CalendarPicker from './CalendarPicker.vue'
const mounted: Array<() => void> = []
async function mountPicker(initial = '2026-09-12', anchorRect = { left: 100, top: 100, bottom: 144, width: 120, height: 44 }) {
const host = document.createElement('div')
const anchor = document.createElement('button')
document.body.append(host, anchor)
Object.defineProperty(anchor, 'getBoundingClientRect', { value: () => anchorRect })
const open = ref(true)
const updates: string[] = []
const app = createApp({ setup: () => () => h(CalendarPicker, { open: open.value, modelValue: initial, anchor, 'onUpdate:open': (value: boolean) => { open.value = value }, 'onUpdate:modelValue': (value: string) => updates.push(value) }) })
app.mount(host)
mounted.push(() => { app.unmount(); host.remove(); anchor.remove() })
await nextTick()
return { open, updates }
}
beforeEach(() => {
vi.stubGlobal('matchMedia', vi.fn().mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() }))
})
afterEach(() => { mounted.splice(0).forEach((cleanup) => cleanup()); document.body.innerHTML = ''; vi.unstubAllGlobals() })
describe('CalendarPicker', () => {
it('keeps selection as a draft until 完成', async () => {
const { updates } = await mountPicker()
const next = document.querySelector<HTMLButtonElement>('[data-date="2026-09-13"]')!
next.click()
await nextTick()
expect(updates).toEqual([])
document.querySelector<HTMLButtonElement>('[data-action="done"]')!.click()
expect(updates).toEqual(['2026-09-13'])
})
it('cancels without changing the original value', async () => {
const { updates } = await mountPicker()
document.querySelector<HTMLButtonElement>('[data-date="2026-09-13"]')!.click()
document.querySelector<HTMLButtonElement>('[data-action="cancel"]')!.click()
expect(updates).toEqual([])
})
it('supports grid keyboard navigation, selection and Escape', async () => {
const { open, updates } = await mountPicker()
const selected = document.querySelector<HTMLButtonElement>('[aria-selected="true"]')!
selected.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }))
await nextTick()
expect(document.activeElement?.getAttribute('data-date')).toBe('2026-09-13')
document.activeElement?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
await nextTick()
expect(updates).toEqual([])
document.querySelector('[role="dialog"]')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await nextTick()
expect(open.value).toBe(false)
expect(updates).toEqual([])
})
it('positions from the rendered popover height and shifts vertically when space is tight', async () => {
vi.stubGlobal('innerHeight', 500)
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
if (this.classList.contains('calendar-picker')) return { left: 0, top: 0, bottom: 388, right: 328, width: 328, height: 388, x: 0, y: 0, toJSON: () => ({}) }
return { left: 0, top: 0, bottom: 0, right: 0, width: 0, height: 0, x: 0, y: 0, toJSON: () => ({}) }
})
await mountPicker('2026-09-12', { left: 100, top: 220, bottom: 264, width: 120, height: 44 })
expect((document.querySelector('[role="dialog"]') as HTMLElement).style.top).toBe('100px')
})
it('moves focus into the newly visible month when month controls are used', async () => {
await mountPicker('2026-09-30')
document.querySelector<HTMLButtonElement>('[aria-label="下个月"]')!.click()
await nextTick()
expect(document.querySelectorAll('[role="gridcell"][tabindex="0"]')).toHaveLength(1)
expect(document.activeElement?.getAttribute('data-date')).toBe('2026-10-30')
})
it('marks today and selected days with accessible grid state', async () => {
vi.setSystemTime(new Date(2026, 8, 9, 12))
await mountPicker()
expect(document.querySelector('[role="dialog"][aria-modal="true"]')).not.toBeNull()
expect(document.querySelector('[role="grid"]')).not.toBeNull()
expect(document.querySelector('[data-date="2026-09-09"]')?.getAttribute('aria-current')).toBe('date')
expect(document.querySelector('[data-date="2026-09-12"]')?.getAttribute('aria-selected')).toBe('true')
vi.useRealTimers()
})
})
+115
View File
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { ChevronLeft, ChevronRight, X } from 'lucide-vue-next'
import { buildCalendarMonth, formatLocalDate, moveCalendarFocus, parseLocalDate, positionCalendarPopover } from '../lib/calendar-picker'
const props = defineProps<{ open: boolean; modelValue: string; anchor: HTMLElement | null }>()
const emit = defineEmits<{ 'update:open': [value: boolean]; 'update:modelValue': [value: string] }>()
const dialog = ref<HTMLElement | null>(null)
const draft = ref('')
const focusedDate = ref(new Date())
const visibleMonth = ref(new Date())
const compact = ref(false)
const position = ref({ left: 12, top: 12 })
const weekdays = ['一', '二', '三', '四', '五', '六', '日']
const title = computed(() => new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: 'long' }).format(visibleMonth.value))
const days = computed(() => buildCalendarMonth(visibleMonth.value, new Date(), parseLocalDate(draft.value)))
const dialogStyle = computed(() => compact.value ? undefined : { left: `${position.value.left}px`, top: `${position.value.top}px` })
let previousFocus: HTMLElement | null = null
function close() { emit('update:open', false) }
function choose(value: string) {
draft.value = value
const date = parseLocalDate(value)!
focusedDate.value = date
visibleMonth.value = new Date(date.getFullYear(), date.getMonth(), 1)
}
function finish() { emit('update:modelValue', draft.value); close() }
function chooseToday() { choose(formatLocalDate(new Date())) }
function changeMonth(amount: number) {
const targetMonth = new Date(visibleMonth.value.getFullYear(), visibleMonth.value.getMonth() + amount, 1)
const finalDay = new Date(targetMonth.getFullYear(), targetMonth.getMonth() + 1, 0).getDate()
focusDay(new Date(targetMonth.getFullYear(), targetMonth.getMonth(), Math.min(focusedDate.value.getDate(), finalDay)))
}
function focusDay(date: Date) {
focusedDate.value = date
visibleMonth.value = new Date(date.getFullYear(), date.getMonth(), 1)
nextTick(() => dialog.value?.querySelector<HTMLElement>(`[data-date="${formatLocalDate(date)}"]`)?.focus())
}
function onGridKey(event: KeyboardEvent) {
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End'].includes(event.key)) {
event.preventDefault()
focusDay(moveCalendarFocus(focusedDate.value, event.key))
} else if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
choose(formatLocalDate(focusedDate.value))
}
}
function onDialogKey(event: KeyboardEvent) {
if (event.key === 'Escape') { event.preventDefault(); close(); return }
if (event.key !== 'Tab' || !dialog.value) return
const focusables = [...dialog.value.querySelectorAll<HTMLElement>('button:not([disabled])')]
if (!focusables.length) return
const first = focusables[0]
const last = focusables[focusables.length - 1]
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus() }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
}
function onOutside(event: PointerEvent) {
if (!props.open || compact.value || dialog.value?.contains(event.target as Node) || props.anchor?.contains(event.target as Node)) return
close()
}
function syncLayout() {
compact.value = window.matchMedia('(max-width: 600px)').matches
if (!compact.value && props.anchor && dialog.value) {
position.value = positionCalendarPopover(props.anchor.getBoundingClientRect(), window.innerWidth, window.innerHeight, dialog.value.getBoundingClientRect().height)
}
}
watch(() => props.open, async (open) => {
if (open) {
previousFocus = document.activeElement as HTMLElement | null
const initial = parseLocalDate(props.modelValue) || new Date()
draft.value = props.modelValue
focusedDate.value = initial
visibleMonth.value = new Date(initial.getFullYear(), initial.getMonth(), 1)
syncLayout()
document.addEventListener('pointerdown', onOutside)
window.addEventListener('resize', syncLayout)
await nextTick()
syncLayout()
dialog.value?.querySelector<HTMLElement>(`[data-date="${formatLocalDate(initial)}"]`)?.focus()
} else {
document.removeEventListener('pointerdown', onOutside)
window.removeEventListener('resize', syncLayout)
previousFocus?.focus()
}
}, { immediate: true })
onBeforeUnmount(() => { document.removeEventListener('pointerdown', onOutside); window.removeEventListener('resize', syncLayout) })
</script>
<template>
<Teleport to="body">
<div v-if="open" class="calendar-picker-layer" :class="{compact}" @click.self="compact && close()">
<section ref="dialog" class="calendar-picker" :class="{compact}" :style="dialogStyle" role="dialog" aria-modal="true" aria-labelledby="calendar-picker-title" @keydown="onDialogKey">
<header class="calendar-picker__header">
<button type="button" aria-label="上个月" @click="changeMonth(-1)"><ChevronLeft/></button>
<h3 id="calendar-picker-title">{{ title }}</h3>
<button type="button" aria-label="下个月" @click="changeMonth(1)"><ChevronRight/></button>
<button v-if="compact" class="calendar-picker__close" type="button" aria-label="关闭日期选择器" @click="close"><X/></button>
</header>
<div class="calendar-picker__weekdays" aria-hidden="true"><span v-for="day in weekdays" :key="day">{{ day }}</span></div>
<div class="calendar-picker__grid" role="grid" aria-label="日期" @keydown="onGridKey">
<button v-for="day in days" :key="day.value" type="button" role="gridcell" :data-date="day.value" :class="{outside:!day.inMonth,today:day.isToday,selected:day.isSelected}" :aria-label="day.value" :aria-selected="day.isSelected" :aria-current="day.isToday?'date':undefined" :tabindex="day.value===formatLocalDate(focusedDate)?0:-1" @focus="focusedDate=day.date" @click="choose(day.value)">{{ day.date.getDate() }}</button>
</div>
<footer class="calendar-picker__footer">
<button type="button" data-action="today" @click="chooseToday">今天</button>
<button type="button" data-action="clear" @click="draft=''">清除</button>
<span/>
<button type="button" data-action="cancel" @click="close">取消</button>
<button type="button" class="calendar-picker__done" data-action="done" @click="finish">完成</button>
</footer>
</section>
</div>
</Teleport>
</template>