@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.
- package/CHANGELOG.md +18 -0
- package/dist/components/CategoricalColumnFilter.js +1 -1
- package/dist/components/CategoricalColumnFilter.js.map +1 -1
- package/dist/components/ColumnManager.d.ts.map +1 -1
- package/dist/components/ColumnManager.js +4 -2
- 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.d.ts +3 -1
- package/dist/components/TanstackTable.d.ts.map +1 -1
- package/dist/components/TanstackTable.js +63 -20
- 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 +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/package.json +7 -5
- package/src/components/CategoricalColumnFilter.tsx +1 -1
- package/src/components/ColumnManager.tsx +5 -2
- 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.tsx +123 -41
- package/src/components/TanstackTableDownloadButton.tsx +27 -1
- package/src/components/useShiftClickCheckbox.tsx +67 -0
- package/src/index.ts +7 -0
- 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
|
-
|
|
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>>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
|
+
}
|
|
@@ -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
|
-
|
|
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
|
|
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=
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
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 &&
|
|
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"
|