@cosmicdrift/kumiko-headless 0.200.0 → 0.201.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 +73 -0
- package/src/apex/css.ts +57 -14
- package/src/apex/index.ts +5 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-headless",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.201.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>",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
}
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
39
|
+
"@cosmicdrift/kumiko-framework": "0.201.0",
|
|
40
40
|
"temporal-polyfill": "^0.3.2",
|
|
41
41
|
"zod": "^4.4.3"
|
|
42
42
|
},
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { APEX_NAV_MENU_CSS, APEX_NAV_TOGGLE_RESPONSIVE_CSS, APEX_STRUCTURAL_CSS } from "../css";
|
|
2
3
|
import { type ApexPage, renderApexPage } from "../index";
|
|
3
4
|
|
|
4
5
|
const brand = { tokensCss: ":root{--primary:#123;--primary-fg:#fff;--bg:#fff;--fg:#000;}" };
|
|
@@ -92,6 +93,78 @@ describe("renderApexPage", () => {
|
|
|
92
93
|
expect(html).toContain('<a href="#pricing">Pricing</a>');
|
|
93
94
|
});
|
|
94
95
|
|
|
96
|
+
test("nav-links wrap in a mobile hamburger toggle; no navLinks omits it", () => {
|
|
97
|
+
const withNav = renderApexPage(
|
|
98
|
+
page({
|
|
99
|
+
header: {
|
|
100
|
+
brand: { href: "/", label: "Acme" },
|
|
101
|
+
navLinks: [{ label: "Pricing", href: "#pricing" }],
|
|
102
|
+
},
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
expect(withNav).toContain('<details class="nav-toggle">');
|
|
106
|
+
expect(withNav).toContain('<summary class="nav-toggle__trigger" aria-label="Menu">');
|
|
107
|
+
expect(withNav).toContain('<nav class="nav-links">');
|
|
108
|
+
|
|
109
|
+
const customLabel = renderApexPage(
|
|
110
|
+
page({
|
|
111
|
+
header: {
|
|
112
|
+
brand: { href: "/", label: "Acme" },
|
|
113
|
+
navLinks: [{ label: "Pricing", href: "#pricing" }],
|
|
114
|
+
menuLabel: "Menü",
|
|
115
|
+
},
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
expect(customLabel).toContain('aria-label="Menü"');
|
|
119
|
+
|
|
120
|
+
const withoutNav = renderApexPage(page());
|
|
121
|
+
expect(withoutNav).not.toContain('<details class="nav-toggle">');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("mobile-open .nav-toggle rule wins the cascade over the desktop-hidden default", () => {
|
|
125
|
+
// APEX_NAV_MENU_CSS sets `.nav-toggle { display: none; }` unconditionally;
|
|
126
|
+
// APEX_NAV_TOGGLE_RESPONSIVE_CSS re-declares it `.nav .nav-toggle { display:
|
|
127
|
+
// inline-flex; }` inside the 640px query. A markup-only assertion can't catch
|
|
128
|
+
// a reordering that silently restores fw#2047 (nav-links hidden on mobile
|
|
129
|
+
// with no way to reopen them).
|
|
130
|
+
const hiddenDefault = APEX_STRUCTURAL_CSS.indexOf(".nav-toggle { display: none; }");
|
|
131
|
+
const mobileOpen = APEX_STRUCTURAL_CSS.indexOf(".nav .nav-toggle { display: inline-flex; }");
|
|
132
|
+
expect(hiddenDefault).toBeGreaterThan(-1);
|
|
133
|
+
expect(mobileOpen).toBeGreaterThan(-1);
|
|
134
|
+
expect(mobileOpen).toBeGreaterThan(hiddenDefault);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("APEX_NAV_TOGGLE_RESPONSIVE_CSS's mobile-open rules outrank APEX_NAV_MENU_CSS's hidden default regardless of concatenation order (fw#2125)", () => {
|
|
138
|
+
// money-horse/show-pony/the docs sample call renderApexHeader() standalone and
|
|
139
|
+
// concatenate APEX_NAV_MENU_CSS with their own page CSS in whatever order they
|
|
140
|
+
// choose — they don't control ordering relative to APEX_STRUCTURAL_CSS's
|
|
141
|
+
// internal layout. The "show" rules are therefore specificity-hardened
|
|
142
|
+
// (`.nav .nav-toggle`, 2 classes) rather than relying on source order to beat
|
|
143
|
+
// APEX_NAV_MENU_CSS's bare `.nav-toggle` default (1 class) — verify both carry
|
|
144
|
+
// that extra `.nav ` prefix so the fix holds even when a consumer emits
|
|
145
|
+
// APEX_NAV_TOGGLE_RESPONSIVE_CSS BEFORE APEX_NAV_MENU_CSS.
|
|
146
|
+
expect(APEX_NAV_MENU_CSS).toContain(".nav-toggle { display: none; }");
|
|
147
|
+
expect(APEX_NAV_MENU_CSS).toContain(".nav-toggle__trigger { display: none;");
|
|
148
|
+
expect(APEX_NAV_MENU_CSS).not.toContain(".nav-toggle { display: inline-flex");
|
|
149
|
+
expect(APEX_NAV_MENU_CSS).not.toContain(".nav-toggle__trigger { display: inline-flex");
|
|
150
|
+
|
|
151
|
+
expect(APEX_NAV_TOGGLE_RESPONSIVE_CSS).toContain(".nav .nav-toggle { display: inline-flex; }");
|
|
152
|
+
expect(APEX_NAV_TOGGLE_RESPONSIVE_CSS).toContain(
|
|
153
|
+
".nav .nav-toggle__trigger { display: inline-flex; }",
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("APEX_NAV_TOGGLE_RESPONSIVE_CSS declares .nav as the positioned ancestor for the open dropdown", () => {
|
|
158
|
+
// `.nav-toggle[open] > .nav-links` is `position: absolute; top: calc(100% +
|
|
159
|
+
// 0.5rem); left: 0; right: 0` — it needs `.nav { position: relative; }` as
|
|
160
|
+
// its containing block. renderApexPage gets that from CHROME_LIGHT, but a
|
|
161
|
+
// standalone renderApexHeader() consumer's own header CSS typically doesn't
|
|
162
|
+
// declare it, and without it the dropdown opens off-screen against the
|
|
163
|
+
// viewport instead of under the header (fw#2125 follow-up: verified none of
|
|
164
|
+
// money-horse/show-pony/the docs sample's HEADER_CSS set it).
|
|
165
|
+
expect(APEX_NAV_TOGGLE_RESPONSIVE_CSS).toContain(".nav { position: relative; }");
|
|
166
|
+
});
|
|
167
|
+
|
|
95
168
|
test("pricing: featured card gets badge + featured class, cap line precedes benefits", () => {
|
|
96
169
|
const html = renderApexPage(
|
|
97
170
|
page({
|
package/src/apex/css.ts
CHANGED
|
@@ -126,7 +126,7 @@ const CHROME_LIGHT = `
|
|
|
126
126
|
background: color-mix(in srgb, var(--bg) 85%, transparent);
|
|
127
127
|
backdrop-filter: saturate(140%) blur(8px);
|
|
128
128
|
border-bottom: 1px solid var(--border); }
|
|
129
|
-
.nav { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding-block: 0.85rem; }
|
|
129
|
+
.nav { position: relative; display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding-block: 0.85rem; }
|
|
130
130
|
.brand { display: flex; align-items: center; gap: 0.55rem; font-weight: 700; font-size: 1.125rem; color: var(--fg); }
|
|
131
131
|
.brand a { color: var(--fg); display: inline-flex; align-items: center; gap: 0.55rem; }
|
|
132
132
|
.brand a:hover { color: var(--fg); }
|
|
@@ -199,29 +199,39 @@ const RESPONSIVE = `
|
|
|
199
199
|
}
|
|
200
200
|
`;
|
|
201
201
|
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
202
|
+
// header renders navLinks TWICE (renderApexHeader): a plain always-visible
|
|
203
|
+
// .nav-links row, plus a copy nested in .nav-toggle for < 640px — a closed
|
|
204
|
+
// <details> can't be forced "always open" on desktop via CSS (Chrome keeps
|
|
205
|
+
// its content unpainted regardless of a child's own `display`), so two
|
|
206
|
+
// copies sidestep that instead of fighting it. Exported standalone so a
|
|
207
|
+
// consumer rendering its own header chrome can include just this. A
|
|
208
|
+
// consumer using renderApexHeader() outside renderApexPage also needs
|
|
209
|
+
// APEX_NAV_TOGGLE_RESPONSIVE_CSS below for the mobile toggle to open.
|
|
208
210
|
export const APEX_NAV_MENU_CSS = `
|
|
211
|
+
.nav-toggle { display: none; }
|
|
212
|
+
.nav-toggle__trigger { display: none; align-items: center; justify-content: center;
|
|
213
|
+
width: 2.25rem; height: 2.25rem; border-radius: 0.5rem; cursor: pointer;
|
|
214
|
+
color: var(--fg-muted); list-style: none; }
|
|
215
|
+
.nav-toggle__trigger::-webkit-details-marker { display: none; }
|
|
216
|
+
.nav-toggle__trigger:hover { color: var(--fg); background: var(--bg-muted); }
|
|
217
|
+
.nav-toggle__trigger svg { width: 1.35rem; height: 1.35rem; }
|
|
218
|
+
.apex-dark .nav-toggle__trigger { color: var(--on-dark-muted); }
|
|
219
|
+
.apex-dark .nav-toggle__trigger:hover { color: var(--on-dark); background: rgba(255,255,255,0.08); }
|
|
220
|
+
|
|
209
221
|
.nav-menu { position: relative; display: inline-flex; }
|
|
210
222
|
.nav-menu__trigger { display: inline-flex; align-items: center; gap: 0.3rem;
|
|
211
223
|
font: inherit; font-size: 0.9375rem; color: var(--fg-muted);
|
|
212
|
-
|
|
224
|
+
cursor: pointer; list-style: none; }
|
|
225
|
+
.nav-menu__trigger::-webkit-details-marker { display: none; }
|
|
213
226
|
.nav-menu__trigger:hover { color: var(--fg); }
|
|
214
227
|
.nav-menu__chev { display: inline-flex; transition: transform 0.15s; opacity: 0.7; }
|
|
215
228
|
.nav-menu__chev svg { width: 0.95em; height: 0.95em; }
|
|
216
|
-
.nav-menu
|
|
229
|
+
.nav-menu[open] .nav-menu__chev { transform: rotate(180deg); }
|
|
217
230
|
.nav-menu__panel { position: absolute; top: calc(100% + 0.5rem); left: 0; z-index: 20;
|
|
218
231
|
min-width: 21rem; padding: 0.5rem; background: var(--bg-card); color: var(--fg);
|
|
219
232
|
border: 1px solid var(--border); border-radius: 0.75rem; box-shadow: var(--shadow);
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
transition: opacity 0.15s, transform 0.15s, visibility 0.15s; }
|
|
223
|
-
.nav-menu:hover .nav-menu__panel, .nav-menu:focus-within .nav-menu__panel {
|
|
224
|
-
opacity: 1; visibility: visible; transform: translateY(0); }
|
|
233
|
+
flex-direction: column; gap: 0.125rem; }
|
|
234
|
+
.nav-menu[open] .nav-menu__panel { display: flex; }
|
|
225
235
|
.nav-menu__item { display: flex; gap: 0.75rem; align-items: flex-start;
|
|
226
236
|
padding: 0.6rem 0.7rem; border-radius: 0.5rem; color: var(--fg); }
|
|
227
237
|
.nav-menu__item:hover { background: var(--bg-muted); color: var(--fg); }
|
|
@@ -247,6 +257,38 @@ export const APEX_NAV_MENU_CSS = `
|
|
|
247
257
|
.nav-menu__panel a.nav-menu__more:hover { color: var(--primary-hover); }
|
|
248
258
|
`;
|
|
249
259
|
|
|
260
|
+
// Standalone mobile-open rules for .nav-toggle, split out of RESPONSIVE so a
|
|
261
|
+
// consumer calling renderApexHeader() outside renderApexPage (money-horse,
|
|
262
|
+
// show-pony, the docs sample) can include just this alongside
|
|
263
|
+
// APEX_NAV_MENU_CSS, without pulling in the rest of RESPONSIVE's page-level
|
|
264
|
+
// grid breakpoints. The two "show" rules use the `.nav ` prefix (matching
|
|
265
|
+
// renderApexHeader's `<div class="container nav">` wrapper) to raise their
|
|
266
|
+
// specificity above APEX_NAV_MENU_CSS's bare `.nav-toggle`/`.nav-toggle__trigger`
|
|
267
|
+
// "display: none" defaults — that makes them win regardless of concatenation
|
|
268
|
+
// order, since a standalone consumer controls the order, not this file.
|
|
269
|
+
// Also declares `.nav { position: relative; }`: the open dropdown positions
|
|
270
|
+
// itself against .nav (`top/left/right` below), and renderApexPage's own
|
|
271
|
+
// CHROME_LIGHT already sets that, but a standalone consumer's own header CSS
|
|
272
|
+
// generally doesn't — without it the dropdown would anchor to the viewport
|
|
273
|
+
// instead of the header and open off-screen. Redeclaring it here is a no-op
|
|
274
|
+
// inside APEX_STRUCTURAL_CSS (same value, harmless if repeated).
|
|
275
|
+
export const APEX_NAV_TOGGLE_RESPONSIVE_CSS = `
|
|
276
|
+
@media (max-width: 640px) {
|
|
277
|
+
.nav { position: relative; }
|
|
278
|
+
.nav .nav-toggle { display: inline-flex; }
|
|
279
|
+
.nav .nav-toggle__trigger { display: inline-flex; }
|
|
280
|
+
.nav-toggle[open] > .nav-links { display: flex; flex-direction: column; align-items: stretch;
|
|
281
|
+
gap: 0.25rem; position: absolute; top: calc(100% + 0.5rem); left: 0; right: 0; z-index: 30;
|
|
282
|
+
background: var(--bg-card); border: 1px solid var(--border); border-radius: 0.75rem;
|
|
283
|
+
box-shadow: var(--shadow); padding: 0.75rem; }
|
|
284
|
+
.nav-toggle[open] > .nav-links > a { color: var(--fg); padding: 0.4rem 0.25rem; }
|
|
285
|
+
.nav-toggle[open] > .nav-links > a:hover { color: var(--fg); }
|
|
286
|
+
.nav-toggle .nav-menu { display: block; }
|
|
287
|
+
.nav-toggle .nav-menu__panel { position: static; min-width: 0;
|
|
288
|
+
margin-top: 0.375rem; box-shadow: none; }
|
|
289
|
+
}
|
|
290
|
+
`;
|
|
291
|
+
|
|
250
292
|
export const APEX_STRUCTURAL_CSS =
|
|
251
293
|
BASE_LAYOUT +
|
|
252
294
|
HERO +
|
|
@@ -257,4 +299,5 @@ export const APEX_STRUCTURAL_CSS =
|
|
|
257
299
|
CHROME_LIGHT +
|
|
258
300
|
CHROME_DARK +
|
|
259
301
|
APEX_NAV_MENU_CSS +
|
|
302
|
+
APEX_NAV_TOGGLE_RESPONSIVE_CSS +
|
|
260
303
|
RESPONSIVE;
|
package/src/apex/index.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { escapeHtml } from "../format";
|
|
|
8
8
|
import { APEX_STRUCTURAL_CSS } from "./css";
|
|
9
9
|
import { APEX_LIGHTBOX_HTML, APEX_LIGHTBOX_SCRIPT } from "./lightbox";
|
|
10
10
|
|
|
11
|
-
export { APEX_NAV_MENU_CSS, APEX_STRUCTURAL_CSS } from "./css";
|
|
11
|
+
export { APEX_NAV_MENU_CSS, APEX_NAV_TOGGLE_RESPONSIVE_CSS, APEX_STRUCTURAL_CSS } from "./css";
|
|
12
12
|
export {
|
|
13
13
|
APEX_LIGHTBOX_HTML,
|
|
14
14
|
APEX_LIGHTBOX_SCRIPT,
|
|
@@ -81,6 +81,8 @@ export type ApexHeader = {
|
|
|
81
81
|
/** Plain links and/or dropdown menus, in order. */
|
|
82
82
|
readonly navLinks?: readonly ApexNavEntry[];
|
|
83
83
|
readonly actions?: readonly ApexCta[];
|
|
84
|
+
/** aria-label for the mobile hamburger trigger (< 640px). Default "Menu". */
|
|
85
|
+
readonly menuLabel?: string;
|
|
84
86
|
};
|
|
85
87
|
|
|
86
88
|
export type ApexFooterColumn = { readonly heading: string; readonly links: readonly ApexLink[] };
|
|
@@ -398,7 +400,7 @@ function renderNavMenu(m: ApexNavMenu): string {
|
|
|
398
400
|
m.footer !== undefined
|
|
399
401
|
? `<div class="nav-menu__sep"></div><a class="nav-menu__more" href="${escapeHtml(m.footer.href)}">${escapeHtml(m.footer.label)}</a>`
|
|
400
402
|
: "";
|
|
401
|
-
return `<
|
|
403
|
+
return `<details class="nav-menu"><summary class="nav-menu__trigger">${escapeHtml(m.label)}<span class="nav-menu__chev">${svgIcon('<path d="m6 9 6 6 6-6"/>')}</span></summary><div class="nav-menu__panel">${itemsHtml}${footer}</div></details>`;
|
|
402
404
|
}
|
|
403
405
|
|
|
404
406
|
function renderNavEntry(entry: ApexNavEntry): string {
|
|
@@ -418,7 +420,7 @@ export function renderApexHeader(h: ApexHeader): string {
|
|
|
418
420
|
return `<header>
|
|
419
421
|
<div class="container nav">
|
|
420
422
|
<div class="brand"><a href="${escapeHtml(h.brand.href)}">${logo}${escapeHtml(h.brand.label)}</a></div>
|
|
421
|
-
${navLinksHtml !== "" ? `<nav class="nav-links">${navLinksHtml}</nav>` : ""}
|
|
423
|
+
${navLinksHtml !== "" ? `<nav class="nav-links">${navLinksHtml}</nav><details class="nav-toggle"><summary class="nav-toggle__trigger" aria-label="${escapeHtml(h.menuLabel ?? "Menu")}">${svgIcon('<path d="M4 6h16M4 12h16M4 18h16"/>')}</summary><nav class="nav-links">${navLinksHtml}</nav></details>` : ""}
|
|
422
424
|
${actionsHtml !== "" ? `<div class="nav-actions">${actionsHtml}</div>` : ""}
|
|
423
425
|
</div>
|
|
424
426
|
</header>`;
|