@cosmicdrift/kumiko-renderer 0.383.1 → 0.385.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.
Files changed (103) hide show
  1. package/dist/app/action-form-body.d.ts +50 -0
  2. package/dist/app/action-form-body.js +275 -0
  3. package/dist/app/client-actions.js +2 -2
  4. package/dist/app/config-edit-body.d.ts +9 -0
  5. package/dist/app/config-edit-body.js +340 -0
  6. package/dist/app/entity-edit-prefill.d.ts +27 -0
  7. package/dist/app/entity-edit-prefill.js +377 -0
  8. package/dist/app/entity-edit-screen.d.ts +11 -0
  9. package/dist/app/entity-edit-screen.js +395 -0
  10. package/dist/app/entity-list-screen.d.ts +10 -0
  11. package/dist/app/entity-list-screen.js +547 -0
  12. package/dist/app/kumiko-screen.d.ts +1 -36
  13. package/dist/app/kumiko-screen.js +16 -2855
  14. package/dist/app/projection-detail-body.d.ts +12 -0
  15. package/dist/app/projection-detail-body.js +464 -0
  16. package/dist/app/projection-detail-shim.d.ts +1 -1
  17. package/dist/app/projection-detail-shim.js +5 -1
  18. package/dist/app/projection-list-body.d.ts +10 -0
  19. package/dist/app/projection-list-body.js +245 -0
  20. package/dist/app/projection-list-shim.d.ts +2 -1
  21. package/dist/app/projection-list-shim.js +4 -0
  22. package/dist/app/reference-facet-bridge.js +2 -2
  23. package/dist/app/row-actions.js +3 -4
  24. package/dist/app/screen-shared.d.ts +72 -0
  25. package/dist/app/screen-shared.js +325 -0
  26. package/dist/app/secret-mint-body.js +3 -3
  27. package/dist/components/edit-mounts.d.ts +33 -0
  28. package/dist/components/edit-mounts.js +53 -0
  29. package/dist/components/error-boundary.js +2 -2
  30. package/dist/components/reference-create-dialog.js +1 -1
  31. package/dist/components/related-list-section.js +2 -2
  32. package/dist/components/render-edit-helpers.d.ts +29 -0
  33. package/dist/components/render-edit-helpers.js +121 -0
  34. package/dist/components/render-edit.d.ts +1 -4
  35. package/dist/components/render-edit.js +41 -443
  36. package/dist/components/render-field.js +3 -4
  37. package/dist/components/render-list.js +2 -2
  38. package/dist/components/use-wizard-form-draft.d.ts +51 -0
  39. package/dist/components/use-wizard-form-draft.js +291 -0
  40. package/dist/dev-console.d.ts +3 -0
  41. package/dist/dev-console.js +15 -0
  42. package/dist/headless/contracts/primitives.js +14 -17
  43. package/dist/headless/format/index.js +25 -6
  44. package/dist/headless/view-model/edit.d.ts +1 -1
  45. package/dist/headless/view-model/edit.js +16 -17
  46. package/dist/headless/view-model/list.d.ts +1 -1
  47. package/dist/headless/view-model/list.js +3 -8
  48. package/dist/index.d.ts +2 -1
  49. package/dist/index.js +5 -5
  50. package/dist/live/error-mapping.js +3 -4
  51. package/dist/web/app/create-app.js +5 -8
  52. package/dist/web/app/dashboard-body.js +4 -599
  53. package/dist/web/app/dashboard-panels.d.ts +53 -0
  54. package/dist/web/app/dashboard-panels.js +373 -0
  55. package/dist/web/app/dashboard-shared.d.ts +90 -0
  56. package/dist/web/app/dashboard-shared.js +171 -0
  57. package/dist/web/app/dashboard-text.d.ts +6 -0
  58. package/dist/web/app/dashboard-text.js +65 -0
  59. package/dist/web/layout/nav-tree.js +3 -4
  60. package/dist/web/layout/target-resolver-stub.js +2 -2
  61. package/dist/web/primitives/banner.d.ts +4 -0
  62. package/dist/web/primitives/banner.js +28 -0
  63. package/dist/web/primitives/button.d.ts +7 -0
  64. package/dist/web/primitives/button.js +48 -0
  65. package/dist/web/primitives/calendar-day-picker.d.ts +13 -0
  66. package/dist/web/primitives/calendar-day-picker.js +38 -0
  67. package/dist/web/primitives/calendar-popover.d.ts +1 -1
  68. package/dist/web/primitives/calendar-popover.js +5 -39
  69. package/dist/web/primitives/card-styles.d.ts +8 -0
  70. package/dist/web/primitives/card-styles.js +18 -0
  71. package/dist/web/primitives/card.d.ts +4 -0
  72. package/dist/web/primitives/card.js +41 -0
  73. package/dist/web/primitives/contexts.d.ts +22 -0
  74. package/dist/web/primitives/contexts.js +50 -0
  75. package/dist/web/primitives/data-table-cell.d.ts +76 -0
  76. package/dist/web/primitives/data-table-cell.js +403 -0
  77. package/dist/web/primitives/data-table.d.ts +4 -0
  78. package/dist/web/primitives/data-table.js +279 -0
  79. package/dist/web/primitives/default-form.d.ts +4 -0
  80. package/dist/web/primitives/default-form.js +79 -0
  81. package/dist/web/primitives/facet-filter.d.ts +14 -0
  82. package/dist/web/primitives/facet-filter.js +39 -0
  83. package/dist/web/primitives/field.d.ts +63 -0
  84. package/dist/web/primitives/field.js +181 -0
  85. package/dist/web/primitives/file-thumbnail.js +69 -7
  86. package/dist/web/primitives/form.d.ts +53 -0
  87. package/dist/web/primitives/form.js +252 -0
  88. package/dist/web/primitives/index.d.ts +7 -30
  89. package/dist/web/primitives/index.js +27 -2005
  90. package/dist/web/primitives/input.d.ts +4 -0
  91. package/dist/web/primitives/input.js +166 -0
  92. package/dist/web/primitives/layout-elements.d.ts +14 -0
  93. package/dist/web/primitives/layout-elements.js +194 -0
  94. package/dist/web/primitives/modal.js +1 -1
  95. package/dist/web/primitives/pager.d.ts +22 -0
  96. package/dist/web/primitives/pager.js +89 -0
  97. package/dist/web/primitives/row-actions.d.ts +13 -0
  98. package/dist/web/primitives/row-actions.js +178 -0
  99. package/dist/web/primitives/secret-reveal.d.ts +4 -0
  100. package/dist/web/primitives/secret-reveal.js +67 -0
  101. package/dist/web/tokens.js +2 -1
  102. package/package.json +5 -5
  103. package/src/changes.json +24 -0
@@ -1,2023 +1,45 @@
1
- import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { cardColumnRoles } from "@cosmicdrift/kumiko-framework/ui-types";
3
- import { PII_ERASED_SENTINEL } from "@cosmicdrift/kumiko-types/kms-adapter-types";
4
- import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
5
- import { cva } from "class-variance-authority";
6
- import { ArrowDown, ArrowUp, ArrowUpDown, Building, CalendarDays, Check, ChevronDown, ChevronLeft, ChevronRight, Globe, Hash, KeyRound, Link, Loader2, Lock, Mail, MapPin, MoreHorizontal, Phone, Search, SlidersHorizontal, Tag, Undo2, User, X, } from "lucide-react";
7
- // The main entry pulls Node-only deps (yargs/pngjs) under bundlers that ignore
8
- // package.json#browser (Metro), hence the explicit browser subpath.
9
- import QRCode from "qrcode/lib/browser.js";
10
- import { Children, createContext, Fragment, isValidElement, useContext, useEffect, useId, useRef, useState, } from "react";
11
- import { fieldIssueText } from "../../field-issue-text.js";
12
- import { applyFormatSpec, EMPTY_VALUE_PLACEHOLDER, isSafeHref } from "../../headless/index.js";
13
- import { InsideDrawerProvider, needsActionConfirm, STICKY_PRIMARY_ACTION_PROP, shouldRenderActionsIconOnly, statusToneForOptionTone, statusToneForValue, useColumnRenderer, useInsideDrawer, useOptionalLocale, useOptionalPrimitives, useOptionalTranslation, useTranslation, WriteFailedError, } from "../../index.js";
14
- import { Icon, NAV_ICONS } from "../icons.js";
15
- import { cn } from "../lib/cn.js";
16
- import { Badge } from "../ui/badge.js";
17
- import { buttonVariants, Button as UiButton } from "../ui/button.js";
18
- import { Checkbox } from "../ui/checkbox.js";
19
- import { Input as UiInput } from "../ui/input.js";
20
- import { Label as UiLabel } from "../ui/label.js";
21
- import { Switch } from "../ui/switch.js";
22
- import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table.js";
23
- import { Textarea } from "../ui/textarea.js";
24
- import { ProgressBar } from "../widgets/progress-bar.js";
25
- import { StatusBadge } from "../widgets/status-badge.js";
26
- import { StepBar } from "../widgets/step-bar.js";
27
- import { inFieldIconButtonClass } from "./calendar-popover.js";
28
- import { ComboboxInput } from "./combobox.js";
29
- import { DateInput } from "./date-input.js";
1
+ /// <reference path="../qrcode-browser.d.ts" />
2
+ // Default shadcn + Tailwind primitives for the web renderer. They implement the
3
+ // primitives contract from `@cosmicdrift/kumiko-renderer`; colors come from
4
+ // Tailwind classes that reference the shadcn CSS variables (no useTokens() calls).
5
+ //
6
+ // One Tailwind class composition per primitive, variants via
7
+ // `class-variance-authority`, Radix UI underneath interactive elements.
8
+ import { applyFormatSpec } from "../../headless/index.js";
30
9
  import { DefaultDialog } from "./dialog.js";
31
10
  import { DefaultDrawer } from "./drawer.js";
32
- import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "./dropdown-menu.js";
33
11
  import { EmbeddedListInput } from "./embedded-list-input.js";
34
- import { CardLeadingImage, FileDisplayCell } from "./file-thumbnail.js";
35
- import { FileUploadInput } from "./file-upload.js";
36
12
  import { FileValueList } from "./file-value-list.js";
37
- import { FilesDropzoneInput } from "./files-dropzone.js";
38
13
  import { DefaultJsonView } from "./json-view.js";
39
- import { screenFormColumnWidthClassName, screenPaddingClassName, screenWidthClassName, } from "./layout.js";
40
14
  import { DefaultLightbox } from "./lightbox.js";
41
- import { LocatedTimestampInput } from "./located-timestamp-input.js";
42
- import { DefaultMetric, DefaultMetricBand, InsideScreenFormColumnContext } from "./metric.js";
15
+ import { DefaultMetric, DefaultMetricBand } from "./metric.js";
43
16
  import { DefaultModal } from "./modal.js";
44
- import { currencyDecimals, formatMoney, MoneyInput } from "./money-input.js";
45
- import { NarrowPinnedFooter } from "./narrow-pinned-footer.js";
46
- import { NumberInput } from "./number-input.js";
47
- import { groupOptions, hasDescriptionOrGroup } from "./option-groups.js";
48
17
  import { DefaultPageHeader } from "./page-header.js";
49
18
  import { PromoPanel } from "./promo-panel.js";
50
- import { SegmentedControl } from "./segmented-select.js";
51
19
  import { CopyButton, ShareButton } from "./share-actions.js";
52
20
  import { DefaultStatusBadge } from "./status-badge.js";
53
- import { STICKY_FOOTER_SAFE_AREA_CLASS, STICKY_FOOTER_SPACER_CLASS, StickyActionBar, } from "./sticky-action-bar.js";
21
+ import { StickyActionBar } from "./sticky-action-bar.js";
54
22
  import { DefaultTabs } from "./tabs.js";
55
- import { TimestampInput } from "./timestamp-input.js";
56
- import { useToast } from "./toast.js";
57
- import { TzInput } from "./tz-input.js";
58
- import { useKeyboardInset } from "./use-keyboard-inset.js";
59
- import { useIsBelowSmViewport, useIsNarrowViewport } from "./use-narrow-viewport.js";
60
23
  import "../react-css-custom-properties.js";
