feat: add responsive task calendar picker
This commit is contained in:
+10
-14
@@ -12,6 +12,7 @@ import { createCompletionPulse } from './lib/completion-motion'
|
|||||||
import MvpPanel from './MvpPanel.vue'
|
import MvpPanel from './MvpPanel.vue'
|
||||||
import CountdownPanel from './CountdownPanel.vue'
|
import CountdownPanel from './CountdownPanel.vue'
|
||||||
import FloatingAddButton from './components/FloatingAddButton.vue'
|
import FloatingAddButton from './components/FloatingAddButton.vue'
|
||||||
|
import CalendarPicker from './components/CalendarPicker.vue'
|
||||||
|
|
||||||
type FolderItem = { id: string; name: string }
|
type FolderItem = { id: string; name: string }
|
||||||
type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: boolean }
|
type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: boolean }
|
||||||
@@ -78,7 +79,8 @@ const composeListId = ref('')
|
|||||||
const composeDueAt = ref('')
|
const composeDueAt = ref('')
|
||||||
const composeHasTime = ref(false)
|
const composeHasTime = ref(false)
|
||||||
const composeTime = ref('12:00')
|
const composeTime = ref('12:00')
|
||||||
const composeDatePicker = ref<HTMLInputElement | null>(null)
|
const composeCalendarOpen = ref(false)
|
||||||
|
const composeDateButton = ref<HTMLButtonElement | null>(null)
|
||||||
const composeTimePicker = ref<HTMLInputElement | null>(null)
|
const composeTimePicker = ref<HTMLInputElement | null>(null)
|
||||||
const composePriority = ref(0)
|
const composePriority = ref(0)
|
||||||
const composeDescription = ref('')
|
const composeDescription = ref('')
|
||||||
@@ -110,6 +112,7 @@ function openTaskCompose() {
|
|||||||
composeDescription.value = ''
|
composeDescription.value = ''
|
||||||
composeRepeat.value = 'none'
|
composeRepeat.value = 'none'
|
||||||
composeRepeatConfig.value = defaultRepeatConfig()
|
composeRepeatConfig.value = defaultRepeatConfig()
|
||||||
|
composeCalendarOpen.value = false
|
||||||
taskComposeOpen.value = true
|
taskComposeOpen.value = true
|
||||||
nextTick(() => document.querySelector<HTMLInputElement>('.task-compose-input')?.focus())
|
nextTick(() => document.querySelector<HTMLInputElement>('.task-compose-input')?.focus())
|
||||||
}
|
}
|
||||||
@@ -120,16 +123,6 @@ const composeDueLabel = computed(() => {
|
|||||||
const value = new Date(year, month - 1, day)
|
const value = new Date(year, month - 1, day)
|
||||||
return Number.isNaN(value.getTime()) ? '设置截止日期' : new Intl.DateTimeFormat('zh-CN', { month: 'short', day: 'numeric', weekday: 'short' }).format(value)
|
return Number.isNaN(value.getTime()) ? '设置截止日期' : new Intl.DateTimeFormat('zh-CN', { month: 'short', day: 'numeric', weekday: 'short' }).format(value)
|
||||||
})
|
})
|
||||||
function openComposeDuePicker() {
|
|
||||||
const picker = composeDatePicker.value as (HTMLInputElement & { showPicker?: () => void }) | null
|
|
||||||
if (!picker) return
|
|
||||||
try {
|
|
||||||
if (picker.showPicker) picker.showPicker()
|
|
||||||
else picker.click()
|
|
||||||
} catch {
|
|
||||||
picker.click()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function clearComposeDueDate() {
|
function clearComposeDueDate() {
|
||||||
composeDueAt.value = ''
|
composeDueAt.value = ''
|
||||||
composeHasTime.value = false
|
composeHasTime.value = false
|
||||||
@@ -142,7 +135,7 @@ function addComposeTime() {
|
|||||||
try { picker?.showPicker?.() } catch { picker?.focus() }
|
try { picker?.showPicker?.() } catch { picker?.focus() }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
function closeTaskCompose() { taskComposeOpen.value = false }
|
function closeTaskCompose() { composeCalendarOpen.value = false; taskComposeOpen.value = false }
|
||||||
function activateFloatingAdd(origin: { x: number; y: number }) {
|
function activateFloatingAdd(origin: { x: number; y: number }) {
|
||||||
composeOrigin.value = origin
|
composeOrigin.value = origin
|
||||||
if (activeView.value === 'habits') habitComposer.value?.openHabitComposer(origin)
|
if (activeView.value === 'habits') habitComposer.value?.openHabitComposer(origin)
|
||||||
@@ -305,6 +298,9 @@ const filteredTaskTree = computed(() => groupTaskTree(visibleTasks.value))
|
|||||||
const taskTree = computed(() => filteredTaskTree.value)
|
const taskTree = computed(() => filteredTaskTree.value)
|
||||||
const overdueTaskTree = computed(() => groupTaskTree(overdueTasks.value))
|
const overdueTaskTree = computed(() => groupTaskTree(overdueTasks.value))
|
||||||
let searchTimer: number | undefined
|
let searchTimer: number | undefined
|
||||||
|
watch(composeDueAt, (value) => {
|
||||||
|
if (!value) { composeHasTime.value = false; composeRepeat.value = 'none' }
|
||||||
|
})
|
||||||
watch(query, () => {
|
watch(query, () => {
|
||||||
if (searchTimer) window.clearTimeout(searchTimer)
|
if (searchTimer) window.clearTimeout(searchTimer)
|
||||||
if (!isTaskView(activeView.value)) return
|
if (!isTaskView(activeView.value)) return
|
||||||
@@ -945,10 +941,10 @@ onMounted(bootstrap)
|
|||||||
<div class="task-compose-row"><label>清单<select v-model="composeListId"><option v-for="list in lists" :key="list.id" :value="list.id">{{list.name}}</option></select></label><label>优先级<select v-model.number="composePriority"><option :value="0">无</option><option :value="1">低</option><option :value="2">中</option><option :value="3">高</option></select></label></div>
|
<div class="task-compose-row"><label>清单<select v-model="composeListId"><option v-for="list in lists" :key="list.id" :value="list.id">{{list.name}}</option></select></label><label>优先级<select v-model.number="composePriority"><option :value="0">无</option><option :value="1">低</option><option :value="2">中</option><option :value="3">高</option></select></label></div>
|
||||||
<div class="task-compose-date-actions" aria-label="截止时间">
|
<div class="task-compose-date-actions" aria-label="截止时间">
|
||||||
<div class="task-compose-date-control">
|
<div class="task-compose-date-control">
|
||||||
<button class="task-compose-date-chip" :class="{selected:composeDueAt}" type="button" @click="openComposeDuePicker">
|
<button ref="composeDateButton" class="task-compose-date-chip" :class="{selected:composeDueAt}" type="button" aria-haspopup="dialog" :aria-expanded="composeCalendarOpen" @click="composeCalendarOpen=true">
|
||||||
<CalendarDays/><span>{{ composeDueLabel }}</span>
|
<CalendarDays/><span>{{ composeDueLabel }}</span>
|
||||||
</button>
|
</button>
|
||||||
<input ref="composeDatePicker" v-model="composeDueAt" class="task-compose-native-picker" type="date" aria-label="选择截止日期" tabindex="-1">
|
<CalendarPicker v-model:open="composeCalendarOpen" v-model="composeDueAt" :anchor="composeDateButton"/>
|
||||||
<button v-if="composeDueAt" class="task-compose-date-clear" type="button" aria-label="清除截止日期" @click="clearComposeDueDate"><X/></button>
|
<button v-if="composeDueAt" class="task-compose-date-clear" type="button" aria-label="清除截止日期" @click="clearComposeDueDate"><X/></button>
|
||||||
</div>
|
</div>
|
||||||
<button v-if="composeDueAt && !composeHasTime" class="task-compose-time-add" type="button" @click="addComposeTime">添加时间</button>
|
<button v-if="composeDueAt && !composeHasTime" class="task-compose-time-add" type="button" @click="addComposeTime">添加时间</button>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
@@ -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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
File diff suppressed because one or more lines are too long
@@ -483,12 +483,11 @@ describe('unified floating add interaction', () => {
|
|||||||
it('uses compact date and time chips instead of large visible mobile inputs', () => {
|
it('uses compact date and time chips instead of large visible mobile inputs', () => {
|
||||||
expect(app).toContain("composeDueAt.value = activeView.value === 'today' ? defaultTaskDueAt() : ''")
|
expect(app).toContain("composeDueAt.value = activeView.value === 'today' ? defaultTaskDueAt() : ''")
|
||||||
expect(app).toContain('const composeDueLabel = computed')
|
expect(app).toContain('const composeDueLabel = computed')
|
||||||
expect(app).toContain('ref="composeDatePicker"')
|
expect(app).toContain('ref="composeDateButton"')
|
||||||
expect(app).toContain('aria-label="选择截止日期" tabindex="-1"')
|
expect(app).toContain('aria-haspopup="dialog" :aria-expanded="composeCalendarOpen"')
|
||||||
expect(app).toContain('class="task-compose-date-chip"')
|
expect(app).toContain('class="task-compose-date-chip"')
|
||||||
expect(app).toContain('@click="openComposeDuePicker"')
|
expect(app).toContain('@click="composeCalendarOpen=true"')
|
||||||
expect(app).toContain('function openComposeDuePicker()')
|
expect(app).toContain('<CalendarPicker v-model:open="composeCalendarOpen" v-model="composeDueAt"')
|
||||||
expect(app).toContain('picker.showPicker()')
|
|
||||||
expect(app).toContain('<span>{{ composeDueLabel }}</span>')
|
expect(app).toContain('<span>{{ composeDueLabel }}</span>')
|
||||||
expect(app).toContain('v-if="composeDueAt" class="task-compose-date-clear"')
|
expect(app).toContain('v-if="composeDueAt" class="task-compose-date-clear"')
|
||||||
expect(app).toContain('v-if="composeDueAt && !composeHasTime"')
|
expect(app).toContain('v-if="composeDueAt && !composeHasTime"')
|
||||||
@@ -497,9 +496,8 @@ describe('unified floating add interaction', () => {
|
|||||||
expect(css).toContain('.task-compose-date-control{position:relative;display:flex;align-items:center;min-width:0}')
|
expect(css).toContain('.task-compose-date-control{position:relative;display:flex;align-items:center;min-width:0}')
|
||||||
expect(css).toContain('.task-compose-date-chip{position:relative;display:inline-flex!important')
|
expect(css).toContain('.task-compose-date-chip{position:relative;display:inline-flex!important')
|
||||||
expect(css).toContain('.task-compose-date-chip{position:relative;display:inline-flex!important;grid-template-columns:none!important;align-items:center;flex-direction:row!important;gap:7px!important;width:auto;min-height:44px')
|
expect(css).toContain('.task-compose-date-chip{position:relative;display:inline-flex!important;grid-template-columns:none!important;align-items:center;flex-direction:row!important;gap:7px!important;width:auto;min-height:44px')
|
||||||
expect(css).toContain('.task-compose-native-picker{position:absolute;width:1px!important;height:1px!important;left:12px;top:50%;transform:translateY(-50%)')
|
|
||||||
expect(css).toContain('pointer-events:none')
|
|
||||||
expect(css).not.toContain('@media(max-width:480px){.task-compose-due-row')
|
expect(css).not.toContain('@media(max-width:480px){.task-compose-due-row')
|
||||||
|
expect(css).not.toContain('.task-compose-native-picker{')
|
||||||
expect(app).toContain("composeHasTime.value ? composeTime.value : '23:59'")
|
expect(app).toContain("composeHasTime.value ? composeTime.value : '23:59'")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user