@pallasoft/ps-table-components 1.18.1 → 1.19.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.
@@ -7,11 +7,14 @@ 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");
11
13
  const ps_table_cell_tooltip_service_1 = require("../services/ps-table-cell-tooltip-service");
12
14
  const ps_table_column_size_service_1 = require("../services/ps-table-column-size-service");
13
15
  const ps_table_group_service_1 = require("../services/ps-table-group-service");
14
16
  const ps_table_query_service_1 = require("../services/ps-table-query-service");
17
+ const ps_table_scroll_service_1 = require("../services/ps-table-scroll-service");
15
18
  const ps_table_bulk_bar_1 = require("./ps-table-bulk-bar");
16
19
  const ps_table_card_list_1 = require("./ps-table-card-list");
17
20
  const ps_table_column_picker_1 = require("./ps-table-column-picker");
@@ -64,9 +67,21 @@ const PsDataTable = ({ caption, className, columns, defaultSort, density = 'norm
64
67
  const hasToolbar = isSearchable || hasFilters || Boolean(toolbarActions);
65
68
  const isSelectable = Boolean(getBulkActions);
66
69
  const columnCount = visibleColumns.length + (isSelectable ? 1 : 0);
70
+ const deferredRows = (0, react_1.useDeferredValue)(rows);
71
+ const deferredFilters = (0, react_1.useDeferredValue)(filters);
72
+ const deferredSearch = (0, react_1.useDeferredValue)(search);
73
+ const deferredSort = (0, react_1.useDeferredValue)(sort);
67
74
  const queriedRows = (0, react_1.useMemo)(() => isServerPaginated
68
- ? rows
69
- : ps_table_query_service_1.PsTableQueryService.apply(rows, columns, filters, search, getSearchText, sort), [columns, filters, getSearchText, isServerPaginated, rows, search, sort]);
75
+ ? deferredRows
76
+ : ps_table_query_service_1.PsTableQueryService.apply(deferredRows, columns, deferredFilters, deferredSearch, getSearchText, deferredSort), [
77
+ columns,
78
+ deferredFilters,
79
+ deferredRows,
80
+ deferredSearch,
81
+ deferredSort,
82
+ getSearchText,
83
+ isServerPaginated,
84
+ ]);
70
85
  const currentPage = page !== null && page !== undefined ? page : uncontrolledPage;
71
86
  const rowCount = isServerPaginated ? totalRows : queriedRows.length;
72
87
  const pageCount = pageSize ? Math.max(Math.ceil(rowCount / pageSize), 1) : 1;
@@ -149,19 +164,62 @@ const PsDataTable = ({ caption, className, columns, defaultSort, density = 'norm
149
164
  updateQuery({ filters: {}, search: '' });
150
165
  resetPage();
151
166
  };
152
- const selectedRows = queriedRows.filter((row) => selectedIds.includes(getRowId(row)));
167
+ const selectedIdSet = (0, react_1.useMemo)(() => new Set(selectedIds), [selectedIds]);
168
+ const selectedRows = selectedIds.length === 0 ? [] : queriedRows.filter((row) => selectedIdSet.has(getRowId(row)));
153
169
  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])]);
170
+ const isPageSelected = pageIds.length > 0 && pageIds.every((rowId) => selectedIdSet.has(rowId));
171
+ const togglePage = () => setSelectedIds((current) => {
172
+ const pageIdSet = new Set(pageIds);
173
+ return isPageSelected
174
+ ? current.filter((rowId) => !pageIdSet.has(rowId))
175
+ : [...new Set([...current, ...pageIds])];
176
+ });
177
+ const bodyRef = (0, react_1.useRef)(null);
178
+ const [scrollParent, setScrollParent] = (0, react_1.useState)(null);
179
+ const [scrollMargin, setScrollMargin] = (0, react_1.useState)(0);
180
+ const shouldVirtualize = !grouping && visibleRows.length > table_config_1.PsTableConfig.virtualizeAfter;
181
+ const virtualOptions = {
182
+ count: shouldVirtualize ? visibleRows.length : 0,
183
+ estimateSize: () => table_config_1.PsTableConfig.rowEstimatePx[density],
184
+ getItemKey: (index) => getRowId(visibleRows[index]),
185
+ initialRect: {
186
+ height: table_config_1.PsTableConfig.virtualizeAfter * table_config_1.PsTableConfig.rowEstimatePx[density],
187
+ width: 0,
188
+ },
189
+ overscan: table_config_1.PsTableConfig.virtualOverscan,
190
+ scrollMargin,
191
+ };
192
+ const elementVirtualizer = (0, react_virtual_1.useVirtualizer)({
193
+ ...virtualOptions,
194
+ count: scrollParent === null ? 0 : virtualOptions.count,
195
+ getScrollElement: () => scrollParent,
196
+ });
197
+ const windowVirtualizer = (0, react_virtual_1.useWindowVirtualizer)({
198
+ ...virtualOptions,
199
+ count: scrollParent === null ? virtualOptions.count : 0,
200
+ });
201
+ const virtualizer = scrollParent === null ? windowVirtualizer : elementVirtualizer;
202
+ const virtualItems = shouldVirtualize ? virtualizer.getVirtualItems() : [];
203
+ const virtualTop = virtualItems.length > 0 ? virtualItems[0].start - scrollMargin : 0;
204
+ const virtualBottom = virtualItems.length > 0
205
+ ? virtualizer.getTotalSize() - (virtualItems[virtualItems.length - 1].end - scrollMargin)
206
+ : 0;
207
+ const cards = (0, use_ps_incremental_count_1.usePsIncrementalCount)(visibleRows.length, table_config_1.PsTableConfig.cardBatch);
208
+ (0, react_1.useEffect)(() => {
209
+ const body = bodyRef.current;
210
+ if (!shouldVirtualize || body === null)
211
+ return;
212
+ const parent = ps_table_scroll_service_1.PsTableScrollService.parent(body);
213
+ setScrollParent(parent);
214
+ setScrollMargin(ps_table_scroll_service_1.PsTableScrollService.offset(body, parent));
215
+ }, [shouldVirtualize, visibleRows.length]);
158
216
  const toggleRow = (rowId) => setSelectedIds((current) => current.includes(rowId) ? current.filter((id) => id !== rowId) : [...current, rowId]);
