@cosmicdrift/kumiko-renderer-web 0.221.0 → 0.223.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 +5 -5
- package/src/__tests__/nav-tree.test.tsx +6 -14
- package/src/__tests__/primitives.test.tsx +29 -1
- package/src/app/plain-content-editor.tsx +6 -2
- package/src/primitives/__tests__/date-parse.test.ts +6 -3
- package/src/primitives/__tests__/grid.test.tsx +64 -0
- package/src/primitives/date-parse.ts +2 -0
- package/src/primitives/index.tsx +31 -8
- package/src/styles.css +1 -0
- package/src/widgets/__tests__/infinity-list.test.tsx +17 -9
- package/src/widgets/drawer.tsx +7 -3
- package/src/widgets/infinity-list.tsx +41 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.223.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.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
19
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.223.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.223.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.223.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.
|
|
67
|
+
"@cosmicdrift/kumiko-locale-de": "0.223.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: [
|
|
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
|
-
|
|
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:
|
|
37
|
-
//
|
|
38
|
-
|
|
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);
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// DefaultGrid's `maxRows` clips the grid to roughly N rows once the option
|
|
2
|
+
// count (given `columns`) actually exceeds that many rows — style-only until
|
|
3
|
+
// then, no extra wrapper element. Height derives from the
|
|
4
|
+
// `--kumiko-grid-row-h` CSS var (same var `grid-auto-rows`'s minmax floor
|
|
5
|
+
// uses), never a hardcoded pixel/rem constant, so a theme redefining the var
|
|
6
|
+
// scales both together. `grid-auto-rows` is a minmax MIN, not a fixed
|
|
7
|
+
// height, so a wrapped label grows its row instead of being clipped.
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import { render, screen } from "@testing-library/react";
|
|
11
|
+
import { defaultPrimitives } from "../index";
|
|
12
|
+
|
|
13
|
+
const { Grid } = defaultPrimitives;
|
|
14
|
+
|
|
15
|
+
describe("DefaultGrid — maxRows", () => {
|
|
16
|
+
test("fewer options than maxRows rows can hold → no scroll container", () => {
|
|
17
|
+
render(
|
|
18
|
+
<Grid columns={2} maxRows={3} testId="grid">
|
|
19
|
+
<div>a</div>
|
|
20
|
+
<div>b</div>
|
|
21
|
+
</Grid>,
|
|
22
|
+
);
|
|
23
|
+
const el = screen.getByTestId("grid");
|
|
24
|
+
expect(el.style.overflowY).toBe("");
|
|
25
|
+
expect(el.style.maxHeight).toBe("");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("more options than maxRows rows can hold → scroll container with height derived from maxRows", () => {
|
|
29
|
+
render(
|
|
30
|
+
<Grid columns={2} maxRows={2} testId="grid">
|
|
31
|
+
<div>a</div>
|
|
32
|
+
<div>b</div>
|
|
33
|
+
<div>c</div>
|
|
34
|
+
<div>d</div>
|
|
35
|
+
<div>e</div>
|
|
36
|
+
</Grid>,
|
|
37
|
+
);
|
|
38
|
+
const el = screen.getByTestId("grid");
|
|
39
|
+
expect(el.style.overflowY).toBe("auto");
|
|
40
|
+
expect(el.style.maxHeight).toContain("var(--kumiko-grid-row-h");
|
|
41
|
+
expect(el.style.maxHeight).toContain("2 *");
|
|
42
|
+
// minmax(..., auto), not a fixed height — a row taller than the CSS var
|
|
43
|
+
// (a wrapped label) grows instead of getting clipped mid-row.
|
|
44
|
+
const gridAutoRows = el.style.getPropertyValue("grid-auto-rows");
|
|
45
|
+
expect(gridAutoRows).toContain("var(--kumiko-grid-row-h");
|
|
46
|
+
expect(gridAutoRows).toContain("minmax(");
|
|
47
|
+
expect(gridAutoRows).toContain("auto");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("no maxRows at all → never a scroll container regardless of option count", () => {
|
|
51
|
+
render(
|
|
52
|
+
<Grid columns={2} testId="grid">
|
|
53
|
+
<div>a</div>
|
|
54
|
+
<div>b</div>
|
|
55
|
+
<div>c</div>
|
|
56
|
+
<div>d</div>
|
|
57
|
+
<div>e</div>
|
|
58
|
+
</Grid>,
|
|
59
|
+
);
|
|
60
|
+
const el = screen.getByTestId("grid");
|
|
61
|
+
expect(el.style.overflowY).toBe("");
|
|
62
|
+
expect(el.style.maxHeight).toBe("");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -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
|
}
|
package/src/primitives/index.tsx
CHANGED
|
@@ -70,6 +70,7 @@ import {
|
|
|
70
70
|
} from "lucide-react";
|
|
71
71
|
import {
|
|
72
72
|
type ChangeEvent,
|
|
73
|
+
Children,
|
|
73
74
|
type CSSProperties,
|
|
74
75
|
createContext,
|
|
75
76
|
type MouseEvent,
|
|
@@ -1003,10 +1004,10 @@ function RowActionButton({
|
|
|
1003
1004
|
);
|
|
1004
1005
|
}
|
|
1005
1006
|
|
|
1006
|
-
// Kebab
|
|
1007
|
-
//
|
|
1008
|
-
//
|
|
1009
|
-
//
|
|
1007
|
+
// Kebab dropdown for >2 actions, one inline confirm dialog per item like
|
|
1008
|
+
// the inline-button path. Menu is controlled so it closes explicitly on
|
|
1009
|
+
// select, instead of relying on Radix's auto-close, which preventDefault
|
|
1010
|
+
// below blocks.
|
|
1010
1011
|
function RowActionsKebab({
|
|
1011
1012
|
row,
|
|
1012
1013
|
actions,
|
|
@@ -1016,10 +1017,11 @@ function RowActionsKebab({
|
|
|
1016
1017
|
}): ReactNode {
|
|
1017
1018
|
const { triggerNow } = useRowActionTrigger(row);
|
|
1018
1019
|
const [pendingConfirm, setPendingConfirm] = useState<DataTableRowAction | null>(null);
|
|
1020
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
1019
1021
|
|
|
1020
1022
|
return (
|
|
1021
1023
|
<>
|
|
1022
|
-
<DropdownMenu>
|
|
1024
|
+
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
|
|
1023
1025
|
<DropdownMenuTrigger asChild>
|
|
1024
1026
|
<button
|
|
1025
1027
|
type="button"
|
|
@@ -1041,6 +1043,7 @@ function RowActionsKebab({
|
|
|
1041
1043
|
data-testid={`row-${row.id}-action-${action.id}`}
|
|
1042
1044
|
onSelect={(e) => {
|
|
1043
1045
|
e.preventDefault();
|
|
1046
|
+
setMenuOpen(false);
|
|
1044
1047
|
if (needsConfirm(action)) {
|
|
1045
1048
|
setPendingConfirm(action);
|
|
1046
1049
|
} else {
|
|
@@ -1846,18 +1849,38 @@ function DefaultSection({
|
|
|
1846
1849
|
);
|
|
1847
1850
|
}
|
|
1848
1851
|
|
|
1849
|
-
function DefaultGrid({ columns, children, testId }: GridProps): ReactNode {
|
|
1852
|
+
function DefaultGrid({ columns, children, testId, maxRows }: GridProps): ReactNode {
|
|
1850
1853
|
// Responsive: Mobile (< sm = 640px) bleibt 1-spaltig, ab sm: greift
|
|
1851
1854
|
// die Author-deklarierte Spaltenzahl. Inline-style schreibt
|
|
1852
1855
|
// CSS-Variable; Tailwind-Klasse liest die Variable mit
|
|
1853
1856
|
// `grid-template-columns: var(--grid-cols)`. Saubere Lösung weil
|
|
1854
1857
|
// Tailwind JIT keinen dynamischen `grid-cols-${N}` auflösen kann.
|
|
1858
|
+
const rowCount = Math.ceil(Children.count(children) / columns);
|
|
1859
|
+
const clipped = maxRows !== undefined && rowCount > maxRows;
|
|
1860
|
+
// gridAutoRows is a minmax MIN, not a fixed height, so wrapped labels grow
|
|
1861
|
+
// their row instead of being clipped mid-row. maxHeight is therefore only
|
|
1862
|
+
// an approximation of "maxRows rows tall" — a row taller than the minimum
|
|
1863
|
+
// means fewer than maxRows rows end up visible before scrolling kicks in.
|
|
1864
|
+
// Themes may override --kumiko-grid-row-h (default 2.5rem, set in
|
|
1865
|
+
// styles.css) to change that minimum.
|
|
1866
|
+
// Mobile note: the grid collapses to 1 column below `sm` (see className),
|
|
1867
|
+
// but rowCount above is always computed against the desktop `columns` —
|
|
1868
|
+
// inline styles can't express a media query, so the mobile row count is
|
|
1869
|
+
// an under-count. Known, accepted.
|
|
1870
|
+
const style: CSSProperties = {
|
|
1871
|
+
"--grid-cols": `repeat(${columns}, minmax(0, 1fr))`,
|
|
1872
|
+
...(clipped && {
|
|
1873
|
+
gridAutoRows: "minmax(var(--kumiko-grid-row-h, 2.5rem), auto)",
|
|
1874
|
+
maxHeight: `calc(${maxRows} * var(--kumiko-grid-row-h, 2.5rem) + ${maxRows - 1} * 1rem)`,
|
|
1875
|
+
overflowY: "auto",
|
|
1876
|
+
}),
|
|
1877
|
+
// workaround: duplicate @types/react instances break direct CSSProperties cast
|
|
1878
|
+
} as unknown as CSSProperties;
|
|
1855
1879
|
return (
|
|
1856
1880
|
<div
|
|
1857
1881
|
data-testid={testId}
|
|
1858
1882
|
className="grid gap-4 grid-cols-1 sm:[grid-template-columns:var(--grid-cols)]"
|
|
1859
|
-
|
|
1860
|
-
style={{ "--grid-cols": `repeat(${columns}, minmax(0, 1fr))` } as unknown as CSSProperties}
|
|
1883
|
+
style={style}
|
|
1861
1884
|
>
|
|
1862
1885
|
{children}
|
|
1863
1886
|
</div>
|
package/src/styles.css
CHANGED
|
@@ -99,6 +99,7 @@
|
|
|
99
99
|
--card-padding: 1.5rem;
|
|
100
100
|
--card-radius: var(--radius-xl);
|
|
101
101
|
--card-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
|
102
|
+
--kumiko-grid-row-h: 2.5rem;
|
|
102
103
|
}
|
|
103
104
|
|
|
104
105
|
/* Light-Mode-Overrides — aktiv wenn `<html>` KEINE `.dark` Klasse
|
|
@@ -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
|
|
259
|
-
|
|
260
|
-
|
|
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
|
|
package/src/widgets/drawer.tsx
CHANGED
|
@@ -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
|
|
143
|
-
event.currentTarget.
|
|
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={
|
|
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
|
|
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 =
|
|
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
|
-
|
|
193
|
-
|
|
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(() => {
|