Tabs als fortschreitende Verbesserung (Dokumentschema v7)

Auf Entscheidung von Umut werden Tabs jetzt mit JavaScript umgesetzt.
Die Regel "der Seiteninhalt braucht kein Browser-JavaScript" bleibt
dabei eingehalten: der Server liefert eine Ankerliste und alle Panels
mit eigenen Überschriften, versteckt nichts, und ohne Skript ist
jeder Inhalt lesbar und über die Tastatur erreichbar.

public/assets/tabs.js ergänzt tablist/tab/tabpanel, Auswahl per
Pfeiltasten, Home und Ende sowie aria-selected und wählt bei einem
passenden Sprungziel das zugehörige Panel.

Die strenge Richtlinie bleibt Voreinstellung: öffentliche Seiten
senden weiterhin script-src 'none'. Nur eine Seite mit Tab-Block
sendet script-src 'self' und bindet die eine externe Datei ein;
Inline-Skript ist nie erlaubt.

Geprüft: 109 PHP-Tests / 395 Assertions unter PHP 8.2.12 und 8.4.22,
14 JavaScript-Modelltests, Lint/Format/Container. Das Skript selbst
wurde nicht ausgeführt: Tastaturbedienung und ARIA-Verhalten sind
ohne Browserlauf ungeprüft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-12 11:41:36 +02:00
1 parent 8600384f1a
commit c6ec00a906
17 files changed
+279 -26

No files matched your search

+12 -7
View File
@@ -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;