@cosmicdrift/kumiko-renderer-web 0.233.0 → 0.235.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__/app-layout.test.tsx +13 -12
- package/src/__tests__/default-app-shell.test.tsx +33 -0
- package/src/__tests__/form-action-bar.test.tsx +1 -1
- package/src/__tests__/kumiko-screen.test.tsx +293 -3
- package/src/__tests__/primitives.test.tsx +182 -3
- package/src/__tests__/projection-detail-record-layout.test.tsx +119 -0
- package/src/__tests__/render-edit.test.tsx +9 -10
- package/src/__tests__/secrets-edit.test.tsx +287 -0
- package/src/__tests__/workspace-shell.test.tsx +5 -4
- package/src/icons.tsx +84 -1
- package/src/layout/app-layout.tsx +7 -7
- package/src/layout/default-app-shell.tsx +3 -2
- package/src/layout/workspace-shell.tsx +3 -2
- package/src/primitives/__tests__/button.test.tsx +37 -0
- package/src/primitives/__tests__/select-segmented.test.tsx +84 -0
- package/src/primitives/index.tsx +210 -17
- package/src/primitives/metric.tsx +18 -10
- package/src/styles.css +21 -17
- package/src/ui/switch.tsx +35 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.235.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.235.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.235.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.235.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.235.0"
|
|
68
68
|
},
|
|
69
69
|
"repository": {
|
|
70
70
|
"type": "git",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
// AppLayout: pinnt den fill-Vertrag — Default ist
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// AppLayout: pinnt den fill-Vertrag — Default ist die viewport-hohe
|
|
2
|
+
// Shell (h-screen, main scrollt innen via min-h-0), fill={false} ist der
|
|
3
|
+
// Escape-Hatch auf den klassischen min-h-screen-Seitenflow.
|
|
4
|
+
// Plus die className/mainClassName-Erweiterungspunkte.
|
|
4
5
|
|
|
5
6
|
import { describe, expect, test } from "bun:test";
|
|
6
7
|
import { AppLayout } from "../layout/app-layout";
|
|
@@ -13,23 +14,23 @@ function root(container: HTMLElement): HTMLElement {
|
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
describe("AppLayout fill", () => {
|
|
16
|
-
test("Default → min-h-
|
|
17
|
+
test("Default → h-screen + main min-h-0 (Innen-Scroll), fill-Marker gesetzt", () => {
|
|
17
18
|
const { container } = render(<AppLayout>x</AppLayout>);
|
|
18
19
|
const el = root(container);
|
|
19
|
-
expect(el.className).toContain("min-h-screen");
|
|
20
|
-
expect(el.getAttribute("data-kumiko-fill")).toBeNull();
|
|
21
|
-
expect((container.querySelector("main") as HTMLElement).className).not.toContain("min-h-0");
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
test("fill → h-screen + main min-h-0 (Innen-Scroll), fill-Marker gesetzt", () => {
|
|
25
|
-
const { container } = render(<AppLayout fill>x</AppLayout>);
|
|
26
|
-
const el = root(container);
|
|
27
20
|
expect(el.className).not.toContain("min-h-screen");
|
|
28
21
|
expect(el.className).toContain("h-screen");
|
|
29
22
|
expect(el.getAttribute("data-kumiko-fill")).toBe("true");
|
|
30
23
|
expect((container.querySelector("main") as HTMLElement).className).toContain("min-h-0");
|
|
31
24
|
});
|
|
32
25
|
|
|
26
|
+
test("fill={false} → min-h-screen (Seiten-Scroll), kein fill-Marker (Escape-Hatch)", () => {
|
|
27
|
+
const { container } = render(<AppLayout fill={false}>x</AppLayout>);
|
|
28
|
+
const el = root(container);
|
|
29
|
+
expect(el.className).toContain("min-h-screen");
|
|
30
|
+
expect(el.getAttribute("data-kumiko-fill")).toBeNull();
|
|
31
|
+
expect((container.querySelector("main") as HTMLElement).className).not.toContain("min-h-0");
|
|
32
|
+
});
|
|
33
|
+
|
|
33
34
|
test("className/mainClassName werden an die Defaults angehängt", () => {
|
|
34
35
|
const { container } = render(
|
|
35
36
|
<AppLayout className="bg-gradient-test" mainClassName="main-test">
|
|
@@ -130,3 +130,36 @@ describe("DefaultAppShell wires user.roles into children for screen-level access
|
|
|
130
130
|
expect(screen.queryByTestId("kumiko-screen-access-denied")).toBeNull();
|
|
131
131
|
});
|
|
132
132
|
});
|
|
133
|
+
|
|
134
|
+
describe("DefaultAppShell fill default", () => {
|
|
135
|
+
test("fill defaults to true: applies h-svh on root and min-h-0 on inset/main", () => {
|
|
136
|
+
render(
|
|
137
|
+
<DefaultAppShell brand={<span>Brand</span>} schema={makeSchema()}>
|
|
138
|
+
<div>content</div>
|
|
139
|
+
</DefaultAppShell>,
|
|
140
|
+
);
|
|
141
|
+
const root = document.querySelector('[data-slot="sidebar-wrapper"]');
|
|
142
|
+
expect(root?.classList.contains("h-svh")).toBe(true);
|
|
143
|
+
const inset = document.querySelector('[data-slot="sidebar-inset"]');
|
|
144
|
+
expect(inset?.classList.contains("min-h-0")).toBe(true);
|
|
145
|
+
const mains = screen.getAllByRole("main");
|
|
146
|
+
const innerMain = mains.find((m) => m !== inset);
|
|
147
|
+
expect(innerMain?.classList.contains("min-h-0")).toBe(true);
|
|
148
|
+
expect(innerMain?.classList.contains("flex-1")).toBe(true);
|
|
149
|
+
expect(innerMain?.classList.contains("overflow-auto")).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// fill={false} is the escape hatch back to page-scroll: must not apply
|
|
153
|
+
// viewport-lock classes.
|
|
154
|
+
test("fill={false} does not apply h-svh or min-h-0", () => {
|
|
155
|
+
render(
|
|
156
|
+
<DefaultAppShell brand={<span>Brand</span>} schema={makeSchema()} fill={false}>
|
|
157
|
+
<div>content</div>
|
|
158
|
+
</DefaultAppShell>,
|
|
159
|
+
);
|
|
160
|
+
const root = document.querySelector('[data-slot="sidebar-wrapper"]');
|
|
161
|
+
expect(root?.classList.contains("h-svh")).toBe(false);
|
|
162
|
+
const inset = document.querySelector('[data-slot="sidebar-inset"]');
|
|
163
|
+
expect(inset?.classList.contains("min-h-0")).toBe(false);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -29,7 +29,7 @@ describe("DefaultForm Action-Footer", () => {
|
|
|
29
29
|
expect(footer.className).toContain("sm:justify-between");
|
|
30
30
|
expect(footer.className).toContain("border-t");
|
|
31
31
|
// Footer sits in the form shell (buttons align with the field edge).
|
|
32
|
-
expect(actions.closest(".max-w-
|
|
32
|
+
expect(actions.closest(".max-w-4xl")).toBeTruthy();
|
|
33
33
|
// Title is its own heading above — not inside the action bar.
|
|
34
34
|
expect(actions.textContent).not.toContain("Titel");
|
|
35
35
|
expect(screen.getByTestId("f-title").textContent).toBe("Titel");
|
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
EntityDefinition,
|
|
5
5
|
EntityEditScreenDefinition,
|
|
6
6
|
EntityListScreenDefinition,
|
|
7
|
+
ProjectionListScreenDefinition,
|
|
7
8
|
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
8
9
|
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
9
10
|
import type { FeatureSchema, NavApi, NavTarget } from "@cosmicdrift/kumiko-renderer";
|
|
@@ -438,9 +439,9 @@ describe("KumikoScreen", () => {
|
|
|
438
439
|
// Issue #912 — Copy-Link-Action. KumikoScreen threads an already-bound
|
|
439
440
|
// onCopyLink callback down to RenderEdit (the actual URL-build/clipboard
|
|
440
441
|
// impl lives in renderer-web's RoutedScreen, outside this test — here we
|
|
441
|
-
// only verify the wiring: button renders in update-mode, fires
|
|
442
|
-
// callback, and
|
|
443
|
-
test("entityEdit update-mode: Copy-Link-Button feuert onCopyLink + zeigt 'copied'-
|
|
442
|
+
// only verify the wiring: button renders icon-only in update-mode, fires
|
|
443
|
+
// the callback, and swaps its accessible name to "copied" afterwards.
|
|
444
|
+
test("entityEdit update-mode: Copy-Link-Button feuert onCopyLink + zeigt 'copied'-Accessible-Name", async () => {
|
|
444
445
|
const user = userEvent.setup();
|
|
445
446
|
const onCopyLink = mock(() => Promise.resolve());
|
|
446
447
|
const dispatcher = makeDispatcher({
|
|
@@ -462,7 +463,9 @@ describe("KumikoScreen", () => {
|
|
|
462
463
|
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
|
|
463
464
|
|
|
464
465
|
const button = screen.getByTestId("render-edit-copy-link");
|
|
466
|
+
// Icon + visible label; accessible name comes from the label text itself.
|
|
465
467
|
expect(button.textContent).toBe("Copy link");
|
|
468
|
+
expect(button.querySelector("svg")).toBeTruthy();
|
|
466
469
|
await user.click(button);
|
|
467
470
|
expect(onCopyLink).toHaveBeenCalledTimes(1);
|
|
468
471
|
await waitFor(() => expect(button.textContent).toBe("Copied!"));
|
|
@@ -2449,3 +2452,290 @@ describe("KumikoScreen: actionForm extension-section", () => {
|
|
|
2449
2452
|
expect(screen.getByTestId("update-timeline").textContent).toBe("inc-7");
|
|
2450
2453
|
});
|
|
2451
2454
|
});
|
|
2455
|
+
|
|
2456
|
+
// --- entityEdit header actions (fw entityEdit-actions) ---
|
|
2457
|
+
// EntityEditScreenDefinition.actions mirrors ProjectionDetailScreenDefinition.
|
|
2458
|
+
// actions — same RenderEdit `actions` prop, same icon-only collapse rule
|
|
2459
|
+
// (shouldRenderActionsIconOnly: >2 actions, every one resolves an icon).
|
|
2460
|
+
describe("KumikoScreen: entityEdit header actions", () => {
|
|
2461
|
+
const threeActionsScreen: EntityEditScreenDefinition = {
|
|
2462
|
+
id: "task-edit-actions-3",
|
|
2463
|
+
type: "entityEdit",
|
|
2464
|
+
entity: "task",
|
|
2465
|
+
layout: { sections: [{ title: "Basics", fields: ["title"] }] },
|
|
2466
|
+
// ids match ACTION_ICON_BY_ID entries (publish/archive/duplicate) so
|
|
2467
|
+
// every action resolves an icon without an explicit `icon` field.
|
|
2468
|
+
actions: [
|
|
2469
|
+
{ id: "publish", label: "Publish", handler: "tasks:write:task:publish" },
|
|
2470
|
+
{ id: "archive", label: "Archive", handler: "tasks:write:task:archive" },
|
|
2471
|
+
{ id: "duplicate", label: "Duplicate", handler: "tasks:write:task:duplicate" },
|
|
2472
|
+
],
|
|
2473
|
+
};
|
|
2474
|
+
const twoActionsScreen: EntityEditScreenDefinition = {
|
|
2475
|
+
...threeActionsScreen,
|
|
2476
|
+
id: "task-edit-actions-2",
|
|
2477
|
+
actions: threeActionsScreen.actions?.slice(0, 2),
|
|
2478
|
+
};
|
|
2479
|
+
const actionsSchema: FeatureSchema = {
|
|
2480
|
+
featureName: "tasks",
|
|
2481
|
+
entities: { task: taskEntity },
|
|
2482
|
+
screens: [threeActionsScreen, twoActionsScreen],
|
|
2483
|
+
};
|
|
2484
|
+
const detailDispatcher = () =>
|
|
2485
|
+
makeDispatcher({
|
|
2486
|
+
query: (async () => ({
|
|
2487
|
+
isSuccess: true,
|
|
2488
|
+
data: { id: "task-1", version: 1, title: "loaded", count: 0, done: false },
|
|
2489
|
+
})) as unknown as Dispatcher["query"],
|
|
2490
|
+
});
|
|
2491
|
+
|
|
2492
|
+
test("update-mode mit drei Header-Actions kollabiert auf Icon-only mit ariaLabel", async () => {
|
|
2493
|
+
render(
|
|
2494
|
+
<DispatcherProvider dispatcher={detailDispatcher()}>
|
|
2495
|
+
<KumikoScreen
|
|
2496
|
+
schema={actionsSchema}
|
|
2497
|
+
qn="tasks:screen:task-edit-actions-3"
|
|
2498
|
+
entityId="task-1"
|
|
2499
|
+
/>
|
|
2500
|
+
</DispatcherProvider>,
|
|
2501
|
+
);
|
|
2502
|
+
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
|
|
2503
|
+
|
|
2504
|
+
const cases = [
|
|
2505
|
+
["publish", "Publish"],
|
|
2506
|
+
["archive", "Archive"],
|
|
2507
|
+
["duplicate", "Duplicate"],
|
|
2508
|
+
] as const;
|
|
2509
|
+
for (const [id, label] of cases) {
|
|
2510
|
+
const button = screen.getByTestId(`render-edit-action-${id}`);
|
|
2511
|
+
expect(button.textContent).toBe("");
|
|
2512
|
+
expect(button.getAttribute("aria-label")).toBe(label);
|
|
2513
|
+
}
|
|
2514
|
+
});
|
|
2515
|
+
|
|
2516
|
+
test("update-mode mit zwei Header-Actions zeigt weiterhin sichtbaren Text", async () => {
|
|
2517
|
+
render(
|
|
2518
|
+
<DispatcherProvider dispatcher={detailDispatcher()}>
|
|
2519
|
+
<KumikoScreen
|
|
2520
|
+
schema={actionsSchema}
|
|
2521
|
+
qn="tasks:screen:task-edit-actions-2"
|
|
2522
|
+
entityId="task-1"
|
|
2523
|
+
/>
|
|
2524
|
+
</DispatcherProvider>,
|
|
2525
|
+
);
|
|
2526
|
+
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
|
|
2527
|
+
|
|
2528
|
+
expect(screen.getByTestId("render-edit-action-publish").textContent).toBe("Publish");
|
|
2529
|
+
expect(screen.getByTestId("render-edit-action-archive").textContent).toBe("Archive");
|
|
2530
|
+
});
|
|
2531
|
+
|
|
2532
|
+
test("update-mode mit drei Header-Actions kollabiert auch den Copy-Link-Button auf Icon-only mit ariaLabel", async () => {
|
|
2533
|
+
render(
|
|
2534
|
+
<DispatcherProvider dispatcher={detailDispatcher()}>
|
|
2535
|
+
<KumikoScreen
|
|
2536
|
+
schema={actionsSchema}
|
|
2537
|
+
qn="tasks:screen:task-edit-actions-3"
|
|
2538
|
+
entityId="task-1"
|
|
2539
|
+
onCopyLink={() => Promise.resolve()}
|
|
2540
|
+
/>
|
|
2541
|
+
</DispatcherProvider>,
|
|
2542
|
+
);
|
|
2543
|
+
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
|
|
2544
|
+
|
|
2545
|
+
const button = screen.getByTestId("render-edit-copy-link");
|
|
2546
|
+
expect(button.textContent).toBe("");
|
|
2547
|
+
expect(button.getAttribute("aria-label")).toBe("Copy link");
|
|
2548
|
+
});
|
|
2549
|
+
|
|
2550
|
+
// Design decision (fw entityEdit-actions): actions target an EXISTING
|
|
2551
|
+
// record (publish/archive/duplicate and friends) — create mode has none
|
|
2552
|
+
// yet, so EntityEditCreateBody never builds a headerActions prop at all.
|
|
2553
|
+
test("create-mode (kein entityId) rendert keine Header-Actions", () => {
|
|
2554
|
+
render(
|
|
2555
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
2556
|
+
<KumikoScreen schema={actionsSchema} qn="tasks:screen:task-edit-actions-3" />
|
|
2557
|
+
</DispatcherProvider>,
|
|
2558
|
+
);
|
|
2559
|
+
expect(screen.getByTestId("render-edit-form")).toBeTruthy();
|
|
2560
|
+
expect(screen.queryByTestId("render-edit-action-publish")).toBeNull();
|
|
2561
|
+
expect(screen.queryByTestId("render-edit-action-archive")).toBeNull();
|
|
2562
|
+
expect(screen.queryByTestId("render-edit-action-duplicate")).toBeNull();
|
|
2563
|
+
});
|
|
2564
|
+
});
|
|
2565
|
+
|
|
2566
|
+
// --- entityList status cells + row actions (fw#2579, fw#2580) ---
|
|
2567
|
+
// Both are leftovers from #2575: the tone heuristic and the icon-only
|
|
2568
|
+
// collapse existed, but neither reached a list cell. These render the real
|
|
2569
|
+
// entityList pipeline (KumikoScreen -> EntityListBody -> RenderList ->
|
|
2570
|
+
// DataTable) against the real web primitives.
|
|
2571
|
+
describe("KumikoScreen: entityList cell rendering", () => {
|
|
2572
|
+
const orderEntity = {
|
|
2573
|
+
fields: {
|
|
2574
|
+
title: { type: "text", required: true },
|
|
2575
|
+
status: { type: "select", options: ["active", "unmapped-value", "constructor"] },
|
|
2576
|
+
},
|
|
2577
|
+
} as unknown as EntityDefinition;
|
|
2578
|
+
|
|
2579
|
+
const statusListScreen: EntityListScreenDefinition = {
|
|
2580
|
+
id: "order-list",
|
|
2581
|
+
type: "entityList",
|
|
2582
|
+
entity: "order",
|
|
2583
|
+
columns: ["title", "status"],
|
|
2584
|
+
};
|
|
2585
|
+
|
|
2586
|
+
// publish/archive/duplicate all resolve an icon from their id
|
|
2587
|
+
// (ACTION_ICON_BY_ID), which is what arms the icon-only collapse.
|
|
2588
|
+
const rowActionListScreen: EntityListScreenDefinition = {
|
|
2589
|
+
id: "order-list-actions",
|
|
2590
|
+
type: "entityList",
|
|
2591
|
+
entity: "order",
|
|
2592
|
+
columns: ["title"],
|
|
2593
|
+
rowActions: [
|
|
2594
|
+
{ id: "publish", label: "Publish", handler: "orders:write:order:publish" },
|
|
2595
|
+
{ id: "archive", label: "Archive", handler: "orders:write:order:archive" },
|
|
2596
|
+
{ id: "duplicate", label: "Duplicate", handler: "orders:write:order:duplicate" },
|
|
2597
|
+
],
|
|
2598
|
+
};
|
|
2599
|
+
|
|
2600
|
+
// "review" has no icon in ACTION_ICON_BY_ID, so this group must stay on
|
|
2601
|
+
// the adaptive default (kebab) instead of collapsing.
|
|
2602
|
+
const mixedActionListScreen: EntityListScreenDefinition = {
|
|
2603
|
+
id: "order-list-mixed",
|
|
2604
|
+
type: "entityList",
|
|
2605
|
+
entity: "order",
|
|
2606
|
+
columns: ["title"],
|
|
2607
|
+
rowActions: [
|
|
2608
|
+
{ id: "publish", label: "Publish", handler: "orders:write:order:publish" },
|
|
2609
|
+
{ id: "archive", label: "Archive", handler: "orders:write:order:archive" },
|
|
2610
|
+
{ id: "review", label: "Review", handler: "orders:write:order:review" },
|
|
2611
|
+
],
|
|
2612
|
+
};
|
|
2613
|
+
|
|
2614
|
+
// projectionList reaches the same RenderList/DataTable path, so the collapse
|
|
2615
|
+
// has to hold there too.
|
|
2616
|
+
const projectionActionListScreen: ProjectionListScreenDefinition = {
|
|
2617
|
+
id: "order-projection-actions",
|
|
2618
|
+
type: "projectionList",
|
|
2619
|
+
query: "orders:query:order:list",
|
|
2620
|
+
columns: [{ field: "title", label: "Title" }],
|
|
2621
|
+
rowActions: [
|
|
2622
|
+
{
|
|
2623
|
+
kind: "writeHandler",
|
|
2624
|
+
id: "publish",
|
|
2625
|
+
label: "Publish",
|
|
2626
|
+
handler: "orders:write:order:publish",
|
|
2627
|
+
},
|
|
2628
|
+
{
|
|
2629
|
+
kind: "writeHandler",
|
|
2630
|
+
id: "archive",
|
|
2631
|
+
label: "Archive",
|
|
2632
|
+
handler: "orders:write:order:archive",
|
|
2633
|
+
},
|
|
2634
|
+
{
|
|
2635
|
+
kind: "writeHandler",
|
|
2636
|
+
id: "duplicate",
|
|
2637
|
+
label: "Duplicate",
|
|
2638
|
+
handler: "orders:write:order:duplicate",
|
|
2639
|
+
},
|
|
2640
|
+
],
|
|
2641
|
+
};
|
|
2642
|
+
|
|
2643
|
+
const orderSchema: FeatureSchema = {
|
|
2644
|
+
featureName: "orders",
|
|
2645
|
+
entities: { order: orderEntity },
|
|
2646
|
+
screens: [
|
|
2647
|
+
statusListScreen,
|
|
2648
|
+
rowActionListScreen,
|
|
2649
|
+
mixedActionListScreen,
|
|
2650
|
+
projectionActionListScreen,
|
|
2651
|
+
],
|
|
2652
|
+
};
|
|
2653
|
+
|
|
2654
|
+
function orderDispatcher(rows: readonly Record<string, unknown>[]): Dispatcher {
|
|
2655
|
+
return makeDispatcher({
|
|
2656
|
+
query: (async () => ({
|
|
2657
|
+
isSuccess: true,
|
|
2658
|
+
data: { rows, nextCursor: null },
|
|
2659
|
+
})) as unknown as Dispatcher["query"],
|
|
2660
|
+
});
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2663
|
+
async function renderOrderList(
|
|
2664
|
+
qn: string,
|
|
2665
|
+
rows: readonly Record<string, unknown>[],
|
|
2666
|
+
): Promise<void> {
|
|
2667
|
+
render(
|
|
2668
|
+
<DispatcherProvider dispatcher={orderDispatcher(rows)}>
|
|
2669
|
+
<KumikoScreen schema={orderSchema} qn={qn} />
|
|
2670
|
+
</DispatcherProvider>,
|
|
2671
|
+
);
|
|
2672
|
+
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
|
|
2673
|
+
}
|
|
2674
|
+
|
|
2675
|
+
test("a known status value renders a toned badge, not the muted default (#2579)", async () => {
|
|
2676
|
+
await renderOrderList("orders:screen:order-list", [
|
|
2677
|
+
{ id: "r1", title: "First", status: "active" },
|
|
2678
|
+
]);
|
|
2679
|
+
|
|
2680
|
+
const badge = screen.getByTestId("cell-r1-status").firstElementChild;
|
|
2681
|
+
expect(badge?.className).toContain("text-status-ok");
|
|
2682
|
+
expect(badge?.className).not.toContain("text-muted-foreground");
|
|
2683
|
+
expect(badge?.textContent).toBe("Active");
|
|
2684
|
+
});
|
|
2685
|
+
|
|
2686
|
+
test("an unmapped select value keeps the neutral pill (#2579)", async () => {
|
|
2687
|
+
await renderOrderList("orders:screen:order-list", [
|
|
2688
|
+
{ id: "r1", title: "First", status: "unmapped-value" },
|
|
2689
|
+
]);
|
|
2690
|
+
|
|
2691
|
+
const badge = screen.getByTestId("cell-r1-status").firstElementChild;
|
|
2692
|
+
expect(badge?.className).toContain("text-muted-foreground");
|
|
2693
|
+
expect(badge?.className).not.toContain("text-status-ok");
|
|
2694
|
+
});
|
|
2695
|
+
|
|
2696
|
+
test("an Object.prototype key is not mistaken for a tone (#2579)", async () => {
|
|
2697
|
+
await renderOrderList("orders:screen:order-list", [
|
|
2698
|
+
{ id: "r1", title: "First", status: "constructor" },
|
|
2699
|
+
]);
|
|
2700
|
+
|
|
2701
|
+
const badge = screen.getByTestId("cell-r1-status").firstElementChild;
|
|
2702
|
+
expect(badge?.className).toContain("text-muted-foreground");
|
|
2703
|
+
});
|
|
2704
|
+
|
|
2705
|
+
test("three icon-bearing row actions collapse to icon-only buttons (#2580)", async () => {
|
|
2706
|
+
await renderOrderList("orders:screen:order-list-actions", [{ id: "r1", title: "First" }]);
|
|
2707
|
+
|
|
2708
|
+
expect(screen.queryByTestId("row-r1-actions-menu")).toBeNull();
|
|
2709
|
+
for (const [id, label] of [
|
|
2710
|
+
["publish", "Publish"],
|
|
2711
|
+
["archive", "Archive"],
|
|
2712
|
+
["duplicate", "Duplicate"],
|
|
2713
|
+
] as const) {
|
|
2714
|
+
const button = screen.getByTestId(`row-r1-action-${id}`);
|
|
2715
|
+
expect(button.textContent).toBe("");
|
|
2716
|
+
expect(button.getAttribute("aria-label")).toBe(label);
|
|
2717
|
+
}
|
|
2718
|
+
});
|
|
2719
|
+
|
|
2720
|
+
test("projectionList row actions collapse the same way (#2580)", async () => {
|
|
2721
|
+
await renderOrderList("orders:screen:order-projection-actions", [{ id: "r1", title: "First" }]);
|
|
2722
|
+
|
|
2723
|
+
expect(screen.queryByTestId("row-r1-actions-menu")).toBeNull();
|
|
2724
|
+
for (const [id, label] of [
|
|
2725
|
+
["publish", "Publish"],
|
|
2726
|
+
["archive", "Archive"],
|
|
2727
|
+
["duplicate", "Duplicate"],
|
|
2728
|
+
] as const) {
|
|
2729
|
+
const button = screen.getByTestId(`row-r1-action-${id}`);
|
|
2730
|
+
expect(button.textContent).toBe("");
|
|
2731
|
+
expect(button.getAttribute("aria-label")).toBe(label);
|
|
2732
|
+
}
|
|
2733
|
+
});
|
|
2734
|
+
|
|
2735
|
+
test("a row-action group with an icon-less member stays on the kebab (#2580)", async () => {
|
|
2736
|
+
await renderOrderList("orders:screen:order-list-mixed", [{ id: "r1", title: "First" }]);
|
|
2737
|
+
|
|
2738
|
+
expect(screen.queryByTestId("row-r1-actions-menu")).not.toBeNull();
|
|
2739
|
+
expect(screen.queryByTestId("row-r1-action-publish")).toBeNull();
|
|
2740
|
+
});
|
|
2741
|
+
});
|
|
@@ -15,7 +15,7 @@ import { defaultPrimitives, END_LABEL_MIN_ROWS } from "../primitives";
|
|
|
15
15
|
import { PageSection, Stack } from "../primitives/layout";
|
|
16
16
|
import { fireEvent, render, screen, waitFor } from "./test-utils";
|
|
17
17
|
|
|
18
|
-
const { Button, Banner, Field, Input, DataTable, Form, Text, Heading, Dialog, Card } =
|
|
18
|
+
const { Button, Banner, Field, Input, DataTable, Form, Text, Heading, Dialog, Card, Section } =
|
|
19
19
|
defaultPrimitives;
|
|
20
20
|
|
|
21
21
|
describe("Button", () => {
|
|
@@ -178,10 +178,10 @@ describe("Input kind mapping", () => {
|
|
|
178
178
|
expect(onChange).toHaveBeenLastCalledWith(undefined);
|
|
179
179
|
});
|
|
180
180
|
|
|
181
|
-
test('kind="boolean": onChange receives checked', () => {
|
|
181
|
+
test('kind="boolean" outside layout="inline": renders a switch, onChange receives checked', () => {
|
|
182
182
|
const onChange = mock();
|
|
183
183
|
render(<Input id="i" name="i" kind="boolean" value={false} onChange={onChange} />);
|
|
184
|
-
fireEvent.click(screen.getByRole("
|
|
184
|
+
fireEvent.click(screen.getByRole("switch"));
|
|
185
185
|
expect(onChange).toHaveBeenCalledWith(true);
|
|
186
186
|
});
|
|
187
187
|
|
|
@@ -288,6 +288,91 @@ describe("Input kind mapping", () => {
|
|
|
288
288
|
});
|
|
289
289
|
});
|
|
290
290
|
|
|
291
|
+
// Boolean fields render a switch inside a standard (stacked) Field — matching
|
|
292
|
+
// every other field's label-above-control arrangement — while layout="inline"
|
|
293
|
+
// Fields (MultiSelectCheckboxes, the BooleanField widget) keep the checkbox.
|
|
294
|
+
describe("Input kind=boolean: switch vs checkbox", () => {
|
|
295
|
+
test("Field (default layout) + Input(boolean): renders role=switch, not checkbox", () => {
|
|
296
|
+
render(
|
|
297
|
+
<Field id="active" label="Active" testId="field-active">
|
|
298
|
+
<Input id="active" name="active" kind="boolean" value={false} onChange={mock()} />
|
|
299
|
+
</Field>,
|
|
300
|
+
);
|
|
301
|
+
const field = screen.getByTestId("field-active");
|
|
302
|
+
expect(field.querySelector('[role="switch"]')).toBeTruthy();
|
|
303
|
+
expect(field.querySelector('[role="checkbox"]')).toBeNull();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("Field(layout=inline) + Input(boolean): keeps the checkbox", () => {
|
|
307
|
+
render(
|
|
308
|
+
<Field id="opt" label="Option A" layout="inline" testId="field-opt">
|
|
309
|
+
<Input id="opt" name="opt" kind="boolean" value={false} onChange={mock()} />
|
|
310
|
+
</Field>,
|
|
311
|
+
);
|
|
312
|
+
const field = screen.getByTestId("field-opt");
|
|
313
|
+
expect(field.querySelector('[role="checkbox"]')).toBeTruthy();
|
|
314
|
+
expect(field.querySelector('[role="switch"]')).toBeNull();
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("clicking the switch reports the inverted value to onChange", () => {
|
|
318
|
+
const onChange = mock();
|
|
319
|
+
render(<Input id="active" name="active" kind="boolean" value={true} onChange={onChange} />);
|
|
320
|
+
fireEvent.click(screen.getByRole("switch"));
|
|
321
|
+
expect(onChange).toHaveBeenCalledWith(false);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("aria-checked reflects the value, and the switch toggles via keyboard", async () => {
|
|
325
|
+
const onChange = mock();
|
|
326
|
+
const { rerender } = render(
|
|
327
|
+
<Input id="active" name="active" kind="boolean" value={false} onChange={onChange} />,
|
|
328
|
+
);
|
|
329
|
+
expect(screen.getByRole("switch").getAttribute("aria-checked")).toBe("false");
|
|
330
|
+
|
|
331
|
+
rerender(<Input id="active" name="active" kind="boolean" value={true} onChange={onChange} />);
|
|
332
|
+
expect(screen.getByRole("switch").getAttribute("aria-checked")).toBe("true");
|
|
333
|
+
|
|
334
|
+
const user = userEvent.setup();
|
|
335
|
+
await user.tab();
|
|
336
|
+
expect(document.activeElement).toBe(screen.getByRole("switch"));
|
|
337
|
+
await user.keyboard(" ");
|
|
338
|
+
expect(onChange).toHaveBeenCalledWith(false);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("disabled prevents toggling", () => {
|
|
342
|
+
const onChange = mock();
|
|
343
|
+
render(
|
|
344
|
+
<Input id="active" name="active" kind="boolean" value={false} onChange={onChange} disabled />,
|
|
345
|
+
);
|
|
346
|
+
const toggle = screen.getByRole("switch") as HTMLButtonElement;
|
|
347
|
+
expect(toggle.disabled).toBe(true);
|
|
348
|
+
fireEvent.click(toggle);
|
|
349
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("boolean field's label sits above the control, same as a text field's", () => {
|
|
353
|
+
render(
|
|
354
|
+
<>
|
|
355
|
+
<Field id="active" label="Active" testId="field-active">
|
|
356
|
+
<Input id="active" name="active" kind="boolean" value={false} onChange={mock()} />
|
|
357
|
+
</Field>
|
|
358
|
+
<Field id="title" label="Title" testId="field-title">
|
|
359
|
+
<Input id="title" name="title" kind="text" value="" onChange={mock()} />
|
|
360
|
+
</Field>
|
|
361
|
+
</>,
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
const booleanField = screen.getByTestId("field-active");
|
|
365
|
+
const textField = screen.getByTestId("field-title");
|
|
366
|
+
|
|
367
|
+
// Same structural shape: label-row first, control second — the exact
|
|
368
|
+
// regression the old inline-layout checkbox introduced for booleans.
|
|
369
|
+
expect(booleanField.children[0]?.querySelector("label")).toBeTruthy();
|
|
370
|
+
expect(booleanField.children[1]?.getAttribute("role")).toBe("switch");
|
|
371
|
+
expect(textField.children[0]?.querySelector("label")).toBeTruthy();
|
|
372
|
+
expect(textField.children[1]?.tagName).toBe("INPUT");
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
291
376
|
describe("DataTable", () => {
|
|
292
377
|
test("empty rows render empty-state slot with derived testId", () => {
|
|
293
378
|
render(
|
|
@@ -1036,6 +1121,76 @@ describe("DataTable", () => {
|
|
|
1036
1121
|
// beim Action-Click gleichzeitig zum Edit-Screen navigieren.
|
|
1037
1122
|
expect(onRowClick).not.toHaveBeenCalled();
|
|
1038
1123
|
});
|
|
1124
|
+
|
|
1125
|
+
// fw-ui-defaults: rowActionMode="inline" renders every action as an
|
|
1126
|
+
// always-visible button (no Kebab, unlike adaptive) — a group with more
|
|
1127
|
+
// than two icon-carrying actions collapses to icon-only so it doesn't
|
|
1128
|
+
// become a wall of text buttons.
|
|
1129
|
+
describe("icon-only collapse (mode='inline', >2 actions, all carry an icon)", () => {
|
|
1130
|
+
test("renders icon-only buttons with an accessible name per action", () => {
|
|
1131
|
+
render(
|
|
1132
|
+
<DataTable
|
|
1133
|
+
columns={cols}
|
|
1134
|
+
rows={rows}
|
|
1135
|
+
testId="dt"
|
|
1136
|
+
rowActionMode="inline"
|
|
1137
|
+
rowActions={[
|
|
1138
|
+
{ id: "edit", label: "Edit", icon: "pencil", onTrigger: mock() },
|
|
1139
|
+
{ id: "archive", label: "Archive", icon: "archive", onTrigger: mock() },
|
|
1140
|
+
{ id: "delete", label: "Delete", icon: "trash", onTrigger: mock() },
|
|
1141
|
+
]}
|
|
1142
|
+
/>,
|
|
1143
|
+
);
|
|
1144
|
+
for (const [id, label] of [
|
|
1145
|
+
["edit", "Edit"],
|
|
1146
|
+
["archive", "Archive"],
|
|
1147
|
+
["delete", "Delete"],
|
|
1148
|
+
] as const) {
|
|
1149
|
+
expect(screen.getByTestId(`row-r1-action-${id}`).getAttribute("aria-label")).toBe(label);
|
|
1150
|
+
}
|
|
1151
|
+
const editButton = screen.getByTestId("row-r1-action-edit");
|
|
1152
|
+
// Icon-only: children (the label text) are gone, only the <svg> renders.
|
|
1153
|
+
expect(editButton.textContent).toBe("");
|
|
1154
|
+
expect(editButton.querySelector("svg")).not.toBeNull();
|
|
1155
|
+
expect(editButton.getAttribute("title")).toBe("Edit");
|
|
1156
|
+
});
|
|
1157
|
+
|
|
1158
|
+
test("a group of exactly two icon actions keeps the text label (rule needs >2)", () => {
|
|
1159
|
+
render(
|
|
1160
|
+
<DataTable
|
|
1161
|
+
columns={cols}
|
|
1162
|
+
rows={rows}
|
|
1163
|
+
testId="dt"
|
|
1164
|
+
rowActionMode="inline"
|
|
1165
|
+
rowActions={[
|
|
1166
|
+
{ id: "edit", label: "Edit", icon: "pencil", onTrigger: mock() },
|
|
1167
|
+
{ id: "delete", label: "Delete", icon: "trash", onTrigger: mock() },
|
|
1168
|
+
]}
|
|
1169
|
+
/>,
|
|
1170
|
+
);
|
|
1171
|
+
expect(screen.getByTestId("row-r1-action-edit").textContent).toContain("Edit");
|
|
1172
|
+
expect(screen.getByTestId("row-r1-action-delete").textContent).toContain("Delete");
|
|
1173
|
+
});
|
|
1174
|
+
|
|
1175
|
+
test("a mixed group (one action without an icon) keeps every label as text", () => {
|
|
1176
|
+
render(
|
|
1177
|
+
<DataTable
|
|
1178
|
+
columns={cols}
|
|
1179
|
+
rows={rows}
|
|
1180
|
+
testId="dt"
|
|
1181
|
+
rowActionMode="inline"
|
|
1182
|
+
rowActions={[
|
|
1183
|
+
{ id: "edit", label: "Edit", icon: "pencil", onTrigger: mock() },
|
|
1184
|
+
{ id: "archive", label: "Archive", icon: "archive", onTrigger: mock() },
|
|
1185
|
+
{ id: "custom", label: "Custom", onTrigger: mock() },
|
|
1186
|
+
]}
|
|
1187
|
+
/>,
|
|
1188
|
+
);
|
|
1189
|
+
expect(screen.getByTestId("row-r1-action-edit").textContent).toContain("Edit");
|
|
1190
|
+
expect(screen.getByTestId("row-r1-action-archive").textContent).toContain("Archive");
|
|
1191
|
+
expect(screen.getByTestId("row-r1-action-custom").textContent).toContain("Custom");
|
|
1192
|
+
});
|
|
1193
|
+
});
|
|
1039
1194
|
});
|
|
1040
1195
|
|
|
1041
1196
|
describe("highlighted column", () => {
|
|
@@ -1518,6 +1673,30 @@ describe("Card", () => {
|
|
|
1518
1673
|
});
|
|
1519
1674
|
});
|
|
1520
1675
|
|
|
1676
|
+
describe("Section", () => {
|
|
1677
|
+
test("declared icon renders left of the title", () => {
|
|
1678
|
+
render(
|
|
1679
|
+
<Section title="Text" icon="tag" testId="s">
|
|
1680
|
+
<span>body</span>
|
|
1681
|
+
</Section>,
|
|
1682
|
+
);
|
|
1683
|
+
const titleEl = screen.getByTestId("s-title");
|
|
1684
|
+
expect(titleEl.querySelector("svg")).not.toBeNull();
|
|
1685
|
+
expect(titleEl.className).toContain("items-center");
|
|
1686
|
+
});
|
|
1687
|
+
|
|
1688
|
+
test("no icon → header renders exactly as before (no svg, no layout change)", () => {
|
|
1689
|
+
render(
|
|
1690
|
+
<Section title="Text" testId="s">
|
|
1691
|
+
<span>body</span>
|
|
1692
|
+
</Section>,
|
|
1693
|
+
);
|
|
1694
|
+
const titleEl = screen.getByTestId("s-title");
|
|
1695
|
+
expect(titleEl.querySelector("svg")).toBeNull();
|
|
1696
|
+
expect(titleEl.textContent).toBe("Text");
|
|
1697
|
+
});
|
|
1698
|
+
});
|
|
1699
|
+
|
|
1521
1700
|
describe("Stack", () => {
|
|
1522
1701
|
test("gap-Variante bildet auf die Tailwind-gap-Klasse ab", () => {
|
|
1523
1702
|
render(
|