@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.
Files changed (46) hide show
  1. package/README.md +40 -363
  2. package/dist/gobj-ui.cjs.js +11154 -5506
  3. package/dist/gobj-ui.es.js +11131 -5508
  4. package/index.js +41 -14
  5. package/package.json +11 -9
  6. package/src/c_g6_nodes_tree.js +6 -1
  7. package/src/c_yui_form.js +1 -1
  8. package/src/c_yui_gobj_tree_js.js +1 -1
  9. package/src/c_yui_json_graph.js +1 -1
  10. package/src/c_yui_main.js +3 -3
  11. package/src/c_yui_map.js +6 -1
  12. package/src/c_yui_nav.js +881 -0
  13. package/src/c_yui_pager.js +545 -0
  14. package/src/c_yui_routing.css +1 -1
  15. package/src/c_yui_routing.js +1 -1
  16. package/src/c_yui_shell.css +571 -0
  17. package/src/c_yui_shell.js +2474 -0
  18. package/src/c_yui_tabs.js +1 -1
  19. package/src/c_yui_treedb_graph.js +35 -9
  20. package/src/c_yui_treedb_topic_with_form.js +1 -1
  21. package/src/c_yui_treedb_topics.js +36 -8
  22. package/src/c_yui_uplot.js +1 -1
  23. package/src/c_yui_window.js +242 -21
  24. package/src/c_yui_window_manager.js +602 -0
  25. package/src/c_yui_wizard.js +612 -0
  26. package/src/pager_helpers.js +138 -0
  27. package/src/pager_helpers.test.js +140 -0
  28. package/src/route_resolver.js +53 -0
  29. package/src/route_resolver.test.js +82 -0
  30. package/src/shell_focus_trap.js +123 -0
  31. package/src/shell_focus_trap.test.js +299 -0
  32. package/src/shell_modals.js +445 -0
  33. package/src/shell_show_on.js +91 -0
  34. package/src/shell_show_on.test.js +86 -0
  35. package/src/shell_toolbar_helpers.js +221 -0
  36. package/src/shell_toolbar_helpers.test.js +207 -0
  37. package/src/tabulator.css +53 -0
  38. package/src/wizard_helpers.js +117 -0
  39. package/src/wizard_helpers.test.js +122 -0
  40. package/src/yui_dev.js +1257 -362
  41. package/src/yui_icons.css +5 -0
  42. package/src/yui_inputs.css +32 -0
  43. package/src/yui_inputs.js +71 -0
  44. package/vite-plugin-yuneta-html.js +2 -2
  45. package/skeleton/config.json +0 -20
  46. 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
+ }