@cosmicdrift/kumiko-renderer 0.231.0 → 0.233.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.231.0",
3
+ "version": "0.233.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.231.0",
19
- "@cosmicdrift/kumiko-headless": "0.231.0",
18
+ "@cosmicdrift/kumiko-framework": "0.233.0",
19
+ "@cosmicdrift/kumiko-headless": "0.233.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.231.0"
28
+ "@cosmicdrift/kumiko-locale-de": "0.233.0"
29
29
  },
30
30
  "repository": {
31
31
  "type": "git",
@@ -292,10 +292,13 @@ const TestButton: ComponentType<ButtonProps> = ({ children, onClick, testId }) =
292
292
  </button>
293
293
  );
294
294
 
295
- const FormWithActions: ComponentType<FormProps> = ({ children, actions }) => (
295
+ const FormWithActions: ComponentType<FormProps> = ({ children, actions, secondaryActions }) => (
296
296
  <>
297
297
  <div data-testid="form-body">{children}</div>
298
- <div data-testid="form-actions">{actions}</div>
298
+ <div data-testid="form-actions">
299
+ {secondaryActions}
300
+ {actions}
301
+ </div>
299
302
  </>
300
303
  );
301
304
 
@@ -43,14 +43,18 @@ const TestButton: ComponentType<ButtonProps> = ({ children, onClick, testId }) =
43
43
  </button>
44
44
  );
45
45
 
