diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fde7357..11a8602 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -178,3 +178,17 @@ Die Startseite von kant-it.de besteht aus Abschnittsarten, die sich aus Sektion, `logos` verhält sich wie `gallery`: ein Container, der ausschließlich Bilder aufnimmt. Die Beschränkung steht im Validator und wird im Editormodell (`ONLY_CHILD`) gespiegelt, damit die Oberfläche gar nicht erst etwas anbietet, was der Server ablehnen würde. Die neuen Typen sind im Tokenmanifest für Außenabstände eingetragen, teils auch für Ausrichtung, Ecken, Spalten und Innenabstand. Die generierte `design.css` ändert sich dadurch nicht, weil sie je Wert und nicht je Blocktyp erzeugt wird. + +## ADR 016 – Tabs als fortschreitende Verbesserung (Dokumentschema v7, 12.09.2026) + +ADR 015 hatte Tabs zurückgestellt, weil es ohne JavaScript keine zugängliche Lösung dafür gibt. Umut hat entschieden, Tabs mit JavaScript umzusetzen. Damit wird eine Grundregel des Plans berührt: „der eigentliche Seiteninhalt benötigt kein Browser-JavaScript“. Diese Regel bleibt gültig, und die Umsetzung hält sie ein. + +**Der Server liefert vollständigen Inhalt.** Ein Tab-Block wird als Ankerliste gefolgt von allen Panels ausgegeben, jedes mit eigener Überschrift. Ohne JavaScript ist damit jeder Inhalt lesbar, verlinkbar und über die Tastatur erreichbar – die Seite ist nicht kaputt, sondern nur weniger kompakt. Kein Panel wird serverseitig versteckt; Verstecken ist ausschließlich Aufgabe des Skripts. + +`public/assets/tabs.js` ergänzt anschließend `tablist`, `tab` und `tabpanel`, die Auswahl per Pfeiltasten, Home und Ende sowie `aria-selected`. Die Überschrift eines Panels wandert dabei in eine visuell versteckte Klasse, weil der Tab selbst das Panel benennt. Ein Sprungziel aus einer fremden Verlinkung wählt das passende Panel aus, statt es verborgen zu lassen. + +**Die strenge Sicherheitsrichtlinie bleibt die Voreinstellung.** Öffentliche Seiten senden weiterhin `script-src 'none'`. Nur eine Seite, die tatsächlich einen Tab-Block enthält, sendet `script-src 'self'` und bindet die eine externe Datei ein; Inline-Skript ist nie erlaubt. Ohne Tabs auf der Seite ändert sich nichts, auch nicht auf der 404-Seite, in Archiven oder im Feed. Der Controller ermittelt dafür beim Ausliefern, ob das Dokument den Blocktyp enthält. + +Die Editorvorschau führt kein JavaScript aus (siehe QUALITY). Tabs erscheinen dort deshalb als untereinanderliegende Abschnitte. Das steht so im Eigenschaftenpanel, damit der Unterschied zur veröffentlichten Seite nicht als Fehler missverstanden wird. + +Ein neuer Tab-Block bringt zwei Tabs mit: ein einzelner Tab ist keine Tabgruppe, und das Skript verbessert erst ab zwei Einträgen. Ein Tab außerhalb einer Tabgruppe und eine Tabgruppe mit anderem Inhalt werden abgewiesen, auch auf oberster Dokumentebene. diff --git a/docs/AUSBAUPLAN.md b/docs/AUSBAUPLAN.md index bb10f5a..4d94ca0 100644 --- a/docs/AUSBAUPLAN.md +++ b/docs/AUSBAUPLAN.md @@ -74,11 +74,11 @@ Beitragsdatum, Autor, Auszug und Beitragsbild im Datenmodell. Übersichtsseite m Abnahme: Die vier bestehenden Projektbeiträge des Piloten lassen sich als Beiträge abbilden, erscheinen in der Übersicht und in einer eingebetteten Liste auf der Startseite. -### C – Blöcke für den Piloten — **erledigt am 12.09.2026, ohne Tabs** +### C – Blöcke für den Piloten — **erledigt am 12.09.2026** *Braucht: nichts; parallel möglich.* -Umgesetzt: Symbolkarte, Kennzahl, Kundenstimme, Logoleiste, Aufruf-Band, Kontaktdaten, Trennlinie, Abstand und Akkordeon, alle ohne neue öffentliche JavaScript-Abhängigkeit. **Tabs bewusst nicht umgesetzt:** ohne JavaScript gibt es dafür keine zugängliche Lösung, und Barrierefreiheit hat im Plan Vorrang vor Funktionsumfang. Begründung und Rückweg in ADR 015. +Umgesetzt: Symbolkarte, Kennzahl, Kundenstimme, Logoleiste, Aufruf-Band, Kontaktdaten, Trennlinie, Abstand und Akkordeon, alle ohne neue öffentliche JavaScript-Abhängigkeit. Tabs wurden zunächst zurückgestellt (ADR 015) und auf Entscheidung von Umut anschließend mit JavaScript umgesetzt: der Server liefert vollständigen Inhalt, ein Skript macht daraus ein Tab-Widget, und die strenge Skriptrichtlinie bleibt überall sonst bestehen (ADR 016). **Offen:** das Skript ist nicht im Browser geprüft. Die oben erhobenen Abschnittsarten als Blöcke: Symbol-und-Text-Karte, Kennzahl, Kundenstimme mit Bewertung, Logoleiste, Handlungsaufruf-Band, Kontaktdatenblock, Trennlinie und Abstandhalter. Dazu Akkordeon und Tabs, weil beide im Plan stehen und ohne JavaScript über `details`/`summary` beziehungsweise Sprungmarken lösbar sind. diff --git a/docs/QUALITY.md b/docs/QUALITY.md index 3f15041..9730c8c 100644 --- a/docs/QUALITY.md +++ b/docs/QUALITY.md @@ -1,5 +1,14 @@ # Qualitätsbericht – erster ausführbarer Prototyp +## Ergänzung: Tabs, 12.09.2026 + +- **109 PHP-Tests / 395 Assertions** unter PHP 8.2.12 und PHP 8.4.22 erfolgreich, dazu **14 JavaScript-Modelltests**. +- Geprüft, dass die Seite ohne JavaScript vollständig bleibt: beide Panels stehen mit Inhalt und Überschrift im HTML, keines ist serverseitig versteckt, und die Ankerliste verweist auf sie. Verstecken ist ausschließlich Aufgabe des Skripts. +- **Die Sicherheitsrichtlinie wurde gezielt geprüft:** `script-src 'self'` erscheint nur auf der Seite, die tatsächlich einen Tab-Block enthält. Eine Seite ohne Tabs und die 404-Seite senden weiterhin `script-src 'none'` und laden kein Skript. Das Skript wird als externe Datei eingebunden; Inline-Skript kommt nicht vor. +- Abgewiesen werden ein Tab außerhalb einer Tabgruppe (auch auf oberster Dokumentebene), eine Tabgruppe mit einem Textkind und Tabs in einem v6-Dokument. +- Im Editormodell geprüft: eine neue Tabgruppe bringt zwei Tabs mit eigenen IDs mit, nimmt ausschließlich Tabs auf, und ein Tab lässt sich weder in eine Sektion noch in ein Akkordeon einfügen; ein Tab selbst nimmt gewöhnlichen Inhalt auf. +- **Das Skript selbst wurde nicht ausgeführt.** Pfeiltastenbedienung, `aria-selected`, das Umschalten der Panels und die Auswahl über ein Sprungziel sind im Code umgesetzt, aber ohne Browserlauf weder funktional noch auf Barrierefreiheit geprüft. Für ein Tab-Widget ist das ein erheblicher Vorbehalt: gerade die Tastaturbedienung und die ARIA-Rollen sind der Grund, warum diese Umsetzung überhaupt gewählt wurde. Ein Browserlauf steht dafür aus. + ## Ergänzung: Blöcke für die Pilotseite, 12.09.2026 - **107 PHP-Tests / 380 Assertions** unter PHP 8.2.12 und PHP 8.4.22 erfolgreich, dazu **13 JavaScript-Modelltests**. diff --git a/docs/STATUS.md b/docs/STATUS.md index f274752..4d9f737 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,6 +1,6 @@ # Projektstatus -Stand: 12.09.2026. Arbeitstitel Kant CMS. **Zehn neue Blöcke für die Abschnitte der Pilotstartseite (Arbeitspaket C). Beiträge mit Datum, Autor, Auszug, Beitragsbild, Kategorien, Schlagwörtern, Archiven, einbettbarer Beitragsliste und RSS (Arbeitspaket B). Menüs mit Seiten-, Anker- und externen Zielen ersetzen die fest verdrahtete Navigation (Arbeitspaket A). Seiten und Beiträge lassen sich im Browser anlegen und löschen, mit Adresshierarchie bis vier Ebenen.** Die vollständige Phase-1-Abnahme bleibt wegen des fehlenden UX-Builder-Referenzabgleichs offen. +Stand: 12.09.2026. Arbeitstitel Kant CMS. **Tabs als fortschreitende Verbesserung mit JavaScript (Dokumentschema v7). Zehn neue Blöcke für die Abschnitte der Pilotstartseite (Arbeitspaket C). Beiträge mit Datum, Autor, Auszug, Beitragsbild, Kategorien, Schlagwörtern, Archiven, einbettbarer Beitragsliste und RSS (Arbeitspaket B). Menüs mit Seiten-, Anker- und externen Zielen ersetzen die fest verdrahtete Navigation (Arbeitspaket A). Seiten und Beiträge lassen sich im Browser anlegen und löschen, mit Adresshierarchie bis vier Ebenen.** Die vollständige Phase-1-Abnahme bleibt wegen des fehlenden UX-Builder-Referenzabgleichs offen. Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. Noch kein Remote und kein Push. @@ -25,6 +25,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. - Auf ausdrücklichen Nutzerwunsch lokalen Administrator `umut` mit zufälligem Passwort angelegt und im In-App-Browser erfolgreich angemeldet. Das Passwort steht in keiner Projektdatei; nur der Hash in der lokalen Datenbank. Keine Browser-Passwortspeicherung ausgelöst. - Seitenerstellung und -löschung im Browser: `POST /admin/pages` (ab `ROLE_EDITOR`) legt Seite oder Beitrag mit gültigem Startdokument als privaten Entwurf an; `POST /admin/pages/{id}/delete` (nur `ROLE_ADMIN`) entfernt eine Seite mit allen Revisionen. Beide mit CSRF-Token, Fehlermeldungen als Flash und ohne JavaScript bedienbar. - Adresshierarchie: neue Spalten `path`, `parent_id`, `type`, `created_at` über Migration `20260912000400`; websiteweit eindeutiger Pfad statt eindeutigem Slug, Tiefe bis vier Ebenen, Beiträge ohne Unterseiten, reservierte Adressen und Adressableitung aus dem Titel. Öffentliche Route bedient verschachtelte Pfade wie `/leistungen/webdesign`; ein Lookahead hält `/admin`, `/media` und `/assets` aus dem Auffangmuster. +- Dokumentschema v7: Tabs. Der Server liefert Ankerliste und alle Panels mit Überschriften, also vollständigen Inhalt ohne JavaScript; `public/assets/tabs.js` macht daraus ein echtes Tab-Widget mit `tablist`/`tab`/`tabpanel` und Pfeiltastenbedienung. **Die strenge Richtlinie bleibt Voreinstellung:** öffentliche Seiten senden weiterhin `script-src 'none'`, nur eine Seite mit Tab-Block sendet `script-src 'self'`. Inline-Skript ist nie erlaubt. In der Editorvorschau erscheinen Tabs untereinander, weil die Vorschau kein JavaScript ausführt; ein Hinweis im Eigenschaftenpanel sagt das. - Dokumentschema v6 mit zehn Blöcken für die Pilotseite: Symbolkarte, Kennzahl, Kundenstimme mit Bewertung, Logoleiste, Aufruf-Band, Kontaktdaten, Trennlinie, Abstand, Akkordeon und Akkordeon-Eintrag. Symbole als Inline-SVG aus einer festen Liste, kein Symbolschriftsatz und keine externe Anfrage. Kontaktdaten werden zu `tel:`- und `mailto:`-Verweisen. Akkordeon über `details`/`summary`, also ohne öffentliches JavaScript. **Tabs sind bewusst nicht umgesetzt** – ohne JavaScript gibt es dafür keine zugängliche Lösung; Begründung in ADR 015. - Beiträge: Datum, Autor, Auszug und Beitragsbild als Seitenspalten, gepflegt über ein Formular in der Seitenübersicht. Kategorien und Schlagwörter als Begriffe mit eigenen Archiven unter `/kategorie/{slug}` und `/schlagwort/{slug}`, Gesamtübersicht unter `/beitraege` mit Paginierung über `?seite=N`, RSS unter `/feed.xml`. Der Block `postlist` (Dokumentschema v5) speichert nur Anzahl und Filter; die Beiträge stellt der Controller beim Ausliefern zusammen, damit ein gespeichertes Dokument keinen unveröffentlichten Beitrag zeigen kann. - Menüverwaltung unter `/admin/menus` für die Plätze Hauptnavigation und Fußzeile. Ein Eintrag verweist auf eine Seite, auf ein Sprungziel innerhalb einer Seite oder auf eine Adresse (HTTPS, lokaler Pfad, `mailto:`, `tel:`). Zwei Ebenen, bis 60 Einträge, optimistische Konflikterkennung. Bedienung über eine nummerierte Zeilenliste, vollständig ohne JavaScript; Untermenüs in der Ausgabe über `details`/`summary`. Seitenverweise speichern die Seiten-ID, nicht den Pfad – ein Eintrag folgt einer umbenannten Seite und überlebt eine gelöschte. @@ -34,8 +35,9 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. ## Geprüft -- 107 PHP-Tests / 380 Assertions unter PHP 8.2.12 und 8.4.22 erfolgreich; zusätzlich 13 JavaScript-Modelltests. Abgedeckt sind Migration/Erhaltung, Versionen, ungültige Geräte-/Typ-/Design-/Effektwerte, Vererbung/Reset/Undo, Vorlage-/Galerie-IDs, Uploadvalidierung und Neuencodierung, Metadatenkonflikte, Archivierung sowie sichere Bildquellen. +- 109 PHP-Tests / 395 Assertions unter PHP 8.2.12 und 8.4.22 erfolgreich; zusätzlich 14 JavaScript-Modelltests. Abgedeckt sind Migration/Erhaltung, Versionen, ungültige Geräte-/Typ-/Design-/Effektwerte, Vererbung/Reset/Undo, Vorlage-/Galerie-IDs, Uploadvalidierung und Neuencodierung, Metadatenkonflikte, Archivierung sowie sichere Bildquellen. - Neu geprüft (15 Tests): privater Entwurf nach dem Anlegen, verschachtelte Pfade mit gleichem Slug unter verschiedenen Eltern, abgewiesene Adressen (Großbuchstaben, Schrägstrich, leer, reserviert, unbekannter Typ, fehlendes Elternteil) ohne Schreibzugriff, doppelte Adresse, Tiefengrenze, Beiträge ohne Unterseiten, Löschen mit allen Revisionen samt Schutz für Elternseiten und veraltete Versionen, sowie dass die CMS-Routen nicht vom öffentlichen Auffangmuster verschluckt werden. Über HTTP zusätzlich: anonyme Schreibversuche, CSRF für Anlegen und Löschen, Adressableitung aus dem Titel, kein Löschangebot und HTTP 403 für die Editorrolle, erfolgreiches Löschen als Administrator. +- Tabs geprüft: beide Panels samt Inhalt und Überschrift stehen ohne JavaScript im HTML und sind serverseitig nicht versteckt, die Ankerliste verweist auf die Panels, das Skript wird als externe Datei geladen und nie inline. Die Richtlinie sendet `script-src 'self'` ausschließlich auf der Seite mit Tab-Block; eine Seite ohne Tabs und die 404-Seite behalten `script-src 'none'` und laden kein Skript. Ein Tab außerhalb einer Tabgruppe, eine Tabgruppe mit Textkind und Tabs in Schema v6 werden abgewiesen. Im Modell: eine neue Tabgruppe bringt zwei Tabs mit eigenen IDs mit, nimmt nur Tabs auf, und ein Tab lässt sich weder in eine Sektion noch in ein Akkordeon einfügen. - Neue Blöcke geprüft: eine Seite aus allen zehn Abschnittsarten wird unverändert gespeichert, veröffentlicht und ausgegeben; Kontaktdaten werden zu `tel:`- und `mailto:`-Verweisen mit bereinigter Nummer, das Akkordeon ist ein echtes `details`-Element, Symbole sind Inline-SVG, die Bewertung ist für Screenreader ausgeschrieben, und die Ausgabe enthält kein `script`. Vierzehn abgewiesene Eingaben schreiben nichts: unbekanntes Symbol, fehlender Titel, `javascript:`-Ziel, Bewertung außerhalb 0–5 und als Zeichenkette, leerer Kontaktblock, ungültige Telefonnummer und E-Mail-Adresse, unbekannte Abstandsgröße, unerlaubtes Linkschema, Text in Logoleiste und Akkordeon, Akkordeon-Eintrag ohne Akkordeon sowie Kinder an einer Trennlinie. Zusätzlich geprüft, dass die neuen Blöcke in Schema v5 weiterhin abgewiesen werden. - Beiträge geprüft: Sortierung nach Datum mit Paginierung und Begrenzung zu großer Seitenzahlen, Seiten und unveröffentlichte Beiträge bleiben aus dem Archiv, Kategorie- und Schlagwortarchive einschließlich getrennter Namensräume, erneutes Speichern ersetzt die Zuordnung statt sie zu ergänzen, ein leer gewordenes Archiv antwortet weiter mit HTTP 200 und leerer Liste, Löschen löst die Zuordnungen und lässt die Begriffe bestehen, Veröffentlichen stempelt ein fehlendes Datum und lässt ein gepflegtes unangetastet, Seiten werden nie gestempelt. Sechs abgewiesene Metadateneingaben schreiben nichts und erhöhen die Version nicht. Die Beitragsliste zeigt nur Beiträge des gefilterten Begriffs, und das gespeicherte Dokument enthält weiterhin ausschließlich Anzahl und Filter. Der Feed ist wohlgeformtes XML mit absoluten Adressen und enthält keinen unveröffentlichten Beitrag. - Menüs geprüft: Seiten-, Anker- und externe Ziele einschließlich `tel:` in der Ausgabe, `aria-current` nur auf der tatsächlich aktuellen Seite und nicht auf Ankereinträgen, gelöschte und unveröffentlichte Seiten fallen aus der Navigation ohne die übrigen Einträge zu verlieren, Kinder eines übersprungenen Eintrags rücken nach, gespeicherte Einträge bleiben erhalten und erscheinen nach dem Veröffentlichen wieder. Zehn abgewiesene Eingaben (leere und überlange Beschriftung, unbekannte Zielart, fehlende Seite, ungültige Sprungziele, `javascript:`, `http:`, protokollrelative Adresse, dritte Ebene) schreiben nichts. Über HTTP zusätzlich: anonymer Schreibversuch, CSRF, veraltete Version, Aufbau des Baums aus flachen Formularzeilen mit Position und Ebene, normalisiertes Sprungziel und die Bestätigung, dass die Navigation nicht mehr aus dem Template stammt. @@ -60,7 +62,8 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. - Gestaltung über geprüfte Tokens, keine beliebigen Pixelwerte, eigenen Schriften oder freien Farben. Noch keine Inline-Textbearbeitung, Formulare, dynamischen Menüs, Plugin-/Themeverwaltung oder WordPress-Import. Abschnittsvorlagen sind lokale Blockrezepte, keine globale Templateverwaltung. - Mediathek derzeit für statische Bilder: keine Videos/PDFs/SVGs, Lightbox/Slideshow, freie Bildzuschnitte, Ordner, Paginierung oder physische Löschung. Bild-URLs sind öffentlich; Archivieren schützt keine vertraulichen Dateien. Originale werden durch optimierte Varianten ersetzt. Einblendungen starten beim Laden, nicht beim Scrollen. - Sektionen bieten die vorhandenen Farb-/Breitentokens geräteübergreifend. Benutzerverwaltung und Passwortänderung sind noch nicht als Browseroberfläche verfügbar. -- Tabs fehlen bewusst (siehe oben). Die neuen Blöcke lassen sich nicht in einer Vorlage bündeln: die drei Abschnittsvorlagen sind unverändert. Die Logoleiste hat keine Graustufen- oder Größenoption, die Kundenstimme kein Bild, die Symbolkarte keine eigene Farbe. +- Das Tab-Skript ist nicht im Browser geprüft: Pfeiltastenbedienung, `aria-selected`, das Verstecken der Panels und die Auswahl über ein Sprungziel sind nur im Code umgesetzt, nicht ausgeführt. Geprüft ist bisher ausschließlich die serverseitig erzeugte Auszeichnung und die Sicherheitsrichtlinie. +- Die neuen Blöcke lassen sich nicht in einer Vorlage bündeln: die drei Abschnittsvorlagen sind unverändert. Die Logoleiste hat keine Graustufen- oder Größenoption, die Kundenstimme kein Bild, die Symbolkarte keine eigene Farbe. - Beiträge haben noch keine Vorschau des Beitragsbilds in der Verwaltung: das Bild wird über seine Mediathek-ID eingetragen. Es gibt keine Begriffsverwaltung (Umbenennen, Zusammenführen, Löschen ungenutzter Begriffe), keine Beitragssuche und keine Archivbeschreibungen. Die Paginierung der Übersicht ist auf zehn Beiträge je Seite festgelegt. - Menüs kennen nur die beiden festen Plätze Hauptnavigation und Fußzeile; weitere Plätze, eigene Menüs pro Seite und automatisch aus der Hierarchie erzeugte Einträge gibt es nicht. Die Einträge werden über Position und Ebene sortiert, nicht per Drag-and-drop. - Seiten lassen sich anlegen und löschen, aber noch nicht umbenennen oder verschieben: eine Adressänderung müsste die Pfade aller Unterseiten mitziehen und Weiterleitungen anlegen. Beiträge werden gespeichert und ausgegeben, haben aber noch keine Übersichtsseite, keine Sortierung nach Datum und keine strukturierten Daten. Die Hauptnavigation ist weiterhin fest im Template; verschachtelte Seiten erscheinen dort nicht automatisch. diff --git a/public/assets/design-tokens.json b/public/assets/design-tokens.json index 8ad324a..ed9db88 100644 --- a/public/assets/design-tokens.json +++ b/public/assets/design-tokens.json @@ -110,7 +110,8 @@ "types": [ "section", "column", - "accordionitem" + "accordionitem", + "tabitem" ], "values": { "none": [ @@ -139,7 +140,8 @@ "label": "Innenabstand links / rechts", "types": [ "column", - "accordionitem" + "accordionitem", + "tabitem" ], "values": { "none": [ @@ -181,7 +183,9 @@ "postlist", "logos", "accordion", - "accordionitem" + "accordionitem", + "tabs", + "tabitem" ], "values": { "none": [ @@ -223,7 +227,9 @@ "postlist", "logos", "accordion", - "accordionitem" + "accordionitem", + "tabs", + "tabitem" ], "values": { "none": [ diff --git a/public/assets/editor-model.js b/public/assets/editor-model.js index 5d541a0..9d10271 100644 --- a/public/assets/editor-model.js +++ b/public/assets/editor-model.js @@ -1,18 +1,19 @@ -export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag' }; -export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·' }; +export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab' }; +export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫' }; // A namespaced type belongs to a plugin this installation may not have. Such blocks stay in the // document, stay selectable and movable, but the editor never edits or fills them. export const PLUGIN_TYPE = /^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/; export const isPluginBlock = block => typeof block?.type === 'string' && PLUGIN_TYPE.test(block.type); export const blockLabel = block => labels[block?.type] ?? (isPluginBlock(block) ? `Baustein ${block.type}` : 'Unbekannt'); export const blockIcon = block => icons[block?.type] ?? '❔'; -export const isContainer = block => ['section', 'row', 'column', 'gallery', 'logos', 'accordion', 'accordionitem'].includes(block?.type); +export const isContainer = block => ['section', 'row', 'column', 'gallery', 'logos', 'accordion', 'accordionitem', 'tabs', 'tabitem'].includes(block?.type); // Nothing may be dropped into a plugin block: the core does not know its layout contract. // Containers that accept only one kind of child, mirrored by the server side validator. -export const ONLY_CHILD = { gallery: 'image', logos: 'image', accordion: 'accordionitem' }; +export const ONLY_CHILD = { gallery: 'image', logos: 'image', accordion: 'accordionitem', tabs: 'tabitem' }; export const canContain = (parent, block) => isContainer(parent) && !isPluginBlock(parent) && (ONLY_CHILD[parent.type] === undefined || ONLY_CHILD[parent.type] === block.type) - && (block.type !== 'accordionitem' || parent.type === 'accordion'); + && (block.type !== 'accordionitem' || parent.type === 'accordion') + && (block.type !== 'tabitem' || parent.type === 'tabs'); export const clone = value => structuredClone(value); export function locate(blocks, id, parent = null) { @@ -30,6 +31,8 @@ export function createBlock(type) { const block = { id: `b_${crypto.randomUUID().replaceAll('-', '')}`, type }; if (isContainer(block)) block.children = []; if (type === 'accordion') block.children = [createBlock('accordionitem')]; + // A single tab is not a tab group, and the script needs at least two to enhance anything. + if (type === 'tabs') block.children = [createBlock('tabitem'), createBlock('tabitem')]; if (type === 'heading') block.props = { text: 'Deine neue Überschrift', level: 2 }; if (type === 'text') block.props = { text: 'Hier beginnt dein neuer Inhalt.' }; if (type === 'button') block.props = { text: 'Mehr erfahren', href: '/' }; @@ -41,6 +44,7 @@ export function createBlock(type) { if (type === 'contact') block.props = { phone: '', email: '', address: '' }; if (type === 'spacer') block.props = { size: 'medium' }; if (type === 'accordionitem') block.props = { title: 'Eine Frage', open: false }; + if (type === 'tabitem') block.props = { title: 'Neuer Tab' }; if (type === 'image') block.props = { src: '/assets/image-placeholder.svg', alt: 'Abstrakte Landschaft als Platzhalter', decorative: false, width: 1000, height: 600 }; return block; } @@ -50,6 +54,7 @@ export function insertBlock(document, type, selected) { if (type === 'gallery') upgradeDocument(document, 3); if (type === 'postlist') upgradeDocument(document, 5); if (['iconcard', 'stat', 'quote', 'logos', 'cta', 'contact', 'divider', 'spacer', 'accordion', 'accordionitem'].includes(type)) upgradeDocument(document, 6); + if (type === 'tabs' || type === 'tabitem') upgradeDocument(document, 7); let target = locate(document.blocks, selected); if (target?.block.type === 'gallery' && type !== 'image') { target.siblings.splice(target.index + 1, 0, block); return block.id; } if (target?.parent?.type === 'gallery' && type !== 'image') target = locate(document.blocks, target.parent.id); @@ -91,7 +96,7 @@ export const devices = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobil' } // Called only inside a history transaction: all other fields and IDs stay untouched. export function upgradeDocument(document, version = 2) { - if (![1, 2, 3, 4, 5, 6].includes(document.schemaVersion) || ![2, 3, 4, 5, 6].includes(version)) throw new Error('Unbekanntes Dokumentschema.'); + if (![1, 2, 3, 4, 5, 6, 7].includes(document.schemaVersion) || ![2, 3, 4, 5, 6, 7].includes(version)) throw new Error('Unbekanntes Dokumentschema.'); document.schemaVersion = Math.max(document.schemaVersion, version); return document; } @@ -108,7 +113,7 @@ export function effectiveDesign(block, device, key) { } export function setDesign(document, id, device, key, value) { - if (![1, 2, 3, 4, 5, 6].includes(document.schemaVersion)) throw new Error('Unbekanntes Dokumentschema.'); + if (![1, 2, 3, 4, 5, 6, 7].includes(document.schemaVersion)) throw new Error('Unbekanntes Dokumentschema.'); if (!Object.hasOwn(devices, device)) throw new Error('Unbekannte Vorschaugröße.'); const block = locate(document.blocks, id)?.block; if (!block) return; diff --git a/public/assets/editor.js b/public/assets/editor.js index 91371c9..35a06e0 100644 --- a/public/assets/editor.js +++ b/public/assets/editor.js @@ -201,6 +201,7 @@ function renderProperties() { field('Anschrift', 'address', block.props.address || '', { multiline: true, maxLength: 200 }); } if (block.type === 'spacer') field('Höhe', 'size', block.props.size, { choices: { small: 'Klein', medium: 'Mittel', large: 'Groß', huge: 'Sehr groß' } }); + if (block.type === 'tabitem') field('Titel', 'title', block.props.title, { maxLength: 160, help: 'Beschriftung des Tabs. In der Vorschau sind alle Tabs untereinander sichtbar, weil die Vorschau kein JavaScript ausführt.' }); if (block.type === 'accordionitem') { field('Titel', 'title', block.props.title, { maxLength: 160 }); field('Beim Laden geöffnet', 'open', block.props.open, { checkbox: true }); diff --git a/public/assets/site.css b/public/assets/site.css index fbdfa27..f801d9b 100644 --- a/public/assets/site.css +++ b/public/assets/site.css @@ -63,3 +63,13 @@ h1,h2,h3,p,a{overflow-wrap:anywhere;hyphens:auto} .block-accordionitem>summary::after{content:'+';font-size:22px;line-height:1} .block-accordionitem[open]>summary::after{content:'–'} .accordion-body{padding-bottom:16px} + +/* Tabs. Rendered as an anchor list plus all panels; tabs.js upgrades them to a tab widget. */ +.tabs-nav{list-style:none;display:flex;flex-wrap:wrap;gap:4px;margin:0 0 22px;padding:0;border-bottom:1px solid currentColor} +.tabs-nav a{display:inline-block;padding:12px 18px;text-decoration:none;border:1px solid transparent;border-bottom:0;border-radius:9px 9px 0 0;margin-bottom:-1px;font-weight:600} +.tabs-nav a:hover{background:#0000000a} +.tabs-nav [aria-selected=true]{border-color:currentColor;background:#fff} +.tabs-ready .tabs-nav a{cursor:pointer} +.block-tabitem{padding-block:4px} +.block-tabitem:focus-visible{outline:3px solid #b55b12;outline-offset:4px} +.tabs-heading{margin:0 0 12px;font-size:17px} diff --git a/public/assets/tabs.js b/public/assets/tabs.js new file mode 100644 index 0000000..7e758ee --- /dev/null +++ b/public/assets/tabs.js @@ -0,0 +1,62 @@ +/** + * Upgrades server-rendered tab sections to a real tab widget. + * + * Without this script the page shows an anchor list followed by every panel, headings and all: + * complete, readable and keyboard operable. The script only changes presentation, never content, + * and it is loaded exclusively on pages that actually contain a tabs block. + */ +(() => { + const setup = group => { + const list = group.querySelector('.tabs-nav'); + const links = [...list.querySelectorAll('a')]; + const panels = links.map(link => document.getElementById(link.getAttribute('href').slice(1))); + if (links.length < 2 || panels.some(panel => panel === null)) return; + + list.setAttribute('role', 'tablist'); + list.querySelectorAll('li').forEach(item => item.setAttribute('role', 'presentation')); + links.forEach((link, index) => { + link.setAttribute('role', 'tab'); + link.id = `${panels[index].id}-tab`; + link.setAttribute('aria-controls', panels[index].id); + panels[index].setAttribute('role', 'tabpanel'); + panels[index].setAttribute('aria-labelledby', link.id); + panels[index].setAttribute('tabindex', '0'); + // The heading stays in the document for the no-script case; the tab now names the panel. + panels[index].querySelector('.tabs-heading')?.classList.add('visually-hidden'); + }); + + const select = (index, moveFocus = true) => { + links.forEach((link, i) => { + const current = i === index; + link.setAttribute('aria-selected', String(current)); + link.tabIndex = current ? 0 : -1; + panels[i].hidden = !current; + }); + if (moveFocus) links[index].focus(); + }; + + list.addEventListener('click', event => { + const index = links.indexOf(event.target.closest('a')); + if (index === -1) return; + // Keep the address bar clean: selecting a tab is not a navigation. + event.preventDefault(); + select(index); + }); + + list.addEventListener('keydown', event => { + const index = links.indexOf(event.target.closest('a')); + if (index === -1) return; + const moves = { ArrowRight: index + 1, ArrowLeft: index - 1, Home: 0, End: links.length - 1 }; + if (!(event.key in moves)) return; + event.preventDefault(); + select((moves[event.key] + links.length) % links.length); + }); + + // A link from elsewhere may point straight at one panel. + const deepLink = panels.findIndex(panel => panel.id === location.hash.slice(1)); + select(deepLink === -1 ? 0 : deepLink, false); + group.classList.add('tabs-ready'); + }; + + document.querySelectorAll('[data-tabs]').forEach(setup); +})(); diff --git a/src/Content/DocumentValidator.php b/src/Content/DocumentValidator.php index e69e065..336406e 100644 --- a/src/Content/DocumentValidator.php +++ b/src/Content/DocumentValidator.php @@ -11,11 +11,13 @@ final class DocumentValidator /** 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']; public const SPACER_SIZES = ['small', 'medium', 'large', 'huge']; + /** Schema v7: tabs, the one pattern that needs a script to become a real widget. */ + public const V7_TYPES = ['tabs', 'tabitem']; public function validate(array $document): void { $schemaVersion = $document['schemaVersion'] ?? null; - $this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6], true), 'Unsupported document schema.'); + $this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7], true), 'Unsupported document schema.'); $this->text($document['title'] ?? null, 160); $this->text($document['description'] ?? null, 320); $this->require(is_array($document['blocks'] ?? null) && array_is_list($document['blocks']), 'Blocks must be a list.'); @@ -40,10 +42,11 @@ final class DocumentValidator // page: a deactivated plugin must never make stored content unreadable. Nothing from // props is rendered for such a block, so unvalidated values never reach the output. $plugin = $schemaVersion >= 4 && is_string($type) && 1 === preg_match('/^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/D', $type); - $this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || $plugin, 'Unknown block type. Preserve source until its plugin is available.'); + $this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || $plugin, 'Unknown block type. Preserve source until its plugin is available.'); // An accordion entry only ever renders inside an accordion; at the top level or anywhere // else it would produce a details element without its group. $this->require('accordionitem' !== $type || 'accordion' === $parentType, 'An accordion entry belongs into an accordion.'); + $this->require('tabitem' !== $type || 'tabs' === $parentType, 'A tab belongs into a tab group.'); $props = $block['props'] ?? []; $layout = $block['layout'] ?? []; $this->require(is_array($props) && is_array($layout), 'Props and layout must be objects.'); @@ -127,6 +130,9 @@ final class DocumentValidator $this->text($props['title'] ?? null, 160); $this->require(is_bool($props['open'] ?? false), 'The open state is a boolean.'); } + if ('tabitem' === $type) { + $this->text($props['title'] ?? null, 160); + } if ('image' === $type) { $this->url($props['src'] ?? null); $this->require(is_bool($props['decorative'] ?? null), 'Image purpose is required.'); @@ -160,10 +166,10 @@ final class DocumentValidator $children = $block['children'] ?? []; $this->require(is_array($children) && array_is_list($children), 'Children must be a list.'); // A plugin block may nest content; keeping its children is part of preserving the page. - $container = $plugin || in_array($type, ['section', 'row', 'column', 'gallery', 'logos', 'accordion', 'accordionitem'], true); + $container = $plugin || in_array($type, ['section', 'row', 'column', 'gallery', 'logos', 'accordion', 'accordionitem', 'tabs', 'tabitem'], true); $this->require($container || [] === $children, 'Leaf blocks cannot contain children.'); // Containers that only accept one kind of child, so the rendered markup stays valid. - $only = ['gallery' => 'image', 'logos' => 'image', 'accordion' => 'accordionitem']; + $only = ['gallery' => 'image', 'logos' => 'image', 'accordion' => 'accordionitem', 'tabs' => 'tabitem']; foreach ($children as $child) { if (isset($only[$type])) { $this->require(is_array($child) && $only[$type] === ($child['type'] ?? null), sprintf('A %s only contains %s blocks.', $type, $only[$type])); diff --git a/src/Controller/EditorController.php b/src/Controller/EditorController.php index ffc61ef..94a3764 100644 --- a/src/Controller/EditorController.php +++ b/src/Controller/EditorController.php @@ -180,7 +180,7 @@ final class EditorController extends AbstractController $navigation[$slot] = $this->menus->resolve($menu['items'], (string) $page['path']); } - return $this->render('page.html.twig', ['page' => [...$page, 'document' => $document], 'canonical' => '', 'editing' => true, 'navigation' => $navigation]); + return $this->render('page.html.twig', ['page' => [...$page, 'document' => $document], 'canonical' => '', 'editing' => true, 'navigation' => $navigation, 'needsTabs' => false]); } if (!isset($body['expectedVersion']) || !is_int($body['expectedVersion']) || $body['expectedVersion'] < 0) { throw new \InvalidArgumentException(); diff --git a/src/Controller/PageController.php b/src/Controller/PageController.php index 30deb6b..d85c9bb 100644 --- a/src/Controller/PageController.php +++ b/src/Controller/PageController.php @@ -38,23 +38,46 @@ final class PageController extends AbstractController 'navigation' => $this->navigation($path), 'postLists' => $this->postLists($page['document']), 'postMeta' => 'post' === $page['type'] ? $this->posts->termsFor($page['id']) : null, + 'needsTabs' => $this->hasType($page['document'], 'tabs'), ]); } - return $this->shell($response); + return $this->shell($response, null !== $page && $this->hasType($page['document'], 'tabs')); } - /** Every public response carries the same headers, whether page, archive or feed. */ - private function shell(Response $response): Response + /** + * Every public response carries the same headers. Scripting stays forbidden unless the page + * actually contains a tab group: only then may the browser load the one enhancement script, + * and only from this origin. Inline script is never allowed. + */ + private function shell(Response $response, bool $allowScript = false): Response { $response->headers->set('Cache-Control', 'no-store'); $response->headers->set('X-Content-Type-Options', 'nosniff'); $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin'); - $response->headers->set('Content-Security-Policy', "default-src 'self'; script-src 'none'; style-src 'self'; img-src 'self' https:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'"); + $response->headers->set('Content-Security-Policy', sprintf( + "default-src 'self'; script-src %s; style-src 'self'; img-src 'self' https:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'", + $allowScript ? "'self'" : "'none'", + )); return $response; } + /** True when the stored document contains at least one block of that type. */ + private function hasType(array $document, string $type): bool + { + $found = false; + $walk = function (array $blocks) use (&$walk, &$found, $type): void { + foreach ($blocks as $block) { + $found = $found || $type === ($block['type'] ?? null); + $walk($block['children'] ?? []); + } + }; + $walk($document['blocks'] ?? []); + + return $found; + } + #[Route('/beitraege', name: 'posts', methods: ['GET'])] #[Route('/kategorie/{slug}', name: 'category', requirements: ['slug' => '[a-z0-9-]{1,80}'], methods: ['GET'])] #[Route('/schlagwort/{slug}', name: 'tag', requirements: ['slug' => '[a-z0-9-]{1,80}'], methods: ['GET'])] diff --git a/templates/admin/editor.html.twig b/templates/admin/editor.html.twig index 4244cd0..e818cf3 100644 --- a/templates/admin/editor.html.twig +++ b/templates/admin/editor.html.twig @@ -17,7 +17,7 @@

