@yuneta/gobj-ui 1.0.1 → 2.2.0

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.
Files changed (46) hide show
  1. package/README.md +40 -363
  2. package/dist/gobj-ui.cjs.js +11154 -5506
  3. package/dist/gobj-ui.es.js +11131 -5508
  4. package/index.js +41 -14
  5. package/package.json +11 -9
  6. package/src/c_g6_nodes_tree.js +6 -1
  7. package/src/c_yui_form.js +1 -1
  8. package/src/c_yui_gobj_tree_js.js +1 -1
  9. package/src/c_yui_json_graph.js +1 -1
  10. package/src/c_yui_main.js +3 -3
  11. package/src/c_yui_map.js +6 -1
  12. package/src/c_yui_nav.js +881 -0
  13. package/src/c_yui_pager.js +545 -0
  14. package/src/c_yui_routing.css +1 -1
  15. package/src/c_yui_routing.js +1 -1
  16. package/src/c_yui_shell.css +571 -0
  17. package/src/c_yui_shell.js +2474 -0
  18. package/src/c_yui_tabs.js +1 -1
  19. package/src/c_yui_treedb_graph.js +35 -9
  20. package/src/c_yui_treedb_topic_with_form.js +1 -1
  21. package/src/c_yui_treedb_topics.js +36 -8
  22. package/src/c_yui_uplot.js +1 -1
  23. package/src/c_yui_window.js +242 -21
  24. package/src/c_yui_window_manager.js +602 -0
  25. package/src/c_yui_wizard.js +612 -0
  26. package/src/pager_helpers.js +138 -0
  27. package/src/pager_helpers.test.js +140 -0
  28. package/src/route_resolver.js +53 -0
  29. package/src/route_resolver.test.js +82 -0
  30. package/src/shell_focus_trap.js +123 -0
  31. package/src/shell_focus_trap.test.js +299 -0
  32. package/src/shell_modals.js +445 -0
  33. package/src/shell_show_on.js +91 -0
  34. package/src/shell_show_on.test.js +86 -0
  35. package/src/shell_toolbar_helpers.js +221 -0
  36. package/src/shell_toolbar_helpers.test.js +207 -0
  37. package/src/tabulator.css +53 -0
  38. package/src/wizard_helpers.js +117 -0
  39. package/src/wizard_helpers.test.js +122 -0
  40. package/src/yui_dev.js +1257 -362
  41. package/src/yui_icons.css +5 -0
  42. package/src/yui_inputs.css +32 -0
  43. package/src/yui_inputs.js +71 -0
  44. package/vite-plugin-yuneta-html.js +2 -2
  45. package/skeleton/config.json +0 -20
  46. package/skeleton/index.html +0 -37
