@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,282 @@
1
+ /***********************************************************************
2
+ * node_tree_model.test.js
3
+ *
4
+ * Unit tests for the pure node-tree helpers.
5
+ ***********************************************************************/
6
+ import { test, expect } from "vitest";
7
+ import {
8
+ chrome_visible,
9
+ split_subpath,
10
+ head_tail,
11
+ join_route,
12
+ projection_renders,
13
+ child_nav_items,
14
+ normalize_spec,
15
+ } from "./node_tree_model.js";
16
+
17
+
18
+ /***************************************************************
19
+ * subpath splitting
20
+ ***************************************************************/
21
+ test("split_subpath drops empty segments", () => {
22
+ expect(split_subpath("a/b/c")).toEqual(["a", "b", "c"]);
23
+ expect(split_subpath("/a//b/")).toEqual(["a", "b"]);
24
+ expect(split_subpath("")).toEqual([]);
25
+ expect(split_subpath(null)).toEqual([]);
26
+ });
27
+
28
+ test("head_tail names the child and hands down the rest", () => {
29
+ expect(head_tail("energy/north/m1")).toEqual({head: "energy", tail: "north/m1"});
30
+ expect(head_tail("energy")).toEqual({head: "energy", tail: ""});
31
+ expect(head_tail("")).toEqual({head: "", tail: ""});
32
+ });
33
+
34
+
35
+ /***************************************************************
36
+ * canonical routes
37
+ ***************************************************************/
38
+ test("join_route builds the canonical route of a node", () => {
39
+ expect(join_route("/cards", ["energy", "north"])).toBe("/cards/energy/north");
40
+ expect(join_route("/cards", [])).toBe("/cards");
41
+ expect(join_route("/cards/", ["a"])).toBe("/cards/a");
42
+ expect(join_route("cards", ["a"])).toBe("/cards/a");
43
+ expect(join_route("/", ["a", "b"])).toBe("/a/b");
44
+ });
45
+
46
+
47
+ /***************************************************************
48
+ * projections
49
+ ***************************************************************/
50
+ test("no projection means: show my children as cards, no chrome", () => {
51
+ expect(projection_renders(undefined, "index")).toEqual([{layout: "cards"}]);
52
+ expect(projection_renders(undefined, "chrome")).toEqual([]);
53
+ });
54
+
55
+ test("a bare string or render object is the INDEX projection only", () => {
56
+ expect(projection_renders("vertical", "index")).toEqual([{layout: "vertical"}]);
57
+ expect(projection_renders("vertical", "chrome")).toEqual([]);
58
+ expect(projection_renders({layout: "cards", show_label: false}, "index"))
59
+ .toEqual([{layout: "cards", show_label: false}]);
60
+ });
61
+
62
+ test("index + chrome, chrome as an array (tabs on desktop, backbar on mobile)", () => {
63
+ let p = {
64
+ index: {layout: "cards"},
65
+ chrome: [
66
+ {layout: "tabs", show_on: ">=tablet"},
67
+ {layout: "backbar", show_on: "<tablet"}
68
+ ]
69
+ };
70
+ expect(projection_renders(p, "index")).toEqual([{layout: "cards"}]);
71
+ expect(projection_renders(p, "chrome")).toEqual([
72
+ {layout: "tabs", show_on: ">=tablet"},
73
+ {layout: "backbar", show_on: "<tablet"}
74
+ ]);
75
+ });
76
+
77
+ test("projection_renders returns copies, never the caller's objects", () => {
78
+ let p = {index: {layout: "cards"}};
79
+ let r = projection_renders(p, "index");
80
+ r[0].layout = "tabs";
81
+ expect(p.index.layout).toBe("cards");
82
+ });
83
+
84
+
85
+ /***************************************************************
86
+ * children → nav items
87
+ ***************************************************************/
88
+ test("child_nav_items builds C_YUI_NAV items with canonical routes", () => {
89
+ let children = [
90
+ {id: "a", label: "Alpha", icon: "yi-eye", tooltip: "", disabled: false},
91
+ {id: "b", label: "Beta", icon: "", tooltip: "hi", disabled: true},
92
+ ];
93
+ let items = child_nav_items(children, (id) => `/cards/${id}`);
94
+ expect(items).toEqual([
95
+ {id: "a", name: "Alpha", route: "/cards/a", icon: "yi-eye"},
96
+ {id: "b", name: "Beta", route: "/cards/b", tooltip: "hi", disabled: true},
97
+ ]);
98
+ });
99
+
100
+
101
+ /***************************************************************
102
+ * spec validation
103
+ ***************************************************************/
104
+ test("normalize_spec fills defaults and recurses into children", () => {
105
+ let errors = [];
106
+ let spec = normalize_spec({
107
+ id: "root",
108
+ children: [
109
+ {id: "a", label: "Alpha", content: {gclass: "C_TEST_VIEW"}},
110
+ {id: "b"}
111
+ ]
112
+ }, errors, "/");
113
+
114
+ expect(errors).toEqual([]);
115
+ expect(spec.id).toBe("root");
116
+ expect(spec.label).toBe("root"); /* label defaults to id */
117
+ expect(spec.children.length).toBe(2);
118
+ expect(spec.children[0].content).toEqual({gclass: "C_TEST_VIEW", kw: {}});
119
+ expect(spec.children[1].label).toBe("b");
120
+ expect(spec.children[1].content).toBe(null);
121
+ });
122
+
123
+ test("an id is ONE url segment", () => {
124
+ let errors = [];
125
+ expect(normalize_spec({id: "a/b"}, errors, "/")).toBe(null);
126
+ expect(errors.length).toBe(1);
127
+ expect(errors[0]).toMatch(/contains '\/'/);
128
+ });
129
+
130
+ test("a node without id is rejected, loudly", () => {
131
+ let errors = [];
132
+ expect(normalize_spec({label: "no id"}, errors, "/")).toBe(null);
133
+ expect(errors[0]).toMatch(/has no 'id'/);
134
+ });
135
+
136
+ test("sibling ids must be unique — they are url segments", () => {
137
+ let errors = [];
138
+ expect(normalize_spec({
139
+ id: "root",
140
+ children: [{id: "dup"}, {id: "dup"}]
141
+ }, errors, "/")).toBe(null);
142
+ expect(errors.some((e) => /duplicated child id 'dup'/.test(e))).toBe(true);
143
+ });
144
+
145
+ test("an unknown layout is a config error, not a silent fallback", () => {
146
+ let errors = [];
147
+ expect(normalize_spec({id: "x", projection: "carousel"}, errors, "/")).toBe(null);
148
+ expect(errors[0]).toMatch(/unknown projection layout 'carousel'/);
149
+
150
+ errors = [];
151
+ expect(normalize_spec({
152
+ id: "x",
153
+ projection: {index: {layout: "grid"}}
154
+ }, errors, "/")).toBe(null);
155
+ expect(errors[0]).toMatch(/unknown projection.index layout 'grid'/);
156
+ });
157
+
158
+ test("content needs a gclass", () => {
159
+ let errors = [];
160
+ expect(normalize_spec({id: "x", content: {kw: {}}}, errors, "/")).toBe(null);
161
+ expect(errors[0]).toMatch(/'content.gclass' is required/);
162
+ });
163
+
164
+ test("errors name the offending node's path", () => {
165
+ let errors = [];
166
+ normalize_spec({
167
+ id: "root",
168
+ children: [{id: "energy", children: [{label: "orphan"}]}]
169
+ }, errors, "/");
170
+ expect(errors[0]).toMatch(/'\/root\/energy\/'/);
171
+ });
172
+
173
+
174
+ /***************************************************************
175
+ * chrome depth
176
+ ***************************************************************/
177
+ test("no declared chrome_depth means every ancestor paints its strip", () => {
178
+ expect(chrome_visible(1, null)).toBe(true);
179
+ expect(chrome_visible(5, null)).toBe(true);
180
+ expect(chrome_visible(3, undefined)).toBe(true);
181
+ });
182
+
183
+ test("chrome_depth caps how many strips are painted above the tip", () => {
184
+ /* distance 1 is the tip's own parent */
185
+ expect(chrome_visible(1, 1)).toBe(true);
186
+ expect(chrome_visible(2, 1)).toBe(false);
187
+ expect(chrome_visible(3, 1)).toBe(false);
188
+ expect(chrome_visible(1, 0)).toBe(false); /* no chrome at all */
189
+ expect(chrome_visible(2, 2)).toBe(true);
190
+ });
191
+
192
+ test("chrome_depth is validated as a non-negative integer", () => {
193
+ let errors = [];
194
+ expect(normalize_spec({id: "x", chrome_depth: 2}, errors, "/").chrome_depth).toBe(2);
195
+ expect(normalize_spec({id: "x", chrome_depth: 0}, errors, "/").chrome_depth).toBe(0);
196
+ expect(normalize_spec({id: "x"}, errors, "/").chrome_depth).toBe(-1); /* inherit */
197
+ expect(errors).toEqual([]);
198
+
199
+ expect(normalize_spec({id: "x", chrome_depth: -2}, errors, "/")).toBe(null);
200
+ expect(errors[0]).toMatch(/must be an integer >= 0/);
201
+ });
202
+
203
+ test("normalize_spec accepts its own output (every level re-normalizes)", () => {
204
+ let errors = [];
205
+ let once = normalize_spec({
206
+ id: "root",
207
+ chrome_depth: 1,
208
+ children: [{id: "a", children: [{id: "b"}]}]
209
+ }, errors, "/");
210
+ let twice = normalize_spec(once, errors, "/");
211
+ expect(errors).toEqual([]);
212
+ expect(twice).toEqual(once);
213
+ });
214
+
215
+
216
+ /***************************************************************
217
+ * aliases — the tree's paths are a contract
218
+ ***************************************************************/
219
+ test("aliases carry the former ids of a node", () => {
220
+ let errors = [];
221
+ let spec = normalize_spec({id: "north", aliases: ["hall1", "h1"]}, errors, "/");
222
+ expect(errors).toEqual([]);
223
+ expect(spec.aliases).toEqual(["hall1", "h1"]);
224
+ expect(normalize_spec({id: "x"}, errors, "/").aliases).toEqual([]);
225
+ });
226
+
227
+ test("an alias is one url segment, like an id", () => {
228
+ let errors = [];
229
+ expect(normalize_spec({id: "x", aliases: ["a/b"]}, errors, "/")).toBe(null);
230
+ expect(errors[0]).toMatch(/is not a valid id/);
231
+
232
+ errors = [];
233
+ expect(normalize_spec({id: "x", aliases: "hall1"}, errors, "/")).toBe(null);
234
+ expect(errors[0]).toMatch(/'aliases' must be an array/);
235
+ });
236
+
237
+
238
+ /***************************************************************
239
+ * link — where structure ends and data begins
240
+ ***************************************************************/
241
+ test("a link declares the data space and its viewer", () => {
242
+ let errors = [];
243
+ let spec = normalize_spec({
244
+ id: "m1",
245
+ link: {kind: "tranger", gclass: "C_TRANGER_VIEW", kw: {topic: "t"}}
246
+ }, errors, "/");
247
+ expect(errors).toEqual([]);
248
+ expect(spec.link).toEqual({
249
+ kind: "tranger", gclass: "C_TRANGER_VIEW", kw: {topic: "t"}
250
+ });
251
+ expect(normalize_spec({id: "x"}, errors, "/").link).toBe(null);
252
+ });
253
+
254
+ test("a link needs both what it points into and what reads it", () => {
255
+ let errors = [];
256
+ expect(normalize_spec({id: "m1", link: {gclass: "C_V"}}, errors, "/")).toBe(null);
257
+ expect(errors[0]).toMatch(/'link.kind' is required/);
258
+
259
+ errors = [];
260
+ expect(normalize_spec({id: "m1", link: {kind: "tranger"}}, errors, "/")).toBe(null);
261
+ expect(errors[0]).toMatch(/'link.gclass' is required/);
262
+ });
263
+
264
+ test("below a link there are no nodes — link + children is a contradiction", () => {
265
+ let errors = [];
266
+ expect(normalize_spec({
267
+ id: "m1",
268
+ link: {kind: "tranger", gclass: "C_V"},
269
+ children: [{id: "nope"}]
270
+ }, errors, "/")).toBe(null);
271
+ expect(errors.some((e) => /both 'link' and 'children'/.test(e))).toBe(true);
272
+ });
273
+
274
+ test("a link IS the node's content — declaring both is rejected", () => {
275
+ let errors = [];
276
+ expect(normalize_spec({
277
+ id: "m1",
278
+ content: {gclass: "C_OTHER"},
279
+ link: {kind: "tranger", gclass: "C_V"}
280
+ }, errors, "/")).toBe(null);
281
+ expect(errors.some((e) => /both 'link' and 'content'/.test(e))).toBe(true);
282
+ });
@@ -138,6 +138,45 @@ function collect_routes(nodes, into)
138
138
  }
