A site can have further languages (Settings → Languages). A page is built once; each language adds only its texts, keyed by the stable block IDs, plus its own address under /en/… (ADR 111, document schema v27, migration 20261009000100 for page_translation). - Editor: DE | EN switch, a list of all texts with original, state (missing, translated, original changed) and filter; structure stays with the main language. Undo, drafts, publishing and revisions cover all languages at once. - Website: /en and translated slugs, lang, canonical, hreflang with x-default, og:locale, sitemap; links to translated pages follow the language; a language switch block. - Header, footer and linked sections translate the same way; menu entries carry labels per language; fixed template texts come from a small dictionary. - Forms: labels, help, options, button and success message per language; messages and the step script speak the language, the submission and the email stay in the main language. - Posts, archives, category names, consent text and the 404 page follow the language. - Single-language sites see none of it and never touch the new table. The forms administration becomes an overview with one row per form and its own edit page; fields are one line each with details collapsed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
205 lines
9.7 KiB
JavaScript
205 lines
9.7 KiB
JavaScript
/**
|
|
* 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);
|
|
})();
|