/** * Formulare mit Schritten, Bedingungen und Zusammenfassung. * * Ohne dieses Skript steht das Formular vollständig da: jeder Schritt ein Abschnitt, jedes Feld * sichtbar, der Server prüft beim Absenden, welche Felder galten. Das Skript ändert nur die * Darstellung: ein Schritt nach dem anderen, Felder hinter einer Bedingung nur bei passender * Antwort, und vor dem Absenden eine Zusammenfassung der Eingaben. Es wird nur auf Seiten * geladen, deren Formular eines davon braucht. */ (() => { const el = (tag, className, text) => { const node = document.createElement(tag); if (className) node.className = className; if (text !== undefined) node.textContent = text; return node; }; const setup = form => { // Die Texte des Skripts. Auf einer übersetzten Seite bringt das Formular sie mit (ADR 111). let texts = {}; try { texts = JSON.parse(form.dataset.texts || '{}'); } catch { texts = {}; } const t = (key, fallback) => texts[key] || fallback; const rules = [...form.querySelectorAll('[data-show-if]')]; const steps = [...form.querySelectorAll('[data-step]')]; const submit = form.querySelector('.cms-button[type="submit"]'); const wantsSummary = form.hasAttribute('data-summary') && steps.length > 0 && submit; if (!rules.length && !steps.length) return; // Die gewählten Werte eines Feldes - nur, solange es gilt. Ein Feld in einem ausgeblendeten // Schritt zählt nicht, auch wenn dort noch etwas angekreuzt ist. const valuesOf = name => [...form.querySelectorAll(`[name="${name}"], [name="${name}[]"]`)] .filter(input => !input.closest('[data-inactive]')) .filter(input => input.type === 'checkbox' || input.type === 'radio' ? input.checked : true) .map(input => input.value) .filter(value => value !== ''); // Bedingungen in Dokumentreihenfolge: ein Feld hängt nur von früheren Feldern ab, also steht // deren Zustand schon fest, wenn es an der Reihe ist. const apply = () => { for (const rule of rules) { const wanted = rule.dataset.showValues.split('|'); const have = valuesOf(rule.dataset.showIf); rule.toggleAttribute('data-inactive', !wanted.some(value => have.includes(value))); } // Was nicht gilt, wird nicht geprüft und nicht gesendet: deaktiviert, nicht nur versteckt. for (const input of form.querySelectorAll('input, select, textarea')) { if (input.type === 'hidden') continue; input.disabled = input.closest('[data-inactive]') !== null; } }; form.addEventListener('change', () => { apply(); if (steps.length) refreshNav(); }); apply(); if (!steps.length) return; // Die Zusammenfassung ist ein eigener, letzter Schritt - erst hier steht der Absendeknopf. let summary = null; if (wantsSummary) { summary = el('div', 'form-step form-summary'); summary.setAttribute('data-step', ''); const title = el('p', 'form-step-title', t('summary', 'Zusammenfassung')); title.tabIndex = -1; summary.append(title, el('p', 'form-step-intro', t('summaryIntro', 'Bitte prüfen Sie Ihre Angaben. Über „Ändern“ kommen Sie zum jeweiligen Schritt zurück.'))); summary.append(el('div', 'form-summary-body')); steps[steps.length - 1].after(summary); steps.push(summary); } const progress = el('ol', 'form-progress'); progress.setAttribute('aria-label', t('steps', 'Schritte des Formulars')); steps.forEach((step, index) => { const item = el('li', 'form-progress-step'); item.append(el('span', 'form-progress-number', String(index + 1)), el('span', 'form-progress-title', step.querySelector('.form-step-title')?.textContent.trim() || `${t('step', 'Schritt')} ${index + 1}`)); progress.append(item); }); form.insertBefore(progress, steps[0]); const nav = el('div', 'form-step-nav'); const back = el('button', 'button cms-button form-step-back', t('back', 'Zurück')); back.type = 'button'; const next = el('button', 'button cms-button form-step-next', t('next', 'Weiter')); next.type = 'button'; nav.append(back, next); (submit || form).before(nav); if (submit) nav.append(submit); const visible = () => steps.map((step, index) => step.hasAttribute('data-inactive') ? -1 : index).filter(index => index >= 0); let current = 0; const labelOf = field => field.dataset.label || ''; const valueOf = field => { const inputs = [...field.querySelectorAll('input, select, textarea')].filter(input => !input.disabled); const picked = inputs.filter(input => input.type === 'checkbox' || input.type === 'radio' ? input.checked : input.value.trim() !== ''); if (!picked.length) return ''; const unit = field.querySelector('.form-unit')?.textContent || ''; return picked.map(input => { if (input.type === 'checkbox' && input.value === 'ja' && field.querySelector('.check-row label')) return t('yes', 'Ja'); if (input.tagName === 'SELECT') return input.selectedOptions[0]?.textContent || input.value; // Angezeigt wird die Beschriftung der Option - auf einer übersetzten Seite die übersetzte. if (input.type === 'checkbox' || input.type === 'radio') return (input.labels?.[0]?.querySelector('.form-choice-text') ?? input.labels?.[0])?.textContent.trim() || input.value; return input.value + (unit ? ` ${unit}` : ''); }).join(', '); }; const renderSummary = () => { const body = summary.querySelector('.form-summary-body'); body.replaceChildren(); steps.forEach((step, index) => { if (step === summary || step.hasAttribute('data-inactive')) return; const block = el('section', 'form-summary-step'); const head = el('div', 'form-summary-head'); head.append(el('h4', 'form-summary-title', step.querySelector('.form-step-title')?.textContent.trim() || `${t('step', 'Schritt')} ${index + 1}`)); const change = el('button', 'form-summary-change', t('change', 'Ändern')); change.type = 'button'; change.setAttribute('aria-label', `${head.firstChild.textContent} ändern`); change.addEventListener('click', () => show(index)); head.append(change); block.append(head); const list = el('dl', 'form-summary-list'); for (const field of step.querySelectorAll('[data-field]')) { if (field.closest('[data-inactive]')) continue; const value = valueOf(field); list.append(el('dt', '', labelOf(field)), el('dd', value ? '' : 'form-summary-empty', value || '—')); } block.append(list); body.append(block); }); }; const refreshNav = () => { const order = visible(); const position = order.indexOf(current); const last = position === order.length - 1; back.hidden = position <= 0; next.hidden = last; if (submit) submit.hidden = !last; progress.querySelectorAll('li').forEach((item, index) => { item.hidden = steps[index].hasAttribute('data-inactive'); item.classList.toggle('is-done', order.indexOf(index) < position); if (index === current) item.setAttribute('aria-current', 'step'); else item.removeAttribute('aria-current'); }); }; const show = (index, focus = true) => { current = index; steps.forEach((step, i) => { step.hidden = i !== index; }); if (summary && steps[index] === summary) renderSummary(); refreshNav(); if (focus) { steps[index].querySelector('.form-step-title')?.focus({ preventScroll: true }); if (progress.getBoundingClientRect().top < 0) progress.scrollIntoView({ block: 'start' }); } }; // Erst der Schritt selbst prüft seine Felder: der Browser meldet das erste ungültige Feld, // eine Pflicht-Mehrfachauswahl ohne Kreuz meldet die Gruppe. const valid = step => { for (const group of step.querySelectorAll('[data-required]')) { if (group.closest('[data-inactive]')) continue; const note = group.querySelector('.field-error[data-live]'); if (!group.querySelector('input:checked')) { if (!note) group.append(Object.assign(el('p', 'field-error', t('chooseOne', 'Bitte wähle mindestens eine Option.')), { dataset: { live: '' } })); else note.hidden = false; group.classList.add('has-error'); group.querySelector('input')?.focus(); return false; } if (note) note.remove(); group.classList.remove('has-error'); } for (const input of step.querySelectorAll('input, select, textarea')) { if (input.disabled || input.checkValidity()) continue; input.reportValidity(); return false; } return true; }; next.addEventListener('click', () => { if (!valid(steps[current])) return; const order = visible(); const following = order[order.indexOf(current) + 1]; if (following !== undefined) show(following); }); back.addEventListener('click', () => { const order = visible(); const previous = order[order.indexOf(current) - 1]; if (previous !== undefined) show(previous); }); // Enter in einem Feld geht zum nächsten Schritt, statt das Formular vorzeitig abzuschicken. form.addEventListener('keydown', event => { if (event.key !== 'Enter' || event.target.tagName === 'TEXTAREA' || event.target.tagName === 'BUTTON') return; if (!next.hidden) { event.preventDefault(); next.click(); } }); // Nach einer Ablehnung durch den Server: der erste Schritt mit einem Fehler, sonst der erste. const failed = steps.findIndex(step => step.querySelector('.has-error')); const order = visible(); show(failed >= 0 ? failed : (order[0] ?? 0), false); form.classList.add('form-steps-ready'); }; document.querySelectorAll('form.block-form').forEach(setup); })();