@yuneta/gobj-ui 5.2.0 → 5.3.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.
@@ -271,6 +271,24 @@
271
271
  opacity: 0.5;
272
272
  }
273
273
 
274
+ /*--- breadcrumb: the path as one line (C_YUI_NODE's `projection.path`) ---*/
275
+ .yui-nav-breadcrumb {
276
+ padding: 0.35rem 0.75rem;
277
+ }
278
+ .yui-nav-breadcrumb .yui-nav-crumb {
279
+ display: inline-flex;
280
+ align-items: center;
281
+ gap: 0.35rem;
282
+ }
283
+ /* Bulma marks the last crumb with li.is-active and greys it; the
284
+ * active_route highlight lands there, so "where I am" reads as the end
285
+ * of the trail instead of as one more link. */
286
+ .yui-nav-breadcrumb li.is-active > a {
287
+ color: var(--bulma-text-strong, #363636);
288
+ font-weight: 600;
289
+ pointer-events: none;
290
+ }
291
+
274
292
  /*--- backbar (mobile "← <section>" back-to-index, submenu.index) ---*/
275
293
  .yui-nav-backbar {
276
294
  display: flex;
@@ -100,6 +100,7 @@ SDATA(data_type_t.DTP_POINTER, "subscriber", 0, null, "Subscriber of outp
100
100
  SDATA(data_type_t.DTP_JSON, "config", 0, null, "Shell declarative config (zones, menu, stages, toolbar)"),
101
101
  SDATA(data_type_t.DTP_STRING, "default_route", 0, "", "Fallback route if hash is empty"),
102
102
  SDATA(data_type_t.DTP_STRING, "current_route", 0, "", "Current active route"),
103
+ SDATA(data_type_t.DTP_BOOLEAN, "remember_section_position", 0, false, "A menu click returns to the last position visited inside that section"),
103
104
  SDATA(data_type_t.DTP_BOOLEAN, "use_hash", 0, true, "Bind navigation to window.location.hash"),
104
105
  SDATA(data_type_t.DTP_POINTER, "mount_element", 0, null, "HTMLElement to mount shell into (default: document.body)"),
105
106
 
@@ -113,6 +114,9 @@ let PRIVATE_DATA = {
113
114
  stages: {},
114
115
  navs: [],
115
116
  item_index: {},
117
+ /* Section route -> last position visited inside it. Mirrors the
118
+ * url for the duration of the page; see remember_position(). */
119
+ section_pos: {},
116
120
  /* Sub-route contributor registry (ROUTING.md): a mounted view
117
121
  * declares the deep, view-owned children of its base route
118
122
  * (topics, /info, /schema, focus topics — subpaths that are NOT
@@ -225,6 +229,14 @@ function mt_start(gobj)
225
229
  return;
226
230
  }
227
231
 
232
+ /* Declarative sugar over the attr, which stays the runtime truth:
233
+ * an app can flip it later without rebuilding the shell. */
234
+ let shell_cfg = config.shell || {};
235
+ if(typeof shell_cfg.remember_section_position === "boolean") {
236
+ gobj_write_attr(gobj, "remember_section_position",
237
+ shell_cfg.remember_section_position);
238
+ }
239
+
228
240
  build_item_index(gobj, config);
229
241
  instantiate_menus(gobj, config);
230
242
  build_toolbar(gobj, config);
@@ -838,6 +850,76 @@ function build_item_index(gobj, config)
838
850
  };
839
851
  }
840
852
  }
853
+
854
+ index_node_tree(gobj, config);
855
+ }
856
+
857
+ /************************************************************
858
+ * config.shell.tree — the navigation is a TREE OF NODES and
859
+ * its root is where the shell's own root used to be.
860
+ *
861
+ * The shell keeps what it is good at, the SPACE: zones, layers,
862
+ * stages, toolbar, overlays, theme, breakpoints. It stops being
863
+ * the owner of the menu: the root C_YUI_NODE holds the primary
864
+ * options as its children and projects them into zones (a left
865
+ * rail, a bottom bar) exactly as `menu.primary.render` used to
866
+ * say — a per-zone projection is still a projection.
867
+ *
868
+ * All this does is synthesize ONE route entry. `owns_subtree`
869
+ * lets it match as the ancestor of everything (route_resolver),
870
+ * so the whole url space below it is the tree's `subpath` and no
871
+ * route table grows. The unknown-route diagnostic is not lost:
872
+ * it moves to the node that knows the names of its children.
873
+ *
874
+ * `menu` and `tree` are not exclusive — an app may declare both
875
+ * while it migrates — but a tree at "/" owns every route no
876
+ * other entry claims, which is the point.
877
+ ************************************************************/
878
+ function index_node_tree(gobj, config)
879
+ {
880
+ let priv = gobj.priv;
881
+ let tree = (config.shell && config.shell.tree) || null;
882
+
883
+ if(!tree) {
884
+ return;
885
+ }
886
+ if(!is_object(tree)) {
887
+ log_error("C_YUI_SHELL: config.shell.tree must be an object");
888
+ return;
889
+ }
890
+
891
+ let base_route = normalize_route(tree.base_route || "/");
892
+
893
+ /* JSON has no comments and these configs annotate themselves with
894
+ * sibling `_name_comment` keys (the established idiom — see
895
+ * app_config.json). They are documentation, not attrs: handing
896
+ * them to gobj_create is an "Attribute NOT FOUND" per comment. */
897
+ let kw = {};
898
+ for(let key of Object.keys(tree)) {
899
+ if(key.charAt(0) === "_" || key === "stage") {
900
+ continue;
901
+ }
902
+ kw[key] = tree[key];
903
+ }
904
+ kw.base_route = base_route;
905
+ if(!kw.node_id) {
906
+ kw.node_id = "root";
907
+ }
908
+
909
+ let prev = priv.item_index[base_route];
910
+ priv.item_index[base_route] = {
911
+ item: (prev && prev.item) || null,
912
+ parent_item: (prev && prev.parent_item) || null,
913
+ stage: tree.stage || "main",
914
+ menu_id: (prev && prev.menu_id) || "",
915
+ target: {
916
+ stage: tree.stage || "main",
917
+ gclass: "C_YUI_NODE",
918
+ lifecycle: "keep_alive",
919
+ owns_subtree: true,
920
+ kw: kw
921
+ }
922
+ };
841
923
  }
