@cosmicdrift/kumiko-renderer 0.196.1 → 0.197.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.196.1",
3
+ "version": "0.197.0",
4
4
  "description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -15,8 +15,8 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@cosmicdrift/kumiko-framework": "0.196.1",
19
- "@cosmicdrift/kumiko-headless": "0.196.1",
18
+ "@cosmicdrift/kumiko-framework": "0.197.0",
19
+ "@cosmicdrift/kumiko-headless": "0.197.0",
20
20
  "react": "^19.2.6",
21
21
  "temporal-polyfill": "^0.3.2",
22
22
  "zod": "^4.4.3"
@@ -0,0 +1,61 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+ import type { Dispatcher, WriteResult } from "@cosmicdrift/kumiko-headless";
3
+ import { act, renderHook } from "@testing-library/react";
4
+ import { useForm } from "../use-form";
5
+
6
+ function makeDispatcher(response?: WriteResult): Dispatcher & {
7
+ readonly writeSpy: ReturnType<typeof mock>;
8
+ } {
9
+ const writeSpy = mock(
10
+ async () => response ?? ({ isSuccess: true, data: { id: "srv-1" } } as WriteResult),
11
+ );
12
+ return {
13
+ writeSpy,
14
+ write: writeSpy as unknown as Dispatcher["write"],
15
+ query: (async () => ({ isSuccess: true, data: null })) as unknown as Dispatcher["query"],
16
+ batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
17
+ async *stream() {},
18
+ statusStore: {
19
+ getState: () => "online",
20
+ subscribe: () => () => {},
21
+ } as unknown as Dispatcher["statusStore"],
22
+ pendingWrites: () => [],
23
+ pendingFiles: () => [],
24
+ };
25
+ }
26
+
27
+ describe("useForm — mounted without a DispatcherProvider", () => {
28
+ test("does not throw on mount — fw#1999 regression", () => {
29
+ const { result } = renderHook(() =>
30
+ useForm({ initial: { title: "" }, submit: { type: "app:write:task:create" } }),
31
+ );
32
+
33
+ expect(result.current.snapshot.values.title).toBe("");
34
+ });
35
+
36
+ test("submit() without an explicit dispatcher rejects instead of crashing the hook", async () => {
37
+ const { result } = renderHook(() =>
38
+ useForm({ initial: { title: "hello" }, submit: { type: "app:write:task:create" } }),
39
+ );
40
+
41
+ await expect(result.current.controller.submit()).rejects.toThrow(
42
+ /submit\(\) called without a dispatcher/,
43
+ );
44
+ });
45
+
46
+ test("submit() with an explicit dispatcher still works without a provider", async () => {
47
+ const dispatcher = makeDispatcher();
48
+ const { result } = renderHook(() =>
49
+ useForm({
50
+ initial: { title: "hello" },
51
+ submit: { type: "app:write:task:create", dispatcher },
52
+ }),
53
+ );
54
+
55
+ await act(async () => {
56
+ await result.current.controller.submit();
57
+ });
58
+
59
+ expect(dispatcher.writeSpy).toHaveBeenCalledWith("app:write:task:create", { title: "hello" });
60
+ });
61
+ });
@@ -6,7 +6,7 @@ import type {
6
6
  } from "@cosmicdrift/kumiko-headless";
7
7
  import { createFormController } from "@cosmicdrift/kumiko-headless";
8
8
  import { useMemo, useSyncExternalStore } from "react";
9
- import { useDispatcher } from "../context/dispatcher-context";
9
+ import { useOptionalDispatcher } from "../context/dispatcher-context";
10
10
 
11
11
  // Thin React wrapper around createFormController. Returns both the
12
12
  // controller (imperative — setField, submit, reset) and the current
@@ -46,7 +46,10 @@ export type UseFormResult<TValues extends FormValues> = {
46
46
  export function useForm<TValues extends FormValues, TCtx = unknown>(
47
47
  options: UseFormOptions<TValues, TCtx>,
48
48
  ): UseFormResult<TValues> {
49
- const contextDispatcher = useDispatcher();
49
+ // Optional: a form with no `submit` config (or one with an explicit
50
+ // dispatcher) never needs the ambient one, and mounting without a
51
+ // DispatcherProvider must not crash just because useForm was called.
52
+ const contextDispatcher = useOptionalDispatcher();
50
53
 
51
54
  // The controller is created once per mount. `options` mutates across
52
55
  // renders in normal React usage (closures get new references), but
@@ -66,6 +66,12 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
66
66
  "kumiko.list.no-entries": "Keine Einträge.",
67
67
  "kumiko.list.end-of-list": "— Ende der Liste —",
68
68
 
69
+ // Pager — Status-Zeile + Prev/Next/Page-aria-Labels (DataTable pagination="pages").
70
+ "kumiko.pager.status": "{from}–{to} von {total}",
71
+ "kumiko.pager.previousPage": "Vorherige Seite",
72
+ "kumiko.pager.nextPage": "Nächste Seite",
73
+ "kumiko.pager.page": "Seite {entry}",
74
+
69
75
  // Combobox — Tier 2.1c Searchable-Select.
70
76
  "kumiko.combobox.search-placeholder": "Suchen…",
71
77
  "kumiko.combobox.empty": "Keine Treffer.",
@@ -257,6 +263,11 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
257
263
  "kumiko.list.no-entries": "No entries.",
258
264
  "kumiko.list.end-of-list": "— End of list —",
259
265
 
266
+ "kumiko.pager.status": "{from}–{to} of {total}",
267
+ "kumiko.pager.previousPage": "Previous page",
268
+ "kumiko.pager.nextPage": "Next page",
269
+ "kumiko.pager.page": "Page {entry}",
270
+
260
271
  "kumiko.combobox.search-placeholder": "Search…",
261
272
  "kumiko.combobox.empty": "No matches.",
262
273
  "kumiko.combobox.loading": "Loading…",