@yuneta/gobj-ui 7.26.27 → 7.26.29
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 +590 -25
- package/dist/gobj-ui.cjs.js +1080 -229
- package/dist/gobj-ui.es.js +1081 -230
- package/package.json +4 -2
- package/scripts/validate-locales.mjs +2656 -315
- package/src/c_g6_nodes_tree.js +1086 -253
- package/src/c_yui_form.js +16 -4
- package/src/c_yui_fsm_graph.js +5 -0
- package/src/c_yui_gclass.js +11 -0
- package/src/c_yui_gobj_tree_js.js +18 -1
- package/src/c_yui_json_graph.js +5 -0
- package/src/c_yui_nav.js +21 -16
- package/src/c_yui_pager.js +1 -1
- package/src/c_yui_period.js +10 -3
- package/src/c_yui_schema_editor.js +34 -18
- package/src/c_yui_shell.js +11 -10
- package/src/c_yui_treedb_graph.js +17 -9
- package/src/c_yui_treedb_schema.js +5 -0
- package/src/c_yui_treedb_topic_with_form.js +9 -9
- package/src/c_yui_treedb_topics.js +13 -13
- package/src/c_yui_window.js +2 -2
- package/src/c_yui_window_manager.js +5 -2
- package/src/c_yui_wizard.js +8 -1
- package/src/form_json_field.js +7 -0
- package/src/g6_canvas_tab_order.js +84 -0
- package/src/g6_canvas_tab_order.wiring.test.js +232 -0
- package/src/g6_nodes_tree_controls.test.js +547 -0
- package/src/g6_nodes_tree_focus.wiring.test.js +1360 -0
- package/src/g6_nodes_tree_i18n_keys.test.js +241 -5
- package/src/i18n_key_functions.js +235 -0
- package/src/i18n_key_functions.test.js +1525 -0
- package/src/lib_graph.js +38 -1
- package/src/nav_backbar_name.wiring.test.js +82 -0
- package/src/schema_flags.js +47 -0
- package/src/schema_import.js +7 -0
- package/src/shell_modals.js +20 -16
- package/src/shell_route_map.js +13 -3
- package/src/wizard_helpers.js +8 -0
- package/src/yui_dev.js +16 -3
- package/src/yui_dev_controls.test.js +174 -0
- package/src/yui_gclass_view.js +1 -1
- package/src/yui_graph_camera.js +1 -1
- package/src/yui_install.js +10 -8
- package/src/yui_table_select.js +2 -2
- package/src/yui_time.js +17 -2
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
/***********************************************************************
|
|
2
|
+
* g6_nodes_tree_controls.test.js
|
|
3
|
+
*
|
|
4
|
+
* The buttons C_G6_NODES_TREE draws as `div`s -- the six floating
|
|
5
|
+
* icons beside a selection (node and edge gear and trash, link,
|
|
6
|
+
* port gear), the ✕ of the detail popover and the items of its two
|
|
7
|
+
* G6 toolbars -- are buttons for the keyboard and for a language
|
|
8
|
+
* switch too (REV-CLOUD V20.5.5). They had a translated title and
|
|
9
|
+
* aria-label and nothing else: no role, no tabindex, no key, so
|
|
10
|
+
* they could not be reached without a pointer and kept the
|
|
11
|
+
* language they were drawn in.
|
|
12
|
+
*
|
|
13
|
+
* Enter and Space must do exactly what the click does: they CLICK
|
|
14
|
+
* the element, and the key stops there, because the graph's
|
|
15
|
+
* container forwards every keydown to G6. And the click does one
|
|
16
|
+
* thing (V20.5 #7): it SENDS an input event of the gclass, with
|
|
17
|
+
* the plain identity of what the button belongs to; the action
|
|
18
|
+
* does the work, where the `machine` trace sees it.
|
|
19
|
+
*
|
|
20
|
+
* A POINTER press does not take the focus (preventDefault on
|
|
21
|
+
* pointerdown): a button that took it and was then removed left
|
|
22
|
+
* the focus on <body> in Firefox, and the graph deaf (V20.5 #1).
|
|
23
|
+
*
|
|
24
|
+
* A DISABLED item says so to the keyboard and to a reader too
|
|
25
|
+
* (V20.5 #5, third pass): `aria-disabled` and out of the Tab
|
|
26
|
+
* order, from its first render and through every
|
|
27
|
+
* disableElements / enableElements; a TOGGLE says its state in
|
|
28
|
+
* `aria-pressed`; and a rebuild gives the focus back to the same
|
|
29
|
+
* CONTROL, whatever state it shows (enter → exit full screen).
|
|
30
|
+
*
|
|
31
|
+
* The double delivers an event to its target only; dispatch()
|
|
32
|
+
* below bubbles it the way a browser does, so a toolbar item's
|
|
33
|
+
* key reaches the toolbar's delegated listener and its click
|
|
34
|
+
* reaches G6's own handler.
|
|
35
|
+
*
|
|
36
|
+
* Copyright (c) 2026, ArtGins.
|
|
37
|
+
* All Rights Reserved.
|
|
38
|
+
***********************************************************************/
|
|
39
|
+
import {describe, test, expect, beforeAll, beforeEach} from "vitest";
|
|
40
|
+
import {install_dom_double, FakeElement} from "../test/dom_double.js";
|
|
41
|
+
|
|
42
|
+
install_dom_double();
|
|
43
|
+
|
|
44
|
+
const {
|
|
45
|
+
SDATA_END, gclass_create, gobj_start_up, gobj_create_yuno,
|
|
46
|
+
refresh_language, set_log_callback,
|
|
47
|
+
} = await import("@yuneta/gobj-js");
|
|
48
|
+
const {
|
|
49
|
+
create_floating_icon, create_detail_close, toolbar_item_html, YuiToolbar,
|
|
50
|
+
} = await import("./c_g6_nodes_tree.js");
|
|
51
|
+
const {disableElements, enableElements} = await import("./lib_graph.js");
|
|
52
|
+
|
|
53
|
+
let i18next = null;
|
|
54
|
+
|
|
55
|
+
/* What reached the sink's machine, in order. */
|
|
56
|
+
const received = [];
|
|
57
|
+
const logged = [];
|
|
58
|
+
let sink = null;
|
|
59
|
+
|
|
60
|
+
/* A DOM event as the browser dispatches it: at the target, then up
|
|
61
|
+
* its ancestors, until a listener stops it. */
|
|
62
|
+
function dispatch(target, type, init)
|
|
63
|
+
{
|
|
64
|
+
const ev = Object.assign({
|
|
65
|
+
type: type, target: target,
|
|
66
|
+
prevented: false, stopped: false,
|
|
67
|
+
preventDefault() {
|
|
68
|
+
this.prevented = true;
|
|
69
|
+
},
|
|
70
|
+
stopPropagation() {
|
|
71
|
+
this.stopped = true;
|
|
72
|
+
},
|
|
73
|
+
}, init || {});
|
|
74
|
+
for(let n = target; n && !ev.stopped; n = n.parentNode) {
|
|
75
|
+
ev.currentTarget = n;
|
|
76
|
+
for(const fn of [...((n.listeners && n.listeners[type]) || [])]) {
|
|
77
|
+
fn.call(n, ev);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return ev;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/* A browser's click() bubbles; the double's does not. */
|
|
84
|
+
FakeElement.prototype.click = function() {
|
|
85
|
+
dispatch(this, "click");
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/* A browser's focus() moves document.activeElement; the double's
|
|
89
|
+
* does nothing. */
|
|
90
|
+
FakeElement.prototype.focus = function() {
|
|
91
|
+
document.activeElement = this;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
beforeAll(async () => {
|
|
95
|
+
i18next = (await import("i18next")).default;
|
|
96
|
+
await i18next.init({lng: "en", resources: {
|
|
97
|
+
en: {translation: {
|
|
98
|
+
"node properties": "Node properties", "close": "Close", "zoom in": "Zoom in",
|
|
99
|
+
}},
|
|
100
|
+
es: {translation: {
|
|
101
|
+
"node properties": "Propiedades del nodo", "close": "Cerrar", "zoom in": "Acercar",
|
|
102
|
+
}},
|
|
103
|
+
}});
|
|
104
|
+
gobj_start_up(null, null, null, null, null, null, null);
|
|
105
|
+
set_log_callback((level, msg) => {
|
|
106
|
+
logged.push({level: String(level), msg: String(msg)});
|
|
107
|
+
});
|
|
108
|
+
const events = ["EV_TOGGLE_NODE_POPOVER", "EV_CLOSE_NODE_DETAIL"];
|
|
109
|
+
gclass_create(
|
|
110
|
+
"C_TEST_CONTROLS_SINK",
|
|
111
|
+
events.map((e) => [e, 0]),
|
|
112
|
+
[["ST_IDLE", events.map((e) => [e, (gobj, ev, kw) => {
|
|
113
|
+
received.push({event: ev, kw: kw});
|
|
114
|
+
return 0;
|
|
115
|
+
}, null])]],
|
|
116
|
+
{}, 0, [SDATA_END()], {}, 0, 0, 0, 0
|
|
117
|
+
);
|
|
118
|
+
sink = gobj_create_yuno("controls_yuno", "C_TEST_CONTROLS_SINK", {});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
beforeEach(() => {
|
|
122
|
+
received.length = 0;
|
|
123
|
+
logged.length = 0;
|
|
124
|
+
document.activeElement = document.body;
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const BUILDERS = {
|
|
128
|
+
"a floating icon": {
|
|
129
|
+
build: () => create_floating_icon("gear", "#1890ff", 0, 0, "node properties",
|
|
130
|
+
sink, "EV_TOGGLE_NODE_POPOVER", {node_id: "node-a"}),
|
|
131
|
+
sent: {event: "EV_TOGGLE_NODE_POPOVER", kw: {node_id: "node-a"}},
|
|
132
|
+
},
|
|
133
|
+
"the detail popover's close": {
|
|
134
|
+
build: () => create_detail_close(sink, "#64748b", "node-a"),
|
|
135
|
+
sent: {event: "EV_CLOSE_NODE_DETAIL", kw: {node_id: "node-a"}},
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/* A key press on `el`, inside a host that records what reaches it. */
|
|
140
|
+
function press(el, key)
|
|
141
|
+
{
|
|
142
|
+
const host = document.createElement("div");
|
|
143
|
+
const reached = [];
|
|
144
|
+
host.addEventListener("keydown", (e) => {
|
|
145
|
+
reached.push(e.key);
|
|
146
|
+
});
|
|
147
|
+
host.appendChild(el);
|
|
148
|
+
const ev = dispatch(el, "keydown", {key: key});
|
|
149
|
+
return {ev, reached};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
describe("C_G6_NODES_TREE draws its div buttons as buttons", () => {
|
|
153
|
+
for(const [what, {build, sent}] of Object.entries(BUILDERS)) {
|
|
154
|
+
describe(what, () => {
|
|
155
|
+
test("is a button a keyboard can reach", () => {
|
|
156
|
+
const el = build();
|
|
157
|
+
expect(el.getAttribute("role")).toBe("button");
|
|
158
|
+
expect(el.getAttribute("tabindex")).toBe("0");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("is named, and each name carries its key", () => {
|
|
162
|
+
const el = build();
|
|
163
|
+
const key = el.getAttribute("data-i18n-title");
|
|
164
|
+
expect(key).toBeTruthy();
|
|
165
|
+
expect(el.getAttribute("data-i18n-aria-label")).toBe(key);
|
|
166
|
+
expect(el.getAttribute("title")).toBe(i18next.t(key));
|
|
167
|
+
expect(el.getAttribute("aria-label")).toBe(i18next.t(key));
|
|
168
|
+
expect(el.getAttribute("title")).not.toBe(key);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("a click SENDS its event to the gobj, with the identity, once", () => {
|
|
172
|
+
const el = build();
|
|
173
|
+
const ev = dispatch(el, "click");
|
|
174
|
+
expect(received).toEqual([sent]);
|
|
175
|
+
/* Stopped, as it always was: the press is the button's,
|
|
176
|
+
* not the canvas's under it. */
|
|
177
|
+
expect(ev.stopped).toBe(true);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("each click sends its own copy of the identity", () => {
|
|
181
|
+
const el = build();
|
|
182
|
+
el.click();
|
|
183
|
+
received[0].kw.node_id = "changed by an action";
|
|
184
|
+
el.click();
|
|
185
|
+
expect(received[1]).toEqual(sent);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("Enter and Space CLICK it, and the key goes no further", () => {
|
|
189
|
+
for(const key of ["Enter", " "]) {
|
|
190
|
+
const {ev, reached} = press(build(), key);
|
|
191
|
+
expect(ev.prevented, key).toBe(true);
|
|
192
|
+
expect(ev.stopped, key).toBe(true);
|
|
193
|
+
expect(reached, key).toEqual([]);
|
|
194
|
+
}
|
|
195
|
+
/* The same path as the pointer: one event per key. */
|
|
196
|
+
expect(received).toEqual([sent, sent]);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("any other key is the graph's", () => {
|
|
200
|
+
for(const key of ["Escape", "Delete", "a", "Tab"]) {
|
|
201
|
+
const {ev, reached} = press(build(), key);
|
|
202
|
+
expect(ev.prevented, key).toBe(false);
|
|
203
|
+
expect(reached, key).toEqual([key]);
|
|
204
|
+
}
|
|
205
|
+
expect(received).toEqual([]);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("a pointer press does not take the focus", () => {
|
|
209
|
+
const el = build();
|
|
210
|
+
const ev = dispatch(el, "pointerdown");
|
|
211
|
+
expect(ev.prevented).toBe(true);
|
|
212
|
+
/* Only the default: the press still reaches the
|
|
213
|
+
* container, whose capture listener focuses the canvas. */
|
|
214
|
+
expect(ev.stopped).toBe(false);
|
|
215
|
+
expect(received).toEqual([]);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("a language switch renames it", async () => {
|
|
219
|
+
const el = build();
|
|
220
|
+
const key = el.getAttribute("data-i18n-title");
|
|
221
|
+
const english = el.getAttribute("title");
|
|
222
|
+
await i18next.changeLanguage("es");
|
|
223
|
+
try {
|
|
224
|
+
refresh_language(el, i18next.t);
|
|
225
|
+
expect(el.getAttribute("title")).toBe(i18next.t(key));
|
|
226
|
+
expect(el.getAttribute("aria-label")).toBe(i18next.t(key));
|
|
227
|
+
expect(el.getAttribute("title")).not.toBe(english);
|
|
228
|
+
} finally {
|
|
229
|
+
await i18next.changeLanguage("en");
|
|
230
|
+
}
|
|
231
|
+
refresh_language(el, i18next.t);
|
|
232
|
+
expect(el.getAttribute("title")).toBe(english);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
test("the floating icon is named by the key it is given", () => {
|
|
238
|
+
const el = create_floating_icon("gear", "#1890ff", 0, 0, "node properties",
|
|
239
|
+
sink, "EV_TOGGLE_NODE_POPOVER", {node_id: "x"});
|
|
240
|
+
expect(el.getAttribute("data-i18n-title")).toBe("node properties");
|
|
241
|
+
expect(el.getAttribute("title")).toBe("Node properties");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("the close keeps the class its stylesheet finds it by", () => {
|
|
245
|
+
const el = create_detail_close(sink, "#64748b", "x");
|
|
246
|
+
expect(el.classList.contains("g6-detail-close")).toBe(true);
|
|
247
|
+
expect(el.getAttribute("data-i18n-title")).toBe("close");
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
/* The attributes of the first tag of `html`, entities decoded. */
|
|
252
|
+
function attrs_of(html)
|
|
253
|
+
{
|
|
254
|
+
const decode = (v) => v.replace(/</g, "<").replace(/>/g, ">")
|
|
255
|
+
.replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, "&");
|
|
256
|
+
const tag = html.match(/^<[a-z]+([^>]*)>/i);
|
|
257
|
+
const out = {};
|
|
258
|
+
for(const m of tag[1].matchAll(/([\w:-]+)(?:="([^"]*)")?/g)) {
|
|
259
|
+
out[m[1]] = (m[2] === undefined)? "" : decode(m[2]);
|
|
260
|
+
}
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/* The double builds no element out of markup: the toolbar's
|
|
265
|
+
* innerHTML is read here into elements, one per top-level tag, the
|
|
266
|
+
* way a browser would give them to the toolbar's listeners. */
|
|
267
|
+
function parse_into($el, html)
|
|
268
|
+
{
|
|
269
|
+
for(const c of [...$el.childNodes]) {
|
|
270
|
+
$el.removeChild(c);
|
|
271
|
+
}
|
|
272
|
+
for(const m of html.matchAll(/<div[^>]*>/g)) {
|
|
273
|
+
const el = document.createElement("div");
|
|
274
|
+
for(const [k, v] of Object.entries(attrs_of(m[0]))) {
|
|
275
|
+
el.setAttribute(k, v);
|
|
276
|
+
}
|
|
277
|
+
$el.appendChild(el);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
describe("the toolbar items are buttons too", () => {
|
|
282
|
+
test("an item: role, tabindex, and its name carries its key", () => {
|
|
283
|
+
const a = attrs_of(toolbar_item_html({
|
|
284
|
+
id: "g6-icon-zoom-in", value: "zoom-in", className: "EV_ZOOM_IN",
|
|
285
|
+
title: "Zoom in", title_key: "zoom in",
|
|
286
|
+
}));
|
|
287
|
+
expect(a.role).toBe("button");
|
|
288
|
+
expect(a.tabindex).toBe("0");
|
|
289
|
+
expect(a.title).toBe("Zoom in");
|
|
290
|
+
expect(a["aria-label"]).toBe("Zoom in");
|
|
291
|
+
expect(a["data-i18n-title"]).toBe("zoom in");
|
|
292
|
+
expect(a["data-i18n-aria-label"]).toBe("zoom in");
|
|
293
|
+
|
|
294
|
+
const text = attrs_of(toolbar_item_html({
|
|
295
|
+
text: "1:1", value: "reset", title: "Actual size", title_key: "actual size",
|
|
296
|
+
}));
|
|
297
|
+
expect(text.role).toBe("button");
|
|
298
|
+
expect(text.tabindex).toBe("0");
|
|
299
|
+
expect(text["data-i18n-title"]).toBe("actual size");
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("the readout and the separator are not buttons", () => {
|
|
303
|
+
const read = attrs_of(toolbar_item_html({
|
|
304
|
+
readout: true, className: "G6_ZOOM_LEVEL", text: "100%",
|
|
305
|
+
title: "Zoom level", title_key: "zoom level",
|
|
306
|
+
}));
|
|
307
|
+
expect(read.role).toBeUndefined();
|
|
308
|
+
expect(read.tabindex).toBeUndefined();
|
|
309
|
+
expect(read["data-i18n-title"]).toBe("zoom level");
|
|
310
|
+
const sep = attrs_of(toolbar_item_html({separator: true}));
|
|
311
|
+
expect(sep.role).toBeUndefined();
|
|
312
|
+
expect(sep.tabindex).toBeUndefined();
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("a disabled item is out of the Tab order and says it is disabled", () => {
|
|
316
|
+
const off = attrs_of(toolbar_item_html({
|
|
317
|
+
id: "g6-icon-save", value: "save", className: "EV_SAVE_GRAPH",
|
|
318
|
+
title: "Save", title_key: "save", disabled: true,
|
|
319
|
+
}));
|
|
320
|
+
expect(off.disabled).toBe("");
|
|
321
|
+
expect(off["aria-disabled"]).toBe("true");
|
|
322
|
+
expect(off.tabindex).toBe("-1");
|
|
323
|
+
/* What enableElements() gives back. */
|
|
324
|
+
expect(off["data-enabled-tabindex"]).toBe("0");
|
|
325
|
+
|
|
326
|
+
const on = attrs_of(toolbar_item_html({
|
|
327
|
+
id: "g6-icon-save", value: "save", title: "Save", title_key: "save",
|
|
328
|
+
}));
|
|
329
|
+
expect(on.disabled).toBeUndefined();
|
|
330
|
+
expect(on["aria-disabled"]).toBeUndefined();
|
|
331
|
+
expect(on.tabindex).toBe("0");
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("a toggle says its state in aria-pressed; a plain button says none", () => {
|
|
335
|
+
const pressed = attrs_of(toolbar_item_html({
|
|
336
|
+
value: "anchor", title: "A", title_key: "anchor view", pressed: true,
|
|
337
|
+
}));
|
|
338
|
+
expect(pressed["aria-pressed"]).toBe("true");
|
|
339
|
+
const released = attrs_of(toolbar_item_html({
|
|
340
|
+
value: "selection-mode", title: "S", title_key: "selection mode", pressed: false,
|
|
341
|
+
}));
|
|
342
|
+
expect(released["aria-pressed"]).toBe("false");
|
|
343
|
+
const plain = attrs_of(toolbar_item_html({
|
|
344
|
+
value: "zoom-in", title: "Z", title_key: "zoom in",
|
|
345
|
+
}));
|
|
346
|
+
expect(plain["aria-pressed"]).toBeUndefined();
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("data-control names the control: its `control`, or its value", () => {
|
|
350
|
+
expect(attrs_of(toolbar_item_html({
|
|
351
|
+
value: "request-fullscreen", control: "fullscreen", title: "F", title_key: "enter full screen",
|
|
352
|
+
}))["data-control"]).toBe("fullscreen");
|
|
353
|
+
expect(attrs_of(toolbar_item_html({
|
|
354
|
+
value: "zoom-in", title: "Z", title_key: "zoom in",
|
|
355
|
+
}))["data-control"]).toBe("zoom-in");
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("a key carries no markup into the attribute", () => {
|
|
359
|
+
const a = attrs_of(toolbar_item_html({
|
|
360
|
+
value: "v", title: "t", title_key: '"><img src=x>',
|
|
361
|
+
}));
|
|
362
|
+
expect(a["data-i18n-title"]).toBe('"><img src=x>');
|
|
363
|
+
expect(toolbar_item_html({value: "v", title: "t", title_key: "<img>"})
|
|
364
|
+
.includes("<img")).toBe(false);
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
/* A YuiToolbar in a container with a canvas, on the real G6 base
|
|
369
|
+
* class: its onClick records the values G6's own handler hands it. */
|
|
370
|
+
async function toolbar_with(items)
|
|
371
|
+
{
|
|
372
|
+
const $container = document.createElement("div");
|
|
373
|
+
const $canvas = document.createElement("canvas");
|
|
374
|
+
$canvas.isConnected = true;
|
|
375
|
+
$container.appendChild($canvas);
|
|
376
|
+
const reached = [];
|
|
377
|
+
$container.addEventListener("keydown", (e) => {
|
|
378
|
+
reached.push(e.key);
|
|
379
|
+
});
|
|
380
|
+
const clicked = [];
|
|
381
|
+
const state = {items: items};
|
|
382
|
+
const toolbar = new YuiToolbar(
|
|
383
|
+
{canvas: {getContainer: () => $container}},
|
|
384
|
+
{
|
|
385
|
+
getItems: () => state.items,
|
|
386
|
+
onClick: (value) => {
|
|
387
|
+
clicked.push(value);
|
|
388
|
+
},
|
|
389
|
+
}
|
|
390
|
+
);
|
|
391
|
+
/* The first rebuild is still to come (an await away): read it. */
|
|
392
|
+
Object.defineProperty(toolbar.$element, "innerHTML", {
|
|
393
|
+
configurable: true,
|
|
394
|
+
set(html) {
|
|
395
|
+
parse_into(this, String(html));
|
|
396
|
+
},
|
|
397
|
+
get() {
|
|
398
|
+
return "";
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
await toolbar.update({});
|
|
402
|
+
const item = (value) => toolbar.$element.querySelectorAll(".g6-toolbar-item")
|
|
403
|
+
.find((el) => el.getAttribute("value") === value) || null;
|
|
404
|
+
return {$container, $canvas, toolbar, clicked, reached, state, item};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const ITEMS = [
|
|
408
|
+
{id: "g6-icon-zoom-in", value: "zoom-in", className: "EV_ZOOM_IN", title: "Zoom in", title_key: "zoom in"},
|
|
409
|
+
{readout: true, className: "G6_ZOOM_LEVEL", text: "100%", title: "Zoom level", title_key: "zoom level"},
|
|
410
|
+
{separator: true},
|
|
411
|
+
{id: "g6-icon-anchor", value: "anchor", className: "EV_TOGGLE_ANCHOR", title: "Anchor", title_key: "anchor view"},
|
|
412
|
+
{id: "g6-icon-save", value: "save", className: "EV_SAVE_GRAPH", title: "Save", title_key: "save", disabled: true},
|
|
413
|
+
];
|
|
414
|
+
|
|
415
|
+
describe("YuiToolbar: the keyboard reaches G6's own click handler", () => {
|
|
416
|
+
test("Enter and Space press the focused item, once each, and stop there", async () => {
|
|
417
|
+
const {clicked, reached, item} = await toolbar_with(ITEMS);
|
|
418
|
+
for(const key of ["Enter", " "]) {
|
|
419
|
+
const ev = dispatch(item("zoom-in"), "keydown", {key: key});
|
|
420
|
+
expect(ev.prevented, key).toBe(true);
|
|
421
|
+
expect(ev.stopped, key).toBe(true);
|
|
422
|
+
}
|
|
423
|
+
expect(clicked).toEqual(["zoom-in", "zoom-in"]);
|
|
424
|
+
expect(reached).toEqual([]);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
test("any other key goes on to the graph's container", async () => {
|
|
428
|
+
const {clicked, reached, item} = await toolbar_with(ITEMS);
|
|
429
|
+
for(const key of ["Escape", "Delete", "Tab"]) {
|
|
430
|
+
const ev = dispatch(item("anchor"), "keydown", {key: key});
|
|
431
|
+
expect(ev.prevented, key).toBe(false);
|
|
432
|
+
}
|
|
433
|
+
expect(reached).toEqual(["Escape", "Delete", "Tab"]);
|
|
434
|
+
expect(clicked).toEqual([]);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test("a disabled item takes the key and does nothing", async () => {
|
|
438
|
+
const {clicked, reached, item} = await toolbar_with(ITEMS);
|
|
439
|
+
const ev = dispatch(item("save"), "keydown", {key: "Enter"});
|
|
440
|
+
expect(ev.prevented).toBe(true);
|
|
441
|
+
expect(clicked).toEqual([]);
|
|
442
|
+
expect(reached).toEqual([]);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
test("a pointer press on an item does not take the focus; elsewhere it is left alone", async () => {
|
|
446
|
+
const {toolbar, item} = await toolbar_with(ITEMS);
|
|
447
|
+
expect(dispatch(item("zoom-in"), "pointerdown").prevented).toBe(true);
|
|
448
|
+
const readout = toolbar.$element.querySelector(".G6_ZOOM_LEVEL");
|
|
449
|
+
expect(dispatch(readout, "pointerdown").prevented).toBe(false);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test("a rebuild gives the focus back to the item of the same value", async () => {
|
|
453
|
+
const {toolbar, item} = await toolbar_with(ITEMS);
|
|
454
|
+
const before = item("anchor");
|
|
455
|
+
before.focus();
|
|
456
|
+
await toolbar.update({});
|
|
457
|
+
const after = item("anchor");
|
|
458
|
+
expect(after).not.toBe(before);
|
|
459
|
+
expect(document.activeElement === after, "the focus is on after").toBe(true);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
test("a rebuild without that item gives the focus to the canvas", async () => {
|
|
463
|
+
const {toolbar, state, $canvas, item} = await toolbar_with(ITEMS);
|
|
464
|
+
item("anchor").focus();
|
|
465
|
+
state.items = ITEMS.filter((it) => it.value !== "anchor");
|
|
466
|
+
await toolbar.update({});
|
|
467
|
+
expect(document.activeElement === $canvas, "the focus is on $canvas").toBe(true);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
test("a rebuild leaves alone a focus that was not on an item", async () => {
|
|
471
|
+
const {toolbar} = await toolbar_with(ITEMS);
|
|
472
|
+
const $find = document.createElement("input");
|
|
473
|
+
$find.focus();
|
|
474
|
+
await toolbar.update({});
|
|
475
|
+
expect(document.activeElement === $find, "the focus is on $find").toBe(true);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test("a rebuild that changes the item's state keeps the focus on that control", async () => {
|
|
479
|
+
const enter = {id: "g6-icon-fullscreen", value: "request-fullscreen", control: "fullscreen",
|
|
480
|
+
className: "EV_REQUEST_FULLSCREEN", title: "Enter", title_key: "enter full screen"};
|
|
481
|
+
const exit = {id: "g6-icon-fullscreen-exit", value: "exit-fullscreen", control: "fullscreen",
|
|
482
|
+
className: "EV_EXIT_FULLSCREEN", title: "Exit", title_key: "exit full screen"};
|
|
483
|
+
const {toolbar, state, item} = await toolbar_with([ITEMS[0], enter]);
|
|
484
|
+
item("request-fullscreen").focus();
|
|
485
|
+
state.items = [ITEMS[0], exit];
|
|
486
|
+
await toolbar.update({});
|
|
487
|
+
expect(document.activeElement === item("exit-fullscreen"),
|
|
488
|
+
"the focus is on exit full screen").toBe(true);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
test("a disabled item is not a Tab stop, and gets its place back when enabled", async () => {
|
|
492
|
+
const {toolbar, item} = await toolbar_with(ITEMS);
|
|
493
|
+
const $tb = toolbar.$element;
|
|
494
|
+
expect(item("save").getAttribute("tabindex")).toBe("-1");
|
|
495
|
+
expect(item("save").getAttribute("aria-disabled")).toBe("true");
|
|
496
|
+
|
|
497
|
+
enableElements($tb, ".EV_SAVE_GRAPH");
|
|
498
|
+
expect(item("save").hasAttribute("disabled")).toBe(false);
|
|
499
|
+
expect(item("save").getAttribute("aria-disabled")).toBe(null);
|
|
500
|
+
expect(item("save").getAttribute("tabindex")).toBe("0");
|
|
501
|
+
expect(item("save").getAttribute("data-enabled-tabindex")).toBe(null);
|
|
502
|
+
|
|
503
|
+
disableElements($tb, ".EV_ZOOM_IN");
|
|
504
|
+
expect(item("zoom-in").hasAttribute("disabled")).toBe(true);
|
|
505
|
+
expect(item("zoom-in").getAttribute("aria-disabled")).toBe("true");
|
|
506
|
+
expect(item("zoom-in").getAttribute("tabindex")).toBe("-1");
|
|
507
|
+
/* Twice is still once: the tabindex it had is not lost. */
|
|
508
|
+
disableElements($tb, ".EV_ZOOM_IN");
|
|
509
|
+
enableElements($tb, ".EV_ZOOM_IN");
|
|
510
|
+
expect(item("zoom-in").getAttribute("tabindex")).toBe("0");
|
|
511
|
+
expect(item("zoom-in").getAttribute("aria-disabled")).toBe(null);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
test("a toolbar that goes away hands the focus to the canvas", async () => {
|
|
515
|
+
const {toolbar, $canvas, item} = await toolbar_with(ITEMS);
|
|
516
|
+
item("zoom-in").focus();
|
|
517
|
+
toolbar.destroy();
|
|
518
|
+
expect(document.activeElement === $canvas, "the focus is on $canvas").toBe(true);
|
|
519
|
+
});
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
describe("disableElements / enableElements on any control", () => {
|
|
523
|
+
test("a native button without a tabindex gets none back", () => {
|
|
524
|
+
const $box = document.createElement("div");
|
|
525
|
+
const $btn = document.createElement("button");
|
|
526
|
+
$btn.className = "X";
|
|
527
|
+
$box.appendChild($btn);
|
|
528
|
+
disableElements($box, ".X");
|
|
529
|
+
expect($btn.getAttribute("aria-disabled")).toBe("true");
|
|
530
|
+
expect($btn.getAttribute("tabindex")).toBe("-1");
|
|
531
|
+
enableElements($box, ".X");
|
|
532
|
+
expect($btn.hasAttribute("disabled")).toBe(false);
|
|
533
|
+
expect($btn.getAttribute("aria-disabled")).toBe(null);
|
|
534
|
+
expect($btn.hasAttribute("tabindex")).toBe(false);
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
test("enabling what was never disabled leaves its tabindex alone", () => {
|
|
538
|
+
const $box = document.createElement("div");
|
|
539
|
+
const $el = document.createElement("div");
|
|
540
|
+
$el.className = "X";
|
|
541
|
+
$el.setAttribute("tabindex", "0");
|
|
542
|
+
$box.appendChild($el);
|
|
543
|
+
enableElements($box, ".X");
|
|
544
|
+
expect($el.getAttribute("tabindex")).toBe("0");
|
|
545
|
+
expect($el.getAttribute("aria-disabled")).toBe(null);
|
|
546
|
+
});
|
|
547
|
+
});
|