@cosmicdrift/kumiko-renderer-web 1.0.0 → 2.0.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.
Files changed (79) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/action-menu.test.tsx +75 -0
  3. package/src/__tests__/breadcrumb.test.tsx +50 -0
  4. package/src/__tests__/create-app.test.tsx +171 -1
  5. package/src/__tests__/dashboard-screen.test.tsx +393 -0
  6. package/src/__tests__/default-app-shell.test.tsx +47 -1
  7. package/src/__tests__/form-action-bar.test.tsx +9 -0
  8. package/src/__tests__/kumiko-screen.test.tsx +155 -1
  9. package/src/__tests__/lightbox.test.tsx +46 -0
  10. package/src/__tests__/nav-tree.test.tsx +122 -0
  11. package/src/__tests__/primitives.test.tsx +32 -1
  12. package/src/__tests__/profile-menu.test.tsx +29 -0
  13. package/src/__tests__/projection-detail.test.tsx +101 -0
  14. package/src/__tests__/projection-list-actions.test.tsx +131 -0
  15. package/src/__tests__/render-list.test.tsx +11 -3
  16. package/src/__tests__/test-utils.tsx +1 -0
  17. package/src/__tests__/toast.test.tsx +9 -9
  18. package/src/__tests__/use-mutation.test.tsx +86 -0
  19. package/src/__tests__/workspace-shell.test.tsx +102 -47
  20. package/src/__tests__/xss-safety.test.tsx +62 -0
  21. package/src/app/__tests__/first-open-screen-qn.test.ts +77 -0
  22. package/src/app/browser-locale.ts +1 -1
  23. package/src/app/create-app.tsx +96 -29
  24. package/src/app/dashboard-body.tsx +454 -0
  25. package/src/index.ts +75 -1
  26. package/src/layout/__tests__/nav-registry.test.ts +105 -0
  27. package/src/layout/__tests__/shell-breadcrumb.test.ts +82 -0
  28. package/src/layout/default-app-shell.tsx +9 -71
  29. package/src/layout/nav-tree.tsx +167 -37
  30. package/src/layout/shell-breadcrumb.ts +46 -0
  31. package/src/layout/shell-header.tsx +114 -0
  32. package/src/layout/workspace-shell.tsx +45 -18
  33. package/src/primitives/__tests__/data-table-logic.test.ts +27 -0
  34. package/src/primitives/__tests__/input-testid.test.tsx +45 -0
  35. package/src/primitives/__tests__/money-input.test.tsx +13 -1
  36. package/src/primitives/dialog.tsx +47 -76
  37. package/src/primitives/index.tsx +215 -37
  38. package/src/primitives/layout.tsx +39 -0
  39. package/src/primitives/lightbox.tsx +39 -0
  40. package/src/primitives/modal-shell.tsx +62 -0
  41. package/src/primitives/money-input.tsx +20 -10
  42. package/src/primitives/toast.tsx +14 -9
  43. package/src/sse/live-events.ts +6 -1
  44. package/src/styles.css +25 -0
  45. package/src/tokens.ts +4 -0
  46. package/src/ui/__tests__/avatar.test.tsx +66 -0
  47. package/src/ui/__tests__/sheet.test.tsx +102 -0
  48. package/src/version/__tests__/update-checker.test.tsx +115 -0
  49. package/src/widgets/__tests__/ai-text-field.test.tsx +267 -0
  50. package/src/widgets/__tests__/drawer.test.tsx +35 -0
  51. package/src/widgets/__tests__/form-kit.test.tsx +368 -0
  52. package/src/widgets/__tests__/infinity-list.test.tsx +132 -0
  53. package/src/widgets/__tests__/query-table.test.tsx +118 -0
  54. package/src/widgets/__tests__/result-panel.test.tsx +117 -0
  55. package/src/widgets/__tests__/widgets.test.tsx +243 -0
  56. package/src/widgets/ai-text-field.tsx +370 -0
  57. package/src/widgets/charts.tsx +228 -0
  58. package/src/widgets/collapsible-section.tsx +42 -0
  59. package/src/widgets/detail-list.tsx +43 -0
  60. package/src/widgets/drawer.tsx +49 -0
  61. package/src/widgets/feed-list.tsx +35 -0
  62. package/src/widgets/form-fields.tsx +326 -0
  63. package/src/widgets/index.ts +59 -0
  64. package/src/widgets/infinity-list.tsx +112 -0
  65. package/src/widgets/mode-switch.tsx +41 -0
  66. package/src/widgets/progress-bar.tsx +27 -0
  67. package/src/widgets/progress-list.tsx +38 -0
  68. package/src/widgets/query-table.tsx +103 -0
  69. package/src/widgets/result-panel.tsx +251 -0
  70. package/src/widgets/section-card.tsx +30 -0
  71. package/src/widgets/stat.tsx +181 -0
  72. package/src/widgets/states.tsx +89 -0
  73. package/src/widgets/status-badge.tsx +51 -0
  74. package/src/widgets/use-draft.ts +40 -0
  75. package/src/ui/card.tsx +0 -93
  76. package/src/ui/collapsible.tsx +0 -34
  77. package/src/ui/dropdown-menu.tsx +0 -258
  78. package/src/ui/select.tsx +0 -191
  79. /package/src/__tests__/{download.test.ts → download.test.tsx} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "1.0.0",
