Kundenansicht neu P1: gemeinsame Reiter mit Balken, Overlay-Rückfall und Zählern

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-16 16:00:39 +00:00
1 parent 819725a3c1
commit a51643c4a5
2 files changed
+261

No files matched your search

+175
View File
@@ -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
}
+86
View File
@@ -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() }
})