842
924
 
843
925
  /************************************************************
@@ -1365,6 +1447,7 @@ function navigate_to(gobj, route, depth, no_drain)
1365
1447
 
1366
1448
  stage.active_route = matched_route;
1367
1449
  gobj_write_attr(gobj, "current_route", route);
1450
+ remember_position(gobj, route);
1368
1451
 
1369
1452
  /* Show/hide secondary navs according to parent item */
1370
1453
  update_secondary_nav_visibility(gobj, entry);
@@ -2234,7 +2317,11 @@ function pop_escape(gobj, handler)
2234
2317
  * - navigate_to() drains (closes) every registered overlay when the
2235
2318
  * RESTING route changes — overlays are transient and do not
2236
2319
  * outlive the view they float above. An action route or a
2237
- * subpath-only move keeps them open.
2320
+ * subpath-only move keeps them open. An overlay registered with
2321
+ * `keep_on_navigate` opts OUT of the drain: it is a NAVIGATION
2322
+ * PANEL, not a thing floating above one view — the site map is
2323
+ * the reference case, where every row click is meant to move the
2324
+ * user with the panel still up.
2238
2325
  * - a drained/buried entry is left INERT: overlay_dismissed only
2239
2326
  * history.back()s when the marker is the CURRENT history entry
2240
2327
  * (adjacent, so the back() is invisible) — never over real route
@@ -2254,14 +2341,25 @@ function drain_overlays(gobj)
2254
2341
  if(!priv || !priv.overlay_stack) {
2255
2342
  return;
2256
2343
  }
2344
+ /* Survivors keep their relative order: the stack is a LIFO and a
2345
+ * panel that outlives the drain must stay where it was, or Escape
2346
+ * would start closing overlays in an order the user never built. */
2347
+ let kept = [];
2257
2348
  while(priv.overlay_stack.length > 0) {
2258
2349
  let entry = priv.overlay_stack.pop();
2350
+ if(entry.keep_on_navigate) {
2351
+ kept.unshift(entry);
2352
+ continue;
2353
+ }
2259
2354
  try {
2260
2355
  entry.close();
2261
2356
  } catch(e) {
2262
2357
  log_warning(`C_YUI_SHELL: overlay close on navigation failed: ${e}`);
2263
2358
  }
2264
2359
  }
2360
+ for(let entry of kept) {
2361
+ priv.overlay_stack.push(entry);
2362
+ }
2265
2363
  }
2266
2364
 
