@cosmicdrift/kumiko-renderer-web 0.221.0 → 0.222.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.221.0",
3
+ "version": "0.222.0",
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.221.0",
20
- "@cosmicdrift/kumiko-headless": "0.221.0",
21
- "@cosmicdrift/kumiko-renderer": "0.221.0",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.222.0",
20
+ "@cosmicdrift/kumiko-headless": "0.222.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.222.0",
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",
@@ -64,7 +64,7 @@
64
64
  "@types/react-dom": "^19.2.3",
65
65
  "jsdom": "^29.1.1",
66
66
  "tailwindcss": "^4.3.0",
67
- "@cosmicdrift/kumiko-locale-de": "0.221.0"
67
+ "@cosmicdrift/kumiko-locale-de": "0.222.0"
68
68
  },
69
69
  "repository": {
70
70
  "type": "git",
@@ -360,22 +360,11 @@ describe("NavTree", () => {
360
360
  order: 20,
361
361
  icon: "shield-check",
362
362
  },
363
+ { id: "cap-list", label: "Caps", screen: "cap-list", order: 60, icon: "gauge" },
363
364
  ],
364
365
  } as FeatureSchema;
365
366
  const { container } = render(<NavTree schema={schema} />);
366
- expectNavIcons(container, ["send", "shield-check"]);
367
- expect(warnSpy).not.toHaveBeenCalled();
368
- });
369
-
370
- test("gauge (cap-list nav) resolves to an icon without missing-icon path", () => {
371
- const schema = {
372
- featureName: "cap-counter",
373
- entities: {},
374
- screens: [{ id: "cap-list", type: "entityList", entity: "x", columns: [] }],
375
- navs: [{ id: "cap-list", label: "Caps", screen: "cap-list", order: 60, icon: "gauge" }],
376
- } as FeatureSchema;
377
- const { container } = render(<NavTree schema={schema} />);
378
- expectNavIcons(container, ["gauge"]);
367
+ expectNavIcons(container, ["send", "shield-check", "gauge"]);
379
368
  expect(warnSpy).not.toHaveBeenCalled();
380
369
  });
381
370
 
