Files
Webklar-Kundenbereich/public/neu/reiter.js
T
2026-10-16 16:43:19 +00:00

180 lines
8.7 KiB
JavaScript

/* 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
}