AJAX-Filter für EXT:news in TYPO3 12

Mehr Tempo für die News-Liste im TYPO3

Mehr Tempo für die News-Liste: Mit einem kleinen JS-Snippet und zwei Template-Tweaks tauschen wir nur den Listen-Block aus. Ergebnis: spürbar schnellere Filter, saubere History und eine deutlich bessere User Experience.

1) List-Template um einen stabilen Container erweitern

Wir überschreiben das News-Listen-Template und packen die Ausgabe in einen fixen Wrapper #news-container, den wir später per AJAX austauschen.

TypoScript (Setup) – Override-Pfade:

                            plugin.tx_news {
 view {
   templateRootPaths.10 = EXT:site_package/Resources/Private/Extensions/News/Templates/
   partialRootPaths.10  = EXT:site_package/Resources/Private/Extensions/News/Partials/
   layoutRootPaths.10   = EXT:site_package/Resources/Private/Extensions/News/Layouts/
 }
}
                        

EXT:site_package/Resources/Private/Extensions/News/Templates/News/List.html:

                            <f:layout name="General" />

<f:section name="content">
  <div id="news-container">
    <f:if condition="{news}">
      <f:for each="{news}" as="newsItem">
        <f:render partial="List/Item" arguments="{newsItem: newsItem, settings: settings}" />
      </f:for>

      <!-- Gib deiner Pagination einen klaren Selektor -->
      <nav class="news-pagination">
        <f:render partial="List/Pagination" arguments="{pagination: pagination, paginator: paginator, settings: settings}" />
      </nav>
    </f:if>
  </div>
</f:section>

                        

Wichtig: Der gleiche #news-container muss auf jeder Seite/Ansicht vorhanden sein, deren HTML du lädst (Kategorie, Pagination, etc.).

2) Kategorie-Links sauber erzeugen (mit cHash)

Erzeuge deine Kategorie-Links immer mit <f:link.page> und additionalParams, damit TYPO3 korrekte URLs inkl. cHash baut.

Beispiel Kategorie-Menü (im eigenen Template/Partial):

                            <ul class="news-categories">
  <f:for each="{categories}" as="category">
    <li>
      <f:link.page
        pageUid="{settings.listPid}"
        class="category-link"
        additionalParams="{
          tx_news_pi1: {
            overwriteDemand: {
              categories: category.uid,
              includeSubCategories: 1,
              categoryConjunction: 'or'
            }
          }
        }"
      >{category.title}</f:link.page>
    </li>
  </f:for>
</ul>

                        

3) JavaScript (Delegation + pushState)

Das folgende Skript:

  • fängt Klicks auf Kategorien und auf Pagination-Links ab,
  • lädt die Ziel-URL per fetch(),
  • ersetzt nur den inneren HTML-Inhalt von #news-container,
  • aktualisiert die Browser-Adresse via history.pushState,
  • reagiert auf den Zurück-Button mittels popstate.

Kommentare im Code sind absichtlich auf Englisch. Zur Zeile-für-Zeile-Erklärung nutze ich Marker [1], [2], …, auf die ich direkt darunter eingehe.

                            <script>
// [1] Run script after DOM is ready
document.addEventListener('DOMContentLoaded', () => {
  // [2] Grab the container that we will replace
  const container = document.querySelector('#news-container');
  if (!container) return; // [3] Safety check

  // [4] Helper to fetch a URL and inject only the inner HTML of #news-container
  async function loadIntoContainer(url, addToHistory = true) {
    try {
      // [5] Fetch the full page HTML (server-side rendering stays intact)
      const res = await fetch(url, { headers: { 'X-Requested-With': 'fetch' } });
      if (!res.ok) throw new Error('HTTP ' + res.status); // [6] Basic error handling

      // [7] Read HTML as text
      const html = await res.text();

      // [8] Create a temporary DOM to query the new #news-container
      const tmp = document.createElement('div');
      tmp.innerHTML = html;

      // [9] Find the #news-container inside the response
      const newContent = tmp.querySelector('#news-container');
      if (!newContent) throw new Error('Missing #news-container in response');

      // [10] Replace current container content
      container.innerHTML = newContent.innerHTML;

      // [11] Optionally push new URL into history (so the address bar updates)
      if (addToHistory) history.pushState({ ajax: true }, '', url);

    } catch (e) {
      // [12] Log AJAX errors for debugging
      console.error('AJAX error:', e);
    }
  }

  // [13] Global click delegation (works for dynamic content too)
  document.addEventListener('click', (ev) => {
    // [14] Find nearest link for the clicked element
    const a = ev.target.closest('a');
    if (!a) return;

    // [15] Category links: intercept and load via AJAX
    if (a.classList.contains('category-link')) {
      ev.preventDefault();

      // [16] Toggle "active" state in the category menu
      document.querySelectorAll('.category-link').forEach(el => el.classList.remove('active'));
      a.classList.add('active');

      // [17] Load the target URL into #news-container
      loadIntoContainer(a.href);
      return;
    }

    // [18] Pagination links: intercept by container class (see List.html)
    if (a.closest('.news-pagination')) {
      ev.preventDefault();
      loadIntoContainer(a.href);
      return;
    }
  });

  // [19] Handle back/forward navigation
  window.addEventListener('popstate', () => {
    // [20] Reload current URL into container without pushing a new history entry
    loadIntoContainer(location.href, /* addToHistory */ false);
  });
});
</script>

                        

