/** 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; }