From 0fa8fdbbac51120f60f688615ecac12b5ec01bd9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 10:53:04 +0000 Subject: [PATCH] =?UTF-8?q?Kundenansicht=20neu:=20Leiste=20am=20iPhone=20w?= =?UTF-8?q?ieder=20ganz=20unten=20(iOS-H=C3=B6henfehler)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In der App vom Home-Bildschirm bleibt auf iOS nach dem Schließen der Tastatur die Fensterhöhe oft rund 60 px kleiner (WebKit-Fehler). Leiste und Chat-Eingabe stehen dann zu hoch, darunter ein leerer Streifen, bis die App neu startet. hoehe.js merkt sich die volle Höhe je Breite und blendet die Hülle nach dem Verlassen eines Feldes, nach einem resize oder wenn die App wieder vorne ist kurz aus und ein, wenn die Höhe hängt. Das zwingt WebKit zum Neu-Messen. Scrollstände und Fokus bleiben, höchstens zwei Versuche je Anlass, nur in der iOS-App vom Home-Bildschirm am Handy. Co-Authored-By: Claude Opus 5.5 --- public/neu/hoehe.js | 89 ++++++++++++++++++++++++++++++++++ public/neu/huelle.js | 5 +- public/neu/leiste.js | 4 +- test/knHoehe.test.js | 111 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 207 insertions(+), 2 deletions(-) create mode 100644 public/neu/hoehe.js create mode 100644 test/knHoehe.test.js diff --git a/public/neu/hoehe.js b/public/neu/hoehe.js new file mode 100644 index 0000000..68e6327 --- /dev/null +++ b/public/neu/hoehe.js @@ -0,0 +1,89 @@ +/* Kundenansicht neu: iOS-Fehler der App vom Home-Bildschirm. Nach dem Schließen der Tastatur bleibt die Fensterhöhe oft kleiner + (WebKit, bekannt seit iOS 17, in iOS 26 wieder da): alles, was unten fest steht (Leiste, Chat-Eingabe), sitzt dann rund 60 px zu hoch, + darunter bleibt ein leerer weißer Streifen, bis die App neu startet. Abhilfe: die Hülle (mindestens volle Fensterhöhe) kurz aus- und + wieder einblenden, das zwingt WebKit, die Höhe neu zu messen. Scrollstände und Fokus bleiben. Nur in der App vom Home-Bildschirm auf + iOS (das entscheidet `aktiv`) und nur, wenn die Höhe wirklich hängt. */ + +export const LUFT = 4 // so viel darf die Höhe schwanken, ohne dass sie als hängend gilt +export const PRUEF_MS = [150, 600] // nach dem Schließen der Tastatur: früh und noch einmal, wenn die Animation sicher durch ist +export const MAX_VERSUCHE = 2 // je Anlass (Tastatur zu, App wieder vorne); hilft es nicht, bleibt es bis zum nächsten Anlass dabei + +/** Reiner Zustand { voll, breite }: größte innere Höhe bei dieser Breite. Ein Breitenwechsel (Drehen, iPad-Fenster) fängt neu an. */ +export function hoeheZustand(alt, { hoehe, breite }) { + if (!alt || Math.abs(breite - alt.breite) > 1) return { voll: hoehe, breite } + return { voll: Math.max(alt.voll, hoehe), breite: alt.breite } +} + +/** Hängt die Höhe? Kleiner als die größte bei gleicher Breite und kein Textfeld im Fokus (eine offene Tastatur ist kein Hängen). */ +export function haengt(zustand, { hoehe, breite, textfeldAktiv }) { + if (!zustand || textfeldAktiv) return false + if (Math.abs(breite - zustand.breite) > 1) return false + return zustand.voll - hoehe > LUFT +} + +/** + * Verdrahtung: merkt sich die volle Höhe (resize), prüft nach dem Verlassen eines Feldes (focusout), nach einem resize ohne Textfeld + * und wenn die App wieder nach vorne kommt (visibilitychange), jeweils zu den Zeiten PRUEF_MS. Rückgabe { pruefe, stand, beende }. + */ +export function beobachteHoehe({ win = window, doc = document, element, aktiv = () => true, istTextfeld = () => false }) { + const messe = () => ({ hoehe: win.innerHeight, breite: win.innerWidth }) + let z = hoeheZustand(null, messe()) + let versuche = 0 + let timer = [] + const merke = () => { z = hoeheZustand(z, messe()) } + + /** Hülle aus- und einblenden, mit synchronem Umbruch dazwischen; vorher gescrollte Kästen, Fensterscroll und Fokus zurücksetzen. */ + function heile() { + const fensterX = win.scrollX ?? 0 + const fensterY = win.scrollY ?? 0 + const staende = [] + for (const el of element.querySelectorAll('*')) { + if (el.scrollTop || el.scrollLeft) staende.push([el, el.scrollTop, el.scrollLeft]) + } + const fokus = doc.activeElement && doc.activeElement !== doc.body && element.contains(doc.activeElement) ? doc.activeElement : null + const vorher = element.style.display + element.style.display = 'none' + void element.offsetHeight + element.style.display = vorher + void element.offsetHeight + for (const [el, top, left] of staende) { el.scrollTop = top; el.scrollLeft = left } + if (fensterX || fensterY) win.scrollTo(fensterX, fensterY) + if (fokus && doc.activeElement !== fokus) fokus.focus?.({ preventScroll: true }) + } + + function pruefe() { + if (!aktiv() || !element) return false + const m = messe() + if (!haengt(z, { ...m, textfeldAktiv: istTextfeld(doc.activeElement) })) { merke(); return false } + if (versuche >= MAX_VERSUCHE) return false + versuche += 1 + heile() + merke() + return true + } + + const warte = (fn, ms) => (win.setTimeout ? win.setTimeout(fn, ms) : setTimeout(fn, ms)) + const stoppe = (id) => (win.clearTimeout ? win.clearTimeout(id) : clearTimeout(id)) + const plane = () => { + for (const id of timer) stoppe(id) + timer = PRUEF_MS.map((ms) => warte(pruefe, ms)) + } + const neuerAnlass = () => { versuche = 0; plane() } + + const beiResize = () => { merke(); if (!istTextfeld(doc.activeElement)) plane() } + const beiUnfokus = () => neuerAnlass() + const beiSicht = () => { if (doc.visibilityState === 'visible') neuerAnlass() } + win.addEventListener('resize', beiResize) + doc.addEventListener('focusout', beiUnfokus) + doc.addEventListener('visibilitychange', beiSicht) + return { + pruefe, + stand: () => ({ ...z, versuche }), + beende() { + for (const id of timer) stoppe(id) + win.removeEventListener('resize', beiResize) + doc.removeEventListener('focusout', beiUnfokus) + doc.removeEventListener('visibilitychange', beiSicht) + }, + } +} diff --git a/public/neu/huelle.js b/public/neu/huelle.js index 81258c0..cd88fbb 100644 --- a/public/neu/huelle.js +++ b/public/neu/huelle.js @@ -7,7 +7,8 @@ import { h, leeren, text } from './dom.js' import { api as apiStandard } from './api.js' import * as routerStandard from './router.js' import { BEREICHE, FUNKTIONS_ETAGEN, hashFuerAnsicht, hashFuerRoute, elternVon, titelFuer, zeigtWiderrufsstreifen } from './routen.js' -import { HANDY_ABFRAGE, baueNavigation, beobachteTastatur, leisteSichtbar } from './leiste.js' +import { HANDY_ABFRAGE, baueNavigation, beobachteTastatur, istTextfeld, leisteSichtbar } from './leiste.js' +import { beobachteHoehe } from './hoehe.js' import { baueEtage } from './etage.js' import { erzeugeZaehler } from './zaehler.js' import { baueStreifen, ladeFristen, ladeFristenNeu } from './fristen-streifen.js' @@ -277,6 +278,8 @@ export async function starteHuelle({ win = window, doc = document, api = apiStan if (handyMq.addEventListener) handyMq.addEventListener('change', beiMedien) else handyMq.addListener?.(beiMedien) beobachteTastatur({ win, doc, onChange: (z) => doc.body.classList.toggle('kn-tastatur', !leisteSichtbar(z)) }) + // iOS-App vom Home-Bildschirm: hängt nach der Tastatur die Fensterhöhe, steht die Leiste zu hoch (hoehe.js); dann einmal neu vermessen + beobachteHoehe({ win, doc, element: doc.querySelector('.kn-huelle'), aktiv: () => istIos() && standalone() && istHandy(), istTextfeld }) // 7. Fristen und Zähler laden parallel und brechen den Start nie ab ladeFristen(api).then((daten) => { fristen = daten; streifen.setze(daten); aktualisiereKonto() }) diff --git a/public/neu/leiste.js b/public/neu/leiste.js index 0cb2738..077633e 100644 --- a/public/neu/leiste.js +++ b/public/neu/leiste.js @@ -82,6 +82,9 @@ export function tastaturZustand(alt, ereignis) { return { voll, breite: gedreht ? ereignis.breite : alt.breite, offen, tippt } } +/** Steht der Fokus in einem Textfeld (das die Bildschirmtastatur öffnet)? */ +export const istTextfeld = (el) => Boolean(el?.matches?.(TEXT_FELD)) + /** Leiste und Streifen sind nur bei Texteingabe weg. */ export const leisteSichtbar = (zustand) => !zustand.tippt @@ -89,7 +92,6 @@ export const leisteSichtbar = (zustand) => !zustand.tippt export function beobachteTastatur({ win = window, doc = document, onChange }) { const vv = win.visualViewport const grob = () => Boolean(win.matchMedia?.('(pointer: coarse)').matches) - const istTextfeld = (el) => Boolean(el?.matches?.(TEXT_FELD)) let z = { voll: vv?.height ?? win.innerHeight, breite: vv?.width ?? win.innerWidth, offen: false, tippt: false } const wende = (ereignis) => { const alt = z diff --git a/test/knHoehe.test.js b/test/knHoehe.test.js new file mode 100644 index 0000000..77c329b --- /dev/null +++ b/test/knHoehe.test.js @@ -0,0 +1,111 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { LUFT, MAX_VERSUCHE, PRUEF_MS, beobachteHoehe, haengt, hoeheZustand } from '../public/neu/hoehe.js' +import { lies } from './support/knHuelle.js' + +test('hoeheZustand: größte Höhe je Breite, Drehen fängt neu an', () => { + let z = hoeheZustand(null, { hoehe: 932, breite: 430 }) + z = hoeheZustand(z, { hoehe: 873, breite: 430 }); assert.deepEqual(z, { voll: 932, breite: 430 }) + z = hoeheZustand(z, { hoehe: 430, breite: 932 }); assert.deepEqual(z, { voll: 430, breite: 932 }) +}) + +test('haengt: nur bei gleicher Breite, ohne Textfeld und mehr als LUFT kleiner (iOS-Fall 932 → 873)', () => { + const z = { voll: 932, breite: 430 } + assert.equal(haengt(z, { hoehe: 873, breite: 430, textfeldAktiv: false }), true) + assert.equal(haengt(z, { hoehe: 873, breite: 430, textfeldAktiv: true }), false) // Tastatur offen + assert.equal(haengt(z, { hoehe: 932 - LUFT, breite: 430, textfeldAktiv: false }), false) + assert.equal(haengt(z, { hoehe: 400, breite: 932, textfeldAktiv: false }), false) // gedreht + assert.equal(haengt(null, { hoehe: 1, breite: 1 }), false) +}) + +/** Kleine Attrappe: Fenster, Dokument und eine Hülle, die mitschreibt, wann sie aus- und eingeblendet wird. */ +function aufbau({ aktiv = () => true } = {}) { + const sender = (felder = {}) => { + const hoerer = {} + return Object.assign({ + addEventListener(typ, fn) { (hoerer[typ] ??= []).push(fn) }, + removeEventListener(typ, fn) { hoerer[typ] = (hoerer[typ] ?? []).filter((x) => x !== fn) }, + senden(typ) { for (const fn of hoerer[typ] ?? []) fn({ type: typ }) }, + anzahl: (typ) => (hoerer[typ] ?? []).length, + }, felder) + } + const spaeter = [] + const protokoll = [] + const win = sender({ + innerHeight: 932, innerWidth: 430, scrollX: 0, scrollY: 0, + setTimeout: (fn, ms) => { spaeter.push({ fn, ms }); return spaeter.length }, + clearTimeout: (id) => { if (spaeter[id - 1]) spaeter[id - 1].fn = () => {} }, + scrollTo: (x, y) => { win.scrollX = x; win.scrollY = y; protokoll.push(['scrollTo', x, y]) }, + }) + const verlauf = { scrollTop: 1200, scrollLeft: 0 } + const haken = {} + const style = {} + let display = '' + Object.defineProperty(style, 'display', { get: () => display, set: (v) => { display = v; protokoll.push(['display', v]); if (v === 'none') { verlauf.scrollTop = 0; win.scrollY = 0; haken.beimAusblenden?.() } } }) + const knopf = { focus: () => protokoll.push(['focus']) } + const element = { + style, + get offsetHeight() { protokoll.push(['umbruch']); return 100 }, + querySelectorAll: () => [verlauf, { scrollTop: 0, scrollLeft: 0 }], + contains: (el) => el === knopf || el === verlauf, + } + const doc = sender({ activeElement: null, body: {}, visibilityState: 'visible' }) + const istFeld = (el) => el?.feld === true + const h = beobachteHoehe({ win, doc, element, aktiv, istTextfeld: istFeld }) + const laufe = () => { while (spaeter.length) spaeter.shift().fn() } + return { win, doc, element, verlauf, knopf, protokoll, spaeter, h, laufe, haken } +} + +test('beobachteHoehe: Tastatur zu und Höhe hängt → Hülle einmal aus und ein, Scrollstände zurück; danach Ruhe', () => { + const t = aufbau() + t.win.scrollY = 40 + t.doc.activeElement = { feld: true } + t.win.innerHeight = 873; t.win.senden('resize') // iOS: die Höhe fällt mit der Tastatur und bleibt unten + assert.equal(t.spaeter.length, 0) // solange das Feld den Fokus hat, wird nicht geprüft + t.doc.activeElement = t.doc.body; t.doc.senden('focusout') + assert.deepEqual(t.spaeter.map((s) => s.ms), PRUEF_MS) + t.spaeter.shift().fn() + assert.deepEqual(t.protokoll, [['display', 'none'], ['umbruch'], ['display', ''], ['umbruch'], ['scrollTo', 0, 40]]) + assert.equal(t.verlauf.scrollTop, 1200) + t.win.innerHeight = 932 // WebKit hat neu gemessen + t.protokoll.length = 0; t.laufe() + assert.deepEqual(t.protokoll, []) + assert.deepEqual(t.h.stand(), { voll: 932, breite: 430, versuche: 1 }) +}) + +test('beobachteHoehe: hilft es nicht, höchstens MAX_VERSUCHE je Anlass; App wieder vorne ist ein neuer Anlass', () => { + const t = aufbau() + t.win.innerHeight = 873 + t.doc.senden('focusout'); t.laufe() + t.win.senden('resize'); t.laufe() + assert.equal(t.protokoll.filter((p) => p[0] === 'display' && p[1] === 'none').length, MAX_VERSUCHE) + t.doc.visibilityState = 'hidden'; t.doc.senden('visibilitychange'); assert.equal(t.spaeter.length, 0) + t.doc.visibilityState = 'visible'; t.doc.senden('visibilitychange'); t.laufe() + assert.equal(t.protokoll.filter((p) => p[0] === 'display' && p[1] === 'none').length, 2 * MAX_VERSUCHE) +}) + +test('beobachteHoehe: Fokus auf einem Knopf in der Hülle bleibt, ohne Scrollen', () => { + const t = aufbau() + t.win.innerHeight = 873 + t.doc.activeElement = t.knopf + t.haken.beimAusblenden = () => { t.doc.activeElement = t.doc.body } // beim Ausblenden verliert der Knopf den Fokus (wie im Browser) + assert.equal(t.h.pruefe(), true) + assert.deepEqual(t.protokoll.at(-1), ['focus']) +}) + +test('beobachteHoehe: nicht aktiv (kein iOS-App-Modus), keine Hülle oder Höhe passt → nichts passiert; beende räumt auf', () => { + const aus = aufbau({ aktiv: () => false }) + aus.win.innerHeight = 873; assert.equal(aus.h.pruefe(), false); assert.deepEqual(aus.protokoll, []) + const ok = aufbau() + assert.equal(ok.h.pruefe(), false) + ok.win.innerHeight = 932 - LUFT; assert.equal(ok.h.pruefe(), false); assert.deepEqual(ok.protokoll, []) + ok.doc.senden('focusout'); ok.h.beende(); ok.win.innerHeight = 873; ok.laufe() + assert.deepEqual(ok.protokoll, []) + assert.deepEqual([ok.win.anzahl('resize'), ok.doc.anzahl('focusout'), ok.doc.anzahl('visibilitychange')], [0, 0, 0]) +}) + +test('huelle.js: Höhen-Wächter nur in der iOS-App vom Home-Bildschirm am Handy, auf der Hülle', () => { + const js = lies('huelle.js') + assert.match(js, /import \{ beobachteHoehe \} from '\.\/hoehe\.js'/) + assert.match(js, /beobachteHoehe\(\{ win, doc, element: doc\.querySelector\('\.kn-huelle'\), aktiv: \(\) => istIos\(\) && standalone\(\) && istHandy\(\), istTextfeld \}\)/) +})