@prairielearn/ui 1.1.1 → 1.2.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 (40) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/components/CategoricalColumnFilter.js +1 -1
  3. package/dist/components/CategoricalColumnFilter.js.map +1 -1
  4. package/dist/components/ColumnManager.d.ts.map +1 -1
  5. package/dist/components/ColumnManager.js +4 -2
  6. package/dist/components/ColumnManager.js.map +1 -1
  7. package/dist/components/MultiSelectColumnFilter.d.ts +25 -0
  8. package/dist/components/MultiSelectColumnFilter.d.ts.map +1 -0
  9. package/dist/components/MultiSelectColumnFilter.js +41 -0
  10. package/dist/components/MultiSelectColumnFilter.js.map +1 -0
  11. package/dist/components/NumericInputColumnFilter.d.ts +42 -0
  12. package/dist/components/NumericInputColumnFilter.d.ts.map +1 -0
  13. package/dist/components/NumericInputColumnFilter.js +79 -0
  14. package/dist/components/NumericInputColumnFilter.js.map +1 -0
  15. package/dist/components/TanstackTable.d.ts +3 -1
  16. package/dist/components/TanstackTable.d.ts.map +1 -1
  17. package/dist/components/TanstackTable.js +63 -20
  18. package/dist/components/TanstackTable.js.map +1 -1
  19. package/dist/components/TanstackTableDownloadButton.d.ts.map +1 -1
  20. package/dist/components/TanstackTableDownloadButton.js +3 -1
  21. package/dist/components/TanstackTableDownloadButton.js.map +1 -1
  22. package/dist/components/useShiftClickCheckbox.d.ts +26 -0
  23. package/dist/components/useShiftClickCheckbox.d.ts.map +1 -0
  24. package/dist/components/useShiftClickCheckbox.js +59 -0
  25. package/dist/components/useShiftClickCheckbox.js.map +1 -0
  26. package/dist/index.d.ts +3 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +3 -0
  29. package/dist/index.js.map +1 -1
  30. package/package.json +7 -5
  31. package/src/components/CategoricalColumnFilter.tsx +1 -1
  32. package/src/components/ColumnManager.tsx +5 -2
  33. package/src/components/MultiSelectColumnFilter.tsx +103 -0
  34. package/src/components/NumericInputColumnFilter.test.ts +102 -0
  35. package/src/components/NumericInputColumnFilter.tsx +153 -0
  36. package/src/components/TanstackTable.tsx +123 -41
  37. package/src/components/TanstackTableDownloadButton.tsx +27 -1
  38. package/src/components/useShiftClickCheckbox.tsx +67 -0
  39. package/src/index.ts +7 -0
  40. package/vitest.config.ts +2 -2
