@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.233.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.233.0",
20
- "@cosmicdrift/kumiko-headless": "0.233.0",
21
- "@cosmicdrift/kumiko-renderer": "0.233.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.233.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 der klassische
2
- // min-h-screen-Seitenflow, fill schaltet auf h-screen + innen-scrollendes
3
- // main (min-h-0). Plus die className/mainClassName-Erweiterungspunkte.
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-screen (Seiten-Scroll), kein fill-Marker", () => {
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-full")).toBeTruthy();
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 the
442
- // callback, and shows the "copied" label afterwards).
443
- test("entityEdit update-mode: Copy-Link-Button feuert onCopyLink + zeigt 'copied'-Label", async () => {
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("checkbox"));
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(