@cosmicdrift/kumiko-renderer 0.386.0 → 0.388.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 (222) hide show
  1. package/LICENSE +21 -57
  2. package/dist/app/action-form-body.d.ts +2 -2
  3. package/dist/app/action-form-body.js +12 -4
  4. package/dist/app/action-form-shim.d.ts +8 -8
  5. package/dist/app/action-form-shim.js +20 -22
  6. package/dist/app/action-form-support.d.ts +1 -0
  7. package/dist/app/action-form-support.js +1 -1
  8. package/dist/app/app-features-context.js +4 -4
  9. package/dist/app/column-renderers.d.ts +7 -9
  10. package/dist/app/column-renderers.js +15 -18
  11. package/dist/app/config-edit-body.js +7 -3
  12. package/dist/app/config-edit-shim.d.ts +7 -8
  13. package/dist/app/config-edit-shim.js +18 -20
  14. package/dist/app/custom-screens.d.ts +3 -3
  15. package/dist/app/custom-screens.js +8 -8
  16. package/dist/app/dashboard-body.d.ts +2 -2
  17. package/dist/app/dashboard-body.js +2 -2
  18. package/dist/app/entity-edit-prefill.d.ts +11 -0
  19. package/dist/app/entity-edit-prefill.js +18 -13
  20. package/dist/app/entity-edit-screen.js +4 -4
  21. package/dist/app/entity-list-screen.js +3 -3
  22. package/dist/app/extension-form-submit.d.ts +1 -1
  23. package/dist/app/extension-form-submit.js +14 -15
  24. package/dist/app/extension-sections.d.ts +29 -28
  25. package/dist/app/extension-sections.js +10 -10
  26. package/dist/app/feature-schema.js +11 -11
  27. package/dist/app/form-schema.js +2 -2
  28. package/dist/app/header-actions-bar.js +2 -2
  29. package/dist/app/kumiko-screen.js +8 -10
  30. package/dist/app/layout-fields.js +1 -1
  31. package/dist/app/list-facets.js +3 -3
  32. package/dist/app/nav.d.ts +10 -10
  33. package/dist/app/nav.js +13 -13
  34. package/dist/app/projection-detail-body.js +4 -4
  35. package/dist/app/projection-detail-shim.d.ts +6 -5
  36. package/dist/app/projection-detail-shim.js +23 -22
  37. package/dist/app/projection-list-body.js +10 -10
  38. package/dist/app/projection-list-shim.d.ts +5 -5
  39. package/dist/app/projection-list-shim.js +17 -18
  40. package/dist/app/qn.js +10 -12
  41. package/dist/app/qualify-screen-id.js +1 -1
  42. package/dist/app/screen-access.js +2 -2
  43. package/dist/app/screen-shared.js +1 -1
  44. package/dist/app/secret-mint-body.js +4 -4
  45. package/dist/app/write-failed-error.js +8 -7
  46. package/dist/components/edit-mounts.d.ts +2 -2
  47. package/dist/components/embedded-list-field.js +5 -5
  48. package/dist/components/grid-cell-for-field.d.ts +1 -1
  49. package/dist/components/reference-create-dialog.js +3 -3
  50. package/dist/components/related-list-section.d.ts +1 -1
  51. package/dist/components/related-list-section.js +15 -2
  52. package/dist/components/render-edit-action-button.d.ts +1 -3
  53. package/dist/components/render-edit-action-button.js +2 -2
  54. package/dist/components/render-edit-logic.js +1 -1
  55. package/dist/components/render-edit-types.d.ts +6 -6
  56. package/dist/components/render-edit.js +74 -73
  57. package/dist/components/render-field.d.ts +7 -7
  58. package/dist/components/render-field.js +62 -61
  59. package/dist/components/render-list.d.ts +42 -43
  60. package/dist/components/render-list.js +93 -84
  61. package/dist/components/use-wizard-form-draft.js +10 -10
  62. package/dist/context/dispatcher-context.js +5 -5
  63. package/dist/context/user-roles-context.js +2 -2
  64. package/dist/flow-diagram/render.js +9 -1
  65. package/dist/headless/apex/index.d.ts +1 -2
  66. package/dist/headless/apex/index.js +5 -6
  67. package/dist/headless/form/form-controller.js +14 -6
  68. package/dist/headless/form/types.d.ts +1 -0
  69. package/dist/headless/form/zod-bridge.js +2 -2
  70. package/dist/headless/format/index.js +89 -64
  71. package/dist/headless/format/money.js +1 -1
  72. package/dist/headless/index.js +2 -2
  73. package/dist/headless/view-model/edit.js +4 -2
  74. package/dist/headless/view-model/list.js +184 -150
  75. package/dist/headless/view-model/types.d.ts +6 -3
  76. package/dist/hooks/live-refetch-scheduler.js +4 -0
  77. package/dist/hooks/reference-limits.js +11 -12
  78. package/dist/hooks/use-list-url-state.d.ts +13 -13
  79. package/dist/hooks/use-list-url-state.js +21 -22
  80. package/dist/hooks/use-query.js +3 -3
  81. package/dist/hooks/use-reference-lookup.d.ts +9 -1
  82. package/dist/hooks/use-reference-lookup.js +32 -19
  83. package/dist/hooks/use-store.js +18 -22
  84. package/dist/hooks/use-stream-handler.js +4 -2
  85. package/dist/i18n-defaults.js +6 -8
  86. package/dist/i18n.d.ts +10 -10
  87. package/dist/i18n.js +25 -25
  88. package/dist/index.d.ts +1 -0
  89. package/dist/index.js +6 -4
  90. package/dist/live/dispatcher-live.js +2 -2
  91. package/dist/primitives.d.ts +244 -221
  92. package/dist/primitives.js +1 -1
  93. package/dist/sse/live-events.d.ts +5 -5
  94. package/dist/sse/live-events.js +17 -17
  95. package/dist/tokens.d.ts +8 -8
  96. package/dist/tokens.js +16 -17
  97. package/dist/web/app/browser-locale.d.ts +9 -9
  98. package/dist/web/app/browser-locale.js +19 -19
  99. package/dist/web/app/client-plugin.d.ts +44 -52
  100. package/dist/web/app/client-plugin.js +4 -5
  101. package/dist/web/app/create-app.d.ts +28 -31
  102. package/dist/web/app/create-app.js +29 -25
  103. package/dist/web/app/dashboard-body.js +17 -1
  104. package/dist/web/app/dashboard-panels.js +12 -2
  105. package/dist/web/app/document-lang-sync.js +1 -1
  106. package/dist/web/app/draft-storage.js +1 -1
  107. package/dist/web/app/nav-providers-context.d.ts +3 -3
  108. package/dist/web/app/nav-providers-context.js +3 -3
  109. package/dist/web/app/nav.d.ts +18 -17
  110. package/dist/web/app/nav.js +66 -64
  111. package/dist/web/app/rich-content-editor.js +1 -1
  112. package/dist/web/components/config-cascade.js +17 -18
  113. package/dist/web/components/config-source-badge.js +4 -4
  114. package/dist/web/index.d.ts +1 -1
  115. package/dist/web/index.js +1 -1
  116. package/dist/web/layout/app-layout.d.ts +9 -9
  117. package/dist/web/layout/app-layout.js +2 -2
  118. package/dist/web/layout/avatar.d.ts +4 -3
  119. package/dist/web/layout/avatar.js +15 -17
  120. package/dist/web/layout/default-app-shell.d.ts +22 -23
  121. package/dist/web/layout/editor-panel.js +3 -3
  122. package/dist/web/layout/nav-tree.js +86 -84
  123. package/dist/web/layout/profile-menu.d.ts +1 -1
  124. package/dist/web/layout/shell-header.js +2 -1
  125. package/dist/web/layout/sidebar-brand.d.ts +7 -7
  126. package/dist/web/layout/sidebar-user.d.ts +1 -1
  127. package/dist/web/layout/sidebar-user.js +4 -4
  128. package/dist/web/layout/sidebar.d.ts +8 -8
  129. package/dist/web/layout/sidebar.js +7 -7
  130. package/dist/web/layout/target-resolver-stub.d.ts +5 -5
  131. package/dist/web/layout/target-resolver-stub.js +20 -20
  132. package/dist/web/layout/target-url.d.ts +7 -7
  133. package/dist/web/layout/target-url.js +22 -22
  134. package/dist/web/layout/theme-toggle.js +4 -5
  135. package/dist/web/layout/uniform-bar-controls.d.ts +1 -0
  136. package/dist/web/layout/uniform-bar-controls.js +4 -0
  137. package/dist/web/layout/workspace-shell.d.ts +21 -21
  138. package/dist/web/layout/workspace-shell.js +29 -30
  139. package/dist/web/lib/cn.d.ts +4 -4
  140. package/dist/web/lib/cn.js +4 -4
  141. package/dist/web/lib/file-ref-download.js +4 -1
  142. package/dist/web/primitives/action-menu.d.ts +8 -8
  143. package/dist/web/primitives/action-menu.js +14 -13
  144. package/dist/web/primitives/calendar-popover.d.ts +1 -1
  145. package/dist/web/primitives/calendar-popover.js +10 -10
  146. package/dist/web/primitives/card-styles.js +1 -1
  147. package/dist/web/primitives/card.js +2 -2
  148. package/dist/web/primitives/code-input.d.ts +6 -0
  149. package/dist/web/primitives/code-input.js +31 -0
  150. package/dist/web/primitives/combobox.d.ts +12 -12
  151. package/dist/web/primitives/combobox.js +34 -35
  152. package/dist/web/primitives/data-table-cell.js +2 -2
  153. package/dist/web/primitives/data-table.js +6 -6
  154. package/dist/web/primitives/date-field.js +2 -2
  155. package/dist/web/primitives/date-input.js +4 -4
  156. package/dist/web/primitives/default-form.js +2 -2
  157. package/dist/web/primitives/dropdown-menu.js +14 -15
  158. package/dist/web/primitives/embedded-list-input.js +3 -3
  159. package/dist/web/primitives/facet-filter.js +2 -2
  160. package/dist/web/primitives/field.js +9 -8
  161. package/dist/web/primitives/file-thumbnail.d.ts +3 -0
  162. package/dist/web/primitives/file-thumbnail.js +5 -0
  163. package/dist/web/primitives/form.d.ts +1 -1
  164. package/dist/web/primitives/form.js +6 -6
  165. package/dist/web/primitives/input.js +8 -3
  166. package/dist/web/primitives/layout-elements.js +7 -5
  167. package/dist/web/primitives/layout.js +2 -2
  168. package/dist/web/primitives/located-timestamp-input.js +4 -4
  169. package/dist/web/primitives/money-input.js +13 -17
  170. package/dist/web/primitives/page-header.js +3 -1
  171. package/dist/web/primitives/pager.js +1 -1
  172. package/dist/web/primitives/row-actions.js +6 -7
  173. package/dist/web/primitives/secret-reveal.js +1 -1
  174. package/dist/web/primitives/timestamp-input.d.ts +9 -9
  175. package/dist/web/primitives/timestamp-input.js +21 -21
  176. package/dist/web/primitives/toast.js +12 -13
  177. package/dist/web/primitives/use-focus-on-mount.d.ts +2 -0
  178. package/dist/web/primitives/use-focus-on-mount.js +9 -0
  179. package/dist/web/sse/live-events.d.ts +6 -6
  180. package/dist/web/sse/live-events.js +3 -3
  181. package/dist/web/tokens.d.ts +11 -12
  182. package/dist/web/tokens.js +35 -37
  183. package/dist/web/version/update-checker.js +14 -13
  184. package/dist/web/widgets/ai-text-field.js +1 -1
  185. package/dist/web/widgets/bounded-load-cache.d.ts +1 -1
  186. package/dist/web/widgets/bounded-load-cache.js +10 -4
  187. package/dist/web/widgets/charts.d.ts +10 -10
  188. package/dist/web/widgets/charts.js +8 -8
  189. package/dist/web/widgets/choice-cards.d.ts +17 -0
  190. package/dist/web/widgets/choice-cards.js +12 -0
  191. package/dist/web/widgets/collapsible-section.d.ts +3 -3
  192. package/dist/web/widgets/collapsible-section.js +7 -7
  193. package/dist/web/widgets/detail-list.d.ts +5 -3
  194. package/dist/web/widgets/detail-list.js +5 -3
  195. package/dist/web/widgets/feed-list.d.ts +2 -2
  196. package/dist/web/widgets/feed-list.js +2 -2
  197. package/dist/web/widgets/form-fields.d.ts +14 -14
  198. package/dist/web/widgets/form-fields.js +14 -13
  199. package/dist/web/widgets/index.d.ts +1 -0
  200. package/dist/web/widgets/index.js +5 -4
  201. package/dist/web/widgets/infinity-list.js +3 -3
  202. package/dist/web/widgets/mode-switch.d.ts +2 -2
  203. package/dist/web/widgets/mode-switch.js +2 -2
  204. package/dist/web/widgets/pdf-preview.js +1 -1
  205. package/dist/web/widgets/plan-card.d.ts +2 -0
  206. package/dist/web/widgets/plan-card.js +1 -1
  207. package/dist/web/widgets/progress-list.d.ts +1 -2
  208. package/dist/web/widgets/progress-list.js +1 -2
  209. package/dist/web/widgets/query-table.d.ts +6 -6
  210. package/dist/web/widgets/result-panel.d.ts +16 -16
  211. package/dist/web/widgets/result-panel.js +10 -10
  212. package/dist/web/widgets/section-card.d.ts +3 -3
  213. package/dist/web/widgets/section-card.js +3 -3
  214. package/dist/web/widgets/stat.d.ts +4 -4
  215. package/dist/web/widgets/stat.js +6 -6
  216. package/dist/web/widgets/states.d.ts +4 -4
  217. package/dist/web/widgets/states.js +4 -4
  218. package/dist/web/widgets/upload-zone.js +13 -5
  219. package/dist/web/widgets/use-draft.d.ts +7 -7
  220. package/dist/web/widgets/use-draft.js +7 -7
  221. package/package.json +6 -6
  222. package/src/changes.json +49 -0
