@prairielearn/ui 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +4 -2
  3. package/dist/components/CategoricalColumnFilter.d.ts +7 -12
  4. package/dist/components/CategoricalColumnFilter.d.ts.map +1 -1
  5. package/dist/components/CategoricalColumnFilter.js +15 -11
  6. package/dist/components/CategoricalColumnFilter.js.map +1 -1
  7. package/dist/components/ColumnManager.d.ts +6 -3
  8. package/dist/components/ColumnManager.d.ts.map +1 -1
  9. package/dist/components/ColumnManager.js +98 -18
  10. package/dist/components/ColumnManager.js.map +1 -1
  11. package/dist/components/MultiSelectColumnFilter.d.ts +8 -12
  12. package/dist/components/MultiSelectColumnFilter.d.ts.map +1 -1
  13. package/dist/components/MultiSelectColumnFilter.js +21 -13
  14. package/dist/components/MultiSelectColumnFilter.js.map +1 -1
  15. package/dist/components/NumericInputColumnFilter.d.ts +13 -13
  16. package/dist/components/NumericInputColumnFilter.d.ts.map +1 -1
  17. package/dist/components/NumericInputColumnFilter.js +44 -15
  18. package/dist/components/NumericInputColumnFilter.js.map +1 -1
  19. package/dist/components/NumericInputColumnFilter.test.d.ts +2 -0
  20. package/dist/components/NumericInputColumnFilter.test.d.ts.map +1 -0
  21. package/dist/components/NumericInputColumnFilter.test.js +90 -0
  22. package/dist/components/NumericInputColumnFilter.test.js.map +1 -0
  23. package/dist/components/OverlayTrigger.d.ts +78 -0
  24. package/dist/components/OverlayTrigger.d.ts.map +1 -0
  25. package/dist/components/OverlayTrigger.js +89 -0
  26. package/dist/components/OverlayTrigger.js.map +1 -0
  27. package/dist/components/TanstackTable.d.ts +15 -4
  28. package/dist/components/TanstackTable.d.ts.map +1 -1
  29. package/dist/components/TanstackTable.js +148 -197
  30. package/dist/components/TanstackTable.js.map +1 -1
  31. package/dist/components/TanstackTableDownloadButton.d.ts +4 -2
  32. package/dist/components/TanstackTableDownloadButton.d.ts.map +1 -1
  33. package/dist/components/TanstackTableDownloadButton.js +4 -3
  34. package/dist/components/TanstackTableDownloadButton.js.map +1 -1
  35. package/dist/components/TanstackTableHeaderCell.d.ts +13 -0
  36. package/dist/components/TanstackTableHeaderCell.d.ts.map +1 -0
  37. package/dist/components/TanstackTableHeaderCell.js +98 -0
  38. package/dist/components/TanstackTableHeaderCell.js.map +1 -0
  39. package/dist/components/{TanstackTable.css → styles.css} +11 -6
  40. package/dist/components/useAutoSizeColumns.d.ts +17 -0
  41. package/dist/components/useAutoSizeColumns.d.ts.map +1 -0
  42. package/dist/components/useAutoSizeColumns.js +99 -0
  43. package/dist/components/useAutoSizeColumns.js.map +1 -0
  44. package/dist/index.d.ts +3 -1
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +3 -0
  47. package/dist/index.js.map +1 -1
  48. package/dist/react-table.d.ts +13 -0
  49. package/dist/react-table.d.ts.map +1 -0
  50. package/dist/react-table.js +3 -0
  51. package/dist/react-table.js.map +1 -0
  52. package/package.json +2 -2
  53. package/src/components/CategoricalColumnFilter.tsx +28 -28
  54. package/src/components/ColumnManager.tsx +222 -46
  55. package/src/components/MultiSelectColumnFilter.tsx +45 -32
  56. package/src/components/NumericInputColumnFilter.test.ts +67 -19
  57. package/src/components/NumericInputColumnFilter.tsx +102 -42
  58. package/src/components/OverlayTrigger.tsx +168 -0
  59. package/src/components/TanstackTable.tsx +315 -363
  60. package/src/components/TanstackTableDownloadButton.tsx +8 -5
  61. package/src/components/TanstackTableHeaderCell.tsx +207 -0
  62. package/src/components/{TanstackTable.css → styles.css} +11 -6
  63. package/src/components/useAutoSizeColumns.tsx +168 -0
  64. package/src/index.ts +5 -0
  65. package/src/react-table.ts +17 -0
  66. package/tsconfig.json +1 -2
