@cosmicdrift/kumiko-renderer-web 0.232.0 → 0.234.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.232.0",
3
+ "version": "0.234.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.232.0",
20
- "@cosmicdrift/kumiko-headless": "0.232.0",
21
- "@cosmicdrift/kumiko-renderer": "0.232.0",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.234.0",
20
+ "@cosmicdrift/kumiko-headless": "0.234.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.234.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.232.0"
67
+ "@cosmicdrift/kumiko-locale-de": "0.234.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
+ });
@@ -15,21 +15,70 @@ import { createMockDispatcher, render, screen } from "./test-utils";
15
15
  const { Form, Section, Button } = defaultPrimitives;
16
16
 
17
17
  describe("DefaultForm Action-Footer", () => {
18
- test("Actions sitzen rechtsbündig am Form-Ende (border-t), Titel ist Heading", () => {
18
+ test("Actions sitzen am Form-Ende (border-t, sm:justify-between), Titel ist Heading", () => {
19
19
  render(
20
20
  <Form onSubmit={() => {}} title="Titel" actions={<Button>Save</Button>} testId="f">
21
21
  <div>body</div>
22
22
  </Form>,
23
23
  );
24
24
  const actions = screen.getByTestId("f-actions");
25
- expect(actions.className).toContain("justify-end");
26
- expect(actions.className).toContain("border-t");
25
+ // The border/layout classes live on the outer footer container (which
26
+ // also wraps the optional secondaryActions group), not on the inner
27
+ // `-actions` div itself.
28
+ const footer = actions.parentElement as HTMLElement;
29
+ expect(footer.className).toContain("sm:justify-between");
30
+ expect(footer.className).toContain("border-t");
27
31
  // Footer sits in the form shell (buttons align with the field edge).
28
- expect(actions.closest(".max-w-full")).toBeTruthy();
32
+ expect(actions.closest(".max-w-4xl")).toBeTruthy();
29
33
  // Title is its own heading above — not inside the action bar.
30
34
  expect(actions.textContent).not.toContain("Titel");
31
35
  expect(screen.getByTestId("f-title").textContent).toBe("Titel");
32
36
  });
37
+
38
+ test("secondaryActions + actions: zwei Gruppen mit eigenen testIds, Container flex-col-reverse", () => {
39
+ render(
40
+ <Form
41
+ onSubmit={() => {}}
42
+ actions={<Button>Save</Button>}
43
+ secondaryActions={<Button variant="danger-ghost">Delete</Button>}
44
+ testId="f"
45
+ >
46
+ <div>body</div>
47
+ </Form>,
48
+ );
49
+ const secondary = screen.getByTestId("f-actions-secondary");
50
+ const main = screen.getByTestId("f-actions");
51
+ expect(secondary.textContent).toBe("Delete");
52
+ expect(main.textContent).toBe("Save");
53
+ // Same outer footer container wraps both groups.
54
+ expect(secondary.parentElement).toBe(main.parentElement);
55
+ const footer = main.parentElement as HTMLElement;
56
+ expect(footer.className).toContain("flex-col-reverse");
57
+ expect(footer.className).toContain("sm:justify-between");
58
+ });
59
+
60
+ test("nur actions (keine secondaryActions): keine -actions-secondary-Gruppe", () => {
61
+ render(
62
+ <Form onSubmit={() => {}} actions={<Button>Save</Button>} testId="f">
63
+ <div>body</div>
64
+ </Form>,
65
+ );
66
+ expect(screen.queryByTestId("f-actions-secondary")).toBeNull();
67
+ expect(screen.getByTestId("f-actions").textContent).toBe("Save");
68
+ });
69
+
70
+ test("nur actions (keine secondaryActions): Gruppe bleibt rechtsbündig (sm:ml-auto)", () => {
71
+ render(
72
+ <Form onSubmit={() => {}} actions={<Button>Save</Button>} testId="f">
73
+ <div>body</div>
74
+ </Form>,
75
+ );
76
+ // justify-between on the footer only pushes the lone group to the edge
77
+ // when there's a second flex child to push away from — with a single
78
+ // child it falls back to flex-start, so the actions group needs its own
79
+ // margin to stay right-aligned on desktop.
80
+ expect(screen.getByTestId("f-actions").className).toContain("sm:ml-auto");
81
+ });
33
82
  });
34
83
 
35
84
  describe("DefaultSection ohne 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
+ });