@yuneta/gobj-ui 7.23.63 → 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
  }
@@ -23812,6 +23863,8 @@ function popup_mount_layer(gobj) {
23812
23863
  * Framework Method: Create
23813
23864
  ***************************************************************/
23814
23865
  function mt_create$5(gobj) {
23866
+ gobj.priv.reading_files = 0;
23867
+ gobj.priv.read_serial = 0;
23815
23868
  let name = (0, _yuneta_gobj_js.clean_name)((0, _yuneta_gobj_js.gobj_name)(gobj));
23816
23869
  (0, _yuneta_gobj_js.gobj_write_attr)(gobj, "table_id", "table" + name);
23817
23870
  (0, _yuneta_gobj_js.gobj_write_attr)(gobj, "toolbar_id", "toolbar" + name);
@@ -25060,6 +25113,7 @@ function form_may_close(gobj) {
25060
25113
  ************************************************************/
25061
25114
  function teardown_form_child(gobj) {
25062
25115
  let priv = gobj.priv;
25116
+ priv.reading_files = 0;
25063
25117
  if (priv.form) {
25064
25118
  if ((0, _yuneta_gobj_js.gobj_is_running)(priv.form)) (0, _yuneta_gobj_js.gobj_stop)(priv.form);
25065
25119
  (0, _yuneta_gobj_js.gobj_destroy)(priv.form);
@@ -25867,6 +25921,10 @@ function ac_paste_rows(gobj, event, kw, src) {
25867
25921
  function ac_form_save_record(gobj, event, kw, src) {
25868
25922
  if (refuse_if_readonly$2(gobj, event)) return -1;
25869
25923
  let mode = (0, _yuneta_gobj_js.gobj_read_str_attr)(src, "form_mode");
25924
+ if (priv.reading_files) {
25925
+ (0, _yuneta_gobj_js.log_warning)(`${(0, _yuneta_gobj_js.gobj_short_name)(gobj)}: save ignored, still reading the picked files`);
25926
+ return -1;
25927
+ }
25870
25928
  let picked = {};
25871
25929
  const answer = (0, _yuneta_gobj_js.gobj_command)(src, "get_picked_files", {}, gobj);
25872
25930
  if (answer && answer.result >= 0 && answer.data) picked = answer.data;
@@ -25878,6 +25936,9 @@ function ac_form_save_record(gobj, event, kw, src) {
25878
25936
  }, 0);
25879
25937
  return 0;
25880
25938
  }
25939
+ const serial = ++priv.read_serial;
25940
+ priv.reading_files = serial;
25941
+ set_form_busy(gobj, true);
25881
25942
  Promise.all(cols.map((col) => {
25882
25943
  return yui_file_read(picked[col]).then((read) => {
25883
25944
  return {
@@ -25891,16 +25952,54 @@ function ac_form_save_record(gobj, event, kw, src) {
25891
25952
  picks[r.col] = r.read;
25892
25953
  });
25893
25954
  (0, _yuneta_gobj_js.gobj_send_event)(gobj, "EV_FILES_READ", {
25955
+ serial,
25894
25956
  mode,
25895
25957
  record: kw,
25896
25958
  picks
25897
25959
  }, gobj);
25898
25960
  }).catch((e) => {
25899
- (0, _yuneta_gobj_js.gobj_send_event)(gobj, "EV_FILES_FAILED", { reason: e && e.message ? e.message : String(e) }, gobj);
25961
+ (0, _yuneta_gobj_js.gobj_send_event)(gobj, "EV_FILES_FAILED", {
25962
+ serial,
25963
+ reason: e && e.message ? e.message : String(e)
25964
+ }, gobj);
25900
25965
  });
25901
25966
  return 0;
25902
25967
  }
25903
25968
  /************************************************************
25969
+ * The form's toolbar while a read is in flight: every button
25970
+ * disabled, the save button spinning. Bulma's `is-loading` is the
25971
+ * spinner; `disabled` is what stops the second click.
25972
+ ************************************************************/
25973
+ function set_form_busy(gobj, busy) {
25974
+ let priv = gobj.priv;
25975
+ if (!priv.form) return;
25976
+ const $container = (0, _yuneta_gobj_js.gobj_read_attr)(priv.form, "$container");
25977
+ if (!$container) return;
25978
+ $container.querySelectorAll(".yui-toolbar-form button").forEach(($b) => {
25979
+ if (busy) {
25980
+ $b.dataset.was_disabled = $b.disabled ? "1" : "0";
25981
+ $b.disabled = true;
25982
+ } else if ($b.dataset.was_disabled !== void 0) {
25983
+ $b.disabled = $b.dataset.was_disabled === "1";
25984
+ delete $b.dataset.was_disabled;
25985
+ }
25986
+ });
25987
+ const $save = $container.querySelector(".yui-toolbar-form .button-save");
25988
+ if ($save) $save.classList.toggle("is-loading", !!busy);
25989
+ }
25990
+ /************************************************************
25991
+ * Is this the read the view is waiting for? A read whose form was
25992
+ * closed (teardown zeroes the serial) or replaced lands on nobody.
25993
+ ************************************************************/
25994
+ function read_is_current(gobj, kw) {
25995
+ let priv = gobj.priv;
25996
+ if (!priv.form || !priv.reading_files || kw.serial !== priv.reading_files) {
25997
+ (0, _yuneta_gobj_js.log_warning)(`${(0, _yuneta_gobj_js.gobj_short_name)(gobj)}: files read for a form that is gone, dropped`);
25998
+ return false;
25999
+ }
26000
+ return true;
26001
+ }
26002
+ /************************************************************
25904
26003
  * The one place a write of this view leaves for the app.
25905
26004
  ************************************************************/
25906
26005
  function publish_treedb_write(gobj, mode, record, jn_files) {
@@ -25915,6 +26014,8 @@ function publish_treedb_write(gobj, mode, record, jn_files) {
25915
26014
  * {mode, record, picks} -- the bytes are here now
25916
26015
  ************************************************************/
25917
26016
  function ac_files_read(gobj, event, kw, src) {
26017
+ if (!read_is_current(gobj, kw)) return -1;
26018
+ gobj.priv.reading_files = 0;
25918
26019
  const { record, __files__ } = yui_files_manifest(kw.picks, kw.record);
25919
26020
  publish_treedb_write(gobj, kw.mode, record, __files__);
25920
26021
  close_form_dialog(gobj);
@@ -25927,6 +26028,9 @@ function ac_files_read(gobj, event, kw, src) {
25927
26028
  * throw away what the person typed for a failure that is not theirs.
25928
26029
  ************************************************************/
25929
26030
  function ac_files_failed(gobj, event, kw, src) {
26031
+ if (!read_is_current(gobj, kw)) return -1;
26032
+ gobj.priv.reading_files = 0;
26033
+ set_form_busy(gobj, false);
25930
26034
  (0, _yuneta_gobj_js.log_error)(`${(0, _yuneta_gobj_js.gobj_short_name)(gobj)}: cannot read the picked file: ${kw.reason}`);
25931
26035
  yui_shell_confirm_ok(yui_shell_of(gobj), "cannot read the picked file", {
25932
26036
  t: i18next.t,
@@ -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
  }
@@ -23807,6 +23858,8 @@ function popup_mount_layer(gobj) {
23807
23858
  * Framework Method: Create
23808
23859
  ***************************************************************/
23809
23860
  function mt_create$5(gobj) {
23861
+ gobj.priv.reading_files = 0;
23862
+ gobj.priv.read_serial = 0;
23810
23863
  let name = clean_name(gobj_name(gobj));
23811
23864
  gobj_write_attr(gobj, "table_id", "table" + name);
23812
23865
  gobj_write_attr(gobj, "toolbar_id", "toolbar" + name);
@@ -25055,6 +25108,7 @@ function form_may_close(gobj) {
25055
25108
  ************************************************************/
25056
25109
  function teardown_form_child(gobj) {
25057
25110
  let priv = gobj.priv;
25111
+ priv.reading_files = 0;
25058
25112
  if (priv.form) {
25059
25113
  if (gobj_is_running(priv.form)) gobj_stop(priv.form);
25060
25114
  gobj_destroy(priv.form);
@@ -25862,6 +25916,10 @@ function ac_paste_rows(gobj, event, kw, src) {
25862
25916
  function ac_form_save_record(gobj, event, kw, src) {
25863
25917
  if (refuse_if_readonly$2(gobj, event)) return -1;
25864
25918
  let mode = gobj_read_str_attr(src, "form_mode");
25919
+ if (priv.reading_files) {
25920
+ log_warning(`${gobj_short_name(gobj)}: save ignored, still reading the picked files`);
25921
+ return -1;
25922
+ }
25865
25923
  let picked = {};
25866
25924
  const answer = gobj_command(src, "get_picked_files", {}, gobj);
25867
25925
  if (answer && answer.result >= 0 && answer.data) picked = answer.data;
@@ -25873,6 +25931,9 @@ function ac_form_save_record(gobj, event, kw, src) {
25873
25931
  }, 0);
25874
25932
  return 0;
25875
25933
  }
25934
+ const serial = ++priv.read_serial;
25935
+ priv.reading_files = serial;
25936
+ set_form_busy(gobj, true);
25876
25937
  Promise.all(cols.map((col) => {
25877
25938
  return yui_file_read(picked[col]).then((read) => {
25878
25939
  return {
@@ -25886,16 +25947,54 @@ function ac_form_save_record(gobj, event, kw, src) {
25886
25947
  picks[r.col] = r.read;
25887
25948
  });
25888
25949
  gobj_send_event(gobj, "EV_FILES_READ", {
25950
+ serial,
25889
25951
  mode,
25890
25952
  record: kw,
25891
25953
  picks
25892
25954
  }, gobj);
25893
25955
  }).catch((e) => {
25894
- gobj_send_event(gobj, "EV_FILES_FAILED", { reason: e && e.message ? e.message : String(e) }, gobj);
25956
+ gobj_send_event(gobj, "EV_FILES_FAILED", {
25957
+ serial,
25958
+ reason: e && e.message ? e.message : String(e)
25959
+ }, gobj);
25895
25960
  });
25896
25961
  return 0;
25897
25962
  }
25898
25963
  /************************************************************
25964
+ * The form's toolbar while a read is in flight: every button
25965
+ * disabled, the save button spinning. Bulma's `is-loading` is the
25966
+ * spinner; `disabled` is what stops the second click.
25967
+ ************************************************************/
25968
+ function set_form_busy(gobj, busy) {
25969
+ let priv = gobj.priv;
25970
+ if (!priv.form) return;
25971
+ const $container = gobj_read_attr(priv.form, "$container");
25972
+ if (!$container) return;
25973
+ $container.querySelectorAll(".yui-toolbar-form button").forEach(($b) => {
25974
+ if (busy) {
25975
+ $b.dataset.was_disabled = $b.disabled ? "1" : "0";
25976
+ $b.disabled = true;
25977
+ } else if ($b.dataset.was_disabled !== void 0) {
25978
+ $b.disabled = $b.dataset.was_disabled === "1";
25979
+ delete $b.dataset.was_disabled;
25980
+ }
25981
+ });
25982
+ const $save = $container.querySelector(".yui-toolbar-form .button-save");
25983
+ if ($save) $save.classList.toggle("is-loading", !!busy);
25984
+ }
25985
+ /************************************************************
25986
+ * Is this the read the view is waiting for? A read whose form was
25987
+ * closed (teardown zeroes the serial) or replaced lands on nobody.
25988
+ ************************************************************/
25989
+ function read_is_current(gobj, kw) {
25990
+ let priv = gobj.priv;
25991
+ if (!priv.form || !priv.reading_files || kw.serial !== priv.reading_files) {
25992
+ log_warning(`${gobj_short_name(gobj)}: files read for a form that is gone, dropped`);
25993
+ return false;
25994
+ }
25995
+ return true;
25996
+ }
25997
+ /************************************************************
25899
25998
  * The one place a write of this view leaves for the app.
25900
25999
  ************************************************************/
25901
26000
  function publish_treedb_write(gobj, mode, record, jn_files) {
@@ -25910,6 +26009,8 @@ function publish_treedb_write(gobj, mode, record, jn_files) {
25910
26009
  * {mode, record, picks} -- the bytes are here now
25911
26010
  ************************************************************/
25912
26011
  function ac_files_read(gobj, event, kw, src) {
26012
+ if (!read_is_current(gobj, kw)) return -1;
26013
+ gobj.priv.reading_files = 0;
25913
26014
  const { record, __files__ } = yui_files_manifest(kw.picks, kw.record);
25914
26015
  publish_treedb_write(gobj, kw.mode, record, __files__);
25915
26016
  close_form_dialog(gobj);
@@ -25922,6 +26023,9 @@ function ac_files_read(gobj, event, kw, src) {
25922
26023
  * throw away what the person typed for a failure that is not theirs.
25923
26024
  ************************************************************/
25924
26025
  function ac_files_failed(gobj, event, kw, src) {
26026
+ if (!read_is_current(gobj, kw)) return -1;
26027
+ gobj.priv.reading_files = 0;
26028
+ set_form_busy(gobj, false);
25925
26029
  log_error(`${gobj_short_name(gobj)}: cannot read the picked file: ${kw.reason}`);
25926
26030
  yui_shell_confirm_ok(yui_shell_of(gobj), "cannot read the picked file", {
25927
26031
  t,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuneta/gobj-ui",
3
- "version": "7.23.63",
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`
@@ -394,6 +394,12 @@ function popup_mount_layer(gobj)
394
394
  ***************************************************************/
395
395
  function mt_create(gobj)
396
396
  {
397
+ /* 0 = not reading; otherwise the serial of the read in flight. A
398
+ * serial and not a flag, so that a read whose form was closed and
399
+ * reopened cannot land on the new form. */
400
+ gobj.priv.reading_files = 0;
401
+ gobj.priv.read_serial = 0;
402
+
397
403
  let name = clean_name(gobj_name(gobj));
398
404
  gobj_write_attr(gobj, "table_id", "table" + name);
399
405
  gobj_write_attr(gobj, "toolbar_id", "toolbar" + name);
@@ -2219,6 +2225,7 @@ function form_may_close(gobj)
2219
2225
  function teardown_form_child(gobj)
2220
2226
  {
2221
2227
  let priv = gobj.priv;
2228
+ priv.reading_files = 0; // a read in flight lands on nobody
2222
2229
  if(priv.form) {
2223
2230
  if(gobj_is_running(priv.form)) {
2224
2231
  gobj_stop(priv.form);
@@ -3484,6 +3491,13 @@ function ac_form_save_record(gobj, event, kw, src)
3484
3491
  * files never came with the record: the form KEPT them and this is
3485
3492
  * where they are asked for. Nothing picked is the ordinary case.
3486
3493
  *------------------------------------------------------*/
3494
+ if(priv.reading_files) {
3495
+ /* The toolbar is disabled while a read is in flight, so this is
3496
+ * a keyboard shortcut or a script: said, and not a second write. */
3497
+ log_warning(`${gobj_short_name(gobj)}: save ignored, still reading the picked files`);
3498
+ return -1;
3499
+ }
3500
+
3487
3501
  let picked = {};
3488
3502
  const answer = gobj_command(src, "get_picked_files", {}, gobj);
3489
3503
  if(answer && answer.result >= 0 && answer.data) {
@@ -3508,8 +3522,16 @@ function ac_form_save_record(gobj, event, kw, src)
3508
3522
  * notification: it enters the machine as an EVENT, never as a
3509
3523
  * chain of callbacks that the trace cannot see. The dialog stays
3510
3524
  * open until it lands, which is what tells the person that 40 MB
3511
- * are still going up.
3525
+ * are still going up -- and stays open BUSY: its toolbar is
3526
+ * disabled and the save button spins, because a second Save
3527
+ * during the read was a second write, and a Cancel was a save
3528
+ * lost without a word. The serial travels with the read: a read
3529
+ * whose form was closed meanwhile lands on nobody (ac_files_read).
3512
3530
  *------------------------------------------------------*/
3531
+ const serial = ++priv.read_serial;
3532
+ priv.reading_files = serial;
3533
+ set_form_busy(gobj, true);
3534
+
3513
3535
  Promise.all(cols.map((col) => {
3514
3536
  return yui_file_read(picked[col]).then((read) => {
3515
3537
  return {col: col, read: read};
@@ -3520,17 +3542,61 @@ function ac_form_save_record(gobj, event, kw, src)
3520
3542
  picks[r.col] = r.read;
3521
3543
  });
3522
3544
  gobj_send_event(gobj, "EV_FILES_READ",
3523
- {mode: mode, record: kw, picks: picks}, gobj
3545
+ {serial: serial, mode: mode, record: kw, picks: picks}, gobj
3524
3546
  );
3525
3547
  }).catch((e) => {
3526
3548
  gobj_send_event(gobj, "EV_FILES_FAILED",
3527
- {reason: (e && e.message)? e.message: String(e)}, gobj
3549
+ {serial: serial, reason: (e && e.message)? e.message: String(e)}, gobj
3528
3550
  );
3529
3551
  });
3530
3552
 
3531
3553
  return 0;
3532
3554
  }
3533
3555
 
3556
+ /************************************************************
3557
+ * The form's toolbar while a read is in flight: every button
3558
+ * disabled, the save button spinning. Bulma's `is-loading` is the
3559
+ * spinner; `disabled` is what stops the second click.
3560
+ ************************************************************/
3561
+ function set_form_busy(gobj, busy)
3562
+ {
3563
+ let priv = gobj.priv;
3564
+ if(!priv.form) {
3565
+ return;
3566
+ }
3567
+ const $container = gobj_read_attr(priv.form, "$container");
3568
+ if(!$container) {
3569
+ return;
3570
+ }
3571
+ $container.querySelectorAll('.yui-toolbar-form button').forEach(($b) => {
3572
+ if(busy) {
3573
+ $b.dataset.was_disabled = $b.disabled? "1": "0";
3574
+ $b.disabled = true;
3575
+ } else if($b.dataset.was_disabled !== undefined) {
3576
+ $b.disabled = ($b.dataset.was_disabled === "1");
3577
+ delete $b.dataset.was_disabled;
3578
+ }
3579
+ });
3580
+ const $save = $container.querySelector('.yui-toolbar-form .button-save');
3581
+ if($save) {
3582
+ $save.classList.toggle('is-loading', !!busy);
3583
+ }
3584
+ }
3585
+
3586
+ /************************************************************
3587
+ * Is this the read the view is waiting for? A read whose form was
3588
+ * closed (teardown zeroes the serial) or replaced lands on nobody.
3589
+ ************************************************************/
3590
+ function read_is_current(gobj, kw)
3591
+ {
3592
+ let priv = gobj.priv;
3593
+ if(!priv.form || !priv.reading_files || kw.serial !== priv.reading_files) {
3594
+ log_warning(`${gobj_short_name(gobj)}: files read for a form that is gone, dropped`);
3595
+ return false;
3596
+ }
3597
+ return true;
3598
+ }
3599
+
3534
3600
  /************************************************************
3535
3601
  * The one place a write of this view leaves for the app.
3536
3602
  ************************************************************/
@@ -3557,6 +3623,10 @@ function publish_treedb_write(gobj, mode, record, jn_files)
3557
3623
  ************************************************************/
3558
3624
  function ac_files_read(gobj, event, kw, src)
3559
3625
  {
3626
+ if(!read_is_current(gobj, kw)) {
3627
+ return -1; /* Warning already logged */
3628
+ }
3629
+ gobj.priv.reading_files = 0;
3560
3630
  const {record, __files__} = yui_files_manifest(kw.picks, kw.record);
3561
3631
  publish_treedb_write(gobj, kw.mode, record, __files__);
3562
3632
  close_form_dialog(gobj);
@@ -3571,6 +3641,11 @@ function ac_files_read(gobj, event, kw, src)
3571
3641
  ************************************************************/
3572
3642
  function ac_files_failed(gobj, event, kw, src)
3573
3643
  {
3644
+ if(!read_is_current(gobj, kw)) {
3645
+ return -1; /* Warning already logged */
3646
+ }
3647
+ gobj.priv.reading_files = 0;
3648
+ set_form_busy(gobj, false);
3574
3649
  log_error(`${gobj_short_name(gobj)}: cannot read the picked file: ${kw.reason}`);
3575
3650
  /* An i18n KEY, not a sentence: the dialog renders it with `i18n`, so
3576
3651
  * a sentence here would be a key nobody defined and would render as
@@ -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
+ });