Werkzeugkasten

Element hinzufügen

In die gewählte Sektion oder hinter das gewählte Element.

- {% for type, label in {section:'Sektion', row:'Zeile', column:'Spalte', heading:'Überschrift', text:'Text', image:'Bild', button:'Button', gallery:'Galerie', postlist:'Beitragsliste', iconcard:'Symbolkarte', stat:'Kennzahl', quote:'Kundenstimme', logos:'Logoleiste', cta:'Aufruf-Band', contact:'Kontaktdaten', accordion:'Akkordeon', accordionitem:'Eintrag', divider:'Trennlinie', spacer:'Abstand'} %}{% endfor %} + {% for type, label in {section:'Sektion', row:'Zeile', column:'Spalte', heading:'Überschrift', text:'Text', image:'Bild', button:'Button', gallery:'Galerie', postlist:'Beitragsliste', iconcard:'Symbolkarte', stat:'Kennzahl', quote:'Kundenstimme', logos:'Logoleiste', cta:'Aufruf-Band', contact:'Kontaktdaten', accordion:'Akkordeon', accordionitem:'Eintrag', tabs:'Tabs', tabitem:'Tab', divider:'Trennlinie', spacer:'Abstand'} %}{% endfor %}

Fertige Abschnitte

Wird nach dem aktuellen Seitenabschnitt eingefügt.

