@cosmicdrift/kumiko-renderer-web 0.183.2 → 0.185.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.
@@ -90,6 +90,7 @@ import {
90
90
  DropdownMenuItem,
91
91
  DropdownMenuTrigger,
92
92
  } from "./dropdown-menu";
93
+ import { EmbeddedListInput } from "./embedded-list-input";
93
94
  import { FileUploadInput } from "./file-upload";
94
95
  import { DefaultLightbox } from "./lightbox";
95
96
  import { LocatedTimestampInput } from "./located-timestamp-input";
@@ -143,15 +144,19 @@ function DefaultButton({
143
144
  width = "auto",
144
145
  children,
145
146
  testId,
147
+ className,
148
+ ref,
146
149
  }: ButtonProps): ReactNode {
147
150
  // link-Variant rendert text-artig (Inline-Link im Fließtext/Banner), nicht als
148
151
  // gepolsterte Fläche; width="full" streckt CTA-Buttons in Karten/Panels.
149
- const className =
150
- [variant === "link" ? "h-auto px-0 py-0" : "", width === "full" ? "w-full" : ""]
151
- .filter(Boolean)
152
- .join(" ") || undefined;
152
+ const resolvedClassName = cn(
153
+ variant === "link" ? "h-auto px-0 py-0" : "",
154
+ width === "full" ? "w-full" : "",
155
+ className,
156
+ );
153
157
  return (
154
158
  <UiButton
159
+ ref={ref}
155
160
  type={type}
156
161
  onClick={onClick}
157
162
  disabled={disabled === true || loading === true}
@@ -160,7 +165,7 @@ function DefaultButton({
160
165
  variant={BUTTON_VARIANT[variant]}
161
166
  size={BUTTON_SIZE[size]}
162
167
  aria-label={ariaLabel}
163
- className={className}
168
+ className={resolvedClassName}
164
169
  >
165
170
  {loading === true ? <Loader2 className="size-4 animate-spin" aria-hidden="true" /> : children}
166
171
  </UiButton>
@@ -1884,6 +1889,7 @@ export const defaultPrimitives: CorePrimitives = {
1884
1889
  Field: DefaultField,
1885
1890
  Input: DefaultInput,
1886
1891
  DataTable: DefaultDataTable,
1892
+ EmbeddedListInput,
1887
1893
  Form: DefaultForm,
1888
1894
  Section: DefaultSection,
1889
1895
  Card: DefaultCard,
@@ -1,8 +1,14 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
3
- import { DispatcherProvider } from "@cosmicdrift/kumiko-renderer";
3
+ import {
4
+ DispatcherProvider,
5
+ type LiveEvent,
6
+ type LiveEventSubscriber,
7
+ LiveEventsProvider,
8
+ } from "@cosmicdrift/kumiko-renderer";
4
9
  import type { ReactNode } from "react";
5
10
  import {
11
+ act,
6
12
  createMockDispatcher,
7
13
  fireEvent,
8
14
  render,
@@ -11,6 +17,30 @@ import {
11
17
  } from "../../__tests__/test-utils";
12
18
  import { InfinityList } from "../infinity-list";
13
19
 
20
+ // Fake LiveEventSubscriber for live-mode tests — collects subscribers,
21
+ // `inject(type, data)` fires the ones matching `data.aggregateType`.
22
+ // Same shape as production; mirrors use-query-live.test.tsx's helper.
23
+ function makeFakeLiveEvents(): {
24
+ subscriber: LiveEventSubscriber;
25
+ inject: (type: string, data: LiveEvent["data"]) => void;
26
+ } {
27
+ const listeners = new Set<{ entity: string; cb: (e: LiveEvent) => void }>();
28
+ return {
29
+ subscriber: (entity, cb) => {
30
+ const entry = { entity, cb };
31
+ listeners.add(entry);
32
+ return () => {
33
+ listeners.delete(entry);
34
+ };
35
+ },
36
+ inject: (type, data) => {
37
+ for (const l of listeners) {
38
+ if (l.entity === data.aggregateType) l.cb({ type, data });
39
+ }
40
+ },
41
+ };
42
+ }
43
+
14
44
  // jsdom has no IntersectionObserver — stub stores the callback per observer
15
45
  // so tests can fire the "sentinel became visible" event manually instead of
16
46
  // simulating real scrolling.
@@ -32,6 +62,14 @@ function renderWithDispatcher(ui: ReactNode, dispatcher: Dispatcher) {
32
62
  return render(<DispatcherProvider dispatcher={dispatcher}>{ui}</DispatcherProvider>);
33
63
  }
34
64
 
65
+ function renderWithLive(ui: ReactNode, dispatcher: Dispatcher, liveEvents: LiveEventSubscriber) {
66
+ return render(
67
+ <DispatcherProvider dispatcher={dispatcher}>
68
+ <LiveEventsProvider value={liveEvents}>{ui}</LiveEventsProvider>
69
+ </DispatcherProvider>,
70
+ );
71
+ }
72
+
35
73
  type Row = { readonly id: string; readonly subject: string };
36
74
  type Page = { readonly rows: readonly Row[]; readonly nextCursor: string | null };
37
75
 
@@ -183,4 +221,203 @@ describe("InfinityList", () => {
183
221
  expect(screen.queryByText("Bo-Treffer")).toBeNull();
184
222
  expect(screen.getByText("Bob-Treffer")).toBeTruthy();
185
223
  });
224
+
225
+ // fw#1827: InfinityList used dispatcher.query directly and never subscribed
226
+ // to live events, so a solon inbox stayed stale until the user reloaded.
227
+ describe("Live-Mode", () => {
228
+ test("SSE-Event mergt nur die erste Seite, bereits geladene Folgeseiten bleiben erhalten", async () => {
229
+ const calls: Array<Readonly<Record<string, unknown>>> = [];
230
+ const dispatcher = createMockDispatcher({
231
+ query: ((_type: string, payload: Readonly<Record<string, unknown>>) => {
232
+ calls.push(payload);
233
+ if (calls.length === 1) {
234
+ return Promise.resolve({
235
+ isSuccess: true,
236
+ data: { rows: [{ id: "m1", subject: "Alt-1" }], nextCursor: "c1" },
237
+ });
238
+ }
239
+ if (calls.length === 2) {
240
+ return Promise.resolve({
241
+ isSuccess: true,
242
+ data: { rows: [{ id: "m2", subject: "Alt-2" }], nextCursor: null },
243
+ });
244
+ }
245
+ // Live refresh of the first page: m1 stays, a new row lands on top.
246
+ return Promise.resolve({
247
+ isSuccess: true,
248
+ data: {
249
+ rows: [
250
+ { id: "m3", subject: "Neu" },
251
+ { id: "m1", subject: "Alt-1" },
252
+ ],
253
+ nextCursor: "c1",
254
+ },
255
+ });
256
+ }) as unknown as Dispatcher["query"],
257
+ });
258
+ const fake = makeFakeLiveEvents();
259
+
260
+ renderWithLive(
261
+ <InfinityList<Page, Row>
262
+ query="inbox:query:message:list"
263
+ pageSize={1}
264
+ rows={(data) => data.rows}
265
+ nextCursor={(data) => data.nextCursor}
266
+ rowId={(row) => row.id}
267
+ renderRow={(row) => <span>{row.subject}</span>}
268
+ testId="inbox"
269
+ />,
270
+ dispatcher,
271
+ fake.subscriber,
272
+ );
273
+
274
+ await waitFor(() => expect(screen.getByText("Alt-1")).toBeTruthy());
275
+ fireIntersect();
276
+ await waitFor(() => expect(screen.getByText("Alt-2")).toBeTruthy());
277
+ expect(calls.length).toBe(2);
278
+
279
+ act(() => {
280
+ fake.inject("message.created", {
281
+ id: "m3",
282
+ aggregateType: "message",
283
+ version: 1,
284
+ payload: {},
285
+ createdAt: "",
286
+ });
287
+ });
288
+
289
+ await waitFor(() => expect(screen.getByText("Neu")).toBeTruthy());
290
+ expect(screen.getByText("Alt-1")).toBeTruthy();
291
+ expect(screen.getByText("Alt-2")).toBeTruthy();
292
+ expect(screen.getAllByText("Alt-1").length).toBe(1);
293
+
294
+ // The live refresh only requests the first page — no cursor in the payload.
295
+ expect(calls[2]).toEqual({ limit: 1 });
296
+ });
297
+
298
+ test("live=false: SSE-Event wird ignoriert, kein Refetch", async () => {
299
+ let calls = 0;
300
+ const dispatcher = createMockDispatcher({
301
+ query: (() => {
302
+ calls += 1;
303
+ return Promise.resolve({
304
+ isSuccess: true,
305
+ data: { rows: [{ id: "m1", subject: "Hallo" }], nextCursor: null },
306
+ });
307
+ }) as unknown as Dispatcher["query"],
308
+ });
309
+ const fake = makeFakeLiveEvents();
310
+
311
+ renderWithLive(
312
+ <InfinityList<Page, Row>
313
+ query="inbox:query:message:list"
314
+ live={false}
315
+ rows={(data) => data.rows}
316
+ nextCursor={(data) => data.nextCursor}
317
+ rowId={(row) => row.id}
318
+ renderRow={(row) => <span>{row.subject}</span>}
319
+ testId="inbox"
320
+ />,
321
+ dispatcher,
322
+ fake.subscriber,
323
+ );
324
+
325
+ await waitFor(() => expect(screen.getByText("Hallo")).toBeTruthy());
326
+
327
+ fake.inject("message.created", {
328
+ id: "m2",
329
+ aggregateType: "message",
330
+ version: 1,
331
+ payload: {},
332
+ createdAt: "",
333
+ });
334
+
335
+ await new Promise((resolve) => setTimeout(resolve, 20));
336
+ expect(calls).toBe(1);
337
+ });
338
+
339
+ test("nur Events für die Query-Entity triggern den Refresh", async () => {
340
+ let calls = 0;
341
+ const dispatcher = createMockDispatcher({
342
+ query: (() => {
343
+ calls += 1;
344
+ return Promise.resolve({
345
+ isSuccess: true,
346
+ data: { rows: [{ id: "m1", subject: "Hallo" }], nextCursor: null },
347
+ });
348
+ }) as unknown as Dispatcher["query"],
349
+ });
350
+ const fake = makeFakeLiveEvents();
351
+
352
+ renderWithLive(
353
+ <InfinityList<Page, Row>
354
+ query="inbox:query:message:list"
355
+ rows={(data) => data.rows}
356
+ nextCursor={(data) => data.nextCursor}
357
+ rowId={(row) => row.id}
358
+ renderRow={(row) => <span>{row.subject}</span>}
359
+ testId="inbox"
360
+ />,
361
+ dispatcher,
362
+ fake.subscriber,
363
+ );
364
+
365
+ await waitFor(() => expect(screen.getByText("Hallo")).toBeTruthy());
366
+
367
+ fake.inject("note.created", {
368
+ id: "n1",
369
+ aggregateType: "note",
370
+ version: 1,
371
+ payload: {},
372
+ createdAt: "",
373
+ });
374
+
375
+ await new Promise((resolve) => setTimeout(resolve, 20));
376
+ expect(calls).toBe(1);
377
+ });
378
+
379
+ // A live event arriving while the mount fetch is still in flight must not
380
+ // discard that fetch — it's the normal case for #1827's own scenario (a
381
+ // screen mounts while writes are already streaming in).
382
+ test("SSE-Event während der ersten Ladephase lässt die Mount-Anfrage trotzdem landen", async () => {
383
+ const resolvers: Array<(res: unknown) => void> = [];
384
+ const dispatcher = createMockDispatcher({
385
+ query: (() =>
386
+ new Promise((resolve) => {
387
+ resolvers.push(resolve);
388
+ })) as unknown as Dispatcher["query"],
389
+ });
390
+ const fake = makeFakeLiveEvents();
391
+
392
+ renderWithLive(list("inbox:query:message:list"), dispatcher, fake.subscriber);
393
+
394
+ await waitFor(() => expect(resolvers.length).toBe(1));
395
+
396
+ act(() => {
397
+ fake.inject("message.created", {
398
+ id: "m2",
399
+ aggregateType: "message",
400
+ version: 1,
401
+ payload: {},
402
+ createdAt: "",
403
+ });
404
+ });
405
+ await waitFor(() => expect(resolvers.length).toBe(2));
406
+
407
+ act(() => {
408
+ resolvers[0]?.({
409
+ isSuccess: true,
410
+ data: { rows: [{ id: "m1", subject: "Hallo" }], nextCursor: null },
411
+ });
412
+ });
413
+ act(() => {
414
+ resolvers[1]?.({
415
+ isSuccess: true,
416
+ data: { rows: [{ id: "m1", subject: "Hallo" }], nextCursor: null },
417
+ });
418
+ });
419
+
420
+ await waitFor(() => expect(screen.getByText("Hallo")).toBeTruthy());
421
+ });
422
+ });
186
423
  });
@@ -0,0 +1,57 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+ import { fireEvent, render, screen, waitFor } from "../../__tests__/test-utils";
3
+ import { UploadZone } from "../upload-zone";
4
+
5
+ function pick(input: HTMLElement, files: readonly File[]): void {
6
+ Object.defineProperty(input, "files", { value: files, configurable: true });
7
+ fireEvent.change(input);
8
+ }
9
+
10
+ describe("UploadZone", () => {
11
+ test("lädt eine Datei hoch und zeigt den done-Status", async () => {
12
+ const onUpload = mock(async () => {});
13
+ render(<UploadZone title="Datei hochladen" onUpload={onUpload} testId="zone" />);
14
+ const file = new File(["hi"], "report.pdf", { type: "application/pdf" });
15
+ pick(screen.getByTestId("zone-input"), [file]);
16
+
17
+ await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1));
18
+ expect(onUpload).toHaveBeenCalledWith(file);
19
+ await waitFor(() => expect(screen.getByText("report.pdf")).toBeTruthy());
20
+ await waitFor(() => expect(screen.getByText("Uploaded")).toBeTruthy());
21
+ });
22
+
23
+ test("zeigt die Fehlermeldung wenn onUpload wirft", async () => {
24
+ const onUpload = mock(async () => {
25
+ throw new Error("too_large");
26
+ });
27
+ render(<UploadZone title="Datei hochladen" onUpload={onUpload} testId="zone" />);
28
+ const file = new File(["hi"], "huge.pdf", { type: "application/pdf" });
29
+ pick(screen.getByTestId("zone-input"), [file]);
30
+
31
+ await waitFor(() => expect(screen.getByText("too_large")).toBeTruthy());
32
+ expect(screen.getByText("Failed")).toBeTruthy();
33
+ });
34
+
35
+ test("multiple=false nimmt nur die erste Datei", async () => {
36
+ const onUpload = mock(async () => {});
37
+ render(
38
+ <UploadZone title="Datei hochladen" onUpload={onUpload} multiple={false} testId="zone" />,
39
+ );
40
+ const a = new File(["a"], "a.pdf");
41
+ const b = new File(["b"], "b.pdf");
42
+ pick(screen.getByTestId("zone-input"), [a, b]);
43
+
44
+ await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1));
45
+ expect(onUpload).toHaveBeenCalledWith(a);
46
+ });
47
+
48
+ test("resettet den Input nach dem Upload-Batch", async () => {
49
+ const onUpload = mock(async () => {});
50
+ render(<UploadZone title="Datei hochladen" onUpload={onUpload} testId="zone" />);
51
+ const input = screen.getByTestId("zone-input") as HTMLInputElement;
52
+ pick(input, [new File(["a"], "a.pdf")]);
53
+
54
+ await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1));
55
+ expect(input.value).toBe("");
56
+ });
57
+ });
@@ -56,4 +56,5 @@ export { SectionCard } from "./section-card";
56
56
  export { MiniStat, Sparkline, StatCard, type StatDelta, type StatTone } from "./stat";
