@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,105 +1,86 @@
1
1
  import { flexRender } from '@tanstack/react-table';
2
- import { notUndefined, useVirtualizer } from '@tanstack/react-virtual';
3
- import type { Header, Row, SortDirection, Table } from '@tanstack/table-core';
2
+ import { useVirtualizer } from '@tanstack/react-virtual';
3
+ import type { Cell, Header, Row, Table } from '@tanstack/table-core';
4
4
  import clsx from 'clsx';
5
5
  import type { ComponentChildren } from 'preact';
6
- import { useEffect, useRef, useState } from 'preact/hooks';
6
+ import { useEffect, useMemo, useRef } from 'preact/hooks';
7
7
  import type { JSX } from 'preact/jsx-runtime';
8
8
  import OverlayTrigger from 'react-bootstrap/OverlayTrigger';
9
9
  import Tooltip from 'react-bootstrap/Tooltip';
10
10
 
11
+ import type { ComponentProps } from '@prairielearn/preact-cjs';
12
+ import { run } from '@prairielearn/run';
13
+
11
14
  import { ColumnManager } from './ColumnManager.js';
12
15
  import {
13
16
  TanstackTableDownloadButton,
14
17
  type TanstackTableDownloadButtonProps,
15
18
  } from './TanstackTableDownloadButton.js';