@@ -1,31 +1,43 @@
1
+ import type { Column } from '@tanstack/table-core';
1
2
  import clsx from 'clsx';
2
3
  import Dropdown from 'react-bootstrap/Dropdown';
3
4
 
4
- interface NumericInputColumnFilterProps {
5
- columnId: string;
6
- columnLabel: string;
7
- value: string;
8
- onChange: (value: string) => void;
9
- }
5
+ export type NumericColumnFilterValue =
6
+ | {
7
+ filterValue: string;
8
+ emptyOnly: false;
9
+ }
10
+ | {
11
+ filterValue: '';
12
+ emptyOnly: true;
13
+ };
10
14
 
11
15
  /**
12
16
  * A component that allows the user to filter a numeric column using comparison operators.
13
17
  * Supports syntax like: <1, >0, <=5, >=10, =5, or just 5 (implicit equals)
14
18
  *
15
19
  * @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
+ * @param params.column - The TanStack Table column object
20
21
  */
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;
22
+ export function NumericInputColumnFilter<TData, TValue>({
23
+ column,
24
+ }: {
25
+ column: Column<TData, TValue>;
26
+ }) {
27
+ const columnId = column.id;
28
+ const value = (column.getFilterValue() as NumericColumnFilterValue | undefined) ?? {
29
+ filterValue: '',
30
+ emptyOnly: false,
31
+ };
32
+
33
+ const label =
34
+ column.columnDef.meta?.label ??
35
+ (typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id);
36
+
37
+ const filterValue = value.filterValue;
38
+ const emptyOnly = value.emptyOnly;
39
+ const hasActiveFilter = filterValue.trim().length > 0 || emptyOnly;
40
+ const isInvalid = filterValue.trim().length > 0 && parseNumericFilter(filterValue) === null;
29
41
 
30
42
  return (
31
43
  <Dropdown align="end">
@@ -36,8 +48,8 @@ export function NumericInputColumnFilter({
36
48
  hasActiveFilter && (isInvalid ? 'text-warning' : 'text-primary'),
37
49
  )}
38
50
  id={`filter-${columnId}`}
39
- aria-label={`Filter ${columnLabel.toLowerCase()}`}
40
- title={`Filter ${columnLabel.toLowerCase()}`}
51
+ aria-label={`Filter ${label.toLowerCase()}`}
52
+ title={`Filter ${label.toLowerCase()}`}
41
53
  >
42
54
  <i
43
55
  class={clsx(
@@ -51,17 +63,42 @@ export function NumericInputColumnFilter({
51
63
  aria-hidden="true"
52
64
  />
53
65
  </Dropdown.Toggle>
54
- <Dropdown.Menu>
66
+ <Dropdown.Menu
67
+ // eslint-disable-next-line @eslint-react/no-forbidden-props
68
+ className="p-0"
69
+ >
55
70
  <div class="p-3" style={{ minWidth: '240px' }}>
56
- <label class="form-label small fw-semibold mb-2">{columnLabel}</label>
71
+ <div class="d-flex align-items-center justify-content-between mb-2">
72
+ <label class="form-label fw-semibold mb-0" id={`${columnId}-filter-label`}>
73
+ {label}
74
+ </label>
75
+ <button
76
+ type="button"
77
+ class={clsx(
78
+ 'btn btn-link btn-sm text-decoration-none',
79
+ !hasActiveFilter && 'invisible',
80
+ )}
81
+ onClick={() => {
82
+ column.setFilterValue({ filterValue: '', emptyOnly: false });
83
+ }}
84
+ >
85
+ Clear
86
+ </button>
87
+ </div>
57
88
  <input
58
89
  type="text"
59
90
  class={clsx('form-control form-control-sm', isInvalid && 'is-invalid')}
60
91
  placeholder="e.g., >0, <5, =10"
61
- value={value}
92
+ aria-labelledby={`${columnId}-filter-label`}
93
+ value={filterValue}
94
+ disabled={emptyOnly}
95
+ aria-describedby={`${columnId}-filter-description`}
62
96
  onInput={(e) => {
63
97
  if (e.target instanceof HTMLInputElement) {
64
- onChange(e.target.value);
98
+ column.setFilterValue({
99
+ filterValue: e.target.value,
100
+ emptyOnly: false,
101
+ });
65
102
  }
66
103
  }}
67
104
  onClick={(e) => e.stopPropagation()}
@@ -72,22 +109,31 @@ export function NumericInputColumnFilter({
72
109
  </div>
73
110
  )}
74
111
  {!isInvalid && (
75
- <div class="form-text small mt-2">
76
- Use operators: <code>&lt;</code>, <code>&gt;</code>, <code>&lt;=</code>,{' '}
112
+ <small class="form-text text-nowrap" id={`${columnId}-filter-description`}>
113
+ Operators: <code>&lt;</code>, <code>&gt;</code>, <code>&lt;=</code>,{' '}
77
114
  <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>
115
+ </small>
90
116
  )}
117
+ <div class="form-check mt-2">
118
+ <input
119
+ class="form-check-input"
120
+ type="checkbox"
121
+ checked={emptyOnly}
122
+ id={`${columnId}-empty-filter`}
123
+ onChange={(e) => {
124
+ if (e.target instanceof HTMLInputElement) {
125
+ column.setFilterValue(
126
+ e.target.checked
127
+ ? { filterValue: '', emptyOnly: true }
128
+ : { filterValue: '', emptyOnly: false },
129
+ );
130
+ }
131
+ }}
132
+ />
133
+ <label class="form-check-label" for={`${columnId}-empty-filter`}>
134
+ Empty values
135
+ </label>
136
+ </div>
91
137
  </div>
92
138
  </Dropdown.Menu>
93
139
  </Dropdown>
@@ -129,13 +175,27 @@ export function parseNumericFilter(filterValue: string): {
129
175
  * filterFn: numericColumnFilterFn,
130
176
  * }
131
177
  */
132
- export function numericColumnFilterFn(row: any, columnId: string, filterValue: string): boolean {
178
+ export function numericColumnFilterFn(
179
+ row: any,
180
+ columnId: string,
181
+ { filterValue, emptyOnly }: NumericColumnFilterValue,
182
+ ): boolean {
183
+ // Handle object-based filter value
184
+ const cellValue = row.getValue(columnId) as number | null;
185
+ const isEmpty = cellValue == null;
186
+
187
+ if (emptyOnly) {
188
+ return isEmpty;
189
+ }
190
+
191
+ // If there's no numeric filter, show all rows
133
192
  const parsed = parseNumericFilter(filterValue);
134
- if (!parsed) return true; // Invalid or empty filter = show all
193
+ if (!parsed) return true;
135
194
 
136
- const cellValue = row.getValue(columnId) as number | null;
137
- if (cellValue === null || cellValue === undefined) return false;
195
+ // If cell is empty and we're doing numeric filtering, don't show it
196
+ if (isEmpty) return false;
138
197
 
198
+ // Apply numeric filter
139
199
  switch (parsed.operator) {
140
200
  case '<':
141
201
  return cellValue < parsed.value;
@@ -0,0 +1,168 @@
1
+ import { useEffect, useRef } from 'preact/compat';
2
+ import {
3
+ // eslint-disable-next-line no-restricted-imports
4
+ OverlayTrigger as BootstrapOverlayTrigger,
5
+ type OverlayTriggerProps as BootstrapOverlayTriggerProps,
6
+ Popover,
7
+ type PopoverProps,
8
+ Tooltip,
9
+ type TooltipProps,
10
+ } from 'react-bootstrap';
11
+
12
+ import { type FocusTrap, focusFirstFocusableChild, trapFocus } from '@prairielearn/browser-utils';
13
+
14
+ export interface OverlayTriggerProps extends Omit<BootstrapOverlayTriggerProps, 'overlay'> {
15
+ popover?: {
16
+ /**
17
+ * Additional props to pass to the Popover component.
18
+ */
19
+ props?: Omit<PopoverProps, 'children'>;
20
+ /**
21
+ * The content to display in the popover body.
22
+ */
23
+ body: React.ReactNode;
24
+ /**
25
+ * Optional header content for the popover.
26
+ */
27
+ header?: React.ReactNode;
28
+ };
29
+ tooltip?: {
30
+ /**
31
+ * Additional props to pass to the Tooltip component. `id` is required for accessibility.
32
+ */
33
+ props: Omit<TooltipProps, 'children' | 'id'> & { id: string };
34
+ /**
35
+ * The content to display in the tooltip body.
36
+ */
37
+ body: React.ReactNode;
38
+ };
39
+ /**
40
+ * Whether to trap focus inside the overlay when it's shown.
41
+ * If true, focus will be trapped and moved to the first focusable element.
42
+ * @default true
43
+ */
44
+ trapFocus?: boolean;
45
+ /**
46
+ * Whether to return focus to the trigger element when the overlay is hidden.
47
+ * @default true
48
+ */
49
+ returnFocus?: boolean;
50
+ }
51
+
52
+ /**
53
+ * A wrapper around react-bootstrap's OverlayTrigger that adds accessibility features:
54
+ * - Automatic focus trapping when the overlay is shown
55
+ * - Auto-focus on the first focusable element in the overlay
56
+ * - Returns focus to the trigger element when the overlay is hidden
57
+ * - Automatically constructs a Popover with proper ref management
58
+ *
59
+ * This component provides a simpler API than react-bootstrap's OverlayTrigger by
60
+ * handling the Popover construction and ref management internally.
61
+ *
62
+ * @example
63
+ * ```tsx
64
+ * <OverlayTrigger
65
+ * tooltip={{
66
+ * body: 'Tooltip content',
67
+ * props: { id: 'tooltip-id' },
68
+ * }}
69
+ * placement="right"
70
+ * >
71
+ * <button>Hover me</button>
72
+ * </OverlayTrigger>
73
+ * ```
74
+ *
75
+ * @example
76
+ * ```tsx
77
+ * <OverlayTrigger
78
+ * popover={{
79
+ * header: 'Popover title',
80
+ * body: 'Popover content',
81
+ * }}
82
+ * placement="right"
83
+ * >
84
+ * <button>Click me</button>
85
+ * </OverlayTrigger>
86
+ * ```
87
+ */
88
+ export function OverlayTrigger({
89
+ children,
90
+ popover,
91
+ tooltip,
92
+ trapFocus: shouldTrapFocus = true,
93
+ returnFocus = true,
94
+ onEntered,
95
+ onExit,
96
+ ...props
97
+ }: OverlayTriggerProps) {
98
+ const overlayBodyRef = useRef<HTMLDivElement>(null);
99
+ const focusTrapRef = useRef<FocusTrap | null>(null);
100
+ const triggerElementRef = useRef<HTMLElement | null>(null);
101
+
102
+ const handleEntered = (node: HTMLElement, isAppearing: boolean) => {
103
+ // Store the currently focused element (the trigger) before we move focus
104
+ if (returnFocus && document.activeElement instanceof HTMLElement) {
105
+ triggerElementRef.current = document.activeElement;
106
+ }
107
+
108
+ if (shouldTrapFocus && overlayBodyRef.current && props.trigger === 'click') {
109
+ // Trap focus inside the overlay body
110
+ focusTrapRef.current = trapFocus(overlayBodyRef.current);
111
+
112
+ // Move focus to the first focusable element
113
+ focusFirstFocusableChild(overlayBodyRef.current);
114
+ }
115
+
116
+ // Call the original onEntered callback if provided
117
+ onEntered?.(node, isAppearing);
118
+ };
119
+
120
+ // Deactivate the focus trap when the component unmounts
121
+ useEffect(() => {
122
+ return () => {
123
+ focusTrapRef.current?.deactivate();
124
+ };
125
+ }, []);
126
+
127
+ const handleExit = (node: HTMLElement) => {
128
+ // Deactivate the focus trap
129
+ if (focusTrapRef.current) {
130
+ focusTrapRef.current.deactivate();
131
+ focusTrapRef.current = null;
132
+ }
133
+
134
+ // Return focus to the trigger element
135
+ if (returnFocus && triggerElementRef.current) {
136
+ triggerElementRef.current.focus();
137
+ triggerElementRef.current = null;
138
+ }
139
+
140
+ // Call the original onExit callback if provided
141
+ onExit?.(node);
142
+ };
143
+
144
+ if (Boolean(popover) === Boolean(tooltip)) {
145
+ throw new Error('Only one of popover or tooltip must be provided');
146
+ }
147
+
148
+ // Construct the popover with our managed ref
149
+ const popoverOverlay = popover ? (
150
+ <Popover {...popover.props}>
151
+ {popover.header && <Popover.Header>{popover.header}</Popover.Header>}
152
+ <Popover.Body ref={overlayBodyRef}>{popover.body}</Popover.Body>
153
+ </Popover>
154
+ ) : null;
155
+
156
+ const tooltipOverlay = tooltip ? <Tooltip {...tooltip.props}>{tooltip.body}</Tooltip> : null;
157
+
158
+ return (
159
+ <BootstrapOverlayTrigger
160
+ {...props}
161
+ overlay={popoverOverlay ?? tooltipOverlay!}
162
+ onEntered={handleEntered}
163
+ onExit={handleExit}
164
+ >
165
+ {children}
166
+ </BootstrapOverlayTrigger>
167
+ );
168
+ }