@cosmicdrift/kumiko-renderer 0.333.0 → 0.334.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.
@@ -162,7 +162,7 @@ function resolveTitleTemplate(template, values, translate, fallback) {
162
162
  return translate(template, values);
163
163
  }
164
164
  export function RenderEdit(props) {
165
- const { screen, entity, featureName, initial, writeCommand, translate: translateProp, ctx, schema, onSubmit, payloadMode = "values", buildPayload, serverFieldPathPrefix, onDelete, onCancel, onReload, onDirtyChange, onCopyLink, actions, onRelatedListDrawerAction, submitLabel, submitVariant, footerActions, summary, labelAppendix, fieldAppendix, entityId: entityIdProp, extensionInitialValues, onChange, onControlsReady, fields: fieldsFilter, disabled = false, hideActions, valueDisplay = "form", hideSectionTitles, fillScreenHeight, headerRegion, buildSectionActions, } = props;
165
+ const { screen, entity, featureName, initial, writeCommand, translate: translateProp, ctx, schema, onSubmit, payloadMode = "values", buildPayload, serverFieldPathPrefix, onDelete, onCancel, onReload, onDirtyChange, onCopyLink, actions, onRelatedListDrawerAction, submitLabel, submitVariant, footerActions, summary, labelAppendix, fieldAppendix, fieldAppendixPlacement, fieldDescription, fieldAccent, markChangedFields = false, entityId: entityIdProp, extensionInitialValues, onChange, onControlsReady, fields: fieldsFilter, disabled = false, hideActions, valueDisplay = "form", hideSectionTitles, fillScreenHeight, dirtyFooter, validateOnChange, headerRegion, buildSectionActions, } = props;
166
166
  const { customSubmit } = props;
167
167
  // Translate-Fallback: wenn der Caller keine Translate-Fn übergibt,
168
168
  // konsumieren wir den i18next-Context direkt. Sonst wären Field-
@@ -569,6 +569,11 @@ export function RenderEdit(props) {
569
569
  // keeps normal document-flow height untouched.
570
570
  const fillHeight = fillScreenHeight === true ||
571
571
  (hideSectionTitles === true && filteredSections[0]?.kind === "relatedList");
572
+ // A lone writeForm tab is the whole page, so its submit joins this form's
573
+ // footer (pinned to the window edge like every other screen's save)
574
+ // instead of sitting in the section's title row.
575
+ const isLoneWriteFormTab = hideSectionTitles === true && filteredSections[0]?.kind === "writeForm";
576
+ const [writeFormFooterAction, setWriteFormFooterAction] = useState(undefined);
572
577
  // Persistiert alle composed Extension-Sections mit der aufgelösten entityId.
573
578
  // false = eine Section schlug fehl (ihr i18n-Key landet im Banner). Ohne
574
579
  // Entity-Kontext (create-mode ohne route-id) gibt es nichts zu schreiben.
@@ -872,10 +877,7 @@ export function RenderEdit(props) {
872
877
  handleSubmitRef.current = handleSubmit;
873
878
  // Screen forms (entityEdit/actionForm filling the shell height) drop the card;
874
879
  // the title then lives in the shell header when the shell offers a slot.
875
- const isScreenForm = fillScreenHeight === true &&
876
- hideSectionTitles !== true &&
877
- headerRegion === undefined &&
878
- !insideDrawer;
880
+ const isScreenForm = fillScreenHeight === true && hideSectionTitles !== true && !insideDrawer;
879
881
  const titleInShell = PageHeader !== undefined &&
880
882
  pageHeaderSlotAvailable &&
881
883
  hideSectionTitles !== true &&
@@ -960,8 +962,26 @@ export function RenderEdit(props) {
960
962
  const hasFormActions = (isWizard && currentStep > 0) ||
961
963
  (isWizard && !isLastWizardStep) ||
962
964
  (showsSubmit && (!isWizard || isLastWizardStep)) ||
963
- footerSlot !== undefined;
965
+ footerSlot !== undefined ||
966
+ writeFormFooterAction !== undefined;
964
967
  const nextStepTitle = isWizard ? filteredSections[currentStep + 1]?.title : undefined;
968
+ const isCreate = (() => {
969
+ const id = resolveExtensionEntityId(entityIdProp, vm.id);
970
+ return id == null || id === "";
971
+ })();
972
+ const changedFieldCount = Object.keys(snapshot.changes).length;
973
+ // Edit-mode screen forms always carry the dirty status (0 = quiet "No unsaved
974
+ // changes"); wizards keep the status-only-when-dirty behaviour.
975
+ const hasDirtyFooter = !isWizard &&
976
+ (dirtyFooter === true || (isScreenForm && !isCreate && headerRegion === undefined));
977
+ const unsavedCount = !hasDirtyFooter
978
+ ? undefined
979
+ : changedFieldCount > 0
980
+ ? changedFieldCount
981
+ : extensionDirty
982
+ ? 1
983
+ : 0;
984
+ const isSettingsList = screen.layout.variant === "settings-list" && hideSectionTitles !== true;
965
985
  const formActions = (_jsxs(_Fragment, { children: [isWizard && currentStep > 0 && (_jsx(Button, { type: "button", variant: "secondary", icon: "chevron-left", onClick: handleWizardBack, testId: "render-edit-wizard-back", children: translate("kumiko.actions.back") })), footerSlot !== undefined && (_jsx(EditSlotMount, { slot: footerSlot, slotName: "footer", screenId: screen.id, entityName: vm.entityName, entityId: resolveExtensionEntityId(entityIdProp, vm.id), values: snapshot.values, hasUnsavedChanges: snapshot.isDirty || extensionDirty, wizardStep: isWizard ? { index: currentStep, isLast: isLastWizardStep } : undefined, ...(screen.slots?.footerPrimary === true && { [STICKY_PRIMARY_ACTION_PROP]: true }) })), isWizard && !isLastWizardStep && showsSubmit && !isCreateMode && (_jsx(Button, { type: "button", variant: "ghost", disabled: isSubmitting || disabled, onClick: () => void handleSubmit({ saveNow: true }), testId: "render-edit-wizard-save-close", [STICKY_PRIMARY_ACTION_PROP]: true, children: translate("kumiko.wizard.save-and-close") })), isWizard && !isLastWizardStep && (_jsx(Button, { type: "submit", variant: "primary", iconEnd: "chevron-right", testId: "render-edit-wizard-next", children: nextStepTitle !== undefined
966
986
  ? translate("kumiko.wizard.next-with-title", { title: nextStepTitle })
967
987
  : translate("kumiko.actions.next") })), showsSubmit &&
@@ -969,9 +989,14 @@ export function RenderEdit(props) {
969
989
  footerActions?.map((action) => (_jsx(Button, { type: "button", variant: action.variant ?? "secondary", ...(action.icon !== undefined && { icon: action.icon }), disabled: isSubmitting || disabled, onClick: () => {
970
990
  controller.setValues(action.patch); // @cast-boundary form-values
971
991
  void handleSubmit();
972
- }, testId: `render-edit-footer-action-${action.id}`, children: translate(action.label) }, action.id))), showsSubmit && (!isWizard || isLastWizardStep) && (_jsx(Button, { type: "submit", disabled: (snapshot.isUnchanged && !extensionDirty && !isFieldless && !insideDrawer) ||
992
+ }, testId: `render-edit-footer-action-${action.id}`, children: translate(action.label) }, action.id))), hasDirtyFooter && showsSubmit && unsavedCount !== undefined && unsavedCount > 0 && (_jsx(Button, { type: "button", variant: "ghost", disabled: isSubmitting || disabled, onClick: () => controller.reset(), testId: "render-edit-discard", [STICKY_PRIMARY_ACTION_PROP]: true, children: translate("kumiko.form.discard") })), showsSubmit && (!isWizard || isLastWizardStep) && (_jsx(Button, { type: "submit", disabled: (snapshot.isUnchanged && !extensionDirty && !isFieldless && !insideDrawer) ||
973
993
  isSubmitting ||
974
- disabled, loading: isSubmitting, variant: submitVariant ?? "primary", testId: "render-edit-submit", children: translate(submitLabel ?? (isWizard ? "kumiko.actions.finish" : "kumiko.actions.save")) }))] }));
994
+ disabled, loading: isSubmitting, variant: submitVariant ?? "primary", testId: "render-edit-submit", children: translate(submitLabel ??
995
+ (isWizard
996
+ ? "kumiko.actions.finish"
997
+ : hasDirtyFooter
998
+ ? "kumiko.form.saveChanges"
999
+ : "kumiko.actions.save")) })), writeFormFooterAction] }));
975
1000
  // Title + Subtitle, create/edit-bewusst. i18n-Keys (mode = "create"|"edit"):
