@cosmicdrift/kumiko-renderer 0.330.0 → 0.330.2

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.
@@ -5,7 +5,7 @@ import { resolveActionIcon } from "@cosmicdrift/kumiko-types/action-icon";
5
5
  import { TENANT_CURRENCY_CONFIG_KEY } from "@cosmicdrift/kumiko-types/fields";
6
6
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
7
7
  import { extractCreatedId, extractIdField } from "../components/reference-create-dialog.js";
8
- import { RenderEdit, } from "../components/render-edit.js";
8
+ import { RenderEdit, resolveRecordTitle, } from "../components/render-edit.js";
9
9
  import { needsActionConfirm, RenderEditActionButton, RenderEditActionConfirmDialog, } from "../components/render-edit-action-button.js";
10
10
  import { RenderList } from "../components/render-list.js";
11
11
  import { useDispatcher, useOptionalDispatcher } from "../context/dispatcher-context.js";
@@ -1843,6 +1843,9 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
1843
1843
  // this shares the same page padding/width as the record's edit card below
1844
1844
  // it, instead of sitting flush against the screen edge (fw record-screen
1845
1845
  // header polish).
1846
+ const recordTitle = screen.recordTitleField !== undefined
1847
+ ? resolveRecordTitle(record[screen.recordTitleField])
1848
+ : undefined;
1846
1849
  const header = screen.header;
1847
1850
  const subtitleHref = resolveSubtitleHref(header, record);
1848
1851
  // Plain Text, not Heading — the Card title slot already renders as its own
@@ -1867,15 +1870,17 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
1867
1870
  const metricsBlock = (subtitle, lead) => MetricBand !== undefined ? (_jsx(MetricBand, { testId: "kumiko-screen-projection-detail-metrics", subtitle: subtitle, ...(lead !== undefined && { lead }), children: metricItems })) : (_jsx(Grid, { columns: screen.metrics?.length ?? 1, testId: "kumiko-screen-projection-detail-metrics", children: metricItems }));
1868
1871
  const usesPageHeaderSlot = pageHeaderSlotAvailable && PageHeader !== undefined;
1869
1872
  const actionErrorBanner = actionError !== null && (_jsx(Banner, { variant: "error", testId: "render-edit-action-error", children: actionError }));
