@pallasoft/ps-table-components 1.18.1 → 1.20.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 (42) hide show
  1. package/dist/components/ps-data-table.js +86 -24
  2. package/dist/components/ps-table-pagination.d.ts +2 -1
  3. package/dist/components/ps-table-pagination.js +3 -2
  4. package/dist/constants/table-config.d.ts +9 -0
  5. package/dist/constants/table-config.js +6 -0
  6. package/dist/esm/components/ps-data-table.js +87 -25
  7. package/dist/esm/components/ps-table-pagination.js +3 -2
  8. package/dist/esm/constants/table-config.js +6 -0
  9. package/dist/esm/hooks/use-ps-incremental-count.js +17 -0
  10. package/dist/esm/hooks/use-ps-scroll-anchor.js +26 -0
  11. package/dist/esm/hooks/use-ps-streamed-rows.js +58 -15
  12. package/dist/esm/index.js +3 -0
  13. package/dist/esm/interfaces/table-scroll-anchor-target.js +1 -0
  14. package/dist/esm/interfaces/table-scroll-anchor.js +1 -0
  15. package/dist/esm/services/ps-table-query-service.js +25 -13
  16. package/dist/esm/services/ps-table-scroll-anchor-service.js +17 -0
  17. package/dist/esm/services/ps-table-scroll-service.js +18 -0
  18. package/dist/esm/services/ps-table-search-service.js +14 -4
  19. package/dist/esm/services/ps-table-stream-service.js +18 -0
  20. package/dist/hooks/use-ps-incremental-count.d.ts +5 -0
  21. package/dist/hooks/use-ps-incremental-count.js +21 -0
  22. package/dist/hooks/use-ps-scroll-anchor.d.ts +2 -0
  23. package/dist/hooks/use-ps-scroll-anchor.js +30 -0
  24. package/dist/hooks/use-ps-streamed-rows.js +57 -14
  25. package/dist/index.d.ts +3 -0
  26. package/dist/index.js +3 -0
  27. package/dist/interfaces/table-scroll-anchor-target.d.ts +10 -0
  28. package/dist/interfaces/table-scroll-anchor-target.js +2 -0
  29. package/dist/interfaces/table-scroll-anchor.d.ts +4 -0
  30. package/dist/interfaces/table-scroll-anchor.js +2 -0
  31. package/dist/interfaces/table-stream-state.d.ts +1 -0
  32. package/dist/services/ps-table-query-service.d.ts +2 -0
  33. package/dist/services/ps-table-query-service.js +25 -13
  34. package/dist/services/ps-table-scroll-anchor-service.d.ts +7 -0
  35. package/dist/services/ps-table-scroll-anchor-service.js +21 -0
  36. package/dist/services/ps-table-scroll-service.d.ts +5 -0
  37. package/dist/services/ps-table-scroll-service.js +22 -0
  38. package/dist/services/ps-table-search-service.d.ts +3 -0
  39. package/dist/services/ps-table-search-service.js +14 -4
  40. package/dist/services/ps-table-stream-service.d.ts +2 -0
  41. package/dist/services/ps-table-stream-service.js +18 -0
  42. package/package.json +2 -1
@@ -7,11 +7,16 @@ const ps_core_components_1 = require("@pallasoft/ps-core-components");
7
7
  const ps_design_system_1 = require("@pallasoft/ps-design-system");
8
8
  const ps_input_components_1 = require("@pallasoft/ps-input-components");
9
9
  const ps_motion_components_1 = require("@pallasoft/ps-motion-components");
10
+ const react_virtual_1 = require("@tanstack/react-virtual");
10
11
  const table_config_1 = require("../constants/table-config");
12
+ const use_ps_incremental_count_1 = require("../hooks/use-ps-incremental-count");
13
+ const use_ps_scroll_anchor_1 = require("../hooks/use-ps-scroll-anchor");
14
+ const use_ps_table_stream_status_1 = require("../hooks/use-ps-table-stream-status");
11
15
  const ps_table_cell_tooltip_service_1 = require("../services/ps-table-cell-tooltip-service");
12
16
  const ps_table_column_size_service_1 = require("../services/ps-table-column-size-service");
13
17
  const ps_table_group_service_1 = require("../services/ps-table-group-service");
14
18
  const ps_table_query_service_1 = require("../services/ps-table-query-service");
19
+ const ps_table_scroll_service_1 = require("../services/ps-table-scroll-service");
15
20
  const ps_table_bulk_bar_1 = require("./ps-table-bulk-bar");
16
21
  const ps_table_card_list_1 = require("./ps-table-card-list");
17
22
  const ps_table_column_picker_1 = require("./ps-table-column-picker");
