/* * Der Formulareditor in der Verwaltung. * * Ohne Skript ist die Seite vollständig bedienbar: Felder sind Karten mit Eingaben, ein Feld ohne * Beschriftung wird nicht gespeichert, die Vorschau zeigt den gespeicherten Stand. Das Skript * macht daraus einen Editor: Felder hinzufügen und entfernen, den technischen Namen aus der * Beschriftung ableiten, Optionen nur bei einer Auswahl zeigen, Farben aus der Palette wählen - * und eine Vorschau, die jeder Eingabe folgt, mit dem Standardbutton der Website. */ import { createColorControl } from './color-control.js'; import { buttonSample } from './button-design.js'; import { openMedia } from './media.js'; const root = document.querySelector('[data-form-schema]'); if (root) { const schema = JSON.parse(root.dataset.formSchema); const bilder = JSON.parse(root.dataset.formImages || '[]'); const defaults = { fieldBorderColor: schema.base.color, fieldBackgroundColor: '#ffffff', buttonColor: schema.buttons.backgroundColor, buttonInk: schema.buttons.color, }; const hex = value => /^#[0-9a-f]{6}/i.test(value || '') ? value.slice(0, 7) : schema.colors.find(color => `@${color.key}` === value)?.value || ''; // Dunkler beim Überfahren, so wie es die Seite selbst tut (82 % Farbe, Rest Schwarz). const darker = value => { const rgb = hex(value); if (!rgb) return value; return '#' + [1, 3, 5].map(index => Math.round(parseInt(rgb.slice(index, index + 2), 16) * 0.82).toString(16).padStart(2, '0')).join(''); }; const luminance = rgb => { const [r, g, b] = [1, 3, 5].map(index => parseInt(rgb.slice(index, index + 2), 16) / 255) .map(channel => channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4); return 0.2126 * r + 0.7152 * g + 0.0722 * b; }; const contrast = (back, front) => { if (!back || !front) return null; const [light, dark] = [luminance(back), luminance(front)].sort((a, b) => b - a); return (light + 0.05) / (dark + 0.05); }; const slug = text => text.toLowerCase() .replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss') .replace(/[^a-z0-9]+/g, '_').replace(/^[^a-z]+/, '').replace(/_+$/, '').slice(0, 32); const el = (tag, className, text) => { const node = document.createElement(tag); node.className = className; if (text !== undefined) node.textContent = text; return node; }; for (const form of root.querySelectorAll('[data-form-editor]')) { const list = form.querySelector('[data-field-list]'), template = form.querySelector('[data-field-template]'); const preview = form.querySelector('[data-form-preview]'), add = form.querySelector('[data-add-field]'); const buttonLabel = form.querySelector('[data-form-button-label]'); const colorInputs = {}; const cards = () => [...list.querySelectorAll('[data-field]')]; let next = cards().length; const renderPreview = () => { preview.replaceChildren(); preview.style.color = hex(schema.base.color) || '#203b34'; preview.style.background = hex(schema.base.backgroundColor) || '#ffffff'; preview.style.setProperty('--form-border', hex(colorInputs.fieldBorderColor.value) || 'currentColor'); preview.style.setProperty('--form-bg', hex(colorInputs.fieldBackgroundColor.value) || '#ffffff'); let shown = 0; for (const card of cards()) { const label = card.querySelector('[data-field-label]').value.trim(); if (!label) continue; shown++; const type = card.querySelector('[data-field-type]').value; const help = card.querySelector('[data-field-help]').value.trim(); if (type === 'step') { // Ein Schritt ist eine Zwischenüberschrift in der Vorschau, mit seiner Einleitung. const step = el('div', 'form-preview-step'); step.append(el('span', 'form-preview-step-title', label)); if (help) step.append(el('span', 'form-preview-help', help)); preview.append(step); continue; } const required = type === 'consent' || card.querySelector('[data-field-required]').checked; const showIf = card.querySelector('[data-field-showif]').value.trim(); const field = el('div', `form-preview-field${showIf ? ' form-preview-conditional' : ''}`); const options = () => card.querySelector('[data-field-options-input]').value.split(',').map(option => option.trim()).filter(Boolean); if (type === 'consent') { const check = el('span', 'form-preview-check'); check.append(el('span', 'form-preview-box'), document.createTextNode(`${label} *`)); field.append(check); } else if (type === 'radio' || type === 'checkboxes') { field.append(el('span', 'form-preview-label', label + (required ? ' *' : ''))); for (const option of options()) { const check = el('span', 'form-preview-check'); check.append(el('span', `form-preview-box${type === 'radio' ? ' form-preview-radio' : ''}`), document.createTextNode(option)); field.append(check); } } else { field.append(el('span', 'form-preview-label', label + (required ? ' *' : ''))); const unit = type === 'number' ? card.querySelector('[data-field-unit]').value.trim() : ''; field.append(el('span', `form-preview-input${type === 'textarea' ? ' form-preview-area' : ''}${type === 'select' ? ' form-preview-select' : ''}${type === 'number' ? ' form-preview-number' : ''}`, type === 'select' ? '— bitte wählen —' : unit)); } if (help) field.append(el('span', 'form-preview-help', help)); if (showIf) field.append(el('span', 'form-preview-help', `Nur wenn ${showIf} = ${card.querySelector('[data-field-showvalues]').value.split(',').map(v => v.trim()).filter(Boolean).join(' oder ') || '…'}`)); preview.append(field); } if (!shown) preview.append(el('p', 'form-preview-empty', 'Noch kein Feld mit Beschriftung.')); const values = { ...schema.buttons }; const fill = colorInputs.buttonColor.value, ink = colorInputs.buttonInk.value; if (fill) { values.backgroundColor = fill; values.hoverBackgroundColor = darker(fill); } if (ink) { values.color = ink; values.hoverColor = ink; } preview.append(buttonSample(buttonLabel.value.trim() || 'Absenden', values, schema)); // Der Kontrast der Buttonschrift auf der Buttonfarbe, nach WCAG: unter 4,5:1 ist der Text // fuer viele nicht mehr lesbar - das soll man sehen, bevor es auf der Website steht. const ratio = contrast(hex(values.backgroundColor), hex(values.color)); note.hidden = ratio === null; if (ratio !== null) { note.className = `contrast-note ${ratio >= 4.5 ? 'contrast-ok' : ratio >= 3 ? 'contrast-warn' : 'contrast-bad'}`; note.textContent = `Buttonschrift auf Buttonfarbe: Kontrast ${ratio.toFixed(1).replace('.', ',')}:1 – ${ratio >= 4.5 ? 'gut lesbar.' : ratio >= 3 ? 'nur für große Schrift ausreichend, für den Button zu wenig.' : 'zu wenig, der Text ist kaum lesbar.'}`; } }; const note = el('p', 'contrast-note'); note.hidden = true; note.setAttribute('role', 'status'); preview.after(note); // Statt Kennungen zu tippen: je Option ein Bild aus der Mediathek, gewählt im Dialog mit // Vorschau und Hochladen. Das Textfeld bleibt das gespeicherte Original und wird aus der // Auswahl geschrieben; ohne Skript tippt man hinein. const bildVon = new Map(bilder.map(bild => [bild.id, bild])); const renderImagePickers = card => { const area = card.querySelector('[data-field-images-input]'); const options = card.querySelector('[data-field-options-input]').value.split(',').map(o => o.trim()).filter(Boolean); let box = card.querySelector('[data-image-pickers]'); if (!box) { box = el('div', 'field-image-pickers'); box.dataset.imagePickers = ''; area.hidden = true; area.after(box); } if (box.dataset.signature === options.join('|')) return; const current = Object.fromEntries(area.value.split('\n').filter(l => l.includes('=')).map(l => l.split('=').map(x => x.trim()))); box.dataset.signature = options.join('|'); box.replaceChildren(); const write = () => { const map = {}; for (const row of box.querySelectorAll('[data-option]')) if (row.dataset.media) map[row.dataset.option] = row.dataset.media; area.value = Object.entries(map).map(([o, id]) => `${o} = ${id}`).join('\n'); renderPreview(); }; for (const option of options) { const row = el('div', 'field-image-picker'); row.dataset.option = option; const thumb = document.createElement('img'); thumb.className = 'field-image-picker-thumb'; thumb.alt = ''; thumb.width = 64; thumb.height = 48; const name = el('span', 'field-image-picker-name'); const choose = el('button', 'secondary', ''); choose.type = 'button'; const remove = el('button', 'text-button danger-text', 'Entfernen'); remove.type = 'button'; remove.setAttribute('aria-label', `Bild für „${option}“ entfernen`); const show = id => { row.dataset.media = id || ''; const bild = id ? bildVon.get(id) : null; thumb.hidden = !bild; if (bild) thumb.src = bild.src; else thumb.removeAttribute('src'); name.textContent = id ? (bild?.title || 'Bild gewählt') : 'Kein Bild'; choose.textContent = id ? 'Anderes Bild' : 'Bild wählen'; choose.setAttribute('aria-label', `${id ? 'Anderes Bild' : 'Bild'} für „${option}“ wählen`); remove.hidden = !id; }; choose.addEventListener('click', () => openMedia({ needsAlt: false, onChoose: ([item]) => { if (!item) return; bildVon.set(item.id, { id: item.id, title: item.title, src: item.variants[0].src }); show(item.id); write(); } })); remove.addEventListener('click', () => { show(''); write(); choose.focus(); }); show(current[option] || ''); row.append(el('span', 'field-image-picker-option', option), thumb, name, choose, remove); box.append(row); } }; /* * Auswahl statt Feldnamen: die Bedingung eines Feldes nennt ein Auswahlfeld weiter oben und * dessen Optionen, die Antwortadresse ein E-Mail-Feld. Die Textfelder bleiben das * gespeicherte Original, versteckt, und werden aus der Auswahl geschrieben. */ const fieldsBefore = card => { const list = []; for (const other of cards()) { if (other === card) break; const name = other.querySelector('[data-field-name]').value.trim(); const type = other.querySelector('[data-field-type]').value; if (name && ['select', 'radio', 'checkboxes'].includes(type)) { list.push({ name, label: other.querySelector('[data-field-label]').value.trim() || name, options: other.querySelector('[data-field-options-input]').value.split(',').map(o => o.trim()).filter(Boolean) }); } } return list; }; const renderCondition = card => { const fieldInput = card.querySelector('[data-field-showif]'); const valuesInput = card.querySelector('[data-field-showvalues]'); const candidates = fieldsBefore(card); const signature = JSON.stringify([candidates, fieldInput.value, valuesInput.value]); let box = card.querySelector('[data-condition-picker]'); if (!box) { box = el('div', 'field-condition-picker'); box.dataset.conditionPicker = ''; fieldInput.closest('div').hidden = true; valuesInput.closest('div').hidden = true; fieldInput.closest('[data-field-condition]').prepend(box); const help = box.parentElement.querySelector(':scope > .field-help'); if (help) help.textContent = 'Zur Wahl stehen Auswahlfelder weiter oben. Bei einem Schritt gilt die Bedingung für alle seine Felder.'; } if (box.dataset.signature === signature) return; box.dataset.signature = signature; const focused = box.contains(document.activeElement) ? document.activeElement.dataset.key : null; box.replaceChildren(); const id = `${fieldInput.id}-select`; const label = el('label', '', 'Nur zeigen, wenn'); label.htmlFor = id; const select = document.createElement('select'); select.id = id; select.dataset.key = 'field'; select.append(new Option('Immer zeigen', '')); for (const candidate of candidates) select.append(new Option(candidate.label, candidate.name)); if (fieldInput.value && !candidates.some(c => c.name === fieldInput.value)) select.append(new Option(`${fieldInput.value} (nicht mehr weiter oben)`, fieldInput.value)); select.value = fieldInput.value; select.addEventListener('change', () => { fieldInput.value = select.value; valuesInput.value = ''; refresh(); }); box.append(label, select); const chosen = candidates.find(c => c.name === fieldInput.value); if (chosen) { const set = document.createElement('fieldset'); set.className = 'field-condition-values'; set.append(el('legend', '', '… eine dieser Antworten gewählt ist')); const current = valuesInput.value.split(',').map(v => v.trim()).filter(Boolean); chosen.options.forEach((option, index) => { const check = el('label', 'check-label'); const box2 = document.createElement('input'); box2.type = 'checkbox'; box2.value = option; box2.checked = current.includes(option); box2.dataset.key = `value-${index}`; box2.addEventListener('change', () => { valuesInput.value = [...set.querySelectorAll('input:checked')].map(input => input.value).join(', '); refresh(); }); check.append(box2, document.createTextNode(` ${option}`)); set.append(check); }); box.append(set); } if (focused) box.querySelector(`[data-key="${focused}"]`)?.focus(); }; const reply = form.querySelector('[name="replyToField"]'); const renderReply = () => { if (!reply) return; const emails = cards().map(card => ({ name: card.querySelector('[data-field-name]').value.trim(), label: card.querySelector('[data-field-label]').value.trim(), type: card.querySelector('[data-field-type]').value })) .filter(field => field.name && field.type === 'email'); let select = form.querySelector('[data-reply-picker]'); if (!select) { select = document.createElement('select'); select.dataset.replyPicker = ''; select.id = reply.id; reply.id += '-value'; reply.type = 'hidden'; reply.after(select); select.addEventListener('change', () => { reply.value = select.value; }); } const signature = JSON.stringify([emails, reply.value]); if (select.dataset.signature === signature) return; select.dataset.signature = signature; select.replaceChildren(new Option('Keine – Antworten gehen an den Absender der E-Mail', '')); for (const field of emails) select.append(new Option(field.label || field.name, field.name)); if (reply.value && !emails.some(field => field.name === reply.value)) select.append(new Option(`${reply.value} (kein E-Mail-Feld mehr)`, reply.value)); select.value = reply.value; const help = reply.parentElement.querySelector('.field-help'); if (help) help.textContent = 'Ein E-Mail-Feld des Formulars. Dann lässt sich auf die Nachricht direkt antworten.'; }; const refresh = () => { cards().forEach((card, index) => { card.querySelector('[data-field-index]').textContent = String(index + 1); const type = card.querySelector('[data-field-type]').value; card.querySelector('[data-field-options]').hidden = !['select', 'radio', 'checkboxes'].includes(type); card.querySelector('[data-field-images]').hidden = !['radio', 'checkboxes'].includes(type); if (['radio', 'checkboxes'].includes(type)) renderImagePickers(card); renderCondition(card); card.querySelector('[data-field-number]').hidden = type !== 'number'; const remove = card.querySelector('[data-remove-field]'); remove.hidden = false; remove.setAttribute('aria-label', `Feld ${index + 1} entfernen`); const required = card.querySelector('[data-field-required]'); // Eine Einwilligung ist immer Pflicht - das Kästchen sagt es, statt es zu verstecken. if (type === 'consent') { required.checked = true; required.disabled = true; } else if (type === 'step') { required.checked = false; required.disabled = true; } else required.disabled = false; }); renderReply(); renderPreview(); }; // Die leere Karte für ein neues Feld braucht es nur, solange es kein Feld gibt: mit Skript // kommt jedes weitere über den Knopf. const empty = cards().filter(card => card.classList.contains('field-card-new')); if (cards().length > empty.length) empty.forEach(card => card.remove()); add.hidden = false; add.addEventListener('click', () => { list.insertAdjacentHTML('beforeend', template.innerHTML.replaceAll('__n__', String(next++))); const card = list.lastElementChild; card.classList.remove('field-card-new'); refresh(); card.querySelector('[data-field-label]').focus(); }); list.addEventListener('click', event => { const button = event.target.closest('[data-remove-field]'); if (!button) return; button.closest('[data-field]').remove(); refresh(); add.focus(); }); list.addEventListener('input', event => { const card = event.target.closest('[data-field]'); if (event.target.matches('[data-field-label]')) { // Der technische Name folgt der Beschriftung, bis jemand ihn selbst schreibt. const name = card.querySelector('[data-field-name]'); if (!name.dataset.touched) name.value = slug(event.target.value); } if (event.target.matches('[data-field-name]')) event.target.dataset.touched = event.target.value ? '1' : ''; refresh(); }); list.addEventListener('change', refresh); for (const card of cards()) { const name = card.querySelector('[data-field-name]'); if (name.value) name.dataset.touched = '1'; } for (const input of form.querySelectorAll('[data-form-color]')) { const key = input.dataset.formColor, label = form.querySelector(`label[for="${input.id}"]`).textContent; const id = input.id; input.type = 'hidden'; input.id += '-value'; colorInputs[key] = input; const control = createColorControl({ id, label, value: input.value, inheritedValue: defaults[key], colors: schema.colors, onChange: value => { input.value = value; renderPreview(); }, onComplete: () => {}, }); control.querySelector('.color-inherit').textContent = 'Wie die Website'; input.after(control); } buttonLabel.addEventListener('input', renderPreview); refresh(); } }