@prairielearn/ui 1.3.0 → 1.4.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 (66) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +4 -2
  3. package/dist/components/CategoricalColumnFilter.d.ts +7 -12
  4. package/dist/components/CategoricalColumnFilter.d.ts.map +1 -1
  5. package/dist/components/CategoricalColumnFilter.js +15 -11
  6. package/dist/components/CategoricalColumnFilter.js.map +1 -1
  7. package/dist/components/ColumnManager.d.ts +6 -3
  8. package/dist/components/ColumnManager.d.ts.map +1 -1
  9. package/dist/components/ColumnManager.js +98 -18
  10. package/dist/components/ColumnManager.js.map +1 -1
  11. package/dist/components/MultiSelectColumnFilter.d.ts +8 -12
  12. package/dist/components/MultiSelectColumnFilter.d.ts.map +1 -1
  13. package/dist/components/MultiSelectColumnFilter.js +21 -13
  14. package/dist/components/MultiSelectColumnFilter.js.map +1 -1
  15. package/dist/components/NumericInputColumnFilter.d.ts +13 -13
  16. package/dist/components/NumericInputColumnFilter.d.ts.map +1 -1
  17. package/dist/components/NumericInputColumnFilter.js +44 -15
  18. package/dist/components/NumericInputColumnFilter.js.map +1 -1
  19. package/dist/components/NumericInputColumnFilter.test.d.ts +2 -0
  20. package/dist/components/NumericInputColumnFilter.test.d.ts.map +1 -0
  21. package/dist/components/NumericInputColumnFilter.test.js +90 -0
  22. package/dist/components/NumericInputColumnFilter.test.js.map +1 -0
  23. package/dist/components/OverlayTrigger.d.ts +78 -0
  24. package/dist/components/OverlayTrigger.d.ts.map +1 -0
  25. package/dist/components/OverlayTrigger.js +89 -0
  26. package/dist/components/OverlayTrigger.js.map +1 -0
  27. package/dist/components/TanstackTable.d.ts +15 -4
  28. package/dist/components/TanstackTable.d.ts.map +1 -1
  29. package/dist/components/TanstackTable.js +148 -197
  30. package/dist/components/TanstackTable.js.map +1 -1
  31. package/dist/components/TanstackTableDownloadButton.d.ts +4 -2
  32. package/dist/components/TanstackTableDownloadButton.d.ts.map +1 -1
  33. package/dist/components/TanstackTableDownloadButton.js +4 -3
  34. package/dist/components/TanstackTableDownloadButton.js.map +1 -1
  35. package/dist/components/TanstackTableHeaderCell.d.ts +13 -0
  36. package/dist/components/TanstackTableHeaderCell.d.ts.map +1 -0
  37. package/dist/components/TanstackTableHeaderCell.js +98 -0
  38. package/dist/components/TanstackTableHeaderCell.js.map +1 -0
  39. package/dist/components/{TanstackTable.css → styles.css} +11 -6
  40. package/dist/components/useAutoSizeColumns.d.ts +17 -0
  41. package/dist/components/useAutoSizeColumns.d.ts.map +1 -0
  42. package/dist/components/useAutoSizeColumns.js +99 -0
  43. package/dist/components/useAutoSizeColumns.js.map +1 -0
  44. package/dist/index.d.ts +3 -1
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +3 -0
  47. package/dist/index.js.map +1 -1
  48. package/dist/react-table.d.ts +13 -0
  49. package/dist/react-table.d.ts.map +1 -0
  50. package/dist/react-table.js +3 -0
  51. package/dist/react-table.js.map +1 -0
  52. package/package.json +2 -2
  53. package/src/components/CategoricalColumnFilter.tsx +28 -28
  54. package/src/components/ColumnManager.tsx +222 -46
  55. package/src/components/MultiSelectColumnFilter.tsx +45 -32
  56. package/src/components/NumericInputColumnFilter.test.ts +67 -19
  57. package/src/components/NumericInputColumnFilter.tsx +102 -42
  58. package/src/components/OverlayTrigger.tsx +168 -0
  59. package/src/components/TanstackTable.tsx +315 -363
  60. package/src/components/TanstackTableDownloadButton.tsx +8 -5
  61. package/src/components/TanstackTableHeaderCell.tsx +207 -0
  62. package/src/components/{TanstackTable.css → styles.css} +11 -6
  63. package/src/components/useAutoSizeColumns.tsx +168 -0
  64. package/src/index.ts +5 -0
  65. package/src/react-table.ts +17 -0
  66. package/tsconfig.json +1 -2