@@ -64,15 +69,21 @@ const PsDataTable = ({ caption, className, columns, defaultSort, density = 'norm
64
69
  const hasToolbar = isSearchable || hasFilters || Boolean(toolbarActions);
65
70
  const isSelectable = Boolean(getBulkActions);
66
71
  const columnCount = visibleColumns.length + (isSelectable ? 1 : 0);
72
+ const deferredSearch = (0, react_1.useDeferredValue)(search);
67
73
  const queriedRows = (0, react_1.useMemo)(() => isServerPaginated
68
74
  ? rows
69
- : ps_table_query_service_1.PsTableQueryService.apply(rows, columns, filters, search, getSearchText, sort), [columns, filters, getSearchText, isServerPaginated, rows, search, sort]);
70
- const currentPage = page !== null && page !== undefined ? page : uncontrolledPage;
71
- const rowCount = isServerPaginated ? totalRows : queriedRows.length;
72
- const pageCount = pageSize ? Math.max(Math.ceil(rowCount / pageSize), 1) : 1;
73
- const safePage = Math.min(Math.max(currentPage, 1), pageCount);
75
+ : ps_table_query_service_1.PsTableQueryService.apply(rows, columns, filters, deferredSearch, getSearchText, sort), [columns, filters, rows, deferredSearch, sort, getSearchText, isServerPaginated]);
76
+ const streamStatus = (0, use_ps_table_stream_status_1.usePsTableStreamStatus)();
74
77
  const isQueried = search.trim() !== '' ||
75
78
  Object.values(filters).some((value) => ps_table_query_service_1.PsTableQueryService.isActive(value));
79
+ const currentPage = page !== null && page !== undefined ? page : uncontrolledPage;
80
+ const loadedRowCount = isServerPaginated ? totalRows : queriedRows.length;
81
+ const rowCount = streamStatus !== null && !isServerPaginated && !isQueried
82
+ ? Math.max(streamStatus.total, loadedRowCount)
83
+ : loadedRowCount;
84
+ const pageCountOf = (count) => (pageSize ? Math.max(Math.ceil(count / pageSize), 1) : 1);
85
+ const pageCount = pageCountOf(rowCount);
86
+ const safePage = Math.min(Math.max(currentPage, 1), pageCountOf(loadedRowCount));
76
87
  const groups = (0, react_1.useMemo)(() => (grouping ? ps_table_group_service_1.PsTableGroupService.group(queriedRows, grouping.getGroupKey) : []), [grouping, queriedRows]);
77
88
  const initialExpandedCount = grouping && grouping.initialExpandedCount !== undefined ? grouping.initialExpandedCount : 1;
78
89
  const isGroupExpanded = (groupKey, groupIndex) => isQueried || groupIndex < initialExpandedCount !== toggledGroupKeys.includes(groupKey);
@@ -149,19 +160,64 @@ const PsDataTable = ({ caption, className, columns, defaultSort, density = 'norm
149
160
  updateQuery({ filters: {}, search: '' });
150
161
  resetPage();
151
162
  };
152
- const selectedRows = queriedRows.filter((row) => selectedIds.includes(getRowId(row)));
163
+ const selectedIdSet = (0, react_1.useMemo)(() => new Set(selectedIds), [selectedIds]);
164
+ const selectedRows = selectedIds.length === 0 ? [] : queriedRows.filter((row) => selectedIdSet.has(getRowId(row)));
153
165
  const pageIds = visibleRows.map((row) => getRowId(row));
154
- const isPageSelected = pageIds.length > 0 && pageIds.every((rowId) => selectedIds.includes(rowId));
155
- const togglePage = () => setSelectedIds((current) => isPageSelected
156
- ? current.filter((rowId) => !pageIds.includes(rowId))
157
- : [...new Set([...current, ...pageIds])]);
166
+ const isPageSelected = pageIds.length > 0 && pageIds.every((rowId) => selectedIdSet.has(rowId));
167
+ const togglePage = () => setSelectedIds((current) => {
168
+ const pageIdSet = new Set(pageIds);
169
+ return isPageSelected
170
+ ? current.filter((rowId) => !pageIdSet.has(rowId))
171
+ : [...new Set([...current, ...pageIds])];
172
+ });
173
+ const bodyRef = (0, react_1.useRef)(null);
174
+ const [scrollParent, setScrollParent] = (0, react_1.useState)(null);
175
+ const [scrollMargin, setScrollMargin] = (0, react_1.useState)(0);
176
+ const shouldVirtualize = !grouping && visibleRows.length > table_config_1.PsTableConfig.virtualizeAfter;
177
+ const virtualOptions = {
178
+ count: shouldVirtualize ? visibleRows.length : 0,
179
+ estimateSize: () => table_config_1.PsTableConfig.rowEstimatePx[density],
180
+ getItemKey: (index) => getRowId(visibleRows[index]),
181
+ initialRect: {
182
+ height: table_config_1.PsTableConfig.virtualizeAfter * table_config_1.PsTableConfig.rowEstimatePx[density],
183
+ width: 0,
184
+ },
185
+ overscan: table_config_1.PsTableConfig.virtualOverscan,
186
+ scrollMargin,
187
+ };
188
+ const elementVirtualizer = (0, react_virtual_1.useVirtualizer)({
189
+ ...virtualOptions,
190
+ count: scrollParent === null ? 0 : virtualOptions.count,
191
+ getScrollElement: () => scrollParent,
192
+ });
193
+ const windowVirtualizer = (0, react_virtual_1.useWindowVirtualizer)({
194
+ ...virtualOptions,
195
+ count: scrollParent === null ? virtualOptions.count : 0,
196
+ });
197
+ const virtualizer = scrollParent === null ? windowVirtualizer : elementVirtualizer;
198
+ const virtualItems = shouldVirtualize ? virtualizer.getVirtualItems() : [];
199
+ const visibleRowIds = (0, react_1.useMemo)(() => (shouldVirtualize ? visibleRows.map((row) => getRowId(row)) : []), [getRowId, shouldVirtualize, visibleRows]);
200
+ (0, use_ps_scroll_anchor_1.usePsScrollAnchor)(virtualizer, visibleRowIds, shouldVirtualize && streamStatus !== null);
201
+ const virtualTop = virtualItems.length > 0 ? virtualItems[0].start - scrollMargin : 0;
202
+ const virtualBottom = virtualItems.length > 0
203
+ ? virtualizer.getTotalSize() - (virtualItems[virtualItems.length - 1].end - scrollMargin)
204
+ : 0;
205
+ const cards = (0, use_ps_incremental_count_1.usePsIncrementalCount)(visibleRows.length, table_config_1.PsTableConfig.cardBatch);
206
+ (0, react_1.useEffect)(() => {
207
+ const body = bodyRef.current;
208
+ if (!shouldVirtualize || body === null)
209
+ return;
210
+ const parent = ps_table_scroll_service_1.PsTableScrollService.parent(body);
211
+ setScrollParent(parent);
212
+ setScrollMargin(ps_table_scroll_service_1.PsTableScrollService.offset(body, parent));
213
+ }, [shouldVirtualize, visibleRows.length]);
158
214
  const toggleRow = (rowId) => setSelectedIds((current) => current.includes(rowId) ? current.filter((id) => id !== rowId) : [...current, rowId]);
159
215
  const renderCardList = (cardRows, emptyContent) => ((0, jsx_runtime_1.jsx)(ps_table_card_list_1.PsTableCardList, { columns: visibleColumns, emptyContent: emptyContent, expandedRowIds: expandedRowIds, footerColumns: hasFooter ? visibleColumns.filter((column) => column.footer !== undefined) : [], getRowId: getRowId, getRowLabel: (row) => (getRowLabel ? getRowLabel(row) : getRowId(row)), isSelectable: isSelectable, onRowClick: onRowClick, onRowHover: onRowHover, onToggleRow: toggleRow, renderExpandedRow: renderExpandedRow, rows: cardRows, selectedIds: selectedIds }));
160
- const renderRow = (row, motion) => {
216
+ const renderRow = (row, motion, virtualItem) => {
161
217
  const rowId = getRowId(row);
162
218
  const isExpanded = expandedRowIds ? expandedRowIds.includes(rowId) : false;
163
219
  const rowLabel = getRowLabel ? getRowLabel(row) : rowId;
164
- return ((0, jsx_runtime_1.jsxs)(react_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)("tr", { "aria-expanded": renderExpandedRow ? isExpanded : undefined, "aria-label": onRowClick ? rowLabel : undefined, className: ps_design_system_1.PallasoftClassNameService.merge('align-middle transition-colors', motion ? motion.className : '', onRowClick ? 'ps-focus-ring cursor-pointer hover:bg-mist/60' : 'hover:bg-mist/40', isExpanded ? 'bg-mist/40' : ''), onClick: onRowClick ? () => onRowClick(row) : undefined, onFocus: onRowHover ? () => onRowHover(row) : undefined, onPointerEnter: onRowHover ? () => onRowHover(row) : undefined, onKeyDown: onRowClick
220
+ return ((0, jsx_runtime_1.jsxs)(react_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)("tr", { "data-index": virtualItem ? virtualItem.index : undefined, ref: virtualItem ? virtualizer.measureElement : undefined, "aria-expanded": renderExpandedRow ? isExpanded : undefined, "aria-label": onRowClick ? rowLabel : undefined, className: ps_design_system_1.PallasoftClassNameService.merge('align-middle transition-colors', motion ? motion.className : '', onRowClick ? 'ps-focus-ring cursor-pointer hover:bg-mist/60' : 'hover:bg-mist/40', isExpanded ? 'bg-mist/40' : ''), onClick: onRowClick ? () => onRowClick(row) : undefined, onFocus: onRowHover ? () => onRowHover(row) : undefined, onPointerEnter: onRowHover ? () => onRowHover(row) : undefined, onKeyDown: onRowClick
165
221
  ? (event) => {
166
222
  if (event.target !== event.currentTarget)
167
223
  return;
@@ -172,7 +228,7 @@ const PsDataTable = ({ caption, className, columns, defaultSort, density = 'norm
172
228
  }
173
229
  : undefined, style: motion && motion.delayMs !== undefined
174
230
  ? { animationDelay: `${motion.delayMs}ms` }
175
- : undefined, tabIndex: onRowClick ? 0 : undefined, children: [isSelectable ? ((0, jsx_runtime_1.jsx)("td", { className: 'w-10 pl-4 pr-0', onClick: (event) => event.stopPropagation(), onKeyDown: (event) => event.stopPropagation(), children: (0, jsx_runtime_1.jsx)(ps_input_components_1.PsCheckbox, { isSelected: selectedIds.includes(rowId), label: `Select ${rowLabel}`, onChange: () => toggleRow(rowId) }) })) : null, visibleColumns.map((column) => ((0, jsx_runtime_1.jsx)("td", { className: ps_design_system_1.PallasoftClassNameService.merge(densityClassNames[density].cell, alignClassNames[column.align !== null && column.align !== undefined ? column.align : 'left'], column.wrap ? '' : 'whitespace-nowrap', (layout === 'fixed' && !column.wrap) ||
231
+ : undefined, tabIndex: onRowClick ? 0 : undefined, children: [isSelectable ? ((0, jsx_runtime_1.jsx)("td", { className: 'w-10 pl-4 pr-0', onClick: (event) => event.stopPropagation(), onKeyDown: (event) => event.stopPropagation(), children: (0, jsx_runtime_1.jsx)(ps_input_components_1.PsCheckbox, { isSelected: selectedIdSet.has(rowId), label: `Select ${rowLabel}`, onChange: () => toggleRow(rowId) }) })) : null, visibleColumns.map((column) => ((0, jsx_runtime_1.jsx)("td", { className: ps_design_system_1.PallasoftClassNameService.merge(densityClassNames[density].cell, alignClassNames[column.align !== null && column.align !== undefined ? column.align : 'left'], column.wrap ? '' : 'whitespace-nowrap', (layout === 'fixed' && !column.wrap) ||
176
232
  ps_table_column_size_service_1.PsTableColumnSizeService.maxWidthOf(column) !== undefined
177
233
  ? 'truncate'
178
234
  : '', 'text-sm text-ink'), style: columnStyle(column), title: ps_table_cell_tooltip_service_1.PsTableCellTooltipService.forCell(column, row), children: column.render(row) }, column.id)))] }), renderExpandedRow ? ((0, jsx_runtime_1.jsx)("tr", { "aria-hidden": !isExpanded, className: isExpanded ? 'border-t-0' : 'border-0', children: (0, jsx_runtime_1.jsx)("td", { className: 'p-0', colSpan: columnCount, children: (0, jsx_runtime_1.jsx)(ps_motion_components_1.PsCollapse, { isOpen: isExpanded, children: (0, jsx_runtime_1.jsx)("div", { className: 'w-0 min-w-full border-l-2 border-primary bg-mist/40 px-4 py-4', children: renderExpandedRow(row) }) }) }) })) : null] }, rowId));
@@ -187,14 +243,14 @@ const PsDataTable = ({ caption, className, columns, defaultSort, density = 'norm
187
243
  }, onSearchChange: (next) => {
188
244
  updateQuery({ search: next });
189
245
  resetPage();
190
- }, rows: rows, search: search, searchPlaceholder: searchPlaceholder })) : null, (0, jsx_runtime_1.jsxs)("div", { className: 'min-w-0 overflow-hidden rounded-[1.25rem] border border-line bg-surface', children: [mobileLayout === 'cards' ? ((0, jsx_runtime_1.jsx)("div", { className: 'sm:hidden', children: grouping
191
- ? groups.map((group, groupIndex) => {
192
- const isExpanded = isGroupExpanded(group.key, groupIndex);
193
- return ((0, jsx_runtime_1.jsxs)("div", { className: 'border-b border-line last:border-b-0', children: [(0, jsx_runtime_1.jsx)(ps_table_group_header_1.PsTableGroupHeader, { count: group.rows.length, isExpanded: isExpanded, label: grouping.getGroupLabel(group.key, group.rows), onToggle: () => toggleGroup(group.key, isExpanded), summary: grouping.renderGroupSummary
194
- ? grouping.renderGroupSummary(group.rows)
195
- : undefined }), (0, jsx_runtime_1.jsx)(ps_motion_components_1.PsCollapse, { isOpen: isExpanded && !closingGroupKeys.includes(group.key), children: renderCardList(group.rows, null) })] }, `group-${group.key}`));
196
- })
197
- : renderCardList(visibleRows, visibleRows.length === 0 && isQueried ? ((0, jsx_runtime_1.jsxs)("div", { className: 'px-4 py-10 text-center', children: [(0, jsx_runtime_1.jsx)("p", { className: 'text-sm font-semibold text-ink', children: table_config_1.PsTableConfig.noMatchesTitle }), (0, jsx_runtime_1.jsx)("button", { className: 'ps-focus-ring mt-2 rounded-full px-3 py-1.5 text-sm font-semibold text-primary', onClick: clearFilters, type: 'button', children: "Clear search and filters" })] })) : null) })) : null, (0, jsx_runtime_1.jsx)("div", { className: ps_design_system_1.PallasoftClassNameService.merge('ps-scrollbar overflow-x-auto overflow-y-hidden', mobileLayout === 'cards' ? 'hidden sm:block' : ''), children: (0, jsx_runtime_1.jsxs)("table", { className: ps_design_system_1.PallasoftClassNameService.merge('w-full border-collapse text-left', layout === 'fixed' ? 'table-fixed' : ''), children: [caption ? (0, jsx_runtime_1.jsx)("caption", { className: 'sr-only', children: caption }) : null, (0, jsx_runtime_1.jsx)("thead", { className: 'bg-mist/70 text-[0.68rem] font-semibold uppercase tracking-[0.18em] text-ink/45', children: (0, jsx_runtime_1.jsxs)("tr", { children: [isSelectable ? ((0, jsx_runtime_1.jsx)("th", { className: 'w-10 pl-4 pr-0', scope: 'col', children: (0, jsx_runtime_1.jsx)(ps_input_components_1.PsCheckbox, { isIndeterminate: !isPageSelected && pageIds.some((rowId) => selectedIds.includes(rowId)), isSelected: isPageSelected, label: 'Select all rows on this page', onChange: togglePage }) })) : null, visibleColumns.map((column) => {
246
+ }, rows: rows, search: search, searchPlaceholder: searchPlaceholder })) : null, (0, jsx_runtime_1.jsxs)("div", { className: 'min-w-0 overflow-hidden rounded-[1.25rem] border border-line bg-surface', children: [mobileLayout === 'cards' ? ((0, jsx_runtime_1.jsxs)("div", { className: 'sm:hidden', children: [grouping
247
+ ? groups.map((group, groupIndex) => {
248
+ const isExpanded = isGroupExpanded(group.key, groupIndex);
249
+ return ((0, jsx_runtime_1.jsxs)("div", { className: 'border-b border-line last:border-b-0', children: [(0, jsx_runtime_1.jsx)(ps_table_group_header_1.PsTableGroupHeader, { count: group.rows.length, isExpanded: isExpanded, label: grouping.getGroupLabel(group.key, group.rows), onToggle: () => toggleGroup(group.key, isExpanded), summary: grouping.renderGroupSummary
250
+ ? grouping.renderGroupSummary(group.rows)
251
+ : undefined }), (0, jsx_runtime_1.jsx)(ps_motion_components_1.PsCollapse, { isOpen: isExpanded && !closingGroupKeys.includes(group.key), children: renderCardList(group.rows, null) })] }, `group-${group.key}`));
252
+ })
253
+ : renderCardList(shouldVirtualize ? visibleRows.slice(0, cards.count) : visibleRows, visibleRows.length === 0 && isQueried ? ((0, jsx_runtime_1.jsxs)("div", { className: 'px-4 py-10 text-center', children: [(0, jsx_runtime_1.jsx)("p", { className: 'text-sm font-semibold text-ink', children: table_config_1.PsTableConfig.noMatchesTitle }), (0, jsx_runtime_1.jsx)("button", { className: 'ps-focus-ring mt-2 rounded-full px-3 py-1.5 text-sm font-semibold text-primary', onClick: clearFilters, type: 'button', children: "Clear search and filters" })] })) : null), !grouping && shouldVirtualize && cards.count < visibleRows.length ? ((0, jsx_runtime_1.jsx)("div", { "aria-hidden": 'true', className: 'h-px', ref: cards.sentinelRef })) : null] })) : null, (0, jsx_runtime_1.jsx)("div", { className: ps_design_system_1.PallasoftClassNameService.merge('ps-scrollbar overflow-x-auto overflow-y-hidden', mobileLayout === 'cards' ? 'hidden sm:block' : ''), children: (0, jsx_runtime_1.jsxs)("table", { className: ps_design_system_1.PallasoftClassNameService.merge('w-full border-collapse text-left', layout === 'fixed' ? 'table-fixed' : ''), children: [caption ? (0, jsx_runtime_1.jsx)("caption", { className: 'sr-only', children: caption }) : null, (0, jsx_runtime_1.jsx)("thead", { className: 'bg-mist/70 text-[0.68rem] font-semibold uppercase tracking-[0.18em] text-ink/45', children: (0, jsx_runtime_1.jsxs)("tr", { children: [isSelectable ? ((0, jsx_runtime_1.jsx)("th", { className: 'w-10 pl-4 pr-0', scope: 'col', children: (0, jsx_runtime_1.jsx)(ps_input_components_1.PsCheckbox, { isIndeterminate: !isPageSelected && pageIds.some((rowId) => selectedIdSet.has(rowId)), isSelected: isPageSelected, label: 'Select all rows on this page', onChange: togglePage }) })) : null, visibleColumns.map((column) => {
198
254
  const align = column.align !== null && column.align !== undefined ? column.align : 'left';
199
255
  const isSorted = sort !== null && sort.columnId === column.id;
200
256
  return ((0, jsx_runtime_1.jsx)("th", { "aria-sort": isSorted
@@ -202,7 +258,7 @@ const PsDataTable = ({ caption, className, columns, defaultSort, density = 'norm
202
258
  : column.sortValue && isQueryable
203
259
  ? 'none'
204
260
  : undefined, className: ps_design_system_1.PallasoftClassNameService.merge(densityClassNames[density].head, alignClassNames[align], 'whitespace-nowrap'), scope: 'col', style: columnStyle(column), children: column.sortValue && isQueryable ? ((0, jsx_runtime_1.jsx)(ps_table_sort_header_1.PsTableSortHeader, { align: align, columnId: column.id, label: column.label, onSort: updateSort, sort: sort })) : (column.label) }, column.id));
205
- })] }) }), (0, jsx_runtime_1.jsxs)("tbody", { className: 'divide-y divide-line', children: [visibleRows.length === 0 && isQueried ? ((0, jsx_runtime_1.jsx)("tr", { children: (0, jsx_runtime_1.jsxs)("td", { className: 'px-4 py-12 text-center', colSpan: columnCount, children: [(0, jsx_runtime_1.jsx)("p", { className: 'text-sm font-semibold text-ink', children: table_config_1.PsTableConfig.noMatchesTitle }), (0, jsx_runtime_1.jsx)("p", { className: 'mt-1 text-sm text-ink/55', children: table_config_1.PsTableConfig.noMatchesDescription }), (0, jsx_runtime_1.jsx)("button", { className: 'ps-focus-ring mt-3 rounded-full px-3 py-1.5 text-sm font-semibold text-primary hover:underline', onClick: clearFilters, type: 'button', children: "Clear search and filters" })] }) })) : null, grouping
261
+ })] }) }), (0, jsx_runtime_1.jsxs)("tbody", { className: 'divide-y divide-line', ref: bodyRef, children: [visibleRows.length === 0 && isQueried ? ((0, jsx_runtime_1.jsx)("tr", { children: (0, jsx_runtime_1.jsxs)("td", { className: 'px-4 py-12 text-center', colSpan: columnCount, children: [(0, jsx_runtime_1.jsx)("p", { className: 'text-sm font-semibold text-ink', children: table_config_1.PsTableConfig.noMatchesTitle }), (0, jsx_runtime_1.jsx)("p", { className: 'mt-1 text-sm text-ink/55', children: table_config_1.PsTableConfig.noMatchesDescription }), (0, jsx_runtime_1.jsx)("button", { className: 'ps-focus-ring mt-3 rounded-full px-3 py-1.5 text-sm font-semibold text-primary hover:underline', onClick: clearFilters, type: 'button', children: "Clear search and filters" })] }) })) : null, grouping
206
262
  ? groups.map((group, groupIndex) => {
207
263
  const isExpanded = isGroupExpanded(group.key, groupIndex);
208
264
  return ((0, jsx_runtime_1.jsxs)(react_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("tr", { children: (0, jsx_runtime_1.jsx)("td", { className: 'p-0', colSpan: columnCount, children: (0, jsx_runtime_1.jsx)(ps_table_group_header_1.PsTableGroupHeader, { count: group.rows.length, isExpanded: isExpanded, label: grouping.getGroupLabel(group.key, group.rows), onToggle: () => toggleGroup(group.key, isExpanded), summary: grouping.renderGroupSummary
@@ -211,8 +267,14 @@ const PsDataTable = ({ caption, className, columns, defaultSort, density = 'norm
211
267
  ? group.rows.map((row, rowIndex) => renderRow(row, groupRowMotion(group.key, rowIndex)))
212
268
  : null] }, `group-${group.key}`));
213
269
  })
214
- : visibleRows.map((row) => renderRow(row))] }), hasFooter ? ((0, jsx_runtime_1.jsx)("tfoot", { className: 'border-t-2 border-line bg-mist/50 text-sm font-semibold text-ink', children: (0, jsx_runtime_1.jsxs)("tr", { children: [isSelectable ? (0, jsx_runtime_1.jsx)("td", {}) : null, visibleColumns.map((column) => ((0, jsx_runtime_1.jsx)("td", { className: ps_design_system_1.PallasoftClassNameService.merge(densityClassNames[density].cell, alignClassNames[column.align !== null && column.align !== undefined
270
+ : shouldVirtualize
271
+ ? [
272
+ (0, jsx_runtime_1.jsx)("tr", { "aria-hidden": 'true', children: (0, jsx_runtime_1.jsx)("td", { className: 'p-0', colSpan: columnCount, style: { height: virtualTop } }) }, 'virtual-top'),
273
+ ...virtualItems.map((item) => renderRow(visibleRows[item.index], undefined, item)),
274
+ (0, jsx_runtime_1.jsx)("tr", { "aria-hidden": 'true', children: (0, jsx_runtime_1.jsx)("td", { className: 'p-0', colSpan: columnCount, style: { height: virtualBottom } }) }, 'virtual-bottom'),
275
+ ]
276
+ : visibleRows.map((row) => renderRow(row))] }), hasFooter ? ((0, jsx_runtime_1.jsx)("tfoot", { className: 'border-t-2 border-line bg-mist/50 text-sm font-semibold text-ink', children: (0, jsx_runtime_1.jsxs)("tr", { children: [isSelectable ? (0, jsx_runtime_1.jsx)("td", {}) : null, visibleColumns.map((column) => ((0, jsx_runtime_1.jsx)("td", { className: ps_design_system_1.PallasoftClassNameService.merge(densityClassNames[density].cell, alignClassNames[column.align !== null && column.align !== undefined
215
277
  ? column.align
216
- : 'left'], 'whitespace-nowrap'), children: column.footer }, column.id)))] }) })) : null] }) }), pageSize && (!grouping || isServerPaginated) ? ((0, jsx_runtime_1.jsx)(ps_table_pagination_1.PsTablePagination, { onPageChange: changePage, page: safePage, pageCount: pageCount, pageSize: pageSize, totalRows: rowCount })) : null] })] }));
278
+ : 'left'], 'whitespace-nowrap'), children: column.footer }, column.id)))] }) })) : null] }) }), pageSize && (!grouping || isServerPaginated) ? ((0, jsx_runtime_1.jsx)(ps_table_pagination_1.PsTablePagination, { loadedPageCount: pageCountOf(loadedRowCount), onPageChange: changePage, page: safePage, pageCount: pageCount, pageSize: pageSize, totalRows: rowCount })) : null] })] }));
217
279
  };
218
280
  exports.PsDataTable = PsDataTable;
@@ -1,9 +1,10 @@
1
1
  export interface PsTablePaginationProps {
2
2
  className?: string;
3
+ loadedPageCount?: number;
3
4
  onPageChange: (page: number) => void;
4
5
  page: number;
5
6
  pageCount: number;
6
7
  pageSize: number;
7
8
  totalRows: number;
8
9
  }
9
- export declare const PsTablePagination: ({ className, onPageChange, page, pageCount, pageSize, totalRows, }: PsTablePaginationProps) => import("react").JSX.Element;
10
+ export declare const PsTablePagination: ({ className, loadedPageCount, onPageChange, page, pageCount, pageSize, totalRows, }: PsTablePaginationProps) => import("react").JSX.Element;
@@ -3,10 +3,11 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.PsTablePagination = void 0;
4
4
  const jsx_runtime_1 = require("react/jsx-runtime");
5
5
  const ps_design_system_1 = require("@pallasoft/ps-design-system");
6
- const PsTablePagination = ({ className, onPageChange, page, pageCount, pageSize, totalRows, }) => {
6
+ const PsTablePagination = ({ className, loadedPageCount, onPageChange, page, pageCount, pageSize, totalRows, }) => {
7
7
  const firstRow = totalRows === 0 ? 0 : (page - 1) * pageSize + 1;
8
8
  const lastRow = Math.min(page * pageSize, totalRows);
9
9
  const buttonClassName = 'ps-focus-ring inline-flex min-h-9 items-center rounded-full border border-line px-3.5 text-sm font-semibold text-ink transition-colors disabled:cursor-not-allowed disabled:opacity-40 enabled:hover:border-primary/40';
10
- return ((0, jsx_runtime_1.jsxs)("nav", { "aria-label": 'Pagination', className: ps_design_system_1.PallasoftClassNameService.merge('flex flex-wrap items-center justify-between gap-3 border-t border-line px-4 py-3', className), children: [(0, jsx_runtime_1.jsx)("p", { className: 'text-sm text-ink/55', children: totalRows === 0 ? 'No rows' : `${firstRow}–${lastRow} of ${totalRows}` }), (0, jsx_runtime_1.jsxs)("div", { className: 'flex items-center gap-2', children: [(0, jsx_runtime_1.jsx)("button", { className: buttonClassName, disabled: page <= 1, onClick: () => onPageChange(page - 1), type: 'button', children: "Previous" }), (0, jsx_runtime_1.jsxs)("span", { className: 'hidden text-sm text-ink/65 sm:inline', children: ["Page ", Math.min(page, pageCount), " of ", pageCount] }), (0, jsx_runtime_1.jsx)("button", { className: buttonClassName, disabled: page >= pageCount, onClick: () => onPageChange(page + 1), type: 'button', children: "Next" })] })] }));
10
+ return ((0, jsx_runtime_1.jsxs)("nav", { "aria-label": 'Pagination', className: ps_design_system_1.PallasoftClassNameService.merge('flex flex-wrap items-center justify-between gap-3 border-t border-line px-4 py-3', className), children: [(0, jsx_runtime_1.jsx)("p", { className: 'text-sm text-ink/55', children: totalRows === 0 ? 'No rows' : `${firstRow}–${lastRow} of ${totalRows}` }), (0, jsx_runtime_1.jsxs)("div", { className: 'flex items-center gap-2', children: [(0, jsx_runtime_1.jsx)("button", { className: buttonClassName, disabled: page <= 1, onClick: () => onPageChange(page - 1), type: 'button', children: "Previous" }), (0, jsx_runtime_1.jsxs)("span", { className: 'hidden text-sm text-ink/65 sm:inline', children: ["Page ", Math.min(page, pageCount), " of ", pageCount] }), (0, jsx_runtime_1.jsx)("button", { className: buttonClassName, disabled: page >=
11
+ (loadedPageCount === undefined ? pageCount : Math.min(loadedPageCount, pageCount)), onClick: () => onPageChange(page + 1), type: 'button', children: "Next" })] })] }));
11
12
  };
12
13
  exports.PsTablePagination = PsTablePagination;
@@ -1,10 +1,19 @@
1
1
  export declare const PsTableConfig: {
2
+ readonly cardBatch: 60;
2
3
  readonly groupCloseMs: 160;
3
4
  readonly groupOpenMs: 280;
4
5
  readonly groupRowStaggerMs: 16;
5
6
  readonly groupStaggerLimit: 12;
6
7
  readonly noMatchesDescription: "Try a different search or clear the filters.";
7
8
  readonly noMatchesTitle: "Nothing matches";
9
+ readonly rowEstimatePx: {
10
+ readonly compact: 37;
11
+ readonly normal: 57;
12
+ };
8
13
  readonly searchPlaceholder: "Search";
9
14
  readonly skeletonRows: 6;
15
+ readonly streamConcurrency: 3;
16
+ readonly streamFlushMs: 200;
17
+ readonly virtualOverscan: 12;
18
+ readonly virtualizeAfter: 100;
10
19
  };
@@ -2,12 +2,18 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.PsTableConfig = void 0;
4
4
  exports.PsTableConfig = {
5
+ cardBatch: 60,
5
6
  groupCloseMs: 160,
6
7
  groupOpenMs: 280,
7
8
  groupRowStaggerMs: 16,
8
9
  groupStaggerLimit: 12,
9
10
  noMatchesDescription: 'Try a different search or clear the filters.',
10
11
  noMatchesTitle: 'Nothing matches',
12
+ rowEstimatePx: { compact: 37, normal: 57 },
11
13
  searchPlaceholder: 'Search',
12
14
  skeletonRows: 6,
15
+ streamConcurrency: 3,
16
+ streamFlushMs: 200,
17
+ virtualOverscan: 12,
18
+ virtualizeAfter: 100,
13
19
  };
@@ -1,14 +1,19 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { Fragment, useMemo, useRef, useState } from 'react';
2
+ import { Fragment, useDeferredValue, useEffect, useMemo, useRef, useState, } from 'react';
3
3
  import { PsSkeletonTable } from '@pallasoft/ps-core-components';
4
4
  import { PallasoftClassNameService } from '@pallasoft/ps-design-system';
5
5
  import { PsCheckbox } from '@pallasoft/ps-input-components';
6
6
  import { PsCollapse } from '@pallasoft/ps-motion-components';
7
+ import { useVirtualizer, useWindowVirtualizer } from '@tanstack/react-virtual';
7
8
  import { PsTableConfig } from '../constants/table-config';
9
+ import { usePsIncrementalCount } from '../hooks/use-ps-incremental-count';
10
+ import { usePsScrollAnchor } from '../hooks/use-ps-scroll-anchor';
11
+ import { usePsTableStreamStatus } from '../hooks/use-ps-table-stream-status';
8
12
  import { PsTableCellTooltipService } from '../services/ps-table-cell-tooltip-service';
9
13
  import { PsTableColumnSizeService } from '../services/ps-table-column-size-service';
10
14
  import { PsTableGroupService } from '../services/ps-table-group-service';
11
15
  import { PsTableQueryService } from '../services/ps-table-query-service';
16
+ import { PsTableScrollService } from '../services/ps-table-scroll-service';
12
17
  import { PsTableBulkBar } from './ps-table-bulk-bar';
13
18
  import { PsTableCardList } from './ps-table-card-list';
14
19
  import { PsTableColumnPicker } from './ps-table-column-picker';
@@ -61,15 +66,21 @@ export const PsDataTable = ({ caption, className, columns, defaultSort, density
61
66
  const hasToolbar = isSearchable || hasFilters || Boolean(toolbarActions);
62
67
  const isSelectable = Boolean(getBulkActions);
63
68
  const columnCount = visibleColumns.length + (isSelectable ? 1 : 0);
69
+ const deferredSearch = useDeferredValue(search);
64
70
  const queriedRows = useMemo(() => isServerPaginated
65
71
  ? rows
66
- : PsTableQueryService.apply(rows, columns, filters, search, getSearchText, sort), [columns, filters, getSearchText, isServerPaginated, rows, search, sort]);
67
- const currentPage = page !== null && page !== undefined ? page : uncontrolledPage;
68
- const rowCount = isServerPaginated ? totalRows : queriedRows.length;
69
- const pageCount = pageSize ? Math.max(Math.ceil(rowCount / pageSize), 1) : 1;
70
- const safePage = Math.min(Math.max(currentPage, 1), pageCount);
72
+ : PsTableQueryService.apply(rows, columns, filters, deferredSearch, getSearchText, sort), [columns, filters, rows, deferredSearch, sort, getSearchText, isServerPaginated]);
73
+ const streamStatus = usePsTableStreamStatus();
71
74
  const isQueried = search.trim() !== '' ||
72
75
  Object.values(filters).some((value) => PsTableQueryService.isActive(value));
76
+ const currentPage = page !== null && page !== undefined ? page : uncontrolledPage;
77
+ const loadedRowCount = isServerPaginated ? totalRows : queriedRows.length;
78
+ const rowCount = streamStatus !== null && !isServerPaginated && !isQueried
79
+ ? Math.max(streamStatus.total, loadedRowCount)
80
+ : loadedRowCount;
81
+ const pageCountOf = (count) => (pageSize ? Math.max(Math.ceil(count / pageSize), 1) : 1);
82
+ const pageCount = pageCountOf(rowCount);
83
+ const safePage = Math.min(Math.max(currentPage, 1), pageCountOf(loadedRowCount));
73
84
  const groups = useMemo(() => (grouping ? PsTableGroupService.group(queriedRows, grouping.getGroupKey) : []), [grouping, queriedRows]);
74
85
  const initialExpandedCount = grouping && grouping.initialExpandedCount !== undefined ? grouping.initialExpandedCount : 1;
75
86
  const isGroupExpanded = (groupKey, groupIndex) => isQueried || groupIndex < initialExpandedCount !== toggledGroupKeys.includes(groupKey);
@@ -146,19 +157,64 @@ export const PsDataTable = ({ caption, className, columns, defaultSort, density
146
157
  updateQuery({ filters: {}, search: '' });
147
158
  resetPage();
148
159
  };
149
- const selectedRows = queriedRows.filter((row) => selectedIds.includes(getRowId(row)));
160
+ const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]);
161
+ const selectedRows = selectedIds.length === 0 ? [] : queriedRows.filter((row) => selectedIdSet.has(getRowId(row)));
150
162
  const pageIds = visibleRows.map((row) => getRowId(row));
151
- const isPageSelected = pageIds.length > 0 && pageIds.every((rowId) => selectedIds.includes(rowId));
152
- const togglePage = () => setSelectedIds((current) => isPageSelected
153
- ? current.filter((rowId) => !pageIds.includes(rowId))
154
- : [...new Set([...current, ...pageIds])]);
163
+ const isPageSelected = pageIds.length > 0 && pageIds.every((rowId) => selectedIdSet.has(rowId));
164
+ const togglePage = () => setSelectedIds((current) => {
165
+ const pageIdSet = new Set(pageIds);
166
+ return isPageSelected
167
+ ? current.filter((rowId) => !pageIdSet.has(rowId))
168
+ : [...new Set([...current, ...pageIds])];
169
+ });
170
+ const bodyRef = useRef(null);
171
+ const [scrollParent, setScrollParent] = useState(null);
172
+ const [scrollMargin, setScrollMargin] = useState(0);
173
+ const shouldVirtualize = !grouping && visibleRows.length > PsTableConfig.virtualizeAfter;
174
+ const virtualOptions = {
175
+ count: shouldVirtualize ? visibleRows.length : 0,
176
+ estimateSize: () => PsTableConfig.rowEstimatePx[density],
177
+ getItemKey: (index) => getRowId(visibleRows[index]),
178
+ initialRect: {
179
+ height: PsTableConfig.virtualizeAfter * PsTableConfig.rowEstimatePx[density],
180
+ width: 0,
181
+ },
182
+ overscan: PsTableConfig.virtualOverscan,
183
+ scrollMargin,
184
+ };
185
+ const elementVirtualizer = useVirtualizer({
186
+ ...virtualOptions,
187
+ count: scrollParent === null ? 0 : virtualOptions.count,
188
+ getScrollElement: () => scrollParent,
189
+ });
190
+ const windowVirtualizer = useWindowVirtualizer({
191
+ ...virtualOptions,
192
+ count: scrollParent === null ? virtualOptions.count : 0,
193
+ });
194
+ const virtualizer = scrollParent === null ? windowVirtualizer : elementVirtualizer;
195
+ const virtualItems = shouldVirtualize ? virtualizer.getVirtualItems() : [];
196
+ const visibleRowIds = useMemo(() => (shouldVirtualize ? visibleRows.map((row) => getRowId(row)) : []), [getRowId, shouldVirtualize, visibleRows]);
197
+ usePsScrollAnchor(virtualizer, visibleRowIds, shouldVirtualize && streamStatus !== null);
198
+ const virtualTop = virtualItems.length > 0 ? virtualItems[0].start - scrollMargin : 0;
199
+ const virtualBottom = virtualItems.length > 0
200
+ ? virtualizer.getTotalSize() - (virtualItems[virtualItems.length - 1].end - scrollMargin)
201
+ : 0;
202
+ const cards = usePsIncrementalCount(visibleRows.length, PsTableConfig.cardBatch);
203
+ useEffect(() => {
204
+ const body = bodyRef.current;
205
+ if (!shouldVirtualize || body === null)
206
+ return;
207
+ const parent = PsTableScrollService.parent(body);
208
+ setScrollParent(parent);
209
+ setScrollMargin(PsTableScrollService.offset(body, parent));
210
+ }, [shouldVirtualize, visibleRows.length]);
155
211
  const toggleRow = (rowId) => setSelectedIds((current) => current.includes(rowId) ? current.filter((id) => id !== rowId) : [...current, rowId]);
156
212
  const renderCardList = (cardRows, emptyContent) => (_jsx(PsTableCardList, { columns: visibleColumns, emptyContent: emptyContent, expandedRowIds: expandedRowIds, footerColumns: hasFooter ? visibleColumns.filter((column) => column.footer !== undefined) : [], getRowId: getRowId, getRowLabel: (row) => (getRowLabel ? getRowLabel(row) : getRowId(row)), isSelectable: isSelectable, onRowClick: onRowClick, onRowHover: onRowHover, onToggleRow: toggleRow, renderExpandedRow: renderExpandedRow, rows: cardRows, selectedIds: selectedIds }));
157
- const renderRow = (row, motion) => {
213
+ const renderRow = (row, motion, virtualItem) => {
158
214
  const rowId = getRowId(row);
159
215
  const isExpanded = expandedRowIds ? expandedRowIds.includes(rowId) : false;
160
216
  const rowLabel = getRowLabel ? getRowLabel(row) : rowId;
161
- return (_jsxs(Fragment, { children: [_jsxs("tr", { "aria-expanded": renderExpandedRow ? isExpanded : undefined, "aria-label": onRowClick ? rowLabel : undefined, className: PallasoftClassNameService.merge('align-middle transition-colors', motion ? motion.className : '', onRowClick ? 'ps-focus-ring cursor-pointer hover:bg-mist/60' : 'hover:bg-mist/40', isExpanded ? 'bg-mist/40' : ''), onClick: onRowClick ? () => onRowClick(row) : undefined, onFocus: onRowHover ? () => onRowHover(row) : undefined, onPointerEnter: onRowHover ? () => onRowHover(row) : undefined, onKeyDown: onRowClick
217
+ return (_jsxs(Fragment, { children: [_jsxs("tr", { "data-index": virtualItem ? virtualItem.index : undefined, ref: virtualItem ? virtualizer.measureElement : undefined, "aria-expanded": renderExpandedRow ? isExpanded : undefined, "aria-label": onRowClick ? rowLabel : undefined, className: PallasoftClassNameService.merge('align-middle transition-colors', motion ? motion.className : '', onRowClick ? 'ps-focus-ring cursor-pointer hover:bg-mist/60' : 'hover:bg-mist/40', isExpanded ? 'bg-mist/40' : ''), onClick: onRowClick ? () => onRowClick(row) : undefined, onFocus: onRowHover ? () => onRowHover(row) : undefined, onPointerEnter: onRowHover ? () => onRowHover(row) : undefined, onKeyDown: onRowClick
162
218
  ? (event) => {
163
219
  if (event.target !== event.currentTarget)
164
220
  return;
@@ -169,7 +225,7 @@ export const PsDataTable = ({ caption, className, columns, defaultSort, density
169
225
  }
170
226
  : undefined, style: motion && motion.delayMs !== undefined
171
227
  ? { animationDelay: `${motion.delayMs}ms` }
172
- : undefined, tabIndex: onRowClick ? 0 : undefined, children: [isSelectable ? (_jsx("td", { className: 'w-10 pl-4 pr-0', onClick: (event) => event.stopPropagation(), onKeyDown: (event) => event.stopPropagation(), children: _jsx(PsCheckbox, { isSelected: selectedIds.includes(rowId), label: `Select ${rowLabel}`, onChange: () => toggleRow(rowId) }) })) : null, visibleColumns.map((column) => (_jsx("td", { className: PallasoftClassNameService.merge(densityClassNames[density].cell, alignClassNames[column.align !== null && column.align !== undefined ? column.align : 'left'], column.wrap ? '' : 'whitespace-nowrap', (layout === 'fixed' && !column.wrap) ||
228
+ : undefined, tabIndex: onRowClick ? 0 : undefined, children: [isSelectable ? (_jsx("td", { className: 'w-10 pl-4 pr-0', onClick: (event) => event.stopPropagation(), onKeyDown: (event) => event.stopPropagation(), children: _jsx(PsCheckbox, { isSelected: selectedIdSet.has(rowId), label: `Select ${rowLabel}`, onChange: () => toggleRow(rowId) }) })) : null, visibleColumns.map((column) => (_jsx("td", { className: PallasoftClassNameService.merge(densityClassNames[density].cell, alignClassNames[column.align !== null && column.align !== undefined ? column.align : 'left'], column.wrap ? '' : 'whitespace-nowrap', (layout === 'fixed' && !column.wrap) ||
173
229
  PsTableColumnSizeService.maxWidthOf(column) !== undefined
174
230
  ? 'truncate'
175
231
  : '', 'text-sm text-ink'), style: columnStyle(column), title: PsTableCellTooltipService.forCell(column, row), children: column.render(row) }, column.id)))] }), renderExpandedRow ? (_jsx("tr", { "aria-hidden": !isExpanded, className: isExpanded ? 'border-t-0' : 'border-0', children: _jsx("td", { className: 'p-0', colSpan: columnCount, children: _jsx(PsCollapse, { isOpen: isExpanded, children: _jsx("div", { className: 'w-0 min-w-full border-l-2 border-primary bg-mist/40 px-4 py-4', children: renderExpandedRow(row) }) }) }) })) : null] }, rowId));
@@ -184,14 +240,14 @@ export const PsDataTable = ({ caption, className, columns, defaultSort, density
184
240
  }, onSearchChange: (next) => {
185
241
  updateQuery({ search: next });
186
242
  resetPage();
187
- }, rows: rows, search: search, searchPlaceholder: searchPlaceholder })) : null, _jsxs("div", { className: 'min-w-0 overflow-hidden rounded-[1.25rem] border border-line bg-surface', children: [mobileLayout === 'cards' ? (_jsx("div", { className: 'sm:hidden', children: grouping
188
- ? groups.map((group, groupIndex) => {
189
- const isExpanded = isGroupExpanded(group.key, groupIndex);
190
- return (_jsxs("div", { className: 'border-b border-line last:border-b-0', children: [_jsx(PsTableGroupHeader, { count: group.rows.length, isExpanded: isExpanded, label: grouping.getGroupLabel(group.key, group.rows), onToggle: () => toggleGroup(group.key, isExpanded), summary: grouping.renderGroupSummary
191
- ? grouping.renderGroupSummary(group.rows)
192
- : undefined }), _jsx(PsCollapse, { isOpen: isExpanded && !closingGroupKeys.includes(group.key), children: renderCardList(group.rows, null) })] }, `group-${group.key}`));
193
- })
194
- : renderCardList(visibleRows, visibleRows.length === 0 && isQueried ? (_jsxs("div", { className: 'px-4 py-10 text-center', children: [_jsx("p", { className: 'text-sm font-semibold text-ink', children: PsTableConfig.noMatchesTitle }), _jsx("button", { className: 'ps-focus-ring mt-2 rounded-full px-3 py-1.5 text-sm font-semibold text-primary', onClick: clearFilters, type: 'button', children: "Clear search and filters" })] })) : null) })) : null, _jsx("div", { className: PallasoftClassNameService.merge('ps-scrollbar overflow-x-auto overflow-y-hidden', mobileLayout === 'cards' ? 'hidden sm:block' : ''), children: _jsxs("table", { className: PallasoftClassNameService.merge('w-full border-collapse text-left', layout === 'fixed' ? 'table-fixed' : ''), children: [caption ? _jsx("caption", { className: 'sr-only', children: caption }) : null, _jsx("thead", { className: 'bg-mist/70 text-[0.68rem] font-semibold uppercase tracking-[0.18em] text-ink/45', children: _jsxs("tr", { children: [isSelectable ? (_jsx("th", { className: 'w-10 pl-4 pr-0', scope: 'col', children: _jsx(PsCheckbox, { isIndeterminate: !isPageSelected && pageIds.some((rowId) => selectedIds.includes(rowId)), isSelected: isPageSelected, label: 'Select all rows on this page', onChange: togglePage }) })) : null, visibleColumns.map((column) => {
243
+ }, rows: rows, search: search, searchPlaceholder: searchPlaceholder })) : null, _jsxs("div", { className: 'min-w-0 overflow-hidden rounded-[1.25rem] border border-line bg-surface', children: [mobileLayout === 'cards' ? (_jsxs("div", { className: 'sm:hidden', children: [grouping
244
+ ? groups.map((group, groupIndex) => {
245
+ const isExpanded = isGroupExpanded(group.key, groupIndex);
246
+ return (_jsxs("div", { className: 'border-b border-line last:border-b-0', children: [_jsx(PsTableGroupHeader, { count: group.rows.length, isExpanded: isExpanded, label: grouping.getGroupLabel(group.key, group.rows), onToggle: () => toggleGroup(group.key, isExpanded), summary: grouping.renderGroupSummary
247
+ ? grouping.renderGroupSummary(group.rows)
248
+ : undefined }), _jsx(PsCollapse, { isOpen: isExpanded && !closingGroupKeys.includes(group.key), children: renderCardList(group.rows, null) })] }, `group-${group.key}`));
249
+ })
250
+ : renderCardList(shouldVirtualize ? visibleRows.slice(0, cards.count) : visibleRows, visibleRows.length === 0 && isQueried ? (_jsxs("div", { className: 'px-4 py-10 text-center', children: [_jsx("p", { className: 'text-sm font-semibold text-ink', children: PsTableConfig.noMatchesTitle }), _jsx("button", { className: 'ps-focus-ring mt-2 rounded-full px-3 py-1.5 text-sm font-semibold text-primary', onClick: clearFilters, type: 'button', children: "Clear search and filters" })] })) : null), !grouping && shouldVirtualize && cards.count < visibleRows.length ? (_jsx("div", { "aria-hidden": 'true', className: 'h-px', ref: cards.sentinelRef })) : null] })) : null, _jsx("div", { className: PallasoftClassNameService.merge('ps-scrollbar overflow-x-auto overflow-y-hidden', mobileLayout === 'cards' ? 'hidden sm:block' : ''), children: _jsxs("table", { className: PallasoftClassNameService.merge('w-full border-collapse text-left', layout === 'fixed' ? 'table-fixed' : ''), children: [caption ? _jsx("caption", { className: 'sr-only', children: caption }) : null, _jsx("thead", { className: 'bg-mist/70 text-[0.68rem] font-semibold uppercase tracking-[0.18em] text-ink/45', children: _jsxs("tr", { children: [isSelectable ? (_jsx("th", { className: 'w-10 pl-4 pr-0', scope: 'col', children: _jsx(PsCheckbox, { isIndeterminate: !isPageSelected && pageIds.some((rowId) => selectedIdSet.has(rowId)), isSelected: isPageSelected, label: 'Select all rows on this page', onChange: togglePage }) })) : null, visibleColumns.map((column) => {
195
251
  const align = column.align !== null && column.align !== undefined ? column.align : 'left';
196
252
  const isSorted = sort !== null && sort.columnId === column.id;
197
253
  return (_jsx("th", { "aria-sort": isSorted
@@ -199,7 +255,7 @@ export const PsDataTable = ({ caption, className, columns, defaultSort, density
199
255
  : column.sortValue && isQueryable
200
256
  ? 'none'
201
257
  : undefined, className: PallasoftClassNameService.merge(densityClassNames[density].head, alignClassNames[align], 'whitespace-nowrap'), scope: 'col', style: columnStyle(column), children: column.sortValue && isQueryable ? (_jsx(PsTableSortHeader, { align: align, columnId: column.id, label: column.label, onSort: updateSort, sort: sort })) : (column.label) }, column.id));
202
- })] }) }), _jsxs("tbody", { className: 'divide-y divide-line', children: [visibleRows.length === 0 && isQueried ? (_jsx("tr", { children: _jsxs("td", { className: 'px-4 py-12 text-center', colSpan: columnCount, children: [_jsx("p", { className: 'text-sm font-semibold text-ink', children: PsTableConfig.noMatchesTitle }), _jsx("p", { className: 'mt-1 text-sm text-ink/55', children: PsTableConfig.noMatchesDescription }), _jsx("button", { className: 'ps-focus-ring mt-3 rounded-full px-3 py-1.5 text-sm font-semibold text-primary hover:underline', onClick: clearFilters, type: 'button', children: "Clear search and filters" })] }) })) : null, grouping
258
+ })] }) }), _jsxs("tbody", { className: 'divide-y divide-line', ref: bodyRef, children: [visibleRows.length === 0 && isQueried ? (_jsx("tr", { children: _jsxs("td", { className: 'px-4 py-12 text-center', colSpan: columnCount, children: [_jsx("p", { className: 'text-sm font-semibold text-ink', children: PsTableConfig.noMatchesTitle }), _jsx("p", { className: 'mt-1 text-sm text-ink/55', children: PsTableConfig.noMatchesDescription }), _jsx("button", { className: 'ps-focus-ring mt-3 rounded-full px-3 py-1.5 text-sm font-semibold text-primary hover:underline', onClick: clearFilters, type: 'button', children: "Clear search and filters" })] }) })) : null, grouping
203
259
  ? groups.map((group, groupIndex) => {
204
260
  const isExpanded = isGroupExpanded(group.key, groupIndex);
205
261
  return (_jsxs(Fragment, { children: [_jsx("tr", { children: _jsx("td", { className: 'p-0', colSpan: columnCount, children: _jsx(PsTableGroupHeader, { count: group.rows.length, isExpanded: isExpanded, label: grouping.getGroupLabel(group.key, group.rows), onToggle: () => toggleGroup(group.key, isExpanded), summary: grouping.renderGroupSummary
@@ -208,7 +264,13 @@ export const PsDataTable = ({ caption, className, columns, defaultSort, density
208
264
  ? group.rows.map((row, rowIndex) => renderRow(row, groupRowMotion(group.key, rowIndex)))
209
265
  : null] }, `group-${group.key}`));
210
266
  })
211
- : visibleRows.map((row) => renderRow(row))] }), hasFooter ? (_jsx("tfoot", { className: 'border-t-2 border-line bg-mist/50 text-sm font-semibold text-ink', children: _jsxs("tr", { children: [isSelectable ? _jsx("td", {}) : null, visibleColumns.map((column) => (_jsx("td", { className: PallasoftClassNameService.merge(densityClassNames[density].cell, alignClassNames[column.align !== null && column.align !== undefined
267
+ : shouldVirtualize
268
+ ? [
269
+ _jsx("tr", { "aria-hidden": 'true', children: _jsx("td", { className: 'p-0', colSpan: columnCount, style: { height: virtualTop } }) }, 'virtual-top'),
270
+ ...virtualItems.map((item) => renderRow(visibleRows[item.index], undefined, item)),
271
+ _jsx("tr", { "aria-hidden": 'true', children: _jsx("td", { className: 'p-0', colSpan: columnCount, style: { height: virtualBottom } }) }, 'virtual-bottom'),
272
+ ]
273
+ : visibleRows.map((row) => renderRow(row))] }), hasFooter ? (_jsx("tfoot", { className: 'border-t-2 border-line bg-mist/50 text-sm font-semibold text-ink', children: _jsxs("tr", { children: [isSelectable ? _jsx("td", {}) : null, visibleColumns.map((column) => (_jsx("td", { className: PallasoftClassNameService.merge(densityClassNames[density].cell, alignClassNames[column.align !== null && column.align !== undefined
212
274
  ? column.align
213
- : 'left'], 'whitespace-nowrap'), children: column.footer }, column.id)))] }) })) : null] }) }), pageSize && (!grouping || isServerPaginated) ? (_jsx(PsTablePagination, { onPageChange: changePage, page: safePage, pageCount: pageCount, pageSize: pageSize, totalRows: rowCount })) : null] })] }));
275
+ : 'left'], 'whitespace-nowrap'), children: column.footer }, column.id)))] }) })) : null] }) }), pageSize && (!grouping || isServerPaginated) ? (_jsx(PsTablePagination, { loadedPageCount: pageCountOf(loadedRowCount), onPageChange: changePage, page: safePage, pageCount: pageCount, pageSize: pageSize, totalRows: rowCount })) : null] })] }));
214
276
  };
@@ -1,8 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { PallasoftClassNameService } from '@pallasoft/ps-design-system';
3
- export const PsTablePagination = ({ className, onPageChange, page, pageCount, pageSize, totalRows, }) => {
3
+ export const PsTablePagination = ({ className, loadedPageCount, onPageChange, page, pageCount, pageSize, totalRows, }) => {
4
4
  const firstRow = totalRows === 0 ? 0 : (page - 1) * pageSize + 1;
5
5
  const lastRow = Math.min(page * pageSize, totalRows);
6
6
  const buttonClassName = 'ps-focus-ring inline-flex min-h-9 items-center rounded-full border border-line px-3.5 text-sm font-semibold text-ink transition-colors disabled:cursor-not-allowed disabled:opacity-40 enabled:hover:border-primary/40';
7
- return (_jsxs("nav", { "aria-label": 'Pagination', className: PallasoftClassNameService.merge('flex flex-wrap items-center justify-between gap-3 border-t border-line px-4 py-3', className), children: [_jsx("p", { className: 'text-sm text-ink/55', children: totalRows === 0 ? 'No rows' : `${firstRow}–${lastRow} of ${totalRows}` }), _jsxs("div", { className: 'flex items-center gap-2', children: [_jsx("button", { className: buttonClassName, disabled: page <= 1, onClick: () => onPageChange(page - 1), type: 'button', children: "Previous" }), _jsxs("span", { className: 'hidden text-sm text-ink/65 sm:inline', children: ["Page ", Math.min(page, pageCount), " of ", pageCount] }), _jsx("button", { className: buttonClassName, disabled: page >= pageCount, onClick: () => onPageChange(page + 1), type: 'button', children: "Next" })] })] }));
7
+ return (_jsxs("nav", { "aria-label": 'Pagination', className: PallasoftClassNameService.merge('flex flex-wrap items-center justify-between gap-3 border-t border-line px-4 py-3', className), children: [_jsx("p", { className: 'text-sm text-ink/55', children: totalRows === 0 ? 'No rows' : `${firstRow}–${lastRow} of ${totalRows}` }), _jsxs("div", { className: 'flex items-center gap-2', children: [_jsx("button", { className: buttonClassName, disabled: page <= 1, onClick: () => onPageChange(page - 1), type: 'button', children: "Previous" }), _jsxs("span", { className: 'hidden text-sm text-ink/65 sm:inline', children: ["Page ", Math.min(page, pageCount), " of ", pageCount] }), _jsx("button", { className: buttonClassName, disabled: page >=
8
+ (loadedPageCount === undefined ? pageCount : Math.min(loadedPageCount, pageCount)), onClick: () => onPageChange(page + 1), type: 'button', children: "Next" })] })] }));
8
9
  };
@@ -1,10 +1,16 @@
1
1
  export const PsTableConfig = {
2
+ cardBatch: 60,
2
3
  groupCloseMs: 160,
3
4
  groupOpenMs: 280,
4
5
  groupRowStaggerMs: 16,
5
6
  groupStaggerLimit: 12,
6
7
  noMatchesDescription: 'Try a different search or clear the filters.',
7
8
  noMatchesTitle: 'Nothing matches',
9
+ rowEstimatePx: { compact: 37, normal: 57 },
8
10
  searchPlaceholder: 'Search',
9
11
  skeletonRows: 6,
12
+ streamConcurrency: 3,
13
+ streamFlushMs: 200,
14
+ virtualOverscan: 12,
15
+ virtualizeAfter: 100,
10
16
  };
@@ -0,0 +1,17 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ export const usePsIncrementalCount = (total, batch) => {
3
+ const [count, setCount] = useState(batch);
4
+ const sentinelRef = useRef(null);
5
+ useEffect(() => {
6
+ const sentinel = sentinelRef.current;
7
+ if (sentinel === null || count >= total)
8
+ return;
9
+ const observer = new IntersectionObserver((entries) => {
10
+ if (entries.some((entry) => entry.isIntersecting))
11
+ setCount((current) => Math.min(current + batch, total));
12
+ });
13
+ observer.observe(sentinel);
14
+ return () => observer.disconnect();
15
+ }, [batch, count, total]);
16
+ return { count: Math.min(count, total), sentinelRef };
17
+ };
@@ -0,0 +1,26 @@
1
+ import { useLayoutEffect, useRef } from 'react';
2
+ import { PsTableScrollAnchorService } from '../services/ps-table-scroll-anchor-service';
3
+ export const usePsScrollAnchor = (target, rowIds, isActive) => {
4
+ const anchor = useRef(null);
5
+ const previousRowIds = useRef(rowIds);
6
+ useLayoutEffect(() => {
7
+ const offset = target.scrollOffset;
8
+ if (!isActive || offset === null || offset <= 0) {
9
+ anchor.current = null;
10
+ previousRowIds.current = rowIds;
11
+ return;
12
+ }
13
+ const previous = anchor.current;
14
+ if (previous !== null && previousRowIds.current !== rowIds) {
15
+ const index = PsTableScrollAnchorService.movedTo(previous, previousRowIds.current, rowIds);
16
+ const start = index === null ? undefined : target.getOffsetForIndex(index, 'start');
17
+ previousRowIds.current = rowIds;
18
+ if (start !== undefined) {
19
+ target.scrollToOffset(PsTableScrollAnchorService.offset(start[0], previous));
20
+ return;
21
+ }
22
+ }
23
+ previousRowIds.current = rowIds;
24
+ anchor.current = PsTableScrollAnchorService.capture(target.getVirtualItems(), rowIds, offset);
25
+ });
26
+ };