2267
2365
  /* A URL rewrite moved the entry `st` tags to `hash`. When `st` is a live
@@ -2279,7 +2377,7 @@ function retag_overlay_hash(gobj, st, hash)
2279
2377
  }
2280
2378
  }
2281
2379
 
2282
- function push_overlay_history(gobj, close)
2380
+ function push_overlay_history(gobj, close, opts)
2283
2381
  {
2284
2382
  let priv = gobj.priv;
2285
2383
  if(!priv || !priv.overlay_stack || !gobj_read_attr(gobj, "use_hash")) {
@@ -2547,6 +2645,11 @@ function ac_nav_clicked(gobj, event, kw, src)
2547
2645
  return 0;
2548
2646
  }
2549
2647
 
2648
+ /* A click on a section returns to where the user was inside it.
2649
+ * It stays a PUSH: they chose to go there, so Back must bring them
2650
+ * back — only WHICH spot inside was decided for them. */
2651
+ route = remembered_position(gobj, route);
2652
+
2550
2653
  /* When hash routing is on, let the hash drive navigate_to() — that
2551
2654
  * way back/forward buttons and programmatic hash changes all flow
2552
2655
  * through the same code path. Otherwise call navigate_to directly.
@@ -2852,6 +2955,77 @@ function register_c_yui_shell()
2852
2955
  * bottom of the file to keep the skeleton layout intact.
2853
2956
  ***************************************************************/
2854
2957
 
2958
+ /************************************************************
2959
+ * Last position inside each section — the routes a MENU CLICK
2960
+ * returns to (`remember_section_position`).
2961
+ *
2962
+ * A section is a menu item's route; anything under it is a
2963
+ * position inside it. The memory MIRRORS the url and is never
2964
+ * the authority for it (ROUTING.md §3): it lives for the page,
2965
+ * not on disk, and only a click on the section's own menu item
2966
+ * consults it. Typing the url, a deep link, Back/Forward and
2967
+ * programmatic navigation all land exactly where they say —
2968
+ * which is the property that would be lost if this were stored
2969
+ * and applied everywhere.
2970
+ ************************************************************/
2971
+ function remember_position(gobj, route)
2972
+ {
2973
+ let priv = gobj.priv;
2974
+
2975
+ if(!gobj_read_attr(gobj, "remember_section_position")) {
2976
+ return;
2977
+ }
2978
+ for(let section of Object.keys(priv.item_index)) {
2979
+ if(section === "/") {
2980
+ continue;
2981
+ }
2982
+ /* The section's own root IS a position inside it. Recording it
2983
+ * is what keeps the memory a MIRROR: after deliberately resting
2984
+ * at /cards, a click on Cards must land there, not teleport
2985
+ * back into the deep spot visited before. */
2986
+ if(route === section || route.indexOf(section + "/") === 0) {
2987
+ priv.section_pos[section] = route;
2988
+ }
2989
+ }
2990
+ }
2991
+
2992
+ /************************************************************
2993
+ * Where a click on `route` should actually land: the last
2994
+ * position inside it, when there is one and it still resolves.
2995
+ * A remembered route whose node has since disappeared is
2996
+ * dropped rather than followed — the tree is allowed to change
2997
+ * under a memory.
2998
+ ************************************************************/
2999
+ function remembered_position(gobj, route)
3000
+ {
3001
+ let priv = gobj.priv;
3002
+
3003
+ if(!gobj_read_attr(gobj, "remember_section_position")) {
3004
+ return route;
3005
+ }
3006
+
3007
+ /* Only a click that ENTERS the section from outside it restores a
3008
+ * position. From INSIDE — a backbar going up to the index, a tab
3009
+ * strip, the rail item of the section you are already in — the
3010
+ * click means the route it names and nothing else. Without this,
3011
+ * "← Section index" put the user back on the leaf they were
3012
+ * leaving and the control looked dead. */
3013
+ let current = gobj_read_attr(gobj, "current_route") || "";
3014
+ if(current === route || current.indexOf(route + "/") === 0) {
3015
+ return route;
3016
+ }
3017
+ let last = priv.section_pos[route];
3018
+ if(!last || last === route) {
3019
+ return route;
3020
+ }
3021
+ let r = resolve_route(priv.item_index, last);
3022
+ if(!r || !r.entry || !r.entry.target) {
3023
+ delete priv.section_pos[route];
3024
+ return route;
3025
+ }
3026
+ return last;
3027
+ }
3028
+
2855
3029
  /************************************************************
2856
3030
  * Resolve the shell that governs `gobj`: the nearest
2857
3031
  * C_YUI_SHELL ancestor, else the last shell created on the
@@ -2871,6 +3045,29 @@ function yui_shell_of(gobj)
2871
3045
  return __last_shell__;
2872
3046
  }
2873
3047
 
3048
+ /************************************************************
3049
+ * The DOM of a zone, for whoever projects into the space.
3050
+ *
3051
+ * The shell owns the space; a root C_YUI_NODE that projects its
3052
+ * children into a left rail or a bottom bar needs the element to
3053
+ * mount them in. Null (and a warning) for an unknown zone —
3054
+ * silently projecting into nowhere is how a menu disappears with
3055
+ * no explanation.
3056
+ ************************************************************/
3057
+ function yui_shell_zone(shell_gobj, zone_id)
3058
+ {
3059
+ if(!shell_gobj || !shell_gobj.priv || !shell_gobj.priv.zones) {
3060
+ log_warning(`C_YUI_SHELL: yui_shell_zone — no shell`);
3061
+ return null;
3062
+ }
3063
+ let $zone = shell_gobj.priv.zones[zone_id];
3064
+ if(!$zone) {
3065
+ log_warning(`C_YUI_SHELL: yui_shell_zone — unknown zone '${zone_id}'`);
3066
+ return null;
3067
+ }
3068
+ return $zone;
3069
+ }
3070
+
2874
3071
  /************************************************************
2875
3072
  * Programmatic navigation.
2876
3073
  *
@@ -3000,9 +3197,13 @@ function yui_shell_pop_escape(shell_gobj, handler)
3000
3197
  * `close_fn` is what the Back button invokes to tear the overlay down.
3001
3198
  * Returns null when history integration is off (no shell / use_hash) —
3002
3199
  * callers just skip the paired yui_shell_overlay_dismissed then. */
