@cosmicdrift/kumiko-renderer-web 0.193.0 → 0.194.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.
Files changed (46) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/create-app.test.tsx +1 -1
  3. package/src/__tests__/entity-edit-redirect.test.tsx +111 -0
  4. package/src/__tests__/field-hide-label.test.tsx +166 -1
  5. package/src/__tests__/kumiko-screen.test.tsx +59 -0
  6. package/src/__tests__/nav-tree.test.tsx +43 -11
  7. package/src/__tests__/primitives.test.tsx +33 -2
  8. package/src/__tests__/render-edit.test.tsx +344 -9
  9. package/src/__tests__/test-utils.tsx +20 -0
  10. package/src/__tests__/wizard-form-validation.test.tsx +129 -1
  11. package/src/app/__tests__/merge-content-editors.test.ts +2 -2
  12. package/src/app/__tests__/plain-content-editor.test.tsx +4 -0
  13. package/src/app/__tests__/rich-content-editor.test.tsx +5 -2
  14. package/src/app/__tests__/tiptap-editor.test.tsx +50 -0
  15. package/src/app/client-plugin.tsx +3 -3
  16. package/src/app/create-app.tsx +40 -49
  17. package/src/app/tiptap-editor.tsx +10 -0
  18. package/src/layout/__tests__/workspace-switcher.test.tsx +40 -0
  19. package/src/layout/sidebar-panel.tsx +1 -4
  20. package/src/layout/workspace-switcher.tsx +20 -13
  21. package/src/lib/__tests__/resize-image.test.ts +152 -0
  22. package/src/lib/clamp.ts +3 -0
  23. package/src/lib/resize-image.ts +61 -0
  24. package/src/primitives/__tests__/data-table-logic.test.ts +15 -3
  25. package/src/primitives/__tests__/date-field.test.tsx +37 -0
  26. package/src/primitives/__tests__/date-parse.test.ts +14 -2
  27. package/src/primitives/__tests__/embedded-list-input.test.tsx +36 -2
  28. package/src/primitives/__tests__/file-upload.test.tsx +24 -0
  29. package/src/primitives/date-field.tsx +8 -0
  30. package/src/primitives/date-parse.ts +6 -1
  31. package/src/primitives/dropdown-menu.tsx +23 -0
  32. package/src/primitives/embedded-list-input.tsx +14 -5
  33. package/src/primitives/file-upload.tsx +6 -1
  34. package/src/primitives/index.tsx +114 -120
  35. package/src/primitives/money-input.tsx +4 -7
  36. package/src/ui/sheet.tsx +3 -1
  37. package/src/widgets/__tests__/drawer.test.tsx +293 -0
  38. package/src/widgets/__tests__/infinity-list.test.tsx +38 -0
  39. package/src/widgets/__tests__/upload-zone.test.tsx +125 -1
  40. package/src/widgets/__tests__/widgets.test.tsx +25 -0
  41. package/src/widgets/ai-text-field.tsx +3 -1
  42. package/src/widgets/drawer.tsx +54 -29
  43. package/src/widgets/infinity-list.tsx +18 -5
  44. package/src/widgets/progress-bar.tsx +3 -1
  45. package/src/widgets/step-bar.tsx +1 -1
  46. package/src/widgets/upload-zone.tsx +42 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.193.0",
3
+ "version": "0.194.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.193.0",
20
- "@cosmicdrift/kumiko-headless": "0.193.0",
21
- "@cosmicdrift/kumiko-renderer": "0.193.0",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.194.0",
20
+ "@cosmicdrift/kumiko-headless": "0.194.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.194.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",
@@ -316,7 +316,7 @@ describe("createKumikoApp", () => {
316
316
  expect(screen.getByTestId("ca-swatch-field").textContent).toBe("color");
317
317
  });
318
318
 
