@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.236.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.236.0",
20
- "@cosmicdrift/kumiko-headless": "0.236.0",
21
- "@cosmicdrift/kumiko-renderer": "0.236.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.236.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
  });
@@ -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: findOwnerFeature(app, fallbackQn),
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
- }, [onRowClick, app.features, nav]);
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
- <div className="flex flex-col gap-4 p-6" data-testid={`dashboard-${screen.id}`}>
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
- </div>
456
+ </PageSection>
456
457
  );
457
458
  }
@@ -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 matches
2164
- // list chrome (full); override via maxWidth / layout.width when a screen
2165
- // truly needs a narrow column (sm=auth-adjacent, 3xl/4xl=centered detail).
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", formScreenShellWidth[maxWidth], className)}
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-Primitives für Custom-Screens: ein Ort für vertikale Abstände und
2
- // Screen-Padding, damit Consumer nicht `flex flex-col gap-*` / `p-6` per Hand
3
- // streuen. Bewusst dünn kein generisches Box-mit-20-props-System.
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 thinnot 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({ className, children, testId }: PageSectionProps): ReactNode {
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
  );