@yuneta/gobj-ui 1.0.1 → 2.2.0
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 +40 -363
- package/dist/gobj-ui.cjs.js +11154 -5506
- package/dist/gobj-ui.es.js +11131 -5508
- package/index.js +41 -14
- package/package.json +11 -9
- package/src/c_g6_nodes_tree.js +6 -1
- package/src/c_yui_form.js +1 -1
- package/src/c_yui_gobj_tree_js.js +1 -1
- package/src/c_yui_json_graph.js +1 -1
- package/src/c_yui_main.js +3 -3
- package/src/c_yui_map.js +6 -1
- package/src/c_yui_nav.js +881 -0
- package/src/c_yui_pager.js +545 -0
- package/src/c_yui_routing.css +1 -1
- package/src/c_yui_routing.js +1 -1
- package/src/c_yui_shell.css +571 -0
- package/src/c_yui_shell.js +2474 -0
- package/src/c_yui_tabs.js +1 -1
- package/src/c_yui_treedb_graph.js +35 -9
- package/src/c_yui_treedb_topic_with_form.js +1 -1
- package/src/c_yui_treedb_topics.js +36 -8
- package/src/c_yui_uplot.js +1 -1
- package/src/c_yui_window.js +242 -21
- package/src/c_yui_window_manager.js +602 -0
- package/src/c_yui_wizard.js +612 -0
- package/src/pager_helpers.js +138 -0
- package/src/pager_helpers.test.js +140 -0
- package/src/route_resolver.js +53 -0
- package/src/route_resolver.test.js +82 -0
- package/src/shell_focus_trap.js +123 -0
- package/src/shell_focus_trap.test.js +299 -0
- package/src/shell_modals.js +445 -0
- package/src/shell_show_on.js +91 -0
- package/src/shell_show_on.test.js +86 -0
- package/src/shell_toolbar_helpers.js +221 -0
- package/src/shell_toolbar_helpers.test.js +207 -0
- package/src/tabulator.css +53 -0
- package/src/wizard_helpers.js +117 -0
- package/src/wizard_helpers.test.js +122 -0
- package/src/yui_dev.js +1257 -362
- package/src/yui_icons.css +5 -0
- package/src/yui_inputs.css +32 -0
- package/src/yui_inputs.js +71 -0
- package/vite-plugin-yuneta-html.js +2 -2
- package/skeleton/config.json +0 -20
- package/skeleton/index.html +0 -37
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/***********************************************************************
|
|
2
|
+
* shell_focus_trap.test.mjs
|
|
3
|
+
*
|
|
4
|
+
* Unit tests for the generic focus-trap helper. Uses a tiny
|
|
5
|
+
* hand-rolled DOM stub injected via the second arg of
|
|
6
|
+
* `activate_focus_trap_on($panel, doc)` — no extra devDep.
|
|
7
|
+
***********************************************************************/
|
|
8
|
+
import { test, expect } from "vitest";
|
|
9
|
+
import {
|
|
10
|
+
FOCUSABLE_SELECTOR,
|
|
11
|
+
activate_focus_trap_on,
|
|
12
|
+
} from "./shell_focus_trap.js";
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
/***************************************************************
|
|
16
|
+
* Minimal stubs. Just enough to drive the focus-trap module.
|
|
17
|
+
***************************************************************/
|
|
18
|
+
function make_focusable(name, opts = {})
|
|
19
|
+
{
|
|
20
|
+
let calls = [];
|
|
21
|
+
return {
|
|
22
|
+
__name: name,
|
|
23
|
+
focus: () => calls.push(`${name}.focus`),
|
|
24
|
+
focus_calls: calls,
|
|
25
|
+
...opts
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function make_panel(focusables)
|
|
30
|
+
{
|
|
31
|
+
return {
|
|
32
|
+
querySelector(_sel) {
|
|
33
|
+
return focusables[0] || null;
|
|
34
|
+
},
|
|
35
|
+
querySelectorAll(_sel) {
|
|
36
|
+
return focusables;
|
|
37
|
+
},
|
|
38
|
+
contains(node) {
|
|
39
|
+
return focusables.indexOf(node) >= 0;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function make_doc(initial_active, body_contains = true)
|
|
45
|
+
{
|
|
46
|
+
let listeners = [];
|
|
47
|
+
return {
|
|
48
|
+
activeElement: initial_active,
|
|
49
|
+
addEventListener(_t, fn, _opts) { listeners.push(fn); },
|
|
50
|
+
removeEventListener(_t, fn, _opts) {
|
|
51
|
+
let i = listeners.indexOf(fn);
|
|
52
|
+
if(i >= 0) {
|
|
53
|
+
listeners.splice(i, 1);
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
body: {
|
|
57
|
+
contains: () => body_contains
|
|
58
|
+
},
|
|
59
|
+
listeners
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
test("FOCUSABLE_SELECTOR covers the standard focusable elements", () => {
|
|
65
|
+
/* Sanity: just check the string is non-trivial. */
|
|
66
|
+
expect(FOCUSABLE_SELECTOR.includes("a[href]")).toBeTruthy();
|
|
67
|
+
expect(FOCUSABLE_SELECTOR.includes("button:not([disabled])")).toBeTruthy();
|
|
68
|
+
expect(FOCUSABLE_SELECTOR.includes("input:not([disabled])")).toBeTruthy();
|
|
69
|
+
expect(FOCUSABLE_SELECTOR.includes("[tabindex]:not([tabindex=\"-1\"])")).toBeTruthy();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("activate moves focus to the first focusable child of the panel", () => {
|
|
73
|
+
let first = make_focusable("first");
|
|
74
|
+
let last = make_focusable("last");
|
|
75
|
+
let panel = make_panel([first, last]);
|
|
76
|
+
let doc = make_doc(null);
|
|
77
|
+
|
|
78
|
+
let release = activate_focus_trap_on(panel, doc);
|
|
79
|
+
|
|
80
|
+
expect(first.focus_calls).toEqual(["first.focus"]);
|
|
81
|
+
expect(last.focus_calls).toEqual([]);
|
|
82
|
+
/* Listener registered. */
|
|
83
|
+
expect(doc.listeners.length).toBe(1);
|
|
84
|
+
|
|
85
|
+
release();
|
|
86
|
+
/* Listener removed. */
|
|
87
|
+
expect(doc.listeners.length).toBe(0);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("Tab from the last focusable wraps to the first", () => {
|
|
91
|
+
let first = make_focusable("first");
|
|
92
|
+
let last = make_focusable("last");
|
|
93
|
+
let panel = make_panel([first, last]);
|
|
94
|
+
let doc = make_doc(null);
|
|
95
|
+
|
|
96
|
+
activate_focus_trap_on(panel, doc);
|
|
97
|
+
|
|
98
|
+
/* Reset focus_calls so we only see what the trap does. */
|
|
99
|
+
first.focus_calls.length = 0;
|
|
100
|
+
last.focus_calls.length = 0;
|
|
101
|
+
|
|
102
|
+
/* Pretend `last` is focused now, then user presses Tab. */
|
|
103
|
+
doc.activeElement = last;
|
|
104
|
+
let prevented = false;
|
|
105
|
+
let ev = {
|
|
106
|
+
key: "Tab",
|
|
107
|
+
shiftKey: false,
|
|
108
|
+
preventDefault() { prevented = true; }
|
|
109
|
+
};
|
|
110
|
+
doc.listeners[0](ev);
|
|
111
|
+
|
|
112
|
+
expect(prevented).toBe(true);
|
|
113
|
+
expect(first.focus_calls).toEqual(["first.focus"]);
|
|
114
|
+
expect(last.focus_calls).toEqual([]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("Shift+Tab from the first focusable wraps to the last", () => {
|
|
118
|
+
let first = make_focusable("first");
|
|
119
|
+
let last = make_focusable("last");
|
|
120
|
+
let panel = make_panel([first, last]);
|
|
121
|
+
let doc = make_doc(null);
|
|
122
|
+
|
|
123
|
+
activate_focus_trap_on(panel, doc);
|
|
124
|
+
first.focus_calls.length = 0;
|
|
125
|
+
last.focus_calls.length = 0;
|
|
126
|
+
|
|
127
|
+
doc.activeElement = first;
|
|
128
|
+
let prevented = false;
|
|
129
|
+
let ev = {
|
|
130
|
+
key: "Tab",
|
|
131
|
+
shiftKey: true,
|
|
132
|
+
preventDefault() { prevented = true; }
|
|
133
|
+
};
|
|
134
|
+
doc.listeners[0](ev);
|
|
135
|
+
|
|
136
|
+
expect(prevented).toBe(true);
|
|
137
|
+
expect(last.focus_calls).toEqual(["last.focus"]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("non-Tab keys are ignored by the trap", () => {
|
|
141
|
+
let first = make_focusable("first");
|
|
142
|
+
let panel = make_panel([first]);
|
|
143
|
+
let doc = make_doc(null);
|
|
144
|
+
|
|
145
|
+
activate_focus_trap_on(panel, doc);
|
|
146
|
+
first.focus_calls.length = 0;
|
|
147
|
+
|
|
148
|
+
let prevented = false;
|
|
149
|
+
let ev = {
|
|
150
|
+
key: "Escape",
|
|
151
|
+
shiftKey: false,
|
|
152
|
+
preventDefault() { prevented = true; }
|
|
153
|
+
};
|
|
154
|
+
doc.listeners[0](ev);
|
|
155
|
+
|
|
156
|
+
expect(prevented).toBe(false);
|
|
157
|
+
expect(first.focus_calls).toEqual([]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("focus jumps into the panel when activeElement is outside", () => {
|
|
161
|
+
let first = make_focusable("first");
|
|
162
|
+
let last = make_focusable("last");
|
|
163
|
+
let panel = make_panel([first, last]);
|
|
164
|
+
let doc = make_doc(null);
|
|
165
|
+
let outside = make_focusable("outside");
|
|
166
|
+
|
|
167
|
+
activate_focus_trap_on(panel, doc);
|
|
168
|
+
first.focus_calls.length = 0;
|
|
169
|
+
|
|
170
|
+
doc.activeElement = outside; /* not in panel.contains() */
|
|
171
|
+
|
|
172
|
+
let prevented = false;
|
|
173
|
+
let ev = {
|
|
174
|
+
key: "Tab",
|
|
175
|
+
shiftKey: false,
|
|
176
|
+
preventDefault() { prevented = true; }
|
|
177
|
+
};
|
|
178
|
+
doc.listeners[0](ev);
|
|
179
|
+
|
|
180
|
+
expect(prevented).toBe(true);
|
|
181
|
+
expect(first.focus_calls).toEqual(["first.focus"]);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("release restores the previously-active element", () => {
|
|
185
|
+
let prev = make_focusable("prev");
|
|
186
|
+
let inside_first = make_focusable("first");
|
|
187
|
+
let panel = make_panel([inside_first]);
|
|
188
|
+
let doc = make_doc(prev); /* prev was active before the trap */
|
|
189
|
+
|
|
190
|
+
let release = activate_focus_trap_on(panel, doc);
|
|
191
|
+
/* After activation, the trap focuses the first inside element. */
|
|
192
|
+
expect(inside_first.focus_calls).toEqual(["first.focus"]);
|
|
193
|
+
|
|
194
|
+
release();
|
|
195
|
+
expect(prev.focus_calls).toEqual(["prev.focus"]);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("release is idempotent — calling it twice is safe", () => {
|
|
199
|
+
let panel = make_panel([make_focusable("first")]);
|
|
200
|
+
let doc = make_doc(null);
|
|
201
|
+
|
|
202
|
+
let release = activate_focus_trap_on(panel, doc);
|
|
203
|
+
expect(doc.listeners.length).toBe(1);
|
|
204
|
+
|
|
205
|
+
release();
|
|
206
|
+
expect(doc.listeners.length).toBe(0);
|
|
207
|
+
|
|
208
|
+
/* Second release call must not throw and must not double-act. */
|
|
209
|
+
release();
|
|
210
|
+
expect(doc.listeners.length).toBe(0);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("missing $panel returns a no-op release function", () => {
|
|
214
|
+
let release = activate_focus_trap_on(null, make_doc(null));
|
|
215
|
+
expect(typeof release).toBe("function");
|
|
216
|
+
release(); /* must not throw */
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("LIFO stacking: only the topmost trap acts on Tab", () => {
|
|
220
|
+
/* Two panels active simultaneously. Pressing Tab must use
|
|
221
|
+
* the SECOND trap's panel, not the first — even though both
|
|
222
|
+
* listeners fire (capture phase). */
|
|
223
|
+
let panel_a_first = make_focusable("a-first");
|
|
224
|
+
let panel_a_last = make_focusable("a-last");
|
|
225
|
+
let panel_a = make_panel([panel_a_first, panel_a_last]);
|
|
226
|
+
|
|
227
|
+
let panel_b_first = make_focusable("b-first");
|
|
228
|
+
let panel_b_last = make_focusable("b-last");
|
|
229
|
+
let panel_b = make_panel([panel_b_first, panel_b_last]);
|
|
230
|
+
|
|
231
|
+
let doc = make_doc(null);
|
|
232
|
+
activate_focus_trap_on(panel_a, doc);
|
|
233
|
+
activate_focus_trap_on(panel_b, doc);
|
|
234
|
+
|
|
235
|
+
/* Both listeners are attached. */
|
|
236
|
+
expect(doc.listeners.length).toBe(2);
|
|
237
|
+
|
|
238
|
+
/* Reset focus_calls so we only see the Tab response. */
|
|
239
|
+
panel_a_first.focus_calls.length = 0;
|
|
240
|
+
panel_b_first.focus_calls.length = 0;
|
|
241
|
+
|
|
242
|
+
/* Pretend focus is "outside" both panels. Press Tab. */
|
|
243
|
+
doc.activeElement = make_focusable("outside");
|
|
244
|
+
let ev = {
|
|
245
|
+
key: "Tab",
|
|
246
|
+
shiftKey: false,
|
|
247
|
+
preventDefault() {}
|
|
248
|
+
};
|
|
249
|
+
/* Listeners run in registration order. */
|
|
250
|
+
doc.listeners[0](ev);
|
|
251
|
+
doc.listeners[1](ev);
|
|
252
|
+
|
|
253
|
+
/* Only panel_b (the topmost) should have grabbed focus. */
|
|
254
|
+
expect(panel_a_first.focus_calls).toEqual([]);
|
|
255
|
+
expect(panel_b_first.focus_calls).toEqual(["b-first.focus"]);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("LIFO stacking: releasing the top trap re-empowers the one underneath", () => {
|
|
259
|
+
let panel_a_first = make_focusable("a-first");
|
|
260
|
+
let panel_a = make_panel([panel_a_first]);
|
|
261
|
+
let panel_b_first = make_focusable("b-first");
|
|
262
|
+
let panel_b = make_panel([panel_b_first]);
|
|
263
|
+
|
|
264
|
+
let doc = make_doc(null);
|
|
265
|
+
activate_focus_trap_on(panel_a, doc);
|
|
266
|
+
let release_b = activate_focus_trap_on(panel_b, doc);
|
|
267
|
+
|
|
268
|
+
release_b();
|
|
269
|
+
expect(doc.listeners.length).toBe(1);
|
|
270
|
+
|
|
271
|
+
/* Now Tab should drive panel_a — it is again the top. */
|
|
272
|
+
panel_a_first.focus_calls.length = 0;
|
|
273
|
+
doc.activeElement = make_focusable("outside");
|
|
274
|
+
let ev = {
|
|
275
|
+
key: "Tab",
|
|
276
|
+
shiftKey: false,
|
|
277
|
+
preventDefault() {}
|
|
278
|
+
};
|
|
279
|
+
doc.listeners[0](ev);
|
|
280
|
+
expect(panel_a_first.focus_calls).toEqual(["a-first.focus"]);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("an empty panel installs the listener but does nothing on Tab", () => {
|
|
284
|
+
let panel = make_panel([]);
|
|
285
|
+
let doc = make_doc(null);
|
|
286
|
+
|
|
287
|
+
activate_focus_trap_on(panel, doc);
|
|
288
|
+
expect(doc.listeners.length).toBe(1);
|
|
289
|
+
|
|
290
|
+
let prevented = false;
|
|
291
|
+
let ev = {
|
|
292
|
+
key: "Tab",
|
|
293
|
+
shiftKey: false,
|
|
294
|
+
preventDefault() { prevented = true; }
|
|
295
|
+
};
|
|
296
|
+
doc.listeners[0](ev);
|
|
297
|
+
/* No focusables → trap stays out of the way. */
|
|
298
|
+
expect(prevented).toBe(false);
|
|
299
|
+
});
|