calendar/src/components/Numeric.vue
Leo Vasanko 9e3f7ddd57 Major new version (#2)
Release Notes

Architecture
- Component refactor: removed monoliths (`Calendar.vue`, `CalendarGrid.vue`); expanded granular view + header/control components.
- Dialog system introduced (`BaseDialog`, `SettingsDialog`).

State & Data
- Store redesigned: Map-based events + recurrence map; mutation counters.
- Local persistence + undo/redo history (custom plugins).

Date & Holidays
- Migrated all date logic to `date-fns` (+ tz).
- Added national holiday support (toggle + loading utilities).

Recurrence & Events
- Consolidated recurrence handling; fixes for monthly edge days (29–31), annual, multi‑day, and complex weekly repeats.
- Reliable splitting/moving/resizing/deletion of repeating and multi‑day events.

Interaction & UX
- Double‑tap to create events; improved drag (multi‑day + position retention).
- Scroll & inertial/momentum navigation; year change via numeric scroller.
- Movable event dialog; live settings application.

Performance
- Progressive / virtual week rendering, reduced off‑screen buffer.
- Targeted repaint strategy; minimized full re-renders.

Plugins Added
- History, undo normalization, persistence, scroll manager, virtual weeks.

Styling & Layout
- Responsive + compact layout refinements; header restructured.
- Simplified visual elements (removed dots/overflow text); holiday styling adjustments.

Reliability / Fixes
- Numerous recurrence, deletion, orientation/rotation, and event indexing corrections.
- Cross-browser fallback (Firefox week info).

Dependencies Added
- date-fns, date-fns-tz, date-holidays, pinia-plugin-persistedstate.

Net Change
- 28 files modified; ~4.4K insertions / ~2.2K deletions (major refactor + feature set).
2025-08-26 05:58:24 +01:00

265 lines
8.3 KiB
Vue

<template>
<div
ref="rootEl"
class="mini-stepper drag-mode"
:class="[extraClass, { dragging }]"
:aria-label="ariaLabel"
role="spinbutton"
:aria-valuemin="minValue"
:aria-valuemax="maxValue"
:aria-valuenow="isPrefix(model) ? undefined : model"
:aria-valuetext="display"
tabindex="0"
@pointerdown="onPointerDown"
@keydown="onKey"
@wheel.prevent="onWheel"
>
<span class="value" :title="String(model)">{{ display }}</span>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
const model = defineModel({ default: 0 })
const props = defineProps({
min: { type: Number, default: 0 },
max: { type: Number, default: 999 },
step: { type: Number, default: 1 },
prefixValues: {
type: Array,
default: () => [],
validator: (arr) =>
arr.every((item) => typeof item === 'object' && 'value' in item && 'display' in item),
},
numberPrefix: { type: String, default: '' },
numberPostfix: { type: String, default: '' },
clamp: { type: Boolean, default: true },
pixelsPerStep: { type: Number, default: 16 },
axis: { type: String, default: 'x' },
ariaLabel: { type: String, default: '' },
extraClass: { type: String, default: '' },
})
const minValue = computed(() => props.min)
const maxValue = computed(() => props.max)
const isPrefix = (value) => props.prefixValues.some((p) => p.value === value)
const getPrefixDisplay = (value) =>
props.prefixValues.find((p) => p.value === value)?.display ?? null
const allValidValues = computed(() => {
const prefixVals = props.prefixValues.map((p) => p.value)
const numericVals = []
for (let i = props.min; i <= props.max; i += props.step) numericVals.push(i)
return [...prefixVals, ...numericVals]
})
const display = computed(() => {
const prefixDisplay = getPrefixDisplay(model.value)
if (prefixDisplay !== null) return prefixDisplay
return `${props.numberPrefix}${String(model.value)}${props.numberPostfix}`
})
const dragging = ref(false)
const rootEl = ref(null)
let startX = 0
let startY = 0
let accumX = 0
let lastClientX = 0
const pointerLocked = ref(false)
function updatePointerLocked() {
pointerLocked.value =
typeof document !== 'undefined' && document.pointerLockElement === rootEl.value
if (pointerLocked.value) {
accumX = 0
startX = 0
}
}
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) {
e.preventDefault()
startX = e.clientX
startY = e.clientY
lastClientX = e.clientX
accumX = 0
dragging.value = true
try {
e.currentTarget.setPointerCapture?.(e.pointerId)
} catch {}
if (e.pointerType === 'mouse' && rootEl.value?.requestPointerLock) {
addPointerLockListeners()
try {
rootEl.value.requestPointerLock()
} catch {}
}
document.addEventListener('pointermove', onPointerMove)
document.addEventListener('pointerup', onPointerUp, { once: true })
document.addEventListener('pointercancel', onPointerCancel, { once: true })
}
function onPointerMove(e) {
if (!dragging.value) return
if (e.pointerType === 'touch') e.preventDefault()
let dx = pointerLocked.value ? e.movementX || 0 : e.clientX - lastClientX
if (!pointerLocked.value) lastClientX = e.clientX
if (!dx) return
accumX += dx
const stepSize = props.pixelsPerStep || 1
let steps = Math.trunc(accumX / stepSize)
if (steps === 0) return
const applySteps = (count) => {
if (!count) return
let direction = count > 0 ? 1 : -1
let remaining = Math.abs(count)
let curVal = model.value
const isNumeric = typeof curVal === 'number' && !Number.isNaN(curVal)
let idx = allValidValues.value.indexOf(curVal)
if (idx === -1) {
if (!isNumeric) {
curVal = props.prefixValues.length ? props.prefixValues[0].value : props.min
} else {
if (direction > 0) curVal = props.min
else
curVal = props.prefixValues.length
? props.prefixValues[props.prefixValues.length - 1].value
: props.min
}
remaining--
}
while (remaining > 0) {
idx = allValidValues.value.indexOf(curVal)
if (idx === -1) break
let targetIdx = idx + direction
if (props.clamp) targetIdx = Math.max(0, Math.min(targetIdx, allValidValues.value.length - 1))
if (targetIdx < 0 || targetIdx >= allValidValues.value.length || targetIdx === idx) break
curVal = allValidValues.value[targetIdx]
remaining--
}
model.value = curVal
}
applySteps(steps)
accumX -= steps * stepSize
}
function endDragListeners() {
document.removeEventListener('pointermove', onPointerMove)
if (pointerLocked.value && document.exitPointerLock) {
try {
document.exitPointerLock()
} catch {}
}
removePointerLockListeners()
}
function onPointerUp() {
dragging.value = false
endDragListeners()
}
function onPointerCancel() {
dragging.value = false
endDragListeners()
}
function onKey(e) {
const key = e.key
let handled = false
let newValue = null
const currentIndex = allValidValues.value.indexOf(model.value)
switch (key) {
case 'ArrowRight':
case 'ArrowUp':
if (currentIndex !== -1 && currentIndex < allValidValues.value.length - 1)
newValue = allValidValues.value[currentIndex + 1]
else if (currentIndex === -1) {
const curVal = model.value
const isNumeric = typeof curVal === 'number' && !Number.isNaN(curVal)
if (!isNumeric && props.prefixValues.length) newValue = props.prefixValues[0].value
else newValue = props.min
}
handled = true
break
case 'ArrowLeft':
case 'ArrowDown':
if (currentIndex !== -1 && currentIndex > 0) newValue = allValidValues.value[currentIndex - 1]
else if (currentIndex === -1)
newValue = props.prefixValues.length
? props.prefixValues[props.prefixValues.length - 1].value
: props.min
handled = true
break
case 'PageUp':
if (currentIndex !== -1)
newValue =
allValidValues.value[Math.min(currentIndex + 10, allValidValues.value.length - 1)]
else newValue = model.value + props.step * 10
handled = true
break
case 'PageDown':
if (currentIndex !== -1) newValue = allValidValues.value[Math.max(currentIndex - 10, 0)]
else newValue = model.value - props.step * 10
handled = true
break
case 'Home':
newValue = allValidValues.value[0] || props.min
handled = true
break
case 'End':
newValue = allValidValues.value[allValidValues.value.length - 1] || props.max
handled = true
break
}
if (newValue !== null) model.value = newValue
if (handled) {
e.preventDefault()
e.stopPropagation()
}
}
function onWheel(e) {
const direction = e.deltaY < 0 ? -1 : e.deltaY > 0 ? 1 : 0
if (direction === 0) return
const idx = allValidValues.value.indexOf(model.value)
if (idx !== -1) {
const nextIdx = idx + direction
if (nextIdx >= 0 && nextIdx < allValidValues.value.length)
model.value = allValidValues.value[nextIdx]
} else {
const curVal = model.value
const isNumeric = typeof curVal === 'number' && !Number.isNaN(curVal)
if (!isNumeric)
model.value = props.prefixValues.length ? props.prefixValues[0].value : props.min
else if (direction > 0) model.value = props.min
else
model.value = props.prefixValues.length
? props.prefixValues[props.prefixValues.length - 1].value
: props.min
}
}
</script>
<style scoped>
.mini-stepper.drag-mode {
cursor: ew-resize;
user-select: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
background: none;
font-variant-numeric: tabular-nums;
touch-action: none;
}
.mini-stepper.drag-mode:focus-visible {
box-shadow: 0 0 0 2px var(--input-focus, #2563eb);
outline: none;
}
.mini-stepper.drag-mode .value {
font-weight: 600;
min-width: 1.6rem;
text-align: center;
pointer-events: none;
}
.mini-stepper.drag-mode.dragging {
cursor: grabbing;
}
</style>