@cosmicdrift/kumiko-renderer-web 1.0.0 → 2.0.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 (79) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/action-menu.test.tsx +75 -0
  3. package/src/__tests__/breadcrumb.test.tsx +50 -0
  4. package/src/__tests__/create-app.test.tsx +171 -1
  5. package/src/__tests__/dashboard-screen.test.tsx +393 -0
  6. package/src/__tests__/default-app-shell.test.tsx +47 -1
  7. package/src/__tests__/form-action-bar.test.tsx +9 -0
  8. package/src/__tests__/kumiko-screen.test.tsx +155 -1
  9. package/src/__tests__/lightbox.test.tsx +46 -0
  10. package/src/__tests__/nav-tree.test.tsx +122 -0
  11. package/src/__tests__/primitives.test.tsx +32 -1
  12. package/src/__tests__/profile-menu.test.tsx +29 -0
  13. package/src/__tests__/projection-detail.test.tsx +101 -0
  14. package/src/__tests__/projection-list-actions.test.tsx +131 -0
  15. package/src/__tests__/render-list.test.tsx +11 -3
  16. package/src/__tests__/test-utils.tsx +1 -0
  17. package/src/__tests__/toast.test.tsx +9 -9
  18. package/src/__tests__/use-mutation.test.tsx +86 -0
  19. package/src/__tests__/workspace-shell.test.tsx +102 -47
  20. package/src/__tests__/xss-safety.test.tsx +62 -0
  21. package/src/app/__tests__/first-open-screen-qn.test.ts +77 -0
  22. package/src/app/browser-locale.ts +1 -1
  23. package/src/app/create-app.tsx +96 -29
  24. package/src/app/dashboard-body.tsx +454 -0
  25. package/src/index.ts +75 -1
  26. package/src/layout/__tests__/nav-registry.test.ts +105 -0
  27. package/src/layout/__tests__/shell-breadcrumb.test.ts +82 -0
  28. package/src/layout/default-app-shell.tsx +9 -71
  29. package/src/layout/nav-tree.tsx +167 -37
  30. package/src/layout/shell-breadcrumb.ts +46 -0
  31. package/src/layout/shell-header.tsx +114 -0
  32. package/src/layout/workspace-shell.tsx +45 -18
  33. package/src/primitives/__tests__/data-table-logic.test.ts +27 -0
  34. package/src/primitives/__tests__/input-testid.test.tsx +45 -0
  35. package/src/primitives/__tests__/money-input.test.tsx +13 -1
  36. package/src/primitives/dialog.tsx +47 -76
  37. package/src/primitives/index.tsx +215 -37
  38. package/src/primitives/layout.tsx +39 -0
  39. package/src/primitives/lightbox.tsx +39 -0
  40. package/src/primitives/modal-shell.tsx +62 -0
  41. package/src/primitives/money-input.tsx +20 -10
  42. package/src/primitives/toast.tsx +14 -9
  43. package/src/sse/live-events.ts +6 -1
  44. package/src/styles.css +25 -0
  45. package/src/tokens.ts +4 -0
  46. package/src/ui/__tests__/avatar.test.tsx +66 -0
  47. package/src/ui/__tests__/sheet.test.tsx +102 -0
  48. package/src/version/__tests__/update-checker.test.tsx +115 -0
  49. package/src/widgets/__tests__/ai-text-field.test.tsx +267 -0
  50. package/src/widgets/__tests__/drawer.test.tsx +35 -0
  51. package/src/widgets/__tests__/form-kit.test.tsx +368 -0
  52. package/src/widgets/__tests__/infinity-list.test.tsx +132 -0
  53. package/src/widgets/__tests__/query-table.test.tsx +118 -0
  54. package/src/widgets/__tests__/result-panel.test.tsx +117 -0
  55. package/src/widgets/__tests__/widgets.test.tsx +243 -0
  56. package/src/widgets/ai-text-field.tsx +370 -0
  57. package/src/widgets/charts.tsx +228 -0
  58. package/src/widgets/collapsible-section.tsx +42 -0
  59. package/src/widgets/detail-list.tsx +43 -0
  60. package/src/widgets/drawer.tsx +49 -0
  61. package/src/widgets/feed-list.tsx +35 -0
  62. package/src/widgets/form-fields.tsx +326 -0
  63. package/src/widgets/index.ts +59 -0
  64. package/src/widgets/infinity-list.tsx +112 -0
  65. package/src/widgets/mode-switch.tsx +41 -0
  66. package/src/widgets/progress-bar.tsx +27 -0
  67. package/src/widgets/progress-list.tsx +38 -0
  68. package/src/widgets/query-table.tsx +103 -0
  69. package/src/widgets/result-panel.tsx +251 -0
  70. package/src/widgets/section-card.tsx +30 -0
  71. package/src/widgets/stat.tsx +181 -0
  72. package/src/widgets/states.tsx +89 -0
  73. package/src/widgets/status-badge.tsx +51 -0
  74. package/src/widgets/use-draft.ts +40 -0
  75. package/src/ui/card.tsx +0 -93
  76. package/src/ui/collapsible.tsx +0 -34
  77. package/src/ui/dropdown-menu.tsx +0 -258
  78. package/src/ui/select.tsx +0 -191
  79. /package/src/__tests__/{download.test.ts → download.test.tsx} +0 -0