3
+ "version": "2.0.0",
4
4
  "description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -16,9 +16,9 @@
16
16
  "./styles.css": "./src/styles.css"
17
17
  },
18
18
  "dependencies": {
19
- "@cosmicdrift/kumiko-dispatcher-live": "1.0.0",
20
- "@cosmicdrift/kumiko-headless": "1.0.0",
21
- "@cosmicdrift/kumiko-renderer": "1.0.0",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "2.0.0",
20
+ "@cosmicdrift/kumiko-headless": "2.0.0",
21
+ "@cosmicdrift/kumiko-renderer": "2.0.0",
22
22
  "@radix-ui/react-dialog": "^1.1.15",
23
23
  "@radix-ui/react-dropdown-menu": "^2.1.16",
24
24
  "@radix-ui/react-label": "^2.1.8",
@@ -0,0 +1,75 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+ import { render, screen, waitFor } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { ActionMenu, type MenuItemDef } from "../primitives/action-menu";
5
+
6
+ describe("ActionMenu", () => {
7
+ test("renders trigger with aria-label and default testId", () => {
8
+ render(
9
+ <ActionMenu
10
+ trigger={<span>Open</span>}
11
+ triggerLabel="More actions"
12
+ items={[{ kind: "item", id: "a", label: "Alpha", onSelect: () => {} }]}
13
+ />,
14
+ );
15
+ const trigger = screen.getByTestId("action-menu-trigger");
16
+ expect(trigger.getAttribute("aria-label")).toBe("More actions");
17
+ expect(screen.getByText("Open")).toBeTruthy();
18
+ });
19
+
20
+ test("opens menu: item, separator, label, shortcut, danger, icon", async () => {
21
+ const user = userEvent.setup();
22
+ const onSelect = mock(() => {});
23
+ const items: MenuItemDef[] = [
24
+ { kind: "label", label: "Section" },
25
+ {
26
+ kind: "item",
27
+ id: "edit",
28
+ label: "Edit",
29
+ icon: <span data-testid="edit-icon">✎</span>,
30
+ shortcut: "⌘E",
31
+ onSelect,
32
+ },
33
+ { kind: "separator" },
34
+ {
35
+ kind: "item",
36
+ id: "delete",
37
+ label: "Delete",
38
+ variant: "danger",
39
+ onSelect: () => {},
40
+ },
41
+ {
42
+ kind: "item",
43
+ id: "disabled",
44
+ label: "Disabled",
45
+ disabled: true,
46
+ onSelect: () => {},
47
+ },
48
+ ];
49
+
50
+ render(
51
+ <ActionMenu
52
+ trigger={<span>Menu</span>}
53
+ items={items}
54
+ align="start"
55
+ minWidth="12rem"
56
+ testId="am"
57
+ />,
58
+ );
59
+ await user.click(screen.getByTestId("am"));
60
+
61
+ await waitFor(() => {
62
+ expect(screen.getByText("Section")).toBeTruthy();
63
+ expect(screen.getByTestId("action-menu-item-edit")).toBeTruthy();
64
+ expect(screen.getByTestId("edit-icon")).toBeTruthy();
65
+ expect(screen.getByText("⌘E")).toBeTruthy();
66
+ expect(screen.getByTestId("action-menu-item-delete").className).toContain("text-destructive");
67
+ expect(screen.getByTestId("action-menu-item-disabled").getAttribute("data-disabled")).toBe(
68
+ "",
69
+ );
70
+ });
71
+
72
+ await user.click(screen.getByTestId("action-menu-item-edit"));
73
+ expect(onSelect).toHaveBeenCalled();
74
+ });
75
+ });
@@ -0,0 +1,50 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { render, screen } from "@testing-library/react";
3
+ import {
4
+ Breadcrumb,
5
+ BreadcrumbEllipsis,
6
+ BreadcrumbItem,
7
+ BreadcrumbLink,
8
+ BreadcrumbList,
9
+ BreadcrumbPage,
10
+ BreadcrumbSeparator,
11
+ } from "../ui/breadcrumb";
12
+
13
+ describe("Breadcrumb", () => {
14
+ test("renders nav list with links, separators, page, ellipsis", () => {
15
+ render(
16
+ <Breadcrumb>
17
+ <BreadcrumbList>
18
+ <BreadcrumbItem>
19
+ <BreadcrumbLink href="/">Home</BreadcrumbLink>
20
+ </BreadcrumbItem>
21
+ <BreadcrumbSeparator />
22
+ <BreadcrumbItem>
23
+ <BreadcrumbEllipsis />
24
+ </BreadcrumbItem>
25
+ <BreadcrumbSeparator>
26
+ <span data-testid="custom-sep">/</span>
27
+ </BreadcrumbSeparator>
28
+ <BreadcrumbItem>
29
+ <BreadcrumbLink asChild>
30
+ <a href="/docs">Docs</a>
31
+ </BreadcrumbLink>
32
+ </BreadcrumbItem>
33
+ <BreadcrumbSeparator />
34
+ <BreadcrumbItem>
35
+ <BreadcrumbPage>Current</BreadcrumbPage>
36
+ </BreadcrumbItem>
37
+ </BreadcrumbList>
38
+ </Breadcrumb>,
39
+ );
40
+
41
+ expect(screen.getByLabelText("breadcrumb")).toBeTruthy();
42
+ expect(screen.getByText("Home").getAttribute("href")).toBe("/");
43
+ expect(screen.getByText("More")).toBeTruthy();
44
+ expect(screen.getByTestId("custom-sep")).toBeTruthy();
45
+ expect(screen.getByText("Docs").getAttribute("href")).toBe("/docs");
46
+ const page = screen.getByText("Current");
47
+ expect(page.getAttribute("aria-current")).toBe("page");
48
+ expect(page.getAttribute("aria-disabled")).toBe("true");
49
+ });
50
+ });
@@ -1,11 +1,18 @@
1
1
  import { afterAll, afterEach, describe, expect, spyOn, test } from "bun:test";
2
2
  import type {
3
+ DashboardScreenDefinition,
3
4
  EntityDefinition,
4
5
  EntityEditScreenDefinition,
5
6
  EntityListScreenDefinition,
6
7
  } from "@cosmicdrift/kumiko-framework/ui-types";
7
8
  import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
8
- import type { ColumnRendererProps, FeatureSchema, NavApi } from "@cosmicdrift/kumiko-renderer";
9
+ import type {
10
+ AppSchema,
11
+ ColumnRendererProps,
12
+ FeatureSchema,
13
+ NavApi,
14
+ } from "@cosmicdrift/kumiko-renderer";
15
+ import { createStaticLocaleResolver } from "@cosmicdrift/kumiko-renderer";
9
16
  import { act, screen, waitFor } from "@testing-library/react";
10
17
  import type { ReactNode } from "react";
11
18
  import type { ClientFeatureDefinition } from "../app/client-plugin";
@@ -54,6 +61,7 @@ const baseSchema: FeatureSchema = {
54
61
  featureName: "tasks",
55
62
  entities: { task: taskEntity },
56
63
  screens: [editScreen, listScreen],
64
+ navs: [{ id: "task-edit", label: "tasks:nav.task-edit", screen: "tasks:screen:task-edit" }],
57
65
  };
58
66
 
59
67
  // createKumikoApp ruft createRoot(...).render(...) direkt auf — React 18+
@@ -148,6 +156,55 @@ describe("createKumikoApp", () => {
148
156
  );
149
157
  });
