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

+10 -4
View File
@@ -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": [
+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;
+1
View File
@@ -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 });
+10
View File
@@ -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}
+62
View File
@@ -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);
})();