@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,1271 @@
|
|
|
1
|
+
// shadcn+Tailwind Default-Primitives für den Web-Renderer.
|
|
2
|
+
// Konsumieren den Primitives-Contract aus `@cosmicdrift/kumiko-renderer`. Keine
|
|
3
|
+
// useTokens()-Aufrufe — die Farben kommen aus den Tailwind-Klassen
|
|
4
|
+
// die auf die shadcn-CSS-Variablen referenzieren.
|
|
5
|
+
//
|
|
6
|
+
// Muster: pro Primitive eine Tailwind-Klassen-Komposition,
|
|
7
|
+
// Konfigurierbarkeit über `class-variance-authority` für variant-
|
|
8
|
+
// basierte Stile. Radix-UI-Unterbau für interaktive Elemente (Modal,
|
|
9
|
+
// Dropdown etc. kommen später).
|
|
10
|
+
|
|
11
|
+
import type { ListRowViewModel } from "@cosmicdrift/kumiko-headless";
|
|
12
|
+
import type {
|
|
13
|
+
DataTableRowAction,
|
|
14
|
+
DataTableSort,
|
|
15
|
+
DataTableSortDir,
|
|
16
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
17
|
+
import {
|
|
18
|
+
type BannerProps,
|
|
19
|
+
type ButtonProps,
|
|
20
|
+
type CorePrimitives,
|
|
21
|
+
type DataTableProps,
|
|
22
|
+
type FieldProps,
|
|
23
|
+
type FormProps,
|
|
24
|
+
type GridCellProps,
|
|
25
|
+
type GridProps,
|
|
26
|
+
type HeadingProps,
|
|
27
|
+
type InputProps,
|
|
28
|
+
type SectionProps,
|
|
29
|
+
type TextProps,
|
|
30
|
+
useColumnRenderer,
|
|
31
|
+
useTranslation,
|
|
32
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
33
|
+
import * as LabelPrimitive from "@radix-ui/react-label";
|
|
34
|
+
import { cva } from "class-variance-authority";
|
|
35
|
+
import {
|
|
36
|
+
ArrowDown,
|
|
37
|
+
ArrowUp,
|
|
38
|
+
ArrowUpDown,
|
|
39
|
+
ChevronLeft,
|
|
40
|
+
ChevronRight,
|
|
41
|
+
Loader2,
|
|
42
|
+
MoreHorizontal,
|
|
43
|
+
} from "lucide-react";
|
|
44
|
+
import { type ChangeEvent, type ReactNode, useEffect, useRef, useState } from "react";
|
|
45
|
+
import { cn } from "../lib/cn";
|
|
46
|
+
import { ComboboxInput } from "./combobox";
|
|
47
|
+
import { DateInput } from "./date-input";
|
|
48
|
+
import { DefaultDialog } from "./dialog";
|
|
49
|
+
import {
|
|
50
|
+
DropdownMenu,
|
|
51
|
+
DropdownMenuContent,
|
|
52
|
+
DropdownMenuItem,
|
|
53
|
+
DropdownMenuTrigger,
|
|
54
|
+
} from "./dropdown-menu";
|
|
55
|
+
import { MoneyInput } from "./money-input";
|
|
56
|
+
|
|
57
|
+
// ---- Button ----
|
|
58
|
+
|
|
59
|
+
const buttonVariants = cva(
|
|
60
|
+
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors " +
|
|
61
|
+
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring " +
|
|
62
|
+
"disabled:pointer-events-none disabled:opacity-50",
|
|
63
|
+
{
|
|
64
|
+
variants: {
|
|
65
|
+
variant: {
|
|
66
|
+
primary: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
|
67
|
+
secondary:
|
|
68
|
+
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
|
69
|
+
danger: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
defaultVariants: { variant: "primary" },
|
|
73
|
+
},
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
function DefaultButton({
|
|
77
|
+
type = "button",
|
|
78
|
+
onClick,
|
|
79
|
+
disabled,
|
|
80
|
+
loading,
|
|
81
|
+
variant = "primary",
|
|
82
|
+
children,
|
|
83
|
+
testId,
|
|
84
|
+
}: ButtonProps): ReactNode {
|
|
85
|
+
return (
|
|
86
|
+
<button
|
|
87
|
+
type={type}
|
|
88
|
+
onClick={onClick}
|
|
89
|
+
disabled={disabled === true || loading === true}
|
|
90
|
+
data-testid={testId}
|
|
91
|
+
data-loading={loading === true ? "true" : undefined}
|
|
92
|
+
className={cn(buttonVariants({ variant }), "h-9 px-4 py-2")}
|
|
93
|
+
>
|
|
94
|
+
{loading === true ? <Loader2 className="size-4 animate-spin" aria-hidden="true" /> : children}
|
|
95
|
+
</button>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---- Banner (shadcn: Alert) ----
|
|
100
|
+
|
|
101
|
+
function DefaultBanner({
|
|
102
|
+
variant = "info",
|
|
103
|
+
children,
|
|
104
|
+
actions,
|
|
105
|
+
padded,
|
|
106
|
+
testId,
|
|
107
|
+
}: BannerProps): ReactNode {
|
|
108
|
+
const isError = variant === "error";
|
|
109
|
+
const banner = (
|
|
110
|
+
<div
|
|
111
|
+
data-testid={testId}
|
|
112
|
+
role={isError ? "alert" : undefined}
|
|
113
|
+
data-variant={variant}
|
|
114
|
+
className={cn(
|
|
115
|
+
"relative w-full rounded-lg border px-4 py-3 text-sm flex items-center gap-3",
|
|
116
|
+
isError
|
|
117
|
+
? "border-destructive/50 text-destructive bg-destructive/10 dark:border-destructive"
|
|
118
|
+
: "bg-card text-card-foreground",
|
|
119
|
+
)}
|
|
120
|
+
>
|
|
121
|
+
<div className="flex-1">{children}</div>
|
|
122
|
+
{actions !== undefined && <div data-slot="actions">{actions}</div>}
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
// Page-State: Banner sitzt alleine im Main (das kein Padding mehr
|
|
126
|
+
// hat). Wrapper gibt 24px Außenabstand damit der Banner nicht edge-
|
|
127
|
+
// to-edge an Sidebar/Browser klebt.
|
|
128
|
+
return padded === true ? <div className="p-6">{banner}</div> : banner;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---- Field (Label + Error) ----
|
|
132
|
+
|
|
133
|
+
function DefaultField({ id, label, required, issues, children, testId }: FieldProps): ReactNode {
|
|
134
|
+
const t = useTranslation();
|
|
135
|
+
const hasError = issues !== undefined && issues.length > 0;
|
|
136
|
+
return (
|
|
137
|
+
<div data-testid={testId} className="flex flex-col gap-1.5">
|
|
138
|
+
<LabelPrimitive.Root
|
|
139
|
+
htmlFor={id}
|
|
140
|
+
className={cn(
|
|
141
|
+
// peer-disabled-Sentinel: shadcn-Pattern — wenn das assoziierte
|
|
142
|
+
// Input disabled ist (peer + disabled-Klasse), wird das Label
|
|
143
|
+
// mitgrayout. Funktioniert weil Radix-Label das nativ-htmlFor-
|
|
144
|
+
// verlinkte Element als Peer betrachtet.
|
|
145
|
+
"text-sm font-medium leading-none",
|
|
146
|
+
"peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
|
147
|
+
hasError ? "text-destructive" : "text-foreground",
|
|
148
|
+
)}
|
|
149
|
+
>
|
|
150
|
+
{label}
|
|
151
|
+
{required === true && <span className="ml-0.5 text-destructive">*</span>}
|
|
152
|
+
</LabelPrimitive.Root>
|
|
153
|
+
{children}
|
|
154
|
+
{hasError && (
|
|
155
|
+
<div
|
|
156
|
+
role="alert"
|
|
157
|
+
data-testid={testId !== undefined ? `${testId}-errors` : undefined}
|
|
158
|
+
className="text-xs text-destructive"
|
|
159
|
+
>
|
|
160
|
+
{issues.map((issue) => (
|
|
161
|
+
<div key={`${issue.path}:${issue.code}`}>{t(issue.i18nKey)}</div>
|
|
162
|
+
))}
|
|
163
|
+
</div>
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---- Input ----
|
|
170
|
+
|
|
171
|
+
const inputClassBase =
|
|
172
|
+
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm " +
|
|
173
|
+
"transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium " +
|
|
174
|
+
"placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring " +
|
|
175
|
+
"disabled:cursor-not-allowed disabled:opacity-50";
|
|
176
|
+
|
|
177
|
+
function DefaultInput(props: InputProps): ReactNode {
|
|
178
|
+
const errorClass =
|
|
179
|
+
props.hasError === true ? "border-destructive focus-visible:ring-destructive" : "";
|
|
180
|
+
const common = {
|
|
181
|
+
id: props.id,
|
|
182
|
+
name: props.name,
|
|
183
|
+
disabled: props.disabled,
|
|
184
|
+
"aria-required": props.required,
|
|
185
|
+
"aria-invalid": props.hasError === true ? true : undefined,
|
|
186
|
+
} as const;
|
|
187
|
+
switch (props.kind) {
|
|
188
|
+
case "text":
|
|
189
|
+
return (
|
|
190
|
+
<input
|
|
191
|
+
type="text"
|
|
192
|
+
{...common}
|
|
193
|
+
value={props.value}
|
|
194
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => props.onChange(e.target.value)}
|
|
195
|
+
{...(props.placeholder !== undefined && { placeholder: props.placeholder })}
|
|
196
|
+
{...(props.autoComplete !== undefined && { autoComplete: props.autoComplete })}
|
|
197
|
+
className={cn(inputClassBase, errorClass)}
|
|
198
|
+
/>
|
|
199
|
+
);
|
|
200
|
+
case "email":
|
|
201
|
+
return (
|
|
202
|
+
<input
|
|
203
|
+
type="email"
|
|
204
|
+
{...common}
|
|
205
|
+
value={props.value}
|
|
206
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => props.onChange(e.target.value)}
|
|
207
|
+
{...(props.placeholder !== undefined && { placeholder: props.placeholder })}
|
|
208
|
+
autoComplete={props.autoComplete ?? "email"}
|
|
209
|
+
className={cn(inputClassBase, errorClass)}
|
|
210
|
+
/>
|
|
211
|
+
);
|
|
212
|
+
case "password":
|
|
213
|
+
return (
|
|
214
|
+
<input
|
|
215
|
+
type="password"
|
|
216
|
+
{...common}
|
|
217
|
+
value={props.value}
|
|
218
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => props.onChange(e.target.value)}
|
|
219
|
+
autoComplete={props.autoComplete ?? "current-password"}
|
|
220
|
+
className={cn(inputClassBase, errorClass)}
|
|
221
|
+
/>
|
|
222
|
+
);
|
|
223
|
+
case "number":
|
|
224
|
+
return (
|
|
225
|
+
<input
|
|
226
|
+
type="number"
|
|
227
|
+
{...common}
|
|
228
|
+
value={props.value}
|
|
229
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
230
|
+
const v = e.target.value;
|
|
231
|
+
props.onChange(v === "" ? undefined : Number(v));
|
|
232
|
+
}}
|
|
233
|
+
className={cn(inputClassBase, "text-right tabular-nums", errorClass)}
|
|
234
|
+
/>
|
|
235
|
+
);
|
|
236
|
+
case "boolean":
|
|
237
|
+
return (
|
|
238
|
+
<input
|
|
239
|
+
type="checkbox"
|
|
240
|
+
{...common}
|
|
241
|
+
checked={props.value}
|
|
242
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => props.onChange(e.target.checked)}
|
|
243
|
+
className={cn(
|
|
244
|
+
"h-4 w-4 rounded-sm border border-input accent-primary focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
|
245
|
+
errorClass,
|
|
246
|
+
)}
|
|
247
|
+
/>
|
|
248
|
+
);
|
|
249
|
+
case "date":
|
|
250
|
+
return (
|
|
251
|
+
<DateInput
|
|
252
|
+
id={props.id}
|
|
253
|
+
name={props.name}
|
|
254
|
+
value={props.value}
|
|
255
|
+
onChange={props.onChange}
|
|
256
|
+
{...(props.locale !== undefined && { locale: props.locale })}
|
|
257
|
+
{...(props.disabled !== undefined && { disabled: props.disabled })}
|
|
258
|
+
{...(props.required !== undefined && { required: props.required })}
|
|
259
|
+
{...(props.hasError !== undefined && { hasError: props.hasError })}
|
|
260
|
+
/>
|
|
261
|
+
);
|
|
262
|
+
case "select": {
|
|
263
|
+
// Visual-Konsolidierung: alle Selects laufen über ComboboxInput
|
|
264
|
+
// (cmdk + Radix-Popover). Vorher hatten wir zwei Pfade — Radix-
|
|
265
|
+
// Select für `kind:"select"` und cmdk für `kind:"combobox"`. Drei
|
|
266
|
+
// visuell unterschiedliche Variants (Single-Select, Combobox-
|
|
267
|
+
// Single, Combobox-Multi) wurden unhandbar. Mit dem Merge ist die
|
|
268
|
+
// Combobox die einzige Implementation: Search-Input ist auch bei
|
|
269
|
+
// 4-Item-Status-Selects vorhanden, das ist eine bewusst akzeptierte
|
|
270
|
+
// UX-Konsequenz für Style-Konsistenz.
|
|
271
|
+
const comboOptions = props.options.map((o) =>
|
|
272
|
+
typeof o === "string" ? { value: o, label: o } : o,
|
|
273
|
+
);
|
|
274
|
+
return (
|
|
275
|
+
<ComboboxInput
|
|
276
|
+
id={props.id}
|
|
277
|
+
name={props.name}
|
|
278
|
+
value={props.value}
|
|
279
|
+
onChange={props.onChange}
|
|
280
|
+
options={comboOptions}
|
|
281
|
+
{...(props.disabled !== undefined && { disabled: props.disabled })}
|
|
282
|
+
{...(props.required !== undefined && { required: props.required })}
|
|
283
|
+
{...(props.hasError !== undefined && { hasError: props.hasError })}
|
|
284
|
+
/>
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
case "combobox": {
|
|
288
|
+
// Tier 2.1c + Tier 2.7e: Discriminated-Union per `multiple` —
|
|
289
|
+
// wir splittan TS-side in zwei Branches damit ComboboxInput's
|
|
290
|
+
// Single/Multi-Variants typgerecht gerendert werden.
|
|
291
|
+
const baseProps = {
|
|
292
|
+
id: props.id,
|
|
293
|
+
name: props.name,
|
|
294
|
+
options: props.options,
|
|
295
|
+
...(props.disabled !== undefined && { disabled: props.disabled }),
|
|
296
|
+
...(props.required !== undefined && { required: props.required }),
|
|
297
|
+
...(props.hasError !== undefined && { hasError: props.hasError }),
|
|
298
|
+
...(props.placeholder !== undefined && { placeholder: props.placeholder }),
|
|
299
|
+
...(props.searchPlaceholder !== undefined && {
|
|
300
|
+
searchPlaceholder: props.searchPlaceholder,
|
|
301
|
+
}),
|
|
302
|
+
...(props.emptyText !== undefined && { emptyText: props.emptyText }),
|
|
303
|
+
...(props.onSearchChange !== undefined && { onSearchChange: props.onSearchChange }),
|
|
304
|
+
...(props.loading !== undefined && { loading: props.loading }),
|
|
305
|
+
} as const;
|
|
306
|
+
if (props.multiple === true) {
|
|
307
|
+
return (
|
|
308
|
+
<ComboboxInput {...baseProps} multiple value={props.value} onChange={props.onChange} />
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
return <ComboboxInput {...baseProps} value={props.value} onChange={props.onChange} />;
|
|
312
|
+
}
|
|
313
|
+
case "money":
|
|
314
|
+
return (
|
|
315
|
+
<MoneyInput
|
|
316
|
+
id={props.id}
|
|
317
|
+
name={props.name}
|
|
318
|
+
value={props.value}
|
|
319
|
+
onChange={props.onChange}
|
|
320
|
+
currency={props.currency ?? "EUR"}
|
|
321
|
+
{...(props.locale !== undefined && { locale: props.locale })}
|
|
322
|
+
{...(props.disabled !== undefined && { disabled: props.disabled })}
|
|
323
|
+
{...(props.required !== undefined && { required: props.required })}
|
|
324
|
+
{...(props.hasError !== undefined && { hasError: props.hasError })}
|
|
325
|
+
/>
|
|
326
|
+
);
|
|
327
|
+
case "timestamp":
|
|
328
|
+
return (
|
|
329
|
+
<input
|
|
330
|
+
type="datetime-local"
|
|
331
|
+
{...common}
|
|
332
|
+
value={props.value}
|
|
333
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
|
334
|
+
props.onChange(e.target.value !== "" ? e.target.value : undefined)
|
|
335
|
+
}
|
|
336
|
+
className={cn(inputClassBase, errorClass)}
|
|
337
|
+
/>
|
|
338
|
+
);
|
|
339
|
+
case "textarea":
|
|
340
|
+
// Default 4 Zeilen — vertikal-resize via resize-y. min-h damit
|
|
341
|
+
// ein bewusstes rows={2} nicht unter eine sinnvolle Mindesthöhe
|
|
342
|
+
// schrumpft. Field-Klasse wird leicht angepasst (kein h-9 weil
|
|
343
|
+
// multiline).
|
|
344
|
+
return (
|
|
345
|
+
<textarea
|
|
346
|
+
{...common}
|
|
347
|
+
value={props.value}
|
|
348
|
+
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => props.onChange(e.target.value)}
|
|
349
|
+
rows={props.rows ?? 4}
|
|
350
|
+
className={cn(
|
|
351
|
+
"flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm",
|
|
352
|
+
"transition-colors placeholder:text-muted-foreground focus-visible:outline-none",
|
|
353
|
+
"focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
|
354
|
+
"resize-y min-h-[80px]",
|
|
355
|
+
errorClass,
|
|
356
|
+
)}
|
|
357
|
+
/>
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ---- DataTable (shadcn: Table) ----
|
|
363
|
+
|
|
364
|
+
function DefaultDataTable({
|
|
365
|
+
columns,
|
|
366
|
+
rows,
|
|
367
|
+
onRowClick,
|
|
368
|
+
sort,
|
|
369
|
+
onSortChange,
|
|
370
|
+
emptyState,
|
|
371
|
+
toolbarTitle,
|
|
372
|
+
toolbarStart,
|
|
373
|
+
toolbarEnd,
|
|
374
|
+
pager,
|
|
375
|
+
onReachEnd,
|
|
376
|
+
loadingMore,
|
|
377
|
+
hasMore,
|
|
378
|
+
rowActions,
|
|
379
|
+
testId,
|
|
380
|
+
}: DataTableProps): ReactNode {
|
|
381
|
+
// Toolbar-Wrapper: gemeinsamer Container für Toolbar+Tabelle damit
|
|
382
|
+
// beide visuell zusammengehören. Toolbar ist NICHT sticky — Lists
|
|
383
|
+
// scrollen typischerweise mit dem Page-Container, nicht intern.
|
|
384
|
+
// Sticky würde mit der Topbar konkurrieren.
|
|
385
|
+
const tableContent =
|
|
386
|
+
rows.length === 0 ? (
|
|
387
|
+
<div
|
|
388
|
+
data-testid={testId !== undefined ? `${testId}-empty` : "render-list-empty"}
|
|
389
|
+
className="flex flex-col items-center justify-center rounded-md border border-dashed p-12 text-sm text-muted-foreground gap-3"
|
|
390
|
+
>
|
|
391
|
+
{emptyState ?? <span>No entries.</span>}
|
|
392
|
+
</div>
|
|
393
|
+
) : (
|
|
394
|
+
<div className="rounded-md border overflow-x-auto">
|
|
395
|
+
<table data-testid={testId} className="w-full caption-bottom text-sm">
|
|
396
|
+
{tableInner(columns, rows, onRowClick, sort, onSortChange, rowActions)}
|
|
397
|
+
</table>
|
|
398
|
+
</div>
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
// Pager wird IMMER unter der Tabelle gerendert (auch bei rows=[]),
|
|
402
|
+
// damit der User bei einem Filter-Hit-of-Zero zurückblättern kann
|
|
403
|
+
// ohne die Liste zu verlieren. Außer total === 0 — dann gibt's
|
|
404
|
+
// nichts zu paginieren. Inkompatibel mit Infinite-Scroll: Caller
|
|
405
|
+
// setzt entweder pager ODER onReachEnd.
|
|
406
|
+
const content =
|
|
407
|
+
pager !== undefined && pager.total > 0 ? (
|
|
408
|
+
<>
|
|
409
|
+
{tableContent}
|
|
410
|
+
<Pager
|
|
411
|
+
page={pager.page}
|
|
412
|
+
limit={pager.limit}
|
|
413
|
+
total={pager.total}
|
|
414
|
+
onPageChange={pager.onPageChange}
|
|
415
|
+
testId={testId !== undefined ? `${testId}-pager` : "render-list-pager"}
|
|
416
|
+
/>
|
|
417
|
+
</>
|
|
418
|
+
) : onReachEnd !== undefined ? (
|
|
419
|
+
<>
|
|
420
|
+
{tableContent}
|
|
421
|
+
<InfiniteSentinel
|
|
422
|
+
onReachEnd={onReachEnd}
|
|
423
|
+
loadingMore={loadingMore === true}
|
|
424
|
+
hasMore={hasMore !== false}
|
|
425
|
+
testId={testId !== undefined ? `${testId}-sentinel` : "render-list-sentinel"}
|
|
426
|
+
/>
|
|
427
|
+
</>
|
|
428
|
+
) : (
|
|
429
|
+
tableContent
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
const hasToolbar =
|
|
433
|
+
toolbarTitle !== undefined || toolbarStart !== undefined || toolbarEnd !== undefined;
|
|
434
|
+
if (!hasToolbar) return <div className="p-6">{content}</div>;
|
|
435
|
+
|
|
436
|
+
// Toolbar als full-width Bar (Main hat kein Padding, also nimmt die
|
|
437
|
+
// Bar von alleine die volle Breite). bg-muted/30 + border-b geben
|
|
438
|
+
// die visuelle Distinction zum Content. Content darunter bekommt
|
|
439
|
+
// eigenes p-6 damit Tabelle/Empty-State nicht an die Edges kleben.
|
|
440
|
+
return (
|
|
441
|
+
<div className="flex flex-col w-full">
|
|
442
|
+
<div
|
|
443
|
+
data-testid={testId !== undefined ? `${testId}-toolbar` : "render-list-toolbar"}
|
|
444
|
+
className="h-12 px-6 bg-muted/30 border-b flex items-center gap-3"
|
|
445
|
+
>
|
|
446
|
+
{toolbarTitle !== undefined && (
|
|
447
|
+
<div className="text-lg font-semibold tracking-tight truncate">{toolbarTitle}</div>
|
|
448
|
+
)}
|
|
449
|
+
{toolbarStart !== undefined && <div className="flex-1 max-w-sm">{toolbarStart}</div>}
|
|
450
|
+
{toolbarEnd !== undefined && (
|
|
451
|
+
<div className="flex items-center gap-2 ml-auto">{toolbarEnd}</div>
|
|
452
|
+
)}
|
|
453
|
+
</div>
|
|
454
|
+
<div className="p-6">{content}</div>
|
|
455
|
+
</div>
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function tableInner(
|
|
460
|
+
columns: DataTableProps["columns"],
|
|
461
|
+
rows: DataTableProps["rows"],
|
|
462
|
+
onRowClick?: DataTableProps["onRowClick"],
|
|
463
|
+
sort?: DataTableProps["sort"],
|
|
464
|
+
onSortChange?: DataTableProps["onSortChange"],
|
|
465
|
+
rowActions?: DataTableProps["rowActions"],
|
|
466
|
+
): ReactNode {
|
|
467
|
+
const hasActions = rowActions !== undefined && rowActions.length > 0;
|
|
468
|
+
return (
|
|
469
|
+
<>
|
|
470
|
+
<thead className="[&_tr]:border-b">
|
|
471
|
+
<tr className="border-b transition-colors hover:bg-muted/50">
|
|
472
|
+
{columns.map((col) => (
|
|
473
|
+
<SortableHeader
|
|
474
|
+
key={col.field}
|
|
475
|
+
field={col.field}
|
|
476
|
+
label={col.label}
|
|
477
|
+
sortable={col.sortable === true}
|
|
478
|
+
{...(sort !== undefined && sort !== null && { sort })}
|
|
479
|
+
{...(onSortChange !== undefined && { onSortChange })}
|
|
480
|
+
/>
|
|
481
|
+
))}
|
|
482
|
+
{hasActions && (
|
|
483
|
+
<th
|
|
484
|
+
data-testid="column-actions"
|
|
485
|
+
// sticky right-0 + bg-background damit die Action-Spalte
|
|
486
|
+
// beim horizontalen Scroll am rechten Rand bleibt und
|
|
487
|
+
// nicht vom Inhalt überdeckt wird (Linear-Pattern).
|
|
488
|
+
className="h-10 px-4 text-right align-middle font-medium text-muted-foreground w-px whitespace-nowrap sticky right-0 bg-background z-10 border-l"
|
|
489
|
+
aria-label="Actions"
|
|
490
|
+
/>
|
|
491
|
+
)}
|
|
492
|
+
</tr>
|
|
493
|
+
</thead>
|
|
494
|
+
<tbody className="[&_tr:last-child]:border-0">
|
|
495
|
+
{rows.map((row) => (
|
|
496
|
+
<tr
|
|
497
|
+
key={row.id}
|
|
498
|
+
data-testid={`row-${row.id}`}
|
|
499
|
+
onClick={onRowClick !== undefined ? () => onRowClick(row) : undefined}
|
|
500
|
+
className={cn(
|
|
501
|
+
"border-b transition-colors hover:bg-muted/50",
|
|
502
|
+
onRowClick !== undefined && "cursor-pointer",
|
|
503
|
+
)}
|
|
504
|
+
>
|
|
505
|
+
{columns.map((col) => (
|
|
506
|
+
<td
|
|
507
|
+
key={col.field}
|
|
508
|
+
data-testid={`cell-${row.id}-${col.field}`}
|
|
509
|
+
// Cells truncaten lange Werte mit ellipsis statt umzu-
|
|
510
|
+
// brechen — Lists bleiben einzeilig + scannbar (Linear-
|
|
511
|
+
// Pattern). max-w-xs gibt eine vernünftige Default-
|
|
512
|
+
// Obergrenze; die <table>-Wrapper hat overflow-x für
|
|
513
|
+
// horizontalen Scroll falls die Summe der Spalten zu
|
|
514
|
+
// breit wird.
|
|
515
|
+
className="p-4 align-middle max-w-xs truncate"
|
|
516
|
+
title={cellTitle(row.values[col.field])}
|
|
517
|
+
>
|
|
518
|
+
<DataTableCell
|
|
519
|
+
value={row.values[col.field]}
|
|
520
|
+
row={row.values}
|
|
521
|
+
field={col.field}
|
|
522
|
+
type={col.type}
|
|
523
|
+
renderer={col.renderer}
|
|
524
|
+
{...(col.optionLabels !== undefined && { optionLabels: col.optionLabels })}
|
|
525
|
+
/>
|
|
526
|
+
</td>
|
|
527
|
+
))}
|
|
528
|
+
{hasActions && (
|
|
529
|
+
<td
|
|
530
|
+
data-testid={`cell-${row.id}-actions`}
|
|
531
|
+
// Sticky-right damit beim horizontalen Scroll die Actions
|
|
532
|
+
// am rechten Rand sichtbar bleiben. bg-background +
|
|
533
|
+
// border-l für den visuellen Abschluss.
|
|
534
|
+
className="p-2 align-middle text-right whitespace-nowrap sticky right-0 bg-background z-10 border-l"
|
|
535
|
+
// Action-Cell-Events dürfen nicht den Row-Click/Activation
|
|
536
|
+
// triggern (typisch "Open Detail" — der User wollte ja die
|
|
537
|
+
// Action, nicht navigieren). Wir stopPropagation für Mouse
|
|
538
|
+
// UND Keyboard, damit a11y konsistent ist.
|
|
539
|
+
onClick={(e) => e.stopPropagation()}
|
|
540
|
+
onKeyDown={(e) => e.stopPropagation()}
|
|
541
|
+
>
|
|
542
|
+
<RowActionsCell row={row} actions={rowActions} />
|
|
543
|
+
</td>
|
|
544
|
+
)}
|
|
545
|
+
</tr>
|
|
546
|
+
))}
|
|
547
|
+
</tbody>
|
|
548
|
+
</>
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// RowActionsCell — entscheidet zwischen Inline-Buttons (≤2 actions) und
|
|
553
|
+
// Kebab-Dropdown (>2). isVisible-Filter wird hier ausgeführt; eine action
|
|
554
|
+
// die für eine Row unsichtbar ist, kommt nicht in den Render. Wenn alle
|
|
555
|
+
// Actions für eine Row hidden sind, bleibt die Cell leer (keine
|
|
556
|
+
// Phantom-Spalte).
|
|
557
|
+
function RowActionsCell({
|
|
558
|
+
row,
|
|
559
|
+
actions,
|
|
560
|
+
}: {
|
|
561
|
+
readonly row: ListRowViewModel;
|
|
562
|
+
readonly actions: readonly DataTableRowAction[];
|
|
563
|
+
}): ReactNode {
|
|
564
|
+
const visible = actions.filter((a) => a.isVisible === undefined || a.isVisible(row));
|
|
565
|
+
if (visible.length === 0) return null;
|
|
566
|
+
if (visible.length <= 2) {
|
|
567
|
+
return (
|
|
568
|
+
<div className="inline-flex items-center gap-1 justify-end">
|
|
569
|
+
{visible.map((a) => (
|
|
570
|
+
<RowActionButton key={a.id} row={row} action={a} />
|
|
571
|
+
))}
|
|
572
|
+
</div>
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
return <RowActionsKebab row={row} actions={visible} />;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Shared trigger-State zwischen Inline-Button + Kebab-Item: busy-Flag
|
|
579
|
+
// (während async onTrigger läuft) + confirm-pending-Action. Beide Sub-
|
|
580
|
+
// Components hatten denselben State-Block dupliziert + parallel zur
|
|
581
|
+
// Confirm-Dialog-Render-Logic — der Hook konsolidiert das.
|
|
582
|
+
//
|
|
583
|
+
// "needsConfirm" Helper kapselt die Regel: explizites confirm ODER
|
|
584
|
+
// style=danger triggert den Dialog, alles andere fired direkt.
|
|
585
|
+
function needsConfirm(action: DataTableRowAction): boolean {
|
|
586
|
+
return action.confirm !== undefined || action.style === "danger";
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function useRowActionTrigger(row: ListRowViewModel) {
|
|
590
|
+
const [busy, setBusy] = useState(false);
|
|
591
|
+
const triggerNow = async (action: DataTableRowAction): Promise<void> => {
|
|
592
|
+
setBusy(true);
|
|
593
|
+
try {
|
|
594
|
+
await action.onTrigger(row);
|
|
595
|
+
} finally {
|
|
596
|
+
setBusy(false);
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
return { busy, triggerNow };
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function RowActionButton({
|
|
603
|
+
row,
|
|
604
|
+
action,
|
|
605
|
+
}: {
|
|
606
|
+
readonly row: ListRowViewModel;
|
|
607
|
+
readonly action: DataTableRowAction;
|
|
608
|
+
}): ReactNode {
|
|
609
|
+
const { busy, triggerNow } = useRowActionTrigger(row);
|
|
610
|
+
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
611
|
+
|
|
612
|
+
const variantClass =
|
|
613
|
+
action.style === "danger"
|
|
614
|
+
? "text-destructive hover:bg-destructive/10"
|
|
615
|
+
: action.style === "primary"
|
|
616
|
+
? "text-primary hover:bg-primary/10"
|
|
617
|
+
: "text-foreground hover:bg-accent";
|
|
618
|
+
|
|
619
|
+
return (
|
|
620
|
+
<>
|
|
621
|
+
<button
|
|
622
|
+
type="button"
|
|
623
|
+
data-testid={`row-${row.id}-action-${action.id}`}
|
|
624
|
+
disabled={busy}
|
|
625
|
+
onClick={(e) => {
|
|
626
|
+
e.stopPropagation();
|
|
627
|
+
if (needsConfirm(action)) {
|
|
628
|
+
setConfirmOpen(true);
|
|
629
|
+
} else {
|
|
630
|
+
void triggerNow(action);
|
|
631
|
+
}
|
|
632
|
+
}}
|
|
633
|
+
className={cn(
|
|
634
|
+
"inline-flex h-8 items-center justify-center rounded-sm px-2 text-sm",
|
|
635
|
+
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
|
636
|
+
"disabled:opacity-50 disabled:pointer-events-none",
|
|
637
|
+
variantClass,
|
|
638
|
+
)}
|
|
639
|
+
>
|
|
640
|
+
{busy ? <Loader2 className="size-3.5 animate-spin" aria-hidden="true" /> : action.label}
|
|
641
|
+
</button>
|
|
642
|
+
<DefaultDialog
|
|
643
|
+
open={confirmOpen}
|
|
644
|
+
onOpenChange={setConfirmOpen}
|
|
645
|
+
title={action.label}
|
|
646
|
+
{...(action.confirm !== undefined && { description: action.confirm })}
|
|
647
|
+
confirmLabel={action.confirmLabel ?? action.label}
|
|
648
|
+
{...(action.style === "danger" && { variant: "danger" as const })}
|
|
649
|
+
onConfirm={() => triggerNow(action)}
|
|
650
|
+
testId={`row-${row.id}-action-${action.id}-dialog`}
|
|
651
|
+
/>
|
|
652
|
+
</>
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Kebab-Dropdown für >2 actions. Confirm-Dialog ist hier inline pro
|
|
657
|
+
// Item analog zum Inline-Button-Pfad — ein gemeinsamer State-Holder
|
|
658
|
+
// per Action damit das Dropdown nach dem Click zumacht und der Dialog
|
|
659
|
+
// danach öffnet (Radix-Dropdown-Item swallowt den Click sonst).
|
|
660
|
+
function RowActionsKebab({
|
|
661
|
+
row,
|
|
662
|
+
actions,
|
|
663
|
+
}: {
|
|
664
|
+
readonly row: ListRowViewModel;
|
|
665
|
+
readonly actions: readonly DataTableRowAction[];
|
|
666
|
+
}): ReactNode {
|
|
667
|
+
const { triggerNow } = useRowActionTrigger(row);
|
|
668
|
+
const [pendingConfirm, setPendingConfirm] = useState<DataTableRowAction | null>(null);
|
|
669
|
+
|
|
670
|
+
return (
|
|
671
|
+
<>
|
|
672
|
+
<DropdownMenu>
|
|
673
|
+
<DropdownMenuTrigger asChild>
|
|
674
|
+
<button
|
|
675
|
+
type="button"
|
|
676
|
+
aria-label="More actions"
|
|
677
|
+
data-testid={`row-${row.id}-actions-menu`}
|
|
678
|
+
className={cn(
|
|
679
|
+
"inline-flex h-8 w-8 items-center justify-center rounded-sm",
|
|
680
|
+
"hover:bg-accent text-muted-foreground hover:text-foreground",
|
|
681
|
+
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
|
682
|
+
)}
|
|
683
|
+
>
|
|
684
|
+
<MoreHorizontal className="size-4" aria-hidden="true" />
|
|
685
|
+
</button>
|
|
686
|
+
</DropdownMenuTrigger>
|
|
687
|
+
<DropdownMenuContent align="end">
|
|
688
|
+
{actions.map((action) => (
|
|
689
|
+
<DropdownMenuItem
|
|
690
|
+
key={action.id}
|
|
691
|
+
data-testid={`row-${row.id}-action-${action.id}`}
|
|
692
|
+
onSelect={(e) => {
|
|
693
|
+
e.preventDefault();
|
|
694
|
+
if (needsConfirm(action)) {
|
|
695
|
+
setPendingConfirm(action);
|
|
696
|
+
} else {
|
|
697
|
+
void triggerNow(action);
|
|
698
|
+
}
|
|
699
|
+
}}
|
|
700
|
+
className={cn(action.style === "danger" && "text-destructive focus:text-destructive")}
|
|
701
|
+
>
|
|
702
|
+
{action.label}
|
|
703
|
+
</DropdownMenuItem>
|
|
704
|
+
))}
|
|
705
|
+
</DropdownMenuContent>
|
|
706
|
+
</DropdownMenu>
|
|
707
|
+
{pendingConfirm !== null && (
|
|
708
|
+
<DefaultDialog
|
|
709
|
+
open={true}
|
|
710
|
+
onOpenChange={(open) => {
|
|
711
|
+
if (!open) setPendingConfirm(null);
|
|
712
|
+
}}
|
|
713
|
+
title={pendingConfirm.label}
|
|
714
|
+
{...(pendingConfirm.confirm !== undefined && { description: pendingConfirm.confirm })}
|
|
715
|
+
confirmLabel={pendingConfirm.label}
|
|
716
|
+
{...(pendingConfirm.style === "danger" && { variant: "danger" as const })}
|
|
717
|
+
onConfirm={async () => {
|
|
718
|
+
const action = pendingConfirm;
|
|
719
|
+
setPendingConfirm(null);
|
|
720
|
+
await triggerNow(action);
|
|
721
|
+
}}
|
|
722
|
+
testId={`row-${row.id}-action-${pendingConfirm.id}-dialog`}
|
|
723
|
+
/>
|
|
724
|
+
)}
|
|
725
|
+
</>
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// InfiniteSentinel — leeres div am Ende der Tabelle, das via
|
|
730
|
+
// IntersectionObserver erkennt wann der User in die Nähe des Listen-
|
|
731
|
+
// Endes scrollt. onReachEnd feuert genau einmal pro "wird sichtbar"-
|
|
732
|
+
// Übergang; der Caller debounced via loadingMore (während eine Page
|
|
733
|
+
// lädt, ignorieren wir weitere Sichtbar-Events). Kein observer in
|
|
734
|
+
// Server-Side-Render, kein observer wenn hasMore=false — dann zeigt
|
|
735
|
+
// der Sentinel nur den End-of-list-Hinweis.
|
|
736
|
+
function InfiniteSentinel({
|
|
737
|
+
onReachEnd,
|
|
738
|
+
loadingMore,
|
|
739
|
+
hasMore,
|
|
740
|
+
testId,
|
|
741
|
+
}: {
|
|
742
|
+
readonly onReachEnd: () => void;
|
|
743
|
+
readonly loadingMore: boolean;
|
|
744
|
+
readonly hasMore: boolean;
|
|
745
|
+
readonly testId?: string;
|
|
746
|
+
}): ReactNode {
|
|
747
|
+
const ref = useRef<HTMLDivElement | null>(null);
|
|
748
|
+
|
|
749
|
+
useEffect(() => {
|
|
750
|
+
if (!hasMore) return;
|
|
751
|
+
if (loadingMore) return;
|
|
752
|
+
if (typeof IntersectionObserver === "undefined") return;
|
|
753
|
+
const node = ref.current;
|
|
754
|
+
if (node === null) return;
|
|
755
|
+
const observer = new IntersectionObserver(
|
|
756
|
+
(entries) => {
|
|
757
|
+
// Nur das erste sichtbar-Event pro Mount auslösen — wenn der
|
|
758
|
+
// User weiter scrollt während noch geladen wird, hindert
|
|
759
|
+
// loadingMore=true den useEffect dass er den Observer überhaupt
|
|
760
|
+
// erst remountet.
|
|
761
|
+
for (const entry of entries) {
|
|
762
|
+
if (entry.isIntersecting) {
|
|
763
|
+
onReachEnd();
|
|
764
|
+
break;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
},
|
|
768
|
+
{ rootMargin: "200px" }, // pre-fetch wenn der Sentinel 200px vom Viewport ist
|
|
769
|
+
);
|
|
770
|
+
observer.observe(node);
|
|
771
|
+
return () => observer.disconnect();
|
|
772
|
+
}, [onReachEnd, loadingMore, hasMore]);
|
|
773
|
+
|
|
774
|
+
return (
|
|
775
|
+
<div
|
|
776
|
+
ref={ref}
|
|
777
|
+
data-testid={testId}
|
|
778
|
+
className="flex items-center justify-center py-4 text-sm text-muted-foreground"
|
|
779
|
+
>
|
|
780
|
+
{!hasMore ? (
|
|
781
|
+
<span data-testid={testId !== undefined ? `${testId}-end` : undefined}>
|
|
782
|
+
— End of list —
|
|
783
|
+
</span>
|
|
784
|
+
) : loadingMore ? (
|
|
785
|
+
<Loader2 className="size-4 animate-spin" aria-hidden="true" />
|
|
786
|
+
) : (
|
|
787
|
+
// Unsichtbar-Spacer wenn weder loading noch end — der Observer
|
|
788
|
+
// braucht ein DOM-Node, der User soll aber nichts sehen.
|
|
789
|
+
<span aria-hidden="true"> </span>
|
|
790
|
+
)}
|
|
791
|
+
</div>
|
|
792
|
+
);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// Pager — klassischer Page-Pager (← 1 … N →) für DataTables mit
|
|
796
|
+
// pagination="pages". Layout: Status-Text links ("X – Y of Z"),
|
|
797
|
+
// Page-Buttons mittig, Prev/Next pfeile außen. Window-of-7 zeigt nicht
|
|
798
|
+
// alle Pages bei großen Listen — der User sieht den aktuellen Bereich
|
|
799
|
+
// + first/last als Anchor.
|
|
800
|
+
function Pager({
|
|
801
|
+
page,
|
|
802
|
+
limit,
|
|
803
|
+
total,
|
|
804
|
+
onPageChange,
|
|
805
|
+
testId,
|
|
806
|
+
}: {
|
|
807
|
+
readonly page: number;
|
|
808
|
+
readonly limit: number;
|
|
809
|
+
readonly total: number;
|
|
810
|
+
readonly onPageChange: (next: number) => void;
|
|
811
|
+
readonly testId?: string;
|
|
812
|
+
}): ReactNode {
|
|
813
|
+
const totalPages = Math.max(1, Math.ceil(total / limit));
|
|
814
|
+
const safePage = Math.max(1, Math.min(page, totalPages));
|
|
815
|
+
const from = (safePage - 1) * limit + 1;
|
|
816
|
+
const to = Math.min(safePage * limit, total);
|
|
817
|
+
const visible = computeVisiblePages(safePage, totalPages);
|
|
818
|
+
|
|
819
|
+
return (
|
|
820
|
+
<div
|
|
821
|
+
data-testid={testId}
|
|
822
|
+
className="flex items-center justify-between mt-3 gap-3 text-sm text-muted-foreground"
|
|
823
|
+
>
|
|
824
|
+
<div data-testid={testId !== undefined ? `${testId}-status` : undefined}>
|
|
825
|
+
{from.toLocaleString()}–{to.toLocaleString()} of {total.toLocaleString()}
|
|
826
|
+
</div>
|
|
827
|
+
<div className="flex items-center gap-1">
|
|
828
|
+
<PagerButton
|
|
829
|
+
ariaLabel="Previous page"
|
|
830
|
+
disabled={safePage <= 1}
|
|
831
|
+
onClick={() => onPageChange(safePage - 1)}
|
|
832
|
+
testId={testId !== undefined ? `${testId}-prev` : undefined}
|
|
833
|
+
>
|
|
834
|
+
<ChevronLeft className="size-4" aria-hidden="true" />
|
|
835
|
+
</PagerButton>
|
|
836
|
+
{visible.map((entry, idx) =>
|
|
837
|
+
entry === "ellipsis" ? (
|
|
838
|
+
<span
|
|
839
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: visible array is pure-derived from safePage/totalPages, so idx is stable across renders. No DnD/Reorder.
|
|
840
|
+
key={`ellipsis-${idx}`}
|
|
841
|
+
className="px-2 text-muted-foreground"
|
|
842
|
+
>
|
|
843
|
+
…
|
|
844
|
+
</span>
|
|
845
|
+
) : (
|
|
846
|
+
<PagerButton
|
|
847
|
+
key={entry}
|
|
848
|
+
ariaLabel={`Page ${entry}`}
|
|
849
|
+
ariaCurrent={entry === safePage ? "page" : undefined}
|
|
850
|
+
active={entry === safePage}
|
|
851
|
+
onClick={() => onPageChange(entry)}
|
|
852
|
+
testId={testId !== undefined ? `${testId}-page-${entry}` : undefined}
|
|
853
|
+
>
|
|
854
|
+
{entry}
|
|
855
|
+
</PagerButton>
|
|
856
|
+
),
|
|
857
|
+
)}
|
|
858
|
+
<PagerButton
|
|
859
|
+
ariaLabel="Next page"
|
|
860
|
+
disabled={safePage >= totalPages}
|
|
861
|
+
onClick={() => onPageChange(safePage + 1)}
|
|
862
|
+
testId={testId !== undefined ? `${testId}-next` : undefined}
|
|
863
|
+
>
|
|
864
|
+
<ChevronRight className="size-4" aria-hidden="true" />
|
|
865
|
+
</PagerButton>
|
|
866
|
+
</div>
|
|
867
|
+
</div>
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function PagerButton({
|
|
872
|
+
children,
|
|
873
|
+
onClick,
|
|
874
|
+
ariaLabel,
|
|
875
|
+
ariaCurrent,
|
|
876
|
+
active,
|
|
877
|
+
disabled,
|
|
878
|
+
testId,
|
|
879
|
+
}: {
|
|
880
|
+
readonly children: ReactNode;
|
|
881
|
+
readonly onClick: () => void;
|
|
882
|
+
readonly ariaLabel: string;
|
|
883
|
+
readonly ariaCurrent?: "page";
|
|
884
|
+
readonly active?: boolean;
|
|
885
|
+
readonly disabled?: boolean;
|
|
886
|
+
readonly testId?: string;
|
|
887
|
+
}): ReactNode {
|
|
888
|
+
return (
|
|
889
|
+
<button
|
|
890
|
+
type="button"
|
|
891
|
+
onClick={onClick}
|
|
892
|
+
disabled={disabled}
|
|
893
|
+
aria-label={ariaLabel}
|
|
894
|
+
aria-current={ariaCurrent}
|
|
895
|
+
data-testid={testId}
|
|
896
|
+
className={cn(
|
|
897
|
+
"inline-flex h-8 min-w-8 items-center justify-center rounded-sm px-2 text-sm",
|
|
898
|
+
"hover:bg-accent hover:text-accent-foreground",
|
|
899
|
+
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
|
900
|
+
"disabled:opacity-40 disabled:pointer-events-none",
|
|
901
|
+
active === true && "bg-accent text-accent-foreground font-medium",
|
|
902
|
+
)}
|
|
903
|
+
>
|
|
904
|
+
{children}
|
|
905
|
+
</button>
|
|
906
|
+
);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// Window-of-7 Strategie: erste + letzte Page immer sichtbar als Anker,
|
|
910
|
+
// 5 Pages um den aktuellen Wert + Ellipsen wenn Distanz zu first/last
|
|
911
|
+
// > 1. Beispiele:
|
|
912
|
+
// p=1, total=20: [1] 2 3 4 5 … 20
|
|
913
|
+
// p=10, total=20: 1 … 8 9 [10] 11 12 … 20
|
|
914
|
+
// p=20, total=20: 1 … 16 17 18 19 [20]
|
|
915
|
+
// total=5: 1 2 3 4 5 (kein Window nötig)
|
|
916
|
+
function computeVisiblePages(page: number, totalPages: number): readonly (number | "ellipsis")[] {
|
|
917
|
+
if (totalPages <= 7) return Array.from({ length: totalPages }, (_, i) => i + 1);
|
|
918
|
+
const out: (number | "ellipsis")[] = [];
|
|
919
|
+
// Always show 1
|
|
920
|
+
out.push(1);
|
|
921
|
+
if (page > 4) out.push("ellipsis");
|
|
922
|
+
const start = Math.max(2, page - 2);
|
|
923
|
+
const end = Math.min(totalPages - 1, page + 2);
|
|
924
|
+
for (let i = start; i <= end; i++) out.push(i);
|
|
925
|
+
if (page < totalPages - 3) out.push("ellipsis");
|
|
926
|
+
// Always show last
|
|
927
|
+
out.push(totalPages);
|
|
928
|
+
return out;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// SortableHeader — rendert pro Spalte den th-Header, mit oder ohne
|
|
932
|
+
// Click-Sort. Drei Pfade:
|
|
933
|
+
// (a) sortable=false ODER kein onSortChange → plain Label, keine
|
|
934
|
+
// Cursor-Interaktion (DataTable rein als View ohne Sort-Wiring).
|
|
935
|
+
// (b) sortable=true + onSortChange → Header ist ein Button, klick
|
|
936
|
+
// cycled asc → desc → null. aria-sort spiegelt den State.
|
|
937
|
+
// (c) sortable=true im Schema, aber keine onSortChange-Prop → still
|
|
938
|
+
// label-only, aber data-sortable=true bleibt damit Tests die
|
|
939
|
+
// Schema-Sicht kennen.
|
|
940
|
+
function SortableHeader({
|
|
941
|
+
field,
|
|
942
|
+
label,
|
|
943
|
+
sortable,
|
|
944
|
+
sort,
|
|
945
|
+
onSortChange,
|
|
946
|
+
}: {
|
|
947
|
+
readonly field: string;
|
|
948
|
+
readonly label: string;
|
|
949
|
+
readonly sortable: boolean;
|
|
950
|
+
readonly sort?: DataTableSort;
|
|
951
|
+
readonly onSortChange?: (next: DataTableSort | null) => void;
|
|
952
|
+
}): ReactNode {
|
|
953
|
+
const active = sort?.field === field ? sort : undefined;
|
|
954
|
+
const ariaSort: "ascending" | "descending" | "none" =
|
|
955
|
+
active?.dir === "asc" ? "ascending" : active?.dir === "desc" ? "descending" : "none";
|
|
956
|
+
|
|
957
|
+
if (!sortable || onSortChange === undefined) {
|
|
958
|
+
return (
|
|
959
|
+
<th
|
|
960
|
+
data-testid={`column-${field}`}
|
|
961
|
+
data-sortable={sortable === true ? true : undefined}
|
|
962
|
+
className="h-10 px-4 text-left align-middle font-medium text-muted-foreground whitespace-nowrap"
|
|
963
|
+
>
|
|
964
|
+
{label}
|
|
965
|
+
</th>
|
|
966
|
+
);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
const Icon = active?.dir === "asc" ? ArrowUp : active?.dir === "desc" ? ArrowDown : ArrowUpDown;
|
|
970
|
+
const next = nextSortState(active?.dir, field);
|
|
971
|
+
|
|
972
|
+
return (
|
|
973
|
+
<th
|
|
974
|
+
data-testid={`column-${field}`}
|
|
975
|
+
data-sortable="true"
|
|
976
|
+
aria-sort={ariaSort}
|
|
977
|
+
className="h-10 px-4 text-left align-middle font-medium text-muted-foreground"
|
|
978
|
+
>
|
|
979
|
+
<button
|
|
980
|
+
type="button"
|
|
981
|
+
onClick={() => onSortChange(next)}
|
|
982
|
+
className={cn(
|
|
983
|
+
"inline-flex h-8 items-center gap-1.5 rounded-sm px-2 -mx-2 text-sm font-medium",
|
|
984
|
+
"hover:bg-accent hover:text-accent-foreground",
|
|
985
|
+
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
|
986
|
+
active !== undefined && "text-foreground",
|
|
987
|
+
)}
|
|
988
|
+
>
|
|
989
|
+
<span>{label}</span>
|
|
990
|
+
<Icon className={cn("size-3.5", active === undefined && "opacity-40")} aria-hidden="true" />
|
|
991
|
+
</button>
|
|
992
|
+
</th>
|
|
993
|
+
);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// 3-State-Toggle: kein Sort → asc → desc → kein Sort. Idiomatisch für
|
|
997
|
+
// Power-User-Listen wo "ich will die Server-Default-Order zurück" eine
|
|
998
|
+
// echte Aktion ist (statt unendlich asc↔desc zu togglen).
|
|
999
|
+
function nextSortState(current: DataTableSortDir | undefined, field: string): DataTableSort | null {
|
|
1000
|
+
if (current === undefined) return { field, dir: "asc" };
|
|
1001
|
+
if (current === "asc") return { field, dir: "desc" };
|
|
1002
|
+
return null;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// Type-guard für die `{ react: { __component: "Name" } }`-Form, in der
|
|
1006
|
+
// PlatformComponent-Renderer im Schema serialisiert ankommen. Schemas
|
|
1007
|
+
// reisen über die Wire (Server → Client), echte Component-Refs würden
|
|
1008
|
+
// das brechen — der String-Key ist die SSoT.
|
|
1009
|
+
function isComponentRendererRef(renderer: unknown): { readonly name: string } | undefined {
|
|
1010
|
+
if (renderer === null || typeof renderer !== "object") return undefined;
|
|
1011
|
+
const reactBranch = (renderer as { react?: unknown }).react;
|
|
1012
|
+
if (reactBranch === null || typeof reactBranch !== "object") return undefined;
|
|
1013
|
+
const component = (reactBranch as { __component?: unknown }).__component;
|
|
1014
|
+
if (typeof component !== "string" || component.length === 0) return undefined;
|
|
1015
|
+
return { name: component };
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// Type-spezifische Default-Cell-Renderer. Author kann pro Spalte einen
|
|
1019
|
+
// expliziten renderer setzen (Function oder PlatformComponent); ohne
|
|
1020
|
+
// expliziten renderer fällt DataTableCell hier durch.
|
|
1021
|
+
//
|
|
1022
|
+
// - boolean → ✓ / leer
|
|
1023
|
+
// - timestamp/date → locale-formatiert (kein roher ISO-String)
|
|
1024
|
+
// - select → human-lesbar (kebab-case → Title Case)
|
|
1025
|
+
// - text/number/sonst → toString
|
|
1026
|
+
function defaultCellRender(
|
|
1027
|
+
value: unknown,
|
|
1028
|
+
type: string,
|
|
1029
|
+
optionLabels?: Readonly<Record<string, string>>,
|
|
1030
|
+
): string {
|
|
1031
|
+
if (value === null || value === undefined || value === "") return "";
|
|
1032
|
+
if (type === "boolean") return value === true ? "✓" : "";
|
|
1033
|
+
if (type === "timestamp" || type === "date") return formatDateCell(value, type);
|
|
1034
|
+
if (type === "select") {
|
|
1035
|
+
const raw = String(value);
|
|
1036
|
+
// Translated Label aus dem ViewModel-Builder (Convention-Key
|
|
1037
|
+
// `<feature>:entity:<entity>:field:<field>:option:<value>`).
|
|
1038
|
+
// Fallback humanizeSlug wenn kein Label registriert — gleiches
|
|
1039
|
+
// Verhalten wie vor dem optionLabels-Patch.
|
|
1040
|
+
const translated = optionLabels?.[raw];
|
|
1041
|
+
if (translated !== undefined && translated !== raw) return translated;
|
|
1042
|
+
return humanizeSlug(raw);
|
|
1043
|
+
}
|
|
1044
|
+
return typeof value === "string" ? value : String(value);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function formatDateCell(value: unknown, type: string): string {
|
|
1048
|
+
// Server liefert ISO-String oder Temporal.Instant.toJSON() (gleicher
|
|
1049
|
+
// ISO-shape). Für `type:"date"` zeigen wir nur das Datum, für
|
|
1050
|
+
// `type:"timestamp"` Datum + Uhrzeit. Locale-Default = Browser.
|
|
1051
|
+
try {
|
|
1052
|
+
const raw = typeof value === "string" ? value : String(value);
|
|
1053
|
+
const date = new Date(raw);
|
|
1054
|
+
if (Number.isNaN(date.getTime())) return raw;
|
|
1055
|
+
if (type === "date") {
|
|
1056
|
+
return date.toLocaleDateString();
|
|
1057
|
+
}
|
|
1058
|
+
return date.toLocaleString(undefined, {
|
|
1059
|
+
year: "numeric",
|
|
1060
|
+
month: "short",
|
|
1061
|
+
day: "numeric",
|
|
1062
|
+
hour: "2-digit",
|
|
1063
|
+
minute: "2-digit",
|
|
1064
|
+
});
|
|
1065
|
+
} catch {
|
|
1066
|
+
return String(value);
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
function humanizeSlug(slug: string): string {
|
|
1071
|
+
// "degraded-performance" → "Degraded performance"
|
|
1072
|
+
if (slug.length === 0) return slug;
|
|
1073
|
+
const spaced = slug.replace(/[-_]/g, " ");
|
|
1074
|
+
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// Tooltip-Text für truncated Cells — bei Hover zeigt der Browser den
|
|
1078
|
+
// vollen Text. Skipping für Object/Array (das ist nicht user-readable);
|
|
1079
|
+
// Number/Boolean stringifyt der Browser ohnehin korrekt.
|
|
1080
|
+
function cellTitle(value: unknown): string | undefined {
|
|
1081
|
+
if (value === null || value === undefined) return undefined;
|
|
1082
|
+
if (typeof value === "string") return value.length > 0 ? value : undefined;
|
|
1083
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
1084
|
+
return undefined;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
type DataTableCellProps = {
|
|
1088
|
+
readonly value: unknown;
|
|
1089
|
+
readonly row: Readonly<Record<string, unknown>>;
|
|
1090
|
+
readonly field: string;
|
|
1091
|
+
readonly type: string;
|
|
1092
|
+
readonly renderer?: unknown;
|
|
1093
|
+
readonly optionLabels?: Readonly<Record<string, string>>;
|
|
1094
|
+
};
|
|
1095
|
+
|
|
1096
|
+
// Cell-Renderer als Component (statt reiner Funktion) damit der
|
|
1097
|
+
// useColumnRenderer-Hook aus dem Provider lesen kann. Die drei Pfade
|
|
1098
|
+
// sind:
|
|
1099
|
+
// 1. Funktion → ruft fn(value) auf, returnt String. Bestand-Pfad,
|
|
1100
|
+
// bleibt unverändert für alle bestehenden Schemas.
|
|
1101
|
+
// 2. PlatformComponent (`{ react: { __component: "X" } }`) → schaut
|
|
1102
|
+
// "X" über useColumnRenderer auf und rendert `<X value row column/>`.
|
|
1103
|
+
// Nicht registriert → einmalige Warnung + Default-Fallback.
|
|
1104
|
+
// 3. Sonst → defaultCellRender (Type-basierter String-Renderer).
|
|
1105
|
+
function DataTableCell({
|
|
1106
|
+
value,
|
|
1107
|
+
row,
|
|
1108
|
+
field,
|
|
1109
|
+
type,
|
|
1110
|
+
renderer,
|
|
1111
|
+
optionLabels,
|
|
1112
|
+
}: DataTableCellProps): ReactNode {
|
|
1113
|
+
const componentRef = isComponentRendererRef(renderer);
|
|
1114
|
+
const ResolvedComponent = useColumnRenderer(componentRef?.name);
|
|
1115
|
+
if (typeof renderer === "function") {
|
|
1116
|
+
// 2. Argument: ganze Row als read-only — function-Renderer können
|
|
1117
|
+
// context-aware sein (Tier 2.7e-Eagerload nutzt das für _refs).
|
|
1118
|
+
const fn = renderer as (v: unknown, r?: Readonly<Record<string, unknown>>) => string;
|
|
1119
|
+
return fn(value, row);
|
|
1120
|
+
}
|
|
1121
|
+
if (componentRef !== undefined) {
|
|
1122
|
+
if (ResolvedComponent !== undefined) {
|
|
1123
|
+
return <ResolvedComponent value={value} row={row} column={{ field }} />;
|
|
1124
|
+
}
|
|
1125
|
+
// Renderer im Schema referenziert, aber client-side keine Map-Eintrag —
|
|
1126
|
+
// typischer Fall: clientFeatures.columnRenderers vergessen oder
|
|
1127
|
+
// Tippfehler im __component-Key. Warnen statt crashen, damit ein
|
|
1128
|
+
// Schema-Boot trotzdem funktioniert (Default-Type-Renderer übernimmt).
|
|
1129
|
+
// biome-ignore lint/suspicious/noConsole: dev-warning für Schema-Konflikte
|
|
1130
|
+
console.warn(`[kumiko] columnRenderer "${componentRef.name}" not registered`);
|
|
1131
|
+
}
|
|
1132
|
+
return defaultCellRender(value, type, optionLabels);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// ---- Form + Section + Grid + Text ----
|
|
1136
|
+
|
|
1137
|
+
function DefaultForm({ onSubmit, children, title, actions, testId }: FormProps): ReactNode {
|
|
1138
|
+
// Form ist full-width — main hat kein Padding, also fügen wir's hier
|
|
1139
|
+
// pro Bereich hinzu. Action-Bar bekommt h-12 + horizontal-px-6 und
|
|
1140
|
+
// klebt sticky am main-Top (ohne Negative-Margin-Tricks). Content
|
|
1141
|
+
// unten kriegt eigenes p-6 + max-w-2xl — schmaler als die volle
|
|
1142
|
+
// Sidebar-flankierte Breite, damit Zeilen nicht reißen und der
|
|
1143
|
+
// Single-Column-Linear-Look erhalten bleibt.
|
|
1144
|
+
return (
|
|
1145
|
+
<form
|
|
1146
|
+
onSubmit={(e) => {
|
|
1147
|
+
e.preventDefault();
|
|
1148
|
+
onSubmit(e);
|
|
1149
|
+
}}
|
|
1150
|
+
data-testid={testId}
|
|
1151
|
+
className="flex flex-col w-full"
|
|
1152
|
+
>
|
|
1153
|
+
{(title !== undefined || actions !== undefined) && (
|
|
1154
|
+
<div
|
|
1155
|
+
data-testid={testId !== undefined ? `${testId}-actions` : undefined}
|
|
1156
|
+
className="sticky top-0 z-10 h-12 px-6 bg-muted/30 border-b flex items-center gap-3"
|
|
1157
|
+
>
|
|
1158
|
+
{title !== undefined && (
|
|
1159
|
+
<div className="text-lg font-semibold tracking-tight truncate">{title}</div>
|
|
1160
|
+
)}
|
|
1161
|
+
{actions !== undefined && (
|
|
1162
|
+
<div className="flex items-center gap-2 ml-auto">{actions}</div>
|
|
1163
|
+
)}
|
|
1164
|
+
</div>
|
|
1165
|
+
)}
|
|
1166
|
+
<div className="px-6 pt-6 pb-12 max-w-2xl w-full flex flex-col gap-8">{children}</div>
|
|
1167
|
+
</form>
|
|
1168
|
+
);
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
function DefaultSection({ title, children, testId }: SectionProps): ReactNode {
|
|
1172
|
+
// Linear-Pattern: keine Card-Box, nur ein dezenter Section-Header
|
|
1173
|
+
// (uppercase, klein, muted) als Trennung. Sections fließen vertikal
|
|
1174
|
+
// im Form-Container; Visualität entsteht aus Whitespace + Typo, nicht
|
|
1175
|
+
// aus Border + Shadow. Spart Chrome und sieht weniger "boxy" aus.
|
|
1176
|
+
return (
|
|
1177
|
+
<section data-testid={testId} className="flex flex-col gap-4">
|
|
1178
|
+
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
1179
|
+
{title}
|
|
1180
|
+
</h3>
|
|
1181
|
+
<div className="flex flex-col gap-4">{children}</div>
|
|
1182
|
+
</section>
|
|
1183
|
+
);
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
function DefaultGrid({ columns, children, testId }: GridProps): ReactNode {
|
|
1187
|
+
// Responsive: Mobile (< sm = 640px) bleibt 1-spaltig, ab sm: greift
|
|
1188
|
+
// die Author-deklarierte Spaltenzahl. Inline-style schreibt
|
|
1189
|
+
// CSS-Variable; Tailwind-Klasse liest die Variable mit
|
|
1190
|
+
// `grid-template-columns: var(--grid-cols)`. Saubere Lösung weil
|
|
1191
|
+
// Tailwind JIT keinen dynamischen `grid-cols-${N}` auflösen kann.
|
|
1192
|
+
return (
|
|
1193
|
+
<div
|
|
1194
|
+
data-testid={testId}
|
|
1195
|
+
className="grid gap-4 grid-cols-1 sm:[grid-template-columns:var(--grid-cols)]"
|
|
1196
|
+
style={{ "--grid-cols": `repeat(${columns}, minmax(0, 1fr))` } as React.CSSProperties}
|
|
1197
|
+
>
|
|
1198
|
+
{children}
|
|
1199
|
+
</div>
|
|
1200
|
+
);
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
function DefaultGridCell({ span, children }: GridCellProps): ReactNode {
|
|
1204
|
+
const s = span !== undefined ? Math.min(span, 12) : 1;
|
|
1205
|
+
return <div style={{ gridColumn: `span ${s}` }}>{children}</div>;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
function DefaultText({ variant = "body", children, testId }: TextProps): ReactNode {
|
|
1209
|
+
switch (variant) {
|
|
1210
|
+
case "code":
|
|
1211
|
+
return (
|
|
1212
|
+
<code
|
|
1213
|
+
data-testid={testId}
|
|
1214
|
+
className="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm"
|
|
1215
|
+
>
|
|
1216
|
+
{children}
|
|
1217
|
+
</code>
|
|
1218
|
+
);
|
|
1219
|
+
case "small":
|
|
1220
|
+
return (
|
|
1221
|
+
<small data-testid={testId} className="text-xs text-muted-foreground">
|
|
1222
|
+
{children}
|
|
1223
|
+
</small>
|
|
1224
|
+
);
|
|
1225
|
+
case "required-mark":
|
|
1226
|
+
return (
|
|
1227
|
+
<span data-testid={testId} data-required className="text-destructive">
|
|
1228
|
+
{children}
|
|
1229
|
+
</span>
|
|
1230
|
+
);
|
|
1231
|
+
default:
|
|
1232
|
+
return <span data-testid={testId}>{children}</span>;
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
function DefaultHeading({ variant = "page", children, testId }: HeadingProps): ReactNode {
|
|
1237
|
+
// Page-Heading = h1, sehr selten in einer App (max 1 pro Screen).
|
|
1238
|
+
// Section-Heading = h2 mit uppercase + muted-foreground — derselbe
|
|
1239
|
+
// Look wie der Section-Header in Forms, aber als Standalone-Component
|
|
1240
|
+
// für Demo-Pages und Custom-Screens nutzbar.
|
|
1241
|
+
if (variant === "section") {
|
|
1242
|
+
return (
|
|
1243
|
+
<h2
|
|
1244
|
+
data-testid={testId}
|
|
1245
|
+
className="text-xs font-semibold uppercase tracking-wider text-muted-foreground"
|
|
1246
|
+
>
|
|
1247
|
+
{children}
|
|
1248
|
+
</h2>
|
|
1249
|
+
);
|
|
1250
|
+
}
|
|
1251
|
+
return (
|
|
1252
|
+
<h1 data-testid={testId} className="text-2xl font-semibold tracking-tight">
|
|
1253
|
+
{children}
|
|
1254
|
+
</h1>
|
|
1255
|
+
);
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
export const defaultPrimitives: CorePrimitives = {
|
|
1259
|
+
Button: DefaultButton,
|
|
1260
|
+
Banner: DefaultBanner,
|
|
1261
|
+
Field: DefaultField,
|
|
1262
|
+
Input: DefaultInput,
|
|
1263
|
+
DataTable: DefaultDataTable,
|
|
1264
|
+
Form: DefaultForm,
|
|
1265
|
+
Section: DefaultSection,
|
|
1266
|
+
Grid: DefaultGrid,
|
|
1267
|
+
GridCell: DefaultGridCell,
|
|
1268
|
+
Text: DefaultText,
|
|
1269
|
+
Heading: DefaultHeading,
|
|
1270
|
+
Dialog: DefaultDialog,
|
|
1271
|
+
};
|