Zeile-für-Zeile-Erklärung (Marker [n]):

  1. Registriert den Startpunkt: Script läuft, sobald der DOM aufgebaut ist.
  2. Sucht den Ziel-Container, den wir dynamisch ersetzen.
  3. Falls kein Container vorhanden ist, bricht das Script sauber ab (kein Fehler).
  4. Hilfsfunktion: Lädt eine URL und ersetzt danach den Inhalt von #news-container.
  5. Holt das komplette Seiten-HTML (Server-Rendering bleibt 100 % erhalten; SEO-sicher). X-Requested-With ist optional, kann serverseitig für Logging genutzt werden.
  6. Wenn der Server keinen 2xx-Status liefert, werfen wir einen Fehler (landet in catch).
  7. Liest den Response-Body als reinen Text (HTML).
  8. Baut einen temporären DOM-Knoten, um im geladenen HTML selektieren zu können.
  9. Greift auf den #news-container in der geladenen Seite zu. Wichtig: Er muss in jeder Zielseite existieren.
  10. Ersetzt den inneren HTML-Inhalt des bestehenden Containers durch den aus der geladenen Antwort.
  11. Aktualisiert die Adresszeile über history.pushState, damit Links teilbar/Bookmark-fähig bleiben (Progressive Enhancement).
  12. Einheitliche Fehlerbehandlung (sichtbar in der Console).
  13. Event-Delegation: Statt allen Links separat Handler zu geben, horchen wir global und reagieren nur auf relevante Klicks – das funktioniert auch nach DOM-Austausch.
  14. Ermittelt das geklickte <a> (oder den nächstliegenden Link-Ancestor), damit auch Klicks auf Icons/Text innerhalb von Links funktionieren.
  15. Kategorie-Links: Standard-Navigation verhindern, stattdessen per AJAX nachladen.
  16. Visuelles Umschalten der aktiven Kategorie (Klasse active).
  17. Lädt die URL (inkl. richtiger cHash, weil der Link via <f:link.page> gebaut wurde) und ersetzt #news-container.
  18. Pagination-Links: Erkennen wir per Nähe zu .news-pagination (siehe Template), laden ebenfalls per AJAX.
  19. Lauschen auf Browser-Navigation zurück/vor.
  20. Lädt die aktuelle URL erneut in den Container, ohne einen neuen History-Eintrag zu erzeugen (damit History korrekt bleibt).

4) Optional: «leichter» Endpunkt nur für die Liste

Wenn du statt der ganzen Seite nur die Listen-Teilansicht laden willst, kannst du einen typeNum anlegen, der nur das Plugin rendert. So transferierst du weniger HTML.

TypoScript:

                            ajax.newsList = PAGE
ajax.newsList {
  typeNum = 165099
  10 =< tt_content.list.20.news_news   # render the list plugin only
  config {
    disableAllHeaderCode = 1
  }
}

                        

JS-Anpassung (nur eine Zeile im Click-Handler ändern):

                            // Before calling loadIntoContainer:
const url = new URL(a.href, location.origin);
url.searchParams.set('type', '165099'); // use partial list endpoint
loadIntoContainer(url.toString());

                        

 

5) Wichtige Hinweise / Stolpersteine

  • cHash: Links immer via <f:link.page> + additionalParams erzeugen (nicht „hart“ basteln).
  • Gleicher Container: Stelle sicher, dass jede Zielseite denselben #news-container enthält.
  • Pagination-Selektor: Achte auf .news-pagination im Template, damit das Delegations-Matching greift.
  • JS global halten: Packe das Skript ausserhalb des dynamisch ersetzten HTMLs (z. B. in dein Site-Bundle), sonst verlierst du Event-Handler.
  • SEO/No-JS: Ohne JS funktionieren die Links normal (Full-Reload). Mit JS bekommst du schnelle Teil-Updates und aktualisierte URL per pushState.

Java Script