@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
@@ -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
@@ -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
@@ -3,5 +3,7 @@ export type FileUploadFormDataInput = {
3
3
  readonly entityType?: string | undefined;
4
4
  readonly entityId?: string | undefined;
5
5
  readonly fieldName?: string | undefined;
6
+ /** Screen QN of an actionForm: the upload is pending for that form's field, not for an entity. */
7
+ readonly actionForm?: string | undefined;
6
8
  };
7
9
  export declare function buildFileUploadFormData(input: FileUploadFormDataInput): FormData;
@@ -7,5 +7,7 @@ export function buildFileUploadFormData(input) {
7
7
  formData.append("entityId", input.entityId);
8
8
  if (input.fieldName !== undefined)
9
9
  formData.append("fieldName", input.fieldName);
10
+ if (input.actionForm !== undefined)
11
+ formData.append("actionForm", input.actionForm);
10
12
  return formData;
11
13
  }
@@ -0,0 +1,7 @@
1
+ /** A dropzone refused a file because the field already holds `maxCount` files. */
2
+ export declare class FileCountLimitError extends Error {
3
+ }
4
+ /** i18n key for a failed upload. The server's error string starts with a stable code before
5
+ * the first ":"; the text after it is English and never shown, an unknown code gets the
6
+ * generic key. */
7
+ export declare function uploadErrorKey(cause: unknown): string;
@@ -0,0 +1,23 @@
1
+ import { FileUploadFailedError } from "./upload-file-ref.js";
2
+ /** A dropzone refused a file because the field already holds `maxCount` files. */
3
+ export class FileCountLimitError extends Error {
4
+ }
5
+ const SERVER_CODE_KEYS = {
6
+ file_too_large: "kumiko.widget.upload.too-large",
7
+ request_too_large: "kumiko.widget.upload.too-large",
8
+ invalid_file_type: "kumiko.widget.upload.invalid-type",
9
+ mime_mismatch: "kumiko.widget.upload.invalid-type",
10
+ content_mismatch: "kumiko.widget.upload.invalid-type",
11
+ };
12
+ /** i18n key for a failed upload. The server's error string starts with a stable code before
13
+ * the first ":"; the text after it is English and never shown, an unknown code gets the
14
+ * generic key. */
15
+ export function uploadErrorKey(cause) {
16
+ if (cause instanceof FileCountLimitError)
17
+ return "kumiko.widget.upload.max-count";
18
+ if (cause instanceof FileUploadFailedError && cause.fromServer) {
19
+ const code = cause.message.split(":")[0]?.trim() ?? "";
20
+ return SERVER_CODE_KEYS[code] ?? "kumiko.widget.upload.error";
21
+ }
22
+ return "kumiko.widget.upload.error";
23
+ }
@@ -1,5 +1,5 @@
1
1
  import { type FileUploadFormDataInput } from "./file-upload-form-data.ts";
2
- export type FileRefTarget = Pick<FileUploadFormDataInput, "entityType" | "entityId" | "fieldName">;
2
+ export type FileRefTarget = Pick<FileUploadFormDataInput, "entityType" | "entityId" | "fieldName" | "actionForm">;
3
3
  /** Carries the server's own `error` message when the upload endpoint sent one. */
4
4
  export declare class FileUploadFailedError extends Error {
5
5
  readonly fromServer: boolean;
@@ -21,6 +21,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
21
21
  // state into a `draftsByCellId` map here that survives the layout swap.
22
22
  import { ArrowDown, ArrowUp, Copy, Plus, Trash2 } from "lucide-react";
23
23
  import { Fragment, useEffect, useRef, useState, } from "react";
24
+ import { fieldIssueText } from "../../field-issue-text.js";
24
25
  import { applyFormatSpec } from "../../headless/index.js";
25
26
  import { useLocale, useTranslation } from "../../index.js";
26
27
  import { cn } from "../lib/cn.js";
@@ -113,8 +114,8 @@ export function computeScrollAffordance(scrollLeft, scrollWidth, clientWidth) {
113
114
  };
114
115
  }
