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:
1 parent
8600384f1a
commit
c6ec00a906
17 files changed
+279
-26
No files matched your search
@@ -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);
|
||||
})();
|
||||
Reference in new issue
Block a user