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