@cosmicdrift/kumiko-renderer-web 0.1.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 (58) hide show
  1. package/package.json +63 -0
  2. package/src/__tests__/avatar.test.tsx +34 -0
  3. package/src/__tests__/combobox.test.tsx +240 -0
  4. package/src/__tests__/config-edit.test.tsx +172 -0
  5. package/src/__tests__/create-app.test.tsx +261 -0
  6. package/src/__tests__/date-input.test.tsx +91 -0
  7. package/src/__tests__/default-app-shell.test.tsx +60 -0
  8. package/src/__tests__/dispatcher-context.test.tsx +101 -0
  9. package/src/__tests__/dispatcher-status-wiring.test.tsx +119 -0
  10. package/src/__tests__/kumiko-screen.test.tsx +1014 -0
  11. package/src/__tests__/language-switcher.test.tsx +100 -0
  12. package/src/__tests__/money-input.test.tsx +232 -0
  13. package/src/__tests__/nav-base-path.test.tsx +388 -0
  14. package/src/__tests__/nav-search-params.test.tsx +88 -0
  15. package/src/__tests__/nav-tree.test.tsx +183 -0
  16. package/src/__tests__/nav.test.tsx +253 -0
  17. package/src/__tests__/primitives.test.tsx +936 -0
  18. package/src/__tests__/render-edit.test.tsx +178 -0
  19. package/src/__tests__/render-list-column-renderer.test.tsx +124 -0
  20. package/src/__tests__/render-list-debounce.test.tsx +128 -0
  21. package/src/__tests__/render-list.test.tsx +151 -0
  22. package/src/__tests__/sidebar.test.tsx +59 -0
  23. package/src/__tests__/test-utils.tsx +144 -0
  24. package/src/__tests__/theme-toggle.test.tsx +101 -0
  25. package/src/__tests__/toast.test.tsx +162 -0
  26. package/src/__tests__/use-form.test.tsx +112 -0
  27. package/src/__tests__/use-query-live.test.tsx +152 -0
  28. package/src/__tests__/use-query.test.tsx +88 -0
  29. package/src/__tests__/use-store.test.tsx +139 -0
  30. package/src/__tests__/workspace-shell.test.tsx +772 -0
  31. package/src/app/browser-locale.ts +85 -0
  32. package/src/app/client-plugin.tsx +63 -0
  33. package/src/app/create-app.tsx +380 -0
  34. package/src/app/nav.tsx +226 -0
  35. package/src/index.ts +137 -0
  36. package/src/layout/app-layout.tsx +35 -0
  37. package/src/layout/avatar.tsx +93 -0
  38. package/src/layout/default-app-shell.tsx +74 -0
  39. package/src/layout/language-switcher.tsx +101 -0
  40. package/src/layout/nav-tree.tsx +281 -0
  41. package/src/layout/profile-menu.tsx +40 -0
  42. package/src/layout/sidebar.tsx +65 -0
  43. package/src/layout/theme-toggle.tsx +44 -0
  44. package/src/layout/topbar.tsx +22 -0
  45. package/src/layout/workspace-shell.tsx +282 -0
  46. package/src/layout/workspace-switcher.tsx +62 -0
  47. package/src/lib/cn.ts +10 -0
  48. package/src/primitives/action-menu.tsx +111 -0
  49. package/src/primitives/combobox.tsx +261 -0
  50. package/src/primitives/date-input.tsx +165 -0
  51. package/src/primitives/dialog.tsx +119 -0
  52. package/src/primitives/dropdown-menu.tsx +103 -0
  53. package/src/primitives/index.tsx +1271 -0
  54. package/src/primitives/money-input.tsx +192 -0
  55. package/src/primitives/toast.tsx +166 -0
  56. package/src/sse/live-events.ts +90 -0
  57. package/src/styles.css +113 -0
  58. package/src/tokens.ts +63 -0
