@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,279 @@
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 { ArrowDown, ArrowUp, ChevronDown, ChevronRight, Search, SlidersHorizontal, X, } from "lucide-react";
4
+ import { Fragment, useId, useState, } from "react";
5
+ import { useOptionalLocale, useOptionalTranslation } from "../../index.js";
6
+ import { cn } from "../lib/cn.js";
7
+ import { Button as UiButton } from "../ui/button.js";
8
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table.js";
9
+ import { CardLeadingImage } from "./file-thumbnail.js";
10
+ import { screenPaddingClassName } from "./layout.js";
11
+ import { useIsNarrowViewport } from "./use-narrow-viewport.js";
12
+ import "../react-css-custom-properties.js";
13
+ import { buildTableItems, cellLinkProp, cellTitle, DataTableCell, FIT_CONTENT_CELL_CLASS, FirstCellLink, isBadgeColumn, isEmptyCellValue, isUnlabeledFalse, NUMERIC_COLUMN_TYPES, rowToneClass, SortableHeader, showsBareCheckMark, TABULAR_COLUMN_TYPES, } from "./data-table-cell.js";
14
+ import { DateRangeFacetFilter, FacetFilter } from "./facet-filter.js";
15
+ import { InfiniteSentinel, Pager, TABLE_FOOTER_BAR_CLASS } from "./pager.js";
16
+ import { RowActionsCell } from "./row-actions.js";
17
+ // The vendored Table wraps <table> in an `overflow-x-auto` container, which
18
+ // would become the scrollport of a sticky header. A scrollBody table scrolls
19
+ // in its parent instead, so it renders the bare <table>.
20
+ function FlushTable({ className, ...props }) {
21
+ return (_jsx("table", { "data-slot": "table", className: cn("w-full caption-bottom text-sm", className), ...props }));
22
+ }
23
+ // Header cells stick to the scroll surface; the bottom rule is an inset
24
+ // shadow because a border on a sticky cell scrolls away under border-collapse.
25
+ // Column weight (medium vs. semibold for the sorted one) lives on the th itself
26
+ // in SortableHeader: a `[&_th]:font-*` rule here would outrank it.
27
+ // Child selectors only: an expanded row nests a whole table inside a cell, and
28
+ // descendant selectors would make its header sticky and re-pad its cells.
29
+ 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");
30
+ // `!`: beats the first/last-child padding of FILL_TABLE_CLASS, which would
31
+ // otherwise widen the narrow toggle column / pad the full-width expansion cell.
32
+ // The nested table's sticky actions cell (last td) gets the muted surface
33
+ // instead of its own md:bg-background, so it blends into the area.
34
+ const EXPAND_TOGGLE_CELL_CLASS = "w-10 px-1! py-0!";
35
+ const EXPANSION_CELL_CLASS = "whitespace-normal bg-muted p-0! align-top md:[&_td:last-child]:bg-muted";
36
+ const EXPANSION_CONTENT_CLASS = "pt-1 pr-4 pb-3.5 pl-[52px]";
37
+ function RowExpandToggle({ expanded, controlsId, label, onToggle, testId, }) {
38
+ 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) => {
39
+ e.stopPropagation();
40
+ onToggle();
41
+ }, onKeyDown: (e) => e.stopPropagation(), children: _jsx(ChevronRight, { className: cn("size-4 transition-transform", expanded && "rotate-90"), "aria-hidden": "true" }) }));
42
+ }
43
+ function DataTableFooter({ count, itemNoun, testId, }) {
44
+ const t = useOptionalTranslation();
45
+ const noun = itemNoun?.(count);
46
+ const label = noun !== undefined
47
+ ? (t?.("kumiko.list.count.noun", { count: count.toLocaleString(), noun }) ??
48
+ `${count.toLocaleString()} ${noun}`)
49
+ : count === 1
50
+ ? (t?.("kumiko.list.count.one") ?? "1 entry")
51
+ : (t?.("kumiko.list.count.other", { count: count.toLocaleString() }) ??
52
+ `${count.toLocaleString()} entries`);
53
+ return (_jsx("div", { "data-testid": testId, className: TABLE_FOOTER_BAR_CLASS, children: _jsx("span", { "data-testid": `${testId}-count`, children: label }) }));
54
+ }
55
+ // Cell content is a ReactNode, so the tooltip text is read from the rendered DOM.
56
+ function showFullTextOnHover(event) {
57
+ const element = event.currentTarget;
58
+ element.title = element.scrollWidth > element.clientWidth ? (element.textContent ?? "") : "";
59
+ }
60
+ export 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, }) {
61
+ // One locale/translate subscription per table — not per cell (fw#2345).
62
+ // Optional hooks: a bare DataTable outside LocaleProvider must not crash.
63
+ const tableTranslate = useOptionalTranslation();
64
+ const tableLocale = useOptionalLocale();
65
+ // Below 768px a table scrolls its columns out of reach with no visible
66
+ // affordance (fw#2159 fixed the desktop case; narrow viewports never had
67
+ // one). Cards replace the table entirely below the breakpoint — same
68
+ // single-mount pattern as EmbeddedListInput/embedded-list-input.tsx.
69
+ const isNarrow = useIsNarrowViewport();
70
+ const [facetsOpenNarrow, setFacetsOpenNarrow] = useState(false);
71
+ const [toggledGroups, setToggledGroups] = useState(new Set());
72
+ // Toolbar wrapper: shared container for toolbar + table so the two belong
73
+ // together visually. The toolbar is NOT sticky: lists typically scroll with
74
+ // the page container, not internally. Sticky would compete with the topbar.
75
+ // The row click already runs rowClick actions, so with a clickable row they
76
+ // must not reappear in the kebab (a list whose only action is the rowClick
77
+ // one gets no actions column at all).
78
+ const rowIsLink = onRowClick !== undefined;
79
+ const menuActions = rowActions !== undefined && rowIsLink
80
+ ? rowActions.filter((action) => action.rowClick !== true)
81
+ : rowActions;
82
+ // The table keeps every action in "inline" mode, and with editable cells the
83
+ // first cell is not a keyboard link (FirstCellLink is off), so the kebab is
84
+ // then the only keyboard route to the rowClick action.
85
+ const tableActions = rowActionMode === "inline" || onCellChange !== undefined ? rowActions : menuActions;
86
+ const rowClickAction = rowActions?.find((action) => action.rowClick === true);
87
+ const rowClickLabel = rowClickAction?.label;
88
+ const rowClickIsHidden = (row) => rowClickAction?.isVisible !== undefined && !rowClickAction.isVisible(row);
89
+ const hasTableActions = tableActions !== undefined && tableActions.length > 0;
90
+ const isEmpty = rows.length === 0;
91
+ const expansionIdPrefix = useId();
92
+ const isRowExpandable = renderExpandedRow !== undefined && onToggleRowExpanded !== undefined;
93
+ const rowTestId = (row) => getRowTestId?.(row) ?? `row-${row.id}`;
94
+ const expansionDomId = (row) => `${expansionIdPrefix}-${row.id}`;
95
+ // The expand toggle of a row, shared by table and card layout. Named after
96
+ // the first data column, the one a user recognizes the row by.
97
+ function renderExpandToggle(row) {
98
+ if (!isRowExpandable)
99
+ return null;
100
+ const expanded = expandedRowIds?.has(row.id) === true;
101
+ const firstColumn = columns[0];
102
+ const title = (firstColumn !== undefined ? cellTitle(row.values[firstColumn.field]) : undefined) ?? "";
103
+ const labelKey = expanded ? "kumiko.list.row.collapse" : "kumiko.list.row.expand";
104
+ return (_jsx(RowExpandToggle, { expanded: expanded, controlsId: expansionDomId(row), label: tableTranslate?.(labelKey, { title }) ?? `${expanded ? "Collapse" : "Expand"} ${title}`, onToggle: () => onToggleRowExpanded(row.id), testId: `${rowTestId(row)}-toggle` }));
105
+ }
106
+ function renderExpansion(row, as, colSpan) {
107
+ if (!isRowExpandable || expandedRowIds?.has(row.id) !== true)
108
+ return null;
109
+ const content = renderExpandedRow(row);
110
+ if (as === "item") {
111
+ 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 }));
112
+ }
113
+ 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 }) }) }));
114
+ }
115
+ const fillsHeight = scrollBody === true;
116
+ const TableRoot = fillsHeight ? FlushTable : Table;
117
+ 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." }) }));
118
+ const tableItems = buildTableItems(rows, rowGrouping, toggledGroups);
119
+ function renderGroupToggle(item) {
120
+ return (_jsxs("button", { type: "button", "aria-expanded": !item.collapsed, "data-testid": `row-group-${item.key}-toggle`, onClick: () => setToggledGroups((prev) => {
121
+ const next = new Set(prev);
122
+ if (!next.delete(item.key))
123
+ next.add(item.key);
124
+ return next;
125
+ }), 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] }));
126
+ }
127
+ function tableInner() {
128
+ if (isEmpty)
129
+ return emptyBlock;
130
+ 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",
131
+ // From md: sticky right-0 + bg-muted (= header tone) so the
132
+ // action column stays at the right edge during horizontal
133
+ // scroll on wider screens. Below md, actions scroll in
134
+ // normal flow — sticky there would pin the column over the
135
+ // preceding data column instead of the (already narrow)
136
+ // page edge. No border-l: a permanent divider looks heavy;
137
+ // the sticky bg already sets the column apart during scroll.
138
+ 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) => {
139
+ if (item.kind === "group") {
140
+ 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}`));
141
+ }
142
+ const row = item.row;
143
+ const opensOnClick = rowIsLink && !rowClickIsHidden(row);
144
+ 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,
145
+ // Text wraps instead of truncating: nowrap cells keep their
146
+ // full width, so a wide table scrolled sideways and its
147
+ // last column slid under the sticky actions column with
148
+ // nothing visibly cut off. max-w-xs bounds a single column;
149
+ // badges and dates stay nowrap by their own classes.
150
+ className: cn(col.fileDisplay === undefined
151
+ ? "max-w-xs whitespace-normal break-words"
152
+ : FIT_CONTENT_CELL_CLASS, colIndex === 0
153
+ ? "font-medium text-foreground"
154
+ : "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 && {
155
+ optionLabels: col.optionLabels,
156
+ }), ...(col.optionTones !== undefined && { optionTones: col.optionTones }), ...(col.grouping !== undefined && { grouping: col.grouping }), ...(col.fileDisplay !== undefined && { fileDisplay: col.fileDisplay }), ...(onCellChange !== undefined && {
157
+ onChange: (value) => onCellChange(row.id, col.field, value),
158
+ }), ...cellLinkProp(col, row, !(colIndex === 0 && opensOnClick && onCellChange === undefined)) }) }) }, col.field))), hasTableActions && (_jsx(TableCell, { "data-testid": getCellTestId?.(row, "actions") ?? `cell-${row.id}-actions`,
159
+ // From md: sticky-right so the actions stay visible on the
160
+ // right edge during horizontal scroll. Below md, actions
161
+ // scroll with the row like any other cell — sticky there
162
+ // would hide the neighboring data column on narrow
163
+ // viewports. bg-background sets the column apart during
164
+ // scroll — no border-l (divider too heavy).
165
+ className: cn("whitespace-nowrap text-right md:sticky md:right-0 md:z-10 md:bg-background", fillsHeight && "md:bg-card"),
166
+ // Action-cell events must not trigger the row click/activation
167
+ // (typically "Open Detail" — the user wanted the action,
168
+ // not navigation). We stopPropagation for mouse and
169
+ // keyboard so a11y stays consistent.
170
+ 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));
171
+ }) })] }) }));
172
+ }
173
+ // No column headers below the breakpoint, so the click-to-sort affordance
174
+ // on SortableHeader has nothing to attach to. A single native <select> is
175
+ // the whole fix — no custom widget, no menu — fed straight from the
176
+ // columns marked sortable in the ViewModel. Options carry the resolved
177
+ // {field, dir} directly so onChange can look the pick up by value instead
178
+ // of parsing/casting the option string back apart.
179
+ const sortableColumns = columns.filter((col) => col.sortable);
180
+ const sortOptions = sortableColumns.flatMap((col) => [
181
+ { value: `${col.field}:asc`, field: col.field, dir: "asc", label: `${col.label} ↑` },
182
+ { value: `${col.field}:desc`, field: col.field, dir: "desc", label: `${col.label} ↓` },
183
+ ]);
184
+ const { image: cardImageColumn, title: cardTitleColumn, status: cardStatusColumn, meta: cardMetaColumns, } = cardColumnRoles(columns);
185
+ function cardCell(row, col) {
186
+ // A bare check mark in the subtitle line says nothing without its column.
187
+ if (showsBareCheckMark(col.type, row.values[col.field], col.renderer))
188
+ return col.label;
189
+ 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 && {
190
+ onChange: (value) => onCellChange(row.id, col.field, value),
191
+ }), ...cellLinkProp(col, row, !(rowIsLink && !rowClickIsHidden(row))) }));
192
+ }
193
+ function renderCard(row) {
194
+ const metaColumns = cardMetaColumns.filter((col) => !isEmptyCellValue(row.values[col.field]) &&
195
+ !isUnlabeledFalse(col.type, row.values[col.field], col.renderer));
196
+ const showStatus = cardStatusColumn !== undefined && !isEmptyCellValue(row.values[cardStatusColumn.field]);
197
+ const hasMenu = menuActions !== undefined && menuActions.length > 0;
198
+ const opensOnClick = rowIsLink && !rowClickIsHidden(row);
199
+ 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) ??
200
+ `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) ??
201
+ `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" }))] }));
202
+ 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) => {
203
+ if (e.key !== "Enter")
204
+ return;
205
+ e.preventDefault();
206
+ e.stopPropagation();
207
+ onRowClick(row);
208
+ }, 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));
209
+ if (!isRowExpandable)
210
+ return rowItem;
211
+ // The expansion is a sibling <li>, outside the row's click target, so
212
+ // clicks in the sub-area never navigate the parent row.
213
+ return (_jsxs(Fragment, { children: [rowItem, renderExpansion(row, "item", 0)] }, row.id));
214
+ }
215
+ function cardsInner() {
216
+ if (isEmpty)
217
+ return emptyBlock;
218
+ const sortLabel = sort !== undefined && sort !== null
219
+ ? (tableTranslate?.("kumiko.list.sort.by", {
220
+ column: columns.find((col) => col.field === sort.field)?.label ?? sort.field,
221
+ }) ?? `Sorted by ${sort.field}`)
222
+ : (tableTranslate?.("kumiko.list.sort.unsorted") ?? "Unsorted");
223
+ const SortArrow = sort?.dir === "desc" ? ArrowDown : ArrowUp;
224
+ // URL state can carry a sort on a column that is not sortable; without an
225
+ // option for it the select would claim "Unsorted" while the list is sorted.
226
+ const currentSortValue = sort !== undefined && sort !== null ? `${sort.field}:${sort.dir}` : "";
227
+ const currentSortIsUnlisted = sort !== undefined && sort !== null && !sortOptions.some((o) => o.value === currentSortValue);
228
+ 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) => {
229
+ const raw = e.target.value;
230
+ if (raw === "") {
231
+ onSortChange(null);
232
+ return;
233
+ }
234
+ const picked = sortOptions.find((o) => o.value === raw);
235
+ if (picked === undefined)
236
+ return;
237
+ onSortChange({ field: picked.field, dir: picked.dir });
238
+ }, 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))) })] }) }));
239
+ }
240
+ const tableContent = isNarrow ? cardsInner() : tableInner();
241
+ // The pager is ALWAYS rendered below the table (even with rows=[]) so the
242
+ // user can page back on a filter hit of zero without losing the list.
243
+ // Except total === 0: then there is nothing to paginate. Incompatible with
244
+ // infinite scroll: the caller sets either pager OR onReachEnd.
245
+ 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;
246
+ 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;
247
+ // With scrollBody the footer (pager, else the entry count) sits OUTSIDE the
248
+ // scroll surface so it stays put at any row count; the infinite sentinel
249
+ // stays INSIDE, or it would never intersect the scrollport.
250
+ 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] }));
251
+ const hasFacets = filterFacets !== undefined && filterFacets.length > 0 && onFilterChange !== undefined;
252
+ const hasDateRangeFacets = dateRangeFacets !== undefined && dateRangeFacets.length > 0 && onDateRangeChange !== undefined;
253
+ const hasActiveFilters = filterValues !== undefined && Object.values(filterValues).some((v) => v.length > 0);
254
+ const facetCluster = hasFacets || hasDateRangeFacets ? (_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [hasFacets &&
255
+ filterFacets.map((facet) => (_jsx(FacetFilter, { facet: facet, selected: filterValues?.[facet.field] ?? [], onChange: onFilterChange, dense: fillsHeight }, facet.field))), hasDateRangeFacets &&
256
+ 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;
257
+ const collapsesFacetsBehindButton = isNarrow && fillsHeight && facetCluster !== undefined;
258
+ const hasToolbar = toolbarStart !== undefined || toolbarEnd !== undefined || facetCluster !== undefined;
259
+ // dashboard-01 pattern: the toolbar (search + facets + "+ New") sits ABOVE
260
+ // the table in the same padding block; no separate bg bar, no screen title
261
+ // (that lives in the shell's breadcrumb).
262
+ return (_jsxs("div", { className: cn("flex flex-col w-full",
263
+ // scrollBody: the wrapper fills its container so the footer sits at the
264
+ // same spot at 3 and 300 rows; the 24px insets live in toolbar, cells
265
+ // and footer, so no outer padding.
266
+ fillsHeight
267
+ ? "h-full min-h-0"
268
+ : cn("gap-4", screenPadding === true
269
+ ? screenPaddingClassName
270
+ : screenPadding === false
271
+ ? undefined
272
+ : "p-6")), children: [hasToolbar && (_jsxs("div", { "data-testid": testId !== undefined ? `${testId}-toolbar` : "render-list-toolbar", className: cn("flex flex-wrap items-center", fillsHeight
273
+ ? isNarrow
274
+ ? "shrink-0 gap-2 p-3"
275
+ : "min-h-13 shrink-0 gap-2 px-6 py-2 md:py-0"
276
+ : "gap-3"), children: [toolbarStart !== undefined && (_jsxs("div", { className: cn(fillsHeight
277
+ ? "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"
278
+ : "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] }));
279
+ }
@@ -0,0 +1,4 @@
1
+ import { type ReactNode } from "react";
2
+ import { type FormProps } from "../../index.ts";
3
+ import "../react-css-custom-properties.ts";
4
+ export declare function DefaultForm({ onSubmit, focusFirstErrorKey, children, title, subtitle, actions, secondaryActions, testId, width, stickyActions, headerRegion, titleAction, fillHeight, chromeless, screenForm, unsavedCount, sectionNav, sideRail, summary, }: FormProps): ReactNode;
@@ -0,0 +1,79 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useContext } from "react";
3
+ import { InsideDrawerProvider, useInsideDrawer } from "../../index.js";
4
+ import { cn } from "../lib/cn.js";
5
+ import { screenFormColumnWidthClassName, screenPaddingClassName } from "./layout.js";
6
+ import { InsideScreenFormColumnContext } from "./metric.js";
7
+ import "../react-css-custom-properties.js";
8
+ import { cardSurface } from "./card-styles.js";
9
+ import { BareFormContext, DrawerBodyContext, EmbeddedFormContext, InsideFormContext, ScreenFormContext, ScreenWidthContext, } from "./contexts.js";
10
+ import { FormFooter, FormRoot, FormScreenShell, FormSectionNav, FormSections, FormTitleBlock, isPinnedFooter, SECTION_NAV_MIN_ITEMS, } from "./form.js";
11
+ // Form inside the Drawer primitive: the drawer header carries the title, so
12
+ // only the description (as help text), the scrolling sections and a pinned
13
+ // footer remain. The scroll area is a sibling of the footer, never its parent.
14
+ function DrawerFormLayout({ onSubmit, focusFirstErrorKey, subtitle, actions, secondaryActions, summary, testId, children, }) {
15
+ 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] }))] }));
16
+ }
17
+ export function DefaultForm({ onSubmit, focusFirstErrorKey, children, title, subtitle, actions, secondaryActions, testId, width, stickyActions, headerRegion, titleAction, fillHeight, chromeless, screenForm, unsavedCount, sectionNav, sideRail, summary, }) {
18
+ const insideDrawer = useInsideDrawer();
19
+ const isEmbeddedForm = useContext(EmbeddedFormContext);
20
+ const contextWidth = useContext(ScreenWidthContext);
21
+ // Embedded (AuthCard etc.): bare <form>, stacked fields with gap; the
22
+ // container carries card/title itself, otherwise card-in-card.
23
+ if (useContext(BareFormContext)) {
24
+ return (_jsxs(FormRoot, { onSubmit: onSubmit, focusFirstErrorKey: focusFirstErrorKey, testId: testId, className: cn("flex flex-col gap-4",
25
+ // Bare forms stack sections without a card; without a divider a
26
+ // section boundary reads as a layout gap, not structure.
27
+ "[&>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] }))] }));
28
+ }
29
+ if (insideDrawer) {
30
+ return (_jsx(DrawerFormLayout, { onSubmit: onSubmit, focusFirstErrorKey: focusFirstErrorKey, subtitle: subtitle, actions: actions, secondaryActions: secondaryActions, summary: summary, testId: testId, children: children }));
31
+ }
32
+ if (isEmbeddedForm) {
33
+ 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 }) }) }))] }));
34
+ }
35
+ const isScreenForm = screenForm === true && fillHeight === true && chromeless !== true;
36
+ const screenFormWidth = width ?? contextWidth;
37
+ const sections = (_jsx(FormSections, { chromeless: chromeless === true, stickyActions: stickyActions, fillHeight: fillHeight, screenForm: isScreenForm, children: children }));
38
+ const footer = (_jsx(FormFooter, { actions: actions, secondaryActions: secondaryActions, testId: testId, chromeless: chromeless === true, stickyActions: stickyActions, fillHeight: fillHeight, ...(unsavedCount !== undefined && { unsavedCount }), railed: sideRail !== undefined }));
39
+ // Screen form: no card. A padded scroll surface holds the form column and,
40
+ // from xl, the section navigation; the footer stays pinned below it.
41
+ if (isScreenForm) {
42
+ const showsSectionNav = sectionNav !== undefined && sectionNav.length >= SECTION_NAV_MIN_ITEMS;
43
+ 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
44
+ ? screenFormColumnWidthClassName[screenFormWidth]
45
+ : "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] }));
46
+ }
47
+ // Pinned footer (fillHeight + stickyActions): the footer is a full-bleed
48
+ // sibling BELOW the padded shell, so the shell's bottom inset does not float
49
+ // it above the viewport edge; the shell shrinks (`flex-1`) to make room.
50
+ const pinnedFooter = isPinnedFooter(stickyActions, fillHeight);
51
+ const pinnedShellClassName = pinnedFooter ? "h-auto flex-1 pb-6" : undefined;
52
+ // Tab content (bedienkonzept A1): the head card is the ONLY card on the
53
+ // screen, so a tabbed projectionDetail's tab content must render as a
54
+ // sibling of that card, not nest another one around itself — chromeless
55
+ // skips the card wrapper and its border/padding so sections and footer
56
+ // sit directly on the page background, same edge as a standalone list
57
+ // screen. Title/subtitle still render unbordered: RenderEdit's tabs
58
+ // caller already blanks the per-section title via hideSectionTitles, but
59
+ // the screen-level subtitle (screen.description) is independent of that
60
+ // and must survive tabs mode.
61
+ if (chromeless === true) {
62
+ 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] }));
63
+ }
64
+ // One card form: title as header (no divider under it), sections divided
65
+ // between each other, muted action footer. Shell width comes from
66
+ // FormScreenShell (layout.width, else the app-wide screenWidth default, else
67
+ // 4xl).
68
+ 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",
69
+ // No flex-1: a fillHeight card sizes to its content (flex's
70
+ // initial 0 1 auto) and only claims more than that once its
71
+ // FormScreenShell ancestor is itself height-constrained and
72
+ // shrinks it back down via min-h-0 — flex-1 would instead force
73
+ // it to always fill the remaining height, stretching a short
74
+ // relatedList tab to the bottom of the panel (fw#2778).
75
+ // The min-height floor keeps the card from collapsing to ~0 on short
76
+ // viewports; the shell then overflows and <main> scrolls instead of
77
+ // the overflow-hidden card clipping the table.
78
+ 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] }));
79
+ }
@@ -0,0 +1,14 @@
1
+ import type { ReactNode } from "react";
2
+ import type { DataTableDateRangeFacet, DataTableFacet } from "../../index.ts";
3
+ import "../react-css-custom-properties.ts";
4
+ export declare function FacetFilter({ facet, selected, onChange, dense, }: {
5
+ facet: DataTableFacet;
6
+ selected: readonly string[];
7
+ onChange: (field: string, values: readonly string[]) => void;
8
+ /** Toolbar of a scrollBody table: h32 with the stronger border. */
9
+ dense?: boolean;
10
+ }): ReactNode;
11
+ export declare function DateRangeFacetFilter({ facet, onChange, }: {
12
+ facet: DataTableDateRangeFacet;
13
+ onChange: (field: string, bound: "from" | "to", value: string) => void;
14
+ }): ReactNode;
@@ -0,0 +1,39 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ChevronDown } from "lucide-react";
3
+ import { cn } from "../lib/cn.js";
4
+ import { Badge } from "../ui/badge.js";
5
+ import { Button as UiButton } from "../ui/button.js";
6
+ import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger, } from "./dropdown-menu.js";
7
+ import "../react-css-custom-properties.js";
8
+ import { DefaultButton } from "./button.js";
9
+ // ---- DataTable (shadcn: Table) ----
10
+ function sameValueSet(a, b) {
11
+ return a.length === b.length && a.every((value) => b.includes(value));
12
+ }
13
+ // Single-choice chip row for a facet with `chips`: a chip is pressed while the
14
+ // current selection equals its values, a click replaces the selection.
15
+ function FacetChips({ facet, selected, onChange, }) {
16
+ const chips = (facet.chips ?? []).filter((chip) => facet.hideEmpty !== true || chip.count !== 0 || sameValueSet(selected, chip.values));
17
+ 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))) }));
18
+ }
19
+ // Faceted filter dropdown: outline button (like shadcn's "Columns" toggle) +
20
+ // multi-select checkboxes. Active selection -> count badge on the button.
21
+ export function FacetFilter({ facet, selected, onChange, dense = false, }) {
22
+ const toggle = (value, checked) => {
23
+ const next = checked ? [...selected, value] : selected.filter((v) => v !== value);
24
+ onChange(facet.field, next);
25
+ };
26
+ if (facet.chips !== undefined) {
27
+ return _jsx(FacetChips, { facet: facet, selected: selected, onChange: onChange });
28
+ }
29
+ 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))) })] }));
30
+ }
31
+ // Time-range filter (fw#3104): two native <input type="date"> instead of a
32
+ // dropdown — the browser supplies the calendar, the locale and the keyboard
33
+ // handling, so no date dependency is needed. Each bound constrains the other
34
+ // through min/max so the picker can't offer an inverted span; the caller
35
+ // clamps a typed value on top of that.
36
+ export function DateRangeFacetFilter({ facet, onChange, }) {
37
+ 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";
38
+ 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) })] }));
39
+ }
@@ -0,0 +1,63 @@
1
+ import type { FieldIconKey } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import { type ComponentProps, type CSSProperties, type ReactNode } from "react";
3
+ import { type FieldProps, type InputFillAction, type TrailingInputAction } from "../../index.ts";
4
+ import type { ComboboxOption } from "./combobox.tsx";
5
+ import { SegmentedControl } from "./segmented-select.tsx";
6
+ import "../react-css-custom-properties.ts";
7
+ export declare function fieldLabelId(id: string): string;
8
+ export declare function DefaultField(props: FieldProps): ReactNode;
9
+ declare const FIELD_ICONS: {
10
+ readonly mail: import("react").ForwardRefExoticComponent<Omit<import("lucide-react").LucideProps, "ref"> & import("react").RefAttributes<SVGSVGElement>>;
11
+ readonly lock: import("react").ForwardRefExoticComponent<Omit<import("lucide-react").LucideProps, "ref"> & import("react").RefAttributes<SVGSVGElement>>;
12
+ readonly hash: import("react").ForwardRefExoticComponent<Omit<import("lucide-react").LucideProps, "ref"> & import("react").RefAttributes<SVGSVGElement>>;
13
+ readonly search: import("react").ForwardRefExoticComponent<Omit<import("lucide-react").LucideProps, "ref"> & import("react").RefAttributes<SVGSVGElement>>;
14
+ readonly user: import("react").ForwardRefExoticComponent<Omit<import("lucide-react").LucideProps, "ref"> & import("react").RefAttributes<SVGSVGElement>>;
15
+ readonly phone: import("react").ForwardRefExoticComponent<Omit<import("lucide-react").LucideProps, "ref"> & import("react").RefAttributes<SVGSVGElement>>;
16
+ readonly calendar: import("react").ForwardRefExoticComponent<Omit<import("lucide-react").LucideProps, "ref"> & import("react").RefAttributes<SVGSVGElement>>;
17
+ readonly link: import("react").ForwardRefExoticComponent<Omit<import("lucide-react").LucideProps, "ref"> & import("react").RefAttributes<SVGSVGElement>>;
18
+ readonly tag: import("react").ForwardRefExoticComponent<Omit<import("lucide-react").LucideProps, "ref"> & import("react").RefAttributes<SVGSVGElement>>;
19
+ readonly building: import("react").ForwardRefExoticComponent<Omit<import("lucide-react").LucideProps, "ref"> & import("react").RefAttributes<SVGSVGElement>>;
20
+ readonly globe: import("react").ForwardRefExoticComponent<Omit<import("lucide-react").LucideProps, "ref"> & import("react").RefAttributes<SVGSVGElement>>;
21
+ readonly key: import("react").ForwardRefExoticComponent<Omit<import("lucide-react").LucideProps, "ref"> & import("react").RefAttributes<SVGSVGElement>>;
22
+ readonly "map-pin": import("react").ForwardRefExoticComponent<Omit<import("lucide-react").LucideProps, "ref"> & import("react").RefAttributes<SVGSVGElement>>;
23
+ };
24
+ export declare function fieldIconFor(icon: string | undefined): (typeof FIELD_ICONS)[FieldIconKey] | undefined;
25
+ export declare const FIELD_ICON_CLASS = "pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground";
26
+ export declare function withFieldIcon(icon: string | undefined, input: ReactNode): ReactNode;
27
+ type InputAddonProps = {
28
+ readonly name: string;
29
+ readonly prefix?: string | undefined;
30
+ readonly suffix?: string | undefined;
31
+ readonly fill?: InputFillAction | undefined;
32
+ };
33
+ export declare function withInputAddons(addons: InputAddonProps, input: ReactNode): ReactNode;
34
+ export declare function unitSuffixId(inputId: string): string;
35
+ export declare function withTrailingAction(action: TrailingInputAction | undefined, input: ReactNode, keepWrapper?: boolean): ReactNode;
36
+ type SelectPresentation = "segmented" | "radioList" | "dropdown";
37
+ export declare function defaultSelectPresentation(options: readonly {
38
+ readonly label: string;
39
+ }[]): SelectPresentation;
40
+ export declare function withoutSegmentedSelectPlaceholder<T extends {
41
+ readonly value: string;
42
+ }>(options: readonly T[]): readonly T[];
43
+ export declare function SegmentedSelect(props: Omit<ComponentProps<typeof SegmentedControl>, "labelProps">): ReactNode;
44
+ export declare function RadioListSelect({ id, name, value, onChange, options, variant, disabled, required, hasError, }: {
45
+ readonly id: string;
46
+ readonly name: string;
47
+ readonly value: string;
48
+ readonly onChange: (v: string) => void;
49
+ readonly options: readonly {
50
+ readonly value: string;
51
+ readonly label: string;
52
+ readonly description?: string;
53
+ readonly group?: string;
54
+ readonly disabled?: boolean;
55
+ }[];
56
+ readonly variant?: "list" | "card";
57
+ readonly disabled?: boolean;
58
+ readonly required?: boolean;
59
+ readonly hasError?: boolean;
60
+ }): ReactNode;
61
+ export declare function textareaMinHeight(rows: number): CSSProperties;
62
+ export declare function withInlineDisabledHint(option: ComboboxOption): ComboboxOption;
63
+ export {};