Progressive breadcrumb resolution with caching, fix double headers 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
467 lines
17 KiB
Plaintext
Executable File
467 lines
17 KiB
Plaintext
Executable File
PRIMARY_SECONDARY_NAV_BREADCRUMBS(3) RSX Framework Manual PRIMARY_SECONDARY_NAV_BREADCRUMBS(3)
|
|
|
|
NAME
|
|
Primary, Secondary Navigation and Breadcrumbs - SPA navigation systems
|
|
|
|
SYNOPSIS
|
|
// Primary navigation (sidebar) in layout
|
|
class Frontend_Spa_Layout extends Spa_Layout {
|
|
on_create() {
|
|
this.state = {
|
|
nav_sections: [
|
|
{ title: 'Overview', items: [
|
|
{ label: 'Dashboard', icon: 'bi-house', href: Rsx.Route('...') }
|
|
]}
|
|
]
|
|
};
|
|
}
|
|
}
|
|
|
|
// Secondary navigation (sublayout sidebar)
|
|
class Settings_Layout extends Spa_Layout {
|
|
static ACTION_CONFIG = {
|
|
'Settings_General_Action': { icon: 'bi-gear', label: 'General' },
|
|
'Settings_Profile_Action': { icon: 'bi-person', label: 'Profile' }
|
|
};
|
|
}
|
|
|
|
// Breadcrumb methods in actions
|
|
class Contacts_View_Action extends Spa_Action {
|
|
async page_title() { return this.data.contact.name; }
|
|
async breadcrumb_label() { return this.data.contact.name; }
|
|
async breadcrumb_label_active() { return 'View Contact'; }
|
|
async breadcrumb_parent() { return Rsx.Route('Contacts_Index_Action'); }
|
|
}
|
|
|
|
DESCRIPTION
|
|
RSX provides a comprehensive navigation system for SPAs consisting of:
|
|
|
|
1. Primary Navigation - Main sidebar with sections and links
|
|
2. Secondary Navigation - Sublayout sidebars for feature areas
|
|
3. Breadcrumbs - Progressive URL-based navigation trail
|
|
|
|
The system uses session storage caching for instant navigation feedback
|
|
while resolving live data in the background.
|
|
|
|
PRIMARY NAVIGATION
|
|
The primary navigation is typically a sidebar defined in the main SPA
|
|
layout (e.g., Frontend_Spa_Layout). It uses the Sidebar_Nav component.
|
|
|
|
Layout Structure:
|
|
// Frontend_Spa_Layout.js
|
|
class Frontend_Spa_Layout extends Spa_Layout {
|
|
on_create() {
|
|
this.state = {
|
|
nav_sections: [
|
|
{
|
|
title: 'Overview',
|
|
items: [
|
|
{
|
|
label: 'Dashboard',
|
|
icon: 'bi-house-door',
|
|
href: Rsx.Route('Dashboard_Index_Action'),
|
|
},
|
|
{
|
|
label: 'Calendar',
|
|
icon: 'bi-calendar',
|
|
href: Rsx.Route('Calendar_Index_Action'),
|
|
},
|
|
],
|
|
},
|
|
{
|
|
title: 'Business',
|
|
items: [
|
|
{ label: 'Clients', icon: 'bi-people',
|
|
href: Rsx.Route('Clients_Index_Action') },
|
|
{ label: 'Contacts', icon: 'bi-person-rolodex',
|
|
href: Rsx.Route('Contacts_Index_Action') },
|
|
],
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
on_action(url, action_name, args) {
|
|
// Update active state on navigation
|
|
this.sid('sidebar_nav').on_action(url, action_name, args);
|
|
}
|
|
}
|
|
|
|
Template Structure:
|
|
<Define:Frontend_Spa_Layout>
|
|
<nav class="app-sidebar">
|
|
<Sidebar_Nav $sid="sidebar_nav"
|
|
$sections=this.state.nav_sections />
|
|
</nav>
|
|
<main $sid="content"></main>
|
|
</Define:Frontend_Spa_Layout>
|
|
|
|
Sidebar_Nav Component:
|
|
Renders navigation sections with automatic active state based on
|
|
current URL. Supports icons (Bootstrap Icons), labels, and href.
|
|
|
|
on_action(url, action_name, args):
|
|
Called by layout to update active link highlighting.
|
|
Matches current URL to nav item hrefs.
|
|
|
|
SECONDARY NAVIGATION (SUBLAYOUTS)
|
|
Secondary navigation is used for feature areas with multiple related
|
|
pages (e.g., Settings). Implemented as sublayouts with their own sidebar.
|
|
|
|
Sublayout Pattern:
|
|
// Settings_Layout.js
|
|
class Settings_Layout extends Spa_Layout {
|
|
// Configure nav items per action
|
|
static ACTION_CONFIG = {
|
|
'Settings_General_Action': {
|
|
icon: 'bi-gear',
|
|
label: 'General Settings'
|
|
},
|
|
'Settings_Profile_Action': {
|
|
icon: 'bi-person',
|
|
label: 'Profile'
|
|
},
|
|
'Settings_User_Management_Index_Action': {
|
|
icon: 'bi-people',
|
|
label: 'User Management'
|
|
},
|
|
};
|
|
|
|
on_create() {
|
|
this._build_nav_items();
|
|
}
|
|
|
|
_build_nav_items() {
|
|
this.state.nav_items = [];
|
|
for (const [action_name, config] of
|
|
Object.entries(Settings_Layout.ACTION_CONFIG)) {
|
|
this.state.nav_items.push({
|
|
action: action_name,
|
|
icon: config.icon,
|
|
label: config.label,
|
|
href: Rsx.Route(action_name),
|
|
});
|
|
}
|
|
}
|
|
|
|
on_action(url, action_name, args) {
|
|
this._update_active_nav(action_name);
|
|
}
|
|
|
|
_update_active_nav(action_name) {
|
|
this.$sid('nav').find('.active').removeClass('active');
|
|
this.$sid('nav')
|
|
.find(`[data-action="${action_name}"]`)
|
|
.addClass('active');
|
|
}
|
|
}
|
|
|
|
Template Structure:
|
|
<Define:Settings_Layout>
|
|
<div class="settings-layout">
|
|
<aside class="settings-sidebar">
|
|
<nav $sid="nav">
|
|
<% for (let item of this.state.nav_items) { %>
|
|
<a href="<%= item.href %>"
|
|
data-action="<%= item.action %>"
|
|
class="settings-sidebar__item">
|
|
<i class="bi <%= item.icon %>"></i>
|
|
<span><%= item.label %></span>
|
|
</a>
|
|
<% } %>
|
|
</nav>
|
|
</aside>
|
|
<div class="settings-content" $sid="content"></div>
|
|
</div>
|
|
</Define:Settings_Layout>
|
|
|
|
Action Declaration:
|
|
Actions declare both outer and inner layouts:
|
|
|
|
@route('/frontend/settings/general')
|
|
@layout('Frontend_Spa_Layout') // Outer layout
|
|
@layout('Settings_Layout') // Sublayout (nested)
|
|
@spa('Frontend_Spa_Controller::index')
|
|
class Settings_General_Action extends Spa_Action { }
|
|
|
|
BREADCRUMB SYSTEM
|
|
Breadcrumbs provide hierarchical navigation context. The system uses
|
|
progressive resolution with caching for instant display.
|
|
|
|
Action Breadcrumb Methods:
|
|
All methods are async and can await the 'load' event if needed.
|
|
|
|
page_title()
|
|
Returns the page title for the header and document.title.
|
|
Example: "Edit: John Smith"
|
|
|
|
breadcrumb_label()
|
|
Returns the label when this page appears as a PARENT in
|
|
another page's breadcrumb trail.
|
|
Example: "John Smith" (when viewing edit page)
|
|
|
|
breadcrumb_label_active()
|
|
Returns the label when this page is the CURRENT page
|
|
(rightmost breadcrumb, no link).
|
|
Example: "Edit Contact"
|
|
|
|
breadcrumb_parent()
|
|
Returns the URL of the parent page, or null for root.
|
|
Example: Rsx.Route('Contacts_View_Action', { id: this.args.id })
|
|
|
|
Example Implementation:
|
|
class Contacts_View_Action extends Spa_Action {
|
|
on_create() {
|
|
this.data.contact = { first_name: '', last_name: '' };
|
|
}
|
|
|
|
async on_load() {
|
|
this.data.contact = await Contact_Model.fetch(this.args.id);
|
|
}
|
|
|
|
// Helper to await loaded data
|
|
async _await_loaded() {
|
|
if (this.data.contact && this.data.contact.id) return;
|
|
await new Promise(resolve => this.on('load', resolve));
|
|
}
|
|
|
|
async page_title() {
|
|
await this._await_loaded();
|
|
const c = this.data.contact;
|
|
return `${c.first_name} ${c.last_name}`.trim() || 'Contact';
|
|
}
|
|
|
|
async breadcrumb_label() {
|
|
await this._await_loaded();
|
|
const c = this.data.contact;
|
|
return `${c.first_name} ${c.last_name}`.trim() || 'Contact';
|
|
}
|
|
|
|
async breadcrumb_label_active() {
|
|
return 'View Contact'; // Static, no await needed
|
|
}
|
|
|
|
async breadcrumb_parent() {
|
|
return Rsx.Route('Contacts_Index_Action'); // Static route
|
|
}
|
|
}
|
|
|
|
Awaiting Loaded Data:
|
|
Breadcrumb methods are called BEFORE on_load() completes. If a method
|
|
needs loaded data (e.g., contact name), it must await the 'load' event:
|
|
|
|
async _await_loaded() {
|
|
// Check if data is already loaded
|
|
if (this.data.contact && this.data.contact.id) return;
|
|
// Otherwise wait for 'load' event
|
|
await new Promise(resolve => this.on('load', resolve));
|
|
}
|
|
|
|
The 'load' event fires immediately if already past that lifecycle
|
|
phase, so this pattern is safe to call multiple times.
|
|
|
|
RSX_BREADCRUMB_RESOLVER
|
|
Framework class that handles breadcrumb resolution with caching.
|
|
|
|
Location: /system/app/RSpade/Breadcrumbs/Rsx_Breadcrumb_Resolver.js
|
|
|
|
API:
|
|
Rsx_Breadcrumb_Resolver.stream(action, url, callbacks)
|
|
Streams breadcrumb data progressively.
|
|
|
|
Parameters:
|
|
action - Current Spa_Action instance
|
|
url - Current URL (pathname + search)
|
|
callbacks - Object with callback functions:
|
|
on_chain(chain) - Chain structure ready
|
|
on_label_update(idx, lbl) - Individual label resolved
|
|
on_complete(chain) - All labels resolved
|
|
|
|
Returns: Cancel function
|
|
|
|
Rsx_Breadcrumb_Resolver.clear_cache(url)
|
|
Clears cached breadcrumb chain for a URL.
|
|
|
|
Resolution Flow:
|
|
1. Check session cache for URL's breadcrumb chain
|
|
2. If cached, display immediately
|
|
3. Walk breadcrumb_parent() chain to get structure (URLs)
|
|
4. Compare to cache:
|
|
- Different length: render skeleton with blank labels
|
|
- Same length: render with cached labels
|
|
5. Resolve each label via breadcrumb_label()/breadcrumb_label_active()
|
|
6. Update display as each label resolves
|
|
7. Cache final result
|
|
|
|
Caching:
|
|
Uses Rsx_Storage.session_set/get() with key prefix 'breadcrumb_chain:'
|
|
Cached data: Array of { url, label, is_active }
|
|
|
|
LAYOUT INTEGRATION
|
|
The layout handles header rendering and breadcrumb streaming.
|
|
|
|
Frontend_Spa_Layout Integration:
|
|
class Frontend_Spa_Layout extends Spa_Layout {
|
|
static TITLE_CACHE_PREFIX = 'header_text:';
|
|
|
|
on_action(url, action_name, args) {
|
|
// Update page title with caching
|
|
this._update_page_title(url);
|
|
|
|
// Cancel previous breadcrumb stream
|
|
if (this._breadcrumb_cancel) {
|
|
this._breadcrumb_cancel();
|
|
}
|
|
|
|
// Start new breadcrumb stream
|
|
this._breadcrumb_cancel = Rsx_Breadcrumb_Resolver.stream(
|
|
this.action,
|
|
url,
|
|
{
|
|
on_chain: (chain) => this._on_breadcrumb_chain(chain),
|
|
on_label_update: (i, l) => this._on_breadcrumb_label_update(i, l),
|
|
on_complete: (chain) => this._on_breadcrumb_complete(chain)
|
|
}
|
|
);
|
|
}
|
|
|
|
async _update_page_title(url) {
|
|
const $title = this.$sid('page_title');
|
|
const cache_key = Frontend_Spa_Layout.TITLE_CACHE_PREFIX + url;
|
|
|
|
// Clear, show cached, then resolve live
|
|
$title.html(' ');
|
|
const cached = Rsx_Storage.session_get(cache_key);
|
|
if (cached) {
|
|
$title.html(cached);
|
|
document.title = 'RSX - ' + cached;
|
|
}
|
|
|
|
const live = await this.action.page_title();
|
|
if (live) {
|
|
$title.html(live);
|
|
document.title = 'RSX - ' + live;
|
|
Rsx_Storage.session_set(cache_key, live);
|
|
}
|
|
}
|
|
}
|
|
|
|
BREADCRUMB_NAV COMPONENT
|
|
Renders the breadcrumb trail from a chain array.
|
|
|
|
Location: /rsx/theme/components/page/breadcrumb_nav.jqhtml
|
|
|
|
Props:
|
|
$crumbs - Array of { label, url, is_active, resolved }
|
|
label: string or null (shows placeholder if null)
|
|
url: string or null (no link if null or is_active)
|
|
is_active: boolean (current page, no link)
|
|
resolved: boolean (false shows loading state)
|
|
|
|
Usage:
|
|
$breadcrumbs.component('Breadcrumb_Nav', {
|
|
crumbs: [
|
|
{ label: 'Contacts', url: '/contacts', is_active: false },
|
|
{ label: 'John Smith', url: '/contacts/view/1', is_active: false },
|
|
{ label: 'Edit Contact', url: null, is_active: true }
|
|
]
|
|
});
|
|
|
|
PAGE HEADER STRUCTURE
|
|
Standard page header with title, breadcrumbs, and action buttons.
|
|
|
|
Template Pattern:
|
|
<header class="page-title-header">
|
|
<div class="d-flex justify-content-between align-items-start">
|
|
<div>
|
|
<h1 $sid="page_title"></h1>
|
|
<nav $sid="page_breadcrumbs"></nav>
|
|
</div>
|
|
<div $sid="page_actions"></div>
|
|
</div>
|
|
</header>
|
|
|
|
Action Buttons:
|
|
Actions define page_actions() to provide header buttons:
|
|
|
|
class Contacts_View_Action extends Spa_Action {
|
|
page_actions() {
|
|
return `
|
|
<div class="d-flex gap-2">
|
|
<a href="${Rsx.Route('Contacts_Index_Action')}"
|
|
class="btn btn-secondary btn-sm">
|
|
<i class="bi bi-arrow-left"></i> Back
|
|
</a>
|
|
<a href="${Rsx.Route('Contacts_Edit_Action', this.args.id)}"
|
|
class="btn btn-primary btn-sm">
|
|
<i class="bi bi-pencil"></i> Edit
|
|
</a>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
The layout renders these via _render_page_actions().
|
|
|
|
BREADCRUMB CHAIN EXAMPLE
|
|
For URL /contacts/edit/1, the breadcrumb chain resolves as:
|
|
|
|
Chain Structure (URLs):
|
|
1. /contacts (Contacts_Index_Action)
|
|
2. /contacts/view/1 (Contacts_View_Action)
|
|
3. /contacts/edit/1 (Contacts_Edit_Action) - ACTIVE
|
|
|
|
Resolution Process:
|
|
1. Contacts_Edit_Action.breadcrumb_parent() -> /contacts/view/1
|
|
2. Load detached Contacts_View_Action
|
|
3. Contacts_View_Action.breadcrumb_parent() -> /contacts
|
|
4. Load detached Contacts_Index_Action
|
|
5. Contacts_Index_Action.breadcrumb_parent() -> null (root)
|
|
|
|
Label Resolution (parallel):
|
|
- Index: breadcrumb_label() -> "Contacts"
|
|
- View: breadcrumb_label() -> "John Smith" (awaits load)
|
|
- Edit: breadcrumb_label_active() -> "Edit Contact"
|
|
|
|
Final Display:
|
|
Contacts > John Smith > Edit Contact
|
|
|
|
CACHING STRATEGY
|
|
Two caches are used for instant navigation feedback:
|
|
|
|
Page Title Cache:
|
|
Key: 'header_text:/contacts/edit/1'
|
|
Value: "Edit: John Smith"
|
|
|
|
Breadcrumb Chain Cache:
|
|
Key: 'breadcrumb_chain:/contacts/edit/1'
|
|
Value: [
|
|
{ url: '/contacts', label: 'Contacts', is_active: false },
|
|
{ url: '/contacts/view/1', label: 'John Smith', is_active: false },
|
|
{ url: '/contacts/edit/1', label: 'Edit Contact', is_active: true }
|
|
]
|
|
|
|
Cache Behavior:
|
|
- Cached values display immediately on navigation
|
|
- Live values resolve in background
|
|
- Cache updates when live values complete
|
|
- Previous breadcrumbs stay visible until new chain ready
|
|
|
|
FILES
|
|
/system/app/RSpade/Breadcrumbs/Rsx_Breadcrumb_Resolver.js
|
|
Breadcrumb resolution with progressive streaming and caching
|
|
|
|
/rsx/app/frontend/Frontend_Spa_Layout.js
|
|
Main layout with navigation and header integration
|
|
|
|
/rsx/theme/components/page/breadcrumb_nav.jqhtml
|
|
Breadcrumb rendering component
|
|
|
|
/rsx/theme/components/nav/sidebar_nav.jqhtml
|
|
Primary sidebar navigation component
|
|
|
|
SEE ALSO
|
|
spa(3), jqhtml(3), storage(3)
|
|
|
|
RSX Framework 2025-12-16 PRIMARY_SECONDARY_NAV_BREADCRUMBS(3)
|