Let menu entries appear as buttons with icon, colours and phone placement

The menu editor gets "Als Button darstellen" per entry: shape, appearance and
size from the button design, fill and ink with hover and palette, icon colour,
icon from the dialog, motion, icon-only and where the button sits on the phone
(in the opened menu, beside the burger, or there as an icon only). Colours go
into the page stylesheet under a stable per-button id because the CSP forbids
inline styles. ADR 110.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5.5 committed 2026-10-07 22:36:12 +02:00
1 parent 48ae2443cd
commit 13f6759df8
22 files changed
+623 -14

No files matched your search

+112 -4
View File
@@ -1,5 +1,8 @@
import { setupPicker } from './page-picker.js';
import './link-field.js';
import { renderIconField, iconSvg, ICON_LABELS } from './icons.js';
import { createColorControl } from './color-control.js';
import { buttonSample } from './button-design.js';
const form = document.querySelector('#menu-editor');
const list = form.querySelector('#menu-items');
@@ -8,7 +11,8 @@ const error = form.querySelector('#menu-error');
const history = [];
let serial = list.children.length, pendingPage = null, busy = false;
const el = (tag, className = '', text = '') => { const node = document.createElement(tag); node.className = className; node.textContent = text; return node; };
const iconNames = { app: 'App', web: 'Web', design: 'Gestaltung', network: 'Netzwerk', hardware: 'Computer', shop: 'Shop', support: 'Support', shield: 'Sicherheit', clock: 'Uhr', star: 'Stern', phone: 'Telefon', mail: 'E-Mail', pin: 'Standort', check: 'Haken', people: 'Menschen', music: 'Musik', home: 'Haus' };
const iconNames = ICON_LABELS;
const buttonSchema = JSON.parse(form.dataset.buttonSchema || '{}');
const menuIcons = JSON.parse(form.dataset.icons || '[]');
const chrome = () => {
const handle = el('button', 'menu-drag-handle'); handle.type = 'button'; handle.dataset.menuDrag = '';
@@ -40,7 +44,9 @@ function refresh() {
const name = row.querySelector('[data-key="label"]').value || 'Neuer Eintrag';
row.querySelector('.menu-entry-title').textContent = name;
const icon = row.querySelector('[data-key="icon"]')?.value, position = row.querySelector('[data-key="iconPosition"]')?.value;
const graphic = icon && document.getElementById(`menu-icon-${icon}`)?.content.cloneNode(true);
row.querySelector('.menu-entry-title svg')?.remove();
const graphic = icon && iconSvg(icon);
if (graphic) { graphic.setAttribute('width', '18'); graphic.setAttribute('height', '18'); }
if (graphic) row.querySelector('.menu-entry-title')[position === 'after' ? 'append' : 'prepend'](graphic);
row.querySelector('[data-menu-drag]')?.setAttribute('aria-label', `${name} verschieben`);
row.querySelectorAll('[data-key="icon"] option').forEach(option => { if (iconNames[option.value]) option.textContent = iconNames[option.value]; });
@@ -91,12 +97,15 @@ function createRow(data) {
anchorChoice(row);
} else { const url = field('url', data.url, 'Adresse'); url.required = true; url.maxLength = 2048; linkButtons(url); }
const newTab = field('newTab', '1', 'In neuem Tab öffnen', 'checkbox'); newTab.checked = data.newTab === true || data.newTab === '1';
for (const [key, label, choices] of [['icon', 'Icon', { '': 'Kein Icon', ...Object.fromEntries(menuIcons.map(icon => [icon, iconNames[icon] || icon])) }], ['iconPosition', 'Icon-Position', { before: 'Vor dem Text', after: 'Nach dem Text' }]]) {
const icons = el('div', 'menu-icon-fields'); body.append(icons);
for (const [key, label, choices] of [['icon', 'Symbol', { '': 'Kein Symbol', ...Object.fromEntries(menuIcons.map(icon => [icon, iconNames[icon] || icon])) }], ['iconPosition', 'Symbol steht', { before: 'Vor dem Text', after: 'Nach dem Text' }]]) {
const select = el('select'); select.dataset.key = key; select.id = `menu-${key}-${id}`;
for (const [value, caption] of Object.entries(choices)) select.append(new Option(caption, value));
select.value = data[key] || (key === 'iconPosition' ? 'before' : '');
const caption = el('label', '', label); caption.htmlFor = select.id; body.append(caption, select);
const wrap = el('div'); const caption = el('label', '', label); caption.htmlFor = select.id; wrap.append(caption, select); icons.append(wrap);
}
body.append(buttonFieldset(data, id));
enhance(row);
const actions = el('div', 'menu-entry-actions'); actions.setAttribute('role', 'group');
for (const [action, caption] of Object.entries({ up: '↑', down: '↓', indent: 'Als Unterpunkt', outdent: 'Hauptebene', remove: 'Entfernen' })) {
const button = el('button', `text-button${action === 'remove' ? ' danger-text' : ''}`, caption); button.type = 'button'; button.dataset.menuAction = action; actions.append(button);
@@ -156,6 +165,105 @@ function linkButtons(input) {
}
list.querySelectorAll('input[data-key="url"]').forEach(linkButtons);
/*
* Ein Eintrag als Button (ADR 110): dieselben Felder wie die Vorlage menu-entry.html.twig, damit
* neue und geladene Zeilen gleich aussehen und dieselben Namen tragen. Leer heißt Website-Standard.
*/
const BUTTON_COLORS = { buttonFill: 'Fläche', buttonInk: 'Schrift', buttonHoverFill: 'Fläche beim Überfahren', buttonHoverInk: 'Schrift beim Überfahren', buttonIconColor: 'Symbolfarbe' };
function buttonFieldset(data, id) {
const set = el('fieldset', 'menu-button-fields'); set.dataset.buttonFields = '';
set.append(el('legend', '', 'Button'));
const toggle = el('input'); toggle.type = 'checkbox'; toggle.dataset.key = 'button'; toggle.value = '1'; toggle.id = `menu-button-${id}`;
toggle.checked = data.button === '1' || data.button === true;
const toggleLabel = el('label', 'check-label', ' Als Button darstellen'); toggleLabel.htmlFor = toggle.id; toggleLabel.prepend(toggle);
const hidden = el('input'); hidden.type = 'hidden'; hidden.dataset.key = 'buttonId'; hidden.value = data.buttonId || '';
const options = el('div', 'menu-button-options'); options.dataset.buttonOptions = ''; options.hidden = !toggle.checked;
const select = (key, label, choices, value) => {
const wrap = el('div'); const input = el('select'); input.dataset.key = key; input.id = `menu-${key}-${id}`;
for (const [option, caption] of Object.entries(choices)) input.append(new Option(caption, option));
input.value = value; const caption = el('label', '', label); caption.htmlFor = input.id; wrap.append(caption, input); options.append(wrap);
};
for (const [key, label] of [['shape', 'Form'], ['appearance', 'Darstellung'], ['size', 'Größe']]) {
const field = `button${key[0].toUpperCase()}${key.slice(1)}`;
select(field, label, { '': 'Website-Standard', ...(buttonSchema.buttonOptions?.[key] ?? {}) }, data[field] || '');
}
for (const [key, label] of Object.entries(BUTTON_COLORS)) {
const wrap = el('div'); wrap.dataset.colorField = '';
const input = el('input'); input.dataset.key = key; input.id = `menu-${key}-${id}`; input.value = data[key] || ''; input.placeholder = 'Website-Standard';
const caption = el('label', '', label); caption.htmlFor = input.id; wrap.append(caption, input); options.append(wrap);
}
select('iconMotion', 'Symbol beim Überfahren', { none: 'Ruhig', slide: 'Rückt', reveal: 'Erscheint' }, data.iconMotion || 'none');
const only = el('input'); only.type = 'checkbox'; only.dataset.key = 'iconOnly'; only.value = '1'; only.id = `menu-iconOnly-${id}`; only.checked = data.iconOnly === '1';
const onlyLabel = el('label', 'check-label', ' Nur das Symbol zeigen'); onlyLabel.htmlFor = only.id; onlyLabel.prepend(only); options.append(onlyLabel);
select('buttonMobile', 'Auf dem Telefon', buttonSchema.mobile ?? { menu: 'Im aufgeklappten Menü' }, data.buttonMobile || 'menu');
set.append(toggleLabel, hidden, options);
return set;
}
/*
* Aus Feldern wird Bedienung: das Symbol über das Auswahlfenster statt einer langen Liste, Farben
* mit der Palette der Website wie im Editor, und eine Vorschau des Buttons - normal und beim
* Überfahren. Gespeichert wird weiter, was in den Feldern steht; ohne Skript bleiben sie sichtbar.
*/
function enhance(row) {
if (row.dataset.enhanced) return;
row.dataset.enhanced = '1';
const value = key => row.querySelector(`[data-key="${key}"]`);
const changed = input => input.dispatchEvent(new Event('change', { bubbles: true }));
// Symbol: das Auswahlfenster schreibt in die versteckte Auswahl.
const iconSelect = value('icon');
if (iconSelect) {
const holder = iconSelect.closest('div'); holder.hidden = true;
const mount = el('div', 'menu-icon-choice'); holder.before(mount);
const draw = () => mount.replaceChildren(renderIconField({
label: 'Symbol', value: iconSelect.value || 'none', none: 'Kein Symbol',
onChoose: name => { iconSelect.value = name === 'none' ? '' : name; changed(iconSelect); draw(); preview(); mount.querySelector('.icon-field-change')?.focus(); },
}));
draw();
}
const toggle = value('button'), options = row.querySelector('[data-button-options]');
if (!toggle || !options) return;
const show = () => { options.hidden = !toggle.checked; };
toggle.addEventListener('change', show);
for (const wrap of options.querySelectorAll('[data-color-field]')) {
const input = wrap.querySelector('input'); const caption = wrap.querySelector('label');
input.type = 'hidden';
const control = createColorControl({
id: `${input.id}-control`, label: caption.textContent, value: input.value, colors: buttonSchema.colors ?? [],
onChange: raw => { input.value = raw; changed(input); preview(); },
});
caption.htmlFor = `${input.id}-control`;
wrap.append(control);
}
const box = el('div', 'menu-button-preview'); box.setAttribute('aria-hidden', 'true');
options.prepend(box);
function preview() {
if (!buttonSchema.buttons) return;
const pick = key => value(key)?.value || '';
const values = { ...buttonSchema.buttons };
for (const [field, key] of [['buttonShape', 'shape'], ['buttonAppearance', 'appearance'], ['buttonSize', 'size'], ['buttonFill', 'backgroundColor'], ['buttonInk', 'color'], ['buttonHoverFill', 'hoverBackgroundColor'], ['buttonHoverInk', 'hoverColor']]) {
if (pick(field)) values[key] = pick(field);
}
const icon = pick('icon'), before = pick('iconPosition') !== 'after', only = value('iconOnly')?.checked && icon;
const samples = [['Normal', false], ['Beim Überfahren', true]].map(([title, hover]) => {
const state = el('div', 'button-state'); state.append(el('span', 'field-help', title));
const sample = buttonSample(only ? '\u200b' : (value('label')?.value || 'Button'), values, buttonSchema, hover);
if (icon) {
const holder = el('span', 'button-icon'); holder.append(iconSvg(icon));
const color = pick('buttonIconColor');
if (color) holder.style.color = color.startsWith('@') ? (buttonSchema.colors ?? []).find(entry => `@${entry.key}` === color)?.value || '' : color;
if (before) sample.prepend(holder); else sample.append(holder);
}
state.append(sample); return state;
});
box.replaceChildren(...samples);
}
row.addEventListener('input', preview);
row.addEventListener('change', preview);
show(); preview();
}
list.querySelectorAll('.menu-entry').forEach(enhance);
function restore(value) {
form.elements.name.value = value.name; list.replaceChildren(...value.rows.map(createRow)); refresh(); last = state();
}