@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.
Files changed (161) hide show
  1. package/dist/app/base-path-context.d.ts +9 -0
  2. package/dist/app/base-path-context.js +13 -0
  3. package/dist/app/base-path.d.ts +3 -0
  4. package/dist/app/base-path.js +27 -0
  5. package/dist/app/config-edit-body.d.ts +1 -1
  6. package/dist/app/config-edit-body.js +15 -6
  7. package/dist/app/config-edit-without-secret-fields.d.ts +2 -0
  8. package/dist/app/config-edit-without-secret-fields.js +28 -0
  9. package/dist/app/entity-list-facet-specs.d.ts +3 -0
  10. package/dist/app/entity-list-facet-specs.js +65 -0
  11. package/dist/app/entity-list-screen.js +74 -206
  12. package/dist/app/kumiko-screen.js +6 -14
  13. package/dist/app/list-bulk-actions.d.ts +18 -0
  14. package/dist/app/list-bulk-actions.js +54 -0
  15. package/dist/app/locked-screen-header.d.ts +9 -0
  16. package/dist/app/locked-screen-header.js +33 -0
  17. package/dist/app/projection-list-body.js +51 -46
  18. package/dist/app/row-actions.d.ts +3 -0
  19. package/dist/app/row-actions.js +10 -5
  20. package/dist/app/screen-shared.js +9 -2
  21. package/dist/app/use-entity-range-facets.d.ts +24 -0
  22. package/dist/app/use-entity-range-facets.js +64 -0
  23. package/dist/app/use-projection-date-range.d.ts +12 -0
  24. package/dist/app/use-projection-date-range.js +21 -0
  25. package/dist/app/use-screen-list-rows.d.ts +37 -0
  26. package/dist/app/use-screen-list-rows.js +104 -0
  27. package/dist/app/use-screen-list-table.d.ts +24 -0
  28. package/dist/app/use-screen-list-table.js +46 -0
  29. package/dist/components/list-group-page-bridges.d.ts +3 -0
  30. package/dist/components/list-group-page-bridges.js +31 -0
  31. package/dist/components/list-state-store.d.ts +16 -0
  32. package/dist/components/list-state-store.js +31 -0
  33. package/dist/components/related-list-section.js +37 -89
  34. package/dist/components/render-field.js +6 -5
  35. package/dist/components/render-list-toolbar-action.d.ts +27 -0
  36. package/dist/components/render-list-toolbar-action.js +40 -0
  37. package/dist/components/render-list.d.ts +13 -29
  38. package/dist/components/render-list.js +42 -50
  39. package/dist/current-user.d.ts +2 -0
  40. package/dist/data-table-aggregate-types.d.ts +47 -0
  41. package/dist/data-table-aggregate-types.js +1 -0
  42. package/dist/data-table-bulk-types.d.ts +27 -0
  43. package/dist/data-table-bulk-types.js +1 -0
  44. package/dist/data-table-facet-types.d.ts +36 -0
  45. package/dist/data-table-facet-types.js +1 -0
  46. package/dist/data-table-view-types.d.ts +73 -0
  47. package/dist/data-table-view-types.js +1 -0
  48. package/dist/headless/view-model/edit.js +3 -1
  49. package/dist/headless/view-model/types.d.ts +4 -2
  50. package/dist/hooks/list-column-layout.d.ts +19 -0
  51. package/dist/hooks/list-column-layout.js +36 -0
  52. package/dist/hooks/list-group-pages.d.ts +26 -0
  53. package/dist/hooks/list-group-pages.js +37 -0
  54. package/dist/hooks/list-reset.d.ts +8 -0
  55. package/dist/hooks/list-reset.js +15 -0
  56. package/dist/hooks/list-server-groups.d.ts +39 -0
  57. package/dist/hooks/list-server-groups.js +84 -0
  58. package/dist/hooks/list-state-schema.d.ts +28 -0
  59. package/dist/hooks/list-state-schema.js +76 -0
  60. package/dist/hooks/use-bulk-run.d.ts +41 -0
  61. package/dist/hooks/use-bulk-run.js +65 -0
  62. package/dist/hooks/use-group-load-state.d.ts +11 -0
  63. package/dist/hooks/use-group-load-state.js +31 -0
  64. package/dist/hooks/use-list-aggregates.d.ts +38 -0
  65. package/dist/hooks/use-list-aggregates.js +107 -0
  66. package/dist/hooks/use-list-table.d.ts +38 -0
  67. package/dist/hooks/use-list-table.js +91 -0
  68. package/dist/hooks/use-list-view-state.d.ts +43 -0
  69. package/dist/hooks/use-list-view-state.js +161 -0
  70. package/dist/hooks/use-related-list-rows.d.ts +27 -0
  71. package/dist/hooks/use-related-list-rows.js +81 -0
  72. package/dist/hooks/use-related-list-state.d.ts +15 -0
  73. package/dist/hooks/use-related-list-state.js +31 -0
  74. package/dist/hooks/use-remembered-list-params.d.ts +13 -0
  75. package/dist/hooks/use-remembered-list-params.js +53 -0
  76. package/dist/hooks/use-remembered-local-list-state.d.ts +26 -0
  77. package/dist/hooks/use-remembered-local-list-state.js +63 -0
  78. package/dist/i18n-defaults.js +53 -0
  79. package/dist/index.d.ts +3 -1
  80. package/dist/index.js +2 -0
  81. package/dist/primitives.d.ts +11 -47
  82. package/dist/web/app/create-app.d.ts +5 -0
  83. package/dist/web/app/create-app.js +7 -4
  84. package/dist/web/app/history-leave-guard.d.ts +6 -0
  85. package/dist/web/app/history-leave-guard.js +115 -0
  86. package/dist/web/app/list-state-store-boot.d.ts +6 -0
  87. package/dist/web/app/list-state-store-boot.js +15 -0
  88. package/dist/web/app/nav.js +7 -54
  89. package/dist/web/app/session-list-state-store.d.ts +15 -0
  90. package/dist/web/app/session-list-state-store.js +117 -0
  91. package/dist/web/index.d.ts +1 -0
  92. package/dist/web/index.js +1 -0
  93. package/dist/web/lib/export-xlsx-request.d.ts +11 -0
  94. package/dist/web/lib/export-xlsx-request.js +46 -0
  95. package/dist/web/primitives/data-table/actions-column.d.ts +16 -0
  96. package/dist/web/primitives/data-table/actions-column.js +34 -0
  97. package/dist/web/primitives/data-table/bulk-selection-bar.d.ts +20 -0
  98. package/dist/web/primitives/data-table/bulk-selection-bar.js +37 -0
  99. package/dist/web/primitives/data-table/card-sort-select.d.ts +14 -0
  100. package/dist/web/primitives/data-table/card-sort-select.js +40 -0
  101. package/dist/web/primitives/data-table/column-layout-mode.d.ts +5 -0
  102. package/dist/web/primitives/data-table/column-layout-mode.js +10 -0
  103. package/dist/web/primitives/data-table/column-picker-section.d.ts +7 -0
  104. package/dist/web/primitives/data-table/column-picker-section.js +35 -0
  105. package/dist/web/primitives/data-table/column-resize-handle.d.ts +14 -0
  106. package/dist/web/primitives/data-table/column-resize-handle.js +46 -0
  107. package/dist/web/primitives/data-table/density.d.ts +14 -0
  108. package/dist/web/primitives/data-table/density.js +20 -0
  109. package/dist/web/primitives/data-table/export-button.d.ts +5 -0
  110. package/dist/web/primitives/data-table/export-button.js +37 -0
  111. package/dist/web/primitives/data-table/facet-cluster.d.ts +8 -0
  112. package/dist/web/primitives/data-table/facet-cluster.js +37 -0
  113. package/dist/web/primitives/data-table/group-band.d.ts +28 -0
  114. package/dist/web/primitives/data-table/group-band.js +53 -0
  115. package/dist/web/primitives/data-table/measure-value.d.ts +10 -0
  116. package/dist/web/primitives/data-table/measure-value.js +28 -0
  117. package/dist/web/primitives/data-table/pin-section.d.ts +8 -0
  118. package/dist/web/primitives/data-table/pin-section.js +20 -0
  119. package/dist/web/primitives/data-table/reset-all-button.d.ts +4 -0
  120. package/dist/web/primitives/data-table/reset-all-button.js +8 -0
  121. package/dist/web/primitives/data-table/server-group-header.d.ts +11 -0
  122. package/dist/web/primitives/data-table/server-group-header.js +51 -0
  123. package/dist/web/primitives/data-table/sortable-header.d.ts +17 -0
  124. package/dist/web/primitives/data-table/sortable-header.js +41 -0
  125. package/dist/web/primitives/data-table/table-chrome.d.ts +13 -0
  126. package/dist/web/primitives/data-table/table-chrome.js +30 -0
  127. package/dist/web/primitives/data-table/table-footer.d.ts +7 -0
  128. package/dist/web/primitives/data-table/table-footer.js +20 -0
  129. package/dist/web/primitives/data-table/toolbar-end-cluster.d.ts +10 -0
  130. package/dist/web/primitives/data-table/toolbar-end-cluster.js +13 -0
  131. package/dist/web/primitives/data-table/totals-footer.d.ts +24 -0
  132. package/dist/web/primitives/data-table/totals-footer.js +48 -0
  133. package/dist/web/primitives/data-table/use-bulk-selection.d.ts +21 -0
  134. package/dist/web/primitives/data-table/use-bulk-selection.js +66 -0
  135. package/dist/web/primitives/data-table/use-column-pins.d.ts +32 -0
  136. package/dist/web/primitives/data-table/use-column-pins.js +117 -0
  137. package/dist/web/primitives/data-table/use-column-sizing.d.ts +22 -0
  138. package/dist/web/primitives/data-table/use-column-sizing.js +44 -0
  139. package/dist/web/primitives/data-table/use-measured-column-widths.d.ts +15 -0
  140. package/dist/web/primitives/data-table/use-measured-column-widths.js +83 -0
  141. package/dist/web/primitives/data-table/use-row-keyboard-navigation.d.ts +22 -0
  142. package/dist/web/primitives/data-table/use-row-keyboard-navigation.js +65 -0
  143. package/dist/web/primitives/data-table/use-row-selection.d.ts +14 -0
  144. package/dist/web/primitives/data-table/use-row-selection.js +48 -0
  145. package/dist/web/primitives/data-table/use-view-columns.d.ts +6 -0
  146. package/dist/web/primitives/data-table/use-view-columns.js +17 -0
  147. package/dist/web/primitives/data-table/view-menu.d.ts +8 -0
  148. package/dist/web/primitives/data-table/view-menu.js +45 -0
  149. package/dist/web/primitives/data-table-cell.d.ts +0 -22
  150. package/dist/web/primitives/data-table-cell.js +1 -58
  151. package/dist/web/primitives/data-table.d.ts +1 -1
  152. package/dist/web/primitives/data-table.js +122 -144
  153. package/dist/web/primitives/file-value-list.d.ts +1 -1
  154. package/dist/web/primitives/file-value-list.js +5 -5
  155. package/dist/web/primitives/number-range-facet.d.ts +7 -0
  156. package/dist/web/primitives/number-range-facet.js +33 -0
  157. package/dist/web/widgets/bounded-load-cache.d.ts +5 -1
  158. package/dist/web/widgets/bounded-load-cache.js +48 -26
  159. package/dist/web/widgets/pdf-preview.js +9 -4
  160. package/package.json +5 -5
  161. 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 { ArrowDown, ArrowUp, ChevronDown, ChevronRight, Search, SlidersHorizontal, X, } from "lucide-react";