150
158
 
159
+ test("fallback screen skips access-restricted screens across features (#1176)", async () => {
160
+ mountRoot();
161
+ const restrictedFeature: FeatureSchema = {
162
+ featureName: "user",
163
+ entities: { task: taskEntity },
164
+ screens: [{ ...listScreen, id: "user-list", access: { roles: ["SystemAdmin"] } }],
165
+ };
166
+ const openFeature: FeatureSchema = { ...baseSchema, featureName: "tasks" };
167
+ const schema: AppSchema = { features: [restrictedFeature, openFeature] };
168
+ await mountApp({ schema, dispatcher: makeDispatcher() });
169
+ // Landing on user-list (SystemAdmin-only) would show an access-denied
170
+ // banner instead — the tasks:task-edit form must win instead.
171
+ await waitFor(() => expect(screen.getByTestId("render-edit-form")).toBeTruthy());
172
+ });
173
+
174
+ test("fallback screen: an openToAll screen counts as accessible", async () => {
175
+ mountRoot();
176
+ const restrictedFeature: FeatureSchema = {
177
+ featureName: "user",
178
+ entities: { task: taskEntity },
179
+ screens: [{ ...editScreen, id: "user-edit", access: { roles: ["SystemAdmin"] } }],
180
+ };
181
+ const openFeature: FeatureSchema = {
182
+ featureName: "tasks",
183
+ entities: { task: taskEntity },
184
+ screens: [{ ...listScreen, access: { openToAll: true } }],
185
+ navs: [{ id: "task-list", label: "tasks:nav.task-list", screen: "tasks:screen:task-list" }],
186
+ };
187
+ const schema: AppSchema = { features: [restrictedFeature, openFeature] };
188
+ await mountApp({ schema, dispatcher: makeDispatcher() });
189
+ expect(await screen.findByTestId("render-list-table-empty")).toBeTruthy();
190
+ });
191
+
192
+ test("all screens access-restricted → throws (no accessible fallback)", () => {
193
+ mountRoot();
194
+ const restrictedOnly: AppSchema = {
195
+ features: [
196
+ {
197
+ featureName: "user",
198
+ entities: { task: taskEntity },
199
+ screens: [{ ...editScreen, access: { roles: ["SystemAdmin"] } }],
200
+ },
201
+ ],
202
+ };
203
+ expect(() => createKumikoApp({ schema: restrictedOnly, dispatcher: makeDispatcher() })).toThrow(
204
+ /no screens/,
205
+ );
206
+ });
207
+
151
208
  test("clientFeatures.columnRenderers → bei Key-Kollision warnt + last-wins gewinnt", async () => {
152
209
  // Zwei Features liefern denselben Renderer-Key — der Merge in
153
210
  // create-app warnt und behält den späteren Eintrag (Last-Wins).
@@ -175,6 +232,9 @@ describe("createKumikoApp", () => {
175
232
  columns: [{ field: "color", renderer: { react: { __component: "Swatch" } } }],
176
233
  },
177
234
  ],
235
+ navs: [
236
+ { id: "color-list", label: "tasks:nav.color-list", screen: "tasks:screen:color-list" },
237
+ ],
178
238
  };
179
239
 
180
240
  mountRoot();
@@ -228,6 +288,9 @@ describe("createKumikoApp", () => {
228
288
  featureName: "tasks",
229
289
  entities: { task: colorEntity },
230
290
  screens: [colorListScreen],
291
+ navs: [
292
+ { id: "color-list", label: "tasks:nav.color-list", screen: "tasks:screen:color-list" },
293
+ ],
231
294
  };
232
295
  const dispatcher = createMockDispatcher({
233
296
  query: (async () => ({
@@ -252,6 +315,113 @@ describe("createKumikoApp", () => {
252
315
  expect(screen.getByTestId("ca-swatch-field").textContent).toBe("color");
253
316
  });
254
317
 
318
+ test("schema.translations (r.translations, #1059) resolves nav/dashboard labels without any clientFeatures duplication", async () => {
319
+ // Mirrors cap-counter's real shape: a feature that declares r.translations
320
+ // for its own nav label but ships NO web/i18n.ts / clientFeatures entry at
321
+ // all. Before #1059 this label rendered as the raw i18n key forever.
322
+ const dashboardScreen: DashboardScreenDefinition = {
323
+ id: "overview",
324
+ type: "dashboard",
325
+ panels: [
326
+ {
327
+ kind: "stat",
328
+ id: "cap-list",
329
+ label: "cap-counter:nav.cap-list",
330
+ query: "cap-counter:query:stat",
331
+ valueField: "value",
332
+ },
333
+ ],
334
+ };
335
+ const schema: FeatureSchema = {
336
+ featureName: "cap-counter",
337
+ entities: {},
338
+ screens: [dashboardScreen],
339
+ navs: [
340
+ {
341
+ id: "overview",
342
+ label: "cap-counter:nav.cap-list",
343
+ screen: "cap-counter:screen:overview",
344
+ },
345
+ ],
346
+ translations: {
347
+ "cap-counter:nav.cap-list": { de: "Limits", en: "Caps" },
348
+ },
349
+ };
350
+ const dispatcher = createMockDispatcher({
351
+ query: (async () => ({
352
+ isSuccess: true,
353
+ data: { value: "3" },
354
+ })) as unknown as Dispatcher["query"],
355
+ });
356
+
357
+ mountRoot();
358
+ await mountApp({
359
+ schema,
360
+ dispatcher,
361
+ locale: createStaticLocaleResolver({ locale: "en" }),
362
+ });
363
+
364
+ await waitFor(() => expect(screen.getByText("Caps")).toBeTruthy());
365
+ expect(screen.queryByText("cap-counter:nav.cap-list")).toBeNull();
366
+ });
367
+
368
+ test("clientFeatures.translations still overrides schema.translations for the same key (precedence)", async () => {
369
+ // App-Override muss auch gegen ein framework-eigenes Label gewinnen —
370
+ // sonst kann eine App ein bundled-feature-Label nicht mehr anpassen,
371
+ // sobald #1059 dessen r.translations automatisch mitliefert.
372
+ const dashboardScreen: DashboardScreenDefinition = {
373
+ id: "overview",
374
+ type: "dashboard",
375
+ panels: [
376
+ {
377
+ kind: "stat",
378
+ id: "cap-list",
379
+ label: "cap-counter:nav.cap-list",
380
+ query: "cap-counter:query:stat",
381
+ valueField: "value",
382
+ },
383
+ ],
384
+ };
385
+ const schema: FeatureSchema = {
386
+ featureName: "cap-counter",
387
+ entities: {},
388
+ screens: [dashboardScreen],
389
+ navs: [
390
+ {
391
+ id: "overview",
392
+ label: "cap-counter:nav.cap-list",
393
+ screen: "cap-counter:screen:overview",
394
+ },
395
+ ],
396
+ translations: {
397
+ "cap-counter:nav.cap-list": { de: "Limits", en: "Caps" },
398
+ },
399
+ };
400
+ const dispatcher = createMockDispatcher({
401
+ query: (async () => ({
402
+ isSuccess: true,
403
+ data: { value: "3" },
404
+ })) as unknown as Dispatcher["query"],
405
+ });
406
+ const override: ClientFeatureDefinition = {
407
+ name: "cap-counter-app-override",
408
+ translations: {
409
+ en: { "cap-counter:nav.cap-list": "Quota" },
410
+ },
411
+ };
412
+
413
+ mountRoot();
414
+ await mountApp({
415
+ schema,
416
+ dispatcher,
417
+ clientFeatures: [override],
418
+ locale: createStaticLocaleResolver({ locale: "en" }),
419
+ });
420
+
421
+ await waitFor(() => expect(screen.getByText("Quota")).toBeTruthy());
422
+ expect(screen.queryByText("Caps")).toBeNull();
423
+ });
424
+
255
425
  test("navAdapter override: eigener Router steuert den aktiven Screen", async () => {
256
426
  // Beweist den Nav-Seam: der Default-Adapter liest location.pathname,
257
427
  // dieser Memory-Adapter hardcoded die Route. Wenn swap funktioniert,