/* 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) let platziert = false function platziere(animiert) { if (!kannMessen()) return // Die erste Platzierung springt (der Gleiter fährt beim Laden nicht aus der Ecke ein), erst die folgenden federn if (!platziert) { animiert = false; platziert = true } 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') const luft = typeof getComputedStyle === 'function' ? parseFloat(getComputedStyle(wurzel).paddingRight) || 0 : 0 // Luft rechts gehört nicht zum Platz der Wörter if (schriftStufe({ inhaltsBreite: basisEl.scrollWidth, verfuegbar: wurzel.clientWidth - luft }) === 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 }