61
- // ---- Card-Chrome (eine Definition für Form/Section/Card) ----
62
- // Die eine Card-Surface. Maße (Padding/Radius/Shadow) kommen aus --card-*
63
- // CSS-Tokens (Defaults in styles.css), damit eine App sie einmal zentral in
64
- // ihrer styles.css überschreiben kann — wie die Farben. Radius-Variante:
65
- // xl = Card/Screen-Fläche (Default, token-getrieben), lg = "innen"-Fläche.
66
- const cardSurface = cva("flex flex-col border bg-card text-card-foreground shadow-[var(--card-shadow)]", {
67
- variants: { radius: { xl: "rounded-[var(--card-radius)]", lg: "rounded-lg" } },
68
- defaultVariants: { radius: "xl" },
69
- });
70
- // Wraps instead of running off-screen when the row outgrows its container (fw#2528).
71
- const cardFooter = "flex flex-wrap items-center justify-end gap-2 py-4";
72
- const cardFooterInset = "px-[var(--card-padding)]";
73
- // /30 read as nearly invisible against the light-theme card (white card,
74
- // muted at 94% lightness) — /50 keeps the same token, just a stronger step.
75
- const cardFooterBorder = "border-t bg-muted/50";
76
- const cardHeaderBorder = "border-b bg-muted/50";
77
- // ---- Button (vendored shadcn ui/button) ----
78
- // Contract-Variant → shadcn-Variant: secondary war schon immer der
79
- // bordered-bg-background-Look = shadcns `outline`. primary→default,
80
- // danger→destructive, link→link (kein BG, underline on hover).
81
- const BUTTON_VARIANT = {
82
- primary: "default",
83
- secondary: "outline",
84
- danger: "destructive",
85
- link: "link",
86
- "danger-ghost": "ghost",
87
- ghost: "ghost",
88
- };
89
- const BUTTON_SIZE = {
90
- sm: "sm",
91
- md: "default",
92
- icon: "icon",
93
- };
94
- // Closed IconKey vocabulary, checked against the shared NAV_ICONS registry
95
- // (icons.tsx) — an unknown key (schema data isn't statically typed against
96
- // IconKey the way this prop is) renders no icon instead of crashing, same
97
- // fallback shape as fieldIconFor above.
98
- function actionIconFor(icon) {
99
- return icon !== undefined && Object.hasOwn(NAV_ICONS, icon) ? icon : undefined;
100
- }
101
- // Shared by DefaultButton's `secondary` variant and triggers that need Radix `asChild`
102
- // prop forwarding (which DefaultButton does not support), so both stay visually identical.
103
- const SECONDARY_BUTTON_EXTRA_CLASSES = "border-input hover:bg-muted";
104
- function DefaultButton({ type = "button", onClick, disabled, loading, variant = "primary", size = "md", ariaLabel, title, pressed, expanded, width = "auto", children, testId, className, ref, icon, iconEnd, dataAttributes, }) {
105
- // link-Variant rendert text-artig (Inline-Link im Fließtext/Banner), nicht als
106
- // gepolsterte Fläche; width="full" streckt CTA-Buttons in Karten/Panels.
107
- const resolvedClassName = cn(variant === "link" ? "h-auto px-0 py-0" : "", variant === "danger-ghost"
108
- ? "text-destructive hover:text-destructive hover:bg-destructive/10"
109
- : "", variant === "ghost" ? "text-primary hover:text-primary hover:bg-primary/10" : "", variant === "secondary" ? SECONDARY_BUTTON_EXTRA_CLASSES : "", pressed === true
110
- ? "border-primary/40 bg-accent text-accent-foreground ring-1 ring-primary/30"
111
- : "", width === "full" ? "w-full" : "", className);
112
- const IconStart = icon !== undefined ? NAV_ICONS[icon] : undefined;
113
- const IconEnd = iconEnd !== undefined ? NAV_ICONS[iconEnd] : undefined;
114
- // Loading swaps the leading icon slot for a spinner and keeps `children` —
115
- // replacing the label would shift the button's width mid-submit.
116
- const leading = loading === true ? (_jsx(Loader2, { className: "size-4 animate-spin", "aria-hidden": "true" })) : IconStart !== undefined ? (_jsx(IconStart, { className: "size-4", "aria-hidden": "true" })) : null;
117
- return (_jsxs(UiButton, { ref: ref, type: type, onClick: onClick, disabled: disabled === true || loading === true, ...dataAttributes, "data-testid": testId, "data-loading": loading === true ? "true" : undefined, variant: BUTTON_VARIANT[variant], size: BUTTON_SIZE[size], "aria-label": ariaLabel, "aria-pressed": pressed, "aria-expanded": expanded, title: title ?? (size === "icon" ? ariaLabel : undefined), className: resolvedClassName, children: [leading, children, IconEnd !== undefined && _jsx(IconEnd, { className: "size-4", "aria-hidden": "true" })] }));
118
- }
119
- // ---- Banner (shadcn: Alert) ----
120
- function DefaultBanner({ variant = "info", title, titleTestId, children, actions, padded, testId, id, }) {
121
- const isError = variant === "error";
122
- const isWarning = variant === "warning";
123
- const isPrimary = variant === "primary";
124
- // `id` is set when a <Field> wraps this Banner as its control (see
125
- // BannerProps.id). A <div> isn't labelable, so a <label htmlFor> pointing
126
- // at it is inert for screen readers — role="group" + aria-labelledby is
127
- // the actual mechanism, pointed at the label id Field emits (fieldLabelId).
128
- const bannerRole = id !== undefined ? "group" : isError ? "alert" : undefined;
129
- const banner = (_jsxs("div", { role: bannerRole, ...(id !== undefined && { "aria-labelledby": fieldLabelId(id) }), "data-testid": testId, "data-variant": variant, className: cn("relative w-full rounded-lg border px-4 py-3 text-sm flex items-center gap-3",
130
- // Viewport breakpoint like the form footer bar; actions drop below the text on phones.
131
- isPrimary && "flex-col items-start sm:flex-row sm:items-center", isError
132
- ? "border-destructive/50 text-destructive bg-destructive/10 dark:border-destructive"
133
- : isWarning
134
- ? "border-status-warn/30 bg-status-warn/10 text-status-warn"
135
- : isPrimary
136
- ? "border-l-4 border-l-primary bg-card text-card-foreground"
137
- : "bg-card text-card-foreground"), children: [_jsxs("div", { className: "flex-1", children: [title !== undefined && (_jsx("div", { "data-testid": titleTestId, className: "font-semibold", children: title })), children] }), actions !== undefined && _jsx("div", { "data-slot": "actions", children: actions })] }));
138
- // Page-State: Banner sitzt alleine im Main (das kein Padding mehr
139
- // hat). Wrapper gibt 24px Außenabstand damit der Banner nicht edge-
140
- // to-edge an Sidebar/Browser klebt.
141
- return padded === true ? _jsx("div", { className: "p-6", children: banner }) : banner;
142
- }
143
- // ---- Field (Label + Error) ----
144
- // Matches DefaultBanner's aria-labelledby target: a non-labelable control
145
- // (e.g. Banner's <div>) can't take a real htmlFor, so Field always also
146
- // gives its label a stable id that such controls can point to.
147
- function fieldLabelId(id) {
148
- return `${id}-label`;
149
- }
150
- // Boolean optics depend on the surrounding Field layout: layout="inline"
151
- // (checkbox lists like MultiSelectCheckboxes, the BooleanField widget) keeps
152
- // the checkbox, the standard form field (layout="stacked"/default) gets the
153
- // new switch. Pure web-renderer rendering decision, no contract field needed
154
- // — Field hands its resolved layout down to the nested Input via context.
155
- const FieldLayoutContext = createContext("stacked");
156
- // True inside the sections of a card-less screen form: sections then drop their
157
- // own horizontal padding, the screen form's scroll surface provides it.
158
- const ScreenFormContext = createContext(false);
159
- const DrawerBodyContext = createContext(false);
160
- // aria-labelledby into a Field label that doesn't exist leaves a radiogroup
161
- // without any accessible name — fall back to the control's name instead.
162
- const FieldLabelledContext = createContext(false);
163
- function useFieldLabelProps(id, name) {
164
- const insideLabelledField = useContext(FieldLabelledContext);
165
- return insideLabelledField ? { "aria-labelledby": fieldLabelId(id) } : { "aria-label": name };
166
- }
167
- // The Field description is the control's accessible description; the control
168
- // is a descendant (not a prop of Field), so the id travels via context. Always
169
- // provided (undefined without a description) so a nested Field never inherits
170
- // its parent's description id.
171
- const FieldDescriptionContext = createContext(undefined);
172
- function fieldDescriptionId(id) {
173
- return `${id}-description`;
174
- }
175
- function fieldErrorId(id) {
176
- return `${id}-error`;
177
- }
178
- function DefaultField(props) {
179
- const describedBy = [
180
- props.description !== undefined ? fieldDescriptionId(props.id) : undefined,
181
- props.issues !== undefined && props.issues.length > 0 ? fieldErrorId(props.id) : undefined,
182
- ]
183
- .filter((id) => id !== undefined)
184
- .join(" ");
185
- return (_jsx(FieldLabelledContext.Provider, { value: props.label !== "", children: _jsx(FieldDescriptionContext.Provider, { value: describedBy === "" ? undefined : describedBy, children: _jsx(DefaultFieldBody, { ...props }) }) }));
186
- }
187
- function DefaultFieldBody({ id, label, required, issues, labelAppendix, fieldAppendix, appendixPlacement, description, status, children, layout, accent, hideLabel, testId, changed, }) {
188
- const t = useTranslation();
189
- const hasError = issues !== undefined && issues.length > 0;
190
- const labelEl = (_jsxs(UiLabel, { id: fieldLabelId(id), htmlFor: id, className: cn("min-w-0 gap-0 text-[13px]", hasError ? "text-destructive" : "text-foreground", hideLabel === true && "sr-only"), children: [_jsx("span", { className: layout === "inline" ? undefined : "truncate", title: typeof label === "string" ? label : undefined, children: label }), required === true && (_jsx("span", { "data-required": true, className: "ml-0.5 shrink-0 text-destructive", children: "*" }))] }));
191
- const errorsEl = hasError ? (_jsx("div", { id: fieldErrorId(id), role: "alert", "data-testid": testId !== undefined ? `${testId}-errors` : undefined, className: "text-xs text-destructive", children: issues.map((issue) => (_jsx("div", { children: fieldIssueText(t, issue) }, `${issue.path}:${issue.code}`))) })) : null;
192
- // Inline (boolean/checkbox): Control links, Label rechts — shadcn-Muster.
193
- if (layout === "inline") {
194
- return (_jsxs("div", { "data-testid": testId, className: "flex flex-col gap-1.5", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(FieldLayoutContext.Provider, { value: "inline", children: children }), labelEl, labelAppendix !== undefined && labelAppendix] }), errorsEl] }));
195
- }
196
- const changedMarker = changed === true ? (_jsxs("span", { "data-testid": testId !== undefined ? `${testId}-changed` : undefined, className: "inline-flex items-center gap-1 text-xs text-status-active", children: [_jsx("span", { className: "size-1.5 rounded-full bg-current", "aria-hidden": "true" }), t("kumiko.form.changed")] })) : null;
197
- const statusEl = status !== undefined ? (_jsx(StatusBadge, { tone: status.tone, testId: status.testId ?? (testId !== undefined ? `${testId}-status` : undefined), children: status.label })) : null;
198
- const belowControl = appendixPlacement === "below-control";
199
- // Settings row: label/help/origin on the left, control on the right. The
200
- // hairlines between rows come from the list Grid, not from the row itself.
201
- if (layout === "row") {
202
- return (_jsxs("div", { "data-testid": testId, className: cn("group/row relative grid gap-x-6 gap-y-2 py-4 @2xl:grid-cols-[minmax(0,3fr)_minmax(0,2fr)] @2xl:grid-rows-[auto_auto_auto_1fr] @2xl:gap-y-0",
203
- // Switch rows keep label left / switch right when stacked.
204
- "@max-2xl:has-[[data-slot=switch]]:grid-cols-[minmax(0,1fr)_auto]", accent === true && "@max-2xl:pl-3"), children: [accent === true && (_jsx("span", { "aria-hidden": "true", "data-testid": testId !== undefined ? `${testId}-accent` : undefined, className: "absolute inset-y-3 w-0.5 bg-status-active @max-2xl:left-0 @2xl:-left-4" })), _jsxs("div", { className: "flex min-w-0 items-center gap-2 @2xl:col-start-1 @2xl:row-start-1", children: [labelEl, changedMarker, labelAppendix !== undefined && labelAppendix, statusEl] }), description !== undefined && (_jsx("div", { id: fieldDescriptionId(id), "data-testid": testId !== undefined ? `${testId}-description` : undefined, className: "min-w-0 text-sm text-muted-foreground @2xl:col-start-1 @2xl:row-start-2 @2xl:mt-1 @max-2xl:group-has-[[data-slot=switch]]/row:col-span-2", children: description })), _jsxs("div", { className: "flex min-w-0 flex-col gap-1.5 has-[[data-slot=switch]]:items-end @2xl:col-start-2 @2xl:row-span-4 @2xl:row-start-1 @max-2xl:group-has-[[data-slot=switch]]/row:col-start-2 @max-2xl:group-has-[[data-slot=switch]]/row:row-start-1", children: [_jsx(FieldLayoutContext.Provider, { value: "row", children: children }), errorsEl] }), fieldAppendix !== undefined && (_jsx("div", { className: "min-w-0 @2xl:col-start-1 @2xl:row-start-3 @2xl:mt-1 @max-2xl:group-has-[[data-slot=switch]]/row:col-span-2", children: fieldAppendix }))] }));
205
- }
206
- return (_jsxs("div", { "data-testid": testId, ...(belowControl && fieldAppendix !== undefined && { "data-appendix-below": "" }), className: "flex flex-col gap-1.5", children: [_jsxs("div", { className: "flex items-center justify-between gap-2", children: [changedMarker !== null ? (_jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [labelEl, changedMarker] })) : (labelEl), labelAppendix !== undefined && labelAppendix, statusEl] }), description !== undefined && (_jsx("div", { id: fieldDescriptionId(id), "data-testid": testId !== undefined ? `${testId}-description` : undefined, className: "text-sm text-muted-foreground", children: description })), !belowControl && fieldAppendix !== undefined && fieldAppendix, children, errorsEl, belowControl && fieldAppendix !== undefined && fieldAppendix] }));
207
- }
208
- // ---- Input ----
209
- // Field-icon registry: `EditFieldSpec.icon`/`InputProps.icon` sets a
210
- // symbolic key, mapped here to a lucide component. Unknown keys → no
211
- // icon (clean fallback, no boot-fail). Mirrors NAV_ICONS' pattern
212
- // (nav-tree.tsx) — a separate, smaller registry instead of a shared
213
- // import, because field icons cover a different use case (email, phone,
214
- // location, …) than nav icons (dashboard, tables, …).
215
- const FIELD_ICONS = {
216
- mail: Mail,
217
- lock: Lock,
218
- hash: Hash,
219
- search: Search,
220
- user: User,
221
- phone: Phone,
222
- calendar: CalendarDays,
223
- link: Link,
224
- tag: Tag,
225
- building: Building,
226
- globe: Globe,
227
- key: KeyRound,
228
- "map-pin": MapPin,
229
- };
230
- function fieldIconFor(icon) {
231
- return icon !== undefined && Object.hasOwn(FIELD_ICONS, icon)
232
- ? FIELD_ICONS[icon]
233
- : undefined;
234
- }
235
- // Wraps a text/number input with a left-positioned prefix icon when
236
- // `icon` carries a known FIELD_ICONS key. `pl-8` overrides (via
237
- // tailwind-merge) only the left padding of the vendored ui/input.tsx —
238
- // right padding and other defaults stay untouched.
239
- const FIELD_ICON_CLASS = "pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground";
240
- function withFieldIcon(icon, input) {
241
- const Icon = fieldIconFor(icon);
242
- if (Icon === undefined)
243
- return input;
244
- return (_jsxs("div", { className: "relative", children: [_jsx(Icon, { "aria-hidden": "true", className: FIELD_ICON_CLASS }), input] }));
245
- }
246
- const INPUT_AFFIX_CLASS = "inline-flex shrink-0 items-center text-sm text-muted-foreground max-md:min-h-11";
247
- // Prefix/suffix text and the fill button sit beside the input, not inside it,
248
- // so the input keeps its own padding contract (icon, unit). The wrapper is
249
- // only added when an addon exists — a plain input keeps its tree shape.
250
- function withInputAddons(addons, input) {
251
- const { name, prefix, suffix, fill } = addons;
252
- if (prefix === undefined && suffix === undefined && fill === undefined)
253
- return input;
254
- return (_jsxs("div", { "data-slot": "input-addons", className: "flex items-center gap-2", children: [prefix !== undefined && (_jsx("span", { "data-testid": `input-prefix-${name}`, className: INPUT_AFFIX_CLASS, children: prefix })), _jsx("div", { className: "min-w-0 flex-1", children: input }), suffix !== undefined && (_jsx("span", { "data-testid": `input-suffix-${name}`, className: INPUT_AFFIX_CLASS, children: suffix })), fill !== undefined && (_jsx(DefaultButton, { variant: "secondary", size: "sm", onClick: fill.onPress, loading: fill.busy === true, disabled: fill.disabled === true, testId: fill.testId ?? `input-fill-${name}`, children: fill.label }))] }));
255
- }
256
- // The unit changes the meaning of the value ("58 mi" vs "58 km"), so it is
257
- // exposed as the input's description rather than hidden from assistive tech.
258
- function unitSuffixId(inputId) {
259
- return `${inputId}-unit`;
260
- }
261
- const TRAILING_ACTION_ICONS = { clear: X, undo: Undo2 };
262
- // In-field icon button on the right (same look as the date input's calendar
263
- // trigger). The wrapped input must reserve the room itself (`pr-9`).
264
- // `keepWrapper` holds the tree shape when the action comes and goes — a
265
- // write-only input loses its action on the first keystroke, and swapping the
266
- // wrapper would remount the input and drop focus mid-typing.
267
- function withTrailingAction(action, input, keepWrapper = false) {
268
- if (action === undefined && !keepWrapper)
269
- return input;
270
- const ActionIcon = action !== undefined ? TRAILING_ACTION_ICONS[action.icon] : undefined;
271
- return (_jsxs("div", { className: "relative", children: [input, action !== undefined && ActionIcon !== undefined && (_jsx("button", { type: "button", "aria-label": action.label, title: action.label, "data-testid": action.testId, onClick: action.onPress, className: inFieldIconButtonClass, children: _jsx(ActionIcon, { className: "size-4", "aria-hidden": "true" }) }))] }));
272
- }
273
- // Default presentation for `kind: "select"` without a `display` of its own
274
- // (board rule): up to 3 short options read as a segmented control, up to 3
275
- // longer ones as a vertical radio list, everything else as a dropdown. Labels
276
- // arrive translated, so the widget can differ between UI languages (#2606).
277
- const SEGMENTED_SELECT_MAX_OPTIONS = 3;
278
- const SEGMENTED_SELECT_MAX_LABEL_LENGTH = 16;
279
- function defaultSelectPresentation(options) {
280
- if (options.length === 0 || options.length > SEGMENTED_SELECT_MAX_OPTIONS)
281
- return "dropdown";
282
- return options.every((option) => option.label.length <= SEGMENTED_SELECT_MAX_LABEL_LENGTH)
283
- ? "segmented"
284
- : "radioList";
285
- }
286
- // A "" value is the unselected placeholder, not a real choice — a radio
287
- // group can't render an unchecked-everything state as its own segment
288
- // (#2606 follow-up). Filtered once so the eligibility count and the
289
- // rendered segments never drift apart.
290
- function withoutSegmentedSelectPlaceholder(options) {
291
- return options.filter((option) => option.value !== "");
292
- }
293
- function SegmentedSelect(props) {
294
- const labelProps = useFieldLabelProps(props.id, props.name);
295
- return _jsx(SegmentedControl, { ...props, labelProps: labelProps });
296
- }
297
- const RADIO_CARD_CLASS = "flex items-start gap-3 rounded-lg border border-input bg-background px-[15px] py-[13px] text-sm font-normal text-foreground " +
298
- "has-[:checked]:border-2 has-[:checked]:border-primary has-[:checked]:bg-primary/5 has-[:checked]:px-[14px] has-[:checked]:py-3 " +
299
- "has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring has-[:disabled]:opacity-50";
300
- function RadioListSelect({ id, name, value, onChange, options, variant = "list", disabled, required, hasError, }) {
301
- const labelProps = useFieldLabelProps(id, name);
302
- const card = variant === "card";
303
- // One radiogroup over all sections keeps native arrow-key navigation across groups
304
- // (radios share `name` in DOM order).
305
- const descriptionIdOf = (opt) => `${id}-option-${options.findIndex((o) => o.value === opt.value)}-description`;
306
- return (_jsx("div", { role: "radiogroup", ...labelProps, "aria-required": required, "aria-invalid": hasError === true ? true : undefined, "data-testid": `radio-list-${id}`, "data-radio-list": "", className: "flex flex-col gap-2", children: groupOptions(options).map((section) => (_jsxs(Fragment, { children: [section.heading !== undefined && (_jsx("div", { "data-testid": `radio-list-${id}-group-${section.heading}`, className: "mt-1 text-xs font-medium text-muted-foreground", children: section.heading })), section.options.map((opt) => (_jsxs("label", { className: card
307
- ? RADIO_CARD_CLASS
308
- : "flex items-start gap-2 text-sm font-normal text-foreground has-[:disabled]:opacity-50", children: [_jsx("input", { type: "radio", name: name, value: opt.value, checked: opt.value === value, disabled: disabled === true || opt.disabled === true, "aria-describedby": opt.description !== undefined ? descriptionIdOf(opt) : undefined, "data-testid": `radio-list-${id}-${opt.value}`, onChange: () => onChange(opt.value), className: cn("size-4 accent-primary", card ? "mt-0.5 shrink-0" : "mt-0.5") }), _jsxs("span", { className: "flex flex-col gap-0.5", children: [_jsx("span", { className: cn(card && "font-semibold"), children: opt.label }), opt.description !== undefined && (_jsx("span", { id: descriptionIdOf(opt), className: "text-[13px] text-foreground-secondary", children: opt.description }))] })] }, opt.value)))] }, section.heading ?? ""))) }));
309
- }
310
- // The vendored shadcn Textarea carries `field-sizing: content`, which derives
311
- // the box height from the content and makes the `rows` attribute inert (#2677).
312
- // A declared row count therefore only survives as a min-height floor: the field
313
- // starts at `rows` lines tall and still grows with its content. The addend is
314
- // the textarea frame — py-2 top+bottom plus the 1px borders.
315
- function textareaMinHeight(rows) {
316
- return { minHeight: `calc(${rows} * 1lh + 1rem + 2px)` };
317
- }
318
- function withInlineDisabledHint(option) {
319
- if (option.disabled !== true || option.description === undefined)
320
- return option;
321
- return {
322
- value: option.value,
323
- label: `${option.label} (${option.description})`,
324
- disabled: true,
325
- ...(option.group !== undefined && { group: option.group }),
326
- };
327
- }
328
- function DefaultInput(props) {
329
- // Vendored ui/input + ui/checkbox stylen Fehler über `aria-invalid`
330
- // selbst — kein manuelles border-destructive mehr nötig.
331
- const booleanLayout = useContext(FieldLayoutContext);
332
- const describedBy = useContext(FieldDescriptionContext);
333
- const common = {
334
- id: props.id,
335
- name: props.name,
336
- disabled: props.disabled,
337
- "aria-describedby": describedBy,
338
- "aria-required": props.required,
339
- "aria-invalid": props.hasError === true ? true : undefined,
340
- };
341
- switch (props.kind) {
342
- case "text":
343
- return withInputAddons(props, withFieldIcon(props.icon, _jsx(UiInput, { type: "text", ...common, ...props.dataAttributes, "data-testid": props.testId, readOnly: props.readOnly, value: props.value, onChange: (e) => props.onChange(e.target.value), onKeyDown: props.onKeyDown, ...(props.placeholder !== undefined && { placeholder: props.placeholder }), ...(props.autoComplete !== undefined && { autoComplete: props.autoComplete }), className: cn(fieldIconFor(props.icon) !== undefined ? "pl-8" : undefined) })));
344
- case "email":
345
- return (_jsx(UiInput, { type: "email", ...common, "data-testid": props.testId, value: props.value, onChange: (e) => props.onChange(e.target.value), ...(props.placeholder !== undefined && { placeholder: props.placeholder }), autoComplete: props.autoComplete ?? "email" }));
346
- case "month":
347
- return (_jsx(UiInput, { type: "month", ...common, "data-testid": props.testId, value: props.value, onChange: (e) => props.onChange(e.target.value) }));
348
- case "password":
349
- return withTrailingAction(props.trailingAction, _jsx(UiInput, { type: "password", ...common, "data-testid": props.testId, value: props.value, onChange: (e) => props.onChange(e.target.value), ...(props.placeholder !== undefined && { placeholder: props.placeholder }), autoComplete: props.autoComplete ?? "current-password", className: props.trailingAction !== undefined ? "pr-9" : undefined }), true);
350
- case "number": {
351
- const NumberIcon = fieldIconFor(props.icon);
352
- // Icon, input and unit share this one box, and the grid cell sizes the
353
- // box rather than the bare input: anchored to a wider wrapper, the unit
354
- // would sit next to a narrower input instead of inside it.
355
- const numberField = (_jsxs("div", { "data-slot": "number-field", className: "relative", children: [NumberIcon !== undefined && (_jsx(NumberIcon, { "aria-hidden": "true", className: FIELD_ICON_CLASS })), _jsx(NumberInput, { id: props.id, name: props.name, value: props.value, onChange: props.onChange, disabled: props.disabled, required: props.required, hasError: props.hasError, testId: props.testId, ...(props.unit !== undefined && { ariaDescribedBy: unitSuffixId(props.id) }), ...(props.locale !== undefined && { locale: props.locale }), ...(props.grouping !== undefined && { grouping: props.grouping }), ...(props.integer !== undefined && { integer: props.integer }), ...(props.placeholder !== undefined && { placeholder: props.placeholder }), className: cn(NumberIcon !== undefined ? "pl-8" : undefined, props.unit !== undefined ? "pr-8" : undefined) }), props.unit !== undefined && (_jsx("span", { id: unitSuffixId(props.id), className: "pointer-events-none absolute right-2.5 top-1/2 max-w-[40%] -translate-y-1/2 truncate text-sm text-muted-foreground", children: props.unit }))] }));
356
- return withInputAddons(props, numberField);
357
- }
358
- case "range":
359
- return (_jsx("input", { type: "range", ...common, min: props.min, max: props.max, step: props.step ?? 1, value: props.value, onChange: (e) => props.onChange(Number(e.target.value)), className: "w-full accent-primary" }));
360
- case "boolean":
361
- // layout="inline" (checkbox lists like MultiSelectCheckboxes, the
362
- // BooleanField widget) keeps the checkbox optics; the standard form
363
- // field (render-field.tsx, layout="stacked") gets the switch.
364
- return booleanLayout === "inline" ? (_jsx(Checkbox, { id: props.id, name: props.name, disabled: props.disabled, "aria-required": props.required, "aria-invalid": props.hasError === true ? true : undefined, checked: props.value, onCheckedChange: (checked) => props.onChange(checked === true) })) : (_jsx(Switch, { id: props.id, name: props.name, disabled: props.disabled, "aria-required": props.required, "aria-invalid": props.hasError === true ? true : undefined, checked: props.value, onCheckedChange: (checked) => props.onChange(checked === true), className: "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40" }));
365
- case "file":
366
- case "image":
367
- return (_jsx(FileUploadInput, { kind: props.kind, id: props.id, value: props.value, onChange: props.onChange, ...(props.accept !== undefined && { accept: props.accept }), ...(props.disabled !== undefined && { disabled: props.disabled }), ...(props.entityType !== undefined && { entityType: props.entityType }), ...(props.uploadActionForm !== undefined && {
368
- uploadActionForm: props.uploadActionForm,
369
- }), ...(props.entityId !== undefined && { entityId: props.entityId }), ...(props.fieldName !== undefined && { fieldName: props.fieldName }), ...(props.imageVariant !== undefined && { imageVariant: props.imageVariant }), ...(props.capture !== undefined && { capture: props.capture }), ...(props.directory !== undefined && { directory: props.directory }), ...(props.onFilesPicked !== undefined && { onFilesPicked: props.onFilesPicked }), ...(props.pickLabel !== undefined && { pickLabel: props.pickLabel }), ...(props.testId !== undefined && { testId: props.testId }) }));
370
- case "files-dropzone":
371
- return (_jsx(FilesDropzoneInput, { id: props.id, value: props.value, onChange: props.onChange, ...(props.accept !== undefined && { accept: props.accept }), ...(props.maxSize !== undefined && { maxSize: props.maxSize }), ...(props.maxCount !== undefined && { maxCount: props.maxCount }), ...(props.disabled !== undefined && { disabled: props.disabled }), ...(props.entityType !== undefined && { entityType: props.entityType }), ...(props.uploadActionForm !== undefined && {
372
- uploadActionForm: props.uploadActionForm,
373
- }), ...(props.entityId !== undefined && { entityId: props.entityId }), ...(props.fieldName !== undefined && { fieldName: props.fieldName }), ...(props.testId !== undefined && { testId: props.testId }) }));
374
- case "date":
375
- return (_jsx(DateInput, { id: props.id, name: props.name, value: props.value, onChange: props.onChange, ...(props.locale !== undefined && { locale: props.locale }), ...(props.min !== undefined && { min: props.min }), ...(props.max !== undefined && { max: props.max }), ...(props.disabled !== undefined && { disabled: props.disabled }), ...(props.required !== undefined && { required: props.required }), ...(props.hasError !== undefined && { hasError: props.hasError }) }));
376
- case "select": {
377
- // Visual-Konsolidierung: alle Selects laufen über ComboboxInput
378
- // (cmdk + Radix-Popover). Vorher hatten wir zwei Pfade — Radix-
379
- // Select für `kind:"select"` und cmdk für `kind:"combobox"`. Drei
380
- // visuell unterschiedliche Variants (Single-Select, Combobox-
381
- // Single, Combobox-Multi) wurden unhandbar. Mit dem Merge ist die
382
- // Combobox die einzige Implementation: Search-Input ist auch bei
383
- // 4-Item-Status-Selects vorhanden, das ist eine bewusst akzeptierte
384
- // UX-Konsequenz für Style-Konsistenz.
385
- const comboOptions = props.options.map((o) => typeof o === "string" ? { value: o, label: o } : o);
386
- // The dropdown shows a disabled option's hint inline, so it stays readable in the trigger and search.
387
- const dropdownOptions = comboOptions.map(withInlineDisabledHint);
388
- // The radio group can't render the unselected placeholder as its own
389
- // segment, so it counts and renders on the real options only — the
390
- // dropdown below keeps comboOptions (placeholder included) since it
391
- // has a genuine "nothing selected" row.
392
- // An author who asks for `display: "radio"` explicitly declared the ""
393
- // option as a real choice (e.g. "Any"), so it stays a segment.
394
- const radioGroupOptions = props.display === "radio" ? comboOptions : withoutSegmentedSelectPlaceholder(comboOptions);
395
- // An explicit `display` is an author decision and outranks the
396
- // heuristic in both directions — a requested radio group renders as
397
- // one even when the options outnumber the heuristic's threshold (#2711).
398
- const requestedPresentation = props.radioVariant === "card"
399
- ? "radioList"
400
- : props.display === "radio"
401
- ? "segmented"
402
- : props.display === "dropdown"
403
- ? "dropdown"
404
- : defaultSelectPresentation(radioGroupOptions);
405
- // A segmented control has no room for a description line or group headings.
406
- const presentation = requestedPresentation === "segmented" && hasDescriptionOrGroup(radioGroupOptions)
407
- ? "radioList"
408
- : requestedPresentation;
409
- if (presentation === "radioList" && radioGroupOptions.length > 0) {
410
- return (_jsx(RadioListSelect, { id: props.id, name: props.name, value: props.value, onChange: props.onChange, options: radioGroupOptions, ...(props.radioVariant !== undefined && { variant: props.radioVariant }), ...(props.disabled !== undefined && { disabled: props.disabled }), ...(props.required !== undefined && { required: props.required }), ...(props.hasError !== undefined && { hasError: props.hasError }) }));
411
- }
412
- if (presentation === "segmented" && radioGroupOptions.length > 0) {
413
- return (_jsx(SegmentedSelect, { id: props.id, name: props.name, value: props.value, onChange: props.onChange, options: radioGroupOptions, ...(props.disabled !== undefined && { disabled: props.disabled }), ...(props.required !== undefined && { required: props.required }), ...(props.hasError !== undefined && { hasError: props.hasError }) }));
414
- }
415
- return (_jsx(ComboboxInput, { id: props.id, name: props.name, value: props.value, onChange: props.onChange, options: dropdownOptions, ...(props.disabled !== undefined && { disabled: props.disabled }), ...(props.required !== undefined && { required: props.required }), ...(props.hasError !== undefined && { hasError: props.hasError }) }));
416
- }
417
- case "combobox": {
418
- // Tier 2.1c + Tier 2.7e: Discriminated-Union per `multiple` —
419
- // wir splittan TS-side in zwei Branches damit ComboboxInput's
420
- // Single/Multi-Variants typgerecht gerendert werden.
421
- const baseProps = {
422
- id: props.id,
423
- name: props.name,
424
- options: props.options,
425
- ...(props.disabled !== undefined && { disabled: props.disabled }),
426
- ...(props.required !== undefined && { required: props.required }),
427
- ...(props.hasError !== undefined && { hasError: props.hasError }),
428
- ...(props.placeholder !== undefined && { placeholder: props.placeholder }),
429
- ...(props.searchPlaceholder !== undefined && {
430
- searchPlaceholder: props.searchPlaceholder,
431
- }),
432
- ...(props.emptyText !== undefined && { emptyText: props.emptyText }),
433
- ...(props.onSearchChange !== undefined && { onSearchChange: props.onSearchChange }),
434
- ...(props.loading !== undefined && { loading: props.loading }),
435
- ...(props.onCreate !== undefined && { onCreate: props.onCreate }),
436
- ...(props.createLabel !== undefined && { createLabel: props.createLabel }),
437
- };
438
- if (props.multiple === true) {
439
- return (_jsx(ComboboxInput, { ...baseProps, multiple: true, value: props.value, onChange: props.onChange }));
440
- }
441
- return _jsx(ComboboxInput, { ...baseProps, value: props.value, onChange: props.onChange });
442
- }
443
- case "money":
444
- return (_jsx(MoneyInput, { id: props.id, name: props.name, value: props.value, onChange: props.onChange, currency: props.currency ?? "EUR", ...(props.locale !== undefined && { locale: props.locale }), ...(props.disabled !== undefined && { disabled: props.disabled }), ...(props.required !== undefined && { required: props.required }), ...(props.hasError !== undefined && { hasError: props.hasError }) }));
445
- case "timestamp":
446
- return (_jsx(TimestampInput, { id: props.id, name: props.name, value: props.value, onChange: props.onChange, ...(props.wallClock !== undefined && { wallClock: props.wallClock }), ...(props.locale !== undefined && { locale: props.locale }), ...(props.min !== undefined && { min: props.min }), ...(props.max !== undefined && { max: props.max }), ...(props.disabled !== undefined && { disabled: props.disabled }), ...(props.required !== undefined && { required: props.required }), ...(props.hasError !== undefined && { hasError: props.hasError }) }));
447
- case "locatedTimestamp":
448
- return (_jsx(LocatedTimestampInput, { id: props.id, name: props.name, value: props.value, onChange: props.onChange, ...(props.locale !== undefined && { locale: props.locale }), ...(props.min !== undefined && { min: props.min }), ...(props.max !== undefined && { max: props.max }), ...(props.disabled !== undefined && { disabled: props.disabled }), ...(props.required !== undefined && { required: props.required }), ...(props.hasError !== undefined && { hasError: props.hasError }) }));
449
- case "textarea": {
450
- const rows = props.rows;
451
- const onSubmitShortcut = props.onSubmitShortcut;
452
- const onKeyDown = props.onKeyDown;
453
- return (_jsx(Textarea, { ...common, ...props.dataAttributes, readOnly: props.readOnly, value: props.value, onChange: (e) => props.onChange(e.target.value), rows: rows ?? 4, className: "resize-y", ...(props.placeholder !== undefined && { placeholder: props.placeholder }), ...(rows !== undefined && { style: textareaMinHeight(rows) }), ...((onSubmitShortcut !== undefined || onKeyDown !== undefined) && {
454
- ...(onSubmitShortcut !== undefined && {
455
- "aria-keyshortcuts": "Control+Enter Meta+Enter",
456
- }),
457
- onKeyDown: (e) => {
458
- onKeyDown?.(e);
459
- // Lets a caller that already handles Ctrl/Cmd+Enter (e.g. an
460
- // "Enter sends" handler that ignores only Shift) opt out of the
461
- // shortcut instead of submitting twice.
462
- if (e.defaultPrevented)
463
- return;
464
- if (onSubmitShortcut === undefined || e.key !== "Enter" || !(e.metaKey || e.ctrlKey))
465
- return;
466
- e.preventDefault();
467
- onSubmitShortcut();
468
- },
469
- }) }));
470
- }
471
- case "tz":
472
- return (_jsx(TzInput, { id: props.id, name: props.name, value: props.value, onChange: props.onChange, ...(props.disabled !== undefined && { disabled: props.disabled }), ...(props.required !== undefined && { required: props.required }), ...(props.hasError !== undefined && { hasError: props.hasError }) }));
473
- }
474
- }
475
- // ---- DataTable (shadcn: Table) ----
476
- function sameValueSet(a, b) {
477
- return a.length === b.length && a.every((value) => b.includes(value));
478
- }
479
- // Single-choice chip row for a facet with `chips`: a chip is pressed while the
480
- // current selection equals its values, a click replaces the selection.
481
- function FacetChips({ facet, selected, onChange, }) {
482
- const chips = (facet.chips ?? []).filter((chip) => facet.hideEmpty !== true || chip.count !== 0 || sameValueSet(selected, chip.values));
483
- return (_jsx("div", { role: "group", "aria-label": facet.label, className: "flex flex-wrap items-center gap-1.5", "data-testid": `facet-${facet.field}`, children: chips.map((chip) => (_jsxs(DefaultButton, { variant: "secondary", size: "sm", pressed: sameValueSet(selected, chip.values), onClick: () => onChange(facet.field, chip.values), testId: `facet-${facet.field}-${chip.id}`, className: "rounded-full", children: [chip.label, facet.showCounts === true && chip.count !== undefined && (_jsx("span", { className: "tabular-nums text-muted-foreground", children: chip.count }))] }, chip.id))) }));
484
- }
485
- // Faceted-Filter-Dropdown: Outline-Button (wie shadcns "Columns"-Toggle) +
486
- // Multi-Select-Checkboxen. Aktive Auswahl → Count-Badge am Button.
487
- function FacetFilter({ facet, selected, onChange, dense = false, }) {
488
- const toggle = (value, checked) => {
489
- const next = checked ? [...selected, value] : selected.filter((v) => v !== value);
490
- onChange(facet.field, next);
491
- };
492
- if (facet.chips !== undefined) {
493
- return _jsx(FacetChips, { facet: facet, selected: selected, onChange: onChange });
494
- }
495
- return (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs(UiButton, { variant: "outline", size: "sm", className: cn("h-9", dense && "h-8 border-border-strong px-2.5 max-md:h-11"), "data-testid": `facet-${facet.field}`, children: [facet.label, selected.length > 0 && (_jsx(Badge, { variant: "secondary", className: "ml-1 rounded-sm px-1 font-normal tabular-nums", children: selected.length })), _jsx(ChevronDown, { className: "text-muted-foreground" })] }) }), _jsx(DropdownMenuContent, { align: "start", className: "max-h-[var(--radix-dropdown-menu-content-available-height)] w-44 overflow-y-auto", children: facet.options.map((opt) => (_jsx(DropdownMenuCheckboxItem, { checked: selected.includes(opt.value), onCheckedChange: (checked) => toggle(opt.value, checked), onSelect: (e) => e.preventDefault(), "data-testid": `facet-${facet.field}-${opt.value}`, children: opt.label }, opt.value))) })] }));
496
- }
497
- // Time-range filter (fw#3104): two native <input type="date"> instead of a
498
- // dropdown — the browser supplies the calendar, the locale and the keyboard
499
- // handling, so no date dependency is needed. Each bound constrains the other
500
- // through min/max so the picker can't offer an inverted span; the caller
501
- // clamps a typed value on top of that.
502
- function DateRangeFacetFilter({ facet, onChange, }) {
503
- const inputClass = "h-9 rounded-md border border-input bg-transparent px-2 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 max-md:min-h-11";
504
- return (_jsxs("div", { className: "flex items-center gap-2", "data-testid": `facet-daterange-${facet.field}`, children: [_jsx("span", { className: "text-sm text-muted-foreground", children: facet.label }), _jsx("input", { type: "date", className: inputClass, value: facet.from, ...(facet.to !== "" && { max: facet.to }), "aria-label": `${facet.label} from`, "data-testid": `facet-daterange-${facet.field}-from`, onChange: (e) => onChange(facet.field, "from", e.target.value) }), _jsx("span", { className: "text-sm text-muted-foreground", children: "\u2013" }), _jsx("input", { type: "date", className: inputClass, value: facet.to, ...(facet.from !== "" && { min: facet.from }), "aria-label": `${facet.label} to`, "data-testid": `facet-daterange-${facet.field}-to`, onChange: (e) => onChange(facet.field, "to", e.target.value) })] }));
505
- }
506
- // The vendored Table wraps <table> in an `overflow-x-auto` container, which
507
- // would become the scrollport of a sticky header. A scrollBody table scrolls
508
- // in its parent instead, so it renders the bare <table>.
509
- function FlushTable({ className, ...props }) {
510
- return (_jsx("table", { "data-slot": "table", className: cn("w-full caption-bottom text-sm", className), ...props }));
511
- }
512
- // Header cells stick to the scroll surface; the bottom rule is an inset
513
- // shadow because a border on a sticky cell scrolls away under border-collapse.
514
- // Column weight (medium vs. semibold for the sorted one) lives on the th itself
515
- // in SortableHeader: a `[&_th]:font-*` rule here would outrank it.
516
- // Child selectors only: an expanded row nests a whole table inside a cell, and
517
- // descendant selectors would make its header sticky and re-pad its cells.
518
- const FILL_TABLE_CLASS = cn("[&>thead>tr>th]:sticky [&>thead>tr>th]:top-0 [&>thead>tr>th]:z-10 [&>thead>tr>th]:h-9 [&>thead>tr>th]:bg-muted [&>thead>tr>th]:px-2 [&>thead>tr>th]:text-[13px]", "[&>thead>tr>th]:shadow-[inset_0_-1px_0_var(--color-border)] [&>thead>tr>th_button]:text-[13px] [&>tbody>tr>td]:px-2 [&>tbody>tr>td]:py-0", "[&>*>tr>:first-child]:pl-6 [&>*>tr>:last-child]:pr-6");
519
- // `!`: beats the first/last-child padding of FILL_TABLE_CLASS, which would
520
- // otherwise widen the narrow toggle column / pad the full-width expansion cell.
521
- // The nested table's sticky actions cell (last td) gets the muted surface
522
- // instead of its own md:bg-background, so it blends into the area.
523
- const EXPAND_TOGGLE_CELL_CLASS = "w-10 px-1! py-0!";
524
- const EXPANSION_CELL_CLASS = "whitespace-normal bg-muted p-0! align-top md:[&_td:last-child]:bg-muted";
525
- const EXPANSION_CONTENT_CLASS = "pt-1 pr-4 pb-3.5 pl-[52px]";
526
- function RowExpandToggle({ expanded, controlsId, label, onToggle, testId, }) {
527
- return (_jsx(UiButton, { type: "button", variant: "ghost", size: "icon", className: "size-8 shrink-0 max-md:size-11", "aria-expanded": expanded, ...(expanded && { "aria-controls": controlsId }), "aria-label": label, "data-testid": testId, onClick: (e) => {
528
- e.stopPropagation();
529
- onToggle();
530
- }, onKeyDown: (e) => e.stopPropagation(), children: _jsx(ChevronRight, { className: cn("size-4 transition-transform", expanded && "rotate-90"), "aria-hidden": "true" }) }));
531
- }
532
- const TABLE_FOOTER_BAR_CLASS = cn("flex h-15 shrink-0 items-center gap-4 border-t border-border bg-card pl-4 pr-2", "text-[13px] tabular-nums text-foreground-secondary md:h-11 md:px-6");
533
- function DataTableFooter({ count, itemNoun, testId, }) {
534
- const t = useOptionalTranslation();
535
- const noun = itemNoun?.(count);
536
- const label = noun !== undefined
537
- ? (t?.("kumiko.list.count.noun", { count: count.toLocaleString(), noun }) ??
538
- `${count.toLocaleString()} ${noun}`)
539
- : count === 1
540
- ? (t?.("kumiko.list.count.one") ?? "1 entry")
541
- : (t?.("kumiko.list.count.other", { count: count.toLocaleString() }) ??
542
- `${count.toLocaleString()} entries`);
543
- return (_jsx("div", { "data-testid": testId, className: TABLE_FOOTER_BAR_CLASS, children: _jsx("span", { "data-testid": `${testId}-count`, children: label }) }));
544
- }
545
- // Cell content is a ReactNode, so the tooltip text is read from the rendered DOM.
546
- function showFullTextOnHover(event) {
547
- const element = event.currentTarget;
548
- element.title = element.scrollWidth > element.clientWidth ? (element.textContent ?? "") : "";
549
- }
550
- function DefaultDataTable({ columns, rows, onRowClick, sort, onSortChange, emptyState, toolbarStart, toolbarDescription, toolbarEnd, pager, onReachEnd, loadingMore, hasMore, itemNoun, rowActions, rowActionMode, rowGrouping, rowTone, filterFacets, filterValues, onFilterChange, onFilterReset, dateRangeFacets, onDateRangeChange, testId, onCellChange, getRowTestId, getCellTestId, chromeless, scrollBody, screenPadding, expandedRowIds, onToggleRowExpanded, renderExpandedRow, }) {
551
- // One locale/translate subscription per table — not per cell (fw#2345).
552
- // Optional hooks: a bare DataTable outside LocaleProvider must not crash.
553
- const tableTranslate = useOptionalTranslation();
554
- const tableLocale = useOptionalLocale();
555
- // Below 768px a table scrolls its columns out of reach with no visible
556
- // affordance (fw#2159 fixed the desktop case; narrow viewports never had
557
- // one). Cards replace the table entirely below the breakpoint — same
558
- // single-mount pattern as EmbeddedListInput/embedded-list-input.tsx.
559
- const isNarrow = useIsNarrowViewport();
560
- const [facetsOpenNarrow, setFacetsOpenNarrow] = useState(false);
561
- const [toggledGroups, setToggledGroups] = useState(new Set());
562
- // Toolbar-Wrapper: gemeinsamer Container für Toolbar+Tabelle damit
563
- // beide visuell zusammengehören. Toolbar ist NICHT sticky — Lists
564
- // scrollen typischerweise mit dem Page-Container, nicht intern.
565
- // Sticky würde mit der Topbar konkurrieren.
566
- // The row click already runs rowClick actions, so with a clickable row they
567
- // must not reappear in the kebab (a list whose only action is the rowClick
568
- // one gets no actions column at all).
569
- const rowIsLink = onRowClick !== undefined;
570
- const menuActions = rowActions !== undefined && rowIsLink
571
- ? rowActions.filter((action) => action.rowClick !== true)
572
- : rowActions;
573
- // The table keeps every action in "inline" mode, and with editable cells the
574
- // first cell is not a keyboard link (FirstCellLink is off), so the kebab is
575
- // then the only keyboard route to the rowClick action.
576
- const tableActions = rowActionMode === "inline" || onCellChange !== undefined ? rowActions : menuActions;
577
- const rowClickAction = rowActions?.find((action) => action.rowClick === true);
578
- const rowClickLabel = rowClickAction?.label;
579
- const rowClickIsHidden = (row) => rowClickAction?.isVisible !== undefined && !rowClickAction.isVisible(row);
580
- const hasTableActions = tableActions !== undefined && tableActions.length > 0;
581
- const isEmpty = rows.length === 0;
582
- const expansionIdPrefix = useId();
583
- const isRowExpandable = renderExpandedRow !== undefined && onToggleRowExpanded !== undefined;
584
- const rowTestId = (row) => getRowTestId?.(row) ?? `row-${row.id}`;
585
- const expansionDomId = (row) => `${expansionIdPrefix}-${row.id}`;
586
- // The expand toggle of a row, shared by table and card layout. Named after
587
- // the first data column, the one a user recognizes the row by.
588
- function renderExpandToggle(row) {
589
- if (!isRowExpandable)
590
- return null;
591
- const expanded = expandedRowIds?.has(row.id) === true;
592
- const firstColumn = columns[0];
593
- const title = (firstColumn !== undefined ? cellTitle(row.values[firstColumn.field]) : undefined) ?? "";
594
- const labelKey = expanded ? "kumiko.list.row.collapse" : "kumiko.list.row.expand";
595
- return (_jsx(RowExpandToggle, { expanded: expanded, controlsId: expansionDomId(row), label: tableTranslate?.(labelKey, { title }) ?? `${expanded ? "Collapse" : "Expand"} ${title}`, onToggle: () => onToggleRowExpanded(row.id), testId: `${rowTestId(row)}-toggle` }));
596
- }
597
- function renderExpansion(row, as, colSpan) {
598
- if (!isRowExpandable || expandedRowIds?.has(row.id) !== true)
599
- return null;
600
- const content = renderExpandedRow(row);
601
- if (as === "item") {
602
- return (_jsx("li", { id: expansionDomId(row), "data-testid": `${rowTestId(row)}-expansion`, className: "border-b border-border-row bg-muted px-4 pt-1 pb-3.5", children: content }));
603
- }
604
- return (_jsx(TableRow, { id: expansionDomId(row), "data-testid": `${rowTestId(row)}-expansion`, className: "hover:bg-transparent", children: _jsx(TableCell, { colSpan: colSpan, className: EXPANSION_CELL_CLASS, children: _jsx("div", { className: EXPANSION_CONTENT_CLASS, children: content }) }) }));
605
- }
606
- const fillsHeight = scrollBody === true;
607
- const TableRoot = fillsHeight ? FlushTable : Table;
608
- const emptyBlock = (_jsx("div", { "data-testid": testId !== undefined ? `${testId}-empty` : "render-list-empty", className: cn("flex flex-col items-center justify-center p-12 text-sm text-muted-foreground gap-3", chromeless !== true && !fillsHeight && "rounded-md border border-dashed"), children: emptyState ?? _jsx("span", { children: tableTranslate?.("kumiko.list.no-entries") ?? "No entries." }) }));
609
- const tableItems = buildTableItems(rows, rowGrouping, toggledGroups);
610
- function renderGroupToggle(item) {
611
- return (_jsxs("button", { type: "button", "aria-expanded": !item.collapsed, "data-testid": `row-group-${item.key}-toggle`, onClick: () => setToggledGroups((prev) => {
612
- const next = new Set(prev);
613
- if (!next.delete(item.key))
614
- next.add(item.key);
615
- return next;
616
- }), className: "flex w-full items-center gap-2 px-4 py-2 text-left text-xs font-medium text-foreground-secondary", children: [item.collapsed ? (_jsx(ChevronRight, { className: "size-3.5" })) : (_jsx(ChevronDown, { className: "size-3.5" })), item.label] }));
617
- }
618
- function tableInner() {
619
- if (isEmpty)
620
- return emptyBlock;
621
- return (_jsx("div", { className: cn(!fillsHeight && "overflow-hidden", !fillsHeight && chromeless !== true && "rounded-lg border bg-card"), children: _jsxs(TableRoot, { "data-testid": testId, className: fillsHeight ? FILL_TABLE_CLASS : undefined, children: [_jsx(TableHeader, { className: "bg-muted", children: _jsxs(TableRow, { className: "hover:bg-transparent", children: [isRowExpandable && (_jsx(TableHead, { "data-testid": "column-expand", className: EXPAND_TOGGLE_CELL_CLASS })), columns.map((col) => (_jsx(SortableHeader, { field: col.field, label: col.label, sortable: col.sortable === true, highlighted: col.highlighted === true, numeric: NUMERIC_COLUMN_TYPES.has(col.type), fitContent: col.fileDisplay !== undefined, ...(sort !== undefined && sort !== null && { sort }), ...(onSortChange !== undefined && { onSortChange }) }, col.field))), hasTableActions && (_jsx(TableHead, { "data-testid": "column-actions",
622
- // From md: sticky right-0 + bg-muted (= header tone) so the
623
- // action column stays at the right edge during horizontal
624
- // scroll on wider screens. Below md, actions scroll in
625
- // normal flow — sticky there would pin the column over the
626
- // preceding data column instead of the (already narrow)
627
- // page edge. No border-l: a permanent divider looks heavy;
628
- // the sticky bg already sets the column apart during scroll.
629
- className: "w-px text-right text-muted-foreground md:sticky md:right-0 md:z-10 md:bg-muted", "aria-label": tableTranslate?.("kumiko.list.actions") ?? "Actions" }))] }) }), _jsx(TableBody, { children: tableItems.map((item) => {
630
- if (item.kind === "group") {
631
- return (_jsx(TableRow, { "data-testid": `row-group-${item.key}`, children: _jsx(TableCell, { colSpan: columns.length + (isRowExpandable ? 1 : 0) + (hasTableActions ? 1 : 0), className: "bg-muted/40 p-0", children: renderGroupToggle(item) }) }, `group-${item.key}`));
632
- }
633
- const row = item.row;
634
- const opensOnClick = rowIsLink && !rowClickIsHidden(row);
635
- return (_jsxs(Fragment, { children: [_jsxs(TableRow, { "data-testid": rowTestId(row), "data-tone": rowTone?.(row), onClick: opensOnClick ? () => onRowClick(row) : undefined, className: cn(rowToneClass(rowTone?.(row)), opensOnClick && "cursor-pointer", fillsHeight && "h-10 border-border-row hover:bg-muted"), children: [isRowExpandable && (_jsx(TableCell, { "data-testid": getCellTestId?.(row, "expand") ?? `cell-${row.id}-expand`, className: EXPAND_TOGGLE_CELL_CLASS, children: renderExpandToggle(row) })), columns.map((col, colIndex) => (_jsx(TableCell, { "data-testid": getCellTestId?.(row, col.field) ?? `cell-${row.id}-${col.field}`, "data-highlighted": col.highlighted === true ? "true" : undefined,
636
- // Text wraps instead of truncating: nowrap cells keep their
637
- // full width, so a wide table scrolled sideways and its
638
- // last column slid under the sticky actions column with
639
- // nothing visibly cut off. max-w-xs bounds a single column;
640
- // badges and dates stay nowrap by their own classes.
641
- className: cn(col.fileDisplay === undefined
642
- ? "max-w-xs whitespace-normal break-words"
643
- : FIT_CONTENT_CELL_CLASS, colIndex === 0
644
- ? "font-medium text-foreground"
645
- : "text-foreground-secondary", NUMERIC_COLUMN_TYPES.has(col.type) && "text-right", TABULAR_COLUMN_TYPES.has(col.type) && "tabular-nums", col.highlighted === true && "bg-accent/40"), title: cellTitle(row.values[col.field]), children: _jsx(FirstCellLink, { enabled: colIndex === 0 && opensOnClick && onCellChange === undefined, onOpen: () => onRowClick?.(row), empty: isEmptyCellValue(row.values[col.field]), emptyLabel: rowClickLabel, children: _jsx(DataTableCell, { value: row.values[col.field], row: row.values, field: col.field, type: col.type, renderer: col.renderer, translate: tableTranslate, locale: tableLocale, ...(col.optionLabels !== undefined && {
646
- optionLabels: col.optionLabels,
647
- }), ...(col.optionTones !== undefined && { optionTones: col.optionTones }), ...(col.grouping !== undefined && { grouping: col.grouping }), ...(col.fileDisplay !== undefined && { fileDisplay: col.fileDisplay }), ...(onCellChange !== undefined && {
648
- onChange: (value) => onCellChange(row.id, col.field, value),
649
- }), ...cellLinkProp(col, row, !(colIndex === 0 && opensOnClick && onCellChange === undefined)) }) }) }, col.field))), hasTableActions && (_jsx(TableCell, { "data-testid": getCellTestId?.(row, "actions") ?? `cell-${row.id}-actions`,
650
- // From md: sticky-right so the actions stay visible on the
651
- // right edge during horizontal scroll. Below md, actions
652
- // scroll with the row like any other cell — sticky there
653
- // would hide the neighboring data column on narrow
654
- // viewports. bg-background sets the column apart during
655
- // scroll — no border-l (divider too heavy).
656
- className: cn("whitespace-nowrap text-right md:sticky md:right-0 md:z-10 md:bg-background", fillsHeight && "md:bg-card"),
657
- // Action-cell events must not trigger the row click/activation
658
- // (typically "Open Detail" — the user wanted the action,
659
- // not navigation). We stopPropagation for mouse and
660
- // keyboard so a11y stays consistent.
661
- onClick: (e) => e.stopPropagation(), onKeyDown: (e) => e.stopPropagation(), children: _jsx(RowActionsCell, { row: row, actions: tableActions, mode: rowActionMode, rowIsLink: rowIsLink }) }))] }), renderExpansion(row, "row", columns.length + (isRowExpandable ? 1 : 0) + (hasTableActions ? 1 : 0))] }, row.id));
662
- }) })] }) }));
663
- }
664
- // No column headers below the breakpoint, so the click-to-sort affordance
665
- // on SortableHeader has nothing to attach to. A single native <select> is
666
- // the whole fix — no custom widget, no menu — fed straight from the
667
- // columns marked sortable in the ViewModel. Options carry the resolved
668
- // {field, dir} directly so onChange can look the pick up by value instead
669
- // of parsing/casting the option string back apart.
670
- const sortableColumns = columns.filter((col) => col.sortable);
671
- const sortOptions = sortableColumns.flatMap((col) => [
672
- { value: `${col.field}:asc`, field: col.field, dir: "asc", label: `${col.label} ↑` },
673
- { value: `${col.field}:desc`, field: col.field, dir: "desc", label: `${col.label} ↓` },
674
- ]);
675
- const { image: cardImageColumn, title: cardTitleColumn, status: cardStatusColumn, meta: cardMetaColumns, } = cardColumnRoles(columns);
676
- function cardCell(row, col) {
677
- // A bare check mark in the subtitle line says nothing without its column.
678
- if (showsBareCheckMark(col.type, row.values[col.field], col.renderer))
679
- return col.label;
680
- return (_jsx(DataTableCell, { value: row.values[col.field], row: row.values, field: col.field, type: col.type, renderer: col.renderer, translate: tableTranslate, locale: tableLocale, ...(col.optionLabels !== undefined && { optionLabels: col.optionLabels }), ...(col.optionTones !== undefined && { optionTones: col.optionTones }), ...(col.grouping !== undefined && { grouping: col.grouping }), ...(col.fileDisplay !== undefined && { fileDisplay: col.fileDisplay, compactFile: true }), ...(onCellChange !== undefined && {
681
- onChange: (value) => onCellChange(row.id, col.field, value),
682
- }), ...cellLinkProp(col, row, !(rowIsLink && !rowClickIsHidden(row))) }));
683
- }
684
- function renderCard(row) {
685
- const metaColumns = cardMetaColumns.filter((col) => !isEmptyCellValue(row.values[col.field]) &&
686
- !isUnlabeledFalse(col.type, row.values[col.field], col.renderer));
687
- const showStatus = cardStatusColumn !== undefined && !isEmptyCellValue(row.values[cardStatusColumn.field]);
688
- const hasMenu = menuActions !== undefined && menuActions.length > 0;
689
- const opensOnClick = rowIsLink && !rowClickIsHidden(row);
690
- const body = (_jsxs(_Fragment, { children: [cardImageColumn?.fileDisplay !== undefined && (_jsx("div", { "data-testid": `card-image-${row.id}`, className: "shrink-0", children: _jsx(CardLeadingImage, { value: row.values[cardImageColumn.field], display: cardImageColumn.fileDisplay }) })), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-1", children: [_jsxs("div", { className: "flex min-w-0 items-start justify-between gap-2", children: [cardTitleColumn !== undefined && (_jsx("span", { "data-testid": getCellTestId?.(row, cardTitleColumn.field) ??
691
- `cell-${row.id}-${cardTitleColumn.field}`, className: "min-w-0 break-words text-base font-semibold text-foreground", children: cardCell(row, cardTitleColumn) })), showStatus && (_jsx("span", { "data-testid": getCellTestId?.(row, cardStatusColumn.field) ??
692
- `cell-${row.id}-${cardStatusColumn.field}`, className: "shrink-0", children: cardCell(row, cardStatusColumn) }))] }), metaColumns.length > 0 && (_jsx("div", { className: "min-w-0 overflow-x-clip", children: _jsx("div", { "data-testid": `card-meta-${row.id}`, className: "-ms-3 flex min-w-0 flex-wrap items-center text-[13px] leading-5 tabular-nums text-foreground-secondary", children: metaColumns.map((col) => (_jsxs("div", { className: "flex h-5 min-w-0 max-w-full items-center", children: [_jsx("span", { "aria-hidden": "true", className: "w-3 shrink-0 text-center", children: "\u00B7" }), _jsx("div", { "data-testid": getCellTestId?.(row, col.field) ?? `cell-${row.id}-${col.field}`, className: "min-w-0 truncate leading-5", onPointerEnter: showFullTextOnHover, children: isBadgeColumn(col) ? (_jsx("span", { className: "inline-flex align-middle", children: cardCell(row, col) })) : (cardCell(row, col)) })] }, col.field))) }) }))] }), opensOnClick && (_jsx(ChevronRight, { className: "size-4 shrink-0 text-muted-foreground", "aria-hidden": "true" }))] }));
693
- const rowItem = (_jsxs("li", { "data-testid": rowTestId(row), "data-tone": rowTone?.(row), className: cn("flex min-h-[72px] items-center gap-2 border-b border-border-row py-3", rowToneClass(rowTone?.(row)), isRowExpandable ? "pl-2" : "pl-4", hasMenu ? "pr-2" : "pr-4"), ...(opensOnClick && { onClick: () => onRowClick(row) }), children: [renderExpandToggle(row), opensOnClick ? (_jsx("button", { type: "button", className: "flex min-w-0 flex-1 cursor-pointer items-center gap-2 bg-transparent p-0 text-left", onKeyDown: (e) => {
694
- if (e.key !== "Enter")
695
- return;
696
- e.preventDefault();
697
- e.stopPropagation();
698
- onRowClick(row);
699
- }, children: body })) : (body), hasMenu && (_jsx("div", { onClick: (e) => e.stopPropagation(), onKeyDown: (e) => e.stopPropagation(), children: _jsx(RowActionsCell, { row: row, actions: menuActions, mode: rowActionMode, rowIsLink: rowIsLink, compact: true }) }))] }, row.id));
700
- if (!isRowExpandable)
701
- return rowItem;
702
- // The expansion is a sibling <li>, outside the row's click target, so
703
- // clicks in the sub-area never navigate the parent row.
704
- return (_jsxs(Fragment, { children: [rowItem, renderExpansion(row, "item", 0)] }, row.id));
705
- }
706
- function cardsInner() {
707
- if (isEmpty)
708
- return emptyBlock;
709
- const sortLabel = sort !== undefined && sort !== null
710
- ? (tableTranslate?.("kumiko.list.sort.by", {
711
- column: columns.find((col) => col.field === sort.field)?.label ?? sort.field,
712
- }) ?? `Sorted by ${sort.field}`)
713
- : (tableTranslate?.("kumiko.list.sort.unsorted") ?? "Unsorted");
714
- const SortArrow = sort?.dir === "desc" ? ArrowDown : ArrowUp;
715
- // URL state can carry a sort on a column that is not sortable; without an
716
- // option for it the select would claim "Unsorted" while the list is sorted.
717
- const currentSortValue = sort !== undefined && sort !== null ? `${sort.field}:${sort.dir}` : "";
718
- const currentSortIsUnlisted = sort !== undefined && sort !== null && !sortOptions.some((o) => o.value === currentSortValue);
719
- return (_jsx("div", { "data-testid": testId, className: "contents", children: _jsxs("div", { "data-testid": testId !== undefined ? `${testId}-cards` : "render-list-cards", className: "flex flex-col", children: [onSortChange !== undefined && sortableColumns.length > 0 && (_jsxs("div", { className: "relative flex h-9 items-center gap-1.5 px-4 text-[13px] text-foreground-secondary", children: [_jsx("span", { "aria-hidden": "true", children: sortLabel }), sort !== undefined && sort !== null && (_jsx(SortArrow, { className: "size-3.5", "aria-hidden": "true" })), _jsxs("select", { "aria-label": tableTranslate?.("kumiko.list.sort.label") ?? "Sort", "data-testid": testId !== undefined ? `${testId}-sort` : "render-list-sort", value: currentSortValue, onChange: (e) => {
720
- const raw = e.target.value;
721
- if (raw === "") {
722
- onSortChange(null);
723
- return;
724
- }
725
- const picked = sortOptions.find((o) => o.value === raw);
726
- if (picked === undefined)
727
- return;
728
- onSortChange({ field: picked.field, dir: picked.dir });
729
- }, className: "absolute inset-0 size-full cursor-pointer opacity-0", children: [_jsx("option", { value: "", children: tableTranslate?.("kumiko.list.sort.unsorted") ?? "Unsorted" }), currentSortIsUnlisted && (_jsx("option", { value: currentSortValue, children: `${columns.find((col) => col.field === sort.field)?.label ?? sort.field} ${sort.dir === "desc" ? "↓" : "↑"}` })), sortOptions.map((o) => (_jsx("option", { value: o.value, children: o.label }, o.value)))] })] })), _jsx("ul", { className: "m-0 list-none border-t border-border-row p-0", children: tableItems.map((item) => item.kind === "group" ? (_jsx("li", { "data-testid": `row-group-${item.key}`, className: "border-b border-border-row bg-muted/40", children: renderGroupToggle(item) }, `group-${item.key}`)) : (renderCard(item.row))) })] }) }));
730
- }
731
- const tableContent = isNarrow ? cardsInner() : tableInner();
732
- // Pager wird IMMER unter der Tabelle gerendert (auch bei rows=[]),
733
- // damit der User bei einem Filter-Hit-of-Zero zurückblättern kann
734
- // ohne die Liste zu verlieren. Außer total === 0 — dann gibt's
735
- // nichts zu paginieren. Inkompatibel mit Infinite-Scroll: Caller
736
- // setzt entweder pager ODER onReachEnd.
737
- const pagerElement = pager !== undefined && pager.total > 0 ? (_jsx(Pager, { page: pager.page, limit: pager.limit, total: pager.total, onPageChange: pager.onPageChange, ...(pager.pageSizeOptions !== undefined && { pageSizeOptions: pager.pageSizeOptions }), ...(pager.onPageSizeChange !== undefined && { onPageSizeChange: pager.onPageSizeChange }), ...(itemNoun !== undefined && { itemNoun }), testId: testId !== undefined ? `${testId}-pager` : "render-list-pager", pinned: fillsHeight })) : undefined;
738
- const sentinelElement = pagerElement === undefined && onReachEnd !== undefined ? (_jsx(InfiniteSentinel, { onReachEnd: onReachEnd, loadingMore: loadingMore === true, hasMore: hasMore !== false, rowsCount: rows.length, testId: testId !== undefined ? `${testId}-sentinel` : "render-list-sentinel" })) : undefined;
739
- // With scrollBody the footer (pager, else the entry count) sits OUTSIDE the
740
- // scroll surface so it stays put at any row count; the infinite sentinel
741
- // stays INSIDE, or it would never intersect the scrollport.
742
- const content = fillsHeight ? (_jsxs(_Fragment, { children: [_jsxs("div", { "data-testid": testId !== undefined ? `${testId}-scroll` : "render-list-scroll", className: "min-h-0 flex-1 overflow-auto border-t border-border bg-card", children: [tableContent, sentinelElement] }), pagerElement ?? (_jsx(DataTableFooter, { count: rows.length, ...(itemNoun !== undefined && { itemNoun }), testId: testId !== undefined ? `${testId}-footer` : "render-list-footer" }))] })) : (_jsxs(_Fragment, { children: [tableContent, pagerElement, sentinelElement] }));
743
- const hasFacets = filterFacets !== undefined && filterFacets.length > 0 && onFilterChange !== undefined;
744
- const hasDateRangeFacets = dateRangeFacets !== undefined && dateRangeFacets.length > 0 && onDateRangeChange !== undefined;
745
- const hasActiveFilters = filterValues !== undefined && Object.values(filterValues).some((v) => v.length > 0);
746
- const facetCluster = hasFacets || hasDateRangeFacets ? (_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [hasFacets &&
747
- filterFacets.map((facet) => (_jsx(FacetFilter, { facet: facet, selected: filterValues?.[facet.field] ?? [], onChange: onFilterChange, dense: fillsHeight }, facet.field))), hasDateRangeFacets &&
748
- dateRangeFacets.map((facet) => (_jsx(DateRangeFacetFilter, { facet: facet, onChange: onDateRangeChange }, facet.field))), hasActiveFilters && onFilterReset !== undefined && (_jsxs(UiButton, { variant: "ghost", size: "sm", className: "h-9 px-2", onClick: onFilterReset, "data-testid": "facet-reset", children: [tableTranslate?.("kumiko.list.filter.reset") ?? "Reset", _jsx(X, {})] }))] })) : undefined;
749
- const collapsesFacetsBehindButton = isNarrow && fillsHeight && facetCluster !== undefined;
750
- const hasToolbar = toolbarStart !== undefined || toolbarEnd !== undefined || facetCluster !== undefined;
751
- // dashboard-01-Muster: die Toolbar (Search + Facets + "+ Neu") sitzt ÜBER
752
- // der Tabelle im selben Padding-Block — kein separater bg-Bar, kein Screen-
753
- // Titel (der steht im Breadcrumb der Shell).
754
- return (_jsxs("div", { className: cn("flex flex-col w-full",
755
- // scrollBody: the wrapper fills its container so the footer sits at the
756
- // same spot at 3 and 300 rows; the 24px insets live in toolbar, cells
757
- // and footer, so no outer padding.
758
- fillsHeight
759
- ? "h-full min-h-0"
760
- : cn("gap-4", screenPadding === true
761
- ? screenPaddingClassName
762
- : screenPadding === false
763
- ? undefined
764
- : "p-6")), children: [hasToolbar && (_jsxs("div", { "data-testid": testId !== undefined ? `${testId}-toolbar` : "render-list-toolbar", className: cn("flex flex-wrap items-center", fillsHeight
765
- ? isNarrow
766
- ? "shrink-0 gap-2 p-3"
767
- : "min-h-13 shrink-0 gap-2 px-6 py-2 md:py-0"
768
- : "gap-3"), children: [toolbarStart !== undefined && (_jsxs("div", { className: cn(fillsHeight
769
- ? "relative w-full md:w-[300px] max-md:flex-1 [&_input]:border-border-strong [&_input]:pl-8 [&_input]:max-md:h-11 md:[&_input]:h-8"
770
- : "flex-1 min-w-48 max-w-sm"), children: [fillsHeight && (_jsx(Search, { className: "pointer-events-none absolute left-2.5 top-1/2 z-10 size-4 -translate-y-1/2 text-muted-foreground", "aria-hidden": "true" })), toolbarStart] })), collapsesFacetsBehindButton ? (_jsxs(_Fragment, { children: [_jsx(UiButton, { type: "button", variant: "outline", size: "icon", className: "size-11", "aria-label": tableTranslate?.("kumiko.list.filter.toggle") ?? "Filter", "aria-expanded": facetsOpenNarrow, "data-testid": testId !== undefined ? `${testId}-filter-toggle` : "filter-toggle", onClick: () => setFacetsOpenNarrow((open) => !open), children: _jsx(SlidersHorizontal, { className: "size-4", "aria-hidden": "true" }) }), facetsOpenNarrow && _jsx("div", { className: "w-full", children: facetCluster })] })) : (facetCluster), toolbarEnd !== undefined && (_jsx("div", { className: "flex flex-wrap items-center gap-2 ml-auto", children: toolbarEnd }))] })), toolbarDescription !== undefined && (_jsx("p", { "data-testid": testId !== undefined ? `${testId}-description` : "render-list-description", className: cn("m-0 pb-2 text-sm text-muted-foreground", fillsHeight && (isNarrow ? "px-3" : "px-6")), children: toolbarDescription })), content] }));
771
- }
772
- // The primary row action is the one that always stays a visible text
773
- // button — `edit` if declared, else the first visible action (fw
774
- // bedienkonzept L3: "Bearbeiten steht in jeder Zeile, immer, als Text").
775
- function primaryRowAction(actions) {
776
- return actions.find((a) => a.id === "edit") ?? actions[0];
777
- }
778
- // RowActionsCell renders the row actions depending on `mode`:
779
- // - "adaptive" (default): when the row itself is the link (`rowIsLink`)
780
- // every action sits in the kebab; otherwise the primary action (see
781
- // `primaryRowAction`) is a link-button and the rest goes into the kebab.
782
- // A single action never gets a kebab.
783
- // - "inline": ALWAYS inline buttons, left-aligned + full-width, even for
784
- // >2 (no kebab). `w-full justify-start` pins the first button to the
785
- // column's left edge so it sits at the same position across rows
786
- // (otherwise it would drift with differently-sized labels).
787
- // The isVisible filter runs here; an action hidden for a given row never
788
- // reaches render. If every action is hidden, the cell stays empty (no
789
- // phantom column).
790
- function RowActionsCell({ row, actions, mode = "adaptive", rowIsLink = false, compact = false, }) {
791
- const visible = actions.filter((a) => a.isVisible === undefined || a.isVisible(row));
792
- if (visible.length === 0)
793
- return null;
794
- const hasExplicitDisplay = visible.some((a) => a.display !== undefined);
795
- if (mode === "inline") {
796
- // Teil C: >2 actions all carrying an icon collapse to icon-only —
797
- // otherwise "inline" is exactly the wall-to-wall text-button problem
798
- // this feature exists to fix. An author-set `display` takes that choice over.
799
- const iconOnly = !hasExplicitDisplay && shouldRenderActionsIconOnly(visible);
800
- return (_jsx("div", { className: "flex w-full items-center gap-1 justify-start", children: visible.map((a) => (_jsx(RowActionButton, { row: row, action: a, iconOnly: iconOnly, compact: compact }, a.id))) }));
801
- }
802
- if (hasExplicitDisplay) {
803
- const inline = visible.filter((a) => a.display !== undefined);
804
- const rest = visible.filter((a) => a.display === undefined);
805
- return (_jsxs("div", { className: "inline-flex flex-wrap items-center gap-1 justify-end", children: [inline.map((a) => (_jsx(RowActionButton, { row: row, action: a, compact: compact }, a.id))), rest.length > 0 && _jsx(RowActionsKebab, { row: row, actions: rest })] }));
806
- }
807
- if (rowIsLink) {
808
- return (_jsx("div", { className: "inline-flex items-center justify-end", children: _jsx(RowActionsKebab, { row: row, actions: visible }) }));
809
- }
810
- const primary = primaryRowAction(visible);
811
- const rest = visible.filter((a) => a.id !== primary?.id);
812
- return (_jsxs("div", { className: "inline-flex flex-wrap items-center gap-1 justify-end", children: [primary !== undefined && _jsx(RowActionButton, { row: row, action: primary, asLink: true }), rest.length > 0 && _jsx(RowActionsKebab, { row: row, actions: rest })] }));
813
- }
814
- function isEmptyCellValue(value) {
815
- return (value === null || value === undefined || (typeof value === "string" && value.trim() === ""));
816
- }
817
- // A boolean false renders as an empty string unless the column's format spec names a falseLabel.
818
- // Projection lists synthesize every column as type "text", so a boolean format spec
819
- // marks a boolean column on its own.
820
- function isBooleanColumn(type, renderer) {
821
- if (type === "boolean")
822
- return true;
823
- return (typeof renderer === "object" &&
824
- renderer !== null &&
825
- "format" in renderer &&
826
- renderer.format === "boolean");
827
- }
828
- function isUnlabeledFalse(type, value, renderer) {
829
- if (value !== false || !isBooleanColumn(type, renderer))
830
- return false;
831
- if (renderer === undefined)
832
- return true;
833
- return (typeof renderer === "object" &&
834
- renderer !== null &&
835
- "format" in renderer &&
836
- renderer.format === "boolean" &&
837
- !("falseLabel" in renderer));
838
- }
839
- function isUnlabeledBooleanFormat(renderer) {
840
- return (typeof renderer === "object" &&
841
- renderer !== null &&
842
- "format" in renderer &&
843
- renderer.format === "boolean" &&
844
- !("trueLabel" in renderer) &&
845
- !("falseLabel" in renderer));
846
- }
847
- function BooleanCell({ value, translate, }) {
848
- const label = value
849
- ? (translate?.("kumiko.boolean.yes") ?? "Yes")
850
- : (translate?.("kumiko.boolean.no") ?? "No");
851
- return (_jsxs("span", { "data-boolean-cell": value ? "true" : "false", children: [value ? (_jsx(Check, { "aria-hidden": "true", className: "h-4 w-4" })) : (_jsx("span", { "aria-hidden": "true", className: "text-muted-foreground", children: EMPTY_VALUE_PLACEHOLDER })), _jsx("span", { className: "sr-only", children: label })] }));
852
- }
853
- // True when the cell would render the default "✓": no renderer, or a boolean
854
- // format spec that names no trueLabel of its own.
855
- function showsBareCheckMark(type, value, renderer) {
856
- if (value !== true || !isBooleanColumn(type, renderer))
857
- return false;
858
- if (renderer === undefined)
859
- return true;
860
- return (typeof renderer === "object" &&
861
- renderer !== null &&
862
- "format" in renderer &&
863
- !("trueLabel" in renderer));
864
- }
865
- // Badges (select pills, component renderers) must never be clipped by the card's meta truncation.
866
- function isBadgeColumn(col) {
867
- return (col.type === "select" ||
868
- col.type === "multiSelect" ||
869
- isComponentRendererRef(col.renderer) !== undefined);
870
- }
871
- // The first cell is the keyboard-reachable entry point of a clickable row; the
872
- // row's own onClick keeps serving the mouse. Enter is handled explicitly so
873
- // it also opens the row where a button does not synthesize a click.
874
- function FirstCellLink({ enabled, empty, emptyLabel, onOpen, children, }) {
875
- if (!enabled || (empty && emptyLabel === undefined))
876
- return children;
877
- return (_jsx("button", { type: "button", className: "max-w-full cursor-pointer truncate bg-transparent p-0 text-left font-medium text-foreground hover:underline focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", onClick: (e) => {
878
- e.stopPropagation();
879
- onOpen();
880
- }, onKeyDown: (e) => {
881
- if (e.key !== "Enter")
882
- return;
883
- e.preventDefault();
884
- e.stopPropagation();
885
- onOpen();
886
- }, children: empty ? _jsx("span", { className: "sr-only", children: emptyLabel }) : children }));
887
- }
888
- // Shared trigger-State zwischen Inline-Button + Kebab-Item: busy-Flag
889
- // (während async onTrigger läuft) + confirm-pending-Action. Beide Sub-
890
- // Components hatten denselben State-Block dupliziert + parallel zur
891
- // Confirm-Dialog-Render-Logic — der Hook konsolidiert das.
892
- function useRowActionTrigger(row) {
893
- const [busy, setBusy] = useState(false);
894
- const { toast } = useToast();
895
- const t = useTranslation();
896
- const triggerNow = async (action) => {
897
- setBusy(true);
898
- try {
899
- await action.onTrigger(row);
900
- }
901
- catch (e) {
902
- // Surfacing statt schlucken: ein verschluckter Write-Fehler sah für
903
- // den User wie "nichts passiert" aus (Prod-Bug 2026-06-07).
904
- const dispatcherError = e instanceof WriteFailedError ? e.dispatcherError : undefined;
905
- const docsUrl = dispatcherError?.docsUrl;
906
- const requestId = dispatcherError?.requestId;
907
- toast({
908
- title: t("kumiko.rowAction.failed"),
909
- description: e instanceof Error ? e.message : String(e),
910
- variant: "bad",
911
- ...(docsUrl !== undefined && { docsUrl }),
912
- ...(requestId !== undefined && { requestId }),
913
- });
914
- }
915
- finally {
916
- setBusy(false);
917
- }
918
- };
919
- return { busy, triggerNow };
920
- }
921
- // An icon-only form without a resolvable icon would render an empty button.
922
- // `compact` is the narrow card layout, where "responsive" collapses to its icon.
923
- function effectiveRowActionDisplay(display, hasIcon, compact) {
924
- if (display === "responsive")
925
- return hasIcon && compact ? "icon" : "button";
926
- if (display === "icon" && !hasIcon)
927
- return "link";
928
- return display;
929
- }
930
- function RowActionButton({ row, action, iconOnly = false, asLink = false, compact = false, }) {
931
- const { busy, triggerNow } = useRowActionTrigger(row);
932
- const [confirmOpen, setConfirmOpen] = useState(false);
933
- const resolvedIcon = actionIconFor(action.icon);
934
- const display = effectiveRowActionDisplay(action.display, resolvedIcon !== undefined, compact);
935
- const isBorderedButton = display === "button";
936
- const showIconOnly = display === "icon" || (display === undefined && iconOnly && resolvedIcon !== undefined);
937
- const linkLike = display === "link" || (display === undefined && asLink);
938
- const variantClass = isBorderedButton
939
- ? action.style === "primary"
940
- ? "border border-transparent bg-primary text-primary-foreground hover:bg-primary/90"
941
- : action.style === "danger"
942
- ? "border border-destructive/40 bg-card text-destructive hover:bg-destructive/10"
943
- : "border border-border-strong bg-card text-foreground hover:bg-accent"
944
- : action.style === "danger"
945
- ? "text-destructive hover:bg-destructive/10"
946
- : linkLike
947
- ? "font-medium text-primary hover:bg-muted"
948
- : action.style === "primary"
949
- ? "text-primary hover:bg-primary/10"
950
- : "text-foreground hover:bg-accent";
951
- return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", "data-testid": `row-${row.id}-action-${action.id}`, disabled: busy, ...(showIconOnly && { "aria-label": action.label, title: action.label }), onClick: (e) => {
952
- e.stopPropagation();
953
- if (needsActionConfirm(action)) {
954
- setConfirmOpen(true);
955
- }
956
- else {
957
- void triggerNow(action);
958
- }
959
- }, className: cn("inline-flex items-center justify-center gap-1.5 text-sm", isBorderedButton
960
- ? "h-8 rounded-md px-3 font-medium max-md:h-11"
961
- : linkLike
962
- ? "h-7 rounded-md px-2.5 max-md:h-11"
963
- : "h-8 rounded-sm max-md:h-11", showIconOnly ? "w-8 max-md:w-11" : !linkLike && !isBorderedButton && "px-2", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", "disabled:opacity-50 disabled:pointer-events-none", variantClass), children: busy ? (_jsx(Loader2, { className: "size-3.5 animate-spin", "aria-hidden": "true" })) : resolvedIcon === undefined || (linkLike && !showIconOnly) ? (action.label) : showIconOnly ? (_jsx(Icon, { name: resolvedIcon, className: "size-4" })) : (_jsxs(_Fragment, { children: [_jsx(Icon, { name: resolvedIcon, className: "size-4" }), action.label] })) }), _jsx(DefaultDialog, { open: confirmOpen, onOpenChange: setConfirmOpen, title: action.label, ...(action.confirm !== undefined && { description: action.confirm }), confirmLabel: action.confirmLabel ?? action.label, ...(action.style === "danger" && { variant: "danger" }), onConfirm: () => triggerNow(action), testId: `row-${row.id}-action-${action.id}-dialog` })] }));
964
- }
965
- // Kebab dropdown for >2 actions, one inline confirm dialog per item like
966
- // the inline-button path. Menu is controlled so it closes explicitly on
967
- // select, instead of relying on Radix's auto-close, which preventDefault
968
- // below blocks.
969
- function RowActionsKebab({ row, actions, }) {
970
- const { triggerNow } = useRowActionTrigger(row);
971
- const t = useTranslation();
972
- const [pendingConfirm, setPendingConfirm] = useState(null);
973
- const [menuOpen, setMenuOpen] = useState(false);
974
- return (_jsxs(_Fragment, { children: [_jsxs(DropdownMenu, { open: menuOpen, onOpenChange: setMenuOpen, children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx("button", { type: "button", "aria-label": t("kumiko.list.row-actions.more"), "data-testid": `row-${row.id}-actions-menu`, className: cn("inline-flex size-7 items-center justify-center rounded-md max-md:size-11", "hover:bg-accent text-muted-foreground hover:text-foreground", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"), children: _jsx(MoreHorizontal, { className: "size-4", "aria-hidden": "true" }) }) }), _jsx(DropdownMenuContent, { align: "end",
975
- // Radix refocuses the trigger after the exit animation, which by then sits behind the confirm dialog's aria-hidden.
976
- onCloseAutoFocus: (e) => {
977
- if (pendingConfirm !== null)
978
- e.preventDefault();
979
- }, children: actions.map((action) => (_jsx(DropdownMenuItem, { "data-testid": `row-${row.id}-action-${action.id}`, onSelect: (e) => {
980
- e.preventDefault();
981
- setMenuOpen(false);
982
- if (needsActionConfirm(action)) {
983
- setPendingConfirm(action);
984
- }
985
- else {
986
- void triggerNow(action);
987
- }
988
- }, className: cn(action.style === "danger" && "text-destructive focus:text-destructive"), children: action.label }, action.id))) })] }), pendingConfirm !== null && (_jsx(DefaultDialog, { open: true, onOpenChange: (open) => {
989
- if (!open)
990
- setPendingConfirm(null);
991
- }, title: pendingConfirm.label, ...(pendingConfirm.confirm !== undefined && { description: pendingConfirm.confirm }), confirmLabel: pendingConfirm.confirmLabel ?? pendingConfirm.label, ...(pendingConfirm.style === "danger" && { variant: "danger" }), onConfirm: async () => {
992
- const action = pendingConfirm;
993
- setPendingConfirm(null);
994
- await triggerNow(action);
995
- }, testId: `row-${row.id}-action-${pendingConfirm.id}-dialog` }))] }));
996
- }
997
- // Header/row-actions overflow menu (A7): the trigger carries the classes of a
998
- // secondary icon Button so it matches neighbouring icon buttons, including
999
- // the 44 px touch size on phones. Generic ActionMenuItemSpec items instead of
1000
- // the DataTableRowAction schema (callers own confirm/danger handling per item).
1001
- function ActionOverflowMenu({ items, label, testId }) {
1002
- const [open, setOpen] = useState(false);
1003
- return (_jsxs(DropdownMenu, { open: open, onOpenChange: setOpen, children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(UiButton, { type: "button", variant: "outline", size: "icon", className: SECONDARY_BUTTON_EXTRA_CLASSES, "aria-label": label, "data-testid": testId ?? "action-overflow-menu-trigger", children: _jsx(MoreHorizontal, { className: "size-4", "aria-hidden": "true" }) }) }), _jsx(DropdownMenuContent, { align: "end", children: items.map((item) => {
1004
- const resolvedIcon = actionIconFor(item.icon);
1005
- return (_jsxs(DropdownMenuItem, { "data-testid": item.testId ?? `${testId ?? "action-overflow-menu"}-item-${item.id}`, disabled: item.disabled === true, onSelect: (e) => {
1006
- e.preventDefault();
1007
- setOpen(false);
1008
- item.onSelect();
1009
- }, className: cn(item.variant === "danger" && "text-destructive focus:text-destructive"), children: [resolvedIcon !== undefined && _jsx(Icon, { name: resolvedIcon, className: "size-4" }), item.label] }, item.id));
1010
- }) })] }));
1011
- }
1012
- // InfiniteSentinel — empty div at the end of the table that uses
1013
- // IntersectionObserver to detect when the user scrolls near the list end.
1014
- // onReachEnd fires exactly once per "becomes visible" transition; the
1015
- // caller debounces via loadingMore (further visibility events are ignored
1016
- // while a page is loading). No observer during server-side render, no
1017
- // observer when hasMore=false — then the sentinel only shows the
1018
- // end-of-list hint via t("kumiko.list.end-of-list"), which requires a
1019
- // LocaleProvider in the tree (same requirement as Field/useRowActionTrigger).
1020
- // Without one, useTranslation() throws; with one that's missing the framework
1021
- // defaults (fallbackBundles not including kumikoDefaultTranslations), the
1022
- // user sees the raw key instead of translated text.
1023
- // ponytail: threshold mirrors the framework default pageSize (kumiko-screen.tsx
1024
- // `screen.pageSize ?? 50`) rather than the real per-screen pageSize, which isn't
1025
- // threaded down to this component. A screen with a custom pageSize can still
1026
- // under- or over-show the marker; thread pageSize through KumikoScreen →
1027
- // RenderList → DataTable → InfiniteSentinel if that's needed (fw#1713).
1028
- export const END_LABEL_MIN_ROWS = 50;
1029
- function InfiniteSentinel({ onReachEnd, loadingMore, hasMore, rowsCount, testId, }) {
1030
- const t = useTranslation();
1031
- const ref = useRef(null);
1032
- useEffect(() => {
1033
- if (!hasMore)
1034
- return;
1035
- if (loadingMore)
1036
- return;
1037
- if (typeof IntersectionObserver === "undefined")
1038
- return;
1039
- const node = ref.current;
1040
- if (node === null)
1041
- return;
1042
- const observer = new IntersectionObserver((entries) => {
1043
- // Nur das erste sichtbar-Event pro Mount auslösen — wenn der
1044
- // User weiter scrollt während noch geladen wird, hindert
1045
- // loadingMore=true den useEffect dass er den Observer überhaupt
1046
- // erst remountet.
1047
- for (const entry of entries) {
1048
- if (entry.isIntersecting) {
1049
- onReachEnd();
1050
- break;
1051
- }
1052
- }
1053
- }, { rootMargin: "200px" });
1054
- observer.observe(node);
1055
- return () => observer.disconnect();
1056
- }, [onReachEnd, loadingMore, hasMore]);
1057
- const showEndLabel = !hasMore && rowsCount >= END_LABEL_MIN_ROWS;
1058
- return (_jsx("div", { ref: ref, "data-testid": testId, className: cn("flex items-center justify-center text-sm text-muted-foreground", showEndLabel || loadingMore ? "py-4" : ""), children: showEndLabel ? (_jsx("span", { "data-testid": testId !== undefined ? `${testId}-end` : undefined, children: t("kumiko.list.end-of-list") })) : loadingMore ? (_jsx(Loader2, { className: "size-4 animate-spin", "aria-hidden": "true" })) : hasMore ? (_jsx("span", { "aria-hidden": "true", children: "\u00A0" })) : null }));
1059
- }
1060
- // Pager — status text on the left ("X – Y of Z"), "Page X of Y" and the
1061
- // prev/next arrows on the right. No numbered page list: the board shows none.
1062
- // `pinned` renders the footer bar of a scrollBody table (fixed height, top
1063
- // border); unpinned it sits as a plain row below the table.
1064
- //
1065
- // Status text and aria-labels go through t(...), which requires a
1066
- // LocaleProvider in the tree — same requirement as InfiniteSentinel above,
1067
- // already established for DataTable consumers.
1068
- function Pager({ page, limit, total, onPageChange, pageSizeOptions, onPageSizeChange, itemNoun, testId, pinned, }) {
1069
- const t = useTranslation();
1070
- const totalPages = Math.max(1, Math.ceil(total / limit));
1071
- const safePage = Math.max(1, Math.min(page, totalPages));
1072
- const from = (safePage - 1) * limit + 1;
1073
- const to = Math.min(safePage * limit, total);
1074
- return (_jsxs("div", { "data-testid": testId, className: pinned === true
1075
- ? TABLE_FOOTER_BAR_CLASS
1076
- : "mt-3 flex items-center gap-4 text-[13px] tabular-nums text-foreground-secondary", children: [_jsx("div", { "data-testid": testId !== undefined ? `${testId}-status` : undefined, className: "flex-1", children: itemNoun !== undefined
1077
- ? t("kumiko.pager.status.noun", {
1078
- from: from.toLocaleString(),
1079
- to: to.toLocaleString(),
1080
- total: total.toLocaleString(),
1081
- noun: itemNoun(total),
1082
- })
1083
- : t("kumiko.pager.status", {
1084
- from: from.toLocaleString(),
1085
- to: to.toLocaleString(),
1086
- total: total.toLocaleString(),
1087
- }) }), pageSizeOptions !== undefined && onPageSizeChange !== undefined && (_jsxs("div", { className: "relative hidden md:block", children: [_jsx("select", { "aria-label": t("kumiko.pager.pageSizeLabel"), "data-testid": testId !== undefined ? `${testId}-page-size` : undefined, value: limit, onChange: (e) => onPageSizeChange(Number(e.target.value)), className: "h-7 appearance-none rounded-md border border-input bg-card pl-2 pr-7 text-[13px] text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", children: pageSizeOptions.map((size) => (_jsx("option", { value: size, children: t("kumiko.pager.pageSize", { size: size.toLocaleString() }) }, size))) }), _jsx(ChevronDown, { className: "pointer-events-none absolute right-2 top-1/2 size-4 -translate-y-1/2 opacity-50", "aria-hidden": "true" })] })), _jsx("div", { className: "hidden md:block", children: t("kumiko.pager.pageOf", {
1088
- page: safePage.toLocaleString(),
1089
- pages: totalPages.toLocaleString(),
1090
- }) }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(PagerButton, { ariaLabel: t("kumiko.pager.previousPage"), disabled: safePage <= 1, onClick: () => onPageChange(safePage - 1), testId: testId !== undefined ? `${testId}-prev` : undefined, children: _jsx(ChevronLeft, { className: "size-3.5", "aria-hidden": "true" }) }), _jsx(PagerButton, { ariaLabel: t("kumiko.pager.nextPage"), disabled: safePage >= totalPages, onClick: () => onPageChange(safePage + 1), testId: testId !== undefined ? `${testId}-next` : undefined, children: _jsx(ChevronRight, { className: "size-3.5", "aria-hidden": "true" }) })] })] }));
1091
- }
1092
- function PagerButton({ children, onClick, ariaLabel, disabled, testId, }) {
1093
- return (_jsx("button", { type: "button", onClick: onClick, disabled: disabled, "aria-label": ariaLabel, "data-testid": testId, className: cn("inline-flex size-11 items-center justify-center rounded-md border border-border md:size-7", "hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", "disabled:pointer-events-none disabled:text-foreground-disabled"), children: children }));
1094
- }
1095
- // SortableHeader — rendert pro Spalte den th-Header, mit oder ohne
1096
- // Click-Sort. Drei Pfade:
1097
- // (a) sortable=false ODER kein onSortChange → plain Label, keine
1098
- // Cursor-Interaktion (DataTable rein als View ohne Sort-Wiring).
1099
- // (b) sortable=true + onSortChange → Header ist ein Button, klick
1100
- // cycled asc → desc → null. aria-sort spiegelt den State.
1101
- // (c) sortable=true im Schema, aber keine onSortChange-Prop → still
1102
- // label-only, aber data-sortable=true bleibt damit Tests die
1103
- // Schema-Sicht kennen.
1104
- // w-px on an auto-layout table cell shrinks the column to its content (the
1105
- // actions column relies on the same behaviour).
1106
- const FIT_CONTENT_CELL_CLASS = "w-px";
1107
- function SortableHeader({ field, label, sortable, highlighted, numeric, fitContent, sort, onSortChange, }) {
1108
- const active = sort?.field === field ? sort : undefined;
1109
- const ariaSort = active?.dir === "asc" ? "ascending" : active?.dir === "desc" ? "descending" : "none";
1110
- if (!sortable || onSortChange === undefined) {
1111
- return (_jsx(TableHead, { "data-testid": `column-${field}`, "data-sortable": sortable === true ? true : undefined, "data-highlighted": highlighted === true ? "true" : undefined, className: cn("font-medium whitespace-normal text-muted-foreground", numeric === true && "text-right", fitContent === true && FIT_CONTENT_CELL_CLASS, highlighted === true && "bg-accent/40"), children: label }));
1112
- }
1113
- const Icon = active?.dir === "asc" ? ArrowUp : active?.dir === "desc" ? ArrowDown : ArrowUpDown;
1114
- const next = nextSortState(active?.dir, field);
1115
- return (_jsx(TableHead, { "data-testid": `column-${field}`, "data-sortable": "true", "data-highlighted": highlighted === true ? "true" : undefined, "aria-sort": ariaSort, className: cn(active !== undefined
1116
- ? "font-semibold text-foreground"
1117
- : "font-medium text-muted-foreground", "whitespace-normal", numeric === true && "text-right", fitContent === true && FIT_CONTENT_CELL_CLASS, highlighted === true && "bg-accent/40"), children: _jsxs("button", { type: "button", onClick: () => onSortChange(next), className: cn("group/sort inline-flex h-8 items-center gap-1.5 rounded-sm px-2 -mx-2 text-sm [font-weight:inherit]", "hover:bg-accent hover:text-accent-foreground", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", active !== undefined && "text-foreground"), children: [_jsx("span", { children: label }), _jsx(Icon, { "data-sort-icon": active === undefined ? "idle" : "active", className: cn("size-3.5", active === undefined &&
1118
- "opacity-0 group-hover/sort:opacity-40 group-focus-visible/sort:opacity-40"), "aria-hidden": "true" })] }) }));
1119
- }
1120
- // 3-State-Toggle: kein Sort → asc → desc → kein Sort. Idiomatisch für
1121
- // Power-User-Listen wo "ich will die Server-Default-Order zurück" eine
1122
- // echte Aktion ist (statt unendlich asc↔desc zu togglen).
1123
- function nextSortState(current, field) {
1124
- if (current === undefined)
1125
- return { field, dir: "asc" };
1126
- if (current === "asc")
1127
- return { field, dir: "desc" };
1128
- return null;
1129
- }
1130
- // Type-guard für die `{ react: { __component: "Name" } }`-Form, in der
1131
- // PlatformComponent-Renderer im Schema serialisiert ankommen. Schemas
1132
- // reisen über die Wire (Server → Client), echte Component-Refs würden
1133
- // das brechen — der String-Key ist die SSoT.
1134
- export function isComponentRendererRef(renderer) {
1135
- if (renderer === null || typeof renderer !== "object")
1136
- return undefined;
1137
- const reactBranch = propertyOf(renderer, "react");
1138
- if (reactBranch === null || typeof reactBranch !== "object")
1139
- return undefined;
1140
- const component = propertyOf(reactBranch, "__component");
1141
- if (typeof component !== "string" || component.length === 0)
1142
- return undefined;
1143
- return { name: component };
1144
- }
1145
- // applyFormatSpec re-exported from headless (platform-agnostic).
1146
- export { applyFormatSpec };
1147
- function isMoneyValue(value) {
1148
- const currency = propertyOf(value, "currency");
1149
- return (typeof value === "object" &&
1150
- value !== null &&
1151
- typeof propertyOf(value, "amount") === "number" &&
1152
- typeof currency === "string" &&
1153
- /^[A-Za-z]{3}$/.test(currency));
1154
- }
1155
- // Type-spezifische Default-Cell-Renderer. Author kann pro Spalte einen
1156
- // expliziten renderer setzen (FormatSpec oder PlatformComponent); ohne
1157
- // expliziten renderer fällt DataTableCell hier durch.
1158
- //
1159
- // - boolean → ✓ / leer
1160
- // - timestamp/date → locale-formatiert (kein roher ISO-String)
1161
- // - number/decimal/bigInt → locale-formatted via Intl.NumberFormat (fw#2160)
1162
- // - select/multiSelect → human-readable (kebab-case → Title Case), multiSelect values joined with ", "
1163
- // - money → { amount, currency } formatted via Intl (not "[object Object]")
1164
- // - text/else → toString
1165
- const ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/;
1166
- // Deduplicated per column key only (not per screen): a once-per-field-name
1167
- // hint, so a second screen reusing the same field name stays silent.
1168
- const warnedTimestampColumns = new Set();
1169
- // A raw ISO string in a "text" column means the author forgot `renderer:
1170
- // { format: "timestamp" }` — guessing a format here could be wrong, so we
1171
- // warn and leave the value as-is instead of auto-formatting.
1172
- function warnMissingTimestampFormat(columnKey) {
1173
- if (typeof process === "undefined" || process.env.NODE_ENV === "production")
1174
- return;
1175
- const key = columnKey ?? "<unknown column>";
1176
- if (warnedTimestampColumns.has(key))
1177
- return;
1178
- warnedTimestampColumns.add(key);
1179
- // biome-ignore lint/suspicious/noConsole: dev-only assertion
1180
- console.warn(`[kumiko] column "${key}" renders a raw ISO timestamp as text — add renderer: { format: "timestamp" } to its column definition.`);
1181
- }
1182
- export function defaultCellRender(value, type, optionLabels, locale, columnKey, grouping = true) {
1183
- if (value === null || value === undefined || value === "")
1184
- return "";
1185
- if (type === "boolean")
1186
- return value === true ? "✓" : "";
1187
- if (type === "timestamp" || type === "date") {
1188
- return applyFormatSpec({ format: type, locale }, value);
1189
- }
1190
- if (type === "number" || type === "decimal" || type === "bigInt") {
1191
- return applyFormatSpec({ format: type, locale, grouping }, value);
1192
- }
1193
- if (type === "money") {
1194
- if (!isMoneyValue(value))
1195
- return String(value);
1196
- // formatMoney expects minor units scaled by currencyDecimals(currency).
1197
- // rehydrateMoney's `amountMinor` is scaled by a flat MINOR_UNIT_SCALE=100
1198
- // instead, which disagrees with currencyDecimals for non-2-decimal
1199
- // currencies (JPY: 0 decimals → 100x too high; BHD: 3 decimals → 10x too
1200
- // low). Deriving minor units from `amount` keeps this consistent with
1201
- // render-field.tsx's moneyMinorValue.
1202
- const minor = Math.round(value.amount * 10 ** currencyDecimals(value.currency));
1203
- return formatMoney(minor, value.currency, locale);
1204
- }
1205
- if (type === "select" || type === "multiSelect") {
1206
- const values = Array.isArray(value) ? value : [value];
1207
- return values
1208
- .map((v) => {
1209
- const raw = String(v);
1210
- // Translated label from the view-model builder (convention key
1211
- // `<feature>:entity:<entity>:field:<field>:option:<value>`).
1212
- // Fallback to humanizeSlug when no label is registered — same
1213
- // behavior as before the optionLabels patch.
1214
- const translated = optionLabels?.[raw];
1215
- return translated !== undefined && translated !== raw ? translated : humanizeSlug(raw);
1216
- })
1217
- .join(", ");
1218
- }
1219
- if (type === "text" && typeof value === "string" && ISO_DATETIME_RE.test(value)) {
1220
- warnMissingTimestampFormat(columnKey);
1221
- }
1222
- return typeof value === "string" ? value : String(value);
1223
- }
1224
- function humanizeSlug(slug) {
1225
- // "degraded-performance" → "Degraded performance". A dotted value such as
1226
- // "mobile.de" is a domain, not a slug, and must stay as stored.
1227
- if (slug.includes("."))
1228
- return slug;
1229
- const spaced = slug.replace(/[-_]/g, " ");
1230
- return spaced.charAt(0).toUpperCase() + spaced.slice(1);
1231
- }
1232
- // Tooltip-Text für truncated Cells — bei Hover zeigt der Browser den
1233
- // vollen Text. Skipping für Object/Array (das ist nicht user-readable);
1234
- // Number stringifyt der Browser ohnehin korrekt; booleans get no raw true/false tooltip.
1235
- function cellTitle(value) {
1236
- if (value === null || value === undefined)
1237
- return undefined;
1238
- if (typeof value === "string")
1239
- return value.length > 0 ? value : undefined;
1240
- if (typeof value === "number")
1241
- return String(value);
1242
- return undefined;
1243
- }
1244
- function selectPill(value, optionLabels, optionTones, locale, RegisteredStatusBadge) {
1245
- const label = defaultCellRender(value, "select", optionLabels, locale);
1246
- const declaredTone = typeof value === "string" && optionTones && Object.hasOwn(optionTones, value)
1247
- ? optionTones[value]
1248
- : undefined;
1249
- const tone = (declaredTone !== undefined ? statusToneForOptionTone(declaredTone) : undefined) ??
1250
- (typeof value === "string" ? statusToneForValue(value) : undefined);
1251
- if (tone !== undefined) {
1252
- // Same registered primitive as the projectionDetail header, so an app-provided badge applies to list cells too.
1253
- if (RegisteredStatusBadge !== undefined) {
1254
- return _jsx(RegisteredStatusBadge, { value: label, tone: tone });
1255
- }
1256
- return _jsx(StatusBadge, { tone: tone, children: label });
1257
- }
1258
- // dashboard-01 pattern: outline badge + muted instead of a filled secondary.
1259
- return (_jsx(Badge, { variant: "outline", className: "px-1.5 text-muted-foreground", children: label }));
1260
- }
1261
- // A multi reference may list the same id twice; the occurrence count keeps React keys unique.
1262
- function uniqueLinkItemKeys(items) {
1263
- const seen = new Map();
1264
- return items.map((item) => {
1265
- const occurrence = seen.get(item.id) ?? 0;
1266
- seen.set(item.id, occurrence + 1);
1267
- return `${item.id}#${occurrence}`;
1268
- });
1269
- }
1270
- // A link nested in the clickable first-cell button or card button would be an
1271
- // <a> inside a <button>, so those cells stay plain text.
1272
- function cellLinkProp(col, row, allowed) {
1273
- if (!allowed)
1274
- return {};
1275
- const link = col.linkFor?.(row.values[col.field], row.values);
1276
- const linkItems = col.linkItemsFor?.(row.values[col.field], row.values);
1277
- return {
1278
- ...(link !== undefined && { link }),
1279
- ...(linkItems !== undefined && { linkItems }),
1280
- };
1281
- }
1282
- function buildTableItems(rows, grouping, toggledGroups) {
1283
- if (grouping === undefined)
1284
- return rows.map((row) => ({ kind: "row", row }));
1285
- const groups = new Map();
1286
- for (const row of rows) {
1287
- const key = grouping.keyOf(row);
1288
- const members = groups.get(key);
1289
- if (members === undefined)
1290
- groups.set(key, [row]);
1291
- else
1292
- members.push(row);
1293
- }
1294
- return [...groups].flatMap(([key, members]) => {
1295
- const label = grouping.headerLabel(key, members);
1296
- if (label === undefined)
1297
- return members.map((row) => ({ kind: "row", row }));
1298
- const collapsed = grouping.startsCollapsed(key) !== toggledGroups.has(key);
1299
- return [
1300
- { kind: "group", key, label, collapsed },
1301
- ...(collapsed ? [] : members.map((row) => ({ kind: "row", row }))),
1302
- ];
1303
- });
1304
- }
1305
- const ROW_TONE_CLASS = {
1306
- bad: "bg-status-bad/10 hover:bg-status-bad/15",
1307
- warn: "bg-status-warn/10 hover:bg-status-warn/15",
1308
- ok: "bg-status-ok/10 hover:bg-status-ok/15",
1309
- neutral: "",
1310
- };
1311
- function rowToneClass(tone) {
1312
- return tone === undefined ? "" : ROW_TONE_CLASS[tone];
1313
- }
1314
- const NUMERIC_COLUMN_TYPES = new Set(["number", "decimal", "bigInt", "money"]);
1315
- // Digits stand in columns here, so they need equal-width figures; the body no longer sets tabular-nums.
1316
- const TABULAR_COLUMN_TYPES = new Set([
1317
- ...NUMERIC_COLUMN_TYPES,
1318
- "date",
1319
- "timestamp",
1320
- "locatedTimestamp",
1321
- ]);
1322
- // A plain <a>, not DefaultLink: the click must not reach the row's own click
1323
- // handler, which opens the same record's row action.
1324
- function CellTextLink({ link, children, }) {
1325
- return (_jsx("a", { href: isSafeHref(link.href) ? link.href : "#", className: "text-primary underline-offset-4 hover:underline", onClick: (event) => {
1326
- event.stopPropagation();
1327
- if (!isPlainPrimaryClick(event))
1328
- return;
1329
- event.preventDefault();
1330
- link.onPress();
1331
- }, children: children }));
1332
- }
1333
- // Cell-Renderer als Component (statt reiner Funktion) damit der
1334
- // useColumnRenderer-Hook aus dem Provider lesen kann. Die vier Pfade:
1335
- // 1. FormatSpec (`{ format: "timestamp" }` etc.) → applyFormatSpec.
1336
- // 2. RuntimeRenderer (Funktion) → direkter Aufruf. Nur für render-list-
1337
- // interne Reference-Lookup-Closures — niemals aus dem serialisierten Schema.
1338
- // 3. PlatformComponent (`{ react: { __component: "X" } }`) → schaut
1339
- // "X" über useColumnRenderer auf und rendert `<X value row column/>`.
1340
- // Nicht registriert → einmalige Warnung + Default-Fallback.
1341
- // 4. Sonst → defaultCellRender (Type-basierter String-Renderer).
1342
- function DataTableCell({ value, row, field, type, renderer, optionLabels, optionTones, onChange, translate, locale, grouping = true, fileDisplay, compactFile = false, link, linkItems, }) {
1343
- const componentRef = isComponentRendererRef(renderer);
1344
- const ResolvedComponent = useColumnRenderer(componentRef?.name);
1345
- const RegisteredStatusBadge = useOptionalPrimitives()?.StatusBadge;
1346
- if (isEmptyCellValue(value) && typeof renderer !== "function" && componentRef === undefined) {
1347
- return (_jsx("span", { "data-empty-cell": "true", className: "text-muted-foreground", children: EMPTY_VALUE_PLACEHOLDER }));
1348
- }
1349
- if (value === PII_ERASED_SENTINEL && translate !== undefined) {
1350
- return translate("kumiko.pii.erased");
1351
- }
1352
- if (typeof value === "boolean" &&
1353
- isBooleanColumn(type, renderer) &&
1354
- (renderer === undefined || isUnlabeledBooleanFormat(renderer))) {
1355
- return _jsx(BooleanCell, { value: value, translate: translate });
1356
- }
1357
- if (isUnlabeledFalse(type, value, renderer)) {
1358
- return (_jsx("span", { "data-empty-cell": "true", className: "text-muted-foreground", children: EMPTY_VALUE_PLACEHOLDER }));
1359
- }
1360
- if (fileDisplay !== undefined) {
1361
- return (_jsx(FileDisplayCell, { value: value, display: fileDisplay, compact: compactFile, fallbackLabel: translate?.("kumiko.field.file-upload.attached") ?? "File attached" }));
1362
- }
1363
- if (typeof renderer === "object" && renderer !== null && "format" in renderer) {
1364
- const spec = renderer;
1365
- return applyFormatSpec({ ...spec, locale: spec.locale ?? locale }, value, translate);
1366
- }
1367
- if (typeof renderer === "function") {
1368
- if (linkItems !== undefined) {
1369
- const linkItemKeys = uniqueLinkItemKeys(linkItems);
1370
- return (_jsx(_Fragment, { children: linkItems.map((item, index) => (_jsxs(Fragment, { children: [index > 0 && ", ", item.link === undefined ? (item.label) : (_jsx(CellTextLink, { link: item.link, children: item.label }))] }, linkItemKeys[index]))) }));
1371
- }
1372
- const fn = renderer;
1373
- const text = fn(value, row);
1374
- return link === undefined ? text : _jsx(CellTextLink, { link: link, children: text });
1375
- }
1376
- if (componentRef !== undefined) {
1377
- if (ResolvedComponent !== undefined) {
1378
- const node = (_jsx(ResolvedComponent, { value: value, row: row, column: { field }, ...(onChange !== undefined && { onChange }) }));
1379
- // stopPropagation only on the editable branch — plain cells must still
1380
- // fire row onClick when onCellChange is set for sibling editable cells.
1381
- if (onChange !== undefined) {
1382
- return (_jsx("span", { className: "contents", onClick: (e) => e.stopPropagation(), onKeyDown: (e) => e.stopPropagation(), children: node }));
1383
- }
1384
- return node;
1385
- }
1386
- // Renderer im Schema referenziert, aber client-side kein Map-Eintrag —
1387
- // typischer Fall: clientFeatures.columnRenderers vergessen oder
1388
- // Tippfehler im __component-Key. Warnen statt crashen, damit ein
1389
- // Schema-Boot trotzdem funktioniert (Default-Type-Renderer übernimmt).
1390
- // biome-ignore lint/suspicious/noConsole: dev-warning für Schema-Konflikte
1391
- console.warn(`[kumiko] columnRenderer "${componentRef.name}" not registered`);
1392
- }
1393
- // A select value renders as a pill instead of plain text. When the raw
1394
- // value is a known status word, it carries the same tone as the
1395
- // projectionDetail header badge — a status column stayed grey while the
1396
- // detail view of the same value was coloured (fw#2579). Unknown values
1397
- // keep the neutral outline pill.
1398
- if (type === "select" && value !== null && value !== undefined && value !== "") {
1399
- return selectPill(value, optionLabels, optionTones, locale, RegisteredStatusBadge);
1400
- }
1401
- // One pill per picked value; a single pill would hold the whole joined list.
1402
- if (type === "multiSelect" && Array.isArray(value) && value.length > 0) {
1403
- return (_jsx("span", { className: "flex flex-wrap gap-1", children: value.map((entry) => (_jsx(Fragment, { children: selectPill(entry, optionLabels, optionTones, locale, RegisteredStatusBadge) }, String(entry)))) }));
1404
- }
1405
- return defaultCellRender(value, type, optionLabels, locale, field, grouping);
1406
- }
1407
- // ---- Form + Section + Grid + Text ----
1408
- // Setzt DefaultSection in den Inner-Region-Modus: das ganze Form ist EINE
1409
- // Card, Sections sind divider-getrennte Abschnitte darin (shadcn-Muster wie
1410
- // Shipping/Invoice/Profile). Standalone (außerhalb Form) bleibt Section eine
1411
- // eigene Card.
1412
- const InsideFormContext = createContext(false);
1413
- // Eingebettete Forms (z.B. im AuthCard) tragen ihre Card-Fläche schon vom
1414
- // Container — der self-cardende DefaultForm würde sonst eine Card-in-Card
1415
- // erzeugen. BareFormProvider schaltet DefaultForm auf ein nacktes <form>
1416
- // (gestapelte Felder, kein eigener Rahmen/max-width).
1417
- const BareFormContext = createContext(false);
1418
- // Dashboard panels with an embedded settings form (`DashboardScreenPanel.chromeless`):
1419
- // the form renders in the page grid like a standalone screen form, without the
1420
- // card frame, but keeps its own (non-pinned) save bar.
1421
- const EmbeddedFormContext = createContext(false);
1422
- export function EmbeddedFormProvider({ children }) {
1423
- return _jsx(EmbeddedFormContext.Provider, { value: true, children: children });
1424
- }
1425
- export function BareFormProvider({ children }) {
1426
- return _jsx(BareFormContext.Provider, { value: true, children: children });
1427
- }
1428
- // App-wide default for FormScreenShell's width when a screen doesn't set its
1429
- // own `layout.width`. Undefined (no `createKumikoApp({ screenWidth })`) lets each
1430
- // shell keep its own default: "4xl" for FormScreenShell, 640px for the
1431
- // screen-form column.
1432
- const ScreenWidthContext = createContext(undefined);
1433
- export function ScreenWidthProvider({ width, children, }) {
1434
- return _jsx(ScreenWidthContext.Provider, { value: width, children: children });
1435
- }
1436
- // Extension sections that render their own <form> via BareFormProvider
1437
- // (legacy custom-form pattern, e.g. ChangeEmailSection before fw#2703) land
1438
- // inside RenderEdit's host <form> (ExtensionSectionMount, render-edit.tsx) —
1439
- // nested <form> elements are invalid DOM, and real browsers can silently
1440
- // fall back to a native GET submit of the OUTER form, leaking field values
1441
- // into the URL (fw#2312, fw#2705). Degrading to a <div> when already inside
1442
- // a form avoids the nesting; the captured click still routes to THIS form's
1443
- // onSubmit instead of activating the real ancestor <form>.
1444
- const SUBMIT_CONTROL_SELECTOR = [
1445
- "button[type=submit]:not(:disabled)",
1446
- "button:not([type]):not(:disabled)",
1447
- "input[type=submit]:not(:disabled)",
1448
- "input[type=image]:not(:disabled)",
1449
- ].join(", ");
1450
- function FormRoot({ onSubmit, testId, className, children, focusFirstErrorKey, }) {
1451
- const rootRef = useRef(null);
1452
- useEffect(() => {
1453
- if (focusFirstErrorKey === undefined || focusFirstErrorKey === 0)
1454
- return;
1455
- const firstInvalid = rootRef.current?.querySelector('[aria-invalid="true"]');
1456
- if (firstInvalid === null || firstInvalid === undefined)
1457
- return;
1458
- firstInvalid.focus();
1459
- firstInvalid.scrollIntoView({ block: "center" });
1460
- }, [focusFirstErrorKey]);
1461
- if (useContext(InsideFormContext)) {
1462
- return (_jsx("div", { ref: (node) => {
1463
- rootRef.current = node;
1464
- }, "data-kumiko-form-root": "", onKeyDownCapture: (e) => {
1465
- // Implicit submission: the section's inputs belong to the OUTER
1466
- // <form> in the DOM, so the browser would otherwise route Enter to
1467
- // that form's default button.
1468
- if (e.key !== "Enter" || e.nativeEvent.isComposing)
1469
- return;
1470
- const field = e.target;
1471
- if (!(field instanceof HTMLInputElement))
1472
- return;
1473
- if (field.closest("[data-kumiko-form-root]") !== e.currentTarget)
1474
- return;
1475
- if (["checkbox", "radio", "submit", "button"].includes(field.type))
1476
- return;
1477
- e.preventDefault();
1478
- onSubmit();
1479
- }, onClickCapture: (e) => {
1480
- // @cast-boundary dom-event-target: closest() needs an Element, and
1481
- // click targets are always one in the browser/happy-dom.
1482
- const submitButton = e.target.closest(SUBMIT_CONTROL_SELECTOR);
1483
- if (submitButton === null)
1484
- return;
1485
- // React events bubble through portals and nested degraded roots; a
1486
- // real <form> only reacts to buttons in its own DOM subtree.
1487
- if (submitButton.closest("[data-kumiko-form-root]") !== e.currentTarget)
1488
- return;
1489
- e.preventDefault();
1490
- onSubmit();
1491
- }, "data-testid": testId, className: className, children: children }));
1492
- }
1493
- return (_jsx("form", { ref: (node) => {
1494
- rootRef.current = node;
1495
- }, onSubmit: (e) => {
1496
- e.preventDefault();
1497
- onSubmit(e);
1498
- }, "data-testid": testId, className: className, children: children }));
1499
- }
1500
- // stickyActions alone pins the primary action with `position: fixed` on
1501
- // narrow viewports; together with fillHeight the flex chain pins the footer
1502
- // on every viewport instead.
1503
- function isPinnedFooter(stickyActions, fillHeight) {
1504
- return stickyActions === true && fillHeight === true;
1505
- }
1506
- // Sections wrapper for DefaultForm's card and chromeless layouts alike —
1507
- // only the card-derived padding on non-section children differs between
1508
- // them (see `chromeless` doc on FormProps).
1509
- function FormSections({ children, chromeless, stickyActions, fillHeight, screenForm = false, }) {
1510
- if (screenForm) {
1511
- return (_jsx("div", { className: "flex flex-col gap-9", children: _jsx(InsideFormContext.Provider, { value: true, children: _jsx(ScreenFormContext.Provider, { value: true, children: children }) }) }));
1512
- }
1513
- return (_jsx("div", { className: cn("flex flex-col",
1514
- // Section-Children (Auto-UI-Edit) trennt eine Linie ZWISCHEN
1515
- // ihnen — sie padden sich selbst. Flache Felder (Custom-Screens)
1516
- // kriegen Padding + Rhythmus, keine Linie zwischen jedem Feld.
1517
- "[&>section:not(:first-child)]:border-t",
1518
- // Margins, not padding: padding would land inside a child that has its own border/height.
1519
- !chromeless && "[&>:not(section)]:mx-6 [&>:not(section)]:my-3", !chromeless && "[&>:not(section):first-child]:mt-6 [&>:not(section):last-child]:mb-6",
1520
- // ponytail: fixed footer now pins only the primary action (single
1521
- // button row + its own p-4, fw#2606) instead of the whole footer —
1522
- // shrunk from pb-32 accordingly. Widen again if a wizard's primary
1523
- // action ever wraps to two rows.
1524
- stickyActions === true && fillHeight !== true && STICKY_FOOTER_SPACER_CLASS,
1525
- // Same "no flex-1" reasoning as the card above: this is the
1526
- // one section allowed to shrink (min-h-0) inside the card, not
1527
- // one forced to grow past its content.
1528
- // Chromeless has no card above to hold a floor, so the sections do.
1529
- fillHeight === true && (chromeless ? "min-h-64" : "min-h-0"),
1530
- // The flex chain pins the footer, so the sections are the scroll surface.
1531
- isPinnedFooter(stickyActions, fillHeight) && "flex-1 overflow-y-auto"), children: _jsx(InsideFormContext.Provider, { value: true, children: children }) }));
1532
- }
1533
- // Title/subtitle block shared by DefaultForm's card and chromeless layouts —
1534
- // RenderEdit already withholds screen.description from `subtitle` in tabs
1535
- // mode (chromeless), so whatever arrives here still renders, just without card chrome.
1536
- function FormTitleBlock({ title, subtitle, titleAction, testId, bordered, fillHeight, }) {
1537
- if (title === undefined && subtitle === undefined && titleAction === undefined)
1538
- return null;
1539
- return (_jsxs("div", { className: cn("flex items-start justify-between gap-4", bordered ? cn(cardHeaderBorder, "px-6 pb-4 pt-5") : "pb-4", fillHeight === true && "shrink-0"), children: [_jsxs("div", { className: "min-w-0", children: [title !== undefined && (_jsx("h2", { "data-testid": testId !== undefined ? `${testId}-title` : undefined, className: "text-lg font-semibold tracking-tight", children: title })), subtitle !== undefined && (_jsx("p", { "data-testid": testId !== undefined ? `${testId}-subtitle` : undefined, className: "mt-1 text-sm text-muted-foreground", children: subtitle }))] }), titleAction !== undefined && (_jsx("div", { "data-testid": testId !== undefined ? `${testId}-title-action` : undefined, className: "flex shrink-0 flex-wrap items-center justify-end gap-2", children: titleAction }))] }));
1540
- }
1541
- // `formActions` (render-edit.tsx) mixes a non-submit wizard Back button in
1542
- // with the submit-type Next/Finish button inside one Fragment — only the
1543
- // submit one is the "primary" action that fw#1918 needs pinned above a
1544
- // virtual keyboard. Back reads as `type="button"`, same as `secondaryActions`
1545
- // (Cancel/Delete/…), so it can safely join that group on mobile. A footer-slot
1546
- // mount can't expose `type="submit"` (its button is opaque app code), so it
1547
- // opts in via `STICKY_PRIMARY_ACTION_PROP` instead (`ScreenSlots.footerPrimary`).
1548
- function isPrimaryStickyAction(node) {
1549
- if (!isValidElement(node))
1550
- return false;
1551
- const props = node.props;
1552
- return props.type === "submit" || props[STICKY_PRIMARY_ACTION_PROP] === true;
1553
- }
1554
- // `actions` from render-edit.tsx arrives as a single `<>…</>` Fragment
1555
- // element (not spread children) — Children.toArray only flattens fragments
1556
- // nested inside an already-flattened children list, not a Fragment passed
1557
- // as the whole value, so the Fragment's own children are unwrapped first.
1558
- function flattenActionNodes(node) {
1559
- if (isValidElement(node) && node.type === Fragment) {
1560
- return Children.toArray(node.props.children);
1561
- }
1562
- return Children.toArray(node);
1563
- }
1564
- function unsavedStatusText(t, unsavedCount) {
1565
- if (unsavedCount === 0)
1566
- return t("kumiko.form.noUnsavedChanges");
1567
- if (unsavedCount === 1)
1568
- return t("kumiko.form.unsaved.one");
1569
- return t("kumiko.form.unsaved.other", { count: unsavedCount });
1570
- }
1571
- // Footer wrapper for DefaultForm's card and chromeless layouts alike — only
1572
- // the card-derived horizontal padding/border differs between them.
1573
- function FormFooter({ actions, secondaryActions, testId, chromeless, stickyActions, fillHeight, unsavedCount, railed = false, }) {
1574
- const t = useTranslation();
1575
- const isBelowSm = useIsBelowSmViewport();
1576
- const keyboardInset = useKeyboardInset();
1577
- if (actions === undefined && secondaryActions === undefined)
1578
- return null;
1579
- // Only split when sticky: the non-sticky (regular, non-wizard) footer must
1580
- // reproduce the previous DOM exactly (form-action-bar.test.tsx pins
1581
- // `-actions`/`-actions-secondary` content 1:1 to the `actions`/
1582
- // `secondaryActions` props).
1583
- const actionNodes = stickyActions === true ? flattenActionNodes(actions) : undefined;
1584
- const primaryActionNodes = actionNodes?.filter(isPrimaryStickyAction);
1585
- const hasPrimaryAction = primaryActionNodes !== undefined && primaryActionNodes.length > 0;
1586
- // No primary node found among `actions` (e.g. a wizard step with only
1587
- // secondary buttons): fall back to pinning the whole, unpartitioned
1588
- // `actions` node instead of splitting it — matches the pre-split fw#1918
1589
- // behaviour where sticky footers always kept `actions` fixed.
1590
- const nonPrimaryActionNodes = hasPrimaryAction
1591
- ? actionNodes?.filter((node) => !isPrimaryStickyAction(node))
1592
- : undefined;
1593
- const renderedActions = stickyActions === true ? (hasPrimaryAction ? primaryActionNodes : actions) : actions;
1594
- const hasNonPrimaryOverflow = nonPrimaryActionNodes !== undefined && nonPrimaryActionNodes.length > 0;
1595
- const renderedSecondary = stickyActions === true && hasPrimaryAction ? (_jsxs(_Fragment, { children: [secondaryActions, nonPrimaryActionNodes] })) : (secondaryActions);
1596
- const pinned = isPinnedFooter(stickyActions, fillHeight);
1597
- const fixedFooterStyle = stickyActions === true && !pinned && isBelowSm && keyboardInset > 0
1598
- ? { bottom: keyboardInset }
1599
- : undefined;
1600
- // Below sm the pinned footer swaps to NarrowPinnedFooter; the subtree remounts, so a
1601
- // confirm dialog owned by an action button closes on a breakpoint change (rotate, resize).
1602
- if (pinned && isBelowSm) {
1603
- return (_jsx(NarrowPinnedFooter, { nodes: [...flattenActionNodes(secondaryActions), ...flattenActionNodes(actions)], overflowLabel: unsavedCount !== undefined && unsavedCount > 0
1604
- ? `${t("kumiko.list.row-actions.more")}, ${unsavedStatusText(t, unsavedCount)}`
1605
- : t("kumiko.list.row-actions.more"), unsavedCount: unsavedCount, unsavedStatus: unsavedCount !== undefined && unsavedCount > 0
1606
- ? unsavedStatusText(t, unsavedCount)
1607
- : undefined, testId: testId, keyboardInset: keyboardInset }));
1608
- }
1609
- return (_jsxs("div", { className: cn(pinned
1610
- ? cn("flex h-14 shrink-0 items-center justify-end gap-2 border-t border-border bg-card px-6", !railed && "md:pl-10")
1611
- : "flex flex-col-reverse gap-3 py-3 sm:flex-row sm:items-center sm:justify-between sm:py-4", !pinned && !chromeless && "px-[var(--card-padding)]", !pinned && !chromeless && cardFooterBorder, !pinned && fillHeight === true && "shrink-0"), children: [unsavedCount !== undefined && (_jsxs("div", { "data-testid": testId !== undefined ? `${testId}-unsaved` : undefined, className: cn("mr-auto flex min-w-0 items-center gap-2 text-[13px]",
1612
- // Only the stacked (flex-col-reverse) footer moves the status above the
1613
- // actions; the pinned single-row footer keeps it on the left.
1614
- !pinned && "max-sm:order-last", unsavedCount > 0 ? "text-foreground-secondary" : "text-muted-foreground"), children: [unsavedCount > 0 && (_jsx("span", { className: "size-1.5 shrink-0 rounded-full bg-status-active", "aria-hidden": "true" })), _jsx("span", { className: "truncate", children: unsavedStatusText(t, unsavedCount) })] })), (secondaryActions !== undefined || hasNonPrimaryOverflow) && (_jsx("div", { "data-testid": testId !== undefined ? `${testId}-actions-secondary` : undefined, className: cn(
1615
- // min-h-11: link-variant buttons (Copy link) are h-auto and would fall
1616
- // below the 24px touch-target minimum on narrow viewports.
1617
- "flex flex-wrap items-center gap-2 max-sm:[&_button]:min-h-11 max-sm:[&_button]:px-2 max-sm:[&_button]:text-xs", pinned && hasNonPrimaryOverflow && "mr-auto"), children: renderedSecondary })), renderedActions !== undefined && (_jsx("div", { "data-testid": testId !== undefined ? `${testId}-actions` : undefined, style: fixedFooterStyle, className: cn("flex items-center gap-2", pinned
1618
- ? "shrink-0 max-md:[&>button]:min-h-11"
1619
- : "flex-wrap max-sm:w-full max-sm:[&>button]:flex-1 max-sm:[&>button]:min-h-11 sm:ml-auto",
1620
- // Below sm (640px): pin only the primary action to the viewport
1621
- // bottom instead of normal flow, so a virtual keyboard shrinking
1622
- // the viewport can't push it out of reach (fw#1918). `fixed`
1623
- // escapes the card's `overflow-hidden` (only transform/filter/
1624
- // contain ancestors trap it, confirmed against
1625
- // AppLayout/SidebarInset — neither sets those).
1626
- stickyActions === true &&
1627
- !pinned &&
1628
- cn("max-sm:fixed max-sm:inset-x-0 max-sm:bottom-0 max-sm:z-20 max-sm:bg-background max-sm:p-4 max-sm:shadow-[0_-4px_12px_-4px_rgb(0_0_0_/_0.15)]", STICKY_FOOTER_SAFE_AREA_CLASS)), children: renderedActions }))] }));
1629
- }
1630
- const SECTION_NAV_MIN_ITEMS = 3;
1631
- // "On this page" navigation next to a screen form. The active entry follows
1632
- // the section nearest the top of the scroll surface; without IntersectionObserver
1633
- // (happy-dom, old engines) the first entry stays active until one is clicked.
1634
- function FormSectionNav({ items }) {
1635
- const t = useTranslation();
1636
- const [activeId, setActiveId] = useState(items[0]?.id);
1637
- useEffect(() => {
1638
- if (typeof IntersectionObserver === "undefined")
1639
- return;
1640
- const visibleIds = new Set();
1641
- const observer = new IntersectionObserver((entries) => {
1642
- for (const entry of entries) {
1643
- if (entry.isIntersecting)
1644
- visibleIds.add(entry.target.id);
1645
- else
1646
- visibleIds.delete(entry.target.id);
1647
- }
1648
- const firstVisible = items.find((item) => visibleIds.has(item.id));
1649
- if (firstVisible !== undefined)
1650
- setActiveId(firstVisible.id);
1651
- }, { rootMargin: "0px 0px -60% 0px" });
1652
- for (const item of items) {
1653
- const target = document.getElementById(item.id);
1654
- if (target !== null)
1655
- observer.observe(target);
1656
- }
1657
- return () => observer.disconnect();
1658
- }, [items]);
1659
- return (_jsxs("nav", { "aria-label": t("kumiko.form.on-this-page"), "data-testid": "form-section-nav", className: "sticky top-0 hidden w-[200px] shrink-0 flex-col gap-0.5 self-start xl:flex", children: [_jsx("span", { className: "pb-1.5 pl-3 text-xs text-muted-foreground", children: t("kumiko.form.on-this-page") }), items.map((item) => {
1660
- const active = item.id === activeId;
1661
- return (_jsx("button", { type: "button", "data-testid": `form-section-nav-${item.id}`, ...(active && { "aria-current": "true" }), onClick: () => {
1662
- setActiveId(item.id);
1663
- document
1664
- .getElementById(item.id)
1665
- ?.scrollIntoView?.({ behavior: "smooth", block: "start" });
1666
- }, className: cn("border-l-2 px-3 py-[5px] text-left text-sm", active
1667
- ? "border-primary font-semibold text-primary"
1668
- : "border-border text-foreground-secondary hover:text-foreground"), children: item.title }, item.id));
1669
- })] }));
1670
- }
1671
- // Form inside the Drawer primitive: the drawer header carries the title, so
1672
- // only the description (as help text), the scrolling sections and a pinned
1673
- // footer remain. The scroll area is a sibling of the footer, never its parent.
1674
- function DrawerFormLayout({ onSubmit, focusFirstErrorKey, subtitle, actions, secondaryActions, summary, testId, children, }) {
1675
- return (_jsxs(FormRoot, { onSubmit: onSubmit, focusFirstErrorKey: focusFirstErrorKey, testId: testId, className: "flex h-full min-h-0 w-full flex-col", children: [_jsxs("div", { "data-testid": testId !== undefined ? `${testId}-scroll` : undefined, className: "flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto px-6 py-5", children: [summary !== undefined && (_jsxs("div", { "data-testid": testId !== undefined ? `${testId}-summary` : undefined, className: "flex flex-col gap-0.5 rounded-lg bg-muted px-3.5 py-3", children: [_jsx("span", { className: "text-sm font-semibold text-foreground", children: summary.title }), summary.subtitle !== undefined && (_jsx("span", { className: "text-[13px] text-foreground-secondary", children: summary.subtitle }))] })), subtitle !== undefined && (_jsx("p", { "data-testid": testId !== undefined ? `${testId}-subtitle` : undefined, className: "text-[13px] text-foreground-secondary", children: subtitle })), _jsx(InsideFormContext.Provider, { value: true, children: _jsx(ScreenFormContext.Provider, { value: true, children: _jsx(InsideDrawerProvider, { value: false, children: _jsx(DrawerBodyContext.Provider, { value: true, children: _jsx("div", { className: "flex flex-col gap-5", children: children }) }) }) }) })] }), (actions !== undefined || secondaryActions !== undefined) && (_jsxs("div", { "data-testid": testId !== undefined ? `${testId}-footer` : undefined, className: "flex h-16 shrink-0 items-center justify-end gap-2 border-t border-border px-6", children: [secondaryActions, actions] }))] }));
1676
- }
1677
- function DefaultForm({ onSubmit, focusFirstErrorKey, children, title, subtitle, actions, secondaryActions, testId, width, stickyActions, headerRegion, titleAction, fillHeight, chromeless, screenForm, unsavedCount, sectionNav, sideRail, summary, }) {
1678
- const insideDrawer = useInsideDrawer();
1679
- const isEmbeddedForm = useContext(EmbeddedFormContext);
1680
- const contextWidth = useContext(ScreenWidthContext);
1681
- // Eingebettet (AuthCard etc.): nacktes <form>, gestapelte Felder mit gap —
1682
- // der Container trägt Card/Titel selbst, sonst Card-in-Card.
1683
- if (useContext(BareFormContext)) {
1684
- return (_jsxs(FormRoot, { onSubmit: onSubmit, focusFirstErrorKey: focusFirstErrorKey, testId: testId, className: cn("flex flex-col gap-4",
1685
- // Bare forms stack sections without a card; without a divider a
1686
- // section boundary reads as a layout gap, not structure.
1687
- "[&>section:not(:first-child)]:border-t"), children: [headerRegion, _jsx(InsideFormContext.Provider, { value: true, children: children }), (secondaryActions !== undefined || actions !== undefined) && (_jsxs("div", { className: "flex items-center justify-end gap-2", children: [secondaryActions, actions] }))] }));
1688
- }
1689
- if (insideDrawer) {
1690
- return (_jsx(DrawerFormLayout, { onSubmit: onSubmit, focusFirstErrorKey: focusFirstErrorKey, subtitle: subtitle, actions: actions, secondaryActions: secondaryActions, summary: summary, testId: testId, children: children }));
1691
- }
1692
- if (isEmbeddedForm) {
1693
- return (_jsxs(FormRoot, { onSubmit: onSubmit, focusFirstErrorKey: focusFirstErrorKey, testId: testId, className: "flex w-full flex-col gap-6", children: [_jsx(FormTitleBlock, { title: title, subtitle: subtitle, titleAction: titleAction, testId: testId, bordered: false, fillHeight: false }), _jsx(FormSections, { chromeless: true, stickyActions: undefined, fillHeight: false, screenForm: true, children: children }), (actions !== undefined || secondaryActions !== undefined) && (_jsx("div", { className: "max-w-4xl border-t border-border", children: _jsx(FormFooter, { actions: actions, secondaryActions: secondaryActions, testId: testId, chromeless: true, stickyActions: undefined, fillHeight: false, ...(unsavedCount !== undefined && { unsavedCount }) }) }))] }));
1694
- }
1695
- const isScreenForm = screenForm === true && fillHeight === true && chromeless !== true;
1696
- const screenFormWidth = width ?? contextWidth;
1697
- const sections = (_jsx(FormSections, { chromeless: chromeless === true, stickyActions: stickyActions, fillHeight: fillHeight, screenForm: isScreenForm, children: children }));
1698
- const footer = (_jsx(FormFooter, { actions: actions, secondaryActions: secondaryActions, testId: testId, chromeless: chromeless === true, stickyActions: stickyActions, fillHeight: fillHeight, ...(unsavedCount !== undefined && { unsavedCount }), railed: sideRail !== undefined }));
1699
- // Screen form: no card. A padded scroll surface holds the form column and,
1700
- // from xl, the section navigation; the footer stays pinned below it.
1701
- if (isScreenForm) {
1702
- const showsSectionNav = sectionNav !== undefined && sectionNav.length >= SECTION_NAV_MIN_ITEMS;
1703
- return (_jsxs(FormRoot, { onSubmit: onSubmit, focusFirstErrorKey: focusFirstErrorKey, testId: testId, className: "flex h-full min-h-0 w-full flex-col", children: [_jsxs("div", { className: "flex min-h-0 flex-1 flex-col lg:flex-row", children: [sideRail, _jsx("div", { "data-testid": testId !== undefined ? `${testId}-scroll` : undefined, className: cn("min-h-0 flex-1 overflow-y-auto", screenPaddingClassName), children: _jsxs("div", { className: "flex gap-18", children: [_jsxs("div", { className: cn("flex w-full min-w-0 flex-col gap-9", screenFormWidth !== undefined
1704
- ? screenFormColumnWidthClassName[screenFormWidth]
1705
- : "max-w-[640px]"), children: [_jsx(InsideScreenFormColumnContext.Provider, { value: true, children: headerRegion }), _jsx(FormTitleBlock, { title: title, subtitle: subtitle, titleAction: titleAction, testId: testId, bordered: false, fillHeight: false }), sections] }), showsSectionNav && _jsx(FormSectionNav, { items: sectionNav })] }) })] }), footer] }));
1706
- }
1707
- // Pinned footer (fillHeight + stickyActions): the footer is a full-bleed
1708
- // sibling BELOW the padded shell, so the shell's bottom inset does not float
1709
- // it above the viewport edge; the shell shrinks (`flex-1`) to make room.
1710
- const pinnedFooter = isPinnedFooter(stickyActions, fillHeight);
1711
- const pinnedShellClassName = pinnedFooter ? "h-auto flex-1 pb-6" : undefined;
1712
- // Tab content (bedienkonzept A1): the head card is the ONLY card on the
1713
- // screen, so a tabbed projectionDetail's tab content must render as a
1714
- // sibling of that card, not nest another one around itself — chromeless
1715
- // skips the card wrapper and its border/padding so sections and footer
1716
- // sit directly on the page background, same edge as a standalone list
1717
- // screen. Title/subtitle still render unbordered: RenderEdit's tabs
1718
- // caller already blanks the per-section title via hideSectionTitles, but
1719
- // the screen-level subtitle (screen.description) is independent of that
1720
- // and must survive tabs mode.
1721
- if (chromeless === true) {
1722
- return (_jsxs(FormRoot, { onSubmit: onSubmit, focusFirstErrorKey: focusFirstErrorKey, testId: testId, className: cn("flex flex-col w-full", fillHeight === true && "h-full min-h-0"), children: [_jsxs(FormScreenShell, { maxWidth: "full", ...(fillHeight === true && { fillHeight: true }), className: cn("!p-0", pinnedShellClassName), children: [headerRegion !== undefined && (_jsx("div", { className: cn("flex flex-col", fillHeight === true && "shrink-0"), children: headerRegion })), _jsx(FormTitleBlock, { title: title, subtitle: subtitle, titleAction: titleAction, testId: testId, bordered: false, fillHeight: fillHeight }), sections, !pinnedFooter && footer] }), pinnedFooter && footer] }));
1723
- }
1724
- // One card form: title as header (no divider under it), sections divided
1725
- // between each other, muted action footer. Shell width comes from
1726
- // FormScreenShell (layout.width, else the app-wide screenWidth default, else
1727
- // 4xl).
1728
- return (_jsxs(FormRoot, { onSubmit: onSubmit, focusFirstErrorKey: focusFirstErrorKey, testId: testId, className: cn("flex flex-col w-full", fillHeight === true && "h-full min-h-0"), children: [_jsxs(FormScreenShell, { ...(width !== undefined && { maxWidth: width }), ...(fillHeight === true && { fillHeight: true }), ...(pinnedShellClassName !== undefined && { className: pinnedShellClassName }), children: [headerRegion !== undefined && (_jsx("div", { className: cn("flex flex-col gap-6 mb-8", fillHeight === true && "shrink-0"), children: headerRegion })), _jsxs("div", { "data-slot": "card", className: cn(cardSurface(), "overflow-hidden",
1729
- // No flex-1: a fillHeight card sizes to its content (flex's
1730
- // initial 0 1 auto) and only claims more than that once its
1731
- // FormScreenShell ancestor is itself height-constrained and
1732
- // shrinks it back down via min-h-0 — flex-1 would instead force
1733
- // it to always fill the remaining height, stretching a short
1734
- // relatedList tab to the bottom of the panel (fw#2778).
1735
- // The min-height floor keeps the card from collapsing to ~0 on short
1736
- // viewports; the shell then overflows and <main> scrolls instead of
1737
- // the overflow-hidden card clipping the table.
1738
- fillHeight === true && "min-h-64 flex flex-col", pinnedFooter && "flex-1"), children: [_jsx(FormTitleBlock, { title: title, subtitle: subtitle, titleAction: titleAction, testId: testId, bordered: true, fillHeight: fillHeight }), sections, !pinnedFooter && footer] })] }), pinnedFooter && footer] }));
1739
- }
1740
- export function FormScreenShell({ children, className, testId, maxWidth, fillHeight, }) {
1741
- const contextWidth = useContext(ScreenWidthContext);
1742
- const width = maxWidth ?? contextWidth ?? "4xl";
1743
- return (_jsx("div", { "data-testid": testId, className: cn(screenPaddingClassName, "w-full", screenWidthClassName[width], fillHeight === true && "h-full flex flex-col min-h-0", className), children: children }));
1744
- }
1745
- function DefaultSection({ id, title, subtitle, children, actions, variant = "default", layout = "stacked", testId, icon, }) {
1746
- const insideForm = useContext(InsideFormContext);
1747
- const insideScreenForm = useContext(ScreenFormContext);
1748
- // h3 statt CardTitle (= div): erhält die Heading-Semantik für
1749
- // Screenreader-Navigation. Subtitle fließt darunter (kein Divider —
1750
- // shadcn CardTitle+CardDescription-Muster).
1751
- // icon only renders alongside a title — a title-less section has nothing
1752
- // for a lone icon to sit next to, so it stays as-is (no heading grows).
1753
- // actions render top-right in this same title row, never a
1754
- // footer — a title-less section still draws the row when actions are
1755
- // present, so a hideTitle tabs-Section with `actions` isn't stranded.
1756
- const TitleTag = insideScreenForm ? "h2" : "h3";
1757
- const titleBlock = title !== undefined || subtitle !== undefined ? (_jsxs("div", { className: "flex flex-col gap-1", children: [title !== undefined && (_jsxs(TitleTag, { "data-testid": testId !== undefined ? `${testId}-title` : undefined, className: "flex items-center gap-2 text-base font-semibold leading-none tracking-tight", children: [icon !== undefined && _jsx(Icon, { name: icon, className: "size-4 text-muted-foreground" }), title] })), subtitle !== undefined && (_jsx("div", { "data-testid": testId !== undefined ? `${testId}-subtitle` : undefined, className: cn("text-sm text-muted-foreground", insideScreenForm && "text-[13px] text-foreground-secondary"), children: subtitle }))] })) : null;
1758
- const header = titleBlock !== null || actions !== undefined ? (_jsxs("div", { className: "flex items-start justify-between gap-4", children: [titleBlock, actions !== undefined && (_jsx("div", { "data-testid": testId !== undefined ? `${testId}-actions` : undefined, className: "ml-auto flex shrink-0 items-center gap-2", children: actions }))] })) : null;
1759
- // Innerhalb eines Forms: divider-loser Abschnitt OHNE eigene Card-Fläche.
1760
- // Die Trennlinien ZWISCHEN Sections macht der divide-y-Wrapper im Form.
1761
- if (insideForm) {
1762
- return (_jsxs("section", { id: id, "data-testid": testId, className: cn(
1763
- // py-4 (not the standalone card's py-6): the border-t between
1764
- // sections already carries the section break, so the vertical
1765
- // gap only needs to read as roughly double the gap-4 field row
1766
- // spacing, not triple it.
1767
- insideScreenForm
1768
- ? "flex scroll-mt-4 flex-col gap-4 [&:not(:first-of-type)]:border-t [&:not(:first-of-type)]:border-border [&:not(:first-of-type)]:pt-7"
1769
- : "flex flex-col gap-4 px-6 py-4", variant === "destructive" && "border-l-2 border-destructive/40", layout === "settings-list" && "@container max-w-4xl"), children: [header, children] }));
1770
- }
1771
- // Standalone: own card, header flows into the body (no divider).
1772
- // overflow-hidden clips the card's own corner radius correctly for
1773
- // portaled overlays (Combobox/Select/Tooltip escape to document.body,
1774
- // unaffected). A non-portaled overlay (e.g. a custom dropdown built
1775
- // directly into `children`) WOULD get silently clipped — verify this
1776
- // against any new standalone-section content that renders its own
1777
- // non-portaled overlay.
1778
- return (_jsx("div", { "data-slot": "card", "data-testid": testId, className: cn(cardSurface(), "overflow-hidden", variant === "destructive" && "border-destructive/40"), children: _jsxs("div", { className: "flex flex-col gap-4 px-6 py-6", children: [header, children] }) }));
1779
- }
1780
- function DefaultFillContainer({ children, testId, grow }) {
1781
- // flex-1 only on request (`grow`, set under fillScreenHeight): without it the
1782
- // container sizes to its content and only shrinks (min-h-0), so a two-row
1783
- // table in a non-fixed-height screen does not stretch (fw#2778).
1784
- return (_jsx("div", { "data-testid": testId, className: cn("flex min-h-0 flex-col", grow === true && "flex-1"), children: children }));
1785
- }
1786
- const DRAWER_FIELD_CELL_WIDTH_CLASS = {
1787
- money: "w-full sm:w-[200px] [&_label]:whitespace-nowrap",
1788
- select: "w-full",
1789
- };
1790
- const FIELD_CELL_WIDTH_CLASS = {
1791
- text: "w-full sm:w-60",
1792
- number: "w-fit min-w-24 shrink-0 [&_label]:whitespace-nowrap [&_[data-slot=number-field]]:w-32",
1793
- money: "w-40 shrink-0 [&_label]:whitespace-nowrap",
1794
- date: "w-full sm:w-[200px]",
1795
- timestamp: "w-full sm:w-[328px]",
1796
- select: "w-full sm:w-auto sm:min-w-[200px] sm:has-[[data-radio-list]]:w-full",
1797
- full: "w-full",
1798
- // self-start: the label shares the top line with neighbouring labels. The
1799
- // switch (1.15rem) gets (h-9 input − switch) / 2 margin to sit on the input line.
1800
- toggle: "w-auto min-w-40 shrink-0 self-start [&_[data-slot=switch]]:my-[0.55rem]",
1801
- };
1802
- function DefaultGrid({ columns, children, testId, maxRows, flow, list, ariaLabelledBy, }) {
1803
- const insideDrawerBody = useContext(DrawerBodyContext);
1804
- const groupProps = ariaLabelledBy !== undefined
1805
- ? { role: "group", "aria-labelledby": ariaLabelledBy }
1806
- : {};
1807
- if (list === true) {
1808
- return (_jsx("div", { ...groupProps, "data-testid": testId, className: "flex flex-col divide-y divide-border", children: children }));
1809
- }
1810
- if (flow === true) {
1811
- return (_jsx("div", { ...groupProps, "data-testid": testId, className: cn("flex gap-4", insideDrawerBody
1812
- ? "flex-col items-start"
1813
- : // items-end aligns inputs when labels wrap to different heights; a
1814
- // below-control appendix grows only the one cell and would push its
1815
- // neighbours down, so such rows align to the top instead. An error
1816
- // line does the same: bottom-aligned, it would lift that field's
1817
- // label above its neighbours'.
1818
- "flex-wrap items-end has-[[data-appendix-below]]:items-start has-[[role=alert]]:items-start"), children: children }));
1819
- }
1820
- // "auto": content-sized items in a wrapping row (e.g. a metrics band of
1821
- // self-sized tiles) instead of N equal-width, container-stretched tracks.
1822
- // maxRows/scrolling don't apply — the row just wraps.
1823
- if (columns === "auto") {
1824
- return (_jsx("div", { ...groupProps, "data-testid": testId, className: "flex flex-wrap items-center gap-4", children: children }));
1825
- }
1826
- // Responsive: Mobile (< sm = 640px) bleibt 1-spaltig, ab sm: greift
1827
- // die Author-deklarierte Spaltenzahl. Inline-style schreibt
1828
- // CSS-Variable; Tailwind-Klasse liest die Variable mit
1829
- // `grid-template-columns: var(--grid-cols)`. Saubere Lösung weil
1830
- // Tailwind JIT keinen dynamischen `grid-cols-${N}` auflösen kann.
1831
- const rowCount = Math.ceil(Children.toArray(children).length / columns);
1832
- const clipped = maxRows !== undefined && rowCount > maxRows;
1833
- // gridAutoRows is a minmax MIN, not a fixed height, so wrapped labels grow
1834
- // their row instead of being clipped mid-row. maxHeight is therefore only
1835
- // an approximation of "maxRows rows tall" — a row taller than the minimum
1836
- // means fewer than maxRows rows end up visible before scrolling kicks in.
1837
- // Themes may override --kumiko-grid-row-h (default 2.5rem, set in
1838
- // styles.css) to change that minimum.
1839
- // Mobile note: the grid collapses to 1 column below `sm` (see className),
1840
- // but rowCount above is always computed against the desktop `columns` —
1841
- // inline styles can't express a media query, so the mobile row count is
1842
- // an under-count. Known, accepted.
1843
- const style = {
1844
- "--grid-cols": `repeat(${columns}, minmax(0, 1fr))`,
1845
- ...(clipped && {
1846
- gridAutoRows: "minmax(var(--kumiko-grid-row-h, 2.5rem), auto)",
1847
- maxHeight: `calc(${maxRows} * var(--kumiko-grid-row-h, 2.5rem) + ${maxRows - 1} * var(--kumiko-grid-gap, 1rem))`,
1848
- overflowY: "auto",
1849
- }),
1850
- };
1851
- return (_jsx("div", { ...groupProps, "data-testid": testId, className: "grid gap-[var(--kumiko-grid-gap,1rem)] grid-cols-1 sm:[grid-template-columns:var(--grid-cols)]", style: style, children: children }));
1852
- }
1853
- function DefaultGridCell({ span, width, children }) {
1854
- const insideDrawerBody = useContext(DrawerBodyContext);
1855
- if (width !== undefined) {
1856
- return (_jsx("div", { className: cn("min-w-0 max-w-full", insideDrawerBody
1857
- ? (DRAWER_FIELD_CELL_WIDTH_CLASS[width] ?? FIELD_CELL_WIDTH_CLASS[width])
1858
- : FIELD_CELL_WIDTH_CLASS[width]), children: children }));
1859
- }
1860
- const s = span !== undefined ? Math.min(span, 12) : 1;
1861
- // Below `sm` the grid has one explicit column (see DefaultGrid); an unconditional
1862
- // `span N` there would add implicit auto columns that squeeze the fr column.
1863
- const style = { "--grid-span": s };
1864
- return (_jsx("div", { className: "min-w-0 sm:[grid-column:span_var(--grid-span)]", style: style, children: children }));
1865
- }
1866
- function DefaultText({ variant = "body", decorative, children, testId }) {
1867
- switch (variant) {
1868
- case "code":
1869
- return (_jsx("code", { "data-testid": testId, className: "relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm", children: children }));
1870
- case "small":
1871
- return (_jsx("small", { "data-testid": testId, className: "text-xs text-muted-foreground", children: children }));
1872
- case "required-mark":
1873
- return (_jsx("span", { "data-testid": testId, "data-required": true, className: "text-destructive", children: children }));
1874
- case "muted":
1875
- return (_jsx("span", { "data-testid": testId, ...(decorative === true && { "aria-hidden": true }), className: "text-sm text-muted-foreground", children: children }));
1876
- default:
1877
- return _jsx("span", { "data-testid": testId, children: children });
1878
- }
1879
- }
1880
- // ---- Link (anchor mit Button-/Muted-Optik) ----
1881
- // isSafeHref lives in @cosmicdrift/kumiko-renderer/headless — shared with
1882
- // page-render's server-side markdown renderer, both take untrusted
1883
- // tenant-authored hrefs.
1884
- // `button` nutzt die Primary-Buttonfläche auf einem semantischen <a> —
1885
- // der Standard für „weiter zu"-Navigationen nach Success-States (ehem.
1886
- // authButtonClass), `muted` der dezente Sekundär-Link (ehem.
1887
- // authMutedLinkClass).
1888
- // Modified and middle clicks keep the browser default (new tab/window).
1889
- function isPlainPrimaryClick(event) {
1890
- return event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey;
1891
- }
1892
- function DefaultLink({ href, variant = "default", target, rel, onPress, className, children, testId, dataAttributes, }) {
1893
- const variantClass = variant === "button"
1894
- ? buttonVariants({ variant: "default" })
1895
- : variant === "muted"
1896
- ? "text-sm text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
1897
- : "text-primary underline-offset-4 hover:underline";
1898
- return (_jsx("a", { href: isSafeHref(href) ? href : "#", target: target, rel: rel ?? (target === "_blank" ? "noopener noreferrer" : undefined), ...dataAttributes, "data-testid": testId, className: cn(variantClass, className), onClick: onPress === undefined
1899
- ? undefined
1900
- : (event) => {
1901
- if (!isPlainPrimaryClick(event))
1902
- return;
1903
- event.preventDefault();
1904
- onPress();
1905
- }, children: children }));
1906
- }
1907
- function DefaultProgress({ value, tone, ariaLabel, testId }) {
1908
- return _jsx(ProgressBar, { value: value, tone: tone, ariaLabel: ariaLabel, testId: testId });
1909
- }
1910
- function DefaultStepBar({ steps, currentIndex, compactLabel, onStepSelect, doneSteps, selectableSteps, narrowLayout, orientation, heading, description, subtitles, upNext, testId, compactTestId, }) {
1911
- return (_jsx(StepBar, { steps: steps, currentIndex: currentIndex, compactLabel: compactLabel, onStepSelect: onStepSelect, doneSteps: doneSteps, selectableSteps: selectableSteps, narrowLayout: narrowLayout, orientation: orientation, heading: heading, description: description, subtitles: subtitles, upNext: upNext, testId: testId, compactTestId: compactTestId }));
1912
- }
1913
- function DefaultWizardStepGroup({ hidden, inset, children }) {
1914
- const visibleClass = inset === true ? "flex min-w-0 flex-col gap-4 p-6" : "contents";
1915
- return (_jsx("fieldset", { disabled: hidden, hidden: hidden, className: hidden ? undefined : visibleClass, children: children }));
1916
- }
1917
- function DefaultHeading({ variant = "page", children, testId }) {
1918
- // Page-Heading = h1, sehr selten in einer App (max 1 pro Screen).
1919
- // Section-Heading = h2 mit uppercase + muted-foreground — derselbe
1920
- // Look wie der Section-Header in Forms, aber als Standalone-Component
1921
- // für Demo-Pages und Custom-Screens nutzbar.
1922
- if (variant === "section") {
1923
- return (_jsx("h2", { "data-testid": testId, className: "text-xs font-semibold uppercase tracking-wider text-muted-foreground", children: children }));
1924
- }
1925
- return (_jsx("h1", { "data-testid": testId, className: "text-2xl font-semibold tracking-tight", children: children }));
1926
- }
1927
24
  import { ConfigCascadeView as DefaultConfigCascadeView } from "../components/config-cascade.js";