115
116
  function formatTotalValue(total, columns, currency, locale) {
116
- const column = columns.find((c) => c.field === total.field);
117
- if (column?.type === "money")
117
+ const isMoney = total.money ?? columns.find((c) => c.field === total.field)?.type === "money";
118
+ if (isMoney)
118
119
  return formatMoney(total.value, currency, locale);
119
120
  return total.value.toLocaleString(locale);
120
121
  }
@@ -131,7 +132,7 @@ function IssueMessages({ issues, testId, constrainWidth, }) {
131
132
  const t = useTranslation();
132
133
  if (issues === undefined || issues.length === 0)
133
134
  return null;
134
- return (_jsx("div", { role: "alert", "data-testid": testId, className: cn("text-xs text-destructive", constrainWidth === true && "max-w-[16rem] whitespace-normal break-words"), children: issues.map((issue) => (_jsx("div", { children: t(issue.i18nKey, issue.params) }, `${issue.path}:${issue.code}`))) }));
135
+ return (_jsx("div", { role: "alert", "data-testid": testId, className: cn("text-xs text-destructive", constrainWidth === true && "max-w-[16rem] whitespace-normal break-words"), children: issues.map((issue) => (_jsx("div", { children: fieldIssueText(t, issue) }, `${issue.path}:${issue.code}`))) }));
135
136
  }
136
137
  function readOnlyCellText(column, value, currency, locale) {
137
138
  if (value === null || value === undefined || value === "")
@@ -335,7 +336,7 @@ export function EmbeddedListInput({ id, columns, rows, totals, currency, disable
335
336
  };
336
337
  const testIdFor = (suffix) => testId !== undefined ? `${testId}-${suffix}` : undefined;
337
338
  if (rows.length === 0) {
338
- return (_jsxs("div", { ref: containerRef, "data-testid": testId !== undefined ? `${testId}-empty` : "embedded-list-empty", className: "flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-10 text-center text-sm text-muted-foreground", children: [_jsx("span", { children: emptyLabel }), showControls && (_jsxs(UiButton, { type: "button", variant: "outline", size: "sm", onClick: onAddRow, "data-testid": testId !== undefined ? `${testId}-empty-add` : "embedded-list-empty-add", children: [_jsx(Plus, { className: "size-4", "aria-hidden": "true" }), emptyCtaLabel] }))] }));
339
+ return (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs("div", { ref: containerRef, "data-testid": testId !== undefined ? `${testId}-empty` : "embedded-list-empty", className: "flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-10 text-center text-sm text-muted-foreground", children: [_jsx("span", { children: emptyLabel }), showControls && (_jsxs(UiButton, { type: "button", variant: "outline", size: "sm", onClick: onAddRow, "data-testid": testId !== undefined ? `${testId}-empty-add` : "embedded-list-empty-add", children: [_jsx(Plus, { className: "size-4", "aria-hidden": "true" }), emptyCtaLabel] }))] }), _jsx(IssueMessages, { issues: listIssues, testId: testIdFor("list-issues") })] }));
339
340
  }
340
341
  const hasTotals = totals !== undefined && totals.length > 0;
