@cosmicdrift/kumiko-renderer 0.378.0 → 0.380.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 (79) hide show
  1. package/dist/app/action-form-shim.js +1 -2
  2. package/dist/app/action-form-support.js +16 -4
  3. package/dist/app/kumiko-screen.js +49 -38
  4. package/dist/app/list-empty-content.d.ts +1 -0
  5. package/dist/app/list-empty-content.js +5 -0
  6. package/dist/app/row-actions.d.ts +7 -1
  7. package/dist/app/row-actions.js +6 -1
  8. package/dist/app/secret-mint-body.js +3 -2
  9. package/dist/components/embedded-list-field.js +16 -7
  10. package/dist/components/file-list-field.js +4 -0
  11. package/dist/components/grid-cell-for-field.d.ts +4 -1
  12. package/dist/components/grid-cell-for-field.js +6 -2
  13. package/dist/components/list-empty-state.d.ts +4 -1
  14. package/dist/components/list-empty-state.js +4 -1
  15. package/dist/components/reference-create-dialog.js +1 -1
  16. package/dist/components/related-list-section.js +4 -1
  17. package/dist/components/render-edit-logic.d.ts +1 -1
  18. package/dist/components/render-edit-logic.js +8 -2
  19. package/dist/components/render-edit-types.d.ts +3 -0
  20. package/dist/components/render-edit.js +25 -6
  21. package/dist/components/render-field.d.ts +3 -1
  22. package/dist/components/render-field.js +19 -7
  23. package/dist/components/render-list.d.ts +3 -1
  24. package/dist/components/render-list.js +29 -8
  25. package/dist/components/write-form-section.js +4 -1
  26. package/dist/field-issue-text.d.ts +4 -0
  27. package/dist/field-issue-text.js +14 -0
  28. package/dist/headless/dispatcher/index.d.ts +1 -0
  29. package/dist/headless/dispatcher/index.js +1 -0
  30. package/dist/headless/dispatcher/request-id.d.ts +1 -0
  31. package/dist/headless/dispatcher/request-id.js +15 -0
  32. package/dist/headless/form/form-controller.js +16 -1
  33. package/dist/headless/form/zod-bridge.js +7 -0
  34. package/dist/headless/index.d.ts +2 -1
  35. package/dist/headless/index.js +2 -1
  36. package/dist/headless/plural-key.d.ts +1 -0
  37. package/dist/headless/plural-key.js +5 -0
  38. package/dist/headless/view-model/edit.d.ts +4 -0
  39. package/dist/headless/view-model/edit.js +10 -5
  40. package/dist/headless/view-model/types.d.ts +10 -2
  41. package/dist/hooks/live-refetch-scheduler.d.ts +6 -0
  42. package/dist/hooks/live-refetch-scheduler.js +45 -0
  43. package/dist/hooks/use-query.js +8 -4
  44. package/dist/hooks/use-reference-lookup.d.ts +1 -0
  45. package/dist/hooks/use-reference-lookup.js +6 -3
  46. package/dist/i18n-defaults.js +22 -3
  47. package/dist/index.d.ts +3 -1
  48. package/dist/index.js +3 -1
  49. package/dist/leave-guard-dialog.d.ts +2 -0
  50. package/dist/leave-guard-dialog.js +28 -0
  51. package/dist/leave-guard.d.ts +15 -0
  52. package/dist/leave-guard.js +39 -0
  53. package/dist/live/dispatcher-live.js +1 -16
  54. package/dist/primitives.d.ts +29 -0
  55. package/dist/sse/live-events.d.ts +3 -0
  56. package/dist/sse/live-events.js +3 -0
  57. package/dist/web/app/create-app.js +2 -2
  58. package/dist/web/app/dashboard-body.js +13 -1
  59. package/dist/web/app/nav.js +51 -9
  60. package/dist/web/layout/nav-tree.js +13 -3
  61. package/dist/web/layout/shell-header.js +3 -3
  62. package/dist/web/lib/file-upload-form-data.d.ts +2 -0
  63. package/dist/web/lib/file-upload-form-data.js +2 -0
  64. package/dist/web/lib/upload-error-key.d.ts +7 -0
  65. package/dist/web/lib/upload-error-key.js +23 -0
  66. package/dist/web/lib/upload-file-ref.d.ts +1 -1
  67. package/dist/web/primitives/embedded-list-input.js +5 -4
  68. package/dist/web/primitives/file-upload.d.ts +2 -1
  69. package/dist/web/primitives/file-upload.js +11 -7
  70. package/dist/web/primitives/files-dropzone.d.ts +16 -0
  71. package/dist/web/primitives/files-dropzone.js +52 -0
  72. package/dist/web/primitives/index.js +62 -29
  73. package/dist/web/primitives/segmented-select.js +8 -2
  74. package/dist/web/sse/live-events.d.ts +13 -0
  75. package/dist/web/sse/live-events.js +88 -23
  76. package/dist/web/widgets/upload-zone.d.ts +11 -2
  77. package/dist/web/widgets/upload-zone.js +30 -7
  78. package/package.json +5 -5
  79. package/src/changes.json +104 -0
@@ -254,15 +254,20 @@ function deriveImageHints(fieldDef) {
254
254
  ...(fieldDef.capture !== undefined && { capture: fieldDef.capture }),
255
255
  };
256
256
  }
