/* * 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'; 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 eine Liste der Mediathek. Das Textfeld bleibt das // gespeicherte Original und wird aus den Listen geschrieben; ohne Skript tippt man hinein. 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(); for (const option of options) { const row = el('label', 'field-image-picker'); row.append(el('span', 'field-image-picker-option', option)); const select = document.createElement('select'); select.append(new Option('Kein Bild', '')); for (const bild of bilder) select.append(new Option(bild.title, bild.id, false, current[option] === bild.id)); select.dataset.option = option; select.addEventListener('change', () => { const map = {}; for (const picker of box.querySelectorAll('select')) if (picker.value) map[picker.dataset.option] = picker.value; area.value = Object.entries(map).map(([o, id]) => `${o} = ${id}`).join('\n'); renderPreview(); }); row.append(select); box.append(row); } }; 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); 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; }); 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(); } }