@cosmicdrift/kumiko-headless 0.98.0 → 0.102.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/package.json +2 -2
- package/src/apex/__tests__/render.test.ts +39 -0
- package/src/apex/css.ts +40 -0
- package/src/apex/index.ts +56 -6
- package/src/view-model/__tests__/list.test.ts +35 -0
- package/src/view-model/list.ts +22 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-headless",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.102.0",
|
|
4
4
|
"description": "Headless UI logic for Kumiko — Dispatcher contract, Form-Controller, View-Model, Nav-Resolver. Plattform- und React-frei; jeder Renderer (renderer, renderer-web, renderer-native, …) komponiert darauf.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
}
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
35
|
+
"@cosmicdrift/kumiko-framework": "0.102.0",
|
|
36
36
|
"zod": "^4.4.3"
|
|
37
37
|
},
|
|
38
38
|
"publishConfig": {
|
|
@@ -53,6 +53,45 @@ describe("renderApexPage", () => {
|
|
|
53
53
|
expect(html).toContain("<rect/>");
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
+
test("nav menu entry renders a dropdown; plain links stay anchors", () => {
|
|
57
|
+
const html = renderApexPage(
|
|
58
|
+
page({
|
|
59
|
+
header: {
|
|
60
|
+
brand: { href: "/", label: "Acme" },
|
|
61
|
+
navLinks: [
|
|
62
|
+
{
|
|
63
|
+
kind: "menu",
|
|
64
|
+
label: "Product",
|
|
65
|
+
items: [
|
|
66
|
+
{ icon: "<rect/>", title: "Planner", desc: "Plan it", href: "/p" },
|
|
67
|
+
{ title: "<b>Reports</b>", href: "/r" },
|
|
68
|
+
],
|
|
69
|
+
footer: { label: "See all", href: "/all" },
|
|
70
|
+
},
|
|
71
|
+
{ label: "Pricing", href: "#pricing" },
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
// Menu chrome + trigger label.
|
|
77
|
+
expect(html).toContain('class="nav-menu"');
|
|
78
|
+
expect(html).toContain('class="nav-menu__trigger"');
|
|
79
|
+
expect(html).toContain("Product");
|
|
80
|
+
// Items: title, description, wrapped icon; missing desc/icon simply omitted.
|
|
81
|
+
expect(html).toContain('class="nav-menu__item" href="/p"');
|
|
82
|
+
expect(html).toContain("Planner");
|
|
83
|
+
expect(html).toContain("Plan it");
|
|
84
|
+
expect(html).toContain("<rect/>");
|
|
85
|
+
// Footer link under the divider.
|
|
86
|
+
expect(html).toContain('class="nav-menu__more" href="/all"');
|
|
87
|
+
expect(html).toContain("See all");
|
|
88
|
+
// Item titles are escaped (app-data, not trusted HTML).
|
|
89
|
+
expect(html).toContain("<b>Reports</b>");
|
|
90
|
+
expect(html).not.toContain("<b>Reports</b>");
|
|
91
|
+
// The sibling plain link still renders as a normal anchor.
|
|
92
|
+
expect(html).toContain('<a href="#pricing">Pricing</a>');
|
|
93
|
+
});
|
|
94
|
+
|
|
56
95
|
test("pricing: featured card gets badge + featured class, cap line precedes benefits", () => {
|
|
57
96
|
const html = renderApexPage(
|
|
58
97
|
page({
|
package/src/apex/css.ts
CHANGED
|
@@ -189,6 +189,45 @@ const RESPONSIVE = `
|
|
|
189
189
|
}
|
|
190
190
|
`;
|
|
191
191
|
|
|
192
|
+
// Dropdown nav entry (kind:"menu"): CSS-only, reveals on hover AND keyboard
|
|
193
|
+
// focus-within (the trigger is a real <button>, panel items are <a>). The panel
|
|
194
|
+
// is a light popover in BOTH themes — only the trigger color tracks the
|
|
195
|
+
// surrounding nav. Nav is hidden < 640px (CHROME_*), so this is desktop-only.
|
|
196
|
+
// Exported standalone so a consumer rendering its own header chrome (not the
|
|
197
|
+
// full apex page) can include just this, without duplicating the rules.
|
|
198
|
+
export const APEX_NAV_MENU_CSS = `
|
|
199
|
+
.nav-menu { position: relative; display: inline-flex; }
|
|
200
|
+
.nav-menu__trigger { display: inline-flex; align-items: center; gap: 0.3rem;
|
|
201
|
+
font: inherit; font-size: 0.9375rem; color: var(--fg-muted);
|
|
202
|
+
background: none; border: 0; padding: 0; cursor: pointer; }
|
|
203
|
+
.nav-menu__trigger:hover { color: var(--fg); }
|
|
204
|
+
.nav-menu__chev { font-size: 0.7em; transition: transform 0.15s; }
|
|
205
|
+
.nav-menu:hover .nav-menu__chev, .nav-menu:focus-within .nav-menu__chev { transform: rotate(180deg); }
|
|
206
|
+
.nav-menu__panel { position: absolute; top: calc(100% + 0.5rem); left: 0; z-index: 20;
|
|
207
|
+
min-width: 21rem; padding: 0.5rem; background: var(--bg-card); color: var(--fg);
|
|
208
|
+
border: 1px solid var(--border); border-radius: 0.75rem; box-shadow: var(--shadow);
|
|
209
|
+
display: flex; flex-direction: column; gap: 0.125rem;
|
|
210
|
+
opacity: 0; visibility: hidden; transform: translateY(0.375rem);
|
|
211
|
+
transition: opacity 0.15s, transform 0.15s, visibility 0.15s; }
|
|
212
|
+
.nav-menu:hover .nav-menu__panel, .nav-menu:focus-within .nav-menu__panel {
|
|
213
|
+
opacity: 1; visibility: visible; transform: translateY(0); }
|
|
214
|
+
.nav-menu__item { display: flex; gap: 0.75rem; align-items: flex-start;
|
|
215
|
+
padding: 0.6rem 0.7rem; border-radius: 0.5rem; color: var(--fg); }
|
|
216
|
+
.nav-menu__item:hover { background: var(--bg-muted); color: var(--fg); }
|
|
217
|
+
.nav-menu__icon { flex: none; display: inline-flex; align-items: center; justify-content: center;
|
|
218
|
+
width: 2rem; height: 2rem; border-radius: 0.5rem;
|
|
219
|
+
background: color-mix(in srgb, var(--primary) 12%, transparent); color: var(--primary); }
|
|
220
|
+
.nav-menu__icon svg { width: 1.1rem; height: 1.1rem; }
|
|
221
|
+
.nav-menu__text { display: flex; flex-direction: column; gap: 0.1rem; }
|
|
222
|
+
.nav-menu__title { font-weight: 600; font-size: 0.9rem; line-height: 1.2; }
|
|
223
|
+
.nav-menu__desc { font-size: 0.8rem; color: var(--fg-muted); line-height: 1.35; }
|
|
224
|
+
.nav-menu__sep { height: 1px; background: var(--border); margin: 0.375rem 0.3rem; }
|
|
225
|
+
.nav-menu__more { display: inline-flex; padding: 0.4rem 0.7rem; font-size: 0.85rem; font-weight: 600; color: var(--primary); }
|
|
226
|
+
.nav-menu__more:hover { color: var(--primary-hover); }
|
|
227
|
+
.apex-dark .nav-menu__trigger { color: var(--on-dark-muted); }
|
|
228
|
+
.apex-dark .nav-menu__trigger:hover { color: var(--on-dark); }
|
|
229
|
+
`;
|
|
230
|
+
|
|
192
231
|
export const APEX_STRUCTURAL_CSS =
|
|
193
232
|
BASE_LAYOUT +
|
|
194
233
|
HERO +
|
|
@@ -198,4 +237,5 @@ export const APEX_STRUCTURAL_CSS =
|
|
|
198
237
|
FINAL_CTA +
|
|
199
238
|
CHROME_LIGHT +
|
|
200
239
|
CHROME_DARK +
|
|
240
|
+
APEX_NAV_MENU_CSS +
|
|
201
241
|
RESPONSIVE;
|
package/src/apex/index.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
import { escapeHtml } from "../format";
|
|
8
8
|
import { APEX_STRUCTURAL_CSS } from "./css";
|
|
9
9
|
|
|
10
|
+
export { APEX_NAV_MENU_CSS, APEX_STRUCTURAL_CSS } from "./css";
|
|
11
|
+
|
|
10
12
|
export type ApexTheme = "light" | "dark";
|
|
11
13
|
|
|
12
14
|
/** Brand is raw CSS the app owns: the :root token block and optional @font-face.
|
|
@@ -28,6 +30,28 @@ export type ApexCta = {
|
|
|
28
30
|
};
|
|
29
31
|
|
|
30
32
|
export type ApexLink = { readonly label: string; readonly href: string };
|
|
33
|
+
|
|
34
|
+
/** One entry inside a dropdown nav menu: icon + title + optional description. */
|
|
35
|
+
export type ApexNavMenuItem = {
|
|
36
|
+
/** Inner SVG markup (paths), wrapped by the standard 24px icon <svg>. Trusted. */
|
|
37
|
+
readonly icon?: string;
|
|
38
|
+
readonly title: string;
|
|
39
|
+
readonly desc?: string;
|
|
40
|
+
readonly href: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** A header nav entry that opens a dropdown (icon/title/desc rows) instead of
|
|
44
|
+
* navigating. CSS-only: reveals on hover + keyboard focus. */
|
|
45
|
+
export type ApexNavMenu = {
|
|
46
|
+
readonly kind: "menu";
|
|
47
|
+
readonly label: string;
|
|
48
|
+
readonly items: readonly ApexNavMenuItem[];
|
|
49
|
+
/** Optional link under a divider at the foot of the panel (e.g. "See all →"). */
|
|
50
|
+
readonly footer?: ApexLink;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** A header nav entry: a plain link, or a dropdown menu. */
|
|
54
|
+
export type ApexNavEntry = ApexLink | ApexNavMenu;
|
|
31
55
|
export type ApexImage = {
|
|
32
56
|
readonly src: string;
|
|
33
57
|
readonly alt: string;
|
|
@@ -37,7 +61,8 @@ export type ApexImage = {
|
|
|
37
61
|
|
|
38
62
|
export type ApexHeader = {
|
|
39
63
|
readonly brand: { readonly href: string; readonly label: string; readonly logoSrc?: string };
|
|
40
|
-
|
|
64
|
+
/** Plain links and/or dropdown menus, in order. */
|
|
65
|
+
readonly navLinks?: readonly ApexNavEntry[];
|
|
41
66
|
readonly actions?: readonly ApexCta[];
|
|
42
67
|
};
|
|
43
68
|
|
|
@@ -329,12 +354,37 @@ function renderSection(s: ApexSection): string {
|
|
|
329
354
|
}
|
|
330
355
|
}
|
|
331
356
|
|
|
332
|
-
function
|
|
357
|
+
function renderNavMenu(m: ApexNavMenu): string {
|
|
358
|
+
const items = m.items
|
|
359
|
+
.map(
|
|
360
|
+
(it) =>
|
|
361
|
+
`<a class="nav-menu__item" href="${escapeHtml(it.href)}">${
|
|
362
|
+
it.icon !== undefined ? `<span class="nav-menu__icon">${svgIcon(it.icon)}</span>` : ""
|
|
363
|
+
}<span class="nav-menu__text"><span class="nav-menu__title">${escapeHtml(it.title)}</span>${
|
|
364
|
+
it.desc !== undefined ? `<span class="nav-menu__desc">${escapeHtml(it.desc)}</span>` : ""
|
|
365
|
+
}</span></a>`,
|
|
366
|
+
)
|
|
367
|
+
.join("");
|
|
368
|
+
const footer =
|
|
369
|
+
m.footer !== undefined
|
|
370
|
+
? `<div class="nav-menu__sep"></div><a class="nav-menu__more" href="${escapeHtml(m.footer.href)}">${escapeHtml(m.footer.label)}</a>`
|
|
371
|
+
: "";
|
|
372
|
+
return `<div class="nav-menu"><button type="button" class="nav-menu__trigger" aria-haspopup="true">${escapeHtml(m.label)}<span class="nav-menu__chev" aria-hidden="true">▾</span></button><div class="nav-menu__panel">${items}${footer}</div></div>`;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function renderNavEntry(entry: ApexNavEntry): string {
|
|
376
|
+
return "items" in entry
|
|
377
|
+
? renderNavMenu(entry)
|
|
378
|
+
: `<a href="${escapeHtml(entry.href)}">${escapeHtml(entry.label)}</a>`;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Render just the apex header chrome (brand + nav + actions). Exported so a
|
|
382
|
+
* consumer that composes its own page shell (not a full apex page) can reuse
|
|
383
|
+
* the identical header — markup stays single-source. */
|
|
384
|
+
export function renderApexHeader(h: ApexHeader): string {
|
|
333
385
|
const logo =
|
|
334
386
|
h.brand.logoSrc !== undefined ? `<img src="${escapeHtml(h.brand.logoSrc)}" alt="" /> ` : "";
|
|
335
|
-
const navLinks = (h.navLinks ?? [])
|
|
336
|
-
.map((l) => `<a href="${escapeHtml(l.href)}">${escapeHtml(l.label)}</a>`)
|
|
337
|
-
.join("\n ");
|
|
387
|
+
const navLinks = (h.navLinks ?? []).map(renderNavEntry).join("\n ");
|
|
338
388
|
const actions = (h.actions ?? []).map(renderCta);
|
|
339
389
|
return `<header>
|
|
340
390
|
<div class="container nav">
|
|
@@ -445,7 +495,7 @@ export function renderApexPage(page: ApexPage): string {
|
|
|
445
495
|
<style>${css}</style>
|
|
446
496
|
</head>
|
|
447
497
|
<body${theme === "dark" ? ` class="apex-dark"` : ""}>
|
|
448
|
-
${
|
|
498
|
+
${renderApexHeader(page.header)}
|
|
449
499
|
|
|
450
500
|
${sections}
|
|
451
501
|
|
|
@@ -118,6 +118,41 @@ describe("computeListViewModel", () => {
|
|
|
118
118
|
).toThrow(/unknown field "doesNotExist"/);
|
|
119
119
|
});
|
|
120
120
|
|
|
121
|
+
test("labeled column with no matching field → virtual presentational column (no throw)", () => {
|
|
122
|
+
const vm = computeListViewModel({
|
|
123
|
+
screen: listScreen([
|
|
124
|
+
"title",
|
|
125
|
+
{ field: "tags", label: "Tags", renderer: { react: { __component: "TagsCell" } } },
|
|
126
|
+
]),
|
|
127
|
+
entity: taskEntity,
|
|
128
|
+
rows: [],
|
|
129
|
+
translate,
|
|
130
|
+
featureName: "tasks",
|
|
131
|
+
});
|
|
132
|
+
// `field` becomes the column key; label is taken verbatim (translate is
|
|
133
|
+
// identity here), type defaults to text, never server-sortable.
|
|
134
|
+
expect(vm.columns[1]).toEqual({
|
|
135
|
+
field: "tags",
|
|
136
|
+
label: "Tags",
|
|
137
|
+
type: "text",
|
|
138
|
+
sortable: false,
|
|
139
|
+
renderer: { react: { __component: "TagsCell" } },
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("label overrides the field-convention header on a real field", () => {
|
|
144
|
+
const vm = computeListViewModel({
|
|
145
|
+
screen: listScreen([{ field: "title", label: "custom.header" }]),
|
|
146
|
+
entity: taskEntity,
|
|
147
|
+
rows: [],
|
|
148
|
+
translate,
|
|
149
|
+
featureName: "tasks",
|
|
150
|
+
});
|
|
151
|
+
// label goes through translate (identity here) instead of the
|
|
152
|
+
// tasks:entity:task:field:title convention key.
|
|
153
|
+
expect(vm.columns[0]?.label).toBe("custom.header");
|
|
154
|
+
});
|
|
155
|
+
|
|
121
156
|
test("translate is called with the expected i18n-key per field", () => {
|
|
122
157
|
const spy = mock((key: string) => `T:${key}`);
|
|
123
158
|
computeListViewModel({
|
package/src/view-model/list.ts
CHANGED
|
@@ -42,18 +42,31 @@ export function computeListViewModel(input: ComputeListViewModelInput): ListView
|
|
|
42
42
|
// columns carry no reference/select metadata and never server-sort.
|
|
43
43
|
const derivedDef = entity.derivedFields?.[normalized.field];
|
|
44
44
|
if (!derivedDef) {
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
45
|
+
// A labeled column with no matching stored/derived field is a *virtual*
|
|
46
|
+
// presentational column — drawn entirely by a columnRenderer component
|
|
47
|
+
// from the row (e.g. tag chips); value is undefined and it never
|
|
48
|
+
// server-sorts. `field` is just the column key. Without a label it's an
|
|
49
|
+
// author typo (or a stale field-rename) → fail loud so the renderer
|
|
50
|
+
// doesn't silently draw an empty column.
|
|
51
|
+
if (normalized.label !== undefined) {
|
|
52
|
+
columns.push({
|
|
53
|
+
field: normalized.field,
|
|
54
|
+
label: translate(normalized.label),
|
|
55
|
+
type: "text",
|
|
56
|
+
sortable: false,
|
|
57
|
+
...(normalized.renderer !== undefined && { renderer: normalized.renderer }),
|
|
58
|
+
});
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
50
61
|
throw new Error(
|
|
51
62
|
`computeListViewModel: screen "${screen.id}" references unknown field "${normalized.field}" on entity "${screen.entity}"`,
|
|
52
63
|
);
|
|
53
64
|
}
|
|
54
65
|
columns.push({
|
|
55
66
|
field: normalized.field,
|
|
56
|
-
label: translate(
|
|
67
|
+
label: translate(
|
|
68
|
+
normalized.label ?? fieldLabelKey(featureName, screen.entity, normalized.field),
|
|
69
|
+
),
|
|
57
70
|
type: derivedDef.valueType,
|
|
58
71
|
// Display-only: a header sort would round-trip to the server, which has
|
|
59
72
|
// no column to sort by (see DerivedFieldDef). Never offer the affordance.
|
|
@@ -62,7 +75,9 @@ export function computeListViewModel(input: ComputeListViewModelInput): ListView
|
|
|
62
75
|
});
|
|
63
76
|
continue;
|
|
64
77
|
}
|
|
65
|
-
const label = translate(
|
|
78
|
+
const label = translate(
|
|
79
|
+
normalized.label ?? fieldLabelKey(featureName, screen.entity, normalized.field),
|
|
80
|
+
);
|
|
66
81
|
// Tier 2.7e-3 + Cross-Feature: Reference-Field — entity-String
|
|
67
82
|
// kann same-feature ("user") oder cross-feature ("users:user")
|
|
68
83
|
// sein. parseRefTarget gibt (featureName, entityName), der
|