57
57
  export { EmptyState, ErrorState, LoadingState } from "./states";
58
58
  export { STATUS_TONE_TEXT, StatusBadge, type StatusTone } from "./status-badge";
59
+ export { UploadZone, type UploadZoneProps } from "./upload-zone";
59
60
  export { useDraft } from "./use-draft";
@@ -1,5 +1,10 @@
1
1
  import type { DispatcherError } from "@cosmicdrift/kumiko-headless";
2
- import { useDispatcher, useTranslation } from "@cosmicdrift/kumiko-renderer";
2
+ import {
3
+ entityFromQueryType,
4
+ useDispatcher,
5
+ useLiveEvents,
6
+ useTranslation,
7
+ } from "@cosmicdrift/kumiko-renderer";
3
8
  import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
4
9
  import { EmptyState, ErrorState, LoadingState } from "./states";
5
10
 
@@ -12,11 +17,19 @@ export type InfinityListProps<TData = unknown, TRow = Readonly<Record<string, un
12
17
  readonly rows: (data: TData) => readonly TRow[];
13
18
  /** Pull the next-page cursor from the result; `null` means last page. */
14
19
  readonly nextCursor: (data: TData) => string | null;
20
+ /** Must derive from row content (e.g. `row.id`), not from `index` — a
21
+ * live refresh reorders rows (new/changed rows move to the front). */
15
22
  readonly rowId: (row: TRow, index: number) => string;
