Sessions/History UI: add left/right swipe gesture (#29944)

This commit is contained in:
Michael Geers 2026-05-16 20:09:32 +02:00 • committed by GitHub
parent 43860c1e0b
commit 57630514e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 128 additions and 1 deletions

View file

@ -7,6 +7,7 @@
<DateNavigatorButton
prev
:disabled="!hasPrevDay"
:highlight="highlightPrev"
:onClick="emitPrevDay"
data-testid="navigate-prev-day"
/>
@ -37,6 +38,7 @@
<DateNavigatorButton
next
:disabled="!hasNextDay"
:highlight="highlightNext"
:onClick="emitNextDay"
data-testid="navigate-next-day"
/>
@ -45,6 +47,7 @@
<DateNavigatorButton
prev
:disabled="!hasPrevMonth"
:highlight="highlightPrev"
:onClick="emitPrevMonth"
data-testid="navigate-prev-month"
/>
@ -65,6 +68,7 @@
<DateNavigatorButton
next
:disabled="!hasNextMonth"
:highlight="highlightNext"
:onClick="emitNextMonth"
data-testid="navigate-next-month"
/>
@ -73,6 +77,7 @@
<DateNavigatorButton
prev
:disabled="!hasPrevMonth"
:highlight="highlightPrev"
:onClick="emitPrevMonth"
data-testid="navigate-prev-year-month"
/>
@ -92,6 +97,7 @@
<DateNavigatorButton
next
:disabled="!hasNextMonth"
:highlight="highlightNext"
:onClick="emitNextMonth"
data-testid="navigate-next-year-month"
/>
@ -104,6 +110,7 @@
<DateNavigatorButton
prev
:disabled="!hasPrevYear"
:highlight="highlightPrev"
:onClick="emitPrevYear"
data-testid="navigate-prev-year"
/>
@ -124,6 +131,7 @@
<DateNavigatorButton
next
:disabled="!hasNextYear"
:highlight="highlightNext"
:onClick="emitNextYear"
data-testid="navigate-next-year"
/>
@ -137,6 +145,7 @@ import CustomSelect from "../Helper/CustomSelect.vue";
import DateNavigatorButton from "./DateNavigatorButton.vue";
import formatter from "@/mixins/formatter";
import type { SelectOption } from "@/types/evcc";
import { attachSwipeHandler } from "@/utils/swipe";
function daysInMonth(year: number, month: number) {
return new Date(year, month, 0).getDate();
@ -163,6 +172,14 @@ export default defineComponent({
showYear: Boolean,
},
emits: ["update-date"],
data() {
return {
detachSwipe: null as (() => void) | null,
highlightPrev: false,
highlightNext: false,
highlightTimer: null as ReturnType<typeof setTimeout> | null,
};
},
computed: {
hasPrevDay() {
const prev = new Date(this.year, this.month - 1, this.day - 1);
@ -258,6 +275,17 @@ export default defineComponent({
return this.fmtMonthYear(date);
},
},
mounted() {
this.detachSwipe = attachSwipeHandler(document.body, {
onSwipeLeft: () => this.swipeNext(),
onSwipeRight: () => this.swipePrev(),
ignoreSelector: "canvas, [_echarts_instance_]",
});
},
unmounted() {
this.detachSwipe?.();
if (this.highlightTimer) clearTimeout(this.highlightTimer);
},
methods: {
emitPrevDay() {
const prev = new Date(this.year, this.month - 1, this.day - 1);
@ -350,6 +378,29 @@ export default defineComponent({
...(this.showDay ? { day: this.clampDay(y, this.month) } : {}),
});
},
flashHighlight(dir: "prev" | "next") {
if (this.highlightTimer) clearTimeout(this.highlightTimer);
this.highlightPrev = dir === "prev";
this.highlightNext = dir === "next";
this.highlightTimer = setTimeout(() => {
this.highlightPrev = false;
this.highlightNext = false;
}, 300);
},
swipePrev() {
if (this.showDay && this.hasPrevDay) this.emitPrevDay();
else if (this.showMonth && this.hasPrevMonth) this.emitPrevMonth();
else if (this.showYear && this.hasPrevYear) this.emitPrevYear();
else return;
this.flashHighlight("prev");
},
swipeNext() {
if (this.showDay && this.hasNextDay) this.emitNextDay();
else if (this.showMonth && this.hasNextMonth) this.emitNextMonth();
else if (this.showYear && this.hasNextYear) this.emitNextYear();
else return;
this.flashHighlight("next");
},
},
});
</script>

View file

@ -1,6 +1,13 @@
<template>
<button class="btn btn-sm border-0" :disabled="disabled" @click="onClick">
<component :is="icon" size="s" :class="iconClass"></component>
<component
:is="icon"
size="s"
:class="[
iconClass,
{ 'nudge-prev': highlight && prev, 'nudge-next': highlight && next },
]"
></component>
</button>
</template>
@ -15,6 +22,7 @@ export default defineComponent({
disabled: Boolean,
prev: Boolean,
next: Boolean,
highlight: Boolean,
onClick: { type: Function as PropType<(event: MouseEvent) => void> },
},
computed: {
@ -39,4 +47,23 @@ export default defineComponent({
.btn:focus {
color: inherit !important;
}
@keyframes nudge {
0%,
100% {
transform: translateX(0);
}
35% {
transform: translateX(var(--nudge-distance));
}
}
.nudge-prev,
.nudge-next {
animation: nudge var(--evcc-transition-fast) ease-out;
}
.nudge-prev {
--nudge-distance: -4px;
}
.nudge-next {
--nudge-distance: 4px;
}
</style>

49
assets/js/utils/swipe.ts Normal file
View file

@ -0,0 +1,49 @@
interface SwipeOptions {
onSwipeLeft?: () => void;
onSwipeRight?: () => void;
ignoreSelector?: string;
minDistance?: number;
maxDuration?: number;
}
export function attachSwipeHandler(el: HTMLElement, options: SwipeOptions): () => void {
const minDistance = options.minDistance ?? 60;
const maxDuration = options.maxDuration ?? 600;
let startX = 0;
let startY = 0;
let startTime = 0;
let ignored = false;
const onTouchStart = (e: TouchEvent) => {
if (e.touches.length !== 1) {
ignored = true;
return;
}
const target = e.target as Element | null;
ignored = !!(options.ignoreSelector && target?.closest(options.ignoreSelector));
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
startTime = Date.now();
};
const onTouchEnd = (e: TouchEvent) => {
if (ignored) return;
const touch = e.changedTouches[0];
if (!touch) return;
const dx = touch.clientX - startX;
const dy = touch.clientY - startY;
if (Date.now() - startTime > maxDuration) return;
if (Math.abs(dx) < minDistance) return;
if (Math.abs(dy) >= Math.abs(dx)) return;
if (dx < 0) options.onSwipeLeft?.();
else options.onSwipeRight?.();
};
el.addEventListener("touchstart", onTouchStart, { passive: true });
el.addEventListener("touchend", onTouchEnd, { passive: true });
return () => {
el.removeEventListener("touchstart", onTouchStart);
el.removeEventListener("touchend", onTouchEnd);
};
}