@cosmicdrift/kumiko-renderer 0.226.0 → 0.228.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.226.0",
3
+ "version": "0.228.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.226.0",
19
- "@cosmicdrift/kumiko-headless": "0.226.0",
18
+ "@cosmicdrift/kumiko-framework": "0.228.0",
19
+ "@cosmicdrift/kumiko-headless": "0.228.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.226.0"
28
+ "@cosmicdrift/kumiko-locale-de": "0.228.0"
29
29
  },
30
30
  "repository": {
31
31
  "type": "git",
@@ -178,7 +178,13 @@ export function KumikoScreen({
178
178
  );
179
179
  case "projectionDetail":
180
180
  return (
181
+ // key on the record identifier alone (never the active tab, see
182
+ // ProjectionDetailBody's own hideSectionTitles/tab handling) forces
183
+ // a full remount when navigating from one record to the next —
184
+ // without it React keeps the RenderEdit form controller alive and
185
+ // the screen briefly shows the PREVIOUS record's fields (fw#2518).
181
186
  <ProjectionDetailBody
187
+ key={entityId ?? "no-entity"}
182
188
  schema={schema}
183
189
  screen={screen}
184
190
  translate={translate}
@@ -2021,7 +2027,7 @@ function ProjectionDetailBody({
2021
2027
  readonly translate?: Translate;
2022
2028
  readonly entityId?: string;
2023
2029
  }): ReactNode {
2024
- const { Banner, Text, Heading, Grid, GridCell, Tabs, StatusBadge } = usePrimitives();
2030
+ const { Banner, Text, Heading, Grid, GridCell, Tabs, StatusBadge, Metric } = usePrimitives();
2025
2031
  const t = useTranslation();
2026
2032
  const effectiveTranslate = translate ?? t;
2027
2033
  const nav = useNav();
@@ -2245,52 +2251,64 @@ function ProjectionDetailBody({
2245
2251
  </Banner>
2246
2252
  );
2247
2253
  }
2248
- return (
2254
+ const hasHeader = screen.header !== undefined;
2255
+ const hasMetrics = screen.metrics !== undefined && screen.metrics.length > 0;
2256
+ const hasTabs = isTabsMode && Tabs !== undefined && activeSection !== undefined;
2257
+ // Rendered as RenderEdit's headerRegion (not as JSX siblings before it) so
2258
+ // this shares the same page padding/width as the record's edit card below
2259
+ // it, instead of sitting flush against the screen edge (fw record-screen
2260
+ // header polish).
2261
+ const header = screen.header;
2262
+ const headerContent = (
2249
2263
  <>
2250
- {screen.header !== undefined && (
2264
+ {header !== undefined && (
2251
2265
  <>
2252
2266
  <Heading variant="page" testId="kumiko-screen-projection-detail-title">
2253
- {String(record[screen.header.title] ?? "")}
2267
+ {String(record[header.title] ?? "")}
2254
2268
  </Heading>
2255
- {screen.header.subtitle !== undefined && (
2256
- <Text variant="muted" testId="kumiko-screen-projection-detail-subtitle">
2257
- {String(record[screen.header.subtitle] ?? "")}
2258
- </Text>
2269
+ {(header.subtitle !== undefined || header.status !== undefined) && (
2270
+ <Grid columns="auto">
2271
+ {header.subtitle !== undefined && (
2272
+ <Text variant="muted" testId="kumiko-screen-projection-detail-subtitle">
2273
+ {String(record[header.subtitle] ?? "")}
2274
+ </Text>
2275
+ )}
2276
+ {header.status !== undefined &&
2277
+ (StatusBadge !== undefined ? (
2278
+ <StatusBadge
2279
+ value={String(record[header.status] ?? "")}
2280
+ testId="kumiko-screen-projection-detail-status"
2281
+ />
2282
+ ) : (
2283
+ <Text testId="kumiko-screen-projection-detail-status">
2284
+ {String(record[header.status] ?? "")}
2285
+ </Text>
2286
+ ))}
2287
+ </Grid>
2259
2288
  )}
2260
- {screen.header.status !== undefined &&
2261
- (StatusBadge !== undefined ? (
2262
- <StatusBadge
2263
- value={String(record[screen.header.status] ?? "")}
2264
- testId="kumiko-screen-projection-detail-status"
2265
- />
2266
- ) : (
2267
- <Text testId="kumiko-screen-projection-detail-status">
2268
- {String(record[screen.header.status] ?? "")}
2269
- </Text>
2270
- ))}
2271
2289
  </>
2272
2290
  )}
2273
- {screen.metrics !== undefined && screen.metrics.length > 0 && (
2274
- <Grid columns={screen.metrics.length} testId="kumiko-screen-projection-detail-metrics">
2275
- {screen.metrics.map((metric) => {
2291
+ {hasMetrics && (
2292
+ <Grid columns="auto" testId="kumiko-screen-projection-detail-metrics">
2293
+ {screen.metrics?.map((metric) => {
2276
2294
  const labelKey = screen.fieldLabels?.[metric];
2277
- return (
2295
+ const label = labelKey !== undefined ? effectiveTranslate(labelKey) : metric;
2296
+ const value = String(record[metric] ?? "");
2297
+ const testId = `kumiko-screen-projection-detail-metric-${metric}`;
2298
+ return Metric !== undefined ? (
2299
+ <Metric key={metric} label={label} value={value} testId={testId} />
2300
+ ) : (
2278
2301
  <GridCell key={metric}>
2279
- <Text
2280
- variant="small"
2281
- testId={`kumiko-screen-projection-detail-metric-${metric}-label`}
2282
- >
2283
- {labelKey !== undefined ? effectiveTranslate(labelKey) : metric}
2284
- </Text>
2285
- <Text testId={`kumiko-screen-projection-detail-metric-${metric}-value`}>
2286
- {String(record[metric] ?? "")}
2302
+ <Text variant="small" testId={`${testId}-label`}>
2303
+ {label}
2287
2304
  </Text>
2305
+ <Text testId={`${testId}-value`}>{value}</Text>
2288
2306
  </GridCell>
2289
2307
  );
2290
2308
  })}
2291
2309
  </Grid>
2292
2310
  )}
2293
- {isTabsMode && Tabs !== undefined && activeSection !== undefined && (
2311
+ {hasTabs && activeSection !== undefined && (
2294
2312
  <Tabs
2295
2313
  testId="kumiko-screen-projection-detail-tabs"
2296
2314
  items={screen.layout.sections.map((section) => ({
@@ -2301,20 +2319,23 @@ function ProjectionDetailBody({
2301
2319
  onSelect={(id) => nav.setSearchParams({ tab: id })}
2302
2320
  />
2303
2321
  )}
2304
- <RenderEdit
2305
- screen={detailScreen}
2306
- entity={entity}
2307
- featureName={schema.featureName}
2308
- initial={record as FormValues}
2309
- entityId={entityId}
2310
- customSubmit={async () => ({ isSuccess: true, validationBlocked: false, data: undefined })}
2311
- {...(headerActions !== undefined && { actions: headerActions })}
2312
- {...(translate !== undefined && { translate })}
2313
- {...(isTabsMode && { hideSectionTitles: true })}
2314
- valueDisplay={screen.valueDisplay ?? "text"}
2315
- />
2316
2322
  </>
2317
2323
  );
2324
+ return (
2325
+ <RenderEdit
2326
+ screen={detailScreen}
2327
+ entity={entity}
2328
+ featureName={schema.featureName}
2329
+ initial={record as FormValues}
2330
+ entityId={entityId}
2331
+ customSubmit={async () => ({ isSuccess: true, validationBlocked: false, data: undefined })}
2332
+ {...(headerActions !== undefined && { actions: headerActions })}
2333
+ {...(translate !== undefined && { translate })}
2334
+ {...(isTabsMode && { hideSectionTitles: true })}
2335
+ {...((hasHeader || hasMetrics || hasTabs) && { headerRegion: headerContent })}
2336
+ valueDisplay={screen.valueDisplay ?? "text"}
2337
+ />
2338
+ );
2318
2339
  }
2319
2340
  // ---- actionForm (Tier 2.7d) ----
2320
2341
 
@@ -133,10 +133,20 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
133
133
  * readOnly fields. */
134
134
  readonly valueDisplay?: "form" | "text";
135
135
  /** Suppresses every section's own title (fields-section header, relatedList
136
- * Section title) for a host that already renders the section label
137
- * itself elsewhere (e.g. a tab strip whose label duplicates it).
138
- * Omitting this prop keeps unchanged behavior. */
136
+ * Section title) AND the form's own top-level title/subtitle for a host
137
+ * that already renders that label itself elsewhere (e.g. a tab strip
138
+ * whose label duplicates it, or a page-level header above RenderEdit).
139
+ * Without this, the form's title (`screen:<id>.title` / `screen.id`)
140
+ * renders unconditionally regardless of section-level suppression — a
141
+ * host relying on this prop to fully own the heading otherwise sees a
142
+ * redundant title above its content. Omitting this prop keeps unchanged
143
+ * behavior. */
139
144
  readonly hideSectionTitles?: boolean;
145
+ /** Extra content rendered above the card, sharing its left padding and
146
+ * width — for a host with its own header region (title/metrics/tabs)
147
+ * that would otherwise render as unpadded siblings before RenderEdit.
148
+ * Omitting this prop keeps unchanged behavior. */
149
+ readonly headerRegion?: ReactNode;
140
150
  };
141
151
 
142
152
  export type RenderEditAction = {
@@ -141,7 +141,7 @@ function deriveFormFields<TValues extends FormValues, TCtx>(
141
141
  // hooks would punish reordering sections between renders).
142
142
  function ExtensionSectionMount({
143
143
  section,
144
- entityName,
144
+ entityName: hostEntityName,
145
145
  entityId,
146
146
  initialValues,
147
147
  values,
@@ -179,7 +179,7 @@ function ExtensionSectionMount({
179
179
  return (
180
180
  <Section title={section.title} testId={`section-extension-${section.title}`}>
181
181
  <Component
182
- entityName={entityName}
182
+ entityName={section.entityName ?? hostEntityName}
183
183
  entityId={entityId}
184
184
  initialValues={initialValues}
185
185
  values={values}
@@ -222,6 +222,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
222
222
  hideActions,
223
223
  valueDisplay = "form",
224
224
  hideSectionTitles,
225
+ headerRegion,
225
226
  } = props;
226
227
  const { customSubmit } = props;
227
228
  // Translate-Fallback: wenn der Caller keine Translate-Fn übergibt,
@@ -1017,12 +1018,14 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1017
1018
  <ExtensionFormRegistryProvider value={extensionFormRegistry}>
1018
1019
  <Form
1019
1020
  onSubmit={() => void handleSubmit()}
1020
- title={formTitle}
1021
- {...(formSubtitle !== undefined && { subtitle: formSubtitle })}
1021
+ {...(hideSectionTitles !== true && { title: formTitle })}
1022
+ {...(hideSectionTitles !== true &&
1023
+ formSubtitle !== undefined && { subtitle: formSubtitle })}
1022
1024
  {...(hideActions !== true && { actions: formActions })}
1023
1025
  testId="render-edit-form"
1024
1026
  stickyActions={isWizard}
1025
1027
  {...(screen.layout.width !== undefined && { width: screen.layout.width })}
1028
+ {...(headerRegion !== undefined && { headerRegion })}
1026
1029
  >
1027
1030
  {draftCandidates !== null && (
1028
1031
  <Banner
package/src/index.ts CHANGED
@@ -192,6 +192,7 @@ export type {
192
192
  InputProps,
193
193
  LightboxProps,
194
194
  LinkProps,
195
+ MetricProps,
195
196
  ModalProps,
196
197
  PrimitivesProviderProps,
197
198
  PrimitivesRegistry,
@@ -707,6 +707,12 @@ export type FormProps = {
707
707
  * unaffected. Native impls may ignore this prop (already bottom-bar by
708
708
  * convention there). */
709
709
  readonly stickyActions?: boolean;
710
+ /** Extra content rendered above the card, inside the same page padding
711
+ * the card itself sits in — for a host that needs its own header region
712
+ * (title/metrics/tabs) to share the form's left padding and width
713
+ * instead of rendering as unpadded siblings before it. Native impls may
714
+ * ignore this prop. */
715
+ readonly headerRegion?: ReactNode;
710
716
  };
711
717
 
712
718
  /** Titled Gruppe von Feldern. Web: `<fieldset>` + `<legend>`, Native:
@@ -734,13 +740,20 @@ export type SectionProps = {
734
740
 
735
741
  /** Columns-basiertes Layout. Web: CSS grid, Native: Flex-Wrap mit
736
742
  * Width-%, oder react-native-grid. Jedes direkte Child kann eine
737
- * `GridCell`-Wrapping bekommen für span-Kontrolle. */
743
+ * `GridCell`-Wrapping bekommen für span-Kontrolle.
744
+ *
745
+ * `columns: "auto"` opts out of the N-column stretch layout entirely —
746
+ * children keep their own content width and wrap onto new lines (e.g. a
747
+ * metrics band of self-sized tiles, or an inline label+badge row) instead
748
+ * of being stretched into equal-width tracks. `GridCell` span-wrapping is
749
+ * meaningless in this mode and should be skipped. */
738
750
  export type GridProps = {
739
- readonly columns: number;
751
+ readonly columns: number | "auto";
740
752
  readonly children: ReactNode;
741
753
  readonly testId?: string;
742
754
  /** Rows visible before the grid becomes vertically scrollable. Omitted =
743
- * the grid grows with its content and never scrolls. */
755
+ * the grid grows with its content and never scrolls. Ignored when
756
+ * `columns` is "auto". */
744
757
  readonly maxRows?: number;
745
758
  };
746
759
 
@@ -964,6 +977,15 @@ export type StatusBadgeProps = {
964
977
  readonly testId?: string;
965
978
  };
966
979
 
980
+ /** Compact label/value tile (record-detail metrics band). `testId` is the
981
+ * tile's own id — the impl derives `${testId}-label`/`${testId}-value` for
982
+ * the two rendered nodes, so a caller only ever needs to know the base id. */
983
+ export type MetricProps = {
984
+ readonly label: string;
985
+ readonly value: string;
986
+ readonly testId?: string;
987
+ };
988
+
967
989
  // ---- Core-Registry (Kumiko-eigene Primitives) ----
968
990
 
969
991
  export type CorePrimitives = {
@@ -1013,6 +1035,10 @@ export type CorePrimitives = {
1013
1035
  * CorePrimitives mocks in tests keep compiling — additive rollout of
1014
1036
  * a new primitive shouldn't force every test double to grow a stub. */
1015
1037
  readonly StatusBadge?: ComponentType<StatusBadgeProps>;
1038
+ /** Optional (unlike the other Core-Primitives) so existing partial
1039
+ * CorePrimitives mocks in tests keep compiling — additive rollout of
1040
+ * a new primitive shouldn't force every test double to grow a stub. */
1041
+ readonly Metric?: ComponentType<MetricProps>;
1016
1042
  };
1017
1043
 
1018
1044
  /** Offene Extension-Zone für App-eigene Primitives. Devs erweitern