@cosmicdrift/kumiko-renderer 0.212.0 → 0.214.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.212.0",
3
+ "version": "0.214.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.212.0",
19
- "@cosmicdrift/kumiko-headless": "0.212.0",
18
+ "@cosmicdrift/kumiko-framework": "0.214.0",
19
+ "@cosmicdrift/kumiko-headless": "0.214.0",
20
20
  "react": "^19.2.6",
21
21
  "temporal-polyfill": "^0.3.2",
22
22
  "zod": "^4.4.3"
@@ -25,7 +25,7 @@
25
25
  "@testing-library/react": "^16.3.2",
26
26
  "@types/react": "^19.2.14",
27
27
  "jsdom": "^29.1.1",
28
- "@cosmicdrift/kumiko-locale-de": "0.212.0"
28
+ "@cosmicdrift/kumiko-locale-de": "0.214.0"
29
29
  },
30
30
  "repository": {
31
31
  "type": "git",
@@ -0,0 +1,102 @@
1
+ // fw#2187: readOnly fields with an explicit `field.renderer: { format: "unit" }`
2
+ // must render through applyFormatSpec's unit case, and default to the
3
+ // LocaleProvider's App-Locale when the FormatSpec itself carries no `locale`
4
+ // — same posture as render-field-app-locale.test.tsx for money/date inputs,
5
+ // but for the FieldRendererOutput (readOnly + declared renderer) path, which
6
+ // that file doesn't cover.
7
+ //
8
+ // Capture-Text instead of a real primitive, same pattern as
9
+ // render-field-app-locale.test.tsx (Capture-Input there).
10
+
11
+ import { describe, expect, test } from "bun:test";
12
+ import type { EditFieldViewModel } from "@cosmicdrift/kumiko-headless";
13
+ import { render } from "@testing-library/react";
14
+ import type { ComponentType, ReactNode } from "react";
15
+ import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
16
+ import { type CorePrimitives, PrimitivesProvider, type TextProps } from "../../primitives";
17
+ import { RenderField } from "../render-field";
18
+
19
+ let captured: TextProps | undefined;
20
+ const captureText: ComponentType<TextProps> = (props) => {
21
+ captured = props;
22
+ return null;
23
+ };
24
+ const noop = (): ReactNode => null;
25
+ const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
26
+
27
+ const testPrimitives: CorePrimitives = {
28
+ Button: noop,
29
+ Banner: noop,
30
+ Field: passChildren,
31
+ Input: noop,
32
+ DataTable: noop,
33
+ Form: noop,
34
+ Section: noop,
35
+ Card: noop,
36
+ Grid: noop,
37
+ GridCell: noop,
38
+ Text: captureText,
39
+ Heading: noop,
40
+ Dialog: noop,
41
+ Modal: noop,
42
+ Lightbox: noop,
43
+ ConfigSourceBadge: noop,
44
+ ConfigCascadeView: noop,
45
+ Link: noop,
46
+ };
47
+
48
+ function livingSpaceField(
49
+ renderer: EditFieldViewModel["renderer"],
50
+ value: unknown = 58,
51
+ ): EditFieldViewModel {
52
+ return {
53
+ field: "livingSpace",
54
+ label: "Wohnfläche",
55
+ type: "decimal",
56
+ value,
57
+ visible: true,
58
+ readOnly: true,
59
+ required: false,
60
+ renderer,
61
+ };
62
+ }
63
+
64
+ function renderUnderLocale(locale: string, field: EditFieldViewModel): void {
65
+ captured = undefined;
66
+ render(
67
+ <LocaleProvider resolver={createStaticLocaleResolver({ locale })}>
68
+ <PrimitivesProvider value={testPrimitives}>
69
+ <RenderField field={field} onChange={() => {}} />
70
+ </PrimitivesProvider>
71
+ </LocaleProvider>,
72
+ );
73
+ }
74
+
75
+ describe("RenderField — unit-FormatSpec (fw#2187)", () => {
76
+ test("m2 rendert Zahl + m²-Suffix, locale-formatiert über das App-Locale", () => {
77
+ renderUnderLocale("de-DE", livingSpaceField({ format: "unit", unit: "m2" }));
78
+ expect(captured?.children).toBe("58 m²");
79
+ });
80
+
81
+ test("ein anderes App-Locale ändert die Zahl-Formatierung (en-US: Punkt statt Komma)", () => {
82
+ renderUnderLocale("en-US", livingSpaceField({ format: "unit", unit: "m2" }, 58.5));
83
+ expect(captured?.children).toBe("58.5 m²");
84
+ });
85
+
86
+ test("CLDR-sanktionierte Unit (km) rendert über Intl.NumberFormat(style:'unit')", () => {
87
+ renderUnderLocale("en-US", livingSpaceField({ format: "unit", unit: "km" }, 3));
88
+ expect(captured?.children).toBe("3 km");
89
+ });
90
+
91
+ test("explizites renderer.locale gewinnt gegen das App-Locale", () => {
92
+ renderUnderLocale("en-US", livingSpaceField({ format: "unit", unit: "m2", locale: "de-DE" }));
93
+ expect(captured?.children).toBe("58 m²");
94
+ });
95
+ });
96
+
97
+ describe("RenderField — App-Locale an FieldRendererOutput durchreichen (fw#2187)", () => {
98
+ test("number-FormatSpec ohne eigenes locale bekommt das App-Locale (de-DE, Komma statt Punkt)", () => {
99
+ renderUnderLocale("de-DE", livingSpaceField({ format: "number" }, 1234.5));
100
+ expect(captured?.children).toBe("1.234,5");
101
+ });
102
+ });
@@ -106,6 +106,7 @@ export function RenderField({
106
106
  <FieldRendererOutput
107
107
  field={field}
108
108
  renderer={field.renderer}
109
+ appLocale={appLocale}
109
110
  {...(row !== undefined && { row })}
110
111
  />
111
112
  ) : field.type === "embedded" && field.embeddedListCells !== undefined ? (
@@ -340,10 +341,12 @@ function FieldRendererOutput({
340
341
  field,
341
342
  renderer,
342
343
  row,
344
+ appLocale,
343
345
  }: {
344
346
  readonly field: EditFieldViewModel;
345
347
  readonly renderer: FieldRenderer;
346
348
  readonly row?: Readonly<Record<string, unknown>>;
349
+ readonly appLocale: string;
347
350
  }): ReactNode {
348
351
  const { Text } = usePrimitives();
349
352
  const componentName =
@@ -352,8 +355,16 @@ function FieldRendererOutput({
352
355
  : undefined;
353
356
  const Component = useColumnRenderer(componentName);
354
357
  if (isFormatSpec(renderer)) {
358
+ // App locale as default when the FormatSpec declares none of its own —
359
+ // otherwise locale-sensitive formats (timestamp/date/number/decimal/
360
+ // bigInt/unit) fell back to Intl's runtime default instead of the app
361
+ // language chosen via LocaleProvider (fw#2187). An explicit
362
+ // `renderer.locale` still wins, same pattern as dateLocale vs. appLocale
363
+ // further below in readOnlyDisplayText.
355
364
  return (
356
- <Text testId={`field-value-${field.field}`}>{applyFormatSpec(renderer, field.value)}</Text>
365
+ <Text testId={`field-value-${field.field}`}>
366
+ {applyFormatSpec({ locale: appLocale, ...renderer }, field.value)}
367
+ </Text>
357
368
  );
358
369
  }
359
370
  if (componentName !== undefined) {
package/src/i18n.tsx CHANGED
@@ -1,19 +1,18 @@
1
- // Locale-Handling für React-Consumer des Kumiko-Renderers. Eine dünne
2
- // Schicht um den platform-agnostischen `LocaleResolver`-Contract aus
3
- // @cosmicdrift/kumiko-headless: Provider, Hooks, Default-Noop-Resolver und ein
4
- // Fallback-Bundle-Merge für Feature-gelieferte Translations.
1
+ // Locale handling for React consumers of the Kumiko renderer. A thin layer
2
+ // around the platform-agnostic `LocaleResolver` contract from
3
+ // @cosmicdrift/kumiko-headless: provider, hooks, a default no-op resolver,
4
+ // and a fallback-bundle merge for feature-supplied translations.
5
5
  //
6
- // Architektur-Idee:
7
- // 1. App liefert genau einen `LocaleResolver` über `<LocaleProvider>`
8
- // (oder überhaupt keinenDefault-Resolver returnt keys as-is).
9
- // 2. Feature-Plugins dürfen Fallback-Bundles mitbringen: wenn der
10
- // App-Resolver einen Key nicht auflöst, probiert `useTranslation`
11
- // die Plugin-Bundles. Das hält Feature-UI unabhängig von der
12
- // App-seitigen i18next-Instanz und funktioniert out-of-the-box,
13
- // bleibt aber vollständig overridbar.
14
- // 3. Re-Render bei Locale-Wechsel via `useSyncExternalStore` auf
15
- // dem Resolver's `subscribe()` — App-Code kann mitten in der
16
- // Session die Sprache umschalten ohne Reload.
6
+ // Architecture:
7
+ // 1. The app supplies exactly one `LocaleResolver` via `<LocaleProvider>`
8
+ // (or none at all the default resolver returns keys as-is).
9
+ // 2. Feature plugins may bring fallback bundles: when the app resolver
10
+ // can't resolve a key, `useTranslation` tries the plugin bundles.
11
+ // This keeps feature UI independent of the app's own i18next instance
12
+ // and works out of the box, while staying fully overridable.
13
+ // 3. Re-render on locale change via `useSyncExternalStore` on the
14
+ // resolver's `subscribe()` app code can switch language mid-session
15
+ // without a reload.
17
16
 
18
17
  import type { LocaleResolver } from "@cosmicdrift/kumiko-headless";
19
18
  import {
@@ -78,14 +77,13 @@ const EMPTY_FALLBACK_BUNDLES: readonly TranslationsByLocale[] = [];
78
77
 
79
78
  export type LocaleProviderProps = {
80
79
  readonly resolver: LocaleResolver;
81
- /** Von Feature-Plugins gelieferte Default-Bundles. Lookup-Reihenfolge
82
- * pro Key: (1) App-Resolver, (2) diese Bundles in Array-Order,
83
- * (3) Key as-is. Apps können somit einzelne Keys overriden ohne
84
- * ganze Feature-Bundles austauschen zu müssen. */
80
+ /** Default bundles supplied by feature plugins. Lookup order per key:
81
+ * (1) app resolver, (2) these bundles in array order, (3) key as-is.
82
+ * Apps can thus override individual keys without swapping out whole
83
+ * feature bundles. */
85
84
  readonly fallbackBundles?: readonly TranslationsByLocale[];
86
- /** Auf den fallbackLocale wird zurückgegriffen, wenn weder der
87
- * current-locale- noch der key-Lookup im Plugin-Bundle greift.
88
- * Default: `"en"`. */
85
+ /** Falls back to fallbackLocale when neither the current-locale nor the
86
+ * key lookup hits in a plugin bundle. Default: `"en"`. */
89
87
  readonly fallbackLocale?: string;
90
88
  readonly children: ReactNode;
91
89
  };
@@ -96,12 +94,12 @@ export function LocaleProvider({
96
94
  fallbackLocale = "en",
97
95
  children,
98
96
  }: LocaleProviderProps): ReactNode {
99
- // Ohne Memoization baut jeder Re-Render des Providers (z.B. weil ein
100
- // Ahnen-Component neu rendert) ein neues Context-Value-Objekt jeder
101
- // Consumer von useTranslation()/useLocale() sieht dann eine neue `ctx`-
102
- // Referenz und damit selbst mit useCallback-Memoization einen neuen `t`.
103
- // Konsequenz: `t` in einem useEffect-Dependency-Array triggert einen
104
- // Endlos-Loop (siehe admin-shell Overview-Screens, Prod-Incident).
97
+ // Without memoization every re-render of the provider (e.g. because an
98
+ // ancestor component re-renders) builds a new context-value object
99
+ // every consumer of useTranslation()/useLocale() then sees a new `ctx`
100
+ // reference and, even with useCallback memoization, a new `t`.
101
+ // Consequence: `t` in a useEffect dependency array triggers an infinite
102
+ // loop (see admin-shell Overview screens, prod incident).
105
103
  const value = useMemo(
106
104
  () => ({ resolver, fallbackBundles, fallbackLocale }),
107
105
  [resolver, fallbackBundles, fallbackLocale],