150 lines
4.3 KiB
Vue
150 lines
4.3 KiB
Vue
<script setup>
|
|
import { computed } from 'vue'
|
|
import { useCalendarStore } from '@/stores/CalendarStore'
|
|
import {
|
|
getLocalizedWeekdayNames,
|
|
reorderByFirstDay,
|
|
getISOWeek,
|
|
getISOWeekYear,
|
|
MIN_YEAR,
|
|
MAX_YEAR,
|
|
} from '@/utils/date'
|
|
import Numeric from '@/components/Numeric.vue'
|
|
import { addDays } from 'date-fns'
|
|
|
|
const props = defineProps({
|
|
scrollTop: { type: Number, default: 0 },
|
|
rowHeight: { type: Number, default: 64 },
|
|
minVirtualWeek: { type: Number, default: 0 },
|
|
})
|
|
|
|
const calendarStore = useCalendarStore()
|
|
|
|
// Emits year-change events
|
|
const emit = defineEmits(['year-change'])
|
|
|
|
const baseDate = computed(() => new Date(1970, 0, 4 + calendarStore.config.first_day))
|
|
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
|
|
|
|
const topVirtualWeek = computed(() => {
|
|
const topDisplayIndex = Math.floor(props.scrollTop / props.rowHeight)
|
|
return topDisplayIndex + props.minVirtualWeek
|
|
})
|
|
|
|
const currentYear = computed(() => {
|
|
const weekStart = addDays(baseDate.value, topVirtualWeek.value * 7)
|
|
const anchor = addDays(weekStart, (4 - weekStart.getDay() + 7) % 7)
|
|
return getISOWeekYear(anchor)
|
|
})
|
|
|
|
function virtualWeekOf(d) {
|
|
const o = (d.getDay() - calendarStore.config.first_day + 7) % 7
|
|
const fd = addDays(d, -o)
|
|
return Math.floor((fd.getTime() - baseDate.value.getTime()) / WEEK_MS)
|
|
}
|
|
|
|
function isoWeekMonday(isoYear, isoWeek) {
|
|
const jan4 = new Date(isoYear, 0, 4)
|
|
const week1Mon = addDays(jan4, -((jan4.getDay() + 6) % 7))
|
|
return addDays(week1Mon, (isoWeek - 1) * 7)
|
|
}
|
|
|
|
function changeYear(y) {
|
|
if (y == null) return
|
|
y = Math.round(Math.max(MIN_YEAR, Math.min(MAX_YEAR, y)))
|
|
if (y === currentYear.value) return
|
|
const vw = topVirtualWeek.value
|
|
// Fraction within current row
|
|
const weekStartScroll = (vw - props.minVirtualWeek) * props.rowHeight
|
|
const frac = Math.max(0, Math.min(1, (props.scrollTop - weekStartScroll) / props.rowHeight))
|
|
// Anchor Thursday of current calendar week
|
|
const curCalWeekStart = addDays(baseDate.value, vw * 7)
|
|
const curAnchorThu = addDays(curCalWeekStart, (4 - curCalWeekStart.getDay() + 7) % 7)
|
|
let isoW = getISOWeek(curAnchorThu)
|
|
// Build Monday of ISO week
|
|
let weekMon = isoWeekMonday(y, isoW)
|
|
if (getISOWeekYear(weekMon) !== y) {
|
|
isoW--
|
|
weekMon = isoWeekMonday(y, isoW)
|
|
}
|
|
// Align to configured first day
|
|
const shift = (weekMon.getDay() - calendarStore.config.first_day + 7) % 7
|
|
const calWeekStart = addDays(weekMon, -shift)
|
|
const targetVW = virtualWeekOf(calWeekStart)
|
|
let scrollTop = (targetVW - props.minVirtualWeek) * props.rowHeight + frac * props.rowHeight
|
|
if (Math.abs(scrollTop - props.scrollTop) < 0.5) scrollTop += 0.25 * props.rowHeight
|
|
emit('year-change', { year: y, scrollTop })
|
|
}
|
|
|
|
const weekdayNames = computed(() => {
|
|
// Reorder names & weekend flags
|
|
const sundayFirstNames = getLocalizedWeekdayNames()
|
|
const reorderedNames = reorderByFirstDay(sundayFirstNames, calendarStore.config.first_day)
|
|
const reorderedWeekend = reorderByFirstDay(calendarStore.weekend, calendarStore.config.first_day)
|
|
|
|
return reorderedNames.map((name, i) => ({
|
|
name,
|
|
isWeekend: reorderedWeekend[i],
|
|
}))
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="calendar-header">
|
|
<div class="year-label">
|
|
<Numeric
|
|
:model-value="currentYear"
|
|
@update:modelValue="changeYear"
|
|
:min="MIN_YEAR"
|
|
:max="MAX_YEAR"
|
|
:step="1"
|
|
aria-label="Year"
|
|
number-prefix=""
|
|
number-postfix=""
|
|
/>
|
|
</div>
|
|
<div
|
|
v-for="day in weekdayNames"
|
|
:key="day.name"
|
|
class="dow"
|
|
:class="{ workday: !day.isWeekend, weekend: day.isWeekend }"
|
|
>
|
|
{{ day.name }}
|
|
</div>
|
|
<div class="overlay-header-spacer"></div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.calendar-header {
|
|
display: grid;
|
|
grid-template-columns: var(--week-w) repeat(7, 1fr) var(--month-w);
|
|
border-bottom: 2px solid var(--muted);
|
|
align-items: last baseline;
|
|
flex-shrink: 0;
|
|
width: 100%;
|
|
/* Prevent text selection */
|
|
-webkit-user-select: none;
|
|
-moz-user-select: none;
|
|
-ms-user-select: none;
|
|
user-select: none;
|
|
-webkit-touch-callout: none;
|
|
-webkit-tap-highlight-color: transparent;
|
|
}
|
|
.dow {
|
|
text-transform: uppercase;
|
|
text-align: center;
|
|
font-weight: 600;
|
|
font-size: 1.2em;
|
|
}
|
|
.dow.weekend {
|
|
color: var(--weekend);
|
|
}
|
|
.dow.workday {
|
|
color: var(--workday);
|
|
}
|
|
.overlay-header-spacer {
|
|
grid-area: auto;
|
|
}
|
|
</style>
|