vue #1
@ -437,26 +437,25 @@ const recurrenceSummary = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
<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>
|
|
||||||
<Numeric
|
<Numeric
|
||||||
v-model="recurrenceInterval"
|
v-model="recurrenceInterval"
|
||||||
:min="1"
|
:prefix-values="[{ value: 1, display: 'Every' }]"
|
||||||
:max="999"
|
:min="2"
|
||||||
:step="1"
|
number-prefix="Every "
|
||||||
aria-label="Interval"
|
aria-label="Interval"
|
||||||
/>
|
/>
|
||||||
<select v-model="recurrenceFrequency" class="freq-select">
|
<select v-model="recurrenceFrequency" class="freq-select">
|
||||||
<option value="weeks">weeks</option>
|
<option value="weeks">{{ recurrenceInterval === 1 ? 'week' : 'weeks' }}</option>
|
||||||
<option value="months">months</option>
|
<option value="months">
|
||||||
|
{{ recurrenceInterval === 1 ? 'month' : 'months' }}
|
||||||
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<Numeric
|
<Numeric
|
||||||
class="occ-stepper"
|
class="occ-stepper"
|
||||||
v-model="recurrenceOccurrences"
|
v-model="recurrenceOccurrences"
|
||||||
:min="0"
|
:min="2"
|
||||||
:max="999"
|
:prefix-values="[{ value: 0, display: '∞' }]"
|
||||||
:step="1"
|
number-postfix=" times"
|
||||||
:infinite-value="0"
|
|
||||||
infinite-display="∞"
|
|
||||||
aria-label="Occurrences (0 = no end)"
|
aria-label="Occurrences (0 = no end)"
|
||||||
extra-class="occ"
|
extra-class="occ"
|
||||||
/>
|
/>
|
||||||
|
@ -63,29 +63,30 @@ function generateRepeatOccurrencesForDate(targetDateStr) {
|
|||||||
if (baseEvent.repeat === 'weeks') {
|
if (baseEvent.repeat === 'weeks') {
|
||||||
const repeatWeekdays = baseEvent.repeatWeekdays
|
const repeatWeekdays = baseEvent.repeatWeekdays
|
||||||
if (targetDate < baseStartDate) continue
|
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
|
if (maxOccurrences === 0) continue
|
||||||
const interval = baseEvent.repeatInterval || 1
|
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.
|
// Determine if targetDate lies within some occurrence span. We look backwards up to spanDays to find a start day.
|
||||||
let occStart = null
|
let occStart = null
|
||||||
for (let back=0; back<=spanDays; back++) {
|
for (let back = 0; back <= spanDays; back++) {
|
||||||
const cand = new Date(targetDate)
|
const cand = new Date(targetDate)
|
||||||
cand.setDate(cand.getDate() - back)
|
cand.setDate(cand.getDate() - back)
|
||||||
if (cand < baseStartDate) break
|
if (cand < baseStartDate) break
|
||||||
const daysDiff = Math.floor((cand - baseStartDate)/msPerDay)
|
const daysDiff = Math.floor((cand - baseStartDate) / msPerDay)
|
||||||
const weeksDiff = Math.floor(daysDiff / 7)
|
const weeksDiff = Math.floor(daysDiff / 7)
|
||||||
if (weeksDiff % interval !== 0) continue
|
if (weeksDiff % interval !== 0) continue
|
||||||
if (repeatWeekdays[cand.getDay()]) {
|
if (repeatWeekdays[cand.getDay()]) {
|
||||||
// candidate start must produce span covering targetDate
|
// candidate start must produce span covering targetDate
|
||||||
const candEnd = new Date(cand)
|
const candEnd = new Date(cand)
|
||||||
candEnd.setDate(candEnd.getDate() + spanDays)
|
candEnd.setDate(candEnd.getDate() + spanDays)
|
||||||
if (targetDate <= candEnd) {
|
if (targetDate <= candEnd) {
|
||||||
occStart = cand
|
occStart = cand
|
||||||
break
|
break
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!occStart) continue
|
if (!occStart) continue
|
||||||
// Skip base occurrence if this is within its span (base already physically stored)
|
// Skip base occurrence if this is within its span (base already physically stored)
|
||||||
@ -94,10 +95,10 @@ function generateRepeatOccurrencesForDate(targetDateStr) {
|
|||||||
let occIdx = 0
|
let occIdx = 0
|
||||||
const cursor = new Date(baseStartDate)
|
const cursor = new Date(baseStartDate)
|
||||||
while (cursor < occStart && occIdx < maxOccurrences) {
|
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)
|
const cWeeksDiff = Math.floor(cDaysDiff / 7)
|
||||||
if (cWeeksDiff % interval === 0 && repeatWeekdays[cursor.getDay()]) occIdx++
|
if (cWeeksDiff % interval === 0 && repeatWeekdays[cursor.getDay()]) occIdx++
|
||||||
cursor.setDate(cursor.getDate()+1)
|
cursor.setDate(cursor.getDate() + 1)
|
||||||
}
|
}
|
||||||
if (occIdx >= maxOccurrences) continue
|
if (occIdx >= maxOccurrences) continue
|
||||||
const occEnd = new Date(occStart)
|
const occEnd = new Date(occStart)
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
role="spinbutton"
|
role="spinbutton"
|
||||||
:aria-valuemin="minValue"
|
:aria-valuemin="minValue"
|
||||||
:aria-valuemax="maxValue"
|
:aria-valuemax="maxValue"
|
||||||
:aria-valuenow="current === infiniteValue ? undefined : current"
|
:aria-valuenow="isPrefix(current) ? undefined : current"
|
||||||
:aria-valuetext="display"
|
:aria-valuetext="display"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
@pointerdown="onPointerDown"
|
@pointerdown="onPointerDown"
|
||||||
@ -26,8 +26,14 @@ const props = defineProps({
|
|||||||
min: { type: Number, default: 0 },
|
min: { type: Number, default: 0 },
|
||||||
max: { type: Number, default: 999 },
|
max: { type: Number, default: 999 },
|
||||||
step: { type: Number, default: 1 },
|
step: { type: Number, default: 1 },
|
||||||
infiniteValue: { type: Number, default: 0 }, // model value representing infinity / special
|
prefixValues: {
|
||||||
infiniteDisplay: { type: String, default: '∞' },
|
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 },
|
clamp: { type: Boolean, default: true },
|
||||||
pixelsPerStep: { type: Number, default: 16 },
|
pixelsPerStep: { type: Number, default: 16 },
|
||||||
// Movement now restricted to horizontal (x). Prop retained for compatibility but ignored.
|
// 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 minValue = computed(() => props.min)
|
||||||
const maxValue = computed(() => props.max)
|
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({
|
const current = computed({
|
||||||
get() {
|
get() {
|
||||||
return model.value
|
return model.value
|
||||||
},
|
},
|
||||||
set(v) {
|
set(v) {
|
||||||
if (props.clamp) {
|
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.min) v = props.min
|
||||||
if (v > props.max) v = props.max
|
if (v > props.max) v = props.max
|
||||||
}
|
}
|
||||||
@ -52,9 +85,16 @@ const current = computed({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const display = computed(() =>
|
const display = computed(() => {
|
||||||
current.value === props.infiniteValue ? props.infiniteDisplay : String(current.value),
|
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
|
// Drag handling
|
||||||
const dragging = ref(false)
|
const dragging = ref(false)
|
||||||
@ -82,12 +122,22 @@ function onPointerMove(e) {
|
|||||||
if (e.pointerType === 'touch') e.preventDefault()
|
if (e.pointerType === 'touch') e.preventDefault()
|
||||||
const primary = e.clientX - startX // horizontal only
|
const primary = e.clientX - startX // horizontal only
|
||||||
const steps = Math.trunc(primary / props.pixelsPerStep)
|
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 (props.clamp) {
|
||||||
if (next < props.min) next = props.min
|
const clampedIndex = Math.max(0, Math.min(newIndex, allValidValues.value.length - 1))
|
||||||
if (next > props.max) next = props.max
|
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() {
|
function endDragListeners() {
|
||||||
rootEl.value?.removeEventListener('pointermove', onPointerMove)
|
rootEl.value?.removeEventListener('pointermove', onPointerMove)
|
||||||
@ -104,36 +154,64 @@ function onPointerCancel() {
|
|||||||
function onKey(e) {
|
function onKey(e) {
|
||||||
const key = e.key
|
const key = e.key
|
||||||
let handled = false
|
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) {
|
switch (key) {
|
||||||
case 'ArrowRight':
|
case 'ArrowRight':
|
||||||
case 'ArrowUp':
|
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
|
handled = true
|
||||||
break
|
break
|
||||||
case 'ArrowLeft':
|
case 'ArrowLeft':
|
||||||
case 'ArrowDown':
|
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
|
handled = true
|
||||||
break
|
break
|
||||||
case 'PageUp':
|
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
|
handled = true
|
||||||
break
|
break
|
||||||
case 'PageDown':
|
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
|
handled = true
|
||||||
break
|
break
|
||||||
case 'Home':
|
case 'Home':
|
||||||
current.value = props.min
|
newValue = allValidValues.value[0] || props.min
|
||||||
handled = true
|
handled = true
|
||||||
break
|
break
|
||||||
case 'End':
|
case 'End':
|
||||||
current.value = props.max
|
newValue = allValidValues.value[allValidValues.value.length - 1] || props.max
|
||||||
handled = true
|
handled = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if (delta !== 0) current.value = current.value + delta
|
|
||||||
|
if (newValue !== null) {
|
||||||
|
current.value = newValue
|
||||||
|
}
|
||||||
|
|
||||||
if (handled) {
|
if (handled) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
Loading…
x
Reference in New Issue
Block a user