Forms are an editor now: one card per field with add and remove, the technical name derived from the label, options only for a choice, and a new "Aussehen" section with a button label and four colours (field border and background, button and button text) that travel to the page as one rule per form inside the hashed style block. A live preview draws the form with the site's default button and names the text contrast. Statistics open with six figures (views against the same span before, average per day, busiest day, pages, referred views, missing addresses), a chart with grid lines and a title per bar, a weekday chart and ranked lists with share bars. The design screen shows the palette as colour cards with seven ready-made palettes, picks the base colours from the palette, previews every font in itself and the typography as a sample. The sidebar ends in an account card with role and a logout icon. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
158 lines
8.6 KiB
JavaScript
158 lines
8.6 KiB
JavaScript
/*
|
||
* 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 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 required = type === 'consent' || card.querySelector('[data-field-required]').checked;
|
||
const field = el('div', 'form-preview-field');
|
||
if (type === 'consent') {
|
||
const check = el('span', 'form-preview-check');
|
||
check.append(el('span', 'form-preview-box'), document.createTextNode(`${label} *`));
|
||
field.append(check);
|
||
} else {
|
||
field.append(el('span', 'form-preview-label', label + (required ? ' *' : '')));
|
||
field.append(el('span', `form-preview-input${type === 'textarea' ? ' form-preview-area' : ''}${type === 'select' ? ' form-preview-select' : ''}`, type === 'select' ? '— bitte wählen —' : ''));
|
||
}
|
||
const help = card.querySelector('[data-field-help]').value.trim();
|
||
if (help) field.append(el('span', 'form-preview-help', help));
|
||
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);
|
||
|
||
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 = type !== 'select';
|
||
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 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();
|
||
}
|
||
}
|