@yuneta/gobj-ui 7.26.29 → 7.26.31
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 +65 -22
- package/dist/gobj-ui.cjs.js +426 -113
- package/dist/gobj-ui.es.js +426 -114
- package/index.js +1 -0
- package/package.json +1 -1
- package/src/c_yui_form.js +12 -0
- 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/c_yui_window.js +32 -5
- package/src/nav_cards_helpers.js +21 -5
- package/src/nav_item_badge.wiring.test.js +564 -0
- package/src/window_layer.wiring.test.js +106 -0
- package/src/yui_badge.js +145 -0
- package/src/yui_badge.test.js +148 -0
- package/src/yui_tabulator_i18n.js +10 -2
- package/src/yui_tabulator_i18n.test.js +80 -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
|
+
});
|
|
@@ -134,8 +134,14 @@ function yui_tabulator_name_filters(table, t)
|
|
|
134
134
|
* already exists in two apps as the header filter's
|
|
135
135
|
* PLACEHOLDER ("filtrar columna...") and carries no
|
|
136
136
|
* interpolation, so reusing it named all five boxes the
|
|
137
|
-
* same -- which is where this started.
|
|
138
|
-
|
|
137
|
+
* same -- which is where this started.
|
|
138
|
+
*
|
|
139
|
+
* The `title` too: every control carries both, and a filter
|
|
140
|
+
* box shows only its placeholder, which goes as soon as
|
|
141
|
+
* something is typed in it. */
|
|
142
|
+
let name = t("filter by column", {column: title});
|
|
143
|
+
$input.setAttribute("aria-label", name);
|
|
144
|
+
$input.setAttribute("title", name);
|
|
139
145
|
}
|
|
140
146
|
} catch(e) {
|
|
141
147
|
/* a table between renders has no columns to name */
|
|
@@ -181,6 +187,8 @@ function yui_tabulator_name_row_selects(table, t)
|
|
|
181
187
|
for(let $box of boxes) {
|
|
182
188
|
$box.setAttribute("aria-label", t("select row"));
|
|
183
189
|
$box.setAttribute("data-i18n-aria-label", "select row");
|
|
190
|
+
$box.setAttribute("title", t("select row"));
|
|
191
|
+
$box.setAttribute("data-i18n-title", "select row");
|
|
184
192
|
}
|
|
185
193
|
} catch(e) {
|
|
186
194
|
/* a table between renders has no boxes to name */
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/***********************************************************************
|
|
2
|
+
* yui_tabulator_i18n.test.js
|
|
3
|
+
*
|
|
4
|
+
* The names given AFTER the render to what Tabulator draws for
|
|
5
|
+
* itself: the header filters and the row-selection boxes carry
|
|
6
|
+
* an aria-label AND a title, like every control.
|
|
7
|
+
*
|
|
8
|
+
* Copyright (c) 2026, ArtGins.
|
|
9
|
+
* All Rights Reserved.
|
|
10
|
+
***********************************************************************/
|
|
11
|
+
import {describe, it, expect} from "vitest";
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
yui_tabulator_name_filters,
|
|
15
|
+
yui_tabulator_name_row_selects,
|
|
16
|
+
} from "./yui_tabulator_i18n.js";
|
|
17
|
+
|
|
18
|
+
/* An element double: attributes, and the one child a selector finds. */
|
|
19
|
+
function element(child)
|
|
20
|
+
{
|
|
21
|
+
const attrs = {};
|
|
22
|
+
return {
|
|
23
|
+
attrs,
|
|
24
|
+
setAttribute: (k, v) => {
|
|
25
|
+
attrs[k] = String(v);
|
|
26
|
+
},
|
|
27
|
+
getAttribute: (k) => (k in attrs) ? attrs[k] : null,
|
|
28
|
+
querySelector: () => child || null,
|
|
29
|
+
querySelectorAll: () => (child ? [child] : []),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/* A column double with its header filter input. */
|
|
34
|
+
function column(title, $input)
|
|
35
|
+
{
|
|
36
|
+
const $el = element($input);
|
|
37
|
+
return {
|
|
38
|
+
getElement: () => $el,
|
|
39
|
+
getDefinition: () => ({title: title}),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const t = (key, opts) => (key === "filter by column")
|
|
44
|
+
? `Filtrar por ${opts.column}`
|
|
45
|
+
: (key === "select row" ? "Seleccionar fila" : key);
|
|
46
|
+
|
|
47
|
+
describe("yui_tabulator_name_filters", () => {
|
|
48
|
+
it("names each header filter with an aria-label and a title", () => {
|
|
49
|
+
const $a = element();
|
|
50
|
+
const $b = element();
|
|
51
|
+
const table = {getColumns: () => [column("Id", $a), column("Name", $b)]};
|
|
52
|
+
|
|
53
|
+
yui_tabulator_name_filters(table, t);
|
|
54
|
+
|
|
55
|
+
expect($a.attrs["aria-label"]).toBe("Filtrar por Id");
|
|
56
|
+
expect($a.attrs["title"]).toBe("Filtrar por Id");
|
|
57
|
+
expect($b.attrs["aria-label"]).toBe("Filtrar por Name");
|
|
58
|
+
expect($b.attrs["title"]).toBe("Filtrar por Name");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("leaves a column without a filter alone", () => {
|
|
62
|
+
const table = {getColumns: () => [column("Id", null)]};
|
|
63
|
+
expect(() => yui_tabulator_name_filters(table, t)).not.toThrow();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("yui_tabulator_name_row_selects", () => {
|
|
68
|
+
it("renames Tabulator's English box with an aria-label and a title, and their keys", () => {
|
|
69
|
+
const $box = element();
|
|
70
|
+
$box.setAttribute("aria-label", "Select Row");
|
|
71
|
+
const table = {element: element($box)};
|
|
72
|
+
|
|
73
|
+
yui_tabulator_name_row_selects(table, t);
|
|
74
|
+
|
|
75
|
+
expect($box.attrs["aria-label"]).toBe("Seleccionar fila");
|
|
76
|
+
expect($box.attrs["data-i18n-aria-label"]).toBe("select row");
|
|
77
|
+
expect($box.attrs["title"]).toBe("Seleccionar fila");
|
|
78
|
+
expect($box.attrs["data-i18n-title"]).toBe("select row");
|
|
79
|
+
});
|
|
80
|
+
});
|