@cosmicdrift/kumiko-renderer-web 0.1.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 (58) hide show
  1. package/package.json +63 -0
  2. package/src/__tests__/avatar.test.tsx +34 -0
  3. package/src/__tests__/combobox.test.tsx +240 -0
  4. package/src/__tests__/config-edit.test.tsx +172 -0
  5. package/src/__tests__/create-app.test.tsx +261 -0
  6. package/src/__tests__/date-input.test.tsx +91 -0
  7. package/src/__tests__/default-app-shell.test.tsx +60 -0
  8. package/src/__tests__/dispatcher-context.test.tsx +101 -0
  9. package/src/__tests__/dispatcher-status-wiring.test.tsx +119 -0
  10. package/src/__tests__/kumiko-screen.test.tsx +1014 -0
  11. package/src/__tests__/language-switcher.test.tsx +100 -0
  12. package/src/__tests__/money-input.test.tsx +232 -0
  13. package/src/__tests__/nav-base-path.test.tsx +388 -0
  14. package/src/__tests__/nav-search-params.test.tsx +88 -0
  15. package/src/__tests__/nav-tree.test.tsx +183 -0
  16. package/src/__tests__/nav.test.tsx +253 -0
  17. package/src/__tests__/primitives.test.tsx +936 -0
  18. package/src/__tests__/render-edit.test.tsx +178 -0
  19. package/src/__tests__/render-list-column-renderer.test.tsx +124 -0
  20. package/src/__tests__/render-list-debounce.test.tsx +128 -0
  21. package/src/__tests__/render-list.test.tsx +151 -0
  22. package/src/__tests__/sidebar.test.tsx +59 -0
  23. package/src/__tests__/test-utils.tsx +144 -0
  24. package/src/__tests__/theme-toggle.test.tsx +101 -0
  25. package/src/__tests__/toast.test.tsx +162 -0
  26. package/src/__tests__/use-form.test.tsx +112 -0
  27. package/src/__tests__/use-query-live.test.tsx +152 -0
  28. package/src/__tests__/use-query.test.tsx +88 -0
  29. package/src/__tests__/use-store.test.tsx +139 -0
  30. package/src/__tests__/workspace-shell.test.tsx +772 -0
  31. package/src/app/browser-locale.ts +85 -0
  32. package/src/app/client-plugin.tsx +63 -0
  33. package/src/app/create-app.tsx +380 -0
  34. package/src/app/nav.tsx +226 -0
  35. package/src/index.ts +137 -0
  36. package/src/layout/app-layout.tsx +35 -0
  37. package/src/layout/avatar.tsx +93 -0
  38. package/src/layout/default-app-shell.tsx +74 -0
  39. package/src/layout/language-switcher.tsx +101 -0
  40. package/src/layout/nav-tree.tsx +281 -0
  41. package/src/layout/profile-menu.tsx +40 -0
  42. package/src/layout/sidebar.tsx +65 -0
  43. package/src/layout/theme-toggle.tsx +44 -0
  44. package/src/layout/topbar.tsx +22 -0
  45. package/src/layout/workspace-shell.tsx +282 -0
  46. package/src/layout/workspace-switcher.tsx +62 -0
  47. package/src/lib/cn.ts +10 -0
  48. package/src/primitives/action-menu.tsx +111 -0
  49. package/src/primitives/combobox.tsx +261 -0
  50. package/src/primitives/date-input.tsx +165 -0
  51. package/src/primitives/dialog.tsx +119 -0
  52. package/src/primitives/dropdown-menu.tsx +103 -0
  53. package/src/primitives/index.tsx +1271 -0
  54. package/src/primitives/money-input.tsx +192 -0
  55. package/src/primitives/toast.tsx +166 -0
  56. package/src/sse/live-events.ts +90 -0
  57. package/src/styles.css +113 -0
  58. package/src/tokens.ts +63 -0
