@cosmicdrift/kumiko-renderer 0.377.0 → 0.379.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 (107) 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 +51 -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/form-derivation-patch.d.ts +8 -0
  12. package/dist/components/form-derivation-patch.js +24 -0
  13. package/dist/components/form-screen-layout.d.ts +13 -0
  14. package/dist/components/form-screen-layout.js +13 -0
  15. package/dist/components/grid-cell-for-field.d.ts +4 -1
  16. package/dist/components/grid-cell-for-field.js +6 -2
  17. package/dist/components/list-empty-state.d.ts +4 -1
  18. package/dist/components/list-empty-state.js +4 -1
  19. package/dist/components/reference-create-dialog.js +1 -1
  20. package/dist/components/related-list-section.js +4 -1
  21. package/dist/components/render-edit-logic.d.ts +1 -1
  22. package/dist/components/render-edit-logic.js +8 -2
  23. package/dist/components/render-edit-types.d.ts +7 -1
  24. package/dist/components/render-edit.js +38 -6
  25. package/dist/components/render-field.d.ts +3 -1
  26. package/dist/components/render-field.js +27 -8
  27. package/dist/components/render-list.d.ts +3 -1
  28. package/dist/components/render-list.js +29 -8
  29. package/dist/components/write-form-section.js +4 -1
  30. package/dist/field-issue-text.d.ts +4 -0
  31. package/dist/field-issue-text.js +14 -0
  32. package/dist/file-ids-of.d.ts +1 -0
  33. package/dist/file-ids-of.js +7 -0
  34. package/dist/flow-diagram/icons.d.ts +3 -0
  35. package/dist/flow-diagram/icons.js +14 -0
  36. package/dist/flow-diagram/index.d.ts +2 -0
  37. package/dist/flow-diagram/index.js +1 -0
  38. package/dist/flow-diagram/render.d.ts +3 -0
  39. package/dist/flow-diagram/render.js +234 -0
  40. package/dist/flow-diagram/types.d.ts +18 -0
  41. package/dist/flow-diagram/types.js +1 -0
  42. package/dist/headless/dispatcher/index.d.ts +1 -0
  43. package/dist/headless/dispatcher/index.js +1 -0
  44. package/dist/headless/dispatcher/request-id.d.ts +1 -0
  45. package/dist/headless/dispatcher/request-id.js +15 -0
  46. package/dist/headless/form/form-controller.js +16 -1
  47. package/dist/headless/form/zod-bridge.js +7 -0
  48. package/dist/headless/index.d.ts +2 -1
  49. package/dist/headless/index.js +2 -1
  50. package/dist/headless/plural-key.d.ts +1 -0
  51. package/dist/headless/plural-key.js +5 -0
  52. package/dist/headless/view-model/edit.d.ts +8 -1
  53. package/dist/headless/view-model/edit.js +23 -7
  54. package/dist/headless/view-model/types.d.ts +11 -3
  55. package/dist/hooks/live-refetch-scheduler.d.ts +6 -0
  56. package/dist/hooks/live-refetch-scheduler.js +45 -0
  57. package/dist/hooks/use-query.js +8 -4
  58. package/dist/hooks/use-reference-lookup.d.ts +1 -0
  59. package/dist/hooks/use-reference-lookup.js +6 -3
  60. package/dist/i18n-defaults.js +28 -3
  61. package/dist/index.d.ts +6 -2
  62. package/dist/index.js +4 -1
  63. package/dist/leave-guard-dialog.d.ts +2 -0
  64. package/dist/leave-guard-dialog.js +28 -0
  65. package/dist/leave-guard.d.ts +15 -0
  66. package/dist/leave-guard.js +39 -0
  67. package/dist/live/dispatcher-live.js +1 -16
  68. package/dist/primitives.d.ts +39 -0
  69. package/dist/sse/live-events.d.ts +3 -0
  70. package/dist/sse/live-events.js +3 -0
  71. package/dist/web/app/create-app.js +2 -2
  72. package/dist/web/app/dashboard-body.js +13 -1
  73. package/dist/web/app/nav.js +51 -9
  74. package/dist/web/index.d.ts +2 -2
  75. package/dist/web/index.js +1 -1
  76. package/dist/web/layout/nav-tree.js +13 -3
  77. package/dist/web/layout/shell-header.js +3 -3
  78. package/dist/web/lib/file-upload-form-data.d.ts +2 -0
  79. package/dist/web/lib/file-upload-form-data.js +2 -0
  80. package/dist/web/lib/upload-error-key.d.ts +7 -0
  81. package/dist/web/lib/upload-error-key.js +23 -0
  82. package/dist/web/lib/upload-file-ref.d.ts +1 -1
  83. package/dist/web/primitives/embedded-list-input.js +5 -4
  84. package/dist/web/primitives/file-thumbnail.d.ts +6 -0
  85. package/dist/web/primitives/file-thumbnail.js +19 -19
  86. package/dist/web/primitives/file-upload.d.ts +2 -1
  87. package/dist/web/primitives/file-upload.js +11 -7
  88. package/dist/web/primitives/file-value-list.d.ts +3 -0
  89. package/dist/web/primitives/file-value-list.js +22 -0
  90. package/dist/web/primitives/files-dropzone.d.ts +16 -0
  91. package/dist/web/primitives/files-dropzone.js +52 -0
  92. package/dist/web/primitives/index.js +65 -30
  93. package/dist/web/primitives/segmented-select.js +8 -2
  94. package/dist/web/sse/live-events.d.ts +13 -0
  95. package/dist/web/sse/live-events.js +88 -23
  96. package/dist/web/widgets/bounded-load-cache.d.ts +1 -0
  97. package/dist/web/widgets/bounded-load-cache.js +25 -0
  98. package/dist/web/widgets/drawer.js +37 -2
  99. package/dist/web/widgets/index.d.ts +1 -0
  100. package/dist/web/widgets/index.js +1 -0
  101. package/dist/web/widgets/pdf-preview.d.ts +10 -0
  102. package/dist/web/widgets/pdf-preview.js +140 -0
  103. package/dist/web/widgets/upload-zone.d.ts +11 -2
  104. package/dist/web/widgets/upload-zone.js +30 -7
  105. package/package.json +14 -5
  106. package/src/changes.json +143 -0
  107. package/src/web/styles.css +13 -9