1870
- const renderHeaderContent = (headerSlot) => (_jsxs(_Fragment, { children: [usesPageHeaderSlot ? (_jsxs(_Fragment, { children: [_jsx(PageHeader, { ...(header !== undefined && { title: String(record[header.title] ?? "") }), ...(header?.status !== undefined &&
1873
+ const renderHeaderContent = (headerSlot) => (_jsxs(_Fragment, { children: [usesPageHeaderSlot ? (_jsxs(_Fragment, { children: [_jsx(PageHeader, { ...(header !== undefined && { title: String(record[header.title] ?? "") }), ...(header === undefined && recordTitle !== undefined && { title: recordTitle }), ...(header?.status !== undefined &&
1871
1874
  StatusBadge !== undefined && {
1872
1875
  status: (_jsx(StatusBadge, { value: String(record[header.status] ?? ""), tone: headerStatusTone(header, record), testId: "kumiko-screen-projection-detail-status" })),
1873
1876
  }), ...(hasHeaderActions && {
1874
1877
  actions: (_jsx(Grid, { columns: "auto", testId: "kumiko-screen-projection-detail-actions", children: _jsx(HeaderActionsBar, { actions: headerActionsList, Button: Button, Dialog: Dialog, ActionOverflowMenu: ActionOverflowMenu, onError: setActionError, collapseAfterPrimary: true }) })),
1875
1878
  }) }), MetricBand === undefined && headerSlot, (hasMetrics || headerSubtitleSlot !== undefined || headerSlot !== undefined) &&
1876
1879
  (MetricBand !== undefined || hasMetrics) &&
1877
- metricsBlock(headerSubtitleSlot, MetricBand !== undefined ? headerSlot : undefined), actionErrorBanner] })) : ((hasHeaderCard || headerSlot !== undefined) && (_jsxs(Card, { slots: {
1880
+ metricsBlock(headerSubtitleSlot, MetricBand !== undefined ? headerSlot : undefined), actionErrorBanner] })) : ((hasHeaderCard || headerSlot !== undefined || recordTitle !== undefined) && (_jsxs(Card, { slots: {
1878
1881
  ...(headerTitleSlot !== undefined && { title: headerTitleSlot }),
1882
+ ...(headerTitleSlot === undefined &&
1883
+ recordTitle !== undefined && { title: recordTitle }),
1879
1884
  ...(headerSubtitleSlot !== undefined && { subtitle: headerSubtitleSlot }),
1880
1885
  ...(headerSlot !== undefined && { headerContent: headerSlot }),
1881
1886
  ...(hasHeaderActions && { headerActions: headerActionsContent }),
@@ -1891,7 +1896,10 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
1891
1896
  ...(count !== undefined && { count }),
1892
1897
  };
1893
1898
  }), activeId: activeSection.id ?? "", onSelect: (id) => nav.setSearchParams({ tab: id }) }))] }));
1894
- return (_jsxs(_Fragment, { children: [_jsx(RenderEdit, { screen: detailScreen, entity: entity, featureName: schema.featureName, initial: record, entityId: effectiveEntityId, customSubmit: async () => ({ isSuccess: true, validationBlocked: false, data: undefined }), onReload: reloadDetail, onRelatedListDrawerAction: openDrawer, ...(translate !== undefined && { translate }), ...(hasTabs && { hideSectionTitles: true }), ...(screenFillsHeight(screen) && { fillScreenHeight: true }), ...((hasHeaderCard || hasTabs || screen.slots?.header !== undefined) && {
1899
+ return (_jsxs(_Fragment, { children: [_jsx(RenderEdit, { screen: detailScreen, entity: entity, featureName: schema.featureName, initial: record, entityId: effectiveEntityId, customSubmit: async () => ({ isSuccess: true, validationBlocked: false, data: undefined }), onReload: reloadDetail, onRelatedListDrawerAction: openDrawer, ...(translate !== undefined && { translate }), ...(hasTabs && { hideSectionTitles: true }), ...(screenFillsHeight(screen) && { fillScreenHeight: true }), ...((hasHeaderCard ||
1900
+ hasTabs ||
1901
+ screen.slots?.header !== undefined ||
1902
+ screen.recordTitleField !== undefined) && {
1895
1903
  headerRegion: renderHeaderContent,
1896
1904
  }), buildSectionActions: buildSectionActions, valueDisplay: screen.valueDisplay ?? "text" }, `${entityId}:${reloadNonce}`), _jsx(DrawerHost, { schema: schema, drawerAction: drawerAction, drawerScreen: drawerScreen, ...(drawerInitialValues !== undefined && { drawerInitialValues }), userRoles: userRoles, ...(translate !== undefined && { translate }), onClose: closeDrawer, onSuccess: () => {
1897
1905
  closeDrawer();
@@ -10,7 +10,7 @@ const FIELD_CELL_WIDTH_BY_TYPE = {
10
10
  locatedTimestamp: "timestamp",
11
11
  select: "select",
12
12
  multiSelect: "select",
13
- boolean: "auto",
13
+ boolean: "toggle",
14
14
  longText: "full",
15
15
  embedded: "full",
16
16
  jsonb: "full",
@@ -3,4 +3,6 @@ import type { RenderEditProps } from "./render-edit-types.js";
3
3
  export type { RenderEditAction, RenderEditChangeState, RenderEditControls, RenderEditProps, } from "./render-edit-types.js";
4
4
  import { type ReactNode } from "react";
5
5
  export declare const PATCH_DRAFT_SAVE_DEBOUNCE_MS = 500;
6
+ /** A record title is a non-blank string; anything else leaves the screen title alone. */
7
+ export declare function resolveRecordTitle(value: unknown): string | undefined;
6
8
  export declare function RenderEdit<TValues extends FormValues, TCtx = unknown>(props: RenderEditProps<TValues, TCtx>): ReactNode;
@@ -3,7 +3,7 @@ import { evalFieldCondition, isFieldsEditSection, normalizeEditField, PROJECTION
3
3
  import { computeEditViewModel } from "@cosmicdrift/kumiko-headless";
4
4
  import { RenderEditActionButton } from "./render-edit-action-button.js";
5
5
  import { AllFieldsRequiredProvider } from "./render-field.js";
6
- import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
6
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
7
7
  import { ExtensionFormRegistryProvider, useExtensionFormHost, } from "../app/extension-form-submit.js";
8
8
  import { extensionSectionName, useExtensionSectionComponent } from "../app/extension-sections.js";
9
9
  import { useOptionalDispatcher } from "../context/dispatcher-context.js";
@@ -141,6 +141,10 @@ function EditSlotMount({ slot, slotName, screenId, entityName, entityId, values,
141
141
  function formSectionDomId(sectionIndex) {
142
142
  return `form-section-${sectionIndex}`;
143
143
  }
144
+ /** A record title is a non-blank string; anything else leaves the screen title alone. */
145
+ export function resolveRecordTitle(value) {
146
+ return typeof value === "string" && value.trim() !== "" ? value : undefined;
147
+ }
144
148
  export function RenderEdit(props) {
145
149
  const { screen, entity, featureName, initial, writeCommand, translate: translateProp, ctx, schema, onSubmit, payloadMode = "values", buildPayload, serverFieldPathPrefix, onDelete, onCancel, onReload, onDirtyChange, onCopyLink, actions, onRelatedListDrawerAction, submitLabel, submitVariant, summary, labelAppendix, fieldAppendix, entityId: entityIdProp, extensionInitialValues, onChange, onControlsReady, fields: fieldsFilter, disabled = false, hideActions, valueDisplay = "form", hideSectionTitles, fillScreenHeight, headerRegion, buildSectionActions, } = props;
146
150
  const { customSubmit } = props;
@@ -957,11 +961,8 @@ export function RenderEdit(props) {
957
961
  return id == null || id === "";
958
962
  })();
959
963
  const formMode = isCreate ? "create" : "edit";
960
- const recordTitleValue = !isCreate && screen.recordTitleField !== undefined
961
- ? initial[screen.recordTitleField]
962
- : undefined;
963
- const recordTitle = typeof recordTitleValue === "string" && recordTitleValue.trim() !== ""
964
- ? recordTitleValue
964
+ const recordTitle = !isCreate && screen.recordTitleField !== undefined
965
+ ? resolveRecordTitle(initial[screen.recordTitleField])
965
966
  : undefined;
966
967
  const resolveScreenText = (suffix) => {
967
968
  for (const key of [
@@ -1084,12 +1085,14 @@ export function RenderEdit(props) {
1084
1085
  // over TValues; controller is mount-lifetime-stable, see onControlsReady above.
1085
1086
  patch: patchAndScheduleDraftSave, validate: scopedValidate, hideTitle: hideSectionTitles, ...(hideSectionTitles !== true &&
1086
1087
  sectionActionsEl !== undefined && { actions: sectionActionsEl }) }, section.title));
1087
- // Tabs mode: unframed like relatedList tabs; the Card chrome is
1088
- // only kept when section actions need a header to live in.
1089
- // testId stays only on the inner Section — the outer wrapper is
1090
- // purely chrome, giving it the same testId would register two
1088
+ // Tabs mode: an unframed padded panel (the tab panel is the
1089
+ // surface), so extension content neither sits flush at the shell
1090
+ // edge nor touches the tab strip. testId stays only on the inner
1091
+ // Section — giving the wrapper the same one would register two
1091
1092
  // elements under one id.
1092
- const wrapped = hideSectionTitles !== true ? (mount) : sectionActionsEl !== undefined ? (_jsx(Card, { slots: { headerActions: sectionActionsEl }, children: mount }, section.title)) : (_jsx(Fragment, { children: mount }, section.title));
1093
+ const wrapped = hideSectionTitles !== true ? (mount) : (_jsx(Card, { options: { framed: false }, ...(sectionActionsEl !== undefined && {
1094
+ slots: { headerActions: sectionActionsEl },
1095
+ }), children: mount }, section.title));
1093
1096
  return wrapWizardStep(section.title, wrapped);
1094
1097
  }
1095
1098
  if (section.kind === "relatedList") {
@@ -1126,8 +1129,8 @@ export function RenderEdit(props) {
1126
1129
  }), ...(fieldAppendix !== undefined && {
1127
1130
  fieldAppendix: fieldAppendix(field.field),
1128
1131
  }), allIssues: snapshot.errors, valueDisplay: valueDisplay, row: snapshot.values, ...(!isCreate && field.field in snapshot.changes && { changed: true }) }, field.field))) }, gridKey));
1129
- // Tabs mode renders one or more titled cards, so this card's actual
1130
- // heading doesn't visually duplicate the short Tab strip label.
1132
+ // Tabs mode: the tab panel is the surface, so field sections render
1133
+ // as unframed padded panels; group cards inside stay framed.
1131
1134
  // Stays `Card`, not `Section` — Section always flattens to a
1132
1135
  // borderless divider when rendered inside this component's own
1133
1136
  // `<Form>` (InsideFormContext), even with `chromeless` set, so it
@@ -1140,13 +1143,13 @@ export function RenderEdit(props) {
1140
1143
  // group cards (Card-in-Card) — the boot-validator rejects that
1141
1144
  // combination in tabs mode, so `sectionActionsEl` is always
1142
1145
  // undefined here at runtime.
1143
- return wrapWizardStep(sectionKey, groupsGrid);
1146
+ return wrapWizardStep(sectionKey, _jsx(Card, { options: { framed: false }, children: groupsGrid }, sectionKey));
1144
1147
  }
1145
1148
  // No section title here (fw#3218) — the Tab strip right above
1146
1149
  // already names this panel, so a repeated Card title would just
1147
1150
  // duplicate it. The title row only exists to carry actions, and
1148
1151
  // then it carries only the actions, never the title text.
1149
- const cardEl = (_jsx(Card, { ...(sectionActionsEl !== undefined && {
1152
+ const cardEl = (_jsx(Card, { options: { framed: false }, ...(sectionActionsEl !== undefined && {
1150
1153
  slots: { headerActions: sectionActionsEl },
1151
1154
  }), testId: `section-${sectionKey}`, children: renderFieldGrid(section.fields, section.columns, `${sectionKey}-grid`) }, sectionKey));
1152
1155
  return wrapWizardStep(sectionKey, cardEl);
@@ -88,11 +88,10 @@ export function WriteFormSection({ section, featureName, translate, hideTitle, o
88
88
  const submitButton = (_jsx(Button, { type: "button", variant: "primary", disabled: isSubmitting, loading: isSubmitting, onClick: () => void handleSubmit(), testId: "write-form-section-submit", children: section.submitLabel ?? effectiveTranslate("kumiko.actions.save") }));
89
89
  const titleRowActions = actions !== undefined ? (_jsxs(_Fragment, { children: [actions, submitButton] })) : (submitButton);
90
90
  // Section always flattens to a borderless divider when rendered inside
91
- // RenderEdit's own <Form> in tabs mode (hideTitle) — Card frames it there
92
- // instead, same reason render-edit.tsx's tabs-mode fields/extension
93
- // branches stay on Card.
91
+ // RenderEdit's own <Form> in tabs mode (hideTitle) — an unframed Card panel
92
+ // stands in, like render-edit.tsx's tabs-mode fields/extension branches.
94
93
  if (hideTitle) {
95
- return (_jsx(Card, { slots: {
94
+ return (_jsx(Card, { options: { framed: false }, slots: {
96
95
  ...(section.description !== undefined && { subtitle: section.description }),
97
96
  headerActions: titleRowActions,
98
97
  }, testId: `write-form-${section.title ?? "section"}`, children: content }));
@@ -876,7 +876,7 @@ export type GridProps = {
876
876
  readonly flow?: boolean;
877
877
  };
878
878
  /** Width class of a field cell inside a `flow` grid: sized by what the field holds. */
879
- export type FieldCellWidth = "text" | "number" | "money" | "date" | "timestamp" | "select" | "full" | "auto";
879
+ export type FieldCellWidth = "text" | "number" | "money" | "date" | "timestamp" | "select" | "full" | "toggle";
880
880
  /** Span-Wrapper für ein Kind innerhalb eines Grid. Web: `style={{gridColumn: span N}}`,
881
881
  * Native: eigenes Width-Rechnen. */
882
882
  export type GridCellProps = {
@@ -1029,6 +1029,9 @@ export type CardOptions = {
1029
1029
  * relatedList table sizing to the panel instead of the page) instead of
1030
1030
  * normal document-flow height. Default false. */
1031
1031
  readonly fillHeight?: boolean;
1032
+ /** false drops border, background, shadow and radius but keeps padding and
1033
+ * header slots — a panel inside a host surface such as a tab panel. Default true. */
1034
+ readonly framed?: boolean;
1032
1035
  };
1033
1036
  /** Generische Card-Chrome (border/bg/radius/shadow + Header/Body/Footer) als
1034
1037
  * Primitive — eine Quelle für alle Card-Flächen, statt pro App nachgebaut.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.330.0",
3
+ "version": "0.330.2",
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,9 +15,9 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@cosmicdrift/kumiko-framework": "0.330.0",
19
- "@cosmicdrift/kumiko-headless": "0.330.0",
20
- "@cosmicdrift/kumiko-types": "0.330.0",
18
+ "@cosmicdrift/kumiko-framework": "0.330.2",
19
+ "@cosmicdrift/kumiko-headless": "0.330.2",
20
+ "@cosmicdrift/kumiko-types": "0.330.2",
21
21
  "react": "^19.2.6",
22
22
  "temporal-polyfill": "^0.3.2",
23
23
  "zod": "^4.4.3"
@@ -28,7 +28,7 @@
28
28
  "@types/react-dom": "^19.2.3",
29
29
  "jsdom": "^29.1.1",
30
30
  "react-dom": "^19.2.6",
31
- "@cosmicdrift/kumiko-locale-de": "0.330.0"
31
+ "@cosmicdrift/kumiko-locale-de": "0.330.2"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",
package/src/changes.json CHANGED
@@ -1,4 +1,18 @@
1
1
  [
2
+ {
3
+ "version": "0.330.1",
4
+ "type": "breaking",
5
+ "title": "Boolean fields in flow forms use FieldCellWidth \"toggle\" instead of \"auto\"",
6
+ "detail": "In flow forms (screen forms and drawers) a boolean field's label now shares the top line with its neighbours' labels and the switch sits on the input line. The `FieldCellWidth` union loses `\"auto\"` and gains `\"toggle\"`; the default web Grid maps it to a fixed minimum width.",
7
+ "migration": "Custom Grid/GridCell primitives keyed by FieldCellWidth: rename `auto` to `toggle`."
8
+ },
9
+ {
10
+ "version": "0.330.1",
11
+ "type": "improvement",
12
+ "title": "projectionDetail tab panels get page padding, recordTitleField on projectionDetail",
13
+ "detail": "Tab panels on a projectionDetail with `layout.mode: \"tabs\"` now pad their content like the rest of the page: extension, field-section, groups and writeForm tabs render as an unframed padded panel with space below the tab strip. relatedList tabs stay flush. This fixes extension tabs sitting flush against the shell edge since 0.330.0. `CardOptions.framed` (default true) drops the card frame and keeps the padding. A projectionDetail without `header` can set `recordTitleField`: the query output field then titles the page header (breadcrumb \"list > record\"). The boot check requires the field to be in the query's outputSchema and rejects it next to `header`.",
14
+ "migration": "Extension tab panels get their padding back, so extension components that added their own padding to make up for the flush panel in 0.330.0 should drop it. Set `recordTitleField: \"<field>\"` on a projectionDetail without `header` to show the record name in the breadcrumb."
15
+ },
2
16
  {
3
17
  "version": "0.330.0",
4
18
  "type": "fix",