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