257
- function deriveFileFieldHints(fieldDef, entityName, fieldName, entityId) {
257
+ function deriveFileFieldHints(fieldDef, entityName, fieldName, entityId, actionFormScreenQn) {
258
258
  if (!isFileFieldType(fieldDef))
259
259
  return {};
260
- const maxCount = fieldDef.type === "files" || fieldDef.type === "images" ? fieldDef.maxCount : undefined;
260
+ const isList = fieldDef.type === "files" || fieldDef.type === "images";
261
+ const maxCount = isList ? fieldDef.maxCount : undefined;
262
+ const fileDisplay = isList ? fieldDef.display : undefined;
261
263
  return {
264
+ ...(fileDisplay !== undefined && { fileDisplay }),
262
265
  ...(fieldDef.accept !== undefined && { accept: fieldDef.accept }),
263
266
  ...(fieldDef.maxSize !== undefined && { maxSize: fieldDef.maxSize }),
264
267
  ...(maxCount !== undefined && { maxCount }),
265
- entityType: entityName,
268
+ ...(actionFormScreenQn !== undefined
269
+ ? { uploadActionForm: actionFormScreenQn }
270
+ : { entityType: entityName }),
266
271
  fieldName,
267
272
  ...(entityId !== undefined && { entityId }),
268
273
  ...deriveImageHints(fieldDef),
@@ -364,7 +369,7 @@ function isLockedByDerivation(derivation, values, field) {
364
369
  return isDerivedAndLocked || isUnusedFallbackOperand(derivation, values, field);
365
370
  }
366
371
  export function computeEditViewModel(input) {
367
- const { screen, entity, values, translate, featureName, mode, entityId, derivation } = input;
372
+ const { screen, entity, values, translate, featureName, mode, entityId, derivation, actionFormScreenQn, } = input;
368
373
  const excludedFields = new Set(mode === undefined ? [] : screen.writeExcludedFields?.[mode]);
369
374
  const isHiddenByExclusion = (fieldSpec) => mode === "create" && excludedFields.has(normalizeEditField(fieldSpec).field);
370
375
  const sections = screen.layout.sections.map((sectionSpec) => {
@@ -428,7 +433,7 @@ export function computeEditViewModel(input) {
428
433
  const dateHints = deriveDateFieldHints(fieldDef);
429
434
  const numericHints = deriveNumericFieldHints(fieldDef, resolvedCurrency);
430
435
  const referenceHints = deriveReferenceFieldHints(fieldDef, normalized, declaredRefTarget, featureName);
431
- const fileHints = deriveFileFieldHints(fieldDef, screen.entity, normalized.field, entityId);
436
+ const fileHints = deriveFileFieldHints(fieldDef, screen.entity, normalized.field, entityId, actionFormScreenQn);
432
437
  const embeddedListHints = deriveEmbeddedListHints(fieldDef, translate, featureName, screen.entity, normalized.field, resolvedCurrency, normalized.embeddedList);
433
438
  const view = {
434
439
  field: normalized.field,
@@ -210,7 +210,12 @@ export type EditFieldViewModel = {
210
210
  * upload POST (`/api/files`), so the endpoint can check maxSize/accept
211
211
  * against the right field def. */
212
212
  readonly entityType?: string;
213
+ /** Only for file fields of an actionForm — the screen QN the upload POST names as
214
+ * `actionForm` instead of `entityType`; the server resolves the field through that screen. */
215
+ readonly uploadActionForm?: string;
213
216
  readonly fieldName?: string;
217
+ /** Only for `type: "files" | "images"` — how the field is shown. Absent = list. */
218
+ readonly fileDisplay?: "list" | "dropzone";
214
219
  /** Record being edited (update mode only). Sent with the upload so the
215
220
  * FileRef is attached to the record. */
216
221
  readonly entityId?: string;
@@ -248,8 +253,11 @@ export type EditFieldViewModel = {
248
253
  readonly op: "multiply" | "sum" | "subtract" | "percent";
249
254
  readonly from: readonly string[];
250
255
  }>>;
251
- /** Numeric sub-field names to sum in a totals row. From EmbeddedFieldDef.totals. */
252
- readonly embeddedListTotals?: readonly string[];
256
+ /** Footer totals: a sub-field name, or a labelled sum of several. From EmbeddedFieldDef.totals. */
257
+ readonly embeddedListTotals?: readonly (string | {
258
+ readonly label: string;
259
+ readonly from: readonly string[];
260
+ })[];
253
261
  /** No add, remove, duplicate or move; cells stay editable. From
254
262
  * EditFieldSpec.embeddedList.fixedRows. */
255
263
  readonly embeddedListFixedRows?: boolean;
@@ -0,0 +1,6 @@
1
+ export declare const LIVE_REFETCH_COALESCE_MS = 250;
2
+ export type LiveRefetchScheduler = {
3
+ readonly trigger: () => void;
4
+ readonly dispose: () => void;
5
+ };
6
+ export declare function createLiveRefetchScheduler(run: () => Promise<void> | void, windowMs?: number): LiveRefetchScheduler;
@@ -0,0 +1,45 @@
1
+ export const LIVE_REFETCH_COALESCE_MS = 250;
2
+ // A burst of live events collapses into one trailing run. An event that lands
3
+ // while a run is still in flight queues exactly one follow-up after it.
4
+ export function createLiveRefetchScheduler(run, windowMs = LIVE_REFETCH_COALESCE_MS) {
5
+ let timer;
6
+ let inFlight = false;
7
+ let rerunQueued = false;
8
+ let disposed = false;
9
+ const fire = async () => {
10
+ timer = undefined;
11
+ inFlight = true;
12
+ try {
13
+ await run();
14
+ }
15
+ finally {
16
+ inFlight = false;
17
+ }
18
+ if (rerunQueued && !disposed) {
19
+ rerunQueued = false;
20
+ await fire();
21
+ }
22
+ };
23
+ return {
24
+ trigger: () => {
25
+ // skip: the subscriber is gone
26
+ if (disposed)
27
+ return;
28
+ if (inFlight) {
29
+ rerunQueued = true;
30
+ // skip: the queued follow-up run covers this event
31
+ return;
32
+ }
33
+ // skip: a trailing run is already scheduled and will cover this event
34
+ if (timer !== undefined)
35
+ return;
36
+ timer = setTimeout(() => void fire(), windowMs);
37
+ },
38
+ dispose: () => {
39
+ disposed = true;
40
+ if (timer !== undefined)
41
+ clearTimeout(timer);
42
+ timer = undefined;
43
+ },
44
+ };
45
+ }
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
2
  import { useDispatcher, useOptionalQueryPools } from "../context/dispatcher-context.js";
3
3
  import { useLiveEvents } from "../sse/live-events.js";
4
+ import { createLiveRefetchScheduler } from "./live-refetch-scheduler.js";
4
5
  // Extract the entity-name from a standard Kumiko query type. Returns
5
6
  // undefined for non-conforming types so the live-mode silently skips
6
7
  // them instead of subscribing to a channel no event will ever match.
@@ -125,9 +126,12 @@ export function useQuery(type, payload, options = {}) {
125
126
  // skip: query type has no mapped entity, nothing to subscribe to
126
127
  if (entity === undefined)
127
128
  return;
128
- return subscribeLive(entity, () => {
129
- void run();
130
- });
131
- }, [live, enabled, type, run, subscribeLive]);
129
+ const scheduler = createLiveRefetchScheduler(() => runFetch(true));
130
+ const unsubscribe = subscribeLive(entity, scheduler.trigger);
131
+ return () => {
132
+ unsubscribe();
133
+ scheduler.dispose();
134
+ };
135
+ }, [live, enabled, type, runFetch, subscribeLive]);
132
136
  return { data, error, loading, refetch: run };
133
137
  }
@@ -18,4 +18,5 @@ export declare function referenceOptionSource(field: {
18
18
  export declare function useReferenceLookup(featureName: string, refEntity: string, labelField: string): {
19
19
  readonly map: ReferenceLookupMap;
20
20
  readonly loading: boolean;
21
+ readonly failed: boolean;
21
22
  };
@@ -66,9 +66,12 @@ export function useReferenceLookup(featureName, refEntity, labelField) {
66
66
  }
67
67
  // System-scope ids (e.g. SYSTEM_TENANT_ID) never have a backing row, so
68
68
  // the bulk lookup above never covers them — inject the label directly.
69
- if (systemLabel !== undefined)
70
- out.set(systemLabel.id, translate(systemLabel.labelKey));
69
+ if (systemLabel !== undefined) {
70
+ for (const id of [systemLabel.id, ...(systemLabel.aliases ?? [])]) {
71
+ out.set(id, translate(systemLabel.labelKey));
72
+ }
73
+ }
71
74
  return out;
72
75
  }, [result.data, labelKey, systemLabel, translate]);
73
- return { map, loading: result.loading };
76
+ return { map, loading: result.loading, failed: result.error !== null };
74
77
  }
@@ -58,7 +58,6 @@ export const kumikoDefaultTranslations = {
58
58
  "kumiko.field.file-list.empty": "No files yet.",
59
59
  "kumiko.field.file-upload.attached": "File attached",
60
60
  "kumiko.field.file-upload.change": "Change",
61
- "kumiko.field.file-upload.failed": "Upload failed",
62
61
  "kumiko.field.file-upload.remove": "Remove",
63
62
  "kumiko.field.file-upload.upload": "Upload",
64
63
  "kumiko.field.embedded-list.add-row": "Add row",
@@ -68,13 +67,17 @@ export const kumikoDefaultTranslations = {
68
67
  "kumiko.field.embedded-list.move-down": "Move down",
69
68
  "kumiko.field.embedded-list.empty": "No rows yet.",
70
69
  "kumiko.field.embedded-list.empty-cta": "Add first row",
71
- "kumiko.field.embedded-list.paste-rows-truncated": "{count} pasted row(s) were dropped (max row count reached).",
72
- "kumiko.field.embedded-list.paste-cells-unmatched": "{count} cell(s) had no matching option and were left unchanged.",
70
+ "kumiko.field.embedded-list.paste-rows-truncated.one": "1 pasted row was dropped (max row count reached).",
71
+ "kumiko.field.embedded-list.paste-rows-truncated.other": "{count} pasted rows were dropped (max row count reached).",
72
+ "kumiko.field.embedded-list.paste-cells-unmatched.one": "1 cell had no matching option and was left unchanged.",
73
+ "kumiko.field.embedded-list.paste-cells-unmatched.other": "{count} cells had no matching option and were left unchanged.",
73
74
  "kumiko.field.multiSelect.select-all": "Select all",
74
75
  "kumiko.field.multiSelect.deselect-all": "Deselect all",
75
76
  "kumiko.list.search-placeholder": "Search…",
76
77
  "kumiko.list.empty.title": "No entries yet.",
77
78
  "kumiko.list.empty.filtered": "No entries match the current search or filters.",
79
+ "kumiko.list.empty.defaultFiltered": "No entries match the default filter. Show all to include entries outside it.",
80
+ "kumiko.list.empty.showAll": "Show all",
78
81
  "kumiko.list.empty.hint": "Create the first one to get started.",
79
82
  "kumiko.list.no-entries": "No entries.",
80
83
  "kumiko.record.not-found.title": "This record was not found.",
@@ -88,6 +91,7 @@ export const kumikoDefaultTranslations = {
88
91
  "kumiko.list.related-list-truncated": "Showing the first {count} entries. More are available but not loaded — this list does not paginate.",
89
92
  "kumiko.reference.system-tenant": "System",
90
93
  "kumiko.reference.system-user": "System",
94
+ "kumiko.reference.unknown-user": "Unknown",
91
95
  "kumiko.pager.status": "{from}–{to} of {total}",
92
96
  "kumiko.pager.pageOf": "Page {page} of {pages}",
93
97
  "kumiko.form.all-required": "All fields are required.",
@@ -158,6 +162,13 @@ export const kumikoDefaultTranslations = {
158
162
  "kumiko.widget.upload.done": "Uploaded",
159
163
  "kumiko.widget.upload.error": "Failed",
160
164
  "kumiko.widget.upload.rejected-type": "File type not allowed",
165
+ "kumiko.widget.upload.too-large": "File is too large",
166
+ "kumiko.widget.upload.invalid-type": "File type or content not allowed",
167
+ "kumiko.widget.upload.max-count": "At most {count} files",
168
+ "kumiko.widget.upload.remove": "Remove file",
169
+ "kumiko.field.dropzone.title": "Drop files here or click to choose",
170
+ "kumiko.field.dropzone.limit-size": "up to {size} per file",
171
+ "kumiko.field.dropzone.limit-count": "up to {count} files",
161
172
  "kumiko.widget.pdf-preview.loading": "Loading PDF…",
162
173
  "kumiko.widget.pdf-preview.pageOf": "Page {page} of {pages}",
163
174
  "kumiko.widget.pdf-preview.renderFailed": "The PDF could not be displayed.",
@@ -258,10 +269,17 @@ export const kumikoDefaultTranslations = {
258
269
  "kumiko.validation.out-of-range": "Value out of allowed range.",
259
270
  "errors.validation.invalid_type": "Invalid value.",
260
271
  "errors.validation.too_small": "Too small or too short (minimum: {minimum}).",
272
+ "errors.validation.list_min_rows.one": "At least 1 row required.",
273
+ "errors.validation.list_min_rows.other": "At least {minimum} rows required.",
261
274
  "errors.validation.too_big": "Too big or too long (maximum: {maximum}).",
262
275
  "kumiko.validation.number.min": "Must be at least {minimum}.",
263
276
  "kumiko.validation.number.max": "Must be {maximum} or less.",
264
277
  "errors.validation.invalid_format": "Invalid format.",
278
+ "errors.validation.invalid_format.email": "Invalid email address, e.g. name@example.com.",
279
+ "errors.validation.invalid_format.url": "Invalid web address, e.g. https://example.com.",
280
+ "errors.validation.invalid_format.date": "Invalid date, e.g. 2026-10-20.",
281
+ "errors.validation.invalid_format.datetime": "Invalid date and time, e.g. 2026-10-20T14:30.",
282
+ "errors.validation.invalid_format.uuid": "Invalid ID, e.g. 123e4567-e89b-12d3-a456-426614174000.",
265
283
  "errors.validation.not_multiple_of": "Must be a multiple of {divisor}.",
266
284
  "errors.validation.unrecognized_keys": "Unknown fields.",
267
285
  "errors.validation.invalid_union": "Invalid value.",
@@ -283,6 +301,7 @@ export const kumikoDefaultTranslations = {
283
301
  "errors.recordLocked": "This record is locked and can no longer be changed.",
284
302
  "errors.uniqueViolation": "This entry already exists.",
285
303
  "errors.unprocessable": "The request could not be processed.",
304
+ "errors.file.violatesBindTarget": "This file does not meet the requirements of the target field.",
286
305
  "errors.unconfigured": "This feature isn't configured yet.",
287
306
  "errors.internal": "Something went wrong. Please try again later.",
288
307
  "errors.rate_limited": "Too many requests. Please try again shortly.",
package/dist/index.d.ts CHANGED
@@ -80,6 +80,8 @@ export type { LocaleProviderProps, TranslationBundle, TranslationsByKey, Transla
80
80
  export { createStaticLocaleResolver, FormalityProvider, LocaleProvider, mergeTranslations, translationsByKeyFromLocales, translationsByLocaleFromKeys, useFormality, useLocale, useOptionalLocale, useOptionalTranslation, useTranslation, } from "./i18n.tsx";
81
81
  export { kumikoDefaultTranslations } from "./i18n-defaults.ts";
82
82
  export { InsideDrawerProvider, useInsideDrawer } from "./inside-drawer.ts";
83
+ export { hasUnsavedInput, registerUnsavedInputSource, requestLeave, setLeaveConfirmer, useUnsavedInputSource, } from "./leave-guard.ts";
84
+ export { LeaveGuardDialog } from "./leave-guard-dialog.tsx";
83
85
  export { PageHeaderCompactProvider, PageHeaderSlotAvailableProvider, usePageHeaderCompact, usePageHeaderSlotAvailable, } from "./page-header-slot.ts";
84
86
  export type { ActionMenuItemSpec, ActionOverflowMenuProps, AppPrimitives, BannerProps, ButtonProps, CardOptions, CardProps, CardSlots, CopyButtonProps, CorePrimitives, DataTableDateRangeFacet, DataTableFacet, DataTableFacetChip, DataTableProps, DataTableRowAction, DataTableRowActionMode, DataTableRowGrouping, DataTableSort, DataTableSortDir, DialogProps, DrawerProps, EmbeddedListCellType, EmbeddedListColumn, EmbeddedListInputProps, EmbeddedListTotal, FieldCellWidth, FieldProps, FileValueListProps, FillContainerProps, FooterActionMarker, FooterActionRole, FormProps, FormSectionNavItem, FormWidth, GridCellProps, GridProps, HeadingProps, InputFillAction, InputProps, JsonViewProps, LightboxImage, LightboxProps, LinkProps, MetricBandProps, MetricProps, ModalProps, PageHeaderProps, PickedFile, PrimitivesProviderProps, PrimitivesRegistry, ProgressProps, ProgressTone, PromoPanelAction, PromoPanelProps, RuntimeRenderer, SecretRevealProps, SecretRevealValue, SectionProps, ShareButtonProps, StatusBadgeProps, StatusTone, StepBarProps, StickyActionBarProps, StickyPrimaryActionMarker, TabsProps, TextProps, TrailingInputAction, WizardStepGroupProps, } from "./primitives.tsx";
85
87
  export { FOOTER_ACTION_ROLE_PROP, NARROW_LABEL_PROP, PrimitivesProvider, STICKY_PRIMARY_ACTION_PROP, shouldRenderActionsIconOnly, statusToneForOptionTone, statusToneForValue, useOptionalPrimitives, usePrimitives, } from "./primitives.tsx";
@@ -88,6 +90,6 @@ export { createSessionEndedSignal, isSessionEndedError, SESSION_ENDED_ERROR_CODE
88
90
  export { buildWhatsAppShareUrl } from "./share-links.ts";
89
91
  export { sortByAccessor } from "./sort-by-accessor.ts";
90
92
  export type { LiveEvent, LiveEventSubscriber, LiveEventsProviderProps, } from "./sse/live-events.tsx";
91
- export { LiveEventsProvider, useLiveEvents } from "./sse/live-events.tsx";
93
+ export { LIVE_RESYNC_EVENT_TYPE, LiveEventsProvider, useLiveEvents } from "./sse/live-events.tsx";
92
94
  export type { AppTokens, ColorTokens, CoreTokens, FontTokens, RadiusTokens, ShadowTokens, SpacingTokens, ThemeMode, ThemePreference, Tokens, TokensApi, TokensProviderProps, } from "./tokens.tsx";
93
95
  export { cssVarTokens, TokensProvider, useTokenController, useTokens } from "./tokens.tsx";
package/dist/index.js CHANGED
@@ -57,10 +57,12 @@ export { useStreamHandler } from "./hooks/use-stream-handler.js";
57
57
  export { createStaticLocaleResolver, FormalityProvider, LocaleProvider, mergeTranslations, translationsByKeyFromLocales, translationsByLocaleFromKeys, useFormality, useLocale, useOptionalLocale, useOptionalTranslation, useTranslation, } from "./i18n.js";
58
58
  export { kumikoDefaultTranslations } from "./i18n-defaults.js";
59
59
  export { InsideDrawerProvider, useInsideDrawer } from "./inside-drawer.js";
60
+ export { hasUnsavedInput, registerUnsavedInputSource, requestLeave, setLeaveConfirmer, useUnsavedInputSource, } from "./leave-guard.js";
61
+ export { LeaveGuardDialog } from "./leave-guard-dialog.js";
60
62
  export { PageHeaderCompactProvider, PageHeaderSlotAvailableProvider, usePageHeaderCompact, usePageHeaderSlotAvailable, } from "./page-header-slot.js";
61
63
  export { FOOTER_ACTION_ROLE_PROP, NARROW_LABEL_PROP, PrimitivesProvider, STICKY_PRIMARY_ACTION_PROP, shouldRenderActionsIconOnly, statusToneForOptionTone, statusToneForValue, useOptionalPrimitives, usePrimitives, } from "./primitives.js";
62
64
  export { createSessionEndedSignal, isSessionEndedError, SESSION_ENDED_ERROR_CODES, SessionEndedSignalProvider, useSessionEndedSignal, withSessionEndedDetection, } from "./session/session-ended.js";
63
65
  export { buildWhatsAppShareUrl } from "./share-links.js";
64
66
  export { sortByAccessor } from "./sort-by-accessor.js";
65
- export { LiveEventsProvider, useLiveEvents } from "./sse/live-events.js";
67
+ export { LIVE_RESYNC_EVENT_TYPE, LiveEventsProvider, useLiveEvents } from "./sse/live-events.js";
66
68
  export { cssVarTokens, TokensProvider, useTokenController, useTokens } from "./tokens.js";
@@ -0,0 +1,2 @@
1
+ import { type ReactNode } from "react";
2
+ export declare function LeaveGuardDialog(): ReactNode;
@@ -0,0 +1,28 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
+ import { useTranslation } from "./i18n.js";
4
+ import { setLeaveConfirmer } from "./leave-guard.js";
5
+ import { usePrimitives } from "./primitives.js";
6
+ export function LeaveGuardDialog() {
7
+ const { Dialog } = usePrimitives();
8
+ const t = useTranslation();
9
+ const [pending, setPending] = useState(null);
10
+ // Dialog fires onOpenChange(false) after onConfirm too; the ref lets that
11
+ // follow-up see the request is already settled instead of cancelling it.
12
+ const pendingRef = useRef(null);
13
+ useEffect(() => setLeaveConfirmer((request) => {
14
+ pendingRef.current = request;
15
+ setPending(request);
16
+ }), []);
17
+ const settle = useCallback((decide) => {
18
+ const request = pendingRef.current;
19
+ pendingRef.current = null;
20
+ setPending(null);
21
+ if (request !== null)
22
+ decide(request);
23
+ }, []);
24
+ return (_jsx(Dialog, { open: pending !== null, onOpenChange: (open) => {
25
+ if (!open)
26
+ settle((request) => request.cancel());
27
+ }, title: t("kumiko.drawer.discard.title"), description: t("kumiko.drawer.discard.body"), confirmLabel: t("kumiko.drawer.discard.confirm"), cancelLabel: t("kumiko.drawer.discard.cancel"), variant: "danger", initialFocus: "cancel", onConfirm: () => settle((request) => request.proceed()), testId: "leave-guard-dialog" }));
28
+ }
@@ -0,0 +1,15 @@
1
+ type UnsavedInputSource = () => boolean;
2
+ type LeaveConfirmer = (decision: {
3
+ readonly proceed: () => void;
4
+ readonly cancel: () => void;
5
+ }) => void;
6
+ export declare function registerUnsavedInputSource(source: UnsavedInputSource): () => void;
7
+ export declare function hasUnsavedInput(): boolean;
8
+ export declare function setLeaveConfirmer(next: LeaveConfirmer): () => void;
9
+ /** Runs `proceed` right away when nothing is dirty (or no dialog host is
10
+ * mounted); otherwise asks the user first. */
11
+ export declare function requestLeave(proceed: () => void, cancel?: () => void): void;
12
+ /** `isDirty` is read lazily, at guard time, so a form can report "clean"
13
+ * synchronously after a successful save — before React re-renders. */
14
+ export declare function useUnsavedInputSource(isDirty: () => boolean): void;
15
+ export {};
@@ -0,0 +1,39 @@
1
+ import { useEffect, useRef } from "react";
2
+ const sources = new Set();
3
+ let confirmer;
4
+ export function registerUnsavedInputSource(source) {
5
+ sources.add(source);
6
+ return () => {
7
+ sources.delete(source);
8
+ };
9
+ }
10
+ export function hasUnsavedInput() {
11
+ for (const source of sources)
12
+ if (source())
13
+ return true;
14
+ return false;
15
+ }
16
+ export function setLeaveConfirmer(next) {
17
+ confirmer = next;
18
+ return () => {
19
+ if (confirmer === next)
20
+ confirmer = undefined;
21
+ };
22
+ }
23
+ /** Runs `proceed` right away when nothing is dirty (or no dialog host is
24
+ * mounted); otherwise asks the user first. */
25
+ export function requestLeave(proceed, cancel = () => { }) {
26
+ if (confirmer === undefined || !hasUnsavedInput()) {
27
+ proceed();
28
+ // skip: nothing to lose, no question needed
29
+ return;
30
+ }
31
+ confirmer({ proceed, cancel });
32
+ }
33
+ /** `isDirty` is read lazily, at guard time, so a form can report "clean"
34
+ * synchronously after a successful save — before React re-renders. */
35
+ export function useUnsavedInputSource(isDirty) {
36
+ const isDirtyRef = useRef(isDirty);
37
+ isDirtyRef.current = isDirty;
38
+ useEffect(() => registerUnsavedInputSource(() => isDirtyRef.current()), []);
39
+ }
@@ -1,5 +1,4 @@
1
- import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
2
- import { createStore, } from "../headless/index.js";
1
+ import { createStore, generateRequestId, } from "../headless/index.js";
3
2
  import { CSRF_HEADER_NAME, readCsrfToken } from "./csrf.js";
4
3
  import { batchEnvelopeSchema, errorOnlyEnvelopeSchema, queryEnvelopeSchema, writeEnvelopeSchema, } from "./envelopes.js";
5
4
  import { buildAbortError, buildNetworkError, mapServerError } from "./error-mapping.js";
@@ -188,20 +187,6 @@ export function createLiveDispatcher(options = {}) {
188
187
  }
189
188
  const EMPTY_PENDING_WRITES = Object.freeze([]);
190
189
  const EMPTY_PENDING_FILES = Object.freeze([]);
191
- // crypto.randomUUID where available (browser, Bun, Node); Math.random
192
- // fallback for React-Native runtimes without the WebCrypto polyfill.
193
- // Uniqueness only needs to hold per user within the server's dedup window —
194
- // this is an idempotency key, not a security token.
195
- function generateRequestId() {
196
- const webCrypto = propertyOf(globalThis, "crypto");
197
- const randomUUID = propertyOf(webCrypto, "randomUUID");
198
- if (typeof randomUUID === "function") {
199
- const id = Reflect.apply(randomUUID, webCrypto, []);
200
- if (typeof id === "string")
201
- return id;
202
- }
203
- return `req-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
204
- }
205
190
  function buildUnexpectedShapeError(status) {
206
191
  // The server did answer: keep its status so 401/5xx handling still applies.
207
192
  return { ...buildNetworkError(`unexpected response shape (${status})`), httpStatus: status };
@@ -341,6 +341,9 @@ export type InputProps = {
341
341
  /** Für den Upload-POST `/api/files` — der Endpoint prüft maxSize/accept
342
342
  * gegen die Field-Def von (entityType, fieldName). */
343
343
  readonly entityType?: string;
344
+ /** Screen QN of the actionForm the field belongs to; sent as `actionForm` instead of
345
+ * `entityType`, so the upload stays pending for that form's field. */
346
+ readonly uploadActionForm?: string;
344
347
  /** Must be sent as the `entityId` part of the upload so the FileRef is
345
348
  * attached to the record (qr overlays and access guards need it). */
346
349
  readonly entityId?: string;
@@ -359,6 +362,26 @@ export type InputProps = {
359
362
  readonly pickLabel?: string;
360
363
  /** Hidden file input gets `${testId}-input`, the pick button `${testId}-pick`. */
361
364
  readonly testId?: string;
365
+ } | {
366
+ /** Drop zone for a `files` / `images` field: the value is the list of uploaded FileRef ids,
367
+ * the implementation offers multi-pick, drag and drop and a remove control per file. */
368
+ readonly kind: "files-dropzone";
369
+ readonly id: string;
370
+ readonly name: string;
371
+ readonly value: readonly string[];
372
+ readonly onChange: (fileIds: readonly string[]) => void;
373
+ readonly disabled?: boolean;
374
+ readonly required?: boolean;
375
+ readonly hasError?: boolean;
376
+ readonly accept?: readonly string[];
377
+ readonly maxSize?: string;
378
+ readonly maxCount?: number;
379
+ /** Upload target, same meaning as on the `file` kind. */
380
+ readonly entityType?: string;
381
+ readonly uploadActionForm?: string;
382
+ readonly entityId?: string;
383
+ readonly fieldName?: string;
384
+ readonly testId?: string;
362
385
  } | {
363
386
  readonly kind: "date";
364
387
  readonly id: string;
@@ -824,6 +847,9 @@ export type EmbeddedListTotal = {
824
847
  readonly field: string;
825
848
  readonly label: string;
826
849
  readonly value: number;
850
+ /** Set for labelled multi-column totals, which have no single column to
851
+ * read the money type from. */
852
+ readonly money?: boolean;
827
853
  };
828
854
  /** Row-array + totals table for a `createEmbeddedListField` (invoice-
829
855
  * positions-style) field — structurally too different from `Input`'s
@@ -890,6 +916,9 @@ export type FormSectionNavItem = {
890
916
  * end of the card (normal document flow). */
891
917
  export type FormProps = {
892
918
  readonly onSubmit: (e?: FormEvent) => void;
919
+ /** Bumped by the host each time a submit fails on field errors; the form
920
+ * then moves focus to its first invalid control. Native forms may ignore it. */
921
+ readonly focusFirstErrorKey?: number;
893
922
  readonly children: ReactNode;
894
923
  readonly title?: ReactNode;
895
924
  /** Optionaler muted Untertitel unter dem Titel (z.B. "Add a new item to
@@ -11,6 +11,9 @@ export type LiveEvent = {
11
11
  readonly createdAt: string;
12
12
  };
13
13
  };
14
+ /** Synthetic eventType delivered to every subscribed entity after the stream
15
+ * reconnected: frames during the gap are lost, so consumers must refetch. */
16
+ export declare const LIVE_RESYNC_EVENT_TYPE = "live.resync";
14
17
  /** Abonniert Live-Events für eine Entity (aggregateType). Returnt die
15
18
  * Unsubscribe-Funktion. Mehrere Subscriptions parallel auf dieselbe
16
19
  * Entity sind erlaubt — jede bekommt ihr eigenes Event. */
@@ -11,6 +11,9 @@ import { jsx as _jsx } from "react/jsx-runtime";
11
11
  // beim Native-Renderer wo die Verbindungs-Lifecycle oft nicht dem
12
12
  // App-Lifecycle entspricht.
13
13
  import { createContext, useContext } from "react";
14
+ /** Synthetic eventType delivered to every subscribed entity after the stream
15
+ * reconnected: frames during the gap are lost, so consumers must refetch. */
16
+ export const LIVE_RESYNC_EVENT_TYPE = "live.resync";
14
17
  // Wenn kein Provider da ist, liefern wir einen No-op-Subscriber statt
15
18
  // zu crashen. Grund: useQuery({ live: true }) kann optimistisch
16
19
  // ausgehakt werden ohne den ganzen Baum abzureißen, wenn die Plattform
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { isOpenToAllGranted } from "@cosmicdrift/kumiko-framework/ui-types";
3
3
  import { createContext, useContext, useEffect, useMemo, useRef, useState, } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
- import { AppFeaturesProvider, ClientActionsProvider, ColumnRenderersProvider, ContentEditorsProvider, CustomScreensProvider, createSessionEndedSignal, DashboardBodyProvider, DispatcherProvider, DraftStorageProvider, ErrorBoundary, ExtensionSectionsProvider, hasDetailScreen, KumikoScreen, kumikoDefaultTranslations, LiveEventsProvider, LocaleProvider, mergeTranslations, NavProvider, navigateWithReturnTo, PrimitivesProvider, qualifyScreenId, ScreenMissingState, SessionEndedSignalProvider, TokensProvider, toAppSchema, translationsByLocaleFromKeys, useNav, usePrimitives, useTranslation, withSessionEndedDetection, } from "../../index.js";
5
+ import { AppFeaturesProvider, ClientActionsProvider, ColumnRenderersProvider, ContentEditorsProvider, CustomScreensProvider, createSessionEndedSignal, DashboardBodyProvider, DispatcherProvider, DraftStorageProvider, ErrorBoundary, ExtensionSectionsProvider, hasDetailScreen, KumikoScreen, kumikoDefaultTranslations, LeaveGuardDialog, LiveEventsProvider, LocaleProvider, mergeTranslations, NavProvider, navigateWithReturnTo, PrimitivesProvider, qualifyScreenId, ScreenMissingState, SessionEndedSignalProvider, TokensProvider, toAppSchema, translationsByLocaleFromKeys, useNav, usePrimitives, useTranslation, withSessionEndedDetection, } from "../../index.js";
6
6
  import { createLiveDispatcher } from "../../live/index.js";
7
7
  import { lastSegment } from "../layout/nav-tree.js";
8
8
  import { defaultPrimitives, ScreenWidthProvider } from "../primitives/index.js";
@@ -237,7 +237,7 @@ function KumikoAppRoot(props) {
237
237
  warnMissingCustomScreens(loaded, props.customScreens);
238
238
  setApp(loaded);
239
239
  }, schemaIsRoleProjected: isFetchMode, ...(isFetchMode && { onUnmountResetSchema: () => setApp(undefined) }), ...(props.translate !== undefined && { translate: props.translate }), ...(props.onRowClick !== undefined && { onRowClick: props.onRowClick }), ...(props.shell !== undefined && { shell: props.shell }) }));
240
- return (_jsx(TokensBoot, { children: _jsxs(LocaleProvider, { resolver: props.localeResolver, fallbackBundles: fallbackBundles, children: [_jsx(DocumentLangSync, { resolver: props.localeResolver }), _jsx(PrimitivesProvider, { value: props.primitives, children: _jsx(ScreenWidthProvider, { width: props.screenWidth, children: _jsx(AppFeaturesProvider, { features: app?.features ?? EMPTY_FEATURES, children: _jsx(DispatcherProvider, { dispatcher: props.dispatcher, children: _jsx(DraftStorageProvider, { value: props.draftStorage, children: _jsx(LiveEventsProvider, { value: props.liveEvents, children: _jsx(DashboardBodyProvider, { value: WebDashboardBody, children: _jsx(CustomScreensProvider, { value: props.customScreens, children: _jsx(ColumnRenderersProvider, { value: props.columnRenderers, children: _jsx(ContentEditorsProvider, { value: props.contentEditors, children: _jsx(ExtensionSectionsProvider, { value: props.extensionSectionComponents, children: _jsx(ClientActionsProvider, { value: props.clientActions, children: _jsx(NavProvidersProvider, { value: navProviders, entities: navEntities, children: _jsx(ResolversProvider, { resolvers: props.resolvers, children: _jsx(ToastProvider, { children: _jsxs(ToastActionNotifier, { children: [_jsx(UpdateChecker, {}), _jsx(SessionEndedSignalProvider, { signal: props.sessionEndedSignal, children: stackWrappers(props.providers, stackWrappers(props.gates, screenNode)) })] }) }) }) }) }) }) }) }) }) }) }) }) }) }) }) })] }) }));
240
+ return (_jsx(TokensBoot, { children: _jsxs(LocaleProvider, { resolver: props.localeResolver, fallbackBundles: fallbackBundles, children: [_jsx(DocumentLangSync, { resolver: props.localeResolver }), _jsx(PrimitivesProvider, { value: props.primitives, children: _jsx(ScreenWidthProvider, { width: props.screenWidth, children: _jsx(AppFeaturesProvider, { features: app?.features ?? EMPTY_FEATURES, children: _jsx(DispatcherProvider, { dispatcher: props.dispatcher, children: _jsx(DraftStorageProvider, { value: props.draftStorage, children: _jsx(LiveEventsProvider, { value: props.liveEvents, children: _jsx(DashboardBodyProvider, { value: WebDashboardBody, children: _jsx(CustomScreensProvider, { value: props.customScreens, children: _jsx(ColumnRenderersProvider, { value: props.columnRenderers, children: _jsx(ContentEditorsProvider, { value: props.contentEditors, children: _jsx(ExtensionSectionsProvider, { value: props.extensionSectionComponents, children: _jsx(ClientActionsProvider, { value: props.clientActions, children: _jsx(NavProvidersProvider, { value: navProviders, entities: navEntities, children: _jsx(ResolversProvider, { resolvers: props.resolvers, children: _jsx(ToastProvider, { children: _jsxs(ToastActionNotifier, { children: [_jsx(UpdateChecker, {}), _jsx(LeaveGuardDialog, {}), _jsx(SessionEndedSignalProvider, { signal: props.sessionEndedSignal, children: stackWrappers(props.providers, stackWrappers(props.gates, screenNode)) })] }) }) }) }) }) }) }) }) }) }) }) }) }) }) }) })] }) }));
241
241
  }
242
242
  // Sits INSIDE the clientFeature gates (e.g. an auth gate) — a gate that
243
243
  // renders a login placeholder instead of children keeps this component
@@ -4,7 +4,7 @@ import { DASHBOARD_STAT_TONES } from "@cosmicdrift/kumiko-types/screen";
4
4
  import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
5
5
  import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react";
6
6
  import { currencyDecimals, EMPTY_VALUE_PLACEHOLDER } from "../../headless/index.js";
7
- import { dispatcherErrorText, EmbeddedScreenProvider, evalVisibleWhen, extensionSectionName, HeaderActionsBar, KumikoScreen, useEmbeddedScreen, useExtensionSectionComponent, useLocale, useNav, usePageHeaderCompact, usePageHeaderSlotAvailable, usePrimitives, useQuery, useRecordActions, useTranslation, } from "../../index.js";
7
+ import { dispatcherErrorText, EmbeddedScreenProvider, evalVisibleWhen, extensionSectionName, HeaderActionsBar, KumikoScreen, lastSegment, useEmbeddedScreen, useExtensionSectionComponent, useLocale, useNav, usePageHeaderCompact, usePageHeaderSlotAvailable, usePrimitives, useQuery, useRecordActions, useTranslation, useUserRoles, } from "../../index.js";
8
8
  import { cn } from "../lib/cn.js";
9
9
  import { EmbeddedFormProvider } from "../primitives/index.js";
10
10
  import { PageSection } from "../primitives/layout.js";
@@ -527,8 +527,20 @@ function TextBlockView({ block, translate, }) {
527
527
  return (_jsx("pre", { "data-block": "code", className: "overflow-x-auto rounded-md bg-muted p-3 text-xs", children: _jsx("code", { children: block.code }) }));
528
528
  case "hint":
529
529
  return (_jsx("p", { "data-block": "hint", className: "text-xs text-muted-foreground", children: translate(block.text) }));
530
+ case "link":
531
+ return _jsx(TextBlockLink, { block: block, translate: translate });
530
532
  }
531
533
  }
534
+ function TextBlockLink({ block, translate, }) {
535
+ const { Link } = usePrimitives();
536
+ const nav = useNav();
537
+ const userRoles = useUserRoles();
538
+ if (block.roles !== undefined && !block.roles.some((role) => userRoles?.includes(role))) {
539
+ return null;
540
+ }
541
+ const target = { screenId: lastSegment(block.screen) };
542
+ return (_jsx(Link, { href: nav.hrefFor(target), onPress: () => nav.navigate(target), testId: `dashboard-text-link-${target.screenId}`, children: translate(block.text) }));
543
+ }
532
544
  function textBlockKey(block) {
533
545
  switch (block.kind) {
534
546
  case "list":