Offer 98 icons in a dialog and style a button icon under Design

The icon set grows from 33 to 98 drawings in seven categories (arrows,
contact, commerce, files and media, people and security, house and trade,
general), including truck, cart, euro, camera, tools and solar. icons.js is
the single source; npm run build:icons writes the Twig drawings and the
validator list, and a test keeps them equal.

Choosing an icon no longer fills the panel with tiles: the panel shows the
current icon with "Change", which opens a modal with category tabs and named
tiles, keyboard navigable, closing with Escape. Button, icon and icon card
share it.

A new style group "Symbol" targets the icon inside a button per device and
on hover: colour, background, size, padding, radius, rotation, plus the gap
to the text. Schema v26 adds hover motion (slide, reveal) and an icon-only
button whose label stays as the accessible name and keeps the line height.
ADR 109.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5.5 committed 2026-10-07 10:01:18 +02:00
1 parent 6af2e2aead
commit 08998de533
20 files changed
+658 -228

No files matched your search

+40 -21
View File
@@ -12,7 +12,7 @@ import { initSliders } from './slider.js';
import { VISIBILITY, setVisibility, registerPlugins, pluginDefinition, PLUGIN_PRESETS, repairSchema } from './editor-model.js';
// Symbole, ihre deutschen Namen und die Kachelauswahl. Die Liste spiegelt DocumentValidator::ICONS.
import { renderIconPicker, BUTTON_ICON_GROUPS, ALL_ICON_GROUPS } from './icons.js';
import { renderIconField } from './icons.js';
const ICON_SIZES = [12, 16, 20, 24, 32, 40, 48, 56, 64, 80, 96, 120, 160, 200, 240];
// Mirrors SocialProfiles::NETWORKS; the addresses themselves live in the settings.
const SOCIAL_NETWORKS = { facebook: 'Facebook', instagram: 'Instagram', linkedin: 'LinkedIn', youtube: 'YouTube', tiktok: 'TikTok', x: 'X', threads: 'Threads', pinterest: 'Pinterest', xing: 'Xing', whatsapp: 'WhatsApp', google: 'Google-Unternehmensprofil' };
@@ -319,6 +319,7 @@ function renderContent(block, panel) {
if (key === 'newTab') upgradeDocument(doc, 20);
if (key === 'collapse') upgradeDocument(doc, 22);
if (key === 'faq') upgradeDocument(doc, 23);
if (key === 'iconOnly') upgradeDocument(doc, 26);
if (current.type === 'image' && key === 'src') { delete current.props.sources; delete current.props.mediaId; }
}, options.choices ? null : `${selected || 'page'}:${key}`);
if (key === 'src') imagePreview?.update({ src: input.value });
@@ -346,21 +347,37 @@ function renderContent(block, panel) {
if (key === 'href' && !options.plugin) field('In neuem Tab öffnen', 'newTab', block.props.newTab === true, { checkbox: true, container });
}
};
// Symbolwahl als Kacheln mit Vorschau. Nach der Wahl wird das Panel neu gezeichnet, damit die
// Markierung stimmt, und der Fokus bleibt auf der gewählten Kachel.
const choose = (key, next, version) => {
// Eine Eigenschaft setzen, die kein Textfeld ist (Symbol, Stellung, Bewegung). Danach wird das
// Panel neu gezeichnet, und der Fokus kehrt zu dem Bedienelement zurück, das sie gesetzt hat.
const choose = (key, next, version, focus) => {
history.change(doc => {
const current = locate(doc.blocks, selected)?.block;
if (!current) return;
(current.props ??= {})[key] = next;
// Ohne Symbol gibt es keinen Button nur aus Symbol - sonst stünde ein leerer Knopf da.
if (key === 'icon' && next === 'none') delete current.props.iconOnly;
if (version) upgradeDocument(doc, version);
}, null);
renderProperties(); renderTree(); controls(); schedulePreview();
document.querySelector(key === 'iconPosition' ? `.icon-position [data-position="${next}"]` : `.icon-tile[data-icon="${next}"]`)?.focus({ preventScroll: true });
document.querySelector(focus)?.focus({ preventScroll: true });
};
const iconField = (label, value, groups, options = {}) => panel.append(renderIconPicker({
label, value, groups, none: options.none, onChoose: name => choose('icon', name, options.version),
const iconField = (label, value, options = {}) => panel.append(renderIconField({
label, value, none: options.none, onChoose: name => choose('icon', name, options.version, '.icon-field-change'),
}));
// Zwei bis drei Möglichkeiten nebeneinander statt einer Auswahlliste.
const segmented = (label, key, current, options, version) => {
const wrap = document.createElement('div'); wrap.className = 'icon-setting';
const caption = document.createElement('p'); caption.className = 'icon-picker-label'; caption.id = `segmented-${key}`; caption.textContent = label;
const group = document.createElement('div'); group.className = 'segmented'; group.setAttribute('role', 'group'); group.setAttribute('aria-labelledby', caption.id);
group.style.gridTemplateColumns = `repeat(${options.length}, 1fr)`;
for (const [value, text] of options) {
const option = document.createElement('button'); option.type = 'button'; option.dataset[key] = value; option.textContent = text;
option.setAttribute('aria-pressed', String(value === current));
option.addEventListener('click', () => { if (value !== current) choose(key, value, version, `[data-${key.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`)}="${value}"]`); });
group.append(option);
}
wrap.append(caption, group); panel.append(wrap);
};
if (!block) {
field('Seitentitel', 'title', history.document.title, { maxLength: 160 });
field('Beschreibung für Suchmaschinen', 'description', history.document.description, { multiline: true, maxLength: 320 });
@@ -403,19 +420,21 @@ function renderContent(block, panel) {
if (block.type === 'button') {
// Ohne eigene Wahl trägt ein Button den Pfeil, den er vor v25 immer hatte.
const icon = block.props.icon ?? 'arrow-up-right';
iconField('Symbol', icon, BUTTON_ICON_GROUPS, { none: 'Kein Symbol', version: 25 });
iconField('Symbol', icon, { none: 'Kein Symbol', version: 25 });
if (icon !== 'none') {
const position = block.props.iconPosition === 'start' ? 'start' : 'end';
const wrap = document.createElement('div'); wrap.className = 'icon-position';
const caption = document.createElement('p'); caption.className = 'icon-picker-label'; caption.id = 'icon-position-label'; caption.textContent = 'Symbol steht';
const group = document.createElement('div'); group.className = 'segmented'; group.setAttribute('role', 'group'); group.setAttribute('aria-labelledby', caption.id);
for (const [value, text] of [['start', 'Vor dem Text'], ['end', 'Nach dem Text']]) {
const option = document.createElement('button'); option.type = 'button'; option.dataset.position = value; option.textContent = text;
option.setAttribute('aria-pressed', String(value === position));
option.addEventListener('click', () => { if (value !== position) choose('iconPosition', value, 25); });
group.append(option);
}
wrap.append(caption, group); panel.append(wrap);
segmented('Stellung', 'iconPosition', block.props.iconPosition === 'start' ? 'start' : 'end', [['start', 'Vor dem Text'], ['end', 'Nach dem Text']], 25);
segmented('Beim Überfahren', 'iconMotion', block.props.iconMotion ?? 'none', [['none', 'Ruhig'], ['slide', 'Rückt'], ['reveal', 'Erscheint']], 26);
field('Nur das Symbol zeigen', 'iconOnly', block.props.iconOnly === true, { checkbox: true, help: 'Ein runder oder eckiger Knopf nur mit Symbol. Die Beschriftung bleibt als Name für Vorlesesoftware erhalten – bitte trotzdem sinnvoll ausfüllen.' });
// Farbe, Fläche, Größe und Drehung sind Gestaltung: sie gelten je Gerät und beim Überfahren,
// wie alle anderen Werte unter Design. Ein Knopf führt direkt dorthin.
const design = document.createElement('button'); design.type = 'button'; design.className = 'text-button icon-design-link';
design.textContent = 'Farbe, Größe und Fläche des Symbols → Design';
design.addEventListener('click', () => {
document.querySelector('[data-panel="design"]')?.click();
const group = document.querySelector('.design-group[data-group="icon"]');
if (group) { group.open = true; group.scrollIntoView({ block: 'start' }); group.querySelector('summary')?.focus({ preventScroll: true }); }
});
panel.append(design);
}
}
if (block.type === 'button' && designSchema) buttonControls(block, panel);
@@ -432,7 +451,7 @@ function renderContent(block, panel) {
}
if (block.type === 'heading') field('Überschriftenebene', 'level', block.props.level, { number: true, choices: { 1: 'H1 · Hauptüberschrift', 2: 'H2 · Abschnitt', 3: 'H3 · Unterabschnitt', 4: 'H4', 5: 'H5', 6: 'H6' }, help: 'Jede Seite benötigt genau eine H1.' });
if (block.type === 'iconcard') {
iconField('Symbol', block.props.icon, ALL_ICON_GROUPS);
iconField('Symbol', block.props.icon);
field('Titel', 'title', block.props.title, { maxLength: 120 });
field('Text', 'text', block.props.text, { multiline: true, maxLength: 600 });
field('Linkziel', 'href', block.props.href || '', { help: 'Optional. Lokaler Pfad, Sprungmarke oder HTTPS-Adresse, Telefon (tel:+49…) oder E-Mail (mailto:…) – oder ein PDF.' });
@@ -467,7 +486,7 @@ function renderContent(block, panel) {
}
if (block.type === 'partialref') field('Sektion', 'partial', block.props.partial, { ...pick('sections', block.props.partial), help: 'Die Sektion wird verknüpft, nicht kopiert: eine Änderung dort wirkt auf allen Seiten, die sie einbinden. Angelegt und bearbeitet wird sie unter „Bereiche“.' });
if (block.type === 'icon') {
iconField('Symbol', block.props.icon, ALL_ICON_GROUPS);
iconField('Symbol', block.props.icon);
// number, so the value is stored as a number: the server wants an int, not "48".
field('Größe', 'size', String(block.props.size), { number: true, choices: Object.fromEntries(ICON_SIZES.map(size => [String(size), `${size} px`])), help: 'Feste Stufen, weil die Größe eine Klasse ist – ein freier Wert hätte keine und das Symbol wäre unsichtbar.' });
field('Nur Dekoration', 'decorative', block.props.decorative, { checkbox: true, help: 'Dekorative Symbole werden Screenreadern verschwiegen. Trägt das Symbol eine Aussage, nimm das Häkchen weg und beschrifte es.' });