@cosmicdrift/kumiko-renderer 0.388.0 → 0.389.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/base-path-context.d.ts +9 -0
- package/dist/app/base-path-context.js +13 -0
- package/dist/app/base-path.d.ts +3 -0
- package/dist/app/base-path.js +27 -0
- package/dist/app/config-edit-body.d.ts +1 -1
- package/dist/app/config-edit-body.js +15 -6
- package/dist/app/config-edit-without-secret-fields.d.ts +2 -0
- package/dist/app/config-edit-without-secret-fields.js +28 -0
- package/dist/app/entity-list-facet-specs.d.ts +3 -0
- package/dist/app/entity-list-facet-specs.js +65 -0
- package/dist/app/entity-list-screen.js +74 -206
- package/dist/app/kumiko-screen.js +6 -14
- package/dist/app/list-bulk-actions.d.ts +18 -0
- package/dist/app/list-bulk-actions.js +54 -0
- package/dist/app/locked-screen-header.d.ts +9 -0
- package/dist/app/locked-screen-header.js +33 -0
- package/dist/app/projection-list-body.js +51 -46
- package/dist/app/row-actions.d.ts +3 -0
- package/dist/app/row-actions.js +10 -5
- package/dist/app/screen-shared.js +9 -2
- package/dist/app/use-entity-range-facets.d.ts +24 -0
- package/dist/app/use-entity-range-facets.js +64 -0
- package/dist/app/use-projection-date-range.d.ts +12 -0
- package/dist/app/use-projection-date-range.js +21 -0
- package/dist/app/use-screen-list-rows.d.ts +37 -0
- package/dist/app/use-screen-list-rows.js +104 -0
- package/dist/app/use-screen-list-table.d.ts +24 -0
- package/dist/app/use-screen-list-table.js +46 -0
- package/dist/components/list-group-page-bridges.d.ts +3 -0
- package/dist/components/list-group-page-bridges.js +31 -0
- package/dist/components/list-state-store.d.ts +16 -0
- package/dist/components/list-state-store.js +31 -0
- package/dist/components/related-list-section.js +37 -89
- package/dist/components/render-field.js +6 -5
- package/dist/components/render-list-toolbar-action.d.ts +27 -0
- package/dist/components/render-list-toolbar-action.js +40 -0
- package/dist/components/render-list.d.ts +13 -29
- package/dist/components/render-list.js +42 -50
- package/dist/current-user.d.ts +2 -0
- package/dist/data-table-aggregate-types.d.ts +47 -0
- package/dist/data-table-aggregate-types.js +1 -0
- package/dist/data-table-bulk-types.d.ts +27 -0
- package/dist/data-table-bulk-types.js +1 -0
- package/dist/data-table-facet-types.d.ts +36 -0
- package/dist/data-table-facet-types.js +1 -0
- package/dist/data-table-view-types.d.ts +73 -0
- package/dist/data-table-view-types.js +1 -0
- package/dist/headless/view-model/edit.js +3 -1
- package/dist/headless/view-model/types.d.ts +4 -2
- package/dist/hooks/list-column-layout.d.ts +19 -0
- package/dist/hooks/list-column-layout.js +36 -0
- package/dist/hooks/list-group-pages.d.ts +26 -0
- package/dist/hooks/list-group-pages.js +37 -0
- package/dist/hooks/list-reset.d.ts +8 -0
- package/dist/hooks/list-reset.js +15 -0
- package/dist/hooks/list-server-groups.d.ts +39 -0
- package/dist/hooks/list-server-groups.js +84 -0
- package/dist/hooks/list-state-schema.d.ts +28 -0
- package/dist/hooks/list-state-schema.js +76 -0
- package/dist/hooks/use-bulk-run.d.ts +41 -0
- package/dist/hooks/use-bulk-run.js +65 -0
- package/dist/hooks/use-group-load-state.d.ts +11 -0
- package/dist/hooks/use-group-load-state.js +31 -0
- package/dist/hooks/use-list-aggregates.d.ts +38 -0
- package/dist/hooks/use-list-aggregates.js +107 -0
- package/dist/hooks/use-list-table.d.ts +38 -0
- package/dist/hooks/use-list-table.js +91 -0
- package/dist/hooks/use-list-view-state.d.ts +43 -0
- package/dist/hooks/use-list-view-state.js +161 -0
- package/dist/hooks/use-related-list-rows.d.ts +27 -0
- package/dist/hooks/use-related-list-rows.js +81 -0
- package/dist/hooks/use-related-list-state.d.ts +15 -0
- package/dist/hooks/use-related-list-state.js +31 -0
- package/dist/hooks/use-remembered-list-params.d.ts +13 -0
- package/dist/hooks/use-remembered-list-params.js +53 -0
- package/dist/hooks/use-remembered-local-list-state.d.ts +26 -0
- package/dist/hooks/use-remembered-local-list-state.js +63 -0
- package/dist/i18n-defaults.js +53 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -0
- package/dist/primitives.d.ts +11 -47
- package/dist/web/app/create-app.d.ts +5 -0
- package/dist/web/app/create-app.js +7 -4
- package/dist/web/app/history-leave-guard.d.ts +6 -0
- package/dist/web/app/history-leave-guard.js +115 -0
- package/dist/web/app/list-state-store-boot.d.ts +6 -0
- package/dist/web/app/list-state-store-boot.js +15 -0
- package/dist/web/app/nav.js +7 -54
- package/dist/web/app/session-list-state-store.d.ts +15 -0
- package/dist/web/app/session-list-state-store.js +117 -0
- package/dist/web/index.d.ts +1 -0
- package/dist/web/index.js +1 -0
- package/dist/web/lib/export-xlsx-request.d.ts +11 -0
- package/dist/web/lib/export-xlsx-request.js +46 -0
- package/dist/web/primitives/data-table/actions-column.d.ts +16 -0
- package/dist/web/primitives/data-table/actions-column.js +34 -0
- package/dist/web/primitives/data-table/bulk-selection-bar.d.ts +20 -0
- package/dist/web/primitives/data-table/bulk-selection-bar.js +37 -0
- package/dist/web/primitives/data-table/card-sort-select.d.ts +14 -0
- package/dist/web/primitives/data-table/card-sort-select.js +40 -0
- package/dist/web/primitives/data-table/column-layout-mode.d.ts +5 -0
- package/dist/web/primitives/data-table/column-layout-mode.js +10 -0
- package/dist/web/primitives/data-table/column-picker-section.d.ts +7 -0
- package/dist/web/primitives/data-table/column-picker-section.js +35 -0
- package/dist/web/primitives/data-table/column-resize-handle.d.ts +14 -0
- package/dist/web/primitives/data-table/column-resize-handle.js +46 -0
- package/dist/web/primitives/data-table/density.d.ts +14 -0
- package/dist/web/primitives/data-table/density.js +20 -0
- package/dist/web/primitives/data-table/export-button.d.ts +5 -0
- package/dist/web/primitives/data-table/export-button.js +37 -0
- package/dist/web/primitives/data-table/facet-cluster.d.ts +8 -0
- package/dist/web/primitives/data-table/facet-cluster.js +37 -0
- package/dist/web/primitives/data-table/group-band.d.ts +28 -0
- package/dist/web/primitives/data-table/group-band.js +53 -0
- package/dist/web/primitives/data-table/measure-value.d.ts +10 -0
- package/dist/web/primitives/data-table/measure-value.js +28 -0
- package/dist/web/primitives/data-table/pin-section.d.ts +8 -0
- package/dist/web/primitives/data-table/pin-section.js +20 -0
- package/dist/web/primitives/data-table/reset-all-button.d.ts +4 -0
- package/dist/web/primitives/data-table/reset-all-button.js +8 -0
- package/dist/web/primitives/data-table/server-group-header.d.ts +11 -0
- package/dist/web/primitives/data-table/server-group-header.js +51 -0
- package/dist/web/primitives/data-table/sortable-header.d.ts +17 -0
- package/dist/web/primitives/data-table/sortable-header.js +41 -0
- package/dist/web/primitives/data-table/table-chrome.d.ts +13 -0
- package/dist/web/primitives/data-table/table-chrome.js +30 -0
- package/dist/web/primitives/data-table/table-footer.d.ts +7 -0
- package/dist/web/primitives/data-table/table-footer.js +20 -0
- package/dist/web/primitives/data-table/toolbar-end-cluster.d.ts +10 -0
- package/dist/web/primitives/data-table/toolbar-end-cluster.js +13 -0
- package/dist/web/primitives/data-table/totals-footer.d.ts +24 -0
- package/dist/web/primitives/data-table/totals-footer.js +48 -0
- package/dist/web/primitives/data-table/use-bulk-selection.d.ts +21 -0
- package/dist/web/primitives/data-table/use-bulk-selection.js +66 -0
- package/dist/web/primitives/data-table/use-column-pins.d.ts +32 -0
- package/dist/web/primitives/data-table/use-column-pins.js +117 -0
- package/dist/web/primitives/data-table/use-column-sizing.d.ts +22 -0
- package/dist/web/primitives/data-table/use-column-sizing.js +44 -0
- package/dist/web/primitives/data-table/use-measured-column-widths.d.ts +15 -0
- package/dist/web/primitives/data-table/use-measured-column-widths.js +83 -0
- package/dist/web/primitives/data-table/use-row-keyboard-navigation.d.ts +22 -0
- package/dist/web/primitives/data-table/use-row-keyboard-navigation.js +65 -0
- package/dist/web/primitives/data-table/use-row-selection.d.ts +14 -0
- package/dist/web/primitives/data-table/use-row-selection.js +48 -0
- package/dist/web/primitives/data-table/use-view-columns.d.ts +6 -0
- package/dist/web/primitives/data-table/use-view-columns.js +17 -0
- package/dist/web/primitives/data-table/view-menu.d.ts +8 -0
- package/dist/web/primitives/data-table/view-menu.js +45 -0
- package/dist/web/primitives/data-table-cell.d.ts +0 -22
- package/dist/web/primitives/data-table-cell.js +1 -58
- package/dist/web/primitives/data-table.d.ts +1 -1
- package/dist/web/primitives/data-table.js +122 -144
- package/dist/web/primitives/file-value-list.d.ts +1 -1
- package/dist/web/primitives/file-value-list.js +5 -5
- package/dist/web/primitives/number-range-facet.d.ts +7 -0
- package/dist/web/primitives/number-range-facet.js +33 -0
- package/dist/web/widgets/bounded-load-cache.d.ts +5 -1
- package/dist/web/widgets/bounded-load-cache.js +48 -26
- package/dist/web/widgets/pdf-preview.js +9 -4
- package/package.json +5 -5
- package/src/changes.json +109 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { cardColumnRoles } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
3
|
-
import {
|
|
4
|
-
import { Fragment, useId, useState
|
|
3
|
+
import { ChevronRight, Search, SlidersHorizontal } from "lucide-react";
|
|
4
|
+
import { Fragment, useId, useState } from "react";
|
|
5
5
|
import { useOptionalLocale, useOptionalTranslation } from "../../index.js";
|
|
6
6
|
import { UNIFORM_BAR_CONTROLS_CLASSES } from "../layout/uniform-bar-controls.js";
|
|
7
7
|
import { cn } from "../lib/cn.js";
|
|
@@ -11,54 +11,28 @@ import { CardLeadingImage } from "./file-thumbnail.js";
|
|
|
11
11
|
import { screenPaddingClassName } from "./layout.js";
|
|
12
12
|
import { useIsNarrowViewport } from "./use-narrow-viewport.js";
|
|
13
13
|
import "../react-css-custom-properties.js";
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
14
|
+
import { ActionsBodyCell, ActionsHeaderCell } from "./data-table/actions-column.js";
|
|
15
|
+
import { CardSortSelect } from "./data-table/card-sort-select.js";
|
|
16
|
+
import { isPinningActive, isResizingActive } from "./data-table/column-layout-mode.js";
|
|
17
|
+
import { ColumnResizeHandle, ColumnWidthGroup } from "./data-table/column-resize-handle.js";
|
|
18
|
+
import { densityClasses } from "./data-table/density.js";
|
|
19
|
+
import { buildFacetCluster } from "./data-table/facet-cluster.js";
|
|
20
|
+
import { bandClassName, bandReactKey, bandTestId, buildDataTableItems, GroupBand, } from "./data-table/group-band.js";
|
|
21
|
+
import { SortableHeader } from "./data-table/sortable-header.js";
|
|
22
|
+
import { EXPAND_TOGGLE_CELL_CLASS, EXPANSION_CELL_CLASS, EXPANSION_CONTENT_CLASS, FILL_TABLE_CLASS, FlushTable, RowExpandToggle, } from "./data-table/table-chrome.js";
|
|
23
|
+
import { DataTableFooter, showFullTextOnHover } from "./data-table/table-footer.js";
|
|
24
|
+
import { ToolbarEndCluster, toolbarEndHasContent } from "./data-table/toolbar-end-cluster.js";
|
|
25
|
+
import { TotalsCard, TotalsFooterRow } from "./data-table/totals-footer.js";
|
|
26
|
+
import { useBulkSelection } from "./data-table/use-bulk-selection.js";
|
|
27
|
+
import { EXPAND_COLUMN_KEY, SELECT_COLUMN_KEY, useColumnPins, } from "./data-table/use-column-pins.js";
|
|
28
|
+
import { useColumnSizing } from "./data-table/use-column-sizing.js";
|
|
29
|
+
import { useMeasuredColumnWidths } from "./data-table/use-measured-column-widths.js";
|
|
30
|
+
import { useRowKeyboardNavigation } from "./data-table/use-row-keyboard-navigation.js";
|
|
31
|
+
import { useViewColumns } from "./data-table/use-view-columns.js";
|
|
32
|
+
import { cellLinkProp, cellTitle, DataTableCell, FIT_CONTENT_CELL_CLASS, FirstCellLink, isBadgeColumn, isEmptyCellValue, isUnlabeledFalse, NUMERIC_COLUMN_TYPES, rowToneClass, showsBareCheckMark, TABULAR_COLUMN_TYPES, } from "./data-table-cell.js";
|
|
33
|
+
import { InfiniteSentinel, Pager } from "./pager.js";
|
|
17
34
|
import { RowActionsCell } from "./row-actions.js";
|
|
18
|
-
|
|
19
|
-
// would become the scrollport of a sticky header. A scrollBody table scrolls
|
|
20
|
-
// in its parent instead, so it renders the bare <table>.
|
|
21
|
-
function FlushTable({ className, ...props }) {
|
|
22
|
-
return (_jsx("table", { "data-slot": "table", className: cn("w-full caption-bottom text-sm", className), ...props }));
|
|
23
|
-
}
|
|
24
|
-
// Header cells stick to the scroll surface; the bottom rule is an inset
|
|
25
|
-
// shadow because a border on a sticky cell scrolls away under border-collapse.
|
|
26
|
-
// Column weight (medium vs. semibold for the sorted one) lives on the th itself
|
|
27
|
-
// in SortableHeader: a `[&_th]:font-*` rule here would outrank it.
|
|
28
|
-
// Child selectors only: an expanded row nests a whole table inside a cell, and
|
|
29
|
-
// descendant selectors would make its header sticky and re-pad its cells.
|
|
30
|
-
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");
|
|
31
|
-
// `!`: beats the first/last-child padding of FILL_TABLE_CLASS, which would
|
|
32
|
-
// otherwise widen the narrow toggle column / pad the full-width expansion cell.
|
|
33
|
-
// The nested table's sticky actions cell (last td) gets the muted surface
|
|
34
|
-
// instead of its own md:bg-background, so it blends into the area.
|
|
35
|
-
const EXPAND_TOGGLE_CELL_CLASS = "w-10 px-1! py-0!";
|
|
36
|
-
const EXPANSION_CELL_CLASS = "whitespace-normal bg-muted p-0! align-top md:[&_td:last-child]:bg-muted";
|
|
37
|
-
const EXPANSION_CONTENT_CLASS = "pt-1 pr-4 pb-3.5 pl-[52px]";
|
|
38
|
-
function RowExpandToggle({ expanded, controlsId, label, onToggle, testId, }) {
|
|
39
|
-
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) => {
|
|
40
|
-
e.stopPropagation();
|
|
41
|
-
onToggle();
|
|
42
|
-
}, onKeyDown: (e) => e.stopPropagation(), children: _jsx(ChevronRight, { className: cn("size-4 transition-transform", expanded && "rotate-90"), "aria-hidden": "true" }) }));
|
|
43
|
-
}
|
|
44
|
-
function DataTableFooter({ count, itemNoun, testId, }) {
|
|
45
|
-
const t = useOptionalTranslation();
|
|
46
|
-
const noun = itemNoun?.(count);
|
|
47
|
-
const label = noun !== undefined
|
|
48
|
-
? (t?.("kumiko.list.count.noun", { count: count.toLocaleString(), noun }) ??
|
|
49
|
-
`${count.toLocaleString()} ${noun}`)
|
|
50
|
-
: count === 1
|
|
51
|
-
? (t?.("kumiko.list.count.one") ?? "1 entry")
|
|
52
|
-
: (t?.("kumiko.list.count.other", { count: count.toLocaleString() }) ??
|
|
53
|
-
`${count.toLocaleString()} entries`);
|
|
54
|
-
return (_jsx("div", { "data-testid": testId, className: TABLE_FOOTER_BAR_CLASS, children: _jsx("span", { "data-testid": `${testId}-count`, children: label }) }));
|
|
55
|
-
}
|
|
56
|
-
// Cell content is a ReactNode, so the tooltip text is read from the rendered DOM.
|
|
57
|
-
function showFullTextOnHover(event) {
|
|
58
|
-
const element = event.currentTarget;
|
|
59
|
-
element.title = element.scrollWidth > element.clientWidth ? (element.textContent ?? "") : "";
|
|
60
|
-
}
|
|
61
|
-
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, }) {
|
|
35
|
+
export function DefaultDataTable({ columns: schemaColumns, rows, onRowClick, sort, onSortChange, emptyState, toolbarStart, toolbarDescription, toolbarEnd, pager, onReachEnd, loadingMore, hasMore, itemNoun, rowActions, rowActionMode, bulk, rowTone, filterFacets, filterValues, onFilterChange, onFilterReset, dateRangeFacets, onDateRangeChange, testId, onCellChange, getRowTestId, getCellTestId, chromeless, scrollBody, screenPadding, expandedRowIds, onToggleRowExpanded, renderExpandedRow, view, resetAll, exportList, serverGroups, totals, notice, rangeFacets, onRangeChange, }) {
|
|
62
36
|
// One locale/translate subscription per table — not per cell.
|
|
63
37
|
// Optional hooks: a bare DataTable outside LocaleProvider must not crash.
|
|
64
38
|
const tableTranslate = useOptionalTranslation();
|
|
@@ -67,8 +41,9 @@ export function DefaultDataTable({ columns, rows, onRowClick, sort, onSortChange
|
|
|
67
41
|
// affordance. Cards replace the table entirely below the breakpoint — same
|
|
68
42
|
// single-mount pattern as EmbeddedListInput/embedded-list-input.tsx.
|
|
69
43
|
const isNarrow = useIsNarrowViewport();
|
|
44
|
+
const viewColumns = useViewColumns(schemaColumns, view, isNarrow);
|
|
45
|
+
const density = densityClasses(view, scrollBody === true);
|
|
70
46
|
const [facetsOpenNarrow, setFacetsOpenNarrow] = useState(false);
|
|
71
|
-
const [toggledGroups, setToggledGroups] = useState(new Set());
|
|
72
47
|
// Toolbar wrapper: shared container for toolbar + table so the two belong
|
|
73
48
|
// together visually. The toolbar is NOT sticky: lists typically scroll with
|
|
74
49
|
// the page container, not internally. Sticky would compete with the topbar.
|
|
@@ -87,10 +62,39 @@ export function DefaultDataTable({ columns, rows, onRowClick, sort, onSortChange
|
|
|
87
62
|
const rowClickLabel = rowClickAction?.label;
|
|
88
63
|
const rowClickIsHidden = (row) => rowClickAction?.isVisible !== undefined && !rowClickAction.isVisible(row);
|
|
89
64
|
const hasTableActions = tableActions !== undefined && tableActions.length > 0;
|
|
90
|
-
const isEmpty = rows.length === 0;
|
|
65
|
+
const isEmpty = rows.length === 0 && (serverGroups?.groups.length ?? 0) === 0;
|
|
91
66
|
const expansionIdPrefix = useId();
|
|
92
|
-
const isRowExpandable = renderExpandedRow !== undefined && onToggleRowExpanded !== undefined;
|
|
93
67
|
const rowTestId = (row) => getRowTestId?.(row) ?? `row-${row.id}`;
|
|
68
|
+
const isRowExpandable = renderExpandedRow !== undefined && onToggleRowExpanded !== undefined;
|
|
69
|
+
const fillsHeight = scrollBody === true;
|
|
70
|
+
const selection = useBulkSelection({
|
|
71
|
+
bulk,
|
|
72
|
+
rows,
|
|
73
|
+
titleField: schemaColumns[0]?.field,
|
|
74
|
+
rowTestId,
|
|
75
|
+
flush: fillsHeight,
|
|
76
|
+
});
|
|
77
|
+
const { headerRowRef, measured } = useMeasuredColumnWidths(isPinningActive(view, isNarrow) || isResizingActive(view, isNarrow));
|
|
78
|
+
const pins = useColumnPins({
|
|
79
|
+
columns: viewColumns,
|
|
80
|
+
view,
|
|
81
|
+
isNarrow,
|
|
82
|
+
measured,
|
|
83
|
+
hasActionsColumn: hasTableActions,
|
|
84
|
+
hasExpandColumn: isRowExpandable,
|
|
85
|
+
hasSelectColumn: selection.active,
|
|
86
|
+
fillsHeight,
|
|
87
|
+
});
|
|
88
|
+
const columns = pins.columns;
|
|
89
|
+
const sizing = useColumnSizing({
|
|
90
|
+
columns,
|
|
91
|
+
view,
|
|
92
|
+
isNarrow,
|
|
93
|
+
measured,
|
|
94
|
+
hasExpandColumn: isRowExpandable,
|
|
95
|
+
hasSelectColumn: selection.active,
|
|
96
|
+
});
|
|
97
|
+
const fullColSpan = columns.length + [isRowExpandable, hasTableActions, selection.active].filter(Boolean).length;
|
|
94
98
|
const expansionDomId = (row) => `${expansionIdPrefix}-${row.id}`;
|
|
95
99
|
// The expand toggle of a row, shared by table and card layout. Named after
|
|
96
100
|
// the first data column, the one a user recognizes the row by.
|
|
@@ -112,75 +116,59 @@ export function DefaultDataTable({ columns, rows, onRowClick, sort, onSortChange
|
|
|
112
116
|
}
|
|
113
117
|
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
118
|
}
|
|
115
|
-
const fillsHeight = scrollBody === true;
|
|
116
119
|
const TableRoot = fillsHeight ? FlushTable : Table;
|
|
117
120
|
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 =
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
121
|
+
const tableItems = buildDataTableItems(rows, serverGroups);
|
|
122
|
+
const keyboardRow = useRowKeyboardNavigation({
|
|
123
|
+
view,
|
|
124
|
+
isNarrow,
|
|
125
|
+
rowIds: tableItems.flatMap((item) => (item.kind === "row" ? [item.row.id] : [])),
|
|
126
|
+
onSelect: selection.onKeyboardSelect,
|
|
127
|
+
});
|
|
128
|
+
const schemaColumnByField = new Map(schemaColumns.map((col) => [col.field, col]));
|
|
129
|
+
const columnOf = (field) => schemaColumnByField.get(field);
|
|
130
|
+
function renderBand(item) {
|
|
131
|
+
return _jsx(GroupBand, { item: item, columnOf: columnOf });
|
|
126
132
|
}
|
|
127
133
|
function tableInner() {
|
|
128
134
|
if (isEmpty)
|
|
129
135
|
return emptyBlock;
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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}`));
|
|
136
|
+
const expandHeaderPin = pins.headerCell(EXPAND_COLUMN_KEY);
|
|
137
|
+
const expandBodyPin = pins.bodyCell(EXPAND_COLUMN_KEY);
|
|
138
|
+
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, ...(sizing !== undefined && { style: sizing.tableStyle }), children: [sizing !== undefined && (_jsx(ColumnWidthGroup, { fields: columns.map((col) => col.field), sizing: sizing, hasExpandColumn: isRowExpandable, hasSelectColumn: selection.active, hasActionsColumn: hasTableActions })), _jsx(TableHeader, { className: "bg-muted", children: _jsxs(TableRow, { ref: headerRowRef, className: "hover:bg-transparent", children: [selection.headerCell(pins.headerCell(SELECT_COLUMN_KEY)), isRowExpandable && (_jsx(TableHead, { "data-testid": "column-expand", className: cn(EXPAND_TOGGLE_CELL_CLASS, expandHeaderPin.className), style: expandHeaderPin.style })), columns.map((col) => {
|
|
139
|
+
const headerPin = pins.headerCell(col.field);
|
|
140
|
+
return (_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, className: cn(density.header, headerPin.className), style: headerPin.style, columnField: headerPin.columnField, trailing: sizing && (_jsx(ColumnResizeHandle, { field: col.field, label: col.label, sizing: sizing })), ...(sort !== undefined && sort !== null && { sort }), ...(onSortChange !== undefined && { onSortChange }) }, col.field));
|
|
141
|
+
}), hasTableActions && (_jsx(ActionsHeaderCell, { label: tableTranslate?.("kumiko.list.actions") ?? "Actions", style: pins.actionsHeaderStyle, absorbsLeftover: sizing !== undefined }))] }) }), _jsx(TableBody, { children: tableItems.map((item) => {
|
|
142
|
+
if (item.kind !== "row") {
|
|
143
|
+
return (_jsx(TableRow, { "data-testid": bandTestId(item), children: _jsx(TableCell, { colSpan: fullColSpan, className: bandClassName(item), children: renderBand(item) }) }, bandReactKey(item)));
|
|
141
144
|
}
|
|
142
145
|
const row = item.row;
|
|
143
146
|
const opensOnClick = rowIsLink && !rowClickIsHidden(row);
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
}) })] }) }));
|
|
147
|
+
const keyboard = keyboardRow(row.id, opensOnClick ? () => onRowClick(row) : undefined);
|
|
148
|
+
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", density.row, keyboard.className), ...keyboard.props, children: [selection.bodyCell(row, pins.bodyCell(SELECT_COLUMN_KEY)), isRowExpandable && (_jsx(TableCell, { "data-testid": getCellTestId?.(row, "expand") ?? `cell-${row.id}-expand`, className: cn(EXPAND_TOGGLE_CELL_CLASS, expandBodyPin.className), style: expandBodyPin.style, children: renderExpandToggle(row) })), columns.map((col, colIndex) => {
|
|
149
|
+
const bodyPin = pins.bodyCell(col.field);
|
|
150
|
+
return (_jsx(TableCell, { "data-testid": getCellTestId?.(row, col.field) ?? `cell-${row.id}-${col.field}`, "data-highlighted": col.highlighted === true ? "true" : undefined,
|
|
151
|
+
// Text wraps instead of truncating: nowrap cells keep their
|
|
152
|
+
// full width, so a wide table scrolled sideways and its
|
|
153
|
+
// last column slid under the sticky actions column with
|
|
154
|
+
// nothing visibly cut off. max-w-xs bounds a single column;
|
|
155
|
+
// badges and dates stay nowrap by their own classes.
|
|
156
|
+
className: cn(col.fileDisplay === undefined
|
|
157
|
+
? cn(sizing ? "overflow-hidden" : "max-w-xs", "whitespace-normal break-words")
|
|
158
|
+
: FIT_CONTENT_CELL_CLASS, colIndex === 0
|
|
159
|
+
? "font-medium text-foreground"
|
|
160
|
+
: "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", density.cell, bodyPin.className), style: bodyPin.style, 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 && {
|
|
161
|
+
optionLabels: col.optionLabels,
|
|
162
|
+
}), ...(col.optionTones !== undefined && {
|
|
163
|
+
optionTones: col.optionTones,
|
|
164
|
+
}), ...(col.grouping !== undefined && { grouping: col.grouping }), ...(col.fileDisplay !== undefined && {
|
|
165
|
+
fileDisplay: col.fileDisplay,
|
|
166
|
+
}), ...(onCellChange !== undefined && {
|
|
167
|
+
onChange: (value) => onCellChange(row.id, col.field, value),
|
|
168
|
+
}), ...cellLinkProp(col, row, !(colIndex === 0 && opensOnClick && onCellChange === undefined)) }) }) }, col.field));
|
|
169
|
+
}), hasTableActions && (_jsx(ActionsBodyCell, { testId: getCellTestId?.(row, "actions") ?? `cell-${row.id}-actions`, fillsHeight: fillsHeight, measureContent: sizing !== undefined, children: _jsx(RowActionsCell, { row: row, actions: tableActions, mode: rowActionMode, rowIsLink: rowIsLink }) }))] }), renderExpansion(row, "row", fullColSpan)] }, row.id));
|
|
170
|
+
}) }), totals !== undefined && (_jsx(TotalsFooterRow, { totals: totals, columns: columns, cellPin: pins.bodyCell, leadingCells: { select: selection.active, expand: isRowExpandable }, actionsCell: hasTableActions ? { fillsHeight } : undefined, cellClassName: density.cell }))] }) }));
|
|
172
171
|
}
|
|
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
172
|
const { image: cardImageColumn, title: cardTitleColumn, status: cardStatusColumn, meta: cardMetaColumns, } = cardColumnRoles(columns);
|
|
185
173
|
function cardCell(row, col) {
|
|
186
174
|
// A bare check mark in the subtitle line says nothing without its column.
|
|
@@ -199,7 +187,7 @@ export function DefaultDataTable({ columns, rows, onRowClick, sort, onSortChange
|
|
|
199
187
|
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
188
|
`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
189
|
`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) => {
|
|
190
|
+
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 || selection.active ? "pl-2" : "pl-4", hasMenu ? "pr-2" : "pr-4", density.card), ...(opensOnClick && { onClick: () => onRowClick(row) }), children: [selection.cardCheckbox(row), 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
191
|
if (e.key !== "Enter")
|
|
204
192
|
return;
|
|
205
193
|
e.preventDefault();
|
|
@@ -215,47 +203,37 @@ export function DefaultDataTable({ columns, rows, onRowClick, sort, onSortChange
|
|
|
215
203
|
function cardsInner() {
|
|
216
204
|
if (isEmpty)
|
|
217
205
|
return emptyBlock;
|
|
218
|
-
|
|
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))) })] }) }));
|
|
206
|
+
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: [_jsx(CardSortSelect, { columns: columns, sort: sort, onSortChange: onSortChange, testId: testId }), _jsx("ul", { className: "m-0 list-none border-t border-border-row p-0", children: tableItems.map((item) => item.kind === "row" ? (renderCard(item.row)) : (_jsx("li", { "data-testid": bandTestId(item), className: cn("border-b border-border-row", bandClassName(item)), children: renderBand(item) }, bandReactKey(item)))) }), totals !== undefined && _jsx(TotalsCard, { totals: totals, columnOf: columnOf })] }) }));
|
|
239
207
|
}
|
|
240
208
|
const tableContent = isNarrow ? cardsInner() : tableInner();
|
|
241
209
|
// The pager is ALWAYS rendered below the table (even with rows=[]) so the
|
|
242
210
|
// user can page back on a filter hit of zero without losing the list.
|
|
243
211
|
// Except total === 0: then there is nothing to paginate. Incompatible with
|
|
244
212
|
// 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;
|
|
213
|
+
const pagerElement = pager !== undefined && pager.total > 0 && serverGroups === undefined ? (_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;
|
|
214
|
+
const sentinelElement = pagerElement === undefined && onReachEnd !== undefined && serverGroups === undefined ? (_jsx(InfiniteSentinel, { onReachEnd: onReachEnd, loadingMore: loadingMore === true, hasMore: hasMore !== false, rowsCount: rows.length, testId: testId !== undefined ? `${testId}-sentinel` : "render-list-sentinel" })) : undefined;
|
|
247
215
|
// With scrollBody the footer (pager, else the entry count) sits OUTSIDE the
|
|
248
216
|
// scroll surface so it stays put at any row count; the infinite sentinel
|
|
249
217
|
// 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
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
218
|
+
const content = fillsHeight ? (_jsxs(_Fragment, { children: [selection.bar, _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: serverGroups?.totalCount ?? rows.length, ...(itemNoun !== undefined && { itemNoun }), testId: testId !== undefined ? `${testId}-footer` : "render-list-footer" }))] })) : (_jsxs(_Fragment, { children: [selection.bar, tableContent, pagerElement, sentinelElement] }));
|
|
219
|
+
const facetCluster = buildFacetCluster({
|
|
220
|
+
filterFacets,
|
|
221
|
+
filterValues,
|
|
222
|
+
onFilterChange,
|
|
223
|
+
onFilterReset,
|
|
224
|
+
dateRangeFacets,
|
|
225
|
+
onDateRangeChange,
|
|
226
|
+
rangeFacets,
|
|
227
|
+
onRangeChange,
|
|
228
|
+
resetAll,
|
|
229
|
+
notice,
|
|
230
|
+
dense: fillsHeight,
|
|
231
|
+
});
|
|
232
|
+
const columnLabels = new Map(schemaColumns.map((col) => [col.field, col.label]));
|
|
257
233
|
const collapsesFacetsBehindButton = isNarrow && fillsHeight && facetCluster !== undefined;
|
|
258
|
-
const hasToolbar = toolbarStart !== undefined ||
|
|
234
|
+
const hasToolbar = toolbarStart !== undefined ||
|
|
235
|
+
facetCluster !== undefined ||
|
|
236
|
+
toolbarEndHasContent(view, exportList, toolbarEnd);
|
|
259
237
|
// dashboard-01 pattern: the toolbar (search + facets + "+ New") sits ABOVE
|
|
260
238
|
// the table in the same padding block; no separate bg bar, no screen title
|
|
261
239
|
// (that lives in the shell's breadcrumb).
|
|
@@ -275,5 +253,5 @@ export function DefaultDataTable({ columns, rows, onRowClick, sort, onSortChange
|
|
|
275
253
|
: "min-h-13 shrink-0 gap-2 px-6 py-2 md:py-0"
|
|
276
254
|
: "gap-3"), children: [toolbarStart !== undefined && (_jsxs("div", { className: cn(fillsHeight
|
|
277
255
|
? "relative w-full md:w-[300px] max-md:flex-1 [&_input]:border-border-strong [&_input]:pl-8 [&_input]:max-md:h-11"
|
|
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),
|
|
256
|
+
: "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), _jsx(ToolbarEndCluster, { view: view, columnLabels: columnLabels, exportList: exportList, toolbarEnd: 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"), fillsHeight && !hasToolbar && "pt-4"), children: toolbarDescription })), content] }));
|
|
279
257
|
}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import { type ReactNode } from "react";
|
|
2
2
|
import { type FileValueListProps } from "../../index.ts";
|
|
3
|
-
export declare function FileValueList({ fileIds, testId }: FileValueListProps): ReactNode;
|
|
3
|
+
export declare function FileValueList({ fileIds, previewOpenByDefault, testId, }: FileValueListProps): ReactNode;
|
|
@@ -6,17 +6,17 @@ import { Button } from "../ui/button.js";
|
|
|
6
6
|
import { PdfPreview } from "../widgets/pdf-preview.js";
|
|
7
7
|
import { fileOriginalUrl, useFileMeta } from "./file-thumbnail.js";
|
|
8
8
|
const PDF_MIME_TYPE = "application/pdf";
|
|
9
|
-
function FileValueRow({ fileId, testId, }) {
|
|
9
|
+
function FileValueRow({ fileId, previewOpenInitially, testId, }) {
|
|
10
10
|
const t = useTranslation();
|
|
11
11
|
const meta = useFileMeta(fileId);
|
|
12
|
-
const [previewOpen, setPreviewOpen] = useState(
|
|
12
|
+
const [previewOpen, setPreviewOpen] = useState(previewOpenInitially);
|
|
13
13
|
if (!isUuid(fileId))
|
|
14
14
|
return _jsx("span", { className: "text-sm", children: fileId });
|
|
15
15
|
const downloadUrl = fileOriginalUrl(fileId);
|
|
16
|
-
return (_jsxs("li", { className: "flex flex-col gap-2", "data-testid": testId, children: [_jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [_jsx("span", { className: "text-sm", children: meta?.fileName ?? fileId.slice(0, 8) }), _jsx("a", { href: downloadUrl, download: meta?.fileName ?? "", className: "text-primary text-sm underline underline-offset-2", ...(testId !== undefined && { "data-testid": `${testId}-download` }), children: t("kumiko.field.file-list.download") }), meta?.mimeType === PDF_MIME_TYPE && (_jsx(Button, { type: "button", variant: "link", size: "sm", "aria-expanded": previewOpen, onClick: () => setPreviewOpen((open) => !open), ...(testId !== undefined && { "data-testid": `${testId}-preview-toggle` }), children: t(previewOpen ? "kumiko.field.file-list.hidePreview" : "kumiko.field.file-list.preview") }))] }), previewOpen && (_jsx(PdfPreview, { source: { url: downloadUrl }, testId: `file-preview-${fileId}` }))] }));
|
|
16
|
+
return (_jsxs("li", { className: "flex flex-col gap-2", "data-testid": testId, children: [_jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [_jsx("span", { className: "text-sm", children: meta?.fileName ?? fileId.slice(0, 8) }), _jsx("a", { href: downloadUrl, download: meta?.fileName ?? "", className: "text-primary text-sm underline underline-offset-2", ...(testId !== undefined && { "data-testid": `${testId}-download` }), children: t("kumiko.field.file-list.download") }), meta?.mimeType === PDF_MIME_TYPE && (_jsx(Button, { type: "button", variant: "link", size: "sm", "aria-expanded": previewOpen, onClick: () => setPreviewOpen((open) => !open), ...(testId !== undefined && { "data-testid": `${testId}-preview-toggle` }), children: t(previewOpen ? "kumiko.field.file-list.hidePreview" : "kumiko.field.file-list.preview") }))] }), previewOpen && meta?.mimeType === PDF_MIME_TYPE && (_jsx(PdfPreview, { source: { url: downloadUrl }, testId: `file-preview-${fileId}` }))] }));
|
|
17
17
|
}
|
|
18
|
-
export function FileValueList({ fileIds, testId }) {
|
|
18
|
+
export function FileValueList({ fileIds, previewOpenByDefault = false, testId, }) {
|
|
19
19
|
if (fileIds.length === 0)
|
|
20
20
|
return null;
|
|
21
|
-
return (_jsx("ul", { className: "m-0 flex list-none flex-col gap-3 p-0", "data-testid": testId, children: fileIds.map((fileId) => (_jsx(FileValueRow, { fileId: fileId, ...(testId !== undefined && { testId: `${testId}-${fileId}` }) }, fileId))) }));
|
|
21
|
+
return (_jsx("ul", { className: "m-0 flex list-none flex-col gap-3 p-0", "data-testid": testId, children: fileIds.map((fileId, index) => (_jsx(FileValueRow, { fileId: fileId, previewOpenInitially: previewOpenByDefault && index === 0, ...(testId !== undefined && { testId: `${testId}-${fileId}` }) }, fileId))) }));
|
|
22
22
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import type { DataTableRangeBound, DataTableRangeFacet } from "../../data-table-aggregate-types.ts";
|
|
3
|
+
/** "From/to" bounds of a number, decimal, money or bigInt field. */
|
|
4
|
+
export declare function NumberRangeFacetFilter({ facet, onChange, }: {
|
|
5
|
+
readonly facet: DataTableRangeFacet;
|
|
6
|
+
readonly onChange: (field: string, bound: DataTableRangeBound, value: string) => void;
|
|
7
|
+
}): ReactNode;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { useOptionalTranslation } from "../../index.js";
|
|
4
|
+
const INPUT_CLASS = "h-9 w-24 rounded-md border border-input bg-transparent px-2 text-sm tabular-nums shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 max-md:min-h-11";
|
|
5
|
+
// A keystroke must not refetch the list, so a bound commits on blur or Enter;
|
|
6
|
+
// a value changed from outside (reset, back navigation) replaces the draft.
|
|
7
|
+
function RangeBoundInput({ facet, bound, label, onCommit, }) {
|
|
8
|
+
const committed = facet[bound];
|
|
9
|
+
const [draft, setDraft] = useState(committed);
|
|
10
|
+
const [seen, setSeen] = useState(committed);
|
|
11
|
+
if (seen !== committed) {
|
|
12
|
+
setSeen(committed);
|
|
13
|
+
setDraft(committed);
|
|
14
|
+
}
|
|
15
|
+
const commit = () => {
|
|
16
|
+
const trimmed = draft.trim();
|
|
17
|
+
if (trimmed !== "" && !Number.isFinite(Number(trimmed))) {
|
|
18
|
+
setDraft(committed);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
if (trimmed !== committed)
|
|
22
|
+
onCommit(facet.field, bound, trimmed);
|
|
23
|
+
};
|
|
24
|
+
return (_jsx("input", { type: "number", inputMode: "decimal", step: "any", className: INPUT_CLASS, value: draft, placeholder: label, "aria-label": `${facet.label} ${label}`, "data-testid": `facet-range-${facet.field}-${bound}`, onChange: (event) => setDraft(event.target.value), onBlur: commit, onKeyDown: (event) => {
|
|
25
|
+
if (event.key === "Enter")
|
|
26
|
+
commit();
|
|
27
|
+
} }));
|
|
28
|
+
}
|
|
29
|
+
/** "From/to" bounds of a number, decimal, money or bigInt field. */
|
|
30
|
+
export function NumberRangeFacetFilter({ facet, onChange, }) {
|
|
31
|
+
const translate = useOptionalTranslation();
|
|
32
|
+
return (_jsxs("div", { className: "flex items-center gap-2", "data-testid": `facet-range-${facet.field}`, children: [_jsx("span", { className: "text-sm text-muted-foreground", children: facet.label }), _jsx(RangeBoundInput, { facet: facet, bound: "min", label: translate?.("kumiko.list.range.min") ?? "From", onCommit: onChange }), _jsx("span", { className: "text-sm text-muted-foreground", children: "\u2013" }), _jsx(RangeBoundInput, { facet: facet, bound: "max", label: translate?.("kumiko.list.range.max") ?? "To", onCommit: onChange }), facet.unit !== undefined && (_jsx("span", { className: "text-sm text-muted-foreground", children: facet.unit }))] }));
|
|
33
|
+
}
|
|
@@ -1 +1,5 @@
|
|
|
1
|
-
export
|
|
1
|
+
export type BoundedLoadCache<T> = {
|
|
2
|
+
readonly load: (key: string, load: () => Promise<T>) => Promise<T>;
|
|
3
|
+
readonly retain: (key: string) => () => void;
|
|
4
|
+
};
|
|
5
|
+
export declare function createBoundedLoadCache<T>(max: number, dispose: (value: T) => void, shouldKeep?: (value: T) => boolean): BoundedLoadCache<T>;
|
|
@@ -1,31 +1,53 @@
|
|
|
1
|
-
// Evicts the oldest entry once `max` is exceeded and disposes only that one,
|
|
2
|
-
// so several documents on one page keep their own loading tasks alive. A
|
|
3
|
-
// resolved value that `shouldKeep` rejects (a handled failure) is dropped so
|
|
4
|
-
// the next mount retries.
|
|
5
1
|
export function createBoundedLoadCache(max, dispose, shouldKeep = () => true) {
|
|
6
2
|
const entries = new Map();
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}, forget);
|
|
22
|
-
if (entries.size > max) {
|
|
23
|
-
const [oldestKey, oldest] = entries.entries().next().value ?? [];
|
|
24
|
-
if (oldestKey !== undefined && oldest !== undefined) {
|
|
25
|
-
entries.delete(oldestKey);
|
|
26
|
-
void oldest.then(dispose);
|
|
27
|
-
}
|
|
3
|
+
const users = new Map();
|
|
4
|
+
const everRetained = new Set();
|
|
5
|
+
const evictIdle = (protectedKey) => {
|
|
6
|
+
for (const [key, entry] of entries) {
|
|
7
|
+
// skip: back within the bound, nothing more to evict
|
|
8
|
+
if (entries.size <= max)
|
|
9
|
+
return;
|
|
10
|
+
const idle = (users.get(key) ?? 0) === 0;
|
|
11
|
+
const settled = everRetained.has(key) || entries.size > max * 2;
|
|
12
|
+
if (key === protectedKey || !idle || !settled)
|
|
13
|
+
continue;
|
|
14
|
+
entries.delete(key);
|
|
15
|
+
everRetained.delete(key);
|
|
16
|
+
void entry.then(dispose);
|
|
28
17
|
}
|
|
29
|
-
|
|
18
|
+
};
|
|
19
|
+
return {
|
|
20
|
+
load: (key, load) => {
|
|
21
|
+
const cached = entries.get(key);
|
|
22
|
+
if (cached !== undefined)
|
|
23
|
+
return cached;
|
|
24
|
+
const loading = load();
|
|
25
|
+
entries.set(key, loading);
|
|
26
|
+
// A failed load must not stick: the next mount retries instead of replaying the rejection.
|
|
27
|
+
const forget = () => {
|
|
28
|
+
if (entries.get(key) === loading)
|
|
29
|
+
entries.delete(key);
|
|
30
|
+
};
|
|
31
|
+
loading.then((value) => {
|
|
32
|
+
if (!shouldKeep(value))
|
|
33
|
+
forget();
|
|
34
|
+
}, forget);
|
|
35
|
+
evictIdle(key);
|
|
36
|
+
return loading;
|
|
37
|
+
},
|
|
38
|
+
retain: (key) => {
|
|
39
|
+
users.set(key, (users.get(key) ?? 0) + 1);
|
|
40
|
+
everRetained.add(key);
|
|
41
|
+
return () => {
|
|
42
|
+
const remaining = (users.get(key) ?? 1) - 1;
|
|
43
|
+
if (remaining > 0) {
|
|
44
|
+
users.set(key, remaining);
|
|
45
|
+
// skip: other consumers still hold the entry
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
users.delete(key);
|
|
49
|
+
evictIdle();
|
|
50
|
+
};
|
|
51
|
+
},
|
|
30
52
|
};
|
|
31
53
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Suspense, use, useCallback, useMemo, useState } from "react";
|
|
2
|
+
import { Suspense, use, useCallback, useEffect, useMemo, useState } from "react";
|
|
3
3
|
import { useTranslation } from "../../i18n.js";
|
|
4
4
|
import { createBoundedLoadCache } from "./bounded-load-cache.js";
|
|
5
5
|
// pdf.js runs in the main thread ("fake worker"): the worker module is bundled
|
|
@@ -7,7 +7,7 @@ import { createBoundedLoadCache } from "./bounded-load-cache.js";
|
|
|
7
7
|
// CDN URL is ever fetched (self-hosting and CSP stay intact).
|
|
8
8
|
const RENDER_SCALE = 96 / 72;
|
|
9
9
|
const MAX_CACHED_DOCUMENTS = 4;
|
|
10
|
-
const
|
|
10
|
+
const documentCache = createBoundedLoadCache(MAX_CACHED_DOCUMENTS, (loaded) => {
|
|
11
11
|
if (loaded.ok)
|
|
12
12
|
void loaded.loadingTask.destroy();
|
|
13
13
|
}, (loaded) => loaded.ok);
|
|
@@ -42,9 +42,11 @@ async function loadDocument(source) {
|
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
// The promise must be stable across renders for use().
|
|
45
|
+
function documentKey(source) {
|
|
46
|
+
return "base64" in source ? `base64:${source.base64}` : `url:${source.url}`;
|
|
47
|
+
}
|
|
45
48
|
function documentFor(source) {
|
|
46
|
-
|
|
47
|
-
return cachedDocument(key, () => loadDocument(source));
|
|
49
|
+
return documentCache.load(documentKey(source), () => loadDocument(source));
|
|
48
50
|
}
|
|
49
51
|
function PageView({ document, pageNumber, }) {
|
|
50
52
|
const t = useTranslation();
|
|
@@ -128,6 +130,9 @@ function pageFor(document, pageNumber) {
|
|
|
128
130
|
function Pages({ source }) {
|
|
129
131
|
const t = useTranslation();
|
|
130
132
|
const loaded = use(documentFor(source));
|
|
133
|
+
// A mounted preview keeps its document out of the eviction: destroying it blanks its pages.
|
|
134
|
+
const key = documentKey(source);
|
|
135
|
+
useEffect(() => documentCache.retain(key), [key]);
|
|
131
136
|
if (!loaded.ok) {
|
|
132
137
|
return _jsx("p", { className: "text-sm text-status-bad", children: t("kumiko.widget.pdf-preview.renderFailed") });
|
|
133
138
|
}
|