@@ -0,0 +1,282 @@
1
+ // WorkspaceShell — App-shell for multi-persona apps. Renders the
2
+ // switcher in the topbar center slot, picks the active workspace from
3
+ // URL state (?w=<id>) or default, and feeds the active workspace's
4
+ // nav-membership down to NavTree as an allow-set.
5
+ //
6
+ // Apps that don't need workspaces stick with DefaultAppShell. Both
7
+ // shells live side-by-side; createKumikoApp's `shell` prop picks one.
8
+ //
9
+ // Active-workspace resolution priority:
10
+ // 1. URL workspace segment `/<workspace>/...` (user-driven, shareable)
11
+ // 2. `initialWorkspaceId` prop (caller-pinned, SSR/test)
12
+ // 3. WorkspaceDefinition with default:true (engine-validated unique)
13
+ // 4. First workspace the user has access to
14
+ //
15
+ // State lives in the URL as the single source of truth via the standard
16
+ // nav route (`useNav().route.workspaceId`). No local React state —
17
+ // tenant-switches and role refreshes heal automatically: `visible`
18
+ // recomputes, the URL id no longer matches a visible workspace, the
19
+ // resolution chain falls through to the default. Reloads / bookmarks /
20
+ // shared links keep the active workspace because it's part of the URL.
21
+ //
22
+ // The switcher's onSelect calls nav.navigate({ workspaceId, screenId })
23
+ // where screenId is the first nav-member of the target workspace, so a
24
+ // click lands on a real screen instead of an unresolved root URL.
25
+ //
26
+ // Roles gating:
27
+ // * access.openToAll → always shown
28
+ // * access.roles → shown only when user.roles ∩ access.roles ≠ ∅
29
+ // * access undefined → shown to everyone (engine convention — same
30
+ // rule that NavDefinition.access follows)
31
+
32
+ import type { AccessRule } from "@cosmicdrift/kumiko-framework/ui-types";
33
+ import type { AppSchema, FeatureSchema, WorkspaceSchema } from "@cosmicdrift/kumiko-renderer";
34
+ import { qualifyNavId, toAppSchema, useNav } from "@cosmicdrift/kumiko-renderer";
35
+ import { type ReactNode, useCallback, useLayoutEffect, useMemo } from "react";
36
+ import { AppLayout } from "./app-layout";
37
+ import { lastSegment, NavTree } from "./nav-tree";
38
+ import { Sidebar } from "./sidebar";
39
+ import { Topbar } from "./topbar";
40
+ import { WorkspaceSwitcher } from "./workspace-switcher";
41
+
42
+ export type WorkspaceShellUser = {
43
+ readonly id: string;
44
+ readonly roles: readonly string[];
45
+ };
46
+
47
+ export type WorkspaceShellProps = {
48
+ /** Branding / logo on the left side of the topbar. */
49
+ readonly brand: ReactNode;
50
+ /** Schema mit `workspaces` populated — die engine baut das aus
51
+ * registry.getAllWorkspaces() + getWorkspaceNavs(). Akzeptiert
52
+ * AppSchema (multi-feature) oder die legacy FeatureSchema (single-
53
+ * feature, workspaces inline). Ohne Workspaces fällt der Shell auf
54
+ * plain NavTree ohne Switcher zurück. */
55
+ readonly schema: AppSchema | FeatureSchema;
56
+ /** Optional topbar end-slot — TenantSwitcher / ThemeToggle / UserMenu. */
57
+ readonly topbarActions?: ReactNode;
58
+ /** Current user. Drives which workspaces appear in the switcher. */
59
+ readonly user?: WorkspaceShellUser;
60
+ /** Initial active workspace short id ("admin"). When omitted: default
61
+ * workspace from schema, otherwise first accessible. Useful for SSR
62
+ * and tests to pre-seed without hitting the URL. */
63
+ readonly initialWorkspaceId?: string;
64
+ /** Footer-Slot unten in der Sidebar — Profile-Row, Help-Link, Build-
65
+ * Info. Klebt am unteren Rand via `mt-auto` (siehe Sidebar.footer).
66
+ * Symmetrisch zu DefaultAppShell.sidebarFooter. */
67
+ readonly sidebarFooter?: ReactNode;
68
+ /** Screen content. */
69
+ readonly children: ReactNode;
70
+ };
71
+
72
+ export function WorkspaceShell({
73
+ brand,
74
+ schema,
75
+ topbarActions,
76
+ user,
77
+ initialWorkspaceId,
78
+ sidebarFooter,
79
+ children,
80
+ }: WorkspaceShellProps): ReactNode {
81
+ const app = useMemo(() => toAppSchema(schema), [schema]);
82
+ const visible = useMemo<readonly WorkspaceSchema[]>(
83
+ () => filterByAccess(app.workspaces ?? [], user?.roles),
84
+ [app.workspaces, user?.roles],
85
+ );
86
+
87
+ const nav = useNav();
88
+ const routeWorkspaceId = nav.route?.workspaceId;
89
+
90
+ // Single source of truth: URL > initial > engine-default > first visible.
91
+ // Recomputed on every dependency change — no local state, no stale-id
92
+ // healing useEffect. If the URL points at a workspace the user can no
93
+ // longer access (e.g. after a tenant-switch), the chain falls through
94
+ // to the resolveDefaultId fallback.
95
+ const activeId = useMemo(() => {
96
+ if (
97
+ routeWorkspaceId !== undefined &&
98
+ visible.some((ws) => ws.definition.id === routeWorkspaceId)
99
+ ) {
100
+ return routeWorkspaceId;
101
+ }
102
+ return resolveDefaultId(visible, initialWorkspaceId);
103
+ }, [routeWorkspaceId, visible, initialWorkspaceId]);
104
+
105
+ const handleSelect = useCallback(
106
+ (id: string) => {
107
+ // Pick the first nav-member that actually points at a screen so
108
+ // the URL lands on something renderable instead of `/<workspace>`.
109
+ // Section-headers (nav-Einträge ohne `screen`) werden übersprungen.
110
+ const target = visible.find((ws) => ws.definition.id === id);
111
+ const screenId = firstNavScreenId(app, target?.navMembers);
112
+ nav.navigate({ workspaceId: id, screenId });
113
+ },
114
+ [nav, app, visible],
115
+ );
116
+
117
+ // Initial sync: covers two URL states that need a default-fill so
118
+ // NavTree links and RoutedScreen have something to chew on.
119
+ //
120
+ // 1. No workspace in URL ("/" or wrong workspace id) → fill the
121
+ // active workspace AND its first nav-member's screen.
122
+ // 2. Workspace present but screen missing ("/admin") → fill in the
123
+ // first nav-member's screen, keep the workspace.
124
+ //
125
+ // replace, NOT navigate: these are default-fills, not user actions.
126
+ // Using pushState would create a history entry the user never asked
127
+ // for — Browser-Back from /admin/x → / → effect re-pushes →
128
+ // Back-loop. replaceState swaps in place: Back leaves the app cleanly.
129
+ //
130
+ // useLayoutEffect, NOT useEffect: the children below this shell render
131
+ // RoutedScreen with the URL's current screenId. If that's "" (URL was
132
+ // "/" or "/admin"), KumikoScreen renders a "Screen not found" banner
133
+ // for the empty qn. useEffect would let that banner paint to the
134
+ // screen for one frame before the URL got fixed. useLayoutEffect runs
135
+ // synchronously between commit and paint, so the user only ever sees
136
+ // the resolved screen. (No SSR here, otherwise we'd need a guard.)
137
+ useLayoutEffect(() => {
138
+ if (activeId === undefined) return;
139
+ const routeScreenEmpty = nav.route?.screenId === undefined || nav.route.screenId === "";
140
+ const workspaceMatches = routeWorkspaceId === activeId;
141
+ if (workspaceMatches && !routeScreenEmpty) return; // URL is fine
142
+ const target = visible.find((ws) => ws.definition.id === activeId);
143
+ const navMembers = target?.navMembers;
144
+ if ((navMembers === undefined || navMembers.length === 0) && !routeScreenEmpty) {
145
+ // Workspace exists but no nav members — keep whatever screen the
146
+ // user typed, just lock the workspace prefix.
147
+ nav.replace({ workspaceId: activeId, screenId: nav.route?.screenId ?? "" });
148
+ return;
149
+ }
150
+ const screenId = firstNavScreenId(app, navMembers);
151
+ // Loop-guard: wenn keiner der nav-members eine screen-property hat
152
+ // (alle section-headers oder unbekannte QNs), würde nav.replace mit
153
+ // screenId="" den Effect bei jedem Re-Render erneut feuern und den
154
+ // User auf "/<workspace>/" pinnen. Lieber gar nicht replacen — der
155
+ // Workspace-Selector kann den User dann manuell auf einen leaf-nav
156
+ // schicken.
157
+ if (screenId === "") return;
158
+ nav.replace({ workspaceId: activeId, screenId });
159
+ }, [activeId, routeWorkspaceId, visible, nav, app]);
160
+
161
+ const activeWorkspace = useMemo(
162
+ () => visible.find((ws) => ws.definition.id === activeId),
163
+ [visible, activeId],
164
+ );
165
+
166
+ // Filter resolution has THREE branches and getting them right matters:
167
+ // * Schema declares workspaces + active resolved → filter to its members
168
+ // * Schema declares workspaces + NO active visible → empty allow-set
169
+ // (NOT undefined). This catches the "user has no accessible workspace"
170
+ // case — falling back to "no filter" would leak nav items the user
171
+ // shouldn't see, e.g. admin entries to a driver after a role change.
172
+ // * Schema doesn't declare workspaces at all → undefined (no filter).
173
+ // Apps that haven't opted into workspaces yet get every nav as before.
174
+ const allowedNavQns = useMemo(() => {
175
+ const hasWorkspaceMode = app.workspaces !== undefined && app.workspaces.length > 0;
176
+ if (!hasWorkspaceMode) return undefined;
177
+ if (activeWorkspace === undefined) return new Set<string>();
178
+ return new Set(activeWorkspace.navMembers);
179
+ }, [app.workspaces, activeWorkspace]);
180
+
181
+ const switcher = activeId !== undefined && (
182
+ <WorkspaceSwitcher workspaces={visible} activeId={activeId} onSelect={handleSelect} />
183
+ );
184
+
185
+ return (
186
+ <AppLayout
187
+ topbar={<Topbar start={brand} center={switcher || undefined} end={topbarActions} />}
188
+ sidebar={
189
+ <Sidebar {...(sidebarFooter !== undefined && { footer: sidebarFooter })}>
190
+ <NavTree
191
+ schema={app}
192
+ {...(user !== undefined && { user })}
193
+ {...(allowedNavQns !== undefined && { allowedNavQns })}
194
+ />
195
+ </Sidebar>
196
+ }
197
+ >
198
+ {children}
199
+ </AppLayout>
200
+ );
201
+ }
202
+
203
+ // --- helpers (exported for tests) ---
204
+
205
+ export function filterByAccess(
206
+ workspaces: readonly WorkspaceSchema[],
207
+ userRoles: readonly string[] | undefined,
208
+ ): readonly WorkspaceSchema[] {
209
+ const roles = userRoles ?? [];
210
+ return [...workspaces]
211
+ .filter((ws) => userMatchesAccess(ws.definition.access, roles))
212
+ .sort(byOrderThenInsertion);
213
+ }
214
+
215
+ function userMatchesAccess(access: AccessRule | undefined, userRoles: readonly string[]): boolean {
216
+ if (access === undefined) return true;
217
+ if ("openToAll" in access) return access.openToAll;
218
+ return access.roles.some((r) => userRoles.includes(r));
219
+ }
220
+
221
+ function byOrderThenInsertion(a: WorkspaceSchema, b: WorkspaceSchema): number {
222
+ // Missing `order` sorts last; ties keep insertion order via stable sort.
223
+ const ao = a.definition.order ?? Number.POSITIVE_INFINITY;
224
+ const bo = b.definition.order ?? Number.POSITIVE_INFINITY;
225
+ return ao - bo;
226
+ }
227
+
228
+ /** Returns the short screen-id of the first nav-member that points at
229
+ * a screen. Walks the workspace's nav-list in order and skips section-
230
+ * headers (NavDefinitions ohne `screen`) so ein workspace dessen erstes
231
+ * Member ein Header ist trotzdem auf einen renderbaren Screen auflöst.
232
+ *
233
+ * Returns "" when:
234
+ * - navMembers is undefined or empty
235
+ * - all members are section-headers (no `screen` property)
236
+ * - all members are unknown to the schema (drift zwischen
237
+ * r.workspace.nav und den registrierten navs — boot-validator fängt
238
+ * das server-side, hier nur graceful fallback)
239
+ *
240
+ * The caller MUST loop-guard on "": rendering with screenId="" macht
241
+ * KumikoScreen den not-found-banner zeigen — und ein nav.replace mit
242
+ * leerem screenId würde den useLayoutEffect in WorkspaceShell bei
243
+ * jedem Re-Render erneut feuern (siehe matching guard dort).
244
+ *
245
+ * Earlier code used `lastSegment(navQn)` which only worked when the
246
+ * feature followed the convention nav.id === screen.id (samples/apps/
247
+ * workspaces does this). Apps with distinct ids (publicstatus: nav
248
+ * "components" → screen "component-list") got "Screen not found".
249
+ *
250
+ * Exported für Edge-Case-Tests. */
251
+ export function firstNavScreenId(
252
+ app: AppSchema,
253
+ navMembers: readonly string[] | undefined,
254
+ ): string {
255
+ if (navMembers === undefined || navMembers.length === 0) return "";
256
+ for (const navQn of navMembers) {
257
+ for (const feature of app.features) {
258
+ for (const nav of feature.navs ?? []) {
259
+ if (qualifyNavId(feature.featureName, nav.id) !== navQn) continue;
260
+ if (nav.screen === undefined) break; // section-header → nächstes member
261
+ return lastSegment(nav.screen);
262
+ }
263
+ }
264
+ }
265
+ return "";
266
+ }
267
+
268
+ export function resolveDefaultId(
269
+ visible: readonly WorkspaceSchema[],
270
+ preferredShortId: string | undefined,
271
+ ): string | undefined {
272
+ // 1. Caller-pinned preference (URL or test prop) wins if it's accessible.
273
+ if (preferredShortId !== undefined) {
274
+ const match = visible.find((ws) => ws.definition.id === preferredShortId);
275
+ if (match !== undefined) return match.definition.id;
276
+ }
277
+ // 2. Engine-declared default if accessible.
278
+ const defaulted = visible.find((ws) => ws.definition.default === true);
279
+ if (defaulted !== undefined) return defaulted.definition.id;
280
+ // 3. First workspace the user can see.
281
+ return visible[0]?.definition.id;
282
+ }
@@ -0,0 +1,62 @@
1
+ // WorkspaceSwitcher — dumb component for picking the active workspace.
2
+ // Receives the role-filtered + order-sorted workspace list, the active
3
+ // id, and a callback. Stays presentational so WorkspaceShell can own the
4
+ // state (URL ?w=, defaults, role filtering) and tests can hand any list
5
+ // in directly.
6
+
7
+ import type { WorkspaceSchema } from "@cosmicdrift/kumiko-renderer";
8
+ import { useTranslation } from "@cosmicdrift/kumiko-renderer";
9
+ import type { ReactNode } from "react";
10
+ import { cn } from "../lib/cn";
11
+
12
+ export type WorkspaceSwitcherProps = {
13
+ readonly workspaces: readonly WorkspaceSchema[];
14
+ readonly activeId: string;
15
+ readonly onSelect: (workspaceQn: string) => void;
16
+ readonly testId?: string;
17
+ };
18
+
19
+ export function WorkspaceSwitcher({
20
+ workspaces,
21
+ activeId,
22
+ onSelect,
23
+ testId,
24
+ }: WorkspaceSwitcherProps): ReactNode {
25
+ const t = useTranslation();
26
+ // Single workspace doesn't need a switcher — the user has no choice
27
+ // anyway. Render nothing instead of a useless one-button row.
28
+ if (workspaces.length <= 1) return null;
29
+ return (
30
+ <div
31
+ data-testid={testId}
32
+ data-kumiko-layout="workspace-switcher"
33
+ role="tablist"
34
+ className="flex items-center gap-1"
35
+ >
36
+ {workspaces.map((ws) => {
37
+ const active = ws.definition.id === activeId;
38
+ const label = ws.definition.label.includes(".")
39
+ ? t(ws.definition.label)
40
+ : ws.definition.label;
41
+ return (
42
+ <button
43
+ type="button"
44
+ key={ws.definition.id}
45
+ role="tab"
46
+ aria-selected={active}
47
+ data-testid={`workspace-tab-${ws.definition.id}`}
48
+ onClick={() => onSelect(ws.definition.id)}
49
+ className={cn(
50
+ "rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
51
+ active
52
+ ? "bg-accent text-accent-foreground"
53
+ : "text-muted-foreground hover:bg-accent/40",
54
+ )}
55
+ >
56
+ {label}
57
+ </button>
58
+ );
59
+ })}
60
+ </div>
61
+ );
62
+ }
package/src/lib/cn.ts ADDED
@@ -0,0 +1,10 @@
1
+ import { type ClassValue, clsx } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ /** shadcn-Standard-Helper: clsx für konditionales Zusammenstecken,
5
+ * tailwind-merge für Konfliktauflösung (z.B. `px-2 px-4` → `px-4`).
6
+ * Jedes Primitive nutzt das um Consumer-Klassen mit Default-Klassen
7
+ * zu mergen ohne Duplikate. */
8
+ export function cn(...inputs: ClassValue[]): string {
9
+ return twMerge(clsx(inputs));
10
+ }
@@ -0,0 +1,111 @@
1
+ // Generic Action-Menu — Trigger + Items, auf @radix-ui/react-dropdown-
2
+ // menu basiert. Wird benutzt für ProfileMenu (Topbar-Avatar), Edit-
3
+ // Header-Menu (Three-Dots), List-Row-Kebab — die Mechanik ist überall
4
+ // dieselbe, nur der Trigger unterscheidet sich.
5
+ //
6
+ // Nicht zu verwechseln mit RowActionsCell (siehe primitives/index.tsx),
7
+ // die hat die rowActions-Schema-API + per-row Visibility-Logik. Hier
8
+ // ist der Layer drunter: stable, schema-frei, callable von App-Code
9
+ // und vom Schema-Renderer (KumikoApp).
10
+ //
11
+ // Items sind Discriminated Union (item / separator / label), Item kann
12
+ // optional einen Keyboard-Shortcut-Hint rechts tragen (Linear-Pattern
13
+ // "O then M") + Icon links + variant für danger-styling.
14
+
15
+ import { type ReactNode, useState } from "react";
16
+ import {
17
+ DropdownMenu,
18
+ DropdownMenuContent,
19
+ DropdownMenuItem,
20
+ DropdownMenuLabel,
21
+ DropdownMenuSeparator,
22
+ DropdownMenuTrigger,
23
+ } from "./dropdown-menu";
24
+
25
+ export type MenuItemDef =
26
+ | {
27
+ readonly kind: "item";
28
+ readonly id: string;
29
+ readonly label: string;
30
+ /** Optional Icon links vor dem Label (16px). */
31
+ readonly icon?: ReactNode;
32
+ /** Pure-display Shortcut-Hint rechts (monospace, gedimmt).
33
+ * Caller registriert den Shortcut separat — Linear-Style
34
+ * Strings wie "O then M", "Alt + Q", "⌘K". */
35
+ readonly shortcut?: string;
36
+ readonly onSelect: () => void;
37
+ readonly disabled?: boolean;
38
+ /** "danger" rendert Label rot — typisch Sign-out, Delete. */
39
+ readonly variant?: "default" | "danger";
40
+ }
41
+ | { readonly kind: "separator" }
42
+ | { readonly kind: "label"; readonly label: string };
43
+
44
+ export type ActionMenuProps = {
45
+ /** Free-form Trigger-Component — Avatar, IconButton, Pill, Custom. */
46
+ readonly trigger: ReactNode;
47
+ /** aria-label für den Trigger-Wrapper. */
48
+ readonly triggerLabel?: string;
49
+ readonly items: readonly MenuItemDef[];
50
+ /** Wo das Popup relativ zum Trigger anchorn soll. Default: end. */
51
+ readonly align?: "start" | "center" | "end";
52
+ /** Min-width für den Content. Default: 14rem. */
53
+ readonly minWidth?: string;
54
+ readonly testId?: string;
55
+ };
56
+
57
+ export function ActionMenu({
58
+ trigger,
59
+ triggerLabel,
60
+ items,
61
+ align = "end",
62
+ minWidth = "14rem",
63
+ testId,
64
+ }: ActionMenuProps): ReactNode {
65
+ const [open, setOpen] = useState(false);
66
+ return (
67
+ <DropdownMenu open={open} onOpenChange={setOpen}>
68
+ <DropdownMenuTrigger asChild>
69
+ <button
70
+ type="button"
71
+ data-testid={testId ?? "action-menu-trigger"}
72
+ {...(triggerLabel !== undefined && { "aria-label": triggerLabel })}
73
+ className="rounded outline-none focus-visible:ring-1 focus-visible:ring-ring"
74
+ >
75
+ {trigger}
76
+ </button>
77
+ </DropdownMenuTrigger>
78
+ <DropdownMenuContent align={align} style={{ minWidth }}>
79
+ {items.map((item, idx) => {
80
+ if (item.kind === "separator") {
81
+ // biome-ignore lint/suspicious/noArrayIndexKey: Separators haben keine ID — Reihenfolge ist Identität
82
+ return <DropdownMenuSeparator key={`sep-${idx}`} />;
83
+ }
84
+ if (item.kind === "label") {
85
+ // biome-ignore lint/suspicious/noArrayIndexKey: gleicher Grund wie separator
86
+ return <DropdownMenuLabel key={`label-${idx}`}>{item.label}</DropdownMenuLabel>;
87
+ }
88
+ return (
89
+ <DropdownMenuItem
90
+ key={item.id}
91
+ onSelect={item.onSelect}
92
+ disabled={item.disabled === true}
93
+ data-testid={`action-menu-item-${item.id}`}
94
+ className={
95
+ item.variant === "danger" ? "text-destructive focus:text-destructive" : undefined
96
+ }
97
+ >
98
+ {item.icon !== undefined && <span className="size-4 shrink-0">{item.icon}</span>}
99
+ <span className="flex-1">{item.label}</span>
100
+ {item.shortcut !== undefined && (
101
+ <span className="ml-auto text-xs text-muted-foreground tracking-wider font-mono">
102
+ {item.shortcut}
103
+ </span>
104
+ )}
105
+ </DropdownMenuItem>
106
+ );
107
+ })}
108
+ </DropdownMenuContent>
109
+ </DropdownMenu>
110
+ );
111
+ }