export const ANSICHTEN = {
@@ -128,7 +128,7 @@ export async function starteHuelle({ win = window, doc = document, api = apiStan
const streifen = baueStreifen(el('kn-streifen'), { koerper: doc.body })
streifen.setze(null)
const navigation = baueNavigation({ haupt: el('kn-zeile-haupt'), menue: el('kn-menue'), onWahl: (bereich) => router.geheZu(hashFuerRoute({ bereich })) })
- const etage = baueEtage({ blatt: el('kn-blatt'), haupt: el('kn-inhalt'), zeile: el('kn-zeile-etage'), titel: el('kn-etage-titel'), brot: el('kn-brot'), reiterSlot: el('kn-reiter-slot'), istHandy, onZurueck: () => router.zurueck() })
+ const etage = baueEtage({ blatt: el('kn-blatt'), haupt: el('kn-inhalt'), zeile: el('kn-zeile-etage'), zurueckSlot: el('kn-blatt-zurueck'), titel: el('kn-etage-titel'), brot: el('kn-brot'), reiterSlot: el('kn-reiter-slot'), istHandy, onZurueck: () => router.zurueck() })
let aktuell = null // die Route, die gerade gezeigt wird
let herkunft = '' // Hash der Route davor (Rückweg der Passwort-Seite)
const zaehler = erzeugeZaehler({
diff --git a/public/neu/wasser-leiste.css b/public/neu/wasser-leiste.css
index f19049a..6d939d4 100644
--- a/public/neu/wasser-leiste.css
+++ b/public/neu/wasser-leiste.css
@@ -1,7 +1,7 @@
/* Kundenansicht neu: Wasser-Leiste (wasser-leiste.js). Pille aus Glas mit Wassertropfen, nach der Komponente „Nimbus UI · Liquid Navbar“
- (guglielmogiannattasio.it). Die Regeln der Pille sind die der Vorlage mit kn-wl-Namen; darunter der Einbau in die Hülle am Handy:
- Die Leiste ist keine weiße Zeile mehr, sondern eine schwebende Pille über dem Inhalt. Unterseiten (Etage 2) zeigen statt der Bereiche eine
- gläserne Pille mit „Zurück“ und Reitern; der aktive Reiter liegt in einem Tropfen wie der CSS-Tropfen der Pille. Lädt nach huelle.css. */
+ (guglielmogiannattasio.it). Die Regeln der Pille sind die der Vorlage mit kn-wl-Namen; darunter die zwei Arten und der Einbau in die Hülle
+ am Handy: Die Leiste ist keine weiße Zeile mehr, sondern eine breite schwebende Pille über dem Inhalt. Hauptseiten zeigen die vier Bereiche
+ (Symbol über dem Wort), Unterseiten mit Reitern deren Wörter in derselben Pille; „Zurück“ steht dort oben rechts (huelle.css). Lädt nach huelle.css. */
/* ---------- Pille ---------- */
@@ -69,66 +69,59 @@
.kn-wl-ico[data-fx="wave"] svg { animation: kn-wl-wink .9s ease-in-out both; transform-origin: 50% 92%; }
@keyframes kn-wl-wink { 0% { transform: none; } 20% { transform: rotate(-15deg); } 40% { transform: rotate(12deg); } 60% { transform: rotate(-8deg); } 80% { transform: rotate(4deg); } 100% { transform: translateY(-1px) scale(1.08); } }
-/* Schmal (wie die Vorlage unter 520 px): nur Symbole, die Wörter bleiben für Screenreader im Knopf */
-@media (max-width: 520px) {
- .kn .kn-wl-item { padding: 0 13px; }
- .kn-wl-label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
-}
+/* ---------- Zwei Arten ---------- */
+
+/* Bereiche (Hauptleiste): Symbol über dem Wort, alle gleich breit */
+.kn-wl[data-art="bereiche"] .kn-wl-item { flex: 1 1 0; flex-direction: column; justify-content: center; gap: 4px; min-width: 0; height: 52px; padding: 0 4px; font-size: 11px; line-height: 13px; letter-spacing: 0; }
+.kn-wl[data-art="bereiche"] .kn-wl-item[aria-selected="true"] { font-weight: 600; }
+.kn-wl[data-art="bereiche"] .kn-wl-ico svg { width: 21px; height: 21px; }
+/* Reiter einer Unterseite: nur Wörter; passen sie nicht, setzt wasser-leiste.js data-eng */
+.kn-wl[data-art="reiter"] .kn-wl-item { flex: 1 1 auto; justify-content: center; min-width: 0; padding: 0 9px; font-size: 13px; }
+.kn-wl[data-art="reiter"][data-eng] .kn-wl-item { padding: 0 5px; font-size: 12px; letter-spacing: -.02em; }
+.kn-wl-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
/* ---------- Einbau in die Hülle (Handy) ----------
- Die Pille schwebt: --kn-wl-luft Abstand zum unteren Rand (über dem Home-Balken des iPhones), darüber 10 px Luft zum Inhalt.
- --kn-wl-zone ist die Höhe dieser Zone; --kn-unten (Blatt, Chat) und der Abstand unter dem Inhalt rechnen damit statt mit der alten Zeile. */
+ Die Pille schwebt mit --kn-wl-luft Abstand über dem unteren Rand: auf dem iPhone ganz über dem Wisch-Balken (Safe-Area plus 6 px), sonst 14 px.
+ Darüber 10 px Luft zum Inhalt. --kn-wl-zone ist die Höhe dieser Zone; --kn-unten (Blatt, Chat) und der Abstand unter dem Inhalt rechnen damit. */
-.kn { --kn-wl-h: 54px; }
+.kn { --kn-wl-h: 64px; }
@media (max-width: 880px), (pointer: coarse) and (max-height: 500px) {
- .kn { --kn-zeile-h: calc(var(--kn-wl-h) + 10px); --kn-wl-luft: max(10px, calc(env(safe-area-inset-bottom) - 10px)); --kn-wl-zone: calc(var(--kn-zeile-h) + var(--kn-wl-luft)); --kn-unten: var(--kn-wl-zone); }
+ .kn { --kn-zeile-h: calc(var(--kn-wl-h) + 10px); --kn-wl-luft: max(14px, calc(env(safe-area-inset-bottom) + 6px)); --kn-wl-zone: calc(var(--kn-zeile-h) + var(--kn-wl-luft)); --kn-unten: var(--kn-wl-zone); }
.kn.kn-hat-streifen { --kn-unten: calc(var(--kn-wl-zone) + 44px); }
.kn.kn-tastatur { --kn-unten: 0px; }
.kn-haupt { padding-bottom: calc(var(--kn-wl-zone) + 28px); }
.kn-hat-streifen .kn-haupt { padding-bottom: calc(var(--kn-wl-zone) + 28px + 44px); }
.kn-streifen { bottom: var(--kn-wl-zone); }
- /* Das Blatt reicht bis unten, sein Inhalt läuft unter der Pille durch */
- .kn-blatt { bottom: 0; padding-bottom: calc(var(--kn-unten) + 24px); }
.kn-leiste { border-top: 0; background: none; padding-bottom: var(--kn-wl-luft); pointer-events: none; }
.kn-leiste-reel { position: relative; overflow: visible; }
.kn-leiste .kn-rw {
- position: absolute; right: 12px; bottom: 0; left: 12px; width: fit-content; max-width: calc(100% - 24px); height: var(--kn-wl-h); margin: 0 auto; padding: 0;
- pointer-events: auto; transition: transform 450ms var(--kn-feder), opacity 250ms ease, visibility 0s;
+ position: absolute; right: 12px; bottom: 0; left: 12px; max-width: 480px; height: var(--kn-wl-h); margin: 0 auto; padding: 0; align-items: flex-end;
+ pointer-events: none; transition: transform 450ms var(--kn-feder), opacity 250ms ease, visibility 0s;
}
+ .kn-leiste .kn-rw > * { pointer-events: auto; }
+ .kn-leiste .kn-wl { display: flex; width: 100%; }
/* huelle.css schiebt beide Zeilen eine Etage hoch; hier liegen die Pillen übereinander und tauschen mit kurzem Auf- und Abtauchen */
.kn-leiste-reel[data-etage="offen"] .kn-rw { transform: none; }
.kn-leiste-reel[data-etage="offen"] #kn-zeile-haupt, .kn-leiste-reel:not([data-etage="offen"]) #kn-zeile-etage {
opacity: 0; transform: translateY(18px) scale(.94); transition: transform 300ms var(--kn-bremse), opacity 200ms ease, visibility 0s 300ms;
}
- /* Etage 2: gläserne Pille mit „Zurück“ und Reitern */
- #kn-zeile-etage {
- min-width: 132px; padding: 5px; border-radius: 999px; isolation: isolate; background: rgba(255,255,255,.66);
- -webkit-backdrop-filter: blur(12px) saturate(1.3) brightness(1.06); backdrop-filter: blur(12px) saturate(1.3) brightness(1.06);
- box-shadow: inset 0 1px 1px rgba(255,255,255,.7), inset 0 0 0 1px rgba(255,255,255,.2), 0 12px 32px -14px rgba(0,40,90,.5), 0 2px 6px rgba(0,40,90,.1);
- }
- #kn-zeile-etage .kn-zurueck { height: 44px; padding: 0 14px 0 8px; border-radius: 999px; }
- #kn-zeile-etage .kn-zurueck--voll { justify-content: center; }
- #kn-zeile-etage .kn-balken { height: 44px; padding-right: 4px; border-radius: 999px; }
- #kn-zeile-etage .kn-t { padding: 0 12px; }
- #kn-zeile-etage .kn-gleiter {
- z-index: 0; top: 4px; bottom: auto; height: 36px; border-radius: 999px; outline-offset: -3px;
- background: radial-gradient(120% 140% at 30% 0%, rgba(255,255,255,.75), rgba(255,255,255,0) 55%), rgba(255,255,255,.38);
- box-shadow: inset 0 1px 1px rgba(255,255,255,.95), inset 0 -2px 6px rgba(0,162,255,.22), inset 0 0 0 1px rgba(255,255,255,.55), 0 6px 16px -6px rgba(0,70,140,.45);
- }
- #kn-zeile-etage .kn-overlay { display: none; }
- #kn-zeile-etage .kn-t[aria-selected="true"] { color: var(--kn-text); }
- .kn #kn-zeile-etage .kn-balken:has(.kn-t:focus-visible) .kn-gleiter { outline-color: var(--kn-accent); }
+ /* Unterseite (Aufbau in huelle.css): mit Unschärfe einblenden statt hochfahren; „Zurück“ oben rechts aus Glas */
+ .kn-blatt { filter: blur(14px); transition: opacity 380ms ease, filter 460ms var(--kn-bremse); }
+ .kn-blatt--auf { filter: none; }
+ .kn-blatt-zurueck { filter: blur(8px); transition: opacity 380ms ease, filter 460ms var(--kn-bremse), visibility 0s 460ms; }
+ .kn-blatt--auf ~ .kn-blatt-zurueck { filter: none; }
+ .kn-blatt-zurueck .kn-zurueck { background: rgba(255,255,255,.62); -webkit-backdrop-filter: blur(12px) saturate(1.3) brightness(1.06); backdrop-filter: blur(12px) saturate(1.3) brightness(1.06); box-shadow: inset 0 1px 1px rgba(255,255,255,.7), inset 0 0 0 1px rgba(255,255,255,.2), 0 12px 32px -14px rgba(0,40,90,.5), 0 2px 6px rgba(0,40,90,.1); }
}
/* Querformat: niedrigere Pille */
@media (max-height: 500px) and (max-width: 880px), (max-height: 500px) and (pointer: coarse) {
- .kn { --kn-wl-h: 48px; }
- .kn .kn-wl-item { height: 36px; }
- #kn-zeile-etage { padding: 2px; }
+ .kn { --kn-wl-h: 54px; }
+ .kn-wl[data-art="bereiche"] .kn-wl-item { height: 42px; gap: 2px; }
+ .kn-wl[data-art="bereiche"] .kn-wl-ico svg { width: 18px; height: 18px; }
}
/* Bewegung reduziert: keine Szenen, Pillen tauschen ohne Fahrt */
@@ -136,4 +129,7 @@
.kn-wl-ico svg, .kn-wl-ico::before, .kn-wl-ico::after, .kn-wl-tuer { animation: none !important; transition: none; }
.kn-leiste .kn-rw,
.kn-leiste-reel[data-etage="offen"] #kn-zeile-haupt, .kn-leiste-reel:not([data-etage="offen"]) #kn-zeile-etage { transform: none; transition: opacity 150ms, visibility 0s; }
+ .kn-blatt { filter: none; transition: opacity 150ms; }
+ .kn-blatt-zurueck { filter: none; transition: opacity 150ms, visibility 0s 150ms; }
+ .kn-blatt--auf ~ .kn-blatt-zurueck { filter: none; transition: opacity 150ms; }
}
diff --git a/public/neu/wasser-leiste.js b/public/neu/wasser-leiste.js
index b97128f..bb9fffb 100644
--- a/public/neu/wasser-leiste.js
+++ b/public/neu/wasser-leiste.js
@@ -3,7 +3,8 @@
Bereich, reißt bei langen Sprüngen in Tröpfchen und fließt mit einer Welle wieder zusammen; jeder Tipp schickt eine Welle durch die Pille, jedes
Symbol spielt eine kleine Szene. Das Wasser zeichnet ein kleiner WebGL-Shader (nur in der Pille). Er ruht, solange die Pille nicht zu sehen ist,
und kommt einige Sekunden nach der letzten Bedienung zur Ruhe (Akku). Ohne WebGL zeigt die Pille einen CSS-Tropfen (wasser-leiste.css).
- Rollen wie die Fahrstuhl-Zeile davor: tablist mit tab, aria-selected und aria-current; Zähler als .kn-zahl am Symbol, Beschriftung per aria-label.
+ Zwei Arten: 'bereiche' (Hauptleiste: Symbol über dem Wort, Zähler als .kn-zahl am Symbol) und 'reiter' (Reiter einer Unterseite: nur Wörter,
+ Kurzwort `kurz` falls vorhanden). Rollen wie die Fahrstuhl-Zeile davor: tablist mit tab, aria-selected und aria-current, Beschriftung per aria-label.
Die Rechenfunktionen (Feder, Tröpfchen, Wellen) sind rein und werden mit node --test geprüft; ohne Browser baut das Modul nur die Knöpfe. */
import { h } from './dom.js'
@@ -291,15 +292,17 @@ const beschriftung = (e) => {
}
/**
- * Wasser-Leiste bauen. Eintrag { key, label, symbol?, fx?, einheit?, ausrufText? }; symbol und fx folgen ohne Angabe dem key (SYMBOLE, SZENEN).
- * bei(key) wird bei jedem Tipp gerufen, auch auf den aktiven Bereich (die Hülle scrollt dann nach oben).
- * Liefert das Element mit setzeAktiv(key), setzeAnzeige(key, { zahl, ausruf }), messen() und beende().
+ * Wasser-Leiste bauen. Eintrag { key, label, kurz?, symbol?, fx?, einheit?, ausrufText? }. Art 'bereiche': symbol und fx folgen ohne Angabe dem key
+ * (SYMBOLE, SZENEN). Art 'reiter': kein Symbol, keine Szene, sichtbar ist `kurz` (sonst label), vorgelesen label.
+ * bei(key) wird bei jedem Tipp gerufen, auch auf den aktiven Eintrag (die Hülle scrollt dann nach oben).
+ * Liefert das Element mit setzeAktiv(key), setzeAnzeige(key, { zahl, ausruf }), messen() und beende() (gibt auch den WebGL-Kontext frei).
*/
-export function baueWasserLeiste({ eintraege, aktiv = '', ariaLabel = 'Bereiche', bei = () => {}, warte = (fn, ms) => setTimeout(fn, ms) }) {
+export function baueWasserLeiste({ eintraege, aktiv = '', art = 'bereiche', ariaLabel = 'Bereiche', bei = () => {}, warte = (fn, ms) => setTimeout(fn, ms) }) {
const win = globalThis.window
const doc = globalThis.document
const s = neuerZustand()
- const liste = eintraege.map((e) => ({ ...e, fx: e.fx ?? SZENEN[e.key] ?? 'pop', zahl: 0, ausruf: false }))
+ const bereiche = art === 'bereiche'
+ const liste = eintraege.map((e) => ({ ...e, fx: e.fx ?? (bereiche ? SZENEN[e.key] ?? 'pop' : 'none'), zahl: 0, ausruf: false }))
let aktuell = aktiv
let glc = null
let glVersucht = false // WebGL erst, wenn die Pille zu sehen ist (am Desktop nie)
@@ -308,15 +311,15 @@ export function baueWasserLeiste({ eintraege, aktiv = '', ariaLabel = 'Bereiche'
const jetzt = () => (globalThis.performance?.now ? globalThis.performance.now() : Date.now())
for (const e of liste) {
- e.ico = h('span', { class: 'kn-wl-ico' }, baueSymbol(e.symbol ?? e.key, doc))
+ e.ico = bereiche ? h('span', { class: 'kn-wl-ico' }, baueSymbol(e.symbol ?? e.key, doc)) : null
e.tab = h('button', { class: 'kn-wl-item', role: 'tab', type: 'button', 'data-key': e.key, 'aria-selected': 'false', 'aria-label': e.label, tabindex: '-1', on: { click: () => waehle(e.key) } },
- e.ico, h('span', { class: 'kn-wl-label' }, e.label))
+ e.ico, h('span', { class: 'kn-wl-label' }, e.kurz ?? e.label))
}
const tabs = liste.map((e) => e.tab)
const canvas = h('canvas', { class: 'kn-wl-wasser', 'aria-hidden': 'true' })
const tropfen = h('span', { class: 'kn-wl-tropfen', 'aria-hidden': 'true' })
const wurzel = h('div', {
- class: 'kn-wl', role: 'tablist', 'aria-label': ariaLabel, 'data-gl': 'on', 'data-brechung': 'aus',
+ class: 'kn-wl', role: 'tablist', 'aria-label': ariaLabel, 'data-art': bereiche ? 'bereiche' : 'reiter', 'data-gl': 'on', 'data-brechung': 'aus',
on: { keydown: beiTaste, pointermove: beiMaus, pointerleave: () => { s.maus.on = false; schleife() }, pointerdown: beiDruck },
}, baueFilter(doc), h('span', { class: 'kn-wl-glas', 'aria-hidden': 'true' }), canvas, tropfen, tabs)
@@ -370,6 +373,10 @@ export function baueWasserLeiste({ eintraege, aktiv = '', ariaLabel = 'Bereiche'
function messen() {
if (!(wurzel.offsetWidth > 0)) return // nicht zu sehen (Desktop, Tastatur offen) oder ohne Browser
if (!glVersucht) starteGL()
+ if (!bereiche) { // passen die Wörter nicht, wird die Schrift kleiner (wie schriftStufe in reiter.js)
+ wurzel.removeAttribute('data-eng')
+ if (wurzel.scrollWidth > wurzel.clientWidth + 1) wurzel.setAttribute('data-eng', '')
+ }
s.w = wurzel.offsetWidth
s.h = wurzel.offsetHeight
const el = liste.find((e) => e.key === aktuell)?.tab
@@ -398,13 +405,14 @@ export function baueWasserLeiste({ eintraege, aktiv = '', ariaLabel = 'Bereiche'
wecke()
}
function symbolMitte(i) {
- const b = liste[i]?.ico?.getBoundingClientRect?.()
+ const b = (liste[i]?.ico ?? liste[i]?.tab)?.getBoundingClientRect?.()
return b ? lokal(b.left + b.width / 2, b.top + b.height / 2) : null
}
/** Szene des Symbols (neu) starten; die anderen Symbole kehren in die Ruhe zurück. */
function spiele(i) {
- liste.forEach((e, j) => { if (j !== i) e.ico.removeAttribute('data-fx') })
+ liste.forEach((e, j) => { if (j !== i) e.ico?.removeAttribute('data-fx') })
const e = liste[i]
+ if (!e.ico) return
e.ico.removeAttribute('data-fx')
if (e.fx === 'none') return
void e.ico.offsetWidth // Stil auflösen, damit die Animation von vorn beginnt
@@ -467,7 +475,7 @@ export function baueWasserLeiste({ eintraege, aktiv = '', ariaLabel = 'Bereiche'
function setzeAnzeige(key, { zahl = 0, ausruf = false } = {}) {
const e = liste.find((x) => x.key === key)
- if (!e) return
+ if (!e?.ico) return
e.zahl = zahl
e.ausruf = ausruf
const text = anzeigeText({ zahl, ausruf })
@@ -522,6 +530,9 @@ export function baueWasserLeiste({ eintraege, aktiv = '', ariaLabel = 'Bereiche'
for (const fn of aufraeumen.splice(0)) fn()
if (raf) win?.cancelAnimationFrame?.(raf)
raf = 0
+ glc?.gl.getExtension('WEBGL_lose_context')?.loseContext() // Browser halten nur wenige Kontexte: jede Unterseite baut ihre Reiter neu
+ glc = null
+ glVersucht = true
}
wurzel.setzeAktiv = setzeAktiv
diff --git a/test/knEfixCss.test.js b/test/knEfixCss.test.js
index 84a4e6d..2d36f07 100644
--- a/test/knEfixCss.test.js
+++ b/test/knEfixCss.test.js
@@ -81,7 +81,8 @@ test('M4: der Mitarbeiter-Banner steht oben (sticky), Kopf, Menü, Blatt und Kon
assert.match(HUELLE, /\.kn-banner \{[^}]*position:\s*sticky;[^}]*top:\s*0/)
assert.match(HUELLE, /\.kn-menue \{[^}]*inset:\s*var\(--kn-banner-h\) auto 0 0/)
assert.match(HUELLE, /\.kn-kopf \{[^}]*position:\s*sticky;\s*top:\s*var\(--kn-banner-h\)/)
- assert.match(HUELLE, /\.kn-blatt \{[^}]*inset:\s*calc\(var\(--kn-banner-h\) \+ var\(--kn-kopf-h\) \+ env\(safe-area-inset-top\)\)/)
+ assert.match(HUELLE, /\.kn-blatt \{[^}]*inset:\s*calc\(var\(--kn-banner-h\) \+ env\(safe-area-inset-top\)\) 0 0/) // Wasser-Leiste: Unterseite als Vollbild unter dem Banner
+ assert.match(HUELLE, /\.kn-blatt-zurueck \{[^}]*top:\s*calc\(var\(--kn-banner-h\) \+ env\(safe-area-inset-top\)/)
assert.match(HUELLE, /\.kn-konto \{[^}]*top:\s*calc\(var\(--kn-banner-h\) \+ 56px\)/)
assert.doesNotMatch(HUELLE, /\.kn-banner:not\(\[hidden\]\) ~ \.kn-huelle \.kn-menue/, 'die feste Höhe 36 px ist weg')
const js = quelle('huelle.js')
diff --git a/test/knHuelleEtage.test.js b/test/knHuelleEtage.test.js
index c326cd9..705561a 100644
--- a/test/knHuelleEtage.test.js
+++ b/test/knHuelleEtage.test.js
@@ -5,24 +5,24 @@ import { h } from '../public/neu/dom.js'
import { baueEtage } from '../public/neu/etage.js'
import { lies, handyTeil } from './support/knHuelle.js'
-const teile = () => ({ blatt: h('div', { hidden: true }, h('section', { 'data-etage': 'termine' })), haupt: h('main'), zeile: h('div'), titel: h('h1'), brot: h('nav'), reiterSlot: h('div') })
+const teile = () => ({ blatt: h('div', { hidden: true }, h('section', { 'data-etage': 'termine' })), haupt: h('main'), zeile: h('div'), zurueckSlot: h('div'), titel: h('h1'), brot: h('nav'), reiterSlot: h('div') })
const REITER = ['Termine', 'Leistungen', 'Team & Plätze', 'Einstellungen', 'Kalender', 'Rechnungen'].map((label, i) => ({ key: `r${i}`, label, kurz: label.split(' ')[0] }))
const EL = { label: 'Funktionen', hash: '#funktionen' }
-test('oeffne und schliesse: Titel, Zurück mit Ziel, Brotkrume, Hauptseite gesperrt', async () => {
+test('oeffne und schliesse: Titel, Zurück oben rechts mit Ziel, Brotkrume, Hauptseite gesperrt', async () => {
const fx = installFakeDom()
try {
const t = teile(); let zu = 0; const e = baueEtage({ ...t, istHandy: () => true, onZurueck: () => { zu += 1 } })
e.oeffne({ titel: 'Terminplaner', eltern: EL, seite: 'termine' })
assert.deepEqual([t.blatt.hidden, t.haupt.getAttribute('inert'), e.istOffen(), t.titel.textContent], [false, '', true, 'Terminplaner'])
assert.deepEqual([fx.text(t.brot), fx.finde(t.brot, 'a').getAttribute('href')], ['Funktionen / Terminplaner', '#funktionen'])
- const z = fx.finde(t.zeile, 'button'); assert.deepEqual([z.textContent, z.getAttribute('aria-label')], ['Zurück', 'Zurück zu Funktionen'])
+ const z = fx.finde(t.zurueckSlot, 'button'); assert.deepEqual([z.textContent, z.getAttribute('aria-label'), t.zeile.children.length], ['Zurück', 'Zurück zu Funktionen', 0])
await fx.klick(z); assert.equal(zu, 1)
- e.schliesse(); assert.deepEqual([t.blatt.hidden, t.haupt.hasAttribute('inert'), fx.alle(t.zeile, 'button').length], [true, false, 0])
+ e.schliesse(); assert.deepEqual([t.blatt.hidden, t.haupt.hasAttribute('inert'), fx.alle(t.zurueckSlot, 'button').length], [true, false, 0])
} finally { fx.restore() }
})
-test('kn:reiter vom Inhalt: Handy Kurzlabels in der Zeile, Desktop Unterstrich im Slot, ohne Reiter füllt Zurück die Zeile (C2)', async () => {
+test('kn:reiter vom Inhalt: Handy Kurzlabels als Wasser-Leiste in der Zeile, Desktop Unterstrich im Slot, ohne Reiter bleibt die Zeile leer (C2)', async () => {
const fx = installFakeDom()
try {
const gew = []; let handy = true; const t = teile(); const e = baueEtage({ ...t, istHandy: () => handy, onZurueck() {} })
@@ -30,9 +30,10 @@ test('kn:reiter vom Inhalt: Handy Kurzlabels in der Zeile, Desktop Unterstrich i
await fx.sende(kind, 'kn:reiter', { detail: { eintraege: REITER, aktiv: 'r0', bei: (k) => gew.push(k) } })
const tabs = fx.alle(t.zeile, '[role=tab]')
assert.deepEqual(tabs.map((x) => x.textContent), ['Termine', 'Leistungen', 'Team', 'Einstellungen', 'Kalender', 'Rechnungen']); assert.equal(tabs[2].getAttribute('aria-label'), 'Team & Plätze')
+ assert.deepEqual([fx.finde(t.zeile, '.kn-wl').getAttribute('data-art'), fx.alle(t.zeile, 'button:not([role=tab])').length], ['reiter', 0]) // unten kein Zurück
await fx.klick(tabs[2]); assert.deepEqual(gew, ['r2'])
await fx.sende(kind, 'kn:reiter', { detail: { eintraege: [], aktiv: '', bei() {} } })
- assert.deepEqual([fx.alle(t.zeile, '[role=tab]').length, Boolean(fx.finde(t.zeile, '.kn-zurueck--voll'))], [0, true])
+ assert.deepEqual([t.zeile.children.length, fx.alle(t.zurueckSlot, '.kn-zurueck').length], [0, 1])
handy = false; e.neuZeichnen(); await fx.sende(kind, 'kn:reiter', { detail: { eintraege: REITER, aktiv: 'r0', bei() {} } })
assert.deepEqual([fx.alle(t.reiterSlot, '[role=tab]').length, fx.alle(t.zeile, '[role=tab]').length], [6, 0])
await fx.sende(kind, 'kn:titel', { detail: { text: 'Kalender' } }); assert.equal(t.titel.textContent, 'Kalender')
@@ -44,14 +45,22 @@ test('Chat-Punkt am Zurück', () => {
try {
const t = teile(); const e = baueEtage({ ...t, istHandy: () => true, onZurueck() {} })
e.oeffne({ titel: 'Webshop', eltern: EL, seite: 'webshop' }); e.setzeChatPunkt(true)
- assert.ok(fx.finde(t.zeile, '.kn-punkt')); assert.equal(fx.finde(t.zeile, 'button').getAttribute('aria-label'), 'Zurück zu Funktionen, neue Nachricht im Chat')
+ assert.ok(fx.finde(t.zurueckSlot, '.kn-punkt')); assert.equal(fx.finde(t.zurueckSlot, 'button').getAttribute('aria-label'), 'Zurück zu Funktionen, neue Nachricht im Chat')
} finally { fx.restore() }
})
-test('huelle.css (Blatt): Handy fährt von unten ein, Zurück füllt die Zeile', () => {
- const css = lies('huelle.css')
- assert.match(handyTeil(css), /\.kn-blatt\s*\{(?=[^}]*border-radius:\s*12px 12px 0 0)(?=[^}]*transition:[^}]*550ms[^}]*var\(--kn-bremse\))/s)
- assert.match(css, /\.kn-zurueck--voll\s*\{[^}]*flex:\s*1/s)
+test('Blatt am Handy: ganzer Bildschirm, mit Unschärfe eingeblendet, Zurück als Glasknopf oben rechts (Glas nur in wasser-leiste.css), Desktop ohne', () => {
+ const css = lies('huelle.css'); const handy = handyTeil(css); const wl = handyTeil(lies('wasser-leiste.css'))
+ assert.match(handy, /\.kn-blatt\s*\{(?=[^}]*inset:\s*calc\(var\(--kn-banner-h\) \+ env\(safe-area-inset-top\)\) 0 0)(?=[^}]*opacity:\s*0)/s)
+ assert.doesNotMatch(handy, /translateY\(100%\)\s*;\s*transition:\s*transform 550ms/) // fährt nicht mehr hoch
+ assert.match(handy, /\.kn-blatt-zurueck\s*\{(?=[^}]*position:\s*fixed)(?=[^}]*right:\s*max\(16px, env\(safe-area-inset-right\)\))/s)
+ assert.match(handy, /\.kn-blatt--auf ~ \.kn-blatt-zurueck\s*\{[^}]*visibility:\s*visible/)
+ assert.match(css.slice(0, css.indexOf('@media')), /\.kn-blatt-zurueck\s*\{\s*display:\s*none/) // Desktop: Brotkrume statt Zurück
+ assert.match(wl, /\.kn-blatt\s*\{\s*filter:\s*blur\(14px\);\s*transition:[^}]*filter/)
+ assert.match(wl, /\.kn-blatt--auf\s*\{\s*filter:\s*none/)
+ assert.match(wl, /\.kn-blatt-zurueck \.kn-zurueck\s*\{[^}]*backdrop-filter:\s*blur/)
+ assert.match(lies('wasser-leiste.css'), /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.kn-blatt\s*\{\s*filter:\s*none/)
+ assert.match(lies('dashboard.html'), /<\/div>\s*<\/div>/) // Geschwister nach dem Blatt (Selektor ~)
})
test('Etage: Brotkrume führt wie Zurück, Chat-Punkt bleibt für die nächste Etage, Reiter anderer Seiten und Reste werden verworfen, gleiche Reiter bleiben stehen', async () => {
@@ -60,7 +69,7 @@ test('Etage: Brotkrume führt wie Zurück, Chat-Punkt bleibt für die nächste E
const t = teile(); t.blatt.append(h('section', { 'data-etage': 'webshop' })); let zu = 0
const e = baueEtage({ ...t, istHandy: () => true, onZurueck: () => { zu += 1 } })
e.setzeChatPunkt(true) // vor dem Öffnen gemerkt
- e.oeffne({ titel: 'Terminplaner', eltern: EL, seite: 'termine' }); assert.ok(fx.finde(t.zeile, '.kn-punkt'))
+ e.oeffne({ titel: 'Terminplaner', eltern: EL, seite: 'termine' }); assert.ok(fx.finde(t.zurueckSlot, '.kn-punkt'))
assert.deepEqual(t.blatt.children.map((s) => s.hidden), [false, true]) // nur die Seite der Etage ist sichtbar
await fx.klick(fx.finde(t.brot, 'a'))
assert.equal(zu, 1) // Klick auf die Brotkrume: wie Zurück, ohne Fragment-Navigation
@@ -72,7 +81,7 @@ test('Etage: Brotkrume führt wie Zurück, Chat-Punkt bleibt für die nächste E
await fx.sende(termine, 'kn:reiter', { detail: { eintraege: REITER, aktiv: 'r3', bei() {} } })
assert.deepEqual([fx.finde(t.zeile, '[role=tablist]') === erster, fx.alle(t.zeile, '[role=tab]').map((x) => x.getAttribute('aria-selected')).indexOf('true')], [true, 3]) // gleiche Reiter: nur die Auswahl wechselt
e.oeffne({ titel: 'Webshop', eltern: EL, seite: 'webshop' })
- assert.deepEqual([fx.alle(t.zeile, '[role=tab]').length, Boolean(fx.finde(t.zeile, '.kn-zurueck--voll')), Boolean(fx.finde(t.zeile, '.kn-punkt'))], [0, true, true]) // neue Etage: alte Reiter weg, Punkt bleibt
+ assert.deepEqual([fx.alle(t.zeile, '[role=tab]').length, Boolean(fx.finde(t.zurueckSlot, '.kn-zurueck')), Boolean(fx.finde(t.zurueckSlot, '.kn-punkt'))], [0, true, true]) // neue Etage: alte Reiter weg, Punkt bleibt
e.schliesse(); await fx.sende(termine, 'kn:reiter', { detail: { eintraege: REITER, aktiv: 'r0', bei() {} } })
assert.equal(fx.alle(t.zeile, '[role=tab]').length, 0) // geschlossen: späte Meldungen tun nichts
} finally { fx.restore() }
diff --git a/test/knHuelleStart.test.js b/test/knHuelleStart.test.js
index aa5a7e0..63386ca 100644
--- a/test/knHuelleStart.test.js
+++ b/test/knHuelleStart.test.js
@@ -76,7 +76,7 @@ test('Etage Terminplaner: wunschProjekt aus ?projekt=, Modul auf #termine-body m
const seen = []
const b = await boot({ hash: '#termine?bestellt=1&projekt=p7', karte: { ...KARTE_KUNDE, '/api/chat/unread-count': { unread: 3 } }, lade: async () => ({ initTermine: (root, opt) => seen.push([root.id, root.dataset.wunschProjekt, typeof opt?.einschalten]) }) })
assert.deepEqual(seen, [['termine-body', 'p7', 'function']]); assert.ok(b.log.some((l) => l[0] === 'stempeln'))
- assert.deepEqual([b.el('kn-blatt').hidden, b.el('kn-etage-titel').textContent, Boolean(b.fx.finde(b.el('kn-zeile-etage'), '.kn-punkt'))], [false, 'Terminplaner', true])
+ assert.deepEqual([b.el('kn-blatt').hidden, b.el('kn-etage-titel').textContent, Boolean(b.fx.finde(b.el('kn-blatt-zurueck'), '.kn-punkt'))], [false, 'Terminplaner', true])
const x = await boot({ hash: '#webshop', karte: KARTE_KUNDE, lade: async () => { throw new Error('404') } })
assert.equal(x.el('webshop-body').textContent, 'Konnte nicht geladen werden.')
})
diff --git a/test/knWasserLeiste.test.js b/test/knWasserLeiste.test.js
index c37dfa5..67dbfcd 100644
--- a/test/knWasserLeiste.test.js
+++ b/test/knWasserLeiste.test.js
@@ -90,6 +90,20 @@ test('Bauen: vier Reiter mit Wort, Symbol-Platz und Szene, Auswahl, Zähler am S
} finally { fx.restore() }
})
+test('Art reiter: nur Wörter (Kurzwort sichtbar, volles Wort vorgelesen), keine Szene, kein Zähler; beende räumt auf', async () => {
+ const fx = installFakeDom()
+ try {
+ const gew = []
+ const w = baueWasserLeiste({ eintraege: [{ key: 'termine', label: 'Termine' }, { key: 'team', label: 'Team & Plätze', kurz: 'Team' }], aktiv: 'termine', art: 'reiter', ariaLabel: 'Reiter', bei: (k) => gew.push(k) })
+ const tabs = fx.alle(w, '[role=tab]')
+ assert.deepEqual([w.getAttribute('data-art'), w.getAttribute('aria-label'), fx.alle(w, '.kn-wl-ico').length], ['reiter', 'Reiter', 0])
+ assert.deepEqual(tabs.map((t) => [t.textContent, t.getAttribute('aria-label')]), [['Termine', 'Termine'], ['Team', 'Team & Plätze']])
+ await fx.klick(tabs[1]); assert.deepEqual([gew, tabs[1].getAttribute('aria-selected')], [['team'], 'true'])
+ w.setzeAnzeige('team', { zahl: 2 }); assert.equal(fx.finde(w, '.kn-zahl'), null)
+ w.beende(); w.beende() // zweimal ist kein Fehler
+ } finally { fx.restore() }
+})
+
test('Symbole und Szenen: für jeden Bereich der Leiste ein Symbol und eine eigene Szene', () => {
for (const { key } of WOERTER) { assert.ok(SYMBOLE[key]?.length > 0, key); assert.ok(SZENEN[key], key) }
assert.equal(new Set(WOERTER.map((w) => SZENEN[w.key])).size, WOERTER.length)
@@ -100,15 +114,18 @@ test('wasser-leiste.css: Pille wie die Vorlage, am Handy schwebend über dem Inh
const css = lies('wasser-leiste.css'); const handy = handyTeil(css)
assert.match(css, /\.kn-wl-glas\s*\{[^}]*backdrop-filter:\s*blur\(10px\) saturate\(1\.2\) brightness\(1\.08\)/s)
assert.match(css, /\.kn-wl\[data-gl="off"\] \.kn-wl-tropfen\s*\{\s*display:\s*block/)
- assert.match(css, /@media \(max-width: 520px\)\s*\{[^@]*\.kn-wl-label\s*\{[^}]*clip:\s*rect\(0 0 0 0\)/s) // schmal nur Symbole, Wort bleibt für Screenreader
+ assert.doesNotMatch(css, /max-width: 520px/) // die Wörter stehen immer da (Wunsch 29.10.)
+ assert.match(css, /\.kn-wl\[data-art="bereiche"\] \.kn-wl-item\s*\{[^}]*flex:\s*1 1 0;[^}]*flex-direction:\s*column/) // Symbol über dem Wort, gleich breit
+ assert.match(css, /\.kn-wl\[data-art="reiter"\]\[data-eng\] \.kn-wl-item\s*\{[^}]*font-size:\s*12px/)
for (const fx of ['pop', 'plane', 'drop', 'spin', 'glow', 'wave']) assert.match(css, new RegExp(`\\.kn-wl-ico\\[data-fx="${fx}"\\] svg\\s*\\{[^}]*animation:`))
assert.ok(handy.startsWith(HANDY))
- assert.match(handy, /\.kn\s*\{[^}]*--kn-wl-luft:\s*max\(10px, calc\(env\(safe-area-inset-bottom\) - 10px\)\)[^}]*--kn-unten:\s*var\(--kn-wl-zone\)/s)
+ assert.match(handy, /\.kn\s*\{[^}]*--kn-wl-luft:\s*max\(14px, calc\(env\(safe-area-inset-bottom\) \+ 6px\)\)[^}]*--kn-unten:\s*var\(--kn-wl-zone\)/s) // ganz über dem Wisch-Balken des iPhones
assert.match(handy, /\.kn\.kn-hat-streifen\s*\{\s*--kn-unten:\s*calc\(var\(--kn-wl-zone\) \+ 44px\)/)
assert.ok(handy.indexOf('.kn.kn-tastatur') > handy.indexOf('.kn.kn-hat-streifen')) // Tastatur schlägt den Streifen
assert.match(handy, /\.kn-leiste\s*\{[^}]*background:\s*none[^}]*pointer-events:\s*none/s)
- assert.match(handy, /\.kn-blatt\s*\{\s*bottom:\s*0;\s*padding-bottom:\s*calc\(var\(--kn-unten\) \+ 24px\)/)
- assert.match(handy, /#kn-zeile-etage\s*\{[^}]*border-radius:\s*999px[^}]*backdrop-filter/s)
+ assert.match(handy, /\.kn-leiste \.kn-rw\s*\{[^}]*right:\s*12px;[^}]*left:\s*12px;[^}]*max-width:\s*480px/s) // breit: Rand 12 px, höchstens 480 px
+ assert.match(handy, /\.kn-leiste \.kn-wl\s*\{\s*display:\s*flex;\s*width:\s*100%/)
+ assert.match(handy, /\.kn-leiste \.kn-rw\s*\{[^}]*pointer-events:\s*none/s); assert.match(handy, /\.kn-leiste \.kn-rw > \*\s*\{\s*pointer-events:\s*auto/)
assert.match(css, /@media \(prefers-reduced-motion: reduce\)\s*\{[^@]*animation:\s*none !important/s)
// huelle.css bleibt unverändert die Grundlage (Desktop und Etage-Logik)
assert.match(lies('huelle.css'), /\.kn-leiste\s*\{[^}]*border-top:\s*1px solid var\(--kn-line\)/)