@yuneta/gobj-ui 7.26.34 → 7.26.36

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.
Files changed (43) hide show
  1. package/README.md +223 -53
  2. package/dist/gobj-ui.cjs.js +1109 -143
  3. package/dist/gobj-ui.es.js +1109 -143
  4. package/package.json +2 -2
  5. package/scripts/validate-locales.mjs +672 -96
  6. package/src/c_yui_form.js +186 -9
  7. package/src/c_yui_gclass.js +2 -0
  8. package/src/c_yui_gobj_tree_js.js +2 -0
  9. package/src/c_yui_json.js +2 -0
  10. package/src/c_yui_json_graph.js +2 -0
  11. package/src/c_yui_nav.js +23 -5
  12. package/src/c_yui_shell.css +16 -0
  13. package/src/c_yui_shell.js +282 -26
  14. package/src/c_yui_treedb_graph.js +26 -8
  15. package/src/c_yui_treedb_topic_with_form.js +40 -18
  16. package/src/c_yui_treedb_topics.js +34 -11
  17. package/src/c_yui_window.js +132 -32
  18. package/src/c_yui_window_manager.js +250 -46
  19. package/src/form_json_editor_names.js +448 -0
  20. package/src/form_json_editor_names.wiring.test.js +595 -0
  21. package/src/form_label_i18n.wiring.test.js +169 -0
  22. package/src/i18n_key_functions.js +14 -6
  23. package/src/i18n_key_functions.test.js +124 -2
  24. package/src/lib_maplibre.js +26 -2
  25. package/src/lib_maplibre_relocalize.test.js +78 -0
  26. package/src/named_controls.wiring.test.js +468 -0
  27. package/src/nav_cards_helpers.js +9 -1
  28. package/src/nav_item_badge.wiring.test.js +182 -1
  29. package/src/shell_route_map.js +25 -8
  30. package/src/table_editor_name.wiring.test.js +201 -0
  31. package/src/treedb_column_title.wiring.test.js +151 -0
  32. package/src/treedb_json_drill.wiring.test.js +41 -1
  33. package/src/window_layer.wiring.test.js +348 -43
  34. package/src/window_show.wiring.test.js +6 -2
  35. package/src/window_stacking.wiring.test.js +334 -0
  36. package/src/yui_badge.js +58 -13
  37. package/src/yui_badge.test.js +40 -3
  38. package/src/yui_dev.js +2 -1
  39. package/src/yui_file_field.js +2 -0
  40. package/src/yui_frontend_view.js +2 -1
  41. package/src/yui_gclass_view.js +9 -6
  42. package/src/yui_tabulator_i18n.js +42 -0
  43. 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
- * 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.
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: role="status" is a
138
- * live region, and rewriting the same number would have a screen
139
- * reader announce it again on every tick. */
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
  }
@@ -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("a live region with the host's classes, its tone and its text as text", () => {
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("status");
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
- * falls back to document.body by contract.
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
  {
@@ -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"
@@ -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
- * falls back to document.body by contract.
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
  ************************************************************/
@@ -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, yui_shell_popup_layer} from "./shell_modals.js";
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, shell);
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, shell)
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
  };