@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
@@ -209,6 +209,38 @@ function errors()
209
209
  return logged.filter((l) => l.level === "error" || l.level === "warning").map((l) => l.msg);
210
210
  }
211
211
 
212
+ /* The NAME a reader computes for a control from its aria-labelledby,
213
+ * as the accessible-name algorithm does: a reference to the control
214
+ * itself reads its aria-label, any other the text of the element it
215
+ * points at (a hidden one too, it is referenced directly), joined by
216
+ * spaces. Without one, its aria-label. */
217
+ function name_of($control)
218
+ {
219
+ const ids = ($control.getAttribute("aria-labelledby") || "").split(/\s+/).filter(Boolean);
220
+ if(ids.length === 0) {
221
+ return $control.getAttribute("aria-label");
222
+ }
223
+ /* An id is looked up in the control's own tree: a nav made on its
224
+ * own in a test is not on the page. */
225
+ let $top = $control;
226
+ while($top.parentNode) {
227
+ $top = $top.parentNode;
228
+ }
229
+ return ids.map((id) => {
230
+ if(id === $control.id) {
231
+ return $control.getAttribute("aria-label") || "";
232
+ }
233
+ const $el = $top.querySelector(`#${id}`);
234
+ expect(!!$el, `aria-labelledby points at '${id}', which is not in its tree`).toBe(true);
235
+ return $el.textContent;
236
+ }).join(" ").trim();
237
+ }
238
+
239
+ function said(shell)
240
+ {
241
+ return root(shell).querySelector(".SHELL_BADGE_STATUS").textContent;
242
+ }
243
+
212
244
 
