Forms gained steps, conditions, image choices, number fields with units and a summary step; the DEEG price finder uses all of it. Plugins are manifest plus Twig: blocks, settings, containers, routes, presets and theme packs, switched per installation. The review backlog is done: archive pages render through the normal page pipeline, forms and post lists work inside areas and sections, repeated sections get their own ids, breakpoints and colours come from the palette, backup and update work on MySQL and lock each other out. The task worker no longer shortens a process that has no time limit: set_time_limit() sets the limit instead of extending it, which turned an unlimited CLI run into eighty seconds and killed the full test suite far away from the scheduler. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
199 lines
9.1 KiB
JavaScript
199 lines
9.1 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 => {
|
|
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', 'Zusammenfassung');
|
|
title.tabIndex = -1;
|
|
summary.append(title, el('p', 'form-step-intro', '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', '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() || `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', 'Zurück');
|
|
back.type = 'button';
|
|
const next = el('button', 'button cms-button form-step-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 'Ja';
|
|
if (input.tagName === 'SELECT') return input.selectedOptions[0]?.textContent || 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() || `Schritt ${index + 1}`));
|
|
const change = el('button', 'form-summary-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', '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);
|
|
})();
|