@cosmicdrift/kumiko-renderer-web 1.0.0 → 2.0.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 +4 -4
- package/src/__tests__/action-menu.test.tsx +75 -0
- package/src/__tests__/breadcrumb.test.tsx +50 -0
- package/src/__tests__/create-app.test.tsx +171 -1
- package/src/__tests__/dashboard-screen.test.tsx +393 -0
- package/src/__tests__/default-app-shell.test.tsx +47 -1
- package/src/__tests__/form-action-bar.test.tsx +9 -0
- package/src/__tests__/kumiko-screen.test.tsx +155 -1
- package/src/__tests__/lightbox.test.tsx +46 -0
- package/src/__tests__/nav-tree.test.tsx +122 -0
- package/src/__tests__/primitives.test.tsx +32 -1
- package/src/__tests__/profile-menu.test.tsx +29 -0
- package/src/__tests__/projection-detail.test.tsx +101 -0
- package/src/__tests__/projection-list-actions.test.tsx +131 -0
- package/src/__tests__/render-list.test.tsx +11 -3
- package/src/__tests__/test-utils.tsx +1 -0
- package/src/__tests__/toast.test.tsx +9 -9
- package/src/__tests__/use-mutation.test.tsx +86 -0
- package/src/__tests__/workspace-shell.test.tsx +102 -47
- package/src/__tests__/xss-safety.test.tsx +62 -0
- package/src/app/__tests__/first-open-screen-qn.test.ts +77 -0
- package/src/app/browser-locale.ts +1 -1
- package/src/app/create-app.tsx +96 -29
- package/src/app/dashboard-body.tsx +454 -0
- package/src/index.ts +75 -1
- package/src/layout/__tests__/nav-registry.test.ts +105 -0
- package/src/layout/__tests__/shell-breadcrumb.test.ts +82 -0
- package/src/layout/default-app-shell.tsx +9 -71
- package/src/layout/nav-tree.tsx +167 -37
- package/src/layout/shell-breadcrumb.ts +46 -0
- package/src/layout/shell-header.tsx +114 -0
- package/src/layout/workspace-shell.tsx +45 -18
- package/src/primitives/__tests__/data-table-logic.test.ts +27 -0
- package/src/primitives/__tests__/input-testid.test.tsx +45 -0
- package/src/primitives/__tests__/money-input.test.tsx +13 -1
- package/src/primitives/dialog.tsx +47 -76
- package/src/primitives/index.tsx +215 -37
- package/src/primitives/layout.tsx +39 -0
- package/src/primitives/lightbox.tsx +39 -0
- package/src/primitives/modal-shell.tsx +62 -0
- package/src/primitives/money-input.tsx +20 -10
- package/src/primitives/toast.tsx +14 -9
- package/src/sse/live-events.ts +6 -1
- package/src/styles.css +25 -0
- package/src/tokens.ts +4 -0
- package/src/ui/__tests__/avatar.test.tsx +66 -0
- package/src/ui/__tests__/sheet.test.tsx +102 -0
- package/src/version/__tests__/update-checker.test.tsx +115 -0
- package/src/widgets/__tests__/ai-text-field.test.tsx +267 -0
- package/src/widgets/__tests__/drawer.test.tsx +35 -0
- package/src/widgets/__tests__/form-kit.test.tsx +368 -0
- package/src/widgets/__tests__/infinity-list.test.tsx +132 -0
- package/src/widgets/__tests__/query-table.test.tsx +118 -0
- package/src/widgets/__tests__/result-panel.test.tsx +117 -0
- package/src/widgets/__tests__/widgets.test.tsx +243 -0
- package/src/widgets/ai-text-field.tsx +370 -0
- package/src/widgets/charts.tsx +228 -0
- package/src/widgets/collapsible-section.tsx +42 -0
- package/src/widgets/detail-list.tsx +43 -0
- package/src/widgets/drawer.tsx +49 -0
- package/src/widgets/feed-list.tsx +35 -0
- package/src/widgets/form-fields.tsx +326 -0
- package/src/widgets/index.ts +59 -0
- package/src/widgets/infinity-list.tsx +112 -0
- package/src/widgets/mode-switch.tsx +41 -0
- package/src/widgets/progress-bar.tsx +27 -0
- package/src/widgets/progress-list.tsx +38 -0
- package/src/widgets/query-table.tsx +103 -0
- package/src/widgets/result-panel.tsx +251 -0
- package/src/widgets/section-card.tsx +30 -0
- package/src/widgets/stat.tsx +181 -0
- package/src/widgets/states.tsx +89 -0
- package/src/widgets/status-badge.tsx +51 -0
- package/src/widgets/use-draft.ts +40 -0
- package/src/ui/card.tsx +0 -93
- package/src/ui/collapsible.tsx +0 -34
- package/src/ui/dropdown-menu.tsx +0 -258
- package/src/ui/select.tsx +0 -191
- /package/src/__tests__/{download.test.ts → download.test.tsx} +0 -0
package/src/primitives/index.tsx
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// Dropdown etc. kommen später).
|
|
10
10
|
|
|
11
11
|
import type { ListRowViewModel } from "@cosmicdrift/kumiko-headless";
|
|
12
|
-
import { applyFormatSpec } from "@cosmicdrift/kumiko-headless";
|
|
12
|
+
import { applyFormatSpec, isSafeHref } from "@cosmicdrift/kumiko-headless";
|
|
13
13
|
import type {
|
|
14
14
|
DataTableRowAction,
|
|
15
15
|
DataTableRowActionMode,
|
|
@@ -29,12 +29,14 @@ import {
|
|
|
29
29
|
type GridProps,
|
|
30
30
|
type HeadingProps,
|
|
31
31
|
type InputProps,
|
|
32
|
+
type LinkProps,
|
|
32
33
|
type SectionProps,
|
|
33
34
|
type TextProps,
|
|
34
35
|
useColumnRenderer,
|
|
35
36
|
useTranslation,
|
|
36
37
|
WriteFailedError,
|
|
37
38
|
} from "@cosmicdrift/kumiko-renderer";
|
|
39
|
+
import { cva } from "class-variance-authority";
|
|
38
40
|
import {
|
|
39
41
|
ArrowDown,
|
|
40
42
|
ArrowUp,
|
|
@@ -58,8 +60,7 @@ import {
|
|
|
58
60
|
} from "react";
|
|
59
61
|
import { cn } from "../lib/cn";
|
|
60
62
|
import { Badge } from "../ui/badge";
|
|
61
|
-
import { Button as UiButton } from "../ui/button";
|
|
62
|
-
import { Card, CardContent } from "../ui/card";
|
|
63
|
+
import { buttonVariants, Button as UiButton } from "../ui/button";
|
|
63
64
|
import { Checkbox } from "../ui/checkbox";
|
|
64
65
|
import { Input as UiInput } from "../ui/input";
|
|
65
66
|
import { Label as UiLabel } from "../ui/label";
|
|
@@ -76,20 +77,44 @@ import {
|
|
|
76
77
|
DropdownMenuTrigger,
|
|
77
78
|
} from "./dropdown-menu";
|
|
78
79
|
import { FileUploadInput } from "./file-upload";
|
|
80
|
+
import { DefaultLightbox } from "./lightbox";
|
|
79
81
|
import { LocatedTimestampInput } from "./located-timestamp-input";
|
|
80
|
-
import { MoneyInput } from "./money-input";
|
|
82
|
+
import { formatMoney, MoneyInput } from "./money-input";
|
|
81
83
|
import { TimestampInput } from "./timestamp-input";
|
|
82
84
|
import { useToast } from "./toast";
|
|
83
85
|
|
|
86
|
+
// ---- Card-Chrome (eine Definition für Form/Section/Card) ----
|
|
87
|
+
|
|
88
|
+
// Die eine Card-Surface. Maße (Padding/Radius/Shadow) kommen aus --card-*
|
|
89
|
+
// CSS-Tokens (Defaults in styles.css), damit eine App sie einmal zentral in
|
|
90
|
+
// ihrer styles.css überschreiben kann — wie die Farben. Radius-Variante:
|
|
91
|
+
// xl = Card/Screen-Fläche (Default, token-getrieben), lg = "innen"-Fläche.
|
|
92
|
+
const cardSurface = cva(
|
|
93
|
+
"flex flex-col border bg-card text-card-foreground shadow-[var(--card-shadow)]",
|
|
94
|
+
{
|
|
95
|
+
variants: { radius: { xl: "rounded-[var(--card-radius)]", lg: "rounded-lg" } },
|
|
96
|
+
defaultVariants: { radius: "xl" },
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
const cardFooter = "flex items-center justify-end gap-2 px-[var(--card-padding)] py-4";
|
|
100
|
+
const cardFooterBorder = "border-t bg-muted/30";
|
|
101
|
+
|
|
84
102
|
// ---- Button (vendored shadcn ui/button) ----
|
|
85
103
|
|
|
86
104
|
// Contract-Variant → shadcn-Variant: secondary war schon immer der
|
|
87
105
|
// bordered-bg-background-Look = shadcns `outline`. primary→default,
|
|
88
|
-
// danger→destructive.
|
|
106
|
+
// danger→destructive, link→link (kein BG, underline on hover).
|
|
89
107
|
const BUTTON_VARIANT = {
|
|
90
108
|
primary: "default",
|
|
91
109
|
secondary: "outline",
|
|
92
110
|
danger: "destructive",
|
|
111
|
+
link: "link",
|
|
112
|
+
} as const;
|
|
113
|
+
|
|
114
|
+
const BUTTON_SIZE = {
|
|
115
|
+
sm: "sm",
|
|
116
|
+
md: "default",
|
|
117
|
+
icon: "icon",
|
|
93
118
|
} as const;
|
|
94
119
|
|
|
95
120
|
function DefaultButton({
|
|
@@ -98,9 +123,18 @@ function DefaultButton({
|
|
|
98
123
|
disabled,
|
|
99
124
|
loading,
|
|
100
125
|
variant = "primary",
|
|
126
|
+
size = "md",
|
|
127
|
+
ariaLabel,
|
|
128
|
+
width = "auto",
|
|
101
129
|
children,
|
|
102
130
|
testId,
|
|
103
131
|
}: ButtonProps): ReactNode {
|
|
132
|
+
// link-Variant rendert text-artig (Inline-Link im Fließtext/Banner), nicht als
|
|
133
|
+
// gepolsterte Fläche; width="full" streckt CTA-Buttons in Karten/Panels.
|
|
134
|
+
const className =
|
|
135
|
+
[variant === "link" ? "h-auto px-0 py-0" : "", width === "full" ? "w-full" : ""]
|
|
136
|
+
.filter(Boolean)
|
|
137
|
+
.join(" ") || undefined;
|
|
104
138
|
return (
|
|
105
139
|
<UiButton
|
|
106
140
|
type={type}
|
|
@@ -109,6 +143,9 @@ function DefaultButton({
|
|
|
109
143
|
data-testid={testId}
|
|
110
144
|
data-loading={loading === true ? "true" : undefined}
|
|
111
145
|
variant={BUTTON_VARIANT[variant]}
|
|
146
|
+
size={BUTTON_SIZE[size]}
|
|
147
|
+
aria-label={ariaLabel}
|
|
148
|
+
className={className}
|
|
112
149
|
>
|
|
113
150
|
{loading === true ? <Loader2 className="size-4 animate-spin" aria-hidden="true" /> : children}
|
|
114
151
|
</UiButton>
|
|
@@ -230,6 +267,8 @@ function DefaultInput(props: InputProps): ReactNode {
|
|
|
230
267
|
<UiInput
|
|
231
268
|
type="text"
|
|
232
269
|
{...common}
|
|
270
|
+
data-testid={props.testId}
|
|
271
|
+
readOnly={props.readOnly}
|
|
233
272
|
value={props.value}
|
|
234
273
|
onChange={(e: ChangeEvent<HTMLInputElement>) => props.onChange(e.target.value)}
|
|
235
274
|
{...(props.placeholder !== undefined && { placeholder: props.placeholder })}
|
|
@@ -241,6 +280,7 @@ function DefaultInput(props: InputProps): ReactNode {
|
|
|
241
280
|
<UiInput
|
|
242
281
|
type="email"
|
|
243
282
|
{...common}
|
|
283
|
+
data-testid={props.testId}
|
|
244
284
|
value={props.value}
|
|
245
285
|
onChange={(e: ChangeEvent<HTMLInputElement>) => props.onChange(e.target.value)}
|
|
246
286
|
{...(props.placeholder !== undefined && { placeholder: props.placeholder })}
|
|
@@ -252,6 +292,7 @@ function DefaultInput(props: InputProps): ReactNode {
|
|
|
252
292
|
<UiInput
|
|
253
293
|
type="password"
|
|
254
294
|
{...common}
|
|
295
|
+
data-testid={props.testId}
|
|
255
296
|
value={props.value}
|
|
256
297
|
onChange={(e: ChangeEvent<HTMLInputElement>) => props.onChange(e.target.value)}
|
|
257
298
|
autoComplete={props.autoComplete ?? "current-password"}
|
|
@@ -262,6 +303,7 @@ function DefaultInput(props: InputProps): ReactNode {
|
|
|
262
303
|
<UiInput
|
|
263
304
|
type="number"
|
|
264
305
|
{...common}
|
|
306
|
+
data-testid={props.testId}
|
|
265
307
|
value={props.value}
|
|
266
308
|
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
267
309
|
const v = e.target.value;
|
|
@@ -270,6 +312,19 @@ function DefaultInput(props: InputProps): ReactNode {
|
|
|
270
312
|
className="text-right tabular-nums"
|
|
271
313
|
/>
|
|
272
314
|
);
|
|
315
|
+
case "range":
|
|
316
|
+
return (
|
|
317
|
+
<input
|
|
318
|
+
type="range"
|
|
319
|
+
{...common}
|
|
320
|
+
min={props.min}
|
|
321
|
+
max={props.max}
|
|
322
|
+
step={props.step ?? 1}
|
|
323
|
+
value={props.value}
|
|
324
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => props.onChange(Number(e.target.value))}
|
|
325
|
+
className="w-full accent-primary"
|
|
326
|
+
/>
|
|
327
|
+
);
|
|
273
328
|
case "boolean":
|
|
274
329
|
return (
|
|
275
330
|
<Checkbox
|
|
@@ -501,9 +556,11 @@ function DefaultDataTable({
|
|
|
501
556
|
{emptyState ?? <span>No entries.</span>}
|
|
502
557
|
</div>
|
|
503
558
|
) : (
|
|
504
|
-
// dashboard-01-Muster:
|
|
505
|
-
//
|
|
506
|
-
|
|
559
|
+
// dashboard-01-Muster: `rounded-lg border`-Rahmen, die Header-Zeile
|
|
560
|
+
// trägt den bg-muted-Grauton. `bg-card` (statt transparent) → die Liste
|
|
561
|
+
// sitzt auf derselben Card-Fläche wie Forms; auf Themes mit farbigem
|
|
562
|
+
// Page-Background (z.B. Cream) matchen Listen sonst nicht die Cards.
|
|
563
|
+
<div className="overflow-hidden rounded-lg border bg-card">
|
|
507
564
|
<Table data-testid={testId}>
|
|
508
565
|
{tableInner(columns, rows, onRowClick, sort, onSortChange, rowActions, rowActionMode)}
|
|
509
566
|
</Table>
|
|
@@ -754,7 +811,7 @@ function useRowActionTrigger(row: ListRowViewModel) {
|
|
|
754
811
|
toast({
|
|
755
812
|
title: t("kumiko.rowAction.failed"),
|
|
756
813
|
description: e instanceof Error ? e.message : String(e),
|
|
757
|
-
variant: "
|
|
814
|
+
variant: "bad",
|
|
758
815
|
...(docsUrl !== undefined && { docsUrl }),
|
|
759
816
|
});
|
|
760
817
|
} finally {
|
|
@@ -1199,6 +1256,16 @@ export function isComponentRendererRef(renderer: unknown): { readonly name: stri
|
|
|
1199
1256
|
// applyFormatSpec re-exported from headless (platform-agnostic).
|
|
1200
1257
|
export { applyFormatSpec };
|
|
1201
1258
|
|
|
1259
|
+
function isMoneyValue(value: unknown): value is { amount: number; currency: string } {
|
|
1260
|
+
return (
|
|
1261
|
+
typeof value === "object" &&
|
|
1262
|
+
value !== null &&
|
|
1263
|
+
typeof (value as Record<string, unknown>)["amount"] === "number" &&
|
|
1264
|
+
typeof (value as Record<string, unknown>)["currency"] === "string" &&
|
|
1265
|
+
/^[A-Za-z]{3}$/.test((value as Record<string, unknown>)["currency"] as string)
|
|
1266
|
+
);
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1202
1269
|
// Type-spezifische Default-Cell-Renderer. Author kann pro Spalte einen
|
|
1203
1270
|
// expliziten renderer setzen (FormatSpec oder PlatformComponent); ohne
|
|
1204
1271
|
// expliziten renderer fällt DataTableCell hier durch.
|
|
@@ -1206,6 +1273,7 @@ export { applyFormatSpec };
|
|
|
1206
1273
|
// - boolean → ✓ / leer
|
|
1207
1274
|
// - timestamp/date → locale-formatiert (kein roher ISO-String)
|
|
1208
1275
|
// - select → human-lesbar (kebab-case → Title Case)
|
|
1276
|
+
// - money → { amount, currency } formatted via Intl (not "[object Object]")
|
|
1209
1277
|
// - text/number/sonst → toString
|
|
1210
1278
|
export function defaultCellRender(
|
|
1211
1279
|
value: unknown,
|
|
@@ -1215,6 +1283,16 @@ export function defaultCellRender(
|
|
|
1215
1283
|
if (value === null || value === undefined || value === "") return "";
|
|
1216
1284
|
if (type === "boolean") return value === true ? "✓" : "";
|
|
1217
1285
|
if (type === "timestamp" || type === "date") return applyFormatSpec({ format: type }, value);
|
|
1286
|
+
if (type === "money") {
|
|
1287
|
+
if (!isMoneyValue(value)) return String(value);
|
|
1288
|
+
// No `locale` param here: DataTableCell has no app-locale context to
|
|
1289
|
+
// thread through (unlike the form path — see MoneyInput/RenderField in
|
|
1290
|
+
// packages/renderer, which pins the LocaleProvider locale). formatMoney
|
|
1291
|
+
// falls back to guessLocale() (navigator.language), so the same amount
|
|
1292
|
+
// can render differently in a table vs. a form on the same screen.
|
|
1293
|
+
// Mid-term: pass the app locale down here too, analogous to render-field.
|
|
1294
|
+
return formatMoney(value.amount, value.currency);
|
|
1295
|
+
}
|
|
1218
1296
|
if (type === "select") {
|
|
1219
1297
|
const raw = String(value);
|
|
1220
1298
|
// Translated Label aus dem ViewModel-Builder (Convention-Key
|
|
@@ -1354,7 +1432,9 @@ function DefaultForm({
|
|
|
1354
1432
|
// OHNE Trennlinie darunter (Titel fließt in die erste Section). Sections
|
|
1355
1433
|
// sind divide-y-getrennt (Linien nur ZWISCHEN ihnen). Action-Footer mit
|
|
1356
1434
|
// bg-muted/30 als Farb-Trenner statt harter Linie. main hat kein Padding;
|
|
1357
|
-
// der max-w-3xl-Body rahmt die Card
|
|
1435
|
+
// der max-w-3xl-Body rahmt die Card, mx-auto zentriert ihn im Main statt
|
|
1436
|
+
// linksbündig (sonst „eingesperrt" mit leerer rechter Hälfte auf breiten
|
|
1437
|
+
// Screens).
|
|
1358
1438
|
return (
|
|
1359
1439
|
<form
|
|
1360
1440
|
onSubmit={(e) => {
|
|
@@ -1364,8 +1444,8 @@ function DefaultForm({
|
|
|
1364
1444
|
data-testid={testId}
|
|
1365
1445
|
className="flex flex-col w-full"
|
|
1366
1446
|
>
|
|
1367
|
-
<
|
|
1368
|
-
<div className="
|
|
1447
|
+
<FormScreenShell>
|
|
1448
|
+
<div className={cn(cardSurface(), "overflow-hidden")}>
|
|
1369
1449
|
{(title !== undefined || subtitle !== undefined) && (
|
|
1370
1450
|
<div className="px-6 pb-2 pt-5">
|
|
1371
1451
|
{title !== undefined && (
|
|
@@ -1402,18 +1482,62 @@ function DefaultForm({
|
|
|
1402
1482
|
{actions !== undefined && (
|
|
1403
1483
|
<div
|
|
1404
1484
|
data-testid={testId !== undefined ? `${testId}-actions` : undefined}
|
|
1405
|
-
className=
|
|
1485
|
+
className={cn(cardFooter, cardFooterBorder)}
|
|
1406
1486
|
>
|
|
1407
1487
|
{actions}
|
|
1408
1488
|
</div>
|
|
1409
1489
|
)}
|
|
1410
1490
|
</div>
|
|
1411
|
-
</
|
|
1491
|
+
</FormScreenShell>
|
|
1412
1492
|
</form>
|
|
1413
1493
|
);
|
|
1414
1494
|
}
|
|
1415
1495
|
|
|
1416
|
-
|
|
1496
|
+
// Kanonische Form/Settings-Shell: zentrierte Spalte mit Standard-Screen-
|
|
1497
|
+
// Padding. DefaultForm (configEdit/entityEdit) UND custom Settings-Screens
|
|
1498
|
+
// (url-settings, privacy-center) teilen sie → einheitliche Breite +
|
|
1499
|
+
// Zentrierung statt per-Screen-Wildwuchs. Breite über `maxWidth`-Intent statt
|
|
1500
|
+
// beliebiger max-w-*-Overrides: sm=schmale Auth-Forms, 3xl=Standard-Detail,
|
|
1501
|
+
// 4xl=tabellen-nahe Forms, full=volle Breite. Inhalt nutzt Card-Primitives;
|
|
1502
|
+
// `className` (z.B. "flex flex-col gap-6") für Multi-Card-Stacks.
|
|
1503
|
+
export type FormScreenShellWidth = "sm" | "3xl" | "4xl" | "full";
|
|
1504
|
+
|
|
1505
|
+
const formScreenShellWidth: Record<FormScreenShellWidth, string> = {
|
|
1506
|
+
sm: "max-w-sm mx-auto",
|
|
1507
|
+
"3xl": "max-w-3xl mx-auto",
|
|
1508
|
+
"4xl": "max-w-4xl mx-auto",
|
|
1509
|
+
full: "max-w-full",
|
|
1510
|
+
};
|
|
1511
|
+
|
|
1512
|
+
export function FormScreenShell({
|
|
1513
|
+
children,
|
|
1514
|
+
className,
|
|
1515
|
+
testId,
|
|
1516
|
+
maxWidth = "3xl",
|
|
1517
|
+
}: {
|
|
1518
|
+
readonly children: ReactNode;
|
|
1519
|
+
readonly className?: string;
|
|
1520
|
+
readonly testId?: string;
|
|
1521
|
+
readonly maxWidth?: FormScreenShellWidth;
|
|
1522
|
+
}): ReactNode {
|
|
1523
|
+
return (
|
|
1524
|
+
<div
|
|
1525
|
+
data-testid={testId}
|
|
1526
|
+
className={cn("px-6 pt-6 pb-12 w-full", formScreenShellWidth[maxWidth], className)}
|
|
1527
|
+
>
|
|
1528
|
+
{children}
|
|
1529
|
+
</div>
|
|
1530
|
+
);
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
function DefaultSection({
|
|
1534
|
+
title,
|
|
1535
|
+
subtitle,
|
|
1536
|
+
children,
|
|
1537
|
+
actions,
|
|
1538
|
+
variant = "default",
|
|
1539
|
+
testId,
|
|
1540
|
+
}: SectionProps): ReactNode {
|
|
1417
1541
|
const insideForm = useContext(InsideFormContext);
|
|
1418
1542
|
|
|
1419
1543
|
// h3 statt CardTitle (= div): erhält die Heading-Semantik für
|
|
@@ -1446,7 +1570,13 @@ function DefaultSection({ title, subtitle, children, actions, testId }: SectionP
|
|
|
1446
1570
|
// actions hier = rechtsbündige Reihe (das Form trägt den eigenen Footer).
|
|
1447
1571
|
if (insideForm) {
|
|
1448
1572
|
return (
|
|
1449
|
-
<section
|
|
1573
|
+
<section
|
|
1574
|
+
data-testid={testId}
|
|
1575
|
+
className={cn(
|
|
1576
|
+
"flex flex-col gap-4 px-6 py-6",
|
|
1577
|
+
variant === "destructive" && "border-l-2 border-destructive/40",
|
|
1578
|
+
)}
|
|
1579
|
+
>
|
|
1450
1580
|
{header}
|
|
1451
1581
|
{children}
|
|
1452
1582
|
{actions !== undefined && (
|
|
@@ -1469,20 +1599,27 @@ function DefaultSection({ title, subtitle, children, actions, testId }: SectionP
|
|
|
1469
1599
|
// `children`) WOULD get silently clipped — verify this against any new
|
|
1470
1600
|
// standalone-section content that renders its own non-portaled overlay.
|
|
1471
1601
|
return (
|
|
1472
|
-
<
|
|
1473
|
-
|
|
1602
|
+
<div
|
|
1603
|
+
data-testid={testId}
|
|
1604
|
+
className={cn(
|
|
1605
|
+
cardSurface(),
|
|
1606
|
+
"overflow-hidden",
|
|
1607
|
+
variant === "destructive" && "border-destructive/40",
|
|
1608
|
+
)}
|
|
1609
|
+
>
|
|
1610
|
+
<div className="flex flex-col gap-4 px-6 py-6">
|
|
1474
1611
|
{header}
|
|
1475
1612
|
{children}
|
|
1476
|
-
</
|
|
1613
|
+
</div>
|
|
1477
1614
|
{actions !== undefined && (
|
|
1478
1615
|
<div
|
|
1479
1616
|
data-testid={testId !== undefined ? `${testId}-actions` : undefined}
|
|
1480
|
-
className=
|
|
1617
|
+
className={cn(cardFooter, cardFooterBorder)}
|
|
1481
1618
|
>
|
|
1482
1619
|
{actions}
|
|
1483
1620
|
</div>
|
|
1484
1621
|
)}
|
|
1485
|
-
</
|
|
1622
|
+
</div>
|
|
1486
1623
|
);
|
|
1487
1624
|
}
|
|
1488
1625
|
|
|
@@ -1532,11 +1669,54 @@ function DefaultText({ variant = "body", children, testId }: TextProps): ReactNo
|
|
|
1532
1669
|
{children}
|
|
1533
1670
|
</span>
|
|
1534
1671
|
);
|
|
1672
|
+
case "muted":
|
|
1673
|
+
return (
|
|
1674
|
+
<span data-testid={testId} className="text-sm text-muted-foreground">
|
|
1675
|
+
{children}
|
|
1676
|
+
</span>
|
|
1677
|
+
);
|
|
1535
1678
|
default:
|
|
1536
1679
|
return <span data-testid={testId}>{children}</span>;
|
|
1537
1680
|
}
|
|
1538
1681
|
}
|
|
1539
1682
|
|
|
1683
|
+
// ---- Link (anchor mit Button-/Muted-Optik) ----
|
|
1684
|
+
|
|
1685
|
+
// isSafeHref lives in @cosmicdrift/kumiko-headless — shared with
|
|
1686
|
+
// page-render's server-side markdown renderer, both take untrusted
|
|
1687
|
+
// tenant-authored hrefs.
|
|
1688
|
+
|
|
1689
|
+
// `button` nutzt die Primary-Buttonfläche auf einem semantischen <a> —
|
|
1690
|
+
// der Standard für „weiter zu"-Navigationen nach Success-States (ehem.
|
|
1691
|
+
// authButtonClass), `muted` der dezente Sekundär-Link (ehem.
|
|
1692
|
+
// authMutedLinkClass).
|
|
1693
|
+
function DefaultLink({
|
|
1694
|
+
href,
|
|
1695
|
+
variant = "default",
|
|
1696
|
+
target,
|
|
1697
|
+
className,
|
|
1698
|
+
children,
|
|
1699
|
+
testId,
|
|
1700
|
+
}: LinkProps): ReactNode {
|
|
1701
|
+
const variantClass =
|
|
1702
|
+
variant === "button"
|
|
1703
|
+
? buttonVariants({ variant: "default" })
|
|
1704
|
+
: variant === "muted"
|
|
1705
|
+
? "text-sm text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
|
|
1706
|
+
: "text-primary underline-offset-4 hover:underline";
|
|
1707
|
+
return (
|
|
1708
|
+
<a
|
|
1709
|
+
href={isSafeHref(href) ? href : "#"}
|
|
1710
|
+
target={target}
|
|
1711
|
+
rel={target === "_blank" ? "noreferrer" : undefined}
|
|
1712
|
+
data-testid={testId}
|
|
1713
|
+
className={cn(variantClass, className)}
|
|
1714
|
+
>
|
|
1715
|
+
{children}
|
|
1716
|
+
</a>
|
|
1717
|
+
);
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1540
1720
|
function DefaultHeading({ variant = "page", children, testId }: HeadingProps): ReactNode {
|
|
1541
1721
|
// Page-Heading = h1, sehr selten in einer App (max 1 pro Screen).
|
|
1542
1722
|
// Section-Heading = h2 mit uppercase + muted-foreground — derselbe
|
|
@@ -1564,14 +1744,14 @@ import { ConfigSourceBadge as DefaultConfigSourceBadge } from "../components/con
|
|
|
1564
1744
|
|
|
1565
1745
|
// Generische Card-Chrome (rounded-xl wie die Entity-Card) — slot- + options-
|
|
1566
1746
|
// basiert, damit der Contract additiv wächst und Consumer nie migriert werden.
|
|
1567
|
-
function DefaultCard({ slots, options, className, testId, children }: CardProps): ReactNode {
|
|
1747
|
+
export function DefaultCard({ slots, options, className, testId, children }: CardProps): ReactNode {
|
|
1568
1748
|
const padded = options?.padded ?? true;
|
|
1569
1749
|
const radius = options?.radius ?? "xl";
|
|
1570
1750
|
const footerBordered = options?.footerBordered ?? true;
|
|
1571
1751
|
const s = slots ?? {};
|
|
1572
1752
|
const defaultHeader =
|
|
1573
1753
|
s.title !== undefined || s.subtitle !== undefined || s.headerActions !== undefined ? (
|
|
1574
|
-
<div className="flex flex-wrap items-start justify-between gap-3 px-
|
|
1754
|
+
<div className="flex flex-wrap items-start justify-between gap-3 px-[var(--card-padding)] pt-6 pb-4">
|
|
1575
1755
|
<div className="flex flex-col gap-1">
|
|
1576
1756
|
{s.title !== undefined && (
|
|
1577
1757
|
<h3 className="text-base font-semibold leading-none tracking-tight">{s.title}</h3>
|
|
@@ -1586,30 +1766,26 @@ function DefaultCard({ slots, options, className, testId, children }: CardProps)
|
|
|
1586
1766
|
const header = s.header ?? defaultHeader;
|
|
1587
1767
|
const hasHeader = header !== null && header !== undefined;
|
|
1588
1768
|
return (
|
|
1589
|
-
<div
|
|
1590
|
-
data-testid={testId}
|
|
1591
|
-
className={cn(
|
|
1592
|
-
"flex flex-col overflow-hidden border bg-card text-card-foreground shadow-sm",
|
|
1593
|
-
radius === "xl" ? "rounded-xl" : "rounded-lg",
|
|
1594
|
-
className,
|
|
1595
|
-
)}
|
|
1596
|
-
>
|
|
1769
|
+
<div data-testid={testId} className={cn(cardSurface({ radius }), "overflow-hidden", className)}>
|
|
1597
1770
|
{header}
|
|
1598
1771
|
{/* != null covers undefined AND explicit null; a `false` child (from
|
|
1599
1772
|
`cond && <El/>`) still renders no visible content either way. */}
|
|
1600
1773
|
{children != null && (
|
|
1601
|
-
<div className={cn("grow", padded && (hasHeader ? "px-6 pb-6" : "p-6"))}>{children}</div>
|
|
1602
|
-
)}
|
|
1603
|
-
{s.footer !== undefined && (
|
|
1604
1774
|
<div
|
|
1605
1775
|
className={cn(
|
|
1606
|
-
"
|
|
1607
|
-
|
|
1776
|
+
"grow",
|
|
1777
|
+
padded &&
|
|
1778
|
+
(hasHeader
|
|
1779
|
+
? "px-[var(--card-padding)] pb-[var(--card-padding)]"
|
|
1780
|
+
: "p-[var(--card-padding)]"),
|
|
1608
1781
|
)}
|
|
1609
1782
|
>
|
|
1610
|
-
{
|
|
1783
|
+
{children}
|
|
1611
1784
|
</div>
|
|
1612
1785
|
)}
|
|
1786
|
+
{s.footer !== undefined && (
|
|
1787
|
+
<div className={cn(cardFooter, footerBordered && cardFooterBorder)}>{s.footer}</div>
|
|
1788
|
+
)}
|
|
1613
1789
|
</div>
|
|
1614
1790
|
);
|
|
1615
1791
|
}
|
|
@@ -1628,6 +1804,8 @@ export const defaultPrimitives: CorePrimitives = {
|
|
|
1628
1804
|
Text: DefaultText,
|
|
1629
1805
|
Heading: DefaultHeading,
|
|
1630
1806
|
Dialog: DefaultDialog,
|
|
1807
|
+
Lightbox: DefaultLightbox,
|
|
1631
1808
|
ConfigSourceBadge: DefaultConfigSourceBadge,
|
|
1632
1809
|
ConfigCascadeView: DefaultConfigCascadeView,
|
|
1810
|
+
Link: DefaultLink,
|
|
1633
1811
|
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Layout-Primitives für Custom-Screens: ein Ort für vertikale Abstände und
|
|
2
|
+
// Screen-Padding, damit Consumer nicht `flex flex-col gap-*` / `p-6` per Hand
|
|
3
|
+
// streuen. Bewusst dünn — kein generisches Box-mit-20-props-System.
|
|
4
|
+
|
|
5
|
+
import type { ReactNode } from "react";
|
|
6
|
+
import { cn } from "../lib/cn";
|
|
7
|
+
|
|
8
|
+
const STACK_GAP = { sm: "gap-2", md: "gap-4", lg: "gap-6" } as const;
|
|
9
|
+
|
|
10
|
+
type StackGap = keyof typeof STACK_GAP;
|
|
11
|
+
|
|
12
|
+
type StackProps = {
|
|
13
|
+
readonly gap?: StackGap;
|
|
14
|
+
readonly className?: string;
|
|
15
|
+
readonly children?: ReactNode;
|
|
16
|
+
readonly testId?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function Stack({ gap = "md", className, children, testId }: StackProps): ReactNode {
|
|
20
|
+
return (
|
|
21
|
+
<div data-testid={testId} className={cn("flex flex-col", STACK_GAP[gap], className)}>
|
|
22
|
+
{children}
|
|
23
|
+
</div>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type PageSectionProps = {
|
|
28
|
+
readonly className?: string;
|
|
29
|
+
readonly children?: ReactNode;
|
|
30
|
+
readonly testId?: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function PageSection({ className, children, testId }: PageSectionProps): ReactNode {
|
|
34
|
+
return (
|
|
35
|
+
<div data-testid={testId} className={cn("p-6", className)}>
|
|
36
|
+
{children}
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Full-size image overlay — ModalShell without confirm/cancel actions.
|
|
2
|
+
|
|
3
|
+
import type { LightboxProps } from "@cosmicdrift/kumiko-renderer";
|
|
4
|
+
import { useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
5
|
+
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
|
6
|
+
import type { ReactNode } from "react";
|
|
7
|
+
import { cn } from "../lib/cn";
|
|
8
|
+
import { ModalShell } from "./modal-shell";
|
|
9
|
+
|
|
10
|
+
export function DefaultLightbox({
|
|
11
|
+
open,
|
|
12
|
+
onOpenChange,
|
|
13
|
+
src,
|
|
14
|
+
alt,
|
|
15
|
+
testId,
|
|
16
|
+
}: LightboxProps): ReactNode {
|
|
17
|
+
const t = useTranslation();
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<ModalShell
|
|
21
|
+
open={open}
|
|
22
|
+
onOpenChange={onOpenChange}
|
|
23
|
+
testId={testId}
|
|
24
|
+
closeLabel={t("kumiko.dialog.close")}
|
|
25
|
+
noAriaDescription
|
|
26
|
+
contentClassName={cn(
|
|
27
|
+
"border-0 bg-transparent p-0 shadow-none",
|
|
28
|
+
"max-w-[95vw] max-h-[90vh] w-auto",
|
|
29
|
+
)}
|
|
30
|
+
>
|
|
31
|
+
<DialogPrimitive.Title className="sr-only">{alt}</DialogPrimitive.Title>
|
|
32
|
+
<img
|
|
33
|
+
src={src}
|
|
34
|
+
alt={alt}
|
|
35
|
+
className="block max-h-[85vh] max-w-[90vw] w-auto rounded-lg border border-border bg-card shadow-lg"
|
|
36
|
+
/>
|
|
37
|
+
</ModalShell>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Shared Radix dialog chrome for confirm Dialog + image Lightbox.
|
|
2
|
+
// Not exported from the package — only an internal building block.
|
|
3
|
+
|
|
4
|
+
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
|
5
|
+
import { X } from "lucide-react";
|
|
6
|
+
import type { ReactNode } from "react";
|
|
7
|
+
import { cn } from "../lib/cn";
|
|
8
|
+
|
|
9
|
+
export type ModalShellProps = {
|
|
10
|
+
readonly open: boolean;
|
|
11
|
+
readonly onOpenChange: (open: boolean) => void;
|
|
12
|
+
readonly children: ReactNode;
|
|
13
|
+
readonly testId?: string;
|
|
14
|
+
readonly contentClassName?: string;
|
|
15
|
+
/** Radix wants aria-describedby={undefined} when there is no Description. */
|
|
16
|
+
readonly noAriaDescription?: boolean;
|
|
17
|
+
readonly closeLabel?: string;
|
|
18
|
+
readonly showCloseButton?: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function ModalShell({
|
|
22
|
+
open,
|
|
23
|
+
onOpenChange,
|
|
24
|
+
children,
|
|
25
|
+
testId,
|
|
26
|
+
contentClassName,
|
|
27
|
+
noAriaDescription,
|
|
28
|
+
closeLabel,
|
|
29
|
+
showCloseButton = true,
|
|
30
|
+
}: ModalShellProps): ReactNode {
|
|
31
|
+
return (
|
|
32
|
+
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
|
|
33
|
+
<DialogPrimitive.Portal>
|
|
34
|
+
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
|
|
35
|
+
<DialogPrimitive.Content
|
|
36
|
+
data-testid={testId}
|
|
37
|
+
{...(noAriaDescription && { "aria-describedby": undefined })}
|
|
38
|
+
className={cn(
|
|
39
|
+
"fixed left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%]",
|
|
40
|
+
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
|
41
|
+
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
|
42
|
+
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
|
43
|
+
contentClassName,
|
|
44
|
+
)}
|
|
45
|
+
>
|
|
46
|
+
{children}
|
|
47
|
+
{showCloseButton && closeLabel !== undefined && (
|
|
48
|
+
<DialogPrimitive.Close asChild>
|
|
49
|
+
<button
|
|
50
|
+
type="button"
|
|
51
|
+
aria-label={closeLabel}
|
|
52
|
+
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
|
|
53
|
+
>
|
|
54
|
+
<X className="size-4" />
|
|
55
|
+
</button>
|
|
56
|
+
</DialogPrimitive.Close>
|
|
57
|
+
)}
|
|
58
|
+
</DialogPrimitive.Content>
|
|
59
|
+
</DialogPrimitive.Portal>
|
|
60
|
+
</DialogPrimitive.Root>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -64,16 +64,7 @@ export function MoneyInput({
|
|
|
64
64
|
const [draft, setDraft] = useState<string>("");
|
|
65
65
|
|
|
66
66
|
const major = value === "" ? null : value / factor;
|
|
67
|
-
|
|
68
|
-
const formatted =
|
|
69
|
-
major === null
|
|
70
|
-
? ""
|
|
71
|
-
: new Intl.NumberFormat(resolvedLocale, {
|
|
72
|
-
style: "currency",
|
|
73
|
-
currency,
|
|
74
|
-
minimumFractionDigits: decimals,
|
|
75
|
-
maximumFractionDigits: decimals,
|
|
76
|
-
}).format(major);
|
|
67
|
+
const formatted = value === "" ? "" : formatMoney(value, currency, resolvedLocale);
|
|
77
68
|
|
|
78
69
|
// Edit-Mode: Decimal-String ohne Tausender-Trenner. Konsistente Helper-
|
|
79
70
|
// Funktion damit Focus-Init und Render-Fallback nicht auseinanderdriften.
|
|
@@ -170,6 +161,25 @@ function guessLocale(): string {
|
|
|
170
161
|
return "en-US";
|
|
171
162
|
}
|
|
172
163
|
|
|
164
|
+
// Shared with defaultCellRender (index.tsx) so both formatting paths stay
|
|
165
|
+
// identical instead of drifting. currency is validated against the
|
|
166
|
+
// 3-letter-alpha shape Intl.NumberFormat requires — a non-conforming
|
|
167
|
+
// value (e.g. from a JSONB custom field that never ran through
|
|
168
|
+
// rehydrateMoney) throws a RangeError inside Intl.NumberFormat, and with
|
|
169
|
+
// no ErrorBoundary in this render tree that would take down the whole
|
|
170
|
+
// page instead of just this cell.
|
|
171
|
+
export function formatMoney(amountMinor: number, currency: string, locale?: string): string {
|
|
172
|
+
if (!/^[A-Za-z]{3}$/.test(currency)) return String(amountMinor);
|
|
173
|
+
const decimals = currencyDecimals(currency);
|
|
174
|
+
const resolvedLocale = locale ?? guessLocale();
|
|
175
|
+
return new Intl.NumberFormat(resolvedLocale, {
|
|
176
|
+
style: "currency",
|
|
177
|
+
currency,
|
|
178
|
+
minimumFractionDigits: decimals,
|
|
179
|
+
maximumFractionDigits: decimals,
|
|
180
|
+
}).format(amountMinor / 10 ** decimals);
|
|
181
|
+
}
|
|
182
|
+
|
|
173
183
|
// Locale-Decimal-Parse: erkennt automatisch ob Komma oder Punkt der
|
|
174
184
|
// Decimal-Separator ist. Intl.NumberFormat liefert die Trenner für
|
|
175
185
|
// das Locale, daraus bauen wir den Reverse-Parser. Strict beim
|