@yuneta/gobj-ui 5.3.3 → 5.4.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.
package/README.md CHANGED
@@ -162,7 +162,11 @@ seen*:
162
162
  "content": {"gclass": "C_MY_LANDING", "kw": {}},
163
163
  "children": [
164
164
  {"id": "energy", "label": "Energy", "icon": "yi-bolt",
165
- "projection": {"index": {"layout": "cards"}},
165
+ "projection": {
166
+ "index": {"layout": "cards"},
167
+ "chrome": [{"layout": "tabs", "show_on": ">=tablet"},
168
+ {"layout": "backbar", "show_on": "<tablet"}]
169
+ },
166
170
  "children": [ /* … any depth … */ ]}
167
171
  ]
168
172
  }
@@ -173,6 +177,16 @@ seen*:
173
177
  icon-bar/backbar and `show_on` all work unchanged) in two modes: `index`
174
178
  when the node is the tip of the path — the projection IS the page — and
175
179
  `chrome` when a child is showing — the projection is the strip around it.
180
+ - **Chrome belongs to the node that declares it — so every branch declares its
181
+ own.** A node's `chrome` strip lists *that* node's children, and its backbar
182
+ goes back to *that* node's route (`back_route = my_route`). A branch that
183
+ declares no `chrome` therefore contributes no strip, and the only ← the user
184
+ can reach is the nearest ancestor that did declare one. Declaring the pair
185
+ **only at the root** is the mistake this rule exists to name: the whole
186
+ subtree then shows one "← root" that says the same thing at every depth,
187
+ instead of one ← per level going up exactly one level. Repeat the same
188
+ `chrome` on every branch that can have a tip below it — that is what
189
+ `test-app`'s `/cards` does, and what makes its ← hierarchical.
176
190
  - **`content` and `children` are not exclusive**: a section with its own page
177
191
  and sub-pages is one node.
178
192
  - **The route table does not grow.** The host declares ONE route; everything
@@ -187,6 +201,30 @@ seen*:
187
201
  `projection.path`) while the rest of the tree keeps its tabs. Note the
188
202
  asymmetry that makes it a third mode and not a layout: `index` and `chrome`
189
203
  project a node's CHILDREN; `path` projects the way in.
204
+ - **`nav_mode` — the three shapes as one runtime knob.** The two bullets above
205
+ describe what a tree *declares*; `nav_mode` is how a user *chooses* between
206
+ the shapes without the app rewriting anything:
207
+
208
+ | mode | what it shows | equivalent declaration |
209
+ |---|---|---|
210
+ | `"stack"` (default) | one strip per ancestor | whatever each branch declares |
211
+ | `"back"` | only the tip's parent, as a `← parent` | `chrome: {"layout":"backbar"}` everywhere + `chrome_depth: 1` |
212
+ | `"path"` | the trail as ONE line | `path: {"layout":"breadcrumb"}` on the root + `chrome_depth: 0` |
213
+
214
+ It belongs to the **root** (`yui_node_set_nav_mode(root, "path")`, or
215
+ `"nav_mode"` in its declaration) and the whole tree reads it from there —
216
+ ancestors stacking strips while a descendant drew a breadcrumb would be
217
+ saying the same thing twice in two languages. Set on a middle node it is
218
+ refused, loudly, rather than accepted and ignored.
219
+
220
+ A mode **filters the renders as they are asked for; it never rewrites what
221
+ the app declared.** That is what makes `"stack"` an exact restore: a branch
222
+ that declared `vertical` chrome comes back as `vertical`, not as the tabs a
223
+ canonical "stacked" shape would have imposed. The `index` projection is
224
+ never touched by a mode — how a node shows its own children when it IS the
225
+ page is not a statement about depth. Modes are per tree, so an app can run
226
+ `/admin` as a breadcrumb and `/alarms` as a backbar; `test-app`'s node lab
227
+ cycles all three on the live tree.
190
228
  - **`chrome_depth`** caps the stacked chrome: with every ancestor painting its
191
229
  own strip, depth N shows N-1 of them. A node declares how many its corner of
192
230
  the tree deserves (`0` = none, omit = all), the **deepest declaration on the
@@ -199,6 +237,7 @@ seen*:
199
237
  yui_node_add(node, spec, index) yui_node_remove(node, node_id)
200
238
  yui_node_set_projection(node, proj) yui_node_set_content(node, content)
201
239
  yui_node_set_chrome_depth(node, n) yui_node_tree_version(node)
240
+ yui_node_set_nav_mode(root, mode) yui_node_nav_mode(node)
202
241
  yui_node_find(node, "energy/north") yui_node_route(node)
