@cosmicdrift/kumiko-renderer 0.383.1 → 0.385.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app/action-form-body.d.ts +50 -0
- package/dist/app/action-form-body.js +275 -0
- package/dist/app/client-actions.js +2 -2
- package/dist/app/config-edit-body.d.ts +9 -0
- package/dist/app/config-edit-body.js +340 -0
- package/dist/app/entity-edit-prefill.d.ts +27 -0
- package/dist/app/entity-edit-prefill.js +377 -0
- package/dist/app/entity-edit-screen.d.ts +11 -0
- package/dist/app/entity-edit-screen.js +395 -0
- package/dist/app/entity-list-screen.d.ts +10 -0
- package/dist/app/entity-list-screen.js +547 -0
- package/dist/app/kumiko-screen.d.ts +1 -36
- package/dist/app/kumiko-screen.js +16 -2855
- package/dist/app/projection-detail-body.d.ts +12 -0
- package/dist/app/projection-detail-body.js +464 -0
- package/dist/app/projection-detail-shim.d.ts +1 -1
- package/dist/app/projection-detail-shim.js +5 -1
- package/dist/app/projection-list-body.d.ts +10 -0
- package/dist/app/projection-list-body.js +245 -0
- package/dist/app/projection-list-shim.d.ts +2 -1
- package/dist/app/projection-list-shim.js +4 -0
- package/dist/app/reference-facet-bridge.js +2 -2
- package/dist/app/row-actions.js +3 -4
- package/dist/app/screen-shared.d.ts +72 -0
- package/dist/app/screen-shared.js +325 -0
- package/dist/app/secret-mint-body.js +3 -3
- package/dist/components/edit-mounts.d.ts +33 -0
- package/dist/components/edit-mounts.js +53 -0
- package/dist/components/error-boundary.js +2 -2
- package/dist/components/reference-create-dialog.js +1 -1
- package/dist/components/related-list-section.js +2 -2
- package/dist/components/render-edit-helpers.d.ts +29 -0
- package/dist/components/render-edit-helpers.js +121 -0
- package/dist/components/render-edit.d.ts +1 -4
- package/dist/components/render-edit.js +41 -443
- package/dist/components/render-field.js +3 -4
- package/dist/components/render-list.js +2 -2
- package/dist/components/use-wizard-form-draft.d.ts +51 -0
- package/dist/components/use-wizard-form-draft.js +291 -0
- package/dist/dev-console.d.ts +3 -0
- package/dist/dev-console.js +15 -0
- package/dist/headless/contracts/primitives.js +14 -17
- package/dist/headless/format/index.js +25 -6
- package/dist/headless/view-model/edit.d.ts +1 -1
- package/dist/headless/view-model/edit.js +16 -17
- package/dist/headless/view-model/list.d.ts +1 -1
- package/dist/headless/view-model/list.js +3 -8
- package/dist/index.d.ts +2 -1
- package/dist/index.js +5 -5
- package/dist/live/error-mapping.js +3 -4
- package/dist/web/app/create-app.js +5 -8
- package/dist/web/app/dashboard-body.js +4 -599
- package/dist/web/app/dashboard-panels.d.ts +53 -0
- package/dist/web/app/dashboard-panels.js +373 -0
- package/dist/web/app/dashboard-shared.d.ts +90 -0
- package/dist/web/app/dashboard-shared.js +171 -0
- package/dist/web/app/dashboard-text.d.ts +6 -0
- package/dist/web/app/dashboard-text.js +65 -0
- package/dist/web/layout/nav-tree.js +3 -4
- package/dist/web/layout/target-resolver-stub.js +2 -2
- package/dist/web/primitives/banner.d.ts +4 -0
- package/dist/web/primitives/banner.js +28 -0
- package/dist/web/primitives/button.d.ts +7 -0
- package/dist/web/primitives/button.js +48 -0
- package/dist/web/primitives/calendar-day-picker.d.ts +13 -0
- package/dist/web/primitives/calendar-day-picker.js +38 -0
- package/dist/web/primitives/calendar-popover.d.ts +1 -1
- package/dist/web/primitives/calendar-popover.js +5 -39
- package/dist/web/primitives/card-styles.d.ts +8 -0
- package/dist/web/primitives/card-styles.js +18 -0
- package/dist/web/primitives/card.d.ts +4 -0
- package/dist/web/primitives/card.js +41 -0
- package/dist/web/primitives/contexts.d.ts +22 -0
- package/dist/web/primitives/contexts.js +50 -0
- package/dist/web/primitives/data-table-cell.d.ts +76 -0
- package/dist/web/primitives/data-table-cell.js +403 -0
- package/dist/web/primitives/data-table.d.ts +4 -0
- package/dist/web/primitives/data-table.js +279 -0
- package/dist/web/primitives/default-form.d.ts +4 -0
- package/dist/web/primitives/default-form.js +79 -0
- package/dist/web/primitives/facet-filter.d.ts +14 -0
- package/dist/web/primitives/facet-filter.js +39 -0
- package/dist/web/primitives/field.d.ts +63 -0
- package/dist/web/primitives/field.js +181 -0
- package/dist/web/primitives/file-thumbnail.js +69 -7
- package/dist/web/primitives/form.d.ts +53 -0
- package/dist/web/primitives/form.js +252 -0
- package/dist/web/primitives/index.d.ts +7 -30
- package/dist/web/primitives/index.js +27 -2005
- package/dist/web/primitives/input.d.ts +4 -0
- package/dist/web/primitives/input.js +166 -0
- package/dist/web/primitives/layout-elements.d.ts +14 -0
- package/dist/web/primitives/layout-elements.js +194 -0
- package/dist/web/primitives/modal.js +1 -1
- package/dist/web/primitives/pager.d.ts +22 -0
- package/dist/web/primitives/pager.js +89 -0
- package/dist/web/primitives/row-actions.d.ts +13 -0
- package/dist/web/primitives/row-actions.js +178 -0
- package/dist/web/primitives/secret-reveal.d.ts +4 -0
- package/dist/web/primitives/secret-reveal.js +67 -0
- package/dist/web/tokens.js +2 -1
- package/package.json +5 -5
- package/src/changes.json +24 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { createContext } from "react";
|
|
3
|
+
import "../react-css-custom-properties.js";
|
|
4
|
+
// Boolean optics depend on the surrounding Field layout: layout="inline"
|
|
5
|
+
// (checkbox lists like MultiSelectCheckboxes, the BooleanField widget) keeps
|
|
6
|
+
// the checkbox, the standard form field (layout="stacked"/default) gets the
|
|
7
|
+
// new switch. Pure web-renderer rendering decision, no contract field needed
|
|
8
|
+
// — Field hands its resolved layout down to the nested Input via context.
|
|
9
|
+
export const FieldLayoutContext = createContext("stacked");
|
|
10
|
+
// True inside the sections of a card-less screen form: sections then drop their
|
|
11
|
+
// own horizontal padding, the screen form's scroll surface provides it.
|
|
12
|
+
export const ScreenFormContext = createContext(false);
|
|
13
|
+
export const DrawerBodyContext = createContext(false);
|
|
14
|
+
// aria-labelledby into a Field label that doesn't exist leaves a radiogroup
|
|
15
|
+
// without any accessible name — fall back to the control's name instead.
|
|
16
|
+
export const FieldLabelledContext = createContext(false);
|
|
17
|
+
// The Field description is the control's accessible description; the control
|
|
18
|
+
// is a descendant (not a prop of Field), so the id travels via context. Always
|
|
19
|
+
// provided (undefined without a description) so a nested Field never inherits
|
|
20
|
+
// its parent's description id.
|
|
21
|
+
export const FieldDescriptionContext = createContext(undefined);
|
|
22
|
+
// ---- Form + Section + Grid + Text ----
|
|
23
|
+
// Puts DefaultSection into inner-region mode: the whole form is ONE card,
|
|
24
|
+
// sections are divider-separated parts inside it (shadcn pattern like
|
|
25
|
+
// Shipping/Invoice/Profile). Standalone (outside a form) a section stays its
|
|
26
|
+
// own card.
|
|
27
|
+
export const InsideFormContext = createContext(false);
|
|
28
|
+
// Embedded forms (e.g. in AuthCard) already get their card surface from the
|
|
29
|
+
// container; the self-carding DefaultForm would otherwise produce a
|
|
30
|
+
// card-in-card. BareFormProvider switches DefaultForm to a bare <form>
|
|
31
|
+
// (stacked fields, no frame of its own/max-width).
|
|
32
|
+
export const BareFormContext = createContext(false);
|
|
33
|
+
// Dashboard panels with an embedded settings form (`DashboardScreenPanel.chromeless`):
|
|
34
|
+
// the form renders in the page grid like a standalone screen form, without the
|
|
35
|
+
// card frame, but keeps its own (non-pinned) save bar.
|
|
36
|
+
export const EmbeddedFormContext = createContext(false);
|
|
37
|
+
export function EmbeddedFormProvider({ children }) {
|
|
38
|
+
return _jsx(EmbeddedFormContext.Provider, { value: true, children: children });
|
|
39
|
+
}
|
|
40
|
+
export function BareFormProvider({ children }) {
|
|
41
|
+
return _jsx(BareFormContext.Provider, { value: true, children: children });
|
|
42
|
+
}
|
|
43
|
+
// App-wide default for FormScreenShell's width when a screen doesn't set its
|
|
44
|
+
// own `layout.width`. Undefined (no `createKumikoApp({ screenWidth })`) lets each
|
|
45
|
+
// shell keep its own default: "4xl" for FormScreenShell, 640px for the
|
|
46
|
+
// screen-form column.
|
|
47
|
+
export const ScreenWidthContext = createContext(undefined);
|
|
48
|
+
export function ScreenWidthProvider({ width, children, }) {
|
|
49
|
+
return _jsx(ScreenWidthContext.Provider, { value: width, children: children });
|
|
50
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { SelectOptionTone } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
|
+
import { type ReactNode } from "react";
|
|
3
|
+
import type { CellLink, CellLinkItem, ListColumnFileDisplay, ListRowViewModel, RuntimeCellLink, RuntimeCellLinkItems } from "../../headless/index.ts";
|
|
4
|
+
import type { DataTableSort } from "../../index.ts";
|
|
5
|
+
import { type DataTableRowGrouping } from "../../index.ts";
|
|
6
|
+
import "../react-css-custom-properties.ts";
|
|
7
|
+
export declare function isEmptyCellValue(value: unknown): boolean;
|
|
8
|
+
export declare function isUnlabeledFalse(type: string, value: unknown, renderer: unknown): boolean;
|
|
9
|
+
export declare function showsBareCheckMark(type: string, value: unknown, renderer: unknown): boolean;
|
|
10
|
+
export declare function isBadgeColumn(col: {
|
|
11
|
+
readonly type: string;
|
|
12
|
+
readonly renderer?: unknown;
|
|
13
|
+
}): boolean;
|
|
14
|
+
export declare function FirstCellLink({ enabled, empty, emptyLabel, onOpen, children, }: {
|
|
15
|
+
readonly enabled: boolean;
|
|
16
|
+
readonly empty: boolean;
|
|
17
|
+
readonly emptyLabel?: string | undefined;
|
|
18
|
+
readonly onOpen: () => void;
|
|
19
|
+
readonly children: ReactNode;
|
|
20
|
+
}): ReactNode;
|
|
21
|
+
export declare const FIT_CONTENT_CELL_CLASS = "w-px";
|
|
22
|
+
export declare function SortableHeader({ field, label, sortable, highlighted, numeric, fitContent, sort, onSortChange, }: {
|
|
23
|
+
readonly field: string;
|
|
24
|
+
readonly label: string;
|
|
25
|
+
readonly sortable: boolean;
|
|
26
|
+
readonly highlighted?: boolean;
|
|
27
|
+
readonly numeric?: boolean;
|
|
28
|
+
readonly fitContent?: boolean;
|
|
29
|
+
readonly sort?: DataTableSort;
|
|
30
|
+
readonly onSortChange?: (next: DataTableSort | null) => void;
|
|
31
|
+
}): ReactNode;
|
|
32
|
+
export declare function isComponentRendererRef(renderer: unknown): {
|
|
33
|
+
readonly name: string;
|
|
34
|
+
} | undefined;
|
|
35
|
+
export declare function defaultCellRender(value: unknown, type: string, optionLabels?: Readonly<Record<string, string>>, locale?: string, columnKey?: string, grouping?: boolean): string;
|
|
36
|
+
export declare function cellTitle(value: unknown): string | undefined;
|
|
37
|
+
type DataTableCellProps = {
|
|
38
|
+
readonly value: unknown;
|
|
39
|
+
readonly row: Readonly<Record<string, unknown>>;
|
|
40
|
+
readonly field: string;
|
|
41
|
+
readonly type: string;
|
|
42
|
+
readonly renderer?: unknown;
|
|
43
|
+
readonly optionLabels?: Readonly<Record<string, string>>;
|
|
44
|
+
readonly optionTones?: Readonly<Partial<Record<string, SelectOptionTone>>>;
|
|
45
|
+
readonly onChange?: (value: unknown) => void;
|
|
46
|
+
readonly translate?: (key: string, params?: Readonly<Record<string, unknown>>) => string;
|
|
47
|
+
readonly locale?: string;
|
|
48
|
+
readonly grouping?: boolean;
|
|
49
|
+
readonly fileDisplay?: ListColumnFileDisplay;
|
|
50
|
+
readonly compactFile?: boolean;
|
|
51
|
+
readonly link?: CellLink;
|
|
52
|
+
readonly linkItems?: readonly CellLinkItem[];
|
|
53
|
+
};
|
|
54
|
+
export declare function cellLinkProp(col: {
|
|
55
|
+
readonly field: string;
|
|
56
|
+
readonly linkFor?: RuntimeCellLink | undefined;
|
|
57
|
+
readonly linkItemsFor?: RuntimeCellLinkItems | undefined;
|
|
58
|
+
}, row: ListRowViewModel, allowed: boolean): {
|
|
59
|
+
readonly link?: CellLink;
|
|
60
|
+
readonly linkItems?: readonly CellLinkItem[];
|
|
61
|
+
};
|
|
62
|
+
export type TableItem = {
|
|
63
|
+
readonly kind: "group";
|
|
64
|
+
readonly key: string;
|
|
65
|
+
readonly label: string;
|
|
66
|
+
readonly collapsed: boolean;
|
|
67
|
+
} | {
|
|
68
|
+
readonly kind: "row";
|
|
69
|
+
readonly row: ListRowViewModel;
|
|
70
|
+
};
|
|
71
|
+
export declare function buildTableItems(rows: readonly ListRowViewModel[], grouping: DataTableRowGrouping | undefined, toggledGroups: ReadonlySet<string>): readonly TableItem[];
|
|
72
|
+
export declare function rowToneClass(tone: SelectOptionTone | undefined): string;
|
|
73
|
+
export declare const NUMERIC_COLUMN_TYPES: ReadonlySet<string>;
|
|
74
|
+
export declare const TABULAR_COLUMN_TYPES: ReadonlySet<string>;
|
|
75
|
+
export declare function DataTableCell({ value, row, field, type, renderer, optionLabels, optionTones, onChange, translate, locale, grouping, fileDisplay, compactFile, link, linkItems, }: DataTableCellProps): ReactNode;
|
|
76
|
+
export {};
|
|
@@ -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;
|