@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,281 @@
1
+ // NavTree: Sidebar-Navigation aus dem Schema. Multi-Feature-aware —
2
+ // jedes Feature trägt seinen Featurenamen, der zum Qualifizieren der
3
+ // nav-ids genutzt wird. Pre-qualifizierte ids (enthält schon ":nav:")
4
+ // gehen unverändert durch — so können einzelne Features Cross-Feature-
5
+ // Referenzen einfügen ohne zur AppSchema migrieren zu müssen.
6
+ //
7
+ // Aktiver Eintrag bekommt die accent-Farben (hintergrund + foreground),
8
+ // inaktive nur muted-foreground. Rekursiv mit Indentation pro Tiefe.
9
+ //
10
+ // Parent-Nodes mit children sind collapsible — Chevron rechts toggled
11
+ // auf/zu. State lebt lokal im NavTree (useState); Default expanded
12
+ // für alles, Caller kann später localStorage-Persistenz drüberlegen.
13
+
14
+ import type { NavDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
15
+ import type { NavNode, NavRegistrySlice } from "@cosmicdrift/kumiko-headless";
16
+ import { resolveNavigation } from "@cosmicdrift/kumiko-headless";
17
+ import type { AppSchema, FeatureSchema } from "@cosmicdrift/kumiko-renderer";
18
+ import { lastSegment, toAppSchema, useNav, useTranslation } from "@cosmicdrift/kumiko-renderer";
19
+ import { ChevronDown, ChevronRight } from "lucide-react";
20
+ import { type ReactNode, useCallback, useMemo, useState } from "react";
21
+ import { KumikoLink } from "../app/nav";
22
+ import { cn } from "../lib/cn";
23
+
24
+ export type NavTreeProps = {
25
+ // Akzeptiert beide Shapes — AppSchema (multi-feature) oder
26
+ // FeatureSchema (legacy single-feature). toAppSchema normalisiert
27
+ // intern, sodass die Pipeline nur AppSchema kennt.
28
+ readonly schema: AppSchema | FeatureSchema;
29
+ readonly user?: { readonly id: string; readonly roles: readonly string[] };
30
+ readonly testId?: string;
31
+ // Workspace membership filter — when set, only nav entries whose qualified
32
+ // id is in the set are visible. WorkspaceShell passes the active
33
+ // workspace's `navMembers` list. Undefined = no filter (legacy / non-
34
+ // workspace apps render every nav).
35
+ readonly allowedNavQns?: ReadonlySet<string>;
36
+ };
37
+
38
+ export function NavTree({ schema, user, testId, allowedNavQns }: NavTreeProps): ReactNode {
39
+ const app = useMemo(() => toAppSchema(schema), [schema]);
40
+ const tree = useMemo(() => {
41
+ const source = buildNavRegistrySliceForApp(app, allowedNavQns);
42
+ return resolveNavigation({ source, ...(user !== undefined && { user }) });
43
+ }, [app, user, allowedNavQns]);
44
+
45
+ // Collapsed-Set: nur die explizit zugeklappten qualified-names. Default
46
+ // ist also "alles auf" — neue Features tauchen sofort offen auf, ohne
47
+ // dass der User erst klicken muss.
48
+ const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(() => new Set());
49
+ const onToggle = useCallback((qn: string) => {
50
+ setCollapsed((prev) => {
51
+ const next = new Set(prev);
52
+ if (next.has(qn)) next.delete(qn);
53
+ else next.add(qn);
54
+ return next;
55
+ });
56
+ }, []);
57
+
58
+ return (
59
+ <div data-testid={testId} data-kumiko-layout="nav-tree" className="flex flex-col gap-0.5">
60
+ {tree.map((node) => (
61
+ <NavNodeItem
62
+ key={node.qualifiedName}
63
+ node={node}
64
+ depth={0}
65
+ collapsed={collapsed}
66
+ onToggle={onToggle}
67
+ />
68
+ ))}
69
+ </div>
70
+ );
71
+ }
72
+
73
+ type NavNodeItemProps = {
74
+ readonly node: NavNode;
75
+ readonly depth: number;
76
+ readonly collapsed: ReadonlySet<string>;
77
+ readonly onToggle: (qn: string) => void;
78
+ };
79
+
80
+ function NavNodeItem({ node, depth, collapsed, onToggle }: NavNodeItemProps): ReactNode {
81
+ const nav = useNav();
82
+ const t = useTranslation();
83
+ const active = node.screen !== undefined && nav.route?.screenId === lastSegment(node.screen);
84
+
85
+ const hasChildren = node.children.length > 0;
86
+ const isCollapsed = collapsed.has(node.qualifiedName);
87
+ const indent = { paddingLeft: `${0.5 + depth * 1}rem` };
88
+
89
+ // i18n-Key Konvention: wenn label einen Punkt enthält, durchs t()
90
+ // laufen lassen — wenn Bundle den Key kennt, wird übersetzt, sonst
91
+ // bleibt der key selbst stehen (und der App-Dev sieht dass er eine
92
+ // Übersetzung vergessen hat). Reine String-Labels ("Dashboard")
93
+ // bleiben unangetastet durch das Mapping.
94
+ const displayLabel = node.label.includes(".") ? t(node.label) : node.label;
95
+
96
+ // In workspace mode the URL is /<ws>/<screen> — sidebar links must
97
+ // carry the active workspaceId, otherwise navigate({ screenId }) would
98
+ // produce /<screen> and the parser would interpret <screen> as a
99
+ // workspace id. Pulled from useNav().route so the link tracks switches.
100
+ const workspaceId = nav.route?.workspaceId;
101
+
102
+ // Chevron-Icon — nur wenn Node children hat. Rechts neben dem Label
103
+ // angeordnet; ein Click auf den Chevron alleine toggled die Section
104
+ // ohne zu navigieren (stopPropagation auf dem KumikoLink-Wrapper).
105
+ const chevron = hasChildren ? (
106
+ <button
107
+ type="button"
108
+ aria-label={t(isCollapsed ? "kumiko.nav.expand" : "kumiko.nav.collapse")}
109
+ aria-expanded={!isCollapsed}
110
+ onClick={(e) => {
111
+ e.preventDefault();
112
+ e.stopPropagation();
113
+ onToggle(node.qualifiedName);
114
+ }}
115
+ className="ml-auto flex size-4 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-accent-foreground"
116
+ >
117
+ {isCollapsed ? <ChevronRight className="size-3" /> : <ChevronDown className="size-3" />}
118
+ </button>
119
+ ) : null;
120
+
121
+ const children =
122
+ hasChildren && !isCollapsed
123
+ ? node.children.map((child) => (
124
+ <NavNodeItem
125
+ key={child.qualifiedName}
126
+ node={child}
127
+ depth={depth + 1}
128
+ collapsed={collapsed}
129
+ onToggle={onToggle}
130
+ />
131
+ ))
132
+ : null;
133
+
134
+ // Variante 1: Node hat einen Screen → KumikoLink. Wenn das Item auch
135
+ // children hat, sitzt der Chevron als Geschwister rechts NEBEN dem
136
+ // Link (nicht IM Link) — sonst würde ein <button> im <a> für invalid
137
+ // HTML sorgen. Wrapper-Div bekommt das hover/active-Styling, Link
138
+ // selbst ist nur die Klick-Fläche.
139
+ if (node.screen !== undefined) {
140
+ const screenId = lastSegment(node.screen);
141
+ const rowClass = cn(
142
+ "flex h-7 items-center gap-2 rounded-md text-[13px] transition-colors",
143
+ "hover:bg-accent/60 hover:text-foreground",
144
+ active
145
+ ? "bg-accent text-foreground font-medium"
146
+ : "text-muted-foreground hover:text-foreground",
147
+ );
148
+ return (
149
+ <>
150
+ <div style={indent} className={rowClass}>
151
+ <KumikoLink
152
+ to={{ ...(workspaceId !== undefined && { workspaceId }), screenId }}
153
+ className={cn(
154
+ "flex flex-1 min-w-0 items-center gap-2 px-2 h-full",
155
+ hasChildren && "pr-0",
156
+ )}
157
+ {...(active && { "aria-current": "page" })}
158
+ >
159
+ <span
160
+ aria-hidden="true"
161
+ className={cn(
162
+ "inline-block size-1.5 rounded-full",
163
+ active ? "bg-accent-foreground" : "bg-muted-foreground/40",
164
+ )}
165
+ />
166
+ <span className="truncate">{displayLabel}</span>
167
+ </KumikoLink>
168
+ {chevron !== null && <div className="pr-2">{chevron}</div>}
169
+ </div>
170
+ {children}
171
+ </>
172
+ );
173
+ }
174
+
175
+ // Variante 2: Node ist ein Section-Header (kein Screen). Mit children
176
+ // wird das Label zum Toggle-Button — Click klappt die ganze Section
177
+ // auf/zu. Chevron rendert hier als Span (kein nested button), weil
178
+ // der äußere Button schon das Toggle-Target ist. Ohne children
179
+ // rendert ein dezenter Section-Header (uppercase).
180
+ const chevronSpan = hasChildren ? (
181
+ <span aria-hidden="true" className="ml-auto flex size-4 items-center justify-center">
182
+ {isCollapsed ? <ChevronRight className="size-3" /> : <ChevronDown className="size-3" />}
183
+ </span>
184
+ ) : null;
185
+ return (
186
+ <>
187
+ {hasChildren ? (
188
+ <button
189
+ type="button"
190
+ onClick={() => onToggle(node.qualifiedName)}
191
+ aria-expanded={!isCollapsed}
192
+ style={indent}
193
+ className="flex h-7 items-center gap-2 rounded-md px-2 pt-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 hover:text-foreground transition-colors text-left"
194
+ >
195
+ <span className="truncate">{displayLabel}</span>
196
+ {chevronSpan}
197
+ </button>
198
+ ) : (
199
+ <div
200
+ style={indent}
201
+ className="px-2 pt-3 pb-1 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70"
202
+ >
203
+ {displayLabel}
204
+ </div>
205
+ )}
206
+ {children}
207
+ </>
208
+ );
209
+ }
210
+
211
+ // Backwards-kompatibler Single-Feature-Builder. Convenience-Wrapper um
212
+ // buildNavRegistrySliceForApp — bestehende Tests die direkt mit
213
+ // FeatureSchema rufen brauchen keine Änderung.
214
+ export function buildNavRegistrySlice(
215
+ schema: FeatureSchema,
216
+ allowedNavQns?: ReadonlySet<string>,
217
+ ): NavRegistrySlice {
218
+ return buildNavRegistrySliceForApp(toAppSchema(schema), allowedNavQns);
219
+ }
220
+
221
+ // Multi-Feature-Variante. Iteriert alle Features, qualifiziert pro
222
+ // Feature mit dem eigenen featureName. Reihenfolge: Features in der
223
+ // AppSchema-Ordnung, navs in der vom Feature deklarierten Reihenfolge.
224
+ //
225
+ // Cross-Feature-Workspaces sind hier nativ unterstützt — `navMembers`
226
+ // referenzieren QNs, der Filter trifft die jeweils richtigen Einträge
227
+ // egal in welchem Feature sie deklariert sind.
228
+ export function buildNavRegistrySliceForApp(
229
+ app: AppSchema,
230
+ allowedNavQns?: ReadonlySet<string>,
231
+ ): NavRegistrySlice {
232
+ const qualified: NavDefinition[] = [];
233
+ for (const feature of app.features) {
234
+ for (const n of feature.navs ?? []) {
235
+ qualified.push({
236
+ ...n,
237
+ id: qualifyNavId(feature.featureName, n.id),
238
+ ...(n.parent !== undefined && { parent: qualifyNavId(feature.featureName, n.parent) }),
239
+ ...(n.screen !== undefined && { screen: qualifyScreenId(feature.featureName, n.screen) }),
240
+ });
241
+ }
242
+ }
243
+ // Workspace filter: drop nav entries whose qualified id isn't in the
244
+ // allow-set. A child whose parent gets dropped surfaces as a top-level
245
+ // entry — the workspace owner should list parents explicitly if they
246
+ // want the grouping preserved.
247
+ const filtered =
248
+ allowedNavQns !== undefined ? qualified.filter((n) => allowedNavQns.has(n.id)) : qualified;
249
+ const allowedQnSet = new Set(filtered.map((n) => n.id));
250
+ const topLevel: NavDefinition[] = [];
251
+ const byParentMap = new Map<string, NavDefinition[]>();
252
+ for (const nav of filtered) {
253
+ // Treat the entry as top-level when its parent isn't visible — keeps
254
+ // children visible after filtering, even if the workspace omits the
255
+ // parent group from its members list.
256
+ const hasVisibleParent = nav.parent !== undefined && allowedQnSet.has(nav.parent);
257
+ if (hasVisibleParent && nav.parent !== undefined) {
258
+ const list = byParentMap.get(nav.parent) ?? [];
259
+ list.push(nav);
260
+ byParentMap.set(nav.parent, list);
261
+ } else {
262
+ topLevel.push(nav);
263
+ }
264
+ }
265
+ return {
266
+ topLevel,
267
+ byParent: (parent) => byParentMap.get(parent) ?? [],
268
+ };
269
+ }
270
+
271
+ function qualifyNavId(feature: string, id: string): string {
272
+ return id.includes(":nav:") ? id : `${feature}:nav:${id}`;
273
+ }
274
+
275
+ function qualifyScreenId(feature: string, id: string): string {
276
+ return id.includes(":screen:") ? id : `${feature}:screen:${id}`;
277
+ }
278
+
279
+ // `lastSegment` lebt jetzt in @cosmicdrift/kumiko-renderer (./app/qn) — eine
280
+ // Quelle, beide Pakete teilen sie.
281
+ export { lastSegment };
@@ -0,0 +1,40 @@
1
+ // Profile-Menu — dünner Wrapper über ActionMenu (siehe primitives/
2
+ // action-menu.tsx). Der Trigger ist immer ein Avatar; alles weitere
3
+ // (Items, Separators, Shortcut-Hints, Danger-Variant) kommt vom
4
+ // generischen ActionMenu. Wenn jemand einen anderen Trigger braucht
5
+ // (Three-Dots-Icon, Pill, Custom-Button), nimmt er ActionMenu direkt.
6
+ //
7
+ // ProfileMenuItem ist ein Alias für MenuItemDef — Bestehender Caller-
8
+ // Code bleibt typsicher, ohne dass wir den Discriminated-Union doppelt
9
+ // pflegen.
10
+ //
11
+ // Linear-Pattern: kompakter Avatar-Pill, Click öffnet Menü mit View-
12
+ // Profile / Settings / Sign-out + optional Keyboard-Shortcut-Hints.
13
+
14
+ import type { ReactNode } from "react";
15
+ import { ActionMenu, type MenuItemDef } from "../primitives/action-menu";
16
+ import { Avatar } from "./avatar";
17
+
18
+ export type ProfileMenuItem = MenuItemDef;
19
+
20
+ export type ProfileMenuProps = {
21
+ readonly user: {
22
+ readonly id: string;
23
+ /** Display-Name oder Email — Quelle für Initials. */
24
+ readonly label: string;
25
+ };
26
+ readonly items: readonly ProfileMenuItem[];
27
+ readonly testId?: string;
28
+ };
29
+
30
+ export function ProfileMenu({ user, items, testId }: ProfileMenuProps): ReactNode {
31
+ return (
32
+ <ActionMenu
33
+ trigger={<Avatar id={user.id} label={user.label} size="md" />}
34
+ triggerLabel={`Open ${user.label} menu`}
35
+ items={items}
36
+ align="end"
37
+ testId={testId ?? "profile-menu-trigger"}
38
+ />
39
+ );
40
+ }
@@ -0,0 +1,65 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ export type SidebarProps = {
4
+ /** Header-Bereich oben — typisch Workspace-Avatar + Name + Badge.
5
+ * Lebt VOR dem scroll-area, bleibt sichtbar wenn das nav scrollt. */
6
+ readonly header?: ReactNode;
7
+ /** Action-Cluster zwischen Header und Nav — typisch "+ Neu" Button
8
+ * und Search-Icon. Linear-Pattern: prominent, nicht im scroll-area. */
9
+ readonly actions?: ReactNode;
10
+ /** NavTree + freie Sections (z.B. "Your teams" Header + Sub-Items).
11
+ * Scrollt eigenständig wenn der Inhalt zu lang wird. */
12
+ readonly children: ReactNode;
13
+ /** Footer-Bereich unten — typisch Invite-Link, Help, Plan-Banner.
14
+ * Klebt am unteren Rand via mt-auto. */
15
+ readonly footer?: ReactNode;
16
+ readonly testId?: string;
17
+ };
18
+
19
+ export function Sidebar({ header, actions, children, footer, testId }: SidebarProps): ReactNode {
20
+ // Linear-Pattern: 4 vertikale Bereiche
21
+ // 1. Header (Workspace-Identity)
22
+ // 2. Actions (Quick-Buttons, nicht-scrollend)
23
+ // 3. Nav-Scroll-Area (NavTree, scrollt wenn nötig)
24
+ // 4. Footer (klebt unten via mt-auto)
25
+ // bg-muted/40 + border-r für visuelle Distinction zur Main-Area;
26
+ // tighter padding (p-3) und kompakte gap-0.5 zwischen Items.
27
+ return (
28
+ <aside
29
+ data-testid={testId}
30
+ data-kumiko-layout="sidebar"
31
+ className="w-60 flex-shrink-0 border-r bg-muted/30 flex flex-col text-sm"
32
+ >
33
+ {header !== undefined && (
34
+ <div
35
+ data-kumiko-layout="sidebar-header"
36
+ className="h-12 flex items-center px-3 border-b border-border/50"
37
+ >
38
+ {header}
39
+ </div>
40
+ )}
41
+ {actions !== undefined && (
42
+ <div
43
+ data-kumiko-layout="sidebar-actions"
44
+ className="px-3 py-2 border-b border-border/50 flex flex-row items-center gap-1"
45
+ >
46
+ {actions}
47
+ </div>
48
+ )}
49
+ <div
50
+ data-kumiko-layout="sidebar-nav"
51
+ className="px-3 py-2 flex flex-col gap-0.5 overflow-auto flex-1"
52
+ >
53
+ {children}
54
+ </div>
55
+ {footer !== undefined && (
56
+ <div
57
+ data-kumiko-layout="sidebar-footer"
58
+ className="px-3 py-2 border-t mt-auto flex flex-col gap-1 text-xs text-muted-foreground"
59
+ >
60
+ {footer}
61
+ </div>
62
+ )}
63
+ </aside>
64
+ );
65
+ }
@@ -0,0 +1,44 @@
1
+ // ThemeToggle — Button der useTokenController().toggleMode() aufruft.
2
+ // Icon-Slots als Props, damit renderer-web keine Icon-Lib als Hard-
3
+ // Dependency zieht. Default: Unicode-Glyphs (☀ / ☾) — funktionieren in
4
+ // jedem Browser, jede App kann lucide/heroicons/eigene SVG via Props
5
+ // reinreichen.
6
+
7
+ import { useTokenController } from "@cosmicdrift/kumiko-renderer";
8
+ import type { ReactNode } from "react";
9
+
10
+ export type ThemeToggleProps = {
11
+ /** Icon für den hellen Modus (wird angezeigt WENN aktuell dark →
12
+ * Klick wechselt zu light). Default: ☀ */
13
+ readonly lightIcon?: ReactNode;
14
+ /** Icon für den dunklen Modus (wird angezeigt WENN aktuell light →
15
+ * Klick wechselt zu dark). Default: ☾ */
16
+ readonly darkIcon?: ReactNode;
17
+ /** Title/aria-label im dunklen Modus. Default: "Heller Modus" */
18
+ readonly titleInDark?: string;
19
+ /** Title/aria-label im hellen Modus. Default: "Dunkler Modus" */
20
+ readonly titleInLight?: string;
21
+ readonly testId?: string;
22
+ };
23
+
24
+ export function ThemeToggle({
25
+ lightIcon = "☀",
26
+ darkIcon = "☾",
27
+ titleInDark = "Heller Modus",
28
+ titleInLight = "Dunkler Modus",
29
+ testId,
30
+ }: ThemeToggleProps): ReactNode {
31
+ const { mode, toggleMode } = useTokenController();
32
+ return (
33
+ <button
34
+ type="button"
35
+ onClick={toggleMode}
36
+ className="inline-flex h-8 w-8 items-center justify-center rounded-md border bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
37
+ title={mode === "dark" ? titleInDark : titleInLight}
38
+ aria-label={mode === "dark" ? titleInDark : titleInLight}
39
+ data-testid={testId}
40
+ >
41
+ {mode === "dark" ? lightIcon : darkIcon}
42
+ </button>
43
+ );
44
+ }
@@ -0,0 +1,22 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ export type TopbarProps = {
4
+ readonly start?: ReactNode;
5
+ readonly center?: ReactNode;
6
+ readonly end?: ReactNode;
7
+ readonly testId?: string;
8
+ };
9
+
10
+ export function Topbar({ start, center, end, testId }: TopbarProps): ReactNode {
11
+ return (
12
+ <header
13
+ data-testid={testId}
14
+ data-kumiko-layout="topbar"
15
+ className="flex h-12 items-center gap-4 border-b bg-background px-4 text-sm"
16
+ >
17
+ {start !== undefined && <div className="flex items-center gap-3">{start}</div>}
18
+ {center !== undefined && <nav className="flex flex-1 items-center gap-6">{center}</nav>}
19
+ {end !== undefined && <div className="ml-auto flex items-center gap-2">{end}</div>}
20
+ </header>
21
+ );
22
+ }