Compare commits

...
4 Commits
Author SHA1 Message Date
kantarciandClaude Opus 5.5 f00fbfef09 Release 1.25.0
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:01:18 +02:00
kantarciandClaude Opus 5.5 08998de533 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>
2026-10-07 10:01:18 +02:00
kantarciandClaude Opus 5.5 6af2e2aead Release 1.24.0
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:39:40 +02:00
kantarciandClaude Opus 5.5 88737a95a9 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>
2026-10-07 09:39:34 +02:00
22 changed files with 913 additions and 35 deletions

No files matched your search

+2
View File
@@ -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`. **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`.
**Symbole (ab 1.25.0):** Button, Symbol oder Symbolkarte auswählen → Inhalt → **Symbol** → **Ändern …**. Das Fenster zeigt 98 Symbole in sieben Kategorien, mit Namen; Pfeiltasten und Escape funktionieren, beim Button gibt es „Kein Symbol“. Am Button außerdem **Stellung** (vor/nach dem Text), **Beim Überfahren** (Ruhig, Rückt, Erscheint) und **Nur das Symbol zeigen** (quadratischer Knopf, die Beschriftung bleibt für Vorlesesoftware). Farbe, Fläche, Größe, Ecken, Drehung und Abstand stehen unter **Design → Symbol**, je Gerät und beim Überfahren. Ohne eigene Wahl trägt ein Button wie bisher den schrägen Pfeil. Neue Symbole kommen nach `public/assets/icons.js`, danach `npm run build:icons`. Prüfung mit isolierter Kant-IT-Kopie: `npm run test:button-icons`, Screenshots unter `reports/button-icons/`. Datenvertrag: ADR 108 und 109.
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. 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. 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.
+1 -1
View File
@@ -1 +1 @@
1.23.1 1.25.0
+28
View File
@@ -2,6 +2,34 @@
Stand: 15.09.2026. Arbeitstitel KantSites; Produktname offen. Stand: 15.09.2026. Arbeitstitel KantSites; Produktname offen.
## ADR 109 – Symbole durchdacht: 98 im Satz, Auswahl im Fenster, Gestaltung unter Design (07.10.2026)
Umut zu 1.24.0: „Du wolltest doch mehr Icons machen oder nicht? Und nicht alles so dort auflisten, erst onklick einblenden oder Modal […] Dann brauche ich Icon-Einstellungen ausgefüllt, extra Farbe usw. Mach es doch durchdacht.“ Zu Recht: 33 Symbole waren zu wenig, die Kachelwand drückte das Panel nach unten, und Farbe oder Größe des Symbols gingen nur über eigenes CSS.
**Symbolsatz.** 98 Symbole in sieben Kategorien (Pfeile, Kontakt & Ort, Handel & Geld, Dateien & Medien, Personen & Sicherheit, Haus & Handwerk, Allgemein). Neu sind unter anderem Warenkorb, Euro, Lieferung (LKW), Paket, Karte, Navigation, Hochladen, Drucken, Teilen, Kamera, Schloss, Auszeichnung, Werkzeug, Hammer, Malerrolle, Baum, Blatt, Sonne, Wasser, Strom, Solar, Auto und Warnung – also auch das LKW-Symbol, das bei Hüfner fehlte. Weiter keine Markenlogos. Eine Quelle: `public/assets/icons.js`. `npm run build:icons` schreibt daraus `icon.html.twig` und `DocumentValidator::ICONS`, `tests/icons.test.mjs` prüft die Gleichheit.
**Auswahl im Fenster.** Im Panel steht nur das gewählte Symbol mit Vorschau, Namen und „Ändern …“. Das öffnet ein modales Fenster (`<dialog>`): Kategorien als Reiter links, Symbole mit Namen als Kacheln, Öffnen bei der Kategorie des gewählten Symbols mit Fokus darauf, Pfeiltasten in Reitern und Raster, Escape oder Klick auf den Rand schließt ohne Änderung, der Fokus kehrt zurück. Beim Button gibt es dort „Kein Symbol“. Symbol und Symbolkarte nutzen dasselbe Fenster.
**Gestaltung unter Design (keine Schemaänderung).** Neue Gruppe „Symbol“ im Style-Schema mit dem Ziel `icon`: Symbolfarbe, Fläche hinter dem Symbol (beide auch beim Überfahren), Symbolgröße, Innenabstand und Ecken der Fläche, Drehung (auch beim Überfahren), dazu Abstand zum Text am Button selbst. Damit gelten Gerätewerte, Vererbung, Hover, „Gestaltung kopieren“ und die Farbpalette wie überall. Der Compiler schreibt `#id .button-icon{…}` und `#id:hover .button-icon,#id:focus-visible .button-icon{…}`. Größe und Abstand laufen über `--icon-size` und `--icon-gap`, die `site.css` mit 1,15 em und 0,65 em als Vorgabe liest – ohne Angabe sieht alles aus wie vorher. Die Gruppe erscheint nur bei Buttons mit Symbol und ist dort aufgeklappt; ein Knopf im Inhalt führt hin.
**Verhalten im Inhalt (Schema v26).** `iconMotion`: `none` (ruhig), `slide` (rückt beim Überfahren in Leserichtung) oder `reveal` (erscheint erst beim Überfahren). `iconOnly`: ein Button nur aus Symbol; die Beschriftung steht dann als `.button-label` ohne Breite im Link – sie bleibt der zugängliche Name und hält die Zeilenhöhe, sodass der Knopf quadratisch und genauso hoch ist wie Text-Buttons daneben (zuerst mit `aspect-ratio` versucht: das zog die Höhe auf die Breite herunter). `iconOnly` ohne Symbol lehnt der Validator ab, und die Wahl „Kein Symbol“ nimmt es im Editor mit weg. Bewegungen folgen „Bewegung reduzieren“.
**Geprüft:** PHPUnit vollständig (616), darunter `testAButtonIconMovesOnHoverOrStandsAlone`, `testButtonIconStylesTargetTheIconPerDeviceAndOnHover`, `testAButtonWithoutAnIconCannotBeIconOnly` und neue Ablehnungsfälle. `npm test` (38). `npm run test:button-icons` im Browser: Panel ohne Kachelwand, Fenster mit sieben Kategorien und axe ohne Befund, Escape, Pfeiltasten, Stellung, Bewegung mit Rückgängig, nur Symbol, Design-Gruppe mit allen Werten und wirksamer Größe, Speichern als v26 und Neuladen, gemessenes Aussehen (Flächenfarbe, Rundung, Drehung beim Überfahren, quadratischer Nur-Symbol-Button). `test:buttons` und `test:browser` (Hüfner, Entwicklungsmodus) grün, HTML 148 KB unter dem Budget.
## 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) ## 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. Beim Aufbau von Hüfner Gartenbau fielen zwei Lücken auf, die Umut im Kern geschlossen haben wollte.
+34
View File
@@ -1,5 +1,39 @@
# Projektstatus # Projektstatus
## Fassung 1.25.0: 98 Symbole, Auswahlfenster, Symbol-Gestaltung unter Design (07.10.2026)
**Anlass:** Umut zu 1.24.0: „Du wolltest doch mehr Icons machen oder nicht? Und nicht alles so dort auflisten, erst onklick einblenden oder Modal das gefällt mir nicht. Dann brauche ich Icon-Einstellungen ausgefüllt, extra Farbe usw. Mach es doch durchdacht.“
**Neu (ADR 109):**
- **98 Symbole** statt 33 in sieben Kategorien, darunter Warenkorb, Euro, LKW, Paket, Karte, Kamera, Schloss, Werkzeug, Malerrolle, Baum, Solar, Strom, Auto, Warnung. Quelle `public/assets/icons.js`, `npm run build:icons` schreibt Twig und Validator.
- **Auswahlfenster:** Im Panel nur das gewählte Symbol mit „Ändern …“. Das Fenster zeigt Kategorien als Reiter und Symbole mit Namen. Gilt für Button, Symbol und Symbolkarte.
- **Design → Symbol** (je Gerät, mit Hover): Farbe, Fläche dahinter, Größe, Innenabstand und Ecken der Fläche, Drehung, Abstand zum Text.
- **Inhalt (Schema v26):** Stellung, „Beim Überfahren: Ruhig / Rückt / Erscheint“, „Nur das Symbol zeigen“ (quadratischer Knopf, Beschriftung bleibt für Vorlesesoftware).
**Geprüft:** PHPUnit vollständig (616) grün, `npm test` (38) grün, `npm run test:button-icons` (Editor im Browser, inkl. axe im Fenster und gemessenem Aussehen), `test:buttons` grün, `test:browser` auf Hüfner (Entwicklungsmodus, Port 8102) grün mit HTML 148 KB. php-cs-fixer und `lint:twig` ohne Befund. Nicht geprüft: Lighthouse.
**Bekannt:** Dokumente mit Bewegung oder „nur Symbol“ brauchen einen Kern ab 1.25.0 (Schema v26). Die Gestaltungswerte (Farbe usw.) brauchen kein neues Schema, werden von älteren Kernen aber abgelehnt (unbekannte Stileigenschaft) – Installationen zuerst aktualisieren. `test:browser` verlangt den Entwicklungsmodus (Kopf `x-kant-queries`), nicht wie in der README beschrieben den Produktionsmodus.
**Nächster Schritt:** Push, Release 1.25.0, Installationen aktualisieren; bei Hüfner das LKW-Symbol aus „Eigenes HTML“ durch den Baustein „Symbol“ ersetzen.
## 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) ## 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). **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).
+2
View File
@@ -5,6 +5,8 @@
"scripts": { "scripts": {
"build:buttons": "node scripts/build-buttons.mjs", "build:buttons": "node scripts/build-buttons.mjs",
"test:buttons": "node scripts/button-check.mjs", "test:buttons": "node scripts/button-check.mjs",
"test:button-icons": "node scripts/button-icon-check.mjs",
"build:icons": "node scripts/build-icons.mjs",
"test:navigation-ux": "node scripts/navigation-ux-check.mjs", "test:navigation-ux": "node scripts/navigation-ux-check.mjs",
"test:responsive-navigation": "node scripts/responsive-navigation-check.mjs", "test:responsive-navigation": "node scripts/responsive-navigation-check.mjs",
"build:design": "node scripts/build-design.mjs", "build:design": "node scripts/build-design.mjs",
+1 -1
View File
@@ -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-label{margin:0 0 6px;font-size:13px;font-weight:700;color:#203b34}.icon-field{margin:16px 0 14px}.icon-field-row{display:grid;grid-template-columns:44px minmax(0,1fr) auto;align-items:center;gap:10px;padding:8px;border:1px solid #dce4dc;border-radius:10px;background:#fff}.icon-field-preview{display:grid;place-items:center;width:44px;height:44px;border-radius:8px;background:#eef5ea;color:#1d4d37}.icon-field-preview svg{width:24px;height:24px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}.icon-field-preview.is-empty{background:repeating-linear-gradient(135deg,#f3f5f2 0 6px,#fff 6px 12px);border:1px dashed #c5d2c7}.icon-field-name{font-size:14px;font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.icon-field-change{min-height:36px;white-space:nowrap}.icon-setting{margin:0 0 14px}.segmented{display:grid;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}.icon-design-link{display:block;margin:4px 0 8px;font-weight:600}dialog.icon-dialog{max-width:860px;width:calc(100% - 40px);padding:0;overflow:hidden}.icon-dialog::backdrop{background:#1b2a2466}.icon-dialog-head{display:flex;align-items:center;justify-content:space-between;padding:18px 22px;border-bottom:1px solid #dce4dc}.icon-dialog-head h2{margin:0;font-size:19px}.icon-dialog-close{display:grid;place-items:center;width:40px;height:40px;border:0;border-radius:8px;background:transparent;color:#203b34;cursor:pointer}.icon-dialog-close:hover{background:#eef3ec}.icon-dialog-close svg{width:22px;height:22px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round}.icon-dialog-body{display:grid;grid-template-columns:200px minmax(0,1fr);height:min(520px,calc(100vh - 220px))}.icon-dialog-tabs{display:flex;flex-direction:column;gap:2px;padding:12px;border-right:1px solid #dce4dc;background:#f7faf5;overflow:auto}.icon-dialog-tab{text-align:left;padding:10px 12px;border:0;border-radius:8px;background:transparent;color:#203b34;font-size:14px;font-weight:600;cursor:pointer}.icon-dialog-tab:hover{background:#eaf1e6}.icon-dialog-tab[aria-selected=true]{background:#2c6b4f;color:#fff}.icon-dialog-tab:focus-visible,.icon-choice:focus-visible,.icon-dialog-close:focus-visible{outline:3px solid #b55b12;outline-offset:2px}.icon-dialog-panel{padding:16px;overflow:auto}.icon-dialog-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(92px,1fr));gap:8px}.icon-choice{display:grid;justify-items:center;align-content:center;gap:8px;min-height:92px;padding:12px 6px;border:1px solid #dce4dc;border-radius:10px;background:#fff;color:#203b34;cursor:pointer;transition:border-color .15s,background-color .15s}.icon-choice svg{width:28px;height:28px;fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}.icon-choice-name{font-size:12px;line-height:1.3;text-align:center}.icon-choice:hover{border-color:#8fb39f;background:#f7faf5}.icon-choice[aria-pLine truncated
+3 -2
View File
@@ -20,6 +20,7 @@ export const GROUPS = {
effect: 'Wirkung', effect: 'Wirkung',
menu: 'Menü-Knopf', menu: 'Menü-Knopf',
slider: 'Slider-Steuerung', slider: 'Slider-Steuerung',
icon: 'Symbol',
}; };
// dvh before vh: on a phone 100vh is taller than the screen, 100dvh is what people mean. // dvh before vh: on a phone 100vh is taller than the screen, 100dvh is what people mean.
@@ -520,9 +521,9 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
if (properties.length === 0) continue; if (properties.length === 0) continue;
const details = element('details', 'design-group'); const details = element('details', 'design-group');
const groupKey = `${block.type}:${group}`; const groupKey = `${block.type}:${group}`;
details.open = openGroups.get(groupKey) ?? (group === priority[0] || group === 'spacing'); details.open = openGroups.get(groupKey) ?? (group === priority[0] || group === 'spacing' || group === 'icon');
renderedGroups.set(groupKey, details); renderedGroups.set(groupKey, details);
details.append(element('summary', null, groupLabel)); details.append(element('summary', null, groupLabel)); details.dataset.group = group;
const body = element('div', 'design-group-body'); const body = element('div', 'design-group-body');
for (const [key, property] of properties) { for (const [key, property] of properties) {
+4 -1
View File
@@ -174,6 +174,8 @@ export function repairSchema(document) {
if (block.anchor) upgradeDocument(document, 21); if (block.anchor) upgradeDocument(document, 21);
if ('collapse' in props) upgradeDocument(document, 22); if ('collapse' in props) upgradeDocument(document, 22);
if ('faq' in props) upgradeDocument(document, 23); if ('faq' in props) upgradeDocument(document, 23);
if (block.type === 'button' && ('icon' in props || 'iconPosition' in props)) upgradeDocument(document, 25);
if (block.type === 'button' && ('iconMotion' in props || 'iconOnly' in props)) upgradeDocument(document, 26);
walk(block.children); walk(block.children);
} }
}; };
@@ -342,7 +344,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 // 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 // liess sich dann gar nichts mehr einfügen, und ein Symbol, ein Slider oder ein externer Inhalt
// nirgends. Eine Zahl, an einer Stelle. // nirgends. Eine Zahl, an einer Stelle.
export const SCHEMA_VERSION = 24; export const SCHEMA_VERSION = 26;
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' }; 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' };
@@ -684,6 +686,7 @@ export function relevantProperty(block, key, property) {
const container = isContainer(block); const container = isContainer(block);
if (property.group === 'slider') return block.type === 'slider'; if (property.group === 'slider') return block.type === 'slider';
if (property.group === 'menu') return block.type === 'menu'; if (property.group === 'menu') return block.type === 'menu';
if (property.group === 'icon') return block.type === 'button' && block.props?.icon !== 'none';
if (property.group === 'image') return ['image', 'gallery', 'logos', 'postlist'].includes(block.type); if (property.group === 'image') return ['image', 'gallery', 'logos', 'postlist'].includes(block.type);
if (property.group === 'text' || key === 'textShadow') return !['image', 'gallery', 'logos', 'spacer', 'divider', 'embed'].includes(block.type); if (property.group === 'text' || key === 'textShadow') return !['image', 'gallery', 'logos', 'spacer', 'divider', 'embed'].includes(block.type);
if (key === 'columnWidths') return block.type === 'row'; if (key === 'columnWidths') return block.type === 'row';
+56 -4
View File
@@ -11,8 +11,8 @@ import { initMotion } from './motion.js';
import { initSliders } from './slider.js'; import { initSliders } from './slider.js';
import { VISIBILITY, setVisibility, registerPlugins, pluginDefinition, PLUGIN_PRESETS, repairSchema } from './editor-model.js'; import { VISIBILITY, setVisibility, registerPlugins, pluginDefinition, PLUGIN_PRESETS, repairSchema } from './editor-model.js';
// Mirrors DocumentValidator::ICONS; the server rejects anything outside this list. // Symbole, ihre deutschen Namen und die Kachelauswahl. Die Liste spiegelt DocumentValidator::ICONS.
const ICONS = ['app', 'web', 'design', 'network', 'hardware', 'shop', 'support', 'shield', 'clock', 'star', 'phone', 'mail', 'pin', 'check', 'people', 'music', 'home']; import { renderIconField } from './icons.js';
const ICON_SIZES = [12, 16, 20, 24, 32, 40, 48, 56, 64, 80, 96, 120, 160, 200, 240]; 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. // 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' }; 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 === 'newTab') upgradeDocument(doc, 20);
if (key === 'collapse') upgradeDocument(doc, 22); if (key === 'collapse') upgradeDocument(doc, 22);
if (key === 'faq') upgradeDocument(doc, 23); 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; } if (current.type === 'image' && key === 'src') { delete current.props.sources; delete current.props.mediaId; }
}, options.choices ? null : `${selected || 'page'}:${key}`); }, options.choices ? null : `${selected || 'page'}:${key}`);
if (key === 'src') imagePreview?.update({ src: input.value }); if (key === 'src') imagePreview?.update({ src: input.value });
@@ -346,6 +347,37 @@ function renderContent(block, panel) {
if (key === 'href' && !options.plugin) field('In neuem Tab öffnen', 'newTab', block.props.newTab === true, { checkbox: true, container }); if (key === 'href' && !options.plugin) field('In neuem Tab öffnen', 'newTab', block.props.newTab === true, { checkbox: true, container });
} }
}; };
// 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(focus)?.focus({ preventScroll: true });
};
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) { if (!block) {
field('Seitentitel', 'title', history.document.title, { maxLength: 160 }); field('Seitentitel', 'title', history.document.title, { maxLength: 160 });
field('Beschreibung für Suchmaschinen', 'description', history.document.description, { multiline: true, maxLength: 320 }); field('Beschreibung für Suchmaschinen', 'description', history.document.description, { multiline: true, maxLength: 320 });
@@ -385,6 +417,26 @@ function renderContent(block, panel) {
panel.append(label, rich.element, help); 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 (['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, { none: 'Kein Symbol', version: 25 });
if (icon !== 'none') {
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); if (block.type === 'button' && designSchema) buttonControls(block, panel);
if (INLINE_TEXT[block.type]) { if (INLINE_TEXT[block.type]) {
const inline = document.createElement('button'); const inline = document.createElement('button');
@@ -399,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 === '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') { if (block.type === 'iconcard') {
field('Symbol', 'icon', block.props.icon, { choices: Object.fromEntries(ICONS.map(name => [name, name])) }); iconField('Symbol', block.props.icon);
field('Titel', 'title', block.props.title, { maxLength: 120 }); field('Titel', 'title', block.props.title, { maxLength: 120 });
field('Text', 'text', block.props.text, { multiline: true, maxLength: 600 }); 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.' }); 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 +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 === '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') { if (block.type === 'icon') {
field('Symbol', 'icon', block.props.icon, { choices: Object.fromEntries(ICONS.map(name => [name, name])) }); iconField('Symbol', block.props.icon);
// number, so the value is stored as a number: the server wants an int, not "48". // 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('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.' }); 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.' });
+278
View File
@@ -0,0 +1,278 @@
// Der Symbolsatz: Zeichnung, deutscher Name und Kategorie. Die Website zeichnet dieselben Pfade aus
// templates/blocks/icon.html.twig; ein Test hält Twig, diese Datei und DocumentValidator::ICONS gleich.
// Alle Symbole sind Linien auf einem 24er-Raster (stroke, keine Füllung) und erben die Textfarbe.
export const ICON_PATHS = {
// Pfeile und Navigation
'arrow-up-right': 'M6.5 17.5 17.5 6.5M8.5 6.5h9v9',
'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': 'M12 20V4.5m-6.5 6.5L12 4.5l6.5 6.5',
'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',
'chevron-left': 'M15 5.5 8.5 12l6.5 6.5',
'chevron-up': 'm5.5 15 6.5-6.5 6.5 6.5',
'chevron-down': 'm5.5 9 6.5 6.5L18.5 9',
'arrow-circle': 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm-4 9h8m-3-3 3 3-3 3',
external: 'M14 4h6v6m0-6-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5',
refresh: 'M20 12a8 8 0 1 1-2.3-5.6M20 4v5h-5',
menu: 'M4 7h16M4 12h16M4 17h16',
close: 'M6 6l12 12M18 6 6 18',
plus: 'M12 5v14M5 12h14',
minus: 'M5 12h14',
// Kontakt und Ort
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',
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',
mail: 'M3 6h18v12H3Zm0 1 9 7 9-7',
chat: 'M4 5h16v11H9.5L5 19.5V16H4Z',
send: 'M21 3 10 14M21 3l-6.5 18-4.5-7-7-4.5Z',
calendar: 'M4 6h16v14H4Zm0 5h16M8 3.5v4m8-4v4',
clock: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 4v5.5l3.5 2',
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',
map: 'M9 4 3 6.5V20l6-2.5 6 2.5 6-2.5V4l-6 2.5ZM9 4v13.5m6-11V20',
navigation: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm3.5 5.5-2 5-5 2 2-5Z',
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',
wifi: 'M3 9.5a13 13 0 0 1 18 0M6 13a8 8 0 0 1 12 0m-9 3.5a3.5 3.5 0 0 1 6 0M12 19.5h.01',
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',
// Handel und Geld
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',
cart: 'M3 4h2.5l2.2 10.5h10.6L20.5 7H6.3M9.5 18a1.2 1.2 0 1 0 0 2.4 1.2 1.2 0 0 0 0-2.4Zm7.5 0a1.2 1.2 0 1 0 0 2.4 1.2 1.2 0 0 0 0-2.4Z',
tag: 'M3.5 12.5v-8a1 1 0 0 1 1-1h8l8 8a1.4 1.4 0 0 1 0 2l-7 7a1.4 1.4 0 0 1-2 0ZM8.5 8h.01',
euro: 'M17.5 6.5A6.5 6.5 0 1 0 17.5 17.5M4 10h9m-9 4h9',
'credit-card': 'M3 6h18v12H3Zm0 4h18M6.5 15h3',
gift: 'M4 9h16v4H4Zm1.5 4v7h13v-7M12 9v11m0-11S10.5 4 8 4.5 7.5 9 12 9Zm0 0s1.5-5 4-4.5S16.5 9 12 9Z',
percent: 'M19 5 5 19M7 5a2 2 0 1 0 0 4 2 2 0 0 0 0-4Zm10 10a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z',
truck: 'M2.5 6h11v10.5h-11Zm11 3.5h4l3 3.5v3.5h-7ZM6.5 17a1.8 1.8 0 1 0 0 3.6 1.8 1.8 0 0 0 0-3.6Zm10.5 0a1.8 1.8 0 1 0 0 3.6 1.8 1.8 0 0 0 0-3.6Z',
package: 'M3.5 7.5 12 3l8.5 4.5v9L12 21l-8.5-4.5Zm0 0L12 12l8.5-4.5M12 12v9M7.8 5.2l8.5 4.6',
briefcase: 'M3.5 7.5h17v12h-17Zm5-3h7v3h-7Zm-5 8h17',
// Dateien und Medien
document: 'M6 3h8l4 4v14H6Zm8 0v4h4M9 12h6m-6 4h6',
download: 'M12 3.5v12m-5-5 5 5 5-5M4.5 20h15',
upload: 'M12 15.5V4m-5 5 5-5 5 5M4.5 20h15',
print: 'M7 9V3.5h10V9M7 17H4.5V9h15v8H17M7 14h10v6.5H7Z',
link: 'M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1.2 1.2M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1.2-1.2',
share: 'M18 3.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM6 9.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5Zm12 6a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM8.2 10.8l7.6-4.1m-7.6 6.5 7.6 4.1',
image: 'M4 4h16v16H4Zm0 12 4.5-4.5 3.5 3.5 2.5-2.5L20 18M15 8.5h.01',
camera: 'M3 7.5h4L8.5 5h7L17 7.5h4V19H3Zm9 2.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z',
video: 'M3 6.5h12v11H3Zm12 4 6-3.5v10l-6-3.5',
play: 'M8 5.5v13l10-6.5Z',
pause: 'M8 5v14m8-14v14',
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',
mic: 'M12 3a3 3 0 0 0-3 3v5.5a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3ZM5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21',
book: 'M5 4.5A1.5 1.5 0 0 1 6.5 3H19v15H6.5A1.5 1.5 0 0 0 5 19.5Zm0 15A1.5 1.5 0 0 0 6.5 21H19v-3',
// Personen und Sicherheit
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',
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',
login: 'M14 4h5a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1h-5M3.5 12h11m-4-4 4 4-4 4',
lock: 'M5 10.5h14V21H5Zm3 0V7a4 4 0 0 1 8 0v3.5M12 14.5v3',
key: 'M7.5 11.5a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm2.8 1.2L19 4m-3 3 2.5 2.5M13 10l2 2',
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',
eye: 'M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Zm9.5-3a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z',
'thumbs-up': 'M7 10.5V20H4v-9.5Zm0 0 3.5-7a2 2 0 0 1 2.5 2v3.5h5.2a2 2 0 0 1 2 2.4l-1.4 7a2 2 0 0 1-2 1.6H7',
award: 'M12 3a5.5 5.5 0 1 0 0 11 5.5 5.5 0 0 0 0-11Zm-3.5 10L7 21l5-2.5 5 2.5-1.5-8',
graduation: 'M2.5 9 12 4.5 21.5 9 12 13.5Zm4 2.3V16c0 1.5 2.5 3 5.5 3s5.5-1.5 5.5-3v-4.7M21.5 9v5',
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',
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',
// Haus, Garten und Handwerk
home: 'm3 11.5 9-7.5 9 7.5M5.5 10v10h13V10M10 20v-6.5h4V20',
building: 'M5 21V4h10v17m0-12h4v12M3 21h18M8.5 7.5h3m-3 3.5h3m-3 3.5h3',
wrench: 'M14.5 3.5a5 5 0 0 0-4.6 6.9L3.5 16.8a1.9 1.9 0 0 0 2.7 2.7l6.4-6.4a5 5 0 0 0 6.9-4.6l-3 3-3-1-1-3Z',
hammer: 'M14 8.5 4.5 18a1.5 1.5 0 0 0 2.1 2.1L16 10.5M11 6l3-3 3.5 1L21 7.5 18.5 10l-2-2-2.5 2.5Z',
brush: 'M4 4h13v5H4Zm13 2.5h2.5V12h-8v3M10 15h3v6h-3Z',
ruler: 'M3.5 16 16 3.5 20.5 8 8 20.5ZM7 13l2 2m1-5 2 2m1-5 2 2',
leaf: 'M5 19C5 10 10 5 20 4c-1 10-6 15-15 15Zm0 0 8-8',
tree: 'M12 21v-5m0-13-6 8h3l-4 5h14l-4-5h3Z',
sun: 'M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm0-5v2m0 14v2M3 12h2m14 0h2M5.6 5.6 7 7m10 10 1.4 1.4M5.6 18.4 7 17M17 7l1.4-1.4',
droplet: 'M12 3s6.5 7 6.5 11.5a6.5 6.5 0 0 1-13 0C5.5 10 12 3 12 3Z',
flame: 'M12 21a6 6 0 0 0 6-6c0-4.5-4-6.5-4.5-11-2.5 2-5 5-5 8.5-1-.5-1.5-1.5-1.8-2.5C5.6 11.5 6 13 6 15a6 6 0 0 0 6 6Z',
bolt: 'M13 2.5 4.5 13.5H12l-1 8 8.5-11H12Z',
solar: 'M5 4h15l-2 9.5H3Zm-1 4.75h15M10 4 8.5 13.5M15.5 4 14 13.5M10.5 13.5V18M7 20.5h7',
thermometer: 'M10 14.5V5a2 2 0 0 1 4 0v9.5a4 4 0 1 1-4 0Z',
snowflake: 'M12 2.5v19M3.8 7.3l16.4 9.4M3.8 16.7l16.4-9.4M9.5 4 12 6l2.5-2m-5 16 2.5-2 2.5 2',
car: 'M5 16.5V12l2-5h10l2 5v4.5M3 12h18v4.5H3Zm3.5 4.5V19m11-2.5V19M7 14h.01M17 14h.01',
// Allgemein
check: 'm4 12.5 5.5 5.5L20 6',
'check-circle': 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm-4 9.2 2.8 2.8L16.5 9',
info: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 8v5.5m0-9v.5',
help: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18ZM9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.6.3-1 .9-1 1.6v.6m0 3v.01',
alert: 'M12 3.5 2.5 20h19Zm0 6V14m0 3v.01',
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',
settings: 'M4 7h9m4 0h3M4 17h3m4 0h9M15 5v4M9 15v4',
filter: 'M3.5 5h17l-6.5 8v6l-4 2v-8Z',
grid: 'M4 4h7v7H4Zm9 0h7v7h-7ZM4 13h7v7H4Zm9 0h7v7h-7Z',
list: 'M9 6h11M9 12h11M9 18h11M4.5 6h.01M4.5 12h.01M4.5 18h.01',
sparkle: 'M12 3c.6 4.4 2.6 6.4 7 7-4.4.6-6.4 2.6-7 7-.6-4.4-2.6-6.4-7-7 4.4-.6 6.4-2.6 7-7Zm6.5 12.5c.2 1.6 1 2.3 2.5 2.5-1.5.2-2.3 1-2.5 2.5-.2-1.5-1-2.3-2.5-2.5 1.5-.2 2.3-1 2.5-2.5Z',
coffee: 'M4 9h12v6a5 5 0 0 1-5 5H9a5 5 0 0 1-5-5Zm12 1.5h1.5a2.5 2.5 0 0 1 0 5H16M8 3.5V6m4-2.5V6',
utensils: 'M6 3v7a2 2 0 0 0 4 0V3M8 10v11m8.5 0V3c-2 1-3 3.5-3 6.5v4h3',
laptop: 'M5 5h14v10H5Zm-2.5 10h19l-1 3.5h-17Z',
hardware: 'M4 5h16v11H4Zm4 15h8m-4-4v4',
network: 'M5 3h14v5H5Zm0 13h14v5H5Zm7-8v8M8 8v3h8V8',
design: 'M4 20h16M6 16 18 4l3 3L9 19l-4 1Z',
};
export const ICON_LABELS = {
'arrow-up-right': 'Pfeil schräg', 'arrow-right': 'Pfeil nach rechts', 'arrow-left': 'Pfeil nach links',
'arrow-up': 'Pfeil nach oben', 'arrow-down': 'Pfeil nach unten', 'chevron-right': 'Winkel rechts',
'chevron-left': 'Winkel links', 'chevron-up': 'Winkel oben', 'chevron-down': 'Winkel unten',
'arrow-circle': 'Pfeil im Kreis', external: 'Externer Link', refresh: 'Aktualisieren', menu: 'Menü',
close: 'Schließen', plus: 'Plus', minus: 'Minus',
phone: 'Telefon', app: 'Smartphone', mail: 'E-Mail', chat: 'Nachricht', send: 'Senden', calendar: 'Kalender',
clock: 'Uhr', pin: 'Ort', map: 'Karte', navigation: 'Navigation', web: 'Globus', wifi: 'WLAN', support: 'Kundendienst',
shop: 'Einkaufstasche', cart: 'Warenkorb', tag: 'Preisschild', euro: 'Euro', 'credit-card': 'Karte bezahlen',
gift: 'Geschenk', percent: 'Prozent', truck: 'Lieferung', package: 'Paket', briefcase: 'Aktentasche',
document: 'Dokument', download: 'Download', upload: 'Hochladen', print: 'Drucken', link: 'Link', share: 'Teilen',
image: 'Bild', camera: 'Kamera', video: 'Video', play: 'Abspielen', pause: 'Pause', music: 'Musik',
mic: 'Mikrofon', book: 'Buch',
user: 'Person', people: 'Personen', login: 'Anmelden', lock: 'Schloss', key: 'Schlüssel', shield: 'Schutz',
eye: 'Ansehen', 'thumbs-up': 'Daumen hoch', award: 'Auszeichnung', graduation: 'Ausbildung', heart: 'Herz', star: 'Stern',
home: 'Haus', building: 'Gebäude', wrench: 'Werkzeug', hammer: 'Hammer', brush: 'Malerrolle', ruler: 'Lineal',
leaf: 'Blatt', tree: 'Baum', sun: 'Sonne', droplet: 'Wasser', flame: 'Flamme', bolt: 'Strom', solar: 'Solar',
thermometer: 'Temperatur', snowflake: 'Kälte', car: 'Auto',
check: 'Häkchen', 'check-circle': 'Häkchen im Kreis', info: 'Info', help: 'Frage', alert: 'Warnung',
search: 'Suche', settings: 'Einstellungen', filter: 'Filter', grid: 'Raster', list: 'Liste', sparkle: 'Funkeln',
coffee: 'Kaffee', utensils: 'Besteck', laptop: 'Laptop', hardware: 'Bildschirm', network: 'Netzwerk', design: 'Stift',
};
/** Die Kategorien des Auswahlfensters, in der Reihenfolge der Reiter. Jedes Symbol steht genau einmal. */
export const ICON_GROUPS = [
['Pfeile', ['arrow-up-right', 'arrow-right', 'arrow-left', 'arrow-up', 'arrow-down', 'chevron-right', 'chevron-left', 'chevron-up', 'chevron-down', 'arrow-circle', 'external', 'refresh', 'menu', 'close', 'plus', 'minus']],
['Kontakt & Ort', ['phone', 'app', 'mail', 'chat', 'send', 'calendar', 'clock', 'pin', 'map', 'navigation', 'web', 'wifi', 'support']],
['Handel & Geld', ['shop', 'cart', 'tag', 'euro', 'credit-card', 'gift', 'percent', 'truck', 'package', 'briefcase']],
['Dateien & Medien', ['document', 'download', 'upload', 'print', 'link', 'share', 'image', 'camera', 'video', 'play', 'pause', 'music', 'mic', 'book']],
['Personen & Sicherheit', ['user', 'people', 'login', 'lock', 'key', 'shield', 'eye', 'thumbs-up', 'award', 'graduation', 'heart', 'star']],
['Haus & Handwerk', ['home', 'building', 'wrench', 'hammer', 'brush', 'ruler', 'leaf', 'tree', 'sun', 'droplet', 'flame', 'bolt', 'solar', 'thermometer', 'snowflake', 'car']],
['Allgemein', ['check', 'check-circle', 'info', 'help', 'alert', 'search', 'settings', 'filter', 'grid', 'list', 'sparkle', 'coffee', 'utensils', 'laptop', 'hardware', 'network', 'design']],
];
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;
}
const element = (tag, className, text) => {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
};
/**
* Das Feld im Panel: das gewählte Symbol als Vorschau mit Namen und ein Knopf, der das
* Auswahlfenster öffnet. Die Kacheln stehen nicht im Panel - bei hundert Symbolen wäre es eine
* lange Wand, die alles andere nach unten drückt.
*
* @param {{ label: string, value: string, none?: string, onChoose: (name: string) => void }} options
*/
export function renderIconField({ label, value, none, onChoose }) {
const wrap = element('div', 'icon-field');
wrap.append(element('p', 'icon-picker-label', label));
const row = element('div', 'icon-field-row');
const preview = element('span', 'icon-field-preview');
preview.setAttribute('aria-hidden', 'true');
if (value && value !== 'none') preview.append(iconSvg(value)); else preview.classList.add('is-empty');
const name = element('span', 'icon-field-name', value && value !== 'none' ? ICON_LABELS[value] ?? value : none ?? 'Kein Symbol');
const open = element('button', 'secondary icon-field-change', 'Ändern …');
open.type = 'button';
open.setAttribute('aria-haspopup', 'dialog');
open.setAttribute('aria-label', `${label} ändern, aktuell: ${name.textContent}`);
open.addEventListener('click', () => openIconDialog({ value, none, onChoose, returnFocus: open }));
row.append(preview, name, open);
wrap.append(row);
return wrap;
}
/**
* Das Auswahlfenster: Kategorien als Reiter links, die Symbole einer Kategorie als Kacheln mit
* Namen. Ein Klick wählt und schließt, Escape schließt ohne Änderung. Geöffnet wird bei der
* Kategorie des gewählten Symbols, der Fokus steht auf ihm.
*/
export function openIconDialog({ value, none, onChoose, returnFocus }) {
const dialog = element('dialog', 'icon-dialog');
dialog.setAttribute('aria-labelledby', 'icon-dialog-title');
const head = element('div', 'icon-dialog-head');
head.append(element('h2', null, 'Symbol wählen'));
head.firstChild.id = 'icon-dialog-title';
const close = element('button', 'icon-dialog-close');
close.type = 'button'; close.setAttribute('aria-label', 'Schließen'); close.append(iconSvg('close'));
close.addEventListener('click', () => dialog.close());
head.append(close);
const body = element('div', 'icon-dialog-body');
const tablist = element('div', 'icon-dialog-tabs');
tablist.setAttribute('role', 'tablist'); tablist.setAttribute('aria-orientation', 'vertical'); tablist.setAttribute('aria-label', 'Kategorien');
const panel = element('div', 'icon-dialog-panel');
panel.setAttribute('role', 'tabpanel'); panel.id = 'icon-dialog-panel';
const tabs = [];
let tiles = [];
const choose = name => { dialog.close(); onChoose(name); };
const show = (index, focusTile = false) => {
tabs.forEach((tab, i) => { tab.setAttribute('aria-selected', String(i === index)); tab.tabIndex = i === index ? 0 : -1; });
panel.setAttribute('aria-labelledby', tabs[index].id);
panel.replaceChildren();
const grid = element('div', 'icon-dialog-grid');
tiles = ICON_GROUPS[index][1].map(name => {
const tile = element('button', 'icon-choice');
tile.type = 'button'; tile.dataset.icon = name;
tile.setAttribute('aria-pressed', String(name === value));
tile.tabIndex = -1;
tile.append(iconSvg(name), element('span', 'icon-choice-name', ICON_LABELS[name]));
tile.addEventListener('click', () => choose(name));
grid.append(tile);
return tile;
});
(tiles.find(tile => tile.dataset.icon === value) ?? tiles[0]).tabIndex = 0;
panel.append(grid);
if (focusTile) (tiles.find(tile => tile.tabIndex === 0)).focus();
};
ICON_GROUPS.forEach(([title], index) => {
const tab = element('button', 'icon-dialog-tab', title);
tab.type = 'button'; tab.id = `icon-tab-${index}`; tab.setAttribute('role', 'tab'); tab.setAttribute('aria-controls', panel.id);
tab.addEventListener('click', () => show(index));
tabs.push(tab); tablist.append(tab);
});
tablist.addEventListener('keydown', event => {
const index = tabs.indexOf(document.activeElement);
const step = { ArrowDown: 1, ArrowRight: 1, ArrowUp: -1, ArrowLeft: -1 }[event.key];
if (index < 0 || !step) return;
event.preventDefault();
const next = (index + step + tabs.length) % tabs.length;
show(next); tabs[next].focus();
});
panel.addEventListener('keydown', event => {
const index = tiles.indexOf(document.activeElement);
if (index < 0) return;
const columns = Math.max(1, Math.round(panel.querySelector('.icon-dialog-grid').clientWidth / tiles[0].offsetWidth));
const step = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: columns, ArrowUp: -columns }[event.key];
const target = event.key === 'Home' ? 0 : event.key === 'End' ? tiles.length - 1 : step ? Math.min(tiles.length - 1, Math.max(0, index + step)) : null;
if (target === null) return;
event.preventDefault();
tiles[index].tabIndex = -1; tiles[target].tabIndex = 0; tiles[target].focus();
});
body.append(tablist, panel);
const foot = element('div', 'icon-dialog-foot');
if (none) {
const without = element('button', 'secondary', none);
without.type = 'button'; without.dataset.icon = 'none';
without.setAttribute('aria-pressed', String(value === 'none'));
without.addEventListener('click', () => choose('none'));
foot.append(without);
}
const cancel = element('button', 'text-button', 'Abbrechen');
cancel.type = 'button'; cancel.addEventListener('click', () => dialog.close());
foot.append(cancel);
dialog.append(head, body, foot);
dialog.addEventListener('close', () => { dialog.remove(); if (returnFocus?.isConnected) returnFocus.focus(); });
// Ein Klick auf den abgedunkelten Rand schließt, wie bei den anderen Fenstern des Editors.
dialog.addEventListener('click', event => { if (event.target === dialog) dialog.close(); });
document.body.append(dialog);
const start = Math.max(0, ICON_GROUPS.findIndex(([, names]) => names.includes(value)));
dialog.showModal();
show(start, true);
return dialog;
}
+3 -3
View File
@@ -47,8 +47,8 @@ p{hyphens:auto}
/* Der Pfeil am Button und in der Beitragsnavigation ist dasselbe Symbol wie ueberall sonst - /* 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 frueher stand dort ein Schriftzeichen, das je nach Schrift anders aussah. Er waechst mit der
Schrift des Knopfs, statt eine eigene Groesse zu haben. */ Schrift des Knopfs, statt eine eigene Groesse zu haben. */
.button-arrow,.nav-arrow{display:inline-flex;align-items:center} .button-icon,.nav-arrow{display:inline-flex;align-items:center}.button-icon{justify-content:center;flex:none;transition:rotate .25s ease,transform .25s ease,background-color .2s,color .2s,max-width .3s ease,opacity .3s ease,margin .3s ease}
.button-arrow>svg,.nav-arrow>svg{width:1.15em;height:1.15em} .nav-arrow>svg{width:1.15em;height:1.15em}.cms-button .button-icon>svg{width:var(--icon-size,1.15em);height:var(--icon-size,1.15em)}.button.cms-button{gap:var(--icon-gap,.65em)}.cms-button.icon-motion-slide:is(:hover,:focus-visible) .button-icon{transform:translateX(.3em)}.cms-button.icon-start.icon-motion-slide:is(:hover,:focus-visible) .button-icon{transform:translateX(-.3em)}.button.cms-button.icon-motion-reveal{gap:0}.cms-button.icon-motion-reveal .button-icon{max-width:0;opacity:0;overflow:hidden}.cms-button.icon-motion-reveal:is(:hover,:focus-visible) .button-icon{max-width:4em;opacity:1;margin-inline-start:var(--icon-gap,.65em)}.cms-button.icon-start.icon-motion-reveal:is(:hover,:focus-visible) .button-icon{margin-inline:0 var(--icon-gap,.65em)}.button.cms-button.icon-only{gap:0;padding:var(--b-py,var(--kb-py,15px))}.cms-button.icon-only .button-icon{min-inline-size:1lh}.cms-button.icon-only .button-label{width:0;overflow:hidden;white-space:nowrap}@media (prefers-reduced-motion:reduce){html.motion-respect .cms-button .button-icon{transition:none}}
.block-iconcard{display:grid;gap:12px;padding:26px;border:1px solid currentColor;border-radius:12px} .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} .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 /* 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} .block-icon .block-icon{color:inherit}
/* Der Pfeil im Knopf ist dasselbe Symbol und nahm deshalb die Akzentfarbe statt der Schriftfarbe /* 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). */ 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-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-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} .icon-size-48{width:48px;height:48px}.icon-size-56{width:56px;height:56px}.icon-size-64{width:64px;height:64px}
+19
View File
@@ -0,0 +1,19 @@
// Schreibt den Symbolsatz aus public/assets/icons.js in die Twig-Vorlage der Website und in die
// erlaubte Liste des Validators. Eine Quelle, drei Abnehmer; tests/icons.test.mjs prüft das Ergebnis.
// Aufruf: node scripts/build-icons.mjs
import { readFile, writeFile } from 'node:fs/promises';
import { ICON_PATHS } from '../public/assets/icons.js';
const twigFile = new URL('../templates/blocks/icon.html.twig', import.meta.url);
const twig = await readFile(twigFile, 'utf8');
const entries = Object.entries(ICON_PATHS).map(([name, path]) => ` ${/-/.test(name) ? `'${name}'` : name}: '${path}'`).join(',\n');
const nextTwig = twig.replace(/\{% set paths = \{\n[\s\S]*?\n\} %\}/, `{% set paths = {\n${entries}\n} %}`);
if (nextTwig === twig && !twig.includes(entries)) throw new Error('Die Symbolliste in icon.html.twig wurde nicht gefunden.');
await writeFile(twigFile, nextTwig);
const validatorFile = new URL('../src/Content/DocumentValidator.php', import.meta.url);
const validator = await readFile(validatorFile, 'utf8');
const list = Object.keys(ICON_PATHS).map(name => `'${name}'`).join(', ');
const nextValidator = validator.replace(/public const ICONS = \[[^\]]*\];/, `public const ICONS = [${list}];`);
await writeFile(validatorFile, nextValidator);
console.log(`${Object.keys(ICON_PATHS).length} Symbole geschrieben.`);
+184
View File
@@ -0,0 +1,184 @@
// Prüft das Symbol am Button im echten Editor auf einer isolierten Kopie der Pilotdaten: Auswahl-
// fenster mit Kategorien, Tastatur, Stellung, Bewegung, nur Symbol, Farbe und Größe unter Design,
// Rückgängig, Speichern und Neuladen. Screenshots unter reports/button-icons/.
import { chromium } from 'playwright';
import AxeBuilder from '@axe-core/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();
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);
const inPreview = fn => page.evaluate(fn);
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('.button-icon svg path')?.getAttribute('d') ?? null;
const classes = el.className;
return (expected.path === undefined || (expected.path === null ? path === null : path?.startsWith(expected.path)))
&& (expected.first === undefined || ((el.firstChild?.nodeType === 1 && el.firstChild.classList.contains('button-icon')) === expected.first))
&& (expected.has ?? []).every(name => classes.includes(name))
&& (expected.not ?? []).every(name => !classes.includes(name));
}, expected);
await page.goto(`${origin}/admin/pages/${fixture.homeId}`);
await selectBlock('qa-icon-button');
const field = page.locator('.icon-field');
await field.waitFor();
assert.equal(await field.locator('.icon-field-name').textContent(), 'Pfeil schräg');
assert.equal(await page.locator('.icon-choice').count(), 0, 'Im Panel steht keine Kachelwand.');
await waitPreview({ path: 'M6.5 17.5', first: false });
await page.locator('.properties, #properties, aside').first().screenshot({ path: `${report}/panel-vorher.png` }).catch(() => {});
checks.push('Panel: nur das gewählte Symbol (Pfeil schräg) mit „Ändern …“');
// Das Fenster: Kategorien links, Symbole mit Namen, geöffnet bei der Kategorie des Pfeils.
await page.getByRole('button', { name: /Symbol ändern, aktuell: Pfeil schräg/ }).click();
const dialog = page.getByRole('dialog', { name: 'Symbol wählen' });
await dialog.waitFor();
assert.equal(await dialog.getByRole('tab').count(), 7);
assert.equal(await dialog.getByRole('tab', { selected: true }).textContent(), 'Pfeile');
assert.equal(await page.evaluate(() => document.activeElement?.dataset.icon), 'arrow-up-right', 'Fokus auf dem gewählten Symbol');
const axe = await new AxeBuilder({ page }).include('.icon-dialog').analyze();
assert.deepEqual(axe.violations.map(v => v.id), [], 'axe im Auswahlfenster');
await dialog.getByRole('tab', { name: 'Haus & Handwerk' }).click();
await dialog.screenshot({ path: `${report}/fenster.png` });
// Escape schließt ohne Änderung, der Fokus geht zurück zum Knopf.
await page.keyboard.press('Escape');
await dialog.waitFor({ state: 'detached' });
assert.match(await page.evaluate(() => document.activeElement?.getAttribute('aria-label') ?? ''), /Symbol ändern/);
await waitPreview({ path: 'M6.5 17.5' });
checks.push('Fenster: 7 Kategorien, Fokus auf dem Gewählten, axe ohne Befund, Escape ändert nichts');
// Tastatur: Kategorie mit Pfeiltaste wechseln, Symbol wählen.
await page.getByRole('button', { name: /Symbol ändern/ }).click();
await dialog.getByRole('tab', { name: 'Pfeile' }).focus();
await page.keyboard.press('ArrowDown');
assert.equal(await dialog.getByRole('tab', { selected: true }).textContent(), 'Kontakt & Ort');
await dialog.getByRole('button', { name: 'Telefon', exact: true }).click();
await dialog.waitFor({ state: 'detached' });
await waitPreview({ path: 'M6 3h4', first: false });
assert.equal(await field.locator('.icon-field-name').textContent(), 'Telefon');
checks.push('Telefon über die Kategorie „Kontakt & Ort“ gewählt');
await page.getByRole('button', { name: 'Vor dem Text', exact: true }).click();
await waitPreview({ first: true, has: ['icon-start'] });
await page.getByRole('button', { name: 'Rückt', exact: true }).click();
await waitPreview({ has: ['icon-motion-slide'] });
await page.getByRole('button', { name: 'Erscheint', exact: true }).click();
await waitPreview({ has: ['icon-motion-reveal'], not: ['icon-motion-slide'] });
await page.locator('#undo').click(); await waitPreview({ has: ['icon-motion-slide'] });
await page.locator('#redo').click(); await waitPreview({ has: ['icon-motion-reveal'] });
checks.push('Stellung und Bewegung, Rückgängig und Wiederholen');
await page.getByLabel('Nur das Symbol zeigen').check();
await waitPreview({ has: ['icon-only'] });
const label = await inPreview(() => document.querySelector('#preview').contentDocument.querySelector('#qa-icon-button .button-label')?.textContent);
assert.equal(label, 'Jetzt anrufen', 'Beschriftung bleibt für Vorlesesoftware');
await page.locator('.properties, #properties, aside').first().screenshot({ path: `${report}/panel-nachher.png` }).catch(() => {});
checks.push('Nur Symbol: Beschriftung unsichtbar im Link');
// Design: Gruppe „Symbol“ mit Farbe, Fläche, Größe, Drehung.
await page.getByRole('button', { name: 'Farbe, Größe und Fläche des Symbols → Design' }).click();
const group = page.locator('.design-group[data-group="icon"]');
await group.waitFor();
assert.equal(await group.evaluate(el => el.open), true);
for (const name of ['Symbolfarbe', 'Fläche hinter dem Symbol', 'Symbolgröße', 'Innenabstand der Fläche', 'Ecken der Fläche', 'Drehung', 'Abstand zum Text']) {
assert.ok(await group.getByText(name, { exact: true }).count() > 0, `${name} fehlt in der Gruppe Symbol`);
}
await group.locator('#style-iconSize').fill('24');
await group.locator('#style-iconSize').press('Enter');
await page.waitForFunction(() => {
const doc = document.querySelector('#preview')?.contentDocument, svg = doc?.querySelector('#qa-icon-button .button-icon svg');
return svg && Math.round(svg.getBoundingClientRect().width) === 24;
});
await group.screenshot({ path: `${report}/design-symbol.png` });
checks.push('Design → Symbol: alle sieben Werte da, Größe 24 px wirkt in der Vorschau');
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;
const button = stored.blocks[0].children[0];
assert.equal(stored.schemaVersion, 26);
assert.deepEqual(button.props, { text: 'Jetzt anrufen', href: 'tel:+4972296992826', icon: 'phone', iconPosition: 'start', iconMotion: 'reveal', iconOnly: true });
assert.equal(button.style.desktop.iconSize, '24px');
await page.reload(); await selectBlock('qa-icon-button');
await page.locator('.icon-field').waitFor();
assert.equal(await page.locator('.icon-field-name').textContent(), 'Telefon');
assert.equal(await page.getByLabel('Nur das Symbol zeigen').isChecked(), true);
checks.push('gespeichert als Schema v26 mit Größe unter Design, nach Neuladen gleich');
// So sieht es aus: Pfeil in einer runden Fläche, die sich beim Überfahren dreht und umfärbt,
// daneben ein Button nur aus Symbol.
const look = (await (await context.request.get(api)).json());
look.document.blocks[0].children = [
{ id: 'qa-look-a', type: 'button', props: { text: 'Projekt anfragen', href: '/kontakt', icon: 'arrow-right', iconMotion: 'slide' }, style: { desktop: { iconColor: '#ffffff', iconBackgroundColor: '#b71c22', iconPadding: '6px', iconRadius: '999px', iconSize: '16px', hover: { iconBackgroundColor: '#1e2a20', iconRotate: '-45deg' } } } },
{ id: 'qa-look-b', type: 'button', props: { text: 'Anrufen', href: 'tel:+4972296992826', icon: 'phone', iconOnly: true } },
{ id: 'qa-look-c', type: 'button', props: { text: 'Prospekt laden', href: '/', icon: 'download', iconPosition: 'start' } },
];
assert.equal((await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': look.csrfToken }, data: { document: look.document, expectedVersion: look.version } })).status(), 200);
await page.reload();
const frame = page.frameLocator('#preview');
await frame.locator('#qa-look-a .button-icon').waitFor();
const section = frame.locator('#qa-icons');
await section.screenshot({ path: `${report}/aussehen-normal.png` });
const styles = await frame.locator('#qa-look-a .button-icon').evaluate(el => { const s = getComputedStyle(el); return { bg: s.backgroundColor, radius: s.borderRadius, color: s.color }; });
assert.deepEqual(styles, { bg: 'rgb(183, 28, 34)', radius: '999px', color: 'rgb(255, 255, 255)' });
await frame.locator('#qa-look-a').hover();
await page.waitForTimeout(400);
await section.screenshot({ path: `${report}/aussehen-hover.png` });
assert.equal(await frame.locator('#qa-look-a .button-icon').evaluate(el => getComputedStyle(el).rotate), '-45deg');
const only = await frame.locator('#qa-look-b').evaluate(el => ({ name: el.textContent.trim(), w: Math.round(el.getBoundingClientRect().width), h: Math.round(el.getBoundingClientRect().height) }));
assert.equal(only.name, 'Anrufen');
assert.ok(Math.abs(only.w - only.h) <= 2, `Nur-Symbol-Button ist nicht quadratisch: ${only.w}x${only.h}`);
checks.push('Aussehen: farbige runde Fläche, Drehung beim Überfahren, quadratischer Nur-Symbol-Button, Symbol vor dem Text');
await selectBlock('qa-look-c');
// Kein Symbol: Nur-Symbol fällt weg, die Einstellungen verschwinden.
await page.getByRole('button', { name: /Symbol ändern/ }).click();
await dialog.getByRole('button', { name: 'Kein Symbol', exact: true }).click();
await page.waitForFunction(() => { const el = document.querySelector('#preview')?.contentDocument?.getElementById('qa-look-c'); return el && !el.querySelector('.button-icon'); });
assert.equal(await page.getByRole('button', { name: 'Vor dem Text', exact: true }).count(), 0);
checks.push('Kein Symbol entfernt auch „nur Symbol“');
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();
}
+22 -2
View File
@@ -16,7 +16,9 @@ final class DocumentValidator
/** Blocks introduced with schema v6 for the sections a marketing page actually needs. */ /** 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']; 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. */ /** 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 = ['arrow-up-right', 'arrow-right', 'arrow-left', 'arrow-up', 'arrow-down', 'chevron-right', 'chevron-left', 'chevron-up', 'chevron-down', 'arrow-circle', 'external', 'refresh', 'menu', 'close', 'plus', 'minus', 'phone', 'app', 'mail', 'chat', 'send', 'calendar', 'clock', 'pin', 'map', 'navigation', 'web', 'wifi', 'support', 'shop', 'cart', 'tag', 'euro', 'credit-card', 'gift', 'percent', 'truck', 'package', 'briefcase', 'document', 'download', 'upload', 'print', 'link', 'share', 'image', 'camera', 'video', 'play', 'pause', 'music', 'mic', 'book', 'user', 'people', 'login', 'lock', 'key', 'shield', 'eye', 'thumbs-up', 'award', 'graduation', 'heart', 'star', 'home', 'building', 'wrench', 'hammer', 'brush', 'ruler', 'leaf', 'tree', 'sun', 'droplet', 'flame', 'bolt', 'solar', 'thermometer', 'snowflake', 'car', 'check', 'check-circle', 'info', 'help', 'alert', 'search', 'settings', 'filter', 'grid', 'list', 'sparkle', 'coffee', 'utensils', 'laptop', 'hardware', 'network', 'design'];
/** 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']; public const SPACER_SIZES = ['small', 'medium', 'large', 'huge'];
/** Schema v7: tabs, the one pattern that needs a script to become a real widget. */ /** Schema v7: tabs, the one pattern that needs a script to become a real widget. */
public const V7_TYPES = ['tabs', 'tabitem']; public const V7_TYPES = ['tabs', 'tabitem'];
@@ -40,7 +42,7 @@ final class DocumentValidator
public const V23_TYPES = ['social']; public const V23_TYPES = ['social'];
public const V24_TYPES = ['html']; public const V24_TYPES = ['html'];
/** Die aktuelle Fassung des Dokumentschemas - dieselbe Zahl wie SCHEMA_VERSION in editor-model.js. */ /** Die aktuelle Fassung des Dokumentschemas - dieselbe Zahl wie SCHEMA_VERSION in editor-model.js. */
public const SCHEMA_VERSION = 24; public const SCHEMA_VERSION = 26;
/** /**
* @param bool $requireHeading A page needs exactly one main heading. A header, a footer or a * @param bool $requireHeading A page needs exactly one main heading. A header, a footer or a
@@ -237,6 +239,24 @@ final class DocumentValidator
} }
if ('button' === $type) { if ('button' === $type) {
$this->link($props['href'] ?? null); $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.');
}
// Schema v26 (ADR 109): was das Symbol beim Überfahren tut, und ein Button nur aus
// Symbol. Der Text bleibt dann für Vorlesesoftware da - ohne Symbol wäre der Button
// unsichtbar, deshalb braucht "nur Symbol" ein Symbol.
if (array_key_exists('iconMotion', $props)) {
$this->require($schemaVersion >= 26 && in_array($props['iconMotion'], ['none', 'slide', 'reveal'], true), 'Unbekannte Bewegung des Symbols oder Dokumentschema vor v26.');
}
if (array_key_exists('iconOnly', $props)) {
$this->require($schemaVersion >= 26 && is_bool($props['iconOnly']), 'Nur Symbol ist an oder aus und braucht Schema v26.');
$this->require(!$props['iconOnly'] || 'none' !== ($props['icon'] ?? self::BUTTON_DEFAULT_ICON), 'Ein Button nur aus Symbol braucht ein Symbol.');
}
} }
if ('iconcard' === $type) { if ('iconcard' === $type) {
$this->require(in_array($props['icon'] ?? null, self::ICONS, true), 'Unknown icon.'); $this->require(in_array($props['icon'] ?? null, self::ICONS, true), 'Unknown icon.');
+10
View File
@@ -208,6 +208,16 @@ final class StyleCompiler
if ('' !== $hover) { if ('' !== $hover) {
$rules .= sprintf('#%s:hover,#%s:focus-within{%s}', $id, $id, $hover); $rules .= sprintf('#%s:hover,#%s:focus-within{%s}', $id, $id, $hover);
} }
// Das Symbol im Button (ADR 109): Farbe, Fläche, Größe und Drehung liegen am Button,
// gelten aber seiner Symbolhülle - auch beim Überfahren und bei Tastaturfokus.
$icon = $this->declarations($values, 'icon', $device);
if ('' !== $icon) {
$rules .= sprintf('#%s .button-icon{%s}', $id, $icon);
}
$iconHover = is_array($values['hover'] ?? null) ? $this->declarations($values['hover'], 'icon', $device) : '';
if ('' !== $iconHover) {
$rules .= sprintf('#%s:hover .button-icon,#%s:focus-visible .button-icon{%s}', $id, $id, $iconHover);
}
// The scrolled state: scrolled.js puts `is-scrolled` on <html> once the page has moved. // The scrolled state: scrolled.js puts `is-scrolled` on <html> once the page has moved.
// The change eases in, so a header does not snap from glass to solid. // The change eases in, so a header does not snap from glass to solid.
$scrolled = is_array($values['scrolled'] ?? null) ? $this->declarations($this->inheritShadow($values['scrolled'], $values), 'self', $device) : ''; $scrolled = is_array($values['scrolled'] ?? null) ? $this->declarations($this->inheritShadow($values['scrolled'], $values), 'self', $device) : '';
+13
View File
@@ -219,6 +219,19 @@ final class StyleSchema
'burgerBorderWidth' => ['group' => 'menu', 'type' => 'length', 'css' => 'border-width', 'label' => 'Menü-Knopf: Rahmenstärke', 'target' => 'burger'], 'burgerBorderWidth' => ['group' => 'menu', 'type' => 'length', 'css' => 'border-width', 'label' => 'Menü-Knopf: Rahmenstärke', 'target' => 'burger'],
'burgerBorderRadius' => ['group' => 'menu', 'type' => 'length', 'css' => 'border-radius', 'label' => 'Menü-Knopf: Ecken', 'target' => 'burger'], 'burgerBorderRadius' => ['group' => 'menu', 'type' => 'length', 'css' => 'border-radius', 'label' => 'Menü-Knopf: Ecken', 'target' => 'burger'],
'burgerFontSize' => ['group' => 'menu', 'type' => 'length', 'css' => 'font-size', 'label' => 'Menü-Knopf: Schriftgröße', 'target' => 'burger'], 'burgerFontSize' => ['group' => 'menu', 'type' => 'length', 'css' => 'font-size', 'label' => 'Menü-Knopf: Schriftgröße', 'target' => 'burger'],
// Das Symbol im Button (ADR 109). Die Werte liegen am Button und wirken auf seine
// Symbolhülle, je Gerät und mit eigenem Wert beim Überfahren. Ohne Angabe folgt das
// Symbol der Schriftfarbe und hat 1,15 em Größe wie bisher.
'iconColor' => ['group' => 'icon', 'type' => 'color', 'css' => 'color', 'label' => 'Symbolfarbe', 'target' => 'icon', 'hover' => true],
'iconBackgroundColor' => ['group' => 'icon', 'type' => 'color', 'css' => 'background-color', 'label' => 'Fläche hinter dem Symbol', 'target' => 'icon', 'hover' => true],
'iconSize' => ['group' => 'icon', 'type' => 'length', 'css' => '--icon-size', 'label' => 'Symbolgröße', 'target' => 'icon'],
'iconPadding' => ['group' => 'icon', 'type' => 'length', 'css' => 'padding', 'label' => 'Innenabstand der Fläche', 'target' => 'icon'],
'iconRadius' => ['group' => 'icon', 'type' => 'length', 'css' => 'border-radius', 'label' => 'Ecken der Fläche', 'target' => 'icon'],
'iconRotate' => ['group' => 'icon', 'type' => 'choice', 'css' => 'rotate', 'label' => 'Drehung', 'target' => 'icon', 'hover' => true, 'values' => [
'0deg' => 'Keine', '45deg' => '45° im Uhrzeigersinn', '90deg' => '90° im Uhrzeigersinn', '180deg' => '180°', '-45deg' => '45° gegen den Uhrzeigersinn', '-90deg' => '90° gegen den Uhrzeigersinn',
]],
// Am Button selbst: der Abstand gilt auch, wenn das Symbol erst beim Überfahren erscheint.
'iconGap' => ['group' => 'icon', 'type' => 'length', 'css' => '--icon-gap', 'label' => 'Abstand zum Text'],
'sliderDotColor' => ['group' => 'slider', 'type' => 'color', 'css' => '--slider-dot', 'label' => 'Punkte', 'target' => 'controls'], 'sliderDotColor' => ['group' => 'slider', 'type' => 'color', 'css' => '--slider-dot', 'label' => 'Punkte', 'target' => 'controls'],
'sliderDotActiveColor' => ['group' => 'slider', 'type' => 'color', 'css' => '--slider-dot-active', 'label' => 'Aktiver Punkt', 'target' => 'controls'], 'sliderDotActiveColor' => ['group' => 'slider', 'type' => 'color', 'css' => '--slider-dot-active', 'label' => 'Aktiver Punkt', 'target' => 'controls'],
'sliderControlColor' => ['group' => 'slider', 'type' => 'color', 'css' => '--slider-ink', 'label' => 'Pfeile und Zähler', 'target' => 'controls'], 'sliderControlColor' => ['group' => 'slider', 'type' => 'color', 'css' => '--slider-ink', 'label' => 'Pfeile und Zähler', 'target' => 'controls'],
+1 -1
View File
@@ -18,7 +18,7 @@
Importzeile sie kennen muss. Sie steht vor jedem Modul-Skript; spaeter geladen wirkt sie Importzeile sie kennen muss. Sie steht vor jedem Modul-Skript; spaeter geladen wirkt sie
nicht mehr. #} nicht mehr. #}
<script type="importmap" nonce="{{ csp_nonce() }}">{"imports":{ <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 ? '' : ',' }} "/assets/{{ modul }}.js": "/assets/{{ modul }}.js?v={{ kant_version() }}"{{ loop.last ? '' : ',' }}
{%- endfor %} {%- endfor %}
}}</script> }}</script>
+11 -1
View File
@@ -26,7 +26,17 @@
{% endif %} {% endif %}
{% elseif type == 'button' %} {% elseif type == 'button' %}
{% for device, settings in layout.button|default({}) %}{% for key, value in settings %}{% set designClasses = designClasses ~ ' b-' ~ device ~ '-' ~ key ~ '-' ~ value %}{% endfor %}{% endfor %} {% 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' %}
{# Schema v26: Bewegung beim Überfahren und ein Button nur aus Symbol. Der Text steht dann
in einer Beschriftung ohne Breite und bleibt der Name des Links für Vorlesesoftware. #}
{% set iconOnly = iconMarkup and props.iconOnly|default(false) %}
{% if iconMarkup %}{% set designClasses = designClasses ~ (iconFirst ? ' icon-start' : '') ~ (props.iconMotion|default('none') != 'none' ? ' icon-motion-' ~ props.iconMotion : '') ~ (iconOnly ? ' icon-only' : '') %}{% endif %}
<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 %}{% if iconOnly %}<span class="button-label">{{ props.text }}</span>{% else %}{{ props.text }}{% endif %}{% if iconMarkup and not iconFirst %} {{ iconMarkup }}{% endif %}</a>
{% elseif type == 'image' %} {% elseif type == 'image' %}
{# Wie breit das Bild tatsaechlich erscheint. Steht im Block eine feste Breite - ein Logo mit {# 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 190px, ein Symbol mit 72px -, dann ist das die Antwort, und der Browser laedt die kleine
+96 -18
View File
@@ -1,24 +1,102 @@
{# Inline SVG from a fixed allowlist. No icon font, no external request, no extra stylesheet. #} {# Inline SVG from a fixed allowlist. No icon font, no external request, no extra stylesheet. #}
{% set paths = { {% set 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', 'arrow-up-right': 'M6.5 17.5 17.5 6.5M8.5 6.5h9v9',
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-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-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': 'M12 20V4.5m-6.5 6.5L12 4.5l6.5 6.5',
'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',
'chevron-left': 'M15 5.5 8.5 12l6.5 6.5',
'chevron-up': 'm5.5 15 6.5-6.5 6.5 6.5',
'chevron-down': 'm5.5 9 6.5 6.5L18.5 9',
'arrow-circle': 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm-4 9h8m-3-3 3 3-3 3',
external: 'M14 4h6v6m0-6-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5',
refresh: 'M20 12a8 8 0 1 1-2.3-5.6M20 4v5h-5',
menu: 'M4 7h16M4 12h16M4 17h16',
close: 'M6 6l12 12M18 6 6 18',
plus: 'M12 5v14M5 12h14',
minus: 'M5 12h14',
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',
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',
mail: 'M3 6h18v12H3Zm0 1 9 7 9-7',
chat: 'M4 5h16v11H9.5L5 19.5V16H4Z',
send: 'M21 3 10 14M21 3l-6.5 18-4.5-7-7-4.5Z',
calendar: 'M4 6h16v14H4Zm0 5h16M8 3.5v4m8-4v4',
clock: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 4v5.5l3.5 2',
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',
map: 'M9 4 3 6.5V20l6-2.5 6 2.5 6-2.5V4l-6 2.5ZM9 4v13.5m6-11V20',
navigation: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm3.5 5.5-2 5-5 2 2-5Z',
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',
wifi: 'M3 9.5a13 13 0 0 1 18 0M6 13a8 8 0 0 1 12 0m-9 3.5a3.5 3.5 0 0 1 6 0M12 19.5h.01',
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',
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',
cart: 'M3 4h2.5l2.2 10.5h10.6L20.5 7H6.3M9.5 18a1.2 1.2 0 1 0 0 2.4 1.2 1.2 0 0 0 0-2.4Zm7.5 0a1.2 1.2 0 1 0 0 2.4 1.2 1.2 0 0 0 0-2.4Z',
tag: 'M3.5 12.5v-8a1 1 0 0 1 1-1h8l8 8a1.4 1.4 0 0 1 0 2l-7 7a1.4 1.4 0 0 1-2 0ZM8.5 8h.01',
euro: 'M17.5 6.5A6.5 6.5 0 1 0 17.5 17.5M4 10h9m-9 4h9',
'credit-card': 'M3 6h18v12H3Zm0 4h18M6.5 15h3',
gift: 'M4 9h16v4H4Zm1.5 4v7h13v-7M12 9v11m0-11S10.5 4 8 4.5 7.5 9 12 9Zm0 0s1.5-5 4-4.5S16.5 9 12 9Z',
percent: 'M19 5 5 19M7 5a2 2 0 1 0 0 4 2 2 0 0 0 0-4Zm10 10a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z',
truck: 'M2.5 6h11v10.5h-11Zm11 3.5h4l3 3.5v3.5h-7ZM6.5 17a1.8 1.8 0 1 0 0 3.6 1.8 1.8 0 0 0 0-3.6Zm10.5 0a1.8 1.8 0 1 0 0 3.6 1.8 1.8 0 0 0 0-3.6Z',
package: 'M3.5 7.5 12 3l8.5 4.5v9L12 21l-8.5-4.5Zm0 0L12 12l8.5-4.5M12 12v9M7.8 5.2l8.5 4.6',
briefcase: 'M3.5 7.5h17v12h-17Zm5-3h7v3h-7Zm-5 8h17',
document: 'M6 3h8l4 4v14H6Zm8 0v4h4M9 12h6m-6 4h6',
download: 'M12 3.5v12m-5-5 5 5 5-5M4.5 20h15',
upload: 'M12 15.5V4m-5 5 5-5 5 5M4.5 20h15',
print: 'M7 9V3.5h10V9M7 17H4.5V9h15v8H17M7 14h10v6.5H7Z',
link: 'M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1.2 1.2M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1.2-1.2',
share: 'M18 3.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM6 9.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5Zm12 6a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM8.2 10.8l7.6-4.1m-7.6 6.5 7.6 4.1',
image: 'M4 4h16v16H4Zm0 12 4.5-4.5 3.5 3.5 2.5-2.5L20 18M15 8.5h.01',
camera: 'M3 7.5h4L8.5 5h7L17 7.5h4V19H3Zm9 2.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z',
video: 'M3 6.5h12v11H3Zm12 4 6-3.5v10l-6-3.5',
play: 'M8 5.5v13l10-6.5Z',
pause: 'M8 5v14m8-14v14',
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',
mic: 'M12 3a3 3 0 0 0-3 3v5.5a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3ZM5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21',
book: 'M5 4.5A1.5 1.5 0 0 1 6.5 3H19v15H6.5A1.5 1.5 0 0 0 5 19.5Zm0 15A1.5 1.5 0 0 0 6.5 21H19v-3',
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',
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',
login: 'M14 4h5a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1h-5M3.5 12h11m-4-4 4 4-4 4',
lock: 'M5 10.5h14V21H5Zm3 0V7a4 4 0 0 1 8 0v3.5M12 14.5v3',
key: 'M7.5 11.5a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm2.8 1.2L19 4m-3 3 2.5 2.5M13 10l2 2',
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',
eye: 'M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Zm9.5-3a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z',
'thumbs-up': 'M7 10.5V20H4v-9.5Zm0 0 3.5-7a2 2 0 0 1 2.5 2v3.5h5.2a2 2 0 0 1 2 2.4l-1.4 7a2 2 0 0 1-2 1.6H7',
award: 'M12 3a5.5 5.5 0 1 0 0 11 5.5 5.5 0 0 0 0-11Zm-3.5 10L7 21l5-2.5 5 2.5-1.5-8',
graduation: 'M2.5 9 12 4.5 21.5 9 12 13.5Zm4 2.3V16c0 1.5 2.5 3 5.5 3s5.5-1.5 5.5-3v-4.7M21.5 9v5',
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',
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',
home: 'm3 11.5 9-7.5 9 7.5M5.5 10v10h13V10M10 20v-6.5h4V20',
building: 'M5 21V4h10v17m0-12h4v12M3 21h18M8.5 7.5h3m-3 3.5h3m-3 3.5h3',
wrench: 'M14.5 3.5a5 5 0 0 0-4.6 6.9L3.5 16.8a1.9 1.9 0 0 0 2.7 2.7l6.4-6.4a5 5 0 0 0 6.9-4.6l-3 3-3-1-1-3Z',
hammer: 'M14 8.5 4.5 18a1.5 1.5 0 0 0 2.1 2.1L16 10.5M11 6l3-3 3.5 1L21 7.5 18.5 10l-2-2-2.5 2.5Z',
brush: 'M4 4h13v5H4Zm13 2.5h2.5V12h-8v3M10 15h3v6h-3Z',
ruler: 'M3.5 16 16 3.5 20.5 8 8 20.5ZM7 13l2 2m1-5 2 2m1-5 2 2',
leaf: 'M5 19C5 10 10 5 20 4c-1 10-6 15-15 15Zm0 0 8-8',
tree: 'M12 21v-5m0-13-6 8h3l-4 5h14l-4-5h3Z',
sun: 'M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm0-5v2m0 14v2M3 12h2m14 0h2M5.6 5.6 7 7m10 10 1.4 1.4M5.6 18.4 7 17M17 7l1.4-1.4',
droplet: 'M12 3s6.5 7 6.5 11.5a6.5 6.5 0 0 1-13 0C5.5 10 12 3 12 3Z',
flame: 'M12 21a6 6 0 0 0 6-6c0-4.5-4-6.5-4.5-11-2.5 2-5 5-5 8.5-1-.5-1.5-1.5-1.8-2.5C5.6 11.5 6 13 6 15a6 6 0 0 0 6 6Z',
bolt: 'M13 2.5 4.5 13.5H12l-1 8 8.5-11H12Z',
solar: 'M5 4h15l-2 9.5H3Zm-1 4.75h15M10 4 8.5 13.5M15.5 4 14 13.5M10.5 13.5V18M7 20.5h7',
thermometer: 'M10 14.5V5a2 2 0 0 1 4 0v9.5a4 4 0 1 1-4 0Z',
snowflake: 'M12 2.5v19M3.8 7.3l16.4 9.4M3.8 16.7l16.4-9.4M9.5 4 12 6l2.5-2m-5 16 2.5-2 2.5 2',
car: 'M5 16.5V12l2-5h10l2 5v4.5M3 12h18v4.5H3Zm3.5 4.5V19m11-2.5V19M7 14h.01M17 14h.01',
check: 'm4 12.5 5.5 5.5L20 6',
'check-circle': 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm-4 9.2 2.8 2.8L16.5 9',
info: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 8v5.5m0-9v.5',
help: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18ZM9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.6.3-1 .9-1 1.6v.6m0 3v.01',
alert: 'M12 3.5 2.5 20h19Zm0 6V14m0 3v.01',
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',
settings: 'M4 7h9m4 0h3M4 17h3m4 0h9M15 5v4M9 15v4',
filter: 'M3.5 5h17l-6.5 8v6l-4 2v-8Z',
grid: 'M4 4h7v7H4Zm9 0h7v7h-7ZM4 13h7v7H4Zm9 0h7v7h-7Z',
list: 'M9 6h11M9 12h11M9 18h11M4.5 6h.01M4.5 12h.01M4.5 18h.01',
sparkle: 'M12 3c.6 4.4 2.6 6.4 7 7-4.4.6-6.4 2.6-7 7-.6-4.4-2.6-6.4-7-7 4.4-.6 6.4-2.6 7-7Zm6.5 12.5c.2 1.6 1 2.3 2.5 2.5-1.5.2-2.3 1-2.5 2.5-.2-1.5-1-2.3-2.5-2.5 1.5-.2 2.3-1 2.5-2.5Z',
coffee: 'M4 9h12v6a5 5 0 0 1-5 5H9a5 5 0 0 1-5-5Zm12 1.5h1.5a2.5 2.5 0 0 1 0 5H16M8 3.5V6m4-2.5V6',
utensils: 'M6 3v7a2 2 0 0 0 4 0V3M8 10v11m8.5 0V3c-2 1-3 3.5-3 6.5v4h3',
laptop: 'M5 5h14v10H5Zm-2.5 10h19l-1 3.5h-17Z',
hardware: 'M4 5h16v11H4Zm4 15h8m-4-4v4',
network: 'M5 3h14v5H5Zm0 13h14v5H5Zm7-8v8M8 8v3h8V8',
design: 'M4 20h16M6 16 18 4l3 3L9 19l-4 1Z'
} %} } %}
<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> <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>
+109 -1
View File
@@ -273,7 +273,115 @@ final class CmsTest extends TestCase
public function testTheButtonArrowTakesTheColourOfTheButtonText(): void public function testTheButtonArrowTakesTheColourOfTheButtonText(): void
{ {
// Der Pfeil ist ein Symbol (block-icon) und bekam die Akzentfarbe: grün auf einem roten Knopf. // 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];
yield 'Bewegung vor v26' => ['iconMotion', 'slide', 25];
yield 'unbekannte Bewegung' => ['iconMotion', 'spin', 26];
yield 'Nur Symbol vor v26' => ['iconOnly', true, 25];
yield 'Nur Symbol ist kein Schalter' => ['iconOnly', 'ja', 26];
}
public function testAButtonWithoutAnIconCannotBeIconOnly(): void
{
$document = DemoContent::pages()['home'];
$document['schemaVersion'] = 26;
$document['blocks'][0]['children'][3]['props'] += ['icon' => 'none', 'iconOnly' => true];
$this->expectException(InvalidDocument::class);
(new DocumentValidator())->validate($document);
}
/**
* Schema v26 (ADR 109): Bewegung beim Überfahren und ein Button nur aus Symbol. Der Text
* bleibt als unsichtbare Beschriftung der Name des Links.
*/
public function testAButtonIconMovesOnHoverOrStandsAlone(): void
{
$document = DemoContent::pages()['home'];
$button = $document['blocks'][0]['children'][3];
$document['schemaVersion'] = 26;
$document['blocks'][0]['children'][3]['props'] += ['icon' => 'phone', 'iconMotion' => 'slide', 'iconOnly' => true];
$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'].'" class="button cms-button[^"]* icon-motion-slide icon-only"[^>]*><span class="button-label">'.preg_quote($button['props']['text'], '/').'<\/span> <span class="button-icon"/', $html);
$document['blocks'][0]['children'][3]['props'] = ['text' => $button['props']['text'], 'href' => $button['props']['href'], 'icon' => 'download', 'iconPosition' => 'start', 'iconMotion' => 'reveal'];
$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'].'" class="button cms-button[^"]* icon-start icon-motion-reveal"/', $html);
$css = (string) file_get_contents(__DIR__.'/../public/assets/site.css');
self::assertStringContainsString('.cms-button.icon-motion-reveal:is(:hover,:focus-visible) .button-icon{max-width:4em;opacity:1', $css);
self::assertStringContainsString('.cms-button .button-icon>svg{width:var(--icon-size,1.15em)', $css);
}
/**
* Farbe, Fläche, Größe und Drehung des Symbols liegen am Button und treffen seine
* Symbolhülle - je Gerät und beim Überfahren, auch bei Tastaturfokus.
*/
public function testButtonIconStylesTargetTheIconPerDeviceAndOnHover(): void
{
$document = ['schemaVersion' => 26, 'title' => 'T', 'description' => '', 'blocks' => [['id' => 'b1', 'type' => 'button', 'props' => ['text' => 'Anrufen', 'href' => '/', 'icon' => 'phone'], 'style' => [
'desktop' => ['iconColor' => '@accent', 'iconBackgroundColor' => '#ffffff', 'iconSize' => '20px', 'iconPadding' => '8px', 'iconRadius' => '999px', 'iconGap' => '12px', 'hover' => ['iconColor' => '#000000', 'iconRotate' => '45deg']],
'mobile' => ['iconSize' => '16px'],
]]]];
$css = (new StyleCompiler())->compileDocument($document);
self::assertStringContainsString('#b1 .button-icon{color:var(--k-accent);background-color:#ffffff;--icon-size:20px;padding:8px;border-radius:999px}', $css);
self::assertStringContainsString('#b1:hover .button-icon,#b1:focus-visible .button-icon{color:#000000;rotate:45deg}', $css);
self::assertStringContainsString('--icon-gap:12px', $css);
self::assertMatchesRegularExpression('/@media \(max-width:\d+px\)\{#b1 \.button-icon\{--icon-size:16px\}\}/', $css);
// Die Werte gelten nur der Symbolhülle, nicht dem Button selbst.
self::assertStringNotContainsString('#b1{color:var(--k-accent)', $css);
}
#[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')] #[DataProvider('invalidDocuments')]
+7
View File
@@ -656,4 +656,11 @@ test('a loaded document is raised to the schema its blocks and properties need',
assert.equal(repairSchema(plain).schemaVersion, 5); assert.equal(repairSchema(plain).schemaVersion, 5);
const faq = { schemaVersion: 10, title: 'F', description: '', blocks: [{ id: 'a', type: 'accordion', props: { faq: true }, children: [] }] }; const faq = { schemaVersion: 10, title: 'F', description: '', blocks: [{ id: 'a', type: 'accordion', props: { faq: true }, children: [] }] };
assert.equal(repairSchema(faq).schemaVersion, 23); 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);
const bewegt = { schemaVersion: 24, title: 'B', description: '', blocks: [{ id: 'b', type: 'button', props: { text: 'Mehr', href: '/', iconMotion: 'slide' } }] };
assert.equal(repairSchema(bewegt).schemaVersion, 26);
}); });
+29
View File
@@ -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, 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. Abhilfe: node scripts/build-icons.mjs
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), allowed);
});
test('every icon has a German name and sits in exactly one category of the dialog', () => {
assert.deepEqual(Object.keys(ICON_LABELS).sort(), Object.keys(ICON_PATHS).sort());
const listed = ICON_GROUPS.flatMap(([, names]) => names);
assert.equal(new Set(listed).size, listed.length, 'Ein Symbol steht in zwei Kategorien.');
assert.deepEqual([...listed].sort(), Object.keys(ICON_PATHS).sort(), 'Jedes Symbol steht in einer Kategorie.');
// Der bisherige Pfeil steht vorn: ihn trägt ein Button ohne eigene Wahl.
assert.equal(ICON_GROUPS[0][1][0], 'arrow-up-right');
assert.ok(listed.length >= 90, `Nur ${listed.length} Symbole.`);
for (const path of Object.values(ICON_PATHS)) assert.match(path, /^[Mm][0-9MmLlHhVvCcSsQqTtAaZz .,-]+$/, 'Ein Pfad enthält Zeichen, die keine Zeichnung sind.');
});