Compare commits

...
6 Commits
Author SHA1 Message Date
kantarciandClaude Opus 5.5 28b2442188 Release 1.26.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 22:39:57 +02:00
kantarciandClaude Opus 5.5 8e628b9417 Keep a checkbox from toggling when the empty part of its row is clicked
Check labels spanned the full width, so a click in the empty middle of
"Als Button darstellen" unticked it and folded the menu button settings.
The label now ends after its text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:39:57 +02:00
kantarciandClaude Opus 5.5 13f6759df8 Let menu entries appear as buttons with icon, colours and phone placement
The menu editor gets "Als Button darstellen" per entry: shape, appearance and
size from the button design, fill and ink with hover and palette, icon colour,
icon from the dialog, motion, icon-only and where the button sits on the phone
(in the opened menu, beside the burger, or there as an icon only). Colours go
into the page stylesheet under a stable per-button id because the CSP forbids
inline styles. ADR 110.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:36:12 +02:00
kantarciandClaude Opus 5.5 48ae2443cd Use the core truck icon in the Hüfner advantages bar
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
Since 1.25.0 the icon set has a truck, so the fleet advantage no longer needs
an own-HTML block with the template's SVG. The seed builds it like its three
neighbours (icon block, 32 px, light green); the local Hüfner database was
patched the same way and republished.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:10:00 +02:00
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
36 changed files with 1298 additions and 248 deletions

No files matched your search

