From 86d38a5a29fba63316bc423b65df3b4da3a9417a Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Fri, 22 Aug 2025 12:59:51 -0600 Subject: [PATCH] Better numeric inputs --- src/components/EventDialog.vue | 21 +++--- src/components/EventOverlay.vue | 29 ++++---- src/components/Numeric.vue | 114 +++++++++++++++++++++++++++----- 3 files changed, 121 insertions(+), 43 deletions(-) diff --git a/src/components/EventDialog.vue b/src/components/EventDialog.vue index 288d63d..0d67819 100644 --- a/src/components/EventDialog.vue +++ b/src/components/EventDialog.vue @@ -437,26 +437,25 @@ const recurrenceSummary = computed(() => {
- Every diff --git a/src/components/EventOverlay.vue b/src/components/EventOverlay.vue index cd363e6..d01f4d5 100644 --- a/src/components/EventOverlay.vue +++ b/src/components/EventOverlay.vue @@ -63,29 +63,30 @@ function generateRepeatOccurrencesForDate(targetDateStr) { if (baseEvent.repeat === 'weeks') { const repeatWeekdays = baseEvent.repeatWeekdays if (targetDate < baseStartDate) continue - const maxOccurrences = baseEvent.repeatCount === 'unlimited' ? Infinity : parseInt(baseEvent.repeatCount, 10) + const maxOccurrences = + baseEvent.repeatCount === 'unlimited' ? Infinity : parseInt(baseEvent.repeatCount, 10) if (maxOccurrences === 0) continue const interval = baseEvent.repeatInterval || 1 - const msPerDay = 24*60*60*1000 + const msPerDay = 24 * 60 * 60 * 1000 // Determine if targetDate lies within some occurrence span. We look backwards up to spanDays to find a start day. let occStart = null - for (let back=0; back<=spanDays; back++) { + for (let back = 0; back <= spanDays; back++) { const cand = new Date(targetDate) cand.setDate(cand.getDate() - back) if (cand < baseStartDate) break - const daysDiff = Math.floor((cand - baseStartDate)/msPerDay) + const daysDiff = Math.floor((cand - baseStartDate) / msPerDay) const weeksDiff = Math.floor(daysDiff / 7) if (weeksDiff % interval !== 0) continue - if (repeatWeekdays[cand.getDay()]) { - // candidate start must produce span covering targetDate - const candEnd = new Date(cand) - candEnd.setDate(candEnd.getDate() + spanDays) - if (targetDate <= candEnd) { - occStart = cand - break - } + if (repeatWeekdays[cand.getDay()]) { + // candidate start must produce span covering targetDate + const candEnd = new Date(cand) + candEnd.setDate(candEnd.getDate() + spanDays) + if (targetDate <= candEnd) { + occStart = cand + break } + } } if (!occStart) continue // Skip base occurrence if this is within its span (base already physically stored) @@ -94,10 +95,10 @@ function generateRepeatOccurrencesForDate(targetDateStr) { let occIdx = 0 const cursor = new Date(baseStartDate) while (cursor < occStart && occIdx < maxOccurrences) { - const cDaysDiff = Math.floor((cursor - baseStartDate)/msPerDay) + const cDaysDiff = Math.floor((cursor - baseStartDate) / msPerDay) const cWeeksDiff = Math.floor(cDaysDiff / 7) if (cWeeksDiff % interval === 0 && repeatWeekdays[cursor.getDay()]) occIdx++ - cursor.setDate(cursor.getDate()+1) + cursor.setDate(cursor.getDate() + 1) } if (occIdx >= maxOccurrences) continue const occEnd = new Date(occStart) diff --git a/src/components/Numeric.vue b/src/components/Numeric.vue index 3f8dc35..1beb9bf 100644 --- a/src/components/Numeric.vue +++ b/src/components/Numeric.vue @@ -7,7 +7,7 @@ role="spinbutton" :aria-valuemin="minValue" :aria-valuemax="maxValue" - :aria-valuenow="current === infiniteValue ? undefined : current" + :aria-valuenow="isPrefix(current) ? undefined : current" :aria-valuetext="display" tabindex="0" @pointerdown="onPointerDown" @@ -26,8 +26,14 @@ const props = defineProps({ min: { type: Number, default: 0 }, max: { type: Number, default: 999 }, step: { type: Number, default: 1 }, - infiniteValue: { type: Number, default: 0 }, // model value representing infinity / special - infiniteDisplay: { type: String, default: '∞' }, + 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 }, // Movement now restricted to horizontal (x). Prop retained for compatibility but ignored. @@ -39,12 +45,39 @@ const props = defineProps({ 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 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 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 } @@ -52,9 +85,16 @@ const current = computed({ }, }) -const display = computed(() => - current.value === props.infiniteValue ? props.infiniteDisplay : String(current.value), -) +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}` +}) // Drag handling const dragging = ref(false) @@ -82,12 +122,22 @@ function onPointerMove(e) { if (e.pointerType === 'touch') e.preventDefault() const primary = e.clientX - startX // horizontal only const steps = Math.trunc(primary / props.pixelsPerStep) - let next = startVal + steps * props.step + + // 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) { - if (next < props.min) next = props.min - if (next > props.max) next = props.max + 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 + } } - if (next !== current.value) current.value = next } function endDragListeners() { rootEl.value?.removeEventListener('pointermove', onPointerMove) @@ -104,36 +154,64 @@ function onPointerCancel() { function onKey(e) { const key = e.key let handled = false - let delta = 0 + let newValue = null + + // Find current value index in all valid values + const currentIndex = allValidValues.value.indexOf(current.value) + switch (key) { case 'ArrowRight': case 'ArrowUp': - delta = props.step + 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 + } handled = true break case 'ArrowLeft': case 'ArrowDown': - delta = -props.step + 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 + } handled = true break case 'PageUp': - delta = props.step * 10 + if (currentIndex !== -1) { + const newIndex = Math.min(currentIndex + 10, allValidValues.value.length - 1) + newValue = allValidValues.value[newIndex] + } else { + newValue = current.value + props.step * 10 + } handled = true break case 'PageDown': - delta = -props.step * 10 + if (currentIndex !== -1) { + const newIndex = Math.max(currentIndex - 10, 0) + newValue = allValidValues.value[newIndex] + } else { + newValue = current.value - props.step * 10 + } handled = true break case 'Home': - current.value = props.min + newValue = allValidValues.value[0] || props.min handled = true break case 'End': - current.value = props.max + newValue = allValidValues.value[allValidValues.value.length - 1] || props.max handled = true break } - if (delta !== 0) current.value = current.value + delta + + if (newValue !== null) { + current.value = newValue + } + if (handled) { e.preventDefault() e.stopPropagation()