evcc-io/assets/js/components/Sessions/DateNavigator.vue

429 lines
12 KiB
Vue

<template>
<div
class="d-sm-flex justify-content-lg-end gap-lg-4"
:class="showDay || showMonth ? 'justify-content-between' : 'justify-content-end'"
>
<div v-if="showDay" class="d-flex justify-content-between">
<DateNavigatorButton
prev
:disabled="!hasPrevDay"
:highlight="highlightPrev"
:onClick="emitPrevDay"
data-testid="navigate-prev-day"
/>
<label class="day-picker position-relative d-block" role="button">
<input
type="date"
class="day-picker-input"
:value="dayIsoValue"
:min="dayIsoMin"
:max="dayIsoMax"
data-testid="navigate-day"
@change="
onDateInputChange(
($event.target as HTMLInputElement).value,
$event.target as HTMLInputElement
)
"
/>
<button
type="button"
class="btn btn-sm border-0 text-truncate h-100"
style="width: 11em"
tabindex="-1"
>
{{ dayLongName }}
</button>
</label>
<DateNavigatorButton
next
:disabled="!hasNextDay"
:highlight="highlightNext"
:onClick="emitNextDay"
data-testid="navigate-next-day"
/>
</div>
<div v-if="showMonth" class="d-none d-sm-flex justify-content-between">
<DateNavigatorButton
prev
:disabled="!hasPrevMonth"
:highlight="highlightPrev"
:onClick="emitPrevMonth"
data-testid="navigate-prev-month"
/>
<CustomSelect
id="sessionsMonth"
:options="monthOptions"
:selected="month"
@change="emitMonth($event.target.value)"
>
<button
class="btn btn-sm border-0 text-truncate h-100"
style="width: 8em"
data-testid="navigate-month"
>
{{ monthName }}
</button>
</CustomSelect>
<DateNavigatorButton
next
:disabled="!hasNextMonth"
:highlight="highlightNext"
:onClick="emitNextMonth"
data-testid="navigate-next-month"
/>
</div>
<div v-if="showMonth && !showDay" class="d-flex d-sm-none justify-content-between">
<DateNavigatorButton
prev
:disabled="!hasPrevMonth"
:highlight="highlightPrev"
:onClick="emitPrevMonth"
data-testid="navigate-prev-year-month"
/>
<CustomSelect
id="sessionsMonthYear"
:options="monthYearOptions"
:selected="month"
@change="emitMonthYear($event.target.value)"
>
<button
class="btn btn-sm border-0 h-100 text-truncate"
data-testid="navigate-month-year"
>
{{ monthYearName }}
</button>
</CustomSelect>
<DateNavigatorButton
next
:disabled="!hasNextMonth"
:highlight="highlightNext"
:onClick="emitNextMonth"
data-testid="navigate-next-year-month"
/>
</div>
<div
v-if="showYear"
class="justify-content-between"
:class="showMonth || showDay ? 'd-none d-sm-flex' : 'd-flex'"
>
<DateNavigatorButton
prev
:disabled="!hasPrevYear"
:highlight="highlightPrev"
:onClick="emitPrevYear"
data-testid="navigate-prev-year"
/>
<CustomSelect
id="sessionsYear"
:options="yearOptions"
:selected="year"
@change="emitYear($event.target.value)"
>
<button
class="btn btn-sm border-0 h-100"
style="width: 4em"
data-testid="navigate-year"
>
{{ year }}
</button>
</CustomSelect>
<DateNavigatorButton
next
:disabled="!hasNextYear"
:highlight="highlightNext"
:onClick="emitNextYear"
data-testid="navigate-next-year"
/>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
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();
}
function dateOnly(d: Date) {
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}
export default defineComponent({
name: "DateNavigator",
components: {
CustomSelect,
DateNavigatorButton,
},
mixins: [formatter],
props: {
day: { type: Number, default: 1 },
month: { type: Number, required: true },
year: { type: Number, required: true },
startDate: { type: Date, required: true },
showDay: Boolean,
showMonth: Boolean,
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);
return dateOnly(prev).getTime() >= dateOnly(this.startDate).getTime();
},
hasNextDay() {
const next = new Date(this.year, this.month - 1, this.day + 1);
return dateOnly(next).getTime() <= dateOnly(new Date()).getTime();
},
hasPrevMonth() {
return (
this.year > this.startDate.getFullYear() ||
this.month > this.startDate.getMonth() + 1
);
},
hasNextMonth() {
const now = new Date();
return this.year < now.getFullYear() || this.month < now.getMonth() + 1;
},
hasPrevYear() {
return this.year > this.startDate.getFullYear();
},
hasNextYear() {
return this.year < new Date().getFullYear();
},
dayOptions(): SelectOption<number>[] {
const total = daysInMonth(this.year, this.month);
return Array.from({ length: total }, (_, i) => i + 1).map((d) => ({
name: this.fmtDayMonth(new Date(this.year, this.month - 1, d)),
value: d,
}));
},
monthOptions() {
return Array.from({ length: 12 }, (_, i) => i + 1).map((month) => ({
name: this.fmtMonth(new Date(this.year, month - 1, 1), false),
value: month,
}));
},
monthYearOptions() {
const first = this.startDate;
const last = new Date();
const yearMonths = [];
for (let year = first.getFullYear(); year <= last.getFullYear(); year++) {
const startMonth = year === first.getFullYear() ? first.getMonth() + 1 : 1;
const endMonth = year === last.getFullYear() ? last.getMonth() + 1 : 12;
for (let month = startMonth; month <= endMonth; month++) {
yearMonths.push({
name: this.fmtMonthYear(new Date(year, month - 1, 1)),
value: `${year}-${month}`,
});
}
}
return yearMonths;
},
yearOptions(): SelectOption<number>[] {
const first = this.startDate;
const last = new Date();
const years = [];
for (let year = first.getFullYear(); year <= last.getFullYear(); year++) {
years.push({ name: year.toString(), value: year });
}
return years;
},
dayName() {
return this.fmtDayMonth(new Date(this.year, this.month - 1, this.day));
},
dayLongName() {
return this.fmtDayMonthYear(new Date(this.year, this.month - 1, this.day));
},
dayIsoValue() {
const pad = (n: number) => String(n).padStart(2, "0");
return `${this.year}-${pad(this.month)}-${pad(this.day)}`;
},
dayIsoMin() {
const d = this.startDate;
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
},
dayIsoMax() {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
},
monthName() {
const date = new Date();
date.setMonth(this.month - 1, 1);
return this.fmtMonth(date, false);
},
monthYearName() {
const date = new Date();
date.setMonth(this.month - 1, 1);
date.setFullYear(this.year);
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);
this.$emit("update-date", {
year: prev.getFullYear(),
month: prev.getMonth() + 1,
day: prev.getDate(),
});
},
emitNextDay() {
const next = new Date(this.year, this.month - 1, this.day + 1);
this.$emit("update-date", {
year: next.getFullYear(),
month: next.getMonth() + 1,
day: next.getDate(),
});
},
emitDay(day: number) {
const clamped = Math.min(Number(day), daysInMonth(this.year, this.month));
this.$emit("update-date", { year: this.year, month: this.month, day: clamped });
},
onDateInputChange(value: string, target?: HTMLInputElement) {
if (target) target.blur();
if (!value) return;
const [y, m, d] = value.split("-").map((s) => parseInt(s, 10));
if (!y || !m || !d) return;
this.$emit("update-date", { year: y, month: m, day: d });
},
clampDay(year: number, month: number) {
return Math.min(this.day, daysInMonth(year, month));
},
emitPrevMonth() {
const prev = new Date(this.year, this.month - 2, 1);
const year = prev.getFullYear();
const month = prev.getMonth() + 1;
this.$emit("update-date", {
year,
month,
...(this.showDay ? { day: this.clampDay(year, month) } : {}),
});
},
emitNextMonth() {
const next = new Date(this.year, this.month, 1);
const year = next.getFullYear();
const month = next.getMonth() + 1;
this.$emit("update-date", {
year,
month,
...(this.showDay ? { day: this.clampDay(year, month) } : {}),
});
},
emitPrevYear() {
const year = this.year - 1;
this.$emit("update-date", {
year,
month: undefined,
...(this.showDay ? { day: this.clampDay(year, this.month) } : {}),
});
},
emitNextYear() {
const year = this.year + 1;
this.$emit("update-date", {
year,
month: undefined,
...(this.showDay ? { day: this.clampDay(year, this.month) } : {}),
});
},
emitMonth(month: number) {
this.$emit("update-date", {
year: this.year,
month,
...(this.showDay ? { day: this.clampDay(this.year, Number(month)) } : {}),
});
},
emitMonthYear(monthYear: string) {
const [yearStr, monthStr] = monthYear.split("-");
const year = parseInt(yearStr || "0");
const month = parseInt(monthStr || "0");
this.$emit("update-date", {
year,
month,
...(this.showDay ? { day: this.clampDay(year, month) } : {}),
});
},
emitYear(year: number) {
const y = Number(year);
this.$emit("update-date", {
year: y,
month: undefined,
...(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>
<style scoped>
.btn {
color: inherit;
}
.day-picker {
border-radius: var(--bs-border-radius);
}
.day-picker:focus-within {
outline: var(--bs-focus-ring-width) solid var(--bs-focus-ring-color);
outline-offset: var(--bs-focus-ring-width);
}
.day-picker-input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
border: 0;
padding: 0;
}
</style>