46
- // Form's `actions` slot carries the header buttons under test — passChildren
47
- // alone would drop it. Wrapped in distinct testid'd containers so a test can
48
- // assert a banner rendered as a Form CHILD (formError/actionError region)
49
- // rather than inside the actions row — the review finding this proves.
50
- const FormWithActions: ComponentType<FormProps> = ({ children, actions }) => (
46
+ // Form's `actions`/`secondaryActions` slots carry the header buttons under
47
+ // test — passChildren alone would drop them. Wrapped in distinct testid'd
48
+ // containers so a test can assert a banner rendered as a Form CHILD
49
+ // (formError/actionError region) rather than inside the actions row — the
50
+ // review finding this proves.
51
+ const FormWithActions: ComponentType<FormProps> = ({ children, actions, secondaryActions }) => (
51
52
  <>
52
53
  <div data-testid="form-body">{children}</div>
53
- <div data-testid="form-actions">{actions}</div>
54
+ <div data-testid="form-actions">
55
+ {secondaryActions}
56
+ {actions}
57
+ </div>
54
58
  </>
55
59
  );
56
60
 
@@ -44,8 +44,9 @@ const testButton: ComponentType<{
44
44
  const testForm: ComponentType<{
45
45
  children?: ReactNode;
46
46
  actions?: ReactNode;
47
+ secondaryActions?: ReactNode;
47
48
  onSubmit?: () => void;
48
- }> = ({ children, actions, onSubmit }) => (
49
+ }> = ({ children, actions, secondaryActions, onSubmit }) => (
49
50
  <form
50
51
  onSubmit={(e) => {
51
52
  e.preventDefault();
@@ -53,6 +54,7 @@ const testForm: ComponentType<{
53
54
  }}
54
55
  >
55
56
  {children}
57
+ {secondaryActions}
56
58
  {actions}
57
59
  </form>
58
60
  );
@@ -0,0 +1,191 @@
1
+ // Editable number-field unit-of-measure suffix (static or sibling-field
2
+ // reference). Complements render-field-unit-format.test.tsx, which only
3
+ // covers the pre-existing read-only `format: "unit"` FieldRenderer path —
4
+ // this feature is display-only decoration on the editable Input widget,
5
+ // never a value conversion.
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import type {
9
+ EntityDefinition,
10
+ EntityEditScreenDefinition,
11
+ } from "@cosmicdrift/kumiko-framework/ui-types";
12
+ import { computeEditViewModel, 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, type InputProps, PrimitivesProvider } from "../../primitives";
17
+ import { RenderField } from "../render-field";
18
+
19
+ let captured: InputProps | undefined;
20
+ const captureInput: ComponentType<InputProps> = (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: captureInput,
32
+ DataTable: noop,
33
+ Form: noop,
34
+ Section: noop,
35
+ Card: noop,
36
+ Grid: noop,
37
+ GridCell: noop,
38
+ Text: noop,
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 buildEntity(
49
+ mileageUnit: string | { readonly field: string } | undefined,
50
+ ): EntityDefinition {
51
+ return {
52
+ fields: {
53
+ mileage: {
54
+ type: "number",
55
+ required: false,
56
+ ...(mileageUnit !== undefined && { unit: mileageUnit }),
57
+ },
58
+ mileageUnit: { type: "text", required: false },
59
+ },
60
+ } as EntityDefinition;
61
+ }
62
+
63
+ function buildScreen(): EntityEditScreenDefinition {
64
+ return {
65
+ id: "vehicle-edit",
66
+ type: "entityEdit",
67
+ entity: "vehicle",
68
+ layout: { sections: [{ columns: 1, fields: ["mileage", "mileageUnit"] }] },
69
+ } as EntityEditScreenDefinition;
70
+ }
71
+
72
+ function mileageField(
73
+ entity: EntityDefinition,
74
+ values: Record<string, unknown>,
75
+ ): EditFieldViewModel {
76
+ const vm = computeEditViewModel({
77
+ screen: buildScreen(),
78
+ entity,
79
+ values,
80
+ translate: (key) => key,
81
+ featureName: "fleet",
82
+ });
83
+ const section = vm.sections[0];
84
+ if (section === undefined || section.kind !== "fields") {
85
+ throw new Error("expected a fields section");
86
+ }
87
+ const field = section.fields.find((f) => f.field === "mileage");
88
+ if (field === undefined) throw new Error("expected a mileage field");
89
+ return field;
90
+ }
91
+
92
+ function currentInput(): InputProps {
93
+ if (captured === undefined) throw new Error("expected an Input to be captured");
94
+ return captured;
95
+ }
96
+
97
+ function renderMileageField(
98
+ entity: EntityDefinition,
99
+ values: Record<string, unknown>,
100
+ row?: Record<string, unknown>,
101
+ ): InputProps {
102
+ captured = undefined;
103
+ render(
104
+ <LocaleProvider resolver={createStaticLocaleResolver({ locale: "en-US" })}>
105
+ <PrimitivesProvider value={testPrimitives}>
106
+ <RenderField
107
+ field={mileageField(entity, values)}
108
+ onChange={() => {}}
109
+ {...(row !== undefined && { row })}
110
+ />
111
+ </PrimitivesProvider>
112
+ </LocaleProvider>,
113
+ );
114
+ if (captured === undefined) throw new Error("mileage field did not render an Input");
115
+ return captured;
116
+ }
117
+
118
+ describe("RenderField — editable number unit suffix", () => {
119
+ test("static unit resolves onto Input.unit, value stays untouched", () => {
120
+ const field = renderMileageField(buildEntity("km"), { mileage: 58 });
121
+ expect(field.kind).toBe("number");
122
+ if (field.kind !== "number") return;
123
+ expect(field.unit).toBe("km");
124
+ expect(field.value).toBe(58);
125
+ });
126
+
127
+ test("no unit configured: Input.unit is undefined", () => {
128
+ const field = renderMileageField(buildEntity(undefined), { mileage: 58 });
129
+ expect(field.kind).toBe("number");
130
+ if (field.kind !== "number") return;
131
+ expect(field.unit).toBeUndefined();
132
+ });
133
+
134
+ test("sibling-field unit resolves from the live row and updates when the sibling changes", () => {
135
+ const entity = buildEntity({ field: "mileageUnit" });
136
+ captured = undefined;
137
+ const { rerender } = render(
138
+ <LocaleProvider resolver={createStaticLocaleResolver({ locale: "en-US" })}>
139
+ <PrimitivesProvider value={testPrimitives}>
140
+ <RenderField
141
+ field={mileageField(entity, { mileage: 58, mileageUnit: "mi" })}
142
+ onChange={() => {}}
143
+ row={{ mileage: 58, mileageUnit: "mi" }}
144
+ />
145
+ </PrimitivesProvider>
146
+ </LocaleProvider>,
147
+ );
148
+ const first = currentInput();
149
+ if (first.kind !== "number") throw new Error("expected a number Input");
150
+ expect(first.unit).toBe("mi");
151
+
152
+ captured = undefined;
153
+ rerender(
154
+ <LocaleProvider resolver={createStaticLocaleResolver({ locale: "en-US" })}>
155
+ <PrimitivesProvider value={testPrimitives}>
156
+ <RenderField
157
+ field={mileageField(entity, { mileage: 58, mileageUnit: "km" })}
158
+ onChange={() => {}}
159
+ row={{ mileage: 58, mileageUnit: "km" }}
160
+ />
161
+ </PrimitivesProvider>
162
+ </LocaleProvider>,
163
+ );
164
+ const second = currentInput();
165
+ if (second.kind !== "number") throw new Error("expected a number Input");
166
+ expect(second.unit).toBe("km");
167
+ });
168
+
169
+ test("missing sibling field: no suffix, no crash, no guessed default", () => {
170
+ const entity = buildEntity({ field: "mileageUnit" });
171
+ const field = renderMileageField(entity, { mileage: 58 }, { mileage: 58 });
172
+ expect(field.kind).toBe("number");
173
+ if (field.kind !== "number") return;
174
+ expect(field.unit).toBeUndefined();
175
+ });
176
+
177
+ test("empty-string sibling value: no suffix, no guessed default", () => {
178
+ const entity = buildEntity({ field: "mileageUnit" });
179
+ const field = renderMileageField(
180
+ entity,
181
+ { mileage: 58, mileageUnit: "" },
182
+ {
183
+ mileage: 58,
184
+ mileageUnit: "",
185
+ },
186
+ );
187
+ expect(field.kind).toBe("number");
188
+ if (field.kind !== "number") return;
189
+ expect(field.unit).toBeUndefined();
190
+ });
191
+ });
@@ -914,25 +914,33 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
914
914
  }
915
915
  handleSubmitRef.current = handleSubmit;
916
916
 
917
- // Sticky-top Action-Bar: Delete (links, destructive) + Cancel +
918
- // Save. Delete sitzt links abgesetzt damit die Click-Distanz zu
919
- // Save is large; red styling + confirm dialog are enough protection
920
- // gegen Fehlklicks. Save bleibt rechts (primary affordance).
921
- const formActions = (
917
+ // Two groups wizard navigation on the right/top, record actions on the
918
+ // left/below; destructive action sits outermost, farthest from the target
919
+ // action.
920
+ const hasSecondaryFormActions =
921
+ onDelete !== undefined ||
922
+ onCopyLink !== undefined ||
923
+ (actions !== undefined && actions.length > 0) ||
924
+ onCancel !== undefined;
925
+ const secondaryFormActions = (
922
926
  <>
923
- {actions?.map((action) => (
924
- <RenderEditActionButton
925
- key={action.id}
926
- action={action}
927
- Button={Button}
928
- Dialog={Dialog}
929
- onError={setActionError}
930
- />
931
- ))}
927
+ {onDelete !== undefined && (
928
+ <Button
929
+ type="button"
930
+ variant="danger-ghost"
931
+ icon="trash"
932
+ testId="render-edit-delete"
933
+ disabled={disabled}
934
+ onClick={() => setConfirmDeleteOpen(true)}
935
+ >
936
+ {translate("kumiko.actions.delete")}
937
+ </Button>
938
+ )}
932
939
  {onCopyLink !== undefined && (
933
940
  <Button
934
941
  type="button"
935
- variant="secondary"
942
+ variant="link"
943
+ icon={linkCopied ? "check" : "link"}
936
944
  testId="render-edit-copy-link"
937
945
  onClick={async () => {
938
946
  await onCopyLink();
@@ -942,31 +950,35 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
942
950
  {translate(linkCopied ? "kumiko.actions.copyLinkCopied" : "kumiko.actions.copyLink")}
943
951
  </Button>
944
952
  )}
945
- {onDelete !== undefined && (
946
- <Button
947
- type="button"
948
- variant="danger"
949
- testId="render-edit-delete"
950
- disabled={disabled}
951
- onClick={() => setConfirmDeleteOpen(true)}
952
- >
953
- {translate("kumiko.actions.delete")}
954
- </Button>
955
- )}
953
+ {actions?.map((action) => (
954
+ <RenderEditActionButton
955
+ key={action.id}
956
+ action={action}
957
+ Button={Button}
958
+ Dialog={Dialog}
959
+ onError={setActionError}
960
+ />
961
+ ))}
956
962
  {onCancel !== undefined && (
957
963
  <Button
958
964
  type="button"
959
- variant="secondary"
965
+ variant="link"
966
+ icon="x"
960
967
  onClick={() => onCancel()}
961
968
  testId="render-edit-cancel"
962
969
  >
963
970
  {translate("kumiko.actions.cancel")}
964
971
  </Button>
965
972
  )}
973
+ </>
974
+ );
975
+ const formActions = (
976
+ <>
966
977
  {isWizard && currentStep > 0 && (
967
978
  <Button
968
979
  type="button"
969
980
  variant="secondary"
981
+ icon="arrow-left"
970
982
  onClick={handleWizardBack}
971
983
  testId="render-edit-wizard-back"
972
984
  >
@@ -974,7 +986,12 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
974
986
  </Button>
975
987
  )}
976
988
  {isWizard && !isLastWizardStep && (
977
- <Button type="submit" variant="primary" testId="render-edit-wizard-next">
989
+ <Button
990
+ type="submit"
991
+ variant="primary"
992
+ iconEnd="arrow-right"
993
+ testId="render-edit-wizard-next"
994
+ >
978
995
  {translate("kumiko.actions.next")}
979
996
  </Button>
980
997
  )}
@@ -984,6 +1001,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
984
1001
  disabled={(snapshot.isUnchanged && !extensionDirty) || isSubmitting || disabled}
985
1002
  loading={isSubmitting}
986
1003
  variant="primary"
1004
+ icon="check"
987
1005
  testId="render-edit-submit"
988
1006
  >
989
1007
  {translate(submitLabel ?? (isWizard ? "kumiko.actions.finish" : "kumiko.actions.save"))}
@@ -1022,6 +1040,8 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1022
1040
  {...(hideSectionTitles !== true &&
1023
1041
  formSubtitle !== undefined && { subtitle: formSubtitle })}
1024
1042
  {...(hideActions !== true && { actions: formActions })}
1043
+ {...(hideActions !== true &&
1044
+ hasSecondaryFormActions && { secondaryActions: secondaryFormActions })}
1025
1045
  testId="render-edit-form"
1026
1046
  stickyActions={isWizard}
1027
1047
  {...(screen.layout.width !== undefined && { width: screen.layout.width })}
@@ -61,11 +61,12 @@ export type RenderFieldProps = {
61
61
  * `field.readOnly` field as plain text instead (projectionDetail's read
62
62
  * view, fw#2245); editable fields are untouched by this prop either way. */
63
63
  readonly valueDisplay?: "form" | "text";
64
- /** Current form values, keyed by field name only consulted when
64
+ /** Current form values, keyed by field name. Consulted when
65
65
  * `field.renderer` resolves to a `{ react: { __component } }` registry
66
- * component, passed through as `ColumnRendererProps.row` (same contract
67
- * as list-column renderers, fw#2245). Omitted falls back to a
68
- * single-key `{ [field.field]: field.value }` row. */
66
+ * component (passed through as `ColumnRendererProps.row`, same contract
67
+ * as list-column renderers, fw#2245), and to resolve a `type: "number"`
68
+ * field's sibling-field `unit`. Omitted falls back to a single-key
69
+ * `{ [field.field]: field.value }` row. */
69
70
  readonly row?: Readonly<Record<string, unknown>>;
70
71
  };
71
72
 
@@ -134,7 +135,7 @@ export function RenderField({
134
135
  ) : readOnlyText && !isComplexFieldType(field.type) ? (
135
136
  <Text testId={`field-value-${field.field}`}>{readOnlyDisplayText(field, appLocale)}</Text>
136
137
  ) : (
137
- renderInput({ field, id, hasError, onChange, Input, appLocale, Banner, Text, t })
138
+ renderInput({ field, id, hasError, onChange, Input, appLocale, Banner, Text, t, row })
138
139
  );
139
140
 
140
141
  return (
@@ -449,6 +450,7 @@ function renderInput({
449
450
  Banner,
450
451
  Text,
451
452
  t,
453
+ row,
452
454
  }: {
453
455
  readonly field: EditFieldViewModel;
454
456
  readonly id: string;
@@ -459,6 +461,7 @@ function renderInput({
459
461
  readonly Banner: ReturnType<typeof usePrimitives>["Banner"];
460
462
  readonly Text: ReturnType<typeof usePrimitives>["Text"];
461
463
  readonly t: ReturnType<typeof useTranslation>;
464
+ readonly row?: Readonly<Record<string, unknown>>;
462
465
  }): ReactNode {
463
466
  const common = {
464
467
  id,
@@ -474,7 +477,8 @@ function renderInput({
474
477
  // already emits `number | undefined`, so no extra coercion is needed
475
478
  // beyond what "number" already does (#1925).
476
479
  case "number":
477
- case "bigInt":
480
+ case "bigInt": {
481
+ const unit = resolveNumberUnit(field.unit, row);
478
482
  return (
479
483
  <Input
480
484
  kind="number"
@@ -482,8 +486,10 @@ function renderInput({
482
486
  value={numberValue(field.value)}
483
487
  onChange={(v) => onChange(v)}
484
488
  {...(field.icon !== undefined && { icon: field.icon })}
489
+ {...(unit !== undefined && { unit })}
485
490
  />
486
491
  );
492
+ }
487
493
  case "decimal":
488
494
  // step="any" disables the native stepMismatch constraint — without it
489
495
  // <input type="number"> defaults to step=1 and blocks form submit on
@@ -721,6 +727,18 @@ function resolveMoneyCurrency(value: unknown, fieldCurrency: string | undefined)
721
727
  return fieldCurrency ?? "EUR";
722
728
  }
723
729
 
730
+ // Static unit → used as-is. Sibling-field reference → read the live row
731
+ // value; missing/empty/non-string sibling means no suffix, never a guess.
732
+ function resolveNumberUnit(
733
+ unit: string | { readonly field: string } | undefined,
734
+ row: Readonly<Record<string, unknown>> | undefined,
735
+ ): string | undefined {
736
+ if (unit === undefined) return undefined;
737
+ if (typeof unit === "string") return unit;
738
+ const sibling = row?.[unit.field];
739
+ return typeof sibling === "string" && sibling.length > 0 ? sibling : undefined;
740
+ }
741
+
724
742
  function moneyMinorValue(v: unknown, currency: string): number | "" {
725
743
  if (v === undefined || v === null || v === "") return "";
726
744
  if (typeof v === "number") return Math.round(v * 10 ** currencyDecimals(currency));
@@ -62,6 +62,8 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
62
62
  "kumiko.list.empty.hint": "Create the first one to get started.",
63
63
  "kumiko.list.no-entries": "No entries.",
64
64
  "kumiko.list.end-of-list": "— End of list —",
65
+ "kumiko.list.sort.label": "Sort",
66
+ "kumiko.list.sort.unsorted": "Unsorted",
65
67
 
66
68
  "kumiko.pager.status": "{from}–{to} of {total}",
67
69
  "kumiko.pager.previousPage": "Previous page",
@@ -35,7 +35,7 @@ import type {
35
35
  ConfigScope,
36
36
  ConfigValueSource,
37
37
  } from "@cosmicdrift/kumiko-framework/engine";
38
- import type { FieldIconKey, FormWidth } from "@cosmicdrift/kumiko-framework/ui-types";
38
+ import type { FieldIconKey, FormWidth, NavIconKey } from "@cosmicdrift/kumiko-framework/ui-types";
39
39
  import type {
40
40
  FieldIssue,
41
41
  ListColumnViewModel,
@@ -67,11 +67,12 @@ export type ButtonProps = {
67
67
  * setzen wenn die Action blockiert bis das Loading abgeschlossen
68
68
  * ist (verhindert Double-Submit). */
69
69
  readonly loading?: boolean;
70
- /** Semantische Klasse — default="primary". Custom-Impls entscheiden
71
- * was daraus visuell wird; die Renderer verwenden "primary" für
72
- * Save, "danger" für Delete, "secondary" für Confirm-State,
73
- * "link" für Inline-Aktionen im Fließtext (kein BG, underline). */
74
- readonly variant?: "primary" | "secondary" | "danger" | "link";
70
+ /** Semantic class — default="primary". Custom impls decide what this
71
+ * becomes visually; the renderers use "primary" for Save, "danger" for
72
+ * Delete, "secondary" for a Confirm state, "link" for inline actions in
73
+ * running text (no background, underline), "danger-ghost" for a
74
+ * destructive action as red text instead of a red fill. */
75
+ readonly variant?: "primary" | "secondary" | "danger" | "link" | "danger-ghost";
75
76
  /** Größe — default="md". "sm" für kompakte Inline-Aktionen (Toolbar,
76
77
  * Listen-Zeilen), "icon" für quadratische Icon-only-Buttons. */
77
78
  readonly size?: "sm" | "md" | "icon";
@@ -91,6 +92,10 @@ export type ButtonProps = {
91
92
  * a click (e.g. binding a drop-target handler). Web forwards it, native
92
93
  * impls ignore it (no native equivalent). */
93
94
  readonly ref?: Ref<HTMLButtonElement>;
95
+ /** Icon before the label. */
96
+ readonly icon?: NavIconKey;
97
+ /** Icon after the label (e.g. "Continue →"). */
98
+ readonly iconEnd?: NavIconKey;
94
99
  };
95
100
 
96
101
  /** Navigations-Link. `variant="button"` rendert die Button-Optik auf einem
@@ -227,6 +232,9 @@ export type InputProps =
227
232
  /** `<input step>`. "any" disables the native stepMismatch constraint
228
233
  * (needed for decimal fields — integer fields leave this unset). */
229
234
  readonly step?: number | "any";
235
+ /** Resolved display suffix (static or from a sibling field) — never
236
+ * part of the numeric value. */
237
+ readonly unit?: string;
230
238
  }
231
239
  | {
232
240
  readonly kind: "range";
@@ -696,6 +704,9 @@ export type FormProps = {
696
704
  * your catalog") — gibt dem Form-Header Kontext statt nur ein Label. */
697
705
  readonly subtitle?: ReactNode;
698
706
  readonly actions?: ReactNode;
707
+ /** Secondary, record-related actions — rendered on the left on desktop,
708
+ * on their own row below the primary action on a narrow viewport. */
709
+ readonly secondaryActions?: ReactNode;
699
710
  readonly testId?: string;
700
711
  /** Max width of the form container. Default "full" — see FormWidth
701
712
  * (`packages/types/src/screen.ts`, EditLayout.width). Native impls may