@cosmicdrift/kumiko-renderer 0.329.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.
package/README.md ADDED
@@ -0,0 +1,13 @@
1
+ # @cosmicdrift/kumiko-renderer
2
+
3
+ Platform-agnostic React renderer for Kumiko screens: primitives contract, hooks, `KumikoScreen`, navigation and SSE abstractions. No DOM, no `EventSource`, no `react-dom`.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ bun add @cosmicdrift/kumiko-renderer
9
+ ```
10
+
11
+ Pair it with `@cosmicdrift/kumiko-renderer-web` for the browser.
12
+
13
+ Documentation: https://kumiko.rocks
@@ -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";
@@ -154,10 +154,12 @@ function pageSizeOptionsFor(currentLimit) {
154
154
  ? PAGE_SIZE_OPTIONS
155
155
  : [...PAGE_SIZE_OPTIONS, currentLimit].sort((a, b) => a - b);
156
156
  }
157
- function useNavigateToCreateFor(schema, entityName) {
157
+ function useNavigateToCreateFor(schema, entityName, createScreen) {
158
158
  const nav = useNav();
159
159
  const host = useReturnHost();
160
160
  const editScreenId = useMemo(() => {
161
+ if (createScreen !== undefined)
162
+ return createScreen;
161
163
  // allowCreate:false = update-only Edit-Screen (Create läuft über einen
162
164
  // Lifecycle-Write) — der zählt nicht als „+ Neu"-Ziel. singleton:true
163
165
  // öffnet ohne entityId den vorhandenen Record (EntityEditSingletonBody)
@@ -167,7 +169,7 @@ function useNavigateToCreateFor(schema, entityName) {
167
169
  s.allowCreate !== false &&
168
170
  s.singleton !== true);
169
171
  return edit !== undefined ? lastSegment(edit.id) : undefined;
170
- }, [schema.screens, entityName]);
172
+ }, [schema.screens, entityName, createScreen]);
171
173
  const navigate = useCallback(() => {
172
174
  if (editScreenId !== undefined)
173
175
  navigateWithReturnTo(nav, { screenId: editScreenId }, host);
@@ -730,12 +732,13 @@ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onRelo
730
732
  const handleCancel = useCallback(() => navigateToReturnOr(nav, returnTarget, navigateToList), [nav, returnTarget, navigateToList]);
731
733
  const handleDelete = useCallback(async () => {
732
734
  const res = await dispatcher.write(deleteCommand, { id: entityId });
733
- if (res.isSuccess) {
734
- // Never return onto the just-deleted record.
735
- const target = returnTarget?.entityId !== entityId ? returnTarget : undefined;
736
- navigateToReturnOr(nav, target, navigateToList);
737
- onDeleted?.();
738
- }
735
+ if (!res.isSuccess)
736
+ return res.error;
737
+ // Never return onto the just-deleted record.
738
+ const target = returnTarget?.entityId !== entityId ? returnTarget : undefined;
739
+ navigateToReturnOr(nav, target, navigateToList);
740
+ onDeleted?.();
741
+ return undefined;
739
742
  }, [dispatcher, deleteCommand, entityId, navigateToList, onDeleted, returnTarget, nav]);
740
743
  // Never seed a tenant-declared money field with entityDefaultCurrency while
741
744
  // its real tenant currency is still in flight (fw#2933) — wait instead. A
@@ -956,7 +959,7 @@ function EntityListScreen({ schema, screen, translate, onRowClick, }) {
956
959
  }
957
960
  function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
958
961
  const featureName = schema.featureName;
959
- const onCreate = useNavigateToCreateFor(schema, screen.entity);
962
+ const onCreate = useNavigateToCreateFor(schema, screen.entity, screen.createScreen);
960
963
  const { Banner } = usePrimitives();
961
964
  const queryType = entityQueryCommand(featureName, screen.entity, "list");
962
965
  const nav = useNav();
@@ -1840,6 +1843,9 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
1840
1843
  // this shares the same page padding/width as the record's edit card below
1841
1844
  // it, instead of sitting flush against the screen edge (fw record-screen
1842
1845
  // header polish).
1846
+ const recordTitle = screen.recordTitleField !== undefined
1847
+ ? resolveRecordTitle(record[screen.recordTitleField])
1848
+ : undefined;
1843
1849
  const header = screen.header;
1844
1850
  const subtitleHref = resolveSubtitleHref(header, record);
1845
1851
  // Plain Text, not Heading — the Card title slot already renders as its own
@@ -1861,18 +1867,20 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
1861
1867
  return Metric !== undefined ? (_jsx(Metric, { label: label, value: value, testId: testId, ...(onPress !== undefined && { onPress }) }, field)) : (_jsxs(GridCell, { children: [_jsx(Text, { variant: "small", testId: `${testId}-label`, children: label }), _jsx(Text, { testId: `${testId}-value`, children: value })] }, field));
1862
1868
  });
1863
1869
  // Metrics are a dl band when the primitive exists; the old Grid stays as the fallback.
1864
- const metricsBlock = (subtitle) => MetricBand !== undefined ? (_jsx(MetricBand, { testId: "kumiko-screen-projection-detail-metrics", subtitle: subtitle, children: metricItems })) : (_jsx(Grid, { columns: screen.metrics?.length ?? 1, testId: "kumiko-screen-projection-detail-metrics", children: metricItems }));
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 }));
1865
1871
  const usesPageHeaderSlot = pageHeaderSlotAvailable && PageHeader !== undefined;
1866
1872
  const actionErrorBanner = actionError !== null && (_jsx(Banner, { variant: "error", testId: "render-edit-action-error", children: actionError }));
1867
- const renderHeaderContent = (headerSlot) => (_jsxs(_Fragment, { children: [usesPageHeaderSlot && headerSlot === undefined ? (_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 &&
1868
1874
  StatusBadge !== undefined && {
1869
1875
  status: (_jsx(StatusBadge, { value: String(record[header.status] ?? ""), tone: headerStatusTone(header, record), testId: "kumiko-screen-projection-detail-status" })),
1870
1876
  }), ...(hasHeaderActions && {
1871
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 }) })),
1872
- }) }), (hasMetrics || headerSubtitleSlot !== undefined) &&
1878
+ }) }), MetricBand === undefined && headerSlot, (hasMetrics || headerSubtitleSlot !== undefined || headerSlot !== undefined) &&
1873
1879
  (MetricBand !== undefined || hasMetrics) &&
1874
- metricsBlock(headerSubtitleSlot), actionErrorBanner] })) : ((hasHeaderCard || headerSlot !== undefined) && (_jsxs(Card, { slots: {
1880
+ metricsBlock(headerSubtitleSlot, MetricBand !== undefined ? headerSlot : undefined), actionErrorBanner] })) : ((hasHeaderCard || headerSlot !== undefined || recordTitle !== undefined) && (_jsxs(Card, { slots: {
1875
1881
  ...(headerTitleSlot !== undefined && { title: headerTitleSlot }),
1882
+ ...(headerTitleSlot === undefined &&
1883
+ recordTitle !== undefined && { title: recordTitle }),
1876
1884
  ...(headerSubtitleSlot !== undefined && { subtitle: headerSubtitleSlot }),
1877
1885
  ...(headerSlot !== undefined && { headerContent: headerSlot }),
1878
1886
  ...(hasHeaderActions && { headerActions: headerActionsContent }),
@@ -1888,7 +1896,10 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
1888
1896
  ...(count !== undefined && { count }),
1889
1897
  };
1890
1898
  }), activeId: activeSection.id ?? "", onSelect: (id) => nav.setSearchParams({ tab: id }) }))] }));
1891
- 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) && {
1892
1903
  headerRegion: renderHeaderContent,
1893
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: () => {
1894
1905
  closeDrawer();
@@ -2187,7 +2198,7 @@ function ConfigEditBody({ schema, screen, translate, }) {
2187
2198
  if (initial === null) {
2188
2199
  return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
2189
2200
  }
2190
- return (_jsx(RenderEdit, { screen: synthScreen, entity: synthEntity, featureName: schema.featureName, initial: initial, customSubmit: customSubmit, ...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel }), ...(translate !== undefined && { translate }), labelAppendix: (fieldName) => {
2201
+ return (_jsx(RenderEdit, { screen: synthScreen, entity: synthEntity, featureName: schema.featureName, initial: initial, customSubmit: customSubmit, ...(screenFillsHeight(screen) && { fillScreenHeight: true }), ...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel }), ...(translate !== undefined && { translate }), labelAppendix: (fieldName) => {
2191
2202
  const cascade = cascades[fieldName];
2192
2203
  if (cascade === undefined)
2193
2204
  return undefined;
@@ -1,6 +1,6 @@
1
1
  import type { EditFieldViewModel, FieldIssue } from "@cosmicdrift/kumiko-headless";
2
2
  import type { ReactNode } from "react";
3
- import type { usePrimitives } from "../primitives.js";
3
+ import type { FieldCellWidth, usePrimitives } from "../primitives.js";
4
4
  export type GridCellForFieldProps = {
5
5
  readonly field: EditFieldViewModel;
6
6
  readonly columns: number;
@@ -22,4 +22,5 @@ export type GridCellForFieldProps = {
22
22
  readonly changed?: boolean;
23
23
  readonly flow?: boolean;
24
24
  };
25
+ export declare function fieldCellWidth(field: EditFieldViewModel): FieldCellWidth;
25
26
  export declare function GridCellForField({ field, columns, issues, onChange, GridCell, featureName, labelAppendix, fieldAppendix, allIssues, valueDisplay, row, changed, flow, }: GridCellForFieldProps): ReactNode;
@@ -6,9 +6,11 @@ const FIELD_CELL_WIDTH_BY_TYPE = {
6
6
  bigInt: "number",
7
7
  money: "money",
8
8
  date: "date",
9
+ timestamp: "timestamp",
10
+ locatedTimestamp: "timestamp",
9
11
  select: "select",
10
12
  multiSelect: "select",
11
- boolean: "auto",
13
+ boolean: "toggle",
12
14
  longText: "full",
13
15
  embedded: "full",
14
16
  jsonb: "full",
@@ -17,9 +19,14 @@ const FIELD_CELL_WIDTH_BY_TYPE = {
17
19
  image: "full",
18
20
  images: "full",
19
21
  };
20
- function fieldCellWidth(field) {
22
+ const READONLY_FULL_WIDTH_TYPES = new Set(["text", "uuid"]);
23
+ // Exported for unit tests.
24
+ export function fieldCellWidth(field) {
21
25
  if (field.span !== undefined || (field.type === "text" && field.multiline))
22
26
  return "full";
27
+ // A readonly value (UUID, long code) is plain text that must not be cut at the input width.
28
+ if (field.readOnly && READONLY_FULL_WIDTH_TYPES.has(field.type))
29
+ return "full";
23
30
  return FIELD_CELL_WIDTH_BY_TYPE[field.type] ?? "text";
24
31
  }
25
32
  export function GridCellForField({ field, columns, issues, onChange, GridCell, featureName, labelAppendix, fieldAppendix, allIssues, valueDisplay, row, changed, flow, }) {
@@ -1,5 +1,5 @@
1
1
  import type { EntityDefinition, EntityEditScreenDefinition, IconKey, RowAction, RowActionDrawer } from "@cosmicdrift/kumiko-framework/ui-types";
2
- import type { FormSnapshot, FormValues, SubmitResult, Translate } from "@cosmicdrift/kumiko-headless";
2
+ import type { DispatcherError, FormSnapshot, FormValues, SubmitResult, Translate } from "@cosmicdrift/kumiko-headless";
3
3
  import type { ReactNode } from "react";
4
4
  import type * as z from "zod";
5
5
  export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
@@ -38,7 +38,9 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
38
38
  * onto form fields — see SubmitConfig.serverFieldPathPrefix. Only needed
39
39
  * when buildPayload nests the form values under a key. */
40
40
  readonly serverFieldPathPrefix?: string;
41
- readonly onDelete?: () => Promise<void> | void;
41
+ /** Returning the rejected write's error shows it in the form-error banner;
42
+ * returning nothing means the delete went through. */
43
+ readonly onDelete?: () => Promise<DispatcherError | void> | DispatcherError | void;
42
44
  readonly onCancel?: () => void;
43
45
  readonly onReload?: () => void;
44
46
  /** Fires when the form gains or loses unsaved input (field changes or a
@@ -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;
@@ -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;
@@ -520,7 +524,7 @@ export function RenderEdit(props) {
520
524
  section.visible), [vm.sections, fieldsFilter]);
521
525
  const allFieldsRequired = useMemo(() => {
522
526
  const fillable = filteredSections.flatMap((section) => section.kind === "fields"
523
- ? section.fields.filter((field) => field.visible && !field.readOnly)
527
+ ? section.fields.filter((field) => field.visible && !field.readOnly && field.type !== "boolean")
524
528
  : []);
525
529
  return fillable.length > 0 && fillable.every((field) => field.required);
526
530
  }, [filteredSections]);
@@ -957,6 +961,9 @@ export function RenderEdit(props) {
957
961
  return id == null || id === "";
958
962
  })();
959
963
  const formMode = isCreate ? "create" : "edit";
964
+ const recordTitle = !isCreate && screen.recordTitleField !== undefined
965
+ ? resolveRecordTitle(initial[screen.recordTitleField])
966
+ : undefined;
960
967
  const resolveScreenText = (suffix) => {
961
968
  for (const key of [
962
969
  `screen:${screen.id}.${formMode}.${suffix}`,
@@ -1006,7 +1013,7 @@ export function RenderEdit(props) {
1006
1013
  })
1007
1014
  : translate("kumiko.wizard.step", { current: currentStep + 1, total: lastStepIndex + 1 });
1008
1015
  })();
1009
- return (_jsxs(ExtensionFormRegistryProvider, { value: extensionFormRegistry, children: [titleInShell && (_jsx(PageHeader, { title: formTitle, ...(headerMenuItems.length > 0 &&
1016
+ return (_jsxs(ExtensionFormRegistryProvider, { value: extensionFormRegistry, children: [titleInShell && (_jsx(PageHeader, { title: formTitle, ...(recordTitle !== undefined && { recordTitle }), ...(headerMenuItems.length > 0 &&
1010
1017
  ActionOverflowMenu !== undefined && {
1011
1018
  actions: (_jsx(ActionOverflowMenu, { label: translate("kumiko.list.row-actions.more"), items: headerMenuItems, testId: "render-edit-header-menu" })),
1012
1019
  }) })), _jsx(Form, { onSubmit: () => void handleSubmit(), ...(hideSectionTitles !== true && !titleInShell && { title: formTitle }), ...(formSubtitle !== undefined && !stepRail && { subtitle: formSubtitle }), ...(isScreenForm && { screenForm: true }), ...(unsavedCount > 0 && { unsavedCount }), ...(sectionNavItems.length > 0 && { sectionNav: sectionNavItems }), ...(stepRail && {
@@ -1078,17 +1085,14 @@ export function RenderEdit(props) {
1078
1085
  // over TValues; controller is mount-lifetime-stable, see onControlsReady above.
1079
1086
  patch: patchAndScheduleDraftSave, validate: scopedValidate, hideTitle: hideSectionTitles, ...(hideSectionTitles !== true &&
1080
1087
  sectionActionsEl !== undefined && { actions: sectionActionsEl }) }, section.title));
1081
- // Section always flattens to a borderless divider inside this
1082
- // component's own <Form>, so tabs mode frames it with Card
1083
- // instead — same reason the fields-section tabs branch below
1084
- // stays on Card. No title here: the tab strip already labels the
1085
- // panel (matches the non-tabs Section branch above via hideTitle).
1086
- // testId stays only on the inner Section — this outer Card is
1087
- // 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
1088
1092
  // elements under one id.
1089
- const wrapped = hideSectionTitles === true ? (_jsx(Card, { ...(sectionActionsEl !== undefined && {
1093
+ const wrapped = hideSectionTitles !== true ? (mount) : (_jsx(Card, { options: { framed: false }, ...(sectionActionsEl !== undefined && {
1090
1094
  slots: { headerActions: sectionActionsEl },
1091
- }), children: mount }, section.title)) : (mount);
1095
+ }), children: mount }, section.title));
1092
1096
  return wrapWizardStep(section.title, wrapped);
1093
1097
  }
1094
1098
  if (section.kind === "relatedList") {
@@ -1125,8 +1129,8 @@ export function RenderEdit(props) {
1125
1129
  }), ...(fieldAppendix !== undefined && {
1126
1130
  fieldAppendix: fieldAppendix(field.field),
1127
1131
  }), allIssues: snapshot.errors, valueDisplay: valueDisplay, row: snapshot.values, ...(!isCreate && field.field in snapshot.changes && { changed: true }) }, field.field))) }, gridKey));
1128
- // Tabs mode renders one or more titled cards, so this card's actual
1129
- // 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.
1130
1134
  // Stays `Card`, not `Section` — Section always flattens to a
1131
1135
  // borderless divider when rendered inside this component's own
1132
1136
  // `<Form>` (InsideFormContext), even with `chromeless` set, so it
@@ -1139,13 +1143,13 @@ export function RenderEdit(props) {
1139
1143
  // group cards (Card-in-Card) — the boot-validator rejects that
1140
1144
  // combination in tabs mode, so `sectionActionsEl` is always
1141
1145
  // undefined here at runtime.
1142
- return wrapWizardStep(sectionKey, groupsGrid);
1146
+ return wrapWizardStep(sectionKey, _jsx(Card, { options: { framed: false }, children: groupsGrid }, sectionKey));
1143
1147
  }
1144
1148
  // No section title here (fw#3218) — the Tab strip right above
1145
1149
  // already names this panel, so a repeated Card title would just
1146
1150
  // duplicate it. The title row only exists to carry actions, and
1147
1151
  // then it carries only the actions, never the title text.
1148
- const cardEl = (_jsx(Card, { ...(sectionActionsEl !== undefined && {
1152
+ const cardEl = (_jsx(Card, { options: { framed: false }, ...(sectionActionsEl !== undefined && {
1149
1153
  slots: { headerActions: sectionActionsEl },
1150
1154
  }), testId: `section-${sectionKey}`, children: renderFieldGrid(section.fields, section.columns, `${sectionKey}-grid`) }, sectionKey));
1151
1155
  return wrapWizardStep(sectionKey, cardEl);
@@ -1163,7 +1167,15 @@ export function RenderEdit(props) {
1163
1167
  }), formError !== null && (_jsx(Banner, { variant: "error", testId: "render-edit-form-error", actions: formError.code === "version_conflict" && onReload !== undefined ? (_jsx(Button, { variant: "secondary", onClick: () => {
1164
1168
  onReload();
1165
1169
  setFormError(null);
1166
- }, testId: "render-edit-form-error-reload", children: translate("kumiko.actions.reload") })) : undefined, children: _jsx(Text, { testId: "render-edit-form-error-key", children: translate(formError.i18nKey) }) })), extensionErrorKey !== null && (_jsx(Banner, { variant: "error", testId: "render-edit-extension-error", children: _jsx(Text, { testId: "render-edit-extension-error-key", children: translate(extensionErrorKey) }) })), actionError !== null && (_jsx(Banner, { variant: "error", testId: "render-edit-action-error", children: actionError })), onDelete !== undefined && (_jsx(Dialog, { open: confirmDeleteOpen, onOpenChange: setConfirmDeleteOpen, title: translate("kumiko.actions.delete-confirm"), confirmLabel: translate("kumiko.actions.delete"), variant: "danger", onConfirm: async () => {
1167
- await onDelete();
1170
+ }, testId: "render-edit-form-error-reload", children: translate("kumiko.actions.reload") })) : undefined, children: _jsx(Text, { testId: "render-edit-form-error-key", children: translate(formError.i18nKey, formError.i18nParams) }) })), extensionErrorKey !== null && (_jsx(Banner, { variant: "error", testId: "render-edit-extension-error", children: _jsx(Text, { testId: "render-edit-extension-error-key", children: translate(extensionErrorKey) }) })), actionError !== null && (_jsx(Banner, { variant: "error", testId: "render-edit-action-error", children: actionError })), onDelete !== undefined && (_jsx(Dialog, { open: confirmDeleteOpen, onOpenChange: setConfirmDeleteOpen, title: translate("kumiko.actions.delete-confirm"), confirmLabel: translate("kumiko.actions.delete"), variant: "danger", onConfirm: async () => {
1171
+ const rejection = await onDelete();
1172
+ // A rejected delete (e.g. a preDelete hook) carries its reason in
1173
+ // the field issues; the top-level key is only "validation failed".
1174
+ if (rejection !== undefined) {
1175
+ const issue = rejection.details?.fields?.[0];
1176
+ setFormError(issue === undefined
1177
+ ? rejection
1178
+ : { ...rejection, i18nKey: issue.i18nKey, i18nParams: issue.params });
1179
+ }
1168
1180
  }, testId: "render-edit-delete-dialog" }))] }) })] }));
1169
1181
  }
@@ -54,7 +54,7 @@ export function RenderField({ field, issues, onChange, featureName, labelAppendi
54
54
  t,
55
55
  row,
56
56
  }));
57
- return (_jsx(Field, { id: id, label: field.label, required: field.required && !allFieldsRequired, ...(issues !== undefined && { issues }), ...(labelAppendix !== undefined && { labelAppendix }), ...(fieldAppendix !== undefined && { fieldAppendix }), ...(changed === true && { changed }), testId: `field-${field.field}`, children: control }));
57
+ return (_jsx(Field, { id: id, label: field.label, required: field.required && field.type !== "boolean" && !allFieldsRequired, ...(issues !== undefined && { issues }), ...(labelAppendix !== undefined && { labelAppendix }), ...(fieldAppendix !== undefined && { fieldAppendix }), ...(changed === true && { changed }), testId: `field-${field.field}`, children: control }));
58
58
  }
59
59
  // Where a reference field's option rows come from, and which of their keys
60
60
  // holds the display text — the entity's own list handler plus `refLabelField`
@@ -319,14 +319,11 @@ function renderInput({ field, id, hasError, onChange, Input, appLocale, Banner,
319
319
  case "bigInt": {
320
320
  const icon = resolveFieldIcon(field);
321
321
  const unit = resolveNumberUnit(field.unit, row);
322
- return (_jsx(Input, { kind: "number", ...common, value: numberValue(field.value), onChange: (v) => onChange(v), ...(icon !== undefined && { icon }), ...(unit !== undefined && { unit }) }));
322
+ return (_jsx(Input, { kind: "number", ...common, value: numberValue(field.value), onChange: (v) => onChange(v), locale: appLocale, ...(field.grouping !== undefined && { grouping: field.grouping }), ...(field.type === "bigInt" && { integer: true }), ...(icon !== undefined && { icon }), ...(unit !== undefined && { unit }) }));
323
323
  }
324
324
  case "decimal": {
325
- // step="any" disables the native stepMismatch constraint — without it
326
- // <input type="number"> defaults to step=1 and blocks form submit on
327
- // any fractional value via silent browser-native validation.
328
325
  const icon = resolveFieldIcon(field);
329
- return (_jsx(Input, { kind: "number", ...common, value: numberValue(field.value), onChange: (v) => onChange(v), step: "any", ...(icon !== undefined && { icon }) }));
326
+ return (_jsx(Input, { kind: "number", ...common, value: numberValue(field.value), onChange: (v) => onChange(v), locale: appLocale, ...(field.grouping !== undefined && { grouping: field.grouping }), ...(icon !== undefined && { icon }) }));
330
327
  }
331
328
  case "tz":
332
329
  return (_jsx(Input, { kind: "tz", ...common, value: stringValue(field.value), onChange: (v) => onChange(v) }));
@@ -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 }));
@@ -81,7 +81,15 @@ export const kumikoDefaultTranslations = {
81
81
  "kumiko.combobox.empty": "No matches.",
82
82
  "kumiko.combobox.loading": "Loading…",
83
83
  "kumiko.combobox.placeholder": "—",
84
+ "kumiko.dashboard.chart.forecast": "Forecast",
85
+ "kumiko.dashboard.chart.markers": "Events",
84
86
  "kumiko.dashboard.filter.all": "All",
87
+ "kumiko.dashboard.panel.error.isolated": "The other panels are not affected.",
88
+ "kumiko.dashboard.panel.error.retry": "Try again",
89
+ "kumiko.dashboard.panel.error.title": "{label} could not be loaded",
90
+ "kumiko.dashboard.time-range": "Time range",
91
+ "kumiko.dashboard.today": "Today",
92
+ "kumiko.dashboard.updated-at": "As of {time}",
85
93
  "kumiko.app-boot.loading": "Loading…",
86
94
  "kumiko.app-boot.unauthorized": "You need to sign in to see this.",
87
95
  "kumiko.app-boot.failed": "Couldn't load the app. Check your connection and try again.",
@@ -211,6 +211,12 @@ export type InputProps = {
211
211
  /** `<input step>`. "any" disables the native stepMismatch constraint
212
212
  * (needed for decimal fields — integer fields leave this unset). */
213
213
  readonly step?: number | "any";
214
+ /** BCP-47 tag for the grouped display and the decimal separator when typing. */
215
+ readonly locale?: string;
216
+ /** `false` shows the value without a thousands separator (e.g. a year). */
217
+ readonly grouping?: boolean;
218
+ /** Integer-only entry (bigInt): fractional input is rejected on blur. */
219
+ readonly integer?: boolean;
214
220
  /** Resolved display suffix (static or from a sibling field) — never
215
221
  * part of the numeric value. */
216
222
  readonly unit?: string;
@@ -475,8 +481,9 @@ export type DataTableRowAction = {
475
481
  readonly onTrigger: (row: ListRowViewModel) => Promise<void> | void;
476
482
  /** Conditional Visibility pro Row (z.B. "Start" nur wenn status==="scheduled"). */
477
483
  readonly isVisible?: (row: ListRowViewModel) => boolean;
478
- /** This action is what a click on the row does. The card layout drops it
479
- * from the row menu, since the whole card is already the tap target. */
484
+ /** This action is what a click on the row does. With `onRowClick` the table
485
+ * (except `"inline"` mode or editable cells) and the card layout drop it
486
+ * from the row menu, since the row itself is already the click target. */
480
487
  readonly rowClick?: boolean;
481
488
  /** Resolved icon (author `RowAction.icon` or the id-derived default) —
482
489
  * drives both the icon-left-of-text render and the icon-only collapse
@@ -530,7 +537,8 @@ export type DataTableProps = {
530
537
  * sich nur um Render + Confirm-Dialog. */
531
538
  readonly rowActions?: readonly DataTableRowAction[];
532
539
  /** How the row-action column renders:
533
- * - `"adaptive"` (default): with `onRowClick` all actions go in the kebab;
540
+ * - `"adaptive"` (default): with `onRowClick` all actions except the
541
+ * `rowClick` one go in the kebab (no kebab or column if none remain);
534
542
  * without it the primary action is a link button and the rest go in the
535
543
  * kebab (a single action gets no kebab).
536
544
  * - `"inline"`: ALWAYS inline buttons, left-aligned, even with >2 (no
@@ -868,7 +876,7 @@ export type GridProps = {
868
876
  readonly flow?: boolean;
869
877
  };
870
878
  /** Width class of a field cell inside a `flow` grid: sized by what the field holds. */
871
- export type FieldCellWidth = "text" | "number" | "money" | "date" | "select" | "full" | "auto";
879
+ export type FieldCellWidth = "text" | "number" | "money" | "date" | "timestamp" | "select" | "full" | "toggle";
872
880
  /** Span-Wrapper für ein Kind innerhalb eines Grid. Web: `style={{gridColumn: span N}}`,
873
881
  * Native: eigenes Width-Rechnen. */
874
882
  export type GridCellProps = {
@@ -1021,6 +1029,9 @@ export type CardOptions = {
1021
1029
  * relatedList table sizing to the panel instead of the page) instead of
1022
1030
  * normal document-flow height. Default false. */
1023
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;
1024
1035
  };
1025
1036
  /** Generische Card-Chrome (border/bg/radius/shadow + Header/Body/Footer) als
1026
1037
  * Primitive — eine Quelle für alle Card-Flächen, statt pro App nachgebaut.
@@ -1135,6 +1146,8 @@ export type JsonViewProps = {
1135
1146
  /** Band above the tabs of a record detail: optional subtitle line plus the
1136
1147
  * metrics as a definition list. `children` are `Metric` tiles. */
1137
1148
  export type MetricBandProps = {
1149
+ /** Free-form block rendered unstyled as the first full-width row of the band. */
1150
+ readonly lead?: ReactNode;
1138
1151
  readonly subtitle?: ReactNode;
1139
1152
  readonly children: ReactNode;
1140
1153
  readonly testId?: string;
@@ -1145,6 +1158,8 @@ export type MetricBandProps = {
1145
1158
  * (see `usePageHeaderSlotAvailable`) callers keep their previous placement. */
1146
1159
  export type PageHeaderProps = {
1147
1160
  readonly title?: string;
1161
+ /** Name of the loaded record; shown as an extra breadcrumb before the last one. */
1162
+ readonly recordTitle?: string;
1148
1163
  readonly status?: ReactNode;
1149
1164
  readonly actions?: ReactNode;
1150
1165
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.329.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.329.0",
19
- "@cosmicdrift/kumiko-headless": "0.329.0",
20
- "@cosmicdrift/kumiko-types": "0.329.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.329.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,31 @@
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
+ },
16
+ {
17
+ "version": "0.330.0",
18
+ "type": "fix",
19
+ "title": "entityEdit screens show the server's reason when a delete is rejected",
20
+ "detail": "The reason (for example from a preDelete hook) now appears in the form-error banner instead of the confirm dialog closing silently. `RenderEdit`'s `onDelete` may return the rejected write's `DispatcherError` for this."
21
+ },
22
+ {
23
+ "version": "0.330.0",
24
+ "type": "improvement",
25
+ "title": "Screen schema gains createScreen, recordTitleField and configEdit fillHeight; extension tabs render unframed",
26
+ "detail": "`createScreen` on an entityList names the screen opened by the create button. `recordTitleField` on an entityEdit shows a field value as the record title in the header breadcrumb (list > record > screen title). `fillHeight` on a configEdit (default true) switches it to the screen-form layout with a pinned footer; `false` restores the card layout. All three are optional and validated at boot. Extension tabs on a projectionDetail no longer wrap their content in a Card, like relatedList tabs. A `headerSlot` now renders inside the page-header slot instead of replacing the header.",
27
+ "migration": "Additive. Set `createScreen: \"<screen-id>\"` on an entityList and `recordTitleField: \"<field>\"` on an entityEdit to use them. configEdit screens now fill the shell height; set `fillHeight: false` to keep the card layout. Apps with screenshot tests of extension tabs lose the surrounding card frame."
28
+ },
2
29
  {
3
30
  "version": "0.328.0",
4
31
  "type": "breaking",