@cosmicdrift/kumiko-renderer-web 0.236.0 → 0.237.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.
|
|
3
|
+
"version": "0.237.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.237.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.237.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.237.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.237.0"
|
|
68
68
|
},
|
|
69
69
|
"repository": {
|
|
70
70
|
"type": "git",
|
|
@@ -682,5 +682,46 @@ describe("createKumikoApp", () => {
|
|
|
682
682
|
|
|
683
683
|
await waitFor(() => expect(window.location.pathname).toBe("/task-detail/r1"));
|
|
684
684
|
});
|
|
685
|
+
|
|
686
|
+
// fw#2640: a list screen with no reachable click target must not look
|
|
687
|
+
// clickable either — the click handler was always defined before,
|
|
688
|
+
// silently no-op-ing on click. Row-testids stay independent of the
|
|
689
|
+
// handler (see primitives/index.tsx), so getByTestId still finds them.
|
|
690
|
+
test("no detailFor screen and no entityEdit screen → no row-click handler, row not clickable", async () => {
|
|
691
|
+
window.history.replaceState(null, "", "/task-list");
|
|
692
|
+
mountRoot();
|
|
693
|
+
const schema: FeatureSchema = {
|
|
694
|
+
featureName: "tasks",
|
|
695
|
+
entities: { task: taskEntity },
|
|
696
|
+
screens: [listScreen],
|
|
697
|
+
};
|
|
698
|
+
await mountApp({
|
|
699
|
+
schema,
|
|
700
|
+
dispatcher: makeRowDispatcher(),
|
|
701
|
+
screenQn: "tasks:screen:task-list",
|
|
702
|
+
});
|
|
703
|
+
await waitFor(() => expect(screen.getByTestId("row-r1")).toBeTruthy());
|
|
704
|
+
|
|
705
|
+
const row = screen.getByTestId("row-r1");
|
|
706
|
+
expect(row.className).not.toContain("cursor-pointer");
|
|
707
|
+
|
|
708
|
+
fireEvent.click(row);
|
|
709
|
+
|
|
710
|
+
// No target exists — the click must not navigate anywhere.
|
|
711
|
+
expect(window.location.pathname).toBe("/task-list");
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
test("entityEdit screen for the entity → row-click handler present, row looks clickable", async () => {
|
|
715
|
+
window.history.replaceState(null, "", "/task-list");
|
|
716
|
+
mountRoot();
|
|
717
|
+
await mountApp({
|
|
718
|
+
schema: baseSchema,
|
|
719
|
+
dispatcher: makeRowDispatcher(),
|
|
720
|
+
screenQn: "tasks:screen:task-list",
|
|
721
|
+
});
|
|
722
|
+
await waitFor(() => expect(screen.getByTestId("row-r1")).toBeTruthy());
|
|
723
|
+
|
|
724
|
+
expect(screen.getByTestId("row-r1").className).toContain("cursor-pointer");
|
|
725
|
+
});
|
|
685
726
|
});
|
|
686
727
|
});
|
|
@@ -11,7 +11,7 @@ import { describe, expect, mock, test } from "bun:test";
|
|
|
11
11
|
import { type ColumnRendererProps, ColumnRenderersProvider } from "@cosmicdrift/kumiko-renderer";
|
|
12
12
|
import userEvent from "@testing-library/user-event";
|
|
13
13
|
import type { ReactNode } from "react";
|
|
14
|
-
import { defaultPrimitives, END_LABEL_MIN_ROWS } from "../primitives";
|
|
14
|
+
import { defaultPrimitives, END_LABEL_MIN_ROWS, FormScreenShell } from "../primitives";
|
|
15
15
|
import { PageSection, Stack } from "../primitives/layout";
|
|
16
16
|
import { fireEvent, render, screen, waitFor } from "./test-utils";
|
|
17
17
|
|
|
@@ -1725,4 +1725,24 @@ describe("PageSection", () => {
|
|
|
1725
1725
|
expect(screen.getByTestId("p").className).toContain("p-6");
|
|
1726
1726
|
expect(screen.getByTestId("child")).toBeDefined();
|
|
1727
1727
|
});
|
|
1728
|
+
|
|
1729
|
+
test("maxWidth=4xl sets the same width class as FormScreenShell maxWidth=4xl (fw#2640)", () => {
|
|
1730
|
+
render(
|
|
1731
|
+
<>
|
|
1732
|
+
<PageSection testId="page-4xl" maxWidth="4xl">
|
|
1733
|
+
x
|
|
1734
|
+
</PageSection>
|
|
1735
|
+
<FormScreenShell testId="shell-4xl" maxWidth="4xl">
|
|
1736
|
+
x
|
|
1737
|
+
</FormScreenShell>
|
|
1738
|
+
</>,
|
|
1739
|
+
);
|
|
1740
|
+
expect(screen.getByTestId("page-4xl").className).toContain("max-w-4xl");
|
|
1741
|
+
expect(screen.getByTestId("shell-4xl").className).toContain("max-w-4xl");
|
|
1742
|
+
});
|
|
1743
|
+
|
|
1744
|
+
test("default maxWidth stays full-width (existing behavior)", () => {
|
|
1745
|
+
render(<PageSection testId="p-default">x</PageSection>);
|
|
1746
|
+
expect(screen.getByTestId("p-default").className).toContain("max-w-full");
|
|
1747
|
+
});
|
|
1728
1748
|
});
|
package/src/app/create-app.tsx
CHANGED
|
@@ -526,6 +526,29 @@ function findOwnerFeature(app: AppSchema, qn: string): FeatureSchema | undefined
|
|
|
526
526
|
return undefined;
|
|
527
527
|
}
|
|
528
528
|
|
|
529
|
+
type ScreenDef = FeatureSchema["screens"][number];
|
|
530
|
+
|
|
531
|
+
// fw#2640: a list screen with no reachable click target (no `detailFor`
|
|
532
|
+
// screen for its entity, no app-wide onRowClick, no entityEdit screen)
|
|
533
|
+
// must render without the row-click affordance instead of a silent no-op —
|
|
534
|
+
// mirrors the precedence effectiveOnRowClick itself resolves at click time.
|
|
535
|
+
// Only entityList/projectionList screens ever render RenderList; every other
|
|
536
|
+
// screen type never wires onRowClick at all, so it can never have a target.
|
|
537
|
+
function hasRowClickTarget(
|
|
538
|
+
app: AppSchema,
|
|
539
|
+
screen: ScreenDef | undefined,
|
|
540
|
+
onRowClick: ((row: ListRowViewModel, entityName: string) => void) | undefined,
|
|
541
|
+
): boolean {
|
|
542
|
+
if (screen === undefined) return false;
|
|
543
|
+
if (screen.type !== "entityList" && screen.type !== "projectionList") return false;
|
|
544
|
+
if (onRowClick !== undefined) return true;
|
|
545
|
+
if (screen.type !== "entityList") return false;
|
|
546
|
+
if (hasDetailScreen(app.features, screen.entity)) return true;
|
|
547
|
+
return app.features.some((f) =>
|
|
548
|
+
f.screens.some((s) => s.type === "entityEdit" && s.entity === screen.entity),
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
|
|
529
552
|
function RoutedScreen({
|
|
530
553
|
app,
|
|
531
554
|
fallbackQn,
|
|
@@ -544,12 +567,16 @@ function RoutedScreen({
|
|
|
544
567
|
// need to pin it to the right feature. Strategy: iterate through all
|
|
545
568
|
// features until the matching screen decl turns up. No match →
|
|
546
569
|
// fallback feature (the one from fallbackQn).
|
|
547
|
-
const { feature, qn, entityId } = useMemo(() => {
|
|
570
|
+
const { feature, qn, entityId, activeScreen } = useMemo(() => {
|
|
548
571
|
if (nav.route === undefined) {
|
|
572
|
+
const ownerFeature = findOwnerFeature(app, fallbackQn);
|
|
549
573
|
return {
|
|
550
|
-
feature:
|
|
574
|
+
feature: ownerFeature,
|
|
551
575
|
qn: fallbackQn,
|
|
552
576
|
entityId: undefined as string | undefined,
|
|
577
|
+
activeScreen: ownerFeature?.screens.find(
|
|
578
|
+
(s) => qualifyScreenId(ownerFeature.featureName, s.id) === fallbackQn,
|
|
579
|
+
),
|
|
553
580
|
};
|
|
554
581
|
}
|
|
555
582
|
const shortId = nav.route.screenId;
|
|
@@ -567,12 +594,16 @@ function RoutedScreen({
|
|
|
567
594
|
feature: ownerFeature,
|
|
568
595
|
qn: qualifiedQn,
|
|
569
596
|
entityId: nav.route.entityId,
|
|
597
|
+
activeScreen: ownerFeature?.screens.find(
|
|
598
|
+
(s) => qualifyScreenId(ownerFeature.featureName, s.id) === qualifiedQn,
|
|
599
|
+
),
|
|
570
600
|
};
|
|
571
601
|
}, [nav.route, app, fallbackQn]);
|
|
572
602
|
|
|
573
603
|
const effectiveOnRowClick = useMemo<
|
|
574
604
|
((row: ListRowViewModel, entityName: string) => void) | undefined
|
|
575
605
|
>(() => {
|
|
606
|
+
if (!hasRowClickTarget(app, activeScreen, onRowClick)) return undefined;
|
|
576
607
|
return (row, entityName) => {
|
|
577
608
|
// Precedence (fw#2164): detailFor screen, then onRowClick, then the
|
|
578
609
|
// entityEdit-search fallback below. An explicit rowActions
|
|
@@ -603,7 +634,7 @@ function RoutedScreen({
|
|
|
603
634
|
}
|
|
604
635
|
}
|
|
605
636
|
};
|
|
606
|
-
}, [
|
|
637
|
+
}, [app, activeScreen, onRowClick, nav]);
|
|
607
638
|
|
|
608
639
|
// Copy-link action (Issue #912) for entityEdit update screens. Builds
|
|
609
640
|
// the absolute permalink URL from the current route + copies it —
|
|
@@ -50,6 +50,7 @@ import {
|
|
|
50
50
|
useTranslation,
|
|
51
51
|
} from "@cosmicdrift/kumiko-renderer";
|
|
52
52
|
import { type ReactNode, useEffect } from "react";
|
|
53
|
+
import { PageSection } from "../primitives/layout";
|
|
53
54
|
import { TimeseriesChart, type TimeseriesPoint } from "../widgets/charts";
|
|
54
55
|
import { FeedList, type FeedRow } from "../widgets/feed-list";
|
|
55
56
|
import { ProgressList, type ProgressListRow } from "../widgets/progress-list";
|
|
@@ -434,7 +435,7 @@ export function WebDashboardBody({ screen, translate }: DashboardBodyProps): Rea
|
|
|
434
435
|
const effectiveTranslate = translate ?? t;
|
|
435
436
|
const { params: filterParams, picker } = useFilterParams(screen);
|
|
436
437
|
return (
|
|
437
|
-
<
|
|
438
|
+
<PageSection className="flex flex-col gap-4" testId={`dashboard-${screen.id}`}>
|
|
438
439
|
{picker}
|
|
439
440
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
440
441
|
{screen.panels.map((panel) => {
|
|
@@ -452,6 +453,6 @@ export function WebDashboardBody({ screen, translate }: DashboardBodyProps): Rea
|
|
|
452
453
|
);
|
|
453
454
|
})}
|
|
454
455
|
</div>
|
|
455
|
-
</
|
|
456
|
+
</PageSection>
|
|
456
457
|
);
|
|
457
458
|
}
|
package/src/primitives/index.tsx
CHANGED
|
@@ -109,6 +109,7 @@ import {
|
|
|
109
109
|
} from "./dropdown-menu";
|
|
110
110
|
import { EmbeddedListInput } from "./embedded-list-input";
|
|
111
111
|
import { FileUploadInput } from "./file-upload";
|
|
112
|
+
import { screenWidthClassName } from "./layout";
|
|
112
113
|
import { DefaultLightbox } from "./lightbox";
|
|
113
114
|
import { LocatedTimestampInput } from "./located-timestamp-input";
|
|
114
115
|
import { DefaultMetric } from "./metric";
|
|
@@ -2160,17 +2161,12 @@ function DefaultForm({
|
|
|
2160
2161
|
}
|
|
2161
2162
|
|
|
2162
2163
|
// Canonical form/settings shell shared by DefaultForm (configEdit/entityEdit)
|
|
2163
|
-
// and custom settings screens (profile, privacy-center). Default width
|
|
2164
|
-
//
|
|
2165
|
-
//
|
|
2164
|
+
// and custom settings screens (profile, privacy-center). Default width is
|
|
2165
|
+
// "4xl" — a centered column, narrower than list chrome (which runs full-
|
|
2166
|
+
// width) — override via maxWidth / layout.width, e.g. "full" to match list
|
|
2167
|
+
// chrome or "3xl" for a narrower auth-adjacent form.
|
|
2166
2168
|
export type FormScreenShellWidth = FormWidth;
|
|
2167
2169
|
|
|
2168
|
-
const formScreenShellWidth: Record<FormScreenShellWidth, string> = {
|
|
2169
|
-
"3xl": "max-w-3xl mx-auto",
|
|
2170
|
-
"4xl": "max-w-4xl mx-auto",
|
|
2171
|
-
full: "max-w-full",
|
|
2172
|
-
};
|
|
2173
|
-
|
|
2174
2170
|
export function FormScreenShell({
|
|
2175
2171
|
children,
|
|
2176
2172
|
className,
|
|
@@ -2185,7 +2181,7 @@ export function FormScreenShell({
|
|
|
2185
2181
|
return (
|
|
2186
2182
|
<div
|
|
2187
2183
|
data-testid={testId}
|
|
2188
|
-
className={cn("px-6 pt-6 pb-12 w-full",
|
|
2184
|
+
className={cn("px-6 pt-6 pb-12 w-full", screenWidthClassName[maxWidth], className)}
|
|
2189
2185
|
>
|
|
2190
2186
|
{children}
|
|
2191
2187
|
</div>
|
|
@@ -1,10 +1,22 @@
|
|
|
1
|
-
// Layout
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Layout primitives for custom screens: one place for vertical spacing and
|
|
2
|
+
// screen padding, so consumers don't hand-roll `flex flex-col gap-*` / `p-6`.
|
|
3
|
+
// Deliberately thin — not a generic box-with-20-props system.
|
|
4
4
|
|
|
5
|
+
import type { FormWidth } from "@cosmicdrift/kumiko-renderer";
|
|
5
6
|
import type { ReactNode } from "react";
|
|
6
7
|
import { cn } from "../lib/cn";
|
|
7
8
|
|
|
9
|
+
export type ScreenWidth = FormWidth;
|
|
10
|
+
|
|
11
|
+
// Shared 3xl/4xl/full → max-w-*/mx-auto map. FormScreenShell
|
|
12
|
+
// (primitives/index.tsx) reuses this instead of keeping its own copy —
|
|
13
|
+
// same width tokens must render the same class everywhere (fw#2640).
|
|
14
|
+
export const screenWidthClassName: Record<ScreenWidth, string> = {
|
|
15
|
+
"3xl": "max-w-3xl mx-auto",
|
|
16
|
+
"4xl": "max-w-4xl mx-auto",
|
|
17
|
+
full: "max-w-full",
|
|
18
|
+
};
|
|
19
|
+
|
|
8
20
|
const STACK_GAP = { sm: "gap-2", md: "gap-4", lg: "gap-6" } as const;
|
|
9
21
|
|
|
10
22
|
type StackGap = keyof typeof STACK_GAP;
|
|
@@ -28,11 +40,17 @@ type PageSectionProps = {
|
|
|
28
40
|
readonly className?: string;
|
|
29
41
|
readonly children?: ReactNode;
|
|
30
42
|
readonly testId?: string;
|
|
43
|
+
readonly maxWidth?: ScreenWidth;
|
|
31
44
|
};
|
|
32
45
|
|
|
33
|
-
export function PageSection({
|
|
46
|
+
export function PageSection({
|
|
47
|
+
className,
|
|
48
|
+
children,
|
|
49
|
+
testId,
|
|
50
|
+
maxWidth = "full",
|
|
51
|
+
}: PageSectionProps): ReactNode {
|
|
34
52
|
return (
|
|
35
|
-
<div data-testid={testId} className={cn("p-6", className)}>
|
|
53
|
+
<div data-testid={testId} className={cn("p-6", screenWidthClassName[maxWidth], className)}>
|
|
36
54
|
{children}
|
|
37
55
|
</div>
|
|
38
56
|
);
|