@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,138 @@
|
|
|
1
|
+
/***********************************************************************
|
|
2
|
+
* pager_helpers.js
|
|
3
|
+
*
|
|
4
|
+
* Pure (DOM-free) navigation-stack logic for C_YUI_PAGER.
|
|
5
|
+
* Kept apart so it is unit-testable with `node --test`
|
|
6
|
+
* (same split as shell_toolbar_helpers.js).
|
|
7
|
+
*
|
|
8
|
+
* Copyright (c) 2026, ArtGins.
|
|
9
|
+
* All Rights Reserved.
|
|
10
|
+
***********************************************************************/
|
|
11
|
+
|
|
12
|
+
/***************************************************************
|
|
13
|
+
* A stack entry is a plain object:
|
|
14
|
+
* { id, title, discardable }
|
|
15
|
+
* The DOM node bound to it is tracked by the gclass, never here.
|
|
16
|
+
***************************************************************/
|
|
17
|
+
|
|
18
|
+
/***************************************************************
|
|
19
|
+
* Push an entry, returning a NEW stack (never mutates).
|
|
20
|
+
***************************************************************/
|
|
21
|
+
function pager_push(stack, entry)
|
|
22
|
+
{
|
|
23
|
+
return stack.concat([entry]);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/***************************************************************
|
|
27
|
+
* Pop the top entry.
|
|
28
|
+
* Returns { stack, popped } or null when the stack is empty.
|
|
29
|
+
***************************************************************/
|
|
30
|
+
function pager_pop(stack)
|
|
31
|
+
{
|
|
32
|
+
if(stack.length === 0) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
stack: stack.slice(0, -1),
|
|
37
|
+
popped: stack[stack.length - 1],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/***************************************************************
|
|
42
|
+
* Replace the top entry (same depth).
|
|
43
|
+
* Returns { stack, replaced }; on an empty stack it just pushes.
|
|
44
|
+
***************************************************************/
|
|
45
|
+
function pager_replace(stack, entry)
|
|
46
|
+
{
|
|
47
|
+
if(stack.length === 0) {
|
|
48
|
+
return { stack: [entry], replaced: null };
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
stack: stack.slice(0, -1).concat([entry]),
|
|
52
|
+
replaced: stack[stack.length - 1],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/***************************************************************
|
|
57
|
+
* Top entry or null.
|
|
58
|
+
***************************************************************/
|
|
59
|
+
function pager_top(stack)
|
|
60
|
+
{
|
|
61
|
+
if(stack.length === 0) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
return stack[stack.length - 1];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/***************************************************************
|
|
68
|
+
* What the header chrome must show for the current stack.
|
|
69
|
+
*
|
|
70
|
+
* opts = { root_title, back_on_root, with_discard }
|
|
71
|
+
*
|
|
72
|
+
* Returns { title, show_back, back_kind, show_discard, depth }.
|
|
73
|
+
* back_kind:
|
|
74
|
+
* "back" — a deeper page: the affordance pops (icon: arrow)
|
|
75
|
+
* "close" — the root page with back_on_root: it exits/closes
|
|
76
|
+
* (icon: a cross, shown INSIDE the popup)
|
|
77
|
+
* "none" — root page, back_on_root false: no affordance
|
|
78
|
+
***************************************************************/
|
|
79
|
+
function pager_header_model(stack, opts)
|
|
80
|
+
{
|
|
81
|
+
opts = opts || {};
|
|
82
|
+
let depth = stack.length;
|
|
83
|
+
let top = pager_top(stack);
|
|
84
|
+
|
|
85
|
+
let title;
|
|
86
|
+
if(top && typeof top.title === "string" && top.title.length > 0) {
|
|
87
|
+
title = top.title;
|
|
88
|
+
} else {
|
|
89
|
+
title = opts.root_title || "";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let back_kind;
|
|
93
|
+
if(depth > 1) {
|
|
94
|
+
back_kind = "back";
|
|
95
|
+
} else if(opts.back_on_root) {
|
|
96
|
+
back_kind = "close";
|
|
97
|
+
} else {
|
|
98
|
+
back_kind = "none";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let show_discard = !!opts.with_discard && !!(top && top.discardable);
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
title: title,
|
|
105
|
+
show_back: back_kind !== "none",
|
|
106
|
+
back_kind: back_kind,
|
|
107
|
+
show_discard: show_discard,
|
|
108
|
+
depth: depth,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/***************************************************************
|
|
113
|
+
* Resolve what the "back" affordance must do.
|
|
114
|
+
*
|
|
115
|
+
* Returns one of:
|
|
116
|
+
* { type: "pop" } — pop the top page
|
|
117
|
+
* { type: "exit" } — leave the pager (host should close)
|
|
118
|
+
* { type: "noop" } — nothing (root, back_on_root === false)
|
|
119
|
+
***************************************************************/
|
|
120
|
+
function pager_back_action(stack, back_on_root)
|
|
121
|
+
{
|
|
122
|
+
if(stack.length > 1) {
|
|
123
|
+
return { type: "pop" };
|
|
124
|
+
}
|
|
125
|
+
if(back_on_root) {
|
|
126
|
+
return { type: "exit" };
|
|
127
|
+
}
|
|
128
|
+
return { type: "noop" };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export {
|
|
132
|
+
pager_push,
|
|
133
|
+
pager_pop,
|
|
134
|
+
pager_replace,
|
|
135
|
+
pager_top,
|
|
136
|
+
pager_header_model,
|
|
137
|
+
pager_back_action,
|
|
138
|
+
};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/***********************************************************************
|
|
2
|
+
* pager_helpers.test.mjs
|
|
3
|
+
*
|
|
4
|
+
* Unit tests for the pure navigation-stack logic of C_YUI_PAGER.
|
|
5
|
+
* Run with: node --test tests/
|
|
6
|
+
***********************************************************************/
|
|
7
|
+
import { test, expect } from "vitest";
|
|
8
|
+
import {
|
|
9
|
+
pager_push,
|
|
10
|
+
pager_pop,
|
|
11
|
+
pager_replace,
|
|
12
|
+
pager_top,
|
|
13
|
+
pager_header_model,
|
|
14
|
+
pager_back_action,
|
|
15
|
+
} from "./pager_helpers.js";
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
/*============================================================
|
|
19
|
+
* pager_push / pager_pop / pager_replace are pure
|
|
20
|
+
*============================================================*/
|
|
21
|
+
test("push does not mutate the input stack", () => {
|
|
22
|
+
const s0 = [];
|
|
23
|
+
const s1 = pager_push(s0, { id: "a", title: "A" });
|
|
24
|
+
expect(s0.length).toBe(0);
|
|
25
|
+
expect(s1.length).toBe(1);
|
|
26
|
+
expect(s1[0].id).toBe("a");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("pop returns popped entry and a shorter new stack", () => {
|
|
30
|
+
const s = pager_push(pager_push([], { id: "a" }), { id: "b" });
|
|
31
|
+
const r = pager_pop(s);
|
|
32
|
+
expect(r.popped.id).toBe("b");
|
|
33
|
+
expect(r.stack.length).toBe(1);
|
|
34
|
+
expect(s.length).toBe(2); // original untouched
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("pop on empty stack returns null", () => {
|
|
38
|
+
expect(pager_pop([])).toBe(null);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("replace swaps the top, keeps depth, reports replaced", () => {
|
|
42
|
+
const s = pager_push(pager_push([], { id: "a" }), { id: "b" });
|
|
43
|
+
const r = pager_replace(s, { id: "b2" });
|
|
44
|
+
expect(r.stack.length).toBe(2);
|
|
45
|
+
expect(r.stack[1].id).toBe("b2");
|
|
46
|
+
expect(r.replaced.id).toBe("b");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("replace on empty stack just pushes", () => {
|
|
50
|
+
const r = pager_replace([], { id: "x" });
|
|
51
|
+
expect(r.stack.length).toBe(1);
|
|
52
|
+
expect(r.replaced).toBe(null);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("pager_top returns the last entry or null", () => {
|
|
56
|
+
expect(pager_top([])).toBe(null);
|
|
57
|
+
expect(pager_top([{ id: "a" }, { id: "b" }]).id).toBe("b");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
/*============================================================
|
|
62
|
+
* pager_header_model
|
|
63
|
+
*============================================================*/
|
|
64
|
+
test("root page: title falls back to root_title", () => {
|
|
65
|
+
const m = pager_header_model([{ id: "root", title: "" }], {
|
|
66
|
+
root_title: "Preferences",
|
|
67
|
+
back_on_root: true,
|
|
68
|
+
});
|
|
69
|
+
expect(m.title).toBe("Preferences");
|
|
70
|
+
expect(m.depth).toBe(1);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("root page: show_back follows back_on_root", () => {
|
|
74
|
+
expect(pager_header_model([{ id: "r" }], { back_on_root: true }).show_back).toBe(true);
|
|
75
|
+
expect(pager_header_model([{ id: "r" }], { back_on_root: false }).show_back).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("deep page: back always shown, title from top entry", () => {
|
|
79
|
+
const m = pager_header_model(
|
|
80
|
+
[{ id: "r", title: "Root" }, { id: "lang", title: "Language" }],
|
|
81
|
+
{ root_title: "Root", back_on_root: false }
|
|
82
|
+
);
|
|
83
|
+
expect(m.show_back).toBe(true);
|
|
84
|
+
expect(m.title).toBe("Language");
|
|
85
|
+
expect(m.depth).toBe(2);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("discard shown only when with_discard AND top.discardable", () => {
|
|
89
|
+
const top_yes = [{ id: "p", discardable: true }];
|
|
90
|
+
const top_no = [{ id: "p", discardable: false }];
|
|
91
|
+
expect(pager_header_model(top_yes, { with_discard: true }).show_discard).toBe(true);
|
|
92
|
+
expect(pager_header_model(top_yes, { with_discard: false }).show_discard).toBe(false);
|
|
93
|
+
expect(pager_header_model(top_no, { with_discard: true }).show_discard).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("empty stack: title is root_title, depth 0", () => {
|
|
97
|
+
const m = pager_header_model([], { root_title: "X" });
|
|
98
|
+
expect(m.title).toBe("X");
|
|
99
|
+
expect(m.depth).toBe(0);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("back_kind: root+back_on_root -> 'close'", () => {
|
|
103
|
+
const m = pager_header_model([{ id: "r" }], { back_on_root: true });
|
|
104
|
+
expect(m.back_kind).toBe("close");
|
|
105
|
+
expect(m.show_back).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("back_kind: root without back_on_root -> 'none'", () => {
|
|
109
|
+
const m = pager_header_model([{ id: "r" }], { back_on_root: false });
|
|
110
|
+
expect(m.back_kind).toBe("none");
|
|
111
|
+
expect(m.show_back).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("back_kind: deeper page -> 'back'", () => {
|
|
115
|
+
const m = pager_header_model(
|
|
116
|
+
[{ id: "r" }, { id: "lang" }], { back_on_root: true }
|
|
117
|
+
);
|
|
118
|
+
expect(m.back_kind).toBe("back");
|
|
119
|
+
expect(m.show_back).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
/*============================================================
|
|
124
|
+
* pager_back_action
|
|
125
|
+
*============================================================*/
|
|
126
|
+
test("back with depth>1 -> pop", () => {
|
|
127
|
+
expect(pager_back_action([{ id: "a" }, { id: "b" }], false)).toEqual({ type: "pop" });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("back at root with back_on_root -> exit", () => {
|
|
131
|
+
expect(pager_back_action([{ id: "a" }], true)).toEqual({ type: "exit" });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("back at root without back_on_root -> noop", () => {
|
|
135
|
+
expect(pager_back_action([{ id: "a" }], false)).toEqual({ type: "noop" });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("back on empty stack without back_on_root -> noop", () => {
|
|
139
|
+
expect(pager_back_action([], false)).toEqual({ type: "noop" });
|
|
140
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/***********************************************************************
|
|
2
|
+
* route_resolver.js
|
|
3
|
+
*
|
|
4
|
+
* Pure route resolver for C_YUI_SHELL (no gobj, no DOM,
|
|
5
|
+
* no imports) — kept apart so it is trivially unit-testable.
|
|
6
|
+
*
|
|
7
|
+
* Copyright (c) 2026, ArtGins.
|
|
8
|
+
* All Rights Reserved.
|
|
9
|
+
***********************************************************************/
|
|
10
|
+
|
|
11
|
+
/************************************************************
|
|
12
|
+
* resolve_route — given the route table (`item_index`) and a
|
|
13
|
+
* requested route, return { entry, matched_route, subpath }:
|
|
14
|
+
*
|
|
15
|
+
* - exact hit with a target → that entry, subpath "".
|
|
16
|
+
* - no exact target → walk ancestors (`/a/b/c` → `/a/b`),
|
|
17
|
+
* return the nearest ancestor that HAS a target plus the
|
|
18
|
+
* trailing `subpath` ("c", or "b/c"); lets a declared view
|
|
19
|
+
* own a deeper, dynamic, deep-linkable level without
|
|
20
|
+
* declaring runtime-only segments in app_config.
|
|
21
|
+
* - root `/` is NEVER an ancestor catch-all (it only matches
|
|
22
|
+
* exactly): otherwise every typo route would silently mount
|
|
23
|
+
* the home view and the unknown-route diagnostic would die.
|
|
24
|
+
* - nothing matched → entry as found (may be
|
|
25
|
+
* null or a targetless submenu parent), subpath "".
|
|
26
|
+
************************************************************/
|
|
27
|
+
function resolve_route(item_index, route)
|
|
28
|
+
{
|
|
29
|
+
let entry = item_index[route];
|
|
30
|
+
let matched_route = route;
|
|
31
|
+
let subpath = "";
|
|
32
|
+
|
|
33
|
+
if(!entry || !entry.target) {
|
|
34
|
+
let parts = route.split("/").filter(s => s.length > 0);
|
|
35
|
+
while(parts.length > 0 && (!entry || !entry.target)) {
|
|
36
|
+
parts.pop();
|
|
37
|
+
let cand = "/" + parts.join("/");
|
|
38
|
+
if(cand === "/") {
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
let e = item_index[cand];
|
|
42
|
+
if(e && e.target) {
|
|
43
|
+
entry = e;
|
|
44
|
+
matched_route = cand;
|
|
45
|
+
subpath = route.slice(cand.length).replace(/^\/+/, "");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { entry: entry, matched_route: matched_route, subpath: subpath };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export { resolve_route };
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/***********************************************************************
|
|
2
|
+
* route_resolver.test.js
|
|
3
|
+
*
|
|
4
|
+
* Unit tests for the pure shell route resolver.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) 2026, ArtGins.
|
|
7
|
+
* All Rights Reserved.
|
|
8
|
+
***********************************************************************/
|
|
9
|
+
import { describe, test, expect } from "vitest";
|
|
10
|
+
import { resolve_route } from "./route_resolver.js";
|
|
11
|
+
|
|
12
|
+
/* Minimal route table shaped like C_YUI_SHELL.priv.item_index. */
|
|
13
|
+
function make_index() {
|
|
14
|
+
const view = (gclass) => ({ stage: "main", gclass: gclass });
|
|
15
|
+
return {
|
|
16
|
+
"/": { item: null, target: view("C_WZ_VIEW") },
|
|
17
|
+
"/monitoring/realtime": { item: null, target: view("C_WZ_MONITORING") },
|
|
18
|
+
"/system": { item: { submenu: { items: [] } }, target: null },
|
|
19
|
+
"/system/db/wattyzer": { item: null, target: view("C_WZ_TREEDB") },
|
|
20
|
+
"/user/preference": { item: null, target: view("C_WZ_PREFERENCES") },
|
|
21
|
+
"/user/logout": { item: null, target: { kind: "action", event: "EV_LOGOUT" } },
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("resolve_route", () => {
|
|
26
|
+
const idx = make_index();
|
|
27
|
+
|
|
28
|
+
test("exact view hit → entry, no subpath", () => {
|
|
29
|
+
const r = resolve_route(idx, "/monitoring/realtime");
|
|
30
|
+
expect(r.matched_route).toBe("/monitoring/realtime");
|
|
31
|
+
expect(r.subpath).toBe("");
|
|
32
|
+
expect(r.entry.target.gclass).toBe("C_WZ_MONITORING");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("exact root '/' matches exactly (not via walk)", () => {
|
|
36
|
+
const r = resolve_route(idx, "/");
|
|
37
|
+
expect(r.matched_route).toBe("/");
|
|
38
|
+
expect(r.subpath).toBe("");
|
|
39
|
+
expect(r.entry.target.gclass).toBe("C_WZ_VIEW");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("3rd level → nearest declared ancestor + subpath", () => {
|
|
43
|
+
const r = resolve_route(idx, "/user/preference/language");
|
|
44
|
+
expect(r.matched_route).toBe("/user/preference");
|
|
45
|
+
expect(r.subpath).toBe("language");
|
|
46
|
+
expect(r.entry.target.gclass).toBe("C_WZ_PREFERENCES");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("deeper dynamic tail is preserved whole", () => {
|
|
50
|
+
const r = resolve_route(idx, "/system/db/wattyzer/topic/42");
|
|
51
|
+
expect(r.matched_route).toBe("/system/db/wattyzer");
|
|
52
|
+
expect(r.subpath).toBe("topic/42");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("action route resolves exactly, kind untouched", () => {
|
|
56
|
+
const r = resolve_route(idx, "/user/logout");
|
|
57
|
+
expect(r.matched_route).toBe("/user/logout");
|
|
58
|
+
expect(r.entry.target.kind).toBe("action");
|
|
59
|
+
expect(r.entry.target.event).toBe("EV_LOGOUT");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("root is NOT an ancestor catch-all → unknown stays unknown", () => {
|
|
63
|
+
const r = resolve_route(idx, "/zzz");
|
|
64
|
+
expect(r.matched_route).toBe("/zzz");
|
|
65
|
+
expect(r.subpath).toBe("");
|
|
66
|
+
expect(r.entry).toBeUndefined(); /* caller → default route */
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("unknown deep route never collapses onto '/'", () => {
|
|
70
|
+
const r = resolve_route(idx, "/nope/deeper/still");
|
|
71
|
+
expect(r.matched_route).toBe("/nope/deeper/still");
|
|
72
|
+
expect(r.entry).toBeUndefined();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("targetless submenu parent is returned as-is (subpath empty)", () => {
|
|
76
|
+
const r = resolve_route(idx, "/system");
|
|
77
|
+
expect(r.matched_route).toBe("/system");
|
|
78
|
+
expect(r.subpath).toBe("");
|
|
79
|
+
expect(r.entry.target).toBeNull();
|
|
80
|
+
expect(r.entry.item.submenu).toBeTruthy();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/***********************************************************************
|
|
2
|
+
* shell_focus_trap.js
|
|
3
|
+
*
|
|
4
|
+
* Generic focus-trap helper used by C_YUI_SHELL for drawers,
|
|
5
|
+
* modals and any other overlay that must capture keyboard
|
|
6
|
+
* navigation while open.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* let release = activate_focus_trap_on($panel);
|
|
10
|
+
* // ... when the overlay closes:
|
|
11
|
+
* release();
|
|
12
|
+
*
|
|
13
|
+
* The trap:
|
|
14
|
+
* - captures Tab / Shift+Tab so focus cycles inside $panel,
|
|
15
|
+
* - moves focus to the first focusable child on activate,
|
|
16
|
+
* - restores focus to whatever element had it before
|
|
17
|
+
* activation when release() runs.
|
|
18
|
+
*
|
|
19
|
+
* Pure module — no gobj / Yuneta dependencies. The optional
|
|
20
|
+
* second argument lets callers inject a mock document object
|
|
21
|
+
* for unit tests.
|
|
22
|
+
*
|
|
23
|
+
* Copyright (c) 2026, ArtGins.
|
|
24
|
+
* All Rights Reserved.
|
|
25
|
+
***********************************************************************/
|
|
26
|
+
|
|
27
|
+
/* Selector matching the elements typically considered focusable.
|
|
28
|
+
* Kept conservative: items that are explicitly removed from the tab
|
|
29
|
+
* order (`tabindex="-1"`) are excluded; everything else is in. */
|
|
30
|
+
export const FOCUSABLE_SELECTOR =
|
|
31
|
+
"a[href], button:not([disabled]), input:not([disabled])," +
|
|
32
|
+
" select:not([disabled]), textarea:not([disabled])," +
|
|
33
|
+
" [tabindex]:not([tabindex=\"-1\"])";
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
/* Module-local LIFO stack of active traps. Multiple overlays can
|
|
37
|
+
* be open simultaneously (modal over drawer, popup over modal, …)
|
|
38
|
+
* and each registers its own document-level keydown listener.
|
|
39
|
+
* Without arbitration every Tab fires every listener, which forces
|
|
40
|
+
* focus to the deepest trap's first/last on every press and makes
|
|
41
|
+
* middle elements unreachable. We keep one stack and only run the
|
|
42
|
+
* topmost trap; the rest short-circuit. */
|
|
43
|
+
const __trap_stack__ = [];
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
/***************************************************************
|
|
47
|
+
* Activate the focus-trap on $panel.
|
|
48
|
+
*
|
|
49
|
+
* Returns a `release` function that must be called to tear the
|
|
50
|
+
* trap down when the overlay closes. Calling release() multiple
|
|
51
|
+
* times is safe (becomes a no-op after the first call).
|
|
52
|
+
*
|
|
53
|
+
* `doc` defaults to globalThis.document; pass a stub for tests.
|
|
54
|
+
***************************************************************/
|
|
55
|
+
export function activate_focus_trap_on($panel, doc)
|
|
56
|
+
{
|
|
57
|
+
if(!doc) {
|
|
58
|
+
doc = globalThis.document;
|
|
59
|
+
}
|
|
60
|
+
if(!$panel || !doc) {
|
|
61
|
+
return function noop() {};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let saved = doc.activeElement || null;
|
|
65
|
+
let released = false;
|
|
66
|
+
|
|
67
|
+
let trap = function(ev) {
|
|
68
|
+
if(ev.key !== "Tab" && ev.keyCode !== 9) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
/* LIFO arbitration: only the topmost active trap acts.
|
|
72
|
+
* Earlier-registered listeners fire too (capture phase),
|
|
73
|
+
* but they short-circuit unless they own the top of the
|
|
74
|
+
* stack. */
|
|
75
|
+
if(__trap_stack__[__trap_stack__.length - 1] !== trap) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
let nodes = $panel.querySelectorAll(FOCUSABLE_SELECTOR);
|
|
79
|
+
if(nodes.length === 0) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
let first = nodes[0];
|
|
83
|
+
let last = nodes[nodes.length - 1];
|
|
84
|
+
let inside = $panel.contains(doc.activeElement);
|
|
85
|
+
|
|
86
|
+
if(ev.shiftKey) {
|
|
87
|
+
if(!inside || doc.activeElement === first) {
|
|
88
|
+
last.focus();
|
|
89
|
+
ev.preventDefault();
|
|
90
|
+
}
|
|
91
|
+
} else {
|
|
92
|
+
if(!inside || doc.activeElement === last) {
|
|
93
|
+
first.focus();
|
|
94
|
+
ev.preventDefault();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
doc.addEventListener("keydown", trap, true);
|
|
99
|
+
__trap_stack__.push(trap);
|
|
100
|
+
|
|
101
|
+
/* Move focus to the first focusable child of the panel. */
|
|
102
|
+
let firstFocusable = $panel.querySelector(FOCUSABLE_SELECTOR);
|
|
103
|
+
if(firstFocusable && typeof firstFocusable.focus === "function") {
|
|
104
|
+
firstFocusable.focus();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return function release_focus_trap() {
|
|
108
|
+
if(released) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
released = true;
|
|
112
|
+
doc.removeEventListener("keydown", trap, true);
|
|
113
|
+
let idx = __trap_stack__.indexOf(trap);
|
|
114
|
+
if(idx >= 0) {
|
|
115
|
+
__trap_stack__.splice(idx, 1);
|
|
116
|
+
}
|
|
117
|
+
if(saved && typeof saved.focus === "function" &&
|
|
118
|
+
(!doc.body || !doc.body.contains || doc.body.contains(saved)))
|
|
119
|
+
{
|
|
120
|
+
saved.focus();
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
}
|