@@ -22,7 +22,10 @@ function ColumnMenuItem<RowDataModel>({
22
22
 
23
23
  if (!column.getCanHide() && !column.getCanPin()) return null;
24
24
 
25
- const header = typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id;
25
+ // Use meta.label if available, otherwise fall back to header or column.id
26
+ const header =
27
+ (column.columnDef.meta as any)?.label ??
28
+ (typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id);
26
29
 
27
30
  return (
28
31
  <Dropdown.Item
@@ -153,7 +156,7 @@ export function ColumnManager<RowDataModel>({ table }: { table: Table<RowDataMod
153
156
  <i class="bi bi-view-list me-2" aria-hidden="true" />
154
157
  View
155
158
  </Dropdown.Toggle>
156
- <Dropdown.Menu>
159
+ <Dropdown.Menu style={{ maxHeight: '60vh', overflowY: 'auto' }}>
157
160
  {pinnedColumns.length > 0 && (
158
161
  <>
159
162
  <div class="px-2 py-1 text-muted small" role="presentation">
@@ -0,0 +1,103 @@
1
+ import clsx from 'clsx';
2
+ import { type JSX, useMemo } from 'preact/compat';
3
+ import Dropdown from 'react-bootstrap/Dropdown';
4
+
5
+ function defaultRenderValueLabel<T>({ value }: { value: T }) {
6
+ return <span>{String(value)}</span>;
7
+ }
8
+
9
+ /**
10
+ * A component that allows the user to filter a column containing arrays of values.
11
+ * Uses AND logic: rows must contain ALL selected values to match.
12
+ *
13
+ * @param params
14
+ * @param params.columnId - The ID of the column
15
+ * @param params.columnLabel - The label of the column, e.g. "Rubric Items"
16
+ * @param params.allColumnValues - All possible values that can appear in the column
17
+ * @param params.renderValueLabel - A function that renders the label for a value
18
+ * @param params.columnValuesFilter - The current state of the column filter
19
+ * @param params.setColumnValuesFilter - A function that sets the state of the column filter
20
+ */
21
+ export function MultiSelectColumnFilter<T extends readonly any[]>({
22
+ columnId,
23
+ columnLabel,
24
+ allColumnValues,
25
+ renderValueLabel = defaultRenderValueLabel,
26
+ columnValuesFilter,
27
+ setColumnValuesFilter,
28
+ }: {
29
+ columnId: string;
30
+ columnLabel: string;
31
+ allColumnValues: T;
32
+ renderValueLabel?: (props: { value: T[number]; isSelected: boolean }) => JSX.Element;
33
+ columnValuesFilter: T[number][];
34
+ setColumnValuesFilter: (value: T[number][]) => void;
35
+ }) {
36
+ const selected = useMemo(() => new Set(columnValuesFilter), [columnValuesFilter]);
37
+
38
+ const toggleSelected = (value: T[number]) => {
39
+ const set = new Set(selected);
40
+ if (set.has(value)) {
41
+ set.delete(value);
42
+ } else {
43
+ set.add(value);
44
+ }
45
+ setColumnValuesFilter(Array.from(set));
46
+ };
47
+
48
+ const hasActiveFilter = selected.size > 0;
49
+
50
+ return (
51
+ <Dropdown align="end">
52
+ <Dropdown.Toggle
53
+ variant="link"
54
+ class="text-muted p-0"
55
+ id={`filter-${columnId}`}
56
+ aria-label={`Filter ${columnLabel.toLowerCase()}`}
57
+ title={`Filter ${columnLabel.toLowerCase()}`}
58
+ >
59
+ <i
60
+ class={clsx('bi', hasActiveFilter ? ['bi-funnel-fill', 'text-primary'] : 'bi-funnel')}
61
+ aria-hidden="true"
62
+ />
63
+ </Dropdown.Toggle>
64
+ <Dropdown.Menu class="p-0">
65
+ <div class="p-3" style={{ minWidth: '250px' }}>
66
+ <div class="d-flex align-items-center justify-content-between mb-2">
67
+ <div class="fw-semibold">{columnLabel}</div>
68
+ <button
69
+ type="button"
70
+ class="btn btn-link btn-sm text-decoration-none p-0"
71
+ onClick={() => setColumnValuesFilter([])}
72
+ >
73
+ Clear
74
+ </button>
75
+ </div>
76
+
77
+ <div class="list-group list-group-flush">
78
+ {allColumnValues.map((value) => {
79
+ const isSelected = selected.has(value);
80
+ return (
81
+ <div key={value} class="list-group-item d-flex align-items-center gap-3 px-0">
82
+ <input
83
+ class="form-check-input"
84
+ type="checkbox"
85
+ checked={isSelected}
86
+ id={`${columnId}-${value}`}
87
+ onChange={() => toggleSelected(value)}
88
+ />
89
+ <label class="form-check-label fw-normal" for={`${columnId}-${value}`}>
90
+ {renderValueLabel({
91
+ value,
92
+ isSelected,
93
+ })}
94
+ </label>
95
+ </div>
96
+ );
97
+ })}
98
+ </div>
99
+ </div>
100
+ </Dropdown.Menu>
101
+ </Dropdown>
102
+ );
103
+ }
@@ -0,0 +1,102 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { numericColumnFilterFn, parseNumericFilter } from './NumericInputColumnFilter.js';
4
+
5
+ describe('parseNumericFilter', () => {
6
+ it('should parse equals operator', () => {
7
+ expect(parseNumericFilter('5')).toEqual({ operator: '=', value: 5 });
8
+ expect(parseNumericFilter('=5')).toEqual({ operator: '=', value: 5 });
9
+ expect(parseNumericFilter('= 5')).toEqual({ operator: '=', value: 5 });
10
+ });
11
+
12
+ it('should parse less than operator', () => {
13
+ expect(parseNumericFilter('<5')).toEqual({ operator: '<', value: 5 });
14
+ expect(parseNumericFilter('< 5')).toEqual({ operator: '<', value: 5 });
15
+ });
16
+
17
+ it('should parse greater than operator', () => {
18
+ expect(parseNumericFilter('>5')).toEqual({ operator: '>', value: 5 });
19
+ expect(parseNumericFilter('> 5')).toEqual({ operator: '>', value: 5 });
20
+ });
21
+
22
+ it('should parse less than or equal operator', () => {
23
+ expect(parseNumericFilter('<=5')).toEqual({ operator: '<=', value: 5 });
24
+ expect(parseNumericFilter('<= 5')).toEqual({ operator: '<=', value: 5 });
25
+ });
26
+
27
+ it('should parse greater than or equal operator', () => {
28
+ expect(parseNumericFilter('>=5')).toEqual({ operator: '>=', value: 5 });
29
+ expect(parseNumericFilter('>= 5')).toEqual({ operator: '>=', value: 5 });
30
+ });
31
+
32
+ it('should handle decimals', () => {
33
+ expect(parseNumericFilter('5.5')).toEqual({ operator: '=', value: 5.5 });
34
+ expect(parseNumericFilter('>3.14')).toEqual({ operator: '>', value: 3.14 });
35
+ });
36
+
37
+ it('should handle negative numbers', () => {
38
+ expect(parseNumericFilter('-5')).toEqual({ operator: '=', value: -5 });
39
+ expect(parseNumericFilter('<-3')).toEqual({ operator: '<', value: -3 });
40
+ });
41
+
42
+ it('should return null for invalid input', () => {
43
+ expect(parseNumericFilter('')).toBeNull();
44
+ expect(parseNumericFilter(' ')).toBeNull();
45
+ expect(parseNumericFilter('abc')).toBeNull();
46
+ expect(parseNumericFilter('>>')).toBeNull();
47
+ expect(parseNumericFilter('5.5.5')).toBeNull();
48
+ });
49
+
50
+ it('should handle whitespace', () => {
51
+ expect(parseNumericFilter(' > 5 ')).toEqual({ operator: '>', value: 5 });
52
+ });
53
+ });
54
+
55
+ describe('numericColumnFilterFn', () => {
56
+ const createMockRow = (value: number | null) => ({
57
+ getValue: () => value,
58
+ });
59
+
60
+ it('should filter with equals operator', () => {
61
+ expect(numericColumnFilterFn(createMockRow(5), 'col', '5')).toBe(true);
62
+ expect(numericColumnFilterFn(createMockRow(5), 'col', '=5')).toBe(true);
63
+ expect(numericColumnFilterFn(createMockRow(5), 'col', '4')).toBe(false);
64
+ });
65
+
66
+ it('should filter with less than operator', () => {
67
+ expect(numericColumnFilterFn(createMockRow(3), 'col', '<5')).toBe(true);
68
+ expect(numericColumnFilterFn(createMockRow(5), 'col', '<5')).toBe(false);
69
+ expect(numericColumnFilterFn(createMockRow(7), 'col', '<5')).toBe(false);
70
+ });
71
+
72
+ it('should filter with greater than operator', () => {
73
+ expect(numericColumnFilterFn(createMockRow(7), 'col', '>5')).toBe(true);
74
+ expect(numericColumnFilterFn(createMockRow(5), 'col', '>5')).toBe(false);
75
+ expect(numericColumnFilterFn(createMockRow(3), 'col', '>5')).toBe(false);
76
+ });
77
+
78
+ it('should filter with less than or equal operator', () => {
79
+ expect(numericColumnFilterFn(createMockRow(3), 'col', '<=5')).toBe(true);
80
+ expect(numericColumnFilterFn(createMockRow(5), 'col', '<=5')).toBe(true);
81
+ expect(numericColumnFilterFn(createMockRow(7), 'col', '<=5')).toBe(false);
82
+ });
83
+
84
+ it('should filter with greater than or equal operator', () => {
85
+ expect(numericColumnFilterFn(createMockRow(7), 'col', '>=5')).toBe(true);
86
+ expect(numericColumnFilterFn(createMockRow(5), 'col', '>=5')).toBe(true);
87
+ expect(numericColumnFilterFn(createMockRow(3), 'col', '>=5')).toBe(false);
88
+ });
89
+
90
+ it('should return true for invalid or empty filter', () => {
91
+ expect(numericColumnFilterFn(createMockRow(5), 'col', '')).toBe(true);
92
+ expect(numericColumnFilterFn(createMockRow(5), 'col', 'invalid')).toBe(true);
93
+ });
94
+
95
+ it('should return false for null values when filter is active', () => {
96
+ expect(numericColumnFilterFn(createMockRow(null), 'col', '>5')).toBe(false);
97
+ });
98
+
99
+ it('should return true for null values when filter is empty', () => {
100
+ expect(numericColumnFilterFn(createMockRow(null), 'col', '')).toBe(true);
101
+ });
102
+ });
@@ -0,0 +1,153 @@
1
+ import clsx from 'clsx';
2
+ import Dropdown from 'react-bootstrap/Dropdown';
3
+
4
+ interface NumericInputColumnFilterProps {
5
+ columnId: string;
6
+ columnLabel: string;
7
+ value: string;
8
+ onChange: (value: string) => void;
9
+ }
10
+
11
+ /**
12
+ * A component that allows the user to filter a numeric column using comparison operators.
13
+ * Supports syntax like: <1, >0, <=5, >=10, =5, or just 5 (implicit equals)
14
+ *
15
+ * @param params
16
+ * @param params.columnId - The ID of the column
17
+ * @param params.columnLabel - The label of the column, e.g. "Manual Points"
18
+ * @param params.value - The current filter value (e.g., ">5" or "10")
19
+ * @param params.onChange - Callback when the filter value changes
20
+ */
21
+ export function NumericInputColumnFilter({
22
+ columnId,
23
+ columnLabel,
24
+ value,
25
+ onChange,
26
+ }: NumericInputColumnFilterProps) {
27
+ const hasActiveFilter = value.trim().length > 0;
28
+ const isInvalid = hasActiveFilter && parseNumericFilter(value) === null;
29
+
30
+ return (
31
+ <Dropdown align="end">
32
+ <Dropdown.Toggle
33
+ variant="link"
34
+ class={clsx(
35
+ 'text-muted p-0',
36
+ hasActiveFilter && (isInvalid ? 'text-warning' : 'text-primary'),
37
+ )}
38
+ id={`filter-${columnId}`}
39
+ aria-label={`Filter ${columnLabel.toLowerCase()}`}
40
+ title={`Filter ${columnLabel.toLowerCase()}`}
41
+ >
42
+ <i
43
+ class={clsx(
44
+ 'bi',
45
+ isInvalid
46
+ ? 'bi-exclamation-triangle'
47
+ : hasActiveFilter
48
+ ? 'bi-funnel-fill'
49
+ : 'bi-funnel',
50
+ )}
51
+ aria-hidden="true"
52
+ />
53
+ </Dropdown.Toggle>
54
+ <Dropdown.Menu>
55
+ <div class="p-3" style={{ minWidth: '240px' }}>
56
+ <label class="form-label small fw-semibold mb-2">{columnLabel}</label>
57
+ <input
58
+ type="text"
59
+ class={clsx('form-control form-control-sm', isInvalid && 'is-invalid')}
60
+ placeholder="e.g., >0, <5, =10"
61
+ value={value}
62
+ onInput={(e) => {
63
+ if (e.target instanceof HTMLInputElement) {
64
+ onChange(e.target.value);
65
+ }
66
+ }}
67
+ onClick={(e) => e.stopPropagation()}
68
+ />
69
+ {isInvalid && (
70
+ <div class="invalid-feedback d-block">
71
+ Invalid filter format. Use operators like <code>&gt;5</code> or <code>&lt;=10</code>
72
+ </div>
73
+ )}
74
+ {!isInvalid && (
75
+ <div class="form-text small mt-2">
76
+ Use operators: <code>&lt;</code>, <code>&gt;</code>, <code>&lt;=</code>,{' '}
77
+ <code>&gt;=</code>, <code>=</code>
78
+ <br />
79
+ Example: <code>&gt;5</code> or <code>&lt;=10</code>
80
+ </div>
81
+ )}
82
+ {hasActiveFilter && (
83
+ <button
84
+ type="button"
85
+ class="btn btn-sm btn-link text-decoration-none mt-2 p-0"
86
+ onClick={() => onChange('')}
87
+ >
88
+ Clear filter
89
+ </button>
90
+ )}
91
+ </div>
92
+ </Dropdown.Menu>
93
+ </Dropdown>
94
+ );
95
+ }
96
+
97
+ /**
98
+ * Helper function to parse a numeric filter value.
99
+ * Returns null if the filter is invalid or empty.
100
+ *
101
+ * @param filterValue - The filter string (e.g., ">5", "<=10", "3")
102
+ * @returns Parsed operator and value, or null if invalid
103
+ */
104
+ export function parseNumericFilter(filterValue: string): {
105
+ operator: '<' | '>' | '<=' | '>=' | '=';
106
+ value: number;
107
+ } | null {
108
+ if (!filterValue.trim()) return null;
109
+
110
+ const match = filterValue.trim().match(/^(<=?|>=?|=)?\s*(-?\d+\.?\d*)$/);
111
+ if (!match) return null;
112
+
113
+ const operator = (match[1] || '=') as '<' | '>' | '<=' | '>=' | '=';
114
+ const value = Number.parseFloat(match[2]);
115
+
116
+ if (Number.isNaN(value)) return null;
117
+
118
+ return { operator, value };
119
+ }
120
+
121
+ /**
122
+ * TanStack Table filter function for numeric columns.
123
+ * Use this as the `filterFn` for numeric columns.
124
+ *
125
+ * @example
126
+ * {
127
+ * id: 'manual_points',
128
+ * accessorKey: 'manual_points',
129
+ * filterFn: numericColumnFilterFn,
130
+ * }
131
+ */
132
+ export function numericColumnFilterFn(row: any, columnId: string, filterValue: string): boolean {
133
+ const parsed = parseNumericFilter(filterValue);
134
+ if (!parsed) return true; // Invalid or empty filter = show all
135
+
136
+ const cellValue = row.getValue(columnId) as number | null;
137
+ if (cellValue === null || cellValue === undefined) return false;
138
+
139
+ switch (parsed.operator) {
140
+ case '<':
141
+ return cellValue < parsed.value;
142
+ case '>':
143
+ return cellValue > parsed.value;
144
+ case '<=':
145
+ return cellValue <= parsed.value;
146
+ case '>=':
147
+ return cellValue >= parsed.value;
148
+ case '=':
149
+ return cellValue === parsed.value;
150
+ default:
151
+ return true;
152
+ }
153
+ }
@@ -195,9 +195,9 @@ export function TanstackTable<RowDataModel>({
195
195
  useEffect(() => {
196
196
  const selector = `[data-grid-cell-row="${focusedCell.row}"][data-grid-cell-col="${focusedCell.col}"]`;
197
197
  const cell = tableRef.current?.querySelector(selector) as HTMLElement | null;
198
- if (!cell) {
199
- return;
200
- }
198
+ if (!cell) return;
199
+
200
+ // eslint-disable-next-line react-you-might-not-need-an-effect/no-chain-state-updates
201
201
  cell.focus();
202
202
  }, [focusedCell]);
203
203
 
@@ -223,6 +223,47 @@ export function TanstackTable<RowDataModel>({
223
223
  document.body.classList.toggle('no-user-select', isTableResizing);
224
224
  }, [isTableResizing]);
225
225
 
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
+ }, []);
266
+
226
267
  // Helper function to get aria-sort value
227
268
  const getAriaSort = (sortDirection: false | SortDirection) => {
228
269
  switch (sortDirection) {
@@ -299,9 +340,19 @@ export function TanstackTable<RowDataModel>({
299
340
  aria-sort={canSort ? getAriaSort(sortDirection) : undefined}
300
341
  role="columnheader"
301
342
  >
302
- <div class="d-flex align-items-center justify-content-between gap-2">
343
+ <div
344
+ class={clsx(
345
+ 'd-flex align-items-center',
346
+ canSort || canFilter
347
+ ? 'justify-content-between'
348
+ : 'justify-content-center',
349
+ )}
350
+ >
303
351
  <button
304
- class="text-nowrap flex-grow-1 text-start"
352
+ class={clsx(
353
+ 'text-nowrap text-start',
354
+ canSort || canFilter ? 'flex-grow-1' : '',
355
+ )}
305
356
  style={{
306
357
  cursor: canSort ? 'pointer' : 'default',
307
358
  overflow: 'hidden',
@@ -331,11 +382,6 @@ export function TanstackTable<RowDataModel>({
331
382
  {header.isPlaceholder
332
383
  ? null
333
384
  : flexRender(header.column.columnDef.header, header.getContext())}
334
- {canSort && (
335
- <span class="ms-2" aria-hidden="true">
336
- <SortIcon sortMethod={sortDirection || false} />
337
- </span>
338
- )}
339
385
  {canSort && (
340
386
  <span class="visually-hidden">
341
387
  , {getAriaSort(sortDirection)}, click to sort
@@ -343,7 +389,22 @@ export function TanstackTable<RowDataModel>({
343
389
  )}
344
390
  </button>
345
391
 
346
- {canFilter && filters[header.column.id]?.({ header })}
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
+ )}
347
408
  </div>
348
409
  {tableRect?.width &&
349
410
  tableRect.width > table.getTotalSize() &&
@@ -369,34 +430,42 @@ export function TanstackTable<RowDataModel>({
369
430
 
370
431
  return (
371
432
  <tr key={row.id} style={{ height: rowHeight }}>
372
- {visibleCells.map((cell, colIdx) => (
373
- <td
374
- key={cell.id}
375
- // You can tab to the most-recently focused cell.
376
- tabIndex={focusedCell.row === rowIdx && focusedCell.col === colIdx ? 0 : -1}
377
- // We store this so you can navigate around the grid.
378
- data-grid-cell-row={rowIdx}
379
- data-grid-cell-col={colIdx}
380
- style={{
381
- width:
382
- cell.column.id === lastColumnId
383
- ? `max(100%, ${cell.column.getSize()}px)`
384
- : cell.column.getSize(),
385
- position: cell.column.getIsPinned() === 'left' ? 'sticky' : undefined,
386
- left:
387
- cell.column.getIsPinned() === 'left'
388
- ? cell.column.getStart()
389
- : undefined,
390
- whiteSpace: 'nowrap',
391
- overflow: 'hidden',
392
- textOverflow: 'ellipsis',
393
- }}
394
- onFocus={() => setFocusedCell({ row: rowIdx, col: colIdx })}
395
- onKeyDown={(e) => handleGridKeyDown(e, rowIdx, colIdx)}
396
- >
397
- {flexRender(cell.column.columnDef.cell, cell.getContext())}
398
- </td>
399
- ))}
433
+ {visibleCells.map((cell, colIdx) => {
434
+ const canSort = cell.column.getCanSort();
435
+ const canFilter = cell.column.getCanFilter();
436
+
437
+ return (
438
+ <td
439
+ 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>
467
+ );
468
+ })}
400
469
  </tr>
401
470
  );
402
471
  })}
@@ -457,6 +526,7 @@ export function TanstackTable<RowDataModel>({
457
526
  * @param params.table - The table model
458
527
  * @param params.title - The title of the card
459
528
  * @param params.headerButtons - The buttons to display in the header
529
+ * @param params.columnManagerButtons - The buttons to display next to the column manager (View button)
460
530
  * @param params.globalFilter - State management for the global filter
461
531
  * @param params.globalFilter.value
462
532
  * @param params.globalFilter.setValue
@@ -468,6 +538,7 @@ export function TanstackTableCard<RowDataModel>({
468
538
  table,
469
539
  title,
470
540
  headerButtons,
541
+ columnManagerButtons,
471
542
  globalFilter,
472
543
  tableOptions,
473
544
  downloadButtonOptions = null,
@@ -475,6 +546,7 @@ export function TanstackTableCard<RowDataModel>({
475
546
  table: Table<RowDataModel>;
476
547
  title: string;
477
548
  headerButtons: JSX.Element;
549
+ columnManagerButtons?: JSX.Element;
478
550
  globalFilter: {
479
551
  value: string;
480
552
  setValue: (value: string) => void;
@@ -561,11 +633,21 @@ export function TanstackTableCard<RowDataModel>({
561
633
  </div>
562
634
  {/* We do this instead of CSS properties for the accessibility checker.
563
635
  We can't have two elements with the same id of 'column-manager-button'. */}
564
- {isMediumOrLarger && <ColumnManager table={table} />}
636
+ {isMediumOrLarger && (
637
+ <>
638
+ <ColumnManager table={table} />
639
+ {columnManagerButtons}
640
+ </>
641
+ )}
565
642
  </div>
566
643
  {/* We do this instead of CSS properties for the accessibility checker.
567
644
  We can't have two elements with the same id of 'column-manager-button'. */}
568
- {!isMediumOrLarger && <ColumnManager table={table} />}
645
+ {!isMediumOrLarger && (
646
+ <>
647
+ <ColumnManager table={table} />
648
+ {columnManagerButtons}
649
+ </>
650
+ )}
569
651
  <div class="flex-lg-grow-1 d-flex flex-row justify-content-end">
570
652
  <div class="text-muted text-nowrap">
571
653
  Showing {displayedCount} of {totalCount} {title.toLowerCase()}
@@ -28,6 +28,8 @@ export function TanstackTableDownloadButton<RowDataModel>({
28
28
  const allRowsJSON = allRows.map(mapRowToData).filter((row) => row !== null);
29
29
  const filteredRows = table.getRowModel().rows.map((row) => row.original);
30
30
  const filteredRowsJSON = filteredRows.map(mapRowToData).filter((row) => row !== null);
31
+ const selectedRows = table.getSelectedRowModel().rows.map((row) => row.original);
32
+ const selectedRowsJSON = selectedRows.map(mapRowToData).filter((row) => row !== null);
31
33
 
32
34
  function downloadJSONAsCSV(
33
35
  jsonRows: Record<string, string | number | null>[],
@@ -53,7 +55,7 @@ export function TanstackTableDownloadButton<RowDataModel>({
53
55
  class="btn btn-light btn-sm dropdown-toggle"
54
56
  >
55
57
  <i aria-hidden="true" class="pe-2 bi bi-download" />
56
- Download
58
+ <span class="d-none d-sm-inline">Download</span>
57
59
  </button>
58
60
  <ul class="dropdown-menu" role="menu" aria-label="Download options">
59
61
  <li role="presentation">
@@ -80,6 +82,30 @@ export function TanstackTableDownloadButton<RowDataModel>({
80
82
  All {pluralLabel} as JSON
81
83
  </button>
82
84
  </li>
85
+ <li role="presentation">
86
+ <button
87
+ class="dropdown-item"
88
+ type="button"
89
+ role="menuitem"
90
+ aria-label={`Download selected ${pluralLabel} as CSV file`}
91
+ disabled={selectedRowsJSON.length === 0}
92
+ onClick={() => downloadJSONAsCSV(selectedRowsJSON, `${filenameBase}_selected.csv`)}
93
+ >
94
+ Selected {pluralLabel} as CSV
95
+ </button>
96
+ </li>
97
+ <li role="presentation">
98
+ <button
99
+ class="dropdown-item"
100
+ type="button"
101
+ role="menuitem"
102
+ aria-label={`Download selected ${pluralLabel} as JSON file`}
103
+ disabled={selectedRowsJSON.length === 0}
104
+ onClick={() => downloadAsJSON(selectedRowsJSON, `${filenameBase}_selected.json`)}
105
+ >
106
+ Selected {pluralLabel} as JSON
107
+ </button>
108
+ </li>
83
109
  <li role="presentation">
84
110
  <button
85
111
  class="dropdown-item"