Layout-managed breadcrumb system for SPA actions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
root
2025-12-11 00:42:20 +00:00
parent 3c25b2ff80
commit dd6c17f923
2 changed files with 343 additions and 6 deletions

View File

@@ -993,7 +993,13 @@ class Spa {
const action_name = action_class.name;
// Merge URL args with extra_args (extra_args take precedence)
const args = { ...route_match.args, ...extra_args };
// Include skip_render_and_ready to prevent side effects from on_ready()
// (e.g., Spa.dispatch() which would cause redirect loops)
const args = {
...route_match.args,
...extra_args,
skip_render_and_ready: true
};
console_debug('Spa', `load_detached_action: Loading ${action_name} with args:`, args);
@@ -1001,11 +1007,7 @@ class Spa {
const $detached = $('<div>');
// Instantiate the action on the detached element
// Skip render and on_ready phases - detached actions are for data extraction only
// (e.g., getting title/breadcrumbs). Running on_ready() could trigger side effects
// like Spa.dispatch() which would cause redirect loops.
const options = { skip_render_and_ready: true };
$detached.component(action_name, args, options);
$detached.component(action_name, args);
const action = $detached.component();
// Wait for on_load to complete (data fetching)

335
app/RSpade/man/breadcrumbs.txt Executable file
View File

@@ -0,0 +1,335 @@
BREADCRUMBS(3) RSX Framework Manual BREADCRUMBS(3)
NAME
breadcrumbs - Layout-managed breadcrumb navigation system for SPA actions
SYNOPSIS
Action Methods:
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 Details'; }
async breadcrumb_parent() {
return Rsx.Route('Contacts_Index_Action');
}
}
Template Component:
<Breadcrumb_Nav $crumbs="<%= JSON.stringify(chain) %>" />
DESCRIPTION
The RSX breadcrumb system provides hierarchical navigation through action
methods that define breadcrumb metadata. Unlike traditional breadcrumb
systems where each page hardcodes its full breadcrumb path, RSX uses
parent URL traversal to automatically build the breadcrumb chain.
Key characteristics:
- Actions define breadcrumb behavior through async methods
- Layout walks up the parent chain using Spa.load_detached_action()
- Each parent contributes its breadcrumb_label() to the chain
- Active page uses breadcrumb_label_active() for descriptive text
- Document title set from page_title() method
This approach provides:
- Dynamic breadcrumb content based on loaded data
- Automatic chain building without hardcoding paths
- Consistent breadcrumb behavior across all SPA actions
- Descriptive active breadcrumb text for root pages
ACTION METHODS
page_title()
Returns the page title for document.title. Called once when action
renders. Used for browser tab title.
async page_title() {
return 'Contacts'; // Root page
}
async page_title() {
return this.data.contact.name; // Detail page
}
async page_title() {
return this.data.is_edit
? `Edit: ${this.data.contact.name}`
: 'New Contact'; // Add/Edit page
}
breadcrumb_label()
Returns the label shown when this action appears as a parent in
another action's breadcrumb chain. Typically returns the entity
name or short identifier.
async breadcrumb_label() {
return this.data.contact.name;
}
async breadcrumb_label() {
return 'Projects';
}
Not needed for pages that are never parents (leaf nodes like edit
forms). Default returns page_title() if not defined.
breadcrumb_label_active()
Returns the label shown for the current active page. This can be
more descriptive than breadcrumb_label() since it's not constrained
by breadcrumb width.
// Root pages - descriptive text
async breadcrumb_label_active() {
return 'Manage your contact database';
}
// Detail pages - entity identifier
async breadcrumb_label_active() {
return `View ${this.data.contact.name}`;
}
// Edit pages - action context
async breadcrumb_label_active() {
return this.data.is_edit ? 'Edit Contact' : 'New Contact';
}
breadcrumb_parent()
Returns the URL of the parent action for building the breadcrumb
chain. The layout walks up this chain calling breadcrumb_label()
on each parent until reaching null.
// Root pages - no parent
async breadcrumb_parent() {
return null;
}
// View page - parent is list
async breadcrumb_parent() {
return Rsx.Route('Contacts_Index_Action');
}
// Edit page - parent depends on context
async breadcrumb_parent() {
if (this.data.is_edit) {
return Rsx.Route('Contacts_View_Action', {
id: this.args.id
});
}
return Rsx.Route('Contacts_Index_Action');
}
LAYOUT INTEGRATION
The layout is responsible for building the breadcrumb chain and rendering
it. This happens in the layout's on_action() method.
Building the Chain:
async _update_header_from_action() {
const chain = [];
// Start with current action's active label
chain.push({
label: await this.action.breadcrumb_label_active(),
url: null // Active item has no link
});
// Walk up parent chain
let parent_url = await this.action.breadcrumb_parent();
while (parent_url) {
const parent = await Spa.load_detached_action(parent_url, {
use_cached_data: true
});
if (!parent) break;
chain.unshift({
label: await parent.breadcrumb_label(),
url: parent_url
});
parent_url = await parent.breadcrumb_parent();
parent.stop(); // Clean up detached action
}
this._set_header({
title: await this.action.page_title(),
breadcrumbs: chain
});
}
Calling from on_action():
async on_action(url, action_name, args) {
await this._update_header_from_action();
}
BREADCRUMB_NAV COMPONENT
Template:
<Define:Breadcrumb_Nav tag="nav" aria-label="breadcrumb">
<ol class="breadcrumb mb-0">
<% for (let i = 0; i < this.args.crumbs.length; i++) {
const crumb = this.args.crumbs[i];
const is_last = i === this.args.crumbs.length - 1;
%>
<% if (is_last || !crumb.url) { %>
<li class="breadcrumb-item active" aria-current="page">
<%= crumb.label %>
</li>
<% } else { %>
<li class="breadcrumb-item">
<a href="<%= crumb.url %>"><%= crumb.label %></a>
</li>
<% } %>
<% } %>
</ol>
</Define:Breadcrumb_Nav>
Uses Bootstrap 5 breadcrumb classes for consistent styling.
PATTERNS BY PAGE TYPE
Index/Root Pages:
Actions at the top of a hierarchy with no parent.
class Contacts_Index_Action extends Spa_Action {
async page_title() { return 'Contacts'; }
async breadcrumb_label() { return 'Contacts'; }
async breadcrumb_label_active() {
return 'Manage your contact database';
}
async breadcrumb_parent() { return null; }
}
Result: Contacts / Manage your contact database
(single breadcrumb with descriptive text)
View/Detail Pages:
Actions showing a single record with a parent list page.
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');
}
}
Result: Contacts > John Smith / View Contact
Edit Pages:
Actions for editing existing or creating new records.
class Contacts_Edit_Action extends Spa_Action {
async page_title() {
if (this.data.is_edit) {
return `Edit: ${this.data.contact.name}`;
}
return 'New Contact';
}
async breadcrumb_label_active() {
return this.data.is_edit ? 'Edit Contact' : 'New Contact';
}
async breadcrumb_parent() {
if (this.data.is_edit) {
return Rsx.Route('Contacts_View_Action', {
id: this.args.id
});
}
return Rsx.Route('Contacts_Index_Action');
}
}
Edit result: Contacts > John Smith > View Contact / Edit Contact
Add result: Contacts / New Contact
Nested Pages (Sublayouts):
Actions within sublayouts like settings sections.
class Settings_Profile_Display_Action extends Spa_Action {
async page_title() { return 'Display Preferences'; }
async breadcrumb_label() { return 'Display Preferences'; }
async breadcrumb_label_active() {
return 'Configure display settings';
}
async breadcrumb_parent() {
return Rsx.Route('Dashboard_Index_Action');
}
}
Result: Dashboard > Display Preferences / Configure display settings
DOCUMENT TITLE
The layout sets document.title using page_title():
document.title = 'RSX - ' + await this.action.page_title();
Format: "AppName - PageTitle"
Examples:
- RSX - Contacts
- RSX - John Smith
- RSX - Edit: John Smith
PERFORMANCE CONSIDERATIONS
Parent Chain Loading:
Spa.load_detached_action() with use_cached_data: true uses cached
data from previous visits when available, minimizing network requests.
When visiting Contacts > John Smith > Edit Contact for the first time:
1. Edit page loads with fresh data (required)
2. View page loads detached with use_cached_data (may hit cache)
3. Index page loads detached with use_cached_data (may hit cache)
Subsequent breadcrumb renders reuse cached data.
Stopping Detached Actions:
Always call parent.stop() after extracting breadcrumb data to clean
up event listeners and prevent memory leaks.
DEFAULT METHODS
If an action doesn't define breadcrumb methods, defaults apply:
page_title() Returns class name (e.g., "Contacts_View_Action")
breadcrumb_label() Returns page_title()
breadcrumb_label_active() Returns page_title()
breadcrumb_parent() Returns null (no parent)
Override only the methods you need. Most actions need at minimum:
- page_title()
- breadcrumb_label_active() (for descriptive root page text)
- breadcrumb_parent() (for non-root pages)
MIGRATION FROM HARDCODED BREADCRUMBS
If converting from inline breadcrumb components:
Old Pattern (template-based):
<Page_Header>
<Page_Header_Left>
<Page_Title>Edit Contact</Page_Title>
<Breadcrumb>
<Breadcrumb_Item $href="...">Dashboard</Breadcrumb_Item>
<Breadcrumb_Item $href="...">Contacts</Breadcrumb_Item>
<Breadcrumb_Item $active=true>Edit</Breadcrumb_Item>
</Breadcrumb>
</Page_Header_Left>
</Page_Header>
New Pattern (action methods):
// Remove from template, keep only:
<Page_Header>
<Page_Header_Right>
<!-- action buttons -->
</Page_Header_Right>
</Page_Header>
// Add to action class:
async page_title() { return 'Edit Contact'; }
async breadcrumb_label_active() { return 'Edit Contact'; }
async breadcrumb_parent() {
return Rsx.Route('Contacts_View_Action', { id: this.args.id });
}
SEE ALSO
spa(3) - SPA routing and layouts
spa(3) DETACHED ACTION LOADING - Spa.load_detached_action() details
spa(3) SUBLAYOUTS - Nested layout handling