Files
KantSites/public/assets/form-steps.js
T
kantarciandClaude Opus 5.5 e15b2517b1 Translate pages, areas, menus, forms and posts field by field instead of rebuilding them
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>
2026-10-09 16:56:52 +02:00

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);
})();