16
23
  readonly renderRow: (row: TRow) => ReactNode;
17
24
  readonly emptyState?: ReactNode;
18
25
  readonly className?: string;
19
26
  readonly testId?: string;
27
+ /** Subscribe to SSE events for the entity parsed from `query`
28
+ * (`<feature>:query:<entity>:<verb>`) and refetch the first page on
29
+ * any create/update/delete/restore event, merging it into the
30
+ * already-accumulated rows instead of collapsing them — see
31
+ * `useQuery`'s `live` option for the same convention. Default true. */
32
+ readonly live?: boolean;
20
33
  };
21
34
 
22
35
  type State<TRow> =
@@ -40,21 +53,26 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
40
53
  emptyState,
41
54
  className,
42
55
  testId,
56
+ live = true,
43
57
  }: InfinityListProps<TData, TRow>): ReactNode {
44
58
  const dispatcher = useDispatcher();
45
59
  const t = useTranslation();
60
+ const subscribeLive = useLiveEvents();
46
61
  const [state, setState] = useState<State<TRow>>({ kind: "loading" });
47
62
  const sentinelRef = useRef<HTMLDivElement | null>(null);
48
63
  const payloadKey = JSON.stringify(payload ?? {});
49
64
 
50
- // rows/nextCursor are fresh closures on every caller render (inline
51
- // arrow props). As useCallback deps that would recreate `load` every
52
- // render → the mount effect below would refetch in a loop. Refs keep
53
- // `load` stable while always reading the current selector.
65
+ // rows/nextCursor/rowId are fresh closures on every caller render
66
+ // (inline arrow props). As useCallback deps that would recreate
67
+ // `load`/`refreshFirstPage` every render → the effects below would
68
+ // refetch in a loop. Refs keep them stable while always reading the
69
+ // current selector.
54
70
  const rowsRef = useRef(rows);
55
71
  rowsRef.current = rows;
56
72
  const nextCursorRef = useRef(nextCursor);
57
73
  nextCursorRef.current = nextCursor;
74
+ const rowIdRef = useRef(rowId);
75
+ rowIdRef.current = rowId;
58
76
 
59
77
  // Discards a response whose request was superseded by a newer one before
60
78
  // it resolved (e.g. two searches fired in quick succession) — without
@@ -94,6 +112,46 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
94
112
  };