4
- import { Fragment, useId, useState, } from "react";
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 { 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";
15
- import { DateRangeFacetFilter, FacetFilter } from "./facet-filter.js";
16
- import { InfiniteSentinel, Pager, TABLE_FOOTER_BAR_CLASS } from "./pager.js";
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
- // The vendored Table wraps <table> in an `overflow-x-auto` container, which
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 = 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] }));
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
- 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}`));
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
- 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
- }) })] }) }));
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
- 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))) })] }) }));
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 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;
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 || toolbarEnd !== undefined || facetCluster !== 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), 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"), fillsHeight && !hasToolbar && "pt-4"), children: toolbarDescription })), content] }));
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(false);
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 declare function createBoundedLoadCache<T>(max: number, dispose: (value: T) => void, shouldKeep?: (value: T) => boolean): (key: string, load: () => Promise<T>) => Promise<T>;
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
- return (key, load) => {
8
- const cached = entries.get(key);
9
- if (cached !== undefined)
10
- return cached;
11
- const loading = load();
12
- entries.set(key, loading);
13
- // A failed load must not stick: the next mount retries instead of replaying the rejection.
14
- const forget = () => {
15
- if (entries.get(key) === loading)
16
- entries.delete(key);
17
- };
18
- loading.then((value) => {
19
- if (!shouldKeep(value))
20
- forget();
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
- return loading;
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 cachedDocument = createBoundedLoadCache(MAX_CACHED_DOCUMENTS, (loaded) => {
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
- const key = "base64" in source ? `base64:${source.base64}` : `url:${source.url}`;
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
  }