@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,88 @@
1
+ // @vitest-environment jsdom
2
+ import type { Dispatcher, DispatcherError } from "@cosmicdrift/kumiko-headless";
3
+ import { DispatcherProvider, useQuery } from "@cosmicdrift/kumiko-renderer";
4
+ import type { ReactNode } from "react";
5
+ import { describe, expect, test, vi } from "vitest";
6
+ import { act, createMockDispatcher, renderHook, waitFor } from "./test-utils";
7
+
8
+ function makeDispatcher(queryFn?: Dispatcher["query"]): Dispatcher {
9
+ return createMockDispatcher({ query: queryFn });
10
+ }
11
+
12
+ function wrap(dispatcher: Dispatcher) {
13
+ return ({ children }: { children: ReactNode }) => (
14
+ <DispatcherProvider dispatcher={dispatcher}>{children}</DispatcherProvider>
15
+ );
16
+ }
17
+
18
+ describe("useQuery", () => {
19
+ test("loads on mount; populates data, flips loading to false", async () => {
20
+ const query = vi.fn(
21
+ async () => ({ isSuccess: true, data: [{ id: "1" }, { id: "2" }] }) as never,
22
+ );
23
+ const dispatcher = makeDispatcher(query as unknown as Dispatcher["query"]);
24
+ const { result } = renderHook(() => useQuery("task:list", {}), {
25
+ wrapper: wrap(dispatcher),
26
+ });
27
+
28
+ // First tick: loading state.
29
+ expect(result.current.loading).toBe(true);
30
+ expect(result.current.data).toBeNull();
31
+
32
+ await waitFor(() => expect(result.current.loading).toBe(false));
33
+ expect(result.current.data).toEqual([{ id: "1" }, { id: "2" }]);
34
+ expect(result.current.error).toBeNull();
35
+ expect(query).toHaveBeenCalledOnce();
36
+ });
37
+
38
+ test("server error surfaces via `error`; data stays null", async () => {
39
+ const err: DispatcherError = {
40
+ code: "not_found",
41
+ httpStatus: 404,
42
+ i18nKey: "errors.not_found",
43
+ message: "no",
44
+ };
45
+ const query = vi.fn(async () => ({ isSuccess: false, error: err }) as never);
46
+ const { result } = renderHook(() => useQuery("task:list", {}), {
47
+ wrapper: wrap(makeDispatcher(query as unknown as Dispatcher["query"])),
48
+ });
49
+
50
+ await waitFor(() => expect(result.current.loading).toBe(false));
51
+ expect(result.current.data).toBeNull();
52
+ expect(result.current.error?.code).toBe("not_found");
53
+ });
54
+
55
+ test("enabled:false skips the auto-fetch until refetch is called", async () => {
56
+ const query = vi.fn(async () => ({ isSuccess: true, data: ["hi"] }) as never);
57
+ const { result } = renderHook(() => useQuery("task:list", {}, { enabled: false }), {
58
+ wrapper: wrap(makeDispatcher(query as unknown as Dispatcher["query"])),
59
+ });
60
+
61
+ await waitFor(() => expect(result.current.loading).toBe(false));
62
+ expect(query).not.toHaveBeenCalled();
63
+ expect(result.current.data).toBeNull();
64
+
65
+ await act(async () => {
66
+ await result.current.refetch();
67
+ });
68
+ expect(query).toHaveBeenCalledOnce();
69
+ expect(result.current.data).toEqual(["hi"]);
70
+ });
71
+
72
+ test("refetch re-runs and replaces data (after-mutation reload pattern)", async () => {
73
+ let callCount = 0;
74
+ const query = vi.fn(async () => {
75
+ callCount += 1;
76
+ return { isSuccess: true, data: [callCount] } as never;
77
+ });
78
+ const { result } = renderHook(() => useQuery<number[]>("task:list", {}), {
79
+ wrapper: wrap(makeDispatcher(query as unknown as Dispatcher["query"])),
80
+ });
81
+
82
+ await waitFor(() => expect(result.current.data).toEqual([1]));
83
+ await act(async () => {
84
+ await result.current.refetch();
85
+ });
86
+ expect(result.current.data).toEqual([2]);
87
+ });
88
+ });
@@ -0,0 +1,139 @@
1
+ // @vitest-environment jsdom
2
+ import { createStore, shallowEqual } from "@cosmicdrift/kumiko-headless";
3
+ import { useStore, useStoreSelector } from "@cosmicdrift/kumiko-renderer";
4
+ import { describe, expect, test, vi } from "vitest";
5
+ import { act, renderHook } from "./test-utils";
6
+
7
+ describe("useStore", () => {
8
+ test("returns the current snapshot", () => {
9
+ const store = createStore({ count: 0 });
10
+ const { result } = renderHook(() => useStore(store));
11
+ expect(result.current).toEqual({ count: 0 });
12
+ });
13
+
14
+ test("re-renders when setState changes the snapshot", () => {
15
+ const store = createStore({ count: 0 });
16
+ const { result } = renderHook(() => useStore(store));
17
+
18
+ act(() => {
19
+ store.setState({ count: 7 });
20
+ });
21
+
22
+ expect(result.current).toEqual({ count: 7 });
23
+ });
24
+
25
+ test("does not re-render when setState is a no-op (Object.is gate)", () => {
26
+ const store = createStore({ count: 0 });
27
+ let renderCount = 0;
28
+ renderHook(() => {
29
+ renderCount += 1;
30
+ return useStore(store);
31
+ });
32
+
33
+ const before = renderCount;
34
+ act(() => {
35
+ store.setState(store.getSnapshot()); // same ref
36
+ });
37
+
38
+ expect(renderCount).toBe(before);
39
+ });
40
+ });
41
+
42
+ describe("useStoreSelector", () => {
43
+ test("returns the selected slice", () => {
44
+ const store = createStore({ a: 1, b: 2 });
45
+ const { result } = renderHook(() => useStoreSelector(store, (s) => s.a));
46
+ expect(result.current).toBe(1);
47
+ });
48
+
49
+ test("re-renders only when the selected slice changes", () => {
50
+ const store = createStore({ a: 1, b: 2 });
51
+ let renderCount = 0;
52
+ const { result } = renderHook(() => {
53
+ renderCount += 1;
54
+ return useStoreSelector(store, (s) => s.a);
55
+ });
56
+
57
+ const beforeA = renderCount;
58
+ act(() => {
59
+ store.setState({ a: 1, b: 99 }); // unrelated slice changed
60
+ });
61
+ expect(renderCount).toBe(beforeA); // no re-render — `a` unchanged
62
+ expect(result.current).toBe(1);
63
+
64
+ act(() => {
65
+ store.setState({ a: 5, b: 99 }); // selected slice changed
66
+ });
67
+ expect(renderCount).toBe(beforeA + 1);
68
+ expect(result.current).toBe(5);
69
+ });
70
+
71
+ test("default Object.is equality re-renders on every object-literal selector return", () => {
72
+ // This documents the trap that motivates the optional `equals` arg.
73
+ // Without a custom equality, a selector returning `{ a, b }` would
74
+ // produce a new object identity each notify and re-render forever.
75
+ // This test asserts that with the DEFAULT (Object.is), a stable-
76
+ // valued slice still works because the slice IS Object.is-equal.
77
+ const store = createStore({ a: 1, b: 2 });
78
+ let renderCount = 0;
79
+ renderHook(() => {
80
+ renderCount += 1;
81
+ return useStoreSelector(store, (s) => s.a + s.b);
82
+ });
83
+
84
+ const before = renderCount;
85
+ act(() => {
86
+ store.setState({ a: 1, b: 2 }); // same shape, but different ref
87
+ });
88
+ // Store's Object.is-gate blocks the notify entirely (same nextValue
89
+ // is rejected before listeners fire), so no re-evaluation happens.
90
+ // We assert the OUTER hook didn't re-render.
91
+ expect(renderCount).toBe(before);
92
+ });
93
+
94
+ test("custom shallowEqual stabilizes object-literal selector returns", () => {
95
+ const store = createStore({ a: 1, b: 2, irrelevant: 0 });
96
+ let renderCount = 0;
97
+ const { result } = renderHook(() => {
98
+ renderCount += 1;
99
+ return useStoreSelector(store, (s) => ({ a: s.a, b: s.b }), shallowEqual);
100
+ });
101
+
102
+ const firstResult = result.current;
103
+ expect(firstResult).toEqual({ a: 1, b: 2 });
104
+
105
+ act(() => {
106
+ // Unrelated change in the snapshot — selector RETURNS a new object
107
+ // ({ a: 1, b: 2 } each time) but shallowEqual sees a/b unchanged.
108
+ store.setState({ a: 1, b: 2, irrelevant: 99 });
109
+ });
110
+
111
+ // Same identity preserved across the notify because shallowEqual
112
+ // matched. Without the equals-arg, this would be a new ref each call.
113
+ expect(result.current).toBe(firstResult);
114
+ expect(renderCount).toBe(1); // only the initial render
115
+ });
116
+
117
+ test("custom equals receives previous and current selected values", () => {
118
+ const store = createStore({ count: 0 });
119
+ const equals = vi.fn((_a: number, _b: number) => false); // never equal
120
+ let renderCount = 0;
121
+ renderHook(() => {
122
+ renderCount += 1;
123
+ return useStoreSelector(store, (s) => s.count, equals);
124
+ });
125
+
126
+ act(() => {
127
+ store.setState({ count: 1 });
128
+ });
129
+
130
+ // equals was called with (prev=0, next=1) at some point during the
131
+ // notify cycle. React may call getSnapshot multiple times per render,
132
+ // so we check that the transition (0, 1) appears among the calls
133
+ // rather than asserting on the last one.
134
+ expect(equals).toHaveBeenCalled();
135
+ const sawTransition = equals.mock.calls.some(([a, b]) => a === 0 && b === 1);
136
+ expect(sawTransition).toBe(true);
137
+ expect(renderCount).toBeGreaterThan(1);
138
+ });
139
+ });