diff --git a/templates/blocks/block.html.twig b/templates/blocks/block.html.twig index d518eeb..697ea12 100644 --- a/templates/blocks/block.html.twig +++ b/templates/blocks/block.html.twig @@ -59,6 +59,20 @@
{% elseif type == 'spacer' %} +{% elseif type == 'tabs' %} + {# Without the script this is an anchor list followed by every panel: complete and readable. + tabs.js turns it into a tab widget; it only changes presentation, never content. #} +
+ + {% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({})} only %}{% endfor %} +
+{% elseif type == 'tabitem' %} +
+

{{ props.title }}

+ {% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({})} only %}{% endfor %} +
{% elseif type == 'accordion' %}
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({})} only %}{% endfor %} diff --git a/templates/page.html.twig b/templates/page.html.twig index 418b341..80628e2 100644 --- a/templates/page.html.twig +++ b/templates/page.html.twig @@ -11,6 +11,7 @@ {% if page.document.schemaVersion >= 2 %}{% endif %} {% if page.document.schemaVersion >= 3 %}{% endif %} + {% if needsTabs|default(false) %}{% endif %} diff --git a/tests/CmsTest.php b/tests/CmsTest.php index 07299a1..a646a99 100644 --- a/tests/CmsTest.php +++ b/tests/CmsTest.php @@ -778,6 +778,82 @@ final class CmsTest extends TestCase self::assertNotEmpty($this->pages->saveDraft('page-1', $document, 0)); } + private static function tabsDocument(): array + { + $document = DemoContent::starter('Mit Tabs'); + $document['schemaVersion'] = 7; + $document['blocks'][] = ['id' => 'tabs', 'type' => 'tabs', 'children' => [ + ['id' => 'tab-web', 'type' => 'tabitem', 'props' => ['title' => 'Web Design'], 'children' => [ + ['id' => 'tab-web-text', 'type' => 'text', 'props' => ['text' => 'Inhalt des ersten Tabs.']], + ]], + ['id' => 'tab-app', 'type' => 'tabitem', 'props' => ['title' => 'App-Entwicklung'], 'children' => [ + ['id' => 'tab-app-text', 'type' => 'text', 'props' => ['text' => 'Inhalt des zweiten Tabs.']], + ]], + ]]; + + return $document; + } + + public function testTabsRenderCompletelyWithoutScriptingAndLoadTheEnhancementOnlyWhereNeeded(): void + { + $document = self::tabsDocument(); + $this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1); + + $response = $this->request('/'); + $html = $response->getContent(); + // Without the script the page still carries both panels, their headings and their content. + self::assertStringContainsString('Inhalt des ersten Tabs.', $html); + self::assertStringContainsString('Inhalt des zweiten Tabs.', $html); + self::assertStringContainsString('