@@ -12,14 +12,15 @@ import { shouldRenderActionsIconOnly, usePrimitives, } from "../primitives.js";
12
12
  import { ListEmptyStateBody } from "./list-empty-state.js";
13
13
  import { needsActionConfirm } from "./render-edit-action-button.js";
14
14
  const SEARCH_DEBOUNCE_MS = 300;
15
- // Icon-less actions are promoted too (with a "plus" fallback at the call site).
15
+ // Icon-less actions are promoted too; on phones the call site falls back to a
16
+ // "plus" icon, on desktop the label carries the action.
16
17
  function isHeaderPromotableToolbarAction(action) {
17
18
  return action.style === "primary" && !needsActionConfirm(action);
18
19
  }
19
20
  export function RenderList(props) {
20
21
  const { screen, entity, rows, featureName, translate: translateProp, onRowClick, emptyState, onCreate, createLabel, searchable = false, searchPlaceholder, description, emptyContent, narrowed = false, defaultFilterActive: defaultFilterActiveProp = false, 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;
21
- // Wie RenderEdit: Translate-Fallback aus dem i18next-Context, sonst
22
- // wären Column-Header raw i18n-Keys.
22
+ // As in RenderEdit, fall back to the i18next context for translation;
23
+ // otherwise column headers would display raw i18n keys.
23
24
  const t = useTranslation();
24
25
  const translate = translateProp ?? t;
25
26
  const { DataTable, Button, Dialog, Input, Banner, PageHeader } = usePrimitives();
@@ -37,24 +38,31 @@ export function RenderList(props) {
37
38
  // The parent may echo our own debounced emits back late (router/URL round
38
39
  // trip), possibly after a newer emit already went out; adopting such a stale
39
40
  // echo would erase keys typed in between. All unechoed emits are kept, and a
40
- // matching echo drops itself and every older one.
41
+ // matching echo drops itself and every older one. The render phase only reads
42
+ // the list (a discarded or repeated render must find the echo again); the
43
+ // matched entries are dropped in an effect.
41
44
  const pendingEmitsRef = useRef([]);
42
45
  if (searchValue !== syncedSearchValue) {
43
46
  setSyncedSearchValue(searchValue);
44
- const echoIndex = pendingEmitsRef.current.indexOf(searchValue ?? "");
45
- if (echoIndex >= 0) {
46
- pendingEmitsRef.current.splice(0, echoIndex + 1);
47
- }
48
- else {
47
+ if (!pendingEmitsRef.current.includes(searchValue ?? "")) {
49
48
  setLocalQ(searchValue ?? "");
50
49
  pendingEmitsRef.current = [];
51
50
  }
52
51
  }
52
+ useEffect(() => {
53
+ const echoIndex = pendingEmitsRef.current.indexOf(syncedSearchValue ?? "");
54
+ if (echoIndex >= 0)
55
+ pendingEmitsRef.current.splice(0, echoIndex + 1);
56
+ }, [syncedSearchValue]);
53
57
  useEffect(() => {
54
58
  if (onSearchChange === undefined)
55
59
  return;
56
60
  if (localQ === (searchValue ?? ""))
57
61
  return;
62
+ // A late echo of an older emit changes searchValue but must not re-send
63
+ // the value that is already in flight.
64
+ if (pendingEmitsRef.current.at(-1) === localQ)
65
+ return;
58
66
  const timer = setTimeout(() => {
59
67
  pendingEmitsRef.current.push(localQ);
60
68
  onSearchChange(localQ);
@@ -62,33 +70,32 @@ export function RenderList(props) {
62
70
  return () => clearTimeout(timer);
63
71
  }, [localQ, searchValue, onSearchChange]);
64
72
  const vm = useMemo(() => computeListViewModel({ screen, entity, rows, translate, featureName }), [screen, entity, rows, translate, featureName]);
65
- // Tier 2.7e-4: Reference-Field-Eagerload via Bridge-Component-Pattern.
66
- // Hooks-Rule-Konflikt: pro reference-Spalte muss useReferenceLookup
67
- // gerufen werden, aber die Anzahl varriiert per Schema. Lösung —
68
- // ReferenceLookupBridge-Komponenten pro Spalte gemountet, die ihre
69
- // Map über onMap-Callback in einen lokalen State veröffentlichen.
70
- // Anzahl der Hook-Calls pro Bridge-Component ist konstant (1), und
71
- // React verwaltet die mounted/unmounted Lifecycles beim Schema-
72
- // Wechsel über die key-Property automatisch.
73
+ // Reference-field eager loading through bridge components.
74
+ // The Hooks rule requires useReferenceLookup for each reference column,
75
+ // but the number of columns varies with the schema. Each column therefore
76
+ // mounts a ReferenceLookupBridge that publishes its map to local state
77
+ // through onMap. Each bridge makes a constant number of hook calls (one),
78
+ // and React manages its lifecycle when the schema changes via key.
73
79
  const referenceColumns = useMemo(() => vm.columns
74
80
  .filter((c) => c.type === "reference" && c.refEntity !== undefined)
75
81
  .map((c) => ({
76
82
  field: c.field,
77
83
  refEntity: c.refEntity ?? "",
78
- // Tier 2.7e Cross-Feature: refFeature kommt aus parseRefTarget
79
- // im ViewModel (default = current featureName).
84
+ // refFeature comes from parseRefTarget in the ViewModel
85
+ // (default = the current featureName).
80
86
  refFeature: c.refFeature ?? featureName,
81
87
  labelField: c.refLabelField ?? "id",
82
88
  })), [vm.columns, featureName]);
83
89
  const [referenceLookups, setReferenceLookups] = useState({});
84
- const handleLookupMap = useCallback((field, map) => {
90
+ const handleLookupMap = useCallback((field, lookup) => {
85
91
  setReferenceLookups((prev) => {
86
- // skip-update wenn die Map identisch ist (Render-Loop-Schutz —
87
- // Bridge-Component re-rendert sonst und schickt die gleiche Map
88
- // wieder rein bis React in eine Endlosschleife geht).
89
- if (prev[field] === map)
92
+ // Skip the update when the lookup is identical. Otherwise the bridge
93
+ // would re-render and send the same map back until React entered
94
+ // an infinite loop.
95
+ const current = prev[field];
96
+ if (current?.map === lookup.map && current.complete === lookup.complete)
90
97
  return prev;
91
- return { ...prev, [field]: map };
98
+ return { ...prev, [field]: lookup };
92
99
  });
93
100
  }, []);
94
101
  const resolveReferenceLink = useReferenceLinkResolver();
@@ -98,19 +105,23 @@ export function RenderList(props) {
98
105
  return vm.columns.map((col) => {
99
106
  if (col.type !== "reference")
100
107
  return col;
101
- // Author-deklarierter Renderer übersteuert immer — Default greift
102
- // nur wenn keiner gesetzt ist.
108
+ // An author-declared renderer always takes precedence;
109
+ // the default is used only when none is set.
103
110
  if (col.renderer !== undefined)
104
111
  return col;
105
- const map = referenceLookups[col.field];
112
+ const published = referenceLookups[col.field];
113
+ const map = published?.map;
106
114
  const unknownKey = UNRESOLVED_REFERENCE_LABEL_KEYS[`${col.refFeature ?? featureName}:${col.refEntity ?? ""}`];
107
115
  // No raw id for entities that opt in: a placeholder while the lookup
108
- // loads, the "unknown" label once it found nothing.
116
+ // loads, the "unknown" label once the complete lookup found nothing. A
117
+ // capped lookup page cannot prove absence, so it keeps the raw id.
109
118
  const unresolvedLabel = (id) => unknownKey === undefined
110
119
  ? id
111
- : map === undefined
120
+ : published === undefined
112
121
  ? EMPTY_VALUE_PLACEHOLDER
113
- : translate(unknownKey);
122
+ : published.complete
123
+ ? translate(unknownKey)
124
+ : id;
114
125
  const labelField = col.refLabelField ?? "id";
115
126
  const refEntity = col.refEntity ?? "";
116
127
  // Only a row the user can see is linkable: resolved by the server
@@ -145,11 +156,11 @@ export function RenderList(props) {
145
156
  });
146
157
  };
147
158
  const renderer = (value, row) => {
148
- // Tier 2.7e Server-Eagerload: wenn der Server _refs mit-
149
- // schickt, lesen wir den Display-Wert direkt aus der
150
- // resolved Row — kein Roundtrip durch die Bridge-Map nötig
151
- // und keine limit:200-Constraint. EagerloadedRow-Type aus
152
- // @cosmicdrift/kumiko-framework/db pinnt die Form von _refs.
159
+ // Server eager loading: when the server includes _refs, read the display
160
+ // value directly from the resolved row. This avoids a round trip
161
+ // through the bridge map and the limit:200 constraint. The
162
+ // EagerloadedRow type from @cosmicdrift/kumiko-framework/db defines
163
+ // the shape of _refs.
153
164
  const resolved = eagerloadedRef(row, col.field);
154
165
  if (Array.isArray(resolved) && resolved.length > 0) {
155
166
  return resolved.map((r) => String(r[labelField] ?? r["id"] ?? "")).join(", ");
@@ -157,7 +168,7 @@ export function RenderList(props) {
157
168
  if (isPlainRecord(resolved)) {
158
169
  return String(resolved[labelField] ?? resolved["id"] ?? "");
159
170
  }
160
- // Renderer-Side-Fallback (kein Server-Eagerload aktiv).
171
+ // Renderer-side fallback when server eager loading is not active.
161
172
  if (Array.isArray(value)) {
162
173
  if (value.length === 0)
163
174
  return EMPTY_VALUE_PLACEHOLDER;
@@ -182,9 +193,9 @@ export function RenderList(props) {
182
193
  ...vm,
183
194
  columns: enrichedColumns,
184
195
  }), [vm, enrichedColumns]);
185
- // i18n-Defaults für Toolbar/Empty-State Strings — Caller kann jeden
186
- // einzeln per Prop überschreiben, sonst kommen die Framework-Bundles
187
- // (kumiko.actions.create, kumiko.list.search-placeholder, …).
196
+ // i18n defaults for toolbar and empty-state strings. The caller can
197
+ // override each one through a prop; otherwise the framework bundles provide
198
+ // them (kumiko.actions.create, kumiko.list.search-placeholder, …).
188
199
  const effectiveCreateLabel = translate(createLabel ?? screen.createLabel ?? "kumiko.actions.create");
189
200
  const effectiveSearchPlaceholder = translate(searchPlaceholder ?? screen.searchPlaceholder ?? "kumiko.list.search-placeholder");
190
201
  const nounKey = itemNounKey ?? `${featureName}:entity:${screen.entity}:noun`;
@@ -193,12 +204,12 @@ export function RenderList(props) {
193
204
  ? (count) => translate(nounKey, { count })
194
205
  : undefined;
195
206
  const toolbarStart = searchable ? (_jsx(Input, { kind: "text", id: "render-list-search", name: "search", value: localQ, onChange: setLocalQ, placeholder: effectiveSearchPlaceholder })) : undefined;
196
- // Toolbar-End-Slot: optionaler Header-Slot (z.B. Cap-Counter, links im
197
- // rechten Cluster) + Toolbar-Actions (List-Header-Buttons aus dem Schema)
198
- // + optional "+ Neu" am rechten Edge. Der Header-Slot lebt IN der Toolbar-
199
- // Zeile (gleicher Card-Rahmen), nicht als loser Text über dem Screen-Titel
200
- // (Bug-Bash 3 #12). Reihenfolge = Array-Reihenfolge (Schema-deklariert),
201
- // "+ Neu" zuletzt weil das die häufigste/auffälligste CTA ist.
207
+ // Toolbar-end slot: an optional header slot (for example, a cap counter,
208
+ // at the left of the right-hand cluster), toolbar actions from the schema,
209
+ // and an optional "+ New" button at the right edge. The header slot lives
210
+ // inside the toolbar row, with the same card frame, rather than as loose
211
+ // text above the screen title. Items retain array order, and "+ New" comes
212
+ // last because it is the most common and prominent CTA.
202
213
  const hasToolbarActions = toolbarActions !== undefined && toolbarActions.length > 0;
203
214
  const hasHeaderSlot = screen.slots?.header !== undefined;
204
215
  const toolbarIconOnly = hasToolbarActions && shouldRenderActionsIconOnly(toolbarActions);
@@ -218,30 +229,29 @@ export function RenderList(props) {
218
229
  hasToolbarActionsInToolbar ||
219
230
  (onCreate !== undefined && !createInPageHeader) ? (_jsxs(_Fragment, { children: [hasHeaderSlot && _jsx(ListHeaderSlotMount, { screen: screen }), hasToolbarActionsInToolbar &&
220
231
  toolbarActionsInToolbar.map((a) => (_jsx(ToolbarActionView, { action: a, iconOnly: toolbarIconOnly, Button: Button, Dialog: Dialog, Banner: Banner }, a.id))), !createInPageHeader && createButton] })) : undefined;
221
- // Empty-State: Default zeigt Heading + Description + optional CTA-
222
- // Button. Caller kann via emptyState-Prop komplett überschreiben.
223
- // Wenn weder onCreate noch ein Override gegeben ist, fällt die
224
- // DataTable auf den DataTable-Default zurück (`kumiko.list.no-entries`).
232
+ // Empty state: by default, show a heading, description, and optional CTA
233
+ // button. The caller can replace it completely through emptyState.
234
+ // Without onCreate or an override, DataTable uses its default
235
+ // (`kumiko.list.no-entries`).
225
236
  const defaultFilterActive = defaultFilterActiveProp && onFilterReset !== undefined;
226
237
  const composedEmptyState = emptyState ??
227
238
  (onCreate !== undefined || emptyContent !== undefined || narrowed || defaultFilterActive ? (_jsx(ListEmptyStateBody, { content: emptyContent, narrowed: narrowed, defaultFilterActive: defaultFilterActive, ...(onFilterReset !== undefined && { onShowAll: onFilterReset }), translate: translate, createButton: onCreate !== undefined && !narrowed && !defaultFilterActive && !createInPageHeader ? (_jsx(Button, { variant: "primary", icon: "plus", onClick: onCreate, testId: "render-list-empty-create", children: effectiveCreateLabel })) : undefined })) : undefined);
228
- // Title-Resolution: ein konventioneller i18n-Key `screen:<id>.title`.
229
- // Wenn das Bundle den Key kennt, schöner Titel; sonst kommt der
230
- // screenId selber raus (kein "Untitled"-Fallback — der App-Dev sieht
231
- // dann dass die Übersetzung fehlt).
239
+ // Title resolution uses the conventional i18n key `screen:<id>.title`.
240
+ // If the bundle knows the key, use its translated title; otherwise use
241
+ // screenId so the app developer can see that the translation is missing.
232
242
  const titleKey = `screen:${screen.id}.title`;
233
243
  const resolvedTitle = translate(titleKey);
234
244
  const toolbarTitle = resolvedTitle === titleKey ? screen.id : resolvedTitle;
235
- // ListSort = DataTableSort (use-list-url-state aliased) — kein Cast nötig.
245
+ // ListSort = DataTableSort (aliased in use-list-url-state), so no cast is needed.
236
246
  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 && { 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" })] }));
237
247
  }
238
- // Header-Slot in der Listen-Toolbar (toolbarEnd): `screen.slots.header` ist
239
- // eine PlatformComponent (`{ react: { __component: "X" } }`), aufgelöst über
240
- // dieselbe ExtensionSectionsProvider-Registry wie entityEdit-Sections.
241
- // entityId ist null (Listen-Kontext, keine Row); die Component lädt ihre
242
- // Daten selbst (z.B. ein Cap-Counter aus einer usage-Query). Eigene
243
- // Component wegen Rules-of-Hooks — useExtensionSectionComponent darf nicht
244
- // konditional im RenderList-Body laufen.
248
+ // Header slot in the list toolbar (toolbarEnd): `screen.slots.header` is
249
+ // a PlatformComponent (`{ react: { __component: "X" } }`) resolved through
250
+ // the same ExtensionSectionsProvider registry as entityEdit sections.
251
+ // entityId is null in the list context because there is no row; the component
252
+ // loads its own data (for example, a cap counter from a usage query). A
253
+ // separate component is required by the Rules of Hooks because
254
+ // useExtensionSectionComponent must not run conditionally in RenderList.
245
255
  function ListHeaderSlotMount({ screen, }) {
246
256
  const header = screen.slots?.header;
247
257
  const name = header !== undefined ? extensionSectionName(header) : undefined;
@@ -261,37 +271,36 @@ function ListHeaderSlotMount({ screen, }) {
261
271
  // (useListUrlState) — e.g. a tag filter narrowing the list to an id-set.
262
272
  return _jsx(Component, { entityName: screen.entity, entityId: null, screenId: screen.id });
263
273
  }
264
- // Tier 2.7e-4: Bridge-Component pro reference-Spalte. Mounted für jede
265
- // Spalte einmal, ruft useReferenceLookup unconditional (Hook-Rules
266
- // happy), und published die Map über onMap an den Parent. React
267
- // verwaltet das Mounting/Unmounting beim Schema-Wechsel über die
268
- // key-Prop im map-Loop des Parents.
274
+ // Bridge component for each reference column. It is mounted once per
275
+ // column, calls useReferenceLookup unconditionally, and publishes the map
276
+ // to the parent through onMap. React manages mounting and unmounting when
277
+ // the schema changes through the key prop in the parent's map loop.
269
278
  function ReferenceLookupBridge({ field, refEntity, labelField, featureName, onMap, }) {
270
279
  const lookup = useReferenceLookup(featureName, refEntity, labelField);
271
- // useEffect statt direktem call damit setState außerhalb des Render-
272
- // Pfads passiert (sonst React-Warning "Cannot update a component
273
- // while rendering a different component").
274
- // Unpublished while loading or after a failed lookup: the renderer treats a missing map as
275
- // "not resolved", an empty published map as "nothing found". Only the latter may say "Unknown".
280
+ // Use an effect rather than calling directly so setState runs outside the
281
+ // render path; otherwise React warns about updating one component while
282
+ // rendering another.
283
+ // Do not publish while loading or after a failed lookup: a missing map means
284
+ // "not resolved", whereas an empty published map means "nothing found".
285
+ // Only the latter may display "Unknown".
276
286
  const resolved = !lookup.loading && !lookup.failed;
277
287
  useEffect(() => {
278
288
  if (resolved)
279
- onMap(field, lookup.map);
280
- }, [field, lookup.map, resolved, onMap]);
289
+ onMap(field, { map: lookup.map, complete: lookup.complete });
290
+ }, [field, lookup.map, lookup.complete, resolved, onMap]);
281
291
  return null;
282
292
  }
283
- // ToolbarActionView — pro Toolbar-Action ein Button (+ Confirm-Dialog
284
- // wenn confirm gesetzt). Same Pattern wie RowAction (Inline-Variante)
285
- // aber ohne row-Context. busy-State während async onTrigger; Dialog
286
- // öffnet sich vor dem Trigger wenn confirm/danger gesetzt.
293
+ // ToolbarActionView renders one button per toolbar action, with a confirm
294
+ // dialog when required. It follows the inline RowAction pattern but has no
295
+ // row context. The busy state covers async onTrigger; the dialog opens before
296
+ // the trigger when confirmation is required for confirm/danger actions.
287
297
  function ToolbarActionView({ action, iconOnly = false, Button, Dialog, Banner, }) {
288
298
  const [busy, setBusy] = useState(false);
289
299
  const [confirmOpen, setConfirmOpen] = useState(false);
290
- // Toolbar-Actions haben (anders als rowActions, die über die DataTable-
291
- // Primitive laufen) keine Toast-Primitive zur Verfügung — ein inline
292
- // Error-Banner ist der surfacing-Pfad hier. Gleicher Grund wie bei
293
- // rowActions (Prod-Bug 2026-06-07): ein verschlucktes Failure-Result
294
- // sieht für den User wie "nichts passiert" aus.
300
+ // Toolbar actions do not have access to the toast primitive, unlike
301
+ // rowActions routed through the DataTable primitive, so an inline error
302
+ // banner is used to surface failures. Without it, a swallowed failure result
303
+ // would look to the user as though nothing happened.
295
304
  const [errorText, setErrorText] = useState(null);
296
305
  const trigger = async () => {
297
306
  setBusy(true);
@@ -309,7 +318,7 @@ function ToolbarActionView({ action, iconOnly = false, Button, Dialog, Banner, }
309
318
  const variant = action.style ?? "secondary";
310
319
  const needsConfirm = needsActionConfirm(action);
311
320
  const showIconOnly = iconOnly && action.icon !== undefined;
312
- 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: () => {
321
+ return (_jsxs(_Fragment, { children: [_jsx(Button, { variant: variant, loading: busy, ...(action.icon !== undefined && { icon: action.icon }), size: showIconOnly ? "icon" : "md", ...(showIconOnly && { ariaLabel: action.label, title: action.label }), onClick: () => {
313
322
  if (needsConfirm) {
314
323
  setConfirmOpen(true);
315
324
  }
@@ -12,10 +12,10 @@ const FORM_DRAFT_LIST = "form-draft:query:list";
12
12
  // Mirrors bundled-features/src/form-draft/web/constants.ts's FORM_DRAFT_KEY_MAX_LENGTH
13
13
  // — same hardcoding rationale as the QNs above (no dependency on that package).
14
14
  const FORM_DRAFT_KEY_MAX_LENGTH = 256;
15
- // Trailing-edge debounce for patch()-triggered draft saves (#1914). A single
15
+ // Trailing-edge debounce for patch()-triggered draft saves. A single
16
16
  // patch() call (VIN-decode, an extension section) should not save immediately
17
17
  // per call, but patch() can also fire from inside onChange on every keystroke
18
- // (the #1888 derived-field shape) — 500ms collapses a typing burst into one
18
+ // (the derived-field shape) — 500ms collapses a typing burst into one
19
19
  // save instead of one per keystroke, while still saving well before a user
20
20
  // abandons the tab.
21
21
  export const PATCH_DRAFT_SAVE_DEBOUNCE_MS = 500;
@@ -40,7 +40,7 @@ function mintDraftId() {
40
40
  return `draft-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
41
41
  }
42
42
  export function useWizardFormDraftState({ screenId, entityId, isCreateMode, draftStorage, }) {
43
- // Create-mode draftId (issue #1913) — resumed from `sessionStorage` (web)
43
+ // Create-mode draftId — resumed from `sessionStorage` (web)
44
44
  // on mount so a same-tab reload finds the right one of several parallel
45
45
  // create-sessions on this screen; `null` until the first step change
46
46
  // mints one (saveDraft), or the mount-time `form-draft:query:list`
@@ -55,7 +55,7 @@ export function useWizardFormDraftState({ screenId, entityId, isCreateMode, draf
55
55
  // once for this mount. Without this, a create-mode discard resets `draftId`
56
56
  // to `null` (see discardDraft below) and would re-arm the list effect —
57
57
  // silently adopting an unrelated parallel draft on the same screen right
58
- // after the user just submitted (#1908).
58
+ // after the user just submitted.
59
59
  const didListRef = useRef(false);
60
60
  const [draftCandidates, setDraftCandidates] = useState(null);
61
61
  // Derived from the screen id + the host entity id only — never from
@@ -64,7 +64,7 @@ export function useWizardFormDraftState({ screenId, entityId, isCreateMode, draf
64
64
  // unchanged. Create-mode: `${screenId}:new:${draftId}` once a draftId
65
65
  // exists (resumed, adopted, or minted), `undefined` before that — two
66
66
  // parallel create sessions on the same screen must never collapse onto
67
- // the same key (kumiko-framework#1908).
67
+ // the same key.
68
68
  const draftKey = useMemo(() => isCreateMode
69
69
  ? draftId !== null
70
70
  ? `${screenId}:new:${draftId}`
@@ -133,7 +133,7 @@ export function useWizardFormDraftRestore({ screenId, isCreateMode, draftEnabled
133
133
  hasNavigatedRef,
134
134
  setRawStep,
135
135
  ]);
136
- // Mount-time fallback (issue #1913) for create-mode when no draftId
136
+ // Mount-time fallback for create-mode when no draftId
137
137
  // survived in storage (new tab, cleared storage): ask the server for this
138
138
  // screen's open drafts and let the user choose via a picker (below) —
139
139
  // even for exactly one candidate. Auto-adopting a lone candidate would
@@ -186,7 +186,7 @@ export function buildWizardDraftActions({ screenId, isCreateMode, draftEnabled,
186
186
  // (see draftCandidates below) — adopt it the same way a single
187
187
  // auto-adopted candidate would be.
188
188
  function adoptDraft(candidate) {
189
- // Locked state (#1896/fw#1909): `disabled` means "no write possible" —
189
+ // Locked state: `disabled` means "no write possible" —
190
190
  // repointing draftKey and patching in the candidate's values is exactly
191
191
  // that, so a locked form must not adopt a draft.
192
192
  // skip: a locked form keeps its current draft (reason above).
@@ -202,8 +202,8 @@ export function buildWizardDraftActions({ screenId, isCreateMode, draftEnabled,
202
202
  //
203
203
  // Create-mode, first draft save (step change OR a debounced patch() from
204
204
  // controlled mode / extension sections, see patchAndScheduleDraftSave
205
- // above — both call this on step 0 too): mints the draftId here (issue
206
- // #1913) rather than at mount, so a form nobody ever interacted with
205
+ // above — both call this on step 0 too): mints the draftId here
206
+ // rather than at mount, so a form nobody ever interacted with
207
207
  // never claims a draftId or writes a row. `draftKey`/`draftId` state
208
208
  // won't reflect the mint until the next render, so the just-minted key is
209
209
  // computed inline instead of read from the memoized `draftKey`.
@@ -220,7 +220,7 @@ export function buildWizardDraftActions({ screenId, isCreateMode, draftEnabled,
220
220
  key = `${screenId}:new:${mintedId}`;
221
221
  // A stale picker from the mount-time list fallback must not survive a
222
222
  // mint — picking a candidate afterwards would repoint draftKey at an
223
- // unrelated draft mid-edit and overwrite it (#1908).
223
+ // unrelated draft mid-edit and overwrite it.
224
224
  setDraftCandidates(null);
225
225
  }
226
226
  // skip: create-mode with a draftId that failed to mint — unreachable
@@ -32,11 +32,11 @@ export function useDispatcher() {
32
32
  }
33
33
  return dispatcher;
34
34
  }
35
- // Soft-Variante: null wenn kein Provider mounted statt throw. Für
36
- // Components die einen Dispatcher OPTIONAL brauchen (z.B. KumikoScreen
37
- // rendert mit/ohne rowActions; die Test-Suite mountet kein Dispatcher
38
- // wenn der Screen keine Mutation triggert). Caller ist zuständig dass
39
- // er bei null einen sinnvollen Fallback hat (typisch: kein Action-Wiring).
35
+ // Soft variant: returns null when no provider is mounted instead of throwing. For
36
+ // components that optionally need a Dispatcher (for example, KumikoScreen renders
37
+ // with or without rowActions; the test suite does not mount a Dispatcher when the
38
+ // screen triggers no mutation). Callers must provide a sensible fallback for null
39
+ // (typically, no action wiring).
40
40
  export function useOptionalDispatcher() {
41
41
  return useContext(DispatcherContext) ?? undefined;
42
42
  }
@@ -1,8 +1,8 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { createContext, useContext } from "react";
3
3
  // Threads the current user's roles through the tree so KumikoScreen can
4
- // gate role-restricted screens at render time (see #1203 — nav filtering
5
- // alone doesn't stop a direct-URL/screenQn hit on a role-gated screen).
4
+ // gate role-restricted screens at render time — nav filtering alone
5
+ // doesn't stop a direct-URL/screenQn hit on a role-gated screen).
6
6
  // Apps wire this from their `shell` render-prop, the same place they
7
7
  // already pass `user` to WorkspaceShell for nav filtering.
8
8
  //
@@ -23,7 +23,15 @@ function cardSize(node) {
23
23
  return { width, height };
24
24
  }
25
25
  function assertEdgesReferenceKnownNodes(spec) {
26
- const ids = new Set(spec.nodes.map((n) => n.id));
26
+ const ids = new Set();
27
+ for (const node of spec.nodes) {
28
+ if (ids.has(node.id))
29
+ throw new Error(`FlowSpec: duplicate node id "${node.id}"`);
30
+ ids.add(node.id);
31
+ if (node.icon !== undefined && node.icon !== "ai" && !Object.hasOwn(ICONS, node.icon)) {
32
+ throw new Error(`FlowSpec: node "${node.id}" has unknown icon "${node.icon}"`);
33
+ }
34
+ }
27
35
  for (const edge of spec.edges) {
28
36
  if (!ids.has(edge.from))
29
37
  throw new Error(`FlowSpec: edge references unknown node "${edge.from}"`);
@@ -231,6 +231,5 @@ export declare function injectPageHead(html: string, tagsHtml: string): string;
231
231
  * the shared timeout. Never throws and never changes `html` on a resolver
232
232
  * error, a `null` resolve, or a timeout — the caller's shell always ships
233
233
  * unchanged. runProdApp and runDevApp both call this single function, so a
234
- * future change to timeout or fallback semantics has one place to land
235
- * (kumiko-framework#3026). */
234
+ * future change to timeout or fallback semantics has one place to land. */
236
235
  export declare function resolveAndInjectPageHead(html: string, resolvePageHead: PageHeadResolver, input: PageHeadResolveInput): Promise<string>;
@@ -8,8 +8,8 @@ import { APEX_STRUCTURAL_CSS } from "./css.js";
8
8
  import { APEX_LIGHTBOX_HTML, APEX_LIGHTBOX_SCRIPT } from "./lightbox.js";
9
9
  export { APEX_NAV_MENU_CSS, APEX_NAV_TOGGLE_RESPONSIVE_CSS, APEX_STRUCTURAL_CSS } from "./css.js";
10
10
  export { APEX_LIGHTBOX_HTML, APEX_LIGHTBOX_SCRIPT, APEX_LIGHTBOX_SCRIPT_CSP_HASH, } from "./lightbox.js";
11
- // JSON in <script>-Kontext: `<` als < serialisieren, damit weder
12
- // `</script>` noch `<!--` aus dem Block ausbrechen kann (JSON bleibt valide).
11
+ // JSON in <script> context: serialize `<` as < so neither
12
+ // `</script>` nor `<!--` can escape the block (JSON remains valid).
13
13
  function scriptSafeJsonHtml(value) {
14
14
  return JSON.stringify(value).replace(/</g, "\\u003c");
15
15
  }
@@ -265,7 +265,7 @@ export function renderApexHeadTags(head) {
265
265
  export function renderApexPage(page) {
266
266
  const { head, brand } = page;
267
267
  const theme = page.theme ?? "light";
268
- // Brand-CSS ist app-authored (Trust-Boundary siehe Datei-Header), kein Tenant-Input.
268
+ // Brand CSS is app-authored (see the file header for the trust boundary), not tenant input.
269
269
  const cssHtml = (brand.fontFaceCss ?? "") + brand.tokensCss + APEX_STRUCTURAL_CSS;
270
270
  const sectionsHtml = page.sections.map(renderSection).join("\n\n ");
271
271
  // interactive-widget is ignored by iOS Safari; apex pages have no fixed bottom bars, so
@@ -308,7 +308,7 @@ export function injectPageHead(html, tagsHtml) {
308
308
  // Resolver runs alongside the request, never gates it: a slow, throwing, or
309
309
  // null-returning resolver must never turn a 200 shell into a 500 or a
310
310
  // stall. One constant shared by runProdApp and runDevApp so both time out
311
- // on the same value (kumiko-framework#3026).
311
+ // on the same value.
312
312
  const PAGE_HEAD_TIMEOUT_MS = 300;
313
313
  async function resolvePageHeadWithTimeout(resolvePageHead, input) {
314
314
  let timer;
@@ -367,8 +367,7 @@ function toApexHead(meta) {
367
367
  * the shared timeout. Never throws and never changes `html` on a resolver
368
368
  * error, a `null` resolve, or a timeout — the caller's shell always ships
369
369
  * unchanged. runProdApp and runDevApp both call this single function, so a
370
- * future change to timeout or fallback semantics has one place to land
371
- * (kumiko-framework#3026). */
370
+ * future change to timeout or fallback semantics has one place to land. */
372
371
  export async function resolveAndInjectPageHead(html, resolvePageHead, input) {
373
372
  const meta = await resolvePageHeadWithTimeout(resolvePageHead, input);
374
373
  if (!meta)
@@ -68,7 +68,7 @@ function stripUntouchedEmptyStrings(values, initial, forceIncludeEmpty) {
68
68
  let out;
69
69
  for (const key of Object.keys(v)) {
70
70
  // Value-based seed strip, except keys previously submitted as "" after a
71
- // real clear — those must survive post-rebase submits (#1858).
71
+ // real clear — those must survive post-rebase submits.
72
72
  if (!forceIncludeEmpty.has(key) && v[key] === "" && ini[key] === "") {
73
73
  out ??= { ...v };
74
74
  delete out[key];
@@ -105,7 +105,7 @@ export function createFormController(options) {
105
105
  let initial = { ...options.initial };
106
106
  let errors = Object.freeze({});
107
107
  // Keys last submitted as "" (intentional clear). Survives rebase so a later
108
- // submit of other fields does not drop them (#1858).
108
+ // submit of other fields does not drop them.
109
109
  const forceIncludeEmpty = new Set();
110
110
  // ctx is cast-held as TCtx; `undefined` is valid when callers don't
111
111
  // declare conditional predicates that depend on it.
@@ -166,7 +166,7 @@ export function createFormController(options) {
166
166
  function runValidate(scope, opts) {
167
167
  // See the validate() doc comment in types.ts for the scope contract.
168
168
  // A scope entry matches its exact path and everything below it, but not
169
- // siblings: `address.city` must not pull in `address.zip` (#1898).
169
+ // siblings: `address.city` must not pull in `address.zip`.
170
170
  const isInScope = scope === undefined
171
171
  ? undefined
172
172
  : (path) => scope.some((entry) => path === entry || path.startsWith(`${entry}.`));
@@ -193,8 +193,8 @@ export function createFormController(options) {
193
193
  hiddenFields.add(fieldKey);
194
194
  }
195
195
  // Root-level .refine() issues use path "(root)". Wizard step validate()
196
- // still drops them (#1885); submit() passes includeRoot so RenderEdit
197
- // fields={…} cannot silently skip cross-field rules (#1907).
196
+ // still drops them; submit() passes includeRoot so RenderEdit
197
+ // fields={…} cannot silently skip cross-field rules.
198
198
  const relevantIssues = relevantFieldIssues(zodErrorToFieldIssues(parsed.error), {
199
199
  hiddenFields,
200
200
  ...(scope !== undefined && { scope }),
@@ -217,6 +217,13 @@ export function createFormController(options) {
217
217
  errors = Object.freeze({});
218
218
  invalidate();
219
219
  }
220
+ function runRebaseFields(fields) {
221
+ initial = {
222
+ ...initial,
223
+ ...Object.fromEntries(fields.map((field) => [field, values[field]])),
224
+ };
225
+ invalidate();
226
+ }
220
227
  // Stale-submit-safe rebase. Difference to runRebase: the baseline is
221
228
  // taken from the values that were actually SENT to the server, not
222
229
  // from `values` at the moment the server replied. If the user typed
@@ -296,6 +303,7 @@ export function createFormController(options) {
296
303
  invalidate();
297
304
  },
298
305
  rebase: runRebase,
306
+ rebaseFields: runRebaseFields,
299
307
  setCtx(nextCtx) {
300
308
  // Cast back to TCtx: setCtx's public signature takes unknown so
301
309
  // callers don't need to plumb generics. If conditions depend on ctx
@@ -382,7 +390,7 @@ export function createFormController(options) {
382
390
  const result = await dispatcher.write(submitCfg.type, payload, { requestId });
383
391
  unansweredAttempt = isTransportFailure(result) ? { requestId, fingerprint } : null;
384
392
  if (result.isSuccess) {
385
- // Remember intentional empty clears so post-rebase submits keep them (#1858).
393
+ // Remember intentional empty clears so post-rebase submits keep them.
386
394
  if (payload && typeof payload === "object" && !Array.isArray(payload)) {
387
395
  for (const [k, v] of Object.entries(payload)) {
388
396
  if (v === "")
@@ -33,6 +33,7 @@ export type FormController<TValues extends FormValues> = {
33
33
  validate(scope?: readonly FormScopeKey<TValues>[]): boolean;
34
34
  reset(): void;
35
35
  rebase(): void;
36
+ rebaseFields(fields: readonly string[]): void;
36
37
  setCtx(ctx: unknown): void;
37
38
  submit<TData = unknown>(): Promise<SubmitResult<TData>>;
38
39
  };
@@ -46,8 +46,8 @@ export function zodErrorToFieldIssues(error) {
46
46
  }
47
47
  // The issues that count for validity: hidden fields never block, and a
48
48
  // scope narrows to the listed paths (an entry matches itself and everything
49
- // below it, not siblings — #1898). `includeRoot` keeps "(root)" refine issues
50
- // inside a scope (submit passes it, #1907). Shared by the controller's
49
+ // below it, not siblings). `includeRoot` keeps "(root)" refine issues
50
+ // inside a scope (submit passes it). Shared by the controller's
51
51
  // validate() and RenderEdit's `valid` dry-run so the two cannot drift.
52
52
  export function relevantFieldIssues(issues, opts) {
53
53
  const { hiddenFields, scope, includeRoot } = opts;