@yuneta/gobj-ui 1.0.1 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -363
- package/dist/gobj-ui.cjs.js +11154 -5506
- package/dist/gobj-ui.es.js +11131 -5508
- package/index.js +41 -14
- package/package.json +11 -9
- package/src/c_g6_nodes_tree.js +6 -1
- package/src/c_yui_form.js +1 -1
- package/src/c_yui_gobj_tree_js.js +1 -1
- package/src/c_yui_json_graph.js +1 -1
- package/src/c_yui_main.js +3 -3
- package/src/c_yui_map.js +6 -1
- package/src/c_yui_nav.js +881 -0
- package/src/c_yui_pager.js +545 -0
- package/src/c_yui_routing.css +1 -1
- package/src/c_yui_routing.js +1 -1
- package/src/c_yui_shell.css +571 -0
- package/src/c_yui_shell.js +2474 -0
- package/src/c_yui_tabs.js +1 -1
- package/src/c_yui_treedb_graph.js +35 -9
- package/src/c_yui_treedb_topic_with_form.js +1 -1
- package/src/c_yui_treedb_topics.js +36 -8
- package/src/c_yui_uplot.js +1 -1
- package/src/c_yui_window.js +242 -21
- package/src/c_yui_window_manager.js +602 -0
- package/src/c_yui_wizard.js +612 -0
- package/src/pager_helpers.js +138 -0
- package/src/pager_helpers.test.js +140 -0
- package/src/route_resolver.js +53 -0
- package/src/route_resolver.test.js +82 -0
- package/src/shell_focus_trap.js +123 -0
- package/src/shell_focus_trap.test.js +299 -0
- package/src/shell_modals.js +445 -0
- package/src/shell_show_on.js +91 -0
- package/src/shell_show_on.test.js +86 -0
- package/src/shell_toolbar_helpers.js +221 -0
- package/src/shell_toolbar_helpers.test.js +207 -0
- package/src/tabulator.css +53 -0
- package/src/wizard_helpers.js +117 -0
- package/src/wizard_helpers.test.js +122 -0
- package/src/yui_dev.js +1257 -362
- package/src/yui_icons.css +5 -0
- package/src/yui_inputs.css +32 -0
- package/src/yui_inputs.js +71 -0
- package/vite-plugin-yuneta-html.js +2 -2
- package/skeleton/config.json +0 -20
- package/skeleton/index.html +0 -37
|
@@ -0,0 +1,2474 @@
|
|
|
1
|
+
/***********************************************************************
|
|
2
|
+
* c_yui_shell.js
|
|
3
|
+
*
|
|
4
|
+
* C_YUI_SHELL — Declarative app shell.
|
|
5
|
+
*
|
|
6
|
+
* Parses a JSON config to build:
|
|
7
|
+
* - Layers (z-stacked): base, overlay, popup, modal, notification, loading
|
|
8
|
+
* - Zones (inside base layer): top, top-sub, left, center, right,
|
|
9
|
+
* bottom-sub, bottom
|
|
10
|
+
* - Menus (primary + submenus) rendered via C_YUI_NAV — one nav
|
|
11
|
+
* instance per zone hosting the menu
|
|
12
|
+
* - Stages: zones declared to host routed view gobjs (typ. center)
|
|
13
|
+
*
|
|
14
|
+
* Each menu item's `target` declares which gclass to instantiate (or
|
|
15
|
+
* gobj to reuse) in which stage. Navigating = show the target gobj's
|
|
16
|
+
* $container in its stage, hide the previous one. Lifecycle per item
|
|
17
|
+
* decides when the gobj is created/destroyed.
|
|
18
|
+
*
|
|
19
|
+
* Hash-based 2-level routing (#/primary/secondary). No dependency on
|
|
20
|
+
* C_YUI_ROUTING.
|
|
21
|
+
*
|
|
22
|
+
* Copyright (c) 2026, ArtGins.
|
|
23
|
+
* All Rights Reserved.
|
|
24
|
+
***********************************************************************/
|
|
25
|
+
/* global window, document */
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
SDATA, SDATA_END, data_type_t, event_flag_t, gclass_flag_t,
|
|
29
|
+
gclass_create, log_error, log_warning,
|
|
30
|
+
gobj_create, gobj_destroy,
|
|
31
|
+
gobj_start, gobj_stop,
|
|
32
|
+
gobj_publish_event,
|
|
33
|
+
gobj_send_event,
|
|
34
|
+
gobj_subscribe_event,
|
|
35
|
+
gobj_read_attr, gobj_read_pointer_attr, gobj_write_attr,
|
|
36
|
+
createElement2, empty_string, is_object, is_array, is_string,
|
|
37
|
+
refresh_language,
|
|
38
|
+
} from "@yuneta/gobj-js";
|
|
39
|
+
|
|
40
|
+
import {
|
|
41
|
+
BULMA_BP_ORDER,
|
|
42
|
+
breakpoints_from_expr,
|
|
43
|
+
bulma_hidden_class,
|
|
44
|
+
} from "./shell_show_on.js";
|
|
45
|
+
|
|
46
|
+
import {
|
|
47
|
+
activate_focus_trap_on,
|
|
48
|
+
} from "./shell_focus_trap.js";
|
|
49
|
+
|
|
50
|
+
import {
|
|
51
|
+
classify_toolbar_item,
|
|
52
|
+
validate_toolbar_item,
|
|
53
|
+
} from "./shell_toolbar_helpers.js";
|
|
54
|
+
|
|
55
|
+
import { resolve_route } from "./route_resolver.js";
|
|
56
|
+
|
|
57
|
+
/***************************************************************
|
|
58
|
+
* Constants
|
|
59
|
+
***************************************************************/
|
|
60
|
+
const GCLASS_NAME = "C_YUI_SHELL";
|
|
61
|
+
|
|
62
|
+
/* Zones rendered inside the base layer. */
|
|
63
|
+
const ZONE_IDS = ["top", "top-sub", "left", "center", "right", "bottom-sub", "bottom"];
|
|
64
|
+
|
|
65
|
+
/* Global stacking layers. */
|
|
66
|
+
const LAYER_DEFS = [
|
|
67
|
+
["base", 1 ],
|
|
68
|
+
["overlay", 15 ],
|
|
69
|
+
["popup", 20 ],
|
|
70
|
+
["modal", 99 ],
|
|
71
|
+
["notification", 120],
|
|
72
|
+
["loading", 150]
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
/***************************************************************
|
|
76
|
+
* Attrs
|
|
77
|
+
***************************************************************/
|
|
78
|
+
const attrs_table = [
|
|
79
|
+
SDATA(data_type_t.DTP_POINTER, "subscriber", 0, null, "Subscriber of output events"),
|
|
80
|
+
|
|
81
|
+
SDATA(data_type_t.DTP_JSON, "config", 0, null, "Shell declarative config (zones, menu, stages, toolbar)"),
|
|
82
|
+
SDATA(data_type_t.DTP_STRING, "default_route", 0, "", "Fallback route if hash is empty"),
|
|
83
|
+
SDATA(data_type_t.DTP_STRING, "current_route", 0, "", "Current active route"),
|
|
84
|
+
SDATA(data_type_t.DTP_BOOLEAN, "use_hash", 0, true, "Bind navigation to window.location.hash"),
|
|
85
|
+
SDATA(data_type_t.DTP_POINTER, "mount_element", 0, null, "HTMLElement to mount shell into (default: document.body)"),
|
|
86
|
+
|
|
87
|
+
SDATA(data_type_t.DTP_POINTER, "$container", 0, null, "Root HTMLElement of the shell"),
|
|
88
|
+
SDATA(data_type_t.DTP_POINTER, "priv", 0, null, "Private runtime state (zones/layers/stages/navs)"),
|
|
89
|
+
SDATA_END()
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
let PRIVATE_DATA = {};
|
|
93
|
+
|
|
94
|
+
let __gclass__ = null;
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
/******************************
|
|
100
|
+
* Framework Methods
|
|
101
|
+
******************************/
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
/***************************************************************
|
|
107
|
+
* Framework Method: Create
|
|
108
|
+
***************************************************************/
|
|
109
|
+
function mt_create(gobj)
|
|
110
|
+
{
|
|
111
|
+
/*
|
|
112
|
+
* SERVICE subscription model
|
|
113
|
+
*/
|
|
114
|
+
const subscriber = gobj_read_pointer_attr(gobj, "subscriber");
|
|
115
|
+
if(subscriber) {
|
|
116
|
+
gobj_subscribe_event(gobj, null, {}, subscriber);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/* Per-instance private state (avoid the gclass-level PRIVATE_DATA). */
|
|
120
|
+
gobj_write_attr(gobj, "priv", {
|
|
121
|
+
zones: {},
|
|
122
|
+
layers: {},
|
|
123
|
+
stages: {},
|
|
124
|
+
navs: [],
|
|
125
|
+
item_index: {},
|
|
126
|
+
hash_handler: null,
|
|
127
|
+
keydown_handler: null,
|
|
128
|
+
/* Escape priority chain: array of { layer, handler }. Each
|
|
129
|
+
* interactive overlay (drawer today, modal/popup tomorrow)
|
|
130
|
+
* pushes its close handler when it opens and pops it when
|
|
131
|
+
* it closes. Escape calls the top handler only — LIFO. */
|
|
132
|
+
escape_stack: [],
|
|
133
|
+
/* Avatar item support — every toolbar item with type:"avatar"
|
|
134
|
+
* registers its <span> here so refresh_avatars() can repaint
|
|
135
|
+
* the initials when the host (wattyzer, hidraulia, …) calls
|
|
136
|
+
* yui_shell_set_avatar_provider() / yui_shell_refresh_avatars().
|
|
137
|
+
* The provider is a () => string callback owned by the host. */
|
|
138
|
+
avatar_provider: null,
|
|
139
|
+
avatar_nodes: [],
|
|
140
|
+
/* Optional i18n translator (t-function) the host registers via
|
|
141
|
+
* yui_shell_set_translator(). The host translates the static
|
|
142
|
+
* shell tree by calling refresh_language($container, t) once,
|
|
143
|
+
* but LAZILY-built DOM (the toolbar dropdown panel) is mounted
|
|
144
|
+
* on the popup layer, OUTSIDE $container, AFTER that call — so
|
|
145
|
+
* it would never be translated. When a translator is set the
|
|
146
|
+
* shell re-applies it to each freshly built panel. */
|
|
147
|
+
translator: null,
|
|
148
|
+
/* Connection-indicator support — every toolbar item with
|
|
149
|
+
* type:"connection" registers its dot <span> here so
|
|
150
|
+
* yui_shell_set_connection_state(shell, bool) can repaint the
|
|
151
|
+
* backend-connected state. State is host/event-driven (unlike
|
|
152
|
+
* the avatar provider it is a setter, not a pull callback). */
|
|
153
|
+
conn_nodes: [],
|
|
154
|
+
/* Currently open toolbar dropdown panel, if any. Tracked here
|
|
155
|
+
* so a second click on any trigger (or programmatic close) can
|
|
156
|
+
* tear down the previous one through the same code path. */
|
|
157
|
+
active_dropdown: null
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
build_ui(gobj);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/***************************************************************
|
|
164
|
+
* Framework Method: Start
|
|
165
|
+
***************************************************************/
|
|
166
|
+
function mt_start(gobj)
|
|
167
|
+
{
|
|
168
|
+
let config = gobj_read_attr(gobj, "config") || {};
|
|
169
|
+
let priv = gobj_read_attr(gobj, "priv");
|
|
170
|
+
|
|
171
|
+
/* Validate the declarative config before anything reads it. This is a
|
|
172
|
+
* system boundary (app-supplied JSON); validation makes shape errors
|
|
173
|
+
* visible loudly instead of producing a half-built shell. */
|
|
174
|
+
if(!validate_config(config)) {
|
|
175
|
+
let $base = priv.layers && priv.layers.base;
|
|
176
|
+
if($base) {
|
|
177
|
+
$base.appendChild(createElement2(
|
|
178
|
+
["div", {class: "notification is-danger m-4"},
|
|
179
|
+
["p", {}, "C_YUI_SHELL: invalid config — see browser console for details"]
|
|
180
|
+
]
|
|
181
|
+
));
|
|
182
|
+
}
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
build_item_index(gobj, config);
|
|
187
|
+
instantiate_menus(gobj, config);
|
|
188
|
+
build_toolbar(gobj, config);
|
|
189
|
+
|
|
190
|
+
/* lifecycle: "eager" — preinstantiate views that must exist from boot. */
|
|
191
|
+
preinstantiate_eager_views(gobj);
|
|
192
|
+
|
|
193
|
+
if(gobj_read_attr(gobj, "use_hash")) {
|
|
194
|
+
priv.hash_handler = () => {
|
|
195
|
+
let route = hash_to_route(window.location.hash);
|
|
196
|
+
if(!empty_string(route)) {
|
|
197
|
+
navigate_to(gobj, route);
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
window.addEventListener("hashchange", priv.hash_handler);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/* Global Escape: route to the top handler of the escape stack,
|
|
204
|
+
* not to a hardcoded "close all drawers". Modals and popups
|
|
205
|
+
* push themselves on top of drawers, so Escape closes them
|
|
206
|
+
* first; second Escape closes the drawer underneath; etc. */
|
|
207
|
+
priv.keydown_handler = ev => {
|
|
208
|
+
if(ev.key !== "Escape" && ev.keyCode !== 27) {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if(priv.escape_stack.length === 0) {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
let top = priv.escape_stack[priv.escape_stack.length - 1];
|
|
215
|
+
ev.preventDefault();
|
|
216
|
+
ev.stopPropagation();
|
|
217
|
+
top.handler();
|
|
218
|
+
};
|
|
219
|
+
window.addEventListener("keydown", priv.keydown_handler);
|
|
220
|
+
|
|
221
|
+
let initial = hash_to_route(window.location.hash);
|
|
222
|
+
if(empty_string(initial)) {
|
|
223
|
+
initial = gobj_read_attr(gobj, "default_route") ||
|
|
224
|
+
(config.shell && config.shell.stages && config.shell.stages.main &&
|
|
225
|
+
config.shell.stages.main.default_route) || "";
|
|
226
|
+
}
|
|
227
|
+
if(!empty_string(initial)) {
|
|
228
|
+
navigate_to(gobj, initial);
|
|
229
|
+
} else {
|
|
230
|
+
/* No hash, no default_route, no stage default: tell the user loudly. */
|
|
231
|
+
show_stage_placeholder(
|
|
232
|
+
gobj, "main",
|
|
233
|
+
"C_YUI_SHELL: no route to display (empty hash, no default_route, " +
|
|
234
|
+
"no stages.main.default_route)"
|
|
235
|
+
);
|
|
236
|
+
log_error(
|
|
237
|
+
"C_YUI_SHELL: no initial route — set default_route, " +
|
|
238
|
+
"shell.stages.<name>.default_route, or navigate via hash"
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/***************************************************************
|
|
244
|
+
* Framework Method: Stop
|
|
245
|
+
***************************************************************/
|
|
246
|
+
function mt_stop(gobj)
|
|
247
|
+
{
|
|
248
|
+
let priv = gobj_read_attr(gobj, "priv");
|
|
249
|
+
if(!priv) {
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/* Tear down any toolbar dropdown that was open at stop time so its
|
|
254
|
+
* document-level mousedown listener and escape-stack entry don't
|
|
255
|
+
* outlive the shell. */
|
|
256
|
+
close_toolbar_dropdown(gobj);
|
|
257
|
+
|
|
258
|
+
if(priv.hash_handler) {
|
|
259
|
+
window.removeEventListener("hashchange", priv.hash_handler);
|
|
260
|
+
priv.hash_handler = null;
|
|
261
|
+
}
|
|
262
|
+
if(priv.keydown_handler) {
|
|
263
|
+
window.removeEventListener("keydown", priv.keydown_handler);
|
|
264
|
+
priv.keydown_handler = null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
for(let nav of priv.navs) {
|
|
268
|
+
try {
|
|
269
|
+
gobj_stop(nav);
|
|
270
|
+
gobj_destroy(nav);
|
|
271
|
+
} catch(e) {
|
|
272
|
+
log_warning(`C_YUI_SHELL: stop/destroy nav failed: ${e}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
priv.navs = [];
|
|
276
|
+
|
|
277
|
+
for(let name in priv.stages) {
|
|
278
|
+
let st = priv.stages[name];
|
|
279
|
+
for(let route in st.items) {
|
|
280
|
+
let g = st.items[route];
|
|
281
|
+
try {
|
|
282
|
+
gobj_stop(g);
|
|
283
|
+
gobj_destroy(g);
|
|
284
|
+
} catch(e) {
|
|
285
|
+
log_warning(`C_YUI_SHELL: stop/destroy view '${route}' failed: ${e}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
st.items = {};
|
|
289
|
+
st.active_route = null;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/***************************************************************
|
|
294
|
+
* Framework Method: Destroy
|
|
295
|
+
***************************************************************/
|
|
296
|
+
function mt_destroy(gobj)
|
|
297
|
+
{
|
|
298
|
+
/* Defensive: tests sometimes call gobj_destroy without a prior
|
|
299
|
+
* gobj_stop. In normal lifecycle mt_stop already closed the
|
|
300
|
+
* dropdown; calling close_toolbar_dropdown here is idempotent
|
|
301
|
+
* (it returns early when nothing is open) and prevents a
|
|
302
|
+
* dangling document mousedown listener / escape-stack entry
|
|
303
|
+
* when stop is skipped. */
|
|
304
|
+
close_toolbar_dropdown(gobj);
|
|
305
|
+
|
|
306
|
+
let $container = gobj_read_attr(gobj, "$container");
|
|
307
|
+
if($container && $container.parentNode) {
|
|
308
|
+
$container.parentNode.removeChild($container);
|
|
309
|
+
}
|
|
310
|
+
gobj_write_attr(gobj, "$container", null);
|
|
311
|
+
gobj_write_attr(gobj, "priv", null);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
/***************************
|
|
318
|
+
* Local Methods
|
|
319
|
+
***************************/
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
/************************************************************
|
|
325
|
+
* Validate the declarative config (system boundary: app JSON).
|
|
326
|
+
*
|
|
327
|
+
* Reports every missing/wrong field via log_error so a malformed
|
|
328
|
+
* config fails loudly instead of producing an empty/broken shell.
|
|
329
|
+
* Returns true iff the config is structurally usable.
|
|
330
|
+
************************************************************/
|
|
331
|
+
function validate_config(config)
|
|
332
|
+
{
|
|
333
|
+
let ok = true;
|
|
334
|
+
|
|
335
|
+
if(!is_object(config)) {
|
|
336
|
+
log_error("C_YUI_SHELL: config must be a JSON object");
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
if(!is_object(config.shell)) {
|
|
340
|
+
log_error("C_YUI_SHELL: config.shell is missing or not an object");
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
let shell_cfg = config.shell;
|
|
345
|
+
if(shell_cfg.zones !== undefined && !is_object(shell_cfg.zones)) {
|
|
346
|
+
log_error("C_YUI_SHELL: config.shell.zones must be an object");
|
|
347
|
+
ok = false;
|
|
348
|
+
}
|
|
349
|
+
if(shell_cfg.stages !== undefined && !is_object(shell_cfg.stages)) {
|
|
350
|
+
log_error("C_YUI_SHELL: config.shell.stages must be an object");
|
|
351
|
+
ok = false;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
let zones_cfg = is_object(shell_cfg.zones) ? shell_cfg.zones : {};
|
|
355
|
+
/* host syntax: must be one of "toolbar", "menu.<id>", "stage.<id>". */
|
|
356
|
+
let HOST_RE = /^(?:toolbar|menu\.\S+|stage\.\S+)$/;
|
|
357
|
+
for(let zid in zones_cfg) {
|
|
358
|
+
if(ZONE_IDS.indexOf(zid) < 0) {
|
|
359
|
+
log_error(
|
|
360
|
+
`C_YUI_SHELL: unknown zone '${zid}' in config.shell.zones; ` +
|
|
361
|
+
`valid zones: ${ZONE_IDS.join(", ")}`
|
|
362
|
+
);
|
|
363
|
+
ok = false;
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
let z = zones_cfg[zid];
|
|
367
|
+
if(z && typeof z.host === "string" && z.host.length > 0 &&
|
|
368
|
+
!HOST_RE.test(z.host))
|
|
369
|
+
{
|
|
370
|
+
log_error(
|
|
371
|
+
`C_YUI_SHELL: zone '${zid}' has invalid host '${z.host}'; ` +
|
|
372
|
+
`must match 'toolbar', 'menu.<id>' or 'stage.<id>'`
|
|
373
|
+
);
|
|
374
|
+
ok = false;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
let stages_cfg = is_object(shell_cfg.stages) ? shell_cfg.stages : {};
|
|
379
|
+
for(let sname in stages_cfg) {
|
|
380
|
+
let st = stages_cfg[sname];
|
|
381
|
+
if(!is_object(st)) {
|
|
382
|
+
log_error(`C_YUI_SHELL: config.shell.stages.${sname} must be an object`);
|
|
383
|
+
ok = false;
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
let zone = st.zone || "center";
|
|
387
|
+
if(ZONE_IDS.indexOf(zone) < 0) {
|
|
388
|
+
log_error(
|
|
389
|
+
`C_YUI_SHELL: stage '${sname}' references unknown zone '${zone}'`
|
|
390
|
+
);
|
|
391
|
+
ok = false;
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
/* zone must actually be declared in shell.zones — catches typos
|
|
395
|
+
* like stages.main.zone = "centre" that pass the ZONE_IDS test
|
|
396
|
+
* by accident. */
|
|
397
|
+
if(!Object.prototype.hasOwnProperty.call(zones_cfg, zone)) {
|
|
398
|
+
log_warning(
|
|
399
|
+
`C_YUI_SHELL: stage '${sname}' references zone '${zone}' ` +
|
|
400
|
+
`which is not declared in config.shell.zones — it will be ` +
|
|
401
|
+
`created with default attributes`
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if(config.menu !== undefined && !is_object(config.menu)) {
|
|
407
|
+
log_error("C_YUI_SHELL: config.menu must be an object");
|
|
408
|
+
ok = false;
|
|
409
|
+
}
|
|
410
|
+
if(config.toolbar !== undefined) {
|
|
411
|
+
/* toolbar = { zone?, aria_label?, items[] } — see SHELL.md §3.4. */
|
|
412
|
+
if(!is_object(config.toolbar)) {
|
|
413
|
+
log_error("C_YUI_SHELL: config.toolbar must be an object");
|
|
414
|
+
ok = false;
|
|
415
|
+
} else if(config.toolbar.items !== undefined &&
|
|
416
|
+
!is_array(config.toolbar.items)) {
|
|
417
|
+
log_error("C_YUI_SHELL: config.toolbar.items must be an array");
|
|
418
|
+
ok = false;
|
|
419
|
+
} else if(is_array(config.toolbar.items)) {
|
|
420
|
+
/* Per-item shape check: brand needs logo+wordmark, dropdown
|
|
421
|
+
* needs items[], action.type must be one of the known set,
|
|
422
|
+
* etc. Surfaced as warnings (additive contract: legacy
|
|
423
|
+
* configs without `type` keep working as type:"action"). */
|
|
424
|
+
for(let it of config.toolbar.items) {
|
|
425
|
+
let r = validate_toolbar_item(it);
|
|
426
|
+
if(!r.ok) {
|
|
427
|
+
for(let w of r.warnings) {
|
|
428
|
+
log_warning(`C_YUI_SHELL: ${w}`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/* Route uniqueness: a route declared in two different menus is a
|
|
436
|
+
* source of subtle bugs (build_item_index has a "first wins"
|
|
437
|
+
* rule, so the second declaration is silently shadowed). Warn
|
|
438
|
+
* loudly. */
|
|
439
|
+
if(is_object(config.menu)) {
|
|
440
|
+
let route_owner = {};
|
|
441
|
+
for(let menu_id in config.menu) {
|
|
442
|
+
let m = config.menu[menu_id];
|
|
443
|
+
if(!m || !is_array(m.items)) {
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
for(let it of m.items) {
|
|
447
|
+
check_route_unique(route_owner, it, menu_id);
|
|
448
|
+
if(it.submenu && is_array(it.submenu.items)) {
|
|
449
|
+
for(let sub of it.submenu.items) {
|
|
450
|
+
check_route_unique(route_owner, sub, menu_id);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
return ok;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/************************************************************
|
|
461
|
+
* Helper for validate_config — only warn when TWO different
|
|
462
|
+
* menus both declare a `target` for the same route. Items
|
|
463
|
+
* without a `target` are just navigators (they delegate to
|
|
464
|
+
* whichever menu owns the route) and do not compete, so the
|
|
465
|
+
* legitimate "menu A navigates / menu B owns" pattern stays
|
|
466
|
+
* silent.
|
|
467
|
+
************************************************************/
|
|
468
|
+
function check_route_unique(route_owner, item, menu_id)
|
|
469
|
+
{
|
|
470
|
+
if(!item || empty_string(item.route)) {
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
if(!item.target) {
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
let route = item.route;
|
|
477
|
+
if(route_owner[route] !== undefined && route_owner[route] !== menu_id) {
|
|
478
|
+
log_warning(
|
|
479
|
+
`C_YUI_SHELL: route '${route}' has a target in menu ` +
|
|
480
|
+
`'${menu_id}' AND in menu '${route_owner[route]}' — the ` +
|
|
481
|
+
`second target is shadowed (build_item_index keeps the ` +
|
|
482
|
+
`first entry that owns a target)`
|
|
483
|
+
);
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
route_owner[route] = menu_id;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/************************************************************
|
|
490
|
+
* Build the DOM: layers → base → zones
|
|
491
|
+
************************************************************/
|
|
492
|
+
function build_ui(gobj)
|
|
493
|
+
{
|
|
494
|
+
let priv = gobj_read_attr(gobj, "priv");
|
|
495
|
+
let config = gobj_read_attr(gobj, "config") || {};
|
|
496
|
+
let shell_cfg = config.shell || {};
|
|
497
|
+
|
|
498
|
+
/* Root */
|
|
499
|
+
let $container = createElement2(
|
|
500
|
+
["div", {class: "C_YUI_SHELL yui-shell"}]
|
|
501
|
+
);
|
|
502
|
+
|
|
503
|
+
/* Build layers */
|
|
504
|
+
for(let [id, z] of LAYER_DEFS) {
|
|
505
|
+
let $layer = createElement2(
|
|
506
|
+
["div", {class: `yui-layer yui-layer-${id}`, style: `z-index:${z};`}]
|
|
507
|
+
);
|
|
508
|
+
$container.appendChild($layer);
|
|
509
|
+
priv.layers[id] = $layer;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/* Base layer holds the grid of zones */
|
|
513
|
+
let $base = priv.layers.base;
|
|
514
|
+
$base.classList.add("yui-base-grid");
|
|
515
|
+
|
|
516
|
+
let zones_cfg = shell_cfg.zones || {};
|
|
517
|
+
|
|
518
|
+
for(let id of ZONE_IDS) {
|
|
519
|
+
let $z = createElement2(
|
|
520
|
+
["div", {class: `yui-zone yui-zone-${id}`,
|
|
521
|
+
"data-zone": id,
|
|
522
|
+
style: `grid-area: ${zone_grid_area(id)};`}]
|
|
523
|
+
);
|
|
524
|
+
apply_show_on($z, (zones_cfg[id] && zones_cfg[id].show_on) || "");
|
|
525
|
+
priv.zones[id] = $z;
|
|
526
|
+
$base.appendChild($z);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/* Register stages: zones hosting routed gobjs.
|
|
530
|
+
* Declared via zones[zone].host === "stage.<name>" or shell.stages.<name>.zone === <zone>.
|
|
531
|
+
*/
|
|
532
|
+
let stages_cfg = shell_cfg.stages || {};
|
|
533
|
+
for(let stage_name in stages_cfg) {
|
|
534
|
+
let zone = stages_cfg[stage_name].zone || "center";
|
|
535
|
+
priv.stages[stage_name] = {
|
|
536
|
+
el: priv.zones[zone],
|
|
537
|
+
items: {},
|
|
538
|
+
active_route: null
|
|
539
|
+
};
|
|
540
|
+
if(priv.zones[zone]) {
|
|
541
|
+
priv.zones[zone].classList.add("yui-stage", `yui-stage-${stage_name}`);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
/* Infer main stage from center zone host if not explicitly declared. */
|
|
545
|
+
for(let id in zones_cfg) {
|
|
546
|
+
let host = zones_cfg[id].host || "";
|
|
547
|
+
let m = /^stage\.(.+)$/.exec(host);
|
|
548
|
+
if(m) {
|
|
549
|
+
let name = m[1];
|
|
550
|
+
if(!priv.stages[name]) {
|
|
551
|
+
priv.stages[name] = { el: priv.zones[id], items: {}, active_route: null };
|
|
552
|
+
if(priv.zones[id]) {
|
|
553
|
+
priv.zones[id].classList.add("yui-stage", `yui-stage-${name}`);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
if(!priv.stages.main && priv.zones.center) {
|
|
559
|
+
priv.stages.main = { el: priv.zones.center, items: {}, active_route: null };
|
|
560
|
+
priv.zones.center.classList.add("yui-stage", "yui-stage-main");
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/* Mount */
|
|
564
|
+
let $mount = gobj_read_attr(gobj, "mount_element") || document.body;
|
|
565
|
+
$mount.appendChild($container);
|
|
566
|
+
|
|
567
|
+
gobj_write_attr(gobj, "$container", $container);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/************************************************************
|
|
571
|
+
* Translate zone id to grid-area name
|
|
572
|
+
************************************************************/
|
|
573
|
+
function zone_grid_area(zone_id)
|
|
574
|
+
{
|
|
575
|
+
switch(zone_id) {
|
|
576
|
+
case "top": return "top";
|
|
577
|
+
case "top-sub": return "topsub";
|
|
578
|
+
case "left": return "left";
|
|
579
|
+
case "center": return "center";
|
|
580
|
+
case "right": return "right";
|
|
581
|
+
case "bottom-sub": return "botsub";
|
|
582
|
+
case "bottom": return "bottom";
|
|
583
|
+
}
|
|
584
|
+
return "";
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/************************************************************
|
|
588
|
+
* Translate "show_on" expression to Bulma is-hidden-* classes.
|
|
589
|
+
* ">=desktop" → hide on touch
|
|
590
|
+
* "<desktop" → hide on desktop+
|
|
591
|
+
* ">=tablet" → hide on mobile
|
|
592
|
+
* "<tablet" → hide on tablet+
|
|
593
|
+
* "mobile|tablet" → list form (OR of breakpoints)
|
|
594
|
+
* "" → always visible
|
|
595
|
+
************************************************************/
|
|
596
|
+
function apply_show_on($el, expr)
|
|
597
|
+
{
|
|
598
|
+
if(empty_string(expr)) {
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
let visible = breakpoints_from_expr(expr);
|
|
602
|
+
for(let bp of BULMA_BP_ORDER) {
|
|
603
|
+
if(!visible[bp]) {
|
|
604
|
+
$el.classList.add(bulma_hidden_class(bp));
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
$el.setAttribute("data-show-on", expr);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/************************************************************
|
|
611
|
+
* Precompute: route → { item, parent_item, stage, target }
|
|
612
|
+
************************************************************/
|
|
613
|
+
function build_item_index(gobj, config)
|
|
614
|
+
{
|
|
615
|
+
let priv = gobj_read_attr(gobj, "priv");
|
|
616
|
+
priv.item_index = {};
|
|
617
|
+
|
|
618
|
+
let menus = (config.menu) || {};
|
|
619
|
+
for(let menu_id in menus) {
|
|
620
|
+
let menu = menus[menu_id];
|
|
621
|
+
let items = (menu && menu.items) || [];
|
|
622
|
+
for(let item of items) {
|
|
623
|
+
if(item.route) {
|
|
624
|
+
/* A later menu must NOT clobber an earlier entry that
|
|
625
|
+
* has a valid target with one that has none. This is
|
|
626
|
+
* the common case where a `quick` drawer just reuses
|
|
627
|
+
* routes declared (with target) in `primary.submenu`.
|
|
628
|
+
* Rule: prefer the first entry with a target. */
|
|
629
|
+
let prev = priv.item_index[item.route];
|
|
630
|
+
if(!prev || (!prev.target && item.target)) {
|
|
631
|
+
priv.item_index[item.route] = {
|
|
632
|
+
item: item,
|
|
633
|
+
parent_item: null,
|
|
634
|
+
stage: item.target && item.target.stage || null,
|
|
635
|
+
target: item.target || null,
|
|
636
|
+
menu_id: menu_id
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
let sub = item.submenu;
|
|
641
|
+
if(sub && is_array(sub.items)) {
|
|
642
|
+
for(let sub_item of sub.items) {
|
|
643
|
+
if(sub_item.route) {
|
|
644
|
+
let prev = priv.item_index[sub_item.route];
|
|
645
|
+
if(!prev || (!prev.target && sub_item.target)) {
|
|
646
|
+
priv.item_index[sub_item.route] = {
|
|
647
|
+
item: sub_item,
|
|
648
|
+
parent_item: item,
|
|
649
|
+
stage: sub_item.target && sub_item.target.stage || null,
|
|
650
|
+
target: sub_item.target || null,
|
|
651
|
+
menu_id: menu_id
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/* Explicit route table: authoritative source for routes that are
|
|
661
|
+
* not a left-menu item — action routes (kind:"action"), forms
|
|
662
|
+
* reachable only from the toolbar/dropdown, and the root "/".
|
|
663
|
+
* Toolbar/dropdown items just carry action:{type:"navigate",
|
|
664
|
+
* route}; their TARGET (view gclass, or kind:"action" event +
|
|
665
|
+
* redirect) lives here so the route resolves by URL on reload /
|
|
666
|
+
* deep-link. Same precedence as menus: only fill or upgrade an
|
|
667
|
+
* entry without a target — never clobber a menu's own target. */
|
|
668
|
+
let routes = (config.shell && config.shell.routes) || {};
|
|
669
|
+
for(let route in routes) {
|
|
670
|
+
let t = routes[route] || null;
|
|
671
|
+
let prev = priv.item_index[route];
|
|
672
|
+
if(!prev || (!prev.target && t)) {
|
|
673
|
+
priv.item_index[route] = {
|
|
674
|
+
item: (prev && prev.item) || null,
|
|
675
|
+
parent_item: (prev && prev.parent_item) || null,
|
|
676
|
+
stage: (t && t.stage) || null,
|
|
677
|
+
target: t,
|
|
678
|
+
menu_id: (prev && prev.menu_id) || ""
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/************************************************************
|
|
685
|
+
* For each menu declared: for each zone hosting it, create
|
|
686
|
+
* a C_YUI_NAV that renders it with that zone's style.
|
|
687
|
+
************************************************************/
|
|
688
|
+
function instantiate_menus(gobj, config)
|
|
689
|
+
{
|
|
690
|
+
let priv = gobj_read_attr(gobj, "priv");
|
|
691
|
+
let zones_cfg = (config.shell && config.shell.zones) || {};
|
|
692
|
+
let menus = config.menu || {};
|
|
693
|
+
|
|
694
|
+
/* Invert zones_cfg: which zones host "menu.<id>" */
|
|
695
|
+
let zones_for_menu = {};
|
|
696
|
+
for(let zone_id in zones_cfg) {
|
|
697
|
+
let host = zones_cfg[zone_id].host || "";
|
|
698
|
+
let m = /^menu\.(.+)$/.exec(host);
|
|
699
|
+
if(m) {
|
|
700
|
+
let menu_id = m[1];
|
|
701
|
+
(zones_for_menu[menu_id] = zones_for_menu[menu_id] || []).push(zone_id);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/* Drawer menus are overlays: they don't need a host in the zone grid.
|
|
706
|
+
* Any menu with render[zone].layout === "drawer" is instantiated too;
|
|
707
|
+
* instantiate_nav_in_zone() will mount it on the overlay layer. */
|
|
708
|
+
for(let menu_id in menus) {
|
|
709
|
+
let r = (menus[menu_id] && menus[menu_id].render) || {};
|
|
710
|
+
for(let zone_id in r) {
|
|
711
|
+
let cfg = r[zone_id];
|
|
712
|
+
let layout = is_string(cfg) ? cfg : (cfg && cfg.layout);
|
|
713
|
+
if(layout === "drawer") {
|
|
714
|
+
let list = (zones_for_menu[menu_id] = zones_for_menu[menu_id] || []);
|
|
715
|
+
if(list.indexOf(zone_id) < 0) {
|
|
716
|
+
list.push(zone_id);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
for(let menu_id in zones_for_menu) {
|
|
723
|
+
let menu = menus[menu_id];
|
|
724
|
+
if(!menu) {
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
for(let zone_id of zones_for_menu[menu_id]) {
|
|
728
|
+
instantiate_nav_in_zone(gobj, menu, menu_id, zone_id, "primary");
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/* Secondary navs: create one per primary-style menu item that
|
|
733
|
+
* declares a submenu with its own render block, for every zone
|
|
734
|
+
* listed in `submenu.render`. Initially hidden, shown when the
|
|
735
|
+
* owning primary item becomes active.
|
|
736
|
+
*
|
|
737
|
+
* We walk every menu mounted via a zone host of the form
|
|
738
|
+
* "menu.<id>" — not just menus.primary. The synthesized
|
|
739
|
+
* sub_menu_id is `secondary.<menu_id>.<item.id>` so two
|
|
740
|
+
* menus can have items with the same id without colliding. */
|
|
741
|
+
for(let menu_id in zones_for_menu) {
|
|
742
|
+
let menu = menus[menu_id];
|
|
743
|
+
if(!menu || !is_array(menu.items)) {
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
for(let item of menu.items) {
|
|
747
|
+
let sub = item.submenu;
|
|
748
|
+
if(!sub || !is_array(sub.items)) {
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
let render_by_zone = sub.render || {};
|
|
752
|
+
for(let zone_id in render_by_zone) {
|
|
753
|
+
if(zone_id === "*") {
|
|
754
|
+
continue;
|
|
755
|
+
}
|
|
756
|
+
let layout = render_by_zone[zone_id];
|
|
757
|
+
if(!priv.zones[zone_id]) {
|
|
758
|
+
log_warning(
|
|
759
|
+
`C_YUI_SHELL: submenu of '${menu_id}.${item.id}' ` +
|
|
760
|
+
`renders in unknown zone '${zone_id}'`
|
|
761
|
+
);
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
let submenu_def = {
|
|
765
|
+
items: sub.items,
|
|
766
|
+
render: { [zone_id]: render_to_obj(layout) }
|
|
767
|
+
};
|
|
768
|
+
let sub_menu_id = `secondary.${menu_id}.${item.id}`;
|
|
769
|
+
let nav = instantiate_nav_in_zone(
|
|
770
|
+
gobj, submenu_def, sub_menu_id, zone_id, "secondary",
|
|
771
|
+
item.name || ""
|
|
772
|
+
);
|
|
773
|
+
/* Hidden until owning primary is active. */
|
|
774
|
+
let $c = gobj_read_attr(nav, "$container");
|
|
775
|
+
if($c) {
|
|
776
|
+
$c.classList.add("is-hidden");
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/* Accept shorthand "tabs"|"vertical"|... as well as object form */
|
|
784
|
+
function render_to_obj(layout)
|
|
785
|
+
{
|
|
786
|
+
if(is_string(layout)) {
|
|
787
|
+
return { layout: layout };
|
|
788
|
+
}
|
|
789
|
+
if(is_object(layout)) {
|
|
790
|
+
return layout;
|
|
791
|
+
}
|
|
792
|
+
return { layout: "vertical" };
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function instantiate_nav_in_zone(gobj, menu, menu_id, zone_id, level, nav_label)
|
|
796
|
+
{
|
|
797
|
+
let priv = gobj_read_attr(gobj, "priv");
|
|
798
|
+
let render_cfg = (menu.render && (menu.render[zone_id] || menu.render["*"])) ||
|
|
799
|
+
{ layout: "vertical" };
|
|
800
|
+
if(is_string(render_cfg)) {
|
|
801
|
+
render_cfg = { layout: render_cfg };
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
let nav_name = `nav_${menu_id.replace(/\./g, "_")}_${zone_id}`;
|
|
805
|
+
let layout = render_cfg.layout || "vertical";
|
|
806
|
+
let nav = gobj_create(
|
|
807
|
+
nav_name,
|
|
808
|
+
"C_YUI_NAV",
|
|
809
|
+
{
|
|
810
|
+
menu_id: menu_id,
|
|
811
|
+
nav_label: nav_label || "",
|
|
812
|
+
menu_items: menu.items || [],
|
|
813
|
+
zone: zone_id,
|
|
814
|
+
layout: layout,
|
|
815
|
+
icon_pos: render_cfg.icon_pos || default_icon_pos(zone_id),
|
|
816
|
+
show_label: render_cfg.show_label !== false,
|
|
817
|
+
level: level,
|
|
818
|
+
shell: gobj
|
|
819
|
+
},
|
|
820
|
+
gobj
|
|
821
|
+
);
|
|
822
|
+
|
|
823
|
+
/* Drawers are position:fixed full-screen overlays: mount them on the
|
|
824
|
+
* overlay layer, not inside the zone grid cell (which may be display:
|
|
825
|
+
* none at some breakpoints and would hide the drawer). The zone still
|
|
826
|
+
* serves as a declarative anchor in the config. */
|
|
827
|
+
let $c = gobj_read_attr(nav, "$container");
|
|
828
|
+
if($c) {
|
|
829
|
+
if(layout === "drawer" && priv.layers.overlay) {
|
|
830
|
+
priv.layers.overlay.appendChild($c);
|
|
831
|
+
} else if(priv.zones[zone_id]) {
|
|
832
|
+
priv.zones[zone_id].appendChild($c);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/* The CHILD subscription model in C_YUI_NAV.mt_create already
|
|
837
|
+
* subscribes the parent (us) to EV_NAV_CLICKED, so no explicit
|
|
838
|
+
* call is needed here. */
|
|
839
|
+
|
|
840
|
+
gobj_start(nav);
|
|
841
|
+
priv.navs.push(nav);
|
|
842
|
+
return nav;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function default_icon_pos(zone_id)
|
|
846
|
+
{
|
|
847
|
+
if(zone_id === "bottom" || zone_id === "top") {
|
|
848
|
+
return "top";
|
|
849
|
+
}
|
|
850
|
+
if(zone_id === "left" || zone_id === "right") {
|
|
851
|
+
return "left";
|
|
852
|
+
}
|
|
853
|
+
if(zone_id === "top-sub" || zone_id === "bottom-sub") {
|
|
854
|
+
return "left";
|
|
855
|
+
}
|
|
856
|
+
return "left";
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/************************************************************
|
|
860
|
+
* Hash <-> route
|
|
861
|
+
************************************************************/
|
|
862
|
+
function hash_to_route(hash)
|
|
863
|
+
{
|
|
864
|
+
if(!hash) {
|
|
865
|
+
return "";
|
|
866
|
+
}
|
|
867
|
+
let s = String(hash).replace(/^#/, "");
|
|
868
|
+
if(s.charAt(0) !== "/") {
|
|
869
|
+
s = "/" + s;
|
|
870
|
+
}
|
|
871
|
+
return s;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function route_to_hash(route)
|
|
875
|
+
{
|
|
876
|
+
if(!route) {
|
|
877
|
+
return "";
|
|
878
|
+
}
|
|
879
|
+
let s = route.charAt(0) === "/" ? route : "/" + route;
|
|
880
|
+
return "#" + s;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/************************************************************
|
|
884
|
+
* Navigate: make `route` active
|
|
885
|
+
************************************************************/
|
|
886
|
+
function navigate_to(gobj, route)
|
|
887
|
+
{
|
|
888
|
+
let priv = gobj_read_attr(gobj, "priv");
|
|
889
|
+
|
|
890
|
+
/* Audit witness: publish the navigation intent FIRST, before any
|
|
891
|
+
* validation or DOM work. This guarantees that the FSM trace and
|
|
892
|
+
* any subscribed auditor see every requested route — including
|
|
893
|
+
* rerouted submenu defaults and routes that ultimately fail. */
|
|
894
|
+
gobj_publish_event(gobj, "EV_ROUTE_REQUESTED", {
|
|
895
|
+
route: route,
|
|
896
|
+
from: gobj_read_attr(gobj, "current_route") || ""
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
if(empty_string(route)) {
|
|
900
|
+
log_error("C_YUI_SHELL: navigate_to called with empty route");
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
let entry = priv.item_index[route];
|
|
904
|
+
|
|
905
|
+
/* Route level 1 only: if it has a submenu, navigate to its default subitem.
|
|
906
|
+
* Skip decorative items (`type:"header"`, `type:"divider"`) — they have
|
|
907
|
+
* no `route`, so the first *navigable* child is used as the fallback.
|
|
908
|
+
* Done on the EXACT entry, BEFORE the ancestor walk, so a submenu
|
|
909
|
+
* parent (e.g. `/system`) is never swallowed by the root `/`. */
|
|
910
|
+
if(entry && !entry.target && entry.item && entry.item.submenu) {
|
|
911
|
+
let sub = entry.item.submenu;
|
|
912
|
+
let first_routable = sub.items && sub.items.find(it => it && it.route);
|
|
913
|
+
let default_sub = sub.default || (first_routable && first_routable.route);
|
|
914
|
+
if(default_sub) {
|
|
915
|
+
return navigate_to(gobj, default_sub);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/* Pure resolution: exact target, or nearest declared ancestor
|
|
920
|
+
* (`/a/b/c` under declared `/a/b`) + the trailing `subpath`.
|
|
921
|
+
* See resolve_route() for the contract (root `/` only matches
|
|
922
|
+
* exactly, never as an ancestor catch-all). */
|
|
923
|
+
let r = resolve_route(priv.item_index, route);
|
|
924
|
+
entry = r.entry;
|
|
925
|
+
let matched_route = r.matched_route;
|
|
926
|
+
let subpath = r.subpath;
|
|
927
|
+
|
|
928
|
+
if(!entry || !entry.target) {
|
|
929
|
+
/* Unknown route → fall back to the default route (standard
|
|
930
|
+
* SPA behaviour). Resilient to stale/foreign hashes:
|
|
931
|
+
* bookmarks, old deep links, or a legacy component that
|
|
932
|
+
* wrote `#<gobj>?<sub>` into the URL before navigation was
|
|
933
|
+
* made self-contained. Only dead-end in a placeholder when
|
|
934
|
+
* the default is itself unresolvable (a real misconfig). */
|
|
935
|
+
let config = gobj_read_attr(gobj, "config") || {};
|
|
936
|
+
let def = gobj_read_attr(gobj, "default_route") ||
|
|
937
|
+
(config.shell && config.shell.stages &&
|
|
938
|
+
config.shell.stages.main &&
|
|
939
|
+
config.shell.stages.main.default_route) || "";
|
|
940
|
+
if(!empty_string(def) && def !== route) {
|
|
941
|
+
log_warning(
|
|
942
|
+
`C_YUI_SHELL: unknown route '${route}', ` +
|
|
943
|
+
`redirecting to default '${def}'`
|
|
944
|
+
);
|
|
945
|
+
return navigate_to(gobj, def);
|
|
946
|
+
}
|
|
947
|
+
log_error(`C_YUI_SHELL: no target for route '${route}'`);
|
|
948
|
+
show_stage_placeholder(
|
|
949
|
+
gobj, "main",
|
|
950
|
+
`C_YUI_SHELL: route '${route}' is not declared in any menu item`
|
|
951
|
+
);
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/* Action route (target.kind:"action") — fires an event. Most
|
|
956
|
+
* flavours are TRANSIENT (no view mounted, current_route not set
|
|
957
|
+
* to it).
|
|
958
|
+
* redirect:
|
|
959
|
+
* "<route>" → go there afterwards (e.g. logout → "/").
|
|
960
|
+
* "back" → return to the previous resting view route.
|
|
961
|
+
* "none"/"" → no navigation; just restore the URL to the
|
|
962
|
+
* previous resting route (the app takes over —
|
|
963
|
+
* e.g. logout tears the shell down itself).
|
|
964
|
+
* "stay" → OVERLAY action: fire the event (the app opens a
|
|
965
|
+
* modal/overlay) and KEEP the URL on this route so
|
|
966
|
+
* it is deep-linkable / bookmarkable. The URL is
|
|
967
|
+
* NOT restored; no view is mounted and
|
|
968
|
+
* current_route stays on the underlying resting
|
|
969
|
+
* view (the overlay floats above it). The app's
|
|
970
|
+
* overlay close path is responsible for
|
|
971
|
+
* history.back() so leaving it returns to that
|
|
972
|
+
* resting route. A direct deep-link / reload onto
|
|
973
|
+
* this route has no resting view yet: mount the
|
|
974
|
+
* default underneath first (that replaceState's the
|
|
975
|
+
* URL to the default), then re-push this hash so a
|
|
976
|
+
* later close → back lands on the default instead
|
|
977
|
+
* of exiting the app.
|
|
978
|
+
* EV_ROUTE_REQUESTED was already published above, so an auditor
|
|
979
|
+
* sees the action route intent too. */
|
|
980
|
+
if(entry.target.kind === "action") {
|
|
981
|
+
let t = entry.target;
|
|
982
|
+
if(!empty_string(t.event)) {
|
|
983
|
+
gobj_publish_event(gobj, t.event, t.kw || {});
|
|
984
|
+
}
|
|
985
|
+
let config = gobj_read_attr(gobj, "config") || {};
|
|
986
|
+
let prev = (priv.stages && priv.stages.main &&
|
|
987
|
+
priv.stages.main.active_route) ||
|
|
988
|
+
gobj_read_attr(gobj, "default_route") ||
|
|
989
|
+
(config.shell && config.shell.stages &&
|
|
990
|
+
config.shell.stages.main &&
|
|
991
|
+
config.shell.stages.main.default_route) || "/";
|
|
992
|
+
let redirect = t.redirect;
|
|
993
|
+
if(redirect === "stay") {
|
|
994
|
+
if(gobj_read_attr(gobj, "use_hash")) {
|
|
995
|
+
let has_resting = !!(priv.stages && priv.stages.main &&
|
|
996
|
+
priv.stages.main.active_route);
|
|
997
|
+
if(!has_resting && prev && prev !== route) {
|
|
998
|
+
/* Deep-link / reload straight onto the overlay
|
|
999
|
+
* route: bring up the default view underneath,
|
|
1000
|
+
* then push this hash back on top so close→back
|
|
1001
|
+
* returns to the default. */
|
|
1002
|
+
navigate_to(gobj, prev);
|
|
1003
|
+
let h = route_to_hash(route);
|
|
1004
|
+
try {
|
|
1005
|
+
window.history.pushState(null, "", h);
|
|
1006
|
+
} catch(e) {
|
|
1007
|
+
window.location.hash = h;
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
/* else: reached via a click that already pushed this
|
|
1011
|
+
* hash — leave the URL exactly as the user sees it. */
|
|
1012
|
+
}
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
if(empty_string(redirect) || redirect === "none") {
|
|
1016
|
+
if(gobj_read_attr(gobj, "use_hash")) {
|
|
1017
|
+
let h = route_to_hash(prev);
|
|
1018
|
+
try {
|
|
1019
|
+
window.history.replaceState(null, "", h);
|
|
1020
|
+
} catch(e) {
|
|
1021
|
+
window.location.hash = h;
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
if(redirect === "back") {
|
|
1027
|
+
redirect = prev;
|
|
1028
|
+
}
|
|
1029
|
+
if(redirect === route) {
|
|
1030
|
+
log_error(
|
|
1031
|
+
`C_YUI_SHELL: action route '${route}' redirects to itself`
|
|
1032
|
+
);
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
return navigate_to(gobj, redirect);
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/* A fresh navigation clears any placeholder shown earlier. */
|
|
1039
|
+
clear_stage_placeholder(gobj, entry.stage || "main");
|
|
1040
|
+
|
|
1041
|
+
let stage_name = entry.stage || "main";
|
|
1042
|
+
let stage = priv.stages[stage_name];
|
|
1043
|
+
if(!stage) {
|
|
1044
|
+
log_error(`C_YUI_SHELL: stage '${stage_name}' not declared`);
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/* View instances are keyed by the BASE (declared) route so a
|
|
1049
|
+
* subpath-only change reuses the same view (no rebuild). */
|
|
1050
|
+
let prev_route = stage.active_route;
|
|
1051
|
+
if(prev_route && prev_route !== matched_route) {
|
|
1052
|
+
let prev_gobj = stage.items[prev_route];
|
|
1053
|
+
if(prev_gobj) {
|
|
1054
|
+
let $c = gobj_read_attr(prev_gobj, "$container");
|
|
1055
|
+
if($c) {
|
|
1056
|
+
$c.classList.add("is-hidden");
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
/* lazy_destroy: drop previous on exit */
|
|
1060
|
+
let prev_entry = priv.item_index[prev_route];
|
|
1061
|
+
if(prev_entry && prev_entry.target && prev_entry.target.lifecycle === "lazy_destroy") {
|
|
1062
|
+
try {
|
|
1063
|
+
gobj_stop(prev_gobj);
|
|
1064
|
+
gobj_destroy(prev_gobj);
|
|
1065
|
+
} catch(e) {
|
|
1066
|
+
log_warning(`C_YUI_SHELL: lazy_destroy of '${prev_route}' failed: ${e}`);
|
|
1067
|
+
}
|
|
1068
|
+
delete stage.items[prev_route];
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/* Show or create current (keyed by base route) */
|
|
1073
|
+
let cur = stage.items[matched_route];
|
|
1074
|
+
if(!cur) {
|
|
1075
|
+
cur = build_view_gobj(gobj, entry, matched_route, stage);
|
|
1076
|
+
if(!cur) {
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
stage.items[matched_route] = cur;
|
|
1080
|
+
}
|
|
1081
|
+
let $c = gobj_read_attr(cur, "$container");
|
|
1082
|
+
if($c) {
|
|
1083
|
+
$c.classList.remove("is-hidden");
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
stage.active_route = matched_route;
|
|
1087
|
+
gobj_write_attr(gobj, "current_route", route);
|
|
1088
|
+
|
|
1089
|
+
/* Show/hide secondary navs according to parent item */
|
|
1090
|
+
update_secondary_nav_visibility(gobj, entry);
|
|
1091
|
+
|
|
1092
|
+
/* Any drawer that triggered (or merely sits open during) the navigation
|
|
1093
|
+
* is a transient overlay — closing it after the route change avoids it
|
|
1094
|
+
* sitting on top of the new view. */
|
|
1095
|
+
close_all_drawers(gobj);
|
|
1096
|
+
|
|
1097
|
+
/* Same logic for an open toolbar dropdown — once the navigation lands
|
|
1098
|
+
* on a new view, the panel is stale. No-op when nothing is open. */
|
|
1099
|
+
close_toolbar_dropdown(gobj);
|
|
1100
|
+
|
|
1101
|
+
/* Update hash silently */
|
|
1102
|
+
if(gobj_read_attr(gobj, "use_hash")) {
|
|
1103
|
+
let target_hash = route_to_hash(route);
|
|
1104
|
+
if(window.location.hash !== target_hash) {
|
|
1105
|
+
/* Using history.replaceState avoids extra hashchange fire. */
|
|
1106
|
+
try {
|
|
1107
|
+
window.history.replaceState(null, "", target_hash);
|
|
1108
|
+
} catch(e) {
|
|
1109
|
+
window.location.hash = target_hash;
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/* Broadcast. `menu_id` carries the owning primary menu so
|
|
1115
|
+
* primary navs can short-circuit when the route belongs to a
|
|
1116
|
+
* different menu — without it, two primary-style menus that
|
|
1117
|
+
* share an item id (legitimate per TODO #5) cross-highlight
|
|
1118
|
+
* each other. */
|
|
1119
|
+
gobj_publish_event(gobj, "EV_ROUTE_CHANGED", {
|
|
1120
|
+
route: route,
|
|
1121
|
+
base: matched_route,
|
|
1122
|
+
subpath: subpath,
|
|
1123
|
+
item: entry.item,
|
|
1124
|
+
parent_item: entry.parent_item,
|
|
1125
|
+
stage: stage_name,
|
|
1126
|
+
menu_id: entry.menu_id || ""
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
function build_view_gobj(gobj, entry, route, stage)
|
|
1131
|
+
{
|
|
1132
|
+
let target = entry.target;
|
|
1133
|
+
/* The deep-link tail is NOT injected into kw: gobj_create2
|
|
1134
|
+
* validates kw against the view's SDATA strictly, so an extra
|
|
1135
|
+
* key breaks every view that doesn't declare it. The shell
|
|
1136
|
+
* already broadcasts `subpath` in EV_ROUTE_CHANGED right after
|
|
1137
|
+
* mount (and on every later subpath-only change) — that single
|
|
1138
|
+
* mechanism is how a view owns its dynamic 3rd level. */
|
|
1139
|
+
let kw = target.kw || {};
|
|
1140
|
+
let name = target.name || `view_${safe_id(route)}`;
|
|
1141
|
+
|
|
1142
|
+
let view;
|
|
1143
|
+
try {
|
|
1144
|
+
view = gobj_create(name, target.gclass, kw, gobj);
|
|
1145
|
+
} catch(e) {
|
|
1146
|
+
log_error(`C_YUI_SHELL: gobj_create failed for ${target.gclass}: ${e}`);
|
|
1147
|
+
return null;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/* Hard contract: every view gclass MUST expose a $container
|
|
1151
|
+
* HTMLElement by the time mt_create returns. If not, the shell
|
|
1152
|
+
* cannot mount or hide it — abort cleanly. */
|
|
1153
|
+
let $view = gobj_read_attr(view, "$container");
|
|
1154
|
+
if(!$view) {
|
|
1155
|
+
log_error(
|
|
1156
|
+
`C_YUI_SHELL: gclass '${target.gclass}' does not expose $container; ` +
|
|
1157
|
+
`the view is unusable — check its mt_create`
|
|
1158
|
+
);
|
|
1159
|
+
try {
|
|
1160
|
+
gobj_destroy(view);
|
|
1161
|
+
} catch(e) {
|
|
1162
|
+
log_warning(`C_YUI_SHELL: cleanup gobj_destroy failed for ${target.gclass}: ${e}`);
|
|
1163
|
+
}
|
|
1164
|
+
return null;
|
|
1165
|
+
}
|
|
1166
|
+
stage.el.appendChild($view);
|
|
1167
|
+
gobj_start(view);
|
|
1168
|
+
return view;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
function safe_id(s)
|
|
1172
|
+
{
|
|
1173
|
+
return String(s).replace(/[^a-zA-Z0-9_]/g, "_").replace(/^_+|_+$/g, "");
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/************************************************************
|
|
1177
|
+
* Toolbar — a small declarative bar mounted in whichever zone
|
|
1178
|
+
* declares `host: "toolbar"`.
|
|
1179
|
+
*
|
|
1180
|
+
* Item kinds (`type`):
|
|
1181
|
+
* "brand" — logo (img) + wordmark (text); typically anchors a
|
|
1182
|
+
* navigate action to the home route.
|
|
1183
|
+
* "avatar" — circular initials populated by an app-registered
|
|
1184
|
+
* provider callback (yui_shell_set_avatar_provider).
|
|
1185
|
+
* "action" — default; icon and/or label that triggers an action.
|
|
1186
|
+
*
|
|
1187
|
+
* Action types (item.action.type):
|
|
1188
|
+
* navigate { type:"navigate", route }
|
|
1189
|
+
* drawer { type:"drawer", op:"toggle"|"open"|"close", menu_id? }
|
|
1190
|
+
* event { type:"event", event, kw? }
|
|
1191
|
+
* dropdown { type:"dropdown", items[] } (toolbar-only)
|
|
1192
|
+
*
|
|
1193
|
+
* Per-item `show_on` is honoured: each rendered item is wrapped with
|
|
1194
|
+
* the same Bulma-helper logic as zones.
|
|
1195
|
+
************************************************************/
|
|
1196
|
+
function build_toolbar(gobj, config)
|
|
1197
|
+
{
|
|
1198
|
+
let tb = config && config.toolbar;
|
|
1199
|
+
if(!tb || !is_array(tb.items)) {
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
let priv = gobj_read_attr(gobj, "priv");
|
|
1204
|
+
let zone_id = tb.zone || find_toolbar_zone(config);
|
|
1205
|
+
let $zone = priv.zones[zone_id];
|
|
1206
|
+
if(!$zone) {
|
|
1207
|
+
log_warning(`C_YUI_SHELL: toolbar target zone '${zone_id}' not found`);
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
/* Drop any avatar-node references from a previous build. Today
|
|
1212
|
+
* build_toolbar runs once per shell, but resetting here keeps
|
|
1213
|
+
* avatar_nodes in sync if a future rebuild path is added (mt_start
|
|
1214
|
+
* is the only caller now; a hot-reload helper would stale-leak
|
|
1215
|
+
* every span without this). */
|
|
1216
|
+
priv.avatar_nodes = [];
|
|
1217
|
+
priv.conn_nodes = [];
|
|
1218
|
+
|
|
1219
|
+
/* Toolbar labels follow the same i18n contract as nav labels:
|
|
1220
|
+
* every translatable text node carries `i18n: <canonical key>`,
|
|
1221
|
+
* which createElement2 maps to `data-i18n` on the rendered
|
|
1222
|
+
* element. Apps swap languages by calling
|
|
1223
|
+
* refresh_language(shell.$container, t) — no DOM rebuild here. */
|
|
1224
|
+
let toolbar_aria = tb.aria_label || "Toolbar";
|
|
1225
|
+
let $bar = createElement2(
|
|
1226
|
+
["nav", {class: "yui-toolbar navbar",
|
|
1227
|
+
role: "navigation",
|
|
1228
|
+
"aria-label": toolbar_aria,
|
|
1229
|
+
"data-i18n-aria-label": toolbar_aria},
|
|
1230
|
+
[
|
|
1231
|
+
["div", {class: "navbar-brand yui-toolbar-start"}],
|
|
1232
|
+
["div", {class: "navbar-end yui-toolbar-end"}]
|
|
1233
|
+
]
|
|
1234
|
+
]
|
|
1235
|
+
);
|
|
1236
|
+
let $start = $bar.querySelector(".yui-toolbar-start");
|
|
1237
|
+
let $end = $bar.querySelector(".yui-toolbar-end");
|
|
1238
|
+
|
|
1239
|
+
for(let it of tb.items) {
|
|
1240
|
+
let parent = (it.align === "end") ? $end : $start;
|
|
1241
|
+
let kind = classify_toolbar_item(it);
|
|
1242
|
+
|
|
1243
|
+
let $item;
|
|
1244
|
+
if(kind === "brand") {
|
|
1245
|
+
$item = build_toolbar_brand_item(gobj, it);
|
|
1246
|
+
} else if(kind === "avatar") {
|
|
1247
|
+
$item = build_toolbar_avatar_item(gobj, it);
|
|
1248
|
+
} else if(kind === "connection") {
|
|
1249
|
+
$item = build_toolbar_connection_item(gobj, it);
|
|
1250
|
+
} else {
|
|
1251
|
+
$item = build_toolbar_action_item(gobj, it);
|
|
1252
|
+
}
|
|
1253
|
+
if(!$item) {
|
|
1254
|
+
continue;
|
|
1255
|
+
}
|
|
1256
|
+
/* Per-item visibility: same syntax/parser as zones. */
|
|
1257
|
+
apply_show_on($item, it.show_on || "");
|
|
1258
|
+
parent.appendChild($item);
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
$zone.appendChild($bar);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
/************************************************************
|
|
1265
|
+
* Renderer for the default ("action") item kind.
|
|
1266
|
+
************************************************************/
|
|
1267
|
+
function build_toolbar_action_item(gobj, it)
|
|
1268
|
+
{
|
|
1269
|
+
let children = [];
|
|
1270
|
+
if(!empty_string(it.icon)) {
|
|
1271
|
+
children.push(["span", {class: "icon"},
|
|
1272
|
+
["i", {class: it.icon, "aria-hidden": "true"}]]);
|
|
1273
|
+
}
|
|
1274
|
+
if(!empty_string(it.name)) {
|
|
1275
|
+
children.push(["span", {class: "yui-toolbar-item-label", i18n: it.name},
|
|
1276
|
+
it.name]);
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
let aria_key = it.aria_label || it.name || it.id || "";
|
|
1280
|
+
let i18n_aria = it.aria_label || it.name;
|
|
1281
|
+
let btn_attrs = {
|
|
1282
|
+
class: "navbar-item yui-toolbar-item is-unselectable",
|
|
1283
|
+
type: "button",
|
|
1284
|
+
"data-toolbar-item-id": it.id || "",
|
|
1285
|
+
"aria-label": aria_key
|
|
1286
|
+
};
|
|
1287
|
+
if(i18n_aria) {
|
|
1288
|
+
btn_attrs["data-i18n-aria-label"] = i18n_aria;
|
|
1289
|
+
}
|
|
1290
|
+
let action_type = (it.action && it.action.type) || "";
|
|
1291
|
+
if(action_type === "dropdown") {
|
|
1292
|
+
btn_attrs["aria-haspopup"] = "menu";
|
|
1293
|
+
btn_attrs["aria-expanded"] = "false";
|
|
1294
|
+
}
|
|
1295
|
+
/* Hover tooltip: prefer explicit `tooltip`, fall back to
|
|
1296
|
+
* `aria_label` (usually the same intent — e.g. "Search (Ctrl+F)").
|
|
1297
|
+
* Skip when both empty so we don't emit `title=""` noise.
|
|
1298
|
+
* Mirror the value in `data-i18n-title` so refresh_language()
|
|
1299
|
+
* can re-translate the tooltip on language switch. */
|
|
1300
|
+
let tip = it.tooltip || it.aria_label;
|
|
1301
|
+
if(tip) {
|
|
1302
|
+
btn_attrs.title = tip;
|
|
1303
|
+
btn_attrs["data-i18n-title"] = tip;
|
|
1304
|
+
}
|
|
1305
|
+
let $item = createElement2(
|
|
1306
|
+
["button", btn_attrs, children]
|
|
1307
|
+
);
|
|
1308
|
+
$item.addEventListener("click", ev => {
|
|
1309
|
+
ev.preventDefault();
|
|
1310
|
+
handle_toolbar_action(gobj, it, $item);
|
|
1311
|
+
});
|
|
1312
|
+
attach_context_action(gobj, $item, it);
|
|
1313
|
+
return $item;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
/************************************************************
|
|
1317
|
+
* Renderer for type:"brand". Always logo (img) + wordmark
|
|
1318
|
+
* (text). Action defaults to navigate to the host's home
|
|
1319
|
+
* route; if missing, the brand is a passive label.
|
|
1320
|
+
************************************************************/
|
|
1321
|
+
function build_toolbar_brand_item(gobj, it)
|
|
1322
|
+
{
|
|
1323
|
+
if(empty_string(it.logo) || empty_string(it.wordmark)) {
|
|
1324
|
+
log_warning(
|
|
1325
|
+
`C_YUI_SHELL: toolbar brand item '${it.id||"?"}' missing ` +
|
|
1326
|
+
`logo and/or wordmark — skipped`
|
|
1327
|
+
);
|
|
1328
|
+
return null;
|
|
1329
|
+
}
|
|
1330
|
+
let alt = it.alt || it.wordmark || "";
|
|
1331
|
+
let aria_key = it.aria_label || it.wordmark || it.id || "";
|
|
1332
|
+
let i18n_aria = it.aria_label || it.wordmark;
|
|
1333
|
+
let attrs = {
|
|
1334
|
+
class: "navbar-item yui-toolbar-item yui-toolbar-brand is-unselectable",
|
|
1335
|
+
"data-toolbar-item-id": it.id || "",
|
|
1336
|
+
"aria-label": aria_key
|
|
1337
|
+
};
|
|
1338
|
+
if(i18n_aria) {
|
|
1339
|
+
attrs["data-i18n-aria-label"] = i18n_aria;
|
|
1340
|
+
}
|
|
1341
|
+
let action_type = (it.action && it.action.type) || "";
|
|
1342
|
+
let tag = "button";
|
|
1343
|
+
if(action_type === "dropdown") {
|
|
1344
|
+
attrs["aria-haspopup"] = "menu";
|
|
1345
|
+
attrs["aria-expanded"] = "false";
|
|
1346
|
+
}
|
|
1347
|
+
if(action_type === "") {
|
|
1348
|
+
/* Passive brand: no action — render a div so it is not
|
|
1349
|
+
* keyboard-focused and screen readers don't announce a
|
|
1350
|
+
* pressable control. */
|
|
1351
|
+
tag = "div";
|
|
1352
|
+
} else {
|
|
1353
|
+
attrs.type = "button";
|
|
1354
|
+
}
|
|
1355
|
+
let $item = createElement2([tag, attrs, [
|
|
1356
|
+
["img", {class: "yui-toolbar-brand-logo",
|
|
1357
|
+
src: it.logo, alt: alt}],
|
|
1358
|
+
["span", {class: "yui-toolbar-brand-wordmark", i18n: it.wordmark},
|
|
1359
|
+
it.wordmark]
|
|
1360
|
+
]]);
|
|
1361
|
+
if(action_type !== "") {
|
|
1362
|
+
$item.addEventListener("click", ev => {
|
|
1363
|
+
ev.preventDefault();
|
|
1364
|
+
handle_toolbar_action(gobj, it, $item);
|
|
1365
|
+
});
|
|
1366
|
+
}
|
|
1367
|
+
return $item;
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
/************************************************************
|
|
1371
|
+
* Renderer for type:"avatar". The <span> that holds the
|
|
1372
|
+
* initials is registered in priv.avatar_nodes so a single
|
|
1373
|
+
* refresh_avatars() call repaints every avatar after the host
|
|
1374
|
+
* swaps the provider.
|
|
1375
|
+
************************************************************/
|
|
1376
|
+
function build_toolbar_avatar_item(gobj, it)
|
|
1377
|
+
{
|
|
1378
|
+
let priv = gobj_read_attr(gobj, "priv");
|
|
1379
|
+
let aria_key = it.aria_label || it.name || it.id || "User menu";
|
|
1380
|
+
let i18n_aria = it.aria_label || it.name || "User menu";
|
|
1381
|
+
let action_type = (it.action && it.action.type) || "";
|
|
1382
|
+
let attrs = {
|
|
1383
|
+
class: "navbar-item yui-toolbar-item yui-toolbar-avatar is-unselectable",
|
|
1384
|
+
type: "button",
|
|
1385
|
+
"data-toolbar-item-id": it.id || "",
|
|
1386
|
+
"aria-label": aria_key,
|
|
1387
|
+
"data-i18n-aria-label": i18n_aria
|
|
1388
|
+
};
|
|
1389
|
+
if(action_type === "dropdown") {
|
|
1390
|
+
attrs["aria-haspopup"] = "menu";
|
|
1391
|
+
attrs["aria-expanded"] = "false";
|
|
1392
|
+
}
|
|
1393
|
+
let tip = it.tooltip || it.aria_label;
|
|
1394
|
+
if(tip) {
|
|
1395
|
+
attrs.title = tip;
|
|
1396
|
+
attrs["data-i18n-title"] = tip;
|
|
1397
|
+
}
|
|
1398
|
+
let $initials = createElement2(["span", {class: "yui-avatar"}, ""]);
|
|
1399
|
+
let $item = createElement2(["button", attrs, [$initials]]);
|
|
1400
|
+
if(action_type !== "") {
|
|
1401
|
+
$item.addEventListener("click", ev => {
|
|
1402
|
+
ev.preventDefault();
|
|
1403
|
+
handle_toolbar_action(gobj, it, $item);
|
|
1404
|
+
});
|
|
1405
|
+
}
|
|
1406
|
+
/* Register and paint once with whatever provider exists today
|
|
1407
|
+
* (host may register it later via yui_shell_set_avatar_provider). */
|
|
1408
|
+
priv.avatar_nodes.push($initials);
|
|
1409
|
+
paint_avatar(priv, $initials);
|
|
1410
|
+
return $item;
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
/* Read initials from the registered provider (if any) and write them
|
|
1414
|
+
* into a single avatar node. The provider is a host-supplied callback
|
|
1415
|
+
* () => string; gobj-ui never reaches into localStorage or app attrs. */
|
|
1416
|
+
function paint_avatar(priv, $node)
|
|
1417
|
+
{
|
|
1418
|
+
let provider = priv && priv.avatar_provider;
|
|
1419
|
+
let s = "";
|
|
1420
|
+
if(typeof provider === "function") {
|
|
1421
|
+
try {
|
|
1422
|
+
s = String(provider() || "");
|
|
1423
|
+
} catch(e) {
|
|
1424
|
+
log_warning(`C_YUI_SHELL: avatar provider threw: ${e}`);
|
|
1425
|
+
s = "";
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
$node.textContent = s;
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
function refresh_avatars(gobj)
|
|
1432
|
+
{
|
|
1433
|
+
let priv = gobj_read_attr(gobj, "priv");
|
|
1434
|
+
if(!priv || !is_array(priv.avatar_nodes)) {
|
|
1435
|
+
return;
|
|
1436
|
+
}
|
|
1437
|
+
for(let $n of priv.avatar_nodes) {
|
|
1438
|
+
paint_avatar(priv, $n);
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
/************************************************************
|
|
1443
|
+
* Wire an optional secondary (right-click) action on a
|
|
1444
|
+
* toolbar item. Same action shape as `action`; used e.g. by
|
|
1445
|
+
* the connection indicator to open a dev panel on right-click.
|
|
1446
|
+
************************************************************/
|
|
1447
|
+
function attach_context_action(gobj, $item, it)
|
|
1448
|
+
{
|
|
1449
|
+
if(!it || !it.context_action) {
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
$item.addEventListener("contextmenu", ev => {
|
|
1453
|
+
ev.preventDefault();
|
|
1454
|
+
handle_toolbar_action(
|
|
1455
|
+
gobj, {id: it.id, action: it.context_action}, $item
|
|
1456
|
+
);
|
|
1457
|
+
});
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
/************************************************************
|
|
1461
|
+
* Renderer for type:"connection" — a small status dot that
|
|
1462
|
+
* reflects the backend connection. The host drives the state
|
|
1463
|
+
* via yui_shell_set_connection_state(shell, bool); the dot
|
|
1464
|
+
* <span> is registered in priv.conn_nodes. Optional `action`
|
|
1465
|
+
* (left-click) and `context_action` (right-click) are honored.
|
|
1466
|
+
************************************************************/
|
|
1467
|
+
function build_toolbar_connection_item(gobj, it)
|
|
1468
|
+
{
|
|
1469
|
+
let priv = gobj_read_attr(gobj, "priv");
|
|
1470
|
+
let aria_key = it.aria_label || it.name || it.id || "backend connection";
|
|
1471
|
+
let i18n_aria = it.aria_label || it.name || "backend connection";
|
|
1472
|
+
let attrs = {
|
|
1473
|
+
class: "navbar-item yui-toolbar-item yui-toolbar-conn " +
|
|
1474
|
+
"is-unselectable is-disconnected",
|
|
1475
|
+
type: "button",
|
|
1476
|
+
"data-toolbar-item-id": it.id || "",
|
|
1477
|
+
"aria-label": aria_key,
|
|
1478
|
+
"data-i18n-aria-label": i18n_aria
|
|
1479
|
+
};
|
|
1480
|
+
let tip = it.tooltip || it.aria_label;
|
|
1481
|
+
if(tip) {
|
|
1482
|
+
attrs.title = tip;
|
|
1483
|
+
attrs["data-i18n-title"] = tip;
|
|
1484
|
+
}
|
|
1485
|
+
let $dot = createElement2(["span", {class: "yui-conn-dot"}, ""]);
|
|
1486
|
+
let $item = createElement2(["button", attrs, [$dot]]);
|
|
1487
|
+
let action_type = (it.action && it.action.type) || "";
|
|
1488
|
+
if(action_type !== "") {
|
|
1489
|
+
$item.addEventListener("click", ev => {
|
|
1490
|
+
ev.preventDefault();
|
|
1491
|
+
handle_toolbar_action(gobj, it, $item);
|
|
1492
|
+
});
|
|
1493
|
+
}
|
|
1494
|
+
attach_context_action(gobj, $item, it);
|
|
1495
|
+
priv.conn_nodes.push($item);
|
|
1496
|
+
return $item;
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
function find_toolbar_zone(config)
|
|
1500
|
+
{
|
|
1501
|
+
let zones = (config && config.shell && config.shell.zones) || {};
|
|
1502
|
+
for(let z in zones) {
|
|
1503
|
+
if(zones[z].host === "toolbar") {
|
|
1504
|
+
return z;
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
return "top";
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
function handle_toolbar_action(gobj, item, $trigger)
|
|
1511
|
+
{
|
|
1512
|
+
let action = (item && item.action) || {};
|
|
1513
|
+
switch(action.type) {
|
|
1514
|
+
case "navigate":
|
|
1515
|
+
if(!empty_string(action.route)) {
|
|
1516
|
+
if(gobj_read_attr(gobj, "use_hash")) {
|
|
1517
|
+
let h = route_to_hash(action.route);
|
|
1518
|
+
if(window.location.hash !== h) {
|
|
1519
|
+
window.location.hash = h; /* fires hashchange */
|
|
1520
|
+
} else {
|
|
1521
|
+
/* Same hash: hashchange won't fire — navigate
|
|
1522
|
+
* explicitly so a re-click still acts (e.g.
|
|
1523
|
+
* toggling a redirect:"stay" overlay whose URL
|
|
1524
|
+
* is already this route). Mirrors
|
|
1525
|
+
* ac_nav_clicked. */
|
|
1526
|
+
navigate_to(gobj, action.route);
|
|
1527
|
+
}
|
|
1528
|
+
} else {
|
|
1529
|
+
navigate_to(gobj, action.route);
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
break;
|
|
1533
|
+
case "drawer": {
|
|
1534
|
+
let op = action.op || "toggle";
|
|
1535
|
+
let menu_id = action.menu_id || null;
|
|
1536
|
+
if(op === "open") {
|
|
1537
|
+
open_drawer(gobj, menu_id);
|
|
1538
|
+
}
|
|
1539
|
+
else if(op === "close") {
|
|
1540
|
+
close_drawer(gobj, menu_id);
|
|
1541
|
+
}
|
|
1542
|
+
else {
|
|
1543
|
+
toggle_drawer(gobj, menu_id);
|
|
1544
|
+
}
|
|
1545
|
+
break;
|
|
1546
|
+
}
|
|
1547
|
+
case "event":
|
|
1548
|
+
/* Publish whatever event name the JSON requested. The shell
|
|
1549
|
+
* is created with gcflag_no_check_output_events so it acts as
|
|
1550
|
+
* an intermediate that forwards arbitrary user-defined events
|
|
1551
|
+
* without each app having to extend our event_types table. */
|
|
1552
|
+
if(!empty_string(action.event)) {
|
|
1553
|
+
gobj_publish_event(gobj, action.event, action.kw || {});
|
|
1554
|
+
}
|
|
1555
|
+
break;
|
|
1556
|
+
case "dropdown":
|
|
1557
|
+
toggle_toolbar_dropdown(gobj, item, action, $trigger);
|
|
1558
|
+
break;
|
|
1559
|
+
default:
|
|
1560
|
+
log_warning(
|
|
1561
|
+
`C_YUI_SHELL: toolbar item '${item.id||"?"}' has no/unknown action.type`
|
|
1562
|
+
);
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
/************************************************************
|
|
1567
|
+
* Toolbar dropdown — open/close a panel anchored to the
|
|
1568
|
+
* trigger button. One dropdown at a time: a second click on
|
|
1569
|
+
* any trigger first closes whatever was open, even if the
|
|
1570
|
+
* trigger differs.
|
|
1571
|
+
************************************************************/
|
|
1572
|
+
function toggle_toolbar_dropdown(gobj, item, action, $trigger)
|
|
1573
|
+
{
|
|
1574
|
+
let priv = gobj_read_attr(gobj, "priv");
|
|
1575
|
+
if(!priv) {
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1578
|
+
let already_open = priv.active_dropdown &&
|
|
1579
|
+
priv.active_dropdown.__yui_trigger__ === $trigger;
|
|
1580
|
+
close_toolbar_dropdown(gobj);
|
|
1581
|
+
if(already_open) {
|
|
1582
|
+
return;
|
|
1583
|
+
}
|
|
1584
|
+
open_toolbar_dropdown(gobj, item, action, $trigger);
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
function open_toolbar_dropdown(gobj, item, action, $trigger)
|
|
1588
|
+
{
|
|
1589
|
+
let priv = gobj_read_attr(gobj, "priv");
|
|
1590
|
+
if(!priv || !priv.layers || !priv.layers.popup) {
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
/* The panel mounts on priv.layers.popup, a sibling of the shell's
|
|
1595
|
+
* $container, and is (re)built lazily on every open — AFTER the
|
|
1596
|
+
* host's one-time refresh_language($container, t). So its i18n
|
|
1597
|
+
* text nodes (item names, aria) would never be translated: fine
|
|
1598
|
+
* in English where key == text, broken in any other locale.
|
|
1599
|
+
* The fix is below: when the host has registered a translator
|
|
1600
|
+
* (yui_shell_set_translator), re-apply it to the freshly built
|
|
1601
|
+
* $panel. Rebuilt per open ⇒ a later language switch is picked
|
|
1602
|
+
* up the next time the dropdown opens. */
|
|
1603
|
+
let aria_key = item && (item.aria_label || item.name || item.id) || "Menu";
|
|
1604
|
+
let i18n_aria = (item && (item.aria_label || item.name)) || "Menu";
|
|
1605
|
+
let $panel = createElement2(["div", {
|
|
1606
|
+
class: "yui-toolbar-dropdown-panel",
|
|
1607
|
+
role: "menu",
|
|
1608
|
+
"aria-label": aria_key,
|
|
1609
|
+
"data-i18n-aria-label": i18n_aria,
|
|
1610
|
+
"data-toolbar-dropdown-for": (item && item.id) || ""
|
|
1611
|
+
}]);
|
|
1612
|
+
|
|
1613
|
+
let raw_items = is_array(action.items) ? action.items : [];
|
|
1614
|
+
for(let i = 0; i < raw_items.length; i++) {
|
|
1615
|
+
let sub = raw_items[i];
|
|
1616
|
+
let $row = build_dropdown_row(gobj, sub, i);
|
|
1617
|
+
if(!$row) {
|
|
1618
|
+
continue;
|
|
1619
|
+
}
|
|
1620
|
+
apply_show_on($row, (sub && sub.show_on) || "");
|
|
1621
|
+
$panel.appendChild($row);
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
/* Position fixed-anchored to the trigger. Right-aligned when the
|
|
1625
|
+
* trigger sits in the navbar-end half of the bar (heuristic:
|
|
1626
|
+
* .navbar-end ancestor); otherwise left-aligned. This keeps the
|
|
1627
|
+
* panel inside the viewport for both burger-side and user-side
|
|
1628
|
+
* triggers without app-level CSS hacks. */
|
|
1629
|
+
let rect = $trigger.getBoundingClientRect();
|
|
1630
|
+
let style_parts = ["position:fixed",
|
|
1631
|
+
`top:${Math.round(rect.bottom)}px`];
|
|
1632
|
+
let in_end = !!$trigger.closest(".navbar-end");
|
|
1633
|
+
if(in_end) {
|
|
1634
|
+
let right = window.innerWidth - rect.right;
|
|
1635
|
+
style_parts.push(`right:${Math.max(0, Math.round(right))}px`);
|
|
1636
|
+
} else {
|
|
1637
|
+
style_parts.push(`left:${Math.max(0, Math.round(rect.left))}px`);
|
|
1638
|
+
}
|
|
1639
|
+
$panel.setAttribute("style", style_parts.join(";"));
|
|
1640
|
+
|
|
1641
|
+
priv.layers.popup.appendChild($panel);
|
|
1642
|
+
|
|
1643
|
+
/* Translate the lazily-built panel (see the note above). */
|
|
1644
|
+
if(typeof priv.translator === "function") {
|
|
1645
|
+
refresh_language($panel, priv.translator);
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
/* Click-outside (capture-phase mousedown) closes the dropdown.
|
|
1649
|
+
* Capture phase so a click on a sibling toolbar trigger lands
|
|
1650
|
+
* here BEFORE that trigger's own handler runs and reopens us. */
|
|
1651
|
+
let backdrop = ev => {
|
|
1652
|
+
if($panel.contains(ev.target)) {
|
|
1653
|
+
return;
|
|
1654
|
+
}
|
|
1655
|
+
if($trigger && $trigger.contains(ev.target)) {
|
|
1656
|
+
return; /* trigger click toggles via its own handler */
|
|
1657
|
+
}
|
|
1658
|
+
close_toolbar_dropdown(gobj);
|
|
1659
|
+
};
|
|
1660
|
+
document.addEventListener("mousedown", backdrop, true);
|
|
1661
|
+
|
|
1662
|
+
/* Scroll/resize: the panel anchor was frozen at open time from
|
|
1663
|
+
* getBoundingClientRect(); any layout shift drifts it from the
|
|
1664
|
+
* trigger. Match native <select> UX and dismiss on either.
|
|
1665
|
+
* Capture-phase + passive scroll so we hear all scrollers (any
|
|
1666
|
+
* ancestor, not just window) without blocking them. */
|
|
1667
|
+
let dismiss = () => close_toolbar_dropdown(gobj);
|
|
1668
|
+
document.addEventListener("scroll", dismiss, {capture: true, passive: true});
|
|
1669
|
+
window.addEventListener("resize", dismiss);
|
|
1670
|
+
|
|
1671
|
+
let close_fn = () => close_toolbar_dropdown(gobj);
|
|
1672
|
+
$panel.__yui_close_handler__ = close_fn;
|
|
1673
|
+
$panel.__yui_backdrop_handler__ = backdrop;
|
|
1674
|
+
$panel.__yui_dismiss_handler__ = dismiss;
|
|
1675
|
+
$panel.__yui_trigger__ = $trigger || null;
|
|
1676
|
+
push_escape(gobj, "popup", close_fn);
|
|
1677
|
+
|
|
1678
|
+
/* Focus trap inside the panel — same module modals/drawers use. */
|
|
1679
|
+
$panel.__yui_focus_release__ = activate_focus_trap_on($panel);
|
|
1680
|
+
|
|
1681
|
+
if($trigger) {
|
|
1682
|
+
$trigger.setAttribute("aria-expanded", "true");
|
|
1683
|
+
}
|
|
1684
|
+
priv.active_dropdown = $panel;
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
function close_toolbar_dropdown(gobj)
|
|
1688
|
+
{
|
|
1689
|
+
let priv = gobj_read_attr(gobj, "priv");
|
|
1690
|
+
if(!priv) {
|
|
1691
|
+
return;
|
|
1692
|
+
}
|
|
1693
|
+
let $panel = priv.active_dropdown;
|
|
1694
|
+
if(!$panel) {
|
|
1695
|
+
return;
|
|
1696
|
+
}
|
|
1697
|
+
if($panel.__yui_focus_release__) {
|
|
1698
|
+
$panel.__yui_focus_release__();
|
|
1699
|
+
$panel.__yui_focus_release__ = null;
|
|
1700
|
+
}
|
|
1701
|
+
if($panel.__yui_close_handler__) {
|
|
1702
|
+
pop_escape(gobj, $panel.__yui_close_handler__);
|
|
1703
|
+
$panel.__yui_close_handler__ = null;
|
|
1704
|
+
}
|
|
1705
|
+
if($panel.__yui_backdrop_handler__) {
|
|
1706
|
+
document.removeEventListener("mousedown",
|
|
1707
|
+
$panel.__yui_backdrop_handler__, true);
|
|
1708
|
+
$panel.__yui_backdrop_handler__ = null;
|
|
1709
|
+
}
|
|
1710
|
+
if($panel.__yui_dismiss_handler__) {
|
|
1711
|
+
document.removeEventListener("scroll",
|
|
1712
|
+
$panel.__yui_dismiss_handler__,
|
|
1713
|
+
{capture: true});
|
|
1714
|
+
window.removeEventListener("resize", $panel.__yui_dismiss_handler__);
|
|
1715
|
+
$panel.__yui_dismiss_handler__ = null;
|
|
1716
|
+
}
|
|
1717
|
+
if($panel.__yui_trigger__) {
|
|
1718
|
+
$panel.__yui_trigger__.setAttribute("aria-expanded", "false");
|
|
1719
|
+
$panel.__yui_trigger__ = null;
|
|
1720
|
+
}
|
|
1721
|
+
if($panel.parentNode) {
|
|
1722
|
+
$panel.parentNode.removeChild($panel);
|
|
1723
|
+
}
|
|
1724
|
+
priv.active_dropdown = null;
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
function build_dropdown_row(gobj, sub, idx)
|
|
1728
|
+
{
|
|
1729
|
+
if(!sub || typeof sub !== "object") {
|
|
1730
|
+
return null;
|
|
1731
|
+
}
|
|
1732
|
+
if(sub.type === "divider") {
|
|
1733
|
+
return createElement2(["div", {
|
|
1734
|
+
class: "yui-toolbar-dropdown-divider",
|
|
1735
|
+
role: "separator"
|
|
1736
|
+
}]);
|
|
1737
|
+
}
|
|
1738
|
+
if(!sub.action || typeof sub.action !== "object") {
|
|
1739
|
+
log_warning(
|
|
1740
|
+
`C_YUI_SHELL: dropdown item [${idx}] '${sub.id||"?"}' has no action — skipped`
|
|
1741
|
+
);
|
|
1742
|
+
return null;
|
|
1743
|
+
}
|
|
1744
|
+
let children = [];
|
|
1745
|
+
if(!empty_string(sub.icon)) {
|
|
1746
|
+
children.push(["span", {class: "icon"},
|
|
1747
|
+
["i", {class: sub.icon, "aria-hidden": "true"}]]);
|
|
1748
|
+
}
|
|
1749
|
+
if(!empty_string(sub.name)) {
|
|
1750
|
+
children.push(["span", {class: "yui-toolbar-dropdown-label",
|
|
1751
|
+
i18n: sub.name}, sub.name]);
|
|
1752
|
+
}
|
|
1753
|
+
let aria_key = sub.aria_label || sub.name || sub.id || "";
|
|
1754
|
+
let i18n_aria = sub.aria_label || sub.name;
|
|
1755
|
+
let attrs = {
|
|
1756
|
+
class: "yui-toolbar-dropdown-item",
|
|
1757
|
+
type: "button",
|
|
1758
|
+
role: "menuitem",
|
|
1759
|
+
"data-dropdown-item-id": sub.id || "",
|
|
1760
|
+
"aria-label": aria_key
|
|
1761
|
+
};
|
|
1762
|
+
if(i18n_aria) {
|
|
1763
|
+
attrs["data-i18n-aria-label"] = i18n_aria;
|
|
1764
|
+
}
|
|
1765
|
+
let $btn = createElement2(["button", attrs, children]);
|
|
1766
|
+
$btn.addEventListener("click", ev => {
|
|
1767
|
+
ev.preventDefault();
|
|
1768
|
+
/* Close BEFORE dispatching: navigate may rebuild stage,
|
|
1769
|
+
* event may open a modal — either way the dropdown should
|
|
1770
|
+
* not linger on top. Nested dropdowns are rejected by
|
|
1771
|
+
* validate_dropdown_action so we don't recurse here. */
|
|
1772
|
+
close_toolbar_dropdown(gobj);
|
|
1773
|
+
handle_toolbar_action(gobj, sub, $btn);
|
|
1774
|
+
});
|
|
1775
|
+
return $btn;
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
/************************************************************
|
|
1779
|
+
* Create all views whose item declares lifecycle:"eager".
|
|
1780
|
+
* They are mounted hidden; navigate_to() will reveal them.
|
|
1781
|
+
************************************************************/
|
|
1782
|
+
function preinstantiate_eager_views(gobj)
|
|
1783
|
+
{
|
|
1784
|
+
let priv = gobj_read_attr(gobj, "priv");
|
|
1785
|
+
for(let route in priv.item_index) {
|
|
1786
|
+
let entry = priv.item_index[route];
|
|
1787
|
+
let t = entry.target;
|
|
1788
|
+
if(!t || t.lifecycle !== "eager") {
|
|
1789
|
+
continue;
|
|
1790
|
+
}
|
|
1791
|
+
let stage_name = entry.stage || "main";
|
|
1792
|
+
let stage = priv.stages[stage_name];
|
|
1793
|
+
if(!stage) {
|
|
1794
|
+
log_warning(`C_YUI_SHELL: eager view ${route} has no stage '${stage_name}'`);
|
|
1795
|
+
continue;
|
|
1796
|
+
}
|
|
1797
|
+
if(stage.items[route]) {
|
|
1798
|
+
continue; /* already built */
|
|
1799
|
+
}
|
|
1800
|
+
let view = build_view_gobj(gobj, entry, route, stage);
|
|
1801
|
+
if(!view) {
|
|
1802
|
+
continue;
|
|
1803
|
+
}
|
|
1804
|
+
stage.items[route] = view;
|
|
1805
|
+
let $c = gobj_read_attr(view, "$container");
|
|
1806
|
+
if($c) {
|
|
1807
|
+
$c.classList.add("is-hidden");
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
/************************************************************
|
|
1813
|
+
* Drawer API — toggle/open/close a nav rendered with
|
|
1814
|
+
* layout:"drawer". menu_id is optional; if omitted, acts on
|
|
1815
|
+
* the first drawer found.
|
|
1816
|
+
************************************************************/
|
|
1817
|
+
function drawers(gobj, menu_id)
|
|
1818
|
+
{
|
|
1819
|
+
let priv = gobj_read_attr(gobj, "priv");
|
|
1820
|
+
let out = [];
|
|
1821
|
+
for(let nav of priv.navs) {
|
|
1822
|
+
if(gobj_read_attr(nav, "layout") !== "drawer") {
|
|
1823
|
+
continue;
|
|
1824
|
+
}
|
|
1825
|
+
if(menu_id && gobj_read_attr(nav, "menu_id") !== menu_id) {
|
|
1826
|
+
continue;
|
|
1827
|
+
}
|
|
1828
|
+
let $c = gobj_read_attr(nav, "$container");
|
|
1829
|
+
if($c) {
|
|
1830
|
+
out.push($c);
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
return out;
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
/************************************************************
|
|
1837
|
+
* Escape priority chain helpers — push/pop a {layer, handler}
|
|
1838
|
+
* record on `priv.escape_stack`. Escape calls the top entry
|
|
1839
|
+
* only and consumes the event; LIFO ordering naturally matches
|
|
1840
|
+
* the z-index layering most apps use (drawer at the bottom,
|
|
1841
|
+
* modal on top, popup on top of that).
|
|
1842
|
+
************************************************************/
|
|
1843
|
+
function push_escape(gobj, layer, handler)
|
|
1844
|
+
{
|
|
1845
|
+
let priv = gobj_read_attr(gobj, "priv");
|
|
1846
|
+
if(!priv || !priv.escape_stack) {
|
|
1847
|
+
return;
|
|
1848
|
+
}
|
|
1849
|
+
priv.escape_stack.push({ layer: layer, handler: handler });
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
function pop_escape(gobj, handler)
|
|
1853
|
+
{
|
|
1854
|
+
let priv = gobj_read_attr(gobj, "priv");
|
|
1855
|
+
if(!priv || !priv.escape_stack) {
|
|
1856
|
+
return;
|
|
1857
|
+
}
|
|
1858
|
+
let idx = priv.escape_stack.findIndex(e => e.handler === handler);
|
|
1859
|
+
if(idx >= 0) {
|
|
1860
|
+
priv.escape_stack.splice(idx, 1);
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
/* Per-drawer open/close. The escape-stack entry and the focus-
|
|
1865
|
+
* trap release function are parked on the $drawer DOM element so
|
|
1866
|
+
* any close path (Escape, backdrop click, toolbar action, public
|
|
1867
|
+
* yui_shell_close_drawer) tears them down through the same code.
|
|
1868
|
+
*
|
|
1869
|
+
* The actual focus-trap is the generic helper from
|
|
1870
|
+
* shell_focus_trap.js — same module modals/popups use. */
|
|
1871
|
+
function open_drawer_one(gobj, $c)
|
|
1872
|
+
{
|
|
1873
|
+
if($c.classList.contains("is-active")) {
|
|
1874
|
+
return;
|
|
1875
|
+
}
|
|
1876
|
+
let close_fn = () => close_drawer_one(gobj, $c);
|
|
1877
|
+
$c.__yui_close_handler__ = close_fn;
|
|
1878
|
+
push_escape(gobj, "overlay", close_fn);
|
|
1879
|
+
$c.classList.add("is-active");
|
|
1880
|
+
let panel = $c.querySelector(".yui-drawer-panel") || $c;
|
|
1881
|
+
$c.__yui_focus_release__ = activate_focus_trap_on(panel);
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
function close_drawer_one(gobj, $c)
|
|
1885
|
+
{
|
|
1886
|
+
if(!$c.classList.contains("is-active")) {
|
|
1887
|
+
return;
|
|
1888
|
+
}
|
|
1889
|
+
$c.classList.remove("is-active");
|
|
1890
|
+
if($c.__yui_focus_release__) {
|
|
1891
|
+
$c.__yui_focus_release__();
|
|
1892
|
+
$c.__yui_focus_release__ = null;
|
|
1893
|
+
}
|
|
1894
|
+
if($c.__yui_close_handler__) {
|
|
1895
|
+
pop_escape(gobj, $c.__yui_close_handler__);
|
|
1896
|
+
$c.__yui_close_handler__ = null;
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
function open_drawer(gobj, menu_id)
|
|
1901
|
+
{
|
|
1902
|
+
for(let $c of drawers(gobj, menu_id)) {
|
|
1903
|
+
open_drawer_one(gobj, $c);
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
function close_drawer(gobj, menu_id)
|
|
1908
|
+
{
|
|
1909
|
+
for(let $c of drawers(gobj, menu_id)) {
|
|
1910
|
+
close_drawer_one(gobj, $c);
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
function toggle_drawer(gobj, menu_id)
|
|
1915
|
+
{
|
|
1916
|
+
for(let $c of drawers(gobj, menu_id)) {
|
|
1917
|
+
if($c.classList.contains("is-active")) {
|
|
1918
|
+
close_drawer_one(gobj, $c);
|
|
1919
|
+
} else {
|
|
1920
|
+
open_drawer_one(gobj, $c);
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
function close_all_drawers(gobj)
|
|
1926
|
+
{
|
|
1927
|
+
let priv = gobj_read_attr(gobj, "priv");
|
|
1928
|
+
if(!priv) {
|
|
1929
|
+
return;
|
|
1930
|
+
}
|
|
1931
|
+
for(let nav of priv.navs) {
|
|
1932
|
+
if(gobj_read_attr(nav, "layout") !== "drawer") {
|
|
1933
|
+
continue;
|
|
1934
|
+
}
|
|
1935
|
+
let $c = gobj_read_attr(nav, "$container");
|
|
1936
|
+
if(!$c) {
|
|
1937
|
+
continue;
|
|
1938
|
+
}
|
|
1939
|
+
close_drawer_one(gobj, $c);
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
/************************************************************
|
|
1944
|
+
* Render a visible placeholder in a stage (used when we have
|
|
1945
|
+
* nothing to show, e.g. no default route configured).
|
|
1946
|
+
************************************************************/
|
|
1947
|
+
function show_stage_placeholder(gobj, stage_name, message)
|
|
1948
|
+
{
|
|
1949
|
+
let priv = gobj_read_attr(gobj, "priv");
|
|
1950
|
+
let stage = priv.stages[stage_name];
|
|
1951
|
+
if(!stage || !stage.el) {
|
|
1952
|
+
return;
|
|
1953
|
+
}
|
|
1954
|
+
clear_stage_placeholder(gobj, stage_name);
|
|
1955
|
+
let $msg = createElement2(
|
|
1956
|
+
["div", {class: "yui-shell-placeholder notification is-warning is-light m-4"},
|
|
1957
|
+
["p", {class: "is-size-6"}, message]
|
|
1958
|
+
]
|
|
1959
|
+
);
|
|
1960
|
+
stage.el.appendChild($msg);
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
function clear_stage_placeholder(gobj, stage_name)
|
|
1964
|
+
{
|
|
1965
|
+
let priv = gobj_read_attr(gobj, "priv");
|
|
1966
|
+
let stage = priv.stages[stage_name];
|
|
1967
|
+
if(!stage || !stage.el) {
|
|
1968
|
+
return;
|
|
1969
|
+
}
|
|
1970
|
+
let $old = stage.el.querySelector(":scope > .yui-shell-placeholder");
|
|
1971
|
+
if($old) {
|
|
1972
|
+
$old.parentNode.removeChild($old);
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
function update_secondary_nav_visibility(gobj, entry)
|
|
1977
|
+
{
|
|
1978
|
+
let priv = gobj_read_attr(gobj, "priv");
|
|
1979
|
+
/* shell.routes entries (root, forms, action routes) have no menu
|
|
1980
|
+
* item — entry.item / entry.parent_item are null. No active
|
|
1981
|
+
* primary then: the secondary zone collapses (has_secondary
|
|
1982
|
+
* below is false), which is correct for a standalone route. */
|
|
1983
|
+
let active_primary_id = (entry.parent_item && entry.parent_item.id)
|
|
1984
|
+
|| (entry.item && entry.item.id)
|
|
1985
|
+
|| "";
|
|
1986
|
+
let owning_menu_id = entry.menu_id || "";
|
|
1987
|
+
let target_secondary_id = `secondary.${owning_menu_id}.${active_primary_id}`;
|
|
1988
|
+
|
|
1989
|
+
/* Does the ACTIVE primary actually have a submenu? Decided from
|
|
1990
|
+
* the config (the item tree), NOT from whether a secondary nav
|
|
1991
|
+
* gobj has been instantiated — those are created lazily, so on a
|
|
1992
|
+
* route with no submenu (e.g. Monitor) there may be zero
|
|
1993
|
+
* secondary navs and a nav-derived check would never collapse
|
|
1994
|
+
* the zone (empty white strip under the toolbar). */
|
|
1995
|
+
let active_primary = entry.parent_item || entry.item || null;
|
|
1996
|
+
let has_secondary = !!(
|
|
1997
|
+
active_primary &&
|
|
1998
|
+
active_primary.submenu &&
|
|
1999
|
+
Array.isArray(active_primary.submenu.items) &&
|
|
2000
|
+
active_primary.submenu.items.some(it => it && it.route)
|
|
2001
|
+
);
|
|
2002
|
+
|
|
2003
|
+
/* Collapse every zone that hosts menu.secondary when the active
|
|
2004
|
+
* route has no submenu; reveal them otherwise. Zone set comes
|
|
2005
|
+
* from the declared config, so it works before any secondary
|
|
2006
|
+
* nav exists. */
|
|
2007
|
+
let config = gobj_read_attr(gobj, "config") || {};
|
|
2008
|
+
let zones_cfg = (config.shell && config.shell.zones) || {};
|
|
2009
|
+
for(let z in zones_cfg) {
|
|
2010
|
+
if(zones_cfg[z] && zones_cfg[z].host === "menu.secondary") {
|
|
2011
|
+
let $z = priv.zones[z];
|
|
2012
|
+
if($z) {
|
|
2013
|
+
$z.classList.toggle("is-hidden", !has_secondary);
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
for(let nav of priv.navs) {
|
|
2019
|
+
let level = gobj_read_attr(nav, "level");
|
|
2020
|
+
if(level !== "secondary") {
|
|
2021
|
+
continue;
|
|
2022
|
+
}
|
|
2023
|
+
let nav_menu_id = gobj_read_attr(nav, "menu_id") || "";
|
|
2024
|
+
if(!nav_menu_id.startsWith("secondary.")) {
|
|
2025
|
+
continue;
|
|
2026
|
+
}
|
|
2027
|
+
let $c = gobj_read_attr(nav, "$container");
|
|
2028
|
+
if(!$c) {
|
|
2029
|
+
continue;
|
|
2030
|
+
}
|
|
2031
|
+
if(nav_menu_id === target_secondary_id) {
|
|
2032
|
+
$c.classList.remove("is-hidden");
|
|
2033
|
+
} else {
|
|
2034
|
+
$c.classList.add("is-hidden");
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
|
|
2040
|
+
|
|
2041
|
+
|
|
2042
|
+
/***************************
|
|
2043
|
+
* Actions
|
|
2044
|
+
***************************/
|
|
2045
|
+
|
|
2046
|
+
|
|
2047
|
+
|
|
2048
|
+
|
|
2049
|
+
/************************************************************
|
|
2050
|
+
* Click-through from a C_YUI_NAV child: we own routing here.
|
|
2051
|
+
************************************************************/
|
|
2052
|
+
function ac_nav_clicked(gobj, event, kw, src)
|
|
2053
|
+
{
|
|
2054
|
+
let route = (kw && kw.route) || "";
|
|
2055
|
+
if(empty_string(route)) {
|
|
2056
|
+
return 0;
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
/* When hash routing is on, let the hash drive navigate_to() — that
|
|
2060
|
+
* way back/forward buttons and programmatic hash changes all flow
|
|
2061
|
+
* through the same code path. Otherwise call navigate_to directly. */
|
|
2062
|
+
if(gobj_read_attr(gobj, "use_hash")) {
|
|
2063
|
+
let target_hash = route_to_hash(route);
|
|
2064
|
+
if(window.location.hash !== target_hash) {
|
|
2065
|
+
window.location.hash = target_hash; /* fires hashchange */
|
|
2066
|
+
} else {
|
|
2067
|
+
/* Same hash: hashchange won't fire — navigate explicitly. */
|
|
2068
|
+
navigate_to(gobj, route);
|
|
2069
|
+
}
|
|
2070
|
+
} else {
|
|
2071
|
+
navigate_to(gobj, route);
|
|
2072
|
+
}
|
|
2073
|
+
return 0;
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
/************************************************************
|
|
2077
|
+
* Drawer backdrop click on a C_YUI_NAV child: the nav publishes
|
|
2078
|
+
* this so the shell can close the drawer through the canonical
|
|
2079
|
+
* flow (DOM + focus-trap + escape-stack pop) instead of mutating
|
|
2080
|
+
* the DOM directly from the nav.
|
|
2081
|
+
************************************************************/
|
|
2082
|
+
function ac_drawer_close_requested(gobj, event, kw, src)
|
|
2083
|
+
{
|
|
2084
|
+
let menu_id = (kw && kw.menu_id) || "";
|
|
2085
|
+
close_drawer(gobj, menu_id);
|
|
2086
|
+
return 0;
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
/************************************************************
|
|
2090
|
+
* Close 'x' on a closable tab (from a C_YUI_NAV child). The
|
|
2091
|
+
* shell does not own the item set — re-publish so the app (which
|
|
2092
|
+
* owns the underlying data, e.g. the selected-nodes list) removes
|
|
2093
|
+
* the item and calls yui_shell_set_submenu() with the new list.
|
|
2094
|
+
************************************************************/
|
|
2095
|
+
function ac_nav_item_close(gobj, event, kw, src)
|
|
2096
|
+
{
|
|
2097
|
+
gobj_publish_event(gobj, "EV_NAV_ITEM_CLOSE", {
|
|
2098
|
+
item_id: (kw && kw.item_id) || "",
|
|
2099
|
+
route: (kw && kw.route) || "",
|
|
2100
|
+
menu_id: (kw && kw.menu_id) || "",
|
|
2101
|
+
zone: (kw && kw.zone) || ""
|
|
2102
|
+
});
|
|
2103
|
+
return 0;
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
/************************************************************
|
|
2107
|
+
* Runtime nav API (Yuneta philosophy: the app_config path is the
|
|
2108
|
+
* first, startup caller of the very same machinery — build_item_index
|
|
2109
|
+
* + instantiate + set items; this is its dynamic counterpart).
|
|
2110
|
+
*
|
|
2111
|
+
* yui_shell_set_submenu(shell, parent_item_id, items) replaces the
|
|
2112
|
+
* items of a primary item's submenu (its secondary nav) at runtime
|
|
2113
|
+
* and re-registers their routes so navigation resolves. Item
|
|
2114
|
+
* descriptors may carry { id, name, icon, route, class, closable,
|
|
2115
|
+
* target:{stage,gclass,kw,lifecycle} }. Routes present before but
|
|
2116
|
+
* absent now are pruned (index entry removed, mounted view destroyed).
|
|
2117
|
+
************************************************************/
|
|
2118
|
+
function find_secondary_nav(priv, parent_item_id)
|
|
2119
|
+
{
|
|
2120
|
+
let suffix = "." + parent_item_id;
|
|
2121
|
+
for(let nav of priv.navs) {
|
|
2122
|
+
let menu_id = gobj_read_attr(nav, "menu_id") || "";
|
|
2123
|
+
if(menu_id.startsWith("secondary.") && menu_id.endsWith(suffix)) {
|
|
2124
|
+
return nav;
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
return null;
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
function find_primary_item(shell_gobj, menu_id, item_id)
|
|
2131
|
+
{
|
|
2132
|
+
let config = gobj_read_attr(shell_gobj, "config") || {};
|
|
2133
|
+
let menu = (config.menu && config.menu[menu_id]) || null;
|
|
2134
|
+
if(menu && is_array(menu.items)) {
|
|
2135
|
+
for(let it of menu.items) {
|
|
2136
|
+
if(it && it.id === item_id) {
|
|
2137
|
+
return it;
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
return null;
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
/* Drop a route from the index and destroy any mounted view for it. */
|
|
2145
|
+
function prune_route(shell_gobj, route)
|
|
2146
|
+
{
|
|
2147
|
+
let priv = gobj_read_attr(shell_gobj, "priv");
|
|
2148
|
+
delete priv.item_index[route];
|
|
2149
|
+
for(let stage_name in priv.stages) {
|
|
2150
|
+
let stage = priv.stages[stage_name];
|
|
2151
|
+
let view = stage.items && stage.items[route];
|
|
2152
|
+
if(!view) {
|
|
2153
|
+
continue;
|
|
2154
|
+
}
|
|
2155
|
+
let $c = gobj_read_attr(view, "$container");
|
|
2156
|
+
if($c) {
|
|
2157
|
+
$c.classList.add("is-hidden");
|
|
2158
|
+
}
|
|
2159
|
+
try {
|
|
2160
|
+
gobj_stop(view);
|
|
2161
|
+
gobj_destroy(view);
|
|
2162
|
+
} catch(e) {
|
|
2163
|
+
log_warning(`C_YUI_SHELL: prune_route destroy '${route}' failed: ${e}`);
|
|
2164
|
+
}
|
|
2165
|
+
delete stage.items[route];
|
|
2166
|
+
if(stage.active_route === route) {
|
|
2167
|
+
stage.active_route = "";
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
function yui_shell_set_submenu(shell_gobj, parent_item_id, items)
|
|
2173
|
+
{
|
|
2174
|
+
let priv = gobj_read_attr(shell_gobj, "priv");
|
|
2175
|
+
if(!priv) {
|
|
2176
|
+
return -1;
|
|
2177
|
+
}
|
|
2178
|
+
let nav = find_secondary_nav(priv, parent_item_id);
|
|
2179
|
+
if(!nav) {
|
|
2180
|
+
log_warning(
|
|
2181
|
+
`C_YUI_SHELL: yui_shell_set_submenu — no secondary nav for '${parent_item_id}'`
|
|
2182
|
+
);
|
|
2183
|
+
return -1;
|
|
2184
|
+
}
|
|
2185
|
+
items = is_array(items) ? items : [];
|
|
2186
|
+
|
|
2187
|
+
/* Resolve owning menu_id + primary item from the nav's synthesized
|
|
2188
|
+
* id "secondary.<menu_id>.<parent_item_id>", so registered routes
|
|
2189
|
+
* highlight correctly and keep this secondary nav visible. */
|
|
2190
|
+
let nav_menu_id = gobj_read_attr(nav, "menu_id") || "";
|
|
2191
|
+
let m = /^secondary\.(.+)\.([^.]+)$/.exec(nav_menu_id);
|
|
2192
|
+
let owning_menu_id = m ? m[1] : "";
|
|
2193
|
+
let parent_item = find_primary_item(shell_gobj, owning_menu_id, parent_item_id);
|
|
2194
|
+
|
|
2195
|
+
/* Track the routes THIS submenu owns dynamically, so we only prune
|
|
2196
|
+
* our own previous routes — never the static config routes (e.g. a
|
|
2197
|
+
* base "/console/agent" landing declared in app_config). */
|
|
2198
|
+
priv.dynamic_routes = priv.dynamic_routes || {};
|
|
2199
|
+
let prev_routes = priv.dynamic_routes[parent_item_id] || [];
|
|
2200
|
+
let new_routes = [];
|
|
2201
|
+
let new_set = {};
|
|
2202
|
+
for(let it of items) {
|
|
2203
|
+
if(it && it.route) {
|
|
2204
|
+
new_routes.push(it.route);
|
|
2205
|
+
new_set[it.route] = true;
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
for(let route of prev_routes) {
|
|
2209
|
+
if(!new_set[route]) {
|
|
2210
|
+
prune_route(shell_gobj, route);
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
/* Register / refresh routes for the new items. */
|
|
2215
|
+
for(let it of items) {
|
|
2216
|
+
if(!it || !it.route) {
|
|
2217
|
+
continue;
|
|
2218
|
+
}
|
|
2219
|
+
priv.item_index[it.route] = {
|
|
2220
|
+
item: it,
|
|
2221
|
+
parent_item: parent_item,
|
|
2222
|
+
stage: (it.target && it.target.stage) || null,
|
|
2223
|
+
target: it.target || null,
|
|
2224
|
+
menu_id: owning_menu_id
|
|
2225
|
+
};
|
|
2226
|
+
}
|
|
2227
|
+
priv.dynamic_routes[parent_item_id] = new_routes;
|
|
2228
|
+
|
|
2229
|
+
/* Push the new items into the nav (rebuilds its DOM in place). */
|
|
2230
|
+
gobj_send_event(nav, "EV_SET_ITEMS", {items: items}, shell_gobj);
|
|
2231
|
+
return 0;
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
/***************************************************************
|
|
2235
|
+
* FSM
|
|
2236
|
+
***************************************************************/
|
|
2237
|
+
/*---------------------------------------------*
|
|
2238
|
+
* Global methods table
|
|
2239
|
+
*---------------------------------------------*/
|
|
2240
|
+
const gmt = {
|
|
2241
|
+
mt_create: mt_create,
|
|
2242
|
+
mt_start: mt_start,
|
|
2243
|
+
mt_stop: mt_stop,
|
|
2244
|
+
mt_destroy: mt_destroy
|
|
2245
|
+
};
|
|
2246
|
+
|
|
2247
|
+
function create_gclass(gclass_name)
|
|
2248
|
+
{
|
|
2249
|
+
if(__gclass__) {
|
|
2250
|
+
log_error(`GClass ALREADY created: ${gclass_name}`);
|
|
2251
|
+
return -1;
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
const states = [
|
|
2255
|
+
["ST_IDLE", [
|
|
2256
|
+
/* Navigation requests flow through EV_NAV_CLICKED (emitted
|
|
2257
|
+
* by child C_YUI_NAVs) and through the window.hashchange
|
|
2258
|
+
* listener (see mt_start). The shell is the sole router. */
|
|
2259
|
+
["EV_NAV_CLICKED", ac_nav_clicked, null],
|
|
2260
|
+
/* Drawer backdrop close (from a C_YUI_NAV child whose
|
|
2261
|
+
* layout is "drawer"). */
|
|
2262
|
+
["EV_DRAWER_CLOSE_REQUESTED", ac_drawer_close_requested, null],
|
|
2263
|
+
/* Close 'x' on a closable tab (from a child C_YUI_NAV);
|
|
2264
|
+
* re-published for the app. */
|
|
2265
|
+
["EV_NAV_ITEM_CLOSE", ac_nav_item_close, null]
|
|
2266
|
+
]]
|
|
2267
|
+
];
|
|
2268
|
+
|
|
2269
|
+
const event_types = [
|
|
2270
|
+
["EV_NAV_CLICKED", 0],
|
|
2271
|
+
["EV_DRAWER_CLOSE_REQUESTED", 0],
|
|
2272
|
+
["EV_NAV_ITEM_CLOSE", event_flag_t.EVF_OUTPUT_EVENT
|
|
2273
|
+
|event_flag_t.EVF_PUBLIC_EVENT
|
|
2274
|
+
|event_flag_t.EVF_NO_WARN_SUBS],
|
|
2275
|
+
/* Audit witness: every navigation attempt publishes this BEFORE
|
|
2276
|
+
* any work is done, so the FSM trace records intent regardless
|
|
2277
|
+
* of whether the route resolves successfully. Pairs with
|
|
2278
|
+
* EV_ROUTE_CHANGED (the corresponding fact event). */
|
|
2279
|
+
["EV_ROUTE_REQUESTED", event_flag_t.EVF_OUTPUT_EVENT
|
|
2280
|
+
|event_flag_t.EVF_PUBLIC_EVENT
|
|
2281
|
+
|event_flag_t.EVF_NO_WARN_SUBS],
|
|
2282
|
+
["EV_ROUTE_CHANGED", event_flag_t.EVF_OUTPUT_EVENT
|
|
2283
|
+
|event_flag_t.EVF_PUBLIC_EVENT
|
|
2284
|
+
|event_flag_t.EVF_NO_WARN_SUBS]
|
|
2285
|
+
];
|
|
2286
|
+
|
|
2287
|
+
__gclass__ = gclass_create(
|
|
2288
|
+
gclass_name,
|
|
2289
|
+
event_types,
|
|
2290
|
+
states,
|
|
2291
|
+
gmt,
|
|
2292
|
+
0, /* lmt */
|
|
2293
|
+
attrs_table,
|
|
2294
|
+
PRIVATE_DATA,
|
|
2295
|
+
0, /* authz_table */
|
|
2296
|
+
0, /* command_table */
|
|
2297
|
+
0, /* s_user_trace_level */
|
|
2298
|
+
gclass_flag_t.gcflag_no_check_output_events
|
|
2299
|
+
);
|
|
2300
|
+
if(!__gclass__) {
|
|
2301
|
+
return -1;
|
|
2302
|
+
}
|
|
2303
|
+
return 0;
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
function register_c_yui_shell()
|
|
2307
|
+
{
|
|
2308
|
+
return create_gclass(GCLASS_NAME);
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
/***************************************************************
|
|
2312
|
+
* Public helpers — exported alongside register_c_yui_shell().
|
|
2313
|
+
* Not gclass methods, no banner needed; left grouped at the
|
|
2314
|
+
* bottom of the file to keep the skeleton layout intact.
|
|
2315
|
+
***************************************************************/
|
|
2316
|
+
|
|
2317
|
+
/************************************************************
|
|
2318
|
+
* Programmatic navigation (bypass hash).
|
|
2319
|
+
************************************************************/
|
|
2320
|
+
function yui_shell_navigate(shell_gobj, route)
|
|
2321
|
+
{
|
|
2322
|
+
navigate_to(shell_gobj, route);
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
/* Drawer helpers — toggle the off-canvas nav from the outside
|
|
2326
|
+
* (e.g. a hamburger button in the toolbar). menu_id is optional. */
|
|
2327
|
+
function yui_shell_open_drawer(shell_gobj, menu_id) { open_drawer(shell_gobj, menu_id); }
|
|
2328
|
+
function yui_shell_close_drawer(shell_gobj, menu_id) { close_drawer(shell_gobj, menu_id); }
|
|
2329
|
+
function yui_shell_toggle_drawer(shell_gobj, menu_id) { toggle_drawer(shell_gobj, menu_id); }
|
|
2330
|
+
|
|
2331
|
+
/* Escape priority chain — public API used by overlays that the
|
|
2332
|
+
* shell does not own (modals from #4, future popups, custom
|
|
2333
|
+
* app-level overlays). Drawer integration is built in.
|
|
2334
|
+
*
|
|
2335
|
+
* let close_fn = () => my_modal.close();
|
|
2336
|
+
* yui_shell_push_escape(shell, "modal", close_fn);
|
|
2337
|
+
* // ... when the modal closes by any path, also call:
|
|
2338
|
+
* yui_shell_pop_escape(shell, close_fn);
|
|
2339
|
+
*
|
|
2340
|
+
* `layer` is a free-form tag (e.g. "modal", "popup", "overlay").
|
|
2341
|
+
* Today it is informational; the LIFO ordering of the stack is
|
|
2342
|
+
* what determines Escape priority — and naturally matches the
|
|
2343
|
+
* z-index layering most apps use (drawer < popup < modal). */
|
|
2344
|
+
function yui_shell_push_escape(shell_gobj, layer, handler)
|
|
2345
|
+
{
|
|
2346
|
+
push_escape(shell_gobj, layer, handler);
|
|
2347
|
+
}
|
|
2348
|
+
function yui_shell_pop_escape(shell_gobj, handler)
|
|
2349
|
+
{
|
|
2350
|
+
pop_escape(shell_gobj, handler);
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
/************************************************************
|
|
2354
|
+
* Avatar provider — toolbar items with type:"avatar" call the
|
|
2355
|
+
* registered provider whenever the shell paints initials. The
|
|
2356
|
+
* provider is a free-form () => string callback owned by the
|
|
2357
|
+
* host (wattyzer/hidraulia/estadodelaire), so gobj-ui never
|
|
2358
|
+
* reaches into localStorage or app-specific attrs. Setting the
|
|
2359
|
+
* provider repaints existing avatars in-place; calling
|
|
2360
|
+
* yui_shell_refresh_avatars() repaints without changing the
|
|
2361
|
+
* provider (e.g. after the user updates their profile name).
|
|
2362
|
+
************************************************************/
|
|
2363
|
+
function yui_shell_set_avatar_provider(shell_gobj, provider)
|
|
2364
|
+
{
|
|
2365
|
+
let priv = gobj_read_attr(shell_gobj, "priv");
|
|
2366
|
+
if(!priv) {
|
|
2367
|
+
return;
|
|
2368
|
+
}
|
|
2369
|
+
priv.avatar_provider = (typeof provider === "function") ? provider : null;
|
|
2370
|
+
refresh_avatars(shell_gobj);
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
function yui_shell_refresh_avatars(shell_gobj)
|
|
2374
|
+
{
|
|
2375
|
+
refresh_avatars(shell_gobj);
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
/************************************************************
|
|
2379
|
+
* Register the host's i18n translator (a t-function:
|
|
2380
|
+
* key => translated string). The host still translates the
|
|
2381
|
+
* static shell tree itself via refresh_language($container, t);
|
|
2382
|
+
* this is only so the shell can translate DOM it builds LAZILY
|
|
2383
|
+
* and OUTSIDE $container — today the toolbar dropdown panel.
|
|
2384
|
+
* Optional: with no translator the panel renders raw keys
|
|
2385
|
+
* (the previous behaviour).
|
|
2386
|
+
************************************************************/
|
|
2387
|
+
function yui_shell_set_translator(shell_gobj, t)
|
|
2388
|
+
{
|
|
2389
|
+
let priv = gobj_read_attr(shell_gobj, "priv");
|
|
2390
|
+
if(!priv) {
|
|
2391
|
+
return;
|
|
2392
|
+
}
|
|
2393
|
+
priv.translator = (typeof t === "function") ? t : null;
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2396
|
+
/************************************************************
|
|
2397
|
+
* Set the backend-connection state painted by every
|
|
2398
|
+
* type:"connection" toolbar item. Host/event-driven: the
|
|
2399
|
+
* app calls this from its transport handlers (EV_ON_OPEN →
|
|
2400
|
+
* true, EV_ON_CLOSE / errors → false). Toggles the
|
|
2401
|
+
* is-connected / is-disconnected classes; CSS owns the look.
|
|
2402
|
+
************************************************************/
|
|
2403
|
+
function yui_shell_set_connection_state(shell_gobj, connected)
|
|
2404
|
+
{
|
|
2405
|
+
let priv = gobj_read_attr(shell_gobj, "priv");
|
|
2406
|
+
if(!priv || !is_array(priv.conn_nodes)) {
|
|
2407
|
+
return;
|
|
2408
|
+
}
|
|
2409
|
+
let on = !!connected;
|
|
2410
|
+
for(let $n of priv.conn_nodes) {
|
|
2411
|
+
$n.classList.toggle("is-connected", on);
|
|
2412
|
+
$n.classList.toggle("is-disconnected", !on);
|
|
2413
|
+
}
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2416
|
+
/************************************************************
|
|
2417
|
+
* Swap the icon of a toolbar item at runtime (host-driven,
|
|
2418
|
+
* like the avatar/connection helpers). Used e.g. for a
|
|
2419
|
+
* theme toggle that shows a sun in light / moon in dark.
|
|
2420
|
+
* `icon_class` fully replaces the <i> class.
|
|
2421
|
+
************************************************************/
|
|
2422
|
+
function yui_shell_set_toolbar_item_icon(shell_gobj, item_id, icon_class)
|
|
2423
|
+
{
|
|
2424
|
+
let $container = gobj_read_attr(shell_gobj, "$container");
|
|
2425
|
+
if(!$container || empty_string(item_id) || empty_string(icon_class)) {
|
|
2426
|
+
return;
|
|
2427
|
+
}
|
|
2428
|
+
let $i = $container.querySelector(
|
|
2429
|
+
`[data-toolbar-item-id="${item_id}"] .icon i`
|
|
2430
|
+
);
|
|
2431
|
+
if($i) {
|
|
2432
|
+
$i.className = icon_class;
|
|
2433
|
+
}
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
/************************************************************
|
|
2437
|
+
* Programmatic close of any open toolbar dropdown. Useful for
|
|
2438
|
+
* external triggers (e.g. EV_LOGOUT firing from elsewhere) that
|
|
2439
|
+
* want to dismiss whatever menu is on screen.
|
|
2440
|
+
************************************************************/
|
|
2441
|
+
function yui_shell_close_dropdown(shell_gobj)
|
|
2442
|
+
{
|
|
2443
|
+
close_toolbar_dropdown(shell_gobj);
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
/* Note: there is no shell-level language switch helper. Every
|
|
2447
|
+
* translatable text node rendered by the shell and its navs is
|
|
2448
|
+
* tagged with `data-i18n` (the canonical English key). Apps swap
|
|
2449
|
+
* language by calling
|
|
2450
|
+
* refresh_language(shell_$container, t)
|
|
2451
|
+
* from `@yuneta/gobj-js`, exactly like `c_yui_main.js` does in
|
|
2452
|
+
* `change_language()`. The shell does not own that flow — with
|
|
2453
|
+
* one exception: DOM the shell builds LAZILY and OUTSIDE
|
|
2454
|
+
* $container (the toolbar dropdown panel) is unreachable by that
|
|
2455
|
+
* call, so the app registers its translator via
|
|
2456
|
+
* yui_shell_set_translator() and the shell re-applies it per
|
|
2457
|
+
* panel build. */
|
|
2458
|
+
|
|
2459
|
+
export {
|
|
2460
|
+
register_c_yui_shell,
|
|
2461
|
+
yui_shell_navigate,
|
|
2462
|
+
yui_shell_open_drawer,
|
|
2463
|
+
yui_shell_close_drawer,
|
|
2464
|
+
yui_shell_toggle_drawer,
|
|
2465
|
+
yui_shell_push_escape,
|
|
2466
|
+
yui_shell_pop_escape,
|
|
2467
|
+
yui_shell_set_avatar_provider,
|
|
2468
|
+
yui_shell_refresh_avatars,
|
|
2469
|
+
yui_shell_set_translator,
|
|
2470
|
+
yui_shell_set_connection_state,
|
|
2471
|
+
yui_shell_set_toolbar_item_icon,
|
|
2472
|
+
yui_shell_close_dropdown,
|
|
2473
|
+
yui_shell_set_submenu
|
|
2474
|
+
};
|