@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
@@ -42,6 +42,7 @@ import {
42
42
  gobj_read_attr, gobj_read_pointer_attr, gobj_write_attr,
43
43
  createElement2, empty_string, is_object, is_array, is_string,
44
44
  refresh_language,
45
+ is_gobj,
45
46
  } from "@yuneta/gobj-js";
46
47
 
47
48
  import {
@@ -59,7 +60,8 @@ import {
59
60
  validate_toolbar_item,
60
61
  } from "./shell_toolbar_helpers.js";
61
62
 
62
- import { resolve_route } from "./route_resolver.js";
63
+ import { resolve_route, normalize_route } from "./route_resolver.js";
64
+ import { build_nav_map } from "./route_map_model.js";
63
65
  import {
64
66
  section_index_target,
65
67
  secondary_nav_renders,
@@ -102,11 +104,73 @@ SDATA(data_type_t.DTP_BOOLEAN, "use_hash", 0, true, "Bind navigation to
102
104
  SDATA(data_type_t.DTP_POINTER, "mount_element", 0, null, "HTMLElement to mount shell into (default: document.body)"),
103
105
 
104
106
  SDATA(data_type_t.DTP_POINTER, "$container", 0, null, "Root HTMLElement of the shell"),
105
- SDATA(data_type_t.DTP_POINTER, "priv", 0, null, "Private runtime state (zones/layers/stages/navs)"),
106
107
  SDATA_END()
107
108
  ];
108
109
 
109
- let PRIVATE_DATA = {};
110
+ let PRIVATE_DATA = {
111
+ zones: {},
112
+ layers: {},
113
+ stages: {},
114
+ navs: [],
115
+ item_index: {},
116
+ /* Sub-route contributor registry (ROUTING.md): a mounted view
117
+ * declares the deep, view-owned children of its base route
118
+ * (topics, /info, /schema, focus topics — subpaths that are NOT
119
+ * declared routes) so the site map can show the full tree. Keyed
120
+ * by base route → ordered [{route,label,icon,children?}]. */
121
+ sub_routes: {},
122
+ /* Event → handler GClass(es) registry (ROUTING.md): a gclass that
123
+ * handles a toolbar/account action event self-declares so the site
124
+ * map can show WHERE the action is implemented. Keyed by event name
125
+ * → array of gclass names. */
126
+ event_handlers: {},
127
+ hash_handler: null,
128
+ keydown_handler: null,
129
+ /* Escape priority chain: array of { layer, handler }. Each
130
+ * interactive overlay (drawer today, modal/popup tomorrow)
131
+ * pushes its close handler when it opens and pops it when
132
+ * it closes. Escape calls the top handler only — LIFO. */
133
+ escape_stack: [],
134
+ /* Overlay history integration (Back button ↔ modals/windows).
135
+ * Each history-participating overlay (modal, floating window)
136
+ * pushes a { id, close } record here when it opens, plus a
137
+ * synthetic browser-history entry (pushState). The browser Back
138
+ * button then closes the TOP overlay instead of navigating; and
139
+ * an overlay dismissed by any other path (X, Escape, backdrop,
140
+ * code) retires its history entry via history.back(). Gated on
141
+ * `use_hash` — see push_overlay_history / overlay_dismissed. */
142
+ overlay_stack: [],
143
+ overlay_seq: 0,
144
+ /* Count of history.back() calls WE issued to retire a dismissed
145
+ * overlay; the popstate they trigger is expected and ignored. */
146
+ expected_pops: 0,
147
+ popstate_handler: null,
148
+ /* Avatar item support — every toolbar item with type:"avatar"
149
+ * registers its <span> here so refresh_avatars() can repaint
150
+ * the initials when the host (wattyzer, hidraulia, …) calls
151
+ * yui_shell_set_avatar_provider() / yui_shell_refresh_avatars().
152
+ * The provider is a () => string callback owned by the host. */
153
+ avatar_provider: null,
154
+ avatar_nodes: [],
155
+ /* Optional i18n translator (t-function) the host registers via
156
+ * yui_shell_set_translator(). The host translates the static
157
+ * shell tree by calling refresh_language($container, t) once,
158
+ * but LAZILY-built DOM (the toolbar dropdown panel) is mounted
159
+ * on the popup layer, OUTSIDE $container, AFTER that call — so
160
+ * it would never be translated. When a translator is set the
161
+ * shell re-applies it to each freshly built panel. */
162
+ translator: null,
163
+ /* Connection-indicator support — every toolbar item with
164
+ * type:"connection" registers its dot <span> here so
165
+ * yui_shell_set_connection_state(shell, bool) can repaint the
166
+ * backend-connected state. State is host/event-driven (unlike
167
+ * the avatar provider it is a setter, not a pull callback). */
168
+ conn_nodes: [],
169
+ /* Currently open toolbar dropdown panel, if any. Tracked here
170
+ * so a second click on any trigger (or programmatic close) can
171
+ * tear down the previous one through the same code path. */
172
+ active_dropdown: null
173
+ };
110
174
 
111
175
  let __gclass__ = null;
112
176
 
@@ -133,47 +197,6 @@ function mt_create(gobj)
133
197
  gobj_subscribe_event(gobj, null, {}, subscriber);
134
198
  }
135
199
 
136
- /* Per-instance private state (avoid the gclass-level PRIVATE_DATA). */
137
- gobj_write_attr(gobj, "priv", {
138
- zones: {},
139
- layers: {},
140
- stages: {},
141
- navs: [],
142
- item_index: {},
143
- hash_handler: null,
144
- keydown_handler: null,
145
- /* Escape priority chain: array of { layer, handler }. Each
146
- * interactive overlay (drawer today, modal/popup tomorrow)
147
- * pushes its close handler when it opens and pops it when
148
- * it closes. Escape calls the top handler only — LIFO. */
149
- escape_stack: [],
150
- /* Avatar item support — every toolbar item with type:"avatar"
151
- * registers its <span> here so refresh_avatars() can repaint
152
- * the initials when the host (wattyzer, hidraulia, …) calls
153
- * yui_shell_set_avatar_provider() / yui_shell_refresh_avatars().
154
- * The provider is a () => string callback owned by the host. */
155
- avatar_provider: null,
156
- avatar_nodes: [],
157
- /* Optional i18n translator (t-function) the host registers via
158
- * yui_shell_set_translator(). The host translates the static
159
- * shell tree by calling refresh_language($container, t) once,
160
- * but LAZILY-built DOM (the toolbar dropdown panel) is mounted
161
- * on the popup layer, OUTSIDE $container, AFTER that call — so
162
- * it would never be translated. When a translator is set the
163
- * shell re-applies it to each freshly built panel. */
164
- translator: null,
165
- /* Connection-indicator support — every toolbar item with
166
- * type:"connection" registers its dot <span> here so
167
- * yui_shell_set_connection_state(shell, bool) can repaint the
168
- * backend-connected state. State is host/event-driven (unlike
169
- * the avatar provider it is a setter, not a pull callback). */
170
- conn_nodes: [],
171
- /* Currently open toolbar dropdown panel, if any. Tracked here
172
- * so a second click on any trigger (or programmatic close) can
173
- * tear down the previous one through the same code path. */
174
- active_dropdown: null
175
- });
176
-
177
200
  __last_shell__ = gobj;
178
201
 
179
202
  build_ui(gobj);
@@ -185,7 +208,7 @@ function mt_create(gobj)
185
208
  function mt_start(gobj)
186
209
  {
187
210
  let config = gobj_read_attr(gobj, "config") || {};
188
- let priv = gobj_read_attr(gobj, "priv");
211
+ let priv = gobj.priv;
189
212
 
190
213
  /* Validate the declarative config before anything reads it. This is a
191
214
  * system boundary (app-supplied JSON); validation makes shape errors
@@ -217,6 +240,69 @@ function mt_start(gobj)
217
240
  }
218
241
  };
219
242
  window.addEventListener("hashchange", priv.hash_handler);
243
+
244
+ /* Back button ↔ overlays. A synthetic overlay history entry
245
+ * keeps the same hash, so Back over it fires popstate WITHOUT a
246
+ * hashchange: close the top overlay and consume the event. A
247
+ * real route Back changes the hash and is handled by hash_handler
248
+ * above. */
249
+ priv.popstate_handler = (ev) => {
250
+ let p = gobj.priv;
251
+ if(!p) {
252
+ return;
253
+ }
254
+ if(p.expected_pops > 0) {
255
+ /* A history.back() we issued to retire a dismissed
256
+ * overlay — the teardown already happened. */
257
+ p.expected_pops--;
258
+ return;
259
+ }
260
+ if(p.overlay_stack.length === 0) {
261
+ return;
262
+ }
263
+ let top = p.overlay_stack[p.overlay_stack.length - 1];
264
+ /* Did this traversal step OFF the top overlay's marker?
265
+ * That is the only landing that closes it, and the marker's
266
+ * OWN hash is what says so: pushState() with no URL keeps
267
+ * the current one, so stepping off a marker always lands on
268
+ * the marker's hash. A landing on any OTHER hash is a route
269
+ * traversal (fragment pushes fire popstate too) and belongs
270
+ * to the hashchange handler — navigate_to() closes the
271
+ * transient overlays when the RESTING route changes, so a
272
+ * subpath move keeps them open.
273
+ *
274
+ * Compare against the MARKER's hash, never against the
275
+ * resting route's: an action route (ROUTING.md §7.1) is
276
+ * transient — `current_route` stays on the underlying
277
+ * resting view while the URL sits on the action route — so
278
+ * a `stay` modal's marker legitimately lives on a hash that
279
+ * is NOT the resting one, and matching on the resting route
280
+ * left Back unable to close it. */
281
+ if(window.location.hash !== top.hash) {
282
+ return;
283
+ }
284
+ /* Which entry did it land ON? Markers ({__yui_overlay__:
285
+ * id}) tag synthetic entries; route entries have a null
286
+ * state.
287
+ * - Landing ON the top overlay's OWN entry (a Back from a
288
+ * subpath entry pushed above it, or an odd
289
+ * history.go(n)): the overlay is at its home entry —
290
+ * keep it open.
291
+ * - Any other landing (the route entry below, or a lower
292
+ * overlay's marker) closes the top overlay: strict
293
+ * LIFO. */
294
+ let st = ev && ev.state;
295
+ if(st && st.__yui_overlay__ === top.id) {
296
+ return;
297
+ }
298
+ p.overlay_stack.pop();
299
+ try {
300
+ top.close();
301
+ } catch(e) {
302
+ log_warning(`C_YUI_SHELL: overlay close on Back failed: ${e}`);
303
+ }
304
+ };
305
+ window.addEventListener("popstate", priv.popstate_handler);
220
306
  }
221
307
 
222
308
  /* Global Escape: route to the top handler of the escape stack,
@@ -264,7 +350,7 @@ function mt_start(gobj)
264
350
  ***************************************************************/
265
351
  function mt_stop(gobj)
266
352
  {
267
- let priv = gobj_read_attr(gobj, "priv");
353
+ let priv = gobj.priv;
268
354
  if(!priv) {
269
355
  return;
270
356
  }
@@ -278,6 +364,10 @@ function mt_stop(gobj)
278
364
  window.removeEventListener("hashchange", priv.hash_handler);
279
365
  priv.hash_handler = null;
280
366
  }
367
+ if(priv.popstate_handler) {
368
+ window.removeEventListener("popstate", priv.popstate_handler);
369
+ priv.popstate_handler = null;
370
+ }
281
371
  if(priv.keydown_handler) {
282
372
  window.removeEventListener("keydown", priv.keydown_handler);
283
373
  priv.keydown_handler = null;
@@ -327,7 +417,6 @@ function mt_destroy(gobj)
327
417
  $container.parentNode.removeChild($container);
328
418
  }
329
419
  gobj_write_attr(gobj, "$container", null);
330
- gobj_write_attr(gobj, "priv", null);
331
420
 
332
421
  if(__last_shell__ === gobj) {
333
422
  __last_shell__ = null;
@@ -514,7 +603,7 @@ function check_route_unique(route_owner, item, menu_id)
514
603
  ************************************************************/
515
604
  function build_ui(gobj)
516
605
  {
517
- let priv = gobj_read_attr(gobj, "priv");
606
+ let priv = gobj.priv;
518
607
  let config = gobj_read_attr(gobj, "config") || {};
519
608
  let shell_cfg = config.shell || {};
520
609
 
@@ -635,7 +724,7 @@ function apply_show_on($el, expr)
635
724
  ************************************************************/
636
725
  function build_item_index(gobj, config)
637
726
  {
638
- let priv = gobj_read_attr(gobj, "priv");
727
+ let priv = gobj.priv;
639
728
  priv.item_index = {};
640
729
 
641
730
  let menus = (config.menu) || {};
@@ -644,6 +733,11 @@ function build_item_index(gobj, config)
644
733
  let items = (menu && menu.items) || [];
645
734
  for(let item of items) {
646
735
  if(item.route) {
736
+ /* Index by the CANONICAL form: requests are normalized
737
+ * (hash_to_route / navigate_to), so a key declared with
738
+ * a trailing or doubled slash would never match its own
739
+ * clicks otherwise. */
740
+ let item_route = normalize_route(item.route);
647
741
  /* Section-index landing (submenu.index): the section
648
742
  * route gets a synthesized target — the submenu itself
649
743
  * rendered as a "cards" C_YUI_NAV in the stage — so it
@@ -659,9 +753,9 @@ function build_item_index(gobj, config)
659
753
  * the common case where a `quick` drawer just reuses
660
754
  * routes declared (with target) in `primary.submenu`.
661
755
  * Rule: prefer the first entry with a target. */
662
- let prev = priv.item_index[item.route];
756
+ let prev = priv.item_index[item_route];
663
757
  if(!prev || (!prev.target && target)) {
664
- priv.item_index[item.route] = {
758
+ priv.item_index[item_route] = {
665
759
  item: item,
666
760
  parent_item: null,
667
761
  stage: target && target.stage || null,
@@ -674,9 +768,10 @@ function build_item_index(gobj, config)
674
768
  if(sub && is_array(sub.items)) {
675
769
  for(let sub_item of sub.items) {
676
770
  if(sub_item.route) {
677
- let prev = priv.item_index[sub_item.route];
771
+ let sub_route = normalize_route(sub_item.route);
772
+ let prev = priv.item_index[sub_route];
678
773
  if(!prev || (!prev.target && sub_item.target)) {
679
- priv.item_index[sub_item.route] = {
774
+ priv.item_index[sub_route] = {
680
775
  item: sub_item,
681
776
  parent_item: item,
682
777
  stage: sub_item.target && sub_item.target.stage || null,
@@ -697,13 +792,44 @@ function build_item_index(gobj, config)
697
792
  * route}; their TARGET (view gclass, or kind:"action" event +
698
793
  * redirect) lives here so the route resolves by URL on reload /
699
794
  * deep-link. Same precedence as menus: only fill or upgrade an
700
- * entry without a target — never clobber a menu's own target. */
795
+ * entry without a target — never clobber a menu's own target.
796
+ *
797
+ * A route IS a path, so only "/…" keys are indexed. JSON has no
798
+ * comments and these configs annotate themselves with sibling
799
+ * `_name_comment` string keys (the established idiom — see
800
+ * app_config.json); indexing one built a route entry whose target
801
+ * was the comment TEXT, which the site map then rendered as a
802
+ * clickable row leading nowhere, and which resolve_route would
803
+ * happily match. Anything else under a "/…" key is malformed and
804
+ * says so. */
701
805
  let routes = (config.shell && config.shell.routes) || {};
702
806
  for(let route in routes) {
807
+ if(route.charAt(0) !== "/") {
808
+ /* String values are the comment idiom and stay silent;
809
+ * an OBJECT under a slash-less key can only be a route
810
+ * target whose key lost its "/" — dropping it silently
811
+ * left the app with a menu entry that "doesn't work". */
812
+ if(routes[route] !== null && typeof routes[route] === "object") {
813
+ log_error(
814
+ `C_YUI_SHELL: route key '${route}' holds a route ` +
815
+ `target but does not start with '/' — ignored ` +
816
+ `(a route key is a path)`
817
+ );
818
+ }
819
+ continue;
820
+ }
821
+ let route_key = normalize_route(route);
703
822
  let t = routes[route] || null;
704
- let prev = priv.item_index[route];
823
+ if(t !== null && typeof t !== "object") {
824
+ log_error(
825
+ `C_YUI_SHELL: route '${route}' target is not an object ` +
826
+ `(${typeof t}) — ignored`
827
+ );
828
+ continue;
829
+ }
830
+ let prev = priv.item_index[route_key];
705
831
  if(!prev || (!prev.target && t)) {
706
- priv.item_index[route] = {
832
+ priv.item_index[route_key] = {
707
833
  item: (prev && prev.item) || null,
708
834
  parent_item: (prev && prev.parent_item) || null,
709
835
  stage: (t && t.stage) || null,
@@ -720,7 +846,7 @@ function build_item_index(gobj, config)
720
846
  ************************************************************/
721
847
  function instantiate_menus(gobj, config)
722
848
  {
723
- let priv = gobj_read_attr(gobj, "priv");
849
+ let priv = gobj.priv;
724
850
  let zones_cfg = (config.shell && config.shell.zones) || {};
725
851
  let menus = config.menu || {};
726
852
 
@@ -835,7 +961,7 @@ function render_to_obj(layout)
835
961
 
836
962
  function instantiate_nav_in_zone(gobj, menu, menu_id, zone_id, level, nav_label)
837
963
  {
838
- let priv = gobj_read_attr(gobj, "priv");
964
+ let priv = gobj.priv;
839
965
  let render_cfg = (menu.render && (menu.render[zone_id] || menu.render["*"])) ||
840
966
  { layout: "vertical" };
841
967
  if(is_string(render_cfg)) {
@@ -910,11 +1036,9 @@ function hash_to_route(hash)
910
1036
  if(!hash) {
911
1037
  return "";
912
1038
  }
913
- let s = String(hash).replace(/^#/, "");
914
- if(s.charAt(0) !== "/") {
915
- s = "/" + s;
916
- }
917
- return s;
1039
+ /* Hashes come from the outside world (typed URLs, shared links):
1040
+ * normalize so "#/a/b/" hits the same index entry as "#/a/b". */
1041
+ return normalize_route(String(hash).replace(/^#/, ""));
918
1042
  }
919
1043
 
920
1044
  function route_to_hash(route)
@@ -927,11 +1051,38 @@ function route_to_hash(route)
927
1051
  }
928
1052
 
929
1053
  /************************************************************
930
- * Navigate: make `route` active
1054
+ * Navigate: make `route` active. Both trailing args are internal;
1055
+ * external callers omit them.
1056
+ * depth — the redirect-recursion counter (submenu default,
1057
+ * unknown-route default, action redirects).
1058
+ * no_drain — this hop continues an action route whose event has
1059
+ * ALREADY fired, so an overlay on the stack may be the
1060
+ * one that event just opened, meant to float above the
1061
+ * route we are moving to. Draining would kill it.
931
1062
  ************************************************************/
932
- function navigate_to(gobj, route)
1063
+ const MAX_REDIRECT_DEPTH = 8;
1064
+
1065
+ function navigate_to(gobj, route, depth, no_drain)
933
1066
  {
934
- let priv = gobj_read_attr(gobj, "priv");
1067
+ let priv = gobj.priv;
1068
+ depth = depth || 0;
1069
+ route = normalize_route(route);
1070
+
1071
+ /* A redirect cycle (submenu default → unknown → default → …) is a
1072
+ * config error; without a cap it recurses to a stack overflow that
1073
+ * kills the app with a mute RangeError. Fail loudly instead. */
1074
+ if(depth > MAX_REDIRECT_DEPTH) {
1075
+ log_error(
1076
+ `C_YUI_SHELL: redirect loop navigating to '${route}' ` +
1077
+ `(depth > ${MAX_REDIRECT_DEPTH}) — check submenu defaults, ` +
1078
+ `default_route and action redirects for a cycle`
1079
+ );
1080
+ show_stage_placeholder(
1081
+ gobj, "main",
1082
+ `C_YUI_SHELL: redirect loop at route '${route}'`
1083
+ );
1084
+ return;
1085
+ }
935
1086
 
936
1087
  /* Audit witness: publish the navigation intent FIRST, before any
937
1088
  * validation or DOM work. This guarantees that the FSM trace and
@@ -958,7 +1109,7 @@ function navigate_to(gobj, route)
958
1109
  let first_routable = sub.items && sub.items.find(it => it && it.route);
959
1110
  let default_sub = sub.default || (first_routable && first_routable.route);
960
1111
  if(default_sub) {
961
- return navigate_to(gobj, default_sub);
1112
+ return navigate_to(gobj, default_sub, depth + 1);
962
1113
  }
963
1114
  }
964
1115
 
@@ -988,7 +1139,7 @@ function navigate_to(gobj, route)
988
1139
  `C_YUI_SHELL: unknown route '${route}', ` +
989
1140
  `redirecting to default '${def}'`
990
1141
  );
991
- return navigate_to(gobj, def);
1142
+ return navigate_to(gobj, def, depth + 1);
992
1143
  }
993
1144
  log_error(`C_YUI_SHELL: no target for route '${route}'`);
994
1145
  show_stage_placeholder(
@@ -1022,12 +1173,32 @@ function navigate_to(gobj, route)
1022
1173
  * later close → back lands on the default instead
1023
1174
  * of exiting the app.
1024
1175
  * EV_ROUTE_REQUESTED was already published above, so an auditor
1025
- * sees the action route intent too. */
1176
+ * sees the action route intent too.
1177
+ *
1178
+ * ORDER of event vs URL work: whenever URL work happens, it happens
1179
+ * BEFORE the event — the overlay a handler opens must register its
1180
+ * synthetic history entry over the hash it will live on:
1181
+ * "stay" → no URL work on a click (the URL stays where
1182
+ * the click pushed it; the marker lands on this
1183
+ * same hash). The DEEP-LINK fix-up does do URL
1184
+ * work (mount underneath + re-push), so there
1185
+ * it runs first and the event fires after.
1186
+ * "back"/"none" → restore the URL FIRST, event after. The old
1187
+ * event-first order left the overlay marker on
1188
+ * the ACTION hash and the restore rewrote it,
1189
+ * stranding the action's own route entry below:
1190
+ * closing the overlay then history.back()ed
1191
+ * onto that entry and RE-FIRED the action — the
1192
+ * "site-map window won't close" loop.
1193
+ * "<route>" → event first (logout-style: the handler may
1194
+ * tear the shell down; navigate afterwards). */
1026
1195
  if(entry.target.kind === "action") {
1027
1196
  let t = entry.target;
1028
- if(!empty_string(t.event)) {
1029
- gobj_publish_event(gobj, t.event, t.kw || {});
1030
- }
1197
+ let publish_action = () => {
1198
+ if(!empty_string(t.event)) {
1199
+ gobj_publish_event(gobj, t.event, t.kw || {});
1200
+ }
1201
+ };
1031
1202
  let config = gobj_read_attr(gobj, "config") || {};
1032
1203
  let prev = (priv.stages && priv.stages.main &&
1033
1204
  priv.stages.main.active_route) ||
@@ -1042,10 +1213,23 @@ function navigate_to(gobj, route)
1042
1213
  priv.stages.main.active_route);
1043
1214
  if(!has_resting && prev && prev !== route) {
1044
1215
  /* Deep-link / reload straight onto the overlay
1045
- * route: bring up the default view underneath,
1046
- * then push this hash back on top so close→back
1047
- * returns to the default. */
1048
- navigate_to(gobj, prev);
1216
+ * route: bring up the default view underneath
1217
+ * (rewriting this initial entry), push this hash
1218
+ * back on top, and only THEN fire the event — the
1219
+ * same restore-then-event rule as `back`/`none`,
1220
+ * for the same reason: the overlay the handler
1221
+ * opens must register its synthetic entry ABOVE
1222
+ * the action hash, giving the history the exact
1223
+ * shape of the click path ([resting, action,
1224
+ * marker]) so ONE Back closes it. Event-first
1225
+ * here buried the marker (rewritten to the resting
1226
+ * hash) UNDER the re-pushed action hash: Back #1
1227
+ * landed ON the marker — own-entry check, overlay
1228
+ * kept open — while the URL flipped underneath,
1229
+ * and Back #2 re-fired the action off the initial
1230
+ * entry. no_drain: internal fix-up hop, not a
1231
+ * resting navigation. */
1232
+ navigate_to(gobj, prev, depth + 1, true);
1049
1233
  let h = route_to_hash(route);
1050
1234
  try {
1051
1235
  window.history.pushState(null, "", h);
@@ -1054,31 +1238,52 @@ function navigate_to(gobj, route)
1054
1238
  }
1055
1239
  }
1056
1240
  /* else: reached via a click that already pushed this
1057
- * hash — leave the URL exactly as the user sees it. */
1241
+ * hash — the URL stays where the user sees it, and
1242
+ * nothing was restored, so there is no ordering to
1243
+ * keep: the event just fires. */
1058
1244
  }
1245
+ publish_action();
1059
1246
  return;
1060
1247
  }
1061
1248
  if(empty_string(redirect) || redirect === "none") {
1062
1249
  if(gobj_read_attr(gobj, "use_hash")) {
1063
1250
  let h = route_to_hash(prev);
1064
1251
  try {
1065
- window.history.replaceState(null, "", h);
1252
+ /* Keep the current entry's state: if it is an
1253
+ * overlay's synthetic entry, nulling the marker
1254
+ * would break its Back/dismiss bookkeeping. */
1255
+ window.history.replaceState(window.history.state, "", h);
1066
1256
  } catch(e) {
1067
1257
  window.location.hash = h;
1068
1258
  }
1069
1259
  }
1260
+ publish_action();
1070
1261
  return;
1071
1262
  }
1072
1263
  if(redirect === "back") {
1073
- redirect = prev;
1264
+ /* Full restore of the previous resting view + its URL (the
1265
+ * replaceState inside rewrites the action's pushed entry),
1266
+ * THEN the event: an overlay opened by the handler floats
1267
+ * above the restored view and its synthetic entry shares
1268
+ * the restored hash — Back/dismiss stay invisible. */
1269
+ if(prev !== route) {
1270
+ navigate_to(gobj, prev, depth + 1);
1271
+ }
1272
+ publish_action();
1273
+ return;
1074
1274
  }
1275
+ publish_action();
1075
1276
  if(redirect === route) {
1076
1277
  log_error(
1077
1278
  `C_YUI_SHELL: action route '${route}' redirects to itself`
1078
1279
  );
1079
1280
  return;
1080
1281
  }
1081
- return navigate_to(gobj, redirect);
1282
+ /* Event-first (logout-style: the handler may tear the shell down),
1283
+ * so anything the handler just put on the overlay stack belongs to
1284
+ * the route we are about to mount, not to the one we are leaving —
1285
+ * no_drain. */
1286
+ return navigate_to(gobj, redirect, depth + 1, true);
1082
1287
  }
1083
1288
 
1084
1289
  /* A fresh navigation clears any placeholder shown earlier. */
@@ -1094,6 +1299,26 @@ function navigate_to(gobj, route)
1094
1299
  /* View instances are keyed by the BASE (declared) route so a
1095
1300
  * subpath-only change reuses the same view (no rebuild). */
1096
1301
  let prev_route = stage.active_route;
1302
+
1303
+ /* A change of RESTING route closes every Back-dismissable overlay:
1304
+ * they are transient (§3) and do not outlive the view they float
1305
+ * above. (An action route or a subpath-only move never reaches
1306
+ * here with a different matched_route, so they keep them open.)
1307
+ * Their synthetic entries are NOT retired — they are buried under
1308
+ * the new route's entry; popped BEFORE close, so each close's
1309
+ * overlay_dismissed finds its entry gone and leaves it inert for a
1310
+ * later Back to absorb.
1311
+ *
1312
+ * This holds at EVERY redirect depth — a submenu default, an
1313
+ * unknown-route default and an action's `"<route>"` redirect all
1314
+ * land the user on a different resting view, and gating the drain
1315
+ * on depth 0 made the very same click drain or not depending on
1316
+ * whether the target redirected. `no_drain` is the one exception,
1317
+ * and it is about ORDER, not depth: see the `"<route>"` hop. */
1318
+ if(!no_drain && prev_route && prev_route !== matched_route) {
1319
+ drain_overlays(gobj);
1320
+ }
1321
+
1097
1322
  if(prev_route && prev_route !== matched_route) {
1098
1323
  let prev_gobj = stage.items[prev_route];
1099
1324
  let $prev = null;
@@ -1157,9 +1382,18 @@ function navigate_to(gobj, route)
1157
1382
  if(gobj_read_attr(gobj, "use_hash")) {
1158
1383
  let target_hash = route_to_hash(route);
1159
1384
  if(window.location.hash !== target_hash) {
1160
- /* Using history.replaceState avoids extra hashchange fire. */
1385
+ /* Using history.replaceState avoids extra hashchange fire.
1386
+ * The current entry's state is preserved: if it is an
1387
+ * overlay's synthetic entry, nulling the marker would break
1388
+ * its Back/dismiss bookkeeping. */
1161
1389
  try {
1162
- window.history.replaceState(null, "", target_hash);
1390
+ window.history.replaceState(window.history.state, "", target_hash);
1391
+ /* This rewrote the hash of the CURRENT entry. When that
1392
+ * entry is a live marker (a replace-navigation issued
1393
+ * with an overlay open), its recorded hash just went
1394
+ * stale — and a stale hash makes the popstate handler
1395
+ * miss the Back that steps off it. */
1396
+ retag_overlay_hash(gobj, window.history.state, target_hash);
1163
1397
  } catch(e) {
1164
1398
  window.location.hash = target_hash;
1165
1399
  }
@@ -1225,7 +1459,7 @@ function build_view_gobj(gobj, entry, route, stage)
1225
1459
  * (synthesized "cards" C_YUI_NAV) is SHELL-owned DOM built after
1226
1460
  * the host's one-shot refresh_language — apply the registered
1227
1461
  * translator, same policy as lazily-built dropdown panels. */
1228
- let priv = gobj_read_attr(gobj, "priv");
1462
+ let priv = gobj.priv;
1229
1463
  if(priv && typeof priv.translator === "function" &&
1230
1464
  target.gclass === "C_YUI_NAV") {
1231
1465
  refresh_language($view, priv.translator);
@@ -1265,7 +1499,7 @@ function build_toolbar(gobj, config)
1265
1499
  return;
1266
1500
  }
1267
1501
 
1268
- let priv = gobj_read_attr(gobj, "priv");
1502
+ let priv = gobj.priv;
1269
1503
  let zone_id = tb.zone || find_toolbar_zone(config);
1270
1504
  let $zone = priv.zones[zone_id];
1271
1505
  if(!$zone) {
@@ -1440,7 +1674,7 @@ function build_toolbar_brand_item(gobj, it)
1440
1674
  ************************************************************/
1441
1675
  function build_toolbar_avatar_item(gobj, it)
1442
1676
  {
1443
- let priv = gobj_read_attr(gobj, "priv");
1677
+ let priv = gobj.priv;
1444
1678
  let aria_key = it.aria_label || it.name || it.id || "User menu";
1445
1679
  let i18n_aria = it.aria_label || it.name || "User menu";
1446
1680
  let action_type = (it.action && it.action.type) || "";
@@ -1495,7 +1729,7 @@ function paint_avatar(priv, $node)
1495
1729
 
1496
1730
  function refresh_avatars(gobj)
1497
1731
  {
1498
- let priv = gobj_read_attr(gobj, "priv");
1732
+ let priv = gobj.priv;
1499
1733
  if(!priv || !is_array(priv.avatar_nodes)) {
1500
1734
  return;
1501
1735
  }
@@ -1531,7 +1765,7 @@ function attach_context_action(gobj, $item, it)
1531
1765
  ************************************************************/
1532
1766
  function build_toolbar_connection_item(gobj, it)
1533
1767
  {
1534
- let priv = gobj_read_attr(gobj, "priv");
1768
+ let priv = gobj.priv;
1535
1769
  let aria_key = it.aria_label || it.name || it.id || "backend connection";
1536
1770
  let i18n_aria = it.aria_label || it.name || "backend connection";
1537
1771
  let attrs = {
@@ -1572,13 +1806,40 @@ function find_toolbar_zone(config)
1572
1806
  return "top";
1573
1807
  }
1574
1808
 
1809
+ /************************************************************
1810
+ * True when a click on `route` resolves to an action route whose
1811
+ * flavour RESTORES the previous resting URL by rewriting the
1812
+ * current history entry ("back", "none"/""). Pushing the action
1813
+ * hash first would leave that rewritten entry as an exact
1814
+ * duplicate of the resting entry below it — one dead Back press
1815
+ * per click. The click entry points navigate those directly
1816
+ * instead of going through the hash. ("stay" and "<route>" keep
1817
+ * the push: their URL genuinely moves.)
1818
+ ************************************************************/
1819
+ function route_restores_url(gobj, route)
1820
+ {
1821
+ let priv = gobj.priv;
1822
+ if(!priv || !priv.item_index) {
1823
+ return false;
1824
+ }
1825
+ let r = resolve_route(priv.item_index, normalize_route(route));
1826
+ let target = r && r.entry && r.entry.target;
1827
+ if(!target || target.kind !== "action") {
1828
+ return false;
1829
+ }
1830
+ return target.redirect === "back" ||
1831
+ target.redirect === "none" ||
1832
+ empty_string(target.redirect);
1833
+ }
1834
+
1575
1835
  function handle_toolbar_action(gobj, item, $trigger)
1576
1836
  {
1577
1837
  let action = (item && item.action) || {};
1578
1838
  switch(action.type) {
1579
1839
  case "navigate":
1580
1840
  if(!empty_string(action.route)) {
1581
- if(gobj_read_attr(gobj, "use_hash")) {
1841
+ if(gobj_read_attr(gobj, "use_hash") &&
1842
+ !route_restores_url(gobj, action.route)) {
1582
1843
  let h = route_to_hash(action.route);
1583
1844
  if(window.location.hash !== h) {
1584
1845
  window.location.hash = h; /* fires hashchange */
@@ -1636,7 +1897,7 @@ function handle_toolbar_action(gobj, item, $trigger)
1636
1897
  ************************************************************/
1637
1898
  function toggle_toolbar_dropdown(gobj, item, action, $trigger)
1638
1899
  {
1639
- let priv = gobj_read_attr(gobj, "priv");
1900
+ let priv = gobj.priv;
1640
1901
  if(!priv) {
1641
1902
  return;
1642
1903
  }
@@ -1651,7 +1912,7 @@ function toggle_toolbar_dropdown(gobj, item, action, $trigger)
1651
1912
 
1652
1913
  function open_toolbar_dropdown(gobj, item, action, $trigger)
1653
1914
  {
1654
- let priv = gobj_read_attr(gobj, "priv");
1915
+ let priv = gobj.priv;
1655
1916
  if(!priv || !priv.layers || !priv.layers.popup) {
1656
1917
  return;
1657
1918
  }
@@ -1751,7 +2012,7 @@ function open_toolbar_dropdown(gobj, item, action, $trigger)
1751
2012
 
1752
2013
  function close_toolbar_dropdown(gobj)
1753
2014
  {
1754
- let priv = gobj_read_attr(gobj, "priv");
2015
+ let priv = gobj.priv;
1755
2016
  if(!priv) {
1756
2017
  return;
1757
2018
  }
@@ -1846,7 +2107,7 @@ function build_dropdown_row(gobj, sub, idx)
1846
2107
  ************************************************************/
1847
2108
  function preinstantiate_eager_views(gobj)
1848
2109
  {
1849
- let priv = gobj_read_attr(gobj, "priv");
2110
+ let priv = gobj.priv;
1850
2111
  for(let route in priv.item_index) {
1851
2112
  let entry = priv.item_index[route];
1852
2113
  let t = entry.target;
@@ -1881,7 +2142,7 @@ function preinstantiate_eager_views(gobj)
1881
2142
  ************************************************************/
1882
2143
  function drawers(gobj, menu_id)
1883
2144
  {
1884
- let priv = gobj_read_attr(gobj, "priv");
2145
+ let priv = gobj.priv;
1885
2146
  let out = [];
1886
2147
  for(let nav of priv.navs) {
1887
2148
  if(gobj_read_attr(nav, "layout") !== "drawer") {
@@ -1907,7 +2168,7 @@ function drawers(gobj, menu_id)
1907
2168
  ************************************************************/
1908
2169
  function push_escape(gobj, layer, handler)
1909
2170
  {
1910
- let priv = gobj_read_attr(gobj, "priv");
2171
+ let priv = gobj.priv;
1911
2172
  if(!priv || !priv.escape_stack) {
1912
2173
  return;
1913
2174
  }
@@ -1916,7 +2177,7 @@ function push_escape(gobj, layer, handler)
1916
2177
 
1917
2178
  function pop_escape(gobj, handler)
1918
2179
  {
1919
- let priv = gobj_read_attr(gobj, "priv");
2180
+ let priv = gobj.priv;
1920
2181
  if(!priv || !priv.escape_stack) {
1921
2182
  return;
1922
2183
  }
@@ -1926,6 +2187,139 @@ function pop_escape(gobj, handler)
1926
2187
  }
1927
2188
  }
1928
2189
 
2190
+ /************************************************************
2191
+ * Overlay history integration — Back button ↔ modals/windows.
2192
+ *
2193
+ * An overlay that wants the browser Back button to close it (modal,
2194
+ * floating window) registers on open with push_overlay_history and
2195
+ * calls overlay_dismissed when it closes by ANY other path.
2196
+ *
2197
+ * On open we push a synthetic history entry that keeps the current
2198
+ * hash, so routing is untouched. The two close paths converge on the
2199
+ * same overlay_stack, and membership in it disambiguates which one ran:
2200
+ *
2201
+ * - Back pressed → popstate pops the entry and calls entry.close();
2202
+ * the browser already dropped the history entry, so the later
2203
+ * overlay_dismissed finds the entry gone and does nothing.
2204
+ * - X/Escape/code → the overlay's close runs first (entry still on
2205
+ * the stack), overlay_dismissed removes it and history.back()s to
2206
+ * retire the still-present browser entry (that popstate is counted
2207
+ * in expected_pops and ignored).
2208
+ *
2209
+ * The synthetic entry carries a state marker ({__yui_overlay__: id})
2210
+ * recording which overlay it belongs to. Route navigation interplay:
2211
+ * - fragment navigations fire popstate too (all engines), so the
2212
+ * popstate handler treats only SAME-HASH landings as overlay pops;
2213
+ * a hash-changing landing is a route move owned by hashchange.
2214
+ * - navigate_to() drains (closes) every registered overlay when the
2215
+ * RESTING route changes — overlays are transient and do not
2216
+ * outlive the view they float above. An action route or a
2217
+ * subpath-only move keeps them open.
2218
+ * - a drained/buried entry is left INERT: overlay_dismissed only
2219
+ * history.back()s when the marker is the CURRENT history entry
2220
+ * (adjacent, so the back() is invisible) — never over real route
2221
+ * entries (that teleported the user). A later Back absorbs an
2222
+ * inert entry as a same-hash no-op.
2223
+ *
2224
+ * Gated on `use_hash`: an app that manages its own routing gets no
2225
+ * synthetic entries — a stray history.back() there could exit it.
2226
+ ************************************************************/
2227
+ /* Close every registered overlay because the resting route changed.
2228
+ * Pop-then-close (the popstate handler's pattern): the overlay's own
2229
+ * close path then finds its entry gone in overlay_dismissed and does
2230
+ * NOT history.back() — the buried synthetic entries stay inert. */
2231
+ function drain_overlays(gobj)
2232
+ {
2233
+ let priv = gobj.priv;
2234
+ if(!priv || !priv.overlay_stack) {
2235
+ return;
2236
+ }
2237
+ while(priv.overlay_stack.length > 0) {
2238
+ let entry = priv.overlay_stack.pop();
2239
+ try {
2240
+ entry.close();
2241
+ } catch(e) {
2242
+ log_warning(`C_YUI_SHELL: overlay close on navigation failed: ${e}`);
2243
+ }
2244
+ }
2245
+ }
2246
+
2247
+ /* A URL rewrite moved the entry `st` tags to `hash`. When `st` is a live
2248
+ * overlay marker, follow it: its entry's recorded hash is the popstate
2249
+ * handler's authority and must not drift from the entry it describes. */
2250
+ function retag_overlay_hash(gobj, st, hash)
2251
+ {
2252
+ let priv = gobj.priv;
2253
+ if(!priv || !priv.overlay_stack || !st || !st.__yui_overlay__) {
2254
+ return;
2255
+ }
2256
+ let entry = priv.overlay_stack.find(e => e.id === st.__yui_overlay__);
2257
+ if(entry) {
2258
+ entry.hash = hash;
2259
+ }
2260
+ }
2261
+
2262
+ function push_overlay_history(gobj, close)
2263
+ {
2264
+ let priv = gobj.priv;
2265
+ if(!priv || !priv.overlay_stack || !gobj_read_attr(gobj, "use_hash")) {
2266
+ return null;
2267
+ }
2268
+ /* `hash` = the hash the marker entry lives on (pushState with no URL
2269
+ * keeps the current one). The popstate handler needs it to tell a
2270
+ * step OFF this marker from a route traversal; it is NOT the resting
2271
+ * route (a `stay` action route parks the URL off it). Kept in sync
2272
+ * by the silent replaceState fix-up in navigate_to(). */
2273
+ let entry = { id: ++priv.overlay_seq, close: close,
2274
+ hash: window.location.hash };
2275
+ priv.overlay_stack.push(entry);
2276
+ try {
2277
+ window.history.pushState({ __yui_overlay__: entry.id }, "");
2278
+ } catch(e) {
2279
+ /* pushState failed (rare): drop the entry so overlay_dismissed
2280
+ * won't later history.back() past a real route. */
2281
+ priv.overlay_stack.pop();
2282
+ log_warning(`C_YUI_SHELL: overlay pushState failed: ${e}`);
2283
+ return null;
2284
+ }
2285
+ return entry;
2286
+ }
2287
+
2288
+ function overlay_dismissed(gobj, entry)
2289
+ {
2290
+ let priv = gobj.priv;
2291
+ if(!priv || !priv.overlay_stack || !entry) {
2292
+ return;
2293
+ }
2294
+ let idx = priv.overlay_stack.indexOf(entry);
2295
+ if(idx < 0) {
2296
+ /* Already removed by the popstate handler (closed via Back). */
2297
+ return;
2298
+ }
2299
+ priv.overlay_stack.splice(idx, 1);
2300
+ /* Retire the synthetic browser entry ONLY when it is the CURRENT
2301
+ * one — its state marker is live — so history.back() steps off it
2302
+ * invisibly (same hash, no route change). In every other case the
2303
+ * entry is NOT adjacent and a back() would traverse REAL entries:
2304
+ * - the user route-navigated after opening the overlay (the
2305
+ * marker sits buried under newer route entries) — a back()
2306
+ * here would teleport them to the pre-overlay route;
2307
+ * - a non-LIFO dismissal (a lower overlay closed under a higher
2308
+ * one) — a back() would pop past the higher overlay's entry.
2309
+ * Leave the entry inert instead: a later Back lands on it with the
2310
+ * same hash and is absorbed harmlessly. */
2311
+ let st = window.history.state;
2312
+ if(st && st.__yui_overlay__ === entry.id) {
2313
+ priv.expected_pops++;
2314
+ try {
2315
+ window.history.back();
2316
+ } catch(e) {
2317
+ priv.expected_pops--;
2318
+ log_warning(`C_YUI_SHELL: overlay history.back() failed: ${e}`);
2319
+ }
2320
+ }
2321
+ }
2322
+
1929
2323
  /* Per-drawer open/close. The escape-stack entry and the focus-
1930
2324
  * trap release function are parked on the $drawer DOM element so
1931
2325
  * any close path (Escape, backdrop click, toolbar action, public
@@ -1989,7 +2383,7 @@ function toggle_drawer(gobj, menu_id)
1989
2383
 
1990
2384
  function close_all_drawers(gobj)
1991
2385
  {
1992
- let priv = gobj_read_attr(gobj, "priv");
2386
+ let priv = gobj.priv;
1993
2387
  if(!priv) {
1994
2388
  return;
1995
2389
  }
@@ -2011,7 +2405,7 @@ function close_all_drawers(gobj)
2011
2405
  ************************************************************/
2012
2406
  function show_stage_placeholder(gobj, stage_name, message)
2013
2407
  {
2014
- let priv = gobj_read_attr(gobj, "priv");
2408
+ let priv = gobj.priv;
2015
2409
  let stage = priv.stages[stage_name];
2016
2410
  if(!stage || !stage.el) {
2017
2411
  return;
@@ -2027,7 +2421,7 @@ function show_stage_placeholder(gobj, stage_name, message)
2027
2421
 
2028
2422
  function clear_stage_placeholder(gobj, stage_name)
2029
2423
  {
2030
- let priv = gobj_read_attr(gobj, "priv");
2424
+ let priv = gobj.priv;
2031
2425
  let stage = priv.stages[stage_name];
2032
2426
  if(!stage || !stage.el) {
2033
2427
  return;
@@ -2040,7 +2434,7 @@ function clear_stage_placeholder(gobj, stage_name)
2040
2434
 
2041
2435
  function update_secondary_nav_visibility(gobj, entry)
2042
2436
  {
2043
- let priv = gobj_read_attr(gobj, "priv");
2437
+ let priv = gobj.priv;
2044
2438
  /* shell.routes entries (root, forms, action routes) have no menu
2045
2439
  * item — entry.item / entry.parent_item are null. No active
2046
2440
  * primary then: the secondary zone collapses (has_secondary
@@ -2135,8 +2529,10 @@ function ac_nav_clicked(gobj, event, kw, src)
2135
2529
 
2136
2530
  /* When hash routing is on, let the hash drive navigate_to() — that
2137
2531
  * way back/forward buttons and programmatic hash changes all flow
2138
- * through the same code path. Otherwise call navigate_to directly. */
2139
- if(gobj_read_attr(gobj, "use_hash")) {
2532
+ * through the same code path. Otherwise call navigate_to directly.
2533
+ * Exception: URL-restoring action routes skip the push — see
2534
+ * route_restores_url. */
2535
+ if(gobj_read_attr(gobj, "use_hash") && !route_restores_url(gobj, route)) {
2140
2536
  let target_hash = route_to_hash(route);
2141
2537
  if(window.location.hash !== target_hash) {
2142
2538
  window.location.hash = target_hash; /* fires hashchange */
@@ -2226,7 +2622,7 @@ function find_primary_item(shell_gobj, menu_id, item_id)
2226
2622
  /* Drop a route from the index and destroy any mounted view for it. */
2227
2623
  function prune_route(shell_gobj, route)
2228
2624
  {
2229
- let priv = gobj_read_attr(shell_gobj, "priv");
2625
+ let priv = shell_gobj.priv;
2230
2626
  delete priv.item_index[route];
2231
2627
  for(let stage_name in priv.stages) {
2232
2628
  let stage = priv.stages[stage_name];
@@ -2253,7 +2649,7 @@ function prune_route(shell_gobj, route)
2253
2649
 
2254
2650
  function yui_shell_set_submenu(shell_gobj, parent_item_id, items)
2255
2651
  {
2256
- let priv = gobj_read_attr(shell_gobj, "priv");
2652
+ let priv = shell_gobj.priv;
2257
2653
  if(!priv) {
2258
2654
  return -1;
2259
2655
  }
@@ -2283,8 +2679,11 @@ function yui_shell_set_submenu(shell_gobj, parent_item_id, items)
2283
2679
  let new_set = {};
2284
2680
  for(let it of items) {
2285
2681
  if(it && it.route) {
2286
- new_routes.push(it.route);
2287
- new_set[it.route] = true;
2682
+ /* Same canonical form as build_item_index: requests are
2683
+ * normalized, so raw keys would never match their clicks. */
2684
+ let it_route = normalize_route(it.route);
2685
+ new_routes.push(it_route);
2686
+ new_set[it_route] = true;
2288
2687
  }
2289
2688
  }
2290
2689
  for(let route of prev_routes) {
@@ -2298,7 +2697,7 @@ function yui_shell_set_submenu(shell_gobj, parent_item_id, items)
2298
2697
  if(!it || !it.route) {
2299
2698
  continue;
2300
2699
  }
2301
- priv.item_index[it.route] = {
2700
+ priv.item_index[normalize_route(it.route)] = {
2302
2701
  item: it,
2303
2702
  parent_item: parent_item,
2304
2703
  stage: (it.target && it.target.stage) || null,
@@ -2388,6 +2787,17 @@ function create_gclass(gclass_name)
2388
2787
  |event_flag_t.EVF_PUBLIC_EVENT
2389
2788
  |event_flag_t.EVF_NO_WARN_SUBS],
2390
2789
  ["EV_ROUTE_CHANGED", event_flag_t.EVF_OUTPUT_EVENT
2790
+ |event_flag_t.EVF_PUBLIC_EVENT
2791
+ |event_flag_t.EVF_NO_WARN_SUBS],
2792
+
2793
+ /* The language changed (the app switched it and called
2794
+ * yui_shell_language_changed). refresh_language() re-translates
2795
+ * every node that CARRIES its key, but a view that COMPOSED a string
2796
+ * with t() at render time — a title, a row counter, a Tabulator
2797
+ * header or paginator — holds no key and cannot be reached that way.
2798
+ * So the fact is published: such a view subscribes to its shell and
2799
+ * re-renders its own translated parts. */
2800
+ ["EV_LANGUAGE_CHANGED", event_flag_t.EVF_OUTPUT_EVENT
2391
2801
  |event_flag_t.EVF_PUBLIC_EVENT
2392
2802
  |event_flag_t.EVF_NO_WARN_SUBS]
2393
2803
  ];
@@ -2442,13 +2852,96 @@ function yui_shell_of(gobj)
2442
2852
  }
2443
2853
 
2444
2854
  /************************************************************
2445
- * Programmatic navigation (bypass hash).
2855
+ * Programmatic navigation.
2856
+ *
2857
+ * Default — PUSH: the user MOVED somewhere new, so change the URL via
2858
+ * the hash and let the browser record a Back entry (routed through the
2859
+ * same hashchange path as a nav click). This is the common case and the
2860
+ * safe default: a human deliberately chose to go there, so Back must
2861
+ * return them.
2862
+ *
2863
+ * `opts.replace:true` — REPLACE: sync the URL WITHOUT a Back entry. Use
2864
+ * when CODE decided the move for the user: redirects, normalizations,
2865
+ * submenu-parent → default child, F5-restores (see ROUTING.md §2/§7).
2866
+ *
2867
+ * `opts.push:true` is redundant now (it IS the default) but stays valid,
2868
+ * so migrated call sites keep documenting their intent explicitly.
2446
2869
  ************************************************************/
2447
- function yui_shell_navigate(shell_gobj, route)
2870
+ function yui_shell_navigate(shell_gobj, route, opts)
2448
2871
  {
2872
+ if(!(opts && opts.replace) && gobj_read_attr(shell_gobj, "use_hash")) {
2873
+ let target_hash = route_to_hash(route);
2874
+ if(window.location.hash !== target_hash) {
2875
+ /* Push a real history entry; the hashchange handler mounts. */
2876
+ window.location.hash = target_hash;
2877
+ return;
2878
+ }
2879
+ /* Same hash — hashchange won't fire; fall through to mount. */
2880
+ }
2449
2881
  navigate_to(shell_gobj, route);
2450
2882
  }
2451
2883
 
2884
+ /************************************************************
2885
+ * Nav map — the WHOLE navigation surface as an ordered tree, for
2886
+ * the "site map" / documentation viewer. The heavy lifting is the
2887
+ * pure builder in route_map_model.js (unit-tested there): toolbar
2888
+ * (incl. the account dropdown), every declared menu (incl. live
2889
+ * dynamic tabs), each view's contributed sub-routes, the routes
2890
+ * declared only in the route table (`other`), and the "you are
2891
+ * here" marker. Returns {brand, toolbar, nav, other}.
2892
+ ************************************************************/
2893
+ function yui_shell_nav_map(shell_gobj)
2894
+ {
2895
+ let priv = shell_gobj.priv;
2896
+ return build_nav_map({
2897
+ config: gobj_read_attr(shell_gobj, "config") || {},
2898
+ item_index: (priv && priv.item_index) || {},
2899
+ sub_routes: (priv && priv.sub_routes) || {},
2900
+ event_handlers: (priv && priv.event_handlers) || {},
2901
+ current_route: gobj_read_attr(shell_gobj, "current_route") || ""
2902
+ });
2903
+ }
2904
+
2905
+ /************************************************************
2906
+ * Sub-route contributor protocol (ROUTING.md): a mounted view
2907
+ * declares the deep, view-owned children of its base route for the
2908
+ * site map. `nodes` is an ordered array of
2909
+ * {route, label, icon?, children?} (full hashless routes); pass
2910
+ * null/[] to clear (call on the view's stop/teardown so the map
2911
+ * never shows a torn-down view's children).
2912
+ ************************************************************/
2913
+ function yui_shell_set_sub_routes(shell_gobj, base_route, nodes)
2914
+ {
2915
+ let priv = shell_gobj.priv;
2916
+ if(!priv || !priv.sub_routes || empty_string(base_route)) {
2917
+ return;
2918
+ }
2919
+ if(nodes && nodes.length) {
2920
+ priv.sub_routes[base_route] = nodes;
2921
+ } else {
2922
+ delete priv.sub_routes[base_route];
2923
+ }
2924
+ }
2925
+
2926
+ /************************************************************
2927
+ * Event-handler registry (ROUTING.md): a gclass that HANDLES a
2928
+ * toolbar/account action event self-declares, so the site map can
2929
+ * show where the action is implemented. Call once (e.g. in mt_start,
2930
+ * next to the matching `gobj_subscribe_event`). Idempotent per
2931
+ * (event, gclass); several gclasses may handle the same event.
2932
+ ************************************************************/
2933
+ function yui_shell_register_event_handler(shell_gobj, event, gclass)
2934
+ {
2935
+ let priv = shell_gobj.priv;
2936
+ if(!priv || !priv.event_handlers || empty_string(event) || empty_string(gclass)) {
2937
+ return;
2938
+ }
2939
+ let list = priv.event_handlers[event] || (priv.event_handlers[event] = []);
2940
+ if(list.indexOf(gclass) < 0) {
2941
+ list.push(gclass);
2942
+ }
2943
+ }
2944
+
2452
2945
  /* Drawer helpers — toggle the off-canvas nav from the outside
2453
2946
  * (e.g. a hamburger button in the toolbar). menu_id is optional. */
2454
2947
  function yui_shell_open_drawer(shell_gobj, menu_id) { open_drawer(shell_gobj, menu_id); }
@@ -2477,6 +2970,45 @@ function yui_shell_pop_escape(shell_gobj, handler)
2477
2970
  pop_escape(shell_gobj, handler);
2478
2971
  }
2479
2972
 
2973
+ /* Overlay history integration — public API for overlays the shell does
2974
+ * not own (modals from shell_modals.js, floating C_YUI_WINDOW popups).
2975
+ *
2976
+ * let overlay = yui_shell_register_overlay(shell, close_fn);
2977
+ * // ... when the overlay closes by ANY non-Back path, also call:
2978
+ * yui_shell_overlay_dismissed(shell, overlay);
2979
+ *
2980
+ * `close_fn` is what the Back button invokes to tear the overlay down.
2981
+ * Returns null when history integration is off (no shell / use_hash) —
2982
+ * callers just skip the paired yui_shell_overlay_dismissed then. */
2983
+ function yui_shell_register_overlay(shell_gobj, close_fn)
2984
+ {
2985
+ return push_overlay_history(shell_gobj, close_fn);
2986
+ }
2987
+ function yui_shell_overlay_dismissed(shell_gobj, overlay)
2988
+ {
2989
+ overlay_dismissed(shell_gobj, overlay);
2990
+ }
2991
+
2992
+ /************************************************************
2993
+ * Take the URL back off an action route the app parked it on
2994
+ * (`redirect:"stay"`), from the overlay's close path — the
2995
+ * reference `stay` wiring (ROUTING.md §7.1).
2996
+ *
2997
+ * Guarded on purpose: when the close came from the shell's
2998
+ * overlay drain (the user navigated to another resting route
2999
+ * while the overlay was open), the URL has ALREADY moved off
3000
+ * the route — a blind history.back() there lands on the
3001
+ * stranded action entries and RE-FIRES the action, reopening
3002
+ * the overlay and hijacking the navigation the user asked
3003
+ * for. Only back() while the URL still sits on the route.
3004
+ ************************************************************/
3005
+ function yui_shell_unpark_route(route)
3006
+ {
3007
+ if(window.location.hash === route_to_hash(normalize_route(route))) {
3008
+ window.history.back();
3009
+ }
3010
+ }
3011
+
2480
3012
  /************************************************************
2481
3013
  * Avatar provider — toolbar items with type:"avatar" call the
2482
3014
  * registered provider whenever the shell paints initials. The
@@ -2489,7 +3021,7 @@ function yui_shell_pop_escape(shell_gobj, handler)
2489
3021
  ************************************************************/
2490
3022
  function yui_shell_set_avatar_provider(shell_gobj, provider)
2491
3023
  {
2492
- let priv = gobj_read_attr(shell_gobj, "priv");
3024
+ let priv = shell_gobj.priv;
2493
3025
  if(!priv) {
2494
3026
  return;
2495
3027
  }
@@ -2513,13 +3045,35 @@ function yui_shell_refresh_avatars(shell_gobj)
2513
3045
  ************************************************************/
2514
3046
  function yui_shell_set_translator(shell_gobj, t)
2515
3047
  {
2516
- let priv = gobj_read_attr(shell_gobj, "priv");
3048
+ let priv = shell_gobj.priv;
2517
3049
  if(!priv) {
2518
3050
  return;
2519
3051
  }
2520
3052
  priv.translator = (typeof t === "function") ? t : null;
2521
3053
  }
2522
3054
 
3055
+ /************************************************************
3056
+ * The app switched the language: re-translate the whole document (every
3057
+ * node carrying data-i18n / data-i18n-title / data-i18n-aria-label) and
3058
+ * PUBLISH the fact, so the views that build DOM imperatively — Tabulator
3059
+ * headers and paginators, composed titles, row counters — can re-render
3060
+ * what no attribute can reach.
3061
+ *
3062
+ * The app remains the owner of the locales: it switches its i18next and
3063
+ * calls this. The shell only fans the fact out.
3064
+ ************************************************************/
3065
+ function yui_shell_language_changed(shell_gobj)
3066
+ {
3067
+ if(!shell_gobj || !is_gobj(shell_gobj)) {
3068
+ return;
3069
+ }
3070
+ let priv = shell_gobj.priv;
3071
+ if(priv && typeof priv.translator === "function") {
3072
+ refresh_language(document.body, priv.translator);
3073
+ }
3074
+ gobj_publish_event(shell_gobj, "EV_LANGUAGE_CHANGED", {});
3075
+ }
3076
+
2523
3077
  /************************************************************
2524
3078
  * Set the backend-connection state painted by every
2525
3079
  * type:"connection" toolbar item. Host/event-driven: the
@@ -2529,7 +3083,7 @@ function yui_shell_set_translator(shell_gobj, t)
2529
3083
  ************************************************************/
2530
3084
  function yui_shell_set_connection_state(shell_gobj, connected)
2531
3085
  {
2532
- let priv = gobj_read_attr(shell_gobj, "priv");
3086
+ let priv = shell_gobj.priv;
2533
3087
  if(!priv || !is_array(priv.conn_nodes)) {
2534
3088
  return;
2535
3089
  }
@@ -2587,14 +3141,21 @@ export {
2587
3141
  register_c_yui_shell,
2588
3142
  yui_shell_of,
2589
3143
  yui_shell_navigate,
3144
+ yui_shell_nav_map,
3145
+ yui_shell_set_sub_routes,
3146
+ yui_shell_register_event_handler,
2590
3147
  yui_shell_open_drawer,
2591
3148
  yui_shell_close_drawer,
2592
3149
  yui_shell_toggle_drawer,
2593
3150
  yui_shell_push_escape,
2594
3151
  yui_shell_pop_escape,
3152
+ yui_shell_register_overlay,
3153
+ yui_shell_overlay_dismissed,
3154
+ yui_shell_unpark_route,
2595
3155
  yui_shell_set_avatar_provider,
2596
3156
  yui_shell_refresh_avatars,
2597
3157
  yui_shell_set_translator,
3158
+ yui_shell_language_changed,
2598
3159
  yui_shell_set_connection_state,
2599
3160
  yui_shell_set_toolbar_item_icon,
2600
3161
  yui_shell_close_dropdown,