@@ -0,0 +1,82 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
3
+ import { resolveDetailBreadcrumb } from "../shell-breadcrumb";
4
+
5
+ const t = (key: string): string => key;
6
+
7
+ describe("resolveDetailBreadcrumb", () => {
8
+ test("entityEdit pairs with entityList on same entity", () => {
9
+ const screens: ScreenDefinition[] = [
10
+ {
11
+ id: "user-list",
12
+ type: "entityList",
13
+ entity: "user",
14
+ columns: ["email"],
15
+ rowActions: [],
16
+ },
17
+ {
18
+ id: "user-edit",
19
+ type: "entityEdit",
20
+ entity: "user",
21
+ layout: { sections: [{ fields: ["email"] }] },
22
+ },
23
+ ];
24
+ expect(resolveDetailBreadcrumb(screens, "user-edit", t)).toEqual([
25
+ { label: "screen:user-list.title", screenId: "user-list" },
26
+ { label: "screen:user-edit.title" },
27
+ ]);
28
+ });
29
+
30
+ test("entityList navigate rowAction links to detail screen", () => {
31
+ const screens: ScreenDefinition[] = [
32
+ {
33
+ id: "export-job-list",
34
+ type: "entityList",
35
+ entity: "export-job",
36
+ columns: ["status"],
37
+ rowActions: [
38
+ {
39
+ kind: "navigate",
40
+ id: "view",
41
+ label: "kumiko.actions.view",
42
+ screen: "export-job-detail",
43
+ entityId: "id",
44
+ },
45
+ ],
46
+ },
47
+ {
48
+ id: "export-job-detail",
49
+ type: "entityEdit",
50
+ entity: "export-job",
51
+ layout: { sections: [{ fields: ["status"] }] },
52
+ },
53
+ ];
54
+ expect(resolveDetailBreadcrumb(screens, "export-job-detail", t)?.[0]?.screenId).toBe(
55
+ "export-job-list",
56
+ );
57
+ });
58
+
59
+ test("custom detail uses listScreenId", () => {
60
+ const screens: ScreenDefinition[] = [
61
+ {
62
+ id: "sysadmin-users",
63
+ type: "custom",
64
+ renderer: { react: { __component: "SysadminUsersScreen" } },
65
+ },
66
+ {
67
+ id: "sysadmin-user-detail",
68
+ type: "custom",
69
+ listScreenId: "sysadmin-users",
70
+ renderer: { react: { __component: "SysadminUserDetailScreen" } },
71
+ },
72
+ ];
73
+ expect(resolveDetailBreadcrumb(screens, "sysadmin-user-detail", t)).toEqual([
74
+ { label: "screen:sysadmin-users.title", screenId: "sysadmin-users" },
75
+ { label: "screen:sysadmin-user-detail.title" },
76
+ ]);
77
+ });
78
+
79
+ test("unknown screen returns undefined", () => {
80
+ expect(resolveDetailBreadcrumb([], "missing", t)).toBeUndefined();
81
+ });
82
+ });
@@ -14,13 +14,12 @@
14
14
  // Apps die feingranulare Kontrolle wollen, gehen direkt auf die ui/-Teile.
15
15
  // Wer ein Topbar mit Workspace-Switcher braucht, nutzt <WorkspaceShell>.
16
16
 
