refactor: turn countdowns into a time capsule
This commit is contained in:
@@ -13,13 +13,18 @@ describe('countdown modal accessibility', () => {
|
|||||||
expect(source).toContain(':inert="open || Boolean(detailItem)"')
|
expect(source).toContain(':inert="open || Boolean(detailItem)"')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses a compact timeline and moves row actions into a detail sheet', () => {
|
it('uses one time-capsule focus and moves row actions into a detail sheet', () => {
|
||||||
expect(source).toContain('class="countdown-timeline"')
|
expect(source).toContain('class="countdown-timeline"')
|
||||||
expect(source).toContain('class="countdown-row"')
|
expect(source).toContain('class="countdown-row"')
|
||||||
expect(source).toContain('@click="openDetail(item)"')
|
expect(source).toContain('class="countdown-focus"')
|
||||||
|
expect(source).toContain('@click="openDetail(focusItem)"')
|
||||||
|
expect(source).toContain('item.id !== focusItem.value?.id')
|
||||||
|
expect(source).toContain('`kind-${item.kind}`')
|
||||||
|
expect(source).toContain(':class="`kind-${focusItem.kind}`"')
|
||||||
expect(source).toContain('class="countdown-detail-sheet"')
|
expect(source).toContain('class="countdown-detail-sheet"')
|
||||||
expect(source).toContain('ref="detailCloseButton"')
|
expect(source).toContain('ref="detailCloseButton"')
|
||||||
expect(source).toContain('detailCloseButton.value?.focus()')
|
expect(source).toContain('detailCloseButton.value?.focus()')
|
||||||
|
expect(source).toContain('@keydown="trapDetailFocus"')
|
||||||
expect(source).not.toContain('class="countdown-actions"')
|
expect(source).not.toContain('class="countdown-actions"')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -70,16 +70,17 @@ function sorted(itemsToSort: Countdown[]) {
|
|||||||
return [...itemsToSort].sort((a, b) => Number(b.pinned) - Number(a.pinned) || a.days - b.days || a.title.localeCompare(b.title, 'zh-Hans-CN'))
|
return [...itemsToSort].sort((a, b) => Number(b.pinned) - Number(a.pinned) || a.days - b.days || a.title.localeCompare(b.title, 'zh-Hans-CN'))
|
||||||
}
|
}
|
||||||
const upcomingItems = computed(() => sorted(items.value.filter((item) => item.days >= 0)))
|
const upcomingItems = computed(() => sorted(items.value.filter((item) => item.days >= 0)))
|
||||||
const focusItem = computed(() => upcomingItems.value[0] ?? sorted(items.value)[0] ?? null)
|
const focusItem = computed(() => sorted(items.value).find((item) => item.pinned) ?? upcomingItems.value[0] ?? sorted(items.value)[0] ?? null)
|
||||||
const countdownSummary = computed(() => {
|
const countdownSummary = computed(() => {
|
||||||
if (!items.value.length) return '还没有记录重要日子'
|
if (!items.value.length) return '还没有记录重要日子'
|
||||||
const pinned = items.value.filter((item) => item.pinned).length
|
const pinned = items.value.filter((item) => item.pinned).length
|
||||||
return pinned ? `${items.value.length} 个重要日子 · ${pinned} 个置顶` : `${items.value.length} 个重要日子`
|
return pinned ? `${items.value.length} 个重要日子 · ${pinned} 个置顶` : `${items.value.length} 个重要日子`
|
||||||
})
|
})
|
||||||
const countdownGroups = computed<CountdownGroup[]>(() => {
|
const countdownGroups = computed<CountdownGroup[]>(() => {
|
||||||
const recent = sorted(items.value.filter((item) => item.days >= 0 && item.days <= 30))
|
const withoutFocus = items.value.filter((item) => item.id !== focusItem.value?.id)
|
||||||
const later = sorted(items.value.filter((item) => item.days > 30))
|
const recent = sorted(withoutFocus.filter((item) => item.days >= 0 && item.days <= 30))
|
||||||
const past = sorted(items.value.filter((item) => item.days < 0))
|
const later = sorted(withoutFocus.filter((item) => item.days > 30))
|
||||||
|
const past = sorted(withoutFocus.filter((item) => item.days < 0))
|
||||||
return [
|
return [
|
||||||
{ key:'recent', title:'最近', items:recent },
|
{ key:'recent', title:'最近', items:recent },
|
||||||
{ key:'later', title:'稍后', items:later },
|
{ key:'later', title:'稍后', items:later },
|
||||||
@@ -149,6 +150,17 @@ function formatDateShort(value:string){const [y,m,d]=value.split('-');return `${
|
|||||||
function repeatLabel(value:Countdown['repeat_rule']){return({none:'不重复',weekly:'每周',monthly:'每月',yearly:'每年'})[value]}
|
function repeatLabel(value:Countdown['repeat_rule']){return({none:'不重复',weekly:'每周',monthly:'每月',yearly:'每年'})[value]}
|
||||||
function openDetail(item:Countdown){detailItem.value=item;previousFocus=document.activeElement instanceof HTMLElement ? document.activeElement : null;void nextTick(() => detailCloseButton.value?.focus())}
|
function openDetail(item:Countdown){detailItem.value=item;previousFocus=document.activeElement instanceof HTMLElement ? document.activeElement : null;void nextTick(() => detailCloseButton.value?.focus())}
|
||||||
function closeDetail(){detailItem.value=null;void nextTick(() => previousFocus?.focus())}
|
function closeDetail(){detailItem.value=null;void nextTick(() => previousFocus?.focus())}
|
||||||
|
function trapDetailFocus(event: KeyboardEvent) {
|
||||||
|
if (event.key !== 'Tab') return
|
||||||
|
const dialog = event.currentTarget as HTMLElement
|
||||||
|
const controls = Array.from(dialog.querySelectorAll<HTMLElement>('button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"])'))
|
||||||
|
.filter((item) => !item.hasAttribute('disabled'))
|
||||||
|
if (!controls.length) return
|
||||||
|
const first = controls[0]
|
||||||
|
const last = controls[controls.length - 1]
|
||||||
|
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus() }
|
||||||
|
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
|
||||||
|
}
|
||||||
function openFromEmpty(){openCountdownComposer()}
|
function openFromEmpty(){openCountdownComposer()}
|
||||||
function openCountdownComposer(origin?: { x: number; y: number }){if(origin)composerOrigin.value=origin;detailItem.value=null;editingId.value=null;showAdvanced.value=false;form.value=freshForm();open.value=true;focusDialog()}
|
function openCountdownComposer(origin?: { x: number; y: number }){if(origin)composerOrigin.value=origin;detailItem.value=null;editingId.value=null;showAdvanced.value=false;form.value=freshForm();open.value=true;focusDialog()}
|
||||||
defineExpose({ openCountdownComposer })
|
defineExpose({ openCountdownComposer })
|
||||||
@@ -162,28 +174,28 @@ onBeforeUnmount(() => { previousFocus = null })
|
|||||||
<header class="countdown-hero"><small>{{countdownSummary}}</small></header>
|
<header class="countdown-hero"><small>{{countdownSummary}}</small></header>
|
||||||
<p v-if="error" class="inline-error">{{error}}</p>
|
<p v-if="error" class="inline-error">{{error}}</p>
|
||||||
<div v-if="items.length" class="countdown-layout">
|
<div v-if="items.length" class="countdown-layout">
|
||||||
<div class="countdown-timeline">
|
<button v-if="focusItem" type="button" class="countdown-focus" :class="`kind-${focusItem.kind}`" @click="openDetail(focusItem)">
|
||||||
|
<small>{{focusItem.pinned?'置顶的重要日子':'下一个重要日子'}}</small>
|
||||||
|
<div class="countdown-number"><strong>{{focusItem.days===0?'今天':Math.abs(focusItem.days)}}</strong><span v-if="focusItem.days!==0">天</span></div>
|
||||||
|
<h3>{{focusItem.title}}</h3><b class="countdown-copy">{{countdownDayText(focusItem.days)}}</b><p>{{primaryDate(focusItem)}}</p>
|
||||||
|
</button>
|
||||||
|
<div v-if="countdownGroups.length" class="countdown-timeline">
|
||||||
<section v-for="group in countdownGroups" :key="group.key" class="countdown-group">
|
<section v-for="group in countdownGroups" :key="group.key" class="countdown-group">
|
||||||
<h3>{{group.title}}</h3>
|
<h3>{{group.title}}</h3>
|
||||||
<button v-for="item in group.items" :key="item.id" type="button" class="countdown-row" :class="{ pinned:item.pinned, today:item.days===0, past:item.days<0 }" @click="openDetail(item)">
|
<button v-for="item in group.items" :key="item.id" type="button" class="countdown-row" :class="[`kind-${item.kind}`, { pinned:item.pinned, today:item.days===0, past:item.days<0 }]" @click="openDetail(item)">
|
||||||
<span class="countdown-main"><b>{{item.title}}</b><small>{{primaryDate(item)}}<template v-if="secondaryDate(item)"> · {{secondaryDate(item)}}</template></small><span class="countdown-badges"><em v-for="badge in visibleBadges(item)" :key="badge">{{badge}}</em></span></span>
|
<span class="countdown-main"><b>{{item.title}}</b><small>{{primaryDate(item)}}<template v-if="secondaryDate(item)"> · {{secondaryDate(item)}}</template></small><span class="countdown-badges"><em v-for="badge in visibleBadges(item)" :key="badge">{{badge}}</em></span></span>
|
||||||
<span class="countdown-state"><strong>{{item.days===0?'今天':Math.abs(item.days)}}</strong><small>{{countdownDayText(item.days)}}</small></span>
|
<span class="countdown-state"><strong>{{item.days===0?'今天':Math.abs(item.days)}}</strong><small>{{countdownDayText(item.days)}}</small></span>
|
||||||
<Pin v-if="item.pinned" class="pinned-icon"/>
|
<Pin v-if="item.pinned" class="pinned-icon"/>
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<aside v-if="focusItem" class="countdown-focus">
|
|
||||||
<small>最近的重要日子</small><h3>{{focusItem.title}}</h3>
|
|
||||||
<div class="countdown-number"><strong>{{focusItem.days===0?'今天':Math.abs(focusItem.days)}}</strong><span v-if="focusItem.days!==0">天</span></div>
|
|
||||||
<b class="countdown-copy">{{countdownDayText(focusItem.days)}}</b><p>{{primaryDate(focusItem)}}</p>
|
|
||||||
</aside>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="!busy" class="countdown-empty"><CalendarHeart/><b>还没有重要日子</b><span>生日、纪念日,或一场期待已久的旅行</span><button type="button" class="primary-small" @click="openFromEmpty">添加第一个重要日子</button></div>
|
<div v-else-if="!busy" class="countdown-empty"><CalendarHeart/><b>还没有重要日子</b><span>生日、纪念日,或一场期待已久的旅行</span><button type="button" class="primary-small" @click="openFromEmpty">添加第一个重要日子</button></div>
|
||||||
<button v-if="archived.length" class="archived-toggle" @click="showArchived=!showArchived"><ArchiveRestore/>已归档({{archived.length}})</button>
|
<button v-if="archived.length" class="archived-toggle" @click="showArchived=!showArchived"><ArchiveRestore/>已归档({{archived.length}})</button>
|
||||||
<div v-if="showArchived" class="archived-countdowns"><article v-for="item in archived" :key="item.id"><b>{{item.title}}</b><small>{{formatDateShort(item.display_date)}}<template v-if="item.lunar_text"> · {{item.lunar_text}}</template><template v-if="item.calendar_mode==='lunar'"> · 农历</template></small><button @click="restore(item)"><ArchiveRestore/>恢复</button><button class="danger-text" @click="purge(item)"><Trash2/>永久删除</button></article></div>
|
<div v-if="showArchived" class="archived-countdowns"><article v-for="item in archived" :key="item.id"><b>{{item.title}}</b><small>{{formatDateShort(item.display_date)}}<template v-if="item.lunar_text"> · {{item.lunar_text}}</template><template v-if="item.calendar_mode==='lunar'"> · 农历</template></small><button @click="restore(item)"><ArchiveRestore/>恢复</button><button class="danger-text" @click="purge(item)"><Trash2/>永久删除</button></article></div>
|
||||||
</div>
|
</div>
|
||||||
<Transition name="countdown-detail">
|
<Transition name="countdown-detail">
|
||||||
<div v-if="detailItem" class="countdown-detail-mask" @click.self="closeDetail"><article class="countdown-detail-sheet" role="dialog" aria-modal="true" aria-labelledby="countdown-detail-title" @keydown.esc="closeDetail">
|
<div v-if="detailItem" class="countdown-detail-mask" @click.self="closeDetail"><article class="countdown-detail-sheet" role="dialog" aria-modal="true" aria-labelledby="countdown-detail-title" @keydown.esc="closeDetail" @keydown="trapDetailFocus">
|
||||||
<header><div><small>重要日子详情</small><h3 id="countdown-detail-title">{{detailItem.title}}</h3></div><button ref="detailCloseButton" type="button" aria-label="关闭详情" @click="closeDetail"><X/></button></header>
|
<header><div><small>重要日子详情</small><h3 id="countdown-detail-title">{{detailItem.title}}</h3></div><button ref="detailCloseButton" type="button" aria-label="关闭详情" @click="closeDetail"><X/></button></header>
|
||||||
<div class="countdown-detail-days"><strong>{{detailItem.days===0?'今天':Math.abs(detailItem.days)}}</strong><span v-if="detailItem.days!==0">天</span><b>{{countdownDayText(detailItem.days)}}</b></div>
|
<div class="countdown-detail-days"><strong>{{detailItem.days===0?'今天':Math.abs(detailItem.days)}}</strong><span v-if="detailItem.days!==0">天</span><b>{{countdownDayText(detailItem.days)}}</b></div>
|
||||||
<dl><div><dt>日期</dt><dd>{{primaryDate(detailItem)}}</dd></div><div v-if="secondaryDate(detailItem)"><dt>换算</dt><dd>{{secondaryDate(detailItem)}}</dd></div><div><dt>类型</dt><dd>{{countdownKindLabel(detailItem.kind)}} · {{detailItem.calendar_mode==='lunar'?'农历':'公历'}} · {{repeatBadge(detailItem) || '不重复'}}</dd></div></dl>
|
<dl><div><dt>日期</dt><dd>{{primaryDate(detailItem)}}</dd></div><div v-if="secondaryDate(detailItem)"><dt>换算</dt><dd>{{secondaryDate(detailItem)}}</dd></div><div><dt>类型</dt><dd>{{countdownKindLabel(detailItem.kind)}} · {{detailItem.calendar_mode==='lunar'?'农历':'公历'}} · {{repeatBadge(detailItem) || '不重复'}}</dd></div></dl>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user