@yuneta/gobj-ui 7.26.29 → 7.26.30
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 +56 -22
- package/dist/gobj-ui.cjs.js +384 -109
- package/dist/gobj-ui.es.js +384 -110
- package/index.js +1 -0
- package/package.json +1 -1
- package/src/c_yui_nav.js +152 -19
- package/src/c_yui_shell.css +84 -18
- package/src/c_yui_shell.js +145 -65
- package/src/nav_cards_helpers.js +21 -5
- package/src/nav_item_badge.wiring.test.js +564 -0
- package/src/yui_badge.js +145 -0
- package/src/yui_badge.test.js +148 -0
package/src/yui_badge.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/***********************************************************************
|
|
2
|
+
* yui_badge.js
|
|
3
|
+
*
|
|
4
|
+
* The BADGE: the little count a control carries -- unread
|
|
5
|
+
* notifications, active alarms. The toolbar items of C_YUI_SHELL
|
|
6
|
+
* and the menu items of C_YUI_NAV build and paint theirs here, so
|
|
7
|
+
* the two follow the same rules and cannot drift apart:
|
|
8
|
+
*
|
|
9
|
+
* EMPTY IS 0, "" AND null ALIKE (false and undefined too): a badge
|
|
10
|
+
* reading "0" is worse than no badge -- it draws the eye to say
|
|
11
|
+
* nothing. A count is only worth a pixel when there IS something.
|
|
12
|
+
*
|
|
13
|
+
* CAPPED AT 99+: a badge sits in a row of fixed width (the app bar,
|
|
14
|
+
* a bottom icon bar), and a four digit pill pushes the items next
|
|
15
|
+
* to it off a phone screen. A string passes through, for the
|
|
16
|
+
* states that are not counts ("!", "…").
|
|
17
|
+
*
|
|
18
|
+
* ALWAYS RENDERED, hidden when empty, so the runtime setters have a
|
|
19
|
+
* node to write into and never rebuild the control that holds it.
|
|
20
|
+
*
|
|
21
|
+
* role="status" and NOT aria-hidden, deliberately. The control
|
|
22
|
+
* carries an explicit aria-label, and an explicit label REPLACES
|
|
23
|
+
* the element's content for a screen reader -- so a badge inside
|
|
24
|
+
* it would be silent. As its own live region it is both read and
|
|
25
|
+
* announced when the count changes, which is the whole point of a
|
|
26
|
+
* badge that means "something needs you". For the same reason a
|
|
27
|
+
* write that changes nothing touches no DOM: rewriting the same
|
|
28
|
+
* number would have it announced again on every tick.
|
|
29
|
+
*
|
|
30
|
+
* A TONE, optional: one of BADGE_TONES, drawn as Bulma's
|
|
31
|
+
* `is-<tone>` on the badge. It says HOW BAD the count is -- the
|
|
32
|
+
* alarms of an app coloured by the worst state among them. No
|
|
33
|
+
* tone is the default look (the danger colour: a badge exists to
|
|
34
|
+
* pull the eye). An unknown tone is the caller's error: it is
|
|
35
|
+
* logged, and the default look is drawn instead.
|
|
36
|
+
*
|
|
37
|
+
* The text is a Text node, never a string: a value that came from
|
|
38
|
+
* outside must not reach createElement2() as markup.
|
|
39
|
+
*
|
|
40
|
+
* Copyright (c) 2026, ArtGins.
|
|
41
|
+
* All Rights Reserved.
|
|
42
|
+
***********************************************************************/
|
|
43
|
+
import {log_error} from "@yuneta/gobj-js";
|
|
44
|
+
|
|
45
|
+
import {text_node} from "./yui_text.js";
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
/***************************************************************
|
|
49
|
+
* Constants
|
|
50
|
+
***************************************************************/
|
|
51
|
+
export const BADGE_TONES = Object.freeze([
|
|
52
|
+
"danger", "warning", "info", "success", "primary", "link"
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
/************************************************************
|
|
57
|
+
* The text a badge shows for `value`, "" when it shows none.
|
|
58
|
+
************************************************************/
|
|
59
|
+
export function badge_text(value)
|
|
60
|
+
{
|
|
61
|
+
if(value === null || value === undefined || value === false) {
|
|
62
|
+
return "";
|
|
63
|
+
}
|
|
64
|
+
if(typeof value === "number") {
|
|
65
|
+
if(!isFinite(value) || value <= 0) {
|
|
66
|
+
return "";
|
|
67
|
+
}
|
|
68
|
+
return (value > 99) ? "99+" : String(Math.floor(value));
|
|
69
|
+
}
|
|
70
|
+
return String(value).trim();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/************************************************************
|
|
74
|
+
* The tone to draw: `tone` when it is one of BADGE_TONES, ""
|
|
75
|
+
* (the default look) when it is empty. Anything else is logged,
|
|
76
|
+
* naming `who`, and answers "".
|
|
77
|
+
************************************************************/
|
|
78
|
+
export function badge_tone(tone, who)
|
|
79
|
+
{
|
|
80
|
+
if(tone === undefined || tone === null || tone === "") {
|
|
81
|
+
return "";
|
|
82
|
+
}
|
|
83
|
+
if(BADGE_TONES.indexOf(tone) < 0) {
|
|
84
|
+
log_error(
|
|
85
|
+
`${who}: unknown badge tone '${tone}', drawn with the default look ` +
|
|
86
|
+
`(the tones: ${BADGE_TONES.join(", ")})`
|
|
87
|
+
);
|
|
88
|
+
return "";
|
|
89
|
+
}
|
|
90
|
+
return tone;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/************************************************************
|
|
94
|
+
* The createElement2() description of a badge. `classes` are
|
|
95
|
+
* the host's (its logical name, `yui-badge` for the look, and
|
|
96
|
+
* where it sits); `who` names the item in the log of an unknown
|
|
97
|
+
* tone.
|
|
98
|
+
************************************************************/
|
|
99
|
+
export function badge_node(value, tone, classes, who)
|
|
100
|
+
{
|
|
101
|
+
let text = badge_text(value);
|
|
102
|
+
let known = badge_tone(tone, who);
|
|
103
|
+
let attrs = {
|
|
104
|
+
class: known ? `${classes} is-${known}` : classes,
|
|
105
|
+
role: "status"
|
|
106
|
+
};
|
|
107
|
+
if(!text) {
|
|
108
|
+
attrs.hidden = "hidden";
|
|
109
|
+
}
|
|
110
|
+
return ["span", attrs, text_node(text)];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/************************************************************
|
|
114
|
+
* Write a badge that is already in the DOM, as badge_node()
|
|
115
|
+
* draws it. `tone` is one badge_tone() answered: the setter
|
|
116
|
+
* checks it once for all the badges it paints.
|
|
117
|
+
************************************************************/
|
|
118
|
+
export function paint_badge($badge, value, tone)
|
|
119
|
+
{
|
|
120
|
+
for(let known of BADGE_TONES) {
|
|
121
|
+
let cls = `is-${known}`;
|
|
122
|
+
if(known === tone) {
|
|
123
|
+
if(!$badge.classList.contains(cls)) {
|
|
124
|
+
$badge.classList.add(cls);
|
|
125
|
+
}
|
|
126
|
+
} else if($badge.classList.contains(cls)) {
|
|
127
|
+
$badge.classList.remove(cls);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let text = badge_text(value);
|
|
132
|
+
if(!text) {
|
|
133
|
+
$badge.textContent = "";
|
|
134
|
+
$badge.setAttribute("hidden", "hidden");
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
/* Do not touch the DOM when nothing changed: role="status" is a
|
|
138
|
+
* live region, and rewriting the same number would have a screen
|
|
139
|
+
* reader announce it again on every tick. */
|
|
140
|
+
if($badge.textContent === text && !$badge.hasAttribute("hidden")) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
$badge.textContent = text;
|
|
144
|
+
$badge.removeAttribute("hidden");
|
|
145
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/***********************************************************************
|
|
2
|
+
* yui_badge.test.js
|
|
3
|
+
*
|
|
4
|
+
* The badge shared by the toolbar items of C_YUI_SHELL and the
|
|
5
|
+
* menu items of C_YUI_NAV: what it shows for a value, which tones
|
|
6
|
+
* it takes, how it is drawn and how it is repainted. The text is a
|
|
7
|
+
* Text node, so a document double is installed.
|
|
8
|
+
*
|
|
9
|
+
* Copyright (c) 2026, ArtGins.
|
|
10
|
+
* All Rights Reserved.
|
|
11
|
+
***********************************************************************/
|
|
12
|
+
import {describe, test, expect, beforeAll, beforeEach} from "vitest";
|
|
13
|
+
import {install_dom_double} from "../test/dom_double.js";
|
|
14
|
+
|
|
15
|
+
install_dom_double();
|
|
16
|
+
|
|
17
|
+
const {createElement2, set_log_callback} = await import("@yuneta/gobj-js");
|
|
18
|
+
const {
|
|
19
|
+
BADGE_TONES, badge_text, badge_tone, badge_node, paint_badge,
|
|
20
|
+
} = await import("./yui_badge.js");
|
|
21
|
+
|
|
22
|
+
const logged = [];
|
|
23
|
+
|
|
24
|
+
beforeAll(() => {
|
|
25
|
+
set_log_callback((level, msg) => {
|
|
26
|
+
logged.push({level: String(level), msg: String(msg)});
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
logged.length = 0;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
function errors()
|
|
35
|
+
{
|
|
36
|
+
return logged.filter((l) => l.level === "error").map((l) => l.msg);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function tones_of($badge)
|
|
40
|
+
{
|
|
41
|
+
return BADGE_TONES.filter((tone) => $badge.classList.contains(`is-${tone}`));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
describe("badge_text", () => {
|
|
46
|
+
test("empty is 0, '', null, false and undefined alike", () => {
|
|
47
|
+
for(const v of [0, "", null, false, undefined, -3, NaN, Infinity, " "]) {
|
|
48
|
+
expect(badge_text(v), String(v)).toBe("");
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("a count, capped at 99+, and a text passes through", () => {
|
|
53
|
+
expect(badge_text(1)).toBe("1");
|
|
54
|
+
expect(badge_text(99)).toBe("99");
|
|
55
|
+
expect(badge_text(100)).toBe("99+");
|
|
56
|
+
expect(badge_text(2.7)).toBe("2");
|
|
57
|
+
expect(badge_text(" ! ")).toBe("!");
|
|
58
|
+
expect(badge_text("3")).toBe("3");
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("badge_tone", () => {
|
|
63
|
+
test("the six tones, and no tone, pass without a word", () => {
|
|
64
|
+
expect(BADGE_TONES).toEqual(["danger", "warning", "info", "success", "primary", "link"]);
|
|
65
|
+
for(const tone of BADGE_TONES) {
|
|
66
|
+
expect(badge_tone(tone, "who")).toBe(tone);
|
|
67
|
+
}
|
|
68
|
+
expect(badge_tone(undefined, "who")).toBe("");
|
|
69
|
+
expect(badge_tone(null, "who")).toBe("");
|
|
70
|
+
expect(badge_tone("", "who")).toBe("");
|
|
71
|
+
expect(errors()).toEqual([]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("an unknown one is logged, naming who asked, and answers the default", () => {
|
|
75
|
+
expect(badge_tone("red", "C_TEST: item 'x'")).toBe("");
|
|
76
|
+
expect(badge_tone("Danger", "C_TEST: item 'x'")).toBe("");
|
|
77
|
+
expect(badge_tone(3, "C_TEST: item 'x'")).toBe("");
|
|
78
|
+
let e = errors();
|
|
79
|
+
expect(e.length).toBe(3);
|
|
80
|
+
expect(e[0]).toContain("C_TEST: item 'x'");
|
|
81
|
+
expect(e[0]).toContain("'red'");
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("badge_node", () => {
|
|
86
|
+
test("a live region with the host's classes, its tone and its text as text", () => {
|
|
87
|
+
let $b = createElement2(badge_node(3, "warning", "X_BADGE yui-badge", "who"));
|
|
88
|
+
expect($b.tagName).toBe("SPAN");
|
|
89
|
+
expect($b.getAttribute("role")).toBe("status");
|
|
90
|
+
expect($b.hasAttribute("hidden")).toBe(false);
|
|
91
|
+
expect([...$b.classList]).toEqual(["X_BADGE", "yui-badge", "is-warning"]);
|
|
92
|
+
expect($b.textContent).toBe("3");
|
|
93
|
+
expect($b.firstChild.nodeType).toBe(3);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("empty: drawn all the same, hidden, so a setter can write into it", () => {
|
|
97
|
+
let $b = createElement2(badge_node(0, "", "X_BADGE yui-badge", "who"));
|
|
98
|
+
expect($b.hasAttribute("hidden")).toBe(true);
|
|
99
|
+
expect($b.textContent).toBe("");
|
|
100
|
+
expect(tones_of($b)).toEqual([]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("markup in a value is text", () => {
|
|
104
|
+
let $b = createElement2(badge_node("<img src=x>", "", "X_BADGE", "who"));
|
|
105
|
+
expect($b.children.length).toBe(0);
|
|
106
|
+
expect($b.textContent).toBe("<img src=x>");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("an unknown tone is logged and drawn with the default look", () => {
|
|
110
|
+
let $b = createElement2(badge_node(3, "red", "X_BADGE", "C_TEST: item 'x'"));
|
|
111
|
+
expect(tones_of($b)).toEqual([]);
|
|
112
|
+
expect(errors().length).toBe(1);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("paint_badge", () => {
|
|
117
|
+
test("sets, changes and clears the tone, one at a time", () => {
|
|
118
|
+
let $b = createElement2(badge_node(3, "danger", "X_BADGE", "who"));
|
|
119
|
+
paint_badge($b, 3, "info");
|
|
120
|
+
expect(tones_of($b)).toEqual(["info"]);
|
|
121
|
+
paint_badge($b, 3, "");
|
|
122
|
+
expect(tones_of($b)).toEqual([]);
|
|
123
|
+
paint_badge($b, 3, "success");
|
|
124
|
+
expect(tones_of($b)).toEqual(["success"]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("hides on empty, shows again on a count", () => {
|
|
128
|
+
let $b = createElement2(badge_node(3, "", "X_BADGE", "who"));
|
|
129
|
+
paint_badge($b, 0, "");
|
|
130
|
+
expect($b.hasAttribute("hidden")).toBe(true);
|
|
131
|
+
expect($b.textContent).toBe("");
|
|
132
|
+
paint_badge($b, 150, "");
|
|
133
|
+
expect($b.hasAttribute("hidden")).toBe(false);
|
|
134
|
+
expect($b.textContent).toBe("99+");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("the same text again, or only a new tone, does not rewrite the text", () => {
|
|
138
|
+
let $b = createElement2(badge_node(5, "", "X_BADGE", "who"));
|
|
139
|
+
let text = $b.firstChild;
|
|
140
|
+
paint_badge($b, 5, "");
|
|
141
|
+
paint_badge($b, 5, "warning");
|
|
142
|
+
expect($b.firstChild).toBe(text);
|
|
143
|
+
expect(tones_of($b)).toEqual(["warning"]);
|
|
144
|
+
paint_badge($b, 6, "warning");
|
|
145
|
+
expect($b.firstChild).not.toBe(text);
|
|
146
|
+
expect($b.textContent).toBe("6");
|
|
147
|
+
});
|
|
148
|
+
});
|