17
- import type { NavNode } from "@cosmicdrift/kumiko-headless";
18
- import { resolveNavigation } from "@cosmicdrift/kumiko-headless";
19
- import type { AppSchema, FeatureSchema } from "@cosmicdrift/kumiko-renderer";
20
- import { toAppSchema, useNav, useTranslation } from "@cosmicdrift/kumiko-renderer";
21
- import { type ReactNode, useMemo } from "react";
22
- import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from "../ui/breadcrumb";
23
- import { Separator } from "../ui/separator";
17
+ import {
18
+ type AppSchema,
19
+ type FeatureSchema,
20
+ UserRolesProvider,
21
+ } from "@cosmicdrift/kumiko-renderer";
22
+ import type { ReactNode } from "react";
24
23
  import {
25
24
  Sidebar,
26
25
  SidebarContent,
@@ -30,9 +29,9 @@ import {
30
29
  SidebarInset,
31
30
  SidebarProvider,
32
31
  SidebarRail,
33
- SidebarTrigger,
34
32
  } from "../ui/sidebar";
35
- import { buildNavRegistrySliceForApp, lastSegment, NavTree } from "./nav-tree";
33
+ import { NavTree } from "./nav-tree";
34
+ import { ShellHeader } from "./shell-header";
36
35
 
37
36
  export type DefaultAppShellUser = {
38
37
  readonly id: string;
@@ -117,70 +116,9 @@ export function DefaultAppShell({
117
116
  {...(headerActions !== undefined && { headerActions })}
118
117
  />
119
118
  <main className={fill === true ? "min-h-0 flex-1 overflow-auto" : "flex-1 overflow-auto"}>
120
- {children}
119
+ <UserRolesProvider roles={user?.roles}>{children}</UserRolesProvider>
121
120
  </main>
122
121
  </SidebarInset>
123
122
  </SidebarProvider>
124
123
  );
125
124
  }
126
-
127
- // Inset-Header: hostet den SidebarTrigger (öffnet das Mobile-Sheet / toggled
128
- // die Rail) und einen Breadcrumb mit dem aktiven Screen. Höhe == Sidebar-
129
- // Header (h-12), damit Rail und Panel oben bündig sind.
130
- function ShellHeader({
131
- schema,
132
- user,
133
- headerActions,
134
- }: {
135
- readonly schema: AppSchema | FeatureSchema;
136
- readonly user?: DefaultAppShellUser;
137
- readonly headerActions?: ReactNode;
138
- }): ReactNode {
139
- const nav = useNav();
140
- const t = useTranslation();
141
- const tree = useMemo(() => {
142
- const source = buildNavRegistrySliceForApp(toAppSchema(schema));
143
- return resolveNavigation({ source, ...(user !== undefined && { user }) });
144
- }, [schema, user]);
145
-
146
- const screenId = nav.route?.screenId;
147
- const label = screenId !== undefined ? activeNavLabel(tree, screenId, (k) => t(k)) : undefined;
148
-
149
- return (
150
- <header className="flex h-16 shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
151
- <div className="flex items-center gap-2 px-4">
152
- <SidebarTrigger className="-ml-1" />
153
- <Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
154
- {label !== undefined && (
155
- <Breadcrumb>
156
- <BreadcrumbList>
157
- <BreadcrumbItem>
158
- <BreadcrumbPage>{label}</BreadcrumbPage>
159
- </BreadcrumbItem>
160
- </BreadcrumbList>
161
- </Breadcrumb>
162
- )}
163
- </div>
164
- {headerActions !== undefined && (
165
- <div data-kumiko-layout="header-actions" className="ml-auto flex items-center gap-2 px-4">
166
- {headerActions}
167
- </div>
168
- )}
169
- </header>
170
- );
171
- }
172
-
173
- function activeNavLabel(
174
- nodes: readonly NavNode[],
175
- screenId: string,
176
- t: (key: string) => string,
177
- ): string | undefined {
178
- for (const node of nodes) {
179
- if (node.screen !== undefined && lastSegment(node.screen) === screenId) {
180
- return node.label.includes(".") ? t(node.label) : node.label;
181
- }
182
- const child = activeNavLabel(node.children, screenId, t);
183
- if (child !== undefined) return child;
184
- }
185
- return undefined;
186
- }
@@ -11,7 +11,12 @@
11
11
  // auf/zu. State lebt lokal im NavTree (useState); Default expanded
12
12
  // für alles, Caller kann später localStorage-Persistenz drüberlegen.
13
13
 
14
- import type { TargetRef, TreeAction, TreeNode } from "@cosmicdrift/kumiko-framework/engine";
14
+ import type {
15
+ AccessRule,
16
+ TargetRef,
17
+ TreeAction,
18
+ TreeNode,
19
+ } from "@cosmicdrift/kumiko-framework/engine";
15
20
  import type { NavDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
16
21
  import type { NavNode, NavRegistrySlice } from "@cosmicdrift/kumiko-headless";
17
22
  import { resolveNavigation } from "@cosmicdrift/kumiko-headless";
@@ -33,22 +38,34 @@ import {
33
38
  ChevronRight,
34
39
  Coins,
35
40
  CreditCard,
41
+ Download,
36
42
  FileText,
37
43
  Folder,
44
+ FolderOpen,
38
45
  Gauge,
46
+ Hash,
39
47
  Home,
48
+ KeyRound,
40
49
  Layers,
41
50
  LayoutDashboard,
42
51
  LineChart,
52
+ Link,
43
53
  List,
54
+ Lock,
55
+ Mail,
56
+ Palette,
44
57
  PiggyBank,
45
58
  Plus,
46
59
  Receipt,
60
+ Rocket,
47
61
  Search,
62
+ Server,
48
63
  Settings,
64
+ Share2,
49
65
  Shield,
50
66
  Sparkles,
51
67
  Table,
68
+ Tag,
52
69
  TrendingUp,
53
70
  User,
54
71
  Users,
@@ -71,6 +88,7 @@ import {
71
88
  SidebarGroup,
72
89
  SidebarGroupContent,
73
90
  SidebarGroupLabel,
91
+ SidebarInput,
74
92
  SidebarMenu,
75
93
  SidebarMenuAction,
76
94
  SidebarMenuButton,
@@ -108,6 +126,7 @@ const NAV_ICONS: Readonly<Record<string, typeof Folder>> = {
108
126
  calendar: CalendarDays,
109
127
  file: FileText,
110
128
  folder: Folder,
129
+ "folder-open": FolderOpen,
111
130
  home: Home,
112
131
  bell: Bell,
113
132
  shield: Shield,
@@ -115,6 +134,17 @@ const NAV_ICONS: Readonly<Record<string, typeof Folder>> = {
115
134
  users: Users,
116
135
  user: User,
117
136
  search: Search,
137
+ tag: Tag,
138
+ key: KeyRound,
139
+ link: Link,
140
+ palette: Palette,
141
+ share: Share2,
142
+ server: Server,
143
+ mail: Mail,
144
+ lock: Lock,
145
+ hash: Hash,
146
+ download: Download,
147
+ rocket: Rocket,
118
148
  };
119
149
 
120
150
  export type NavTreeProps = {
@@ -139,6 +169,22 @@ export type NavTreeProps = {
139
169
  const EMPTY_BADGES: ReadonlyMap<string, ReactNode> = new Map();
140
170
  const NavBadgesContext = createContext<ReadonlyMap<string, ReactNode>>(EMPTY_BADGES);
141
171
 
172
+ // Sidebar-Filter: `q` leer = inaktiv (matches akzeptiert alles). `matches`
173
+ // bekommt das ROHE Label (i18n-Key oder Klartext) und übersetzt intern — so
174
+ // filtert das Suchfeld auf den sichtbaren Text, nicht den Key.
175
+ type NavFilter = { readonly q: string; readonly matches: (rawLabel: string) => boolean };
176
+ const NavFilterContext = createContext<NavFilter>({ q: "", matches: () => true });
177
+
178
+ // Ein Knoten überlebt den Filter, wenn er selbst oder ein Nachfahre matcht.
179
+ // Provider-Kinder sind zur Filterzeit schon materialisiert (Provider-Knoten
180
+ // sind default-expanded → eager geladen), darum reicht die statische
181
+ // children-Rekursion. ponytail: ein zur Filterzeit noch NICHT geladener
182
+ // (user-zugeklappter) Provider wird über den force-expand in useNavNodeState
183
+ // aufgeklappt, lädt und filtert dann mit.
184
+ function subtreeMatches(node: NavNode, matches: (rawLabel: string) => boolean): boolean {
185
+ return matches(node.label) || node.children.some((c) => subtreeMatches(c, matches));
186
+ }
187
+
142
188
  export function NavTree({
143
189
  schema,
144
190
  user,
@@ -165,25 +211,47 @@ export function NavTree({
165
211
  });
166
212
  }, []);
167
213
 
214
+ const t = useTranslation();
215
+ const [filter, setFilter] = useState("");
216
+ const q = filter.trim().toLowerCase();
217
+ const matches = useCallback(
218
+ (raw: string): boolean => {
219
+ const label = raw.includes(".") ? t(raw) : raw;
220
+ return label.toLowerCase().includes(q);
221
+ },
222
+ [t, q],
223
+ );
224
+ const navFilter = useMemo<NavFilter>(() => ({ q, matches }), [q, matches]);
225
+
168
226
  return (
169
- <NavBadgesContext.Provider value={navBadges ?? EMPTY_BADGES}>
170
- <div data-testid={testId} data-kumiko-layout="nav-tree" className="flex w-full flex-col">
171
- {tree.map((node) =>
172
- isPureSection(node) ? (
173
- <NavSection
174
- key={node.qualifiedName}
175
- node={node}
176
- collapsed={collapsed}
177
- onToggle={onToggle}
227
+ <NavFilterContext.Provider value={navFilter}>
228
+ <NavBadgesContext.Provider value={navBadges ?? EMPTY_BADGES}>
229
+ <div data-testid={testId} data-kumiko-layout="nav-tree" className="flex w-full flex-col">
230
+ <div className="px-2 pt-2 pb-1">
231
+ <SidebarInput
232
+ value={filter}
233
+ onChange={(e) => setFilter(e.target.value)}
234
+ placeholder={t("kumiko.nav.search")}
235
+ aria-label={t("kumiko.nav.search")}
178
236
  />
179
- ) : (
180
- <SidebarMenu key={node.qualifiedName} className="px-2 py-1">
181
- <NavMenuNode node={node} collapsed={collapsed} onToggle={onToggle} />
182
- </SidebarMenu>
183
- ),
184
- )}
185
- </div>
186
- </NavBadgesContext.Provider>
237
+ </div>
238
+ {tree.map((node) =>
239
+ isPureSection(node) ? (
240
+ <NavSection
241
+ key={node.qualifiedName}
242
+ node={node}
243
+ collapsed={collapsed}
244
+ onToggle={onToggle}
245
+ />
246
+ ) : (
247
+ <SidebarMenu key={node.qualifiedName} className="px-2 py-1">
248
+ <NavMenuNode node={node} collapsed={collapsed} onToggle={onToggle} />
249
+ </SidebarMenu>
250
+ ),
251
+ )}
252
+ </div>
253
+ </NavBadgesContext.Provider>
254
+ </NavFilterContext.Provider>
187
255
  );
188
256
  }
189
257
 
@@ -192,8 +260,14 @@ export function NavTree({
192
260
  // schiebt ihn ans rechte Ende; das truncate-Label gibt nach.
193
261
  // ponytail: nur an screen/target-Leaves verdrahtet, nicht an Container-
194
262
  // Knoten (die tragen den ml-auto-Chevron) — Tier-Badge ist ein Leaf.
263
+ // Defense-in-depth (555/4): every current call site already guards on
264
+ // node.screen/node.target !== undefined before rendering <NavBadge>, so this
265
+ // early-return changes no current behavior — it's here so a future call
266
+ // site added without that guard can't silently show a leaf's badge on an
267
+ // unrelated container node that happens to share its last-segment.
195
268
  function NavBadge({ node }: { readonly node: NavNode }): ReactNode {
196
269
  const badges = useContext(NavBadgesContext);
270
+ if (node.screen === undefined && node.target === undefined) return null;
197
271
  const badge = badges.get(lastSegment(node.qualifiedName));
198
272
  if (badge === undefined) return null;
199
273
  return <span className="ml-auto shrink-0">{badge}</span>;
@@ -229,8 +303,17 @@ function useLabel(node: NavNode): string {
229
303
 
230
304
  // Icon-or-Dot: bekannter icon-Key → Lucide-Icon, sonst ein dezenter Dot.
231
305
  // Aktiv = accent-foreground, inaktiv = gedimmt.
232
- function NavLeadingIcon({ node, active }: { node: NavNode; active: boolean }): ReactNode {
233
- const NavIcon = node.icon !== undefined ? NAV_ICONS[node.icon] : undefined;
306
+ function NavLeadingIcon({
307
+ node,
308
+ active,
309
+ expanded = false,
310
+ }: {
311
+ node: NavNode;
312
+ active: boolean;
313
+ expanded?: boolean;
314
+ }): ReactNode {
315
+ const iconKey = expanded && node.icon === "folder" ? "folder-open" : node.icon;
316
+ const NavIcon = iconKey !== undefined ? NAV_ICONS[iconKey] : undefined;
234
317
  if (NavIcon !== undefined) return <NavIcon aria-hidden="true" className="shrink-0" />;
235
318
  return (
236
319
  <span
@@ -350,6 +433,7 @@ type NavNodeState = {
350
433
  readonly isExpanded: boolean;
351
434
  readonly active: boolean;
352
435
  readonly childNodes: readonly NavNode[];
436
+ readonly hidden: boolean;
353
437
  readonly providerLoading: boolean;
354
438
  readonly providerError: string | null;
355
439
  readonly workspaceId: string | undefined;
@@ -361,10 +445,14 @@ type NavNodeState = {
361
445
  // in den shadcn-Primitives, nicht in dieser Logik.
362
446
  function useNavNodeState(node: NavNode, collapsed: ReadonlySet<string>): NavNodeState {
363
447
  const nav = useNav();
448
+ const { q: filterQ, matches } = useContext(NavFilterContext);
449
+ const filterActive = filterQ !== "";
364
450
  const displayLabel = useLabel(node);
365
451
  const isCollapsed = collapsed.has(node.qualifiedName);
366
452
  const expandable = node.children.length > 0 || node.provider === true;
367
- const isExpanded = expandable && !isCollapsed;
453
+ // Aktiver Filter klappt jeden Container auf, damit Treffer unter zugeklappten
454
+ // Ordnern sichtbar werden (und Provider-Kinder nachladen).
455
+ const isExpanded = expandable && (!isCollapsed || filterActive);
368
456
  const { nodes: providerChildren, error: providerError } = useNavProviderChildren(
369
457
  node.qualifiedName,
370
458
  node.provider === true && isExpanded,
@@ -377,13 +465,19 @@ function useNavNodeState(node: NavNode, collapsed: ReadonlySet<string>): NavNode
377
465
  node.screen !== undefined && nav.route?.screenId === lastSegment(node.screen);
378
466
  const targetActive = node.target !== undefined && targetsEqual(node.target, activeTarget);
379
467
  const childNodes = node.provider === true ? (providerChildren ?? []) : node.children;
468
+ const visibleChildNodes = filterActive
469
+ ? childNodes.filter((c) => subtreeMatches(c, matches))
470
+ : childNodes;
380
471
  return {
381
472
  displayLabel,
382
473
  isCollapsed,
383
474
  expandable,
384
475
  isExpanded,
385
476
  active: screenActive || targetActive,
386
- childNodes,
477
+ childNodes: visibleChildNodes,
478
+ // Ein gefilterter Knoten verschwindet nur, wenn weder er selbst noch ein
479
+ // sichtbar gebliebenes Kind matcht — sonst bliebe ein leerer Ordner-Header.
480
+ hidden: filterActive && !matches(node.label) && visibleChildNodes.length === 0,
387
481
  providerLoading: node.provider === true && isExpanded && providerChildren === null,
388
482
  providerError,
389
483
  workspaceId: nav.route?.workspaceId,
@@ -472,6 +566,16 @@ function ProviderStatus({
472
566
  // gehört auf Items MIT children, nicht auf die Section selbst.
473
567
  function NavSection({ node, collapsed, onToggle }: NavSubProps): ReactNode {
474
568
  const displayLabel = useLabel(node);
569
+ const { q, matches } = useContext(NavFilterContext);
570
+ // Provider-Kinder (statisch leer bis geladen) halten die Section offen — ihr
571
+ // Match lässt sich hier nicht statisch prüfen; der Knoten selbst self-hidet.
572
+ if (
573
+ q !== "" &&
574
+ !matches(node.label) &&
575
+ !node.children.some((c) => c.provider === true || subtreeMatches(c, matches))
576
+ ) {
577
+ return null;
578
+ }
475
579
  return (
476
580
  <SidebarGroup className="py-1">
477
581
  <SidebarGroupLabel>{displayLabel}</SidebarGroupLabel>
@@ -502,6 +606,7 @@ function NavMenuNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
502
606
  const t = useTranslation();
503
607
  const dispatch = useDispatchTarget();
504
608
  const s = useNavNodeState(node, collapsed);
609
+ if (s.hidden) return null;
505
610
 
506
611
  const sub = s.isExpanded ? (
507
612
  <SidebarMenuSub>
@@ -519,11 +624,11 @@ function NavMenuNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
519
624
 
520
625
  const chevron = s.expandable ? (
521
626
  <SidebarMenuAction
522
- aria-label={t(s.isCollapsed ? "kumiko.nav.expand" : "kumiko.nav.collapse")}
523
- aria-expanded={!s.isCollapsed}
627
+ aria-label={t(s.isExpanded ? "kumiko.nav.collapse" : "kumiko.nav.expand")}
628
+ aria-expanded={s.isExpanded}
524
629
  onClick={() => onToggle(node.qualifiedName)}
525
630
  >
526
- {s.isCollapsed ? <ChevronRight /> : <ChevronDown />}
631
+ {s.isExpanded ? <ChevronDown /> : <ChevronRight />}
527
632
  </SidebarMenuAction>
528
633
  ) : null;
529
634
 
@@ -575,15 +680,15 @@ function NavMenuNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
575
680
  onClick={() => {
576
681
  if (s.expandable) onToggle(node.qualifiedName);
577
682
  }}
578
- {...(s.expandable && { "aria-expanded": !s.isCollapsed })}
683
+ {...(s.expandable && { "aria-expanded": s.isExpanded })}
579
684
  >
580
- <NavLeadingIcon node={node} active={false} />
685
+ <NavLeadingIcon node={node} active={false} expanded={s.isExpanded} />
581
686
  <span className="truncate">{s.displayLabel}</span>
582
687
  {s.expandable &&
583
- (s.isCollapsed ? (
584
- <ChevronRight className="ml-auto" />
585
- ) : (
688
+ (s.isExpanded ? (
586
689
  <ChevronDown className="ml-auto" />
690
+ ) : (
691
+ <ChevronRight className="ml-auto" />
587
692
  ))}
588
693
  </SidebarMenuButton>
589
694
  <NodeActions node={node} />
@@ -601,9 +706,14 @@ function NavSubNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
601
706
  const t = useTranslation();
602
707
  const dispatch = useDispatchTarget();
603
708
  const s = useNavNodeState(node, collapsed);
709
+ if (s.hidden) return null;
604
710
 
711
+ // Kinder in ein eigenes SidebarMenuSub (<ul>) — ein Sub-Item ist ein <li>,
712
+ // verschachtelte <li> ohne <ul> dazwischen sind invalides HTML (nested
713
+ // Content-Ordner: Marketing/ > Block). Der <ul> trägt zugleich die
714
+ // Einrück-/Border-Stufe pro Tiefe.
605
715
  const deeper = s.isExpanded ? (
606
- <>
716
+ <SidebarMenuSub>
607
717
  <ProviderStatus loading={s.providerLoading} error={s.providerError} />
608
718
  {s.childNodes.map((child) => (
609
719
  <NavSubNode
@@ -613,21 +723,21 @@ function NavSubNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
613
723
  onToggle={onToggle}
614
724
  />
615
725
  ))}
616
- </>
726
+ </SidebarMenuSub>
617
727
  ) : null;
618
728
 
619
729
  const chevron = s.expandable ? (
620
730
  <button
621
731
  type="button"
622
- aria-label={t(s.isCollapsed ? "kumiko.nav.expand" : "kumiko.nav.collapse")}
623
- aria-expanded={!s.isCollapsed}
732
+ aria-label={t(s.isExpanded ? "kumiko.nav.collapse" : "kumiko.nav.expand")}
733
+ aria-expanded={s.isExpanded}
624
734
  onClick={(e) => {
625
735
  e.stopPropagation();
626
736
  onToggle(node.qualifiedName);
627
737
  }}
628
738
  className="absolute top-1 right-1 flex size-5 items-center justify-center rounded-md text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
629
739
  >
630
- {s.isCollapsed ? <ChevronRight className="size-3.5" /> : <ChevronDown className="size-3.5" />}
740
+ {s.isExpanded ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
631
741
  </button>
632
742
  ) : null;
633
743
 
@@ -679,7 +789,7 @@ function NavSubNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
679
789
  if (s.expandable) onToggle(node.qualifiedName);
680
790
  }}
681
791
  >
682
- <NavLeadingIcon node={node} active={false} />
792
+ <NavLeadingIcon node={node} active={false} expanded={s.isExpanded} />
683
793
  <span className="truncate">{s.displayLabel}</span>
684
794
  </button>
685
795
  </SidebarMenuSubButton>
@@ -711,14 +821,24 @@ export function buildNavRegistrySliceForApp(
711
821
  app: AppSchema,
712
822
  allowedNavQns?: ReadonlySet<string>,
713
823
  ): NavRegistrySlice {
824
+ const screenAccessByQn = buildScreenAccessMap(app);
714
825
  const qualified: NavDefinition[] = [];
715
826
  for (const feature of app.features) {
716
827
  for (const n of feature.navs ?? []) {
828
+ const screen =
829
+ n.screen !== undefined ? qualifyScreenId(feature.featureName, n.screen) : undefined;
830
+ // Nav entries without their own `access` inherit the referenced
831
+ // screen's access rule — otherwise a broader workspace/parent access
832
+ // lets a role see a nav entry whose target screen 403s them (#1099).
833
+ // An explicit `n.access` always wins; inheritance only fills the gap.
834
+ const inheritedAccess =
835
+ n.access ?? (screen !== undefined ? screenAccessByQn.get(screen) : undefined);
717
836
  qualified.push({
718
837
  ...n,
719
838
  id: qualifyNavId(feature.featureName, n.id),
720
839
  ...(n.parent !== undefined && { parent: qualifyNavId(feature.featureName, n.parent) }),
721
- ...(n.screen !== undefined && { screen: qualifyScreenId(feature.featureName, n.screen) }),
840
+ ...(screen !== undefined && { screen }),
841
+ ...(inheritedAccess !== undefined && { access: inheritedAccess }),
722
842
  });
723
843
  }
724
844
  }
@@ -758,6 +878,16 @@ function qualifyScreenId(feature: string, id: string): string {
758
878
  return id.includes(":screen:") ? id : `${feature}:screen:${id}`;
759
879
  }
760
880
 
881
+ function buildScreenAccessMap(app: AppSchema): Map<string, AccessRule | undefined> {
882
+ const map = new Map<string, AccessRule | undefined>();
883
+ for (const feature of app.features) {
884
+ for (const s of feature.screens) {
885
+ map.set(qualifyScreenId(feature.featureName, s.id), s.access);
886
+ }
887
+ }
888
+ return map;
889
+ }
890
+
761
891
  // `lastSegment` lebt jetzt in @cosmicdrift/kumiko-renderer (./app/qn) — eine
762
892
  // Quelle, beide Pakete teilen sie.
763
893
  export { lastSegment };
@@ -0,0 +1,46 @@
1
+ import type { ScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import { lastSegment } from "./nav-tree";
3
+
4
+ export type BreadcrumbCrumb = {
5
+ readonly label: string;
6
+ readonly screenId?: string;
7
+ };
8
+
9
+ export function screenTitleKey(screenShortId: string): string {
10
+ return `screen:${screenShortId}.title`;
11
+ }
12
+
13
+ export function resolveDetailBreadcrumb(
14
+ screens: readonly ScreenDefinition[],
15
+ detailScreenId: string,
16
+ t: (key: string) => string,
17
+ ): readonly BreadcrumbCrumb[] | undefined {
18
+ const detail = screens.find((s) => lastSegment(s.id) === detailScreenId);
19
+ if (detail === undefined) return undefined;
20
+
21
+ const listFromRowAction = screens.find((s) => {
22
+ if (s.type !== "entityList") return false;
23
+ return (s.rowActions ?? []).some((a) => a.kind === "navigate" && a.screen === detailScreenId);
24
+ });
25
+
26
+ const listFromEntity =
27
+ detail.type === "entityEdit"
28
+ ? screens.find((s) => s.type === "entityList" && s.entity === detail.entity)
29
+ : undefined;
30
+
31
+ const listFromCustomParent =
32
+ detail.type === "custom" && detail.listScreenId !== undefined
33
+ ? screens.find((s) => lastSegment(s.id) === detail.listScreenId)
34
+ : undefined;
35
+
36
+ const list = listFromRowAction ?? listFromEntity ?? listFromCustomParent;
37
+ if (list === undefined) {
38
+ return [{ label: t(screenTitleKey(detailScreenId)) }];
39
+ }
40
+
41
+ const listId = lastSegment(list.id);
42
+ return [
43
+ { label: t(screenTitleKey(listId)), screenId: listId },
44
+ { label: t(screenTitleKey(detailScreenId)) },
45
+ ];
46
+ }