@yuneta/gobj-ui 2.2.4 → 2.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.
@@ -0,0 +1,71 @@
1
+ /***********************************************************************
2
+ * nav_cards_helpers.js
3
+ *
4
+ * Pure descriptor builders for C_YUI_NAV's "cards" layout — a
5
+ * grid of tappable cards, one per item, used as the section-index
6
+ * landing of a submenu (list → detail pattern). Split out of
7
+ * c_yui_nav.js so it can be unit-tested without a DOM: the
8
+ * functions return createElement2 node descriptors, never
9
+ * HTMLElements.
10
+ *
11
+ * Copyright (c) 2026, ArtGins.
12
+ * All Rights Reserved.
13
+ ***********************************************************************/
14
+
15
+ /************************************************************
16
+ * Descriptor of one card: an <a> carrying the same data-*
17
+ * contract as every other nav item (data-route/data-item-id/
18
+ * data-disabled), so C_YUI_NAV's delegated click handler and
19
+ * active-route highlight work unchanged.
20
+ ************************************************************/
21
+ export function card_descriptor(it, show_label)
22
+ {
23
+ let children = [];
24
+ let label = it.name || "";
25
+
26
+ if(it.icon) {
27
+ children.push(["span", {class: "icon is-medium"},
28
+ ["i", {class: it.icon, "aria-hidden": "true"}]]);
29
+ }
30
+ if(show_label && label) {
31
+ children.push(["span", {class: "yui-nav-label", i18n: label}, label]);
32
+ }
33
+
34
+ let a_attrs = {
35
+ class: "yui-nav-item yui-nav-card",
36
+ href: it.route ? "#" + it.route : "#",
37
+ "data-item-id": it.id,
38
+ "data-route": it.route || "",
39
+ "data-disabled": it.disabled ? "1" : "0",
40
+ "aria-label": label || it.id
41
+ };
42
+ if(label) {
43
+ a_attrs["data-i18n-aria-label"] = label;
44
+ }
45
+ let tip = it.tooltip || it.aria_label;
46
+ if(tip) {
47
+ a_attrs.title = tip;
48
+ a_attrs["data-i18n-title"] = tip;
49
+ }
50
+ if(it.disabled) {
51
+ a_attrs["aria-disabled"] = "true";
52
+ a_attrs["tabindex"] = "-1";
53
+ }
54
+ return ["a", a_attrs, children];
55
+ }
56
+
57
+ /************************************************************
58
+ * Descriptor of the whole grid. Decorative items (`header`/
59
+ * `divider`) are dropped, same policy as the tabs layout.
60
+ ************************************************************/
61
+ export function cards_grid_descriptor(items, show_label)
62
+ {
63
+ let cards = [];
64
+ for(let it of (items || [])) {
65
+ if(!it || it.type === "header" || it.type === "divider") {
66
+ continue;
67
+ }
68
+ cards.push(card_descriptor(it, show_label));
69
+ }
70
+ return ["div", {}, cards];
71
+ }
@@ -0,0 +1,122 @@
1
+ /***********************************************************************
2
+ * nav_cards_helpers.test.js
3
+ *
4
+ * Unit tests for the pure "cards" layout descriptor builders.
5
+ * Descriptors are plain createElement2 arrays, so no DOM needed.
6
+ ***********************************************************************/
7
+ import { test, expect } from "vitest";
8
+ import {
9
+ card_descriptor,
10
+ cards_grid_descriptor,
11
+ } from "./nav_cards_helpers.js";
12
+
13
+
14
+ /***************************************************************
15
+ * card_descriptor
16
+ ***************************************************************/
17
+ test("card carries the nav item data-* contract", () => {
18
+ let [tag, attrs, children] = card_descriptor(
19
+ {id: "budgets", name: "budgets", icon: "wzi-coins", route: "/reports/budgets"},
20
+ true
21
+ );
22
+ expect(tag).toBe("a");
23
+ expect(attrs["data-item-id"]).toBe("budgets");
24
+ expect(attrs["data-route"]).toBe("/reports/budgets");
25
+ expect(attrs["data-disabled"]).toBe("0");
26
+ expect(attrs.href).toBe("#/reports/budgets");
27
+ expect(attrs["aria-label"]).toBe("budgets");
28
+ expect(attrs["data-i18n-aria-label"]).toBe("budgets");
29
+ expect(attrs.class).toContain("yui-nav-card");
30
+
31
+ let [icon_tag, icon_attrs] = children[0];
32
+ expect(icon_tag).toBe("span");
33
+ expect(icon_attrs.class).toContain("icon");
34
+
35
+ let [label_tag, label_attrs, label_text] = children[1];
36
+ expect(label_tag).toBe("span");
37
+ expect(label_attrs.i18n).toBe("budgets");
38
+ expect(label_text).toBe("budgets");
39
+ });
40
+
41
+ test("card without icon renders label only", () => {
42
+ let [, , children] = card_descriptor(
43
+ {id: "x", name: "x label", route: "/x"},
44
+ true
45
+ );
46
+ expect(children.length).toBe(1);
47
+ expect(children[0][0]).toBe("span");
48
+ expect(children[0][2]).toBe("x label");
49
+ });
50
+
51
+ test("show_label=false drops the label span", () => {
52
+ let [, attrs, children] = card_descriptor(
53
+ {id: "x", name: "x label", icon: "yi-gear", route: "/x"},
54
+ false
55
+ );
56
+ expect(children.length).toBe(1);
57
+ expect(children[0][1].class).toContain("icon");
58
+ /* aria still carries the name for icon-only cards. */
59
+ expect(attrs["aria-label"]).toBe("x label");
60
+ });
61
+
62
+ test("disabled card is marked for the click handler and a11y", () => {
63
+ let [, attrs] = card_descriptor(
64
+ {id: "x", name: "x", route: "/x", disabled: true},
65
+ true
66
+ );
67
+ expect(attrs["data-disabled"]).toBe("1");
68
+ expect(attrs["aria-disabled"]).toBe("true");
69
+ expect(attrs["tabindex"]).toBe("-1");
70
+ });
71
+
72
+ test("tooltip mirrors into title + data-i18n-title", () => {
73
+ let [, attrs] = card_descriptor(
74
+ {id: "x", name: "x", route: "/x", tooltip: "the tip"},
75
+ true
76
+ );
77
+ expect(attrs.title).toBe("the tip");
78
+ expect(attrs["data-i18n-title"]).toBe("the tip");
79
+ });
80
+
81
+ test("item without route degrades to inert href '#'", () => {
82
+ let [, attrs] = card_descriptor({id: "x", name: "x"}, true);
83
+ expect(attrs.href).toBe("#");
84
+ expect(attrs["data-route"]).toBe("");
85
+ });
86
+
87
+
88
+ /***************************************************************
89
+ * cards_grid_descriptor
90
+ ***************************************************************/
91
+ test("grid renders one card per navigable item", () => {
92
+ let [tag, , cards] = cards_grid_descriptor(
93
+ [
94
+ {id: "a", name: "a", route: "/s/a"},
95
+ {id: "b", name: "b", route: "/s/b"},
96
+ ],
97
+ true
98
+ );
99
+ expect(tag).toBe("div");
100
+ expect(cards.length).toBe(2);
101
+ expect(cards[0][1]["data-route"]).toBe("/s/a");
102
+ expect(cards[1][1]["data-route"]).toBe("/s/b");
103
+ });
104
+
105
+ test("grid drops decorative items (same policy as tabs)", () => {
106
+ let [, , cards] = cards_grid_descriptor(
107
+ [
108
+ {type: "header", name: "group"},
109
+ {id: "a", name: "a", route: "/s/a"},
110
+ {type: "divider"},
111
+ {id: "b", name: "b", route: "/s/b"},
112
+ null,
113
+ ],
114
+ true
115
+ );
116
+ expect(cards.length).toBe(2);
117
+ });
118
+
119
+ test("grid tolerates empty/missing items", () => {
120
+ expect(cards_grid_descriptor([], true)[2].length).toBe(0);
121
+ expect(cards_grid_descriptor(null, true)[2].length).toBe(0);
122
+ });
@@ -0,0 +1,106 @@
1
+ /***********************************************************************
2
+ * shell_section_index.js
3
+ *
4
+ * Pure helper for C_YUI_SHELL: synthesize the stage target of a
5
+ * section-index landing route.
6
+ *
7
+ * A level-1 menu item that declares `submenu.index` opts out of
8
+ * the redirect-to-default-child behaviour: its own route becomes
9
+ * a real resting, deep-linkable route whose view is the submenu
10
+ * itself rendered as a "cards" C_YUI_NAV (the shell's view
11
+ * contract only requires a $container by the end of mt_create,
12
+ * which C_YUI_NAV already satisfies). Config:
13
+ *
14
+ * "submenu": {
15
+ * "render": { "top-sub": "tabs" },
16
+ * "index": true — landing in stage "main"
17
+ * "index": {"stage": "x"} — landing in stage "x"
18
+ * }
19
+ *
20
+ * Precedence: an explicit inline `target` on the item wins (this
21
+ * helper returns null then). When the target is synthesized,
22
+ * `submenu.default` becomes inert for the section: navigate_to()
23
+ * only redirects to the default child while the entry has NO
24
+ * target.
25
+ *
26
+ * Tabs and cards never coexist (DRY of navigation): while the
27
+ * index is on stage the shell collapses the secondary zone, and
28
+ * for index sections the tab strip defaults to desktop/tablet
29
+ * only — on mobile a "backbar" nav (← <section>) takes its place
30
+ * inside a child view. See secondary_nav_renders().
31
+ *
32
+ * Split out of c_yui_shell.js so it can be unit-tested without
33
+ * a DOM.
34
+ *
35
+ * Copyright (c) 2026, ArtGins.
36
+ * All Rights Reserved.
37
+ ***********************************************************************/
38
+
39
+ /************************************************************
40
+ * Return the synthesized target for `item`'s section route,
41
+ * or null when the item doesn't opt in.
42
+ ************************************************************/
43
+ export function section_index_target(menu_id, item)
44
+ {
45
+ if(!item || !item.route || item.target) {
46
+ return null;
47
+ }
48
+ let sub = item.submenu;
49
+ if(!sub || !Array.isArray(sub.items) || !sub.index) {
50
+ return null;
51
+ }
52
+ let index_cfg = (typeof sub.index === "object") ? sub.index : {};
53
+ return {
54
+ stage: index_cfg.stage || "main",
55
+ gclass: "C_YUI_NAV",
56
+ kw: {
57
+ menu_id: `index.${menu_id}.${item.id}`,
58
+ nav_label: item.name || item.id || "",
59
+ menu_items: sub.items,
60
+ layout: "cards",
61
+ level: "secondary"
62
+ }
63
+ };
64
+ }
65
+
66
+ /************************************************************
67
+ * Return the list of render configs for one (submenu, zone)
68
+ * pair. `layout` is the raw submenu.render[zone] value
69
+ * (string or object).
70
+ *
71
+ * Without submenu.index: the declared render, unchanged.
72
+ * With submenu.index (list → detail):
73
+ * - the declared nav defaults to show_on ">=tablet" (an
74
+ * explicit show_on in the render object wins);
75
+ * - a "backbar" nav (show_on "<tablet") is appended so a
76
+ * child view offers "← <section>" on mobile instead of
77
+ * the duplicated tab strip. Disable with
78
+ * index: {backbar: false}; tune its breakpoints with
79
+ * index: {backbar: {show_on: "..."}}.
80
+ ************************************************************/
81
+ export function secondary_nav_renders(item, layout)
82
+ {
83
+ let cfg = (layout && typeof layout === "object")
84
+ ? Object.assign({}, layout)
85
+ : {layout: String(layout || "vertical")};
86
+
87
+ let sub = (item && item.submenu) || {};
88
+ if(!sub.index) {
89
+ return [cfg];
90
+ }
91
+ let index_cfg = (typeof sub.index === "object") ? sub.index : {};
92
+ if(!cfg.show_on) {
93
+ cfg.show_on = ">=tablet";
94
+ }
95
+ if(index_cfg.backbar === false) {
96
+ return [cfg];
97
+ }
98
+ let backbar_cfg = (typeof index_cfg.backbar === "object" && index_cfg.backbar)
99
+ ? index_cfg.backbar
100
+ : {};
101
+ return [cfg, {
102
+ layout: "backbar",
103
+ show_on: backbar_cfg.show_on || "<tablet",
104
+ back_route: (item && item.route) || ""
105
+ }];
106
+ }
@@ -0,0 +1,151 @@
1
+ /***********************************************************************
2
+ * shell_section_index.test.js
3
+ *
4
+ * Unit tests for the section-index target synthesis.
5
+ ***********************************************************************/
6
+ import { test, expect } from "vitest";
7
+ import {
8
+ section_index_target,
9
+ secondary_nav_renders,
10
+ } from "./shell_section_index.js";
11
+
12
+
13
+ const SUB_ITEMS = [
14
+ {id: "insights", name: "insights", route: "/reports/insights"},
15
+ {id: "budgets", name: "budgets", route: "/reports/budgets"},
16
+ ];
17
+
18
+ function reports_item(extra_sub, extra_item)
19
+ {
20
+ return Object.assign({
21
+ id: "reports",
22
+ name: "reports",
23
+ route: "/reports",
24
+ submenu: Object.assign({
25
+ render: {"top-sub": "tabs"},
26
+ items: SUB_ITEMS,
27
+ }, extra_sub || {}),
28
+ }, extra_item || {});
29
+ }
30
+
31
+
32
+ /***************************************************************
33
+ * Opt-in gate
34
+ ***************************************************************/
35
+ test("no submenu.index → null (redirect-to-default preserved)", () => {
36
+ expect(section_index_target("primary", reports_item())).toBe(null);
37
+ });
38
+
39
+ test("index=false → null", () => {
40
+ expect(section_index_target("primary", reports_item({index: false}))).toBe(null);
41
+ });
42
+
43
+ test("item without route → null", () => {
44
+ let item = reports_item({index: true});
45
+ delete item.route;
46
+ expect(section_index_target("primary", item)).toBe(null);
47
+ });
48
+
49
+ test("explicit inline target wins over index", () => {
50
+ let item = reports_item(
51
+ {index: true},
52
+ {target: {stage: "main", gclass: "C_APP_VIEW"}}
53
+ );
54
+ expect(section_index_target("primary", item)).toBe(null);
55
+ });
56
+
57
+ test("submenu without items array → null", () => {
58
+ let item = reports_item({index: true});
59
+ item.submenu.items = null;
60
+ expect(section_index_target("primary", item)).toBe(null);
61
+ });
62
+
63
+ test("no submenu at all → null", () => {
64
+ expect(section_index_target("primary", {id: "x", route: "/x"})).toBe(null);
65
+ });
66
+
67
+
68
+ /***************************************************************
69
+ * Synthesis
70
+ ***************************************************************/
71
+ test("index=true synthesizes a cards C_YUI_NAV target in stage 'main'", () => {
72
+ let t = section_index_target("primary", reports_item({index: true}));
73
+ expect(t).not.toBe(null);
74
+ expect(t.stage).toBe("main");
75
+ expect(t.gclass).toBe("C_YUI_NAV");
76
+ expect(t.kw.layout).toBe("cards");
77
+ expect(t.kw.level).toBe("secondary");
78
+ expect(t.kw.menu_id).toBe("index.primary.reports");
79
+ expect(t.kw.nav_label).toBe("reports");
80
+ /* Same array reference: a later yui_shell_set_submenu() refresh
81
+ * of kw.menu_items must be observable by the next mount. */
82
+ expect(t.kw.menu_items).toBe(SUB_ITEMS);
83
+ });
84
+
85
+ test("index={stage} overrides the stage", () => {
86
+ let t = section_index_target("primary", reports_item({index: {stage: "aux"}}));
87
+ expect(t.stage).toBe("aux");
88
+ });
89
+
90
+ test("nav_label falls back to the item id", () => {
91
+ let item = reports_item({index: true});
92
+ delete item.name;
93
+ let t = section_index_target("primary", item);
94
+ expect(t.kw.nav_label).toBe("reports");
95
+ });
96
+
97
+
98
+ /***************************************************************
99
+ * secondary_nav_renders
100
+ ***************************************************************/
101
+ test("no index → the declared render, unchanged, alone", () => {
102
+ let renders = secondary_nav_renders(reports_item(), {layout: "tabs"});
103
+ expect(renders).toEqual([{layout: "tabs"}]);
104
+ });
105
+
106
+ test("index → tabs constrained to >=tablet + mobile backbar", () => {
107
+ let renders = secondary_nav_renders(
108
+ reports_item({index: true}), {layout: "tabs"}
109
+ );
110
+ expect(renders.length).toBe(2);
111
+ expect(renders[0]).toEqual({layout: "tabs", show_on: ">=tablet"});
112
+ expect(renders[1]).toEqual({
113
+ layout: "backbar",
114
+ show_on: "<tablet",
115
+ back_route: "/reports"
116
+ });
117
+ });
118
+
119
+ test("explicit show_on on the declared render wins", () => {
120
+ let renders = secondary_nav_renders(
121
+ reports_item({index: true}), {layout: "tabs", show_on: ">=desktop"}
122
+ );
123
+ expect(renders[0].show_on).toBe(">=desktop");
124
+ });
125
+
126
+ test("index {backbar:false} suppresses the backbar", () => {
127
+ let renders = secondary_nav_renders(
128
+ reports_item({index: {backbar: false}}), {layout: "tabs"}
129
+ );
130
+ expect(renders.length).toBe(1);
131
+ expect(renders[0].show_on).toBe(">=tablet");
132
+ });
133
+
134
+ test("index {backbar:{show_on}} tunes the backbar breakpoints", () => {
135
+ let renders = secondary_nav_renders(
136
+ reports_item({index: {backbar: {show_on: "<desktop"}}}),
137
+ {layout: "tabs"}
138
+ );
139
+ expect(renders[1].show_on).toBe("<desktop");
140
+ });
141
+
142
+ test("string layout shorthand is normalized", () => {
143
+ let renders = secondary_nav_renders(reports_item(), "tabs");
144
+ expect(renders).toEqual([{layout: "tabs"}]);
145
+ });
146
+
147
+ test("input render object is not mutated", () => {
148
+ let declared = {layout: "tabs"};
149
+ secondary_nav_renders(reports_item({index: true}), declared);
150
+ expect(declared).toEqual({layout: "tabs"});
151
+ });