From 5a5933f8cdb4d8388784229660eaec180c1862e8 Mon Sep 17 00:00:00 2001 From: Umut Kantarci Date: Sun, 13 Sep 2026 10:40:50 +0200 Subject: [PATCH] Slider mit Autorotation, Beitragsliste mit Blaettern Zwei Wuensche, zwei Bausteine. Die Autorotation ist Sekunden, kein Haekchen: 0 heisst aus, ab 3 laeuft sie. Sie braucht ein Skript - das zweite auf einer oeffentlichen Seite nach den Tabs - und es kommt nur auf Seiten mit einem solchen Slider, sonst bleibt script-src 'none'. Es laeuft nicht bei "weniger Bewegung", hoert beim ersten Tippen, Klicken, Wischen oder Scrollen endgueltig auf und pausiert bei Zeiger, Fokus, Hintergrundtab und ausserhalb des Bildes. Ohne Skript bleibt der Slider vollstaendig bedienbar. Die Beitragsliste kann blaettern statt abzuschneiden. Eine Auswahl auf der Startseite zeigt die neuesten und hoert auf; eine Uebersichtsseite blaettert und sagt, wie viele es sind. Das war die eigene Regel aus Arbeitspaket M, die fuer diesen Baustein noch nicht galt. Beim Bauen zweimal dieselbe Falle wie vorgestern: eine Variable war in der Schliessung nicht gebunden. Einmal das Sprungziel des Sliders (-78 statt 666), einmal die Seitenzahl - die zweite fiel sofort auf, weil die Seite mit 500 antwortete. Geprueft: 395 Tests unter PHP 8.2 und 8.4, 18 JS-Tests, Editortest, Browserpruefung, axe ohne Verstoss. Co-Authored-By: Claude Opus 5 --- docs/ARCHITECTURE.md | 25 ++++++++++ docs/QUALITY.md | 9 ++++ public/assets/editor-model.js | 2 +- public/assets/editor.js | 2 + public/assets/site.css | 2 +- public/assets/slider.js | 67 ++++++++++++++++++++++++++ src/Content/DocumentValidator.php | 12 +++++ src/Controller/PageController.php | 35 ++++++++++++-- templates/blocks/block.html.twig | 5 +- templates/blocks/post-list.html.twig | 8 ++++ templates/page.html.twig | 1 + tests/CmsTest.php | 70 ++++++++++++++++++++++++++++ 12 files changed, 229 insertions(+), 9 deletions(-) create mode 100644 public/assets/slider.js diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0dcea44..5870cff 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -440,6 +440,31 @@ Vom Verweis bleibt nur der Host. Die vollständige Adresse, aus der jemand kam, **Aufbewahrung:** nach 60 Tagen werden die Stunden eines Tages zu einer Zeile verschmolzen, nach 425 Tagen wird gelöscht. Gut ein Jahr, damit ein Vergleich mit dem Vorjahresmonat möglich bleibt. `cms:stats:prune` macht beides und ist für einen Cronjob gedacht. +## ADR 047 – Ein Slider, der von selbst weiterblättert (13.09.2026) + +Umut wollte die Autorotation. Die Regel „öffentliche Seiten laufen ohne JavaScript“ steht seit dem ersten Tag, und sie bleibt – **mit einer Ausnahme, die es schon gab**: die Tabs laden ein kleines Skript, aber nur auf Seiten, die Tabs enthalten. Der Slider folgt genau diesem Muster. + +**Die Einstellung sind Sekunden, kein Häkchen.** 0 heißt aus, unter drei Sekunden wäre es keine Hilfe mehr, sondern Hektik. Die Zahl steht im Baustein, nicht im Skript. + +**Was das Skript nicht tut, ist der interessantere Teil:** +- Es läuft **gar nicht**, wenn das System weniger Bewegung verlangt. +- Es hört **endgültig** auf, sobald jemand tippt, klickt, wischt oder von Hand scrollt. Nicht „pausiert kurz“ – aus. Wer selbst blättert, hat entschieden. +- Es pausiert, solange der Zeiger darin ist, solange etwas darin den Fokus hat, solange der Tab im Hintergrund liegt und solange der Slider nicht zu sehen ist. + +**Ohne das Skript bleibt alles benutzbar**: wischen, scrollen, Tastatur, Punkte. Es kommt nur eine Bequemlichkeit dazu, es hängt nichts davon ab. + +**Und ein Fehler beim Bauen, der zeigt, warum man so etwas nachmisst:** die erste Fassung rechnete das Sprungziel aus `offsetLeft` und zog zusätzlich den Offset des Sliders ab – bei einem Slider, der selbst der Bezugsrahmen ist, kam dabei −78 heraus, also gar keine Bewegung. Gemessen wird jetzt über die Bildschirmrechtecke. Der Test dazu ist kein Schnappschuss, sondern ein Verlauf: 0 → 666 → 1332 → 1998 in drei Schritten, und nach einem Mausrad-Ereignis bleibt der Wert stehen. + +## ADR 048 – Eine Beitragsliste darf blättern (13.09.2026) + +Umut: „fehlt die Pagination bei Projekte? Es werden nur 12 angezeigt.“ Richtig, und es widersprach der eigenen Regel aus Arbeitspaket M: *eine feste Obergrenze ist kein Blättern, sie verschweigt, dass etwas fehlt.* Für die Verwaltung war das behoben, für die Beitragsliste einer Seite nicht. + +Der Unterschied ist absichtlich und heißt jetzt `paginate`: eine **Auswahl** auf der Startseite zeigt die neuesten vier und hört auf – dort will niemand blättern, dort will man weiter zur Übersicht. Eine **Übersichtsseite** blättert und sagt dabei, wie viele es insgesamt sind. + +Zwei Kleinigkeiten, die dabei wichtig waren: eine blätternde Liste nimmt die Seitenzahl aus der Anfrage, eine Auswahl immer die erste – sonst würden zwei Listen auf derselben Seite einander umblättern. Und eine Seitenzahl jenseits des Endes landet auf der letzten Seite statt im Leeren. + +**Beim Bauen zum zweiten Mal dieselbe Falle:** die Seitenzahl war in der Schließung, die die Blöcke durchläuft, nicht gebunden – genau wie vorgestern beim Filter für Überschriften. Diesmal fiel es sofort auf, weil die Seite mit 500 antwortete statt still das Falsche zu tun. + ## ADR 046 – Der eigene Blick: was ich vor einem Livegang ändern würde (13.09.2026) Umut: „schau selbst drüber, ohne zu vergleichen – würdest du so online gehen?“ Antwort: **so nicht.** Fünf Dinge sind mir aufgefallen, und drei davon waren Fehler im CMS, nicht im Inhalt. diff --git a/docs/QUALITY.md b/docs/QUALITY.md index aee7bdc..aec4a5c 100644 --- a/docs/QUALITY.md +++ b/docs/QUALITY.md @@ -1,5 +1,14 @@ # Qualitätsbericht – erster ausführbarer Prototyp +## Ergänzung: Autorotation, Blättern, eigene Texte, 13.09.2026 + +- **395 PHP-Tests / 1672 Assertions** unter PHP 8.2.12 und 8.4.22, 18 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung. axe: null Verstöße auf Startseite, Projekteseite, Beitrag. +- **Die Autorotation ist als Verlauf geprüft, nicht als Schnappschuss:** 0 → 666 → 1332 → 1998 in drei Schritten zu je sieben Sekunden, und nach einem Mausrad-Ereignis bleibt der Wert stehen. Dabei fiel der Fehler der ersten Fassung auf: das Sprungziel wurde aus `offsetLeft` berechnet und der Offset des Sliders doppelt abgezogen – Ergebnis −78, also keine Bewegung. +- Belegt ist außerdem, dass das Skript **nur auf Seiten mit einem solchen Slider** geladen wird und die Richtlinie sonst bei `script-src 'none'` bleibt. +- **Blättern in der Beitragsliste** mit sieben Beiträgen geprüft: erste Seite ohne „Neuere“, letzte ohne „Ältere“, Seite 99 landet auf der letzten, und ohne den Schalter bleibt es eine Auswahl ohne Leiste. +- **Acht Projekttexte sind geschrieben, nicht übernommen** – für die Beiträge, die auf der laufenden Website keinen Text haben (zwei davon nicht einmal einen Auszug). Sie beschreiben Aufgabe, Weg und Ergebnis, ohne Zahlen, Namen oder Zusagen zu erfinden. **Sie gehören vor einem Livegang gegengelesen**, denn sie sind meine Formulierung über Umuts Projekte. +- **Nicht geprüft:** die Autorotation auf einem echten Touchgerät und in Firefox oder Safari; gemessen ist sie im Chromium der Browserprüfung. + ## Ergänzung: Nachbau vollständig, 13.09.2026 - **390 PHP-Tests / 1641 Assertions** unter PHP 8.2.12 und 8.4.22, 18 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung. **axe: null Verstöße** auf Startseite, Projekteseite, Datenschutzerklärung, Projektbeitrag und mobiler Projekteseite. diff --git a/public/assets/editor-model.js b/public/assets/editor-model.js index 40585fc..36bf18a 100644 --- a/public/assets/editor-model.js +++ b/public/assets/editor-model.js @@ -52,7 +52,7 @@ export function createBlock(type) { if (type === 'embed') block.props = { group: 'external', title: '', provider: '', url: '', hint: '', height: 400 }; if (type === 'icon') block.props = { icon: 'star', size: 48, decorative: true, label: '', href: '' }; if (type === 'progress') block.props = { label: 'Fortschritt', value: 70, showValue: true }; - if (type === 'slider') block.props = { desktopVisible: 3, tabletVisible: 2, mobileVisible: 1, snap: true, dots: true }; + if (type === 'slider') block.props = { desktopVisible: 3, tabletVisible: 2, mobileVisible: 1, snap: true, dots: true, autoplay: 0 }; if (type === 'image') block.props = { src: '/assets/image-placeholder.svg', alt: 'Abstrakte Landschaft als Platzhalter', decorative: false, width: 1000, height: 600 }; return block; } diff --git a/public/assets/editor.js b/public/assets/editor.js index 74e0867..330ca25 100644 --- a/public/assets/editor.js +++ b/public/assets/editor.js @@ -260,6 +260,7 @@ function renderProperties() { field('Wert anzeigen', 'showValue', block.props.showValue, { checkbox: true }); } if (block.type === 'slider') { + field('Automatisch weiterblättern (Sekunden, 0 = aus)', 'autoplay', block.props.autoplay || 0, { number: true, help: 'Ab 3 Sekunden. Lädt ein kleines Skript, nur auf Seiten mit einem solchen Slider. Bei „weniger Bewegung“ im Betriebssystem bleibt er stehen, und die erste Berührung stoppt ihn endgültig.' }); field('Punkte und Pfeile', 'dots', block.props.dots === true, { checkbox: true, help: 'Zeichnet der Browser selbst, ohne Skript. Wo er es noch nicht kann, bleibt ein Schiebefeld.' }); for (const [key, label] of [['desktopVisible', 'Sichtbar auf Desktop'], ['tabletVisible', 'Sichtbar auf Tablet'], ['mobileVisible', 'Sichtbar auf Mobil']]) { field(label, key, block.props[key], { number: true, help: key === 'desktopVisible' ? 'Wie viele Folien gleichzeitig zu sehen sind, 1 bis 6.' : undefined }); @@ -284,6 +285,7 @@ function renderProperties() { field('Anzahl Beiträge', 'count', block.props.count, { number: true, help: 'Zwischen 1 und 24. Die Beiträge selbst kommen beim Ausliefern dazu, nicht aus dem gespeicherten Inhalt.' }); field('Nur Kategorie', 'category', block.props.category || '', { help: 'Adresse der Kategorie, zum Beispiel projekte. Leer lassen für alle Beiträge.' }); field('Nur Schlagwort', 'tag', block.props.tag || '', { help: 'Adresse des Schlagworts. Leer lassen für alle Beiträge.' }); + field('Blättern statt abschneiden', 'paginate', block.props.paginate === true, { checkbox: true, help: 'Für eine Übersichtsseite. Eine Auswahl auf der Startseite lässt man aus.' }); field('Datum und Autor zeigen', 'showMeta', block.props.showMeta !== false, { checkbox: true }); field('Auszug zeigen', 'showExcerpt', block.props.showExcerpt !== false, { checkbox: true }); field('Kategorie zeigen', 'showTerms', block.props.showTerms !== false, { checkbox: true, help: 'Alle drei aus: ein Raster aus Bild und Titel, wie es zu Projekten passt.' }); diff --git a/public/assets/site.css b/public/assets/site.css index 11814b1..70a08fb 100644 --- a/public/assets/site.css +++ b/public/assets/site.css @@ -31,7 +31,7 @@ p{hyphens:auto} .post-meta,.post-header{display:flex;flex-wrap:wrap;gap:6px 16px;align-items:baseline;font-size:13px;margin:0} .post-header{padding-block:22px 0} .post-excerpt{margin:0;line-height:1.65} -.post-terms{display:flex;flex-wrap:wrap;gap:8px;margin:0;font-size:13px} +.post-pager{display:flex;flex-wrap:wrap;gap:10px 22px;align-items:center;justify-content:center;margin-top:34px;font-size:14px}.post-pager span{color:var(--muted)}.post-terms{display:flex;flex-wrap:wrap;gap:8px;margin:0;font-size:13px} .post-empty{padding:26px;border:1px dashed currentColor;border-radius:10px} .pagination{display:flex;flex-wrap:wrap;gap:12px 24px;align-items:center;margin-top:38px;padding-top:22px;border-top:1px solid currentColor;font-size:14px} .post-terms-label{font-size:11px;font-weight:700;letter-spacing:1.6px;text-transform:uppercase;margin-bottom:10px} diff --git a/public/assets/slider.js b/public/assets/slider.js new file mode 100644 index 0000000..266f26f --- /dev/null +++ b/public/assets/slider.js @@ -0,0 +1,67 @@ +/** + * Lets a slider advance by itself - and stop as soon as somebody takes over. + * + * Without this script a slider is a scroll container with snap points and, where the browser can + * draw them, dots: complete, swipeable and keyboard operable. The script only adds the automatic + * step, and it is loaded exclusively on pages that carry a slider which asked for it. + * + * What it refuses to do: + * - it does not run when the system asks for reduced motion, + * - it stops for good at the first touch, click, key press or manual scroll, + * - it pauses while the pointer is inside, while something in it has focus, and while the tab is + * in the background or the slider is off screen. + * + * A carousel that keeps moving while somebody reads it is a nuisance, and one that cannot be + * stopped is a barrier. + */ +(() => { + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; + + const setup = slider => { + const seconds = Number(slider.dataset.autoplay); + if (!Number.isFinite(seconds) || seconds < 3) return; + + let paused = false; + let stopped = false; + let visible = true; + let ownScroll = 0; + + const slides = [...slider.children]; + if (slides.length < 2) return; + + const step = () => { + if (stopped || paused || !visible || document.hidden) return; + const last = slider.scrollLeft + slider.clientWidth >= slider.scrollWidth - 2; + // Measured, not computed from offsetLeft: whether a slide's offset parent is the slider or + // something further up decides the number, and getting that wrong means scrolling nowhere. + const left = slide => slide.getBoundingClientRect().left - slider.getBoundingClientRect().left + slider.scrollLeft; + const next = last ? slides[0] : slides.find(slide => left(slide) > slider.scrollLeft + 2); + if (!next) return; + // Remember that the next scroll event is ours, so it does not look like a person. + ownScroll = Date.now(); + slider.scrollTo({ left: last ? 0 : left(next), behavior: 'smooth' }); + }; + + const stop = () => { stopped = true; clearInterval(timer); }; + const timer = setInterval(step, seconds * 1000); + + slider.addEventListener('pointerenter', () => { paused = true; }); + slider.addEventListener('pointerleave', () => { paused = false; }); + slider.addEventListener('focusin', () => { paused = true; }); + slider.addEventListener('focusout', () => { paused = false; }); + for (const event of ['pointerdown', 'keydown', 'touchstart', 'wheel']) { + slider.addEventListener(event, stop, { passive: true }); + } + slider.addEventListener('scroll', () => { + // A scroll that is not the one we just started belongs to a person; then we are done. + if (Date.now() - ownScroll > 1200) stop(); + }, { passive: true }); + + if ('IntersectionObserver' in window) { + new IntersectionObserver(entries => { visible = entries[0].isIntersecting; }, { threshold: 0.35 }) + .observe(slider); + } + }; + + document.querySelectorAll('.block-slider[data-autoplay]').forEach(setup); +})(); diff --git a/src/Content/DocumentValidator.php b/src/Content/DocumentValidator.php index d177b73..d3c5a74 100644 --- a/src/Content/DocumentValidator.php +++ b/src/Content/DocumentValidator.php @@ -137,6 +137,12 @@ final class DocumentValidator // und Kategorie gehören in ein Blogarchiv und lassen eine Projektseite falsch // aussehen. Voreinstellung ist der alte Zustand, damit bestehende Seiten sich nicht // ändern, wenn niemand etwas einstellt. + if (isset($props['paginate'])) { + // Eine Auswahl zeigt die ersten n und hört auf. Eine Übersicht blättert - sonst + // verschweigt sie, dass es mehr gibt. Das ist die eigene Regel aus Arbeitspaket M. + $this->require($schemaVersion >= 17, 'Das Blättern einer Beitragsliste braucht Schema v17.'); + $this->require(is_bool($props['paginate']), 'Das Blättern ist ja oder nein.'); + } foreach (['showMeta', 'showExcerpt', 'showTerms'] as $switch) { if (isset($props[$switch])) { $this->require($schemaVersion >= 17, 'Die Anzeigeoptionen der Beitragsliste brauchen Schema v17.'); @@ -235,6 +241,12 @@ final class DocumentValidator $this->require(is_int($props[$key] ?? null) && $props[$key] >= 1 && $props[$key] <= 6, sprintf('"%s" liegt zwischen 1 und 6.', $key)); } $this->require(is_bool($props['snap'] ?? null), 'Das Einrasten ist anzugeben.'); + if (isset($props['autoplay'])) { + // Sekunden bis zur nächsten Folie. 0 heißt aus, und unter drei Sekunden wäre es + // keine Hilfe mehr, sondern Hektik. + $this->require($schemaVersion >= 17, 'Das automatische Weiterblättern braucht Schema v17.'); + $this->require(is_int($props['autoplay']) && (0 === $props['autoplay'] || ($props['autoplay'] >= 3 && $props['autoplay'] <= 30)), 'Das Weiterblättern liegt bei 0 (aus) oder 3 bis 30 Sekunden.'); + } if (isset($props['dots'])) { // Punkte und Pfeile zeichnet der Browser aus dem Scrollbereich selbst; das // geht erst ab v17, weil es vorher keine Eigenschaft dafür gab. diff --git a/src/Controller/PageController.php b/src/Controller/PageController.php index e1e7545..92ebc64 100644 --- a/src/Controller/PageController.php +++ b/src/Controller/PageController.php @@ -65,9 +65,12 @@ final class PageController extends AbstractController 'page' => $page, 'canonical' => rtrim($this->siteOrigin, '/').$this->settings->publicPath($path, $home), 'navigation' => $this->navigation($path), - 'postLists' => $this->postLists($page['document']), + 'postLists' => $this->postLists($page['document'], max(1, $request->query->getInt('seite', 1))), 'postMeta' => 'post' === $page['type'] ? $this->posts->termsFor($page['id']) : null, 'needsTabs' => $this->hasType($page['document'], 'tabs'), + // Ein Slider mit automatischem Weiterblättern ist der zweite Baustein, der + // dafür ein Skript braucht - nach demselben Muster wie die Tabs. + 'needsSlider' => $this->hasAutoplaySlider($page['document']), // Own scripts arrive as one file per page, never inline, so the policy can stay // hash based and no page without a script loses its strict setting. 'pageScript' => [] === $this->code->scriptsOf($page['document']) ? null : '/assets/seite/'.$page['id'].'.js', @@ -83,7 +86,7 @@ final class PageController extends AbstractController ]); } - $wantsScript = null !== $page && ($this->hasType($page['document'], 'tabs') || [] !== $this->code->scriptsOf($page['document'])); + $wantsScript = null !== $page && ($this->hasType($page['document'], 'tabs') || $this->hasAutoplaySlider($page['document']) || [] !== $this->code->scriptsOf($page['document'])); $frames = null === $page ? [] : $this->frameHosts($request, $page['document'], ...array_values($areas), ...array_values($sections)); return $this->shell($response, $wantsScript, $styles, null !== $googleFonts, $frames); @@ -233,6 +236,23 @@ final class PageController extends AbstractController } /** True when the stored document contains at least one block of that type. */ + /** True when a slider on this page asked to advance by itself. */ + private function hasAutoplaySlider(array $document): bool + { + $found = false; + $walk = function (array $blocks) use (&$walk, &$found): void { + foreach ($blocks as $block) { + if ('slider' === ($block['type'] ?? null) && ($block['props']['autoplay'] ?? 0) >= 3) { + $found = true; + } + $walk($block['children'] ?? []); + } + }; + $walk($document['blocks'] ?? []); + + return $found; + } + private function hasType(array $document, string $type): bool { $found = false; @@ -297,10 +317,10 @@ final class PageController extends AbstractController * * @return array> */ - private function postLists(array $document): array + private function postLists(array $document, int $page = 1): array { $lists = []; - $walk = function (array $blocks) use (&$walk, &$lists): void { + $walk = function (array $blocks) use (&$walk, &$lists, $page): void { foreach ($blocks as $block) { if ('postlist' === ($block['type'] ?? null)) { $props = $block['props'] ?? []; @@ -310,7 +330,12 @@ final class PageController extends AbstractController $term = $this->posts->findTerm($kind, $props[$kind]); } } - $lists[$block['id']] = $this->posts->published(1, $term['id'] ?? null, $props['count'])['posts']; + // Eine blätternde Liste nimmt die Seite aus der Anfrage, eine Auswahl + // immer die erste - sonst würden zwei Listen auf einer Seite einander + // umblättern. + $paginate = (bool) ($props['paginate'] ?? false); + $result = $this->posts->published($paginate ? $page : 1, $term['id'] ?? null, $props['count']); + $lists[$block['id']] = $paginate ? $result : $result['posts']; } $walk($block['children'] ?? []); } diff --git a/templates/blocks/block.html.twig b/templates/blocks/block.html.twig index 0dae49d..d90818e 100644 --- a/templates/blocks/block.html.twig +++ b/templates/blocks/block.html.twig @@ -158,7 +158,7 @@ a trackpad, a mouse wheel and the keyboard, and it degrades to a scrollable strip on a browser that ignores snapping. No autoplay - a moving carousel is something people ask to stop, not something a page should start on its own. #} -
= 3 %} data-autoplay="{{ props.autoplay }}"{% endif %} tabindex="0" role="group" aria-label="Zum Blättern seitwärts scrollen"> {% for child in block.children|default([]) %}
{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), eagerImage: eagerImage|default(null)} only %}
@@ -195,7 +195,8 @@ {% endif %} {% elseif type == 'postlist' %}
- {% include 'blocks/post-list.html.twig' with {posts: postLists[block.id]|default([]), showMeta: block.props.showMeta ?? true, showExcerpt: block.props.showExcerpt ?? true, showTerms: block.props.showTerms ?? true} only %} + {% set feed = postLists[block.id]|default([]) %} + {% include 'blocks/post-list.html.twig' with {posts: feed.posts|default(feed), pages: feed.pages|default(1), page: feed.page|default(1), total: feed.total|default(null), showMeta: block.props.showMeta ?? true, showExcerpt: block.props.showExcerpt ?? true, showTerms: block.props.showTerms ?? true} only %}
{% elseif type == 'gallery' %}
diff --git a/templates/blocks/post-list.html.twig b/templates/blocks/post-list.html.twig index 21bd8de..66ab244 100644 --- a/templates/blocks/post-list.html.twig +++ b/templates/blocks/post-list.html.twig @@ -31,3 +31,11 @@ {% endfor %} {% endif %} +{% if pages|default(1) > 1 %} +{# Dieselbe Blätterleiste wie im Archiv: eine Liste, die mehr hat als sie zeigt, sagt es. #} + +{% endif %} diff --git a/templates/page.html.twig b/templates/page.html.twig index 252b85a..7747879 100644 --- a/templates/page.html.twig +++ b/templates/page.html.twig @@ -19,6 +19,7 @@ {% if page.document.schemaVersion >= 2 %}{% endif %} {% if page.document.schemaVersion >= 3 %}{% endif %} {% if needsTabs|default(false) %}{% endif %} + {% if needsSlider|default(false) %}{% endif %} {# Own scripts of this page, as a file. Never rendered in the editor preview: the preview must show the layout, not run code that could change the page under the editor's hands. #} {% if pageScript|default(null) and not editing|default(false) %}{% endif %} diff --git a/tests/CmsTest.php b/tests/CmsTest.php index 06b48f7..41a4cb6 100644 --- a/tests/CmsTest.php +++ b/tests/CmsTest.php @@ -686,6 +686,76 @@ final class CmsTest extends TestCase } } + public function testASliderAdvancesByItselfOnlyWhenItWasAskedTo(): void + { + // Autorotation braucht ein Skript - das einzige auf einer öffentlichen Seite außer den + // Tabs. Es kommt nur auf Seiten, die einen solchen Slider tatsächlich enthalten, und die + // Richtlinie bleibt sonst bei script-src 'none'. + $document = DemoContent::starter('Mit Slider'); + $document['schemaVersion'] = 17; + $document['blocks'][] = ['id' => 'slider', 'type' => 'slider', 'props' => [ + 'desktopVisible' => 1, 'tabletVisible' => 1, 'mobileVisible' => 1, 'snap' => true, 'autoplay' => 0, + ], 'children' => []]; + $this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1); + + $ohne = $this->request('/'); + self::assertStringNotContainsString('slider.js', $ohne->getContent()); + self::assertStringContainsString("script-src 'none'", (string) $ohne->headers->get('Content-Security-Policy')); + + $document['blocks'][array_key_last($document['blocks'])]['props']['autoplay'] = 7; + $revision = $this->pages->saveDraft('page-1', $document, $this->pages->findForEditor('page-1')['version']); + $this->pages->publish('page-1', $revision, $this->pages->findForEditor('page-1')['version']); + + $mit = $this->request('/'); + self::assertStringContainsString('', $mit->getContent()); + self::assertStringContainsString('data-autoplay="7"', $mit->getContent()); + self::assertStringContainsString("script-src 'self'", (string) $mit->headers->get('Content-Security-Policy')); + + // Das Skript hält sich an die Bewegungsschranke und hört beim ersten Eingriff auf. + $skript = (string) file_get_contents(__DIR__.'/../public/assets/slider.js'); + self::assertStringContainsString("matchMedia('(prefers-reduced-motion: reduce)')", $skript); + self::assertStringContainsString("'pointerdown', 'keydown', 'touchstart', 'wheel'", $skript); + } + + public function testAPostListCanPageThroughEverythingInsteadOfCuttingOff(): void + { + // Umut: „fehlt die Pagination bei Projekte? Es werden nur 12 angezeigt.“ Richtig: eine + // Auswahl zeigt die ersten n, eine Übersicht muss blättern - sonst verschweigt sie, dass + // es mehr gibt. Das ist die eigene Regel aus Arbeitspaket M, die für die Beitragsliste + // eines Dokuments bis hierher nicht galt. + for ($i = 1; $i <= 7; ++$i) { + $this->publishedPost('projekt-'.$i, 'Projekt '.$i, sprintf('2026-02-%02d', $i), ['Projekte'], []); + } + $document = DemoContent::starter('Übersicht'); + $document['schemaVersion'] = 17; + $document['blocks'][] = ['id' => 'liste', 'type' => 'postlist', 'props' => [ + 'count' => 3, 'category' => 'projekte', 'tag' => '', 'paginate' => true, + ]]; + $this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1); + + $erste = $this->request('/')->getContent(); + self::assertSame(3, substr_count($erste, 'post-card')); + self::assertStringContainsString('Seite 1 von 3', $erste); + self::assertStringContainsString('7 Beiträge', $erste); + self::assertStringNotContainsString('Neuere', $erste, 'Auf der ersten Seite gibt es nichts Neueres.'); + + $dritte = $this->request('/?seite=3')->getContent(); + self::assertStringContainsString('Seite 3 von 3', $dritte); + self::assertSame(1, substr_count($dritte, 'post-card')); + self::assertStringNotContainsString('Ältere', $dritte); + + // Eine Seitenzahl jenseits des Endes landet auf der letzten Seite statt im Leeren. + self::assertStringContainsString('Seite 3 von 3', $this->request('/?seite=99')->getContent()); + + // Ohne den Schalter bleibt es eine Auswahl: drei Karten, keine Leiste. + $document['blocks'][array_key_last($document['blocks'])]['props']['paginate'] = false; + $revision = $this->pages->saveDraft('page-1', $document, $this->pages->findForEditor('page-1')['version']); + $this->pages->publish('page-1', $revision, $this->pages->findForEditor('page-1')['version']); + $auswahl = $this->request('/?seite=2')->getContent(); + self::assertSame(3, substr_count($auswahl, 'post-card')); + self::assertStringNotContainsString('Seite 1 von', $auswahl); + } + public function testAPostCardReactsToThePointerAndToTheKeyboard(): void { // Umut: „es gibt keinen Mausover-Effekt, das fehlt“. Eine Karte, die sich beim Zeigen