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