Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28b2442188 | ||
|
|
8e628b9417 | ||
|
|
13f6759df8 | ||
|
|
48ae2443cd | ||
|
|
f00fbfef09 | ||
|
|
08998de533 | ||
|
|
6af2e2aead | ||
|
|
88737a95a9 |
No files matched your search
@@ -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,6 +220,8 @@ Unter `http://127.0.0.1:8080/admin` eine Seite auswählen. Links Blöcke hinzuf
|
|||||||
|
|
||||||
**Links und Bildvorschauen (ab 1.7.3):** Unter Linkziel steht **In neuem Tab öffnen** – für Buttons, verlinkte Bilder, Symbole, Symbolkarten und Aufruf-Bänder. Im Text zuerst den Link über die Werkzeugleiste öffnen; bei Menüeinträgen steht das Häkchen in den aufgeklappten Einstellungen. Ausgewählte Bilder und Galerie-/Logobilder zeigen eine Vorschau mit Entfernen-Funktion. „Bild entfernen“ entfernt das Bild aus dem Inhalt; die Datei bleibt in der Mediathek. Rückgängig stellt die Verwendung wieder her. Design → Hintergrund zeigt auch geerbte Bilder: Entfernen blendet das Bild auf dem aktiven und kleineren Geräten aus, Zurücksetzen stellt die Vererbung wieder her. Bildaustausch erhält Link, Bildunterschrift und eingestellte Größe. Prüfung mit isolierter Kant-IT-Kopie: `npm run test:link-media`, Bericht und Screenshots unter `reports/link-media/`. Datenvertrag und Rückweg: ADR 081 in `docs/ARCHITECTURE.md`.
|
**Links und Bildvorschauen (ab 1.7.3):** Unter Linkziel steht **In neuem Tab öffnen** – für Buttons, verlinkte Bilder, Symbole, Symbolkarten und Aufruf-Bänder. Im Text zuerst den Link über die Werkzeugleiste öffnen; bei Menüeinträgen steht das Häkchen in den aufgeklappten Einstellungen. Ausgewählte Bilder und Galerie-/Logobilder zeigen eine Vorschau mit Entfernen-Funktion. „Bild entfernen“ entfernt das Bild aus dem Inhalt; die Datei bleibt in der Mediathek. Rückgängig stellt die Verwendung wieder her. Design → Hintergrund zeigt auch geerbte Bilder: Entfernen blendet das Bild auf dem aktiven und kleineren Geräten aus, Zurücksetzen stellt die Vererbung wieder her. Bildaustausch erhält Link, Bildunterschrift und eingestellte Größe. Prüfung mit isolierter Kant-IT-Kopie: `npm run test:link-media`, Bericht und Screenshots unter `reports/link-media/`. Datenvertrag und Rückweg: ADR 081 in `docs/ARCHITECTURE.md`.
|
||||||
|
|
||||||
|
**Symbole (ab 1.25.0):** Button, Symbol oder Symbolkarte auswählen → Inhalt → **Symbol** → **Ändern …**. Das Fenster zeigt 98 Symbole in sieben Kategorien, mit Namen; Pfeiltasten und Escape funktionieren, beim Button gibt es „Kein Symbol“. Am Button außerdem **Stellung** (vor/nach dem Text), **Beim Überfahren** (Ruhig, Rückt, Erscheint) und **Nur das Symbol zeigen** (quadratischer Knopf, die Beschriftung bleibt für Vorlesesoftware). Farbe, Fläche, Größe, Ecken, Drehung und Abstand stehen unter **Design → Symbol**, je Gerät und beim Überfahren. Ohne eigene Wahl trägt ein Button wie bisher den schrägen Pfeil. Neue Symbole kommen nach `public/assets/icons.js`, danach `npm run build:icons`. Prüfung mit isolierter Kant-IT-Kopie: `npm run test:button-icons`, Screenshots unter `reports/button-icons/`. Datenvertrag: ADR 108 und 109.
|
||||||
|
|
||||||
Blöcke per Drag-and-drop verschieben: auf einen Container ziehen, um hineinzuverschieben; auf einen Text-/Bild-/Linkblock ziehen, um davor einzufügen. Alternativ unten am Strukturbaum die Schaltflächen für Auf/Ab, Verschachteln und Herausheben verwenden. Duplizieren vergibt neue Block-IDs und wandelt eine kopierte H1 in H2 um. Löschen lässt sich rückgängig machen. Jede speicherbare Seite benötigt genau eine H1.
|
Blöcke per Drag-and-drop verschieben: auf einen Container ziehen, um hineinzuverschieben; auf einen Text-/Bild-/Linkblock ziehen, um davor einzufügen. Alternativ unten am Strukturbaum die Schaltflächen für Auf/Ab, Verschachteln und Herausheben verwenden. Duplizieren vergibt neue Block-IDs und wandelt eine kopierte H1 in H2 um. Löschen lässt sich rückgängig machen. Jede speicherbare Seite benötigt genau eine H1.
|
||||||
|
|
||||||
Undo/Redo gilt für die lokale Bearbeitung, `Strg+S` speichert den Entwurf. `Strg+Z`/`Strg+Umschalt+Z` gelten außerhalb der Texteingaben für das Dokument; Texteingaben behalten ihr natives Undo. Erst speichern, dann separat veröffentlichen. Die Bestätigungsdialoge unterstützen Escape und Fokus-Rückkehr. Reine Redakteure können nicht veröffentlichen; was Redakteur, Veröffentlicher und Administrator dürfen, steht unter Benutzer bei jeder Rolle und im Abschnitt „Rollen“ oben.
|
Undo/Redo gilt für die lokale Bearbeitung, `Strg+S` speichert den Entwurf. `Strg+Z`/`Strg+Umschalt+Z` gelten außerhalb der Texteingaben für das Dokument; Texteingaben behalten ihr natives Undo. Erst speichern, dann separat veröffentlichen. Die Bestätigungsdialoge unterstützen Escape und Fokus-Rückkehr. Reine Redakteure können nicht veröffentlichen; was Redakteur, Veröffentlicher und Administrator dürfen, steht unter Benutzer bei jeder Rolle und im Abschnitt „Rollen“ oben.
|
||||||
|
|||||||
@@ -2,6 +2,52 @@
|
|||||||
|
|
||||||
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)
|
||||||
|
|
||||||
|
Umut: „Buttons mit Icon? Geht das, wenn ja wo? Und einfacher Button ist ein Pfeil, wie ändere ich das?“ – und dann: „Ein Satz Symbole, die man so alltäglich brauchen könnte, und schöne Vorschau-Auswahl, bitte kein Dropdown mit Suche.“ Bis hierher hängte `block.html.twig` jedem Button fest `arrow-up-right` an, auch dem Textlink. Ändern ging nur über eigenes CSS (Hüfner blendet den Pfeil so aus, EB Solar drehte ihn).
|
||||||
|
|
||||||
|
**Datenvertrag (Schema v25).** Zwei neue, optionale Eigenschaften am Button: `icon` – ein Name aus `DocumentValidator::ICONS` oder `none` – und `iconPosition` – `start` oder `end`. Fehlt `icon`, bleibt es beim schrägen Pfeil (`BUTTON_DEFAULT_ICON`); fehlt `iconPosition`, steht das Symbol hinter dem Text. Ein Dokument ohne die beiden Felder sieht also genau aus wie vorher und braucht keinen Migrationsweg. Beide Felder verlangen v25, damit ein älterer Kern ein Dokument mit Symbolwahl ablehnt, statt es still mit Pfeil zu zeigen. Der Editor hebt das Dokument bei der ersten Wahl an, und `repairSchema` tut es beim Laden.
|
||||||
|
|
||||||
|
**Kompatibilität mit eigenem CSS.** Die Hülle des Symbols heißt jetzt `button-icon`. Der Standardpfeil trägt zusätzlich weiter `button-arrow`, damit Regeln wie `& .button-arrow{display:none}` auf bestehenden Websites wirken wie bisher. Wer dort ein anderes Symbol wählt, bekommt es sichtbar – die alte Regel trifft es nicht. Die Farb- und Größenregeln aus ADR 107 gelten jetzt für `button-icon`.
|
||||||
|
|
||||||
|
**Symbolsatz.** Dreizehn alltägliche Symbole kommen dazu: Pfeil nach unten, Winkel, Kalender, Nachricht, Senden, Download, Dokument, Abspielen, Suche, Herz, Info, Plus, Person. Sie stehen allen Bausteinen mit Symbol offen (Symbol, Symbolkarte, Button), nicht nur dem Button; das erweitert eine bestehende Liste und braucht wie ADR 107 kein neues Schema. Keine Markenzeichen (etwa WhatsApp): deren Logos haben eigene Nutzungsregeln.
|
||||||
|
|
||||||
|
**Auswahl mit Kacheln.** `public/assets/icons.js` hält Pfade, deutsche Namen und die Gruppen (Pfeile, Kontakt, Allgemein; bei Symbol und Symbolkarte zusätzlich „Weitere“) und zeichnet die Auswahl: jedes Symbol ein Knopf mit seiner Zeichnung und `aria-pressed`, „Kein Symbol“ vorn, Pfeiltasten wandern, Tab verlässt die Gruppe. Die bisherige Auswahlliste mit englischen Schlüsseln bei Symbol und Symbolkarte ist durch dieselbe Kachelauswahl ersetzt. Die Stellung ist ein Umschalter aus zwei Knöpfen und erscheint nur, wenn ein Symbol gewählt ist. Die Pfade stehen damit zweimal (Twig für die Website, JS für die Vorschau); `tests/icons.test.mjs` hält beide und die Liste im Validator gleich.
|
||||||
|
|
||||||
|
**Geprüft:** `CmsTest::testAButtonCarriesTheChosenIconBeforeOrAfterItsTextOrNone` (Standardpfeil mit `button-arrow`, Telefon vorn, kein Symbol), `testAButtonRejectsUnknownIconsAndOlderSchemas`, `tests/icons.test.mjs`, `repairSchema` hebt auf v25, `npm run test:button-icons` im Browser (Kachel, Stellung, Tastatur, Rückgängig, kein Symbol, Speichern als v25 und Neuladen).
|
||||||
|
|
||||||
## ADR 107 – Knöpfe dürfen anrufen und schreiben, der Pfeil hat die Schriftfarbe (06.10.2026)
|
## ADR 107 – Knöpfe dürfen anrufen und schreiben, der Pfeil hat die Schriftfarbe (06.10.2026)
|
||||||
|
|
||||||
Beim Aufbau von Hüfner Gartenbau fielen zwei Lücken auf, die Umut im Kern geschlossen haben wollte.
|
Beim Aufbau von Hüfner Gartenbau fielen zwei Lücken auf, die Umut im Kern geschlossen haben wollte.
|
||||||
|
|||||||
@@ -1,5 +1,53 @@
|
|||||||
# 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)
|
||||||
|
|
||||||
|
**Anlass:** Umut: „Buttons mit Icon? Geht das, wenn ja wo? Und einfacher Button ist ein Pfeil, wie ändere ich das?“ – dann: „Ja bitte umsetzen. Ein Satz Symbole, die man so alltäglich brauchen könnte, und schöne Vorschau-Auswahl, bitte kein Dropdown mit Suche usw.“
|
||||||
|
|
||||||
|
**Neu (Kern, Schema v25, ADR 108):**
|
||||||
|
|
||||||
|
- Button → Inhalt → **Symbol**: Kacheln mit Vorschau in drei Gruppen (Pfeile, Kontakt, Allgemein), „Kein Symbol“ vorn. Darunter **Symbol steht: Vor dem Text / Nach dem Text**, nur wenn ein Symbol gewählt ist. Ohne Wahl bleibt der schräge Pfeil hinter dem Text – bestehende Seiten sehen unverändert aus, eigenes CSS auf `.button-arrow` greift weiter.
|
||||||
|
- 13 neue Symbole im gemeinsamen Satz (Pfeil nach unten, Winkel, Kalender, Nachricht, Senden, Download, Dokument, Abspielen, Suche, Herz, Info, Plus, Person), damit 33 insgesamt.
|
||||||
|
- Symbol und Symbolkarte wählen ihr Symbol mit derselben Kachelauswahl statt der Liste mit englischen Namen.
|
||||||
|
- `public/assets/icons.js` (Pfade, deutsche Namen, Gruppen, Auswahl), in der Importmap eingetragen. Stile in `button-controls.css`. `site.css`: Hülle heißt `button-icon`.
|
||||||
|
|
||||||
|
**Geprüft:** PHPUnit vollständig (609 Tests) grün, darunter zwei neue Tests in `CmsTest` (Ausgabe mit Standardpfeil, Telefon vorn, kein Symbol; Ablehnung unbekannter Symbole und älterer Schemata). `npm test` (38) grün, dazu `tests/icons.test.mjs` (Twig, Editor und Validator kennen dieselben Symbole) und `repairSchema` hebt auf v25. `npm run test:button-icons` im Browser auf einer Kopie der Pilotdaten: Kachel wählen, Stellung umschalten, Pfeiltasten und Enter, Rückgängig/Wiederholen, kein Symbol, Speichern als v25, nach Neuladen gleich; keine Seitenfehler. Screenshots `reports/button-icons/`. php-cs-fixer und `lint:twig` ohne Befund. Nicht geprüft: Lighthouse und `test:browser` (die öffentliche Ausgabe ändert sich nur bei gewähltem Symbol um ein Inline-SVG gleicher Größe).
|
||||||
|
|
||||||
|
**Bekannt:** Ein Dokument mit Symbolwahl lässt sich auf einem Kern vor dieser Fassung nicht speichern (Schema v25) – Installationen zuerst aktualisieren. Keine Markenlogos im Satz.
|
||||||
|
|
||||||
|
**Nächster Schritt:** Push von `main` und Tag `v1.24.0`, Releasearchiv auf der Releaseseite veröffentlichen, dann die Installationen aktualisieren (vor Hüfner, damit dort Symbole gewählt werden können).
|
||||||
|
|
||||||
## Fassung 1.23.1: neunte Installation „Hüfner Gartenbau“, Knöpfe mit tel: und mailto:, Pfeil in der Schriftfarbe (06.10.2026)
|
## Fassung 1.23.1: neunte Installation „Hüfner Gartenbau“, Knöpfe mit tel: und mailto:, Pfeil in der Schriftfarbe (06.10.2026)
|
||||||
|
|
||||||
**Anlass:** Umut: „Hier liegt ein Designentwurf für Hüfner Gartenbau. Kannst du mir db und media erstellen für KantSites bitte. […] Effekte rein. Nichts am Code ändern, sondern alles an KantSites-Designelementen anpassen/bauen. Falls was nicht geht, Rückmeldung.“ Vorlage: `Downloads/huefner-website/huefner-website` (statisches `index.html`, 16 Fotos, Logo).
|
**Anlass:** Umut: „Hier liegt ein Designentwurf für Hüfner Gartenbau. Kannst du mir db und media erstellen für KantSites bitte. […] Effekte rein. Nichts am Code ändern, sondern alles an KantSites-Designelementen anpassen/bauen. Falls was nicht geht, Rückmeldung.“ Vorlage: `Downloads/huefner-website/huefner-website` (statisches `index.html`, 16 Fotos, Logo).
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build:buttons": "node scripts/build-buttons.mjs",
|
"build:buttons": "node scripts/build-buttons.mjs",
|
||||||
"test:buttons": "node scripts/button-check.mjs",
|
"test:buttons": "node scripts/button-check.mjs",
|
||||||
|
"test:button-icons": "node scripts/button-icon-check.mjs",
|
||||||
|
"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",
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
.button-designer{padding:16px 0;border-block:1px solid #dce4dc;margin-block:16px}.button-designer h3{margin:0 0 12px;font-size:15px}.button-state-previews{display:grid;gap:12px;padding:16px;background:#fff;border:1px solid #dce4dc;border-radius:12px;margin-bottom:16px}.button-state{display:grid;justify-items:start;gap:7px;min-width:0}.button-state>.field-help{margin:0}.button-preset-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin:12px 0 16px}.button-preset{display:grid;justify-items:center;align-content:center;gap:9px;min-width:0;padding:12px 6px;background:#fff;border:1px solid #dce4dc;border-radius:10px;color:#203b34;cursor:pointer}.button-preset[aria-pressed=true]{border-color:#2c6b4f;box-shadow:inset 0 0 0 1px #2c6b4f;background:#f1f6ef}.button-preset:focus-visible{outline:3px solid #b55b12;outline-offset:2px}.button-preset .cms-button{font-size:12px!important;padding:6px 10px;min-height:34px;pointer-events:none}.button-preset-label{font-size:11px;line-height:1.4;text-align:center}.button-designer select{width:100%;margin-bottom:12px}.button-defaults{margin-block:24px;padding:24px;border:1px solid #dce4dc;border-radius:14px;min-width:0}.button-defaults legend{font-size:20px;font-weight:700;padding:0 8px}.button-defaults-layout{display:grid;grid-template-columns:minmax(0,1fr) minmax(260px,.75fr);gap:28px}.button-defaults .fields{align-content:start}.button-defaults .button-preview-column{min-width:0}.button-defaults .button-preview-column h3{font-size:15px;margin:0 0 12px}.button-defaults .color-field{margin:0}.button-defaults .color-trigger{width:100%}@media(max-width:900px){.button-defaults-layout{grid-template-columns:1fr}.button-defaults{padding:16px}}
|
.button-designer{padding:16px 0;border-block:1px solid #dce4dc;margin-block:16px}.button-designer h3{margin:0 0 12px;font-size:15px}.button-state-previews{display:grid;gap:12px;padding:16px;background:#fff;border:1px solid #dce4dc;border-radius:12px;margin-bottom:16px}.button-state{display:grid;justify-items:start;gap:7px;min-width:0}.button-state>.field-help{margin:0}.button-preset-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin:12px 0 16px}.button-preset{display:grid;justify-items:center;align-content:center;gap:9px;min-width:0;padding:12px 6px;background:#fff;border:1px solid #dce4dc;border-radius:10px;color:#203b34;cursor:pointer}.button-preset[aria-pressed=true]{border-color:#2c6b4f;box-shadow:inset 0 0 0 1px #2c6b4f;background:#f1f6ef}.button-preset:focus-visible{outline:3px solid #b55b12;outline-offset:2px}.button-preset .cms-button{font-size:12px!important;padding:6px 10px;min-height:34px;pointer-events:none}.button-preset-label{font-size:11px;line-height:1.4;text-align:center}.button-designer select{width:100%;margin-bottom:12px}.button-defaults{margin-block:24px;padding:24px;border:1px solid #dce4dc;border-radius:14px;min-width:0}.button-defaults legend{font-size:20px;font-weight:700;padding:0 8px}.button-defaults-layout{display:grid;grid-template-columns:minmax(0,1fr) minmax(260px,.75fr);gap:28px}.button-defaults .fields{align-content:start}.button-defaults .button-preview-column{min-width:0}.button-defaults .button-preview-column h3{font-size:15px;margin:0 0 12px}.button-defaults .color-field{margin:0}.button-defaults .color-trigger{width:100%}@media(max-width:900px){.button-defaults-layout{grid-template-columns:1fr}.button-defaults{padding:16px}}.icon-picker-label{margin:0 0 6px;font-size:13px;font-weight:700;color:#203b34}.icon-field{margin:16px 0 14px}.icon-field-row{display:grid;grid-template-columns:44px minmax(0,1fr) auto;align-items:center;gap:10px;padding:8px;border:1px solid #dce4dc;border-radius:10px;background:#fff}.icon-field-preview{display:grid;place-items:center;width:44px;height:44px;border-radius:8px;background:#eef5ea;color:#1d4d37}.icon-field-preview svg{width:24px;height:24px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}.icon-field-preview.is-empty{background:repeating-linear-gradient(135deg,#f3f5f2 0 6px,#fff 6px 12px);border:1px dashed #c5d2c7}.icon-field-name{font-size:14px;font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.icon-field-change{min-height:36px;white-space:nowrap}.icon-setting{margin:0 0 14px}.segmented{display:grid;border:1px solid #dce4dc;border-radius:9px;overflow:hidden}.segmented button{min-height:38px;border:0;background:#fff;color:#203b34;font-size:13px;font-weight:600;cursor:pointer}.segmented button+button{border-left:1px solid #dce4dc}.segmented button[aria-pressed=true]{background:#2c6b4f;color:#fff}.segmented button:focus-visible{outline:3px solid #b55b12;outline-offset:-3px}.icon-design-link{display:block;margin:4px 0 8px;font-weight:600}dialog.icon-dialog{max-width:860px;width:calc(100% - 40px);padding:0;overflow:hidden}.icon-dialog::backdrop{background:#1b2a2466}.icon-dialog-head{display:flex;align-items:center;justify-content:space-between;padding:18px 22px;border-bottom:1px solid #dce4dc}.icon-dialog-head h2{margin:0;font-size:19px}.icon-dialog-close{display:grid;place-items:center;width:40px;height:40px;border:0;border-radius:8px;background:transparent;color:#203b34;cursor:pointer}.icon-dialog-close:hover{background:#eef3ec}.icon-dialog-close svg{width:22px;height:22px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round}.icon-dialog-body{display:grid;grid-template-columns:200px minmax(0,1fr);height:min(520px,calc(100vh - 220px))}.icon-dialog-tabs{display:flex;flex-direction:column;gap:2px;padding:12px;border-right:1px solid #dce4dc;background:#f7faf5;overflow:auto}.icon-dialog-tab{text-align:left;padding:10px 12px;border:0;border-radius:8px;background:transparent;color:#203b34;font-size:14px;font-weight:600;cursor:pointer}.icon-dialog-tab:hover{background:#eaf1e6}.icon-dialog-tab[aria-selected=true]{background:#2c6b4f;color:#fff}.icon-dialog-tab:focus-visible,.icon-choice:focus-visible,.icon-dialog-close:focus-visible{outline:3px solid #b55b12;outline-offset:2px}.icon-dialog-panel{padding:16px;overflow:auto}.icon-dialog-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(92px,1fr));gap:8px}.icon-choice{display:grid;justify-items:center;align-content:center;gap:8px;min-height:92px;padding:12px 6px;border:1px solid #dce4dc;border-radius:10px;background:#fff;color:#203b34;cursor:pointer;transition:border-color .15s,background-color .15s}.icon-choice svg{width:28px;height:28px;fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}.icon-choice-name{font-size:12px;line-height:1.3;text-align:center}.icon-choice:hover{border-color:#8fb39f;background:#f7faf5}.icon-choice[aria-pLine truncated
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -174,6 +174,8 @@ export function repairSchema(document) {
|
|||||||
if (block.anchor) upgradeDocument(document, 21);
|
if (block.anchor) upgradeDocument(document, 21);
|
||||||
if ('collapse' in props) upgradeDocument(document, 22);
|
if ('collapse' in props) upgradeDocument(document, 22);
|
||||||
if ('faq' in props) upgradeDocument(document, 23);
|
if ('faq' in props) upgradeDocument(document, 23);
|
||||||
|
if (block.type === 'button' && ('icon' in props || 'iconPosition' in props)) upgradeDocument(document, 25);
|
||||||
|
if (block.type === 'button' && ('iconMotion' in props || 'iconOnly' in props)) upgradeDocument(document, 26);
|
||||||
walk(block.children);
|
walk(block.children);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -342,7 +344,7 @@ export const devices = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobil' }
|
|||||||
// blieb bei 12 stehen, während Bausteine schon v13 bis v15 verlangten: in einem aktuellen Dokument
|
// blieb bei 12 stehen, während Bausteine schon v13 bis v15 verlangten: in einem aktuellen Dokument
|
||||||
// liess sich dann gar nichts mehr einfügen, und ein Symbol, ein Slider oder ein externer Inhalt
|
// liess sich dann gar nichts mehr einfügen, und ein Symbol, ein Slider oder ein externer Inhalt
|
||||||
// nirgends. Eine Zahl, an einer Stelle.
|
// nirgends. Eine Zahl, an einer Stelle.
|
||||||
export const SCHEMA_VERSION = 24;
|
export const SCHEMA_VERSION = 26;
|
||||||
|
|
||||||
export const VISIBILITY = { all: 'Auf allen Geräten', desktop: 'Nur Desktop', tablet: 'Nur Tablet', mobile: 'Nur Mobil', 'hide-desktop': 'Auf Desktop ausblenden', 'hide-tablet': 'Auf Tablet ausblenden', 'hide-mobile': 'Auf Mobil ausblenden', hidden: 'Überall ausblenden' };
|
export const VISIBILITY = { all: 'Auf allen Geräten', desktop: 'Nur Desktop', tablet: 'Nur Tablet', mobile: 'Nur Mobil', 'hide-desktop': 'Auf Desktop ausblenden', 'hide-tablet': 'Auf Tablet ausblenden', 'hide-mobile': 'Auf Mobil ausblenden', hidden: 'Überall ausblenden' };
|
||||||
|
|
||||||
@@ -684,6 +686,7 @@ export function relevantProperty(block, key, property) {
|
|||||||
const container = isContainer(block);
|
const container = isContainer(block);
|
||||||
if (property.group === 'slider') return block.type === 'slider';
|
if (property.group === 'slider') return block.type === 'slider';
|
||||||
if (property.group === 'menu') return block.type === 'menu';
|
if (property.group === 'menu') return block.type === 'menu';
|
||||||
|
if (property.group === 'icon') return block.type === 'button' && block.props?.icon !== 'none';
|
||||||
if (property.group === 'image') return ['image', 'gallery', 'logos', 'postlist'].includes(block.type);
|
if (property.group === 'image') return ['image', 'gallery', 'logos', 'postlist'].includes(block.type);
|
||||||
if (property.group === 'text' || key === 'textShadow') return !['image', 'gallery', 'logos', 'spacer', 'divider', 'embed'].includes(block.type);
|
if (property.group === 'text' || key === 'textShadow') return !['image', 'gallery', 'logos', 'spacer', 'divider', 'embed'].includes(block.type);
|
||||||
if (key === 'columnWidths') return block.type === 'row';
|
if (key === 'columnWidths') return block.type === 'row';
|
||||||
|
|||||||
+56
-4
@@ -11,8 +11,8 @@ import { initMotion } from './motion.js';
|
|||||||
import { initSliders } from './slider.js';
|
import { initSliders } from './slider.js';
|
||||||
import { VISIBILITY, setVisibility, registerPlugins, pluginDefinition, PLUGIN_PRESETS, repairSchema } from './editor-model.js';
|
import { VISIBILITY, setVisibility, registerPlugins, pluginDefinition, PLUGIN_PRESETS, repairSchema } from './editor-model.js';
|
||||||
|
|
||||||
// Mirrors DocumentValidator::ICONS; the server rejects anything outside this list.
|
// Symbole, ihre deutschen Namen und die Kachelauswahl. Die Liste spiegelt DocumentValidator::ICONS.
|
||||||
const ICONS = ['app', 'web', 'design', 'network', 'hardware', 'shop', 'support', 'shield', 'clock', 'star', 'phone', 'mail', 'pin', 'check', 'people', 'music', 'home'];
|
import { renderIconField } from './icons.js';
|
||||||
const ICON_SIZES = [12, 16, 20, 24, 32, 40, 48, 56, 64, 80, 96, 120, 160, 200, 240];
|
const ICON_SIZES = [12, 16, 20, 24, 32, 40, 48, 56, 64, 80, 96, 120, 160, 200, 240];
|
||||||
// Mirrors SocialProfiles::NETWORKS; the addresses themselves live in the settings.
|
// Mirrors SocialProfiles::NETWORKS; the addresses themselves live in the settings.
|
||||||
const SOCIAL_NETWORKS = { facebook: 'Facebook', instagram: 'Instagram', linkedin: 'LinkedIn', youtube: 'YouTube', tiktok: 'TikTok', x: 'X', threads: 'Threads', pinterest: 'Pinterest', xing: 'Xing', whatsapp: 'WhatsApp', google: 'Google-Unternehmensprofil' };
|
const SOCIAL_NETWORKS = { facebook: 'Facebook', instagram: 'Instagram', linkedin: 'LinkedIn', youtube: 'YouTube', tiktok: 'TikTok', x: 'X', threads: 'Threads', pinterest: 'Pinterest', xing: 'Xing', whatsapp: 'WhatsApp', google: 'Google-Unternehmensprofil' };
|
||||||
@@ -319,6 +319,7 @@ function renderContent(block, panel) {
|
|||||||
if (key === 'newTab') upgradeDocument(doc, 20);
|
if (key === 'newTab') upgradeDocument(doc, 20);
|
||||||
if (key === 'collapse') upgradeDocument(doc, 22);
|
if (key === 'collapse') upgradeDocument(doc, 22);
|
||||||
if (key === 'faq') upgradeDocument(doc, 23);
|
if (key === 'faq') upgradeDocument(doc, 23);
|
||||||
|
if (key === 'iconOnly') upgradeDocument(doc, 26);
|
||||||
if (current.type === 'image' && key === 'src') { delete current.props.sources; delete current.props.mediaId; }
|
if (current.type === 'image' && key === 'src') { delete current.props.sources; delete current.props.mediaId; }
|
||||||
}, options.choices ? null : `${selected || 'page'}:${key}`);
|
}, options.choices ? null : `${selected || 'page'}:${key}`);
|
||||||
if (key === 'src') imagePreview?.update({ src: input.value });
|
if (key === 'src') imagePreview?.update({ src: input.value });
|
||||||
@@ -346,6 +347,37 @@ function renderContent(block, panel) {
|
|||||||
if (key === 'href' && !options.plugin) field('In neuem Tab öffnen', 'newTab', block.props.newTab === true, { checkbox: true, container });
|
if (key === 'href' && !options.plugin) field('In neuem Tab öffnen', 'newTab', block.props.newTab === true, { checkbox: true, container });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// Eine Eigenschaft setzen, die kein Textfeld ist (Symbol, Stellung, Bewegung). Danach wird das
|
||||||
|
// Panel neu gezeichnet, und der Fokus kehrt zu dem Bedienelement zurück, das sie gesetzt hat.
|
||||||
|
const choose = (key, next, version, focus) => {
|
||||||
|
history.change(doc => {
|
||||||
|
const current = locate(doc.blocks, selected)?.block;
|
||||||
|
if (!current) return;
|
||||||
|
(current.props ??= {})[key] = next;
|
||||||
|
// Ohne Symbol gibt es keinen Button nur aus Symbol - sonst stünde ein leerer Knopf da.
|
||||||
|
if (key === 'icon' && next === 'none') delete current.props.iconOnly;
|
||||||
|
if (version) upgradeDocument(doc, version);
|
||||||
|
}, null);
|
||||||
|
renderProperties(); renderTree(); controls(); schedulePreview();
|
||||||
|
document.querySelector(focus)?.focus({ preventScroll: true });
|
||||||
|
};
|
||||||
|
const iconField = (label, value, options = {}) => panel.append(renderIconField({
|
||||||
|
label, value, none: options.none, onChoose: name => choose('icon', name, options.version, '.icon-field-change'),
|
||||||
|
}));
|
||||||
|
// Zwei bis drei Möglichkeiten nebeneinander statt einer Auswahlliste.
|
||||||
|
const segmented = (label, key, current, options, version) => {
|
||||||
|
const wrap = document.createElement('div'); wrap.className = 'icon-setting';
|
||||||
|
const caption = document.createElement('p'); caption.className = 'icon-picker-label'; caption.id = `segmented-${key}`; caption.textContent = label;
|
||||||
|
const group = document.createElement('div'); group.className = 'segmented'; group.setAttribute('role', 'group'); group.setAttribute('aria-labelledby', caption.id);
|
||||||
|
group.style.gridTemplateColumns = `repeat(${options.length}, 1fr)`;
|
||||||
|
for (const [value, text] of options) {
|
||||||
|
const option = document.createElement('button'); option.type = 'button'; option.dataset[key] = value; option.textContent = text;
|
||||||
|
option.setAttribute('aria-pressed', String(value === current));
|
||||||
|
option.addEventListener('click', () => { if (value !== current) choose(key, value, version, `[data-${key.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`)}="${value}"]`); });
|
||||||
|
group.append(option);
|
||||||
|
}
|
||||||
|
wrap.append(caption, group); panel.append(wrap);
|
||||||
|
};
|
||||||
if (!block) {
|
if (!block) {
|
||||||
field('Seitentitel', 'title', history.document.title, { maxLength: 160 });
|
field('Seitentitel', 'title', history.document.title, { maxLength: 160 });
|
||||||
field('Beschreibung für Suchmaschinen', 'description', history.document.description, { multiline: true, maxLength: 320 });
|
field('Beschreibung für Suchmaschinen', 'description', history.document.description, { multiline: true, maxLength: 320 });
|
||||||
@@ -385,6 +417,26 @@ function renderContent(block, panel) {
|
|||||||
panel.append(label, rich.element, help);
|
panel.append(label, rich.element, help);
|
||||||
}
|
}
|
||||||
if (['heading', 'button'].includes(block.type)) field(block.type === 'button' ? 'Beschriftung' : 'Text', 'text', block.props.text, { multiline: block.type !== 'button' });
|
if (['heading', 'button'].includes(block.type)) field(block.type === 'button' ? 'Beschriftung' : 'Text', 'text', block.props.text, { multiline: block.type !== 'button' });
|
||||||
|
if (block.type === 'button') {
|
||||||
|
// Ohne eigene Wahl trägt ein Button den Pfeil, den er vor v25 immer hatte.
|
||||||
|
const icon = block.props.icon ?? 'arrow-up-right';
|
||||||
|
iconField('Symbol', icon, { none: 'Kein Symbol', version: 25 });
|
||||||
|
if (icon !== 'none') {
|
||||||
|
segmented('Stellung', 'iconPosition', block.props.iconPosition === 'start' ? 'start' : 'end', [['start', 'Vor dem Text'], ['end', 'Nach dem Text']], 25);
|
||||||
|
segmented('Beim Überfahren', 'iconMotion', block.props.iconMotion ?? 'none', [['none', 'Ruhig'], ['slide', 'Rückt'], ['reveal', 'Erscheint']], 26);
|
||||||
|
field('Nur das Symbol zeigen', 'iconOnly', block.props.iconOnly === true, { checkbox: true, help: 'Ein runder oder eckiger Knopf nur mit Symbol. Die Beschriftung bleibt als Name für Vorlesesoftware erhalten – bitte trotzdem sinnvoll ausfüllen.' });
|
||||||
|
// Farbe, Fläche, Größe und Drehung sind Gestaltung: sie gelten je Gerät und beim Überfahren,
|
||||||
|
// wie alle anderen Werte unter Design. Ein Knopf führt direkt dorthin.
|
||||||
|
const design = document.createElement('button'); design.type = 'button'; design.className = 'text-button icon-design-link';
|
||||||
|
design.textContent = 'Farbe, Größe und Fläche des Symbols → Design';
|
||||||
|
design.addEventListener('click', () => {
|
||||||
|
document.querySelector('[data-panel="design"]')?.click();
|
||||||
|
const group = document.querySelector('.design-group[data-group="icon"]');
|
||||||
|
if (group) { group.open = true; group.scrollIntoView({ block: 'start' }); group.querySelector('summary')?.focus({ preventScroll: true }); }
|
||||||
|
});
|
||||||
|
panel.append(design);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (block.type === 'button' && designSchema) buttonControls(block, panel);
|
if (block.type === 'button' && designSchema) buttonControls(block, panel);
|
||||||
if (INLINE_TEXT[block.type]) {
|
if (INLINE_TEXT[block.type]) {
|
||||||
const inline = document.createElement('button');
|
const inline = document.createElement('button');
|
||||||
@@ -399,7 +451,7 @@ function renderContent(block, panel) {
|
|||||||
}
|
}
|
||||||
if (block.type === 'heading') field('Überschriftenebene', 'level', block.props.level, { number: true, choices: { 1: 'H1 · Hauptüberschrift', 2: 'H2 · Abschnitt', 3: 'H3 · Unterabschnitt', 4: 'H4', 5: 'H5', 6: 'H6' }, help: 'Jede Seite benötigt genau eine H1.' });
|
if (block.type === 'heading') field('Überschriftenebene', 'level', block.props.level, { number: true, choices: { 1: 'H1 · Hauptüberschrift', 2: 'H2 · Abschnitt', 3: 'H3 · Unterabschnitt', 4: 'H4', 5: 'H5', 6: 'H6' }, help: 'Jede Seite benötigt genau eine H1.' });
|
||||||
if (block.type === 'iconcard') {
|
if (block.type === 'iconcard') {
|
||||||
field('Symbol', 'icon', block.props.icon, { choices: Object.fromEntries(ICONS.map(name => [name, name])) });
|
iconField('Symbol', block.props.icon);
|
||||||
field('Titel', 'title', block.props.title, { maxLength: 120 });
|
field('Titel', 'title', block.props.title, { maxLength: 120 });
|
||||||
field('Text', 'text', block.props.text, { multiline: true, maxLength: 600 });
|
field('Text', 'text', block.props.text, { multiline: true, maxLength: 600 });
|
||||||
field('Linkziel', 'href', block.props.href || '', { help: 'Optional. Lokaler Pfad, Sprungmarke oder HTTPS-Adresse, Telefon (tel:+49…) oder E-Mail (mailto:…) – oder ein PDF.' });
|
field('Linkziel', 'href', block.props.href || '', { help: 'Optional. Lokaler Pfad, Sprungmarke oder HTTPS-Adresse, Telefon (tel:+49…) oder E-Mail (mailto:…) – oder ein PDF.' });
|
||||||
@@ -434,7 +486,7 @@ function renderContent(block, panel) {
|
|||||||
}
|
}
|
||||||
if (block.type === 'partialref') field('Sektion', 'partial', block.props.partial, { ...pick('sections', block.props.partial), help: 'Die Sektion wird verknüpft, nicht kopiert: eine Änderung dort wirkt auf allen Seiten, die sie einbinden. Angelegt und bearbeitet wird sie unter „Bereiche“.' });
|
if (block.type === 'partialref') field('Sektion', 'partial', block.props.partial, { ...pick('sections', block.props.partial), help: 'Die Sektion wird verknüpft, nicht kopiert: eine Änderung dort wirkt auf allen Seiten, die sie einbinden. Angelegt und bearbeitet wird sie unter „Bereiche“.' });
|
||||||
if (block.type === 'icon') {
|
if (block.type === 'icon') {
|
||||||
field('Symbol', 'icon', block.props.icon, { choices: Object.fromEntries(ICONS.map(name => [name, name])) });
|
iconField('Symbol', block.props.icon);
|
||||||
// number, so the value is stored as a number: the server wants an int, not "48".
|
// number, so the value is stored as a number: the server wants an int, not "48".
|
||||||
field('Größe', 'size', String(block.props.size), { number: true, choices: Object.fromEntries(ICON_SIZES.map(size => [String(size), `${size} px`])), help: 'Feste Stufen, weil die Größe eine Klasse ist – ein freier Wert hätte keine und das Symbol wäre unsichtbar.' });
|
field('Größe', 'size', String(block.props.size), { number: true, choices: Object.fromEntries(ICON_SIZES.map(size => [String(size), `${size} px`])), help: 'Feste Stufen, weil die Größe eine Klasse ist – ein freier Wert hätte keine und das Symbol wäre unsichtbar.' });
|
||||||
field('Nur Dekoration', 'decorative', block.props.decorative, { checkbox: true, help: 'Dekorative Symbole werden Screenreadern verschwiegen. Trägt das Symbol eine Aussage, nimm das Häkchen weg und beschrifte es.' });
|
field('Nur Dekoration', 'decorative', block.props.decorative, { checkbox: true, help: 'Dekorative Symbole werden Screenreadern verschwiegen. Trägt das Symbol eine Aussage, nimm das Häkchen weg und beschrifte es.' });
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
// Der Symbolsatz: Zeichnung, deutscher Name und Kategorie. Die Website zeichnet dieselben Pfade aus
|
||||||
|
// templates/blocks/icon.html.twig; ein Test hält Twig, diese Datei und DocumentValidator::ICONS gleich.
|
||||||
|
// Alle Symbole sind Linien auf einem 24er-Raster (stroke, keine Füllung) und erben die Textfarbe.
|
||||||
|
export const ICON_PATHS = {
|
||||||
|
// Pfeile und Navigation
|
||||||
|
'arrow-up-right': 'M6.5 17.5 17.5 6.5M8.5 6.5h9v9',
|
||||||
|
'arrow-right': 'M4 12h15.5m-6.5-6.5 6.5 6.5-6.5 6.5',
|
||||||
|
'arrow-left': 'M20 12H4.5M11 5.5 4.5 12 11 18.5',
|
||||||
|
'arrow-up': 'M12 20V4.5m-6.5 6.5L12 4.5l6.5 6.5',
|
||||||
|
'arrow-down': 'M12 4v15.5m-6.5-6.5 6.5 6.5 6.5-6.5',
|
||||||
|
'chevron-right': 'm9 5.5 6.5 6.5L9 18.5',
|
||||||
|
'chevron-left': 'M15 5.5 8.5 12l6.5 6.5',
|
||||||
|
'chevron-up': 'm5.5 15 6.5-6.5 6.5 6.5',
|
||||||
|
'chevron-down': 'm5.5 9 6.5 6.5L18.5 9',
|
||||||
|
'arrow-circle': 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm-4 9h8m-3-3 3 3-3 3',
|
||||||
|
external: 'M14 4h6v6m0-6-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5',
|
||||||
|
refresh: 'M20 12a8 8 0 1 1-2.3-5.6M20 4v5h-5',
|
||||||
|
menu: 'M4 7h16M4 12h16M4 17h16',
|
||||||
|
close: 'M6 6l12 12M18 6 6 18',
|
||||||
|
plus: 'M12 5v14M5 12h14',
|
||||||
|
minus: 'M5 12h14',
|
||||||
|
// Kontakt und Ort
|
||||||
|
phone: 'M6 3h4l2 5-2.5 1.5a12 12 0 0 0 5 5L16 12l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 4 5a2 2 0 0 1 2-2Z',
|
||||||
|
app: 'M7 2h10a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Zm4 17h2',
|
||||||
|
mail: 'M3 6h18v12H3Zm0 1 9 7 9-7',
|
||||||
|
chat: 'M4 5h16v11H9.5L5 19.5V16H4Z',
|
||||||
|
send: 'M21 3 10 14M21 3l-6.5 18-4.5-7-7-4.5Z',
|
||||||
|
calendar: 'M4 6h16v14H4Zm0 5h16M8 3.5v4m8-4v4',
|
||||||
|
clock: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 4v5.5l3.5 2',
|
||||||
|
pin: 'M12 21s7-6.3 7-11a7 7 0 1 0-14 0c0 4.7 7 11 7 11Zm0-8.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z',
|
||||||
|
map: 'M9 4 3 6.5V20l6-2.5 6 2.5 6-2.5V4l-6 2.5ZM9 4v13.5m6-11V20',
|
||||||
|
navigation: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm3.5 5.5-2 5-5 2 2-5Z',
|
||||||
|
web: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 0c2.5 2.4 3.8 5.4 3.8 9s-1.3 6.6-3.8 9m0-18C9.5 5.4 8.2 8.4 8.2 12s1.3 6.6 3.8 9M3.2 9h17.6M3.2 15h17.6',
|
||||||
|
wifi: 'M3 9.5a13 13 0 0 1 18 0M6 13a8 8 0 0 1 12 0m-9 3.5a3.5 3.5 0 0 1 6 0M12 19.5h.01',
|
||||||
|
support: 'M12 3a7 7 0 0 0-7 7v5m14-5a7 7 0 0 0-7-7m7 7v5m-14 0a2 2 0 0 0 2 2h1v-5H5Zm14 0a2 2 0 0 1-2 2h-1v-5h3Z',
|
||||||
|
// Handel und Geld
|
||||||
|
shop: 'M4 7h16l-1.4 11.2a2 2 0 0 1-2 1.8H7.4a2 2 0 0 1-2-1.8Zm4 0a4 4 0 0 1 8 0',
|
||||||
|
cart: 'M3 4h2.5l2.2 10.5h10.6L20.5 7H6.3M9.5 18a1.2 1.2 0 1 0 0 2.4 1.2 1.2 0 0 0 0-2.4Zm7.5 0a1.2 1.2 0 1 0 0 2.4 1.2 1.2 0 0 0 0-2.4Z',
|
||||||
|
tag: 'M3.5 12.5v-8a1 1 0 0 1 1-1h8l8 8a1.4 1.4 0 0 1 0 2l-7 7a1.4 1.4 0 0 1-2 0ZM8.5 8h.01',
|
||||||
|
euro: 'M17.5 6.5A6.5 6.5 0 1 0 17.5 17.5M4 10h9m-9 4h9',
|
||||||
|
'credit-card': 'M3 6h18v12H3Zm0 4h18M6.5 15h3',
|
||||||
|
gift: 'M4 9h16v4H4Zm1.5 4v7h13v-7M12 9v11m0-11S10.5 4 8 4.5 7.5 9 12 9Zm0 0s1.5-5 4-4.5S16.5 9 12 9Z',
|
||||||
|
percent: 'M19 5 5 19M7 5a2 2 0 1 0 0 4 2 2 0 0 0 0-4Zm10 10a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z',
|
||||||
|
truck: 'M2.5 6h11v10.5h-11Zm11 3.5h4l3 3.5v3.5h-7ZM6.5 17a1.8 1.8 0 1 0 0 3.6 1.8 1.8 0 0 0 0-3.6Zm10.5 0a1.8 1.8 0 1 0 0 3.6 1.8 1.8 0 0 0 0-3.6Z',
|
||||||
|
package: 'M3.5 7.5 12 3l8.5 4.5v9L12 21l-8.5-4.5Zm0 0L12 12l8.5-4.5M12 12v9M7.8 5.2l8.5 4.6',
|
||||||
|
briefcase: 'M3.5 7.5h17v12h-17Zm5-3h7v3h-7Zm-5 8h17',
|
||||||
|
// Dateien und Medien
|
||||||
|
document: 'M6 3h8l4 4v14H6Zm8 0v4h4M9 12h6m-6 4h6',
|
||||||
|
download: 'M12 3.5v12m-5-5 5 5 5-5M4.5 20h15',
|
||||||
|
upload: 'M12 15.5V4m-5 5 5-5 5 5M4.5 20h15',
|
||||||
|
print: 'M7 9V3.5h10V9M7 17H4.5V9h15v8H17M7 14h10v6.5H7Z',
|
||||||
|
link: 'M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1.2 1.2M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1.2-1.2',
|
||||||
|
share: 'M18 3.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM6 9.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5Zm12 6a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM8.2 10.8l7.6-4.1m-7.6 6.5 7.6 4.1',
|
||||||
|
image: 'M4 4h16v16H4Zm0 12 4.5-4.5 3.5 3.5 2.5-2.5L20 18M15 8.5h.01',
|
||||||
|
camera: 'M3 7.5h4L8.5 5h7L17 7.5h4V19H3Zm9 2.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z',
|
||||||
|
video: 'M3 6.5h12v11H3Zm12 4 6-3.5v10l-6-3.5',
|
||||||
|
play: 'M8 5.5v13l10-6.5Z',
|
||||||
|
pause: 'M8 5v14m8-14v14',
|
||||||
|
music: 'M10 18V5.5L20 3v12.5M10 9.5 20 7M10 18a3 2.5 0 1 0-6 0 3 2.5 0 0 0 6 0ZM20 15.5a3 2.5 0 1 0-6 0 3 2.5 0 0 0 6 0Z',
|
||||||
|
mic: 'M12 3a3 3 0 0 0-3 3v5.5a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3ZM5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21',
|
||||||
|
book: 'M5 4.5A1.5 1.5 0 0 1 6.5 3H19v15H6.5A1.5 1.5 0 0 0 5 19.5Zm0 15A1.5 1.5 0 0 0 6.5 21H19v-3',
|
||||||
|
// Personen und Sicherheit
|
||||||
|
user: 'M12 11.5a4 4 0 1 0 0-8 4 4 0 0 0 0 8ZM4.5 20.5c0-3.9 3.4-6 7.5-6s7.5 2.1 7.5 6',
|
||||||
|
people: 'M9.5 11.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7ZM2.5 20v-1c0-3.3 3-5 7-5s7 1.7 7 5v1M16.5 5.3a3.2 3.2 0 0 1 0 6.4M18 14.4c2.1.7 3.5 2.1 3.5 4.1V20',
|
||||||
|
login: 'M14 4h5a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1h-5M3.5 12h11m-4-4 4 4-4 4',
|
||||||
|
lock: 'M5 10.5h14V21H5Zm3 0V7a4 4 0 0 1 8 0v3.5M12 14.5v3',
|
||||||
|
key: 'M7.5 11.5a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm2.8 1.2L19 4m-3 3 2.5 2.5M13 10l2 2',
|
||||||
|
shield: 'M12 3 5 6v6c0 4.4 2.9 7.9 7 9 4.1-1.1 7-4.6 7-9V6Zm-2.5 9 2 2 4-4.5',
|
||||||
|
eye: 'M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Zm9.5-3a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z',
|
||||||
|
'thumbs-up': 'M7 10.5V20H4v-9.5Zm0 0 3.5-7a2 2 0 0 1 2.5 2v3.5h5.2a2 2 0 0 1 2 2.4l-1.4 7a2 2 0 0 1-2 1.6H7',
|
||||||
|
award: 'M12 3a5.5 5.5 0 1 0 0 11 5.5 5.5 0 0 0 0-11Zm-3.5 10L7 21l5-2.5 5 2.5-1.5-8',
|
||||||
|
graduation: 'M2.5 9 12 4.5 21.5 9 12 13.5Zm4 2.3V16c0 1.5 2.5 3 5.5 3s5.5-1.5 5.5-3v-4.7M21.5 9v5',
|
||||||
|
heart: 'M12 20s-7.5-4.6-7.5-10A4.3 4.3 0 0 1 12 7.4 4.3 4.3 0 0 1 19.5 10c0 5.4-7.5 10-7.5 10Z',
|
||||||
|
star: 'm12 3 2.7 5.9 6.3.7-4.7 4.3 1.3 6.1L12 17l-5.6 3 1.3-6.1L3 9.6l6.3-.7Z',
|
||||||
|
// Haus, Garten und Handwerk
|
||||||
|
home: 'm3 11.5 9-7.5 9 7.5M5.5 10v10h13V10M10 20v-6.5h4V20',
|
||||||
|
building: 'M5 21V4h10v17m0-12h4v12M3 21h18M8.5 7.5h3m-3 3.5h3m-3 3.5h3',
|
||||||
|
wrench: 'M14.5 3.5a5 5 0 0 0-4.6 6.9L3.5 16.8a1.9 1.9 0 0 0 2.7 2.7l6.4-6.4a5 5 0 0 0 6.9-4.6l-3 3-3-1-1-3Z',
|
||||||
|
hammer: 'M14 8.5 4.5 18a1.5 1.5 0 0 0 2.1 2.1L16 10.5M11 6l3-3 3.5 1L21 7.5 18.5 10l-2-2-2.5 2.5Z',
|
||||||
|
brush: 'M4 4h13v5H4Zm13 2.5h2.5V12h-8v3M10 15h3v6h-3Z',
|
||||||
|
ruler: 'M3.5 16 16 3.5 20.5 8 8 20.5ZM7 13l2 2m1-5 2 2m1-5 2 2',
|
||||||
|
leaf: 'M5 19C5 10 10 5 20 4c-1 10-6 15-15 15Zm0 0 8-8',
|
||||||
|
tree: 'M12 21v-5m0-13-6 8h3l-4 5h14l-4-5h3Z',
|
||||||
|
sun: 'M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm0-5v2m0 14v2M3 12h2m14 0h2M5.6 5.6 7 7m10 10 1.4 1.4M5.6 18.4 7 17M17 7l1.4-1.4',
|
||||||
|
droplet: 'M12 3s6.5 7 6.5 11.5a6.5 6.5 0 0 1-13 0C5.5 10 12 3 12 3Z',
|
||||||
|
flame: 'M12 21a6 6 0 0 0 6-6c0-4.5-4-6.5-4.5-11-2.5 2-5 5-5 8.5-1-.5-1.5-1.5-1.8-2.5C5.6 11.5 6 13 6 15a6 6 0 0 0 6 6Z',
|
||||||
|
bolt: 'M13 2.5 4.5 13.5H12l-1 8 8.5-11H12Z',
|
||||||
|
solar: 'M5 4h15l-2 9.5H3Zm-1 4.75h15M10 4 8.5 13.5M15.5 4 14 13.5M10.5 13.5V18M7 20.5h7',
|
||||||
|
thermometer: 'M10 14.5V5a2 2 0 0 1 4 0v9.5a4 4 0 1 1-4 0Z',
|
||||||
|
snowflake: 'M12 2.5v19M3.8 7.3l16.4 9.4M3.8 16.7l16.4-9.4M9.5 4 12 6l2.5-2m-5 16 2.5-2 2.5 2',
|
||||||
|
car: 'M5 16.5V12l2-5h10l2 5v4.5M3 12h18v4.5H3Zm3.5 4.5V19m11-2.5V19M7 14h.01M17 14h.01',
|
||||||
|
// Allgemein
|
||||||
|
check: 'm4 12.5 5.5 5.5L20 6',
|
||||||
|
'check-circle': 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm-4 9.2 2.8 2.8L16.5 9',
|
||||||
|
info: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 8v5.5m0-9v.5',
|
||||||
|
help: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18ZM9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.6.3-1 .9-1 1.6v.6m0 3v.01',
|
||||||
|
alert: 'M12 3.5 2.5 20h19Zm0 6V14m0 3v.01',
|
||||||
|
search: 'M10.5 4a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13Zm4.8 11.3L20 20',
|
||||||
|
settings: 'M4 7h9m4 0h3M4 17h3m4 0h9M15 5v4M9 15v4',
|
||||||
|
filter: 'M3.5 5h17l-6.5 8v6l-4 2v-8Z',
|
||||||
|
grid: 'M4 4h7v7H4Zm9 0h7v7h-7ZM4 13h7v7H4Zm9 0h7v7h-7Z',
|
||||||
|
list: 'M9 6h11M9 12h11M9 18h11M4.5 6h.01M4.5 12h.01M4.5 18h.01',
|
||||||
|
sparkle: 'M12 3c.6 4.4 2.6 6.4 7 7-4.4.6-6.4 2.6-7 7-.6-4.4-2.6-6.4-7-7 4.4-.6 6.4-2.6 7-7Zm6.5 12.5c.2 1.6 1 2.3 2.5 2.5-1.5.2-2.3 1-2.5 2.5-.2-1.5-1-2.3-2.5-2.5 1.5-.2 2.3-1 2.5-2.5Z',
|
||||||
|
coffee: 'M4 9h12v6a5 5 0 0 1-5 5H9a5 5 0 0 1-5-5Zm12 1.5h1.5a2.5 2.5 0 0 1 0 5H16M8 3.5V6m4-2.5V6',
|
||||||
|
utensils: 'M6 3v7a2 2 0 0 0 4 0V3M8 10v11m8.5 0V3c-2 1-3 3.5-3 6.5v4h3',
|
||||||
|
laptop: 'M5 5h14v10H5Zm-2.5 10h19l-1 3.5h-17Z',
|
||||||
|
hardware: 'M4 5h16v11H4Zm4 15h8m-4-4v4',
|
||||||
|
network: 'M5 3h14v5H5Zm0 13h14v5H5Zm7-8v8M8 8v3h8V8',
|
||||||
|
design: 'M4 20h16M6 16 18 4l3 3L9 19l-4 1Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ICON_LABELS = {
|
||||||
|
'arrow-up-right': 'Pfeil schräg', 'arrow-right': 'Pfeil nach rechts', 'arrow-left': 'Pfeil nach links',
|
||||||
|
'arrow-up': 'Pfeil nach oben', 'arrow-down': 'Pfeil nach unten', 'chevron-right': 'Winkel rechts',
|
||||||
|
'chevron-left': 'Winkel links', 'chevron-up': 'Winkel oben', 'chevron-down': 'Winkel unten',
|
||||||
|
'arrow-circle': 'Pfeil im Kreis', external: 'Externer Link', refresh: 'Aktualisieren', menu: 'Menü',
|
||||||
|
close: 'Schließen', plus: 'Plus', minus: 'Minus',
|
||||||
|
phone: 'Telefon', app: 'Smartphone', mail: 'E-Mail', chat: 'Nachricht', send: 'Senden', calendar: 'Kalender',
|
||||||
|
clock: 'Uhr', pin: 'Ort', map: 'Karte', navigation: 'Navigation', web: 'Globus', wifi: 'WLAN', support: 'Kundendienst',
|
||||||
|
shop: 'Einkaufstasche', cart: 'Warenkorb', tag: 'Preisschild', euro: 'Euro', 'credit-card': 'Karte bezahlen',
|
||||||
|
gift: 'Geschenk', percent: 'Prozent', truck: 'Lieferung', package: 'Paket', briefcase: 'Aktentasche',
|
||||||
|
document: 'Dokument', download: 'Download', upload: 'Hochladen', print: 'Drucken', link: 'Link', share: 'Teilen',
|
||||||
|
image: 'Bild', camera: 'Kamera', video: 'Video', play: 'Abspielen', pause: 'Pause', music: 'Musik',
|
||||||
|
mic: 'Mikrofon', book: 'Buch',
|
||||||
|
user: 'Person', people: 'Personen', login: 'Anmelden', lock: 'Schloss', key: 'Schlüssel', shield: 'Schutz',
|
||||||
|
eye: 'Ansehen', 'thumbs-up': 'Daumen hoch', award: 'Auszeichnung', graduation: 'Ausbildung', heart: 'Herz', star: 'Stern',
|
||||||
|
home: 'Haus', building: 'Gebäude', wrench: 'Werkzeug', hammer: 'Hammer', brush: 'Malerrolle', ruler: 'Lineal',
|
||||||
|
leaf: 'Blatt', tree: 'Baum', sun: 'Sonne', droplet: 'Wasser', flame: 'Flamme', bolt: 'Strom', solar: 'Solar',
|
||||||
|
thermometer: 'Temperatur', snowflake: 'Kälte', car: 'Auto',
|
||||||
|
check: 'Häkchen', 'check-circle': 'Häkchen im Kreis', info: 'Info', help: 'Frage', alert: 'Warnung',
|
||||||
|
search: 'Suche', settings: 'Einstellungen', filter: 'Filter', grid: 'Raster', list: 'Liste', sparkle: 'Funkeln',
|
||||||
|
coffee: 'Kaffee', utensils: 'Besteck', laptop: 'Laptop', hardware: 'Bildschirm', network: 'Netzwerk', design: 'Stift',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Die Kategorien des Auswahlfensters, in der Reihenfolge der Reiter. Jedes Symbol steht genau einmal. */
|
||||||
|
export const ICON_GROUPS = [
|
||||||
|
['Pfeile', ['arrow-up-right', 'arrow-right', 'arrow-left', 'arrow-up', 'arrow-down', 'chevron-right', 'chevron-left', 'chevron-up', 'chevron-down', 'arrow-circle', 'external', 'refresh', 'menu', 'close', 'plus', 'minus']],
|
||||||
|
['Kontakt & Ort', ['phone', 'app', 'mail', 'chat', 'send', 'calendar', 'clock', 'pin', 'map', 'navigation', 'web', 'wifi', 'support']],
|
||||||
|
['Handel & Geld', ['shop', 'cart', 'tag', 'euro', 'credit-card', 'gift', 'percent', 'truck', 'package', 'briefcase']],
|
||||||
|
['Dateien & Medien', ['document', 'download', 'upload', 'print', 'link', 'share', 'image', 'camera', 'video', 'play', 'pause', 'music', 'mic', 'book']],
|
||||||
|
['Personen & Sicherheit', ['user', 'people', 'login', 'lock', 'key', 'shield', 'eye', 'thumbs-up', 'award', 'graduation', 'heart', 'star']],
|
||||||
|
['Haus & Handwerk', ['home', 'building', 'wrench', 'hammer', 'brush', 'ruler', 'leaf', 'tree', 'sun', 'droplet', 'flame', 'bolt', 'solar', 'thermometer', 'snowflake', 'car']],
|
||||||
|
['Allgemein', ['check', 'check-circle', 'info', 'help', 'alert', 'search', 'settings', 'filter', 'grid', 'list', 'sparkle', 'coffee', 'utensils', 'laptop', 'hardware', 'network', 'design']],
|
||||||
|
];
|
||||||
|
|
||||||
|
const svgNS = 'http://www.w3.org/2000/svg';
|
||||||
|
|
||||||
|
export function iconSvg(name) {
|
||||||
|
const svg = document.createElementNS(svgNS, 'svg');
|
||||||
|
svg.setAttribute('viewBox', '0 0 24 24');
|
||||||
|
svg.setAttribute('aria-hidden', 'true');
|
||||||
|
svg.setAttribute('focusable', 'false');
|
||||||
|
// 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');
|
||||||
|
path.setAttribute('d', ICON_PATHS[name] ?? '');
|
||||||
|
svg.append(path);
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = (tag, className, text) => {
|
||||||
|
const node = document.createElement(tag);
|
||||||
|
if (className) node.className = className;
|
||||||
|
if (text !== undefined) node.textContent = text;
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Das Feld im Panel: das gewählte Symbol als Vorschau mit Namen und ein Knopf, der das
|
||||||
|
* Auswahlfenster öffnet. Die Kacheln stehen nicht im Panel - bei hundert Symbolen wäre es eine
|
||||||
|
* lange Wand, die alles andere nach unten drückt.
|
||||||
|
*
|
||||||
|
* @param {{ label: string, value: string, none?: string, onChoose: (name: string) => void }} options
|
||||||
|
*/
|
||||||
|
export function renderIconField({ label, value, none, onChoose }) {
|
||||||
|
const wrap = element('div', 'icon-field');
|
||||||
|
wrap.append(element('p', 'icon-picker-label', label));
|
||||||
|
const row = element('div', 'icon-field-row');
|
||||||
|
const preview = element('span', 'icon-field-preview');
|
||||||
|
preview.setAttribute('aria-hidden', 'true');
|
||||||
|
if (value && value !== 'none') preview.append(iconSvg(value)); else preview.classList.add('is-empty');
|
||||||
|
const name = element('span', 'icon-field-name', value && value !== 'none' ? ICON_LABELS[value] ?? value : none ?? 'Kein Symbol');
|
||||||
|
const open = element('button', 'secondary icon-field-change', 'Ändern …');
|
||||||
|
open.type = 'button';
|
||||||
|
open.setAttribute('aria-haspopup', 'dialog');
|
||||||
|
open.setAttribute('aria-label', `${label} ändern, aktuell: ${name.textContent}`);
|
||||||
|
open.addEventListener('click', () => openIconDialog({ value, none, onChoose, returnFocus: open }));
|
||||||
|
row.append(preview, name, open);
|
||||||
|
wrap.append(row);
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Das Auswahlfenster: Kategorien als Reiter links, die Symbole einer Kategorie als Kacheln mit
|
||||||
|
* Namen. Ein Klick wählt und schließt, Escape schließt ohne Änderung. Geöffnet wird bei der
|
||||||
|
* Kategorie des gewählten Symbols, der Fokus steht auf ihm.
|
||||||
|
*/
|
||||||
|
export function openIconDialog({ value, none, onChoose, returnFocus }) {
|
||||||
|
const dialog = element('dialog', 'icon-dialog');
|
||||||
|
dialog.setAttribute('aria-labelledby', 'icon-dialog-title');
|
||||||
|
const head = element('div', 'icon-dialog-head');
|
||||||
|
head.append(element('h2', null, 'Symbol wählen'));
|
||||||
|
head.firstChild.id = 'icon-dialog-title';
|
||||||
|
const close = element('button', 'icon-dialog-close');
|
||||||
|
close.type = 'button'; close.setAttribute('aria-label', 'Schließen'); close.append(iconSvg('close'));
|
||||||
|
close.addEventListener('click', () => dialog.close());
|
||||||
|
head.append(close);
|
||||||
|
const body = element('div', 'icon-dialog-body');
|
||||||
|
const tablist = element('div', 'icon-dialog-tabs');
|
||||||
|
tablist.setAttribute('role', 'tablist'); tablist.setAttribute('aria-orientation', 'vertical'); tablist.setAttribute('aria-label', 'Kategorien');
|
||||||
|
const panel = element('div', 'icon-dialog-panel');
|
||||||
|
panel.setAttribute('role', 'tabpanel'); panel.id = 'icon-dialog-panel';
|
||||||
|
const tabs = [];
|
||||||
|
let tiles = [];
|
||||||
|
const choose = name => { dialog.close(); onChoose(name); };
|
||||||
|
const show = (index, focusTile = false) => {
|
||||||
|
tabs.forEach((tab, i) => { tab.setAttribute('aria-selected', String(i === index)); tab.tabIndex = i === index ? 0 : -1; });
|
||||||
|
panel.setAttribute('aria-labelledby', tabs[index].id);
|
||||||
|
panel.replaceChildren();
|
||||||
|
const grid = element('div', 'icon-dialog-grid');
|
||||||
|
tiles = ICON_GROUPS[index][1].map(name => {
|
||||||
|
const tile = element('button', 'icon-choice');
|
||||||
|
tile.type = 'button'; tile.dataset.icon = name;
|
||||||
|
tile.setAttribute('aria-pressed', String(name === value));
|
||||||
|
tile.tabIndex = -1;
|
||||||
|
tile.append(iconSvg(name), element('span', 'icon-choice-name', ICON_LABELS[name]));
|
||||||
|
tile.addEventListener('click', () => choose(name));
|
||||||
|
grid.append(tile);
|
||||||
|
return tile;
|
||||||
|
});
|
||||||
|
(tiles.find(tile => tile.dataset.icon === value) ?? tiles[0]).tabIndex = 0;
|
||||||
|
panel.append(grid);
|
||||||
|
if (focusTile) (tiles.find(tile => tile.tabIndex === 0)).focus();
|
||||||
|
};
|
||||||
|
ICON_GROUPS.forEach(([title], index) => {
|
||||||
|
const tab = element('button', 'icon-dialog-tab', title);
|
||||||
|
tab.type = 'button'; tab.id = `icon-tab-${index}`; tab.setAttribute('role', 'tab'); tab.setAttribute('aria-controls', panel.id);
|
||||||
|
tab.addEventListener('click', () => show(index));
|
||||||
|
tabs.push(tab); tablist.append(tab);
|
||||||
|
});
|
||||||
|
tablist.addEventListener('keydown', event => {
|
||||||
|
const index = tabs.indexOf(document.activeElement);
|
||||||
|
const step = { ArrowDown: 1, ArrowRight: 1, ArrowUp: -1, ArrowLeft: -1 }[event.key];
|
||||||
|
if (index < 0 || !step) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const next = (index + step + tabs.length) % tabs.length;
|
||||||
|
show(next); tabs[next].focus();
|
||||||
|
});
|
||||||
|
panel.addEventListener('keydown', event => {
|
||||||
|
const index = tiles.indexOf(document.activeElement);
|
||||||
|
if (index < 0) return;
|
||||||
|
const columns = Math.max(1, Math.round(panel.querySelector('.icon-dialog-grid').clientWidth / tiles[0].offsetWidth));
|
||||||
|
const step = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: columns, ArrowUp: -columns }[event.key];
|
||||||
|
const target = event.key === 'Home' ? 0 : event.key === 'End' ? tiles.length - 1 : step ? Math.min(tiles.length - 1, Math.max(0, index + step)) : null;
|
||||||
|
if (target === null) return;
|
||||||
|
event.preventDefault();
|
||||||
|
tiles[index].tabIndex = -1; tiles[target].tabIndex = 0; tiles[target].focus();
|
||||||
|
});
|
||||||
|
body.append(tablist, panel);
|
||||||
|
const foot = element('div', 'icon-dialog-foot');
|
||||||
|
if (none) {
|
||||||
|
const without = element('button', 'secondary', none);
|
||||||
|
without.type = 'button'; without.dataset.icon = 'none';
|
||||||
|
without.setAttribute('aria-pressed', String(value === 'none'));
|
||||||
|
without.addEventListener('click', () => choose('none'));
|
||||||
|
foot.append(without);
|
||||||
|
}
|
||||||
|
const cancel = element('button', 'text-button', 'Abbrechen');
|
||||||
|
cancel.type = 'button'; cancel.addEventListener('click', () => dialog.close());
|
||||||
|
foot.append(cancel);
|
||||||
|
dialog.append(head, body, foot);
|
||||||
|
dialog.addEventListener('close', () => { dialog.remove(); if (returnFocus?.isConnected) returnFocus.focus(); });
|
||||||
|
// Ein Klick auf den abgedunkelten Rand schließt, wie bei den anderen Fenstern des Editors.
|
||||||
|
dialog.addEventListener('click', event => { if (event.target === dialog) dialog.close(); });
|
||||||
|
document.body.append(dialog);
|
||||||
|
const start = Math.max(0, ICON_GROUPS.findIndex(([, names]) => names.includes(value)));
|
||||||
|
dialog.showModal();
|
||||||
|
show(start, true);
|
||||||
|
return dialog;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-3
@@ -47,8 +47,8 @@ p{hyphens:auto}
|
|||||||
/* Der Pfeil am Button und in der Beitragsnavigation ist dasselbe Symbol wie ueberall sonst -
|
/* Der Pfeil am Button und in der Beitragsnavigation ist dasselbe Symbol wie ueberall sonst -
|
||||||
frueher stand dort ein Schriftzeichen, das je nach Schrift anders aussah. Er waechst mit der
|
frueher stand dort ein Schriftzeichen, das je nach Schrift anders aussah. Er waechst mit der
|
||||||
Schrift des Knopfs, statt eine eigene Groesse zu haben. */
|
Schrift des Knopfs, statt eine eigene Groesse zu haben. */
|
||||||
.button-arrow,.nav-arrow{display:inline-flex;align-items:center}
|
.button-icon,.nav-arrow{display:inline-flex;align-items:center}.button-icon{justify-content:center;flex:none;transition:rotate .25s ease,transform .25s ease,background-color .2s,color .2s,max-width .3s ease,opacity .3s ease,margin .3s ease}
|
||||||
.button-arrow>svg,.nav-arrow>svg{width:1.15em;height:1.15em}
|
.nav-arrow>svg{width:1.15em;height:1.15em}.cms-button .button-icon>svg{width:var(--icon-size,1.15em);height:var(--icon-size,1.15em)}.button.cms-button{gap:var(--icon-gap,.65em)}.cms-button.icon-motion-slide:is(:hover,:focus-visible) .button-icon{transform:translateX(.3em)}.cms-button.icon-start.icon-motion-slide:is(:hover,:focus-visible) .button-icon{transform:translateX(-.3em)}.button.cms-button.icon-motion-reveal{gap:0}.cms-button.icon-motion-reveal .button-icon{max-width:0;opacity:0;overflow:hidden}.cms-button.icon-motion-reveal:is(:hover,:focus-visible) .button-icon{max-width:4em;opacity:1;margin-inline-start:var(--icon-gap,.65em)}.cms-button.icon-start.icon-motion-reveal:is(:hover,:focus-visible) .button-icon{margin-inline:0 var(--icon-gap,.65em)}.button.cms-button.icon-only{gap:0;padding:var(--b-py,var(--kb-py,15px))}.cms-button.icon-only .button-icon{min-inline-size:1lh}.cms-button.icon-only .button-label{width:0;overflow:hidden;white-space:nowrap}@media (prefers-reduced-motion:reduce){html.motion-respect .cms-button .button-icon{transition:none}}
|
||||||
.block-iconcard{display:grid;gap:12px;padding:26px;border:1px solid currentColor;border-radius:12px}
|
.block-iconcard{display:grid;gap:12px;padding:26px;border:1px solid currentColor;border-radius:12px}
|
||||||
.block-iconcard + .block-iconcard,.block-quote + .block-quote,.block-cta + .block-cta,.block-iconcard + .block-quote,.block-quote + .block-iconcard{margin-top:18px}
|
.block-iconcard + .block-iconcard,.block-quote + .block-quote,.block-cta + .block-cta,.block-iconcard + .block-quote,.block-quote + .block-iconcard{margin-top:18px}
|
||||||
/* Gestapelt brauchen Karten den Abstand; nebeneinander in einer Zeile macht er die zweite bis
|
/* Gestapelt brauchen Karten den Abstand; nebeneinander in einer Zeile macht er die zweite bis
|
||||||
@@ -269,7 +269,7 @@ body.consent-open{padding-bottom:320px}
|
|||||||
.block-icon .block-icon{color:inherit}
|
.block-icon .block-icon{color:inherit}
|
||||||
/* Der Pfeil im Knopf ist dasselbe Symbol und nahm deshalb die Akzentfarbe statt der Schriftfarbe
|
/* Der Pfeil im Knopf ist dasselbe Symbol und nahm deshalb die Akzentfarbe statt der Schriftfarbe
|
||||||
des Knopfs: grün auf Rot, auch beim Überfahren (Hüfner, 06.10.2026). */
|
des Knopfs: grün auf Rot, auch beim Überfahren (Hüfner, 06.10.2026). */
|
||||||
.button-arrow .block-icon{color:inherit}
|
.button-icon .block-icon{color:inherit}
|
||||||
.icon-size-12{width:12px;height:12px}.icon-size-16{width:16px;height:16px}.icon-size-20{width:20px;height:20px}
|
.icon-size-12{width:12px;height:12px}.icon-size-16{width:16px;height:16px}.icon-size-20{width:20px;height:20px}
|
||||||
.icon-size-24{width:24px;height:24px}.icon-size-32{width:32px;height:32px}.icon-size-40{width:40px;height:40px}
|
.icon-size-24{width:24px;height:24px}.icon-size-32{width:32px;height:32px}.icon-size-40{width:40px;height:40px}
|
||||||
.icon-size-48{width:48px;height:48px}.icon-size-56{width:56px;height:56px}.icon-size-64{width:64px;height:64px}
|
.icon-size-48{width:48px;height:48px}.icon-size-56{width:56px;height:56px}.icon-size-64{width:64px;height:64px}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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.`);
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
// Prüft das Symbol am Button im echten Editor auf einer isolierten Kopie der Pilotdaten: Auswahl-
|
||||||
|
// fenster mit Kategorien, Tastatur, Stellung, Bewegung, nur Symbol, Farbe und Größe unter Design,
|
||||||
|
// Rückgängig, Speichern und Neuladen. Screenshots unter reports/button-icons/.
|
||||||
|
import { chromium } from 'playwright';
|
||||||
|
import AxeBuilder from '@axe-core/playwright';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { execFileSync, spawn } from 'node:child_process';
|
||||||
|
import { mkdir, writeFile } from 'node:fs/promises';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import net from 'node:net';
|
||||||
|
|
||||||
|
const php = process.env.PHP_BINARY || 'php';
|
||||||
|
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||||
|
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||||
|
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
|
||||||
|
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||||
|
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||||
|
const origin = `http://127.0.0.1:${port}`;
|
||||||
|
const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], { windowsHide: true, stdio: 'ignore', env: { ...env, SITE_ORIGIN: origin } });
|
||||||
|
const report = 'reports/button-icons'; await mkdir(report, { recursive: true });
|
||||||
|
let browser, page;
|
||||||
|
const checks = [], errors = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (let attempt = 0; attempt < 60; attempt++) {
|
||||||
|
try { if ((await fetch(origin)).ok) break; } catch {}
|
||||||
|
if (attempt === 59) throw new Error('Pilot test server did not start');
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
}
|
||||||
|
browser = await chromium.launch();
|
||||||
|
const context = await browser.newContext({ viewport: { width: 1600, height: 1050 } });
|
||||||
|
page = await context.newPage(); page.setDefaultTimeout(15000); page.on('pageerror', error => errors.push(error.message));
|
||||||
|
const selectBlock = async id => {
|
||||||
|
await page.getByRole('tab', { name: 'Struktur', exact: true }).click();
|
||||||
|
await page.locator(`#structure [data-block-id="${id}"]`).click();
|
||||||
|
await page.locator(`#structure [data-edit-block="${id}"]`).click();
|
||||||
|
};
|
||||||
|
await page.goto(origin + '/admin/login');
|
||||||
|
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
|
||||||
|
await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin');
|
||||||
|
const api = `${origin}/admin/api/pages/${fixture.homeId}`;
|
||||||
|
const data = await (await context.request.get(api)).json();
|
||||||
|
data.document.blocks.unshift({ id: 'qa-icons', type: 'section', children: [
|
||||||
|
{ id: 'qa-icon-button', type: 'button', props: { text: 'Jetzt anrufen', href: 'tel:+4972296992826' } },
|
||||||
|
] });
|
||||||
|
assert.equal((await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': data.csrfToken }, data: { document: data.document, expectedVersion: data.version } })).status(), 200);
|
||||||
|
|
||||||
|
const inPreview = fn => page.evaluate(fn);
|
||||||
|
const waitPreview = async expected => page.waitForFunction(expected => {
|
||||||
|
const el = document.querySelector('#preview')?.contentDocument?.getElementById('qa-icon-button');
|
||||||
|
if (!el) return false;
|
||||||
|
const path = el.querySelector('.button-icon svg path')?.getAttribute('d') ?? null;
|
||||||
|
const classes = el.className;
|
||||||
|
return (expected.path === undefined || (expected.path === null ? path === null : path?.startsWith(expected.path)))
|
||||||
|
&& (expected.first === undefined || ((el.firstChild?.nodeType === 1 && el.firstChild.classList.contains('button-icon')) === expected.first))
|
||||||
|
&& (expected.has ?? []).every(name => classes.includes(name))
|
||||||
|
&& (expected.not ?? []).every(name => !classes.includes(name));
|
||||||
|
}, expected);
|
||||||
|
|
||||||
|
await page.goto(`${origin}/admin/pages/${fixture.homeId}`);
|
||||||
|
await selectBlock('qa-icon-button');
|
||||||
|
const field = page.locator('.icon-field');
|
||||||
|
await field.waitFor();
|
||||||
|
assert.equal(await field.locator('.icon-field-name').textContent(), 'Pfeil schräg');
|
||||||
|
assert.equal(await page.locator('.icon-choice').count(), 0, 'Im Panel steht keine Kachelwand.');
|
||||||
|
await waitPreview({ path: 'M6.5 17.5', first: false });
|
||||||
|
await page.locator('.properties, #properties, aside').first().screenshot({ path: `${report}/panel-vorher.png` }).catch(() => {});
|
||||||
|
checks.push('Panel: nur das gewählte Symbol (Pfeil schräg) mit „Ändern …“');
|
||||||
|
|
||||||
|
// Das Fenster: Kategorien links, Symbole mit Namen, geöffnet bei der Kategorie des Pfeils.
|
||||||
|
await page.getByRole('button', { name: /Symbol ändern, aktuell: Pfeil schräg/ }).click();
|
||||||
|
const dialog = page.getByRole('dialog', { name: 'Symbol wählen' });
|
||||||
|
await dialog.waitFor();
|
||||||
|
assert.equal(await dialog.getByRole('tab').count(), 7);
|
||||||
|
assert.equal(await dialog.getByRole('tab', { selected: true }).textContent(), 'Pfeile');
|
||||||
|
assert.equal(await page.evaluate(() => document.activeElement?.dataset.icon), 'arrow-up-right', 'Fokus auf dem gewählten Symbol');
|
||||||
|
const axe = await new AxeBuilder({ page }).include('.icon-dialog').analyze();
|
||||||
|
assert.deepEqual(axe.violations.map(v => v.id), [], 'axe im Auswahlfenster');
|
||||||
|
await dialog.getByRole('tab', { name: 'Haus & Handwerk' }).click();
|
||||||
|
await dialog.screenshot({ path: `${report}/fenster.png` });
|
||||||
|
// Escape schließt ohne Änderung, der Fokus geht zurück zum Knopf.
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await dialog.waitFor({ state: 'detached' });
|
||||||
|
assert.match(await page.evaluate(() => document.activeElement?.getAttribute('aria-label') ?? ''), /Symbol ändern/);
|
||||||
|
await waitPreview({ path: 'M6.5 17.5' });
|
||||||
|
checks.push('Fenster: 7 Kategorien, Fokus auf dem Gewählten, axe ohne Befund, Escape ändert nichts');
|
||||||
|
|
||||||
|
// Tastatur: Kategorie mit Pfeiltaste wechseln, Symbol wählen.
|
||||||
|
await page.getByRole('button', { name: /Symbol ändern/ }).click();
|
||||||
|
await dialog.getByRole('tab', { name: 'Pfeile' }).focus();
|
||||||
|
await page.keyboard.press('ArrowDown');
|
||||||
|
assert.equal(await dialog.getByRole('tab', { selected: true }).textContent(), 'Kontakt & Ort');
|
||||||
|
await dialog.getByRole('button', { name: 'Telefon', exact: true }).click();
|
||||||
|
await dialog.waitFor({ state: 'detached' });
|
||||||
|
await waitPreview({ path: 'M6 3h4', first: false });
|
||||||
|
assert.equal(await field.locator('.icon-field-name').textContent(), 'Telefon');
|
||||||
|
checks.push('Telefon über die Kategorie „Kontakt & Ort“ gewählt');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Vor dem Text', exact: true }).click();
|
||||||
|
await waitPreview({ first: true, has: ['icon-start'] });
|
||||||
|
await page.getByRole('button', { name: 'Rückt', exact: true }).click();
|
||||||
|
await waitPreview({ has: ['icon-motion-slide'] });
|
||||||
|
await page.getByRole('button', { name: 'Erscheint', exact: true }).click();
|
||||||
|
await waitPreview({ has: ['icon-motion-reveal'], not: ['icon-motion-slide'] });
|
||||||
|
await page.locator('#undo').click(); await waitPreview({ has: ['icon-motion-slide'] });
|
||||||
|
await page.locator('#redo').click(); await waitPreview({ has: ['icon-motion-reveal'] });
|
||||||
|
checks.push('Stellung und Bewegung, Rückgängig und Wiederholen');
|
||||||
|
|
||||||
|
await page.getByLabel('Nur das Symbol zeigen').check();
|
||||||
|
await waitPreview({ has: ['icon-only'] });
|
||||||
|
const label = await inPreview(() => document.querySelector('#preview').contentDocument.querySelector('#qa-icon-button .button-label')?.textContent);
|
||||||
|
assert.equal(label, 'Jetzt anrufen', 'Beschriftung bleibt für Vorlesesoftware');
|
||||||
|
await page.locator('.properties, #properties, aside').first().screenshot({ path: `${report}/panel-nachher.png` }).catch(() => {});
|
||||||
|
checks.push('Nur Symbol: Beschriftung unsichtbar im Link');
|
||||||
|
|
||||||
|
// Design: Gruppe „Symbol“ mit Farbe, Fläche, Größe, Drehung.
|
||||||
|
await page.getByRole('button', { name: 'Farbe, Größe und Fläche des Symbols → Design' }).click();
|
||||||
|
const group = page.locator('.design-group[data-group="icon"]');
|
||||||
|
await group.waitFor();
|
||||||
|
assert.equal(await group.evaluate(el => el.open), true);
|
||||||
|
for (const name of ['Symbolfarbe', 'Fläche hinter dem Symbol', 'Symbolgröße', 'Innenabstand der Fläche', 'Ecken der Fläche', 'Drehung', 'Abstand zum Text']) {
|
||||||
|
assert.ok(await group.getByText(name, { exact: true }).count() > 0, `${name} fehlt in der Gruppe Symbol`);
|
||||||
|
}
|
||||||
|
await group.locator('#style-iconSize').fill('24');
|
||||||
|
await group.locator('#style-iconSize').press('Enter');
|
||||||
|
await page.waitForFunction(() => {
|
||||||
|
const doc = document.querySelector('#preview')?.contentDocument, svg = doc?.querySelector('#qa-icon-button .button-icon svg');
|
||||||
|
return svg && Math.round(svg.getBoundingClientRect().width) === 24;
|
||||||
|
});
|
||||||
|
await group.screenshot({ path: `${report}/design-symbol.png` });
|
||||||
|
checks.push('Design → Symbol: alle sieben Werte da, Größe 24 px wirkt in der Vorschau');
|
||||||
|
|
||||||
|
await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
|
||||||
|
const stored = (await (await context.request.get(api)).json()).document;
|
||||||
|
const button = stored.blocks[0].children[0];
|
||||||
|
assert.equal(stored.schemaVersion, 26);
|
||||||
|
assert.deepEqual(button.props, { text: 'Jetzt anrufen', href: 'tel:+4972296992826', icon: 'phone', iconPosition: 'start', iconMotion: 'reveal', iconOnly: true });
|
||||||
|
assert.equal(button.style.desktop.iconSize, '24px');
|
||||||
|
await page.reload(); await selectBlock('qa-icon-button');
|
||||||
|
await page.locator('.icon-field').waitFor();
|
||||||
|
assert.equal(await page.locator('.icon-field-name').textContent(), 'Telefon');
|
||||||
|
assert.equal(await page.getByLabel('Nur das Symbol zeigen').isChecked(), true);
|
||||||
|
checks.push('gespeichert als Schema v26 mit Größe unter Design, nach Neuladen gleich');
|
||||||
|
|
||||||
|
// So sieht es aus: Pfeil in einer runden Fläche, die sich beim Überfahren dreht und umfärbt,
|
||||||
|
// daneben ein Button nur aus Symbol.
|
||||||
|
const look = (await (await context.request.get(api)).json());
|
||||||
|
look.document.blocks[0].children = [
|
||||||
|
{ id: 'qa-look-a', type: 'button', props: { text: 'Projekt anfragen', href: '/kontakt', icon: 'arrow-right', iconMotion: 'slide' }, style: { desktop: { iconColor: '#ffffff', iconBackgroundColor: '#b71c22', iconPadding: '6px', iconRadius: '999px', iconSize: '16px', hover: { iconBackgroundColor: '#1e2a20', iconRotate: '-45deg' } } } },
|
||||||
|
{ id: 'qa-look-b', type: 'button', props: { text: 'Anrufen', href: 'tel:+4972296992826', icon: 'phone', iconOnly: true } },
|
||||||
|
{ id: 'qa-look-c', type: 'button', props: { text: 'Prospekt laden', href: '/', icon: 'download', iconPosition: 'start' } },
|
||||||
|
];
|
||||||
|
assert.equal((await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': look.csrfToken }, data: { document: look.document, expectedVersion: look.version } })).status(), 200);
|
||||||
|
await page.reload();
|
||||||
|
const frame = page.frameLocator('#preview');
|
||||||
|
await frame.locator('#qa-look-a .button-icon').waitFor();
|
||||||
|
const section = frame.locator('#qa-icons');
|
||||||
|
await section.screenshot({ path: `${report}/aussehen-normal.png` });
|
||||||
|
const styles = await frame.locator('#qa-look-a .button-icon').evaluate(el => { const s = getComputedStyle(el); return { bg: s.backgroundColor, radius: s.borderRadius, color: s.color }; });
|
||||||
|
assert.deepEqual(styles, { bg: 'rgb(183, 28, 34)', radius: '999px', color: 'rgb(255, 255, 255)' });
|
||||||
|
await frame.locator('#qa-look-a').hover();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
await section.screenshot({ path: `${report}/aussehen-hover.png` });
|
||||||
|
assert.equal(await frame.locator('#qa-look-a .button-icon').evaluate(el => getComputedStyle(el).rotate), '-45deg');
|
||||||
|
const only = await frame.locator('#qa-look-b').evaluate(el => ({ name: el.textContent.trim(), w: Math.round(el.getBoundingClientRect().width), h: Math.round(el.getBoundingClientRect().height) }));
|
||||||
|
assert.equal(only.name, 'Anrufen');
|
||||||
|
assert.ok(Math.abs(only.w - only.h) <= 2, `Nur-Symbol-Button ist nicht quadratisch: ${only.w}x${only.h}`);
|
||||||
|
checks.push('Aussehen: farbige runde Fläche, Drehung beim Überfahren, quadratischer Nur-Symbol-Button, Symbol vor dem Text');
|
||||||
|
await selectBlock('qa-look-c');
|
||||||
|
|
||||||
|
// Kein Symbol: Nur-Symbol fällt weg, die Einstellungen verschwinden.
|
||||||
|
await page.getByRole('button', { name: /Symbol ändern/ }).click();
|
||||||
|
await dialog.getByRole('button', { name: 'Kein Symbol', exact: true }).click();
|
||||||
|
await page.waitForFunction(() => { const el = document.querySelector('#preview')?.contentDocument?.getElementById('qa-look-c'); return el && !el.querySelector('.button-icon'); });
|
||||||
|
assert.equal(await page.getByRole('button', { name: 'Vor dem Text', exact: true }).count(), 0);
|
||||||
|
checks.push('Kein Symbol entfernt auch „nur Symbol“');
|
||||||
|
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
await writeFile(`${report}/report.json`, JSON.stringify({ origin, checks }, null, 2));
|
||||||
|
console.log(checks.map(check => `✓ ${check}`).join('\n'));
|
||||||
|
} finally {
|
||||||
|
await browser?.close();
|
||||||
|
server.kill();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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]]),
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ final class DocumentValidator
|
|||||||
/** Blocks introduced with schema v6 for the sections a marketing page actually needs. */
|
/** Blocks introduced with schema v6 for the sections a marketing page actually needs. */
|
||||||
public const V6_TYPES = ['iconcard', 'stat', 'quote', 'logos', 'cta', 'contact', 'divider', 'spacer', 'accordion', 'accordionitem'];
|
public const V6_TYPES = ['iconcard', 'stat', 'quote', 'logos', 'cta', 'contact', 'divider', 'spacer', 'accordion', 'accordionitem'];
|
||||||
/** Icons ship as inline SVG from this fixed list; no icon font and no external request. */
|
/** Icons ship as inline SVG from this fixed list; no icon font and no external request. */
|
||||||
public const ICONS = ['app', 'web', 'design', 'network', 'hardware', 'shop', 'support', 'shield', 'clock', 'star', 'phone', 'mail', 'pin', 'check', 'people', 'music', 'home', 'arrow-right', 'arrow-left', 'arrow-up-right'];
|
public const ICONS = ['arrow-up-right', 'arrow-right', 'arrow-left', 'arrow-up', 'arrow-down', 'chevron-right', 'chevron-left', 'chevron-up', 'chevron-down', 'arrow-circle', 'external', 'refresh', 'menu', 'close', 'plus', 'minus', 'phone', 'app', 'mail', 'chat', 'send', 'calendar', 'clock', 'pin', 'map', 'navigation', 'web', 'wifi', 'support', 'shop', 'cart', 'tag', 'euro', 'credit-card', 'gift', 'percent', 'truck', 'package', 'briefcase', 'document', 'download', 'upload', 'print', 'link', 'share', 'image', 'camera', 'video', 'play', 'pause', 'music', 'mic', 'book', 'user', 'people', 'login', 'lock', 'key', 'shield', 'eye', 'thumbs-up', 'award', 'graduation', 'heart', 'star', 'home', 'building', 'wrench', 'hammer', 'brush', 'ruler', 'leaf', 'tree', 'sun', 'droplet', 'flame', 'bolt', 'solar', 'thermometer', 'snowflake', 'car', 'check', 'check-circle', 'info', 'help', 'alert', 'search', 'settings', 'filter', 'grid', 'list', 'sparkle', 'coffee', 'utensils', 'laptop', 'hardware', 'network', 'design'];
|
||||||
|
/** Das Symbol eines Buttons, wenn keins gewählt ist: der Pfeil, den jeder Button vor v25 hatte. */
|
||||||
|
public const BUTTON_DEFAULT_ICON = 'arrow-up-right';
|
||||||
public const SPACER_SIZES = ['small', 'medium', 'large', 'huge'];
|
public const SPACER_SIZES = ['small', 'medium', 'large', 'huge'];
|
||||||
/** Schema v7: tabs, the one pattern that needs a script to become a real widget. */
|
/** Schema v7: tabs, the one pattern that needs a script to become a real widget. */
|
||||||
public const V7_TYPES = ['tabs', 'tabitem'];
|
public const V7_TYPES = ['tabs', 'tabitem'];
|
||||||
@@ -40,7 +42,7 @@ final class DocumentValidator
|
|||||||
public const V23_TYPES = ['social'];
|
public const V23_TYPES = ['social'];
|
||||||
public const V24_TYPES = ['html'];
|
public const V24_TYPES = ['html'];
|
||||||
/** Die aktuelle Fassung des Dokumentschemas - dieselbe Zahl wie SCHEMA_VERSION in editor-model.js. */
|
/** Die aktuelle Fassung des Dokumentschemas - dieselbe Zahl wie SCHEMA_VERSION in editor-model.js. */
|
||||||
public const SCHEMA_VERSION = 24;
|
public const SCHEMA_VERSION = 26;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param bool $requireHeading A page needs exactly one main heading. A header, a footer or a
|
* @param bool $requireHeading A page needs exactly one main heading. A header, a footer or a
|
||||||
@@ -237,6 +239,24 @@ final class DocumentValidator
|
|||||||
}
|
}
|
||||||
if ('button' === $type) {
|
if ('button' === $type) {
|
||||||
$this->link($props['href'] ?? null);
|
$this->link($props['href'] ?? null);
|
||||||
|
// Schema v25: das Symbol am Button ist wählbar. Fehlt es, bleibt es beim Pfeil wie
|
||||||
|
// bisher - ältere Dokumente sehen also unverändert aus. "none" heißt ohne Symbol.
|
||||||
|
if (array_key_exists('icon', $props)) {
|
||||||
|
$this->require($schemaVersion >= 25 && ('none' === $props['icon'] || in_array($props['icon'], self::ICONS, true)), 'Unbekanntes Symbol am Button oder Dokumentschema vor v25.');
|
||||||
|
}
|
||||||
|
if (array_key_exists('iconPosition', $props)) {
|
||||||
|
$this->require($schemaVersion >= 25 && in_array($props['iconPosition'], ['start', 'end'], true), 'Das Symbol steht vor oder nach dem Text und braucht Schema v25.');
|
||||||
|
}
|
||||||
|
// Schema v26 (ADR 109): was das Symbol beim Überfahren tut, und ein Button nur aus
|
||||||
|
// Symbol. Der Text bleibt dann für Vorlesesoftware da - ohne Symbol wäre der Button
|
||||||
|
// unsichtbar, deshalb braucht "nur Symbol" ein Symbol.
|
||||||
|
if (array_key_exists('iconMotion', $props)) {
|
||||||
|
$this->require($schemaVersion >= 26 && in_array($props['iconMotion'], ['none', 'slide', 'reveal'], true), 'Unbekannte Bewegung des Symbols oder Dokumentschema vor v26.');
|
||||||
|
}
|
||||||
|
if (array_key_exists('iconOnly', $props)) {
|
||||||
|
$this->require($schemaVersion >= 26 && is_bool($props['iconOnly']), 'Nur Symbol ist an oder aus und braucht Schema v26.');
|
||||||
|
$this->require(!$props['iconOnly'] || 'none' !== ($props['icon'] ?? self::BUTTON_DEFAULT_ICON), 'Ein Button nur aus Symbol braucht ein Symbol.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if ('iconcard' === $type) {
|
if ('iconcard' === $type) {
|
||||||
$this->require(in_array($props['icon'] ?? null, self::ICONS, true), 'Unknown icon.');
|
$this->require(in_array($props['icon'] ?? null, self::ICONS, true), 'Unknown icon.');
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) : '';
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
Importzeile sie kennen muss. Sie steht vor jedem Modul-Skript; spaeter geladen wirkt sie
|
Importzeile sie kennen muss. Sie steht vor jedem Modul-Skript; spaeter geladen wirkt sie
|
||||||
nicht mehr. #}
|
nicht mehr. #}
|
||||||
<script type="importmap" nonce="{{ csp_nonce() }}">{"imports":{
|
<script type="importmap" nonce="{{ csp_nonce() }}">{"imports":{
|
||||||
{%- for modul in ['editor-model', 'media', 'design-panel', 'button-design', 'color-control', 'rich-text', 'canvas-overlay', 'library-previews', 'preview-containers', 'page-picker', 'motion', 'slider', 'live-search'] %}
|
{%- for modul in ['editor-model', 'media', 'design-panel', 'button-design', 'color-control', 'rich-text', 'canvas-overlay', 'library-previews', 'preview-containers', 'page-picker', 'motion', 'slider', 'live-search', 'icons'] %}
|
||||||
"/assets/{{ modul }}.js": "/assets/{{ modul }}.js?v={{ kant_version() }}"{{ loop.last ? '' : ',' }}
|
"/assets/{{ modul }}.js": "/assets/{{ modul }}.js?v={{ kant_version() }}"{{ loop.last ? '' : ',' }}
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
}}</script>
|
}}</script>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -26,7 +26,17 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% elseif type == 'button' %}
|
{% elseif type == 'button' %}
|
||||||
{% for device, settings in layout.button|default({}) %}{% for key, value in settings %}{% set designClasses = designClasses ~ ' b-' ~ device ~ '-' ~ key ~ '-' ~ value %}{% endfor %}{% endfor %}
|
{% for device, settings in layout.button|default({}) %}{% for key, value in settings %}{% set designClasses = designClasses ~ ' b-' ~ device ~ '-' ~ key ~ '-' ~ value %}{% endfor %}{% endfor %}
|
||||||
<a id="{{ block.id }}" class="button cms-button{{ designClasses }}" href="{{ props.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: props.newTab|default(false)} only %}>{{ props.text }} <span class="button-arrow" aria-hidden="true">{% include 'blocks/icon.html.twig' with {name: 'arrow-up-right'} only %}</span></a>
|
{# Schema v25: Symbol wählbar. Ohne Angabe der Pfeil wie bisher; er behält die Klasse
|
||||||
|
button-arrow, damit eigenes CSS älterer Websites (ausblenden, drehen) weiter greift. #}
|
||||||
|
{% set buttonIcon = props.icon|default('arrow-up-right') %}
|
||||||
|
{% set iconMarkup = '' %}
|
||||||
|
{% if buttonIcon != 'none' %}{% set iconMarkup %}<span class="button-icon{{ buttonIcon == 'arrow-up-right' ? ' button-arrow' }}" aria-hidden="true">{% include 'blocks/icon.html.twig' with {name: buttonIcon} only %}</span>{% endset %}{% endif %}
|
||||||
|
{% set iconFirst = props.iconPosition|default('end') == 'start' %}
|
||||||
|
{# Schema v26: Bewegung beim Überfahren und ein Button nur aus Symbol. Der Text steht dann
|
||||||
|
in einer Beschriftung ohne Breite und bleibt der Name des Links für Vorlesesoftware. #}
|
||||||
|
{% set iconOnly = iconMarkup and props.iconOnly|default(false) %}
|
||||||
|
{% if iconMarkup %}{% set designClasses = designClasses ~ (iconFirst ? ' icon-start' : '') ~ (props.iconMotion|default('none') != 'none' ? ' icon-motion-' ~ props.iconMotion : '') ~ (iconOnly ? ' icon-only' : '') %}{% endif %}
|
||||||
|
<a id="{{ block.id }}" class="button cms-button{{ designClasses }}" href="{{ props.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: props.newTab|default(false)} only %}>{% if iconMarkup and iconFirst %}{{ iconMarkup }} {% endif %}{% if iconOnly %}<span class="button-label">{{ props.text }}</span>{% else %}{{ props.text }}{% endif %}{% if iconMarkup and not iconFirst %} {{ iconMarkup }}{% endif %}</a>
|
||||||
{% elseif type == 'image' %}
|
{% elseif type == 'image' %}
|
||||||
{# Wie breit das Bild tatsaechlich erscheint. Steht im Block eine feste Breite - ein Logo mit
|
{# Wie breit das Bild tatsaechlich erscheint. Steht im Block eine feste Breite - ein Logo mit
|
||||||
190px, ein Symbol mit 72px -, dann ist das die Antwort, und der Browser laedt die kleine
|
190px, ein Symbol mit 72px -, dann ist das die Antwort, und der Browser laedt die kleine
|
||||||
@@ -104,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') %}
|
||||||
|
|||||||
@@ -1,24 +1,102 @@
|
|||||||
{# Inline SVG from a fixed allowlist. No icon font, no external request, no extra stylesheet. #}
|
{# Inline SVG from a fixed allowlist. No icon font, no external request, no extra stylesheet. #}
|
||||||
{% set paths = {
|
{% set paths = {
|
||||||
app: 'M7 2h10a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Zm4 17h2',
|
'arrow-up-right': 'M6.5 17.5 17.5 6.5M8.5 6.5h9v9',
|
||||||
web: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 0c2.5 2.4 3.8 5.4 3.8 9s-1.3 6.6-3.8 9m0-18C9.5 5.4 8.2 8.4 8.2 12s1.3 6.6 3.8 9M3.2 9h17.6M3.2 15h17.6',
|
|
||||||
design: 'M4 20h16M6 16 18 4l3 3L9 19l-4 1Z',
|
|
||||||
network: 'M5 3h14v5H5Zm0 13h14v5H5Zm7-8v8M8 8v3h8V8',
|
|
||||||
hardware: 'M4 5h16v11H4Zm4 15h8m-4-4v4',
|
|
||||||
shop: 'M4 7h16l-1.4 11.2a2 2 0 0 1-2 1.8H7.4a2 2 0 0 1-2-1.8Zm4 0a4 4 0 0 1 8 0',
|
|
||||||
support: 'M12 3a7 7 0 0 0-7 7v5m14-5a7 7 0 0 0-7-7m7 7v5m-14 0a2 2 0 0 0 2 2h1v-5H5Zm14 0a2 2 0 0 1-2 2h-1v-5h3Z',
|
|
||||||
shield: 'M12 3 5 6v6c0 4.4 2.9 7.9 7 9 4.1-1.1 7-4.6 7-9V6Zm-2.5 9 2 2 4-4.5',
|
|
||||||
clock: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 4v5.5l3.5 2',
|
|
||||||
star: 'm12 3 2.7 5.9 6.3.7-4.7 4.3 1.3 6.1L12 17l-5.6 3 1.3-6.1L3 9.6l6.3-.7Z',
|
|
||||||
phone: 'M6 3h4l2 5-2.5 1.5a12 12 0 0 0 5 5L16 12l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 4 5a2 2 0 0 1 2-2Z',
|
|
||||||
mail: 'M3 6h18v12H3Zm0 1 9 7 9-7',
|
|
||||||
pin: 'M12 21s7-6.3 7-11a7 7 0 1 0-14 0c0 4.7 7 11 7 11Zm0-8.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z',
|
|
||||||
check: 'm4 12.5 5.5 5.5L20 6',
|
|
||||||
people: 'M9.5 11.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7ZM2.5 20v-1c0-3.3 3-5 7-5s7 1.7 7 5v1M16.5 5.3a3.2 3.2 0 0 1 0 6.4M18 14.4c2.1.7 3.5 2.1 3.5 4.1V20',
|
|
||||||
music: 'M10 18V5.5L20 3v12.5M10 9.5 20 7M10 18a3 2.5 0 1 0-6 0 3 2.5 0 0 0 6 0ZM20 15.5a3 2.5 0 1 0-6 0 3 2.5 0 0 0 6 0Z',
|
|
||||||
home: 'm3 11.5 9-7.5 9 7.5M5.5 10v10h13V10M10 20v-6.5h4V20',
|
|
||||||
'arrow-right': 'M4 12h15.5m-6.5-6.5 6.5 6.5-6.5 6.5',
|
'arrow-right': 'M4 12h15.5m-6.5-6.5 6.5 6.5-6.5 6.5',
|
||||||
'arrow-left': 'M20 12H4.5M11 5.5 4.5 12 11 18.5',
|
'arrow-left': 'M20 12H4.5M11 5.5 4.5 12 11 18.5',
|
||||||
'arrow-up-right': 'M6.5 17.5 17.5 6.5M8.5 6.5h9v9'
|
'arrow-up': 'M12 20V4.5m-6.5 6.5L12 4.5l6.5 6.5',
|
||||||
|
'arrow-down': 'M12 4v15.5m-6.5-6.5 6.5 6.5 6.5-6.5',
|
||||||
|
'chevron-right': 'm9 5.5 6.5 6.5L9 18.5',
|
||||||
|
'chevron-left': 'M15 5.5 8.5 12l6.5 6.5',
|
||||||
|
'chevron-up': 'm5.5 15 6.5-6.5 6.5 6.5',
|
||||||
|
'chevron-down': 'm5.5 9 6.5 6.5L18.5 9',
|
||||||
|
'arrow-circle': 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm-4 9h8m-3-3 3 3-3 3',
|
||||||
|
external: 'M14 4h6v6m0-6-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5',
|
||||||
|
refresh: 'M20 12a8 8 0 1 1-2.3-5.6M20 4v5h-5',
|
||||||
|
menu: 'M4 7h16M4 12h16M4 17h16',
|
||||||
|
close: 'M6 6l12 12M18 6 6 18',
|
||||||
|
plus: 'M12 5v14M5 12h14',
|
||||||
|
minus: 'M5 12h14',
|
||||||
|
phone: 'M6 3h4l2 5-2.5 1.5a12 12 0 0 0 5 5L16 12l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 4 5a2 2 0 0 1 2-2Z',
|
||||||
|
app: 'M7 2h10a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Zm4 17h2',
|
||||||
|
mail: 'M3 6h18v12H3Zm0 1 9 7 9-7',
|
||||||
|
chat: 'M4 5h16v11H9.5L5 19.5V16H4Z',
|
||||||
|
send: 'M21 3 10 14M21 3l-6.5 18-4.5-7-7-4.5Z',
|
||||||
|
calendar: 'M4 6h16v14H4Zm0 5h16M8 3.5v4m8-4v4',
|
||||||
|
clock: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 4v5.5l3.5 2',
|
||||||
|
pin: 'M12 21s7-6.3 7-11a7 7 0 1 0-14 0c0 4.7 7 11 7 11Zm0-8.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z',
|
||||||
|
map: 'M9 4 3 6.5V20l6-2.5 6 2.5 6-2.5V4l-6 2.5ZM9 4v13.5m6-11V20',
|
||||||
|
navigation: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm3.5 5.5-2 5-5 2 2-5Z',
|
||||||
|
web: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 0c2.5 2.4 3.8 5.4 3.8 9s-1.3 6.6-3.8 9m0-18C9.5 5.4 8.2 8.4 8.2 12s1.3 6.6 3.8 9M3.2 9h17.6M3.2 15h17.6',
|
||||||
|
wifi: 'M3 9.5a13 13 0 0 1 18 0M6 13a8 8 0 0 1 12 0m-9 3.5a3.5 3.5 0 0 1 6 0M12 19.5h.01',
|
||||||
|
support: 'M12 3a7 7 0 0 0-7 7v5m14-5a7 7 0 0 0-7-7m7 7v5m-14 0a2 2 0 0 0 2 2h1v-5H5Zm14 0a2 2 0 0 1-2 2h-1v-5h3Z',
|
||||||
|
shop: 'M4 7h16l-1.4 11.2a2 2 0 0 1-2 1.8H7.4a2 2 0 0 1-2-1.8Zm4 0a4 4 0 0 1 8 0',
|
||||||
|
cart: 'M3 4h2.5l2.2 10.5h10.6L20.5 7H6.3M9.5 18a1.2 1.2 0 1 0 0 2.4 1.2 1.2 0 0 0 0-2.4Zm7.5 0a1.2 1.2 0 1 0 0 2.4 1.2 1.2 0 0 0 0-2.4Z',
|
||||||
|
tag: 'M3.5 12.5v-8a1 1 0 0 1 1-1h8l8 8a1.4 1.4 0 0 1 0 2l-7 7a1.4 1.4 0 0 1-2 0ZM8.5 8h.01',
|
||||||
|
euro: 'M17.5 6.5A6.5 6.5 0 1 0 17.5 17.5M4 10h9m-9 4h9',
|
||||||
|
'credit-card': 'M3 6h18v12H3Zm0 4h18M6.5 15h3',
|
||||||
|
gift: 'M4 9h16v4H4Zm1.5 4v7h13v-7M12 9v11m0-11S10.5 4 8 4.5 7.5 9 12 9Zm0 0s1.5-5 4-4.5S16.5 9 12 9Z',
|
||||||
|
percent: 'M19 5 5 19M7 5a2 2 0 1 0 0 4 2 2 0 0 0 0-4Zm10 10a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z',
|
||||||
|
truck: 'M2.5 6h11v10.5h-11Zm11 3.5h4l3 3.5v3.5h-7ZM6.5 17a1.8 1.8 0 1 0 0 3.6 1.8 1.8 0 0 0 0-3.6Zm10.5 0a1.8 1.8 0 1 0 0 3.6 1.8 1.8 0 0 0 0-3.6Z',
|
||||||
|
package: 'M3.5 7.5 12 3l8.5 4.5v9L12 21l-8.5-4.5Zm0 0L12 12l8.5-4.5M12 12v9M7.8 5.2l8.5 4.6',
|
||||||
|
briefcase: 'M3.5 7.5h17v12h-17Zm5-3h7v3h-7Zm-5 8h17',
|
||||||
|
document: 'M6 3h8l4 4v14H6Zm8 0v4h4M9 12h6m-6 4h6',
|
||||||
|
download: 'M12 3.5v12m-5-5 5 5 5-5M4.5 20h15',
|
||||||
|
upload: 'M12 15.5V4m-5 5 5-5 5 5M4.5 20h15',
|
||||||
|
print: 'M7 9V3.5h10V9M7 17H4.5V9h15v8H17M7 14h10v6.5H7Z',
|
||||||
|
link: 'M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1.2 1.2M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1.2-1.2',
|
||||||
|
share: 'M18 3.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM6 9.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5Zm12 6a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM8.2 10.8l7.6-4.1m-7.6 6.5 7.6 4.1',
|
||||||
|
image: 'M4 4h16v16H4Zm0 12 4.5-4.5 3.5 3.5 2.5-2.5L20 18M15 8.5h.01',
|
||||||
|
camera: 'M3 7.5h4L8.5 5h7L17 7.5h4V19H3Zm9 2.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z',
|
||||||
|
video: 'M3 6.5h12v11H3Zm12 4 6-3.5v10l-6-3.5',
|
||||||
|
play: 'M8 5.5v13l10-6.5Z',
|
||||||
|
pause: 'M8 5v14m8-14v14',
|
||||||
|
music: 'M10 18V5.5L20 3v12.5M10 9.5 20 7M10 18a3 2.5 0 1 0-6 0 3 2.5 0 0 0 6 0ZM20 15.5a3 2.5 0 1 0-6 0 3 2.5 0 0 0 6 0Z',
|
||||||
|
mic: 'M12 3a3 3 0 0 0-3 3v5.5a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3ZM5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21',
|
||||||
|
book: 'M5 4.5A1.5 1.5 0 0 1 6.5 3H19v15H6.5A1.5 1.5 0 0 0 5 19.5Zm0 15A1.5 1.5 0 0 0 6.5 21H19v-3',
|
||||||
|
user: 'M12 11.5a4 4 0 1 0 0-8 4 4 0 0 0 0 8ZM4.5 20.5c0-3.9 3.4-6 7.5-6s7.5 2.1 7.5 6',
|
||||||
|
people: 'M9.5 11.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7ZM2.5 20v-1c0-3.3 3-5 7-5s7 1.7 7 5v1M16.5 5.3a3.2 3.2 0 0 1 0 6.4M18 14.4c2.1.7 3.5 2.1 3.5 4.1V20',
|
||||||
|
login: 'M14 4h5a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1h-5M3.5 12h11m-4-4 4 4-4 4',
|
||||||
|
lock: 'M5 10.5h14V21H5Zm3 0V7a4 4 0 0 1 8 0v3.5M12 14.5v3',
|
||||||
|
key: 'M7.5 11.5a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm2.8 1.2L19 4m-3 3 2.5 2.5M13 10l2 2',
|
||||||
|
shield: 'M12 3 5 6v6c0 4.4 2.9 7.9 7 9 4.1-1.1 7-4.6 7-9V6Zm-2.5 9 2 2 4-4.5',
|
||||||
|
eye: 'M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Zm9.5-3a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z',
|
||||||
|
'thumbs-up': 'M7 10.5V20H4v-9.5Zm0 0 3.5-7a2 2 0 0 1 2.5 2v3.5h5.2a2 2 0 0 1 2 2.4l-1.4 7a2 2 0 0 1-2 1.6H7',
|
||||||
|
award: 'M12 3a5.5 5.5 0 1 0 0 11 5.5 5.5 0 0 0 0-11Zm-3.5 10L7 21l5-2.5 5 2.5-1.5-8',
|
||||||
|
graduation: 'M2.5 9 12 4.5 21.5 9 12 13.5Zm4 2.3V16c0 1.5 2.5 3 5.5 3s5.5-1.5 5.5-3v-4.7M21.5 9v5',
|
||||||
|
heart: 'M12 20s-7.5-4.6-7.5-10A4.3 4.3 0 0 1 12 7.4 4.3 4.3 0 0 1 19.5 10c0 5.4-7.5 10-7.5 10Z',
|
||||||
|
star: 'm12 3 2.7 5.9 6.3.7-4.7 4.3 1.3 6.1L12 17l-5.6 3 1.3-6.1L3 9.6l6.3-.7Z',
|
||||||
|
home: 'm3 11.5 9-7.5 9 7.5M5.5 10v10h13V10M10 20v-6.5h4V20',
|
||||||
|
building: 'M5 21V4h10v17m0-12h4v12M3 21h18M8.5 7.5h3m-3 3.5h3m-3 3.5h3',
|
||||||
|
wrench: 'M14.5 3.5a5 5 0 0 0-4.6 6.9L3.5 16.8a1.9 1.9 0 0 0 2.7 2.7l6.4-6.4a5 5 0 0 0 6.9-4.6l-3 3-3-1-1-3Z',
|
||||||
|
hammer: 'M14 8.5 4.5 18a1.5 1.5 0 0 0 2.1 2.1L16 10.5M11 6l3-3 3.5 1L21 7.5 18.5 10l-2-2-2.5 2.5Z',
|
||||||
|
brush: 'M4 4h13v5H4Zm13 2.5h2.5V12h-8v3M10 15h3v6h-3Z',
|
||||||
|
ruler: 'M3.5 16 16 3.5 20.5 8 8 20.5ZM7 13l2 2m1-5 2 2m1-5 2 2',
|
||||||
|
leaf: 'M5 19C5 10 10 5 20 4c-1 10-6 15-15 15Zm0 0 8-8',
|
||||||
|
tree: 'M12 21v-5m0-13-6 8h3l-4 5h14l-4-5h3Z',
|
||||||
|
sun: 'M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm0-5v2m0 14v2M3 12h2m14 0h2M5.6 5.6 7 7m10 10 1.4 1.4M5.6 18.4 7 17M17 7l1.4-1.4',
|
||||||
|
droplet: 'M12 3s6.5 7 6.5 11.5a6.5 6.5 0 0 1-13 0C5.5 10 12 3 12 3Z',
|
||||||
|
flame: 'M12 21a6 6 0 0 0 6-6c0-4.5-4-6.5-4.5-11-2.5 2-5 5-5 8.5-1-.5-1.5-1.5-1.8-2.5C5.6 11.5 6 13 6 15a6 6 0 0 0 6 6Z',
|
||||||
|
bolt: 'M13 2.5 4.5 13.5H12l-1 8 8.5-11H12Z',
|
||||||
|
solar: 'M5 4h15l-2 9.5H3Zm-1 4.75h15M10 4 8.5 13.5M15.5 4 14 13.5M10.5 13.5V18M7 20.5h7',
|
||||||
|
thermometer: 'M10 14.5V5a2 2 0 0 1 4 0v9.5a4 4 0 1 1-4 0Z',
|
||||||
|
snowflake: 'M12 2.5v19M3.8 7.3l16.4 9.4M3.8 16.7l16.4-9.4M9.5 4 12 6l2.5-2m-5 16 2.5-2 2.5 2',
|
||||||
|
car: 'M5 16.5V12l2-5h10l2 5v4.5M3 12h18v4.5H3Zm3.5 4.5V19m11-2.5V19M7 14h.01M17 14h.01',
|
||||||
|
check: 'm4 12.5 5.5 5.5L20 6',
|
||||||
|
'check-circle': 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm-4 9.2 2.8 2.8L16.5 9',
|
||||||
|
info: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 8v5.5m0-9v.5',
|
||||||
|
help: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18ZM9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.6.3-1 .9-1 1.6v.6m0 3v.01',
|
||||||
|
alert: 'M12 3.5 2.5 20h19Zm0 6V14m0 3v.01',
|
||||||
|
search: 'M10.5 4a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13Zm4.8 11.3L20 20',
|
||||||
|
settings: 'M4 7h9m4 0h3M4 17h3m4 0h9M15 5v4M9 15v4',
|
||||||
|
filter: 'M3.5 5h17l-6.5 8v6l-4 2v-8Z',
|
||||||
|
grid: 'M4 4h7v7H4Zm9 0h7v7h-7ZM4 13h7v7H4Zm9 0h7v7h-7Z',
|
||||||
|
list: 'M9 6h11M9 12h11M9 18h11M4.5 6h.01M4.5 12h.01M4.5 18h.01',
|
||||||
|
sparkle: 'M12 3c.6 4.4 2.6 6.4 7 7-4.4.6-6.4 2.6-7 7-.6-4.4-2.6-6.4-7-7 4.4-.6 6.4-2.6 7-7Zm6.5 12.5c.2 1.6 1 2.3 2.5 2.5-1.5.2-2.3 1-2.5 2.5-.2-1.5-1-2.3-2.5-2.5 1.5-.2 2.3-1 2.5-2.5Z',
|
||||||
|
coffee: 'M4 9h12v6a5 5 0 0 1-5 5H9a5 5 0 0 1-5-5Zm12 1.5h1.5a2.5 2.5 0 0 1 0 5H16M8 3.5V6m4-2.5V6',
|
||||||
|
utensils: 'M6 3v7a2 2 0 0 0 4 0V3M8 10v11m8.5 0V3c-2 1-3 3.5-3 6.5v4h3',
|
||||||
|
laptop: 'M5 5h14v10H5Zm-2.5 10h19l-1 3.5h-17Z',
|
||||||
|
hardware: 'M4 5h16v11H4Zm4 15h8m-4-4v4',
|
||||||
|
network: 'M5 3h14v5H5Zm0 13h14v5H5Zm7-8v8M8 8v3h8V8',
|
||||||
|
design: 'M4 20h16M6 16 18 4l3 3L9 19l-4 1Z'
|
||||||
} %}
|
} %}
|
||||||
<svg class="block-icon" viewBox="0 0 24 24" width="32" height="32" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="{{ paths[name] }}"/></svg>
|
<svg class="block-icon" viewBox="0 0 24 24" width="32" height="32" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="{{ paths[name] }}"/></svg>
|
||||||
@@ -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>
|
||||||
@@ -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">
|
||||||
|
|||||||
+183
-1
@@ -273,7 +273,115 @@ final class CmsTest extends TestCase
|
|||||||
public function testTheButtonArrowTakesTheColourOfTheButtonText(): void
|
public function testTheButtonArrowTakesTheColourOfTheButtonText(): void
|
||||||
{
|
{
|
||||||
// Der Pfeil ist ein Symbol (block-icon) und bekam die Akzentfarbe: grün auf einem roten Knopf.
|
// Der Pfeil ist ein Symbol (block-icon) und bekam die Akzentfarbe: grün auf einem roten Knopf.
|
||||||
self::assertStringContainsString('.button-arrow .block-icon{color:inherit}', (string) file_get_contents(__DIR__.'/../public/assets/site.css'));
|
self::assertStringContainsString('.button-icon .block-icon{color:inherit}', (string) file_get_contents(__DIR__.'/../public/assets/site.css'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema v25: Umut wollte Buttons mit Symbol und den festen Pfeil ändern können. Ohne Wahl
|
||||||
|
* bleibt alles wie bisher - der Pfeil mit der Klasse button-arrow, auf die eigenes CSS älterer
|
||||||
|
* Websites zielt (Hüfner blendet ihn so aus).
|
||||||
|
*/
|
||||||
|
public function testAButtonCarriesTheChosenIconBeforeOrAfterItsTextOrNone(): void
|
||||||
|
{
|
||||||
|
$document = DemoContent::pages()['home'];
|
||||||
|
$button = $document['blocks'][0]['children'][3];
|
||||||
|
self::assertSame('button', $button['type']);
|
||||||
|
$id = $this->pages->saveDraft('page-1', $document, 0);
|
||||||
|
$this->pages->publish('page-1', $id, 1);
|
||||||
|
$html = (string) $this->request('/')->getContent();
|
||||||
|
self::assertMatchesRegularExpression('/<a id="'.$button['id'].'"[^>]*>[^<]+ <span class="button-icon button-arrow" aria-hidden="true"><svg class="block-icon"[^>]*><path d="M6\.5 17\.5/', $html, 'Ohne Wahl: der schräge Pfeil hinter dem Text.');
|
||||||
|
|
||||||
|
$document['schemaVersion'] = 25;
|
||||||
|
$document['blocks'][0]['children'][3]['props'] += ['icon' => 'phone', 'iconPosition' => 'start'];
|
||||||
|
$id = $this->pages->saveDraft('page-1', $document, 2);
|
||||||
|
$this->pages->publish('page-1', $id, 3);
|
||||||
|
$html = (string) $this->request('/')->getContent();
|
||||||
|
self::assertMatchesRegularExpression('/<a id="'.$button['id'].'"[^>]*><span class="button-icon" aria-hidden="true"><svg class="block-icon"[^>]*><path d="M6 3h4l2 5[^"]*"\/><\/svg>\s*<\/span> '.preg_quote($button['props']['text'], '/').'<\/a>/', $html, 'Telefon vor dem Text, ohne die Pfeil-Klasse.');
|
||||||
|
|
||||||
|
$document['blocks'][0]['children'][3]['props']['icon'] = 'none';
|
||||||
|
$id = $this->pages->saveDraft('page-1', $document, 4);
|
||||||
|
$this->pages->publish('page-1', $id, 5);
|
||||||
|
$html = (string) $this->request('/')->getContent();
|
||||||
|
self::assertMatchesRegularExpression('/<a id="'.$button['id'].'"[^>]*>'.preg_quote($button['props']['text'], '/').'<\/a>/', $html, 'Kein Symbol: nur der Text.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return iterable<string, array{string, mixed}>
|
||||||
|
*/
|
||||||
|
public static function rejectedButtonIcons(): iterable
|
||||||
|
{
|
||||||
|
yield 'Symbol vor v25' => ['icon', 'phone', 24];
|
||||||
|
yield 'unbekanntes Symbol' => ['icon', 'whatsapp', 25];
|
||||||
|
yield 'Symbol ist kein Text' => ['icon', ['phone'], 25];
|
||||||
|
yield 'Stellung vor v25' => ['iconPosition', 'start', 24];
|
||||||
|
yield 'unbekannte Stellung' => ['iconPosition', 'middle', 25];
|
||||||
|
yield 'Bewegung vor v26' => ['iconMotion', 'slide', 25];
|
||||||
|
yield 'unbekannte Bewegung' => ['iconMotion', 'spin', 26];
|
||||||
|
yield 'Nur Symbol vor v26' => ['iconOnly', true, 25];
|
||||||
|
yield 'Nur Symbol ist kein Schalter' => ['iconOnly', 'ja', 26];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAButtonWithoutAnIconCannotBeIconOnly(): void
|
||||||
|
{
|
||||||
|
$document = DemoContent::pages()['home'];
|
||||||
|
$document['schemaVersion'] = 26;
|
||||||
|
$document['blocks'][0]['children'][3]['props'] += ['icon' => 'none', 'iconOnly' => true];
|
||||||
|
$this->expectException(InvalidDocument::class);
|
||||||
|
(new DocumentValidator())->validate($document);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema v26 (ADR 109): Bewegung beim Überfahren und ein Button nur aus Symbol. Der Text
|
||||||
|
* bleibt als unsichtbare Beschriftung der Name des Links.
|
||||||
|
*/
|
||||||
|
public function testAButtonIconMovesOnHoverOrStandsAlone(): void
|
||||||
|
{
|
||||||
|
$document = DemoContent::pages()['home'];
|
||||||
|
$button = $document['blocks'][0]['children'][3];
|
||||||
|
$document['schemaVersion'] = 26;
|
||||||
|
$document['blocks'][0]['children'][3]['props'] += ['icon' => 'phone', 'iconMotion' => 'slide', 'iconOnly' => true];
|
||||||
|
$id = $this->pages->saveDraft('page-1', $document, 0);
|
||||||
|
$this->pages->publish('page-1', $id, 1);
|
||||||
|
$html = (string) $this->request('/')->getContent();
|
||||||
|
self::assertMatchesRegularExpression('/<a id="'.$button['id'].'" class="button cms-button[^"]* icon-motion-slide icon-only"[^>]*><span class="button-label">'.preg_quote($button['props']['text'], '/').'<\/span> <span class="button-icon"/', $html);
|
||||||
|
|
||||||
|
$document['blocks'][0]['children'][3]['props'] = ['text' => $button['props']['text'], 'href' => $button['props']['href'], 'icon' => 'download', 'iconPosition' => 'start', 'iconMotion' => 'reveal'];
|
||||||
|
$id = $this->pages->saveDraft('page-1', $document, 2);
|
||||||
|
$this->pages->publish('page-1', $id, 3);
|
||||||
|
$html = (string) $this->request('/')->getContent();
|
||||||
|
self::assertMatchesRegularExpression('/<a id="'.$button['id'].'" class="button cms-button[^"]* icon-start icon-motion-reveal"/', $html);
|
||||||
|
$css = (string) file_get_contents(__DIR__.'/../public/assets/site.css');
|
||||||
|
self::assertStringContainsString('.cms-button.icon-motion-reveal:is(:hover,:focus-visible) .button-icon{max-width:4em;opacity:1', $css);
|
||||||
|
self::assertStringContainsString('.cms-button .button-icon>svg{width:var(--icon-size,1.15em)', $css);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Farbe, Fläche, Größe und Drehung des Symbols liegen am Button und treffen seine
|
||||||
|
* Symbolhülle - je Gerät und beim Überfahren, auch bei Tastaturfokus.
|
||||||
|
*/
|
||||||
|
public function testButtonIconStylesTargetTheIconPerDeviceAndOnHover(): void
|
||||||
|
{
|
||||||
|
$document = ['schemaVersion' => 26, 'title' => 'T', 'description' => '', 'blocks' => [['id' => 'b1', 'type' => 'button', 'props' => ['text' => 'Anrufen', 'href' => '/', 'icon' => 'phone'], 'style' => [
|
||||||
|
'desktop' => ['iconColor' => '@accent', 'iconBackgroundColor' => '#ffffff', 'iconSize' => '20px', 'iconPadding' => '8px', 'iconRadius' => '999px', 'iconGap' => '12px', 'hover' => ['iconColor' => '#000000', 'iconRotate' => '45deg']],
|
||||||
|
'mobile' => ['iconSize' => '16px'],
|
||||||
|
]]]];
|
||||||
|
$css = (new StyleCompiler())->compileDocument($document);
|
||||||
|
self::assertStringContainsString('#b1 .button-icon{color:var(--k-accent);background-color:#ffffff;--icon-size:20px;padding:8px;border-radius:999px}', $css);
|
||||||
|
self::assertStringContainsString('#b1:hover .button-icon,#b1:focus-visible .button-icon{color:#000000;rotate:45deg}', $css);
|
||||||
|
self::assertStringContainsString('--icon-gap:12px', $css);
|
||||||
|
self::assertMatchesRegularExpression('/@media \(max-width:\d+px\)\{#b1 \.button-icon\{--icon-size:16px\}\}/', $css);
|
||||||
|
// Die Werte gelten nur der Symbolhülle, nicht dem Button selbst.
|
||||||
|
self::assertStringNotContainsString('#b1{color:var(--k-accent)', $css);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('rejectedButtonIcons')]
|
||||||
|
public function testAButtonRejectsUnknownIconsAndOlderSchemas(string $key, mixed $value, int $version): void
|
||||||
|
{
|
||||||
|
$document = DemoContent::pages()['home'];
|
||||||
|
$document['schemaVersion'] = $version;
|
||||||
|
$document['blocks'][0]['children'][3]['props'][$key] = $value;
|
||||||
|
$this->expectException(InvalidDocument::class);
|
||||||
|
(new DocumentValidator())->validate($document);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[DataProvider('invalidDocuments')]
|
#[DataProvider('invalidDocuments')]
|
||||||
@@ -629,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');
|
||||||
|
|||||||
@@ -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')));
|
||||||
|
|||||||
@@ -656,4 +656,11 @@ test('a loaded document is raised to the schema its blocks and properties need',
|
|||||||
assert.equal(repairSchema(plain).schemaVersion, 5);
|
assert.equal(repairSchema(plain).schemaVersion, 5);
|
||||||
const faq = { schemaVersion: 10, title: 'F', description: '', blocks: [{ id: 'a', type: 'accordion', props: { faq: true }, children: [] }] };
|
const faq = { schemaVersion: 10, title: 'F', description: '', blocks: [{ id: 'a', type: 'accordion', props: { faq: true }, children: [] }] };
|
||||||
assert.equal(repairSchema(faq).schemaVersion, 23);
|
assert.equal(repairSchema(faq).schemaVersion, 23);
|
||||||
|
// Ein Button mit gewähltem Symbol braucht v25; ohne Symbol bleibt er bei seiner Fassung.
|
||||||
|
const symbol = { schemaVersion: 24, title: 'S', description: '', blocks: [{ id: 'b', type: 'button', props: { text: 'Anrufen', href: 'tel:+49', icon: 'phone' } }] };
|
||||||
|
assert.equal(repairSchema(symbol).schemaVersion, 25);
|
||||||
|
const pfeil = { schemaVersion: 24, title: 'P', description: '', blocks: [{ id: 'b', type: 'button', props: { text: 'Mehr', href: '/' } }] };
|
||||||
|
assert.equal(repairSchema(pfeil).schemaVersion, 24);
|
||||||
|
const bewegt = { schemaVersion: 24, title: 'B', description: '', blocks: [{ id: 'b', type: 'button', props: { text: 'Mehr', href: '/', iconMotion: 'slide' } }] };
|
||||||
|
assert.equal(repairSchema(bewegt).schemaVersion, 26);
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { ICON_PATHS, ICON_LABELS, ICON_GROUPS } from '../public/assets/icons.js';
|
||||||
|
|
||||||
|
const read = path => readFileSync(new URL(path, import.meta.url), 'utf8');
|
||||||
|
|
||||||
|
test('the editor draws the same icons the website renders and the server allows', () => {
|
||||||
|
// Drei Listen, ein Satz: die Zeichnungen der Website (Twig), die Vorschau im Editor und die
|
||||||
|
// erlaubten Namen im Validator. Weicht eine ab, zeigt die Auswahl ein anderes Symbol als die
|
||||||
|
// Seite - oder bietet eins an, das der Server beim Speichern ablehnt. Abhilfe: node scripts/build-icons.mjs
|
||||||
|
const twig = read('../templates/blocks/icon.html.twig');
|
||||||
|
const website = Object.fromEntries([...twig.matchAll(/^\s{4}'?([a-z-]+)'?: '([^']+)',?$/gm)].map(([, name, path]) => [name, path]));
|
||||||
|
assert.deepEqual(ICON_PATHS, website);
|
||||||
|
const validator = read('../src/Content/DocumentValidator.php');
|
||||||
|
const allowed = [...validator.match(/public const ICONS = \[([^\]]+)\]/)[1].matchAll(/'([a-z-]+)'/g)].map(([, name]) => name);
|
||||||
|
assert.deepEqual(Object.keys(ICON_PATHS), allowed);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every icon has a German name and sits in exactly one category of the dialog', () => {
|
||||||
|
assert.deepEqual(Object.keys(ICON_LABELS).sort(), Object.keys(ICON_PATHS).sort());
|
||||||
|
const listed = ICON_GROUPS.flatMap(([, names]) => names);
|
||||||
|
assert.equal(new Set(listed).size, listed.length, 'Ein Symbol steht in zwei Kategorien.');
|
||||||
|
assert.deepEqual([...listed].sort(), Object.keys(ICON_PATHS).sort(), 'Jedes Symbol steht in einer Kategorie.');
|
||||||
|
// Der bisherige Pfeil steht vorn: ihn trägt ein Button ohne eigene Wahl.
|
||||||
|
assert.equal(ICON_GROUPS[0][1][0], 'arrow-up-right');
|
||||||
|
assert.ok(listed.length >= 90, `Nur ${listed.length} Symbole.`);
|
||||||
|
for (const path of Object.values(ICON_PATHS)) assert.match(path, /^[Mm][0-9MmLlHhVvCcSsQqTtAaZz .,-]+$/, 'Ein Pfad enthält Zeichen, die keine Zeichnung sind.');
|
||||||
|
});
|
||||||
Reference in new issue
Block a user