@cosmicdrift/kumiko-renderer-web 0.205.0 → 0.207.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.207.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.207.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.207.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.207.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",
|
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
EntityDefinition,
|
|
5
5
|
EntityEditScreenDefinition,
|
|
6
6
|
EntityListScreenDefinition,
|
|
7
|
+
ScreenDefinition,
|
|
7
8
|
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
8
9
|
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
9
10
|
import type {
|
|
@@ -14,7 +15,7 @@ import type {
|
|
|
14
15
|
NavApi,
|
|
15
16
|
} from "@cosmicdrift/kumiko-renderer";
|
|
16
17
|
import { createStaticLocaleResolver, useContentEditor } from "@cosmicdrift/kumiko-renderer";
|
|
17
|
-
import { act, screen, waitFor } from "@testing-library/react";
|
|
18
|
+
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
|
|
18
19
|
import type { ReactNode } from "react";
|
|
19
20
|
import type { ClientFeatureDefinition } from "../app/client-plugin";
|
|
20
21
|
import { type CreateKumikoAppOptions, createKumikoApp } from "../app/create-app";
|
|
@@ -594,4 +595,92 @@ describe("createKumikoApp", () => {
|
|
|
594
595
|
// Und definitiv NICHT der Edit-Screen (der wäre die Default-Landing).
|
|
595
596
|
expect(screen.queryByTestId("render-edit-form")).toBeNull();
|
|
596
597
|
});
|
|
598
|
+
|
|
599
|
+
// fw#2164: default row-click target. A screen declaring `detailFor` for the
|
|
600
|
+
// entity is the "Akte" — the click should land there without anyone
|
|
601
|
+
// wiring rowActions/onRowClick, and it should win over both of the old
|
|
602
|
+
// defaults (entityEdit-search, app-wide onRowClick).
|
|
603
|
+
describe("default row click (fw#2164)", () => {
|
|
604
|
+
const taskDetailScreen: ScreenDefinition = {
|
|
605
|
+
id: "task-detail",
|
|
606
|
+
type: "custom",
|
|
607
|
+
detailFor: "task",
|
|
608
|
+
renderer: { react: { __component: "TaskDetail" } },
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
function TaskDetailStub(): ReactNode {
|
|
612
|
+
return <span data-testid="task-detail-mounted" />;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function makeRowDispatcher(): Dispatcher {
|
|
616
|
+
return createMockDispatcher({
|
|
617
|
+
query: (async () => ({
|
|
618
|
+
isSuccess: true,
|
|
619
|
+
data: { rows: [{ id: "r1", title: "Alpha" }], nextCursor: null },
|
|
620
|
+
})) as unknown as Dispatcher["query"],
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
test("declared detailFor screen wins over the entityEdit-search fallback", async () => {
|
|
625
|
+
window.history.replaceState(null, "", "/task-list");
|
|
626
|
+
mountRoot();
|
|
627
|
+
const schema: FeatureSchema = {
|
|
628
|
+
featureName: "tasks",
|
|
629
|
+
entities: { task: taskEntity },
|
|
630
|
+
screens: [editScreen, listScreen, taskDetailScreen],
|
|
631
|
+
};
|
|
632
|
+
await mountApp({
|
|
633
|
+
schema,
|
|
634
|
+
dispatcher: makeRowDispatcher(),
|
|
635
|
+
screenQn: "tasks:screen:task-list",
|
|
636
|
+
clientFeatures: [{ name: "tasks", components: { "task-detail": TaskDetailStub } }],
|
|
637
|
+
});
|
|
638
|
+
await waitFor(() => expect(screen.getByTestId("row-r1")).toBeTruthy());
|
|
639
|
+
|
|
640
|
+
fireEvent.click(screen.getByTestId("row-r1"));
|
|
641
|
+
|
|
642
|
+
await waitFor(() => expect(window.location.pathname).toBe("/task-detail/r1"));
|
|
643
|
+
expect(await screen.findByTestId("task-detail-mounted")).toBeTruthy();
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
test("no detailFor screen → unchanged fallback to the entity's entityEdit screen", async () => {
|
|
647
|
+
window.history.replaceState(null, "", "/task-list");
|
|
648
|
+
mountRoot();
|
|
649
|
+
await mountApp({
|
|
650
|
+
schema: baseSchema,
|
|
651
|
+
dispatcher: makeRowDispatcher(),
|
|
652
|
+
screenQn: "tasks:screen:task-list",
|
|
653
|
+
});
|
|
654
|
+
await waitFor(() => expect(screen.getByTestId("row-r1")).toBeTruthy());
|
|
655
|
+
|
|
656
|
+
fireEvent.click(screen.getByTestId("row-r1"));
|
|
657
|
+
|
|
658
|
+
await waitFor(() => expect(window.location.pathname).toBe("/task-edit/r1"));
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
test("declared detailFor screen wins even over an app-wide onRowClick option", async () => {
|
|
662
|
+
window.history.replaceState(null, "", "/task-list");
|
|
663
|
+
mountRoot();
|
|
664
|
+
const schema: FeatureSchema = {
|
|
665
|
+
featureName: "tasks",
|
|
666
|
+
entities: { task: taskEntity },
|
|
667
|
+
screens: [listScreen, taskDetailScreen],
|
|
668
|
+
};
|
|
669
|
+
const onRowClick = () => {
|
|
670
|
+
throw new Error("onRowClick must not fire when a detailFor screen resolves");
|
|
671
|
+
};
|
|
672
|
+
await mountApp({
|
|
673
|
+
schema,
|
|
674
|
+
dispatcher: makeRowDispatcher(),
|
|
675
|
+
screenQn: "tasks:screen:task-list",
|
|
676
|
+
onRowClick,
|
|
677
|
+
clientFeatures: [{ name: "tasks", components: { "task-detail": TaskDetailStub } }],
|
|
678
|
+
});
|
|
679
|
+
await waitFor(() => expect(screen.getByTestId("row-r1")).toBeTruthy());
|
|
680
|
+
|
|
681
|
+
fireEvent.click(screen.getByTestId("row-r1"));
|
|
682
|
+
|
|
683
|
+
await waitFor(() => expect(window.location.pathname).toBe("/task-detail/r1"));
|
|
684
|
+
});
|
|
685
|
+
});
|
|
597
686
|
});
|
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import { describe, expect, mock, test } from "bun:test";
|
|
2
2
|
import type { LocaleResolver } from "@cosmicdrift/kumiko-headless";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
createStaticLocaleResolver,
|
|
5
|
+
kumikoDefaultTranslations,
|
|
6
|
+
LocaleProvider,
|
|
7
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
4
8
|
import { render as _render, screen, waitFor } from "@testing-library/react";
|
|
5
9
|
import userEvent from "@testing-library/user-event";
|
|
6
10
|
import type { ReactNode } from "react";
|
|
7
11
|
import { LanguageSwitcher } from "../layout/language-switcher";
|
|
8
12
|
|
|
9
|
-
// Tests
|
|
10
|
-
// (setLocale + subscribe)
|
|
11
|
-
//
|
|
12
|
-
// Radix
|
|
13
|
+
// Tests exercise the LanguageSwitcher with both a stateful stub resolver
|
|
14
|
+
// (setLocale + subscribe) and a stateless resolver, covering the two
|
|
15
|
+
// branches: the switcher only renders when setLocale is present.
|
|
16
|
+
// Radix DropdownMenu opens on pointerdown, so userEvent is used instead of
|
|
13
17
|
// fireEvent.click.
|
|
14
18
|
|
|
15
19
|
function makeStatefulResolver(initial: string): LocaleResolver {
|
|
@@ -33,7 +37,11 @@ function makeStatefulResolver(initial: string): LocaleResolver {
|
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
function renderWithResolver(resolver: LocaleResolver, ui: ReactNode) {
|
|
36
|
-
return _render(
|
|
40
|
+
return _render(
|
|
41
|
+
<LocaleProvider resolver={resolver} fallbackBundles={[kumikoDefaultTranslations]}>
|
|
42
|
+
{ui}
|
|
43
|
+
</LocaleProvider>,
|
|
44
|
+
);
|
|
37
45
|
}
|
|
38
46
|
|
|
39
47
|
const locales = [
|
|
@@ -54,9 +62,9 @@ describe("LanguageSwitcher", () => {
|
|
|
54
62
|
test("active locale shown via shorthand", () => {
|
|
55
63
|
const resolver = makeStatefulResolver("de");
|
|
56
64
|
renderWithResolver(resolver, <LanguageSwitcher locales={locales} testId="lang" />);
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
// getByText
|
|
65
|
+
// The trigger shows the locale code in the DOM. Tailwind only
|
|
66
|
+
// uppercases it visually via CSS, the text node stays lowercase.
|
|
67
|
+
// That is fine, getByText sees the DOM text.
|
|
60
68
|
expect(screen.getByText("de")).toBeTruthy();
|
|
61
69
|
});
|
|
62
70
|
|
|
@@ -78,18 +86,23 @@ describe("LanguageSwitcher", () => {
|
|
|
78
86
|
expect(resolver.setLocale).toHaveBeenCalledWith("en");
|
|
79
87
|
});
|
|
80
88
|
|
|
89
|
+
test("trigger accessible name follows the active locale (en → Language)", () => {
|
|
90
|
+
const resolver = makeStatefulResolver("en");
|
|
91
|
+
renderWithResolver(resolver, <LanguageSwitcher locales={locales} testId="lang" />);
|
|
92
|
+
expect(screen.getByRole("button", { name: "Language" })).toBeTruthy();
|
|
93
|
+
});
|
|
94
|
+
|
|
81
95
|
test("matches active locale via language-root (de-AT → de)", async () => {
|
|
82
96
|
const user = userEvent.setup();
|
|
83
97
|
const resolver = makeStatefulResolver("de-AT");
|
|
84
98
|
renderWithResolver(resolver, <LanguageSwitcher locales={locales} testId="lang" />);
|
|
85
|
-
//
|
|
86
|
-
//
|
|
99
|
+
// The trigger shows "DE", derived from de-AT. The active marker in the
|
|
100
|
+
// dropdown must sit on "Deutsch", not "English".
|
|
87
101
|
await user.click(screen.getByRole("button", { name: "Sprache" }));
|
|
88
102
|
await waitFor(() => {
|
|
89
|
-
// Radix
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
// Tricks.
|
|
103
|
+
// Radix CheckboxItem marks active via aria-checked="true". The check
|
|
104
|
+
// icon (lucide) lives in the ItemIndicator and is only visible when
|
|
105
|
+
// checked, so the ARIA variant is robust against rendering quirks.
|
|
93
106
|
const deItem = screen.getByText("Deutsch").closest('[role="menuitemcheckbox"]');
|
|
94
107
|
expect(deItem?.getAttribute("aria-checked")).toBe("true");
|
|
95
108
|
const enItem = screen.getByText("English").closest('[role="menuitemcheckbox"]');
|
package/src/app/create-app.tsx
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
DraftStorageProvider,
|
|
20
20
|
ExtensionSectionsProvider,
|
|
21
21
|
type FeatureSchema,
|
|
22
|
+
hasDetailScreen,
|
|
22
23
|
KumikoScreen,
|
|
23
24
|
kumikoDefaultTranslations,
|
|
24
25
|
LiveEventsProvider,
|
|
@@ -143,6 +144,8 @@ export type CreateKumikoAppOptions = {
|
|
|
143
144
|
readonly draftStorage?: DraftStorage;
|
|
144
145
|
readonly screenQn?: string;
|
|
145
146
|
readonly translate?: Translate;
|
|
147
|
+
/** Row-click fallback for entities with no declared `detailFor` screen
|
|
148
|
+
* (fw#2164) — a `detailFor` screen always wins over this option. */
|
|
146
149
|
readonly onRowClick?: (row: ListRowViewModel, entityName: string) => void;
|
|
147
150
|
/** App-Shell. Bekommt das resolved `schema` als Prop — so können
|
|
148
151
|
* AppShell-Komponenten an WorkspaceShell/DefaultAppShell durchreichen
|
|
@@ -568,19 +571,31 @@ function RoutedScreen({
|
|
|
568
571
|
const effectiveOnRowClick = useMemo<
|
|
569
572
|
((row: ListRowViewModel, entityName: string) => void) | undefined
|
|
570
573
|
>(() => {
|
|
571
|
-
if (onRowClick !== undefined) return onRowClick;
|
|
572
574
|
return (row, entityName) => {
|
|
573
|
-
//
|
|
574
|
-
//
|
|
575
|
-
//
|
|
576
|
-
//
|
|
575
|
+
// Precedence (fw#2164): detailFor screen, then onRowClick, then the
|
|
576
|
+
// entityEdit-search fallback below. An explicit rowActions
|
|
577
|
+
// rowClick:true already wins over all of this — kumiko-screen.tsx
|
|
578
|
+
// handles it before onRowClick is ever called.
|
|
579
|
+
if (hasDetailScreen(app.features, entityName)) {
|
|
580
|
+
nav.navigate({ entity: entityName, id: row.id });
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
if (onRowClick !== undefined) {
|
|
584
|
+
onRowClick(row, entityName);
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
// No declared detail screen and no app-wide onRowClick — search for
|
|
588
|
+
// the edit screen for the entity across all features — in a
|
|
589
|
+
// single-feature setup that's the same feature as the active one, in
|
|
590
|
+
// multi-feature the edit could theoretically live in a different
|
|
591
|
+
// feature (one that shares the entity).
|
|
577
592
|
for (const f of app.features) {
|
|
578
593
|
const editScreen = f.screens.find(
|
|
579
594
|
(s) => s.type === "entityEdit" && s.entity === entityName,
|
|
580
595
|
);
|
|
581
596
|
if (editScreen) {
|
|
582
|
-
// editScreen.id is
|
|
583
|
-
//
|
|
597
|
+
// editScreen.id is already short form; lastSegment is a no-op
|
|
598
|
+
// safety net here, kept for symmetry with the other call sites.
|
|
584
599
|
nav.navigate({ screenId: lastSegment(editScreen.id), entityId: row.id });
|
|
585
600
|
return;
|
|
586
601
|
}
|
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
// LanguageSwitcher —
|
|
2
|
-
// LocaleResolver.setLocale
|
|
3
|
-
//
|
|
1
|
+
// LanguageSwitcher — dropdown that switches the app locale via
|
|
2
|
+
// LocaleResolver.setLocale. Built on Radix DropdownMenu, same stack
|
|
3
|
+
// as UserMenu/TenantSwitcher.
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
// (
|
|
7
|
-
// stateful
|
|
8
|
-
//
|
|
5
|
+
// Renders nothing if the resolver doesn't offer a setLocale method
|
|
6
|
+
// (static resolver) — the app dev then immediately sees they need to
|
|
7
|
+
// wire up a stateful resolver before the switcher becomes visible in
|
|
8
|
+
// the UI.
|
|
9
9
|
//
|
|
10
|
-
// Icon
|
|
11
|
-
//
|
|
12
|
-
//
|
|
10
|
+
// Icon slot is optional: the framework doesn't pull in lucide-react
|
|
11
|
+
// itself; an app that doesn't want an icon import gets the globe
|
|
12
|
+
// unicode glyph (🌐) as default.
|
|
13
13
|
|
|
14
|
-
import { useLocale } from "@cosmicdrift/kumiko-renderer";
|
|
14
|
+
import { useLocale, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
15
15
|
import { type ReactNode, useMemo } from "react";
|
|
16
16
|
import { cn } from "../lib/cn";
|
|
17
17
|
import {
|
|
@@ -22,19 +22,19 @@ import {
|
|
|
22
22
|
} from "../primitives/dropdown-menu";
|
|
23
23
|
|
|
24
24
|
export type LocaleOption = {
|
|
25
|
-
/** BCP-47
|
|
26
|
-
* resolver.setLocale()
|
|
25
|
+
/** BCP-47 code, e.g. "de", "en-US", "fr-CA". Passed through 1:1 to
|
|
26
|
+
* resolver.setLocale(). */
|
|
27
27
|
readonly code: string;
|
|
28
|
-
/**
|
|
28
|
+
/** Human-readable label shown in the dropdown. */
|
|
29
29
|
readonly label: string;
|
|
30
30
|
};
|
|
31
31
|
|
|
32
32
|
export type LanguageSwitcherProps = {
|
|
33
|
-
/**
|
|
33
|
+
/** Selectable locales. Order = display order in the menu. */
|
|
34
34
|
readonly locales: readonly LocaleOption[];
|
|
35
|
-
/** Icon
|
|
35
|
+
/** Icon slot left of the button label. Default: 🌐. */
|
|
36
36
|
readonly icon?: ReactNode;
|
|
37
|
-
/** aria-label + title
|
|
37
|
+
/** aria-label + title of the trigger. Default: translated "kumiko.nav.language". */
|
|
38
38
|
readonly label?: string;
|
|
39
39
|
readonly testId?: string;
|
|
40
40
|
};
|
|
@@ -42,15 +42,17 @@ export type LanguageSwitcherProps = {
|
|
|
42
42
|
export function LanguageSwitcher({
|
|
43
43
|
locales,
|
|
44
44
|
icon = "🌐",
|
|
45
|
-
label
|
|
45
|
+
label,
|
|
46
46
|
testId,
|
|
47
47
|
}: LanguageSwitcherProps): ReactNode {
|
|
48
48
|
const resolver = useLocale();
|
|
49
|
+
const t = useTranslation();
|
|
50
|
+
const resolvedLabel = label ?? t("kumiko.nav.language");
|
|
49
51
|
|
|
50
52
|
const activeLocale = resolver.locale();
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
53
|
+
// Matches either exact ("de-DE") or the language root ("de") against
|
|
54
|
+
// the available options. So the switcher shows "German" active when
|
|
55
|
+
// the browser reports "de-AT" but the option is just "de".
|
|
54
56
|
const activeOption = useMemo(() => {
|
|
55
57
|
const exact = locales.find((o) => o.code === activeLocale);
|
|
56
58
|
if (exact) return exact;
|
|
@@ -59,7 +61,7 @@ export function LanguageSwitcher({
|
|
|
59
61
|
}, [locales, activeLocale]);
|
|
60
62
|
|
|
61
63
|
if (resolver.setLocale === undefined) {
|
|
62
|
-
// Stateless
|
|
64
|
+
// Stateless resolver → no switching possible. No noise in the topbar.
|
|
63
65
|
return null;
|
|
64
66
|
}
|
|
65
67
|
|
|
@@ -70,8 +72,8 @@ export function LanguageSwitcher({
|
|
|
70
72
|
<DropdownMenuTrigger asChild>
|
|
71
73
|
<button
|
|
72
74
|
type="button"
|
|
73
|
-
aria-label={
|
|
74
|
-
title={
|
|
75
|
+
aria-label={resolvedLabel}
|
|
76
|
+
title={resolvedLabel}
|
|
75
77
|
data-testid={testId}
|
|
76
78
|
className={cn(
|
|
77
79
|
"inline-flex h-8 items-center gap-1.5 rounded-md border bg-background px-2 text-sm",
|
|
@@ -85,7 +87,7 @@ export function LanguageSwitcher({
|
|
|
85
87
|
</span>
|
|
86
88
|
</button>
|
|
87
89
|
</DropdownMenuTrigger>
|
|
88
|
-
<DropdownMenuContent align="end" className="min-w-[10rem]" aria-label={
|
|
90
|
+
<DropdownMenuContent align="end" className="min-w-[10rem]" aria-label={resolvedLabel}>
|
|
89
91
|
{locales.map((opt) => (
|
|
90
92
|
<DropdownMenuCheckboxItem
|
|
91
93
|
key={opt.code}
|