@cosmicdrift/kumiko-renderer-web 0.183.0 → 0.183.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.183.
|
|
3
|
+
"version": "0.183.2",
|
|
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.183.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.183.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "0.183.
|
|
19
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.183.2",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.183.2",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.183.2",
|
|
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",
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
PrimitivesProvider,
|
|
11
11
|
} from "@cosmicdrift/kumiko-renderer";
|
|
12
12
|
import { render as _render, act } from "@testing-library/react";
|
|
13
|
+
import userEvent from "@testing-library/user-event";
|
|
13
14
|
import type { ReactNode } from "react";
|
|
14
15
|
import { useBrowserNavApi } from "../app/nav";
|
|
15
16
|
import {
|
|
@@ -20,7 +21,17 @@ import {
|
|
|
20
21
|
} from "../layout/workspace-shell";
|
|
21
22
|
import { WorkspaceSwitcher } from "../layout/workspace-switcher";
|
|
22
23
|
import { defaultPrimitives } from "../primitives";
|
|
23
|
-
import { createMockDispatcher,
|
|
24
|
+
import { createMockDispatcher, renderWithSidebar, screen } from "./test-utils";
|
|
25
|
+
|
|
26
|
+
// Dropdown instead of a tab row (see workspace-switcher.tsx) — the
|
|
27
|
+
// trigger always shows only the active label, Radix renders the list
|
|
28
|
+
// only once open. Assertions that just check "which workspace is
|
|
29
|
+
// active" read the trigger text instead of a per-tab aria-selected;
|
|
30
|
+
// only tests that need the full list or click an entry open the
|
|
31
|
+
// dropdown (userEvent, Radix reacts to pointerdown).
|
|
32
|
+
function activeWorkspaceLabel(): string | null {
|
|
33
|
+
return screen.getByTestId("workspace-switcher-trigger").textContent;
|
|
34
|
+
}
|
|
24
35
|
|
|
25
36
|
// jsdom shares window.history across tests in the same file. Reset to /
|
|
26
37
|
// before each render so URL-driven workspace state from one test doesn't
|
|
@@ -208,18 +219,19 @@ describe("firstNavScreenId", () => {
|
|
|
208
219
|
|
|
209
220
|
describe("WorkspaceSwitcher", () => {
|
|
210
221
|
test("renders nothing for a single workspace (no choice = no UI)", () => {
|
|
211
|
-
const { container } =
|
|
222
|
+
const { container } = renderWithSidebar(
|
|
212
223
|
<WorkspaceSwitcher
|
|
213
224
|
workspaces={[ws("only", { openToAll: true })]}
|
|
214
225
|
activeId="only"
|
|
215
226
|
onSelect={() => {}}
|
|
216
227
|
/>,
|
|
217
228
|
);
|
|
218
|
-
expect(container.
|
|
229
|
+
expect(container.querySelector('[data-testid="workspace-switcher-trigger"]')).toBeNull();
|
|
219
230
|
});
|
|
220
231
|
|
|
221
|
-
test("
|
|
222
|
-
|
|
232
|
+
test("trigger shows the active workspace, dropdown lists all with aria-checked", async () => {
|
|
233
|
+
const user = userEvent.setup();
|
|
234
|
+
renderWithSidebar(
|
|
223
235
|
<WorkspaceSwitcher
|
|
224
236
|
workspaces={[
|
|
225
237
|
ws("admin", { label: "Admin", openToAll: true }),
|
|
@@ -229,15 +241,16 @@ describe("WorkspaceSwitcher", () => {
|
|
|
229
241
|
onSelect={() => {}}
|
|
230
242
|
/>,
|
|
231
243
|
);
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
expect(
|
|
235
|
-
expect(
|
|
244
|
+
expect(activeWorkspaceLabel()).toBe("Admin");
|
|
245
|
+
await user.click(screen.getByTestId("workspace-switcher-trigger"));
|
|
246
|
+
expect(screen.getByTestId("workspace-tab-admin").getAttribute("aria-checked")).toBe("true");
|
|
247
|
+
expect(screen.getByTestId("workspace-tab-driver").getAttribute("aria-checked")).toBe("false");
|
|
236
248
|
});
|
|
237
249
|
|
|
238
|
-
test("clicking a
|
|
250
|
+
test("clicking a dropdown entry calls onSelect with that workspace id", async () => {
|
|
251
|
+
const user = userEvent.setup();
|
|
239
252
|
const onSelect = mock();
|
|
240
|
-
|
|
253
|
+
renderWithSidebar(
|
|
241
254
|
<WorkspaceSwitcher
|
|
242
255
|
workspaces={[
|
|
243
256
|
ws("admin", { label: "Admin", openToAll: true }),
|
|
@@ -247,7 +260,8 @@ describe("WorkspaceSwitcher", () => {
|
|
|
247
260
|
onSelect={onSelect}
|
|
248
261
|
/>,
|
|
249
262
|
);
|
|
250
|
-
|
|
263
|
+
await user.click(screen.getByTestId("workspace-switcher-trigger"));
|
|
264
|
+
await user.click(screen.getByTestId("workspace-tab-driver"));
|
|
251
265
|
expect(onSelect).toHaveBeenCalledWith("driver");
|
|
252
266
|
});
|
|
253
267
|
});
|
|
@@ -292,7 +306,8 @@ describe("WorkspaceShell", () => {
|
|
|
292
306
|
],
|
|
293
307
|
} as const;
|
|
294
308
|
|
|
295
|
-
test("an admin sees admin + dispatch in the switcher (driver hidden)", () => {
|
|
309
|
+
test("an admin sees admin + dispatch in the switcher (driver hidden)", async () => {
|
|
310
|
+
const user = userEvent.setup();
|
|
296
311
|
renderShell(
|
|
297
312
|
<WorkspaceShell
|
|
298
313
|
brand={<div>Brand</div>}
|
|
@@ -302,6 +317,7 @@ describe("WorkspaceShell", () => {
|
|
|
302
317
|
<div>content</div>
|
|
303
318
|
</WorkspaceShell>,
|
|
304
319
|
);
|
|
320
|
+
await user.click(screen.getByTestId("workspace-switcher-trigger"));
|
|
305
321
|
expect(screen.getByTestId("workspace-tab-admin")).toBeTruthy();
|
|
306
322
|
expect(screen.getByTestId("workspace-tab-dispatch")).toBeTruthy();
|
|
307
323
|
expect(screen.queryByTestId("workspace-tab-driver")).toBeNull();
|
|
@@ -317,7 +333,7 @@ describe("WorkspaceShell", () => {
|
|
|
317
333
|
<div>content</div>
|
|
318
334
|
</WorkspaceShell>,
|
|
319
335
|
);
|
|
320
|
-
expect(
|
|
336
|
+
expect(activeWorkspaceLabel()).toBe("Admin");
|
|
321
337
|
});
|
|
322
338
|
|
|
323
339
|
test("only the active workspace's nav members appear in the sidebar", () => {
|
|
@@ -337,7 +353,8 @@ describe("WorkspaceShell", () => {
|
|
|
337
353
|
expect(screen.queryAllByText("Tours").length).toBe(0);
|
|
338
354
|
});
|
|
339
355
|
|
|
340
|
-
test("clicking the dispatch tab swaps the visible nav set", () => {
|
|
356
|
+
test("clicking the dispatch tab swaps the visible nav set", async () => {
|
|
357
|
+
const user = userEvent.setup();
|
|
341
358
|
renderShell(
|
|
342
359
|
<WorkspaceShell
|
|
343
360
|
brand={<div>Brand</div>}
|
|
@@ -347,7 +364,8 @@ describe("WorkspaceShell", () => {
|
|
|
347
364
|
<div>content</div>
|
|
348
365
|
</WorkspaceShell>,
|
|
349
366
|
);
|
|
350
|
-
|
|
367
|
+
await user.click(screen.getByTestId("workspace-switcher-trigger"));
|
|
368
|
+
await user.click(screen.getByTestId("workspace-tab-dispatch"));
|
|
351
369
|
// dispatch → orders + tours, NOT system (getAllByText: Desktop + Mobile-Sheet)
|
|
352
370
|
expect(screen.getAllByText("Orders").length).toBeGreaterThan(0);
|
|
353
371
|
expect(screen.getAllByText("Tours").length).toBeGreaterThan(0);
|
|
@@ -401,7 +419,7 @@ describe("WorkspaceShell", () => {
|
|
|
401
419
|
<div>content</div>
|
|
402
420
|
</WorkspaceShell>,
|
|
403
421
|
);
|
|
404
|
-
expect(
|
|
422
|
+
expect(activeWorkspaceLabel()).toBe("Cockpit");
|
|
405
423
|
});
|
|
406
424
|
|
|
407
425
|
// Security regression — without the empty-allow-set branch, the NavTree
|
|
@@ -535,17 +553,18 @@ describe("WorkspaceShell — URL sync (path-based)", () => {
|
|
|
535
553
|
<div>content</div>
|
|
536
554
|
</WorkspaceShell>,
|
|
537
555
|
);
|
|
538
|
-
expect(
|
|
539
|
-
expect(screen.getByTestId("workspace-tab-admin").getAttribute("aria-selected")).toBe("false");
|
|
556
|
+
expect(activeWorkspaceLabel()).toBe("dispatch");
|
|
540
557
|
});
|
|
541
558
|
|
|
542
|
-
test("clicking a tab pushes /<workspace>/<screen> to the URL", () => {
|
|
559
|
+
test("clicking a tab pushes /<workspace>/<screen> to the URL", async () => {
|
|
560
|
+
const user = userEvent.setup();
|
|
543
561
|
renderShell(
|
|
544
562
|
<WorkspaceShell brand={<div>B</div>} schema={schema} user={{ id: "u1", roles: ["admin"] }}>
|
|
545
563
|
<div>content</div>
|
|
546
564
|
</WorkspaceShell>,
|
|
547
565
|
);
|
|
548
|
-
|
|
566
|
+
await user.click(screen.getByTestId("workspace-switcher-trigger"));
|
|
567
|
+
await user.click(screen.getByTestId("workspace-tab-dispatch"));
|
|
549
568
|
// dispatch's first nav-member is bmc:nav:orders → screenId "orders"
|
|
550
569
|
expect(window.location.pathname).toBe("/dispatch/orders");
|
|
551
570
|
});
|
|
@@ -604,7 +623,7 @@ describe("WorkspaceShell — URL sync (path-based)", () => {
|
|
|
604
623
|
</WorkspaceShell>,
|
|
605
624
|
);
|
|
606
625
|
// Default workspace = admin. Ghost id ignored, no error thrown.
|
|
607
|
-
expect(
|
|
626
|
+
expect(activeWorkspaceLabel()).toBe("admin");
|
|
608
627
|
});
|
|
609
628
|
|
|
610
629
|
test("popstate (back/forward) updates the active tab", () => {
|
|
@@ -614,7 +633,7 @@ describe("WorkspaceShell — URL sync (path-based)", () => {
|
|
|
614
633
|
<div>content</div>
|
|
615
634
|
</WorkspaceShell>,
|
|
616
635
|
);
|
|
617
|
-
expect(
|
|
636
|
+
expect(activeWorkspaceLabel()).toBe("admin");
|
|
618
637
|
// Simulate the user hitting "forward" to /dispatch/orders. pushState
|
|
619
638
|
// alone doesn't fire popstate — we synthesize the event so the
|
|
620
639
|
// hook's listener-set notifies subscribers. act() flushes the
|
|
@@ -623,7 +642,7 @@ describe("WorkspaceShell — URL sync (path-based)", () => {
|
|
|
623
642
|
window.history.pushState(null, "", "/dispatch/orders");
|
|
624
643
|
window.dispatchEvent(new PopStateEvent("popstate"));
|
|
625
644
|
});
|
|
626
|
-
expect(
|
|
645
|
+
expect(activeWorkspaceLabel()).toBe("dispatch");
|
|
627
646
|
});
|
|
628
647
|
});
|
|
629
648
|
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// WorkspaceSwitcher Render-Tests (Phase 1, test-luecken-integration, Tier 2).
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// <= 1
|
|
5
|
-
//
|
|
3
|
+
// Dropdown built on Radix (like LanguageSwitcher/TenantSwitcher). Pins:
|
|
4
|
+
// no switcher at <= 1 workspace, trigger shows the active label, dropdown
|
|
5
|
+
// lists all workspaces with aria-checked on the active one, onSelect
|
|
6
|
+
// callback. Radix opens on pointerdown → userEvent instead of
|
|
7
|
+
// fireEvent.click, same as language-switcher.test.tsx.
|
|
6
8
|
|
|
7
9
|
import { describe, expect, mock, test } from "bun:test";
|
|
8
10
|
import {
|
|
@@ -10,7 +12,8 @@ import {
|
|
|
10
12
|
LocaleProvider,
|
|
11
13
|
type WorkspaceSchema,
|
|
12
14
|
} from "@cosmicdrift/kumiko-renderer";
|
|
13
|
-
import
|
|
15
|
+
import userEvent from "@testing-library/user-event";
|
|
16
|
+
import { renderWithSidebar, screen } from "../../__tests__/test-utils";
|
|
14
17
|
import { WorkspaceSwitcher } from "../workspace-switcher";
|
|
15
18
|
|
|
16
19
|
function ws(id: string, label = id): WorkspaceSchema {
|
|
@@ -19,14 +22,14 @@ function ws(id: string, label = id): WorkspaceSchema {
|
|
|
19
22
|
|
|
20
23
|
describe("WorkspaceSwitcher — Render", () => {
|
|
21
24
|
test("ein einziger Workspace → rendert nichts (kein nutzloser Switcher)", () => {
|
|
22
|
-
const { container } =
|
|
25
|
+
const { container } = renderWithSidebar(
|
|
23
26
|
<WorkspaceSwitcher workspaces={[ws("a")]} activeId="a" onSelect={() => {}} />,
|
|
24
27
|
);
|
|
25
|
-
expect(container.querySelector('[
|
|
28
|
+
expect(container.querySelector('[data-testid="workspace-switcher-trigger"]')).toBeNull();
|
|
26
29
|
});
|
|
27
30
|
|
|
28
|
-
test("mehrere Workspaces →
|
|
29
|
-
|
|
31
|
+
test("mehrere Workspaces → Trigger zeigt aktives Label", () => {
|
|
32
|
+
renderWithSidebar(
|
|
30
33
|
<WorkspaceSwitcher
|
|
31
34
|
workspaces={[ws("a", "Alpha"), ws("b", "Beta")]}
|
|
32
35
|
activeId="b"
|
|
@@ -34,29 +37,45 @@ describe("WorkspaceSwitcher — Render", () => {
|
|
|
34
37
|
testId="sw"
|
|
35
38
|
/>,
|
|
36
39
|
);
|
|
37
|
-
expect(screen.getByTestId("sw")
|
|
38
|
-
expect(screen.
|
|
39
|
-
expect(screen.getByTestId("workspace-tab-b").getAttribute("aria-selected")).toBe("true");
|
|
40
|
+
expect(screen.getByTestId("sw")).toBeTruthy();
|
|
41
|
+
expect(screen.getByText("Beta")).toBeTruthy();
|
|
40
42
|
});
|
|
41
43
|
|
|
42
|
-
test("
|
|
44
|
+
test("Dropdown öffnen listet alle Workspaces, aria-checked am aktiven", async () => {
|
|
45
|
+
const user = userEvent.setup();
|
|
46
|
+
renderWithSidebar(
|
|
47
|
+
<WorkspaceSwitcher
|
|
48
|
+
workspaces={[ws("a", "Alpha"), ws("b", "Beta")]}
|
|
49
|
+
activeId="b"
|
|
50
|
+
onSelect={() => {}}
|
|
51
|
+
/>,
|
|
52
|
+
);
|
|
53
|
+
await user.click(screen.getByTestId("workspace-switcher-trigger"));
|
|
54
|
+
expect(screen.getByTestId("workspace-tab-a").getAttribute("aria-checked")).toBe("false");
|
|
55
|
+
expect(screen.getByTestId("workspace-tab-b").getAttribute("aria-checked")).toBe("true");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("Click auf einen Eintrag ruft onSelect mit der Workspace-id", async () => {
|
|
59
|
+
const user = userEvent.setup();
|
|
43
60
|
const onSelect = mock((_id: string) => {});
|
|
44
|
-
|
|
61
|
+
renderWithSidebar(
|
|
45
62
|
<WorkspaceSwitcher
|
|
46
63
|
workspaces={[ws("a", "Alpha"), ws("b", "Beta")]}
|
|
47
64
|
activeId="a"
|
|
48
65
|
onSelect={onSelect}
|
|
49
66
|
/>,
|
|
50
67
|
);
|
|
51
|
-
|
|
68
|
+
await user.click(screen.getByTestId("workspace-switcher-trigger"));
|
|
69
|
+
await user.click(screen.getByTestId("workspace-tab-b"));
|
|
52
70
|
expect(onSelect).toHaveBeenCalledWith("b");
|
|
53
71
|
});
|
|
54
72
|
});
|
|
55
73
|
|
|
56
74
|
describe("WorkspaceSwitcher — i18n-Labels (Punkt-Konvention)", () => {
|
|
57
|
-
test("Label mit Punkt geht durch t() und rendert die Übersetzung", () => {
|
|
75
|
+
test("Label mit Punkt geht durch t() und rendert die Übersetzung", async () => {
|
|
76
|
+
const user = userEvent.setup();
|
|
58
77
|
const bundle = { en: { "nav.adminArea": "Admin Area" } };
|
|
59
|
-
|
|
78
|
+
renderWithSidebar(
|
|
60
79
|
<LocaleProvider
|
|
61
80
|
resolver={createStaticLocaleResolver({ locale: "en" })}
|
|
62
81
|
fallbackBundles={[bundle]}
|
|
@@ -68,8 +87,10 @@ describe("WorkspaceSwitcher — i18n-Labels (Punkt-Konvention)", () => {
|
|
|
68
87
|
/>
|
|
69
88
|
</LocaleProvider>,
|
|
70
89
|
);
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
90
|
+
// Trigger shows the translated active label.
|
|
91
|
+
expect(screen.getByText("Admin Area")).toBeTruthy();
|
|
92
|
+
await user.click(screen.getByTestId("workspace-switcher-trigger"));
|
|
93
|
+
// No dot: verbatim, no t() roundtrip.
|
|
94
|
+
expect(screen.getByTestId("workspace-tab-b").textContent).toBe("Plain Label");
|
|
74
95
|
});
|
|
75
96
|
});
|
package/src/layout/nav-tree.tsx
CHANGED
|
@@ -31,11 +31,13 @@ import {
|
|
|
31
31
|
import {
|
|
32
32
|
BarChart3,
|
|
33
33
|
Bell,
|
|
34
|
+
BookOpen,
|
|
34
35
|
Building,
|
|
35
36
|
Calculator,
|
|
36
37
|
CalendarDays,
|
|
37
38
|
ChevronDown,
|
|
38
39
|
ChevronRight,
|
|
40
|
+
ClipboardList,
|
|
39
41
|
Coins,
|
|
40
42
|
CreditCard,
|
|
41
43
|
Download,
|
|
@@ -54,6 +56,7 @@ import {
|
|
|
54
56
|
List,
|
|
55
57
|
Lock,
|
|
56
58
|
Mail,
|
|
59
|
+
Package,
|
|
57
60
|
Palette,
|
|
58
61
|
PiggyBank,
|
|
59
62
|
Plus,
|
|
@@ -109,6 +112,9 @@ import { parseTargetFromSearchParams } from "./target-url";
|
|
|
109
112
|
const NAV_ICONS: Readonly<Record<string, typeof Folder>> = {
|
|
110
113
|
dashboard: LayoutDashboard,
|
|
111
114
|
"layout-grid": LayoutGrid,
|
|
115
|
+
"book-open": BookOpen,
|
|
116
|
+
"clipboard-list": ClipboardList,
|
|
117
|
+
package: Package,
|
|
112
118
|
gauge: Gauge,
|
|
113
119
|
list: List,
|
|
114
120
|
table: Table,
|
|
@@ -231,7 +237,7 @@ export function NavTree({
|
|
|
231
237
|
<NavFilterContext.Provider value={navFilter}>
|
|
232
238
|
<NavBadgesContext.Provider value={navBadges ?? EMPTY_BADGES}>
|
|
233
239
|
<div data-testid={testId} data-kumiko-layout="nav-tree" className="flex w-full flex-col">
|
|
234
|
-
<div className="px-2 pt-2 pb-1">
|
|
240
|
+
<div className="px-2 pt-2 pb-1 group-data-[collapsible=icon]:hidden">
|
|
235
241
|
<SidebarInput
|
|
236
242
|
value={filter}
|
|
237
243
|
onChange={(e) => setFilter(e.target.value)}
|
|
@@ -37,11 +37,13 @@ export function SidebarBrand({
|
|
|
37
37
|
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
|
38
38
|
{logo ?? <span className="text-sm font-semibold">{name.charAt(0)}</span>}
|
|
39
39
|
</div>
|
|
40
|
-
<div className="grid flex-1 text-left text-sm leading-tight">
|
|
40
|
+
<div className="grid flex-1 text-left text-sm leading-tight group-data-[collapsible=icon]:hidden">
|
|
41
41
|
<span className="truncate font-semibold">{name}</span>
|
|
42
42
|
{plan !== undefined && <span className="truncate text-xs">{plan}</span>}
|
|
43
43
|
</div>
|
|
44
|
-
{collapsible &&
|
|
44
|
+
{collapsible && (
|
|
45
|
+
<ChevronsUpDown className="ml-auto group-data-[collapsible=icon]:hidden" />
|
|
46
|
+
)}
|
|
45
47
|
</SidebarMenuButton>
|
|
46
48
|
</SidebarMenuItem>
|
|
47
49
|
</SidebarMenu>
|
|
@@ -3,11 +3,28 @@
|
|
|
3
3
|
// id, and a callback. Stays presentational so WorkspaceShell can own the
|
|
4
4
|
// state (URL ?w=, defaults, role filtering) and tests can hand any list
|
|
5
5
|
// in directly.
|
|
6
|
+
//
|
|
7
|
+
// Dropdown instead of a tab row: a row of fixed buttons overflowed the
|
|
8
|
+
// sidebar width with 3+ workspaces (or even 2 longer names) —
|
|
9
|
+
// truncate+scroll then hid the last entry entirely, unclickable. A
|
|
10
|
+
// dropdown has constant trigger width regardless of count/length.
|
|
11
|
+
//
|
|
12
|
+
// Hidden entirely when collapsed instead of an icon/initial: a label at
|
|
13
|
+
// icon width isn't readable anyway, and the user switches workspaces via
|
|
14
|
+
// the full sidebar — no attempt to keep this operable in the icon rail
|
|
15
|
+
// too (same as the search box in nav-tree.tsx).
|
|
6
16
|
|
|
7
17
|
import type { WorkspaceSchema } from "@cosmicdrift/kumiko-renderer";
|
|
8
18
|
import { useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
19
|
+
import { ChevronsUpDown } from "lucide-react";
|
|
9
20
|
import type { ReactNode } from "react";
|
|
10
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
DropdownMenu,
|
|
23
|
+
DropdownMenuCheckboxItem,
|
|
24
|
+
DropdownMenuContent,
|
|
25
|
+
DropdownMenuTrigger,
|
|
26
|
+
} from "../primitives/dropdown-menu";
|
|
27
|
+
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar";
|
|
11
28
|
|
|
12
29
|
export type WorkspaceSwitcherProps = {
|
|
13
30
|
readonly workspaces: readonly WorkspaceSchema[];
|
|
@@ -26,37 +43,39 @@ export function WorkspaceSwitcher({
|
|
|
26
43
|
// Single workspace doesn't need a switcher — the user has no choice
|
|
27
44
|
// anyway. Render nothing instead of a useless one-button row.
|
|
28
45
|
if (workspaces.length <= 1) return null;
|
|
46
|
+
|
|
47
|
+
const labelOf = (ws: WorkspaceSchema): string =>
|
|
48
|
+
ws.definition.label.includes(".") ? t(ws.definition.label) : ws.definition.label;
|
|
49
|
+
const active = workspaces.find((ws) => ws.definition.id === activeId);
|
|
50
|
+
|
|
29
51
|
return (
|
|
30
|
-
<
|
|
52
|
+
<SidebarMenu
|
|
31
53
|
data-testid={testId}
|
|
32
54
|
data-kumiko-layout="workspace-switcher"
|
|
33
|
-
|
|
34
|
-
className="flex items-center gap-1"
|
|
55
|
+
className="group-data-[collapsible=icon]:hidden"
|
|
35
56
|
>
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
)}
|
|
55
|
-
>
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
})}
|
|
60
|
-
</div>
|
|
57
|
+
<SidebarMenuItem>
|
|
58
|
+
<DropdownMenu>
|
|
59
|
+
<DropdownMenuTrigger asChild>
|
|
60
|
+
<SidebarMenuButton data-testid="workspace-switcher-trigger">
|
|
61
|
+
<span className="truncate">{active !== undefined ? labelOf(active) : ""}</span>
|
|
62
|
+
<ChevronsUpDown className="ml-auto" />
|
|
63
|
+
</SidebarMenuButton>
|
|
64
|
+
</DropdownMenuTrigger>
|
|
65
|
+
<DropdownMenuContent align="start" className="min-w-[10rem]">
|
|
66
|
+
{workspaces.map((ws) => (
|
|
67
|
+
<DropdownMenuCheckboxItem
|
|
68
|
+
key={ws.definition.id}
|
|
69
|
+
checked={ws.definition.id === activeId}
|
|
70
|
+
data-testid={`workspace-tab-${ws.definition.id}`}
|
|
71
|
+
onSelect={() => onSelect(ws.definition.id)}
|
|
72
|
+
>
|
|
73
|
+
<span className="truncate">{labelOf(ws)}</span>
|
|
74
|
+
</DropdownMenuCheckboxItem>
|
|
75
|
+
))}
|
|
76
|
+
</DropdownMenuContent>
|
|
77
|
+
</DropdownMenu>
|
|
78
|
+
</SidebarMenuItem>
|
|
79
|
+
</SidebarMenu>
|
|
61
80
|
);
|
|
62
81
|
}
|