feat: visual palette picker with accurate color previews (1.8.2)
This commit is contained in:
1 parent
c1b152b376
commit
55dab4bc1e
11 files changed
+347
-39
No files matched your search
@@ -0,0 +1,125 @@
|
||||
/** 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;
|
||||
}
|
||||
Reference in new issue
Block a user