@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.
Files changed (45) hide show
  1. package/README.md +223 -53
  2. package/dist/gobj-ui.cjs.js +1053 -144
  3. package/dist/gobj-ui.es.js +1053 -144
  4. package/package.json +2 -2
  5. package/scripts/validate-locales.mjs +672 -96
  6. package/src/c_yui_form.js +229 -11
  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 +156 -44
  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/form_table_validator.wiring.test.js +192 -0
  23. package/src/form_template_field.wiring.test.js +21 -0
  24. package/src/i18n_key_functions.js +14 -6
  25. package/src/i18n_key_functions.test.js +124 -2
  26. package/src/lib_maplibre.js +26 -2
  27. package/src/lib_maplibre_relocalize.test.js +78 -0
  28. package/src/named_controls.wiring.test.js +468 -0
  29. package/src/nav_cards_helpers.js +9 -1
  30. package/src/nav_item_badge.wiring.test.js +182 -1
  31. package/src/shell_route_map.js +25 -8
  32. package/src/table_editor_name.wiring.test.js +201 -0
  33. package/src/treedb_column_title.wiring.test.js +151 -0
  34. package/src/treedb_json_drill.wiring.test.js +41 -1
  35. package/src/window_layer.wiring.test.js +308 -43
  36. package/src/window_show.wiring.test.js +6 -2
  37. package/src/window_stacking.wiring.test.js +334 -0
  38. package/src/yui_badge.js +58 -13
  39. package/src/yui_badge.test.js +40 -3
  40. package/src/yui_dev.js +2 -1
  41. package/src/yui_file_field.js +2 -0
  42. package/src/yui_frontend_view.js +2 -1
  43. package/src/yui_gclass_view.js +9 -6
  44. package/src/yui_tabulator_i18n.js +42 -0
  45. package/src/yui_tabulator_i18n.test.js +48 -1
