@yuneta/gobj-ui 3.0.0 → 5.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.
Files changed (45) hide show
  1. package/README.md +335 -6
  2. package/dist/gobj-ui.cjs.js +10930 -5027
  3. package/dist/gobj-ui.es.js +10948 -5080
  4. package/index.js +45 -1
  5. package/package.json +11 -11
  6. package/src/c_g6_nodes_tree.js +128 -40
  7. package/src/c_yui_form.js +112 -53
  8. package/src/c_yui_gobj_tree_js.js +58 -36
  9. package/src/c_yui_json.css +139 -0
  10. package/src/c_yui_json.js +928 -0
  11. package/src/c_yui_json_graph.js +110 -20
  12. package/src/c_yui_map.js +19 -37
  13. package/src/c_yui_nav.js +6 -9
  14. package/src/c_yui_period.css +184 -0
  15. package/src/c_yui_period.js +1441 -0
  16. package/src/c_yui_shell.css +33 -0
  17. package/src/c_yui_shell.js +672 -111
  18. package/src/c_yui_treedb_graph.js +639 -37
  19. package/src/c_yui_treedb_schema.js +478 -0
  20. package/src/c_yui_treedb_topic_with_form.css +7 -42
  21. package/src/c_yui_treedb_topic_with_form.js +152 -103
  22. package/src/c_yui_treedb_topics.css +73 -0
  23. package/src/c_yui_treedb_topics.js +1070 -29
  24. package/src/c_yui_window.js +180 -97
  25. package/src/c_yui_window_manager.js +38 -4
  26. package/src/json_view_helpers.js +214 -0
  27. package/src/json_view_helpers.test.js +135 -0
  28. package/src/route_map_model.js +317 -0
  29. package/src/route_map_model.test.js +209 -0
  30. package/src/route_resolver.js +24 -1
  31. package/src/route_resolver.test.js +40 -1
  32. package/src/shell_modals.js +117 -39
  33. package/src/shell_route_map.css +225 -0
  34. package/src/shell_route_map.js +363 -0
  35. package/src/tabulator.css +67 -0
  36. package/src/yui_dev.js +130 -8
  37. package/src/yui_frontend_view.js +106 -0
  38. package/src/yui_icons.css +62 -0
  39. package/src/yui_inputs.css +8 -3
  40. package/src/yui_inputs.js +51 -8
  41. package/src/yui_tabulator_i18n.js +128 -0
  42. package/src/yui_theme.js +147 -0
  43. package/src/yui_time.js +666 -0
  44. package/src/yui_time.test.js +330 -0
  45. 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
+ });
@@ -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
+ });
@@ -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 = gobj_read_attr(shell, "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 = gobj_read_attr(shell, "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 = gobj_read_attr(shell, "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", i18n: title}, title],
221
- ["button", {class: "yui-dialog-x", type: "button", "aria-label": "close"},
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: "modal yui-modal" + (dialog ? " yui-dialog" : "") + " is-active",
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
- let close = function() {
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(close_fn) {
277
- yui_shell_pop_escape(shell, close_fn);
278
- close_fn = null;
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
- yui_shell_push_escape(shell, "modal", close);
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", close);
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", close);
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", close);
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", close);
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
  );