319
- test("clientFeatures.contentEditors → createKumikoApp verdrahtet MergeProvider → useContentEditor end-to-end", async () => {
319
+ test("clientFeatures.contentEditors → createKumikoApp wires mergeprovider → useContentEditor end-to-end", async () => {
320
320
  // Proves the whole chain: ClientFeatureDefinition.contentEditors →
321
321
  // mergeContentEditors → ContentEditorsProvider in the tree →
322
322
  // useContentEditor in the consumer. Without the provider mount,
@@ -14,6 +14,7 @@ import type {
14
14
  import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
15
15
  import type { NavTarget } from "@cosmicdrift/kumiko-renderer";
16
16
  import { DispatcherProvider, KumikoScreen, NavProvider } from "@cosmicdrift/kumiko-renderer";
17
+ import userEvent from "@testing-library/user-event";
17
18
  import { createMockDispatcher, fireEvent, render, screen, waitFor } from "./test-utils";
18
19
 
19
20
  const productEntity: EntityDefinition = {
@@ -163,4 +164,114 @@ describe("entityEdit redirect (#1942)", () => {
163
164
 
164
165
  await waitFor(() => expect(navigated).toEqual([{ screenId: "product-detail" }]));
165
166
  });
167
+
168
+ test("update: redirect as fully-qualified cross-feature QN navigates via its short id (#1946)", async () => {
169
+ // Mirrors the create-mode QN test above — EntityEditUpdateForm's
170
+ // handleSubmitted has its own lastSegment(screen.redirect) call
171
+ // (kumiko-screen.tsx), separate from the create path's.
172
+ const navigated: NavTarget[] = [];
173
+ const dispatcher = createMockDispatcher({
174
+ query: (async () => ({
175
+ isSuccess: true,
176
+ data: { id: "42", version: 1, name: "Existing" },
177
+ })) as unknown as Dispatcher["query"],
178
+ });
179
+
180
+ render(
181
+ <DispatcherProvider dispatcher={dispatcher}>
182
+ <NavProvider
183
+ value={{
184
+ route: { screenId: "shop:screen:product-edit", entityId: "42" },
185
+ navigate: (target) => navigated.push(target),
186
+ replace: () => {},
187
+ hrefFor: () => "",
188
+ searchParams: {},
189
+ setSearchParams: () => {},
190
+ }}
191
+ >
192
+ <KumikoScreen
193
+ schema={buildSchema("statements:screen:statement-upload-list")}
194
+ qn="shop:screen:product-edit"
195
+ entityId="42"
196
+ />
197
+ </NavProvider>
198
+ </DispatcherProvider>,
199
+ );
200
+
201
+ await waitFor(() => expect(screen.getByTestId("field-name")).toBeTruthy());
202
+ fillNameAndSubmit();
203
+
204
+ await waitFor(() => expect(navigated).toEqual([{ screenId: "statement-upload-list" }]));
205
+ });
206
+
207
+ test("update: no redirect set falls back to the entity's list screen", async () => {
208
+ const navigated: NavTarget[] = [];
209
+ const dispatcher = createMockDispatcher({
210
+ query: (async () => ({
211
+ isSuccess: true,
212
+ data: { id: "42", version: 1, name: "Existing" },
213
+ })) as unknown as Dispatcher["query"],
214
+ });
215
+
216
+ render(
217
+ <DispatcherProvider dispatcher={dispatcher}>
218
+ <NavProvider
219
+ value={{
220
+ route: { screenId: "shop:screen:product-edit", entityId: "42" },
221
+ navigate: (target) => navigated.push(target),
222
+ replace: () => {},
223
+ hrefFor: () => "",
224
+ searchParams: {},
225
+ setSearchParams: () => {},
226
+ }}
227
+ >
228
+ <KumikoScreen schema={buildSchema()} qn="shop:screen:product-edit" entityId="42" />
229
+ </NavProvider>
230
+ </DispatcherProvider>,
231
+ );
232
+
233
+ await waitFor(() => expect(screen.getByTestId("field-name")).toBeTruthy());
234
+ fillNameAndSubmit();
235
+
236
+ await waitFor(() => expect(navigated).toEqual([{ screenId: "product-list" }]));
237
+ });
238
+
239
+ test("update: delete navigates to the list even with redirect set", async () => {
240
+ const user = userEvent.setup();
241
+ const navigated: NavTarget[] = [];
242
+ const dispatcher = createMockDispatcher({
243
+ query: (async () => ({
244
+ isSuccess: true,
245
+ data: { id: "42", version: 1, name: "Existing" },
246
+ })) as unknown as Dispatcher["query"],
247
+ });
248
+
249
+ render(
250
+ <DispatcherProvider dispatcher={dispatcher}>
251
+ <NavProvider
252
+ value={{
253
+ route: { screenId: "shop:screen:product-edit", entityId: "42" },
254
+ navigate: (target) => navigated.push(target),
255
+ replace: () => {},
256
+ hrefFor: () => "",
257
+ searchParams: {},
258
+ setSearchParams: () => {},
259
+ }}
260
+ >
261
+ <KumikoScreen
262
+ schema={buildSchema("product-detail")}
263
+ qn="shop:screen:product-edit"
264
+ entityId="42"
265
+ />
266
+ </NavProvider>
267
+ </DispatcherProvider>,
268
+ );
269
+
270
+ await waitFor(() => expect(screen.getByTestId("field-name")).toBeTruthy());
271
+ // userEvent instead of fireEvent: the Radix dialog fires async state updates.
272
+ await user.click(screen.getByTestId("render-edit-delete"));
273
+ await user.click(screen.getByTestId("render-edit-delete-dialog-confirm"));
274
+
275
+ await waitFor(() => expect(navigated).toEqual([{ screenId: "product-list" }]));
276
+ });
166
277
  });
@@ -1,6 +1,19 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { DispatcherProvider } from "@cosmicdrift/kumiko-renderer";
3
+ import type { ReactElement } from "react";
2
4
  import { defaultPrimitives } from "../primitives";
3
- import { render } from "./test-utils";
5
+ import { AiTextField } from "../widgets/ai-text-field";
6
+ import {
7
+ BooleanField,
8
+ DateField,
9
+ FileField,
10
+ NumberField,
11
+ RangeField,
12
+ SelectField,
13
+ TextareaField,
14
+ TextField,
15
+ } from "../widgets/form-fields";
16
+ import { createMockDispatcher, render } from "./test-utils";
4
17
 
5
18
  const { Field } = defaultPrimitives;
6
19
 
@@ -42,3 +55,155 @@ describe("DefaultField hideLabel (fw#1870)", () => {
42
55
  expect(view.getByLabelText("Maps to")).toBeTruthy();
43
56
  });
44
57
  });
58
+
59
+ const LABEL = "Maps to";
60
+
61
+ // Every *Field widget in form-fields.tsx wires hideLabel through to the
62
+ // underlying Field primitive by hand (fw#1870/#1871) — a widget that forgets
63
+ // the pass-through renders a visible label with no compile error, so this
64
+ // has to be caught per-widget rather than assumed from the Field test above.
65
+ const FIELD_WIDGETS: ReadonlyArray<readonly [string, ReactElement]> = [
66
+ [
67
+ "NumberField",
68
+ <NumberField
69
+ key="NumberField"
70
+ id="f1"
71
+ name="f1"
72
+ label={LABEL}
73
+ value={1}
74
+ onChange={() => {}}
75
+ hideLabel
76
+ testId="field"
77
+ />,
78
+ ],
79
+ [
80
+ "TextField",
81
+ <TextField
82
+ key="TextField"
83
+ id="f1"
84
+ name="f1"
85
+ label={LABEL}
86
+ value="hallo"
87
+ onChange={() => {}}
88
+ hideLabel
89
+ testId="field"
90
+ />,
91
+ ],
92
+ [
93
+ "SelectField",
94
+ <SelectField
95
+ key="SelectField"
96
+ id="f1"
97
+ name="f1"
98
+ label={LABEL}
99
+ value="a"
100
+ onChange={() => {}}
101
+ options={["a", "b"]}
102
+ hideLabel
103
+ testId="field"
104
+ />,
105
+ ],
106
+ [
107
+ "DateField",
108
+ <DateField
109
+ key="DateField"
110
+ id="f1"
111
+ name="f1"
112
+ label={LABEL}
113
+ value="2026-01-01"
114
+ onChange={() => {}}
115
+ hideLabel
116
+ testId="field"
117
+ />,
118
+ ],
119
+ [
120
+ "BooleanField",
121
+ <BooleanField
122
+ key="BooleanField"
123
+ id="f1"
124
+ name="f1"
125
+ label={LABEL}
126
+ value={false}
127
+ onChange={() => {}}
128
+ hideLabel
129
+ testId="field"
130
+ />,
131
+ ],
132
+ [
133
+ "TextareaField",
134
+ <TextareaField
135
+ key="TextareaField"
136
+ id="f1"
137
+ name="f1"
138
+ label={LABEL}
139
+ value="hallo"
140
+ onChange={() => {}}
141
+ hideLabel
142
+ testId="field"
143
+ />,
144
+ ],
145
+ [
146
+ "RangeField",
147
+ <RangeField
148
+ key="RangeField"
149
+ id="f1"
150
+ name="f1"
151
+ label={LABEL}
152
+ value={5}
153
+ onChange={() => {}}
154
+ min={0}
155
+ max={10}
156
+ hideLabel
157
+ testId="field"
158
+ />,
159
+ ],
160
+ [
161
+ "FileField",
162
+ <FileField
163
+ key="FileField"
164
+ id="f1"
165
+ name="f1"
166
+ label={LABEL}
167
+ value={null}
168
+ onChange={() => {}}
169
+ hideLabel
170
+ testId="field"
171
+ />,
172
+ ],
173
+ ];
174
+
175
+ describe("form-fields.tsx hideLabel pass-through (fw#1870/#1871)", () => {
176
+ test.each(FIELD_WIDGETS)(
177
+ "%s collapses its label to sr-only and keeps it htmlFor-linked",
178
+ (_name, element) => {
179
+ const view = render(element);
180
+ expect(view.getByText(LABEL).className).toContain("sr-only");
181
+ expect(view.getByLabelText(LABEL)).toBeTruthy();
182
+ },
183
+ );
184
+ });
185
+
186
+ // AiTextField builds its own <Field> instead of going through form-fields.tsx
187
+ // (fw#1871#1) — a separate widget, so it needs its own pass-through check.
188
+ describe("AiTextField hideLabel pass-through (fw#1871#1)", () => {
189
+ test("collapses its label to sr-only and keeps it htmlFor-linked", () => {
190
+ const dispatcher = createMockDispatcher({});
191
+ const view = render(
192
+ <DispatcherProvider dispatcher={dispatcher}>
193
+ <AiTextField
194
+ id="f1"
195
+ name="f1"
196
+ label={LABEL}
197
+ value="hallo"
198
+ onChange={() => {}}
199
+ actions={[]}
200
+ completion={false}
201
+ hideLabel
202
+ testId="field"
203
+ />
204
+ </DispatcherProvider>,
205
+ );
206
+ expect(view.getByText(LABEL).className).toContain("sr-only");
207
+ expect(view.getByLabelText(LABEL)).toBeTruthy();
208
+ });
209
+ });
@@ -1527,6 +1527,38 @@ describe("KumikoScreen", () => {
1527
1527
  expect(navigateCalls).toEqual([{ screenId: "task-list" }]);
1528
1528
  });
1529
1529
 
1530
+ test("actionForm mit Cross-Feature-QN als cancelTarget: Abbrechen navigiert per Short-Id (#1946)", () => {
1531
+ const navigateCalls: { screenId: string }[] = [];
1532
+ const memoryNav = {
1533
+ route: { screenId: "quick-add" },
1534
+ navigate: (target: { screenId: string }) => navigateCalls.push(target),
1535
+ replace: () => undefined,
1536
+ hrefFor: (t: { screenId: string }) => `/${t.screenId}`,
1537
+ searchParams: {},
1538
+ setSearchParams: () => undefined,
1539
+ };
1540
+ const actionScreen: ActionFormScreenDefinition = {
1541
+ id: "quick-add",
1542
+ type: "actionForm",
1543
+ handler: "tasks:write:task:quick-add",
1544
+ fields: { title: { type: "text", required: true } },
1545
+ layout: { sections: [{ title: "x", fields: ["title"] }] },
1546
+ cancelTarget: "statements:screen:statement-upload-list",
1547
+ };
1548
+ render(
1549
+ <NavProvider value={memoryNav}>
1550
+ <DispatcherProvider dispatcher={makeDispatcher()}>
1551
+ <KumikoScreen
1552
+ schema={{ ...schema, screens: [actionScreen, listScreen] }}
1553
+ qn="tasks:screen:quick-add"
1554
+ />
1555
+ </DispatcherProvider>
1556
+ </NavProvider>,
1557
+ );
1558
+ fireEvent.click(screen.getByTestId("render-edit-cancel"));
1559
+ expect(navigateCalls).toEqual([{ screenId: "statement-upload-list" }]);
1560
+ });
1561
+
1530
1562
  // Tier 2.7e-2: URL-Search-Params füllen die actionForm initial values.
1531
1563
  // Use-case: rowAction kind=navigate setzt `?taskId=r1`, das actionForm
1532
1564
  // sieht es beim Mount und pre-fillt das title-Feld.
@@ -1956,6 +1988,33 @@ describe("KumikoScreen: singleton entityEdit", () => {
1956
1988
  expect(screen.getByTestId("kumiko-screen-create-disabled")).toBeTruthy();
1957
1989
  expect(screen.queryByTestId("render-edit-form")).toBeNull();
1958
1990
  });
1991
+
1992
+ // The singleton wrapper fires its own list(limit:1) query up front — a
1993
+ // screen whose `list` handler has stricter access.roles than its `edit`
1994
+ // handler used to render fine (no query ran before this feature) and now
1995
+ // flips to an error banner instead. No test caught that regression.
1996
+ test("list-Query schlägt fehl → Fehler-Banner statt Create-Form", async () => {
1997
+ const dispatcher = makeDispatcher({
1998
+ query: (async () => ({
1999
+ isSuccess: false,
2000
+ error: {
2001
+ code: "access_denied",
2002
+ httpStatus: 403,
2003
+ i18nKey: "errors.access.denied",
2004
+ message: "",
2005
+ },
2006
+ })) as unknown as Dispatcher["query"],
2007
+ });
2008
+ render(
2009
+ <DispatcherProvider dispatcher={dispatcher}>
2010
+ <KumikoScreen schema={singletonSchema} qn="tasks:screen:task-edit" />
2011
+ </DispatcherProvider>,
2012
+ );
2013
+ await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
2014
+ const bannerText = screen.getByTestId("kumiko-screen-error").textContent;
2015
+ expect(bannerText).toBe(kumikoDefaultTranslations["en"]?.["errors.access.denied"] ?? "");
2016
+ expect(screen.queryByTestId("render-edit-form")).toBeNull();
2017
+ });
1959
2018
  });