213
245
  describe("a badge declared in app_config.json", () => {
214
246
  test("renders: after the label in the rail, on the icon in the icon bar", () => {
@@ -216,7 +248,7 @@ describe("a badge declared in app_config.json", () => {
216
248
 
217
249
  const $rail = badge_in(zone(shell, "left"), "alarms");
218
250
  expect(shows($rail)).toEqual({text: "2", tones: ["warning"]});
219
- expect($rail.getAttribute("role")).toBe("status");
251
+ expect($rail.getAttribute("role")).toBe(null); // the shell says it now
220
252
  expect($rail.classList.contains("yui-badge")).toBe(true);
221
253
  expect($rail.classList.contains("yui-nav-badge-pinned")).toBe(false);
222
254
  expect($rail.parentNode.tagName).toBe("A");
@@ -562,3 +594,152 @@ describe("the toolbar badge takes a tone too", () => {
562
594
  expect(e[0]).toContain("yui_shell_set_toolbar_item_badge('bell')");
563
595
  });
564
596
  });
597
+
598
+ /*
599
+ * REV-CLOUD V23.5.5: what a READER gets. Up to 7.26.34 the badge was its
600
+ * own live region: the count was never part of the item's name, the
601
+ * region said a bare number, it was hidden while empty (so 0 -> 1 was
602
+ * most likely never said), and inside a <button> (the toolbar, an
603
+ * accordion head) it was not in the tree at all.
604
+ */
605
+ describe("a reader gets the count with the name of what it counts", () => {
606
+ function control_of($badge)
607
+ {
608
+ return $badge.closest("a, button");
609
+ }
610
+
611
+ test("the count is part of the item's name, in every layout that draws one", () => {
612
+ const shell = new_shell();
613
+ for(const $b of badges_in(root(shell), "alarms")) {
614
+ const $a = control_of($b);
615
+ /* rail and icon bar seed 2; the drawer's entry declares none */
616
+ const expected = shows($b).text ? `alarms ${shows($b).text}` : "alarms";
617
+ expect(name_of($a)).toBe(expected);
618
+ }
619
+ yui_shell_set_nav_item_badge(shell, "alarms", 7);
620
+ for(const $b of badges_in(root(shell), "alarms")) {
621
+ expect(name_of(control_of($b))).toBe("alarms 7");
622
+ }
623
+ yui_shell_set_nav_item_badge(shell, "alarms", 0);
624
+ for(const $b of badges_in(root(shell), "alarms")) {
625
+ expect(name_of(control_of($b))).toBe("alarms");
626
+ }
627
+ });
628
+
629
+ test("tabs, cards and an accordion head -- a <button> -- too", () => {
630
+ let $c = root(new_nav({layout: "tabs", menu_id: "lab",
631
+ menu_items: [{id: "t", name: "t", icon: "yi-eye", route: "/t", badge: 2}]}));
632
+ expect(name_of(control_of(badge_in($c, "t")))).toBe("t 2");
633
+
634
+ $c = root(new_nav({layout: "cards", menu_id: "lab",
635
+ menu_items: [{id: "c", name: "c", icon: "yi-eye", route: "/c", badge: 3}]}));
636
+ expect(name_of(control_of(badge_in($c, "c")))).toBe("c 3");
637
+
638
+ $c = root(new_nav({layout: "accordion", menu_id: "lab",
639
+ menu_items: [{id: "s", name: "section", route: "/s", badge: 4, submenu: {items: []}}]}));
640
+ const $head = $c.querySelector(".yui-accordion-head");
641
+ expect($head.tagName).toBe("BUTTON");
642
+ expect(name_of($head)).toBe("section 4");
643
+ });
644
+
645
+ test("the toolbar's button too", () => {
646
+ const shell = new_shell();
647
+ const $btn = root(shell).querySelector('[data-toolbar-item-id="bell"]');
648
+ expect($btn.tagName).toBe("BUTTON");
649
+ expect(name_of($btn)).toBe("alarms 4");
650
+ yui_shell_set_toolbar_item_badge(shell, "bell", 9);
651
+ expect(name_of($btn)).toBe("alarms 9");
652
+ });
653
+
654
+ test("the name changes language with the app, the count stays", async () => {
655
+ const i18next = (await import("i18next")).default;
656
+ i18next.addResourceBundle("es", "translation", {"alarms": "alarmas"}, true, true);
657
+ const shell = new_shell();
658
+ yui_shell_set_nav_item_badge(shell, "alarms", 5);
659
+ yui_shell_set_translator(shell, i18next.getFixedT("es"));
660
+ yui_shell_language_changed(shell);
661
+ const $a = zone(shell, "left").querySelector('a[data-item-id="alarms"]');
662
+ expect(name_of($a)).toBe("alarmas 5");
663
+ });
664
+
665
+ test("one live region, always in the tree: not hidden, inside no control", () => {
666
+ const shell = new_shell();
667
+ const $status = root(shell).querySelector(".SHELL_BADGE_STATUS");
668
+ expect($status.getAttribute("role")).toBe("status");
669
+ expect($status.getAttribute("aria-live")).toBe("polite");
670
+ expect($status.getAttribute("aria-atomic")).toBe("true");
671
+ expect($status.hasAttribute("hidden")).toBe(false);
672
+ expect($status.classList.contains("is-sr-only")).toBe(true);
673
+ expect($status.closest("a, button")).toBe(null);
674
+ expect(said(shell)).toBe("");
675
+ });
676
+
677
+ test("the first count is said, with the item's name -- once, though three navs draw it", async () => {
678
+ const i18next = (await import("i18next")).default;
679
+ i18next.addResourceBundle("en", "translation",
680
+ {"{{name}}, {{n}}": "{{name}}, {{n}}", "alarms": "Alarms", "home": "Home"}, true, true);
681
+ const shell = new_shell();
682
+ yui_shell_set_translator(shell, i18next.getFixedT("en"));
683
+
684
+ /* "home" has no badge: its first count is a change from nothing */
685
+ yui_shell_set_nav_item_badge(shell, "home", 1);
686
+ expect(said(shell)).toBe("Home, 1");
687
+
688
+ const writes = [];
689
+ const $status = root(shell).querySelector(".SHELL_BADGE_STATUS");
690
+ const real = Object.getOwnPropertyDescriptor(Object.getPrototypeOf($status), "textContent");
691
+ Object.defineProperty($status, "textContent", {
692
+ get() {
693
+ return real.get.call(this);
694
+ },
695
+ set(v) {
696
+ writes.push(v);
697
+ real.set.call(this, v);
698
+ },
699
+ configurable: true
700
+ });
701
+ yui_shell_set_nav_item_badge(shell, "alarms", 3);
702
+ expect(writes).toEqual(["Alarms, 3"]);
703
+ });
704
+
705
+ test("the same count is no news, a count going away is not either, one coming back is", async () => {
706
+ const i18next = (await import("i18next")).default;
707
+ i18next.addResourceBundle("en", "translation",
708
+ {"{{name}}, {{n}}": "{{name}}, {{n}}", "alarms": "Alarms"}, true, true);
709
+ const shell = new_shell();
710
+ yui_shell_set_translator(shell, i18next.getFixedT("en"));
711
+ const $status = root(shell).querySelector(".SHELL_BADGE_STATUS");
712
+
713
+ /* The seed is 2: setting 2 changes nothing a reader sees */
714
+ yui_shell_set_nav_item_badge(shell, "alarms", 2);
715
+ expect(said(shell)).toBe("");
716
+
717
+ yui_shell_set_nav_item_badge(shell, "alarms", 3);
718
+ expect(said(shell)).toBe("Alarms, 3");
719
+ const node = $status.firstChild;
720
+
721
+ yui_shell_set_nav_item_badge(shell, "alarms", 3, "danger"); // only the tone
722
+ expect($status.firstChild).toBe(node);
723
+
724
+ yui_shell_set_nav_item_badge(shell, "alarms", 0);
725
+ expect($status.firstChild).toBe(node); // not said
726
+
727
+ yui_shell_set_nav_item_badge(shell, "alarms", 3);
728
+ expect(said(shell)).toBe("Alarms, 3");
729
+ expect($status.firstChild).not.toBe(node); // said again
730
+ });
731
+
732
+ test("the toolbar's count is said with the button's name", async () => {
733
+ const i18next = (await import("i18next")).default;
734
+ i18next.addResourceBundle("en", "translation",
735
+ {"{{name}}, {{n}}": "{{name}}, {{n}}", "alarms": "Alarms"}, true, true);
736
+ const shell = new_shell();
737
+ yui_shell_set_translator(shell, i18next.getFixedT("en"));
738
+
739
+ yui_shell_set_toolbar_item_badge(shell, "bell", 4); // the seed: no news
740
+ expect(said(shell)).toBe("");
741
+ yui_shell_set_toolbar_item_badge(shell, "bell", 120);
742
+ expect(said(shell)).toBe("Alarms, 99+");
743
+ expect(errors()).toEqual([]);
744
+ });
745
+ });
@@ -32,7 +32,7 @@ import {
32
32
  log_error,
33
33
  } from "@yuneta/gobj-js";
34
34
 
35
- import {yui_shell_show_modal, yui_shell_popup_layer} from "./shell_modals.js";
35
+ import {yui_shell_show_modal} from "./shell_modals.js";
36
36
  import {yui_shell_nav_map} from "./c_yui_shell.js";
37
37
  import {attach_clear} from "./yui_inputs.js";
38
38
 
@@ -86,7 +86,7 @@ function render_node(node)
86
86
  let row = [];
87
87
  if(node.icon && /^yi-[a-z0-9-]+$/.test(node.icon)) {
88
88
  row.push(["span", {class: "icon ROUTEMAP_ICON"},
89
- [["i", {class: node.icon}]]]);
89
+ [["i", {class: node.icon, "aria-hidden": "true"}]]]);
90
90
  }
91
91
  row.push(["span", {class: "ROUTEMAP_NAME", i18n: node.label},
92
92
  text_node(node.label)]);
@@ -123,10 +123,22 @@ function render_node(node)
123
123
  (node.ref ? " ROUTEMAP_IS_REF" : "");
124
124
  let $row;
125
125
  if(node.route) {
126
- $row = createElement2(
127
- ["a", {class: "ROUTEMAP_LINK ROUTEMAP_ROW" + row_class,
128
- href: "#" + node.route,
129
- title: node.route}, row]);
126
+ /* Its NAME is the node's label, with its key (build_body()
127
+ * translates it with the rest): taken from its content, a link
128
+ * read the label, the route, the gclass, the event and "shown
129
+ * above" as one name. The title stays the route, data shown
130
+ * where the reader points; "you are here" is aria-current. */
131
+ let a_attrs = {class: "ROUTEMAP_LINK ROUTEMAP_ROW" + row_class,
132
+ href: "#" + node.route,
133
+ title: node.route};
134
+ if(node.label) {
135
+ a_attrs["aria-label"] = node.label;
136
+ a_attrs["data-i18n-aria-label"] = node.label;
137
+ }
138
+ if(node.current) {
139
+ a_attrs["aria-current"] = "page";
140
+ }
141
+ $row = createElement2(["a", a_attrs, row]);
130
142
  } else {
131
143
  $row = createElement2(
132
144
  ["span", {class: "ROUTEMAP_ROW ROUTEMAP_STRUCT" + row_class}, row]);
@@ -497,8 +509,13 @@ export function yui_shell_show_route_map(shell, opts)
497
509
  * `keep_on_navigate` gives it the same survives-navigation
498
510
  * behaviour there. */
499
511
  let $body = build_body(shell, t);
500
- let $parent = yui_shell_popup_layer(shell) ||
501
- (typeof document !== "undefined" &&
512
+ /* The WINDOW layer, where every window lives (the legacy
513
+ * '#top-layer' when a page has one): no `$parent` takes it. It was
514
+ * the popup layer, above the drawer and in the way of the
515
+ * toolbar's dropdowns, which opened BEHIND the map where they
516
+ * met it, and in an order of its own that no other window could
517
+ * rise above. */
518
+ let $parent = (typeof document !== "undefined" &&
502
519
  document.getElementById("top-layer")) || null;
503
520
  let win = gobj_create_service(WIN_NAME, "C_YUI_WINDOW", {
504
521
  $parent: $parent,
@@ -0,0 +1,201 @@
1
+ /***********************************************************************
2
+ * table_editor_name.wiring.test.js
3
+ *
4
+ * The box Tabulator draws in a cell while it is edited had no
5
+ * name: no label, no aria-label, no title (REV-CLOUD V23.5.6, seen
6
+ * in the alarms of an estadodelaire device). Both tables of the
7
+ * library that edit in place -- a table field of C_YUI_FORM and
8
+ * the inline editing of C_YUI_TREEDB_TOPIC_WITH_FORM -- now name
9
+ * it after its column, asked at the moment of editing: Tabulator
10
+ * calls a function `editorParams` with the cell, and its built-in
11
+ * editors write the `elementAttributes` it answers.
12
+ *
13
+ * Driven through the real gclasses on a document double, with a
14
+ * Tabulator double that keeps the settings it was built with.
15
+ *
16
+ * Copyright (c) 2026, ArtGins.
17
+ * All Rights Reserved.
18
+ ***********************************************************************/
19
+ import {describe, test, expect, beforeAll, afterEach, vi} from "vitest";
20
+ import {install_dom_double} from "../test/dom_double.js";
21
+
22
+ install_dom_double();
23
+
24
+ if(typeof globalThis.ResizeObserver === "undefined") {
25
+ globalThis.ResizeObserver = class {
26
+ observe() {}
27
+ unobserve() {}
28
+ disconnect() {}
29
+ };
30
+ }
31
+
32
+ const tables = vi.hoisted(() => []);
33
+ vi.mock("tabulator-tables", () => {
34
+ class FakeTabulator {
35
+ constructor($el, settings) {
36
+ this.$el = $el;
37
+ this.element = $el;
38
+ this.settings = settings;
39
+ this.options = settings;
40
+ this.externalEvents = {events: {}};
41
+ this.initialized = true;
42
+ tables.push(this);
43
+ return new Proxy(this, {
44
+ get(target, prop) {
45
+ if(prop in target) {
46
+ return target[prop];
47
+ }
48
+ return () => [];
49
+ }
50
+ });
51
+ }
52
+ on(ev, fn) {
53
+ (this.externalEvents.events[ev] = this.externalEvents.events[ev] || []).push(fn);
54
+ }
55
+ off(ev) {
56
+ delete this.externalEvents.events[ev];
57
+ }
58
+ getRows() {
59
+ return [];
60
+ }
61
+ destroy() {
62
+ }
63
+ }
64
+ return {TabulatorFull: FakeTabulator, Tabulator: FakeTabulator};
65
+ });
66
+
67
+ /* No json field is opened here, but the form imports the editor. */
68
+ vi.mock("vanilla-jsoneditor", () => ({
69
+ createJSONEditor: () => ({
70
+ get() {
71
+ return {text: ""};
72
+ },
73
+ set() {},
74
+ destroy() {},
75
+ }),
76
+ }));
77
+
78
+ const {
79
+ SDATA_END, gclass_create,
80
+ gobj_start_up, gobj_create_yuno, gobj_create_pure_child, gobj_create_service,
81
+ gobj_start, gobj_write_bool_attr,
82
+ } = await import("@yuneta/gobj-js");
83
+ const {register_c_yui_form} = await import("./c_yui_form.js");
84
+ const {register_c_yui_treedb_topic_with_form} = await import("./c_yui_treedb_topic_with_form.js");
85
+
86
+ let i18next = null;
87
+ let yuno = null;
88
+ let seq = 0;
89
+
90
+ beforeAll(async () => {
91
+ i18next = (await import("i18next")).default;
92
+ await i18next.init({
93
+ lng: "en",
94
+ resources: {
95
+ en: {translation: {"threshold": "Threshold", "users.name": "User name"}},
96
+ es: {translation: {"threshold": "Umbral", "users.name": "Nombre del usuario"}}
97
+ }
98
+ });
99
+ gobj_start_up(null, null, null, null, null, null, null);
100
+ gclass_create("C_TEST_EDITOR_HOST", [], [["ST_IDLE", []]], {}, 0, [SDATA_END()], {}, 0, 0, 0, 0);
101
+ gclass_create(
102
+ "C_TEST_EDITOR_APP",
103
+ [["EV_RECORD_CHANGED", 0]],
104
+ [["ST_IDLE", [["EV_RECORD_CHANGED", () => 0, null]]]],
105
+ {}, 0, [SDATA_END()], {}, 0, 0, 0, 0
106
+ );
107
+ register_c_yui_form();
108
+ register_c_yui_treedb_topic_with_form();
109
+ yuno = gobj_create_yuno("editor_name_yuno", "C_TEST_EDITOR_HOST", {});
110
+ gobj_start(yuno);
111
+ });
112
+
113
+ afterEach(async () => {
114
+ tables.length = 0;
115
+ await i18next.changeLanguage("en");
116
+ });
117
+
118
+ function column(table, field)
119
+ {
120
+ return (table.settings.columns || []).find((c) => c.field === field);
121
+ }
122
+
123
+ /* What Tabulator hands the editor when a cell of `col` starts editing. */
124
+ function editor_params(col)
125
+ {
126
+ return (typeof col.editorParams === "function")
127
+ ? col.editorParams({getValue: () => ""})
128
+ : (col.editorParams || {});
129
+ }
130
+
131
+ describe("a table field of C_YUI_FORM", () => {
132
+ test("names the editor of an editable cell after its column, in the language of the moment", async () => {
133
+ const app = gobj_create_pure_child(`eapp${++seq}`, "C_TEST_EDITOR_APP", {}, yuno);
134
+ gobj_start(app);
135
+ const form = gobj_create_pure_child(`eform${seq}`, "C_YUI_FORM", {
136
+ template: {
137
+ triggers: {
138
+ type: "array",
139
+ flag: ["persistent", "writable", "table"],
140
+ table: {
141
+ id: {type: "string", flag: ["writable", "persistent", "required"]},
142
+ value: {type: "string", header: "threshold", flag: ["writable", "persistent"]},
143
+ note: {type: "string", flag: ["persistent"]} // not writable
144
+ }
145
+ }
146
+ },
147
+ record: {triggers: []},
148
+ editable: true,
149
+ }, app);
150
+ gobj_start(form);
151
+ expect(tables.length).toBe(1);
152
+
153
+ const value = column(tables[0], "value");
154
+ expect(value.editor).toBe("input");
155
+ expect(editor_params(value).elementAttributes).toEqual(
156
+ {"aria-label": "Threshold", title: "Threshold"});
157
+
158
+ await i18next.changeLanguage("es");
159
+ expect(editor_params(value).elementAttributes).toEqual(
160
+ {"aria-label": "Umbral", title: "Umbral"});
161
+
162
+ /* A column nobody edits has no editor to name: what params it
163
+ * had for one are left as they were. */
164
+ const note = column(tables[0], "note");
165
+ expect(note.editor).toBe(undefined);
166
+ expect(typeof note.editorParams).not.toBe("function");
167
+ });
168
+ });
169
+
170
+ describe("the inline editing of C_YUI_TREEDB_TOPIC_WITH_FORM", () => {
171
+ test("names the editor after the header's own cascade, in the language of the moment", async () => {
172
+ const form = gobj_create_service(`etopic${++seq}`, "C_YUI_TREEDB_TOPIC_WITH_FORM", {
173
+ treedb_name: "treedb_test",
174
+ topic_name: "users",
175
+ desc: {
176
+ id: "users",
177
+ pkey: "id",
178
+ cols: [
179
+ {id: "id", header: "Id", type: "string", flag: ["persistent", "required"]},
180
+ {id: "name", header: "Name", type: "string", flag: ["persistent", "writable"]}
181
+ ]
182
+ }
183
+ }, yuno);
184
+ gobj_write_bool_attr(form, "editable", true);
185
+ gobj_start(form);
186
+ expect(tables.length > 0).toBe(true);
187
+ const table = tables[tables.length - 1];
188
+
189
+ const name = column(table, "name");
190
+ expect(name.editor).toBe("input");
191
+ expect(editor_params(name).elementAttributes).toEqual(
192
+ {"aria-label": "User name", title: "User name"});
193
+
194
+ await i18next.changeLanguage("es");
195
+ expect(editor_params(name).elementAttributes).toEqual(
196
+ {"aria-label": "Nombre del usuario", title: "Nombre del usuario"});
197
+
198
+ /* The pkey is never edited in place. */
199
+ expect(column(table, "id").editor).toBe(undefined);
200
+ });
201
+ });
@@ -0,0 +1,151 @@
1
+ /***********************************************************************
2
+ * treedb_column_title.wiring.test.js
3
+ *
4
+ * The header of a column of C_YUI_TREEDB_TOPIC_WITH_FORM is
5
+ * translated by a CASCADE -- '<topic>.<col>', then '<col>', then
6
+ * the schema's header -- and carries the key a language change
7
+ * re-translates it by (data-i18n). Up to 7.26.34 that key was
8
+ * always the bare '<col>', whatever key had translated it:
9
+ *
10
+ * (a) a header translated by '<topic>.<col>' alone carried no
11
+ * key, and stayed in its language;
12
+ * (b) one translated by both went to the bare column's text.
13
+ *
14
+ * The apps with a shell did not show it: the view rebuilds its
15
+ * columns on EV_LANGUAGE_CHANGED, after refresh_language(), and
16
+ * the formatter runs the cascade again. A refresh_language() alone
17
+ * did. The twin of C_YUI_FORM's field labels
18
+ * (form_label_i18n.wiring.test.js).
19
+ *
20
+ * The real gclass on a document double, with a Tabulator double
21
+ * that keeps the columns it was built with.
22
+ *
23
+ * Copyright (c) 2026, ArtGins.
24
+ * All Rights Reserved.
25
+ ***********************************************************************/
26
+ import {describe, test, expect, beforeAll, afterEach, vi} from "vitest";
27
+ import {install_dom_double} from "../test/dom_double.js";
28
+
29
+ install_dom_double();
30
+
31
+ if(typeof globalThis.ResizeObserver === "undefined") {
32
+ globalThis.ResizeObserver = class {
33
+ observe() {}
34
+ unobserve() {}
35
+ disconnect() {}
36
+ };
37
+ }
38
+
39
+ const tables = vi.hoisted(() => []);
40
+ vi.mock("tabulator-tables", () => {
41
+ class FakeTabulator {
42
+ constructor($el, settings) {
43
+ this.element = $el;
44
+ this.options = settings;
45
+ this.externalEvents = {events: {}};
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
+ destroy() {}
63
+ }
64
+ return {TabulatorFull: FakeTabulator, Tabulator: FakeTabulator};
65
+ });
66
+
67
+ const {
68
+ SDATA_END, gclass_create,
69
+ gobj_start_up, gobj_create_yuno, gobj_create_service, gobj_start,
70
+ } = await import("@yuneta/gobj-js");
71
+ const {register_c_yui_treedb_topic_with_form} = await import("./c_yui_treedb_topic_with_form.js");
72
+
73
+ let i18next = null;
74
+ let yuno = null;
75
+ let seq = 0;
76
+
77
+ beforeAll(async () => {
78
+ i18next = (await import("i18next")).default;
79
+ await i18next.init({
80
+ lng: "en",
81
+ resources: {
82
+ en: {translation: {
83
+ "users.alias": "User alias", // (a)
84
+ "users.title": "User title", "title": "Title", // (b)
85
+ "email": "E-mail" // the column alone
86
+ }},
87
+ es: {translation: {
88
+ "users.alias": "Alias del usuario",
89
+ "users.title": "Título del usuario", "title": "Título",
90
+ "email": "Correo"
91
+ }}
92
+ }
93
+ });
94
+ gobj_start_up(null, null, null, null, null, null, null);
95
+ gclass_create("C_TEST_TITLE_HOST", [], [["ST_IDLE", []]], {}, 0, [SDATA_END()], {}, 0, 0, 0, 0);
96
+ register_c_yui_treedb_topic_with_form();
97
+ yuno = gobj_create_yuno("column_title_yuno", "C_TEST_TITLE_HOST", {});
98
+ gobj_start(yuno);
99
+ });
100
+
101
+ afterEach(async () => {
102
+ tables.length = 0;
103
+ await i18next.changeLanguage("en");
104
+ });
105
+
106
+ /* The header a column draws, as Tabulator asks its formatter. */
107
+ function header_of(field)
108
+ {
109
+ const view = gobj_create_service(`title_view${++seq}`, "C_YUI_TREEDB_TOPIC_WITH_FORM", {
110
+ treedb_name: "treedb_test",
111
+ topic_name: "users",
112
+ desc: {id: "users", pkey: "id", cols: [
113
+ {id: "id", header: "Id", type: "string", flag: ["persistent", "required"]},
114
+ {id: field, header: field, type: "string", flag: ["persistent"]}
115
+ ]}
116
+ }, yuno);
117
+ gobj_start(view);
118
+ const col = tables[tables.length - 1].options.columns.find((c) => c.field === field);
119
+ expect(typeof col.titleFormatter).toBe("function");
120
+ return col.titleFormatter({});
121
+ }
122
+
123
+ describe("the header of a column changes language by the key that translated it", () => {
124
+ test("(a) translated by '<topic>.<col>' alone", async () => {
125
+ const $h = header_of("alias");
126
+ expect($h.textContent).toBe("User alias");
127
+ expect($h.getAttribute("data-i18n")).toBe("users.alias");
128
+ await i18next.changeLanguage("es");
129
+ expect(i18next.t($h.getAttribute("data-i18n"))).toBe("Alias del usuario");
130
+ });
131
+
132
+ test("(b) translated by both: the topic's key, before and after", async () => {
133
+ const $h = header_of("title");
134
+ expect($h.textContent).toBe("User title");
135
+ expect($h.getAttribute("data-i18n")).toBe("users.title");
136
+ await i18next.changeLanguage("es");
137
+ expect(i18next.t($h.getAttribute("data-i18n"))).toBe("Título del usuario");
138
+ });
139
+
140
+ test("translated by '<col>' alone, as before", () => {
141
+ const $h = header_of("email");
142
+ expect($h.textContent).toBe("E-mail");
143
+ expect($h.getAttribute("data-i18n")).toBe("email");
144
+ });
145
+
146
+ test("translated by nothing: the schema's header, with no key, as before", () => {
147
+ const $h = header_of("zone");
148
+ expect($h.textContent).toBe("zone");
149
+ expect($h.getAttribute("data-i18n")).toBe(null);
150
+ });
151
+ });