@zolomedia/bifrost-client 1.7.74
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/L1_Foundation/L1_Foundation.js +13 -0
- package/L1_Foundation/bootstrap/bootstrap.js +11 -0
- package/L1_Foundation/bootstrap/bootstrap_hooks.js +123 -0
- package/L1_Foundation/bootstrap/bootstrap_index.js +15 -0
- package/L1_Foundation/bootstrap/bootstrap_logger.js +135 -0
- package/L1_Foundation/bootstrap/cdn_loader.js +217 -0
- package/L1_Foundation/bootstrap/module_registry.js +102 -0
- package/L1_Foundation/bootstrap/prism_loader.js +164 -0
- package/L1_Foundation/config/client_config.js +110 -0
- package/L1_Foundation/config/config.js +7 -0
- package/L1_Foundation/connection/connection.js +8 -0
- package/L1_Foundation/connection/websocket_connection.js +122 -0
- package/L1_Foundation/constants/bifrost_constants.js +284 -0
- package/L1_Foundation/constants/constants.js +7 -0
- package/L1_Foundation/logger/logger.js +10 -0
- package/L2_Handling/L2_Handling.js +15 -0
- package/L2_Handling/cache/cache.js +22 -0
- package/L2_Handling/cache/cache_constants.js +69 -0
- package/L2_Handling/cache/orchestration/cache_manager.js +299 -0
- package/L2_Handling/cache/orchestration/cache_orchestrator.js +260 -0
- package/L2_Handling/cache/orchestration/orchestration.js +12 -0
- package/L2_Handling/cache/storage/session_manager.js +289 -0
- package/L2_Handling/cache/storage/storage.js +10 -0
- package/L2_Handling/cache/storage/storage_manager.js +590 -0
- package/L2_Handling/display/composite/composite.js +13 -0
- package/L2_Handling/display/composite/dashboard_renderer.js +221 -0
- package/L2_Handling/display/composite/swiper_renderer.js +564 -0
- package/L2_Handling/display/composite/terminal_renderer.js +922 -0
- package/L2_Handling/display/composite/wizard_conditional_renderer.js +274 -0
- package/L2_Handling/display/display.js +30 -0
- package/L2_Handling/display/feedback/feedback.js +11 -0
- package/L2_Handling/display/feedback/progressbar_renderer.js +418 -0
- package/L2_Handling/display/feedback/spinner_renderer.js +246 -0
- package/L2_Handling/display/inputs/button_renderer.js +634 -0
- package/L2_Handling/display/inputs/form_renderer.js +583 -0
- package/L2_Handling/display/inputs/input_renderer.js +658 -0
- package/L2_Handling/display/inputs/inputs.js +12 -0
- package/L2_Handling/display/navigation/menu_renderer.js +206 -0
- package/L2_Handling/display/navigation/navigation.js +11 -0
- package/L2_Handling/display/navigation/navigation_renderer.js +703 -0
- package/L2_Handling/display/orchestration/orchestration.js +11 -0
- package/L2_Handling/display/orchestration/renderer.js +430 -0
- package/L2_Handling/display/orchestration/zdisplay_orchestrator.js +1759 -0
- package/L2_Handling/display/outputs/alert_renderer.js +161 -0
- package/L2_Handling/display/outputs/audio_renderer.js +94 -0
- package/L2_Handling/display/outputs/card_renderer.js +229 -0
- package/L2_Handling/display/outputs/code_renderer.js +66 -0
- package/L2_Handling/display/outputs/dl_renderer.js +131 -0
- package/L2_Handling/display/outputs/header_renderer.js +162 -0
- package/L2_Handling/display/outputs/icon_renderer.js +107 -0
- package/L2_Handling/display/outputs/image_renderer.js +145 -0
- package/L2_Handling/display/outputs/list_renderer.js +190 -0
- package/L2_Handling/display/outputs/outputs.js +19 -0
- package/L2_Handling/display/outputs/table_renderer.js +765 -0
- package/L2_Handling/display/outputs/text_renderer.js +818 -0
- package/L2_Handling/display/outputs/typography_renderer.js +293 -0
- package/L2_Handling/display/outputs/video_renderer.js +116 -0
- package/L2_Handling/display/primitives/document_structure_primitives.js +319 -0
- package/L2_Handling/display/primitives/form_primitives.js +526 -0
- package/L2_Handling/display/primitives/generic_containers.js +109 -0
- package/L2_Handling/display/primitives/interactive_primitives.js +305 -0
- package/L2_Handling/display/primitives/link_primitives.js +552 -0
- package/L2_Handling/display/primitives/lists_primitives.js +262 -0
- package/L2_Handling/display/primitives/media_primitives.js +383 -0
- package/L2_Handling/display/primitives/primitives.js +19 -0
- package/L2_Handling/display/primitives/semantic_element_primitive.js +226 -0
- package/L2_Handling/display/primitives/table_primitives.js +528 -0
- package/L2_Handling/display/primitives/typography_primitives.js +175 -0
- package/L2_Handling/display/specialized/input_request_renderer.js +467 -0
- package/L2_Handling/display/specialized/specialized.js +10 -0
- package/L2_Handling/hooks/hooks.js +9 -0
- package/L2_Handling/hooks/menu_integration.js +57 -0
- package/L2_Handling/hooks/widget_hook_manager.js +292 -0
- package/L2_Handling/message/message.js +8 -0
- package/L2_Handling/message/message_handler.js +701 -0
- package/L2_Handling/navigation/navigation.js +8 -0
- package/L2_Handling/navigation/navigation_manager.js +403 -0
- package/L2_Handling/zhooks/features/cache_live.js +287 -0
- package/L2_Handling/zhooks/features/crumbs_live.js +292 -0
- package/L2_Handling/zhooks/zhooks_manager.js +65 -0
- package/L2_Handling/zvaf/zvaf.js +8 -0
- package/L2_Handling/zvaf/zvaf_manager.js +334 -0
- package/L3_Abstraction/L3_Abstraction.js +12 -0
- package/L3_Abstraction/orchestrator/container_unwrapper.js +101 -0
- package/L3_Abstraction/orchestrator/group_renderer.js +698 -0
- package/L3_Abstraction/orchestrator/input_event_handler.js +797 -0
- package/L3_Abstraction/orchestrator/metadata_processor.js +249 -0
- package/L3_Abstraction/orchestrator/navbar_builder.js +201 -0
- package/L3_Abstraction/orchestrator/orchestrator.js +13 -0
- package/L3_Abstraction/orchestrator/wizard_gate_handler.js +360 -0
- package/L3_Abstraction/renderer/renderer.js +1 -0
- package/L3_Abstraction/session/session.js +1 -0
- package/L4_Orchestration/L4_Orchestration.js +11 -0
- package/L4_Orchestration/client/client.js +1 -0
- package/L4_Orchestration/facade/facade.js +9 -0
- package/L4_Orchestration/facade/manager_registry.js +118 -0
- package/L4_Orchestration/facade/renderer_registry.js +274 -0
- package/L4_Orchestration/lifecycle/asset_loader.js +255 -0
- package/L4_Orchestration/lifecycle/initializer.js +135 -0
- package/L4_Orchestration/lifecycle/lifecycle.js +8 -0
- package/L4_Orchestration/rendering/facade.js +94 -0
- package/L4_Orchestration/rendering/rendering.js +7 -0
- package/LICENSE +21 -0
- package/README.md +82 -0
- package/bifrost_client.js +204 -0
- package/bifrost_core.js +1686 -0
- package/docs/ARCHITECTURE.md +111 -0
- package/docs/PROTOCOL.md +106 -0
- package/docs/RENDERERS.md +101 -0
- package/docs/SECURITY.md +92 -0
- package/package.json +24 -0
- package/syntax/prism-zconfig.js +41 -0
- package/syntax/prism-zenv.js +69 -0
- package/syntax/prism-zolo-theme.css +288 -0
- package/syntax/prism-zolo.js +380 -0
- package/syntax/prism-zschema.js +38 -0
- package/syntax/prism-zspark.js +25 -0
- package/syntax/prism-zui.js +68 -0
- package/zSys/accessibility/accessibility.js +10 -0
- package/zSys/accessibility/emoji_accessibility.js +173 -0
- package/zSys/dom/block_utils.js +122 -0
- package/zSys/dom/container_utils.js +370 -0
- package/zSys/dom/dom.js +13 -0
- package/zSys/dom/dom_utils.js +328 -0
- package/zSys/dom/encoding_utils.js +117 -0
- package/zSys/dom/style_utils.js +71 -0
- package/zSys/errors/error_display.js +299 -0
- package/zSys/errors/errors.js +10 -0
- package/zSys/theme/color_utils.js +274 -0
- package/zSys/theme/dark_mode_utils.js +272 -0
- package/zSys/theme/size_utils.js +256 -0
- package/zSys/theme/spacing_utils.js +405 -0
- package/zSys/theme/theme.js +14 -0
- package/zSys/theme/zbase.css +1735 -0
- package/zSys/theme/zbase_inject.js +161 -0
- package/zSys/theme/ztheme_utils.js +305 -0
- package/zSys/validation/error_boundary.js +201 -0
- package/zSys/validation/validation.js +11 -0
- package/zSys/validation/validation_utils.js +238 -0
- package/zSys/zSys.js +14 -0
|
@@ -0,0 +1,703 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*
|
|
3
|
+
* Navigation Renderer - zTheme Navigation Components
|
|
4
|
+
*
|
|
5
|
+
*
|
|
6
|
+
* Renders navigation components aligned with zTheme:
|
|
7
|
+
* - zNav (navigation bars)
|
|
8
|
+
* - zNavbar (top navigation)
|
|
9
|
+
* - zBreadcrumb (breadcrumb trails)
|
|
10
|
+
* - zTabs (tabbed navigation)
|
|
11
|
+
* - zPagination (page navigation)
|
|
12
|
+
* - Sidebar navigation
|
|
13
|
+
* - Dropdown menus
|
|
14
|
+
*
|
|
15
|
+
* REFACTORED: Uses Layer 0 primitives
|
|
16
|
+
*
|
|
17
|
+
* @module rendering/navigation_renderer
|
|
18
|
+
* @layer 3
|
|
19
|
+
* @pattern Strategy (navigation components)
|
|
20
|
+
*
|
|
21
|
+
* @see https://github.com/ZoloAi/zTheme - zTheme Navigation
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// ─────────────────────────────────────────────────────────────────
|
|
25
|
+
// Imports
|
|
26
|
+
// ─────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
// Layer 2: Utilities
|
|
29
|
+
import { withErrorBoundary } from '../../../zSys/validation/error_boundary.js';
|
|
30
|
+
|
|
31
|
+
// Layer 0: Primitives
|
|
32
|
+
import { createNav } from '../primitives/document_structure_primitives.js';
|
|
33
|
+
import { createList, createListItem } from '../primitives/lists_primitives.js';
|
|
34
|
+
import { createLink, createButton } from '../primitives/interactive_primitives.js';
|
|
35
|
+
import { createDiv, createSpan } from '../primitives/generic_containers.js';
|
|
36
|
+
import { renderLink } from '../primitives/link_primitives.js';
|
|
37
|
+
|
|
38
|
+
export class NavigationRenderer {
|
|
39
|
+
constructor(logger = null, client = null) {
|
|
40
|
+
this.logger = logger || console;
|
|
41
|
+
this.client = client; // NEW: Store client for link rendering
|
|
42
|
+
|
|
43
|
+
// Wrap renderNavBar method with error boundary
|
|
44
|
+
// Note: We wrap it after the class is fully initialized
|
|
45
|
+
const proto = Object.getPrototypeOf(this);
|
|
46
|
+
if (proto.renderNavBar) {
|
|
47
|
+
const originalRenderNavBar = proto.renderNavBar.bind(this);
|
|
48
|
+
this.renderNavBar = withErrorBoundary(originalRenderNavBar, {
|
|
49
|
+
component: 'NavigationRenderer.renderNavBar',
|
|
50
|
+
logger: this.logger
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Render a navigation bar from menu items (zTheme zNavbar component)
|
|
57
|
+
* @param {Array<string|Object>} items - Array of navigation items (strings or {label, href})
|
|
58
|
+
* @param {Object} options - Rendering options
|
|
59
|
+
* @returns {HTMLElement} - Navigation element with zNavbar classes
|
|
60
|
+
* @see https://github.com/ZoloAi/zTheme/blob/main/src/css/zNavbar.css
|
|
61
|
+
*/
|
|
62
|
+
renderNavBar(items, options = {}) {
|
|
63
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
64
|
+
this.logger.warn('[NavigationRenderer] No items provided for navbar');
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const {
|
|
69
|
+
className = 'zcli-navbar-meta',
|
|
70
|
+
theme = 'light',
|
|
71
|
+
activeIndex = null,
|
|
72
|
+
href = '#',
|
|
73
|
+
brand = null
|
|
74
|
+
} = options;
|
|
75
|
+
|
|
76
|
+
// Generate unique ID for collapse target
|
|
77
|
+
const collapseId = `navbar-collapse-${Math.random().toString(36).substr(2, 9)}`;
|
|
78
|
+
|
|
79
|
+
// Create nav container with zNavbar component classes (using primitive)
|
|
80
|
+
const nav = createNav({
|
|
81
|
+
class: `zNavbar zNavbar-${theme} ${className}`,
|
|
82
|
+
role: 'navigation'
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Add brand/logo if provided (using primitive)
|
|
86
|
+
if (brand) {
|
|
87
|
+
const brandLink = createLink('/', { class: 'zNavbar-brand' });
|
|
88
|
+
brandLink.textContent = brand;
|
|
89
|
+
nav.appendChild(brandLink);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Create mobile hamburger toggle button (using primitive)
|
|
93
|
+
const toggleButton = createButton('button', {
|
|
94
|
+
class: 'zNavbar-toggler',
|
|
95
|
+
'data-bs-toggle': 'collapse',
|
|
96
|
+
'data-bs-target': `#${collapseId}`,
|
|
97
|
+
'aria-controls': collapseId,
|
|
98
|
+
'aria-expanded': 'false',
|
|
99
|
+
'aria-label': 'Toggle navigation'
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Add Bootstrap Icon (hamburger menu)
|
|
103
|
+
toggleButton.innerHTML = `
|
|
104
|
+
<i class="bi bi-list" style="font-size: 1.5rem;"></i>
|
|
105
|
+
`;
|
|
106
|
+
nav.appendChild(toggleButton);
|
|
107
|
+
|
|
108
|
+
// Create navbar collapse wrapper (using primitive)
|
|
109
|
+
const collapseDiv = createDiv({
|
|
110
|
+
class: 'zNavbar-collapse',
|
|
111
|
+
id: collapseId
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// FIX: Add manual toggle handler (zTheme doesn't include Bootstrap JS)
|
|
115
|
+
toggleButton.addEventListener('click', (e) => {
|
|
116
|
+
e.preventDefault();
|
|
117
|
+
const isExpanded = toggleButton.getAttribute('aria-expanded') === 'true';
|
|
118
|
+
|
|
119
|
+
// Toggle aria state
|
|
120
|
+
toggleButton.setAttribute('aria-expanded', !isExpanded);
|
|
121
|
+
|
|
122
|
+
// Toggle visibility (try both 'show' and 'zShow' for compatibility)
|
|
123
|
+
if (isExpanded) {
|
|
124
|
+
collapseDiv.classList.remove('show', 'zShow');
|
|
125
|
+
this.logger.debug('[NavigationRenderer] Navbar collapsed');
|
|
126
|
+
} else {
|
|
127
|
+
collapseDiv.classList.add('show', 'zShow');
|
|
128
|
+
this.logger.debug('[NavigationRenderer] Navbar expanded');
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
this.logger.debug('[NavigationRenderer] Hamburger toggle attached to:', collapseId);
|
|
132
|
+
|
|
133
|
+
// Create navigation list (using primitive)
|
|
134
|
+
const ul = createList(false, { class: 'zNavbar-nav' });
|
|
135
|
+
|
|
136
|
+
// REFACTORED: Use link_primitives.js for ALL navigation links
|
|
137
|
+
// This ensures consistent behavior between navbar and content links
|
|
138
|
+
items.forEach((item, index) => {
|
|
139
|
+
const li = createListItem({ class: 'zNav-item' });
|
|
140
|
+
|
|
141
|
+
// Check if this is a hierarchical item with zSub
|
|
142
|
+
if (typeof item === 'object' && item !== null && !item.label && !item.href) {
|
|
143
|
+
// Dict format: {"zProducts": {"zSub": ["zCLI", "zBifrost", ...]}}
|
|
144
|
+
const itemName = Object.keys(item)[0];
|
|
145
|
+
const itemData = item[itemName];
|
|
146
|
+
|
|
147
|
+
if (itemData && typeof itemData === 'object' && itemData.zSub && Array.isArray(itemData.zSub)) {
|
|
148
|
+
// This is a hierarchical menu item - render using zTheme's zDropdown component
|
|
149
|
+
li.classList.add('zDropdown'); // zTheme dropdown container
|
|
150
|
+
|
|
151
|
+
const parentLabel = itemName.replace(/^[$^~]+/, '');
|
|
152
|
+
const parentHref = this._convertDeltaLinkToHref(itemName);
|
|
153
|
+
|
|
154
|
+
// Create dropdown toggle link (zTheme adds caret automatically via ::after)
|
|
155
|
+
const dropdownLink = createLink(parentHref, {
|
|
156
|
+
class: `zNav-link zDropdown-toggle${activeIndex === index ? ' active' : ''}`,
|
|
157
|
+
'data-toggle': 'dropdown',
|
|
158
|
+
'aria-haspopup': 'true',
|
|
159
|
+
'aria-expanded': 'false'
|
|
160
|
+
});
|
|
161
|
+
dropdownLink.textContent = parentLabel;
|
|
162
|
+
|
|
163
|
+
// Create dropdown menu using zTheme classes
|
|
164
|
+
const dropdownMenu = createDiv({ class: 'zDropdown-menu' });
|
|
165
|
+
|
|
166
|
+
// Add sub-items using zTheme's zDropdown-item class
|
|
167
|
+
itemData.zSub.forEach(subItem => {
|
|
168
|
+
const subHref = `${parentHref}/${subItem}`;
|
|
169
|
+
const subLink = createLink(subHref, { class: 'zDropdown-item' });
|
|
170
|
+
subLink.textContent = subItem;
|
|
171
|
+
|
|
172
|
+
// Add click handler for internal navigation
|
|
173
|
+
subLink.addEventListener('click', (e) => {
|
|
174
|
+
e.preventDefault();
|
|
175
|
+
// Close dropdown after selection
|
|
176
|
+
dropdownMenu.classList.remove('zShow');
|
|
177
|
+
dropdownLink.setAttribute('aria-expanded', 'false');
|
|
178
|
+
|
|
179
|
+
if (this.client && this.client.navigationManager) {
|
|
180
|
+
this.client.navigationManager.navigateToRoute(subHref);
|
|
181
|
+
} else {
|
|
182
|
+
window.location.href = subHref;
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
dropdownMenu.appendChild(subLink);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Toggle dropdown on click (zTheme pattern)
|
|
190
|
+
dropdownLink.addEventListener('click', (e) => {
|
|
191
|
+
e.preventDefault();
|
|
192
|
+
const isOpen = dropdownMenu.classList.contains('zShow');
|
|
193
|
+
|
|
194
|
+
// Close all other dropdowns
|
|
195
|
+
document.querySelectorAll('.zDropdown-menu.zShow').forEach(menu => {
|
|
196
|
+
if (menu !== dropdownMenu) {
|
|
197
|
+
menu.classList.remove('zShow');
|
|
198
|
+
const toggle = menu.previousElementSibling;
|
|
199
|
+
if (toggle) toggle.setAttribute('aria-expanded', 'false');
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// Toggle this dropdown
|
|
204
|
+
if (isOpen) {
|
|
205
|
+
dropdownMenu.classList.remove('zShow');
|
|
206
|
+
dropdownLink.setAttribute('aria-expanded', 'false');
|
|
207
|
+
} else {
|
|
208
|
+
dropdownMenu.classList.add('zShow');
|
|
209
|
+
dropdownLink.setAttribute('aria-expanded', 'true');
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
li.appendChild(dropdownLink);
|
|
214
|
+
li.appendChild(dropdownMenu);
|
|
215
|
+
ul.appendChild(li);
|
|
216
|
+
|
|
217
|
+
this.logger.debug(`[NavigationRenderer] Created dropdown for ${parentLabel}`);
|
|
218
|
+
return; // Continue to next item
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Handle simple item as string or object {label, href}
|
|
223
|
+
let itemLabel, itemHref, originalItem;
|
|
224
|
+
if (typeof item === 'string') {
|
|
225
|
+
// Strip navigation prefixes for clean display
|
|
226
|
+
// $ (delta link), ^ (bounce-back), ~ (anchor)
|
|
227
|
+
// Example: "$^zLogin" → "zLogin"
|
|
228
|
+
originalItem = item;
|
|
229
|
+
itemLabel = item.replace(/^[$^~]+/, '');
|
|
230
|
+
// Convert delta links ($zBlock) to web routes (/zBlock)
|
|
231
|
+
itemHref = this._convertDeltaLinkToHref(item);
|
|
232
|
+
} else if (typeof item === 'object' && item !== null) {
|
|
233
|
+
originalItem = item.label || item.text || '';
|
|
234
|
+
itemLabel = originalItem.replace(/^[$^~]+/, '');
|
|
235
|
+
itemHref = item.href || this._convertDeltaLinkToHref(itemLabel);
|
|
236
|
+
} else {
|
|
237
|
+
originalItem = String(item);
|
|
238
|
+
itemLabel = originalItem;
|
|
239
|
+
itemHref = href;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Detect link type for renderLink primitive
|
|
243
|
+
const linkType = this._detectLinkType(itemHref, originalItem);
|
|
244
|
+
|
|
245
|
+
// Prepare link data for renderLink primitive
|
|
246
|
+
const linkData = {
|
|
247
|
+
label: itemLabel,
|
|
248
|
+
href: itemHref,
|
|
249
|
+
target: '_self',
|
|
250
|
+
link_type: linkType,
|
|
251
|
+
_zClass: `zNav-link${activeIndex === index ? ' active' : ''}`,
|
|
252
|
+
color: '',
|
|
253
|
+
window: {}
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
this.logger.debug('[NavigationRenderer] Creating navbar link:', linkData.label);
|
|
257
|
+
|
|
258
|
+
// Use renderLink primitive (now returns element directly)
|
|
259
|
+
const link = renderLink(linkData, null, this.client, this.logger);
|
|
260
|
+
|
|
261
|
+
if (link) {
|
|
262
|
+
li.appendChild(link);
|
|
263
|
+
} else {
|
|
264
|
+
this.logger.error('[NavigationRenderer] [ERROR] renderLink returned no link element');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
ul.appendChild(li);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// Assemble: ul -> collapseDiv -> nav
|
|
271
|
+
collapseDiv.appendChild(ul);
|
|
272
|
+
nav.appendChild(collapseDiv);
|
|
273
|
+
|
|
274
|
+
// Close dropdowns when clicking outside (zTheme pattern)
|
|
275
|
+
const closeDropdowns = (e) => {
|
|
276
|
+
if (!nav.contains(e.target)) {
|
|
277
|
+
nav.querySelectorAll('.zDropdown-menu.zShow').forEach(menu => {
|
|
278
|
+
menu.classList.remove('zShow');
|
|
279
|
+
const toggle = menu.previousElementSibling;
|
|
280
|
+
if (toggle) {
|
|
281
|
+
toggle.setAttribute('aria-expanded', 'false');
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
// Use setTimeout to avoid immediate triggering
|
|
288
|
+
setTimeout(() => {
|
|
289
|
+
document.addEventListener('click', closeDropdowns);
|
|
290
|
+
}, 100);
|
|
291
|
+
|
|
292
|
+
this.logger.info('[NavigationRenderer] Rendered navbar (%s items)', items.length);
|
|
293
|
+
|
|
294
|
+
return nav;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Convert delta link notation ($zBlock) to web route (/zBlock)
|
|
299
|
+
*
|
|
300
|
+
* Delta links ($) are used in YAML for intra-file navigation.
|
|
301
|
+
* Navigation modifiers (^, ~) are stripped for clean URLs.
|
|
302
|
+
* In Bifrost mode, these are converted to web routes for proper navigation.
|
|
303
|
+
*
|
|
304
|
+
* @param {string} item - Item text (may contain $^zBlock notation with modifiers)
|
|
305
|
+
* @returns {string} - Web-friendly href
|
|
306
|
+
* @private
|
|
307
|
+
*/
|
|
308
|
+
_convertDeltaLinkToHref(item) {
|
|
309
|
+
if (typeof item !== 'string') {
|
|
310
|
+
return '#';
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Strip all navigation prefixes: $ (delta), ^ (bounce-back), ~ (anchor)
|
|
314
|
+
// Example: "$^zLogin" → "zLogin" → "/zLogin"
|
|
315
|
+
const cleanBlock = item.replace(/^[$^~]+/, '');
|
|
316
|
+
|
|
317
|
+
// Check if original item had $ (delta link) or other navigation prefixes
|
|
318
|
+
if (item !== cleanBlock) {
|
|
319
|
+
// Had navigation prefixes - convert to web route
|
|
320
|
+
return `/${cleanBlock}`;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Default: use item as-is (for explicit /path or # links)
|
|
324
|
+
return item.startsWith('/') || item.startsWith('#') ? item : `/${item}`;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Detect link type from href and original item.
|
|
329
|
+
*
|
|
330
|
+
* This mirrors the logic in link_primitives.js to ensure consistent
|
|
331
|
+
* link type detection across navbar and content links.
|
|
332
|
+
*
|
|
333
|
+
* @param {string} href - Converted href (e.g., "/zBlock")
|
|
334
|
+
* @param {string} originalItem - Original item with prefixes (e.g., "$zBlock")
|
|
335
|
+
* @returns {string} - Link type: 'delta', 'zpath', 'external', 'anchor', 'placeholder'
|
|
336
|
+
* @private
|
|
337
|
+
*/
|
|
338
|
+
_detectLinkType(href, originalItem) {
|
|
339
|
+
// Check original item for navigation prefixes
|
|
340
|
+
if (originalItem && typeof originalItem === 'string') {
|
|
341
|
+
// Delta link ($) - internal navigation
|
|
342
|
+
if (originalItem.startsWith('$') || originalItem.includes('$')) {
|
|
343
|
+
return 'delta';
|
|
344
|
+
}
|
|
345
|
+
// zPath (@) - absolute path navigation
|
|
346
|
+
if (originalItem.startsWith('@')) {
|
|
347
|
+
return 'zpath';
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Check href for external URLs
|
|
352
|
+
if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('www.')) {
|
|
353
|
+
return 'external';
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Check for anchor links
|
|
357
|
+
if (href.startsWith('#') && href !== '#') {
|
|
358
|
+
return 'anchor';
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Placeholder link
|
|
362
|
+
if (!href || href === '#') {
|
|
363
|
+
return 'placeholder';
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Default: treat as delta (internal navigation)
|
|
367
|
+
return 'delta';
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Render breadcrumb navigation (zTheme-styled)
|
|
372
|
+
* @param {Array<string>} trail - Breadcrumb trail items
|
|
373
|
+
* @param {Object} options - Rendering options
|
|
374
|
+
* @returns {HTMLElement} - Breadcrumb element with zTheme classes
|
|
375
|
+
*/
|
|
376
|
+
renderBreadcrumb(trail, options = {}) {
|
|
377
|
+
if (!Array.isArray(trail) || trail.length === 0) {
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const {
|
|
382
|
+
separator = '>',
|
|
383
|
+
className = 'zcli-breadcrumb'
|
|
384
|
+
} = options;
|
|
385
|
+
|
|
386
|
+
const nav = createNav({
|
|
387
|
+
class: `${className} zmb-3`,
|
|
388
|
+
'aria-label': 'breadcrumb'
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
const ol = createList(true, {
|
|
392
|
+
class: 'zD-flex zFlex-row zFlex-items-center zGap-2'
|
|
393
|
+
});
|
|
394
|
+
ol.style.listStyle = 'none';
|
|
395
|
+
ol.style.padding = '0';
|
|
396
|
+
ol.style.margin = '0';
|
|
397
|
+
|
|
398
|
+
trail.forEach((item, index) => {
|
|
399
|
+
const li = createListItem({ class: 'breadcrumb-item' });
|
|
400
|
+
|
|
401
|
+
if (index === trail.length - 1) {
|
|
402
|
+
// Last item (current page) - use muted text, bold weight (using primitive)
|
|
403
|
+
const span = createSpan({
|
|
404
|
+
class: 'zText-muted zFw-bold',
|
|
405
|
+
'aria-current': 'page'
|
|
406
|
+
});
|
|
407
|
+
span.textContent = item;
|
|
408
|
+
li.appendChild(span);
|
|
409
|
+
} else {
|
|
410
|
+
// Link to parent pages - use primary color (using primitive)
|
|
411
|
+
const a = createLink('#', { class: 'zText-primary zText-decoration-none' });
|
|
412
|
+
a.textContent = item;
|
|
413
|
+
li.appendChild(a);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
ol.appendChild(li);
|
|
417
|
+
|
|
418
|
+
// Add separator (except after last item) (using primitive)
|
|
419
|
+
if (index < trail.length - 1) {
|
|
420
|
+
const sep = createSpan({ class: 'breadcrumb-separator zText-muted' });
|
|
421
|
+
sep.textContent = ` ${separator} `;
|
|
422
|
+
ol.appendChild(sep);
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
nav.appendChild(ol);
|
|
427
|
+
return nav;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Render breadcrumbs from zCrumbs display event (handles multiple trails)
|
|
432
|
+
* Uses zTheme breadcrumb structure: nav > ol.zBreadcrumb > li.zBreadcrumb-item
|
|
433
|
+
*
|
|
434
|
+
* Single trail: Returns <nav> directly (no wrapper div) - semantic HTML like zH1
|
|
435
|
+
* Multi-trail: Returns container div with multiple navs + scope labels
|
|
436
|
+
*
|
|
437
|
+
* @param {Object} eventData - Event data from backend zCrumbs event
|
|
438
|
+
* @returns {HTMLElement|null} - nav element (single trail) or container div (multi-trail)
|
|
439
|
+
* @see zOS/zTheme/Manual/ztheme-breadcrumb.html
|
|
440
|
+
*/
|
|
441
|
+
/**
|
|
442
|
+
* Derive display labels from an array of zPaths using minimum-depth uniqueness.
|
|
443
|
+
* Mirrors the Python _derive_zpath_labels logic for Bifrost-side label rendering.
|
|
444
|
+
* @param {string[]} paths - Array of zPath strings (may include #N suffixes)
|
|
445
|
+
* @returns {string[]} - Unique display labels at minimum consistent depth
|
|
446
|
+
*/
|
|
447
|
+
_deriveZpathLabels(paths) {
|
|
448
|
+
if (!paths || paths.length === 0) return [];
|
|
449
|
+
// resolve_zpath_references converts @.UI.* zPaths to HTTP routes before the chunk is
|
|
450
|
+
// sent to Bifrost (e.g. @.UI.zProducts.zUI.zOS.zOS → /zProducts/zOS).
|
|
451
|
+
// URL paths have no '.' separators, so the dot-split algorithm yields empty strings.
|
|
452
|
+
// Detect URL-format paths and derive labels from the last non-empty path segment instead.
|
|
453
|
+
if (paths[0] && paths[0].startsWith('/')) {
|
|
454
|
+
return paths.map(p => {
|
|
455
|
+
const segs = p.split('/').filter(Boolean);
|
|
456
|
+
return segs.length > 0 ? segs[segs.length - 1] : p;
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
// zPath strings (@.UI.* not yet resolved): minimum-depth uniqueness algorithm
|
|
460
|
+
const stripped = paths.map(p => p.split('#')[0]);
|
|
461
|
+
const parts = stripped.map(p => p.split('.'));
|
|
462
|
+
const maxDepth = Math.max(...parts.map(p => p.length));
|
|
463
|
+
for (let depth = 2; depth < maxDepth; depth++) {
|
|
464
|
+
const labels = parts.map(p => p.slice(-depth).join('.'));
|
|
465
|
+
if (new Set(labels).size === labels.length) return labels;
|
|
466
|
+
}
|
|
467
|
+
return parts.map(p => p.slice(1).join('.'));
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Derive structure trail from the client's current page context.
|
|
472
|
+
* Reads zVaFolder + zVaFile from bifrostClient.zuiConfig — the runtime SSOT.
|
|
473
|
+
* @returns {string[]} - Folder segments + file label (e.g. ['zProducts','zOS','Events','zNavigation'])
|
|
474
|
+
*/
|
|
475
|
+
_deriveStructureTrail() {
|
|
476
|
+
const folder = this.client?.zuiConfig?.zVaFolder || '';
|
|
477
|
+
const file = this.client?.zuiConfig?.zVaFile || '';
|
|
478
|
+
if (!folder && !file) return [];
|
|
479
|
+
// "@.UI.zProducts.zOS.Events" → strip root prefix → "UI.zProducts.zOS.Events" → split → drop mount root
|
|
480
|
+
const folderParts = folder.replace(/^[@~]\./, '').split('.');
|
|
481
|
+
// Keep only z-prefixed segments (named sections like zProducts, zOS).
|
|
482
|
+
// Plain organizational folders (Events, UI, etc.) are file-system details, not nav levels.
|
|
483
|
+
const segments = folderParts.slice(1).filter(p => p && p.startsWith('z'));
|
|
484
|
+
const fileLabel = file.startsWith('zUI.') ? file.slice(4) : file;
|
|
485
|
+
return [...segments, ...(fileLabel ? [fileLabel] : [])];
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
_formatLabel(item) {
|
|
489
|
+
return String(item).replace(/_/g, ' ');
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Render breadcrumbs from a zCrumbs display event.
|
|
494
|
+
*
|
|
495
|
+
* Bifrost receives the raw expander display_data: {event, show, header, trail?, parent?}
|
|
496
|
+
* — the Python zCrumbs() method does NOT run in Bifrost mode (chunks are pre-built).
|
|
497
|
+
* All derivation (labels, structure trail) must happen here in JS.
|
|
498
|
+
*
|
|
499
|
+
* Modes:
|
|
500
|
+
* manual — trail[] of zPaths in eventData; derive labels; ancestors are zLink clickable
|
|
501
|
+
* structure — derive trail from client.zuiConfig.zVaFolder/zVaFile; ancestors display-only
|
|
502
|
+
* session — crumbs from separate try_gui_event payload (crumbs key) if present; else empty
|
|
503
|
+
* static — legacy: parent dot-path in eventData; display-only ancestors
|
|
504
|
+
*
|
|
505
|
+
* @param {Object} eventData - Raw event from backend: {event, show, trail?, parent?, crumbs?}
|
|
506
|
+
* @returns {HTMLElement|null}
|
|
507
|
+
*/
|
|
508
|
+
renderBreadcrumbs(eventData) {
|
|
509
|
+
this.logger.debug('[NavigationRenderer] renderBreadcrumbs called', eventData);
|
|
510
|
+
|
|
511
|
+
const show = eventData.show || 'session';
|
|
512
|
+
const zMenu = eventData.zMenu === true || eventData.zMenu === 'true';
|
|
513
|
+
let displayLabels = [];
|
|
514
|
+
let navPaths = null; // zPaths for manual mode
|
|
515
|
+
let structureSegs = null; // raw URL segments for structure mode
|
|
516
|
+
|
|
517
|
+
if (show === 'manual') {
|
|
518
|
+
// trail: array of zPaths injected by the expander
|
|
519
|
+
const rawPaths = (eventData.trail || []).map(p => String(p).trim().replace(/^["']|["']$/g, ''));
|
|
520
|
+
if (!rawPaths.length) return null;
|
|
521
|
+
displayLabels = this._deriveZpathLabels(rawPaths);
|
|
522
|
+
navPaths = rawPaths;
|
|
523
|
+
this.logger.debug('[NavigationRenderer] manual mode, labels:', displayLabels);
|
|
524
|
+
|
|
525
|
+
} else if (show === 'structure') {
|
|
526
|
+
// Derive from current page context — raw segments double as URL path parts
|
|
527
|
+
structureSegs = this._deriveStructureTrail();
|
|
528
|
+
if (!structureSegs.length) return null;
|
|
529
|
+
displayLabels = structureSegs;
|
|
530
|
+
this.logger.debug('[NavigationRenderer] structure mode, zMenu=%s, trail:', zMenu, displayLabels);
|
|
531
|
+
|
|
532
|
+
} else if (show === 'session') {
|
|
533
|
+
// Session crumbs come via a separate try_gui_event payload (crumbs key)
|
|
534
|
+
// if the Python backend sent them; otherwise return null (no history yet)
|
|
535
|
+
const crumbsData = eventData.crumbs || {};
|
|
536
|
+
const trails = crumbsData.trails || {};
|
|
537
|
+
const visibleTrails = Object.entries(trails).filter(([k]) => !k.startsWith('_'));
|
|
538
|
+
if (!visibleTrails.length) {
|
|
539
|
+
this.logger.debug('[NavigationRenderer] session mode: no crumbs data, skipping');
|
|
540
|
+
return null;
|
|
541
|
+
}
|
|
542
|
+
displayLabels = visibleTrails[0][1];
|
|
543
|
+
this.logger.debug('[NavigationRenderer] session mode, labels:', displayLabels);
|
|
544
|
+
|
|
545
|
+
} else if (show === 'static') {
|
|
546
|
+
// Legacy: parent dot-path → display-only label trail
|
|
547
|
+
const parent = eventData.parent || '';
|
|
548
|
+
if (!parent) return null;
|
|
549
|
+
displayLabels = parent.split('.');
|
|
550
|
+
this.logger.debug('[NavigationRenderer] static (legacy) mode, labels:', displayLabels);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (!displayLabels || !displayLabels.length) return null;
|
|
554
|
+
|
|
555
|
+
// Build nav > ol.zBreadcrumb
|
|
556
|
+
const nav = createNav({ 'aria-label': `${show} breadcrumb`, class: 'zmb-3' });
|
|
557
|
+
const ol = createList(true, { class: 'zBreadcrumb' });
|
|
558
|
+
|
|
559
|
+
displayLabels.forEach((label, index) => {
|
|
560
|
+
const isLast = index === displayLabels.length - 1;
|
|
561
|
+
const li = createListItem({
|
|
562
|
+
class: isLast ? 'zBreadcrumb-item zActive' : 'zBreadcrumb-item'
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
if (isLast) {
|
|
566
|
+
li.setAttribute('aria-current', 'page');
|
|
567
|
+
li.textContent = this._formatLabel(label);
|
|
568
|
+
} else if (show === 'manual' && navPaths && navPaths[index]) {
|
|
569
|
+
// manual ancestors: zMenu: true → clickable zLink; zMenu: false → disabled link
|
|
570
|
+
const zPath = navPaths[index];
|
|
571
|
+
if (zMenu) {
|
|
572
|
+
const a = createLink('#', {});
|
|
573
|
+
a.textContent = this._formatLabel(label);
|
|
574
|
+
a.onclick = async (e) => {
|
|
575
|
+
e.preventDefault();
|
|
576
|
+
this.logger.log(`[Breadcrumbs] zLink → ${zPath}`);
|
|
577
|
+
if (this.client) {
|
|
578
|
+
try {
|
|
579
|
+
if (typeof this.client.zLink === 'function') {
|
|
580
|
+
await this.client.zLink(zPath);
|
|
581
|
+
} else if (this.client.navigationManager) {
|
|
582
|
+
const url = this.client._zLinkPathToUrl?.(zPath) || zPath;
|
|
583
|
+
await this.client.navigationManager.navigateToRoute(url);
|
|
584
|
+
}
|
|
585
|
+
} catch (err) {
|
|
586
|
+
this.logger.error('[Breadcrumbs] zLink failed:', err);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
};
|
|
590
|
+
li.appendChild(a);
|
|
591
|
+
} else {
|
|
592
|
+
// disabled link — same visual as zMenu:true but non-clickable
|
|
593
|
+
const a = createLink('#', {
|
|
594
|
+
'aria-disabled': 'true',
|
|
595
|
+
tabindex: '-1'
|
|
596
|
+
});
|
|
597
|
+
a.style.pointerEvents = 'none';
|
|
598
|
+
a.style.cursor = 'default';
|
|
599
|
+
a.textContent = this._formatLabel(label);
|
|
600
|
+
li.appendChild(a);
|
|
601
|
+
}
|
|
602
|
+
} else if (show === 'structure' && structureSegs) {
|
|
603
|
+
// structure ancestors: cumulative URL path from folder segments
|
|
604
|
+
const href = '/' + structureSegs.slice(0, index + 1).join('/');
|
|
605
|
+
if (zMenu) {
|
|
606
|
+
// zMenu: true → real navigable link
|
|
607
|
+
const a = createLink(href, {});
|
|
608
|
+
a.textContent = this._formatLabel(label);
|
|
609
|
+
a.onclick = (e) => {
|
|
610
|
+
e.preventDefault();
|
|
611
|
+
if (this.client?.navigationManager) {
|
|
612
|
+
this.client.navigationManager.navigateToRoute(href);
|
|
613
|
+
} else {
|
|
614
|
+
window.location.href = href;
|
|
615
|
+
}
|
|
616
|
+
};
|
|
617
|
+
li.appendChild(a);
|
|
618
|
+
} else {
|
|
619
|
+
// zMenu: false (default) → disabled link (same visual, non-clickable)
|
|
620
|
+
const a = createLink(href, {
|
|
621
|
+
'aria-disabled': 'true',
|
|
622
|
+
tabindex: '-1'
|
|
623
|
+
});
|
|
624
|
+
a.style.pointerEvents = 'none';
|
|
625
|
+
a.style.cursor = 'default';
|
|
626
|
+
a.textContent = this._formatLabel(label);
|
|
627
|
+
li.appendChild(a);
|
|
628
|
+
}
|
|
629
|
+
} else {
|
|
630
|
+
// session / static ancestors: display-only span
|
|
631
|
+
const span = createSpan({ class: 'zText-muted' });
|
|
632
|
+
span.textContent = this._formatLabel(label);
|
|
633
|
+
li.appendChild(span);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
ol.appendChild(li);
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
nav.appendChild(ol);
|
|
640
|
+
this.logger.debug('[NavigationRenderer] Rendered breadcrumbs (%s mode, %s items)', show, displayLabels.length);
|
|
641
|
+
return nav;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Render vertical sidebar navigation (zTheme-styled)
|
|
646
|
+
* @param {Array<string>} items - Navigation item labels
|
|
647
|
+
* @param {Object} options - Rendering options
|
|
648
|
+
* @returns {HTMLElement} - Sidebar nav element with zTheme classes
|
|
649
|
+
*/
|
|
650
|
+
renderSidebarNav(items, options = {}) {
|
|
651
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
652
|
+
return null;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const {
|
|
656
|
+
className = 'zcli-sidebar-nav',
|
|
657
|
+
activeIndex = null
|
|
658
|
+
} = options;
|
|
659
|
+
|
|
660
|
+
// Sidebar container with zTheme utilities (using primitive)
|
|
661
|
+
const nav = createNav({ class: `${className} zBg-light zP-3 zRounded` });
|
|
662
|
+
nav.style.width = '200px';
|
|
663
|
+
|
|
664
|
+
const ul = createList(false, { class: 'zD-flex zFlex-column zGap-2' });
|
|
665
|
+
ul.style.listStyle = 'none';
|
|
666
|
+
ul.style.padding = '0';
|
|
667
|
+
ul.style.margin = '0';
|
|
668
|
+
|
|
669
|
+
items.forEach((item, index) => {
|
|
670
|
+
const li = createListItem({ class: 'sidebar-item' });
|
|
671
|
+
|
|
672
|
+
const a = createLink('#');
|
|
673
|
+
a.textContent = item;
|
|
674
|
+
|
|
675
|
+
// zTheme sidebar link: padding, display block, rounded
|
|
676
|
+
a.className = 'sidebar-link zText-dark zText-decoration-none zP-2 zD-block zRounded';
|
|
677
|
+
|
|
678
|
+
// Active state with zTheme classes
|
|
679
|
+
if (activeIndex === index) {
|
|
680
|
+
a.classList.add('zBg-primary', 'zText-white', 'zFw-bold');
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// Hover effect via zTheme classes
|
|
684
|
+
a.addEventListener('mouseenter', () => {
|
|
685
|
+
if (activeIndex !== index) {
|
|
686
|
+
a.classList.add('zBg-white');
|
|
687
|
+
}
|
|
688
|
+
});
|
|
689
|
+
a.addEventListener('mouseleave', () => {
|
|
690
|
+
if (activeIndex !== index) {
|
|
691
|
+
a.classList.remove('zBg-white');
|
|
692
|
+
}
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
li.appendChild(a);
|
|
696
|
+
ul.appendChild(li);
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
nav.appendChild(ul);
|
|
700
|
+
return nav;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|