diff --git a/public/neu/dashboard.html b/public/neu/dashboard.html index 8015bcc..e267fac 100644 --- a/public/neu/dashboard.html +++ b/public/neu/dashboard.html @@ -73,6 +73,7 @@ +
diff --git a/public/neu/etage.js b/public/neu/etage.js index 24d65a3..6f8f8a7 100644 --- a/public/neu/etage.js +++ b/public/neu/etage.js @@ -1,29 +1,32 @@ -/* Kundenansicht neu: Etage 2. Eine Unterseite (Funktion, Detailseite, Passwort …) schiebt sich als Blatt über die Hauptseite. - Handy: Die Zeile unten fährt eine Etage hoch und zeigt „Zurück“ und, falls die Seite Reiter meldet, deren Kurzlabels als Balken. - Desktop: Brotkrume statt Zeile, Reiter als Unterstrich im Slot unter der Überschrift. +/* Kundenansicht neu: Etage 2. Eine Unterseite (Funktion, Detailseite, Passwort …) legt sich als Blatt über die Hauptseite. + Handy: Das Blatt deckt den ganzen Bildschirm, „Zurück“ steht oben rechts (zurueckSlot), und meldet die Seite Reiter, zeigt die Zeile unten + deren Kurzlabels als Wasser-Leiste (wasser-leiste.js, Art 'reiter') an Stelle der vier Bereiche; ohne Reiter bleibt unten nichts. + Desktop: Brotkrume statt Zurück, Reiter als Unterstrich im Slot unter der Überschrift. Der Inhalt meldet Reiter und Titel ohne Import per CustomEvent (bubbles): kn:reiter { eintraege, aktiv, bei } (leere Liste = keine Reiter) und kn:titel { text }. Alles Sichtbare wird mit h() und textContent gebaut. */ import { h, leeren, text } from './dom.js' import { baueReiter } from './reiter.js' +import { baueWasserLeiste } from './wasser-leiste.js' const gleicheEintraege = (a, b) => a.length === b.length && a.every((x, i) => x.key === b[i].key && x.label === b[i].label && (x.kurz ?? '') === (b[i].kurz ?? '')) -export function baueEtage({ blatt, haupt, zeile, titel, brot, reiterSlot, istHandy, onZurueck }) { +export function baueEtage({ blatt, haupt, zeile, zurueckSlot, titel, brot, reiterSlot, istHandy, onZurueck }) { const stand = { offen: false, titel: '', eltern: null, seite: '', reiter: null, chatPunkt: false, reiterEl: null, zurueckEl: null } const zurueckLabel = () => `Zurück zu ${stand.eltern.label}${stand.chatPunkt ? ', neue Nachricht im Chat' : ''}` - function baueZurueck(voll) { - return h('button', { class: voll ? 'kn-zurueck kn-zurueck--voll' : 'kn-zurueck', type: 'button', 'aria-label': zurueckLabel(), on: { click: () => onZurueck() } }, + function baueZurueck() { + return h('button', { class: 'kn-zurueck', type: 'button', 'aria-label': zurueckLabel(), on: { click: () => onZurueck() } }, h('span', { class: 'kn-l' }, 'Zurück', stand.chatPunkt ? h('span', { class: 'kn-punkt' }) : null)) } function neuerReiter(art) { const { eintraege, aktiv } = stand.reiter - // Handy zeigt die Kurzlabels (der Balken hat wenig Platz), Desktop die vollen Beschriftungen - const zeigen = art === 'balken' ? eintraege : eintraege.map(({ kurz, ...rest }) => rest) - return baueReiter({ eintraege: zeigen, aktiv, art, ariaLabel: 'Reiter', bei: (key) => stand.reiter?.bei?.(key) }) + const bei = (key) => stand.reiter?.bei?.(key) + // Handy: Wasser-Leiste mit den Kurzlabels (vorgelesen wird das volle Wort); Desktop: Unterstrich mit den vollen Beschriftungen + if (art === 'wasser') return baueWasserLeiste({ eintraege: eintraege.map(({ key, label, kurz }) => ({ key, label, kurz })), aktiv, art: 'reiter', ariaLabel: 'Reiter', bei }) + return baueReiter({ eintraege: eintraege.map(({ kurz, ...rest }) => rest), aktiv, art, ariaLabel: 'Reiter', bei }) } function zeichneBrot() { @@ -37,18 +40,19 @@ export function baueEtage({ blatt, haupt, zeile, titel, brot, reiterSlot, istHan brot.append(h('a', { href: stand.eltern.hash, on: { click: verlassen } }, stand.eltern.label), h('span', { class: 'kn-brot-trenner', 'aria-hidden': 'true' }, ' / '), h('span', { 'aria-current': 'page' }, stand.titel)) } - // Zeile (Handy) und Reiter-Slot (Desktop) aus dem Stand neu aufbauen + // Zurück und Zeile (Handy) bzw. Reiter-Slot (Desktop) aus dem Stand neu aufbauen function zeichne() { - leeren(zeile); leeren(reiterSlot) + stand.reiterEl?.beende?.() // die alte Wasser-Leiste gibt ihren WebGL-Kontext frei + leeren(zeile); leeren(reiterSlot); if (zurueckSlot) leeren(zurueckSlot) stand.zurueckEl = null; stand.reiterEl = null zeichneBrot() if (!stand.offen) return if (istHandy()) { - stand.zurueckEl = baueZurueck(!stand.reiter) - zeile.append(stand.zurueckEl) + stand.zurueckEl = baueZurueck() + ;(zurueckSlot ?? zeile).append(stand.zurueckEl) if (stand.reiter) { - stand.reiterEl = neuerReiter('balken') - zeile.append(h('span', { class: 'kn-vl' }), stand.reiterEl) + stand.reiterEl = neuerReiter('wasser') + zeile.append(stand.reiterEl) } } else if (stand.reiter) { stand.reiterEl = neuerReiter('unterstrich') diff --git a/public/neu/huelle.css b/public/neu/huelle.css index 4694888..873b2cb 100644 --- a/public/neu/huelle.css +++ b/public/neu/huelle.css @@ -1,4 +1,5 @@ -/* Kundenansicht neu: Hülle (Menü, Kopf, Blatt, Leiste, Streifen, Konto). Weiß, Haarlinie, Schwarz; keine Verläufe, kein Glas. +/* Kundenansicht neu: Hülle (Menü, Kopf, Blatt, Leiste, Streifen, Konto). Weiß, Haarlinie, Schwarz; keine Verläufe, kein Glas (das Glas der Handy-Leiste + und des Zurück-Knopfs steht allein in wasser-leiste.css). Die Grundregeln gelten am Desktop, die Handy-Abfrage überschreibt für das Handy. */ /* ---------- Blatt (Etage 2) ---------- */ @@ -13,20 +14,28 @@ .kn-zurueck { position: relative; display: flex; align-items: center; gap: 6px; height: 44px; padding: 0 8px 0 6px; font-size: 15px; font-weight: 600; color: var(--kn-text); } .kn-zurueck::before { content: ""; width: 9px; height: 9px; margin-left: 4px; border: solid; border-width: 0 0 2px 2px; transform: rotate(45deg); } -.kn-zurueck--voll { flex: 1; } .kn-zurueck .kn-l { position: relative; line-height: 20px; } -.kn-vl { flex: none; width: 1px; height: 24px; margin: 0 4px; background: var(--kn-line); } +.kn-blatt-zurueck { display: none; } /* „Zurück“ der Unterseite, nur am Handy (etage.js); der Desktop hat die Brotkrume */ .kn-punkt { position: absolute; top: -2px; left: 100%; width: 9px; height: 9px; margin-left: 2px; border-radius: 50%; background: #b3093c; box-shadow: 0 0 0 2px var(--kn-bg); } @media (max-width: 880px), (pointer: coarse) and (max-height: 500px) { .kn { --kn-unten: calc(var(--kn-zeile-h) + env(safe-area-inset-bottom)); } + /* Die Unterseite öffnet sich ganz (unter der Statusleiste) und wird eingeblendet (die Unschärfe dazu in wasser-leiste.css). Oben links die + Überschrift, oben rechts „Zurück“; er bleibt beim Scrollen stehen. Reiter stehen unten in der Wasser-Leiste. */ + .kn { --kn-zurueck-oben: 17px; } /* so steht der 40 px hohe Knopf auf der Mitte der ersten Zeile der Überschrift */ .kn-blatt { - position: fixed; inset: calc(var(--kn-banner-h) + var(--kn-kopf-h) + env(safe-area-inset-top)) 0 var(--kn-unten, var(--kn-zeile-h)); z-index: 20; - overflow-y: auto; overscroll-behavior: contain; border-radius: 12px 12px 0 0; - box-shadow: 0 0 0 1px rgba(26,31,54,.06), 0 -8px 24px rgba(26,31,54,.1); - transform: translateY(100%); transition: transform 550ms var(--kn-bremse); + position: fixed; inset: calc(var(--kn-banner-h) + env(safe-area-inset-top)) 0 0; z-index: 20; overflow-y: auto; overscroll-behavior: contain; + opacity: 0; transition: opacity 380ms ease; } - .kn-blatt--auf { transform: none; } + .kn-blatt--auf { opacity: 1; } + .kn-blatt > .kn-titel { padding-right: 108px; } + .kn-blatt-zurueck { + display: block; position: fixed; top: calc(var(--kn-banner-h) + env(safe-area-inset-top) + var(--kn-zurueck-oben)); right: max(16px, env(safe-area-inset-right)); z-index: 25; + visibility: hidden; opacity: 0; transition: opacity 380ms ease, visibility 0s 380ms; + } + .kn-blatt--auf ~ .kn-blatt-zurueck { visibility: visible; opacity: 1; transition-delay: 0s; } + .kn-blatt-zurueck .kn-zurueck { height: 40px; padding: 0 16px 0 10px; border-radius: 999px; background: var(--kn-bg); box-shadow: var(--kn-shadow-pop); } + .kn .kn-blatt-zurueck .kn-zurueck:focus-visible { outline-offset: 2px; } .kn-haupt[inert] { display: block; } /* am Handy bleibt die Hauptseite unter dem Blatt stehen */ .kn-brot { display: none; } } @@ -131,7 +140,7 @@ .kn-kopf-titel { display: block; font-size: 15px; line-height: 22px; font-weight: 600; opacity: 0; transform: translateY(5px); transition: opacity 250ms, transform 250ms; } .kn-titled .kn-kopf-titel { opacity: 1; transform: none; } .kn-haupt { max-width: none; padding: 2px max(16px, env(safe-area-inset-right)) calc(var(--kn-zeile-h) + env(safe-area-inset-bottom) + 28px) max(16px, env(safe-area-inset-left)); } - .kn-blatt { max-width: none; padding: 0 max(16px, env(safe-area-inset-right)) 24px max(16px, env(safe-area-inset-left)); } + .kn-blatt { max-width: none; padding: 14px max(16px, env(safe-area-inset-right)) calc(var(--kn-unten, var(--kn-zeile-h)) + 24px) max(16px, env(safe-area-inset-left)); } .kn-haupt .kn-titel, .kn-blatt .kn-titel { margin: 6px 0 10px; font-size: 28px; line-height: 34px; } .kn-banner button { min-height: 44px; } #kn-fehler { margin: 12px max(16px, env(safe-area-inset-right)) 0 max(16px, env(safe-area-inset-left)); } @@ -204,7 +213,9 @@ @media (max-height: 500px) and (max-width: 880px), (max-height: 500px) and (pointer: coarse) { .kn { --kn-zeile-h: 44px; --kn-kopf-h: 44px; } .kn-leiste-reel[data-etage="offen"] .kn-rw { transform: translateY(-44px); } - .kn-haupt .kn-titel, .kn-blatt .kn-titel { display: none; } + .kn-haupt .kn-titel { display: none; } + .kn-blatt .kn-titel { margin: 0 0 6px; font-size: 20px; line-height: 26px; } /* die Unterseite deckt den Kopf ab: ihre Überschrift bleibt, kleiner */ + .kn { --kn-zurueck-oben: 7px; } .kn-kopf-titel { opacity: 1; transform: none; } } @@ -217,6 +228,8 @@ .kn-leiste-reel[data-etage="offen"] #kn-zeile-haupt, .kn-leiste-reel:not([data-etage="offen"]) #kn-zeile-etage { transition: none; } .kn-blatt { transform: none; opacity: 0; transition: opacity 150ms; } .kn-blatt--auf { opacity: 1; } + .kn-blatt-zurueck { transition: opacity 150ms, visibility 0s 150ms; } + .kn-blatt--auf ~ .kn-blatt-zurueck { transition: opacity 150ms; } .kn-konto-scrim, .kn-konto { animation: kn-einblenden 150ms ease-out; } .kn-zahl { animation: none; } .kn-zahl--weg { transition: none; } diff --git a/public/neu/huelle.js b/public/neu/huelle.js index 8d1f2e2..a0dbc7a 100644 --- a/public/neu/huelle.js +++ b/public/neu/huelle.js @@ -14,7 +14,7 @@ import { erzeugeZaehler } from './zaehler.js' import { baueStreifen, ladeFristen, ladeFristenNeu } from './fristen-streifen.js' import { baueAvatar, baueKonto, installAngebot } from './konto.js' -export const GERUEST_IDS = ['kn-banner', 'kn-ansage', 'kn-fehler', 'kn-menue', 'kn-kopf', 'kn-kopf-titel', 'kn-kopf-konto', 'kn-inhalt', 'bereich-website', 'bereich-funktionen', 'bereich-chat', 'bereich-vertraege', 'website-body', 'funktionen-body', 'chat-body', 'vertraege-body', 'kn-blatt', 'kn-brot', 'kn-etage-titel', 'kn-reiter-slot', 'news-body', 'termine-body', 'webshop-body', 'buchungen-body', 'zeiten-body', 'analyse-body', 'etage-body', 'kn-streifen', 'kn-leiste', 'kn-zeile-haupt', 'kn-zeile-etage', 'kn-konto-wurzel'] +export const GERUEST_IDS = ['kn-banner', 'kn-ansage', 'kn-fehler', 'kn-menue', 'kn-kopf', 'kn-kopf-titel', 'kn-kopf-konto', 'kn-inhalt', 'bereich-website', 'bereich-funktionen', 'bereich-chat', 'bereich-vertraege', 'website-body', 'funktionen-body', 'chat-body', 'vertraege-body', 'kn-blatt', 'kn-brot', 'kn-etage-titel', 'kn-reiter-slot', 'news-body', 'termine-body', 'webshop-body', 'buchungen-body', 'zeiten-body', 'analyse-body', 'etage-body', 'kn-streifen', 'kn-leiste', 'kn-zeile-haupt', 'kn-zeile-etage', 'kn-konto-wurzel', 'kn-blatt-zurueck'] // Bereich → [Modul, Funktion, Wurzel]; die Ansicht heißt ansicht-.js und exportiert zeige 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\)/)