@cosmicdrift/kumiko-renderer-web 0.195.0 → 0.196.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.195.0",
3
+ "version": "0.196.1",
4
4
  "description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -16,9 +16,9 @@
16
16
  "./styles.css": "./src/styles.css"
17
17
  },
18
18
  "dependencies": {
19
- "@cosmicdrift/kumiko-dispatcher-live": "0.195.0",
20
- "@cosmicdrift/kumiko-headless": "0.195.0",
21
- "@cosmicdrift/kumiko-renderer": "0.195.0",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.196.1",
20
+ "@cosmicdrift/kumiko-headless": "0.196.1",
21
+ "@cosmicdrift/kumiko-renderer": "0.196.1",
22
22
  "@radix-ui/react-dialog": "^1.1.15",
23
23
  "@radix-ui/react-dropdown-menu": "^2.1.16",
24
24
  "@radix-ui/react-label": "^2.1.8",
@@ -14,7 +14,7 @@ import {
14
14
  type RenderEditControls,
15
15
  useExtensionFormSubmit,
16
16
  } from "@cosmicdrift/kumiko-renderer";
17
- import { useState } from "react";
17
+ import { type ReactNode, useCallback, useState } from "react";
18
18
  import { z } from "zod";
19
19
  import {
20
20
  act,
@@ -757,6 +757,55 @@ describe("RenderEdit — controlled mode (#1887)", () => {
757
757
  expect(controls?.getValues().count).toBe("Acme".length);
758
758
  });
759
759
 
760
+ // fw#1899: a caller that supplies onControlsReady only after this mount
761
+ // (e.g. `onControlsReady={ready ? handler : undefined}`) must still get
762
+ // delivered to for THIS mount — the effect's deps previously excluded
763
+ // onControlsReady itself, so it never re-ran once mounted without it.
764
+ test("onControlsReady delivers to a handler supplied after mount, not just at mount time", () => {
765
+ function Host(): ReactNode {
766
+ const [ready, setReady] = useState(false);
767
+ const [received, setReceived] = useState<RenderEditControls<TestValues> | undefined>(
768
+ undefined,
769
+ );
770
+ // Stable identity across renders, like a real caller's useCallback:
771
+ // an inline arrow here would change on every render and defeat the
772
+ // re-delivery guard's identity check, looping the effect.
773
+ const onControlsReady = useCallback((c: RenderEditControls<TestValues>) => {
774
+ setReceived(c);
775
+ }, []);
776
+ return (
777
+ <>
778
+ <button type="button" data-testid="make-ready" onClick={() => setReady(true)}>
779
+ ready
780
+ </button>
781
+ <div data-testid="received">{received !== undefined ? "yes" : "no"}</div>
782
+ <RenderEdit<TestValues>
783
+ screen={makeScreen()}
784
+ entity={orderEntity}
785
+ featureName="orders"
786
+ initial={{ title: "", count: 0, isUrgent: false }}
787
+ writeCommand="order:create"
788
+ {...(ready && { onControlsReady })}
789
+ />
790
+ </>
791
+ );
792
+ }
793
+
794
+ render(
795
+ <DispatcherProvider dispatcher={makeDispatcher()}>
796
+ <Host />
797
+ </DispatcherProvider>,
798
+ );
799
+
800
+ expect(screen.getByTestId("received").textContent).toBe("no");
801
+
802
+ act(() => {
803
+ fireEvent.click(screen.getByTestId("make-ready"));
804
+ });
805
+
806
+ expect(screen.getByTestId("received").textContent).toBe("yes");
807
+ });
808
+
760
809
  test("controls.patch sets values from outside without losing edits already made in other fields", () => {
761
810
  let controls: RenderEditControls<TestValues> | undefined;
762
811
  render(
@@ -1230,6 +1279,122 @@ describe("RenderEdit wizard mode", () => {
1230
1279
  expect(screen.queryByTestId("field-title-errors")).toBeNull();
1231
1280
  });
1232
1281
 
1282
+ // fw#1901: handleWizardNext used to validate every field in the section,
1283
+ // including ones currently hidden by their own condition. A required
1284
+ // field that only applies conditionally (e.g. a VAT id shown only for
1285
+ // companies) must not permanently block "Next" while it's hidden. Note:
1286
+ // form-controller.ts's runValidate() already excludes hidden-field
1287
+ // issues from every validate() call via its own `hiddenFields` set
1288
+ // (form-controller.ts:200), independent of scope; this test protects
1289
+ // that existing guard, not the fieldNames narrowing added here.
1290
+ test("Weiter is not blocked by a required field that's currently hidden by its own condition", async () => {
1291
+ const companyEntity = {
1292
+ fields: {
1293
+ isCompany: { type: "boolean" },
1294
+ vatId: { type: "text", required: true },
1295
+ count: { type: "number" },
1296
+ },
1297
+ } as unknown as EntityDefinition;
1298
+ const wizardScreen: EntityEditScreenDefinition = {
1299
+ id: "orders:screen:order-wizard-conditional",
1300
+ type: "entityEdit",
1301
+ entity: "order",
1302
+ layout: {
1303
+ mode: "wizard",
1304
+ sections: [
1305
+ {
1306
+ title: "Basics",
1307
+ columns: 1,
1308
+ fields: [
1309
+ "isCompany",
1310
+ {
1311
+ field: "vatId",
1312
+ visible: { field: "isCompany", eq: true },
1313
+ required: { field: "isCompany", eq: true },
1314
+ },
1315
+ ],
1316
+ },
1317
+ { title: "Details", columns: 1, fields: [{ field: "count" }] },
1318
+ ],
1319
+ },
1320
+ };
1321
+ // A schema that (realistically) doesn't special-case the conditional
1322
+ // requirement itself — it's the FieldCondition's hidden-field exclusion
1323
+ // that's supposed to keep this from blocking, not the schema.
1324
+ const schema = z.object({ isCompany: z.boolean(), vatId: z.string().min(1) });
1325
+
1326
+ render(
1327
+ <DispatcherProvider dispatcher={makeDispatcher()}>
1328
+ <RenderEdit
1329
+ screen={wizardScreen}
1330
+ entity={companyEntity}
1331
+ featureName="orders"
1332
+ initial={{ isCompany: false, vatId: "", count: 0 } as never}
1333
+ writeCommand="order:create"
1334
+ schema={schema}
1335
+ />
1336
+ </DispatcherProvider>,
1337
+ );
1338
+
1339
+ expect(screen.queryByTestId("field-vatId")).toBeNull();
1340
+
1341
+ await act(async () => {
1342
+ fireEvent.click(screen.getByTestId("render-edit-wizard-next"));
1343
+ await Promise.resolve();
1344
+ });
1345
+
1346
+ expect(screen.getByTestId("field-count")).toBeTruthy();
1347
+ expect(screen.queryByTestId("render-edit-wizard-next")).toBeNull();
1348
+ });
1349
+
1350
+ // fw#1901: a section that's entirely hidden (every field in it currently
1351
+ // condition-hidden) must not occupy its own wizard step — it would render
1352
+ // empty and the step count/progress would include a step nobody can see.
1353
+ test("a fully-hidden section is skipped in the wizard step count instead of rendering an empty step", async () => {
1354
+ const wizardScreen: EntityEditScreenDefinition = {
1355
+ id: "orders:screen:order-wizard-hidden-section",
1356
+ type: "entityEdit",
1357
+ entity: "order",
1358
+ layout: {
1359
+ mode: "wizard",
1360
+ sections: [
1361
+ { title: "Basics", columns: 1, fields: [{ field: "title" }] },
1362
+ {
1363
+ title: "Company-only",
1364
+ columns: 1,
1365
+ fields: [{ field: "notes", visible: { field: "isUrgent", eq: true } }],
1366
+ },
1367
+ { title: "Details", columns: 1, fields: [{ field: "count" }] },
1368
+ ],
1369
+ },
1370
+ };
1371
+
1372
+ render(
1373
+ <DispatcherProvider dispatcher={makeDispatcher()}>
1374
+ <RenderEdit<TestValues>
1375
+ screen={wizardScreen}
1376
+ entity={orderEntity}
1377
+ featureName="orders"
1378
+ initial={{ title: "Acme", count: 0, isUrgent: false }}
1379
+ writeCommand="order:create"
1380
+ />
1381
+ </DispatcherProvider>,
1382
+ );
1383
+
1384
+ // Only 2 real steps (Basics, Details) — "Company-only" is entirely
1385
+ // hidden (isUrgent is false) and must not count as a step.
1386
+ expect(screen.getByTestId("render-edit-wizard-step-label").textContent).toContain("of 2");
1387
+
1388
+ await act(async () => {
1389
+ fireEvent.click(screen.getByTestId("render-edit-wizard-next"));
1390
+ await Promise.resolve();
1391
+ });
1392
+
1393
+ // Landed directly on Details, skipping the hidden "Company-only" step.
1394
+ expect(screen.getByTestId("field-count")).toBeTruthy();
1395
+ expect(screen.queryByTestId("render-edit-wizard-next")).toBeNull();
1396
+ });
1397
+
1233
1398
  // A server-side field error can land on a field belonging to a step the
1234
1399
  // user isn't currently viewing — the old `setFormError(fieldIssues.length
1235
1400
  // === 0 ? result.error : null)` rule suppressed the banner in that case
@@ -1605,6 +1770,62 @@ describe("RenderEdit wizard draft", () => {
1605
1770
  expect(calls).toContain("form-draft:write:discard");
1606
1771
  });
1607
1772
 
1773
+ // fw#1978: payloadMode "changes" + an untouched form means
1774
+ // controller.submit() never calls dispatcher.write (nothing changed to
1775
+ // send) — handleSubmit must not treat that no-write success like a normal
1776
+ // submit and discard the draft underneath it, or a host driving
1777
+ // controls.submit() on a pre-filled, untouched form silently loses it.
1778
+ test("payloadMode 'changes' + an unchanged form: submit() is a no-op that keeps the draft alive", async () => {
1779
+ const { dispatcher, store, calls } = makeDraftDispatcher();
1780
+ let controls: RenderEditControls<TestValues> | undefined;
1781
+ let submitResult: SubmitResult<unknown> | undefined;
1782
+
1783
+ render(
1784
+ <DispatcherProvider dispatcher={dispatcher}>
1785
+ <RenderEdit<TestValues>
1786
+ screen={makeDraftWizardScreen(true)}
1787
+ entity={orderEntity}
1788
+ featureName="orders"
1789
+ initial={{ title: "Acme", count: 0 }}
1790
+ writeCommand="order:create"
1791
+ payloadMode="changes"
1792
+ onControlsReady={(c) => {
1793
+ controls = c;
1794
+ }}
1795
+ onSubmit={(result) => {
1796
+ submitResult = result;
1797
+ }}
1798
+ />
1799
+ </DispatcherProvider>,
1800
+ );
1801
+
1802
+ // Step to the last wizard step without touching any field — saveDraft()
1803
+ // persists the current (unmodified) values, so a draft exists, but the
1804
+ // form itself is still not dirty.
1805
+ await act(async () => {
1806
+ fireEvent.click(screen.getByTestId("render-edit-wizard-next"));
1807
+ await Promise.resolve();
1808
+ });
1809
+ await waitFor(() => expect(store.current).not.toBeNull());
1810
+ calls.length = 0;
1811
+
1812
+ // The built-in Save button stays disabled on an unchanged form — drive
1813
+ // the write the way a host would (RenderEditControls.submit(), the API
1814
+ // this changeset's "pre-filled, untouched form" use case documents).
1815
+ await act(async () => {
1816
+ await controls?.submit();
1817
+ });
1818
+
1819
+ expect(submitResult?.validationBlocked).toBe(false);
1820
+ if (submitResult?.validationBlocked === false) {
1821
+ expect(submitResult.isSuccess).toBe(true);
1822
+ expect(submitResult.isNoOp).toBe(true);
1823
+ }
1824
+ expect(calls).not.toContain("order:create");
1825
+ expect(calls).not.toContain("form-draft:write:discard");
1826
+ expect(store.current).not.toBeNull();
1827
+ });
1828
+
1608
1829
  test("without layout.draft nothing hits the form-draft feature", async () => {
1609
1830
  const { dispatcher, calls } = makeDraftDispatcher();
1610
1831
 
@@ -1863,6 +2084,41 @@ describe("RenderEdit wizard draft", () => {
1863
2084
  expect(savesSoFar()).toBe(before + 1);
1864
2085
  });
1865
2086
 
2087
+ // fw#1932: a patch() inside the 500ms debounce window followed by an
2088
+ // unmount (navigation away, dialog close) must not drop the save — the
2089
+ // old cleanup only cleared the timer, silently discarding the last edit.
2090
+ test("a pending debounced patch-save flushes on unmount instead of being dropped", async () => {
2091
+ const { dispatcher, store } = makeDraftDispatcher();
2092
+ let controls: RenderEditControls<TestValues> | undefined;
2093
+
2094
+ const rendered = render(
2095
+ <DispatcherProvider dispatcher={dispatcher}>
2096
+ <RenderEdit<TestValues>
2097
+ screen={makeDraftWizardScreen(true)}
2098
+ entity={orderEntity}
2099
+ featureName="orders"
2100
+ initial={{ title: "Acme", count: 0 }}
2101
+ writeCommand="order:create"
2102
+ onControlsReady={(c) => {
2103
+ controls = c;
2104
+ }}
2105
+ />
2106
+ </DispatcherProvider>,
2107
+ );
2108
+
2109
+ fireEvent.click(screen.getByTestId("render-edit-wizard-next"));
2110
+ expect(screen.getByTestId("field-count")).toBeTruthy();
2111
+
2112
+ // Arms the debounce, then unmounts well inside the 500ms window — before
2113
+ // the timer could ever fire on its own.
2114
+ act(() => {
2115
+ controls?.patch({ count: 7 });
2116
+ });
2117
+ rendered.unmount();
2118
+
2119
+ await waitFor(() => expect(store.current?.values["count"]).toBe(7));
2120
+ });
2121
+
1866
2122
  // #1914's debounce must not resurrect a draft after it was intentionally
1867
2123
  // ended: discardDraft() clears the pending timer (render-edit.tsx:735-738)
1868
2124
  // specifically so a patch() immediately followed by submit can't have its
@@ -2613,6 +2869,90 @@ describe("RenderEdit locked state (#1896)", () => {
2613
2869
  const titleInput = screen.getByTestId("field-title").querySelector("input");
2614
2870
  expect((titleInput as HTMLInputElement).disabled).toBe(false);
2615
2871
  });
2872
+
2873
+ test("disabled prevents the Delete button from invoking onDelete (fw#1909)", async () => {
2874
+ const onDelete = mock(async () => {});
2875
+ render(
2876
+ <DispatcherProvider dispatcher={makeDispatcher()}>
2877
+ <RenderEdit<TestValues>
2878
+ screen={makeScreen()}
2879
+ entity={orderEntity}
2880
+ featureName="orders"
2881
+ initial={{ title: "Acme", count: 1, isUrgent: false }}
2882
+ writeCommand="order:create"
2883
+ onDelete={onDelete}
2884
+ disabled
2885
+ />
2886
+ </DispatcherProvider>,
2887
+ );
2888
+
2889
+ const deleteButton = screen.getByTestId("render-edit-delete") as HTMLButtonElement;
2890
+ expect(deleteButton.disabled).toBe(true);
2891
+ fireEvent.click(deleteButton);
2892
+ expect(screen.queryByTestId("render-edit-delete-dialog")).toBeNull();
2893
+ expect(onDelete).not.toHaveBeenCalled();
2894
+ });
2895
+
2896
+ test("disabled prevents picking a draft candidate from adopting it (fw#1909)", async () => {
2897
+ const screenDef: EntityEditScreenDefinition = {
2898
+ id: "orders:screen:order-wizard-locked-draftpicker",
2899
+ type: "entityEdit",
2900
+ entity: "order",
2901
+ layout: {
2902
+ mode: "wizard",
2903
+ draft: true,
2904
+ sections: [
2905
+ { title: "Basics", columns: 1, fields: [{ field: "title" }] },
2906
+ { title: "Details", columns: 1, fields: [{ field: "count" }] },
2907
+ ],
2908
+ },
2909
+ };
2910
+ const candidates = [
2911
+ {
2912
+ id: `${screenDef.id}:new:draft-1`,
2913
+ draftKey: `${screenDef.id}:new:draft-1`,
2914
+ stepIndex: 0,
2915
+ savedAt: "2026-01-01T00:00:00Z",
2916
+ },
2917
+ {
2918
+ id: `${screenDef.id}:new:draft-2`,
2919
+ draftKey: `${screenDef.id}:new:draft-2`,
2920
+ stepIndex: 0,
2921
+ savedAt: "2026-01-02T00:00:00Z",
2922
+ },
2923
+ ];
2924
+ const dispatcher = createMockDispatcher({
2925
+ query: (async (type: string) => {
2926
+ if (type === "form-draft:query:list")
2927
+ return { isSuccess: true, data: { drafts: candidates } };
2928
+ return { isSuccess: true, data: {} };
2929
+ }) as Dispatcher["query"],
2930
+ write: (async () => ({ isSuccess: true, data: { id: "1" } })) as Dispatcher["write"],
2931
+ });
2932
+
2933
+ render(
2934
+ <DispatcherProvider dispatcher={dispatcher}>
2935
+ <DraftStorageProvider value={createFakeDraftStorage()}>
2936
+ <RenderEdit<TestValues>
2937
+ screen={screenDef}
2938
+ entity={orderEntity}
2939
+ featureName="orders"
2940
+ initial={{ title: "", count: 0 }}
2941
+ writeCommand="order:create"
2942
+ disabled
2943
+ />
2944
+ </DraftStorageProvider>
2945
+ </DispatcherProvider>,
2946
+ );
2947
+
2948
+ await waitFor(() => expect(screen.getByTestId("render-edit-draft-picker")).toBeTruthy());
2949
+ const pickSecond = screen.getByTestId(`render-edit-draft-pick-${screenDef.id}:new:draft-2`);
2950
+ fireEvent.click(pickSecond);
2951
+
2952
+ expect(screen.getByTestId("render-edit-draft-picker")).toBeTruthy();
2953
+ const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
2954
+ expect(titleInput.value).toBe("");
2955
+ });
2616
2956
  });
2617
2957
 
2618
2958
  describe("RenderEdit hideActions (host-driven action bar)", () => {
@@ -101,6 +101,7 @@ import {
101
101
  SidebarMenuSub,
102
102
  SidebarMenuSubButton,
103
103
  SidebarMenuSubItem,
104
+ useSidebar,
104
105
  } from "../ui/sidebar";
105
106
  import { useDispatchTarget } from "./target-resolver-stub";
106
107
  import { parseTargetFromSearchParams } from "./target-url";
@@ -224,7 +225,14 @@ export function NavTree({
224
225
  }, []);
225
226
 
226
227
  const t = useTranslation();
228
+ const { state: sidebarState } = useSidebar();
227
229
  const [filter, setFilter] = useState("");
230
+ // The search box is only CSS-hidden (`group-data-[collapsible=icon]:hidden`)
231
+ // when the sidebar collapses to icon rail, not unmounted — without this,
232
+ // a stale filter re-applies invisibly on the next expand (fw#1816).
233
+ useEffect(() => {
234
+ if (sidebarState === "collapsed") setFilter("");
235
+ }, [sidebarState]);
228
236
  const q = filter.trim().toLowerCase();
229
237
  const matches = useCallback(
230
238
  (raw: string): boolean => {
@@ -9,11 +9,16 @@ describe("resizeImageBeforeUpload", () => {
9
9
  });
10
10
 
11
11
  test("fehlt OffscreenCanvas, bleibt das Bild unverändert", async () => {
12
+ const originalOffscreenCanvas = globalThis.OffscreenCanvas;
12
13
  // @ts-expect-error simulate a browser without OffscreenCanvas support
13
14
  globalThis.OffscreenCanvas = undefined;
14
- const file = new File(["hi"], "photo.jpg", { type: "image/jpeg" });
15
- const result = await resizeImageBeforeUpload(file);
16
- expect(result).toBe(file);
15
+ try {
16
+ const file = new File(["hi"], "photo.jpg", { type: "image/jpeg" });
17
+ const result = await resizeImageBeforeUpload(file);
18
+ expect(result).toBe(file);
19
+ } finally {
20
+ globalThis.OffscreenCanvas = originalOffscreenCanvas;
21
+ }
17
22
  });
18
23
 
19
24
  test("lässt SVGs unverändert (Vektor würde beim Re-Encode zerstört)", async () => {
@@ -0,0 +1,5 @@
1
+ // "jpg" → ".jpg", "image/png" stays as-is. Empty list → no accept attribute.
2
+ export function toAcceptAttr(accept?: readonly string[]): string | undefined {
3
+ if (accept === undefined || accept.length === 0) return undefined;
4
+ return accept.map((a) => (a.startsWith(".") || a.includes("/") ? a : `.${a}`)).join(",");
5
+ }
@@ -443,6 +443,18 @@ export function EmbeddedListInput({
443
443
  const isTabForward = event.key === "Tab" && !event.shiftKey;
444
444
  const isEnter = event.key === "Enter";
445
445
  if (!isTabForward && !isEnter) return;
446
+ // A select/reference last column renders its picker trigger as a
447
+ // <button> (or an open cmdk popover's own input) in the cell — Enter
448
+ // there is the browser's native button-activation, not "append a row".
449
+ // Returning before preventDefault() lets that activation proceed;
450
+ // Tab still falls through to the append-row behavior below.
451
+ if (
452
+ isEnter &&
453
+ event.target instanceof HTMLElement &&
454
+ event.target.closest('button, [role="combobox"], [cmdk-input]') !== null
455
+ ) {
456
+ return;
457
+ }
446
458
  if (maxItems !== undefined && rows.length >= maxItems) return;
447
459
  // Enter bubbles up from the cell control through this TableCell — an
448
460
  // ancestor of any <form> the caller wraps the whole field in.
@@ -6,6 +6,7 @@
6
6
  import { CSRF_HEADER_NAME, readCsrfToken } from "@cosmicdrift/kumiko-dispatcher-live";
7
7
  import { ImageIcon, Loader2, Upload } from "lucide-react";
8
8
  import { type ChangeEvent, type ReactNode, useRef, useState } from "react";
9
+ import { toAcceptAttr } from "../lib/accept-attr";
9
10
  import { resizeImageBeforeUpload } from "../lib/resize-image";
10
11
  import { Button as UiButton } from "../ui/button";
11
12
 
@@ -22,12 +23,6 @@ export type FileUploadInputProps = {
22
23
  readonly capture?: "environment" | "user";
23
24
  };
24
25
 
25
- // "jpg" → ".jpg", "image/png" bleibt. Leere Liste → kein accept-Attribut.
26
- function toAcceptAttr(accept?: readonly string[]): string | undefined {
27
- if (accept === undefined || accept.length === 0) return undefined;
28
- return accept.map((a) => (a.startsWith(".") || a.includes("/") ? a : `.${a}`)).join(",");
29
- }
30
-
31
26
  export function FileUploadInput({
32
27
  kind,
33
28
  id,
@@ -69,6 +69,7 @@ import {
69
69
  type ChangeEvent,
70
70
  type CSSProperties,
71
71
  createContext,
72
+ type MouseEvent,
72
73
  type ReactNode,
73
74
  useContext,
74
75
  useEffect,
@@ -732,6 +733,12 @@ function DefaultDataTable({
732
733
  // if the sum of the columns gets too wide.
733
734
  className={cn("max-w-xs truncate", col.highlighted === true && "bg-accent/40")}
734
735
  title={cellTitle(row.values[col.field])}
736
+ // A click into the editable cell's widget must not also
737
+ // trigger the row's onClick (typically "Open Detail") —
738
+ // same reasoning as the actions cell below.
739
+ {...(onCellChange !== undefined && {
740
+ onClick: (e: MouseEvent) => e.stopPropagation(),
741
+ })}
735
742
  >
736
743
  <DataTableCell
737
744
  value={row.values[col.field]}
@@ -1,3 +1,4 @@
1
+ import { useTranslation } from "@cosmicdrift/kumiko-renderer";
1
2
  import { Maximize2Icon, Minimize2Icon } from "lucide-react";
2
3
  import { type ReactNode, useRef, useState } from "react";
3
4
  import { clamp } from "../lib/clamp";
@@ -79,6 +80,7 @@ export function Drawer({
79
80
  resize,
80
81
  backdrop,
81
82
  }: DrawerProps): ReactNode {
83
+ const t = useTranslation();
82
84
  const canResize = resize !== undefined && (side === "left" || side === "right");
83
85
  const minWidthPx = resize?.minWidthPx ?? MIN_WIDTH_PX;
84
86
  const maxWidthPx = resize?.maxWidthPx ?? MAX_WIDTH_PX;
@@ -152,7 +154,9 @@ export function Drawer({
152
154
  type="button"
153
155
  onClick={() => setMaximized((m) => !m)}
154
156
  aria-pressed={maximized}
155
- aria-label={maximized ? "Restore drawer width" : "Maximize drawer width"}
157
+ aria-label={
158
+ maximized ? t("kumiko.widget.drawer.restore") : t("kumiko.widget.drawer.maximize")
159
+ }
156
160
  className="absolute top-4 right-14 z-10 rounded-xs p-1 text-muted-foreground opacity-70 transition-opacity hover:opacity-100 hover:bg-secondary focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-hidden"
157
161
  >
158
162
  {maximized ? (
@@ -175,7 +179,7 @@ export function Drawer({
175
179
  <div
176
180
  role="separator"
177
181
  aria-orientation="vertical"
178
- aria-label="Resize drawer"
182
+ aria-label={t("kumiko.widget.drawer.resize")}
179
183
  aria-valuenow={effectiveWidthPx}
180
184
  aria-valuemin={minWidthPx}
181
185
  aria-valuemax={effectiveMaxWidthPx()}
@@ -20,6 +20,12 @@ export type InfinityListProps<TData = unknown, TRow = Readonly<Record<string, un
20
20
  /** Must derive from row content (e.g. `row.id`), not from `index` — a
21
21
  * live refresh reorders rows (new/changed rows move to the front). */
22
22
  readonly rowId: (row: TRow, index: number) => string;
23
+ /** Pass this when `rowId` depends on list position — the live-merge path
24
+ * needs a position-independent identity. Without it, an index-based
25
+ * `rowId` (legitimate when live-merge isn't used) can collide between
26
+ * the freshly-fetched first page's local indices and the accumulated
27
+ * list's indices, silently dropping an unrelated mid-list row on merge. */
28
+ readonly rowKey?: (row: TRow) => string;
23
29
  readonly renderRow: (row: TRow) => ReactNode;
24
30
  readonly emptyState?: ReactNode;
25
31
  readonly className?: string;
@@ -49,6 +55,7 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
49
55
  rows,
50
56
  nextCursor,
51
57
  rowId,
58
+ rowKey,
52
59
  renderRow,
53
60
  emptyState,
54
61
  className,
@@ -73,6 +80,16 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
73
80
  nextCursorRef.current = nextCursor;
74
81
  const rowIdRef = useRef(rowId);
75
82
  rowIdRef.current = rowId;
83
+ const rowKeyRef = useRef(rowKey);
84
+ rowKeyRef.current = rowKey;
85
+ // Position-independent when rowKey is supplied, otherwise falls back to
86
+ // the (possibly position-dependent) rowId — same identity function used
87
+ // for both sides of the live-merge diff below so a position-based rowId
88
+ // can no longer collide across the fresh page's local indices and the
89
+ // accumulated list's indices (fw#1829).
90
+ const identifyRef = useRef((row: TRow, index: number): string =>
91
+ rowKeyRef.current !== undefined ? rowKeyRef.current(row) : rowIdRef.current(row, index),
92
+ );
76
93
 
77
94
  // Discards a response whose request was superseded by a newer one before
78
95
  // it resolved (e.g. two searches fired in quick succession) — without
@@ -102,8 +119,11 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
102
119
  }
103
120
  const nextRows = rowsRef.current(res.data);
104
121
  if (cursor === null) {
122
+ // Same identity as refreshFirstPage's freshIds/staleRows diff below
123
+ // — this ref is what that diff's previousFirstPageIds reads, so it
124
+ // must agree on rowKey vs rowId or the two id spaces never overlap.
105
125
  firstPageIdsRef.current = new Set(
106
- nextRows.map((row, index) => rowIdRef.current(row, index)),
126
+ nextRows.map((row, index) => identifyRef.current(row, index)),
107
127
  );
108
128
  }
109
129
  setState((prev) => {
@@ -154,7 +174,7 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
154
174
  // skip: background live refresh failed, keep showing the current rows
155
175
  if (!res.isSuccess) return;
156
176
  const freshRows = rowsRef.current(res.data);
157
- const freshIds = new Set(freshRows.map((row, index) => rowIdRef.current(row, index)));
177
+ const freshIds = new Set(freshRows.map((row, index) => identifyRef.current(row, index)));
158
178
  // A row absent from freshIds is stale for one of two reasons: it was
159
179
  // dropped from page 1 (deleted, or filtered out elsewhere) and must be
160
180
  // pruned, or it belongs to an already-accumulated later page and must
@@ -163,10 +183,18 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
163
183
  const previousFirstPageIds = firstPageIdsRef.current;
164
184
  firstPageIdsRef.current = freshIds;
165
185
  setState((prev) => {
166
- // skip: not showing an accumulated list yet, nothing to merge into
167
- if (prev.kind !== "ready") return prev;
186
+ // skip: the initial load() is still in flight and owns the eventual
187
+ // state a concurrent refresh landing here has nothing accumulated
188
+ // to merge into yet.
189
+ if (prev.kind === "loading") return prev;
190
+ if (prev.kind !== "ready") {
191
+ // prev.kind === "error": this refresh succeeded — promote straight
192
+ // to "ready" instead of leaving the list stuck on the earlier
193
+ // failure forever (a live event should be able to recover it).
194
+ return { kind: "ready", rows: freshRows, cursor: nextCursorRef.current(res.data) };
195
+ }
168
196
  const staleRows = prev.rows.filter((row, index) => {
169
- const id = rowIdRef.current(row, index);
197
+ const id = identifyRef.current(row, index);
170
198
  return !freshIds.has(id) && !previousFirstPageIds.has(id);
171
199
  });
172
200
  return { kind: "ready", rows: [...freshRows, ...staleRows], cursor: prev.cursor };
@@ -1,6 +1,7 @@
1
1
  import { useTranslation } from "@cosmicdrift/kumiko-renderer";
2
2
  import { CheckCircle2, FileUp, Loader2, TriangleAlert, Upload } from "lucide-react";
3
3
  import { type DragEvent, type ReactNode, useId, useRef, useState } from "react";
4
+ import { toAcceptAttr } from "../lib/accept-attr";
4
5
  import { cn } from "../lib/cn";
5
6
  import { resizeImageBeforeUpload } from "../lib/resize-image";
6
7
 
@@ -39,12 +40,6 @@ export type UploadZoneProps = {
39
40
  readonly testId?: string;
40
41
  };
41
42
 
42
- // "jpg" → ".jpg", "image/png" stays as-is. Empty list → no accept attribute.
43
- function toAcceptAttr(accept?: readonly string[]): string | undefined {
44
- if (accept === undefined || accept.length === 0) return undefined;
45
- return accept.map((a) => (a.startsWith(".") || a.includes("/") ? a : `.${a}`)).join(",");
46
- }
47
-
48
43
  // `accept` on the native <input> only filters the file-picker dialog — a
49
44
  // drag&drop drop is never routed through it, so any file type lands in
50
45
  // `onUpload` regardless of what `accept` promises. Same matching rules as