@yuneta/gobj-ui 3.0.0 → 4.0.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 +324 -6
- package/dist/gobj-ui.cjs.js +10891 -5006
- package/dist/gobj-ui.es.js +10915 -5064
- package/index.js +45 -1
- package/package.json +4 -4
- package/src/c_g6_nodes_tree.js +128 -40
- package/src/c_yui_form.js +112 -53
- package/src/c_yui_gobj_tree_js.js +58 -36
- package/src/c_yui_json.css +139 -0
- package/src/c_yui_json.js +928 -0
- package/src/c_yui_json_graph.js +110 -20
- package/src/c_yui_map.js +18 -36
- package/src/c_yui_nav.js +6 -9
- package/src/c_yui_period.css +184 -0
- package/src/c_yui_period.js +1441 -0
- package/src/c_yui_shell.css +33 -0
- package/src/c_yui_shell.js +672 -111
- package/src/c_yui_treedb_graph.js +639 -37
- package/src/c_yui_treedb_schema.js +478 -0
- package/src/c_yui_treedb_topic_with_form.css +7 -42
- package/src/c_yui_treedb_topic_with_form.js +152 -103
- package/src/c_yui_treedb_topics.css +73 -0
- package/src/c_yui_treedb_topics.js +1070 -29
- package/src/c_yui_window.js +180 -97
- package/src/c_yui_window_manager.js +38 -4
- package/src/json_view_helpers.js +214 -0
- package/src/json_view_helpers.test.js +135 -0
- package/src/route_map_model.js +317 -0
- package/src/route_map_model.test.js +209 -0
- package/src/route_resolver.js +24 -1
- package/src/route_resolver.test.js +40 -1
- package/src/shell_modals.js +117 -39
- package/src/shell_route_map.css +225 -0
- package/src/shell_route_map.js +363 -0
- package/src/tabulator.css +67 -0
- package/src/yui_dev.js +130 -8
- package/src/yui_frontend_view.js +106 -0
- package/src/yui_icons.css +62 -0
- package/src/yui_inputs.css +8 -3
- package/src/yui_inputs.js +51 -8
- package/src/yui_tabulator_i18n.js +128 -0
- package/src/yui_theme.js +147 -0
- package/src/yui_time.js +666 -0
- package/src/yui_time.test.js +330 -0
- package/src/yui_toolbar.js +86 -43
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/***********************************************************************
|
|
2
|
+
* route_map_model.test.js
|
|
3
|
+
*
|
|
4
|
+
* Unit tests for the pure site-map (nav map) builder.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) 2026, ArtGins.
|
|
7
|
+
* All Rights Reserved.
|
|
8
|
+
***********************************************************************/
|
|
9
|
+
import { describe, test, expect } from "vitest";
|
|
10
|
+
import { build_nav_map } from "./route_map_model.js";
|
|
11
|
+
|
|
12
|
+
const view = (gclass) => ({ stage: "main", gclass: gclass });
|
|
13
|
+
|
|
14
|
+
/* A config + index shaped like a small wattyzer-style app:
|
|
15
|
+
* toolbar (brand + account dropdown), a primary menu with a submenu,
|
|
16
|
+
* a second menu, and a route table with URL-only routes. */
|
|
17
|
+
function make_input(extra)
|
|
18
|
+
{
|
|
19
|
+
const config = {
|
|
20
|
+
toolbar: { items: [
|
|
21
|
+
{ type: "brand", id: "brand", wordmark: "demo",
|
|
22
|
+
action: { type: "navigate", route: "/" } },
|
|
23
|
+
{ id: "theme", name: "theme",
|
|
24
|
+
action: { type: "event", event: "EV_TOGGLE_THEME" } },
|
|
25
|
+
{ type: "avatar", id: "user", name: "account",
|
|
26
|
+
action: { type: "dropdown", items: [
|
|
27
|
+
{ id: "sitemap", name: "site map",
|
|
28
|
+
action: { type: "navigate", route: "/sitemap" } },
|
|
29
|
+
{ type: "divider" },
|
|
30
|
+
{ id: "logout", name: "logout",
|
|
31
|
+
action: { type: "event", event: "EV_LOGOUT" } }
|
|
32
|
+
] } }
|
|
33
|
+
] },
|
|
34
|
+
menu: {
|
|
35
|
+
primary: { items: [
|
|
36
|
+
{ id: "reports", name: "reports", route: "/reports",
|
|
37
|
+
submenu: { items: [
|
|
38
|
+
{ id: "a", name: "a", route: "/reports/a" }
|
|
39
|
+
] } },
|
|
40
|
+
{ id: "form", name: "form", route: "/form" }
|
|
41
|
+
] },
|
|
42
|
+
footer: { items: [
|
|
43
|
+
{ id: "legal", name: "legal", route: "/legal" }
|
|
44
|
+
] }
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
const item_index = {
|
|
48
|
+
"/": { item: null, target: view("C_HOME") },
|
|
49
|
+
"/reports": { item: config.menu.primary.items[0], target: null },
|
|
50
|
+
"/reports/a": { item: null, target: view("C_REPORT_A") },
|
|
51
|
+
"/form": { item: null, target: view("C_FORM") },
|
|
52
|
+
"/legal": { item: null, target: view("C_LEGAL") },
|
|
53
|
+
"/sitemap": { item: null,
|
|
54
|
+
target: { kind: "action", event: "EV_OPEN_SITEMAP",
|
|
55
|
+
redirect: "back" } },
|
|
56
|
+
"/hidden": { item: null, target: view("C_HIDDEN") }
|
|
57
|
+
};
|
|
58
|
+
return Object.assign({
|
|
59
|
+
config: config,
|
|
60
|
+
item_index: item_index,
|
|
61
|
+
sub_routes: {},
|
|
62
|
+
event_handlers: {},
|
|
63
|
+
current_route: ""
|
|
64
|
+
}, extra || {});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe("build_nav_map", () => {
|
|
68
|
+
test("brand + toolbar in declaration order, dividers skipped", () => {
|
|
69
|
+
const m = build_nav_map(make_input());
|
|
70
|
+
expect(m.brand).toEqual({ label: "demo", route: "/" });
|
|
71
|
+
expect(m.toolbar.map(n => n.id)).toEqual(["theme", "user"]);
|
|
72
|
+
expect(m.toolbar[1].children.map(n => n.id))
|
|
73
|
+
.toEqual(["sitemap", "logout"]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("action-route item shows the event it fires", () => {
|
|
77
|
+
const m = build_nav_map(make_input());
|
|
78
|
+
const sitemap = m.toolbar[1].children[0];
|
|
79
|
+
expect(sitemap.route).toBe("/sitemap");
|
|
80
|
+
expect(sitemap.event).toBe("EV_OPEN_SITEMAP");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("primary menu flat; other menus as labelled groups", () => {
|
|
84
|
+
const m = build_nav_map(make_input());
|
|
85
|
+
expect(m.nav.map(n => n.id)).toEqual(["reports", "form", "footer"]);
|
|
86
|
+
const footer = m.nav[2];
|
|
87
|
+
expect(footer.kind).toBe("group");
|
|
88
|
+
expect(footer.children.map(n => n.route)).toEqual(["/legal"]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("other: uncovered route-table routes only, in index order", () => {
|
|
92
|
+
const m = build_nav_map(make_input());
|
|
93
|
+
/* "/" is brand-covered; "/sitemap" is dropdown-covered;
|
|
94
|
+
* "/legal" is footer-covered → only "/hidden" is left. */
|
|
95
|
+
expect(m.other.map(n => n.route)).toEqual(["/hidden"]);
|
|
96
|
+
expect(m.other[0].gclass).toBe("C_HIDDEN");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("dynamic submenu children merged by parent id", () => {
|
|
100
|
+
const input = make_input();
|
|
101
|
+
input.item_index["/reports/rt1"] = {
|
|
102
|
+
item: { id: "rt1", name: "runtime tab" },
|
|
103
|
+
parent_item: input.config.menu.primary.items[0],
|
|
104
|
+
target: view("C_RT")
|
|
105
|
+
};
|
|
106
|
+
const m = build_nav_map(input);
|
|
107
|
+
const reports = m.nav[0];
|
|
108
|
+
expect(reports.children.map(n => n.route))
|
|
109
|
+
.toEqual(["/reports/a", "/reports/rt1"]);
|
|
110
|
+
/* ...and a dynamic child is covered, not duplicated in other. */
|
|
111
|
+
expect(m.other.map(n => n.route)).toEqual(["/hidden"]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("sub_routes enrich their base node (and count as covered)", () => {
|
|
115
|
+
const input = make_input({
|
|
116
|
+
sub_routes: { "/reports/a": [
|
|
117
|
+
{ route: "/reports/a/x", label: "x" }
|
|
118
|
+
] }
|
|
119
|
+
});
|
|
120
|
+
const m = build_nav_map(input);
|
|
121
|
+
const a = m.nav[0].children[0];
|
|
122
|
+
expect(a.children.map(n => n.route)).toEqual(["/reports/a/x"]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("event handlers stamp the implementing gclass", () => {
|
|
126
|
+
const input = make_input({
|
|
127
|
+
event_handlers: { "EV_LOGOUT": ["C_APP"] }
|
|
128
|
+
});
|
|
129
|
+
const m = build_nav_map(input);
|
|
130
|
+
const logout = m.toolbar[1].children[1];
|
|
131
|
+
expect(logout.gclass).toBe("C_APP");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("current: exact route marked", () => {
|
|
135
|
+
const m = build_nav_map(make_input({ current_route: "/form" }));
|
|
136
|
+
const form = m.nav[1];
|
|
137
|
+
expect(form.current).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("current: deep subpath marks the longest base node", () => {
|
|
141
|
+
const m = build_nav_map(
|
|
142
|
+
make_input({ current_route: "/reports/a/topic/42" }));
|
|
143
|
+
expect(m.nav[0].children[0].current).toBe(true); /* /reports/a */
|
|
144
|
+
expect(m.nav[0].current).toBeUndefined();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("current: the brand's own route is markable (it renders as root)", () => {
|
|
148
|
+
const m = build_nav_map(make_input({ current_route: "/" }));
|
|
149
|
+
expect(m.brand.current).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("current: a menu item beats the brand on the same route", () => {
|
|
153
|
+
const input = make_input({ current_route: "/" });
|
|
154
|
+
input.config.menu.primary.items.push(
|
|
155
|
+
{ id: "home", name: "home", route: "/" });
|
|
156
|
+
const m = build_nav_map(input);
|
|
157
|
+
expect(m.nav.find(n => n.id === "home").current).toBe(true);
|
|
158
|
+
expect(m.brand.current).toBeUndefined();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
/* yui_shell_set_sub_routes stores the view's array BY REFERENCE, so a
|
|
162
|
+
* builder that splices those objects in and stamps `current` on one
|
|
163
|
+
* leaves the mark on a view-owned object forever: the next build shows
|
|
164
|
+
* a second "you are here" and the viewer scrolls to the stale one. */
|
|
165
|
+
test("sub-route contributions are cloned, never marked in place", () => {
|
|
166
|
+
const sub_routes = { "/reports": [
|
|
167
|
+
{ id: "", label: "a", icon: "", route: "/reports/topic",
|
|
168
|
+
event: "", gclass: "C_TOPIC", kind: "route", children: [] }
|
|
169
|
+
] };
|
|
170
|
+
const marked = (m) => {
|
|
171
|
+
const out = [];
|
|
172
|
+
const visit = (n) => {
|
|
173
|
+
if(n.current) {
|
|
174
|
+
out.push(n.route);
|
|
175
|
+
}
|
|
176
|
+
(n.children || []).forEach(visit);
|
|
177
|
+
};
|
|
178
|
+
m.toolbar.forEach(visit);
|
|
179
|
+
m.nav.forEach(visit);
|
|
180
|
+
m.other.forEach(visit);
|
|
181
|
+
return out;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const first = build_nav_map(
|
|
185
|
+
make_input({ sub_routes: sub_routes, current_route: "/reports/topic" }));
|
|
186
|
+
expect(marked(first)).toEqual(["/reports/topic"]);
|
|
187
|
+
/* the caller's own object was never touched */
|
|
188
|
+
expect(sub_routes["/reports"][0].current).toBeUndefined();
|
|
189
|
+
|
|
190
|
+
const second = build_nav_map(
|
|
191
|
+
make_input({ sub_routes: sub_routes, current_route: "/form" }));
|
|
192
|
+
expect(marked(second)).toEqual(["/form"]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("current: at most one node, none when nothing matches", () => {
|
|
196
|
+
const m = build_nav_map(make_input({ current_route: "/nope" }));
|
|
197
|
+
const marked = [];
|
|
198
|
+
const visit = (n) => {
|
|
199
|
+
if(n.current) {
|
|
200
|
+
marked.push(n.route);
|
|
201
|
+
}
|
|
202
|
+
(n.children || []).forEach(visit);
|
|
203
|
+
};
|
|
204
|
+
m.toolbar.forEach(visit);
|
|
205
|
+
m.nav.forEach(visit);
|
|
206
|
+
m.other.forEach(visit);
|
|
207
|
+
expect(marked).toEqual([]);
|
|
208
|
+
});
|
|
209
|
+
});
|
package/src/route_resolver.js
CHANGED
|
@@ -24,6 +24,29 @@
|
|
|
24
24
|
* - nothing matched → entry as found (may be
|
|
25
25
|
* null or a targetless submenu parent), subpath "".
|
|
26
26
|
************************************************************/
|
|
27
|
+
/************************************************************
|
|
28
|
+
* normalize_route — canonical form of a route: leading "/",
|
|
29
|
+
* duplicate slashes collapsed, trailing slashes stripped
|
|
30
|
+
* (root "/" stays "/"). Routes arrive from the outside
|
|
31
|
+
* world (typed URLs, shared links, old bookmarks): "#/a/b/"
|
|
32
|
+
* must resolve like "#/a/b" instead of missing the index
|
|
33
|
+
* and falling into the unknown-route redirect.
|
|
34
|
+
************************************************************/
|
|
35
|
+
function normalize_route(route)
|
|
36
|
+
{
|
|
37
|
+
if(!route) {
|
|
38
|
+
return "";
|
|
39
|
+
}
|
|
40
|
+
let s = String(route).replace(/\/{2,}/g, "/");
|
|
41
|
+
if(s.charAt(0) !== "/") {
|
|
42
|
+
s = "/" + s;
|
|
43
|
+
}
|
|
44
|
+
if(s.length > 1) {
|
|
45
|
+
s = s.replace(/\/+$/, "");
|
|
46
|
+
}
|
|
47
|
+
return s;
|
|
48
|
+
}
|
|
49
|
+
|
|
27
50
|
function resolve_route(item_index, route)
|
|
28
51
|
{
|
|
29
52
|
let entry = item_index[route];
|
|
@@ -50,4 +73,4 @@ function resolve_route(item_index, route)
|
|
|
50
73
|
return { entry: entry, matched_route: matched_route, subpath: subpath };
|
|
51
74
|
}
|
|
52
75
|
|
|
53
|
-
export { resolve_route };
|
|
76
|
+
export { resolve_route, normalize_route };
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* All Rights Reserved.
|
|
8
8
|
***********************************************************************/
|
|
9
9
|
import { describe, test, expect } from "vitest";
|
|
10
|
-
import { resolve_route } from "./route_resolver.js";
|
|
10
|
+
import { resolve_route, normalize_route } from "./route_resolver.js";
|
|
11
11
|
|
|
12
12
|
/* Minimal route table shaped like C_YUI_SHELL.priv.item_index. */
|
|
13
13
|
function make_index() {
|
|
@@ -80,3 +80,42 @@ describe("resolve_route", () => {
|
|
|
80
80
|
expect(r.entry.item.submenu).toBeTruthy();
|
|
81
81
|
});
|
|
82
82
|
});
|
|
83
|
+
|
|
84
|
+
describe("normalize_route", () => {
|
|
85
|
+
test("already canonical → unchanged", () => {
|
|
86
|
+
expect(normalize_route("/a/b")).toBe("/a/b");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("trailing slash stripped", () => {
|
|
90
|
+
expect(normalize_route("/a/b/")).toBe("/a/b");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("multiple trailing slashes stripped", () => {
|
|
94
|
+
expect(normalize_route("/a/b///")).toBe("/a/b");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("duplicate inner slashes collapsed", () => {
|
|
98
|
+
expect(normalize_route("//a///b")).toBe("/a/b");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("missing leading slash added", () => {
|
|
102
|
+
expect(normalize_route("a/b")).toBe("/a/b");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("root stays root", () => {
|
|
106
|
+
expect(normalize_route("/")).toBe("/");
|
|
107
|
+
expect(normalize_route("///")).toBe("/");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("empty stays empty (caller decides the error)", () => {
|
|
111
|
+
expect(normalize_route("")).toBe("");
|
|
112
|
+
expect(normalize_route(null)).toBe("");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("normalized trailing-slash route resolves like the clean one", () => {
|
|
116
|
+
const idx = make_index();
|
|
117
|
+
const r = resolve_route(idx, normalize_route("/monitoring/realtime/"));
|
|
118
|
+
expect(r.matched_route).toBe("/monitoring/realtime");
|
|
119
|
+
expect(r.subpath).toBe("");
|
|
120
|
+
});
|
|
121
|
+
});
|
package/src/shell_modals.js
CHANGED
|
@@ -41,6 +41,8 @@ import {
|
|
|
41
41
|
import {
|
|
42
42
|
yui_shell_push_escape,
|
|
43
43
|
yui_shell_pop_escape,
|
|
44
|
+
yui_shell_register_overlay,
|
|
45
|
+
yui_shell_overlay_dismissed,
|
|
44
46
|
} from "./c_yui_shell.js";
|
|
45
47
|
|
|
46
48
|
|
|
@@ -52,7 +54,7 @@ function notification_layer(shell)
|
|
|
52
54
|
if(!shell) {
|
|
53
55
|
return null;
|
|
54
56
|
}
|
|
55
|
-
let priv =
|
|
57
|
+
let priv = shell.priv;
|
|
56
58
|
return priv && priv.layers && priv.layers.notification;
|
|
57
59
|
}
|
|
58
60
|
|
|
@@ -61,7 +63,7 @@ function modal_layer(shell)
|
|
|
61
63
|
if(!shell) {
|
|
62
64
|
return null;
|
|
63
65
|
}
|
|
64
|
-
let priv =
|
|
66
|
+
let priv = shell.priv;
|
|
65
67
|
return priv && priv.layers && priv.layers.modal;
|
|
66
68
|
}
|
|
67
69
|
|
|
@@ -75,7 +77,7 @@ export function yui_shell_popup_layer(shell)
|
|
|
75
77
|
if(!shell) {
|
|
76
78
|
return null;
|
|
77
79
|
}
|
|
78
|
-
let priv =
|
|
80
|
+
let priv = shell.priv;
|
|
79
81
|
return (priv && priv.layers && priv.layers.popup) || null;
|
|
80
82
|
}
|
|
81
83
|
|
|
@@ -119,11 +121,11 @@ function show_notification(shell, kind, message, opts)
|
|
|
119
121
|
? {i18n: message}
|
|
120
122
|
: {};
|
|
121
123
|
let $note = createElement2(
|
|
122
|
-
["div", {class: `notification yui-notification is-${kind} is-light`,
|
|
124
|
+
["div", {class: `TOAST notification yui-notification is-${kind} is-light`,
|
|
123
125
|
role: kind === "danger" ? "alert" : "status"},
|
|
124
126
|
[
|
|
125
|
-
["button", {class: "delete", "aria-label": "close"}],
|
|
126
|
-
["p", p_attrs, message]
|
|
127
|
+
["button", {class: "TOAST_CLOSE delete", "aria-label": "close"}],
|
|
128
|
+
["p", {...p_attrs, class: "TOAST_MSG"}, message]
|
|
127
129
|
]
|
|
128
130
|
]
|
|
129
131
|
);
|
|
@@ -205,6 +207,14 @@ export function yui_shell_show_modal(shell, content, opts)
|
|
|
205
207
|
* history.back()), so gobj-ui stays routing-agnostic. */
|
|
206
208
|
let dialog = !!(opts && opts.dialog);
|
|
207
209
|
let title = (opts && opts.title) || "";
|
|
210
|
+
/* Optional DATA half of the title (a topic/service name), never
|
|
211
|
+
* translated, shown before `title`. Same contract as C_YUI_WINDOW's
|
|
212
|
+
* `title_prefix`, and for the same reason: composing
|
|
213
|
+
* `${topic} · ${t("keys")}` into `title` yields a string that is not
|
|
214
|
+
* an i18n key, so it never re-translates. Split, only the kind half
|
|
215
|
+
* carries a key. The separator is CSS — createElement2 trims text
|
|
216
|
+
* nodes and would eat the spaces around it. */
|
|
217
|
+
let title_prefix = (opts && opts.title_prefix) || "";
|
|
208
218
|
|
|
209
219
|
/* The external Bulma `.modal-close is-large` sits at the
|
|
210
220
|
* top-right of the viewport, outside the content box. Callers
|
|
@@ -214,37 +224,47 @@ export function yui_shell_show_modal(shell, content, opts)
|
|
|
214
224
|
let with_close = !(opts && opts.with_close_button === false);
|
|
215
225
|
let modal_children;
|
|
216
226
|
if(dialog) {
|
|
217
|
-
let header = ["div", {class: "yui-dialog-header"}, [
|
|
218
|
-
["button", {class: "yui-dialog-back", type: "button", "aria-label": "back"},
|
|
227
|
+
let header = ["div", {class: "MODAL_HEADER yui-dialog-header"}, [
|
|
228
|
+
["button", {class: "MODAL_BACK yui-dialog-back", type: "button", "aria-label": "back"},
|
|
219
229
|
[["i", {class: "yi-arrow-left"}]]],
|
|
220
|
-
["span", {class: "yui-dialog-title"
|
|
221
|
-
|
|
230
|
+
["span", {class: "MODAL_TITLE yui-dialog-title"},
|
|
231
|
+
(title_prefix ? [["span", {class: "MODAL_TITLE_PREFIX"}, title_prefix]] : [])
|
|
232
|
+
.concat(title
|
|
233
|
+
? [["span", {class: "MODAL_TITLE_KIND", i18n: title}, title]]
|
|
234
|
+
: [])],
|
|
235
|
+
["button", {class: "MODAL_CLOSE yui-dialog-x", type: "button", "aria-label": "close"},
|
|
222
236
|
[["i", {class: "yi-xmark"}]]],
|
|
223
237
|
]];
|
|
224
|
-
let body = ["div", {class: "yui-dialog-body"}, inner ? [inner] : []];
|
|
238
|
+
let body = ["div", {class: "MODAL_BODY yui-dialog-body"}, inner ? [inner] : []];
|
|
225
239
|
modal_children = [
|
|
226
|
-
["div", {class: "modal-background"}],
|
|
227
|
-
["div", {class: "modal-content yui-dialog-content"}, [header, body]]
|
|
240
|
+
["div", {class: "MODAL_BACKDROP modal-background"}],
|
|
241
|
+
["div", {class: "MODAL_CONTENT modal-content yui-dialog-content"}, [header, body]]
|
|
228
242
|
];
|
|
229
243
|
/* The header X replaces the external Bulma close button. */
|
|
230
244
|
with_close = false;
|
|
231
245
|
} else {
|
|
232
246
|
modal_children = [
|
|
233
|
-
["div", {class: "modal-background"}],
|
|
234
|
-
["div", {class: "modal-content"},
|
|
247
|
+
["div", {class: "MODAL_BACKDROP modal-background"}],
|
|
248
|
+
["div", {class: "MODAL_CONTENT modal-content"},
|
|
235
249
|
inner ? [inner] : []
|
|
236
250
|
]
|
|
237
251
|
];
|
|
238
252
|
if(with_close) {
|
|
239
253
|
modal_children.push(
|
|
240
|
-
["button", {class: "modal-close is-large",
|
|
254
|
+
["button", {class: "MODAL_CLOSE modal-close is-large",
|
|
241
255
|
"aria-label": "close"}]
|
|
242
256
|
);
|
|
243
257
|
}
|
|
244
258
|
}
|
|
245
259
|
|
|
260
|
+
/* `opts.logical_class`: the CALLER's UPPER_SNAKE name for THIS modal
|
|
261
|
+
* (e.g. TRANGER_KEYS_SHEET). Every modal shares the MODAL block names,
|
|
262
|
+
* only this tells one popup from another in the Inspector or a selector. */
|
|
263
|
+
let logical = (opts && opts.logical_class) || "";
|
|
264
|
+
|
|
246
265
|
let $modal = createElement2(
|
|
247
|
-
["div", {class: "
|
|
266
|
+
["div", {class: "MODAL" + (logical ? " " + logical : "") +
|
|
267
|
+
" modal yui-modal" + (dialog ? " yui-dialog" : "") + " is-active",
|
|
248
268
|
role: "dialog", "aria-modal": "true"},
|
|
249
269
|
modal_children
|
|
250
270
|
]
|
|
@@ -261,10 +281,13 @@ export function yui_shell_show_modal(shell, content, opts)
|
|
|
261
281
|
maybe_apply_translator($modal, opts);
|
|
262
282
|
|
|
263
283
|
let closed = false;
|
|
264
|
-
let close_fn = null;
|
|
265
284
|
let release_focus = null;
|
|
285
|
+
let overlay = null;
|
|
286
|
+
let escape_handler = null;
|
|
266
287
|
|
|
267
|
-
|
|
288
|
+
/* Unconditional teardown. The returned close() maps here: a
|
|
289
|
+
* programmatic close always closes, bypassing before_close. */
|
|
290
|
+
let do_close = function() {
|
|
268
291
|
if(closed) {
|
|
269
292
|
return;
|
|
270
293
|
}
|
|
@@ -273,13 +296,16 @@ export function yui_shell_show_modal(shell, content, opts)
|
|
|
273
296
|
release_focus();
|
|
274
297
|
release_focus = null;
|
|
275
298
|
}
|
|
276
|
-
if(
|
|
277
|
-
yui_shell_pop_escape(shell,
|
|
278
|
-
|
|
299
|
+
if(escape_handler) {
|
|
300
|
+
yui_shell_pop_escape(shell, escape_handler);
|
|
301
|
+
escape_handler = null;
|
|
279
302
|
}
|
|
280
303
|
if($modal.parentNode) {
|
|
281
304
|
$modal.parentNode.removeChild($modal);
|
|
282
305
|
}
|
|
306
|
+
/* Retire the browser-history entry (no-op when Back triggered
|
|
307
|
+
* this close — the entry is already gone). */
|
|
308
|
+
yui_shell_overlay_dismissed(shell, overlay);
|
|
283
309
|
if(opts && typeof opts.on_close === "function") {
|
|
284
310
|
try {
|
|
285
311
|
opts.on_close();
|
|
@@ -288,9 +314,50 @@ export function yui_shell_show_modal(shell, content, opts)
|
|
|
288
314
|
}
|
|
289
315
|
}
|
|
290
316
|
};
|
|
291
|
-
close_fn = close;
|
|
292
317
|
|
|
293
|
-
|
|
318
|
+
/* `opts.before_close`: a guard consulted on EVERY user-driven dismiss
|
|
319
|
+
* (Escape, backdrop, the X / back-arrow, browser Back). Return false to
|
|
320
|
+
* VETO — the modal stays up and the caller takes over (e.g. an
|
|
321
|
+
* unsaved-changes prompt that calls close() itself on confirm). Return
|
|
322
|
+
* true/undefined to let it close. Absent guard ⇒ always closes, so
|
|
323
|
+
* existing callers are unaffected. */
|
|
324
|
+
let guarded_close = function(on_veto) {
|
|
325
|
+
if(closed) {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
let allow = true;
|
|
329
|
+
if(opts && typeof opts.before_close === "function") {
|
|
330
|
+
try {
|
|
331
|
+
allow = opts.before_close();
|
|
332
|
+
} catch(e) {
|
|
333
|
+
log_warning(`yui_shell_show_modal: before_close threw: ${e}`);
|
|
334
|
+
allow = true;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
if(allow === false) {
|
|
338
|
+
if(typeof on_veto === "function") {
|
|
339
|
+
on_veto();
|
|
340
|
+
}
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
do_close();
|
|
344
|
+
};
|
|
345
|
+
let request_close = function() {
|
|
346
|
+
guarded_close(null);
|
|
347
|
+
};
|
|
348
|
+
/* Browser Back already consumed the history entry; if the guard vetoes,
|
|
349
|
+
* re-arm a fresh entry so a later Back still targets this modal. */
|
|
350
|
+
let on_back = function() {
|
|
351
|
+
guarded_close(function() {
|
|
352
|
+
overlay = yui_shell_register_overlay(shell, on_back);
|
|
353
|
+
});
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
escape_handler = request_close;
|
|
357
|
+
yui_shell_push_escape(shell, "modal", request_close);
|
|
358
|
+
/* Browser Back closes the top-most modal (see overlay history in
|
|
359
|
+
* c_yui_shell.js). Null when history integration is off. */
|
|
360
|
+
overlay = yui_shell_register_overlay(shell, on_back);
|
|
294
361
|
/* Trap on $modal (not on .modal-content) so the .modal-close
|
|
295
362
|
* button — rendered as a SIBLING of .modal-content — is
|
|
296
363
|
* reachable via Tab. Without this, Tab/Shift+Tab can only
|
|
@@ -299,24 +366,24 @@ export function yui_shell_show_modal(shell, content, opts)
|
|
|
299
366
|
release_focus = activate_focus_trap_on($modal);
|
|
300
367
|
|
|
301
368
|
if((opts == null || opts.dismiss_on_background !== false)) {
|
|
302
|
-
$modal.querySelector(".modal-background").addEventListener("click",
|
|
369
|
+
$modal.querySelector(".modal-background").addEventListener("click", request_close);
|
|
303
370
|
}
|
|
304
371
|
let $close_btn = $modal.querySelector(".modal-close");
|
|
305
372
|
if($close_btn) {
|
|
306
|
-
$close_btn.addEventListener("click",
|
|
373
|
+
$close_btn.addEventListener("click", request_close);
|
|
307
374
|
}
|
|
308
375
|
if(dialog) {
|
|
309
376
|
let $back = $modal.querySelector(".yui-dialog-back");
|
|
310
377
|
if($back) {
|
|
311
|
-
$back.addEventListener("click",
|
|
378
|
+
$back.addEventListener("click", request_close);
|
|
312
379
|
}
|
|
313
380
|
let $x = $modal.querySelector(".yui-dialog-x");
|
|
314
381
|
if($x) {
|
|
315
|
-
$x.addEventListener("click",
|
|
382
|
+
$x.addEventListener("click", request_close);
|
|
316
383
|
}
|
|
317
384
|
}
|
|
318
385
|
|
|
319
|
-
return { close };
|
|
386
|
+
return { close: do_close };
|
|
320
387
|
}
|
|
321
388
|
|
|
322
389
|
|
|
@@ -361,11 +428,11 @@ function build_dialog(shell, message, buttons, opts)
|
|
|
361
428
|
let icon = CONFIRM_TYPE_ICONS[type] || CONFIRM_TYPE_ICONS["question"];
|
|
362
429
|
|
|
363
430
|
let $body_children = (typeof message === "string")
|
|
364
|
-
? [["p", {class: "yui-confirm-msg", i18n: message}, message]]
|
|
431
|
+
? [["p", {class: "CONFIRM_MSG yui-confirm-msg", i18n: message}, message]]
|
|
365
432
|
: [message];
|
|
366
433
|
|
|
367
434
|
let $footer_children = buttons.map(b => {
|
|
368
|
-
let cls = "button px-5";
|
|
435
|
+
let cls = "CONFIRM_BTN button px-5";
|
|
369
436
|
if(b.kind === "primary") {
|
|
370
437
|
cls += " is-link";
|
|
371
438
|
} else if(b.kind === "danger") {
|
|
@@ -380,29 +447,33 @@ function build_dialog(shell, message, buttons, opts)
|
|
|
380
447
|
});
|
|
381
448
|
|
|
382
449
|
let $card_children = [
|
|
383
|
-
["div", {class: "yui-confirm-icon"},
|
|
450
|
+
["div", {class: "CONFIRM_ICON yui-confirm-icon"},
|
|
384
451
|
[["i", {class: icon, "aria-hidden": "true"}]]
|
|
385
452
|
]
|
|
386
453
|
];
|
|
387
454
|
if(!empty_string(title)) {
|
|
388
455
|
$card_children.push(
|
|
389
|
-
["p", {class: "yui-confirm-title has-text-centered",
|
|
456
|
+
["p", {class: "CONFIRM_TITLE yui-confirm-title has-text-centered",
|
|
390
457
|
i18n: title}, title]
|
|
391
458
|
);
|
|
392
459
|
}
|
|
393
460
|
$card_children.push(
|
|
394
|
-
["button", {class: "delete yui-confirm-x", "aria-label": "close"}],
|
|
395
|
-
["section", {class: "modal-card-body has-text-centered"},
|
|
461
|
+
["button", {class: "CONFIRM_CLOSE delete yui-confirm-x", "aria-label": "close"}],
|
|
462
|
+
["section", {class: "CONFIRM_BODY modal-card-body has-text-centered"},
|
|
396
463
|
$body_children],
|
|
397
|
-
["footer", {class: "modal-card-foot"}, $footer_children]
|
|
464
|
+
["footer", {class: "CONFIRM_FOOT modal-card-foot"}, $footer_children]
|
|
398
465
|
);
|
|
399
466
|
|
|
467
|
+
/* Caller's own UPPER_SNAKE name for THIS confirm, same contract as
|
|
468
|
+
* yui_shell_show_modal's `logical_class`. */
|
|
469
|
+
let logical = (opts && opts.logical_class) ? " " + opts.logical_class : "";
|
|
470
|
+
|
|
400
471
|
let $modal = createElement2(
|
|
401
|
-
["div", {class: `modal yui-modal yui-confirm is-active is-${type}`,
|
|
472
|
+
["div", {class: `CONFIRM${logical} modal yui-modal yui-confirm is-active is-${type}`,
|
|
402
473
|
role: "dialog", "aria-modal": "true"},
|
|
403
474
|
[
|
|
404
|
-
["div", {class: "modal-background"}],
|
|
405
|
-
["div", {class: "modal-card"}, $card_children]
|
|
475
|
+
["div", {class: "CONFIRM_BACKDROP modal-background"}],
|
|
476
|
+
["div", {class: "CONFIRM_CARD modal-card"}, $card_children]
|
|
406
477
|
]
|
|
407
478
|
]
|
|
408
479
|
);
|
|
@@ -413,6 +484,7 @@ function build_dialog(shell, message, buttons, opts)
|
|
|
413
484
|
let resolved = false;
|
|
414
485
|
let close_fn = null;
|
|
415
486
|
let release_focus = null;
|
|
487
|
+
let overlay = null;
|
|
416
488
|
|
|
417
489
|
let close = function(value) {
|
|
418
490
|
if(resolved) {
|
|
@@ -430,11 +502,17 @@ function build_dialog(shell, message, buttons, opts)
|
|
|
430
502
|
if($modal.parentNode) {
|
|
431
503
|
$modal.parentNode.removeChild($modal);
|
|
432
504
|
}
|
|
505
|
+
/* Retire the browser-history entry (no-op when Back
|
|
506
|
+
* triggered this close). */
|
|
507
|
+
yui_shell_overlay_dismissed(shell, overlay);
|
|
433
508
|
resolve(value);
|
|
434
509
|
};
|
|
435
510
|
close_fn = () => close(dismiss_value);
|
|
436
511
|
|
|
437
512
|
yui_shell_push_escape(shell, "modal", close_fn);
|
|
513
|
+
/* Browser Back dismisses the dialog with the safe-default value,
|
|
514
|
+
* exactly like Escape. */
|
|
515
|
+
overlay = yui_shell_register_overlay(shell, close_fn);
|
|
438
516
|
release_focus = activate_focus_trap_on(
|
|
439
517
|
$modal.querySelector(".modal-card")
|
|
440
518
|
);
|