@@ -383,7 +372,10 @@ describe("NavTree", () => {
383
372
  const schema = {
384
373
  featureName: "showcase",
385
374
  entities: {},
386
- screens: [{ id: "x", type: "entityList", entity: "x", columns: [] }],
375
+ screens: [
376
+ { id: "x", type: "entityList", entity: "x", columns: [] },
377
+ { id: "cap-list", type: "entityList", entity: "x", columns: [] },
378
+ ],
387
379
  navs: [
388
380
  {
389
381
  id: "x",
@@ -13,7 +13,7 @@ import userEvent from "@testing-library/user-event";
13
13
  import type { ReactNode } from "react";
14
14
  import { defaultPrimitives, END_LABEL_MIN_ROWS } from "../primitives";
15
15
  import { PageSection, Stack } from "../primitives/layout";
16
- import { fireEvent, render, screen } from "./test-utils";
16
+ import { fireEvent, render, screen, waitFor } from "./test-utils";
17
17
 
18
18
  const { Button, Banner, Field, Input, DataTable, Form, Text, Heading, Dialog, Card } =
19
19
  defaultPrimitives;
@@ -858,6 +858,34 @@ describe("DataTable", () => {
858
858
  expect(screen.queryByTestId("row-r1-action-delete-dialog")).not.toBeNull();
859
859
  });
860
860
 
861
+ // Regression (solon e2e/cost-category.spec.ts, fw): unconditional
862
+ // preventDefault() in onSelect blocked Radix' auto-close, so the
863
+ // dropdown stayed in the DOM after the confirm flow (role="menu" +
864
+ // body.style.pointerEvents="none" overlay lock never released), even
865
+ // though the write itself had already succeeded.
866
+ test("Kebab: nach Confirm+onTrigger ist das Dropdown-Menu geschlossen (kein Overlay-Lock)", async () => {
867
+ const user = userEvent.setup();
868
+ const onTrigger = mock();
869
+ render(
870
+ <DataTable
871
+ columns={cols}
872
+ rows={rows}
873
+ testId="dt"
874
+ rowActions={[
875
+ { id: "a", label: "Archive", onTrigger: mock() },
876
+ { id: "b", label: "Duplicate", onTrigger: mock() },
877
+ { id: "delete", label: "Delete", style: "danger", onTrigger },
878
+ ]}
879
+ />,
880
+ );
881
+ await user.click(screen.getByTestId("row-r1-actions-menu"));
882
+ await user.click(screen.getByTestId("row-r1-action-delete"));
883
+ await user.click(screen.getByTestId("row-r1-action-delete-dialog-confirm"));
884
+ await waitFor(() => expect(onTrigger).toHaveBeenCalledWith(rows[0]));
885
+ await waitFor(() => expect(screen.queryByRole("menu")).toBeNull());
886
+ expect(document.body.style.pointerEvents).not.toBe("none");
887
+ });
888
+
861
889
  test("confirmLabel separat vom label: Dialog-Button zeigt confirmLabel", async () => {
862
890
  const user = userEvent.setup();
863
891
  render(
@@ -53,7 +53,7 @@ export function PlainContentEditor({
53
53
  };
54
54
 
55
55
  return (
56
- <div>
56
+ <div className="rounded-md border border-input bg-transparent">
57
57
  <TextareaContentEditor
58
58
  id={id}
59
59
  value={value}
@@ -61,7 +61,11 @@ export function PlainContentEditor({
61
61
  variables={variables}
62
62
  readOnly={readOnly}
63
63
  />
64
- <VariableChips variables={variables} onInsert={insertAtCaret} disabled={readOnly} />
64
+ {variables.length > 0 && (
65
+ <div className="flex flex-wrap gap-1 border-t border-input p-1">
66
+ <VariableChips variables={variables} onInsert={insertAtCaret} disabled={readOnly} />
67
+ </div>
68
+ )}
65
69
  </div>
66
70
  );
67
71
  }
@@ -33,9 +33,12 @@ const enPlaceholderLetters = placeholderLetters("en");
33
33
  describe("parseIso", () => {
34
34
  test("valid yyyy-mm-dd → PlainDate (no TZ conversion)", () => {
35
35
  const d = parseIso("2026-04-25");
36
- // Cross-realm safe: bun and happy-dom can expose different Temporal
37
- // copies, so toBeInstanceOf fails depending on file order. The ISO
38
- // string proves both the type and the no-TZ-conversion property.
36
+ // Cross-realm safe: assert against the same Temporal activeTemporal() uses
37
+ // (native when present), not the polyfill import Bun 1.4+ ships Temporal.
38
+ const ActivePlainDate = (
39
+ (globalThis as unknown as { Temporal?: typeof Temporal }).Temporal ?? Temporal
40
+ ).PlainDate;
41
+ expect(d).toBeInstanceOf(ActivePlainDate);
39
42
  expect(String(d)).toBe("2026-04-25");
40
43
  expect(d?.year).toBe(2026);
41
44
  expect(d?.month).toBe(4);
@@ -10,6 +10,8 @@ import { Temporal } from "temporal-polyfill";
10
10
  // Prefer the native Temporal (Chromium 144+/Firefox 139+) over the bundled
11
11
  // polyfill so `instanceof` checks match values crossing package boundaries;
12
12
  // falls back to the polyfill where native support is absent.
13
+ // Callers must not feed parseIso()/makePlainDate results into polyfill-only
14
+ // Statics (compare/equals/since) — mixed Temporal brands throw TypeError.
13
15
  function activeTemporal(): typeof Temporal {
14
16
  return (globalThis as unknown as { Temporal?: typeof Temporal }).Temporal ?? Temporal;
15
17
  }
@@ -1003,10 +1003,10 @@ function RowActionButton({
1003
1003
  );
1004
1004
  }
1005
1005
 
1006
- // Kebab-Dropdown für >2 actions. Confirm-Dialog ist hier inline pro
1007
- // Item analog zum Inline-Button-Pfad ein gemeinsamer State-Holder
1008
- // per Action damit das Dropdown nach dem Click zumacht und der Dialog
1009
- // danach öffnet (Radix-Dropdown-Item swallowt den Click sonst).
1006
+ // Kebab dropdown for >2 actions, one inline confirm dialog per item like
1007
+ // the inline-button path. Menu is controlled so it closes explicitly on
1008
+ // select, instead of relying on Radix's auto-close, which preventDefault
1009
+ // below blocks.
1010
1010
  function RowActionsKebab({
1011
1011
  row,
1012
1012
  actions,
@@ -1016,10 +1016,11 @@ function RowActionsKebab({
1016
1016
  }): ReactNode {
1017
1017
  const { triggerNow } = useRowActionTrigger(row);
1018
1018
  const [pendingConfirm, setPendingConfirm] = useState<DataTableRowAction | null>(null);
1019
+ const [menuOpen, setMenuOpen] = useState(false);
1019
1020
 
1020
1021
  return (
1021
1022
  <>
1022
- <DropdownMenu>
1023
+ <DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
1023
1024
  <DropdownMenuTrigger asChild>
1024
1025
  <button
1025
1026
  type="button"
@@ -1041,6 +1042,7 @@ function RowActionsKebab({
1041
1042
  data-testid={`row-${row.id}-action-${action.id}`}
1042
1043
  onSelect={(e) => {
1043
1044
  e.preventDefault();
1045
+ setMenuOpen(false);
1044
1046
  if (needsConfirm(action)) {
1045
1047
  setPendingConfirm(action);
1046
1048
  } else {
@@ -73,7 +73,7 @@ function renderWithLive(ui: ReactNode, dispatcher: Dispatcher, liveEvents: LiveE
73
73
  type Row = { readonly id: string; readonly subject: string };
74
74
  type Page = { readonly rows: readonly Row[]; readonly nextCursor: string | null };
75
75
 
76
- function list(query: string) {
76
+ function list(query: string, live = false) {
77
77
  return (
78
78
  <InfinityList<Page, Row>
79
79
  query={query}
@@ -82,6 +82,7 @@ function list(query: string) {
82
82
  rowId={(row) => row.id}
83
83
  renderRow={(row) => <span>{row.subject}</span>}
84
84
  testId="inbox"
85
+ live={live}
85
86
  />
86
87
  );
87
88
  }
@@ -255,14 +256,18 @@ describe("InfinityList", () => {
255
256
  isSuccess: true,
256
257
  data: { rows: [{ id: "m1", subject: "Bo-Treffer" }], nextCursor: null },
257
258
  });
258
- await new Promise((resolve) => setTimeout(resolve, 10));
259
- expect(screen.queryByText("Bo-Treffer")).toBeNull();
260
- expect(screen.getByText("Bob-Treffer")).toBeTruthy();
259
+ await waitFor(() => {
260
+ expect(screen.queryByText("Bo-Treffer")).toBeNull();
261
+ expect(screen.getByText("Bob-Treffer")).toBeTruthy();
262
+ });
261
263
  });
262
264
 
263
265
  // fw#1827: InfinityList used dispatcher.query directly and never subscribed
264
266
  // to live events, so a solon inbox stayed stale until the user reloaded.
265
267
  describe("Live-Mode", () => {
268
+ // fw#1829 debounces live refresh by 250ms; CI needs headroom beyond default 1s.
269
+ const liveWait = { timeout: 3000 } as const;
270
+
266
271
  test("SSE-Event mergt nur die erste Seite, bereits geladene Folgeseiten bleiben erhalten", async () => {
267
272
  const calls: Array<Readonly<Record<string, unknown>>> = [];
268
273
  const dispatcher = createMockDispatcher({
@@ -304,6 +309,7 @@ describe("InfinityList", () => {
304
309
  rowId={(row) => row.id}
305
310
  renderRow={(row) => <span>{row.subject}</span>}
306
311
  testId="inbox"
312
+ live={true}
307
313
  />,
308
314
  dispatcher,
309
315
  fake.subscriber,
@@ -324,7 +330,7 @@ describe("InfinityList", () => {
324
330
  });
325
331
  });
326
332
 
327
- await waitFor(() => expect(screen.getByText("Neu")).toBeTruthy());
333
+ await waitFor(() => expect(screen.getByText("Neu")).toBeTruthy(), liveWait);
328
334
  expect(screen.getByText("Alt-1")).toBeTruthy();
329
335
  expect(screen.getByText("Alt-2")).toBeTruthy();
330
336
  expect(screen.getAllByText("Alt-1").length).toBe(1);
@@ -362,7 +368,7 @@ describe("InfinityList", () => {
362
368
  });
363
369
  const fake = makeFakeLiveEvents();
364
370
 
365
- renderWithLive(list("inbox:query:message:list"), dispatcher, fake.subscriber);
371
+ renderWithLive(list("inbox:query:message:list", true), dispatcher, fake.subscriber);
366
372
 
367
373
  await waitFor(() => expect(screen.getByText("Erste")).toBeTruthy());
368
374
  expect(screen.getByText("Zweite")).toBeTruthy();
@@ -377,7 +383,7 @@ describe("InfinityList", () => {
377
383
  });
378
384
  });
379
385
 
380
- await waitFor(() => expect(screen.queryByText("Erste")).toBeNull());
386
+ await waitFor(() => expect(screen.queryByText("Erste")).toBeNull(), liveWait);
381
387
  expect(screen.getByText("Zweite")).toBeTruthy();
382
388
  });
383
389
 
@@ -421,6 +427,7 @@ describe("InfinityList", () => {
421
427
  rowId={(row) => row.id}
422
428
  renderRow={(row) => <span>{row.subject}</span>}
423
429
  testId="inbox"
430
+ live={true}
424
431
  />,
425
432
  dispatcher,
426
433
  fake.subscriber,
@@ -441,7 +448,7 @@ describe("InfinityList", () => {
441
448
  });
442
449
  });
443
450
 
444
- await waitFor(() => expect(screen.getByText("Neu")).toBeTruthy());
451
+ await waitFor(() => expect(screen.getByText("Neu")).toBeTruthy(), liveWait);
445
452
  expect(screen.queryByText("Erste")).toBeNull();
446
453
  expect(screen.getByText("Zweite")).toBeTruthy();
447
454
  });
@@ -508,6 +515,7 @@ describe("InfinityList", () => {
508
515
  rowId={(row) => row.id}
509
516
  renderRow={(row) => <span>{row.subject}</span>}
510
517
  testId="inbox"
518
+ live={true}
511
519
  />,
512
520
  dispatcher,
513
521
  fake.subscriber,
@@ -540,7 +548,7 @@ describe("InfinityList", () => {
540
548
  });
541
549
  const fake = makeFakeLiveEvents();
542
550
 
543
- renderWithLive(list("inbox:query:message:list"), dispatcher, fake.subscriber);
551
+ renderWithLive(list("inbox:query:message:list", true), dispatcher, fake.subscriber);
544
552
 
545
553
  await waitFor(() => expect(resolvers.length).toBe(1));
546
554
 
@@ -139,8 +139,10 @@ export function Drawer({
139
139
  const signedDelta = side === "right" ? -deltaX : deltaX;
140
140
  setWidth(clamp(dragRef.current.startWidth + signedDelta, minWidthPx, effectiveMaxWidthPx()));
141
141
  };
142
- const onHandlePointerUp = (event: React.PointerEvent<HTMLDivElement>): void => {
143
- event.currentTarget.releasePointerCapture(event.pointerId);
142
+ const endHandlePointerDrag = (event: React.PointerEvent<HTMLDivElement>): void => {
143
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
144
+ event.currentTarget.releasePointerCapture(event.pointerId);
145
+ }
144
146
  dragRef.current = null;
145
147
  document.body.style.removeProperty("user-select");
146
148
  };
@@ -210,7 +212,9 @@ export function Drawer({
210
212
  tabIndex={0}
211
213
  onPointerDown={onHandlePointerDown}
212
214
  onPointerMove={onHandlePointerMove}
213
- onPointerUp={onHandlePointerUp}
215
+ onPointerUp={endHandlePointerDrag}
216
+ onPointerCancel={endHandlePointerDrag}
217
+ onLostPointerCapture={endHandlePointerDrag}
214
218
  onKeyDown={onHandleKeyDown}
215
219
  className={cn(
216
220
  "absolute inset-y-0 z-10 w-1 cursor-col-resize touch-none after:absolute after:inset-y-0 after:left-1/2 after:w-3 after:-translate-x-1/2 hover:bg-border",
@@ -28,7 +28,7 @@ export type InfinityListProps<TData = unknown, TRow = Readonly<Record<string, un
28
28
  * (`<feature>:query:<entity>:<verb>`) and refetch the first page on
29
29
  * any create/update/delete/restore event, merging it into the
30
30
  * already-accumulated rows instead of collapsing them — see
31
- * `useQuery`'s `live` option for the same convention. Default true. */
31
+ * `useQuery`'s `live` option for the same convention. Default false. */
32
32
  readonly live?: boolean;
33
33
  };
34
34
 
@@ -53,7 +53,7 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
53
53
  emptyState,
54
54
  className,
55
55
  testId,
56
- live = true,
56
+ live = false,
57
57
  }: InfinityListProps<TData, TRow>): ReactNode {
58
58
  const dispatcher = useDispatcher();
59
59
  const t = useTranslation();
@@ -183,15 +183,51 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
183
183
  });
184
184
  }, [dispatcher, query, pageSize, payloadKey]);
185
185
 
186
+ // Trailing debounce + in-flight coalesce: bulk SSE storms must not fan out
187
+ // into one query per event (fw#1829). A refresh already running schedules
188
+ // exactly one trailing pass after it settles.
189
+ const liveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
190
+ const liveInFlightRef = useRef(false);
191
+ const liveTrailingRef = useRef(false);
192
+
186
193
  useEffect(() => {
187
194
  // skip: live mode off, no SSE subscription needed
188
195
  if (!live) return;
189
196
  const entity = entityFromQueryType(query);
190
197
  // skip: query type has no mapped entity, nothing to subscribe to
191
198
  if (entity === undefined) return;
192
- return subscribeLive(entity, () => {
193
- void refreshFirstPage();
194
- });
199
+
200
+ const runRefresh = (): void => {
201
+ liveInFlightRef.current = true;
202
+ void refreshFirstPage().finally(() => {
203
+ liveInFlightRef.current = false;
204
+ if (liveTrailingRef.current) {
205
+ liveTrailingRef.current = false;
206
+ runRefresh();
207
+ }
208
+ });
209
+ };
210
+
211
+ const scheduleRefresh = (): void => {
212
+ if (liveTimerRef.current !== null) clearTimeout(liveTimerRef.current);
213
+ liveTimerRef.current = setTimeout(() => {
214
+ liveTimerRef.current = null;
215
+ if (liveInFlightRef.current) {
216
+ liveTrailingRef.current = true;
217
+ return;
218
+ }
219
+ runRefresh();
220
+ }, 250);
221
+ };
222
+
223
+ const unsubscribe = subscribeLive(entity, scheduleRefresh);
224
+ return () => {
225
+ unsubscribe();
226
+ if (liveTimerRef.current !== null) {
227
+ clearTimeout(liveTimerRef.current);
228
+ liveTimerRef.current = null;
229
+ }
230
+ };
195
231
  }, [live, query, refreshFirstPage, subscribeLive]);
196
232
 
197
233
  useEffect(() => {