SITE_ORIGIN alone decides whether the site is www or not: CanonicalHostSubscriber sends the other spelling there with 301, cms:site:origin writes the setting, the system screen shows it. The roles now match the role help (editor: drafts, publisher: live content, admin: design, settings, users, system). The installer no longer reopens on an unreachable database, the session cookie follows ADMIN_PATH, percent-encoded credentials no longer break the container, consent only accepts same-site decisions, JSON-LD is HTML-safe, own CSS stays in its block, plus the editor, updater, download and public-output findings from the full review (ADR 099, ADR 100). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
126 lines
6.5 KiB
JavaScript
126 lines
6.5 KiB
JavaScript
/** A compact palette picker. Palette references stay references when saved. */
|
|
let sequence = 0;
|
|
const node = (tag, className, text) => {
|
|
const el = document.createElement(tag);
|
|
el.className = className;
|
|
if (text !== undefined) el.textContent = text;
|
|
return el;
|
|
};
|
|
const hexPattern = /^#[0-9a-f]{6}([0-9a-f]{2})?$/i;
|
|
|
|
export function createColorControl({ id, label, value = '', inheritedValue = '', colors, onChange, onComplete = () => {} }) {
|
|
const wrap = node('div', 'color-control');
|
|
const trigger = node('button', 'color-trigger');
|
|
trigger.type = 'button'; trigger.id = id;
|
|
trigger.setAttribute('aria-expanded', 'false');
|
|
trigger.setAttribute('aria-controls', `${id}-choices`);
|
|
const sample = node('span', 'color-sample'); sample.setAttribute('aria-hidden', 'true');
|
|
const ink = node('span', 'color-sample-ink'); sample.append(ink);
|
|
const description = node('span', 'color-description');
|
|
const name = node('span', 'color-name'), detail = node('span', 'color-detail');
|
|
description.append(name, detail);
|
|
const arrow = node('span', 'color-chevron', '⌄'); arrow.setAttribute('aria-hidden', 'true');
|
|
trigger.append(sample, description, arrow);
|
|
|
|
const choices = node('div', 'color-choices'); choices.id = `${id}-choices`; choices.hidden = true;
|
|
const heading = node('p', 'color-heading', 'Website-Farben');
|
|
const palette = node('div', 'color-palette');
|
|
palette.setAttribute('role', 'group'); palette.setAttribute('aria-label', `Website-Farben für ${label}`);
|
|
const buttons = [];
|
|
let current = value, editing = false, mergeKey = `color-${++sequence}`;
|
|
const entryFor = raw => colors.find(color => `@${color.key}` === raw);
|
|
const resolve = raw => hexPattern.test(raw) ? raw : entryFor(raw)?.value || '';
|
|
const restoreFocus = () => document.getElementById(id)?.focus();
|
|
const choose = raw => { current = raw; sync(); onChange(raw); restoreFocus(); };
|
|
for (const color of colors) {
|
|
const button = node('button', 'color-option'); button.type = 'button';
|
|
button.dataset.color = `@${color.key}`;
|
|
button.title = `${color.name} · ${color.value}`;
|
|
button.setAttribute('aria-label', button.title);
|
|
const swatch = node('span', 'color-option-swatch'); swatch.style.backgroundColor = color.value;
|
|
swatch.setAttribute('aria-hidden', 'true');
|
|
button.append(swatch, node('span', 'color-option-name', color.name));
|
|
button.addEventListener('click', () => choose(`@${color.key}`));
|
|
buttons.push(button); palette.append(button);
|
|
}
|
|
if (colors.length) choices.append(heading, palette);
|
|
|
|
const customLabel = node('label', 'color-heading', 'Eigene Farbe'); customLabel.htmlFor = `${id}-hex`;
|
|
const custom = node('div', 'color-custom');
|
|
const picker = node('input', 'color-native'); picker.type = 'color';
|
|
picker.setAttribute('aria-label', `Eigene Farbe für ${label}`);
|
|
const text = node('input', 'color-text'); text.type = 'text'; text.id = `${id}-hex`;
|
|
text.autocomplete = 'off'; text.spellcheck = false; text.placeholder = '#RRGGBB';
|
|
text.setAttribute('aria-label', `Farbwert für ${label}`);
|
|
const accept = node('button', 'color-accept', '✓'); accept.type = 'button';
|
|
accept.setAttribute('aria-label', `Farbwert für ${label} übernehmen`); accept.title = 'Farbwert übernehmen';
|
|
custom.append(picker, text, accept);
|
|
const error = node('p', 'field-error'); error.id = `${id}-error`; error.hidden = true;
|
|
error.setAttribute('role', 'status'); text.setAttribute('aria-describedby', error.id);
|
|
const clear = node('button', 'color-inherit', 'Geerbten Wert verwenden'); clear.type = 'button';
|
|
clear.addEventListener('click', () => choose(''));
|
|
choices.append(customLabel, custom, error, clear);
|
|
wrap.append(trigger, choices);
|
|
|
|
function sync() {
|
|
const shown = current || inheritedValue, entry = entryFor(shown), hex = resolve(shown);
|
|
ink.style.backgroundColor = hex || 'transparent';
|
|
name.textContent = entry?.name || (hex ? 'Eigene Farbe' : current ? 'Unbekannte Palettenfarbe' : 'Übernehmen');
|
|
detail.textContent = [!current ? 'Geerbt' : entry ? 'Palette' : '', hex || (current || 'Kein eigener Farbwert')].filter(Boolean).join(' · ');
|
|
trigger.setAttribute('aria-label', `${label}: ${name.textContent}, ${detail.textContent}. Farbe auswählen`);
|
|
picker.value = hex ? hex.slice(0, 7) : '#ffffff';
|
|
text.value = hex || current || '';
|
|
for (const button of buttons) button.setAttribute('aria-pressed', String(button.dataset.color === shown));
|
|
clear.disabled = current === '';
|
|
}
|
|
const finish = () => {
|
|
if (!editing) return;
|
|
editing = false;
|
|
onComplete();
|
|
};
|
|
trigger.addEventListener('click', () => {
|
|
if (!choices.hidden) {
|
|
choices.hidden = true; trigger.setAttribute('aria-expanded', 'false'); finish(); restoreFocus();
|
|
} else {
|
|
choices.hidden = false; trigger.setAttribute('aria-expanded', 'true');
|
|
}
|
|
});
|
|
// A native picker emits many input events during one gesture. Keep its DOM and one undo entry.
|
|
picker.addEventListener('input', () => {
|
|
current = picker.value + (resolve(current || inheritedValue).slice(7) || '');
|
|
editing = true; sync(); onChange(current, mergeKey);
|
|
});
|
|
picker.addEventListener('change', () => {
|
|
current = picker.value + (resolve(current || inheritedValue).slice(7) || '');
|
|
sync(); onChange(current, editing ? mergeKey : undefined);
|
|
if (editing) finish();
|
|
restoreFocus();
|
|
});
|
|
const applyText = () => {
|
|
let raw = text.value.trim();
|
|
if (/^#[0-9a-f]{3}$/i.test(raw)) raw = '#' + [...raw.slice(1)].map(char => char + char).join('');
|
|
if (raw && !hexPattern.test(raw) && !entryFor(raw)) {
|
|
text.setAttribute('aria-invalid', 'true'); error.hidden = false;
|
|
error.textContent = 'Hexfarbe wie #1a2b3c oder eine vorhandene Palettenfarbe eingeben.';
|
|
text.focus(); return;
|
|
}
|
|
choose(raw);
|
|
};
|
|
text.addEventListener('input', () => { text.removeAttribute('aria-invalid'); error.hidden = true; });
|
|
text.addEventListener('keydown', event => {
|
|
if (event.key === 'Enter') { event.preventDefault(); applyText(); }
|
|
});
|
|
accept.addEventListener('click', applyText);
|
|
wrap.addEventListener('keydown', event => {
|
|
if (event.key !== 'Escape' || choices.hidden) return;
|
|
event.preventDefault(); event.stopPropagation(); choices.hidden = true;
|
|
trigger.setAttribute('aria-expanded', 'false'); finish(); restoreFocus();
|
|
});
|
|
// Commit after leaving the native picker even on browsers that omit its change event.
|
|
wrap.addEventListener('focusout', () => setTimeout(() => {
|
|
if (wrap.isConnected && !wrap.contains(document.activeElement)) finish();
|
|
}, 0));
|
|
sync();
|
|
return wrap;
|
|
}
|