@yuneta/gobj-ui 5.5.0 → 5.6.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
@@ -464,6 +464,46 @@ host can use it to toggle. The tree is a **pure child of the window**, so every
464
464
  teardown path (the ✕, or the host destroying the window to toggle the entry
465
465
  off) takes it down too.
466
466
 
467
+ ### Toolbar badge — a count pinned to an item's icon
468
+
469
+ ```js
470
+ /* app_config.json — seeds the FIRST paint only */
471
+ { "id": "alarms", "icon": "yi-triangle-exclamation", "align": "end",
472
+ "aria_label": "alarms", "badge": 0,
473
+ "action": {"type": "navigate", "route": "/alarms"} }
474
+
475
+ /* the interface that matters: a count is a RUNTIME fact */
476
+ yui_shell_set_toolbar_item_badge(shell, "alarms", 3);
477
+ yui_shell_set_toolbar_item_badge(shell, "alarms", 0); // clears it
478
+ ```
479
+
480
+ An icon-only toolbar button is a link; the badge is what makes it a
481
+ **signal**. Without a number, an alarm bell cannot say whether anything needs
482
+ you — which is the reason to look at it at all.
483
+
484
+ Rules baked in, all for the same reason (a badge that lies costs more than no
485
+ badge):
486
+
487
+ - **`0`, `""`, `null` and `false` all clear it.** A badge reading "0" is worse
488
+ than none: it draws the eye to say nothing.
489
+ - **Over 99 renders `99+`.** The toolbar is a fixed-width row and a four-digit
490
+ pill pushes its neighbours off a phone screen.
491
+ - **A string passes through** for the states that are not counts (`"!"`, `"…"`).
492
+ - **Unknown item id is a silent no-op**, so an app whose toolbar has no such
493
+ item does not log an error on every tick of whatever feeds the number.
494
+ - Writing the **same** value again touches no DOM: the badge is a
495
+ `role="status"` live region, and rewriting it would have a screen reader
496
+ announce the same number on every tick.
497
+
498
+ `role="status"` and not `aria-hidden`, deliberately: the button carries an
499
+ explicit `aria-label`, and an explicit label **replaces** an element's content
500
+ for a screen reader — a badge inside it would otherwise be silent. As its own
501
+ live region it is both read and announced when it changes.
502
+
503
+ > `C_YUI_NAV` items do **not** have this. Its item contract listed `badge` for a
504
+ > long time and nothing ever rendered it; the claim is gone. Implement it there
505
+ > the day a menu entry needs one.
506
+
467
507
  ### Modals — `yui_shell_show_modal` and the `before_close` veto
468
508
 
469
509
  `yui_shell_show_modal(shell, $box, opts)` is the standard popup: pass
@@ -1656,17 +1656,59 @@ function build_toolbar(gobj, config) {
1656
1656
  $zone.appendChild($bar);
1657
1657
  }
1658
1658
  /************************************************************
1659
+ * The badge node of a toolbar item — the little count pinned to
1660
+ * the corner of its icon (unread notifications, active alarms).
1661
+ *
1662
+ * ALWAYS rendered, hidden when empty, so the runtime setter has a
1663
+ * node to write into and does not have to rebuild the button.
1664
+ *
1665
+ * EMPTY IS 0, "" AND null ALIKE: a badge reading "0" is worse than
1666
+ * no badge — it draws the eye to say nothing. A count is only
1667
+ * worth a pixel when there IS something.
1668
+ *
1669
+ * Capped at 99+: the toolbar is a fixed-width row, and a four
1670
+ * digit pill pushes the items next to it off a phone screen.
1671
+ *
1672
+ * role="status" and NOT aria-hidden, deliberately. The button
1673
+ * carries an explicit aria-label, and an explicit label REPLACES
1674
+ * the element's content for a screen reader — so a badge inside
1675
+ * it would be silent. As its own live region it is both read and
1676
+ * announced when the count changes, which is the whole point of a
1677
+ * badge that means "something needs you".
1678
+ ************************************************************/
1679
+ function badge_text(value) {
1680
+ if (value === null || value === void 0 || value === false) return "";
1681
+ if (typeof value === "number") {
1682
+ if (!isFinite(value) || value <= 0) return "";
1683
+ return value > 99 ? "99+" : String(Math.floor(value));
1684
+ }
1685
+ return String(value).trim();
1686
+ }
1687
+ function badge_node(value) {
1688
+ let text = badge_text(value);
1689
+ let attrs = {
1690
+ class: "yui-toolbar-badge",
1691
+ role: "status"
1692
+ };
1693
+ if (!text) attrs.hidden = "hidden";
1694
+ return [
1695
+ "span",
1696
+ attrs,
1697
+ text
1698
+ ];
1699
+ }
1700
+ /************************************************************
1659
1701
  * Renderer for the default ("action") item kind.
1660
1702
  ************************************************************/
