From a51643c4a577c349bc1684f69c977ce2a18d6784 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 16:00:39 +0000 Subject: [PATCH] =?UTF-8?q?Kundenansicht=20neu=20P1:=20gemeinsame=20Reiter?= =?UTF-8?q?=20mit=20Balken,=20Overlay-R=C3=BCckfall=20und=20Z=C3=A4hlern?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- public/neu/reiter.js | 175 ++++++++++++++++++++++++++++++++++++ test/knHuelleReiter.test.js | 86 ++++++++++++++++++ 2 files changed, 261 insertions(+) create mode 100644 public/neu/reiter.js create mode 100644 test/knHuelleReiter.test.js diff --git a/public/neu/reiter.js b/public/neu/reiter.js new file mode 100644 index 0000000..f2c9948 --- /dev/null +++ b/public/neu/reiter.js @@ -0,0 +1,175 @@ +/* Kundenansicht neu: gemeinsame Reiter. Zwei Arten mit demselben Baustein: + 'unterstrich' = Reiter einer Hauptseite (basis.css, .kn-reiter), 'balken' = Fahrstuhl-Zeile und Reiter in Etage 2: + ein schwarzer Gleiter fährt federnd unter das aktive Wort, darin eine Overlay-Kopie der Wörter in Weiß mit Gegenbewegung + (final-2-frei.html). Das Overlay ist aria-hidden und trägt keine Rollen. Zahlenfunktionen sind rein und werden mit node --test geprüft; + Messen und Bewegen braucht getBoundingClientRect und entfällt ohne Browser. */ + +import { h } from './dom.js' + +const FEDER_MS = 260 // so lange bleibt eine ausgeblendete Zahl im Baum (Animation .kn-zahl--weg) + +/** Maße von Gleiter und Overlay. Das Overlay ist so breit wie die Zeile und läuft dem Gleiter um dessen Position entgegen. */ +export function balkenMasse({ basis, aktiv, gesamtBreite }) { + const a = basis[aktiv] + if (!a) return { links: 0, breite: 0, overlayVersatz: 0, overlayBreite: gesamtBreite } + return { links: a.links, breite: a.breite, overlayVersatz: 0 - a.links, overlayBreite: gesamtBreite } +} + +/** Stimmen die Wortbreiten der Overlay-Kopie mit denen der Wörter überein? Sonst Rückfall ohne Overlay. */ +export function overlayPasst(basisBreiten, overlayBreiten, toleranz = 0.5) { + return basisBreiten.length === overlayBreiten.length && basisBreiten.every((b, i) => Math.abs(b - overlayBreiten[i]) <= toleranz) +} + +/** 15 px, solange alle Wörter in die Zeile passen, sonst 14 px. */ +export function schriftStufe({ inhaltsBreite, verfuegbar }) { + return inhaltsBreite > verfuegbar ? 14 : 15 +} + +/** Neue Scrollposition, damit ein Reiter mit `rand` Luft im Bild steht; sonst bleibt sie. */ +export function sichtbarMachen({ scrollLinks, sichtbarBreite, itemLinks, itemBreite, rand = 16 }) { + let ziel = scrollLinks + if (itemLinks - rand < scrollLinks) ziel = itemLinks - rand + else if (itemLinks + itemBreite + rand > scrollLinks + sichtbarBreite) ziel = itemLinks + itemBreite + rand - sichtbarBreite + return Math.max(0, ziel) +} + +/** Text der Anzeige am Wort: "!" hat Vorrang, sonst die Zahl, ab 100 "99+", bei 0 nichts. */ +export function anzeigeText({ zahl = 0, ausruf = false } = {}) { + if (ausruf) return '!' + const n = Math.trunc(Number(zahl)) + if (!(n > 0)) return '' + return n > 99 ? '99+' : String(n) +} + +const beschriftung = (e) => { + const text = anzeigeText({ zahl: e.zahl, ausruf: e.ausruf }) + if (!text) return e.kurz ? e.label : null + if (e.ausruf) return `${e.label}, ${e.ausrufText ?? 'Hinweis'}` + return `${e.label}, ${text}${e.einheit ? ` ${e.einheit}` : ''}` +} + +/** Reiter bauen. Eintrag { key, label, kurz?, einheit?, ausrufText? }. Liefert das Element mit setzeAktiv, setzeAnzeige und messen. */ +export function baueReiter({ eintraege, aktiv, art = 'unterstrich', ariaLabel = 'Reiter', bei = () => {}, warte = (fn, ms) => setTimeout(fn, ms) }) { + const balken = art === 'balken' + const liste = eintraege.map((e) => ({ ...e, zahl: 0, ausruf: false })) + let aktuell = aktiv + + const wort = (e) => h('span', { class: 'kn-l' }, e.kurz ?? e.label) + for (const e of liste) { + e.tab = h('button', { class: balken ? 'kn-t' : 'kn-reiter-t', role: 'tab', type: 'button', 'data-key': e.key, 'aria-selected': 'false', tabindex: '-1', on: { click: () => waehle(e.key) } }, wort(e)) + e.kopie = balken ? h('span', { class: 'kn-t' }, wort(e)) : null + } + const tabs = liste.map((e) => e.tab) + + function waehle(key) { + setzeAktiv(key) + bei(key) + } + + const beiTaste = (ev) => { + const i = tabs.indexOf(ev.target?.closest?.('[role=tab]')) + if (i === -1) return + const n = tabs.length + const ziel = { ArrowRight: (i + 1) % n, ArrowLeft: (i + n - 1) % n, Home: 0, End: n - 1 }[ev.key] + if (ziel === undefined) return + ev.preventDefault() + tabs[ziel].focus() + if (ziel !== i) waehle(liste[ziel].key) + } + + let gleiter = null + let overlay = null + let basisEl = null + let wurzel + if (balken) { + overlay = h('div', { class: 'kn-overlay' }, liste.map((e) => e.kopie)) + gleiter = h('div', { class: 'kn-gleiter', 'aria-hidden': 'true' }, overlay) + basisEl = h('div', { class: 'kn-balken-basis', role: 'tablist', 'aria-label': ariaLabel, on: { keydown: beiTaste } }, tabs) + wurzel = h('div', { class: 'kn-balken', 'data-art': 'balken' }, gleiter, basisEl) + } else { + wurzel = h('div', { class: 'kn-reiter', role: 'tablist', 'aria-label': ariaLabel, 'data-art': 'unterstrich', on: { keydown: beiTaste } }, tabs) + } + + const kannMessen = () => typeof wurzel.getBoundingClientRect === 'function' && wurzel.isConnected && wurzel.getBoundingClientRect().width > 0 + const rect = (el) => el.getBoundingClientRect() + const reduziert = () => Boolean(globalThis.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches) + + function platziere(animiert) { + if (!kannMessen()) return + const bezug = rect(basisEl ?? wurzel) + const i = liste.findIndex((e) => e.key === aktuell) + if (balken) { + const basis = tabs.map((t) => { const r = rect(t); return { links: Math.round(r.left - bezug.left), breite: Math.round(r.width) } }) + const m = balkenMasse({ basis, aktiv: i, gesamtBreite: bezug.width }) + if (!animiert) { gleiter.style.transition = 'none'; overlay.style.transition = 'none' } + gleiter.style.width = `${m.breite}px` + gleiter.style.transform = `translateX(${m.links}px)` + overlay.style.width = `${m.overlayBreite}px` + overlay.style.transform = `translateX(${m.overlayVersatz}px)` + if (!animiert) { void gleiter.offsetWidth; gleiter.style.transition = ''; overlay.style.transition = '' } + } + if (i === -1) return + const r = rect(tabs[i]) + const ziel = sichtbarMachen({ scrollLinks: wurzel.scrollLeft, sichtbarBreite: wurzel.clientWidth, itemLinks: r.left - rect(wurzel).left + wurzel.scrollLeft, itemBreite: r.width }) + if (Math.abs(ziel - wurzel.scrollLeft) < 1) return + if (typeof wurzel.scrollTo === 'function') wurzel.scrollTo({ left: ziel, behavior: animiert && !reduziert() ? 'smooth' : 'auto' }) + else wurzel.scrollLeft = ziel + } + + /** Schrift, Overlay-Rückfall und Gleiter neu vermessen (ohne Animation); nach Größenänderung und beim Einhängen. */ + function messen() { + if (!kannMessen()) return + if (balken) { + wurzel.classList.remove('kn-schrift-klein', 'kn-fb') + if (schriftStufe({ inhaltsBreite: basisEl.scrollWidth, verfuegbar: wurzel.clientWidth }) === 14) wurzel.classList.add('kn-schrift-klein') + overlay.style.width = `${rect(basisEl).width}px` + if (!overlayPasst(tabs.map((t) => rect(t).width), liste.map((e) => rect(e.kopie).width))) wurzel.classList.add('kn-fb') + } + platziere(false) + } + + function setzeAktiv(key) { + aktuell = key + const treffer = liste.some((e) => e.key === key) + for (const e of liste) { + const an = e.key === key + e.tab.setAttribute('aria-selected', String(an)) + if (an) e.tab.setAttribute('aria-current', 'page') + else e.tab.removeAttribute('aria-current') + e.tab.setAttribute('tabindex', an || (!treffer && e === liste[0]) ? '0' : '-1') + } + platziere(true) + } + + function setzeAnzeige(key, { zahl = 0, ausruf = false } = {}) { + const e = liste.find((x) => x.key === key) + if (!e) return + e.zahl = zahl + e.ausruf = ausruf + const text = anzeigeText({ zahl, ausruf }) + for (const ziel of [e.tab, e.kopie].filter(Boolean)) { + let punkt = ziel.querySelector('.kn-zahl') + if (text) { + if (punkt?.classList.contains('kn-zahl--weg')) { punkt.remove(); punkt = null } // frisch erscheinen, damit die Feder noch einmal läuft + if (!punkt) { punkt = h('span', { class: 'kn-zahl' }); ziel.querySelector('.kn-l').append(punkt) } + punkt.textContent = text + punkt.classList.toggle('kn-zahl--ausruf', ausruf) + } else if (punkt && !punkt.classList.contains('kn-zahl--weg')) { + punkt.classList.add('kn-zahl--weg') + warte(() => { if (punkt.classList.contains('kn-zahl--weg')) punkt.remove() }, FEDER_MS) + } + } + const label = beschriftung(e) + if (label) e.tab.setAttribute('aria-label', label) + else e.tab.removeAttribute('aria-label') + } + + wurzel.setzeAktiv = setzeAktiv + wurzel.setzeAnzeige = setzeAnzeige + wurzel.messen = messen + setzeAktiv(aktiv) + for (const e of liste) if (e.kurz) e.tab.setAttribute('aria-label', e.label) + if (typeof ResizeObserver === 'function') new ResizeObserver(() => messen()).observe(wurzel) + globalThis.document?.fonts?.ready?.then(() => messen()) + return wurzel +} diff --git a/test/knHuelleReiter.test.js b/test/knHuelleReiter.test.js new file mode 100644 index 0000000..df22af9 --- /dev/null +++ b/test/knHuelleReiter.test.js @@ -0,0 +1,86 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { installFakeDom } from './support/fakeDom.js' +import { balkenMasse, overlayPasst, schriftStufe, sichtbarMachen, anzeigeText, baueReiter } from '../public/neu/reiter.js' + +test('Zahlenfunktionen: Balken, Overlay-Rückfall, Schriftstufe, Sichtbarmachen (C2)', () => { + const basis = [{ links: 0, breite: 90 }, { links: 90, breite: 100 }, { links: 190, breite: 60 }, { links: 250, breite: 90 }] + assert.deepEqual(balkenMasse({ basis, aktiv: 1, gesamtBreite: 340 }), { links: 90, breite: 100, overlayVersatz: -90, overlayBreite: 340 }) + assert.deepEqual([overlayPasst([90, 100], [90, 100.4]), overlayPasst([90, 100], [90, 101])], [true, false]) + assert.deepEqual([schriftStufe({ inhaltsBreite: 342, verfuegbar: 342 }), schriftStufe({ inhaltsBreite: 343, verfuegbar: 342 })], [15, 14]) + const s = (scrollLinks, itemLinks) => sichtbarMachen({ scrollLinks, sichtbarBreite: 300, itemLinks, itemBreite: 80 }) + assert.deepEqual([s(0, 420), s(200, 120), s(100, 150), s(50, 5)], [216, 104, 100, 0]) +}) + +test('anzeigeText: Zahl, "!" und Deckel 99+ (Review Focus 3)', () => { + assert.deepEqual([0, 7, 99, 100, 1500].map((zahl) => anzeigeText({ zahl })), ['', '7', '99', '99+', '99+']) + assert.deepEqual([anzeigeText({ zahl: 3, ausruf: true }), anzeigeText({ zahl: 0, ausruf: true })], ['!', '!']) +}) + +test('baueReiter: Rollen, Auswahl, Kurzlabel, Pfeiltasten, Anzeige mit Beschriftung', async () => { + const fx = installFakeDom() + try { + const gew = [] + const eintraege = [{ key: 'termine', label: 'Termine' }, { key: 'team', label: 'Team & Plätze', kurz: 'Team' }, { key: 'chat', label: 'Chat', einheit: 'ungelesen' }] + const r = baueReiter({ eintraege, aktiv: 'termine', art: 'balken', ariaLabel: 'Bereiche', bei: (k) => gew.push(k), warte: (fn) => fn() }) + const tabs = fx.alle(r, '[role=tab]') + assert.deepEqual(tabs.map((t) => t.textContent), ['Termine', 'Team', 'Chat']); assert.equal(tabs[1].getAttribute('aria-label'), 'Team & Plätze') + assert.deepEqual(tabs.map((t) => t.getAttribute('aria-selected')), ['true', 'false', 'false']); assert.equal(tabs[0].getAttribute('aria-current'), 'page') + assert.equal(fx.alle(r, '.kn-overlay [role=tab]').length, 0) + await fx.klick(tabs[1]); await fx.sende(tabs[1], 'keydown', { key: 'ArrowRight' }); assert.deepEqual(gew, ['team', 'chat']) + r.setzeAktiv('chat'); assert.equal(tabs[2].getAttribute('aria-selected'), 'true') + r.setzeAnzeige('chat', { zahl: 2 }) + assert.deepEqual([fx.finde(tabs[2], '.kn-zahl').textContent, tabs[2].getAttribute('aria-label')], ['2', 'Chat, 2 ungelesen']) + r.setzeAnzeige('chat', { zahl: 0 }); assert.deepEqual([fx.finde(tabs[2], '.kn-zahl'), tabs[2].hasAttribute('aria-label')], [null, false]) + } finally { fx.restore() } +}) + +test('baueReiter: Randfälle (ohne aktiven Eintrag, Home/End/Umbruch, Unterstrich-Art, Spiegelung im Overlay, Zahl kehrt vor dem Entfernen zurück)', async () => { + assert.deepEqual(balkenMasse({ basis: [{ links: 0, breite: 90 }], aktiv: -1, gesamtBreite: 340 }), { links: 0, breite: 0, overlayVersatz: 0, overlayBreite: 340 }) + assert.ok(Object.is(balkenMasse({ basis: [{ links: 0, breite: 90 }], aktiv: 0, gesamtBreite: 340 }).overlayVersatz, 0)) + const fx = installFakeDom() + try { + const gew = []; const spaeter = [] + const eintraege = [{ key: 'a', label: 'A' }, { key: 'b', label: 'B', einheit: 'offen', ausrufText: 'Zahlung fehlgeschlagen' }, { key: 'c', label: 'C' }] + const r = baueReiter({ eintraege, aktiv: 'zzz', art: 'balken', bei: (k) => gew.push(k), warte: (fn) => spaeter.push(fn) }) + const tabs = fx.alle(r, '[role=tab]'); const kopien = fx.alle(r, '.kn-overlay .kn-t') + assert.deepEqual([tabs.map((t) => t.getAttribute('aria-selected')), tabs.map((t) => t.getAttribute('tabindex'))], [['false', 'false', 'false'], ['0', '-1', '-1']]) // ohne Treffer bleibt der erste Reiter per Tab erreichbar + assert.deepEqual([r.getAttribute('data-art'), fx.finde(r, '.kn-overlay').parentNode.getAttribute('aria-hidden'), kopien.map((k) => k.textContent)], ['balken', 'true', ['A', 'B', 'C']]) + await fx.sende(tabs[0], 'keydown', { key: 'ArrowLeft' }); await fx.sende(tabs[2], 'keydown', { key: 'ArrowRight' }); await fx.sende(tabs[1], 'keydown', { key: 'Home' }); await fx.sende(tabs[1], 'keydown', { key: 'End' }); await fx.sende(tabs[1], 'keydown', { key: 'x' }) + assert.deepEqual(gew, ['c', 'a', 'a', 'c']) + r.setzeAnzeige('b', { zahl: 150 }); assert.deepEqual([fx.finde(tabs[1], '.kn-zahl').textContent, fx.finde(kopien[1], '.kn-zahl').textContent, tabs[1].getAttribute('aria-label')], ['99+', '99+', 'B, 99+ offen']) + r.setzeAnzeige('b', { zahl: 5, ausruf: true }); assert.deepEqual([fx.finde(tabs[1], '.kn-zahl').textContent, tabs[1].getAttribute('aria-label')], ['!', 'B, Zahlung fehlgeschlagen']) + r.setzeAnzeige('b', { zahl: 0 }); assert.ok(fx.finde(tabs[1], '.kn-zahl').classList.contains('kn-zahl--weg')); assert.equal(spaeter.length, 2) // erst nach 260 ms weg + r.setzeAnzeige('b', { zahl: 3 }); spaeter.splice(0).forEach((fn) => fn()) // der alte Timer darf die neue Zahl nicht löschen + assert.deepEqual([fx.alle(tabs[1], '.kn-zahl').map((z) => z.textContent), fx.alle(tabs[1], '.kn-zahl--weg').length], [['3'], 0]) + const u = baueReiter({ eintraege, aktiv: 'b', ariaLabel: 'Funktionen', bei: () => {} }) + assert.deepEqual([u.className, u.getAttribute('role'), u.getAttribute('aria-label'), fx.alle(u, '.kn-reiter-t').length, fx.alle(u, '.kn-overlay').length], ['kn-reiter', 'tablist', 'Funktionen', 3, 0]) + } finally { fx.restore() } +}) + +test('messen: Gleiter, Overlay-Gegenbewegung, Schriftstufe, Rückfall und Sichtbarmachen mit nachgestellter Geometrie', () => { + const fx = installFakeDom() + try { + const eintraege = ['a', 'b', 'c', 'd', 'e', 'f'].map((key) => ({ key, label: key.toUpperCase() })) + const r = baueReiter({ eintraege, aktiv: 'a', art: 'balken', bei: () => {} }) + fx.document.body.append(r) + const basis = fx.finde(r, '.kn-balken-basis'); const gleiter = fx.finde(r, '.kn-gleiter'); const overlay = fx.finde(r, '.kn-overlay') + const tabs = fx.alle(r, '[role=tab]'); const kopien = fx.alle(overlay, '.kn-t') + for (const el of [gleiter, overlay]) el.style = {} + gleiter.offsetWidth = 0 + r.clientWidth = 300; r.scrollLeft = 0; basis.scrollWidth = 600 + r.getBoundingClientRect = () => ({ left: 10, width: 300 }); basis.getBoundingClientRect = () => ({ left: 10 - r.scrollLeft, width: 600 }) + tabs.forEach((t, i) => { t.getBoundingClientRect = () => ({ left: 10 - r.scrollLeft + i * 100, width: 100 }) }) + kopien.forEach((k) => { k.getBoundingClientRect = () => ({ left: 0, width: 100 }) }) + r.messen() + assert.deepEqual([r.classList.contains('kn-schrift-klein'), r.classList.contains('kn-fb')], [true, false]) // 600 > 300: Schrift 14 px, alle Wörter gleich breit: Overlay bleibt + assert.deepEqual([gleiter.style.width, gleiter.style.transform, overlay.style.width, overlay.style.transform], ['100px', 'translateX(0px)', '600px', 'translateX(0px)']) + r.setzeAktiv('c') + assert.deepEqual([gleiter.style.transform, overlay.style.transform, gleiter.style.transition], ['translateX(200px)', 'translateX(-200px)', '']) + r.setzeAktiv('f'); assert.equal(r.scrollLeft, 316) // 500 + 100 + 16 - 300 + r.setzeAktiv('a'); assert.equal(r.scrollLeft, 0) + basis.scrollWidth = 300; kopien[2].getBoundingClientRect = () => ({ left: 0, width: 103 }); r.messen() + assert.deepEqual([r.classList.contains('kn-schrift-klein'), r.classList.contains('kn-fb')], [false, true]) // passt, aber das Overlay weicht ab: Rückfall + r.setzeAktiv('zzz'); assert.equal(gleiter.style.width, '0px') + } finally { fx.restore() } +})