@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,881 @@
1
+ /***********************************************************************
2
+ * c_yui_nav.js
3
+ *
4
+ * C_YUI_NAV — Renders a menu (list of items) in one zone, using one
5
+ * of the supported layouts. Driven by C_YUI_SHELL; one instance per
6
+ * (menu, zone) pair.
7
+ *
8
+ * Layouts:
9
+ * "vertical" — Bulma .menu (left/right side nav)
10
+ * "icon-bar" — horizontal bar of icon+label (bottom/top on mobile)
11
+ * "tabs" — Bulma .tabs (secondary nav)
12
+ * "drawer" — off-canvas vertical (toggled by hamburger)
13
+ * "submenu" — Bulma .menu nested under a heading
14
+ * "accordion" — Bulma .menu list with collapsible group
15
+ *
16
+ * Each item supports:
17
+ * id, name, icon (CSS class or svg id), route, badge, disabled
18
+ * class — extra CSS class(es) on the item (tabs layout), e.g.
19
+ * a per-item state colour like "yui-nav-disconnected"
20
+ * closable — render a trailing ✕ that emits EV_NAV_ITEM_CLOSE
21
+ * instead of navigating (tabs layout)
22
+ *
23
+ * Items can be replaced at runtime by sending EV_SET_ITEMS
24
+ * {items:[...]} to the nav; the DOM is rebuilt in place and the
25
+ * active highlight restored. The app_config path is simply the
26
+ * first, startup caller of the same mechanism.
27
+ *
28
+ * In addition to navigable items, secondary navs accept two
29
+ * decorative item kinds for in-place visual grouping:
30
+ *
31
+ * { "type": "header", "name": "<group label>" }
32
+ * { "type": "divider" }
33
+ *
34
+ * Headers render as a small-caps section label; dividers as a thin
35
+ * separator line. Both are non-clickable and skipped by routing
36
+ * and click handling. Layouts that have no room for them
37
+ * (`tabs`, `icon-bar`) silently drop these entries.
38
+ *
39
+ * Copyright (c) 2026, ArtGins.
40
+ * All Rights Reserved.
41
+ ***********************************************************************/
42
+ /* global document */
43
+
44
+ import {
45
+ SDATA, SDATA_END, data_type_t, event_flag_t,
46
+ gclass_create, log_error, log_warning,
47
+ gobj_subscribe_event,
48
+ gobj_parent,
49
+ gobj_read_attr, gobj_read_pointer_attr, gobj_write_attr,
50
+ gobj_publish_event,
51
+ createElement2, empty_string, is_array, is_object, is_string,
52
+ } from "@yuneta/gobj-js";
53
+
54
+ /***************************************************************
55
+ * Constants
56
+ ***************************************************************/
57
+ const GCLASS_NAME = "C_YUI_NAV";
58
+
59
+ const SUPPORTED_LAYOUTS = [
60
+ "vertical", "icon-bar", "tabs", "drawer", "submenu", "accordion"
61
+ ];
62
+
63
+ /* Decorative items have no `route` and never participate in
64
+ * navigation; the shell's route indexer already skips items
65
+ * without a route, so this only affects the rendering side. */
66
+ function is_decorative(it)
67
+ {
68
+ return !!(it && (it.type === "header" || it.type === "divider"));
69
+ }
70
+
71
+ /***************************************************************
72
+ * Attrs
73
+ ***************************************************************/
74
+ const attrs_table = [
75
+ SDATA(data_type_t.DTP_POINTER, "subscriber", 0, null, "Subscriber of output events"),
76
+
77
+ SDATA(data_type_t.DTP_STRING, "menu_id", 0, "", "Id of the menu this nav renders"),
78
+ SDATA(data_type_t.DTP_STRING, "nav_label", 0, "", "Human-readable label for aria/heading; falls back to menu_id"),
79
+ SDATA(data_type_t.DTP_JSON, "menu_items", 0, null, "Array of menu items to render"),
80
+ SDATA(data_type_t.DTP_STRING, "zone", 0, "", "Zone id where this nav lives"),
81
+ SDATA(data_type_t.DTP_STRING, "layout", 0, "vertical", "vertical|icon-bar|tabs|drawer|submenu|accordion"),
82
+ SDATA(data_type_t.DTP_STRING, "icon_pos", 0, "left", "left|right|top|bottom"),
83
+ SDATA(data_type_t.DTP_BOOLEAN, "show_label", 0, true, "Show item label next to/under the icon"),
84
+ SDATA(data_type_t.DTP_STRING, "level", 0, "primary", "primary|secondary"),
85
+
86
+ SDATA(data_type_t.DTP_POINTER, "shell", 0, null, "C_YUI_SHELL gobj (for navigation calls)"),
87
+ SDATA(data_type_t.DTP_POINTER, "$container", 0, null, "Root HTMLElement of this nav"),
88
+ SDATA(data_type_t.DTP_STRING, "active_route", 0, "", "Currently active route"),
89
+ SDATA(data_type_t.DTP_POINTER, "priv", 0, null, "Private runtime state (click listener, etc.)"),
90
+ SDATA_END()
91
+ ];
92
+
93
+ /* Monotonic id generator for ARIA pairs (aria-controls etc.). */
94
+ let __nav_aria_seq__ = 0;
95
+
96
+ let PRIVATE_DATA = {};
97
+ let __gclass__ = null;
98
+
99
+
100
+
101
+
102
+ /******************************
103
+ * Framework Methods
104
+ ******************************/
105
+
106
+
107
+
108
+
109
+ /***************************************************************
110
+ * Framework Method: Create
111
+ ***************************************************************/
112
+ function mt_create(gobj)
113
+ {
114
+ /*
115
+ * CHILD subscription model
116
+ */
117
+ let subscriber = gobj_read_pointer_attr(gobj, "subscriber");
118
+ if(!subscriber) {
119
+ subscriber = gobj_parent(gobj);
120
+ }
121
+ gobj_subscribe_event(gobj, null, {}, subscriber);
122
+
123
+ gobj_write_attr(gobj, "priv", {
124
+ click_handler: null
125
+ });
126
+ build_ui(gobj);
127
+ }
128
+
129
+ /***************************************************************
130
+ * Framework Method: Start
131
+ ***************************************************************/
132
+ function mt_start(gobj)
133
+ {
134
+ let shell = gobj_read_attr(gobj, "shell");
135
+ if(shell) {
136
+ /* Listen to route changes from the shell so we can highlight
137
+ * the active item. */
138
+ try {
139
+ gobj_subscribe_event(shell, "EV_ROUTE_CHANGED", {}, gobj);
140
+ } catch(e) {
141
+ log_warning(`C_YUI_NAV: subscribe to EV_ROUTE_CHANGED failed: ${e}`);
142
+ }
143
+ }
144
+ }
145
+
146
+ /***************************************************************
147
+ * Framework Method: Stop
148
+ ***************************************************************/
149
+ function mt_stop(gobj)
150
+ {
151
+ }
152
+
153
+ /***************************************************************
154
+ * Framework Method: Destroy
155
+ ***************************************************************/
156
+ function mt_destroy(gobj)
157
+ {
158
+ let $c = gobj_read_attr(gobj, "$container");
159
+ let priv = gobj_read_attr(gobj, "priv");
160
+ if($c && priv && priv.click_handler) {
161
+ $c.removeEventListener("click", priv.click_handler);
162
+ }
163
+ if($c && $c.parentNode) {
164
+ $c.parentNode.removeChild($c);
165
+ }
166
+ gobj_write_attr(gobj, "$container", null);
167
+ gobj_write_attr(gobj, "priv", null);
168
+ }
169
+
170
+
171
+
172
+
173
+ /***************************
174
+ * Local Methods
175
+ ***************************/
176
+
177
+
178
+
179
+
180
+ /************************************************************
181
+ * Build the UI: dispatch to one of the six layout renderers
182
+ ************************************************************/
183
+ function build_ui(gobj)
184
+ {
185
+ let layout = gobj_read_attr(gobj, "layout") || "vertical";
186
+ if(SUPPORTED_LAYOUTS.indexOf(layout) < 0) {
187
+ log_error(`C_YUI_NAV: unsupported layout '${layout}', falling back to vertical`);
188
+ layout = "vertical";
189
+ }
190
+
191
+ let items = gobj_read_attr(gobj, "menu_items") || [];
192
+ let zone = gobj_read_attr(gobj, "zone") || "";
193
+ let menu_id = gobj_read_attr(gobj, "menu_id") || "nav";
194
+ let $container;
195
+
196
+ switch(layout) {
197
+ case "vertical": $container = render_vertical(gobj, items); break;
198
+ case "icon-bar": $container = render_icon_bar(gobj, items); break;
199
+ case "tabs": $container = render_tabs(gobj, items); break;
200
+ case "drawer": $container = render_drawer(gobj, items); break;
201
+ case "submenu": $container = render_submenu(gobj, items); break;
202
+ case "accordion": $container = render_accordion(gobj, items); break;
203
+ default: $container = render_vertical(gobj, items);
204
+ }
205
+ $container.setAttribute("data-nav-zone", zone);
206
+ $container.setAttribute("data-nav-layout", layout);
207
+ $container.classList.add("C_YUI_NAV", "yui-nav", `yui-nav-${layout}`);
208
+
209
+ /* Accessibility: every nav root is a landmark. For the drawer we
210
+ * tag the wrapper as role=dialog and the panel as role=navigation. */
211
+ if(layout !== "drawer") {
212
+ $container.setAttribute("role", "navigation");
213
+ if(!$container.hasAttribute("aria-label")) {
214
+ let label = gobj_read_attr(gobj, "nav_label") || menu_id;
215
+ $container.setAttribute("aria-label", label);
216
+ }
217
+ }
218
+
219
+ wire_clicks(gobj, $container);
220
+
221
+ gobj_write_attr(gobj, "$container", $container);
222
+ }
223
+
224
+ /************************************************************
225
+ * Rebuild the nav DOM in place from the current menu_items.
226
+ * Used by EV_SET_ITEMS so a nav's tabs can be changed at runtime
227
+ * (the app_config path is just the first, startup caller).
228
+ ************************************************************/
229
+ function rebuild(gobj)
230
+ {
231
+ let $old = gobj_read_attr(gobj, "$container");
232
+ let priv = gobj_read_attr(gobj, "priv") || {};
233
+ let parent = $old ? $old.parentNode : null;
234
+ let next = $old ? $old.nextSibling : null;
235
+ let was_hidden = !!($old && $old.classList.contains("is-hidden"));
236
+
237
+ /* Drop the old delegated click listener before discarding the node. */
238
+ if($old && priv.click_handler) {
239
+ $old.removeEventListener("click", priv.click_handler);
240
+ }
241
+
242
+ build_ui(gobj); /* writes a fresh $container + wires new clicks */
243
+
244
+ let $new = gobj_read_attr(gobj, "$container");
245
+ if($new && was_hidden) {
246
+ $new.classList.add("is-hidden");
247
+ }
248
+ if(parent && $new) {
249
+ parent.insertBefore($new, next);
250
+ }
251
+ if($old && $old.parentNode) {
252
+ $old.parentNode.removeChild($old);
253
+ }
254
+
255
+ apply_active_route(gobj);
256
+ }
257
+
258
+ /************************************************************
259
+ * Re-apply the active-route highlight after a rebuild (the
260
+ * shell only pushes EV_ROUTE_CHANGED on navigation, not on a
261
+ * self-triggered items change).
262
+ ************************************************************/
263
+ function apply_active_route(gobj)
264
+ {
265
+ let $c = gobj_read_attr(gobj, "$container");
266
+ let route = gobj_read_attr(gobj, "active_route") || "";
267
+ if(!$c || empty_string(route)) {
268
+ return;
269
+ }
270
+ let $a = $c.querySelector(`a[data-route="${css_escape(route)}"]`);
271
+ if($a) {
272
+ let $li = $a.closest("li");
273
+ if($li) {
274
+ $li.classList.add("is-active");
275
+ }
276
+ $a.classList.add("is-active");
277
+ }
278
+ }
279
+
280
+ /************************************************************
281
+ * Layouts
282
+ *
283
+ * IMPORTANT 1: createElement2 destructures node descriptors as
284
+ * [tag, attrs, content, events] positionally, so multiple
285
+ * children MUST be wrapped in a single array (otherwise the
286
+ * third sibling would be interpreted as an event-listener map
287
+ * and addEventListener would crash). In short:
288
+ *
289
+ * OK: ["ul", {}, [li1, li2, li3]]
290
+ * WRONG: ["ul", {}, li1, li2, li3]
291
+ *
292
+ * IMPORTANT 2: every translatable text node carries an `i18n`
293
+ * attribute with its canonical (English) key. createElement2
294
+ * maps it to `data-i18n` on the rendered element. The host app
295
+ * changes language by calling `refresh_language(element, t)`
296
+ * from `@yuneta/gobj-js`, which walks all `[data-i18n]` and
297
+ * re-translates the first text node with `t(key)`. See the
298
+ * legacy `change_language()` in `c_yui_main.js` for the
299
+ * canonical pattern.
300
+ ************************************************************/
301
+ function render_vertical(gobj, items)
302
+ {
303
+ let show_label = gobj_read_attr(gobj, "show_label");
304
+ let icon_pos = gobj_read_attr(gobj, "icon_pos");
305
+
306
+ let lis = [];
307
+ for(let it of items) {
308
+ lis.push(item_li(gobj, it, { icon_pos, show_label, stacked: false }));
309
+ }
310
+ return createElement2(
311
+ ["aside", {class: "menu p-3"},
312
+ ["ul", {class: "menu-list"}, lis]
313
+ ]
314
+ );
315
+ }
316
+
317
+ function render_icon_bar(gobj, items)
318
+ {
319
+ let show_label = gobj_read_attr(gobj, "show_label");
320
+ let icon_pos = gobj_read_attr(gobj, "icon_pos"); /* typically "top" */
321
+
322
+ let bar_items = [];
323
+ for(let it of items) {
324
+ if(is_decorative(it)) {
325
+ continue; /* headers/dividers don't fit a horizontal icon bar */
326
+ }
327
+ bar_items.push(item_iconbar(gobj, it, { icon_pos, show_label }));
328
+ }
329
+ return createElement2(
330
+ ["div", {class: "yui-nav-iconbar level is-mobile"}, bar_items]
331
+ );
332
+ }
333
+
334
+ function render_tabs(gobj, items)
335
+ {
336
+ let show_label = gobj_read_attr(gobj, "show_label");
337
+
338
+ let lis = [];
339
+ for(let it of items) {
340
+ if(is_decorative(it)) {
341
+ continue; /* tab strips have no room for section labels */
342
+ }
343
+ let children = [];
344
+ if(!empty_string(it.icon)) {
345
+ children.push(["span", {class: "icon is-small"},
346
+ ["i", {class: it.icon, "aria-hidden":"true"}]]);
347
+ }
348
+ if(show_label && !empty_string(it.name)) {
349
+ children.push(["span", {i18n: it.name}, it.name]);
350
+ }
351
+ /* Optional close affordance: a trailing ✕ that emits
352
+ * EV_NAV_ITEM_CLOSE instead of navigating (caught first in
353
+ * wire_clicks by its data-close-item marker). */
354
+ if(it.closable) {
355
+ children.push(["span", {class: "icon is-small yui-nav-close ml-2",
356
+ "data-close-item": it.id, role: "button",
357
+ "aria-label": "close", title: "close"},
358
+ ["i", {class: "yi-xmark", "aria-hidden":"true"}]]);
359
+ }
360
+ let a_attrs = {
361
+ href: it.route ? "#" + it.route : "#",
362
+ "data-item-id": it.id,
363
+ "data-route": it.route || ""
364
+ };
365
+ let tip = it.tooltip || it.aria_label;
366
+ if(tip) {
367
+ a_attrs.title = tip;
368
+ a_attrs["data-i18n-title"] = tip;
369
+ }
370
+ /* Per-item state class (e.g. a "disconnected" colour) rides on
371
+ * the <li>, alongside Bulma's own is-active. */
372
+ let li_class = empty_string(it.class) ? "" : String(it.class);
373
+ lis.push(
374
+ ["li", {class: li_class, "data-item-id": it.id, "data-route": it.route || ""},
375
+ ["a", a_attrs, children]
376
+ ]
377
+ );
378
+ }
379
+ return createElement2(
380
+ ["div", {class: "tabs is-boxed"},
381
+ ["ul", {}, lis]
382
+ ]
383
+ );
384
+ }
385
+
386
+ function render_drawer(gobj, items)
387
+ {
388
+ /* Off-canvas drawer: initially hidden; toggled via .is-active on the
389
+ * outer wrapper. Open/close is driven from the toolbar or from the
390
+ * public yui_shell_{open,close,toggle}_drawer() helpers.
391
+ */
392
+ let menu_id = gobj_read_attr(gobj, "menu_id") || "drawer";
393
+ let wrap = document.createElement("div");
394
+ wrap.className = "yui-drawer";
395
+ wrap.setAttribute("role", "dialog");
396
+ wrap.setAttribute("aria-modal", "true");
397
+ wrap.setAttribute("aria-label", menu_id);
398
+
399
+ let back = document.createElement("div");
400
+ back.className = "yui-drawer-backdrop";
401
+ back.setAttribute("data-close-drawer", "1");
402
+ back.setAttribute("aria-hidden", "true");
403
+
404
+ let panel = document.createElement("div");
405
+ panel.className = "yui-drawer-panel";
406
+ panel.setAttribute("role", "navigation");
407
+ panel.setAttribute("aria-label", menu_id);
408
+ panel.appendChild(render_vertical(gobj, items));
409
+
410
+ wrap.appendChild(back);
411
+ wrap.appendChild(panel);
412
+ return wrap;
413
+ }
414
+
415
+ function render_submenu(gobj, items)
416
+ {
417
+ let show_label = gobj_read_attr(gobj, "show_label");
418
+ let icon_pos = gobj_read_attr(gobj, "icon_pos");
419
+ let menu_id = gobj_read_attr(gobj, "menu_id") || "";
420
+ let nav_label = gobj_read_attr(gobj, "nav_label") || menu_id;
421
+
422
+ let lis = [];
423
+ for(let it of items) {
424
+ lis.push(item_li(gobj, it, { icon_pos, show_label, stacked: false, compact: true }));
425
+ }
426
+ let heading_text = nav_label || "—";
427
+ let heading_attrs = nav_label
428
+ ? { class: "menu-label", i18n: nav_label }
429
+ : { class: "menu-label" };
430
+ return createElement2(
431
+ ["aside", {class: "menu p-2"},
432
+ [
433
+ ["p", heading_attrs, heading_text],
434
+ ["ul", {class: "menu-list"}, lis]
435
+ ]
436
+ ]
437
+ );
438
+ }
439
+
440
+ function render_accordion(gobj, items)
441
+ {
442
+ /* A flat accordion: each top item becomes a heading with a
443
+ * collapsible child list. Useful when reusing this renderer for
444
+ * deeply nested menus — first-level items here are accordion sections.
445
+ */
446
+ let show_label = gobj_read_attr(gobj, "show_label");
447
+ let icon_pos = gobj_read_attr(gobj, "icon_pos");
448
+
449
+ let $container = createElement2(["aside", {class: "menu yui-nav-accordion p-2"}]);
450
+ for(let it of items) {
451
+ if(is_decorative(it)) {
452
+ continue; /* primary-level decorations don't apply to accordion sections */
453
+ }
454
+ let acc_id = ++__nav_aria_seq__;
455
+ let head_id = `yui-acc-head-${acc_id}`;
456
+ let body_id = `yui-acc-body-${acc_id}`;
457
+
458
+ let head_text = it.name || "";
459
+ let head_attrs = {
460
+ type: "button",
461
+ id: head_id,
462
+ class: "menu-label yui-accordion-head",
463
+ "data-item-id": it.id,
464
+ "data-route": it.route || "",
465
+ "aria-expanded": "false",
466
+ "aria-controls": body_id
467
+ };
468
+ if(!empty_string(head_text)) {
469
+ head_attrs.i18n = head_text;
470
+ }
471
+ let $hdr = createElement2(["button", head_attrs, head_text]);
472
+ $container.appendChild($hdr);
473
+
474
+ let $ul = createElement2(
475
+ ["ul", {id: body_id,
476
+ class: "menu-list yui-accordion-body is-hidden",
477
+ role: "region",
478
+ "aria-labelledby": head_id}]
479
+ );
480
+ if(is_array(it.submenu && it.submenu.items)) {
481
+ for(let sub of it.submenu.items) {
482
+ $ul.appendChild(createElement2(
483
+ item_li(gobj, sub, { icon_pos, show_label, stacked: false })
484
+ ));
485
+ }
486
+ }
487
+ $container.appendChild($ul);
488
+ }
489
+ return $container;
490
+ }
491
+
492
+ /************************************************************
493
+ * Shared helpers
494
+ ************************************************************/
495
+ function item_li(gobj, it, opts)
496
+ {
497
+ /* Decorative entries: a non-interactive section header or a
498
+ * thin divider rule. Used inside vertical/submenu/accordion
499
+ * navs to chunk the list visually without introducing a third
500
+ * navigation level. Both render as <li> with no <a>, so the
501
+ * click handler's `closest("[data-route]")` skips them. */
502
+ if(it && it.type === "divider") {
503
+ return ["li", {
504
+ class: "yui-nav-section-divider",
505
+ role: "separator",
506
+ "aria-hidden": "true"
507
+ }];
508
+ }
509
+ if(it && it.type === "header") {
510
+ let label = it.name || "";
511
+ let span_attrs = {class: "yui-nav-section-header-label"};
512
+ if(label) {
513
+ span_attrs.i18n = label;
514
+ }
515
+ return ["li", {class: "yui-nav-section-header", role: "presentation"},
516
+ ["span", span_attrs, label]
517
+ ];
518
+ }
519
+
520
+ let { icon_pos, show_label, stacked } = opts;
521
+ let children = [];
522
+ let label = it.name || "";
523
+
524
+ let icon_el = !empty_string(it.icon)
525
+ ? ["span", {class: "icon"}, ["i", {class: it.icon, "aria-hidden":"true"}]]
526
+ : null;
527
+ let label_el = (show_label && !empty_string(label))
528
+ ? ["span", {class: "yui-nav-label", i18n: label}, label]
529
+ : null;
530
+
531
+ let a_class = "yui-nav-item";
532
+ if(stacked || icon_pos === "top" || icon_pos === "bottom") {
533
+ a_class += " yui-nav-stacked";
534
+ }
535
+
536
+ if(icon_pos === "right" || icon_pos === "bottom") {
537
+ if(label_el) {
538
+ children.push(label_el);
539
+ }
540
+ if(icon_el) {
541
+ children.push(icon_el);
542
+ }
543
+ } else {
544
+ if(icon_el) {
545
+ children.push(icon_el);
546
+ }
547
+ if(label_el) {
548
+ children.push(label_el);
549
+ }
550
+ }
551
+
552
+ let aria_key = label || it.id;
553
+ let a_attrs = {
554
+ class: a_class,
555
+ href: it.route ? "#" + it.route : "#",
556
+ "data-item-id": it.id,
557
+ "data-route": it.route || "",
558
+ "data-disabled": it.disabled ? "1" : "0",
559
+ "aria-label": aria_key
560
+ };
561
+ /* Mirror the aria-label key in `data-i18n-aria-label` so
562
+ * refresh_language() can re-translate it on language switch.
563
+ * Only when sourced from a real label (skip the bare-id
564
+ * fallback — ids aren't translation keys). */
565
+ if(label) {
566
+ a_attrs["data-i18n-aria-label"] = label;
567
+ }
568
+ /* Hover tooltip: prefer explicit `tooltip`, fall back to
569
+ * `aria_label` (typically equivalent, e.g. "Search (Ctrl+F)").
570
+ * Skip when both empty so we don't emit `title=""` noise.
571
+ * Mirror the value in `data-i18n-title` so refresh_language()
572
+ * can re-translate the tooltip on language switch. */
573
+ let tip = it.tooltip || it.aria_label;
574
+ if(tip) {
575
+ a_attrs.title = tip;
576
+ a_attrs["data-i18n-title"] = tip;
577
+ }
578
+ if(it.disabled) {
579
+ a_attrs["aria-disabled"] = "true";
580
+ a_attrs["tabindex"] = "-1";
581
+ }
582
+
583
+ return ["li", {},
584
+ ["a", a_attrs, children]
585
+ ];
586
+ }
587
+
588
+ function item_iconbar(gobj, it, opts)
589
+ {
590
+ let { icon_pos, show_label } = opts;
591
+ let label = it.name || "";
592
+ let icon_el = !empty_string(it.icon)
593
+ ? ["span", {class: "icon is-medium"},
594
+ ["i", {class: it.icon, "aria-hidden":"true"}]]
595
+ : null;
596
+ let label_el = (show_label && !empty_string(label))
597
+ ? ["span", {class: "yui-nav-label is-size-7", i18n: label}, label]
598
+ : null;
599
+
600
+ let children = [];
601
+ if(icon_pos === "bottom") {
602
+ if(label_el) {
603
+ children.push(label_el);
604
+ }
605
+ if(icon_el) {
606
+ children.push(icon_el);
607
+ }
608
+ } else {
609
+ if(icon_el) {
610
+ children.push(icon_el);
611
+ }
612
+ if(label_el) {
613
+ children.push(label_el);
614
+ }
615
+ }
616
+
617
+ let a_attrs = {
618
+ class: "yui-nav-item yui-nav-stacked",
619
+ href: it.route ? "#" + it.route : "#",
620
+ "data-item-id": it.id,
621
+ "data-route": it.route || "",
622
+ "aria-label": label || it.id
623
+ };
624
+ if(label) {
625
+ a_attrs["data-i18n-aria-label"] = label;
626
+ }
627
+ let tip = it.tooltip || it.aria_label;
628
+ if(tip) {
629
+ a_attrs.title = tip;
630
+ a_attrs["data-i18n-title"] = tip;
631
+ }
632
+ return ["div", {class: "level-item"},
633
+ ["a", a_attrs, children]
634
+ ];
635
+ }
636
+
637
+ function wire_clicks(gobj, $container)
638
+ {
639
+ let priv = gobj_read_attr(gobj, "priv") || {};
640
+
641
+ let handler = ev => {
642
+ let target = ev.target;
643
+ if(!target || !target.closest) {
644
+ return;
645
+ }
646
+
647
+ /* Close affordance (✕ on a closable tab): emit the intent and
648
+ * let the shell/app remove the item; never navigate. */
649
+ let $close = target.closest("[data-close-item]");
650
+ if($close) {
651
+ let $routed = $close.closest("[data-route]");
652
+ gobj_publish_event(gobj, "EV_NAV_ITEM_CLOSE", {
653
+ item_id: $close.getAttribute("data-close-item") || "",
654
+ route: ($routed && $routed.getAttribute("data-route")) || "",
655
+ menu_id: gobj_read_attr(gobj, "menu_id") || "",
656
+ zone: gobj_read_attr(gobj, "zone") || ""
657
+ });
658
+ ev.preventDefault();
659
+ ev.stopPropagation();
660
+ return;
661
+ }
662
+
663
+ /* Drawer backdrop close. Don't mutate the DOM directly —
664
+ * the shell owns the drawer state (focus-trap, escape
665
+ * stack) and must run its full close path. Publish the
666
+ * intent and let the shell handle it through
667
+ * ac_drawer_close_requested. */
668
+ let $bk = target.closest("[data-close-drawer]");
669
+ if($bk) {
670
+ gobj_publish_event(gobj, "EV_DRAWER_CLOSE_REQUESTED", {
671
+ menu_id: gobj_read_attr(gobj, "menu_id") || ""
672
+ });
673
+ ev.preventDefault();
674
+ return;
675
+ }
676
+
677
+ /* Accordion head toggle takes precedence over navigation: the
678
+ * head is a container for its sub-items, not a destination. */
679
+ let $head = target.closest(".yui-accordion-head");
680
+ if($head && $container.contains($head)) {
681
+ let $next = $head.nextElementSibling;
682
+ if($next && $next.classList.contains("yui-accordion-body")) {
683
+ let open = $next.classList.contains("is-hidden");
684
+ $next.classList.toggle("is-hidden", !open);
685
+ $head.classList.toggle("is-open", open);
686
+ $head.setAttribute("aria-expanded", open ? "true" : "false");
687
+ }
688
+ ev.preventDefault();
689
+ return;
690
+ }
691
+
692
+ /* Normal navigation intent.
693
+ * The nav never navigates on its own: it emits EV_NAV_CLICKED
694
+ * and lets the shell decide how to route (via hash or direct
695
+ * call). This breaks the circular import on yui_shell_navigate
696
+ * and keeps ownership of routing in one place. */
697
+ let $a = target.closest("[data-route]");
698
+ if(!$a) {
699
+ return;
700
+ }
701
+ let route = $a.getAttribute("data-route");
702
+ if(empty_string(route)) {
703
+ return;
704
+ }
705
+ if($a.getAttribute("data-disabled") === "1") {
706
+ ev.preventDefault();
707
+ return;
708
+ }
709
+ ev.preventDefault();
710
+ gobj_publish_event(gobj, "EV_NAV_CLICKED", {
711
+ route: route,
712
+ item_id: $a.getAttribute("data-item-id") || "",
713
+ zone: gobj_read_attr(gobj, "zone") || "",
714
+ level: gobj_read_attr(gobj, "level") || "primary"
715
+ });
716
+ };
717
+
718
+ $container.addEventListener("click", handler);
719
+ priv.click_handler = handler;
720
+ }
721
+
722
+
723
+
724
+
725
+ /***************************
726
+ * Actions
727
+ ***************************/
728
+
729
+
730
+
731
+
732
+ /************************************************************
733
+ * EV_SET_ITEMS — replace this nav's items and re-render in place.
734
+ * This is the runtime counterpart of the startup config path:
735
+ * the shell (or app, via the shell) drives dynamic navs through it.
736
+ ************************************************************/
737
+ function ac_set_items(gobj, event, kw, src)
738
+ {
739
+ gobj_write_attr(gobj, "menu_items", is_array(kw.items) ? kw.items : []);
740
+ rebuild(gobj);
741
+ return 0;
742
+ }
743
+
744
+ /************************************************************
745
+ * EV_ROUTE_CHANGED from the shell — refresh active highlight
746
+ ************************************************************/
747
+ function ac_route_changed(gobj, event, kw, src)
748
+ {
749
+ let route = kw.route || "";
750
+ let parent_item = kw.parent_item;
751
+ let item = kw.item;
752
+ let level = gobj_read_attr(gobj, "level");
753
+
754
+ gobj_write_attr(gobj, "active_route", route);
755
+ let $c = gobj_read_attr(gobj, "$container");
756
+ if(!$c) {
757
+ return 0;
758
+ }
759
+
760
+ /* Remove prior active class on any children */
761
+ let previouslyActive = $c.querySelectorAll(".is-active");
762
+ previouslyActive.forEach(n => n.classList.remove("is-active"));
763
+
764
+ /* Decide which id we highlight:
765
+ * primary nav highlights parent_item (or item if top-level)
766
+ * secondary nav highlights the leaf item
767
+ *
768
+ * Multi-menu scoping: primary navs short-circuit when the
769
+ * route's owning menu differs from this nav's menu — two
770
+ * primary-style menus that share an item id (legitimate per
771
+ * TODO #5) must NOT cross-highlight. Treat empty kw.menu_id
772
+ * as a permissive match for backwards compatibility with
773
+ * pre-TODO #5 callers. */
774
+ let id_to_mark = null;
775
+ if(level === "primary") {
776
+ let nav_menu_id = gobj_read_attr(gobj, "menu_id") || "";
777
+ if(kw.menu_id && nav_menu_id && nav_menu_id !== kw.menu_id) {
778
+ return 0;
779
+ }
780
+ id_to_mark = parent_item ? parent_item.id : (item && item.id);
781
+ } else {
782
+ id_to_mark = item && item.id;
783
+ }
784
+ if(!id_to_mark) {
785
+ return 0;
786
+ }
787
+
788
+ let $a = $c.querySelector(`[data-item-id="${css_escape(id_to_mark)}"]`);
789
+ if($a) {
790
+ /* For Bulma .tabs and .menu, 'is-active' goes on the <li> parent. */
791
+ let $li = $a.closest("li");
792
+ if($li) {
793
+ $li.classList.add("is-active");
794
+ }
795
+ $a.classList.add("is-active");
796
+ }
797
+
798
+ /* Accordion: ensure only the section containing the active leaf
799
+ * is expanded. Any other open section collapses. */
800
+ if(gobj_read_attr(gobj, "layout") === "accordion") {
801
+ let active_leaf_id = (item && item.id) || id_to_mark;
802
+ let heads = $c.querySelectorAll(".yui-accordion-head");
803
+ heads.forEach($hd => {
804
+ let $body = $hd.nextElementSibling;
805
+ if(!$body || !$body.classList.contains("yui-accordion-body")) {
806
+ return;
807
+ }
808
+ let contains_active = !!$body.querySelector(
809
+ `[data-item-id="${css_escape(active_leaf_id)}"]`
810
+ );
811
+ $body.classList.toggle("is-hidden", !contains_active);
812
+ $hd.classList.toggle("is-open", contains_active);
813
+ $hd.setAttribute("aria-expanded", contains_active ? "true" : "false");
814
+ });
815
+ }
816
+ return 0;
817
+ }
818
+
819
+ function css_escape(s)
820
+ {
821
+ /* Minimal escape for attribute selector */
822
+ return String(s).replace(/"/g, '\\"');
823
+ }
824
+
825
+ /***************************************************************
826
+ * FSM
827
+ ***************************************************************/
828
+ /*---------------------------------------------*
829
+ * Global methods table
830
+ *---------------------------------------------*/
831
+ const gmt = {
832
+ mt_create: mt_create,
833
+ mt_start: mt_start,
834
+ mt_stop: mt_stop,
835
+ mt_destroy: mt_destroy
836
+ };
837
+
838
+ function create_gclass(gclass_name)
839
+ {
840
+ if(__gclass__) {
841
+ log_error(`GClass ALREADY created: ${gclass_name}`);
842
+ return -1;
843
+ }
844
+
845
+ const states = [
846
+ ["ST_IDLE", [
847
+ ["EV_ROUTE_CHANGED", ac_route_changed, null],
848
+ ["EV_SET_ITEMS", ac_set_items, null]
849
+ ]]
850
+ ];
851
+
852
+ const event_types = [
853
+ ["EV_ROUTE_CHANGED", 0],
854
+ ["EV_SET_ITEMS", 0],
855
+ ["EV_NAV_CLICKED", event_flag_t.EVF_OUTPUT_EVENT|event_flag_t.EVF_PUBLIC_EVENT],
856
+ ["EV_NAV_ITEM_CLOSE", event_flag_t.EVF_OUTPUT_EVENT|event_flag_t.EVF_PUBLIC_EVENT],
857
+ ["EV_DRAWER_CLOSE_REQUESTED", event_flag_t.EVF_OUTPUT_EVENT|event_flag_t.EVF_PUBLIC_EVENT]
858
+ ];
859
+
860
+ __gclass__ = gclass_create(
861
+ gclass_name,
862
+ event_types,
863
+ states,
864
+ gmt,
865
+ 0,
866
+ attrs_table,
867
+ PRIVATE_DATA,
868
+ 0, 0, 0, 0
869
+ );
870
+ if(!__gclass__) {
871
+ return -1;
872
+ }
873
+ return 0;
874
+ }
875
+
876
+ function register_c_yui_nav()
877
+ {
878
+ return create_gclass(GCLASS_NAME);
879
+ }
880
+
881
+ export { register_c_yui_nav };