@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.
- 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 +3 -3
- package/dist/headless/view-model/edit.d.ts +1 -1
- package/dist/headless/view-model/edit.js +9 -16
- 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/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/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 -2006
- 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/package.json +5 -5
- 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 {};
|