@cosmicdrift/kumiko-renderer 0.334.0 → 0.336.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 (43) hide show
  1. package/dist/app/content-preview.js +7 -9
  2. package/dist/app/extension-sections.d.ts +6 -0
  3. package/dist/app/form-schema.d.ts +2 -1
  4. package/dist/app/form-schema.js +11 -2
  5. package/dist/app/kumiko-screen.d.ts +5 -0
  6. package/dist/app/kumiko-screen.js +196 -65
  7. package/dist/app/nav.d.ts +5 -0
  8. package/dist/app/nav.js +17 -4
  9. package/dist/app/reference-facet-bridge.js +8 -1
  10. package/dist/app/row-actions.d.ts +12 -0
  11. package/dist/app/row-actions.js +24 -7
  12. package/dist/app/screen-access.js +13 -9
  13. package/dist/app/secret-mint-body.js +40 -6
  14. package/dist/app/secrets-edit-body.js +71 -44
  15. package/dist/components/embedded-list-field.js +2 -4
  16. package/dist/components/multi-select-checkboxes.js +4 -1
  17. package/dist/components/reference-create-dialog.js +9 -4
  18. package/dist/components/related-list-section.js +7 -5
  19. package/dist/components/render-edit-action-button.d.ts +1 -1
  20. package/dist/components/render-edit-action-button.js +5 -1
  21. package/dist/components/render-edit-logic.d.ts +2 -1
  22. package/dist/components/render-edit-logic.js +8 -5
  23. package/dist/components/render-edit-types.d.ts +10 -1
  24. package/dist/components/render-edit.js +189 -54
  25. package/dist/components/render-field.js +10 -13
  26. package/dist/components/render-list.js +6 -6
  27. package/dist/components/write-form-section.js +12 -20
  28. package/dist/hooks/use-reference-lookup.d.ts +7 -0
  29. package/dist/hooks/use-reference-lookup.js +10 -0
  30. package/dist/hooks/use-report-step-complete.d.ts +2 -0
  31. package/dist/hooks/use-report-step-complete.js +13 -0
  32. package/dist/i18n.js +8 -4
  33. package/dist/index.d.ts +6 -2
  34. package/dist/index.js +4 -1
  35. package/dist/primitives.d.ts +30 -8
  36. package/dist/primitives.js +5 -0
  37. package/dist/session/session-ended.d.ts +16 -0
  38. package/dist/session/session-ended.js +73 -0
  39. package/dist/sort-by-accessor.d.ts +6 -2
  40. package/dist/sort-by-accessor.js +13 -3
  41. package/dist/sse/live-events.d.ts +1 -1
  42. package/package.json +5 -5
  43. package/src/changes.json +54 -0
@@ -1,6 +1,6 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { evalFieldCondition, isFieldsEditSection, normalizeEditField, PROJECTION_DETAIL_ENTITY, } from "@cosmicdrift/kumiko-framework/ui-types";
3
- import { computeEditViewModel } from "@cosmicdrift/kumiko-headless";
2
+ import { evalFieldCondition, isFieldsEditSection, normalizeEditField, PROJECTION_DETAIL_ENTITY, sectionFieldSpecs, } from "@cosmicdrift/kumiko-framework/ui-types";
3
+ import { computeEditViewModel, relevantFieldIssues, zodErrorToFieldIssues, } from "@cosmicdrift/kumiko-headless";
4
4
  import { RenderEditActionButton } from "./render-edit-action-button.js";
5
5
  import { AllFieldsRequiredProvider } from "./render-field.js";
6
6
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -14,7 +14,7 @@ import { useForm } from "../hooks/use-form.js";
14
14
  import { useTranslation } from "../i18n.js";
15
15
  import { useInsideDrawer } from "../inside-drawer.js";
16
16
  import { usePageHeaderCompact, usePageHeaderSlotAvailable } from "../page-header-slot.js";
17
- import { STICKY_PRIMARY_ACTION_PROP, shouldRenderActionsIconOnly, usePrimitives, } from "../primitives.js";
17
+ import { FOOTER_ACTION_ROLE_PROP, NARROW_LABEL_PROP, STICKY_PRIMARY_ACTION_PROP, shouldRenderActionsIconOnly, usePrimitives, } from "../primitives.js";
18
18
  import { GridCellForField } from "./grid-cell-for-field.js";
19
19
  import { RelatedListSection } from "./related-list-section.js";
20
20
  import { filterEditSections, hasEditableSection, hasIssueWithoutRenderedField, resolveExtensionEntityId, shouldNotifyCaller, } from "./render-edit-logic.js";
