@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
@@ -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 &&
@@ -8,6 +8,11 @@ export declare function FileThumbnail({ fileId, variant, className, testId, }: {
8
8
  readonly className?: string;
9
9
  readonly testId?: string;
10
10
  }): ReactNode;
11
+ type FileMeta = {
12
+ readonly fileName: string;
13
+ readonly mimeType: string | undefined;
14
+ };
15
+ export declare function useFileMeta(fileId: string): FileMeta | null;
11
16
  export declare function FileNameLink({ fileId, fallbackLabel, className, }: {
12
17
  readonly fileId: string;
13
18
  readonly fallbackLabel: string;
@@ -23,3 +28,4 @@ export declare function FileDisplayCell({ value, display, fallbackLabel, compact
23
28
  readonly fallbackLabel: string;
24
29
  readonly compact?: boolean;
25
30
  }): ReactNode;
31
+ export {};
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { isUuid } from "@cosmicdrift/kumiko-types/identifiers";
3
3
  import { ImageIcon } from "lucide-react";
4
4
  import { useEffect, useState } from "react";
5
+ import { fileIdsOf } from "../../file-ids-of.js";
5
6
  import { cn } from "../lib/cn.js";
6
7
  // Stored values are untrusted: only UUIDs reach a request, and path segments are
7
8
  // encoded so an id like "../x" or "a?b" cannot redirect the request.
@@ -33,42 +34,41 @@ function ThumbnailChain({ fileId, variant, className, testId, }) {
33
34
  export function FileThumbnail({ fileId, variant, className = "h-24 w-32", testId, }) {
34
35
  return (_jsx(ThumbnailChain, { fileId: fileId, variant: variant, className: className, testId: testId }, `${fileId}:${variant ?? ""}`));
35
36
  }
36
- function isFileMeta(value) {
37
- return (typeof value === "object" &&
38
- value !== null &&
39
- "fileName" in value &&
40
- typeof value.fileName === "string");
37
+ function parseFileMeta(value) {
38
+ if (typeof value !== "object" || value === null || !("fileName" in value))
39
+ return null;
40
+ if (typeof value.fileName !== "string")
41
+ return null;
42
+ const mimeType = "mimeType" in value && typeof value.mimeType === "string" ? value.mimeType : undefined;
43
+ return { fileName: value.fileName, mimeType };
41
44
  }
42
- export function FileNameLink({ fileId, fallbackLabel, className, }) {
43
- const [fileName, setFileName] = useState(null);
45
+ export function useFileMeta(fileId) {
46
+ const [meta, setMeta] = useState(null);
44
47
  const validId = isUuid(fileId);
45
48
  useEffect(() => {
46
49
  let cancelled = false;
47
- setFileName(null);
50
+ setMeta(null);
48
51
  if (!validId)
49
52
  return;
50
53
  fetch(`${fileOriginalUrl(fileId)}/meta`)
51
54
  .then((res) => (res.ok ? res.json() : null))
52
- .then((meta) => {
53
- if (!cancelled && isFileMeta(meta))
54
- setFileName(meta.fileName);
55
+ .then((json) => {
56
+ if (!cancelled)
57
+ setMeta(parseFileMeta(json));
55
58
  })
56
59
  .catch(() => { });
57
60
  return () => {
58
61
  cancelled = true;
59
62
  };
60
63
  }, [fileId, validId]);
61
- if (!validId)
64
+ return meta;
65
+ }
66
+ export function FileNameLink({ fileId, fallbackLabel, className, }) {
67
+ const fileName = useFileMeta(fileId)?.fileName ?? null;
68
+ if (!isUuid(fileId))
62
69
  return _jsx("span", { className: cn("text-sm", className), children: fallbackLabel });
63
70
  return (_jsx("a", { href: fileOriginalUrl(fileId), target: "_blank", rel: "noreferrer", className: cn("text-sm underline underline-offset-2", className), children: fileName ?? fallbackLabel }));
64
71
  }
65
- function fileIdsOf(value) {
66
- if (typeof value === "string")
67
- return value === "" ? [] : [value];
68
- if (!Array.isArray(value))
69
- return [];
70
- return value.filter((entry) => typeof entry === "string" && entry !== "");
71
- }
72
72
  // Height-bound, width follows the aspect ratio within a clamp, so a portrait and
73
73
  // a landscape photo both read as a preview without stretching the column.
74
74
  const TABLE_THUMBNAIL_CLASS = "h-16 min-w-16 max-w-24 object-cover";
@@ -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,3 @@
1
+ import { type ReactNode } from "react";
2
+ import { type FileValueListProps } from "../../index.ts";
3
+ export declare function FileValueList({ fileIds, testId }: FileValueListProps): ReactNode;
@@ -0,0 +1,22 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { isUuid } from "@cosmicdrift/kumiko-types/identifiers";
3
+ import { useState } from "react";
4
+ import { useTranslation } from "../../index.js";
5
+ import { Button } from "../ui/button.js";
6
+ import { PdfPreview } from "../widgets/pdf-preview.js";
7
+ import { fileOriginalUrl, useFileMeta } from "./file-thumbnail.js";
8
+ const PDF_MIME_TYPE = "application/pdf";
9
+ function FileValueRow({ fileId, testId, }) {
10
+ const t = useTranslation();
11
+ const meta = useFileMeta(fileId);
12
+ const [previewOpen, setPreviewOpen] = useState(false);
13
+ if (!isUuid(fileId))
14
+ return _jsx("span", { className: "text-sm", children: fileId });
15
+ const downloadUrl = fileOriginalUrl(fileId);
16
+ return (_jsxs("li", { className: "flex flex-col gap-2", "data-testid": testId, children: [_jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [_jsx("span", { className: "text-sm", children: meta?.fileName ?? fileId.slice(0, 8) }), _jsx("a", { href: downloadUrl, download: meta?.fileName ?? "", className: "text-primary text-sm underline underline-offset-2", ...(testId !== undefined && { "data-testid": `${testId}-download` }), children: t("kumiko.field.file-list.download") }), meta?.mimeType === PDF_MIME_TYPE && (_jsx(Button, { type: "button", variant: "link", size: "sm", "aria-expanded": previewOpen, onClick: () => setPreviewOpen((open) => !open), ...(testId !== undefined && { "data-testid": `${testId}-preview-toggle` }), children: t(previewOpen ? "kumiko.field.file-list.hidePreview" : "kumiko.field.file-list.preview") }))] }), previewOpen && (_jsx(PdfPreview, { source: { url: downloadUrl }, testId: `file-preview-${fileId}` }))] }));
17
+ }
18
+ export function FileValueList({ fileIds, testId }) {
19
+ if (fileIds.length === 0)
20
+ return null;
21
+ return (_jsx("ul", { className: "m-0 flex list-none flex-col gap-3 p-0", "data-testid": testId, children: fileIds.map((fileId) => (_jsx(FileValueRow, { fileId: fileId, ...(testId !== undefined && { testId: `${testId}-${fileId}` }) }, fileId))) }));
22
+ }
@@ -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
+ }