@cosmicdrift/kumiko-renderer-web 0.165.0 → 2.0.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.165.0",
3
+ "version": "2.0.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.165.0",
20
- "@cosmicdrift/kumiko-headless": "0.165.0",
21
- "@cosmicdrift/kumiko-renderer": "0.165.0",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "2.0.0",
20
+ "@cosmicdrift/kumiko-headless": "2.0.0",
21
+ "@cosmicdrift/kumiko-renderer": "2.0.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",
@@ -16,6 +16,10 @@ const PAYLOAD = '<script>window.__xss = true;</script><img src=x onerror="window
16
16
  const UNSAFE_HREFS = [
17
17
  "javascript:window.__xss = true",
18
18
  "data:text/html,<script>window.__xss=true</script>",
19
+ // Browsers strip embedded control/whitespace chars before scheme
20
+ // detection — a tab/newline mid-scheme must not bypass the check.
21
+ "java\tscript:window.__xss = true",
22
+ "java\nscript:window.__xss = true",
19
23
  ];
20
24
 
21
25
  describe("XSS-safety (tenant-authored content)", () => {
package/src/index.ts CHANGED
@@ -163,8 +163,10 @@ export type {
163
163
  BooleanFieldProps,
164
164
  ComparisonMetric,
165
165
  DateFieldProps,
166
+ DrawerProps,
166
167
  FeedRow,
167
168
  FileFieldProps,
169
+ InfinityListProps,
168
170
  NumberFieldProps,
169
171
  ProgressListRow,
170
172
  QueryTableColumn,
@@ -188,10 +190,12 @@ export {
188
190
  ComparisonTable,
189
191
  DateField,
190
192
  DetailList,
193
+ Drawer,
191
194
  EmptyState,
192
195
  ErrorState,
193
196
  FeedList,
194
197
  FileField,
198
+ InfinityList,
195
199
  LoadingState,
196
200
  MiniStat,
197
201
  ModeSwitch,
@@ -186,4 +186,9 @@ describe("defaultCellRender", () => {
186
186
  test("money → unerwarteter Value-Shape fällt auf String zurück", () => {
187
187
  expect(defaultCellRender(45000, "money")).toBe("45000");
188
188
  });
189
+
190
+ test("money → invalid currency does not throw, falls back to [object Object] like pre-regression (#1494)", () => {
191
+ expect(() => defaultCellRender({ amount: 1, currency: "" }, "money")).not.toThrow();
192
+ expect(defaultCellRender({ amount: 1, currency: "" }, "money")).toBe("[object Object]");
193
+ });
189
194
  });
@@ -8,7 +8,19 @@
8
8
 
9
9
  import { describe, expect, mock, test } from "bun:test";
10
10
  import { fireEvent, render, screen } from "@testing-library/react";
11
- import { currencyDecimals, MoneyInput, parseLocaleNumber } from "../money-input";
11
+ import { currencyDecimals, formatMoney, MoneyInput, parseLocaleNumber } from "../money-input";
12
+
13
+ describe("formatMoney", () => {
14
+ test("formats a normal amount with the currency symbol", () => {
15
+ expect(formatMoney(199_99, "USD", "en-US")).toBe("$199.99");
16
+ });
17
+
18
+ test("falls back to the raw amount instead of throwing on an invalid currency (#1494)", () => {
19
+ expect(formatMoney(100, "", "en-US")).toBe("100");
20
+ expect(formatMoney(100, "EU", "en-US")).toBe("100");
21
+ expect(formatMoney(100, "€", "en-US")).toBe("100");
22
+ });
23
+ });
12
24
 
13
25
  describe("currencyDecimals", () => {
14
26
  test("0-Decimal-Währungen (JPY/KRW/VND/ISK)", () => {
@@ -9,7 +9,7 @@
9
9
  // Dropdown etc. kommen später).
10
10
 
11
11
  import type { ListRowViewModel } from "@cosmicdrift/kumiko-headless";
12
- import { applyFormatSpec } from "@cosmicdrift/kumiko-headless";
12
+ import { applyFormatSpec, isSafeHref } from "@cosmicdrift/kumiko-headless";
13
13
  import type {
14
14
  DataTableRowAction,
15
15
  DataTableRowActionMode,
@@ -79,7 +79,7 @@ import {
79
79
  import { FileUploadInput } from "./file-upload";
80
80
  import { DefaultLightbox } from "./lightbox";
81
81
  import { LocatedTimestampInput } from "./located-timestamp-input";
82
- import { currencyDecimals, MoneyInput } from "./money-input";
82
+ import { formatMoney, MoneyInput } from "./money-input";
83
83
  import { TimestampInput } from "./timestamp-input";
84
84
  import { useToast } from "./toast";
85
85
 
@@ -1261,7 +1261,8 @@ function isMoneyValue(value: unknown): value is { amount: number; currency: stri
1261
1261
  typeof value === "object" &&
1262
1262
  value !== null &&
1263
1263
  typeof (value as Record<string, unknown>)["amount"] === "number" &&
1264
- typeof (value as Record<string, unknown>)["currency"] === "string"
1264
+ typeof (value as Record<string, unknown>)["currency"] === "string" &&
1265
+ /^[A-Za-z]{3}$/.test((value as Record<string, unknown>)["currency"] as string)
1265
1266
  );
1266
1267
  }
1267
1268
 
@@ -1284,13 +1285,13 @@ export function defaultCellRender(
1284
1285
  if (type === "timestamp" || type === "date") return applyFormatSpec({ format: type }, value);
1285
1286
  if (type === "money") {
1286
1287
  if (!isMoneyValue(value)) return String(value);
1287
- const decimals = currencyDecimals(value.currency);
1288
- return new Intl.NumberFormat(undefined, {
1289
- style: "currency",
1290
- currency: value.currency,
1291
- minimumFractionDigits: decimals,
1292
- maximumFractionDigits: decimals,
1293
- }).format(value.amount / 10 ** decimals);
1288
+ // No `locale` param here: DataTableCell has no app-locale context to
1289
+ // thread through (unlike the form path — see MoneyInput/RenderField in
1290
+ // packages/renderer, which pins the LocaleProvider locale). formatMoney
1291
+ // falls back to guessLocale() (navigator.language), so the same amount
1292
+ // can render differently in a table vs. a form on the same screen.
1293
+ // Mid-term: pass the app locale down here too, analogous to render-field.
1294
+ return formatMoney(value.amount, value.currency);
1294
1295
  }
1295
1296
  if (type === "select") {
1296
1297
  const raw = String(value);
@@ -1681,14 +1682,9 @@ function DefaultText({ variant = "body", children, testId }: TextProps): ReactNo
1681
1682
 
1682
1683
  // ---- Link (anchor mit Button-/Muted-Optik) ----
1683
1684
 
1684
- // http(s)/mailto or scheme-less (relative/anchor) allowed; javascript:, data:,
1685
- // vbscript:, etc. rejected. Mirrors renderer-mail-html's renderSafeMarkdown
1686
- // href guard — no sanitizer dependency needed for a four-line regex check.
1687
- function isSafeHref(href: string): boolean {
1688
- const trimmed = href.trim().toLowerCase();
1689
- if (!/^[a-z][a-z0-9+.-]*:/.test(trimmed)) return true;
1690
- return /^(?:https?|mailto):/.test(trimmed);
1691
- }
1685
+ // isSafeHref lives in @cosmicdrift/kumiko-headless shared with
1686
+ // page-render's server-side markdown renderer, both take untrusted
1687
+ // tenant-authored hrefs.
1692
1688
 
1693
1689
  // `button` nutzt die Primary-Buttonfläche auf einem semantischen <a> —
1694
1690
  // der Standard für „weiter zu"-Navigationen nach Success-States (ehem.
@@ -64,16 +64,7 @@ export function MoneyInput({
64
64
  const [draft, setDraft] = useState<string>("");
65
65
 
66
66
  const major = value === "" ? null : value / factor;
67
-
68
- const formatted =
69
- major === null
70
- ? ""
71
- : new Intl.NumberFormat(resolvedLocale, {
72
- style: "currency",
73
- currency,
74
- minimumFractionDigits: decimals,
75
- maximumFractionDigits: decimals,
76
- }).format(major);
67
+ const formatted = value === "" ? "" : formatMoney(value, currency, resolvedLocale);
77
68
 
78
69
  // Edit-Mode: Decimal-String ohne Tausender-Trenner. Konsistente Helper-
79
70
  // Funktion damit Focus-Init und Render-Fallback nicht auseinanderdriften.
@@ -170,6 +161,25 @@ function guessLocale(): string {
170
161
  return "en-US";
171
162
  }
172
163
 
164
+ // Shared with defaultCellRender (index.tsx) so both formatting paths stay
165
+ // identical instead of drifting. currency is validated against the
166
+ // 3-letter-alpha shape Intl.NumberFormat requires — a non-conforming
167
+ // value (e.g. from a JSONB custom field that never ran through
168
+ // rehydrateMoney) throws a RangeError inside Intl.NumberFormat, and with
169
+ // no ErrorBoundary in this render tree that would take down the whole
170
+ // page instead of just this cell.
171
+ export function formatMoney(amountMinor: number, currency: string, locale?: string): string {
172
+ if (!/^[A-Za-z]{3}$/.test(currency)) return String(amountMinor);
173
+ const decimals = currencyDecimals(currency);
174
+ const resolvedLocale = locale ?? guessLocale();
175
+ return new Intl.NumberFormat(resolvedLocale, {
176
+ style: "currency",
177
+ currency,
178
+ minimumFractionDigits: decimals,
179
+ maximumFractionDigits: decimals,
180
+ }).format(amountMinor / 10 ** decimals);
181
+ }
182
+
173
183
  // Locale-Decimal-Parse: erkennt automatisch ob Komma oder Punkt der
174
184
  // Decimal-Separator ist. Intl.NumberFormat liefert die Trenner für
175
185
  // das Locale, daraus bauen wir den Reverse-Parser. Strict beim
@@ -20,16 +20,18 @@ describe("ui/Avatar", () => {
20
20
  expect(document.querySelector('[data-slot="avatar"]')).not.toBeNull();
21
21
  });
22
22
 
23
- test("AvatarImage with failed load shows AvatarFallback", () => {
23
+ test("fallback renders while image is unloaded", () => {
24
24
  render(
25
25
  <Avatar>
26
26
  <AvatarImage src="/photo.png" alt="User" />
27
27
  <AvatarFallback>FB</AvatarFallback>
28
28
  </Avatar>,
29
29
  );
30
- // happy-dom does not load images Radix keeps the fallback visible
30
+ // happy-dom never loads images, so AvatarImage never reaches "loaded"
31
+ // this only exercises the unloaded state, not an actual load failure.
31
32
  expect(screen.getByText("FB")).toBeTruthy();
32
33
  expect(document.querySelector('[data-slot="avatar-fallback"]')).not.toBeNull();
34
+ expect(document.querySelector('[data-slot="avatar-image"]')).toBeNull();
33
35
  });
34
36
 
35
37
  test("AvatarBadge renders inside avatar", () => {
@@ -2,7 +2,7 @@
2
2
  // shouldShowUpdate/isKumikoBuild stay in update-checker.test.ts (unit).
3
3
 
4
4
  import { afterEach, describe, expect, mock, test } from "bun:test";
5
- import { screen, waitFor } from "@testing-library/react";
5
+ import { act, screen, waitFor } from "@testing-library/react";
6
6
  import { render } from "../../__tests__/test-utils";
7
7
  import { UpdateChecker } from "../update-checker";
8
8
 
@@ -99,7 +99,7 @@ describe("UpdateChecker", () => {
99
99
  expect(screen.queryByRole("status")).toBeNull();
100
100
  });
101
101
 
102
- test("no __KUMIKO_BUILD__ → no fetch, no banner", () => {
102
+ test("no __KUMIKO_BUILD__ → no fetch, no banner", async () => {
103
103
  const fetchSpy = mock(async () => ({
104
104
  ok: true,
105
105
  json: async () => ({ id: "other", builtAt: "" }),
@@ -107,6 +107,7 @@ describe("UpdateChecker", () => {
107
107
  globalThis.fetch = fetchSpy as typeof globalThis.fetch;
108
108
 
109
109
  render(<UpdateChecker />);
110
+ await act(async () => {});
110
111
 
111
112
  expect(fetchSpy).not.toHaveBeenCalled();
112
113
  expect(screen.queryByRole("status")).toBeNull();
@@ -0,0 +1,35 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+ import { fireEvent, render, screen } from "../../__tests__/test-utils";
3
+ import { Drawer } from "../drawer";
4
+
5
+ describe("Drawer", () => {
6
+ test("rendert Titel + Inhalt wenn open", () => {
7
+ render(
8
+ <Drawer open={true} onOpenChange={() => {}} title="Mail" testId="drawer">
9
+ <div>Body</div>
10
+ </Drawer>,
11
+ );
12
+ expect(screen.getByText("Mail")).toBeTruthy();
13
+ expect(screen.getByText("Body")).toBeTruthy();
14
+ });
15
+
16
+ test("open=false rendert nichts", () => {
17
+ render(
18
+ <Drawer open={false} onOpenChange={() => {}} title="Mail">
19
+ <div>Body</div>
20
+ </Drawer>,
21
+ );
22
+ expect(screen.queryByText("Body")).toBeNull();
23
+ });
24
+
25
+ test("Escape ruft onOpenChange(false)", () => {
26
+ const onOpenChange = mock((_open: boolean) => {});
27
+ render(
28
+ <Drawer open={true} onOpenChange={onOpenChange} title="Mail" testId="drawer">
29
+ <div>Body</div>
30
+ </Drawer>,
31
+ );
32
+ fireEvent.keyDown(screen.getByTestId("drawer"), { key: "Escape" });
33
+ expect(onOpenChange).toHaveBeenCalledWith(false);
34
+ });
35
+ });
@@ -0,0 +1,132 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
3
+ import { DispatcherProvider } from "@cosmicdrift/kumiko-renderer";
4
+ import type { ReactNode } from "react";
5
+ import {
6
+ createMockDispatcher,
7
+ fireEvent,
8
+ render,
9
+ screen,
10
+ waitFor,
11
+ } from "../../__tests__/test-utils";
12
+ import { InfinityList } from "../infinity-list";
13
+
14
+ // jsdom has no IntersectionObserver — stub stores the callback per observer
15
+ // so tests can fire the "sentinel became visible" event manually instead of
16
+ // simulating real scrolling.
17
+ const observers: ((entries: readonly { isIntersecting: boolean }[]) => void)[] = [];
18
+ globalThis.IntersectionObserver = class {
19
+ constructor(cb: (entries: readonly { isIntersecting: boolean }[]) => void) {
20
+ observers.push(cb);
21
+ }
22
+ observe(): void {}
23
+ unobserve(): void {}
24
+ disconnect(): void {}
25
+ } as unknown as typeof IntersectionObserver;
26
+
27
+ function fireIntersect(): void {
28
+ observers[observers.length - 1]?.([{ isIntersecting: true }]);
29
+ }
30
+
31
+ function renderWithDispatcher(ui: ReactNode, dispatcher: Dispatcher) {
32
+ return render(<DispatcherProvider dispatcher={dispatcher}>{ui}</DispatcherProvider>);
33
+ }
34
+
35
+ type Row = { readonly id: string; readonly subject: string };
36
+ type Page = { readonly rows: readonly Row[]; readonly nextCursor: string | null };
37
+
38
+ function list(query: string) {
39
+ return (
40
+ <InfinityList<Page, Row>
41
+ query={query}
42
+ rows={(data) => data.rows}
43
+ nextCursor={(data) => data.nextCursor}
44
+ rowId={(row) => row.id}
45
+ renderRow={(row) => <span>{row.subject}</span>}
46
+ testId="inbox"
47
+ />
48
+ );
49
+ }
50
+
51
+ describe("InfinityList", () => {
52
+ test("rendert die erste Seite", async () => {
53
+ const dispatcher = createMockDispatcher({
54
+ query: (async () => ({
55
+ isSuccess: true,
56
+ data: { rows: [{ id: "m1", subject: "Hallo" }], nextCursor: null },
57
+ })) as unknown as Dispatcher["query"],
58
+ });
59
+ renderWithDispatcher(list("inbox:query:message:list"), dispatcher);
60
+ await waitFor(() => expect(screen.getByText("Hallo")).toBeTruthy());
61
+ });
62
+
63
+ test("lädt die nächste Seite nach, sobald der Sentinel sichtbar wird", async () => {
64
+ let calls = 0;
65
+ const dispatcher = createMockDispatcher({
66
+ query: (async () => {
67
+ calls += 1;
68
+ if (calls === 1) {
69
+ return {
70
+ isSuccess: true,
71
+ data: { rows: [{ id: "m1", subject: "Erste" }], nextCursor: "c1" },
72
+ };
73
+ }
74
+ return {
75
+ isSuccess: true,
76
+ data: { rows: [{ id: "m2", subject: "Zweite" }], nextCursor: null },
77
+ };
78
+ }) as unknown as Dispatcher["query"],
79
+ });
80
+ renderWithDispatcher(list("inbox:query:message:list"), dispatcher);
81
+ await waitFor(() => expect(screen.getByText("Erste")).toBeTruthy());
82
+ fireIntersect();
83
+ await waitFor(() => expect(screen.getByText("Zweite")).toBeTruthy());
84
+ expect(screen.getByText("Erste")).toBeTruthy();
85
+ expect(calls).toBe(2);
86
+ });
87
+
88
+ test("Fehler → ErrorState, Retry lädt neu", async () => {
89
+ let calls = 0;
90
+ const dispatcher = createMockDispatcher({
91
+ query: (async () => {
92
+ calls += 1;
93
+ if (calls === 1) {
94
+ return {
95
+ isSuccess: false,
96
+ error: { code: "internal", message: "kaputt", i18nKey: "errors.internal" },
97
+ };
98
+ }
99
+ return {
100
+ isSuccess: true,
101
+ data: { rows: [{ id: "m1", subject: "Hallo" }], nextCursor: null },
102
+ };
103
+ }) as unknown as Dispatcher["query"],
104
+ });
105
+ renderWithDispatcher(list("inbox:query:message:list"), dispatcher);
106
+ await waitFor(() => expect(screen.getByRole("button")).toBeTruthy());
107
+ fireEvent.click(screen.getByRole("button"));
108
+ await waitFor(() => expect(screen.getByText("Hallo")).toBeTruthy());
109
+ expect(calls).toBe(2);
110
+ });
111
+
112
+ test("leeres Result rendert Empty-State", async () => {
113
+ const dispatcher = createMockDispatcher({
114
+ query: (async () => ({
115
+ isSuccess: true,
116
+ data: { rows: [], nextCursor: null },
117
+ })) as unknown as Dispatcher["query"],
118
+ });
119
+ renderWithDispatcher(
120
+ <InfinityList<Page, Row>
121
+ query="inbox:query:message:list"
122
+ rows={(data) => data.rows}
123
+ nextCursor={(data) => data.nextCursor}
124
+ rowId={(row) => row.id}
125
+ renderRow={(row) => <span>{row.subject}</span>}
126
+ emptyState={<span>Keine Nachrichten</span>}
127
+ />,
128
+ dispatcher,
129
+ );
130
+ await waitFor(() => expect(screen.getByText("Keine Nachrichten")).toBeTruthy());
131
+ });
132
+ });
@@ -55,6 +55,28 @@ describe("ResultTable", () => {
55
55
  expect(totalRow?.className).toContain("font-semibold");
56
56
  expect(screen.getByText("Subtotal")).toBeTruthy();
57
57
  });
58
+
59
+ test("single-column table: footer value still renders (not dropped)", () => {
60
+ const singleColumn = [{ header: "Jahr", cell: (row: { year: number }) => row.year }];
61
+ const { container } = render(
62
+ <ResultTable
63
+ columns={singleColumn}
64
+ rows={ROWS}
65
+ rowKey={(r) => String(r.year)}
66
+ testId="rt"
67
+ footer={[{ label: "Total", value: "300", emphasize: true }]}
68
+ />,
69
+ );
70
+ const footerRows = container.querySelectorAll("tbody tr");
71
+ expect(footerRows).toHaveLength(3);
72
+ const totalRow = footerRows[2];
73
+ // Single <td> carries both label and value — colSpan is dropped, not
74
+ // set to 0, since there's only one column to span.
75
+ expect(totalRow?.querySelectorAll("td")).toHaveLength(1);
76
+ expect(totalRow?.querySelector("td")?.hasAttribute("colspan")).toBe(false);
77
+ expect(screen.getByText("Total")).toBeTruthy();
78
+ expect(screen.getByText("300")).toBeTruthy();
79
+ });
58
80
  });
59
81
 
60
82
  describe("ComparisonTable", () => {
@@ -0,0 +1,49 @@
1
+ import type { ReactNode } from "react";
2
+ import {
3
+ Sheet,
4
+ SheetContent,
5
+ SheetDescription,
6
+ SheetFooter,
7
+ SheetHeader,
8
+ SheetTitle,
9
+ } from "../ui/sheet";
10
+
11
+ export type DrawerProps = {
12
+ readonly open: boolean;
13
+ readonly onOpenChange: (open: boolean) => void;
14
+ readonly side?: "left" | "right" | "top" | "bottom";
15
+ readonly title?: ReactNode;
16
+ readonly description?: ReactNode;
17
+ readonly footer?: ReactNode;
18
+ readonly children: ReactNode;
19
+ readonly testId?: string;
20
+ };
21
+
22
+ /** Slide-in panel beside a list (e.g. mail reader next to the inbox) —
23
+ * thin wrapper over the Sheet primitive with header/body/footer slots
24
+ * so screens skip per-route Radix boilerplate. */
25
+ export function Drawer({
26
+ open,
27
+ onOpenChange,
28
+ side = "right",
29
+ title,
30
+ description,
31
+ footer,
32
+ children,
33
+ testId,
34
+ }: DrawerProps): ReactNode {
35
+ return (
36
+ <Sheet open={open} onOpenChange={onOpenChange}>
37
+ <SheetContent side={side} data-testid={testId}>
38
+ {(title !== undefined || description !== undefined) && (
39
+ <SheetHeader>
40
+ {title !== undefined && <SheetTitle>{title}</SheetTitle>}
41
+ {description !== undefined && <SheetDescription>{description}</SheetDescription>}
42
+ </SheetHeader>
43
+ )}
44
+ <div className="flex-1 overflow-y-auto px-4">{children}</div>
45
+ {footer !== undefined && <SheetFooter>{footer}</SheetFooter>}
46
+ </SheetContent>
47
+ </Sheet>
48
+ );
49
+ }
@@ -18,6 +18,7 @@ export {
18
18
  } from "./charts";
19
19
  export { CollapsibleSection } from "./collapsible-section";
20
20
  export { DetailList } from "./detail-list";
21
+ export { Drawer, type DrawerProps } from "./drawer";
21
22
  export { FeedList, type FeedRow } from "./feed-list";
22
23
  export {
23
24
  BooleanField,
@@ -39,6 +40,7 @@ export {
39
40
  TextField,
40
41
  type TextFieldProps,
41
42
  } from "./form-fields";
43
+ export { InfinityList, type InfinityListProps } from "./infinity-list";
42
44
  export { ModeSwitch } from "./mode-switch";
43
45
  export { ProgressBar } from "./progress-bar";
44
46
  export { ProgressList, type ProgressListRow } from "./progress-list";
@@ -0,0 +1,112 @@
1
+ import type { DispatcherError } from "@cosmicdrift/kumiko-headless";
2
+ import { useDispatcher, useTranslation } from "@cosmicdrift/kumiko-renderer";
3
+ import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
4
+ import { EmptyState, ErrorState, LoadingState } from "./states";
5
+
6
+ export type InfinityListProps<TData = unknown, TRow = Readonly<Record<string, unknown>>> = {
7
+ /** Dispatcher-Query-Type (`<feature>:query:<entity>:<verb>`). */
8
+ readonly query: string;
9
+ readonly payload?: Readonly<Record<string, unknown>>;
10
+ /** Rows per page — sent as `limit` in the query payload. Default 50. */
11
+ readonly pageSize?: number;
12
+ readonly rows: (data: TData) => readonly TRow[];
13
+ /** Pull the next-page cursor from the result; `null` means last page. */
14
+ readonly nextCursor: (data: TData) => string | null;
15
+ readonly rowId: (row: TRow, index: number) => string;
16
+ readonly renderRow: (row: TRow) => ReactNode;
17
+ readonly emptyState?: ReactNode;
18
+ readonly className?: string;
19
+ readonly testId?: string;
20
+ };
21
+
22
+ type State<TRow> =
23
+ | { readonly kind: "loading" }
24
+ | { readonly kind: "error"; readonly error: DispatcherError }
25
+ | { readonly kind: "ready"; readonly rows: readonly TRow[]; readonly cursor: string | null };
26
+
27
+ /** Cursor-paginated scroll list (mail inbox, activity feeds) — loads the
28
+ * next page when the end sentinel becomes visible (IntersectionObserver),
29
+ * instead of a pager bar like QueryTable/entityList. The query handler
30
+ * receives `{ ...payload, limit, cursor? }` and returns rows plus the next
31
+ * cursor (same cursor convention as the audit-log screen). */
32
+ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unknown>>>({
33
+ query,
34
+ payload,
35
+ pageSize = 50,
36
+ rows,
37
+ nextCursor,
38
+ rowId,
39
+ renderRow,
40
+ emptyState,
41
+ className,
42
+ testId,
43
+ }: InfinityListProps<TData, TRow>): ReactNode {
44
+ const dispatcher = useDispatcher();
45
+ const t = useTranslation();
46
+ const [state, setState] = useState<State<TRow>>({ kind: "loading" });
47
+ const sentinelRef = useRef<HTMLDivElement | null>(null);
48
+ const payloadKey = JSON.stringify(payload ?? {});
49
+
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.
54
+ const rowsRef = useRef(rows);
55
+ rowsRef.current = rows;
56
+ const nextCursorRef = useRef(nextCursor);
57
+ nextCursorRef.current = nextCursor;
58
+
59
+ // biome-ignore lint/correctness/useExhaustiveDependencies: payload goes through payloadKey
60
+ const load = useCallback(
61
+ async (cursor: string | null): Promise<void> => {
62
+ const res = await dispatcher.query<TData>(query, {
63
+ ...payload,
64
+ limit: pageSize,
65
+ ...(cursor !== null && { cursor }),
66
+ });
67
+ if (!res.isSuccess) {
68
+ setState({ kind: "error", error: res.error });
69
+ return;
70
+ }
71
+ const nextRows = rowsRef.current(res.data);
72
+ setState((prev) => ({
73
+ kind: "ready",
74
+ rows: cursor === null || prev.kind !== "ready" ? nextRows : [...prev.rows, ...nextRows],
75
+ cursor: nextCursorRef.current(res.data),
76
+ }));
77
+ },
78
+ [dispatcher, query, pageSize, payloadKey],
79
+ );
80
+
81
+ useEffect(() => {
82
+ setState({ kind: "loading" });
83
+ void load(null);
84
+ }, [load]);
85
+
86
+ useEffect(() => {
87
+ const sentinel = sentinelRef.current;
88
+ // skip: no further page or not ready yet — observer not needed
89
+ if (sentinel === null || state.kind !== "ready" || state.cursor === null) return;
90
+ const cursor = state.cursor;
91
+ const observer = new IntersectionObserver((entries) => {
92
+ if (entries[0]?.isIntersecting === true) void load(cursor);
93
+ });
94
+ observer.observe(sentinel);
95
+ return () => observer.disconnect();
96
+ }, [state, load]);
97
+
98
+ if (state.kind === "loading") return <LoadingState rows={4} testId={testId} />;
99
+ if (state.kind === "error")
100
+ return <ErrorState error={state.error} onRetry={() => void load(null)} testId={testId} />;
101
+ if (state.rows.length === 0)
102
+ return <>{emptyState ?? <EmptyState title={t("kumiko.list.no-entries")} testId={testId} />}</>;
103
+
104
+ return (
105
+ <div data-testid={testId} className={className ?? "flex flex-col overflow-y-auto"}>
106
+ {state.rows.map((row, index) => (
107
+ <div key={rowId(row, index)}>{renderRow(row)}</div>
108
+ ))}
109
+ <div ref={sentinelRef} className="h-px" />
110
+ </div>
111
+ );
112
+ }
@@ -129,14 +129,25 @@ export function ResultTable<Row>({
129
129
  className={cn(i === 0 && "border-t", row.emphasize === true && "font-semibold")}
130
130
  >
131
131
  <td
132
- colSpan={Math.max(columns.length - 1, 1)}
132
+ colSpan={columns.length > 1 ? Math.max(columns.length - 1, 1) : undefined}
133
133
  className={cn(
134
134
  "py-1.5 text-muted-foreground",
135
135
  card === true && "px-3",
136
136
  row.emphasize === true && "text-foreground",
137
+ columns.length === 1 && "flex items-center justify-between gap-4",
137
138
  )}
138
139
  >
139
140
  {row.label}
141
+ {columns.length === 1 && (
142
+ <span
143
+ className={cn(
144
+ "text-right tabular-nums",
145
+ row.emphasize === true && "text-foreground",
146
+ )}
147
+ >
148
+ {row.value}
149
+ </span>
150
+ )}
140
151
  </td>
141
152
  {columns.length > 1 && (
142
153
  <td className={cn("py-1.5 text-right tabular-nums", card === true && "px-3")}>