@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
@@ -0,0 +1,403 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { PII_ERASED_SENTINEL } from "@cosmicdrift/kumiko-types/kms-adapter-types";
3
+ import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
4
+ import { ArrowDown, ArrowUp, ArrowUpDown, Check } from "lucide-react";
5
+ import { Fragment } from "react";
6
+ import { rendererWarn } from "../../dev-console.js";
7
+ import { applyFormatSpec, EMPTY_VALUE_PLACEHOLDER, isSafeHref } from "../../headless/index.js";
8
+ import { statusToneForOptionTone, statusToneForValue, useColumnRenderer, useOptionalPrimitives, } from "../../index.js";
9
+ import { cn } from "../lib/cn.js";
10
+ import { Badge } from "../ui/badge.js";
11
+ import { TableHead } from "../ui/table.js";
12
+ import { StatusBadge } from "../widgets/status-badge.js";
13
+ import { FileDisplayCell } from "./file-thumbnail.js";
14
+ import { currencyDecimals, formatMoney } from "./money-input.js";
15
+ import "../react-css-custom-properties.js";
16
+ import { isPlainPrimaryClick } from "./layout-elements.js";
17
+ export function isEmptyCellValue(value) {
18
+ return (value === null || value === undefined || (typeof value === "string" && value.trim() === ""));
19
+ }
20
+ // A boolean false renders as an empty string unless the column's format spec names a falseLabel.
21
+ // Projection lists synthesize every column as type "text", so a boolean format spec
22
+ // marks a boolean column on its own.
23
+ function isBooleanColumn(type, renderer) {
24
+ if (type === "boolean")
25
+ return true;
26
+ return (typeof renderer === "object" &&
27
+ renderer !== null &&
28
+ "format" in renderer &&
29
+ renderer.format === "boolean");
30
+ }
31
+ export function isUnlabeledFalse(type, value, renderer) {
32
+ if (value !== false || !isBooleanColumn(type, renderer))
33
+ return false;
34
+ if (renderer === undefined)
35
+ return true;
36
+ return (typeof renderer === "object" &&
37
+ renderer !== null &&
38
+ "format" in renderer &&
39
+ renderer.format === "boolean" &&
40
+ !("falseLabel" in renderer));
41
+ }
42
+ function isUnlabeledBooleanFormat(renderer) {
43
+ return (typeof renderer === "object" &&
44
+ renderer !== null &&
45
+ "format" in renderer &&
46
+ renderer.format === "boolean" &&
47
+ !("trueLabel" in renderer) &&
48
+ !("falseLabel" in renderer));
49
+ }
50
+ function BooleanCell({ value, translate, }) {
51
+ const label = value
52
+ ? (translate?.("kumiko.boolean.yes") ?? "Yes")
53
+ : (translate?.("kumiko.boolean.no") ?? "No");
54
+ 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 })] }));
55
+ }
56
+ // True when the cell would render the default "✓": no renderer, or a boolean
57
+ // format spec that names no trueLabel of its own.
58
+ export function showsBareCheckMark(type, value, renderer) {
59
+ if (value !== true || !isBooleanColumn(type, renderer))
60
+ return false;
61
+ if (renderer === undefined)
62
+ return true;
63
+ return (typeof renderer === "object" &&
64
+ renderer !== null &&
65
+ "format" in renderer &&
66
+ !("trueLabel" in renderer));
67
+ }
68
+ // Badges (select pills, component renderers) must never be clipped by the card's meta truncation.
69
+ export function isBadgeColumn(col) {
70
+ return (col.type === "select" ||
71
+ col.type === "multiSelect" ||
72
+ isComponentRendererRef(col.renderer) !== undefined);
73
+ }
74
+ // The first cell is the keyboard-reachable entry point of a clickable row; the
75
+ // row's own onClick keeps serving the mouse. Enter is handled explicitly so
76
+ // it also opens the row where a button does not synthesize a click.
77
+ export function FirstCellLink({ enabled, empty, emptyLabel, onOpen, children, }) {
78
+ if (!enabled || (empty && emptyLabel === undefined))
79
+ return children;
80
+ 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) => {
81
+ e.stopPropagation();
82
+ onOpen();
83
+ }, onKeyDown: (e) => {
84
+ if (e.key !== "Enter")
85
+ return;
86
+ e.preventDefault();
87
+ e.stopPropagation();
88
+ onOpen();
89
+ }, children: empty ? _jsx("span", { className: "sr-only", children: emptyLabel }) : children }));
90
+ }
91
+ // SortableHeader: renders the th header per column, with or without
92
+ // click-sort. Three paths:
93
+ // (a) sortable=false OR no onSortChange -> plain label, no cursor
94
+ // interaction (DataTable purely as a view without sort wiring).
95
+ // (b) sortable=true + onSortChange -> header is a button, click
96
+ // cycles asc -> desc -> null. aria-sort mirrors the state.
97
+ // (c) sortable=true in the schema but no onSortChange prop -> still
98
+ // label-only, but data-sortable=true stays so tests that know the
99
+ // schema view keep working.
100
+ // w-px on an auto-layout table cell shrinks the column to its content (the
101
+ // actions column relies on the same behaviour).
102
+ export const FIT_CONTENT_CELL_CLASS = "w-px";
103
+ export function SortableHeader({ field, label, sortable, highlighted, numeric, fitContent, sort, onSortChange, }) {
104
+ const active = sort?.field === field ? sort : undefined;
105
+ const ariaSort = active?.dir === "asc" ? "ascending" : active?.dir === "desc" ? "descending" : "none";
106
+ if (!sortable || onSortChange === undefined) {
107
+ 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 }));
108
+ }
109
+ const Icon = active?.dir === "asc" ? ArrowUp : active?.dir === "desc" ? ArrowDown : ArrowUpDown;
110
+ const next = nextSortState(active?.dir, field);
111
+ return (_jsx(TableHead, { "data-testid": `column-${field}`, "data-sortable": "true", "data-highlighted": highlighted === true ? "true" : undefined, "aria-sort": ariaSort, className: cn(active !== undefined
112
+ ? "font-semibold text-foreground"
113
+ : "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 &&
114
+ "opacity-0 group-hover/sort:opacity-40 group-focus-visible/sort:opacity-40"), "aria-hidden": "true" })] }) }));
115
+ }
116
+ // 3-state toggle: no sort -> asc -> desc -> no sort. Idiomatic for
117
+ // power-user lists where "I want the server default order back" is a
118
+ // real action (instead of toggling asc<->desc forever).
119
+ function nextSortState(current, field) {
120
+ if (current === undefined)
121
+ return { field, dir: "asc" };
122
+ if (current === "asc")
123
+ return { field, dir: "desc" };
124
+ return null;
125
+ }
126
+ // Type guard for the `{ react: { __component: "Name" } }` form in which
127
+ // PlatformComponent renderers arrive serialized in the schema. Schemas
128
+ // travel over the wire (server -> client), real component refs would break
129
+ // that; the string key is the single source of truth.
130
+ export function isComponentRendererRef(renderer) {
131
+ if (renderer === null || typeof renderer !== "object")
132
+ return undefined;
133
+ const reactBranch = propertyOf(renderer, "react");
134
+ if (reactBranch === null || typeof reactBranch !== "object")
135
+ return undefined;
136
+ const component = propertyOf(reactBranch, "__component");
137
+ if (typeof component !== "string" || component.length === 0)
138
+ return undefined;
139
+ return { name: component };
140
+ }
141
+ function isMoneyValue(value) {
142
+ const currency = propertyOf(value, "currency");
143
+ return (typeof value === "object" &&
144
+ value !== null &&
145
+ typeof propertyOf(value, "amount") === "number" &&
146
+ typeof currency === "string" &&
147
+ /^[A-Za-z]{3}$/.test(currency));
148
+ }
149
+ // Type-specific default cell renderers. The author can set an explicit
150
+ // renderer per column (FormatSpec or PlatformComponent); without an
151
+ // explicit renderer DataTableCell falls through to here.
152
+ //
153
+ // - boolean -> check mark / empty
154
+ // - timestamp/date -> locale-formatted (no raw ISO string)
155
+ // - number/decimal/bigInt → locale-formatted via Intl.NumberFormat (fw#2160)
156
+ // - select/multiSelect → human-readable (kebab-case → Title Case), multiSelect values joined with ", "
157
+ // - money → { amount, currency } formatted via Intl (not "[object Object]")
158
+ // - text/else -> toString
159
+ const ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/;
160
+ // Deduplicated per column key only (not per screen): a once-per-field-name
161
+ // hint, so a second screen reusing the same field name stays silent.
162
+ const warnedTimestampColumns = new Set();
163
+ // A raw ISO string in a "text" column means the author forgot `renderer:
164
+ // { format: "timestamp" }` — guessing a format here could be wrong, so we
165
+ // warn and leave the value as-is instead of auto-formatting.
166
+ function warnMissingTimestampFormat(columnKey) {
167
+ if (typeof process === "undefined" || process.env.NODE_ENV === "production")
168
+ return;
169
+ const key = columnKey ?? "<unknown column>";
170
+ if (warnedTimestampColumns.has(key))
171
+ return;
172
+ warnedTimestampColumns.add(key);
173
+ rendererWarn(`[kumiko] column "${key}" renders a raw ISO timestamp as text — add renderer: { format: "timestamp" } to its column definition.`);
174
+ }
175
+ export function defaultCellRender(value, type, optionLabels, locale, columnKey, grouping = true) {
176
+ if (value === null || value === undefined || value === "")
177
+ return "";
178
+ if (type === "boolean")
179
+ return value === true ? "✓" : "";
180
+ if (type === "timestamp" || type === "date") {
181
+ return applyFormatSpec({ format: type, locale }, value);
182
+ }
183
+ if (type === "number" || type === "decimal" || type === "bigInt") {
184
+ return applyFormatSpec({ format: type, locale, grouping }, value);
185
+ }
186
+ if (type === "money") {
187
+ if (!isMoneyValue(value))
188
+ return String(value);
189
+ // formatMoney expects minor units scaled by currencyDecimals(currency).
190
+ // rehydrateMoney's `amountMinor` is scaled by a flat MINOR_UNIT_SCALE=100
191
+ // instead, which disagrees with currencyDecimals for non-2-decimal
192
+ // currencies (JPY: 0 decimals → 100x too high; BHD: 3 decimals → 10x too
193
+ // low). Deriving minor units from `amount` keeps this consistent with
194
+ // render-field.tsx's moneyMinorValue.
195
+ const minor = Math.round(value.amount * 10 ** currencyDecimals(value.currency));
196
+ return formatMoney(minor, value.currency, locale);
197
+ }
198
+ if (type === "select" || type === "multiSelect") {
199
+ const values = Array.isArray(value) ? value : [value];
200
+ return values
201
+ .map((v) => {
202
+ const raw = String(v);
203
+ // Translated label from the view-model builder (convention key
204
+ // `<feature>:entity:<entity>:field:<field>:option:<value>`).
205
+ // Fallback to humanizeSlug when no label is registered — same
206
+ // behavior as before the optionLabels patch.
207
+ const translated = optionLabels?.[raw];
208
+ return translated !== undefined && translated !== raw ? translated : humanizeSlug(raw);
209
+ })
210
+ .join(", ");
211
+ }
212
+ if (type === "text" && typeof value === "string" && ISO_DATETIME_RE.test(value)) {
213
+ warnMissingTimestampFormat(columnKey);
214
+ }
215
+ return typeof value === "string" ? value : String(value);
216
+ }
217
+ function humanizeSlug(slug) {
218
+ // "degraded-performance" → "Degraded performance". A dotted value such as
219
+ // "mobile.de" is a domain, not a slug, and must stay as stored.
220
+ if (slug.includes("."))
221
+ return slug;
222
+ const spaced = slug.replace(/[-_]/g, " ");
223
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1);
224
+ }
225
+ // Tooltip text for truncated cells: on hover the browser shows the
226
+ // full text. Skipped for object/array (not user-readable);
227
+ // the browser stringifies numbers correctly anyway; booleans get no raw true/false tooltip.
228
+ export function cellTitle(value) {
229
+ if (value === null || value === undefined)
230
+ return undefined;
231
+ if (typeof value === "string")
232
+ return value.length > 0 ? value : undefined;
233
+ if (typeof value === "number")
234
+ return String(value);
235
+ return undefined;
236
+ }
237
+ function selectPill(value, optionLabels, optionTones, locale, RegisteredStatusBadge) {
238
+ const label = defaultCellRender(value, "select", optionLabels, locale);
239
+ const declaredTone = typeof value === "string" && optionTones && Object.hasOwn(optionTones, value)
240
+ ? optionTones[value]
241
+ : undefined;
242
+ const tone = (declaredTone !== undefined ? statusToneForOptionTone(declaredTone) : undefined) ??
243
+ (typeof value === "string" ? statusToneForValue(value) : undefined);
244
+ if (tone !== undefined) {
245
+ // Same registered primitive as the projectionDetail header, so an app-provided badge applies to list cells too.
246
+ if (RegisteredStatusBadge !== undefined) {
247
+ return _jsx(RegisteredStatusBadge, { value: label, tone: tone });
248
+ }
249
+ return _jsx(StatusBadge, { tone: tone, children: label });
250
+ }
251
+ // dashboard-01 pattern: outline badge + muted instead of a filled secondary.
252
+ return (_jsx(Badge, { variant: "outline", className: "px-1.5 text-muted-foreground", children: label }));
253
+ }
254
+ // A multi reference may list the same id twice; the occurrence count keeps React keys unique.
255
+ function uniqueLinkItemKeys(items) {
256
+ const seen = new Map();
257
+ return items.map((item) => {
258
+ const occurrence = seen.get(item.id) ?? 0;
259
+ seen.set(item.id, occurrence + 1);
260
+ return `${item.id}#${occurrence}`;
261
+ });
262
+ }
263
+ // A link nested in the clickable first-cell button or card button would be an
264
+ // <a> inside a <button>, so those cells stay plain text.
265
+ export function cellLinkProp(col, row, allowed) {
266
+ if (!allowed)
267
+ return {};
268
+ const link = col.linkFor?.(row.values[col.field], row.values);
269
+ const linkItems = col.linkItemsFor?.(row.values[col.field], row.values);
270
+ return {
271
+ ...(link !== undefined && { link }),
272
+ ...(linkItems !== undefined && { linkItems }),
273
+ };
274
+ }
275
+ export function buildTableItems(rows, grouping, toggledGroups) {
276
+ if (grouping === undefined)
277
+ return rows.map((row) => ({ kind: "row", row }));
278
+ const groups = new Map();
279
+ for (const row of rows) {
280
+ const key = grouping.keyOf(row);
281
+ const members = groups.get(key);
282
+ if (members === undefined)
283
+ groups.set(key, [row]);
284
+ else
285
+ members.push(row);
286
+ }
287
+ return [...groups].flatMap(([key, members]) => {
288
+ const label = grouping.headerLabel(key, members);
289
+ if (label === undefined)
290
+ return members.map((row) => ({ kind: "row", row }));
291
+ const collapsed = grouping.startsCollapsed(key) !== toggledGroups.has(key);
292
+ return [
293
+ { kind: "group", key, label, collapsed },
294
+ ...(collapsed ? [] : members.map((row) => ({ kind: "row", row }))),
295
+ ];
296
+ });
297
+ }
298
+ const ROW_TONE_CLASS = {
299
+ bad: "bg-status-bad/10 hover:bg-status-bad/15",
300
+ warn: "bg-status-warn/10 hover:bg-status-warn/15",
301
+ ok: "bg-status-ok/10 hover:bg-status-ok/15",
302
+ neutral: "",
303
+ };
304
+ export function rowToneClass(tone) {
305
+ return tone === undefined ? "" : ROW_TONE_CLASS[tone];
306
+ }
307
+ export const NUMERIC_COLUMN_TYPES = new Set([
308
+ "number",
309
+ "decimal",
310
+ "bigInt",
311
+ "money",
312
+ ]);
313
+ // Digits stand in columns here, so they need equal-width figures; the body no longer sets tabular-nums.
314
+ export const TABULAR_COLUMN_TYPES = new Set([
315
+ ...NUMERIC_COLUMN_TYPES,
316
+ "date",
317
+ "timestamp",
318
+ "locatedTimestamp",
319
+ ]);
320
+ // A plain <a>, not DefaultLink: the click must not reach the row's own click
321
+ // handler, which opens the same record's row action.
322
+ function CellTextLink({ link, children, }) {
323
+ return (_jsx("a", { href: isSafeHref(link.href) ? link.href : "#", className: "text-primary underline-offset-4 hover:underline", onClick: (event) => {
324
+ event.stopPropagation();
325
+ if (!isPlainPrimaryClick(event))
326
+ return;
327
+ event.preventDefault();
328
+ link.onPress();
329
+ }, children: children }));
330
+ }
331
+ // Cell renderer as a component (instead of a plain function) so the
332
+ // useColumnRenderer hook can read from the provider. The four paths:
333
+ // 1. FormatSpec (`{ format: "timestamp" }` etc.) -> applyFormatSpec.
334
+ // 2. RuntimeRenderer (function) -> direct call. Only for render-list-
335
+ // internal reference-lookup closures, never from the serialized schema.
336
+ // 3. PlatformComponent (`{ react: { __component: "X" } }`) -> looks up
337
+ // "X" via useColumnRenderer and renders `<X value row column/>`.
338
+ // Not registered -> one-time warning + default fallback.
339
+ // 4. Otherwise -> defaultCellRender (type-based string renderer).
340
+ export function DataTableCell({ value, row, field, type, renderer, optionLabels, optionTones, onChange, translate, locale, grouping = true, fileDisplay, compactFile = false, link, linkItems, }) {
341
+ const componentRef = isComponentRendererRef(renderer);
342
+ const ResolvedComponent = useColumnRenderer(componentRef?.name);
343
+ const RegisteredStatusBadge = useOptionalPrimitives()?.StatusBadge;
344
+ if (isEmptyCellValue(value) && typeof renderer !== "function" && componentRef === undefined) {
345
+ return (_jsx("span", { "data-empty-cell": "true", className: "text-muted-foreground", children: EMPTY_VALUE_PLACEHOLDER }));
346
+ }
347
+ if (value === PII_ERASED_SENTINEL && translate !== undefined) {
348
+ return translate("kumiko.pii.erased");
349
+ }
350
+ if (typeof value === "boolean" &&
351
+ isBooleanColumn(type, renderer) &&
352
+ (renderer === undefined || isUnlabeledBooleanFormat(renderer))) {
353
+ return _jsx(BooleanCell, { value: value, translate: translate });
354
+ }
355
+ if (isUnlabeledFalse(type, value, renderer)) {
356
+ return (_jsx("span", { "data-empty-cell": "true", className: "text-muted-foreground", children: EMPTY_VALUE_PLACEHOLDER }));
357
+ }
358
+ if (fileDisplay !== undefined) {
359
+ return (_jsx(FileDisplayCell, { value: value, display: fileDisplay, compact: compactFile, fallbackLabel: translate?.("kumiko.field.file-upload.attached") ?? "File attached" }));
360
+ }
361
+ if (typeof renderer === "object" && renderer !== null && "format" in renderer) {
362
+ const spec = renderer;
363
+ return applyFormatSpec({ ...spec, locale: spec.locale ?? locale }, value, translate);
364
+ }
365
+ if (typeof renderer === "function") {
366
+ if (linkItems !== undefined) {
367
+ const linkItemKeys = uniqueLinkItemKeys(linkItems);
368
+ 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]))) }));
369
+ }
370
+ const fn = renderer;
371
+ const text = fn(value, row);
372
+ return link === undefined ? text : _jsx(CellTextLink, { link: link, children: text });
373
+ }
374
+ if (componentRef !== undefined) {
375
+ if (ResolvedComponent !== undefined) {
376
+ const node = (_jsx(ResolvedComponent, { value: value, row: row, column: { field }, ...(onChange !== undefined && { onChange }) }));
377
+ // stopPropagation only on the editable branch — plain cells must still
378
+ // fire row onClick when onCellChange is set for sibling editable cells.
379
+ if (onChange !== undefined) {
380
+ return (_jsx("span", { className: "contents", onClick: (e) => e.stopPropagation(), onKeyDown: (e) => e.stopPropagation(), children: node }));
381
+ }
382
+ return node;
383
+ }
384
+ // Renderer referenced in the schema but no map entry client-side —
385
+ // typical case: clientFeatures.columnRenderers forgotten or a typo in the
386
+ // __component key. Warn instead of crashing so a schema boot still works
387
+ // (the default type renderer takes over).
388
+ rendererWarn(`[kumiko] columnRenderer "${componentRef.name}" not registered`);
389
+ }
390
+ // A select value renders as a pill instead of plain text. When the raw
391
+ // value is a known status word, it carries the same tone as the
392
+ // projectionDetail header badge — a status column stayed grey while the
393
+ // detail view of the same value was coloured (fw#2579). Unknown values
394
+ // keep the neutral outline pill.
395
+ if (type === "select" && value !== null && value !== undefined && value !== "") {
396
+ return selectPill(value, optionLabels, optionTones, locale, RegisteredStatusBadge);
397
+ }
398
+ // One pill per picked value; a single pill would hold the whole joined list.
399
+ if (type === "multiSelect" && Array.isArray(value) && value.length > 0) {
400
+ return (_jsx("span", { className: "flex flex-wrap gap-1", children: value.map((entry) => (_jsx(Fragment, { children: selectPill(entry, optionLabels, optionTones, locale, RegisteredStatusBadge) }, String(entry)))) }));
401
+ }
402
+ return defaultCellRender(value, type, optionLabels, locale, field, grouping);
403
+ }
@@ -0,0 +1,4 @@
1
+ import { type ReactNode } from "react";
2
+ import { type DataTableProps } from "../../index.ts";
3
+ import "../react-css-custom-properties.ts";
4
+ export declare 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, }: DataTableProps): ReactNode;