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
+10 -14
View File
@@ -12,6 +12,7 @@ import { createCompletionPulse } from './lib/completion-motion'
import MvpPanel from './MvpPanel.vue'
import CountdownPanel from './CountdownPanel.vue'
import FloatingAddButton from './components/FloatingAddButton.vue'
import CalendarPicker from './components/CalendarPicker.vue'
type FolderItem = { id: string; name: string }
type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: boolean }
@@ -78,7 +79,8 @@ const composeListId = ref('')
const composeDueAt = ref('')
const composeHasTime = ref(false)
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 composePriority = ref(0)
const composeDescription = ref('')
@@ -110,6 +112,7 @@ function openTaskCompose() {
composeDescription.value = ''
composeRepeat.value = 'none'
composeRepeatConfig.value = defaultRepeatConfig()
composeCalendarOpen.value = false
taskComposeOpen.value = true
nextTick(() => document.querySelector<HTMLInputElement>('.task-compose-input')?.focus())
}
@@ -120,16 +123,6 @@ const composeDueLabel = computed(() => {
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)
})
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() {
composeDueAt.value = ''
composeHasTime.value = false
@@ -142,7 +135,7 @@ function addComposeTime() {
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 }) {
composeOrigin.value = 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 overdueTaskTree = computed(() => groupTaskTree(overdueTasks.value))
let searchTimer: number | undefined
watch(composeDueAt, (value) => {
if (!value) { composeHasTime.value = false; composeRepeat.value = 'none' }
})
watch(query, () => {
if (searchTimer) window.clearTimeout(searchTimer)
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-date-actions" aria-label="截止时间">
<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>
</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>
</div>
<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()
})
})
+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>
+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)
File diff suppressed because one or more lines are too long
+5 -7
View File
@@ -483,12 +483,11 @@ describe('unified floating add interaction', () => {
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('const composeDueLabel = computed')
expect(app).toContain('ref="composeDatePicker"')
expect(app).toContain('aria-label="选择截止日期" tabindex="-1"')
expect(app).toContain('ref="composeDateButton"')
expect(app).toContain('aria-haspopup="dialog" :aria-expanded="composeCalendarOpen"')
expect(app).toContain('class="task-compose-date-chip"')
expect(app).toContain('@click="openComposeDuePicker"')
expect(app).toContain('function openComposeDuePicker()')
expect(app).toContain('picker.showPicker()')
expect(app).toContain('@click="composeCalendarOpen=true"')
expect(app).toContain('<CalendarPicker v-model:open="composeCalendarOpen" v-model="composeDueAt"')
expect(app).toContain('<span>{{ composeDueLabel }}</span>')
expect(app).toContain('v-if="composeDueAt" class="task-compose-date-clear"')
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-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-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('.task-compose-native-picker{')
expect(app).toContain("composeHasTime.value ? composeTime.value : '23:59'")
})