Config UI: mobile optimized section navigation (#32810)
This commit is contained in:
parent
070c16cb4f
commit
0e58436ca4
19 changed files with 1190 additions and 474 deletions
|
|
@ -800,6 +800,43 @@ html.app .modal-dialog {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* in-place fade-swipe between two views (Vue transition classes, e.g. config list>detail) */
|
||||||
|
.fade-swap-left-enter-active,
|
||||||
|
.fade-swap-left-leave-active,
|
||||||
|
.fade-swap-right-enter-active,
|
||||||
|
.fade-swap-right-leave-active {
|
||||||
|
transition:
|
||||||
|
opacity 150ms ease,
|
||||||
|
transform 150ms ease;
|
||||||
|
}
|
||||||
|
/* leaving view overlaps the entering one instead of pushing it down;
|
||||||
|
ancestor must be position:relative and unpadded, or the view jumps when going absolute */
|
||||||
|
.fade-swap-left-leave-active,
|
||||||
|
.fade-swap-right-leave-active {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
.fade-swap-left-enter-from,
|
||||||
|
.fade-swap-left-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-1rem);
|
||||||
|
}
|
||||||
|
.fade-swap-right-enter-from,
|
||||||
|
.fade-swap-right-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(1rem);
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.fade-swap-left-enter-active,
|
||||||
|
.fade-swap-left-leave-active,
|
||||||
|
.fade-swap-right-enter-active,
|
||||||
|
.fade-swap-right-leave-active {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.round-box--error {
|
.round-box--error {
|
||||||
box-shadow: 0 0 0.5rem var(--bs-danger);
|
box-shadow: 0 0 0.5rem var(--bs-danger);
|
||||||
border: 1px solid var(--bs-danger);
|
border: 1px solid var(--bs-danger);
|
||||||
|
|
|
||||||
47
assets/js/components/Config/ConfigSection.vue
Normal file
47
assets/js/components/Config/ConfigSection.vue
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
<template>
|
||||||
|
<section :id="slug">
|
||||||
|
<template v-if="!mobile">
|
||||||
|
<h2 class="my-4 mt-5">{{ title }}</h2>
|
||||||
|
<slot />
|
||||||
|
</template>
|
||||||
|
<Transition v-else name="fade-swap-right" @after-enter="focusPanel">
|
||||||
|
<div
|
||||||
|
v-if="active"
|
||||||
|
ref="panel"
|
||||||
|
class="detail-panel my-4"
|
||||||
|
role="region"
|
||||||
|
:aria-label="title"
|
||||||
|
tabindex="-1"
|
||||||
|
:data-testid="`section-detail-${slug}`"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent } from "vue";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: "ConfigSection",
|
||||||
|
props: {
|
||||||
|
slug: { type: String, required: true },
|
||||||
|
title: { type: String, required: true },
|
||||||
|
mobile: Boolean,
|
||||||
|
active: Boolean,
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
focusPanel() {
|
||||||
|
(this.$refs["panel"] as HTMLElement | undefined)?.focus({ preventScroll: true });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* no ring on programmatic focus */
|
||||||
|
.detail-panel {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
107
assets/js/components/Config/ConfigSectionNav.vue
Normal file
107
assets/js/components/Config/ConfigSectionNav.vue
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
<template>
|
||||||
|
<nav class="nav-list round-box box-pull-out p-0" data-testid="config-section-nav">
|
||||||
|
<button
|
||||||
|
v-for="section in sections"
|
||||||
|
:key="section.slug"
|
||||||
|
type="button"
|
||||||
|
class="nav-row d-flex align-items-center gap-3 w-100 text-start px-3"
|
||||||
|
:data-slug="section.slug"
|
||||||
|
@click="open(section.slug)"
|
||||||
|
>
|
||||||
|
<div class="nav-icon d-flex align-items-center justify-content-center flex-shrink-0">
|
||||||
|
<component :is="section.icon" v-if="section.icon" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1 overflow-hidden">
|
||||||
|
<div class="fw-bold text-truncate">{{ section.title }}</div>
|
||||||
|
<div v-if="section.subline" class="small text-gray text-truncate">
|
||||||
|
{{ section.subline }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
v-if="section.error"
|
||||||
|
class="circle-badge bg-danger flex-shrink-0"
|
||||||
|
data-testid="section-error"
|
||||||
|
>
|
||||||
|
<span class="visually-hidden">{{ $t("general.error") }}</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else-if="section.warning"
|
||||||
|
class="circle-badge bg-warning flex-shrink-0"
|
||||||
|
data-testid="section-warning"
|
||||||
|
>
|
||||||
|
<span class="visually-hidden">{{ $t("general.warning") }}</span>
|
||||||
|
</span>
|
||||||
|
<span v-if="section.count" class="fw-bold text-gray flex-shrink-0">
|
||||||
|
{{ section.count }}
|
||||||
|
</span>
|
||||||
|
<ChevronRight class="chevron flex-shrink-0" />
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent, type Component, type PropType } from "vue";
|
||||||
|
import ChevronRight from "../MaterialIcon/ChevronRight.vue";
|
||||||
|
import { hapticFeedback } from "@/utils/haptic";
|
||||||
|
|
||||||
|
export interface SectionEntry {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
icon?: Component | string;
|
||||||
|
count?: number;
|
||||||
|
error?: boolean;
|
||||||
|
warning?: boolean;
|
||||||
|
subline?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: "ConfigSectionNav",
|
||||||
|
components: { ChevronRight },
|
||||||
|
props: {
|
||||||
|
sections: { type: Array as PropType<SectionEntry[]>, required: true },
|
||||||
|
},
|
||||||
|
emits: ["open"],
|
||||||
|
methods: {
|
||||||
|
open(slug: string) {
|
||||||
|
hapticFeedback("light");
|
||||||
|
this.$emit("open", slug);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.nav-row {
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid var(--bs-border-color-translucent);
|
||||||
|
color: var(--evcc-default-text);
|
||||||
|
min-height: 3.5rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
/* carry the parent .round-box rounding so focus rings and :active follow it */
|
||||||
|
.nav-row:first-child {
|
||||||
|
border-top-left-radius: 1rem;
|
||||||
|
border-top-right-radius: 1rem;
|
||||||
|
}
|
||||||
|
.nav-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
border-bottom-left-radius: 1rem;
|
||||||
|
border-bottom-right-radius: 1rem;
|
||||||
|
}
|
||||||
|
.nav-row:active {
|
||||||
|
background: var(--evcc-box-border);
|
||||||
|
}
|
||||||
|
/* uniform icon slot; font-size 0 collapses the shopicons' inner inline-svg line box */
|
||||||
|
.nav-icon > * {
|
||||||
|
display: block;
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
font-size: 0;
|
||||||
|
}
|
||||||
|
.chevron {
|
||||||
|
color: var(--evcc-gray);
|
||||||
|
margin-right: -0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -13,9 +13,3 @@ export default {
|
||||||
components: { Markdown },
|
components: { Markdown },
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.alert {
|
|
||||||
margin-bottom: 5rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
|
||||||
15
assets/js/components/MaterialIcon/ChevronRight.vue
Normal file
15
assets/js/components/MaterialIcon/ChevronRight.vue
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<template>
|
||||||
|
<svg :style="svgStyle" viewBox="0 -960 960 960">
|
||||||
|
<path fill="currentColor" d="M504-480 320-664l56-56 240 240-240 240-56-56 184-184Z" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent } from "vue";
|
||||||
|
import icon from "@/mixins/icon";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: "ChevronRight",
|
||||||
|
mixins: [icon],
|
||||||
|
});
|
||||||
|
</script>
|
||||||
18
assets/js/components/MaterialIcon/Integrations.vue
Normal file
18
assets/js/components/MaterialIcon/Integrations.vue
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<template>
|
||||||
|
<svg :style="svgStyle" viewBox="0 -960 960 960">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M200-200h520v-184l45-22q16-8 25.5-22t9.5-32q0-17-9.5-31.5T765-514l-45-21v-185H528l-10-68q-3-22-19.5-37T460-840q-23 0-39.5 15T401-788l-10 68H200v86q56 21 88 68t32 106q0 60-32 107t-88 68v85Zm0 80q-34 0-57-23t-23-57v-152q48 0 84-30.5t36-77.5q0-46-36-76t-84-32v-152q0-33 23.5-56.5T200-800h122q7-51 46-85.5t92-34.5q52 0 91 34.5t47 85.5h122q33 0 56.5 23.5T800-720v134q36 18 58 52t22 74q0 41-22 75t-58 51v134q0 34-23.5 57T720-120H200Zm300-340Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent } from "vue";
|
||||||
|
import icon from "@/mixins/icon";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: "Integrations",
|
||||||
|
mixins: [icon],
|
||||||
|
});
|
||||||
|
</script>
|
||||||
18
assets/js/components/MaterialIcon/Services.vue
Normal file
18
assets/js/components/MaterialIcon/Services.vue
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<template>
|
||||||
|
<svg :style="svgStyle" viewBox="0 -960 960 960">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M300-720q-25 0-42.5 17.5T240-660q0 25 17.5 42.5T300-600q25 0 42.5-17.5T360-660q0-25-17.5-42.5T300-720Zm0 400q-25 0-42.5 17.5T240-260q0 25 17.5 42.5T300-200q25 0 42.5-17.5T360-260q0-25-17.5-42.5T300-320ZM160-840h640q17 0 28.5 11.5T840-800v280q0 17-11.5 28.5T800-480H160q-17 0-28.5-11.5T120-520v-280q0-17 11.5-28.5T160-840Zm40 80v200h560v-200H200Zm-40 320h640q17 0 28.5 11.5T840-400v280q0 17-11.5 28.5T800-80H160q-17 0-28.5-11.5T120-120v-280q0-17 11.5-28.5T160-440Zm40 80v200h560v-200H200Zm0-400v200-200Zm0 400v200-200Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent } from "vue";
|
||||||
|
import icon from "@/mixins/icon";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: "Services",
|
||||||
|
mixins: [icon],
|
||||||
|
});
|
||||||
|
</script>
|
||||||
18
assets/js/components/MaterialIcon/System.vue
Normal file
18
assets/js/components/MaterialIcon/System.vue
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<template>
|
||||||
|
<svg :style="svgStyle" viewBox="0 -960 960 960">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M686-132 444-376q-20 8-40.5 12t-43.5 4q-100 0-170-70t-70-170q0-36 10-68.5t28-61.5l146 146 72-72-146-146q29-18 61.5-28t68.5-10q100 0 170 70t70 170q0 23-4 43.5T584-516l244 242q12 12 12 29t-12 29l-84 84q-12 12-29 12t-29-12Zm29-85 27-27-256-256q18-20 26-46.5t8-53.5q0-60-38.5-104.5T386-758l74 74q12 12 12 28t-12 28L332-500q-12 12-28 12t-28-12l-74-74q9 57 53.5 95.5T360-440q26 0 52-8t47-25l256 256ZM472-488Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent } from "vue";
|
||||||
|
import icon from "@/mixins/icon";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: "System",
|
||||||
|
mixins: [icon],
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
@ -3,14 +3,30 @@
|
||||||
class="d-flex justify-content-between align-items-center py-3 py-md-4"
|
class="d-flex justify-content-between align-items-center py-3 py-md-4"
|
||||||
data-testid="header"
|
data-testid="header"
|
||||||
>
|
>
|
||||||
<h1 class="mb-1 pt-1 d-flex text-nowrap text-truncate">
|
<div class="title position-relative flex-grow-1 mb-1">
|
||||||
<span class="text-truncate">{{ title }}</span>
|
<Transition name="fade-swap-left">
|
||||||
</h1>
|
<h1 v-if="!parentTitle" class="mb-0 pt-1 text-truncate">{{ title }}</h1>
|
||||||
|
</Transition>
|
||||||
|
<Transition name="fade-swap-right">
|
||||||
|
<div v-if="parentTitle" class="d-flex align-items-center text-nowrap">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-link back-button d-flex align-items-center p-0 border-0 me-2"
|
||||||
|
:aria-label="$t('general.back')"
|
||||||
|
@click="$emit('back')"
|
||||||
|
>
|
||||||
|
<shopicon-bold-arrowback></shopicon-bold-arrowback>
|
||||||
|
</button>
|
||||||
|
<h1 class="mb-0 pt-1 text-truncate">{{ title }}</h1>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
<TopNavigationArea ref="navigationArea" :notifications="notifications" />
|
<TopNavigationArea ref="navigationArea" :notifications="notifications" />
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import "@h2d2/shopicons/es/bold/arrowback";
|
||||||
import TopNavigationArea from "./TopNavigationArea.vue";
|
import TopNavigationArea from "./TopNavigationArea.vue";
|
||||||
import { defineComponent, type PropType } from "vue";
|
import { defineComponent, type PropType } from "vue";
|
||||||
import type { Notification } from "@/types/evcc";
|
import type { Notification } from "@/types/evcc";
|
||||||
|
|
@ -22,8 +38,11 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
title: String,
|
title: String,
|
||||||
|
// set on drill-down pages: shows a back button next to the title
|
||||||
|
parentTitle: String,
|
||||||
notifications: { type: Array as PropType<Notification[]>, default: () => [] },
|
notifications: { type: Array as PropType<Notification[]>, default: () => [] },
|
||||||
},
|
},
|
||||||
|
emits: ["back"],
|
||||||
methods: {
|
methods: {
|
||||||
requestAuthProvider(providerId: string) {
|
requestAuthProvider(providerId: string) {
|
||||||
const navigationArea = this.$refs["navigationArea"] as
|
const navigationArea = this.$refs["navigationArea"] as
|
||||||
|
|
@ -34,3 +53,13 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* allow the flex item to shrink so the titles can truncate */
|
||||||
|
.title {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.back-button {
|
||||||
|
color: var(--evcc-default-text);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -71,5 +71,6 @@ describe("extractQueryString", () => {
|
||||||
expect(extractQueryString("/config")).toBe("");
|
expect(extractQueryString("/config")).toBe("");
|
||||||
expect(extractQueryString("/#/config?meter=1")).toBe("meter=1");
|
expect(extractQueryString("/#/config?meter=1")).toBe("meter=1");
|
||||||
expect(extractQueryString("/config?meter=1&vehicle=2")).toBe("meter=1&vehicle=2");
|
expect(extractQueryString("/config?meter=1&vehicle=2")).toBe("meter=1&vehicle=2");
|
||||||
|
expect(extractQueryString("/config?meter=1#vehicles")).toBe("meter=1");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -194,7 +194,7 @@ export function buildQuery(stack: ModalEntry[]): Record<string, string> {
|
||||||
export function extractQueryString(fullPath: string): string {
|
export function extractQueryString(fullPath: string): string {
|
||||||
const qIdx = fullPath.indexOf("?");
|
const qIdx = fullPath.indexOf("?");
|
||||||
if (qIdx === -1) return "";
|
if (qIdx === -1) return "";
|
||||||
return fullPath.substring(qIdx + 1);
|
return fullPath.substring(qIdx + 1).split("#")[0]!;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initConfigModal(router: Router): void {
|
export function initConfigModal(router: Router): void {
|
||||||
|
|
@ -254,7 +254,7 @@ export function openModal(
|
||||||
|
|
||||||
return new Promise<ModalResult>((resolve) => {
|
return new Promise<ModalResult>((resolve) => {
|
||||||
_resolvers.push(resolve);
|
_resolvers.push(resolve);
|
||||||
_router!.push({ path: "/config", query });
|
_router!.push({ path: "/config", query, hash: _router!.currentRoute.value.hash });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -280,7 +280,7 @@ export async function closeModal(result?: ModalResult): Promise<void> {
|
||||||
// Update stack synchronously to prevent double-close from GenericModal's handleHidden
|
// Update stack synchronously to prevent double-close from GenericModal's handleHidden
|
||||||
configModal.stack = newStack;
|
configModal.stack = newStack;
|
||||||
|
|
||||||
await _router.push({ path: "/config", query });
|
await _router.push({ path: "/config", query, hash: _router.currentRoute.value.hash });
|
||||||
resolve?.(finalResult);
|
resolve?.(finalResult);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -299,7 +299,7 @@ export function replaceModal(
|
||||||
const newStack = [...configModal.stack.slice(0, -1), entry];
|
const newStack = [...configModal.stack.slice(0, -1), entry];
|
||||||
const query = buildQuery(newStack);
|
const query = buildQuery(newStack);
|
||||||
|
|
||||||
_router.replace({ path: "/config", query });
|
_router.replace({ path: "/config", query, hash: _router.currentRoute.value.hash });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getModal(name: string): ModalEntry | undefined {
|
export function getModal(name: string): ModalEntry | undefined {
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,20 @@
|
||||||
export type Breakpoint = "xs" | "sm" | "md" | "lg" | "xl" | "xxl";
|
export type Breakpoint = "xs" | "sm" | "md" | "lg" | "xl" | "xxl";
|
||||||
|
|
||||||
interface BreakpointDef {
|
// bootstrap breakpoints, value = last width still inside
|
||||||
name: Breakpoint;
|
export const MAX_WIDTH: Record<Breakpoint, number> = {
|
||||||
maxWidth: number;
|
xs: 575,
|
||||||
}
|
sm: 767,
|
||||||
|
md: 991,
|
||||||
|
lg: 1199,
|
||||||
|
xl: 1399,
|
||||||
|
xxl: Infinity,
|
||||||
|
};
|
||||||
|
|
||||||
const BREAKPOINTS: BreakpointDef[] = [
|
const BREAKPOINTS = Object.entries(MAX_WIDTH) as [Breakpoint, number][];
|
||||||
{ name: "xs", maxWidth: 575 },
|
|
||||||
{ name: "sm", maxWidth: 767 },
|
export function isMobileWidth(width: number = window.innerWidth): boolean {
|
||||||
{ name: "md", maxWidth: 991 },
|
return width <= MAX_WIDTH.sm;
|
||||||
{ name: "lg", maxWidth: 1199 },
|
}
|
||||||
{ name: "xl", maxWidth: 1399 },
|
|
||||||
{ name: "xxl", maxWidth: Infinity },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
|
|
@ -24,9 +26,9 @@ export default {
|
||||||
updateBreakpoint(): void {
|
updateBreakpoint(): void {
|
||||||
const width: number = window.innerWidth;
|
const width: number = window.innerWidth;
|
||||||
const self = this as any;
|
const self = this as any;
|
||||||
for (const bp of BREAKPOINTS) {
|
for (const [name, maxWidth] of BREAKPOINTS) {
|
||||||
if (width <= bp.maxWidth) {
|
if (width <= maxWidth) {
|
||||||
self.breakpoint = bp.name;
|
self.breakpoint = name;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
56
assets/js/mixins/listDetail.ts
Normal file
56
assets/js/mixins/listDetail.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { defineComponent } from "vue";
|
||||||
|
import breakpoint, { MAX_WIDTH, isMobileWidth } from "./breakpoint";
|
||||||
|
import { hapticFeedback } from "@/utils/haptic";
|
||||||
|
|
||||||
|
// hash-driven list>detail pages (see Config.vue): consumers override `activeSlug`,
|
||||||
|
// list rows carry data-slug; pairs with TopHeader subpageTitle + fade-swap classes
|
||||||
|
export default defineComponent({
|
||||||
|
mixins: [breakpoint],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
listScroll: 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
mobile(): boolean {
|
||||||
|
return isMobileWidth(MAX_WIDTH[this.breakpoint]);
|
||||||
|
},
|
||||||
|
activeSlug(): string | undefined {
|
||||||
|
return undefined; // consumers override
|
||||||
|
},
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
"$route.hash"(_: string, oldHash: string) {
|
||||||
|
// new content dictates the scroll position
|
||||||
|
if (this.mobile && this.activeSlug) {
|
||||||
|
this.listScroll = window.scrollY;
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
}
|
||||||
|
// back to the list: restore its scroll and focus the originating row
|
||||||
|
if (this.mobile && !this.activeSlug && oldHash) {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
window.scrollTo(0, this.listScroll);
|
||||||
|
document
|
||||||
|
.querySelector<HTMLElement>(`[data-slug="${oldHash.slice(1)}"]`)
|
||||||
|
?.focus({ preventScroll: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
goBack() {
|
||||||
|
hapticFeedback("light");
|
||||||
|
const back = this.$router.options.history.state["back"] as string | null;
|
||||||
|
if (back?.startsWith(this.$route.path) && !back.includes("#")) {
|
||||||
|
this.$router.back();
|
||||||
|
} else {
|
||||||
|
// direct deep link, browser back would leave the app
|
||||||
|
this.$router.replace({
|
||||||
|
path: this.$route.path,
|
||||||
|
query: this.$route.query,
|
||||||
|
hash: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
isConfigured,
|
isConfigured,
|
||||||
} from "./components/Auth/auth";
|
} from "./components/Auth/auth";
|
||||||
import { initConfigModal } from "./configModal";
|
import { initConfigModal } from "./configModal";
|
||||||
|
import { isMobileWidth } from "./mixins/breakpoint";
|
||||||
import { hapticFeedback } from "./utils/haptic";
|
import { hapticFeedback } from "./utils/haptic";
|
||||||
import type { VueI18nInstance } from "vue-i18n";
|
import type { VueI18nInstance } from "vue-i18n";
|
||||||
|
|
||||||
|
|
@ -65,6 +66,10 @@ export default function setupRouter(i18n: VueI18nInstance) {
|
||||||
stringifyQuery,
|
stringifyQuery,
|
||||||
scrollBehavior(to, from) {
|
scrollBehavior(to, from) {
|
||||||
if (to.hash) {
|
if (to.hash) {
|
||||||
|
// config section hashes open a detail panel on small screens, no scrolling
|
||||||
|
if (to.path === "/config" && isMobileWidth()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const check = () => {
|
const check = () => {
|
||||||
if (document.querySelector(to.hash)) {
|
if (document.querySelector(to.hash)) {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1175,9 +1175,12 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"general": {
|
"general": {
|
||||||
|
"back": "Zurück",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
|
"error": "Fehler",
|
||||||
"none": "keine",
|
"none": "keine",
|
||||||
"note": "Hinweis:"
|
"note": "Hinweis:",
|
||||||
|
"warning": "Warnung"
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"about": "Über",
|
"about": "Über",
|
||||||
|
|
|
||||||
11
i18n/en.json
11
i18n/en.json
|
|
@ -842,8 +842,8 @@
|
||||||
"general": "General",
|
"general": "General",
|
||||||
"grid": "Grid",
|
"grid": "Grid",
|
||||||
"integrations": "Integrations",
|
"integrations": "Integrations",
|
||||||
"loadpoints": "Charging points & Heaters",
|
"loadpoints": "Charging points & heaters",
|
||||||
"meter": "Solar & Battery",
|
"meter": "Solar & battery",
|
||||||
"services": "Services",
|
"services": "Services",
|
||||||
"system": "System",
|
"system": "System",
|
||||||
"vehicles": "Vehicles"
|
"vehicles": "Vehicles"
|
||||||
|
|
@ -977,7 +977,7 @@
|
||||||
"titleEdit": "Edit Temperature Forecast"
|
"titleEdit": "Edit Temperature Forecast"
|
||||||
},
|
},
|
||||||
"template": "Provider",
|
"template": "Provider",
|
||||||
"title": "Tariffs & Forecasts",
|
"title": "Tariffs & forecasts",
|
||||||
"titleChoice": "What Do You Want To Add?",
|
"titleChoice": "What Do You Want To Add?",
|
||||||
"type": {
|
"type": {
|
||||||
"co2": "CO₂ Intensity",
|
"co2": "CO₂ Intensity",
|
||||||
|
|
@ -1175,9 +1175,12 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"general": {
|
"general": {
|
||||||
|
"back": "Back",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
|
"error": "Error",
|
||||||
"none": "none",
|
"none": "none",
|
||||||
"note": "Note:"
|
"note": "Note:",
|
||||||
|
"warning": "Warning"
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"about": "About",
|
"about": "About",
|
||||||
|
|
|
||||||
152
tests/config-mobile-nav.spec.ts
Normal file
152
tests/config-mobile-nav.spec.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
import { test, expect, devices } from "@playwright/test";
|
||||||
|
import { start, stop, restart, baseUrl } from "./evcc";
|
||||||
|
import { expectModalHidden, expectModalVisible, newLoadpoint, addDemoCharger } from "./utils";
|
||||||
|
|
||||||
|
test.use({ baseURL: baseUrl(), viewport: devices["iPhone 12 Mini"].viewport });
|
||||||
|
test.describe.configure({ mode: "parallel" });
|
||||||
|
|
||||||
|
const desktop = devices["Desktop Chrome"].viewport;
|
||||||
|
|
||||||
|
test.describe("mobile section navigation", async () => {
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
await start();
|
||||||
|
});
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await stop();
|
||||||
|
});
|
||||||
|
test("shows section list instead of long page", async ({ page }) => {
|
||||||
|
await page.goto("/#/config");
|
||||||
|
const nav = page.getByTestId("config-section-nav");
|
||||||
|
await expect(nav).toBeVisible();
|
||||||
|
await expect(nav.getByRole("button", { name: "Vehicles" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("heading", { name: "Vehicles" })).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("open and close section detail", async ({ page }) => {
|
||||||
|
await page.goto("/#/config");
|
||||||
|
await page.getByRole("button", { name: "Vehicles" }).click();
|
||||||
|
|
||||||
|
const detail = page.getByTestId("section-detail-vehicles");
|
||||||
|
await expect(detail).toBeVisible();
|
||||||
|
await page.waitForURL("**/#/config#vehicles");
|
||||||
|
await expect(detail.getByTestId("add-vehicle")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Back" }).click();
|
||||||
|
await expect(detail).not.toBeVisible();
|
||||||
|
await page.waitForURL("**/#/config");
|
||||||
|
await expect(page.getByTestId("config-section-nav")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("browser back closes section detail", async ({ page }) => {
|
||||||
|
await page.goto("/#/config");
|
||||||
|
await page.getByRole("button", { name: "Grid" }).click();
|
||||||
|
await expect(page.getByTestId("section-detail-grid")).toBeVisible();
|
||||||
|
|
||||||
|
await page.goBack();
|
||||||
|
await expect(page.getByTestId("section-detail-grid")).not.toBeVisible();
|
||||||
|
await expect(page.getByTestId("config-section-nav")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deep link opens section detail directly", async ({ page }) => {
|
||||||
|
await page.goto("/#/config#tariffs");
|
||||||
|
const detail = page.getByTestId("section-detail-tariffs");
|
||||||
|
await expect(detail).toBeVisible();
|
||||||
|
|
||||||
|
// back button must not leave the app
|
||||||
|
await page.getByRole("button", { name: "Back" }).click();
|
||||||
|
await expect(detail).not.toBeVisible();
|
||||||
|
await page.waitForURL("**/#/config");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("modal keeps section detail open", async ({ page }) => {
|
||||||
|
await page.goto("/#/config#vehicles");
|
||||||
|
const detail = page.getByTestId("section-detail-vehicles");
|
||||||
|
await expect(detail).toBeVisible();
|
||||||
|
|
||||||
|
await detail.getByTestId("add-vehicle").click();
|
||||||
|
const vehicleModal = page.getByTestId("vehicle-modal");
|
||||||
|
await expectModalVisible(vehicleModal);
|
||||||
|
expect(page.url()).toContain("vehicle");
|
||||||
|
expect(page.url()).toContain("#vehicles");
|
||||||
|
|
||||||
|
await vehicleModal.getByRole("button", { name: "Cancel" }).click();
|
||||||
|
await expectModalHidden(vehicleModal);
|
||||||
|
await expect(detail).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("modal deep link with section hash survives reload", async ({ page }) => {
|
||||||
|
await page.goto("/#/config#integrations");
|
||||||
|
const detail = page.getByTestId("section-detail-integrations");
|
||||||
|
await expect(detail).toBeVisible();
|
||||||
|
|
||||||
|
await detail.getByTestId("mqtt").getByRole("button", { name: "edit" }).click();
|
||||||
|
const mqttModal = page.getByTestId("mqtt-modal");
|
||||||
|
await expectModalVisible(mqttModal);
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await expectModalVisible(mqttModal);
|
||||||
|
await mqttModal.getByRole("button", { name: "Cancel" }).click();
|
||||||
|
await expectModalHidden(mqttModal);
|
||||||
|
await expect(detail).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("desktop keeps long page and scrolls to anchor", async ({ page }) => {
|
||||||
|
await page.setViewportSize(desktop);
|
||||||
|
await page.goto("/#/config#tariffs");
|
||||||
|
|
||||||
|
await expect(page.getByTestId("config-section-nav")).not.toBeVisible();
|
||||||
|
await expect(page.getByTestId("section-detail-tariffs")).not.toBeVisible();
|
||||||
|
const heading = page.getByRole("heading", { name: "Tariffs & forecasts" });
|
||||||
|
await expect(heading).toBeInViewport();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resize switches between list and long page", async ({ page }) => {
|
||||||
|
await page.goto("/#/config#grid");
|
||||||
|
await expect(page.getByTestId("section-detail-grid")).toBeVisible();
|
||||||
|
|
||||||
|
await page.setViewportSize(desktop);
|
||||||
|
await expect(page.getByTestId("section-detail-grid")).not.toBeVisible();
|
||||||
|
// level 2: the header title briefly shows "Grid" while its swap transition runs
|
||||||
|
await expect(page.getByRole("heading", { name: "Grid", level: 2 })).toBeVisible();
|
||||||
|
|
||||||
|
await page.setViewportSize(devices["iPhone 12 Mini"].viewport);
|
||||||
|
await expect(page.getByTestId("section-detail-grid")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("section indicators", async () => {
|
||||||
|
test.afterEach(async () => {
|
||||||
|
await stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("device counts", async ({ page }) => {
|
||||||
|
await start("config-with-vehicle.evcc.yaml");
|
||||||
|
await page.goto("/#/config");
|
||||||
|
|
||||||
|
const nav = page.getByTestId("config-section-nav");
|
||||||
|
await expect(nav.getByRole("button", { name: "Charging points & heaters" })).toContainText("1");
|
||||||
|
await expect(nav.getByRole("button", { name: "Vehicles" })).toContainText("1");
|
||||||
|
await expect(nav.getByRole("button", { name: "Grid" })).toContainText("1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("error indicator on fatal device error", async ({ page }) => {
|
||||||
|
await start("config-invalid-references-vehicle.evcc.yaml");
|
||||||
|
await page.goto("/#/config#loadpoints");
|
||||||
|
|
||||||
|
// loadpoint referencing a vehicle that disappears after restart
|
||||||
|
await newLoadpoint(page, "Garage");
|
||||||
|
await addDemoCharger(page);
|
||||||
|
const lpModal = page.getByTestId("loadpoint-modal");
|
||||||
|
await lpModal.getByRole("link", { name: "Advanced configuration" }).click();
|
||||||
|
await lpModal.getByLabel("Default vehicle").selectOption("Legacy Vehicle");
|
||||||
|
await lpModal.getByRole("button", { name: "Save" }).click();
|
||||||
|
await expectModalHidden(lpModal);
|
||||||
|
|
||||||
|
await restart();
|
||||||
|
await page.goto("/#/config");
|
||||||
|
|
||||||
|
await expect(page.getByTestId("fatal-error")).toBeVisible();
|
||||||
|
const row = page.getByRole("button", { name: "Charging points & heaters" });
|
||||||
|
await expect(row.getByTestId("section-error")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -26,7 +26,7 @@ test.describe("tariffs", async () => {
|
||||||
await page.goto("/#/config");
|
await page.goto("/#/config");
|
||||||
|
|
||||||
// New configuration section should show with "Add Tariff" button
|
// New configuration section should show with "Add Tariff" button
|
||||||
await expect(page.getByRole("heading", { name: "Tariffs & Forecasts" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Tariffs & forecasts" })).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "Add Tariff" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Add Tariff" })).toBeVisible();
|
||||||
|
|
||||||
// Old tariff card should not be shown
|
// Old tariff card should not be shown
|
||||||
|
|
@ -97,7 +97,7 @@ grid:
|
||||||
await expect(page.getByTestId("tariffs-legacy")).toBeVisible();
|
await expect(page.getByTestId("tariffs-legacy")).toBeVisible();
|
||||||
await expect(page.getByTestId("tariffs-legacy")).toContainText(
|
await expect(page.getByTestId("tariffs-legacy")).toContainText(
|
||||||
[
|
[
|
||||||
"Tariffs & Forecasts",
|
"Tariffs & forecasts",
|
||||||
"Grid price",
|
"Grid price",
|
||||||
"30.0 öre",
|
"30.0 öre",
|
||||||
"Feed-in price",
|
"Feed-in price",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue