@yuneta/gobj-ui 7.23.64 → 7.23.66

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.
@@ -1586,6 +1586,20 @@ function safe_id(s)
1586
1586
  * drawer { type:"drawer", op:"toggle"|"open"|"close", menu_id? }
1587
1587
  * event { type:"event", event, kw? }
1588
1588
  * dropdown { type:"dropdown", items[] } (toolbar-only)
1589
+ * link { type:"link", url, target? }
1590
+ *
1591
+ * `link` leaves the application, so on a toolbar item and on the brand
1592
+ * it is rendered as a real <a href> rather than a <button>:
1593
+ * middle-click, open-in-new-tab and the url in the status bar are what
1594
+ * make a link recognisable as one, and a button that assigns
1595
+ * location.href has none of them. It is also why it does not become
1596
+ * an FSM event -- there is no state of this application to transition;
1597
+ * the page is being left. A target of "_blank" gets
1598
+ * rel="noopener noreferrer" for free.
1599
+ *
1600
+ * INSIDE A DROPDOWN it stays a <button>, deliberately: those rows are
1601
+ * role="menuitemradio" in a menu, and a link is not a choice among
1602
+ * options. There the dispatcher navigates.
1589
1603
  *
1590
1604
  * Per-item `show_on` is honoured: each rendered item is wrapped with
1591
1605
  * the same Bulma-helper logic as zones.
@@ -1703,6 +1717,35 @@ function badge_node(value)
1703
1717
  return ["span", attrs, text];
1704
1718
  }
1705
1719
 
1720
+ /************************************************************
1721
+ * The attributes that turn a toolbar item into a real link, or
1722
+ * null when this item is not one. Answering null is what keeps
1723
+ * every caller's default (a <button> that dispatches an action)
1724
+ * exactly as it was.
1725
+ ************************************************************/
1726
+ function toolbar_link_attrs(it)
1727
+ {
1728
+ let action = (it && it.action) || {};
1729
+ if(action.type !== "link") {
1730
+ return null;
1731
+ }
1732
+ if(empty_string(action.url)) {
1733
+ log_warning(
1734
+ `C_YUI_SHELL: toolbar item '${it.id||"?"}' has action.type "link" ` +
1735
+ `without a url`
1736
+ );
1737
+ return null;
1738
+ }
1739
+ let attrs = {href: action.url};
1740
+ if(!empty_string(action.target)) {
1741
+ attrs.target = action.target;
1742
+ if(action.target === "_blank") {
1743
+ attrs.rel = "noopener noreferrer";
1744
+ }
1745
+ }
1746
+ return attrs;
1747
+ }
1748
+
1706
1749
  /************************************************************
1707
1750
  * Renderer for the default ("action") item kind.
1708
1751
  ************************************************************/
@@ -1751,13 +1794,20 @@ function build_toolbar_action_item(gobj, it)
1751
1794
  btn_attrs.title = tip;
1752
1795
  btn_attrs["data-i18n-title"] = tip;
1753
1796
  }
1797
+ let link = toolbar_link_attrs(it);
1798
+ if(link) {
1799
+ delete btn_attrs.type; /* meaningless, and invalid, on an <a> */
1800
+ Object.assign(btn_attrs, link);
1801
+ }
1754
1802
  let $item = createElement2(
1755
- ["button", btn_attrs, children]
1803
+ [link? "a": "button", btn_attrs, children]
1756
1804
  );
1757
- $item.addEventListener("click", ev => {
1758
- ev.preventDefault();
1759
- handle_toolbar_action(gobj, it, $item);
1760
- });
1805
+ if(!link) {
1806
+ $item.addEventListener("click", ev => {
1807
+ ev.preventDefault();
1808
+ handle_toolbar_action(gobj, it, $item);
1809
+ });
1810
+ }
1761
1811
  attach_context_action(gobj, $item, it);
1762
1812
  return $item;
1763
1813
  }
@@ -1793,7 +1843,11 @@ function build_toolbar_brand_item(gobj, it)
1793
1843
  attrs["aria-haspopup"] = "menu";
1794
1844
  attrs["aria-expanded"] = "false";
1795
1845
  }
1796
- if(action_type === "") {
1846
+ let link = toolbar_link_attrs(it);
1847
+ if(link) {
1848
+ tag = "a";
1849
+ Object.assign(attrs, link);
1850
+ } else if(action_type === "") {
1797
1851
  /* Passive brand: no action — render a div so it is not
1798
1852
  * keyboard-focused and screen readers don't announce a
1799
1853
  * pressable control. */
@@ -1807,7 +1861,7 @@ function build_toolbar_brand_item(gobj, it)
1807
1861
  ["span", {class: "yui-toolbar-brand-wordmark", i18n: it.wordmark},
1808
1862
  it.wordmark]
1809
1863
  ]]);
1810
- if(action_type !== "") {
1864
+ if(action_type !== "" && !link) {
1811
1865
  $item.addEventListener("click", ev => {
1812
1866
  ev.preventDefault();
1813
1867
  handle_toolbar_action(gobj, it, $item);
@@ -2032,6 +2086,23 @@ function handle_toolbar_action(gobj, item, $trigger)
2032
2086
  case "dropdown":
2033
2087
  toggle_toolbar_dropdown(gobj, item, action, $trigger);
2034
2088
  break;
2089
+ case "link":
2090
+ /* Reached only from the renderers that stay buttons (avatar,
2091
+ * and the entries inside a dropdown panel). The anchor
2092
+ * renderers never come through here. */
2093
+ if(!empty_string(action.url)) {
2094
+ if(action.target === "_blank") {
2095
+ window.open(action.url, "_blank", "noopener,noreferrer");
2096
+ } else {
2097
+ window.location.assign(action.url);
2098
+ }
2099
+ } else {
2100
+ log_warning(
2101
+ `C_YUI_SHELL: toolbar item '${item.id||"?"}' has action.type ` +
2102
+ `"link" without a url`
2103
+ );
2104
+ }
2105
+ break;
2035
2106
  default:
2036
2107
  log_warning(
2037
2108
  `C_YUI_SHELL: toolbar item '${item.id||"?"}' has no/unknown action.type`
@@ -37,6 +37,7 @@ import {
37
37
  kw_get_dict_value,
38
38
  gobj_short_name,
39
39
  gobj_read_str_attr,
40
+ gobj_read_integer_attr,
40
41
  gobj_destroy,
41
42
  json_object_update,
42
43
  is_object,
@@ -80,6 +81,7 @@ import {
80
81
  import {yui_toolbar} from "./yui_toolbar.js";
81
82
  import {attach_clear} from "./yui_inputs.js";
82
83
  import {register_c_g6_nodes_tree} from "./c_g6_nodes_tree.js";
84
+ import {yui_graph_fold_items} from "./yui_graph_camera.js";
83
85
  import {
84
86
  removeChildElements,
85
87
  disableElements,
@@ -107,6 +109,8 @@ SDATA(data_type_t.DTP_LIST, "operation_modes", 0,
107
109
  '["reading", "operation", "writing", "edition"]',
108
110
  "Available **permission** or behaviour modes. These operation modes are required to be accomplish by the graph handler (G6 child). TODO permissions must match treedb permissions."),
109
111
  SDATA(data_type_t.DTP_BOOLEAN, "with_treedb_tables",0, false, "Include treedb tables"),
112
+ SDATA(data_type_t.DTP_INTEGER, "expand_depth", 0, 2, "Levels of the treedb open when it loads: 1 = the roots alone, 2 = the roots and their children. Forwarded to the G6 child"),
113
+ SDATA(data_type_t.DTP_INTEGER, "fold_page_size", 0, 24, "Children of one hook shown per page in the graph; a `+N` chip opens the next page. Forwarded to the G6 child"),
110
114
 
111
115
  /*---------------- User last selections ----------------*/
112
116
  SDATA(data_type_t.DTP_STRING, "operation_mode", sdata_flag_t.SDF_PERSIST, "reading", "Current operation mode (internal behaviour or role). Changed by the user trough the gui."),
@@ -240,6 +244,8 @@ function mt_create(gobj)
240
244
  with_treedb_tables: priv.with_treedb_tables,
241
245
  hook_port_position: "bottom",
242
246
  fkey_port_position: "top",
247
+ expand_depth: gobj_read_integer_attr(gobj, "expand_depth"),
248
+ fold_page_size: gobj_read_integer_attr(gobj, "fold_page_size"),
243
249
  },
244
250
  gobj
245
251
  );
@@ -563,6 +569,12 @@ function make_toolbar(gobj)
563
569
  }
564
570
  }],
565
571
 
572
+ /* The fold pair, the same two chevrons the JSON graph and the
573
+ * lazy tree use: the graph opens folded (see the G6 child), and
574
+ * these are the whole thing and the roots alone. Refresh is the
575
+ * way back to the default depth. */
576
+ ...yui_graph_fold_items(gobj, gobj_read_str_attr(gobj, "wide")),
577
+
566
578
  /* Which colour is which topic. The port colour of a node encodes
567
579
  * the topic it links to, which is the whole point of the graph and
568
580
  * which nothing on screen explained. Clicking an entry focuses that
@@ -2466,6 +2478,28 @@ function ac_find_nodes(gobj, event, kw, src)
2466
2478
  return 0;
2467
2479
  }
2468
2480
 
2481
+ /************************************************************
2482
+ * The fold pair of the toolbar, forwarded to the G6 child,
2483
+ * which owns the tree.
2484
+ ************************************************************/
2485
+ function ac_expand_all(gobj, event, kw, src)
2486
+ {
2487
+ let priv = gobj.priv;
2488
+ if(priv.gobj_nodes_tree) {
2489
+ gobj_send_event(priv.gobj_nodes_tree, "EV_EXPAND_ALL", {}, gobj);
2490
+ }
2491
+ return 0;
2492
+ }
2493
+
2494
+ function ac_collapse_all(gobj, event, kw, src)
2495
+ {
2496
+ let priv = gobj.priv;
2497
+ if(priv.gobj_nodes_tree) {
2498
+ gobj_send_event(priv.gobj_nodes_tree, "EV_COLLAPSE_ALL", {}, gobj);
2499
+ }
2500
+ return 0;
2501
+ }
2502
+
2469
2503
  /************************************************************
2470
2504
  * How many nodes the term matched, from the graph child. Zero with a
2471
2505
  * term typed is an ANSWER and is shown; an empty box shows nothing.
@@ -2602,6 +2636,8 @@ function create_gclass(gclass_name)
2602
2636
  ["EV_SET_OPERATION_MODE", ac_set_operation_mode, null],
2603
2637
  ["EV_SET_FOCUS_TOPIC", ac_set_focus_topic, null],
2604
2638
  ["EV_FIND_NODES", ac_find_nodes, null],
2639
+ ["EV_EXPAND_ALL", ac_expand_all, null],
2640
+ ["EV_COLLAPSE_ALL", ac_collapse_all, null],
2605
2641
  ["EV_LAYOUT_AUTOSET", ac_layout_autoset, null],
2606
2642
  ["EV_TOGGLE_LEGEND", ac_toggle_legend, null],
2607
2643
  ["EV_LEGEND_TOPIC", ac_legend_topic, null],
@@ -2641,6 +2677,8 @@ function create_gclass(gclass_name)
2641
2677
  ["EV_SET_OPERATION_MODE", 0],
2642
2678
  ["EV_SET_FOCUS_TOPIC", 0],
2643
2679
  ["EV_FIND_NODES", 0],
2680
+ ["EV_EXPAND_ALL", 0],
2681
+ ["EV_COLLAPSE_ALL", 0],
2644
2682
  ["EV_FIND_RESULT", 0],
2645
2683
  ["EV_LAYOUT_AUTOSET", 0],
2646
2684
  ["EV_TOGGLE_LEGEND", 0],
@@ -19,14 +19,19 @@
19
19
  * triggers an action.type on click
20
20
  *
21
21
  * Action types accepted on a toolbar item or on a dropdown sub-item:
22
- * "navigate" | "drawer" | "event" | "dropdown"
22
+ * "navigate" | "drawer" | "event" | "dropdown" | "link"
23
+ *
24
+ * "link" is the one that leaves the application, and the one item
25
+ * rendered as a real <a href> rather than a <button>.
23
26
  *
24
27
  * Copyright (c) 2026, ArtGins.
25
28
  * All Rights Reserved.
26
29
  ***********************************************************************/
27
30
 
28
31
  export const TOOLBAR_ITEM_KINDS = ["brand", "avatar", "connection", "action"];
29
- export const TOOLBAR_ACTION_TYPES = ["navigate", "drawer", "event", "dropdown"];
32
+ export const TOOLBAR_ACTION_TYPES = [
33
+ "navigate", "drawer", "event", "dropdown", "link"
34
+ ];
30
35
 
31
36
 
32
37
  /************************************************************
@@ -141,6 +146,11 @@ export function validate_action(action, owner_id)
141
146
  `toolbar item '${id}' (action.type:event) requires 'event' name`
142
147
  );
143
148
  }
149
+ if(action.type === "link" && !_is_non_empty_string(action.url)) {
150
+ warnings.push(
151
+ `toolbar item '${id}' (action.type:link) requires 'url'`
152
+ );
153
+ }
144
154
  if(action.type === "dropdown") {
145
155
  let nested = validate_dropdown_action(action, id);
146
156
  for(let w of nested) {
@@ -203,7 +203,9 @@ test("dropdown: invalid sub-action surfaces underlying warning", () => {
203
203
  *============================================================*/
204
204
  test("kinds + action-type constants are exposed", () => {
205
205
  expect(TOOLBAR_ITEM_KINDS).toEqual(["brand", "avatar", "connection", "action"]);
206
- expect(TOOLBAR_ACTION_TYPES).toEqual(["navigate", "drawer", "event", "dropdown"]);
206
+ expect(TOOLBAR_ACTION_TYPES).toEqual(
207
+ ["navigate", "drawer", "event", "dropdown", "link"]
208
+ );
207
209
  });
208
210
 
209
211
 
@@ -246,3 +248,55 @@ test("dropdown: non-boolean 'selected' warns", () => {
246
248
  expect(r.length).toBe(1);
247
249
  expect(r[0]).toContain("non-boolean");
248
250
  });
251
+
252
+
253
+ /*============================================================
254
+ * validate_action — link
255
+ *
256
+ * The action that LEAVES the application. It is the only one
257
+ * rendered as an <a href>, so the url is not an optional extra:
258
+ * without it there is no link, only a dead control.
259
+ *============================================================*/
260
+ test("validate link: ok with a url", () => {
261
+ let r = validate_toolbar_item({
262
+ id: "landing", icon: "yi-arrow-left", name: "yuneta.io",
263
+ action: { type: "link", url: "https://yuneta.io/" }
264
+ });
265
+ expect(r.ok).toBe(true);
266
+ expect(r.warnings).toEqual([]);
267
+ });
268
+
269
+ test("validate link: a target is optional", () => {
270
+ let r = validate_toolbar_item({
271
+ id: "docs",
272
+ action: { type: "link", url: "https://doc.yuneta.io/", target: "_blank" }
273
+ });
274
+ expect(r.ok).toBe(true);
275
+ });
276
+
277
+ test("validate link: missing url flags warning", () => {
278
+ let r = validate_toolbar_item({
279
+ id: "landing", action: { type: "link" }
280
+ });
281
+ expect(r.ok).toBe(false);
282
+ expect(r.warnings.join("\n")).toMatch(/requires 'url'/);
283
+ });
284
+
285
+ test("validate link: empty url counts as missing", () => {
286
+ let r = validate_toolbar_item({
287
+ id: "landing", action: { type: "link", url: " " }
288
+ });
289
+ expect(r.ok).toBe(false);
290
+ expect(r.warnings.join("\n")).toMatch(/requires 'url'/);
291
+ });
292
+
293
+ test("validate link: accepted inside a dropdown too", () => {
294
+ let r = validate_dropdown_action({
295
+ type: "dropdown",
296
+ items: [
297
+ { id: "site", name: "yuneta.io",
298
+ action: { type: "link", url: "https://yuneta.io/" } },
299
+ ],
300
+ }, "help");
301
+ expect(r).toEqual([]);
302
+ });