Files
survey-bundle/public/js/survey-frontend.js
T
admin.juergenandClaude Fable 5 6077832efc Honor the mandatory flag with explicit no-answer options
Symfony's required default forced an answer to every question no matter
what the mandatory checkbox said. Optional choice questions now offer a
dedicated 'Keine Antwort' option, optional range and text questions a
'Keine Angaben machen' checkbox (preselected for ranges, because a slider
always submits a value); the side that is not chosen gets dimmed but stays
clickable. Mandatory ranges start their value display at '?' and reject
the submit until the slider was actually used, so the browser's midpoint
default no longer leaks into the results.

Empty answers are stored as a deliberate 'no answer', kept out of counts,
averages and charts, and reported separately on the results page and in
the PDF export. Also gives the reader textarea its full width and field
styling, adds grid overflow guards for unbreakable content and styles the
form error box.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 14:36:38 +02:00

347 lines
12 KiB
JavaScript

document.addEventListener('DOMContentLoaded', function () {
var surveyEditor = document.querySelector('[data-survey-handorgel]');
var resetEditorViewport = function () {
if (!surveyEditor) {
return;
}
window.scrollTo(0, 0);
};
if (surveyEditor && window.history && 'scrollRestoration' in window.history) {
window.history.scrollRestoration = 'manual';
}
resetEditorViewport();
window.addEventListener('load', function () {
window.setTimeout(resetEditorViewport, 0);
}, { once: true });
document.querySelectorAll('[data-survey-range]').forEach(function (input) {
var targetId = input.getAttribute('data-survey-range-target');
var output = targetId ? document.getElementById(targetId) : null;
if (!output) {
return;
}
var form = input.closest('form');
var touchedField = form ? form.querySelector('[data-survey-range-touched-field]') : null;
var update = function () {
output.textContent = input.value;
};
input.addEventListener('input', function () {
// Erst eine bewusste Bedienung macht aus dem "?" einen Wert und
// markiert die Pflicht-Bewertung serverseitig als beantwortet.
input.removeAttribute('data-survey-range-untouched');
if (touchedField) {
touchedField.value = '1';
}
update();
});
// Ohne bestätigten Wert bleibt das "?" aus dem Template stehen.
if (!input.hasAttribute('data-survey-range-untouched')) {
update();
}
});
// "Keine Antwort" / "Keine Angaben machen" bei optionalen Fragen: Die jeweils
// nicht gewählte Seite wird gedimmt (bleibt aber klickbar, damit der Wechsel
// jederzeit möglich ist). Serverseitig gewinnt das "Keine Angabe"-Feld.
document.querySelectorAll('[data-survey-answer-group]').forEach(function (group) {
var noAnswerInput = group.querySelector('[data-survey-no-answer]');
var answerZone = group.querySelector('[data-survey-answer-zone]');
var noAnswerZone = group.querySelector('[data-survey-no-answer-zone]');
if (!noAnswerInput || !answerZone || !noAnswerZone) {
return;
}
var realInputs = Array.prototype.filter.call(
answerZone.querySelectorAll('input, textarea, select'),
function (input) { return input !== noAnswerInput; }
);
var hasRealAnswer = function () {
return realInputs.some(function (input) {
if (input.type === 'radio' || input.type === 'checkbox') {
return input.checked;
}
if (input.type === 'range') {
// Der Slider hat technisch immer einen Wert - er zählt als
// Antwort, sobald "Keine Angaben machen" abgewählt ist.
return !noAnswerInput.checked;
}
return String(input.value || '').trim() !== '';
});
};
var sync = function () {
answerZone.classList.toggle('survey-dimmed', noAnswerInput.checked);
noAnswerZone.classList.toggle('survey-dimmed', !noAnswerInput.checked && hasRealAnswer());
// Bewertungsfragen: Solange "Keine Angaben machen" gewählt ist, zeigt
// die Wertanzeige "?" statt des technischen Slider-Werts.
var rangeInput = answerZone.querySelector('input[type=range]');
if (rangeInput) {
var rangeTargetId = rangeInput.getAttribute('data-survey-range-target');
var rangeOutput = rangeTargetId ? document.getElementById(rangeTargetId) : null;
if (rangeOutput) {
rangeOutput.textContent = noAnswerInput.checked ? '?' : rangeInput.value;
}
}
};
noAnswerInput.addEventListener('change', function () {
if (noAnswerInput.checked) {
realInputs.forEach(function (input) {
if (input.type === 'radio' || input.type === 'checkbox') {
input.checked = false;
}
});
}
sync();
});
var chooseAnswer = function () {
if (noAnswerInput.checked) {
noAnswerInput.checked = false;
}
sync();
};
realInputs.forEach(function (input) {
input.addEventListener('change', chooseAnswer);
input.addEventListener('input', chooseAnswer);
if (input.type === 'range' || input.tagName === 'TEXTAREA') {
input.addEventListener('focus', chooseAnswer);
}
});
// Ein Klick irgendwo in den gedimmten Antwortbereich (z. B. auf die
// Wertanzeige des Sliders) reaktiviert ihn ebenfalls.
answerZone.addEventListener('pointerdown', function () {
if (noAnswerInput.checked) {
chooseAnswer();
}
});
sync();
});
document.querySelectorAll('[data-survey-question-type]').forEach(function (select) {
var wrapper = select.closest('[data-survey-question-editor]');
if (!wrapper) {
return;
}
var toggle = function () {
var showRange = select.value === 'range';
var showYesNoMaybe = select.value === 'yes_no_maybe';
var showChoice = select.value === 'choice';
var isSection = select.value === 'section';
wrapper.querySelectorAll('.question-only-field').forEach(function (element) {
element.style.display = isSection ? 'none' : '';
});
wrapper.querySelectorAll('.section-only-field').forEach(function (element) {
element.style.display = isSection ? '' : 'none';
});
wrapper.querySelectorAll('.range-config-field').forEach(function (element) {
element.style.display = showRange ? '' : 'none';
});
wrapper.querySelectorAll('.yes-no-config-field').forEach(function (element) {
element.style.display = showYesNoMaybe ? '' : 'none';
});
wrapper.querySelectorAll('.choice-config-field').forEach(function (element) {
element.style.display = showChoice ? '' : 'none';
});
};
select.addEventListener('change', toggle);
toggle();
});
document.querySelectorAll('[data-copy-text]').forEach(function (button) {
var originalLabel = button.getAttribute('data-copy-label') || button.textContent;
var successLabel = button.getAttribute('data-copy-success') || originalLabel;
var copyText = function (value) {
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(value);
}
return new Promise(function (resolve, reject) {
var helper = document.createElement('textarea');
helper.value = value;
helper.setAttribute('readonly', 'readonly');
helper.style.position = 'absolute';
helper.style.left = '-9999px';
document.body.appendChild(helper);
helper.select();
try {
document.execCommand('copy');
document.body.removeChild(helper);
resolve();
} catch (error) {
document.body.removeChild(helper);
reject(error);
}
});
};
button.addEventListener('click', function () {
var text = button.getAttribute('data-copy-text') || '';
if (!text) {
return;
}
copyText(text).then(function () {
button.textContent = successLabel;
window.setTimeout(function () {
button.textContent = originalLabel;
}, 1800);
}).catch(function () {
button.textContent = originalLabel;
});
});
});
var surveyDirtyChecks = [];
var surveyIsSubmitting = false;
document.querySelectorAll('[data-survey-dirty-form]').forEach(function (form) {
var formId = form.getAttribute('id') || '';
var hint = form.querySelector('[data-survey-dirty-hint]');
if (!hint && formId) {
hint = document.querySelector('[data-survey-dirty-hint-for="' + formId + '"]');
}
var serializeForm = function () {
var entries = [];
var formData = new FormData(form);
formData.forEach(function (value, key) {
entries.push(key + '=' + String(value));
});
return entries.join('&');
};
var initialState = serializeForm();
var isDirty = function () {
return serializeForm() !== initialState;
};
surveyDirtyChecks.push(isDirty);
var updateDirtyState = function () {
if (hint) {
hint.hidden = !isDirty();
}
};
if (hint) {
hint.hidden = true;
}
form.addEventListener('input', updateDirtyState);
form.addEventListener('change', updateDirtyState);
form.addEventListener('reset', function () {
window.setTimeout(function () {
if (hint) {
hint.hidden = true;
}
}, 0);
});
});
if (surveyDirtyChecks.length > 0) {
// Ein bewusstes Absenden (Speichern oder eine Aktion) darf nicht warnen.
document.addEventListener('submit', function () {
surveyIsSubmitting = true;
}, true);
window.addEventListener('beforeunload', function (event) {
if (surveyIsSubmitting) {
return undefined;
}
var hasUnsavedChanges = surveyDirtyChecks.some(function (isDirty) {
return isDirty();
});
if (!hasUnsavedChanges) {
return undefined;
}
// Moderne Browser zeigen einen generischen Hinweis; der Text wird
// ignoriert, muss aber gesetzt werden, damit der Dialog erscheint.
event.preventDefault();
event.returnValue = '';
return '';
});
}
if (typeof handorgel === 'function') {
document.querySelectorAll('[data-survey-handorgel]').forEach(function (element) {
var accordion = new handorgel(element, {
collapsible: true,
multiSelectable: false,
initialOpenTransition: true,
initialOpenTransitionDelay: 0
});
if (accordion && Array.isArray(accordion.folds)) {
accordion.folds.forEach(function (fold) {
if (!fold) {
return;
}
var article = fold.header ? fold.header.closest('article') : null;
var shouldOpen = !!(article && article.hasAttribute('data-survey-open'));
if (shouldOpen && !fold.expanded) {
fold.open(false);
} else if (!shouldOpen && fold.expanded) {
fold.close();
}
});
var openArticle = element.querySelector('article[data-survey-open]');
if (openArticle && typeof openArticle.scrollIntoView === 'function') {
window.setTimeout(function () {
openArticle.scrollIntoView({ block: 'start', behavior: 'smooth' });
}, 50);
}
}
});
}
resetEditorViewport();
});