@@ -40,6 +40,9 @@ export const kumikoDefaultTranslations = {
40
40
  "kumiko.field.locatedTzHint": "Time local to the given location",
41
41
  "kumiko.field.reference-created-no-id": "Record was created but could not be selected automatically. Please select it manually.",
42
42
  "kumiko.field.unsupported": "This field type can't be edited here yet.",
43
+ "kumiko.field.file-list.download": "Download",
44
+ "kumiko.field.file-list.hidePreview": "Hide preview",
45
+ "kumiko.field.file-list.preview": "Preview",
43
46
  "kumiko.field.fillAction.failed": "Could not fetch a suggestion.",
44
47
  "kumiko.field.writeOnly.remove": "Remove stored value",
45
48
  "kumiko.field.writeOnly.set": "Set",
@@ -55,7 +58,6 @@ export const kumikoDefaultTranslations = {
55
58
  "kumiko.field.file-list.empty": "No files yet.",
56
59
  "kumiko.field.file-upload.attached": "File attached",
57
60
  "kumiko.field.file-upload.change": "Change",
58
- "kumiko.field.file-upload.failed": "Upload failed",
59
61
  "kumiko.field.file-upload.remove": "Remove",
60
62
  "kumiko.field.file-upload.upload": "Upload",
61
63
  "kumiko.field.embedded-list.add-row": "Add row",
@@ -65,13 +67,17 @@ export const kumikoDefaultTranslations = {
65
67
  "kumiko.field.embedded-list.move-down": "Move down",
66
68
  "kumiko.field.embedded-list.empty": "No rows yet.",
67
69
  "kumiko.field.embedded-list.empty-cta": "Add first row",
68
- "kumiko.field.embedded-list.paste-rows-truncated": "{count} pasted row(s) were dropped (max row count reached).",
69
- "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.",
70
74
  "kumiko.field.multiSelect.select-all": "Select all",
71
75
  "kumiko.field.multiSelect.deselect-all": "Deselect all",
72
76
  "kumiko.list.search-placeholder": "Search…",
73
77
  "kumiko.list.empty.title": "No entries yet.",
74
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",
75
81
  "kumiko.list.empty.hint": "Create the first one to get started.",
76
82
  "kumiko.list.no-entries": "No entries.",
77
83
  "kumiko.record.not-found.title": "This record was not found.",
@@ -85,6 +91,7 @@ export const kumikoDefaultTranslations = {
85
91
  "kumiko.list.related-list-truncated": "Showing the first {count} entries. More are available but not loaded — this list does not paginate.",
86
92
  "kumiko.reference.system-tenant": "System",
87
93
  "kumiko.reference.system-user": "System",
94
+ "kumiko.reference.unknown-user": "Unknown",
88
95
  "kumiko.pager.status": "{from}–{to} of {total}",
89
96
  "kumiko.pager.pageOf": "Page {page} of {pages}",
90
97
  "kumiko.form.all-required": "All fields are required.",
@@ -155,6 +162,16 @@ export const kumikoDefaultTranslations = {
155
162
  "kumiko.widget.upload.done": "Uploaded",
156
163
  "kumiko.widget.upload.error": "Failed",
157
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",
172
+ "kumiko.widget.pdf-preview.loading": "Loading PDF…",
173
+ "kumiko.widget.pdf-preview.pageOf": "Page {page} of {pages}",
174
+ "kumiko.widget.pdf-preview.renderFailed": "The PDF could not be displayed.",
158
175
  "kumiko.widget.step-bar.done": "Done",
159
176
  "kumiko.widget.drawer.restore": "Restore drawer width",
160
177
  "kumiko.widget.drawer.maximize": "Maximize drawer width",
@@ -252,10 +269,17 @@ export const kumikoDefaultTranslations = {
252
269
  "kumiko.validation.out-of-range": "Value out of allowed range.",
253
270
  "errors.validation.invalid_type": "Invalid value.",
254
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.",
255
274
  "errors.validation.too_big": "Too big or too long (maximum: {maximum}).",
256
275
  "kumiko.validation.number.min": "Must be at least {minimum}.",
257
276
  "kumiko.validation.number.max": "Must be {maximum} or less.",
258
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.",
259
283
  "errors.validation.not_multiple_of": "Must be a multiple of {divisor}.",
260
284
  "errors.validation.unrecognized_keys": "Unknown fields.",
261
285
  "errors.validation.invalid_union": "Invalid value.",
@@ -277,6 +301,7 @@ export const kumikoDefaultTranslations = {
277
301
  "errors.recordLocked": "This record is locked and can no longer be changed.",
278
302
  "errors.uniqueViolation": "This entry already exists.",
279
303
  "errors.unprocessable": "The request could not be processed.",
304
+ "errors.file.violatesBindTarget": "This file does not meet the requirements of the target field.",
280
305
  "errors.unconfigured": "This feature isn't configured yet.",
281
306
  "errors.internal": "Something went wrong. Please try again later.",
282
307
  "errors.rate_limited": "Too many requests. Please try again shortly.",
package/dist/index.d.ts CHANGED
@@ -39,6 +39,8 @@ export { evalVisibleWhen, type VisibleWhenVerdict } from "./app/visible-when.ts"
39
39
  export { dispatcherErrorText, WriteFailedError } from "./app/write-failed-error.ts";
40
40
  export type { ErrorBoundaryFallbackProps, ErrorBoundaryProps, } from "./components/error-boundary.tsx";
41
41
  export { ErrorBoundary } from "./components/error-boundary.tsx";
42
+ export type { FormScreenLayoutProps } from "./components/form-screen-layout.tsx";
43
+ export { FormScreenLayout } from "./components/form-screen-layout.tsx";
42
44
  export { RelatedListSection } from "./components/related-list-section.tsx";
43
45
  export type { RenderEditAction, RenderEditChangeState, RenderEditControls, RenderEditProps, } from "./components/render-edit.tsx";
44
46
  export { RenderEdit } from "./components/render-edit.tsx";
@@ -78,14 +80,16 @@ export type { LocaleProviderProps, TranslationBundle, TranslationsByKey, Transla
78
80
  export { createStaticLocaleResolver, FormalityProvider, LocaleProvider, mergeTranslations, translationsByKeyFromLocales, translationsByLocaleFromKeys, useFormality, useLocale, useOptionalLocale, useOptionalTranslation, useTranslation, } from "./i18n.tsx";
79
81
  export { kumikoDefaultTranslations } from "./i18n-defaults.ts";
80
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";
81
85
  export { PageHeaderCompactProvider, PageHeaderSlotAvailableProvider, usePageHeaderCompact, usePageHeaderSlotAvailable, } from "./page-header-slot.ts";
82
- export type { ActionMenuItemSpec, ActionOverflowMenuProps, AppPrimitives, BannerProps, ButtonProps, CardOptions, CardProps, CardSlots, CopyButtonProps, CorePrimitives, DataTableDateRangeFacet, DataTableFacet, DataTableFacetChip, DataTableProps, DataTableRowAction, DataTableRowActionMode, DataTableRowGrouping, DataTableSort, DataTableSortDir, DialogProps, DrawerProps, EmbeddedListCellType, EmbeddedListColumn, EmbeddedListInputProps, EmbeddedListTotal, FieldCellWidth, FieldProps, FillContainerProps, 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";
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";
83
87
  export { FOOTER_ACTION_ROLE_PROP, NARROW_LABEL_PROP, PrimitivesProvider, STICKY_PRIMARY_ACTION_PROP, shouldRenderActionsIconOnly, statusToneForOptionTone, statusToneForValue, useOptionalPrimitives, usePrimitives, } from "./primitives.tsx";
84
88
  export type { SessionEndedSignal, SessionEndedSignalProviderProps, } from "./session/session-ended.tsx";
85
89
  export { createSessionEndedSignal, isSessionEndedError, SESSION_ENDED_ERROR_CODES, SessionEndedSignalProvider, useSessionEndedSignal, withSessionEndedDetection, } from "./session/session-ended.tsx";
86
90
  export { buildWhatsAppShareUrl } from "./share-links.ts";
87
91
  export { sortByAccessor } from "./sort-by-accessor.ts";
88
92
  export type { LiveEvent, LiveEventSubscriber, LiveEventsProviderProps, } from "./sse/live-events.tsx";
89
- export { LiveEventsProvider, useLiveEvents } from "./sse/live-events.tsx";
93
+ export { LIVE_RESYNC_EVENT_TYPE, LiveEventsProvider, useLiveEvents } from "./sse/live-events.tsx";
90
94
  export type { AppTokens, ColorTokens, CoreTokens, FontTokens, RadiusTokens, ShadowTokens, SpacingTokens, ThemeMode, ThemePreference, Tokens, TokensApi, TokensProviderProps, } from "./tokens.tsx";
91
95
  export { cssVarTokens, TokensProvider, useTokenController, useTokens } from "./tokens.tsx";
package/dist/index.js CHANGED
@@ -32,6 +32,7 @@ export { VariableChips } from "./app/variable-chips.js";
32
32
  export { evalVisibleWhen } from "./app/visible-when.js";
33
33
  export { dispatcherErrorText, WriteFailedError } from "./app/write-failed-error.js";
34
34
  export { ErrorBoundary } from "./components/error-boundary.js";
35
+ export { FormScreenLayout } from "./components/form-screen-layout.js";
35
36
  export { RelatedListSection } from "./components/related-list-section.js";
36
37
  export { RenderEdit } from "./components/render-edit.js";
37
38
  export { needsActionConfirm } from "./components/render-edit-action-button.js";
@@ -56,10 +57,12 @@ export { useStreamHandler } from "./hooks/use-stream-handler.js";
56
57
  export { createStaticLocaleResolver, FormalityProvider, LocaleProvider, mergeTranslations, translationsByKeyFromLocales, translationsByLocaleFromKeys, useFormality, useLocale, useOptionalLocale, useOptionalTranslation, useTranslation, } from "./i18n.js";
57
58
  export { kumikoDefaultTranslations } from "./i18n-defaults.js";
58
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";
59
62
  export { PageHeaderCompactProvider, PageHeaderSlotAvailableProvider, usePageHeaderCompact, usePageHeaderSlotAvailable, } from "./page-header-slot.js";
60
63
  export { FOOTER_ACTION_ROLE_PROP, NARROW_LABEL_PROP, PrimitivesProvider, STICKY_PRIMARY_ACTION_PROP, shouldRenderActionsIconOnly, statusToneForOptionTone, statusToneForValue, useOptionalPrimitives, usePrimitives, } from "./primitives.js";
61
64
  export { createSessionEndedSignal, isSessionEndedError, SESSION_ENDED_ERROR_CODES, SessionEndedSignalProvider, useSessionEndedSignal, withSessionEndedDetection, } from "./session/session-ended.js";
62
65
  export { buildWhatsAppShareUrl } from "./share-links.js";
63
66
  export { sortByAccessor } from "./sort-by-accessor.js";
64
- export { LiveEventsProvider, useLiveEvents } from "./sse/live-events.js";
67
+ export { LIVE_RESYNC_EVENT_TYPE, LiveEventsProvider, useLiveEvents } from "./sse/live-events.js";
65
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
@@ -1346,6 +1375,12 @@ export type JsonViewProps = {
1346
1375
  readonly indent?: number;
1347
1376
  readonly testId?: string;
1348
1377
  };
1378
+ /** Read-only list of stored files (FileRef ids): name, download link and, for
1379
+ * PDFs, an inline preview. Used for `renderer: { format: "file" }` on a detail field. */
1380
+ export type FileValueListProps = {
1381
+ readonly fileIds: readonly string[];
1382
+ readonly testId?: string;
1383
+ };
1349
1384
  /** Band above the tabs of a record detail: optional subtitle line plus the
1350
1385
  * metrics as a definition list. `children` are `Metric` tiles. */
1351
1386
  export type MetricBandProps = {
@@ -1528,6 +1563,10 @@ export type CorePrimitives = {
1528
1563
  * CorePrimitives mocks in tests keep compiling — additive rollout of
1529
1564
  * a new primitive shouldn't force every test double to grow a stub. */
1530
1565
  readonly JsonView?: ComponentType<JsonViewProps>;
1566
+ /** Optional (unlike the other Core-Primitives) so existing partial
1567
+ * CorePrimitives mocks in tests keep compiling — additive rollout of
1568
+ * a new primitive shouldn't force every test double to grow a stub. */
1569
+ readonly FileValueList?: ComponentType<FileValueListProps>;
1531
1570
  /** Optional (unlike the other Core-Primitives) so existing partial
1532
1571
  * CorePrimitives mocks in tests keep compiling — additive rollout of
1533
1572
  * a new primitive shouldn't force every test double to grow a stub. */
@@ -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":
@@ -11,7 +11,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
11
11
  // Event-Bus). Hand-rolled Listener-Set ist hier idiomatisch: zwei
12
12
  // Notify-Trigger (eigenes pushPath, popstate-Event), nicht generalisierbar.
13
13
  import { useCallback, useMemo, useSyncExternalStore, } from "react";
14
- import { formatPath, parsePath, resolveTarget, useNav, } from "../../index.js";
14
+ import { formatPath, hasUnsavedInput, parsePath, requestLeave, resolveTarget, useNav, } from "../../index.js";
15
15
  // basePath erlaubt es, die App unter einem URL-Prefix zu mounten
16
16
  // (z.B. `/admin`). Read-Pfad strippt den Prefix vor dem parsePath,
17
17
  // Write-Pfad prepend'd ihn vor dem pushState/replaceState/href.
@@ -54,6 +54,12 @@ function prependBasePath(path, basePath) {
54
54
  // läuft über einen window-Listener, den wir einmalig verdrahten.
55
55
  const listeners = new Set();
56
56
  let popstateWired = false;
57
+ // URL the app currently shows; popstate has already moved the browser to the
58
+ // target by the time we can ask, so a declined leave must restore this one.
59
+ let committedUrl = "";
60
+ function readUrl() {
61
+ return `${window.location.pathname}${window.location.search}${window.location.hash}`;
62
+ }
57
63
  // Stable identity for the omitted-features case — `?? []` would create a
58
64
  // fresh array every render, busting the useMemo below on every render for
59
65
  // every existing useBrowserNavApi() call site (none of which pass features).
@@ -64,13 +70,35 @@ function ensurePopstateWired() {
64
70
  if (typeof window === "undefined")
65
71
  return;
66
72
  window.addEventListener("popstate", () => {
67
- for (const l of listeners)
68
- l();
73
+ if (!hasUnsavedInput()) {
74
+ committedUrl = readUrl();
75
+ for (const l of listeners)
76
+ l();
77
+ return;
78
+ }
79
+ const targetUrl = readUrl();
80
+ window.history.pushState(null, "", committedUrl);
81
+ requestLeave(() => {
82
+ // replaceState, not history.back(): the declined entry is already ours
83
+ // and a forward navigation would otherwise land on the wrong entry.
84
+ window.history.replaceState(null, "", targetUrl);
85
+ committedUrl = targetUrl;
86
+ for (const l of listeners)
87
+ l();
88
+ });
89
+ });
90
+ window.addEventListener("beforeunload", (event) => {
91
+ if (!hasUnsavedInput())
92
+ return;
93
+ event.preventDefault();
94
+ event.returnValue = "";
69
95
  });
70
96
  popstateWired = true;
71
97
  }
72
98
  function subscribe(listener) {
73
99
  ensurePopstateWired();
100
+ if (typeof window !== "undefined" && listeners.size === 0)
101
+ committedUrl = readUrl();
74
102
  listeners.add(listener);
75
103
  return () => {
76
104
  listeners.delete(listener);
@@ -118,6 +146,7 @@ function applySearchParamUpdates(updates) {
118
146
  return;
119
147
  const url = `${window.location.pathname}${nextSearch}${window.location.hash}`;
120
148
  window.history.replaceState(null, "", url);
149
+ committedUrl = readUrl();
121
150
  for (const l of listeners)
122
151
  l();
123
152
  }
@@ -137,9 +166,12 @@ function pushPath(path, search) {
137
166
  (search === undefined || window.location.search === search);
138
167
  if (unchanged)
139
168
  return;
140
- window.history.pushState(null, "", `${path}${search ?? ""}`);
141
- for (const l of listeners)
142
- l();
169
+ requestLeave(() => {
170
+ window.history.pushState(null, "", `${path}${search ?? ""}`);
171
+ committedUrl = readUrl();
172
+ for (const l of listeners)
173
+ l();
174
+ });
143
175
  }
144
176
  function replacePath(path, search) {
145
177
  if (typeof window === "undefined")
@@ -148,9 +180,19 @@ function replacePath(path, search) {
148
180
  // to avoid creating a history entry, even when the URL is identical.
149
181
  // (pushPath skips no-op pushes; replacePath honors the call so the
150
182
  // entry-stack semantics stay predictable.)
151
- window.history.replaceState(null, "", `${path}${search ?? ""}`);
152
- for (const l of listeners)
153
- l();
183
+ const nextUrl = `${path}${search ?? ""}`;
184
+ const leavesCurrentPage = window.location.pathname !== path ||
185
+ (search !== undefined && window.location.search !== search);
186
+ const apply = () => {
187
+ window.history.replaceState(null, "", nextUrl);
188
+ committedUrl = readUrl();
189
+ for (const l of listeners)
190
+ l();
191
+ };
192
+ if (leavesCurrentPage)
193
+ requestLeave(apply);
194
+ else
195
+ apply();
154
196
  }
155
197
  /** React-Hook der eine NavApi aus der Browser-History baut. Sollte
156
198
  * einmal im App-Root aufgerufen und als value an den shared
@@ -74,5 +74,5 @@ export { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "./ui/resiz
74
74
  export { SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider } from "./ui/sidebar.tsx";
75
75
  export type { KumikoBuild } from "./version/update-checker.tsx";
76
76
  export { readLoadedBuild } from "./version/update-checker.tsx";
77
- export type { AiTextAreaProps, AiTextFieldProps, BooleanFieldProps, ComparisonMetric, DateFieldProps, DrawerProps, FeedRow, FileFieldProps, FloatingPanelGeometry, FloatingPanelProps, InfinityListProps, InfinityListSelection, ModeSwitchVariant, NumberFieldProps, PhotoSlotSpec, PhotoSlotsProps, PlanCardActionSlot, PlanCardLinkSlot, PlanCardPrice, PlanCardProps, PlanGridProps, ProgressListRow, PublicShellProps, PublicShellVariant, QueryTableColumn, QueryTableProps, RangeFieldProps, ResultColumn, SelectFieldProps, SideBySideTableCell, SideBySideTableColumn, SideBySideTableRow, StatDelta, StatTone, StatusBadgeTone, StatusBarEntry, StatusTone, TextareaFieldProps, TextFieldProps, TimeseriesHeight, TimeseriesPoint, TimeseriesReferenceLine, TimeseriesXAxis, TimeseriesYAxis, UploadZoneProps, } from "./widgets/index.ts";
78
- export { AiTextArea, AiTextField, BooleanField, CollapsibleSection, ComparisonTable, DateField, DetailList, Drawer, EmptyState, ErrorState, FeedList, FileField, FloatingPanel, InfinityList, LoadingState, MiniStat, ModeSwitch, MoneyField, NumberField, PercentField, PhotoSlots, PlanCard, PlanGrid, ProgressBar, ProgressList, PublicShell, QueryTable, RangeField, ResultPanel, ResultTable, SectionCard, SelectField, SideBySideTable, Sparkline, STATUS_TONE_TEXT, StatCard, StatusBadge, StatusBarChart, StepBar, smoothPath, TextareaField, TextField, TimeseriesChart, UploadZone, useDraft, } from "./widgets/index.ts";
77
+ export type { AiTextAreaProps, AiTextFieldProps, BooleanFieldProps, ComparisonMetric, DateFieldProps, DrawerProps, FeedRow, FileFieldProps, FloatingPanelGeometry, FloatingPanelProps, InfinityListProps, InfinityListSelection, ModeSwitchVariant, NumberFieldProps, PdfPreviewSource, PhotoSlotSpec, PhotoSlotsProps, PlanCardActionSlot, PlanCardLinkSlot, PlanCardPrice, PlanCardProps, PlanGridProps, ProgressListRow, PublicShellProps, PublicShellVariant, QueryTableColumn, QueryTableProps, RangeFieldProps, ResultColumn, SelectFieldProps, SideBySideTableCell, SideBySideTableColumn, SideBySideTableRow, StatDelta, StatTone, StatusBadgeTone, StatusBarEntry, StatusTone, TextareaFieldProps, TextFieldProps, TimeseriesHeight, TimeseriesPoint, TimeseriesReferenceLine, TimeseriesXAxis, TimeseriesYAxis, UploadZoneProps, } from "./widgets/index.ts";
78
+ export { AiTextArea, AiTextField, BooleanField, CollapsibleSection, ComparisonTable, DateField, DetailList, Drawer, EmptyState, ErrorState, FeedList, FileField, FloatingPanel, InfinityList, LoadingState, MiniStat, ModeSwitch, MoneyField, NumberField, PdfPreview, PercentField, PhotoSlots, PlanCard, PlanGrid, ProgressBar, ProgressList, PublicShell, QueryTable, RangeField, ResultPanel, ResultTable, SectionCard, SelectField, SideBySideTable, Sparkline, STATUS_TONE_TEXT, StatCard, StatusBadge, StatusBarChart, StepBar, smoothPath, TextareaField, TextField, TimeseriesChart, UploadZone, useDraft, } from "./widgets/index.ts";
package/dist/web/index.js CHANGED
@@ -50,4 +50,4 @@ export { applyTokensToCssVars, defaultTokens, lightTokens, useBrowserTokensApi,
50
50
  export { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "./ui/resizable.js";
51
51
  export { SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider } from "./ui/sidebar.js";
52
52
  export { readLoadedBuild } from "./version/update-checker.js";
53
- export { AiTextArea, AiTextField, BooleanField, CollapsibleSection, ComparisonTable, DateField, DetailList, Drawer, EmptyState, ErrorState, FeedList, FileField, FloatingPanel, InfinityList, LoadingState, MiniStat, ModeSwitch, MoneyField, NumberField, PercentField, PhotoSlots, PlanCard, PlanGrid, ProgressBar, ProgressList, PublicShell, QueryTable, RangeField, ResultPanel, ResultTable, SectionCard, SelectField, SideBySideTable, Sparkline, STATUS_TONE_TEXT, StatCard, StatusBadge, StatusBarChart, StepBar, smoothPath, TextareaField, TextField, TimeseriesChart, UploadZone, useDraft, } from "./widgets/index.js";
53
+ export { AiTextArea, AiTextField, BooleanField, CollapsibleSection, ComparisonTable, DateField, DetailList, Drawer, EmptyState, ErrorState, FeedList, FileField, FloatingPanel, InfinityList, LoadingState, MiniStat, ModeSwitch, MoneyField, NumberField, PdfPreview, PercentField, PhotoSlots, PlanCard, PlanGrid, ProgressBar, ProgressList, PublicShell, QueryTable, RangeField, ResultPanel, ResultTable, SectionCard, SelectField, SideBySideTable, Sparkline, STATUS_TONE_TEXT, StatCard, StatusBadge, StatusBarChart, StepBar, smoothPath, TextareaField, TextField, TimeseriesChart, UploadZone, useDraft, } from "./widgets/index.js";
@@ -1,7 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { ChevronDown, ChevronRight, Plus, Search } from "lucide-react";
3
- import { createContext, useCallback, useContext, useEffect, useMemo, useState, } from "react";
3
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react";
4
4
  import { resolveNavigation } from "../../headless/index.js";
5
+ import { createLiveRefetchScheduler } from "../../hooks/live-refetch-scheduler.js";
5
6
  import { lastSegment, toAppSchema, useLiveEvents, useNav, useTranslation } from "../../index.js";
6
7
  import { KumikoLink } from "../app/nav.js";
7
8
  import { useNavEntities, useNavProviders } from "../app/nav-providers-context.js";
@@ -205,13 +206,20 @@ function useNavProviderChildren(qn, enabled) {
205
206
  const [raw, setRaw] = useState(null);
206
207
  const [error, setError] = useState(null);
207
208
  const [attempt, setAttempt] = useState(0);
209
+ // A live refresh keeps the current nodes on screen instead of blanking them.
210
+ const keepNodesOnRefire = useRef(false);
208
211
  useEffect(() => {
209
212
  if (!enabled || entities.length === 0)
210
213
  return;
211
- const unsubs = entities.map((e) => subscribeLive(e, () => setAttempt((n) => n + 1)));
214
+ const scheduler = createLiveRefetchScheduler(() => {
215
+ keepNodesOnRefire.current = true;
216
+ setAttempt((n) => n + 1);
217
+ });
218
+ const unsubs = entities.map((e) => subscribeLive(e, scheduler.trigger));
212
219
  return () => {
213
220
  for (const u of unsubs)
214
221
  u();
222
+ scheduler.dispose();
215
223
  };
216
224
  }, [enabled, entities, subscribeLive]);
217
225
  // `attempt` triggert SSE-Refresh + erneutes Subscribe — Biome sieht es
@@ -228,7 +236,9 @@ function useNavProviderChildren(qn, enabled) {
228
236
  return;
229
237
  }
230
238
  setError(null);
231
- setRaw(null);
239
+ if (!keepNodesOnRefire.current)
240
+ setRaw(null);
241
+ keepNodesOnRefire.current = false;
232
242
  try {
233
243
  const subscribe = provider();
234
244
  try {
@@ -61,13 +61,13 @@ export function ShellHeader({ schema, user, headerActions, }) {
61
61
  : crumbs !== undefined && recordTitle !== undefined && crumbs.length > 0
62
62
  ? [...crumbs.slice(0, -1), { label: recordTitle }, ...crumbs.slice(-1)]
63
63
  : crumbs;
64
- return (_jsxs("header", { "data-kumiko-layout": "shell-header", className: cn("flex h-14 shrink-0 items-center gap-3 border-b border-border pl-1.5 pr-3 md:pl-4 md:pr-6", compact && "relative"), children: [_jsxs("div", { className: cn("flex min-w-0 items-center gap-3", compact && "flex-1"), children: [_jsx(SidebarTrigger, { label: t("kumiko.sidebar.toggle") }), shownCrumbs !== undefined && shownCrumbs.length > 0 && (_jsx(Breadcrumb, { className: "min-w-0", "aria-label": t("kumiko.breadcrumb.label"), children: _jsx(BreadcrumbList, { className: "flex-nowrap gap-1.5 sm:gap-1.5", children: shownCrumbs.map((crumb, index) => {
64
+ return (_jsxs("header", { "data-kumiko-layout": "shell-header", className: cn("flex h-14 shrink-0 items-center gap-3 border-b border-border pl-1.5 pr-3 md:pl-4 md:pr-6", compact && "relative"), children: [_jsxs("div", { className: "@container flex min-w-0 flex-1 items-center gap-3", children: [_jsx(SidebarTrigger, { label: t("kumiko.sidebar.toggle") }), shownCrumbs !== undefined && shownCrumbs.length > 0 && (_jsx(Breadcrumb, { className: "min-w-0", "aria-label": t("kumiko.breadcrumb.label"), children: _jsx(BreadcrumbList, { className: "flex-nowrap gap-1.5 sm:gap-1.5", children: shownCrumbs.map((crumb, index) => {
65
65
  const screenId = crumb.screenId;
66
66
  const isLast = index === shownCrumbs.length - 1;
67
- return (_jsxs(Fragment, { children: [index > 0 && (_jsx(BreadcrumbSeparator, { className: "hidden text-muted-foreground sm:inline-flex [&>svg]:size-3.5" })), _jsx(BreadcrumbItem, { className: cn("min-w-0", !isLast && "hidden sm:inline-flex"), children: screenId !== undefined && !isLast ? (_jsx(BreadcrumbLink, { href: "#", className: "whitespace-nowrap text-foreground-secondary", onClick: (e) => {
67
+ return (_jsxs(Fragment, { children: [index > 0 && (_jsx(BreadcrumbSeparator, { className: "hidden text-muted-foreground @xs:inline-flex [&>svg]:size-3.5" })), _jsx(BreadcrumbItem, { className: cn(isLast ? "min-w-0" : "hidden shrink-0 @xs:inline-flex"), children: screenId !== undefined && !isLast ? (_jsx(BreadcrumbLink, { href: "#", className: "whitespace-nowrap text-foreground-secondary", onClick: (e) => {
68
68
  e.preventDefault();
69
69
  nav.navigate({ screenId });
70
- }, children: crumb.label })) : isLast ? (_jsx("h1", { "aria-current": "page", className: "truncate text-lg font-semibold text-foreground", children: titleOverride ?? crumb.label })) : (_jsx(BreadcrumbPage, { className: "whitespace-nowrap text-foreground-secondary", children: crumb.label })) })] }, screenId ?? crumb.label));
70
+ }, children: crumb.label })) : isLast ? (_jsx("h1", { "aria-current": "page", title: titleOverride ?? crumb.label, className: "truncate text-lg font-semibold text-foreground", children: titleOverride ?? crumb.label })) : (_jsx(BreadcrumbPage, { className: "whitespace-nowrap text-foreground-secondary", children: crumb.label })) })] }, screenId ?? crumb.label));
71
71
  }) }) })), _jsx("div", { ref: setStatusElement, "data-kumiko-layout": "page-header-status", className: cn("flex items-center gap-2 empty:hidden", compact ? "min-w-0 overflow-hidden" : "shrink-0") })] }), _jsxs("div", { className: cn("ml-auto flex items-center gap-2", compact ? "min-w-0 max-w-[60%]" : "min-w-0 max-w-[50%] shrink-0 sm:max-w-none"), children: [_jsx("div", { ref: setActionsElement, "data-kumiko-layout": "page-header-actions", className: cn("flex min-w-0 items-center gap-2 empty:hidden [&_*]:min-w-0 [&_*]:max-w-full [&_button]:overflow-hidden [&_button]:whitespace-nowrap", compact && "overflow-hidden") }), !compact && headerActions !== undefined && (_jsx("div", { "data-kumiko-layout": "header-actions", className: "flex items-center gap-2", children: headerActions })), hasOverflow && _jsx(HeaderOverflow, { items: overflowItems, headerActions: headerActions })] })] }));
72
72
  }
73
73
  // A hand-rolled menu instead of Radix DropdownMenu: app headerActions are opaque