341
342
  return (_jsxs("div", { ref: containerRef, "data-testid": testId, className: "min-w-0", children: [!isMobile && (_jsxs("div", { "data-testid": testIdFor("desktop"), className: "hidden min-w-0 md:block", children: [_jsxs("div", { className: "min-w-0 overflow-hidden rounded-lg border bg-card", children: [_jsxs("div", { className: "relative min-w-0", children: [_jsx("div", { ref: setScrollContainerEl, "data-testid": testIdFor("desktop-scroll"), className: "overflow-x-auto [&_[data-slot=table-container]]:overflow-x-visible", children: _jsxs(Table, { className: "w-full table-fixed", style: { minWidth: `${tableMinWidthRem(columns, showControls)}rem` }, children: [_jsxs("colgroup", { children: [columns.map((column) => (_jsx("col", { style: columnWidthStyle(column.type) }, column.field))), showControls && _jsx("col", { style: { width: `${ACTIONS_COLUMN_REM}rem` } })] }), _jsx(TableHeader, { className: "bg-muted", children: _jsxs(TableRow, { className: "hover:bg-transparent", children: [columns.map((column) => (_jsx(TableHead, { title: column.label, className: cn("truncate", columnAlignClass(column.type)), children: column.label }, column.field))), showControls && (_jsx(TableHead, { className: cn("sticky right-0 z-10 whitespace-nowrap bg-muted px-1 text-right", isHorizontallyScrollable &&
@@ -7,6 +7,7 @@ export type FileUploadInputProps = {
7
7
  readonly accept?: readonly string[];
8
8
  readonly disabled?: boolean;
9
9
  readonly entityType?: string;
10
+ readonly uploadActionForm?: string;
10
11
  readonly entityId?: string;
11
12
  readonly fieldName?: string;
12
13
  readonly imageVariant?: string;
@@ -18,4 +19,4 @@ export type FileUploadInputProps = {
18
19
  readonly pickLabel?: string;
19
20
  readonly testId?: string;
20
21
  };
21
- export declare function FileUploadInput({ kind, id, value, onChange, accept, disabled, entityType, entityId, fieldName, imageVariant, capture, directory, onFilesPicked, pickLabel, testId, }: FileUploadInputProps): ReactNode;
22
+ export declare function FileUploadInput({ kind, id, value, onChange, accept, disabled, entityType, uploadActionForm, entityId, fieldName, imageVariant, capture, directory, onFilesPicked, pickLabel, testId, }: FileUploadInputProps): ReactNode;
@@ -9,12 +9,13 @@ import { useRef, useState } from "react";
9
9
  import { useTranslation } from "../../index.js";
10
10
  import { toAcceptAttr } from "../lib/accept-attr.js";
11
11
  import { resizeImageBeforeUpload } from "../lib/resize-image.js";
12
- import { FileUploadFailedError, uploadFileRef } from "../lib/upload-file-ref.js";
12
+ import { uploadErrorKey } from "../lib/upload-error-key.js";
13
+ import { uploadFileRef } from "../lib/upload-file-ref.js";
13
14
  import { Button as UiButton } from "../ui/button.js";
14
15
  import { FileNameLink, FileThumbnail } from "./file-thumbnail.js";
15
16
  // React's input typings have no `webkitdirectory`; spreading keeps it typed-cast-free.
16
17
  const DIRECTORY_INPUT_ATTRS = { webkitdirectory: "", multiple: true };
17
- export function FileUploadInput({ kind, id, value, onChange, accept, disabled, entityType, entityId, fieldName, imageVariant, capture, directory, onFilesPicked, pickLabel, testId, }) {
18
+ export function FileUploadInput({ kind, id, value, onChange, accept, disabled, entityType, uploadActionForm, entityId, fieldName, imageVariant, capture, directory, onFilesPicked, pickLabel, testId, }) {
18
19
  const t = useTranslation();
19
20
  const inputRef = useRef(null);
20
21
  const [uploading, setUploading] = useState(false);
@@ -33,12 +34,15 @@ export function FileUploadInput({ kind, id, value, onChange, accept, disabled, e
33
34
  setError(null);
34
35
  try {
35
36
  const uploadFile = await resizeImageBeforeUpload(file);
36
- onChange(await uploadFileRef(uploadFile, { entityType, entityId, fieldName }));
37
+ onChange(await uploadFileRef(uploadFile, {
38
+ entityType,
39
+ entityId,
40
+ fieldName,
41
+ actionForm: uploadActionForm,
42
+ }));
37
43
  }
38
- catch (error) {
39
- setError(error instanceof FileUploadFailedError && error.fromServer
40
- ? error.message
41
- : t("kumiko.field.file-upload.failed"));
44
+ catch (cause) {
45
+ setError(t(uploadErrorKey(cause)));
42
46
  }
43
47
  finally {
44
48
  setUploading(false);
@@ -0,0 +1,16 @@
1
+ import { type ReactNode } from "react";
2
+ export type FilesDropzoneInputProps = {
3
+ readonly id: string;
4
+ readonly value: readonly string[];
5
+ readonly onChange: (fileIds: readonly string[]) => void;
6
+ readonly accept?: readonly string[];
7
+ readonly maxSize?: string;
8
+ readonly maxCount?: number;
9
+ readonly disabled?: boolean;
10
+ readonly entityType?: string;
11
+ readonly uploadActionForm?: string;
12
+ readonly entityId?: string;
13
+ readonly fieldName?: string;
14
+ readonly testId?: string;
15
+ };
16
+ export declare function FilesDropzoneInput({ id, value, onChange, accept, maxSize, maxCount, disabled, entityType, uploadActionForm, entityId, fieldName, testId, }: FilesDropzoneInputProps): ReactNode;
@@ -0,0 +1,52 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ // Drop zone for a `files` / `images` field with `display: "dropzone"`: one status row per picked
3
+ // file, the field value is the array of FileRef ids of the files that uploaded. Failed files stay
4
+ // out of the value; removing a row removes its id.
5
+ import { useEffect, useRef } from "react";
6
+ import { useTranslation } from "../../index.js";
7
+ import { FileCountLimitError, uploadErrorKey } from "../lib/upload-error-key.js";
8
+ import { uploadFileRef } from "../lib/upload-file-ref.js";
9
+ import { UploadZone } from "../widgets/upload-zone.js";
10
+ export function FilesDropzoneInput({ id, value, onChange, accept, maxSize, maxCount, disabled, entityType, uploadActionForm, entityId, fieldName, testId, }) {
11
+ const t = useTranslation();
12
+ // Uploads of one batch finish independently; building each next value from the render's
13
+ // `value` would drop the ids of the others. Re-synced whenever the owner changes the value.
14
+ const latestIds = useRef(value);
15
+ const uploadsRunning = useRef(0);
16
+ useEffect(() => {
17
+ latestIds.current = value;
18
+ }, [value]);
19
+ const target = {
20
+ entityType,
21
+ entityId,
22
+ fieldName,
23
+ actionForm: uploadActionForm,
24
+ };
25
+ async function uploadOne(file) {
26
+ if (maxCount !== undefined && latestIds.current.length + uploadsRunning.current >= maxCount) {
27
+ throw new FileCountLimitError();
28
+ }
29
+ uploadsRunning.current += 1;
30
+ try {
31
+ const fileId = await uploadFileRef(file, target);
32
+ latestIds.current = [...latestIds.current, fileId];
33
+ onChange(latestIds.current);
34
+ return fileId;
35
+ }
36
+ finally {
37
+ uploadsRunning.current -= 1;
38
+ }
39
+ }
40
+ function removeOne(fileId) {
41
+ latestIds.current = latestIds.current.filter((existing) => existing !== fileId);
42
+ onChange(latestIds.current);
43
+ }
44
+ const limits = [
45
+ accept !== undefined && accept.length > 0 ? accept.join(", ").toUpperCase() : undefined,
46
+ maxSize !== undefined ? t("kumiko.field.dropzone.limit-size", { size: maxSize }) : undefined,
47
+ maxCount !== undefined
48
+ ? t("kumiko.field.dropzone.limit-count", { count: maxCount })
49
+ : undefined,
50
+ ].filter((part) => part !== undefined);
51
+ return (_jsx(UploadZone, { testId: testId ?? `${id}-dropzone`, title: t("kumiko.field.dropzone.title"), ...(limits.length > 0 && { hint: limits.join(" · ") }), ...(accept !== undefined && { accept }), ...(disabled !== undefined && { disabled }), doneValues: value, onUpload: uploadOne, onRemove: removeOne, describeError: (cause) => t(uploadErrorKey(cause), { count: maxCount }) }));
52
+ }