@cosmicdrift/kumiko-renderer 0.332.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.
Files changed (35) hide show
  1. package/dist/app/config-edit-shim.d.ts +7 -0
  2. package/dist/app/config-edit-shim.js +39 -0
  3. package/dist/app/facet-count-bridge.d.ts +10 -0
  4. package/dist/app/facet-count-bridge.js +20 -0
  5. package/dist/app/kumiko-screen.js +103 -42
  6. package/dist/app/list-facets.d.ts +14 -2
  7. package/dist/app/list-facets.js +74 -0
  8. package/dist/app/query-state-banners.d.ts +8 -0
  9. package/dist/app/query-state-banners.js +14 -0
  10. package/dist/app/row-actions.js +50 -40
  11. package/dist/app/secret-mint-body.js +3 -3
  12. package/dist/app/secrets-edit-body.js +46 -17
  13. package/dist/components/grid-cell-for-field.d.ts +7 -2
  14. package/dist/components/grid-cell-for-field.js +2 -2
  15. package/dist/components/query-options-select.d.ts +12 -3
  16. package/dist/components/query-options-select.js +45 -2
  17. package/dist/components/related-list-section.js +41 -8
  18. package/dist/components/render-edit-types.d.ts +24 -1
  19. package/dist/components/render-edit.js +100 -28
  20. package/dist/components/render-field.d.ts +7 -1
  21. package/dist/components/render-field.js +32 -4
  22. package/dist/components/render-list.d.ts +4 -1
  23. package/dist/components/render-list.js +2 -2
  24. package/dist/components/write-form-section.d.ts +5 -1
  25. package/dist/components/write-form-section.js +17 -5
  26. package/dist/context/embedded-screen-context.d.ts +5 -0
  27. package/dist/context/embedded-screen-context.js +12 -0
  28. package/dist/hooks/use-list-url-state.d.ts +2 -1
  29. package/dist/hooks/use-list-url-state.js +23 -9
  30. package/dist/i18n-defaults.js +31 -5
  31. package/dist/index.d.ts +2 -1
  32. package/dist/index.js +1 -0
  33. package/dist/primitives.d.ts +95 -13
  34. package/package.json +5 -5
  35. package/src/changes.json +7 -0
@@ -6,6 +6,7 @@ import { AllFieldsRequiredProvider } from "./render-field.js";
6
6
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
7
7
  import { ExtensionFormRegistryProvider, useExtensionFormHost, } from "../app/extension-form-submit.js";
8
8
  import { extensionSectionName, useExtensionSectionComponent } from "../app/extension-sections.js";
9
+ import { layoutEditFields } from "../app/layout-fields.js";
9
10
  import { useOptionalDispatcher } from "../context/dispatcher-context.js";
10
11
  import { useDraftStorage } from "../context/draft-storage-context.js";
11
12
  import { formatWhen } from "../format-when.js";
