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:
parent
ab23c9f4ef
commit
250f8390cf
5 changed files with 608 additions and 103 deletions
|
|
@ -1,90 +1,268 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="timeline-nav glass--button" role="group" aria-label="Zeitnavigation">
|
<div ref="root" class="timeline-nav">
|
||||||
<button
|
<!-- Quick-jump strip: folds out above the pill -->
|
||||||
type="button"
|
<Transition name="timeline-nav-pop">
|
||||||
class="timeline-nav__arrow"
|
<div
|
||||||
:disabled="!canPrev"
|
v-if="open"
|
||||||
aria-label="Ein Jahr zurück"
|
class="timeline-nav__panel glass--panel"
|
||||||
@click="$emit('prev')"
|
role="dialog"
|
||||||
>
|
aria-label="Schnell zu einem Jahr oder Monat springen"
|
||||||
<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"
|
|
||||||
>
|
>
|
||||||
<q-list dense role="listbox" class="timeline-nav__years">
|
<!-- Decades, only when the years alone would be a long strip -->
|
||||||
<q-item
|
<div v-if="decades.length > 1" class="timeline-nav__decades" role="tablist" aria-label="Jahrzehnte">
|
||||||
v-for="entry in years"
|
<button
|
||||||
:key="entry"
|
v-for="decade in decades"
|
||||||
clickable
|
:key="decade"
|
||||||
role="option"
|
type="button"
|
||||||
:active="entry === year"
|
role="tab"
|
||||||
:aria-selected="entry === year"
|
class="timeline-nav__decade"
|
||||||
active-class="timeline-nav__year-item--active"
|
:class="{ 'timeline-nav__decade--current': decade === currentDecade }"
|
||||||
class="timeline-nav__year-item"
|
:aria-selected="decade === currentDecade"
|
||||||
@click.stop="jumpTo(entry)"
|
@click="showDecade(decade)"
|
||||||
>
|
>
|
||||||
<q-item-section>{{ entry }}</q-item-section>
|
{{ decade }}er
|
||||||
</q-item>
|
</button>
|
||||||
</q-list>
|
</div>
|
||||||
</q-menu>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
<div ref="yearStrip" class="timeline-nav__strip" role="listbox" aria-label="Jahre">
|
||||||
type="button"
|
<button
|
||||||
class="timeline-nav__arrow"
|
v-for="entry in yearIndex"
|
||||||
:disabled="!canNext"
|
:key="entry.year"
|
||||||
aria-label="Ein Jahr vor"
|
type="button"
|
||||||
@click="$emit('next')"
|
role="option"
|
||||||
>
|
class="timeline-nav__chip"
|
||||||
<q-icon name="chevron_right" size="22px" />
|
:class="{
|
||||||
</button>
|
'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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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 of the event at the viewport centre. */
|
||||||
year: { type: Number, default: null },
|
year: { type: Number, default: null },
|
||||||
/** Month name of that event, already formatted. */
|
/** Month name of that event, already formatted. */
|
||||||
month: { type: String, default: '' },
|
month: { type: String, default: '' },
|
||||||
/** Every year that occurs in the data, ascending. */
|
/** Month of that event, 0–11, or null. */
|
||||||
years: { type: Array, default: () => [] },
|
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 },
|
canPrev: { type: Boolean, default: false },
|
||||||
canNext: { type: Boolean, default: false }
|
canNext: { type: Boolean, default: false }
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['prev', 'next', 'jump'])
|
const emit = defineEmits(['prev', 'next', 'jump', 'jump-month'])
|
||||||
const menuOpen = ref(false)
|
|
||||||
|
|
||||||
function jumpTo(year) {
|
const root = ref(null)
|
||||||
menuOpen.value = false
|
const yearStrip = ref(null)
|
||||||
emit('jump', year)
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.timeline-nav {
|
.timeline-nav {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-nav__pill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
|
|
@ -125,7 +303,7 @@ function jumpTo(year) {
|
||||||
|
|
||||||
.timeline-nav__label {
|
.timeline-nav__label {
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 0 10px;
|
padding: 0 8px 0 10px;
|
||||||
min-width: 112px;
|
min-width: 112px;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
@ -145,20 +323,209 @@ function jumpTo(year) {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-nav__years {
|
.timeline-nav__caret {
|
||||||
max-height: 50dvh;
|
opacity: 0.55;
|
||||||
min-width: 120px;
|
transition: transform 0.2s ease;
|
||||||
padding: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-nav__year-item {
|
.timeline-nav__caret--open {
|
||||||
border-radius: 8px;
|
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;
|
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;
|
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,31 @@ const yearRanges = computed(() => {
|
||||||
// All years that exist in the data, ascending (for the year navigation)
|
// All years that exist in the data, ascending (for the year navigation)
|
||||||
const allYears = computed(() => yearRanges.value.map(r => r.year))
|
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
|
// Virtualization: only render events near the viewport
|
||||||
const VIS_BUFFER = 2
|
const VIS_BUFFER = 2
|
||||||
const VIEW_EMIT_BUFFER = 3
|
const VIEW_EMIT_BUFFER = 3
|
||||||
|
|
@ -151,7 +176,8 @@ const activePosition = computed(() => {
|
||||||
const month = calendarMonthIndex(event.date)
|
const month = calendarMonthIndex(event.date)
|
||||||
return {
|
return {
|
||||||
year: calendarYear(event.date),
|
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() {
|
function updateViewportWidth() {
|
||||||
if (!timelineRef.value) return
|
if (!timelineRef.value) return
|
||||||
viewportWidth.value = timelineRef.value.clientWidth || 400
|
viewportWidth.value = timelineRef.value.clientWidth || 400
|
||||||
|
|
@ -530,7 +578,9 @@ defineExpose({
|
||||||
forceReflow,
|
forceReflow,
|
||||||
activePosition,
|
activePosition,
|
||||||
years: allYears,
|
years: allYears,
|
||||||
scrollToYearCenter
|
yearIndex,
|
||||||
|
scrollToYearCenter,
|
||||||
|
scrollToMonth
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -125,12 +125,14 @@
|
||||||
class="lifewave-bar__nav"
|
class="lifewave-bar__nav"
|
||||||
:year="activePosition?.year ?? null"
|
:year="activePosition?.year ?? null"
|
||||||
:month="activePosition?.month ?? ''"
|
:month="activePosition?.month ?? ''"
|
||||||
:years="timelineYears"
|
:month-index="activePosition?.monthIndex ?? null"
|
||||||
|
:year-index="timelineYearIndex"
|
||||||
:can-prev="canGoPrevYear"
|
:can-prev="canGoPrevYear"
|
||||||
:can-next="canGoNextYear"
|
:can-next="canGoNextYear"
|
||||||
@prev="goToYear(activeYearIndex - 1)"
|
@prev="goToYear(activeYearIndex - 1)"
|
||||||
@next="goToYear(activeYearIndex + 1)"
|
@next="goToYear(activeYearIndex + 1)"
|
||||||
@jump="year => timelineViewRef?.scrollToYearCenter(year)"
|
@jump="year => timelineViewRef?.scrollToYearCenter(year)"
|
||||||
|
@jump-month="({ year, month }) => timelineViewRef?.scrollToMonth(year, month)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AddEventButton class="lifewave-bar__add" @click="onAddEvent" />
|
<AddEventButton class="lifewave-bar__add" @click="onAddEvent" />
|
||||||
|
|
@ -449,6 +451,7 @@ const parsedGradient = computed(() => {
|
||||||
// Position and year navigation, read from TimelineView
|
// Position and year navigation, read from TimelineView
|
||||||
const activePosition = computed(() => timelineViewRef.value?.activePosition ?? null)
|
const activePosition = computed(() => timelineViewRef.value?.activePosition ?? null)
|
||||||
const timelineYears = computed(() => timelineViewRef.value?.years ?? [])
|
const timelineYears = computed(() => timelineViewRef.value?.years ?? [])
|
||||||
|
const timelineYearIndex = computed(() => timelineViewRef.value?.yearIndex ?? [])
|
||||||
const activeYearIndex = computed(() => timelineYears.value.indexOf(activePosition.value?.year))
|
const activeYearIndex = computed(() => timelineYears.value.indexOf(activePosition.value?.year))
|
||||||
const canGoPrevYear = computed(() => activeYearIndex.value > 0)
|
const canGoPrevYear = computed(() => activeYearIndex.value > 0)
|
||||||
const canGoNextYear = computed(() =>
|
const canGoNextYear = computed(() =>
|
||||||
|
|
|
||||||
|
|
@ -2,25 +2,31 @@ import { describe, expect, it } from 'vitest'
|
||||||
import { mount } from '@vue/test-utils'
|
import { mount } from '@vue/test-utils'
|
||||||
import TimelineNav from 'components/TimelineNav.vue'
|
import TimelineNav from 'components/TimelineNav.vue'
|
||||||
|
|
||||||
// The menu is a portal; rendering it inline keeps the year list testable.
|
function yearEntry(year, months) {
|
||||||
const inlineMenu = {
|
return {
|
||||||
QMenu: {
|
year,
|
||||||
props: ['modelValue'],
|
count: months.reduce((sum, [, count]) => sum + count, 0),
|
||||||
template: '<div v-if="modelValue" class="menu-stub"><slot /></div>'
|
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 = {}) {
|
function mountNav(props = {}) {
|
||||||
return mount(TimelineNav, {
|
return mount(TimelineNav, {
|
||||||
props: {
|
props: {
|
||||||
year: 2026,
|
year: 2026,
|
||||||
month: 'Januar',
|
month: 'Januar',
|
||||||
years: [2025, 2026, 2027],
|
monthIndex: 0,
|
||||||
|
yearIndex: YEARS,
|
||||||
canPrev: true,
|
canPrev: true,
|
||||||
canNext: true,
|
canNext: true,
|
||||||
...props
|
...props
|
||||||
},
|
}
|
||||||
global: { stubs: inlineMenu }
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -51,27 +57,94 @@ describe('TimelineNav — the position pill', () => {
|
||||||
expect(arrows[1].attributes('disabled')).toBeDefined()
|
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', () => {
|
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__year').text()).toBe('–')
|
||||||
expect(wrapper.find('.timeline-nav__month').exists()).toBe(false)
|
expect(wrapper.find('.timeline-nav__month').exists()).toBe(false)
|
||||||
expect(wrapper.find('.timeline-nav__label').attributes('disabled')).toBeDefined()
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -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
|
// Whichever event the timeline centres on mount, month and year must be
|
||||||
// the ones spelled out in that event's date string.
|
// the ones spelled out in that event's date string.
|
||||||
const matching = EVENTS.map(event => positionFromDateString(event.date))
|
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', () => {
|
it('names months in full for the navigation pill', () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue