@yuneta/gobj-ui 7.23.64 → 7.23.65

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.
@@ -268,7 +268,8 @@ var TOOLBAR_ACTION_TYPES = [
268
268
  "navigate",
269
269
  "drawer",
270
270
  "event",
271
- "dropdown"
271
+ "dropdown",
272
+ "link"
272
273
  ];
273
274
  /************************************************************
274
275
  * Decide which renderer to use for a toolbar item.
@@ -337,6 +338,7 @@ function validate_action(action, owner_id) {
337
338
  }
338
339
  if (action.type === "navigate" && !_is_non_empty_string(action.route)) warnings.push(`toolbar item '${id}' (action.type:navigate) requires 'route'`);
339
340
  if (action.type === "event" && !_is_non_empty_string(action.event)) warnings.push(`toolbar item '${id}' (action.type:event) requires 'event' name`);
341
+ if (action.type === "link" && !_is_non_empty_string(action.url)) warnings.push(`toolbar item '${id}' (action.type:link) requires 'url'`);
340
342
  if (action.type === "dropdown") {
341
343
  let nested = validate_dropdown_action(action, id);
342
344
  for (let w of nested) warnings.push(w);
@@ -1684,6 +1686,20 @@ function safe_id(s) {
1684
1686
  * drawer { type:"drawer", op:"toggle"|"open"|"close", menu_id? }
1685
1687
  * event { type:"event", event, kw? }
1686
1688
  * dropdown { type:"dropdown", items[] } (toolbar-only)
1689
+ * link { type:"link", url, target? }
1690
+ *
1691
+ * `link` leaves the application, so on a toolbar item and on the brand
1692
+ * it is rendered as a real <a href> rather than a <button>:
1693
+ * middle-click, open-in-new-tab and the url in the status bar are what
1694
+ * make a link recognisable as one, and a button that assigns
1695
+ * location.href has none of them. It is also why it does not become
1696
+ * an FSM event -- there is no state of this application to transition;
1697
+ * the page is being left. A target of "_blank" gets
1698
+ * rel="noopener noreferrer" for free.
1699
+ *
1700
+ * INSIDE A DROPDOWN it stays a <button>, deliberately: those rows are
1701
+ * role="menuitemradio" in a menu, and a link is not a choice among
1702
+ * options. There the dispatcher navigates.
1687
1703
  *
1688
1704
  * Per-item `show_on` is honoured: each rendered item is wrapped with
1689
1705
  * the same Bulma-helper logic as zones.
@@ -1770,6 +1786,26 @@ function badge_node(value) {
1770
1786
  ];
1771
1787
  }
1772
1788
  /************************************************************
1789
+ * The attributes that turn a toolbar item into a real link, or
1790
+ * null when this item is not one. Answering null is what keeps
1791
+ * every caller's default (a <button> that dispatches an action)
1792
+ * exactly as it was.
1793
+ ************************************************************/
1794
+ function toolbar_link_attrs(it) {
1795
+ let action = it && it.action || {};
1796
+ if (action.type !== "link") return null;
1797
+ if ((0, _yuneta_gobj_js.empty_string)(action.url)) {
1798
+ (0, _yuneta_gobj_js.log_warning)(`C_YUI_SHELL: toolbar item '${it.id || "?"}' has action.type "link" without a url`);
1799
+ return null;
1800
+ }
1801
+ let attrs = { href: action.url };
1802
+ if (!(0, _yuneta_gobj_js.empty_string)(action.target)) {
1803
+ attrs.target = action.target;
1804
+ if (action.target === "_blank") attrs.rel = "noopener noreferrer";
1805
+ }
1806
+ return attrs;
1807
+ }
1808
+ /************************************************************
1773
1809
  * Renderer for the default ("action") item kind.
1774
1810
  ************************************************************/
1775
1811
  function build_toolbar_action_item(gobj, it) {
@@ -1808,12 +1844,17 @@ function build_toolbar_action_item(gobj, it) {
1808
1844
  btn_attrs.title = tip;
1809
1845
  btn_attrs["data-i18n-title"] = tip;
1810
1846
  }
1847
+ let link = toolbar_link_attrs(it);
1848
+ if (link) {
1849
+ delete btn_attrs.type;
1850
+ Object.assign(btn_attrs, link);
1851
+ }
1811
1852
  let $item = (0, _yuneta_gobj_js.createElement2)([
1812
- "button",
1853
+ link ? "a" : "button",
1813
1854
  btn_attrs,
1814
1855
  children
1815
1856
  ]);
1816
- $item.addEventListener("click", (ev) => {
1857
+ if (!link) $item.addEventListener("click", (ev) => {
1817
1858
  ev.preventDefault();
1818
1859
  handle_toolbar_action(gobj, it, $item);
1819
1860
  });
@@ -1845,7 +1886,11 @@ function build_toolbar_brand_item(gobj, it) {
1845
1886
  attrs["aria-haspopup"] = "menu";
1846
1887
  attrs["aria-expanded"] = "false";
1847
1888
  }
1848
- if (action_type === "") tag = "div";
1889
+ let link = toolbar_link_attrs(it);
1890
+ if (link) {
1891
+ tag = "a";
1892
+ Object.assign(attrs, link);
1893
+ } else if (action_type === "") tag = "div";
1849
1894
  else attrs.type = "button";
1850
1895
  let $item = (0, _yuneta_gobj_js.createElement2)([
1851
1896
  tag,
@@ -1863,7 +1908,7 @@ function build_toolbar_brand_item(gobj, it) {
1863
1908
  it.wordmark
1864
1909
  ]]
1865
1910
  ]);
1866
- if (action_type !== "") $item.addEventListener("click", (ev) => {
1911
+ if (action_type !== "" && !link) $item.addEventListener("click", (ev) => {
1867
1912
  ev.preventDefault();
1868
1913
  handle_toolbar_action(gobj, it, $item);
1869
1914
  });
@@ -2035,6 +2080,12 @@ function handle_toolbar_action(gobj, item, $trigger) {
2035
2080
  case "dropdown":
2036
2081
  toggle_toolbar_dropdown(gobj, item, action, $trigger);
2037
2082
  break;
2083
+ case "link":
2084
+ if (!(0, _yuneta_gobj_js.empty_string)(action.url)) {
2085
+ if (action.target === "_blank") window.open(action.url, "_blank", "noopener,noreferrer");
2086
+ else window.location.assign(action.url);
2087
+ } else (0, _yuneta_gobj_js.log_warning)(`C_YUI_SHELL: toolbar item '${item.id || "?"}' has action.type "link" without a url`);
2088
+ break;
2038
2089
  default: (0, _yuneta_gobj_js.log_warning)(`C_YUI_SHELL: toolbar item '${item.id || "?"}' has no/unknown action.type`);
2039
2090
  }
2040
2091
  }
@@ -263,7 +263,8 @@ var TOOLBAR_ACTION_TYPES = [
263
263
  "navigate",
264
264
  "drawer",
265
265
  "event",
266
- "dropdown"
266
+ "dropdown",
267
+ "link"
267
268
  ];
268
269
  /************************************************************
269
270
  * Decide which renderer to use for a toolbar item.
@@ -332,6 +333,7 @@ function validate_action(action, owner_id) {
332
333
  }
333
334
  if (action.type === "navigate" && !_is_non_empty_string(action.route)) warnings.push(`toolbar item '${id}' (action.type:navigate) requires 'route'`);
334
335
  if (action.type === "event" && !_is_non_empty_string(action.event)) warnings.push(`toolbar item '${id}' (action.type:event) requires 'event' name`);
336
+ if (action.type === "link" && !_is_non_empty_string(action.url)) warnings.push(`toolbar item '${id}' (action.type:link) requires 'url'`);
335
337
  if (action.type === "dropdown") {
336
338
  let nested = validate_dropdown_action(action, id);
337
339
  for (let w of nested) warnings.push(w);
@@ -1679,6 +1681,20 @@ function safe_id(s) {
1679
1681
  * drawer { type:"drawer", op:"toggle"|"open"|"close", menu_id? }
1680
1682
  * event { type:"event", event, kw? }
1681
1683
  * dropdown { type:"dropdown", items[] } (toolbar-only)
1684
+ * link { type:"link", url, target? }
1685
+ *
1686
+ * `link` leaves the application, so on a toolbar item and on the brand
1687
+ * it is rendered as a real <a href> rather than a <button>:
1688
+ * middle-click, open-in-new-tab and the url in the status bar are what
1689
+ * make a link recognisable as one, and a button that assigns
1690
+ * location.href has none of them. It is also why it does not become
1691
+ * an FSM event -- there is no state of this application to transition;
1692
+ * the page is being left. A target of "_blank" gets
1693
+ * rel="noopener noreferrer" for free.
1694
+ *
1695
+ * INSIDE A DROPDOWN it stays a <button>, deliberately: those rows are
1696
+ * role="menuitemradio" in a menu, and a link is not a choice among
1697
+ * options. There the dispatcher navigates.
1682
1698
  *
1683
1699
  * Per-item `show_on` is honoured: each rendered item is wrapped with
1684
1700
  * the same Bulma-helper logic as zones.
@@ -1765,6 +1781,26 @@ function badge_node(value) {
1765
1781
  ];
1766
1782
  }
1767
1783
  /************************************************************
1784
+ * The attributes that turn a toolbar item into a real link, or
1785
+ * null when this item is not one. Answering null is what keeps
1786
+ * every caller's default (a <button> that dispatches an action)
1787
+ * exactly as it was.
1788
+ ************************************************************/
1789
+ function toolbar_link_attrs(it) {
1790
+ let action = it && it.action || {};
1791
+ if (action.type !== "link") return null;
1792
+ if (empty_string(action.url)) {
1793
+ log_warning(`C_YUI_SHELL: toolbar item '${it.id || "?"}' has action.type "link" without a url`);
1794
+ return null;
1795
+ }
1796
+ let attrs = { href: action.url };
1797
+ if (!empty_string(action.target)) {
1798
+ attrs.target = action.target;
1799
+ if (action.target === "_blank") attrs.rel = "noopener noreferrer";
1800
+ }
1801
+ return attrs;
1802
+ }
1803
+ /************************************************************
1768
1804
  * Renderer for the default ("action") item kind.
1769
1805
  ************************************************************/
1770
1806
  function build_toolbar_action_item(gobj, it) {
@@ -1803,12 +1839,17 @@ function build_toolbar_action_item(gobj, it) {
1803
1839
  btn_attrs.title = tip;
1804
1840
  btn_attrs["data-i18n-title"] = tip;
1805
1841
  }
1842
+ let link = toolbar_link_attrs(it);
1843
+ if (link) {
1844
+ delete btn_attrs.type;
1845
+ Object.assign(btn_attrs, link);
1846
+ }
1806
1847
  let $item = createElement2([
1807
- "button",
1848
+ link ? "a" : "button",
1808
1849
  btn_attrs,
1809
1850
  children
1810
1851
  ]);
1811
- $item.addEventListener("click", (ev) => {
1852
+ if (!link) $item.addEventListener("click", (ev) => {
1812
1853
  ev.preventDefault();
1813
1854
  handle_toolbar_action(gobj, it, $item);
1814
1855
  });
@@ -1840,7 +1881,11 @@ function build_toolbar_brand_item(gobj, it) {
1840
1881
  attrs["aria-haspopup"] = "menu";
1841
1882
  attrs["aria-expanded"] = "false";
1842
1883
  }
1843
- if (action_type === "") tag = "div";
1884
+ let link = toolbar_link_attrs(it);
1885
+ if (link) {
1886
+ tag = "a";
1887
+ Object.assign(attrs, link);
1888
+ } else if (action_type === "") tag = "div";
1844
1889
  else attrs.type = "button";
1845
1890
  let $item = createElement2([
1846
1891
  tag,
@@ -1858,7 +1903,7 @@ function build_toolbar_brand_item(gobj, it) {
1858
1903
  it.wordmark
1859
1904
  ]]
1860
1905
  ]);
1861
- if (action_type !== "") $item.addEventListener("click", (ev) => {
1906
+ if (action_type !== "" && !link) $item.addEventListener("click", (ev) => {
1862
1907
  ev.preventDefault();
1863
1908
  handle_toolbar_action(gobj, it, $item);
1864
1909
  });
@@ -2030,6 +2075,12 @@ function handle_toolbar_action(gobj, item, $trigger) {
2030
2075
  case "dropdown":
2031
2076
  toggle_toolbar_dropdown(gobj, item, action, $trigger);
2032
2077
  break;
2078
+ case "link":
2079
+ if (!empty_string(action.url)) {
2080
+ if (action.target === "_blank") window.open(action.url, "_blank", "noopener,noreferrer");
2081
+ else window.location.assign(action.url);
2082
+ } else log_warning(`C_YUI_SHELL: toolbar item '${item.id || "?"}' has action.type "link" without a url`);
2083
+ break;
2033
2084
  default: log_warning(`C_YUI_SHELL: toolbar item '${item.id || "?"}' has no/unknown action.type`);
2034
2085
  }
2035
2086
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuneta/gobj-ui",
3
- "version": "7.23.64",
3
+ "version": "7.23.65",
4
4
  "type": "module",
5
5
  "main": "dist/gobj-ui.cjs.js",
6
6
  "module": "dist/gobj-ui.es.js",
@@ -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`
@@ -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
+ });