Web Design

', $html); + self::assertStringContainsString('App-Entwicklung', $html); + // Neither panel is hidden server side; hiding is the script's job alone. + self::assertStringContainsString('
', $html); + self::assertStringContainsString('
', $html); + // The enhancement is an external file; inline script never appears. + self::assertStringContainsString('', $html); + // Scripting is permitted for this page only, and only from this origin. + self::assertStringContainsString("script-src 'self'", (string) $response->headers->get('Content-Security-Policy')); + + // A page without tabs keeps the strict policy and loads no script at all. + $plain = $this->publishedPage('ohne-tabs', 'Ohne Tabs'); + self::assertNotNull($this->pages->findForEditor($plain)); + $other = $this->request('/ohne-tabs'); + self::assertStringNotContainsString('tabs.js', $other->getContent()); + self::assertStringContainsString("script-src 'none'", (string) $other->headers->get('Content-Security-Policy')); + self::assertSame(404, $this->request('/gibt-es-nicht')->getStatusCode()); + self::assertStringContainsString("script-src 'none'", (string) $this->request('/gibt-es-nicht')->headers->get('Content-Security-Policy')); + } + + public function testTabsSurviveSavingAndRejectMisplacedEntries(): void + { + $document = self::tabsDocument(); + $revision = $this->pages->saveDraft('page-1', $document, 0); + self::assertSame($document, json_decode($this->db->fetchOne('SELECT document FROM revision WHERE id = ?', [$revision]), true)); + + // A tab outside a tab group, and a tab group holding anything else, are both refused. + $loose = DemoContent::starter('Titel'); + $loose['schemaVersion'] = 7; + $loose['blocks'][] = ['id' => 'x', 'type' => 'tabitem', 'props' => ['title' => 'Allein'], 'children' => []]; + try { + $this->pages->saveDraft('page-1', $loose, 1); + self::fail('Expected a tab outside a tab group to be rejected.'); + } catch (InvalidDocument) { + } + $wrong = DemoContent::starter('Titel'); + $wrong['schemaVersion'] = 7; + $wrong['blocks'][] = ['id' => 'x', 'type' => 'tabs', 'children' => [['id' => 'y', 'type' => 'text', 'props' => ['text' => 'direkt']]]]; + try { + $this->pages->saveDraft('page-1', $wrong, 1); + self::fail('Expected a tab group with a text child to be rejected.'); + } catch (InvalidDocument) { + } + // And tabs need schema v7. + $old = self::tabsDocument(); + $old['schemaVersion'] = 6; + $this->expectException(InvalidDocument::class); + $this->pages->saveDraft('page-1', $old, 1); + } + private function request(string $url, string $method = 'GET'): Response { $previous = []; diff --git a/tests/editor-model.test.mjs b/tests/editor-model.test.mjs index cd9ec85..25e2d44 100644 --- a/tests/editor-model.test.mjs +++ b/tests/editor-model.test.mjs @@ -211,3 +211,26 @@ test('v6 containers accept only their own children and an accordion starts with history.undo(); history.undo(); history.undo(); assert.deepEqual(history.document, fixture()); }); + +test('a new tab group starts with two tabs and accepts nothing but tabs', () => { + const history = new EditorHistory(fixture()); + const id = history.change(doc => insertBlock(doc, 'tabs', 's2')); + const group = locate(history.document.blocks, id).block; + + assert.equal(history.document.schemaVersion, 7); + // One tab is not a tab group, and the enhancement needs at least two to do anything. + assert.equal(group.children.length, 2); + assert.deepEqual(group.children.map(c => c.type), ['tabitem', 'tabitem']); + assert.notEqual(group.children[0].id, group.children[1].id); + assert.deepEqual(group.children[0].props, { title: 'Neuer Tab' }); + + assert.equal(canContain(group, { type: 'tabitem' }), true); + assert.equal(canContain(group, { type: 'text' }), false); + assert.equal(canContain({ type: 'section', children: [] }, { type: 'tabitem' }), false); + assert.equal(canContain({ type: 'accordion', children: [] }, { type: 'tabitem' }), false); + // A tab itself holds ordinary content. + assert.equal(canContain(group.children[0], { type: 'text' }), true); + + history.undo(); + assert.deepEqual(history.document, fixture()); +});