139
139
  }
140
140
 
141
+ /************************************************************
142
+ * A route can be reached from more than one surface: the primary
143
+ * menu, a drawer, an account dropdown. Each occurrence rendered
144
+ * its whole SUBTREE, so a branch reachable three ways was drawn
145
+ * three times — bearable when a section had four children, noise
146
+ * once a node tree hangs there with fourteen.
147
+ *
148
+ * Exactly one occurrence owns the subtree; the others become
149
+ * references (`ref: true`, no children) — still real, clickable
150
+ * routes, just not repeated structure.
151
+ *
152
+ * Who owns it: the first occurrence in the NAV, then in `other`,
153
+ * then in the toolbar. The nav is where the app's structure
154
+ * lives; a toolbar entry pointing at the same route is a
155
+ * shortcut — a non-structural edge — and hanging the tree off it
156
+ * would bury the whole structure inside the account menu.
157
+ ************************************************************/
158
+ function dedupe_subtrees(groups)
159
+ {
160
+ let owner = {};
161
+ let visit = (node) => {
162
+ if(node.route) {
163
+ if(owner[node.route] === undefined) {
164
+ owner[node.route] = node;
165
+ } else if(owner[node.route] !== node) {
166
+ node.ref = true;
167
+ node.children = [];
168
+ return; /* a reference has no subtree to walk */
169
+ }
170
+ }
171
+ if(Array.isArray(node.children)) {
172
+ node.children.forEach(visit);
173
+ }
174
+ };
175
+ for(let group of groups) {
176
+ group.forEach(visit);
177
+ }
178
+ }
179
+
141
180
  /************************************************************
142
181
  * Mark "you are here": the node whose route best matches
143
182
  * current_route — exact hit wins, else the LONGEST declared
@@ -151,6 +190,11 @@ function mark_current(groups, current_route)
151
190
  }
152
191
  let best = null;
153
192
  let visit = (n) => {
193
+ /* Never on a reference: the mark belongs on the occurrence
194
+ * that carries the structure, not on a shortcut to it. */
195
+ if(n.ref) {
196
+ return;
197
+ }
154
198
  if(n.route) {
155
199
  if(n.route === current_route) {
156
200
  if(!best || best.node.route !== current_route) {
@@ -303,6 +347,10 @@ function build_nav_map(input)
303
347
  }
304
348
  other.forEach(enrich);
305
349
 
350
+ /* One subtree, one place. Before marking "you are here", so the
351
+ * mark lands on the occurrence that kept its children. */
352
+ dedupe_subtrees([nav, other, toolbar]);
353
+
306
354
  /* The brand is rendered as the tree's ROOT row (shell_route_map),
307
355
  * so it is markable like any other route — and it is the only
308
356
  * rendered node in neither group, which left an app whose brand
@@ -207,3 +207,73 @@ describe("build_nav_map", () => {
207
207
  expect(marked).toEqual([]);
208
208
  });
209
209
  });
210
+
211
+
212
+ /***************************************************************
213
+ * one subtree per route
214
+ ***************************************************************/
215
+ test("a route reachable from several surfaces keeps ONE subtree", () => {
216
+ let config = {
217
+ toolbar: {items: [
218
+ {id: "user", type: "action", action: {type: "dropdown", items: [
219
+ {id: "d-cards", name: "Go to Cards",
220
+ action: {type: "navigate", route: "/cards"}}
221
+ ]}}
222
+ ]},
223
+ menu: {
224
+ primary: {items: [
225
+ {id: "cards", name: "Cards", route: "/cards", target: {gclass: "C_X"}}
226
+ ]},
227
+ quick: {items: [
228
+ {id: "cards", name: "Cards", route: "/cards"}
229
+ ]}
230
+ }
231
+ };
232
+ let map = build_nav_map({
233
+ config: config,
234
+ item_index: {"/cards": {item: {id: "cards"}, target: {gclass: "C_X"}}},
235
+ sub_routes: {"/cards": [
236
+ {route: "/cards/energy", label: "Energy", children: [
237
+ {route: "/cards/energy/north", label: "North", children: []}
238
+ ]}
239
+ ]},
240
+ });
241
+
242
+ /* The NAV owns it: primary is the first occurrence there. */
243
+ let primary = map.nav.find((n) => n.route === "/cards");
244
+ expect(primary.ref).toBeUndefined();
245
+ expect(primary.children.length).toBe(1);
246
+ expect(primary.children[0].children.length).toBe(1);
247
+
248
+ /* The drawer copy (a group inside nav) and the account entry are
249
+ * references: live routes, no repeated branch. */
250
+ let quick = map.nav.find((n) => n.kind === "group" && n.id === "quick");
251
+ expect(quick.children[0].route).toBe("/cards");
252
+ expect(quick.children[0].ref).toBe(true);
253
+ expect(quick.children[0].children).toEqual([]);
254
+
255
+ let dropdown = map.toolbar[0].children[0];
256
+ expect(dropdown.route).toBe("/cards");
257
+ expect(dropdown.ref).toBe(true);
258
+ expect(dropdown.children).toEqual([]);
259
+ });
260
+
261
+ test("'you are here' lands on the owner, never on a reference", () => {
262
+ let config = {
263
+ toolbar: {items: [
264
+ {id: "user", type: "action", action: {type: "dropdown", items: [
265
+ {id: "d-cards", action: {type: "navigate", route: "/cards"}}
266
+ ]}}
267
+ ]},
268
+ menu: {primary: {items: [
269
+ {id: "cards", name: "Cards", route: "/cards", target: {gclass: "C_X"}}
270
+ ]}}
271
+ };
272
+ let map = build_nav_map({
273
+ config: config,
274
+ item_index: {"/cards": {item: {id: "cards"}, target: {gclass: "C_X"}}},
275
+ current_route: "/cards",
276
+ });
277
+ expect(map.nav.find((n) => n.route === "/cards").current).toBe(true);
278
+ expect(map.toolbar[0].children[0].current).toBeUndefined();
279
+ });
@@ -21,6 +21,11 @@
21
21
  * - root `/` is NEVER an ancestor catch-all (it only matches
22
22
  * exactly): otherwise every typo route would silently mount
23
23
  * the home view and the unknown-route diagnostic would die.
24
+ * ONE exception, opt-in: an entry whose target declares
25
+ * `owns_subtree` is a view that resolves the tail ITSELF and
26
+ * says so when a segment names nothing — a C_YUI_NODE tree
27
+ * rooted at `/`. The diagnostic is not lost there, it moves
28
+ * one layer down to whoever actually knows the names.
24
29
  * - nothing matched → entry as found (may be
25
30
  * null or a targetless submenu parent), subpath "".
26
31
  ************************************************************/
@@ -59,6 +64,12 @@ function resolve_route(item_index, route)
59
64
  parts.pop();
60
65
  let cand = "/" + parts.join("/");
61
66
  if(cand === "/") {
67
+ let root = item_index["/"];
68
+ if(root && root.target && root.target.owns_subtree) {
69
+ entry = root;
70
+ matched_route = "/";
71
+ subpath = route.replace(/^\/+/, "");
72
+ }
62
73
  break;
63
74
  }
64
75
  let e = item_index[cand];
@@ -74,11 +74,21 @@
74
74
  flex: 1 1 auto;
75
75
  max-height: none;
76
76
  }
77
+ /* FOUR characters per level, house rule: the tree is read as
78
+ * indentation and it has to be the same four everywhere (the JSON
79
+ * viewer, the raw dumps, this map). `ch` follows the row's own
80
+ * monospace font, so the guides stay aligned with the routes. */
77
81
  .ROUTEMAP_UL {
78
82
  list-style: none;
79
83
  margin: 0;
80
- padding-left: 1.1rem;
81
- border-left: 1px dotted var(--bulma-border, #b5b5b5);
84
+ padding-left: 4ch;
85
+ /* The guide is what makes the depth READABLE — at four levels the
86
+ * eye follows the line, not the offset. Dotted at 1px in the weak
87
+ * border colour was almost invisible against the panel; solid, in
88
+ * the stronger border shade, still reads as chrome but can be
89
+ * followed. Both shades are Bulma vars, so it re-shades itself in
90
+ * the dark theme instead of turning into a black hairline. */
91
+ border-left: 1px solid var(--bulma-border-hover, #8c8c8c);
82
92
  }
83
93
  .ROUTEMAP_ROOT {
84
94
  padding-left: 0;
@@ -167,6 +177,30 @@ a.ROUTEMAP_LINK .ROUTEMAP_NAME {
167
177
  background: rgba(120, 150, 255, 0.16) !important;
168
178
  outline-color: rgba(120, 150, 255, 0.55);
169
179
  }
180
+ /*--- reference row: this route's subtree is drawn where the nav owns
181
+ * it, so the copy reachable from a drawer or the account menu is a
182
+ * live link without the repeated branch. Quiet on purpose: it is a
183
+ * footnote, not a second structure. ---*/
184
+ .ROUTEMAP_REFS_TOGGLE {
185
+ display: inline-flex;
186
+ align-items: center;
187
+ white-space: nowrap;
188
+ color: var(--bulma-text-weak, #7a7a7a);
189
+ }
190
+
191
+ .ROUTEMAP_REF {
192
+ font-size: 0.68rem;
193
+ letter-spacing: 0.03em;
194
+ padding: 0.02rem 0.4rem;
195
+ border-radius: 0.25rem;
196
+ border: 1px solid var(--bulma-border-weak, #dbdbdb);
197
+ color: var(--bulma-text-weak, #7a7a7a);
198
+ white-space: nowrap;
199
+ }
200
+ .ROUTEMAP_ROW.ROUTEMAP_IS_REF .ROUTEMAP_NAME {
201
+ font-weight: 400;
202
+ }
203
+
170
204
  .ROUTEMAP_HERE {
171
205
  font-size: 0.68rem;
172
206
  font-weight: 700;