1928
25
  import { ConfigSourceBadge as DefaultConfigSourceBadge } from "../components/config-source-badge.js";
1929
- // Generische Card-Chrome (rounded-xl wie die Entity-Card) — slot- + options-
1930
- // basiert, damit der Contract additiv wächst und Consumer nie migriert werden.
1931
- export function DefaultCard({ slots, options, className, testId, dataAttributes, children, }) {
1932
- const screenBody = options?.screenBody ?? false;
1933
- const padded = screenBody ? false : (options?.padded ?? true);
1934
- const radius = options?.radius ?? "xl";
1935
- const footerBordered = options?.footerBordered ?? true;
1936
- const footerInset = options?.footerInset ?? true;
1937
- const fillHeight = options?.fillHeight ?? false;
1938
- const framed = screenBody ? false : (options?.framed ?? true);
1939
- const s = slots ?? {};
1940
- const defaultHeader = s.title !== undefined ||
1941
- s.subtitle !== undefined ||
1942
- s.headerContent !== undefined ||
1943
- s.headerActions !== undefined ? (_jsxs("div", { className: cn("flex flex-col gap-3 px-[var(--card-padding)] pt-6 pb-4", fillHeight && "shrink-0"), children: [(s.title !== undefined || s.subtitle !== undefined || s.headerActions !== undefined) && (_jsxs("div", { className: "flex flex-wrap items-start justify-between gap-3", children: [_jsxs("div", { className: "flex flex-col gap-1", children: [s.title !== undefined && (_jsx("h3", { className: "text-base font-semibold leading-none tracking-tight", children: s.title })), s.subtitle !== undefined && (_jsx("p", { className: "text-sm text-muted-foreground", children: s.subtitle }))] }), s.headerActions] })), s.headerContent] })) : null;
1944
- const header = s.header ?? defaultHeader;
1945
- const hasHeader = header !== null && header !== undefined;
1946
- const card = (_jsxs("div", { ...dataAttributes, "data-slot": "card", "data-testid": testId, className: cn(framed ? cardSurface({ radius }) : "flex flex-col",
1947
- // An unframed card has no radius to clip; overflow-hidden would also
1948
- // zero its flex-item min-height and cut off content inside a scroller.
1949
- (framed || fillHeight) && "overflow-hidden",
1950
- // Same "no flex-1" reasoning as DefaultForm's own fillHeight card
1951
- // (fw#2722/#2778): sizes to content and only shrinks (min-h-0) once
1952
- // an ancestor is itself height-constrained.
1953
- fillHeight && "min-h-0 flex flex-col", className), children: [header, children != null && (_jsx("div", { className: cn("grow", screenBody && "flex flex-col gap-6", padded &&
1954
- (hasHeader
1955
- ? "px-[var(--card-padding)] pb-[var(--card-padding)]"
1956
- : "p-[var(--card-padding)]"),
1957
- // The one region allowed to shrink and scroll internally — mirrors
1958
- // FormSections' fillHeight === true && "min-h-0" treatment of the
1959
- // section it wraps.
1960
- fillHeight && "min-h-0 flex flex-col"), children: children })), s.footer !== undefined && s.footer !== null && s.footer !== false && (_jsx("div", { className: cn(cardFooter, footerInset && cardFooterInset, footerBordered && cardFooterBorder, fillHeight && "shrink-0"), children: s.footer }))] }));
1961
- return (_jsx(InsideScreenFormColumnContext.Provider, { value: false, children: screenBody ? _jsx(FormScreenShell, { children: card }) : card }));
1962
- }
1963
- // otpauth:// enrollment URI → scannable QR (errorCorrectionLevel "H", ~30%
1964
- // redundancy — more resilient to camera/lighting issues than the default, no
1965
- // downside for a code this short-lived). Own component (not inline in
1966
- // values.map) because QRCode.toString is async — a hook inside .map would
1967
- // violate the rules of hooks.
1968
- function QrSecretValue({ value }) {
1969
- const [svg, setSvg] = useState(null);
1970
- const [failed, setFailed] = useState(false);
1971
- useEffect(() => {
1972
- let cancelled = false;
1973
- setFailed(false);
1974
- QRCode.toString(value, { type: "svg", errorCorrectionLevel: "H" })
1975
- .then((result) => {
1976
- if (!cancelled)
1977
- setSvg(result);
1978
- })
1979
- .catch(() => {
1980
- // A generic `qr` field may have no separate text display, so fall back to the plain value.
1981
- if (!cancelled)
1982
- setFailed(true);
1983
- });
1984
- return () => {
1985
- cancelled = true;
1986
- };
1987
- }, [value]);
1988
- if (failed) {
1989
- return (_jsx("code", { className: "flex-1 overflow-x-auto whitespace-pre-wrap break-all rounded bg-muted px-3 py-2 font-mono text-sm", children: value }));
1990
- }
1991
- if (svg === null)
1992
- return null;
1993
- return (_jsx("div", { className: "h-40 w-40",
1994
- // qrcode's own SVG string output, not user input — safe to inline
1995
- // biome-ignore lint/security/noDangerouslySetInnerHtml: qrcode-generated SVG, no user input
1996
- dangerouslySetInnerHTML: { __html: svg } }));
1997
- }
1998
- // One-time secret reveal (fw#2548, secretMint confirm phase) — monospaced
1999
- // per-value display with a copy button that flips to `copiedLabel` on
2000
- // success. Silent-catch on clipboard error mirrors the copy-link pattern in
2001
- // create-app.tsx.
2002
- function DefaultSecretReveal({ values, copyLabel, copiedLabel, testId, }) {
2003
- const [copiedIndex, setCopiedIndex] = useState(null);
2004
- // Labels are not unique per reveal; suffix repeats so React keys stay distinct.
2005
- const seenLabels = new Map();
2006
- const rowKeys = values.map((v) => {
2007
- const occurrence = seenLabels.get(v.label) ?? 0;
2008
- seenLabels.set(v.label, occurrence + 1);
2009
- return `${v.label}#${occurrence}`;
2010
- });
2011
- return (_jsx("div", { "data-testid": testId, className: "flex flex-col gap-3", children: values.map((v, i) => (_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx("span", { className: "text-sm font-medium text-muted-foreground", children: v.label }), _jsxs("div", { className: "flex items-start gap-2", children: [v.qr === true ? (_jsx(QrSecretValue, { value: v.value })) : v.multiline ? (_jsx("pre", { className: "flex-1 overflow-x-auto whitespace-pre-wrap break-all rounded bg-muted px-3 py-2 font-mono text-sm", children: v.value })) : (_jsx("code", { className: "flex-1 overflow-x-auto whitespace-pre-wrap break-all rounded bg-muted px-3 py-2 font-mono text-sm", children: v.value })), v.copyable && (_jsx(UiButton, { type: "button", variant: "outline", size: "sm", onClick: async () => {
2012
- try {
2013
- await navigator.clipboard.writeText(v.value);
2014
- setCopiedIndex(i);
2015
- }
2016
- catch {
2017
- // clipboard blocked (non-secure context) — no fallback UI needed here
2018
- }
2019
- }, children: copiedIndex === i ? copiedLabel : copyLabel }))] })] }, rowKeys[i]))) }));
2020
- }
26
+ import { DefaultBanner } from "./banner.js";
27
+ import { DefaultButton } from "./button.js";
28
+ import { DefaultCard } from "./card.js";
29
+ import { DefaultDataTable } from "./data-table.js";
30
+ import { DefaultForm } from "./default-form.js";
31
+ import { DefaultField } from "./field.js";
32
+ import { DefaultInput } from "./input.js";
33
+ import { DefaultFillContainer, DefaultGrid, DefaultGridCell, DefaultHeading, DefaultLink, DefaultProgress, DefaultSection, DefaultStepBar, DefaultText, DefaultWizardStepGroup, } from "./layout-elements.js";
34
+ import { ActionOverflowMenu } from "./row-actions.js";
35
+ import { DefaultSecretReveal } from "./secret-reveal.js";
36
+ export { DefaultCard } from "./card.js";
37
+ export { BareFormProvider, EmbeddedFormProvider, ScreenWidthProvider } from "./contexts.js";
38
+ export { defaultCellRender, isComponentRendererRef } from "./data-table-cell.js";
39
+ export { FormScreenShell } from "./form.js";
40
+ export { END_LABEL_MIN_ROWS } from "./pager.js";
41
+ // applyFormatSpec re-exported from headless (platform-agnostic).
42
+ export { applyFormatSpec };
2021
43
  export const defaultPrimitives = {
2022
44
  Button: DefaultButton,
2023
45
  Banner: DefaultBanner,