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 \}\)/) +})