1960
2019
 
1961
2020
  // --- actionForm extension-section (Wave J: Incident-Update-Timeline) ---
@@ -138,6 +138,36 @@ describe("NavTree role-gating", () => {
138
138
  });
139
139
  });
140
140
 
141
+ // Lucide tags every icon's <svg> with a `lucide-<slug>` class (see
142
+ // createLucideIcon) — asserting on it distinguishes "icon X rendered" from
143
+ // "some svg rendered", so a NAV_ICONS entry pointing at the wrong component
144
+ // (or a duplicate) fails even when the total svg count still matches.
145
+ const NAV_ICON_LUCIDE_CLASS: Readonly<Record<string, string>> = {
146
+ dashboard: "lucide-layout-dashboard",
147
+ layers: "lucide-layers",
148
+ building: "lucide-building",
149
+ tag: "lucide-tag",
150
+ key: "lucide-key-round",
151
+ server: "lucide-server",
152
+ mail: "lucide-mail",
153
+ download: "lucide-download",
154
+ upload: "lucide-upload",
155
+ rocket: "lucide-rocket",
156
+ palette: "lucide-palette",
157
+ link: "lucide-link",
158
+ share: "lucide-share-2",
159
+ };
160
+
161
+ function expectNavIcons(container: HTMLElement, iconKeys: readonly string[]): void {
162
+ const classes = Array.from(container.querySelectorAll("svg")).map(
163
+ (svg) => svg.getAttribute("class") ?? "",
164
+ );
165
+ expect(classes).toHaveLength(iconKeys.length);
166
+ iconKeys.forEach((key, i) => {
167
+ expect(classes[i]).toContain(NAV_ICON_LUCIDE_CLASS[key]);
168
+ });
169
+ }
170
+
141
171
  describe("NavTree", () => {
142
172
  test("Section-Header (parent ohne screen) ist statisches Label, children sichtbar", () => {
143
173
  render(<NavTree schema={makeSchema()} testId="tree" />);
@@ -204,10 +234,10 @@ describe("NavTree", () => {
204
234
  const { container } = render(<NavTree schema={schema} />);
205
235
  // Flache Navigation ohne Sections → keine Chevrons. Genau EIN svg:
206
236
  // das dashboard-Icon. Das icon-lose Item rendert den Dot (span, kein svg).
207
- expect(container.querySelectorAll("svg").length).toBe(1);
237
+ expectNavIcons(container, ["dashboard"]);
208
238
  });
209
239
 
210
- test("layers + building (money-horse Kredit-Gruppen/Mandanten) lösen auf ein Icon auf", () => {
240
+ test("layers + building (money-horse credit groups/tenants) resolve to an icon", () => {
211
241
  const schema = {
212
242
  featureName: "showcase",
213
243
  entities: {},
@@ -221,10 +251,10 @@ describe("NavTree", () => {
221
251
  ],
222
252
  } as FeatureSchema;
223
253
  const { container } = render(<NavTree schema={schema} />);
224
- expect(container.querySelectorAll("svg").length).toBe(2);
254
+ expectNavIcons(container, ["layers", "building"]);
225
255
  });
226
256
 
227
- test("tag + key (custom-fields / api-tokens) lösen auf ein Icon auf", () => {
257
+ test("tag + key (custom-fields / api-tokens) resolve to an icon", () => {
228
258
  const schema = {
229
259
  featureName: "showcase",
230
260
  entities: {},
@@ -238,13 +268,15 @@ describe("NavTree", () => {
238
268
  ],
239
269
  } as FeatureSchema;
240
270
  const { container } = render(<NavTree schema={schema} />);
241
- expect(container.querySelectorAll("svg").length).toBe(2);
271
+ expectNavIcons(container, ["tag", "key"]);
242
272
  });
243
273
 
244
- test("server, mail, download, upload, rocket lösen auf ein Icon auf (Config-/SMTP-Nav)", () => {
245
- // Config-Settings-Hub leitet Child-Nav-Icons aus dem mask.icon der
246
- // ConfigKey ab (smtp-host="server", from="mail", subscription="rocket");
247
- // fehlten sie in NAV_ICONS, rendert das Nav blank statt Icon.
274
+ test("server, mail, download, upload, rocket resolve to an icon (config/SMTP nav)", () => {
275
+ // The config settings hub derives child nav icons from a ConfigKey's
276
+ // mask.icon (smtp-host="server", from="mail", subscription="rocket");
277
+ // a missing NAV_ICONS entry renders the nav blank instead of an icon.
278
+ // download/upload guard the same map against feature-authored icons
279
+ // (e.g. GDPR export-job nav).
248
280
  const schema = {
249
281
  featureName: "app",
250
282
  entities: {},
@@ -264,7 +296,7 @@ describe("NavTree", () => {
264
296
  ],
265
297
  } as FeatureSchema;
266
298
  const { container } = render(<NavTree schema={schema} />);
267
- expect(container.querySelectorAll("svg").length).toBe(5);
299
+ expectNavIcons(container, ["server", "mail", "download", "upload", "rocket"]);
268
300
  });
269
301
 
270
302
  test("palette, link und share rendern Lucide-Icons (Share-/Branding-Nav)", () => {
@@ -283,7 +315,7 @@ describe("NavTree", () => {
283
315
  ],
284
316
  } as FeatureSchema;
285
317
  const { container } = render(<NavTree schema={schema} />);
286
- expect(container.querySelectorAll("svg").length).toBe(3);
318
+ expectNavIcons(container, ["palette", "link", "share"]);
287
319
  });
288
320
 
289
321
  test("unbekannter icon-Key fällt sauber auf den Dot zurück (kein svg)", () => {
@@ -87,6 +87,21 @@ describe("Banner", () => {
87
87
  const slot = screen.getByTestId("b").querySelector('[data-slot="actions"]');
88
88
  expect(slot?.textContent).toBe("undo");
89
89
  });
90
+
91
+ test("id prop (Field-as-control usage) sets role=group + aria-labelledby, not a bare id/htmlFor pairing", () => {
92
+ render(
93
+ <Field id="unsupported-field" label="Raw data">
94
+ <Banner id="unsupported-field" variant="info" testId="b">
95
+ No editor for this field type
96
+ </Banner>
97
+ </Field>,
98
+ );
99
+ const banner = screen.getByTestId("b");
100
+ expect(banner.getAttribute("role")).toBe("group");
101
+ const labelledBy = banner.getAttribute("aria-labelledby");
102
+ expect(labelledBy).not.toBeNull();
103
+ expect(document.getElementById(labelledBy ?? "")?.textContent).toContain("Raw data");
104
+ });
90
105
  });
91
106
 
92
107
  describe("Field", () => {
@@ -893,6 +908,16 @@ describe("DataTable", () => {
893
908
  expect(screen.getByTestId("cell-r1-y2026").getAttribute("data-highlighted")).toBe("true");
894
909
  expect(screen.getByTestId("cell-r1-name").getAttribute("data-highlighted")).toBeNull();
895
910
  });
911
+
912
+ test("marks the header of a highlighted sortable column too", () => {
913
+ const sortableCols = [
914
+ { field: "name", label: "Name", type: "string", sortable: false },
915
+ { field: "y2026", label: "2026", type: "number", sortable: true, highlighted: true },
916
+ ] as const;
917
+ render(<DataTable columns={sortableCols} rows={oneRow} onSortChange={mock()} />);
918
+ expect(screen.getByTestId("column-y2026").getAttribute("data-highlighted")).toBe("true");
919
+ expect(screen.getByTestId("column-name").getAttribute("data-highlighted")).toBeNull();
920
+ });
896
921
  });
897
922
 
898
923
  describe("testId overrides", () => {
@@ -1052,7 +1077,10 @@ describe("Form", () => {
1052
1077
  <div>content</div>
1053
1078
  </Form>,
1054
1079
  );
1055
- expect(screen.getByTestId("form-actions").className).toContain("max-sm:fixed");
1080
+ const actionsFooter = screen.getByTestId("form-actions");
1081
+ expect(actionsFooter.className).toContain("max-sm:fixed");
1082
+ const contentContainer = actionsFooter.previousElementSibling as HTMLElement;
1083
+ expect(contentContainer.className).toContain("max-sm:pb-24");
1056
1084
  });
1057
1085
 
1058
1086
  test("ohne stickyActions: Footer bleibt im normalen Dokumentfluss", () => {
@@ -1061,7 +1089,10 @@ describe("Form", () => {
1061
1089
  <div>content</div>
1062
1090
  </Form>,
1063
1091
  );
1064
- expect(screen.getByTestId("form-actions").className).not.toContain("max-sm:fixed");
1092
+ const actionsFooter = screen.getByTestId("form-actions");
1093
+ expect(actionsFooter.className).not.toContain("max-sm:fixed");
1094
+ const contentContainer = actionsFooter.previousElementSibling as HTMLElement;
1095
+ expect(contentContainer.className).not.toContain("max-sm:pb-24");
1065
1096
  });
1066
1097
  });
1067
1098