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).
This commit was merged in pull request #2.
This commit is contained in:
1 parent
018b9ecc55
commit
9e3f7ddd57
28 files changed
+4465
-2207
No files matched your search
+131
-118
@@ -7,21 +7,20 @@
|
||||
role="spinbutton"
|
||||
:aria-valuemin="minValue"
|
||||
:aria-valuemax="maxValue"
|
||||
:aria-valuenow="isPrefix(current) ? undefined : current"
|
||||
:aria-valuenow="isPrefix(model) ? undefined : model"
|
||||
:aria-valuetext="display"
|
||||
tabindex="0"
|
||||
@pointerdown="onPointerDown"
|
||||
@keydown="onKey"
|
||||
@wheel.prevent="onWheel"
|
||||
>
|
||||
<span class="value" :title="String(current)">{{ display }}</span>
|
||||
<span class="value" :title="String(model)">{{ display }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const model = defineModel({ type: Number, default: 0 })
|
||||
|
||||
const model = defineModel({ default: 0 })
|
||||
const props = defineProps({
|
||||
min: { type: Number, default: 0 },
|
||||
max: { type: Number, default: 999 },
|
||||
@@ -36,111 +35,122 @@ const props = defineProps({
|
||||
numberPostfix: { type: String, default: '' },
|
||||
clamp: { type: Boolean, default: true },
|
||||
pixelsPerStep: { type: Number, default: 16 },
|
||||
// Movement now restricted to horizontal (x). Prop retained for compatibility but ignored.
|
||||
axis: { type: String, default: 'x' },
|
||||
ariaLabel: { type: String, default: '' },
|
||||
extraClass: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const minValue = computed(() => props.min)
|
||||
const maxValue = computed(() => props.max)
|
||||
|
||||
// Helper to check if a value is in the prefix values
|
||||
const isPrefix = (value) => {
|
||||
return props.prefixValues.some((prefix) => prefix.value === value)
|
||||
}
|
||||
|
||||
// Helper to get the display for a prefix value
|
||||
const getPrefixDisplay = (value) => {
|
||||
const prefix = props.prefixValues.find((p) => p.value === value)
|
||||
return prefix ? prefix.display : null
|
||||
}
|
||||
|
||||
// Get all valid values in order: prefixValues, then min to 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)
|
||||
}
|
||||
for (let i = props.min; i <= props.max; i += props.step) numericVals.push(i)
|
||||
return [...prefixVals, ...numericVals]
|
||||
})
|
||||
|
||||
const current = computed({
|
||||
get() {
|
||||
return model.value
|
||||
},
|
||||
set(v) {
|
||||
if (props.clamp) {
|
||||
// If it's a prefix value, allow it
|
||||
if (isPrefix(v)) {
|
||||
model.value = v
|
||||
return
|
||||
}
|
||||
// Otherwise clamp to numeric range
|
||||
if (v < props.min) v = props.min
|
||||
if (v > props.max) v = props.max
|
||||
}
|
||||
model.value = v
|
||||
},
|
||||
})
|
||||
|
||||
const display = computed(() => {
|
||||
const prefixDisplay = getPrefixDisplay(current.value)
|
||||
if (prefixDisplay !== null) {
|
||||
// For prefix values, show only the display text without number prefix/postfix
|
||||
return prefixDisplay
|
||||
}
|
||||
// For numeric values, include prefix and postfix
|
||||
const numericValue = String(current.value)
|
||||
return `${props.numberPrefix}${numericValue}${props.numberPostfix}`
|
||||
const prefixDisplay = getPrefixDisplay(model.value)
|
||||
if (prefixDisplay !== null) return prefixDisplay
|
||||
return `${props.numberPrefix}${String(model.value)}${props.numberPostfix}`
|
||||
})
|
||||
|
||||
// Drag handling
|
||||
const dragging = ref(false)
|
||||
const rootEl = ref(null)
|
||||
let startX = 0
|
||||
let startY = 0
|
||||
let startVal = 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
|
||||
startVal = current.value
|
||||
lastClientX = e.clientX
|
||||
accumX = 0
|
||||
dragging.value = true
|
||||
try {
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
e.currentTarget.setPointerCapture?.(e.pointerId)
|
||||
} catch {}
|
||||
rootEl.value?.addEventListener('pointermove', onPointerMove)
|
||||
rootEl.value?.addEventListener('pointerup', onPointerUp, { once: true })
|
||||
rootEl.value?.addEventListener('pointercancel', onPointerCancel, { once: true })
|
||||
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
|
||||
// Prevent page scroll on touch while dragging
|
||||
if (e.pointerType === 'touch') e.preventDefault()
|
||||
const primary = e.clientX - startX // horizontal only
|
||||
const steps = Math.trunc(primary / props.pixelsPerStep)
|
||||
|
||||
// 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 {
|
||||
if (newIndex >= 0 && newIndex < allValidValues.value.length) {
|
||||
const next = allValidValues.value[newIndex]
|
||||
if (next !== current.value) current.value = next
|
||||
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() {
|
||||
rootEl.value?.removeEventListener('pointermove', onPointerMove)
|
||||
document.removeEventListener('pointermove', onPointerMove)
|
||||
if (pointerLocked.value && document.exitPointerLock) {
|
||||
try {
|
||||
document.exitPointerLock()
|
||||
} catch {}
|
||||
}
|
||||
removePointerLockListeners()
|
||||
}
|
||||
function onPointerUp() {
|
||||
dragging.value = false
|
||||
@@ -150,52 +160,43 @@ function onPointerCancel() {
|
||||
dragging.value = false
|
||||
endDragListeners()
|
||||
}
|
||||
|
||||
function onKey(e) {
|
||||
const key = e.key
|
||||
let handled = false
|
||||
let newValue = null
|
||||
|
||||
// Find current value index in all valid values
|
||||
const currentIndex = allValidValues.value.indexOf(current.value)
|
||||
|
||||
const currentIndex = allValidValues.value.indexOf(model.value)
|
||||
switch (key) {
|
||||
case 'ArrowRight':
|
||||
case 'ArrowUp':
|
||||
if (currentIndex !== -1 && currentIndex < allValidValues.value.length - 1) {
|
||||
if (currentIndex !== -1 && currentIndex < allValidValues.value.length - 1)
|
||||
newValue = allValidValues.value[currentIndex + 1]
|
||||
} else if (currentIndex === -1) {
|
||||
// Current value not in list, try to increment normally
|
||||
newValue = current.value + props.step
|
||||
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) {
|
||||
// Current value not in list, try to decrement normally
|
||||
newValue = current.value - props.step
|
||||
}
|
||||
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) {
|
||||
const newIndex = Math.min(currentIndex + 10, allValidValues.value.length - 1)
|
||||
newValue = allValidValues.value[newIndex]
|
||||
} else {
|
||||
newValue = current.value + props.step * 10
|
||||
}
|
||||
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) {
|
||||
const newIndex = Math.max(currentIndex - 10, 0)
|
||||
newValue = allValidValues.value[newIndex]
|
||||
} else {
|
||||
newValue = current.value - props.step * 10
|
||||
}
|
||||
if (currentIndex !== -1) newValue = allValidValues.value[Math.max(currentIndex - 10, 0)]
|
||||
else newValue = model.value - props.step * 10
|
||||
handled = true
|
||||
break
|
||||
case 'Home':
|
||||
@@ -207,16 +208,32 @@ function onKey(e) {
|
||||
handled = true
|
||||
break
|
||||
}
|
||||
|
||||
if (newValue !== null) {
|
||||
current.value = newValue
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -226,18 +243,14 @@ function onKey(e) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 0.4rem;
|
||||
gap: 0.25rem;
|
||||
border: 1px solid var(--input-border, var(--muted));
|
||||
background: var(--panel-alt);
|
||||
border-radius: 0.4rem;
|
||||
min-height: 1.8rem;
|
||||
background: none;
|
||||
font-variant-numeric: tabular-nums;
|
||||
touch-action: none; /* allow custom drag without scrolling */
|
||||
touch-action: none;
|
||||
}
|
||||
.mini-stepper.drag-mode:focus-visible {
|
||||
outline: 2px solid var(--input-focus, #2563eb);
|
||||
outline-offset: 2px;
|
||||
box-shadow: 0 0 0 2px var(--input-focus, #2563eb);
|
||||
outline: none;
|
||||
}
|
||||
.mini-stepper.drag-mode .value {
|
||||
font-weight: 600;
|
||||
|
||||
Reference in new issue
Block a user