976
1001
  // screen:<id>.<mode>.title / .<mode>.subtitle
977
1002
  // Fallback-Kette: mode-spezifisch → generisch (screen:<id>.title/.subtitle).
@@ -982,10 +1007,6 @@ export function RenderEdit(props) {
982
1007
  // prose, same as it does for an unknown key (fw#2723 review). Same slot
983
1008
  // section.description already fills for a section. Falls back to
984
1009
  // undefined (no subtitle) when neither is set.
985
- const isCreate = (() => {
986
- const id = resolveExtensionEntityId(entityIdProp, vm.id);
987
- return id == null || id === "";
988
- })();
989
1010
  const formMode = isCreate ? "create" : "edit";
990
1011
  const recordTitle = !isCreate && screen.recordTitleField !== undefined
991
1012
  ? resolveRecordTitle(initial[screen.recordTitleField])
@@ -1009,18 +1030,14 @@ export function RenderEdit(props) {
1009
1030
  ? (resolveScreenText("subtitle") ??
1010
1031
  (screen.description !== undefined ? translate(screen.description) : undefined))
1011
1032
  : undefined;
1033
+ // Embedded settings-list forms (dashboard panels) have no shell header: the
1034
+ // form title/subtitle move into the first section's header column instead of
1035
+ // floating as a heading above the band.
1036
+ const titleInSettingsHeader = isSettingsList && !titleInShell;
1012
1037
  const usesFieldFlow = isScreenForm || insideDrawer;
1013
1038
  const allRequiredHint = (_jsx(Text, { variant: "muted", testId: "render-edit-all-required-hint", children: translate("kumiko.form.all-required") }));
1014
1039
  const stepRail = isWizard && isScreenForm && StepBar !== undefined;
1015
1040
  const upNextSection = screen.layout.wizard?.aside?.upNext === true ? filteredSections[currentStep + 1] : undefined;
1016
- const changedFieldCount = isCreate ? 0 : Object.keys(snapshot.changes).length;
1017
- const unsavedCount = isCreate || !isScreenForm
1018
- ? 0
1019
- : changedFieldCount > 0
1020
- ? changedFieldCount
1021
- : extensionDirty
1022
- ? 1
1023
- : 0;
1024
1041
  const sectionNavItems = isScreenForm && !isStepped
1025
1042
  ? filteredSections.flatMap((section, index) => section.kind !== "extension" &&
1026
1043
  section.kind !== "relatedList" &&
@@ -1046,7 +1063,12 @@ export function RenderEdit(props) {
1046
1063
  !pageHeaderCompact &&
1047
1064
  ActionOverflowMenu !== undefined && {
1048
1065
  actions: (_jsx(ActionOverflowMenu, { label: translate("kumiko.list.row-actions.more"), items: headerMenuItems, testId: "render-edit-header-menu" })),
1049
- }) })), _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 && {
1066
+ }) })), _jsx(Form, { onSubmit: () => void handleSubmit(), ...(hideSectionTitles !== true &&
1067
+ !titleInShell &&
1068
+ !titleInSettingsHeader && { title: formTitle }), ...(formSubtitle !== undefined &&
1069
+ !stepRail &&
1070
+ !titleInSettingsHeader && { subtitle: formSubtitle }), ...(isScreenForm && { screenForm: true }), ...(unsavedCount !== undefined &&
1071
+ (unsavedCount > 0 || hasDirtyFooter) && { unsavedCount }), ...(sectionNavItems.length > 0 && { sectionNav: sectionNavItems }), ...(stepRail && {
1050
1072
  sideRail: (_jsx(StepBar, { steps: filteredSections.map((section) => section.title ?? ""), currentIndex: currentStep, compactLabel: compactStepLabel, onStepSelect: handleWizardJumpBack, orientation: "vertical", heading: translate("kumiko.wizard.step", {
1051
1073
  current: currentStep + 1,
1052
1074
  total: lastStepIndex + 1,
@@ -1153,19 +1175,29 @@ export function RenderEdit(props) {
1153
1175
  // Own submit button + dispatcher call, entirely independent of
1154
1176
  // this screen's (nonexistent, on projectionDetail) form submit —
1155
1177
  // rejected at boot in wizard layouts, so no WizardStepGroup here.
1156
- return (_jsx(WriteFormSection, { section: section, featureName: featureName, translate: translate, hideTitle: hideSectionTitles, onSubmitted: () => onReload?.(), ...(sectionActionsEl !== undefined && { actions: sectionActionsEl }) }, section.title ?? `write-form-${sectionIndex}`));
1178
+ return (_jsx(WriteFormSection, { section: section, featureName: featureName, translate: translate, hideTitle: hideSectionTitles, onSubmitted: () => onReload?.(), ...(isLoneWriteFormTab && { onFooterAction: setWriteFormFooterAction }), ...(sectionActionsEl !== undefined && { actions: sectionActionsEl }) }, section.title ?? `write-form-${sectionIndex}`));
1157
1179
  }
1158
1180
  if (!section.visible)
1159
1181
  return null;
1160
1182
  // Titellose Sections kollidieren sonst auf key/testId — Index-Fallback.
1161
1183
  const sectionKey = section.title ?? `section-${sectionIndex}`;
1162
- const renderFieldGrid = (fields, columns, gridKey) => (_jsx(Grid, { columns: columns, ...(usesFieldFlow && { flow: true }), children: fields.map((field) => (_jsx(GridCellForField, { field: disabled ? { ...field, readOnly: true } : field, columns: columns, ...(usesFieldFlow && { flow: true }), issues: snapshot.errors[field.field], onChange: (v) => {
1184
+ const renderFieldGrid = (fields, columns, gridKey) => (_jsx(Grid, { columns: columns, ...(isSettingsList ? { list: true } : usesFieldFlow && { flow: true }), children: fields.map((field) => (_jsx(GridCellForField, { field: disabled ? { ...field, readOnly: true } : field, columns: columns, ...(!isSettingsList && usesFieldFlow && { flow: true }), ...(isSettingsList && {
1185
+ layout: "row",
1186
+ accent: fieldAccent?.(field.field) === true,
1187
+ }), issues: snapshot.errors[field.field], onChange: (v) => {
1163
1188
  controller.setField(field.field, v);
1189
+ if (validateOnChange === true)
1190
+ controller.validate([field.field]);
1164
1191
  }, GridCell: GridCell, featureName: featureName, ...(labelAppendix !== undefined && {
1165
1192
  labelAppendix: labelAppendix(field.field),
1166
1193
  }), ...(fieldAppendix !== undefined && {
1167
1194
  fieldAppendix: fieldAppendix(field.field),
1168
- }), allIssues: snapshot.errors, valueDisplay: valueDisplay, row: snapshot.values, ...(!isCreate && field.field in snapshot.changes && { changed: true }) }, field.field))) }, gridKey));
1195
+ }), ...(fieldAppendixPlacement !== undefined && {
1196
+ appendixPlacement: fieldAppendixPlacement,
1197
+ }), ...(fieldDescription !== undefined && {
1198
+ description: fieldDescription(field.field),
1199
+ }), allIssues: snapshot.errors, valueDisplay: valueDisplay, row: snapshot.values, ...((!isCreate || markChangedFields) &&
1200
+ field.field in snapshot.changes && { changed: true }) }, field.field))) }, gridKey));
1169
1201
  // Tabs mode: the tab panel is the surface, so field sections render
1170
1202
  // as unframed padded panels; group cards inside stay framed.
1171
1203
  // Stays `Card`, not `Section` — Section always flattens to a
@@ -1196,10 +1228,16 @@ export function RenderEdit(props) {
1196
1228
  // tab label right above it. Without a Tabs primitive the sections
1197
1229
  // render stacked, and then the titles are what tells them apart.
1198
1230
  const repeatsTabLabel = isTabs && isStepped;
1199
- const sectionTitle = section.title === formTitle || repeatsTabLabel ? undefined : section.title;
1231
+ const headerFallback = titleInSettingsHeader && sectionIndex === 0;
1232
+ const sectionTitle = headerFallback
1233
+ ? (section.title ?? formTitle)
1234
+ : section.title === formTitle || repeatsTabLabel
1235
+ ? undefined
1236
+ : section.title;
1237
+ const sectionSubtitle = section.description ?? (headerFallback ? formSubtitle : undefined);
1200
1238
  const sectionEl = (_jsxs(Section, { ...(sectionNavItems.some((item) => item.id === formSectionDomId(sectionIndex)) && {
1201
1239
  id: formSectionDomId(sectionIndex),
1202
- }), ...(sectionTitle !== undefined && { title: sectionTitle }), ...(section.description !== undefined && { subtitle: section.description }), ...(section.icon !== undefined && { icon: section.icon }), ...(sectionActionsEl !== undefined && { actions: sectionActionsEl }), testId: `section-${sectionKey}`, children: [showsAllRequiredHint && insideDrawer && sectionIndex === 0 && allRequiredHint, renderFieldGrid(section.fields, section.columns, `${sectionKey}-grid`)] }, sectionKey));
1240
+ }), ...(sectionTitle !== undefined && { title: sectionTitle }), ...(sectionSubtitle !== undefined && { subtitle: sectionSubtitle }), ...(section.icon !== undefined && { icon: section.icon }), ...(sectionActionsEl !== undefined && { actions: sectionActionsEl }), ...(isSettingsList && { layout: "settings-list" }), testId: `section-${sectionKey}`, children: [showsAllRequiredHint && insideDrawer && sectionIndex === 0 && allRequiredHint, renderFieldGrid(section.fields, section.columns, `${sectionKey}-grid`)] }, sectionKey));
1203
1241
  return wrapWizardStep(sectionKey, sectionEl);
1204
1242
  }), formError !== null && (_jsx(Banner, { variant: "error", testId: "render-edit-form-error", actions: formError.code === "version_conflict" && onReload !== undefined ? (_jsx(Button, { variant: "secondary", onClick: () => {
1205
1243
  onReload();
@@ -1,5 +1,6 @@
1
1
  import { type EditFieldViewModel, type FieldIssue } from "@cosmicdrift/kumiko-headless";
2
2
  import { type ReactNode } from "react";
3
+ import { type FieldProps } from "../primitives.js";
3
4
  export type RenderFieldProps = {
4
5
  readonly field: EditFieldViewModel;
5
6
  readonly issues?: readonly FieldIssue[];
@@ -14,6 +15,9 @@ export type RenderFieldProps = {
14
15
  /** Optionaler Zusatz-Inhalt der nach dem Input gerendert wird (z.B.
15
16
  * ConfigCascade). */
16
17
  readonly fieldAppendix?: ReactNode;
18
+ readonly appendixPlacement?: FieldProps["appendixPlacement"];
19
+ readonly description?: ReactNode;
20
+ readonly status?: FieldProps["status"];
17
21
  /** Flat issues-by-path map (FormSnapshot.errors) — only relevant for
18
22
  * type:"embedded" with embeddedListCells, to bucket row-/cell-issues
19
23
  * (`${field}.${rowIndex}` / `${field}.${rowIndex}.${cellField}`).
@@ -33,6 +37,8 @@ export type RenderFieldProps = {
33
37
  readonly row?: Readonly<Record<string, unknown>>;
34
38
  /** Marks the field as modified against the loaded record (edit mode only). */
35
39
  readonly changed?: boolean;
40
+ readonly layout?: FieldProps["layout"];
41
+ readonly accent?: boolean;
36
42
  };
37
43
  export declare const AllFieldsRequiredProvider: import("react").Provider<boolean>;
38
- export declare function RenderField({ field, issues, onChange, featureName, labelAppendix, fieldAppendix, allIssues, valueDisplay, row, changed, }: RenderFieldProps): ReactNode;
44
+ export declare function RenderField({ field, issues, onChange, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay, row, changed, layout, accent, }: RenderFieldProps): ReactNode;
@@ -19,7 +19,7 @@ import { QueryOptionsSelect } from "./query-options-select.js";
19
19
  import { ReferenceCreateDialog } from "./reference-create-dialog.js";
20
20
  const AllFieldsRequiredContext = createContext(false);
21
21
  export const AllFieldsRequiredProvider = AllFieldsRequiredContext.Provider;
22
- export function RenderField({ field, issues, onChange, featureName, labelAppendix, fieldAppendix, allIssues, valueDisplay = "form", row, changed, }) {
22
+ export function RenderField({ field, issues, onChange, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay = "form", row, changed, layout, accent, }) {
23
23
  const { Field, Input, Banner, Text, JsonView } = usePrimitives();
24
24
  // App-Locale (i18n) für money/date-Inputs — sonst fielen sie auf
25
25
  // navigator.language (Browser-Sprache) zurück statt der gewählten
@@ -42,7 +42,7 @@ export function RenderField({ field, issues, onChange, featureName, labelAppendi
42
42
  // Reference-Field rendert eine eigene Component — sie nutzt
43
43
  // useQuery() für den Live-Lookup, also muss sie als React-
44
44
  // Komponente gemountet werden (nicht als pure render-Call).
45
- const control = field.readOnly && field.renderer !== undefined ? (_jsx(FieldRendererOutput, { field: field, renderer: field.renderer, appLocale: appLocale, ...(row !== undefined && { row }) })) : field.type === "embedded" && field.embeddedListCells !== undefined ? (_jsx(EmbeddedListField, { field: field, id: id, onChange: onChange, allIssues: allIssues ?? {}, featureName: featureName ?? "" })) : field.type === "reference" ? (readOnlyText ? (_jsx(ReadOnlyReferenceValue, { field: field, featureName: featureName ?? "" })) : (_jsx(ReferenceInput, { field: field, id: id, hasError: hasError, onChange: onChange, Input: Input, featureName: featureName ?? "" }))) : field.type === "select" && field.selectOptionsQuery !== undefined && !readOnlyText ? (_jsx(QueryOptionsSelect, { field: field, query: field.selectOptionsQuery.query, payload: field.selectOptionsQuery.payload, id: id, hasError: hasError, onChange: onChange })) : readOnlyText && !isComplexFieldType(field.type) ? (_jsx(Text, { testId: `field-value-${field.field}`, children: readOnlyDisplayText(field, appLocale) })) : (renderInput({
45
+ const control = field.readOnly && field.renderer !== undefined ? (_jsx(FieldRendererOutput, { field: field, renderer: field.renderer, appLocale: appLocale, ...(row !== undefined && { row }) })) : field.type === "embedded" && field.embeddedListCells !== undefined ? (_jsx(EmbeddedListField, { field: field, id: id, onChange: onChange, allIssues: allIssues ?? {}, featureName: featureName ?? "" })) : field.type === "reference" ? (readOnlyText ? (_jsx(ReadOnlyReferenceValue, { field: field, featureName: featureName ?? "" })) : (_jsx(ReferenceInput, { field: field, id: id, hasError: hasError, onChange: onChange, Input: Input, featureName: featureName ?? "" }))) : field.type === "select" && field.selectOptionsQuery !== undefined && !readOnlyText ? (_jsx(QueryOptionsSelect, { field: field, query: field.selectOptionsQuery.query, payload: field.selectOptionsQuery.payload, row: row ?? { [field.field]: field.value }, id: id, hasError: hasError, onChange: onChange })) : readOnlyText && !isComplexFieldType(field.type) ? (_jsx(Text, { testId: `field-value-${field.field}`, children: readOnlyDisplayText(field, appLocale, t) })) : field.type === "text" && field.writeOnly === true && !readOnlyText ? (_jsx(WriteOnlyTextInput, { field: field, id: id, hasError: hasError, onChange: onChange, changed: changed === true })) : (renderInput({
46
46
  field,
47
47
  id,
48
48
  hasError,
@@ -55,7 +55,33 @@ export function RenderField({ field, issues, onChange, featureName, labelAppendi
55
55
  t,
56
56
  row,
57
57
  }));
58
- 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
+ return (_jsx(Field, { id: id, label: field.label, required: field.required && field.type !== "boolean" && !allFieldsRequired, ...(issues !== undefined && { issues }), ...(labelAppendix !== undefined && { labelAppendix }), ...(fieldAppendix !== undefined && { fieldAppendix }), ...(appendixPlacement !== undefined && { appendixPlacement }), ...(description !== undefined && { description }), ...(status !== undefined && { status }), ...(changed === true && { changed }), ...(layout !== undefined && { layout }), ...(accent === true && { accent }), testId: `field-${field.field}`, children: control }));
59
+ }
60
+ // Wire value of a writeOnly field is `true` (set) / `null` (empty), never the
61
+ // secret. Typing sets a new value; an untouched or emptied input submits
62
+ // nothing / "" which the server reads as "keep". `null` after a loaded `true`
63
+ // (changed) means "remove on save".
64
+ function WriteOnlyTextInput({ field, id, hasError, onChange, changed, }) {
65
+ const { Input } = usePrimitives();
66
+ const t = useTranslation();
67
+ const isSet = field.value === true;
68
+ const isMarkedForRemoval = field.value === null && changed;
69
+ if (isMarkedForRemoval) {
70
+ return (_jsx(Input, { kind: "password", id: id, name: field.field, disabled: true, hasError: hasError, value: "", onChange: () => { }, autoComplete: "new-password", placeholder: t("kumiko.field.writeOnly.willRemove"), testId: `field-write-only-removal-${field.field}`, trailingAction: {
71
+ icon: "undo",
72
+ label: t("kumiko.field.writeOnly.undo"),
73
+ onPress: () => onChange(true),
74
+ } }));
75
+ }
76
+ return (_jsx(Input, { kind: "password", id: id, name: field.field, disabled: field.readOnly, required: field.required && !isSet, hasError: hasError, value: typeof field.value === "string" ? field.value : "", onChange: (v) => onChange(v), autoComplete: "new-password", ...(isSet && { placeholder: t("kumiko.field.writeOnly.setPlaceholder") }), ...(isSet &&
77
+ !field.required &&
78
+ !field.readOnly && {
79
+ trailingAction: {
80
+ icon: "clear",
81
+ label: t("kumiko.field.writeOnly.remove"),
82
+ onPress: () => onChange(null),
83
+ },
84
+ }) }));
59
85
  }
60
86
  // Where a reference field's option rows come from, and which of their keys
61
87
  // holds the display text — the entity's own list handler plus `refLabelField`
@@ -488,10 +514,12 @@ function locatedValue(v) {
488
514
  // covered here — it needs a live label lookup, see ReadOnlyReferenceValue.
489
515
  // `isComplexFieldType` types aren't covered either — callers keep those on
490
516
  // `renderInput`'s existing Banner fallback.
491
- function readOnlyDisplayText(field, appLocale) {
517
+ function readOnlyDisplayText(field, appLocale, t) {
492
518
  const { type, value } = field;
493
519
  if (value === undefined || value === null || value === "")
494
520
  return "—";
521
+ if (type === "text" && field.writeOnly === true)
522
+ return t("kumiko.field.writeOnly.set");
495
523
  if (type === "text" && field.format === "password")
496
524
  return "••••••••";
497
525
  switch (type) {
@@ -13,5 +13,9 @@ export type WriteFormSectionProps = {
13
13
  * rendered alongside (before) the section's own submit button in the
14
14
  * title-row actions slot. */
15
15
  readonly actions?: ReactNode;
16
+ /** Set when this section is a whole tab: the submit button is handed to
17
+ * the host form's footer (pinned like every other screen's save) instead
18
+ * of the section's title row. Called with undefined on unmount. */
19
+ readonly onFooterAction?: (action: ReactNode | undefined) => void;
16
20
  };
17
- export declare function WriteFormSection({ section, featureName, translate, hideTitle, onSubmitted, actions, }: WriteFormSectionProps): ReactNode;
21
+ export declare function WriteFormSection({ section, featureName, translate, hideTitle, onSubmitted, actions, onFooterAction, }: WriteFormSectionProps): ReactNode;
@@ -1,12 +1,12 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { I18N_KEY_PARAM } from "@cosmicdrift/kumiko-headless";
3
- import { useMemo, useState } from "react";
3
+ import { useEffect, useMemo, useRef, useState } from "react";
4
4
  import * as z from "zod";
5
5
  import { REQUIRED_FIELD_I18N_KEY } from "../app/form-schema.js";
6
6
  import { dispatcherErrorText } from "../app/write-failed-error.js";
7
7
  import { useForm } from "../hooks/use-form.js";
8
8
  import { useTranslation } from "../i18n.js";
9
- import { usePrimitives } from "../primitives.js";
9
+ import { STICKY_PRIMARY_ACTION_PROP, usePrimitives } from "../primitives.js";
10
10
  import { GridCellForField } from "./grid-cell-for-field.js";
11
11
  // Same "has a value" rule as buildFormSchema's isPresent (app/form-schema.ts)
12
12
  // — duplicated because that helper walks raw EditFieldSpec + EntityDefinition,
@@ -45,7 +45,7 @@ function buildWriteFormSchema(fields) {
45
45
  // would render a second, invalid nested <form> inside the host's own form
46
46
  // element — so this mounts its own independent useForm() controller and
47
47
  // submits via a plain button click instead of native form submission.
48
- export function WriteFormSection({ section, featureName, translate, hideTitle, onSubmitted, actions, }) {
48
+ export function WriteFormSection({ section, featureName, translate, hideTitle, onSubmitted, actions, onFooterAction, }) {
49
49
  const { Section, Card, Grid, GridCell, Button, Banner } = usePrimitives();
50
50
  const t = useTranslation();
51
51
  const effectiveTranslate = translate ?? t;
@@ -85,8 +85,20 @@ export function WriteFormSection({ section, featureName, translate, hideTitle, o
85
85
  // type="button" (not "submit") is load-bearing: this section is deliberately
86
86
  // NOT a nested <form> (see the component doc above), so a "submit" type
87
87
  // would instead trigger the host RenderEdit's own form submit.
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
- const titleRowActions = actions !== undefined ? (_jsxs(_Fragment, { children: [actions, submitButton] })) : (submitButton);
88
+ // The footer copy of the button outlives this render, so it calls the
89
+ // latest handleSubmit through a ref instead of capturing a stale closure.
90
+ const handleSubmitRef = useRef(handleSubmit);
91
+ handleSubmitRef.current = handleSubmit;
92
+ const submitLabel = section.submitLabel ?? effectiveTranslate("kumiko.actions.save");
93
+ const submitButton = (_jsx(Button, { type: "button", variant: "primary", disabled: isSubmitting, loading: isSubmitting, onClick: () => void handleSubmitRef.current(), testId: "write-form-section-submit", [STICKY_PRIMARY_ACTION_PROP]: true, children: submitLabel }));
94
+ // biome-ignore lint/correctness/useExhaustiveDependencies: submitButton is rebuilt every render; isSubmitting and submitLabel are everything it shows.
95
+ useEffect(() => {
96
+ if (onFooterAction === undefined)
97
+ return;
98
+ onFooterAction(submitButton);
99
+ return () => onFooterAction(undefined);
100
+ }, [onFooterAction, isSubmitting, submitLabel]);
101
+ const titleRowActions = onFooterAction !== undefined ? (actions) : actions !== undefined ? (_jsxs(_Fragment, { children: [actions, submitButton] })) : (submitButton);
90
102
  // Section always flattens to a borderless divider when rendered inside
91
103
  // RenderEdit's own <Form> in tabs mode (hideTitle) — an unframed Card panel
92
104
  // stands in, like render-edit.tsx's tabs-mode fields/extension branches.
@@ -0,0 +1,5 @@
1
+ import { type ReactNode } from "react";
2
+ export declare function EmbeddedScreenProvider({ children }: {
3
+ readonly children: ReactNode;
4
+ }): ReactNode;
5
+ export declare function useIsEmbeddedScreen(): boolean;
@@ -0,0 +1,12 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, useContext } from "react";
3
+ // A host that already frames and pads the embedded screen (dashboard panel
4
+ // with `chromeless`) marks the subtree, so list screens drop their own
5
+ // screen padding and card frame instead of stacking it on the host's.
6
+ const EmbeddedScreenContext = createContext(false);
7
+ export function EmbeddedScreenProvider({ children }) {
8
+ return _jsx(EmbeddedScreenContext, { value: true, children: children });
9
+ }
10
+ export function useIsEmbeddedScreen() {
11
+ return useContext(EmbeddedScreenContext);
12
+ }
@@ -14,6 +14,7 @@ export const kumikoDefaultTranslations = {
14
14
  "kumiko.actions.delete": "Delete",
15
15
  "kumiko.actions.delete-confirm": "Confirm delete?",
16
16
  "kumiko.actions.reload": "Reload",
17
+ "kumiko.actions.retry": "Try again",
17
18
  "kumiko.actions.create": "New",
18
19
  "kumiko.actions.edit": "Edit",
19
20
  "kumiko.actions.view": "View",
@@ -36,6 +37,11 @@ export const kumikoDefaultTranslations = {
36
37
  "kumiko.field.locatedTzHint": "Time local to the given location",
37
38
  "kumiko.field.reference-created-no-id": "Record was created but could not be selected automatically. Please select it manually.",
38
39
  "kumiko.field.unsupported": "This field type can't be edited here yet.",
40
+ "kumiko.field.writeOnly.remove": "Remove stored value",
41
+ "kumiko.field.writeOnly.set": "Set",
42
+ "kumiko.field.writeOnly.setPlaceholder": "Set — leave empty to keep it",
43
+ "kumiko.field.writeOnly.undo": "Undo",
44
+ "kumiko.field.writeOnly.willRemove": "Removed on save",
39
45
  "kumiko.field.embedded-list.add-row": "Add row",
40
46
  "kumiko.field.embedded-list.remove-row": "Remove row",
41
47
  "kumiko.field.embedded-list.duplicate-row": "Duplicate row",
@@ -77,6 +83,9 @@ export const kumikoDefaultTranslations = {
77
83
  "kumiko.list.sort.by": "Sorted by {column}",
78
84
  "kumiko.form.changed": "changed",
79
85
  "kumiko.form.on-this-page": "On this page",
86
+ "kumiko.form.noUnsavedChanges": "No unsaved changes",
87
+ "kumiko.form.discard": "Discard",
88
+ "kumiko.form.saveChanges": "Save changes",
80
89
  "kumiko.form.unsaved.one": "1 unsaved change",
81
90
  "kumiko.form.unsaved.other": "{count} unsaved changes",
82
91
  "kumiko.wizard.next-with-title": "Next: {title}",
@@ -153,16 +162,30 @@ export const kumikoDefaultTranslations = {
153
162
  "kumiko.contentEditor.heading2": "Heading 2",
154
163
  "kumiko.contentEditor.bulletList": "Bullet list",
155
164
  "kumiko.contentEditor.orderedList": "Numbered list",
156
- "kumiko.config.source.user": "My value",
165
+ "kumiko.config.source.user": "Your value",
157
166
  "kumiko.config.source.tenant": "Tenant",
158
- "kumiko.config.source.system": "System",
167
+ "kumiko.config.source.system": "Platform",
159
168
  "kumiko.config.source.appOverride": "App override",
160
169
  "kumiko.config.source.computed": "Computed",
161
170
  "kumiko.config.source.default": "Default",
162
171
  "kumiko.config.source.missing": "Missing",
163
- "kumiko.config.cascade.noValue": "No value set",
164
- "kumiko.config.cascade.activeMarker": "active",
165
- "kumiko.config.cascade.resetTo": "Reset override ({scope})",
172
+ "kumiko.config.cascade.noValue": "Not set",
173
+ "kumiko.config.cascade.noDefault": "not set",
174
+ "kumiko.config.cascade.notSetRequired": "Not set. Required.",
175
+ "kumiko.config.cascade.activeMarker": "in use",
176
+ "kumiko.config.cascade.resetTo": "Reset to default",
177
+ "kumiko.config.cascade.resetToApp": "Reset to app value",
178
+ "kumiko.config.cascade.showLevels": "Show all levels",
179
+ "kumiko.config.cascade.hideLevels": "Hide levels",
180
+ "kumiko.config.cascade.on": "On",
181
+ "kumiko.config.cascade.off": "Off",
182
+ "kumiko.config.cascade.origin.tenant": "Set for this tenant. Default is {value}.",
183
+ "kumiko.config.cascade.origin.user": "Set by you. Default is {value}.",
184
+ "kumiko.config.cascade.originNoDefault.tenant": "Set for this tenant.",
185
+ "kumiko.config.cascade.originNoDefault.user": "Set by you.",
186
+ "kumiko.config.cascade.originNoDefault.system": "Set for the platform.",
187
+ "kumiko.config.cascade.origin.system": "Set for the platform. App default is {value}.",
188
+ "kumiko.config.cascade.origin.default": "Uses the default",
166
189
  "kumiko.form.error.generic": "Something went wrong.",
167
190
  "kumiko.form.error.version-conflict": "Record was modified in the meantime. Reload and try again.",
168
191
  "kumiko.form.extension.save-failed": "A custom field could not be saved.",
@@ -183,6 +206,8 @@ export const kumikoDefaultTranslations = {
183
206
  "errors.validation.invalid_type": "Invalid value.",
184
207
  "errors.validation.too_small": "Too small or too short (minimum: {minimum}).",
185
208
  "errors.validation.too_big": "Too big or too long (maximum: {maximum}).",
209
+ "kumiko.validation.number.min": "Must be at least {minimum}.",
210
+ "kumiko.validation.number.max": "Must be {maximum} or less.",
186
211
  "errors.validation.invalid_format": "Invalid format.",
187
212
  "errors.validation.not_multiple_of": "Must be a multiple of {divisor}.",
188
213
  "errors.validation.unrecognized_keys": "Unknown fields.",
package/dist/index.d.ts CHANGED
@@ -43,6 +43,7 @@ export type { DispatcherProviderProps } from "./context/dispatcher-context.js";
43
43
  export { DispatcherProvider, useDispatcher, useDispatcherStatus, useOptionalDispatcher, } from "./context/dispatcher-context.js";
44
44
  export type { DraftStorage, DraftStorageProviderProps } from "./context/draft-storage-context.js";
45
45
  export { DraftStorageProvider, useDraftStorage } from "./context/draft-storage-context.js";
46
+ export { EmbeddedScreenProvider, useIsEmbeddedScreen } from "./context/embedded-screen-context.js";
46
47
  export type { UserRolesProviderProps } from "./context/user-roles-context.js";
47
48
  export { UserRolesProvider, useUserRoles } from "./context/user-roles-context.js";
48
49
  export { formatWhen } from "./format-when.js";
@@ -67,7 +68,7 @@ export { createStaticLocaleResolver, FormalityProvider, LocaleProvider, mergeTra
67
68
  export { kumikoDefaultTranslations } from "./i18n-defaults.js";
68
69
  export { InsideDrawerProvider, useInsideDrawer } from "./inside-drawer.js";
69
70
  export { PageHeaderCompactProvider, PageHeaderSlotAvailableProvider, usePageHeaderCompact, usePageHeaderSlotAvailable, } from "./page-header-slot.js";
70
- export type { ActionMenuItemSpec, ActionOverflowMenuProps, AppPrimitives, BannerProps, ButtonProps, CardOptions, CardProps, CardSlots, CopyButtonProps, CorePrimitives, DataTableDateRangeFacet, DataTableFacet, DataTableFacetChip, DataTableProps, DataTableRowAction, DataTableRowActionMode, DataTableRowGrouping, DataTableSort, DataTableSortDir, DialogProps, DrawerProps, EmbeddedListCellType, EmbeddedListColumn, EmbeddedListInputProps, EmbeddedListTotal, FieldCellWidth, FieldProps, FillContainerProps, FormProps, FormSectionNavItem, FormWidth, GridCellProps, GridProps, HeadingProps, InputProps, JsonViewProps, LightboxProps, LinkProps, MetricBandProps, MetricProps, ModalProps, PageHeaderProps, PrimitivesProviderProps, PrimitivesRegistry, ProgressProps, ProgressTone, PromoPanelAction, PromoPanelProps, RuntimeRenderer, SecretRevealProps, SecretRevealValue, SectionProps, ShareButtonProps, StatusBadgeProps, StatusTone, StepBarProps, StickyActionBarProps, StickyPrimaryActionMarker, TabsProps, TextProps, WizardStepGroupProps, } from "./primitives.js";
71
+ export type { ActionMenuItemSpec, ActionOverflowMenuProps, AppPrimitives, BannerProps, ButtonProps, CardOptions, CardProps, CardSlots, CopyButtonProps, CorePrimitives, DataTableDateRangeFacet, DataTableFacet, DataTableFacetChip, DataTableProps, DataTableRowAction, DataTableRowActionMode, DataTableRowGrouping, DataTableSort, DataTableSortDir, DialogProps, DrawerProps, EmbeddedListCellType, EmbeddedListColumn, EmbeddedListInputProps, EmbeddedListTotal, FieldCellWidth, FieldProps, FillContainerProps, FormProps, FormSectionNavItem, FormWidth, GridCellProps, GridProps, HeadingProps, InputProps, JsonViewProps, LightboxProps, LinkProps, MetricBandProps, MetricProps, ModalProps, PageHeaderProps, PrimitivesProviderProps, PrimitivesRegistry, ProgressProps, ProgressTone, PromoPanelAction, PromoPanelProps, RuntimeRenderer, SecretRevealProps, SecretRevealValue, SectionProps, ShareButtonProps, StatusBadgeProps, StatusTone, StepBarProps, StickyActionBarProps, StickyPrimaryActionMarker, TabsProps, TextProps, TrailingInputAction, WizardStepGroupProps, } from "./primitives.js";
71
72
  export { PrimitivesProvider, STICKY_PRIMARY_ACTION_PROP, shouldRenderActionsIconOnly, statusToneForOptionTone, statusToneForValue, usePrimitives, } from "./primitives.js";
72
73
  export { buildWhatsAppShareUrl } from "./share-links.js";
73
74
  export { sortByAccessor } from "./sort-by-accessor.js";
package/dist/index.js CHANGED
@@ -33,6 +33,7 @@ export { RenderField } from "./components/render-field.js";
33
33
  export { RenderList } from "./components/render-list.js";
34
34
  export { DispatcherProvider, useDispatcher, useDispatcherStatus, useOptionalDispatcher, } from "./context/dispatcher-context.js";
35
35
  export { DraftStorageProvider, useDraftStorage } from "./context/draft-storage-context.js";
36
+ export { EmbeddedScreenProvider, useIsEmbeddedScreen } from "./context/embedded-screen-context.js";
36
37
  export { UserRolesProvider, useUserRoles } from "./context/user-roles-context.js";
37
38
  export { formatWhen } from "./format-when.js";
38
39
  export { REFERENCE_COMBOBOX_LIMIT, REFERENCE_LIST_LOOKUP_LIMIT, REFERENCE_SEARCH_DEBOUNCE_MS, } from "./hooks/reference-limits.js";
@@ -123,11 +123,28 @@ export type FieldProps = {
123
123
  readonly issues?: readonly FieldIssue[];
124
124
  readonly labelAppendix?: ReactNode;
125
125
  readonly fieldAppendix?: ReactNode;
126
+ /** Where `fieldAppendix` sits relative to the control. Default
127
+ * `above-control` (a disclosure panel belongs under its label-row
128
+ * trigger); `below-control` keeps sibling inputs on one line when the
129
+ * appendix is a status line or an expanded detail. */
130
+ readonly appendixPlacement?: "above-control" | "below-control";
131
+ /** Help text directly under the label, above the control. */
132
+ readonly description?: ReactNode;
133
+ /** Status pill at the right end of the label row (e.g. "Saved", "Not set"). */
134
+ readonly status?: {
135
+ readonly tone: StatusTone;
136
+ readonly label: string;
137
+ readonly testId?: string;
138
+ };
126
139
  readonly children: ReactNode;
127
140
  /** Label-Layout. `stacked` (Default): Label über dem Control. `inline`:
128
141
  * Control + Label nebeneinander (Box links, Label rechts) — für
129
142
  * Checkbox/Switch-Felder (boolean), wie das shadcn-Muster. */
130
- readonly layout?: "stacked" | "inline";
143
+ readonly layout?: "stacked" | "inline" | "row";
144
+ /** Marks a field whose value is set at this level (differs from the
145
+ * inherited one). Only `layout="row"` draws it: a 2px primary line on the
146
+ * row's left edge. Ignored by the other layouts. */
147
+ readonly accent?: boolean;
131
148
  /** Collapses the visible label to `sr-only` — stays associated via
132
149
  * `htmlFor` (no separate `aria-label` needed). For tables/grids whose
133
150
  * column header already carries the label for each row. */
@@ -141,6 +158,12 @@ export type FieldProps = {
141
158
  /** Discriminated union — jede Input-Sorte hat ihre eigene value/onChange
142
159
  * Signatur. Custom-Impls dispatchen intern, rendern anders (Toggle
143
160
  * statt Checkbox), oder nur einzelne kinds unterschiedlich. */
161
+ export type TrailingInputAction = {
162
+ readonly icon: "clear" | "undo";
163
+ readonly label: string;
164
+ readonly onPress: () => void;
165
+ readonly testId?: string;
166
+ };
144
167
  export type InputProps = {
145
168
  readonly kind: "text";
146
169
  readonly id: string;
@@ -198,6 +221,9 @@ export type InputProps = {
198
221
  * Browser-Password-Manager nutzen das für die Speicherentscheidung.
199
222
  * Native: textContentType="password" / "newPassword". */
200
223
  readonly autoComplete?: "current-password" | "new-password";
224
+ /** Icon button inside the field's trailing edge (clear a stored value,
225
+ * undo a pending removal). `label` is its accessible name + tooltip. */
226
+ readonly trailingAction?: TrailingInputAction;
201
227
  readonly testId?: string;
202
228
  } | {
203
229
  readonly kind: "number";
@@ -299,8 +325,13 @@ export type InputProps = {
299
325
  readonly options: readonly string[] | readonly {
300
326
  readonly value: string;
301
327
  readonly label: string;
302
- /** Second line under the label; shown by `radioVariant="card"`. */
328
+ /** Muted second line under the label (radio list and dropdown items;
329
+ * the dropdown search matches it too). */
303
330
  readonly description?: string;
331
+ /** Group heading. Groups form in order of first occurrence; options
332
+ * without a group stay ungrouped and come before all groups. Options
333
+ * with description or group never render as a segmented control. */
334
+ readonly group?: string;
304
335
  }[];
305
336
  readonly disabled?: boolean;
306
337
  readonly required?: boolean;
@@ -837,8 +868,8 @@ export type FormProps = {
837
868
  * detail screens (which also fill the height) do not set it. Native impls
838
869
  * may ignore this prop. */
839
870
  readonly screenForm?: boolean;
840
- /** Number of unsaved changes, shown in the pinned footer. Omitted in create
841
- * mode and when nothing changed — the footer then shows no status. */
871
+ /** Number of unsaved changes, shown in the pinned footer; 0 shows the quiet
872
+ * "No unsaved changes" status. Omitted (create mode) — no status at all. */
842
873
  readonly unsavedCount?: number;
843
874
  /** Drawer-hosted forms: context box above the fields. */
844
875
  readonly summary?: {
@@ -870,6 +901,9 @@ export type SectionProps = {
870
901
  * title-less/subtitle-less Section still draws the row when `actions`
871
902
  * is set, so a hideTitle tabs-Section with actions isn't stranded. */
872
903
  readonly actions?: ReactNode;
904
+ /** "settings-list": title + subtitle form a left header column and the
905
+ * children (field rows) the right one on `md:` and up. Default "stacked". */
906
+ readonly layout?: "stacked" | "settings-list";
873
907
  /** "destructive" marks the Section as a warning/danger area (e.g. account
874
908
  * deletion, restrict processing) — border color only, no content change.
875
909
  * Default "default" (normal card border). */
@@ -920,6 +954,9 @@ export type GridProps = {
920
954
  /** Fields flow left to right in wrapping rows sized by `GridCell.width`
921
955
  * instead of equal-width tracks (screen forms and drawers). */
922
956
  readonly flow?: boolean;
957
+ /** One full-width row per child, separated by hairlines — the field rows of
958
+ * a `settings-list` section. Wins over `columns`/`flow`. */
959
+ readonly list?: boolean;
923
960
  };
924
961
  /** Width class of a field cell inside a `flow` grid: sized by what the field holds. */
925
962
  export type FieldCellWidth = "text" | "number" | "money" | "date" | "timestamp" | "select" | "full" | "toggle";
@@ -1036,19 +1073,19 @@ export type ConfigSourceBadgeProps = {
1036
1073
  * rule as ConfigCascadeView's toDisplayLevels. */
1037
1074
  readonly screenScope?: ConfigScope;
1038
1075
  };
1039
- /** Collapsible cascade-view for a config-edit field. Split into two slots
1040
- * (#429): "trigger" is the collapsed ▶ + source + value line for the label
1041
- * row; "panel" is the expanded cascade + reset shown below the input. In
1042
- * split mode the screen owns the `expanded` state (both slots share it);
1043
- * without `slot` the component renders both with its own state. */
1076
+ /** Cascade view for a config-edit field: one origin line ("Set for this
1077
+ * tenant", "Uses the default", …), a reset action for an own value, and a
1078
+ * disclosure listing every level. Owns its disclosure state. */
1044
1079
  export type ConfigCascadeViewProps = {
1045
1080
  readonly cascade: ConfigCascade;
1046
1081
  readonly screenScope: ConfigScope;
1047
1082
  readonly onReset?: (key: string, scope: ConfigScope) => void;
1048
1083
  readonly qualifiedKey?: string;
1049
- readonly slot?: "trigger" | "panel";
1050
- readonly expanded?: boolean;
1051
- readonly onToggle?: () => void;
1084
+ /** The key declares `required: true` — a missing value reads "Not set. Required.". */
1085
+ readonly required?: boolean;
1086
+ /** Display form of a level value (e.g. a select's label instead of its raw
1087
+ * value). Absent = the raw value; a missing value keeps the no-value text. */
1088
+ readonly renderValue?: (value: string | number | boolean) => ReactNode;
1052
1089
  };
1053
1090
  /** Card-Slots — benannte Inhalts-Regionen, alle optional. Neue Slots später =
1054
1091
  * rein additiv → Consumer müssen NIE migriert werden. `header` ist der Escape-
@@ -1082,6 +1119,11 @@ export type CardOptions = {
1082
1119
  /** false drops border, background, shadow and radius but keeps padding and
1083
1120
  * header slots — a panel inside a host surface such as a tab panel. Default true. */
1084
1121
  readonly framed?: boolean;
1122
+ /** The card is the whole screen body (secretMint reveal and done phase):
1123
+ * no frame, the shared screen insets and width of form screens, children
1124
+ * stacked with a gap. Without it such a screen sits raw against the
1125
+ * shell edge. Default false. */
1126
+ readonly screenBody?: boolean;
1085
1127
  };
1086
1128
  /** Generische Card-Chrome (border/bg/radius/shadow + Header/Body/Footer) als
1087
1129
  * Primitive — eine Quelle für alle Card-Flächen, statt pro App nachgebaut.