@@ -0,0 +1,169 @@
1
+ /***********************************************************************
2
+ * form_label_i18n.wiring.test.js
3
+ *
4
+ * The label of a field of a C_YUI_FORM is translated by a
5
+ * CASCADE -- '<topic>.<col>', then '<col>', then the header -- and
6
+ * re-translated on a language change by the key its node carries
7
+ * (data-i18n on the <label>, data-i18n-aria-label / -title on the
8
+ * control). Up to 7.26.34 that key was chosen by asking only the
9
+ * bare '<col>':
10
+ *
11
+ * (a) a label translated by '<topic>.<col>' alone carried its own
12
+ * TRANSLATED text as the key, and never changed language;
13
+ * (b) one translated by both switched to the bare '<col>' text at
14
+ * the first language change.
15
+ *
16
+ * Driven through the real gclass on a document double, switching
17
+ * the language with i18next and refresh_language(), as an app does.
18
+ *
19
+ * Copyright (c) 2026, ArtGins.
20
+ * All Rights Reserved.
21
+ ***********************************************************************/
22
+ import {describe, test, expect, beforeAll, afterEach, vi} from "vitest";
23
+ import {install_dom_double} from "../test/dom_double.js";
24
+
25
+ install_dom_double();
26
+
27
+ if(typeof globalThis.ResizeObserver === "undefined") {
28
+ globalThis.ResizeObserver = class {
29
+ observe() {}
30
+ unobserve() {}
31
+ disconnect() {}
32
+ };
33
+ }
34
+
35
+ /* No json field here, but the module imports the editor. */
36
+ vi.mock("vanilla-jsoneditor", () => ({
37
+ createJSONEditor: () => ({
38
+ get() {
39
+ return {text: ""};
40
+ },
41
+ set() {},
42
+ destroy() {},
43
+ }),
44
+ }));
45
+
46
+ const {
47
+ SDATA_END, gclass_create,
48
+ gobj_start_up, gobj_create_yuno, gobj_create_pure_child,
49
+ gobj_start, gobj_read_attr, refresh_language,
50
+ } = await import("@yuneta/gobj-js");
51
+ const {register_c_yui_form} = await import("./c_yui_form.js");
52
+
53
+ let i18next = null;
54
+ let yuno = null;
55
+ let seq = 0;
56
+
57
+ beforeAll(async () => {
58
+ i18next = (await import("i18next")).default;
59
+ await i18next.init({
60
+ lng: "en",
61
+ resources: {
62
+ en: {translation: {
63
+ "devices.alias": "Device alias",
64
+ "groups.title": "Group title",
65
+ "title": "Title"
66
+ }},
67
+ es: {translation: {
68
+ "devices.alias": "Alias del equipo",
69
+ "groups.title": "Título del grupo",
70
+ "title": "Título"
71
+ }}
72
+ }
73
+ });
74
+ gobj_start_up(null, null, null, null, null, null, null);
75
+ gclass_create("C_TEST_LABEL_HOST", [], [["ST_IDLE", []]], {}, 0, [SDATA_END()], {}, 0, 0, 0, 0);
76
+ gclass_create(
77
+ "C_TEST_LABEL_APP",
78
+ [["EV_RECORD_CHANGED", 0]],
79
+ [["ST_IDLE", [["EV_RECORD_CHANGED", () => 0, null]]]],
80
+ {}, 0, [SDATA_END()], {}, 0, 0, 0, 0
81
+ );
82
+ register_c_yui_form();
83
+ yuno = gobj_create_yuno("form_label_yuno", "C_TEST_LABEL_HOST", {});
84
+ gobj_start(yuno);
85
+ });
86
+
87
+ afterEach(async () => {
88
+ await i18next.changeLanguage("en");
89
+ });
90
+
91
+ /* A form of `topic` with one writable column `col`, built in English. */
92
+ function build(topic, col)
93
+ {
94
+ const app = gobj_create_pure_child(`lapp${++seq}`, "C_TEST_LABEL_APP", {}, yuno);
95
+ gobj_start(app);
96
+ const form = gobj_create_pure_child(`lform${seq}`, "C_YUI_FORM", {
97
+ template: [
98
+ {id: "id", header: "id", type: "string", flag: ["persistent", "required"]},
99
+ {id: col, header: col, type: "string", flag: ["persistent", "writable"]},
100
+ ],
101
+ topic_name: topic,
102
+ record: {id: "a", [col]: "x"},
103
+ render_mode: "edit",
104
+ editable: true,
105
+ }, app);
106
+ gobj_start(form);
107
+ const $c = gobj_read_attr(form, "$container");
108
+ const $label = $c.querySelector(`label[for="${col}"]`);
109
+ const $input = $c.querySelector(`input[name="${col}"]`);
110
+ expect(!!$label).toBe(true);
111
+ expect(!!$input).toBe(true);
112
+ return {$c, $label, $input};
113
+ }
114
+
115
+ /* What an app does on a language change. */
116
+ async function switch_to_spanish($c)
117
+ {
118
+ await i18next.changeLanguage("es");
119
+ refresh_language($c, i18next.t.bind(i18next));
120
+ }
121
+
122
+ /* The text refresh_language() writes into a node that carries
123
+ * data-i18n: t(<that key>). Asked directly, because the document
124
+ * double's text nodes have no `nodeValue`, which is what it writes
125
+ * (the attributes -- aria-label, title -- it rewrites in the double
126
+ * too, and those are read from the control). */
127
+ function retranslated($el)
128
+ {
129
+ return i18next.t($el.getAttribute("data-i18n"));
130
+ }
131
+
132
+ describe("the label of a field changes language by the key that translated it", () => {
133
+ test("(a) translated by '<topic>.<col>' alone", async () => {
134
+ const {$c, $label, $input} = build("devices", "alias");
135
+ expect($label.textContent).toBe("Device alias");
136
+ expect($label.getAttribute("data-i18n")).toBe("devices.alias");
137
+ expect($input.getAttribute("data-i18n-aria-label")).toBe("devices.alias");
138
+
139
+ await switch_to_spanish($c);
140
+ expect(retranslated($label)).toBe("Alias del equipo");
141
+ expect($input.getAttribute("aria-label")).toBe("Alias del equipo");
142
+ expect($input.getAttribute("title")).toBe("Alias del equipo");
143
+ });
144
+
145
+ test("(b) translated by '<topic>.<col>' and by '<col>': the topic's wins, before and after", async () => {
146
+ const {$c, $label, $input} = build("groups", "title");
147
+ expect($label.textContent).toBe("Group title");
148
+ expect($label.getAttribute("data-i18n")).toBe("groups.title");
149
+
150
+ await switch_to_spanish($c);
151
+ expect(retranslated($label)).toBe("Título del grupo");
152
+ expect($input.getAttribute("aria-label")).toBe("Título del grupo");
153
+ });
154
+
155
+ test("translated by '<col>' alone, as before", async () => {
156
+ const {$c, $label} = build("other", "title");
157
+ expect($label.textContent).toBe("Title");
158
+ expect($label.getAttribute("data-i18n")).toBe("title");
159
+
160
+ await switch_to_spanish($c);
161
+ expect(retranslated($label)).toBe("Título");
162
+ });
163
+
164
+ test("translated by nothing: the header, as before", () => {
165
+ const {$label} = build("other", "zone");
166
+ expect($label.textContent).toBe("zone");
167
+ expect($label.getAttribute("data-i18n")).toBe("zone");
168
+ });
169
+ });
@@ -0,0 +1,192 @@
1
+ /***********************************************************************
2
+ * form_table_validator.wiring.test.js
3
+ *
4
+ * A table field of a C_YUI_FORM ('exec' mode) and its validators.
5
+ *
6
+ * - A template item may carry Tabulator validators of its own
7
+ * (`validator`: a name or a function), which its column runs
8
+ * besides `required`: what a host knows and a schema cannot say
9
+ * (estadodelaire refuses a trigger value that its measure can
10
+ * never match).
11
+ * - A table that does not validate refuses the save. Tabulator's
12
+ * validate() answers `true` or the LIST of the bad cells, and
13
+ * validate_form() asked `!validate()`, which a list never is: a
14
+ * `required` cell left empty was saved.
15
+ *
16
+ * Driven through the real gclass on a document double, with a
17
+ * Tabulator double that keeps its settings and answers validate()
18
+ * as told.
19
+ *
20
+ * Copyright (c) 2026, ArtGins.
21
+ * All Rights Reserved.
22
+ ***********************************************************************/
23
+ import {describe, test, expect, beforeAll, beforeEach, vi} from "vitest";
24
+ import {install_dom_double} from "../test/dom_double.js";
25
+
26
+ install_dom_double();
27
+
28
+ if(typeof globalThis.ResizeObserver === "undefined") {
29
+ globalThis.ResizeObserver = class {
30
+ observe() {}
31
+ unobserve() {}
32
+ disconnect() {}
33
+ };
34
+ }
35
+
36
+ const tables = vi.hoisted(() => []);
37
+ vi.mock("tabulator-tables", () => {
38
+ class FakeTabulator {
39
+ constructor($el, settings) {
40
+ this.$el = $el;
41
+ this.settings = settings;
42
+ this.externalEvents = {events: {}};
43
+ this.initialized = true;
44
+ this.answer = true; // what validate() says
45
+ this.validated = 0;
46
+ tables.push(this);
47
+ return new Proxy(this, {
48
+ get(target, prop) {
49
+ if(prop in target) {
50
+ return target[prop];
51
+ }
52
+ return () => [];
53
+ }
54
+ });
55
+ }
56
+ on(ev, fn) {
57
+ (this.externalEvents.events[ev] = this.externalEvents.events[ev] || []).push(fn);
58
+ }
59
+ off(ev) {
60
+ delete this.externalEvents.events[ev];
61
+ }
62
+ validate() {
63
+ this.validated++;
64
+ return this.answer;
65
+ }
66
+ getRows() {
67
+ return [];
68
+ }
69
+ destroy() {
70
+ }
71
+ }
72
+ return {TabulatorFull: FakeTabulator, Tabulator: FakeTabulator};
73
+ });
74
+
75
+ const {
76
+ SDATA_END, gclass_create,
77
+ gobj_start_up, gobj_create_yuno, gobj_create_pure_child,
78
+ gobj_start, gobj_send_event,
79
+ set_log_callback,
80
+ } = await import("@yuneta/gobj-js");
81
+ const {register_c_yui_form} = await import("./c_yui_form.js");
82
+
83
+ const saved = [];
84
+ let yuno = null;
85
+
86
+ beforeAll(async () => {
87
+ const i18next = (await import("i18next")).default;
88
+ await i18next.init({lng: "en", resources: {}});
89
+ gobj_start_up(null, null, null, null, null, null, null);
90
+ set_log_callback(() => {});
91
+ gclass_create("C_TEST_FORM_HOST", [], [["ST_IDLE", []]], {}, 0, [SDATA_END()], {}, 0, 0, 0, 0);
92
+ gclass_create(
93
+ "C_TEST_FORM_APP",
94
+ [["EV_SAVE_RECORD", 0], ["EV_RECORD_CHANGED", 0]],
95
+ [["ST_IDLE", [
96
+ ["EV_SAVE_RECORD", (gobj, event, kw) => {
97
+ saved.push(kw);
98
+ return 0;
99
+ }, null],
100
+ ["EV_RECORD_CHANGED", () => 0, null],
101
+ ]]],
102
+ {}, 0, [SDATA_END()], {}, 0, 0, 0, 0
103
+ );
104
+ register_c_yui_form();
105
+ yuno = gobj_create_yuno("form_validator_yuno", "C_TEST_FORM_HOST", {});
106
+ gobj_start(yuno);
107
+ });
108
+
109
+ beforeEach(() => {
110
+ saved.length = 0;
111
+ tables.length = 0;
112
+ });
113
+
114
+ let seq = 0;
115
+
116
+ /* The alarms of an estadodelaire device, cut down: a table whose
117
+ * `value` column carries a validator the host added. */
118
+ function build(validator)
119
+ {
120
+ const value = {
121
+ type: "string",
122
+ flag: ["writable", "persistent"]
123
+ };
124
+ if(validator) {
125
+ value.validator = validator;
126
+ }
127
+ const app = gobj_create_pure_child(`vapp${++seq}`, "C_TEST_FORM_APP", {}, yuno);
128
+ gobj_start(app);
129
+ const form = gobj_create_pure_child(`vform${seq}`, "C_YUI_FORM", {
130
+ template: {
131
+ triggers: {
132
+ type: "array",
133
+ flag: ["persistent", "writable", "table"],
134
+ table: {
135
+ id: {
136
+ type: "string",
137
+ flag: ["writable", "persistent", "required"]
138
+ },
139
+ value: value
140
+ }
141
+ }
142
+ },
143
+ record: {triggers: []},
144
+ editable: true,
145
+ }, app);
146
+ gobj_start(form);
147
+ expect(tables.length).toBe(1);
148
+ return {form, table: tables[0]};
149
+ }
150
+
151
+ function column(table, field)
152
+ {
153
+ return table.settings.columns.find((c) => c.field === field);
154
+ }
155
+
156
+ describe("a table field's validators", () => {
157
+ test("a template item's validator runs in its column, besides required", () => {
158
+ const fits = () => true;
159
+ const {table} = build(fits);
160
+ expect(column(table, "id").validator).toEqual(["required"]);
161
+ expect(column(table, "value").validator).toEqual([fits]);
162
+ });
163
+
164
+ test("a list of them, names and functions, is taken whole", () => {
165
+ const fits = () => true;
166
+ const {table} = build(["numeric", fits]);
167
+ expect(column(table, "value").validator).toEqual(["numeric", fits]);
168
+ });
169
+
170
+ test("an item without one keeps what the schema says", () => {
171
+ const {table} = build(null);
172
+ expect(column(table, "value").validator).toEqual([]);
173
+ });
174
+
175
+ test("a table with a bad cell refuses the save, and shows the cell", () => {
176
+ const {form, table} = build(null);
177
+ const $cell = document.createElement("div");
178
+ let shown = 0;
179
+ $cell.scrollIntoView = () => {
180
+ shown++;
181
+ };
182
+ table.answer = [{getElement: () => $cell}]; // what Tabulator answers
183
+ gobj_send_event(form, "EV_SAVE_RECORD", {}, form);
184
+ expect(table.validated).toBe(1);
185
+ expect(saved).toEqual([]);
186
+ expect(shown).toBe(1);
187
+
188
+ table.answer = true;
189
+ gobj_send_event(form, "EV_SAVE_RECORD", {}, form);
190
+ expect(saved.length).toBe(1);
191
+ });
192
+ });
@@ -143,6 +143,27 @@ describe("a template field in exec mode (a sub-form)", () => {
143
143
  expect(errors()).toEqual([]);
144
144
  });
