@yuneta/gobj-ui 5.3.3 → 5.5.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 +114 -1
- package/dist/gobj-ui.cjs.js +475 -212
- package/dist/gobj-ui.es.js +473 -213
- package/index.js +5 -0
- package/package.json +44 -44
- package/src/c_yui_node.js +142 -6
- package/src/c_yui_service_view.js +362 -0
- package/src/c_yui_shell.js +42 -14
- package/src/node_tree_model.js +67 -0
- package/src/node_tree_model.test.js +70 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/***********************************************************************
|
|
2
|
+
* c_yui_service_view.js
|
|
3
|
+
*
|
|
4
|
+
* Mounting a view that TALKS TO A BACKEND.
|
|
5
|
+
*
|
|
6
|
+
* THE PROBLEM
|
|
7
|
+
* -----------
|
|
8
|
+
* A view that asks the backend for data does it with
|
|
9
|
+
* gobj_command(remote, "...", kw, src = itself)
|
|
10
|
+
* and C_IEVENT_CLI routes the answer back with
|
|
11
|
+
* gobj_find_service(gobj_name(src))
|
|
12
|
+
* which only finds REGISTERED SERVICES.
|
|
13
|
+
*
|
|
14
|
+
* Neither host creates one: C_YUI_SHELL mounts a route's view with
|
|
15
|
+
* `gobj_create()` and C_YUI_NODE with `gobj_create_pure_child()`.
|
|
16
|
+
* So a backend-talking view mounted directly never receives a single
|
|
17
|
+
* answer — and not quietly: the ievent logs "service not found" once
|
|
18
|
+
* per answer while the view sits empty forever.
|
|
19
|
+
*
|
|
20
|
+
* There is a second half. A route's `target.kw` is STATIC JSON, so
|
|
21
|
+
* it cannot carry the live transport pointer the view needs. It has
|
|
22
|
+
* to be resolved at mount time and injected.
|
|
23
|
+
*
|
|
24
|
+
* THE PIECE
|
|
25
|
+
* ---------
|
|
26
|
+
* Both halves had been written four separate times, in three repos,
|
|
27
|
+
* each wrapper saying the same thing in its own header. They are
|
|
28
|
+
* here now, in two shapes, because the four callers are not alike:
|
|
29
|
+
*
|
|
30
|
+
* yui_mount_service_view(host, spec) — the helper. For a wrapper
|
|
31
|
+
* that has its OWN extras on top (bridging url segments to the
|
|
32
|
+
* hosted view, rebinding it when a connection drops). Those
|
|
33
|
+
* extras are app or route logic and do NOT belong here; the
|
|
34
|
+
* wrapper keeps them and drops only the boilerplate.
|
|
35
|
+
*
|
|
36
|
+
* C_YUI_SERVICE_VIEW — the gclass, for a route with NO extras:
|
|
37
|
+
* declare it in the route/node and name the view it hosts. It
|
|
38
|
+
* is the helper plus a lifecycle, nothing more.
|
|
39
|
+
*
|
|
40
|
+
* CONTRACT of the hosted gclass:
|
|
41
|
+
* - declares the attr the transport is injected under
|
|
42
|
+
* (`gobj_remote_yuno` by default),
|
|
43
|
+
* - builds its `$container` in mt_create (re-exposed as the
|
|
44
|
+
* host's, which is what the shell/node mounts),
|
|
45
|
+
* - flags EVF_PUBLIC_EVENT on what arrives from the backend
|
|
46
|
+
* (EV_MT_COMMAND_ANSWER and anything the remote publishes): the
|
|
47
|
+
* ievent drops events that are not public.
|
|
48
|
+
*
|
|
49
|
+
* NAMES: the service name must be UNIQUE per mount. A duplicate is
|
|
50
|
+
* not fatal and that is the danger — gobj-js logs "service ALREADY
|
|
51
|
+
* REGISTERED. Will be UPDATED" and REBINDS the name, so the answers
|
|
52
|
+
* of one view would land in the other. Derive it from the route (or
|
|
53
|
+
* from whatever else makes the mount unique: a connection id, a
|
|
54
|
+
* workspace) and never from the gclass alone.
|
|
55
|
+
*
|
|
56
|
+
* Copyright (c) 2026, ArtGins.
|
|
57
|
+
* All Rights Reserved.
|
|
58
|
+
***********************************************************************/
|
|
59
|
+
import {
|
|
60
|
+
SDATA,
|
|
61
|
+
SDATA_END,
|
|
62
|
+
data_type_t,
|
|
63
|
+
gclass_create,
|
|
64
|
+
gclass_find_by_name,
|
|
65
|
+
log_error,
|
|
66
|
+
gobj_parent,
|
|
67
|
+
gobj_read_attr,
|
|
68
|
+
gobj_read_str_attr,
|
|
69
|
+
gobj_read_pointer_attr,
|
|
70
|
+
gobj_write_attr,
|
|
71
|
+
gobj_subscribe_event,
|
|
72
|
+
gobj_find_service,
|
|
73
|
+
gobj_create_service,
|
|
74
|
+
gobj_start,
|
|
75
|
+
gobj_stop,
|
|
76
|
+
gobj_is_running,
|
|
77
|
+
gobj_short_name,
|
|
78
|
+
is_gobj,
|
|
79
|
+
is_string,
|
|
80
|
+
empty_string,
|
|
81
|
+
createElement2,
|
|
82
|
+
} from "@yuneta/gobj-js";
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
/***************************************************************
|
|
86
|
+
* Constants
|
|
87
|
+
***************************************************************/
|
|
88
|
+
const GCLASS_NAME = "C_YUI_SERVICE_VIEW";
|
|
89
|
+
|
|
90
|
+
/* What the four wrappers all did: the app's single transport, injected
|
|
91
|
+
* under the attr every backend-talking gclass in this library declares. */
|
|
92
|
+
const DEFAULT_TRANSPORT_SERVICE = "__remote_service__";
|
|
93
|
+
const DEFAULT_TRANSPORT_ATTR = "gobj_remote_yuno";
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
/***************************************************************
|
|
97
|
+
* Data
|
|
98
|
+
***************************************************************/
|
|
99
|
+
const attrs_table = [
|
|
100
|
+
SDATA(data_type_t.DTP_POINTER, "subscriber", 0, null, "Subscriber of output events"),
|
|
101
|
+
|
|
102
|
+
SDATA(data_type_t.DTP_STRING, "view_gclass", 0, "", "GClass of the hosted view (must be registered)"),
|
|
103
|
+
SDATA(data_type_t.DTP_STRING, "service_name", 0, "", "Service name of the hosted view — UNIQUE per mount"),
|
|
104
|
+
SDATA(data_type_t.DTP_JSON, "view_kw", 0, null, "kw passed to the hosted view"),
|
|
105
|
+
SDATA(data_type_t.DTP_STRING, "transport_service", 0, DEFAULT_TRANSPORT_SERVICE, "Service name of the live transport"),
|
|
106
|
+
SDATA(data_type_t.DTP_STRING, "transport_attr", 0, DEFAULT_TRANSPORT_ATTR, "Attr of the hosted view the transport is injected under"),
|
|
107
|
+
SDATA(data_type_t.DTP_POINTER, "$container", 0, null, "Root HTMLElement"),
|
|
108
|
+
SDATA_END()
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
let PRIVATE_DATA = {
|
|
112
|
+
view: null,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
let __gclass__ = null;
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
/***************************
|
|
121
|
+
* Public helper
|
|
122
|
+
***************************/
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
/************************************************************
|
|
128
|
+
* Create `spec.gclass` as a NAMED SERVICE under `host`, with the
|
|
129
|
+
* live transport injected, and return it (null on failure, error
|
|
130
|
+
* already logged). The caller owns what happens next — starting
|
|
131
|
+
* it, exposing its DOM, bridging routes into it.
|
|
132
|
+
*
|
|
133
|
+
* spec = {
|
|
134
|
+
* gclass, // string, required — must be registered
|
|
135
|
+
* name, // string, required — service name, unique per mount
|
|
136
|
+
* kw, // object, optional — the view's kw
|
|
137
|
+
* transport, // gobj | service name | omitted:
|
|
138
|
+
* // a gobj is used as-is (the caller already
|
|
139
|
+
* // resolved it — e.g. per connection), a string
|
|
140
|
+
* // is looked up with gobj_find_service(), and
|
|
141
|
+
* // omitted means "__remote_service__".
|
|
142
|
+
* // null DISABLES injection: for a hosted view
|
|
143
|
+
* // that takes its transport some other way.
|
|
144
|
+
* transport_attr // string, optional — default "gobj_remote_yuno"
|
|
145
|
+
* }
|
|
146
|
+
************************************************************/
|
|
147
|
+
function yui_mount_service_view(host, spec)
|
|
148
|
+
{
|
|
149
|
+
if(!host || !is_gobj(host) || !spec) {
|
|
150
|
+
log_error(`${GCLASS_NAME}: yui_mount_service_view without host or spec`);
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const gclass = spec.gclass;
|
|
155
|
+
const name = spec.name;
|
|
156
|
+
|
|
157
|
+
if(empty_string(gclass) || empty_string(name)) {
|
|
158
|
+
log_error(
|
|
159
|
+
`${gobj_short_name(host)}: mounting a service view needs gclass and name`
|
|
160
|
+
);
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
if(!gclass_find_by_name(gclass)) {
|
|
164
|
+
log_error(`${gobj_short_name(host)}: gclass '${gclass}' is not registered`);
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let kw = Object.assign({}, spec.kw || {});
|
|
169
|
+
|
|
170
|
+
/* `undefined` means "the app's single transport"; an explicit null
|
|
171
|
+
* means "do not inject one". They are NOT the same, hence the
|
|
172
|
+
* hasOwnProperty test instead of a truthiness check. */
|
|
173
|
+
let transport = Object.prototype.hasOwnProperty.call(spec, "transport")
|
|
174
|
+
? spec.transport
|
|
175
|
+
: DEFAULT_TRANSPORT_SERVICE;
|
|
176
|
+
|
|
177
|
+
if(transport !== null) {
|
|
178
|
+
if(is_string(transport)) {
|
|
179
|
+
const service_name = transport;
|
|
180
|
+
transport = gobj_find_service(service_name);
|
|
181
|
+
if(!transport) {
|
|
182
|
+
log_error(
|
|
183
|
+
`${gobj_short_name(host)}: transport service ` +
|
|
184
|
+
`'${service_name}' not found`
|
|
185
|
+
);
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if(!is_gobj(transport)) {
|
|
190
|
+
log_error(`${gobj_short_name(host)}: transport is not a gobj`);
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
kw[spec.transport_attr || DEFAULT_TRANSPORT_ATTR] = transport;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const view = gobj_create_service(name, gclass, kw, host);
|
|
197
|
+
if(!view) {
|
|
198
|
+
log_error(`${gobj_short_name(host)}: cannot create '${gclass}' as '${name}'`);
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
return view;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/************************************************************
|
|
205
|
+
* The hosted view's $container, re-exposed as the host's — the
|
|
206
|
+
* shell/node mounts the HOST, so without this nothing is drawn.
|
|
207
|
+
* Never returns without setting one: an empty div beats a blank
|
|
208
|
+
* screen with the reason only in the console.
|
|
209
|
+
************************************************************/
|
|
210
|
+
function expose_view_container(host, view)
|
|
211
|
+
{
|
|
212
|
+
let $c = view ? gobj_read_attr(view, "$container") : null;
|
|
213
|
+
if(!$c) {
|
|
214
|
+
log_error(
|
|
215
|
+
`${gobj_short_name(host)}: hosted view does not expose $container ` +
|
|
216
|
+
`— check its mt_create`
|
|
217
|
+
);
|
|
218
|
+
$c = createElement2(["div", {}, ""]);
|
|
219
|
+
}
|
|
220
|
+
gobj_write_attr(host, "$container", $c);
|
|
221
|
+
return $c;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
/***************************
|
|
228
|
+
* Framework Methods
|
|
229
|
+
***************************/
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
/***************************************************************
|
|
235
|
+
* Framework Method: Create
|
|
236
|
+
***************************************************************/
|
|
237
|
+
function mt_create(gobj)
|
|
238
|
+
{
|
|
239
|
+
let priv = gobj.priv;
|
|
240
|
+
|
|
241
|
+
/*
|
|
242
|
+
* CHILD subscription model
|
|
243
|
+
*/
|
|
244
|
+
let subscriber = gobj_read_pointer_attr(gobj, "subscriber");
|
|
245
|
+
if(!subscriber) {
|
|
246
|
+
subscriber = gobj_parent(gobj);
|
|
247
|
+
}
|
|
248
|
+
gobj_subscribe_event(gobj, null, {}, subscriber);
|
|
249
|
+
|
|
250
|
+
priv.view = yui_mount_service_view(gobj, {
|
|
251
|
+
gclass: gobj_read_str_attr(gobj, "view_gclass"),
|
|
252
|
+
name: gobj_read_str_attr(gobj, "service_name"),
|
|
253
|
+
kw: gobj_read_attr(gobj, "view_kw") || {},
|
|
254
|
+
transport: gobj_read_str_attr(gobj, "transport_service"),
|
|
255
|
+
transport_attr: gobj_read_str_attr(gobj, "transport_attr")
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
expose_view_container(gobj, priv.view);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/***************************************************************
|
|
262
|
+
* Framework Method: Start
|
|
263
|
+
*
|
|
264
|
+
* The view starts HERE and not in mt_create, so its first
|
|
265
|
+
* request goes out when the host shows it.
|
|
266
|
+
***************************************************************/
|
|
267
|
+
function mt_start(gobj)
|
|
268
|
+
{
|
|
269
|
+
const view = gobj.priv.view;
|
|
270
|
+
if(view && !gobj_is_running(view)) {
|
|
271
|
+
gobj_start(view);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/***************************************************************
|
|
276
|
+
* Framework Method: Stop
|
|
277
|
+
***************************************************************/
|
|
278
|
+
function mt_stop(gobj)
|
|
279
|
+
{
|
|
280
|
+
const view = gobj.priv.view;
|
|
281
|
+
if(view && gobj_is_running(view)) {
|
|
282
|
+
gobj_stop(view);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/***************************************************************
|
|
287
|
+
* Framework Method: Destroy
|
|
288
|
+
*
|
|
289
|
+
* The view is a SERVICE created with this gobj as its parent, so
|
|
290
|
+
* gobj_destroy cascades onto it — and deregisters its name. Do
|
|
291
|
+
* NOT destroy it again here.
|
|
292
|
+
***************************************************************/
|
|
293
|
+
function mt_destroy(gobj)
|
|
294
|
+
{
|
|
295
|
+
let priv = gobj.priv;
|
|
296
|
+
|
|
297
|
+
priv.view = null;
|
|
298
|
+
|
|
299
|
+
const $c = gobj_read_attr(gobj, "$container");
|
|
300
|
+
if($c && $c.parentNode) {
|
|
301
|
+
$c.parentNode.removeChild($c);
|
|
302
|
+
}
|
|
303
|
+
gobj_write_attr(gobj, "$container", null);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
/***************************
|
|
310
|
+
* FSM
|
|
311
|
+
***************************/
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
const gmt = {
|
|
317
|
+
mt_create: mt_create,
|
|
318
|
+
mt_start: mt_start,
|
|
319
|
+
mt_stop: mt_stop,
|
|
320
|
+
mt_destroy: mt_destroy
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
function create_gclass(gclass_name)
|
|
324
|
+
{
|
|
325
|
+
if(__gclass__) {
|
|
326
|
+
log_error(`GClass ALREADY created: ${gclass_name}`);
|
|
327
|
+
return -1;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const states = [
|
|
331
|
+
["ST_IDLE", []]
|
|
332
|
+
];
|
|
333
|
+
|
|
334
|
+
const event_types = [];
|
|
335
|
+
|
|
336
|
+
__gclass__ = gclass_create(
|
|
337
|
+
gclass_name,
|
|
338
|
+
event_types,
|
|
339
|
+
states,
|
|
340
|
+
gmt,
|
|
341
|
+
0,
|
|
342
|
+
attrs_table,
|
|
343
|
+
PRIVATE_DATA,
|
|
344
|
+
0,
|
|
345
|
+
0,
|
|
346
|
+
0,
|
|
347
|
+
0
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
return __gclass__ ? 0 : -1;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function register_c_yui_service_view()
|
|
354
|
+
{
|
|
355
|
+
return create_gclass(GCLASS_NAME);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export {
|
|
359
|
+
register_c_yui_service_view,
|
|
360
|
+
yui_mount_service_view,
|
|
361
|
+
expose_view_container,
|
|
362
|
+
};
|
package/src/c_yui_shell.js
CHANGED
|
@@ -1542,10 +1542,8 @@ function build_view_gobj(gobj, entry, route, stage)
|
|
|
1542
1542
|
* (synthesized "cards" C_YUI_NAV) is SHELL-owned DOM built after
|
|
1543
1543
|
* the host's one-shot refresh_language — apply the registered
|
|
1544
1544
|
* translator, same policy as lazily-built dropdown panels. */
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
target.gclass === "C_YUI_NAV") {
|
|
1548
|
-
refresh_language($view, priv.translator);
|
|
1545
|
+
if(target.gclass === "C_YUI_NAV") {
|
|
1546
|
+
yui_shell_translate(gobj, $view);
|
|
1549
1547
|
}
|
|
1550
1548
|
return view;
|
|
1551
1549
|
}
|
|
@@ -2050,9 +2048,7 @@ function open_toolbar_dropdown(gobj, item, action, $trigger)
|
|
|
2050
2048
|
priv.layers.popup.appendChild($panel);
|
|
2051
2049
|
|
|
2052
2050
|
/* Translate the lazily-built panel (see the note above). */
|
|
2053
|
-
|
|
2054
|
-
refresh_language($panel, priv.translator);
|
|
2055
|
-
}
|
|
2051
|
+
yui_shell_translate(gobj, $panel);
|
|
2056
2052
|
|
|
2057
2053
|
/* Click-outside (capture-phase mousedown) closes the dropdown.
|
|
2058
2054
|
* Capture phase so a click on a sibling toolbar trigger lands
|
|
@@ -2857,10 +2853,7 @@ function yui_shell_set_submenu(shell_gobj, parent_item_id, items)
|
|
|
2857
2853
|
gobj_send_event(view, "EV_SET_ITEMS", {items: items}, shell_gobj);
|
|
2858
2854
|
/* EV_SET_ITEMS rebuilt the DOM: re-apply the translator
|
|
2859
2855
|
* (shell-owned DOM, same policy as build_view_gobj). */
|
|
2860
|
-
|
|
2861
|
-
if($view && typeof priv.translator === "function") {
|
|
2862
|
-
refresh_language($view, priv.translator);
|
|
2863
|
-
}
|
|
2856
|
+
yui_shell_translate(shell_gobj, gobj_read_attr(view, "$container"));
|
|
2864
2857
|
}
|
|
2865
2858
|
}
|
|
2866
2859
|
}
|
|
@@ -3265,9 +3258,10 @@ function yui_shell_refresh_avatars(shell_gobj)
|
|
|
3265
3258
|
* Register the host's i18n translator (a t-function:
|
|
3266
3259
|
* key => translated string). The host still translates the
|
|
3267
3260
|
* static shell tree itself via refresh_language($container, t);
|
|
3268
|
-
* this is only so the shell
|
|
3269
|
-
*
|
|
3270
|
-
*
|
|
3261
|
+
* this is only so the shell — and the library components that
|
|
3262
|
+
* build DOM under it — can translate DOM built LAZILY, after
|
|
3263
|
+
* that one-shot pass and often outside $container.
|
|
3264
|
+
* Optional: with no translator such DOM renders raw keys
|
|
3271
3265
|
* (the previous behaviour).
|
|
3272
3266
|
************************************************************/
|
|
3273
3267
|
function yui_shell_set_translator(shell_gobj, t)
|
|
@@ -3279,6 +3273,39 @@ function yui_shell_set_translator(shell_gobj, t)
|
|
|
3279
3273
|
priv.translator = (typeof t === "function") ? t : null;
|
|
3280
3274
|
}
|
|
3281
3275
|
|
|
3276
|
+
/************************************************************
|
|
3277
|
+
* Apply the registered translator to a FRESHLY BUILT subtree.
|
|
3278
|
+
*
|
|
3279
|
+
* Carrying the `i18n` key on a node is not enough for it to
|
|
3280
|
+
* render translated: the node is born holding the raw English
|
|
3281
|
+
* key, and the host's refresh_language() passes walk what
|
|
3282
|
+
* ALREADY exists — the shell tree at start up, document.body on
|
|
3283
|
+
* a language switch. Anything built after that (a dropdown
|
|
3284
|
+
* panel, a nav a node renders when you walk into it) is reached
|
|
3285
|
+
* by neither, so it renders the key: lower-case English that
|
|
3286
|
+
* never changes language, i.e. exactly what a MISSING key looks
|
|
3287
|
+
* like.
|
|
3288
|
+
*
|
|
3289
|
+
* The division of labour, unchanged: LIBRARY-built DOM is
|
|
3290
|
+
* translated through here; APP view gclasses translate their own
|
|
3291
|
+
* DOM (they own a `t` — see mount_view).
|
|
3292
|
+
*
|
|
3293
|
+
* Silent no-op with no shell or no translator: an app that never
|
|
3294
|
+
* registered one keeps the previous behaviour instead of losing
|
|
3295
|
+
* its chrome.
|
|
3296
|
+
************************************************************/
|
|
3297
|
+
function yui_shell_translate(shell_gobj, $el)
|
|
3298
|
+
{
|
|
3299
|
+
if(!$el || !shell_gobj || !is_gobj(shell_gobj)) {
|
|
3300
|
+
return;
|
|
3301
|
+
}
|
|
3302
|
+
let priv = shell_gobj.priv;
|
|
3303
|
+
if(!priv || typeof priv.translator !== "function") {
|
|
3304
|
+
return;
|
|
3305
|
+
}
|
|
3306
|
+
refresh_language($el, priv.translator);
|
|
3307
|
+
}
|
|
3308
|
+
|
|
3282
3309
|
/************************************************************
|
|
3283
3310
|
* The app switched the language: re-translate the whole document (every
|
|
3284
3311
|
* node carrying data-i18n / data-i18n-title / data-i18n-aria-label) and
|
|
@@ -3383,6 +3410,7 @@ export {
|
|
|
3383
3410
|
yui_shell_set_avatar_provider,
|
|
3384
3411
|
yui_shell_refresh_avatars,
|
|
3385
3412
|
yui_shell_set_translator,
|
|
3413
|
+
yui_shell_translate,
|
|
3386
3414
|
yui_shell_language_changed,
|
|
3387
3415
|
yui_shell_set_connection_state,
|
|
3388
3416
|
yui_shell_set_toolbar_item_icon,
|
package/src/node_tree_model.js
CHANGED
|
@@ -211,6 +211,73 @@ export function chrome_visible(distance, depth)
|
|
|
211
211
|
return distance <= depth;
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
+
/************************************************************
|
|
215
|
+
* NAVIGATION MODES — the three ways a tree can show the way in.
|
|
216
|
+
*
|
|
217
|
+
* "stack" — one chrome strip per ancestor. Whatever each
|
|
218
|
+
* node declared, painted at every level.
|
|
219
|
+
* "back" — only the tip's parent, and only as a "← parent".
|
|
220
|
+
* "path" — no strips at all: the trail as ONE breadcrumb
|
|
221
|
+
* line, drawn by the root.
|
|
222
|
+
*
|
|
223
|
+
* A mode is a FILTER over what the app declared, applied when the
|
|
224
|
+
* renders are asked for — never a rewrite of the declared
|
|
225
|
+
* projections. That is what makes going back to "stack" free and
|
|
226
|
+
* exact: an app that declares `vertical` chrome gets `vertical`
|
|
227
|
+
* back, not the tabs a canonical "stack" shape would have imposed.
|
|
228
|
+
*
|
|
229
|
+
* The index projection is NEVER touched: how a node shows its own
|
|
230
|
+
* children when it IS the page is not a statement about depth.
|
|
231
|
+
************************************************************/
|
|
232
|
+
export const NAV_MODES = ["stack", "back", "path"];
|
|
233
|
+
|
|
234
|
+
export function is_nav_mode(mode)
|
|
235
|
+
{
|
|
236
|
+
return NAV_MODES.indexOf(mode) >= 0;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/************************************************************
|
|
240
|
+
* The renders of one projection slot under a nav mode.
|
|
241
|
+
*
|
|
242
|
+
* `is_root` only matters for "path": the trail is drawn from the
|
|
243
|
+
* tree root down to the tip whoever declares it, so exactly one
|
|
244
|
+
* node must draw it or the same line appears once per ancestor.
|
|
245
|
+
************************************************************/
|
|
246
|
+
export function nav_mode_renders(projection, mode, slot, is_root)
|
|
247
|
+
{
|
|
248
|
+
if(slot === "index" || !is_nav_mode(mode) || mode === "stack") {
|
|
249
|
+
return projection_renders(projection, slot);
|
|
250
|
+
}
|
|
251
|
+
if(mode === "back") {
|
|
252
|
+
return (slot === "chrome") ? [{layout: "backbar"}] : [];
|
|
253
|
+
}
|
|
254
|
+
/* "path" */
|
|
255
|
+
if(slot === "path") {
|
|
256
|
+
return is_root ? [{layout: "breadcrumb"}] : [];
|
|
257
|
+
}
|
|
258
|
+
return [];
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/************************************************************
|
|
262
|
+
* The effective chrome depth under a nav mode.
|
|
263
|
+
*
|
|
264
|
+
* "back" is exactly `chrome_depth: 1` — one strip, the parent's.
|
|
265
|
+
* "path" is `0`: the trail already says everything the strips
|
|
266
|
+
* would, and a strip under it would be the same information twice.
|
|
267
|
+
* Both override what the tree declared, because the user asked for
|
|
268
|
+
* a shape and the declaration is what they asked to change.
|
|
269
|
+
************************************************************/
|
|
270
|
+
export function nav_mode_depth(declared_depth, mode)
|
|
271
|
+
{
|
|
272
|
+
if(mode === "back") {
|
|
273
|
+
return 1;
|
|
274
|
+
}
|
|
275
|
+
if(mode === "path") {
|
|
276
|
+
return 0;
|
|
277
|
+
}
|
|
278
|
+
return declared_depth;
|
|
279
|
+
}
|
|
280
|
+
|
|
214
281
|
/************************************************************
|
|
215
282
|
* Validate + fill one node spec. Returns the normalized spec,
|
|
216
283
|
* or null when it is unusable; every rejection pushes a
|
|
@@ -12,6 +12,9 @@ import {
|
|
|
12
12
|
projection_renders,
|
|
13
13
|
child_nav_items,
|
|
14
14
|
normalize_spec,
|
|
15
|
+
is_nav_mode,
|
|
16
|
+
nav_mode_renders,
|
|
17
|
+
nav_mode_depth,
|
|
15
18
|
} from "./node_tree_model.js";
|
|
16
19
|
|
|
17
20
|
|
|
@@ -280,3 +283,70 @@ test("a link IS the node's content — declaring both is rejected", () => {
|
|
|
280
283
|
}, errors, "/")).toBe(null);
|
|
281
284
|
expect(errors.some((e) => /both 'link' and 'content'/.test(e))).toBe(true);
|
|
282
285
|
});
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
/***************************************************************
|
|
289
|
+
* navigation modes
|
|
290
|
+
***************************************************************/
|
|
291
|
+
const DECLARED = {
|
|
292
|
+
index: {layout: "cards"},
|
|
293
|
+
chrome: [
|
|
294
|
+
{layout: "tabs", show_on: ">=tablet"},
|
|
295
|
+
{layout: "backbar", show_on: "<tablet"}
|
|
296
|
+
]
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
test("only the three modes are modes", () => {
|
|
300
|
+
expect(is_nav_mode("stack")).toBe(true);
|
|
301
|
+
expect(is_nav_mode("back")).toBe(true);
|
|
302
|
+
expect(is_nav_mode("path")).toBe(true);
|
|
303
|
+
expect(is_nav_mode("breadcrumb")).toBe(false); /* that is a LAYOUT */
|
|
304
|
+
expect(is_nav_mode("")).toBe(false);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("stack gives back exactly what the node declared", () => {
|
|
308
|
+
expect(nav_mode_renders(DECLARED, "stack", "chrome", true))
|
|
309
|
+
.toEqual(DECLARED.chrome);
|
|
310
|
+
expect(nav_mode_renders("vertical", "stack", "index", false))
|
|
311
|
+
.toEqual([{layout: "vertical"}]);
|
|
312
|
+
/* An unknown mode must not silently reshape a tree */
|
|
313
|
+
expect(nav_mode_renders(DECLARED, "nonsense", "chrome", true))
|
|
314
|
+
.toEqual(DECLARED.chrome);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("back replaces every chrome with one backbar, and drops the trail", () => {
|
|
318
|
+
expect(nav_mode_renders(DECLARED, "back", "chrome", true))
|
|
319
|
+
.toEqual([{layout: "backbar"}]);
|
|
320
|
+
expect(nav_mode_renders(DECLARED, "back", "chrome", false))
|
|
321
|
+
.toEqual([{layout: "backbar"}]);
|
|
322
|
+
expect(nav_mode_renders({path: {layout: "breadcrumb"}}, "back", "path", true))
|
|
323
|
+
.toEqual([]);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("path draws the trail ONCE, at the root, and no strips anywhere", () => {
|
|
327
|
+
expect(nav_mode_renders(DECLARED, "path", "path", true))
|
|
328
|
+
.toEqual([{layout: "breadcrumb"}]);
|
|
329
|
+
expect(nav_mode_renders(DECLARED, "path", "path", false)).toEqual([]);
|
|
330
|
+
expect(nav_mode_renders(DECLARED, "path", "chrome", true)).toEqual([]);
|
|
331
|
+
expect(nav_mode_renders(DECLARED, "path", "chrome", false)).toEqual([]);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("the index projection survives every mode", () => {
|
|
335
|
+
for(const mode of ["stack", "back", "path"]) {
|
|
336
|
+
expect(nav_mode_renders(DECLARED, mode, "index", true))
|
|
337
|
+
.toEqual([{layout: "cards"}]);
|
|
338
|
+
}
|
|
339
|
+
/* and an undeclared projection keeps defaulting to cards */
|
|
340
|
+
expect(nav_mode_renders(null, "path", "index", true))
|
|
341
|
+
.toEqual([{layout: "cards"}]);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("back is depth 1 and path is depth 0, whatever the tree declared", () => {
|
|
345
|
+
expect(nav_mode_depth(null, "back")).toBe(1);
|
|
346
|
+
expect(nav_mode_depth(3, "back")).toBe(1);
|
|
347
|
+
expect(nav_mode_depth(null, "path")).toBe(0);
|
|
348
|
+
expect(nav_mode_depth(3, "path")).toBe(0);
|
|
349
|
+
/* stack keeps the declaration, unlimited included */
|
|
350
|
+
expect(nav_mode_depth(null, "stack")).toBe(null);
|
|
351
|
+
expect(nav_mode_depth(2, "stack")).toBe(2);
|
|
352
|
+
});
|