Timeline: Sprungleiste über dem Monat/Jahr-Pill

Ein Tipp auf das Pill öffnet eine Leiste mit allen Jahren, die Ereignisse
enthalten, samt Dichte (Punkte bis drei, sonst Zahl); ab zwölf Jahren
kommt eine Jahrzehnte-Zeile zum Scrubben dazu. Hat das gewählte Jahr
Ereignisse in mehreren Monaten, erscheint eine Monatszeile, in der nur
belegte Monate aktiv sind. Ein Jahr springt sofort, ein Monat zentriert
dessen Ereignisse; Tippen außerhalb oder Escape schließt.

Ersetzt das q-menu im Pill, das durch doppeltes Umschalten nie sichtbar
wurde. Die Timeline liefert dafür einen Jahr/Monat-Index und kann auf
einen Monat zentrieren.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-25 09:31:35 +00:00
parent ab23c9f4ef
commit 250f8390cf
5 changed files with 608 additions and 103 deletions

View file

@ -1,90 +1,268 @@
<template>
<div class="timeline-nav glass--button" role="group" aria-label="Zeitnavigation">
<button
type="button"
class="timeline-nav__arrow"
:disabled="!canPrev"
aria-label="Ein Jahr zurück"
@click="$emit('prev')"
>
<q-icon name="chevron_left" size="22px" />
</button>
<!-- Current position; a tap opens the year list -->
<button
type="button"
class="timeline-nav__label"
:disabled="years.length === 0"
aria-haspopup="listbox"
:aria-expanded="menuOpen"
@click="menuOpen = !menuOpen"
>
<span v-if="month" class="timeline-nav__month">{{ month }}</span>
<span class="timeline-nav__year">{{ year ?? '–' }}</span>
<q-menu
v-model="menuOpen"
anchor="top middle"
self="bottom middle"
:offset="[0, 10]"
class="timeline-nav__menu"
transition-show="jump-up"
transition-hide="jump-down"
<div ref="root" class="timeline-nav">
<!-- Quick-jump strip: folds out above the pill -->
<Transition name="timeline-nav-pop">
<div
v-if="open"
class="timeline-nav__panel glass--panel"
role="dialog"
aria-label="Schnell zu einem Jahr oder Monat springen"
>
<q-list dense role="listbox" class="timeline-nav__years">
<q-item
v-for="entry in years"
:key="entry"
clickable
role="option"
:active="entry === year"
:aria-selected="entry === year"
active-class="timeline-nav__year-item--active"
class="timeline-nav__year-item"
@click.stop="jumpTo(entry)"
<!-- Decades, only when the years alone would be a long strip -->
<div v-if="decades.length > 1" class="timeline-nav__decades" role="tablist" aria-label="Jahrzehnte">
<button
v-for="decade in decades"
:key="decade"
type="button"
role="tab"
class="timeline-nav__decade"
:class="{ 'timeline-nav__decade--current': decade === currentDecade }"
:aria-selected="decade === currentDecade"
@click="showDecade(decade)"
>
<q-item-section>{{ entry }}</q-item-section>
</q-item>
</q-list>
</q-menu>
</button>
{{ decade }}er
</button>
</div>
<button
type="button"
class="timeline-nav__arrow"
:disabled="!canNext"
aria-label="Ein Jahr vor"
@click="$emit('next')"
>
<q-icon name="chevron_right" size="22px" />
</button>
<div ref="yearStrip" class="timeline-nav__strip" role="listbox" aria-label="Jahre">
<button
v-for="entry in yearIndex"
:key="entry.year"
type="button"
role="option"
class="timeline-nav__chip"
:class="{
'timeline-nav__chip--current': entry.year === year,
'timeline-nav__chip--expanded': entry.year === expandedYear
}"
:aria-selected="entry.year === year"
:aria-label="`${entry.year}, ${entry.count} ${entry.count === 1 ? 'Ereignis' : 'Ereignisse'}`"
:data-year="entry.year"
@click="selectYear(entry)"
>
<span class="timeline-nav__chip-year">{{ entry.year }}</span>
<span class="timeline-nav__chip-count" aria-hidden="true">
<template v-if="entry.count <= 3">
<i v-for="n in entry.count" :key="n" class="timeline-nav__chip-dot" />
</template>
<template v-else>{{ entry.count }}</template>
</span>
</button>
</div>
<!-- Months of the chosen year, once it spreads over more than one -->
<div
v-if="expandedEntry && expandedEntry.months.length > 1"
class="timeline-nav__months"
role="listbox"
:aria-label="`Monate ${expandedYear}`"
>
<button
v-for="(name, index) in MONTHS_SHORT"
:key="name"
type="button"
role="option"
class="timeline-nav__month-cell"
:class="{ 'timeline-nav__month-cell--current': expandedYear === year && index === monthIndex }"
:disabled="!monthCount(index)"
:aria-selected="expandedYear === year && index === monthIndex"
:aria-label="monthCount(index) ? `${MONTHS_LONG[index]} ${expandedYear}, ${monthCount(index)} ${monthCount(index) === 1 ? 'Ereignis' : 'Ereignisse'}` : `${MONTHS_LONG[index]}, keine Ereignisse`"
@click="selectMonth(index)"
>
{{ name }}
<i v-if="monthCount(index)" class="timeline-nav__month-dot" />
</button>
</div>
</div>
</Transition>
<div class="timeline-nav__pill glass--button" role="group" aria-label="Zeitnavigation">
<button
type="button"
class="timeline-nav__arrow"
:disabled="!canPrev"
aria-label="Ein Jahr zurück"
@click="$emit('prev')"
>
<q-icon name="chevron_left" size="22px" />
</button>
<!-- Current position; a tap opens the quick-jump strip -->
<button
type="button"
class="timeline-nav__label"
:disabled="yearIndex.length === 0"
aria-haspopup="dialog"
:aria-expanded="open"
@click="toggle"
>
<span v-if="month" class="timeline-nav__month">{{ month }}</span>
<span class="timeline-nav__year">{{ year ?? '–' }}</span>
<q-icon
v-if="yearIndex.length > 0"
name="expand_less"
size="16px"
class="timeline-nav__caret"
:class="{ 'timeline-nav__caret--open': open }"
/>
</button>
<button
type="button"
class="timeline-nav__arrow"
:disabled="!canNext"
aria-label="Ein Jahr vor"
@click="$emit('next')"
>
<q-icon name="chevron_right" size="22px" />
</button>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
defineProps({
const MONTHS_SHORT = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez']
const MONTHS_LONG = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']
// Above this many years the strip gets a decade row to scrub with.
const DECADE_ROW_FROM = 12
const props = defineProps({
/** Year of the event at the viewport centre. */
year: { type: Number, default: null },
/** Month name of that event, already formatted. */
month: { type: String, default: '' },
/** Every year that occurs in the data, ascending. */
years: { type: Array, default: () => [] },
/** Month of that event, 0–11, or null. */
monthIndex: { type: Number, default: null },
/**
* Every year that occurs in the data, ascending:
* `{ year, count, months: [{ month, count }] }`.
*/
yearIndex: { type: Array, default: () => [] },
canPrev: { type: Boolean, default: false },
canNext: { type: Boolean, default: false }
})
const emit = defineEmits(['prev', 'next', 'jump'])
const menuOpen = ref(false)
const emit = defineEmits(['prev', 'next', 'jump', 'jump-month'])
function jumpTo(year) {
menuOpen.value = false
emit('jump', year)
const root = ref(null)
const yearStrip = ref(null)
const open = ref(false)
// The year whose months are shown; starts as the year in view.
const expandedYear = ref(null)
const expandedEntry = computed(() => props.yearIndex.find(entry => entry.year === expandedYear.value) ?? null)
const decades = computed(() => {
if (props.yearIndex.length < DECADE_ROW_FROM) return []
return [...new Set(props.yearIndex.map(entry => Math.floor(entry.year / 10) * 10))]
})
const currentDecade = computed(() => {
const year = expandedYear.value ?? props.year
return year === null ? null : Math.floor(year / 10) * 10
})
function monthCount(index) {
return expandedEntry.value?.months.find(entry => entry.month === index)?.count ?? 0
}
function toggle() {
if (open.value) {
close()
return
}
expandedYear.value = props.year
open.value = true
nextTick(() => centerChip(props.year))
}
function close() {
open.value = false
}
function selectYear(entry) {
emit('jump', entry.year)
expandedYear.value = entry.year
// One month only: nothing left to refine, the strip has done its job.
if (entry.months.length <= 1) {
close()
return
}
nextTick(() => centerChip(entry.year))
}
function selectMonth(index) {
if (expandedYear.value === null) return
emit('jump-month', { year: expandedYear.value, month: index })
close()
}
function chipFor(year) {
return yearStrip.value?.querySelector(`[data-year="${year}"]`) ?? null
}
/** Scrolls the year strip so `year` sits in its middle. */
function centerChip(year) {
const strip = yearStrip.value
const chip = chipFor(year)
if (!strip || !chip) return
strip.scrollTo({
left: chip.offsetLeft - strip.clientWidth / 2 + chip.offsetWidth / 2,
behavior: 'auto'
})
}
/** Scrolls the year strip to the first year of a decade (no jump yet). */
function showDecade(decade) {
const first = props.yearIndex.find(entry => entry.year >= decade)
const strip = yearStrip.value
const chip = first ? chipFor(first.year) : null
if (!strip || !chip) return
strip.scrollTo({ left: Math.max(0, chip.offsetLeft - 8), behavior: 'smooth' })
}
// Tapping anywhere else, or Escape, folds the strip away again.
function onPointerDown(event) {
if (root.value && !root.value.contains(event.target)) close()
}
function onKeydown(event) {
if (event.key !== 'Escape' || !open.value) return
event.stopPropagation()
close()
}
watch(open, (isOpen) => {
if (typeof document === 'undefined') return
if (isOpen) {
document.addEventListener('pointerdown', onPointerDown, true)
document.addEventListener('keydown', onKeydown, true)
} else {
document.removeEventListener('pointerdown', onPointerDown, true)
document.removeEventListener('keydown', onKeydown, true)
}
})
// No years left (user switch, everything deleted): nothing to show.
watch(() => props.yearIndex.length, (count) => {
if (count === 0) close()
})
onBeforeUnmount(() => {
if (typeof document === 'undefined') return
document.removeEventListener('pointerdown', onPointerDown, true)
document.removeEventListener('keydown', onKeydown, true)
})
</script>
<style scoped>
.timeline-nav {
position: relative;
display: inline-flex;
justify-content: center;
}
.timeline-nav__pill {
display: inline-flex;
align-items: center;
height: 44px;
@ -125,7 +303,7 @@ function jumpTo(year) {
.timeline-nav__label {
gap: 6px;
padding: 0 10px;
padding: 0 8px 0 10px;
min-width: 112px;
font-variant-numeric: tabular-nums;
}
@ -145,20 +323,209 @@ function jumpTo(year) {
font-weight: 700;
}
.timeline-nav__years {
max-height: 50dvh;
min-width: 120px;
padding: 4px;
.timeline-nav__caret {
opacity: 0.55;
transition: transform 0.2s ease;
}
.timeline-nav__year-item {
border-radius: 8px;
.timeline-nav__caret--open {
transform: rotate(180deg);
}
/* --- Quick-jump panel ------------------------------------------------- */
.timeline-nav__panel {
position: absolute;
bottom: calc(100% + 10px);
left: 50%;
transform: translateX(-50%);
width: min(92vw, 560px);
padding: 8px;
border-radius: 16px;
border: 1px solid rgba(var(--tm-accent-rgb, 245, 194, 107), 0.24);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
}
.timeline-nav-pop-enter-active,
.timeline-nav-pop-leave-active {
transition: opacity 0.16s ease, transform 0.16s ease;
}
.timeline-nav-pop-enter-from,
.timeline-nav-pop-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(8px);
}
/* Decades */
.timeline-nav__decades {
display: flex;
gap: 4px;
overflow-x: auto;
padding: 0 2px 6px;
scrollbar-width: none;
}
.timeline-nav__decades::-webkit-scrollbar {
display: none;
}
.timeline-nav__decade {
flex: 0 0 auto;
height: 26px;
padding: 0 10px;
border: 0;
border-radius: 13px;
background: transparent;
color: inherit;
font: inherit;
font-size: 12px;
opacity: 0.7;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.timeline-nav__decade--current {
opacity: 1;
font-weight: 600;
background: rgba(128, 128, 128, 0.16);
}
/* Years */
.timeline-nav__strip {
display: flex;
gap: 6px;
overflow-x: auto;
padding: 2px;
scroll-snap-type: x proximity;
scrollbar-width: none;
}
.timeline-nav__strip::-webkit-scrollbar {
display: none;
}
.timeline-nav__chip {
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
min-width: 58px;
height: 46px;
padding: 0 8px;
border: 1px solid transparent;
border-radius: 12px;
background: rgba(128, 128, 128, 0.08);
color: inherit;
font: inherit;
font-variant-numeric: tabular-nums;
text-align: center;
cursor: pointer;
scroll-snap-align: center;
-webkit-tap-highlight-color: transparent;
}
.timeline-nav__year-item--active {
.timeline-nav__chip:hover {
background: rgba(128, 128, 128, 0.16);
}
.timeline-nav__chip--expanded {
border-color: rgba(var(--tm-accent-rgb, 245, 194, 107), 0.6);
}
.timeline-nav__chip--current {
background: rgba(var(--tm-accent-rgb, 245, 194, 107), 0.22);
}
.timeline-nav__chip-year {
font-size: 14px;
font-weight: 600;
line-height: 1;
}
.timeline-nav__chip--current .timeline-nav__chip-year {
font-weight: 700;
background: rgba(var(--tm-accent-rgb, 115, 115, 115), 0.2);
}
.timeline-nav__chip-count {
display: inline-flex;
align-items: center;
gap: 3px;
height: 10px;
font-size: 10px;
line-height: 1;
opacity: 0.6;
}
.timeline-nav__chip-dot {
width: 4px;
height: 4px;
border-radius: 50%;
background: currentColor;
}
/* Months */
.timeline-nav__months {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 4px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid rgba(128, 128, 128, 0.15);
}
.timeline-nav__month-cell {
position: relative;
height: 34px;
padding: 0;
border: 0;
border-radius: 8px;
background: rgba(128, 128, 128, 0.08);
color: inherit;
font: inherit;
font-size: 12px;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.timeline-nav__month-cell:not(:disabled):hover {
background: rgba(128, 128, 128, 0.16);
}
.timeline-nav__month-cell:disabled {
background: transparent;
opacity: 0.35;
cursor: default;
}
.timeline-nav__month-cell--current {
background: rgba(var(--tm-accent-rgb, 245, 194, 107), 0.22);
font-weight: 700;
}
.timeline-nav__month-dot {
position: absolute;
left: 50%;
bottom: 4px;
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--tm-accent, #f5c26b);
transform: translateX(-50%);
}
@media (max-width: 480px) {
.timeline-nav__months {
grid-template-columns: repeat(6, 1fr);
}
}
@media (prefers-reduced-motion: reduce) {
.timeline-nav-pop-enter-active,
.timeline-nav-pop-leave-active,
.timeline-nav__caret {
transition: none;
}
}
</style>

View file

@ -104,6 +104,31 @@ const yearRanges = computed(() => {
// All years that exist in the data, ascending (for the year navigation)
const allYears = computed(() => yearRanges.value.map(r => r.year))
// Years with their event count and the months that hold events, for the
// quick-jump strip. Events are sorted by date, so every year and every month
// within it is one contiguous run of indices.
const yearIndex = computed(() => {
const years = []
displayEvents.value.forEach((event, index) => {
const year = calendarYear(event.date)
const month = calendarMonthIndex(event.date)
let entry = years[years.length - 1]
if (!entry || entry.year !== year) {
entry = { year, count: 0, firstIndex: index, months: [] }
years.push(entry)
}
entry.count += 1
if (month === null) return
let monthEntry = entry.months[entry.months.length - 1]
if (!monthEntry || monthEntry.month !== month) {
monthEntry = { month, count: 0, firstIndex: index }
entry.months.push(monthEntry)
}
monthEntry.count += 1
})
return years
})
// Virtualization: only render events near the viewport
const VIS_BUFFER = 2
const VIEW_EMIT_BUFFER = 3
@ -151,7 +176,8 @@ const activePosition = computed(() => {
const month = calendarMonthIndex(event.date)
return {
year: calendarYear(event.date),
month: month === null ? '' : MONTHS_LONG[month]
month: month === null ? '' : MONTHS_LONG[month],
monthIndex: month
}
})
@ -180,6 +206,28 @@ function scrollToYearCenter(year) {
})
}
// Centres the run of events from index `first` to `last`.
function scrollToIndexRange(first, last) {
if (!timelineRef.value) return
const centerX = (getEventX(first) + getEventX(last)) / 2
timelineRef.value.scrollTo({
left: centerX - viewportWidth.value / 2,
behavior: 'smooth'
})
}
// Centres the events of one month; a month without events falls back to
// the year.
function scrollToMonth(year, month) {
const entry = yearIndex.value.find(candidate => candidate.year === year)
const monthEntry = entry?.months.find(candidate => candidate.month === month)
if (!monthEntry) {
scrollToYearCenter(year)
return
}
scrollToIndexRange(monthEntry.firstIndex, monthEntry.firstIndex + monthEntry.count - 1)
}
function updateViewportWidth() {
if (!timelineRef.value) return
viewportWidth.value = timelineRef.value.clientWidth || 400
@ -530,7 +578,9 @@ defineExpose({
forceReflow,
activePosition,
years: allYears,
scrollToYearCenter
yearIndex,
scrollToYearCenter,
scrollToMonth
})
</script>

View file

@ -125,12 +125,14 @@
class="lifewave-bar__nav"
:year="activePosition?.year ?? null"
:month="activePosition?.month ?? ''"
:years="timelineYears"
:month-index="activePosition?.monthIndex ?? null"
:year-index="timelineYearIndex"
:can-prev="canGoPrevYear"
:can-next="canGoNextYear"
@prev="goToYear(activeYearIndex - 1)"
@next="goToYear(activeYearIndex + 1)"
@jump="year => timelineViewRef?.scrollToYearCenter(year)"
@jump-month="({ year, month }) => timelineViewRef?.scrollToMonth(year, month)"
/>
<AddEventButton class="lifewave-bar__add" @click="onAddEvent" />
@ -449,6 +451,7 @@ const parsedGradient = computed(() => {
// Position and year navigation, read from TimelineView
const activePosition = computed(() => timelineViewRef.value?.activePosition ?? null)
const timelineYears = computed(() => timelineViewRef.value?.years ?? [])
const timelineYearIndex = computed(() => timelineViewRef.value?.yearIndex ?? [])
const activeYearIndex = computed(() => timelineYears.value.indexOf(activePosition.value?.year))
const canGoPrevYear = computed(() => activeYearIndex.value > 0)
const canGoNextYear = computed(() =>

View file

@ -2,25 +2,31 @@ import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import TimelineNav from 'components/TimelineNav.vue'
// The menu is a portal; rendering it inline keeps the year list testable.
const inlineMenu = {
QMenu: {
props: ['modelValue'],
template: '<div v-if="modelValue" class="menu-stub"><slot /></div>'
function yearEntry(year, months) {
return {
year,
count: months.reduce((sum, [, count]) => sum + count, 0),
months: months.map(([month, count]) => ({ month, count }))
}
}
const YEARS = [
yearEntry(2025, [[3, 1]]),
yearEntry(2026, [[0, 2], [5, 1], [11, 4]]),
yearEntry(2027, [[7, 1]])
]
function mountNav(props = {}) {
return mount(TimelineNav, {
props: {
year: 2026,
month: 'Januar',
years: [2025, 2026, 2027],
monthIndex: 0,
yearIndex: YEARS,
canPrev: true,
canNext: true,
...props
},
global: { stubs: inlineMenu }
}
})
}
@ -51,27 +57,94 @@ describe('TimelineNav — the position pill', () => {
expect(arrows[1].attributes('disabled')).toBeDefined()
})
it('opens the year list on tap and emits the chosen year', async () => {
const wrapper = mountNav()
expect(wrapper.find('.menu-stub').exists()).toBe(false)
await wrapper.find('.timeline-nav__label').trigger('click')
expect(wrapper.find('.menu-stub').exists()).toBe(true)
const items = wrapper.findAll('.timeline-nav__year-item')
expect(items.map(item => item.text())).toEqual(['2025', '2026', '2027'])
await items[2].trigger('click')
expect(wrapper.emitted('jump')).toEqual([[2027]])
// Choosing closes the list.
expect(wrapper.find('.menu-stub').exists()).toBe(false)
})
it('shows a dash and no month while there is no event', () => {
const wrapper = mountNav({ year: null, month: '', years: [], canPrev: false, canNext: false })
const wrapper = mountNav({ year: null, month: '', monthIndex: null, yearIndex: [], canPrev: false, canNext: false })
expect(wrapper.find('.timeline-nav__year').text()).toBe('–')
expect(wrapper.find('.timeline-nav__month').exists()).toBe(false)
expect(wrapper.find('.timeline-nav__label').attributes('disabled')).toBeDefined()
})
})
describe('TimelineNav — the quick-jump strip', () => {
it('opens on tap with every year, its density and the months of the year in view', async () => {
const wrapper = mountNav()
expect(wrapper.find('.timeline-nav__panel').exists()).toBe(false)
await wrapper.find('.timeline-nav__label').trigger('click')
expect(wrapper.find('.timeline-nav__panel').exists()).toBe(true)
const chips = wrapper.findAll('.timeline-nav__chip')
expect(chips.map(chip => chip.find('.timeline-nav__chip-year').text())).toEqual(['2025', '2026', '2027'])
// Up to three events are dots, more is a number.
expect(chips[0].findAll('.timeline-nav__chip-dot')).toHaveLength(1)
expect(chips[1].find('.timeline-nav__chip-count').text()).toBe('7')
expect(chips[1].classes()).toContain('timeline-nav__chip--current')
// 2026 spreads over three months, so the month row is there right away.
const cells = wrapper.findAll('.timeline-nav__month-cell')
expect(cells).toHaveLength(12)
expect(cells.filter(cell => cell.attributes('disabled') === undefined).map(cell => cell.text()))
.toEqual(['Jan', 'Jun', 'Dez'])
expect(cells[0].classes()).toContain('timeline-nav__month-cell--current')
// Three years: no decade row.
expect(wrapper.find('.timeline-nav__decades').exists()).toBe(false)
})
it('jumps to a single-month year and closes', async () => {
const wrapper = mountNav()
await wrapper.find('.timeline-nav__label').trigger('click')
await wrapper.findAll('.timeline-nav__chip')[2].trigger('click')
expect(wrapper.emitted('jump')).toEqual([[2027]])
expect(wrapper.find('.timeline-nav__panel').exists()).toBe(false)
})
it('stays open for a year with several months and jumps to the chosen month', async () => {
const wrapper = mountNav({ year: 2025, month: 'April', monthIndex: 3 })
await wrapper.find('.timeline-nav__label').trigger('click')
// The year in view has one month only: no month row yet.
expect(wrapper.find('.timeline-nav__months').exists()).toBe(false)
await wrapper.findAll('.timeline-nav__chip')[1].trigger('click')
expect(wrapper.emitted('jump')).toEqual([[2026]])
expect(wrapper.find('.timeline-nav__panel').exists()).toBe(true)
expect(wrapper.findAll('.timeline-nav__chip')[1].classes()).toContain('timeline-nav__chip--expanded')
const cells = wrapper.findAll('.timeline-nav__month-cell')
expect(cells[3].attributes('disabled')).toBeDefined()
await cells[11].trigger('click')
expect(wrapper.emitted('jump-month')).toEqual([[{ year: 2026, month: 11 }]])
expect(wrapper.find('.timeline-nav__panel').exists()).toBe(false)
})
it('adds a decade row once there are many years', async () => {
const years = Array.from({ length: 30 }, (_, i) => yearEntry(1990 + i, [[i % 12, 1]]))
const wrapper = mountNav({ year: 2005, month: 'Mai', monthIndex: 4, yearIndex: years })
await wrapper.find('.timeline-nav__label').trigger('click')
const decades = wrapper.findAll('.timeline-nav__decade')
expect(decades.map(decade => decade.text())).toEqual(['1990er', '2000er', '2010er'])
expect(decades[1].classes()).toContain('timeline-nav__decade--current')
})
it('closes on Escape and when tapping elsewhere', async () => {
const wrapper = mountNav({ attachTo: document.body })
await wrapper.find('.timeline-nav__label').trigger('click')
expect(wrapper.find('.timeline-nav__panel').exists()).toBe(true)
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
await wrapper.vm.$nextTick()
expect(wrapper.find('.timeline-nav__panel').exists()).toBe(false)
await wrapper.find('.timeline-nav__label').trigger('click')
expect(wrapper.find('.timeline-nav__panel').exists()).toBe(true)
document.body.dispatchEvent(new Event('pointerdown', { bubbles: true }))
await wrapper.vm.$nextTick()
expect(wrapper.find('.timeline-nav__panel').exists()).toBe(false)
wrapper.unmount()
})
})

View file

@ -79,7 +79,19 @@ describe('TimelineView — the reported position follows the calendar date (A18)
// Whichever event the timeline centres on mount, month and year must be
// the ones spelled out in that event's date string.
const matching = EVENTS.map(event => positionFromDateString(event.date))
expect(matching).toContainEqual(position)
expect(matching).toContainEqual({ year: position.year, month: position.month })
expect(MONTHS_LONG[position.monthIndex]).toBe(position.month)
})
it('indexes years and months with their event counts for the quick-jump strip', () => {
const index = wrapper.vm.yearIndex
expect(index.map(entry => entry.year)).toEqual([2025, 2026, 2027])
expect(index.reduce((sum, entry) => sum + entry.count, 0)).toBe(EVENTS.length)
for (const entry of index) {
expect(entry.months.reduce((sum, month) => sum + month.count, 0)).toBe(entry.count)
expect(entry.months.every(month => month.month >= 0 && month.month <= 11)).toBe(true)
}
expect(typeof wrapper.vm.scrollToMonth).toBe('function')
})
it('names months in full for the navigation pill', () => {