145
145
 
146
+ test("a colour cleared is black, and its readout says so", () => {
147
+ const form = build({
148
+ template_version: 7,
149
+ alarm_colors: {
150
+ active_not_validated: "#ff0000",
151
+ active_validated: "#e5a50a",
152
+ not_active_not_validated: "#ff00ff"
153
+ }
154
+ });
155
+ const $c = gobj_read_attr(form, "$container");
156
+ const $colors = $c.querySelectorAll("fieldset input");
157
+ expect($colors.length).toBe(3);
158
+ gobj_send_event(form, "EV_CLEAR_RECORD", {}, form);
159
+ for(const $input of $colors) {
160
+ /* Never "": a colour input refuses it (Chrome warns). */
161
+ expect($input.value).toBe("#000000");
162
+ expect($input.yui_color_value.textContent).toBe("#000000");
163
+ }
164
+ expect(errors()).toEqual([]);
165
+ });
166
+
146
167
  test("left null by a save before the fix, comes back with its defaults", () => {
147
168
  const form = build({
148
169
  template_version: 7,
@@ -71,11 +71,15 @@
71
71
  * literal (or DOM, or nothing), a const the reader follows, a key
72
72
  * position of the enclosing table function handed on, or a marked
73
73
  * value fails, with the file and the line; a TRANSLATED text --
74
- * t("..."), tr(), an alias of t, `t?.()`, `t.call()` -- fails even
75
- * marked (it would be translated again, and never change
76
- * language). A test (i18n_key_functions.test.js) holds the library
77
- * to it, and validate-locales an app's own code. Publishing a list
78
- * exempts no module.
74
+ * t("..."), tr(), an alias of t, `t?.()`, `t.call()`, or a string
75
+ * built from one, `t("k") + ":"` -- fails even marked (it would be
76
+ * translated again, and never change language). A translator
77
+ * handed as a callback is held the same way: each element
78
+ * `keys.map(t)` walks is a key position, and what `.then(t)` is
79
+ * handed is one no source writes. A test
80
+ * (i18n_key_functions.test.js) holds the library to it, and
81
+ * validate-locales an app's own code. Publishing a list exempts
82
+ * no module.
79
83
  *
80
84
  * The table is COMPLETE and CORRECT, and the same test proves both
81
85
  * (REV-CLOUD V21.4.1, V22): it finds every function of the library
@@ -233,7 +237,8 @@ export const I18N_KEY_FUNCTIONS = Object.freeze([
233
237
  keys: ["1[].name", "1[].tooltip", "1[].aria_label"]},
234
238
  {module: "c_yui_nav.js", name: "render_drawer", exported: false,
235
239
  keys: ["1[].name", "1[].tooltip", "1[].aria_label"]},
236
- {module: "c_yui_nav.js", name: "render_breadcrumb", exported: false, keys: ["1[].name", "1[].id"]},
240
+ {module: "c_yui_nav.js", name: "render_breadcrumb", exported: false,
241
+ keys: ["1[].name", "1[].id", "1[].tooltip", "1[].aria_label"]},
237
242
  {module: "c_yui_nav.js", name: "render_accordion", exported: false,
238
243
  keys: ["1[].name", "1[].submenu.items[].name", "1[].submenu.items[].tooltip",
239
244
  "1[].submenu.items[].aria_label"]},
@@ -253,6 +258,9 @@ export const I18N_KEY_FUNCTIONS = Object.freeze([
253
258
  {module: "c_yui_shell.js", name: "toggle_toolbar_dropdown", exported: false, keys: ["1.name", "1.aria_label"]},
254
259
  {module: "c_yui_shell.js", name: "open_toolbar_dropdown", exported: false, keys: ["1.name", "1.aria_label"]},
255
260
  {module: "c_yui_shell.js", name: "build_dropdown_row", exported: false, keys: ["1.name", "1.aria_label"]},
261
+ /* c_yui_shell.js -- the name of a badged item, said with its count:
262
+ * the app's shell declaration */
263
+ {module: "c_yui_shell.js", name: "announce_badge", exported: false, keys: ["1"]},
256
264
  /* shell_route_map.js -- a node of the route map */
257
265
  {module: "shell_route_map.js", name: "render_node", exported: false, keys: ["0.label"]},
258
266
  ]);
@@ -36,8 +36,10 @@
36
36
  * handed -- a comment `i18n: <NAME>_I18N_KEYS` (the module
37
37
  * publishes those keys) or `i18n: data` (no key a list can hold).
38
38
  * Anything else fails, naming the file and the line; so does a
39
- * translated text -- t("..."), tr(), an alias of t -- marked or
40
- * not;
39
+ * translated text -- t("..."), tr(), an alias of t, a string built
40
+ * from one (`t("k") + ":"`) -- marked or not. A translator handed
41
+ * as a callback (V23.5: `keys.map(t)`, `promise.then(t)`) is held
42
+ * the same way, each element it walks a key position;
41
43
  * - every marker is one and stands where a key is read; the lists
42
44
  * it names are published by its module or one it imports; the
43
45
  * keys it marks that can be read are in them -- a marked `let`
@@ -1935,3 +1937,123 @@ describe("REV-CLOUD V22.3-4: the data marker hides no key; one notion of a trans
1935
1937
  [{name: "B_I18N_KEYS", keys: ["b"]}]).map((e) => e.line)).toEqual([2]);
1936
1938
  });
1937
1939
  });
