@yuneta/gobj-ui 5.2.0 → 5.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +165 -0
- package/dist/gobj-ui.cjs.js +386 -43
- package/dist/gobj-ui.es.js +520 -177
- package/package.json +1 -1
- package/src/c_yui_json.css +23 -3
- package/src/c_yui_json.js +17 -3
- package/src/c_yui_nav.js +63 -3
- package/src/c_yui_node.css +77 -0
- package/src/c_yui_node.js +1567 -0
- package/src/c_yui_shell.css +18 -0
- package/src/c_yui_shell.js +206 -4
- package/src/c_yui_treedb_schema.js +1 -0
- package/src/c_yui_window.js +8 -3
- package/src/node_tree_model.js +463 -0
- package/src/node_tree_model.test.js +282 -0
- package/src/route_map_model.js +48 -0
- package/src/route_map_model.test.js +70 -0
- package/src/route_resolver.js +11 -0
- package/src/shell_route_map.css +36 -2
- package/src/shell_route_map.js +194 -45
- package/src/yui_dev.js +3 -3
- package/src/yui_icons.css +13 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/***********************************************************************
|
|
2
|
+
* node_tree_model.test.js
|
|
3
|
+
*
|
|
4
|
+
* Unit tests for the pure node-tree helpers.
|
|
5
|
+
***********************************************************************/
|
|
6
|
+
import { test, expect } from "vitest";
|
|
7
|
+
import {
|
|
8
|
+
chrome_visible,
|
|
9
|
+
split_subpath,
|
|
10
|
+
head_tail,
|
|
11
|
+
join_route,
|
|
12
|
+
projection_renders,
|
|
13
|
+
child_nav_items,
|
|
14
|
+
normalize_spec,
|
|
15
|
+
} from "./node_tree_model.js";
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
/***************************************************************
|
|
19
|
+
* subpath splitting
|
|
20
|
+
***************************************************************/
|
|
21
|
+
test("split_subpath drops empty segments", () => {
|
|
22
|
+
expect(split_subpath("a/b/c")).toEqual(["a", "b", "c"]);
|
|
23
|
+
expect(split_subpath("/a//b/")).toEqual(["a", "b"]);
|
|
24
|
+
expect(split_subpath("")).toEqual([]);
|
|
25
|
+
expect(split_subpath(null)).toEqual([]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("head_tail names the child and hands down the rest", () => {
|
|
29
|
+
expect(head_tail("energy/north/m1")).toEqual({head: "energy", tail: "north/m1"});
|
|
30
|
+
expect(head_tail("energy")).toEqual({head: "energy", tail: ""});
|
|
31
|
+
expect(head_tail("")).toEqual({head: "", tail: ""});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
/***************************************************************
|
|
36
|
+
* canonical routes
|
|
37
|
+
***************************************************************/
|
|
38
|
+
test("join_route builds the canonical route of a node", () => {
|
|
39
|
+
expect(join_route("/cards", ["energy", "north"])).toBe("/cards/energy/north");
|
|
40
|
+
expect(join_route("/cards", [])).toBe("/cards");
|
|
41
|
+
expect(join_route("/cards/", ["a"])).toBe("/cards/a");
|
|
42
|
+
expect(join_route("cards", ["a"])).toBe("/cards/a");
|
|
43
|
+
expect(join_route("/", ["a", "b"])).toBe("/a/b");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
/***************************************************************
|
|
48
|
+
* projections
|
|
49
|
+
***************************************************************/
|
|
50
|
+
test("no projection means: show my children as cards, no chrome", () => {
|
|
51
|
+
expect(projection_renders(undefined, "index")).toEqual([{layout: "cards"}]);
|
|
52
|
+
expect(projection_renders(undefined, "chrome")).toEqual([]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("a bare string or render object is the INDEX projection only", () => {
|
|
56
|
+
expect(projection_renders("vertical", "index")).toEqual([{layout: "vertical"}]);
|
|
57
|
+
expect(projection_renders("vertical", "chrome")).toEqual([]);
|
|
58
|
+
expect(projection_renders({layout: "cards", show_label: false}, "index"))
|
|
59
|
+
.toEqual([{layout: "cards", show_label: false}]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("index + chrome, chrome as an array (tabs on desktop, backbar on mobile)", () => {
|
|
63
|
+
let p = {
|
|
64
|
+
index: {layout: "cards"},
|
|
65
|
+
chrome: [
|
|
66
|
+
{layout: "tabs", show_on: ">=tablet"},
|
|
67
|
+
{layout: "backbar", show_on: "<tablet"}
|
|
68
|
+
]
|
|
69
|
+
};
|
|
70
|
+
expect(projection_renders(p, "index")).toEqual([{layout: "cards"}]);
|
|
71
|
+
expect(projection_renders(p, "chrome")).toEqual([
|
|
72
|
+
{layout: "tabs", show_on: ">=tablet"},
|
|
73
|
+
{layout: "backbar", show_on: "<tablet"}
|
|
74
|
+
]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("projection_renders returns copies, never the caller's objects", () => {
|
|
78
|
+
let p = {index: {layout: "cards"}};
|
|
79
|
+
let r = projection_renders(p, "index");
|
|
80
|
+
r[0].layout = "tabs";
|
|
81
|
+
expect(p.index.layout).toBe("cards");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
/***************************************************************
|
|
86
|
+
* children → nav items
|
|
87
|
+
***************************************************************/
|
|
88
|
+
test("child_nav_items builds C_YUI_NAV items with canonical routes", () => {
|
|
89
|
+
let children = [
|
|
90
|
+
{id: "a", label: "Alpha", icon: "yi-eye", tooltip: "", disabled: false},
|
|
91
|
+
{id: "b", label: "Beta", icon: "", tooltip: "hi", disabled: true},
|
|
92
|
+
];
|
|
93
|
+
let items = child_nav_items(children, (id) => `/cards/${id}`);
|
|
94
|
+
expect(items).toEqual([
|
|
95
|
+
{id: "a", name: "Alpha", route: "/cards/a", icon: "yi-eye"},
|
|
96
|
+
{id: "b", name: "Beta", route: "/cards/b", tooltip: "hi", disabled: true},
|
|
97
|
+
]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
/***************************************************************
|
|
102
|
+
* spec validation
|
|
103
|
+
***************************************************************/
|
|
104
|
+
test("normalize_spec fills defaults and recurses into children", () => {
|
|
105
|
+
let errors = [];
|
|
106
|
+
let spec = normalize_spec({
|
|
107
|
+
id: "root",
|
|
108
|
+
children: [
|
|
109
|
+
{id: "a", label: "Alpha", content: {gclass: "C_TEST_VIEW"}},
|
|
110
|
+
{id: "b"}
|
|
111
|
+
]
|
|
112
|
+
}, errors, "/");
|
|
113
|
+
|
|
114
|
+
expect(errors).toEqual([]);
|
|
115
|
+
expect(spec.id).toBe("root");
|
|
116
|
+
expect(spec.label).toBe("root"); /* label defaults to id */
|
|
117
|
+
expect(spec.children.length).toBe(2);
|
|
118
|
+
expect(spec.children[0].content).toEqual({gclass: "C_TEST_VIEW", kw: {}});
|
|
119
|
+
expect(spec.children[1].label).toBe("b");
|
|
120
|
+
expect(spec.children[1].content).toBe(null);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("an id is ONE url segment", () => {
|
|
124
|
+
let errors = [];
|
|
125
|
+
expect(normalize_spec({id: "a/b"}, errors, "/")).toBe(null);
|
|
126
|
+
expect(errors.length).toBe(1);
|
|
127
|
+
expect(errors[0]).toMatch(/contains '\/'/);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("a node without id is rejected, loudly", () => {
|
|
131
|
+
let errors = [];
|
|
132
|
+
expect(normalize_spec({label: "no id"}, errors, "/")).toBe(null);
|
|
133
|
+
expect(errors[0]).toMatch(/has no 'id'/);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("sibling ids must be unique — they are url segments", () => {
|
|
137
|
+
let errors = [];
|
|
138
|
+
expect(normalize_spec({
|
|
139
|
+
id: "root",
|
|
140
|
+
children: [{id: "dup"}, {id: "dup"}]
|
|
141
|
+
}, errors, "/")).toBe(null);
|
|
142
|
+
expect(errors.some((e) => /duplicated child id 'dup'/.test(e))).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("an unknown layout is a config error, not a silent fallback", () => {
|
|
146
|
+
let errors = [];
|
|
147
|
+
expect(normalize_spec({id: "x", projection: "carousel"}, errors, "/")).toBe(null);
|
|
148
|
+
expect(errors[0]).toMatch(/unknown projection layout 'carousel'/);
|
|
149
|
+
|
|
150
|
+
errors = [];
|
|
151
|
+
expect(normalize_spec({
|
|
152
|
+
id: "x",
|
|
153
|
+
projection: {index: {layout: "grid"}}
|
|
154
|
+
}, errors, "/")).toBe(null);
|
|
155
|
+
expect(errors[0]).toMatch(/unknown projection.index layout 'grid'/);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("content needs a gclass", () => {
|
|
159
|
+
let errors = [];
|
|
160
|
+
expect(normalize_spec({id: "x", content: {kw: {}}}, errors, "/")).toBe(null);
|
|
161
|
+
expect(errors[0]).toMatch(/'content.gclass' is required/);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("errors name the offending node's path", () => {
|
|
165
|
+
let errors = [];
|
|
166
|
+
normalize_spec({
|
|
167
|
+
id: "root",
|
|
168
|
+
children: [{id: "energy", children: [{label: "orphan"}]}]
|
|
169
|
+
}, errors, "/");
|
|
170
|
+
expect(errors[0]).toMatch(/'\/root\/energy\/'/);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
/***************************************************************
|
|
175
|
+
* chrome depth
|
|
176
|
+
***************************************************************/
|
|
177
|
+
test("no declared chrome_depth means every ancestor paints its strip", () => {
|
|
178
|
+
expect(chrome_visible(1, null)).toBe(true);
|
|
179
|
+
expect(chrome_visible(5, null)).toBe(true);
|
|
180
|
+
expect(chrome_visible(3, undefined)).toBe(true);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("chrome_depth caps how many strips are painted above the tip", () => {
|
|
184
|
+
/* distance 1 is the tip's own parent */
|
|
185
|
+
expect(chrome_visible(1, 1)).toBe(true);
|
|
186
|
+
expect(chrome_visible(2, 1)).toBe(false);
|
|
187
|
+
expect(chrome_visible(3, 1)).toBe(false);
|
|
188
|
+
expect(chrome_visible(1, 0)).toBe(false); /* no chrome at all */
|
|
189
|
+
expect(chrome_visible(2, 2)).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("chrome_depth is validated as a non-negative integer", () => {
|
|
193
|
+
let errors = [];
|
|
194
|
+
expect(normalize_spec({id: "x", chrome_depth: 2}, errors, "/").chrome_depth).toBe(2);
|
|
195
|
+
expect(normalize_spec({id: "x", chrome_depth: 0}, errors, "/").chrome_depth).toBe(0);
|
|
196
|
+
expect(normalize_spec({id: "x"}, errors, "/").chrome_depth).toBe(-1); /* inherit */
|
|
197
|
+
expect(errors).toEqual([]);
|
|
198
|
+
|
|
199
|
+
expect(normalize_spec({id: "x", chrome_depth: -2}, errors, "/")).toBe(null);
|
|
200
|
+
expect(errors[0]).toMatch(/must be an integer >= 0/);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("normalize_spec accepts its own output (every level re-normalizes)", () => {
|
|
204
|
+
let errors = [];
|
|
205
|
+
let once = normalize_spec({
|
|
206
|
+
id: "root",
|
|
207
|
+
chrome_depth: 1,
|
|
208
|
+
children: [{id: "a", children: [{id: "b"}]}]
|
|
209
|
+
}, errors, "/");
|
|
210
|
+
let twice = normalize_spec(once, errors, "/");
|
|
211
|
+
expect(errors).toEqual([]);
|
|
212
|
+
expect(twice).toEqual(once);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
/***************************************************************
|
|
217
|
+
* aliases — the tree's paths are a contract
|
|
218
|
+
***************************************************************/
|
|
219
|
+
test("aliases carry the former ids of a node", () => {
|
|
220
|
+
let errors = [];
|
|
221
|
+
let spec = normalize_spec({id: "north", aliases: ["hall1", "h1"]}, errors, "/");
|
|
222
|
+
expect(errors).toEqual([]);
|
|
223
|
+
expect(spec.aliases).toEqual(["hall1", "h1"]);
|
|
224
|
+
expect(normalize_spec({id: "x"}, errors, "/").aliases).toEqual([]);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("an alias is one url segment, like an id", () => {
|
|
228
|
+
let errors = [];
|
|
229
|
+
expect(normalize_spec({id: "x", aliases: ["a/b"]}, errors, "/")).toBe(null);
|
|
230
|
+
expect(errors[0]).toMatch(/is not a valid id/);
|
|
231
|
+
|
|
232
|
+
errors = [];
|
|
233
|
+
expect(normalize_spec({id: "x", aliases: "hall1"}, errors, "/")).toBe(null);
|
|
234
|
+
expect(errors[0]).toMatch(/'aliases' must be an array/);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
/***************************************************************
|
|
239
|
+
* link — where structure ends and data begins
|
|
240
|
+
***************************************************************/
|
|
241
|
+
test("a link declares the data space and its viewer", () => {
|
|
242
|
+
let errors = [];
|
|
243
|
+
let spec = normalize_spec({
|
|
244
|
+
id: "m1",
|
|
245
|
+
link: {kind: "tranger", gclass: "C_TRANGER_VIEW", kw: {topic: "t"}}
|
|
246
|
+
}, errors, "/");
|
|
247
|
+
expect(errors).toEqual([]);
|
|
248
|
+
expect(spec.link).toEqual({
|
|
249
|
+
kind: "tranger", gclass: "C_TRANGER_VIEW", kw: {topic: "t"}
|
|
250
|
+
});
|
|
251
|
+
expect(normalize_spec({id: "x"}, errors, "/").link).toBe(null);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("a link needs both what it points into and what reads it", () => {
|
|
255
|
+
let errors = [];
|
|
256
|
+
expect(normalize_spec({id: "m1", link: {gclass: "C_V"}}, errors, "/")).toBe(null);
|
|
257
|
+
expect(errors[0]).toMatch(/'link.kind' is required/);
|
|
258
|
+
|
|
259
|
+
errors = [];
|
|
260
|
+
expect(normalize_spec({id: "m1", link: {kind: "tranger"}}, errors, "/")).toBe(null);
|
|
261
|
+
expect(errors[0]).toMatch(/'link.gclass' is required/);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("below a link there are no nodes — link + children is a contradiction", () => {
|
|
265
|
+
let errors = [];
|
|
266
|
+
expect(normalize_spec({
|
|
267
|
+
id: "m1",
|
|
268
|
+
link: {kind: "tranger", gclass: "C_V"},
|
|
269
|
+
children: [{id: "nope"}]
|
|
270
|
+
}, errors, "/")).toBe(null);
|
|
271
|
+
expect(errors.some((e) => /both 'link' and 'children'/.test(e))).toBe(true);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("a link IS the node's content — declaring both is rejected", () => {
|
|
275
|
+
let errors = [];
|
|
276
|
+
expect(normalize_spec({
|
|
277
|
+
id: "m1",
|
|
278
|
+
content: {gclass: "C_OTHER"},
|
|
279
|
+
link: {kind: "tranger", gclass: "C_V"}
|
|
280
|
+
}, errors, "/")).toBe(null);
|
|
281
|
+
expect(errors.some((e) => /both 'link' and 'content'/.test(e))).toBe(true);
|
|
282
|
+
});
|
package/src/route_map_model.js
CHANGED
|
@@ -138,6 +138,45 @@ function collect_routes(nodes, into)
|
|
|
138
138
|
}
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
/************************************************************
|
|
142
|
+
* A route can be reached from more than one surface: the primary
|
|
143
|
+
* menu, a drawer, an account dropdown. Each occurrence rendered
|
|
144
|
+
* its whole SUBTREE, so a branch reachable three ways was drawn
|
|
145
|
+
* three times — bearable when a section had four children, noise
|
|
146
|
+
* once a node tree hangs there with fourteen.
|
|
147
|
+
*
|
|
148
|
+
* Exactly one occurrence owns the subtree; the others become
|
|
149
|
+
* references (`ref: true`, no children) — still real, clickable
|
|
150
|
+
* routes, just not repeated structure.
|
|
151
|
+
*
|
|
152
|
+
* Who owns it: the first occurrence in the NAV, then in `other`,
|
|
153
|
+
* then in the toolbar. The nav is where the app's structure
|
|
154
|
+
* lives; a toolbar entry pointing at the same route is a
|
|
155
|
+
* shortcut — a non-structural edge — and hanging the tree off it
|
|
156
|
+
* would bury the whole structure inside the account menu.
|
|
157
|
+
************************************************************/
|
|
158
|
+
function dedupe_subtrees(groups)
|
|
159
|
+
{
|
|
160
|
+
let owner = {};
|
|
161
|
+
let visit = (node) => {
|
|
162
|
+
if(node.route) {
|
|
163
|
+
if(owner[node.route] === undefined) {
|
|
164
|
+
owner[node.route] = node;
|
|
165
|
+
} else if(owner[node.route] !== node) {
|
|
166
|
+
node.ref = true;
|
|
167
|
+
node.children = [];
|
|
168
|
+
return; /* a reference has no subtree to walk */
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if(Array.isArray(node.children)) {
|
|
172
|
+
node.children.forEach(visit);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
for(let group of groups) {
|
|
176
|
+
group.forEach(visit);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
141
180
|
/************************************************************
|
|
142
181
|
* Mark "you are here": the node whose route best matches
|
|
143
182
|
* current_route — exact hit wins, else the LONGEST declared
|
|
@@ -151,6 +190,11 @@ function mark_current(groups, current_route)
|
|
|
151
190
|
}
|
|
152
191
|
let best = null;
|
|
153
192
|
let visit = (n) => {
|
|
193
|
+
/* Never on a reference: the mark belongs on the occurrence
|
|
194
|
+
* that carries the structure, not on a shortcut to it. */
|
|
195
|
+
if(n.ref) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
154
198
|
if(n.route) {
|
|
155
199
|
if(n.route === current_route) {
|
|
156
200
|
if(!best || best.node.route !== current_route) {
|
|
@@ -303,6 +347,10 @@ function build_nav_map(input)
|
|
|
303
347
|
}
|
|
304
348
|
other.forEach(enrich);
|
|
305
349
|
|
|
350
|
+
/* One subtree, one place. Before marking "you are here", so the
|
|
351
|
+
* mark lands on the occurrence that kept its children. */
|
|
352
|
+
dedupe_subtrees([nav, other, toolbar]);
|
|
353
|
+
|
|
306
354
|
/* The brand is rendered as the tree's ROOT row (shell_route_map),
|
|
307
355
|
* so it is markable like any other route — and it is the only
|
|
308
356
|
* rendered node in neither group, which left an app whose brand
|
|
@@ -207,3 +207,73 @@ describe("build_nav_map", () => {
|
|
|
207
207
|
expect(marked).toEqual([]);
|
|
208
208
|
});
|
|
209
209
|
});
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
/***************************************************************
|
|
213
|
+
* one subtree per route
|
|
214
|
+
***************************************************************/
|
|
215
|
+
test("a route reachable from several surfaces keeps ONE subtree", () => {
|
|
216
|
+
let config = {
|
|
217
|
+
toolbar: {items: [
|
|
218
|
+
{id: "user", type: "action", action: {type: "dropdown", items: [
|
|
219
|
+
{id: "d-cards", name: "Go to Cards",
|
|
220
|
+
action: {type: "navigate", route: "/cards"}}
|
|
221
|
+
]}}
|
|
222
|
+
]},
|
|
223
|
+
menu: {
|
|
224
|
+
primary: {items: [
|
|
225
|
+
{id: "cards", name: "Cards", route: "/cards", target: {gclass: "C_X"}}
|
|
226
|
+
]},
|
|
227
|
+
quick: {items: [
|
|
228
|
+
{id: "cards", name: "Cards", route: "/cards"}
|
|
229
|
+
]}
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
let map = build_nav_map({
|
|
233
|
+
config: config,
|
|
234
|
+
item_index: {"/cards": {item: {id: "cards"}, target: {gclass: "C_X"}}},
|
|
235
|
+
sub_routes: {"/cards": [
|
|
236
|
+
{route: "/cards/energy", label: "Energy", children: [
|
|
237
|
+
{route: "/cards/energy/north", label: "North", children: []}
|
|
238
|
+
]}
|
|
239
|
+
]},
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
/* The NAV owns it: primary is the first occurrence there. */
|
|
243
|
+
let primary = map.nav.find((n) => n.route === "/cards");
|
|
244
|
+
expect(primary.ref).toBeUndefined();
|
|
245
|
+
expect(primary.children.length).toBe(1);
|
|
246
|
+
expect(primary.children[0].children.length).toBe(1);
|
|
247
|
+
|
|
248
|
+
/* The drawer copy (a group inside nav) and the account entry are
|
|
249
|
+
* references: live routes, no repeated branch. */
|
|
250
|
+
let quick = map.nav.find((n) => n.kind === "group" && n.id === "quick");
|
|
251
|
+
expect(quick.children[0].route).toBe("/cards");
|
|
252
|
+
expect(quick.children[0].ref).toBe(true);
|
|
253
|
+
expect(quick.children[0].children).toEqual([]);
|
|
254
|
+
|
|
255
|
+
let dropdown = map.toolbar[0].children[0];
|
|
256
|
+
expect(dropdown.route).toBe("/cards");
|
|
257
|
+
expect(dropdown.ref).toBe(true);
|
|
258
|
+
expect(dropdown.children).toEqual([]);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("'you are here' lands on the owner, never on a reference", () => {
|
|
262
|
+
let config = {
|
|
263
|
+
toolbar: {items: [
|
|
264
|
+
{id: "user", type: "action", action: {type: "dropdown", items: [
|
|
265
|
+
{id: "d-cards", action: {type: "navigate", route: "/cards"}}
|
|
266
|
+
]}}
|
|
267
|
+
]},
|
|
268
|
+
menu: {primary: {items: [
|
|
269
|
+
{id: "cards", name: "Cards", route: "/cards", target: {gclass: "C_X"}}
|
|
270
|
+
]}}
|
|
271
|
+
};
|
|
272
|
+
let map = build_nav_map({
|
|
273
|
+
config: config,
|
|
274
|
+
item_index: {"/cards": {item: {id: "cards"}, target: {gclass: "C_X"}}},
|
|
275
|
+
current_route: "/cards",
|
|
276
|
+
});
|
|
277
|
+
expect(map.nav.find((n) => n.route === "/cards").current).toBe(true);
|
|
278
|
+
expect(map.toolbar[0].children[0].current).toBeUndefined();
|
|
279
|
+
});
|
package/src/route_resolver.js
CHANGED
|
@@ -21,6 +21,11 @@
|
|
|
21
21
|
* - root `/` is NEVER an ancestor catch-all (it only matches
|
|
22
22
|
* exactly): otherwise every typo route would silently mount
|
|
23
23
|
* the home view and the unknown-route diagnostic would die.
|
|
24
|
+
* ONE exception, opt-in: an entry whose target declares
|
|
25
|
+
* `owns_subtree` is a view that resolves the tail ITSELF and
|
|
26
|
+
* says so when a segment names nothing — a C_YUI_NODE tree
|
|
27
|
+
* rooted at `/`. The diagnostic is not lost there, it moves
|
|
28
|
+
* one layer down to whoever actually knows the names.
|
|
24
29
|
* - nothing matched → entry as found (may be
|
|
25
30
|
* null or a targetless submenu parent), subpath "".
|
|
26
31
|
************************************************************/
|
|
@@ -59,6 +64,12 @@ function resolve_route(item_index, route)
|
|
|
59
64
|
parts.pop();
|
|
60
65
|
let cand = "/" + parts.join("/");
|
|
61
66
|
if(cand === "/") {
|
|
67
|
+
let root = item_index["/"];
|
|
68
|
+
if(root && root.target && root.target.owns_subtree) {
|
|
69
|
+
entry = root;
|
|
70
|
+
matched_route = "/";
|
|
71
|
+
subpath = route.replace(/^\/+/, "");
|
|
72
|
+
}
|
|
62
73
|
break;
|
|
63
74
|
}
|
|
64
75
|
let e = item_index[cand];
|
package/src/shell_route_map.css
CHANGED
|
@@ -74,11 +74,21 @@
|
|
|
74
74
|
flex: 1 1 auto;
|
|
75
75
|
max-height: none;
|
|
76
76
|
}
|
|
77
|
+
/* FOUR characters per level, house rule: the tree is read as
|
|
78
|
+
* indentation and it has to be the same four everywhere (the JSON
|
|
79
|
+
* viewer, the raw dumps, this map). `ch` follows the row's own
|
|
80
|
+
* monospace font, so the guides stay aligned with the routes. */
|
|
77
81
|
.ROUTEMAP_UL {
|
|
78
82
|
list-style: none;
|
|
79
83
|
margin: 0;
|
|
80
|
-
padding-left:
|
|
81
|
-
|
|
84
|
+
padding-left: 4ch;
|
|
85
|
+
/* The guide is what makes the depth READABLE — at four levels the
|
|
86
|
+
* eye follows the line, not the offset. Dotted at 1px in the weak
|
|
87
|
+
* border colour was almost invisible against the panel; solid, in
|
|
88
|
+
* the stronger border shade, still reads as chrome but can be
|
|
89
|
+
* followed. Both shades are Bulma vars, so it re-shades itself in
|
|
90
|
+
* the dark theme instead of turning into a black hairline. */
|
|
91
|
+
border-left: 1px solid var(--bulma-border-hover, #8c8c8c);
|
|
82
92
|
}
|
|
83
93
|
.ROUTEMAP_ROOT {
|
|
84
94
|
padding-left: 0;
|
|
@@ -167,6 +177,30 @@ a.ROUTEMAP_LINK .ROUTEMAP_NAME {
|
|
|
167
177
|
background: rgba(120, 150, 255, 0.16) !important;
|
|
168
178
|
outline-color: rgba(120, 150, 255, 0.55);
|
|
169
179
|
}
|
|
180
|
+
/*--- reference row: this route's subtree is drawn where the nav owns
|
|
181
|
+
* it, so the copy reachable from a drawer or the account menu is a
|
|
182
|
+
* live link without the repeated branch. Quiet on purpose: it is a
|
|
183
|
+
* footnote, not a second structure. ---*/
|
|
184
|
+
.ROUTEMAP_REFS_TOGGLE {
|
|
185
|
+
display: inline-flex;
|
|
186
|
+
align-items: center;
|
|
187
|
+
white-space: nowrap;
|
|
188
|
+
color: var(--bulma-text-weak, #7a7a7a);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.ROUTEMAP_REF {
|
|
192
|
+
font-size: 0.68rem;
|
|
193
|
+
letter-spacing: 0.03em;
|
|
194
|
+
padding: 0.02rem 0.4rem;
|
|
195
|
+
border-radius: 0.25rem;
|
|
196
|
+
border: 1px solid var(--bulma-border-weak, #dbdbdb);
|
|
197
|
+
color: var(--bulma-text-weak, #7a7a7a);
|
|
198
|
+
white-space: nowrap;
|
|
199
|
+
}
|
|
200
|
+
.ROUTEMAP_ROW.ROUTEMAP_IS_REF .ROUTEMAP_NAME {
|
|
201
|
+
font-weight: 400;
|
|
202
|
+
}
|
|
203
|
+
|
|
170
204
|
.ROUTEMAP_HERE {
|
|
171
205
|
font-size: 0.68rem;
|
|
172
206
|
font-weight: 700;
|