@@ -0,0 +1,2474 @@
1
+ /***********************************************************************
2
+ * c_yui_shell.js
3
+ *
4
+ * C_YUI_SHELL — Declarative app shell.
5
+ *
6
+ * Parses a JSON config to build:
7
+ * - Layers (z-stacked): base, overlay, popup, modal, notification, loading
8
+ * - Zones (inside base layer): top, top-sub, left, center, right,
9
+ * bottom-sub, bottom
10
+ * - Menus (primary + submenus) rendered via C_YUI_NAV — one nav
11
+ * instance per zone hosting the menu
12
+ * - Stages: zones declared to host routed view gobjs (typ. center)
13
+ *
14
+ * Each menu item's `target` declares which gclass to instantiate (or
15
+ * gobj to reuse) in which stage. Navigating = show the target gobj's
16
+ * $container in its stage, hide the previous one. Lifecycle per item
17
+ * decides when the gobj is created/destroyed.
18
+ *
19
+ * Hash-based 2-level routing (#/primary/secondary). No dependency on
20
+ * C_YUI_ROUTING.
21
+ *
22
+ * Copyright (c) 2026, ArtGins.
23
+ * All Rights Reserved.
24
+ ***********************************************************************/
25
+ /* global window, document */
26
+
27
+ import {
28
+ SDATA, SDATA_END, data_type_t, event_flag_t, gclass_flag_t,
29
+ gclass_create, log_error, log_warning,
30
+ gobj_create, gobj_destroy,
31
+ gobj_start, gobj_stop,
32
+ gobj_publish_event,
33
+ gobj_send_event,
34
+ gobj_subscribe_event,
35
+ gobj_read_attr, gobj_read_pointer_attr, gobj_write_attr,
36
+ createElement2, empty_string, is_object, is_array, is_string,
37
+ refresh_language,
38
+ } from "@yuneta/gobj-js";
39
+
40
+ import {
41
+ BULMA_BP_ORDER,
42
+ breakpoints_from_expr,
43
+ bulma_hidden_class,
44
+ } from "./shell_show_on.js";
45
+
46
+ import {
47
+ activate_focus_trap_on,
48
+ } from "./shell_focus_trap.js";
49
+
50
+ import {
51
+ classify_toolbar_item,
52
+ validate_toolbar_item,
53
+ } from "./shell_toolbar_helpers.js";
54
+
55
+ import { resolve_route } from "./route_resolver.js";
56
+
57
+ /***************************************************************
58
+ * Constants
59
+ ***************************************************************/
60
+ const GCLASS_NAME = "C_YUI_SHELL";
61
+
62
+ /* Zones rendered inside the base layer. */
63
+ const ZONE_IDS = ["top", "top-sub", "left", "center", "right", "bottom-sub", "bottom"];
64
+
65
+ /* Global stacking layers. */
66
+ const LAYER_DEFS = [
67
+ ["base", 1 ],
68
+ ["overlay", 15 ],
69
+ ["popup", 20 ],
70
+ ["modal", 99 ],
71
+ ["notification", 120],
72
+ ["loading", 150]
73
+ ];
74
+
75
+ /***************************************************************
76
+ * Attrs
77
+ ***************************************************************/
78
+ const attrs_table = [
79
+ SDATA(data_type_t.DTP_POINTER, "subscriber", 0, null, "Subscriber of output events"),
80
+
81
+ SDATA(data_type_t.DTP_JSON, "config", 0, null, "Shell declarative config (zones, menu, stages, toolbar)"),
82
+ SDATA(data_type_t.DTP_STRING, "default_route", 0, "", "Fallback route if hash is empty"),
83
+ SDATA(data_type_t.DTP_STRING, "current_route", 0, "", "Current active route"),
84
+ SDATA(data_type_t.DTP_BOOLEAN, "use_hash", 0, true, "Bind navigation to window.location.hash"),
85
+ SDATA(data_type_t.DTP_POINTER, "mount_element", 0, null, "HTMLElement to mount shell into (default: document.body)"),
86
+
87
+ SDATA(data_type_t.DTP_POINTER, "$container", 0, null, "Root HTMLElement of the shell"),
88
+ SDATA(data_type_t.DTP_POINTER, "priv", 0, null, "Private runtime state (zones/layers/stages/navs)"),
89
+ SDATA_END()
90
+ ];
91
+
92
+ let PRIVATE_DATA = {};
93
+
94
+ let __gclass__ = null;
95
+
96
+
97
+
98
+
99
+ /******************************
100
+ * Framework Methods
101
+ ******************************/
102
+
103
+
104
+
105
+
106
+ /***************************************************************
107
+ * Framework Method: Create
108
+ ***************************************************************/
109
+ function mt_create(gobj)
110
+ {
111
+ /*
112
+ * SERVICE subscription model
113
+ */
114
+ const subscriber = gobj_read_pointer_attr(gobj, "subscriber");
115
+ if(subscriber) {
116
+ gobj_subscribe_event(gobj, null, {}, subscriber);
117
+ }
118
+
119
+ /* Per-instance private state (avoid the gclass-level PRIVATE_DATA). */
120
+ gobj_write_attr(gobj, "priv", {
121
+ zones: {},
122
+ layers: {},
123
+ stages: {},
124
+ navs: [],
125
+ item_index: {},
126
+ hash_handler: null,
127
+ keydown_handler: null,
128
+ /* Escape priority chain: array of { layer, handler }. Each
129
+ * interactive overlay (drawer today, modal/popup tomorrow)
130
+ * pushes its close handler when it opens and pops it when
131
+ * it closes. Escape calls the top handler only — LIFO. */
132
+ escape_stack: [],
133
+ /* Avatar item support — every toolbar item with type:"avatar"
134
+ * registers its <span> here so refresh_avatars() can repaint
135
+ * the initials when the host (wattyzer, hidraulia, …) calls
136
+ * yui_shell_set_avatar_provider() / yui_shell_refresh_avatars().
137
+ * The provider is a () => string callback owned by the host. */
138
+ avatar_provider: null,
139
+ avatar_nodes: [],
140
+ /* Optional i18n translator (t-function) the host registers via
141
+ * yui_shell_set_translator(). The host translates the static
142
+ * shell tree by calling refresh_language($container, t) once,
143
+ * but LAZILY-built DOM (the toolbar dropdown panel) is mounted
144
+ * on the popup layer, OUTSIDE $container, AFTER that call — so
145
+ * it would never be translated. When a translator is set the
146
+ * shell re-applies it to each freshly built panel. */
147
+ translator: null,
148
+ /* Connection-indicator support — every toolbar item with
149
+ * type:"connection" registers its dot <span> here so
150
+ * yui_shell_set_connection_state(shell, bool) can repaint the
151
+ * backend-connected state. State is host/event-driven (unlike
152
+ * the avatar provider it is a setter, not a pull callback). */
153
+ conn_nodes: [],
154
+ /* Currently open toolbar dropdown panel, if any. Tracked here
155
+ * so a second click on any trigger (or programmatic close) can
156
+ * tear down the previous one through the same code path. */
157
+ active_dropdown: null
158
+ });
159
+
160
+ build_ui(gobj);
161
+ }
162
+
163
+ /***************************************************************
164
+ * Framework Method: Start
165
+ ***************************************************************/
166
+ function mt_start(gobj)
167
+ {
168
+ let config = gobj_read_attr(gobj, "config") || {};
169
+ let priv = gobj_read_attr(gobj, "priv");
170
+
171
+ /* Validate the declarative config before anything reads it. This is a
172
+ * system boundary (app-supplied JSON); validation makes shape errors
173
+ * visible loudly instead of producing a half-built shell. */
174
+ if(!validate_config(config)) {
175
+ let $base = priv.layers && priv.layers.base;
176
+ if($base) {
177
+ $base.appendChild(createElement2(
178
+ ["div", {class: "notification is-danger m-4"},
179
+ ["p", {}, "C_YUI_SHELL: invalid config — see browser console for details"]
180
+ ]
181
+ ));
182
+ }
183
+ return;
184
+ }
185
+
186
+ build_item_index(gobj, config);
187
+ instantiate_menus(gobj, config);
188
+ build_toolbar(gobj, config);
189
+
190
+ /* lifecycle: "eager" — preinstantiate views that must exist from boot. */
191
+ preinstantiate_eager_views(gobj);
192
+
193
+ if(gobj_read_attr(gobj, "use_hash")) {
194
+ priv.hash_handler = () => {
195
+ let route = hash_to_route(window.location.hash);
196
+ if(!empty_string(route)) {
197
+ navigate_to(gobj, route);
198
+ }
199
+ };
200
+ window.addEventListener("hashchange", priv.hash_handler);
201
+ }
202
+
203
+ /* Global Escape: route to the top handler of the escape stack,
204
+ * not to a hardcoded "close all drawers". Modals and popups
205
+ * push themselves on top of drawers, so Escape closes them
206
+ * first; second Escape closes the drawer underneath; etc. */
207
+ priv.keydown_handler = ev => {
208
+ if(ev.key !== "Escape" && ev.keyCode !== 27) {
209
+ return;
210
+ }
211
+ if(priv.escape_stack.length === 0) {
212
+ return;
213
+ }
214
+ let top = priv.escape_stack[priv.escape_stack.length - 1];
215
+ ev.preventDefault();
216
+ ev.stopPropagation();
217
+ top.handler();
218
+ };
219
+ window.addEventListener("keydown", priv.keydown_handler);
220
+
221
+ let initial = hash_to_route(window.location.hash);
222
+ if(empty_string(initial)) {
223
+ initial = gobj_read_attr(gobj, "default_route") ||
224
+ (config.shell && config.shell.stages && config.shell.stages.main &&
225
+ config.shell.stages.main.default_route) || "";
226
+ }
227
+ if(!empty_string(initial)) {
228
+ navigate_to(gobj, initial);
229
+ } else {
230
+ /* No hash, no default_route, no stage default: tell the user loudly. */
231
+ show_stage_placeholder(
232
+ gobj, "main",
233
+ "C_YUI_SHELL: no route to display (empty hash, no default_route, " +
234
+ "no stages.main.default_route)"
235
+ );
236
+ log_error(
237
+ "C_YUI_SHELL: no initial route — set default_route, " +
238
+ "shell.stages.<name>.default_route, or navigate via hash"
239
+ );
240
+ }
241
+ }
242
+
243
+ /***************************************************************
244
+ * Framework Method: Stop
245
+ ***************************************************************/
246
+ function mt_stop(gobj)
247
+ {
248
+ let priv = gobj_read_attr(gobj, "priv");
249
+ if(!priv) {
250
+ return;
251
+ }
252
+
253
+ /* Tear down any toolbar dropdown that was open at stop time so its
254
+ * document-level mousedown listener and escape-stack entry don't
255
+ * outlive the shell. */
256
+ close_toolbar_dropdown(gobj);
257
+
258
+ if(priv.hash_handler) {
259
+ window.removeEventListener("hashchange", priv.hash_handler);
260
+ priv.hash_handler = null;
261
+ }
262
+ if(priv.keydown_handler) {
263
+ window.removeEventListener("keydown", priv.keydown_handler);
264
+ priv.keydown_handler = null;
265
+ }
266
+
267
+ for(let nav of priv.navs) {
268
+ try {
269
+ gobj_stop(nav);
270
+ gobj_destroy(nav);
271
+ } catch(e) {
272
+ log_warning(`C_YUI_SHELL: stop/destroy nav failed: ${e}`);
273
+ }
274
+ }
275
+ priv.navs = [];
276
+
277
+ for(let name in priv.stages) {
278
+ let st = priv.stages[name];
279
+ for(let route in st.items) {
280
+ let g = st.items[route];
281
+ try {
282
+ gobj_stop(g);
283
+ gobj_destroy(g);
284
+ } catch(e) {
285
+ log_warning(`C_YUI_SHELL: stop/destroy view '${route}' failed: ${e}`);
286
+ }
287
+ }
288
+ st.items = {};
289
+ st.active_route = null;
290
+ }
291
+ }
292
+
293
+ /***************************************************************
294
+ * Framework Method: Destroy
295
+ ***************************************************************/
296
+ function mt_destroy(gobj)
297
+ {
298
+ /* Defensive: tests sometimes call gobj_destroy without a prior
299
+ * gobj_stop. In normal lifecycle mt_stop already closed the
300
+ * dropdown; calling close_toolbar_dropdown here is idempotent
301
+ * (it returns early when nothing is open) and prevents a
302
+ * dangling document mousedown listener / escape-stack entry
303
+ * when stop is skipped. */
304
+ close_toolbar_dropdown(gobj);
305
+
306
+ let $container = gobj_read_attr(gobj, "$container");
307
+ if($container && $container.parentNode) {
308
+ $container.parentNode.removeChild($container);
309
+ }
310
+ gobj_write_attr(gobj, "$container", null);
311
+ gobj_write_attr(gobj, "priv", null);
312
+ }
313
+
314
+
315
+
316
+
317
+ /***************************
318
+ * Local Methods
319
+ ***************************/
320
+
321
+
322
+
323
+
324
+ /************************************************************
325
+ * Validate the declarative config (system boundary: app JSON).
326
+ *
327
+ * Reports every missing/wrong field via log_error so a malformed
328
+ * config fails loudly instead of producing an empty/broken shell.
329
+ * Returns true iff the config is structurally usable.
330
+ ************************************************************/
331
+ function validate_config(config)
332
+ {
333
+ let ok = true;
334
+
335
+ if(!is_object(config)) {
336
+ log_error("C_YUI_SHELL: config must be a JSON object");
337
+ return false;
338
+ }
339
+ if(!is_object(config.shell)) {
340
+ log_error("C_YUI_SHELL: config.shell is missing or not an object");
341
+ return false;
342
+ }
343
+
344
+ let shell_cfg = config.shell;
345
+ if(shell_cfg.zones !== undefined && !is_object(shell_cfg.zones)) {
346
+ log_error("C_YUI_SHELL: config.shell.zones must be an object");
347
+ ok = false;
348
+ }
349
+ if(shell_cfg.stages !== undefined && !is_object(shell_cfg.stages)) {
350
+ log_error("C_YUI_SHELL: config.shell.stages must be an object");
351
+ ok = false;
352
+ }
353
+
354
+ let zones_cfg = is_object(shell_cfg.zones) ? shell_cfg.zones : {};
355
+ /* host syntax: must be one of "toolbar", "menu.<id>", "stage.<id>". */
356
+ let HOST_RE = /^(?:toolbar|menu\.\S+|stage\.\S+)$/;
357
+ for(let zid in zones_cfg) {
358
+ if(ZONE_IDS.indexOf(zid) < 0) {
359
+ log_error(
360
+ `C_YUI_SHELL: unknown zone '${zid}' in config.shell.zones; ` +
361
+ `valid zones: ${ZONE_IDS.join(", ")}`
362
+ );
363
+ ok = false;
364
+ continue;
365
+ }
366
+ let z = zones_cfg[zid];
367
+ if(z && typeof z.host === "string" && z.host.length > 0 &&
368
+ !HOST_RE.test(z.host))
369
+ {
370
+ log_error(
371
+ `C_YUI_SHELL: zone '${zid}' has invalid host '${z.host}'; ` +
372
+ `must match 'toolbar', 'menu.<id>' or 'stage.<id>'`
373
+ );
374
+ ok = false;
375
+ }
376
+ }
377
+
378
+ let stages_cfg = is_object(shell_cfg.stages) ? shell_cfg.stages : {};
379
+ for(let sname in stages_cfg) {
380
+ let st = stages_cfg[sname];
381
+ if(!is_object(st)) {
382
+ log_error(`C_YUI_SHELL: config.shell.stages.${sname} must be an object`);
383
+ ok = false;
384
+ continue;
385
+ }
386
+ let zone = st.zone || "center";
387
+ if(ZONE_IDS.indexOf(zone) < 0) {
388
+ log_error(
389
+ `C_YUI_SHELL: stage '${sname}' references unknown zone '${zone}'`
390
+ );
391
+ ok = false;
392
+ continue;
393
+ }
394
+ /* zone must actually be declared in shell.zones — catches typos
395
+ * like stages.main.zone = "centre" that pass the ZONE_IDS test
396
+ * by accident. */
397
+ if(!Object.prototype.hasOwnProperty.call(zones_cfg, zone)) {
398
+ log_warning(
399
+ `C_YUI_SHELL: stage '${sname}' references zone '${zone}' ` +
400
+ `which is not declared in config.shell.zones — it will be ` +
401
+ `created with default attributes`
402
+ );
403
+ }
404
+ }
405
+
406
+ if(config.menu !== undefined && !is_object(config.menu)) {
407
+ log_error("C_YUI_SHELL: config.menu must be an object");
408
+ ok = false;
409
+ }
410
+ if(config.toolbar !== undefined) {
411
+ /* toolbar = { zone?, aria_label?, items[] } — see SHELL.md §3.4. */
412
+ if(!is_object(config.toolbar)) {
413
+ log_error("C_YUI_SHELL: config.toolbar must be an object");
414
+ ok = false;
415
+ } else if(config.toolbar.items !== undefined &&
416
+ !is_array(config.toolbar.items)) {
417
+ log_error("C_YUI_SHELL: config.toolbar.items must be an array");
418
+ ok = false;
419
+ } else if(is_array(config.toolbar.items)) {
420
+ /* Per-item shape check: brand needs logo+wordmark, dropdown
421
+ * needs items[], action.type must be one of the known set,
422
+ * etc. Surfaced as warnings (additive contract: legacy
423
+ * configs without `type` keep working as type:"action"). */
424
+ for(let it of config.toolbar.items) {
425
+ let r = validate_toolbar_item(it);
426
+ if(!r.ok) {
427
+ for(let w of r.warnings) {
428
+ log_warning(`C_YUI_SHELL: ${w}`);
429
+ }
430
+ }
431
+ }
432
+ }
433
+ }
434
+
435
+ /* Route uniqueness: a route declared in two different menus is a
436
+ * source of subtle bugs (build_item_index has a "first wins"
437
+ * rule, so the second declaration is silently shadowed). Warn
438
+ * loudly. */
439
+ if(is_object(config.menu)) {
440
+ let route_owner = {};
441
+ for(let menu_id in config.menu) {
442
+ let m = config.menu[menu_id];
443
+ if(!m || !is_array(m.items)) {
444
+ continue;
445
+ }
446
+ for(let it of m.items) {
447
+ check_route_unique(route_owner, it, menu_id);
448
+ if(it.submenu && is_array(it.submenu.items)) {
449
+ for(let sub of it.submenu.items) {
450
+ check_route_unique(route_owner, sub, menu_id);
451
+ }
452
+ }
453
+ }
454
+ }
455
+ }
456
+
457
+ return ok;
458
+ }
459
+
460
+ /************************************************************
461
+ * Helper for validate_config — only warn when TWO different
462
+ * menus both declare a `target` for the same route. Items
463
+ * without a `target` are just navigators (they delegate to
464
+ * whichever menu owns the route) and do not compete, so the
465
+ * legitimate "menu A navigates / menu B owns" pattern stays
466
+ * silent.
467
+ ************************************************************/
468
+ function check_route_unique(route_owner, item, menu_id)
469
+ {
470
+ if(!item || empty_string(item.route)) {
471
+ return;
472
+ }
473
+ if(!item.target) {
474
+ return;
475
+ }
476
+ let route = item.route;
477
+ if(route_owner[route] !== undefined && route_owner[route] !== menu_id) {
478
+ log_warning(
479
+ `C_YUI_SHELL: route '${route}' has a target in menu ` +
480
+ `'${menu_id}' AND in menu '${route_owner[route]}' — the ` +
481
+ `second target is shadowed (build_item_index keeps the ` +
482
+ `first entry that owns a target)`
483
+ );
484
+ return;
485
+ }
486
+ route_owner[route] = menu_id;
487
+ }
488
+
489
+ /************************************************************
490
+ * Build the DOM: layers → base → zones
491
+ ************************************************************/
492
+ function build_ui(gobj)
493
+ {
494
+ let priv = gobj_read_attr(gobj, "priv");
495
+ let config = gobj_read_attr(gobj, "config") || {};
496
+ let shell_cfg = config.shell || {};
497
+
498
+ /* Root */
499
+ let $container = createElement2(
500
+ ["div", {class: "C_YUI_SHELL yui-shell"}]
501
+ );
502
+
503
+ /* Build layers */
504
+ for(let [id, z] of LAYER_DEFS) {
505
+ let $layer = createElement2(
506
+ ["div", {class: `yui-layer yui-layer-${id}`, style: `z-index:${z};`}]
507
+ );
508
+ $container.appendChild($layer);
509
+ priv.layers[id] = $layer;
510
+ }
511
+
512
+ /* Base layer holds the grid of zones */
513
+ let $base = priv.layers.base;
514
+ $base.classList.add("yui-base-grid");
515
+
516
+ let zones_cfg = shell_cfg.zones || {};
517
+
518
+ for(let id of ZONE_IDS) {
519
+ let $z = createElement2(
520
+ ["div", {class: `yui-zone yui-zone-${id}`,
521
+ "data-zone": id,
522
+ style: `grid-area: ${zone_grid_area(id)};`}]
523
+ );
524
+ apply_show_on($z, (zones_cfg[id] && zones_cfg[id].show_on) || "");
525
+ priv.zones[id] = $z;
526
+ $base.appendChild($z);
527
+ }
528
+
529
+ /* Register stages: zones hosting routed gobjs.
530
+ * Declared via zones[zone].host === "stage.<name>" or shell.stages.<name>.zone === <zone>.
531
+ */
532
+ let stages_cfg = shell_cfg.stages || {};
533
+ for(let stage_name in stages_cfg) {
534
+ let zone = stages_cfg[stage_name].zone || "center";
535
+ priv.stages[stage_name] = {
536
+ el: priv.zones[zone],
537
+ items: {},
538
+ active_route: null
539
+ };
540
+ if(priv.zones[zone]) {
541
+ priv.zones[zone].classList.add("yui-stage", `yui-stage-${stage_name}`);
542
+ }
543
+ }
544
+ /* Infer main stage from center zone host if not explicitly declared. */
545
+ for(let id in zones_cfg) {
546
+ let host = zones_cfg[id].host || "";
547
+ let m = /^stage\.(.+)$/.exec(host);
548
+ if(m) {
549
+ let name = m[1];
550
+ if(!priv.stages[name]) {
551
+ priv.stages[name] = { el: priv.zones[id], items: {}, active_route: null };
552
+ if(priv.zones[id]) {
553
+ priv.zones[id].classList.add("yui-stage", `yui-stage-${name}`);
554
+ }
555
+ }
556
+ }
557
+ }
558
+ if(!priv.stages.main && priv.zones.center) {
559
+ priv.stages.main = { el: priv.zones.center, items: {}, active_route: null };
560
+ priv.zones.center.classList.add("yui-stage", "yui-stage-main");
561
+ }
562
+
563
+ /* Mount */
564
+ let $mount = gobj_read_attr(gobj, "mount_element") || document.body;
565
+ $mount.appendChild($container);
566
+
567
+ gobj_write_attr(gobj, "$container", $container);
568
+ }
569
+
570
+ /************************************************************
571
+ * Translate zone id to grid-area name
572
+ ************************************************************/
573
+ function zone_grid_area(zone_id)
574
+ {
575
+ switch(zone_id) {
576
+ case "top": return "top";
577
+ case "top-sub": return "topsub";
578
+ case "left": return "left";
579
+ case "center": return "center";
580
+ case "right": return "right";
581
+ case "bottom-sub": return "botsub";
582
+ case "bottom": return "bottom";
583
+ }
584
+ return "";
585
+ }
586
+
587
+ /************************************************************
588
+ * Translate "show_on" expression to Bulma is-hidden-* classes.
589
+ * ">=desktop" → hide on touch
590
+ * "<desktop" → hide on desktop+
591
+ * ">=tablet" → hide on mobile
592
+ * "<tablet" → hide on tablet+
593
+ * "mobile|tablet" → list form (OR of breakpoints)
594
+ * "" → always visible
595
+ ************************************************************/
596
+ function apply_show_on($el, expr)
597
+ {
598
+ if(empty_string(expr)) {
599
+ return;
600
+ }
601
+ let visible = breakpoints_from_expr(expr);
602
+ for(let bp of BULMA_BP_ORDER) {
603
+ if(!visible[bp]) {
604
+ $el.classList.add(bulma_hidden_class(bp));
605
+ }
606
+ }
607
+ $el.setAttribute("data-show-on", expr);
608
+ }
609
+
610
+ /************************************************************
611
+ * Precompute: route → { item, parent_item, stage, target }
612
+ ************************************************************/
613
+ function build_item_index(gobj, config)
614
+ {
615
+ let priv = gobj_read_attr(gobj, "priv");
616
+ priv.item_index = {};
617
+
618
+ let menus = (config.menu) || {};
619
+ for(let menu_id in menus) {
620
+ let menu = menus[menu_id];
621
+ let items = (menu && menu.items) || [];
622
+ for(let item of items) {
623
+ if(item.route) {
624
+ /* A later menu must NOT clobber an earlier entry that
625
+ * has a valid target with one that has none. This is
626
+ * the common case where a `quick` drawer just reuses
627
+ * routes declared (with target) in `primary.submenu`.
628
+ * Rule: prefer the first entry with a target. */
629
+ let prev = priv.item_index[item.route];
630
+ if(!prev || (!prev.target && item.target)) {
631
+ priv.item_index[item.route] = {
632
+ item: item,
633
+ parent_item: null,
634
+ stage: item.target && item.target.stage || null,
635
+ target: item.target || null,
636
+ menu_id: menu_id
637
+ };
638
+ }
639
+ }
640
+ let sub = item.submenu;
641
+ if(sub && is_array(sub.items)) {
642
+ for(let sub_item of sub.items) {
643
+ if(sub_item.route) {
644
+ let prev = priv.item_index[sub_item.route];
645
+ if(!prev || (!prev.target && sub_item.target)) {
646
+ priv.item_index[sub_item.route] = {
647
+ item: sub_item,
648
+ parent_item: item,
649
+ stage: sub_item.target && sub_item.target.stage || null,
650
+ target: sub_item.target || null,
651
+ menu_id: menu_id
652
+ };
653
+ }
654
+ }
655
+ }
656
+ }
657
+ }
658
+ }
659
+
660
+ /* Explicit route table: authoritative source for routes that are
661
+ * not a left-menu item — action routes (kind:"action"), forms
662
+ * reachable only from the toolbar/dropdown, and the root "/".
663
+ * Toolbar/dropdown items just carry action:{type:"navigate",
664
+ * route}; their TARGET (view gclass, or kind:"action" event +
665
+ * redirect) lives here so the route resolves by URL on reload /
666
+ * deep-link. Same precedence as menus: only fill or upgrade an
667
+ * entry without a target — never clobber a menu's own target. */
668
+ let routes = (config.shell && config.shell.routes) || {};
669
+ for(let route in routes) {
670
+ let t = routes[route] || null;
671
+ let prev = priv.item_index[route];
672
+ if(!prev || (!prev.target && t)) {
673
+ priv.item_index[route] = {
674
+ item: (prev && prev.item) || null,
675
+ parent_item: (prev && prev.parent_item) || null,
676
+ stage: (t && t.stage) || null,
677
+ target: t,
678
+ menu_id: (prev && prev.menu_id) || ""
679
+ };
680
+ }
681
+ }
682
+ }
683
+
684
+ /************************************************************
685
+ * For each menu declared: for each zone hosting it, create
686
+ * a C_YUI_NAV that renders it with that zone's style.
687
+ ************************************************************/
688
+ function instantiate_menus(gobj, config)
689
+ {
690
+ let priv = gobj_read_attr(gobj, "priv");
691
+ let zones_cfg = (config.shell && config.shell.zones) || {};
692
+ let menus = config.menu || {};
693
+
694
+ /* Invert zones_cfg: which zones host "menu.<id>" */
695
+ let zones_for_menu = {};
696
+ for(let zone_id in zones_cfg) {
697
+ let host = zones_cfg[zone_id].host || "";
698
+ let m = /^menu\.(.+)$/.exec(host);
699
+ if(m) {
700
+ let menu_id = m[1];
701
+ (zones_for_menu[menu_id] = zones_for_menu[menu_id] || []).push(zone_id);
702
+ }
703
+ }
704
+
705
+ /* Drawer menus are overlays: they don't need a host in the zone grid.
706
+ * Any menu with render[zone].layout === "drawer" is instantiated too;
707
+ * instantiate_nav_in_zone() will mount it on the overlay layer. */
708
+ for(let menu_id in menus) {
709
+ let r = (menus[menu_id] && menus[menu_id].render) || {};
710
+ for(let zone_id in r) {
711
+ let cfg = r[zone_id];
712
+ let layout = is_string(cfg) ? cfg : (cfg && cfg.layout);
713
+ if(layout === "drawer") {
714
+ let list = (zones_for_menu[menu_id] = zones_for_menu[menu_id] || []);
715
+ if(list.indexOf(zone_id) < 0) {
716
+ list.push(zone_id);
717
+ }
718
+ }
719
+ }
720
+ }
721
+
722
+ for(let menu_id in zones_for_menu) {
723
+ let menu = menus[menu_id];
724
+ if(!menu) {
725
+ continue;
726
+ }
727
+ for(let zone_id of zones_for_menu[menu_id]) {
728
+ instantiate_nav_in_zone(gobj, menu, menu_id, zone_id, "primary");
729
+ }
730
+ }
731
+
732
+ /* Secondary navs: create one per primary-style menu item that
733
+ * declares a submenu with its own render block, for every zone
734
+ * listed in `submenu.render`. Initially hidden, shown when the
735
+ * owning primary item becomes active.
736
+ *
737
+ * We walk every menu mounted via a zone host of the form
738
+ * "menu.<id>" — not just menus.primary. The synthesized
739
+ * sub_menu_id is `secondary.<menu_id>.<item.id>` so two
740
+ * menus can have items with the same id without colliding. */
741
+ for(let menu_id in zones_for_menu) {
742
+ let menu = menus[menu_id];
743
+ if(!menu || !is_array(menu.items)) {
744
+ continue;
745
+ }
746
+ for(let item of menu.items) {
747
+ let sub = item.submenu;
748
+ if(!sub || !is_array(sub.items)) {
749
+ continue;
750
+ }
751
+ let render_by_zone = sub.render || {};
752
+ for(let zone_id in render_by_zone) {
753
+ if(zone_id === "*") {
754
+ continue;
755
+ }
756
+ let layout = render_by_zone[zone_id];
757
+ if(!priv.zones[zone_id]) {
758
+ log_warning(
759
+ `C_YUI_SHELL: submenu of '${menu_id}.${item.id}' ` +
760
+ `renders in unknown zone '${zone_id}'`
761
+ );
762
+ continue;
763
+ }
764
+ let submenu_def = {
765
+ items: sub.items,
766
+ render: { [zone_id]: render_to_obj(layout) }
767
+ };
768
+ let sub_menu_id = `secondary.${menu_id}.${item.id}`;
769
+ let nav = instantiate_nav_in_zone(
770
+ gobj, submenu_def, sub_menu_id, zone_id, "secondary",
771
+ item.name || ""
772
+ );
773
+ /* Hidden until owning primary is active. */
774
+ let $c = gobj_read_attr(nav, "$container");
775
+ if($c) {
776
+ $c.classList.add("is-hidden");
777
+ }
778
+ }
779
+ }
780
+ }
781
+ }
782
+
783
+ /* Accept shorthand "tabs"|"vertical"|... as well as object form */
784
+ function render_to_obj(layout)
785
+ {
786
+ if(is_string(layout)) {
787
+ return { layout: layout };
788
+ }
789
+ if(is_object(layout)) {
790
+ return layout;
791
+ }
792
+ return { layout: "vertical" };
793
+ }
794
+
795
+ function instantiate_nav_in_zone(gobj, menu, menu_id, zone_id, level, nav_label)
796
+ {
797
+ let priv = gobj_read_attr(gobj, "priv");
798
+ let render_cfg = (menu.render && (menu.render[zone_id] || menu.render["*"])) ||
799
+ { layout: "vertical" };
800
+ if(is_string(render_cfg)) {
801
+ render_cfg = { layout: render_cfg };
802
+ }
803
+
804
+ let nav_name = `nav_${menu_id.replace(/\./g, "_")}_${zone_id}`;
805
+ let layout = render_cfg.layout || "vertical";
806
+ let nav = gobj_create(
807
+ nav_name,
808
+ "C_YUI_NAV",
809
+ {
810
+ menu_id: menu_id,
811
+ nav_label: nav_label || "",
812
+ menu_items: menu.items || [],
813
+ zone: zone_id,
814
+ layout: layout,
815
+ icon_pos: render_cfg.icon_pos || default_icon_pos(zone_id),
816
+ show_label: render_cfg.show_label !== false,
817
+ level: level,
818
+ shell: gobj
819
+ },
820
+ gobj
821
+ );
822
+
823
+ /* Drawers are position:fixed full-screen overlays: mount them on the
824
+ * overlay layer, not inside the zone grid cell (which may be display:
825
+ * none at some breakpoints and would hide the drawer). The zone still
826
+ * serves as a declarative anchor in the config. */
827
+ let $c = gobj_read_attr(nav, "$container");
828
+ if($c) {
829
+ if(layout === "drawer" && priv.layers.overlay) {
830
+ priv.layers.overlay.appendChild($c);
831
+ } else if(priv.zones[zone_id]) {
832
+ priv.zones[zone_id].appendChild($c);
833
+ }
834
+ }
835
+
836
+ /* The CHILD subscription model in C_YUI_NAV.mt_create already
837
+ * subscribes the parent (us) to EV_NAV_CLICKED, so no explicit
838
+ * call is needed here. */
839
+
840
+ gobj_start(nav);
841
+ priv.navs.push(nav);
842
+ return nav;
843
+ }
844
+
845
+ function default_icon_pos(zone_id)
846
+ {
847
+ if(zone_id === "bottom" || zone_id === "top") {
848
+ return "top";
849
+ }
850
+ if(zone_id === "left" || zone_id === "right") {
851
+ return "left";
852
+ }
853
+ if(zone_id === "top-sub" || zone_id === "bottom-sub") {
854
+ return "left";
855
+ }
856
+ return "left";
857
+ }
858
+
859
+ /************************************************************
860
+ * Hash <-> route
861
+ ************************************************************/
862
+ function hash_to_route(hash)
863
+ {
864
+ if(!hash) {
865
+ return "";
866
+ }
867
+ let s = String(hash).replace(/^#/, "");
868
+ if(s.charAt(0) !== "/") {
869
+ s = "/" + s;
870
+ }
871
+ return s;
872
+ }
873
+
874
+ function route_to_hash(route)
875
+ {
876
+ if(!route) {
877
+ return "";
878
+ }
879
+ let s = route.charAt(0) === "/" ? route : "/" + route;
880
+ return "#" + s;
881
+ }
882
+
883
+ /************************************************************
884
+ * Navigate: make `route` active
885
+ ************************************************************/
886
+ function navigate_to(gobj, route)
887
+ {
888
+ let priv = gobj_read_attr(gobj, "priv");
889
+
890
+ /* Audit witness: publish the navigation intent FIRST, before any
891
+ * validation or DOM work. This guarantees that the FSM trace and
892
+ * any subscribed auditor see every requested route — including
893
+ * rerouted submenu defaults and routes that ultimately fail. */
894
+ gobj_publish_event(gobj, "EV_ROUTE_REQUESTED", {
895
+ route: route,
896
+ from: gobj_read_attr(gobj, "current_route") || ""
897
+ });
898
+
899
+ if(empty_string(route)) {
900
+ log_error("C_YUI_SHELL: navigate_to called with empty route");
901
+ return;
902
+ }
903
+ let entry = priv.item_index[route];
904
+
905
+ /* Route level 1 only: if it has a submenu, navigate to its default subitem.
906
+ * Skip decorative items (`type:"header"`, `type:"divider"`) — they have
907
+ * no `route`, so the first *navigable* child is used as the fallback.
908
+ * Done on the EXACT entry, BEFORE the ancestor walk, so a submenu
909
+ * parent (e.g. `/system`) is never swallowed by the root `/`. */
910
+ if(entry && !entry.target && entry.item && entry.item.submenu) {
911
+ let sub = entry.item.submenu;
912
+ let first_routable = sub.items && sub.items.find(it => it && it.route);
913
+ let default_sub = sub.default || (first_routable && first_routable.route);
914
+ if(default_sub) {
915
+ return navigate_to(gobj, default_sub);
916
+ }
917
+ }
918
+
919
+ /* Pure resolution: exact target, or nearest declared ancestor
920
+ * (`/a/b/c` under declared `/a/b`) + the trailing `subpath`.
921
+ * See resolve_route() for the contract (root `/` only matches
922
+ * exactly, never as an ancestor catch-all). */
923
+ let r = resolve_route(priv.item_index, route);
924
+ entry = r.entry;
925
+ let matched_route = r.matched_route;
926
+ let subpath = r.subpath;
927
+
928
+ if(!entry || !entry.target) {
929
+ /* Unknown route → fall back to the default route (standard
930
+ * SPA behaviour). Resilient to stale/foreign hashes:
931
+ * bookmarks, old deep links, or a legacy component that
932
+ * wrote `#<gobj>?<sub>` into the URL before navigation was
933
+ * made self-contained. Only dead-end in a placeholder when
934
+ * the default is itself unresolvable (a real misconfig). */
935
+ let config = gobj_read_attr(gobj, "config") || {};
936
+ let def = gobj_read_attr(gobj, "default_route") ||
937
+ (config.shell && config.shell.stages &&
938
+ config.shell.stages.main &&
939
+ config.shell.stages.main.default_route) || "";
940
+ if(!empty_string(def) && def !== route) {
941
+ log_warning(
942
+ `C_YUI_SHELL: unknown route '${route}', ` +
943
+ `redirecting to default '${def}'`
944
+ );
945
+ return navigate_to(gobj, def);
946
+ }
947
+ log_error(`C_YUI_SHELL: no target for route '${route}'`);
948
+ show_stage_placeholder(
949
+ gobj, "main",
950
+ `C_YUI_SHELL: route '${route}' is not declared in any menu item`
951
+ );
952
+ return;
953
+ }
954
+
955
+ /* Action route (target.kind:"action") — fires an event. Most
956
+ * flavours are TRANSIENT (no view mounted, current_route not set
957
+ * to it).
958
+ * redirect:
959
+ * "<route>" → go there afterwards (e.g. logout → "/").
960
+ * "back" → return to the previous resting view route.
961
+ * "none"/"" → no navigation; just restore the URL to the
962
+ * previous resting route (the app takes over —
963
+ * e.g. logout tears the shell down itself).
964
+ * "stay" → OVERLAY action: fire the event (the app opens a
965
+ * modal/overlay) and KEEP the URL on this route so
966
+ * it is deep-linkable / bookmarkable. The URL is
967
+ * NOT restored; no view is mounted and
968
+ * current_route stays on the underlying resting
969
+ * view (the overlay floats above it). The app's
970
+ * overlay close path is responsible for
971
+ * history.back() so leaving it returns to that
972
+ * resting route. A direct deep-link / reload onto
973
+ * this route has no resting view yet: mount the
974
+ * default underneath first (that replaceState's the
975
+ * URL to the default), then re-push this hash so a
976
+ * later close → back lands on the default instead
977
+ * of exiting the app.
978
+ * EV_ROUTE_REQUESTED was already published above, so an auditor
979
+ * sees the action route intent too. */
980
+ if(entry.target.kind === "action") {
981
+ let t = entry.target;
982
+ if(!empty_string(t.event)) {
983
+ gobj_publish_event(gobj, t.event, t.kw || {});
984
+ }
985
+ let config = gobj_read_attr(gobj, "config") || {};
986
+ let prev = (priv.stages && priv.stages.main &&
987
+ priv.stages.main.active_route) ||
988
+ gobj_read_attr(gobj, "default_route") ||
989
+ (config.shell && config.shell.stages &&
990
+ config.shell.stages.main &&
991
+ config.shell.stages.main.default_route) || "/";
992
+ let redirect = t.redirect;
993
+ if(redirect === "stay") {
994
+ if(gobj_read_attr(gobj, "use_hash")) {
995
+ let has_resting = !!(priv.stages && priv.stages.main &&
996
+ priv.stages.main.active_route);
997
+ if(!has_resting && prev && prev !== route) {
998
+ /* Deep-link / reload straight onto the overlay
999
+ * route: bring up the default view underneath,
1000
+ * then push this hash back on top so close→back
1001
+ * returns to the default. */
1002
+ navigate_to(gobj, prev);
1003
+ let h = route_to_hash(route);
1004
+ try {
1005
+ window.history.pushState(null, "", h);
1006
+ } catch(e) {
1007
+ window.location.hash = h;
1008
+ }
1009
+ }
1010
+ /* else: reached via a click that already pushed this
1011
+ * hash — leave the URL exactly as the user sees it. */
1012
+ }
1013
+ return;
1014
+ }
1015
+ if(empty_string(redirect) || redirect === "none") {
1016
+ if(gobj_read_attr(gobj, "use_hash")) {
1017
+ let h = route_to_hash(prev);
1018
+ try {
1019
+ window.history.replaceState(null, "", h);
1020
+ } catch(e) {
1021
+ window.location.hash = h;
1022
+ }
1023
+ }
1024
+ return;
1025
+ }
1026
+ if(redirect === "back") {
1027
+ redirect = prev;
1028
+ }
1029
+ if(redirect === route) {
1030
+ log_error(
1031
+ `C_YUI_SHELL: action route '${route}' redirects to itself`
1032
+ );
1033
+ return;
1034
+ }
1035
+ return navigate_to(gobj, redirect);
1036
+ }
1037
+
1038
+ /* A fresh navigation clears any placeholder shown earlier. */
1039
+ clear_stage_placeholder(gobj, entry.stage || "main");
1040
+
1041
+ let stage_name = entry.stage || "main";
1042
+ let stage = priv.stages[stage_name];
1043
+ if(!stage) {
1044
+ log_error(`C_YUI_SHELL: stage '${stage_name}' not declared`);
1045
+ return;
1046
+ }
1047
+
1048
+ /* View instances are keyed by the BASE (declared) route so a
1049
+ * subpath-only change reuses the same view (no rebuild). */
1050
+ let prev_route = stage.active_route;
1051
+ if(prev_route && prev_route !== matched_route) {
1052
+ let prev_gobj = stage.items[prev_route];
1053
+ if(prev_gobj) {
1054
+ let $c = gobj_read_attr(prev_gobj, "$container");
1055
+ if($c) {
1056
+ $c.classList.add("is-hidden");
1057
+ }
1058
+ }
1059
+ /* lazy_destroy: drop previous on exit */
1060
+ let prev_entry = priv.item_index[prev_route];
1061
+ if(prev_entry && prev_entry.target && prev_entry.target.lifecycle === "lazy_destroy") {
1062
+ try {
1063
+ gobj_stop(prev_gobj);
1064
+ gobj_destroy(prev_gobj);
1065
+ } catch(e) {
1066
+ log_warning(`C_YUI_SHELL: lazy_destroy of '${prev_route}' failed: ${e}`);
1067
+ }
1068
+ delete stage.items[prev_route];
1069
+ }
1070
+ }
1071
+
1072
+ /* Show or create current (keyed by base route) */
1073
+ let cur = stage.items[matched_route];
1074
+ if(!cur) {
1075
+ cur = build_view_gobj(gobj, entry, matched_route, stage);
1076
+ if(!cur) {
1077
+ return;
1078
+ }
1079
+ stage.items[matched_route] = cur;
1080
+ }
1081
+ let $c = gobj_read_attr(cur, "$container");
1082
+ if($c) {
1083
+ $c.classList.remove("is-hidden");
1084
+ }
1085
+
1086
+ stage.active_route = matched_route;
1087
+ gobj_write_attr(gobj, "current_route", route);
1088
+
1089
+ /* Show/hide secondary navs according to parent item */
1090
+ update_secondary_nav_visibility(gobj, entry);
1091
+
1092
+ /* Any drawer that triggered (or merely sits open during) the navigation
1093
+ * is a transient overlay — closing it after the route change avoids it
1094
+ * sitting on top of the new view. */
1095
+ close_all_drawers(gobj);
1096
+
1097
+ /* Same logic for an open toolbar dropdown — once the navigation lands
1098
+ * on a new view, the panel is stale. No-op when nothing is open. */
1099
+ close_toolbar_dropdown(gobj);
1100
+
1101
+ /* Update hash silently */
1102
+ if(gobj_read_attr(gobj, "use_hash")) {
1103
+ let target_hash = route_to_hash(route);
1104
+ if(window.location.hash !== target_hash) {
1105
+ /* Using history.replaceState avoids extra hashchange fire. */
1106
+ try {
1107
+ window.history.replaceState(null, "", target_hash);
1108
+ } catch(e) {
1109
+ window.location.hash = target_hash;
1110
+ }
1111
+ }
1112
+ }
1113
+
1114
+ /* Broadcast. `menu_id` carries the owning primary menu so
1115
+ * primary navs can short-circuit when the route belongs to a
1116
+ * different menu — without it, two primary-style menus that
1117
+ * share an item id (legitimate per TODO #5) cross-highlight
1118
+ * each other. */
1119
+ gobj_publish_event(gobj, "EV_ROUTE_CHANGED", {
1120
+ route: route,
1121
+ base: matched_route,
1122
+ subpath: subpath,
1123
+ item: entry.item,
1124
+ parent_item: entry.parent_item,
1125
+ stage: stage_name,
1126
+ menu_id: entry.menu_id || ""
1127
+ });
1128
+ }
1129
+
1130
+ function build_view_gobj(gobj, entry, route, stage)
1131
+ {
1132
+ let target = entry.target;
1133
+ /* The deep-link tail is NOT injected into kw: gobj_create2
1134
+ * validates kw against the view's SDATA strictly, so an extra
1135
+ * key breaks every view that doesn't declare it. The shell
1136
+ * already broadcasts `subpath` in EV_ROUTE_CHANGED right after
1137
+ * mount (and on every later subpath-only change) — that single
1138
+ * mechanism is how a view owns its dynamic 3rd level. */
1139
+ let kw = target.kw || {};
1140
+ let name = target.name || `view_${safe_id(route)}`;
1141
+
1142
+ let view;
1143
+ try {
1144
+ view = gobj_create(name, target.gclass, kw, gobj);
1145
+ } catch(e) {
1146
+ log_error(`C_YUI_SHELL: gobj_create failed for ${target.gclass}: ${e}`);
1147
+ return null;
1148
+ }
1149
+
1150
+ /* Hard contract: every view gclass MUST expose a $container
1151
+ * HTMLElement by the time mt_create returns. If not, the shell
1152
+ * cannot mount or hide it — abort cleanly. */
1153
+ let $view = gobj_read_attr(view, "$container");
1154
+ if(!$view) {
1155
+ log_error(
1156
+ `C_YUI_SHELL: gclass '${target.gclass}' does not expose $container; ` +
1157
+ `the view is unusable — check its mt_create`
1158
+ );
1159
+ try {
1160
+ gobj_destroy(view);
1161
+ } catch(e) {
1162
+ log_warning(`C_YUI_SHELL: cleanup gobj_destroy failed for ${target.gclass}: ${e}`);
1163
+ }
1164
+ return null;
1165
+ }
1166
+ stage.el.appendChild($view);
1167
+ gobj_start(view);
1168
+ return view;
1169
+ }
1170
+
1171
+ function safe_id(s)
1172
+ {
1173
+ return String(s).replace(/[^a-zA-Z0-9_]/g, "_").replace(/^_+|_+$/g, "");
1174
+ }
1175
+
1176
+ /************************************************************
1177
+ * Toolbar — a small declarative bar mounted in whichever zone
1178
+ * declares `host: "toolbar"`.
1179
+ *
1180
+ * Item kinds (`type`):
1181
+ * "brand" — logo (img) + wordmark (text); typically anchors a
1182
+ * navigate action to the home route.
1183
+ * "avatar" — circular initials populated by an app-registered
1184
+ * provider callback (yui_shell_set_avatar_provider).
1185
+ * "action" — default; icon and/or label that triggers an action.
1186
+ *
1187
+ * Action types (item.action.type):
1188
+ * navigate { type:"navigate", route }
1189
+ * drawer { type:"drawer", op:"toggle"|"open"|"close", menu_id? }
1190
+ * event { type:"event", event, kw? }
1191
+ * dropdown { type:"dropdown", items[] } (toolbar-only)
1192
+ *
1193
+ * Per-item `show_on` is honoured: each rendered item is wrapped with
1194
+ * the same Bulma-helper logic as zones.
1195
+ ************************************************************/
1196
+ function build_toolbar(gobj, config)
1197
+ {
1198
+ let tb = config && config.toolbar;
1199
+ if(!tb || !is_array(tb.items)) {
1200
+ return;
1201
+ }
1202
+
1203
+ let priv = gobj_read_attr(gobj, "priv");
1204
+ let zone_id = tb.zone || find_toolbar_zone(config);
1205
+ let $zone = priv.zones[zone_id];
1206
+ if(!$zone) {
1207
+ log_warning(`C_YUI_SHELL: toolbar target zone '${zone_id}' not found`);
1208
+ return;
1209
+ }
1210
+
1211
+ /* Drop any avatar-node references from a previous build. Today
1212
+ * build_toolbar runs once per shell, but resetting here keeps
1213
+ * avatar_nodes in sync if a future rebuild path is added (mt_start
1214
+ * is the only caller now; a hot-reload helper would stale-leak
1215
+ * every span without this). */
1216
+ priv.avatar_nodes = [];
1217
+ priv.conn_nodes = [];
1218
+
1219
+ /* Toolbar labels follow the same i18n contract as nav labels:
1220
+ * every translatable text node carries `i18n: <canonical key>`,
1221
+ * which createElement2 maps to `data-i18n` on the rendered
1222
+ * element. Apps swap languages by calling
1223
+ * refresh_language(shell.$container, t) — no DOM rebuild here. */
1224
+ let toolbar_aria = tb.aria_label || "Toolbar";
1225
+ let $bar = createElement2(
1226
+ ["nav", {class: "yui-toolbar navbar",
1227
+ role: "navigation",
1228
+ "aria-label": toolbar_aria,
1229
+ "data-i18n-aria-label": toolbar_aria},
1230
+ [
1231
+ ["div", {class: "navbar-brand yui-toolbar-start"}],
1232
+ ["div", {class: "navbar-end yui-toolbar-end"}]
1233
+ ]
1234
+ ]
1235
+ );
1236
+ let $start = $bar.querySelector(".yui-toolbar-start");
1237
+ let $end = $bar.querySelector(".yui-toolbar-end");
1238
+
1239
+ for(let it of tb.items) {
1240
+ let parent = (it.align === "end") ? $end : $start;
1241
+ let kind = classify_toolbar_item(it);
1242
+
1243
+ let $item;
1244
+ if(kind === "brand") {
1245
+ $item = build_toolbar_brand_item(gobj, it);
1246
+ } else if(kind === "avatar") {
1247
+ $item = build_toolbar_avatar_item(gobj, it);
1248
+ } else if(kind === "connection") {
1249
+ $item = build_toolbar_connection_item(gobj, it);
1250
+ } else {
1251
+ $item = build_toolbar_action_item(gobj, it);
1252
+ }
1253
+ if(!$item) {
1254
+ continue;
1255
+ }
1256
+ /* Per-item visibility: same syntax/parser as zones. */
1257
+ apply_show_on($item, it.show_on || "");
1258
+ parent.appendChild($item);
1259
+ }
1260
+
1261
+ $zone.appendChild($bar);
1262
+ }
1263
+
1264
+ /************************************************************
1265
+ * Renderer for the default ("action") item kind.
1266
+ ************************************************************/
1267
+ function build_toolbar_action_item(gobj, it)
1268
+ {
1269
+ let children = [];
1270
+ if(!empty_string(it.icon)) {
1271
+ children.push(["span", {class: "icon"},
1272
+ ["i", {class: it.icon, "aria-hidden": "true"}]]);
1273
+ }
1274
+ if(!empty_string(it.name)) {
1275
+ children.push(["span", {class: "yui-toolbar-item-label", i18n: it.name},
1276
+ it.name]);
1277
+ }
1278
+
1279
+ let aria_key = it.aria_label || it.name || it.id || "";
1280
+ let i18n_aria = it.aria_label || it.name;
1281
+ let btn_attrs = {
1282
+ class: "navbar-item yui-toolbar-item is-unselectable",
1283
+ type: "button",
1284
+ "data-toolbar-item-id": it.id || "",
1285
+ "aria-label": aria_key
1286
+ };
1287
+ if(i18n_aria) {
1288
+ btn_attrs["data-i18n-aria-label"] = i18n_aria;
1289
+ }
1290
+ let action_type = (it.action && it.action.type) || "";
1291
+ if(action_type === "dropdown") {
1292
+ btn_attrs["aria-haspopup"] = "menu";
1293
+ btn_attrs["aria-expanded"] = "false";
1294
+ }
1295
+ /* Hover tooltip: prefer explicit `tooltip`, fall back to
1296
+ * `aria_label` (usually the same intent — e.g. "Search (Ctrl+F)").
1297
+ * Skip when both empty so we don't emit `title=""` noise.
1298
+ * Mirror the value in `data-i18n-title` so refresh_language()
1299
+ * can re-translate the tooltip on language switch. */
1300
+ let tip = it.tooltip || it.aria_label;
1301
+ if(tip) {
1302
+ btn_attrs.title = tip;
1303
+ btn_attrs["data-i18n-title"] = tip;
1304
+ }
1305
+ let $item = createElement2(
1306
+ ["button", btn_attrs, children]
1307
+ );
1308
+ $item.addEventListener("click", ev => {
1309
+ ev.preventDefault();
1310
+ handle_toolbar_action(gobj, it, $item);
1311
+ });
1312
+ attach_context_action(gobj, $item, it);
1313
+ return $item;
1314
+ }
1315
+
1316
+ /************************************************************
1317
+ * Renderer for type:"brand". Always logo (img) + wordmark
1318
+ * (text). Action defaults to navigate to the host's home
1319
+ * route; if missing, the brand is a passive label.
1320
+ ************************************************************/
1321
+ function build_toolbar_brand_item(gobj, it)
1322
+ {
1323
+ if(empty_string(it.logo) || empty_string(it.wordmark)) {
1324
+ log_warning(
1325
+ `C_YUI_SHELL: toolbar brand item '${it.id||"?"}' missing ` +
1326
+ `logo and/or wordmark — skipped`
1327
+ );
1328
+ return null;
1329
+ }
1330
+ let alt = it.alt || it.wordmark || "";
1331
+ let aria_key = it.aria_label || it.wordmark || it.id || "";
1332
+ let i18n_aria = it.aria_label || it.wordmark;
1333
+ let attrs = {
1334
+ class: "navbar-item yui-toolbar-item yui-toolbar-brand is-unselectable",
1335
+ "data-toolbar-item-id": it.id || "",
1336
+ "aria-label": aria_key
1337
+ };
1338
+ if(i18n_aria) {
1339
+ attrs["data-i18n-aria-label"] = i18n_aria;
1340
+ }
1341
+ let action_type = (it.action && it.action.type) || "";
1342
+ let tag = "button";
1343
+ if(action_type === "dropdown") {
1344
+ attrs["aria-haspopup"] = "menu";
1345
+ attrs["aria-expanded"] = "false";
1346
+ }
1347
+ if(action_type === "") {
1348
+ /* Passive brand: no action — render a div so it is not
1349
+ * keyboard-focused and screen readers don't announce a
1350
+ * pressable control. */
1351
+ tag = "div";
1352
+ } else {
1353
+ attrs.type = "button";
1354
+ }
1355
+ let $item = createElement2([tag, attrs, [
1356
+ ["img", {class: "yui-toolbar-brand-logo",
1357
+ src: it.logo, alt: alt}],
1358
+ ["span", {class: "yui-toolbar-brand-wordmark", i18n: it.wordmark},
1359
+ it.wordmark]
1360
+ ]]);
1361
+ if(action_type !== "") {
1362
+ $item.addEventListener("click", ev => {
1363
+ ev.preventDefault();
1364
+ handle_toolbar_action(gobj, it, $item);
1365
+ });
1366
+ }
1367
+ return $item;
1368
+ }
1369
+
1370
+ /************************************************************
1371
+ * Renderer for type:"avatar". The <span> that holds the
1372
+ * initials is registered in priv.avatar_nodes so a single
1373
+ * refresh_avatars() call repaints every avatar after the host
1374
+ * swaps the provider.
1375
+ ************************************************************/
1376
+ function build_toolbar_avatar_item(gobj, it)
1377
+ {
1378
+ let priv = gobj_read_attr(gobj, "priv");
1379
+ let aria_key = it.aria_label || it.name || it.id || "User menu";
1380
+ let i18n_aria = it.aria_label || it.name || "User menu";
1381
+ let action_type = (it.action && it.action.type) || "";
1382
+ let attrs = {
1383
+ class: "navbar-item yui-toolbar-item yui-toolbar-avatar is-unselectable",
1384
+ type: "button",
1385
+ "data-toolbar-item-id": it.id || "",
1386
+ "aria-label": aria_key,
1387
+ "data-i18n-aria-label": i18n_aria
1388
+ };
1389
+ if(action_type === "dropdown") {
1390
+ attrs["aria-haspopup"] = "menu";
1391
+ attrs["aria-expanded"] = "false";
1392
+ }
1393
+ let tip = it.tooltip || it.aria_label;
1394
+ if(tip) {
1395
+ attrs.title = tip;
1396
+ attrs["data-i18n-title"] = tip;
1397
+ }
1398
+ let $initials = createElement2(["span", {class: "yui-avatar"}, ""]);
1399
+ let $item = createElement2(["button", attrs, [$initials]]);
1400
+ if(action_type !== "") {
1401
+ $item.addEventListener("click", ev => {
1402
+ ev.preventDefault();
1403
+ handle_toolbar_action(gobj, it, $item);
1404
+ });
1405
+ }
1406
+ /* Register and paint once with whatever provider exists today
1407
+ * (host may register it later via yui_shell_set_avatar_provider). */
1408
+ priv.avatar_nodes.push($initials);
1409
+ paint_avatar(priv, $initials);
1410
+ return $item;
1411
+ }
1412
+
1413
+ /* Read initials from the registered provider (if any) and write them
1414
+ * into a single avatar node. The provider is a host-supplied callback
1415
+ * () => string; gobj-ui never reaches into localStorage or app attrs. */
1416
+ function paint_avatar(priv, $node)
1417
+ {
1418
+ let provider = priv && priv.avatar_provider;
1419
+ let s = "";
1420
+ if(typeof provider === "function") {
1421
+ try {
1422
+ s = String(provider() || "");
1423
+ } catch(e) {
1424
+ log_warning(`C_YUI_SHELL: avatar provider threw: ${e}`);
1425
+ s = "";
1426
+ }
1427
+ }
1428
+ $node.textContent = s;
1429
+ }
1430
+
1431
+ function refresh_avatars(gobj)
1432
+ {
1433
+ let priv = gobj_read_attr(gobj, "priv");
1434
+ if(!priv || !is_array(priv.avatar_nodes)) {
1435
+ return;
1436
+ }
1437
+ for(let $n of priv.avatar_nodes) {
1438
+ paint_avatar(priv, $n);
1439
+ }
1440
+ }
1441
+
1442
+ /************************************************************
1443
+ * Wire an optional secondary (right-click) action on a
1444
+ * toolbar item. Same action shape as `action`; used e.g. by
1445
+ * the connection indicator to open a dev panel on right-click.
1446
+ ************************************************************/
1447
+ function attach_context_action(gobj, $item, it)
1448
+ {
1449
+ if(!it || !it.context_action) {
1450
+ return;
1451
+ }
1452
+ $item.addEventListener("contextmenu", ev => {
1453
+ ev.preventDefault();
1454
+ handle_toolbar_action(
1455
+ gobj, {id: it.id, action: it.context_action}, $item
1456
+ );
1457
+ });
1458
+ }
1459
+
1460
+ /************************************************************
1461
+ * Renderer for type:"connection" — a small status dot that
1462
+ * reflects the backend connection. The host drives the state
1463
+ * via yui_shell_set_connection_state(shell, bool); the dot
1464
+ * <span> is registered in priv.conn_nodes. Optional `action`
1465
+ * (left-click) and `context_action` (right-click) are honored.
1466
+ ************************************************************/
1467
+ function build_toolbar_connection_item(gobj, it)
1468
+ {
1469
+ let priv = gobj_read_attr(gobj, "priv");
1470
+ let aria_key = it.aria_label || it.name || it.id || "backend connection";
1471
+ let i18n_aria = it.aria_label || it.name || "backend connection";
1472
+ let attrs = {
1473
+ class: "navbar-item yui-toolbar-item yui-toolbar-conn " +
1474
+ "is-unselectable is-disconnected",
1475
+ type: "button",
1476
+ "data-toolbar-item-id": it.id || "",
1477
+ "aria-label": aria_key,
1478
+ "data-i18n-aria-label": i18n_aria
1479
+ };
1480
+ let tip = it.tooltip || it.aria_label;
1481
+ if(tip) {
1482
+ attrs.title = tip;
1483
+ attrs["data-i18n-title"] = tip;
1484
+ }
1485
+ let $dot = createElement2(["span", {class: "yui-conn-dot"}, ""]);
1486
+ let $item = createElement2(["button", attrs, [$dot]]);
1487
+ let action_type = (it.action && it.action.type) || "";
1488
+ if(action_type !== "") {
1489
+ $item.addEventListener("click", ev => {
1490
+ ev.preventDefault();
1491
+ handle_toolbar_action(gobj, it, $item);
1492
+ });
1493
+ }
1494
+ attach_context_action(gobj, $item, it);
1495
+ priv.conn_nodes.push($item);
1496
+ return $item;
1497
+ }
1498
+
1499
+ function find_toolbar_zone(config)
1500
+ {
1501
+ let zones = (config && config.shell && config.shell.zones) || {};
1502
+ for(let z in zones) {
1503
+ if(zones[z].host === "toolbar") {
1504
+ return z;
1505
+ }
1506
+ }
1507
+ return "top";
1508
+ }
1509
+
1510
+ function handle_toolbar_action(gobj, item, $trigger)
1511
+ {
1512
+ let action = (item && item.action) || {};
1513
+ switch(action.type) {
1514
+ case "navigate":
1515
+ if(!empty_string(action.route)) {
1516
+ if(gobj_read_attr(gobj, "use_hash")) {
1517
+ let h = route_to_hash(action.route);
1518
+ if(window.location.hash !== h) {
1519
+ window.location.hash = h; /* fires hashchange */
1520
+ } else {
1521
+ /* Same hash: hashchange won't fire — navigate
1522
+ * explicitly so a re-click still acts (e.g.
1523
+ * toggling a redirect:"stay" overlay whose URL
1524
+ * is already this route). Mirrors
1525
+ * ac_nav_clicked. */
1526
+ navigate_to(gobj, action.route);
1527
+ }
1528
+ } else {
1529
+ navigate_to(gobj, action.route);
1530
+ }
1531
+ }
1532
+ break;
1533
+ case "drawer": {
1534
+ let op = action.op || "toggle";
1535
+ let menu_id = action.menu_id || null;
1536
+ if(op === "open") {
1537
+ open_drawer(gobj, menu_id);
1538
+ }
1539
+ else if(op === "close") {
1540
+ close_drawer(gobj, menu_id);
1541
+ }
1542
+ else {
1543
+ toggle_drawer(gobj, menu_id);
1544
+ }
1545
+ break;
1546
+ }
1547
+ case "event":
1548
+ /* Publish whatever event name the JSON requested. The shell
1549
+ * is created with gcflag_no_check_output_events so it acts as
1550
+ * an intermediate that forwards arbitrary user-defined events
1551
+ * without each app having to extend our event_types table. */
1552
+ if(!empty_string(action.event)) {
1553
+ gobj_publish_event(gobj, action.event, action.kw || {});
1554
+ }
1555
+ break;
1556
+ case "dropdown":
1557
+ toggle_toolbar_dropdown(gobj, item, action, $trigger);
1558
+ break;
1559
+ default:
1560
+ log_warning(
1561
+ `C_YUI_SHELL: toolbar item '${item.id||"?"}' has no/unknown action.type`
1562
+ );
1563
+ }
1564
+ }
1565
+
1566
+ /************************************************************
1567
+ * Toolbar dropdown — open/close a panel anchored to the
1568
+ * trigger button. One dropdown at a time: a second click on
1569
+ * any trigger first closes whatever was open, even if the
1570
+ * trigger differs.
1571
+ ************************************************************/
1572
+ function toggle_toolbar_dropdown(gobj, item, action, $trigger)
1573
+ {
1574
+ let priv = gobj_read_attr(gobj, "priv");
1575
+ if(!priv) {
1576
+ return;
1577
+ }
1578
+ let already_open = priv.active_dropdown &&
1579
+ priv.active_dropdown.__yui_trigger__ === $trigger;
1580
+ close_toolbar_dropdown(gobj);
1581
+ if(already_open) {
1582
+ return;
1583
+ }
1584
+ open_toolbar_dropdown(gobj, item, action, $trigger);
1585
+ }
1586
+
1587
+ function open_toolbar_dropdown(gobj, item, action, $trigger)
1588
+ {
1589
+ let priv = gobj_read_attr(gobj, "priv");
1590
+ if(!priv || !priv.layers || !priv.layers.popup) {
1591
+ return;
1592
+ }
1593
+
1594
+ /* The panel mounts on priv.layers.popup, a sibling of the shell's
1595
+ * $container, and is (re)built lazily on every open — AFTER the
1596
+ * host's one-time refresh_language($container, t). So its i18n
1597
+ * text nodes (item names, aria) would never be translated: fine
1598
+ * in English where key == text, broken in any other locale.
1599
+ * The fix is below: when the host has registered a translator
1600
+ * (yui_shell_set_translator), re-apply it to the freshly built
1601
+ * $panel. Rebuilt per open ⇒ a later language switch is picked
1602
+ * up the next time the dropdown opens. */
1603
+ let aria_key = item && (item.aria_label || item.name || item.id) || "Menu";
1604
+ let i18n_aria = (item && (item.aria_label || item.name)) || "Menu";
1605
+ let $panel = createElement2(["div", {
1606
+ class: "yui-toolbar-dropdown-panel",
1607
+ role: "menu",
1608
+ "aria-label": aria_key,
1609
+ "data-i18n-aria-label": i18n_aria,
1610
+ "data-toolbar-dropdown-for": (item && item.id) || ""
1611
+ }]);
1612
+
1613
+ let raw_items = is_array(action.items) ? action.items : [];
1614
+ for(let i = 0; i < raw_items.length; i++) {
1615
+ let sub = raw_items[i];
1616
+ let $row = build_dropdown_row(gobj, sub, i);
1617
+ if(!$row) {
1618
+ continue;
1619
+ }
1620
+ apply_show_on($row, (sub && sub.show_on) || "");
1621
+ $panel.appendChild($row);
1622
+ }
1623
+
1624
+ /* Position fixed-anchored to the trigger. Right-aligned when the
1625
+ * trigger sits in the navbar-end half of the bar (heuristic:
1626
+ * .navbar-end ancestor); otherwise left-aligned. This keeps the
1627
+ * panel inside the viewport for both burger-side and user-side
1628
+ * triggers without app-level CSS hacks. */
1629
+ let rect = $trigger.getBoundingClientRect();
1630
+ let style_parts = ["position:fixed",
1631
+ `top:${Math.round(rect.bottom)}px`];
1632
+ let in_end = !!$trigger.closest(".navbar-end");
1633
+ if(in_end) {
1634
+ let right = window.innerWidth - rect.right;
1635
+ style_parts.push(`right:${Math.max(0, Math.round(right))}px`);
1636
+ } else {
1637
+ style_parts.push(`left:${Math.max(0, Math.round(rect.left))}px`);
1638
+ }
1639
+ $panel.setAttribute("style", style_parts.join(";"));
1640
+
1641
+ priv.layers.popup.appendChild($panel);
1642
+
1643
+ /* Translate the lazily-built panel (see the note above). */
1644
+ if(typeof priv.translator === "function") {
1645
+ refresh_language($panel, priv.translator);
1646
+ }
1647
+
1648
+ /* Click-outside (capture-phase mousedown) closes the dropdown.
1649
+ * Capture phase so a click on a sibling toolbar trigger lands
1650
+ * here BEFORE that trigger's own handler runs and reopens us. */
1651
+ let backdrop = ev => {
1652
+ if($panel.contains(ev.target)) {
1653
+ return;
1654
+ }
1655
+ if($trigger && $trigger.contains(ev.target)) {
1656
+ return; /* trigger click toggles via its own handler */
1657
+ }
1658
+ close_toolbar_dropdown(gobj);
1659
+ };
1660
+ document.addEventListener("mousedown", backdrop, true);
1661
+
1662
+ /* Scroll/resize: the panel anchor was frozen at open time from
1663
+ * getBoundingClientRect(); any layout shift drifts it from the
1664
+ * trigger. Match native <select> UX and dismiss on either.
1665
+ * Capture-phase + passive scroll so we hear all scrollers (any
1666
+ * ancestor, not just window) without blocking them. */
1667
+ let dismiss = () => close_toolbar_dropdown(gobj);
1668
+ document.addEventListener("scroll", dismiss, {capture: true, passive: true});
1669
+ window.addEventListener("resize", dismiss);
1670
+
1671
+ let close_fn = () => close_toolbar_dropdown(gobj);
1672
+ $panel.__yui_close_handler__ = close_fn;
1673
+ $panel.__yui_backdrop_handler__ = backdrop;
1674
+ $panel.__yui_dismiss_handler__ = dismiss;
1675
+ $panel.__yui_trigger__ = $trigger || null;
1676
+ push_escape(gobj, "popup", close_fn);
1677
+
1678
+ /* Focus trap inside the panel — same module modals/drawers use. */
1679
+ $panel.__yui_focus_release__ = activate_focus_trap_on($panel);
1680
+
1681
+ if($trigger) {
1682
+ $trigger.setAttribute("aria-expanded", "true");
1683
+ }
1684
+ priv.active_dropdown = $panel;
1685
+ }
1686
+
1687
+ function close_toolbar_dropdown(gobj)
1688
+ {
1689
+ let priv = gobj_read_attr(gobj, "priv");
1690
+ if(!priv) {
1691
+ return;
1692
+ }
1693
+ let $panel = priv.active_dropdown;
1694
+ if(!$panel) {
1695
+ return;
1696
+ }
1697
+ if($panel.__yui_focus_release__) {
1698
+ $panel.__yui_focus_release__();
1699
+ $panel.__yui_focus_release__ = null;
1700
+ }
1701
+ if($panel.__yui_close_handler__) {
1702
+ pop_escape(gobj, $panel.__yui_close_handler__);
1703
+ $panel.__yui_close_handler__ = null;
1704
+ }
1705
+ if($panel.__yui_backdrop_handler__) {
1706
+ document.removeEventListener("mousedown",
1707
+ $panel.__yui_backdrop_handler__, true);
1708
+ $panel.__yui_backdrop_handler__ = null;
1709
+ }
1710
+ if($panel.__yui_dismiss_handler__) {
1711
+ document.removeEventListener("scroll",
1712
+ $panel.__yui_dismiss_handler__,
1713
+ {capture: true});
1714
+ window.removeEventListener("resize", $panel.__yui_dismiss_handler__);
1715
+ $panel.__yui_dismiss_handler__ = null;
1716
+ }
1717
+ if($panel.__yui_trigger__) {
1718
+ $panel.__yui_trigger__.setAttribute("aria-expanded", "false");
1719
+ $panel.__yui_trigger__ = null;
1720
+ }
1721
+ if($panel.parentNode) {
1722
+ $panel.parentNode.removeChild($panel);
1723
+ }
1724
+ priv.active_dropdown = null;
1725
+ }
1726
+
1727
+ function build_dropdown_row(gobj, sub, idx)
1728
+ {
1729
+ if(!sub || typeof sub !== "object") {
1730
+ return null;
1731
+ }
1732
+ if(sub.type === "divider") {
1733
+ return createElement2(["div", {
1734
+ class: "yui-toolbar-dropdown-divider",
1735
+ role: "separator"
1736
+ }]);
1737
+ }
1738
+ if(!sub.action || typeof sub.action !== "object") {
1739
+ log_warning(
1740
+ `C_YUI_SHELL: dropdown item [${idx}] '${sub.id||"?"}' has no action — skipped`
1741
+ );
1742
+ return null;
1743
+ }
1744
+ let children = [];
1745
+ if(!empty_string(sub.icon)) {
1746
+ children.push(["span", {class: "icon"},
1747
+ ["i", {class: sub.icon, "aria-hidden": "true"}]]);
1748
+ }
1749
+ if(!empty_string(sub.name)) {
1750
+ children.push(["span", {class: "yui-toolbar-dropdown-label",
1751
+ i18n: sub.name}, sub.name]);
1752
+ }
1753
+ let aria_key = sub.aria_label || sub.name || sub.id || "";
1754
+ let i18n_aria = sub.aria_label || sub.name;
1755
+ let attrs = {
1756
+ class: "yui-toolbar-dropdown-item",
1757
+ type: "button",
1758
+ role: "menuitem",
1759
+ "data-dropdown-item-id": sub.id || "",
1760
+ "aria-label": aria_key
1761
+ };
1762
+ if(i18n_aria) {
1763
+ attrs["data-i18n-aria-label"] = i18n_aria;
1764
+ }
1765
+ let $btn = createElement2(["button", attrs, children]);
1766
+ $btn.addEventListener("click", ev => {
1767
+ ev.preventDefault();
1768
+ /* Close BEFORE dispatching: navigate may rebuild stage,
1769
+ * event may open a modal — either way the dropdown should
1770
+ * not linger on top. Nested dropdowns are rejected by
1771
+ * validate_dropdown_action so we don't recurse here. */
1772
+ close_toolbar_dropdown(gobj);
1773
+ handle_toolbar_action(gobj, sub, $btn);
1774
+ });
1775
+ return $btn;
1776
+ }
1777
+
1778
+ /************************************************************
1779
+ * Create all views whose item declares lifecycle:"eager".
1780
+ * They are mounted hidden; navigate_to() will reveal them.
1781
+ ************************************************************/
1782
+ function preinstantiate_eager_views(gobj)
1783
+ {
1784
+ let priv = gobj_read_attr(gobj, "priv");
1785
+ for(let route in priv.item_index) {
1786
+ let entry = priv.item_index[route];
1787
+ let t = entry.target;
1788
+ if(!t || t.lifecycle !== "eager") {
1789
+ continue;
1790
+ }
1791
+ let stage_name = entry.stage || "main";
1792
+ let stage = priv.stages[stage_name];
1793
+ if(!stage) {
1794
+ log_warning(`C_YUI_SHELL: eager view ${route} has no stage '${stage_name}'`);
1795
+ continue;
1796
+ }
1797
+ if(stage.items[route]) {
1798
+ continue; /* already built */
1799
+ }
1800
+ let view = build_view_gobj(gobj, entry, route, stage);
1801
+ if(!view) {
1802
+ continue;
1803
+ }
1804
+ stage.items[route] = view;
1805
+ let $c = gobj_read_attr(view, "$container");
1806
+ if($c) {
1807
+ $c.classList.add("is-hidden");
1808
+ }
1809
+ }
1810
+ }
1811
+
1812
+ /************************************************************
1813
+ * Drawer API — toggle/open/close a nav rendered with
1814
+ * layout:"drawer". menu_id is optional; if omitted, acts on
1815
+ * the first drawer found.
1816
+ ************************************************************/
1817
+ function drawers(gobj, menu_id)
1818
+ {
1819
+ let priv = gobj_read_attr(gobj, "priv");
1820
+ let out = [];
1821
+ for(let nav of priv.navs) {
1822
+ if(gobj_read_attr(nav, "layout") !== "drawer") {
1823
+ continue;
1824
+ }
1825
+ if(menu_id && gobj_read_attr(nav, "menu_id") !== menu_id) {
1826
+ continue;
1827
+ }
1828
+ let $c = gobj_read_attr(nav, "$container");
1829
+ if($c) {
1830
+ out.push($c);
1831
+ }
1832
+ }
1833
+ return out;
1834
+ }
1835
+
1836
+ /************************************************************
1837
+ * Escape priority chain helpers — push/pop a {layer, handler}
1838
+ * record on `priv.escape_stack`. Escape calls the top entry
1839
+ * only and consumes the event; LIFO ordering naturally matches
1840
+ * the z-index layering most apps use (drawer at the bottom,
1841
+ * modal on top, popup on top of that).
1842
+ ************************************************************/
1843
+ function push_escape(gobj, layer, handler)
1844
+ {
1845
+ let priv = gobj_read_attr(gobj, "priv");
1846
+ if(!priv || !priv.escape_stack) {
1847
+ return;
1848
+ }
1849
+ priv.escape_stack.push({ layer: layer, handler: handler });
1850
+ }
1851
+
1852
+ function pop_escape(gobj, handler)
1853
+ {
1854
+ let priv = gobj_read_attr(gobj, "priv");
1855
+ if(!priv || !priv.escape_stack) {
1856
+ return;
1857
+ }
1858
+ let idx = priv.escape_stack.findIndex(e => e.handler === handler);
1859
+ if(idx >= 0) {
1860
+ priv.escape_stack.splice(idx, 1);
1861
+ }
1862
+ }
1863
+
1864
+ /* Per-drawer open/close. The escape-stack entry and the focus-
1865
+ * trap release function are parked on the $drawer DOM element so
1866
+ * any close path (Escape, backdrop click, toolbar action, public
1867
+ * yui_shell_close_drawer) tears them down through the same code.
1868
+ *
1869
+ * The actual focus-trap is the generic helper from
1870
+ * shell_focus_trap.js — same module modals/popups use. */
1871
+ function open_drawer_one(gobj, $c)
1872
+ {
1873
+ if($c.classList.contains("is-active")) {
1874
+ return;
1875
+ }
1876
+ let close_fn = () => close_drawer_one(gobj, $c);
1877
+ $c.__yui_close_handler__ = close_fn;
1878
+ push_escape(gobj, "overlay", close_fn);
1879
+ $c.classList.add("is-active");
1880
+ let panel = $c.querySelector(".yui-drawer-panel") || $c;
1881
+ $c.__yui_focus_release__ = activate_focus_trap_on(panel);
1882
+ }
1883
+
1884
+ function close_drawer_one(gobj, $c)
1885
+ {
1886
+ if(!$c.classList.contains("is-active")) {
1887
+ return;
1888
+ }
1889
+ $c.classList.remove("is-active");
1890
+ if($c.__yui_focus_release__) {
1891
+ $c.__yui_focus_release__();
1892
+ $c.__yui_focus_release__ = null;
1893
+ }
1894
+ if($c.__yui_close_handler__) {
1895
+ pop_escape(gobj, $c.__yui_close_handler__);
1896
+ $c.__yui_close_handler__ = null;
1897
+ }
1898
+ }
1899
+
1900
+ function open_drawer(gobj, menu_id)
1901
+ {
1902
+ for(let $c of drawers(gobj, menu_id)) {
1903
+ open_drawer_one(gobj, $c);
1904
+ }
1905
+ }
1906
+
1907
+ function close_drawer(gobj, menu_id)
1908
+ {
1909
+ for(let $c of drawers(gobj, menu_id)) {
1910
+ close_drawer_one(gobj, $c);
1911
+ }
1912
+ }
1913
+
1914
+ function toggle_drawer(gobj, menu_id)
1915
+ {
1916
+ for(let $c of drawers(gobj, menu_id)) {
1917
+ if($c.classList.contains("is-active")) {
1918
+ close_drawer_one(gobj, $c);
1919
+ } else {
1920
+ open_drawer_one(gobj, $c);
1921
+ }
1922
+ }
1923
+ }
1924
+
1925
+ function close_all_drawers(gobj)
1926
+ {
1927
+ let priv = gobj_read_attr(gobj, "priv");
1928
+ if(!priv) {
1929
+ return;
1930
+ }
1931
+ for(let nav of priv.navs) {
1932
+ if(gobj_read_attr(nav, "layout") !== "drawer") {
1933
+ continue;
1934
+ }
1935
+ let $c = gobj_read_attr(nav, "$container");
1936
+ if(!$c) {
1937
+ continue;
1938
+ }
1939
+ close_drawer_one(gobj, $c);
1940
+ }
1941
+ }
1942
+
1943
+ /************************************************************
1944
+ * Render a visible placeholder in a stage (used when we have
1945
+ * nothing to show, e.g. no default route configured).
1946
+ ************************************************************/
1947
+ function show_stage_placeholder(gobj, stage_name, message)
1948
+ {
1949
+ let priv = gobj_read_attr(gobj, "priv");
1950
+ let stage = priv.stages[stage_name];
1951
+ if(!stage || !stage.el) {
1952
+ return;
1953
+ }
1954
+ clear_stage_placeholder(gobj, stage_name);
1955
+ let $msg = createElement2(
1956
+ ["div", {class: "yui-shell-placeholder notification is-warning is-light m-4"},
1957
+ ["p", {class: "is-size-6"}, message]
1958
+ ]
1959
+ );
1960
+ stage.el.appendChild($msg);
1961
+ }
1962
+
1963
+ function clear_stage_placeholder(gobj, stage_name)
1964
+ {
1965
+ let priv = gobj_read_attr(gobj, "priv");
1966
+ let stage = priv.stages[stage_name];
1967
+ if(!stage || !stage.el) {
1968
+ return;
1969
+ }
1970
+ let $old = stage.el.querySelector(":scope > .yui-shell-placeholder");
1971
+ if($old) {
1972
+ $old.parentNode.removeChild($old);
1973
+ }
1974
+ }
1975
+
1976
+ function update_secondary_nav_visibility(gobj, entry)
1977
+ {
1978
+ let priv = gobj_read_attr(gobj, "priv");
1979
+ /* shell.routes entries (root, forms, action routes) have no menu
1980
+ * item — entry.item / entry.parent_item are null. No active
1981
+ * primary then: the secondary zone collapses (has_secondary
1982
+ * below is false), which is correct for a standalone route. */
1983
+ let active_primary_id = (entry.parent_item && entry.parent_item.id)
1984
+ || (entry.item && entry.item.id)
1985
+ || "";
1986
+ let owning_menu_id = entry.menu_id || "";
1987
+ let target_secondary_id = `secondary.${owning_menu_id}.${active_primary_id}`;
1988
+
1989
+ /* Does the ACTIVE primary actually have a submenu? Decided from
1990
+ * the config (the item tree), NOT from whether a secondary nav
1991
+ * gobj has been instantiated — those are created lazily, so on a
1992
+ * route with no submenu (e.g. Monitor) there may be zero
1993
+ * secondary navs and a nav-derived check would never collapse
1994
+ * the zone (empty white strip under the toolbar). */
1995
+ let active_primary = entry.parent_item || entry.item || null;
1996
+ let has_secondary = !!(
1997
+ active_primary &&
1998
+ active_primary.submenu &&
1999
+ Array.isArray(active_primary.submenu.items) &&
2000
+ active_primary.submenu.items.some(it => it && it.route)
2001
+ );
2002
+
2003
+ /* Collapse every zone that hosts menu.secondary when the active
2004
+ * route has no submenu; reveal them otherwise. Zone set comes
2005
+ * from the declared config, so it works before any secondary
2006
+ * nav exists. */
2007
+ let config = gobj_read_attr(gobj, "config") || {};
2008
+ let zones_cfg = (config.shell && config.shell.zones) || {};
2009
+ for(let z in zones_cfg) {
2010
+ if(zones_cfg[z] && zones_cfg[z].host === "menu.secondary") {
2011
+ let $z = priv.zones[z];
2012
+ if($z) {
2013
+ $z.classList.toggle("is-hidden", !has_secondary);
2014
+ }
2015
+ }
2016
+ }
2017
+
2018
+ for(let nav of priv.navs) {
2019
+ let level = gobj_read_attr(nav, "level");
2020
+ if(level !== "secondary") {
2021
+ continue;
2022
+ }
2023
+ let nav_menu_id = gobj_read_attr(nav, "menu_id") || "";
2024
+ if(!nav_menu_id.startsWith("secondary.")) {
2025
+ continue;
2026
+ }
2027
+ let $c = gobj_read_attr(nav, "$container");
2028
+ if(!$c) {
2029
+ continue;
2030
+ }
2031
+ if(nav_menu_id === target_secondary_id) {
2032
+ $c.classList.remove("is-hidden");
2033
+ } else {
2034
+ $c.classList.add("is-hidden");
2035
+ }
2036
+ }
2037
+ }
2038
+
2039
+
2040
+
2041
+
2042
+ /***************************
2043
+ * Actions
2044
+ ***************************/
2045
+
2046
+
2047
+
2048
+
2049
+ /************************************************************
2050
+ * Click-through from a C_YUI_NAV child: we own routing here.
2051
+ ************************************************************/
2052
+ function ac_nav_clicked(gobj, event, kw, src)
2053
+ {
2054
+ let route = (kw && kw.route) || "";
2055
+ if(empty_string(route)) {
2056
+ return 0;
2057
+ }
2058
+
2059
+ /* When hash routing is on, let the hash drive navigate_to() — that
2060
+ * way back/forward buttons and programmatic hash changes all flow
2061
+ * through the same code path. Otherwise call navigate_to directly. */
2062
+ if(gobj_read_attr(gobj, "use_hash")) {
2063
+ let target_hash = route_to_hash(route);
2064
+ if(window.location.hash !== target_hash) {
2065
+ window.location.hash = target_hash; /* fires hashchange */
2066
+ } else {
2067
+ /* Same hash: hashchange won't fire — navigate explicitly. */
2068
+ navigate_to(gobj, route);
2069
+ }
2070
+ } else {
2071
+ navigate_to(gobj, route);
2072
+ }
2073
+ return 0;
2074
+ }
2075
+
2076
+ /************************************************************
2077
+ * Drawer backdrop click on a C_YUI_NAV child: the nav publishes
2078
+ * this so the shell can close the drawer through the canonical
2079
+ * flow (DOM + focus-trap + escape-stack pop) instead of mutating
2080
+ * the DOM directly from the nav.
2081
+ ************************************************************/
2082
+ function ac_drawer_close_requested(gobj, event, kw, src)
2083
+ {
2084
+ let menu_id = (kw && kw.menu_id) || "";
2085
+ close_drawer(gobj, menu_id);
2086
+ return 0;
2087
+ }
2088
+
2089
+ /************************************************************
2090
+ * Close 'x' on a closable tab (from a C_YUI_NAV child). The
2091
+ * shell does not own the item set — re-publish so the app (which
2092
+ * owns the underlying data, e.g. the selected-nodes list) removes
2093
+ * the item and calls yui_shell_set_submenu() with the new list.
2094
+ ************************************************************/
2095
+ function ac_nav_item_close(gobj, event, kw, src)
2096
+ {
2097
+ gobj_publish_event(gobj, "EV_NAV_ITEM_CLOSE", {
2098
+ item_id: (kw && kw.item_id) || "",
2099
+ route: (kw && kw.route) || "",
2100
+ menu_id: (kw && kw.menu_id) || "",
2101
+ zone: (kw && kw.zone) || ""
2102
+ });
2103
+ return 0;
2104
+ }
2105
+
2106
+ /************************************************************
2107
+ * Runtime nav API (Yuneta philosophy: the app_config path is the
2108
+ * first, startup caller of the very same machinery — build_item_index
2109
+ * + instantiate + set items; this is its dynamic counterpart).
2110
+ *
2111
+ * yui_shell_set_submenu(shell, parent_item_id, items) replaces the
2112
+ * items of a primary item's submenu (its secondary nav) at runtime
2113
+ * and re-registers their routes so navigation resolves. Item
2114
+ * descriptors may carry { id, name, icon, route, class, closable,
2115
+ * target:{stage,gclass,kw,lifecycle} }. Routes present before but
2116
+ * absent now are pruned (index entry removed, mounted view destroyed).
2117
+ ************************************************************/
2118
+ function find_secondary_nav(priv, parent_item_id)
2119
+ {
2120
+ let suffix = "." + parent_item_id;
2121
+ for(let nav of priv.navs) {
2122
+ let menu_id = gobj_read_attr(nav, "menu_id") || "";
2123
+ if(menu_id.startsWith("secondary.") && menu_id.endsWith(suffix)) {
2124
+ return nav;
2125
+ }
2126
+ }
2127
+ return null;
2128
+ }
2129
+
2130
+ function find_primary_item(shell_gobj, menu_id, item_id)
2131
+ {
2132
+ let config = gobj_read_attr(shell_gobj, "config") || {};
2133
+ let menu = (config.menu && config.menu[menu_id]) || null;
2134
+ if(menu && is_array(menu.items)) {
2135
+ for(let it of menu.items) {
2136
+ if(it && it.id === item_id) {
2137
+ return it;
2138
+ }
2139
+ }
2140
+ }
2141
+ return null;
2142
+ }
2143
+
2144
+ /* Drop a route from the index and destroy any mounted view for it. */
2145
+ function prune_route(shell_gobj, route)
2146
+ {
2147
+ let priv = gobj_read_attr(shell_gobj, "priv");
2148
+ delete priv.item_index[route];
2149
+ for(let stage_name in priv.stages) {
2150
+ let stage = priv.stages[stage_name];
2151
+ let view = stage.items && stage.items[route];
2152
+ if(!view) {
2153
+ continue;
2154
+ }
2155
+ let $c = gobj_read_attr(view, "$container");
2156
+ if($c) {
2157
+ $c.classList.add("is-hidden");
2158
+ }
2159
+ try {
2160
+ gobj_stop(view);
2161
+ gobj_destroy(view);
2162
+ } catch(e) {
2163
+ log_warning(`C_YUI_SHELL: prune_route destroy '${route}' failed: ${e}`);
2164
+ }
2165
+ delete stage.items[route];
2166
+ if(stage.active_route === route) {
2167
+ stage.active_route = "";
2168
+ }
2169
+ }
2170
+ }
2171
+
2172
+ function yui_shell_set_submenu(shell_gobj, parent_item_id, items)
2173
+ {
2174
+ let priv = gobj_read_attr(shell_gobj, "priv");
2175
+ if(!priv) {
2176
+ return -1;
2177
+ }
2178
+ let nav = find_secondary_nav(priv, parent_item_id);
2179
+ if(!nav) {
2180
+ log_warning(
2181
+ `C_YUI_SHELL: yui_shell_set_submenu — no secondary nav for '${parent_item_id}'`
2182
+ );
2183
+ return -1;
2184
+ }
2185
+ items = is_array(items) ? items : [];
2186
+
2187
+ /* Resolve owning menu_id + primary item from the nav's synthesized
2188
+ * id "secondary.<menu_id>.<parent_item_id>", so registered routes
2189
+ * highlight correctly and keep this secondary nav visible. */
2190
+ let nav_menu_id = gobj_read_attr(nav, "menu_id") || "";
2191
+ let m = /^secondary\.(.+)\.([^.]+)$/.exec(nav_menu_id);
2192
+ let owning_menu_id = m ? m[1] : "";
2193
+ let parent_item = find_primary_item(shell_gobj, owning_menu_id, parent_item_id);
2194
+
2195
+ /* Track the routes THIS submenu owns dynamically, so we only prune
2196
+ * our own previous routes — never the static config routes (e.g. a
2197
+ * base "/console/agent" landing declared in app_config). */
2198
+ priv.dynamic_routes = priv.dynamic_routes || {};
2199
+ let prev_routes = priv.dynamic_routes[parent_item_id] || [];
2200
+ let new_routes = [];
2201
+ let new_set = {};
2202
+ for(let it of items) {
2203
+ if(it && it.route) {
2204
+ new_routes.push(it.route);
2205
+ new_set[it.route] = true;
2206
+ }
2207
+ }
2208
+ for(let route of prev_routes) {
2209
+ if(!new_set[route]) {
2210
+ prune_route(shell_gobj, route);
2211
+ }
2212
+ }
2213
+
2214
+ /* Register / refresh routes for the new items. */
2215
+ for(let it of items) {
2216
+ if(!it || !it.route) {
2217
+ continue;
2218
+ }
2219
+ priv.item_index[it.route] = {
2220
+ item: it,
2221
+ parent_item: parent_item,
2222
+ stage: (it.target && it.target.stage) || null,
2223
+ target: it.target || null,
2224
+ menu_id: owning_menu_id
2225
+ };
2226
+ }
2227
+ priv.dynamic_routes[parent_item_id] = new_routes;
2228
+
2229
+ /* Push the new items into the nav (rebuilds its DOM in place). */
2230
+ gobj_send_event(nav, "EV_SET_ITEMS", {items: items}, shell_gobj);
2231
+ return 0;
2232
+ }
2233
+
2234
+ /***************************************************************
2235
+ * FSM
2236
+ ***************************************************************/
2237
+ /*---------------------------------------------*
2238
+ * Global methods table
2239
+ *---------------------------------------------*/
2240
+ const gmt = {
2241
+ mt_create: mt_create,
2242
+ mt_start: mt_start,
2243
+ mt_stop: mt_stop,
2244
+ mt_destroy: mt_destroy
2245
+ };
2246
+
2247
+ function create_gclass(gclass_name)
2248
+ {
2249
+ if(__gclass__) {
2250
+ log_error(`GClass ALREADY created: ${gclass_name}`);
2251
+ return -1;
2252
+ }
2253
+
2254
+ const states = [
2255
+ ["ST_IDLE", [
2256
+ /* Navigation requests flow through EV_NAV_CLICKED (emitted
2257
+ * by child C_YUI_NAVs) and through the window.hashchange
2258
+ * listener (see mt_start). The shell is the sole router. */
2259
+ ["EV_NAV_CLICKED", ac_nav_clicked, null],
2260
+ /* Drawer backdrop close (from a C_YUI_NAV child whose
2261
+ * layout is "drawer"). */
2262
+ ["EV_DRAWER_CLOSE_REQUESTED", ac_drawer_close_requested, null],
2263
+ /* Close 'x' on a closable tab (from a child C_YUI_NAV);
2264
+ * re-published for the app. */
2265
+ ["EV_NAV_ITEM_CLOSE", ac_nav_item_close, null]
2266
+ ]]
2267
+ ];
2268
+
2269
+ const event_types = [
2270
+ ["EV_NAV_CLICKED", 0],
2271
+ ["EV_DRAWER_CLOSE_REQUESTED", 0],
2272
+ ["EV_NAV_ITEM_CLOSE", event_flag_t.EVF_OUTPUT_EVENT
2273
+ |event_flag_t.EVF_PUBLIC_EVENT
2274
+ |event_flag_t.EVF_NO_WARN_SUBS],
2275
+ /* Audit witness: every navigation attempt publishes this BEFORE
2276
+ * any work is done, so the FSM trace records intent regardless
2277
+ * of whether the route resolves successfully. Pairs with
2278
+ * EV_ROUTE_CHANGED (the corresponding fact event). */
2279
+ ["EV_ROUTE_REQUESTED", event_flag_t.EVF_OUTPUT_EVENT
2280
+ |event_flag_t.EVF_PUBLIC_EVENT
2281
+ |event_flag_t.EVF_NO_WARN_SUBS],
2282
+ ["EV_ROUTE_CHANGED", event_flag_t.EVF_OUTPUT_EVENT
2283
+ |event_flag_t.EVF_PUBLIC_EVENT
2284
+ |event_flag_t.EVF_NO_WARN_SUBS]
2285
+ ];
2286
+
2287
+ __gclass__ = gclass_create(
2288
+ gclass_name,
2289
+ event_types,
2290
+ states,
2291
+ gmt,
2292
+ 0, /* lmt */
2293
+ attrs_table,
2294
+ PRIVATE_DATA,
2295
+ 0, /* authz_table */
2296
+ 0, /* command_table */
2297
+ 0, /* s_user_trace_level */
2298
+ gclass_flag_t.gcflag_no_check_output_events
2299
+ );
2300
+ if(!__gclass__) {
2301
+ return -1;
2302
+ }
2303
+ return 0;
2304
+ }
2305
+
2306
+ function register_c_yui_shell()
2307
+ {
2308
+ return create_gclass(GCLASS_NAME);
2309
+ }
2310
+
2311
+ /***************************************************************
2312
+ * Public helpers — exported alongside register_c_yui_shell().
2313
+ * Not gclass methods, no banner needed; left grouped at the
2314
+ * bottom of the file to keep the skeleton layout intact.
2315
+ ***************************************************************/
2316
+
2317
+ /************************************************************
2318
+ * Programmatic navigation (bypass hash).
2319
+ ************************************************************/
2320
+ function yui_shell_navigate(shell_gobj, route)
2321
+ {
2322
+ navigate_to(shell_gobj, route);
2323
+ }
2324
+
2325
+ /* Drawer helpers — toggle the off-canvas nav from the outside
2326
+ * (e.g. a hamburger button in the toolbar). menu_id is optional. */
2327
+ function yui_shell_open_drawer(shell_gobj, menu_id) { open_drawer(shell_gobj, menu_id); }
2328
+ function yui_shell_close_drawer(shell_gobj, menu_id) { close_drawer(shell_gobj, menu_id); }
2329
+ function yui_shell_toggle_drawer(shell_gobj, menu_id) { toggle_drawer(shell_gobj, menu_id); }
2330
+
2331
+ /* Escape priority chain — public API used by overlays that the
2332
+ * shell does not own (modals from #4, future popups, custom
2333
+ * app-level overlays). Drawer integration is built in.
2334
+ *
2335
+ * let close_fn = () => my_modal.close();
2336
+ * yui_shell_push_escape(shell, "modal", close_fn);
2337
+ * // ... when the modal closes by any path, also call:
2338
+ * yui_shell_pop_escape(shell, close_fn);
2339
+ *
2340
+ * `layer` is a free-form tag (e.g. "modal", "popup", "overlay").
2341
+ * Today it is informational; the LIFO ordering of the stack is
2342
+ * what determines Escape priority — and naturally matches the
2343
+ * z-index layering most apps use (drawer < popup < modal). */
2344
+ function yui_shell_push_escape(shell_gobj, layer, handler)
2345
+ {
2346
+ push_escape(shell_gobj, layer, handler);
2347
+ }
2348
+ function yui_shell_pop_escape(shell_gobj, handler)
2349
+ {
2350
+ pop_escape(shell_gobj, handler);
2351
+ }
2352
+
2353
+ /************************************************************
2354
+ * Avatar provider — toolbar items with type:"avatar" call the
2355
+ * registered provider whenever the shell paints initials. The
2356
+ * provider is a free-form () => string callback owned by the
2357
+ * host (wattyzer/hidraulia/estadodelaire), so gobj-ui never
2358
+ * reaches into localStorage or app-specific attrs. Setting the
2359
+ * provider repaints existing avatars in-place; calling
2360
+ * yui_shell_refresh_avatars() repaints without changing the
2361
+ * provider (e.g. after the user updates their profile name).
2362
+ ************************************************************/
2363
+ function yui_shell_set_avatar_provider(shell_gobj, provider)
2364
+ {
2365
+ let priv = gobj_read_attr(shell_gobj, "priv");
2366
+ if(!priv) {
2367
+ return;
2368
+ }
2369
+ priv.avatar_provider = (typeof provider === "function") ? provider : null;
2370
+ refresh_avatars(shell_gobj);
2371
+ }
2372
+
2373
+ function yui_shell_refresh_avatars(shell_gobj)
2374
+ {
2375
+ refresh_avatars(shell_gobj);
2376
+ }
2377
+
2378
+ /************************************************************
2379
+ * Register the host's i18n translator (a t-function:
2380
+ * key => translated string). The host still translates the
2381
+ * static shell tree itself via refresh_language($container, t);
2382
+ * this is only so the shell can translate DOM it builds LAZILY
2383
+ * and OUTSIDE $container — today the toolbar dropdown panel.
2384
+ * Optional: with no translator the panel renders raw keys
2385
+ * (the previous behaviour).
2386
+ ************************************************************/
2387
+ function yui_shell_set_translator(shell_gobj, t)
2388
+ {
2389
+ let priv = gobj_read_attr(shell_gobj, "priv");
2390
+ if(!priv) {
2391
+ return;
2392
+ }
2393
+ priv.translator = (typeof t === "function") ? t : null;
2394
+ }
2395
+
2396
+ /************************************************************
2397
+ * Set the backend-connection state painted by every
2398
+ * type:"connection" toolbar item. Host/event-driven: the
2399
+ * app calls this from its transport handlers (EV_ON_OPEN →
2400
+ * true, EV_ON_CLOSE / errors → false). Toggles the
2401
+ * is-connected / is-disconnected classes; CSS owns the look.
2402
+ ************************************************************/
2403
+ function yui_shell_set_connection_state(shell_gobj, connected)
2404
+ {
2405
+ let priv = gobj_read_attr(shell_gobj, "priv");
2406
+ if(!priv || !is_array(priv.conn_nodes)) {
2407
+ return;
2408
+ }
2409
+ let on = !!connected;
2410
+ for(let $n of priv.conn_nodes) {
2411
+ $n.classList.toggle("is-connected", on);
2412
+ $n.classList.toggle("is-disconnected", !on);
2413
+ }
2414
+ }
2415
+
2416
+ /************************************************************
2417
+ * Swap the icon of a toolbar item at runtime (host-driven,
2418
+ * like the avatar/connection helpers). Used e.g. for a
2419
+ * theme toggle that shows a sun in light / moon in dark.
2420
+ * `icon_class` fully replaces the <i> class.
2421
+ ************************************************************/
2422
+ function yui_shell_set_toolbar_item_icon(shell_gobj, item_id, icon_class)
2423
+ {
2424
+ let $container = gobj_read_attr(shell_gobj, "$container");
2425
+ if(!$container || empty_string(item_id) || empty_string(icon_class)) {
2426
+ return;
2427
+ }
2428
+ let $i = $container.querySelector(
2429
+ `[data-toolbar-item-id="${item_id}"] .icon i`
2430
+ );
2431
+ if($i) {
2432
+ $i.className = icon_class;
2433
+ }
2434
+ }
2435
+
2436
+ /************************************************************
2437
+ * Programmatic close of any open toolbar dropdown. Useful for
2438
+ * external triggers (e.g. EV_LOGOUT firing from elsewhere) that
2439
+ * want to dismiss whatever menu is on screen.
2440
+ ************************************************************/
2441
+ function yui_shell_close_dropdown(shell_gobj)
2442
+ {
2443
+ close_toolbar_dropdown(shell_gobj);
2444
+ }
2445
+
2446
+ /* Note: there is no shell-level language switch helper. Every
2447
+ * translatable text node rendered by the shell and its navs is
2448
+ * tagged with `data-i18n` (the canonical English key). Apps swap
2449
+ * language by calling
2450
+ * refresh_language(shell_$container, t)
2451
+ * from `@yuneta/gobj-js`, exactly like `c_yui_main.js` does in
2452
+ * `change_language()`. The shell does not own that flow — with
2453
+ * one exception: DOM the shell builds LAZILY and OUTSIDE
2454
+ * $container (the toolbar dropdown panel) is unreachable by that
2455
+ * call, so the app registers its translator via
2456
+ * yui_shell_set_translator() and the shell re-applies it per
2457
+ * panel build. */
2458
+
2459
+ export {
2460
+ register_c_yui_shell,
2461
+ yui_shell_navigate,
2462
+ yui_shell_open_drawer,
2463
+ yui_shell_close_drawer,
2464
+ yui_shell_toggle_drawer,
2465
+ yui_shell_push_escape,
2466
+ yui_shell_pop_escape,
2467
+ yui_shell_set_avatar_provider,
2468
+ yui_shell_refresh_avatars,
2469
+ yui_shell_set_translator,
2470
+ yui_shell_set_connection_state,
2471
+ yui_shell_set_toolbar_item_icon,
2472
+ yui_shell_close_dropdown,
2473
+ yui_shell_set_submenu
2474
+ };