@cosmicdrift/kumiko-renderer-web 0.174.0 → 0.176.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 +4 -4
- package/src/__tests__/primitives.test.tsx +12 -7
- package/src/layout/default-app-shell.tsx +5 -3
- package/src/layout/fill-classes.ts +8 -0
- package/src/layout/workspace-shell.tsx +10 -8
- package/src/primitives/date-parse.ts +11 -4
- package/src/primitives/index.tsx +24 -12
- package/src/widgets/__tests__/infinity-list.test.tsx +54 -0
- package/src/widgets/infinity-list.tsx +11 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.176.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.176.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.176.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.176.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",
|
|
@@ -470,16 +470,21 @@ describe("DataTable", () => {
|
|
|
470
470
|
});
|
|
471
471
|
});
|
|
472
472
|
|
|
473
|
-
// Infinite-
|
|
474
|
-
// loadingMore,
|
|
475
|
-
//
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
//
|
|
473
|
+
// Infinite-scroll sentinel: renders a sentinel div, shows a spinner when
|
|
474
|
+
// loadingMore, and the i18n end-of-list marker (kumiko.list.end-of-list)
|
|
475
|
+
// when !hasMore and enough rows have loaded (short lists don't need a
|
|
476
|
+
// marker — they show their end on their own). IntersectionObserver itself
|
|
477
|
+
// is unmocked in jsdom — we only test the marker; the observer-fire path
|
|
478
|
+
// lives in KumikoScreen.EntityListBody.
|
|
479
479
|
describe("InfiniteSentinel", () => {
|
|
480
480
|
const cols = [{ field: "name", label: "Name", type: "string", sortable: false }] as const;
|
|
481
481
|
const oneRow = [{ id: "r1", values: { name: "A" } }];
|
|
482
|
-
|
|
482
|
+
// 60 rows — above END_LABEL_MIN_ROWS (50, the framework default pageSize)
|
|
483
|
+
// so hasMore=false genuinely means "reached the end of a full default
|
|
484
|
+
// page". 25 used to pass here too, back when the threshold was a
|
|
485
|
+
// mismatched 20 (fw#1713) — that under-a-page case is now covered
|
|
486
|
+
// separately below by the exact-boundary tests.
|
|
487
|
+
const manyRows = Array.from({ length: 60 }, (_, i) => ({
|
|
483
488
|
id: `r${i}`,
|
|
484
489
|
values: { name: `A${i}` },
|
|
485
490
|
}));
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
SidebarProvider,
|
|
31
31
|
SidebarRail,
|
|
32
32
|
} from "../ui/sidebar";
|
|
33
|
+
import { fillClasses } from "./fill-classes";
|
|
33
34
|
import { NavTree } from "./nav-tree";
|
|
34
35
|
import { ShellHeader } from "./shell-header";
|
|
35
36
|
|
|
@@ -83,8 +84,9 @@ export function DefaultAppShell({
|
|
|
83
84
|
fill,
|
|
84
85
|
children,
|
|
85
86
|
}: DefaultAppShellProps): ReactNode {
|
|
87
|
+
const fillCls = fillClasses(fill);
|
|
86
88
|
return (
|
|
87
|
-
<SidebarProvider {...
|
|
89
|
+
<SidebarProvider {...fillCls.provider}>
|
|
88
90
|
{/* sidebar-07-Muster: Standard-Variante (border-r, flush content) +
|
|
89
91
|
collapsible="icon" — der Rail klappt auf Icon-Breite zu (Trigger/Rail). */}
|
|
90
92
|
<Sidebar collapsible="icon">
|
|
@@ -109,13 +111,13 @@ export function DefaultAppShell({
|
|
|
109
111
|
)}
|
|
110
112
|
<SidebarRail />
|
|
111
113
|
</Sidebar>
|
|
112
|
-
<SidebarInset className={
|
|
114
|
+
<SidebarInset className={fillCls.inset}>
|
|
113
115
|
<ShellHeader
|
|
114
116
|
schema={schema}
|
|
115
117
|
{...(user !== undefined && { user })}
|
|
116
118
|
{...(headerActions !== undefined && { headerActions })}
|
|
117
119
|
/>
|
|
118
|
-
<main className={
|
|
120
|
+
<main className={fillCls.main}>
|
|
119
121
|
<UserRolesProvider roles={user?.roles}>{children}</UserRolesProvider>
|
|
120
122
|
</main>
|
|
121
123
|
</SidebarInset>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Shared `fill`-prop wiring for the sidebar-based app shells
|
|
2
|
+
// (DefaultAppShell, WorkspaceShell) — both wrap the same vendored
|
|
3
|
+
// shadcn sidebar and need identical viewport-fit classes.
|
|
4
|
+
export const fillClasses = (fill?: boolean) => ({
|
|
5
|
+
provider: fill === true ? { className: "h-svh" } : {},
|
|
6
|
+
inset: fill === true ? "min-h-0" : undefined,
|
|
7
|
+
main: fill === true ? "min-h-0 flex-1 overflow-auto" : "flex-1 overflow-auto",
|
|
8
|
+
});
|
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
SidebarRail,
|
|
45
45
|
} from "../ui/sidebar";
|
|
46
46
|
import { EditorPanel } from "./editor-panel";
|
|
47
|
+
import { fillClasses } from "./fill-classes";
|
|
47
48
|
import { lastSegment, NavTree } from "./nav-tree";
|
|
48
49
|
import { ShellHeader } from "./shell-header";
|
|
49
50
|
import { parseTargetFromSearchParams } from "./target-url";
|
|
@@ -218,13 +219,14 @@ export function WorkspaceShell({
|
|
|
218
219
|
/>
|
|
219
220
|
);
|
|
220
221
|
|
|
221
|
-
// Modern shell (
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
222
|
+
// Modern shell (like DefaultAppShell): collapsible icon-rail sidebar
|
|
223
|
+
// carries brand + workspace switcher + nav + footer; the SidebarInset
|
|
224
|
+
// renders the shared ShellHeader (panel toggle + breadcrumb + actions)
|
|
225
|
+
// above the content. topbarActions render right inside the header row
|
|
226
|
+
// instead of a separate topbar — one header line, not two.
|
|
227
|
+
const fillCls = fillClasses(fill);
|
|
226
228
|
return (
|
|
227
|
-
<SidebarProvider {...
|
|
229
|
+
<SidebarProvider {...fillCls.provider}>
|
|
228
230
|
<Sidebar collapsible="icon">
|
|
229
231
|
<SidebarHeader data-kumiko-layout="sidebar-header">
|
|
230
232
|
{brand}
|
|
@@ -236,13 +238,13 @@ export function WorkspaceShell({
|
|
|
236
238
|
)}
|
|
237
239
|
<SidebarRail />
|
|
238
240
|
</Sidebar>
|
|
239
|
-
<SidebarInset className={
|
|
241
|
+
<SidebarInset className={fillCls.inset}>
|
|
240
242
|
<ShellHeader
|
|
241
243
|
schema={app}
|
|
242
244
|
{...(user !== undefined && { user })}
|
|
243
245
|
{...(topbarActions !== undefined && { headerActions: topbarActions })}
|
|
244
246
|
/>
|
|
245
|
-
<main className={
|
|
247
|
+
<main className={fillCls.main}>
|
|
246
248
|
{activeTarget !== undefined ? (
|
|
247
249
|
<EditorPanel resolvers={resolvers} />
|
|
248
250
|
) : (
|
|
@@ -7,6 +7,13 @@
|
|
|
7
7
|
|
|
8
8
|
import { Temporal } from "temporal-polyfill";
|
|
9
9
|
|
|
10
|
+
// Prefer the native Temporal (Chromium 144+/Firefox 139+) over the bundled
|
|
11
|
+
// polyfill so `instanceof` checks match values crossing package boundaries;
|
|
12
|
+
// falls back to the polyfill where native support is absent.
|
|
13
|
+
function activeTemporal(): typeof Temporal {
|
|
14
|
+
return (globalThis as unknown as { Temporal?: typeof Temporal }).Temporal ?? Temporal;
|
|
15
|
+
}
|
|
16
|
+
|
|
10
17
|
export function guessLocale(): string {
|
|
11
18
|
if (typeof navigator !== "undefined" && navigator.language) return navigator.language;
|
|
12
19
|
return "en-US";
|
|
@@ -17,7 +24,7 @@ export function guessLocale(): string {
|
|
|
17
24
|
// a different date.
|
|
18
25
|
function makePlainDate(y: number, m: number, d: number): Temporal.PlainDate | undefined {
|
|
19
26
|
try {
|
|
20
|
-
return
|
|
27
|
+
return activeTemporal().PlainDate.from({ year: y, month: m, day: d }, { overflow: "reject" });
|
|
21
28
|
} catch {
|
|
22
29
|
return undefined;
|
|
23
30
|
}
|
|
@@ -67,9 +74,9 @@ type DateSlot = "y" | "m" | "d";
|
|
|
67
74
|
// "UTC" keeps the reference from shifting to the 1st depending on the
|
|
68
75
|
// browser's TZ.
|
|
69
76
|
function localeDateOrder(locale: string): readonly DateSlot[] {
|
|
70
|
-
const refEpochMillis =
|
|
71
|
-
|
|
72
|
-
|
|
77
|
+
const refEpochMillis = activeTemporal()
|
|
78
|
+
.PlainDate.from({ year: 2026, month: 1, day: 2 })
|
|
79
|
+
.toZonedDateTime("UTC").epochMilliseconds;
|
|
73
80
|
const order: DateSlot[] = [];
|
|
74
81
|
for (const part of new Intl.DateTimeFormat(locale, { timeZone: "UTC" }).formatToParts(
|
|
75
82
|
refEpochMillis,
|
package/src/primitives/index.tsx
CHANGED
|
@@ -288,12 +288,16 @@ const FIELD_ICONS: Readonly<Record<string, typeof Mail>> = {
|
|
|
288
288
|
"map-pin": MapPin,
|
|
289
289
|
};
|
|
290
290
|
|
|
291
|
+
function fieldIconFor(icon: string | undefined): (typeof FIELD_ICONS)[string] | undefined {
|
|
292
|
+
return icon !== undefined ? FIELD_ICONS[icon] : undefined;
|
|
293
|
+
}
|
|
294
|
+
|
|
291
295
|
// Wraps a text/number input with a left-positioned prefix icon when
|
|
292
296
|
// `icon` carries a known FIELD_ICONS key. `pl-8` overrides (via
|
|
293
297
|
// tailwind-merge) only the left padding of the vendored ui/input.tsx —
|
|
294
298
|
// right padding and other defaults stay untouched.
|
|
295
299
|
function withFieldIcon(icon: string | undefined, input: ReactNode): ReactNode {
|
|
296
|
-
const Icon = icon
|
|
300
|
+
const Icon = fieldIconFor(icon);
|
|
297
301
|
if (Icon === undefined) return input;
|
|
298
302
|
return (
|
|
299
303
|
<div className="relative">
|
|
@@ -329,7 +333,7 @@ function DefaultInput(props: InputProps): ReactNode {
|
|
|
329
333
|
onChange={(e: ChangeEvent<HTMLInputElement>) => props.onChange(e.target.value)}
|
|
330
334
|
{...(props.placeholder !== undefined && { placeholder: props.placeholder })}
|
|
331
335
|
{...(props.autoComplete !== undefined && { autoComplete: props.autoComplete })}
|
|
332
|
-
className={cn(props.icon !== undefined
|
|
336
|
+
className={cn(fieldIconFor(props.icon) !== undefined ? "pl-8" : undefined)}
|
|
333
337
|
/>,
|
|
334
338
|
);
|
|
335
339
|
case "email":
|
|
@@ -369,7 +373,7 @@ function DefaultInput(props: InputProps): ReactNode {
|
|
|
369
373
|
}}
|
|
370
374
|
className={cn(
|
|
371
375
|
"text-right tabular-nums",
|
|
372
|
-
props.icon !== undefined
|
|
376
|
+
fieldIconFor(props.icon) !== undefined ? "pl-8" : undefined,
|
|
373
377
|
)}
|
|
374
378
|
/>,
|
|
375
379
|
);
|
|
@@ -1012,15 +1016,23 @@ function RowActionsKebab({
|
|
|
1012
1016
|
);
|
|
1013
1017
|
}
|
|
1014
1018
|
|
|
1015
|
-
// InfiniteSentinel —
|
|
1016
|
-
// IntersectionObserver
|
|
1017
|
-
//
|
|
1018
|
-
//
|
|
1019
|
-
//
|
|
1020
|
-
//
|
|
1021
|
-
//
|
|
1022
|
-
//
|
|
1023
|
-
|
|
1019
|
+
// InfiniteSentinel — empty div at the end of the table that uses
|
|
1020
|
+
// IntersectionObserver to detect when the user scrolls near the list end.
|
|
1021
|
+
// onReachEnd fires exactly once per "becomes visible" transition; the
|
|
1022
|
+
// caller debounces via loadingMore (further visibility events are ignored
|
|
1023
|
+
// while a page is loading). No observer during server-side render, no
|
|
1024
|
+
// observer when hasMore=false — then the sentinel only shows the
|
|
1025
|
+
// end-of-list hint via t("kumiko.list.end-of-list"), which requires a
|
|
1026
|
+
// LocaleProvider in the tree (same requirement as Field/useRowActionTrigger).
|
|
1027
|
+
// Without one, useTranslation() throws; with one that's missing the framework
|
|
1028
|
+
// defaults (fallbackBundles not including kumikoDefaultTranslations), the
|
|
1029
|
+
// user sees the raw key instead of translated text.
|
|
1030
|
+
// ponytail: threshold mirrors the framework default pageSize (kumiko-screen.tsx
|
|
1031
|
+
// `screen.pageSize ?? 50`) rather than the real per-screen pageSize, which isn't
|
|
1032
|
+
// threaded down to this component. A screen with a custom pageSize can still
|
|
1033
|
+
// under- or over-show the marker; thread pageSize through KumikoScreen →
|
|
1034
|
+
// RenderList → DataTable → InfiniteSentinel if that's needed (fw#1713).
|
|
1035
|
+
export const END_LABEL_MIN_ROWS = 50;
|
|
1024
1036
|
|
|
1025
1037
|
function InfiniteSentinel({
|
|
1026
1038
|
onReachEnd,
|
|
@@ -129,4 +129,58 @@ describe("InfinityList", () => {
|
|
|
129
129
|
);
|
|
130
130
|
await waitFor(() => expect(screen.getByText("Keine Nachrichten")).toBeTruthy());
|
|
131
131
|
});
|
|
132
|
+
|
|
133
|
+
// fw#1705: a fast payload change (e.g. two keystrokes in a search field)
|
|
134
|
+
// fires a second request before the first resolves. Without sequencing,
|
|
135
|
+
// a slow first response landing after a faster second one clobbers it —
|
|
136
|
+
// the displayed rows end up out of sync with the current payload.
|
|
137
|
+
test("verwirft eine überholte Response, wenn eine frühere Anfrage später auflöst", async () => {
|
|
138
|
+
const resolvers: Array<(res: unknown) => void> = [];
|
|
139
|
+
const dispatcher = createMockDispatcher({
|
|
140
|
+
query: (() =>
|
|
141
|
+
new Promise((resolve) => {
|
|
142
|
+
resolvers.push(resolve);
|
|
143
|
+
})) as unknown as Dispatcher["query"],
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
function SearchList({ search }: { readonly search: string }) {
|
|
147
|
+
return (
|
|
148
|
+
<InfinityList<Page, Row>
|
|
149
|
+
query="inbox:query:message:list"
|
|
150
|
+
payload={{ search }}
|
|
151
|
+
rows={(data) => data.rows}
|
|
152
|
+
nextCursor={(data) => data.nextCursor}
|
|
153
|
+
rowId={(row) => row.id}
|
|
154
|
+
renderRow={(row) => <span>{row.subject}</span>}
|
|
155
|
+
testId="inbox"
|
|
156
|
+
/>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const { rerender } = renderWithDispatcher(<SearchList search="Bo" />, dispatcher);
|
|
161
|
+
await waitFor(() => expect(resolvers.length).toBe(1));
|
|
162
|
+
|
|
163
|
+
rerender(
|
|
164
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
165
|
+
<SearchList search="Bob" />
|
|
166
|
+
</DispatcherProvider>,
|
|
167
|
+
);
|
|
168
|
+
await waitFor(() => expect(resolvers.length).toBe(2));
|
|
169
|
+
|
|
170
|
+
// "Bob" (second, faster) resolves first.
|
|
171
|
+
resolvers[1]?.({
|
|
172
|
+
isSuccess: true,
|
|
173
|
+
data: { rows: [{ id: "m2", subject: "Bob-Treffer" }], nextCursor: null },
|
|
174
|
+
});
|
|
175
|
+
await waitFor(() => expect(screen.getByText("Bob-Treffer")).toBeTruthy());
|
|
176
|
+
|
|
177
|
+
// "Bo" (first, slower) resolves late — must be discarded, not overwrite the display.
|
|
178
|
+
resolvers[0]?.({
|
|
179
|
+
isSuccess: true,
|
|
180
|
+
data: { rows: [{ id: "m1", subject: "Bo-Treffer" }], nextCursor: null },
|
|
181
|
+
});
|
|
182
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
183
|
+
expect(screen.queryByText("Bo-Treffer")).toBeNull();
|
|
184
|
+
expect(screen.getByText("Bob-Treffer")).toBeTruthy();
|
|
185
|
+
});
|
|
132
186
|
});
|
|
@@ -56,14 +56,22 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
|
|
|
56
56
|
const nextCursorRef = useRef(nextCursor);
|
|
57
57
|
nextCursorRef.current = nextCursor;
|
|
58
58
|
|
|
59
|
+
// Discards a response whose request was superseded by a newer one before
|
|
60
|
+
// it resolved (e.g. two searches fired in quick succession) — without
|
|
61
|
+
// this, a slow earlier response can land after a faster later one and
|
|
62
|
+
// overwrite it, leaving stale rows on screen (fw#1705).
|
|
63
|
+
const requestSeq = useRef(0);
|
|
64
|
+
|
|
59
65
|
// biome-ignore lint/correctness/useExhaustiveDependencies: payload goes through payloadKey
|
|
60
66
|
const load = useCallback(
|
|
61
67
|
async (cursor: string | null): Promise<void> => {
|
|
68
|
+
const mySeq = ++requestSeq.current;
|
|
62
69
|
const res = await dispatcher.query<TData>(query, {
|
|
63
70
|
...payload,
|
|
64
71
|
limit: pageSize,
|
|
65
72
|
...(cursor !== null && { cursor }),
|
|
66
73
|
});
|
|
74
|
+
if (mySeq !== requestSeq.current) return;
|
|
67
75
|
if (!res.isSuccess) {
|
|
68
76
|
setState({ kind: "error", error: res.error });
|
|
69
77
|
return;
|
|
@@ -81,6 +89,9 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
|
|
|
81
89
|
useEffect(() => {
|
|
82
90
|
setState({ kind: "loading" });
|
|
83
91
|
void load(null);
|
|
92
|
+
return () => {
|
|
93
|
+
requestSeq.current += 1;
|
|
94
|
+
};
|
|
84
95
|
}, [load]);
|
|
85
96
|
|
|
86
97
|
useEffect(() => {
|