274 lines
6.3 KiB
Vue
274 lines
6.3 KiB
Vue
<template>
|
|
<button
|
|
class="root position-relative"
|
|
tabindex="0"
|
|
:class="{ active, belowLimit, batteryHold, full }"
|
|
:style="{ '--soc': `${adjustedSoc}%` }"
|
|
:disabled="disabled"
|
|
:aria-label="ariaLabel"
|
|
data-testid="battery-boost-button"
|
|
@click="toggle"
|
|
>
|
|
<div
|
|
v-if="active"
|
|
class="progress position-absolute"
|
|
:style="{ height: `${adjustedSoc}%` }"
|
|
>
|
|
<div class="progress-bar bg-primary progress-bar-striped progress-bar-animated"></div>
|
|
</div>
|
|
<div class="icon-wrapper" :style="iconStyle">
|
|
<BatteryBoost :active="active && available" />
|
|
</div>
|
|
<div class="icon-wrapper text-white" :style="iconActiveStyle">
|
|
<BatteryBoost :active="active && available" />
|
|
</div>
|
|
</button>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
import { defineComponent, type PropType } from "vue";
|
|
import { BATTERY_MODE, CHARGE_MODE, type Timeout } from "@/types/evcc";
|
|
import BatteryBoost from "../MaterialIcon/BatteryBoost.vue";
|
|
|
|
export default defineComponent({
|
|
name: "BatteryBoostButton",
|
|
components: {
|
|
BatteryBoost,
|
|
},
|
|
props: {
|
|
batteryBoost: Boolean,
|
|
batteryBoostLimit: { type: Number, default: 100 },
|
|
mode: String as PropType<CHARGE_MODE>,
|
|
batterySoc: { type: Number, default: 0 },
|
|
batteryMode: String as PropType<BATTERY_MODE>,
|
|
},
|
|
emits: ["updated", "status"],
|
|
data() {
|
|
return {
|
|
selected: null as boolean | null,
|
|
timeout: null as Timeout | null,
|
|
};
|
|
},
|
|
computed: {
|
|
active(): boolean {
|
|
return this.selected ?? this.batteryBoost;
|
|
},
|
|
disabled() {
|
|
return this.mode && [CHARGE_MODE.OFF, CHARGE_MODE.NOW].includes(this.mode);
|
|
},
|
|
adjustedSoc(): number {
|
|
const range = 100 - this.batteryBoostLimit;
|
|
if (range <= 0) return 0;
|
|
return Math.max(
|
|
0,
|
|
Math.min(100, ((this.batterySoc - this.batteryBoostLimit) / range) * 100)
|
|
);
|
|
},
|
|
belowLimit(): boolean {
|
|
return this.batterySoc < this.batteryBoostLimit;
|
|
},
|
|
batteryHold(): boolean {
|
|
return this.batteryMode === BATTERY_MODE.HOLD;
|
|
},
|
|
available(): boolean {
|
|
return !this.belowLimit && !this.batteryHold;
|
|
},
|
|
iconStyle() {
|
|
return {
|
|
clipPath: this.active ? `inset(0 0 calc(var(--soc)) 0)` : undefined,
|
|
};
|
|
},
|
|
full(): boolean {
|
|
return !this.active && this.adjustedSoc >= 90;
|
|
},
|
|
ariaLabel(): string {
|
|
const t = (key: string) => this.$t(`main.loadpointSettings.batteryBoost.${key}`);
|
|
if (this.batteryHold) return t("stateHold");
|
|
if (this.active) return t("stateActive");
|
|
if (this.belowLimit) return t("stateBelowLimit");
|
|
return t("stateReady");
|
|
},
|
|
iconActiveStyle() {
|
|
return {
|
|
opacity: this.active ? 1 : 0,
|
|
clipPath: this.active ? `inset(calc(100% - var(--soc)) 0 0 0)` : undefined,
|
|
};
|
|
},
|
|
},
|
|
watch: {
|
|
batteryBoost() {
|
|
this.clearSelected();
|
|
},
|
|
},
|
|
unmounted() {
|
|
this.clearSelected();
|
|
},
|
|
methods: {
|
|
toggle() {
|
|
(this.$el as HTMLElement).blur();
|
|
const status = (key: string, params?: Record<string, unknown>, type?: string) =>
|
|
this.$emit("status", {
|
|
message: this.$t(`main.vehicleStatus.${key}`, params ?? {}),
|
|
type,
|
|
});
|
|
const newValue = !this.active;
|
|
|
|
// battery hold: only show message, don't toggle
|
|
if (newValue && this.batteryHold) {
|
|
status("batteryBoostHold");
|
|
return;
|
|
}
|
|
|
|
// below limit: only show message, don't toggle
|
|
if (newValue && this.belowLimit) {
|
|
status("batteryBoostBelowLimit");
|
|
return;
|
|
}
|
|
|
|
// optimistic update, instant user feedback
|
|
this.selected = newValue;
|
|
if (this.timeout) clearTimeout(this.timeout);
|
|
this.timeout = setTimeout(() => this.clearSelected(), 5000);
|
|
|
|
if (newValue) {
|
|
status("batteryBoostEnabled", { limit: `${this.batteryBoostLimit}%` }, "primary");
|
|
} else {
|
|
status("batteryBoostDisabled");
|
|
}
|
|
this.$emit("updated", newValue);
|
|
},
|
|
clearSelected() {
|
|
this.selected = null;
|
|
if (this.timeout) {
|
|
clearTimeout(this.timeout);
|
|
this.timeout = null;
|
|
}
|
|
},
|
|
},
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
.root {
|
|
--size: 32px;
|
|
height: var(--size);
|
|
width: var(--size);
|
|
border-radius: var(--bs-border-radius);
|
|
overflow: hidden;
|
|
border: none;
|
|
background: none;
|
|
padding: 0;
|
|
color: var(--evcc-default-text);
|
|
opacity: 1;
|
|
transition: opacity var(--evcc-transition-fast) linear;
|
|
}
|
|
.root:focus,
|
|
.root:active {
|
|
outline: var(--bs-focus-ring-width) solid var(--bs-focus-ring-color);
|
|
outline-width: var(--bs-focus-ring-width);
|
|
}
|
|
.root:disabled {
|
|
color: inherit;
|
|
opacity: 0.25;
|
|
}
|
|
.root.belowLimit:not(:disabled),
|
|
.root.batteryHold:not(:disabled) {
|
|
opacity: 0.5;
|
|
}
|
|
.root:before,
|
|
.root:after {
|
|
content: "";
|
|
position: absolute;
|
|
inset: 0;
|
|
border-color: var(--bs-primary);
|
|
border-radius: var(--bs-border-radius);
|
|
border-width: 2px;
|
|
border-style: solid;
|
|
transition: opacity var(--evcc-transition-fast) linear;
|
|
}
|
|
.root:before {
|
|
opacity: 0.25;
|
|
clip-path: inset(0 0 calc(var(--soc)) 0);
|
|
}
|
|
.root:after {
|
|
clip-path: inset(calc(100% - var(--soc)) 0 0 0);
|
|
}
|
|
.root:hover:before {
|
|
opacity: 0.5;
|
|
}
|
|
.root.active:after {
|
|
display: none;
|
|
}
|
|
.root.full:after {
|
|
clip-path: none;
|
|
background: conic-gradient(
|
|
from var(--border-angle, 0deg),
|
|
var(--bs-primary-dim) 0%,
|
|
var(--bs-primary) 12%,
|
|
var(--bs-primary-dim) 33%,
|
|
var(--bs-primary) 45%,
|
|
var(--bs-primary-dim) 66%,
|
|
var(--bs-primary) 78%,
|
|
var(--bs-primary-dim) 100%
|
|
);
|
|
border: none;
|
|
mask:
|
|
linear-gradient(#fff 0 0) content-box,
|
|
linear-gradient(#fff 0 0);
|
|
mask-composite: exclude;
|
|
padding: 2px;
|
|
animation: rotate-border 3s linear infinite;
|
|
}
|
|
.root.full:hover:after {
|
|
background: var(--bs-primary);
|
|
animation: none;
|
|
}
|
|
@keyframes rotate-border {
|
|
to {
|
|
--border-angle: 360deg;
|
|
}
|
|
}
|
|
@property --border-angle {
|
|
syntax: "<angle>";
|
|
initial-value: 0deg;
|
|
inherits: false;
|
|
}
|
|
.progress {
|
|
border-radius: 0;
|
|
bottom: 0;
|
|
left: 0;
|
|
right: 0;
|
|
}
|
|
.progress-bar {
|
|
height: 100%;
|
|
width: 100%;
|
|
background-image: linear-gradient(
|
|
0deg,
|
|
rgba(255, 255, 255, 0.15) 25%,
|
|
transparent 25%,
|
|
transparent 50%,
|
|
rgba(255, 255, 255, 0.15) 50%,
|
|
rgba(255, 255, 255, 0.15) 75%,
|
|
transparent 75%,
|
|
transparent
|
|
) !important;
|
|
animation: progress-bar-stripes-down 1s linear infinite !important;
|
|
}
|
|
@keyframes progress-bar-stripes-down {
|
|
from {
|
|
background-position: 0 0;
|
|
}
|
|
to {
|
|
background-position: 0 1rem;
|
|
}
|
|
}
|
|
.icon-wrapper {
|
|
position: absolute;
|
|
inset: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transform: translateZ(0); /* fix Safari hover jump */
|
|
transition: opacity var(--evcc-transition-fast) ease;
|
|
}
|
|
</style>
|