@@ -77,7 +77,7 @@ function deriveFormFields(screen) {
77
77
  // relatedList carries no `fields` for the form-condition map either.
78
78
  if (!isFieldsEditSection(section))
79
79
  continue;
80
- for (const spec of section.fields) {
80
+ for (const spec of sectionFieldSpecs(section)) {
81
81
  const normalized = normalizeEditField(spec);
82
82
  out[normalized.field] = {
83
83
  ...(normalized.visible !== undefined && {
@@ -94,16 +94,67 @@ function deriveFormFields(screen) {
94
94
  }
95
95
  return out;
96
96
  }
97
+ // Dry-run parse against `schema` — NOT controller.validate(), which writes
98
+ // field errors into the snapshot. Issues are filtered exactly like the
99
+ // controller's validate(): hidden fields never count, `scope` narrows.
100
+ function dryRunFieldIssues(schema, snapshot, scope, includeRoot) {
101
+ const parsed = schema.safeParse(snapshot.values);
102
+ if (parsed.success)
103
+ return [];
104
+ const hiddenFields = new Set(Object.entries(snapshot.fields)
105
+ .filter(([, state]) => !state.visible)
106
+ .map(([fieldKey]) => fieldKey));
107
+ return relevantFieldIssues(zodErrorToFieldIssues(parsed.error), {
108
+ hiddenFields,
109
+ ...(scope !== undefined && { scope }),
110
+ includeRoot,
111
+ });
112
+ }
113
+ // Index-independent: indices shift when a fields section hides.
114
+ function wizardStepKey(section, index) {
115
+ const declaredId = section.kind === "fields" || section.kind === "extension" ? section.id : undefined;
116
+ return declaredId ?? section.title ?? `step-${index}`;
117
+ }
118
+ function isMoneyValue(value) {
119
+ return typeof value === "object" && value !== null && "amount" in value;
120
+ }
121
+ // The seeds buildInitialValues writes for NULL columns of an existing record.
122
+ function isEmptyFormValue(value) {
123
+ if (value === null || value === undefined || value === "" || value === 0 || value === false) {
124
+ return true;
125
+ }
126
+ if (Array.isArray(value))
127
+ return value.length === 0;
128
+ return isMoneyValue(value) && value.amount === 0;
129
+ }
130
+ // select/boolean are excluded from the "holds user data" check: they are
131
+ // often pre-filled (defaults, server-set values), so they say nothing about
132
+ // whether the step was filled in. They still count through the validation.
133
+ function isFieldsStepFilled(section, schema, snapshot) {
134
+ const fieldNames = section.fields.map((f) => f.field);
135
+ if (schema !== undefined && dryRunFieldIssues(schema, snapshot, fieldNames, false).length > 0) {
136
+ return false;
137
+ }
138
+ return section.fields.some((f) => f.visible &&
139
+ !f.readOnly &&
140
+ f.type !== "boolean" &&
141
+ f.type !== "select" &&
142
+ !isEmptyFormValue(f.value));
143
+ }
97
144
  // Resolves an extension-section's `{ react: { __component: "X" } }` marker
98
145
  // to a registered React component via ExtensionSectionsProvider (filled in
99
146
  // createKumikoApp from clientFeatures.extensionSectionComponents) and
100
147
  // mounts it with the host entity name + id. Hook lives in its own
101
148
  // component so we don't call `use*` inside vm.sections.map (rules-of-
102
149
  // hooks would punish reordering sections between renders).
103
- function ExtensionSectionMount({ section, entityName: hostEntityName, entityId, initialValues, values, patch, validate, hideTitle, actions, }) {
150
+ function ExtensionSectionMount({ section, entityName: hostEntityName, entityId, initialValues, values, patch, validate, hideTitle, actions, stepKey, onReportStepComplete, }) {
104
151
  const { Banner, FillContainer, Section, Text } = usePrimitives();
105
152
  const name = extensionSectionName(section.component);
106
153
  const Component = useExtensionSectionComponent(name);
154
+ const reportStepComplete = useCallback((complete) => {
155
+ if (stepKey !== undefined)
156
+ onReportStepComplete?.(stepKey, complete);
157
+ }, [stepKey, onReportStepComplete]);
107
158
  const testId = `section-extension-${section.title}`;
108
159
  if (Component === undefined) {
109
160
  const placeholder = (_jsx(Banner, { variant: "info", testId: `section-extension-placeholder-${section.title}`, children: _jsxs(Text, { children: ["Extension section component ", _jsx(Text, { variant: "code", children: name ?? "(no __component name)" }), " ", "not registered in clientFeatures.extensionSectionComponents."] }) }));
@@ -115,7 +166,7 @@ function ExtensionSectionMount({ section, entityName: hostEntityName, entityId,
115
166
  // uses for its own hideTitle branch).
116
167
  return hideTitle === true ? (FillContainer !== undefined ? (_jsx(FillContainer, { testId: testId, children: placeholder })) : (placeholder)) : (_jsx(Section, { title: section.title, testId: testId, children: placeholder }, section.title));
117
168
  }
118
- const mounted = (_jsx(Component, { entityName: section.entityName ?? hostEntityName, entityId: entityId, initialValues: initialValues, values: values, patch: patch, validate: validate }));
169
+ const mounted = (_jsx(Component, { entityName: section.entityName ?? hostEntityName, entityId: entityId, initialValues: initialValues, values: values, patch: patch, validate: validate, ...(onReportStepComplete !== undefined && { reportStepComplete }) }));
119
170
  // Same reasoning as the placeholder branch above: tabs mode's outer Card
120
171
  // is the only frame this content gets, no nested Section divider.
121
172
  return hideTitle === true ? (FillContainer !== undefined ? (_jsx(FillContainer, { testId: testId, children: mounted })) : (mounted)) : (_jsx(Section, { ...(actions !== undefined && { actions }), title: section.title, testId: testId, children: mounted }));
@@ -162,7 +213,8 @@ function resolveTitleTemplate(template, values, translate, fallback) {
162
213
  return translate(template, values);
163
214
  }
164
215
  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, fieldAppendixPlacement, fieldDescription, fieldAccent, markChangedFields = false, entityId: entityIdProp, extensionInitialValues, onChange, onControlsReady, fields: fieldsFilter, disabled = false, hideActions, valueDisplay = "form", hideSectionTitles, fillScreenHeight, dirtyFooter, validateOnChange, headerRegion, buildSectionActions, } = props;
216
+ 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, leadContent, } = props;
217
+ const i18nScreenId = props.i18nScreenId ?? screen.id;
166
218
  const { customSubmit } = props;
167
219
  // Translate-Fallback: wenn der Caller keine Translate-Fn übergibt,
168
220
  // konsumieren wir den i18next-Context direkt. Sonst wären Field-
@@ -185,6 +237,9 @@ export function RenderEdit(props) {
185
237
  const [linkCopied, setLinkCopied] = useState(false);
186
238
  const [isSubmitting, setIsSubmitting] = useState(false);
187
239
  const [formError, setFormError] = useState(null);
240
+ // A rejected delete can carry several blocking reasons; the first one is the
241
+ // banner's headline (formError), the rest are listed beneath it.
242
+ const [extraDeleteIssues, setExtraDeleteIssues] = useState([]);
188
243
  // One state for all header actions (actions?), not per-button — only one
189
244
  // action can be in flight at a time, and this keeps the error surfaced in
190
245
  // the shared formError-adjacent banner region instead of inside the
@@ -192,6 +247,14 @@ export function RenderEdit(props) {
192
247
  // flex justify-end action bar).
193
248
  const [actionError, setActionError] = useState(null);
194
249
  const [rawStep, setRawStep] = useState(0);
250
+ const [passedStepKeys, setPassedStepKeys] = useState(() => new Set());
251
+ const [extensionStepComplete, setExtensionStepComplete] = useState({});
252
+ const reportExtensionStepComplete = useCallback((stepKey, complete) => {
253
+ setExtensionStepComplete((prev) => (prev[stepKey] === true) === complete ? prev : { ...prev, [stepKey]: complete });
254
+ }, []);
255
+ // Tabs have stable ids, so the active tab follows its id: a field change
256
+ // that hides an earlier tab must not shift the index onto another tab.
257
+ const [activeTabId, setActiveTabId] = useState(undefined);
195
258
  // Create-mode draftId (issue #1913) — resumed from `sessionStorage` (web)
196
259
  // on mount so a same-tab reload finds the right one of several parallel
197
260
  // create-sessions on this screen; `null` until the first step change
@@ -400,9 +463,12 @@ export function RenderEdit(props) {
400
463
  // on every keystroke, painting error messages while the user is still
401
464
  // typing. `valid` can therefore legitimately diverge from what's
402
465
  // currently rendered under the fields (the last *mutating* validate()
403
- // call, e.g. from controls.validate() or submit()).
466
+ // call, e.g. from controls.validate() or submit()). The issues are
467
+ // filtered exactly like submit()'s validate: hidden fields and fields
468
+ // outside the `fields` scope never make the form invalid.
404
469
  const currentSchema = schemaRef.current;
405
- const valid = currentSchema === undefined ? true : currentSchema.safeParse(snapshot.values).success;
470
+ const valid = currentSchema === undefined ||
471
+ dryRunFieldIssues(currentSchema, snapshot, scopeFieldNamesRef.current, true).length === 0;
406
472
  cb({
407
473
  values: snapshot.values,
408
474
  changes: snapshot.changes,
@@ -593,12 +659,45 @@ export function RenderEdit(props) {
593
659
  // Clamped on read, not on write: section visibility is value-dependent, so a
594
660
  // stored stepIndex can point past what is currently rendered — that lands on
595
661
  // the last step instead of an empty one.
596
- const currentStep = Math.min(rawStep, lastStepIndex);
662
+ const activeTabIndex = isTabs && activeTabId !== undefined
663
+ ? filteredSections.findIndex((s, i) => tabIdAt(s, i) === activeTabId)
664
+ : -1;
665
+ const currentStep = activeTabIndex !== -1 ? activeTabIndex : Math.min(rawStep, lastStepIndex);
597
666
  // Kept fresh for patchAndScheduleDraftSave's debounce timer above, which
598
667
  // is defined before `currentStep` exists (it depends on `filteredSections`,
599
668
  // computed further up from `vm`) and so cannot close over it directly.
600
669
  currentStepRef.current = currentStep;
601
670
  const isLastWizardStep = currentStep >= lastStepIndex;
671
+ const wizardDoneSteps = useMemo(() => {
672
+ if (!isWizard || isCreateMode)
673
+ return undefined;
674
+ const snapshotForDryRun = { values: snapshot.values, fields: snapshot.fields };
675
+ return filteredSections.map((section, index) => {
676
+ if (passedStepKeys.has(wizardStepKey(section, index)))
677
+ return true;
678
+ if (section.kind === "fields")
679
+ return isFieldsStepFilled(section, schema, snapshotForDryRun);
680
+ return (section.kind === "extension" &&
681
+ extensionStepComplete[wizardStepKey(section, index)] === true);
682
+ });
683
+ }, [
684
+ isWizard,
685
+ isCreateMode,
686
+ filteredSections,
687
+ passedStepKeys,
688
+ extensionStepComplete,
689
+ schema,
690
+ snapshot.values,
691
+ snapshot.fields,
692
+ ]);
693
+ const updateModeStepBarProps = wizardDoneSteps === undefined
694
+ ? {}
695
+ : { doneSteps: wizardDoneSteps, selectableSteps: "all" };
696
+ function jumpToStep(index) {
697
+ setRawStep(index);
698
+ if (isTabs)
699
+ setActiveTabId(tabIdAt(filteredSections[index], index));
700
+ }
602
701
  // Step transitions only — never per keystroke. Deliberately not awaited: a
603
702
  // failed draft save must not block the step change.
604
703
  //
@@ -638,11 +737,13 @@ export function RenderEdit(props) {
638
737
  console.warn(`RenderEdit: draftKey "${key}" is ${key.length} chars, over the server's ${FORM_DRAFT_KEY_MAX_LENGTH}-char limit — skipping this draft save. Shorten screen.id or entity id.`);
639
738
  return;
640
739
  }
641
- const passwordFieldNames = new Set(vm.sections.flatMap((section) => section.kind === "fields"
642
- ? section.fields.filter((f) => f.format === "password").map((f) => f.field)
740
+ const secretFieldNames = new Set(vm.sections.flatMap((section) => section.kind === "fields"
741
+ ? section.fields
742
+ .filter((f) => f.format === "password" || f.writeOnly === true)
743
+ .map((f) => f.field)
643
744
  : []));
644
- // A password field never enters the persisted draft blob — it would be stored in the clear and restored on resume.
645
- const draftValues = Object.fromEntries(Object.entries(controller.getSnapshot().values).filter(([field]) => !passwordFieldNames.has(field)));
745
+ // A password or writeOnly field never enters the persisted draft blob — it would be stored in the clear and restored on resume.
746
+ const draftValues = Object.fromEntries(Object.entries(controller.getSnapshot().values).filter(([field]) => !secretFieldNames.has(field)));
646
747
  void dispatcher
647
748
  .write(FORM_DRAFT_SAVE, {
648
749
  draftKey: key,
@@ -670,29 +771,45 @@ export function RenderEdit(props) {
670
771
  setRawStep(next);
671
772
  return;
672
773
  }
673
- const section = filteredSections[currentStep];
674
- const fieldNames = section?.kind === "fields" ? section.fields.map((f) => f.field) : [];
675
774
  // skip: the current step has field errors — no transition, no draft save.
676
- if (fieldNames.length > 0 && !controller.validate(fieldNames))
775
+ if (currentStepHasFieldErrors())
677
776
  return;
777
+ if (!isCreateMode) {
778
+ const leftSection = filteredSections[currentStep];
779
+ if (leftSection !== undefined) {
780
+ const leftKey = wizardStepKey(leftSection, currentStep);
781
+ setPassedStepKeys((prev) => (prev.has(leftKey) ? prev : new Set(prev).add(leftKey)));
782
+ }
783
+ }
678
784
  setRawStep(next);
679
785
  saveDraft(next);
680
786
  }
787
+ function currentStepHasFieldErrors() {
788
+ const section = filteredSections[currentStep];
789
+ const fieldNames = section?.kind === "fields" ? section.fields.map((f) => f.field) : [];
790
+ return fieldNames.length > 0 && !controller.validate(fieldNames);
791
+ }
681
792
  function handleWizardBack() {
682
- handleWizardJumpBack(currentStep - 1);
793
+ handleWizardStepSelect(currentStep - 1);
683
794
  }
684
- // Only backwards: a forward jump would skip the per-step validate() gate.
685
- function handleWizardJumpBack(targetStep) {
686
- if (targetStep >= currentStep)
795
+ // Update mode: the record already exists and the final Save validates the
796
+ // whole form and jumps to the first erroring step (findFirstErroringStep), so
797
+ // skipping steps forward is safe; leaving the current step still goes through
798
+ // its validate gate.
799
+ function handleWizardStepSelect(targetStep) {
800
+ if (targetStep === currentStep)
687
801
  return;
688
- const previous = Math.max(targetStep, 0);
689
- hasNavigatedRef.current = true;
690
- if (disabled) {
691
- setRawStep(previous);
802
+ const isForward = targetStep > currentStep;
803
+ // Create mode: a forward jump would skip the per-step validate() gate.
804
+ if (isForward && isCreateMode)
692
805
  return;
693
- }
694
- setRawStep(previous);
695
- saveDraft(previous);
806
+ if (isForward && !disabled && currentStepHasFieldErrors())
807
+ return;
808
+ const clampedTarget = Math.min(Math.max(targetStep, 0), lastStepIndex);
809
+ hasNavigatedRef.current = true;
810
+ setRawStep(clampedTarget);
811
+ if (!disabled)
812
+ saveDraft(clampedTarget);
696
813
  }
697
814
  async function discardDraft() {
698
815
  // skip: this screen does not persist a draft.
@@ -823,6 +940,7 @@ export function RenderEdit(props) {
823
940
  let extensionsPersisted = true;
824
941
  if (result.isSuccess) {
825
942
  setFormError(null);
943
+ setExtraDeleteIssues([]);
826
944
  // `isNoOp: true` (payloadMode "changes", pre-filled form submitted
827
945
  // untouched) means controller.submit() never called dispatcher.write
828
946
  // — nothing to discard, and discarding here would delete a draft the
@@ -843,7 +961,7 @@ export function RenderEdit(props) {
843
961
  if (isStepped) {
844
962
  const step = findFirstErroringStep(Object.keys(controller.getSnapshot().errors));
845
963
  if (step !== undefined)
846
- setRawStep(step);
964
+ jumpToStep(step);
847
965
  }
848
966
  }
849
967
  else {
@@ -859,8 +977,9 @@ export function RenderEdit(props) {
859
977
  if (isStepped) {
860
978
  const step = findFirstErroringStep(issuePaths);
861
979
  if (step !== undefined)
862
- setRawStep(step);
980
+ jumpToStep(step);
863
981
  }
982
+ setExtraDeleteIssues([]);
864
983
  setFormError(hasIssueWithoutRenderedField(issuePaths, filteredSections) ? result.error : null);
865
984
  }
866
985
  // skip: on entity-success-but-extension-failure the extension-error
@@ -955,14 +1074,20 @@ export function RenderEdit(props) {
955
1074
  const isFieldlessSubmitForm = isFieldless && screen.entity !== PROJECTION_DETAIL_ENTITY;
956
1075
  const showsSubmit = isFormEditable || hasExtensionRegistrations || isFieldlessSubmitForm;
957
1076
  const footerSlot = screen.slots?.footer;
1077
+ // An unregistered footer component renders null, so only a resolvable one may
1078
+ // count as an action (else read-only screens get an empty footer strip).
1079
+ const FooterSlotComponent = useExtensionSectionComponent(footerSlot !== undefined ? extensionSectionName(footerSlot) : undefined);
958
1080
  // Mirrors every branch inside formActions below — without this guard
959
1081
  // DefaultForm renders an empty footer strip (border + padding, no content)
960
1082
  // on read-only detail screens, since `actions` would otherwise always be
961
1083
  // a defined (if empty) fragment.
962
- const hasFormActions = (isWizard && currentStep > 0) ||
963
- (isWizard && !isLastWizardStep) ||
964
- (showsSubmit && (!isWizard || isLastWizardStep)) ||
965
- footerSlot !== undefined ||
1084
+ const showWizardBack = isWizard && currentStep > 0;
1085
+ const showWizardNext = isWizard && !isLastWizardStep;
1086
+ const showSubmit = showsSubmit && (!isWizard || isLastWizardStep);
1087
+ const hasFormActions = showWizardBack ||
1088
+ showWizardNext ||
1089
+ showSubmit ||
1090
+ FooterSlotComponent !== undefined ||
966
1091
  writeFormFooterAction !== undefined;
967
1092
  const nextStepTitle = isWizard ? filteredSections[currentStep + 1]?.title : undefined;
968
1093
  const isCreate = (() => {
@@ -982,14 +1107,14 @@ export function RenderEdit(props) {
982
1107
  ? 1
983
1108
  : 0;
984
1109
  const isSettingsList = screen.layout.variant === "settings-list" && hideSectionTitles !== true;
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
1110
+ const formActions = (_jsxs(_Fragment, { children: [showWizardBack && (_jsx(Button, { type: "button", variant: "secondary", icon: "chevron-left", onClick: handleWizardBack, testId: "render-edit-wizard-back", [FOOTER_ACTION_ROLE_PROP]: "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: hasUnsavedInput, 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") })), showWizardNext && (_jsx(Button, { type: "submit", variant: "primary", iconEnd: "chevron-right", testId: "render-edit-wizard-next", [FOOTER_ACTION_ROLE_PROP]: "primary",
1111
+ [NARROW_LABEL_PROP]: translate("kumiko.actions.next"), children: nextStepTitle !== undefined
986
1112
  ? translate("kumiko.wizard.next-with-title", { title: nextStepTitle })
987
- : translate("kumiko.actions.next") })), showsSubmit &&
988
- (!isWizard || isLastWizardStep) &&
1113
+ : translate("kumiko.actions.next") })), showSubmit &&
989
1114
  footerActions?.map((action) => (_jsx(Button, { type: "button", variant: action.variant ?? "secondary", ...(action.icon !== undefined && { icon: action.icon }), disabled: isSubmitting || disabled, onClick: () => {
990
1115
  controller.setValues(action.patch); // @cast-boundary form-values
991
1116
  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) ||
1117
+ }, 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") })), showSubmit && (_jsx(Button, { type: "submit", disabled: (snapshot.isUnchanged && !extensionDirty && !isFieldless && !insideDrawer) ||
993
1118
  isSubmitting ||
994
1119
  disabled, loading: isSubmitting, variant: submitVariant ?? "primary", testId: "render-edit-submit", children: translate(submitLabel ??
995
1120
  (isWizard
@@ -1013,8 +1138,8 @@ export function RenderEdit(props) {
1013
1138
  : undefined;
1014
1139
  const resolveScreenText = (suffix) => {
1015
1140
  for (const key of [
1016
- `screen:${screen.id}.${formMode}.${suffix}`,
1017
- `screen:${screen.id}.${suffix}`,
1141
+ `screen:${i18nScreenId}.${formMode}.${suffix}`,
1142
+ `screen:${i18nScreenId}.${suffix}`,
1018
1143
  ]) {
1019
1144
  const value = translate(key);
1020
1145
  if (value !== key)
@@ -1022,14 +1147,18 @@ export function RenderEdit(props) {
1022
1147
  }
1023
1148
  return undefined;
1024
1149
  };
1025
- const plainFormTitle = resolveScreenText("title") ?? screen.id;
1150
+ const plainFormTitle = resolveScreenText("title") ?? i18nScreenId;
1026
1151
  const formTitle = resolveTitleTemplate(screen.titleTemplate, snapshot.values, translate, plainFormTitle);
1027
- // screen.description is head-card copy, not tab content — in tabs mode
1028
- // (hideSectionTitles) the head card already carries title/subtitle/status.
1152
+ // In tabs mode (hideSectionTitles) screen.description is dropped — the head
1153
+ // card carries it — but an explicit subtitle i18n override has no other
1154
+ // source there, so it stays unless it merely resolves to the description.
1155
+ const explicitSubtitle = resolveScreenText("subtitle");
1156
+ const descriptionSubtitle = screen.description !== undefined ? translate(screen.description) : undefined;
1029
1157
  const formSubtitle = hideSectionTitles !== true
1030
- ? (resolveScreenText("subtitle") ??
1031
- (screen.description !== undefined ? translate(screen.description) : undefined))
1032
- : undefined;
1158
+ ? (explicitSubtitle ?? descriptionSubtitle)
1159
+ : explicitSubtitle !== descriptionSubtitle
1160
+ ? explicitSubtitle
1161
+ : undefined;
1033
1162
  // Embedded settings-list forms (dashboard panels) have no shell header: the
1034
1163
  // form title/subtitle move into the first section's header column instead of
1035
1164
  // floating as a heading above the band.
@@ -1069,7 +1198,7 @@ export function RenderEdit(props) {
1069
1198
  !stepRail &&
1070
1199
  !titleInSettingsHeader && { subtitle: formSubtitle }), ...(isScreenForm && { screenForm: true }), ...(unsavedCount !== undefined &&
1071
1200
  (unsavedCount > 0 || hasDirtyFooter) && { unsavedCount }), ...(sectionNavItems.length > 0 && { sectionNav: sectionNavItems }), ...(stepRail && {
1072
- sideRail: (_jsx(StepBar, { steps: filteredSections.map((section) => section.title ?? ""), currentIndex: currentStep, compactLabel: compactStepLabel, onStepSelect: handleWizardJumpBack, orientation: "vertical", heading: translate("kumiko.wizard.step", {
1201
+ sideRail: (_jsx(StepBar, { steps: filteredSections.map((section) => section.title ?? ""), currentIndex: currentStep, compactLabel: compactStepLabel, onStepSelect: handleWizardStepSelect, ...updateModeStepBarProps, orientation: "vertical", heading: translate("kumiko.wizard.step", {
1073
1202
  current: currentStep + 1,
1074
1203
  total: lastStepIndex + 1,
1075
1204
  }), ...(formSubtitle !== undefined && { description: formSubtitle }), subtitles: filteredSections.map((section) => section.kind === "fields" ? section.subtitle : undefined), ...(upNextSection !== undefined && {
@@ -1081,12 +1210,12 @@ export function RenderEdit(props) {
1081
1210
  },
1082
1211
  }), testId: "render-edit-wizard-steps", compactTestId: "render-edit-wizard-step-label" })),
1083
1212
  }), ...(hideActions !== true && hasFormActions && { actions: formActions }), ...(hideActions !== true &&
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: [
1213
+ 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: [leadContent, showsAllRequiredHint && !insideDrawer && allRequiredHint, draftCandidates !== null && (_jsx(Banner, { variant: "info", testId: "render-edit-draft-picker", actions: [
1085
1214
  ...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))),
1086
1215
  _jsx(Button, { type: "button", variant: "link", onClick: () => setDraftCandidates(null), testId: "render-edit-draft-start-new", children: translate("kumiko.form.draft.start-new") }, "start-new"),
1087
1216
  ], children: _jsx(Text, { children: translate(draftCandidates.length === 1
1088
1217
  ? "kumiko.form.draft.resume-single"
1089
- : "kumiko.form.draft.resume-multiple") }) })), isWizard && !stepRail && (_jsxs(_Fragment, { children: [Progress !== undefined && (_jsx(Progress, { value: (currentStep + 1) / (lastStepIndex + 1), testId: "render-edit-wizard-progress" })), StepBar === undefined ? (_jsx(Text, { variant: "small", testId: "render-edit-wizard-step-label", children: compactStepLabel })) : (_jsx(StepBar, { steps: filteredSections.map((section) => section.title ?? ""), currentIndex: currentStep, compactLabel: compactStepLabel, onStepSelect: handleWizardJumpBack, testId: "render-edit-wizard-steps", compactTestId: "render-edit-wizard-step-label" }))] })), isTabs &&
1218
+ : "kumiko.form.draft.resume-multiple") }) })), isWizard && !stepRail && (_jsxs(_Fragment, { children: [Progress !== undefined && (_jsx(Progress, { value: (currentStep + 1) / (lastStepIndex + 1), testId: "render-edit-wizard-progress" })), StepBar === undefined ? (_jsx(Text, { variant: "small", testId: "render-edit-wizard-step-label", children: compactStepLabel })) : (_jsx(StepBar, { steps: filteredSections.map((section) => section.title ?? ""), currentIndex: currentStep, compactLabel: compactStepLabel, onStepSelect: handleWizardStepSelect, ...updateModeStepBarProps, testId: "render-edit-wizard-steps", compactTestId: "render-edit-wizard-step-label" }))] })), isTabs &&
1090
1219
  (() => {
1091
1220
  // No Tabs primitive registered → every section renders stacked
1092
1221
  // (stepHidden below is gated on the same condition), which is the
@@ -1102,7 +1231,7 @@ export function RenderEdit(props) {
1102
1231
  // skip: the strip reported an id no section owns.
1103
1232
  if (index === -1)
1104
1233
  return;
1105
- setRawStep(index);
1234
+ jumpToStep(index);
1106
1235
  } }));
1107
1236
  })(), filteredSections.map((section, sectionIndex) => {
1108
1237
  // turns this section's own `actions` (RowAction[]) into
@@ -1142,7 +1271,11 @@ export function RenderEdit(props) {
1142
1271
  const mount = (_jsx(ExtensionSectionMount, { section: section, entityName: vm.entityName, entityId: resolveExtensionEntityId(entityIdProp, vm.id), initialValues: extensionInitialValues, values: snapshot.values,
1143
1272
  // @cast-boundary form-values: ExtensionSectionProps is not generic
1144
1273
  // over TValues; controller is mount-lifetime-stable, see onControlsReady above.
1145
- patch: patchAndScheduleDraftSave, validate: scopedValidate, hideTitle: hideSectionTitles, ...(hideSectionTitles !== true &&
1274
+ patch: patchAndScheduleDraftSave, validate: scopedValidate, hideTitle: hideSectionTitles, ...(isWizard &&
1275
+ !isCreateMode && {
1276
+ stepKey: wizardStepKey(section, sectionIndex),
1277
+ onReportStepComplete: reportExtensionStepComplete,
1278
+ }), ...(hideSectionTitles !== true &&
1146
1279
  sectionActionsEl !== undefined && { actions: sectionActionsEl }) }, section.title));
1147
1280
  // Tabs mode: an unframed padded panel (the tab panel is the
1148
1281
  // surface), so extension content neither sits flush at the shell
@@ -1239,15 +1372,17 @@ export function RenderEdit(props) {
1239
1372
  id: formSectionDomId(sectionIndex),
1240
1373
  }), ...(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));
1241
1374
  return wrapWizardStep(sectionKey, sectionEl);
1242
- }), formError !== null && (_jsx(Banner, { variant: "error", testId: "render-edit-form-error", actions: formError.code === "version_conflict" && onReload !== undefined ? (_jsx(Button, { variant: "secondary", onClick: () => {
1375
+ }), formError !== null && (_jsxs(Banner, { variant: "error", testId: "render-edit-form-error", actions: formError.code === "version_conflict" && onReload !== undefined ? (_jsx(Button, { variant: "secondary", onClick: () => {
1243
1376
  onReload();
1244
1377
  setFormError(null);
1245
- }, testId: "render-edit-form-error-reload", children: translate("kumiko.actions.reload") })) : undefined, children: _jsx(Text, { testId: "render-edit-form-error-key", children: translate(formError.i18nKey, formError.i18nParams) }) })), extensionErrorKey !== null && (_jsx(Banner, { variant: "error", testId: "render-edit-extension-error", children: _jsx(Text, { testId: "render-edit-extension-error-key", children: translate(extensionErrorKey) }) })), actionError !== null && (_jsx(Banner, { variant: "error", testId: "render-edit-action-error", children: actionError })), onDelete !== undefined && (_jsx(Dialog, { open: confirmDeleteOpen, onOpenChange: setConfirmDeleteOpen, title: translate("kumiko.actions.delete-confirm"), confirmLabel: translate("kumiko.actions.delete"), variant: "danger", onConfirm: async () => {
1378
+ setExtraDeleteIssues([]);
1379
+ }, testId: "render-edit-form-error-reload", children: translate("kumiko.actions.reload") })) : undefined, children: [_jsx(Text, { testId: "render-edit-form-error-key", children: translate(formError.i18nKey, formError.i18nParams) }), extraDeleteIssues.map((issue) => (_jsx(Text, { testId: "render-edit-form-error-extra", children: translate(issue.i18nKey, issue.params) }, `${issue.path}:${issue.i18nKey}`)))] })), (snapshot.errors["(root)"] ?? []).map((issue) => (_jsx(Banner, { variant: "error", testId: "render-edit-root-issue", children: _jsx(Text, { children: translate(issue.i18nKey, issue.params) }) }, issue.i18nKey))), extensionErrorKey !== null && (_jsx(Banner, { variant: "error", testId: "render-edit-extension-error", children: _jsx(Text, { testId: "render-edit-extension-error-key", children: translate(extensionErrorKey) }) })), actionError !== null && (_jsx(Banner, { variant: "error", testId: "render-edit-action-error", children: actionError })), onDelete !== undefined && (_jsx(Dialog, { open: confirmDeleteOpen, onOpenChange: setConfirmDeleteOpen, title: translate("kumiko.actions.delete-confirm"), confirmLabel: translate("kumiko.actions.delete"), variant: "danger", onConfirm: async () => {
1246
1380
  const rejection = await onDelete();
1247
1381
  // A rejected delete (e.g. a preDelete hook) carries its reason in
1248
1382
  // the field issues; the top-level key is only "validation failed".
1249
1383
  if (rejection !== undefined) {
1250
- const issue = rejection.details?.fields?.[0];
1384
+ const [issue, ...otherIssues] = rejection.details?.fields ?? [];
1385
+ setExtraDeleteIssues(otherIssues);
1251
1386
  setFormError(issue === undefined
1252
1387
  ? rejection
1253
1388
  : { ...rejection, i18nKey: issue.i18nKey, i18nParams: issue.params });
@@ -10,7 +10,7 @@ import { screenAccessAllows } from "../app/screen-access.js";
10
10
  import { useUserRoles } from "../context/user-roles-context.js";
11
11
  import { REFERENCE_COMBOBOX_LIMIT } from "../hooks/reference-limits.js";
12
12
  import { useQuery } from "../hooks/use-query.js";
13
- import { referenceLookupSource } from "../hooks/use-reference-lookup.js";
13
+ import { referenceOptionSource } from "../hooks/use-reference-lookup.js";
14
14
  import { useLocale, useTranslation } from "../i18n.js";
15
15
  import { usePrimitives } from "../primitives.js";
16
16
  import { EmbeddedListField } from "./embedded-list-field.js";
@@ -83,16 +83,6 @@ function WriteOnlyTextInput({ field, id, hasError, onChange, changed, }) {
83
83
  },
84
84
  }) }));
85
85
  }
86
- // Where a reference field's option rows come from, and which of their keys
87
- // holds the display text — the entity's own list handler plus `refLabelField`
88
- // by default, or an author-supplied handler whose rows carry `label`
89
- // (fw#2780), for targets with no readable column of their own.
90
- function referenceOptionSource(field, refFeature, refEntity) {
91
- if (field.refOptionsQuery !== undefined) {
92
- return { queryQn: field.refOptionsQuery, labelKey: "label" };
93
- }
94
- return referenceLookupSource(refFeature, refEntity, field.refLabelField ?? "id");
95
- }
96
86
  // Tier 2.7e-3 + 2.1c: Reference-Input rendert eine Searchable Combobox
97
87
  // gefüllt aus einer Live-Query auf die referenced Entity. Default-
98
88
  // Limit: 200 — bei größeren Datasets fehlt der Tail im Dropdown
@@ -464,15 +454,22 @@ function resolveMoneyCurrency(value, fieldCurrency) {
464
454
  }
465
455
  return fieldCurrency ?? "EUR";
466
456
  }
457
+ // The suffix span is absolutely positioned inside a fixed right padding sized
458
+ // for short symbols; a free-text sibling value longer than any real unit
459
+ // symbol would overflow the number.
460
+ const MAX_SIBLING_UNIT_LENGTH = 8;
467
461
  // Static unit → used as-is. Sibling-field reference → read the live row
468
- // value; missing/empty/non-string sibling means no suffix, never a guess.
462
+ // value; missing/empty/non-string/overlong sibling means no suffix, never a guess.
469
463
  function resolveNumberUnit(unit, row) {
470
464
  if (unit === undefined)
471
465
  return undefined;
472
466
  if (typeof unit === "string")
473
467
  return unit;
474
468
  const sibling = row?.[unit.field];
475
- return typeof sibling === "string" && sibling.length > 0 ? sibling : undefined;
469
+ if (typeof sibling !== "string")
470
+ return undefined;
471
+ const trimmed = sibling.trim();
472
+ return trimmed.length > 0 && trimmed.length <= MAX_SIBLING_UNIT_LENGTH ? trimmed : undefined;
476
473
  }
477
474
  function moneyMinorValue(v, currency) {
478
475
  if (v === undefined || v === null || v === "")
@@ -6,11 +6,11 @@ import { useReferenceLookup } from "../hooks/use-reference-lookup.js";
6
6
  import { useTranslation } from "../i18n.js";
7
7
  import { usePageHeaderCompact, usePageHeaderSlotAvailable } from "../page-header-slot.js";
8
8
  import { shouldRenderActionsIconOnly, usePrimitives, } from "../primitives.js";
9
+ import { needsActionConfirm } from "./render-edit-action-button.js";
9
10
  const SEARCH_DEBOUNCE_MS = 300;
10
- // Fire-and-forget actions (schema navigate/drawer) can leave the toolbar for the
11
- // page header without losing the confirm dialog or busy/error surfacing.
11
+ // Icon-less actions are promoted too (with a "plus" fallback at the call site).
12
12
  function isHeaderPromotableToolbarAction(action) {
13
- return (action.style === "primary" && action.confirm === undefined && action.confirmRequired === false);
13
+ return action.style === "primary" && !needsActionConfirm(action);
14
14
  }
15
15
  export function RenderList(props) {
16
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;
@@ -147,7 +147,7 @@ export function RenderList(props) {
147
147
  pageHeaderCompact
148
148
  ? toolbarActions?.find(isHeaderPromotableToolbarAction)
149
149
  : undefined;
150
- const promotedActionButton = promotedToolbarAction !== undefined ? (_jsx(Button, { variant: "primary", icon: promotedToolbarAction.icon ?? "plus", size: "icon", ariaLabel: promotedToolbarAction.label, title: promotedToolbarAction.label, onClick: () => void promotedToolbarAction.onTrigger(), testId: `render-list-toolbar-action-${promotedToolbarAction.id}` })) : undefined;
150
+ const promotedActionButton = promotedToolbarAction !== undefined ? (_jsx(ToolbarActionView, { action: { ...promotedToolbarAction, icon: promotedToolbarAction.icon ?? "plus" }, iconOnly: true, Button: Button, Dialog: Dialog, Banner: Banner })) : undefined;
151
151
  const toolbarActionsInToolbar = toolbarActions?.filter((a) => a !== promotedToolbarAction) ?? [];
152
152
  const hasToolbarActionsInToolbar = toolbarActionsInToolbar.length > 0;
153
153
  const toolbarEnd = hasHeaderSlot ||
@@ -239,9 +239,9 @@ function ToolbarActionView({ action, iconOnly = false, Button, Dialog, Banner, }
239
239
  }
240
240
  };
241
241
  const variant = action.style ?? "secondary";
242
- const needsConfirm = action.confirm !== undefined || (action.confirmRequired ?? action.style === "danger");
242
+ const needsConfirm = needsActionConfirm(action);
243
243
  const showIconOnly = iconOnly && action.icon !== undefined;
244
- return (_jsxs(_Fragment, { children: [_jsx(Button, { variant: variant, loading: busy, ...(action.icon !== undefined && { icon: action.icon }), size: showIconOnly ? "icon" : "sm", ...(showIconOnly && { ariaLabel: action.label }), onClick: () => {
244
+ return (_jsxs(_Fragment, { children: [_jsx(Button, { variant: variant, loading: busy, ...(action.icon !== undefined && { icon: action.icon }), size: showIconOnly ? "icon" : "sm", ...(showIconOnly && { ariaLabel: action.label, title: action.label }), onClick: () => {
245
245
  if (needsConfirm) {
246
246
  setConfirmOpen(true);
247
247
  }
@@ -2,24 +2,13 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
2
2
  import { I18N_KEY_PARAM } from "@cosmicdrift/kumiko-headless";
3
3
  import { useEffect, useMemo, useRef, useState } from "react";
4
4
  import * as z from "zod";
5
- import { REQUIRED_FIELD_I18N_KEY } from "../app/form-schema.js";
5
+ import { isPresent, 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
9
  import { STICKY_PRIMARY_ACTION_PROP, usePrimitives } from "../primitives.js";
10
10
  import { GridCellForField } from "./grid-cell-for-field.js";
11
- // Same "has a value" rule as buildFormSchema's isPresent (app/form-schema.ts)
12
- // — duplicated because that helper walks raw EditFieldSpec + EntityDefinition,
13
- // unavailable here: this section only ever sees computeEditViewModel's
14
- // already-resolved EditFieldViewModel[] (required/readOnly/visible are plain
15
- // booleans by the time render-edit.tsx hands the section to this component).
16
- function isPresent(value) {
17
- if (value === undefined || value === null || value === "")
18
- return false;
19
- if (Array.isArray(value) && value.length === 0)
20
- return false;
21
- return true;
22
- }
11
+ import { hasIssueWithoutVisibleField } from "./render-edit-logic.js";
23
12
  function buildWriteFormSchema(fields) {
24
13
  return z
25
14
  .object({})
@@ -71,11 +60,14 @@ export function WriteFormSection({ section, featureName, translate, hideTitle, o
71
60
  }
72
61
  if (result.validationBlocked)
73
62
  return;
74
- // Field-level issues already surface inline via snapshot.errors (same
75
- // GridCellForField/RenderField path RenderEdit uses) — the banner is
76
- // only for form-level errors nothing else would show (fw#1901 pattern).
77
- const fieldIssues = result.error.details?.fields ?? [];
78
- setError(fieldIssues.length === 0 ? dispatcherErrorText(result.error, effectiveTranslate) : null);
63
+ // Field-level issues surface inline via snapshot.errors (same
64
+ // GridCellForField/RenderField path RenderEdit uses) — the banner stays
65
+ // for anything no rendered field can show (`version`, `id`, a root
66
+ // refine, a hidden field).
67
+ const issuePaths = (result.error.details?.fields ?? []).map((i) => i.path);
68
+ setError(hasIssueWithoutVisibleField(issuePaths, section.fields)
69
+ ? dispatcherErrorText(result.error, effectiveTranslate)
70
+ : null);
79
71
  }
80
72
  finally {
81
73
  setIsSubmitting(false);
@@ -106,10 +98,10 @@ export function WriteFormSection({ section, featureName, translate, hideTitle, o
106
98
  return (_jsx(Card, { options: { framed: false }, slots: {
107
99
  ...(section.description !== undefined && { subtitle: section.description }),
108
100
  headerActions: titleRowActions,
109
- }, testId: `write-form-${section.title ?? "section"}`, children: content }));
101
+ }, testId: `write-form-${section.title ?? section.handler}`, children: content }));
110
102
  }
111
103
  // Routed through Section's `actions` slot (same mechanism render-edit.tsx
112
104
  // uses via Form's `actions`) so the button gets the established right-
113
105
  // aligned footer treatment instead of stretching full-width inline.
114
- return (_jsx(Section, { ...(section.title !== undefined && { title: section.title }), ...(section.description !== undefined && { subtitle: section.description }), ...(section.icon !== undefined && { icon: section.icon }), actions: titleRowActions, testId: `write-form-${section.title ?? "section"}`, children: content }));
106
+ return (_jsx(Section, { ...(section.title !== undefined && { title: section.title }), ...(section.description !== undefined && { subtitle: section.description }), ...(section.icon !== undefined && { icon: section.icon }), actions: titleRowActions, testId: `write-form-${section.title ?? section.handler}`, children: content }));
115
107
  }