Refactor numeric input to its own component, UX improvements.
This commit is contained in:
parent
80810c53e3
commit
74a5c201c2
@ -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
146
src/components/Numeric.vue
Normal 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>
|
Loading…
x
Reference in New Issue
Block a user