1661
1703
  function build_toolbar_action_item(gobj, it) {
1662
1704
  let children = [];
1663
1705
  if (!(0, _yuneta_gobj_js.empty_string)(it.icon)) children.push([
1664
1706
  "span",
1665
- { class: "icon" },
1666
- ["i", {
1707
+ { class: "icon yui-toolbar-icon" },
1708
+ [["i", {
1667
1709
  class: it.icon,
1668
1710
  "aria-hidden": "true"
1669
- }]
1711
+ }], badge_node(it.badge)]
1670
1712
  ]);
1671
1713
  if (!(0, _yuneta_gobj_js.empty_string)(it.name)) children.push([
1672
1714
  "span",
@@ -2677,6 +2719,41 @@ function yui_shell_set_toolbar_item_icon(shell_gobj, item_id, icon_class) {
2677
2719
  if ($i) $i.className = icon_class;
2678
2720
  }
2679
2721
  /************************************************************
2722
+ * Set (or clear) a toolbar item's badge — the count pinned to
2723
+ * its icon.
2724
+ *
2725
+ * yui_shell_set_toolbar_item_badge(shell, "alarms", 3);
2726
+ * yui_shell_set_toolbar_item_badge(shell, "alarms", 0); // hidden
2727
+ *
2728
+ * This is the API that matters: a count is a RUNTIME fact. The
2729
+ * `badge` field of a toolbar item only seeds the first paint, and
2730
+ * an app whose number never changes did not need a badge.
2731
+ *
2732
+ * 0 / "" / null / false all clear it, and a number over 99 renders
2733
+ * "99+" — see badge_node(). Accepts a string for the states that
2734
+ * are not counts ("!", "…").
2735
+ *
2736
+ * Silent no-op on an unknown item id: a toolbar that does not
2737
+ * declare the item (an app without alarms) must not be an error
2738
+ * at every tick of whatever feeds the number.
2739
+ ************************************************************/
2740
+ function yui_shell_set_toolbar_item_badge(shell_gobj, item_id, value) {
2741
+ if (!shell_gobj || !(0, _yuneta_gobj_js.is_gobj)(shell_gobj) || (0, _yuneta_gobj_js.empty_string)(item_id)) return;
2742
+ let $container = (0, _yuneta_gobj_js.gobj_read_attr)(shell_gobj, "$container");
2743
+ if (!$container) return;
2744
+ let $badge = $container.querySelector(`[data-toolbar-item-id="${item_id}"] .yui-toolbar-badge`);
2745
+ if (!$badge) return;
2746
+ let text = badge_text(value);
2747
+ if (!text) {
2748
+ $badge.textContent = "";
2749
+ $badge.setAttribute("hidden", "hidden");
2750
+ return;
2751
+ }
2752
+ if ($badge.textContent === text && !$badge.hasAttribute("hidden")) return;
2753
+ $badge.textContent = text;
2754
+ $badge.removeAttribute("hidden");
2755
+ }
2756
+ /************************************************************
2680
2757
  * Programmatic close of any open toolbar dropdown. Useful for
2681
2758
  * external triggers (e.g. EV_LOGOUT firing from elsewhere) that
2682
2759
  * want to dismiss whatever menu is on screen.
@@ -12943,7 +13020,12 @@ function cards_grid_descriptor(items, show_label) {
12943
13020
  * container; build_ui re-applies them on every rebuild.
12944
13021
  *
12945
13022
  * Each item supports:
12946
- * id, name, icon (CSS class or svg id), route, badge, disabled
13023
+ * id, name, icon (CSS class or svg id), route, disabled
13024
+ * (`badge` was listed here for a long time and NOTHING ever
13025
+ * rendered it — a menu item cannot carry a count today. The
13026
+ * toolbar can: `badge` on a toolbar item plus
13027
+ * yui_shell_set_toolbar_item_badge(). Implement it here the
13028
+ * day a menu entry needs one, rather than re-listing it.)
12947
13029
  * class — extra CSS class(es) on the item (tabs layout), e.g.
12948
13030
  * a per-item state colour like "yui-nav-disconnected"
12949
13031
  * closable — render a trailing ✕ that emits EV_NAV_ITEM_CLOSE
@@ -53622,6 +53704,7 @@ exports.yui_shell_register_overlay = yui_shell_register_overlay;
53622
53704
  exports.yui_shell_set_avatar_provider = yui_shell_set_avatar_provider;
53623
53705
  exports.yui_shell_set_connection_state = yui_shell_set_connection_state;
53624
53706
  exports.yui_shell_set_sub_routes = yui_shell_set_sub_routes;
53707
+ exports.yui_shell_set_toolbar_item_badge = yui_shell_set_toolbar_item_badge;
53625
53708
  exports.yui_shell_set_toolbar_item_icon = yui_shell_set_toolbar_item_icon;
53626
53709
  exports.yui_shell_set_translator = yui_shell_set_translator;
53627
53710
  exports.yui_shell_show_error = yui_shell_show_error;
@@ -1651,17 +1651,59 @@ function build_toolbar(gobj, config) {
1651
1651
  $zone.appendChild($bar);
1652
1652
  }
1653
1653
  /************************************************************
1654
+ * The badge node of a toolbar item — the little count pinned to
1655
+ * the corner of its icon (unread notifications, active alarms).
1656
+ *
1657
+ * ALWAYS rendered, hidden when empty, so the runtime setter has a
1658
+ * node to write into and does not have to rebuild the button.
1659
+ *
1660
+ * EMPTY IS 0, "" AND null ALIKE: a badge reading "0" is worse than
1661
+ * no badge — it draws the eye to say nothing. A count is only
1662
+ * worth a pixel when there IS something.
1663
+ *
1664
+ * Capped at 99+: the toolbar is a fixed-width row, and a four
1665
+ * digit pill pushes the items next to it off a phone screen.
1666
+ *
1667
+ * role="status" and NOT aria-hidden, deliberately. The button
1668
+ * carries an explicit aria-label, and an explicit label REPLACES
1669
+ * the element's content for a screen reader — so a badge inside
1670
+ * it would be silent. As its own live region it is both read and
1671
+ * announced when the count changes, which is the whole point of a
1672
+ * badge that means "something needs you".
1673
+ ************************************************************/
1674
+ function badge_text(value) {
1675
+ if (value === null || value === void 0 || value === false) return "";
1676
+ if (typeof value === "number") {
1677
+ if (!isFinite(value) || value <= 0) return "";
1678
+ return value > 99 ? "99+" : String(Math.floor(value));
1679
+ }
1680
+ return String(value).trim();
1681
+ }
1682
+ function badge_node(value) {
1683
+ let text = badge_text(value);
1684
+ let attrs = {
1685
+ class: "yui-toolbar-badge",
1686
+ role: "status"
1687
+ };
1688
+ if (!text) attrs.hidden = "hidden";
1689
+ return [
1690
+ "span",
1691
+ attrs,
1692
+ text
1693
+ ];
1694
+ }
1695
+ /************************************************************
1654
1696
  * Renderer for the default ("action") item kind.
1655
1697
  ************************************************************/
1656
1698
  function build_toolbar_action_item(gobj, it) {
1657
1699
  let children = [];
1658
1700
  if (!empty_string(it.icon)) children.push([
1659
1701
  "span",
1660
- { class: "icon" },
1661
- ["i", {
1702
+ { class: "icon yui-toolbar-icon" },
1703
+ [["i", {
1662
1704
  class: it.icon,
1663
1705
  "aria-hidden": "true"
1664
- }]
1706
+ }], badge_node(it.badge)]
1665
1707
  ]);
1666
1708
  if (!empty_string(it.name)) children.push([
1667
1709
  "span",
@@ -2672,6 +2714,41 @@ function yui_shell_set_toolbar_item_icon(shell_gobj, item_id, icon_class) {
2672
2714
  if ($i) $i.className = icon_class;
2673
2715
  }
2674
2716
  /************************************************************
2717
+ * Set (or clear) a toolbar item's badge — the count pinned to
2718
+ * its icon.
2719
+ *
2720
+ * yui_shell_set_toolbar_item_badge(shell, "alarms", 3);
2721
+ * yui_shell_set_toolbar_item_badge(shell, "alarms", 0); // hidden
2722
+ *
2723
+ * This is the API that matters: a count is a RUNTIME fact. The
2724
+ * `badge` field of a toolbar item only seeds the first paint, and
2725
+ * an app whose number never changes did not need a badge.
2726
+ *
2727
+ * 0 / "" / null / false all clear it, and a number over 99 renders
2728
+ * "99+" — see badge_node(). Accepts a string for the states that
2729
+ * are not counts ("!", "…").
2730
+ *
2731
+ * Silent no-op on an unknown item id: a toolbar that does not
2732
+ * declare the item (an app without alarms) must not be an error
2733
+ * at every tick of whatever feeds the number.
2734
+ ************************************************************/
2735
+ function yui_shell_set_toolbar_item_badge(shell_gobj, item_id, value) {
2736
+ if (!shell_gobj || !is_gobj(shell_gobj) || empty_string(item_id)) return;
2737
+ let $container = gobj_read_attr(shell_gobj, "$container");
2738
+ if (!$container) return;
2739
+ let $badge = $container.querySelector(`[data-toolbar-item-id="${item_id}"] .yui-toolbar-badge`);
2740
+ if (!$badge) return;
2741
+ let text = badge_text(value);
2742
+ if (!text) {
2743
+ $badge.textContent = "";
2744
+ $badge.setAttribute("hidden", "hidden");
2745
+ return;
2746
+ }
2747
+ if ($badge.textContent === text && !$badge.hasAttribute("hidden")) return;
2748
+ $badge.textContent = text;
2749
+ $badge.removeAttribute("hidden");
2750
+ }
2751
+ /************************************************************
2675
2752
  * Programmatic close of any open toolbar dropdown. Useful for
2676
2753
  * external triggers (e.g. EV_LOGOUT firing from elsewhere) that
2677
2754
  * want to dismiss whatever menu is on screen.
@@ -12938,7 +13015,12 @@ function cards_grid_descriptor(items, show_label) {
12938
13015
  * container; build_ui re-applies them on every rebuild.
12939
13016
  *
12940
13017
  * Each item supports:
12941
- * id, name, icon (CSS class or svg id), route, badge, disabled
13018
+ * id, name, icon (CSS class or svg id), route, disabled
13019
+ * (`badge` was listed here for a long time and NOTHING ever
13020
+ * rendered it — a menu item cannot carry a count today. The
13021
+ * toolbar can: `badge` on a toolbar item plus
13022
+ * yui_shell_set_toolbar_item_badge(). Implement it here the
13023
+ * day a menu entry needs one, rather than re-listing it.)
12942
13024
  * class — extra CSS class(es) on the item (tabs layout), e.g.
12943
13025
  * a per-item state colour like "yui-nav-disconnected"
12944
13026
  * closable — render a trailing ✕ that emits EV_NAV_ITEM_CLOSE
@@ -53529,6 +53611,6 @@ function setup_frontend_view(self) {
53529
53611
  return win;
53530
53612
  }
53531
53613
  //#endregion
53532
- export { EditControl, MarkerControl, YUI_PERIODS, YUI_PERIODS_DEFAULT, YUI_ROLLING, addClasses, apply_dev_traces, attach_clear, build_dev_panel, dev_window_was_open, disableElements, enableElements, epoch_to_local_input, epoch_to_ms, expose_view_container, fmt_epoch, getStrokeColor, infer_period, info_traffic, inject_svg_icons, is_current_period, iso_week, local_input_to_epoch, ms_to_epoch, period_bounds, period_bounds_epoch, period_label, period_name, period_shift, period_spec, period_start, refresh_clear, register_c_g6_nodes_tree, register_c_yui_form, register_c_yui_gobj_tree_js, register_c_yui_json, register_c_yui_json_graph, register_c_yui_map, register_c_yui_nav, register_c_yui_pager, register_c_yui_period, register_c_yui_service_view, register_c_yui_shell, register_c_yui_treedb_graph, register_c_yui_treedb_schema, register_c_yui_treedb_topic_with_form, register_c_yui_treedb_topics, register_c_yui_uplot, register_c_yui_window, register_c_yui_window_manager, register_c_yui_wizard, removeChildElements, removeClasses, rolling_bounds, safe_locale, set_active_state, set_cancel_state, set_submit_state, setup_dev, setup_frontend_view, toggleClasses, yui_is_dark, yui_mount_service_view, yui_shell_close_drawer, yui_shell_close_dropdown, yui_shell_confirm_ok, yui_shell_confirm_yesno, yui_shell_confirm_yesnocancel, yui_shell_navigate, yui_shell_open_drawer, yui_shell_overlay_dismissed, yui_shell_pop_escape, yui_shell_push_escape, yui_shell_refresh_avatars, yui_shell_register_event_handler, yui_shell_register_overlay, yui_shell_set_avatar_provider, yui_shell_set_connection_state, yui_shell_set_sub_routes, yui_shell_set_toolbar_item_icon, yui_shell_set_translator, yui_shell_show_error, yui_shell_show_info, yui_shell_show_modal, yui_shell_show_route_map, yui_shell_show_warning, yui_shell_toggle_drawer, yui_shell_unpark_route, yui_theme_now, yui_toolbar, yui_watch_theme };
53614
+ export { EditControl, MarkerControl, YUI_PERIODS, YUI_PERIODS_DEFAULT, YUI_ROLLING, addClasses, apply_dev_traces, attach_clear, build_dev_panel, dev_window_was_open, disableElements, enableElements, epoch_to_local_input, epoch_to_ms, expose_view_container, fmt_epoch, getStrokeColor, infer_period, info_traffic, inject_svg_icons, is_current_period, iso_week, local_input_to_epoch, ms_to_epoch, period_bounds, period_bounds_epoch, period_label, period_name, period_shift, period_spec, period_start, refresh_clear, register_c_g6_nodes_tree, register_c_yui_form, register_c_yui_gobj_tree_js, register_c_yui_json, register_c_yui_json_graph, register_c_yui_map, register_c_yui_nav, register_c_yui_pager, register_c_yui_period, register_c_yui_service_view, register_c_yui_shell, register_c_yui_treedb_graph, register_c_yui_treedb_schema, register_c_yui_treedb_topic_with_form, register_c_yui_treedb_topics, register_c_yui_uplot, register_c_yui_window, register_c_yui_window_manager, register_c_yui_wizard, removeChildElements, removeClasses, rolling_bounds, safe_locale, set_active_state, set_cancel_state, set_submit_state, setup_dev, setup_frontend_view, toggleClasses, yui_is_dark, yui_mount_service_view, yui_shell_close_drawer, yui_shell_close_dropdown, yui_shell_confirm_ok, yui_shell_confirm_yesno, yui_shell_confirm_yesnocancel, yui_shell_navigate, yui_shell_open_drawer, yui_shell_overlay_dismissed, yui_shell_pop_escape, yui_shell_push_escape, yui_shell_refresh_avatars, yui_shell_register_event_handler, yui_shell_register_overlay, yui_shell_set_avatar_provider, yui_shell_set_connection_state, yui_shell_set_sub_routes, yui_shell_set_toolbar_item_badge, yui_shell_set_toolbar_item_icon, yui_shell_set_translator, yui_shell_show_error, yui_shell_show_info, yui_shell_show_modal, yui_shell_show_route_map, yui_shell_show_warning, yui_shell_toggle_drawer, yui_shell_unpark_route, yui_theme_now, yui_toolbar, yui_watch_theme };
53533
53615
 
53534
53616
  //# sourceMappingURL=gobj-ui.es.js.map
package/index.js CHANGED
@@ -46,6 +46,7 @@ export {
46
46
  yui_shell_set_translator,
47
47
  yui_shell_set_connection_state,
48
48
  yui_shell_set_toolbar_item_icon,
49
+ yui_shell_set_toolbar_item_badge,
49
50
  yui_shell_close_dropdown,
50
51
  yui_shell_register_event_handler,
51
52
  yui_shell_set_sub_routes,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuneta/gobj-ui",
3
- "version": "5.5.0",
3
+ "version": "5.6.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_nav.js CHANGED
@@ -32,7 +32,12 @@
32
32
  * container; build_ui re-applies them on every rebuild.
33
33
  *
34
34
  * Each item supports:
35
- * id, name, icon (CSS class or svg id), route, badge, disabled
35
+ * id, name, icon (CSS class or svg id), route, disabled
36
+ * (`badge` was listed here for a long time and NOTHING ever
37
+ * rendered it — a menu item cannot carry a count today. The
38
+ * toolbar can: `badge` on a toolbar item plus
39
+ * yui_shell_set_toolbar_item_badge(). Implement it here the
40
+ * day a menu entry needs one, rather than re-listing it.)
36
41
  * class — extra CSS class(es) on the item (tabs layout), e.g.
37
42
  * a per-item state colour like "yui-nav-disconnected"
38
43
  * closable — render a trailing ✕ that emits EV_NAV_ITEM_CLOSE
@@ -449,6 +449,53 @@
449
449
  background-color: var(--bulma-scheme-main-bis, rgba(0,0,0,0.04));
450
450
  }
451
451
 
452
+ /*------------------------------------------------------------
453
+ * Toolbar badge — the count pinned to an item's icon
454
+ *
455
+ * Anchored to the ICON and not to the button: with its label on
456
+ * (desktop) the button is wide, and a badge in its corner would
457
+ * float away from the glyph it counts.
458
+ *
459
+ * `hidden` is honoured explicitly: the element is always in the
460
+ * DOM (so the runtime setter has somewhere to write) and
461
+ * [hidden] loses to `display:inline-flex` without this rule.
462
+ *
463
+ * Danger colour on purpose — a badge exists to pull the eye. It
464
+ * is its own colour pair in both schemes rather than a Bulma
465
+ * helper, so it keeps its contrast when the toolbar background
466
+ * changes with the theme. No transition: house rule.
467
+ *------------------------------------------------------------*/
468
+ .yui-toolbar-icon {
469
+ position: relative;
470
+ overflow: visible;
471
+ }
472
+ .yui-toolbar-badge {
473
+ position: absolute;
474
+ top: -0.35em;
475
+ right: -0.55em;
476
+ display: inline-flex;
477
+ align-items: center;
478
+ justify-content: center;
479
+ box-sizing: border-box;
480
+ /* A round dot for one digit, a pill from two on. */
481
+ min-width: 1.15em;
482
+ height: 1.15em;
483
+ padding: 0 0.3em;
484
+ border-radius: 999px;
485
+ font-size: 0.62em;
486
+ font-weight: 700;
487
+ line-height: 1;
488
+ font-family: inherit;
489
+ background-color: var(--bulma-danger, #f14668);
490
+ color: var(--bulma-danger-invert, #fff);
491
+ /* Separates the pill from the glyph when they overlap. */
492
+ box-shadow: 0 0 0 0.12em var(--bulma-scheme-main, #fff);
493
+ pointer-events: none;
494
+ }
495
+ .yui-toolbar-badge[hidden] {
496
+ display: none;
497
+ }
498
+
452
499
  /*============================================================
453
500
  * Toolbar — type:"brand"
454
501
  * Logo image + wordmark text. Renders as <button> when
@@ -1641,6 +1641,51 @@ function build_toolbar(gobj, config)
1641
1641
  $zone.appendChild($bar);
1642
1642
  }
1643
1643
 
1644
+ /************************************************************
1645
+ * The badge node of a toolbar item — the little count pinned to
1646
+ * the corner of its icon (unread notifications, active alarms).
1647
+ *
1648
+ * ALWAYS rendered, hidden when empty, so the runtime setter has a
1649
+ * node to write into and does not have to rebuild the button.
1650
+ *
1651
+ * EMPTY IS 0, "" AND null ALIKE: a badge reading "0" is worse than
1652
+ * no badge — it draws the eye to say nothing. A count is only
1653
+ * worth a pixel when there IS something.
1654
+ *
1655
+ * Capped at 99+: the toolbar is a fixed-width row, and a four
1656
+ * digit pill pushes the items next to it off a phone screen.
1657
+ *
1658
+ * role="status" and NOT aria-hidden, deliberately. The button
1659
+ * carries an explicit aria-label, and an explicit label REPLACES
1660
+ * the element's content for a screen reader — so a badge inside
1661
+ * it would be silent. As its own live region it is both read and
1662
+ * announced when the count changes, which is the whole point of a
1663
+ * badge that means "something needs you".
1664
+ ************************************************************/
1665
+ function badge_text(value)
1666
+ {
1667
+ if(value === null || value === undefined || value === false) {
1668
+ return "";
1669
+ }
1670
+ if(typeof value === "number") {
1671
+ if(!isFinite(value) || value <= 0) {
1672
+ return "";
1673
+ }
1674
+ return (value > 99) ? "99+" : String(Math.floor(value));
1675
+ }
1676
+ return String(value).trim();
1677
+ }
1678
+
1679
+ function badge_node(value)
1680
+ {
1681
+ let text = badge_text(value);
1682
+ let attrs = {class: "yui-toolbar-badge", role: "status"};
1683
+ if(!text) {
1684
+ attrs.hidden = "hidden";
1685
+ }
1686
+ return ["span", attrs, text];
1687
+ }
1688
+
1644
1689
  /************************************************************
1645
1690
  * Renderer for the default ("action") item kind.
1646
1691
  ************************************************************/
@@ -1648,8 +1693,15 @@ function build_toolbar_action_item(gobj, it)
1648
1693
  {
1649
1694
  let children = [];
1650
1695
  if(!empty_string(it.icon)) {
1651
- children.push(["span", {class: "icon"},
1652
- ["i", {class: it.icon, "aria-hidden": "true"}]]);
1696
+ /* The badge is anchored to the ICON, not to the button: with a
1697
+ * label on (desktop) the button is wide, and a badge pinned to
1698
+ * its corner would float far from the glyph it counts. */
1699
+ children.push(["span", {class: "icon yui-toolbar-icon"},
1700
+ [
1701
+ ["i", {class: it.icon, "aria-hidden": "true"}],
1702
+ badge_node(it.badge)
1703
+ ]
1704
+ ]);
1653
1705
  }
1654
1706
  if(!empty_string(it.name)) {
1655
1707
  children.push(["span", {class: "yui-toolbar-item-label", i18n: it.name},
@@ -3368,6 +3420,57 @@ function yui_shell_set_toolbar_item_icon(shell_gobj, item_id, icon_class)
3368
3420
  }
3369
3421
  }
3370
3422
 
3423
+ /************************************************************
3424
+ * Set (or clear) a toolbar item's badge — the count pinned to
3425
+ * its icon.
3426
+ *
3427
+ * yui_shell_set_toolbar_item_badge(shell, "alarms", 3);
3428
+ * yui_shell_set_toolbar_item_badge(shell, "alarms", 0); // hidden
3429
+ *
3430
+ * This is the API that matters: a count is a RUNTIME fact. The
3431
+ * `badge` field of a toolbar item only seeds the first paint, and
3432
+ * an app whose number never changes did not need a badge.
3433
+ *
3434
+ * 0 / "" / null / false all clear it, and a number over 99 renders
3435
+ * "99+" — see badge_node(). Accepts a string for the states that
3436
+ * are not counts ("!", "…").
3437
+ *
3438
+ * Silent no-op on an unknown item id: a toolbar that does not
3439
+ * declare the item (an app without alarms) must not be an error
3440
+ * at every tick of whatever feeds the number.
3441
+ ************************************************************/
3442
+ function yui_shell_set_toolbar_item_badge(shell_gobj, item_id, value)
3443
+ {
3444
+ if(!shell_gobj || !is_gobj(shell_gobj) || empty_string(item_id)) {
3445
+ return;
3446
+ }
3447
+ let $container = gobj_read_attr(shell_gobj, "$container");
3448
+ if(!$container) {
3449
+ return;
3450
+ }
3451
+ let $badge = $container.querySelector(
3452
+ `[data-toolbar-item-id="${item_id}"] .yui-toolbar-badge`
3453
+ );
3454
+ if(!$badge) {
3455
+ return;
3456
+ }
3457
+
3458
+ let text = badge_text(value);
3459
+ if(!text) {
3460
+ $badge.textContent = "";
3461
+ $badge.setAttribute("hidden", "hidden");
3462
+ return;
3463
+ }
3464
+ /* Do not touch the DOM when nothing changed: role="status" is a
3465
+ * live region, and rewriting the same number would have a screen
3466
+ * reader announce it again on every tick. */
3467
+ if($badge.textContent === text && !$badge.hasAttribute("hidden")) {
3468
+ return;
3469
+ }
3470
+ $badge.textContent = text;
3471
+ $badge.removeAttribute("hidden");
3472
+ }
3473
+
3371
3474
  /************************************************************
3372
3475
  * Programmatic close of any open toolbar dropdown. Useful for
3373
3476
  * external triggers (e.g. EV_LOGOUT firing from elsewhere) that
@@ -3414,6 +3517,7 @@ export {
3414
3517
  yui_shell_language_changed,
3415
3518
  yui_shell_set_connection_state,
3416
3519
  yui_shell_set_toolbar_item_icon,
3520
+ yui_shell_set_toolbar_item_badge,
3417
3521
  yui_shell_close_dropdown,
3418
3522
  yui_shell_set_submenu
3419
3523
  };