1940
+
1941
+ describe("REV-CLOUD V23.5: a translator as a callback, by destructuring; setAttributeNS; t() compositions; aliases", () => {
1942
+ let module = "x.js";
1943
+ let head = [`import {yui_shell_show_info} from "./shell_modals.js";`,
1944
+ `import i18next from "i18next";`];
1945
+ let scan = (lines) => scan_ast(parse_as([...head, ...lines].join("\n"), module), {lib_module: module});
1946
+ let rule = (lines) => rule_report(parse_as([...head, ...lines].join("\n"), module), module);
1947
+ let found_in = (lines) => {
1948
+ let ast = parse_as(lines.join("\n"), module);
1949
+ return find_forwarders([module], () => ast, []);
1950
+ };
1951
+
1952
+ test("each element a translator handed to .map() / .forEach() walks is a key; a promise's value is none", () => {
1953
+ let r = scan([`const KINDS = ["kind a", "kind b"];`,
1954
+ `KINDS.map(t);`,
1955
+ `["literal a"].forEach(i18next.t);`,
1956
+ `Array.from(["from a"], t);`]);
1957
+ expect(["kind a", "kind b", "literal a", "from a"].every((k) => r.keys.has(k))).toBe(true);
1958
+ expect(rule([`const KINDS = ["kind a"];`, `KINDS.map(t);`])).toEqual([]);
1959
+ expect(rule([`function f(kw) {`,
1960
+ ` let rows = kw.rows;`,
1961
+ ` rows.map(t);`,
1962
+ ` load().then(t);`,
1963
+ ` /* i18n: data */ rows.forEach(t);`,
1964
+ ` load().catch(/* i18n: data */ i18next.t);`,
1965
+ ` [t("a")].map(t);`,
1966
+ `}`])).toEqual([
1967
+ `${module}:5 t() [map callback]: rows`,
1968
+ `${module}:6 t() [then callback]: load().then(t)`,
1969
+ `${module}:9 t() [map callback]: t("a") -- a TRANSLATED text where a key goes: hand the key`
1970
+ ]);
1971
+ /* A translator handed to a FUNCTION is how gobj-ui takes one: no callback of these */
1972
+ expect(rule([`function f(shell) { yui_shell_show_info(shell, "k", {t: t}); build(rows, t); }`])).toEqual([]);
1973
+ });
1974
+
1975
+ test("the search for forwarders sees them: an array walked by t(), setAttributeNS(), a destructured t", () => {
1976
+ let found = found_in([
1977
+ `const ATTR = "data-i18n-title";`,
1978
+ `export function f_map(gobj, keys) { return keys.map(t); }`,
1979
+ `export function f_ns(gobj, el, k) { el.setAttributeNS(null, ATTR, k); }`,
1980
+ `export function f_destr(gobj, {t: translate}, k) { return translate(k); }`,
1981
+ `export function f_const(gobj, k) { const {t: tt} = i18next; return tt(k); }`
1982
+ ]);
1983
+ expect(found.opaque).toEqual([]);
1984
+ expect(Object.fromEntries(found.found.map((f) => [f.name, f.keys]))).toEqual({
1985
+ f_map: ["1[]"], f_ns: ["2"], f_destr: ["2"], f_const: ["1"]
1986
+ });
1987
+ });
1988
+
1989
+ test("a destructured translator translates: its key is asked for, and its text is no key", () => {
1990
+ let r = scan([`const {t: tt} = i18next;`, `tt("a destructured key");`]);
1991
+ expect(r.keys.has("a destructured key")).toBe(true);
1992
+ expect(rule([`const {t: tt} = i18next;`, `yui_shell_show_info(shell, /* i18n: data */ tt("k"));`]))
1993
+ .toEqual([`${module}:4 yui_shell_show_info() [1]: tt("k") -- a TRANSLATED text where a key goes: hand the key`]);
1994
+ });
1995
+
1996
+ test("a text BUILT from a translation is translated text: at a key position, and under a marker anywhere", () => {
1997
+ expect(rule([`yui_shell_show_info(shell, /* i18n: data */ t("k") + ":");`,
1998
+ `yui_shell_show_info(shell, /* i18n: data */ t("k").toUpperCase());`,
1999
+ "yui_shell_show_info(shell, /* i18n: data */ `${t(\"k\")}: ${n}`);",
2000
+ `yui_shell_show_info(shell, /* i18n: data */ [t("a"), name].join(" "));`,
2001
+ `yui_shell_show_info(shell, /* i18n: data */ name + ":");`,
2002
+ `yui_shell_show_info(shell, /* i18n: data */ ["p", {}, t("k")]);`])
2003
+ .map((l) => l.replace(/ -- .*/, ""))).toEqual([
2004
+ `${module}:3 yui_shell_show_info() [1]: t("k") + ":"`,
2005
+ `${module}:4 yui_shell_show_info() [1]: t("k").toUpperCase()`,
2006
+ `${module}:5 yui_shell_show_info() [1]: \`\${t("k")}: \${n}\``,
2007
+ `${module}:6 yui_shell_show_info() [1]: [t("a"), name].join(" ")`
2008
+ ]);
2009
+ let r = scan([`t(/* i18n: data */ t("k") + "!");`,
2010
+ `x.i18n = /* i18n: data */ t("k").trim();`,
2011
+ `t(/* i18n: data */ kw.comment + "!");`]);
2012
+ expect(r.marker_errors.map((e) => e.line)).toEqual([3, 4]);
2013
+ expect(r.marker_errors[0].why).toMatch(/^marked as data, but `t\("k"\) \+ "!"` is a TRANSLATED text: .*hand the key, not t\(key\)$/);
2014
+ });
2015
+
2016
+ test("an UNMARKED let given nothing but literals is read where a key is read -- at a key position the rule still fails it", () => {
2017
+ let r = scan([`function f(x, el) {`,
2018
+ ` let k = x? "a let key" : "another let key";`,
2019
+ ` let held = "a held key";`,
2020
+ ` held = k;`, // a let given a literal-only let
2021
+ ` el.setAttribute("data-i18n", held);`,
2022
+ ` return t(k);`,
2023
+ `}`,
2024
+ `function g(kw) {`,
2025
+ ` let d = "a data let key";`,
2026
+ ` d = kw.comment;`,
2027
+ ` return t(d);`,
2028
+ `}`]);
2029
+ expect(["a let key", "another let key", "a held key"].every((k) => r.keys.has(k))).toBe(true);
2030
+ expect(r.keys.has("a data let key")).toBe(false);
2031
+ expect(r.marker_errors).toEqual([]);
2032
+ expect(rule([`function f(x) { let k = x? "a" : "b"; yui_shell_show_info(shell, k); }`]))
2033
+ .toEqual([`${module}:3 yui_shell_show_info() [1]: k`]);
2034
+ });
2035
+
2036
+ test("a published list mutated through a const or let alias, or a namespace member", () => {
2037
+ let ast = parse_source([
2038
+ `import * as M from "./m.js";`,
2039
+ `import {Y_I18N_KEYS} from "./y.js";`,
2040
+ `export const X_I18N_KEYS = ["x"];`,
2041
+ `const a = X_I18N_KEYS;`,
2042
+ `a.push("k");`, // 5
2043
+ `let b = Y_I18N_KEYS;`,
2044
+ `b[0] = "k";`, // 7
2045
+ `M.Z_I18N_KEYS.unshift("k");`, // 8
2046
+ `const z = M.Z_I18N_KEYS;`,
2047
+ `delete z[0];`, // 10
2048
+ `let c = X_I18N_KEYS;`,
2049
+ `c = c.concat(["k"]);`,
2050
+ `c.push("a copy");`,
2051
+ `M.helper.push("not a list");`,
2052
+ `function f(a) { a.push("a parameter"); }`,
2053
+ `let d = X_I18N_KEYS;`,
2054
+ `d = X_I18N_KEYS;`, // the alias pointed again: no mutation
2055
+ `X_I18N_KEYS = [];` // 18: the list itself given another value
2056
+ ].join("\n"));
2057
+ expect(list_mutations(ast, [{name: "X_I18N_KEYS", keys: ["x"]}]).map((e) => e.line)).toEqual([5, 7, 8, 10, 18]);
2058
+ });
2059
+ });
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Utilities for maplibre
5
5
  *