+5 -1
View File
@@ -4,6 +4,10 @@ Selbst betreibbares Block-CMS für PHP-Webhosting mit visuellem Editor nach dem
Die übernommenen Originaldokumente sind `AGENTS.md` und `ENTWICKLUNGSPLAN.md`. Den tatsächlichen Stand enthält `docs/STATUS.md`, Entscheidungen und Datenmodell stehen in `docs/ARCHITECTURE.md`. Die übernommenen Originaldokumente sind `AGENTS.md` und `ENTWICKLUNGSPLAN.md`. Den tatsächlichen Stand enthält `docs/STATUS.md`, Entscheidungen und Datenmodell stehen in `docs/ARCHITECTURE.md`.
## Buttons im Menü (ab 1.26.0)
Unter **Menüs** einen Eintrag aufklappen und **Als Button darstellen** anhaken. Dann stehen Form, Darstellung und Größe (leer = Website-Standard unter Design → Buttons), Fläche und Schrift – auch beim Überfahren – sowie die Symbolfarbe mit der Farbpalette zur Wahl; das Symbol wird wie im Editor im Fenster gewählt, dazu Bewegung und „Nur das Symbol zeigen“. **Auf dem Telefon**: im aufgeklappten Menü (volle Breite), neben dem Menü-Knopf oder dort nur als Symbol. Eine Vorschau zeigt den Button normal und beim Überfahren. Einträge mit Unterpunkten können kein Button sein. Prüfung: `npm run test:menu-buttons`, Screenshots unter `reports/menu-buttons/`. Details: ADR 110.
## Bildecken unter Design (ab 1.21.0) ## Bildecken unter Design (ab 1.21.0)
Wie stark die Ecken der Bilder gerundet sind, steht jetzt unter **Design → Bildecken** und gilt für die ganze Website – Bilder, Galerien, Logoleisten und Beitragslisten. Vorgabe ist `12px` wie bisher, `0px` macht sie eckig. Ein einzelnes Bild lässt sich davon abweichend einstellen: im Editor beim ausgewählten Bild unter **Design → Bild → Bildecken**, dort auch je Gerät und je Ecke. Vorher stand die Rundung fest im Stylesheet und war in keinem Feld zu finden. Wie stark die Ecken der Bilder gerundet sind, steht jetzt unter **Design → Bildecken** und gilt für die ganze Website – Bilder, Galerien, Logoleisten und Beitragslisten. Vorgabe ist `12px` wie bisher, `0px` macht sie eckig. Ein einzelnes Bild lässt sich davon abweichend einstellen: im Editor beim ausgewählten Bild unter **Design → Bild → Bildecken**, dort auch je Gerät und je Ecke. Vorher stand die Rundung fest im Stylesheet und war in keinem Feld zu finden.
@@ -216,7 +220,7 @@ 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`.
**Symbol am Button (Schema v25):** Button auswählen → Inhalt → **Symbol**. Die Kacheln zeigen jedes Symbol als Vorschau, geordnet nach Pfeilen, Kontakt und Allgemein; „Kein Symbol“ steht vorn. Darunter **Symbol steht: Vor dem Text / Nach dem Text**. Ohne eigene Wahl trägt ein Button wie bisher den schrägen Pfeil ↗. Die Kacheln bedienen sich auch mit den Pfeiltasten. Symbol und Symbolkarte wählen ihr Symbol mit derselben Auswahl. Prüfung mit isolierter Kant-IT-Kopie: `npm run test:button-icons`, Screenshots unter `reports/button-icons/`. Datenvertrag: ADR 108. **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.
+1 -1
View File
@@ -1 +1 @@
1.24.0 1.26.0
+32
View File
@@ -2,6 +2,38 @@
Stand: 15.09.2026. Arbeitstitel KantSites; Produktname offen. Stand: 15.09.2026. Arbeitstitel KantSites; Produktname offen.
## ADR 110 – Menüeinträge als Button (07.10.2026)
Umut: „Buttons im Menü sind in KantSites aktuell nicht möglich … kannst du das im Menüeditor einbauen, dass man individuell Buttons nach dem gleichen Prinzip wie im Editor einbauen kann? Mit Icon, Farbe usw.“ Typisch ist „Angebot anfragen“ oder „Anrufen“ rechts im Kopf, auf dem Telefon griffbereit neben dem Menü-Knopf.
**Datenvertrag (keine Schemaänderung der Seiten).** Ein Menüeintrag trägt optional `button` = `{id, shape?, appearance?, size?, fill?, ink?, hoverFill?, hoverInk?, iconColor?, mobile, iconMotion?, iconOnly?}` im JSON-Baum des Menüs (ADR 012). Form, Darstellung und Größe stammen aus `ButtonDesign::OPTIONS`, Bewegung und „nur Symbol“ wie am Button-Baustein (ADR 109), das Symbol ist das vorhandene `icon` des Eintrags. Fehlende Werte heißen Website-Standard unter Design → Buttons. Farben sind `#hex` oder `@palette` (`StyleSchema::COLOR`). `mobile`: `menu` (im aufgeklappten Menü, volle Breite), `bar` (neben dem Menü-Knopf) oder `bar-icon` (dort nur als Symbol – verlangt ein Symbol). Ein Button hat keine Unterpunkte. `MenuButton::validate` lehnt alles andere ab, also auch CSS in Farbwerten.
**Darstellung.** `templates/menu-button.html.twig` erzeugt dieselben Klassen wie der Button-Baustein (`cms-button`, `b-desktop-*`, `icon-start`, `icon-motion-*`, `icon-only`), damit gelten `buttons.css`, Website-Standards, Hover und „Bewegung reduzieren“ unverändert. Die Menüregeln für Links (Polster, Unterstreichung, Großschrift im aufgeklappten Kopf, `margin-top` von `.button`) gelten für Menü-Buttons nicht.
**Eigene Farben ohne Inline-Stil.** Die CSP erlaubt keine `style`-Attribute. Jeder Button bekommt deshalb eine feste Kennung (`m` + 10 Hexzeichen, bleibt beim Speichern erhalten), und `MenuRepository::buttonCss` schreibt `.menu-button-<id>{--kb-fill…}` samt der abgeleiteten Werte der Darstellung in das Seiten-Stylesheet – auf der Website, in der Editor- und in der Bereichsvorschau.
**Telefon.** `bar`/`bar-icon` werden im Menübaustein ein zweites Mal als `ul.menu-actions` vor dem Menü-Knopf ausgegeben; sichtbar nur, solange das Menü zusammengeklappt ist (Tablet bzw. Telefon je nach Einstellung), im aufgeklappten Menü ist das Original dann ausgeblendet (`menu-keep-visible`) – nichts steht doppelt. Neben dem Knopf sind Buttons kompakter und brechen nicht um; Leiste und Knopf bleiben in einer Zeile. Erster Versuch nur mit `bar`: Ein zweizeiliger Text-Button schob den Menü-Knopf auf dem 390-px-Telefon in eine neue Zeile – daher die Variante nur mit Symbol.
**Menüverwaltung.** Je Eintrag „Als Button darstellen“; erst dann erscheinen Form, Darstellung, Größe, fünf Farbfelder mit Palette (wie im Editor), Bewegung, „Nur das Symbol zeigen“ und die Telefon-Anzeige, dazu eine Vorschau normal und beim Überfahren. Das Symbol wird wie im Editor im Fenster gewählt (ADR 109). Ohne JavaScript bleiben alle Felder normale Formularfelder.
**Geprüft:** PHPUnit vollständig, darunter `testAMenuEntryRendersAsAButtonWithItsOwnColoursAndStaysBesideTheBurger`, neun Ablehnungsfälle und `testMenuFormTurnsAnEntryIntoAButtonAndKeepsItsId`. `npm test`. `npm run test:menu-buttons` im Browser auf isolierter Pilotkopie: Verwaltung mit Fenster, Palette, Vorschau und axe; Speichern/Neuladen; Website bei 1440 und 390 px (Symbol und Menü-Knopf auf einer Linie, aufgeklappt nicht doppelt, zweiter Button im aufgeklappten Menü in voller Breite mit Rahmen, axe, kein waagerechtes Scrollen). Screenshots unter `reports/menu-buttons/`.
**Bekannt:** Ist die Spalte mit dem Menü schmal (Pilot: zwei gleich breite Spalten), bricht ein Button wie jeder Menüeintrag in die nächste Zeile; abhelfen über die Spaltenbreite oder Größe „Klein“. Menüs mit Buttons brauchen einen Kern ab 1.26.0; ältere Kerne lehnen den unbekannten Schlüssel ab.
## 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) ## 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). 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).
+31
View File
@@ -1,5 +1,36 @@
# Projektstatus # Projektstatus
## Fassung 1.26.0: Menüeinträge als Button (07.10.2026)
**Anlass:** Umut: Buttons im Menü – individuell wie im Editor, mit Symbol und Farbe, auf dem Telefon im aufgeklappten Menü oder neben dem Menü-Knopf.
**Neu (ADR 110):** In der Menüverwaltung je Eintrag „Als Button darstellen“ mit Form, Darstellung, Größe, Fläche/Schrift (auch beim Überfahren), Symbolfarbe, Symbol aus dem Fenster, Bewegung, „nur Symbol“ und Anzeige auf dem Telefon (im Menü / neben dem Menü-Knopf / dort nur als Symbol). Vorschau normal und beim Überfahren.
**Geprüft:** PHPUnit vollständig, `npm test`, `npm run test:menu-buttons` (Browser, axe, 1440 und 390 px, je ein Button neben dem Menü-Knopf und im aufgeklappten Menü), `lint:twig`, php-cs-fixer.
**Bekannt:** In schmalen Kopfspalten bricht der Button in die nächste Zeile (wie jeder Menüeintrag). Kern ab 1.26.0 nötig.
## 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.
**Veröffentlicht:** Release v1.25.0 auf git.kant-it.de mit Archiv und `install.php`, Prüfsumme nach dem Herunterladen bestätigt.
**Hüfner aktualisiert (07.10.2026):** Die lokale Installation läuft aus diesem Repository und hat damit 1.25.0. Auf der Startseite ist das LKW-Symbol der Vorteilsleiste (`vorteil-3-icon`) jetzt der Baustein „Symbol“ mit `truck`, gestaltet wie die drei Nachbarn (32 px, `@hellgruen`), statt „Eigenes HTML“. Geändert direkt in `var/huefner.db` (veröffentlicht, Sicherung `var/huefner.db.vor-lkw`) und in `scripts/seed-huefner.php`, damit ein Neuaufbau dasselbe ergibt. Geprüft im Browser (Symbol sichtbar, keine HTML-Bausteine mehr) und mit `test:browser` (grün, HTML 148 KB).
**Nächster Schritt:** Livegang Hüfner nach `docs/LIVEGANG.md`, die übrigen Installationen auf 1.25.0 aktualisieren.
## Fassung 1.24.0: Symbol am Button wählbar, Kachelauswahl für Symbole (07.10.2026) ## 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.“ **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.“
+2
View File
@@ -6,6 +6,8 @@
"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", "test:button-icons": "node scripts/button-icon-check.mjs",
"test:menu-buttons": "node scripts/menu-button-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",
+3 -1
View File
@@ -70,7 +70,9 @@ textarea { resize: vertical; min-height: 110px; line-height: 1.6 }
label { display: block; margin: 18px 0 7px; font-size: 13px; font-weight: 600 } label { display: block; margin: 18px 0 7px; font-size: 13px; font-weight: 600 }
.field-help { font-size: 11px; line-height: 1.55; color: var(--muted); margin: 7px 0 0 } .field-help { font-size: 11px; line-height: 1.55; color: var(--muted); margin: 7px 0 0 }
.field-help code, .card code { background: #eef2ea; padding: 1px 5px; border-radius: 4px; font-size: .95em } .field-help code, .card code { background: #eef2ea; padding: 1px 5px; border-radius: 4px; font-size: .95em }
.check-label { display: flex; align-items: center; gap: 8px; font-weight: 500 } /* Nur Häkchen und Text sind klickbar: über die volle Breite schaltete ein Klick in die leere Mitte
der Zeile das Häkchen um - in der Menüverwaltung klappten so die Button-Einstellungen zu. */
.check-label { display: flex; align-items: center; gap: 8px; font-weight: 500; width: fit-content; max-width: 100% }
.check-label input { width: 16px; min-height: 16px } .check-label input { width: 16px; min-height: 16px }
/* A grid of fields that becomes one column when there is no room. */ /* A grid of fields that becomes one column when there is no room. */
.fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 0 24px } .fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 0 24px }
+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}}.icon-picker{margin:4px 0 16px}.icon-picker-label{margin:0 0 6px;font-size:13px;font-weight:700;color:#203b34}.icon-group-title{margin:10px 0 6px;font-size:11px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;color:#5b6b64}.icon-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(40px,1fr));gap:6px}.icon-tile{display:grid;place-items:center;aspect-ratio:1;min-height:40px;padding:0;background:#fff;border:1px solid #dce4dc;border-radius:9px;color:#203b34;cursor:pointer;transition:border-color .15s,background-color .15s}.icon-tile svg{width:22px;height:22px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}.icon-tile:hover{border-color:#8fb39f;background:#f7faf5}.icon-tile[aria-pressed=true]{border-color:#2c6b4f;box-shadow:inset 0 0 0 1px #2c6b4f;background:#e8f2e4;color:#1d4d37}.icon-tile:focus-visible{outline:3px solid #b55b12;outline-offset:2px}.icon-tile-none{grid-column:span 2;aspect-ratio:auto}.icon-tile-none::after{content:"Kein Symbol";font-size:12px;font-weight:600}.icon-tile-empty{display:none}.icon-position{margin:0 0 16px}.segmented{display:grid;grid-template-columns:1fr 1fr;border:1px solid #dce4dc;border-radius:9px;overflow:hidden}.segmented button{min-height:38px;border:0;background:#fff;color:#203b34;font-size:13px;font-weight:600;cursor:pointer}.segmented button+button{border-left:1px solid #dce4dc}.segmented button[aria-pressed=true]{background:#2c6b4f;color:#fff}.segmented button:focus-visible{outline:3px solid #b55b12;outline-offset:-3px} .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) {
+3 -1
View File
@@ -175,6 +175,7 @@ export function repairSchema(document) {
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' && ('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);
} }
}; };
@@ -343,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 = 25; 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' };
@@ -685,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';
+40 -21
View File
@@ -12,7 +12,7 @@ import { initSliders } from './slider.js';
import { VISIBILITY, setVisibility, registerPlugins, pluginDefinition, PLUGIN_PRESETS, repairSchema } from './editor-model.js'; import { VISIBILITY, setVisibility, registerPlugins, pluginDefinition, PLUGIN_PRESETS, repairSchema } from './editor-model.js';
// Symbole, ihre deutschen Namen und die Kachelauswahl. Die Liste spiegelt DocumentValidator::ICONS. // Symbole, ihre deutschen Namen und die Kachelauswahl. Die Liste spiegelt DocumentValidator::ICONS.
import { renderIconPicker, BUTTON_ICON_GROUPS, ALL_ICON_GROUPS } from './icons.js'; import { renderIconField } from './icons.js';
const ICON_SIZES = [12, 16, 20, 24, 32, 40, 48, 56, 64, 80, 96, 120, 160, 200, 240]; 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,21 +347,37 @@ function renderContent(block, panel) {
if (key === 'href' && !options.plugin) field('In neuem Tab öffnen', 'newTab', block.props.newTab === true, { checkbox: true, container }); if (key === 'href' && !options.plugin) field('In neuem Tab öffnen', 'newTab', block.props.newTab === true, { checkbox: true, container });
} }
}; };
// Symbolwahl als Kacheln mit Vorschau. Nach der Wahl wird das Panel neu gezeichnet, damit die // Eine Eigenschaft setzen, die kein Textfeld ist (Symbol, Stellung, Bewegung). Danach wird das
// Markierung stimmt, und der Fokus bleibt auf der gewählten Kachel. // Panel neu gezeichnet, und der Fokus kehrt zu dem Bedienelement zurück, das sie gesetzt hat.
const choose = (key, next, version) => { const choose = (key, next, version, focus) => {
history.change(doc => { history.change(doc => {
const current = locate(doc.blocks, selected)?.block; const current = locate(doc.blocks, selected)?.block;
if (!current) return; if (!current) return;
(current.props ??= {})[key] = next; (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); if (version) upgradeDocument(doc, version);
}, null); }, null);
renderProperties(); renderTree(); controls(); schedulePreview(); renderProperties(); renderTree(); controls(); schedulePreview();
document.querySelector(key === 'iconPosition' ? `.icon-position [data-position="${next}"]` : `.icon-tile[data-icon="${next}"]`)?.focus({ preventScroll: true }); document.querySelector(focus)?.focus({ preventScroll: true });
}; };
const iconField = (label, value, groups, options = {}) => panel.append(renderIconPicker({ const iconField = (label, value, options = {}) => panel.append(renderIconField({
label, value, groups, none: options.none, onChoose: name => choose('icon', name, options.version), 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 });
@@ -403,19 +420,21 @@ function renderContent(block, panel) {
if (block.type === 'button') { if (block.type === 'button') {
// Ohne eigene Wahl trägt ein Button den Pfeil, den er vor v25 immer hatte. // Ohne eigene Wahl trägt ein Button den Pfeil, den er vor v25 immer hatte.
const icon = block.props.icon ?? 'arrow-up-right'; const icon = block.props.icon ?? 'arrow-up-right';
iconField('Symbol', icon, BUTTON_ICON_GROUPS, { none: 'Kein Symbol', version: 25 }); iconField('Symbol', icon, { none: 'Kein Symbol', version: 25 });
if (icon !== 'none') { if (icon !== 'none') {
const position = block.props.iconPosition === 'start' ? 'start' : 'end'; segmented('Stellung', 'iconPosition', block.props.iconPosition === 'start' ? 'start' : 'end', [['start', 'Vor dem Text'], ['end', 'Nach dem Text']], 25);
const wrap = document.createElement('div'); wrap.className = 'icon-position'; segmented('Beim Überfahren', 'iconMotion', block.props.iconMotion ?? 'none', [['none', 'Ruhig'], ['slide', 'Rückt'], ['reveal', 'Erscheint']], 26);
const caption = document.createElement('p'); caption.className = 'icon-picker-label'; caption.id = 'icon-position-label'; caption.textContent = 'Symbol steht'; 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.' });
const group = document.createElement('div'); group.className = 'segmented'; group.setAttribute('role', 'group'); group.setAttribute('aria-labelledby', caption.id); // Farbe, Fläche, Größe und Drehung sind Gestaltung: sie gelten je Gerät und beim Überfahren,
for (const [value, text] of [['start', 'Vor dem Text'], ['end', 'Nach dem Text']]) { // wie alle anderen Werte unter Design. Ein Knopf führt direkt dorthin.
const option = document.createElement('button'); option.type = 'button'; option.dataset.position = value; option.textContent = text; const design = document.createElement('button'); design.type = 'button'; design.className = 'text-button icon-design-link';
option.setAttribute('aria-pressed', String(value === position)); design.textContent = 'Farbe, Größe und Fläche des Symbols → Design';
option.addEventListener('click', () => { if (value !== position) choose('iconPosition', value, 25); }); design.addEventListener('click', () => {
group.append(option); document.querySelector('[data-panel="design"]')?.click();
} const group = document.querySelector('.design-group[data-group="icon"]');
wrap.append(caption, group); panel.append(wrap); 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);
@@ -432,7 +451,7 @@ function renderContent(block, panel) {
} }
if (block.type === 'heading') field('Überschriftenebene', 'level', block.props.level, { number: true, choices: { 1: 'H1 · Hauptüberschrift', 2: 'H2 · Abschnitt', 3: 'H3 · Unterabschnitt', 4: 'H4', 5: 'H5', 6: 'H6' }, help: 'Jede Seite benötigt genau eine H1.' }); if (block.type === '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') {
iconField('Symbol', block.props.icon, ALL_ICON_GROUPS); 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.' });
@@ -467,7 +486,7 @@ function renderContent(block, panel) {
} }
if (block.type === 'partialref') field('Sektion', 'partial', block.props.partial, { ...pick('sections', block.props.partial), help: 'Die Sektion wird verknüpft, nicht kopiert: eine Änderung dort wirkt auf allen Seiten, die sie einbinden. Angelegt und bearbeitet wird sie unter „Bereiche“.' }); if (block.type === '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') {
iconField('Symbol', block.props.icon, ALL_ICON_GROUPS); iconField('Symbol', block.props.icon);
// number, so the value is stored as a number: the server wants an int, not "48". // 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.' });
+245 -109
View File
@@ -1,62 +1,146 @@
// Die Zeichnungen der Symbole für die Auswahl im Editor. Die Website zeichnet dieselben Pfade aus // Der Symbolsatz: Zeichnung, deutscher Name und Kategorie. Die Website zeichnet dieselben Pfade aus
// templates/blocks/icon.html.twig; ein Test hält beide Listen und DocumentValidator::ICONS gleich. // 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 = { export const ICON_PATHS = {
app: 'M7 2h10a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Zm4 17h2', // Pfeile und Navigation
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', 'arrow-up-right': 'M6.5 17.5 17.5 6.5M8.5 6.5h9v9',
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', '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-right': 'm9 5.5 6.5 6.5L9 18.5',
calendar: 'M4 6h16v14H4Zm0 5h16M8 3.5v4m8-4v4', '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', chat: 'M4 5h16v11H9.5L5 19.5V16H4Z',
send: 'M21 3 10 14M21 3l-6.5 18-4.5-7-7-4.5Z', send: 'M21 3 10 14M21 3l-6.5 18-4.5-7-7-4.5Z',
download: 'M12 3.5v12m-5-5 5 5 5-5M4.5 20h15', 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', 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', play: 'M8 5.5v13l10-6.5Z',
search: 'M10.5 4a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13Zm4.8 11.3L20 20', pause: 'M8 5v14m8-14v14',
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', 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',
info: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 8v5.5m0-9v.5', 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',
plus: 'M12 5v14M5 12h14', 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', 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 = { export const ICON_LABELS = {
app: 'Smartphone', web: 'Globus', design: 'Stift', network: 'Netzwerk', hardware: 'Bildschirm', 'arrow-up-right': 'Pfeil schräg', 'arrow-right': 'Pfeil nach rechts', 'arrow-left': 'Pfeil nach links',
shop: 'Einkaufstasche', support: 'Kopfhörer', shield: 'Schutz', clock: 'Uhr', star: 'Stern', 'arrow-up': 'Pfeil nach oben', 'arrow-down': 'Pfeil nach unten', 'chevron-right': 'Winkel rechts',
phone: 'Telefon', mail: 'E-Mail', pin: 'Ort', check: 'Häkchen', people: 'Personen', music: 'Musik', 'chevron-left': 'Winkel links', 'chevron-up': 'Winkel oben', 'chevron-down': 'Winkel unten',
home: 'Haus', 'arrow-right': 'Pfeil nach rechts', 'arrow-left': 'Pfeil nach links', 'arrow-circle': 'Pfeil im Kreis', external: 'Externer Link', refresh: 'Aktualisieren', menu: 'Menü',
'arrow-up-right': 'Pfeil schräg', 'arrow-down': 'Pfeil nach unten', 'chevron-right': 'Winkel', close: 'Schließen', plus: 'Plus', minus: 'Minus',
calendar: 'Kalender', chat: 'Nachricht', send: 'Senden', download: 'Download', document: 'Dokument', phone: 'Telefon', app: 'Smartphone', mail: 'E-Mail', chat: 'Nachricht', send: 'Senden', calendar: 'Kalender',
play: 'Abspielen', search: 'Suche', heart: 'Herz', info: 'Info', plus: 'Plus', user: 'Person', 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 Auswahl am Button: die Symbole, die man an einem Knopf wirklich braucht, nach Zweck geordnet. */ /** Die Kategorien des Auswahlfensters, in der Reihenfolge der Reiter. Jedes Symbol steht genau einmal. */
export const BUTTON_ICON_GROUPS = [ export const ICON_GROUPS = [
['Pfeile', ['arrow-up-right', 'arrow-right', 'chevron-right', 'arrow-down', 'arrow-left']], ['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', ['phone', 'mail', 'chat', 'send', 'calendar', 'pin']], ['Kontakt & Ort', ['phone', 'app', 'mail', 'chat', 'send', 'calendar', 'clock', 'pin', 'map', 'navigation', 'web', 'wifi', 'support']],
['Allgemein', ['download', 'document', 'play', 'search', 'check', 'star', 'heart', 'info', 'plus', 'user', 'home', 'shop', 'clock']], ['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']],
/** Alle Symbole für Symbol und Symbolkarte, in derselben Ordnung plus die übrigen. */ ['Haus & Handwerk', ['home', 'building', 'wrench', 'hammer', 'brush', 'ruler', 'leaf', 'tree', 'sun', 'droplet', 'flame', 'bolt', 'solar', 'thermometer', 'snowflake', 'car']],
export const ALL_ICON_GROUPS = [ ['Allgemein', ['check', 'check-circle', 'info', 'help', 'alert', 'search', 'settings', 'filter', 'grid', 'list', 'sparkle', 'coffee', 'utensils', 'laptop', 'hardware', 'network', 'design']],
...BUTTON_ICON_GROUPS,
['Weitere', ['app', 'web', 'design', 'network', 'hardware', 'support', 'shield', 'people', 'music']],
]; ];
const svgNS = 'http://www.w3.org/2000/svg'; const svgNS = 'http://www.w3.org/2000/svg';
@@ -66,79 +150,131 @@ export function iconSvg(name) {
svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('aria-hidden', 'true'); svg.setAttribute('aria-hidden', 'true');
svg.setAttribute('focusable', 'false'); svg.setAttribute('focusable', 'false');
// Wie auf der Website: Linien in der Textfarbe. CSS darf Größe und Strichstärke überschreiben.
for (const [name, value] of Object.entries({ fill: 'none', stroke: 'currentColor', 'stroke-width': '1.6', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' })) svg.setAttribute(name, value);
const path = document.createElementNS(svgNS, 'path'); const path = document.createElementNS(svgNS, 'path');
path.setAttribute('d', ICON_PATHS[name] ?? ''); path.setAttribute('d', ICON_PATHS[name] ?? '');
svg.append(path); svg.append(path);
return svg; 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;
};
/** /**
* Eine Kachelauswahl statt einer Liste aus Namen: jedes Symbol ist ein Knopf mit seiner Zeichnung, * Das Feld im Panel: das gewählte Symbol als Vorschau mit Namen und ein Knopf, der das
* der gewählte ist markiert. Pfeiltasten wandern innerhalb der Gruppe, Tab verlässt die Auswahl. * 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, groups: Array<[string, string[]]>, none?: string, onChoose: (name: string) => void }} options * @param {{ label: string, value: string, none?: string, onChoose: (name: string) => void }} options
*/ */
export function renderIconPicker({ label, value, groups, none, onChoose }) { export function renderIconField({ label, value, none, onChoose }) {
const wrap = document.createElement('div'); const wrap = element('div', 'icon-field');
wrap.className = 'icon-picker'; wrap.append(element('p', 'icon-picker-label', label));
const heading = document.createElement('p'); const row = element('div', 'icon-field-row');
heading.className = 'icon-picker-label'; const preview = element('span', 'icon-field-preview');
heading.id = `icon-picker-${Math.random().toString(36).slice(2, 8)}`; preview.setAttribute('aria-hidden', 'true');
heading.textContent = label; if (value && value !== 'none') preview.append(iconSvg(value)); else preview.classList.add('is-empty');
wrap.append(heading); const name = element('span', 'icon-field-name', value && value !== 'none' ? ICON_LABELS[value] ?? value : none ?? 'Kein Symbol');
const tiles = []; const open = element('button', 'secondary icon-field-change', 'Ändern …');
const tile = name => { open.type = 'button';
const button = document.createElement('button'); open.setAttribute('aria-haspopup', 'dialog');
button.type = 'button'; open.setAttribute('aria-label', `${label} ändern, aktuell: ${name.textContent}`);
button.className = name === 'none' ? 'icon-tile icon-tile-none' : 'icon-tile'; open.addEventListener('click', () => openIconDialog({ value, none, onChoose, returnFocus: open }));
button.dataset.icon = name; row.append(preview, name, open);
const text = name === 'none' ? none : ICON_LABELS[name]; wrap.append(row);
button.title = text;
button.setAttribute('aria-label', text);
button.setAttribute('aria-pressed', String(name === value));
button.tabIndex = name === value ? 0 : -1;
if (name === 'none') {
const mark = document.createElement('span');
mark.className = 'icon-tile-empty';
mark.setAttribute('aria-hidden', 'true');
button.append(mark);
} else button.append(iconSvg(name));
button.addEventListener('click', () => onChoose(name));
tiles.push(button);
return button;
};
const group = document.createElement('div');
group.setAttribute('role', 'group');
group.setAttribute('aria-labelledby', heading.id);
if (none) {
const row = document.createElement('div');
row.className = 'icon-grid';
row.append(tile('none'));
group.append(row);
}
for (const [title, names] of groups) {
const caption = document.createElement('p');
caption.className = 'icon-group-title';
caption.textContent = title;
const grid = document.createElement('div');
grid.className = 'icon-grid';
for (const name of names) grid.append(tile(name));
group.append(caption, grid);
}
// Ohne gewählten Knopf in der Liste (etwa ein Symbol außerhalb der Gruppen) bleibt der erste
// erreichbar, sonst käme man mit Tab gar nicht hinein.
if (!tiles.some(button => button.tabIndex === 0)) tiles[0].tabIndex = 0;
group.addEventListener('keydown', event => {
const index = tiles.indexOf(document.activeElement);
if (index < 0) return;
const step = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[event.key];
const target = event.key === 'Home' ? 0 : event.key === 'End' ? tiles.length - 1 : step ? (index + step + tiles.length) % tiles.length : null;
if (target === null) return;
event.preventDefault();
tiles[index].tabIndex = -1;
tiles[target].tabIndex = 0;
tiles[target].focus();
});
wrap.append(group);
return wrap; 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;
}
+112 -4
View File
@@ -1,5 +1,8 @@
import { setupPicker } from './page-picker.js'; import { setupPicker } from './page-picker.js';
import './link-field.js'; import './link-field.js';
import { renderIconField, iconSvg, ICON_LABELS } from './icons.js';
import { createColorControl } from './color-control.js';
import { buttonSample } from './button-design.js';
const form = document.querySelector('#menu-editor'); const form = document.querySelector('#menu-editor');
const list = form.querySelector('#menu-items'); const list = form.querySelector('#menu-items');
@@ -8,7 +11,8 @@ const error = form.querySelector('#menu-error');
const history = []; const history = [];
let serial = list.children.length, pendingPage = null, busy = false; let serial = list.children.length, pendingPage = null, busy = false;
const el = (tag, className = '', text = '') => { const node = document.createElement(tag); node.className = className; node.textContent = text; return node; }; const el = (tag, className = '', text = '') => { const node = document.createElement(tag); node.className = className; node.textContent = text; return node; };
const iconNames = { app: 'App', web: 'Web', design: 'Gestaltung', network: 'Netzwerk', hardware: 'Computer', shop: 'Shop', support: 'Support', shield: 'Sicherheit', clock: 'Uhr', star: 'Stern', phone: 'Telefon', mail: 'E-Mail', pin: 'Standort', check: 'Haken', people: 'Menschen', music: 'Musik', home: 'Haus' }; const iconNames = ICON_LABELS;
const buttonSchema = JSON.parse(form.dataset.buttonSchema || '{}');
const menuIcons = JSON.parse(form.dataset.icons || '[]'); const menuIcons = JSON.parse(form.dataset.icons || '[]');
const chrome = () => { const chrome = () => {
const handle = el('button', 'menu-drag-handle'); handle.type = 'button'; handle.dataset.menuDrag = ''; const handle = el('button', 'menu-drag-handle'); handle.type = 'button'; handle.dataset.menuDrag = '';
@@ -40,7 +44,9 @@ function refresh() {
const name = row.querySelector('[data-key="label"]').value || 'Neuer Eintrag'; const name = row.querySelector('[data-key="label"]').value || 'Neuer Eintrag';
row.querySelector('.menu-entry-title').textContent = name; row.querySelector('.menu-entry-title').textContent = name;
const icon = row.querySelector('[data-key="icon"]')?.value, position = row.querySelector('[data-key="iconPosition"]')?.value; const icon = row.querySelector('[data-key="icon"]')?.value, position = row.querySelector('[data-key="iconPosition"]')?.value;
const graphic = icon && document.getElementById(`menu-icon-${icon}`)?.content.cloneNode(true); row.querySelector('.menu-entry-title svg')?.remove();
const graphic = icon && iconSvg(icon);
if (graphic) { graphic.setAttribute('width', '18'); graphic.setAttribute('height', '18'); }
if (graphic) row.querySelector('.menu-entry-title')[position === 'after' ? 'append' : 'prepend'](graphic); if (graphic) row.querySelector('.menu-entry-title')[position === 'after' ? 'append' : 'prepend'](graphic);
row.querySelector('[data-menu-drag]')?.setAttribute('aria-label', `${name} verschieben`); row.querySelector('[data-menu-drag]')?.setAttribute('aria-label', `${name} verschieben`);
row.querySelectorAll('[data-key="icon"] option').forEach(option => { if (iconNames[option.value]) option.textContent = iconNames[option.value]; }); row.querySelectorAll('[data-key="icon"] option').forEach(option => { if (iconNames[option.value]) option.textContent = iconNames[option.value]; });
@@ -91,12 +97,15 @@ function createRow(data) {
anchorChoice(row); anchorChoice(row);
} else { const url = field('url', data.url, 'Adresse'); url.required = true; url.maxLength = 2048; linkButtons(url); } } else { const url = field('url', data.url, 'Adresse'); url.required = true; url.maxLength = 2048; linkButtons(url); }
const newTab = field('newTab', '1', 'In neuem Tab öffnen', 'checkbox'); newTab.checked = data.newTab === true || data.newTab === '1'; const newTab = field('newTab', '1', 'In neuem Tab öffnen', 'checkbox'); newTab.checked = data.newTab === true || data.newTab === '1';
for (const [key, label, choices] of [['icon', 'Icon', { '': 'Kein Icon', ...Object.fromEntries(menuIcons.map(icon => [icon, iconNames[icon] || icon])) }], ['iconPosition', 'Icon-Position', { before: 'Vor dem Text', after: 'Nach dem Text' }]]) { const icons = el('div', 'menu-icon-fields'); body.append(icons);
for (const [key, label, choices] of [['icon', 'Symbol', { '': 'Kein Symbol', ...Object.fromEntries(menuIcons.map(icon => [icon, iconNames[icon] || icon])) }], ['iconPosition', 'Symbol steht', { before: 'Vor dem Text', after: 'Nach dem Text' }]]) {
const select = el('select'); select.dataset.key = key; select.id = `menu-${key}-${id}`; const select = el('select'); select.dataset.key = key; select.id = `menu-${key}-${id}`;
for (const [value, caption] of Object.entries(choices)) select.append(new Option(caption, value)); for (const [value, caption] of Object.entries(choices)) select.append(new Option(caption, value));
select.value = data[key] || (key === 'iconPosition' ? 'before' : ''); select.value = data[key] || (key === 'iconPosition' ? 'before' : '');
const caption = el('label', '', label); caption.htmlFor = select.id; body.append(caption, select); const wrap = el('div'); const caption = el('label', '', label); caption.htmlFor = select.id; wrap.append(caption, select); icons.append(wrap);
} }
body.append(buttonFieldset(data, id));
enhance(row);
const actions = el('div', 'menu-entry-actions'); actions.setAttribute('role', 'group'); const actions = el('div', 'menu-entry-actions'); actions.setAttribute('role', 'group');
for (const [action, caption] of Object.entries({ up: '↑', down: '↓', indent: 'Als Unterpunkt', outdent: 'Hauptebene', remove: 'Entfernen' })) { for (const [action, caption] of Object.entries({ up: '↑', down: '↓', indent: 'Als Unterpunkt', outdent: 'Hauptebene', remove: 'Entfernen' })) {
const button = el('button', `text-button${action === 'remove' ? ' danger-text' : ''}`, caption); button.type = 'button'; button.dataset.menuAction = action; actions.append(button); const button = el('button', `text-button${action === 'remove' ? ' danger-text' : ''}`, caption); button.type = 'button'; button.dataset.menuAction = action; actions.append(button);
@@ -156,6 +165,105 @@ function linkButtons(input) {
} }
list.querySelectorAll('input[data-key="url"]').forEach(linkButtons); list.querySelectorAll('input[data-key="url"]').forEach(linkButtons);
/*
* Ein Eintrag als Button (ADR 110): dieselben Felder wie die Vorlage menu-entry.html.twig, damit
* neue und geladene Zeilen gleich aussehen und dieselben Namen tragen. Leer heißt Website-Standard.
*/
const BUTTON_COLORS = { buttonFill: 'Fläche', buttonInk: 'Schrift', buttonHoverFill: 'Fläche beim Überfahren', buttonHoverInk: 'Schrift beim Überfahren', buttonIconColor: 'Symbolfarbe' };
function buttonFieldset(data, id) {
const set = el('fieldset', 'menu-button-fields'); set.dataset.buttonFields = '';
set.append(el('legend', '', 'Button'));
const toggle = el('input'); toggle.type = 'checkbox'; toggle.dataset.key = 'button'; toggle.value = '1'; toggle.id = `menu-button-${id}`;
toggle.checked = data.button === '1' || data.button === true;
const toggleLabel = el('label', 'check-label', ' Als Button darstellen'); toggleLabel.htmlFor = toggle.id; toggleLabel.prepend(toggle);
const hidden = el('input'); hidden.type = 'hidden'; hidden.dataset.key = 'buttonId'; hidden.value = data.buttonId || '';
const options = el('div', 'menu-button-options'); options.dataset.buttonOptions = ''; options.hidden = !toggle.checked;
const select = (key, label, choices, value) => {
const wrap = el('div'); const input = el('select'); input.dataset.key = key; input.id = `menu-${key}-${id}`;
for (const [option, caption] of Object.entries(choices)) input.append(new Option(caption, option));
input.value = value; const caption = el('label', '', label); caption.htmlFor = input.id; wrap.append(caption, input); options.append(wrap);
};
for (const [key, label] of [['shape', 'Form'], ['appearance', 'Darstellung'], ['size', 'Größe']]) {
const field = `button${key[0].toUpperCase()}${key.slice(1)}`;
select(field, label, { '': 'Website-Standard', ...(buttonSchema.buttonOptions?.[key] ?? {}) }, data[field] || '');
}
for (const [key, label] of Object.entries(BUTTON_COLORS)) {
const wrap = el('div'); wrap.dataset.colorField = '';
const input = el('input'); input.dataset.key = key; input.id = `menu-${key}-${id}`; input.value = data[key] || ''; input.placeholder = 'Website-Standard';
const caption = el('label', '', label); caption.htmlFor = input.id; wrap.append(caption, input); options.append(wrap);
}
select('iconMotion', 'Symbol beim Überfahren', { none: 'Ruhig', slide: 'Rückt', reveal: 'Erscheint' }, data.iconMotion || 'none');
const only = el('input'); only.type = 'checkbox'; only.dataset.key = 'iconOnly'; only.value = '1'; only.id = `menu-iconOnly-${id}`; only.checked = data.iconOnly === '1';
const onlyLabel = el('label', 'check-label', ' Nur das Symbol zeigen'); onlyLabel.htmlFor = only.id; onlyLabel.prepend(only); options.append(onlyLabel);
select('buttonMobile', 'Auf dem Telefon', buttonSchema.mobile ?? { menu: 'Im aufgeklappten Menü' }, data.buttonMobile || 'menu');
set.append(toggleLabel, hidden, options);
return set;
}
/*
* Aus Feldern wird Bedienung: das Symbol über das Auswahlfenster statt einer langen Liste, Farben
* mit der Palette der Website wie im Editor, und eine Vorschau des Buttons - normal und beim
* Überfahren. Gespeichert wird weiter, was in den Feldern steht; ohne Skript bleiben sie sichtbar.
*/
function enhance(row) {
if (row.dataset.enhanced) return;
row.dataset.enhanced = '1';
const value = key => row.querySelector(`[data-key="${key}"]`);
const changed = input => input.dispatchEvent(new Event('change', { bubbles: true }));
// Symbol: das Auswahlfenster schreibt in die versteckte Auswahl.
const iconSelect = value('icon');
if (iconSelect) {
const holder = iconSelect.closest('div'); holder.hidden = true;
const mount = el('div', 'menu-icon-choice'); holder.before(mount);
const draw = () => mount.replaceChildren(renderIconField({
label: 'Symbol', value: iconSelect.value || 'none', none: 'Kein Symbol',
onChoose: name => { iconSelect.value = name === 'none' ? '' : name; changed(iconSelect); draw(); preview(); mount.querySelector('.icon-field-change')?.focus(); },
}));
draw();
}
const toggle = value('button'), options = row.querySelector('[data-button-options]');
if (!toggle || !options) return;
const show = () => { options.hidden = !toggle.checked; };
toggle.addEventListener('change', show);
for (const wrap of options.querySelectorAll('[data-color-field]')) {
const input = wrap.querySelector('input'); const caption = wrap.querySelector('label');
input.type = 'hidden';
const control = createColorControl({
id: `${input.id}-control`, label: caption.textContent, value: input.value, colors: buttonSchema.colors ?? [],
onChange: raw => { input.value = raw; changed(input); preview(); },
});
caption.htmlFor = `${input.id}-control`;
wrap.append(control);
}
const box = el('div', 'menu-button-preview'); box.setAttribute('aria-hidden', 'true');
options.prepend(box);
function preview() {
if (!buttonSchema.buttons) return;
const pick = key => value(key)?.value || '';
const values = { ...buttonSchema.buttons };
for (const [field, key] of [['buttonShape', 'shape'], ['buttonAppearance', 'appearance'], ['buttonSize', 'size'], ['buttonFill', 'backgroundColor'], ['buttonInk', 'color'], ['buttonHoverFill', 'hoverBackgroundColor'], ['buttonHoverInk', 'hoverColor']]) {
if (pick(field)) values[key] = pick(field);
}
const icon = pick('icon'), before = pick('iconPosition') !== 'after', only = value('iconOnly')?.checked && icon;
const samples = [['Normal', false], ['Beim Überfahren', true]].map(([title, hover]) => {
const state = el('div', 'button-state'); state.append(el('span', 'field-help', title));
const sample = buttonSample(only ? '\u200b' : (value('label')?.value || 'Button'), values, buttonSchema, hover);
if (icon) {
const holder = el('span', 'button-icon'); holder.append(iconSvg(icon));
const color = pick('buttonIconColor');
if (color) holder.style.color = color.startsWith('@') ? (buttonSchema.colors ?? []).find(entry => `@${entry.key}` === color)?.value || '' : color;
if (before) sample.prepend(holder); else sample.append(holder);
}
state.append(sample); return state;
});
box.replaceChildren(...samples);
}
row.addEventListener('input', preview);
row.addEventListener('change', preview);
show(); preview();
}
list.querySelectorAll('.menu-entry').forEach(enhance);
function restore(value) { function restore(value) {
form.elements.name.value = value.name; list.replaceChildren(...value.rows.map(createRow)); refresh(); last = state(); form.elements.name.value = value.name; list.replaceChildren(...value.rows.map(createRow)); refresh(); last = state();
} }
+29 -2
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-icon,.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-icon>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
@@ -494,6 +494,33 @@ body.consent-open{padding-bottom:320px}
box-shadow:0 15px 25px color-mix(in srgb,var(--ink) 12%,transparent)} box-shadow:0 15px 25px color-mix(in srgb,var(--ink) 12%,transparent)}
.site-header .menu-burger-body .menu-list{gap:0} .site-header .menu-burger-body .menu-list{gap:0}
.site-header .menu-burger-body .menu-list a{padding:12px 10px;font-size:16px} .site-header .menu-burger-body .menu-list a{padding:12px 10px;font-size:16px}
/* Menüeinträge als Button (ADR 110). Die Menüregeln für Links (Polster, Unterstreichung, Schrift
im aufgeklappten Kopf) gelten nicht für sie - sie sehen aus wie jeder andere Button. */
.menu-list a.cms-button,.menu-actions a.cms-button{display:inline-flex;padding:var(--b-py,var(--kb-py,15px)) var(--b-px,var(--kb-px,21px));font-size:var(--kb-font-size,14px);text-decoration:none;margin:0}
.menu-list a.cms-button:hover,.menu-list a.cms-button[aria-current=page]{text-decoration:none}
.menu-list a.cms-button.b-desktop-appearance-link{text-decoration:underline}
.site-header .menu-burger-body .menu-list a.cms-button{padding:var(--b-py,var(--kb-py,15px)) var(--b-px,var(--kb-px,21px));font-size:var(--kb-font-size,14px)}
/* Im aufgeklappten Menü volle Breite und etwas Luft zu den Links darüber. */
.menu-burger-body .menu-item-button{align-self:stretch;margin-top:8px}
.menu-burger-body .menu-item-button>.cms-button:not(.icon-only){width:100%}
/* Neben dem Menü-Knopf stehen bleiben: nur solange das Menü zusammengeklappt ist. */
.menu-actions{display:none;list-style:none;margin:0;padding:0;gap:8px;align-items:center;flex:0 1 auto;min-width:0}
/* Neben dem Menü-Knopf ist wenig Platz: kompakter, in einer Zeile mit dem Knopf. */
.menu-actions a.cms-button{--b-py:8px;--b-px:14px;white-space:nowrap}
.menu-actions a.cms-button.icon-only{--b-px:8px}
nav.menu-burger>.menu-actions{display:flex}
nav.menu-burger:has(>.menu-actions){display:flex;flex-wrap:nowrap;align-items:center;justify-content:flex-end;gap:8px}
nav.menu-burger .menu-burger-body .menu-keep-visible{display:none}
@media (max-width:1024px),(hover:none) and (pointer:coarse){
.menu-collapse-tablet>.menu-actions{display:flex}
.menu-collapse-tablet:has(>.menu-actions){display:flex;flex-wrap:nowrap;align-items:center;justify-content:flex-end;gap:8px}
.menu-collapse-tablet .menu-burger-body .menu-keep-visible{display:none}
}
@media (max-width:767px){
.menu-collapse-mobile>.menu-actions{display:flex}
.menu-collapse-mobile:has(>.menu-actions){display:flex;flex-wrap:nowrap;align-items:center;justify-content:flex-end;gap:8px}
.menu-collapse-mobile .menu-burger-body .menu-keep-visible{display:none}
}
/* Visibility never overwrites the block's grid/flex/display settings. */ /* Visibility never overwrites the block's grid/flex/display settings. */
.visibility-hidden{display:none!important} .visibility-hidden{display:none!important}
.slider-controls button[hidden]{display:none} .slider-controls button[hidden]{display:none}
+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.`);
+116 -54
View File
@@ -1,6 +1,8 @@
// Prüft die Symbolwahl am Button im echten Editor: Kachel wählen, Stellung umschalten, kein Symbol, // Prüft das Symbol am Button im echten Editor auf einer isolierten Kopie der Pilotdaten: Auswahl-
// Tastatur, Rückgängig, Speichern und Neuladen - auf einer isolierten Kopie der Pilotdaten. // 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 { chromium } from 'playwright';
import AxeBuilder from '@axe-core/playwright';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { execFileSync, spawn } from 'node:child_process'; import { execFileSync, spawn } from 'node:child_process';
import { mkdir, writeFile } from 'node:fs/promises'; import { mkdir, writeFile } from 'node:fs/promises';
@@ -38,80 +40,140 @@ try {
await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin'); await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin');
const api = `${origin}/admin/api/pages/${fixture.homeId}`; const api = `${origin}/admin/api/pages/${fixture.homeId}`;
const data = await (await context.request.get(api)).json(); const data = await (await context.request.get(api)).json();
const startVersion = data.document.schemaVersion;
data.document.blocks.unshift({ id: 'qa-icons', type: 'section', children: [ data.document.blocks.unshift({ id: 'qa-icons', type: 'section', children: [
{ id: 'qa-icon-button', type: 'button', props: { text: 'Jetzt anrufen', href: 'tel:+4972296992826' } }, { 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); assert.equal((await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': data.csrfToken }, data: { document: data.document, expectedVersion: data.version } })).status(), 200);
// Was die Vorschau im Button zeigt: Reihenfolge von Text und Symbol und die Zeichnung. const inPreview = fn => page.evaluate(fn);
const preview = () => page.evaluate(() => {
const el = document.querySelector('#preview')?.contentDocument?.getElementById('qa-icon-button');
if (!el) return null;
return { first: el.firstElementChild?.className ?? null, text: el.textContent.trim(), path: el.querySelector('svg path')?.getAttribute('d') ?? null, firstNode: el.firstChild?.nodeType };
});
const waitPreview = async expected => page.waitForFunction(expected => { const waitPreview = async expected => page.waitForFunction(expected => {
const el = document.querySelector('#preview')?.contentDocument?.getElementById('qa-icon-button'); const el = document.querySelector('#preview')?.contentDocument?.getElementById('qa-icon-button');
if (!el) return false; if (!el) return false;
const path = el.querySelector('svg path')?.getAttribute('d') ?? null; const path = el.querySelector('.button-icon svg path')?.getAttribute('d') ?? null;
const iconFirst = el.firstChild?.nodeType === 1; const classes = el.className;
return (expected.path === null ? path === null : path?.startsWith(expected.path)) && (expected.first === undefined || iconFirst === expected.first); 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); }, expected);
await page.goto(`${origin}/admin/pages/${fixture.homeId}`); await page.goto(`${origin}/admin/pages/${fixture.homeId}`);
await selectBlock('qa-icon-button'); await selectBlock('qa-icon-button');
const picker = page.locator('.icon-picker'); const field = page.locator('.icon-field');
await picker.waitFor(); await field.waitFor();
// Ohne Wahl: der bisherige Pfeil ist markiert und steht in der Vorschau hinter dem Text. assert.equal(await field.locator('.icon-field-name').textContent(), 'Pfeil schräg');
assert.equal(await picker.locator('[aria-pressed="true"]').getAttribute('data-icon'), 'arrow-up-right'); assert.equal(await page.locator('.icon-choice').count(), 0, 'Im Panel steht keine Kachelwand.');
await waitPreview({ path: 'M6.5 17.5', first: false }); await waitPreview({ path: 'M6.5 17.5', first: false });
assert.equal(await picker.locator('.icon-tile').count(), 25, 'Kein Symbol plus 24 Symbole in drei Gruppen'); await page.locator('.properties, #properties, aside').first().screenshot({ path: `${report}/panel-vorher.png` }).catch(() => {});
assert.deepEqual(await picker.locator('.icon-group-title').allTextContents(), ['Pfeile', 'Kontakt', 'Allgemein']); checks.push('Panel: nur das gewählte Symbol (Pfeil schräg) mit „Ändern …“');
assert.equal(await page.locator('#property-icon').count(), 0, 'Keine Auswahlliste mehr');
await page.locator('.properties-panel, #properties, aside').first().screenshot({ path: `${report}/vorher.png` }).catch(() => {});
checks.push('ohne Wahl: Pfeil ↗ markiert und hinter dem Text, Kacheln statt Liste');
await picker.getByRole('button', { name: 'Telefon', exact: true }).click(); // 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 }); await waitPreview({ path: 'M6 3h4', first: false });
assert.equal(await page.locator('.icon-tile[data-icon="phone"]').getAttribute('aria-pressed'), 'true'); assert.equal(await field.locator('.icon-field-name').textContent(), 'Telefon');
assert.equal(await page.evaluate(() => document.activeElement?.dataset.icon), 'phone', 'Fokus bleibt auf der gewählten Kachel'); checks.push('Telefon über die Kategorie „Kontakt & Ort“ gewählt');
await page.getByRole('button', { name: 'Vor dem Text', exact: true }).click(); await page.getByRole('button', { name: 'Vor dem Text', exact: true }).click();
await waitPreview({ path: 'M6 3h4', first: true }); await waitPreview({ first: true, has: ['icon-start'] });
checks.push('Telefon gewählt, dann vor den Text gestellt - Vorschau folgt'); 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');
// Tastatur: Pfeiltasten wandern durch die Kacheln, Enter wählt. await page.getByLabel('Nur das Symbol zeigen').check();
await page.locator('.icon-tile[data-icon="phone"]').focus(); await waitPreview({ has: ['icon-only'] });
await page.keyboard.press('ArrowRight'); const label = await inPreview(() => document.querySelector('#preview').contentDocument.querySelector('#qa-icon-button .button-label')?.textContent);
assert.equal(await page.evaluate(() => document.activeElement?.dataset.icon), 'mail'); assert.equal(label, 'Jetzt anrufen', 'Beschriftung bleibt für Vorlesesoftware');
await page.keyboard.press('Enter'); await page.locator('.properties, #properties, aside').first().screenshot({ path: `${report}/panel-nachher.png` }).catch(() => {});
await waitPreview({ path: 'M3 6h18', first: true }); checks.push('Nur Symbol: Beschriftung unsichtbar im Link');
checks.push('Pfeiltaste und Enter wählen E-Mail');
await page.locator('#undo').click(); await waitPreview({ path: 'M6 3h4', first: true }); // Design: Gruppe „Symbol“ mit Farbe, Fläche, Größe, Drehung.
await page.locator('#redo').click(); await waitPreview({ path: 'M3 6h18', first: true }); await page.getByRole('button', { name: 'Farbe, Größe und Fläche des Symbols → Design' }).click();
checks.push('Rückgängig und Wiederholen'); const group = page.locator('.design-group[data-group="icon"]');
await group.waitFor();
await page.getByRole('button', { name: 'Kein Symbol', exact: true }).click(); assert.equal(await group.evaluate(el => el.open), true);
await waitPreview({ path: null }); 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.equal(await page.getByRole('button', { name: 'Vor dem Text', exact: true }).count(), 0, 'Ohne Symbol keine Stellung'); assert.ok(await group.getByText(name, { exact: true }).count() > 0, `${name} fehlt in der Gruppe Symbol`);
assert.equal((await preview()).text, 'Jetzt anrufen'); }
await page.locator('#undo').click(); await waitPreview({ path: 'M3 6h18', first: true }); await group.locator('#style-iconSize').fill('24');
await picker.getByRole('button', { name: 'Pfeil nach rechts', exact: true }).click(); await group.locator('#style-iconSize').press('Enter');
await waitPreview({ path: 'M4 12h15.5', first: true }); await page.waitForFunction(() => {
await page.locator('.icon-picker').screenshot({ path: `${report}/auswahl.png` }); const doc = document.querySelector('#preview')?.contentDocument, svg = doc?.querySelector('#qa-icon-button .button-icon svg');
await page.locator('.icon-position').screenshot({ path: `${report}/stellung.png` }); return svg && Math.round(svg.getBoundingClientRect().width) === 24;
checks.push('kein Symbol, dann Pfeil nach rechts'); });
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(); 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 stored = (await (await context.request.get(api)).json()).document;
assert.equal(stored.schemaVersion, 25, `vorher v${startVersion}`); const button = stored.blocks[0].children[0];
assert.deepEqual(stored.blocks[0].children[0].props, { text: 'Jetzt anrufen', href: 'tel:+4972296992826', icon: 'arrow-right', iconPosition: 'start' }); 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.reload(); await selectBlock('qa-icon-button');
await page.locator('.icon-picker').waitFor(); await page.locator('.icon-field').waitFor();
assert.equal(await page.locator('.icon-tile[aria-pressed="true"]').getAttribute('data-icon'), 'arrow-right'); assert.equal(await page.locator('.icon-field-name').textContent(), 'Telefon');
assert.equal(await page.locator('.icon-position [aria-pressed="true"]').getAttribute('data-position'), 'start'); assert.equal(await page.getByLabel('Nur das Symbol zeigen').isChecked(), true);
await waitPreview({ path: 'M4 12h15.5', first: true }); checks.push('gespeichert als Schema v26 mit Größe unter Design, nach Neuladen gleich');
checks.push('gespeichert als Schema v25, nach Neuladen unverändert');
// 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, []); assert.deepEqual(errors, []);
await writeFile(`${report}/report.json`, JSON.stringify({ origin, checks }, null, 2)); await writeFile(`${report}/report.json`, JSON.stringify({ origin, checks }, null, 2));
+141
View File
@@ -0,0 +1,141 @@
// Prüft Menü-Buttons (ADR 110) auf einer isolierten Kopie der Pilotdaten: in der Menüverwaltung
// einen eigenen Link als Button gestalten (Form, Symbol aus dem Fenster, Palettenfarbe, Telefon-
// Anzeige), Vorschau, Speichern, Neuladen - und die öffentliche Seite auf Desktop und Telefon.
// Screenshots unter reports/menu-buttons/.
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/menu-buttons'; await mkdir(report, { recursive: true });
let browser;
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: 1440, height: 1000 } });
const page = await context.newPage(); page.setDefaultTimeout(15000); page.on('pageerror', error => errors.push(error.message));
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');
await page.goto(origin + '/admin/menus');
await page.locator('summary', { hasText: 'Eigener Link' }).click();
await page.locator('#link-label').fill('Angebot anfragen');
await page.locator('#link-url').fill('/kontakt');
await page.locator('#menu-add-url').click();
const row = page.locator('.menu-entry').last();
await row.locator('summary').click();
// Ohne Häkchen keine Button-Optionen.
assert.equal(await row.locator('[data-button-options]').isVisible(), false);
await row.getByLabel('Als Button darstellen').check();
const options = row.locator('[data-button-options]');
await options.waitFor();
// Ein Klick in die leere Mitte der Häkchenzeile schaltet nichts um.
const box = await row.locator('[data-button-fields]').boundingBox();
const label = await row.locator('label[for^="menu-button-"]').boundingBox();
await page.mouse.click(box.x + box.width * 0.6, label.y + label.height / 2);
assert.equal(await row.getByLabel('Als Button darstellen').isChecked(), true, 'Klick in die Leere klappt die Einstellungen nicht zu');
assert.equal(await options.isVisible(), true);
await options.getByLabel('Form').selectOption('pill');
// Symbol über das Fenster.
await row.getByRole('button', { name: /Symbol ändern/ }).click();
const dialog = page.getByRole('dialog', { name: 'Symbol wählen' });
await dialog.getByRole('button', { name: 'Pfeil nach rechts', exact: true }).click();
await dialog.waitFor({ state: 'detached' });
assert.equal(await row.locator('.icon-field-name').textContent(), 'Pfeil nach rechts');
await row.getByLabel('Symbol steht').selectOption('after');
// Fläche aus der Palette, Schrift als eigener Wert.
await options.locator('.color-trigger').first().click();
await options.locator('.color-choices:not([hidden]) .color-option').nth(1).click();
const fill = await row.locator('input[data-key="buttonFill"]').inputValue();
assert.match(fill, /^@[a-z]/, 'Palettenfarbe als Verweis gespeichert');
await options.getByLabel('Symbol beim Überfahren').selectOption('slide');
await options.getByLabel('Auf dem Telefon').selectOption('bar-icon');
const sample = row.locator('.menu-button-preview .cms-button').first();
assert.equal(await sample.evaluate(el => getComputedStyle(el).borderRadius), '999px');
assert.equal(await row.locator('.menu-button-preview .button-icon svg').count(), 2, 'Symbol in Normal- und Hover-Vorschau');
const axe = await new AxeBuilder({ page }).include('.menu-entry:last-child').analyze();
assert.deepEqual(axe.violations.map(v => v.id), [], 'axe in der Menüzeile');
await row.screenshot({ path: `${report}/verwaltung.png` });
checks.push('Verwaltung: Häkchen zeigt Optionen, Klick in die leere Zeile schaltet nichts um, Pille, Symbol aus dem Fenster, Palettenfarbe als Verweis, Vorschau mit Symbol, axe ohne Befund');
// Zweiter Button bleibt auf dem Telefon im aufgeklappten Menü (Vorgabe).
await page.locator('#link-label').fill('Rückruf');
await page.locator('#link-url').fill('/rueckruf');
await page.locator('#menu-add-url').click();
const second = page.locator('.menu-entry').last();
await second.locator('summary').click();
await second.getByLabel('Als Button darstellen').check();
await second.locator('select[data-key="buttonAppearance"]').selectOption('outline');
assert.equal(await second.locator('select[data-key="buttonMobile"]').inputValue(), 'menu');
await page.getByRole('button', { name: 'Menü speichern' }).click();
await page.locator('#menu-status').filter({ hasText: 'Gespeicherter Stand' }).waitFor();
await page.reload();
const saved = page.locator('.menu-entry').filter({ hasText: 'Angebot anfragen' }).last();
await saved.locator('summary').click();
assert.equal(await saved.getByLabel('Als Button darstellen').isChecked(), true);
assert.equal(await saved.locator('select[data-key="buttonShape"]').inputValue(), 'pill');
assert.equal(await saved.locator('select[data-key="buttonMobile"]').inputValue(), 'bar-icon');
assert.equal(await saved.locator('.icon-field-name').textContent(), 'Pfeil nach rechts');
checks.push('gespeichert und nach Neuladen gleich');
// Öffentliche Seite: Desktop im Menü, Telefon neben dem Menü-Knopf.
const site = await (await browser.newContext({ viewport: { width: 1440, height: 900 } })).newPage();
site.on('pageerror', error => errors.push(error.message));
await site.goto(origin + '/');
const desktop = site.locator('.site-header .block-menu > .menu-list a.menu-button');
await desktop.first().waitFor();
assert.equal(await desktop.first().isVisible(), true);
const look = await desktop.first().evaluate(el => ({ radius: getComputedStyle(el).borderRadius, bg: getComputedStyle(el).backgroundColor, deco: getComputedStyle(el).textDecorationLine }));
assert.equal(look.radius, '999px'); assert.equal(look.deco, 'none');
assert.equal(await site.locator('.site-header .menu-actions').first().isVisible(), false, 'Auf dem Desktop keine Zweitausgabe');
await site.locator('.site-header').screenshot({ path: `${report}/desktop.png` });
await site.setViewportSize({ width: 390, height: 844 });
await site.reload();
const bar = site.locator('.site-header .menu-actions a.menu-button');
assert.equal(await bar.first().isVisible(), true, 'Auf dem Telefon neben dem Menü-Knopf');
assert.match(await bar.first().getAttribute('class'), /icon-only/, 'Dort nur als Symbol');
assert.equal(await bar.first().getAttribute('href'), '/kontakt');
const line = await site.evaluate(() => { const a = document.querySelector('.site-header .menu-actions a'), s = document.querySelector('.site-header .menu-burger > summary'); return Math.abs(a.getBoundingClientRect().top + a.getBoundingClientRect().height / 2 - (s.getBoundingClientRect().top + s.getBoundingClientRect().height / 2)); });
await site.locator('.site-header').screenshot({ path: `${report}/telefon.png` });
assert.ok(line < 4, `Symbol und Menü-Knopf nicht in einer Zeile: ${line}px`);
await site.locator('.site-header .menu-burger > summary').first().click();
assert.equal(await site.locator('.site-header .menu-burger-body .menu-keep-visible').first().isVisible(), false, 'Im aufgeklappten Menü nicht doppelt');
const inMenu = site.locator('.site-header .menu-burger-body a.menu-button', { hasText: 'Rückruf' });
assert.equal(await inMenu.isVisible(), true, 'Button im aufgeklappten Menü');
assert.equal(await site.locator('.site-header .menu-actions a', { hasText: 'Rückruf' }).count(), 0, 'nicht neben dem Menü-Knopf');
const fit = await inMenu.evaluate(a => ({ a: a.getBoundingClientRect().width, list: a.closest('.menu-list').getBoundingClientRect().width, border: getComputedStyle(a).borderTopStyle }));
assert.ok(Math.abs(fit.a - fit.list) < 2, `volle Breite im Menü: ${fit.a} von ${fit.list}`);
assert.equal(fit.border, 'solid', 'Darstellung „Rahmen“ bleibt im Menü');
await site.screenshot({ path: `${report}/telefon-offen.png` });
const axeSite = await new AxeBuilder({ page: site }).include('.site-header').analyze();
assert.deepEqual(axeSite.violations.map(v => v.id), [], 'axe im Kopf der Website');
const width = await site.evaluate(() => document.documentElement.scrollWidth);
assert.ok(width <= 390, `waagerechtes Scrollen: ${width}`);
checks.push('Website: Button im Menü (Pille, ohne Unterstreichung), auf dem Telefon neben dem Menü-Knopf, aufgeklappt nicht doppelt, zweiter Button im aufgeklappten Menü in voller Breite, axe ohne Befund, kein waagerechtes Scrollen');
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();
}
+4 -5
View File
@@ -23,8 +23,8 @@
* - Die Stellen unter „Karriere“ sind Links (E-Mail mit Betreff), weil sie einen Pfeil tragen. * - Die Stellen unter „Karriere“ sind Links (E-Mail mit Betreff), weil sie einen Pfeil tragen.
* - Anrufknöpfe sind echte Knöpfe mit tel: (Kern seit 06.10.2026), der Pfeil im Knopf hat die * - Anrufknöpfe sind echte Knöpfe mit tel: (Kern seit 06.10.2026), der Pfeil im Knopf hat die
* Schriftfarbe. * Schriftfarbe.
* - Das LKW-Symbol der Leiste gibt es im Symbolsatz des Kerns nicht; es steht als „Eigenes * - Das LKW-Symbol der Leiste ist seit 1.25.0 ein gewöhnliches Symbol („truck“); vorher stand
* HTML“ mit dem Pfad der Vorlage da. * es als „Eigenes HTML“ mit dem Pfad der Vorlage da.
* - Leiste und Kopf kleben gemeinsam oben (die Einstellung gilt für den ganzen Kopfbereich); in * - Leiste und Kopf kleben gemeinsam oben (die Einstellung gilt für den ganzen Kopfbereich); in
* der Vorlage klebt nur der Kopf. Auf dem Telefon ist die Leiste deshalb einzeilig. * der Vorlage klebt nur der Kopf. Auf dem Telefon ist die Leiste deshalb einzeilig.
* - Impressum, Datenschutz und 404 sind neu. Die Vorlage verweist nur auf #impressum. * - Impressum, Datenschutz und 404 sind neu. Die Vorlage verweist nur auf #impressum.
@@ -592,9 +592,8 @@ $vorteil = static function (int $n, array $icon, string $titel, string $satz) us
}; };
$vorteilIcon = static fn (int $n, string $name) => $symbol('vorteil-'.$n.'-icon', $name, 32, ['desktop' => ['color' => '@hellgruen', 'flexShrink' => 0]]); $vorteilIcon = static fn (int $n, string $name) => $symbol('vorteil-'.$n.'-icon', $name, 32, ['desktop' => ['color' => '@hellgruen', 'flexShrink' => 0]]);
// Der LKW fehlt im Symbolsatz des Kerns; der Pfad ist der aus der Vorlage. // Seit 1.25.0 hat der Symbolsatz einen LKW; vorher stand er hier als „Eigenes HTML“.
$lkw = ['id' => 'vorteil-3-icon', 'type' => 'html', 'props' => ['html' => '<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#8fd16f" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><rect x="1" y="7" width="13" height="10" rx="1"></rect><path d="M14 10h4l3 3v4h-7z"></path><circle cx="6" cy="18.5" r="2"></circle><circle cx="17" cy="18.5" r="2"></circle></svg>'], $lkw = $vorteilIcon(3, 'truck');
'style' => ['desktop' => ['flexShrink' => 0]], 'css' => '& svg{display:block}'];
$haken = static fn (string $id, string $inhalt) => $zeile($id, [ $haken = static fn (string $id, string $inhalt) => $zeile($id, [
$symbol($id.'-icon', 'check', 24, ['desktop' => ['color' => '@gruen', 'flexShrink' => 0]]), $symbol($id.'-icon', 'check', 24, ['desktop' => ['color' => '@gruen', 'flexShrink' => 0]]),
+12 -2
View File
@@ -16,7 +16,7 @@ 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', 'arrow-down', 'chevron-right', 'calendar', 'chat', 'send', 'download', 'document', 'play', 'search', 'heart', 'info', 'plus', 'user']; 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. */ /** 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 BUTTON_DEFAULT_ICON = 'arrow-up-right';
public const SPACER_SIZES = ['small', 'medium', 'large', 'huge']; public const SPACER_SIZES = ['small', 'medium', 'large', 'huge'];
@@ -42,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 = 25; 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
@@ -247,6 +247,16 @@ final class DocumentValidator
if (array_key_exists('iconPosition', $props)) { 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.'); $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.');
+136
View File
@@ -0,0 +1,136 @@
<?php
namespace App\Content;
use App\Domain\InvalidMenu;
/**
* Ein Menüeintrag, der als Button erscheint (ADR 110).
*
* Die Gestalt kommt aus demselben Wortschatz wie der Button-Baustein: Form, Darstellung und Größe
* aus ButtonDesign, das Symbol aus dem Symbolsatz. Fehlt ein Wert, gilt der Website-Standard unter
* Design → Buttons. Eigene Farben brauchen eine Kennung, weil die Sicherheitsrichtlinie keine
* Inline-Stile erlaubt: sie werden als Regel für `.menu-button-<id>` ins Stylesheet der Seite
* geschrieben.
*
* Gespeichert am Eintrag als `button`:
* {id, shape?, appearance?, size?, fill?, ink?, hoverFill?, hoverInk?, iconColor?, mobile, iconMotion?, iconOnly?}
*/
final class MenuButton
{
/** Wo der Button auf dem Telefon steht, wenn das Menü zum Menü-Knopf zusammenklappt. */
public const MOBILE = ['menu' => 'Im aufgeklappten Menü', 'bar' => 'Neben dem Menü-Knopf', 'bar-icon' => 'Neben dem Menü-Knopf, nur als Symbol'];
public const MOTION = ['none', 'slide', 'reveal'];
/** Formularfeld => gespeicherter Schlüssel. */
public const COLORS = ['buttonFill' => 'fill', 'buttonInk' => 'ink', 'buttonHoverFill' => 'hoverFill', 'buttonHoverInk' => 'hoverInk', 'buttonIconColor' => 'iconColor'];
private const DESIGN = ['buttonShape' => 'shape', 'buttonAppearance' => 'appearance', 'buttonSize' => 'size'];
/**
* Aus den flachen Formularfeldern einer Zeile. Ohne Häkchen „Als Button“ kein Button.
*/
public static function fromForm(array $row): ?array
{
if ('1' !== (string) ($row['button'] ?? '')) {
return null;
}
$id = (string) ($row['buttonId'] ?? '');
$button = ['id' => 1 === preg_match('/^m[a-f0-9]{10}$/D', $id) ? $id : 'm'.bin2hex(random_bytes(5))];
foreach ([...self::DESIGN, ...self::COLORS] as $field => $key) {
$value = trim((string) ($row[$field] ?? ''));
if ('' !== $value) {
$button[$key] = $value;
}
}
$button['mobile'] = (string) ($row['buttonMobile'] ?? 'menu');
$motion = (string) ($row['iconMotion'] ?? 'none');
if ('none' !== $motion) {
$button['iconMotion'] = $motion;
}
if ('1' === (string) ($row['iconOnly'] ?? '')) {
$button['iconOnly'] = true;
}
return $button;
}
public static function validate(mixed $button, array $item): void
{
if (!is_array($button) || 1 !== preg_match('/^m[a-f0-9]{10}$/D', (string) ($button['id'] ?? ''))) {
throw new InvalidMenu('Ein Menü-Button braucht eine Kennung.');
}
$known = ['id', 'mobile', 'iconMotion', 'iconOnly', ...array_values(self::DESIGN), ...array_values(self::COLORS)];
if ([] !== array_diff(array_keys($button), $known)) {
throw new InvalidMenu('Unbekannte Einstellung am Menü-Button.');
}
foreach (self::DESIGN as $key) {
if (isset($button[$key]) && !isset(ButtonDesign::OPTIONS[$key][$button[$key]])) {
throw new InvalidMenu('Unbekannte Form, Darstellung oder Größe am Menü-Button.');
}
}
foreach (self::COLORS as $key) {
if (isset($button[$key]) && (!is_string($button[$key]) || 1 !== preg_match(StyleSchema::COLOR, $button[$key]))) {
throw new InvalidMenu('Eine Farbe am Menü-Button ist ein Hexwert wie #b71c22 oder eine Farbe der Palette.');
}
}
if (!isset(self::MOBILE[$button['mobile'] ?? ''])) {
throw new InvalidMenu('Unbekannte Anzeige auf dem Telefon.');
}
// Auf dem Telefon nur als Symbol: ohne Symbol stünde dort ein leerer Knopf.
if ('bar-icon' === $button['mobile'] && '' === ($item['icon'] ?? '')) {
throw new InvalidMenu('Nur als Symbol neben dem Menü-Knopf braucht ein Symbol.');
}
if (isset($button['iconMotion']) && !in_array($button['iconMotion'], self::MOTION, true)) {
throw new InvalidMenu('Unbekannte Bewegung des Symbols.');
}
if (isset($button['iconOnly']) && (true !== $button['iconOnly'] || '' === ($item['icon'] ?? ''))) {
throw new InvalidMenu('Ein Menü-Button nur aus Symbol braucht ein Symbol.');
}
// Ein Knopf öffnet kein Untermenü: mit Unterpunkten wäre er Knopf und Aufklapper zugleich.
if ([] !== ($item['children'] ?? [])) {
throw new InvalidMenu(sprintf('„%s“ hat Unterpunkte und kann deshalb kein Button sein.', $item['label'] ?? ''));
}
}
/**
* Die Farbregeln aller Menü-Buttons. Gesetzt werden die Variablen, aus denen die Button-Stile
* ihre Flächen berechnen, plus die abgeleiteten Werte der gewählten oder der Standard-
* Darstellung - sonst rechnete ein Button ohne eigene Darstellung weiter mit den Farben vom
* Seitenanfang.
*
* @param array<string, array{items: array}> $menus
*/
public static function css(array $menus, array $themeButtons): string
{
$color = static fn (string $v): string => str_starts_with($v, '@') ? 'var(--k-'.substr($v, 1).')' : $v;
$css = '';
$walk = function (array $items) use (&$walk, &$css, $color, $themeButtons): void {
foreach ($items as $item) {
$walk($item['children'] ?? []);
$button = $item['button'] ?? null;
if (!is_array($button) || !isset($button['id'])) {
continue;
}
$vars = [];
foreach (['fill' => '--kb-fill', 'ink' => '--kb-ink', 'hoverFill' => '--kb-hover-fill', 'hoverInk' => '--kb-hover-ink'] as $key => $var) {
if (isset($button[$key])) {
$vars[] = $var.':'.$color($button[$key]);
}
}
if ([] !== $vars) {
foreach (ButtonDesign::appearance($button['appearance'] ?? $themeButtons['appearance'] ?? 'solid') as $key => $value) {
$vars[] = '--kb-'.$key.':'.$value;
}
$css .= sprintf('.menu-button-%s{%s}', $button['id'], implode(';', $vars));
}
if (isset($button['iconColor'])) {
$css .= sprintf('.menu-button-%s .button-icon{color:%s}', $button['id'], $color($button['iconColor']));
}
}
};
foreach ($menus as $menu) {
$walk($menu['items'] ?? []);
}
return $css;
}
}
+10 -1
View File
@@ -132,12 +132,18 @@ final class MenuRepository
if ('' === trim($label)) { if ('' === trim($label)) {
continue; continue;
} }
$links[] = ['label' => $label, 'href' => $href, 'current' => $current, 'children' => $children, ...(($item['newTab'] ?? false) ? ['newTab' => true] : []), ...(!empty($item['icon']) ? ['icon' => $item['icon'], 'iconPosition' => $item['iconPosition'] ?? 'before'] : [])]; $links[] = ['label' => $label, 'href' => $href, 'current' => $current, 'children' => $children, ...(($item['newTab'] ?? false) ? ['newTab' => true] : []), ...(!empty($item['icon']) ? ['icon' => $item['icon'], 'iconPosition' => $item['iconPosition'] ?? 'before'] : []), ...(isset($item['button']) ? ['button' => $item['button']] : [])];
} }
return $links; return $links;
} }
/** Die Farben der Menü-Buttons aller Plätze, fürs Stylesheet der Seite (ADR 110). */
public function buttonCss(array $themeButtons, string $locale = 'de'): string
{
return MenuButton::css($this->all($locale), $themeButtons);
}
private function validate(array $items, int $depth = 1, int &$count = 0): void private function validate(array $items, int $depth = 1, int &$count = 0): void
{ {
if (!array_is_list($items)) { if (!array_is_list($items)) {
@@ -175,6 +181,9 @@ final class MenuRepository
} else { } else {
throw new InvalidMenu('Unbekannte Zielart.'); throw new InvalidMenu('Unbekannte Zielart.');
} }
if (array_key_exists('button', $item)) {
MenuButton::validate($item['button'], $item);
}
$this->validate($item['children'] ?? [], $depth + 1, $count); $this->validate($item['children'] ?? [], $depth + 1, $count);
} }
} }
+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
@@ -492,7 +492,7 @@ final class EditorController extends AbstractController
// CSS, Bereichs- und Sektionsstilen und der Formulare mit ihren Farben. Vorher // CSS, Bereichs- und Sektionsstilen und der Formulare mit ihren Farben. Vorher
// fehlte all das, und "sieht in der Vorschau anders aus" war immer wahr. // fehlte all das, und "sieht in der Vorschau anders aus" war immer wahr.
$documents = [$document, ...array_values($areas), ...array_values($sections)]; $documents = [$document, ...array_values($areas), ...array_values($sections)];
$styles = $this->theme->css($theme); $styles = $this->theme->css($theme).$this->menus->buttonCss($theme['buttons']);
foreach ($documents as $doc) { foreach ($documents as $doc) {
$styles .= $this->styles->compileDocument($doc).$this->code->compileDocument($doc); $styles .= $this->styles->compileDocument($doc).$this->code->compileDocument($doc);
} }
+12 -1
View File
@@ -2,8 +2,11 @@
namespace App\Controller; namespace App\Controller;
use App\Content\ButtonDesign;
use App\Content\MenuButton;
use App\Content\MenuRepository; use App\Content\MenuRepository;
use App\Content\PageRepository; use App\Content\PageRepository;
use App\Content\Theme;
use App\Domain\InvalidMenu; use App\Domain\InvalidMenu;
use App\Domain\StaleWrite; use App\Domain\StaleWrite;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -14,7 +17,7 @@ use Symfony\Component\Routing\Attribute\Route;
final class MenuController extends AbstractController final class MenuController extends AbstractController
{ {
public function __construct(private MenuRepository $menus, private PageRepository $pages) public function __construct(private MenuRepository $menus, private PageRepository $pages, private Theme $theme)
{ {
} }
@@ -67,6 +70,7 @@ final class MenuController extends AbstractController
$slot = $request->query->getString('slot', 'header'); $slot = $request->query->getString('slot', 'header');
$slot = isset(MenuRepository::SLOTS[$slot]) ? $slot : 'header'; $slot = isset(MenuRepository::SLOTS[$slot]) ? $slot : 'header';
$menu = $this->menus->all()[$slot] ?? null; $menu = $this->menus->all()[$slot] ?? null;
$theme = $this->theme->load();
$rows = $this->flatten($menu['items'] ?? []); $rows = $this->flatten($menu['items'] ?? []);
$selected = []; $selected = [];
foreach ($rows as &$row) { foreach ($rows as &$row) {
@@ -81,6 +85,8 @@ final class MenuController extends AbstractController
'slots' => MenuRepository::SLOTS, 'slot' => $slot, 'slotName' => MenuRepository::SLOTS[$slot], 'slots' => MenuRepository::SLOTS, 'slot' => $slot, 'slotName' => MenuRepository::SLOTS[$slot],
'menu' => $menu, 'rows' => $rows, 'menu' => $menu, 'rows' => $rows,
'menuIcons' => \App\Content\DocumentValidator::ICONS, 'menuIcons' => \App\Content\DocumentValidator::ICONS,
// Fuer die Button-Vorschau und die Farbwahl am Eintrag: dieselben Daten wie im Editor.
'buttonSchema' => ['buttons' => $theme['buttons'], 'colors' => $theme['colors'], 'fonts' => $theme['fonts'], 'buttonOptions' => ButtonDesign::OPTIONS, 'mobile' => MenuButton::MOBILE],
]); ]);
} }
@@ -123,6 +129,7 @@ final class MenuController extends AbstractController
'pageId' => $item['pageId'] ?? '', 'anchor' => $item['anchor'] ?? '', 'url' => $item['url'] ?? '', 'pageId' => $item['pageId'] ?? '', 'anchor' => $item['anchor'] ?? '', 'url' => $item['url'] ?? '',
'newTab' => $item['newTab'] ?? false, 'newTab' => $item['newTab'] ?? false,
'icon' => $item['icon'] ?? '', 'iconPosition' => $item['iconPosition'] ?? 'before', 'icon' => $item['icon'] ?? '', 'iconPosition' => $item['iconPosition'] ?? 'before',
'button' => $item['button'] ?? null,
]; ];
array_push($rows, ...$this->flatten($item['children'] ?? [], $level + 1)); array_push($rows, ...$this->flatten($item['children'] ?? [], $level + 1));
} }
@@ -152,6 +159,7 @@ final class MenuController extends AbstractController
'icon' => (string) ($row['icon'] ?? ''), 'iconPosition' => (string) ($row['iconPosition'] ?? 'before'), 'icon' => (string) ($row['icon'] ?? ''), 'iconPosition' => (string) ($row['iconPosition'] ?? 'before'),
'level' => max(1, min(MenuRepository::MAX_DEPTH, (int) ($row['level'] ?? 1))), 'level' => max(1, min(MenuRepository::MAX_DEPTH, (int) ($row['level'] ?? 1))),
'position' => (int) ($row['position'] ?? 0), 'position' => (int) ($row['position'] ?? 0),
'button' => MenuButton::fromForm($row),
]; ];
} }
usort($flat, static fn (array $a, array $b): int => $a['position'] <=> $b['position']); usort($flat, static fn (array $a, array $b): int => $a['position'] <=> $b['position']);
@@ -166,6 +174,9 @@ final class MenuController extends AbstractController
if ($row['newTab']) { if ($row['newTab']) {
$item['newTab'] = true; $item['newTab'] = true;
} }
if (null !== $row['button']) {
$item['button'] = $row['button'];
}
if ('url' === $row['type']) { if ('url' === $row['type']) {
$item['url'] = $row['url']; $item['url'] = $row['url'];
} else { } else {
+1 -1
View File
@@ -138,7 +138,7 @@ final class PageController extends AbstractController
// for the small script that marks the page as scrolled. // for the small script that marks the page as scrolled.
// Den Nach-oben-Knopf blendet dasselbe Skript ein; ohne es bleibt er dauerhaft sichtbar. // Den Nach-oben-Knopf blendet dasselbe Skript ein; ohne es bleibt er dauerhaft sichtbar.
$needsScrolled = (isset($areas['header']) && $this->hasScrolled($areas['header'])) || (bool) ($this->settings->load()['scrollTop'] ?? false); $needsScrolled = (isset($areas['header']) && $this->hasScrolled($areas['header'])) || (bool) ($this->settings->load()['scrollTop'] ?? false);
$styles = $this->theme->css($theme).$this->scrollTopStyles($this->settings->load(), $theme).$this->consent->styles($theme).$this->styles->compileDocument($page['document']).$this->code->compileDocument($page['document']); $styles = $this->theme->css($theme).$this->menus->buttonCss($theme['buttons']).$this->scrollTopStyles($this->settings->load(), $theme).$this->consent->styles($theme).$this->styles->compileDocument($page['document']).$this->code->compileDocument($page['document']);
foreach ([...array_values($areas), ...array_values($sections)] as $extra) { foreach ([...array_values($areas), ...array_values($sections)] as $extra) {
$styles .= $this->styles->compileDocument($extra).$this->code->compileDocument($extra); $styles .= $this->styles->compileDocument($extra).$this->code->compileDocument($extra);
} }
+1 -1
View File
@@ -179,7 +179,7 @@ final class PartialController extends AbstractController
} }
// Wie die Seitenvorschau: eigenes CSS, Sektionsstile und Formulare mit ihren Farben. // Wie die Seitenvorschau: eigenes CSS, Sektionsstile und Formulare mit ihren Farben.
$styles = $this->theme->css($theme).$this->styles->compileDocument($document).$this->code->compileDocument($document); $styles = $this->theme->css($theme).$this->menus->buttonCss($theme['buttons']).$this->styles->compileDocument($document).$this->code->compileDocument($document);
foreach ($sections as $section) { foreach ($sections as $section) {
$styles .= $this->styles->compileDocument($section).$this->code->compileDocument($section); $styles .= $this->styles->compileDocument($section).$this->code->compileDocument($section);
} }
+21
View File
@@ -17,6 +17,27 @@
{% endif %} {% endif %}
<label class="check-label" for="menu-newTab-{{ n }}"><input id="menu-newTab-{{ n }}" type="checkbox" data-key="newTab" name="items[{{ n }}][newTab]" value="1"{{ row.newTab|default(false) ? ' checked' : '' }}> In neuem Tab öffnen</label> <label class="check-label" for="menu-newTab-{{ n }}"><input id="menu-newTab-{{ n }}" type="checkbox" data-key="newTab" name="items[{{ n }}][newTab]" value="1"{{ row.newTab|default(false) ? ' checked' : '' }}> In neuem Tab öffnen</label>
<div class="menu-icon-fields"><div><label for="menu-icon-{{ n }}">Icon</label><select id="menu-icon-{{ n }}" data-key="icon" name="items[{{ n }}][icon]"><option value="">Kein Icon</option>{% for icon in menuIcons %}<option value="{{ icon }}"{{ row.icon|default('') == icon ? ' selected' : '' }}>{{ icon }}</option>{% endfor %}</select></div><div><label for="menu-iconPosition-{{ n }}">Icon-Position</label><select id="menu-iconPosition-{{ n }}" data-key="iconPosition" name="items[{{ n }}][iconPosition]"><option value="before"{{ row.iconPosition|default('before') == 'before' ? ' selected' : '' }}>Vor dem Text</option><option value="after"{{ row.iconPosition|default('before') == 'after' ? ' selected' : '' }}>Nach dem Text</option></select></div></div> <div class="menu-icon-fields"><div><label for="menu-icon-{{ n }}">Icon</label><select id="menu-icon-{{ n }}" data-key="icon" name="items[{{ n }}][icon]"><option value="">Kein Icon</option>{% for icon in menuIcons %}<option value="{{ icon }}"{{ row.icon|default('') == icon ? ' selected' : '' }}>{{ icon }}</option>{% endfor %}</select></div><div><label for="menu-iconPosition-{{ n }}">Icon-Position</label><select id="menu-iconPosition-{{ n }}" data-key="iconPosition" name="items[{{ n }}][iconPosition]"><option value="before"{{ row.iconPosition|default('before') == 'before' ? ' selected' : '' }}>Vor dem Text</option><option value="after"{{ row.iconPosition|default('before') == 'after' ? ' selected' : '' }}>Nach dem Text</option></select></div></div>
{# Als Button (ADR 110). Ohne Skript sind es gewöhnliche Felder; mit Skript kommen
Vorschau, Symbolfenster und Palettenfarben dazu. Leer heißt Website-Standard. #}
{% set btn = row.button|default({}) %}
<fieldset class="menu-button-fields" data-button-fields>
<legend>Button</legend>
<label class="check-label" for="menu-button-{{ n }}"><input id="menu-button-{{ n }}" type="checkbox" data-key="button" name="items[{{ n }}][button]" value="1"{{ row.button ? ' checked' : '' }}> Als Button darstellen</label>
<input type="hidden" data-key="buttonId" name="items[{{ n }}][buttonId]" value="{{ btn.id|default('') }}">
<div class="menu-button-options" data-button-options{{ row.button ? '' : ' hidden' }}>
{% for key, caption in {shape: 'Form', appearance: 'Darstellung', size: 'Größe'} %}
{% set field = 'button' ~ key|capitalize %}
<div><label for="menu-{{ field }}-{{ n }}">{{ caption }}</label><select id="menu-{{ field }}-{{ n }}" data-key="{{ field }}" name="items[{{ n }}][{{ field }}]"><option value="">Website-Standard</option>{% for value, name in buttonSchema.buttonOptions[key] %}<option value="{{ value }}"{{ btn[key]|default('') == value ? ' selected' : '' }}>{{ name }}</option>{% endfor %}</select></div>
{% endfor %}
{% for field, caption in {buttonFill: 'Fläche', buttonInk: 'Schrift', buttonHoverFill: 'Fläche beim Überfahren', buttonHoverInk: 'Schrift beim Überfahren', buttonIconColor: 'Symbolfarbe'} %}
{% set stored = {buttonFill: 'fill', buttonInk: 'ink', buttonHoverFill: 'hoverFill', buttonHoverInk: 'hoverInk', buttonIconColor: 'iconColor'}[field] %}
<div data-color-field><label for="menu-{{ field }}-{{ n }}">{{ caption }}</label><input id="menu-{{ field }}-{{ n }}" data-key="{{ field }}" name="items[{{ n }}][{{ field }}]" value="{{ btn[stored]|default('') }}" placeholder="Website-Standard" pattern="(#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?|@[a-z][a-z0-9-]{0,31})" maxlength="33"></div>
{% endfor %}
<div><label for="menu-iconMotion-{{ n }}">Symbol beim Überfahren</label><select id="menu-iconMotion-{{ n }}" data-key="iconMotion" name="items[{{ n }}][iconMotion]">{% for value, name in {none: 'Ruhig', slide: 'Rückt', reveal: 'Erscheint'} %}<option value="{{ value }}"{{ btn.iconMotion|default('none') == value ? ' selected' : '' }}>{{ name }}</option>{% endfor %}</select></div>
<label class="check-label" for="menu-iconOnly-{{ n }}"><input id="menu-iconOnly-{{ n }}" type="checkbox" data-key="iconOnly" name="items[{{ n }}][iconOnly]" value="1"{{ btn.iconOnly|default(false) ? ' checked' : '' }}> Nur das Symbol zeigen</label>
<div><label for="menu-buttonMobile-{{ n }}">Auf dem Telefon</label><select id="menu-buttonMobile-{{ n }}" data-key="buttonMobile" name="items[{{ n }}][buttonMobile]">{% for value, name in buttonSchema.mobile %}<option value="{{ value }}"{{ btn.mobile|default('menu') == value ? ' selected' : '' }}>{{ name }}</option>{% endfor %}</select></div>
</div>
</fieldset>
</div> </div>
</details> </details>
<div class="menu-entry-actions" role="group" aria-label="{{ row.label }} verschieben oder entfernen"> <div class="menu-entry-actions" role="group" aria-label="{{ row.label }} verschieben oder entfernen">
+2 -2
View File
@@ -3,12 +3,12 @@
{% block title %}Menüs{% endblock %} {% block title %}Menüs{% endblock %}
{% block heading %}Menüs{% endblock %} {% block heading %}Menüs{% endblock %}
{% block lead %}Inhalte suchen und hinzufügen. Rechts bestimmst du Beschriftung, Reihenfolge und Unterpunkte.{% endblock %} {% block lead %}Inhalte suchen und hinzufügen. Rechts bestimmst du Beschriftung, Reihenfolge und Unterpunkte.{% endblock %}
{% block styles %}<link rel="stylesheet" href="/assets/media-dialog.css?v={{ kant_version() }}">{% endblock %} {% block styles %}<link rel="stylesheet" href="/assets/media-dialog.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/buttons.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/button-controls.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/color-control.css?v={{ kant_version() }}">{% endblock %}
{% block scripts %}<script type="module" src="/assets/menu-editor.js?v={{ kant_version() }}"></script>{% endblock %} {% block scripts %}<script type="module" src="/assets/menu-editor.js?v={{ kant_version() }}"></script>{% endblock %}
{% block content %} {% block content %}
{% include 'admin/menu-icons.html.twig' %} {% include 'admin/menu-icons.html.twig' %}
<nav class="menu-slots" aria-label="Menü auswählen">{% for key, label in slots %}<a href="{{ path('admin_menus', {slot: key}) }}"{{ key == slot ? ' aria-current="page"' : '' }}>{{ label }}</a>{% endfor %}</nav> <nav class="menu-slots" aria-label="Menü auswählen">{% for key, label in slots %}<a href="{{ path('admin_menus', {slot: key}) }}"{{ key == slot ? ' aria-current="page"' : '' }}>{{ label }}</a>{% endfor %}</nav>
<form action="{{ path('admin_menu_save', {slot: slot}) }}" method="post" id="menu-editor" class="menu-workspace menu-form" data-icons="{{ menuIcons|json_encode|e('html_attr') }}"> <form action="{{ path('admin_menu_save', {slot: slot}) }}" method="post" id="menu-editor" class="menu-workspace menu-form" data-icons="{{ menuIcons|json_encode|e('html_attr') }}" data-button-schema="{{ buttonSchema|json_encode|e('html_attr') }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('menus') }}"><input type="hidden" name="expectedVersion" value="{{ menu ? menu.version : 0 }}"> <input type="hidden" name="_csrf_token" value="{{ csrf_token('menus') }}"><input type="hidden" name="expectedVersion" value="{{ menu ? menu.version : 0 }}">
<aside class="menu-add" aria-label="Menüeinträge hinzufügen"> <aside class="menu-add" aria-label="Menüeinträge hinzufügen">
<section class="card card-body"><h2>Seiten und Beiträge</h2> <section class="card card-body"><h2>Seiten und Beiträge</h2>
+9 -1
View File
@@ -32,7 +32,11 @@
{% set iconMarkup = '' %} {% 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 %} {% 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' %} {% set iconFirst = props.iconPosition|default('end') == 'start' %}
<a id="{{ block.id }}" class="button cms-button{{ designClasses }}" href="{{ props.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: props.newTab|default(false)} only %}>{% if iconMarkup and iconFirst %}{{ iconMarkup }} {% endif %}{{ props.text }}{% if iconMarkup and not iconFirst %} {{ iconMarkup }}{% endif %}</a> {# 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
@@ -110,6 +114,10 @@
<nav id="{{ block.id }}" class="block-menu menu-{{ menuLayout }} menu-collapse-{{ collapse }}{{ designClasses }}" <nav id="{{ block.id }}" class="block-menu menu-{{ menuLayout }} menu-collapse-{{ collapse }}{{ designClasses }}"
aria-label="{{ {header: 'Hauptnavigation', secondary: 'Zweite Navigation', footer: 'Fußzeile', legal: 'Rechtliches'}[props.slot]|default('Navigation') }}"> aria-label="{{ {header: 'Hauptnavigation', secondary: 'Zweite Navigation', footer: 'Fußzeile', legal: 'Rechtliches'}[props.slot]|default('Navigation') }}">
{% if menuLayout == 'burger' or collapse != 'never' %} {% if menuLayout == 'burger' or collapse != 'never' %}
{# Buttons, die auf dem Telefon neben dem Menü-Knopf stehen bleiben (ADR 110). Sichtbar nur,
solange das Menü zusammengeklappt ist; im aufgeklappten Menü fehlen sie dann. #}
{% set barLinks = links|filter(link => link.button is defined and link.button.mobile|default('menu') in ['bar', 'bar-icon']) %}
{% if barLinks is not empty %}<ul class="menu-actions">{% for link in barLinks %}<li>{% include 'menu-button.html.twig' with {link: link, forceIconOnly: link.button.mobile == 'bar-icon'} only %}</li>{% endfor %}</ul>{% endif %}
<details class="menu-burger"> <details class="menu-burger">
{# Nur Symbol behält den Namen für Vorlesesoftware; sichtbar ist dann nur der Strich. #} {# Nur Symbol behält den Namen für Vorlesesoftware; sichtbar ist dann nur der Strich. #}
{% set burger = props.burgerLabel|default('both') %} {% set burger = props.burgerLabel|default('both') %}
+88 -23
View File
@@ -1,37 +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', '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-right': 'm9 5.5 6.5 6.5L9 18.5',
calendar: 'M4 6h16v14H4Zm0 5h16M8 3.5v4m8-4v4', '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', chat: 'M4 5h16v11H9.5L5 19.5V16H4Z',
send: 'M21 3 10 14M21 3l-6.5 18-4.5-7-7-4.5Z', send: 'M21 3 10 14M21 3l-6.5 18-4.5-7-7-4.5Z',
download: 'M12 3.5v12m-5-5 5 5 5-5M4.5 20h15', 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', 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', play: 'M8 5.5v13l10-6.5Z',
search: 'M10.5 4a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13Zm4.8 11.3L20 20', 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', 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', info: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 8v5.5m0-9v.5',
plus: 'M12 5v14M5 12h14', 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',
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' 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>
+15
View File
@@ -0,0 +1,15 @@
{# Ein Menüeintrag als Button (ADR 110): dieselben Klassen wie der Button-Baustein, also dieselben
Website-Standards, Varianten, Symbole und Bewegungen. Eigene Farben stehen als Regel für
.menu-button-<id> im Stylesheet der Seite. Die Beschriftung bleibt auch bei "nur Symbol" im
Link - als zugänglicher Name. #}
{% set b = link.button %}
{% set iconFirst = link.iconPosition|default('before') == 'before' %}
{% set hasIcon = link.icon|default('') != '' %}
{# forceIconOnly: neben dem Menü-Knopf auf dem Telefon, wenn dort nur das Symbol stehen soll. #}
{% set iconOnly = hasIcon and (b.iconOnly|default(false) or forceIconOnly|default(false)) %}
{% set classes = 'button cms-button menu-button menu-button-' ~ b.id %}
{% for key in ['shape', 'appearance', 'size'] %}{% if b[key] is defined %}{% set classes = classes ~ ' b-desktop-' ~ key ~ '-' ~ b[key] %}{% endif %}{% endfor %}
{% if hasIcon %}{% set classes = classes ~ (iconFirst ? ' icon-start' : '') ~ (b.iconMotion|default('none') != 'none' ? ' icon-motion-' ~ b.iconMotion : '') ~ (iconOnly ? ' icon-only' : '') %}{% endif %}
{% set iconMarkup = '' %}
{% if hasIcon %}{% set iconMarkup %}<span class="button-icon" aria-hidden="true">{% include 'blocks/icon.html.twig' with {name: link.icon} only %}</span>{% endset %}{% endif %}
<a class="{{ classes }}" href="{{ link.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: link.newTab|default(false)} only %}{% if link.current %} aria-current="page"{% endif %}>{% if iconMarkup and iconFirst %}{{ iconMarkup }} {% endif %}<span class="button-label">{{ link.label }}</span>{% if iconMarkup and not iconFirst %} {{ iconMarkup }}{% endif %}</a>
+4 -2
View File
@@ -1,8 +1,10 @@
{# One level of a resolved menu. Dropdowns use details/summary so submenus work without JavaScript. #} {# One level of a resolved menu. Dropdowns use details/summary so submenus work without JavaScript. #}
<ul class="menu-list menu-level-{{ level|default(1) }}"> <ul class="menu-list menu-level-{{ level|default(1) }}">
{% for link in links %} {% for link in links %}
<li> <li{% if link.button is defined %} class="menu-item-button{{ link.button.mobile|default('menu') in ['bar', 'bar-icon'] ? ' menu-keep-visible' }}"{% endif %}>
{% if link.children is not empty %} {% if link.button is defined %}
{% include 'menu-button.html.twig' with {link: link} only %}
{% elseif link.children is not empty %}
<details class="menu-branch"> <details class="menu-branch">
<summary>{% include 'nav-label.html.twig' with {link: link} only %}</summary> <summary>{% include 'nav-label.html.twig' with {link: link} only %}</summary>
<div class="menu-branch-body"> <div class="menu-branch-body">
+131
View File
@@ -315,6 +315,63 @@ final class CmsTest extends TestCase
yield 'Symbol ist kein Text' => ['icon', ['phone'], 25]; yield 'Symbol ist kein Text' => ['icon', ['phone'], 25];
yield 'Stellung vor v25' => ['iconPosition', 'start', 24]; yield 'Stellung vor v25' => ['iconPosition', 'start', 24];
yield 'unbekannte Stellung' => ['iconPosition', 'middle', 25]; 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')] #[DataProvider('rejectedButtonIcons')]
@@ -680,6 +737,80 @@ final class CmsTest extends TestCase
self::assertStringNotContainsString('<a href="/" aria-current="page">', $other); self::assertStringNotContainsString('<a href="/" aria-current="page">', $other);
} }
/**
* ADR 110: ein Menüeintrag als Button - dieselben Klassen wie der Button-Baustein, eigene
* Farben als Regel im Stylesheet der Seite, und auf dem Telefon wahlweise neben dem Menü-Knopf.
*/
public function testAMenuEntryRendersAsAButtonWithItsOwnColoursAndStaysBesideTheBurger(): void
{
$this->menus->save('header', 'Hauptnavigation', [
['label' => 'Start', 'type' => 'page', 'pageId' => 'page-1', 'anchor' => '', 'children' => []],
['label' => 'Angebot anfragen', 'type' => 'url', 'url' => '#kontakt', 'icon' => 'arrow-right', 'iconPosition' => 'after', 'children' => [],
'button' => ['id' => 'mabcdef0123', 'shape' => 'pill', 'appearance' => 'solid', 'fill' => '#b71c22', 'ink' => '#ffffff', 'hoverFill' => '@ink', 'iconColor' => '#ffd700', 'mobile' => 'bar', 'iconMotion' => 'slide']],
['label' => 'Anrufen', 'type' => 'url', 'url' => 'tel:+4972296992826', 'icon' => 'phone', 'children' => [],
'button' => ['id' => 'm0123456789', 'appearance' => 'outline', 'mobile' => 'menu', 'iconOnly' => true]],
['label' => 'Hotline', 'type' => 'url', 'url' => 'tel:+497229', 'icon' => 'phone', 'children' => [],
'button' => ['id' => 'm1111111111', 'mobile' => 'bar-icon']],
], 0);
$document = DemoContent::starter('Mit Menü');
$document['schemaVersion'] = 22;
$document['blocks'][0]['children'][] = ['id' => 'nav', 'type' => 'menu', 'props' => ['slot' => 'header', 'layout' => 'horizontal', 'collapse' => 'tablet']];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$response = $this->request('/');
$html = (string) $response->getContent();
$crawler = new \Symfony\Component\DomCrawler\Crawler($html);
$offer = $crawler->filter('#nav > .menu-list a.menu-button-mabcdef0123');
self::assertSame('button cms-button menu-button menu-button-mabcdef0123 b-desktop-shape-pill b-desktop-appearance-solid icon-motion-slide', $offer->attr('class'));
self::assertSame('#kontakt', $offer->attr('href'));
self::assertSame('Angebot anfragen', $offer->filter('.button-label')->text());
self::assertCount(1, $offer->filter('.button-icon svg'));
self::assertSame('menu-item-button menu-keep-visible', $offer->closest('li')->attr('class'));
// Beim Menü-Knopf steht er ein zweites Mal - sichtbar nur, solange das Menü zusammengeklappt ist.
self::assertCount(1, $crawler->filter('#nav > ul.menu-actions a.menu-button-mabcdef0123'));
self::assertCount(0, $crawler->filter('#nav > ul.menu-actions a.menu-button-m0123456789'));
// Nur Symbol: die Beschriftung bleibt als Name des Links.
$call = $crawler->filter('#nav > .menu-list a.menu-button-m0123456789');
self::assertStringContainsString('icon-start icon-only', (string) $call->attr('class'));
self::assertSame('Anrufen', $call->filter('.button-label')->text());
// Neben dem Menü-Knopf nur als Symbol, im Menü selbst mit Beschriftung.
self::assertStringContainsString('icon-only', (string) $crawler->filter('#nav > ul.menu-actions a.menu-button-m1111111111')->attr('class'));
self::assertStringNotContainsString('icon-only', (string) $crawler->filter('#nav > .menu-list a.menu-button-m1111111111')->attr('class'));
self::assertSame('Hotline', $crawler->filter('#nav > ul.menu-actions a.menu-button-m1111111111 .button-label')->text());
// Gewöhnliche Einträge bleiben gewöhnliche Links.
self::assertCount(1, $crawler->filter('#nav > .menu-list a[href="/"] .nav-label'));
self::assertStringContainsString('.menu-button-mabcdef0123{--kb-fill:#b71c22;--kb-ink:#ffffff;--kb-hover-fill:var(--k-ink);--kb-bg:var(--kb-fill)', $html);
self::assertStringContainsString('.menu-button-mabcdef0123 .button-icon{color:#ffd700}', $html);
self::assertStringNotContainsString('.menu-button-m0123456789{', $html, 'Ohne eigene Farben keine Regel.');
$css = (string) file_get_contents(__DIR__.'/../public/assets/site.css');
self::assertStringContainsString('.menu-collapse-tablet>.menu-actions{display:flex}', $css);
self::assertStringContainsString('.menu-collapse-tablet .menu-burger-body .menu-keep-visible{display:none}', $css);
}
/** @return iterable<string, array{array}> */
public static function rejectedMenuButtons(): iterable
{
$entry = ['label' => 'Angebot', 'type' => 'url', 'url' => '/kontakt', 'children' => []];
$button = ['id' => 'mabcdef0123', 'mobile' => 'menu'];
yield 'ohne Kennung' => [[...$entry, 'button' => ['mobile' => 'menu']]];
yield 'unbekannte Form' => [[...$entry, 'button' => [...$button, 'shape' => 'round']]];
yield 'Farbe als Wort' => [[...$entry, 'button' => [...$button, 'fill' => 'red']]];
yield 'CSS in der Farbe' => [[...$entry, 'button' => [...$button, 'ink' => '#fff;}body{display:none']]];
yield 'unbekannte Anzeige auf dem Telefon' => [[...$entry, 'button' => [...$button, 'mobile' => 'oben']]];
yield 'unbekannte Einstellung' => [[...$entry, 'button' => [...$button, 'style' => 'x']]];
yield 'neben dem Menü-Knopf nur als Symbol, ohne Symbol' => [[...$entry, 'button' => [...$button, 'mobile' => 'bar-icon']]];
yield 'nur Symbol ohne Symbol' => [[...$entry, 'button' => [...$button, 'iconOnly' => true]]];
yield 'Button mit Unterpunkten' => [[...$entry, 'button' => $button, 'children' => [['label' => 'Unter', 'type' => 'url', 'url' => '/a', 'children' => []]]]];
}
#[DataProvider('rejectedMenuButtons')]
public function testAMenuButtonRejectsUnknownOrUnsafeSettings(array $entry): void
{
$this->expectException(InvalidMenu::class);
$this->menus->save('header', 'Hauptnavigation', [$entry], 0);
}
public function testMenuSurvivesDeletedAndUnpublishedPagesWithoutLosingOtherEntries(): void public function testMenuSurvivesDeletedAndUnpublishedPagesWithoutLosingOtherEntries(): void
{ {
$doomed = $this->publishedPage('temporaer', 'Temporaer'); $doomed = $this->publishedPage('temporaer', 'Temporaer');
+32
View File
@@ -405,6 +405,38 @@ final class EditorSecurityTest extends WebTestCase
self::assertSame(['/', '/#kontakt', 'https://hausklar.app'], $public->extract(['href'])); self::assertSame(['/', '/#kontakt', 'https://hausklar.app'], $public->extract(['href']));
} }
/**
* ADR 110: Ein Menüeintrag wird über das Formular zum Button - mit Gestalt, Farben, Symbol,
* Bewegung und Anzeige auf dem Telefon. Die Kennung für die Farbregel entsteht beim ersten
* Speichern und bleibt danach gleich; leere Felder heißen Website-Standard und fehlen.
*/
public function testMenuFormTurnsAnEntryIntoAButtonAndKeepsItsId(): void
{
$client = $this->login('publisher');
$crawler = $client->request('GET', '/admin/menus');
$token = $crawler->filter('.menu-form input[name="_csrf_token"]')->first()->attr('value');
$row = ['position' => 10, 'level' => 1, 'label' => 'Angebot anfragen', 'type' => 'url', 'url' => '#kontakt',
'icon' => 'arrow-right', 'iconPosition' => 'after', 'button' => '1', 'buttonId' => '', 'buttonShape' => 'pill', 'buttonAppearance' => '',
'buttonSize' => '', 'buttonFill' => '#b71c22', 'buttonInk' => '', 'buttonHoverFill' => '@ink', 'buttonHoverInk' => '', 'buttonIconColor' => '',
'iconMotion' => 'slide', 'buttonMobile' => 'bar'];
$client->request('POST', '/admin/menus/header', ['_csrf_token' => $token, 'name' => 'Hauptnavigation', 'expectedVersion' => 0, 'items' => [$row]]);
self::assertResponseRedirects('/admin/menus');
$button = json_decode($this->db->fetchOne('SELECT items FROM menu WHERE slot = ?', ['header']), true)[0]['button'];
self::assertMatchesRegularExpression('/^m[a-f0-9]{10}$/', $button['id']);
self::assertSame(['shape' => 'pill', 'fill' => '#b71c22', 'hoverFill' => '@ink', 'mobile' => 'bar', 'iconMotion' => 'slide'], array_diff_key($button, ['id' => true]));
// Die Verwaltung zeigt die Werte wieder an, und erneutes Speichern behält die Kennung.
$crawler = $client->request('GET', '/admin/menus');
self::assertSame($button['id'], $crawler->filter('input[data-key="buttonId"]')->attr('value'));
self::assertSame('pill', $crawler->filter('select[data-key="buttonShape"] option[selected]')->attr('value'));
$client->request('POST', '/admin/menus/header', ['_csrf_token' => $token, 'name' => 'Hauptnavigation', 'expectedVersion' => 1, 'items' => [[...$row, 'buttonId' => $button['id']]]]);
self::assertSame($button['id'], json_decode($this->db->fetchOne('SELECT items FROM menu WHERE slot = ?', ['header']), true)[0]['button']['id']);
// Ohne Häkchen ist es wieder ein Link.
$client->request('POST', '/admin/menus/header', ['_csrf_token' => $token, 'name' => 'Hauptnavigation', 'expectedVersion' => 2, 'items' => [array_diff_key($row, ['button' => true])]]);
self::assertArrayNotHasKey('button', json_decode($this->db->fetchOne('SELECT items FROM menu WHERE slot = ?', ['header']), true)[0]);
}
public function testMenuWritesNeedAPublisherRoleACsrfTokenAndTheCurrentVersion(): void public function testMenuWritesNeedAPublisherRoleACsrfTokenAndTheCurrentVersion(): void
{ {
$menus = new MenuRepository($this->db, new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media'))); $menus = new MenuRepository($this->db, new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media')));
+2
View File
@@ -661,4 +661,6 @@ test('a loaded document is raised to the schema its blocks and properties need',
assert.equal(repairSchema(symbol).schemaVersion, 25); assert.equal(repairSchema(symbol).schemaVersion, 25);
const pfeil = { schemaVersion: 24, title: 'P', description: '', blocks: [{ id: 'b', type: 'button', props: { text: 'Mehr', href: '/' } }] }; const pfeil = { schemaVersion: 24, title: 'P', description: '', blocks: [{ id: 'b', type: 'button', props: { text: 'Mehr', href: '/' } }] };
assert.equal(repairSchema(pfeil).schemaVersion, 24); 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);
}); });
+11 -11
View File
@@ -1,29 +1,29 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { ICON_PATHS, ICON_LABELS, BUTTON_ICON_GROUPS, ALL_ICON_GROUPS } from '../public/assets/icons.js'; import { ICON_PATHS, ICON_LABELS, ICON_GROUPS } from '../public/assets/icons.js';
const read = path => readFileSync(new URL(path, import.meta.url), 'utf8'); 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', () => { 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 // 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 // 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. // 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 twig = read('../templates/blocks/icon.html.twig');
const website = Object.fromEntries([...twig.matchAll(/^\s{4}'?([a-z-]+)'?: '([^']+)',?$/gm)].map(([, name, path]) => [name, path])); const website = Object.fromEntries([...twig.matchAll(/^\s{4}'?([a-z-]+)'?: '([^']+)',?$/gm)].map(([, name, path]) => [name, path]));
assert.deepEqual(ICON_PATHS, website); assert.deepEqual(ICON_PATHS, website);
const validator = read('../src/Content/DocumentValidator.php'); const validator = read('../src/Content/DocumentValidator.php');
const allowed = [...validator.match(/public const ICONS = \[([^\]]+)\]/)[1].matchAll(/'([a-z-]+)'/g)].map(([, name]) => name); const allowed = [...validator.match(/public const ICONS = \[([^\]]+)\]/)[1].matchAll(/'([a-z-]+)'/g)].map(([, name]) => name);
assert.deepEqual(Object.keys(ICON_PATHS).sort(), [...allowed].sort()); assert.deepEqual(Object.keys(ICON_PATHS), allowed);
}); });
test('every icon has a German name and the pickers offer each icon exactly once', () => { 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()); assert.deepEqual(Object.keys(ICON_LABELS).sort(), Object.keys(ICON_PATHS).sort());
const button = BUTTON_ICON_GROUPS.flatMap(([, names]) => names); const listed = ICON_GROUPS.flatMap(([, names]) => names);
assert.equal(new Set(button).size, button.length, 'Ein Symbol steht doppelt in der Button-Auswahl.'); assert.equal(new Set(listed).size, listed.length, 'Ein Symbol steht in zwei Kategorien.');
for (const name of button) assert.ok(ICON_PATHS[name], `${name} hat keine Zeichnung.`); assert.deepEqual([...listed].sort(), Object.keys(ICON_PATHS).sort(), 'Jedes Symbol steht in einer Kategorie.');
// Der bisherige Pfeil steht vorn: er ist das, was ein Button ohne eigene Wahl trägt. // Der bisherige Pfeil steht vorn: ihn trägt ein Button ohne eigene Wahl.
assert.equal(button[0], 'arrow-up-right'); assert.equal(ICON_GROUPS[0][1][0], 'arrow-up-right');
const all = ALL_ICON_GROUPS.flatMap(([, names]) => names); assert.ok(listed.length >= 90, `Nur ${listed.length} Symbole.`);
assert.deepEqual([...all].sort(), Object.keys(ICON_PATHS).sort(), 'Symbol und Symbolkarte bieten jedes Symbol genau einmal an.'); for (const path of Object.values(ICON_PATHS)) assert.match(path, /^[Mm][0-9MmLlHhVvCcSsQqTtAaZz .,-]+$/, 'Ein Pfad enthält Zeichen, die keine Zeichnung sind.');
}); });