@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.
Files changed (43) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/components/CategoricalColumnFilter.d.ts.map +1 -1
  3. package/dist/components/CategoricalColumnFilter.js +13 -5
  4. package/dist/components/CategoricalColumnFilter.js.map +1 -1
  5. package/dist/components/ColumnManager.d.ts +2 -1
  6. package/dist/components/ColumnManager.d.ts.map +1 -1
  7. package/dist/components/ColumnManager.js +13 -28
  8. package/dist/components/ColumnManager.js.map +1 -1
  9. package/dist/components/MultiSelectColumnFilter.d.ts +25 -0
  10. package/dist/components/MultiSelectColumnFilter.d.ts.map +1 -0
  11. package/dist/components/MultiSelectColumnFilter.js +41 -0
  12. package/dist/components/MultiSelectColumnFilter.js.map +1 -0
  13. package/dist/components/NumericInputColumnFilter.d.ts +42 -0
  14. package/dist/components/NumericInputColumnFilter.d.ts.map +1 -0
  15. package/dist/components/NumericInputColumnFilter.js +79 -0
  16. package/dist/components/NumericInputColumnFilter.js.map +1 -0
  17. package/dist/components/TanstackTable.css +49 -0
  18. package/dist/components/TanstackTable.d.ts +8 -1
  19. package/dist/components/TanstackTable.d.ts.map +1 -1
  20. package/dist/components/TanstackTable.js +78 -46
  21. package/dist/components/TanstackTable.js.map +1 -1
  22. package/dist/components/TanstackTableDownloadButton.d.ts.map +1 -1
  23. package/dist/components/TanstackTableDownloadButton.js +3 -1
  24. package/dist/components/TanstackTableDownloadButton.js.map +1 -1
  25. package/dist/components/useShiftClickCheckbox.d.ts +26 -0
  26. package/dist/components/useShiftClickCheckbox.d.ts.map +1 -0
  27. package/dist/components/useShiftClickCheckbox.js +59 -0
  28. package/dist/components/useShiftClickCheckbox.js.map +1 -0
  29. package/dist/index.d.ts +4 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +4 -1
  32. package/dist/index.js.map +1 -1
  33. package/package.json +6 -4
  34. package/src/components/CategoricalColumnFilter.tsx +57 -27
  35. package/src/components/ColumnManager.tsx +32 -57
  36. package/src/components/MultiSelectColumnFilter.tsx +103 -0
  37. package/src/components/NumericInputColumnFilter.test.ts +102 -0
  38. package/src/components/NumericInputColumnFilter.tsx +153 -0
  39. package/src/components/TanstackTable.css +49 -0
  40. package/src/components/TanstackTable.tsx +193 -116
  41. package/src/components/TanstackTableDownloadButton.tsx +27 -1
  42. package/src/components/useShiftClickCheckbox.tsx +67 -0
  43. 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>&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
+ }
@@ -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 border border-top-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 class="d-flex align-items-center justify-content-between gap-2">
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="text-nowrap flex-grow-1 text-start"
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 && filters[header.column.id]?.({ header })}
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
- <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
- ))}
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 text-muted py-4"
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
- background: 'var(--bs-body-bg)',
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
- <i class="bi bi-eye-slash display-4 mb-2" aria-hidden="true" />
429
- <p class="mb-0">No columns selected. Use the View menu to show columns.</p>
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-column">
536
- <div class="d-flex flex-row flex-wrap align-items-center mb-3 gap-2">
537
- <div class="flex-grow-1 flex-lg-grow-0 col-xl-6 col-lg-7 d-flex flex-row gap-2">
538
- <div class="input-group">
539
- <input
540
- ref={searchInputRef}
541
- type="text"
542
- class="form-control"
543
- aria-label={globalFilter.placeholder}
544
- placeholder={globalFilter.placeholder}
545
- value={globalFilter.value}
546
- onInput={(e) => {
547
- if (!(e.target instanceof HTMLInputElement)) return;
548
- globalFilter.setValue(e.target.value);
549
- }}
550
- />
551
- <button
552
- type="button"
553
- class="btn btn-outline-secondary"
554
- aria-label="Clear search"
555
- title="Clear search"
556
- data-bs-toggle="tooltip"
557
- onClick={() => globalFilter.setValue('')}
558
- >
559
- <i class="bi bi-x-circle" aria-hidden="true" />
560
- </button>
561
- </div>
562
- {/* We do this instead of CSS properties for the accessibility checker.
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
- {/* We do this instead of CSS properties for the accessibility checker.
567
- We can't have two elements with the same id of 'column-manager-button'. */}
568
- {!isMediumOrLarger && <ColumnManager table={table} />}
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="flex-grow-1">
576
- <TanstackTable table={table} title={title} {...tableOptions} />
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"