@yuneta/gobj-ui 7.26.33 → 7.26.35
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 +223 -53
- package/dist/gobj-ui.cjs.js +1053 -144
- package/dist/gobj-ui.es.js +1053 -144
- package/package.json +2 -2
- package/scripts/validate-locales.mjs +672 -96
- package/src/c_yui_form.js +229 -11
- package/src/c_yui_gclass.js +2 -0
- package/src/c_yui_gobj_tree_js.js +2 -0
- package/src/c_yui_json.js +2 -0
- package/src/c_yui_json_graph.js +2 -0
- package/src/c_yui_nav.js +23 -5
- package/src/c_yui_shell.css +16 -0
- package/src/c_yui_shell.js +282 -26
- package/src/c_yui_treedb_graph.js +26 -8
- package/src/c_yui_treedb_topic_with_form.js +40 -18
- package/src/c_yui_treedb_topics.js +34 -11
- package/src/c_yui_window.js +132 -32
- package/src/c_yui_window_manager.js +156 -44
- package/src/form_json_editor_names.js +448 -0
- package/src/form_json_editor_names.wiring.test.js +595 -0
- package/src/form_label_i18n.wiring.test.js +169 -0
- package/src/form_table_validator.wiring.test.js +192 -0
- package/src/form_template_field.wiring.test.js +21 -0
- package/src/i18n_key_functions.js +14 -6
- package/src/i18n_key_functions.test.js +124 -2
- package/src/lib_maplibre.js +26 -2
- package/src/lib_maplibre_relocalize.test.js +78 -0
- package/src/named_controls.wiring.test.js +468 -0
- package/src/nav_cards_helpers.js +9 -1
- package/src/nav_item_badge.wiring.test.js +182 -1
- package/src/shell_route_map.js +25 -8
- package/src/table_editor_name.wiring.test.js +201 -0
- package/src/treedb_column_title.wiring.test.js +151 -0
- package/src/treedb_json_drill.wiring.test.js +41 -1
- package/src/window_layer.wiring.test.js +308 -43
- package/src/window_show.wiring.test.js +6 -2
- package/src/window_stacking.wiring.test.js +334 -0
- package/src/yui_badge.js +58 -13
- package/src/yui_badge.test.js +40 -3
- package/src/yui_dev.js +2 -1
- package/src/yui_file_field.js +2 -0
- package/src/yui_frontend_view.js +2 -1
- package/src/yui_gclass_view.js +9 -6
- package/src/yui_tabulator_i18n.js +42 -0
- package/src/yui_tabulator_i18n.test.js +48 -1
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/***********************************************************************
|
|
2
|
+
* window_stacking.wiring.test.js
|
|
3
|
+
*
|
|
4
|
+
* Every window the library opens lives in the shell's WINDOW layer,
|
|
5
|
+
* and every window -- docked or not -- is ordered by ONE number
|
|
6
|
+
* (yui_shell_raise_window).
|
|
7
|
+
*
|
|
8
|
+
* Up to 7.26.34 four places of the library put their windows in
|
|
9
|
+
* the shell's POPUP layer -- the site map, the raw json of the
|
|
10
|
+
* treedb graph and of the treedb topics, the gclass viewer opened
|
|
11
|
+
* from no window, the json of a treedb topic form: above the
|
|
12
|
+
* drawer, in the way of the toolbar's dropdowns (which opened
|
|
13
|
+
* behind them where they met). And there were two orders: the
|
|
14
|
+
* dock raised its windows to a counter of its own, a window with
|
|
15
|
+
* no dock sat at 3 and was moved to the end of its parent to rise,
|
|
16
|
+
* so a window opened later without a dock opened UNDER any docked
|
|
17
|
+
* one, and no click brought it up.
|
|
18
|
+
*
|
|
19
|
+
* The real gclasses on a document double, with doubles of what
|
|
20
|
+
* draws (Tabulator, the treedb graph's engine) and of a backend.
|
|
21
|
+
*
|
|
22
|
+
* Copyright (c) 2026, ArtGins.
|
|
23
|
+
* All Rights Reserved.
|
|
24
|
+
***********************************************************************/
|
|
25
|
+
import {describe, test, expect, beforeAll, beforeEach, vi} from "vitest";
|
|
26
|
+
import {readFileSync} from "node:fs";
|
|
27
|
+
import {dirname, join} from "node:path";
|
|
28
|
+
import {fileURLToPath} from "node:url";
|
|
29
|
+
import {install_dom_double} from "../test/dom_double.js";
|
|
30
|
+
|
|
31
|
+
install_dom_double();
|
|
32
|
+
|
|
33
|
+
/* A laptop: a json opens in a window, not in a phone's sheet. */
|
|
34
|
+
globalThis.innerWidth = 1280;
|
|
35
|
+
if(globalThis.window) {
|
|
36
|
+
globalThis.window.innerWidth = 1280;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if(typeof globalThis.ResizeObserver === "undefined") {
|
|
40
|
+
globalThis.ResizeObserver = class {
|
|
41
|
+
observe() {}
|
|
42
|
+
unobserve() {}
|
|
43
|
+
disconnect() {}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
if(typeof globalThis.matchMedia === "undefined") {
|
|
47
|
+
globalThis.matchMedia = () => ({matches: false, addEventListener() {}, removeEventListener() {}});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
vi.mock("tabulator-tables", () => {
|
|
51
|
+
class FakeTabulator {
|
|
52
|
+
constructor($el, settings) {
|
|
53
|
+
this.element = $el;
|
|
54
|
+
this.options = settings;
|
|
55
|
+
this.externalEvents = {events: {}};
|
|
56
|
+
return new Proxy(this, {
|
|
57
|
+
get(target, prop) {
|
|
58
|
+
if(prop in target) {
|
|
59
|
+
return target[prop];
|
|
60
|
+
}
|
|
61
|
+
return () => [];
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
on(ev, fn) {
|
|
66
|
+
(this.externalEvents.events[ev] = this.externalEvents.events[ev] || []).push(fn);
|
|
67
|
+
}
|
|
68
|
+
off(ev) {
|
|
69
|
+
delete this.externalEvents.events[ev];
|
|
70
|
+
}
|
|
71
|
+
destroy() {}
|
|
72
|
+
}
|
|
73
|
+
return {TabulatorFull: FakeTabulator, Tabulator: FakeTabulator};
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const {
|
|
77
|
+
SDATA, SDATA_END, data_type_t, gclass_create, gobj_write_attr,
|
|
78
|
+
gobj_start_up, gobj_create_yuno, gobj_create, gobj_create_service, gobj_create_pure_child,
|
|
79
|
+
gobj_start, gobj_read_attr, gobj_read_pointer_attr, gobj_subscribe_event, gobj_send_event,
|
|
80
|
+
gobj_change_state, gobj_full_name, set_log_callback,
|
|
81
|
+
} = await import("@yuneta/gobj-js");
|
|
82
|
+
const {register_c_yui_shell, yui_shell_window_layer} = await import("./c_yui_shell.js");
|
|
83
|
+
const {register_c_yui_nav} = await import("./c_yui_nav.js");
|
|
84
|
+
const {register_c_yui_window} = await import("./c_yui_window.js");
|
|
85
|
+
const {register_c_yui_window_manager} = await import("./c_yui_window_manager.js");
|
|
86
|
+
const {register_c_yui_json} = await import("./c_yui_json.js");
|
|
87
|
+
const {register_c_yui_gclass} = await import("./c_yui_gclass.js");
|
|
88
|
+
const {register_c_yui_treedb_topics} = await import("./c_yui_treedb_topics.js");
|
|
89
|
+
const {register_c_yui_treedb_graph} = await import("./c_yui_treedb_graph.js");
|
|
90
|
+
const {register_c_yui_treedb_topic_with_form} = await import("./c_yui_treedb_topic_with_form.js");
|
|
91
|
+
const {yui_shell_show_route_map} = await import("./shell_route_map.js");
|
|
92
|
+
const {open_gclass_view} = await import("./yui_gclass_view.js");
|
|
93
|
+
|
|
94
|
+
const SRC = dirname(fileURLToPath(import.meta.url));
|
|
95
|
+
const logged = [];
|
|
96
|
+
let yuno = null;
|
|
97
|
+
let shell = null;
|
|
98
|
+
let seq = 0;
|
|
99
|
+
|
|
100
|
+
beforeAll(async () => {
|
|
101
|
+
const i18next = (await import("i18next")).default;
|
|
102
|
+
await i18next.init({lng: "en", resources: {}});
|
|
103
|
+
gobj_start_up(null, null, null, null, null, null, null);
|
|
104
|
+
set_log_callback((level, msg) => {
|
|
105
|
+
logged.push({level: String(level), msg: String(msg)});
|
|
106
|
+
});
|
|
107
|
+
gclass_create("C_TEST_STACK_HOST", [], [["ST_IDLE", []]], {}, 0, [SDATA_END()], {}, 0, 0, 0, 0);
|
|
108
|
+
gclass_create(
|
|
109
|
+
"C_TEST_STACK_VIEW", [], [["ST_IDLE", []]], {
|
|
110
|
+
mt_create: (gobj) => {
|
|
111
|
+
gobj_write_attr(gobj, "$container", document.createElement("div"));
|
|
112
|
+
}
|
|
113
|
+
}, 0, [
|
|
114
|
+
SDATA(data_type_t.DTP_POINTER, "$container", 0, null, "view root"),
|
|
115
|
+
SDATA_END()
|
|
116
|
+
], {}, 0, 0, 0, 0
|
|
117
|
+
);
|
|
118
|
+
gclass_create(
|
|
119
|
+
"C_TEST_STACK_APP",
|
|
120
|
+
[["EV_RECORD_WRITTEN", 0], ["EV_TOPIC_SELECTED", 0], ["EV_OPERATION_MODE_CHANGED", 0]],
|
|
121
|
+
[["ST_IDLE", [
|
|
122
|
+
["EV_RECORD_WRITTEN", () => 0, null],
|
|
123
|
+
["EV_TOPIC_SELECTED", () => 0, null],
|
|
124
|
+
["EV_OPERATION_MODE_CHANGED", () => 0, null],
|
|
125
|
+
]]],
|
|
126
|
+
{}, 0, [SDATA_END()], {}, 0, 0, 0, 0
|
|
127
|
+
);
|
|
128
|
+
/* A backend in session that answers nothing. */
|
|
129
|
+
gclass_create("C_TEST_STACK_REMOTE", [], [["ST_SESSION", []]],
|
|
130
|
+
{mt_command_parser: () => null}, 0, [SDATA_END()], {}, 0, 0, 0, 0);
|
|
131
|
+
/* The treedb graph's engine, silent: this is about its window. */
|
|
132
|
+
const ENGINE_EVENTS = [
|
|
133
|
+
"EV_DESCS", "EV_CLEAR_DATA", "EV_LOAD_DATA", "EV_NODE_CREATED",
|
|
134
|
+
"EV_NODE_UPDATED", "EV_NODE_DELETED", "EV_GRAPHS_WRITE_REFUSED",
|
|
135
|
+
"EV_SHOW", "EV_HIDE", "EV_RESIZE", "EV_THEME", "EV_LANGUAGE_CHANGED",
|
|
136
|
+
"EV_SET_OPERATION_MODE", "EV_SET_LAYOUT"
|
|
137
|
+
];
|
|
138
|
+
gclass_create(
|
|
139
|
+
"C_G6_NODES_TREE",
|
|
140
|
+
ENGINE_EVENTS.map((e) => [e, 0]).concat([["EV_UPDATE_NODE", 2]]),
|
|
141
|
+
[["ST_IDLE", ENGINE_EVENTS.map((e) => [e, () => 0, null])]],
|
|
142
|
+
{
|
|
143
|
+
mt_create: (gobj) => {
|
|
144
|
+
const subscriber = gobj_read_pointer_attr(gobj, "subscriber");
|
|
145
|
+
if(subscriber) {
|
|
146
|
+
gobj_subscribe_event(gobj, null, {}, subscriber);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
0,
|
|
151
|
+
[
|
|
152
|
+
SDATA(data_type_t.DTP_POINTER, "subscriber", 0, null, ""),
|
|
153
|
+
SDATA(data_type_t.DTP_POINTER, "$container", 0, null, ""),
|
|
154
|
+
SDATA(data_type_t.DTP_POINTER, "gobj_remote_yuno", 0, null, ""),
|
|
155
|
+
SDATA(data_type_t.DTP_STRING, "treedb_name", 0, "", ""),
|
|
156
|
+
SDATA(data_type_t.DTP_JSON, "layout_names", 0, "[]", ""),
|
|
157
|
+
SDATA(data_type_t.DTP_STRING, "layout", 0, "", ""),
|
|
158
|
+
SDATA_END()
|
|
159
|
+
],
|
|
160
|
+
{}, 0, 0, 0, 0
|
|
161
|
+
);
|
|
162
|
+
register_c_yui_shell();
|
|
163
|
+
register_c_yui_nav();
|
|
164
|
+
register_c_yui_window();
|
|
165
|
+
register_c_yui_window_manager();
|
|
166
|
+
register_c_yui_json();
|
|
167
|
+
register_c_yui_gclass();
|
|
168
|
+
register_c_yui_treedb_topics();
|
|
169
|
+
register_c_yui_treedb_graph();
|
|
170
|
+
register_c_yui_treedb_topic_with_form();
|
|
171
|
+
yuno = gobj_create_yuno("stacking_yuno", "C_TEST_STACK_HOST", {});
|
|
172
|
+
gobj_start(yuno);
|
|
173
|
+
|
|
174
|
+
shell = gobj_create("stack_shell", "C_YUI_SHELL", {
|
|
175
|
+
config: {
|
|
176
|
+
shell: {
|
|
177
|
+
zones: {left: {host: "menu.primary"}, center: {host: "stage.main"}},
|
|
178
|
+
stages: {main: {zone: "center", default_route: "/home"}}
|
|
179
|
+
},
|
|
180
|
+
menu: {primary: {items: [
|
|
181
|
+
{id: "home", name: "home", route: "/home",
|
|
182
|
+
target: {stage: "main", gclass: "C_TEST_STACK_VIEW", kw: {}}}
|
|
183
|
+
]}}
|
|
184
|
+
},
|
|
185
|
+
use_hash: false
|
|
186
|
+
}, yuno);
|
|
187
|
+
gobj_start(shell);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
beforeEach(() => {
|
|
191
|
+
logged.length = 0;
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
function errors()
|
|
195
|
+
{
|
|
196
|
+
return logged.filter((l) => l.level === "error").map((l) => l.msg);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/* Identity, asked as a boolean (a failing toBe() over a DOM double
|
|
200
|
+
* makes vitest pretty-print it, and the double cannot be printed). */
|
|
201
|
+
function in_window_layer(win)
|
|
202
|
+
{
|
|
203
|
+
return gobj_read_attr(win, "$container").parentNode === yui_shell_window_layer(shell);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function z_of(win)
|
|
207
|
+
{
|
|
208
|
+
return Number(gobj_read_attr(win, "$container").style.zIndex);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function press(win)
|
|
212
|
+
{
|
|
213
|
+
gobj_read_attr(win, "$container").dispatchEvent({type: "pointerdown"});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function remote()
|
|
217
|
+
{
|
|
218
|
+
const r = gobj_create_service(`stack_remote${++seq}`, "C_TEST_STACK_REMOTE", {}, yuno);
|
|
219
|
+
gobj_change_state(r, "ST_SESSION");
|
|
220
|
+
return r;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
describe("every window the library opens lives in the window layer", () => {
|
|
224
|
+
test("the site map", () => {
|
|
225
|
+
const win = yui_shell_show_route_map(shell);
|
|
226
|
+
expect(!!win).toBe(true);
|
|
227
|
+
expect(in_window_layer(win)).toBe(true);
|
|
228
|
+
yui_shell_show_route_map(shell); // the second call closes it
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("the raw json of the treedb topics", () => {
|
|
232
|
+
const app = gobj_create(`stack_app${++seq}`, "C_TEST_STACK_APP", {}, shell);
|
|
233
|
+
const topics = gobj_create_service(`stack_topics${seq}`, "C_YUI_TREEDB_TOPICS", {
|
|
234
|
+
treedb_name: "treedb_test",
|
|
235
|
+
gobj_remote_yuno: remote(),
|
|
236
|
+
subscriber: app
|
|
237
|
+
}, app);
|
|
238
|
+
gobj_start(topics);
|
|
239
|
+
gobj_send_event(topics, "EV_OPEN_JSON", {}, topics);
|
|
240
|
+
const win = topics.priv.json_win;
|
|
241
|
+
expect(!!win).toBe(true);
|
|
242
|
+
expect(in_window_layer(win)).toBe(true);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("the raw json of the treedb graph", () => {
|
|
246
|
+
const app = gobj_create(`stack_app${++seq}`, "C_TEST_STACK_APP", {}, shell);
|
|
247
|
+
const graph = gobj_create_service(`stack_graph${seq}`, "C_YUI_TREEDB_GRAPH", {
|
|
248
|
+
treedb_name: "treedb_test",
|
|
249
|
+
gobj_remote_yuno: remote(),
|
|
250
|
+
subscriber: app
|
|
251
|
+
}, app);
|
|
252
|
+
gobj_send_event(graph, "EV_OPEN_JSON", {}, graph);
|
|
253
|
+
const win = graph.priv.json_win;
|
|
254
|
+
expect(!!win).toBe(true);
|
|
255
|
+
expect(in_window_layer(win)).toBe(true);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("the gclass viewer opened from no window", () => {
|
|
259
|
+
const host = gobj_create(`stack_host${++seq}`, "C_TEST_STACK_VIEW", {}, shell);
|
|
260
|
+
const handle = open_gclass_view(host, "C_YUI_WINDOW", {});
|
|
261
|
+
expect(!!(handle && handle.win)).toBe(true);
|
|
262
|
+
expect(in_window_layer(handle.win)).toBe(true);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("the json of a treedb topic form (its schema)", () => {
|
|
266
|
+
const app = gobj_create(`stack_app${++seq}`, "C_TEST_STACK_APP", {}, shell);
|
|
267
|
+
const form = gobj_create_service(`stack_form${seq}`, "C_YUI_TREEDB_TOPIC_WITH_FORM", {
|
|
268
|
+
treedb_name: "treedb_test",
|
|
269
|
+
topic_name: "users",
|
|
270
|
+
desc: {id: "users", pkey: "id", cols: [
|
|
271
|
+
{id: "id", header: "Id", type: "string", flag: ["persistent", "required"]}
|
|
272
|
+
]}
|
|
273
|
+
}, app);
|
|
274
|
+
gobj_start(form);
|
|
275
|
+
gobj_send_event(form, "EV_SHOW_SCHEMA", {}, form);
|
|
276
|
+
const win = form.priv.schema_win;
|
|
277
|
+
expect(!!win).toBe(true);
|
|
278
|
+
expect(in_window_layer(win)).toBe(true);
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
describe("one z-order for every window, docked or not", () => {
|
|
283
|
+
test("a window opened later is on top; a press brings either up", () => {
|
|
284
|
+
const dock = gobj_create_service(`stack_dock${++seq}`, "C_YUI_WINDOW_MANAGER", {}, yuno);
|
|
285
|
+
gobj_start(dock);
|
|
286
|
+
const docked = gobj_create_service(`stack_docked${seq}`, "C_YUI_WINDOW",
|
|
287
|
+
{header: "d", body: ["div", {}, "d"], manager: dock}, yuno);
|
|
288
|
+
gobj_start(docked);
|
|
289
|
+
const floating = gobj_create_service(`stack_floating${seq}`, "C_YUI_WINDOW",
|
|
290
|
+
{header: "f", body: ["div", {}, "f"]}, yuno);
|
|
291
|
+
gobj_start(floating);
|
|
292
|
+
|
|
293
|
+
/* Both in the window layer, the later one on top -- it opened
|
|
294
|
+
* UNDER the docked one, whatever came first. */
|
|
295
|
+
expect(in_window_layer(docked) && in_window_layer(floating)).toBe(true);
|
|
296
|
+
expect(z_of(floating) > z_of(docked)).toBe(true);
|
|
297
|
+
|
|
298
|
+
/* A press on the docked one: the dock raises it, on the same
|
|
299
|
+
* counter. */
|
|
300
|
+
press(docked);
|
|
301
|
+
expect(z_of(docked) > z_of(floating)).toBe(true);
|
|
302
|
+
|
|
303
|
+
/* A press on the floating one brings it up again: it crosses
|
|
304
|
+
* the FSM as EV_SHOW. */
|
|
305
|
+
press(floating);
|
|
306
|
+
expect(z_of(floating) > z_of(docked)).toBe(true);
|
|
307
|
+
|
|
308
|
+
/* And the dock's chip, the same. */
|
|
309
|
+
gobj_send_event(dock, "EV_FOCUS_WINDOW", {window: gobj_full_name(docked)}, docked);
|
|
310
|
+
expect(z_of(docked) > z_of(floating)).toBe(true);
|
|
311
|
+
expect(errors()).toEqual([]);
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
describe("the layers, as the stylesheet stacks them", () => {
|
|
316
|
+
/* CSS read as text: the double has no layout. Every window is in the
|
|
317
|
+
* window layer, inside the overlay layer; so the drawer (in the
|
|
318
|
+
* overlay, above the window layer), the toolbar's dropdowns (popup
|
|
319
|
+
* layer), the dialogs (modal) and the toasts (notification) are all
|
|
320
|
+
* above every window, whatever number it takes. */
|
|
321
|
+
test("the window layer is under the drawer, and every layer above it is above the overlay", () => {
|
|
322
|
+
const css = readFileSync(join(SRC, "c_yui_shell.css"), "utf8");
|
|
323
|
+
const z = (selector) => {
|
|
324
|
+
const m = css.match(new RegExp(selector + "\\s*\\{([^}]*)\\}"));
|
|
325
|
+
const v = m && m[1].match(/z-index:\s*(\d+)/);
|
|
326
|
+
return v ? Number(v[1]) : NaN;
|
|
327
|
+
};
|
|
328
|
+
expect(z("\\.yui-layer > \\.yui-window-layer") < z("\\.yui-drawer")).toBe(true);
|
|
329
|
+
const overlay = z("\\.yui-layer-overlay");
|
|
330
|
+
for(const layer of ["popup", "modal", "notification"]) {
|
|
331
|
+
expect(z(`\\.yui-layer-${layer}`) > overlay, layer).toBe(true);
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
});
|
package/src/yui_badge.js
CHANGED
|
@@ -18,14 +18,26 @@
|
|
|
18
18
|
* ALWAYS RENDERED, hidden when empty, so the runtime setters have a
|
|
19
19
|
* node to write into and never rebuild the control that holds it.
|
|
20
20
|
*
|
|
21
|
-
*
|
|
22
|
-
* carries an explicit aria-label, and an explicit label
|
|
23
|
-
* the element's content for a screen reader -- so a badge
|
|
24
|
-
* it
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
21
|
+
* PART OF THE CONTROL'S NAME (name_control_with_badge()). The
|
|
22
|
+
* control carries an explicit aria-label, and an explicit label
|
|
23
|
+
* REPLACES the element's content for a screen reader -- so a badge
|
|
24
|
+
* inside it is silent, and inside a <button> (the toolbar, an
|
|
25
|
+
* accordion head) its children are not even in the tree. The
|
|
26
|
+
* control is labelled by ITSELF (its aria-label, which
|
|
27
|
+
* refresh_language() re-translates) and by the badge: the name is
|
|
28
|
+
* "Alarms 3" while there is a count, "Alarms" while there is none,
|
|
29
|
+
* with nothing composed in javascript that a language switch would
|
|
30
|
+
* leave behind.
|
|
31
|
+
*
|
|
32
|
+
* NOT a live region any more. Up to 7.26.34 the badge was its own
|
|
33
|
+
* role="status", and that said "3" with no word of what has three;
|
|
34
|
+
* hidden while empty, it was not in the tree when the first count
|
|
35
|
+
* arrived, so 0 -> 1 was most likely never said; and inside a
|
|
36
|
+
* <button> it was not there at all. The change is said by the
|
|
37
|
+
* shell, in one live region of its own that is always there, with
|
|
38
|
+
* the name of the item: "Alarms, 3" (yui_shell_set_nav_item_badge,
|
|
39
|
+
* yui_shell_set_toolbar_item_badge). A write that changes nothing
|
|
40
|
+
* still touches no DOM.
|
|
29
41
|
*
|
|
30
42
|
* A TONE, optional: one of BADGE_TONES, drawn as Bulma's
|
|
31
43
|
* `is-<tone>` on the badge. It says HOW BAD the count is -- the
|
|
@@ -52,6 +64,10 @@ export const BADGE_TONES = Object.freeze([
|
|
|
52
64
|
"danger", "warning", "info", "success", "primary", "link"
|
|
53
65
|
]);
|
|
54
66
|
|
|
67
|
+
/* Unique ids for the badges and the controls they name: the
|
|
68
|
+
* aria-labelledby of a control points at two of them. */
|
|
69
|
+
let __badge_seq__ = 0;
|
|
70
|
+
|
|
55
71
|
|
|
56
72
|
/************************************************************
|
|
57
73
|
* The text a badge shows for `value`, "" when it shows none.
|
|
@@ -101,8 +117,7 @@ export function badge_node(value, tone, classes, who)
|
|
|
101
117
|
let text = badge_text(value);
|
|
102
118
|
let known = badge_tone(tone, who);
|
|
103
119
|
let attrs = {
|
|
104
|
-
class: known ? `${classes} is-${known}` : classes
|
|
105
|
-
role: "status"
|
|
120
|
+
class: known ? `${classes} is-${known}` : classes
|
|
106
121
|
};
|
|
107
122
|
if(!text) {
|
|
108
123
|
attrs.hidden = "hidden";
|
|
@@ -110,6 +125,35 @@ export function badge_node(value, tone, classes, who)
|
|
|
110
125
|
return ["span", attrs, text_node(text)];
|
|
111
126
|
}
|
|
112
127
|
|
|
128
|
+
/************************************************************
|
|
129
|
+
* Make the count part of the NAME of the control that carries
|
|
130
|
+
* the badge: its `aria-labelledby` points at the control itself
|
|
131
|
+
* and at the badge.
|
|
132
|
+
*
|
|
133
|
+
* The control keeps its `aria-label` and its
|
|
134
|
+
* data-i18n-aria-label: a self-reference reads the aria-label,
|
|
135
|
+
* so the name is the translated label followed by the badge's
|
|
136
|
+
* text -- "Alarms 3" -- or the label alone while the badge is
|
|
137
|
+
* empty. A painted count is in the name at once, and a language
|
|
138
|
+
* switch re-translates the label as it always did.
|
|
139
|
+
*
|
|
140
|
+
* Both are createElement2() descriptions, before anything is
|
|
141
|
+
* built: `control_attrs` (the attrs of the <a> or <button>, which
|
|
142
|
+
* must carry its aria-label) gets an `id` when it has none, and
|
|
143
|
+
* the `aria-labelledby`; `badge` (what badge_node() answered)
|
|
144
|
+
* gets its `id`.
|
|
145
|
+
************************************************************/
|
|
146
|
+
export function name_control_with_badge(control_attrs, badge)
|
|
147
|
+
{
|
|
148
|
+
let seq = ++__badge_seq__;
|
|
149
|
+
if(!control_attrs.id) {
|
|
150
|
+
control_attrs.id = `yui-badged-${seq}`;
|
|
151
|
+
}
|
|
152
|
+
let badge_attrs = badge[1];
|
|
153
|
+
badge_attrs.id = `yui-badge-${seq}`;
|
|
154
|
+
control_attrs["aria-labelledby"] = `${control_attrs.id} ${badge_attrs.id}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
113
157
|
/************************************************************
|
|
114
158
|
* Write a badge that is already in the DOM, as badge_node()
|
|
115
159
|
* draws it. `tone` is one badge_tone() answered: the setter
|
|
@@ -134,9 +178,10 @@ export function paint_badge($badge, value, tone)
|
|
|
134
178
|
$badge.setAttribute("hidden", "hidden");
|
|
135
179
|
return;
|
|
136
180
|
}
|
|
137
|
-
/* Do not touch the DOM when nothing changed:
|
|
138
|
-
*
|
|
139
|
-
*
|
|
181
|
+
/* Do not touch the DOM when nothing changed: whatever feeds the
|
|
182
|
+
* number ticks, and a rewrite is a mutation for every observer of
|
|
183
|
+
* the tree (and was an announcement while the badge was a live
|
|
184
|
+
* region). */
|
|
140
185
|
if($badge.textContent === text && !$badge.hasAttribute("hidden")) {
|
|
141
186
|
return;
|
|
142
187
|
}
|
package/src/yui_badge.test.js
CHANGED
|
@@ -16,7 +16,7 @@ install_dom_double();
|
|
|
16
16
|
|
|
17
17
|
const {createElement2, set_log_callback} = await import("@yuneta/gobj-js");
|
|
18
18
|
const {
|
|
19
|
-
BADGE_TONES, badge_text, badge_tone, badge_node, paint_badge,
|
|
19
|
+
BADGE_TONES, badge_text, badge_tone, badge_node, paint_badge, name_control_with_badge,
|
|
20
20
|
} = await import("./yui_badge.js");
|
|
21
21
|
|
|
22
22
|
const logged = [];
|
|
@@ -83,10 +83,14 @@ describe("badge_tone", () => {
|
|
|
83
83
|
});
|
|
84
84
|
|
|
85
85
|
describe("badge_node", () => {
|
|
86
|
-
test("
|
|
86
|
+
test("the host's classes, its tone and its text as text; no live region of its own", () => {
|
|
87
|
+
/* It said "3" with no context, missed the first count while
|
|
88
|
+
* hidden, and was not in the tree inside a <button>: the shell
|
|
89
|
+
* says the change now (REV-CLOUD V23.5.5). */
|
|
87
90
|
let $b = createElement2(badge_node(3, "warning", "X_BADGE yui-badge", "who"));
|
|
88
91
|
expect($b.tagName).toBe("SPAN");
|
|
89
|
-
expect($b.getAttribute("role")).toBe(
|
|
92
|
+
expect($b.getAttribute("role")).toBe(null);
|
|
93
|
+
expect($b.getAttribute("aria-live")).toBe(null);
|
|
90
94
|
expect($b.hasAttribute("hidden")).toBe(false);
|
|
91
95
|
expect([...$b.classList]).toEqual(["X_BADGE", "yui-badge", "is-warning"]);
|
|
92
96
|
expect($b.textContent).toBe("3");
|
|
@@ -146,3 +150,36 @@ describe("paint_badge", () => {
|
|
|
146
150
|
expect($b.textContent).toBe("6");
|
|
147
151
|
});
|
|
148
152
|
});
|
|
153
|
+
|
|
154
|
+
describe("name_control_with_badge", () => {
|
|
155
|
+
/* The count is part of the control's NAME: it is labelled by itself
|
|
156
|
+
* (its aria-label, re-translated by refresh_language) and by the
|
|
157
|
+
* badge -- "alarms 3" -- with nothing composed in javascript. */
|
|
158
|
+
test("the control is labelled by itself and by its badge", () => {
|
|
159
|
+
let attrs = {"aria-label": "alarms", "data-i18n-aria-label": "alarms"};
|
|
160
|
+
let badge = badge_node(3, "", "X_BADGE", "who");
|
|
161
|
+
name_control_with_badge(attrs, badge);
|
|
162
|
+
let $a = createElement2(["a", attrs, [badge]]);
|
|
163
|
+
let $b = $a.firstElementChild;
|
|
164
|
+
|
|
165
|
+
expect($a.id).toBeTruthy();
|
|
166
|
+
expect($b.id).toBeTruthy();
|
|
167
|
+
expect($a.getAttribute("aria-labelledby")).toBe(`${$a.id} ${$b.id}`);
|
|
168
|
+
/* The aria-label stays, and with its key: the self-reference reads it */
|
|
169
|
+
expect($a.getAttribute("aria-label")).toBe("alarms");
|
|
170
|
+
expect($a.getAttribute("data-i18n-aria-label")).toBe("alarms");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("an id the control has is kept, and every pair is its own", () => {
|
|
174
|
+
let one = {id: "head-1", "aria-label": "a"};
|
|
175
|
+
let two = {"aria-label": "b"};
|
|
176
|
+
let b1 = badge_node(1, "", "X_BADGE", "who");
|
|
177
|
+
let b2 = badge_node(2, "", "X_BADGE", "who");
|
|
178
|
+
name_control_with_badge(one, b1);
|
|
179
|
+
name_control_with_badge(two, b2);
|
|
180
|
+
expect(one.id).toBe("head-1");
|
|
181
|
+
expect(one["aria-labelledby"]).toBe(`head-1 ${b1[1].id}`);
|
|
182
|
+
expect(two.id).not.toBe(one.id);
|
|
183
|
+
expect(b2[1].id).not.toBe(b1[1].id);
|
|
184
|
+
});
|
|
185
|
+
});
|
package/src/yui_dev.js
CHANGED
|
@@ -1850,7 +1850,8 @@ function apply_dev_traces()
|
|
|
1850
1850
|
* Shell-agnostic: the legacy C_YUI_MAIN shell has a
|
|
1851
1851
|
* '#top-layer' stacking element; the new C_YUI_SHELL does not.
|
|
1852
1852
|
* We pass that element when present, otherwise null — C_YUI_WINDOW
|
|
1853
|
-
*
|
|
1853
|
+
* then takes the window layer of the shell (document.body while
|
|
1854
|
+
* there is none), and follows the shell across a logout.
|
|
1854
1855
|
************************************************************/
|
|
1855
1856
|
function setup_dev(self, show)
|
|
1856
1857
|
{
|
package/src/yui_file_field.js
CHANGED
|
@@ -266,6 +266,7 @@ function yui_file_control(gobj, {name, value, readonly, accept, on_pick})
|
|
|
266
266
|
const $pick = createElement2(["button", {
|
|
267
267
|
type: "button",
|
|
268
268
|
class: "FILE_PICK button is-small",
|
|
269
|
+
title: t("choose a file"),
|
|
269
270
|
"data-i18n-title": "choose a file",
|
|
270
271
|
"aria-label": t("choose a file"),
|
|
271
272
|
"data-i18n-aria-label": "choose a file"
|
|
@@ -280,6 +281,7 @@ function yui_file_control(gobj, {name, value, readonly, accept, on_pick})
|
|
|
280
281
|
const $clear = createElement2(["button", {
|
|
281
282
|
type: "button",
|
|
282
283
|
class: "FILE_CLEAR button is-small",
|
|
284
|
+
title: t("remove the file"),
|
|
283
285
|
"data-i18n-title": "remove the file",
|
|
284
286
|
"aria-label": t("remove the file"),
|
|
285
287
|
"data-i18n-aria-label": "remove the file"
|
package/src/yui_frontend_view.js
CHANGED
|
@@ -35,7 +35,8 @@ const WIN_NAME = "Frontend-View-Window";
|
|
|
35
35
|
* Shell-agnostic: the legacy C_YUI_MAIN shell has a
|
|
36
36
|
* '#top-layer' stacking element; the new C_YUI_SHELL does not.
|
|
37
37
|
* We pass that element when present, otherwise null — C_YUI_WINDOW
|
|
38
|
-
*
|
|
38
|
+
* then takes the window layer of the shell (document.body while
|
|
39
|
+
* there is none), and follows the shell across a logout.
|
|
39
40
|
*
|
|
40
41
|
* Returns the window gobj, or null.
|
|
41
42
|
************************************************************/
|
package/src/yui_gclass_view.js
CHANGED
|
@@ -42,7 +42,7 @@ import {
|
|
|
42
42
|
|
|
43
43
|
import {describe_local_gclass} from "./gclass_describe.js";
|
|
44
44
|
import {register_c_yui_gclass} from "./c_yui_gclass.js";
|
|
45
|
-
import {yui_shell_show_error, yui_shell_show_modal
|
|
45
|
+
import {yui_shell_show_error, yui_shell_show_modal} from "./shell_modals.js";
|
|
46
46
|
import {yui_shell_of} from "./c_yui_shell.js";
|
|
47
47
|
|
|
48
48
|
import {t} from "i18next";
|
|
@@ -205,7 +205,7 @@ function open_gclass_view(host, gclass_name, opts)
|
|
|
205
205
|
return handle;
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
-
let $win_parent = window_layer_of(host
|
|
208
|
+
let $win_parent = window_layer_of(host);
|
|
209
209
|
|
|
210
210
|
handle.win = gobj_create_service(
|
|
211
211
|
`gclass-win-${clean_name(gobj_name(host))}`,
|
|
@@ -307,8 +307,13 @@ function close_gclass_view(handle)
|
|
|
307
307
|
* window on `body` and mounted in the shell's popup layer
|
|
308
308
|
* lands BEHIND its opener whatever number it is given, and
|
|
309
309
|
* the button reads as one that did nothing.
|
|
310
|
+
*
|
|
311
|
+
* Opened from no window: null -- the window layer of the
|
|
312
|
+
* shell, where every window lives (the legacy '#top-layer'
|
|
313
|
+
* when a page has one). It was the shell's popup layer: above
|
|
314
|
+
* the drawer, in the way of the toolbar's dropdowns.
|
|
310
315
|
************************************************************/
|
|
311
|
-
function window_layer_of(host
|
|
316
|
+
function window_layer_of(host)
|
|
312
317
|
{
|
|
313
318
|
let $c = gobj_read_pointer_attr(host, "$container");
|
|
314
319
|
if($c && typeof $c.closest === "function") {
|
|
@@ -319,9 +324,7 @@ function window_layer_of(host, shell)
|
|
|
319
324
|
}
|
|
320
325
|
|
|
321
326
|
return (typeof document !== "undefined" &&
|
|
322
|
-
document.getElementById("top-layer")) ||
|
|
323
|
-
(shell && yui_shell_popup_layer(shell)) ||
|
|
324
|
-
null;
|
|
327
|
+
document.getElementById("top-layer")) || null;
|
|
325
328
|
}
|
|
326
329
|
|
|
327
330
|
/************************************************************
|
|
@@ -195,6 +195,47 @@ function yui_tabulator_name_row_selects(table, t)
|
|
|
195
195
|
}
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
/***************************************************************
|
|
199
|
+
* Name the EDITOR of an editable column: the box Tabulator draws
|
|
200
|
+
* in a cell while it is edited (input, number, textarea, list,
|
|
201
|
+
* tickCross, date...).
|
|
202
|
+
*
|
|
203
|
+
* Tabulator gives it nothing -- no label, no aria-label, no title
|
|
204
|
+
* -- so a reader lands in an anonymous edit box. Its name is the
|
|
205
|
+
* column's: a cell is edited under its column's title. Asked at
|
|
206
|
+
* the MOMENT of editing: Tabulator takes `editorParams` as a
|
|
207
|
+
* function of the cell, and every built-in editor writes the
|
|
208
|
+
* `elementAttributes` it answers onto the element it draws. So
|
|
209
|
+
* the name is in the language of that moment, with nothing to
|
|
210
|
+
* re-apply after a language change or a rebuild of the columns
|
|
211
|
+
* -- the twin of the header filters above, which have to be
|
|
212
|
+
* renamed after every render.
|
|
213
|
+
*
|
|
214
|
+
* `name_of(cell)` answers the column's title, TRANSLATED (the
|
|
215
|
+
* view's own label: a cascade, a schema header); "" names
|
|
216
|
+
* nothing. What `editorParams` the column had is kept, its own
|
|
217
|
+
* `elementAttributes` too; a name it set itself wins.
|
|
218
|
+
***************************************************************/
|
|
219
|
+
function yui_tabulator_name_editor(column_def, name_of)
|
|
220
|
+
{
|
|
221
|
+
if(!column_def || !column_def.editor || typeof name_of !== "function") {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
let base = column_def.editorParams;
|
|
225
|
+
column_def.editorParams = function(cell) {
|
|
226
|
+
let params = (typeof base === "function") ? base(cell) : (base || {});
|
|
227
|
+
let name = name_of(cell);
|
|
228
|
+
if(!name) {
|
|
229
|
+
return params;
|
|
230
|
+
}
|
|
231
|
+
let attrs = Object.assign(
|
|
232
|
+
{"aria-label": name, title: name},
|
|
233
|
+
params.elementAttributes || {}
|
|
234
|
+
);
|
|
235
|
+
return Object.assign({}, params, {elementAttributes: attrs});
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
198
239
|
function yui_tabulator_relocalize(table, t)
|
|
199
240
|
{
|
|
200
241
|
if(!table) {
|
|
@@ -243,4 +284,5 @@ export {
|
|
|
243
284
|
yui_tabulator_relocalize,
|
|
244
285
|
yui_tabulator_name_filters,
|
|
245
286
|
yui_tabulator_name_row_selects,
|
|
287
|
+
yui_tabulator_name_editor,
|
|
246
288
|
};
|