@yuneta/gobj-ui 2.6.0 → 4.0.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 (56) hide show
  1. package/README.md +331 -11
  2. package/dist/gobj-ui.cjs.js +21386 -17919
  3. package/dist/gobj-ui.es.js +26059 -22613
  4. package/index.js +45 -9
  5. package/package.json +4 -4
  6. package/src/c_g6_nodes_tree.js +128 -40
  7. package/src/c_yui_form.css +9 -0
  8. package/src/c_yui_form.js +113 -53
  9. package/src/c_yui_gobj_tree_js.js +58 -36
  10. package/src/c_yui_json.css +139 -0
  11. package/src/c_yui_json.js +928 -0
  12. package/src/c_yui_json_graph.js +110 -20
  13. package/src/c_yui_map.js +18 -36
  14. package/src/c_yui_nav.js +6 -9
  15. package/src/c_yui_period.css +184 -0
  16. package/src/c_yui_period.js +1441 -0
  17. package/src/c_yui_shell.css +120 -0
  18. package/src/c_yui_shell.js +672 -111
  19. package/src/c_yui_treedb_graph.js +639 -37
  20. package/src/c_yui_treedb_schema.js +478 -0
  21. package/src/c_yui_treedb_topic_with_form.css +18 -0
  22. package/src/c_yui_treedb_topic_with_form.js +153 -103
  23. package/src/c_yui_treedb_topics.css +73 -0
  24. package/src/c_yui_treedb_topics.js +1070 -29
  25. package/src/c_yui_window.css +22 -0
  26. package/src/c_yui_window.js +182 -97
  27. package/src/c_yui_window_manager.js +38 -4
  28. package/src/json_view_helpers.js +214 -0
  29. package/src/json_view_helpers.test.js +135 -0
  30. package/src/route_map_model.js +317 -0
  31. package/src/route_map_model.test.js +209 -0
  32. package/src/route_resolver.js +24 -1
  33. package/src/route_resolver.test.js +40 -1
  34. package/src/shell_modals.js +162 -52
  35. package/src/shell_route_map.css +225 -0
  36. package/src/shell_route_map.js +363 -0
  37. package/src/tabulator.css +245 -0
  38. package/src/yui_dev.js +130 -8
  39. package/src/yui_frontend_view.js +106 -0
  40. package/src/yui_icons.css +62 -0
  41. package/src/yui_inputs.css +8 -3
  42. package/src/yui_inputs.js +51 -8
  43. package/src/yui_tabulator_i18n.js +128 -0
  44. package/src/yui_theme.js +147 -0
  45. package/src/yui_time.js +666 -0
  46. package/src/yui_time.test.js +330 -0
  47. package/src/yui_toolbar.css +28 -0
  48. package/src/yui_toolbar.js +86 -43
  49. package/src/c_yui_main.css +0 -501
  50. package/src/c_yui_main.js +0 -1623
  51. package/src/c_yui_routing.css +0 -18
  52. package/src/c_yui_routing.js +0 -837
  53. package/src/c_yui_tabs.js +0 -487
  54. package/src/themes.js +0 -215
  55. package/src/ytable.css +0 -63
  56. package/src/ytable.js +0 -505
