@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,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
|
+
}
|