Offer 98 icons in a dialog and style a button icon under Design
The icon set grows from 33 to 98 drawings in seven categories (arrows, contact, commerce, files and media, people and security, house and trade, general), including truck, cart, euro, camera, tools and solar. icons.js is the single source; npm run build:icons writes the Twig drawings and the validator list, and a test keeps them equal. Choosing an icon no longer fills the panel with tiles: the panel shows the current icon with "Change", which opens a modal with category tabs and named tiles, keyboard navigable, closing with Escape. Button, icon and icon card share it. A new style group "Symbol" targets the icon inside a button per device and on hover: colour, background, size, padding, radius, rotation, plus the gap to the text. Schema v26 adds hover motion (slide, reveal) and an icon-only button whose label stays as the accessible name and keeps the line height. ADR 109. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6af2e2aead
commit
08998de533
20 files changed
+658
-228
No files matched your search
@@ -216,7 +216,7 @@ Unter `http://127.0.0.1:8080/admin` eine Seite auswählen. Links Blöcke hinzuf
|
|||||||
|
|
||||||
**Links und Bildvorschauen (ab 1.7.3):** Unter Linkziel steht **In neuem Tab öffnen** – für Buttons, verlinkte Bilder, Symbole, Symbolkarten und Aufruf-Bänder. Im Text zuerst den Link über die Werkzeugleiste öffnen; bei Menüeinträgen steht das Häkchen in den aufgeklappten Einstellungen. Ausgewählte Bilder und Galerie-/Logobilder zeigen eine Vorschau mit Entfernen-Funktion. „Bild entfernen“ entfernt das Bild aus dem Inhalt; die Datei bleibt in der Mediathek. Rückgängig stellt die Verwendung wieder her. Design → Hintergrund zeigt auch geerbte Bilder: Entfernen blendet das Bild auf dem aktiven und kleineren Geräten aus, Zurücksetzen stellt die Vererbung wieder her. Bildaustausch erhält Link, Bildunterschrift und eingestellte Größe. Prüfung mit isolierter Kant-IT-Kopie: `npm run test:link-media`, Bericht und Screenshots unter `reports/link-media/`. Datenvertrag und Rückweg: ADR 081 in `docs/ARCHITECTURE.md`.
|
**Links und Bildvorschauen (ab 1.7.3):** Unter Linkziel steht **In neuem Tab öffnen** – für Buttons, verlinkte Bilder, Symbole, Symbolkarten und Aufruf-Bänder. Im Text zuerst den Link über die Werkzeugleiste öffnen; bei Menüeinträgen steht das Häkchen in den aufgeklappten Einstellungen. Ausgewählte Bilder und Galerie-/Logobilder zeigen eine Vorschau mit Entfernen-Funktion. „Bild entfernen“ entfernt das Bild aus dem Inhalt; die Datei bleibt in der Mediathek. Rückgängig stellt die Verwendung wieder her. Design → Hintergrund zeigt auch geerbte Bilder: Entfernen blendet das Bild auf dem aktiven und kleineren Geräten aus, Zurücksetzen stellt die Vererbung wieder her. Bildaustausch erhält Link, Bildunterschrift und eingestellte Größe. Prüfung mit isolierter Kant-IT-Kopie: `npm run test:link-media`, Bericht und Screenshots unter `reports/link-media/`. Datenvertrag und Rückweg: ADR 081 in `docs/ARCHITECTURE.md`.
|
||||||
|
|
||||||
**Symbol am Button (Schema v25):** Button auswählen → Inhalt → **Symbol**. Die Kacheln zeigen jedes Symbol als Vorschau, geordnet nach Pfeilen, Kontakt und Allgemein; „Kein Symbol“ steht vorn. Darunter **Symbol steht: Vor dem Text / Nach dem Text**. Ohne eigene Wahl trägt ein Button wie bisher den schrägen Pfeil ↗. Die Kacheln bedienen sich auch mit den Pfeiltasten. Symbol und Symbolkarte wählen ihr Symbol mit derselben Auswahl. Prüfung mit isolierter Kant-IT-Kopie: `npm run test:button-icons`, Screenshots unter `reports/button-icons/`. Datenvertrag: ADR 108.
|
**Symbole (ab 1.25.0):** Button, Symbol oder Symbolkarte auswählen → Inhalt → **Symbol** → **Ändern …**. Das Fenster zeigt 98 Symbole in sieben Kategorien, mit Namen; Pfeiltasten und Escape funktionieren, beim Button gibt es „Kein Symbol“. Am Button außerdem **Stellung** (vor/nach dem Text), **Beim Überfahren** (Ruhig, Rückt, Erscheint) und **Nur das Symbol zeigen** (quadratischer Knopf, die Beschriftung bleibt für Vorlesesoftware). Farbe, Fläche, Größe, Ecken, Drehung und Abstand stehen unter **Design → Symbol**, je Gerät und beim Überfahren. Ohne eigene Wahl trägt ein Button wie bisher den schrägen Pfeil. Neue Symbole kommen nach `public/assets/icons.js`, danach `npm run build:icons`. Prüfung mit isolierter Kant-IT-Kopie: `npm run test:button-icons`, Screenshots unter `reports/button-icons/`. Datenvertrag: ADR 108 und 109.
|
||||||
|
|
||||||
Blöcke per Drag-and-drop verschieben: auf einen Container ziehen, um hineinzuverschieben; auf einen Text-/Bild-/Linkblock ziehen, um davor einzufügen. Alternativ unten am Strukturbaum die Schaltflächen für Auf/Ab, Verschachteln und Herausheben verwenden. Duplizieren vergibt neue Block-IDs und wandelt eine kopierte H1 in H2 um. Löschen lässt sich rückgängig machen. Jede speicherbare Seite benötigt genau eine H1.
|
Blöcke per Drag-and-drop verschieben: auf einen Container ziehen, um hineinzuverschieben; auf einen Text-/Bild-/Linkblock ziehen, um davor einzufügen. Alternativ unten am Strukturbaum die Schaltflächen für Auf/Ab, Verschachteln und Herausheben verwenden. Duplizieren vergibt neue Block-IDs und wandelt eine kopierte H1 in H2 um. Löschen lässt sich rückgängig machen. Jede speicherbare Seite benötigt genau eine H1.
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,20 @@
|
|||||||
|
|
||||||
Stand: 15.09.2026. Arbeitstitel KantSites; Produktname offen.
|
Stand: 15.09.2026. Arbeitstitel KantSites; Produktname offen.
|
||||||
|
|
||||||
|
## ADR 109 – Symbole durchdacht: 98 im Satz, Auswahl im Fenster, Gestaltung unter Design (07.10.2026)
|
||||||
|
|
||||||
|
Umut zu 1.24.0: „Du wolltest doch mehr Icons machen oder nicht? Und nicht alles so dort auflisten, erst onklick einblenden oder Modal […] Dann brauche ich Icon-Einstellungen ausgefüllt, extra Farbe usw. Mach es doch durchdacht.“ Zu Recht: 33 Symbole waren zu wenig, die Kachelwand drückte das Panel nach unten, und Farbe oder Größe des Symbols gingen nur über eigenes CSS.
|
||||||
|
|
||||||
|
**Symbolsatz.** 98 Symbole in sieben Kategorien (Pfeile, Kontakt & Ort, Handel & Geld, Dateien & Medien, Personen & Sicherheit, Haus & Handwerk, Allgemein). Neu sind unter anderem Warenkorb, Euro, Lieferung (LKW), Paket, Karte, Navigation, Hochladen, Drucken, Teilen, Kamera, Schloss, Auszeichnung, Werkzeug, Hammer, Malerrolle, Baum, Blatt, Sonne, Wasser, Strom, Solar, Auto und Warnung – also auch das LKW-Symbol, das bei Hüfner fehlte. Weiter keine Markenlogos. Eine Quelle: `public/assets/icons.js`. `npm run build:icons` schreibt daraus `icon.html.twig` und `DocumentValidator::ICONS`, `tests/icons.test.mjs` prüft die Gleichheit.
|
||||||
|
|
||||||
|
**Auswahl im Fenster.** Im Panel steht nur das gewählte Symbol mit Vorschau, Namen und „Ändern …“. Das öffnet ein modales Fenster (`<dialog>`): Kategorien als Reiter links, Symbole mit Namen als Kacheln, Öffnen bei der Kategorie des gewählten Symbols mit Fokus darauf, Pfeiltasten in Reitern und Raster, Escape oder Klick auf den Rand schließt ohne Änderung, der Fokus kehrt zurück. Beim Button gibt es dort „Kein Symbol“. Symbol und Symbolkarte nutzen dasselbe Fenster.
|
||||||
|
|
||||||
|
**Gestaltung unter Design (keine Schemaänderung).** Neue Gruppe „Symbol“ im Style-Schema mit dem Ziel `icon`: Symbolfarbe, Fläche hinter dem Symbol (beide auch beim Überfahren), Symbolgröße, Innenabstand und Ecken der Fläche, Drehung (auch beim Überfahren), dazu Abstand zum Text am Button selbst. Damit gelten Gerätewerte, Vererbung, Hover, „Gestaltung kopieren“ und die Farbpalette wie überall. Der Compiler schreibt `#id .button-icon{…}` und `#id:hover .button-icon,#id:focus-visible .button-icon{…}`. Größe und Abstand laufen über `--icon-size` und `--icon-gap`, die `site.css` mit 1,15 em und 0,65 em als Vorgabe liest – ohne Angabe sieht alles aus wie vorher. Die Gruppe erscheint nur bei Buttons mit Symbol und ist dort aufgeklappt; ein Knopf im Inhalt führt hin.
|
||||||
|
|
||||||
|
**Verhalten im Inhalt (Schema v26).** `iconMotion`: `none` (ruhig), `slide` (rückt beim Überfahren in Leserichtung) oder `reveal` (erscheint erst beim Überfahren). `iconOnly`: ein Button nur aus Symbol; die Beschriftung steht dann als `.button-label` ohne Breite im Link – sie bleibt der zugängliche Name und hält die Zeilenhöhe, sodass der Knopf quadratisch und genauso hoch ist wie Text-Buttons daneben (zuerst mit `aspect-ratio` versucht: das zog die Höhe auf die Breite herunter). `iconOnly` ohne Symbol lehnt der Validator ab, und die Wahl „Kein Symbol“ nimmt es im Editor mit weg. Bewegungen folgen „Bewegung reduzieren“.
|
||||||
|
|
||||||
|
**Geprüft:** PHPUnit vollständig (616), darunter `testAButtonIconMovesOnHoverOrStandsAlone`, `testButtonIconStylesTargetTheIconPerDeviceAndOnHover`, `testAButtonWithoutAnIconCannotBeIconOnly` und neue Ablehnungsfälle. `npm test` (38). `npm run test:button-icons` im Browser: Panel ohne Kachelwand, Fenster mit sieben Kategorien und axe ohne Befund, Escape, Pfeiltasten, Stellung, Bewegung mit Rückgängig, nur Symbol, Design-Gruppe mit allen Werten und wirksamer Größe, Speichern als v26 und Neuladen, gemessenes Aussehen (Flächenfarbe, Rundung, Drehung beim Überfahren, quadratischer Nur-Symbol-Button). `test:buttons` und `test:browser` (Hüfner, Entwicklungsmodus) grün, HTML 148 KB unter dem Budget.
|
||||||
|
|
||||||
## ADR 108 – Das Symbol am Button ist wählbar, ausgewählt wird mit Kacheln (07.10.2026)
|
## ADR 108 – Das Symbol am Button ist wählbar, ausgewählt wird mit Kacheln (07.10.2026)
|
||||||
|
|
||||||
Umut: „Buttons mit Icon? Geht das, wenn ja wo? Und einfacher Button ist ein Pfeil, wie ändere ich das?“ – und dann: „Ein Satz Symbole, die man so alltäglich brauchen könnte, und schöne Vorschau-Auswahl, bitte kein Dropdown mit Suche.“ Bis hierher hängte `block.html.twig` jedem Button fest `arrow-up-right` an, auch dem Textlink. Ändern ging nur über eigenes CSS (Hüfner blendet den Pfeil so aus, EB Solar drehte ihn).
|
Umut: „Buttons mit Icon? Geht das, wenn ja wo? Und einfacher Button ist ein Pfeil, wie ändere ich das?“ – und dann: „Ein Satz Symbole, die man so alltäglich brauchen könnte, und schöne Vorschau-Auswahl, bitte kein Dropdown mit Suche.“ Bis hierher hängte `block.html.twig` jedem Button fest `arrow-up-right` an, auch dem Textlink. Ändern ging nur über eigenes CSS (Hüfner blendet den Pfeil so aus, EB Solar drehte ihn).
|
||||||
|
|||||||
@@ -1,5 +1,22 @@
|
|||||||
# Projektstatus
|
# Projektstatus
|
||||||
|
|
||||||
|
## Fassung 1.25.0: 98 Symbole, Auswahlfenster, Symbol-Gestaltung unter Design (07.10.2026)
|
||||||
|
|
||||||
|
**Anlass:** Umut zu 1.24.0: „Du wolltest doch mehr Icons machen oder nicht? Und nicht alles so dort auflisten, erst onklick einblenden oder Modal das gefällt mir nicht. Dann brauche ich Icon-Einstellungen ausgefüllt, extra Farbe usw. Mach es doch durchdacht.“
|
||||||
|
|
||||||
|
**Neu (ADR 109):**
|
||||||
|
|
||||||
|
- **98 Symbole** statt 33 in sieben Kategorien, darunter Warenkorb, Euro, LKW, Paket, Karte, Kamera, Schloss, Werkzeug, Malerrolle, Baum, Solar, Strom, Auto, Warnung. Quelle `public/assets/icons.js`, `npm run build:icons` schreibt Twig und Validator.
|
||||||
|
- **Auswahlfenster:** Im Panel nur das gewählte Symbol mit „Ändern …“. Das Fenster zeigt Kategorien als Reiter und Symbole mit Namen. Gilt für Button, Symbol und Symbolkarte.
|
||||||
|
- **Design → Symbol** (je Gerät, mit Hover): Farbe, Fläche dahinter, Größe, Innenabstand und Ecken der Fläche, Drehung, Abstand zum Text.
|
||||||
|
- **Inhalt (Schema v26):** Stellung, „Beim Überfahren: Ruhig / Rückt / Erscheint“, „Nur das Symbol zeigen“ (quadratischer Knopf, Beschriftung bleibt für Vorlesesoftware).
|
||||||
|
|
||||||
|
**Geprüft:** PHPUnit vollständig (616) grün, `npm test` (38) grün, `npm run test:button-icons` (Editor im Browser, inkl. axe im Fenster und gemessenem Aussehen), `test:buttons` grün, `test:browser` auf Hüfner (Entwicklungsmodus, Port 8102) grün mit HTML 148 KB. php-cs-fixer und `lint:twig` ohne Befund. Nicht geprüft: Lighthouse.
|
||||||
|
|
||||||
|
**Bekannt:** Dokumente mit Bewegung oder „nur Symbol“ brauchen einen Kern ab 1.25.0 (Schema v26). Die Gestaltungswerte (Farbe usw.) brauchen kein neues Schema, werden von älteren Kernen aber abgelehnt (unbekannte Stileigenschaft) – Installationen zuerst aktualisieren. `test:browser` verlangt den Entwicklungsmodus (Kopf `x-kant-queries`), nicht wie in der README beschrieben den Produktionsmodus.
|
||||||
|
|
||||||
|
**Nächster Schritt:** Push, Release 1.25.0, Installationen aktualisieren; bei Hüfner das LKW-Symbol aus „Eigenes HTML“ durch den Baustein „Symbol“ ersetzen.
|
||||||
|
|
||||||
## Fassung 1.24.0: Symbol am Button wählbar, Kachelauswahl für Symbole (07.10.2026)
|
## Fassung 1.24.0: Symbol am Button wählbar, Kachelauswahl für Symbole (07.10.2026)
|
||||||
|
|
||||||
**Anlass:** Umut: „Buttons mit Icon? Geht das, wenn ja wo? Und einfacher Button ist ein Pfeil, wie ändere ich das?“ – dann: „Ja bitte umsetzen. Ein Satz Symbole, die man so alltäglich brauchen könnte, und schöne Vorschau-Auswahl, bitte kein Dropdown mit Suche usw.“
|
**Anlass:** Umut: „Buttons mit Icon? Geht das, wenn ja wo? Und einfacher Button ist ein Pfeil, wie ändere ich das?“ – dann: „Ja bitte umsetzen. Ein Satz Symbole, die man so alltäglich brauchen könnte, und schöne Vorschau-Auswahl, bitte kein Dropdown mit Suche usw.“
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"build:buttons": "node scripts/build-buttons.mjs",
|
"build:buttons": "node scripts/build-buttons.mjs",
|
||||||
"test:buttons": "node scripts/button-check.mjs",
|
"test:buttons": "node scripts/button-check.mjs",
|
||||||
"test:button-icons": "node scripts/button-icon-check.mjs",
|
"test:button-icons": "node scripts/button-icon-check.mjs",
|
||||||
|
"build:icons": "node scripts/build-icons.mjs",
|
||||||
"test:navigation-ux": "node scripts/navigation-ux-check.mjs",
|
"test:navigation-ux": "node scripts/navigation-ux-check.mjs",
|
||||||
"test:responsive-navigation": "node scripts/responsive-navigation-check.mjs",
|
"test:responsive-navigation": "node scripts/responsive-navigation-check.mjs",
|
||||||
"build:design": "node scripts/build-design.mjs",
|
"build:design": "node scripts/build-design.mjs",
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
.button-designer{padding:16px 0;border-block:1px solid #dce4dc;margin-block:16px}.button-designer h3{margin:0 0 12px;font-size:15px}.button-state-previews{display:grid;gap:12px;padding:16px;background:#fff;border:1px solid #dce4dc;border-radius:12px;margin-bottom:16px}.button-state{display:grid;justify-items:start;gap:7px;min-width:0}.button-state>.field-help{margin:0}.button-preset-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin:12px 0 16px}.button-preset{display:grid;justify-items:center;align-content:center;gap:9px;min-width:0;padding:12px 6px;background:#fff;border:1px solid #dce4dc;border-radius:10px;color:#203b34;cursor:pointer}.button-preset[aria-pressed=true]{border-color:#2c6b4f;box-shadow:inset 0 0 0 1px #2c6b4f;background:#f1f6ef}.button-preset:focus-visible{outline:3px solid #b55b12;outline-offset:2px}.button-preset .cms-button{font-size:12px!important;padding:6px 10px;min-height:34px;pointer-events:none}.button-preset-label{font-size:11px;line-height:1.4;text-align:center}.button-designer select{width:100%;margin-bottom:12px}.button-defaults{margin-block:24px;padding:24px;border:1px solid #dce4dc;border-radius:14px;min-width:0}.button-defaults legend{font-size:20px;font-weight:700;padding:0 8px}.button-defaults-layout{display:grid;grid-template-columns:minmax(0,1fr) minmax(260px,.75fr);gap:28px}.button-defaults .fields{align-content:start}.button-defaults .button-preview-column{min-width:0}.button-defaults .button-preview-column h3{font-size:15px;margin:0 0 12px}.button-defaults .color-field{margin:0}.button-defaults .color-trigger{width:100%}@media(max-width:900px){.button-defaults-layout{grid-template-columns:1fr}.button-defaults{padding:16px}}.icon-picker{margin:4px 0 16px}.icon-picker-label{margin:0 0 6px;font-size:13px;font-weight:700;color:#203b34}.icon-group-title{margin:10px 0 6px;font-size:11px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;color:#5b6b64}.icon-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(40px,1fr));gap:6px}.icon-tile{display:grid;place-items:center;aspect-ratio:1;min-height:40px;padding:0;background:#fff;border:1px solid #dce4dc;border-radius:9px;color:#203b34;cursor:pointer;transition:border-color .15s,background-color .15s}.icon-tile svg{width:22px;height:22px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}.icon-tile:hover{border-color:#8fb39f;background:#f7faf5}.icon-tile[aria-pressed=true]{border-color:#2c6b4f;box-shadow:inset 0 0 0 1px #2c6b4f;background:#e8f2e4;color:#1d4d37}.icon-tile:focus-visible{outline:3px solid #b55b12;outline-offset:2px}.icon-tile-none{grid-column:span 2;aspect-ratio:auto}.icon-tile-none::after{content:"Kein Symbol";font-size:12px;font-weight:600}.icon-tile-empty{display:none}.icon-position{margin:0 0 16px}.segmented{display:grid;grid-template-columns:1fr 1fr;border:1px solid #dce4dc;border-radius:9px;overflow:hidden}.segmented button{min-height:38px;border:0;background:#fff;color:#203b34;font-size:13px;font-weight:600;cursor:pointer}.segmented button+button{border-left:1px solid #dce4dc}.segmented button[aria-pressed=true]{background:#2c6b4f;color:#fff}.segmented button:focus-visible{outline:3px solid #b55b12;outline-offset:-3px}
|
.button-designer{padding:16px 0;border-block:1px solid #dce4dc;margin-block:16px}.button-designer h3{margin:0 0 12px;font-size:15px}.button-state-previews{display:grid;gap:12px;padding:16px;background:#fff;border:1px solid #dce4dc;border-radius:12px;margin-bottom:16px}.button-state{display:grid;justify-items:start;gap:7px;min-width:0}.button-state>.field-help{margin:0}.button-preset-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin:12px 0 16px}.button-preset{display:grid;justify-items:center;align-content:center;gap:9px;min-width:0;padding:12px 6px;background:#fff;border:1px solid #dce4dc;border-radius:10px;color:#203b34;cursor:pointer}.button-preset[aria-pressed=true]{border-color:#2c6b4f;box-shadow:inset 0 0 0 1px #2c6b4f;background:#f1f6ef}.button-preset:focus-visible{outline:3px solid #b55b12;outline-offset:2px}.button-preset .cms-button{font-size:12px!important;padding:6px 10px;min-height:34px;pointer-events:none}.button-preset-label{font-size:11px;line-height:1.4;text-align:center}.button-designer select{width:100%;margin-bottom:12px}.button-defaults{margin-block:24px;padding:24px;border:1px solid #dce4dc;border-radius:14px;min-width:0}.button-defaults legend{font-size:20px;font-weight:700;padding:0 8px}.button-defaults-layout{display:grid;grid-template-columns:minmax(0,1fr) minmax(260px,.75fr);gap:28px}.button-defaults .fields{align-content:start}.button-defaults .button-preview-column{min-width:0}.button-defaults .button-preview-column h3{font-size:15px;margin:0 0 12px}.button-defaults .color-field{margin:0}.button-defaults .color-trigger{width:100%}@media(max-width:900px){.button-defaults-layout{grid-template-columns:1fr}.button-defaults{padding:16px}}.icon-picker-label{margin:0 0 6px;font-size:13px;font-weight:700;color:#203b34}.icon-field{margin:16px 0 14px}.icon-field-row{display:grid;grid-template-columns:44px minmax(0,1fr) auto;align-items:center;gap:10px;padding:8px;border:1px solid #dce4dc;border-radius:10px;background:#fff}.icon-field-preview{display:grid;place-items:center;width:44px;height:44px;border-radius:8px;background:#eef5ea;color:#1d4d37}.icon-field-preview svg{width:24px;height:24px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}.icon-field-preview.is-empty{background:repeating-linear-gradient(135deg,#f3f5f2 0 6px,#fff 6px 12px);border:1px dashed #c5d2c7}.icon-field-name{font-size:14px;font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.icon-field-change{min-height:36px;white-space:nowrap}.icon-setting{margin:0 0 14px}.segmented{display:grid;border:1px solid #dce4dc;border-radius:9px;overflow:hidden}.segmented button{min-height:38px;border:0;background:#fff;color:#203b34;font-size:13px;font-weight:600;cursor:pointer}.segmented button+button{border-left:1px solid #dce4dc}.segmented button[aria-pressed=true]{background:#2c6b4f;color:#fff}.segmented button:focus-visible{outline:3px solid #b55b12;outline-offset:-3px}.icon-design-link{display:block;margin:4px 0 8px;font-weight:600}dialog.icon-dialog{max-width:860px;width:calc(100% - 40px);padding:0;overflow:hidden}.icon-dialog::backdrop{background:#1b2a2466}.icon-dialog-head{display:flex;align-items:center;justify-content:space-between;padding:18px 22px;border-bottom:1px solid #dce4dc}.icon-dialog-head h2{margin:0;font-size:19px}.icon-dialog-close{display:grid;place-items:center;width:40px;height:40px;border:0;border-radius:8px;background:transparent;color:#203b34;cursor:pointer}.icon-dialog-close:hover{background:#eef3ec}.icon-dialog-close svg{width:22px;height:22px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round}.icon-dialog-body{display:grid;grid-template-columns:200px minmax(0,1fr);height:min(520px,calc(100vh - 220px))}.icon-dialog-tabs{display:flex;flex-direction:column;gap:2px;padding:12px;border-right:1px solid #dce4dc;background:#f7faf5;overflow:auto}.icon-dialog-tab{text-align:left;padding:10px 12px;border:0;border-radius:8px;background:transparent;color:#203b34;font-size:14px;font-weight:600;cursor:pointer}.icon-dialog-tab:hover{background:#eaf1e6}.icon-dialog-tab[aria-selected=true]{background:#2c6b4f;color:#fff}.icon-dialog-tab:focus-visible,.icon-choice:focus-visible,.icon-dialog-close:focus-visible{outline:3px solid #b55b12;outline-offset:2px}.icon-dialog-panel{padding:16px;overflow:auto}.icon-dialog-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(92px,1fr));gap:8px}.icon-choice{display:grid;justify-items:center;align-content:center;gap:8px;min-height:92px;padding:12px 6px;border:1px solid #dce4dc;border-radius:10px;background:#fff;color:#203b34;cursor:pointer;transition:border-color .15s,background-color .15s}.icon-choice svg{width:28px;height:28px;fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}.icon-choice-name{font-size:12px;line-height:1.3;text-align:center}.icon-choice:hover{border-color:#8fb39f;background:#f7faf5}.icon-choice[aria-pLine truncated
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ export function repairSchema(document) {
|
|||||||
if ('collapse' in props) upgradeDocument(document, 22);
|
if ('collapse' in props) upgradeDocument(document, 22);
|
||||||
if ('faq' in props) upgradeDocument(document, 23);
|
if ('faq' in props) upgradeDocument(document, 23);
|
||||||
if (block.type === 'button' && ('icon' in props || 'iconPosition' in props)) upgradeDocument(document, 25);
|
if (block.type === 'button' && ('icon' in props || 'iconPosition' in props)) upgradeDocument(document, 25);
|
||||||
|
if (block.type === 'button' && ('iconMotion' in props || 'iconOnly' in props)) upgradeDocument(document, 26);
|
||||||
walk(block.children);
|
walk(block.children);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -343,7 +344,7 @@ export const devices = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobil' }
|
|||||||
// blieb bei 12 stehen, während Bausteine schon v13 bis v15 verlangten: in einem aktuellen Dokument
|
// blieb bei 12 stehen, während Bausteine schon v13 bis v15 verlangten: in einem aktuellen Dokument
|
||||||
// liess sich dann gar nichts mehr einfügen, und ein Symbol, ein Slider oder ein externer Inhalt
|
// liess sich dann gar nichts mehr einfügen, und ein Symbol, ein Slider oder ein externer Inhalt
|
||||||
// nirgends. Eine Zahl, an einer Stelle.
|
// nirgends. Eine Zahl, an einer Stelle.
|
||||||
export const SCHEMA_VERSION = 25;
|
export const SCHEMA_VERSION = 26;
|
||||||
|
|
||||||
export const VISIBILITY = { all: 'Auf allen Geräten', desktop: 'Nur Desktop', tablet: 'Nur Tablet', mobile: 'Nur Mobil', 'hide-desktop': 'Auf Desktop ausblenden', 'hide-tablet': 'Auf Tablet ausblenden', 'hide-mobile': 'Auf Mobil ausblenden', hidden: 'Überall ausblenden' };
|
export const VISIBILITY = { all: 'Auf allen Geräten', desktop: 'Nur Desktop', tablet: 'Nur Tablet', mobile: 'Nur Mobil', 'hide-desktop': 'Auf Desktop ausblenden', 'hide-tablet': 'Auf Tablet ausblenden', 'hide-mobile': 'Auf Mobil ausblenden', hidden: 'Überall ausblenden' };
|
||||||
|
|
||||||
@@ -685,6 +686,7 @@ export function relevantProperty(block, key, property) {
|
|||||||
const container = isContainer(block);
|
const container = isContainer(block);
|
||||||
if (property.group === 'slider') return block.type === 'slider';
|
if (property.group === 'slider') return block.type === 'slider';
|
||||||
if (property.group === 'menu') return block.type === 'menu';
|
if (property.group === 'menu') return block.type === 'menu';
|
||||||
|
if (property.group === 'icon') return block.type === 'button' && block.props?.icon !== 'none';
|
||||||
if (property.group === 'image') return ['image', 'gallery', 'logos', 'postlist'].includes(block.type);
|
if (property.group === 'image') return ['image', 'gallery', 'logos', 'postlist'].includes(block.type);
|
||||||
if (property.group === 'text' || key === 'textShadow') return !['image', 'gallery', 'logos', 'spacer', 'divider', 'embed'].includes(block.type);
|
if (property.group === 'text' || key === 'textShadow') return !['image', 'gallery', 'logos', 'spacer', 'divider', 'embed'].includes(block.type);
|
||||||
if (key === 'columnWidths') return block.type === 'row';
|
if (key === 'columnWidths') return block.type === 'row';
|
||||||
|
|||||||
+40
-21
@@ -12,7 +12,7 @@ import { initSliders } from './slider.js';
|
|||||||
import { VISIBILITY, setVisibility, registerPlugins, pluginDefinition, PLUGIN_PRESETS, repairSchema } from './editor-model.js';
|
import { VISIBILITY, setVisibility, registerPlugins, pluginDefinition, PLUGIN_PRESETS, repairSchema } from './editor-model.js';
|
||||||
|
|
||||||
// Symbole, ihre deutschen Namen und die Kachelauswahl. Die Liste spiegelt DocumentValidator::ICONS.
|
// Symbole, ihre deutschen Namen und die Kachelauswahl. Die Liste spiegelt DocumentValidator::ICONS.
|
||||||
import { renderIconPicker, BUTTON_ICON_GROUPS, ALL_ICON_GROUPS } from './icons.js';
|
import { renderIconField } from './icons.js';
|
||||||
const ICON_SIZES = [12, 16, 20, 24, 32, 40, 48, 56, 64, 80, 96, 120, 160, 200, 240];
|
const ICON_SIZES = [12, 16, 20, 24, 32, 40, 48, 56, 64, 80, 96, 120, 160, 200, 240];
|
||||||
// Mirrors SocialProfiles::NETWORKS; the addresses themselves live in the settings.
|
// Mirrors SocialProfiles::NETWORKS; the addresses themselves live in the settings.
|
||||||
const SOCIAL_NETWORKS = { facebook: 'Facebook', instagram: 'Instagram', linkedin: 'LinkedIn', youtube: 'YouTube', tiktok: 'TikTok', x: 'X', threads: 'Threads', pinterest: 'Pinterest', xing: 'Xing', whatsapp: 'WhatsApp', google: 'Google-Unternehmensprofil' };
|
const SOCIAL_NETWORKS = { facebook: 'Facebook', instagram: 'Instagram', linkedin: 'LinkedIn', youtube: 'YouTube', tiktok: 'TikTok', x: 'X', threads: 'Threads', pinterest: 'Pinterest', xing: 'Xing', whatsapp: 'WhatsApp', google: 'Google-Unternehmensprofil' };
|
||||||
@@ -319,6 +319,7 @@ function renderContent(block, panel) {
|
|||||||
if (key === 'newTab') upgradeDocument(doc, 20);
|
if (key === 'newTab') upgradeDocument(doc, 20);
|
||||||
if (key === 'collapse') upgradeDocument(doc, 22);
|
if (key === 'collapse') upgradeDocument(doc, 22);
|
||||||
if (key === 'faq') upgradeDocument(doc, 23);
|
if (key === 'faq') upgradeDocument(doc, 23);
|
||||||
|
if (key === 'iconOnly') upgradeDocument(doc, 26);
|
||||||
if (current.type === 'image' && key === 'src') { delete current.props.sources; delete current.props.mediaId; }
|
if (current.type === 'image' && key === 'src') { delete current.props.sources; delete current.props.mediaId; }
|
||||||
}, options.choices ? null : `${selected || 'page'}:${key}`);
|
}, options.choices ? null : `${selected || 'page'}:${key}`);
|
||||||
if (key === 'src') imagePreview?.update({ src: input.value });
|
if (key === 'src') imagePreview?.update({ src: input.value });
|
||||||
@@ -346,21 +347,37 @@ function renderContent(block, panel) {
|
|||||||
if (key === 'href' && !options.plugin) field('In neuem Tab öffnen', 'newTab', block.props.newTab === true, { checkbox: true, container });
|
if (key === 'href' && !options.plugin) field('In neuem Tab öffnen', 'newTab', block.props.newTab === true, { checkbox: true, container });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Symbolwahl als Kacheln mit Vorschau. Nach der Wahl wird das Panel neu gezeichnet, damit die
|
// Eine Eigenschaft setzen, die kein Textfeld ist (Symbol, Stellung, Bewegung). Danach wird das
|
||||||
// Markierung stimmt, und der Fokus bleibt auf der gewählten Kachel.
|
// Panel neu gezeichnet, und der Fokus kehrt zu dem Bedienelement zurück, das sie gesetzt hat.
|
||||||
const choose = (key, next, version) => {
|
const choose = (key, next, version, focus) => {
|
||||||
history.change(doc => {
|
history.change(doc => {
|
||||||
const current = locate(doc.blocks, selected)?.block;
|
const current = locate(doc.blocks, selected)?.block;
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
(current.props ??= {})[key] = next;
|
(current.props ??= {})[key] = next;
|
||||||
|
// Ohne Symbol gibt es keinen Button nur aus Symbol - sonst stünde ein leerer Knopf da.
|
||||||
|
if (key === 'icon' && next === 'none') delete current.props.iconOnly;
|
||||||
if (version) upgradeDocument(doc, version);
|
if (version) upgradeDocument(doc, version);
|
||||||
}, null);
|
}, null);
|
||||||
renderProperties(); renderTree(); controls(); schedulePreview();
|
renderProperties(); renderTree(); controls(); schedulePreview();
|
||||||
document.querySelector(key === 'iconPosition' ? `.icon-position [data-position="${next}"]` : `.icon-tile[data-icon="${next}"]`)?.focus({ preventScroll: true });
|
document.querySelector(focus)?.focus({ preventScroll: true });
|
||||||
};
|
};
|
||||||
const iconField = (label, value, groups, options = {}) => panel.append(renderIconPicker({
|
const iconField = (label, value, options = {}) => panel.append(renderIconField({
|
||||||
label, value, groups, none: options.none, onChoose: name => choose('icon', name, options.version),
|
label, value, none: options.none, onChoose: name => choose('icon', name, options.version, '.icon-field-change'),
|
||||||
}));
|
}));
|
||||||
|
// Zwei bis drei Möglichkeiten nebeneinander statt einer Auswahlliste.
|
||||||
|
const segmented = (label, key, current, options, version) => {
|
||||||
|
const wrap = document.createElement('div'); wrap.className = 'icon-setting';
|
||||||
|
const caption = document.createElement('p'); caption.className = 'icon-picker-label'; caption.id = `segmented-${key}`; caption.textContent = label;
|
||||||
|
const group = document.createElement('div'); group.className = 'segmented'; group.setAttribute('role', 'group'); group.setAttribute('aria-labelledby', caption.id);
|
||||||
|
group.style.gridTemplateColumns = `repeat(${options.length}, 1fr)`;
|
||||||
|
for (const [value, text] of options) {
|
||||||
|
const option = document.createElement('button'); option.type = 'button'; option.dataset[key] = value; option.textContent = text;
|
||||||
|
option.setAttribute('aria-pressed', String(value === current));
|
||||||
|
option.addEventListener('click', () => { if (value !== current) choose(key, value, version, `[data-${key.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`)}="${value}"]`); });
|
||||||
|
group.append(option);
|
||||||
|
}
|
||||||
|
wrap.append(caption, group); panel.append(wrap);
|
||||||
|
};
|
||||||
if (!block) {
|
if (!block) {
|
||||||
field('Seitentitel', 'title', history.document.title, { maxLength: 160 });
|
field('Seitentitel', 'title', history.document.title, { maxLength: 160 });
|
||||||
field('Beschreibung für Suchmaschinen', 'description', history.document.description, { multiline: true, maxLength: 320 });
|
field('Beschreibung für Suchmaschinen', 'description', history.document.description, { multiline: true, maxLength: 320 });
|
||||||
@@ -403,19 +420,21 @@ function renderContent(block, panel) {
|
|||||||
if (block.type === 'button') {
|
if (block.type === 'button') {
|
||||||
// Ohne eigene Wahl trägt ein Button den Pfeil, den er vor v25 immer hatte.
|
// Ohne eigene Wahl trägt ein Button den Pfeil, den er vor v25 immer hatte.
|
||||||
const icon = block.props.icon ?? 'arrow-up-right';
|
const icon = block.props.icon ?? 'arrow-up-right';
|
||||||
iconField('Symbol', icon, BUTTON_ICON_GROUPS, { none: 'Kein Symbol', version: 25 });
|
iconField('Symbol', icon, { none: 'Kein Symbol', version: 25 });
|
||||||
if (icon !== 'none') {
|
if (icon !== 'none') {
|
||||||
const position = block.props.iconPosition === 'start' ? 'start' : 'end';
|
segmented('Stellung', 'iconPosition', block.props.iconPosition === 'start' ? 'start' : 'end', [['start', 'Vor dem Text'], ['end', 'Nach dem Text']], 25);
|
||||||
const wrap = document.createElement('div'); wrap.className = 'icon-position';
|
segmented('Beim Überfahren', 'iconMotion', block.props.iconMotion ?? 'none', [['none', 'Ruhig'], ['slide', 'Rückt'], ['reveal', 'Erscheint']], 26);
|
||||||
const caption = document.createElement('p'); caption.className = 'icon-picker-label'; caption.id = 'icon-position-label'; caption.textContent = 'Symbol steht';
|
field('Nur das Symbol zeigen', 'iconOnly', block.props.iconOnly === true, { checkbox: true, help: 'Ein runder oder eckiger Knopf nur mit Symbol. Die Beschriftung bleibt als Name für Vorlesesoftware erhalten – bitte trotzdem sinnvoll ausfüllen.' });
|
||||||
const group = document.createElement('div'); group.className = 'segmented'; group.setAttribute('role', 'group'); group.setAttribute('aria-labelledby', caption.id);
|
// Farbe, Fläche, Größe und Drehung sind Gestaltung: sie gelten je Gerät und beim Überfahren,
|
||||||
for (const [value, text] of [['start', 'Vor dem Text'], ['end', 'Nach dem Text']]) {
|
// wie alle anderen Werte unter Design. Ein Knopf führt direkt dorthin.
|
||||||
const option = document.createElement('button'); option.type = 'button'; option.dataset.position = value; option.textContent = text;
|
const design = document.createElement('button'); design.type = 'button'; design.className = 'text-button icon-design-link';
|
||||||
option.setAttribute('aria-pressed', String(value === position));
|
design.textContent = 'Farbe, Größe und Fläche des Symbols → Design';
|
||||||
option.addEventListener('click', () => { if (value !== position) choose('iconPosition', value, 25); });
|
design.addEventListener('click', () => {
|
||||||
group.append(option);
|
document.querySelector('[data-panel="design"]')?.click();
|
||||||
}
|
const group = document.querySelector('.design-group[data-group="icon"]');
|
||||||
wrap.append(caption, group); panel.append(wrap);
|
if (group) { group.open = true; group.scrollIntoView({ block: 'start' }); group.querySelector('summary')?.focus({ preventScroll: true }); }
|
||||||
|
});
|
||||||
|
panel.append(design);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (block.type === 'button' && designSchema) buttonControls(block, panel);
|
if (block.type === 'button' && designSchema) buttonControls(block, panel);
|
||||||
@@ -432,7 +451,7 @@ function renderContent(block, panel) {
|
|||||||
}
|
}
|
||||||
if (block.type === 'heading') field('Überschriftenebene', 'level', block.props.level, { number: true, choices: { 1: 'H1 · Hauptüberschrift', 2: 'H2 · Abschnitt', 3: 'H3 · Unterabschnitt', 4: 'H4', 5: 'H5', 6: 'H6' }, help: 'Jede Seite benötigt genau eine H1.' });
|
if (block.type === 'heading') field('Überschriftenebene', 'level', block.props.level, { number: true, choices: { 1: 'H1 · Hauptüberschrift', 2: 'H2 · Abschnitt', 3: 'H3 · Unterabschnitt', 4: 'H4', 5: 'H5', 6: 'H6' }, help: 'Jede Seite benötigt genau eine H1.' });
|
||||||
if (block.type === 'iconcard') {
|
if (block.type === 'iconcard') {
|
||||||
iconField('Symbol', block.props.icon, ALL_ICON_GROUPS);
|
iconField('Symbol', block.props.icon);
|
||||||
field('Titel', 'title', block.props.title, { maxLength: 120 });
|
field('Titel', 'title', block.props.title, { maxLength: 120 });
|
||||||
field('Text', 'text', block.props.text, { multiline: true, maxLength: 600 });
|
field('Text', 'text', block.props.text, { multiline: true, maxLength: 600 });
|
||||||
field('Linkziel', 'href', block.props.href || '', { help: 'Optional. Lokaler Pfad, Sprungmarke oder HTTPS-Adresse, Telefon (tel:+49…) oder E-Mail (mailto:…) – oder ein PDF.' });
|
field('Linkziel', 'href', block.props.href || '', { help: 'Optional. Lokaler Pfad, Sprungmarke oder HTTPS-Adresse, Telefon (tel:+49…) oder E-Mail (mailto:…) – oder ein PDF.' });
|
||||||
@@ -467,7 +486,7 @@ function renderContent(block, panel) {
|
|||||||
}
|
}
|
||||||
if (block.type === 'partialref') field('Sektion', 'partial', block.props.partial, { ...pick('sections', block.props.partial), help: 'Die Sektion wird verknüpft, nicht kopiert: eine Änderung dort wirkt auf allen Seiten, die sie einbinden. Angelegt und bearbeitet wird sie unter „Bereiche“.' });
|
if (block.type === 'partialref') field('Sektion', 'partial', block.props.partial, { ...pick('sections', block.props.partial), help: 'Die Sektion wird verknüpft, nicht kopiert: eine Änderung dort wirkt auf allen Seiten, die sie einbinden. Angelegt und bearbeitet wird sie unter „Bereiche“.' });
|
||||||
if (block.type === 'icon') {
|
if (block.type === 'icon') {
|
||||||
iconField('Symbol', block.props.icon, ALL_ICON_GROUPS);
|
iconField('Symbol', block.props.icon);
|
||||||
// number, so the value is stored as a number: the server wants an int, not "48".
|
// number, so the value is stored as a number: the server wants an int, not "48".
|
||||||
field('Größe', 'size', String(block.props.size), { number: true, choices: Object.fromEntries(ICON_SIZES.map(size => [String(size), `${size} px`])), help: 'Feste Stufen, weil die Größe eine Klasse ist – ein freier Wert hätte keine und das Symbol wäre unsichtbar.' });
|
field('Größe', 'size', String(block.props.size), { number: true, choices: Object.fromEntries(ICON_SIZES.map(size => [String(size), `${size} px`])), help: 'Feste Stufen, weil die Größe eine Klasse ist – ein freier Wert hätte keine und das Symbol wäre unsichtbar.' });
|
||||||
field('Nur Dekoration', 'decorative', block.props.decorative, { checkbox: true, help: 'Dekorative Symbole werden Screenreadern verschwiegen. Trägt das Symbol eine Aussage, nimm das Häkchen weg und beschrifte es.' });
|
field('Nur Dekoration', 'decorative', block.props.decorative, { checkbox: true, help: 'Dekorative Symbole werden Screenreadern verschwiegen. Trägt das Symbol eine Aussage, nimm das Häkchen weg und beschrifte es.' });
|
||||||
|
|||||||
+243
-109
@@ -1,62 +1,146 @@
|
|||||||
// Die Zeichnungen der Symbole für die Auswahl im Editor. Die Website zeichnet dieselben Pfade aus
|
// Der Symbolsatz: Zeichnung, deutscher Name und Kategorie. Die Website zeichnet dieselben Pfade aus
|
||||||
// templates/blocks/icon.html.twig; ein Test hält beide Listen und DocumentValidator::ICONS gleich.
|
// templates/blocks/icon.html.twig; ein Test hält Twig, diese Datei und DocumentValidator::ICONS gleich.
|
||||||
|
// Alle Symbole sind Linien auf einem 24er-Raster (stroke, keine Füllung) und erben die Textfarbe.
|
||||||
export const ICON_PATHS = {
|
export const ICON_PATHS = {
|
||||||
app: 'M7 2h10a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Zm4 17h2',
|
// Pfeile und Navigation
|
||||||
web: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 0c2.5 2.4 3.8 5.4 3.8 9s-1.3 6.6-3.8 9m0-18C9.5 5.4 8.2 8.4 8.2 12s1.3 6.6 3.8 9M3.2 9h17.6M3.2 15h17.6',
|
'arrow-up-right': 'M6.5 17.5 17.5 6.5M8.5 6.5h9v9',
|
||||||
design: 'M4 20h16M6 16 18 4l3 3L9 19l-4 1Z',
|
|
||||||
network: 'M5 3h14v5H5Zm0 13h14v5H5Zm7-8v8M8 8v3h8V8',
|
|
||||||
hardware: 'M4 5h16v11H4Zm4 15h8m-4-4v4',
|
|
||||||
shop: 'M4 7h16l-1.4 11.2a2 2 0 0 1-2 1.8H7.4a2 2 0 0 1-2-1.8Zm4 0a4 4 0 0 1 8 0',
|
|
||||||
support: 'M12 3a7 7 0 0 0-7 7v5m14-5a7 7 0 0 0-7-7m7 7v5m-14 0a2 2 0 0 0 2 2h1v-5H5Zm14 0a2 2 0 0 1-2 2h-1v-5h3Z',
|
|
||||||
shield: 'M12 3 5 6v6c0 4.4 2.9 7.9 7 9 4.1-1.1 7-4.6 7-9V6Zm-2.5 9 2 2 4-4.5',
|
|
||||||
clock: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 4v5.5l3.5 2',
|
|
||||||
star: 'm12 3 2.7 5.9 6.3.7-4.7 4.3 1.3 6.1L12 17l-5.6 3 1.3-6.1L3 9.6l6.3-.7Z',
|
|
||||||
phone: 'M6 3h4l2 5-2.5 1.5a12 12 0 0 0 5 5L16 12l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 4 5a2 2 0 0 1 2-2Z',
|
|
||||||
mail: 'M3 6h18v12H3Zm0 1 9 7 9-7',
|
|
||||||
pin: 'M12 21s7-6.3 7-11a7 7 0 1 0-14 0c0 4.7 7 11 7 11Zm0-8.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z',
|
|
||||||
check: 'm4 12.5 5.5 5.5L20 6',
|
|
||||||
people: 'M9.5 11.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7ZM2.5 20v-1c0-3.3 3-5 7-5s7 1.7 7 5v1M16.5 5.3a3.2 3.2 0 0 1 0 6.4M18 14.4c2.1.7 3.5 2.1 3.5 4.1V20',
|
|
||||||
music: 'M10 18V5.5L20 3v12.5M10 9.5 20 7M10 18a3 2.5 0 1 0-6 0 3 2.5 0 0 0 6 0ZM20 15.5a3 2.5 0 1 0-6 0 3 2.5 0 0 0 6 0Z',
|
|
||||||
home: 'm3 11.5 9-7.5 9 7.5M5.5 10v10h13V10M10 20v-6.5h4V20',
|
|
||||||
'arrow-right': 'M4 12h15.5m-6.5-6.5 6.5 6.5-6.5 6.5',
|
'arrow-right': 'M4 12h15.5m-6.5-6.5 6.5 6.5-6.5 6.5',
|
||||||
'arrow-left': 'M20 12H4.5M11 5.5 4.5 12 11 18.5',
|
'arrow-left': 'M20 12H4.5M11 5.5 4.5 12 11 18.5',
|
||||||
'arrow-up-right': 'M6.5 17.5 17.5 6.5M8.5 6.5h9v9',
|
'arrow-up': 'M12 20V4.5m-6.5 6.5L12 4.5l6.5 6.5',
|
||||||
'arrow-down': 'M12 4v15.5m-6.5-6.5 6.5 6.5 6.5-6.5',
|
'arrow-down': 'M12 4v15.5m-6.5-6.5 6.5 6.5 6.5-6.5',
|
||||||
'chevron-right': 'm9 5.5 6.5 6.5L9 18.5',
|
'chevron-right': 'm9 5.5 6.5 6.5L9 18.5',
|
||||||
calendar: 'M4 6h16v14H4Zm0 5h16M8 3.5v4m8-4v4',
|
'chevron-left': 'M15 5.5 8.5 12l6.5 6.5',
|
||||||
|
'chevron-up': 'm5.5 15 6.5-6.5 6.5 6.5',
|
||||||
|
'chevron-down': 'm5.5 9 6.5 6.5L18.5 9',
|
||||||
|
'arrow-circle': 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm-4 9h8m-3-3 3 3-3 3',
|
||||||
|
external: 'M14 4h6v6m0-6-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5',
|
||||||
|
refresh: 'M20 12a8 8 0 1 1-2.3-5.6M20 4v5h-5',
|
||||||
|
menu: 'M4 7h16M4 12h16M4 17h16',
|
||||||
|
close: 'M6 6l12 12M18 6 6 18',
|
||||||
|
plus: 'M12 5v14M5 12h14',
|
||||||
|
minus: 'M5 12h14',
|
||||||
|
// Kontakt und Ort
|
||||||
|
phone: 'M6 3h4l2 5-2.5 1.5a12 12 0 0 0 5 5L16 12l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 4 5a2 2 0 0 1 2-2Z',
|
||||||
|
app: 'M7 2h10a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Zm4 17h2',
|
||||||
|
mail: 'M3 6h18v12H3Zm0 1 9 7 9-7',
|
||||||
chat: 'M4 5h16v11H9.5L5 19.5V16H4Z',
|
chat: 'M4 5h16v11H9.5L5 19.5V16H4Z',
|
||||||
send: 'M21 3 10 14M21 3l-6.5 18-4.5-7-7-4.5Z',
|
send: 'M21 3 10 14M21 3l-6.5 18-4.5-7-7-4.5Z',
|
||||||
download: 'M12 3.5v12m-5-5 5 5 5-5M4.5 20h15',
|
calendar: 'M4 6h16v14H4Zm0 5h16M8 3.5v4m8-4v4',
|
||||||
|
clock: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 4v5.5l3.5 2',
|
||||||
|
pin: 'M12 21s7-6.3 7-11a7 7 0 1 0-14 0c0 4.7 7 11 7 11Zm0-8.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z',
|
||||||
|
map: 'M9 4 3 6.5V20l6-2.5 6 2.5 6-2.5V4l-6 2.5ZM9 4v13.5m6-11V20',
|
||||||
|
navigation: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm3.5 5.5-2 5-5 2 2-5Z',
|
||||||
|
web: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 0c2.5 2.4 3.8 5.4 3.8 9s-1.3 6.6-3.8 9m0-18C9.5 5.4 8.2 8.4 8.2 12s1.3 6.6 3.8 9M3.2 9h17.6M3.2 15h17.6',
|
||||||
|
wifi: 'M3 9.5a13 13 0 0 1 18 0M6 13a8 8 0 0 1 12 0m-9 3.5a3.5 3.5 0 0 1 6 0M12 19.5h.01',
|
||||||
|
support: 'M12 3a7 7 0 0 0-7 7v5m14-5a7 7 0 0 0-7-7m7 7v5m-14 0a2 2 0 0 0 2 2h1v-5H5Zm14 0a2 2 0 0 1-2 2h-1v-5h3Z',
|
||||||
|
// Handel und Geld
|
||||||
|
shop: 'M4 7h16l-1.4 11.2a2 2 0 0 1-2 1.8H7.4a2 2 0 0 1-2-1.8Zm4 0a4 4 0 0 1 8 0',
|
||||||
|
cart: 'M3 4h2.5l2.2 10.5h10.6L20.5 7H6.3M9.5 18a1.2 1.2 0 1 0 0 2.4 1.2 1.2 0 0 0 0-2.4Zm7.5 0a1.2 1.2 0 1 0 0 2.4 1.2 1.2 0 0 0 0-2.4Z',
|
||||||
|
tag: 'M3.5 12.5v-8a1 1 0 0 1 1-1h8l8 8a1.4 1.4 0 0 1 0 2l-7 7a1.4 1.4 0 0 1-2 0ZM8.5 8h.01',
|
||||||
|
euro: 'M17.5 6.5A6.5 6.5 0 1 0 17.5 17.5M4 10h9m-9 4h9',
|
||||||
|
'credit-card': 'M3 6h18v12H3Zm0 4h18M6.5 15h3',
|
||||||
|
gift: 'M4 9h16v4H4Zm1.5 4v7h13v-7M12 9v11m0-11S10.5 4 8 4.5 7.5 9 12 9Zm0 0s1.5-5 4-4.5S16.5 9 12 9Z',
|
||||||
|
percent: 'M19 5 5 19M7 5a2 2 0 1 0 0 4 2 2 0 0 0 0-4Zm10 10a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z',
|
||||||
|
truck: 'M2.5 6h11v10.5h-11Zm11 3.5h4l3 3.5v3.5h-7ZM6.5 17a1.8 1.8 0 1 0 0 3.6 1.8 1.8 0 0 0 0-3.6Zm10.5 0a1.8 1.8 0 1 0 0 3.6 1.8 1.8 0 0 0 0-3.6Z',
|
||||||
|
package: 'M3.5 7.5 12 3l8.5 4.5v9L12 21l-8.5-4.5Zm0 0L12 12l8.5-4.5M12 12v9M7.8 5.2l8.5 4.6',
|
||||||
|
briefcase: 'M3.5 7.5h17v12h-17Zm5-3h7v3h-7Zm-5 8h17',
|
||||||
|
// Dateien und Medien
|
||||||
document: 'M6 3h8l4 4v14H6Zm8 0v4h4M9 12h6m-6 4h6',
|
document: 'M6 3h8l4 4v14H6Zm8 0v4h4M9 12h6m-6 4h6',
|
||||||
|
download: 'M12 3.5v12m-5-5 5 5 5-5M4.5 20h15',
|
||||||
|
upload: 'M12 15.5V4m-5 5 5-5 5 5M4.5 20h15',
|
||||||
|
print: 'M7 9V3.5h10V9M7 17H4.5V9h15v8H17M7 14h10v6.5H7Z',
|
||||||
|
link: 'M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1.2 1.2M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1.2-1.2',
|
||||||
|
share: 'M18 3.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM6 9.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5Zm12 6a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM8.2 10.8l7.6-4.1m-7.6 6.5 7.6 4.1',
|
||||||
|
image: 'M4 4h16v16H4Zm0 12 4.5-4.5 3.5 3.5 2.5-2.5L20 18M15 8.5h.01',
|
||||||
|
camera: 'M3 7.5h4L8.5 5h7L17 7.5h4V19H3Zm9 2.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z',
|
||||||
|
video: 'M3 6.5h12v11H3Zm12 4 6-3.5v10l-6-3.5',
|
||||||
play: 'M8 5.5v13l10-6.5Z',
|
play: 'M8 5.5v13l10-6.5Z',
|
||||||
search: 'M10.5 4a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13Zm4.8 11.3L20 20',
|
pause: 'M8 5v14m8-14v14',
|
||||||
heart: 'M12 20s-7.5-4.6-7.5-10A4.3 4.3 0 0 1 12 7.4 4.3 4.3 0 0 1 19.5 10c0 5.4-7.5 10-7.5 10Z',
|
music: 'M10 18V5.5L20 3v12.5M10 9.5 20 7M10 18a3 2.5 0 1 0-6 0 3 2.5 0 0 0 6 0ZM20 15.5a3 2.5 0 1 0-6 0 3 2.5 0 0 0 6 0Z',
|
||||||
info: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 8v5.5m0-9v.5',
|
mic: 'M12 3a3 3 0 0 0-3 3v5.5a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3ZM5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21',
|
||||||
plus: 'M12 5v14M5 12h14',
|
book: 'M5 4.5A1.5 1.5 0 0 1 6.5 3H19v15H6.5A1.5 1.5 0 0 0 5 19.5Zm0 15A1.5 1.5 0 0 0 6.5 21H19v-3',
|
||||||
|
// Personen und Sicherheit
|
||||||
user: 'M12 11.5a4 4 0 1 0 0-8 4 4 0 0 0 0 8ZM4.5 20.5c0-3.9 3.4-6 7.5-6s7.5 2.1 7.5 6',
|
user: 'M12 11.5a4 4 0 1 0 0-8 4 4 0 0 0 0 8ZM4.5 20.5c0-3.9 3.4-6 7.5-6s7.5 2.1 7.5 6',
|
||||||
|
people: 'M9.5 11.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7ZM2.5 20v-1c0-3.3 3-5 7-5s7 1.7 7 5v1M16.5 5.3a3.2 3.2 0 0 1 0 6.4M18 14.4c2.1.7 3.5 2.1 3.5 4.1V20',
|
||||||
|
login: 'M14 4h5a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1h-5M3.5 12h11m-4-4 4 4-4 4',
|
||||||
|
lock: 'M5 10.5h14V21H5Zm3 0V7a4 4 0 0 1 8 0v3.5M12 14.5v3',
|
||||||
|
key: 'M7.5 11.5a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm2.8 1.2L19 4m-3 3 2.5 2.5M13 10l2 2',
|
||||||
|
shield: 'M12 3 5 6v6c0 4.4 2.9 7.9 7 9 4.1-1.1 7-4.6 7-9V6Zm-2.5 9 2 2 4-4.5',
|
||||||
|
eye: 'M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Zm9.5-3a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z',
|
||||||
|
'thumbs-up': 'M7 10.5V20H4v-9.5Zm0 0 3.5-7a2 2 0 0 1 2.5 2v3.5h5.2a2 2 0 0 1 2 2.4l-1.4 7a2 2 0 0 1-2 1.6H7',
|
||||||
|
award: 'M12 3a5.5 5.5 0 1 0 0 11 5.5 5.5 0 0 0 0-11Zm-3.5 10L7 21l5-2.5 5 2.5-1.5-8',
|
||||||
|
graduation: 'M2.5 9 12 4.5 21.5 9 12 13.5Zm4 2.3V16c0 1.5 2.5 3 5.5 3s5.5-1.5 5.5-3v-4.7M21.5 9v5',
|
||||||
|
heart: 'M12 20s-7.5-4.6-7.5-10A4.3 4.3 0 0 1 12 7.4 4.3 4.3 0 0 1 19.5 10c0 5.4-7.5 10-7.5 10Z',
|
||||||
|
star: 'm12 3 2.7 5.9 6.3.7-4.7 4.3 1.3 6.1L12 17l-5.6 3 1.3-6.1L3 9.6l6.3-.7Z',
|
||||||
|
// Haus, Garten und Handwerk
|
||||||
|
home: 'm3 11.5 9-7.5 9 7.5M5.5 10v10h13V10M10 20v-6.5h4V20',
|
||||||
|
building: 'M5 21V4h10v17m0-12h4v12M3 21h18M8.5 7.5h3m-3 3.5h3m-3 3.5h3',
|
||||||
|
wrench: 'M14.5 3.5a5 5 0 0 0-4.6 6.9L3.5 16.8a1.9 1.9 0 0 0 2.7 2.7l6.4-6.4a5 5 0 0 0 6.9-4.6l-3 3-3-1-1-3Z',
|
||||||
|
hammer: 'M14 8.5 4.5 18a1.5 1.5 0 0 0 2.1 2.1L16 10.5M11 6l3-3 3.5 1L21 7.5 18.5 10l-2-2-2.5 2.5Z',
|
||||||
|
brush: 'M4 4h13v5H4Zm13 2.5h2.5V12h-8v3M10 15h3v6h-3Z',
|
||||||
|
ruler: 'M3.5 16 16 3.5 20.5 8 8 20.5ZM7 13l2 2m1-5 2 2m1-5 2 2',
|
||||||
|
leaf: 'M5 19C5 10 10 5 20 4c-1 10-6 15-15 15Zm0 0 8-8',
|
||||||
|
tree: 'M12 21v-5m0-13-6 8h3l-4 5h14l-4-5h3Z',
|
||||||
|
sun: 'M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm0-5v2m0 14v2M3 12h2m14 0h2M5.6 5.6 7 7m10 10 1.4 1.4M5.6 18.4 7 17M17 7l1.4-1.4',
|
||||||
|
droplet: 'M12 3s6.5 7 6.5 11.5a6.5 6.5 0 0 1-13 0C5.5 10 12 3 12 3Z',
|
||||||
|
flame: 'M12 21a6 6 0 0 0 6-6c0-4.5-4-6.5-4.5-11-2.5 2-5 5-5 8.5-1-.5-1.5-1.5-1.8-2.5C5.6 11.5 6 13 6 15a6 6 0 0 0 6 6Z',
|
||||||
|
bolt: 'M13 2.5 4.5 13.5H12l-1 8 8.5-11H12Z',
|
||||||
|
solar: 'M5 4h15l-2 9.5H3Zm-1 4.75h15M10 4 8.5 13.5M15.5 4 14 13.5M10.5 13.5V18M7 20.5h7',
|
||||||
|
thermometer: 'M10 14.5V5a2 2 0 0 1 4 0v9.5a4 4 0 1 1-4 0Z',
|
||||||
|
snowflake: 'M12 2.5v19M3.8 7.3l16.4 9.4M3.8 16.7l16.4-9.4M9.5 4 12 6l2.5-2m-5 16 2.5-2 2.5 2',
|
||||||
|
car: 'M5 16.5V12l2-5h10l2 5v4.5M3 12h18v4.5H3Zm3.5 4.5V19m11-2.5V19M7 14h.01M17 14h.01',
|
||||||
|
// Allgemein
|
||||||
|
check: 'm4 12.5 5.5 5.5L20 6',
|
||||||
|
'check-circle': 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm-4 9.2 2.8 2.8L16.5 9',
|
||||||
|
info: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 8v5.5m0-9v.5',
|
||||||
|
help: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18ZM9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.6.3-1 .9-1 1.6v.6m0 3v.01',
|
||||||
|
alert: 'M12 3.5 2.5 20h19Zm0 6V14m0 3v.01',
|
||||||
|
search: 'M10.5 4a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13Zm4.8 11.3L20 20',
|
||||||
|
settings: 'M4 7h9m4 0h3M4 17h3m4 0h9M15 5v4M9 15v4',
|
||||||
|
filter: 'M3.5 5h17l-6.5 8v6l-4 2v-8Z',
|
||||||
|
grid: 'M4 4h7v7H4Zm9 0h7v7h-7ZM4 13h7v7H4Zm9 0h7v7h-7Z',
|
||||||
|
list: 'M9 6h11M9 12h11M9 18h11M4.5 6h.01M4.5 12h.01M4.5 18h.01',
|
||||||
|
sparkle: 'M12 3c.6 4.4 2.6 6.4 7 7-4.4.6-6.4 2.6-7 7-.6-4.4-2.6-6.4-7-7 4.4-.6 6.4-2.6 7-7Zm6.5 12.5c.2 1.6 1 2.3 2.5 2.5-1.5.2-2.3 1-2.5 2.5-.2-1.5-1-2.3-2.5-2.5 1.5-.2 2.3-1 2.5-2.5Z',
|
||||||
|
coffee: 'M4 9h12v6a5 5 0 0 1-5 5H9a5 5 0 0 1-5-5Zm12 1.5h1.5a2.5 2.5 0 0 1 0 5H16M8 3.5V6m4-2.5V6',
|
||||||
|
utensils: 'M6 3v7a2 2 0 0 0 4 0V3M8 10v11m8.5 0V3c-2 1-3 3.5-3 6.5v4h3',
|
||||||
|
laptop: 'M5 5h14v10H5Zm-2.5 10h19l-1 3.5h-17Z',
|
||||||
|
hardware: 'M4 5h16v11H4Zm4 15h8m-4-4v4',
|
||||||
|
network: 'M5 3h14v5H5Zm0 13h14v5H5Zm7-8v8M8 8v3h8V8',
|
||||||
|
design: 'M4 20h16M6 16 18 4l3 3L9 19l-4 1Z',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ICON_LABELS = {
|
export const ICON_LABELS = {
|
||||||
app: 'Smartphone', web: 'Globus', design: 'Stift', network: 'Netzwerk', hardware: 'Bildschirm',
|
'arrow-up-right': 'Pfeil schräg', 'arrow-right': 'Pfeil nach rechts', 'arrow-left': 'Pfeil nach links',
|
||||||
shop: 'Einkaufstasche', support: 'Kopfhörer', shield: 'Schutz', clock: 'Uhr', star: 'Stern',
|
'arrow-up': 'Pfeil nach oben', 'arrow-down': 'Pfeil nach unten', 'chevron-right': 'Winkel rechts',
|
||||||
phone: 'Telefon', mail: 'E-Mail', pin: 'Ort', check: 'Häkchen', people: 'Personen', music: 'Musik',
|
'chevron-left': 'Winkel links', 'chevron-up': 'Winkel oben', 'chevron-down': 'Winkel unten',
|
||||||
home: 'Haus', 'arrow-right': 'Pfeil nach rechts', 'arrow-left': 'Pfeil nach links',
|
'arrow-circle': 'Pfeil im Kreis', external: 'Externer Link', refresh: 'Aktualisieren', menu: 'Menü',
|
||||||
'arrow-up-right': 'Pfeil schräg', 'arrow-down': 'Pfeil nach unten', 'chevron-right': 'Winkel',
|
close: 'Schließen', plus: 'Plus', minus: 'Minus',
|
||||||
calendar: 'Kalender', chat: 'Nachricht', send: 'Senden', download: 'Download', document: 'Dokument',
|
phone: 'Telefon', app: 'Smartphone', mail: 'E-Mail', chat: 'Nachricht', send: 'Senden', calendar: 'Kalender',
|
||||||
play: 'Abspielen', search: 'Suche', heart: 'Herz', info: 'Info', plus: 'Plus', user: 'Person',
|
clock: 'Uhr', pin: 'Ort', map: 'Karte', navigation: 'Navigation', web: 'Globus', wifi: 'WLAN', support: 'Kundendienst',
|
||||||
|
shop: 'Einkaufstasche', cart: 'Warenkorb', tag: 'Preisschild', euro: 'Euro', 'credit-card': 'Karte bezahlen',
|
||||||
|
gift: 'Geschenk', percent: 'Prozent', truck: 'Lieferung', package: 'Paket', briefcase: 'Aktentasche',
|
||||||
|
document: 'Dokument', download: 'Download', upload: 'Hochladen', print: 'Drucken', link: 'Link', share: 'Teilen',
|
||||||
|
image: 'Bild', camera: 'Kamera', video: 'Video', play: 'Abspielen', pause: 'Pause', music: 'Musik',
|
||||||
|
mic: 'Mikrofon', book: 'Buch',
|
||||||
|
user: 'Person', people: 'Personen', login: 'Anmelden', lock: 'Schloss', key: 'Schlüssel', shield: 'Schutz',
|
||||||
|
eye: 'Ansehen', 'thumbs-up': 'Daumen hoch', award: 'Auszeichnung', graduation: 'Ausbildung', heart: 'Herz', star: 'Stern',
|
||||||
|
home: 'Haus', building: 'Gebäude', wrench: 'Werkzeug', hammer: 'Hammer', brush: 'Malerrolle', ruler: 'Lineal',
|
||||||
|
leaf: 'Blatt', tree: 'Baum', sun: 'Sonne', droplet: 'Wasser', flame: 'Flamme', bolt: 'Strom', solar: 'Solar',
|
||||||
|
thermometer: 'Temperatur', snowflake: 'Kälte', car: 'Auto',
|
||||||
|
check: 'Häkchen', 'check-circle': 'Häkchen im Kreis', info: 'Info', help: 'Frage', alert: 'Warnung',
|
||||||
|
search: 'Suche', settings: 'Einstellungen', filter: 'Filter', grid: 'Raster', list: 'Liste', sparkle: 'Funkeln',
|
||||||
|
coffee: 'Kaffee', utensils: 'Besteck', laptop: 'Laptop', hardware: 'Bildschirm', network: 'Netzwerk', design: 'Stift',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Die Auswahl am Button: die Symbole, die man an einem Knopf wirklich braucht, nach Zweck geordnet. */
|
/** Die Kategorien des Auswahlfensters, in der Reihenfolge der Reiter. Jedes Symbol steht genau einmal. */
|
||||||
export const BUTTON_ICON_GROUPS = [
|
export const ICON_GROUPS = [
|
||||||
['Pfeile', ['arrow-up-right', 'arrow-right', 'chevron-right', 'arrow-down', 'arrow-left']],
|
['Pfeile', ['arrow-up-right', 'arrow-right', 'arrow-left', 'arrow-up', 'arrow-down', 'chevron-right', 'chevron-left', 'chevron-up', 'chevron-down', 'arrow-circle', 'external', 'refresh', 'menu', 'close', 'plus', 'minus']],
|
||||||
['Kontakt', ['phone', 'mail', 'chat', 'send', 'calendar', 'pin']],
|
['Kontakt & Ort', ['phone', 'app', 'mail', 'chat', 'send', 'calendar', 'clock', 'pin', 'map', 'navigation', 'web', 'wifi', 'support']],
|
||||||
['Allgemein', ['download', 'document', 'play', 'search', 'check', 'star', 'heart', 'info', 'plus', 'user', 'home', 'shop', 'clock']],
|
['Handel & Geld', ['shop', 'cart', 'tag', 'euro', 'credit-card', 'gift', 'percent', 'truck', 'package', 'briefcase']],
|
||||||
];
|
['Dateien & Medien', ['document', 'download', 'upload', 'print', 'link', 'share', 'image', 'camera', 'video', 'play', 'pause', 'music', 'mic', 'book']],
|
||||||
|
['Personen & Sicherheit', ['user', 'people', 'login', 'lock', 'key', 'shield', 'eye', 'thumbs-up', 'award', 'graduation', 'heart', 'star']],
|
||||||
/** Alle Symbole für Symbol und Symbolkarte, in derselben Ordnung plus die übrigen. */
|
['Haus & Handwerk', ['home', 'building', 'wrench', 'hammer', 'brush', 'ruler', 'leaf', 'tree', 'sun', 'droplet', 'flame', 'bolt', 'solar', 'thermometer', 'snowflake', 'car']],
|
||||||
export const ALL_ICON_GROUPS = [
|
['Allgemein', ['check', 'check-circle', 'info', 'help', 'alert', 'search', 'settings', 'filter', 'grid', 'list', 'sparkle', 'coffee', 'utensils', 'laptop', 'hardware', 'network', 'design']],
|
||||||
...BUTTON_ICON_GROUPS,
|
|
||||||
['Weitere', ['app', 'web', 'design', 'network', 'hardware', 'support', 'shield', 'people', 'music']],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const svgNS = 'http://www.w3.org/2000/svg';
|
const svgNS = 'http://www.w3.org/2000/svg';
|
||||||
@@ -72,73 +156,123 @@ export function iconSvg(name) {
|
|||||||
return svg;
|
return svg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const element = (tag, className, text) => {
|
||||||
|
const node = document.createElement(tag);
|
||||||
|
if (className) node.className = className;
|
||||||
|
if (text !== undefined) node.textContent = text;
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Eine Kachelauswahl statt einer Liste aus Namen: jedes Symbol ist ein Knopf mit seiner Zeichnung,
|
* Das Feld im Panel: das gewählte Symbol als Vorschau mit Namen und ein Knopf, der das
|
||||||
* der gewählte ist markiert. Pfeiltasten wandern innerhalb der Gruppe, Tab verlässt die Auswahl.
|
* Auswahlfenster öffnet. Die Kacheln stehen nicht im Panel - bei hundert Symbolen wäre es eine
|
||||||
|
* lange Wand, die alles andere nach unten drückt.
|
||||||
*
|
*
|
||||||
* @param {{ label: string, value: string, groups: Array<[string, string[]]>, none?: string, onChoose: (name: string) => void }} options
|
* @param {{ label: string, value: string, none?: string, onChoose: (name: string) => void }} options
|
||||||
*/
|
*/
|
||||||
export function renderIconPicker({ label, value, groups, none, onChoose }) {
|
export function renderIconField({ label, value, none, onChoose }) {
|
||||||
const wrap = document.createElement('div');
|
const wrap = element('div', 'icon-field');
|
||||||
wrap.className = 'icon-picker';
|
wrap.append(element('p', 'icon-picker-label', label));
|
||||||
const heading = document.createElement('p');
|
const row = element('div', 'icon-field-row');
|
||||||
heading.className = 'icon-picker-label';
|
const preview = element('span', 'icon-field-preview');
|
||||||
heading.id = `icon-picker-${Math.random().toString(36).slice(2, 8)}`;
|
preview.setAttribute('aria-hidden', 'true');
|
||||||
heading.textContent = label;
|
if (value && value !== 'none') preview.append(iconSvg(value)); else preview.classList.add('is-empty');
|
||||||
wrap.append(heading);
|
const name = element('span', 'icon-field-name', value && value !== 'none' ? ICON_LABELS[value] ?? value : none ?? 'Kein Symbol');
|
||||||
const tiles = [];
|
const open = element('button', 'secondary icon-field-change', 'Ändern …');
|
||||||
const tile = name => {
|
open.type = 'button';
|
||||||
const button = document.createElement('button');
|
open.setAttribute('aria-haspopup', 'dialog');
|
||||||
button.type = 'button';
|
open.setAttribute('aria-label', `${label} ändern, aktuell: ${name.textContent}`);
|
||||||
button.className = name === 'none' ? 'icon-tile icon-tile-none' : 'icon-tile';
|
open.addEventListener('click', () => openIconDialog({ value, none, onChoose, returnFocus: open }));
|
||||||
button.dataset.icon = name;
|
row.append(preview, name, open);
|
||||||
const text = name === 'none' ? none : ICON_LABELS[name];
|
wrap.append(row);
|
||||||
button.title = text;
|
|
||||||
button.setAttribute('aria-label', text);
|
|
||||||
button.setAttribute('aria-pressed', String(name === value));
|
|
||||||
button.tabIndex = name === value ? 0 : -1;
|
|
||||||
if (name === 'none') {
|
|
||||||
const mark = document.createElement('span');
|
|
||||||
mark.className = 'icon-tile-empty';
|
|
||||||
mark.setAttribute('aria-hidden', 'true');
|
|
||||||
button.append(mark);
|
|
||||||
} else button.append(iconSvg(name));
|
|
||||||
button.addEventListener('click', () => onChoose(name));
|
|
||||||
tiles.push(button);
|
|
||||||
return button;
|
|
||||||
};
|
|
||||||
const group = document.createElement('div');
|
|
||||||
group.setAttribute('role', 'group');
|
|
||||||
group.setAttribute('aria-labelledby', heading.id);
|
|
||||||
if (none) {
|
|
||||||
const row = document.createElement('div');
|
|
||||||
row.className = 'icon-grid';
|
|
||||||
row.append(tile('none'));
|
|
||||||
group.append(row);
|
|
||||||
}
|
|
||||||
for (const [title, names] of groups) {
|
|
||||||
const caption = document.createElement('p');
|
|
||||||
caption.className = 'icon-group-title';
|
|
||||||
caption.textContent = title;
|
|
||||||
const grid = document.createElement('div');
|
|
||||||
grid.className = 'icon-grid';
|
|
||||||
for (const name of names) grid.append(tile(name));
|
|
||||||
group.append(caption, grid);
|
|
||||||
}
|
|
||||||
// Ohne gewählten Knopf in der Liste (etwa ein Symbol außerhalb der Gruppen) bleibt der erste
|
|
||||||
// erreichbar, sonst käme man mit Tab gar nicht hinein.
|
|
||||||
if (!tiles.some(button => button.tabIndex === 0)) tiles[0].tabIndex = 0;
|
|
||||||
group.addEventListener('keydown', event => {
|
|
||||||
const index = tiles.indexOf(document.activeElement);
|
|
||||||
if (index < 0) return;
|
|
||||||
const step = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[event.key];
|
|
||||||
const target = event.key === 'Home' ? 0 : event.key === 'End' ? tiles.length - 1 : step ? (index + step + tiles.length) % tiles.length : null;
|
|
||||||
if (target === null) return;
|
|
||||||
event.preventDefault();
|
|
||||||
tiles[index].tabIndex = -1;
|
|
||||||
tiles[target].tabIndex = 0;
|
|
||||||
tiles[target].focus();
|
|
||||||
});
|
|
||||||
wrap.append(group);
|
|
||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Das Auswahlfenster: Kategorien als Reiter links, die Symbole einer Kategorie als Kacheln mit
|
||||||
|
* Namen. Ein Klick wählt und schließt, Escape schließt ohne Änderung. Geöffnet wird bei der
|
||||||
|
* Kategorie des gewählten Symbols, der Fokus steht auf ihm.
|
||||||
|
*/
|
||||||
|
export function openIconDialog({ value, none, onChoose, returnFocus }) {
|
||||||
|
const dialog = element('dialog', 'icon-dialog');
|
||||||
|
dialog.setAttribute('aria-labelledby', 'icon-dialog-title');
|
||||||
|
const head = element('div', 'icon-dialog-head');
|
||||||
|
head.append(element('h2', null, 'Symbol wählen'));
|
||||||
|
head.firstChild.id = 'icon-dialog-title';
|
||||||
|
const close = element('button', 'icon-dialog-close');
|
||||||
|
close.type = 'button'; close.setAttribute('aria-label', 'Schließen'); close.append(iconSvg('close'));
|
||||||
|
close.addEventListener('click', () => dialog.close());
|
||||||
|
head.append(close);
|
||||||
|
const body = element('div', 'icon-dialog-body');
|
||||||
|
const tablist = element('div', 'icon-dialog-tabs');
|
||||||
|
tablist.setAttribute('role', 'tablist'); tablist.setAttribute('aria-orientation', 'vertical'); tablist.setAttribute('aria-label', 'Kategorien');
|
||||||
|
const panel = element('div', 'icon-dialog-panel');
|
||||||
|
panel.setAttribute('role', 'tabpanel'); panel.id = 'icon-dialog-panel';
|
||||||
|
const tabs = [];
|
||||||
|
let tiles = [];
|
||||||
|
const choose = name => { dialog.close(); onChoose(name); };
|
||||||
|
const show = (index, focusTile = false) => {
|
||||||
|
tabs.forEach((tab, i) => { tab.setAttribute('aria-selected', String(i === index)); tab.tabIndex = i === index ? 0 : -1; });
|
||||||
|
panel.setAttribute('aria-labelledby', tabs[index].id);
|
||||||
|
panel.replaceChildren();
|
||||||
|
const grid = element('div', 'icon-dialog-grid');
|
||||||
|
tiles = ICON_GROUPS[index][1].map(name => {
|
||||||
|
const tile = element('button', 'icon-choice');
|
||||||
|
tile.type = 'button'; tile.dataset.icon = name;
|
||||||
|
tile.setAttribute('aria-pressed', String(name === value));
|
||||||
|
tile.tabIndex = -1;
|
||||||
|
tile.append(iconSvg(name), element('span', 'icon-choice-name', ICON_LABELS[name]));
|
||||||
|
tile.addEventListener('click', () => choose(name));
|
||||||
|
grid.append(tile);
|
||||||
|
return tile;
|
||||||
|
});
|
||||||
|
(tiles.find(tile => tile.dataset.icon === value) ?? tiles[0]).tabIndex = 0;
|
||||||
|
panel.append(grid);
|
||||||
|
if (focusTile) (tiles.find(tile => tile.tabIndex === 0)).focus();
|
||||||
|
};
|
||||||
|
ICON_GROUPS.forEach(([title], index) => {
|
||||||
|
const tab = element('button', 'icon-dialog-tab', title);
|
||||||
|
tab.type = 'button'; tab.id = `icon-tab-${index}`; tab.setAttribute('role', 'tab'); tab.setAttribute('aria-controls', panel.id);
|
||||||
|
tab.addEventListener('click', () => show(index));
|
||||||
|
tabs.push(tab); tablist.append(tab);
|
||||||
|
});
|
||||||
|
tablist.addEventListener('keydown', event => {
|
||||||
|
const index = tabs.indexOf(document.activeElement);
|
||||||
|
const step = { ArrowDown: 1, ArrowRight: 1, ArrowUp: -1, ArrowLeft: -1 }[event.key];
|
||||||
|
if (index < 0 || !step) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const next = (index + step + tabs.length) % tabs.length;
|
||||||
|
show(next); tabs[next].focus();
|
||||||
|
});
|
||||||
|
panel.addEventListener('keydown', event => {
|
||||||
|
const index = tiles.indexOf(document.activeElement);
|
||||||
|
if (index < 0) return;
|
||||||
|
const columns = Math.max(1, Math.round(panel.querySelector('.icon-dialog-grid').clientWidth / tiles[0].offsetWidth));
|
||||||
|
const step = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: columns, ArrowUp: -columns }[event.key];
|
||||||
|
const target = event.key === 'Home' ? 0 : event.key === 'End' ? tiles.length - 1 : step ? Math.min(tiles.length - 1, Math.max(0, index + step)) : null;
|
||||||
|
if (target === null) return;
|
||||||
|
event.preventDefault();
|
||||||
|
tiles[index].tabIndex = -1; tiles[target].tabIndex = 0; tiles[target].focus();
|
||||||
|
});
|
||||||
|
body.append(tablist, panel);
|
||||||
|
const foot = element('div', 'icon-dialog-foot');
|
||||||
|
if (none) {
|
||||||
|
const without = element('button', 'secondary', none);
|
||||||
|
without.type = 'button'; without.dataset.icon = 'none';
|
||||||
|
without.setAttribute('aria-pressed', String(value === 'none'));
|
||||||
|
without.addEventListener('click', () => choose('none'));
|
||||||
|
foot.append(without);
|
||||||
|
}
|
||||||
|
const cancel = element('button', 'text-button', 'Abbrechen');
|
||||||
|
cancel.type = 'button'; cancel.addEventListener('click', () => dialog.close());
|
||||||
|
foot.append(cancel);
|
||||||
|
dialog.append(head, body, foot);
|
||||||
|
dialog.addEventListener('close', () => { dialog.remove(); if (returnFocus?.isConnected) returnFocus.focus(); });
|
||||||
|
// Ein Klick auf den abgedunkelten Rand schließt, wie bei den anderen Fenstern des Editors.
|
||||||
|
dialog.addEventListener('click', event => { if (event.target === dialog) dialog.close(); });
|
||||||
|
document.body.append(dialog);
|
||||||
|
const start = Math.max(0, ICON_GROUPS.findIndex(([, names]) => names.includes(value)));
|
||||||
|
dialog.showModal();
|
||||||
|
show(start, true);
|
||||||
|
return dialog;
|
||||||
|
}
|
||||||
@@ -47,8 +47,8 @@ p{hyphens:auto}
|
|||||||
/* Der Pfeil am Button und in der Beitragsnavigation ist dasselbe Symbol wie ueberall sonst -
|
/* Der Pfeil am Button und in der Beitragsnavigation ist dasselbe Symbol wie ueberall sonst -
|
||||||
frueher stand dort ein Schriftzeichen, das je nach Schrift anders aussah. Er waechst mit der
|
frueher stand dort ein Schriftzeichen, das je nach Schrift anders aussah. Er waechst mit der
|
||||||
Schrift des Knopfs, statt eine eigene Groesse zu haben. */
|
Schrift des Knopfs, statt eine eigene Groesse zu haben. */
|
||||||
.button-icon,.nav-arrow{display:inline-flex;align-items:center}
|
.button-icon,.nav-arrow{display:inline-flex;align-items:center}.button-icon{justify-content:center;flex:none;transition:rotate .25s ease,transform .25s ease,background-color .2s,color .2s,max-width .3s ease,opacity .3s ease,margin .3s ease}
|
||||||
.button-icon>svg,.nav-arrow>svg{width:1.15em;height:1.15em}
|
.nav-arrow>svg{width:1.15em;height:1.15em}.cms-button .button-icon>svg{width:var(--icon-size,1.15em);height:var(--icon-size,1.15em)}.button.cms-button{gap:var(--icon-gap,.65em)}.cms-button.icon-motion-slide:is(:hover,:focus-visible) .button-icon{transform:translateX(.3em)}.cms-button.icon-start.icon-motion-slide:is(:hover,:focus-visible) .button-icon{transform:translateX(-.3em)}.button.cms-button.icon-motion-reveal{gap:0}.cms-button.icon-motion-reveal .button-icon{max-width:0;opacity:0;overflow:hidden}.cms-button.icon-motion-reveal:is(:hover,:focus-visible) .button-icon{max-width:4em;opacity:1;margin-inline-start:var(--icon-gap,.65em)}.cms-button.icon-start.icon-motion-reveal:is(:hover,:focus-visible) .button-icon{margin-inline:0 var(--icon-gap,.65em)}.button.cms-button.icon-only{gap:0;padding:var(--b-py,var(--kb-py,15px))}.cms-button.icon-only .button-icon{min-inline-size:1lh}.cms-button.icon-only .button-label{width:0;overflow:hidden;white-space:nowrap}@media (prefers-reduced-motion:reduce){html.motion-respect .cms-button .button-icon{transition:none}}
|
||||||
.block-iconcard{display:grid;gap:12px;padding:26px;border:1px solid currentColor;border-radius:12px}
|
.block-iconcard{display:grid;gap:12px;padding:26px;border:1px solid currentColor;border-radius:12px}
|
||||||
.block-iconcard + .block-iconcard,.block-quote + .block-quote,.block-cta + .block-cta,.block-iconcard + .block-quote,.block-quote + .block-iconcard{margin-top:18px}
|
.block-iconcard + .block-iconcard,.block-quote + .block-quote,.block-cta + .block-cta,.block-iconcard + .block-quote,.block-quote + .block-iconcard{margin-top:18px}
|
||||||
/* Gestapelt brauchen Karten den Abstand; nebeneinander in einer Zeile macht er die zweite bis
|
/* Gestapelt brauchen Karten den Abstand; nebeneinander in einer Zeile macht er die zweite bis
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// Schreibt den Symbolsatz aus public/assets/icons.js in die Twig-Vorlage der Website und in die
|
||||||
|
// erlaubte Liste des Validators. Eine Quelle, drei Abnehmer; tests/icons.test.mjs prüft das Ergebnis.
|
||||||
|
// Aufruf: node scripts/build-icons.mjs
|
||||||
|
import { readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { ICON_PATHS } from '../public/assets/icons.js';
|
||||||
|
|
||||||
|
const twigFile = new URL('../templates/blocks/icon.html.twig', import.meta.url);
|
||||||
|
const twig = await readFile(twigFile, 'utf8');
|
||||||
|
const entries = Object.entries(ICON_PATHS).map(([name, path]) => ` ${/-/.test(name) ? `'${name}'` : name}: '${path}'`).join(',\n');
|
||||||
|
const nextTwig = twig.replace(/\{% set paths = \{\n[\s\S]*?\n\} %\}/, `{% set paths = {\n${entries}\n} %}`);
|
||||||
|
if (nextTwig === twig && !twig.includes(entries)) throw new Error('Die Symbolliste in icon.html.twig wurde nicht gefunden.');
|
||||||
|
await writeFile(twigFile, nextTwig);
|
||||||
|
|
||||||
|
const validatorFile = new URL('../src/Content/DocumentValidator.php', import.meta.url);
|
||||||
|
const validator = await readFile(validatorFile, 'utf8');
|
||||||
|
const list = Object.keys(ICON_PATHS).map(name => `'${name}'`).join(', ');
|
||||||
|
const nextValidator = validator.replace(/public const ICONS = \[[^\]]*\];/, `public const ICONS = [${list}];`);
|
||||||
|
await writeFile(validatorFile, nextValidator);
|
||||||
|
console.log(`${Object.keys(ICON_PATHS).length} Symbole geschrieben.`);
|
||||||
+116
-54
@@ -1,6 +1,8 @@
|
|||||||
// Prüft die Symbolwahl am Button im echten Editor: Kachel wählen, Stellung umschalten, kein Symbol,
|
// Prüft das Symbol am Button im echten Editor auf einer isolierten Kopie der Pilotdaten: Auswahl-
|
||||||
// Tastatur, Rückgängig, Speichern und Neuladen - auf einer isolierten Kopie der Pilotdaten.
|
// fenster mit Kategorien, Tastatur, Stellung, Bewegung, nur Symbol, Farbe und Größe unter Design,
|
||||||
|
// Rückgängig, Speichern und Neuladen. Screenshots unter reports/button-icons/.
|
||||||
import { chromium } from 'playwright';
|
import { chromium } from 'playwright';
|
||||||
|
import AxeBuilder from '@axe-core/playwright';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { execFileSync, spawn } from 'node:child_process';
|
import { execFileSync, spawn } from 'node:child_process';
|
||||||
import { mkdir, writeFile } from 'node:fs/promises';
|
import { mkdir, writeFile } from 'node:fs/promises';
|
||||||
@@ -38,80 +40,140 @@ try {
|
|||||||
await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin');
|
await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin');
|
||||||
const api = `${origin}/admin/api/pages/${fixture.homeId}`;
|
const api = `${origin}/admin/api/pages/${fixture.homeId}`;
|
||||||
const data = await (await context.request.get(api)).json();
|
const data = await (await context.request.get(api)).json();
|
||||||
const startVersion = data.document.schemaVersion;
|
|
||||||
data.document.blocks.unshift({ id: 'qa-icons', type: 'section', children: [
|
data.document.blocks.unshift({ id: 'qa-icons', type: 'section', children: [
|
||||||
{ id: 'qa-icon-button', type: 'button', props: { text: 'Jetzt anrufen', href: 'tel:+4972296992826' } },
|
{ id: 'qa-icon-button', type: 'button', props: { text: 'Jetzt anrufen', href: 'tel:+4972296992826' } },
|
||||||
] });
|
] });
|
||||||
assert.equal((await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': data.csrfToken }, data: { document: data.document, expectedVersion: data.version } })).status(), 200);
|
assert.equal((await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': data.csrfToken }, data: { document: data.document, expectedVersion: data.version } })).status(), 200);
|
||||||
|
|
||||||
// Was die Vorschau im Button zeigt: Reihenfolge von Text und Symbol und die Zeichnung.
|
const inPreview = fn => page.evaluate(fn);
|
||||||
const preview = () => page.evaluate(() => {
|
|
||||||
const el = document.querySelector('#preview')?.contentDocument?.getElementById('qa-icon-button');
|
|
||||||
if (!el) return null;
|
|
||||||
return { first: el.firstElementChild?.className ?? null, text: el.textContent.trim(), path: el.querySelector('svg path')?.getAttribute('d') ?? null, firstNode: el.firstChild?.nodeType };
|
|
||||||
});
|
|
||||||
const waitPreview = async expected => page.waitForFunction(expected => {
|
const waitPreview = async expected => page.waitForFunction(expected => {
|
||||||
const el = document.querySelector('#preview')?.contentDocument?.getElementById('qa-icon-button');
|
const el = document.querySelector('#preview')?.contentDocument?.getElementById('qa-icon-button');
|
||||||
if (!el) return false;
|
if (!el) return false;
|
||||||
const path = el.querySelector('svg path')?.getAttribute('d') ?? null;
|
const path = el.querySelector('.button-icon svg path')?.getAttribute('d') ?? null;
|
||||||
const iconFirst = el.firstChild?.nodeType === 1;
|
const classes = el.className;
|
||||||
return (expected.path === null ? path === null : path?.startsWith(expected.path)) && (expected.first === undefined || iconFirst === expected.first);
|
return (expected.path === undefined || (expected.path === null ? path === null : path?.startsWith(expected.path)))
|
||||||
|
&& (expected.first === undefined || ((el.firstChild?.nodeType === 1 && el.firstChild.classList.contains('button-icon')) === expected.first))
|
||||||
|
&& (expected.has ?? []).every(name => classes.includes(name))
|
||||||
|
&& (expected.not ?? []).every(name => !classes.includes(name));
|
||||||
}, expected);
|
}, expected);
|
||||||
|
|
||||||
await page.goto(`${origin}/admin/pages/${fixture.homeId}`);
|
await page.goto(`${origin}/admin/pages/${fixture.homeId}`);
|
||||||
await selectBlock('qa-icon-button');
|
await selectBlock('qa-icon-button');
|
||||||
const picker = page.locator('.icon-picker');
|
const field = page.locator('.icon-field');
|
||||||
await picker.waitFor();
|
await field.waitFor();
|
||||||
// Ohne Wahl: der bisherige Pfeil ist markiert und steht in der Vorschau hinter dem Text.
|
assert.equal(await field.locator('.icon-field-name').textContent(), 'Pfeil schräg');
|
||||||
assert.equal(await picker.locator('[aria-pressed="true"]').getAttribute('data-icon'), 'arrow-up-right');
|
assert.equal(await page.locator('.icon-choice').count(), 0, 'Im Panel steht keine Kachelwand.');
|
||||||
await waitPreview({ path: 'M6.5 17.5', first: false });
|
await waitPreview({ path: 'M6.5 17.5', first: false });
|
||||||
assert.equal(await picker.locator('.icon-tile').count(), 25, 'Kein Symbol plus 24 Symbole in drei Gruppen');
|
await page.locator('.properties, #properties, aside').first().screenshot({ path: `${report}/panel-vorher.png` }).catch(() => {});
|
||||||
assert.deepEqual(await picker.locator('.icon-group-title').allTextContents(), ['Pfeile', 'Kontakt', 'Allgemein']);
|
checks.push('Panel: nur das gewählte Symbol (Pfeil schräg) mit „Ändern …“');
|
||||||
assert.equal(await page.locator('#property-icon').count(), 0, 'Keine Auswahlliste mehr');
|
|
||||||
await page.locator('.properties-panel, #properties, aside').first().screenshot({ path: `${report}/vorher.png` }).catch(() => {});
|
|
||||||
checks.push('ohne Wahl: Pfeil ↗ markiert und hinter dem Text, Kacheln statt Liste');
|
|
||||||
|
|
||||||
await picker.getByRole('button', { name: 'Telefon', exact: true }).click();
|
// Das Fenster: Kategorien links, Symbole mit Namen, geöffnet bei der Kategorie des Pfeils.
|
||||||
|
await page.getByRole('button', { name: /Symbol ändern, aktuell: Pfeil schräg/ }).click();
|
||||||
|
const dialog = page.getByRole('dialog', { name: 'Symbol wählen' });
|
||||||
|
await dialog.waitFor();
|
||||||
|
assert.equal(await dialog.getByRole('tab').count(), 7);
|
||||||
|
assert.equal(await dialog.getByRole('tab', { selected: true }).textContent(), 'Pfeile');
|
||||||
|
assert.equal(await page.evaluate(() => document.activeElement?.dataset.icon), 'arrow-up-right', 'Fokus auf dem gewählten Symbol');
|
||||||
|
const axe = await new AxeBuilder({ page }).include('.icon-dialog').analyze();
|
||||||
|
assert.deepEqual(axe.violations.map(v => v.id), [], 'axe im Auswahlfenster');
|
||||||
|
await dialog.getByRole('tab', { name: 'Haus & Handwerk' }).click();
|
||||||
|
await dialog.screenshot({ path: `${report}/fenster.png` });
|
||||||
|
// Escape schließt ohne Änderung, der Fokus geht zurück zum Knopf.
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await dialog.waitFor({ state: 'detached' });
|
||||||
|
assert.match(await page.evaluate(() => document.activeElement?.getAttribute('aria-label') ?? ''), /Symbol ändern/);
|
||||||
|
await waitPreview({ path: 'M6.5 17.5' });
|
||||||
|
checks.push('Fenster: 7 Kategorien, Fokus auf dem Gewählten, axe ohne Befund, Escape ändert nichts');
|
||||||
|
|
||||||
|
// Tastatur: Kategorie mit Pfeiltaste wechseln, Symbol wählen.
|
||||||
|
await page.getByRole('button', { name: /Symbol ändern/ }).click();
|
||||||
|
await dialog.getByRole('tab', { name: 'Pfeile' }).focus();
|
||||||
|
await page.keyboard.press('ArrowDown');
|
||||||
|
assert.equal(await dialog.getByRole('tab', { selected: true }).textContent(), 'Kontakt & Ort');
|
||||||
|
await dialog.getByRole('button', { name: 'Telefon', exact: true }).click();
|
||||||
|
await dialog.waitFor({ state: 'detached' });
|
||||||
await waitPreview({ path: 'M6 3h4', first: false });
|
await waitPreview({ path: 'M6 3h4', first: false });
|
||||||
assert.equal(await page.locator('.icon-tile[data-icon="phone"]').getAttribute('aria-pressed'), 'true');
|
assert.equal(await field.locator('.icon-field-name').textContent(), 'Telefon');
|
||||||
assert.equal(await page.evaluate(() => document.activeElement?.dataset.icon), 'phone', 'Fokus bleibt auf der gewählten Kachel');
|
checks.push('Telefon über die Kategorie „Kontakt & Ort“ gewählt');
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Vor dem Text', exact: true }).click();
|
await page.getByRole('button', { name: 'Vor dem Text', exact: true }).click();
|
||||||
await waitPreview({ path: 'M6 3h4', first: true });
|
await waitPreview({ first: true, has: ['icon-start'] });
|
||||||
checks.push('Telefon gewählt, dann vor den Text gestellt - Vorschau folgt');
|
await page.getByRole('button', { name: 'Rückt', exact: true }).click();
|
||||||
|
await waitPreview({ has: ['icon-motion-slide'] });
|
||||||
|
await page.getByRole('button', { name: 'Erscheint', exact: true }).click();
|
||||||
|
await waitPreview({ has: ['icon-motion-reveal'], not: ['icon-motion-slide'] });
|
||||||
|
await page.locator('#undo').click(); await waitPreview({ has: ['icon-motion-slide'] });
|
||||||
|
await page.locator('#redo').click(); await waitPreview({ has: ['icon-motion-reveal'] });
|
||||||
|
checks.push('Stellung und Bewegung, Rückgängig und Wiederholen');
|
||||||
|
|
||||||
// Tastatur: Pfeiltasten wandern durch die Kacheln, Enter wählt.
|
await page.getByLabel('Nur das Symbol zeigen').check();
|
||||||
await page.locator('.icon-tile[data-icon="phone"]').focus();
|
await waitPreview({ has: ['icon-only'] });
|
||||||
await page.keyboard.press('ArrowRight');
|
const label = await inPreview(() => document.querySelector('#preview').contentDocument.querySelector('#qa-icon-button .button-label')?.textContent);
|
||||||
assert.equal(await page.evaluate(() => document.activeElement?.dataset.icon), 'mail');
|
assert.equal(label, 'Jetzt anrufen', 'Beschriftung bleibt für Vorlesesoftware');
|
||||||
await page.keyboard.press('Enter');
|
await page.locator('.properties, #properties, aside').first().screenshot({ path: `${report}/panel-nachher.png` }).catch(() => {});
|
||||||
await waitPreview({ path: 'M3 6h18', first: true });
|
checks.push('Nur Symbol: Beschriftung unsichtbar im Link');
|
||||||
checks.push('Pfeiltaste und Enter wählen E-Mail');
|
|
||||||
|
|
||||||
await page.locator('#undo').click(); await waitPreview({ path: 'M6 3h4', first: true });
|
// Design: Gruppe „Symbol“ mit Farbe, Fläche, Größe, Drehung.
|
||||||
await page.locator('#redo').click(); await waitPreview({ path: 'M3 6h18', first: true });
|
await page.getByRole('button', { name: 'Farbe, Größe und Fläche des Symbols → Design' }).click();
|
||||||
checks.push('Rückgängig und Wiederholen');
|
const group = page.locator('.design-group[data-group="icon"]');
|
||||||
|
await group.waitFor();
|
||||||
await page.getByRole('button', { name: 'Kein Symbol', exact: true }).click();
|
assert.equal(await group.evaluate(el => el.open), true);
|
||||||
await waitPreview({ path: null });
|
for (const name of ['Symbolfarbe', 'Fläche hinter dem Symbol', 'Symbolgröße', 'Innenabstand der Fläche', 'Ecken der Fläche', 'Drehung', 'Abstand zum Text']) {
|
||||||
assert.equal(await page.getByRole('button', { name: 'Vor dem Text', exact: true }).count(), 0, 'Ohne Symbol keine Stellung');
|
assert.ok(await group.getByText(name, { exact: true }).count() > 0, `${name} fehlt in der Gruppe Symbol`);
|
||||||
assert.equal((await preview()).text, 'Jetzt anrufen');
|
}
|
||||||
await page.locator('#undo').click(); await waitPreview({ path: 'M3 6h18', first: true });
|
await group.locator('#style-iconSize').fill('24');
|
||||||
await picker.getByRole('button', { name: 'Pfeil nach rechts', exact: true }).click();
|
await group.locator('#style-iconSize').press('Enter');
|
||||||
await waitPreview({ path: 'M4 12h15.5', first: true });
|
await page.waitForFunction(() => {
|
||||||
await page.locator('.icon-picker').screenshot({ path: `${report}/auswahl.png` });
|
const doc = document.querySelector('#preview')?.contentDocument, svg = doc?.querySelector('#qa-icon-button .button-icon svg');
|
||||||
await page.locator('.icon-position').screenshot({ path: `${report}/stellung.png` });
|
return svg && Math.round(svg.getBoundingClientRect().width) === 24;
|
||||||
checks.push('kein Symbol, dann Pfeil nach rechts');
|
});
|
||||||
|
await group.screenshot({ path: `${report}/design-symbol.png` });
|
||||||
|
checks.push('Design → Symbol: alle sieben Werte da, Größe 24 px wirkt in der Vorschau');
|
||||||
|
|
||||||
await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
|
await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
|
||||||
const stored = (await (await context.request.get(api)).json()).document;
|
const stored = (await (await context.request.get(api)).json()).document;
|
||||||
assert.equal(stored.schemaVersion, 25, `vorher v${startVersion}`);
|
const button = stored.blocks[0].children[0];
|
||||||
assert.deepEqual(stored.blocks[0].children[0].props, { text: 'Jetzt anrufen', href: 'tel:+4972296992826', icon: 'arrow-right', iconPosition: 'start' });
|
assert.equal(stored.schemaVersion, 26);
|
||||||
|
assert.deepEqual(button.props, { text: 'Jetzt anrufen', href: 'tel:+4972296992826', icon: 'phone', iconPosition: 'start', iconMotion: 'reveal', iconOnly: true });
|
||||||
|
assert.equal(button.style.desktop.iconSize, '24px');
|
||||||
await page.reload(); await selectBlock('qa-icon-button');
|
await page.reload(); await selectBlock('qa-icon-button');
|
||||||
await page.locator('.icon-picker').waitFor();
|
await page.locator('.icon-field').waitFor();
|
||||||
assert.equal(await page.locator('.icon-tile[aria-pressed="true"]').getAttribute('data-icon'), 'arrow-right');
|
assert.equal(await page.locator('.icon-field-name').textContent(), 'Telefon');
|
||||||
assert.equal(await page.locator('.icon-position [aria-pressed="true"]').getAttribute('data-position'), 'start');
|
assert.equal(await page.getByLabel('Nur das Symbol zeigen').isChecked(), true);
|
||||||
await waitPreview({ path: 'M4 12h15.5', first: true });
|
checks.push('gespeichert als Schema v26 mit Größe unter Design, nach Neuladen gleich');
|
||||||
checks.push('gespeichert als Schema v25, nach Neuladen unverändert');
|
|
||||||
|
// So sieht es aus: Pfeil in einer runden Fläche, die sich beim Überfahren dreht und umfärbt,
|
||||||
|
// daneben ein Button nur aus Symbol.
|
||||||
|
const look = (await (await context.request.get(api)).json());
|
||||||
|
look.document.blocks[0].children = [
|
||||||
|
{ id: 'qa-look-a', type: 'button', props: { text: 'Projekt anfragen', href: '/kontakt', icon: 'arrow-right', iconMotion: 'slide' }, style: { desktop: { iconColor: '#ffffff', iconBackgroundColor: '#b71c22', iconPadding: '6px', iconRadius: '999px', iconSize: '16px', hover: { iconBackgroundColor: '#1e2a20', iconRotate: '-45deg' } } } },
|
||||||
|
{ id: 'qa-look-b', type: 'button', props: { text: 'Anrufen', href: 'tel:+4972296992826', icon: 'phone', iconOnly: true } },
|
||||||
|
{ id: 'qa-look-c', type: 'button', props: { text: 'Prospekt laden', href: '/', icon: 'download', iconPosition: 'start' } },
|
||||||
|
];
|
||||||
|
assert.equal((await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': look.csrfToken }, data: { document: look.document, expectedVersion: look.version } })).status(), 200);
|
||||||
|
await page.reload();
|
||||||
|
const frame = page.frameLocator('#preview');
|
||||||
|
await frame.locator('#qa-look-a .button-icon').waitFor();
|
||||||
|
const section = frame.locator('#qa-icons');
|
||||||
|
await section.screenshot({ path: `${report}/aussehen-normal.png` });
|
||||||
|
const styles = await frame.locator('#qa-look-a .button-icon').evaluate(el => { const s = getComputedStyle(el); return { bg: s.backgroundColor, radius: s.borderRadius, color: s.color }; });
|
||||||
|
assert.deepEqual(styles, { bg: 'rgb(183, 28, 34)', radius: '999px', color: 'rgb(255, 255, 255)' });
|
||||||
|
await frame.locator('#qa-look-a').hover();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
await section.screenshot({ path: `${report}/aussehen-hover.png` });
|
||||||
|
assert.equal(await frame.locator('#qa-look-a .button-icon').evaluate(el => getComputedStyle(el).rotate), '-45deg');
|
||||||
|
const only = await frame.locator('#qa-look-b').evaluate(el => ({ name: el.textContent.trim(), w: Math.round(el.getBoundingClientRect().width), h: Math.round(el.getBoundingClientRect().height) }));
|
||||||
|
assert.equal(only.name, 'Anrufen');
|
||||||
|
assert.ok(Math.abs(only.w - only.h) <= 2, `Nur-Symbol-Button ist nicht quadratisch: ${only.w}x${only.h}`);
|
||||||
|
checks.push('Aussehen: farbige runde Fläche, Drehung beim Überfahren, quadratischer Nur-Symbol-Button, Symbol vor dem Text');
|
||||||
|
await selectBlock('qa-look-c');
|
||||||
|
|
||||||
|
// Kein Symbol: Nur-Symbol fällt weg, die Einstellungen verschwinden.
|
||||||
|
await page.getByRole('button', { name: /Symbol ändern/ }).click();
|
||||||
|
await dialog.getByRole('button', { name: 'Kein Symbol', exact: true }).click();
|
||||||
|
await page.waitForFunction(() => { const el = document.querySelector('#preview')?.contentDocument?.getElementById('qa-look-c'); return el && !el.querySelector('.button-icon'); });
|
||||||
|
assert.equal(await page.getByRole('button', { name: 'Vor dem Text', exact: true }).count(), 0);
|
||||||
|
checks.push('Kein Symbol entfernt auch „nur Symbol“');
|
||||||
|
|
||||||
assert.deepEqual(errors, []);
|
assert.deepEqual(errors, []);
|
||||||
await writeFile(`${report}/report.json`, JSON.stringify({ origin, checks }, null, 2));
|
await writeFile(`${report}/report.json`, JSON.stringify({ origin, checks }, null, 2));
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ final class DocumentValidator
|
|||||||
/** Blocks introduced with schema v6 for the sections a marketing page actually needs. */
|
/** Blocks introduced with schema v6 for the sections a marketing page actually needs. */
|
||||||
public const V6_TYPES = ['iconcard', 'stat', 'quote', 'logos', 'cta', 'contact', 'divider', 'spacer', 'accordion', 'accordionitem'];
|
public const V6_TYPES = ['iconcard', 'stat', 'quote', 'logos', 'cta', 'contact', 'divider', 'spacer', 'accordion', 'accordionitem'];
|
||||||
/** Icons ship as inline SVG from this fixed list; no icon font and no external request. */
|
/** Icons ship as inline SVG from this fixed list; no icon font and no external request. */
|
||||||
public const ICONS = ['app', 'web', 'design', 'network', 'hardware', 'shop', 'support', 'shield', 'clock', 'star', 'phone', 'mail', 'pin', 'check', 'people', 'music', 'home', 'arrow-right', 'arrow-left', 'arrow-up-right', 'arrow-down', 'chevron-right', 'calendar', 'chat', 'send', 'download', 'document', 'play', 'search', 'heart', 'info', 'plus', 'user'];
|
public const ICONS = ['arrow-up-right', 'arrow-right', 'arrow-left', 'arrow-up', 'arrow-down', 'chevron-right', 'chevron-left', 'chevron-up', 'chevron-down', 'arrow-circle', 'external', 'refresh', 'menu', 'close', 'plus', 'minus', 'phone', 'app', 'mail', 'chat', 'send', 'calendar', 'clock', 'pin', 'map', 'navigation', 'web', 'wifi', 'support', 'shop', 'cart', 'tag', 'euro', 'credit-card', 'gift', 'percent', 'truck', 'package', 'briefcase', 'document', 'download', 'upload', 'print', 'link', 'share', 'image', 'camera', 'video', 'play', 'pause', 'music', 'mic', 'book', 'user', 'people', 'login', 'lock', 'key', 'shield', 'eye', 'thumbs-up', 'award', 'graduation', 'heart', 'star', 'home', 'building', 'wrench', 'hammer', 'brush', 'ruler', 'leaf', 'tree', 'sun', 'droplet', 'flame', 'bolt', 'solar', 'thermometer', 'snowflake', 'car', 'check', 'check-circle', 'info', 'help', 'alert', 'search', 'settings', 'filter', 'grid', 'list', 'sparkle', 'coffee', 'utensils', 'laptop', 'hardware', 'network', 'design'];
|
||||||
/** Das Symbol eines Buttons, wenn keins gewählt ist: der Pfeil, den jeder Button vor v25 hatte. */
|
/** Das Symbol eines Buttons, wenn keins gewählt ist: der Pfeil, den jeder Button vor v25 hatte. */
|
||||||
public const BUTTON_DEFAULT_ICON = 'arrow-up-right';
|
public const BUTTON_DEFAULT_ICON = 'arrow-up-right';
|
||||||
public const SPACER_SIZES = ['small', 'medium', 'large', 'huge'];
|
public const SPACER_SIZES = ['small', 'medium', 'large', 'huge'];
|
||||||
@@ -42,7 +42,7 @@ final class DocumentValidator
|
|||||||
public const V23_TYPES = ['social'];
|
public const V23_TYPES = ['social'];
|
||||||
public const V24_TYPES = ['html'];
|
public const V24_TYPES = ['html'];
|
||||||
/** Die aktuelle Fassung des Dokumentschemas - dieselbe Zahl wie SCHEMA_VERSION in editor-model.js. */
|
/** Die aktuelle Fassung des Dokumentschemas - dieselbe Zahl wie SCHEMA_VERSION in editor-model.js. */
|
||||||
public const SCHEMA_VERSION = 25;
|
public const SCHEMA_VERSION = 26;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param bool $requireHeading A page needs exactly one main heading. A header, a footer or a
|
* @param bool $requireHeading A page needs exactly one main heading. A header, a footer or a
|
||||||
@@ -247,6 +247,16 @@ final class DocumentValidator
|
|||||||
if (array_key_exists('iconPosition', $props)) {
|
if (array_key_exists('iconPosition', $props)) {
|
||||||
$this->require($schemaVersion >= 25 && in_array($props['iconPosition'], ['start', 'end'], true), 'Das Symbol steht vor oder nach dem Text und braucht Schema v25.');
|
$this->require($schemaVersion >= 25 && in_array($props['iconPosition'], ['start', 'end'], true), 'Das Symbol steht vor oder nach dem Text und braucht Schema v25.');
|
||||||
}
|
}
|
||||||
|
// Schema v26 (ADR 109): was das Symbol beim Überfahren tut, und ein Button nur aus
|
||||||
|
// Symbol. Der Text bleibt dann für Vorlesesoftware da - ohne Symbol wäre der Button
|
||||||
|
// unsichtbar, deshalb braucht "nur Symbol" ein Symbol.
|
||||||
|
if (array_key_exists('iconMotion', $props)) {
|
||||||
|
$this->require($schemaVersion >= 26 && in_array($props['iconMotion'], ['none', 'slide', 'reveal'], true), 'Unbekannte Bewegung des Symbols oder Dokumentschema vor v26.');
|
||||||
|
}
|
||||||
|
if (array_key_exists('iconOnly', $props)) {
|
||||||
|
$this->require($schemaVersion >= 26 && is_bool($props['iconOnly']), 'Nur Symbol ist an oder aus und braucht Schema v26.');
|
||||||
|
$this->require(!$props['iconOnly'] || 'none' !== ($props['icon'] ?? self::BUTTON_DEFAULT_ICON), 'Ein Button nur aus Symbol braucht ein Symbol.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if ('iconcard' === $type) {
|
if ('iconcard' === $type) {
|
||||||
$this->require(in_array($props['icon'] ?? null, self::ICONS, true), 'Unknown icon.');
|
$this->require(in_array($props['icon'] ?? null, self::ICONS, true), 'Unknown icon.');
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
@@ -32,7 +32,11 @@
|
|||||||
{% set iconMarkup = '' %}
|
{% set iconMarkup = '' %}
|
||||||
{% if buttonIcon != 'none' %}{% set iconMarkup %}<span class="button-icon{{ buttonIcon == 'arrow-up-right' ? ' button-arrow' }}" aria-hidden="true">{% include 'blocks/icon.html.twig' with {name: buttonIcon} only %}</span>{% endset %}{% endif %}
|
{% if buttonIcon != 'none' %}{% set iconMarkup %}<span class="button-icon{{ buttonIcon == 'arrow-up-right' ? ' button-arrow' }}" aria-hidden="true">{% include 'blocks/icon.html.twig' with {name: buttonIcon} only %}</span>{% endset %}{% endif %}
|
||||||
{% set iconFirst = props.iconPosition|default('end') == 'start' %}
|
{% set iconFirst = props.iconPosition|default('end') == 'start' %}
|
||||||
<a id="{{ block.id }}" class="button cms-button{{ designClasses }}" href="{{ props.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: props.newTab|default(false)} only %}>{% if iconMarkup and iconFirst %}{{ iconMarkup }} {% endif %}{{ props.text }}{% if iconMarkup and not iconFirst %} {{ iconMarkup }}{% endif %}</a>
|
{# Schema v26: Bewegung beim Überfahren und ein Button nur aus Symbol. Der Text steht dann
|
||||||
|
in einer Beschriftung ohne Breite und bleibt der Name des Links für Vorlesesoftware. #}
|
||||||
|
{% set iconOnly = iconMarkup and props.iconOnly|default(false) %}
|
||||||
|
{% if iconMarkup %}{% set designClasses = designClasses ~ (iconFirst ? ' icon-start' : '') ~ (props.iconMotion|default('none') != 'none' ? ' icon-motion-' ~ props.iconMotion : '') ~ (iconOnly ? ' icon-only' : '') %}{% endif %}
|
||||||
|
<a id="{{ block.id }}" class="button cms-button{{ designClasses }}" href="{{ props.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: props.newTab|default(false)} only %}>{% if iconMarkup and iconFirst %}{{ iconMarkup }} {% endif %}{% if iconOnly %}<span class="button-label">{{ props.text }}</span>{% else %}{{ props.text }}{% endif %}{% if iconMarkup and not iconFirst %} {{ iconMarkup }}{% endif %}</a>
|
||||||
{% elseif type == 'image' %}
|
{% elseif type == 'image' %}
|
||||||
{# Wie breit das Bild tatsaechlich erscheint. Steht im Block eine feste Breite - ein Logo mit
|
{# Wie breit das Bild tatsaechlich erscheint. Steht im Block eine feste Breite - ein Logo mit
|
||||||
190px, ein Symbol mit 72px -, dann ist das die Antwort, und der Browser laedt die kleine
|
190px, ein Symbol mit 72px -, dann ist das die Antwort, und der Browser laedt die kleine
|
||||||
|
|||||||
@@ -1,37 +1,102 @@
|
|||||||
{# Inline SVG from a fixed allowlist. No icon font, no external request, no extra stylesheet. #}
|
{# Inline SVG from a fixed allowlist. No icon font, no external request, no extra stylesheet. #}
|
||||||
{% set paths = {
|
{% set paths = {
|
||||||
app: 'M7 2h10a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Zm4 17h2',
|
'arrow-up-right': 'M6.5 17.5 17.5 6.5M8.5 6.5h9v9',
|
||||||
web: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 0c2.5 2.4 3.8 5.4 3.8 9s-1.3 6.6-3.8 9m0-18C9.5 5.4 8.2 8.4 8.2 12s1.3 6.6 3.8 9M3.2 9h17.6M3.2 15h17.6',
|
|
||||||
design: 'M4 20h16M6 16 18 4l3 3L9 19l-4 1Z',
|
|
||||||
network: 'M5 3h14v5H5Zm0 13h14v5H5Zm7-8v8M8 8v3h8V8',
|
|
||||||
hardware: 'M4 5h16v11H4Zm4 15h8m-4-4v4',
|
|
||||||
shop: 'M4 7h16l-1.4 11.2a2 2 0 0 1-2 1.8H7.4a2 2 0 0 1-2-1.8Zm4 0a4 4 0 0 1 8 0',
|
|
||||||
support: 'M12 3a7 7 0 0 0-7 7v5m14-5a7 7 0 0 0-7-7m7 7v5m-14 0a2 2 0 0 0 2 2h1v-5H5Zm14 0a2 2 0 0 1-2 2h-1v-5h3Z',
|
|
||||||
shield: 'M12 3 5 6v6c0 4.4 2.9 7.9 7 9 4.1-1.1 7-4.6 7-9V6Zm-2.5 9 2 2 4-4.5',
|
|
||||||
clock: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 4v5.5l3.5 2',
|
|
||||||
star: 'm12 3 2.7 5.9 6.3.7-4.7 4.3 1.3 6.1L12 17l-5.6 3 1.3-6.1L3 9.6l6.3-.7Z',
|
|
||||||
phone: 'M6 3h4l2 5-2.5 1.5a12 12 0 0 0 5 5L16 12l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 4 5a2 2 0 0 1 2-2Z',
|
|
||||||
mail: 'M3 6h18v12H3Zm0 1 9 7 9-7',
|
|
||||||
pin: 'M12 21s7-6.3 7-11a7 7 0 1 0-14 0c0 4.7 7 11 7 11Zm0-8.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z',
|
|
||||||
check: 'm4 12.5 5.5 5.5L20 6',
|
|
||||||
people: 'M9.5 11.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7ZM2.5 20v-1c0-3.3 3-5 7-5s7 1.7 7 5v1M16.5 5.3a3.2 3.2 0 0 1 0 6.4M18 14.4c2.1.7 3.5 2.1 3.5 4.1V20',
|
|
||||||
music: 'M10 18V5.5L20 3v12.5M10 9.5 20 7M10 18a3 2.5 0 1 0-6 0 3 2.5 0 0 0 6 0ZM20 15.5a3 2.5 0 1 0-6 0 3 2.5 0 0 0 6 0Z',
|
|
||||||
home: 'm3 11.5 9-7.5 9 7.5M5.5 10v10h13V10M10 20v-6.5h4V20',
|
|
||||||
'arrow-right': 'M4 12h15.5m-6.5-6.5 6.5 6.5-6.5 6.5',
|
'arrow-right': 'M4 12h15.5m-6.5-6.5 6.5 6.5-6.5 6.5',
|
||||||
'arrow-left': 'M20 12H4.5M11 5.5 4.5 12 11 18.5',
|
'arrow-left': 'M20 12H4.5M11 5.5 4.5 12 11 18.5',
|
||||||
'arrow-up-right': 'M6.5 17.5 17.5 6.5M8.5 6.5h9v9',
|
'arrow-up': 'M12 20V4.5m-6.5 6.5L12 4.5l6.5 6.5',
|
||||||
'arrow-down': 'M12 4v15.5m-6.5-6.5 6.5 6.5 6.5-6.5',
|
'arrow-down': 'M12 4v15.5m-6.5-6.5 6.5 6.5 6.5-6.5',
|
||||||
'chevron-right': 'm9 5.5 6.5 6.5L9 18.5',
|
'chevron-right': 'm9 5.5 6.5 6.5L9 18.5',
|
||||||
calendar: 'M4 6h16v14H4Zm0 5h16M8 3.5v4m8-4v4',
|
'chevron-left': 'M15 5.5 8.5 12l6.5 6.5',
|
||||||
|
'chevron-up': 'm5.5 15 6.5-6.5 6.5 6.5',
|
||||||
|
'chevron-down': 'm5.5 9 6.5 6.5L18.5 9',
|
||||||
|
'arrow-circle': 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm-4 9h8m-3-3 3 3-3 3',
|
||||||
|
external: 'M14 4h6v6m0-6-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5',
|
||||||
|
refresh: 'M20 12a8 8 0 1 1-2.3-5.6M20 4v5h-5',
|
||||||
|
menu: 'M4 7h16M4 12h16M4 17h16',
|
||||||
|
close: 'M6 6l12 12M18 6 6 18',
|
||||||
|
plus: 'M12 5v14M5 12h14',
|
||||||
|
minus: 'M5 12h14',
|
||||||
|
phone: 'M6 3h4l2 5-2.5 1.5a12 12 0 0 0 5 5L16 12l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 4 5a2 2 0 0 1 2-2Z',
|
||||||
|
app: 'M7 2h10a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Zm4 17h2',
|
||||||
|
mail: 'M3 6h18v12H3Zm0 1 9 7 9-7',
|
||||||
chat: 'M4 5h16v11H9.5L5 19.5V16H4Z',
|
chat: 'M4 5h16v11H9.5L5 19.5V16H4Z',
|
||||||
send: 'M21 3 10 14M21 3l-6.5 18-4.5-7-7-4.5Z',
|
send: 'M21 3 10 14M21 3l-6.5 18-4.5-7-7-4.5Z',
|
||||||
download: 'M12 3.5v12m-5-5 5 5 5-5M4.5 20h15',
|
calendar: 'M4 6h16v14H4Zm0 5h16M8 3.5v4m8-4v4',
|
||||||
|
clock: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 4v5.5l3.5 2',
|
||||||
|
pin: 'M12 21s7-6.3 7-11a7 7 0 1 0-14 0c0 4.7 7 11 7 11Zm0-8.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z',
|
||||||
|
map: 'M9 4 3 6.5V20l6-2.5 6 2.5 6-2.5V4l-6 2.5ZM9 4v13.5m6-11V20',
|
||||||
|
navigation: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm3.5 5.5-2 5-5 2 2-5Z',
|
||||||
|
web: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 0c2.5 2.4 3.8 5.4 3.8 9s-1.3 6.6-3.8 9m0-18C9.5 5.4 8.2 8.4 8.2 12s1.3 6.6 3.8 9M3.2 9h17.6M3.2 15h17.6',
|
||||||
|
wifi: 'M3 9.5a13 13 0 0 1 18 0M6 13a8 8 0 0 1 12 0m-9 3.5a3.5 3.5 0 0 1 6 0M12 19.5h.01',
|
||||||
|
support: 'M12 3a7 7 0 0 0-7 7v5m14-5a7 7 0 0 0-7-7m7 7v5m-14 0a2 2 0 0 0 2 2h1v-5H5Zm14 0a2 2 0 0 1-2 2h-1v-5h3Z',
|
||||||
|
shop: 'M4 7h16l-1.4 11.2a2 2 0 0 1-2 1.8H7.4a2 2 0 0 1-2-1.8Zm4 0a4 4 0 0 1 8 0',
|
||||||
|
cart: 'M3 4h2.5l2.2 10.5h10.6L20.5 7H6.3M9.5 18a1.2 1.2 0 1 0 0 2.4 1.2 1.2 0 0 0 0-2.4Zm7.5 0a1.2 1.2 0 1 0 0 2.4 1.2 1.2 0 0 0 0-2.4Z',
|
||||||
|
tag: 'M3.5 12.5v-8a1 1 0 0 1 1-1h8l8 8a1.4 1.4 0 0 1 0 2l-7 7a1.4 1.4 0 0 1-2 0ZM8.5 8h.01',
|
||||||
|
euro: 'M17.5 6.5A6.5 6.5 0 1 0 17.5 17.5M4 10h9m-9 4h9',
|
||||||
|
'credit-card': 'M3 6h18v12H3Zm0 4h18M6.5 15h3',
|
||||||
|
gift: 'M4 9h16v4H4Zm1.5 4v7h13v-7M12 9v11m0-11S10.5 4 8 4.5 7.5 9 12 9Zm0 0s1.5-5 4-4.5S16.5 9 12 9Z',
|
||||||
|
percent: 'M19 5 5 19M7 5a2 2 0 1 0 0 4 2 2 0 0 0 0-4Zm10 10a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z',
|
||||||
|
truck: 'M2.5 6h11v10.5h-11Zm11 3.5h4l3 3.5v3.5h-7ZM6.5 17a1.8 1.8 0 1 0 0 3.6 1.8 1.8 0 0 0 0-3.6Zm10.5 0a1.8 1.8 0 1 0 0 3.6 1.8 1.8 0 0 0 0-3.6Z',
|
||||||
|
package: 'M3.5 7.5 12 3l8.5 4.5v9L12 21l-8.5-4.5Zm0 0L12 12l8.5-4.5M12 12v9M7.8 5.2l8.5 4.6',
|
||||||
|
briefcase: 'M3.5 7.5h17v12h-17Zm5-3h7v3h-7Zm-5 8h17',
|
||||||
document: 'M6 3h8l4 4v14H6Zm8 0v4h4M9 12h6m-6 4h6',
|
document: 'M6 3h8l4 4v14H6Zm8 0v4h4M9 12h6m-6 4h6',
|
||||||
|
download: 'M12 3.5v12m-5-5 5 5 5-5M4.5 20h15',
|
||||||
|
upload: 'M12 15.5V4m-5 5 5-5 5 5M4.5 20h15',
|
||||||
|
print: 'M7 9V3.5h10V9M7 17H4.5V9h15v8H17M7 14h10v6.5H7Z',
|
||||||
|
link: 'M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1.2 1.2M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1.2-1.2',
|
||||||
|
share: 'M18 3.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM6 9.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5Zm12 6a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM8.2 10.8l7.6-4.1m-7.6 6.5 7.6 4.1',
|
||||||
|
image: 'M4 4h16v16H4Zm0 12 4.5-4.5 3.5 3.5 2.5-2.5L20 18M15 8.5h.01',
|
||||||
|
camera: 'M3 7.5h4L8.5 5h7L17 7.5h4V19H3Zm9 2.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z',
|
||||||
|
video: 'M3 6.5h12v11H3Zm12 4 6-3.5v10l-6-3.5',
|
||||||
play: 'M8 5.5v13l10-6.5Z',
|
play: 'M8 5.5v13l10-6.5Z',
|
||||||
search: 'M10.5 4a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13Zm4.8 11.3L20 20',
|
pause: 'M8 5v14m8-14v14',
|
||||||
|
music: 'M10 18V5.5L20 3v12.5M10 9.5 20 7M10 18a3 2.5 0 1 0-6 0 3 2.5 0 0 0 6 0ZM20 15.5a3 2.5 0 1 0-6 0 3 2.5 0 0 0 6 0Z',
|
||||||
|
mic: 'M12 3a3 3 0 0 0-3 3v5.5a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3ZM5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21',
|
||||||
|
book: 'M5 4.5A1.5 1.5 0 0 1 6.5 3H19v15H6.5A1.5 1.5 0 0 0 5 19.5Zm0 15A1.5 1.5 0 0 0 6.5 21H19v-3',
|
||||||
|
user: 'M12 11.5a4 4 0 1 0 0-8 4 4 0 0 0 0 8ZM4.5 20.5c0-3.9 3.4-6 7.5-6s7.5 2.1 7.5 6',
|
||||||
|
people: 'M9.5 11.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7ZM2.5 20v-1c0-3.3 3-5 7-5s7 1.7 7 5v1M16.5 5.3a3.2 3.2 0 0 1 0 6.4M18 14.4c2.1.7 3.5 2.1 3.5 4.1V20',
|
||||||
|
login: 'M14 4h5a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1h-5M3.5 12h11m-4-4 4 4-4 4',
|
||||||
|
lock: 'M5 10.5h14V21H5Zm3 0V7a4 4 0 0 1 8 0v3.5M12 14.5v3',
|
||||||
|
key: 'M7.5 11.5a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm2.8 1.2L19 4m-3 3 2.5 2.5M13 10l2 2',
|
||||||
|
shield: 'M12 3 5 6v6c0 4.4 2.9 7.9 7 9 4.1-1.1 7-4.6 7-9V6Zm-2.5 9 2 2 4-4.5',
|
||||||
|
eye: 'M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Zm9.5-3a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z',
|
||||||
|
'thumbs-up': 'M7 10.5V20H4v-9.5Zm0 0 3.5-7a2 2 0 0 1 2.5 2v3.5h5.2a2 2 0 0 1 2 2.4l-1.4 7a2 2 0 0 1-2 1.6H7',
|
||||||
|
award: 'M12 3a5.5 5.5 0 1 0 0 11 5.5 5.5 0 0 0 0-11Zm-3.5 10L7 21l5-2.5 5 2.5-1.5-8',
|
||||||
|
graduation: 'M2.5 9 12 4.5 21.5 9 12 13.5Zm4 2.3V16c0 1.5 2.5 3 5.5 3s5.5-1.5 5.5-3v-4.7M21.5 9v5',
|
||||||
heart: 'M12 20s-7.5-4.6-7.5-10A4.3 4.3 0 0 1 12 7.4 4.3 4.3 0 0 1 19.5 10c0 5.4-7.5 10-7.5 10Z',
|
heart: 'M12 20s-7.5-4.6-7.5-10A4.3 4.3 0 0 1 12 7.4 4.3 4.3 0 0 1 19.5 10c0 5.4-7.5 10-7.5 10Z',
|
||||||
|
star: 'm12 3 2.7 5.9 6.3.7-4.7 4.3 1.3 6.1L12 17l-5.6 3 1.3-6.1L3 9.6l6.3-.7Z',
|
||||||
|
home: 'm3 11.5 9-7.5 9 7.5M5.5 10v10h13V10M10 20v-6.5h4V20',
|
||||||
|
building: 'M5 21V4h10v17m0-12h4v12M3 21h18M8.5 7.5h3m-3 3.5h3m-3 3.5h3',
|
||||||
|
wrench: 'M14.5 3.5a5 5 0 0 0-4.6 6.9L3.5 16.8a1.9 1.9 0 0 0 2.7 2.7l6.4-6.4a5 5 0 0 0 6.9-4.6l-3 3-3-1-1-3Z',
|
||||||
|
hammer: 'M14 8.5 4.5 18a1.5 1.5 0 0 0 2.1 2.1L16 10.5M11 6l3-3 3.5 1L21 7.5 18.5 10l-2-2-2.5 2.5Z',
|
||||||
|
brush: 'M4 4h13v5H4Zm13 2.5h2.5V12h-8v3M10 15h3v6h-3Z',
|
||||||
|
ruler: 'M3.5 16 16 3.5 20.5 8 8 20.5ZM7 13l2 2m1-5 2 2m1-5 2 2',
|
||||||
|
leaf: 'M5 19C5 10 10 5 20 4c-1 10-6 15-15 15Zm0 0 8-8',
|
||||||
|
tree: 'M12 21v-5m0-13-6 8h3l-4 5h14l-4-5h3Z',
|
||||||
|
sun: 'M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm0-5v2m0 14v2M3 12h2m14 0h2M5.6 5.6 7 7m10 10 1.4 1.4M5.6 18.4 7 17M17 7l1.4-1.4',
|
||||||
|
droplet: 'M12 3s6.5 7 6.5 11.5a6.5 6.5 0 0 1-13 0C5.5 10 12 3 12 3Z',
|
||||||
|
flame: 'M12 21a6 6 0 0 0 6-6c0-4.5-4-6.5-4.5-11-2.5 2-5 5-5 8.5-1-.5-1.5-1.5-1.8-2.5C5.6 11.5 6 13 6 15a6 6 0 0 0 6 6Z',
|
||||||
|
bolt: 'M13 2.5 4.5 13.5H12l-1 8 8.5-11H12Z',
|
||||||
|
solar: 'M5 4h15l-2 9.5H3Zm-1 4.75h15M10 4 8.5 13.5M15.5 4 14 13.5M10.5 13.5V18M7 20.5h7',
|
||||||
|
thermometer: 'M10 14.5V5a2 2 0 0 1 4 0v9.5a4 4 0 1 1-4 0Z',
|
||||||
|
snowflake: 'M12 2.5v19M3.8 7.3l16.4 9.4M3.8 16.7l16.4-9.4M9.5 4 12 6l2.5-2m-5 16 2.5-2 2.5 2',
|
||||||
|
car: 'M5 16.5V12l2-5h10l2 5v4.5M3 12h18v4.5H3Zm3.5 4.5V19m11-2.5V19M7 14h.01M17 14h.01',
|
||||||
|
check: 'm4 12.5 5.5 5.5L20 6',
|
||||||
|
'check-circle': 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm-4 9.2 2.8 2.8L16.5 9',
|
||||||
info: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 8v5.5m0-9v.5',
|
info: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 8v5.5m0-9v.5',
|
||||||
plus: 'M12 5v14M5 12h14',
|
help: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18ZM9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.6.3-1 .9-1 1.6v.6m0 3v.01',
|
||||||
user: 'M12 11.5a4 4 0 1 0 0-8 4 4 0 0 0 0 8ZM4.5 20.5c0-3.9 3.4-6 7.5-6s7.5 2.1 7.5 6'
|
alert: 'M12 3.5 2.5 20h19Zm0 6V14m0 3v.01',
|
||||||
|
search: 'M10.5 4a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13Zm4.8 11.3L20 20',
|
||||||
|
settings: 'M4 7h9m4 0h3M4 17h3m4 0h9M15 5v4M9 15v4',
|
||||||
|
filter: 'M3.5 5h17l-6.5 8v6l-4 2v-8Z',
|
||||||
|
grid: 'M4 4h7v7H4Zm9 0h7v7h-7ZM4 13h7v7H4Zm9 0h7v7h-7Z',
|
||||||
|
list: 'M9 6h11M9 12h11M9 18h11M4.5 6h.01M4.5 12h.01M4.5 18h.01',
|
||||||
|
sparkle: 'M12 3c.6 4.4 2.6 6.4 7 7-4.4.6-6.4 2.6-7 7-.6-4.4-2.6-6.4-7-7 4.4-.6 6.4-2.6 7-7Zm6.5 12.5c.2 1.6 1 2.3 2.5 2.5-1.5.2-2.3 1-2.5 2.5-.2-1.5-1-2.3-2.5-2.5 1.5-.2 2.3-1 2.5-2.5Z',
|
||||||
|
coffee: 'M4 9h12v6a5 5 0 0 1-5 5H9a5 5 0 0 1-5-5Zm12 1.5h1.5a2.5 2.5 0 0 1 0 5H16M8 3.5V6m4-2.5V6',
|
||||||
|
utensils: 'M6 3v7a2 2 0 0 0 4 0V3M8 10v11m8.5 0V3c-2 1-3 3.5-3 6.5v4h3',
|
||||||
|
laptop: 'M5 5h14v10H5Zm-2.5 10h19l-1 3.5h-17Z',
|
||||||
|
hardware: 'M4 5h16v11H4Zm4 15h8m-4-4v4',
|
||||||
|
network: 'M5 3h14v5H5Zm0 13h14v5H5Zm7-8v8M8 8v3h8V8',
|
||||||
|
design: 'M4 20h16M6 16 18 4l3 3L9 19l-4 1Z'
|
||||||
} %}
|
} %}
|
||||||
<svg class="block-icon" viewBox="0 0 24 24" width="32" height="32" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="{{ paths[name] }}"/></svg>
|
<svg class="block-icon" viewBox="0 0 24 24" width="32" height="32" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="{{ paths[name] }}"/></svg>
|
||||||
@@ -315,6 +315,63 @@ final class CmsTest extends TestCase
|
|||||||
yield 'Symbol ist kein Text' => ['icon', ['phone'], 25];
|
yield 'Symbol ist kein Text' => ['icon', ['phone'], 25];
|
||||||
yield 'Stellung vor v25' => ['iconPosition', 'start', 24];
|
yield 'Stellung vor v25' => ['iconPosition', 'start', 24];
|
||||||
yield 'unbekannte Stellung' => ['iconPosition', 'middle', 25];
|
yield 'unbekannte Stellung' => ['iconPosition', 'middle', 25];
|
||||||
|
yield 'Bewegung vor v26' => ['iconMotion', 'slide', 25];
|
||||||
|
yield 'unbekannte Bewegung' => ['iconMotion', 'spin', 26];
|
||||||
|
yield 'Nur Symbol vor v26' => ['iconOnly', true, 25];
|
||||||
|
yield 'Nur Symbol ist kein Schalter' => ['iconOnly', 'ja', 26];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAButtonWithoutAnIconCannotBeIconOnly(): void
|
||||||
|
{
|
||||||
|
$document = DemoContent::pages()['home'];
|
||||||
|
$document['schemaVersion'] = 26;
|
||||||
|
$document['blocks'][0]['children'][3]['props'] += ['icon' => 'none', 'iconOnly' => true];
|
||||||
|
$this->expectException(InvalidDocument::class);
|
||||||
|
(new DocumentValidator())->validate($document);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema v26 (ADR 109): Bewegung beim Überfahren und ein Button nur aus Symbol. Der Text
|
||||||
|
* bleibt als unsichtbare Beschriftung der Name des Links.
|
||||||
|
*/
|
||||||
|
public function testAButtonIconMovesOnHoverOrStandsAlone(): void
|
||||||
|
{
|
||||||
|
$document = DemoContent::pages()['home'];
|
||||||
|
$button = $document['blocks'][0]['children'][3];
|
||||||
|
$document['schemaVersion'] = 26;
|
||||||
|
$document['blocks'][0]['children'][3]['props'] += ['icon' => 'phone', 'iconMotion' => 'slide', 'iconOnly' => true];
|
||||||
|
$id = $this->pages->saveDraft('page-1', $document, 0);
|
||||||
|
$this->pages->publish('page-1', $id, 1);
|
||||||
|
$html = (string) $this->request('/')->getContent();
|
||||||
|
self::assertMatchesRegularExpression('/<a id="'.$button['id'].'" class="button cms-button[^"]* icon-motion-slide icon-only"[^>]*><span class="button-label">'.preg_quote($button['props']['text'], '/').'<\/span> <span class="button-icon"/', $html);
|
||||||
|
|
||||||
|
$document['blocks'][0]['children'][3]['props'] = ['text' => $button['props']['text'], 'href' => $button['props']['href'], 'icon' => 'download', 'iconPosition' => 'start', 'iconMotion' => 'reveal'];
|
||||||
|
$id = $this->pages->saveDraft('page-1', $document, 2);
|
||||||
|
$this->pages->publish('page-1', $id, 3);
|
||||||
|
$html = (string) $this->request('/')->getContent();
|
||||||
|
self::assertMatchesRegularExpression('/<a id="'.$button['id'].'" class="button cms-button[^"]* icon-start icon-motion-reveal"/', $html);
|
||||||
|
$css = (string) file_get_contents(__DIR__.'/../public/assets/site.css');
|
||||||
|
self::assertStringContainsString('.cms-button.icon-motion-reveal:is(:hover,:focus-visible) .button-icon{max-width:4em;opacity:1', $css);
|
||||||
|
self::assertStringContainsString('.cms-button .button-icon>svg{width:var(--icon-size,1.15em)', $css);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Farbe, Fläche, Größe und Drehung des Symbols liegen am Button und treffen seine
|
||||||
|
* Symbolhülle - je Gerät und beim Überfahren, auch bei Tastaturfokus.
|
||||||
|
*/
|
||||||
|
public function testButtonIconStylesTargetTheIconPerDeviceAndOnHover(): void
|
||||||
|
{
|
||||||
|
$document = ['schemaVersion' => 26, 'title' => 'T', 'description' => '', 'blocks' => [['id' => 'b1', 'type' => 'button', 'props' => ['text' => 'Anrufen', 'href' => '/', 'icon' => 'phone'], 'style' => [
|
||||||
|
'desktop' => ['iconColor' => '@accent', 'iconBackgroundColor' => '#ffffff', 'iconSize' => '20px', 'iconPadding' => '8px', 'iconRadius' => '999px', 'iconGap' => '12px', 'hover' => ['iconColor' => '#000000', 'iconRotate' => '45deg']],
|
||||||
|
'mobile' => ['iconSize' => '16px'],
|
||||||
|
]]]];
|
||||||
|
$css = (new StyleCompiler())->compileDocument($document);
|
||||||
|
self::assertStringContainsString('#b1 .button-icon{color:var(--k-accent);background-color:#ffffff;--icon-size:20px;padding:8px;border-radius:999px}', $css);
|
||||||
|
self::assertStringContainsString('#b1:hover .button-icon,#b1:focus-visible .button-icon{color:#000000;rotate:45deg}', $css);
|
||||||
|
self::assertStringContainsString('--icon-gap:12px', $css);
|
||||||
|
self::assertMatchesRegularExpression('/@media \(max-width:\d+px\)\{#b1 \.button-icon\{--icon-size:16px\}\}/', $css);
|
||||||
|
// Die Werte gelten nur der Symbolhülle, nicht dem Button selbst.
|
||||||
|
self::assertStringNotContainsString('#b1{color:var(--k-accent)', $css);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[DataProvider('rejectedButtonIcons')]
|
#[DataProvider('rejectedButtonIcons')]
|
||||||
|
|||||||
@@ -661,4 +661,6 @@ test('a loaded document is raised to the schema its blocks and properties need',
|
|||||||
assert.equal(repairSchema(symbol).schemaVersion, 25);
|
assert.equal(repairSchema(symbol).schemaVersion, 25);
|
||||||
const pfeil = { schemaVersion: 24, title: 'P', description: '', blocks: [{ id: 'b', type: 'button', props: { text: 'Mehr', href: '/' } }] };
|
const pfeil = { schemaVersion: 24, title: 'P', description: '', blocks: [{ id: 'b', type: 'button', props: { text: 'Mehr', href: '/' } }] };
|
||||||
assert.equal(repairSchema(pfeil).schemaVersion, 24);
|
assert.equal(repairSchema(pfeil).schemaVersion, 24);
|
||||||
|
const bewegt = { schemaVersion: 24, title: 'B', description: '', blocks: [{ id: 'b', type: 'button', props: { text: 'Mehr', href: '/', iconMotion: 'slide' } }] };
|
||||||
|
assert.equal(repairSchema(bewegt).schemaVersion, 26);
|
||||||
});
|
});
|
||||||
+11
-11
@@ -1,29 +1,29 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { ICON_PATHS, ICON_LABELS, BUTTON_ICON_GROUPS, ALL_ICON_GROUPS } from '../public/assets/icons.js';
|
import { ICON_PATHS, ICON_LABELS, ICON_GROUPS } from '../public/assets/icons.js';
|
||||||
|
|
||||||
const read = path => readFileSync(new URL(path, import.meta.url), 'utf8');
|
const read = path => readFileSync(new URL(path, import.meta.url), 'utf8');
|
||||||
|
|
||||||
test('the editor draws the same icons the website renders and the server allows', () => {
|
test('the editor draws the same icons the website renders and the server allows', () => {
|
||||||
// Drei Listen, ein Satz: die Zeichnungen der Website (Twig), die Vorschau im Editor und die
|
// Drei Listen, ein Satz: die Zeichnungen der Website (Twig), die Vorschau im Editor und die
|
||||||
// erlaubten Namen im Validator. Weicht eine ab, zeigt die Auswahl ein anderes Symbol als die
|
// erlaubten Namen im Validator. Weicht eine ab, zeigt die Auswahl ein anderes Symbol als die
|
||||||
// Seite - oder bietet eins an, das der Server beim Speichern ablehnt.
|
// Seite - oder bietet eins an, das der Server beim Speichern ablehnt. Abhilfe: node scripts/build-icons.mjs
|
||||||
const twig = read('../templates/blocks/icon.html.twig');
|
const twig = read('../templates/blocks/icon.html.twig');
|
||||||
const website = Object.fromEntries([...twig.matchAll(/^\s{4}'?([a-z-]+)'?: '([^']+)',?$/gm)].map(([, name, path]) => [name, path]));
|
const website = Object.fromEntries([...twig.matchAll(/^\s{4}'?([a-z-]+)'?: '([^']+)',?$/gm)].map(([, name, path]) => [name, path]));
|
||||||
assert.deepEqual(ICON_PATHS, website);
|
assert.deepEqual(ICON_PATHS, website);
|
||||||
const validator = read('../src/Content/DocumentValidator.php');
|
const validator = read('../src/Content/DocumentValidator.php');
|
||||||
const allowed = [...validator.match(/public const ICONS = \[([^\]]+)\]/)[1].matchAll(/'([a-z-]+)'/g)].map(([, name]) => name);
|
const allowed = [...validator.match(/public const ICONS = \[([^\]]+)\]/)[1].matchAll(/'([a-z-]+)'/g)].map(([, name]) => name);
|
||||||
assert.deepEqual(Object.keys(ICON_PATHS).sort(), [...allowed].sort());
|
assert.deepEqual(Object.keys(ICON_PATHS), allowed);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('every icon has a German name and the pickers offer each icon exactly once', () => {
|
test('every icon has a German name and sits in exactly one category of the dialog', () => {
|
||||||
assert.deepEqual(Object.keys(ICON_LABELS).sort(), Object.keys(ICON_PATHS).sort());
|
assert.deepEqual(Object.keys(ICON_LABELS).sort(), Object.keys(ICON_PATHS).sort());
|
||||||
const button = BUTTON_ICON_GROUPS.flatMap(([, names]) => names);
|
const listed = ICON_GROUPS.flatMap(([, names]) => names);
|
||||||
assert.equal(new Set(button).size, button.length, 'Ein Symbol steht doppelt in der Button-Auswahl.');
|
assert.equal(new Set(listed).size, listed.length, 'Ein Symbol steht in zwei Kategorien.');
|
||||||
for (const name of button) assert.ok(ICON_PATHS[name], `${name} hat keine Zeichnung.`);
|
assert.deepEqual([...listed].sort(), Object.keys(ICON_PATHS).sort(), 'Jedes Symbol steht in einer Kategorie.');
|
||||||
// Der bisherige Pfeil steht vorn: er ist das, was ein Button ohne eigene Wahl trägt.
|
// Der bisherige Pfeil steht vorn: ihn trägt ein Button ohne eigene Wahl.
|
||||||
assert.equal(button[0], 'arrow-up-right');
|
assert.equal(ICON_GROUPS[0][1][0], 'arrow-up-right');
|
||||||
const all = ALL_ICON_GROUPS.flatMap(([, names]) => names);
|
assert.ok(listed.length >= 90, `Nur ${listed.length} Symbole.`);
|
||||||
assert.deepEqual([...all].sort(), Object.keys(ICON_PATHS).sort(), 'Symbol und Symbolkarte bieten jedes Symbol genau einmal an.');
|
for (const path of Object.values(ICON_PATHS)) assert.match(path, /^[Mm][0-9MmLlHhVvCcSsQqTtAaZz .,-]+$/, 'Ein Pfad enthält Zeichen, die keine Zeichnung sind.');
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user