159
217
  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) => {
218
+ const renderRow = (row, motion, virtualItem) => {
161
219
  const rowId = getRowId(row);
162
220
  const isExpanded = expandedRowIds ? expandedRowIds.includes(rowId) : false;
163
221
  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
222
+ 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
223
  ? (event) => {
166
224
  if (event.target !== event.currentTarget)
167
225
  return;
@@ -172,7 +230,7 @@ const PsDataTable = ({ caption, className, columns, defaultSort, density = 'norm
172
230
  }
173
231
  : undefined, style: motion && motion.delayMs !== undefined
174
232
  ? { 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) ||
233
+ : 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
234
  ps_table_column_size_service_1.PsTableColumnSizeService.maxWidthOf(column) !== undefined
177
235
  ? 'truncate'
178
236
  : '', '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 +245,14 @@ const PsDataTable = ({ caption, className, columns, defaultSort, density = 'norm
187
245
  }, onSearchChange: (next) => {
188
246
  updateQuery({ search: next });
189
247
  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) => {
248
+ }, 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
249
+ ? groups.map((group, groupIndex) => {
250
+ const isExpanded = isGroupExpanded(group.key, groupIndex);
251
+ 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
252
+ ? grouping.renderGroupSummary(group.rows)
253
+ : 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}`));
254
+ })
255
+ : 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
256
  const align = column.align !== null && column.align !== undefined ? column.align : 'left';
199
257
  const isSorted = sort !== null && sort.columnId === column.id;
200
258
  return ((0, jsx_runtime_1.jsx)("th", { "aria-sort": isSorted
@@ -202,7 +260,7 @@ const PsDataTable = ({ caption, className, columns, defaultSort, density = 'norm
202
260
  : column.sortValue && isQueryable
203
261
  ? 'none'
204
262
  : 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
263
+ })] }) }), (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
264
  ? groups.map((group, groupIndex) => {
207
265
  const isExpanded = isGroupExpanded(group.key, groupIndex);
208
266
  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,7 +269,13 @@ const PsDataTable = ({ caption, className, columns, defaultSort, density = 'norm
211
269
  ? group.rows.map((row, rowIndex) => renderRow(row, groupRowMotion(group.key, rowIndex)))
212
270
  : null] }, `group-${group.key}`));
213
271
  })
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
272
+ : shouldVirtualize
273
+ ? [
274
+ (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'),
275
+ ...virtualItems.map((item) => renderRow(visibleRows[item.index], undefined, item)),
276
+ (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'),
277
+ ]
278
+ : 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
279
  ? column.align
216
280
  : '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] })] }));
217
281
  };
@@ -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,17 @@
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';
8
10
  import { PsTableCellTooltipService } from '../services/ps-table-cell-tooltip-service';
9
11
  import { PsTableColumnSizeService } from '../services/ps-table-column-size-service';
10
12
  import { PsTableGroupService } from '../services/ps-table-group-service';
11
13
  import { PsTableQueryService } from '../services/ps-table-query-service';
14
+ import { PsTableScrollService } from '../services/ps-table-scroll-service';
12
15
  import { PsTableBulkBar } from './ps-table-bulk-bar';
13
16
  import { PsTableCardList } from './ps-table-card-list';
14
17
  import { PsTableColumnPicker } from './ps-table-column-picker';
@@ -61,9 +64,21 @@ export const PsDataTable = ({ caption, className, columns, defaultSort, density
61
64
  const hasToolbar = isSearchable || hasFilters || Boolean(toolbarActions);
62
65
  const isSelectable = Boolean(getBulkActions);
63
66
  const columnCount = visibleColumns.length + (isSelectable ? 1 : 0);
67
+ const deferredRows = useDeferredValue(rows);
68
+ const deferredFilters = useDeferredValue(filters);
69
+ const deferredSearch = useDeferredValue(search);
70
+ const deferredSort = useDeferredValue(sort);
64
71
  const queriedRows = useMemo(() => isServerPaginated
65
- ? rows
66
- : PsTableQueryService.apply(rows, columns, filters, search, getSearchText, sort), [columns, filters, getSearchText, isServerPaginated, rows, search, sort]);
72
+ ? deferredRows
73
+ : PsTableQueryService.apply(deferredRows, columns, deferredFilters, deferredSearch, getSearchText, deferredSort), [
74
+ columns,
75
+ deferredFilters,
76
+ deferredRows,
77
+ deferredSearch,
78
+ deferredSort,
79
+ getSearchText,
80
+ isServerPaginated,
81
+ ]);
67
82
  const currentPage = page !== null && page !== undefined ? page : uncontrolledPage;
68
83
  const rowCount = isServerPaginated ? totalRows : queriedRows.length;
69
84
  const pageCount = pageSize ? Math.max(Math.ceil(rowCount / pageSize), 1) : 1;
@@ -146,19 +161,62 @@ export const PsDataTable = ({ caption, className, columns, defaultSort, density
146
161
  updateQuery({ filters: {}, search: '' });
147
162
  resetPage();
148
163
  };
149
- const selectedRows = queriedRows.filter((row) => selectedIds.includes(getRowId(row)));
164
+ const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]);
165
+ const selectedRows = selectedIds.length === 0 ? [] : queriedRows.filter((row) => selectedIdSet.has(getRowId(row)));
150
166
  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])]);
167
+ const isPageSelected = pageIds.length > 0 && pageIds.every((rowId) => selectedIdSet.has(rowId));
168
+ const togglePage = () => setSelectedIds((current) => {
169
+ const pageIdSet = new Set(pageIds);
170
+ return isPageSelected
171
+ ? current.filter((rowId) => !pageIdSet.has(rowId))
172
+ : [...new Set([...current, ...pageIds])];
173
+ });
174
+ const bodyRef = useRef(null);
175
+ const [scrollParent, setScrollParent] = useState(null);
176
+ const [scrollMargin, setScrollMargin] = useState(0);
177
+ const shouldVirtualize = !grouping && visibleRows.length > PsTableConfig.virtualizeAfter;
178
+ const virtualOptions = {
179
+ count: shouldVirtualize ? visibleRows.length : 0,
180
+ estimateSize: () => PsTableConfig.rowEstimatePx[density],
181
+ getItemKey: (index) => getRowId(visibleRows[index]),
182
+ initialRect: {
183
+ height: PsTableConfig.virtualizeAfter * PsTableConfig.rowEstimatePx[density],
184
+ width: 0,
185
+ },
186
+ overscan: PsTableConfig.virtualOverscan,
187
+ scrollMargin,
188
+ };
189
+ const elementVirtualizer = useVirtualizer({
190
+ ...virtualOptions,
191
+ count: scrollParent === null ? 0 : virtualOptions.count,
192
+ getScrollElement: () => scrollParent,
193
+ });
194
+ const windowVirtualizer = useWindowVirtualizer({
195
+ ...virtualOptions,
196
+ count: scrollParent === null ? virtualOptions.count : 0,
197
+ });
198
+ const virtualizer = scrollParent === null ? windowVirtualizer : elementVirtualizer;
199
+ const virtualItems = shouldVirtualize ? virtualizer.getVirtualItems() : [];
200
+ const virtualTop = virtualItems.length > 0 ? virtualItems[0].start - scrollMargin : 0;
201
+ const virtualBottom = virtualItems.length > 0
202
+ ? virtualizer.getTotalSize() - (virtualItems[virtualItems.length - 1].end - scrollMargin)
203
+ : 0;
204
+ const cards = usePsIncrementalCount(visibleRows.length, PsTableConfig.cardBatch);
205
+ useEffect(() => {
206
+ const body = bodyRef.current;
207
+ if (!shouldVirtualize || body === null)
208
+ return;
209
+ const parent = PsTableScrollService.parent(body);
210
+ setScrollParent(parent);
211
+ setScrollMargin(PsTableScrollService.offset(body, parent));
212
+ }, [shouldVirtualize, visibleRows.length]);
155
213
  const toggleRow = (rowId) => setSelectedIds((current) => current.includes(rowId) ? current.filter((id) => id !== rowId) : [...current, rowId]);
156
214
  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) => {
215
+ const renderRow = (row, motion, virtualItem) => {
158
216
  const rowId = getRowId(row);
159
217
  const isExpanded = expandedRowIds ? expandedRowIds.includes(rowId) : false;
160
218
  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
219
+ 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
220
  ? (event) => {
163
221
  if (event.target !== event.currentTarget)
164
222
  return;
@@ -169,7 +227,7 @@ export const PsDataTable = ({ caption, className, columns, defaultSort, density
169
227
  }
170
228
  : undefined, style: motion && motion.delayMs !== undefined
171
229
  ? { 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) ||
230
+ : 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
231
  PsTableColumnSizeService.maxWidthOf(column) !== undefined
174
232
  ? 'truncate'
175
233
  : '', '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 +242,14 @@ export const PsDataTable = ({ caption, className, columns, defaultSort, density
184
242
  }, onSearchChange: (next) => {
185
243
  updateQuery({ search: next });
186
244
  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) => {
245
+ }, 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
246
+ ? groups.map((group, groupIndex) => {
247
+ const isExpanded = isGroupExpanded(group.key, groupIndex);
248
+ 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
249
+ ? grouping.renderGroupSummary(group.rows)
250
+ : undefined }), _jsx(PsCollapse, { isOpen: isExpanded && !closingGroupKeys.includes(group.key), children: renderCardList(group.rows, null) })] }, `group-${group.key}`));
251
+ })
252
+ : 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
253
  const align = column.align !== null && column.align !== undefined ? column.align : 'left';
196
254
  const isSorted = sort !== null && sort.columnId === column.id;
197
255
  return (_jsx("th", { "aria-sort": isSorted
@@ -199,7 +257,7 @@ export const PsDataTable = ({ caption, className, columns, defaultSort, density
199
257
  : column.sortValue && isQueryable
200
258
  ? 'none'
201
259
  : 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
260
+ })] }) }), _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
261
  ? groups.map((group, groupIndex) => {
204
262
  const isExpanded = isGroupExpanded(group.key, groupIndex);
205
263
  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 +266,13 @@ export const PsDataTable = ({ caption, className, columns, defaultSort, density
208
266
  ? group.rows.map((row, rowIndex) => renderRow(row, groupRowMotion(group.key, rowIndex)))
209
267
  : null] }, `group-${group.key}`));
210
268
  })
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
269
+ : shouldVirtualize
270
+ ? [
271
+ _jsx("tr", { "aria-hidden": 'true', children: _jsx("td", { className: 'p-0', colSpan: columnCount, style: { height: virtualTop } }) }, 'virtual-top'),
272
+ ...virtualItems.map((item) => renderRow(visibleRows[item.index], undefined, item)),
273
+ _jsx("tr", { "aria-hidden": 'true', children: _jsx("td", { className: 'p-0', colSpan: columnCount, style: { height: virtualBottom } }) }, 'virtual-bottom'),
274
+ ]
275
+ : 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
276
  ? column.align
213
277
  : '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] })] }));
214
278
  };
@@ -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
+ };
@@ -1,41 +1,84 @@
1
- import { useEffect, useMemo, useRef, useState } from 'react';
1
+ import { startTransition, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { PsTableConfig } from '../constants/table-config';
2
3
  import { PsTableStreamService } from '../services/ps-table-stream-service';
3
4
  export const usePsStreamedRows = ({ first, getRowId, loadPage, pageSize, scope, total, }) => {
4
5
  const [stream, setStream] = useState({
5
6
  first,
6
7
  hasFailed: false,
8
+ isDone: first.length >= total,
7
9
  previous: [],
8
10
  rows: first,
9
11
  scope,
10
12
  });
11
13
  const rowId = useRef(getRowId);
12
14
  const load = useRef(loadPage);
13
- const requested = useRef(null);
14
15
  rowId.current = getRowId;
15
16
  load.current = loadPage;
16
17
  if (stream.first !== first)
17
18
  setStream({
18
19
  first,
19
20
  hasFailed: false,
21
+ isDone: first.length >= total,
20
22
  previous: PsTableStreamService.carried(stream.rows, stream.previous, getRowId, total, stream.scope === scope),
21
23
  rows: first,
22
24
  scope,
23
25
  });
24
- const nextPage = stream.hasFailed
25
- ? null
26
- : PsTableStreamService.nextPage(stream.rows.length, total, pageSize);
27
26
  useEffect(() => {
28
- if (nextPage === null || requested.current === stream.rows)
27
+ const pages = PsTableStreamService.pages(first.length, total, pageSize);
28
+ if (pages.length === 0)
29
29
  return;
30
- const base = stream.rows;
31
- requested.current = base;
32
- load.current(nextPage).then((page) => setStream((current) => current.rows !== base
33
- ? current
34
- : page.length === 0
35
- ? { ...current, hasFailed: true }
36
- : { ...current, rows: PsTableStreamService.append(current.rows, page, rowId.current) }), () => setStream((current) => (current.rows !== base ? current : { ...current, hasFailed: true })));
37
- }, [nextPage, stream.rows]);
38
- const isStreaming = nextPage !== null;
30
+ const seen = new Set(first.map((row) => rowId.current(row)));
31
+ const loaded = new Map();
32
+ let isCancelled = false;
33
+ let nextIndex = 0;
34
+ let nextToAppend = pages[0];
35
+ let pending = [];
36
+ let timer = null;
37
+ const commit = (patch) => {
38
+ const added = pending;
39
+ pending = [];
40
+ timer = null;
41
+ startTransition(() => setStream((current) => current.first !== first
42
+ ? current
43
+ : {
44
+ ...current,
45
+ ...patch,
46
+ rows: added.length === 0 ? current.rows : current.rows.concat(added),
47
+ }));
48
+ };
49
+ const work = async () => {
50
+ while (!isCancelled && nextIndex < pages.length) {
51
+ const page = pages[nextIndex];
52
+ nextIndex += 1;
53
+ loaded.set(page, await load.current(page));
54
+ if (isCancelled)
55
+ return;
56
+ for (let ready = loaded.get(nextToAppend); ready !== undefined;) {
57
+ pending = pending.concat(PsTableStreamService.unseen(ready, seen, rowId.current));
58
+ loaded.delete(nextToAppend);
59
+ nextToAppend += 1;
60
+ ready = loaded.get(nextToAppend);
61
+ }
62
+ if (timer === null)
63
+ timer = setTimeout(() => commit({ hasFailed: false, isDone: false }), PsTableConfig.streamFlushMs);
64
+ }
65
+ };
66
+ const finish = (hasFailed) => {
67
+ if (isCancelled)
68
+ return;
69
+ if (timer !== null)
70
+ clearTimeout(timer);
71
+ isCancelled = true;
72
+ commit({ hasFailed, isDone: !hasFailed });
73
+ };
74
+ void Promise.all(Array.from({ length: Math.min(PsTableConfig.streamConcurrency, pages.length) }, work)).then(() => finish(false), () => finish(true));
75
+ return () => {
76
+ isCancelled = true;
77
+ if (timer !== null)
78
+ clearTimeout(timer);
79
+ };
80
+ }, [first, pageSize, total]);
81
+ const isStreaming = !stream.hasFailed && !stream.isDone;
39
82
  const rows = useMemo(() => isStreaming
40
83
  ? PsTableStreamService.merge(stream.rows, stream.previous, rowId.current, total)
41
84
  : stream.rows, [isStreaming, stream.previous, stream.rows, total]);
package/dist/esm/index.js CHANGED
@@ -18,6 +18,7 @@ export * from './interfaces/table-sort';
18
18
  export * from './interfaces/table-stream-input';
19
19
  export * from './interfaces/table-stream-result';
20
20
  export * from './interfaces/table-stream-status';
21
+ export * from './hooks/use-ps-incremental-count';
21
22
  export * from './hooks/use-ps-streamed-rows';
22
23
  export * from './hooks/use-ps-table-stream-status';
23
24
  export * from './services/ps-table-cell-tooltip-service';
@@ -25,6 +26,7 @@ export * from './services/ps-table-column-size-service';
25
26
  export * from './services/ps-table-group-service';
26
27
  export * from './services/ps-table-query-service';
27
28
  export * from './services/ps-table-search-service';
29
+ export * from './services/ps-table-scroll-service';
28
30
  export * from './services/ps-table-stream-service';
29
31
  export * from './components/ps-table-export-button';
30
32
  export * from './constants/ps-table-download-config';
@@ -37,26 +37,37 @@ export class PsTableQueryService {
37
37
  return true;
38
38
  }
39
39
  static apply(rows, columns, filters, search, getSearchText, sort) {
40
- const filtered = rows.filter((row) => (!getSearchText || PsTableSearchService.matches(getSearchText(row), search)) &&
41
- columns.every((column) => PsTableQueryService.matchesFilter(row, column, filters[column.id] ? filters[column.id] : {})));
40
+ const tokens = getSearchText ? PsTableSearchService.tokens(search) : [];
41
+ const activeColumns = columns.filter((column) => column.filter && PsTableQueryService.isActive(filters[column.id]));
42
+ const filtered = tokens.length === 0 && activeColumns.length === 0
43
+ ? rows
44
+ : rows.filter((row) => (tokens.length === 0 ||
45
+ (getSearchText !== undefined &&
46
+ PsTableSearchService.matchesTokens(PsTableSearchService.haystack(row, getSearchText), tokens))) &&
47
+ activeColumns.every((column) => PsTableQueryService.matchesFilter(row, column, filters[column.id])));
42
48
  const sortColumn = sort ? columns.find((column) => column.id === sort.columnId) : undefined;
43
49
  if (!sort || !sortColumn || !sortColumn.sortValue)
44
50
  return filtered;
45
51
  const getValue = sortColumn.sortValue;
46
52
  const direction = sort.direction === 'ascending' ? 1 : -1;
47
- return [...filtered].sort((left, right) => {
48
- const a = getValue(left);
49
- const b = getValue(right);
50
- if (a === b)
51
- return 0;
52
- if (a === null)
53
+ return filtered
54
+ .map((row, index) => ({ index, row, value: getValue(row) }))
55
+ .sort((left, right) => {
56
+ if (left.value === null && right.value !== null)
53
57
  return 1;
54
- if (b === null)
58
+ if (right.value === null && left.value !== null)
55
59
  return -1;
56
- if (typeof a === 'number' && typeof b === 'number')
57
- return (a - b) * direction;
58
- return String(a).localeCompare(String(b), undefined, { numeric: true }) * direction;
59
- });
60
+ const order = PsTableQueryService.compare(left.value, right.value);
61
+ return order === 0 ? left.index - right.index : order * direction;
62
+ })
63
+ .map((entry) => entry.row);
64
+ }
65
+ static compare(a, b) {
66
+ if (a === b || a === null || b === null)
67
+ return 0;
68
+ if (typeof a === 'number' && typeof b === 'number')
69
+ return a - b;
70
+ return PsTableQueryService.collator.compare(String(a), String(b));
60
71
  }
61
72
  static options(rows, column) {
62
73
  if (!column.filter)
@@ -91,3 +102,4 @@ export class PsTableQueryService {
91
102
  return null;
92
103
  }
93
104
  }
105
+ PsTableQueryService.collator = new Intl.Collator(undefined, { numeric: true });
@@ -0,0 +1,18 @@
1
+ export class PsTableScrollService {
2
+ static parent(element) {
3
+ for (let node = element.parentElement; node !== null; node = node.parentElement) {
4
+ if (node === document.body || node === document.documentElement)
5
+ return null;
6
+ if (PsTableScrollService.scrollable.test(getComputedStyle(node).overflowY))
7
+ return node;
8
+ }
9
+ return null;
10
+ }
11
+ static offset(element, parent) {
12
+ const top = element.getBoundingClientRect().top;
13
+ return parent === null
14
+ ? top + window.scrollY
15
+ : top - parent.getBoundingClientRect().top + parent.scrollTop;
16
+ }
17
+ }
18
+ PsTableScrollService.scrollable = /(auto|scroll|overlay)/;
@@ -15,10 +15,20 @@ export class PsTableSearchService {
15
15
  return normalised === '' ? [] : normalised.split(' ');
16
16
  }
17
17
  static matches(text, query) {
18
- const tokens = PsTableSearchService.tokens(query);
19
- if (tokens.length === 0)
20
- return true;
21
- const haystack = PsTableSearchService.normalise(text);
18
+ return PsTableSearchService.matchesTokens(PsTableSearchService.normalise(text), PsTableSearchService.tokens(query));
19
+ }
20
+ static matchesTokens(haystack, tokens) {
22
21
  return tokens.every((token) => haystack.includes(token));
23
22
  }
23
+ static haystack(row, getSearchText) {
24
+ if (typeof row !== 'object' || row === null)
25
+ return PsTableSearchService.normalise(getSearchText(row));
26
+ const cached = PsTableSearchService.haystacks.get(row);
27
+ if (cached !== undefined)
28
+ return cached;
29
+ const haystack = PsTableSearchService.normalise(getSearchText(row));
30
+ PsTableSearchService.haystacks.set(row, haystack);
31
+ return haystack;
32
+ }
24
33
  }
34
+ PsTableSearchService.haystacks = new WeakMap();
@@ -2,7 +2,25 @@ export class PsTableStreamService {
2
2
  static nextPage(loaded, total, pageSize) {
3
3
  return loaded < total ? Math.floor(loaded / pageSize) + 1 : null;
4
4
  }
5
+ static pages(loaded, total, pageSize) {
6
+ const first = PsTableStreamService.nextPage(loaded, total, pageSize);
7
+ if (first === null)
8
+ return [];
9
+ const last = Math.ceil(total / pageSize);
10
+ return Array.from({ length: last - first + 1 }, (_, index) => first + index);
11
+ }
12
+ static unseen(page, seen, getRowId) {
13
+ return page.filter((row) => {
14
+ const rowId = getRowId(row);
15
+ if (seen.has(rowId))
16
+ return false;
17
+ seen.add(rowId);
18
+ return true;
19
+ });
20
+ }
5
21
  static merge(fresh, previous, getRowId, total) {
22
+ if (previous.length === 0)
23
+ return fresh.length > total ? fresh.slice(0, total) : fresh;
6
24
  const freshIds = new Set(fresh.map(getRowId));
7
25
  return [...fresh, ...previous.filter((row) => !freshIds.has(getRowId(row)))].slice(0, total);
8
26
  }
@@ -0,0 +1,5 @@
1
+ import { type RefObject } from 'react';
2
+ export declare const usePsIncrementalCount: (total: number, batch: number) => {
3
+ count: number;
4
+ sentinelRef: RefObject<HTMLDivElement>;
5
+ };
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.usePsIncrementalCount = void 0;
4
+ const react_1 = require("react");
5
+ const usePsIncrementalCount = (total, batch) => {
6
+ const [count, setCount] = (0, react_1.useState)(batch);
7
+ const sentinelRef = (0, react_1.useRef)(null);
8
+ (0, react_1.useEffect)(() => {
9
+ const sentinel = sentinelRef.current;
10
+ if (sentinel === null || count >= total)
11
+ return;
12
+ const observer = new IntersectionObserver((entries) => {
13
+ if (entries.some((entry) => entry.isIntersecting))
14
+ setCount((current) => Math.min(current + batch, total));
15
+ });
16
+ observer.observe(sentinel);
17
+ return () => observer.disconnect();
18
+ }, [batch, count, total]);
19
+ return { count: Math.min(count, total), sentinelRef };
20
+ };
21
+ exports.usePsIncrementalCount = usePsIncrementalCount;
@@ -2,43 +2,86 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.usePsStreamedRows = void 0;
4
4
  const react_1 = require("react");
5
+ const table_config_1 = require("../constants/table-config");
5
6
  const ps_table_stream_service_1 = require("../services/ps-table-stream-service");
6
7
  const usePsStreamedRows = ({ first, getRowId, loadPage, pageSize, scope, total, }) => {
7
8
  const [stream, setStream] = (0, react_1.useState)({
8
9
  first,
9
10
  hasFailed: false,
11
+ isDone: first.length >= total,
10
12
  previous: [],
11
13
  rows: first,
12
14
  scope,
13
15
  });
14
16
  const rowId = (0, react_1.useRef)(getRowId);
15
17
  const load = (0, react_1.useRef)(loadPage);
16
- const requested = (0, react_1.useRef)(null);
17
18
  rowId.current = getRowId;
18
19
  load.current = loadPage;
19
20
  if (stream.first !== first)
20
21
  setStream({
21
22
  first,
22
23
  hasFailed: false,
24
+ isDone: first.length >= total,
23
25
  previous: ps_table_stream_service_1.PsTableStreamService.carried(stream.rows, stream.previous, getRowId, total, stream.scope === scope),
24
26
  rows: first,
25
27
  scope,
26
28
  });
27
- const nextPage = stream.hasFailed
28
- ? null
29
- : ps_table_stream_service_1.PsTableStreamService.nextPage(stream.rows.length, total, pageSize);
30
29
  (0, react_1.useEffect)(() => {
31
- if (nextPage === null || requested.current === stream.rows)
30
+ const pages = ps_table_stream_service_1.PsTableStreamService.pages(first.length, total, pageSize);
31
+ if (pages.length === 0)
32
32
  return;
33
- const base = stream.rows;
34
- requested.current = base;
35
- load.current(nextPage).then((page) => setStream((current) => current.rows !== base
36
- ? current
37
- : page.length === 0
38
- ? { ...current, hasFailed: true }
39
- : { ...current, rows: ps_table_stream_service_1.PsTableStreamService.append(current.rows, page, rowId.current) }), () => setStream((current) => (current.rows !== base ? current : { ...current, hasFailed: true })));
40
- }, [nextPage, stream.rows]);
41
- const isStreaming = nextPage !== null;
33
+ const seen = new Set(first.map((row) => rowId.current(row)));
34
+ const loaded = new Map();
35
+ let isCancelled = false;
36
+ let nextIndex = 0;
37
+ let nextToAppend = pages[0];
38
+ let pending = [];
39
+ let timer = null;
40
+ const commit = (patch) => {
41
+ const added = pending;
42
+ pending = [];
43
+ timer = null;
44
+ (0, react_1.startTransition)(() => setStream((current) => current.first !== first
45
+ ? current
46
+ : {
47
+ ...current,
48
+ ...patch,
49
+ rows: added.length === 0 ? current.rows : current.rows.concat(added),
50
+ }));
51
+ };
52
+ const work = async () => {
53
+ while (!isCancelled && nextIndex < pages.length) {
54
+ const page = pages[nextIndex];
55
+ nextIndex += 1;
56
+ loaded.set(page, await load.current(page));
57
+ if (isCancelled)
58
+ return;
59
+ for (let ready = loaded.get(nextToAppend); ready !== undefined;) {
60
+ pending = pending.concat(ps_table_stream_service_1.PsTableStreamService.unseen(ready, seen, rowId.current));
61
+ loaded.delete(nextToAppend);
62
+ nextToAppend += 1;
63
+ ready = loaded.get(nextToAppend);
64
+ }
65
+ if (timer === null)
66
+ timer = setTimeout(() => commit({ hasFailed: false, isDone: false }), table_config_1.PsTableConfig.streamFlushMs);
67
+ }
68
+ };
69
+ const finish = (hasFailed) => {
70
+ if (isCancelled)
71
+ return;
72
+ if (timer !== null)
73
+ clearTimeout(timer);
74
+ isCancelled = true;
75
+ commit({ hasFailed, isDone: !hasFailed });
76
+ };
77
+ void Promise.all(Array.from({ length: Math.min(table_config_1.PsTableConfig.streamConcurrency, pages.length) }, work)).then(() => finish(false), () => finish(true));
78
+ return () => {
79
+ isCancelled = true;
80
+ if (timer !== null)
81
+ clearTimeout(timer);
82
+ };
83
+ }, [first, pageSize, total]);
84
+ const isStreaming = !stream.hasFailed && !stream.isDone;
42
85
  const rows = (0, react_1.useMemo)(() => isStreaming
43
86
  ? ps_table_stream_service_1.PsTableStreamService.merge(stream.rows, stream.previous, rowId.current, total)
44
87
  : stream.rows, [isStreaming, stream.previous, stream.rows, total]);
package/dist/index.d.ts CHANGED
@@ -18,6 +18,7 @@ export * from './interfaces/table-sort';
18
18
  export * from './interfaces/table-stream-input';
19
19
  export * from './interfaces/table-stream-result';
20
20
  export * from './interfaces/table-stream-status';
21
+ export * from './hooks/use-ps-incremental-count';
21
22
  export * from './hooks/use-ps-streamed-rows';
22
23
  export * from './hooks/use-ps-table-stream-status';
23
24
  export * from './services/ps-table-cell-tooltip-service';
@@ -25,6 +26,7 @@ export * from './services/ps-table-column-size-service';
25
26
  export * from './services/ps-table-group-service';
26
27
  export * from './services/ps-table-query-service';
27
28
  export * from './services/ps-table-search-service';
29
+ export * from './services/ps-table-scroll-service';
28
30
  export * from './services/ps-table-stream-service';
29
31
  export * from './components/ps-table-export-button';
30
32
  export * from './constants/ps-table-download-config';
package/dist/index.js CHANGED
@@ -34,6 +34,7 @@ __exportStar(require("./interfaces/table-sort"), exports);
34
34
  __exportStar(require("./interfaces/table-stream-input"), exports);
35
35
  __exportStar(require("./interfaces/table-stream-result"), exports);
36
36
  __exportStar(require("./interfaces/table-stream-status"), exports);
37
+ __exportStar(require("./hooks/use-ps-incremental-count"), exports);
37
38
  __exportStar(require("./hooks/use-ps-streamed-rows"), exports);
38
39
  __exportStar(require("./hooks/use-ps-table-stream-status"), exports);
39
40
  __exportStar(require("./services/ps-table-cell-tooltip-service"), exports);
@@ -41,6 +42,7 @@ __exportStar(require("./services/ps-table-column-size-service"), exports);
41
42
  __exportStar(require("./services/ps-table-group-service"), exports);
42
43
  __exportStar(require("./services/ps-table-query-service"), exports);
43
44
  __exportStar(require("./services/ps-table-search-service"), exports);
45
+ __exportStar(require("./services/ps-table-scroll-service"), exports);
44
46
  __exportStar(require("./services/ps-table-stream-service"), exports);
45
47
  __exportStar(require("./components/ps-table-export-button"), exports);
46
48
  __exportStar(require("./constants/ps-table-download-config"), exports);
@@ -1,6 +1,7 @@
1
1
  export interface PsTableStreamState<RowData> {
2
2
  first: RowData[];
3
3
  hasFailed: boolean;
4
+ isDone: boolean;
4
5
  previous: RowData[];
5
6
  rows: RowData[];
6
7
  scope: string | undefined;
@@ -6,6 +6,8 @@ export declare class PsTableQueryService {
6
6
  static isActive(value: PsTableFilterValue | undefined): boolean;
7
7
  static matchesFilter<RowData>(row: RowData, column: PsTableColumn<RowData>, value: PsTableFilterValue): boolean;
8
8
  static apply<RowData>(rows: RowData[], columns: PsTableColumn<RowData>[], filters: Record<string, PsTableFilterValue>, search: string, getSearchText: ((row: RowData) => string) | undefined, sort: PsTableSort | null): RowData[];
9
+ private static compare;
10
+ private static readonly collator;
9
11
  static options<RowData>(rows: RowData[], column: PsTableColumn<RowData>): import("..").PsTableFilterOption[];
10
12
  static empty(sort: PsTableSort | null): PsTableQuery;
11
13
  static isEqual(left: PsTableQuery, right: PsTableQuery): boolean;
@@ -40,26 +40,37 @@ class PsTableQueryService {
40
40
  return true;
41
41
  }
42
42
  static apply(rows, columns, filters, search, getSearchText, sort) {
43
- const filtered = rows.filter((row) => (!getSearchText || ps_table_search_service_1.PsTableSearchService.matches(getSearchText(row), search)) &&
44
- columns.every((column) => PsTableQueryService.matchesFilter(row, column, filters[column.id] ? filters[column.id] : {})));
43
+ const tokens = getSearchText ? ps_table_search_service_1.PsTableSearchService.tokens(search) : [];
44
+ const activeColumns = columns.filter((column) => column.filter && PsTableQueryService.isActive(filters[column.id]));
45
+ const filtered = tokens.length === 0 && activeColumns.length === 0
46
+ ? rows
47
+ : rows.filter((row) => (tokens.length === 0 ||
48
+ (getSearchText !== undefined &&
49
+ ps_table_search_service_1.PsTableSearchService.matchesTokens(ps_table_search_service_1.PsTableSearchService.haystack(row, getSearchText), tokens))) &&
50
+ activeColumns.every((column) => PsTableQueryService.matchesFilter(row, column, filters[column.id])));
45
51
  const sortColumn = sort ? columns.find((column) => column.id === sort.columnId) : undefined;
46
52
  if (!sort || !sortColumn || !sortColumn.sortValue)
47
53
  return filtered;
48
54
  const getValue = sortColumn.sortValue;
49
55
  const direction = sort.direction === 'ascending' ? 1 : -1;
50
- return [...filtered].sort((left, right) => {
51
- const a = getValue(left);
52
- const b = getValue(right);
53
- if (a === b)
54
- return 0;
55
- if (a === null)
56
+ return filtered
57
+ .map((row, index) => ({ index, row, value: getValue(row) }))
58
+ .sort((left, right) => {
59
+ if (left.value === null && right.value !== null)
56
60
  return 1;
57
- if (b === null)
61
+ if (right.value === null && left.value !== null)
58
62
  return -1;
59
- if (typeof a === 'number' && typeof b === 'number')
60
- return (a - b) * direction;
61
- return String(a).localeCompare(String(b), undefined, { numeric: true }) * direction;
62
- });
63
+ const order = PsTableQueryService.compare(left.value, right.value);
64
+ return order === 0 ? left.index - right.index : order * direction;
65
+ })
66
+ .map((entry) => entry.row);
67
+ }
68
+ static compare(a, b) {
69
+ if (a === b || a === null || b === null)
70
+ return 0;
71
+ if (typeof a === 'number' && typeof b === 'number')
72
+ return a - b;
73
+ return PsTableQueryService.collator.compare(String(a), String(b));
63
74
  }
64
75
  static options(rows, column) {
65
76
  if (!column.filter)
@@ -95,3 +106,4 @@ class PsTableQueryService {
95
106
  }
96
107
  }
97
108
  exports.PsTableQueryService = PsTableQueryService;
109
+ PsTableQueryService.collator = new Intl.Collator(undefined, { numeric: true });
@@ -0,0 +1,5 @@
1
+ export declare class PsTableScrollService {
2
+ private static readonly scrollable;
3
+ static parent(element: HTMLElement): HTMLElement | null;
4
+ static offset(element: HTMLElement, parent: HTMLElement | null): number;
5
+ }
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PsTableScrollService = void 0;
4
+ class PsTableScrollService {
5
+ static parent(element) {
6
+ for (let node = element.parentElement; node !== null; node = node.parentElement) {
7
+ if (node === document.body || node === document.documentElement)
8
+ return null;
9
+ if (PsTableScrollService.scrollable.test(getComputedStyle(node).overflowY))
10
+ return node;
11
+ }
12
+ return null;
13
+ }
14
+ static offset(element, parent) {
15
+ const top = element.getBoundingClientRect().top;
16
+ return parent === null
17
+ ? top + window.scrollY
18
+ : top - parent.getBoundingClientRect().top + parent.scrollTop;
19
+ }
20
+ }
21
+ exports.PsTableScrollService = PsTableScrollService;
22
+ PsTableScrollService.scrollable = /(auto|scroll|overlay)/;
@@ -1,5 +1,8 @@
1
1
  export declare class PsTableSearchService {
2
+ private static readonly haystacks;
2
3
  static normalise(value: string): string;
3
4
  static tokens(query: string): string[];
4
5
  static matches(text: string, query: string): boolean;
6
+ static matchesTokens(haystack: string, tokens: string[]): boolean;
7
+ static haystack<RowData>(row: RowData, getSearchText: (row: RowData) => string): string;
5
8
  }
@@ -18,11 +18,21 @@ class PsTableSearchService {
18
18
  return normalised === '' ? [] : normalised.split(' ');
19
19
  }
20
20
  static matches(text, query) {
21
- const tokens = PsTableSearchService.tokens(query);
22
- if (tokens.length === 0)
23
- return true;
24
- const haystack = PsTableSearchService.normalise(text);
21
+ return PsTableSearchService.matchesTokens(PsTableSearchService.normalise(text), PsTableSearchService.tokens(query));
22
+ }
23
+ static matchesTokens(haystack, tokens) {
25
24
  return tokens.every((token) => haystack.includes(token));
26
25
  }
26
+ static haystack(row, getSearchText) {
27
+ if (typeof row !== 'object' || row === null)
28
+ return PsTableSearchService.normalise(getSearchText(row));
29
+ const cached = PsTableSearchService.haystacks.get(row);
30
+ if (cached !== undefined)
31
+ return cached;
32
+ const haystack = PsTableSearchService.normalise(getSearchText(row));
33
+ PsTableSearchService.haystacks.set(row, haystack);
34
+ return haystack;
35
+ }
27
36
  }
28
37
  exports.PsTableSearchService = PsTableSearchService;
38
+ PsTableSearchService.haystacks = new WeakMap();
@@ -1,5 +1,7 @@
1
1
  export declare class PsTableStreamService {
2
2
  static nextPage(loaded: number, total: number, pageSize: number): number | null;
3
+ static pages(loaded: number, total: number, pageSize: number): number[];
4
+ static unseen<RowData>(page: RowData[], seen: Set<string>, getRowId: (row: RowData) => string): RowData[];
3
5
  static merge<RowData>(fresh: RowData[], previous: RowData[], getRowId: (row: RowData) => string, total: number): RowData[];
4
6
  static carried<RowData>(rows: RowData[], previous: RowData[], getRowId: (row: RowData) => string, total: number, isSameScope: boolean): RowData[];
5
7
  static append<RowData>(rows: RowData[], page: RowData[], getRowId: (row: RowData) => string): RowData[];
@@ -5,7 +5,25 @@ class PsTableStreamService {
5
5
  static nextPage(loaded, total, pageSize) {
6
6
  return loaded < total ? Math.floor(loaded / pageSize) + 1 : null;
7
7
  }
8
+ static pages(loaded, total, pageSize) {
9
+ const first = PsTableStreamService.nextPage(loaded, total, pageSize);
10
+ if (first === null)
11
+ return [];
12
+ const last = Math.ceil(total / pageSize);
13
+ return Array.from({ length: last - first + 1 }, (_, index) => first + index);
14
+ }
15
+ static unseen(page, seen, getRowId) {
16
+ return page.filter((row) => {
17
+ const rowId = getRowId(row);
18
+ if (seen.has(rowId))
19
+ return false;
20
+ seen.add(rowId);
21
+ return true;
22
+ });
23
+ }
8
24
  static merge(fresh, previous, getRowId, total) {
25
+ if (previous.length === 0)
26
+ return fresh.length > total ? fresh.slice(0, total) : fresh;
9
27
  const freshIds = new Set(fresh.map(getRowId));
10
28
  return [...fresh, ...previous.filter((row) => !freshIds.has(getRowId(row)))].slice(0, total);
11
29
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pallasoft/ps-table-components",
3
- "version": "1.18.1",
3
+ "version": "1.19.0",
4
4
  "private": false,
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/esm/index.js",
@@ -78,6 +78,7 @@
78
78
  "@pallasoft/ps-icons": "^1.5.4",
79
79
  "@pallasoft/ps-input-components": "^1.14.0",
80
80
  "@pallasoft/ps-motion-components": "^1.2.0",
81
+ "@tanstack/react-virtual": "^3.14.13",
81
82
  "react-aria-components": "^1.21.1"
82
83
  }
83
84
  }