@yuneta/gobj-ui 5.2.0 → 5.3.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.
@@ -0,0 +1,1567 @@
1
+ /***********************************************************************
2
+ * c_yui_node.js
3
+ *
4
+ * C_YUI_NODE — a navigable position, and the whole navigation
5
+ * model in one recursive gclass.
6
+ *
7
+ * THE MODEL
8
+ * ---------
9
+ * A node is a gobj. Nodes form a TREE (the gobj tree IS the
10
+ * navigation tree — no parallel structure), and the URL is the
11
+ * path of node ids from the tree's `base_route` down:
12
+ *
13
+ * #/cards/energy/north/m1
14
+ * \____/\____________/
15
+ * base node ids
16
+ *
17
+ * Every node holds HOW IT WANTS ITS CHILDREN SEEN — the
18
+ * `projection` (cards, tabs, vertical, backbar…, per breakpoint),
19
+ * and how it wants the WAY IN shown: `projection.path` draws the
20
+ * trail down to the user as one breadcrumb line, the alternative
21
+ * to stacking one chrome strip per level.
22
+ * That is the piece that removes "two levels of menu" as a
23
+ * concept: there is no primary/secondary nav, there is a parent
24
+ * projecting its children, recursively, at any depth. Rendering
25
+ * is DRY: a projection IS a C_YUI_NAV render config, so every
26
+ * level reuses the same renderer, the same item contract, the
27
+ * same i18n and the same click event as the shell's own menus.
28
+ *
29
+ * A node may have CONTENT (a gclass to mount), CHILDREN, or both:
30
+ * a section with its own page and sub-pages is one node with
31
+ * both, not two concepts.
32
+ *
33
+ * DECLARATIVE == DYNAMIC
34
+ * ----------------------
35
+ * The runtime API is the contract; the declared `children` attr
36
+ * is only its first caller. mt_create does not build the tree
37
+ * by a private path — it sends itself one EV_ADD_NODE per
38
+ * declared child, exactly what yui_node_add() does at 3pm with
39
+ * the app running. If the boot path could build something the
40
+ * API cannot, that would be an API bug, not a difference between
41
+ * "config" and "runtime".
42
+ *
43
+ * THE ROOT
44
+ * --------
45
+ * The root node is where the shell's own root used to be: its
46
+ * children are the app's primary options, and it projects them
47
+ * into ZONES of the space (a left rail, a bottom bar) instead of
48
+ * into its own body — a render config with a `zone` is mounted
49
+ * through yui_shell_zone(). That is the only asymmetry in the
50
+ * model, and it is not an exception to it: `menu.primary.render`
51
+ * always was a per-zone projection, it just had no owner that
52
+ * could hold it. Declared as `config.shell.tree`, which
53
+ * synthesizes ONE route entry owning the whole url space.
54
+ *
55
+ * WHERE THE TREE ENDS
56
+ * -------------------
57
+ * One gobj per structural node is right; one gobj per meter
58
+ * reading is not. A node marks the boundary with `link`: a
59
+ * pointer into a data space (a timeranger — millions of raw
60
+ * records, series/time, key/value) plus the viewer suited to that
61
+ * shape. A link node is ALWAYS the tip of the structural path:
62
+ * the url keeps going, but the tail is handed to the viewer as
63
+ * `EV_ROUTE_CHANGED {base, subpath}` — the SAME contract the shell
64
+ * gives a view (ROUTING.md §5), so a viewer cannot tell whether it
65
+ * was mounted by the shell at a declared route or by a node deep
66
+ * in a tree. Below a link there are no nodes, and declaring
67
+ * `children` there is a config error.
68
+ *
69
+ * ROUTING (see ROUTING.md)
70
+ * ------------------------
71
+ * The tree owns everything below `base_route` through the
72
+ * shell's `subpath`: ONE declared route, arbitrary depth. Clicks
73
+ * never mutate the view — a projection click publishes
74
+ * EV_NAV_CLICKED, the node turns it into a URL change (push), and
75
+ * the shell's EV_ROUTE_CHANGED walks back down the tree as
76
+ * EV_ACTIVATE. Intent → URL → view, so Back/Forward, F5 and deep
77
+ * links are correct by construction.
78
+ *
79
+ * Only the ROOT node (the one whose parent is not a C_YUI_NODE)
80
+ * talks to the shell: it subscribes to EV_ROUTE_CHANGED, resolves
81
+ * the subpath and activates the branch. Inner nodes only ever
82
+ * hear their parent.
83
+ *
84
+ * A path segment that names no living child is not silently
85
+ * swallowed: it logs and the URL is rewritten (replace) to the
86
+ * deepest living ancestor — the tree is dynamic, so the ground
87
+ * CAN disappear under a bookmark.
88
+ *
89
+ * Copyright (c) 2026, ArtGins.
90
+ * All Rights Reserved.
91
+ ***********************************************************************/
92
+ import {
93
+ SDATA,
94
+ SDATA_END,
95
+ data_type_t,
96
+ event_flag_t,
97
+ gclass_create,
98
+ gclass_find_by_name,
99
+ log_error,
100
+ log_warning,
101
+ gobj_create_pure_child,
102
+ gobj_destroy,
103
+ gobj_start,
104
+ gobj_stop,
105
+ gobj_is_running,
106
+ gobj_parent,
107
+ gobj_gclass_name,
108
+ gobj_read_attr,
109
+ gobj_read_pointer_attr,
110
+ gobj_write_attr,
111
+ gobj_subscribe_event,
112
+ gobj_unsubscribe_event,
113
+ gobj_send_event,
114
+ gobj_change_state,
115
+ gobj_current_state,
116
+ createElement2,
117
+ empty_string,
118
+ is_array,
119
+ } from "@yuneta/gobj-js";
120
+
121
+ import {
122
+ split_subpath,
123
+ head_tail,
124
+ join_route,
125
+ projection_renders,
126
+ child_nav_items,
127
+ chrome_visible,
128
+ normalize_spec,
129
+ } from "./node_tree_model.js";
130
+
131
+ import {
132
+ yui_shell_of,
133
+ yui_shell_zone,
134
+ yui_shell_navigate,
135
+ yui_shell_set_sub_routes,
136
+ } from "./c_yui_shell.js";
137
+
138
+ import "./c_yui_node.css";
139
+
140
+
141
+ /***************************************************************
142
+ * Constants
143
+ ***************************************************************/
144
+ const GCLASS_NAME = "C_YUI_NODE";
145
+
146
+ /* Internal children are named apart from node children, whose gobj
147
+ * name IS their node id (so the `machine` trace reads like the URL). */
148
+ const CONTENT_NAME = "__content__";
149
+ const NAV_PREFIX = "__nav_";
150
+
151
+
152
+ /***************************************************************
153
+ * Attrs
154
+ ***************************************************************/
155
+ const attrs_table = [
156
+ SDATA(data_type_t.DTP_POINTER, "subscriber", 0, null, "Subscriber of output events"),
157
+
158
+ SDATA(data_type_t.DTP_STRING, "node_id", 0, "", "Id of this node — it IS its url segment (immutable)"),
159
+ SDATA(data_type_t.DTP_STRING, "label", 0, "", "Human label (i18n key); falls back to node_id"),
160
+ SDATA(data_type_t.DTP_STRING, "icon", 0, "", "Icon css class"),
161
+ SDATA(data_type_t.DTP_STRING, "tooltip", 0, "", "Tooltip/aria label (i18n key)"),
162
+ SDATA(data_type_t.DTP_BOOLEAN, "disabled", 0, false, "Rendered but not navigable"),
163
+ SDATA(data_type_t.DTP_INTEGER, "chrome_depth", 0, -1, "Chrome strips painted above the tip; -1 = inherit, 0 = none"),
164
+ SDATA(data_type_t.DTP_JSON, "aliases", 0, null, "Former ids of this node — old urls keep resolving (rewritten)"),
165
+ SDATA(data_type_t.DTP_JSON, "projection", 0, null, "How this node shows its children: layout | {index,chrome}"),
166
+ SDATA(data_type_t.DTP_JSON, "content", 0, null, "{gclass, kw} mounted when this node is the tip of the path"),
167
+ SDATA(data_type_t.DTP_JSON, "link", 0, null, "{kind, gclass, kw} — structure ends here; the viewer owns the subpath"),
168
+ SDATA(data_type_t.DTP_JSON, "children", 0, null, "Declared child specs — the first caller of the runtime API"),
169
+
170
+ SDATA(data_type_t.DTP_STRING, "base_route", 0, "", "ROOT only: the declared route the whole tree hangs from"),
171
+ SDATA(data_type_t.DTP_STRING, "tree_version", 0, "", "ROOT only: version of the tree CONTRACT (its paths are public urls)"),
172
+
173
+ SDATA(data_type_t.DTP_POINTER, "$container", 0, null, "Root HTMLElement (shell view contract)"),
174
+ SDATA_END()
175
+ ];
176
+
177
+ let PRIVATE_DATA = {
178
+ children: null, /* child NODE gobjs, in order */
179
+ navs: null, /* live C_YUI_NAV gobjs inside my own body */
180
+ zone_navs: null, /* navs projected into the SPACE, by key */
181
+ zone_sig: null, /* their last item signature */
182
+ content_gobj: null,
183
+ active_child: null, /* child NODE gobj currently on the path */
184
+ chrome_depth: null, /* effective for the active path (null = all) */
185
+ distance: 0, /* segments from me down to the tip */
186
+ chain: null, /* nodes from my child down to the tip */
187
+ is_root: false,
188
+ $chrome: null,
189
+ $body: null
190
+ };
191
+
192
+ let __gclass__ = null;
193
+
194
+
195
+
196
+
197
+ /******************************
198
+ * Framework Methods
199
+ ******************************/
200
+
201
+
202
+
203
+
204
+ /***************************************************************
205
+ * Framework Method: Create
206
+ ***************************************************************/
207
+ function mt_create(gobj)
208
+ {
209
+ let priv = gobj.priv;
210
+
211
+ /*
212
+ * CHILD subscription model
213
+ */
214
+ let subscriber = gobj_read_pointer_attr(gobj, "subscriber");
215
+ if(!subscriber) {
216
+ subscriber = gobj_parent(gobj);
217
+ }
218
+ gobj_subscribe_event(gobj, null, {}, subscriber);
219
+
220
+ priv.children = [];
221
+ priv.navs = [];
222
+ priv.zone_navs = {};
223
+ priv.zone_sig = {};
224
+
225
+ let parent = gobj_parent(gobj);
226
+ priv.is_root = !parent || gobj_gclass_name(parent) !== GCLASS_NAME;
227
+
228
+ if(empty_string(gobj_read_attr(gobj, "node_id"))) {
229
+ log_error(`${GCLASS_NAME}: created without 'node_id' — the id IS the url segment`);
230
+ }
231
+ if(priv.is_root && empty_string(gobj_read_attr(gobj, "base_route"))) {
232
+ log_error(`${GCLASS_NAME}: root node created without 'base_route'`);
233
+ }
234
+
235
+ build_ui(gobj);
236
+
237
+ /*
238
+ * The declared tree is not a privileged path: it is a caller of
239
+ * the same runtime API, one EV_ADD_NODE per child.
240
+ */
241
+ let declared = gobj_read_attr(gobj, "children");
242
+ if(declared !== null && declared !== undefined) {
243
+ if(!is_array(declared)) {
244
+ log_error(`${GCLASS_NAME} '${node_path_str(gobj)}': 'children' must be an array`);
245
+ } else {
246
+ for(let spec of declared) {
247
+ gobj_send_event(gobj, "EV_ADD_NODE", {spec: spec}, gobj);
248
+ }
249
+ }
250
+ }
251
+ }
252
+
253
+ /***************************************************************
254
+ * Framework Method: Start
255
+ ***************************************************************/
256
+ function mt_start(gobj)
257
+ {
258
+ let priv = gobj.priv;
259
+
260
+ for(let child of priv.children) {
261
+ if(!gobj_is_running(child)) {
262
+ gobj_start(child);
263
+ }
264
+ }
265
+
266
+ if(!priv.is_root) {
267
+ return;
268
+ }
269
+
270
+ /*
271
+ * Only the root talks to the shell. Subscribing in mt_start keeps
272
+ * it symmetric with the unsubscribe in mt_stop; the shell mounts a
273
+ * view (create → appendChild → start) and only THEN broadcasts
274
+ * EV_ROUTE_CHANGED, so this still precedes the first broadcast.
275
+ */
276
+ let shell = yui_shell_of(gobj);
277
+ if(!shell) {
278
+ log_error(`${GCLASS_NAME}: no shell — the tree cannot route`);
279
+ return;
280
+ }
281
+ gobj_subscribe_event(shell, "EV_ROUTE_CHANGED", {}, gobj);
282
+ publish_sub_routes(gobj);
283
+ }
284
+
285
+ /***************************************************************
286
+ * Framework Method: Stop
287
+ ***************************************************************/
288
+ function mt_stop(gobj)
289
+ {
290
+ let priv = gobj.priv;
291
+
292
+ if(priv.is_root) {
293
+ let shell = yui_shell_of(gobj);
294
+ if(shell) {
295
+ gobj_unsubscribe_event(shell, "EV_ROUTE_CHANGED", {}, gobj);
296
+ yui_shell_set_sub_routes(shell, gobj_read_attr(gobj, "base_route"), null);
297
+ }
298
+ }
299
+
300
+ deactivate(gobj);
301
+
302
+ /* Symmetric with mt_start: what this node started, it stops —
303
+ * the framework destroys children, and destroying a RUNNING gobj
304
+ * is an error. */
305
+ if(priv.content_gobj && gobj_is_running(priv.content_gobj)) {
306
+ gobj_stop(priv.content_gobj);
307
+ }
308
+ for(let key of Object.keys(priv.zone_navs)) {
309
+ let nav = priv.zone_navs[key];
310
+ if(gobj_is_running(nav)) {
311
+ gobj_stop(nav);
312
+ }
313
+ }
314
+ for(let child of priv.children) {
315
+ if(gobj_is_running(child)) {
316
+ gobj_stop(child);
317
+ }
318
+ }
319
+ }
320
+
321
+ /***************************************************************
322
+ * Framework Method: Destroy
323
+ ***************************************************************/
324
+ function mt_destroy(gobj)
325
+ {
326
+ let priv = gobj.priv;
327
+
328
+ /* gobj_destroy() destroys the children BEFORE calling mt_destroy,
329
+ * so the content and the projection navs are already gone: reaching
330
+ * for them here only logs "gobj NULL or DESTROYED". Drop the
331
+ * references and own nothing but the DOM. */
332
+ priv.content_gobj = null;
333
+ priv.navs = [];
334
+ priv.zone_navs = {};
335
+ priv.zone_sig = {};
336
+ priv.active_child = null;
337
+
338
+ let $c = gobj_read_attr(gobj, "$container");
339
+ if($c && $c.parentNode) {
340
+ $c.parentNode.removeChild($c);
341
+ }
342
+ gobj_write_attr(gobj, "$container", null);
343
+ }
344
+
345
+
346
+
347
+
348
+ /***************************
349
+ * Local Methods
350
+ ***************************/
351
+
352
+
353
+
354
+
355
+ /************************************************************
356
+ * Build the (empty) DOM skeleton. Inner DOM is built when the
357
+ * node is activated and torn down when it leaves the path, so
358
+ * an inactive branch costs one empty <div>.
359
+ ************************************************************/
360
+ function build_ui(gobj)
361
+ {
362
+ let priv = gobj.priv;
363
+ let node_id = gobj_read_attr(gobj, "node_id");
364
+
365
+ let $container = createElement2(
366
+ ["div", {class: `${GCLASS_NAME} NODE_VIEW`, "data-node-id": node_id}, [
367
+ ["div", {class: "NODE_CHROME"}],
368
+ ["div", {class: "NODE_BODY"}]
369
+ ]]
370
+ );
371
+ priv.$chrome = $container.querySelector(".NODE_CHROME");
372
+ priv.$body = $container.querySelector(".NODE_BODY");
373
+
374
+ gobj_write_attr(gobj, "$container", $container);
375
+ }
376
+
377
+ /************************************************************
378
+ * The tree's root node (the one whose parent is not a node).
379
+ ************************************************************/
380
+ function tree_root_of(gobj)
381
+ {
382
+ let g = gobj;
383
+ while(true) {
384
+ let parent = gobj_parent(g);
385
+ if(!parent || gobj_gclass_name(parent) !== GCLASS_NAME) {
386
+ return g;
387
+ }
388
+ g = parent;
389
+ }
390
+ }
391
+
392
+ /************************************************************
393
+ * Node ids from the root DOWN TO this node, root excluded
394
+ * (the root's own segment is already inside base_route).
395
+ ************************************************************/
396
+ function node_ids(gobj)
397
+ {
398
+ let ids = [];
399
+ let g = gobj;
400
+ while(true) {
401
+ let parent = gobj_parent(g);
402
+ if(!parent || gobj_gclass_name(parent) !== GCLASS_NAME) {
403
+ break;
404
+ }
405
+ ids.unshift(gobj_read_attr(g, "node_id"));
406
+ g = parent;
407
+ }
408
+ return ids;
409
+ }
410
+
411
+ /************************************************************
412
+ * Canonical route of a node — the URL that lands exactly here.
413
+ ************************************************************/
414
+ function route_of(gobj)
415
+ {
416
+ let root = tree_root_of(gobj);
417
+ return join_route(gobj_read_attr(root, "base_route"), node_ids(gobj));
418
+ }
419
+
420
+ /************************************************************
421
+ * Human path for logs: "<base>/a/b".
422
+ ************************************************************/
423
+ function node_path_str(gobj)
424
+ {
425
+ return route_of(gobj);
426
+ }
427
+
428
+ /************************************************************
429
+ * Child node by id, or null.
430
+ ************************************************************/
431
+ function find_child(gobj, node_id)
432
+ {
433
+ for(let child of gobj.priv.children) {
434
+ if(gobj_read_attr(child, "node_id") === node_id) {
435
+ return child;
436
+ }
437
+ }
438
+ return null;
439
+ }
440
+
441
+ /************************************************************
442
+ * Child node whose FORMER id is `node_id`, or null.
443
+ *
444
+ * The tree's paths are a contract — a url a client may have
445
+ * bookmarked, scripted or been sold as a door into the system.
446
+ * So an id is never rewritten in place: a rename ships the old
447
+ * id in `aliases`, the old url keeps resolving, and the shell
448
+ * rewrites it to the canonical one. That is what makes a
449
+ * version bump migratable instead of breaking.
450
+ ************************************************************/
451
+ function find_child_by_alias(gobj, node_id)
452
+ {
453
+ for(let child of gobj.priv.children) {
454
+ let aliases = gobj_read_attr(child, "aliases");
455
+ if(is_array(aliases) && aliases.indexOf(node_id) >= 0) {
456
+ return child;
457
+ }
458
+ }
459
+ return null;
460
+ }
461
+
462
+ /************************************************************
463
+ * Is this node the boundary between structure and data?
464
+ ************************************************************/
465
+ function has_link(gobj)
466
+ {
467
+ let link = gobj_read_attr(gobj, "link");
468
+ return !!(link && link.gclass);
469
+ }
470
+
471
+ /************************************************************
472
+ * What this node mounts when it is the tip: its link's viewer
473
+ * (the data case) or its content (the structural case).
474
+ ************************************************************/
475
+ function view_spec(gobj)
476
+ {
477
+ let link = gobj_read_attr(gobj, "link");
478
+ if(link && link.gclass) {
479
+ return link;
480
+ }
481
+ return gobj_read_attr(gobj, "content");
482
+ }
483
+
484
+ /************************************************************
485
+ * Effective chrome depth for the active path: the DEEPEST
486
+ * declaration wins (a node close to the leaf knows better how
487
+ * much chrome its corner deserves than the root does), falling
488
+ * back to what the parent handed down, then to unlimited.
489
+ *
490
+ * This is why an intermediate node may exist for no other
491
+ * reason than to hold this number.
492
+ ************************************************************/
493
+ function resolve_path_info(gobj, subpath, inherited)
494
+ {
495
+ let effective = (typeof inherited === "number") ? inherited : null;
496
+
497
+ let own = gobj_read_attr(gobj, "chrome_depth");
498
+ if(typeof own === "number" && own >= 0) {
499
+ effective = own;
500
+ }
501
+ if(has_link(gobj)) {
502
+ return {distance: 0, chrome_depth: effective, chain: []};
503
+ }
504
+
505
+ /* Distance is counted in STRUCTURAL segments: the tail a viewer
506
+ * owns below a link is url, not tree, and counting it would push
507
+ * every ancestor out of a chrome_depth budget it never spent. */
508
+ let distance = 0;
509
+ let chain = [];
510
+ let g = gobj;
511
+ for(let seg of split_subpath(subpath)) {
512
+ let child = find_child(g, seg) || find_child_by_alias(g, seg);
513
+ if(!child) {
514
+ break;
515
+ }
516
+ distance++;
517
+ chain.push(child);
518
+ let declared = gobj_read_attr(child, "chrome_depth");
519
+ if(typeof declared === "number" && declared >= 0) {
520
+ effective = declared;
521
+ }
522
+ g = child;
523
+ if(has_link(child)) {
524
+ break;
525
+ }
526
+ }
527
+ return {distance: distance, chrome_depth: effective, chain: chain};
528
+ }
529
+
530
+ /************************************************************
531
+ * Resolve a path of ids ("a/b/c") from `node` downwards.
532
+ * Returns the deepest LIVING node plus the segments that did
533
+ * not resolve — the caller decides what a dead tail means.
534
+ ************************************************************/
535
+ function walk_path(node, path)
536
+ {
537
+ let segs = split_subpath(path);
538
+ let g = node;
539
+ let i = 0;
540
+ while(i < segs.length) {
541
+ let next = find_child(g, segs[i]);
542
+ if(!next) {
543
+ break;
544
+ }
545
+ g = next;
546
+ i++;
547
+ }
548
+ return {node: g, missing: segs.slice(i)};
549
+ }
550
+
551
+ /************************************************************
552
+ * Destroy the C_YUI_NAV gobjs rendering this node's projection.
553
+ ************************************************************/
554
+ function clear_navs(gobj)
555
+ {
556
+ let priv = gobj.priv;
557
+ for(let nav of priv.navs) {
558
+ if(gobj_is_running(nav)) {
559
+ gobj_stop(nav);
560
+ }
561
+ gobj_destroy(nav);
562
+ }
563
+ priv.navs = [];
564
+ }
565
+
566
+ /************************************************************
567
+ * Render this node's projection of its children, in one mode:
568
+ * "index" — I am the tip: the projection is the page.
569
+ * "chrome" — a child is showing: the projection is its chrome.
570
+ ************************************************************/
571
+ function render_projection(gobj, mode, $where, active_route, active_id, zones_only)
572
+ {
573
+ let priv = gobj.priv;
574
+
575
+ if(!priv.children.length) {
576
+ return;
577
+ }
578
+ let renders = projection_renders(gobj_read_attr(gobj, "projection"), mode);
579
+ if(!renders.length) {
580
+ return;
581
+ }
582
+
583
+ let my_route = route_of(gobj);
584
+ let base = my_route === "/" ? "" : my_route;
585
+ let specs = [];
586
+ for(let child of priv.children) {
587
+ specs.push({
588
+ id: gobj_read_attr(child, "node_id"),
589
+ label: gobj_read_attr(child, "label"),
590
+ icon: gobj_read_attr(child, "icon"),
591
+ tooltip: gobj_read_attr(child, "tooltip"),
592
+ disabled: gobj_read_attr(child, "disabled")
593
+ });
594
+ }
595
+ let items = child_nav_items(specs, (id) => `${base}/${id}`);
596
+
597
+ let i = 0;
598
+ for(let render of renders) {
599
+ /* A projection with a `zone` goes into the SPACE (the root
600
+ * rail/bar), not into this node's body — and it PERSISTS. It
601
+ * is the app's standing chrome: rebuilding it on every
602
+ * navigation would throw away its scroll and blink the rail,
603
+ * so it is created once and told where the user is. */
604
+ if(render.zone) {
605
+ project_into_zone(gobj, render, items, active_route, active_id);
606
+ i++;
607
+ continue;
608
+ }
609
+ if(zones_only) {
610
+ i++;
611
+ continue;
612
+ }
613
+ let $target = $where;
614
+ let nav = gobj_create_pure_child(
615
+ `${NAV_PREFIX}${mode}_${i}__`,
616
+ "C_YUI_NAV",
617
+ {
618
+ menu_id: `node.${my_route}`,
619
+ nav_label: gobj_read_attr(gobj, "label") || gobj_read_attr(gobj, "node_id"),
620
+ menu_items: items,
621
+ layout: render.layout,
622
+ zone: render.zone || "",
623
+ icon_pos: render.icon_pos || "top",
624
+ show_label: render.show_label !== false,
625
+ show_on: render.show_on || "",
626
+ level: render.zone ? "primary" : "secondary",
627
+ /* A backbar goes UP: back to this node's own index. */
628
+ back_route: (render.layout === "backbar") ? my_route : "",
629
+ active_route: active_route || ""
630
+ },
631
+ gobj
632
+ );
633
+ gobj_start(nav);
634
+ let $nav = gobj_read_attr(nav, "$container");
635
+ if($nav) {
636
+ $target.appendChild($nav);
637
+ }
638
+ priv.navs.push(nav);
639
+ i++;
640
+ }
641
+ }
642
+
643
+ /************************************************************
644
+ * Project into a zone of the SPACE, once, and keep it.
645
+ *
646
+ * Rebuilt only when the children actually changed (the runtime
647
+ * API added or removed one); otherwise the nav is just told the
648
+ * new position, which is what C_YUI_NAV's EV_ROUTE_CHANGED is
649
+ * for. Unknown zone: nothing is built rather than mounted
650
+ * nowhere — a menu that silently vanishes is the worse failure.
651
+ ************************************************************/
652
+ function project_into_zone(gobj, render, items, active_route, active_id)
653
+ {
654
+ let priv = gobj.priv;
655
+ let key = `${render.zone}|${render.layout}|${render.show_on || ""}`;
656
+ let sig = items.map((it) => it.id).join(",");
657
+ let nav = priv.zone_navs[key];
658
+
659
+ if(!nav) {
660
+ let $zone = yui_shell_zone(yui_shell_of(gobj), render.zone);
661
+ if(!$zone) {
662
+ return; /* Error already logged */
663
+ }
664
+ nav = gobj_create_pure_child(
665
+ `${NAV_PREFIX}zone_${render.zone}_${render.layout}__`,
666
+ "C_YUI_NAV",
667
+ {
668
+ menu_id: `node.${route_of(gobj)}`,
669
+ nav_label: gobj_read_attr(gobj, "label") || gobj_read_attr(gobj, "node_id"),
670
+ menu_items: items,
671
+ zone: render.zone,
672
+ layout: render.layout,
673
+ icon_pos: render.icon_pos || "left",
674
+ show_label: render.show_label !== false,
675
+ show_on: render.show_on || "",
676
+ level: "primary"
677
+ },
678
+ gobj
679
+ );
680
+ gobj_start(nav);
681
+ let $nav = gobj_read_attr(nav, "$container");
682
+ if($nav) {
683
+ $zone.appendChild($nav);
684
+ }
685
+ priv.zone_navs[key] = nav;
686
+ priv.zone_sig[key] = sig;
687
+ } else if(priv.zone_sig[key] !== sig) {
688
+ gobj_send_event(nav, "EV_SET_ITEMS", {items: items}, gobj);
689
+ priv.zone_sig[key] = sig;
690
+ }
691
+
692
+ gobj_send_event(nav, "EV_ROUTE_CHANGED", {
693
+ route: active_route || "",
694
+ item: {id: active_id || ""}
695
+ }, gobj);
696
+ }
697
+
698
+ /************************************************************
699
+ * Mount this node's own content (lazily, once). A node with
700
+ * content AND children shows the content first and projects
701
+ * its children under it.
702
+ ************************************************************/
703
+ function mount_content(gobj)
704
+ {
705
+ let priv = gobj.priv;
706
+ let content = view_spec(gobj);
707
+
708
+ if(!content || !content.gclass) {
709
+ return null;
710
+ }
711
+ if(priv.content_gobj) {
712
+ return priv.content_gobj;
713
+ }
714
+ if(!gclass_find_by_name(content.gclass)) {
715
+ log_error(
716
+ `${GCLASS_NAME} '${node_path_str(gobj)}': content gclass ` +
717
+ `'${content.gclass}' is not registered`
718
+ );
719
+ return null;
720
+ }
721
+
722
+ let view = gobj_create_pure_child(
723
+ CONTENT_NAME, content.gclass, content.kw || {}, gobj
724
+ );
725
+ gobj_start(view);
726
+ priv.content_gobj = view;
727
+ return view;
728
+ }
729
+
730
+ /************************************************************
731
+ * Tear down the mounted content, orderly: a RUNNING gobj must
732
+ * be stopped before it is destroyed.
733
+ ************************************************************/
734
+ function destroy_content(gobj)
735
+ {
736
+ let priv = gobj.priv;
737
+
738
+ if(!priv.content_gobj) {
739
+ return;
740
+ }
741
+ if(gobj_is_running(priv.content_gobj)) {
742
+ gobj_stop(priv.content_gobj);
743
+ }
744
+ gobj_destroy(priv.content_gobj);
745
+ priv.content_gobj = null;
746
+ }
747
+
748
+ /************************************************************
749
+ * Paint state ST_SELF: I am the tip of the path.
750
+ ************************************************************/
751
+ function render_self(gobj)
752
+ {
753
+ let priv = gobj.priv;
754
+
755
+ clear_navs(gobj);
756
+ priv.$chrome.replaceChildren();
757
+ priv.$body.replaceChildren();
758
+
759
+ let view = mount_content(gobj);
760
+ if(view) {
761
+ let $v = gobj_read_attr(view, "$container");
762
+ if($v) {
763
+ let $slot = createElement2(["div", {class: "NODE_CONTENT"}]);
764
+ $slot.appendChild($v);
765
+ priv.$body.appendChild($slot);
766
+ }
767
+ }
768
+
769
+ render_path(gobj, priv.$chrome);
770
+
771
+ if(priv.children.length) {
772
+ let $index = createElement2(["div", {class: "NODE_INDEX"}]);
773
+ priv.$body.appendChild($index);
774
+ render_projection(gobj, "index", $index, "", "");
775
+ /* The zone projection is standing chrome: it lives across states,
776
+ * so its chrome renders must be refreshed here too or the rail
777
+ * would keep highlighting the child we just left. ZONES ONLY —
778
+ * a body-bound chrome render drawn next to the index would be
779
+ * the same children listed twice on one screen. */
780
+ render_projection(gobj, "chrome", $index, "", "", true);
781
+ return;
782
+ }
783
+
784
+ if(!view) {
785
+ priv.$body.appendChild(createElement2(
786
+ ["div", {class: "NODE_EMPTY", i18n: "nothing here yet"},
787
+ "Nothing here yet."]
788
+ ));
789
+ }
790
+ }
791
+
792
+ /************************************************************
793
+ * Paint state ST_CHILD: a child of mine is on the path.
794
+ ************************************************************/
795
+ function render_child(gobj, child)
796
+ {
797
+ let priv = gobj.priv;
798
+
799
+ clear_navs(gobj);
800
+ priv.$chrome.replaceChildren();
801
+ priv.$body.replaceChildren();
802
+
803
+ /* Every ancestor painting its own chrome stacks one strip per
804
+ * level. `chrome_depth` is how a node caps that for its corner
805
+ * of the tree (see resolve_chrome_depth). */
806
+ if(chrome_visible(priv.distance, priv.chrome_depth)) {
807
+ render_projection(gobj, "chrome", priv.$chrome, route_of(child),
808
+ gobj_read_attr(child, "node_id"));
809
+ }
810
+ render_path(gobj, priv.$chrome);
811
+
812
+ let $slot = createElement2(["div", {class: "NODE_CHILD"}]);
813
+ let $child = gobj_read_attr(child, "$container");
814
+ if($child) {
815
+ $slot.appendChild($child);
816
+ }
817
+ priv.$body.appendChild($slot);
818
+ }
819
+
820
+ /************************************************************
821
+ * Render the trail down to where the user is, as one line.
822
+ *
823
+ * The other two projections show a node's CHILDREN; this one shows
824
+ * the PATH, so its items are me plus the chain below me — which is
825
+ * why it is rendered by ONE node (whoever declares it) instead of
826
+ * by every ancestor. Same item contract as any other nav, so the
827
+ * crumbs click and translate like everything else.
828
+ ************************************************************/
829
+ function render_path(gobj, $where)
830
+ {
831
+ let priv = gobj.priv;
832
+ let renders = projection_renders(gobj_read_attr(gobj, "projection"), "path");
833
+
834
+ if(!renders.length) {
835
+ return;
836
+ }
837
+
838
+ /* From the TREE ROOT down to the tip, not from here: a trail that
839
+ * starts halfway ("South hall › Meter 3") answers the question it
840
+ * was drawn to answer only by half. The declaring node decides
841
+ * HOW its corner shows the way in; it does not decide where the
842
+ * way in starts. */
843
+ let path = [];
844
+ let g = gobj;
845
+ while(g) {
846
+ path.unshift(g);
847
+ let parent = gobj_parent(g);
848
+ g = (parent && gobj_gclass_name(parent) === GCLASS_NAME) ? parent : null;
849
+ }
850
+ let items = path.concat(priv.chain || []).map((node) => ({
851
+ id: gobj_read_attr(node, "node_id"),
852
+ name: gobj_read_attr(node, "label") || gobj_read_attr(node, "node_id"),
853
+ icon: gobj_read_attr(node, "icon") || "",
854
+ route: route_of(node)
855
+ }));
856
+ let tip = items[items.length - 1];
857
+
858
+ let i = 0;
859
+ for(let render of renders) {
860
+ let nav = gobj_create_pure_child(
861
+ `${NAV_PREFIX}path_${i}__`,
862
+ "C_YUI_NAV",
863
+ {
864
+ menu_id: `node.path.${route_of(gobj)}`,
865
+ nav_label: gobj_read_attr(gobj, "label") || "",
866
+ menu_items: items,
867
+ layout: render.layout,
868
+ icon_pos: render.icon_pos || "left",
869
+ show_label: render.show_label !== false,
870
+ show_on: render.show_on || "",
871
+ level: "secondary",
872
+ active_route: tip.route
873
+ },
874
+ gobj
875
+ );
876
+ gobj_start(nav);
877
+ let $nav = gobj_read_attr(nav, "$container");
878
+ if($nav) {
879
+ $where.appendChild($nav);
880
+ }
881
+ priv.navs.push(nav);
882
+ i++;
883
+ }
884
+ }
885
+
886
+ /************************************************************
887
+ * Leave the path: tear down my rendering and my active child's.
888
+ ************************************************************/
889
+ function deactivate(gobj)
890
+ {
891
+ let priv = gobj.priv;
892
+
893
+ if(priv.active_child) {
894
+ gobj_send_event(priv.active_child, "EV_DEACTIVATE", {}, gobj);
895
+ priv.active_child = null;
896
+ }
897
+ clear_navs(gobj);
898
+ if(priv.$chrome) {
899
+ priv.$chrome.replaceChildren();
900
+ }
901
+ if(priv.$body) {
902
+ priv.$body.replaceChildren();
903
+ }
904
+ }
905
+
906
+ /************************************************************
907
+ * Enter (or re-enter) the path with `subpath` as my tail.
908
+ * Returns the new state name.
909
+ ************************************************************/
910
+ function activate(gobj, subpath, inherited_depth)
911
+ {
912
+ let priv = gobj.priv;
913
+ let {head, tail} = head_tail(subpath);
914
+
915
+ let info = resolve_path_info(gobj, subpath, inherited_depth);
916
+ priv.distance = info.distance;
917
+ priv.chrome_depth = info.chrome_depth;
918
+ priv.chain = info.chain;
919
+
920
+ /* Structure ends here: whatever is left of the url is data, and it
921
+ * belongs to the viewer. Re-render only the first time — paging
922
+ * through data must not rebuild the DOM under the viewer. */
923
+ if(has_link(gobj)) {
924
+ if(!priv.content_gobj || gobj_current_state(gobj) !== "ST_SELF") {
925
+ if(priv.active_child) {
926
+ gobj_send_event(priv.active_child, "EV_DEACTIVATE", {}, gobj);
927
+ priv.active_child = null;
928
+ }
929
+ render_self(gobj);
930
+ }
931
+ forward_subpath(gobj, subpath);
932
+ return "ST_SELF";
933
+ }
934
+
935
+ if(!head) {
936
+ if(priv.active_child) {
937
+ gobj_send_event(priv.active_child, "EV_DEACTIVATE", {}, gobj);
938
+ priv.active_child = null;
939
+ }
940
+ render_self(gobj);
941
+ return "ST_SELF";
942
+ }
943
+
944
+ let child = find_child(gobj, head);
945
+ if(!child) {
946
+ /* A FORMER id: the url is still part of the contract, it just
947
+ * is not the canonical spelling any more. Rewrite it (replace
948
+ * — code decided) and carry on; the route change that follows
949
+ * re-enters here on the canonical path. */
950
+ let renamed = find_child_by_alias(gobj, head);
951
+ if(renamed) {
952
+ let shell = yui_shell_of(gobj);
953
+ if(shell) {
954
+ let canonical = tail ?
955
+ `${route_of(renamed)}/${tail}` : route_of(renamed);
956
+ yui_shell_navigate(shell, canonical, {replace: true});
957
+ }
958
+ child = renamed;
959
+ }
960
+ }
961
+ if(!child) {
962
+ /* The ground moved under a bookmark (or a typo): land on the
963
+ * deepest living ancestor — me — and say so. CODE decided the
964
+ * move, so it is a replace (ROUTING.md §2). */
965
+ log_warning(
966
+ `${GCLASS_NAME} '${node_path_str(gobj)}': no child '${head}' — ` +
967
+ `falling back to this node`
968
+ );
969
+ let shell = yui_shell_of(gobj);
970
+ if(shell) {
971
+ yui_shell_navigate(shell, route_of(gobj), {replace: true});
972
+ }
973
+ if(priv.active_child) {
974
+ gobj_send_event(priv.active_child, "EV_DEACTIVATE", {}, gobj);
975
+ priv.active_child = null;
976
+ }
977
+ render_self(gobj);
978
+ return "ST_SELF";
979
+ }
980
+
981
+ if(priv.active_child && priv.active_child !== child) {
982
+ gobj_send_event(priv.active_child, "EV_DEACTIVATE", {}, gobj);
983
+ }
984
+ priv.active_child = child;
985
+ render_child(gobj, child);
986
+ gobj_send_event(child, "EV_ACTIVATE", {
987
+ subpath: tail,
988
+ chrome_depth: priv.chrome_depth
989
+ }, gobj);
990
+ return "ST_CHILD";
991
+ }
992
+
993
+ /************************************************************
994
+ * Hand the data tail to the viewer, in the SAME shape the shell
995
+ * gives a view (ROUTING.md §5): `base` is this node's canonical
996
+ * route — what the viewer builds its own deep links from — and
997
+ * `subpath` is its tail, empty meaning "the viewer's home".
998
+ *
999
+ * A viewer behind a link therefore cannot tell whether the shell
1000
+ * mounted it at a declared route or a node did, deep in a tree.
1001
+ * It MUST declare EV_ROUTE_CHANGED: a data space with no
1002
+ * addressable position inside it would not need a link at all.
1003
+ ************************************************************/
1004
+ function forward_subpath(gobj, subpath)
1005
+ {
1006
+ let priv = gobj.priv;
1007
+
1008
+ if(!priv.content_gobj) {
1009
+ return; /* Error already logged by mount_content() */
1010
+ }
1011
+ let base = route_of(gobj);
1012
+ gobj_send_event(priv.content_gobj, "EV_ROUTE_CHANGED", {
1013
+ route: subpath ? `${base}/${subpath}` : base,
1014
+ base: base,
1015
+ subpath: subpath || ""
1016
+ }, gobj);
1017
+ }
1018
+
1019
+ /************************************************************
1020
+ * Repaint after the tree changed under me, without moving the
1021
+ * user: same state, fresh projection.
1022
+ ************************************************************/
1023
+ function repaint(gobj)
1024
+ {
1025
+ let priv = gobj.priv;
1026
+ let state = gobj_current_state(gobj);
1027
+
1028
+ if(state === "ST_SELF") {
1029
+ render_self(gobj);
1030
+ return;
1031
+ }
1032
+ if(state === "ST_CHILD" && priv.active_child) {
1033
+ render_child(gobj, priv.active_child);
1034
+ }
1035
+ }
1036
+
1037
+ /************************************************************
1038
+ * Contribute the whole tree to the shell's site map (ROUTING
1039
+ * §5.4) — a pull-at-render registry, so the map shows every
1040
+ * level instead of one declared route.
1041
+ ************************************************************/
1042
+ function publish_sub_routes(gobj)
1043
+ {
1044
+ let priv = gobj.priv;
1045
+
1046
+ if(!priv.is_root) {
1047
+ return;
1048
+ }
1049
+ let shell = yui_shell_of(gobj);
1050
+ if(!shell) {
1051
+ return;
1052
+ }
1053
+ yui_shell_set_sub_routes(
1054
+ shell, gobj_read_attr(gobj, "base_route"), sub_route_nodes(gobj)
1055
+ );
1056
+ }
1057
+
1058
+ function sub_route_nodes(gobj)
1059
+ {
1060
+ let nodes = [];
1061
+ for(let child of gobj.priv.children) {
1062
+ nodes.push({
1063
+ route: route_of(child),
1064
+ label: gobj_read_attr(child, "label") || gobj_read_attr(child, "node_id"),
1065
+ icon: gobj_read_attr(child, "icon") || "",
1066
+ children: sub_route_nodes(child)
1067
+ });
1068
+ }
1069
+ return nodes;
1070
+ }
1071
+
1072
+ /************************************************************
1073
+ * Tell the root the shape changed (site map + any host), by
1074
+ * bubbling one event up the gobj tree. Sent, not published:
1075
+ * the root's parent is the SHELL, whose FSM must not receive
1076
+ * events of a gclass it does not know about.
1077
+ ************************************************************/
1078
+ function notify_tree_changed(gobj)
1079
+ {
1080
+ let parent = gobj_parent(gobj);
1081
+ if(parent && gobj_gclass_name(parent) === GCLASS_NAME) {
1082
+ gobj_send_event(parent, "EV_NODE_TREE_CHANGED", {}, gobj);
1083
+ return;
1084
+ }
1085
+ publish_sub_routes(gobj);
1086
+ }
1087
+
1088
+
1089
+
1090
+
1091
+ /***************************
1092
+ * Actions
1093
+ ***************************/
1094
+
1095
+
1096
+
1097
+
1098
+ /************************************************************
1099
+ * The shell moved the URL (root only).
1100
+ ************************************************************/
1101
+ function ac_route_changed(gobj, event, kw, src)
1102
+ {
1103
+ let base = kw.base || "";
1104
+ let my_base = gobj_read_attr(gobj, "base_route");
1105
+
1106
+ if(base !== my_base) {
1107
+ /* Another view owns the URL now. A keep_alive tree stays
1108
+ * created but must not keep painting a stale branch. */
1109
+ deactivate(gobj);
1110
+ gobj_change_state(gobj, "ST_OFF");
1111
+ return 0;
1112
+ }
1113
+
1114
+ gobj_change_state(gobj, activate(gobj, kw.subpath || "", null));
1115
+ return 0;
1116
+ }
1117
+
1118
+ /************************************************************
1119
+ * My parent put me on the path.
1120
+ ************************************************************/
1121
+ function ac_activate(gobj, event, kw, src)
1122
+ {
1123
+ gobj_change_state(gobj, activate(gobj, kw.subpath || "", kw.chrome_depth));
1124
+ return 0;
1125
+ }
1126
+
1127
+ /************************************************************
1128
+ * My parent took me off the path.
1129
+ ************************************************************/
1130
+ function ac_deactivate(gobj, event, kw, src)
1131
+ {
1132
+ deactivate(gobj);
1133
+ gobj_change_state(gobj, "ST_OFF");
1134
+ return 0;
1135
+ }
1136
+
1137
+ /************************************************************
1138
+ * A projection was clicked. The node never navigates itself
1139
+ * by mutating state: it changes the URL and lets the route
1140
+ * come back down (ROUTING.md §1.3).
1141
+ ************************************************************/
1142
+ function ac_nav_clicked(gobj, event, kw, src)
1143
+ {
1144
+ let route = kw.route || "";
1145
+
1146
+ if(empty_string(route)) {
1147
+ log_error(`${GCLASS_NAME} '${node_path_str(gobj)}': EV_NAV_CLICKED without route`);
1148
+ return -1;
1149
+ }
1150
+ let shell = yui_shell_of(gobj);
1151
+ if(!shell) {
1152
+ log_error(`${GCLASS_NAME} '${node_path_str(gobj)}': no shell to navigate to '${route}'`);
1153
+ return -1;
1154
+ }
1155
+ yui_shell_navigate(shell, route);
1156
+ return 0;
1157
+ }
1158
+
1159
+ /************************************************************
1160
+ * Runtime API: add a child. Same path the declared tree takes.
1161
+ ************************************************************/
1162
+ function ac_add_node(gobj, event, kw, src)
1163
+ {
1164
+ let priv = gobj.priv;
1165
+ let errors = [];
1166
+ let spec = normalize_spec(kw.spec, errors, `${node_path_str(gobj)}/`);
1167
+
1168
+ if(!spec) {
1169
+ for(let e of errors) {
1170
+ log_error(`${GCLASS_NAME}: ${e}`);
1171
+ }
1172
+ return -1;
1173
+ }
1174
+ if(find_child(gobj, spec.id)) {
1175
+ log_error(
1176
+ `${GCLASS_NAME} '${node_path_str(gobj)}': child '${spec.id}' ` +
1177
+ `already exists — sibling ids are url segments`
1178
+ );
1179
+ return -1;
1180
+ }
1181
+
1182
+ /* A DTP_JSON attr rejects null (json2data FAILED), so a node
1183
+ * without projection/content simply does not declare them. */
1184
+ let kw_child = {
1185
+ node_id: spec.id,
1186
+ label: spec.label,
1187
+ icon: spec.icon,
1188
+ tooltip: spec.tooltip,
1189
+ disabled: spec.disabled,
1190
+ chrome_depth: spec.chrome_depth,
1191
+ children: spec.children
1192
+ };
1193
+ if(spec.aliases && spec.aliases.length) {
1194
+ kw_child.aliases = spec.aliases;
1195
+ }
1196
+ if(spec.projection) {
1197
+ kw_child.projection = spec.projection;
1198
+ }
1199
+ if(spec.content) {
1200
+ kw_child.content = spec.content;
1201
+ }
1202
+ if(spec.link) {
1203
+ kw_child.link = spec.link;
1204
+ }
1205
+ let child = gobj_create_pure_child(spec.id, GCLASS_NAME, kw_child, gobj);
1206
+
1207
+ let index = (typeof kw.index === "number") ? kw.index : priv.children.length;
1208
+ if(index < 0) {
1209
+ index = 0;
1210
+ }
1211
+ if(index > priv.children.length) {
1212
+ index = priv.children.length;
1213
+ }
1214
+ priv.children.splice(index, 0, child);
1215
+
1216
+ if(gobj_is_running(gobj)) {
1217
+ gobj_start(child);
1218
+ }
1219
+ repaint(gobj);
1220
+ notify_tree_changed(gobj);
1221
+ return 0;
1222
+ }
1223
+
1224
+ /************************************************************
1225
+ * Runtime API: remove a child (and its whole subtree).
1226
+ ************************************************************/
1227
+ function ac_remove_node(gobj, event, kw, src)
1228
+ {
1229
+ let priv = gobj.priv;
1230
+ let node_id = kw.node_id || "";
1231
+ let child = find_child(gobj, node_id);
1232
+
1233
+ if(!child) {
1234
+ log_error(
1235
+ `${GCLASS_NAME} '${node_path_str(gobj)}': cannot remove '${node_id}' — no such child`
1236
+ );
1237
+ return -1;
1238
+ }
1239
+
1240
+ /* Removing the branch the user is standing on: move them to the
1241
+ * nearest living ancestor (me) BEFORE the gobj dies. */
1242
+ let was_active = (priv.active_child === child);
1243
+ if(was_active) {
1244
+ gobj_send_event(child, "EV_DEACTIVATE", {}, gobj);
1245
+ priv.active_child = null;
1246
+ }
1247
+
1248
+ let idx = priv.children.indexOf(child);
1249
+ priv.children.splice(idx, 1);
1250
+ if(gobj_is_running(child)) {
1251
+ gobj_stop(child);
1252
+ }
1253
+ gobj_destroy(child);
1254
+
1255
+ if(was_active) {
1256
+ let shell = yui_shell_of(gobj);
1257
+ if(shell) {
1258
+ yui_shell_navigate(shell, route_of(gobj), {replace: true});
1259
+ }
1260
+ gobj_change_state(gobj, "ST_SELF");
1261
+ render_self(gobj);
1262
+ } else {
1263
+ repaint(gobj);
1264
+ }
1265
+ notify_tree_changed(gobj);
1266
+ return 0;
1267
+ }
1268
+
1269
+ /************************************************************
1270
+ * Runtime API: change how I show my children.
1271
+ ************************************************************/
1272
+ function ac_set_projection(gobj, event, kw, src)
1273
+ {
1274
+ let errors = [];
1275
+ /* Validate through the same door as a whole spec: a projection
1276
+ * swap must not be the one path that accepts garbage. */
1277
+ let probe = normalize_spec(
1278
+ {id: gobj_read_attr(gobj, "node_id"), projection: kw.projection},
1279
+ errors, `${node_path_str(gobj)}`
1280
+ );
1281
+ if(!probe) {
1282
+ for(let e of errors) {
1283
+ log_error(`${GCLASS_NAME}: ${e}`);
1284
+ }
1285
+ return -1;
1286
+ }
1287
+
1288
+ gobj_write_attr(gobj, "projection", kw.projection || {});
1289
+ repaint(gobj);
1290
+ return 0;
1291
+ }
1292
+
1293
+ /************************************************************
1294
+ * Runtime API: change how much stacked chrome my corner shows.
1295
+ *
1296
+ * Declarative == dynamic: `chrome_depth` is how a branch trades
1297
+ * stacked strips for something else, so it has to be reachable
1298
+ * at runtime like every other part of the shape. Paired with
1299
+ * EV_SET_PROJECTION this is what lets an app offer "tabs or
1300
+ * breadcrumb" as a live choice.
1301
+ ************************************************************/
1302
+ function ac_set_chrome_depth(gobj, event, kw, src)
1303
+ {
1304
+ let depth = kw.chrome_depth;
1305
+
1306
+ if(typeof depth !== "number" || depth < -1 || Math.floor(depth) !== depth) {
1307
+ log_error(
1308
+ `${GCLASS_NAME} '${node_path_str(gobj)}': chrome_depth must be an ` +
1309
+ `integer >= 0 (or -1 to inherit) — got '${depth}'`
1310
+ );
1311
+ return -1;
1312
+ }
1313
+ gobj_write_attr(gobj, "chrome_depth", depth);
1314
+
1315
+ /* The depth is resolved along the ACTIVE PATH, so the node that
1316
+ * re-renders it is the one the shell is talking to: ask the tree
1317
+ * root to re-apply the current route rather than repainting only
1318
+ * from here, which would leave the ancestors' strips behind. */
1319
+ let shell = yui_shell_of(gobj);
1320
+ if(shell) {
1321
+ yui_shell_navigate(shell, gobj_read_attr(shell, "current_route") ||
1322
+ route_of(gobj), {replace: true});
1323
+ }
1324
+ return 0;
1325
+ }
1326
+
1327
+ /************************************************************
1328
+ * Runtime API: change (or set) my content.
1329
+ ************************************************************/
1330
+ function ac_set_content(gobj, event, kw, src)
1331
+ {
1332
+ let priv = gobj.priv;
1333
+ let errors = [];
1334
+ let probe = normalize_spec(
1335
+ {id: gobj_read_attr(gobj, "node_id"), content: kw.content},
1336
+ errors, `${node_path_str(gobj)}`
1337
+ );
1338
+ if(!probe) {
1339
+ for(let e of errors) {
1340
+ log_error(`${GCLASS_NAME}: ${e}`);
1341
+ }
1342
+ return -1;
1343
+ }
1344
+
1345
+ destroy_content(gobj);
1346
+ gobj_write_attr(gobj, "content", probe.content || {});
1347
+ repaint(gobj);
1348
+ return 0;
1349
+ }
1350
+
1351
+ /************************************************************
1352
+ * A descendant changed shape; keep bubbling to the root, which
1353
+ * owns the site-map contribution.
1354
+ ************************************************************/
1355
+ function ac_tree_changed(gobj, event, kw, src)
1356
+ {
1357
+ notify_tree_changed(gobj);
1358
+ return 0;
1359
+ }
1360
+
1361
+
1362
+
1363
+
1364
+ /***************************
1365
+ * Public API
1366
+ ***************************/
1367
+
1368
+
1369
+
1370
+
1371
+ /************************************************************
1372
+ * The runtime API. Every call is an event: the machine trace
1373
+ * IS the audit log of how the tree got its shape.
1374
+ ************************************************************/
1375
+ function yui_node_add(node, spec, index)
1376
+ {
1377
+ return gobj_send_event(node, "EV_ADD_NODE", {spec: spec, index: index}, node);
1378
+ }
1379
+
1380
+ function yui_node_remove(node, node_id)
1381
+ {
1382
+ return gobj_send_event(node, "EV_REMOVE_NODE", {node_id: node_id}, node);
1383
+ }
1384
+
1385
+ function yui_node_set_projection(node, projection)
1386
+ {
1387
+ return gobj_send_event(node, "EV_SET_PROJECTION", {projection: projection}, node);
1388
+ }
1389
+
1390
+ function yui_node_set_content(node, content)
1391
+ {
1392
+ return gobj_send_event(node, "EV_SET_CONTENT", {content: content}, node);
1393
+ }
1394
+
1395
+ function yui_node_set_chrome_depth(node, depth)
1396
+ {
1397
+ return gobj_send_event(node, "EV_SET_CHROME_DEPTH",
1398
+ {chrome_depth: depth}, node);
1399
+ }
1400
+
1401
+ /************************************************************
1402
+ * Resolve a path of ids from a node ("" = the node itself).
1403
+ * Returns null when any segment is missing — callers that mean
1404
+ * "the deepest living one" must say so.
1405
+ ************************************************************/
1406
+ function yui_node_find(node, path)
1407
+ {
1408
+ let r = walk_path(node, path);
1409
+ if(r.missing.length) {
1410
+ return null;
1411
+ }
1412
+ return r.node;
1413
+ }
1414
+
1415
+ /************************************************************
1416
+ * Canonical route of a node — for a host building shortcuts
1417
+ * (toolbar quick links) without hardcoding paths.
1418
+ ************************************************************/
1419
+ function yui_node_route(node)
1420
+ {
1421
+ return route_of(node);
1422
+ }
1423
+
1424
+ /************************************************************
1425
+ * Version of the tree CONTRACT this node belongs to.
1426
+ *
1427
+ * There is deliberately NO reparent/move API: once published, a
1428
+ * node's path is a url someone may depend on, so the shape is a
1429
+ * versioned contract, not runtime state. Renames migrate
1430
+ * through `aliases`; anything a version bump cannot cover is a
1431
+ * new tree, declared as such.
1432
+ ************************************************************/
1433
+ function yui_node_tree_version(node)
1434
+ {
1435
+ return gobj_read_attr(tree_root_of(node), "tree_version") || "";
1436
+ }
1437
+
1438
+
1439
+ /***************************************************************
1440
+ * FSM
1441
+ ***************************************************************/
1442
+ /*---------------------------------------------*
1443
+ * Global methods table
1444
+ *---------------------------------------------*/
1445
+ const gmt = {
1446
+ mt_create: mt_create,
1447
+ mt_start: mt_start,
1448
+ mt_stop: mt_stop,
1449
+ mt_destroy: mt_destroy
1450
+ };
1451
+
1452
+ /***************************************************************
1453
+ * Create the GClass
1454
+ ***************************************************************/
1455
+ function create_gclass(gclass_name)
1456
+ {
1457
+ if(__gclass__) {
1458
+ log_error(`GClass ALREADY created: ${gclass_name}`);
1459
+ return -1;
1460
+ }
1461
+
1462
+ /*---------------------------------------------*
1463
+ * States
1464
+ *
1465
+ * ST_OFF — not on the active path (nothing painted).
1466
+ * ST_SELF — I am the tip: my content and/or my index
1467
+ * projection of my children.
1468
+ * ST_CHILD — a child of mine is on the path: my chrome
1469
+ * projection plus that child's body.
1470
+ *
1471
+ * The mutation events are legal in every state on purpose:
1472
+ * the tree is dynamic, so a node can gain or lose children
1473
+ * while nobody is looking at it.
1474
+ *---------------------------------------------*/
1475
+ const states = [
1476
+ ["ST_OFF", [
1477
+ ["EV_ROUTE_CHANGED", ac_route_changed, null],
1478
+ ["EV_ACTIVATE", ac_activate, null],
1479
+ ["EV_ADD_NODE", ac_add_node, null],
1480
+ ["EV_REMOVE_NODE", ac_remove_node, null],
1481
+ ["EV_SET_PROJECTION", ac_set_projection, null],
1482
+ ["EV_SET_CHROME_DEPTH", ac_set_chrome_depth, null],
1483
+ ["EV_SET_CONTENT", ac_set_content, null],
1484
+ ["EV_NODE_TREE_CHANGED", ac_tree_changed, null]
1485
+ ]],
1486
+ ["ST_SELF", [
1487
+ ["EV_ROUTE_CHANGED", ac_route_changed, null],
1488
+ ["EV_ACTIVATE", ac_activate, null],
1489
+ ["EV_DEACTIVATE", ac_deactivate, null],
1490
+ ["EV_NAV_CLICKED", ac_nav_clicked, null],
1491
+ ["EV_ADD_NODE", ac_add_node, null],
1492
+ ["EV_REMOVE_NODE", ac_remove_node, null],
1493
+ ["EV_SET_PROJECTION", ac_set_projection, null],
1494
+ ["EV_SET_CHROME_DEPTH", ac_set_chrome_depth, null],
1495
+ ["EV_SET_CONTENT", ac_set_content, null],
1496
+ ["EV_NODE_TREE_CHANGED", ac_tree_changed, null]
1497
+ ]],
1498
+ ["ST_CHILD", [
1499
+ ["EV_ROUTE_CHANGED", ac_route_changed, null],
1500
+ ["EV_ACTIVATE", ac_activate, null],
1501
+ ["EV_DEACTIVATE", ac_deactivate, null],
1502
+ ["EV_NAV_CLICKED", ac_nav_clicked, null],
1503
+ ["EV_ADD_NODE", ac_add_node, null],
1504
+ ["EV_REMOVE_NODE", ac_remove_node, null],
1505
+ ["EV_SET_PROJECTION", ac_set_projection, null],
1506
+ ["EV_SET_CHROME_DEPTH", ac_set_chrome_depth, null],
1507
+ ["EV_SET_CONTENT", ac_set_content, null],
1508
+ ["EV_NODE_TREE_CHANGED", ac_tree_changed, null]
1509
+ ]]
1510
+ ];
1511
+
1512
+ /*---------------------------------------------*
1513
+ * Events
1514
+ *---------------------------------------------*/
1515
+ const event_types = [
1516
+ ["EV_ROUTE_CHANGED", 0],
1517
+ ["EV_ACTIVATE", 0],
1518
+ ["EV_DEACTIVATE", 0],
1519
+ ["EV_NAV_CLICKED", 0],
1520
+ ["EV_ADD_NODE", 0],
1521
+ ["EV_REMOVE_NODE", 0],
1522
+ ["EV_SET_PROJECTION", 0],
1523
+ ["EV_SET_CHROME_DEPTH", 0],
1524
+ ["EV_SET_CONTENT", 0],
1525
+ ["EV_NODE_TREE_CHANGED", 0]
1526
+ ];
1527
+
1528
+ __gclass__ = gclass_create(
1529
+ gclass_name,
1530
+ event_types,
1531
+ states,
1532
+ gmt,
1533
+ 0, // lmt,
1534
+ attrs_table,
1535
+ PRIVATE_DATA,
1536
+ 0, // authz_table,
1537
+ 0, // command_table,
1538
+ 0, // s_user_trace_level
1539
+ 0 // gclass_flag
1540
+ );
1541
+
1542
+ if(!__gclass__) {
1543
+ return -1;
1544
+ }
1545
+
1546
+ return 0;
1547
+ }
1548
+
1549
+ /***************************************************************
1550
+ * Register GClass
1551
+ ***************************************************************/
1552
+ function register_c_yui_node()
1553
+ {
1554
+ return create_gclass(GCLASS_NAME);
1555
+ }
1556
+
1557
+ export {
1558
+ register_c_yui_node,
1559
+ yui_node_add,
1560
+ yui_node_remove,
1561
+ yui_node_set_projection,
1562
+ yui_node_set_content,
1563
+ yui_node_set_chrome_depth,
1564
+ yui_node_find,
1565
+ yui_node_route,
1566
+ yui_node_tree_version
1567
+ };