Refactor numeric input to its own component, UX improvements.

This commit is contained in:
Leo Vasanko 2025-08-22 10:59:25 -06:00
parent 80810c53e3
commit 74a5c201c2
2 changed files with 168 additions and 43 deletions

View File

@ -2,6 +2,7 @@
import { useCalendarStore } from '@/stores/CalendarStore'
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import WeekdaySelector from './WeekdaySelector.vue'
import Numeric from './Numeric.vue'
const props = defineProps({
selection: { type: Object, default: () => ({ start: null, end: null }) },
@ -493,54 +494,29 @@ const recurrenceSummary = computed(() => {
<div v-if="recurrenceEnabled" class="recurrence-form">
<div class="line compact">
<span>Every</span>
<div class="mini-stepper" aria-label="Interval">
<button
type="button"
class="step"
@click="recurrenceInterval = Math.max(1, recurrenceInterval - 1)"
:disabled="recurrenceInterval <= 1"
>
</button>
<span class="value" role="textbox" aria-readonly="true">{{
recurrenceInterval
}}</span>
<button
type="button"
class="step"
@click="recurrenceInterval = Math.min(999, recurrenceInterval + 1)"
>
+
</button>
</div>
<Numeric
v-model="recurrenceInterval"
:min="1"
:max="999"
:step="1"
aria-label="Interval"
/>
<select v-model="recurrenceFrequency" class="freq-select">
<option value="weeks">weeks</option>
<option value="months">months</option>
<option value="years">years</option>
</select>
<div class="mini-stepper occ" aria-label="Occurrences (0 = no end)">
<button
type="button"
class="step"
@click="recurrenceOccurrences = Math.max(0, recurrenceOccurrences - 1)"
:disabled="recurrenceOccurrences <= 0"
>
</button>
<span class="value" role="textbox" aria-readonly="true">{{
recurrenceOccurrences === 0 ? '∞' : recurrenceOccurrences
}}</span>
<button
type="button"
class="step"
@click="
recurrenceOccurrences =
recurrenceOccurrences === 0 ? 2 : Math.min(999, recurrenceOccurrences + 1)
"
>
+
</button>
</div>
<Numeric
class="occ-stepper"
v-model="recurrenceOccurrences"
:min="0"
:max="999"
:step="1"
:infinite-value="0"
infinite-display="∞"
aria-label="Occurrences (0 = no end)"
extra-class="occ"
/>
</div>
<div v-if="recurrenceFrequency === 'weeks'" @click.stop>
<WeekdaySelector v-model="recurrenceWeekdays" :fallback="fallbackWeekdays" />
@ -906,6 +882,9 @@ const recurrenceSummary = computed(() => {
.mini-stepper.occ .value {
min-width: 2rem;
}
.occ-stepper.mini-stepper.occ .value {
min-width: 2rem;
}
.mini-stepper .step:focus-visible {
outline: 2px solid var(--input-focus);
outline-offset: -2px;

146
src/components/Numeric.vue Normal file
View File

@ -0,0 +1,146 @@
<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="current === infiniteValue ? undefined : current"
:aria-valuetext="display"
tabindex="0"
@pointerdown="onPointerDown"
@keydown.prevent.stop="onKey"
>
<span class="value" :title="String(current)">{{ display }}</span>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
const model = defineModel({ type: Number, default: 0 })
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: '∞' },
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)
const current = computed({
get() {
return model.value
},
set(v) {
if (props.clamp) {
if (v < props.min) v = props.min
if (v > props.max) v = props.max
}
model.value = v
},
})
const display = computed(() =>
current.value === props.infiniteValue ? props.infiniteDisplay : String(current.value),
)
// Drag handling
const dragging = ref(false)
const rootEl = ref(null)
let startX = 0
let startY = 0
let startVal = 0
function onPointerDown(e) {
e.preventDefault()
startX = e.clientX
startY = e.clientY
startVal = current.value
dragging.value = true
try {
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 })
}
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)
let next = startVal + steps * props.step
if (props.clamp) {
if (next < props.min) next = props.min
if (next > props.max) next = props.max
}
if (next !== current.value) current.value = next
}
function endDragListeners() {
rootEl.value?.removeEventListener('pointermove', onPointerMove)
}
function onPointerUp() {
dragging.value = false
endDragListeners()
}
function onPointerCancel() {
dragging.value = false
endDragListeners()
}
function onKey(e) {
const key = e.key
let delta = 0
if (key === 'ArrowRight' || key === 'ArrowUp') delta = props.step
else if (key === 'ArrowLeft' || key === 'ArrowDown') delta = -props.step
else if (key === 'PageUp') delta = props.step * 10
else if (key === 'PageDown') delta = -props.step * 10
else if (key === 'Home') current.value = props.min
else if (key === 'End') current.value = props.max
if (delta !== 0) current.value = current.value + delta
}
</script>
<style scoped>
.mini-stepper.drag-mode {
cursor: ew-resize;
user-select: none;
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;
font-variant-numeric: tabular-nums;
touch-action: none; /* allow custom drag without scrolling */
}
.mini-stepper.drag-mode:focus-visible {
outline: 2px solid var(--input-focus, #2563eb);
outline-offset: 2px;
}
.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>