From 748226d631defecf600c6c11e6fc9b2d04f480de Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 16:04:46 +0000 Subject: [PATCH] =?UTF-8?q?Kundenansicht=20neu=20P1:=20Etage=202=20mit=20Z?= =?UTF-8?q?ur=C3=BCck,=20Reiterzeile=20und=20Brotkrume?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- public/neu/etage.js | 117 +++++++++++++++++++++++++++++++++++++ public/neu/huelle.css | 32 ++++++++++ test/knHuelleEtage.test.js | 79 +++++++++++++++++++++++++ 3 files changed, 228 insertions(+) create mode 100644 public/neu/etage.js create mode 100644 public/neu/huelle.css create mode 100644 test/knHuelleEtage.test.js diff --git a/public/neu/etage.js b/public/neu/etage.js new file mode 100644 index 0000000..24d65a3 --- /dev/null +++ b/public/neu/etage.js @@ -0,0 +1,117 @@ +/* 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. + 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' + +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 }) { + 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() } }, + 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) }) + } + + function zeichneBrot() { + leeren(brot) + if (!stand.offen) return + const verlassen = (ev) => { + if (ev.defaultPrevented || ev.button > 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return + ev.preventDefault() // die Brotkrume führt wie „Zurück“ zur Elternseite, ohne neuen Verlaufseintrag + onZurueck() + } + 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 + function zeichne() { + leeren(zeile); leeren(reiterSlot) + stand.zurueckEl = null; stand.reiterEl = null + zeichneBrot() + if (!stand.offen) return + if (istHandy()) { + stand.zurueckEl = baueZurueck(!stand.reiter) + zeile.append(stand.zurueckEl) + if (stand.reiter) { + stand.reiterEl = neuerReiter('balken') + zeile.append(h('span', { class: 'kn-vl' }), stand.reiterEl) + } + } else if (stand.reiter) { + stand.reiterEl = neuerReiter('unterstrich') + reiterSlot.append(stand.reiterEl) + } + stand.reiterEl?.messen() + } + + function oeffne({ titel: titelText, eltern, seite }) { + const warOffen = stand.offen + Object.assign(stand, { offen: true, titel: titelText, eltern, seite, reiter: null }) + text(titel, titelText) + for (const s of blatt.querySelectorAll('section[data-etage]')) s.hidden = s.getAttribute('data-etage') !== seite + haupt.setAttribute('inert', '') + if (!warOffen) { + blatt.hidden = false + void blatt.offsetWidth // Ausgangslage festschreiben, damit das Blatt von unten einfährt + blatt.classList.add('kn-blatt--auf') + } + zeichne() + } + + function schliesse() { + stand.offen = false + stand.reiter = null + blatt.classList.remove('kn-blatt--auf') + blatt.hidden = true + haupt.removeAttribute('inert') + zeichne() + } + + function setzeReiter({ eintraege = [], aktiv = '', bei } = {}) { + if (!stand.offen) return + const alt = stand.reiter + stand.reiter = eintraege.length ? { eintraege, aktiv, bei } : null + // Gleiche Reiter bleiben stehen (der Balken fährt weiter), nur die Auswahl wechselt + if (alt && stand.reiter && stand.reiterEl && gleicheEintraege(alt.eintraege, eintraege)) stand.reiterEl.setzeAktiv(aktiv) + else zeichne() + } + + function setzeChatPunkt(an) { + stand.chatPunkt = Boolean(an) + const knopf = stand.zurueckEl + if (!knopf) return + const wort = knopf.querySelector('.kn-l') + const punkt = wort.querySelector('.kn-punkt') + if (stand.chatPunkt && !punkt) wort.append(h('span', { class: 'kn-punkt' })) + else if (!stand.chatPunkt && punkt) punkt.remove() + knopf.setAttribute('aria-label', zurueckLabel()) + } + + // Meldungen aus dem Inhalt; Meldungen einer verborgenen Seite oder nach dem Schließen gelten nicht + const vonDieserSeite = (ev) => { + const s = ev.target?.closest?.('section[data-etage]') + return stand.offen && (!s || s.getAttribute('data-etage') === stand.seite) + } + blatt.addEventListener('kn:reiter', (ev) => { if (vonDieserSeite(ev)) setzeReiter(ev.detail ?? {}) }) + blatt.addEventListener('kn:titel', (ev) => { + if (!vonDieserSeite(ev) || !ev.detail?.text) return + stand.titel = String(ev.detail.text) + text(titel, stand.titel) + zeichneBrot() + }) + + return { oeffne, schliesse, setzeReiter, neuZeichnen: zeichne, setzeChatPunkt, istOffen: () => stand.offen } +} diff --git a/public/neu/huelle.css b/public/neu/huelle.css new file mode 100644 index 0000000..8bf63a3 --- /dev/null +++ b/public/neu/huelle.css @@ -0,0 +1,32 @@ +/* Kundenansicht neu: Hülle (Menü, Kopf, Blatt, Leiste, Streifen, Konto). Weiß, Haarlinie, Schwarz; keine Verläufe, kein Glas. + Die Grundregeln gelten am Desktop, die Handy-Abfrage überschreibt für das Handy. */ + +/* ---------- Blatt (Etage 2) ---------- */ + +.kn-blatt { max-width: 1160px; padding: 36px 48px 80px; background: var(--kn-bg); } +.kn-haupt[inert] { display: none; } /* Desktop: die Unterseite ersetzt die Hauptseite in der Spalte */ + +.kn-brot { margin-bottom: 8px; font-size: 13px; line-height: 20px; color: var(--kn-text-2); } +.kn-brot a { color: var(--kn-text-2); } +.kn-brot-trenner { color: var(--kn-text-3); } +.kn-brot [aria-current="page"] { color: var(--kn-text); } + +.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-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)); } + .kn-blatt { + position: fixed; inset: 0 0 var(--kn-unten, var(--kn-zeile-h)); z-index: 20; max-width: none; padding: 0 16px 24px; + 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); + } + .kn-blatt--auf { transform: none; } + .kn-haupt[inert] { display: block; } /* am Handy bleibt die Hauptseite unter dem Blatt stehen */ + .kn-brot { display: none; } +} diff --git a/test/knHuelleEtage.test.js b/test/knHuelleEtage.test.js new file mode 100644 index 0000000..c326cd9 --- /dev/null +++ b/test/knHuelleEtage.test.js @@ -0,0 +1,79 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { installFakeDom } from './support/fakeDom.js' +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 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 () => { + 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']) + 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]) + } 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 () => { + const fx = installFakeDom() + try { + const gew = []; let handy = true; const t = teile(); const e = baueEtage({ ...t, istHandy: () => handy, onZurueck() {} }) + e.oeffne({ titel: 'Terminplaner', eltern: EL, seite: 'termine' }); const kind = t.blatt.children[0] + 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') + 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]) + 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') + } finally { fx.restore() } +}) + +test('Chat-Punkt am Zurück', () => { + const fx = installFakeDom() + 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') + } 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('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 () => { + const fx = installFakeDom() + try { + 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')) + 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 + const [termine, shop] = t.blatt.children + await fx.sende(shop, 'kn:reiter', { detail: { eintraege: REITER, aktiv: 'r0', bei() {} } }) // Reiter einer verborgenen Seite: ignoriert + assert.equal(fx.alle(t.zeile, '[role=tab]').length, 0) + await fx.sende(termine, 'kn:reiter', { detail: { eintraege: REITER, aktiv: 'r0', bei() {} } }) + const erster = fx.finde(t.zeile, '[role=tablist]') + 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 + 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() } +})