@@ -145,8 +146,23 @@ function formSectionDomId(sectionIndex) {
145
146
  export function resolveRecordTitle(value) {
146
147
  return typeof value === "string" && value.trim() !== "" ? value : undefined;
147
148
  }
149
+ const TITLE_PLACEHOLDER_PATTERN = /\{(\w+)\}/g;
150
+ // Falls back to the plain title while any placeholder value is still empty,
151
+ // so the header never shows "Add " mid-typing.
152
+ function resolveTitleTemplate(template, values, translate, fallback) {
153
+ if (template === undefined)
154
+ return fallback;
155
+ const placeholders = [...translate(template).matchAll(TITLE_PLACEHOLDER_PATTERN)].map((match) => match[1] ?? "");
156
+ const isFilled = (name) => {
157
+ const value = values[name];
158
+ return value !== undefined && value !== null && String(value).trim() !== "";
159
+ };
160
+ if (!placeholders.every(isFilled))
161
+ return fallback;
162
+ return translate(template, values);
163
+ }
148
164
  export function RenderEdit(props) {
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;
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;
150
166
  const { customSubmit } = props;
151
167
  // Translate-Fallback: wenn der Caller keine Translate-Fn übergibt,
152
168
  // konsumieren wir den i18next-Context direkt. Sonst wären Field-
@@ -239,11 +255,15 @@ export function RenderEdit(props) {
239
255
  // Submit-Config nur wenn der Caller einen writeCommand mitgibt; bei
240
256
  // customSubmit-Pfad kommt der Form-Controller ohne Submit-Wiring,
241
257
  // weil wir controller.submit() eh nicht rufen.
258
+ const omitFields = useMemo(() => layoutEditFields(screen)
259
+ .filter((spec) => spec.submit === false)
260
+ .map((spec) => spec.field), [screen]);
242
261
  const submitConfig = writeCommand !== undefined
243
262
  ? {
244
263
  type: writeCommand,
245
264
  payloadMode,
246
265
  ...(buildPayload !== undefined && { buildPayload }),
266
+ ...(omitFields.length > 0 && { omitFields }),
247
267
  ...(serverFieldPathPrefix !== undefined && { serverFieldPathPrefix }),
248
268
  ...(scopeFieldNames !== undefined && { validateScope: scopeFieldNames }),
249
269
  }
@@ -549,6 +569,11 @@ export function RenderEdit(props) {
549
569
  // keeps normal document-flow height untouched.
550
570
  const fillHeight = fillScreenHeight === true ||
551
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);
552
577
  // Persistiert alle composed Extension-Sections mit der aufgelösten entityId.
553
578
  // false = eine Section schlug fehl (ihr i18n-Key landet im Banner). Ohne
554
579
  // Entity-Kontext (create-mode ohne route-id) gibt es nichts zu schreiben.
@@ -852,10 +877,7 @@ export function RenderEdit(props) {
852
877
  handleSubmitRef.current = handleSubmit;
853
878
  // Screen forms (entityEdit/actionForm filling the shell height) drop the card;
854
879
  // the title then lives in the shell header when the shell offers a slot.
855
- const isScreenForm = fillScreenHeight === true &&
856
- hideSectionTitles !== true &&
857
- headerRegion === undefined &&
858
- !insideDrawer;
880
+ const isScreenForm = fillScreenHeight === true && hideSectionTitles !== true && !insideDrawer;
859
881
  const titleInShell = PageHeader !== undefined &&
860
882
  pageHeaderSlotAvailable &&
861
883
  hideSectionTitles !== true &&
@@ -940,13 +962,41 @@ export function RenderEdit(props) {
940
962
  const hasFormActions = (isWizard && currentStep > 0) ||
941
963
  (isWizard && !isLastWizardStep) ||
942
964
  (showsSubmit && (!isWizard || isLastWizardStep)) ||
943
- footerSlot !== undefined;
965
+ footerSlot !== undefined ||
966
+ writeFormFooterAction !== undefined;
944
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;
945
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
946
986
  ? translate("kumiko.wizard.next-with-title", { title: nextStepTitle })
947
- : translate("kumiko.actions.next") })), showsSubmit && (!isWizard || isLastWizardStep) && (_jsx(Button, { type: "submit", disabled: (snapshot.isUnchanged && !extensionDirty && !isFieldless && !insideDrawer) ||
987
+ : translate("kumiko.actions.next") })), showsSubmit &&
988
+ (!isWizard || isLastWizardStep) &&
989
+ footerActions?.map((action) => (_jsx(Button, { type: "button", variant: action.variant ?? "secondary", ...(action.icon !== undefined && { icon: action.icon }), disabled: isSubmitting || disabled, onClick: () => {
990
+ controller.setValues(action.patch); // @cast-boundary form-values
991
+ void handleSubmit();
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) ||
948
993
  isSubmitting ||
949
- 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] }));
950
1000
  // Title + Subtitle, create/edit-bewusst. i18n-Keys (mode = "create"|"edit"):
951
1001
  // screen:<id>.<mode>.title / .<mode>.subtitle
952
1002
  // Fallback-Kette: mode-spezifisch → generisch (screen:<id>.title/.subtitle).
@@ -957,10 +1007,6 @@ export function RenderEdit(props) {
957
1007
  // prose, same as it does for an unknown key (fw#2723 review). Same slot
958
1008
  // section.description already fills for a section. Falls back to
959
1009
  // undefined (no subtitle) when neither is set.
960
- const isCreate = (() => {
961
- const id = resolveExtensionEntityId(entityIdProp, vm.id);
962
- return id == null || id === "";
963
- })();
964
1010
  const formMode = isCreate ? "create" : "edit";
965
1011
  const recordTitle = !isCreate && screen.recordTitleField !== undefined
966
1012
  ? resolveRecordTitle(initial[screen.recordTitleField])
@@ -976,24 +1022,22 @@ export function RenderEdit(props) {
976
1022
  }
977
1023
  return undefined;
978
1024
  };
979
- const formTitle = resolveScreenText("title") ?? screen.id;
1025
+ const plainFormTitle = resolveScreenText("title") ?? screen.id;
1026
+ const formTitle = resolveTitleTemplate(screen.titleTemplate, snapshot.values, translate, plainFormTitle);
980
1027
  // screen.description is head-card copy, not tab content — in tabs mode
981
1028
  // (hideSectionTitles) the head card already carries title/subtitle/status.
982
1029
  const formSubtitle = hideSectionTitles !== true
983
1030
  ? (resolveScreenText("subtitle") ??
984
1031
  (screen.description !== undefined ? translate(screen.description) : undefined))
985
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;
986
1037
  const usesFieldFlow = isScreenForm || insideDrawer;
987
1038
  const allRequiredHint = (_jsx(Text, { variant: "muted", testId: "render-edit-all-required-hint", children: translate("kumiko.form.all-required") }));
988
1039
  const stepRail = isWizard && isScreenForm && StepBar !== undefined;
989
- const changedFieldCount = isCreate ? 0 : Object.keys(snapshot.changes).length;
990
- const unsavedCount = isCreate || !isScreenForm
991
- ? 0
992
- : changedFieldCount > 0
993
- ? changedFieldCount
994
- : extensionDirty
995
- ? 1
996
- : 0;
1040
+ const upNextSection = screen.layout.wizard?.aside?.upNext === true ? filteredSections[currentStep + 1] : undefined;
997
1041
  const sectionNavItems = isScreenForm && !isStepped
998
1042
  ? filteredSections.flatMap((section, index) => section.kind !== "extension" &&
999
1043
  section.kind !== "relatedList" &&
@@ -1019,11 +1063,23 @@ export function RenderEdit(props) {
1019
1063
  !pageHeaderCompact &&
1020
1064
  ActionOverflowMenu !== undefined && {
1021
1065
  actions: (_jsx(ActionOverflowMenu, { label: translate("kumiko.list.row-actions.more"), items: headerMenuItems, testId: "render-edit-header-menu" })),
1022
- }) })), _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 && {
1023
1072
  sideRail: (_jsx(StepBar, { steps: filteredSections.map((section) => section.title ?? ""), currentIndex: currentStep, compactLabel: compactStepLabel, onStepSelect: handleWizardJumpBack, orientation: "vertical", heading: translate("kumiko.wizard.step", {
1024
1073
  current: currentStep + 1,
1025
1074
  total: lastStepIndex + 1,
1026
- }), ...(formSubtitle !== undefined && { description: formSubtitle }), testId: "render-edit-wizard-steps", compactTestId: "render-edit-wizard-step-label" })),
1075
+ }), ...(formSubtitle !== undefined && { description: formSubtitle }), subtitles: filteredSections.map((section) => section.kind === "fields" ? section.subtitle : undefined), ...(upNextSection !== undefined && {
1076
+ upNext: {
1077
+ heading: translate("kumiko.wizard.up-next"),
1078
+ title: upNextSection.title ?? "",
1079
+ ...(upNextSection.kind === "fields" &&
1080
+ upNextSection.subtitle !== undefined && { subtitle: upNextSection.subtitle }),
1081
+ },
1082
+ }), testId: "render-edit-wizard-steps", compactTestId: "render-edit-wizard-step-label" })),
1027
1083
  }), ...(hideActions !== true && hasFormActions && { actions: formActions }), ...(hideActions !== true &&
1028
1084
  hasSecondaryFormActions && { secondaryActions: secondaryFormActions }), testId: "render-edit-form", stickyActions: isWizard || fillScreenHeight === true, ...(screen.layout.width !== undefined && { width: screen.layout.width }), ...(formHeaderRegion !== undefined && { headerRegion: formHeaderRegion }), ...(titleActionMount !== undefined && { titleAction: titleActionMount }), ...(fillHeight && { fillHeight }), ...(hideSectionTitles === true && { chromeless: true }), ...(summary !== undefined && { summary }), children: _jsxs(AllFieldsRequiredProvider, { value: showsAllRequiredHint, children: [showsAllRequiredHint && !insideDrawer && allRequiredHint, draftCandidates !== null && (_jsx(Banner, { variant: "info", testId: "render-edit-draft-picker", actions: [
1029
1085
  ...draftCandidates.map((candidate) => (_jsx(Button, { type: "button", variant: "link", onClick: () => adoptDraft(candidate), testId: `render-edit-draft-pick-${candidate.id}`, children: formatWhen(candidate.savedAt) }, candidate.id))),
@@ -1119,19 +1175,29 @@ export function RenderEdit(props) {
1119
1175
  // Own submit button + dispatcher call, entirely independent of
1120
1176
  // this screen's (nonexistent, on projectionDetail) form submit —
1121
1177
  // rejected at boot in wizard layouts, so no WizardStepGroup here.
1122
- 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}`));
1123
1179
  }
1124
1180
  if (!section.visible)
1125
1181
  return null;
1126
1182
  // Titellose Sections kollidieren sonst auf key/testId — Index-Fallback.
1127
1183
  const sectionKey = section.title ?? `section-${sectionIndex}`;
1128
- 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) => {
1129
1188
  controller.setField(field.field, v);
1189
+ if (validateOnChange === true)
1190
+ controller.validate([field.field]);
1130
1191
  }, GridCell: GridCell, featureName: featureName, ...(labelAppendix !== undefined && {
1131
1192
  labelAppendix: labelAppendix(field.field),
1132
1193
  }), ...(fieldAppendix !== undefined && {
1133
1194
  fieldAppendix: fieldAppendix(field.field),
1134
- }), 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));
1135
1201
  // Tabs mode: the tab panel is the surface, so field sections render
1136
1202
  // as unframed padded panels; group cards inside stay framed.
1137
1203
  // Stays `Card`, not `Section` — Section always flattens to a
@@ -1162,10 +1228,16 @@ export function RenderEdit(props) {
1162
1228
  // tab label right above it. Without a Tabs primitive the sections
1163
1229
  // render stacked, and then the titles are what tells them apart.
1164
1230
  const repeatsTabLabel = isTabs && isStepped;
1165
- 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);
1166
1238
  const sectionEl = (_jsxs(Section, { ...(sectionNavItems.some((item) => item.id === formSectionDomId(sectionIndex)) && {
1167
1239
  id: formSectionDomId(sectionIndex),
1168
- }), ...(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));
1169
1241
  return wrapWizardStep(sectionKey, sectionEl);
1170
1242
  }), formError !== null && (_jsx(Banner, { variant: "error", testId: "render-edit-form-error", actions: formError.code === "version_conflict" && onReload !== undefined ? (_jsx(Button, { variant: "secondary", onClick: () => {
1171
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) {
@@ -2,7 +2,7 @@ import type { EntityDefinition, EntityListScreenDefinition, IconKey } from "@cos
2
2
  import type { ListRowViewModel, Translate } from "@cosmicdrift/kumiko-headless";
3
3
  import { type ReactNode } from "react";
4
4
  import type { ListSort } from "../hooks/use-list-url-state.js";
5
- import { type DataTableDateRangeFacet, type DataTableFacet, type DataTableRowAction, type DataTableRowActionMode } from "../primitives.js";
5
+ import { type DataTableDateRangeFacet, type DataTableFacet, type DataTableProps, type DataTableRowAction, type DataTableRowActionMode, type DataTableRowGrouping } from "../primitives.js";
6
6
  export type RenderListProps = {
7
7
  readonly screen: EntityListScreenDefinition;
8
8
  readonly entity: EntityDefinition;
@@ -65,6 +65,9 @@ export type RenderListProps = {
65
65
  * KumikoScreen derives it from the actions themselves; without it the
66
66
  * DataTable falls back to its adaptive default. */
67
67
  readonly rowActionMode?: DataTableRowActionMode;
68
+ /** Forwarded to `DataTableProps.rowGrouping` / `rowTone`. */
69
+ readonly rowGrouping?: DataTableRowGrouping;
70
+ readonly rowTone?: DataTableProps["rowTone"];
68
71
  /** Toolbar-Aktionen im List-Header — Resolved-Form (KumikoScreen baut
69
72
  * das aus EntityListScreenDefinition.toolbarActions: navigate-target
70
73
  * → useNav, handler-QN → dispatcher-Call). RenderList rendert die
@@ -13,7 +13,7 @@ function isHeaderPromotableToolbarAction(action) {
13
13
  return (action.style === "primary" && action.confirm === undefined && action.confirmRequired === false);
14
14
  }
15
15
  export function RenderList(props) {
16
- const { screen, entity, rows, featureName, translate: translateProp, onRowClick, emptyState, onCreate, createLabel, searchable = false, searchPlaceholder, description, itemNounKey, searchValue, onSearchChange, sort, onSortChange, pager, onReachEnd, loadingMore, hasMore, rowActions, rowActionMode, toolbarActions, filterFacets, filterValues, onFilterChange, onFilterReset, dateRangeFacets, onDateRangeChange, chromeless, scrollBody, screenPadding, expandedRowIds, onToggleRowExpanded, renderExpandedRow, } = props;
16
+ const { screen, entity, rows, featureName, translate: translateProp, onRowClick, emptyState, onCreate, createLabel, searchable = false, searchPlaceholder, description, itemNounKey, searchValue, onSearchChange, sort, onSortChange, pager, onReachEnd, loadingMore, hasMore, rowActions, rowActionMode, rowGrouping, rowTone, toolbarActions, filterFacets, filterValues, onFilterChange, onFilterReset, dateRangeFacets, onDateRangeChange, chromeless, scrollBody, screenPadding, expandedRowIds, onToggleRowExpanded, renderExpandedRow, } = props;
17
17
  // Wie RenderEdit: Translate-Fallback aus dem i18next-Context, sonst
18
18
  // wären Column-Header raw i18n-Keys.
19
19
  const t = useTranslation();
@@ -168,7 +168,7 @@ export function RenderList(props) {
168
168
  const resolvedTitle = translate(titleKey);
169
169
  const toolbarTitle = resolvedTitle === titleKey ? screen.id : resolvedTitle;
170
170
  // ListSort = DataTableSort (use-list-url-state aliased) — kein Cast nötig.
171
- return (_jsxs(_Fragment, { children: [createInPageHeader && PageHeader !== undefined && _jsx(PageHeader, { actions: createButton }), promotedActionButton !== undefined && PageHeader !== undefined && (_jsx(PageHeader, { actions: promotedActionButton })), referenceColumns.map((rc) => (_jsx(ReferenceLookupBridge, { field: rc.field, refEntity: rc.refEntity, labelField: rc.labelField, featureName: rc.refFeature, onMap: handleLookupMap }, rc.field))), _jsx(DataTable, { columns: enrichedVm.columns, rows: enrichedVm.rows, toolbarTitle: toolbarTitle, ...(onRowClick !== undefined && { onRowClick }), ...(composedEmptyState !== undefined && { emptyState: composedEmptyState }), ...(toolbarStart !== undefined && { toolbarStart }), ...(description !== undefined && !searchable && { toolbarDescription: description }), ...(toolbarEnd !== undefined && { toolbarEnd }), ...(sort !== undefined && { sort }), ...(onSortChange !== undefined && { onSortChange }), ...(pager !== undefined && { pager }), ...(onReachEnd !== undefined && { onReachEnd }), ...(loadingMore !== undefined && { loadingMore }), ...(hasMore !== undefined && { hasMore }), ...(itemNoun !== undefined && { itemNoun }), ...(rowActions !== undefined && { rowActions }), ...(rowActionMode !== undefined && { rowActionMode }), ...(filterFacets !== undefined && { filterFacets }), ...(filterValues !== undefined && { filterValues }), ...(onFilterChange !== undefined && { onFilterChange }), ...(onFilterReset !== undefined && { onFilterReset }), ...(dateRangeFacets !== undefined && { dateRangeFacets }), ...(onDateRangeChange !== undefined && { onDateRangeChange }), ...(chromeless !== undefined && { chromeless }), ...(scrollBody !== undefined && { scrollBody }), ...(screenPadding !== undefined && { screenPadding }), ...(expandedRowIds !== undefined && { expandedRowIds }), ...(onToggleRowExpanded !== undefined && { onToggleRowExpanded }), ...(renderExpandedRow !== undefined && { renderExpandedRow }), testId: "render-list-table" })] }));
171
+ return (_jsxs(_Fragment, { children: [createInPageHeader && PageHeader !== undefined && _jsx(PageHeader, { actions: createButton }), promotedActionButton !== undefined && PageHeader !== undefined && (_jsx(PageHeader, { actions: promotedActionButton })), referenceColumns.map((rc) => (_jsx(ReferenceLookupBridge, { field: rc.field, refEntity: rc.refEntity, labelField: rc.labelField, featureName: rc.refFeature, onMap: handleLookupMap }, rc.field))), _jsx(DataTable, { columns: enrichedVm.columns, rows: enrichedVm.rows, toolbarTitle: toolbarTitle, ...(onRowClick !== undefined && { onRowClick }), ...(composedEmptyState !== undefined && { emptyState: composedEmptyState }), ...(toolbarStart !== undefined && { toolbarStart }), ...(description !== undefined && !searchable && { toolbarDescription: description }), ...(toolbarEnd !== undefined && { toolbarEnd }), ...(sort !== undefined && { sort }), ...(onSortChange !== undefined && { onSortChange }), ...(pager !== undefined && { pager }), ...(onReachEnd !== undefined && { onReachEnd }), ...(loadingMore !== undefined && { loadingMore }), ...(hasMore !== undefined && { hasMore }), ...(itemNoun !== undefined && { itemNoun }), ...(rowActions !== undefined && { rowActions }), ...(rowActionMode !== undefined && { rowActionMode }), ...(rowGrouping !== undefined && { rowGrouping }), ...(rowTone !== undefined && { rowTone }), ...(filterFacets !== undefined && { filterFacets }), ...(filterValues !== undefined && { filterValues }), ...(onFilterChange !== undefined && { onFilterChange }), ...(onFilterReset !== undefined && { onFilterReset }), ...(dateRangeFacets !== undefined && { dateRangeFacets }), ...(onDateRangeChange !== undefined && { onDateRangeChange }), ...(chromeless !== undefined && { chromeless }), ...(scrollBody !== undefined && { scrollBody }), ...(screenPadding !== undefined && { screenPadding }), ...(expandedRowIds !== undefined && { expandedRowIds }), ...(onToggleRowExpanded !== undefined && { onToggleRowExpanded }), ...(renderExpandedRow !== undefined && { renderExpandedRow }), testId: "render-list-table" })] }));
172
172
  }
173
173
  // Header-Slot in der Listen-Toolbar (toolbarEnd): `screen.slots.header` ist
174
174
  // eine PlatformComponent (`{ react: { __component: "X" } }`), aufgelöst über
@@ -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
+ }
@@ -1,4 +1,5 @@
1
1
  import type { DataTableSort, DataTableSortDir } from "../primitives.js";
2
+ export declare const NO_FILTER_URL_VALUE = "~";
2
3
  export type ListSortDir = DataTableSortDir;
3
4
  export type ListSort = DataTableSort;
4
5
  export type ListUrlState = {
@@ -40,4 +41,4 @@ export type ListUrlStateApi = ListUrlState & {
40
41
  }) => void;
41
42
  };
42
43
  export declare const PAGE_SIZE_OPTIONS: readonly number[];
43
- export declare function useListUrlState(screenId: string): ListUrlStateApi;
44
+ export declare function useListUrlState(screenId: string, defaultFilters?: Readonly<Record<string, readonly string[]>>): ListUrlStateApi;
@@ -8,12 +8,18 @@
8
8
  // <screenId>.dir — "asc" | "desc"
9
9
  // <screenId>.q — search term (URL-encoded)
10
10
  // <screenId>.page — 1-based page number (nur bei pagination="pages")
11
- // <screenId>.f.<field> — Faceted-Filter: comma-joined selected values
11
+ // <screenId>.f.<field> — Faceted-Filter: comma-joined selected values;
12
+ // NO_FILTER_URL_VALUE = "no filter", chosen on purpose
13
+ // for a field that has a declared default
12
14
  //
13
15
  // Schreibt mit setSearchParams (replaceState — kein push), damit
14
16
  // Sort/Filter-Toggles nicht die Browser-History fluten.
15
17
  import { useCallback, useMemo } from "react";
16
18
  import { useNav } from "../app/nav.js";
19
+ // A facet with a `defaultFilters` entry falls back to that default whenever
20
+ // its URL key is absent. Clearing it therefore has to leave a marker, or the
21
+ // default would come straight back.
22
+ export const NO_FILTER_URL_VALUE = "~";
17
23
  // `.` als Trenner: lesbar (`?orders.sort=name`), kollidiert nicht mit
18
24
  // üblichen Field-Namen (kebab- oder camelCase ohne Punkt). Boot-Validator
19
25
  // pinnt screen.id ohne Punkt — siehe boot-validator entityList Section.
@@ -36,7 +42,7 @@ function parsePage(value) {
36
42
  const n = Number.parseInt(value, 10);
37
43
  return Number.isFinite(n) && n > 0 ? n : 1;
38
44
  }
39
- export function useListUrlState(screenId) {
45
+ export function useListUrlState(screenId, defaultFilters) {
40
46
  const nav = useNav();
41
47
  const params = nav.searchParams;
42
48
  const sort = useMemo(() => {
@@ -60,10 +66,17 @@ export function useListUrlState(screenId) {
60
66
  continue;
61
67
  // ponytail: comma-join — Facet-Werte sind select-Options/booleans
62
68
  // ohne Komma; upgrade auf repeated-keys falls je Komma-Werte nötig.
63
- out[field] = v.split(",").filter((s) => s !== "");
69
+ out[field] = v === NO_FILTER_URL_VALUE ? [] : v.split(",").filter((s) => s !== "");
70
+ }
71
+ for (const [field, values] of Object.entries(defaultFilters ?? {})) {
72
+ if (!Object.hasOwn(out, field))
73
+ out[field] = values;
64
74
  }
65
75
  return out;
66
- }, [params, filterPrefix]);
76
+ }, [params, filterPrefix, defaultFilters]);
77
+ const clearedFilterValue = useCallback((field) => defaultFilters !== undefined && Object.hasOwn(defaultFilters, field)
78
+ ? NO_FILTER_URL_VALUE
79
+ : null, [defaultFilters]);
67
80
  const setSort = useCallback((next) => {
68
81
  // Atomares Update: bei jedem Sort-Wechsel resetten wir auch die
69
82
  // Page (sonst wäre der User auf "Seite 5 von alter Sortierung"
@@ -97,10 +110,10 @@ export function useListUrlState(screenId) {
97
110
  }, [nav, screenId]);
98
111
  const setFilter = useCallback((field, values) => {
99
112
  nav.setSearchParams({
100
- [key(screenId, `f.${field}`)]: values.length === 0 ? null : values.join(","),
113
+ [key(screenId, `f.${field}`)]: values.length === 0 ? clearedFilterValue(field) : values.join(","),
101
114
  [key(screenId, "page")]: null,
102
115
  });
103
- }, [nav, screenId]);
116
+ }, [nav, screenId, clearedFilterValue]);
104
117
  const setDateRange = useCallback((field, next) => {
105
118
  nav.setSearchParams({
106
119
  [key(screenId, `f.${field}.from`)]: next.from === "" ? null : next.from,
@@ -110,10 +123,11 @@ export function useListUrlState(screenId) {
110
123
  }, [nav, screenId]);
111
124
  const clearFilters = useCallback(() => {
112
125
  const updates = { [key(screenId, "page")]: null };
113
- for (const field of Object.keys(filters))
114
- updates[key(screenId, `f.${field}`)] = null;
126
+ for (const field of Object.keys(filters)) {
127
+ updates[key(screenId, `f.${field}`)] = clearedFilterValue(field);
128
+ }
115
129
  nav.setSearchParams(updates);
116
- }, [nav, screenId, filters]);
130
+ }, [nav, screenId, filters, clearedFilterValue]);
117
131
  return {
118
132
  sort,
119
133
  q,