95
113
  }, [load]);
96
114
 
115
+ // Live-mode: on an SSE event for the query's entity, refetch only the
116
+ // first page and merge it in — rows the fresh page still contains move
117
+ // to the front (newest-first feeds), rows it dropped (edited/deleted
118
+ // elsewhere) are pruned, and everything beyond page 1 stays untouched.
119
+ // A full reload would collapse already-accumulated pages and jump the
120
+ // scroll position; see fw#1827.
121
+ // biome-ignore lint/correctness/useExhaustiveDependencies: payload goes through payloadKey
122
+ const refreshFirstPage = useCallback(async (): Promise<void> => {
123
+ // Read, don't bump: a concurrent load() (e.g. the mount fetch still
124
+ // in flight) must still land. Bumping here would make load()'s own
125
+ // sequence check discard it, and the refresh below then bails on
126
+ // `prev.kind !== "ready"` — the list gets stuck in loading forever.
127
+ const seqAtStart = requestSeq.current;
128
+ const res = await dispatcher.query<TData>(query, { ...payload, limit: pageSize });
129
+ if (seqAtStart !== requestSeq.current) return;
130
+ // skip: background live refresh failed, keep showing the current rows
131
+ if (!res.isSuccess) return;
132
+ const freshRows = rowsRef.current(res.data);
133
+ const freshIds = new Set(freshRows.map((row, index) => rowIdRef.current(row, index)));
134
+ setState((prev) => {
135
+ // skip: not showing an accumulated list yet, nothing to merge into
136
+ if (prev.kind !== "ready") return prev;
137
+ const staleRows = prev.rows.filter(
138
+ (row, index) => !freshIds.has(rowIdRef.current(row, index)),
139
+ );
140
+ return { kind: "ready", rows: [...freshRows, ...staleRows], cursor: prev.cursor };
141
+ });
142
+ }, [dispatcher, query, pageSize, payloadKey]);
143
+
144
+ useEffect(() => {
145
+ // skip: live mode off, no SSE subscription needed
146
+ if (!live) return;
147
+ const entity = entityFromQueryType(query);
148
+ // skip: query type has no mapped entity, nothing to subscribe to
149
+ if (entity === undefined) return;
150
+ return subscribeLive(entity, () => {
151
+ void refreshFirstPage();
152
+ });
153
+ }, [live, query, refreshFirstPage, subscribeLive]);
154
+
97
155
  useEffect(() => {
98
156
  const sentinel = sentinelRef.current;
99
157
  // skip: no further page or not ready yet — observer not needed
@@ -0,0 +1,158 @@
1
+ import { useTranslation } from "@cosmicdrift/kumiko-renderer";
2
+ import { CheckCircle2, FileUp, Loader2, TriangleAlert, Upload } from "lucide-react";
3
+ import { type DragEvent, type ReactNode, useId, useRef, useState } from "react";
4
+ import { cn } from "../lib/cn";
5
+
6
+ type UploadRowStatus = "uploading" | "done" | "error";
7
+
8
+ type UploadRow = {
9
+ readonly id: string;
10
+ readonly fileName: string;
11
+ readonly status: UploadRowStatus;
12
+ readonly error?: string;
13
+ };
14
+
15
+ const STATUS_ICON: Record<UploadRowStatus, ReactNode> = {
16
+ uploading: <Loader2 className="size-3.5 animate-spin text-muted-foreground" aria-hidden="true" />,
17
+ done: <CheckCircle2 className="size-3.5 text-primary" aria-hidden="true" />,
18
+ error: <TriangleAlert className="size-3.5 text-destructive" aria-hidden="true" />,
19
+ };
20
+
21
+ const STATUS_LABEL_KEY: Record<UploadRowStatus, string> = {
22
+ uploading: "kumiko.widget.upload.uploading",
23
+ done: "kumiko.widget.upload.done",
24
+ error: "kumiko.widget.upload.error",
25
+ };
26
+
27
+ export type UploadZoneProps = {
28
+ /** Uploads a single file (POST + optional follow-up mutation). Throws on
29
+ * failure — the message ends up in the row's error line. Runs in
30
+ * parallel per file within a batch (no sequential waiting). */
31
+ readonly onUpload: (file: File) => Promise<void>;
32
+ readonly title: ReactNode;
33
+ readonly hint?: ReactNode;
34
+ readonly accept?: readonly string[];
35
+ /** Allow multiple files per pick/drop. Defaults to true. */
36
+ readonly multiple?: boolean;
37
+ readonly disabled?: boolean;
38
+ readonly testId?: string;
39
+ };
40
+
41
+ // "jpg" → ".jpg", "image/png" stays as-is. Empty list → no accept attribute.
42
+ function toAcceptAttr(accept?: readonly string[]): string | undefined {
43
+ if (accept === undefined || accept.length === 0) return undefined;
44
+ return accept.map((a) => (a.startsWith(".") || a.includes("/") ? a : `.${a}`)).join(",");
45
+ }
46
+
47
+ /** Drop zone + multi-file picker with a per-file status row (uploading/done/
48
+ * error). For screens that accept several files and upload them
49
+ * independently — unlike `FileField` (single file, FileRef value inside a
50
+ * form field). `onUpload` decides what "upload" means (storage POST, ingest
51
+ * mutation, or both); the zone only tracks uploading/done/error. */
52
+ export function UploadZone({
53
+ onUpload,
54
+ title,
55
+ hint,
56
+ accept,
57
+ multiple = true,
58
+ disabled,
59
+ testId,
60
+ }: UploadZoneProps): ReactNode {
61
+ const t = useTranslation();
62
+ const inputId = useId();
63
+ const inputRef = useRef<HTMLInputElement>(null);
64
+ const [rows, setRows] = useState<readonly UploadRow[]>([]);
65
+ const [dragOver, setDragOver] = useState(false);
66
+
67
+ async function uploadOne(file: File): Promise<void> {
68
+ const rowId = crypto.randomUUID();
69
+ setRows((prev) => [...prev, { id: rowId, fileName: file.name, status: "uploading" }]);
70
+ try {
71
+ await onUpload(file);
72
+ setRows((prev) => prev.map((row) => (row.id === rowId ? { ...row, status: "done" } : row)));
73
+ } catch (cause) {
74
+ const message = cause instanceof Error ? cause.message : "upload_failed";
75
+ setRows((prev) =>
76
+ prev.map((row) => (row.id === rowId ? { ...row, status: "error", error: message } : row)),
77
+ );
78
+ }
79
+ }
80
+
81
+ async function uploadFiles(files: FileList | null): Promise<void> {
82
+ if (files === null || files.length === 0) return;
83
+ const picked = multiple ? Array.from(files) : files[0] !== undefined ? [files[0]] : [];
84
+ await Promise.all(picked.map((file) => uploadOne(file)));
85
+ // Reset so re-picking the SAME file still fires change — the browser
86
+ // suppresses the event otherwise.
87
+ if (inputRef.current) inputRef.current.value = "";
88
+ }
89
+
90
+ function handleDrop(e: DragEvent<HTMLLabelElement>): void {
91
+ e.preventDefault();
92
+ setDragOver(false);
93
+ if (disabled === true) return;
94
+ void uploadFiles(e.dataTransfer.files);
95
+ }
96
+
97
+ const acceptAttr = toAcceptAttr(accept);
98
+
99
+ return (
100
+ <div data-testid={testId} className="flex flex-col gap-4">
101
+ <label
102
+ htmlFor={inputId}
103
+ onDragOver={(e) => {
104
+ e.preventDefault();
105
+ if (disabled !== true) setDragOver(true);
106
+ }}
107
+ onDragLeave={(e) => {
108
+ if (!(e.relatedTarget instanceof Node) || !e.currentTarget.contains(e.relatedTarget)) {
109
+ setDragOver(false);
110
+ }
111
+ }}
112
+ onDrop={handleDrop}
113
+ data-testid={testId !== undefined ? `${testId}-dropzone` : undefined}
114
+ className={cn(
115
+ "flex w-full cursor-pointer flex-col items-center gap-3 rounded-lg border-2 border-dashed p-10 text-center transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring has-[:focus-visible]:ring-offset-2",
116
+ dragOver ? "border-primary bg-muted/40" : "border-border",
117
+ disabled === true && "cursor-not-allowed opacity-50",
118
+ )}
119
+ >
120
+ <Upload className="size-8 text-muted-foreground" aria-hidden="true" />
121
+ <span className="text-sm">{title}</span>
122
+ {hint !== undefined && <span className="text-sm text-muted-foreground">{hint}</span>}
123
+ <input
124
+ ref={inputRef}
125
+ id={inputId}
126
+ type="file"
127
+ multiple={multiple}
128
+ className="sr-only"
129
+ data-testid={testId !== undefined ? `${testId}-input` : undefined}
130
+ disabled={disabled}
131
+ {...(acceptAttr !== undefined && { accept: acceptAttr })}
132
+ onChange={(e) => void uploadFiles(e.target.files)}
133
+ />
134
+ </label>
135
+ {rows.length > 0 && (
136
+ <ul className="flex flex-col gap-2">
137
+ {rows.map((row) => (
138
+ <li
139
+ key={row.id}
140
+ data-testid={testId !== undefined ? `${testId}-row` : undefined}
141
+ className="flex items-center justify-between gap-3 rounded-md border border-border px-4 py-2"
142
+ >
143
+ <div className="flex min-w-0 items-center gap-2">
144
+ <FileUp className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
145
+ <span className="truncate text-sm">{row.fileName}</span>
146
+ </div>
147
+ <output className="flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground">
148
+ {STATUS_ICON[row.status]}
149
+ <span className="sr-only">{t(STATUS_LABEL_KEY[row.status])}</span>
150
+ {row.status === "error" && row.error !== undefined && <span>{row.error}</span>}
151
+ </output>
152
+ </li>
153
+ ))}
154
+ </ul>
155
+ )}
156
+ </div>
157
+ );
158
+ }