Set min/max year based on platform limitations 1901...2100.

This commit is contained in:
Leo Vasanko 2025-08-24 21:07:53 -06:00
parent 9a4d1c7196
commit cb7a111020
6 changed files with 92 additions and 46 deletions

View File

@ -31,6 +31,8 @@ import {
toLocalString, toLocalString,
mondayIndex, mondayIndex,
DEFAULT_TZ, DEFAULT_TZ,
MIN_YEAR,
MAX_YEAR,
} from '@/utils/date' } from '@/utils/date'
import { addDays } from 'date-fns' import { addDays } from 'date-fns'
import WeekRow from './WeekRow.vue' import WeekRow from './WeekRow.vue'
@ -43,8 +45,6 @@ const minVirtualWeek = ref(0)
const visibleWeeks = ref([]) const visibleWeeks = ref([])
const config = { const config = {
min_year: 1900,
max_year: 2100,
weekend: getLocaleWeekendDays(), weekend: getLocaleWeekendDays(),
} }
@ -116,7 +116,7 @@ const handleWheel = (e) => {
const currentYear = calendarStore.viewYear const currentYear = calendarStore.viewYear
const delta = Math.round(e.deltaY * (1 / 3)) const delta = Math.round(e.deltaY * (1 / 3))
if (!delta) return if (!delta) return
const newYear = Math.max(config.min_year, Math.min(config.max_year, currentYear + delta)) const newYear = Math.max(MIN_YEAR, Math.min(MAX_YEAR, currentYear + delta))
if (newYear === currentYear) return if (newYear === currentYear) return
const topDisplayIndex = Math.floor(viewportEl.value.scrollTop / rowHeight.value) const topDisplayIndex = Math.floor(viewportEl.value.scrollTop / rowHeight.value)
@ -156,8 +156,8 @@ const goToTodayHandler = () => {
onMounted(() => { onMounted(() => {
rowHeight.value = computeRowHeight() rowHeight.value = computeRowHeight()
const minYearDate = new Date(config.min_year, 0, 1) const minYearDate = new Date(MIN_YEAR, 0, 1)
const maxYearLastDay = new Date(config.max_year, 11, 31) const maxYearLastDay = new Date(MAX_YEAR, 11, 31)
const lastWeekMonday = addDays(maxYearLastDay, -mondayIndex(maxYearLastDay)) const lastWeekMonday = addDays(maxYearLastDay, -mondayIndex(maxYearLastDay))
minVirtualWeek.value = getWeekIndex(minYearDate) minVirtualWeek.value = getWeekIndex(minYearDate)

View File

@ -6,6 +6,8 @@ import {
reorderByFirstDay, reorderByFirstDay,
getISOWeek, getISOWeek,
getISOWeekYear, getISOWeekYear,
MIN_YEAR,
MAX_YEAR,
} from '@/utils/date' } from '@/utils/date'
import Numeric from '@/components/Numeric.vue' import Numeric from '@/components/Numeric.vue'
import { addDays } from 'date-fns' import { addDays } from 'date-fns'
@ -49,7 +51,7 @@ function isoWeekMonday(isoYear, isoWeek) {
function changeYear(y) { function changeYear(y) {
if (y == null) return if (y == null) return
y = Math.round(Math.max(calendarStore.minYear, Math.min(calendarStore.maxYear, y))) y = Math.round(Math.max(MIN_YEAR, Math.min(MAX_YEAR, y)))
if (y === currentYear.value) return if (y === currentYear.value) return
const vw = topVirtualWeek.value const vw = topVirtualWeek.value
// Fraction within current row // Fraction within current row
@ -94,8 +96,8 @@ const weekdayNames = computed(() => {
<Numeric <Numeric
:model-value="currentYear" :model-value="currentYear"
@update:modelValue="changeYear" @update:modelValue="changeYear"
:min="calendarStore.minYear" :min="MIN_YEAR"
:max="calendarStore.maxYear" :max="MAX_YEAR"
:step="1" :step="1"
aria-label="Year" aria-label="Year"
number-prefix="" number-prefix=""

View File

@ -17,6 +17,8 @@ import {
getOccurrenceIndex, getOccurrenceIndex,
getVirtualOccurrenceEndDate, getVirtualOccurrenceEndDate,
getISOWeek, getISOWeek,
MIN_YEAR,
MAX_YEAR,
} from '@/utils/date' } from '@/utils/date'
import { toLocalString, fromLocalString, DEFAULT_TZ } from '@/utils/date' import { toLocalString, fromLocalString, DEFAULT_TZ } from '@/utils/date'
import { addDays, differenceInCalendarDays, differenceInWeeks } from 'date-fns' import { addDays, differenceInCalendarDays, differenceInWeeks } from 'date-fns'
@ -76,14 +78,14 @@ function registerTap(rawDate, type) {
} }
const minVirtualWeek = computed(() => { const minVirtualWeek = computed(() => {
const date = new Date(calendarStore.minYear, 0, 1) const date = new Date(MIN_YEAR, 0, 1)
const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7 const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7
const firstDayOfWeek = addDays(date, -dayOffset) const firstDayOfWeek = addDays(date, -dayOffset)
return differenceInWeeks(firstDayOfWeek, baseDate.value) return differenceInWeeks(firstDayOfWeek, baseDate.value)
}) })
const maxVirtualWeek = computed(() => { const maxVirtualWeek = computed(() => {
const date = new Date(calendarStore.maxYear, 11, 31) const date = new Date(MAX_YEAR, 11, 31)
const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7 const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7
const firstDayOfWeek = addDays(date, -dayOffset) const firstDayOfWeek = addDays(date, -dayOffset)
return differenceInWeeks(firstDayOfWeek, baseDate.value) return differenceInWeeks(firstDayOfWeek, baseDate.value)
@ -276,7 +278,7 @@ function createWeek(virtualWeek) {
let monthLabel = null let monthLabel = null
if (hasFirst && monthToLabel !== null) { if (hasFirst && monthToLabel !== null) {
if (labelYear && labelYear <= calendarStore.config.max_year) { if (labelYear && labelYear <= MAX_YEAR) {
let weeksSpan = 0 let weeksSpan = 0
const d = addDays(cur, -1) const d = addDays(cur, -1)

View File

@ -2,7 +2,7 @@
<div <div
ref="rootEl" ref="rootEl"
class="mini-stepper drag-mode" class="mini-stepper drag-mode"
:class="[extraClass, { dragging }]" :class="[extraClass, { dragging, 'pointer-locked': pointerLocked }]"
:aria-label="ariaLabel" :aria-label="ariaLabel"
role="spinbutton" role="spinbutton"
:aria-valuemin="minValue" :aria-valuemin="minValue"
@ -19,7 +19,7 @@
</template> </template>
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref, onBeforeUnmount } from 'vue'
const model = defineModel({ type: Number, default: 0 }) const model = defineModel({ type: Number, default: 0 })
@ -102,46 +102,91 @@ const dragging = ref(false)
const rootEl = ref(null) const rootEl = ref(null)
let startX = 0 let startX = 0
let startY = 0 let startY = 0
let startVal = 0 let accumX = 0 // accumulated horizontal movement since last applied step (works for both locked & unlocked)
let lastClientX = 0 // previous clientX when not pointer locked
const pointerLocked = ref(false)
function updatePointerLocked() {
pointerLocked.value =
typeof document !== 'undefined' && document.pointerLockElement === rootEl.value
// Reset baseline if lock just engaged
if (pointerLocked.value) {
accumX = 0
startX = 0 // not used while locked
}
}
function addPointerLockListeners() {
if (typeof document === 'undefined') return
document.addEventListener('pointerlockchange', updatePointerLocked)
document.addEventListener('pointerlockerror', updatePointerLocked)
}
function removePointerLockListeners() {
if (typeof document === 'undefined') return
document.removeEventListener('pointerlockchange', updatePointerLocked)
document.removeEventListener('pointerlockerror', updatePointerLocked)
}
function onPointerDown(e) { function onPointerDown(e) {
e.preventDefault() e.preventDefault()
startX = e.clientX startX = e.clientX
startY = e.clientY startY = e.clientY
startVal = current.value lastClientX = e.clientX
accumX = 0
dragging.value = true dragging.value = true
try { try {
e.currentTarget.setPointerCapture(e.pointerId) e.currentTarget.setPointerCapture?.(e.pointerId)
} catch {} } catch {}
rootEl.value?.addEventListener('pointermove', onPointerMove) if (e.pointerType === 'mouse' && rootEl.value?.requestPointerLock) {
rootEl.value?.addEventListener('pointerup', onPointerUp, { once: true }) addPointerLockListeners()
rootEl.value?.addEventListener('pointercancel', onPointerCancel, { once: true }) try {
rootEl.value.requestPointerLock()
} catch {}
}
document.addEventListener('pointermove', onPointerMove)
document.addEventListener('pointerup', onPointerUp, { once: true })
document.addEventListener('pointercancel', onPointerCancel, { once: true })
} }
function onPointerMove(e) { function onPointerMove(e) {
if (!dragging.value) return if (!dragging.value) return
// Prevent page scroll on touch while dragging
if (e.pointerType === 'touch') e.preventDefault() if (e.pointerType === 'touch') e.preventDefault()
const primary = e.clientX - startX // horizontal only let dx
const steps = Math.trunc(primary / props.pixelsPerStep) if (pointerLocked.value) {
dx = e.movementX || 0
// Find current value index in all valid values
const currentIndex = allValidValues.value.indexOf(startVal)
if (currentIndex === -1) return // shouldn't happen
const newIndex = currentIndex + steps
if (props.clamp) {
const clampedIndex = Math.max(0, Math.min(newIndex, allValidValues.value.length - 1))
const next = allValidValues.value[clampedIndex]
if (next !== current.value) current.value = next
} else { } else {
if (newIndex >= 0 && newIndex < allValidValues.value.length) { dx = e.clientX - lastClientX
const next = allValidValues.value[newIndex] lastClientX = e.clientX
}
if (!dx) return
accumX += dx
const stepSize = props.pixelsPerStep || 1
let steps = Math.trunc(accumX / stepSize)
if (steps === 0) return
// Apply steps relative to current value index each time (dynamic baseline) and keep leftover pixels
const applySteps = (count) => {
const currentIndex = allValidValues.value.indexOf(current.value)
if (currentIndex === -1) return
let targetIndex = currentIndex + count
if (props.clamp) {
targetIndex = Math.max(0, Math.min(targetIndex, allValidValues.value.length - 1))
}
if (targetIndex >= 0 && targetIndex < allValidValues.value.length) {
const next = allValidValues.value[targetIndex]
if (next !== current.value) current.value = next if (next !== current.value) current.value = next
} }
} }
applySteps(steps)
// Remove consumed distance (works even if clamped; leftover ensures responsiveness keeps consistent feel)
accumX -= steps * stepSize
} }
function endDragListeners() { function endDragListeners() {
rootEl.value?.removeEventListener('pointermove', onPointerMove) document.removeEventListener('pointermove', onPointerMove)
if (pointerLocked.value && document.exitPointerLock) {
try {
document.exitPointerLock()
} catch {}
}
removePointerLockListeners()
} }
function onPointerUp() { function onPointerUp() {
dragging.value = false dragging.value = false
@ -267,4 +312,7 @@ function onWheel(e) {
.mini-stepper.drag-mode.dragging { .mini-stepper.drag-mode.dragging {
cursor: grabbing; cursor: grabbing;
} }
.mini-stepper.drag-mode.pointer-locked.dragging {
cursor: none; /* hide cursor for infinite drag */
}
</style> </style>

View File

@ -10,9 +10,6 @@ import {
import { differenceInCalendarDays, addDays } from 'date-fns' import { differenceInCalendarDays, addDays } from 'date-fns'
import { initializeHolidays, getAvailableCountries, getAvailableStates } from '@/utils/holidays' import { initializeHolidays, getAvailableCountries, getAvailableStates } from '@/utils/holidays'
const MIN_YEAR = 1900
const MAX_YEAR = 2100
export const useCalendarStore = defineStore('calendar', { export const useCalendarStore = defineStore('calendar', {
state: () => ({ state: () => ({
today: toLocalString(new Date(), DEFAULT_TZ), today: toLocalString(new Date(), DEFAULT_TZ),
@ -23,8 +20,6 @@ export const useCalendarStore = defineStore('calendar', {
_holidaysInitialized: false, _holidaysInitialized: false,
config: { config: {
select_days: 14, select_days: 14,
min_year: MIN_YEAR,
max_year: MAX_YEAR,
first_day: 1, first_day: 1,
holidays: { holidays: {
enabled: true, enabled: true,
@ -34,12 +29,6 @@ export const useCalendarStore = defineStore('calendar', {
}, },
}, },
}), }),
getters: {
minYear: () => MIN_YEAR,
maxYear: () => MAX_YEAR,
},
actions: { actions: {
_resolveCountry(code) { _resolveCountry(code) {
if (!code || code !== 'auto') return code if (!code || code !== 'auto') return code

View File

@ -23,6 +23,9 @@ const monthAbbr = [
'nov', 'nov',
'dec', 'dec',
] ]
// Calendar year bounds (used instead of config.min_year / config.max_year)
const MIN_YEAR = 1901
const MAX_YEAR = 2100
// Core helpers ------------------------------------------------------------ // Core helpers ------------------------------------------------------------
/** /**
@ -320,6 +323,8 @@ function formatTodayString(date) {
export { export {
// constants // constants
monthAbbr, monthAbbr,
MIN_YEAR,
MAX_YEAR,
DEFAULT_TZ, DEFAULT_TZ,
// core tz helpers // core tz helpers
makeTZDate, makeTZDate,