ex-akt / ctm-header
Header components (menu toggle, navigation behaviour) for Contao Theme-Manager
Package info
Type:symfony-bundle
pkg:composer/ex-akt/ctm-header
Requires
- php: ^8.2
- contao-thememanager/core: ^2.2
- contao-thememanager/ctm-push-navigation: ^1.1
- contao/core-bundle: ^5.3
Requires (Dev)
- contao/manager-plugin: ^2.3.1
Conflicts
- contao/core: *
- contao/manager-plugin: <2.0 || >=3.0
This package is auto-updated.
Last update: 2026-08-11 12:49:53 UTC
README
Header-Bausteine für Contao-Projekte mit dem Theme-Manager – zentral gepflegt statt in jedem Kundenprojekt neu aufgebaut.
Warum das Paket existiert
ctm-push-navigation initialisiert sich nur, wenn im DOM etwas auf seinen
Default-Selektor .mod_toggle passt. Contao bringt für diesen Zweck keinen
Modultyp mit, also stand das Markup in den Projekten bisher je als HTML-Modul
in tl_module.html:
<div class="mod_toggle pn-toggle pn-show"><span class="burger-box"><span class="burger-inner"></span></span></div>
Das sind 114 Zeichen in der Datenbank – nicht im Git, nicht im Deploy
(database:release überschreibt, es merged nicht), nicht zwischen Projekten
diffbar und nicht per composer update zu reparieren. Eine Korrektur in einem
Projekt erreichte die anderen nie. Beim Toggle-CSS war die Drift entsprechend
schon messbar: dieselbe Datei, in drei Regeln auseinandergelaufen.
Dieses Paket macht daraus Code.
Inhalt
| Baustein | Was er löst |
|---|---|
Modultyp Menü-Umschalter (exakt_toggle) |
Markup im Template statt in der Datenbank |
ctm_modules-xh/_toggle.scss |
zeichnet das Burger-Symbol – ctm-push-navigation liefert dafür nur Sichtbarkeit und Button-Reset |
ctm_modules-xh/_navigation.scss |
Umschaltung Hauptnavigation ↔ Push-Navigation inkl. der :not(.pn-init)-Spezifitätsfalle, dazu die Listen-Rücksetzung |
ctm_modules-xh/_submenu.scss |
Fläche und Farben des Desktop-Untermenüs – Core und a11y-Paket liefern keine |
ctm_modules-xh/_navigation-icons.scss |
die Aufklapp-Pfeile als CSS-Form, weil ihre Glyphen nicht im Projekt-Font landen |
ctm_modules-xh/_pushnavigation.scss |
zweite Erscheinungsform des mobilen Menüs: Panel statt Vollbild-Overlay |
Skript exakt_navigation (js_exakt_navigation) |
Init der Push-Navigation mit Scroll-Sperre, „Klick daneben schließt", Schließen bei Sprungmarken und Escape |
ctm_modules-xh/_container.scss |
Breite von Header und Footer – der Core begrenzt nur Artikel |
style-manager-exakt-header.xml |
Style-Manager-Gruppen für Symbolgröße (am Modul), Inhaltsbreite und mobiles Menü (am Layout), ohne Kopie ins Projekt |
Installation
composer require ex-akt/ctm-header
In layout/styles/projekt.scss (die Datei aus tl_theme.skinSourceFiles)
einbinden – nach pushnavigation, dessen .pn-toggle das Paket ergänzt:
@import "theme"; @import "accessibility"; @import "pushnavigation"; @import "stickyheader"; @import "header"; // ex-akt/ctm-header
Ohne Pfadangabe: Der Theme-Compiler setzt das Verzeichnis jeder in
TC_SOURCES registrierten Datei als Import-Pfad.
Danach im Backend:
- Das bisherige HTML-Modul durch ein Modul vom Typ Menü-Umschalter ersetzen
und im Layout an dessen Stelle setzen (Bereich
header). - In
tl_layout.scriptsmussjs_ctm_pushnavigationstehen –scripts, nichtscript. Das Singular-Feld ist ein Freitextfeld für eigenen Code, dort wirken Template-Namen nicht. contao-thememanager/ctm-accessibilitymuss installiert sein. Das Push-Navigation-JS liestnavAriaLabelsungeschützt, definiert die Variable aber nie – ohne das a11y-Paket bricht der Konstruktor mitReferenceErrorab und das Menü tut kommentarlos nichts.- Unter Layout → Inhaltsbreite die Breite von Header und Footer wählen. Ohne Auswahl bleibt es beim bisherigen Verhalten (volle Fensterbreite) – das Aktualisieren des Pakets ändert an einer bestehenden Seite also nichts.
- Für ein Panel statt des Vollbild-Overlays: unter Layout → Mobiles Menü die
Richtung wählen und in
tl_layout.scriptsctm_pushnavigationdurchexakt_navigationersetzen (siehe unten). - Theme neu kompilieren.
Das Toggle-CSS, die header .mod_navigation-Regeln und die
Breitenbegrenzung von Header und Footer aus dem Projekt-SCSS entfernen – sie
kommen jetzt aus dem Paket.
Zwei weitere Regeln, die in Projekten oft danebenstehen, gehören ebenfalls nicht hierher, sondern sind Framework-Sache:
- Platz unter dem fixierten Header. Der Core liefert
body.fixed.undock { padding-top: var(--hdr-hght) }, geschaltet über Layout → Header-Verhalten → Undocked. Ein eigenes#wrapper { padding-top }baut das nach. Grund für die Verwechslung:ctm-sticky-headersetztposition: fixedunabhängig von der Klasse – ohne die Backend-Auswahl fehlt der Platzhalter also trotzdem, und der Reflex ist, ihn selbst zu schreiben. - Logo und Navigation nebeneinander. Der Core setzt
header > .insidebereits aufdisplay: flex; Ausrichtung und Innenabstand kommen aus$header-alignment-vertical,$header-alignment-horizontalund$header-padding. Achtung: Intl_theme.themeConfigkönnen abweichende Werte stehen, die den Core-Default überschreiben (gemessen:centerstattspace-between). Wer die Projektregel entfernt, ohne das zu prüfen, rückt den Header in die Mitte.
Untermenüs
Fällt erst auf, wenn eine Seite zum ersten Mal Unterseiten bekommt — dann ist
plötzlich nichts erkennbar und nichts lesbar. Das Aufklappen selbst liefert
ctm-accessibility vollständig (Button .btn-toggle-submenu, Schalter
.nav-expanded am li); es legt dabei die Hover-Variante des Themes still.
Diese Mechanik also nicht nachbauen. Was fehlt, ist die Erscheinung, und die
kommt aus diesem Paket.
Voraussetzung: nav nav-x am Navigationsmodul (Style-Manager → Navigation,
Wert Horizontal). Der Core setzt .submenu { position: relative } und das
absolute Dropdown ausschließlich unter .nav-x. Fehlt die Klasse, findet der
Aufklapp-Button keinen positionierten Vorfahren — gemessen bei 1440px
Fensterbreite: Pfeil bei x = 1415 statt bei 673. Ein nav allein, wie es
oft von Hand in der cssID steht, genügt nicht.
Was das Paket ergänzt:
-
Fläche. Weder Core noch a11y-Paket geben der
.level_2einen Hintergrund. Über einem Hero-Bild sind die Unterpunkte damit schlicht nicht lesbar. -
Farbe ausdrücklich statt geerbt.
ctm-sticky-headerfärbtheader .level_1 > li:is(.active, .trail)und setzt an> li > acolor: inherit— die zweite Ebene wechselt sonst ihre Farbe, je nachdem ob der Elternpunkt gerade aktiv ist. -
Platz für den Pfeil, der absolut am rechten Rand des Elternpunkts sitzt und sonst auf dessen letztem Buchstaben klebt.
-
Die Pfeile selbst, als CSS-Form. Der ThemeCompiler erzeugt
_icon.cssund den Icomoon-Font nur aus den im Projekt ausgewählten Icons; die UI-Glyphen der Pakete sind nie dabei. Ohne Auswahl entsteht gar keine_icon.css(gemessen). Betroffen sind.btn-toggle-submenu::before(Desktop,\e985aus"ctm-icon"),.pn-handle.i-navigate_forwardund.pn-breadcrumb > i(mobil) — die beiden.i-*-Klassen sind überhaupt nirgends definiert. Der Button ist dabei da, trägt seinaria-labelund funktioniert; man sieht ihn nur nicht.Die native Rotation bleibt erhalten:
--btsz(0deg zu, −180deg offen, −90/−270deg im.level_2) wird zum Grundwinkel addiert statt ihn zu ersetzen.
header { --xh-sub-bg: var(--body-bg); // Fläche des Dropdowns --xh-sub-clr: var(--text-clr-base); --xh-sub-clr-a: var(--clr-primary); // aktiv/hover --xh-sub-p: .5rem 0; // Innenabstand des Panels --xh-sub-i-p: .5rem 1.25rem; // je Eintrag --xh-sub-shdw: 0 .5rem 1.25rem rgba(0, 0, 0, .14); --xh-sub-bdr-r: 0; --xh-sub-gap: 1.6em; // Platz für den Pfeil }
Hat ein Projekt die Glyphen doch im eigenen Icomoon-Satz, holt es sie mit
content: '\e985' und border: 0 an derselben Stelle zurück.
Mobiles Menü: Overlay oder Panel
ctm-push-navigation zeichnet das mobile Menü als Vollbild-Overlay:
.pn-init liegt position: fixed über 100vw/100vh, halbtransparent
(rgba(var(--body-bg-rgb),.85)) mit backdrop-filter, eingeblendet über
opacity. „Push" heißt dort nur, dass die Unterebenen seitlich hereinfahren
(.pn-next { translateX(-100%) }) — nicht das Menü selbst.
Das Paket-JS verwaltet ausschließlich Zustandsklassen; Geometrie steht dort an
keiner Stelle. Ein von der Seite einfahrendes Panel braucht deshalb keinen
Fork, nur einen zweiten CSS-Satz auf denselben Klassen. Genau den bringt
ctm_modules-xh/_pushnavigation.scss mit, geschaltet über eine Klasse an
<body>:
| Layout → Mobiles Menü | Klasse | Ergebnis |
|---|---|---|
| (ohne Auswahl) | – | Vollbild-Overlay wie bisher |
| Panel von links | nav-pnl-left |
Panel fester Breite, fährt von links ein |
| Panel von rechts | nav-pnl-right |
dasselbe von rechts |
Dazu gehört das Skript exakt_navigation in tl_layout.scripts —
anstelle von ctm_pushnavigation, nicht zusätzlich: Beide erzeugen eine
Instanz auf window.pn, und das Standard-Template käme zuerst zum Zug. Es lädt
dasselbe Paket-JS und nutzt nur dessen vorgesehene Optionen
(onShow/onHide/onDestroy) sowie isOpen(); alles Weitere läuft über den
Umschalter, den das Paket selbst erzeugt (button.pn-btn). Keine
minifizierten Interna.
Was es ergänzt, und warum das nicht ins Projekt-JS gehört:
nav-openan<body>— Grundlage für den abgedunkelten Grund und die Scroll-Sperre. Als Klasse und nicht als Inline-Style, damit ein Projekt beides überschreiben kann.- Klick daneben schließt. Beim Overlay gibt es kein „daneben", beim Panel schon.
- Klick auf einen Menüpunkt schließt. Führt ein Punkt auf dieselbe Seite
(Sprungmarke
#anker), lädt nichts neu — ohne das bleibt das Menü offen über dem Ziel stehen. Der häufigste Beschwerdegrund auf One-Pagern. - Escape schließt und gibt den Fokus an den Umschalter zurück. Das Paket baut einen Fokus-Zyklus auf; ohne Tastaturausstieg ist das ein Keyboard-Trap.
Drei Dinge, die beim Nachbauen schiefgehen — alle gemessen:
- Der Panel-Transform gehört an
.pn-init, nicht an dieul. Die trägt beim Absteigen in die zweite Ebene bereits.pn-next { translateX(-100%) }. Ein zweiter Transform dort zieht den Panel-Zustand mit: Das Panel führe beim Öffnen eines Untermenüs zur Hälfte wieder hinaus. - Der abgedunkelte Grund kann kein Pseudoelement des Panels sein. Sowohl
transformals auchbackdrop-filtermachen.pn-initzum containing block fürposition: fixed— der Grund säße im Panel fest. Er hängt deshalb an<body>::after, mitz-index: 98unter demheader { z-index: 99 }des Core. - Die gestaffelte Einblendung des Pakets braucht
!important. Die Regel.pn-active:not(.pn-next) li:nth-of-type(N)hat Spezifität 0,5,1; ein realistischer Selektor von außen bleibt darunter. Im Panel wirkt die Staffelung nachgezogen (die Punkte poppen einzeln auf, nachdem das Panel schon steht), deshalb ist sie dort auf0sgesetzt.
Feinjustage über CSS-Variablen, am besten an body oder header:
body { --xh-pnl-w: min(85vw, 21rem); // Panelbreite --xh-pnl-bg: var(--body-bg); // Panelgrund --xh-pnl-p-x: 1.75rem; // Innenabstand, wandert mit dem Pfeil --xh-pnl-p-y: 1.5rem; --xh-pnl-dur: .3s; // Fahrtdauer, gilt auch für den Grund --xh-pnl-shdw: 0 0 2rem rgba(0, 0, 0, .18); --xh-pnl-bd: rgba(0, 0, 0, .45); // abgedunkelter Grund --xh-pnl-bd-blur: 2px; --xh-pnl-bd-z: 98; }
Die Theme-Manager-Einstellung Push-Navigation → Hintergrund (--pn-bg) greift
im Panel-Modus nicht: Ihr Default ist halbtransparent, was für ein Panel nicht
taugt. Wer sie trotzdem will: --xh-pnl-bg: var(--pn-bg).
Anpassen im Projekt
Das Paket wird zweimal übersetzt, und davon hängt ab, was greift:
| Weg | Ergebnis | $-Overrides |
|---|---|---|
@import "header" in projekt.scss |
landet in projekt.css |
greifen, wenn _config-overrides.scss vor dem Import steht |
TC_SOURCES['files'] (automatisch) |
eigene _header.css |
greifen nicht – eigenes Kompilat ohne Projekt-SCSS |
Da beide Dateien entstehen, aber nur die eingebundene zählt: Wer nach dem
Muster oben installiert, kann $xh-toggle-width in _config-overrides.scss
setzen. Verlässlicher – und unabhängig vom Einbindungsweg – sind die
CSS-Variablen:
.mod_toggle { --xh-tgl-w: 2.5rem; // Breite --xh-tgl-h: 1.5rem; // Höhe --xh-tgl-bar-h: .25rem; // Balkenstärke --xh-tgl-bar-r: 0; // Balkenradius --xh-tgl-clr: #333; // Farbe --xh-tgl-dur: .22s; // Dauer der Öffnen-Animation } header .mod_navigation { --xh-nav-gap: 2rem; // Abstand der Hauptpunkte --xh-nav-clr-a: var(--clr-secondary); // aktiv/hover } // Breite: normalerweise über die Layout-Auswahl. Für einen Wert, den keine // Stufe trifft, oder für Header ≠ Footer: header { --xh-hdr-wdth: 1400px; } footer { --xh-ftr-wdth: 100%; }
Zur Breite im Einzelnen: Die Backend-Auswahl setzt --xh-cnt-wdth an <body>,
von dort erbt es an beide. --xh-hdr-wdth und --xh-ftr-wdth stehen davor und
übersteuern es einzeln. Ohne jeden Wert bleibt max-width auf none.
Die drei Stufen entsprechen den Artikelbreiten art-px-1/2/3, damit Header,
Inhalt und Footer bündig stehen – gemessen bei 1440px: Header und ein Artikel
mit art-px-2 beide bei x = 104, Breite 1232px. Dass die kleinere Ziffer
die größere Breite bedeutet, ist eine Eigenart des Core (art-px beschreibt den
Außenabstand, nicht die Breite); das Paket spiegelt sie, statt eine zweite
Zählweise einzuführen.
Am Element gesetzt gewinnen sie immer über die :root-Defaults des Pakets –
unabhängig davon, welche CSS-Datei zuerst geladen wurde.
Merkhilfe: $… gilt zur Compile-Zeit und nur im selben Kompilat, --… zur
Laufzeit und überall.
Style-Manager
Config::loadBundleConfiguration() parst alle style-manager-*.xml unter
vendor/*/*/contao/templates/ zur Laufzeit. Das Paket zu installieren
genügt – nichts kopieren, nichts im Backend importieren. Dass
tl_style_manager leer bleibt, ist dabei kein Fehler.
Die XML wird generiert, nicht von Hand gepflegt:
php bin/generate-style-manager-xml.php
Die cssClasses- und modules-Felder sind serialisierte PHP-Arrays; von Hand
getippt stimmen die Längenpräfixe erfahrungsgemäß nicht (s:12:"exakt_toggle",
nicht s:13:), und ein falsches Präfix macht die Gruppe still unbrauchbar.
Zwei Gruppen mit unterschiedlichem Ziel:
| Gruppe | Ziel | Klassen |
|---|---|---|
mHeaderToggle → Größe des Symbols |
extendModule, nur exakt_toggle |
tgl-small, tgl-large |
xhHeaderFooter → Inhaltsbreite |
extendLayout |
cnt-w-1, cnt-w-2, cnt-w-3 |
Bei extendLayout schreibt Contao die Klasse an <body> – genauso arbeitet die
Core-Gruppe „Header behaviour", deren Regel body.fixed.undock lautet. Die
Klasse steht dabei sowohl in tl_layout.cssClass als auch, unter dem Schlüssel
{archivIdentifier}_{gruppenAlias}, im serialisierten Feld
tl_layout.styleManager. Wer sie nur in cssClass einträgt, verliert sie beim
nächsten Speichern: StyleManager::clearClasses() filtert jede Klasse aus dem
Feld, die zu einer Gruppe gehört, aber dort nicht ausgewählt ist.
Eigene Archive statt Andocken an die leeren Core-Archive gHeader/gFooter:
Für YAML-Konfigurationen ist das Zusammenführen nach identifier vorgesehen
(Config::parseYamlConfiguration prüft isset($styleArchives[$archiveIdent])),
für XML läuft der Import über ImportController::importXmlFiles – ohne
zugesicherte Merge-Semantik und abhängig von der Dateireihenfolge.
Was gegenüber den bisherigen Einzelkopien korrigiert wurde
Die Striche standen versetzt. In .burger-inner::before/::after fehlten
display: block und left: 0. Absolut positionierte Pseudoelemente behalten
dann ihre statische Position – gemessen 16px, also die halbe Symbolbreite.
Folge: oberer und unterer Strich nach rechts verschoben (der obere ragte über
den Rand), und beim Aufklappen erschien statt eines X ein Winkel. Hier stehen
jetzt beide Angaben.
pn-toggle und pn-show gehören nicht ins Markup. Das Paket-JS pflegt
beide selbst (this.s.classList.add(this.e.s), pn-show beim Ein- und
Ausblenden) und richtet sich dabei nach --nav-bhr. Gemessen bei 1440px:
pn-toggle gesetzt, pn-show nicht, Umschalter unsichtbar. Hartkodiert
verdoppelt das Markup diese Logik – und erzwingt eine Sonderregel, die den
Umschalter oberhalb des Breakpoints wieder ausblenden muss. Beides entfällt.
Die Animation lief einstufig. Ein gemeinsames transition-property: transform, top, bottom, opacity bewegt alles gleichzeitig. Bekommen
top/bottom/opacity eigene Übergänge mit Verzögerung, fahren erst die
äußeren Striche zusammen und dann dreht sich das Kreuz – dazu ein anderes
Easing beim Öffnen (cubic-bezier(.215, .61, .355, 1)) als beim Schließen.
Kleinere Angleichungen: line-height: 1, z-index, :hover { opacity: .7 }
im geöffneten Zustand, <div> statt <span> für die Boxen.
Die Breitenbegrenzung stand dreimal da, in drei Schreibweisen. Der Core
begrenzt die Inhaltsbreite über [class*=art-px] > .inside – eine Klasse, die
nur Artikel und Container tragen. Header und Footer haben keine Artikel, also
lief ihr Inhalt bis an den Fensterrand, während der Seiteninhalt mittig stand.
Gemessen:
| Projekt | Wert | Umsetzung |
|---|---|---|
| A, Header + Footer | --art-wdth |
max-width + margin-left/right: auto !important |
| B, Header | derselbe Wert | dieselben vier Zeilen, plus position: relative |
| B, Footer | derselbe Wert | nur die Variable, ohne Regel – wirkungslos |
| C, Header + Footer | anderer Wert, hart notiert | zusätzlich box-sizing und width |
In Projekt A stand der Wunsch bereits als Kommentar im Code („Als Einstellung
ins Repo übernehmen!"), in Projekt B war die Kopie im Footer schon
auseinandergefallen. box-sizing ist überdies global im Core
(ctm_base/_defaults.scss).
Der Listen-Reset nahm dem mobilen Menü seinen Innenabstand. Zwei Projekte
setzten header .mod_navigation ul { margin: 0; padding: 0; list-style: none }.
ctm-push-navigation klont die Navigation aber nicht – es hängt .pn-init an
dasselbe Element, das Overlay bleibt im <header> und wird von diesem Selektor
mitgetroffen. Mit Spezifität 0,1,2 schlägt er .pn-init ul { padding: 30px }
(0,1,1), das aufgeklappte Menü klebte also am Rand. Umgekehrt gilt für
list-style das Gegenteil: Der Core setzt es nur an .nav und nur oberhalb von
$navigation-behaviour-min-width, die Push-Navigation gar nicht – ohne eine
Regel stehen im mobilen Menü Aufzählungspunkte. Das Paket trennt beides
entsprechend auf.
Fallen beim Weiterentwickeln
-
contao/templates/twig/.twig-rootmuss existieren. Ohne diesen Marker behandelt Contao nichttwig/, sondern jeden Unterordner als Namespace-Wurzel; das Template hieße dannexakt_togglestattfrontend_module/exakt_toggleund der Aufruf endet in „Could neither find template …". -
Twig-Templates gehören unter
contao/templates/twig/, nicht direkt nachcontao/templates/. Die XML-Dateien dagegen schon – der ResourceFinder des Style-Managers sucht rekursiv. -
config/services.yamlwird nur über die DI-Extension geladen. FehltExAktCtmHeaderExtension, ist der Controller kein Fragment und der Modultyp fehlt kommentarlos in der Auswahl. -
Media Queries ausschreiben. Die Dateien werden zweimal übersetzt: über
@import "header"im Projekt (dort hat_theme.scssvorherctm_utilsgeladen) und als eigenes_header.cssüberTC_SOURCES. Im zweiten Kontext fehlen die Mixins. Gemessen:Schreibweise Paket-Kompilat @media (min-width: $navigation-behaviour-min-width)✓ 768px @media (min-width: map-get($breakpoints, 's'))✓ 768px @include media-breakpoint('s')✗ „Undefined mixin." $breakpointsund alle$-Werte auscore/_config.scsssind verfügbar, weil der Compiler jeder Datei dieconfigFilesaller Pakete voranstellt – nurctm_utils/nicht. Für benannte Breakpoints deshalbmap-get($breakpoints, …)statt des Mixins.Ein Abbruch hier beendet den gesamten Compile-Lauf, auch
projekt.csswird dann nicht mehr geschrieben – und derThemeCompileCommandzeigt die Ursache nicht an, sondern scheitert im catch-Block an einem undefinierten$io. Fehler also isoliert nachstellen (siehebin/).So halten es auch
ctm-push-navigationundctm-accessibility: dort steht ausschließlich ausgeschriebenes@mediamit derselben Variable. Nur der Core darf die Mixins nutzen – er wird über_theme.scssübersetzt.
Anforderungen
- PHP ≥ 8.2, Contao ≥ 5.3
contao-thememanager/core≥ 2.2contao-thememanager/ctm-push-navigation≥ 1.1contao-thememanager/ctm-accessibility(nicht als Abhängigkeit erzwungen, für das mobile Menü aber faktisch nötig – siehe oben)