From 21efd23fcfe53ce411acc5e01fae9e246d401f0d Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Sun, 12 Apr 2026 08:07:46 +0200 Subject: [PATCH] iOS: fix modal+validation scrolling (#29023) --- assets/css/app.css | 5 +++++ .../Config/DeviceModal/DeviceModalBase.vue | 3 ++- .../Config/utils/reportValidityInModal.ts | 20 +++++++++++++++++++ assets/js/components/Config/utils/test.ts | 3 ++- 4 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 assets/js/components/Config/utils/reportValidityInModal.ts diff --git a/assets/css/app.css b/assets/css/app.css index 992b23c68..cc748436c 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -444,6 +444,11 @@ a:hover { } } +.modal { + /* prevent touch-scroll chaining from the modal to on iOS Safari */ + overscroll-behavior: contain; +} + .modal-body { padding: 1rem 0 0; } diff --git a/assets/js/components/Config/DeviceModal/DeviceModalBase.vue b/assets/js/components/Config/DeviceModal/DeviceModalBase.vue index 5bd10b632..104b45f9a 100644 --- a/assets/js/components/Config/DeviceModal/DeviceModalBase.vue +++ b/assets/js/components/Config/DeviceModal/DeviceModalBase.vue @@ -188,6 +188,7 @@ import YamlEntry from "./YamlEntry.vue"; import AuthCodeDisplay from "../AuthCodeDisplay.vue"; import AuthConnectButton from "../AuthConnectButton.vue"; import { initialTestState, performTest } from "../utils/test"; +import { reportValidityInModal } from "../utils/reportValidityInModal"; import { initialAuthState, prepareAuthLogin } from "../utils/authProvider"; import sleep from "@/utils/sleep"; import { ConfigType } from "@/types/evcc"; @@ -618,7 +619,7 @@ export default defineComponent({ // trigger browser validation if (this.$refs["form"]) { - if (!(this.$refs["form"] as HTMLFormElement).reportValidity()) { + if (!reportValidityInModal(this.$refs["form"] as HTMLFormElement)) { return; } } diff --git a/assets/js/components/Config/utils/reportValidityInModal.ts b/assets/js/components/Config/utils/reportValidityInModal.ts new file mode 100644 index 000000000..9a47b9f30 --- /dev/null +++ b/assets/js/components/Config/utils/reportValidityInModal.ts @@ -0,0 +1,20 @@ +/** + * `form.reportValidity()` with the document scroll restored afterwards. + * + * The native call scroll-into-views the first invalid field and leaks scroll + * up to `` (overflow:hidden only blocks user scroll). On iOS Safari + * that wedges the modal's touch scrolling onto the page underneath. + */ +export function reportValidityInModal(form: HTMLFormElement): boolean { + const { scrollX, scrollY } = window; + const valid = form.reportValidity(); + if (window.scrollX !== scrollX || window.scrollY !== scrollY) { + // Chromium ignores scrollTop writes while overflow-y:hidden; relax briefly. + const html = document.documentElement; + const prev = html.style.overflowY; + html.style.overflowY = "auto"; + window.scrollTo(scrollX, scrollY); + html.style.overflowY = prev; + } + return valid; +} diff --git a/assets/js/components/Config/utils/test.ts b/assets/js/components/Config/utils/test.ts index 7a0e1723c..f6e8d6047 100644 --- a/assets/js/components/Config/utils/test.ts +++ b/assets/js/components/Config/utils/test.ts @@ -1,5 +1,6 @@ import type { AxiosResponse } from "axios"; import sleep from "@/utils/sleep"; +import { reportValidityInModal } from "./reportValidityInModal"; export type TestState = { isUnknown: boolean; @@ -28,7 +29,7 @@ export const performTest = async ( api: () => Promise>, form: HTMLElement | undefined ) => { - if (form && !(form as HTMLFormElement).reportValidity()) return false; + if (form && !reportValidityInModal(form as HTMLFormElement)) return false; state.isUnknown = false; state.isSuccess = false; state.isRunning = true;