3003
- function yui_shell_register_overlay(shell_gobj, close_fn)
3200
+ /* `opts.keep_on_navigate` this overlay is a navigation PANEL and
3201
+ * survives a resting-route change (see drain_overlays). Default off:
3202
+ * an overlay is transient, and outliving the view it floats above is
3203
+ * the exception that has to be asked for. */
3204
+ function yui_shell_register_overlay(shell_gobj, close_fn, opts)
3004
3205
  {
3005
- return push_overlay_history(shell_gobj, close_fn);
3206
+ return push_overlay_history(shell_gobj, close_fn, opts);
3006
3207
  }
3007
3208
  function yui_shell_overlay_dismissed(shell_gobj, overlay)
3008
3209
  {
@@ -3160,6 +3361,7 @@ function yui_shell_close_dropdown(shell_gobj)
3160
3361
  export {
3161
3362
  register_c_yui_shell,
3162
3363
  yui_shell_of,
3364
+ yui_shell_zone,
3163
3365
  yui_shell_navigate,
3164
3366
  yui_shell_nav_map,
3165
3367
  yui_shell_set_sub_routes,
@@ -20,6 +20,7 @@ import {
20
20
  log_error,
21
21
  gobj_read_pointer_attr,
22
22
  gobj_subscribe_event,
23
+ gobj_send_event,
23
24
  gobj_parent,
24
25
  gobj_read_attr,
25
26
  createElement2,
@@ -107,6 +107,7 @@ SDATA(data_type_t.DTP_POINTER, "focus", 0, null, "Brings focus to the
107
107
  SDATA(data_type_t.DTP_BOOLEAN, "modal", 0, false, "Enable modal mode"),
108
108
  SDATA(data_type_t.DTP_BOOLEAN, "keyboard", 0, true, "Close window on ESC if not modal"),
109
109
  SDATA(data_type_t.DTP_BOOLEAN, "back_dismissable", 0, true, "Browser Back closes this window (floating overlays only; ignored when it has a `manager`)"),
110
+ SDATA(data_type_t.DTP_BOOLEAN, "keep_on_navigate", 0, false, "This window is a navigation PANEL: a route change does not close it"),
110
111
  SDATA(data_type_t.DTP_POINTER, "back_overlay", 0, null, "Internal: overlay-history entry (Back-button integration)"),
111
112
  SDATA(data_type_t.DTP_POINTER, "$container", 0, null, "Internal: Window container element"),
112
113
  SDATA(data_type_t.DTP_STRING, "window_id", 0, "", "Internal: Window ID"),
@@ -183,12 +184,16 @@ function mt_create(gobj)
183
184
  } else if(gobj_read_bool_attr(gobj, "back_dismissable")) {
184
185
  /* Floating overlay window (no dock manager): the browser Back
185
186
  * button closes it, like a modal/popup. Dock-managed windows
186
- * are persistent workspace surfaces and are left out. Retired
187
- * in mt_destroy (covers every teardown path). */
187
+ * are persistent workspace surfaces and are left out — which
188
+ * is also why a navigation panel prefers a manager, and falls
189
+ * back to `keep_on_navigate` when the app has no dock.
190
+ * Retired in mt_destroy (covers every teardown path). */
188
191
  let shell = yui_shell_of(gobj);
189
192
  if(shell) {
190
193
  let overlay = yui_shell_register_overlay(
191
- shell, function() { close_window(gobj); }
194
+ shell,
195
+ function() { close_window(gobj); },
196
+ {keep_on_navigate: gobj_read_bool_attr(gobj, "keep_on_navigate")}
192
197
  );
193
198
  gobj_write_attr(gobj, "back_overlay", overlay);
194
199
  }