6
- * Copyright (c) 2025, ArtGins.
6
+ * Copyright (c) 2025-2026, ArtGins.
7
7
  * All Rights Reserved.
8
8
  ***********************************************************************/
9
9
  import {
@@ -283,6 +283,23 @@ function label_ctrl($root, selector, text)
283
283
  }
284
284
  }
285
285
 
286
+ /* What maplibre wrote ONCE from its locale and never again: the name
287
+ * of the map's canvas (`Map.Title`: a region a keyboard focuses, so a
288
+ * reader announces it) and of a default marker (`Marker.Title`).
289
+ * Rewritten where it still holds the word maplibre wrote -- a name an
290
+ * app gave a marker is its own. */
291
+ function relabel_written($root, selector, was, now)
292
+ {
293
+ if(!$root || !now) {
294
+ return;
295
+ }
296
+ for(const $el of $root.querySelectorAll(selector)) {
297
+ if($el.getAttribute("aria-label") === was) {
298
+ $el.setAttribute("aria-label", now);
299
+ }
300
+ }
301
+ }
302
+
286
303
  function yui_maplibre_relocalize(map, t)
287
304
  {
288
305
  if(!map || typeof map.getContainer !== "function") {
@@ -291,7 +308,9 @@ function yui_maplibre_relocalize(map, t)
291
308
  const strings = yui_maplibre_locale(t);
292
309
 
293
310
  /* The map's own table first: a control added later (or a popup
294
- * opened later) reads it when it builds itself. */
311
+ * opened later) reads it when it builds itself. What it said
312
+ * before is kept, to know which names maplibre wrote with it. */
313
+ const was = Object.assign({}, map._locale);
295
314
  try {
296
315
  map._locale = Object.assign({}, map._locale, strings);
297
316
  } catch(e) {
@@ -308,6 +327,11 @@ function yui_maplibre_relocalize(map, t)
308
327
  label_ctrl($c, ".maplibregl-ctrl-fullscreen", strings["FullscreenControl.Enter"]);
309
328
  label_ctrl($c, ".maplibregl-popup-close-button", strings["Popup.Close"]);
310
329
 
330
+ /* The canvas said "Mapa" in an English app after a switch, and a
331
+ * default marker "Marcador del mapa": maplibre names them once. */
332
+ relabel_written($c, ".maplibregl-canvas", was["Map.Title"], strings["Map.Title"]);
333
+ relabel_written($c, ".maplibregl-marker", was["Marker.Title"], strings["Marker.Title"]);
334
+
311
335
  /* The notice of the cooperative gesture: two lines, one for a
312
336
  * pointer and one for a finger, built by `_setupUI()` as
313
337
  * `.maplibregl-desktop-message` / `.maplibregl-mobile-message`. */