@cosmicdrift/kumiko-renderer 0.221.0 → 0.223.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.221.0",
3
+ "version": "0.223.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.221.0",
19
- "@cosmicdrift/kumiko-headless": "0.221.0",
18
+ "@cosmicdrift/kumiko-framework": "0.223.0",
19
+ "@cosmicdrift/kumiko-headless": "0.223.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.221.0"
28
+ "@cosmicdrift/kumiko-locale-de": "0.223.0"
29
29
  },
30
30
  "repository": {
31
31
  "type": "git",
@@ -42,13 +42,18 @@ export const ExtensionFormRegistryProvider = ExtensionFormRegistryContext.Provid
42
42
  // Host-Seite (render-edit): hält die Registrierungen in einem ref, meldet den
43
43
  // aggregierten dirty-State via onDirtyChange hoch (damit der Save-Button
44
44
  // re-rendert) und liefert runAll() zum Ausführen aller Handler beim Submit.
45
- export function useExtensionFormHost(onDirtyChange: (anyDirty: boolean) => void): {
45
+ export type ExtensionFormHostStatus = {
46
+ readonly anyDirty: boolean;
47
+ readonly hasRegistrations: boolean;
48
+ };
49
+
50
+ export function useExtensionFormHost(onStatusChange: (status: ExtensionFormHostStatus) => void): {
46
51
  readonly registry: ExtensionFormRegistry;
47
52
  readonly runAll: (ctx: ExtensionSubmitContext) => Promise<readonly ExtensionSubmitResult[]>;
48
53
  } {
49
54
  const regsRef = useRef<Map<string, Registration>>(new Map());
50
- const onDirtyRef = useRef(onDirtyChange);
51
- onDirtyRef.current = onDirtyChange;
55
+ const onStatusRef = useRef(onStatusChange);
56
+ onStatusRef.current = onStatusChange;
52
57
 
53
58
  const registry = useMemo<ExtensionFormRegistry>(() => {
54
59
  const emitDirty = (): void => {
@@ -59,7 +64,7 @@ export function useExtensionFormHost(onDirtyChange: (anyDirty: boolean) => void)
59
64
  break;
60
65
  }
61
66
  }
62
- onDirtyRef.current(any);
67
+ onStatusRef.current({ anyDirty: any, hasRegistrations: regsRef.current.size > 0 });
63
68
  };
64
69
  return {
65
70
  upsert: (reg) => {
@@ -0,0 +1,162 @@
1
+ // display: "checkboxes" on a multiSelect field renders a checkbox grid
2
+ // (MultiSelectCheckboxes) instead of the combobox dropdown. Style follows
3
+ // render-field-app-locale.test.tsx: mount RenderField under real Locale +
4
+ // Primitives providers with capturing stubs, then invoke the captured
5
+ // callbacks the way a real primitive implementation would.
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import type { EditFieldViewModel } from "@cosmicdrift/kumiko-headless";
9
+ import { render } from "@testing-library/react";
10
+ import type { ComponentType, ReactNode } from "react";
11
+ import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
12
+ import { kumikoDefaultTranslations } from "../../i18n-defaults";
13
+ import {
14
+ type ButtonProps,
15
+ type CorePrimitives,
16
+ type GridProps,
17
+ type InputProps,
18
+ PrimitivesProvider,
19
+ } from "../../primitives";
20
+ import { RenderField } from "../render-field";
21
+
22
+ let capturedInputs: InputProps[] = [];
23
+ let capturedButton: ButtonProps | undefined;
24
+ let capturedGrid: GridProps | undefined;
25
+
26
+ const captureInput: ComponentType<InputProps> = (props) => {
27
+ capturedInputs.push(props);
28
+ return null;
29
+ };
30
+ const captureButton: ComponentType<ButtonProps> = (props) => {
31
+ capturedButton = props;
32
+ return null;
33
+ };
34
+ const captureGrid: ComponentType<GridProps> = (props) => {
35
+ capturedGrid = props;
36
+ return <>{props.children}</>;
37
+ };
38
+ const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
39
+ const noop = (): ReactNode => null;
40
+
41
+ const testPrimitives: CorePrimitives = {
42
+ Button: captureButton,
43
+ Banner: noop,
44
+ Field: passChildren,
45
+ Input: captureInput,
46
+ DataTable: noop,
47
+ Form: noop,
48
+ Section: noop,
49
+ Card: noop,
50
+ Grid: captureGrid,
51
+ GridCell: passChildren,
52
+ Text: noop,
53
+ Heading: noop,
54
+ Dialog: noop,
55
+ Modal: noop,
56
+ Lightbox: noop,
57
+ ConfigSourceBadge: noop,
58
+ ConfigCascadeView: noop,
59
+ Link: noop,
60
+ };
61
+
62
+ function languagesField(overrides: Partial<EditFieldViewModel> = {}): EditFieldViewModel {
63
+ return {
64
+ field: "languages",
65
+ label: "Languages",
66
+ type: "multiSelect",
67
+ value: [],
68
+ visible: true,
69
+ readOnly: false,
70
+ required: false,
71
+ options: ["en", "de", "es"],
72
+ optionLabels: { en: "English", de: "German", es: "Spanish" },
73
+ display: "checkboxes",
74
+ ...overrides,
75
+ };
76
+ }
77
+
78
+ let lastOnChange: unknown;
79
+ function renderField(field: EditFieldViewModel): void {
80
+ capturedInputs = [];
81
+ capturedButton = undefined;
82
+ capturedGrid = undefined;
83
+ lastOnChange = undefined;
84
+ render(
85
+ <LocaleProvider
86
+ resolver={createStaticLocaleResolver()}
87
+ fallbackBundles={[kumikoDefaultTranslations]}
88
+ >
89
+ <PrimitivesProvider value={testPrimitives}>
90
+ <RenderField field={field} onChange={(v) => (lastOnChange = v)} />
91
+ </PrimitivesProvider>
92
+ </LocaleProvider>,
93
+ );
94
+ }
95
+
96
+ describe("RenderField — multiSelect display: checkboxes", () => {
97
+ test("renders one boolean checkbox per option, no combobox", () => {
98
+ renderField(languagesField());
99
+ expect(capturedInputs).toHaveLength(3);
100
+ for (const input of capturedInputs) {
101
+ expect(input.kind).toBe("boolean");
102
+ }
103
+ });
104
+
105
+ test("without display, the field still renders the combobox (back-compat)", () => {
106
+ renderField(languagesField({ display: undefined }));
107
+ expect(capturedInputs).toHaveLength(1);
108
+ expect(capturedInputs[0]?.kind).toBe("combobox");
109
+ });
110
+
111
+ test("clicking one checkbox emits the full array including previously-set values, in options order", () => {
112
+ renderField(languagesField({ value: ["en"] }));
113
+ const deInput = capturedInputs.find(
114
+ (i) => i.kind === "boolean" && i.id === "kumiko-edit-languages-de",
115
+ );
116
+ expect(deInput?.kind).toBe("boolean");
117
+ if (deInput?.kind === "boolean") deInput.onChange(true);
118
+ expect(lastOnChange).toEqual(["en", "de"]);
119
+ });
120
+
121
+ test("unchecking a checkbox drops only that value, keeping options order", () => {
122
+ renderField(languagesField({ value: ["en", "de", "es"] }));
123
+ const deInput = capturedInputs.find(
124
+ (i) => i.kind === "boolean" && i.id === "kumiko-edit-languages-de",
125
+ );
126
+ if (deInput?.kind === "boolean") deInput.onChange(false);
127
+ expect(lastOnChange).toEqual(["en", "es"]);
128
+ });
129
+
130
+ test("select-all toggle shows 'Select all' when nothing is selected, and selects everything in options order", () => {
131
+ renderField(languagesField({ value: [] }));
132
+ expect(capturedButton?.children).toBe("Select all");
133
+ capturedButton?.onClick?.();
134
+ expect(lastOnChange).toEqual(["en", "de", "es"]);
135
+ });
136
+
137
+ test("select-all toggle flips to 'Deselect all' once everything is selected, and clears on click", () => {
138
+ renderField(languagesField({ value: ["en", "de", "es"] }));
139
+ expect(capturedButton?.children).toBe("Deselect all");
140
+ capturedButton?.onClick?.();
141
+ expect(lastOnChange).toEqual([]);
142
+ });
143
+
144
+ test("disabled (readOnly) disables every checkbox and the select-all toggle", () => {
145
+ renderField(languagesField({ readOnly: true }));
146
+ expect(capturedButton?.disabled).toBe(true);
147
+ for (const input of capturedInputs) {
148
+ expect(input.disabled).toBe(true);
149
+ }
150
+ });
151
+
152
+ test("columns and maxRows pass through to the Grid primitive", () => {
153
+ renderField(languagesField({ columns: 2, maxRows: 3 }));
154
+ expect(capturedGrid?.columns).toBe(2);
155
+ expect(capturedGrid?.maxRows).toBe(3);
156
+ });
157
+
158
+ test("omitting columns/maxRows keeps the default layout (no maxRows on the Grid)", () => {
159
+ renderField(languagesField());
160
+ expect(capturedGrid?.maxRows).toBeUndefined();
161
+ });
162
+ });
@@ -34,7 +34,16 @@ const TestDialog: ComponentType<DialogProps> = ({
34
34
  {description !== undefined && (
35
35
  <span data-testid={`${testId}-description`}>{description}</span>
36
36
  )}
37
- <button type="button" data-testid={`${testId}-confirm`} onClick={() => void onConfirm()}>
37
+ <button
38
+ type="button"
39
+ data-testid={`${testId}-confirm`}
40
+ onClick={() => {
41
+ void (async () => {
42
+ await onConfirm();
43
+ onOpenChange(false);
44
+ })();
45
+ }}
46
+ >
38
47
  {confirmLabel ?? "Confirm"}
39
48
  </button>
40
49
  <button type="button" data-testid={`${testId}-cancel`} onClick={() => onOpenChange(false)}>
@@ -98,6 +107,9 @@ describe("RenderEditActionButton", () => {
98
107
  fireEvent.click(rtlScreen.getByTestId("render-edit-action-archive"));
99
108
  fireEvent.click(rtlScreen.getByTestId("render-edit-action-archive-dialog-confirm"));
100
109
  await waitFor(() => expect(pressed).toBe(1));
110
+ await waitFor(() =>
111
+ expect(rtlScreen.queryByTestId("render-edit-action-archive-dialog")).toBeNull(),
112
+ );
101
113
  });
102
114
 
103
115
  test("danger style forces confirm dialog even without confirm text", async () => {
@@ -192,6 +192,20 @@ describe("RenderField money round-trip (kumiko-framework#1923)", () => {
192
192
  expect(result.data?.["price"]).toEqual({ amount: 500, currency: "JPY" });
193
193
  });
194
194
 
195
+ test("record currency differs from entity.defaultCurrency (#1930)", () => {
196
+ const entity = buildEntity("EUR");
197
+ let payload: unknown;
198
+ const field = renderMoneyField(entity, { price: { amount: 500, currency: "JPY" } }, (v) => {
199
+ payload = v;
200
+ });
201
+ if (field.kind !== "money") throw new Error("expected money kind");
202
+ // Prefer the value's own currency over entity.defaultCurrency (EUR=2dp).
203
+ expect(field.currency).toBe("JPY");
204
+ expect(field.value).toBe(500);
205
+ field.onChange(500);
206
+ expect(payload).toEqual({ amount: 500, currency: "JPY" });
207
+ });
208
+
195
209
  test("server schema strips an unexpected amountMinor key instead of rejecting the payload", () => {
196
210
  const entity = buildEntity("USD");
197
211
  const result = buildUpdateSchema(entity).safeParse({
@@ -99,4 +99,9 @@ describe("RenderField — App-Locale an FieldRendererOutput durchreichen (fw#218
99
99
  renderUnderLocale("de-DE", livingSpaceField({ format: "number" }, 1234.5));
100
100
  expect(captured?.children).toBe("1.234,5");
101
101
  });
102
+
103
+ test("explicit locale: undefined still falls back to App-Locale (#2332)", () => {
104
+ renderUnderLocale("de-DE", livingSpaceField({ format: "number", locale: undefined }, 1234.5));
105
+ expect(captured?.children).toBe("1.234,5");
106
+ });
102
107
  });
@@ -0,0 +1,93 @@
1
+ import type { EditFieldViewModel } from "@cosmicdrift/kumiko-headless";
2
+ import type { ReactNode } from "react";
3
+ import { useTranslation } from "../i18n";
4
+ import { usePrimitives } from "../primitives";
5
+
6
+ export type MultiSelectCheckboxOption = {
7
+ readonly value: string;
8
+ readonly label: string;
9
+ };
10
+
11
+ export type MultiSelectCheckboxesProps = {
12
+ readonly field: EditFieldViewModel;
13
+ readonly id: string;
14
+ readonly options: readonly MultiSelectCheckboxOption[];
15
+ readonly value: readonly string[];
16
+ readonly onChange: (value: readonly string[]) => void;
17
+ };
18
+
19
+ // Renders MultiSelectFieldDef's `display: "checkboxes"` mode — every option
20
+ // as a visible checkbox plus a select-all toggle, built purely from the
21
+ // platform-neutral Primitives contract (Grid/GridCell/Field/Input kind
22
+ // "boolean", the same primitive `case "boolean"` uses in render-field.tsx).
23
+ // No renderer-web import here — this file must stay usable from Expo too.
24
+ export function MultiSelectCheckboxes({
25
+ field,
26
+ id,
27
+ options,
28
+ value,
29
+ onChange,
30
+ }: MultiSelectCheckboxesProps): ReactNode {
31
+ const { Grid, GridCell, Field, Input, Button } = usePrimitives();
32
+ const t = useTranslation();
33
+ const disabled = field.readOnly;
34
+ const selected = new Set(value);
35
+ const allSelected = options.length > 0 && options.every((opt) => selected.has(opt.value));
36
+
37
+ // Always emits in `field.options` order (MultiSelectFieldDef's documented
38
+ // ordering guarantee), never in click/selection order.
39
+ const emitOrdered = (next: ReadonlySet<string>): void => {
40
+ onChange(options.filter((opt) => next.has(opt.value)).map((opt) => opt.value));
41
+ };
42
+
43
+ const toggleOption = (optionValue: string, checked: boolean): void => {
44
+ const next = new Set(selected);
45
+ if (checked) {
46
+ next.add(optionValue);
47
+ } else {
48
+ next.delete(optionValue);
49
+ }
50
+ emitOrdered(next);
51
+ };
52
+
53
+ const toggleAll = (): void => {
54
+ emitOrdered(allSelected ? new Set() : new Set(options.map((opt) => opt.value)));
55
+ };
56
+
57
+ return (
58
+ <>
59
+ <Button
60
+ type="button"
61
+ variant="secondary"
62
+ size="sm"
63
+ disabled={disabled}
64
+ onClick={toggleAll}
65
+ testId={`${id}-select-all`}
66
+ >
67
+ {allSelected
68
+ ? t("kumiko.field.multiSelect.deselect-all")
69
+ : t("kumiko.field.multiSelect.select-all")}
70
+ </Button>
71
+ <Grid
72
+ columns={field.columns ?? 2}
73
+ testId={`${id}-checkboxes`}
74
+ {...(field.maxRows !== undefined && { maxRows: field.maxRows })}
75
+ >
76
+ {options.map((opt) => (
77
+ <GridCell key={opt.value}>
78
+ <Field id={`${id}-${opt.value}`} label={opt.label} layout="inline">
79
+ <Input
80
+ kind="boolean"
81
+ id={`${id}-${opt.value}`}
82
+ name={`${id}-${opt.value}`}
83
+ value={selected.has(opt.value)}
84
+ onChange={(checked) => toggleOption(opt.value, checked)}
85
+ disabled={disabled}
86
+ />
87
+ </Field>
88
+ </GridCell>
89
+ ))}
90
+ </Grid>
91
+ </>
92
+ );
93
+ }
@@ -277,9 +277,14 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
277
277
  // never enters `fields`/`controller.getSnapshot().values` — there is no
278
278
  // draft-blob-covered state left for persistExtensions() to compete over.
279
279
  const [extensionDirty, setExtensionDirty] = useState(false);
280
+ const [hasExtensionRegistrations, setHasExtensionRegistrations] = useState(false);
280
281
  const [extensionErrorKey, setExtensionErrorKey] = useState<string | null>(null);
281
- const { registry: extensionFormRegistry, runAll: runExtensionSubmits } =
282
- useExtensionFormHost(setExtensionDirty);
282
+ const { registry: extensionFormRegistry, runAll: runExtensionSubmits } = useExtensionFormHost(
283
+ ({ anyDirty, hasRegistrations }) => {
284
+ setExtensionDirty(anyDirty);
285
+ setHasExtensionRegistrations(hasRegistrations);
286
+ },
287
+ );
283
288
  const {
284
289
  Button,
285
290
  Banner,
@@ -971,7 +976,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
971
976
  {translate("kumiko.actions.next")}
972
977
  </Button>
973
978
  )}
974
- {isFormEditable && (!isWizard || isLastWizardStep) && (
979
+ {(isFormEditable || hasExtensionRegistrations) && (!isWizard || isLastWizardStep) && (
975
980
  <Button
976
981
  type="submit"
977
982
  disabled={(snapshot.isUnchanged && !extensionDirty) || isSubmitting || disabled}
@@ -1103,6 +1108,19 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1103
1108
  // navigating past its step — otherwise Finish only ran the last
1104
1109
  // mounted step's handler and silently dropped earlier steps' writes.
1105
1110
  const stepHidden = isWizard && sectionIndex !== currentStep;
1111
+ const wrapWizardStep = (key: string, el: ReactNode): ReactNode => {
1112
+ if (!isWizard) return el;
1113
+ if (WizardStepGroup === undefined) {
1114
+ throw new Error(
1115
+ "RenderEdit: wizard layout requires primitives.WizardStepGroup, but none is registered.",
1116
+ );
1117
+ }
1118
+ return (
1119
+ <WizardStepGroup key={key} hidden={stepHidden}>
1120
+ {el}
1121
+ </WizardStepGroup>
1122
+ );
1123
+ };
1106
1124
  // Off-screen wizard steps stay mounted (see comment above) but must
1107
1125
  // not participate in native constraint validation, or the Next
1108
1126
  // button's `type="submit"` triggers the browser's full-form check
@@ -1129,18 +1147,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1129
1147
  validate={scopedValidate}
1130
1148
  />
1131
1149
  );
1132
- if (!isWizard) return mount;
1133
- if (WizardStepGroup === undefined) {
1134
- // Both silent fallbacks are unsafe here — render-visible-all-steps or unmount-drops-registry.
1135
- throw new Error(
1136
- "RenderEdit: wizard layout requires primitives.WizardStepGroup, but none is registered.",
1137
- );
1138
- }
1139
- return (
1140
- <WizardStepGroup key={section.title} hidden={stepHidden}>
1141
- {mount}
1142
- </WizardStepGroup>
1143
- );
1150
+ return wrapWizardStep(section.title, mount);
1144
1151
  }
1145
1152
  if (section.kind === "relatedList") {
1146
1153
  // parentId is the displayed record's id — without it there's no
@@ -1198,17 +1205,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1198
1205
  </Grid>
1199
1206
  </Section>
1200
1207
  );
1201
- if (!isWizard) return sectionEl;
1202
- if (WizardStepGroup === undefined) {
1203
- throw new Error(
1204
- "RenderEdit: wizard layout requires primitives.WizardStepGroup, but none is registered.",
1205
- );
1206
- }
1207
- return (
1208
- <WizardStepGroup key={sectionKey} hidden={stepHidden}>
1209
- {sectionEl}
1210
- </WizardStepGroup>
1211
- );
1208
+ return wrapWizardStep(sectionKey, sectionEl);
1212
1209
  })}
1213
1210
  {formError !== null && (
1214
1211
  <Banner
@@ -21,6 +21,7 @@ import { useQuery } from "../hooks/use-query";
21
21
  import { useLocale, useTranslation } from "../i18n";
22
22
  import { usePrimitives } from "../primitives";
23
23
  import { EmbeddedListField } from "./embedded-list-field";
24
+ import { MultiSelectCheckboxes } from "./multi-select-checkboxes";
24
25
  import { ReferenceCreateDialog } from "./reference-create-dialog";
25
26
 
26
27
  // RenderField übersetzt ein EditFieldViewModel → Primitives-Baum.
@@ -359,12 +360,18 @@ function FieldRendererOutput({
359
360
  // App locale as default when the FormatSpec declares none of its own —
360
361
  // otherwise locale-sensitive formats (timestamp/date/number/decimal/
361
362
  // bigInt/unit) fell back to Intl's runtime default instead of the app
362
- // language chosen via LocaleProvider (fw#2187). An explicit
363
- // `renderer.locale` still wins, same pattern as dateLocale vs. appLocale
364
- // further below in readOnlyDisplayText.
363
+ // language chosen via LocaleProvider (fw#2187). Prefer renderer.locale
364
+ // when set; coalesce undefined (spread override) back to appLocale (#2332).
365
365
  return (
366
366
  <Text testId={`field-value-${field.field}`}>
367
- {applyFormatSpec({ locale: appLocale, ...renderer }, field.value, t)}
367
+ {applyFormatSpec(
368
+ {
369
+ ...renderer,
370
+ locale: (renderer as { locale?: string }).locale ?? appLocale,
371
+ },
372
+ field.value,
373
+ t,
374
+ )}
368
375
  </Text>
369
376
  );
370
377
  }
@@ -508,6 +515,17 @@ function renderInput({
508
515
  ? rawOptions.map((value: string) => ({ value, label: labels[value] ?? value }))
509
516
  : rawOptions.map((value: string) => ({ value, label: value }));
510
517
  const arrayValue = Array.isArray(field.value) ? (field.value as readonly string[]) : [];
518
+ if (field.display === "checkboxes") {
519
+ return (
520
+ <MultiSelectCheckboxes
521
+ field={field}
522
+ id={id}
523
+ options={multiSelectOptions}
524
+ value={arrayValue}
525
+ onChange={(v) => onChange(v)}
526
+ />
527
+ );
528
+ }
511
529
  return (
512
530
  <Input
513
531
  kind="combobox"
@@ -520,7 +538,7 @@ function renderInput({
520
538
  );
521
539
  }
522
540
  case "money": {
523
- const currency = field.currency ?? "EUR";
541
+ const currency = resolveMoneyCurrency(field.value, field.currency);
524
542
  return (
525
543
  <Input
526
544
  kind="money"
@@ -692,6 +710,17 @@ function numberValue(v: unknown): number | "" {
692
710
  // stored-config coercion) is MAJOR units too — every producer in this repo
693
711
  // hands rehydrateMoney's `{amount,…}` shape or a raw major-unit number, never
694
712
  // pre-scaled minor units.
713
+ function resolveMoneyCurrency(value: unknown, fieldCurrency: string | undefined): string {
714
+ if (
715
+ typeof value === "object" &&
716
+ value !== null &&
717
+ typeof (value as { currency?: unknown }).currency === "string"
718
+ ) {
719
+ return (value as { currency: string }).currency;
720
+ }
721
+ return fieldCurrency ?? "EUR";
722
+ }
723
+
695
724
  function moneyMinorValue(v: unknown, currency: string): number | "" {
696
725
  if (v === undefined || v === null || v === "") return "";
697
726
  if (typeof v === "number") return Math.round(v * 10 ** currencyDecimals(currency));
@@ -756,7 +785,7 @@ function readOnlyDisplayText(field: EditFieldViewModel, appLocale: string): stri
756
785
  return n === "" ? "—" : new Intl.NumberFormat(appLocale).format(n);
757
786
  }
758
787
  case "money": {
759
- const currency = field.currency ?? "EUR";
788
+ const currency = resolveMoneyCurrency(value, field.currency);
760
789
  const minor = moneyMinorValue(value, currency);
761
790
  if (minor === "") return "—";
762
791
  const major = minor / 10 ** currencyDecimals(currency);
@@ -54,6 +54,8 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
54
54
  "{count} pasted row(s) were dropped (max row count reached).",
55
55
  "kumiko.field.embedded-list.paste-cells-unmatched":
56
56
  "{count} cell(s) had no matching option and were left unchanged.",
57
+ "kumiko.field.multiSelect.select-all": "Select all",
58
+ "kumiko.field.multiSelect.deselect-all": "Deselect all",
57
59
 
58
60
  "kumiko.list.search-placeholder": "Search…",
59
61
  "kumiko.list.empty.title": "No entries yet.",
@@ -739,6 +739,9 @@ export type GridProps = {
739
739
  readonly columns: number;
740
740
  readonly children: ReactNode;
741
741
  readonly testId?: string;
742
+ /** Rows visible before the grid becomes vertically scrollable. Omitted =
743
+ * the grid grows with its content and never scrolls. */
744
+ readonly maxRows?: number;
742
745
  };
743
746
 
744
747
  /** Span-Wrapper für ein Kind innerhalb eines Grid. Web: `style={{gridColumn: span N}}`,