@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.
- package/package.json +63 -0
- package/src/__tests__/avatar.test.tsx +34 -0
- package/src/__tests__/combobox.test.tsx +240 -0
- package/src/__tests__/config-edit.test.tsx +172 -0
- package/src/__tests__/create-app.test.tsx +261 -0
- package/src/__tests__/date-input.test.tsx +91 -0
- package/src/__tests__/default-app-shell.test.tsx +60 -0
- package/src/__tests__/dispatcher-context.test.tsx +101 -0
- package/src/__tests__/dispatcher-status-wiring.test.tsx +119 -0
- package/src/__tests__/kumiko-screen.test.tsx +1014 -0
- package/src/__tests__/language-switcher.test.tsx +100 -0
- package/src/__tests__/money-input.test.tsx +232 -0
- package/src/__tests__/nav-base-path.test.tsx +388 -0
- package/src/__tests__/nav-search-params.test.tsx +88 -0
- package/src/__tests__/nav-tree.test.tsx +183 -0
- package/src/__tests__/nav.test.tsx +253 -0
- package/src/__tests__/primitives.test.tsx +936 -0
- package/src/__tests__/render-edit.test.tsx +178 -0
- package/src/__tests__/render-list-column-renderer.test.tsx +124 -0
- package/src/__tests__/render-list-debounce.test.tsx +128 -0
- package/src/__tests__/render-list.test.tsx +151 -0
- package/src/__tests__/sidebar.test.tsx +59 -0
- package/src/__tests__/test-utils.tsx +144 -0
- package/src/__tests__/theme-toggle.test.tsx +101 -0
- package/src/__tests__/toast.test.tsx +162 -0
- package/src/__tests__/use-form.test.tsx +112 -0
- package/src/__tests__/use-query-live.test.tsx +152 -0
- package/src/__tests__/use-query.test.tsx +88 -0
- package/src/__tests__/use-store.test.tsx +139 -0
- package/src/__tests__/workspace-shell.test.tsx +772 -0
- package/src/app/browser-locale.ts +85 -0
- package/src/app/client-plugin.tsx +63 -0
- package/src/app/create-app.tsx +380 -0
- package/src/app/nav.tsx +226 -0
- package/src/index.ts +137 -0
- package/src/layout/app-layout.tsx +35 -0
- package/src/layout/avatar.tsx +93 -0
- package/src/layout/default-app-shell.tsx +74 -0
- package/src/layout/language-switcher.tsx +101 -0
- package/src/layout/nav-tree.tsx +281 -0
- package/src/layout/profile-menu.tsx +40 -0
- package/src/layout/sidebar.tsx +65 -0
- package/src/layout/theme-toggle.tsx +44 -0
- package/src/layout/topbar.tsx +22 -0
- package/src/layout/workspace-shell.tsx +282 -0
- package/src/layout/workspace-switcher.tsx +62 -0
- package/src/lib/cn.ts +10 -0
- package/src/primitives/action-menu.tsx +111 -0
- package/src/primitives/combobox.tsx +261 -0
- package/src/primitives/date-input.tsx +165 -0
- package/src/primitives/dialog.tsx +119 -0
- package/src/primitives/dropdown-menu.tsx +103 -0
- package/src/primitives/index.tsx +1271 -0
- package/src/primitives/money-input.tsx +192 -0
- package/src/primitives/toast.tsx +166 -0
- package/src/sse/live-events.ts +90 -0
- package/src/styles.css +113 -0
- 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
|
+
}
|