diff --git a/assets/js/components/Sessions/DateNavigator.vue b/assets/js/components/Sessions/DateNavigator.vue
index 256d21f81..f8cca98a3 100644
--- a/assets/js/components/Sessions/DateNavigator.vue
+++ b/assets/js/components/Sessions/DateNavigator.vue
@@ -7,6 +7,7 @@
@@ -37,6 +38,7 @@
@@ -45,6 +47,7 @@
@@ -65,6 +68,7 @@
@@ -73,6 +77,7 @@
@@ -92,6 +97,7 @@
@@ -104,6 +110,7 @@
@@ -124,6 +131,7 @@
@@ -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 | 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");
+ },
},
});
diff --git a/assets/js/components/Sessions/DateNavigatorButton.vue b/assets/js/components/Sessions/DateNavigatorButton.vue
index 5f88c518b..d134ede18 100644
--- a/assets/js/components/Sessions/DateNavigatorButton.vue
+++ b/assets/js/components/Sessions/DateNavigatorButton.vue
@@ -1,6 +1,13 @@
@@ -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;
+}
diff --git a/assets/js/utils/swipe.ts b/assets/js/utils/swipe.ts
new file mode 100644
index 000000000..c74d2ae1a
--- /dev/null
+++ b/assets/js/utils/swipe.ts
@@ -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);
+ };
+}