Seiten
Inhalt
Navigation – Component Concept
Status: CONCEPT · Erstellt März 2026 Übergeordnet:
GLOBAL_concept-ecosystem.md,GLOBAL_concept-web.mdImplementierung in:@xpulse/controllerInspiration: J•Frame Theming Component –NavigationManager+@NavigationItemAnnotation
Problem
Der Router kennt nach der Controller-Discovery alle registrierten Routes –
inklusive parametrisierter Routen (/info/:page?), API-Endpunkte (POST /contact),
und interner Sub-Routen die nie als Navigation-Link erscheinen sollen.
Wer alle Routes blind als Navigation ausgibt, hat ein Filterproblem. Wer die Navigation manuell pflegt, hat ein Synchronisierungsproblem.
Lösung: Opt-in Navigation via `define()` + Event Pipeline
Navigation-Einträge werden im Controller deklariert – explizit, nicht automatisch.
Nur was mit this.navItem() markiert ist, taucht in der Navigation auf.
Nach der Discovery läuft die Navigation durch eine Event-Pipeline die
der App-Code nutzen kann um zu filtern, zu erweitern oder umzusortieren –
bevor die fertige Navigation als _nav ins Template injiziert wird.
Konzept im Überblick
| Controller Discovery |
| ↓ |
| navItem() Deklarationen gesammelt ← Opt-in, direkt im Controller |
| ↓ |
| controller:navigation:collected ← Event: App-Code kann filtern/erweitern |
| ↓ |
| controller:navigation:ready ← Event: fertige Navigation, read-only |
| ↓ |
| template:render:before ← _nav in Template-Daten injiziert |
| ↓ |
| Template rendert <nav> |
Opt-in im Controller
Navigation wird in define() deklariert – der gleiche Hook der auch
Route-Overrides (overrideRoute, overrideMethod) enthält.
| // src/controllers/info.js |
| export default class InfoController extends Controller { |
| define() { |
| this.navItem('index', { label: 'Info', position: 10 }); |
| this.navItem('team', { label: 'Team', position: 20 }); |
| // guide() bleibt stumm – erreichbar, aber kein Nav-Eintrag |
| } |
| index() { ... } // → GET /info |
| team() { ... } // → GET /info/team |
| guide() { ... } // → GET /info/guide (kein navItem → kein Nav-Link) |
| } |
| // src/controllers/index.js |
| export default class IndexController extends Controller { |
| define() { |
| this.navItem('index', { label: 'Start', position: 0 }); |
| } |
| index() { ... } // → GET / |
| } |
| // src/controllers/contact.js |
| export default class ContactController extends Controller { |
| define() { |
| this.navItem('index', { label: 'Kontakt', position: 30 }); |
| // submit() ist POST – kein navItem, macht keinen Sinn |
| } |
| index() { ... } // → GET /contact |
| submit() { ... } // → POST /contact |
| } |
`navItem()` API
| this.navItem(method, options) |
| Option | Typ | Default | Beschreibung |
|---|---|---|---|
key |
string |
auto-generiert | Eigener Key – überschreibt den auto-generierten |
label |
string |
Methodenname (ucfirst) | Anzeigename im Nav-Link |
position |
number |
0 |
Sortierreihenfolge (aufsteigend) |
parent |
string |
'' |
Key eines anderen navItems → Sub-Navigation |
icon |
string |
'' |
Optionaler Icon-Identifier (z.B. CSS-Klasse) |
target |
string |
'_self' |
Link-Target (_self, _blank) |
method verweist auf eine Methode des eigenen Controllers – der Router
löst den korrekten href automatisch auf. Kein manuelles Pfad-Tippen.
Key-Konstruktion
Auto-generierter Key
Der Key wird aus dem Controller-Slug und dem Methodennamen zusammengebaut,
getrennt durch _:
| {controllerSlug}_{methodName} |
Der Controller-Slug leitet sich aus dem Dateipfad relativ zu src/controllers/ ab –
Verzeichnistrenner werden zu _, die Dateiendung fällt weg:
| src/controllers/index.js → controllerSlug: index |
| src/controllers/info.js → controllerSlug: info |
| src/controllers/tool/chat.js → controllerSlug: tool_chat |
| src/controllers/admin/user.js → controllerSlug: admin_user |
Daraus ergeben sich die Keys:
| index.js → index() → key: index_index |
| info.js → index() → key: info_index |
| info.js → team() → key: info_team |
| info.js → guide() → key: info_guide |
| tool/chat.js → index() → key: tool_chat_index |
| tool/chat.js → guide() → key: tool_chat_guide |
| admin/user.js → index() → key: admin_user_index |
| admin/user.js → edit() → key: admin_user_edit |
Key zur Entwicklungszeit herausfinden
Als Entwickler brauchst du den Key z.B. wenn du parent in einem anderen
Controller setzen willst. Es gibt drei Wege:
1. Aus dem Dateipfad ableiten (ohne Tool):
| src/controllers/{path}/{file}.js → {path}_{file}_{method} |
| Unterstriche statt Slashes, kein .js |
2. Logging beim Start – @xpulse/controller loggt alle navItems auf debug-Level:
| [controller] navItem registered: info_team → GET /info/team "Team" |
| [controller] navItem registered: info_index → GET /info "Info" |
3. @xpulse/debug Collector (wenn installiert) – zeigt alle registrierten
navItems in der Debug Toolbar / Web Profiler unter "Navigation".
Eigenen Key vergeben
Wenn der auto-generierte Key zu lang oder unhandlich ist, kann ein eigener gesetzt werden:
| // src/controllers/tool/chat.js |
| define() { |
| this.navItem('index', { key: 'chat', label: 'Chat', position: 10 }); |
| } |
Der custom Key 'chat' ersetzt 'tool_chat_index' vollständig –
in der Datenstruktur, im Event-Payload und als parent-Referenz.
| // Ein anderer Controller referenziert den custom Key als parent: |
| this.navItem('index', { label: 'Chat Guide', parent: 'chat' }); |
Vorsicht: Custom Keys liegen in der Verantwortung des Entwicklers. Kollidiert ein custom Key mit einem anderen auto-generierten oder custom Key, greift das Error Handling (siehe unten).
NavItem Datenstruktur
Was die Event-Pipeline bekommt und weitergibt:
| { |
| key: 'info_team', // eindeutiger Schlüssel: controllerKey_methodName |
| label: 'Team', |
| href: '/info/team', // vom Router aufgelöst |
| position: 20, |
| parent: 'info_index', // '' wenn kein Parent |
| icon: '', |
| target: '_self', |
| active: false, // true wenn aktuelle Route matcht |
| children: [], // NavItems mit passendem parent-Key |
| } |
Event Pipeline
`controller:navigation:collected`
Wird gefeuert nachdem alle Controller-Discovery abgeschlossen ist und
alle navItem() Deklarationen gesammelt wurden – bevor die Navigation
finalisiert wird.
| // Payload |
| { items: NavItem[] } |
Der Handler kann das items-Array mutieren – filtern, ergänzen,
umsortieren. Der Rückgabewert wird ignoriert; Mutation ist der Weg.
| // Beispiel: Filtern |
| event.on('controller:navigation:collected', ({ items }) => { |
| // Admin-Link nur anzeigen wenn eingeloggt |
| const filtered = items.filter(item => { |
| if (item.key === 'admin_index') return isAuthenticated(); |
| return true; |
| }); |
| items.splice(0, items.length, ...filtered); |
| }); |
| // Beispiel: Externen Link ergänzen |
| event.on('controller:navigation:collected', ({ items }) => { |
| items.push({ |
| key: 'extern_github', |
| label: 'GitHub', |
| href: 'https://github.com/xpulse1', |
| position: 99, |
| parent: '', |
| icon: '', |
| target: '_blank', |
| active: false, |
| children: [], |
| }); |
| }); |
| // Beispiel: Umsortieren |
| event.on('controller:navigation:collected', ({ items }) => { |
| items.sort((a, b) => a.position - b.position); |
| }); |
`controller:navigation:ready`
Wird gefeuert nachdem die Pipeline abgeschlossen ist – Navigation ist finalisiert, Parent-Child-Struktur aufgebaut, sortiert.
Read-only. Mutation hier hat keinen Effekt mehr.
| // Payload |
| { items: NavItem[] } // bereits hierarchisch – items mit children[] |
Nützlich für Logging, Debugging oder externe Systeme die die fertige
Navigation kennen müssen (z.B. Sitemap-Generator, @xpulse/debug Collector).
Hierarchie: Parent-Child
Nach der Pipeline baut @xpulse/controller die Hierarchie auf –
NavItems mit parent-Key werden als children[] eingehängt.
| // Deklaration |
| this.navItem('index', { label: 'Produkte', position: 10 }); |
| this.navItem('chat', { label: 'Chat', position: 0, parent: 'produkte_index' }); |
| this.navItem('web', { label: 'Web', position: 1, parent: 'produkte_index' }); |
| // Resultierende Struktur |
| { |
| key: 'produkte_index', |
| label: 'Produkte', |
| href: '/produkte', |
| children: [ |
| { key: 'produkte_chat', label: 'Chat', href: '/produkte/chat', children: [] }, |
| { key: 'produkte_web', label: 'Web', href: '/produkte/web', children: [] }, |
| ] |
| } |
Active State
@xpulse/controller markiert das NavItem dessen href mit der aktuellen
Route (req.path) übereinstimmt als active: true.
Bei Hierarchie: wenn ein Child active ist, wird auch das Parent als
active: true markiert – praktisch für aufgeklappte Sub-Navigationen.
Template-Injection
Analog zu _route wird _nav automatisch in alle Template-Daten injiziert –
einmalig beim controller.init(), kein manueller Aufwand pro View.
| // Intern in @xpulse/controller |
| event.on('template:render:before', ({ data }) => { |
| data._nav = navigationRegistry.getItems(); // fertige, hierarchische NavItems |
| }); |
Im Template:
| <nav> |
| {% for item in _nav %} |
| <a href="{{ item.href }}" |
| class="{{ item.active ? 'active' : '' }}" |
| target="{{ item.target }}"> |
| {{ item.label }} |
| </a> |
| {% if item.children.length %} |
| <ul> |
| {% for child in item.children %} |
| <li> |
| <a href="{{ child.href }}" |
| class="{{ child.active ? 'active' : '' }}"> |
| {{ child.label }} |
| </a> |
| </li> |
| {% endfor %} |
| </ul> |
| {% endif %} |
| {% endfor %} |
| </nav> |
Error Handling
Doppelter Key
Der häufigste Fehlerfall: zwei navItem()-Deklarationen landen beim gleichen Key –
entweder weil zwei Controller zufällig den gleichen auto-generierten Key produzieren
(selten aber möglich), oder weil ein custom Key kollidiert.
Verhalten: Der zweite Eintrag wird verworfen. Der erste gewinnt immer.
| [controller] navItem DUPLICATE key "chat" ignored |
| → src/controllers/support/chat.js @ index() |
| already registered by: src/controllers/tool/chat.js @ index() |
| Fix: use key option to set an explicit unique key |
Das ist ein Startup-Fehler der sofort auffällt – kein stiller Datenverlust, kein falsches Rendering.
Unbekannte Methode
Wird in navItem() ein Methodenname angegeben der im Controller nicht existiert,
wird gewarnt und der Eintrag verworfen.
| [controller] navItem UNKNOWN method "details" in InfoController |
| → src/controllers/info.js |
| Available methods: index, team, guide |
Unbekannter Parent-Key
Referenziert ein parent-Wert einen Key der nach der kompletten Discovery
nicht existiert, wird gewarnt – das navItem bleibt aber erhalten und wird
als Top-Level-Eintrag behandelt.
| [controller] navItem parent "produkte_index" not found for key "info_team" |
| → src/controllers/info.js @ team() |
| Item will be rendered as top-level nav entry |
Das ist kein fataler Fehler – die Navigation funktioniert weiter, nur ohne
die gewünschte Hierarchie. Gibt dem Entwickler die Chance das im collected-Event
noch zu korrigieren.
Zusammenfassung Error-Strategie
| Fehlerfall | Verhalten | Log-Level |
|---|---|---|
| Doppelter Key | Zweiter Eintrag verworfen, erster gewinnt | warn |
| Unbekannte Methode | Eintrag verworfen | warn |
| Unbekannter Parent-Key | Eintrag bleibt, wird Top-Level | warn |
navItem() außerhalb define() |
Eintrag verworfen | warn |
Kein Fehlerfall wirft eine Exception oder bricht den Start ab – Navigation ist non-critical. Der App-Start läuft durch, alle Fehler sind im Log sichtbar.
Logging
@xpulse/controller loggt alle Navigation-relevanten Vorgänge über
@xpulse/logger. Die Log-Level sind bewusst gewählt:
`debug` – Normalfall
| [controller] navItem registered: info_index → GET /info "Info" position=10 |
| [controller] navItem registered: info_team → GET /info/team "Team" position=20 |
| [controller] navigation collected: 5 items |
| [controller] navigation ready: 5 items (2 top-level, 3 children) |
Alles was im Normalfall passiert läuft auf debug – im Produktionsbetrieb
stumm, in der Entwicklung mit LOG_LEVEL=debug sichtbar.
`warn` – Entwicklerfehler
Alle Error-Handling-Fälle (siehe oben) werden auf warn geloggt –
immer sichtbar, auch im Produktionsbetrieb, weil sie auf einen Konfigurationsfehler
hinweisen der behoben werden sollte.
`info` – Summary beim Start
Einmalig nach controller:navigation:ready:
| [controller] Navigation ready – 5 items registered |
Knappes Summary das im normalen Startup-Log erscheint und bestätigt dass die Navigation-Pipeline durchgelaufen ist.
| Verantwortung | Wo |
|---|---|
| Welche Routes gibt es? | @xpulse/router |
| Welche Routes sind Nav-relevant? | @xpulse/controller – Opt-in via navItem() |
| Filtern / Erweitern / Sortieren | App-Code – via controller:navigation:collected |
_nav ins Template liefern |
@xpulse/controller – via template:render:before |
| Nav rendern und stylen | Template + @xpulse/theme |
@xpulse/theme entscheidet nie was in der Navigation steht –
es rendert nur was _nav liefert.
Vergleich J•Frame
| J•Frame | xPulse |
|---|---|
@NavigationItem Annotation (PHP Reflection) |
this.navItem() in define() |
NavigationManager.getItemsFromControllerAnnotations() |
Controller-Discovery sammelt navItems |
addNavigationItemToNavigation() nach Build |
controller:navigation:collected Event |
Navigation Objekt |
NavItem[] Array (flach, dann hierarchisch) |
| Im Theming-Package (historisch) | In @xpulse/controller (logisch korrekt) |
Das Ergebnis ist identisch – der Weg ist event-driven statt reflection-based.
Gefeuerte Events (Übersicht)
| Event | Payload | Wann |
|---|---|---|
controller:navigation:collected |
{ items: NavItem[] } |
nach Discovery, vor Finalisierung – mutable |
controller:navigation:ready |
{ items: NavItem[] } |
nach Finalisierung – read-only |
Abhängigkeit zu bestehenden Controller-Events
Navigation-Events feuern nach controller:discovered:
| controller:discovered ← alle Controller gefunden, Routes registriert |
| controller:navigation:collected ← navItems gesammelt, Pipeline offen |
| controller:navigation:ready ← Navigation finalisiert |