/* Terminplaner im Kundenbereich (ES-Modul; app.js laedt es erst beim Oeffnen der Ansicht #termine). Das ist die Huelle: initTermine(rootEl, { projectId }): Projektauswahl und je nach Zustand der Funktion (GET /api/termine/einstellungen, state) die Ansicht: off: ohne laufendes Wartungs-Abo (daten.abo.ok) der Hinweis wie in der Kachel (Text vom Server, GET /api/funktionen termine.hinweis; kein Fragebogen), sonst der Fragebogen mit der Bestellseite (termine-fragebogen.js); ordered und setup: die Statuskarte (funktion-bestellen.js) und darunter die Reiter Leistungen, Team & Plaetze und Einstellungen nur zum Lesen (der Reiter Termine entfaellt, der Server antwortet dort mit 409); active: die Reiter, ohne Einstellung die Vorlagenauswahl; disabled: der Banner der Abschaltung (abschaltText) und die Reiter nur zum Lesen. Rueckkehr von Stripe: #termine?bestellt=1&projekt= (Zahlung) und #termine?projekt= (Abbruch) waehlen das Projekt, „Danke!“ gibt es nur mit bestellt=1; beides wird einmal gelesen und aus der Adresse genommen. Die Reiter stehen in REITER: jeder ist ein Modul mit render(panel, ctx) => void | Promise (termine-tab-termine.js, termine-tab-leistungen.js, termine-tab-team.js, termine-tab-einstellungen.js; die drei letzten speichern ueber termine-speichern.js). ctx = { s, projectId, daten, nurLesen, auswahl, aktuell(), api, meldung, hinweise, zumReiter, neuLaden } (baueCtx): Zustand und Pruefung "bin ich noch aktuell?" bekommen die Reiter nur ueber ctx, nie ueber Modulvariablen. daten ist die Antwort von GET /api/termine/einstellungen (die Reiter, die speichern, halten daten.einstellung und daten.version aktuell), nurLesen gilt fuer alles ausser off und active, hinweise() zeichnet die Hinweisleiste ueber den Reitern neu (z. B. wenn Telefon und Anschrift nach dem Speichern nicht mehr fehlen). Server: server/routes/termine.js. Tage sind Berliner Kalendertage 'JJJJ-MM-TT'; gerechnet wird in termine-view.js. Helfer ohne Zustand: termine-dom.js. Styles: termine.css. */ import { abschaltText, fehltText, heuteBerlin } from './termine-view.js' import { exportLink, fehlerKarte, h, infoKarte, request } from './termine-dom.js' import { render as renderTermine } from './termine-tab-termine.js' import { render as renderLeistungen } from './termine-tab-leistungen.js' import { render as renderTeam } from './termine-tab-team.js' import { render as renderEinstellungen } from './termine-tab-einstellungen.js' import { renderFragebogen } from './termine-fragebogen.js' import { renderBestellen, statusKarte } from './funktion-bestellen.js' import { ZU_VERTRAEGE } from './vertraege-link.js' import { meldeReiter, reiterEintraege } from './fn-reiter.js' // Auswahl bleibt beim Wechsel zwischen Funktionen und Terminplaner erhalten (der Tag nicht: er beginnt immer bei heute) const auswahl = { projectId: '', reiter: 'termine', ansicht: 'tag', filter: 'alle' } let S = null // aktuelle Sitzung der Ansicht; eine alte Antwort darf eine neuere nicht ueberschreiben (S !== s) /* --------------------------------------------------------- Einstieg --- */ /** * Rueckkehr von Stripe aus der Adresse lesen und die Adresse bereinigen (wie initNews): #termine?bestellt=1&projekt= nach der Zahlung * (success_url) und #termine?projekt= nach dem Abbruch (cancel_url). Gelesen wird einmal, danach steht nur noch #termine da. */ function leseRueckkehr() { const hash = window.location.hash || '' if (!hash.startsWith('#termine?')) return { bestellt: false, projekt: '' } const query = new URLSearchParams(hash.slice(hash.indexOf('?') + 1)) window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}#termine`) return { bestellt: query.get('bestellt') === '1', projekt: query.get('projekt') || '' } } // Abo-Modell (Antwort von GET /api/funktionen, strikt): die Reiter zeichnet die Etage, die Ansicht meldet sie (fn-reiter.js) const istAbo = (s) => s.modell === 'abo' /** Eine Ansicht ohne Reiter meldet die leere Liste, damit die Etage nicht die Reiter der vorigen stehen laesst. */ const keineReiter = (s) => { if (istAbo(s)) meldeReiter(s.root, { eintraege: [] }) } const zustandVon = (projekt) => projekt?.features?.termine?.state || 'off' const istBestellt = (zustand) => zustand === 'ordered' || zustand === 'setup' // Entwurf: Funktionsdokument im Zustand Aus mit siteKey (wie die Kachel in funktionen.js) const hatEntwurf = (projekt) => zustandVon(projekt) === 'off' && Boolean(projekt?.features?.termine?.siteKey) export async function initTermine(root, { projectId, einschalten } = {}) { if (!root) return const rueckkehr = leseRueckkehr() const wunsch = rueckkehr.projekt || projectId || root.dataset.wunschProjekt || '' // die Kachel in Funktionen legt ihr Projekt hier ab delete root.dataset.wunschProjekt if (wunsch) auswahl.projectId = wunsch const { projects = [], termine, modell } = await request('/api/funktionen') const s = { root, projects, termine: termine || null, modell, projekt: null, daten: null, tag: heuteBerlin(), nurLesen: false, lauf: 0, buehne: null, meldungEl: null, danke: false, einschalten } S = s if (!termine?.verfuegbar) { keineReiter(s) root.replaceChildren(infoKarte('Der Terminplaner ist noch nicht verfügbar', 'Sobald er für dich bereit ist, findest du ihn hier.')) return } if (!projects.length) { keineReiter(s) root.replaceChildren(h('div', { class: 'kb-panel kb-empty' }, 'Noch keine Website-Projekte zugewiesen.')) return } // Wahl wie bei der Kachel: das gewuenschte Projekt, dann aktiv, bestellt, abgeschaltet, Entwurf, das erste s.projekt = projects.find((p) => p.projectId === auswahl.projectId) || projects.find((p) => zustandVon(p) === 'active') || projects.find((p) => istBestellt(zustandVon(p))) || projects.find((p) => zustandVon(p) === 'disabled') || projects.find(hatEntwurf) || projects[0] auswahl.projectId = s.projekt.projectId // „Danke!“ nur nach der Zahlung (bestellt=1) und nur fuer das Projekt, fuer das bezahlt wurde s.danke = rueckkehr.bestellt && (!rueckkehr.projekt || rueckkehr.projekt === s.projekt.projectId) s.buehne = h('div', { id: 'termine-buehne' }) root.replaceChildren(...(projects.length > 1 ? [projektwahl(s)] : []), s.buehne) await zeigeProjekt(s) } function projektwahl(s) { const select = h('select', { id: 'termine-project' }, s.projects.map((p) => { const option = h('option', { value: p.projectId }, p.projectName) option.selected = p.projectId === s.projekt.projectId return option })) select.addEventListener('change', () => { auswahl.projectId = select.value s.projekt = s.projects.find((p) => p.projectId === select.value) s.tag = heuteBerlin() s.danke = false // der Dank gilt dem Projekt, mit dem man von Stripe zurueckkam zeigeProjekt(s) }) return h('div', { class: 'fn-project kb-panel' }, h('label', { for: 'termine-project' }, 'Website'), select) } const ladeDaten = (projectId) => request(`/api/termine/einstellungen?projectId=${encodeURIComponent(projectId)}`) /** * Liste der Funktionen neu holen (GET /api/funktionen): Projekte, das aktuelle Projekt darin, Preis, Einrichtungsdauer und Abo-Hinweis des * Servers. Wirft bei einem Fehler und aendert dann nichts. */ async function ladeFunktionen(s) { const antwort = await request('/api/funktionen') const projects = Array.isArray(antwort.projects) ? antwort.projects : [] s.projects = projects s.termine = antwort.termine || null s.modell = antwort.modell s.projekt = projects.find((p) => p.projectId === s.projekt?.projectId) || s.projekt } /** * Hinweis fuer abgeschaltete Funktionen: Grund, Loeschdatum und (ausser vor der Einrichtung) der Export-Link, der einzige Link im Hinweis. * abgeschaltet: { grund, seit, loeschungAm } aus GET /einstellungen (fehlt bei einer aelteren Antwort: dann der bisherige Satz). */ function hinweisAbgeschaltet(projectId, abgeschaltet) { const { davor, danach, mitExport } = abschaltText(abgeschaltet) // ein einziger Textblock in der Flex-Zeile der Box, sonst zerfaellt der Satz in drei Spalten (Text, Link, Punkt) return h('div', { class: 'tm-box tm-box-warn', role: 'note' }, h('span', null, davor, mitExport && exportLink(projectId, 'exportieren'), danach)) } /** * Je nach Zustand der Funktion die Ansicht zeigen (siehe Kopf der Datei). Jeder Aufruf zaehlt s.lauf hoch: die Antwort * einer frueheren Website (langsam, dann zur anderen gewechselt) wird verworfen und erscheint nie unter der neuen. */ async function zeigeProjekt(s, optionen = {}) { const projekt = s.projekt const lauf = ++s.lauf const aktuell = () => S === s && s.lauf === lauf keineReiter(s) s.buehne.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…')) let daten try { daten = await ladeDaten(projekt.projectId) } catch (err) { if (aktuell()) s.buehne.replaceChildren(fehlerKarte(err.message, () => zeigeProjekt(s))) return } if (!aktuell()) return await zeichneAnsicht(s, { daten, aktuell, ...optionen }) } /** * Ansicht nach einer gescheiterten Aktion der Statuskarte neu aufbauen (Ruling R110): Liste und Daten frisch holen und erst dann zeichnen. * Scheitert das Laden, wirft die Funktion und die Karte bleibt mit ihrer Meldung stehen. optionen: bestellformular (ordered zeigt das * Bestellformular statt der Karte), meldung ({ text, art }, nur im Zustand ordered noch von Belang). */ async function ladeNeu(s, optionen = {}) { const lauf = s.lauf await ladeFunktionen(s) const daten = await ladeDaten(s.projekt.projectId) if (S !== s || s.lauf !== lauf) return // inzwischen hat ein Wechsel der Website die Ansicht ersetzt const nr = ++s.lauf await zeichneAnsicht(s, { daten, aktuell: () => S === s && s.lauf === nr, ...optionen }) } /** Die Ansicht zum Zustand aus den frisch geladenen Daten (daten.state; faellt auf den Eintrag der Liste zurueck). */ async function zeichneAnsicht(s, { daten, aktuell, bestellformular = false, meldung = null }) { const projekt = s.projekt s.daten = daten const zustand = daten.state || zustandVon(projekt) s.nurLesen = zustand !== 'active' && zustand !== 'off' keineReiter(s) // die Ansichten mit Reitern (zeigeReiter) melden ihre Liste gleich danach if (zustand === 'off') { if (daten.abo?.ok === true) zeigeFragebogen(s, { projekt, aktuell }) else await zeigeAboHinweis(s, { aktuell }) return } if (istBestellt(zustand)) { await zeigeBestellung(s, { zustand, aktuell, bestellformular, meldung }) return } if (daten.einstellung) zeigeReiter(s, { projekt, aktuell, zustand }) else if (s.nurLesen) { s.buehne.replaceChildren( hinweisAbgeschaltet(projekt.projectId, daten.abgeschaltet), infoKarte('Keine Termine eingerichtet', 'Für diese Website wurden keine Einstellungen und Termine angelegt.', { chat: true })) } else zeigeVorlagen(s, { projekt, aktuell }) } const neueMeldung = () => h('div', { class: 'tm-meldung', role: 'status', 'aria-live': 'polite', tabindex: '-1' }) /* ----------------------------------------------- Zustand off: Hinweis, Fragebogen --- */ /** * Ohne laufendes Wartungs-Abo gibt es keinen Fragebogen, nur den Hinweis. Der Text kommt ausschliesslich vom Server, nie aus einer Kopie im * Browser: aboHinweis aus GET /api/termine/einstellungen (derselbe Aufruf, der den Zustand liefert, also auch dann gefuellt, wenn das Abo nicht * lesbar war oder sich seit dem Laden der Liste geaendert hat), ersatzweise termine.hinweis aus GET /api/funktionen (beide: aboMeldung in * server/services/featureOrder.js). Fehlt der Text auch dort, wird die Liste noch einmal geholt. Bei kein_abo kommt der Link zu den * Vertraegen dazu (Quelle: vertraege-link.js). „Neu laden“ holt Daten und Ansicht neu, damit der Kunde nach einer Aenderung des Abos nicht * an der Karte haengen bleibt. */ async function zeigeAboHinweis(s, { aktuell }) { const textVon = (wert) => (typeof wert === 'string' ? wert : '') if (textVon(s.daten?.aboHinweis) === '' && textVon(s.termine?.hinweis) === '') { try { await ladeFunktionen(s) } catch { // ohne Text bleibt der Hinweis bei der Ueberschrift (und dem Link bei kein_abo) } if (!aktuell()) return } const hinweis = textVon(s.daten?.aboHinweis) || textVon(s.termine?.hinweis) const keinAbo = s.daten?.abo?.grund === 'kein_abo' || (s.termine?.bestellbar === false && s.termine.grund === 'kein_abo') const link = keinAbo ? h('button', { type: 'button', class: ZU_VERTRAEGE.klasse, 'data-view': ZU_VERTRAEGE.view }, ZU_VERTRAEGE.text) : null s.buehne.replaceChildren(h('div', { class: 'kb-panel tm-info', 'data-hinweis': 'abo' }, h('h3', null, 'Terminplaner einrichten'), hinweis || link ? h('p', null, hinweis, hinweis && link ? ' ' : null, link) : null, h('div', { class: 'tm-aktionen' }, h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'neu-laden', onclick: () => zeigeProjekt(s) }, 'Neu laden')))) } /** Der Fragebogen mit der Bestellseite danach (termine-fragebogen.js); die Meldung ueber ihm ist s.meldungEl (ctx.meldung). */ function zeigeFragebogen(s, { projekt, aktuell }) { s.meldungEl = neueMeldung() const wurzel = h('div', { class: 'tm-fb-wurzel' }) s.buehne.replaceChildren(s.meldungEl, wurzel) renderFragebogen(wurzel, baueCtx(s, { projekt, aktuell })) } /* ----------------------------------------------- Zustand ordered, setup --- */ const GESPERRT = 'Deine Angaben sind seit der Bestellung gesperrt. Änderungen laufen über den Chat.' /** * Statuskarte (Zahlung offen | In Einrichtung) und darunter die Reiter zum Lesen. Der Eintrag der Liste liefert Bestelldatum, Ticketnummer und * Rechnung; stimmt sein Zustand nicht mit dem der Daten ueberein (die Liste ist aelter, z. B. nach der Zahlung), wird sie neu geholt. * Die Karte meldet ueber weiter(), wenn „Zahlung fortsetzen“ scheitert: ladeNeu zeichnet die Ansicht dann neu (bei 409 zustimmung_fehlt * mit dem Bestellformular statt der Karte). Das Bestellformular (daten.bestellung, R110) zeigt dieselbe Seite wie der Fragebogen. */ async function zeigeBestellung(s, { zustand, aktuell, bestellformular, meldung }) { if (s.projekt.features?.termine?.state !== zustand) { try { await ladeFunktionen(s) } catch { // es gilt der Zustand der Daten; was nur die Liste kennt (Ticketnummer, Rechnung), fehlt dann in der Karte } if (!aktuell()) return } const projekt = s.projekt const eintrag = { ...projekt.features?.termine, state: zustand } const danke = s.danke s.danke = false // nur beim ersten Zeichnen nach der Rueckkehr const formular = bestellformular && zustand === 'ordered' && Boolean(s.daten.bestellung) const kopf = (ctx) => { if (formular) { const seite = h('div', { class: 'tm-fb-bestellen' }) renderBestellen(seite, { ctx, daten: s.daten, zumSchritt: () => ctx.meldung(GESPERRT, 'fehler') }) return seite } return statusKarte({ projekt, bestellung: eintrag, einrichtung: s.termine?.einrichtung, danke, kundenansicht: s.daten.kundenansicht === true, modell: s.modell, meldung: zustand === 'ordered' && meldung ? meldung.text : '', // die Meldung gilt nur, solange die Bestellung noch offen ist }, { weiter: (fall) => ladeNeu(s, { bestellformular: fall?.bestellen === true, meldung: fall?.meldung ? { text: fall.meldung } : null }) }) } zeigeReiter(s, { projekt, aktuell, zustand, kopf, meldung: formular && meldung ? { text: meldung.text, art: 'ok' } : null }) } /* ---------------------------------------------------- Vorlage wählen --- */ function zeigeVorlagen(s, { projekt, aktuell }) { const fehler = h('p', { class: 'tm-fehler', role: 'alert' }) const start = h('button', { type: 'button', class: 'kb-btn-primary', disabled: true }, 'Mit dieser Vorlage starten') let gewaehlt = null const karten = (s.daten.vorlagen || []).map((v) => { const radio = h('input', { type: 'radio', name: 'termine-vorlage', value: v.key }) radio.addEventListener('change', () => { gewaehlt = v; start.disabled = false; fehler.textContent = '' }) return h('label', { class: 'tm-vorlage' }, radio, h('span', null, h('strong', null, v.name), h('small', null, v.beschreibung))) }) start.addEventListener('click', async () => { if (!gewaehlt) return start.disabled = true fehler.textContent = '' try { await request('/api/termine/einstellungen/vorlage', { method: 'POST', body: { projectId: projekt.projectId, vorlage: gewaehlt.key } }) const daten = await ladeDaten(projekt.projectId) if (!aktuell()) return s.daten = daten zeigeReiter(s, { projekt, aktuell }) zeigeMeldung(s, `Die Vorlage „${gewaehlt.name}“ ist angelegt. Alles darin ist ein Vorschlag, den du anpassen kannst.`) } catch (err) { fehler.textContent = err.message start.disabled = false } }) s.buehne.replaceChildren(h('div', { class: 'kb-panel tm-vorlagen' }, h('h3', null, 'Womit möchtest du starten?'), h('p', null, 'Such dir die Vorlage aus, die am besten zu deinem Betrieb passt. Die Vorlage legt Leistungen, Zeiten und Regeln als Vorschlag an. Du kannst danach alles ändern.'), h('fieldset', { class: 'tm-vorlagen-liste' }, h('legend', { class: 'tm-sr' }, 'Vorlage'), karten), fehler, h('div', { class: 'abo-actions' }, start))) } /* -------------------------------------------------------- Reiter-Gerüst --- */ /** key, Beschriftung, Funktion (panel, ctx). */ const REITER = [ { key: 'termine', label: 'Termine', render: renderTermine }, { key: 'leistungen', label: 'Leistungen', render: renderLeistungen }, { key: 'team', label: 'Team & Plätze', render: renderTeam }, { key: 'einstellungen', label: 'Einstellungen', render: renderEinstellungen }, ] /** * Meldung ueber der Reiterleiste: art 'ok' (Standard) oder 'fehler'; leerer Text blendet sie aus. Die Meldung ist die einzige Live-Region * der Ansicht. Sie erscheint dort, wo man hinschaut: liegt sie ausserhalb des Bildes, wird sie hingescrollt (block: 'nearest'), eine * Fehlermeldung bekommt zusaetzlich den Fokus. */ function zeigeMeldung(s, text, art = 'ok') { const el = s.meldungEl if (!el) return el.textContent = text || '' el.className = `tm-meldung tm-meldung-${art}` el.setAttribute('role', art === 'fehler' ? 'alert' : 'status') el.setAttribute('aria-live', art === 'fehler' ? 'assertive' : 'polite') if (!text) return if (art === 'fehler') el.focus({ preventScroll: true }) el.scrollIntoView({ block: 'nearest' }) } /** * Kontext fuer die Module, die in der Huelle laufen (die Reiter, der Fragebogen): Zustand und die Pruefung "bin ich noch aktuell?" kommen nur hierher. * hinweise() zeichnet die Hinweisleiste ueber den Reitern neu (der Fragebogen hat keine), zumReiter(key) wechselt den Reiter; beide duerfen fehlen. * nurLesen und daten sind die der Huelle im Moment des Aufrufs; neuLaden() holt die Daten neu und ersetzt sie in s und im Kontext. */ export function baueCtx(s, { projekt, aktuell, hinweise = () => {}, zumReiter = () => {} }) { const projectId = projekt.projectId const ctx = { s, projectId, daten: s.daten, nurLesen: s.nurLesen, auswahl, aktuell, einschalten: s.einschalten, // Ablauf des Einschaltens im Abo-Modell (Init-Option der Hülle), der Fragebogen mountet ihn im letzten Schritt fertig: () => ladeNeu(s), // nach dem Einschalten: Ansicht neu zeichnen api: { get: (pfad, query = {}) => request(`/api/termine${pfad}?${new URLSearchParams({ projectId, ...query })}`), send: (method, pfad, body = {}) => (method === 'DELETE' ? request(`/api/termine${pfad}?${new URLSearchParams({ projectId })}`, { method }) : request(`/api/termine${pfad}`, { method, body: { projectId, ...body } })), }, // eine Meldung, die erst nach dem Wechsel der Website oder der Ansicht eintrifft, gehoert nicht mehr auf diese Seite meldung: (text, art) => { if (aktuell()) zeigeMeldung(s, text, art) }, hinweise: () => { if (aktuell()) hinweise() }, zumReiter: (key) => zumReiter(key), neuLaden: async () => { const daten = await ladeDaten(projectId) if (aktuell()) { s.daten = daten ctx.daten = daten hinweise() } return daten }, } return ctx } /** * Die Reiter (Hinweisleiste, Meldung, Reiterleiste, Panel). zustand steuert, was dazugehoert: disabled hat den Banner der Abschaltung, * active den Hinweis auf fehlende Pflichtangaben, ordered und setup keinen der beiden und keinen Reiter „Termine“ (der Server antwortet * dort mit 409). kopf(ctx) liefert ein Element, das ueber der Meldung steht (die Statuskarte), meldung ({ text, art }) wird nach dem * ersten Zeichnen angezeigt. Ohne Einstellung gibt es keine Reiter, dann bleibt es beim Kopf. */ function zeigeReiter(s, { projekt, aktuell, zustand = s.nurLesen ? 'disabled' : 'active', kopf = null, meldung = null }) { const projectId = projekt.projectId const nurLesen = s.nurLesen s.meldungEl = neueMeldung() const ctx = baueCtx(s, { projekt, aktuell, hinweise: zeichneHinweise, zumReiter: waehleReiter }) const reiterListe = istBestellt(zustand) ? REITER.filter((r) => r.key !== 'termine') : REITER const kopfEl = kopf ? kopf(ctx) : null // Hinweisleiste: abgeschaltet, oder Pflichtangaben fuer Gaeste fehlen (GET /einstellungen: fehlt). Sie folgt s.daten. const hinweisBox = h('div', { class: 'tm-hinweise' }) function zeichneHinweise() { const hinweise = [] if (zustand === 'disabled') { hinweise.push(hinweisAbgeschaltet(projectId, s.daten.abgeschaltet)) } else if (!nurLesen && (s.daten.fehlt || []).length) { hinweise.push(h('div', { class: 'tm-box tm-box-warn', role: 'note' }, h('span', null, `Noch nicht buchbar: Es fehlt ${fehltText(s.daten.fehlt) || 'eine Angabe zu deinem Betrieb'}. Solange das fehlt, können Gäste über deine Website nicht buchen.`), h('button', { type: 'button', class: 'kb-btn-outline', onclick: () => waehleReiter('einstellungen') }, 'Zu den Einstellungen'))) } hinweisBox.replaceChildren(...hinweise) } zeichneHinweise() if (!s.daten.einstellung) { s.buehne.replaceChildren(...[hinweisBox, kopfEl, s.meldungEl].filter(Boolean)) if (meldung) zeigeMeldung(s, meldung.text, meldung.art) return } const abo = istAbo(s) // Abo-Modell: keine eigene Leiste, die Etage zeichnet sie (kn:reiter) const panel = h('div', { class: 'tm-panel', role: 'tabpanel', id: 'tm-panel', tabindex: '0' }) let aktiv = null // Schluessel des gezeigten Reiters const knoepfe = abo ? [] : reiterListe.map((r) => h('button', { type: 'button', role: 'tab', class: 'tm-reiter', id: `tm-tab-${r.key}`, 'aria-controls': 'tm-panel', 'data-reiter': r.key, onclick: () => waehleReiter(r.key), }, r.label)) const leiste = abo ? null : h('div', { class: 'tm-reiterleiste', role: 'tablist', 'aria-label': 'Bereiche des Terminplaners' }, knoepfe) leiste?.addEventListener('keydown', (ev) => { const i = reiterListe.findIndex((r) => r.key === aktiv) const ziel = { ArrowRight: (i + 1) % reiterListe.length, ArrowLeft: (i + reiterListe.length - 1) % reiterListe.length, Home: 0, End: reiterListe.length - 1 }[ev.key] if (ziel === undefined) return ev.preventDefault() waehleReiter(reiterListe[ziel].key) knoepfe[ziel].focus() }) function waehleReiter(key, { merken = true } = {}) { // ein Reiter, den es in diesem Zustand nicht gibt (Termine in ordered und setup), wird nicht gemerkt: die Auswahl gilt fuer die naechste Website weiter const reiter = reiterListe.find((r) => r.key === key) || reiterListe[0] if (merken && reiter.key === key) auswahl.reiter = reiter.key aktiv = reiter.key for (const k of knoepfe) { const an = k.dataset.reiter === reiter.key k.setAttribute('aria-selected', String(an)) k.tabIndex = an ? 0 : -1 } if (abo) { panel.setAttribute('aria-label', reiter.label) meldeReiter(s.root, { eintraege: reiterEintraege('termine', reiterListe), aktiv: reiter.key, bei: (neu) => waehleReiter(neu) }) } else panel.setAttribute('aria-labelledby', `tm-tab-${reiter.key}`) zeigeMeldung(s, '') panel.replaceChildren() Promise.resolve() .then(() => reiter.render(panel, ctx)) .catch((err) => { if (aktuell()) panel.replaceChildren(fehlerKarte(err.message, () => waehleReiter(reiter.key))) }) } s.buehne.replaceChildren(...[hinweisBox, kopfEl, s.meldungEl, leiste, panel].filter(Boolean)) // replaceChildren(null) schriebe „null“ in die Seite waehleReiter(auswahl.reiter, { merken: false }) if (meldung) zeigeMeldung(s, meldung.text, meldung.art) }