@@ -0,0 +1,214 @@
1
+ /***********************************************************************
2
+ * json_view_helpers.js
3
+ *
4
+ * Pure, testable logic behind C_YUI_JSON (the lazy JSON tree
5
+ * viewer). Kept out of the gclass so it can be unit-tested with
6
+ * no DOM: collapsed-sentinel detection, path (segments) algebra,
7
+ * search matching, timestamp recognition/formatting and the JSON
8
+ * type discriminator.
9
+ *
10
+ * Path convention mirrors the C kernel (kw_collapse / kw_find_path
11
+ * in kwid.c): segments are joined by the backtick delimiter, arrays
12
+ * are indexed by their numeric position.
13
+ *
14
+ * Copyright (c) 2026, ArtGins.
15
+ * All Rights Reserved.
16
+ ***********************************************************************/
17
+
18
+ /*
19
+ * Path delimiter, identical to the kernel's `delimiter` in kwid.c.
20
+ */
21
+ export const JSON_PATH_DELIMITER = "`";
22
+
23
+ /************************************************************
24
+ * JSON type discriminator (viewer vocabulary)
25
+ ************************************************************/
26
+ export function json_type(value)
27
+ {
28
+ if(value === null) {
29
+ return "null";
30
+ }
31
+ if(Array.isArray(value)) {
32
+ return "array";
33
+ }
34
+ switch(typeof value) {
35
+ case "string":
36
+ return "string";
37
+ case "number":
38
+ return "number";
39
+ case "boolean":
40
+ return "boolean";
41
+ case "object":
42
+ return "object";
43
+ default:
44
+ return "unknown";
45
+ }
46
+ }
47
+
48
+ /************************************************************
49
+ * Detect a kw_collapse() sentinel.
50
+ *
51
+ * The kernel replaces an over-limit dict with:
52
+ * { "__collapsed__": { "path": ..., "size": N } }
53
+ * and an over-limit array with:
54
+ * [ { "__collapsed__": { "path": ..., "size": N } } ]
55
+ *
56
+ * Returns { size, path, is_array } or null.
57
+ ************************************************************/
58
+ export function is_collapsed(value)
59
+ {
60
+ if(value && typeof value === "object" && !Array.isArray(value)) {
61
+ let keys = Object.keys(value);
62
+ if(keys.length === 1 && keys[0] === "__collapsed__") {
63
+ let c = value.__collapsed__ || {};
64
+ return {size: c.size, path: c.path, is_array: false};
65
+ }
66
+ return null;
67
+ }
68
+ if(Array.isArray(value) && value.length === 1) {
69
+ let e = value[0];
70
+ if(e && typeof e === "object" && !Array.isArray(e) &&
71
+ Object.keys(e).length === 1 && e.__collapsed__)
72
+ {
73
+ let c = e.__collapsed__ || {};
74
+ return {size: c.size, path: c.path, is_array: true};
75
+ }
76
+ }
77
+ return null;
78
+ }
79
+
80
+ /************************************************************
81
+ * Join / split absolute path segments (kernel convention)
82
+ ************************************************************/
83
+ export function seg_join(segments)
84
+ {
85
+ return segments.map(function(s) {
86
+ return String(s);
87
+ }).join(JSON_PATH_DELIMITER);
88
+ }
89
+
90
+ export function seg_split(path)
91
+ {
92
+ if(path === "" || path === null || path === undefined) {
93
+ return [];
94
+ }
95
+ return path.split(JSON_PATH_DELIMITER);
96
+ }
97
+
98
+ /************************************************************
99
+ * Walk a JSON tree by absolute segments; undefined if the
100
+ * path does not resolve.
101
+ ************************************************************/
102
+ export function get_by_segments(root, segments)
103
+ {
104
+ let v = root;
105
+ for(let seg of segments) {
106
+ if(v === null || v === undefined) {
107
+ return undefined;
108
+ }
109
+ if(Array.isArray(v)) {
110
+ v = v[Number(seg)];
111
+ } else if(typeof v === "object") {
112
+ v = v[seg];
113
+ } else {
114
+ return undefined;
115
+ }
116
+ }
117
+ return v;
118
+ }
119
+
120
+ /************************************************************
121
+ * Set a value at absolute segments (mutates root).
122
+ *
123
+ * segments == [] means "replace the whole root" — the caller
124
+ * must use the RETURNED value in that case (a primitive/array
125
+ * root cannot be mutated in place).
126
+ ************************************************************/
127
+ export function set_by_segments(root, segments, new_value)
128
+ {
129
+ if(segments.length === 0) {
130
+ return new_value;
131
+ }
132
+ let parent = get_by_segments(root, segments.slice(0, -1));
133
+ if(parent === null || parent === undefined || typeof parent !== "object") {
134
+ return root;
135
+ }
136
+ let last = segments[segments.length - 1];
137
+ if(Array.isArray(parent)) {
138
+ parent[Number(last)] = new_value;
139
+ } else {
140
+ parent[last] = new_value;
141
+ }
142
+ return root;
143
+ }
144
+
145
+ /************************************************************
146
+ * Does `value` (or any loaded descendant / its own key)
147
+ * contain the lower-cased search term?
148
+ *
149
+ * Collapsed (not-yet-loaded) subtrees can't be searched, so
150
+ * they never match on content — only their key can match,
151
+ * which the renderer checks separately.
152
+ ************************************************************/
153
+ export function subtree_matches(value, term)
154
+ {
155
+ if(!term) {
156
+ return true;
157
+ }
158
+ if(is_collapsed(value)) {
159
+ return false;
160
+ }
161
+ let type = json_type(value);
162
+ if(type === "object") {
163
+ for(let [k, v] of Object.entries(value)) {
164
+ if(String(k).toLowerCase().includes(term)) {
165
+ return true;
166
+ }
167
+ if(subtree_matches(v, term)) {
168
+ return true;
169
+ }
170
+ }
171
+ return false;
172
+ }
173
+ if(type === "array") {
174
+ for(let v of value) {
175
+ if(subtree_matches(v, term)) {
176
+ return true;
177
+ }
178
+ }
179
+ return false;
180
+ }
181
+ return String(value).toLowerCase().includes(term);
182
+ }
183
+
184
+ /************************************************************
185
+ * Fields whose numeric value is an epoch timestamp.
186
+ * Mirrors the timestampTag list wired into C_YUI_FORM's
187
+ * jsoneditor control, so the two viewers agree.
188
+ ************************************************************/
189
+ export function is_time_field(field)
190
+ {
191
+ return field === "__t__" || field === "__tm__" ||
192
+ field === "tm" || field === "t" || field === "time" ||
193
+ field === "from_t" || field === "to_t" ||
194
+ field === "t_input" || field === "t_output" ||
195
+ field === "from_tm" || field === "to_tm";
196
+ }
197
+
198
+ /************************************************************
199
+ * Format an epoch value as a local wall-clock string.
200
+ * Accepts seconds or milliseconds; returns null when the
201
+ * value is not a usable positive timestamp (0 == "unset").
202
+ ************************************************************/
203
+ export function format_epoch(value)
204
+ {
205
+ if(typeof value !== "number" || !isFinite(value) || value <= 0) {
206
+ return null;
207
+ }
208
+ let ms = Math.abs(value) < 1e12 ? value * 1000 : value;
209
+ let d = new Date(ms);
210
+ if(isNaN(d.getTime())) {
211
+ return null;
212
+ }
213
+ return d.toLocaleString();
214
+ }
@@ -0,0 +1,135 @@
1
+ /***********************************************************************
2
+ * json_view_helpers.test.js
3
+ *
4
+ * Unit tests for the pure logic of C_YUI_JSON.
5
+ * Run with: npm test
6
+ ***********************************************************************/
7
+ import { test, expect } from "vitest";
8
+ import {
9
+ json_type,
10
+ is_collapsed,
11
+ seg_join,
12
+ seg_split,
13
+ get_by_segments,
14
+ set_by_segments,
15
+ subtree_matches,
16
+ is_time_field,
17
+ format_epoch,
18
+ } from "./json_view_helpers.js";
19
+
20
+
21
+ /*============================================================
22
+ * json_type
23
+ *============================================================*/
24
+ test("json_type discriminates every JSON kind", () => {
25
+ expect(json_type(null)).toBe("null");
26
+ expect(json_type("x")).toBe("string");
27
+ expect(json_type(3)).toBe("number");
28
+ expect(json_type(true)).toBe("boolean");
29
+ expect(json_type([])).toBe("array");
30
+ expect(json_type({})).toBe("object");
31
+ });
32
+
33
+
34
+ /*============================================================
35
+ * is_collapsed — both kernel sentinel shapes
36
+ *============================================================*/
37
+ test("is_collapsed detects the dict sentinel", () => {
38
+ let v = {__collapsed__: {path: "topics`nodes", size: 4231}};
39
+ let c = is_collapsed(v);
40
+ expect(c).not.toBeNull();
41
+ expect(c.size).toBe(4231);
42
+ expect(c.path).toBe("topics`nodes");
43
+ expect(c.is_array).toBe(false);
44
+ });
45
+
46
+ test("is_collapsed detects the array sentinel", () => {
47
+ let v = [{__collapsed__: {path: "topics`rows", size: 99}}];
48
+ let c = is_collapsed(v);
49
+ expect(c).not.toBeNull();
50
+ expect(c.size).toBe(99);
51
+ expect(c.is_array).toBe(true);
52
+ });
53
+
54
+ test("is_collapsed ignores ordinary dicts/arrays", () => {
55
+ expect(is_collapsed({a: 1, __collapsed__: 2})).toBeNull();
56
+ expect(is_collapsed({a: 1})).toBeNull();
57
+ expect(is_collapsed([1, 2])).toBeNull();
58
+ expect(is_collapsed([{a: 1}])).toBeNull();
59
+ expect(is_collapsed("x")).toBeNull();
60
+ });
61
+
62
+
63
+ /*============================================================
64
+ * segments algebra
65
+ *============================================================*/
66
+ test("seg_join / seg_split round-trip with the backtick delimiter", () => {
67
+ expect(seg_join(["topics", "nodes", 4])).toBe("topics`nodes`4");
68
+ expect(seg_split("topics`nodes`4")).toEqual(["topics", "nodes", "4"]);
69
+ expect(seg_split("")).toEqual([]);
70
+ expect(seg_join([])).toBe("");
71
+ });
72
+
73
+ test("get_by_segments walks dicts and arrays (numeric index)", () => {
74
+ let root = {a: {b: [{id: "x"}, {id: "y"}]}};
75
+ expect(get_by_segments(root, ["a", "b", "1", "id"])).toBe("y");
76
+ expect(get_by_segments(root, ["a", "nope"])).toBeUndefined();
77
+ expect(get_by_segments(root, [])).toBe(root);
78
+ });
79
+
80
+ test("set_by_segments splices a fetched subtree at a dict path", () => {
81
+ let root = {topics: {nodes: {__collapsed__: {path: "topics`nodes", size: 5}}}};
82
+ let full = {n1: {id: "n1"}, n2: {id: "n2"}};
83
+ set_by_segments(root, ["topics", "nodes"], full);
84
+ expect(root.topics.nodes).toBe(full);
85
+ });
86
+
87
+ test("set_by_segments splices at an array index", () => {
88
+ let root = {rows: [ [{__collapsed__: {path: "rows`0", size: 3}}] ]};
89
+ set_by_segments(root, ["rows", "0"], [10, 20, 30]);
90
+ expect(root.rows[0]).toEqual([10, 20, 30]);
91
+ });
92
+
93
+ test("set_by_segments with [] returns the replacement (root swap)", () => {
94
+ let out = set_by_segments({old: 1}, [], {fresh: 2});
95
+ expect(out).toEqual({fresh: 2});
96
+ });
97
+
98
+
99
+ /*============================================================
100
+ * subtree_matches
101
+ *============================================================*/
102
+ test("subtree_matches finds a term in keys and primitive values", () => {
103
+ let v = {alpha: {beta: "HELLO world"}};
104
+ expect(subtree_matches(v, "hello")).toBe(true);
105
+ expect(subtree_matches(v, "beta")).toBe(true);
106
+ expect(subtree_matches(v, "missing")).toBe(false);
107
+ });
108
+
109
+ test("subtree_matches never matches inside a collapsed subtree", () => {
110
+ let v = {__collapsed__: {path: "x", size: 9}};
111
+ expect(subtree_matches(v, "x")).toBe(false);
112
+ });
113
+
114
+ test("subtree_matches with empty term is always true", () => {
115
+ expect(subtree_matches({a: 1}, "")).toBe(true);
116
+ });
117
+
118
+
119
+ /*============================================================
120
+ * time fields
121
+ *============================================================*/
122
+ test("is_time_field matches the kernel timestamp field set", () => {
123
+ ["__t__", "t", "tm", "from_t", "to_t", "t_input"].forEach((f) => {
124
+ expect(is_time_field(f)).toBe(true);
125
+ });
126
+ expect(is_time_field("name")).toBe(false);
127
+ });
128
+
129
+ test("format_epoch handles seconds, milliseconds and the unset case", () => {
130
+ expect(format_epoch(0)).toBeNull();
131
+ expect(format_epoch(-5)).toBeNull();
132
+ expect(format_epoch("x")).toBeNull();
133
+ expect(typeof format_epoch(1700000000)).toBe("string"); // seconds
134
+ expect(typeof format_epoch(1700000000000)).toBe("string"); // milliseconds
135
+ });
@@ -0,0 +1,317 @@
1
+ /***********************************************************************
2
+ * route_map_model.js
3
+ *
4
+ * Pure nav-map builder for C_YUI_SHELL's site map (no gobj,
5
+ * no DOM, no imports) — kept apart so it is trivially
6
+ * unit-testable, like route_resolver.js.
7
+ *
8
+ * build_nav_map() turns the shell's declarative surface into
9
+ * an ordered tree for the site-map viewer / documentation:
10
+ * toolbar (incl. the account dropdown), EVERY declared menu
11
+ * (incl. live dynamic tabs), each mounted view's contributed
12
+ * sub-routes, and the routes declared ONLY in the route table
13
+ * (config.shell.routes) that no menu item points at — so the
14
+ * map really is the WHOLE navigation surface, and an orphan
15
+ * route is visible instead of silently unreachable.
16
+ *
17
+ * Copyright (c) 2026, ArtGins.
18
+ * All Rights Reserved.
19
+ ***********************************************************************/
20
+
21
+ /************************************************************
22
+ * One node of the nav map from a declared config item, in
23
+ * DECLARATION ORDER (never sorted). Recurses into static submenus
24
+ * and toolbar dropdowns, and merges the LIVE dynamic submenu tabs
25
+ * (added at runtime via yui_shell_set_submenu) by parent id.
26
+ ************************************************************/
27
+ function nav_node_from_item(it, index)
28
+ {
29
+ if(!it || it.type === "divider" || it.type === "header") {
30
+ return null;
31
+ }
32
+ let action = it.action || {};
33
+ let route = it.route ||
34
+ (action.type === "navigate" ? action.route : "") || "";
35
+ let event = (action.type === "event") ? action.event : "";
36
+ /* Where it is implemented: the view GClass mounted at this route
37
+ * (and, for an action route, the event it fires) — from item_index. */
38
+ let gclass = "";
39
+ if(route && index[route] && index[route].target) {
40
+ let tgt = index[route].target;
41
+ gclass = tgt.gclass || "";
42
+ if(!event && tgt.kind === "action" && tgt.event) {
43
+ event = tgt.event;
44
+ }
45
+ }
46
+ let node = {
47
+ id: it.id || "",
48
+ label: it.name || it.wordmark || it.id || route || "",
49
+ icon: it.icon || "",
50
+ route: route,
51
+ event: event,
52
+ gclass: gclass,
53
+ kind: it.type || (route ? "route" : (event ? "action" :
54
+ (action.type || "item"))),
55
+ children: []
56
+ };
57
+
58
+ /* Static submenu (declared) and toolbar dropdown (the account menu). */
59
+ let sub_items = (it.submenu && Array.isArray(it.submenu.items)) ?
60
+ it.submenu.items :
61
+ ((action.type === "dropdown" && Array.isArray(action.items)) ?
62
+ action.items : null);
63
+ if(sub_items) {
64
+ for(let s of sub_items) {
65
+ let n = nav_node_from_item(s, index);
66
+ if(n) {
67
+ node.children.push(n);
68
+ }
69
+ }
70
+ }
71
+
72
+ /* Live dynamic submenu children (runtime tabs) — item_index entries
73
+ * whose parent is this item, in item_index (insertion) order, minus
74
+ * any already added statically. */
75
+ if(it.id && index) {
76
+ for(let r of Object.keys(index)) {
77
+ let e = index[r];
78
+ if(e && e.parent_item && e.parent_item.id === it.id &&
79
+ !node.children.some((c) => c.route === r)) {
80
+ node.children.push({
81
+ id: (e.item && e.item.id) || "",
82
+ label: (e.item && e.item.name) || r,
83
+ icon: (e.item && e.item.icon) || "",
84
+ route: r,
85
+ event: "",
86
+ gclass: (e.target && e.target.gclass) || "",
87
+ kind: "route",
88
+ children: []
89
+ });
90
+ }
91
+ }
92
+ }
93
+ return node;
94
+ }
95
+
96
+ /************************************************************
97
+ * Deep-copy contributed sub-route nodes.
98
+ *
99
+ * yui_shell_set_sub_routes() stores the CALLER's array by
100
+ * reference — those objects belong to the mounted view and
101
+ * outlive this build. Splicing them into the tree as-is made
102
+ * build_nav_map() a mutator of its own input: mark_current()
103
+ * stamped `current: true` on a view-owned object and nothing
104
+ * ever cleared it, so every later build kept the stale mark and
105
+ * the map grew a second "you are here" per visited sub-route.
106
+ ************************************************************/
107
+ function clone_nodes(nodes)
108
+ {
109
+ let out = [];
110
+ for(let n of nodes) {
111
+ if(!n) {
112
+ continue;
113
+ }
114
+ let c = Object.assign({}, n);
115
+ c.children = Array.isArray(n.children) ? clone_nodes(n.children) : [];
116
+ delete c.current;
117
+ out.push(c);
118
+ }
119
+ return out;
120
+ }
121
+
122
+ /************************************************************
123
+ * Collect every route reachable from the given nodes (recursing
124
+ * into children, sub-route contributions included).
125
+ ************************************************************/
126
+ function collect_routes(nodes, into)
127
+ {
128
+ for(let n of nodes) {
129
+ if(!n) {
130
+ continue;
131
+ }
132
+ if(n.route) {
133
+ into[n.route] = true;
134
+ }
135
+ if(Array.isArray(n.children) && n.children.length) {
136
+ collect_routes(n.children, into);
137
+ }
138
+ }
139
+ }
140
+
141
+ /************************************************************
142
+ * Mark "you are here": the node whose route best matches
143
+ * current_route — exact hit wins, else the LONGEST declared
144
+ * route that is a path-prefix of it (the base view of a deep
145
+ * subpath position). At most one node is marked.
146
+ ************************************************************/
147
+ function mark_current(groups, current_route)
148
+ {
149
+ if(!current_route) {
150
+ return;
151
+ }
152
+ let best = null;
153
+ let visit = (n) => {
154
+ if(n.route) {
155
+ if(n.route === current_route) {
156
+ if(!best || best.node.route !== current_route) {
157
+ best = {node: n, len: n.route.length};
158
+ }
159
+ } else if(current_route.startsWith(n.route + "/") &&
160
+ (!best || (best.node.route !== current_route &&
161
+ n.route.length > best.len))) {
162
+ best = {node: n, len: n.route.length};
163
+ }
164
+ }
165
+ if(Array.isArray(n.children)) {
166
+ n.children.forEach(visit);
167
+ }
168
+ };
169
+ for(let g of groups) {
170
+ g.forEach(visit);
171
+ }
172
+ if(best) {
173
+ best.node.current = true;
174
+ }
175
+ }
176
+
177
+ /************************************************************
178
+ * build_nav_map({config, item_index, sub_routes, event_handlers,
179
+ * current_route}) →
180
+ * { brand:{label,route,current?}, toolbar:[node…], nav:[node…],
181
+ * other:[node…] }
182
+ * where a node is {id,label,icon,route,event,gclass,kind,
183
+ * children[], current?}. `route` is a navigable hash (or "");
184
+ * `event` is the action it fires; `gclass` is the view GClass
185
+ * mounted at that route or the self-declared handler(s) of the
186
+ * event (where it is implemented).
187
+ *
188
+ * - `nav` walks EVERY declared menu in declaration order: the
189
+ * `primary` menu contributes its items flat (the common case);
190
+ * any other menu contributes a group node labelled by its key.
191
+ * - `other` lists the routes declared only in the route table
192
+ * (config.shell.routes) that no rendered node covers — root "/",
193
+ * URL-only action routes, toolbar-less forms. Brand-covered
194
+ * and menu-covered routes are excluded.
195
+ * - the node whose route best matches `current_route` is marked
196
+ * `current: true` ("you are here") — the brand included, since it
197
+ * renders as the tree's root row.
198
+ *
199
+ * PURE: the returned tree is built fresh every call, contributed
200
+ * sub-route nodes included (they are cloned, never spliced in by
201
+ * reference — see clone_nodes). Nothing the caller passed in is
202
+ * mutated, so repeated builds cannot accumulate state.
203
+ ************************************************************/
204
+ function build_nav_map(input)
205
+ {
206
+ let config = (input && input.config) || {};
207
+ let index = (input && input.item_index) || {};
208
+ let sub = (input && input.sub_routes) || {};
209
+ let handlers = (input && input.event_handlers) || {};
210
+ let current_route = (input && input.current_route) || "";
211
+
212
+ let brand = {label: "", route: ""};
213
+ let toolbar = [];
214
+ let tb = config.toolbar && Array.isArray(config.toolbar.items) ?
215
+ config.toolbar.items : [];
216
+ for(let it of tb) {
217
+ if(it && it.type === "brand") {
218
+ let a = it.action || {};
219
+ brand = {
220
+ label: it.wordmark || it.alt || it.id || "",
221
+ route: it.route || (a.type === "navigate" ? a.route : "") || ""
222
+ };
223
+ continue;
224
+ }
225
+ let n = nav_node_from_item(it, index);
226
+ if(n) {
227
+ toolbar.push(n);
228
+ }
229
+ }
230
+
231
+ /* Every declared menu, in declaration order. `primary` stays flat
232
+ * (backwards-compatible single-menu shape); any additional menu is
233
+ * wrapped in a group node so its origin stays visible. */
234
+ let nav = [];
235
+ let menus = config.menu || {};
236
+ for(let menu_id of Object.keys(menus)) {
237
+ let m = menus[menu_id];
238
+ if(!m || !Array.isArray(m.items)) {
239
+ continue;
240
+ }
241
+ let nodes = [];
242
+ for(let it of m.items) {
243
+ let n = nav_node_from_item(it, index);
244
+ if(n) {
245
+ nodes.push(n);
246
+ }
247
+ }
248
+ if(menu_id === "primary") {
249
+ nav = nav.concat(nodes);
250
+ } else if(nodes.length) {
251
+ nav.push({
252
+ id: menu_id, label: menu_id, icon: "", route: "", event: "",
253
+ gclass: "", kind: "group", children: nodes
254
+ });
255
+ }
256
+ }
257
+
258
+ /* Enrich the tree: merge each mounted view's declared sub-routes into
259
+ * its base-route node, and stamp the handler gclass on action-event
260
+ * nodes (where the action is implemented). */
261
+ let enrich = (node) => {
262
+ if(node.route && Array.isArray(sub[node.route]) && sub[node.route].length) {
263
+ node.children = (node.children || []).concat(
264
+ clone_nodes(sub[node.route]));
265
+ }
266
+ if(node.event && !node.gclass &&
267
+ Array.isArray(handlers[node.event]) && handlers[node.event].length) {
268
+ node.gclass = handlers[node.event].join(", ");
269
+ }
270
+ if(Array.isArray(node.children)) {
271
+ node.children.forEach(enrich);
272
+ }
273
+ };
274
+ toolbar.forEach(enrich);
275
+ nav.forEach(enrich);
276
+
277
+ /* Routes declared in the index that no rendered node covers —
278
+ * root "/", route-table-only action routes, toolbar-less forms.
279
+ * Declaration (insertion) order, like everything else. */
280
+ let covered = {};
281
+ if(brand.route) {
282
+ covered[brand.route] = true;
283
+ }
284
+ collect_routes(toolbar, covered);
285
+ collect_routes(nav, covered);
286
+ let other = [];
287
+ for(let r of Object.keys(index)) {
288
+ if(covered[r]) {
289
+ continue;
290
+ }
291
+ let e = index[r];
292
+ let tgt = (e && e.target) || null;
293
+ other.push({
294
+ id: (e && e.item && e.item.id) || "",
295
+ label: (e && e.item && (e.item.name || e.item.id)) || r,
296
+ icon: (e && e.item && e.item.icon) || "",
297
+ route: r,
298
+ event: (tgt && tgt.kind === "action" && tgt.event) || "",
299
+ gclass: (tgt && tgt.gclass) || "",
300
+ kind: (tgt && tgt.kind === "action") ? "action-route" : "route",
301
+ children: []
302
+ });
303
+ }
304
+ other.forEach(enrich);
305
+
306
+ /* The brand is rendered as the tree's ROOT row (shell_route_map),
307
+ * so it is markable like any other route — and it is the only
308
+ * rendered node in neither group, which left an app whose brand
309
+ * routes home unable to show "you are here" at all. Marked LAST:
310
+ * a menu item declaring the same route is the more useful hit, and
311
+ * the first exact match wins. */
312
+ mark_current([toolbar, nav, other, [brand]], current_route);
313
+
314
+ return {brand: brand, toolbar: toolbar, nav: nav, other: other};
315
+ }
316
+
317
+ export { build_nav_map };