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); + }; +}