16
-
17
- function SortIcon({ sortMethod }: { sortMethod: false | SortDirection }) {
18
- if (sortMethod === 'asc') {
19
- return <i class="bi bi-sort-up-alt" aria-hidden="true" />;
20
- } else if (sortMethod === 'desc') {
21
- return <i class="bi bi-sort-down" aria-hidden="true" />;
22
- } else {
23
- return <i class="bi bi-arrow-down-up opacity-75 text-muted" aria-hidden="true" />;
24
- }
25
- }
26
-
27
- function ResizeHandle<RowDataModel>({
28
- header,
29
- setColumnSizing,
19
+ import { TanstackTableHeaderCell } from './TanstackTableHeaderCell.js';
20
+ import { useAutoSizeColumns } from './useAutoSizeColumns.js';
21
+
22
+ function TableCell<RowDataModel>({
23
+ cell,
24
+ rowIdx,
25
+ colIdx,
26
+ canSort,
27
+ canFilter,
28
+ wrapText,
29
+ handleGridKeyDown,
30
30
  }: {
31
- header: Header<RowDataModel, unknown>;
32
- setColumnSizing: Table<RowDataModel>['setColumnSizing'];
31
+ cell: Cell<RowDataModel, unknown>;
32
+ rowIdx: number;
33
+ colIdx: number;
34
+ canSort: boolean;
35
+ canFilter: boolean;
36
+ wrapText: boolean;
37
+ handleGridKeyDown: (e: KeyboardEvent, rowIdx: number, colIdx: number) => void;
33
38
  }) {
34
- const minSize = header.column.columnDef.minSize ?? 0;
35
- const maxSize = header.column.columnDef.maxSize ?? 0;
36
- const handleKeyDown = (e: KeyboardEvent) => {
37
- if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
38
- e.preventDefault();
39
- const currentSize = header.getSize();
40
- const increment = e.shiftKey ? 20 : 5; // Larger increment with Shift key
41
- const newSize =
42
- e.key === 'ArrowLeft'
43
- ? Math.max(minSize, currentSize - increment)
44
- : Math.min(maxSize, currentSize + increment);
45
-
46
- setColumnSizing((prevSizing) => ({
47
- ...prevSizing,
48
- [header.column.id]: newSize,
49
- }));
50
- } else if (e.key === 'Home') {
51
- e.preventDefault();
52
- header.column.resetSize();
53
- }
54
- };
55
-
56
- const columnName =
57
- typeof header.column.columnDef.header === 'string'
58
- ? header.column.columnDef.header
59
- : header.column.id;
60
-
61
39
  return (
62
- <div class="py-1 h-100" style={{ position: 'absolute', right: 0, top: 0, width: '4px' }}>
63
- {/* separator role is focusable, so these jsx-a11y-x rules are false positives.
64
- https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/separator_role#focusable_separator
65
- */}
66
- {/* eslint-disable-next-line jsx-a11y-x/no-noninteractive-element-interactions */}
40
+ <td
41
+ key={cell.id}
42
+ tabIndex={0}
43
+ data-grid-cell-row={rowIdx}
44
+ data-grid-cell-col={colIdx}
45
+ class={clsx(!canSort && !canFilter && 'text-center')}
46
+ style={{
47
+ display: 'flex',
48
+ width: cell.column.getSize(),
49
+ minWidth: 0,
50
+ maxWidth: cell.column.getSize(),
51
+ flexShrink: 0,
52
+ position: cell.column.getIsPinned() === 'left' ? 'sticky' : undefined,
53
+ left: cell.column.getIsPinned() === 'left' ? cell.column.getStart() : undefined,
54
+ verticalAlign: 'middle',
55
+ }}
56
+ onKeyDown={(e) => handleGridKeyDown(e, rowIdx, colIdx)}
57
+ >
67
58
  <div
68
- role="separator"
69
- aria-label={`Resize '${columnName}' column`}
70
- aria-valuetext={`${header.getSize()}px`}
71
- aria-orientation="vertical"
72
- aria-valuemin={minSize}
73
- aria-valuemax={maxSize}
74
- aria-valuenow={header.getSize()}
75
- // eslint-disable-next-line jsx-a11y-x/no-noninteractive-tabindex
76
- tabIndex={0}
77
- class="h-100"
78
59
  style={{
79
- background: header.column.getIsResizing() ? 'var(--bs-primary)' : 'var(--bs-gray-400)',
80
- cursor: 'col-resize',
81
- transition: 'background-color 0.2s',
60
+ display: 'block',
61
+ minWidth: 0,
62
+ maxWidth: '100%',
63
+ overflow: wrapText ? 'visible' : 'hidden',
64
+ textOverflow: wrapText ? undefined : 'ellipsis',
65
+ whiteSpace: wrapText ? 'normal' : 'nowrap',
66
+ flex: '1 1 0%',
67
+ width: 0, // Allow flex to control width, but start from 0
82
68
  }}
83
- onMouseDown={header.getResizeHandler()}
84
- onTouchStart={header.getResizeHandler()}
85
- onKeyDown={handleKeyDown}
86
- />
87
- </div>
69
+ >
70
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
71
+ </div>
72
+ </td>
88
73
  );
89
74
  }
90
75
 
91
76
  const DefaultNoResultsState = (
92
- <>
93
- <i class="bi bi-search display-4 mb-2" aria-hidden="true" />
94
- <p class="mb-0">No results found matching your search criteria.</p>
95
- </>
77
+ <TanstackTableEmptyState iconName="bi-search">
78
+ No results found matching your search criteria.
79
+ </TanstackTableEmptyState>
96
80
  );
97
81
 
98
82
  const DefaultEmptyState = (
99
- <>
100
- <i class="bi bi-eye-slash display-4 mb-2" aria-hidden="true" />
101
- <p class="mb-0">No results found.</p>
102
- </>
83
+ <TanstackTableEmptyState iconName="bi-eye-slash">No results found.</TanstackTableEmptyState>
103
84
  );
104
85
 
105
86
  interface TanstackTableProps<RowDataModel> {
@@ -109,6 +90,7 @@ interface TanstackTableProps<RowDataModel> {
109
90
  rowHeight?: number;
110
91
  noResultsState?: JSX.Element;
111
92
  emptyState?: JSX.Element;
93
+ scrollRef?: React.RefObject<HTMLDivElement> | null;
112
94
  }
113
95
 
114
96
  const DEFAULT_FILTER_MAP = {};
@@ -122,6 +104,7 @@ const DEFAULT_FILTER_MAP = {};
122
104
  * @param params.rowHeight - The height of the rows in the table
123
105
  * @param params.noResultsState - The no results state for the table
124
106
  * @param params.emptyState - The empty state for the table
107
+ * @param params.scrollRef - Optional ref that will be attached to the scroll container element.
125
108
  */
126
109
  export function TanstackTable<RowDataModel>({
127
110
  table,
@@ -130,19 +113,60 @@ export function TanstackTable<RowDataModel>({
130
113
  rowHeight = 42,
131
114
  noResultsState = DefaultNoResultsState,
132
115
  emptyState = DefaultEmptyState,
116
+ scrollRef,
133
117
  }: TanstackTableProps<RowDataModel>) {
134
118
  const parentRef = useRef<HTMLDivElement>(null);
135
119
  const tableRef = useRef<HTMLDivElement>(null);
120
+ const scrollContainerRef = scrollRef ?? parentRef;
121
+
136
122
  const rows = [...table.getTopRows(), ...table.getCenterRows()];
137
123
  const rowVirtualizer = useVirtualizer({
138
124
  count: rows.length,
139
- getScrollElement: () => parentRef.current,
125
+ getScrollElement: () => scrollContainerRef.current,
140
126
  estimateSize: () => rowHeight,
141
127
  overscan: 10,
128
+ measureElement: (el) => el?.getBoundingClientRect().height ?? rowHeight,
142
129
  });
143
130
 
144
- // Track focused cell for grid navigation
145
- const [focusedCell, setFocusedCell] = useState<{ row: number; col: number }>({ row: 0, col: 0 });
131
+ const visibleColumns = table.getVisibleLeafColumns();
132
+ const centerColumns = visibleColumns.filter((col) => !col.getIsPinned());
133
+
134
+ const columnVirtualizer = useVirtualizer({
135
+ count: centerColumns.length,
136
+ estimateSize: (index) => centerColumns[index]?.getSize(),
137
+ // `useAutoSizeColumns` solves a different problem (happens once when the column set changes)
138
+ // and we don't need to measure the cells themselves, so we can use the default estimateSize.
139
+ getScrollElement: () => scrollContainerRef.current,
140
+ horizontal: true,
141
+ overscan: 3,
142
+ });
143
+
144
+ const virtualColumns = columnVirtualizer.getVirtualItems();
145
+
146
+ const virtualPaddingLeft = run(() => {
147
+ if (columnVirtualizer && virtualColumns?.length > 0) {
148
+ return virtualColumns[0]?.start ?? 0;
149
+ }
150
+ return null;
151
+ });
152
+
153
+ const virtualPaddingRight = run(() => {
154
+ if (columnVirtualizer && virtualColumns?.length > 0) {
155
+ return (
156
+ columnVirtualizer.getTotalSize() - (virtualColumns[virtualColumns.length - 1]?.end ?? 0)
157
+ );
158
+ }
159
+ return null;
160
+ });
161
+
162
+ // Check if any column has wrapping enabled
163
+ const hasWrappedColumns = table.getAllLeafColumns().some((col) => col.columnDef.meta?.wrapText);
164
+
165
+ // Create callback for remeasuring after resize
166
+ const handleResizeEnd = useMemo(() => {
167
+ if (!hasWrappedColumns) return undefined;
168
+ return () => rowVirtualizer.measure();
169
+ }, [hasWrappedColumns, rowVirtualizer]);
146
170
 
147
171
  const getVisibleCells = (row: Row<RowDataModel>) => [
148
172
  ...row.getLeftVisibleCells(),
@@ -176,108 +200,57 @@ export function TanstackTable<RowDataModel>({
176
200
  return;
177
201
  }
178
202
 
179
- setFocusedCell({ row: next.row, col: next.col });
180
- // If we are on the leftmost column, we should allow left scrolling.
181
- if (colIdx === 0 && e.key === 'ArrowLeft') {
182
- return;
183
- }
203
+ // Only handle arrow keys if we're in the cell itself, not in an interactive element
204
+ const target = e.target as HTMLElement;
205
+ if (target.tagName === 'TD') {
206
+ // If we are on the leftmost column, we should allow left scrolling.
207
+ if (colIdx === 0 && e.key === 'ArrowLeft') {
208
+ return;
209
+ }
184
210
 
185
- // If we are on the top row, we should allow up scrolling.
186
- if (rowIdx === 0 && e.key === 'ArrowUp') {
187
- return;
188
- }
211
+ // If we are on the top row, we should allow up scrolling.
212
+ if (rowIdx === 0 && e.key === 'ArrowUp') {
213
+ return;
214
+ }
189
215
 
190
- // If we are on the rightmost column, we should allow right scrolling.
191
- if (colIdx === rowLength - 1 && e.key === 'ArrowRight') {
192
- return;
193
- }
216
+ // If we are on the rightmost column, we should allow right scrolling.
217
+ if (colIdx === rowLength - 1 && e.key === 'ArrowRight') {
218
+ return;
219
+ }
194
220
 
195
- e.preventDefault();
221
+ e.preventDefault();
222
+ const selector = `[data-grid-cell-row="${next.row}"][data-grid-cell-col="${next.col}"]`;
223
+ const nextCell = tableRef.current?.querySelector(selector) as HTMLElement | null;
224
+ nextCell?.focus();
225
+ }
196
226
  };
197
227
 
198
- useEffect(() => {
199
- const selector = `[data-grid-cell-row="${focusedCell.row}"][data-grid-cell-col="${focusedCell.col}"]`;
200
- const cell = tableRef.current?.querySelector(selector) as HTMLElement | null;
201
- if (!cell) return;
202
-
203
- // eslint-disable-next-line react-you-might-not-need-an-effect/no-chain-state-updates
204
- cell.focus();
205
- }, [focusedCell]);
206
-
207
228
  const virtualRows = rowVirtualizer.getVirtualItems();
208
- const [before, after] =
209
- virtualRows.length > 0
210
- ? [
211
- notUndefined(virtualRows[0]).start - rowVirtualizer.options.scrollMargin,
212
- rowVirtualizer.getTotalSize() - notUndefined(virtualRows.at(-1)).end,
213
- ]
214
- : [0, 0];
229
+
215
230
  const headerGroups = table.getHeaderGroups();
216
- const isTableResizing = headerGroups.some((headerGroup) =>
217
- headerGroup.headers.some((header) => header.column.getIsResizing()),
231
+
232
+ const leafHeaderGroup = headerGroups[headerGroups.length - 1];
233
+
234
+ const leftPinnedHeaders = leafHeaderGroup.headers.filter(
235
+ (header) => header.column.getIsPinned() === 'left',
218
236
  );
219
- const lastColumnId = table.getAllLeafColumns()[table.getAllLeafColumns().length - 1].id;
237
+ const centerHeaders = leafHeaderGroup.headers.filter((header) => !header.column.getIsPinned());
220
238
 
221
- const tableRect = tableRef.current?.getBoundingClientRect();
239
+ const isTableResizing = leafHeaderGroup.headers.some((header) => header.column.getIsResizing());
222
240
 
223
241
  // We toggle this here instead of in the parent since this component logically manages all UI for the table.
224
- // eslint-disable-next-line react-you-might-not-need-an-effect/no-manage-parent
225
242
  useEffect(() => {
226
- document.body.classList.toggle('no-user-select', isTableResizing);
243
+ document.body.classList.toggle('pl-ui-no-user-select', isTableResizing);
227
244
  }, [isTableResizing]);
228
245
 
229
- // Dismiss popovers when their triggering element scrolls out of view
230
- useEffect(() => {
231
- const handleScroll = () => {
232
- const scrollElement = parentRef.current;
233
- if (!scrollElement) return;
234
-
235
- // Find and check all open popovers
236
- const popovers = document.querySelectorAll('.popover.show');
237
- popovers.forEach((popover) => {
238
- // Find the trigger element for this popover
239
- const triggerElement = document.querySelector(`[aria-describedby="${popover.id}"]`);
240
- if (!triggerElement) return;
241
-
242
- // Check if the trigger element is still visible in the scroll container
243
- const scrollRect = scrollElement.getBoundingClientRect();
244
- const triggerRect = triggerElement.getBoundingClientRect();
245
-
246
- // Check if trigger is outside the visible scroll area
247
- const isOutOfView =
248
- triggerRect.bottom < scrollRect.top ||
249
- triggerRect.top > scrollRect.bottom ||
250
- triggerRect.right < scrollRect.left ||
251
- triggerRect.left > scrollRect.right;
252
-
253
- if (isOutOfView) {
254
- // Use Bootstrap's Popover API to properly hide it
255
- const popoverInstance = (window as any).bootstrap?.Popover?.getInstance(triggerElement);
256
- if (popoverInstance) {
257
- popoverInstance.hide();
258
- }
259
- }
260
- });
261
- };
262
-
263
- const scrollElement = parentRef.current;
264
- if (scrollElement) {
265
- scrollElement.addEventListener('scroll', handleScroll);
266
- return () => scrollElement.removeEventListener('scroll', handleScroll);
267
- }
268
- }, []);
246
+ const hasAutoSized = useAutoSizeColumns(table, tableRef, filters);
269
247
 
270
- // Helper function to get aria-sort value
271
- const getAriaSort = (sortDirection: false | SortDirection) => {
272
- switch (sortDirection) {
273
- case 'asc':
274
- return 'ascending';
275
- case 'desc':
276
- return 'descending';
277
- default:
278
- return 'none';
248
+ // Re-measure the virtualizer when auto-sizing completes
249
+ useEffect(() => {
250
+ if (hasAutoSized) {
251
+ columnVirtualizer.measure();
279
252
  }
280
- };
253
+ }, [columnVirtualizer, hasAutoSized]);
281
254
 
282
255
  const displayedCount = table.getRowModel().rows.length;
283
256
  const totalCount = table.getCoreRowModel().rows.length;
@@ -285,7 +258,7 @@ export function TanstackTable<RowDataModel>({
285
258
  return (
286
259
  <div style={{ position: 'relative' }} class="d-flex flex-column h-100">
287
260
  <div
288
- ref={parentRef}
261
+ ref={scrollContainerRef}
289
262
  style={{
290
263
  position: 'absolute',
291
264
  top: 0,
@@ -305,178 +278,144 @@ export function TanstackTable<RowDataModel>({
305
278
  >
306
279
  <table
307
280
  class="table table-hover mb-0"
308
- style={{ tableLayout: 'fixed' }}
281
+ style={{ display: 'grid', tableLayout: 'fixed' }}
309
282
  aria-label={title}
310
283
  role="grid"
311
284
  >
312
- <thead>
313
- {headerGroups.map((headerGroup) => (
314
- <tr key={headerGroup.id}>
315
- {headerGroup.headers.map((header, index) => {
316
- const isPinned = header.column.getIsPinned();
317
- const sortDirection = header.column.getIsSorted();
318
- const canSort = header.column.getCanSort();
319
- const canFilter = header.column.getCanFilter();
320
- const columnName =
321
- typeof header.column.columnDef.header === 'string'
322
- ? header.column.columnDef.header
323
- : header.column.id;
324
-
325
- const style: JSX.CSSProperties = {
326
- width:
327
- header.column.id === lastColumnId
328
- ? `max(100%, ${header.getSize()}px)`
329
- : header.getSize(),
330
- position: 'sticky',
331
- top: 0,
332
- zIndex: isPinned === 'left' ? 2 : 1,
333
- left: isPinned === 'left' ? header.getStart() : undefined,
334
- boxShadow:
335
- '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)',
336
- };
337
-
338
- return (
339
- <th
340
- key={header.id}
341
- class={clsx(isPinned === 'left' && 'bg-light')}
342
- style={style}
343
- aria-sort={canSort ? getAriaSort(sortDirection) : undefined}
344
- role="columnheader"
345
- >
346
- <div
347
- class={clsx(
348
- 'd-flex align-items-center',
349
- canSort || canFilter
350
- ? 'justify-content-between'
351
- : 'justify-content-center',
352
- )}
353
- >
354
- <button
355
- class={clsx(
356
- 'text-nowrap text-start',
357
- canSort || canFilter ? 'flex-grow-1' : '',
358
- )}
359
- style={{
360
- cursor: canSort ? 'pointer' : 'default',
361
- overflow: 'hidden',
362
- textOverflow: 'ellipsis',
363
- background: 'transparent',
364
- border: 'none',
365
- }}
366
- type="button"
367
- aria-label={
368
- canSort
369
- ? `'${columnName}' column, current sort is ${getAriaSort(sortDirection)}`
370
- : undefined
371
- }
372
- onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
373
- onKeyDown={
374
- canSort
375
- ? (e) => {
376
- const handleSort = header.column.getToggleSortingHandler();
377
- if (e.key === 'Enter' && handleSort) {
378
- e.preventDefault();
379
- handleSort(e);
380
- }
381
- }
382
- : undefined
383
- }
384
- >
385
- {header.isPlaceholder
386
- ? null
387
- : flexRender(header.column.columnDef.header, header.getContext())}
388
- {canSort && (
389
- <span class="visually-hidden">
390
- , {getAriaSort(sortDirection)}, click to sort
391
- </span>
392
- )}
393
- </button>
394
-
395
- {(canSort || canFilter) && (
396
- <div class="d-flex align-items-center">
397
- {canSort && (
398
- <button
399
- type="button"
400
- class="btn btn-link text-muted p-0"
401
- aria-label={`Sort ${columnName.toLowerCase()}`}
402
- title={`Sort ${columnName.toLowerCase()}`}
403
- onClick={header.column.getToggleSortingHandler()}
404
- >
405
- <SortIcon sortMethod={sortDirection || false} />
406
- </button>
407
- )}
408
- {canFilter && filters[header.column.id]?.({ header })}
409
- </div>
410
- )}
411
- </div>
412
- {tableRect?.width &&
413
- tableRect.width > table.getTotalSize() &&
414
- index === headerGroup.headers.length - 1 ? null : (
415
- <ResizeHandle header={header} setColumnSizing={table.setColumnSizing} />
416
- )}
417
- </th>
418
- );
419
- })}
420
- </tr>
421
- ))}
285
+ <thead
286
+ style={{
287
+ display: 'grid',
288
+ position: 'sticky',
289
+ top: 0,
290
+ zIndex: 1,
291
+ }}
292
+ >
293
+ <tr
294
+ key={leafHeaderGroup.id}
295
+ style={{ display: 'flex', width: `${table.getTotalSize()}px` }}
296
+ >
297
+ {/* Left pinned columns */}
298
+ {leftPinnedHeaders.map((header) => {
299
+ return (
300
+ <TanstackTableHeaderCell
301
+ key={header.id}
302
+ header={header}
303
+ filters={filters}
304
+ table={table}
305
+ handleResizeEnd={handleResizeEnd}
306
+ isPinned="left"
307
+ />
308
+ );
309
+ })}
310
+
311
+ {/* Virtual padding for left side of center columns */}
312
+ {virtualPaddingLeft ? (
313
+ <th style={{ display: 'flex', width: virtualPaddingLeft }} />
314
+ ) : null}
315
+
316
+ {/* Virtualized center columns */}
317
+ {virtualColumns.map((virtualColumn) => {
318
+ const header = centerHeaders[virtualColumn.index];
319
+ if (!header) return null;
320
+
321
+ return (
322
+ <TanstackTableHeaderCell
323
+ key={header.id}
324
+ header={header}
325
+ filters={filters}
326
+ table={table}
327
+ handleResizeEnd={handleResizeEnd}
328
+ isPinned={false}
329
+ />
330
+ );
331
+ })}
332
+
333
+ {/* Virtual padding for right side of center columns */}
334
+ {virtualPaddingRight ? (
335
+ <th style={{ display: 'flex', width: virtualPaddingRight }} />
336
+ ) : null}
337
+ </tr>
422
338
  </thead>
423
- <tbody>
424
- {before > 0 && (
425
- <tr tabIndex={-1}>
426
- <td colSpan={headerGroups[0].headers.length} style={{ height: before }} />
427
- </tr>
428
- )}
339
+ <tbody
340
+ style={{
341
+ display: 'grid',
342
+ height: `${rowVirtualizer.getTotalSize()}px`,
343
+ position: 'relative',
344
+ }}
345
+ >
429
346
  {virtualRows.map((virtualRow) => {
430
347
  const row = rows[virtualRow.index];
431
- const visibleCells = getVisibleCells(row);
432
348
  const rowIdx = virtualRow.index;
349
+ const leftPinnedCells = row.getLeftVisibleCells();
350
+ const centerCells = row.getCenterVisibleCells();
351
+
352
+ let currentColIdx = 0;
433
353
 
434
354
  return (
435
- <tr key={row.id} style={{ height: rowHeight }}>
436
- {visibleCells.map((cell, colIdx) => {
355
+ <tr
356
+ key={row.id}
357
+ ref={(node) => rowVirtualizer.measureElement(node)}
358
+ data-index={virtualRow.index}
359
+ style={{
360
+ display: 'flex',
361
+ position: 'absolute',
362
+ transform: `translateY(${virtualRow.start}px)`,
363
+ width: `${table.getTotalSize()}px`,
364
+ }}
365
+ >
366
+ {leftPinnedCells.map((cell) => {
367
+ const colIdx = currentColIdx++;
437
368
  const canSort = cell.column.getCanSort();
438
369
  const canFilter = cell.column.getCanFilter();
370
+ const wrapText = cell.column.columnDef.meta?.wrapText ?? false;
439
371
 
440
372
  return (
441
- <td
373
+ <TableCell
442
374
  key={cell.id}
443
- // You can tab to the most-recently focused cell.
444
- tabIndex={
445
- focusedCell.row === rowIdx && focusedCell.col === colIdx ? 0 : -1
446
- }
447
- // We store this so you can navigate around the grid.
448
- data-grid-cell-row={rowIdx}
449
- data-grid-cell-col={colIdx}
450
- class={clsx(!canSort && !canFilter && 'text-center')}
451
- style={{
452
- width:
453
- cell.column.id === lastColumnId
454
- ? `max(100%, ${cell.column.getSize()}px)`
455
- : cell.column.getSize(),
456
- position: cell.column.getIsPinned() === 'left' ? 'sticky' : undefined,
457
- left:
458
- cell.column.getIsPinned() === 'left'
459
- ? cell.column.getStart()
460
- : undefined,
461
- whiteSpace: 'nowrap',
462
- overflow: 'hidden',
463
- textOverflow: 'ellipsis',
464
- }}
465
- onFocus={() => setFocusedCell({ row: rowIdx, col: colIdx })}
466
- onKeyDown={(e) => handleGridKeyDown(e, rowIdx, colIdx)}
467
- >
468
- {flexRender(cell.column.columnDef.cell, cell.getContext())}
469
- </td>
375
+ cell={cell}
376
+ rowIdx={rowIdx}
377
+ colIdx={colIdx}
378
+ canSort={canSort}
379
+ canFilter={canFilter}
380
+ wrapText={wrapText}
381
+ handleGridKeyDown={handleGridKeyDown}
382
+ />
470
383
  );
471
384
  })}
385
+
386
+ {virtualPaddingLeft ? (
387
+ <td style={{ display: 'flex', width: virtualPaddingLeft }} />
388
+ ) : null}
389
+
390
+ {virtualColumns.map((virtualColumn) => {
391
+ const cell = centerCells[virtualColumn.index];
392
+ if (!cell) return null;
393
+
394
+ const colIdx = currentColIdx++;
395
+ const canSort = cell.column.getCanSort();
396
+ const canFilter = cell.column.getCanFilter();
397
+ const wrapText = cell.column.columnDef.meta?.wrapText ?? false;
398
+
399
+ return (
400
+ <TableCell
401
+ key={cell.id}
402
+ cell={cell}
403
+ rowIdx={rowIdx}
404
+ colIdx={colIdx}
405
+ canSort={canSort}
406
+ canFilter={canFilter}
407
+ wrapText={wrapText}
408
+ handleGridKeyDown={handleGridKeyDown}
409
+ />
410
+ );
411
+ })}
412
+
413
+ {virtualPaddingRight ? (
414
+ <td style={{ display: 'flex', width: virtualPaddingRight }} />
415
+ ) : null}
472
416
  </tr>
473
417
  );
474
418
  })}
475
- {after > 0 && (
476
- <tr tabIndex={-1}>
477
- <td colSpan={headerGroups[0].headers.length} style={{ height: after }} />
478
- </tr>
479
- )}
480
419
  </tbody>
481
420
  </table>
482
421
  </div>
@@ -528,8 +467,13 @@ export function TanstackTable<RowDataModel>({
528
467
  * @param params
529
468
  * @param params.table - The table model
530
469
  * @param params.title - The title of the card
470
+ * @param params.className - The class name to apply to the card
471
+ * @param params.style - The style to apply to the card
472
+ * @param params.singularLabel - The singular label for a single row in the table, e.g. "student"
473
+ * @param params.pluralLabel - The plural label for multiple rows in the table, e.g. "students"
531
474
  * @param params.headerButtons - The buttons to display in the header
532
475
  * @param params.columnManagerButtons - The buttons to display next to the column manager (View button)
476
+ * @param params.columnManagerTopContent - Optional content to display at the top of the column manager (View) dropdown menu
533
477
  * @param params.globalFilter - State management for the global filter
534
478
  * @param params.globalFilter.value
535
479
  * @param params.globalFilter.setValue
@@ -540,24 +484,35 @@ export function TanstackTable<RowDataModel>({
540
484
  export function TanstackTableCard<RowDataModel>({
541
485
  table,
542
486
  title,
487
+ singularLabel,
488
+ pluralLabel,
543
489
  headerButtons,
544
490
  columnManagerButtons,
491
+ columnManagerTopContent,
545
492
  globalFilter,
546
493
  tableOptions,
547
- downloadButtonOptions = null,
494
+ downloadButtonOptions,
495
+ className,
496
+ ...divProps
548
497
  }: {
549
498
  table: Table<RowDataModel>;
550
499
  title: string;
500
+ singularLabel: string;
501
+ pluralLabel: string;
551
502
  headerButtons: JSX.Element;
552
503
  columnManagerButtons?: JSX.Element;
504
+ columnManagerTopContent?: JSX.Element;
553
505
  globalFilter: {
554
506
  value: string;
555
507
  setValue: (value: string) => void;
556
508
  placeholder: string;
557
509
  };
558
510
  tableOptions: Partial<Omit<TanstackTableProps<RowDataModel>, 'table'>>;
559
- downloadButtonOptions?: Omit<TanstackTableDownloadButtonProps<RowDataModel>, 'table'> | null;
560
- }) {
511
+ downloadButtonOptions?: Omit<
512
+ TanstackTableDownloadButtonProps<RowDataModel>,
513
+ 'table' | 'singularLabel' | 'pluralLabel'
514
+ >;
515
+ } & Omit<ComponentProps<'div'>, 'class'>) {
561
516
  const searchInputRef = useRef<HTMLInputElement>(null);
562
517
 
563
518
  // Focus the search input when Ctrl+F is pressed
@@ -578,7 +533,7 @@ export function TanstackTableCard<RowDataModel>({
578
533
  const totalCount = table.getCoreRowModel().rows.length;
579
534
 
580
535
  return (
581
- <div class="card d-flex flex-column h-100">
536
+ <div class={clsx('card d-flex flex-column', className)} {...divProps}>
582
537
  <div class="card-header bg-primary text-white">
583
538
  <div class="d-flex align-items-center justify-content-between gap-2">
584
539
  <div>{title}</div>
@@ -586,53 +541,50 @@ export function TanstackTableCard<RowDataModel>({
586
541
  {headerButtons}
587
542
 
588
543
  {downloadButtonOptions && (
589
- <TanstackTableDownloadButton table={table} {...downloadButtonOptions} />
544
+ <TanstackTableDownloadButton
545
+ table={table}
546
+ pluralLabel={pluralLabel}
547
+ singularLabel={singularLabel}
548
+ {...downloadButtonOptions}
549
+ />
590
550
  )}
591
551
  </div>
592
552
  </div>
593
553
  </div>
594
554
  <div class="card-body d-flex flex-row flex-wrap flex-grow-0 align-items-center gap-2">
595
- <div class="flex-grow-1 flex-lg-grow-0 col-xl-6 col-lg-7 d-flex flex-row gap-2">
596
- <div class="position-relative flex-grow-1">
597
- <input
598
- ref={searchInputRef}
599
- type="text"
600
- class="form-control tanstack-table-search-input tanstack-table-focusable-shadow"
601
- aria-label={globalFilter.placeholder}
602
- placeholder={globalFilter.placeholder}
603
- value={globalFilter.value}
604
- autoComplete="off"
605
- onInput={(e) => {
606
- if (!(e.target instanceof HTMLInputElement)) return;
607
- globalFilter.setValue(e.target.value);
608
- }}
609
- />
610
- {globalFilter.value && (
611
- <OverlayTrigger overlay={<Tooltip>Clear search</Tooltip>}>
612
- <button
613
- type="button"
614
- class="btn btn-link tanstack-table-clear-search"
615
- aria-label="Clear search"
616
- onClick={() => globalFilter.setValue('')}
617
- >
618
- <i class="bi bi-x-circle-fill" aria-hidden="true" />
619
- </button>
620
- </OverlayTrigger>
621
- )}
622
- </div>
623
- <div class="d-none d-md-block">
624
- <ColumnManager table={table} id="column-manager-button-wide" />
625
- {columnManagerButtons}
626
- </div>
555
+ <div class="position-relative w-100" style={{ maxWidth: 'min(400px, 100%)' }}>
556
+ <input
557
+ ref={searchInputRef}
558
+ type="text"
559
+ class="form-control pl-ui-tanstack-table-search-input pl-ui-tanstack-table-focusable-shadow"
560
+ aria-label={globalFilter.placeholder}
561
+ placeholder={globalFilter.placeholder}
562
+ value={globalFilter.value}
563
+ autoComplete="off"
564
+ onInput={(e) => {
565
+ if (!(e.target instanceof HTMLInputElement)) return;
566
+ globalFilter.setValue(e.target.value);
567
+ }}
568
+ />
569
+ {globalFilter.value && (
570
+ <OverlayTrigger overlay={<Tooltip>Clear search</Tooltip>}>
571
+ <button
572
+ type="button"
573
+ class="btn btn-floating-icon"
574
+ aria-label="Clear search"
575
+ onClick={() => globalFilter.setValue('')}
576
+ >
577
+ <i class="bi bi-x-circle-fill" aria-hidden="true" />
578
+ </button>
579
+ </OverlayTrigger>
580
+ )}
627
581
  </div>
628
- <div class="d-block d-md-none">
629
- <ColumnManager table={table} id="column-manager-button-narrow" />
582
+ <div class="d-flex flex-wrap flex-row align-items-center gap-2">
583
+ <ColumnManager table={table} topContent={columnManagerTopContent} />
630
584
  {columnManagerButtons}
631
585
  </div>
632
- <div class="flex-lg-grow-1 d-flex flex-row justify-content-end">
633
- <div class="text-muted text-nowrap">
634
- Showing {displayedCount} of {totalCount} {title.toLowerCase()}
635
- </div>
586
+ <div class="ms-auto text-muted text-nowrap">
587
+ Showing {displayedCount} of {totalCount} {totalCount === 1 ? singularLabel : pluralLabel}
636
588
  </div>
637
589
  </div>
638
590
  <div class="flex-grow-1">