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
@@ -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('<h3 class="tabs-heading">Web Design</h3>', $html);
|
||||
self::assertStringContainsString('<a href="#tab-app">App-Entwicklung</a>', $html);
|
||||
// Neither panel is hidden server side; hiding is the script's job alone.
|
||||
self::assertStringContainsString('<section id="tab-web" class="block-tabitem">', $html);
|
||||
self::assertStringContainsString('<section id="tab-app" class="block-tabitem">', $html);
|
||||
// The enhancement is an external file; inline script never appears.
|
||||
self::assertStringContainsString('<script src="/assets/tabs.js" defer></script>', $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 = [];
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
Reference in new issue
Block a user