@cosmicdrift/kumiko-renderer-web 0.65.0 → 0.67.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 +6 -4
- package/src/__tests__/form-action-bar.test.tsx +50 -15
- package/src/__tests__/nav-tree.test.tsx +250 -16
- package/src/__tests__/primitives.test.tsx +9 -6
- package/src/__tests__/render-edit.test.tsx +6 -6
- package/src/__tests__/test-utils.tsx +21 -0
- package/src/__tests__/workspace-shell.test.tsx +21 -66
- package/src/app/__tests__/qualify-nav-provider-key.test.ts +27 -0
- package/src/app/client-plugin.tsx +12 -27
- package/src/app/create-app.tsx +31 -19
- package/src/app/nav-providers-context.tsx +56 -0
- package/src/index.ts +8 -0
- package/src/layout/app-layout.tsx +9 -2
- package/src/layout/default-app-shell.tsx +116 -33
- package/src/layout/nav-tree.tsx +491 -125
- package/src/layout/sidebar-brand.tsx +40 -0
- package/src/layout/sidebar-user.tsx +46 -0
- package/src/layout/sidebar.tsx +1 -1
- package/src/layout/target-resolver-stub.tsx +3 -5
- package/src/layout/workspace-shell.tsx +32 -34
- package/src/primitives/file-upload.tsx +118 -0
- package/src/primitives/index.tsx +314 -175
- package/src/styles.css +76 -50
- package/src/ui/avatar.tsx +110 -0
- package/src/ui/badge.tsx +49 -0
- package/src/ui/breadcrumb.tsx +110 -0
- package/src/ui/button.tsx +65 -0
- package/src/ui/card.tsx +93 -0
- package/src/ui/checkbox.tsx +33 -0
- package/src/ui/collapsible.tsx +34 -0
- package/src/ui/dropdown-menu.tsx +258 -0
- package/src/ui/input.tsx +22 -0
- package/src/ui/label.tsx +25 -0
- package/src/ui/select.tsx +191 -0
- package/src/ui/separator.tsx +29 -0
- package/src/ui/sheet.tsx +144 -0
- package/src/ui/sidebar.tsx +727 -0
- package/src/ui/skeleton.tsx +14 -0
- package/src/ui/table.tsx +117 -0
- package/src/ui/textarea.tsx +19 -0
- package/src/ui/tooltip.tsx +58 -0
- package/src/ui/use-mobile.ts +20 -0
- package/src/__tests__/visual-tree-integration.test.tsx +0 -314
- package/src/app/tree-providers-context.tsx +0 -68
- package/src/layout/__tests__/visual-tree.test.tsx +0 -303
- package/src/layout/tree-node-renderer.tsx +0 -386
- package/src/layout/visual-tree.tsx +0 -398
package/src/primitives/index.tsx
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
type BannerProps,
|
|
21
21
|
type ButtonProps,
|
|
22
22
|
type CorePrimitives,
|
|
23
|
+
type DataTableFacet,
|
|
23
24
|
type DataTableProps,
|
|
24
25
|
type FieldProps,
|
|
25
26
|
type FormProps,
|
|
@@ -33,50 +34,60 @@ import {
|
|
|
33
34
|
useTranslation,
|
|
34
35
|
WriteFailedError,
|
|
35
36
|
} from "@cosmicdrift/kumiko-renderer";
|
|
36
|
-
import * as LabelPrimitive from "@radix-ui/react-label";
|
|
37
|
-
import { cva } from "class-variance-authority";
|
|
38
37
|
import {
|
|
39
38
|
ArrowDown,
|
|
40
39
|
ArrowUp,
|
|
41
40
|
ArrowUpDown,
|
|
41
|
+
ChevronDown,
|
|
42
42
|
ChevronLeft,
|
|
43
43
|
ChevronRight,
|
|
44
44
|
Loader2,
|
|
45
45
|
MoreHorizontal,
|
|
46
|
+
X,
|
|
46
47
|
} from "lucide-react";
|
|
47
|
-
import {
|
|
48
|
+
import {
|
|
49
|
+
type ChangeEvent,
|
|
50
|
+
createContext,
|
|
51
|
+
type ReactNode,
|
|
52
|
+
useContext,
|
|
53
|
+
useEffect,
|
|
54
|
+
useRef,
|
|
55
|
+
useState,
|
|
56
|
+
} from "react";
|
|
48
57
|
import { cn } from "../lib/cn";
|
|
58
|
+
import { Badge } from "../ui/badge";
|
|
59
|
+
import { Button as UiButton } from "../ui/button";
|
|
60
|
+
import { Card, CardContent, CardHeader } from "../ui/card";
|
|
61
|
+
import { Checkbox } from "../ui/checkbox";
|
|
62
|
+
import { Input as UiInput } from "../ui/input";
|
|
63
|
+
import { Label as UiLabel } from "../ui/label";
|
|
64
|
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table";
|
|
65
|
+
import { Textarea } from "../ui/textarea";
|
|
49
66
|
import { ComboboxInput } from "./combobox";
|
|
50
67
|
import { DateInput } from "./date-input";
|
|
51
68
|
import { DefaultDialog } from "./dialog";
|
|
52
69
|
import {
|
|
53
70
|
DropdownMenu,
|
|
71
|
+
DropdownMenuCheckboxItem,
|
|
54
72
|
DropdownMenuContent,
|
|
55
73
|
DropdownMenuItem,
|
|
56
74
|
DropdownMenuTrigger,
|
|
57
75
|
} from "./dropdown-menu";
|
|
76
|
+
import { FileUploadInput } from "./file-upload";
|
|
58
77
|
import { MoneyInput } from "./money-input";
|
|
59
78
|
import { TimestampInput } from "./timestamp-input";
|
|
60
79
|
import { useToast } from "./toast";
|
|
61
80
|
|
|
62
|
-
// ---- Button ----
|
|
81
|
+
// ---- Button (vendored shadcn ui/button) ----
|
|
63
82
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
secondary:
|
|
73
|
-
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
|
74
|
-
danger: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
|
75
|
-
},
|
|
76
|
-
},
|
|
77
|
-
defaultVariants: { variant: "primary" },
|
|
78
|
-
},
|
|
79
|
-
);
|
|
83
|
+
// Contract-Variant → shadcn-Variant: secondary war schon immer der
|
|
84
|
+
// bordered-bg-background-Look = shadcns `outline`. primary→default,
|
|
85
|
+
// danger→destructive.
|
|
86
|
+
const BUTTON_VARIANT = {
|
|
87
|
+
primary: "default",
|
|
88
|
+
secondary: "outline",
|
|
89
|
+
danger: "destructive",
|
|
90
|
+
} as const;
|
|
80
91
|
|
|
81
92
|
function DefaultButton({
|
|
82
93
|
type = "button",
|
|
@@ -88,16 +99,16 @@ function DefaultButton({
|
|
|
88
99
|
testId,
|
|
89
100
|
}: ButtonProps): ReactNode {
|
|
90
101
|
return (
|
|
91
|
-
<
|
|
102
|
+
<UiButton
|
|
92
103
|
type={type}
|
|
93
104
|
onClick={onClick}
|
|
94
105
|
disabled={disabled === true || loading === true}
|
|
95
106
|
data-testid={testId}
|
|
96
107
|
data-loading={loading === true ? "true" : undefined}
|
|
97
|
-
|
|
108
|
+
variant={BUTTON_VARIANT[variant]}
|
|
98
109
|
>
|
|
99
110
|
{loading === true ? <Loader2 className="size-4 animate-spin" aria-hidden="true" /> : children}
|
|
100
|
-
</
|
|
111
|
+
</UiButton>
|
|
101
112
|
);
|
|
102
113
|
}
|
|
103
114
|
|
|
@@ -143,28 +154,47 @@ function DefaultField({
|
|
|
143
154
|
labelAppendix,
|
|
144
155
|
fieldAppendix,
|
|
145
156
|
children,
|
|
157
|
+
layout,
|
|
146
158
|
testId,
|
|
147
159
|
}: FieldProps): ReactNode {
|
|
148
160
|
const t = useTranslation();
|
|
149
161
|
const hasError = issues !== undefined && issues.length > 0;
|
|
162
|
+
const labelEl = (
|
|
163
|
+
<UiLabel htmlFor={id} className={hasError ? "text-destructive" : "text-foreground"}>
|
|
164
|
+
{label}
|
|
165
|
+
{required === true && <span className="ml-0.5 text-destructive">*</span>}
|
|
166
|
+
</UiLabel>
|
|
167
|
+
);
|
|
168
|
+
const errorsEl = hasError ? (
|
|
169
|
+
<div
|
|
170
|
+
role="alert"
|
|
171
|
+
data-testid={testId !== undefined ? `${testId}-errors` : undefined}
|
|
172
|
+
className="text-xs text-destructive"
|
|
173
|
+
>
|
|
174
|
+
{issues.map((issue) => (
|
|
175
|
+
<div key={`${issue.path}:${issue.code}`}>{t(issue.i18nKey, issue.params)}</div>
|
|
176
|
+
))}
|
|
177
|
+
</div>
|
|
178
|
+
) : null;
|
|
179
|
+
|
|
180
|
+
// Inline (boolean/checkbox): Control links, Label rechts — shadcn-Muster.
|
|
181
|
+
if (layout === "inline") {
|
|
182
|
+
return (
|
|
183
|
+
<div data-testid={testId} className="flex flex-col gap-1.5">
|
|
184
|
+
<div className="flex items-center gap-2">
|
|
185
|
+
{children}
|
|
186
|
+
{labelEl}
|
|
187
|
+
{labelAppendix !== undefined && labelAppendix}
|
|
188
|
+
</div>
|
|
189
|
+
{errorsEl}
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
150
194
|
return (
|
|
151
195
|
<div data-testid={testId} className="flex flex-col gap-1.5">
|
|
152
196
|
<div className="flex items-center justify-between gap-2">
|
|
153
|
-
|
|
154
|
-
htmlFor={id}
|
|
155
|
-
className={cn(
|
|
156
|
-
// peer-disabled-Sentinel: shadcn-Pattern — wenn das assoziierte
|
|
157
|
-
// Input disabled ist (peer + disabled-Klasse), wird das Label
|
|
158
|
-
// mitgrayout. Funktioniert weil Radix-Label das nativ-htmlFor-
|
|
159
|
-
// verlinkte Element als Peer betrachtet.
|
|
160
|
-
"text-sm font-medium leading-none",
|
|
161
|
-
"peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
|
162
|
-
hasError ? "text-destructive" : "text-foreground",
|
|
163
|
-
)}
|
|
164
|
-
>
|
|
165
|
-
{label}
|
|
166
|
-
{required === true && <span className="ml-0.5 text-destructive">*</span>}
|
|
167
|
-
</LabelPrimitive.Root>
|
|
197
|
+
{labelEl}
|
|
168
198
|
{/* appendix neben dem <label>, nicht darin — interaktiver Inhalt
|
|
169
199
|
(Disclosure-Button) gehört nicht in ein label-Element. */}
|
|
170
200
|
{labelAppendix !== undefined && labelAppendix}
|
|
@@ -174,32 +204,16 @@ function DefaultField({
|
|
|
174
204
|
Label-Row, nicht durch den Input davon getrennt. */}
|
|
175
205
|
{fieldAppendix !== undefined && fieldAppendix}
|
|
176
206
|
{children}
|
|
177
|
-
{
|
|
178
|
-
<div
|
|
179
|
-
role="alert"
|
|
180
|
-
data-testid={testId !== undefined ? `${testId}-errors` : undefined}
|
|
181
|
-
className="text-xs text-destructive"
|
|
182
|
-
>
|
|
183
|
-
{issues.map((issue) => (
|
|
184
|
-
<div key={`${issue.path}:${issue.code}`}>{t(issue.i18nKey, issue.params)}</div>
|
|
185
|
-
))}
|
|
186
|
-
</div>
|
|
187
|
-
)}
|
|
207
|
+
{errorsEl}
|
|
188
208
|
</div>
|
|
189
209
|
);
|
|
190
210
|
}
|
|
191
211
|
|
|
192
212
|
// ---- Input ----
|
|
193
213
|
|
|
194
|
-
const inputClassBase =
|
|
195
|
-
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm " +
|
|
196
|
-
"transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium " +
|
|
197
|
-
"placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring " +
|
|
198
|
-
"disabled:cursor-not-allowed disabled:opacity-50";
|
|
199
|
-
|
|
200
214
|
function DefaultInput(props: InputProps): ReactNode {
|
|
201
|
-
|
|
202
|
-
|
|
215
|
+
// Vendored ui/input + ui/checkbox stylen Fehler über `aria-invalid`
|
|
216
|
+
// selbst — kein manuelles border-destructive mehr nötig.
|
|
203
217
|
const common = {
|
|
204
218
|
id: props.id,
|
|
205
219
|
name: props.name,
|
|
@@ -210,42 +224,39 @@ function DefaultInput(props: InputProps): ReactNode {
|
|
|
210
224
|
switch (props.kind) {
|
|
211
225
|
case "text":
|
|
212
226
|
return (
|
|
213
|
-
<
|
|
227
|
+
<UiInput
|
|
214
228
|
type="text"
|
|
215
229
|
{...common}
|
|
216
230
|
value={props.value}
|
|
217
231
|
onChange={(e: ChangeEvent<HTMLInputElement>) => props.onChange(e.target.value)}
|
|
218
232
|
{...(props.placeholder !== undefined && { placeholder: props.placeholder })}
|
|
219
233
|
{...(props.autoComplete !== undefined && { autoComplete: props.autoComplete })}
|
|
220
|
-
className={cn(inputClassBase, errorClass)}
|
|
221
234
|
/>
|
|
222
235
|
);
|
|
223
236
|
case "email":
|
|
224
237
|
return (
|
|
225
|
-
<
|
|
238
|
+
<UiInput
|
|
226
239
|
type="email"
|
|
227
240
|
{...common}
|
|
228
241
|
value={props.value}
|
|
229
242
|
onChange={(e: ChangeEvent<HTMLInputElement>) => props.onChange(e.target.value)}
|
|
230
243
|
{...(props.placeholder !== undefined && { placeholder: props.placeholder })}
|
|
231
244
|
autoComplete={props.autoComplete ?? "email"}
|
|
232
|
-
className={cn(inputClassBase, errorClass)}
|
|
233
245
|
/>
|
|
234
246
|
);
|
|
235
247
|
case "password":
|
|
236
248
|
return (
|
|
237
|
-
<
|
|
249
|
+
<UiInput
|
|
238
250
|
type="password"
|
|
239
251
|
{...common}
|
|
240
252
|
value={props.value}
|
|
241
253
|
onChange={(e: ChangeEvent<HTMLInputElement>) => props.onChange(e.target.value)}
|
|
242
254
|
autoComplete={props.autoComplete ?? "current-password"}
|
|
243
|
-
className={cn(inputClassBase, errorClass)}
|
|
244
255
|
/>
|
|
245
256
|
);
|
|
246
257
|
case "number":
|
|
247
258
|
return (
|
|
248
|
-
<
|
|
259
|
+
<UiInput
|
|
249
260
|
type="number"
|
|
250
261
|
{...common}
|
|
251
262
|
value={props.value}
|
|
@@ -253,20 +264,33 @@ function DefaultInput(props: InputProps): ReactNode {
|
|
|
253
264
|
const v = e.target.value;
|
|
254
265
|
props.onChange(v === "" ? undefined : Number(v));
|
|
255
266
|
}}
|
|
256
|
-
className=
|
|
267
|
+
className="text-right tabular-nums"
|
|
257
268
|
/>
|
|
258
269
|
);
|
|
259
270
|
case "boolean":
|
|
260
271
|
return (
|
|
261
|
-
<
|
|
262
|
-
|
|
263
|
-
{
|
|
272
|
+
<Checkbox
|
|
273
|
+
id={props.id}
|
|
274
|
+
name={props.name}
|
|
275
|
+
disabled={props.disabled}
|
|
276
|
+
aria-required={props.required}
|
|
277
|
+
aria-invalid={props.hasError === true ? true : undefined}
|
|
264
278
|
checked={props.value}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
279
|
+
onCheckedChange={(checked) => props.onChange(checked === true)}
|
|
280
|
+
/>
|
|
281
|
+
);
|
|
282
|
+
case "file":
|
|
283
|
+
case "image":
|
|
284
|
+
return (
|
|
285
|
+
<FileUploadInput
|
|
286
|
+
kind={props.kind}
|
|
287
|
+
id={props.id}
|
|
288
|
+
value={props.value}
|
|
289
|
+
onChange={props.onChange}
|
|
290
|
+
{...(props.accept !== undefined && { accept: props.accept })}
|
|
291
|
+
{...(props.disabled !== undefined && { disabled: props.disabled })}
|
|
292
|
+
{...(props.entityType !== undefined && { entityType: props.entityType })}
|
|
293
|
+
{...(props.fieldName !== undefined && { fieldName: props.fieldName })}
|
|
270
294
|
/>
|
|
271
295
|
);
|
|
272
296
|
case "date":
|
|
@@ -366,23 +390,13 @@ function DefaultInput(props: InputProps): ReactNode {
|
|
|
366
390
|
/>
|
|
367
391
|
);
|
|
368
392
|
case "textarea":
|
|
369
|
-
// Default 4 Zeilen — vertikal-resize via resize-y. min-h damit
|
|
370
|
-
// ein bewusstes rows={2} nicht unter eine sinnvolle Mindesthöhe
|
|
371
|
-
// schrumpft. Field-Klasse wird leicht angepasst (kein h-9 weil
|
|
372
|
-
// multiline).
|
|
373
393
|
return (
|
|
374
|
-
<
|
|
394
|
+
<Textarea
|
|
375
395
|
{...common}
|
|
376
396
|
value={props.value}
|
|
377
397
|
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => props.onChange(e.target.value)}
|
|
378
398
|
rows={props.rows ?? 4}
|
|
379
|
-
className=
|
|
380
|
-
"flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm",
|
|
381
|
-
"transition-colors placeholder:text-muted-foreground focus-visible:outline-none",
|
|
382
|
-
"focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
|
383
|
-
"resize-y min-h-[80px]",
|
|
384
|
-
errorClass,
|
|
385
|
-
)}
|
|
399
|
+
className="resize-y"
|
|
386
400
|
/>
|
|
387
401
|
);
|
|
388
402
|
}
|
|
@@ -390,6 +404,51 @@ function DefaultInput(props: InputProps): ReactNode {
|
|
|
390
404
|
|
|
391
405
|
// ---- DataTable (shadcn: Table) ----
|
|
392
406
|
|
|
407
|
+
// Faceted-Filter-Dropdown: Outline-Button (wie shadcns "Columns"-Toggle) +
|
|
408
|
+
// Multi-Select-Checkboxen. Aktive Auswahl → Count-Badge am Button.
|
|
409
|
+
function FacetFilter({
|
|
410
|
+
facet,
|
|
411
|
+
selected,
|
|
412
|
+
onChange,
|
|
413
|
+
}: {
|
|
414
|
+
facet: DataTableFacet;
|
|
415
|
+
selected: readonly string[];
|
|
416
|
+
onChange: (field: string, values: readonly string[]) => void;
|
|
417
|
+
}): ReactNode {
|
|
418
|
+
const toggle = (value: string, checked: boolean): void => {
|
|
419
|
+
const next = checked ? [...selected, value] : selected.filter((v) => v !== value);
|
|
420
|
+
onChange(facet.field, next);
|
|
421
|
+
};
|
|
422
|
+
return (
|
|
423
|
+
<DropdownMenu>
|
|
424
|
+
<DropdownMenuTrigger asChild>
|
|
425
|
+
<UiButton variant="outline" size="sm" className="h-9" data-testid={`facet-${facet.field}`}>
|
|
426
|
+
{facet.label}
|
|
427
|
+
{selected.length > 0 && (
|
|
428
|
+
<Badge variant="secondary" className="ml-1 rounded-sm px-1 font-normal tabular-nums">
|
|
429
|
+
{selected.length}
|
|
430
|
+
</Badge>
|
|
431
|
+
)}
|
|
432
|
+
<ChevronDown className="text-muted-foreground" />
|
|
433
|
+
</UiButton>
|
|
434
|
+
</DropdownMenuTrigger>
|
|
435
|
+
<DropdownMenuContent align="start" className="w-44">
|
|
436
|
+
{facet.options.map((opt) => (
|
|
437
|
+
<DropdownMenuCheckboxItem
|
|
438
|
+
key={opt.value}
|
|
439
|
+
checked={selected.includes(opt.value)}
|
|
440
|
+
onCheckedChange={(checked: boolean) => toggle(opt.value, checked)}
|
|
441
|
+
onSelect={(e: Event) => e.preventDefault()}
|
|
442
|
+
data-testid={`facet-${facet.field}-${opt.value}`}
|
|
443
|
+
>
|
|
444
|
+
{opt.label}
|
|
445
|
+
</DropdownMenuCheckboxItem>
|
|
446
|
+
))}
|
|
447
|
+
</DropdownMenuContent>
|
|
448
|
+
</DropdownMenu>
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
|
|
393
452
|
function DefaultDataTable({
|
|
394
453
|
columns,
|
|
395
454
|
rows,
|
|
@@ -397,7 +456,6 @@ function DefaultDataTable({
|
|
|
397
456
|
sort,
|
|
398
457
|
onSortChange,
|
|
399
458
|
emptyState,
|
|
400
|
-
toolbarTitle,
|
|
401
459
|
toolbarStart,
|
|
402
460
|
toolbarEnd,
|
|
403
461
|
pager,
|
|
@@ -406,6 +464,10 @@ function DefaultDataTable({
|
|
|
406
464
|
hasMore,
|
|
407
465
|
rowActions,
|
|
408
466
|
rowActionMode,
|
|
467
|
+
filterFacets,
|
|
468
|
+
filterValues,
|
|
469
|
+
onFilterChange,
|
|
470
|
+
onFilterReset,
|
|
409
471
|
testId,
|
|
410
472
|
}: DataTableProps): ReactNode {
|
|
411
473
|
// Toolbar-Wrapper: gemeinsamer Container für Toolbar+Tabelle damit
|
|
@@ -421,10 +483,12 @@ function DefaultDataTable({
|
|
|
421
483
|
{emptyState ?? <span>No entries.</span>}
|
|
422
484
|
</div>
|
|
423
485
|
) : (
|
|
424
|
-
|
|
425
|
-
|
|
486
|
+
// dashboard-01-Muster: schlichter `rounded-lg border`-Rahmen (kein
|
|
487
|
+
// bg-card/shadow), die Header-Zeile trägt den bg-muted-Grauton.
|
|
488
|
+
<div className="overflow-hidden rounded-lg border">
|
|
489
|
+
<Table data-testid={testId}>
|
|
426
490
|
{tableInner(columns, rows, onRowClick, sort, onSortChange, rowActions, rowActionMode)}
|
|
427
|
-
</
|
|
491
|
+
</Table>
|
|
428
492
|
</div>
|
|
429
493
|
);
|
|
430
494
|
|
|
@@ -459,29 +523,56 @@ function DefaultDataTable({
|
|
|
459
523
|
tableContent
|
|
460
524
|
);
|
|
461
525
|
|
|
526
|
+
const hasFacets =
|
|
527
|
+
filterFacets !== undefined && filterFacets.length > 0 && onFilterChange !== undefined;
|
|
528
|
+
const hasActiveFilters =
|
|
529
|
+
filterValues !== undefined && Object.values(filterValues).some((v) => v.length > 0);
|
|
530
|
+
const facetCluster = hasFacets ? (
|
|
531
|
+
<div className="flex items-center gap-2">
|
|
532
|
+
{filterFacets.map((facet) => (
|
|
533
|
+
<FacetFilter
|
|
534
|
+
key={facet.field}
|
|
535
|
+
facet={facet}
|
|
536
|
+
selected={filterValues?.[facet.field] ?? []}
|
|
537
|
+
onChange={onFilterChange}
|
|
538
|
+
/>
|
|
539
|
+
))}
|
|
540
|
+
{hasActiveFilters && onFilterReset !== undefined && (
|
|
541
|
+
<UiButton
|
|
542
|
+
variant="ghost"
|
|
543
|
+
size="sm"
|
|
544
|
+
className="h-9 px-2"
|
|
545
|
+
onClick={onFilterReset}
|
|
546
|
+
data-testid="facet-reset"
|
|
547
|
+
>
|
|
548
|
+
Reset
|
|
549
|
+
<X />
|
|
550
|
+
</UiButton>
|
|
551
|
+
)}
|
|
552
|
+
</div>
|
|
553
|
+
) : undefined;
|
|
554
|
+
|
|
462
555
|
const hasToolbar =
|
|
463
|
-
|
|
464
|
-
if (!hasToolbar) return <div className="p-6">{content}</div>;
|
|
556
|
+
toolbarStart !== undefined || toolbarEnd !== undefined || facetCluster !== undefined;
|
|
465
557
|
|
|
466
|
-
//
|
|
467
|
-
//
|
|
468
|
-
//
|
|
469
|
-
// eigenes p-6 damit Tabelle/Empty-State nicht an die Edges kleben.
|
|
558
|
+
// dashboard-01-Muster: die Toolbar (Search + Facets + "+ Neu") sitzt ÜBER
|
|
559
|
+
// der Tabelle im selben Padding-Block — kein separater bg-Bar, kein Screen-
|
|
560
|
+
// Titel (der steht im Breadcrumb der Shell).
|
|
470
561
|
return (
|
|
471
|
-
<div className="flex flex-col w-full">
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
<div className="
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
562
|
+
<div className="flex flex-col gap-4 p-6 w-full">
|
|
563
|
+
{hasToolbar && (
|
|
564
|
+
<div
|
|
565
|
+
data-testid={testId !== undefined ? `${testId}-toolbar` : "render-list-toolbar"}
|
|
566
|
+
className="flex items-center gap-3"
|
|
567
|
+
>
|
|
568
|
+
{toolbarStart !== undefined && <div className="flex-1 max-w-sm">{toolbarStart}</div>}
|
|
569
|
+
{facetCluster}
|
|
570
|
+
{toolbarEnd !== undefined && (
|
|
571
|
+
<div className="flex items-center gap-2 ml-auto">{toolbarEnd}</div>
|
|
572
|
+
)}
|
|
573
|
+
</div>
|
|
574
|
+
)}
|
|
575
|
+
{content}
|
|
485
576
|
</div>
|
|
486
577
|
);
|
|
487
578
|
}
|
|
@@ -498,8 +589,8 @@ function tableInner(
|
|
|
498
589
|
const hasActions = rowActions !== undefined && rowActions.length > 0;
|
|
499
590
|
return (
|
|
500
591
|
<>
|
|
501
|
-
<
|
|
502
|
-
<
|
|
592
|
+
<TableHeader className="bg-muted">
|
|
593
|
+
<TableRow className="hover:bg-transparent">
|
|
503
594
|
{columns.map((col) => (
|
|
504
595
|
<SortableHeader
|
|
505
596
|
key={col.field}
|
|
@@ -511,39 +602,34 @@ function tableInner(
|
|
|
511
602
|
/>
|
|
512
603
|
))}
|
|
513
604
|
{hasActions && (
|
|
514
|
-
<
|
|
605
|
+
<TableHead
|
|
515
606
|
data-testid="column-actions"
|
|
516
|
-
// sticky right-0 + bg-
|
|
517
|
-
// beim horizontalen Scroll am rechten Rand bleibt
|
|
518
|
-
|
|
519
|
-
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"
|
|
607
|
+
// sticky right-0 + bg-muted (= Header-Ton) damit die Action-
|
|
608
|
+
// Spalte beim horizontalen Scroll am rechten Rand bleibt.
|
|
609
|
+
className="sticky right-0 z-10 w-px border-l bg-muted text-right text-muted-foreground"
|
|
520
610
|
aria-label="Actions"
|
|
521
611
|
/>
|
|
522
612
|
)}
|
|
523
|
-
</
|
|
524
|
-
</
|
|
525
|
-
<
|
|
613
|
+
</TableRow>
|
|
614
|
+
</TableHeader>
|
|
615
|
+
<TableBody>
|
|
526
616
|
{rows.map((row) => (
|
|
527
|
-
<
|
|
617
|
+
<TableRow
|
|
528
618
|
key={row.id}
|
|
529
619
|
data-testid={`row-${row.id}`}
|
|
530
620
|
onClick={onRowClick !== undefined ? () => onRowClick(row) : undefined}
|
|
531
|
-
className={cn(
|
|
532
|
-
"border-b transition-colors hover:bg-muted/50",
|
|
533
|
-
onRowClick !== undefined && "cursor-pointer",
|
|
534
|
-
)}
|
|
621
|
+
className={cn(onRowClick !== undefined && "cursor-pointer")}
|
|
535
622
|
>
|
|
536
623
|
{columns.map((col) => (
|
|
537
|
-
<
|
|
624
|
+
<TableCell
|
|
538
625
|
key={col.field}
|
|
539
626
|
data-testid={`cell-${row.id}-${col.field}`}
|
|
540
627
|
// Cells truncaten lange Werte mit ellipsis statt umzu-
|
|
541
628
|
// brechen — Lists bleiben einzeilig + scannbar (Linear-
|
|
542
629
|
// Pattern). max-w-xs gibt eine vernünftige Default-
|
|
543
|
-
// Obergrenze;
|
|
544
|
-
//
|
|
545
|
-
|
|
546
|
-
className="p-4 align-middle max-w-xs truncate"
|
|
630
|
+
// Obergrenze; der Table-Container scrollt horizontal
|
|
631
|
+
// falls die Summe der Spalten zu breit wird.
|
|
632
|
+
className="max-w-xs truncate"
|
|
547
633
|
title={cellTitle(row.values[col.field])}
|
|
548
634
|
>
|
|
549
635
|
<DataTableCell
|
|
@@ -554,15 +640,15 @@ function tableInner(
|
|
|
554
640
|
renderer={col.renderer}
|
|
555
641
|
{...(col.optionLabels !== undefined && { optionLabels: col.optionLabels })}
|
|
556
642
|
/>
|
|
557
|
-
</
|
|
643
|
+
</TableCell>
|
|
558
644
|
))}
|
|
559
645
|
{hasActions && (
|
|
560
|
-
<
|
|
646
|
+
<TableCell
|
|
561
647
|
data-testid={`cell-${row.id}-actions`}
|
|
562
648
|
// Sticky-right damit beim horizontalen Scroll die Actions
|
|
563
649
|
// am rechten Rand sichtbar bleiben. bg-background +
|
|
564
650
|
// border-l für den visuellen Abschluss.
|
|
565
|
-
className="
|
|
651
|
+
className="sticky right-0 z-10 border-l bg-background text-right"
|
|
566
652
|
// Action-Cell-Events dürfen nicht den Row-Click/Activation
|
|
567
653
|
// triggern (typisch "Open Detail" — der User wollte ja die
|
|
568
654
|
// Action, nicht navigieren). Wir stopPropagation für Mouse
|
|
@@ -571,11 +657,11 @@ function tableInner(
|
|
|
571
657
|
onKeyDown={(e) => e.stopPropagation()}
|
|
572
658
|
>
|
|
573
659
|
<RowActionsCell row={row} actions={rowActions} mode={rowActionMode} />
|
|
574
|
-
</
|
|
660
|
+
</TableCell>
|
|
575
661
|
)}
|
|
576
|
-
</
|
|
662
|
+
</TableRow>
|
|
577
663
|
))}
|
|
578
|
-
</
|
|
664
|
+
</TableBody>
|
|
579
665
|
</>
|
|
580
666
|
);
|
|
581
667
|
}
|
|
@@ -1031,13 +1117,13 @@ function SortableHeader({
|
|
|
1031
1117
|
|
|
1032
1118
|
if (!sortable || onSortChange === undefined) {
|
|
1033
1119
|
return (
|
|
1034
|
-
<
|
|
1120
|
+
<TableHead
|
|
1035
1121
|
data-testid={`column-${field}`}
|
|
1036
1122
|
data-sortable={sortable === true ? true : undefined}
|
|
1037
|
-
className="
|
|
1123
|
+
className="px-4 text-muted-foreground"
|
|
1038
1124
|
>
|
|
1039
1125
|
{label}
|
|
1040
|
-
</
|
|
1126
|
+
</TableHead>
|
|
1041
1127
|
);
|
|
1042
1128
|
}
|
|
1043
1129
|
|
|
@@ -1045,11 +1131,11 @@ function SortableHeader({
|
|
|
1045
1131
|
const next = nextSortState(active?.dir, field);
|
|
1046
1132
|
|
|
1047
1133
|
return (
|
|
1048
|
-
<
|
|
1134
|
+
<TableHead
|
|
1049
1135
|
data-testid={`column-${field}`}
|
|
1050
1136
|
data-sortable="true"
|
|
1051
1137
|
aria-sort={ariaSort}
|
|
1052
|
-
className="
|
|
1138
|
+
className="px-4 text-muted-foreground"
|
|
1053
1139
|
>
|
|
1054
1140
|
<button
|
|
1055
1141
|
type="button"
|
|
@@ -1064,7 +1150,7 @@ function SortableHeader({
|
|
|
1064
1150
|
<span>{label}</span>
|
|
1065
1151
|
<Icon className={cn("size-3.5", active === undefined && "opacity-40")} aria-hidden="true" />
|
|
1066
1152
|
</button>
|
|
1067
|
-
</
|
|
1153
|
+
</TableHead>
|
|
1068
1154
|
);
|
|
1069
1155
|
}
|
|
1070
1156
|
|
|
@@ -1185,18 +1271,40 @@ function DataTableCell({
|
|
|
1185
1271
|
// biome-ignore lint/suspicious/noConsole: dev-warning für Schema-Konflikte
|
|
1186
1272
|
console.warn(`[kumiko] columnRenderer "${componentRef.name}" not registered`);
|
|
1187
1273
|
}
|
|
1274
|
+
// select-Werte als neutrale Badge-Pill (shadcn secondary) statt Plain-Text.
|
|
1275
|
+
// Farbige Status-Semantik (grün/amber) bleibt App-Sache via columnRenderer.
|
|
1276
|
+
if (type === "select" && value !== null && value !== undefined && value !== "") {
|
|
1277
|
+
// dashboard-01-Muster: outline-Badge + muted statt gefülltem secondary.
|
|
1278
|
+
return (
|
|
1279
|
+
<Badge variant="outline" className="px-1.5 text-muted-foreground">
|
|
1280
|
+
{defaultCellRender(value, type, optionLabels)}
|
|
1281
|
+
</Badge>
|
|
1282
|
+
);
|
|
1283
|
+
}
|
|
1188
1284
|
return defaultCellRender(value, type, optionLabels);
|
|
1189
1285
|
}
|
|
1190
1286
|
|
|
1191
1287
|
// ---- Form + Section + Grid + Text ----
|
|
1192
1288
|
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1289
|
+
// Setzt DefaultSection in den Inner-Region-Modus: das ganze Form ist EINE
|
|
1290
|
+
// Card, Sections sind divider-getrennte Abschnitte darin (shadcn-Muster wie
|
|
1291
|
+
// Shipping/Invoice/Profile). Standalone (außerhalb Form) bleibt Section eine
|
|
1292
|
+
// eigene Card.
|
|
1293
|
+
const InsideFormContext = createContext(false);
|
|
1294
|
+
|
|
1295
|
+
function DefaultForm({
|
|
1296
|
+
onSubmit,
|
|
1297
|
+
children,
|
|
1298
|
+
title,
|
|
1299
|
+
subtitle,
|
|
1300
|
+
actions,
|
|
1301
|
+
testId,
|
|
1302
|
+
}: FormProps): ReactNode {
|
|
1303
|
+
// shadcn-Form-Muster: das Formular ist EINE Card — Titel als Card-Header
|
|
1304
|
+
// OHNE Trennlinie darunter (Titel fließt in die erste Section). Sections
|
|
1305
|
+
// sind divide-y-getrennt (Linien nur ZWISCHEN ihnen). Action-Footer mit
|
|
1306
|
+
// bg-muted/30 als Farb-Trenner statt harter Linie. main hat kein Padding;
|
|
1307
|
+
// der max-w-3xl-Body rahmt die Card.
|
|
1200
1308
|
return (
|
|
1201
1309
|
<form
|
|
1202
1310
|
onSubmit={(e) => {
|
|
@@ -1206,43 +1314,74 @@ function DefaultForm({ onSubmit, children, title, actions, testId }: FormProps):
|
|
|
1206
1314
|
data-testid={testId}
|
|
1207
1315
|
className="flex flex-col w-full"
|
|
1208
1316
|
>
|
|
1209
|
-
|
|
1210
|
-
<div
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1317
|
+
<div className="px-6 pt-6 pb-12 max-w-3xl w-full">
|
|
1318
|
+
<div className="bg-card overflow-hidden rounded-xl border shadow-sm">
|
|
1319
|
+
{(title !== undefined || subtitle !== undefined) && (
|
|
1320
|
+
<div className="px-6 pb-2 pt-5">
|
|
1321
|
+
{title !== undefined && (
|
|
1322
|
+
<h2
|
|
1323
|
+
data-testid={testId !== undefined ? `${testId}-title` : undefined}
|
|
1324
|
+
className="text-lg font-semibold tracking-tight"
|
|
1325
|
+
>
|
|
1326
|
+
{title}
|
|
1327
|
+
</h2>
|
|
1328
|
+
)}
|
|
1329
|
+
{subtitle !== undefined && (
|
|
1330
|
+
<p
|
|
1331
|
+
data-testid={testId !== undefined ? `${testId}-subtitle` : undefined}
|
|
1332
|
+
className="mt-1 text-sm text-muted-foreground"
|
|
1333
|
+
>
|
|
1334
|
+
{subtitle}
|
|
1335
|
+
</p>
|
|
1336
|
+
)}
|
|
1337
|
+
</div>
|
|
1338
|
+
)}
|
|
1339
|
+
<div className="divide-y">
|
|
1340
|
+
<InsideFormContext.Provider value={true}>{children}</InsideFormContext.Provider>
|
|
1224
1341
|
</div>
|
|
1342
|
+
{actions !== undefined && (
|
|
1343
|
+
<div
|
|
1344
|
+
data-testid={testId !== undefined ? `${testId}-actions` : undefined}
|
|
1345
|
+
className="flex items-center justify-end gap-2 border-t bg-muted/30 px-6 py-4"
|
|
1346
|
+
>
|
|
1347
|
+
{actions}
|
|
1348
|
+
</div>
|
|
1349
|
+
)}
|
|
1225
1350
|
</div>
|
|
1226
|
-
|
|
1227
|
-
<div className="px-6 pt-6 pb-12 max-w-2xl w-full flex flex-col gap-8">{children}</div>
|
|
1351
|
+
</div>
|
|
1228
1352
|
</form>
|
|
1229
1353
|
);
|
|
1230
1354
|
}
|
|
1231
1355
|
|
|
1232
1356
|
function DefaultSection({ title, children, testId }: SectionProps): ReactNode {
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
//
|
|
1236
|
-
//
|
|
1357
|
+
const insideForm = useContext(InsideFormContext);
|
|
1358
|
+
|
|
1359
|
+
// Innerhalb eines Forms: divider-loser Abschnitt OHNE eigene Card-Fläche.
|
|
1360
|
+
// Die Trennlinien ZWISCHEN Sections macht der divide-y-Wrapper im Form —
|
|
1361
|
+
// keine Linie unter dem Titel, keine vor dem Footer.
|
|
1362
|
+
if (insideForm) {
|
|
1363
|
+
return (
|
|
1364
|
+
<section data-testid={testId} className="flex flex-col gap-4 px-6 py-6">
|
|
1365
|
+
{title !== undefined && (
|
|
1366
|
+
<h3 className="text-base font-semibold leading-none tracking-tight">{title}</h3>
|
|
1367
|
+
)}
|
|
1368
|
+
{children}
|
|
1369
|
+
</section>
|
|
1370
|
+
);
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
// Standalone: eigene Card (abgesetzter border-b-Header + gap-4-Body).
|
|
1237
1374
|
return (
|
|
1238
|
-
<
|
|
1375
|
+
<Card data-testid={testId} className="gap-0 rounded-lg py-0">
|
|
1239
1376
|
{title !== undefined && (
|
|
1240
|
-
<
|
|
1241
|
-
{
|
|
1242
|
-
|
|
1377
|
+
<CardHeader className="border-b px-6 py-4">
|
|
1378
|
+
{/* h3 statt CardTitle (= div): erhält die Heading-Semantik für
|
|
1379
|
+
Screenreader-Navigation. Klassen = CardTitle-Default. */}
|
|
1380
|
+
<h3 className="text-base font-semibold leading-none tracking-tight">{title}</h3>
|
|
1381
|
+
</CardHeader>
|
|
1243
1382
|
)}
|
|
1244
|
-
<
|
|
1245
|
-
</
|
|
1383
|
+
<CardContent className="flex flex-col gap-4 px-6 py-6">{children}</CardContent>
|
|
1384
|
+
</Card>
|
|
1246
1385
|
);
|
|
1247
1386
|
}
|
|
1248
1387
|
|