@prairielearn/ui 1.1.2 → 1.3.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.
- package/CHANGELOG.md +20 -0
- package/dist/components/CategoricalColumnFilter.d.ts.map +1 -1
- package/dist/components/CategoricalColumnFilter.js +13 -5
- package/dist/components/CategoricalColumnFilter.js.map +1 -1
- package/dist/components/ColumnManager.d.ts +2 -1
- package/dist/components/ColumnManager.d.ts.map +1 -1
- package/dist/components/ColumnManager.js +13 -28
- package/dist/components/ColumnManager.js.map +1 -1
- package/dist/components/MultiSelectColumnFilter.d.ts +25 -0
- package/dist/components/MultiSelectColumnFilter.d.ts.map +1 -0
- package/dist/components/MultiSelectColumnFilter.js +41 -0
- package/dist/components/MultiSelectColumnFilter.js.map +1 -0
- package/dist/components/NumericInputColumnFilter.d.ts +42 -0
- package/dist/components/NumericInputColumnFilter.d.ts.map +1 -0
- package/dist/components/NumericInputColumnFilter.js +79 -0
- package/dist/components/NumericInputColumnFilter.js.map +1 -0
- package/dist/components/TanstackTable.css +49 -0
- package/dist/components/TanstackTable.d.ts +8 -1
- package/dist/components/TanstackTable.d.ts.map +1 -1
- package/dist/components/TanstackTable.js +78 -46
- package/dist/components/TanstackTable.js.map +1 -1
- package/dist/components/TanstackTableDownloadButton.d.ts.map +1 -1
- package/dist/components/TanstackTableDownloadButton.js +3 -1
- package/dist/components/TanstackTableDownloadButton.js.map +1 -1
- package/dist/components/useShiftClickCheckbox.d.ts +26 -0
- package/dist/components/useShiftClickCheckbox.d.ts.map +1 -0
- package/dist/components/useShiftClickCheckbox.js +59 -0
- package/dist/components/useShiftClickCheckbox.js.map +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/package.json +6 -4
- package/src/components/CategoricalColumnFilter.tsx +57 -27
- package/src/components/ColumnManager.tsx +32 -57
- package/src/components/MultiSelectColumnFilter.tsx +103 -0
- package/src/components/NumericInputColumnFilter.test.ts +102 -0
- package/src/components/NumericInputColumnFilter.tsx +153 -0
- package/src/components/TanstackTable.css +49 -0
- package/src/components/TanstackTable.tsx +193 -116
- package/src/components/TanstackTableDownloadButton.tsx +27 -1
- package/src/components/useShiftClickCheckbox.tsx +67 -0
- package/src/index.ts +12 -1
|
@@ -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>>5</code> or <code><=10</code>
|
|
72
|
+
</div>
|
|
73
|
+
)}
|
|
74
|
+
{!isInvalid && (
|
|
75
|
+
<div class="form-text small mt-2">
|
|
76
|
+
Use operators: <code><</code>, <code>></code>, <code><=</code>,{' '}
|
|
77
|
+
<code>>=</code>, <code>=</code>
|
|
78
|
+
<br />
|
|
79
|
+
Example: <code>>5</code> or <code><=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
|
+
}
|
|
@@ -2,3 +2,52 @@ body.no-user-select {
|
|
|
2
2
|
user-select: none;
|
|
3
3
|
-webkit-user-select: none;
|
|
4
4
|
}
|
|
5
|
+
|
|
6
|
+
.tanstack-table-search-input {
|
|
7
|
+
padding-right: 2.5rem;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.tanstack-table-clear-search {
|
|
11
|
+
position: absolute;
|
|
12
|
+
right: 0;
|
|
13
|
+
top: 50%;
|
|
14
|
+
transform: translateY(-50%);
|
|
15
|
+
padding: 0.25rem 0.5rem;
|
|
16
|
+
color: var(--bs-secondary);
|
|
17
|
+
opacity: 0.6;
|
|
18
|
+
transition: opacity 0.15s ease-in-out;
|
|
19
|
+
text-decoration: none;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.tanstack-table-clear-search:hover {
|
|
23
|
+
opacity: 1;
|
|
24
|
+
color: var(--bs-secondary);
|
|
25
|
+
text-decoration: none;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.tanstack-table-clear-search:focus {
|
|
29
|
+
opacity: 1;
|
|
30
|
+
color: var(--bs-secondary);
|
|
31
|
+
text-decoration: none;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.btn.btn-tanstack-table {
|
|
35
|
+
--bs-btn-color: var(--bs-body-color);
|
|
36
|
+
--bs-btn-bg: var(--bs-input-bg);
|
|
37
|
+
--bs-btn-border-color: var(--bs-border-color);
|
|
38
|
+
--bs-btn-hover-color: var(--bs-body-color);
|
|
39
|
+
--bs-btn-hover-bg: var(--bs-border-color);
|
|
40
|
+
--bs-btn-hover-border-color: var(--bs-border-color);
|
|
41
|
+
--bs-btn-focus-shadow-rgb: var(--bs-primary-rgb);
|
|
42
|
+
--bs-btn-active-color: var(--bs-body-color);
|
|
43
|
+
--bs-btn-active-bg: var(--bs-border-color);
|
|
44
|
+
--bs-btn-active-border-color: var(--bs-border-color);
|
|
45
|
+
--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
|
|
46
|
+
--bs-btn-disabled-color: var(--bs-body-color);
|
|
47
|
+
--bs-btn-disabled-bg: var(--bs-secondary-bg);
|
|
48
|
+
--bs-btn-disabled-border-color: var(--bs-border-color);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.tanstack-table-focusable-shadow:not(:focus-visible) {
|
|
52
|
+
box-shadow: var(--bs-box-shadow-sm);
|
|
53
|
+
}
|
|
@@ -2,8 +2,11 @@ import { flexRender } from '@tanstack/react-table';
|
|
|
2
2
|
import { notUndefined, useVirtualizer } from '@tanstack/react-virtual';
|
|
3
3
|
import type { Header, Row, SortDirection, Table } from '@tanstack/table-core';
|
|
4
4
|
import clsx from 'clsx';
|
|
5
|
+
import type { ComponentChildren } from 'preact';
|
|
5
6
|
import { useEffect, useRef, useState } 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';
|
|
7
10
|
|
|
8
11
|
import { ColumnManager } from './ColumnManager.js';
|
|
9
12
|
import {
|
|
@@ -223,6 +226,47 @@ export function TanstackTable<RowDataModel>({
|
|
|
223
226
|
document.body.classList.toggle('no-user-select', isTableResizing);
|
|
224
227
|
}, [isTableResizing]);
|
|
225
228
|
|
|
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
|
+
}, []);
|
|
269
|
+
|
|
226
270
|
// Helper function to get aria-sort value
|
|
227
271
|
const getAriaSort = (sortDirection: false | SortDirection) => {
|
|
228
272
|
switch (sortDirection) {
|
|
@@ -260,7 +304,7 @@ export function TanstackTable<RowDataModel>({
|
|
|
260
304
|
}}
|
|
261
305
|
>
|
|
262
306
|
<table
|
|
263
|
-
class="table table-hover mb-0
|
|
307
|
+
class="table table-hover mb-0"
|
|
264
308
|
style={{ tableLayout: 'fixed' }}
|
|
265
309
|
aria-label={title}
|
|
266
310
|
role="grid"
|
|
@@ -299,9 +343,19 @@ export function TanstackTable<RowDataModel>({
|
|
|
299
343
|
aria-sort={canSort ? getAriaSort(sortDirection) : undefined}
|
|
300
344
|
role="columnheader"
|
|
301
345
|
>
|
|
302
|
-
<div
|
|
346
|
+
<div
|
|
347
|
+
class={clsx(
|
|
348
|
+
'd-flex align-items-center',
|
|
349
|
+
canSort || canFilter
|
|
350
|
+
? 'justify-content-between'
|
|
351
|
+
: 'justify-content-center',
|
|
352
|
+
)}
|
|
353
|
+
>
|
|
303
354
|
<button
|
|
304
|
-
class=
|
|
355
|
+
class={clsx(
|
|
356
|
+
'text-nowrap text-start',
|
|
357
|
+
canSort || canFilter ? 'flex-grow-1' : '',
|
|
358
|
+
)}
|
|
305
359
|
style={{
|
|
306
360
|
cursor: canSort ? 'pointer' : 'default',
|
|
307
361
|
overflow: 'hidden',
|
|
@@ -331,11 +385,6 @@ export function TanstackTable<RowDataModel>({
|
|
|
331
385
|
{header.isPlaceholder
|
|
332
386
|
? null
|
|
333
387
|
: 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
388
|
{canSort && (
|
|
340
389
|
<span class="visually-hidden">
|
|
341
390
|
, {getAriaSort(sortDirection)}, click to sort
|
|
@@ -343,7 +392,22 @@ export function TanstackTable<RowDataModel>({
|
|
|
343
392
|
)}
|
|
344
393
|
</button>
|
|
345
394
|
|
|
346
|
-
{canFilter &&
|
|
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
|
+
)}
|
|
347
411
|
</div>
|
|
348
412
|
{tableRect?.width &&
|
|
349
413
|
tableRect.width > table.getTotalSize() &&
|
|
@@ -369,34 +433,42 @@ export function TanstackTable<RowDataModel>({
|
|
|
369
433
|
|
|
370
434
|
return (
|
|
371
435
|
<tr key={row.id} style={{ height: rowHeight }}>
|
|
372
|
-
{visibleCells.map((cell, colIdx) =>
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
436
|
+
{visibleCells.map((cell, colIdx) => {
|
|
437
|
+
const canSort = cell.column.getCanSort();
|
|
438
|
+
const canFilter = cell.column.getCanFilter();
|
|
439
|
+
|
|
440
|
+
return (
|
|
441
|
+
<td
|
|
442
|
+
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>
|
|
470
|
+
);
|
|
471
|
+
})}
|
|
400
472
|
</tr>
|
|
401
473
|
);
|
|
402
474
|
})}
|
|
@@ -409,44 +481,44 @@ export function TanstackTable<RowDataModel>({
|
|
|
409
481
|
</table>
|
|
410
482
|
</div>
|
|
411
483
|
</div>
|
|
412
|
-
|
|
413
|
-
{table.getVisibleLeafColumns().length === 0 && (
|
|
484
|
+
{table.getVisibleLeafColumns().length === 0 || displayedCount === 0 ? (
|
|
414
485
|
<div>
|
|
415
486
|
<div
|
|
416
|
-
class="d-flex flex-column justify-content-center align-items-center
|
|
487
|
+
class="d-flex flex-column justify-content-center align-items-center p-4"
|
|
417
488
|
style={{
|
|
418
489
|
position: 'absolute',
|
|
419
490
|
top: 0,
|
|
420
491
|
left: 0,
|
|
421
492
|
right: 0,
|
|
422
493
|
bottom: 0,
|
|
423
|
-
|
|
494
|
+
// Allow pointer events (e.g. scrolling) to reach the underlying table.
|
|
495
|
+
pointerEvents: 'none',
|
|
424
496
|
}}
|
|
425
497
|
role="status"
|
|
426
498
|
aria-live="polite"
|
|
427
499
|
>
|
|
428
|
-
<
|
|
429
|
-
|
|
500
|
+
<div
|
|
501
|
+
class="col-lg-6"
|
|
502
|
+
style={{
|
|
503
|
+
// Allow selecting and interacting with the empty state content.
|
|
504
|
+
pointerEvents: 'auto',
|
|
505
|
+
}}
|
|
506
|
+
>
|
|
507
|
+
{table.getVisibleLeafColumns().length === 0 ? (
|
|
508
|
+
<TanstackTableEmptyState iconName="bi-eye-slash">
|
|
509
|
+
No columns selected. Use the View menu to show columns.
|
|
510
|
+
</TanstackTableEmptyState>
|
|
511
|
+
) : displayedCount === 0 ? (
|
|
512
|
+
totalCount > 0 ? (
|
|
513
|
+
noResultsState
|
|
514
|
+
) : (
|
|
515
|
+
emptyState
|
|
516
|
+
)
|
|
517
|
+
) : null}
|
|
518
|
+
</div>
|
|
430
519
|
</div>
|
|
431
520
|
</div>
|
|
432
|
-
)}
|
|
433
|
-
{displayedCount === 0 && (
|
|
434
|
-
<div
|
|
435
|
-
class="d-flex flex-column justify-content-center align-items-center text-muted py-4"
|
|
436
|
-
style={{
|
|
437
|
-
position: 'absolute',
|
|
438
|
-
top: 0,
|
|
439
|
-
left: 0,
|
|
440
|
-
right: 0,
|
|
441
|
-
bottom: 0,
|
|
442
|
-
background: 'var(--bs-body-bg)',
|
|
443
|
-
}}
|
|
444
|
-
role="status"
|
|
445
|
-
aria-live="polite"
|
|
446
|
-
>
|
|
447
|
-
{totalCount > 0 ? noResultsState : emptyState}
|
|
448
|
-
</div>
|
|
449
|
-
)}
|
|
521
|
+
) : null}
|
|
450
522
|
</div>
|
|
451
523
|
);
|
|
452
524
|
}
|
|
@@ -457,6 +529,7 @@ export function TanstackTable<RowDataModel>({
|
|
|
457
529
|
* @param params.table - The table model
|
|
458
530
|
* @param params.title - The title of the card
|
|
459
531
|
* @param params.headerButtons - The buttons to display in the header
|
|
532
|
+
* @param params.columnManagerButtons - The buttons to display next to the column manager (View button)
|
|
460
533
|
* @param params.globalFilter - State management for the global filter
|
|
461
534
|
* @param params.globalFilter.value
|
|
462
535
|
* @param params.globalFilter.setValue
|
|
@@ -468,6 +541,7 @@ export function TanstackTableCard<RowDataModel>({
|
|
|
468
541
|
table,
|
|
469
542
|
title,
|
|
470
543
|
headerButtons,
|
|
544
|
+
columnManagerButtons,
|
|
471
545
|
globalFilter,
|
|
472
546
|
tableOptions,
|
|
473
547
|
downloadButtonOptions = null,
|
|
@@ -475,6 +549,7 @@ export function TanstackTableCard<RowDataModel>({
|
|
|
475
549
|
table: Table<RowDataModel>;
|
|
476
550
|
title: string;
|
|
477
551
|
headerButtons: JSX.Element;
|
|
552
|
+
columnManagerButtons?: JSX.Element;
|
|
478
553
|
globalFilter: {
|
|
479
554
|
value: string;
|
|
480
555
|
setValue: (value: string) => void;
|
|
@@ -485,22 +560,6 @@ export function TanstackTableCard<RowDataModel>({
|
|
|
485
560
|
}) {
|
|
486
561
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
487
562
|
|
|
488
|
-
// Track screen size for aria-hidden
|
|
489
|
-
const mediaQuery = typeof window !== 'undefined' ? window.matchMedia('(min-width: 768px)') : null;
|
|
490
|
-
const [isMediumOrLarger, setIsMediumOrLarger] = useState(false);
|
|
491
|
-
|
|
492
|
-
useEffect(() => {
|
|
493
|
-
// TODO: This is a workaround to avoid a hydration mismatch.
|
|
494
|
-
// eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect
|
|
495
|
-
setIsMediumOrLarger(mediaQuery?.matches ?? true);
|
|
496
|
-
}, [mediaQuery]);
|
|
497
|
-
|
|
498
|
-
useEffect(() => {
|
|
499
|
-
const handler = (e: MediaQueryListEvent) => setIsMediumOrLarger(e.matches);
|
|
500
|
-
mediaQuery?.addEventListener('change', handler);
|
|
501
|
-
return () => mediaQuery?.removeEventListener('change', handler);
|
|
502
|
-
}, [mediaQuery]);
|
|
503
|
-
|
|
504
563
|
// Focus the search input when Ctrl+F is pressed
|
|
505
564
|
useEffect(() => {
|
|
506
565
|
function onKeyDown(event: KeyboardEvent) {
|
|
@@ -532,50 +591,68 @@ export function TanstackTableCard<RowDataModel>({
|
|
|
532
591
|
</div>
|
|
533
592
|
</div>
|
|
534
593
|
</div>
|
|
535
|
-
<div class="card-body d-flex flex-
|
|
536
|
-
<div class="
|
|
537
|
-
<div class="
|
|
538
|
-
<
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
We can't have two elements with the same id of 'column-manager-button'. */}
|
|
564
|
-
{isMediumOrLarger && <ColumnManager table={table} />}
|
|
594
|
+
<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
|
+
)}
|
|
565
622
|
</div>
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
<div class="flex-lg-grow-1 d-flex flex-row justify-content-end">
|
|
570
|
-
<div class="text-muted text-nowrap">
|
|
571
|
-
Showing {displayedCount} of {totalCount} {title.toLowerCase()}
|
|
572
|
-
</div>
|
|
623
|
+
<div class="d-none d-md-block">
|
|
624
|
+
<ColumnManager table={table} id="column-manager-button-wide" />
|
|
625
|
+
{columnManagerButtons}
|
|
573
626
|
</div>
|
|
574
627
|
</div>
|
|
575
|
-
<div class="
|
|
576
|
-
<
|
|
628
|
+
<div class="d-block d-md-none">
|
|
629
|
+
<ColumnManager table={table} id="column-manager-button-narrow" />
|
|
630
|
+
{columnManagerButtons}
|
|
631
|
+
</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>
|
|
577
636
|
</div>
|
|
578
637
|
</div>
|
|
638
|
+
<div class="flex-grow-1">
|
|
639
|
+
<TanstackTable table={table} title={title} {...tableOptions} />
|
|
640
|
+
</div>
|
|
641
|
+
</div>
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
export function TanstackTableEmptyState({
|
|
646
|
+
iconName,
|
|
647
|
+
children,
|
|
648
|
+
}: {
|
|
649
|
+
iconName: `bi-${string}`;
|
|
650
|
+
children: ComponentChildren;
|
|
651
|
+
}) {
|
|
652
|
+
return (
|
|
653
|
+
<div class="d-flex flex-column justify-content-center align-items-center text-muted">
|
|
654
|
+
<i class={clsx('bi', iconName, 'display-4 mb-2')} aria-hidden="true" />
|
|
655
|
+
<div>{children}</div>
|
|
579
656
|
</div>
|
|
580
657
|
);
|
|
581
658
|
}
|
|
@@ -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"
|