203
242
  ```
204
243
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuneta/gobj-ui",
3
- "version": "5.3.3",
3
+ "version": "5.4.0",
4
4
  "type": "module",
5
5
  "main": "dist/gobj-ui.cjs.js",
6
6
  "module": "dist/gobj-ui.es.js",
package/src/c_yui_node.js CHANGED
@@ -126,6 +126,10 @@ import {
126
126
  child_nav_items,
127
127
  chrome_visible,
128
128
  normalize_spec,
129
+ is_nav_mode,
130
+ nav_mode_renders,
131
+ nav_mode_depth,
132
+ NAV_MODES,
129
133
  } from "./node_tree_model.js";
130
134
 
131
135
  import {
@@ -169,6 +173,7 @@ SDATA(data_type_t.DTP_JSON, "children", 0, null, "Declared child spec
169
173
 
170
174
  SDATA(data_type_t.DTP_STRING, "base_route", 0, "", "ROOT only: the declared route the whole tree hangs from"),
171
175
  SDATA(data_type_t.DTP_STRING, "tree_version", 0, "", "ROOT only: version of the tree CONTRACT (its paths are public urls)"),
176
+ SDATA(data_type_t.DTP_STRING, "nav_mode", 0, "stack","ROOT only: how the way in is shown — stack|back|path"),
172
177
 
173
178
  SDATA(data_type_t.DTP_POINTER, "$container", 0, null, "Root HTMLElement (shell view contract)"),
174
179
  SDATA_END()
@@ -389,6 +394,29 @@ function tree_root_of(gobj)
389
394
  }
390
395
  }
391
396
 
397
+ /************************************************************
398
+ * The navigation mode in force for this node.
399
+ *
400
+ * It is ONE knob per tree, held by the root: a tree whose ancestors
401
+ * stacked strips while a descendant drew a breadcrumb would be
402
+ * showing the way in twice, in two languages. Descendants read it
403
+ * from the root rather than keeping a copy, so a node added at
404
+ * runtime is born in the mode the tree is already in.
405
+ ************************************************************/
406
+ function nav_mode_of(gobj)
407
+ {
408
+ let mode = gobj_read_attr(tree_root_of(gobj), "nav_mode") || "stack";
409
+
410
+ if(!is_nav_mode(mode)) {
411
+ log_error(
412
+ `${GCLASS_NAME} '${node_path_str(gobj)}': unknown nav_mode ` +
413
+ `'${mode}' — expected one of ${NAV_MODES.join("|")}`
414
+ );
415
+ return "stack";
416
+ }
417
+ return mode;
418
+ }
419
+
392
420
  /************************************************************
393
421
  * Node ids from the root DOWN TO this node, root excluded
394
422
  * (the root's own segment is already inside base_route).
@@ -494,12 +522,14 @@ function resolve_path_info(gobj, subpath, inherited)
494
522
  {
495
523
  let effective = (typeof inherited === "number") ? inherited : null;
496
524
 
525
+ let mode = nav_mode_of(gobj);
526
+
497
527
  let own = gobj_read_attr(gobj, "chrome_depth");
498
528
  if(typeof own === "number" && own >= 0) {
499
529
  effective = own;
500
530
  }
501
531
  if(has_link(gobj)) {
502
- return {distance: 0, chrome_depth: effective, chain: []};
532
+ return {distance: 0, chrome_depth: nav_mode_depth(effective, mode), chain: []};
503
533
  }
504
534
 
505
535
  /* Distance is counted in STRUCTURAL segments: the tail a viewer
@@ -524,7 +554,11 @@ function resolve_path_info(gobj, subpath, inherited)
524
554
  break;
525
555
  }
526
556
  }
527
- return {distance: distance, chrome_depth: effective, chain: chain};
557
+ return {
558
+ distance: distance,
559
+ chrome_depth: nav_mode_depth(effective, mode),
560
+ chain: chain
561
+ };
528
562
  }
529
563
 
530
564
  /************************************************************
@@ -575,7 +609,8 @@ function render_projection(gobj, mode, $where, active_route, active_id, zones_on
575
609
  if(!priv.children.length) {
576
610
  return;
577
611
  }
578
- let renders = projection_renders(gobj_read_attr(gobj, "projection"), mode);
612
+ let renders = nav_mode_renders(gobj_read_attr(gobj, "projection"),
613
+ nav_mode_of(gobj), mode, priv.is_root);
579
614
  if(!renders.length) {
580
615
  return;
581
616
  }
@@ -833,7 +868,8 @@ function render_child(gobj, child)
833
868
  function render_path(gobj, $where)
834
869
  {
835
870
  let priv = gobj.priv;
836
- let renders = projection_renders(gobj_read_attr(gobj, "projection"), "path");
871
+ let renders = nav_mode_renders(gobj_read_attr(gobj, "projection"),
872
+ nav_mode_of(gobj), "path", priv.is_root);
837
873
 
838
874
  if(!renders.length) {
839
875
  return;
@@ -1328,6 +1364,54 @@ function ac_set_chrome_depth(gobj, event, kw, src)
1328
1364
  return 0;
1329
1365
  }
1330
1366
 
1367
+ /************************************************************
1368
+ * Runtime API: change how this TREE shows the way in.
1369
+ *
1370
+ * One knob for the whole tree, and deliberately root-only: the
1371
+ * mode answers "how deep am I and how do I get back", a question
1372
+ * that has one answer per tree. Set on a middle node it would
1373
+ * silently do nothing (descendants read the root's), and a knob
1374
+ * that accepts a call and ignores it is worse than one that
1375
+ * refuses it.
1376
+ *
1377
+ * Nothing declared is rewritten — the mode filters the renders as
1378
+ * they are asked for — so going back to "stack" restores exactly
1379
+ * what the app declared, per branch, including layouts this mode
1380
+ * never mentions.
1381
+ ************************************************************/
1382
+ function ac_set_nav_mode(gobj, event, kw, src)
1383
+ {
1384
+ let priv = gobj.priv;
1385
+ let mode = kw.nav_mode || "";
1386
+
1387
+ if(!is_nav_mode(mode)) {
1388
+ log_error(
1389
+ `${GCLASS_NAME} '${node_path_str(gobj)}': unknown nav_mode ` +
1390
+ `'${mode}' — expected one of ${NAV_MODES.join("|")}`
1391
+ );
1392
+ return -1;
1393
+ }
1394
+ if(!priv.is_root) {
1395
+ log_error(
1396
+ `${GCLASS_NAME} '${node_path_str(gobj)}': nav_mode belongs to the ` +
1397
+ `tree ROOT — set it there, the whole tree reads it`
1398
+ );
1399
+ return -1;
1400
+ }
1401
+ gobj_write_attr(gobj, "nav_mode", mode);
1402
+
1403
+ /* Same reason as EV_SET_CHROME_DEPTH: what changes is the shape
1404
+ * of the ACTIVE PATH, whose strips are painted by the ancestors,
1405
+ * so the tree is asked to re-apply the current route instead of
1406
+ * repainting one node. */
1407
+ let shell = yui_shell_of(gobj);
1408
+ if(shell) {
1409
+ yui_shell_navigate(shell, gobj_read_attr(shell, "current_route") ||
1410
+ route_of(gobj), {replace: true});
1411
+ }
1412
+ return 0;
1413
+ }
1414
+
1331
1415
  /************************************************************
1332
1416
  * Runtime API: change (or set) my content.
1333
1417
  ************************************************************/
@@ -1402,6 +1486,20 @@ function yui_node_set_chrome_depth(node, depth)
1402
1486
  {chrome_depth: depth}, node);
1403
1487
  }
1404
1488
 
1489
+ /************************************************************
1490
+ * The tree's navigation mode: "stack" | "back" | "path".
1491
+ * Send it to the ROOT — the whole tree reads it from there.
1492
+ ************************************************************/
1493
+ function yui_node_set_nav_mode(node, mode)
1494
+ {
1495
+ return gobj_send_event(node, "EV_SET_NAV_MODE", {nav_mode: mode}, node);
1496
+ }
1497
+
1498
+ function yui_node_nav_mode(node)
1499
+ {
1500
+ return gobj_read_attr(tree_root_of(node), "nav_mode") || "stack";
1501
+ }
1502
+
1405
1503
  /************************************************************
1406
1504
  * Resolve a path of ids from a node ("" = the node itself).
1407
1505
  * Returns null when any segment is missing — callers that mean
@@ -1484,6 +1582,7 @@ function create_gclass(gclass_name)
1484
1582
  ["EV_REMOVE_NODE", ac_remove_node, null],
1485
1583
  ["EV_SET_PROJECTION", ac_set_projection, null],
1486
1584
  ["EV_SET_CHROME_DEPTH", ac_set_chrome_depth, null],
1585
+ ["EV_SET_NAV_MODE", ac_set_nav_mode, null],
1487
1586
  ["EV_SET_CONTENT", ac_set_content, null],
1488
1587
  ["EV_NODE_TREE_CHANGED", ac_tree_changed, null]
1489
1588
  ]],
@@ -1496,6 +1595,7 @@ function create_gclass(gclass_name)
1496
1595
  ["EV_REMOVE_NODE", ac_remove_node, null],
1497
1596
  ["EV_SET_PROJECTION", ac_set_projection, null],
1498
1597
  ["EV_SET_CHROME_DEPTH", ac_set_chrome_depth, null],
1598
+ ["EV_SET_NAV_MODE", ac_set_nav_mode, null],
1499
1599
  ["EV_SET_CONTENT", ac_set_content, null],
1500
1600
  ["EV_NODE_TREE_CHANGED", ac_tree_changed, null]
1501
1601
  ]],
@@ -1508,6 +1608,7 @@ function create_gclass(gclass_name)
1508
1608
  ["EV_REMOVE_NODE", ac_remove_node, null],
1509
1609
  ["EV_SET_PROJECTION", ac_set_projection, null],
1510
1610
  ["EV_SET_CHROME_DEPTH", ac_set_chrome_depth, null],
1611
+ ["EV_SET_NAV_MODE", ac_set_nav_mode, null],
1511
1612
  ["EV_SET_CONTENT", ac_set_content, null],
1512
1613
  ["EV_NODE_TREE_CHANGED", ac_tree_changed, null]
1513
1614
  ]]
@@ -1525,6 +1626,7 @@ function create_gclass(gclass_name)
1525
1626
  ["EV_REMOVE_NODE", 0],
1526
1627
  ["EV_SET_PROJECTION", 0],
1527
1628
  ["EV_SET_CHROME_DEPTH", 0],
1629
+ ["EV_SET_NAV_MODE", 0],
1528
1630
  ["EV_SET_CONTENT", 0],
1529
1631
  ["EV_NODE_TREE_CHANGED", 0]
1530
1632
  ];
@@ -1565,6 +1667,8 @@ export {
1565
1667
  yui_node_set_projection,
1566
1668
  yui_node_set_content,
1567
1669
  yui_node_set_chrome_depth,
1670
+ yui_node_set_nav_mode,
1671
+ yui_node_nav_mode,
1568
1672
  yui_node_find,
1569
1673
  yui_node_route,
1570
1674
  yui_node_tree_version
@@ -211,6 +211,73 @@ export function chrome_visible(distance, depth)
211
211
  return distance <= depth;
212
212
  }
213
213
 
214
+ /************************************************************
215
+ * NAVIGATION MODES — the three ways a tree can show the way in.
216
+ *
217
+ * "stack" — one chrome strip per ancestor. Whatever each
218
+ * node declared, painted at every level.
219
+ * "back" — only the tip's parent, and only as a "← parent".
220
+ * "path" — no strips at all: the trail as ONE breadcrumb
221
+ * line, drawn by the root.
222
+ *
223
+ * A mode is a FILTER over what the app declared, applied when the
224
+ * renders are asked for — never a rewrite of the declared
225
+ * projections. That is what makes going back to "stack" free and
226
+ * exact: an app that declares `vertical` chrome gets `vertical`
227
+ * back, not the tabs a canonical "stack" shape would have imposed.
228
+ *
229
+ * The index projection is NEVER touched: how a node shows its own
230
+ * children when it IS the page is not a statement about depth.
231
+ ************************************************************/
232
+ export const NAV_MODES = ["stack", "back", "path"];
233
+
234
+ export function is_nav_mode(mode)
235
+ {
236
+ return NAV_MODES.indexOf(mode) >= 0;
237
+ }
238
+
239
+ /************************************************************
240
+ * The renders of one projection slot under a nav mode.
241
+ *
242
+ * `is_root` only matters for "path": the trail is drawn from the
243
+ * tree root down to the tip whoever declares it, so exactly one
244
+ * node must draw it or the same line appears once per ancestor.
245
+ ************************************************************/
246
+ export function nav_mode_renders(projection, mode, slot, is_root)
247
+ {
248
+ if(slot === "index" || !is_nav_mode(mode) || mode === "stack") {
249
+ return projection_renders(projection, slot);
250
+ }
251
+ if(mode === "back") {
252
+ return (slot === "chrome") ? [{layout: "backbar"}] : [];
253
+ }
254
+ /* "path" */
255
+ if(slot === "path") {
256
+ return is_root ? [{layout: "breadcrumb"}] : [];
257
+ }
258
+ return [];
259
+ }
260
+
261
+ /************************************************************
262
+ * The effective chrome depth under a nav mode.
263
+ *
264
+ * "back" is exactly `chrome_depth: 1` — one strip, the parent's.
265
+ * "path" is `0`: the trail already says everything the strips
266
+ * would, and a strip under it would be the same information twice.
267
+ * Both override what the tree declared, because the user asked for
268
+ * a shape and the declaration is what they asked to change.
269
+ ************************************************************/
270
+ export function nav_mode_depth(declared_depth, mode)
271
+ {
272
+ if(mode === "back") {
273
+ return 1;
274
+ }
275
+ if(mode === "path") {
276
+ return 0;
277
+ }
278
+ return declared_depth;
279
+ }
280
+
214
281
  /************************************************************
215
282
  * Validate + fill one node spec. Returns the normalized spec,
216
283
  * or null when it is unusable; every rejection pushes a
@@ -12,6 +12,9 @@ import {
12
12
  projection_renders,
13
13
  child_nav_items,
14
14
  normalize_spec,
15
+ is_nav_mode,
16
+ nav_mode_renders,
17
+ nav_mode_depth,
15
18
  } from "./node_tree_model.js";
16
19
 
17
20
 
@@ -280,3 +283,70 @@ test("a link IS the node's content — declaring both is rejected", () => {
280
283
  }, errors, "/")).toBe(null);
281
284
  expect(errors.some((e) => /both 'link' and 'content'/.test(e))).toBe(true);
282
285
  });
286
+
287
+
288
+ /***************************************************************
289
+ * navigation modes
290
+ ***************************************************************/
291
+ const DECLARED = {
292
+ index: {layout: "cards"},
293
+ chrome: [
294
+ {layout: "tabs", show_on: ">=tablet"},
295
+ {layout: "backbar", show_on: "<tablet"}
296
+ ]
297
+ };
298
+
299
+ test("only the three modes are modes", () => {
300
+ expect(is_nav_mode("stack")).toBe(true);
301
+ expect(is_nav_mode("back")).toBe(true);
302
+ expect(is_nav_mode("path")).toBe(true);
303
+ expect(is_nav_mode("breadcrumb")).toBe(false); /* that is a LAYOUT */
304
+ expect(is_nav_mode("")).toBe(false);
305
+ });
306
+
307
+ test("stack gives back exactly what the node declared", () => {
308
+ expect(nav_mode_renders(DECLARED, "stack", "chrome", true))
309
+ .toEqual(DECLARED.chrome);
310
+ expect(nav_mode_renders("vertical", "stack", "index", false))
311
+ .toEqual([{layout: "vertical"}]);
312
+ /* An unknown mode must not silently reshape a tree */
313
+ expect(nav_mode_renders(DECLARED, "nonsense", "chrome", true))
314
+ .toEqual(DECLARED.chrome);
315
+ });
316
+
317
+ test("back replaces every chrome with one backbar, and drops the trail", () => {
318
+ expect(nav_mode_renders(DECLARED, "back", "chrome", true))
319
+ .toEqual([{layout: "backbar"}]);
320
+ expect(nav_mode_renders(DECLARED, "back", "chrome", false))
321
+ .toEqual([{layout: "backbar"}]);
322
+ expect(nav_mode_renders({path: {layout: "breadcrumb"}}, "back", "path", true))
323
+ .toEqual([]);
324
+ });
325
+
326
+ test("path draws the trail ONCE, at the root, and no strips anywhere", () => {
327
+ expect(nav_mode_renders(DECLARED, "path", "path", true))
328
+ .toEqual([{layout: "breadcrumb"}]);
329
+ expect(nav_mode_renders(DECLARED, "path", "path", false)).toEqual([]);
330
+ expect(nav_mode_renders(DECLARED, "path", "chrome", true)).toEqual([]);
331
+ expect(nav_mode_renders(DECLARED, "path", "chrome", false)).toEqual([]);
332
+ });
333
+
334
+ test("the index projection survives every mode", () => {
335
+ for(const mode of ["stack", "back", "path"]) {
336
+ expect(nav_mode_renders(DECLARED, mode, "index", true))
337
+ .toEqual([{layout: "cards"}]);
338
+ }
339
+ /* and an undeclared projection keeps defaulting to cards */
340
+ expect(nav_mode_renders(null, "path", "index", true))
341
+ .toEqual([{layout: "cards"}]);
342
+ });
343
+
344
+ test("back is depth 1 and path is depth 0, whatever the tree declared", () => {
345
+ expect(nav_mode_depth(null, "back")).toBe(1);
346
+ expect(nav_mode_depth(3, "back")).toBe(1);
347
+ expect(nav_mode_depth(null, "path")).toBe(0);
348
+ expect(nav_mode_depth(3, "path")).toBe(0);
349
+ /* stack keeps the declaration, unlimited included */
350
+ expect(nav_mode_depth(null, "stack")).toBe(null);
351
+ expect(nav_mode_depth(2, "stack")).toBe(2);
352
+ });