calendar/src/components/CalendarHeader.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>