@@ -0,0 +1,253 @@
1
+ // @vitest-environment jsdom
2
+ import { formatPath, NavProvider, parsePath, useNav } from "@cosmicdrift/kumiko-renderer";
3
+ import { act, fireEvent, render, screen } from "@testing-library/react";
4
+ import type { ReactNode } from "react";
5
+ import { beforeEach, describe, expect, test } from "vitest";
6
+ import { KumikoLink, useBrowserNavApi } from "../app/nav";
7
+
8
+ describe("parsePath", () => {
9
+ test("leerer Pfad / → undefined", () => {
10
+ expect(parsePath("/")).toBeUndefined();
11
+ });
12
+
13
+ test("/<screenId> → { screenId }", () => {
14
+ expect(parsePath("/task-list")).toEqual({ screenId: "task-list" });
15
+ });
16
+
17
+ test("/<screenId>/<entityId> → { screenId, entityId }", () => {
18
+ expect(parsePath("/task-edit/abc-123")).toEqual({
19
+ screenId: "task-edit",
20
+ entityId: "abc-123",
21
+ });
22
+ });
23
+
24
+ test("trailing-slash wird toleriert", () => {
25
+ expect(parsePath("/task-list/")).toEqual({ screenId: "task-list" });
26
+ });
27
+
28
+ test("zusätzliche segmente werden ignoriert (kein nested routing)", () => {
29
+ expect(parsePath("/task-edit/abc-123/extra/segments")).toEqual({
30
+ screenId: "task-edit",
31
+ entityId: "abc-123",
32
+ });
33
+ });
34
+ });
35
+
36
+ describe("formatPath", () => {
37
+ test("nur screenId", () => {
38
+ expect(formatPath({ screenId: "task-list" })).toBe("/task-list");
39
+ });
40
+
41
+ test("screenId + entityId", () => {
42
+ expect(formatPath({ screenId: "task-edit", entityId: "abc-123" })).toBe("/task-edit/abc-123");
43
+ });
44
+
45
+ test("workspaceId prefix bei Workspace-Mode", () => {
46
+ expect(formatPath({ workspaceId: "admin", screenId: "task-list" })).toBe("/admin/task-list");
47
+ });
48
+
49
+ test("workspaceId + screenId + entityId", () => {
50
+ expect(
51
+ formatPath({ workspaceId: "dispatch", screenId: "order-edit", entityId: "abc-123" }),
52
+ ).toBe("/dispatch/order-edit/abc-123");
53
+ });
54
+ });
55
+
56
+ describe("parsePath — workspace mode", () => {
57
+ test("/<workspaceId>/<screenId> → trägt beide", () => {
58
+ expect(parsePath("/admin/task-list", true)).toEqual({
59
+ workspaceId: "admin",
60
+ screenId: "task-list",
61
+ });
62
+ });
63
+
64
+ test("/<workspaceId>/<screenId>/<entityId> → mit entityId", () => {
65
+ expect(parsePath("/admin/task-edit/abc-123", true)).toEqual({
66
+ workspaceId: "admin",
67
+ screenId: "task-edit",
68
+ entityId: "abc-123",
69
+ });
70
+ });
71
+
72
+ test("/<workspaceId> ohne screen → screenId leer (caller resolved Default)", () => {
73
+ expect(parsePath("/admin", true)).toEqual({ workspaceId: "admin", screenId: "" });
74
+ });
75
+
76
+ test("/ → undefined auch im Workspace-Mode", () => {
77
+ expect(parsePath("/", true)).toBeUndefined();
78
+ });
79
+ });
80
+
81
+ // Wrapper der das web-spezifische useBrowserNavApi aufruft und in
82
+ // einen shared-NavProvider durchreicht. Das ist genau das was
83
+ // createKumikoApp intern macht.
84
+ function BrowserNav({ children }: { readonly children: ReactNode }): ReactNode {
85
+ const api = useBrowserNavApi();
86
+ return <NavProvider value={api}>{children}</NavProvider>;
87
+ }
88
+
89
+ describe("useBrowserNavApi + NavProvider", () => {
90
+ beforeEach(() => {
91
+ // jsdom teilt window.history zwischen Tests — auf / zurücksetzen,
92
+ // sonst leaken Routen aus vorigen Tests in die nächsten.
93
+ window.history.replaceState(null, "", "/");
94
+ });
95
+
96
+ function Probe(): React.ReactElement {
97
+ const nav = useNav();
98
+ return (
99
+ <div>
100
+ <span data-testid="screen-id">{nav.route?.screenId ?? "(none)"}</span>
101
+ <span data-testid="entity-id">{nav.route?.entityId ?? "(none)"}</span>
102
+ <button
103
+ type="button"
104
+ data-testid="go-list"
105
+ onClick={() => nav.navigate({ screenId: "task-list" })}
106
+ >
107
+ go-list
108
+ </button>
109
+ <button
110
+ type="button"
111
+ data-testid="go-edit"
112
+ onClick={() => nav.navigate({ screenId: "task-edit", entityId: "xyz" })}
113
+ >
114
+ go-edit
115
+ </button>
116
+ <button
117
+ type="button"
118
+ data-testid="replace-list"
119
+ onClick={() => nav.replace({ screenId: "task-list" })}
120
+ >
121
+ replace-list
122
+ </button>
123
+ </div>
124
+ );
125
+ }
126
+
127
+ test("initial-route aus window.location.pathname", () => {
128
+ window.history.replaceState(null, "", "/task-list");
129
+ render(
130
+ <BrowserNav>
131
+ <Probe />
132
+ </BrowserNav>,
133
+ );
134
+ expect(screen.getByTestId("screen-id").textContent).toBe("task-list");
135
+ expect(screen.getByTestId("entity-id").textContent).toBe("(none)");
136
+ });
137
+
138
+ test("navigate() aktualisiert location + re-rendert", () => {
139
+ render(
140
+ <BrowserNav>
141
+ <Probe />
142
+ </BrowserNav>,
143
+ );
144
+ expect(screen.getByTestId("screen-id").textContent).toBe("(none)");
145
+
146
+ act(() => {
147
+ fireEvent.click(screen.getByTestId("go-edit"));
148
+ });
149
+
150
+ expect(window.location.pathname).toBe("/task-edit/xyz");
151
+ expect(screen.getByTestId("screen-id").textContent).toBe("task-edit");
152
+ expect(screen.getByTestId("entity-id").textContent).toBe("xyz");
153
+ });
154
+
155
+ test("replace() aktualisiert location ohne History-Eintrag", () => {
156
+ render(
157
+ <BrowserNav>
158
+ <Probe />
159
+ </BrowserNav>,
160
+ );
161
+ const before = window.history.length;
162
+ act(() => {
163
+ fireEvent.click(screen.getByTestId("replace-list"));
164
+ });
165
+ expect(window.location.pathname).toBe("/task-list");
166
+ expect(screen.getByTestId("screen-id").textContent).toBe("task-list");
167
+ // Das ist der Unterschied zu navigate(): keine zusätzliche History-
168
+ // Stufe. Browser-Back springt damit zur Origin-Seite zurück, nicht
169
+ // auf die alte URL — wichtig für Mount-Time URL-Fills wie in
170
+ // WorkspaceShell, wo der User die alte URL nie gewählt hat.
171
+ expect(window.history.length).toBe(before);
172
+ });
173
+
174
+ test("popstate (Browser-Back) re-rendert die aktuelle Route", () => {
175
+ render(
176
+ <BrowserNav>
177
+ <Probe />
178
+ </BrowserNav>,
179
+ );
180
+ act(() => {
181
+ fireEvent.click(screen.getByTestId("go-list"));
182
+ });
183
+ expect(screen.getByTestId("screen-id").textContent).toBe("task-list");
184
+
185
+ // Simulate Back-Button: history.replaceState statt back() — jsdom's
186
+ // back() feuert nicht immer synchron popstate. Wir dispatchen das
187
+ // Event manuell, genau wie es der Browser tun würde.
188
+ act(() => {
189
+ window.history.replaceState(null, "", "/");
190
+ window.dispatchEvent(new PopStateEvent("popstate"));
191
+ });
192
+ expect(screen.getByTestId("screen-id").textContent).toBe("(none)");
193
+ });
194
+ });
195
+
196
+ describe("KumikoLink", () => {
197
+ beforeEach(() => {
198
+ window.history.replaceState(null, "", "/");
199
+ });
200
+
201
+ test("rendert <a> mit korrekter href", () => {
202
+ render(
203
+ <BrowserNav>
204
+ <KumikoLink to={{ screenId: "task-edit", entityId: "xyz" }}>Edit</KumikoLink>
205
+ </BrowserNav>,
206
+ );
207
+ const anchor = screen.getByText("Edit") as HTMLAnchorElement;
208
+ expect(anchor.tagName).toBe("A");
209
+ expect(anchor.getAttribute("href")).toBe("/task-edit/xyz");
210
+ });
211
+
212
+ test("left-click wird abgefangen → navigate() statt full reload", () => {
213
+ render(
214
+ <BrowserNav>
215
+ <KumikoLink to={{ screenId: "task-list" }}>Liste</KumikoLink>
216
+ </BrowserNav>,
217
+ );
218
+ act(() => {
219
+ fireEvent.click(screen.getByText("Liste"), { button: 0 });
220
+ });
221
+ expect(window.location.pathname).toBe("/task-list");
222
+ });
223
+
224
+ test("meta-click (Cmd/Ctrl) wird NICHT abgefangen — Browser öffnet in neuem Tab", () => {
225
+ render(
226
+ <BrowserNav>
227
+ <KumikoLink to={{ screenId: "task-list" }}>Liste</KumikoLink>
228
+ </BrowserNav>,
229
+ );
230
+ const anchor = screen.getByText("Liste") as HTMLAnchorElement;
231
+ let kumikoLinkPreventedDefault: boolean | undefined;
232
+ const observer = (e: Event) => {
233
+ kumikoLinkPreventedDefault = e.defaultPrevented;
234
+ e.preventDefault(); // silence jsdom nav
235
+ };
236
+ anchor.addEventListener("click", observer);
237
+ try {
238
+ act(() => {
239
+ anchor.dispatchEvent(
240
+ new MouseEvent("click", {
241
+ bubbles: true,
242
+ cancelable: true,
243
+ button: 0,
244
+ metaKey: true,
245
+ }),
246
+ );
247
+ });
248
+ } finally {
249
+ anchor.removeEventListener("click", observer);
250
+ }
251
+ expect(kumikoLinkPreventedDefault).toBe(false);
252
+ });
253
+ });