@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 +4 -4
- package/src/app/kumiko-screen.tsx +65 -44
- package/src/components/render-edit-types.ts +13 -3
- package/src/components/render-edit.tsx +7 -4
- package/src/index.ts +1 -0
- package/src/primitives.tsx +29 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "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.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "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.
|
|
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
|
-
|
|
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
|
-
{
|
|
2264
|
+
{header !== undefined && (
|
|
2251
2265
|
<>
|
|
2252
2266
|
<Heading variant="page" testId="kumiko-screen-projection-detail-title">
|
|
2253
|
-
{String(record[
|
|
2267
|
+
{String(record[header.title] ?? "")}
|
|
2254
2268
|
</Heading>
|
|
2255
|
-
{
|
|
2256
|
-
<
|
|
2257
|
-
{
|
|
2258
|
-
|
|
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
|
-
{
|
|
2274
|
-
<Grid columns=
|
|
2275
|
-
{screen.metrics
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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)
|
|
137
|
-
* itself elsewhere (e.g. a tab strip
|
|
138
|
-
*
|
|
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
|
|
1021
|
-
{...(
|
|
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
package/src/primitives.tsx
CHANGED
|
@@ -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
|