@@ -1 +1 @@
1
- {"version":3,"file":"TanstackTable.d.ts","sourceRoot":"","sources":["../../src/components/TanstackTable.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,MAAM,EAAsB,KAAK,EAAE,MAAM,sBAAsB,CAAC;AAE9E,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,QAAQ,CAAC;AAEhD,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,oBAAoB,CAAC;AAK9C,OAAO,EAEL,KAAK,gCAAgC,EACtC,MAAM,kCAAkC,CAAC;AA0F1C,UAAU,kBAAkB,CAAC,YAAY;IACvC,KAAK,EAAE,KAAK,CAAC,YAAY,CAAC,CAAC;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,KAAK,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC,YAAY,EAAE,OAAO,CAAC,CAAA;KAAE,KAAK,GAAG,CAAC,OAAO,CAAC,CAAC;IAC5F,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,cAAc,CAAC,EAAE,GAAG,CAAC,OAAO,CAAC;IAC7B,UAAU,CAAC,EAAE,GAAG,CAAC,OAAO,CAAC;CAC1B;AAID;;;;;;;;;GASG;AACH,wBAAgB,aAAa,CAAC,YAAY,EAAE,EAC1C,KAAK,EACL,KAAK,EACL,OAA4B,EAC5B,SAAc,EACd,cAAsC,EACtC,UAA8B,GAC/B,EAAE,kBAAkB,CAAC,YAAY,CAAC,eAuYlC;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,iBAAiB,CAAC,YAAY,EAAE,EAC9C,KAAK,EACL,KAAK,EACL,aAAa,EACb,oBAAoB,EACpB,YAAY,EACZ,YAAY,EACZ,qBAA4B,GAC7B,EAAE;IACD,KAAK,EAAE,KAAK,CAAC,YAAY,CAAC,CAAC;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,EAAE,GAAG,CAAC,OAAO,CAAC;IAC3B,oBAAoB,CAAC,EAAE,GAAG,CAAC,OAAO,CAAC;IACnC,YAAY,EAAE;QACZ,KAAK,EAAE,MAAM,CAAC;QACd,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;QAClC,WAAW,EAAE,MAAM,CAAC;KACrB,CAAC;IACF,YAAY,EAAE,OAAO,CAAC,IAAI,CAAC,kBAAkB,CAAC,YAAY,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC;IACvE,qBAAqB,CAAC,EAAE,IAAI,CAAC,gCAAgC,CAAC,YAAY,CAAC,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;CAC9F,eAmFA;AAED,wBAAgB,uBAAuB,CAAC,EACtC,QAAQ,EACR,QAAQ,GACT,EAAE;IACD,QAAQ,EAAE,MAAM,MAAM,EAAE,CAAC;IACzB,QAAQ,EAAE,iBAAiB,CAAC;CAC7B,eAOA"}
1
+ {"version":3,"file":"TanstackTable.d.ts","sourceRoot":"","sources":["../../src/components/TanstackTable.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAQ,MAAM,EAAO,KAAK,EAAE,MAAM,sBAAsB,CAAC;AAErE,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,QAAQ,CAAC;AAEhD,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,oBAAoB,CAAC;AAI9C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAI/D,OAAO,EAEL,KAAK,gCAAgC,EACtC,MAAM,kCAAkC,CAAC;AAoE1C,UAAU,kBAAkB,CAAC,YAAY;IACvC,KAAK,EAAE,KAAK,CAAC,YAAY,CAAC,CAAC;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,KAAK,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC,YAAY,EAAE,OAAO,CAAC,CAAA;KAAE,KAAK,GAAG,CAAC,OAAO,CAAC,CAAC;IAC5F,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,cAAc,CAAC,EAAE,GAAG,CAAC,OAAO,CAAC;IAC7B,UAAU,CAAC,EAAE,GAAG,CAAC,OAAO,CAAC;IACzB,SAAS,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC,cAAc,CAAC,GAAG,IAAI,CAAC;CACpD;AAID;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAAC,YAAY,EAAE,EAC1C,KAAK,EACL,KAAK,EACL,OAA4B,EAC5B,SAAc,EACd,cAAsC,EACtC,UAA8B,EAC9B,SAAS,GACV,EAAE,kBAAkB,CAAC,YAAY,CAAC,eA0VlC;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,iBAAiB,CAAC,YAAY,EAAE,EAC9C,KAAK,EACL,KAAK,EACL,aAAa,EACb,WAAW,EACX,aAAa,EACb,oBAAoB,EACpB,uBAAuB,EACvB,YAAY,EACZ,YAAY,EACZ,qBAAqB,EACrB,SAAS,EACT,GAAG,QAAQ,EACZ,EAAE;IACD,KAAK,EAAE,KAAK,CAAC,YAAY,CAAC,CAAC;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,EAAE,MAAM,CAAC;IACtB,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,EAAE,GAAG,CAAC,OAAO,CAAC;IAC3B,oBAAoB,CAAC,EAAE,GAAG,CAAC,OAAO,CAAC;IACnC,uBAAuB,CAAC,EAAE,GAAG,CAAC,OAAO,CAAC;IACtC,YAAY,EAAE;QACZ,KAAK,EAAE,MAAM,CAAC;QACd,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;QAClC,WAAW,EAAE,MAAM,CAAC;KACrB,CAAC;IACF,YAAY,EAAE,OAAO,CAAC,IAAI,CAAC,kBAAkB,CAAC,YAAY,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC;IACvE,qBAAqB,CAAC,EAAE,IAAI,CAC1B,gCAAgC,CAAC,YAAY,CAAC,EAC9C,OAAO,GAAG,eAAe,GAAG,aAAa,CAC1C,CAAC;CACH,GAAG,IAAI,CAAC,cAAc,CAAC,KAAK,CAAC,EAAE,OAAO,CAAC,eAgFvC;AAED,wBAAgB,uBAAuB,CAAC,EACtC,QAAQ,EACR,QAAQ,GACT,EAAE;IACD,QAAQ,EAAE,MAAM,MAAM,EAAE,CAAC;IACzB,QAAQ,EAAE,iBAAiB,CAAC;CAC7B,eAOA"}
@@ -1,57 +1,38 @@
1
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "@prairielearn/preact-cjs/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@prairielearn/preact-cjs/jsx-runtime";
2
2
  import { flexRender } from '@tanstack/react-table';
3
- import { notUndefined, useVirtualizer } from '@tanstack/react-virtual';
3
+ import { useVirtualizer } from '@tanstack/react-virtual';
4
4
  import clsx from 'clsx';
5
- import { useEffect, useRef, useState } from 'preact/hooks';
5
+ import { useEffect, useMemo, useRef } from 'preact/hooks';
6
6
  import OverlayTrigger from 'react-bootstrap/OverlayTrigger';
7
7
  import Tooltip from 'react-bootstrap/Tooltip';
8
+ import { run } from '@prairielearn/run';
8
9
  import { ColumnManager } from './ColumnManager.js';
9
10
  import { TanstackTableDownloadButton, } from './TanstackTableDownloadButton.js';
10
- function SortIcon({ sortMethod }) {
11
- if (sortMethod === 'asc') {
12
- return _jsx("i", { class: "bi bi-sort-up-alt", "aria-hidden": "true" });
13
- }
14
- else if (sortMethod === 'desc') {
15
- return _jsx("i", { class: "bi bi-sort-down", "aria-hidden": "true" });
16
- }
17
- else {
18
- return _jsx("i", { class: "bi bi-arrow-down-up opacity-75 text-muted", "aria-hidden": "true" });
19
- }
11
+ import { TanstackTableHeaderCell } from './TanstackTableHeaderCell.js';
12
+ import { useAutoSizeColumns } from './useAutoSizeColumns.js';
13
+ function TableCell({ cell, rowIdx, colIdx, canSort, canFilter, wrapText, handleGridKeyDown, }) {
14
+ return (_jsx("td", { tabIndex: 0, "data-grid-cell-row": rowIdx, "data-grid-cell-col": colIdx, class: clsx(!canSort && !canFilter && 'text-center'), style: {
15
+ display: 'flex',
16
+ width: cell.column.getSize(),
17
+ minWidth: 0,
18
+ maxWidth: cell.column.getSize(),
19
+ flexShrink: 0,
20
+ position: cell.column.getIsPinned() === 'left' ? 'sticky' : undefined,
21
+ left: cell.column.getIsPinned() === 'left' ? cell.column.getStart() : undefined,
22
+ verticalAlign: 'middle',
23
+ }, onKeyDown: (e) => handleGridKeyDown(e, rowIdx, colIdx), children: _jsx("div", { style: {
24
+ display: 'block',
25
+ minWidth: 0,
26
+ maxWidth: '100%',
27
+ overflow: wrapText ? 'visible' : 'hidden',
28
+ textOverflow: wrapText ? undefined : 'ellipsis',
29
+ whiteSpace: wrapText ? 'normal' : 'nowrap',
30
+ flex: '1 1 0%',
31
+ width: 0, // Allow flex to control width, but start from 0
32
+ }, children: flexRender(cell.column.columnDef.cell, cell.getContext()) }) }, cell.id));
20
33
  }
21
- function ResizeHandle({ header, setColumnSizing, }) {
22
- const minSize = header.column.columnDef.minSize ?? 0;
23
- const maxSize = header.column.columnDef.maxSize ?? 0;
24
- const handleKeyDown = (e) => {
25
- if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
26
- e.preventDefault();
27
- const currentSize = header.getSize();
28
- const increment = e.shiftKey ? 20 : 5; // Larger increment with Shift key
29
- const newSize = e.key === 'ArrowLeft'
30
- ? Math.max(minSize, currentSize - increment)
31
- : Math.min(maxSize, currentSize + increment);
32
- setColumnSizing((prevSizing) => ({
33
- ...prevSizing,
34
- [header.column.id]: newSize,
35
- }));
36
- }
37
- else if (e.key === 'Home') {
38
- e.preventDefault();
39
- header.column.resetSize();
40
- }
41
- };
42
- const columnName = typeof header.column.columnDef.header === 'string'
43
- ? header.column.columnDef.header
44
- : header.column.id;
45
- return (_jsx("div", { class: "py-1 h-100", style: { position: 'absolute', right: 0, top: 0, width: '4px' }, children: _jsx("div", { role: "separator", "aria-label": `Resize '${columnName}' column`, "aria-valuetext": `${header.getSize()}px`, "aria-orientation": "vertical", "aria-valuemin": minSize, "aria-valuemax": maxSize, "aria-valuenow": header.getSize(),
46
- // eslint-disable-next-line jsx-a11y-x/no-noninteractive-tabindex
47
- tabIndex: 0, class: "h-100", style: {
48
- background: header.column.getIsResizing() ? 'var(--bs-primary)' : 'var(--bs-gray-400)',
49
- cursor: 'col-resize',
50
- transition: 'background-color 0.2s',
51
- }, onMouseDown: header.getResizeHandler(), onTouchStart: header.getResizeHandler(), onKeyDown: handleKeyDown }) }));
52
- }
53
- const DefaultNoResultsState = (_jsxs(_Fragment, { children: [_jsx("i", { class: "bi bi-search display-4 mb-2", "aria-hidden": "true" }), _jsx("p", { class: "mb-0", children: "No results found matching your search criteria." })] }));
54
- const DefaultEmptyState = (_jsxs(_Fragment, { children: [_jsx("i", { class: "bi bi-eye-slash display-4 mb-2", "aria-hidden": "true" }), _jsx("p", { class: "mb-0", children: "No results found." })] }));
34
+ const DefaultNoResultsState = (_jsx(TanstackTableEmptyState, { iconName: "bi-search", children: "No results found matching your search criteria." }));
35
+ const DefaultEmptyState = (_jsx(TanstackTableEmptyState, { iconName: "bi-eye-slash", children: "No results found." }));
55
36
  const DEFAULT_FILTER_MAP = {};
56
37
  /**
57
38
  * A generic component that renders a full-width, resizeable Tanstack Table.
@@ -62,19 +43,52 @@ const DEFAULT_FILTER_MAP = {};
62
43
  * @param params.rowHeight - The height of the rows in the table
63
44
  * @param params.noResultsState - The no results state for the table
64
45
  * @param params.emptyState - The empty state for the table
46
+ * @param params.scrollRef - Optional ref that will be attached to the scroll container element.
65
47
  */
66
- export function TanstackTable({ table, title, filters = DEFAULT_FILTER_MAP, rowHeight = 42, noResultsState = DefaultNoResultsState, emptyState = DefaultEmptyState, }) {
48
+ export function TanstackTable({ table, title, filters = DEFAULT_FILTER_MAP, rowHeight = 42, noResultsState = DefaultNoResultsState, emptyState = DefaultEmptyState, scrollRef, }) {
67
49
  const parentRef = useRef(null);
68
50
  const tableRef = useRef(null);
51
+ const scrollContainerRef = scrollRef ?? parentRef;
69
52
  const rows = [...table.getTopRows(), ...table.getCenterRows()];
70
53
  const rowVirtualizer = useVirtualizer({
71
54
  count: rows.length,
72
- getScrollElement: () => parentRef.current,
55
+ getScrollElement: () => scrollContainerRef.current,
73
56
  estimateSize: () => rowHeight,
74
57
  overscan: 10,
58
+ measureElement: (el) => el?.getBoundingClientRect().height ?? rowHeight,
59
+ });
60
+ const visibleColumns = table.getVisibleLeafColumns();
61
+ const centerColumns = visibleColumns.filter((col) => !col.getIsPinned());
62
+ const columnVirtualizer = useVirtualizer({
63
+ count: centerColumns.length,
64
+ estimateSize: (index) => centerColumns[index]?.getSize(),
65
+ // `useAutoSizeColumns` solves a different problem (happens once when the column set changes)
66
+ // and we don't need to measure the cells themselves, so we can use the default estimateSize.
67
+ getScrollElement: () => scrollContainerRef.current,
68
+ horizontal: true,
69
+ overscan: 3,
70
+ });
71
+ const virtualColumns = columnVirtualizer.getVirtualItems();
72
+ const virtualPaddingLeft = run(() => {
73
+ if (columnVirtualizer && virtualColumns?.length > 0) {
74
+ return virtualColumns[0]?.start ?? 0;
75
+ }
76
+ return null;
75
77
  });
76
- // Track focused cell for grid navigation
77
- const [focusedCell, setFocusedCell] = useState({ row: 0, col: 0 });
78
+ const virtualPaddingRight = run(() => {
79
+ if (columnVirtualizer && virtualColumns?.length > 0) {
80
+ return (columnVirtualizer.getTotalSize() - (virtualColumns[virtualColumns.length - 1]?.end ?? 0));
81
+ }
82
+ return null;
83
+ });
84
+ // Check if any column has wrapping enabled
85
+ const hasWrappedColumns = table.getAllLeafColumns().some((col) => col.columnDef.meta?.wrapText);
86
+ // Create callback for remeasuring after resize
87
+ const handleResizeEnd = useMemo(() => {
88
+ if (!hasWrappedColumns)
89
+ return undefined;
90
+ return () => rowVirtualizer.measure();
91
+ }, [hasWrappedColumns, rowVirtualizer]);
78
92
  const getVisibleCells = (row) => [
79
93
  ...row.getLeftVisibleCells(),
80
94
  ...row.getCenterVisibleCells(),
@@ -103,95 +117,47 @@ export function TanstackTable({ table, title, filters = DEFAULT_FILTER_MAP, rowH
103
117
  if (!next) {
104
118
  return;
105
119
  }
106
- setFocusedCell({ row: next.row, col: next.col });
107
- // If we are on the leftmost column, we should allow left scrolling.
108
- if (colIdx === 0 && e.key === 'ArrowLeft') {
109
- return;
110
- }
111
- // If we are on the top row, we should allow up scrolling.
112
- if (rowIdx === 0 && e.key === 'ArrowUp') {
113
- return;
114
- }
115
- // If we are on the rightmost column, we should allow right scrolling.
116
- if (colIdx === rowLength - 1 && e.key === 'ArrowRight') {
117
- return;
120
+ // Only handle arrow keys if we're in the cell itself, not in an interactive element
121
+ const target = e.target;
122
+ if (target.tagName === 'TD') {
123
+ // If we are on the leftmost column, we should allow left scrolling.
124
+ if (colIdx === 0 && e.key === 'ArrowLeft') {
125
+ return;
126
+ }
127
+ // If we are on the top row, we should allow up scrolling.
128
+ if (rowIdx === 0 && e.key === 'ArrowUp') {
129
+ return;
130
+ }
131
+ // If we are on the rightmost column, we should allow right scrolling.
132
+ if (colIdx === rowLength - 1 && e.key === 'ArrowRight') {
133
+ return;
134
+ }
135
+ e.preventDefault();
136
+ const selector = `[data-grid-cell-row="${next.row}"][data-grid-cell-col="${next.col}"]`;
137
+ const nextCell = tableRef.current?.querySelector(selector);
138
+ nextCell?.focus();
118
139
  }
119
- e.preventDefault();
120
140
  };
121
- useEffect(() => {
122
- const selector = `[data-grid-cell-row="${focusedCell.row}"][data-grid-cell-col="${focusedCell.col}"]`;
123
- const cell = tableRef.current?.querySelector(selector);
124
- if (!cell)
125
- return;
126
- // eslint-disable-next-line react-you-might-not-need-an-effect/no-chain-state-updates
127
- cell.focus();
128
- }, [focusedCell]);
129
141
  const virtualRows = rowVirtualizer.getVirtualItems();
130
- const [before, after] = virtualRows.length > 0
131
- ? [
132
- notUndefined(virtualRows[0]).start - rowVirtualizer.options.scrollMargin,
133
- rowVirtualizer.getTotalSize() - notUndefined(virtualRows.at(-1)).end,
134
- ]
135
- : [0, 0];
136
142
  const headerGroups = table.getHeaderGroups();
137
- const isTableResizing = headerGroups.some((headerGroup) => headerGroup.headers.some((header) => header.column.getIsResizing()));
138
- const lastColumnId = table.getAllLeafColumns()[table.getAllLeafColumns().length - 1].id;
139
- const tableRect = tableRef.current?.getBoundingClientRect();
143
+ const leafHeaderGroup = headerGroups[headerGroups.length - 1];
144
+ const leftPinnedHeaders = leafHeaderGroup.headers.filter((header) => header.column.getIsPinned() === 'left');
145
+ const centerHeaders = leafHeaderGroup.headers.filter((header) => !header.column.getIsPinned());
146
+ const isTableResizing = leafHeaderGroup.headers.some((header) => header.column.getIsResizing());
140
147
  // We toggle this here instead of in the parent since this component logically manages all UI for the table.
141
- // eslint-disable-next-line react-you-might-not-need-an-effect/no-manage-parent
142
148
  useEffect(() => {
143
- document.body.classList.toggle('no-user-select', isTableResizing);
149
+ document.body.classList.toggle('pl-ui-no-user-select', isTableResizing);
144
150
  }, [isTableResizing]);
145
- // Dismiss popovers when their triggering element scrolls out of view
151
+ const hasAutoSized = useAutoSizeColumns(table, tableRef, filters);
152
+ // Re-measure the virtualizer when auto-sizing completes
146
153
  useEffect(() => {
147
- const handleScroll = () => {
148
- const scrollElement = parentRef.current;
149
- if (!scrollElement)
150
- return;
151
- // Find and check all open popovers
152
- const popovers = document.querySelectorAll('.popover.show');
153
- popovers.forEach((popover) => {
154
- // Find the trigger element for this popover
155
- const triggerElement = document.querySelector(`[aria-describedby="${popover.id}"]`);
156
- if (!triggerElement)
157
- return;
158
- // Check if the trigger element is still visible in the scroll container
159
- const scrollRect = scrollElement.getBoundingClientRect();
160
- const triggerRect = triggerElement.getBoundingClientRect();
161
- // Check if trigger is outside the visible scroll area
162
- const isOutOfView = triggerRect.bottom < scrollRect.top ||
163
- triggerRect.top > scrollRect.bottom ||
164
- triggerRect.right < scrollRect.left ||
165
- triggerRect.left > scrollRect.right;
166
- if (isOutOfView) {
167
- // Use Bootstrap's Popover API to properly hide it
168
- const popoverInstance = window.bootstrap?.Popover?.getInstance(triggerElement);
169
- if (popoverInstance) {
170
- popoverInstance.hide();
171
- }
172
- }
173
- });
174
- };
175
- const scrollElement = parentRef.current;
176
- if (scrollElement) {
177
- scrollElement.addEventListener('scroll', handleScroll);
178
- return () => scrollElement.removeEventListener('scroll', handleScroll);
179
- }
180
- }, []);
181
- // Helper function to get aria-sort value
182
- const getAriaSort = (sortDirection) => {
183
- switch (sortDirection) {
184
- case 'asc':
185
- return 'ascending';
186
- case 'desc':
187
- return 'descending';
188
- default:
189
- return 'none';
154
+ if (hasAutoSized) {
155
+ columnVirtualizer.measure();
190
156
  }
191
- };
157
+ }, [columnVirtualizer, hasAutoSized]);
192
158
  const displayedCount = table.getRowModel().rows.length;
193
159
  const totalCount = table.getCoreRowModel().rows.length;
194
- return (_jsxs("div", { style: { position: 'relative' }, class: "d-flex flex-column h-100", children: [_jsx("div", { ref: parentRef, style: {
160
+ return (_jsxs("div", { style: { position: 'relative' }, class: "d-flex flex-column h-100", children: [_jsx("div", { ref: scrollContainerRef, style: {
195
161
  position: 'absolute',
196
162
  top: 0,
197
163
  left: 0,
@@ -202,70 +168,50 @@ export function TanstackTable({ table, title, filters = DEFAULT_FILTER_MAP, rowH
202
168
  }, children: _jsx("div", { ref: tableRef, style: {
203
169
  position: 'relative',
204
170
  width: `max(${table.getTotalSize()}px, 100%)`,
205
- }, children: _jsxs("table", { class: "table table-hover mb-0", style: { tableLayout: 'fixed' }, "aria-label": title, role: "grid", children: [_jsx("thead", { children: headerGroups.map((headerGroup) => (_jsx("tr", { children: headerGroup.headers.map((header, index) => {
206
- const isPinned = header.column.getIsPinned();
207
- const sortDirection = header.column.getIsSorted();
208
- const canSort = header.column.getCanSort();
209
- const canFilter = header.column.getCanFilter();
210
- const columnName = typeof header.column.columnDef.header === 'string'
211
- ? header.column.columnDef.header
212
- : header.column.id;
213
- const style = {
214
- width: header.column.id === lastColumnId
215
- ? `max(100%, ${header.getSize()}px)`
216
- : header.getSize(),
217
- position: 'sticky',
218
- top: 0,
219
- zIndex: isPinned === 'left' ? 2 : 1,
220
- left: isPinned === 'left' ? header.getStart() : undefined,
221
- boxShadow: 'inset 0 calc(-1 * var(--bs-border-width)) 0 0 rgba(0, 0, 0, 1), inset 0 var(--bs-border-width) 0 0 var(--bs-border-color)',
222
- };
223
- return (_jsxs("th", { class: clsx(isPinned === 'left' && 'bg-light'), style: style, "aria-sort": canSort ? getAriaSort(sortDirection) : undefined, role: "columnheader", children: [_jsxs("div", { class: clsx('d-flex align-items-center', canSort || canFilter
224
- ? 'justify-content-between'
225
- : 'justify-content-center'), children: [_jsxs("button", { class: clsx('text-nowrap text-start', canSort || canFilter ? 'flex-grow-1' : ''), style: {
226
- cursor: canSort ? 'pointer' : 'default',
227
- overflow: 'hidden',
228
- textOverflow: 'ellipsis',
229
- background: 'transparent',
230
- border: 'none',
231
- }, type: "button", "aria-label": canSort
232
- ? `'${columnName}' column, current sort is ${getAriaSort(sortDirection)}`
233
- : undefined, onClick: canSort ? header.column.getToggleSortingHandler() : undefined, onKeyDown: canSort
234
- ? (e) => {
235
- const handleSort = header.column.getToggleSortingHandler();
236
- if (e.key === 'Enter' && handleSort) {
237
- e.preventDefault();
238
- handleSort(e);
239
- }
240
- }
241
- : undefined, children: [header.isPlaceholder
242
- ? null
243
- : flexRender(header.column.columnDef.header, header.getContext()), canSort && (_jsxs("span", { class: "visually-hidden", children: [", ", getAriaSort(sortDirection), ", click to sort"] }))] }), (canSort || canFilter) && (_jsxs("div", { class: "d-flex align-items-center", children: [canSort && (_jsx("button", { type: "button", class: "btn btn-link text-muted p-0", "aria-label": `Sort ${columnName.toLowerCase()}`, title: `Sort ${columnName.toLowerCase()}`, onClick: header.column.getToggleSortingHandler(), children: _jsx(SortIcon, { sortMethod: sortDirection || false }) })), canFilter && filters[header.column.id]?.({ header })] }))] }), tableRect?.width &&
244
- tableRect.width > table.getTotalSize() &&
245
- index === headerGroup.headers.length - 1 ? null : (_jsx(ResizeHandle, { header: header, setColumnSizing: table.setColumnSizing }))] }, header.id));
246
- }) }, headerGroup.id))) }), _jsxs("tbody", { children: [before > 0 && (_jsx("tr", { tabIndex: -1, children: _jsx("td", { colSpan: headerGroups[0].headers.length, style: { height: before } }) })), virtualRows.map((virtualRow) => {
247
- const row = rows[virtualRow.index];
248
- const visibleCells = getVisibleCells(row);
249
- const rowIdx = virtualRow.index;
250
- return (_jsx("tr", { style: { height: rowHeight }, children: visibleCells.map((cell, colIdx) => {
171
+ }, children: _jsxs("table", { class: "table table-hover mb-0", style: { display: 'grid', tableLayout: 'fixed' }, "aria-label": title, role: "grid", children: [_jsx("thead", { style: {
172
+ display: 'grid',
173
+ position: 'sticky',
174
+ top: 0,
175
+ zIndex: 1,
176
+ }, children: _jsxs("tr", { style: { display: 'flex', width: `${table.getTotalSize()}px` }, children: [leftPinnedHeaders.map((header) => {
177
+ return (_jsx(TanstackTableHeaderCell, { header: header, filters: filters, table: table, handleResizeEnd: handleResizeEnd, isPinned: "left" }, header.id));
178
+ }), virtualPaddingLeft ? (_jsx("th", { style: { display: 'flex', width: virtualPaddingLeft } })) : null, virtualColumns.map((virtualColumn) => {
179
+ const header = centerHeaders[virtualColumn.index];
180
+ if (!header)
181
+ return null;
182
+ return (_jsx(TanstackTableHeaderCell, { header: header, filters: filters, table: table, handleResizeEnd: handleResizeEnd, isPinned: false }, header.id));
183
+ }), virtualPaddingRight ? (_jsx("th", { style: { display: 'flex', width: virtualPaddingRight } })) : null] }, leafHeaderGroup.id) }), _jsx("tbody", { style: {
184
+ display: 'grid',
185
+ height: `${rowVirtualizer.getTotalSize()}px`,
186
+ position: 'relative',
187
+ }, children: virtualRows.map((virtualRow) => {
188
+ const row = rows[virtualRow.index];
189
+ const rowIdx = virtualRow.index;
190
+ const leftPinnedCells = row.getLeftVisibleCells();
191
+ const centerCells = row.getCenterVisibleCells();
192
+ let currentColIdx = 0;
193
+ return (_jsxs("tr", { ref: (node) => rowVirtualizer.measureElement(node), "data-index": virtualRow.index, style: {
194
+ display: 'flex',
195
+ position: 'absolute',
196
+ transform: `translateY(${virtualRow.start}px)`,
197
+ width: `${table.getTotalSize()}px`,
198
+ }, children: [leftPinnedCells.map((cell) => {
199
+ const colIdx = currentColIdx++;
200
+ const canSort = cell.column.getCanSort();
201
+ const canFilter = cell.column.getCanFilter();
202
+ const wrapText = cell.column.columnDef.meta?.wrapText ?? false;
203
+ return (_jsx(TableCell, { cell: cell, rowIdx: rowIdx, colIdx: colIdx, canSort: canSort, canFilter: canFilter, wrapText: wrapText, handleGridKeyDown: handleGridKeyDown }, cell.id));
204
+ }), virtualPaddingLeft ? (_jsx("td", { style: { display: 'flex', width: virtualPaddingLeft } })) : null, virtualColumns.map((virtualColumn) => {
205
+ const cell = centerCells[virtualColumn.index];
206
+ if (!cell)
207
+ return null;
208
+ const colIdx = currentColIdx++;
251
209
  const canSort = cell.column.getCanSort();
252
210
  const canFilter = cell.column.getCanFilter();
253
- return (_jsx("td", {
254
- // You can tab to the most-recently focused cell.
255
- tabIndex: focusedCell.row === rowIdx && focusedCell.col === colIdx ? 0 : -1, "data-grid-cell-row": rowIdx, "data-grid-cell-col": colIdx, class: clsx(!canSort && !canFilter && 'text-center'), style: {
256
- width: cell.column.id === lastColumnId
257
- ? `max(100%, ${cell.column.getSize()}px)`
258
- : cell.column.getSize(),
259
- position: cell.column.getIsPinned() === 'left' ? 'sticky' : undefined,
260
- left: cell.column.getIsPinned() === 'left'
261
- ? cell.column.getStart()
262
- : undefined,
263
- whiteSpace: 'nowrap',
264
- overflow: 'hidden',
265
- textOverflow: 'ellipsis',
266
- }, onFocus: () => setFocusedCell({ row: rowIdx, col: colIdx }), onKeyDown: (e) => handleGridKeyDown(e, rowIdx, colIdx), children: flexRender(cell.column.columnDef.cell, cell.getContext()) }, cell.id));
267
- }) }, row.id));
268
- }), after > 0 && (_jsx("tr", { tabIndex: -1, children: _jsx("td", { colSpan: headerGroups[0].headers.length, style: { height: after } }) }))] })] }) }) }), table.getVisibleLeafColumns().length === 0 || displayedCount === 0 ? (_jsx("div", { children: _jsx("div", { class: "d-flex flex-column justify-content-center align-items-center p-4", style: {
211
+ const wrapText = cell.column.columnDef.meta?.wrapText ?? false;
212
+ return (_jsx(TableCell, { cell: cell, rowIdx: rowIdx, colIdx: colIdx, canSort: canSort, canFilter: canFilter, wrapText: wrapText, handleGridKeyDown: handleGridKeyDown }, cell.id));
213
+ }), virtualPaddingRight ? (_jsx("td", { style: { display: 'flex', width: virtualPaddingRight } })) : null] }, row.id));
214
+ }) })] }) }) }), table.getVisibleLeafColumns().length === 0 || displayedCount === 0 ? (_jsx("div", { children: _jsx("div", { class: "d-flex flex-column justify-content-center align-items-center p-4", style: {
269
215
  position: 'absolute',
270
216
  top: 0,
271
217
  left: 0,
@@ -283,8 +229,13 @@ export function TanstackTable({ table, title, filters = DEFAULT_FILTER_MAP, rowH
283
229
  * @param params
284
230
  * @param params.table - The table model
285
231
  * @param params.title - The title of the card
232
+ * @param params.className - The class name to apply to the card
233
+ * @param params.style - The style to apply to the card
234
+ * @param params.singularLabel - The singular label for a single row in the table, e.g. "student"
235
+ * @param params.pluralLabel - The plural label for multiple rows in the table, e.g. "students"
286
236
  * @param params.headerButtons - The buttons to display in the header
287
237
  * @param params.columnManagerButtons - The buttons to display next to the column manager (View button)
238
+ * @param params.columnManagerTopContent - Optional content to display at the top of the column manager (View) dropdown menu
288
239
  * @param params.globalFilter - State management for the global filter
289
240
  * @param params.globalFilter.value
290
241
  * @param params.globalFilter.setValue
@@ -292,7 +243,7 @@ export function TanstackTable({ table, title, filters = DEFAULT_FILTER_MAP, rowH
292
243
  * @param params.tableOptions - Specific options for the table. See {@link TanstackTableProps} for more details.
293
244
  * @param params.downloadButtonOptions - Specific options for the download button. See {@link TanstackTableDownloadButtonProps} for more details.
294
245
  */
295
- export function TanstackTableCard({ table, title, headerButtons, columnManagerButtons, globalFilter, tableOptions, downloadButtonOptions = null, }) {
246
+ export function TanstackTableCard({ table, title, singularLabel, pluralLabel, headerButtons, columnManagerButtons, columnManagerTopContent, globalFilter, tableOptions, downloadButtonOptions, className, ...divProps }) {
296
247
  const searchInputRef = useRef(null);
297
248
  // Focus the search input when Ctrl+F is pressed
298
249
  useEffect(() => {
@@ -309,11 +260,11 @@ export function TanstackTableCard({ table, title, headerButtons, columnManagerBu
309
260
  }, []);
310
261
  const displayedCount = table.getRowModel().rows.length;
311
262
  const totalCount = table.getCoreRowModel().rows.length;
312
- return (_jsxs("div", { class: "card d-flex flex-column h-100", children: [_jsx("div", { class: "card-header bg-primary text-white", children: _jsxs("div", { class: "d-flex align-items-center justify-content-between gap-2", children: [_jsx("div", { children: title }), _jsxs("div", { class: "d-flex gap-2", children: [headerButtons, downloadButtonOptions && (_jsx(TanstackTableDownloadButton, { table: table, ...downloadButtonOptions }))] })] }) }), _jsxs("div", { class: "card-body d-flex flex-row flex-wrap flex-grow-0 align-items-center gap-2", children: [_jsxs("div", { class: "flex-grow-1 flex-lg-grow-0 col-xl-6 col-lg-7 d-flex flex-row gap-2", children: [_jsxs("div", { class: "position-relative flex-grow-1", children: [_jsx("input", { ref: searchInputRef, type: "text", class: "form-control tanstack-table-search-input tanstack-table-focusable-shadow", "aria-label": globalFilter.placeholder, placeholder: globalFilter.placeholder, value: globalFilter.value, autoComplete: "off", onInput: (e) => {
313
- if (!(e.target instanceof HTMLInputElement))
314
- return;
315
- globalFilter.setValue(e.target.value);
316
- } }), globalFilter.value && (_jsx(OverlayTrigger, { overlay: _jsx(Tooltip, { children: "Clear search" }), children: _jsx("button", { type: "button", class: "btn btn-link tanstack-table-clear-search", "aria-label": "Clear search", onClick: () => globalFilter.setValue(''), children: _jsx("i", { class: "bi bi-x-circle-fill", "aria-hidden": "true" }) }) }))] }), _jsxs("div", { class: "d-none d-md-block", children: [_jsx(ColumnManager, { table: table, id: "column-manager-button-wide" }), columnManagerButtons] })] }), _jsxs("div", { class: "d-block d-md-none", children: [_jsx(ColumnManager, { table: table, id: "column-manager-button-narrow" }), columnManagerButtons] }), _jsx("div", { class: "flex-lg-grow-1 d-flex flex-row justify-content-end", children: _jsxs("div", { class: "text-muted text-nowrap", children: ["Showing ", displayedCount, " of ", totalCount, " ", title.toLowerCase()] }) })] }), _jsx("div", { class: "flex-grow-1", children: _jsx(TanstackTable, { table: table, title: title, ...tableOptions }) })] }));
263
+ return (_jsxs("div", { class: clsx('card d-flex flex-column', className), ...divProps, children: [_jsx("div", { class: "card-header bg-primary text-white", children: _jsxs("div", { class: "d-flex align-items-center justify-content-between gap-2", children: [_jsx("div", { children: title }), _jsxs("div", { class: "d-flex gap-2", children: [headerButtons, downloadButtonOptions && (_jsx(TanstackTableDownloadButton, { table: table, pluralLabel: pluralLabel, singularLabel: singularLabel, ...downloadButtonOptions }))] })] }) }), _jsxs("div", { class: "card-body d-flex flex-row flex-wrap flex-grow-0 align-items-center gap-2", children: [_jsxs("div", { class: "position-relative w-100", style: { maxWidth: 'min(400px, 100%)' }, children: [_jsx("input", { ref: searchInputRef, type: "text", class: "form-control pl-ui-tanstack-table-search-input pl-ui-tanstack-table-focusable-shadow", "aria-label": globalFilter.placeholder, placeholder: globalFilter.placeholder, value: globalFilter.value, autoComplete: "off", onInput: (e) => {
264
+ if (!(e.target instanceof HTMLInputElement))
265
+ return;
266
+ globalFilter.setValue(e.target.value);
267
+ } }), globalFilter.value && (_jsx(OverlayTrigger, { overlay: _jsx(Tooltip, { children: "Clear search" }), children: _jsx("button", { type: "button", class: "btn btn-floating-icon", "aria-label": "Clear search", onClick: () => globalFilter.setValue(''), children: _jsx("i", { class: "bi bi-x-circle-fill", "aria-hidden": "true" }) }) }))] }), _jsxs("div", { class: "d-flex flex-wrap flex-row align-items-center gap-2", children: [_jsx(ColumnManager, { table: table, topContent: columnManagerTopContent }), columnManagerButtons] }), _jsxs("div", { class: "ms-auto text-muted text-nowrap", children: ["Showing ", displayedCount, " of ", totalCount, " ", totalCount === 1 ? singularLabel : pluralLabel] })] }), _jsx("div", { class: "flex-grow-1", children: _jsx(TanstackTable, { table: table, title: title, ...tableOptions }) })] }));
317
268
  }
318
269
  export function TanstackTableEmptyState({ iconName, children, }) {
319
270
  return (_jsxs("div", { class: "d-flex flex-column justify-content-center align-items-center text-muted", children: [_jsx("i", { class: clsx('bi', iconName, 'display-4 mb-2'), "aria-hidden": "true" }), _jsx("div", { children: children })] }));