@cosmicdrift/kumiko-renderer 0.329.0 → 0.330.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/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
@@ -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();
@@ -1861,17 +1864,17 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
1861
1864
  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
1865
  });
1863
1866
  // 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 }));
1867
+ 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
1868
  const usesPageHeaderSlot = pageHeaderSlotAvailable && PageHeader !== undefined;
1866
1869
  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 &&
1870
+ const renderHeaderContent = (headerSlot) => (_jsxs(_Fragment, { children: [usesPageHeaderSlot ? (_jsxs(_Fragment, { children: [_jsx(PageHeader, { ...(header !== undefined && { title: String(record[header.title] ?? "") }), ...(header?.status !== undefined &&
1868
1871
  StatusBadge !== undefined && {
1869
1872
  status: (_jsx(StatusBadge, { value: String(record[header.status] ?? ""), tone: headerStatusTone(header, record), testId: "kumiko-screen-projection-detail-status" })),
1870
1873
  }), ...(hasHeaderActions && {
1871
1874
  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) &&
1875
+ }) }), MetricBand === undefined && headerSlot, (hasMetrics || headerSubtitleSlot !== undefined || headerSlot !== undefined) &&
1873
1876
  (MetricBand !== undefined || hasMetrics) &&
1874
- metricsBlock(headerSubtitleSlot), actionErrorBanner] })) : ((hasHeaderCard || headerSlot !== undefined) && (_jsxs(Card, { slots: {
1877
+ metricsBlock(headerSubtitleSlot, MetricBand !== undefined ? headerSlot : undefined), actionErrorBanner] })) : ((hasHeaderCard || headerSlot !== undefined) && (_jsxs(Card, { slots: {
1875
1878
  ...(headerTitleSlot !== undefined && { title: headerTitleSlot }),
1876
1879
  ...(headerSubtitleSlot !== undefined && { subtitle: headerSubtitleSlot }),
1877
1880
  ...(headerSlot !== undefined && { headerContent: headerSlot }),
@@ -2187,7 +2190,7 @@ function ConfigEditBody({ schema, screen, translate, }) {
2187
2190
  if (initial === null) {
2188
2191
  return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
2189
2192
  }
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) => {
2193
+ 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
2194
  const cascade = cascades[fieldName];
2192
2195
  if (cascade === undefined)
2193
2196
  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,6 +6,8 @@ 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
13
  boolean: "auto",
@@ -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,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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
6
+ import { Fragment, 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";
@@ -520,7 +520,7 @@ export function RenderEdit(props) {
520
520
  section.visible), [vm.sections, fieldsFilter]);
521
521
  const allFieldsRequired = useMemo(() => {
522
522
  const fillable = filteredSections.flatMap((section) => section.kind === "fields"
523
- ? section.fields.filter((field) => field.visible && !field.readOnly)
523
+ ? section.fields.filter((field) => field.visible && !field.readOnly && field.type !== "boolean")
524
524
  : []);
525
525
  return fillable.length > 0 && fillable.every((field) => field.required);
526
526
  }, [filteredSections]);
@@ -957,6 +957,12 @@ export function RenderEdit(props) {
957
957
  return id == null || id === "";
958
958
  })();
959
959
  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
965
+ : undefined;
960
966
  const resolveScreenText = (suffix) => {
961
967
  for (const key of [
962
968
  `screen:${screen.id}.${formMode}.${suffix}`,
@@ -1006,7 +1012,7 @@ export function RenderEdit(props) {
1006
1012
  })
1007
1013
  : translate("kumiko.wizard.step", { current: currentStep + 1, total: lastStepIndex + 1 });
1008
1014
  })();
1009
- return (_jsxs(ExtensionFormRegistryProvider, { value: extensionFormRegistry, children: [titleInShell && (_jsx(PageHeader, { title: formTitle, ...(headerMenuItems.length > 0 &&
1015
+ return (_jsxs(ExtensionFormRegistryProvider, { value: extensionFormRegistry, children: [titleInShell && (_jsx(PageHeader, { title: formTitle, ...(recordTitle !== undefined && { recordTitle }), ...(headerMenuItems.length > 0 &&
1010
1016
  ActionOverflowMenu !== undefined && {
1011
1017
  actions: (_jsx(ActionOverflowMenu, { label: translate("kumiko.list.row-actions.more"), items: headerMenuItems, testId: "render-edit-header-menu" })),
1012
1018
  }) })), _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 +1084,12 @@ export function RenderEdit(props) {
1078
1084
  // over TValues; controller is mount-lifetime-stable, see onControlsReady above.
1079
1085
  patch: patchAndScheduleDraftSave, validate: scopedValidate, hideTitle: hideSectionTitles, ...(hideSectionTitles !== true &&
1080
1086
  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
+ // 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
1087
1090
  // purely chrome, giving it the same testId would register two
1088
1091
  // elements under one id.
1089
- const wrapped = hideSectionTitles === true ? (_jsx(Card, { ...(sectionActionsEl !== undefined && {
1090
- slots: { headerActions: sectionActionsEl },
1091
- }), children: mount }, section.title)) : (mount);
1092
+ const wrapped = hideSectionTitles !== true ? (mount) : sectionActionsEl !== undefined ? (_jsx(Card, { slots: { headerActions: sectionActionsEl }, children: mount }, section.title)) : (_jsx(Fragment, { children: mount }, section.title));
1092
1093
  return wrapWizardStep(section.title, wrapped);
1093
1094
  }
1094
1095
  if (section.kind === "relatedList") {
@@ -1163,7 +1164,15 @@ export function RenderEdit(props) {
1163
1164
  }), formError !== null && (_jsx(Banner, { variant: "error", testId: "render-edit-form-error", actions: formError.code === "version_conflict" && onReload !== undefined ? (_jsx(Button, { variant: "secondary", onClick: () => {
1164
1165
  onReload();
1165
1166
  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();
1167
+ }, 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 () => {
1168
+ const rejection = await onDelete();
1169
+ // A rejected delete (e.g. a preDelete hook) carries its reason in
1170
+ // the field issues; the top-level key is only "validation failed".
1171
+ if (rejection !== undefined) {
1172
+ const issue = rejection.details?.fields?.[0];
1173
+ setFormError(issue === undefined
1174
+ ? rejection
1175
+ : { ...rejection, i18nKey: issue.i18nKey, i18nParams: issue.params });
1176
+ }
1168
1177
  }, testId: "render-edit-delete-dialog" }))] }) })] }));
1169
1178
  }
@@ -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) }));
@@ -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" | "auto";
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 = {
@@ -1135,6 +1143,8 @@ export type JsonViewProps = {
1135
1143
  /** Band above the tabs of a record detail: optional subtitle line plus the
1136
1144
  * metrics as a definition list. `children` are `Metric` tiles. */
1137
1145
  export type MetricBandProps = {
1146
+ /** Free-form block rendered unstyled as the first full-width row of the band. */
1147
+ readonly lead?: ReactNode;
1138
1148
  readonly subtitle?: ReactNode;
1139
1149
  readonly children: ReactNode;
1140
1150
  readonly testId?: string;
@@ -1145,6 +1155,8 @@ export type MetricBandProps = {
1145
1155
  * (see `usePageHeaderSlotAvailable`) callers keep their previous placement. */
1146
1156
  export type PageHeaderProps = {
1147
1157
  readonly title?: string;
1158
+ /** Name of the loaded record; shown as an extra breadcrumb before the last one. */
1159
+ readonly recordTitle?: string;
1148
1160
  readonly status?: ReactNode;
1149
1161
  readonly actions?: ReactNode;
1150
1162
  };
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.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,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.0",
19
+ "@cosmicdrift/kumiko-headless": "0.330.0",
20
+ "@cosmicdrift/kumiko-types": "0.330.0",
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.0"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",
package/src/changes.json CHANGED
@@ -1,4 +1,17 @@
1
1
  [
2
+ {
3
+ "version": "0.330.0",
4
+ "type": "fix",
5
+ "title": "entityEdit screens show the server's reason when a delete is rejected",
6
+ "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."
7
+ },
8
+ {
9
+ "version": "0.330.0",
10
+ "type": "improvement",
11
+ "title": "Screen schema gains createScreen, recordTitleField and configEdit fillHeight; extension tabs render unframed",
12
+ "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.",
13
+ "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."
14
+ },
2
15
  {
3
16
  "version": "0.328.0",
4
17
  "type": "breaking",