Let a button carry a chosen icon before or after its text, and pick icons from tiles
Schema v25: a button takes an optional icon (any name from the shared set or "none") and an icon position (start or end). Without a choice it keeps the diagonal arrow behind the text, still with the button-arrow class, so stored pages and their own CSS look exactly as before. Thirteen everyday icons join the set (arrow down, chevron, calendar, message, send, download, document, play, search, heart, info, plus, person). Button, icon and icon card choose from a tile picker with a drawing per icon, grouped, keyboard navigable, instead of a select with English keys. icons.js holds the paths for the editor; a test keeps them equal to the Twig drawings and the validator list. ADR 108. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e1753fa7e4
commit
88737a95a9
17 files changed
+463
-15
No files matched your search
@@ -216,6 +216,8 @@ Unter `http://127.0.0.1:8080/admin` eine Seite auswählen. Links Blöcke hinzuf
|
||||
|
||||
**Links und Bildvorschauen (ab 1.7.3):** Unter Linkziel steht **In neuem Tab öffnen** – für Buttons, verlinkte Bilder, Symbole, Symbolkarten und Aufruf-Bänder. Im Text zuerst den Link über die Werkzeugleiste öffnen; bei Menüeinträgen steht das Häkchen in den aufgeklappten Einstellungen. Ausgewählte Bilder und Galerie-/Logobilder zeigen eine Vorschau mit Entfernen-Funktion. „Bild entfernen“ entfernt das Bild aus dem Inhalt; die Datei bleibt in der Mediathek. Rückgängig stellt die Verwendung wieder her. Design → Hintergrund zeigt auch geerbte Bilder: Entfernen blendet das Bild auf dem aktiven und kleineren Geräten aus, Zurücksetzen stellt die Vererbung wieder her. Bildaustausch erhält Link, Bildunterschrift und eingestellte Größe. Prüfung mit isolierter Kant-IT-Kopie: `npm run test:link-media`, Bericht und Screenshots unter `reports/link-media/`. Datenvertrag und Rückweg: ADR 081 in `docs/ARCHITECTURE.md`.
|
||||
|
||||
**Symbol am Button (Schema v25):** Button auswählen → Inhalt → **Symbol**. Die Kacheln zeigen jedes Symbol als Vorschau, geordnet nach Pfeilen, Kontakt und Allgemein; „Kein Symbol“ steht vorn. Darunter **Symbol steht: Vor dem Text / Nach dem Text**. Ohne eigene Wahl trägt ein Button wie bisher den schrägen Pfeil ↗. Die Kacheln bedienen sich auch mit den Pfeiltasten. Symbol und Symbolkarte wählen ihr Symbol mit derselben Auswahl. Prüfung mit isolierter Kant-IT-Kopie: `npm run test:button-icons`, Screenshots unter `reports/button-icons/`. Datenvertrag: ADR 108.
|
||||
|
||||
Blöcke per Drag-and-drop verschieben: auf einen Container ziehen, um hineinzuverschieben; auf einen Text-/Bild-/Linkblock ziehen, um davor einzufügen. Alternativ unten am Strukturbaum die Schaltflächen für Auf/Ab, Verschachteln und Herausheben verwenden. Duplizieren vergibt neue Block-IDs und wandelt eine kopierte H1 in H2 um. Löschen lässt sich rückgängig machen. Jede speicherbare Seite benötigt genau eine H1.
|
||||
|
||||
Undo/Redo gilt für die lokale Bearbeitung, `Strg+S` speichert den Entwurf. `Strg+Z`/`Strg+Umschalt+Z` gelten außerhalb der Texteingaben für das Dokument; Texteingaben behalten ihr natives Undo. Erst speichern, dann separat veröffentlichen. Die Bestätigungsdialoge unterstützen Escape und Fokus-Rückkehr. Reine Redakteure können nicht veröffentlichen; was Redakteur, Veröffentlicher und Administrator dürfen, steht unter Benutzer bei jeder Rolle und im Abschnitt „Rollen“ oben.
|
||||
|
||||
@@ -2,6 +2,20 @@
|
||||
|
||||
Stand: 15.09.2026. Arbeitstitel KantSites; Produktname offen.
|
||||
|
||||
## ADR 108 – Das Symbol am Button ist wählbar, ausgewählt wird mit Kacheln (07.10.2026)
|
||||
|
||||
Umut: „Buttons mit Icon? Geht das, wenn ja wo? Und einfacher Button ist ein Pfeil, wie ändere ich das?“ – und dann: „Ein Satz Symbole, die man so alltäglich brauchen könnte, und schöne Vorschau-Auswahl, bitte kein Dropdown mit Suche.“ Bis hierher hängte `block.html.twig` jedem Button fest `arrow-up-right` an, auch dem Textlink. Ändern ging nur über eigenes CSS (Hüfner blendet den Pfeil so aus, EB Solar drehte ihn).
|
||||
|
||||
**Datenvertrag (Schema v25).** Zwei neue, optionale Eigenschaften am Button: `icon` – ein Name aus `DocumentValidator::ICONS` oder `none` – und `iconPosition` – `start` oder `end`. Fehlt `icon`, bleibt es beim schrägen Pfeil (`BUTTON_DEFAULT_ICON`); fehlt `iconPosition`, steht das Symbol hinter dem Text. Ein Dokument ohne die beiden Felder sieht also genau aus wie vorher und braucht keinen Migrationsweg. Beide Felder verlangen v25, damit ein älterer Kern ein Dokument mit Symbolwahl ablehnt, statt es still mit Pfeil zu zeigen. Der Editor hebt das Dokument bei der ersten Wahl an, und `repairSchema` tut es beim Laden.
|
||||
|
||||
**Kompatibilität mit eigenem CSS.** Die Hülle des Symbols heißt jetzt `button-icon`. Der Standardpfeil trägt zusätzlich weiter `button-arrow`, damit Regeln wie `& .button-arrow{display:none}` auf bestehenden Websites wirken wie bisher. Wer dort ein anderes Symbol wählt, bekommt es sichtbar – die alte Regel trifft es nicht. Die Farb- und Größenregeln aus ADR 107 gelten jetzt für `button-icon`.
|
||||
|
||||
**Symbolsatz.** Dreizehn alltägliche Symbole kommen dazu: Pfeil nach unten, Winkel, Kalender, Nachricht, Senden, Download, Dokument, Abspielen, Suche, Herz, Info, Plus, Person. Sie stehen allen Bausteinen mit Symbol offen (Symbol, Symbolkarte, Button), nicht nur dem Button; das erweitert eine bestehende Liste und braucht wie ADR 107 kein neues Schema. Keine Markenzeichen (etwa WhatsApp): deren Logos haben eigene Nutzungsregeln.
|
||||
|
||||
**Auswahl mit Kacheln.** `public/assets/icons.js` hält Pfade, deutsche Namen und die Gruppen (Pfeile, Kontakt, Allgemein; bei Symbol und Symbolkarte zusätzlich „Weitere“) und zeichnet die Auswahl: jedes Symbol ein Knopf mit seiner Zeichnung und `aria-pressed`, „Kein Symbol“ vorn, Pfeiltasten wandern, Tab verlässt die Gruppe. Die bisherige Auswahlliste mit englischen Schlüsseln bei Symbol und Symbolkarte ist durch dieselbe Kachelauswahl ersetzt. Die Stellung ist ein Umschalter aus zwei Knöpfen und erscheint nur, wenn ein Symbol gewählt ist. Die Pfade stehen damit zweimal (Twig für die Website, JS für die Vorschau); `tests/icons.test.mjs` hält beide und die Liste im Validator gleich.
|
||||
|
||||
**Geprüft:** `CmsTest::testAButtonCarriesTheChosenIconBeforeOrAfterItsTextOrNone` (Standardpfeil mit `button-arrow`, Telefon vorn, kein Symbol), `testAButtonRejectsUnknownIconsAndOlderSchemas`, `tests/icons.test.mjs`, `repairSchema` hebt auf v25, `npm run test:button-icons` im Browser (Kachel, Stellung, Tastatur, Rückgängig, kein Symbol, Speichern als v25 und Neuladen).
|
||||
|
||||
## ADR 107 – Knöpfe dürfen anrufen und schreiben, der Pfeil hat die Schriftfarbe (06.10.2026)
|
||||
|
||||
Beim Aufbau von Hüfner Gartenbau fielen zwei Lücken auf, die Umut im Kern geschlossen haben wollte.
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
# Projektstatus
|
||||
|
||||
## Fassung 1.24.0: Symbol am Button wählbar, Kachelauswahl für Symbole (07.10.2026)
|
||||
|
||||
**Anlass:** Umut: „Buttons mit Icon? Geht das, wenn ja wo? Und einfacher Button ist ein Pfeil, wie ändere ich das?“ – dann: „Ja bitte umsetzen. Ein Satz Symbole, die man so alltäglich brauchen könnte, und schöne Vorschau-Auswahl, bitte kein Dropdown mit Suche usw.“
|
||||
|
||||
**Neu (Kern, Schema v25, ADR 108):**
|
||||
|
||||
- Button → Inhalt → **Symbol**: Kacheln mit Vorschau in drei Gruppen (Pfeile, Kontakt, Allgemein), „Kein Symbol“ vorn. Darunter **Symbol steht: Vor dem Text / Nach dem Text**, nur wenn ein Symbol gewählt ist. Ohne Wahl bleibt der schräge Pfeil hinter dem Text – bestehende Seiten sehen unverändert aus, eigenes CSS auf `.button-arrow` greift weiter.
|
||||
- 13 neue Symbole im gemeinsamen Satz (Pfeil nach unten, Winkel, Kalender, Nachricht, Senden, Download, Dokument, Abspielen, Suche, Herz, Info, Plus, Person), damit 33 insgesamt.
|
||||
- Symbol und Symbolkarte wählen ihr Symbol mit derselben Kachelauswahl statt der Liste mit englischen Namen.
|
||||
- `public/assets/icons.js` (Pfade, deutsche Namen, Gruppen, Auswahl), in der Importmap eingetragen. Stile in `button-controls.css`. `site.css`: Hülle heißt `button-icon`.
|
||||
|
||||
**Geprüft:** PHPUnit vollständig (609 Tests) grün, darunter zwei neue Tests in `CmsTest` (Ausgabe mit Standardpfeil, Telefon vorn, kein Symbol; Ablehnung unbekannter Symbole und älterer Schemata). `npm test` (38) grün, dazu `tests/icons.test.mjs` (Twig, Editor und Validator kennen dieselben Symbole) und `repairSchema` hebt auf v25. `npm run test:button-icons` im Browser auf einer Kopie der Pilotdaten: Kachel wählen, Stellung umschalten, Pfeiltasten und Enter, Rückgängig/Wiederholen, kein Symbol, Speichern als v25, nach Neuladen gleich; keine Seitenfehler. Screenshots `reports/button-icons/`. php-cs-fixer und `lint:twig` ohne Befund. Nicht geprüft: Lighthouse und `test:browser` (die öffentliche Ausgabe ändert sich nur bei gewähltem Symbol um ein Inline-SVG gleicher Größe).
|
||||
|
||||
**Bekannt:** Ein Dokument mit Symbolwahl lässt sich auf einem Kern vor dieser Fassung nicht speichern (Schema v25) – Installationen zuerst aktualisieren. Keine Markenlogos im Satz.
|
||||
|
||||
**Nächster Schritt:** Push von `main` und Tag `v1.24.0`, Releasearchiv auf der Releaseseite veröffentlichen, dann die Installationen aktualisieren (vor Hüfner, damit dort Symbole gewählt werden können).
|
||||
|
||||
## Fassung 1.23.1: neunte Installation „Hüfner Gartenbau“, Knöpfe mit tel: und mailto:, Pfeil in der Schriftfarbe (06.10.2026)
|
||||
|
||||
**Anlass:** Umut: „Hier liegt ein Designentwurf für Hüfner Gartenbau. Kannst du mir db und media erstellen für KantSites bitte. […] Effekte rein. Nichts am Code ändern, sondern alles an KantSites-Designelementen anpassen/bauen. Falls was nicht geht, Rückmeldung.“ Vorlage: `Downloads/huefner-website/huefner-website` (statisches `index.html`, 16 Fotos, Logo).
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"scripts": {
|
||||
"build:buttons": "node scripts/build-buttons.mjs",
|
||||
"test:buttons": "node scripts/button-check.mjs",
|
||||
"test:button-icons": "node scripts/button-icon-check.mjs",
|
||||
"test:navigation-ux": "node scripts/navigation-ux-check.mjs",
|
||||
"test:responsive-navigation": "node scripts/responsive-navigation-check.mjs",
|
||||
"build:design": "node scripts/build-design.mjs",
|
||||
|
||||
@@ -1 +1 @@
|
||||
.button-designer{padding:16px 0;border-block:1px solid #dce4dc;margin-block:16px}.button-designer h3{margin:0 0 12px;font-size:15px}.button-state-previews{display:grid;gap:12px;padding:16px;background:#fff;border:1px solid #dce4dc;border-radius:12px;margin-bottom:16px}.button-state{display:grid;justify-items:start;gap:7px;min-width:0}.button-state>.field-help{margin:0}.button-preset-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin:12px 0 16px}.button-preset{display:grid;justify-items:center;align-content:center;gap:9px;min-width:0;padding:12px 6px;background:#fff;border:1px solid #dce4dc;border-radius:10px;color:#203b34;cursor:pointer}.button-preset[aria-pressed=true]{border-color:#2c6b4f;box-shadow:inset 0 0 0 1px #2c6b4f;background:#f1f6ef}.button-preset:focus-visible{outline:3px solid #b55b12;outline-offset:2px}.button-preset .cms-button{font-size:12px!important;padding:6px 10px;min-height:34px;pointer-events:none}.button-preset-label{font-size:11px;line-height:1.4;text-align:center}.button-designer select{width:100%;margin-bottom:12px}.button-defaults{margin-block:24px;padding:24px;border:1px solid #dce4dc;border-radius:14px;min-width:0}.button-defaults legend{font-size:20px;font-weight:700;padding:0 8px}.button-defaults-layout{display:grid;grid-template-columns:minmax(0,1fr) minmax(260px,.75fr);gap:28px}.button-defaults .fields{align-content:start}.button-defaults .button-preview-column{min-width:0}.button-defaults .button-preview-column h3{font-size:15px;margin:0 0 12px}.button-defaults .color-field{margin:0}.button-defaults .color-trigger{width:100%}@media(max-width:900px){.button-defaults-layout{grid-template-columns:1fr}.button-defaults{padding:16px}}
|
||||
.button-designer{padding:16px 0;border-block:1px solid #dce4dc;margin-block:16px}.button-designer h3{margin:0 0 12px;font-size:15px}.button-state-previews{display:grid;gap:12px;padding:16px;background:#fff;border:1px solid #dce4dc;border-radius:12px;margin-bottom:16px}.button-state{display:grid;justify-items:start;gap:7px;min-width:0}.button-state>.field-help{margin:0}.button-preset-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin:12px 0 16px}.button-preset{display:grid;justify-items:center;align-content:center;gap:9px;min-width:0;padding:12px 6px;background:#fff;border:1px solid #dce4dc;border-radius:10px;color:#203b34;cursor:pointer}.button-preset[aria-pressed=true]{border-color:#2c6b4f;box-shadow:inset 0 0 0 1px #2c6b4f;background:#f1f6ef}.button-preset:focus-visible{outline:3px solid #b55b12;outline-offset:2px}.button-preset .cms-button{font-size:12px!important;padding:6px 10px;min-height:34px;pointer-events:none}.button-preset-label{font-size:11px;line-height:1.4;text-align:center}.button-designer select{width:100%;margin-bottom:12px}.button-defaults{margin-block:24px;padding:24px;border:1px solid #dce4dc;border-radius:14px;min-width:0}.button-defaults legend{font-size:20px;font-weight:700;padding:0 8px}.button-defaults-layout{display:grid;grid-template-columns:minmax(0,1fr) minmax(260px,.75fr);gap:28px}.button-defaults .fields{align-content:start}.button-defaults .button-preview-column{min-width:0}.button-defaults .button-preview-column h3{font-size:15px;margin:0 0 12px}.button-defaults .color-field{margin:0}.button-defaults .color-trigger{width:100%}@media(max-width:900px){.button-defaults-layout{grid-template-columns:1fr}.button-defaults{padding:16px}}.icon-picker{margin:4px 0 16px}.icon-picker-label{margin:0 0 6px;font-size:13px;font-weight:700;color:#203b34}.icon-group-title{margin:10px 0 6px;font-size:11px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;color:#5b6b64}.icon-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(40px,1fr));gap:6px}.icon-tile{display:grid;place-items:center;aspect-ratio:1;min-height:40px;padding:0;background:#fff;border:1px solid #dce4dc;border-radius:9px;color:#203b34;cursor:pointer;transition:border-color .15s,background-color .15s}.icon-tile svg{width:22px;height:22px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}.icon-tile:hover{border-color:#8fb39f;background:#f7faf5}.icon-tile[aria-pressed=true]{border-color:#2c6b4f;box-shadow:inset 0 0 0 1px #2c6b4f;background:#e8f2e4;color:#1d4d37}.icon-tile:focus-visible{outline:3px solid #b55b12;outline-offset:2px}.icon-tile-none{grid-column:span 2;aspect-ratio:auto}.icon-tile-none::after{content:"Kein Symbol";font-size:12px;font-weight:600}.icon-tile-empty{display:none}.icon-position{margin:0 0 16px}.segmented{display:grid;grid-template-columns:1fr 1fr;border:1px solid #dce4dc;border-radius:9px;overflow:hidden}.segmented button{min-height:38px;border:0;background:#fff;color:#203b34;font-size:13px;font-weight:600;cursor:pointer}.segmented button+button{border-left:1px solid #dce4dc}.segmented button[aria-pressed=true]{background:#2c6b4f;color:#fff}.segmented button:focus-visible{outline:3px solid #b55b12;outline-offset:-3px}
|
||||
@@ -174,6 +174,7 @@ export function repairSchema(document) {
|
||||
if (block.anchor) upgradeDocument(document, 21);
|
||||
if ('collapse' in props) upgradeDocument(document, 22);
|
||||
if ('faq' in props) upgradeDocument(document, 23);
|
||||
if (block.type === 'button' && ('icon' in props || 'iconPosition' in props)) upgradeDocument(document, 25);
|
||||
walk(block.children);
|
||||
}
|
||||
};
|
||||
@@ -342,7 +343,7 @@ export const devices = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobil' }
|
||||
// blieb bei 12 stehen, während Bausteine schon v13 bis v15 verlangten: in einem aktuellen Dokument
|
||||
// liess sich dann gar nichts mehr einfügen, und ein Symbol, ein Slider oder ein externer Inhalt
|
||||
// nirgends. Eine Zahl, an einer Stelle.
|
||||
export const SCHEMA_VERSION = 24;
|
||||
export const SCHEMA_VERSION = 25;
|
||||
|
||||
export const VISIBILITY = { all: 'Auf allen Geräten', desktop: 'Nur Desktop', tablet: 'Nur Tablet', mobile: 'Nur Mobil', 'hide-desktop': 'Auf Desktop ausblenden', 'hide-tablet': 'Auf Tablet ausblenden', 'hide-mobile': 'Auf Mobil ausblenden', hidden: 'Überall ausblenden' };
|
||||
|
||||
|
||||
+37
-4
@@ -11,8 +11,8 @@ import { initMotion } from './motion.js';
|
||||
import { initSliders } from './slider.js';
|
||||
import { VISIBILITY, setVisibility, registerPlugins, pluginDefinition, PLUGIN_PRESETS, repairSchema } from './editor-model.js';
|
||||
|
||||
// Mirrors DocumentValidator::ICONS; the server rejects anything outside this list.
|
||||
const ICONS = ['app', 'web', 'design', 'network', 'hardware', 'shop', 'support', 'shield', 'clock', 'star', 'phone', 'mail', 'pin', 'check', 'people', 'music', 'home'];
|
||||
// Symbole, ihre deutschen Namen und die Kachelauswahl. Die Liste spiegelt DocumentValidator::ICONS.
|
||||
import { renderIconPicker, BUTTON_ICON_GROUPS, ALL_ICON_GROUPS } 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' };
|
||||
@@ -346,6 +346,21 @@ 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) => {
|
||||
history.change(doc => {
|
||||
const current = locate(doc.blocks, selected)?.block;
|
||||
if (!current) return;
|
||||
(current.props ??= {})[key] = next;
|
||||
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 });
|
||||
};
|
||||
const iconField = (label, value, groups, options = {}) => panel.append(renderIconPicker({
|
||||
label, value, groups, none: options.none, onChoose: name => choose('icon', name, options.version),
|
||||
}));
|
||||
if (!block) {
|
||||
field('Seitentitel', 'title', history.document.title, { maxLength: 160 });
|
||||
field('Beschreibung für Suchmaschinen', 'description', history.document.description, { multiline: true, maxLength: 320 });
|
||||
@@ -385,6 +400,24 @@ function renderContent(block, panel) {
|
||||
panel.append(label, rich.element, help);
|
||||
}
|
||||
if (['heading', 'button'].includes(block.type)) field(block.type === 'button' ? 'Beschriftung' : 'Text', 'text', block.props.text, { multiline: block.type !== 'button' });
|
||||
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 });
|
||||
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);
|
||||
}
|
||||
}
|
||||
if (block.type === 'button' && designSchema) buttonControls(block, panel);
|
||||
if (INLINE_TEXT[block.type]) {
|
||||
const inline = document.createElement('button');
|
||||
@@ -399,7 +432,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') {
|
||||
field('Symbol', 'icon', block.props.icon, { choices: Object.fromEntries(ICONS.map(name => [name, name])) });
|
||||
iconField('Symbol', block.props.icon, ALL_ICON_GROUPS);
|
||||
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.' });
|
||||
@@ -434,7 +467,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') {
|
||||
field('Symbol', 'icon', block.props.icon, { choices: Object.fromEntries(ICONS.map(name => [name, name])) });
|
||||
iconField('Symbol', block.props.icon, ALL_ICON_GROUPS);
|
||||
// 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.' });
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
// Die Zeichnungen der Symbole für die Auswahl im Editor. Die Website zeichnet dieselben Pfade aus
|
||||
// templates/blocks/icon.html.twig; ein Test hält beide Listen und DocumentValidator::ICONS gleich.
|
||||
export const ICON_PATHS = {
|
||||
app: 'M7 2h10a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Zm4 17h2',
|
||||
web: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 0c2.5 2.4 3.8 5.4 3.8 9s-1.3 6.6-3.8 9m0-18C9.5 5.4 8.2 8.4 8.2 12s1.3 6.6 3.8 9M3.2 9h17.6M3.2 15h17.6',
|
||||
design: 'M4 20h16M6 16 18 4l3 3L9 19l-4 1Z',
|
||||
network: 'M5 3h14v5H5Zm0 13h14v5H5Zm7-8v8M8 8v3h8V8',
|
||||
hardware: 'M4 5h16v11H4Zm4 15h8m-4-4v4',
|
||||
shop: 'M4 7h16l-1.4 11.2a2 2 0 0 1-2 1.8H7.4a2 2 0 0 1-2-1.8Zm4 0a4 4 0 0 1 8 0',
|
||||
support: 'M12 3a7 7 0 0 0-7 7v5m14-5a7 7 0 0 0-7-7m7 7v5m-14 0a2 2 0 0 0 2 2h1v-5H5Zm14 0a2 2 0 0 1-2 2h-1v-5h3Z',
|
||||
shield: 'M12 3 5 6v6c0 4.4 2.9 7.9 7 9 4.1-1.1 7-4.6 7-9V6Zm-2.5 9 2 2 4-4.5',
|
||||
clock: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 4v5.5l3.5 2',
|
||||
star: 'm12 3 2.7 5.9 6.3.7-4.7 4.3 1.3 6.1L12 17l-5.6 3 1.3-6.1L3 9.6l6.3-.7Z',
|
||||
phone: 'M6 3h4l2 5-2.5 1.5a12 12 0 0 0 5 5L16 12l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 4 5a2 2 0 0 1 2-2Z',
|
||||
mail: 'M3 6h18v12H3Zm0 1 9 7 9-7',
|
||||
pin: 'M12 21s7-6.3 7-11a7 7 0 1 0-14 0c0 4.7 7 11 7 11Zm0-8.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z',
|
||||
check: 'm4 12.5 5.5 5.5L20 6',
|
||||
people: 'M9.5 11.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7ZM2.5 20v-1c0-3.3 3-5 7-5s7 1.7 7 5v1M16.5 5.3a3.2 3.2 0 0 1 0 6.4M18 14.4c2.1.7 3.5 2.1 3.5 4.1V20',
|
||||
music: 'M10 18V5.5L20 3v12.5M10 9.5 20 7M10 18a3 2.5 0 1 0-6 0 3 2.5 0 0 0 6 0ZM20 15.5a3 2.5 0 1 0-6 0 3 2.5 0 0 0 6 0Z',
|
||||
home: 'm3 11.5 9-7.5 9 7.5M5.5 10v10h13V10M10 20v-6.5h4V20',
|
||||
'arrow-right': 'M4 12h15.5m-6.5-6.5 6.5 6.5-6.5 6.5',
|
||||
'arrow-left': 'M20 12H4.5M11 5.5 4.5 12 11 18.5',
|
||||
'arrow-up-right': 'M6.5 17.5 17.5 6.5M8.5 6.5h9v9',
|
||||
'arrow-down': 'M12 4v15.5m-6.5-6.5 6.5 6.5 6.5-6.5',
|
||||
'chevron-right': 'm9 5.5 6.5 6.5L9 18.5',
|
||||
calendar: 'M4 6h16v14H4Zm0 5h16M8 3.5v4m8-4v4',
|
||||
chat: 'M4 5h16v11H9.5L5 19.5V16H4Z',
|
||||
send: 'M21 3 10 14M21 3l-6.5 18-4.5-7-7-4.5Z',
|
||||
download: 'M12 3.5v12m-5-5 5 5 5-5M4.5 20h15',
|
||||
document: 'M6 3h8l4 4v14H6Zm8 0v4h4M9 12h6m-6 4h6',
|
||||
play: 'M8 5.5v13l10-6.5Z',
|
||||
search: 'M10.5 4a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13Zm4.8 11.3L20 20',
|
||||
heart: 'M12 20s-7.5-4.6-7.5-10A4.3 4.3 0 0 1 12 7.4 4.3 4.3 0 0 1 19.5 10c0 5.4-7.5 10-7.5 10Z',
|
||||
info: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 8v5.5m0-9v.5',
|
||||
plus: 'M12 5v14M5 12h14',
|
||||
user: 'M12 11.5a4 4 0 1 0 0-8 4 4 0 0 0 0 8ZM4.5 20.5c0-3.9 3.4-6 7.5-6s7.5 2.1 7.5 6',
|
||||
};
|
||||
|
||||
export const ICON_LABELS = {
|
||||
app: 'Smartphone', web: 'Globus', design: 'Stift', network: 'Netzwerk', hardware: 'Bildschirm',
|
||||
shop: 'Einkaufstasche', support: 'Kopfhörer', shield: 'Schutz', clock: 'Uhr', star: 'Stern',
|
||||
phone: 'Telefon', mail: 'E-Mail', pin: 'Ort', check: 'Häkchen', people: 'Personen', music: 'Musik',
|
||||
home: 'Haus', 'arrow-right': 'Pfeil nach rechts', 'arrow-left': 'Pfeil nach links',
|
||||
'arrow-up-right': 'Pfeil schräg', 'arrow-down': 'Pfeil nach unten', 'chevron-right': 'Winkel',
|
||||
calendar: 'Kalender', chat: 'Nachricht', send: 'Senden', download: 'Download', document: 'Dokument',
|
||||
play: 'Abspielen', search: 'Suche', heart: 'Herz', info: 'Info', plus: 'Plus', user: 'Person',
|
||||
};
|
||||
|
||||
/** Die Auswahl am Button: die Symbole, die man an einem Knopf wirklich braucht, nach Zweck geordnet. */
|
||||
export const BUTTON_ICON_GROUPS = [
|
||||
['Pfeile', ['arrow-up-right', 'arrow-right', 'chevron-right', 'arrow-down', 'arrow-left']],
|
||||
['Kontakt', ['phone', 'mail', 'chat', 'send', 'calendar', 'pin']],
|
||||
['Allgemein', ['download', 'document', 'play', 'search', 'check', 'star', 'heart', 'info', 'plus', 'user', 'home', 'shop', 'clock']],
|
||||
];
|
||||
|
||||
/** Alle Symbole für Symbol und Symbolkarte, in derselben Ordnung plus die übrigen. */
|
||||
export const ALL_ICON_GROUPS = [
|
||||
...BUTTON_ICON_GROUPS,
|
||||
['Weitere', ['app', 'web', 'design', 'network', 'hardware', 'support', 'shield', 'people', 'music']],
|
||||
];
|
||||
|
||||
const svgNS = 'http://www.w3.org/2000/svg';
|
||||
|
||||
export function iconSvg(name) {
|
||||
const svg = document.createElementNS(svgNS, 'svg');
|
||||
svg.setAttribute('viewBox', '0 0 24 24');
|
||||
svg.setAttribute('aria-hidden', 'true');
|
||||
svg.setAttribute('focusable', 'false');
|
||||
const path = document.createElementNS(svgNS, 'path');
|
||||
path.setAttribute('d', ICON_PATHS[name] ?? '');
|
||||
svg.append(path);
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Kachelauswahl statt einer Liste aus Namen: jedes Symbol ist ein Knopf mit seiner Zeichnung,
|
||||
* der gewählte ist markiert. Pfeiltasten wandern innerhalb der Gruppe, Tab verlässt die Auswahl.
|
||||
*
|
||||
* @param {{ label: string, value: string, groups: Array<[string, string[]]>, none?: string, onChoose: (name: string) => void }} options
|
||||
*/
|
||||
export function renderIconPicker({ label, value, groups, none, onChoose }) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'icon-picker';
|
||||
const heading = document.createElement('p');
|
||||
heading.className = 'icon-picker-label';
|
||||
heading.id = `icon-picker-${Math.random().toString(36).slice(2, 8)}`;
|
||||
heading.textContent = label;
|
||||
wrap.append(heading);
|
||||
const tiles = [];
|
||||
const tile = name => {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = name === 'none' ? 'icon-tile icon-tile-none' : 'icon-tile';
|
||||
button.dataset.icon = name;
|
||||
const text = name === 'none' ? none : ICON_LABELS[name];
|
||||
button.title = text;
|
||||
button.setAttribute('aria-label', text);
|
||||
button.setAttribute('aria-pressed', String(name === value));
|
||||
button.tabIndex = name === value ? 0 : -1;
|
||||
if (name === 'none') {
|
||||
const mark = document.createElement('span');
|
||||
mark.className = 'icon-tile-empty';
|
||||
mark.setAttribute('aria-hidden', 'true');
|
||||
button.append(mark);
|
||||
} else button.append(iconSvg(name));
|
||||
button.addEventListener('click', () => onChoose(name));
|
||||
tiles.push(button);
|
||||
return button;
|
||||
};
|
||||
const group = document.createElement('div');
|
||||
group.setAttribute('role', 'group');
|
||||
group.setAttribute('aria-labelledby', heading.id);
|
||||
if (none) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'icon-grid';
|
||||
row.append(tile('none'));
|
||||
group.append(row);
|
||||
}
|
||||
for (const [title, names] of groups) {
|
||||
const caption = document.createElement('p');
|
||||
caption.className = 'icon-group-title';
|
||||
caption.textContent = title;
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'icon-grid';
|
||||
for (const name of names) grid.append(tile(name));
|
||||
group.append(caption, grid);
|
||||
}
|
||||
// Ohne gewählten Knopf in der Liste (etwa ein Symbol außerhalb der Gruppen) bleibt der erste
|
||||
// erreichbar, sonst käme man mit Tab gar nicht hinein.
|
||||
if (!tiles.some(button => button.tabIndex === 0)) tiles[0].tabIndex = 0;
|
||||
group.addEventListener('keydown', event => {
|
||||
const index = tiles.indexOf(document.activeElement);
|
||||
if (index < 0) return;
|
||||
const step = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[event.key];
|
||||
const target = event.key === 'Home' ? 0 : event.key === 'End' ? tiles.length - 1 : step ? (index + step + tiles.length) % tiles.length : null;
|
||||
if (target === null) return;
|
||||
event.preventDefault();
|
||||
tiles[index].tabIndex = -1;
|
||||
tiles[target].tabIndex = 0;
|
||||
tiles[target].focus();
|
||||
});
|
||||
wrap.append(group);
|
||||
return wrap;
|
||||
}
|
||||
@@ -47,8 +47,8 @@ p{hyphens:auto}
|
||||
/* Der Pfeil am Button und in der Beitragsnavigation ist dasselbe Symbol wie ueberall sonst -
|
||||
frueher stand dort ein Schriftzeichen, das je nach Schrift anders aussah. Er waechst mit der
|
||||
Schrift des Knopfs, statt eine eigene Groesse zu haben. */
|
||||
.button-arrow,.nav-arrow{display:inline-flex;align-items:center}
|
||||
.button-arrow>svg,.nav-arrow>svg{width:1.15em;height:1.15em}
|
||||
.button-icon,.nav-arrow{display:inline-flex;align-items:center}
|
||||
.button-icon>svg,.nav-arrow>svg{width:1.15em;height:1.15em}
|
||||
.block-iconcard{display:grid;gap:12px;padding:26px;border:1px solid currentColor;border-radius:12px}
|
||||
.block-iconcard + .block-iconcard,.block-quote + .block-quote,.block-cta + .block-cta,.block-iconcard + .block-quote,.block-quote + .block-iconcard{margin-top:18px}
|
||||
/* Gestapelt brauchen Karten den Abstand; nebeneinander in einer Zeile macht er die zweite bis
|
||||
@@ -269,7 +269,7 @@ body.consent-open{padding-bottom:320px}
|
||||
.block-icon .block-icon{color:inherit}
|
||||
/* Der Pfeil im Knopf ist dasselbe Symbol und nahm deshalb die Akzentfarbe statt der Schriftfarbe
|
||||
des Knopfs: grün auf Rot, auch beim Überfahren (Hüfner, 06.10.2026). */
|
||||
.button-arrow .block-icon{color:inherit}
|
||||
.button-icon .block-icon{color:inherit}
|
||||
.icon-size-12{width:12px;height:12px}.icon-size-16{width:16px;height:16px}.icon-size-20{width:20px;height:20px}
|
||||
.icon-size-24{width:24px;height:24px}.icon-size-32{width:32px;height:32px}.icon-size-40{width:40px;height:40px}
|
||||
.icon-size-48{width:48px;height:48px}.icon-size-56{width:56px;height:56px}.icon-size-64{width:64px;height:64px}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// Prüft die Symbolwahl am Button im echten Editor: Kachel wählen, Stellung umschalten, kein Symbol,
|
||||
// Tastatur, Rückgängig, Speichern und Neuladen - auf einer isolierten Kopie der Pilotdaten.
|
||||
import { chromium } from 'playwright';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import net from 'node:net';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], { windowsHide: true, stdio: 'ignore', env: { ...env, SITE_ORIGIN: origin } });
|
||||
const report = 'reports/button-icons'; await mkdir(report, { recursive: true });
|
||||
let browser, page;
|
||||
const checks = [], errors = [];
|
||||
|
||||
try {
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
try { if ((await fetch(origin)).ok) break; } catch {}
|
||||
if (attempt === 59) throw new Error('Pilot test server did not start');
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
}
|
||||
browser = await chromium.launch();
|
||||
const context = await browser.newContext({ viewport: { width: 1600, height: 1050 } });
|
||||
page = await context.newPage(); page.setDefaultTimeout(15000); page.on('pageerror', error => errors.push(error.message));
|
||||
const selectBlock = async id => {
|
||||
await page.getByRole('tab', { name: 'Struktur', exact: true }).click();
|
||||
await page.locator(`#structure [data-block-id="${id}"]`).click();
|
||||
await page.locator(`#structure [data-edit-block="${id}"]`).click();
|
||||
};
|
||||
await page.goto(origin + '/admin/login');
|
||||
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
|
||||
await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin');
|
||||
const api = `${origin}/admin/api/pages/${fixture.homeId}`;
|
||||
const data = await (await context.request.get(api)).json();
|
||||
const startVersion = data.document.schemaVersion;
|
||||
data.document.blocks.unshift({ id: 'qa-icons', type: 'section', children: [
|
||||
{ id: 'qa-icon-button', type: 'button', props: { text: 'Jetzt anrufen', href: 'tel:+4972296992826' } },
|
||||
] });
|
||||
assert.equal((await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': data.csrfToken }, data: { document: data.document, expectedVersion: data.version } })).status(), 200);
|
||||
|
||||
// Was die Vorschau im Button zeigt: Reihenfolge von Text und Symbol und die Zeichnung.
|
||||
const preview = () => page.evaluate(() => {
|
||||
const el = document.querySelector('#preview')?.contentDocument?.getElementById('qa-icon-button');
|
||||
if (!el) return null;
|
||||
return { first: el.firstElementChild?.className ?? null, text: el.textContent.trim(), path: el.querySelector('svg path')?.getAttribute('d') ?? null, firstNode: el.firstChild?.nodeType };
|
||||
});
|
||||
const waitPreview = async expected => page.waitForFunction(expected => {
|
||||
const el = document.querySelector('#preview')?.contentDocument?.getElementById('qa-icon-button');
|
||||
if (!el) return false;
|
||||
const path = el.querySelector('svg path')?.getAttribute('d') ?? null;
|
||||
const iconFirst = el.firstChild?.nodeType === 1;
|
||||
return (expected.path === null ? path === null : path?.startsWith(expected.path)) && (expected.first === undefined || iconFirst === expected.first);
|
||||
}, expected);
|
||||
|
||||
await page.goto(`${origin}/admin/pages/${fixture.homeId}`);
|
||||
await selectBlock('qa-icon-button');
|
||||
const picker = page.locator('.icon-picker');
|
||||
await picker.waitFor();
|
||||
// Ohne Wahl: der bisherige Pfeil ist markiert und steht in der Vorschau hinter dem Text.
|
||||
assert.equal(await picker.locator('[aria-pressed="true"]').getAttribute('data-icon'), 'arrow-up-right');
|
||||
await waitPreview({ path: 'M6.5 17.5', first: false });
|
||||
assert.equal(await picker.locator('.icon-tile').count(), 25, 'Kein Symbol plus 24 Symbole in drei Gruppen');
|
||||
assert.deepEqual(await picker.locator('.icon-group-title').allTextContents(), ['Pfeile', 'Kontakt', 'Allgemein']);
|
||||
assert.equal(await page.locator('#property-icon').count(), 0, 'Keine Auswahlliste mehr');
|
||||
await page.locator('.properties-panel, #properties, aside').first().screenshot({ path: `${report}/vorher.png` }).catch(() => {});
|
||||
checks.push('ohne Wahl: Pfeil ↗ markiert und hinter dem Text, Kacheln statt Liste');
|
||||
|
||||
await picker.getByRole('button', { name: 'Telefon', exact: true }).click();
|
||||
await waitPreview({ path: 'M6 3h4', first: false });
|
||||
assert.equal(await page.locator('.icon-tile[data-icon="phone"]').getAttribute('aria-pressed'), 'true');
|
||||
assert.equal(await page.evaluate(() => document.activeElement?.dataset.icon), 'phone', 'Fokus bleibt auf der gewählten Kachel');
|
||||
await page.getByRole('button', { name: 'Vor dem Text', exact: true }).click();
|
||||
await waitPreview({ path: 'M6 3h4', first: true });
|
||||
checks.push('Telefon gewählt, dann vor den Text gestellt - Vorschau folgt');
|
||||
|
||||
// Tastatur: Pfeiltasten wandern durch die Kacheln, Enter wählt.
|
||||
await page.locator('.icon-tile[data-icon="phone"]').focus();
|
||||
await page.keyboard.press('ArrowRight');
|
||||
assert.equal(await page.evaluate(() => document.activeElement?.dataset.icon), 'mail');
|
||||
await page.keyboard.press('Enter');
|
||||
await waitPreview({ path: 'M3 6h18', first: true });
|
||||
checks.push('Pfeiltaste und Enter wählen E-Mail');
|
||||
|
||||
await page.locator('#undo').click(); await waitPreview({ path: 'M6 3h4', first: true });
|
||||
await page.locator('#redo').click(); await waitPreview({ path: 'M3 6h18', first: true });
|
||||
checks.push('Rückgängig und Wiederholen');
|
||||
|
||||
await page.getByRole('button', { name: 'Kein Symbol', exact: true }).click();
|
||||
await waitPreview({ path: null });
|
||||
assert.equal(await page.getByRole('button', { name: 'Vor dem Text', exact: true }).count(), 0, 'Ohne Symbol keine Stellung');
|
||||
assert.equal((await preview()).text, 'Jetzt anrufen');
|
||||
await page.locator('#undo').click(); await waitPreview({ path: 'M3 6h18', first: true });
|
||||
await picker.getByRole('button', { name: 'Pfeil nach rechts', exact: true }).click();
|
||||
await waitPreview({ path: 'M4 12h15.5', first: true });
|
||||
await page.locator('.icon-picker').screenshot({ path: `${report}/auswahl.png` });
|
||||
await page.locator('.icon-position').screenshot({ path: `${report}/stellung.png` });
|
||||
checks.push('kein Symbol, dann Pfeil nach rechts');
|
||||
|
||||
await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
|
||||
const stored = (await (await context.request.get(api)).json()).document;
|
||||
assert.equal(stored.schemaVersion, 25, `vorher v${startVersion}`);
|
||||
assert.deepEqual(stored.blocks[0].children[0].props, { text: 'Jetzt anrufen', href: 'tel:+4972296992826', icon: 'arrow-right', iconPosition: 'start' });
|
||||
await page.reload(); await selectBlock('qa-icon-button');
|
||||
await page.locator('.icon-picker').waitFor();
|
||||
assert.equal(await page.locator('.icon-tile[aria-pressed="true"]').getAttribute('data-icon'), 'arrow-right');
|
||||
assert.equal(await page.locator('.icon-position [aria-pressed="true"]').getAttribute('data-position'), 'start');
|
||||
await waitPreview({ path: 'M4 12h15.5', first: true });
|
||||
checks.push('gespeichert als Schema v25, nach Neuladen unverändert');
|
||||
|
||||
assert.deepEqual(errors, []);
|
||||
await writeFile(`${report}/report.json`, JSON.stringify({ origin, checks }, null, 2));
|
||||
console.log(checks.map(check => `✓ ${check}`).join('\n'));
|
||||
} finally {
|
||||
await browser?.close();
|
||||
server.kill();
|
||||
}
|
||||
@@ -16,7 +16,9 @@ final class DocumentValidator
|
||||
/** Blocks introduced with schema v6 for the sections a marketing page actually needs. */
|
||||
public const V6_TYPES = ['iconcard', 'stat', 'quote', 'logos', 'cta', 'contact', 'divider', 'spacer', 'accordion', 'accordionitem'];
|
||||
/** Icons ship as inline SVG from this fixed list; no icon font and no external request. */
|
||||
public const ICONS = ['app', 'web', 'design', 'network', 'hardware', 'shop', 'support', 'shield', 'clock', 'star', 'phone', 'mail', 'pin', 'check', 'people', 'music', 'home', 'arrow-right', 'arrow-left', 'arrow-up-right'];
|
||||
public const ICONS = ['app', 'web', 'design', 'network', 'hardware', 'shop', 'support', 'shield', 'clock', 'star', 'phone', 'mail', 'pin', 'check', 'people', 'music', 'home', 'arrow-right', 'arrow-left', 'arrow-up-right', 'arrow-down', 'chevron-right', 'calendar', 'chat', 'send', 'download', 'document', 'play', 'search', 'heart', 'info', 'plus', 'user'];
|
||||
/** Das Symbol eines Buttons, wenn keins gewählt ist: der Pfeil, den jeder Button vor v25 hatte. */
|
||||
public const BUTTON_DEFAULT_ICON = 'arrow-up-right';
|
||||
public const SPACER_SIZES = ['small', 'medium', 'large', 'huge'];
|
||||
/** Schema v7: tabs, the one pattern that needs a script to become a real widget. */
|
||||
public const V7_TYPES = ['tabs', 'tabitem'];
|
||||
@@ -40,7 +42,7 @@ final class DocumentValidator
|
||||
public const V23_TYPES = ['social'];
|
||||
public const V24_TYPES = ['html'];
|
||||
/** Die aktuelle Fassung des Dokumentschemas - dieselbe Zahl wie SCHEMA_VERSION in editor-model.js. */
|
||||
public const SCHEMA_VERSION = 24;
|
||||
public const SCHEMA_VERSION = 25;
|
||||
|
||||
/**
|
||||
* @param bool $requireHeading A page needs exactly one main heading. A header, a footer or a
|
||||
@@ -237,6 +239,14 @@ final class DocumentValidator
|
||||
}
|
||||
if ('button' === $type) {
|
||||
$this->link($props['href'] ?? null);
|
||||
// Schema v25: das Symbol am Button ist wählbar. Fehlt es, bleibt es beim Pfeil wie
|
||||
// bisher - ältere Dokumente sehen also unverändert aus. "none" heißt ohne Symbol.
|
||||
if (array_key_exists('icon', $props)) {
|
||||
$this->require($schemaVersion >= 25 && ('none' === $props['icon'] || in_array($props['icon'], self::ICONS, true)), 'Unbekanntes Symbol am Button oder Dokumentschema vor v25.');
|
||||
}
|
||||
if (array_key_exists('iconPosition', $props)) {
|
||||
$this->require($schemaVersion >= 25 && in_array($props['iconPosition'], ['start', 'end'], true), 'Das Symbol steht vor oder nach dem Text und braucht Schema v25.');
|
||||
}
|
||||
}
|
||||
if ('iconcard' === $type) {
|
||||
$this->require(in_array($props['icon'] ?? null, self::ICONS, true), 'Unknown icon.');
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
Importzeile sie kennen muss. Sie steht vor jedem Modul-Skript; spaeter geladen wirkt sie
|
||||
nicht mehr. #}
|
||||
<script type="importmap" nonce="{{ csp_nonce() }}">{"imports":{
|
||||
{%- for modul in ['editor-model', 'media', 'design-panel', 'button-design', 'color-control', 'rich-text', 'canvas-overlay', 'library-previews', 'preview-containers', 'page-picker', 'motion', 'slider', 'live-search'] %}
|
||||
{%- for modul in ['editor-model', 'media', 'design-panel', 'button-design', 'color-control', 'rich-text', 'canvas-overlay', 'library-previews', 'preview-containers', 'page-picker', 'motion', 'slider', 'live-search', 'icons'] %}
|
||||
"/assets/{{ modul }}.js": "/assets/{{ modul }}.js?v={{ kant_version() }}"{{ loop.last ? '' : ',' }}
|
||||
{%- endfor %}
|
||||
}}</script>
|
||||
|
||||
@@ -26,7 +26,13 @@
|
||||
{% endif %}
|
||||
{% elseif type == 'button' %}
|
||||
{% for device, settings in layout.button|default({}) %}{% for key, value in settings %}{% set designClasses = designClasses ~ ' b-' ~ device ~ '-' ~ key ~ '-' ~ value %}{% endfor %}{% endfor %}
|
||||
<a id="{{ block.id }}" class="button cms-button{{ designClasses }}" href="{{ props.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: props.newTab|default(false)} only %}>{{ props.text }} <span class="button-arrow" aria-hidden="true">{% include 'blocks/icon.html.twig' with {name: 'arrow-up-right'} only %}</span></a>
|
||||
{# Schema v25: Symbol wählbar. Ohne Angabe der Pfeil wie bisher; er behält die Klasse
|
||||
button-arrow, damit eigenes CSS älterer Websites (ausblenden, drehen) weiter greift. #}
|
||||
{% set buttonIcon = props.icon|default('arrow-up-right') %}
|
||||
{% set iconMarkup = '' %}
|
||||
{% if buttonIcon != 'none' %}{% set iconMarkup %}<span class="button-icon{{ buttonIcon == 'arrow-up-right' ? ' button-arrow' }}" aria-hidden="true">{% include 'blocks/icon.html.twig' with {name: buttonIcon} only %}</span>{% endset %}{% endif %}
|
||||
{% set iconFirst = props.iconPosition|default('end') == 'start' %}
|
||||
<a id="{{ block.id }}" class="button cms-button{{ designClasses }}" href="{{ props.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: props.newTab|default(false)} only %}>{% if iconMarkup and iconFirst %}{{ iconMarkup }} {% endif %}{{ props.text }}{% if iconMarkup and not iconFirst %} {{ iconMarkup }}{% endif %}</a>
|
||||
{% elseif type == 'image' %}
|
||||
{# Wie breit das Bild tatsaechlich erscheint. Steht im Block eine feste Breite - ein Logo mit
|
||||
190px, ein Symbol mit 72px -, dann ist das die Antwort, und der Browser laedt die kleine
|
||||
|
||||
@@ -19,6 +19,19 @@
|
||||
home: 'm3 11.5 9-7.5 9 7.5M5.5 10v10h13V10M10 20v-6.5h4V20',
|
||||
'arrow-right': 'M4 12h15.5m-6.5-6.5 6.5 6.5-6.5 6.5',
|
||||
'arrow-left': 'M20 12H4.5M11 5.5 4.5 12 11 18.5',
|
||||
'arrow-up-right': 'M6.5 17.5 17.5 6.5M8.5 6.5h9v9'
|
||||
'arrow-up-right': 'M6.5 17.5 17.5 6.5M8.5 6.5h9v9',
|
||||
'arrow-down': 'M12 4v15.5m-6.5-6.5 6.5 6.5 6.5-6.5',
|
||||
'chevron-right': 'm9 5.5 6.5 6.5L9 18.5',
|
||||
calendar: 'M4 6h16v14H4Zm0 5h16M8 3.5v4m8-4v4',
|
||||
chat: 'M4 5h16v11H9.5L5 19.5V16H4Z',
|
||||
send: 'M21 3 10 14M21 3l-6.5 18-4.5-7-7-4.5Z',
|
||||
download: 'M12 3.5v12m-5-5 5 5 5-5M4.5 20h15',
|
||||
document: 'M6 3h8l4 4v14H6Zm8 0v4h4M9 12h6m-6 4h6',
|
||||
play: 'M8 5.5v13l10-6.5Z',
|
||||
search: 'M10.5 4a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13Zm4.8 11.3L20 20',
|
||||
heart: 'M12 20s-7.5-4.6-7.5-10A4.3 4.3 0 0 1 12 7.4 4.3 4.3 0 0 1 19.5 10c0 5.4-7.5 10-7.5 10Z',
|
||||
info: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 8v5.5m0-9v.5',
|
||||
plus: 'M12 5v14M5 12h14',
|
||||
user: 'M12 11.5a4 4 0 1 0 0-8 4 4 0 0 0 0 8ZM4.5 20.5c0-3.9 3.4-6 7.5-6s7.5 2.1 7.5 6'
|
||||
} %}
|
||||
<svg class="block-icon" viewBox="0 0 24 24" width="32" height="32" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="{{ paths[name] }}"/></svg>
|
||||
+52
-1
@@ -273,7 +273,58 @@ final class CmsTest extends TestCase
|
||||
public function testTheButtonArrowTakesTheColourOfTheButtonText(): void
|
||||
{
|
||||
// Der Pfeil ist ein Symbol (block-icon) und bekam die Akzentfarbe: grün auf einem roten Knopf.
|
||||
self::assertStringContainsString('.button-arrow .block-icon{color:inherit}', (string) file_get_contents(__DIR__.'/../public/assets/site.css'));
|
||||
self::assertStringContainsString('.button-icon .block-icon{color:inherit}', (string) file_get_contents(__DIR__.'/../public/assets/site.css'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema v25: Umut wollte Buttons mit Symbol und den festen Pfeil ändern können. Ohne Wahl
|
||||
* bleibt alles wie bisher - der Pfeil mit der Klasse button-arrow, auf die eigenes CSS älterer
|
||||
* Websites zielt (Hüfner blendet ihn so aus).
|
||||
*/
|
||||
public function testAButtonCarriesTheChosenIconBeforeOrAfterItsTextOrNone(): void
|
||||
{
|
||||
$document = DemoContent::pages()['home'];
|
||||
$button = $document['blocks'][0]['children'][3];
|
||||
self::assertSame('button', $button['type']);
|
||||
$id = $this->pages->saveDraft('page-1', $document, 0);
|
||||
$this->pages->publish('page-1', $id, 1);
|
||||
$html = (string) $this->request('/')->getContent();
|
||||
self::assertMatchesRegularExpression('/<a id="'.$button['id'].'"[^>]*>[^<]+ <span class="button-icon button-arrow" aria-hidden="true"><svg class="block-icon"[^>]*><path d="M6\.5 17\.5/', $html, 'Ohne Wahl: der schräge Pfeil hinter dem Text.');
|
||||
|
||||
$document['schemaVersion'] = 25;
|
||||
$document['blocks'][0]['children'][3]['props'] += ['icon' => 'phone', 'iconPosition' => 'start'];
|
||||
$id = $this->pages->saveDraft('page-1', $document, 2);
|
||||
$this->pages->publish('page-1', $id, 3);
|
||||
$html = (string) $this->request('/')->getContent();
|
||||
self::assertMatchesRegularExpression('/<a id="'.$button['id'].'"[^>]*><span class="button-icon" aria-hidden="true"><svg class="block-icon"[^>]*><path d="M6 3h4l2 5[^"]*"\/><\/svg>\s*<\/span> '.preg_quote($button['props']['text'], '/').'<\/a>/', $html, 'Telefon vor dem Text, ohne die Pfeil-Klasse.');
|
||||
|
||||
$document['blocks'][0]['children'][3]['props']['icon'] = 'none';
|
||||
$id = $this->pages->saveDraft('page-1', $document, 4);
|
||||
$this->pages->publish('page-1', $id, 5);
|
||||
$html = (string) $this->request('/')->getContent();
|
||||
self::assertMatchesRegularExpression('/<a id="'.$button['id'].'"[^>]*>'.preg_quote($button['props']['text'], '/').'<\/a>/', $html, 'Kein Symbol: nur der Text.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string, mixed}>
|
||||
*/
|
||||
public static function rejectedButtonIcons(): iterable
|
||||
{
|
||||
yield 'Symbol vor v25' => ['icon', 'phone', 24];
|
||||
yield 'unbekanntes Symbol' => ['icon', 'whatsapp', 25];
|
||||
yield 'Symbol ist kein Text' => ['icon', ['phone'], 25];
|
||||
yield 'Stellung vor v25' => ['iconPosition', 'start', 24];
|
||||
yield 'unbekannte Stellung' => ['iconPosition', 'middle', 25];
|
||||
}
|
||||
|
||||
#[DataProvider('rejectedButtonIcons')]
|
||||
public function testAButtonRejectsUnknownIconsAndOlderSchemas(string $key, mixed $value, int $version): void
|
||||
{
|
||||
$document = DemoContent::pages()['home'];
|
||||
$document['schemaVersion'] = $version;
|
||||
$document['blocks'][0]['children'][3]['props'][$key] = $value;
|
||||
$this->expectException(InvalidDocument::class);
|
||||
(new DocumentValidator())->validate($document);
|
||||
}
|
||||
|
||||
#[DataProvider('invalidDocuments')]
|
||||
|
||||
@@ -656,4 +656,9 @@ test('a loaded document is raised to the schema its blocks and properties need',
|
||||
assert.equal(repairSchema(plain).schemaVersion, 5);
|
||||
const faq = { schemaVersion: 10, title: 'F', description: '', blocks: [{ id: 'a', type: 'accordion', props: { faq: true }, children: [] }] };
|
||||
assert.equal(repairSchema(faq).schemaVersion, 23);
|
||||
// Ein Button mit gewähltem Symbol braucht v25; ohne Symbol bleibt er bei seiner Fassung.
|
||||
const symbol = { schemaVersion: 24, title: 'S', description: '', blocks: [{ id: 'b', type: 'button', props: { text: 'Anrufen', href: 'tel:+49', icon: 'phone' } }] };
|
||||
assert.equal(repairSchema(symbol).schemaVersion, 25);
|
||||
const pfeil = { schemaVersion: 24, title: 'P', description: '', blocks: [{ id: 'b', type: 'button', props: { text: 'Mehr', href: '/' } }] };
|
||||
assert.equal(repairSchema(pfeil).schemaVersion, 24);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { ICON_PATHS, ICON_LABELS, BUTTON_ICON_GROUPS, ALL_ICON_GROUPS } from '../public/assets/icons.js';
|
||||
|
||||
const read = path => readFileSync(new URL(path, import.meta.url), 'utf8');
|
||||
|
||||
test('the editor draws the same icons the website renders and the server allows', () => {
|
||||
// Drei Listen, ein Satz: die Zeichnungen der Website (Twig), die Vorschau im Editor und die
|
||||
// erlaubten Namen im Validator. Weicht eine ab, zeigt die Auswahl ein anderes Symbol als die
|
||||
// Seite - oder bietet eins an, das der Server beim Speichern ablehnt.
|
||||
const twig = read('../templates/blocks/icon.html.twig');
|
||||
const website = Object.fromEntries([...twig.matchAll(/^\s{4}'?([a-z-]+)'?: '([^']+)',?$/gm)].map(([, name, path]) => [name, path]));
|
||||
assert.deepEqual(ICON_PATHS, website);
|
||||
const validator = read('../src/Content/DocumentValidator.php');
|
||||
const allowed = [...validator.match(/public const ICONS = \[([^\]]+)\]/)[1].matchAll(/'([a-z-]+)'/g)].map(([, name]) => name);
|
||||
assert.deepEqual(Object.keys(ICON_PATHS).sort(), [...allowed].sort());
|
||||
});
|
||||
|
||||
test('every icon has a German name and the pickers offer each icon exactly once', () => {
|
||||
assert.deepEqual(Object.keys(ICON_LABELS).sort(), Object.keys(ICON_PATHS).sort());
|
||||
const button = BUTTON_ICON_GROUPS.flatMap(([, names]) => names);
|
||||
assert.equal(new Set(button).size, button.length, 'Ein Symbol steht doppelt in der Button-Auswahl.');
|
||||
for (const name of button) assert.ok(ICON_PATHS[name], `${name} hat keine Zeichnung.`);
|
||||
// Der bisherige Pfeil steht vorn: er ist das, was ein Button ohne eigene Wahl trägt.
|
||||
assert.equal(button[0], 'arrow-up-right');
|
||||
const all = ALL_ICON_GROUPS.flatMap(([, names]) => names);
|
||||
assert.deepEqual([...all].sort(), Object.keys(ICON_PATHS).sort(), 'Symbol und Symbolkarte bieten jedes Symbol genau einmal an.');
|
||||
});
|
||||
Reference in new issue
Block a user