@cosmicdrift/kumiko-renderer-web 0.232.0 → 0.234.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 +5 -5
- package/src/__tests__/app-layout.test.tsx +13 -12
- package/src/__tests__/default-app-shell.test.tsx +33 -0
- package/src/__tests__/form-action-bar.test.tsx +53 -4
- package/src/__tests__/kumiko-screen.test.tsx +293 -3
- package/src/__tests__/primitives.test.tsx +245 -15
- package/src/__tests__/projection-detail-record-layout.test.tsx +91 -0
- package/src/__tests__/render-edit.test.tsx +29 -10
- package/src/__tests__/render-list-narrow-cards.test.tsx +187 -0
- package/src/__tests__/workspace-shell.test.tsx +5 -4
- package/src/icons.tsx +199 -0
- package/src/layout/app-layout.tsx +7 -7
- package/src/layout/default-app-shell.tsx +3 -2
- package/src/layout/nav-tree.tsx +25 -122
- package/src/layout/workspace-shell.tsx +3 -2
- package/src/primitives/__tests__/button.test.tsx +37 -0
- package/src/primitives/__tests__/data-table-logic.test.ts +33 -0
- package/src/primitives/__tests__/select-segmented.test.tsx +84 -0
- package/src/primitives/index.tsx +475 -50
- package/src/primitives/metric.tsx +18 -10
- package/src/styles.css +21 -17
- package/src/ui/switch.tsx +35 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// DataTable — card layout below the 768px breakpoint (offlot#37). Below
|
|
2
|
+
// that width the table scrolled its columns out of reach with no visible
|
|
3
|
+
// affordance (worse: the `md:sticky` actions column scrolled away WITH the
|
|
4
|
+
// last data columns instead of staying reachable). These tests pin the
|
|
5
|
+
// replacement: below the breakpoint, no <table> at all — one card per row,
|
|
6
|
+
// every ViewModel column present as a label/value pair, actions always
|
|
7
|
+
// visible, and a native <select> standing in for the header-click sort
|
|
8
|
+
// affordance that has no header to attach to down here.
|
|
9
|
+
//
|
|
10
|
+
// Viewport is driven the same way embedded-list-input.test.tsx does it —
|
|
11
|
+
// happy-dom's real innerWidth backs useIsNarrowViewport's matchMedia query,
|
|
12
|
+
// so no matchMedia mock is needed.
|
|
13
|
+
|
|
14
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
15
|
+
import userEvent from "@testing-library/user-event";
|
|
16
|
+
import { defaultPrimitives } from "../primitives";
|
|
17
|
+
import { fireEvent, render, screen, within } from "./test-utils";
|
|
18
|
+
|
|
19
|
+
const { DataTable } = defaultPrimitives;
|
|
20
|
+
|
|
21
|
+
function setViewportWidth(width: number): void {
|
|
22
|
+
(
|
|
23
|
+
window as unknown as { happyDOM: { setInnerWidth: (n: number) => void } }
|
|
24
|
+
).happyDOM.setInnerWidth(width);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function withViewportWidth(width: number, run: () => void): void {
|
|
28
|
+
const originalWidth = window.innerWidth;
|
|
29
|
+
setViewportWidth(width);
|
|
30
|
+
try {
|
|
31
|
+
run();
|
|
32
|
+
} finally {
|
|
33
|
+
setViewportWidth(originalWidth);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const LONG_BIO =
|
|
38
|
+
"Anna joined the handler team in 2019 and has led onboarding for every partner integration since, focusing on payment reconciliation edge cases and cross-border tax handling.";
|
|
39
|
+
|
|
40
|
+
const COLUMNS = [
|
|
41
|
+
{ field: "name", label: "Name", type: "string", sortable: true },
|
|
42
|
+
{ field: "email", label: "Email", type: "string", sortable: false },
|
|
43
|
+
{ field: "role", label: "Role", type: "string", sortable: true },
|
|
44
|
+
{ field: "bio", label: "Bio", type: "string", sortable: false },
|
|
45
|
+
] as const;
|
|
46
|
+
|
|
47
|
+
const ROWS = [
|
|
48
|
+
{
|
|
49
|
+
id: "u1",
|
|
50
|
+
values: { name: "Anna Beispiel", email: "anna@haendler.de", role: "Admin", bio: LONG_BIO },
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
describe("DataTable — cards below 768px", () => {
|
|
55
|
+
test("desktop viewport: table renders as before, no cards", () => {
|
|
56
|
+
withViewportWidth(1024, () => {
|
|
57
|
+
render(<DataTable columns={COLUMNS} rows={ROWS} testId="t" />);
|
|
58
|
+
expect(screen.getByTestId("t").tagName).toBe("TABLE");
|
|
59
|
+
expect(screen.queryByTestId("t-cards")).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("narrow viewport: no <table>, one card per row, every column present as label + value", () => {
|
|
64
|
+
withViewportWidth(500, () => {
|
|
65
|
+
render(<DataTable columns={COLUMNS} rows={ROWS} testId="t" />);
|
|
66
|
+
expect(document.querySelector("table")).toBeNull();
|
|
67
|
+
const card = within(screen.getByTestId("t-cards")).getByTestId("row-u1");
|
|
68
|
+
// Title = first column (none highlighted here) — still findable, and
|
|
69
|
+
// not duplicated as a label/value pair below.
|
|
70
|
+
expect(card.textContent).toContain("Anna Beispiel");
|
|
71
|
+
for (const col of COLUMNS.filter((c) => c.field !== "name")) {
|
|
72
|
+
expect(within(card).getByText(col.label)).not.toBeNull();
|
|
73
|
+
}
|
|
74
|
+
expect(within(card).getByTestId("cell-u1-email").textContent).toBe("anna@haendler.de");
|
|
75
|
+
expect(within(card).getByTestId("cell-u1-role").textContent).toBe("Admin");
|
|
76
|
+
expect(within(card).getByTestId("cell-u1-bio").textContent).toBe(LONG_BIO);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("highlighted column becomes the card title and does not repeat as a label/value pair", () => {
|
|
81
|
+
withViewportWidth(500, () => {
|
|
82
|
+
const columns = COLUMNS.map((c) => (c.field === "role" ? { ...c, highlighted: true } : c));
|
|
83
|
+
render(<DataTable columns={columns} rows={ROWS} testId="t" />);
|
|
84
|
+
const card = within(screen.getByTestId("t-cards")).getByTestId("row-u1");
|
|
85
|
+
expect(within(card).queryByText("Role")).toBeNull();
|
|
86
|
+
expect(within(card).getByText("Name")).not.toBeNull();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("a value the table would truncate is shown in full in the card, without the truncate class", () => {
|
|
91
|
+
const originalWidth = window.innerWidth;
|
|
92
|
+
try {
|
|
93
|
+
setViewportWidth(1024);
|
|
94
|
+
const { unmount } = render(<DataTable columns={COLUMNS} rows={ROWS} testId="t" />);
|
|
95
|
+
const desktopCell = screen.getByTestId("cell-u1-bio");
|
|
96
|
+
expect(desktopCell.className).toContain("truncate");
|
|
97
|
+
unmount();
|
|
98
|
+
|
|
99
|
+
setViewportWidth(500);
|
|
100
|
+
render(<DataTable columns={COLUMNS} rows={ROWS} testId="t" />);
|
|
101
|
+
const cardCell = screen.getByTestId("cell-u1-bio");
|
|
102
|
+
expect(cardCell.className).not.toContain("truncate");
|
|
103
|
+
expect(cardCell.textContent).toBe(LONG_BIO);
|
|
104
|
+
} finally {
|
|
105
|
+
setViewportWidth(originalWidth);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("row actions are present in the DOM and operable in card mode", async () => {
|
|
110
|
+
const originalWidth = window.innerWidth;
|
|
111
|
+
setViewportWidth(500);
|
|
112
|
+
try {
|
|
113
|
+
const onTrigger = mock();
|
|
114
|
+
render(
|
|
115
|
+
<DataTable
|
|
116
|
+
columns={COLUMNS}
|
|
117
|
+
rows={ROWS}
|
|
118
|
+
rowActions={[{ id: "edit", label: "Edit", onTrigger }]}
|
|
119
|
+
testId="t"
|
|
120
|
+
/>,
|
|
121
|
+
);
|
|
122
|
+
const button = screen.getByTestId("row-u1-action-edit");
|
|
123
|
+
await userEvent.setup().click(button);
|
|
124
|
+
expect(onTrigger).toHaveBeenCalledTimes(1);
|
|
125
|
+
} finally {
|
|
126
|
+
setViewportWidth(originalWidth);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("empty state renders the same way in card mode, no card container", () => {
|
|
131
|
+
withViewportWidth(500, () => {
|
|
132
|
+
render(<DataTable columns={COLUMNS} rows={[]} testId="t" />);
|
|
133
|
+
expect(screen.getByTestId("t-empty")).not.toBeNull();
|
|
134
|
+
expect(screen.queryByTestId("t-cards")).toBeNull();
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// No column headers below the breakpoint, so SortableHeader's click-to-sort
|
|
140
|
+
// has nothing to attach to — a native <select> fed from the sortable
|
|
141
|
+
// columns is the whole replacement, only rendered when there is something
|
|
142
|
+
// to sort and somewhere for the result to go.
|
|
143
|
+
describe("DataTable — card-mode sort select", () => {
|
|
144
|
+
test("lists only the sortable columns, both directions, and reports the picked one", () => {
|
|
145
|
+
withViewportWidth(500, () => {
|
|
146
|
+
const onSortChange = mock();
|
|
147
|
+
render(<DataTable columns={COLUMNS} rows={ROWS} onSortChange={onSortChange} testId="t" />);
|
|
148
|
+
const select = screen.getByTestId("t-sort") as HTMLSelectElement;
|
|
149
|
+
const optionLabels = Array.from(select.options).map((o) => o.textContent);
|
|
150
|
+
expect(optionLabels).toEqual(["Unsorted", "Name ↑", "Name ↓", "Role ↑", "Role ↓"]);
|
|
151
|
+
|
|
152
|
+
fireEvent.change(select, { target: { value: "role:desc" } });
|
|
153
|
+
expect(onSortChange).toHaveBeenCalledWith({ field: "role", dir: "desc" });
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("no select without onSortChange — nothing to wire it to", () => {
|
|
158
|
+
withViewportWidth(500, () => {
|
|
159
|
+
render(<DataTable columns={COLUMNS} rows={ROWS} testId="t" />);
|
|
160
|
+
expect(screen.queryByTestId("t-sort")).toBeNull();
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("no select when no column is sortable", () => {
|
|
165
|
+
withViewportWidth(500, () => {
|
|
166
|
+
const onSortChange = mock();
|
|
167
|
+
const nonSortableColumns = COLUMNS.map((c) => ({ ...c, sortable: false }));
|
|
168
|
+
render(
|
|
169
|
+
<DataTable
|
|
170
|
+
columns={nonSortableColumns}
|
|
171
|
+
rows={ROWS}
|
|
172
|
+
onSortChange={onSortChange}
|
|
173
|
+
testId="t"
|
|
174
|
+
/>,
|
|
175
|
+
);
|
|
176
|
+
expect(screen.queryByTestId("t-sort")).toBeNull();
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("desktop viewport never renders the select — header clicks already cover it", () => {
|
|
181
|
+
withViewportWidth(1024, () => {
|
|
182
|
+
const onSortChange = mock();
|
|
183
|
+
render(<DataTable columns={COLUMNS} rows={ROWS} onSortChange={onSortChange} testId="t" />);
|
|
184
|
+
expect(screen.queryByTestId("t-sort")).toBeNull();
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
});
|
|
@@ -440,13 +440,12 @@ describe("WorkspaceShell", () => {
|
|
|
440
440
|
expect(screen.queryByText("Tours")).toBeNull();
|
|
441
441
|
});
|
|
442
442
|
|
|
443
|
-
test("fill
|
|
443
|
+
test("fill defaults to true: applies h-svh on root and min-h-0 on inset/main", () => {
|
|
444
444
|
renderShell(
|
|
445
445
|
<WorkspaceShell
|
|
446
446
|
brand={<div>Brand</div>}
|
|
447
447
|
schema={schema}
|
|
448
448
|
user={{ id: "u1", roles: ["admin"] }}
|
|
449
|
-
fill
|
|
450
449
|
>
|
|
451
450
|
<div>content</div>
|
|
452
451
|
</WorkspaceShell>,
|
|
@@ -462,13 +461,15 @@ describe("WorkspaceShell", () => {
|
|
|
462
461
|
expect(innerMain?.classList.contains("overflow-auto")).toBe(true);
|
|
463
462
|
});
|
|
464
463
|
|
|
465
|
-
//
|
|
466
|
-
|
|
464
|
+
// fill={false} is the escape hatch back to page-scroll: must not apply
|
|
465
|
+
// viewport-lock classes.
|
|
466
|
+
test("fill={false} does not apply h-svh or min-h-0", () => {
|
|
467
467
|
renderShell(
|
|
468
468
|
<WorkspaceShell
|
|
469
469
|
brand={<div>Brand</div>}
|
|
470
470
|
schema={schema}
|
|
471
471
|
user={{ id: "u1", roles: ["admin"] }}
|
|
472
|
+
fill={false}
|
|
472
473
|
>
|
|
473
474
|
<div>content</div>
|
|
474
475
|
</WorkspaceShell>,
|
package/src/icons.tsx
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Icon registry: symbolic `NavIconKey` → lucide-react component. `NavIconKey`
|
|
2
|
+
// is the closed vocabulary a feature author can write (packages/types/src/
|
|
3
|
+
// nav-icon.ts); `satisfies` below makes this map a compile-time drift
|
|
4
|
+
// guard — a key added to one without the other fails the build.
|
|
5
|
+
//
|
|
6
|
+
// Runtime data (provider-supplied nav/action icons) is only typed as plain
|
|
7
|
+
// `string` at the resolved-tree layer, so an unknown key can still show up
|
|
8
|
+
// at runtime on occasion — `Icon` falls back to rendering the raw key as
|
|
9
|
+
// text rather than crashing, mirroring the old `ActionGlyph` behavior in
|
|
10
|
+
// nav-tree.tsx.
|
|
11
|
+
//
|
|
12
|
+
// Buttons resolve icons through the same map — importing it from the nav
|
|
13
|
+
// layout module would couple them to the sidebar.
|
|
14
|
+
import type { NavIconKey } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
15
|
+
import {
|
|
16
|
+
AlertTriangle,
|
|
17
|
+
Archive,
|
|
18
|
+
ArrowLeft,
|
|
19
|
+
ArrowRight,
|
|
20
|
+
BarChart3,
|
|
21
|
+
Bell,
|
|
22
|
+
BookOpen,
|
|
23
|
+
Building,
|
|
24
|
+
Calculator,
|
|
25
|
+
CalendarDays,
|
|
26
|
+
Check,
|
|
27
|
+
CheckCircle2,
|
|
28
|
+
ChevronDown,
|
|
29
|
+
ChevronRight,
|
|
30
|
+
ClipboardList,
|
|
31
|
+
Clock,
|
|
32
|
+
Coins,
|
|
33
|
+
Copy,
|
|
34
|
+
CreditCard,
|
|
35
|
+
Download,
|
|
36
|
+
ExternalLink,
|
|
37
|
+
Eye,
|
|
38
|
+
EyeOff,
|
|
39
|
+
FileText,
|
|
40
|
+
Filter,
|
|
41
|
+
Flag,
|
|
42
|
+
Folder,
|
|
43
|
+
FolderOpen,
|
|
44
|
+
Gauge,
|
|
45
|
+
Hash,
|
|
46
|
+
Home,
|
|
47
|
+
Info,
|
|
48
|
+
KeyRound,
|
|
49
|
+
Languages,
|
|
50
|
+
Layers,
|
|
51
|
+
LayoutDashboard,
|
|
52
|
+
LayoutGrid,
|
|
53
|
+
LineChart,
|
|
54
|
+
Link,
|
|
55
|
+
List,
|
|
56
|
+
Loader2,
|
|
57
|
+
Lock,
|
|
58
|
+
Mail,
|
|
59
|
+
MapPin,
|
|
60
|
+
MoreHorizontal,
|
|
61
|
+
MoreVertical,
|
|
62
|
+
Package,
|
|
63
|
+
Palette,
|
|
64
|
+
Pencil,
|
|
65
|
+
Phone,
|
|
66
|
+
PiggyBank,
|
|
67
|
+
Plus,
|
|
68
|
+
Printer,
|
|
69
|
+
Receipt,
|
|
70
|
+
RefreshCw,
|
|
71
|
+
Rocket,
|
|
72
|
+
Save,
|
|
73
|
+
Search,
|
|
74
|
+
Send,
|
|
75
|
+
Server,
|
|
76
|
+
Settings,
|
|
77
|
+
Share2,
|
|
78
|
+
Shield,
|
|
79
|
+
ShieldCheck,
|
|
80
|
+
Sparkles,
|
|
81
|
+
Star,
|
|
82
|
+
Table,
|
|
83
|
+
Tag,
|
|
84
|
+
Trash2,
|
|
85
|
+
TrendingUp,
|
|
86
|
+
Undo2,
|
|
87
|
+
Upload,
|
|
88
|
+
User,
|
|
89
|
+
Users,
|
|
90
|
+
Wallet,
|
|
91
|
+
Wand2,
|
|
92
|
+
X,
|
|
93
|
+
XCircle,
|
|
94
|
+
} from "lucide-react";
|
|
95
|
+
import type { ReactNode } from "react";
|
|
96
|
+
import { cn } from "./lib/cn";
|
|
97
|
+
|
|
98
|
+
export const NAV_ICONS = {
|
|
99
|
+
dashboard: LayoutDashboard,
|
|
100
|
+
"layout-grid": LayoutGrid,
|
|
101
|
+
"book-open": BookOpen,
|
|
102
|
+
"clipboard-list": ClipboardList,
|
|
103
|
+
package: Package,
|
|
104
|
+
gauge: Gauge,
|
|
105
|
+
list: List,
|
|
106
|
+
table: Table,
|
|
107
|
+
layers: Layers,
|
|
108
|
+
building: Building,
|
|
109
|
+
calculator: Calculator,
|
|
110
|
+
wallet: Wallet,
|
|
111
|
+
coins: Coins,
|
|
112
|
+
"credit-card": CreditCard,
|
|
113
|
+
"piggy-bank": PiggyBank,
|
|
114
|
+
receipt: Receipt,
|
|
115
|
+
chart: LineChart,
|
|
116
|
+
"bar-chart": BarChart3,
|
|
117
|
+
trending: TrendingUp,
|
|
118
|
+
sparkles: Sparkles,
|
|
119
|
+
wand: Wand2,
|
|
120
|
+
calendar: CalendarDays,
|
|
121
|
+
file: FileText,
|
|
122
|
+
folder: Folder,
|
|
123
|
+
"folder-open": FolderOpen,
|
|
124
|
+
home: Home,
|
|
125
|
+
bell: Bell,
|
|
126
|
+
shield: Shield,
|
|
127
|
+
"shield-check": ShieldCheck,
|
|
128
|
+
send: Send,
|
|
129
|
+
settings: Settings,
|
|
130
|
+
users: Users,
|
|
131
|
+
user: User,
|
|
132
|
+
search: Search,
|
|
133
|
+
tag: Tag,
|
|
134
|
+
key: KeyRound,
|
|
135
|
+
link: Link,
|
|
136
|
+
palette: Palette,
|
|
137
|
+
share: Share2,
|
|
138
|
+
server: Server,
|
|
139
|
+
mail: Mail,
|
|
140
|
+
lock: Lock,
|
|
141
|
+
hash: Hash,
|
|
142
|
+
download: Download,
|
|
143
|
+
upload: Upload,
|
|
144
|
+
rocket: Rocket,
|
|
145
|
+
// Was imported but never registered — `icon: "plus"` silently fell back.
|
|
146
|
+
plus: Plus,
|
|
147
|
+
languages: Languages,
|
|
148
|
+
trash: Trash2,
|
|
149
|
+
x: X,
|
|
150
|
+
check: Check,
|
|
151
|
+
"arrow-left": ArrowLeft,
|
|
152
|
+
"arrow-right": ArrowRight,
|
|
153
|
+
copy: Copy,
|
|
154
|
+
pencil: Pencil,
|
|
155
|
+
eye: Eye,
|
|
156
|
+
"eye-off": EyeOff,
|
|
157
|
+
filter: Filter,
|
|
158
|
+
refresh: RefreshCw,
|
|
159
|
+
"more-horizontal": MoreHorizontal,
|
|
160
|
+
"more-vertical": MoreVertical,
|
|
161
|
+
"external-link": ExternalLink,
|
|
162
|
+
"chevron-down": ChevronDown,
|
|
163
|
+
"chevron-right": ChevronRight,
|
|
164
|
+
save: Save,
|
|
165
|
+
undo: Undo2,
|
|
166
|
+
archive: Archive,
|
|
167
|
+
star: Star,
|
|
168
|
+
flag: Flag,
|
|
169
|
+
clock: Clock,
|
|
170
|
+
"map-pin": MapPin,
|
|
171
|
+
phone: Phone,
|
|
172
|
+
printer: Printer,
|
|
173
|
+
"alert-triangle": AlertTriangle,
|
|
174
|
+
info: Info,
|
|
175
|
+
"check-circle": CheckCircle2,
|
|
176
|
+
"x-circle": XCircle,
|
|
177
|
+
loader: Loader2,
|
|
178
|
+
} as const satisfies Readonly<Record<NavIconKey, typeof Folder>>;
|
|
179
|
+
|
|
180
|
+
// Widened alias for runtime lookups against the plain `string` icon keys
|
|
181
|
+
// resolved-tree nodes carry — not the closed NavIconKey union NAV_ICONS
|
|
182
|
+
// itself is typed against.
|
|
183
|
+
const ICON_LOOKUP: Readonly<Record<string, typeof Folder | undefined>> = NAV_ICONS;
|
|
184
|
+
|
|
185
|
+
export function Icon({
|
|
186
|
+
name,
|
|
187
|
+
className,
|
|
188
|
+
}: {
|
|
189
|
+
readonly name: NavIconKey;
|
|
190
|
+
readonly className?: string;
|
|
191
|
+
}): ReactNode {
|
|
192
|
+
const LucideIcon = Object.hasOwn(NAV_ICONS, name) ? ICON_LOOKUP[name] : undefined;
|
|
193
|
+
if (LucideIcon !== undefined) return <LucideIcon aria-hidden="true" className={className} />;
|
|
194
|
+
return (
|
|
195
|
+
<span aria-hidden="true" className={cn("text-xs", className)}>
|
|
196
|
+
{name}
|
|
197
|
+
</span>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
@@ -13,12 +13,12 @@ export type AppLayoutProps = {
|
|
|
13
13
|
readonly sidebar?: ReactNode;
|
|
14
14
|
readonly children: ReactNode;
|
|
15
15
|
readonly testId?: string;
|
|
16
|
-
/** Viewport-fit Shell. true → Wurzel = `h-screen` (fixe
|
|
17
|
-
* Sidebar/Topbar bleiben stehen, der Main-Bereich
|
|
18
|
-
* (`min-h-0` + `overflow-auto`). false
|
|
19
|
-
* `min-h-screen`-Flow, die ganze Seite scrollt
|
|
20
|
-
*
|
|
21
|
-
* Clippt nie — der Content scrollt in `main` statt im Body. */
|
|
16
|
+
/** Viewport-fit Shell. Default `true` → Wurzel = `h-screen` (fixe
|
|
17
|
+
* Viewport-Höhe), Sidebar/Topbar bleiben stehen, der Main-Bereich
|
|
18
|
+
* scrollt INNEN (`min-h-0` + `overflow-auto`). `false` → klassischer
|
|
19
|
+
* `min-h-screen`-Flow, die ganze Seite scrollt — Escape-Hatch für
|
|
20
|
+
* eine öffentliche, lange Content-Seite die echten Body-Scroll
|
|
21
|
+
* braucht. Clippt nie — der Content scrollt in `main` statt im Body. */
|
|
22
22
|
readonly fill?: boolean;
|
|
23
23
|
/** Optionaler Klassen-Append an die Wurzel (eigener Hintergrund etc.).
|
|
24
24
|
* Erweitert die Defaults, ersetzt sie nicht (cn-merge). */
|
|
@@ -32,7 +32,7 @@ export function AppLayout({
|
|
|
32
32
|
sidebar,
|
|
33
33
|
children,
|
|
34
34
|
testId,
|
|
35
|
-
fill,
|
|
35
|
+
fill = true,
|
|
36
36
|
className,
|
|
37
37
|
mainClassName,
|
|
38
38
|
}: AppLayoutProps): ReactNode {
|
|
@@ -68,7 +68,8 @@ export type DefaultAppShellProps = {
|
|
|
68
68
|
* Plan-Banner. */
|
|
69
69
|
readonly sidebarFooter?: ReactNode;
|
|
70
70
|
/** Viewport-fit Shell. true → fixe Viewport-Höhe (`h-svh`), der Content
|
|
71
|
-
* scrollt INNEN statt der ganzen Seite. Default
|
|
71
|
+
* scrollt INNEN statt der ganzen Seite. Default true — `fill={false}`
|
|
72
|
+
* schaltet zurück auf Seiten-Scroll. */
|
|
72
73
|
readonly fill?: boolean;
|
|
73
74
|
/** Screen-Content der im SidebarInset gerendert wird. */
|
|
74
75
|
readonly children: ReactNode;
|
|
@@ -82,7 +83,7 @@ export function DefaultAppShell({
|
|
|
82
83
|
headerActions,
|
|
83
84
|
navBadges,
|
|
84
85
|
sidebarFooter,
|
|
85
|
-
fill,
|
|
86
|
+
fill = true,
|
|
86
87
|
children,
|
|
87
88
|
}: DefaultAppShellProps): ReactNode {
|
|
88
89
|
const fillCls = fillClasses(fill);
|
package/src/layout/nav-tree.tsx
CHANGED
|
@@ -17,7 +17,7 @@ import type {
|
|
|
17
17
|
TreeAction,
|
|
18
18
|
TreeNode,
|
|
19
19
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
20
|
-
import type { NavDefinition
|
|
20
|
+
import type { NavDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
21
21
|
import type { NavNode, NavRegistrySlice } from "@cosmicdrift/kumiko-headless";
|
|
22
22
|
import { resolveNavigation } from "@cosmicdrift/kumiko-headless";
|
|
23
23
|
import type { AppSchema, FeatureSchema } from "@cosmicdrift/kumiko-renderer";
|
|
@@ -28,58 +28,7 @@ import {
|
|
|
28
28
|
useNav,
|
|
29
29
|
useTranslation,
|
|
30
30
|
} from "@cosmicdrift/kumiko-renderer";
|
|
31
|
-
import {
|
|
32
|
-
BarChart3,
|
|
33
|
-
Bell,
|
|
34
|
-
BookOpen,
|
|
35
|
-
Building,
|
|
36
|
-
Calculator,
|
|
37
|
-
CalendarDays,
|
|
38
|
-
ChevronDown,
|
|
39
|
-
ChevronRight,
|
|
40
|
-
ClipboardList,
|
|
41
|
-
Coins,
|
|
42
|
-
CreditCard,
|
|
43
|
-
Download,
|
|
44
|
-
FileText,
|
|
45
|
-
Folder,
|
|
46
|
-
FolderOpen,
|
|
47
|
-
Gauge,
|
|
48
|
-
Hash,
|
|
49
|
-
Home,
|
|
50
|
-
KeyRound,
|
|
51
|
-
Languages,
|
|
52
|
-
Layers,
|
|
53
|
-
LayoutDashboard,
|
|
54
|
-
LayoutGrid,
|
|
55
|
-
LineChart,
|
|
56
|
-
Link,
|
|
57
|
-
List,
|
|
58
|
-
Lock,
|
|
59
|
-
Mail,
|
|
60
|
-
Package,
|
|
61
|
-
Palette,
|
|
62
|
-
PiggyBank,
|
|
63
|
-
Plus,
|
|
64
|
-
Receipt,
|
|
65
|
-
Rocket,
|
|
66
|
-
Search,
|
|
67
|
-
Send,
|
|
68
|
-
Server,
|
|
69
|
-
Settings,
|
|
70
|
-
Share2,
|
|
71
|
-
Shield,
|
|
72
|
-
ShieldCheck,
|
|
73
|
-
Sparkles,
|
|
74
|
-
Table,
|
|
75
|
-
Tag,
|
|
76
|
-
TrendingUp,
|
|
77
|
-
Upload,
|
|
78
|
-
User,
|
|
79
|
-
Users,
|
|
80
|
-
Wallet,
|
|
81
|
-
Wand2,
|
|
82
|
-
} from "lucide-react";
|
|
31
|
+
import { ChevronDown, ChevronRight, type Folder, Plus } from "lucide-react";
|
|
83
32
|
import {
|
|
84
33
|
createContext,
|
|
85
34
|
type ReactNode,
|
|
@@ -91,6 +40,7 @@ import {
|
|
|
91
40
|
} from "react";
|
|
92
41
|
import { KumikoLink } from "../app/nav";
|
|
93
42
|
import { useNavEntities, useNavProviders } from "../app/nav-providers-context";
|
|
43
|
+
import { NAV_ICONS } from "../icons";
|
|
94
44
|
import { cn } from "../lib/cn";
|
|
95
45
|
import {
|
|
96
46
|
SidebarGroup,
|
|
@@ -109,69 +59,6 @@ import {
|
|
|
109
59
|
import { useDispatchTarget } from "./target-resolver-stub";
|
|
110
60
|
import { parseTargetFromSearchParams } from "./target-url";
|
|
111
61
|
|
|
112
|
-
// Nav-icon registry: a nav entry sets `icon: "<key>"` (in the r.nav decl),
|
|
113
|
-
// the renderer maps the symbolic key to a lucide component. `NavIconKey` is
|
|
114
|
-
// the closed vocabulary a feature author can write (packages/types/src/
|
|
115
|
-
// nav-icon.ts); `satisfies` below makes this map a compile-time drift
|
|
116
|
-
// guard — a key added to one without the other fails the build.
|
|
117
|
-
//
|
|
118
|
-
// `node.icon`/`TreeAction.icon` stay plain `string` at the resolved-tree
|
|
119
|
-
// layer (dynamic/provider-supplied data isn't statically known), so the
|
|
120
|
-
// runtime `Object.hasOwn` lookups below still see an unknown key on
|
|
121
|
-
// occasion — that's the defense-in-depth fallback to the dot, not the
|
|
122
|
-
// primary guard anymore.
|
|
123
|
-
const NAV_ICONS = {
|
|
124
|
-
dashboard: LayoutDashboard,
|
|
125
|
-
"layout-grid": LayoutGrid,
|
|
126
|
-
"book-open": BookOpen,
|
|
127
|
-
"clipboard-list": ClipboardList,
|
|
128
|
-
package: Package,
|
|
129
|
-
gauge: Gauge,
|
|
130
|
-
list: List,
|
|
131
|
-
table: Table,
|
|
132
|
-
layers: Layers,
|
|
133
|
-
building: Building,
|
|
134
|
-
calculator: Calculator,
|
|
135
|
-
wallet: Wallet,
|
|
136
|
-
coins: Coins,
|
|
137
|
-
"credit-card": CreditCard,
|
|
138
|
-
"piggy-bank": PiggyBank,
|
|
139
|
-
receipt: Receipt,
|
|
140
|
-
chart: LineChart,
|
|
141
|
-
"bar-chart": BarChart3,
|
|
142
|
-
trending: TrendingUp,
|
|
143
|
-
sparkles: Sparkles,
|
|
144
|
-
wand: Wand2,
|
|
145
|
-
calendar: CalendarDays,
|
|
146
|
-
file: FileText,
|
|
147
|
-
folder: Folder,
|
|
148
|
-
"folder-open": FolderOpen,
|
|
149
|
-
home: Home,
|
|
150
|
-
bell: Bell,
|
|
151
|
-
shield: Shield,
|
|
152
|
-
"shield-check": ShieldCheck,
|
|
153
|
-
send: Send,
|
|
154
|
-
settings: Settings,
|
|
155
|
-
users: Users,
|
|
156
|
-
user: User,
|
|
157
|
-
search: Search,
|
|
158
|
-
tag: Tag,
|
|
159
|
-
key: KeyRound,
|
|
160
|
-
link: Link,
|
|
161
|
-
palette: Palette,
|
|
162
|
-
share: Share2,
|
|
163
|
-
server: Server,
|
|
164
|
-
mail: Mail,
|
|
165
|
-
lock: Lock,
|
|
166
|
-
hash: Hash,
|
|
167
|
-
download: Download,
|
|
168
|
-
upload: Upload,
|
|
169
|
-
rocket: Rocket,
|
|
170
|
-
// Was imported but never registered — `icon: "plus"` silently fell back.
|
|
171
|
-
plus: Plus,
|
|
172
|
-
languages: Languages,
|
|
173
|
-
} as const satisfies Readonly<Record<NavIconKey, typeof Folder>>;
|
|
174
|
-
|
|
175
62
|
// Widened alias for the two lookup sites below, which index by the plain
|
|
176
63
|
// `string` icon key of the resolved NavNode/TreeAction tree — not the
|
|
177
64
|
// closed NavIconKey union NAV_ICONS itself is typed against.
|
|
@@ -711,7 +598,10 @@ function NavMenuNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
|
|
|
711
598
|
{...(s.active && { "aria-current": "page" })}
|
|
712
599
|
>
|
|
713
600
|
<NavLeadingIcon node={node} active={s.active} label={s.displayLabel} />
|
|
714
|
-
<span
|
|
601
|
+
<span
|
|
602
|
+
className="min-w-0 truncate group-data-[collapsible=icon]:hidden"
|
|
603
|
+
title={s.displayLabel}
|
|
604
|
+
>
|
|
715
605
|
{s.displayLabel}
|
|
716
606
|
</span>
|
|
717
607
|
<NavBadge node={node} />
|
|
@@ -734,7 +624,10 @@ function NavMenuNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
|
|
|
734
624
|
onClick={() => dispatch(target)}
|
|
735
625
|
>
|
|
736
626
|
<NavLeadingIcon node={node} active={s.active} label={s.displayLabel} />
|
|
737
|
-
<span
|
|
627
|
+
<span
|
|
628
|
+
className="min-w-0 truncate group-data-[collapsible=icon]:hidden"
|
|
629
|
+
title={s.displayLabel}
|
|
630
|
+
>
|
|
738
631
|
{s.displayLabel}
|
|
739
632
|
</span>
|
|
740
633
|
<NavBadge node={node} />
|
|
@@ -755,7 +648,9 @@ function NavMenuNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
|
|
|
755
648
|
{...(s.expandable && { "aria-expanded": s.isExpanded })}
|
|
756
649
|
>
|
|
757
650
|
<NavLeadingIcon node={node} active={false} expanded={s.isExpanded} label={s.displayLabel} />
|
|
758
|
-
<span className="truncate group-data-[collapsible=icon]:hidden"
|
|
651
|
+
<span className="truncate group-data-[collapsible=icon]:hidden" title={s.displayLabel}>
|
|
652
|
+
{s.displayLabel}
|
|
653
|
+
</span>
|
|
759
654
|
{s.expandable &&
|
|
760
655
|
(s.isExpanded ? (
|
|
761
656
|
<ChevronDown className="ml-auto" />
|
|
@@ -823,7 +718,10 @@ function NavSubNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
|
|
|
823
718
|
{...(s.active && { "aria-current": "page" })}
|
|
824
719
|
>
|
|
825
720
|
<NavLeadingIcon node={node} active={s.active} label={s.displayLabel} />
|
|
826
|
-
<span
|
|
721
|
+
<span
|
|
722
|
+
className="min-w-0 truncate group-data-[collapsible=icon]:hidden"
|
|
723
|
+
title={s.displayLabel}
|
|
724
|
+
>
|
|
827
725
|
{s.displayLabel}
|
|
828
726
|
</span>
|
|
829
727
|
<NavBadge node={node} />
|
|
@@ -843,7 +741,10 @@ function NavSubNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
|
|
|
843
741
|
<SidebarMenuSubButton asChild isActive={s.active}>
|
|
844
742
|
<button type="button" onClick={() => dispatch(target)}>
|
|
845
743
|
<NavLeadingIcon node={node} active={s.active} label={s.displayLabel} />
|
|
846
|
-
<span
|
|
744
|
+
<span
|
|
745
|
+
className="min-w-0 truncate group-data-[collapsible=icon]:hidden"
|
|
746
|
+
title={s.displayLabel}
|
|
747
|
+
>
|
|
847
748
|
{s.displayLabel}
|
|
848
749
|
</span>
|
|
849
750
|
<NavBadge node={node} />
|
|
@@ -871,7 +772,9 @@ function NavSubNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
|
|
|
871
772
|
expanded={s.isExpanded}
|
|
872
773
|
label={s.displayLabel}
|
|
873
774
|
/>
|
|
874
|
-
<span className="truncate group-data-[collapsible=icon]:hidden"
|
|
775
|
+
<span className="truncate group-data-[collapsible=icon]:hidden" title={s.displayLabel}>
|
|
776
|
+
{s.displayLabel}
|
|
777
|
+
</span>
|
|
875
778
|
</button>
|
|
876
779
|
</SidebarMenuSubButton>
|
|
877
780
|
<NodeActions node={node} />
|